calendar and contacts are core again, with radicale as a plain sidecar

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 04:19:50 +00:00
co-authored by Claude Opus 5
parent f566fda0b2
commit 9c309c5ab6
7 changed files with 32 additions and 41 deletions
+4 -4
View File
@@ -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
}
@@ -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() {
+4 -4
View File
@@ -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.
-18
View File
@@ -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'] },
+8 -5
View File
@@ -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', () => {
+8 -8
View File
@@ -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<typeof createRouter>
// ['/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());
+1 -2
View File
@@ -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: ['/'],