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:
@@ -103,23 +103,23 @@ pkgs_core() {
|
|||||||
echo curl ca-certificates gnupg git jq unzip \
|
echo curl ca-certificates gnupg git jq unzip \
|
||||||
apt-transport-https lsb-release software-properties-common \
|
apt-transport-https lsb-release software-properties-common \
|
||||||
wget zip brotli build-essential python3 btop htop tree tmux ripgrep fd-find net-tools eza \
|
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)
|
pacman)
|
||||||
echo curl ca-certificates gnupg git jq unzip \
|
echo curl ca-certificates gnupg git jq unzip \
|
||||||
wget zip brotli base-devel python btop htop tree tmux ripgrep fd net-tools eza \
|
wget zip brotli base-devel python btop htop tree tmux ripgrep fd net-tools eza \
|
||||||
poppler fail2ban
|
poppler radicale fail2ban
|
||||||
;;
|
;;
|
||||||
dnf)
|
dnf)
|
||||||
echo curl ca-certificates gnupg2 git jq unzip \
|
echo curl ca-certificates gnupg2 git jq unzip \
|
||||||
wget zip brotli python3 btop htop tree tmux ripgrep fd-find net-tools eza \
|
wget zip brotli python3 btop htop tree tmux ripgrep fd-find net-tools eza \
|
||||||
poppler-utils fail2ban
|
poppler-utils radicale fail2ban
|
||||||
;;
|
;;
|
||||||
brew)
|
brew)
|
||||||
# curl, unzip and the TLS roots ship with macOS; the compilers come from
|
# 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_*.
|
# 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.
|
# 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
|
esac
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,13 @@ CORE_PROCESSES=(
|
|||||||
"officer-claude-code|bun|run src/servers/sidecar/claude/user-instance.ts"
|
"officer-claude-code|bun|run src/servers/sidecar/claude/user-instance.ts"
|
||||||
"officer-opencode|bun|run src/servers/sidecar/opencode/index.ts"
|
"officer-opencode|bun|run src/servers/sidecar/opencode/index.ts"
|
||||||
"officer-pty|node|src/servers/sidecar/pty/index.mjs"
|
"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() {
|
write_ecosystem() {
|
||||||
|
|||||||
+4
-4
@@ -426,10 +426,10 @@ const server = serve({
|
|||||||
// fixed, spec-defined paths — `/.well-known/caldav` unauthenticated, before they hold any
|
// 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
|
// 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.
|
// and the phone gets an HTML page where it expected a redirect.
|
||||||
// '/.well-known/caldav': honoServer.fetch,
|
'/.well-known/caldav': honoServer.fetch,
|
||||||
// '/.well-known/carddav': honoServer.fetch,
|
'/.well-known/carddav': honoServer.fetch,
|
||||||
// '/dav': honoServer.fetch,
|
'/dav': honoServer.fetch,
|
||||||
// '/dav/*': honoServer.fetch,
|
'/dav/*': honoServer.fetch,
|
||||||
/*
|
/*
|
||||||
Top-level paths a plugin claims (`/identity/*`, …), handed to hono BEFORE the SPA fallback.
|
Top-level paths a plugin claims (`/identity/*`, …), handed to hono BEFORE the SPA fallback.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
// 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
|
// `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.
|
// `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',
|
id: 'gitea',
|
||||||
ui: { name: 'Gitea', icon: 'GitBranch', color: '#34d399', rootRoute: '/gitea', routes: ['/gitea'] },
|
ui: { name: 'Gitea', icon: 'GitBranch', color: '#34d399', rootRoute: '/gitea', routes: ['/gitea'] },
|
||||||
|
|||||||
@@ -18,11 +18,14 @@ describe('what adding a member would do', () => {
|
|||||||
|
|
||||||
describe('which installed services have work to do', () => {
|
describe('which installed services have work to do', () => {
|
||||||
it('skips single-tenant daemons entirely', () => {
|
it('skips single-tenant daemons entirely', () => {
|
||||||
// caldav and memos provision accounts; vnc has no user concept. Both survivors are services with no
|
// memos provisions accounts; vnc has no user concept and must be skipped.
|
||||||
// extraction planned — this assertion has been re-pointed four times by services leaving for
|
//
|
||||||
// `plugins/`, so it now names ones that are staying.
|
// Down to ONE `accounts` service in the catalogue, which is why this names a single id rather than
|
||||||
const ids = servicesNeedingMemberProvision(['caldav', 'vnc', 'memos']).map((e) => e.id);
|
// two. It has been re-pointed five times as services left — jellyfin, invoiceshelf and slskd to
|
||||||
expect(ids.sort()).toEqual(['caldav', 'memos']);
|
// `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', () => {
|
it('ignores services that are not installed', () => {
|
||||||
|
|||||||
+8
-8
@@ -26,12 +26,12 @@ import { isPluginPublicPath, pluginForProtocolHeaders } from './plugins/public-p
|
|||||||
import { agentHandoffRouter } from './api/agent-handoff/router';
|
import { agentHandoffRouter } from './api/agent-handoff/router';
|
||||||
// import { walletRouter } from './api/wallet/router';
|
// import { walletRouter } from './api/wallet/router';
|
||||||
import { terminalRouter } from './api/terminal/sidecar-server';
|
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 { memosRouter } from './api/memos/router';
|
||||||
// import { giteaRouter } from './api/gitea/router';
|
// import { giteaRouter } from './api/gitea/router';
|
||||||
import { appStoreRouter } from './api/app-store/router';
|
import { appStoreRouter } from './api/app-store/router';
|
||||||
// import { davSyncRouter } from './api/dav/sync-router';
|
import { davSyncRouter } from './api/dav/sync-router';
|
||||||
// import { davRouter } from './api/dav/router';
|
import { davRouter } from './api/dav/router';
|
||||||
import { claimIosProfile } from './api/dav/ios-profile';
|
import { claimIosProfile } from './api/dav/ios-profile';
|
||||||
// import { notifyRouter } from './api/notify/router';
|
// import { notifyRouter } from './api/notify/router';
|
||||||
import { systemMonitorRouter } from './api/system-monitor/system-monitor';
|
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
|
// ['/gitea', giteaRouter], // plugin — switched off 2026-08-13
|
||||||
['/app-store', appStoreRouter],
|
['/app-store', appStoreRouter],
|
||||||
['/plugins', pluginsRouter],
|
['/plugins', pluginsRouter],
|
||||||
// ['/caldav', caldavRouter], // the JSON door for Officer's own calendar/contacts UI — plugin, switched off 2026-08-13
|
['/caldav', caldavRouter], // the JSON door for Officer's own calendar/contacts UI
|
||||||
// ['/dav', davRouter], // app-password management (the sync door is /dav, top-level) — plugin, switched off
|
['/dav', davRouter], // app-password management (the sync door is /dav, top-level)
|
||||||
// ['/notify', notifyRouter], // plugin — switched off 2026-08-13
|
// ['/notify', notifyRouter], // plugin — switched off 2026-08-13
|
||||||
// ['/photos', photosRouter], // plugin — switched off 2026-08-13
|
// ['/photos', photosRouter], // plugin — switched off 2026-08-13
|
||||||
// ['/wallet', walletRouter], // 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
|
// 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
|
// 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
|
// `.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
|
// 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.
|
// client while looking perfectly healthy in a browser.
|
||||||
// app.all('/.well-known/caldav', (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));
|
app.all('/.well-known/carddav', (ctx) => ctx.redirect('/dav/', 301));
|
||||||
|
|
||||||
const protectedRouter = createRouter();
|
const protectedRouter = createRouter();
|
||||||
protectedRouter.use(bodyParser());
|
protectedRouter.use(bodyParser());
|
||||||
|
|||||||
@@ -175,8 +175,7 @@ const CORE_REGISTRY: Permission[] = [
|
|||||||
label: 'Calendar and contacts',
|
label: 'Calendar and contacts',
|
||||||
description: 'Calendars, contacts, and the app passwords that sync them to a phone',
|
description: 'Calendars, contacts, and the app passwords that sync them to a phone',
|
||||||
kind: 'app',
|
kind: 'app',
|
||||||
// api: ['/caldav', '/dav'], // plugin — switched off 2026-08-13
|
api: ['/caldav', '/dav'],
|
||||||
api: [],
|
|
||||||
routes: ['/calendar', '/contacts'],
|
routes: ['/calendar', '/contacts'],
|
||||||
// App passwords are minted for and revoked by their own owner; `/dav` holds nothing shared.
|
// App passwords are minted for and revoked by their own owner; `/dav` holds nothing shared.
|
||||||
personal: ['/'],
|
personal: ['/'],
|
||||||
|
|||||||
Reference in New Issue
Block a user