From 9c309c5ab624be59292cd4548568858480b59cd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Wed, 19 Aug 2026 04:19:50 +0000 Subject: [PATCH] calendar and contacts are core again, with radicale as a plain sidecar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverses the 2026-08-13 decision to make caldav an app-store entry. Three reasons it does not belong there. It is not an integration. Photos-via-Immich or torrents-via-Transmission assume you already run the thing; a calendar assumes nothing, and a personal server without one is missing a baseline rather than an optional app. It needs TWO top-level routes, /calendar and /contacts, and a plugin gets one — derived from its appName, which is what makes it structurally impossible for a plugin to shadow a core route. Nobody thinks "I'll open my dav", so collapsing them under /dav was the wrong trade, and weakening the namespace guarantee to allow two was a worse one. As core routes the question does not arise. And it never actually needed Docker. Radicale is a ~2MB Python package; sidecar/caldav/radicale.ts already spawns it directly on loopback with http_x_remote_user. The container came from the app store making everything a container, not from anything Radicale requires. So: the routers are mounted again (/caldav for the UI's JSON door, /dav for app passwords, the top-level /dav sync door and the two .well-known redirects), officer-caldav joins CORE_PROCESSES, radicale joins the machine-setup package list for all four package managers, and the catalogue entry is gone. members.test.ts re-anchored a fifth time — caldav was one of two `accounts` services and memos is now the last. Noted in the test that the sixth time should be a literal fixture instead. Verified: assertPermissionTotality accepts /caldav and /dav, tsgo clean, 1085 pass. NOT yet verified against a real client — radicale is not installed on this machine. Co-Authored-By: Claude Opus 5 --- scripts/setup/machine-setup/lib/packages.sh | 8 ++++---- scripts/setup/officer-setup/lib/services.sh | 7 +++++++ src/server.tsx | 8 ++++---- src/servers/app-store/catalogue.ts | 18 ------------------ src/servers/app-store/members.test.ts | 13 ++++++++----- src/servers/hono.ts | 16 ++++++++-------- src/servers/permissions/registry.ts | 3 +-- 7 files changed, 32 insertions(+), 41 deletions(-) diff --git a/scripts/setup/machine-setup/lib/packages.sh b/scripts/setup/machine-setup/lib/packages.sh index 0e994a18..277a3dee 100644 --- a/scripts/setup/machine-setup/lib/packages.sh +++ b/scripts/setup/machine-setup/lib/packages.sh @@ -103,23 +103,23 @@ pkgs_core() { echo curl ca-certificates gnupg git jq unzip \ apt-transport-https lsb-release software-properties-common \ wget zip brotli build-essential python3 btop htop tree tmux ripgrep fd-find net-tools eza \ - poppler-utils fail2ban unattended-upgrades + poppler-utils radicale fail2ban unattended-upgrades ;; pacman) echo curl ca-certificates gnupg git jq unzip \ wget zip brotli base-devel python btop htop tree tmux ripgrep fd net-tools eza \ - poppler fail2ban + poppler radicale fail2ban ;; dnf) echo curl ca-certificates gnupg2 git jq unzip \ wget zip brotli python3 btop htop tree tmux ripgrep fd-find net-tools eza \ - poppler-utils fail2ban + poppler-utils radicale fail2ban ;; brew) # curl, unzip and the TLS roots ship with macOS; the compilers come from # the Xcode command line tools, which is not a formula — see xcode_clt_*. # brotli is here because macOS ships the library but not the CLI. - echo gnupg git jq wget brotli btop htop tree ripgrep fd eza poppler + echo gnupg git jq wget brotli btop htop tree ripgrep fd eza poppler radicale ;; esac } diff --git a/scripts/setup/officer-setup/lib/services.sh b/scripts/setup/officer-setup/lib/services.sh index c5fb567f..a379b8c6 100644 --- a/scripts/setup/officer-setup/lib/services.sh +++ b/scripts/setup/officer-setup/lib/services.sh @@ -44,6 +44,13 @@ CORE_PROCESSES=( "officer-claude-code|bun|run src/servers/sidecar/claude/user-instance.ts" "officer-opencode|bun|run src/servers/sidecar/opencode/index.ts" "officer-pty|node|src/servers/sidecar/pty/index.mjs" + # Calendar and contacts, CORE since 2026-08-19 rather than an app-store entry. + # + # It was a Docker service because the app store made everything one, not because Radicale needs a + # container — it is a ~2MB Python package, and this sidecar spawns it directly on loopback. A personal + # server having a calendar is not an integration with something you already run; it is the baseline. + # Radicale needs `python3` and the `radicale` package, both installed by machine-setup. + "officer-caldav|bun|run src/servers/sidecar/caldav/index.ts" ) write_ecosystem() { diff --git a/src/server.tsx b/src/server.tsx index cd4b5768..0983ed52 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -426,10 +426,10 @@ const server = serve({ // fixed, spec-defined paths — `/.well-known/caldav` unauthenticated, before they hold any // credential at all. They need naming explicitly here or the `/*` SPA fallback below swallows them // and the phone gets an HTML page where it expected a redirect. - // '/.well-known/caldav': honoServer.fetch, - // '/.well-known/carddav': honoServer.fetch, - // '/dav': honoServer.fetch, - // '/dav/*': honoServer.fetch, + '/.well-known/caldav': honoServer.fetch, + '/.well-known/carddav': honoServer.fetch, + '/dav': honoServer.fetch, + '/dav/*': honoServer.fetch, /* Top-level paths a plugin claims (`/identity/*`, …), handed to hono BEFORE the SPA fallback. diff --git a/src/servers/app-store/catalogue.ts b/src/servers/app-store/catalogue.ts index d471bc6f..9d2609b2 100644 --- a/src/servers/app-store/catalogue.ts +++ b/src/servers/app-store/catalogue.ts @@ -196,24 +196,6 @@ export const CATALOGUE: CatalogueEntry[] = [ // Deleting the entry is REQUIRED, not tidying — see the note where photos used to sit, and the trap in // `EXTRACTING-A-PLUGIN.md`. A leftover entry makes the permission permanently `unavailable`, because // `permissionAvailability()` derives from `sidecar_installs` and a plugin never gets a row there. - { - id: 'caldav', - ui: { - name: 'Calendar', - icon: 'CalendarDays', - color: '#3b82f6', - rootRoute: '/calendar', - routes: ['/calendar', '/contacts'], - }, - process: 'officer-caldav', - label: 'Calendar', - summary: 'Radicale — calendars and contacts over CalDAV/CardDAV', - members: 'accounts', - modes: ['existing', 'provisioned'], - permission: 'calendar', - composeTemplate: 'radicale', - existingFields: [{ key: 'url', label: 'CalDAV URL', type: 'url', required: true }], - }, { id: 'gitea', ui: { name: 'Gitea', icon: 'GitBranch', color: '#34d399', rootRoute: '/gitea', routes: ['/gitea'] }, diff --git a/src/servers/app-store/members.test.ts b/src/servers/app-store/members.test.ts index 505ef388..fbfb200f 100644 --- a/src/servers/app-store/members.test.ts +++ b/src/servers/app-store/members.test.ts @@ -18,11 +18,14 @@ describe('what adding a member would do', () => { describe('which installed services have work to do', () => { it('skips single-tenant daemons entirely', () => { - // caldav and memos provision accounts; vnc has no user concept. Both survivors are services with no - // extraction planned — this assertion has been re-pointed four times by services leaving for - // `plugins/`, so it now names ones that are staying. - const ids = servicesNeedingMemberProvision(['caldav', 'vnc', 'memos']).map((e) => e.id); - expect(ids.sort()).toEqual(['caldav', 'memos']); + // memos provisions accounts; vnc has no user concept and must be skipped. + // + // Down to ONE `accounts` service in the catalogue, which is why this names a single id rather than + // two. It has been re-pointed five times as services left — jellyfin, invoiceshelf and slskd to + // `plugins/`, caldav to CORE — and memos is the last one standing. If memos ever leaves too, this + // wants a literal fixture like `installer.test.ts` has, not a sixth re-point. + const ids = servicesNeedingMemberProvision(['memos', 'vnc']).map((e) => e.id); + expect(ids.sort()).toEqual(['memos']); }); it('ignores services that are not installed', () => { diff --git a/src/servers/hono.ts b/src/servers/hono.ts index 1e76c642..fcbddf18 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -26,12 +26,12 @@ import { isPluginPublicPath, pluginForProtocolHeaders } from './plugins/public-p import { agentHandoffRouter } from './api/agent-handoff/router'; // import { walletRouter } from './api/wallet/router'; import { terminalRouter } from './api/terminal/sidecar-server'; -// import { caldavRouter } from './api/dav/sidecar-server'; +import { caldavRouter } from './api/dav/sidecar-server'; // import { memosRouter } from './api/memos/router'; // import { giteaRouter } from './api/gitea/router'; import { appStoreRouter } from './api/app-store/router'; -// import { davSyncRouter } from './api/dav/sync-router'; -// import { davRouter } from './api/dav/router'; +import { davSyncRouter } from './api/dav/sync-router'; +import { davRouter } from './api/dav/router'; import { claimIosProfile } from './api/dav/ios-profile'; // import { notifyRouter } from './api/notify/router'; import { systemMonitorRouter } from './api/system-monitor/system-monitor'; @@ -143,8 +143,8 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType // ['/gitea', giteaRouter], // plugin — switched off 2026-08-13 ['/app-store', appStoreRouter], ['/plugins', pluginsRouter], - // ['/caldav', caldavRouter], // the JSON door for Officer's own calendar/contacts UI — plugin, switched off 2026-08-13 - // ['/dav', davRouter], // app-password management (the sync door is /dav, top-level) — plugin, switched off + ['/caldav', caldavRouter], // the JSON door for Officer's own calendar/contacts UI + ['/dav', davRouter], // app-password management (the sync door is /dav, top-level) // ['/notify', notifyRouter], // plugin — switched off 2026-08-13 // ['/photos', photosRouter], // plugin — switched off 2026-08-13 // ['/wallet', walletRouter], // plugin — switched off 2026-08-13 @@ -274,7 +274,7 @@ export function buildHonoApp(plugins: MountedPlugin[] = []): Hono<{ Variables: H }); }); - // app.route('/dav', davSyncRouter); // plugin — switched off 2026-08-13 + app.route('/dav', davSyncRouter); // Autodiscovery. This is most of what makes adding an account on a phone feel transparent instead of // fiddly: the client is given a bare domain and probes these paths UNAUTHENTICATED before it has any @@ -284,8 +284,8 @@ export function buildHonoApp(plugins: MountedPlugin[] = []): Hono<{ Variables: H // `.all`, not `.get`: RFC 6764 §6 has the client probe the well-known URI with the method it actually // wants to use, and iOS sends PROPFIND, not GET. Registered as GET-only these answered 404 to every real // client while looking perfectly healthy in a browser. - // app.all('/.well-known/caldav', (ctx) => ctx.redirect('/dav/', 301)); - // app.all('/.well-known/carddav', (ctx) => ctx.redirect('/dav/', 301)); + app.all('/.well-known/caldav', (ctx) => ctx.redirect('/dav/', 301)); + app.all('/.well-known/carddav', (ctx) => ctx.redirect('/dav/', 301)); const protectedRouter = createRouter(); protectedRouter.use(bodyParser()); diff --git a/src/servers/permissions/registry.ts b/src/servers/permissions/registry.ts index ae3058d4..5f62d114 100644 --- a/src/servers/permissions/registry.ts +++ b/src/servers/permissions/registry.ts @@ -175,8 +175,7 @@ const CORE_REGISTRY: Permission[] = [ label: 'Calendar and contacts', description: 'Calendars, contacts, and the app passwords that sync them to a phone', kind: 'app', - // api: ['/caldav', '/dav'], // plugin — switched off 2026-08-13 - api: [], + api: ['/caldav', '/dav'], routes: ['/calendar', '/contacts'], // App passwords are minted for and revoked by their own owner; `/dav` holds nothing shared. personal: ['/'],