|
| 1 | +# caldav — sync a pod calendar to phones & desktops |
| 2 | + |
| 3 | +CalDAV ([RFC 4791](https://www.rfc-editor.org/rfc/rfc4791)) over a Solid pod as |
| 4 | +a #206 loader plugin — the piece that completes the DAV family |
| 5 | +([`webdav/`](../webdav) → [`carddav/`](../carddav) → `caldav/`). CalDAV *is* |
| 6 | +WebDAV plus calendar semantics, so this is the `webdav/` bridge specialised for |
| 7 | +events — the exact sibling of `carddav/` with calendar semantics substituted for |
| 8 | +addressbook ones. Clients (iOS/macOS Calendar, Thunderbird) talk to |
| 9 | +`/caldav/<path>` and every request is replayed as a Solid/LDP request against |
| 10 | +the host over loopback HTTP, carrying the client's own credentials. The bridge |
| 11 | +holds **no authority** — the host's real auth + WAC decide every call, so it |
| 12 | +cannot disagree with the server's policy (the loopback pattern from |
| 13 | +`notifications/`, generalised to the data plane by `webdav/`). |
| 14 | + |
| 15 | +Each event is a `.ics` resource (iCalendar VEVENT, stored verbatim) under a |
| 16 | +calendar container, e.g. `<pod>/calendar/`. |
| 17 | + |
| 18 | +```js |
| 19 | +plugins: [{ module: 'caldav/plugin.js', prefix: '/caldav', |
| 20 | + config: { |
| 21 | + baseUrl: 'https://pod.example', // REQUIRED (finding 1) |
| 22 | + loopbackUrl: 'http://127.0.0.1:3000', // optional (defaults to baseUrl) |
| 23 | + calendar: 'calendar', // optional container name |
| 24 | + color: '#3b8ea5ff', // optional Apple calendar-color |
| 25 | + } }] |
| 26 | +``` |
| 27 | + |
| 28 | +## Adding the account on a client |
| 29 | + |
| 30 | +Get a pod Bearer token first (`POST /.pods` → `{ token }`). Because CalDAV |
| 31 | +account dialogs only prompt for username + password, the bridge maps |
| 32 | +**Basic → Bearer: any username, the token as the password.** |
| 33 | + |
| 34 | +- **iOS / iPadOS** — Settings → Calendar → Accounts → Add Account → Other → Add |
| 35 | + CalDAV Account. Server: `pod.example` (or the full |
| 36 | + `https://pod.example/caldav/alice/calendar/`), User: anything, Password: the |
| 37 | + pod token. iOS probes `/.well-known/caldav` then discovers the calendar. |
| 38 | +- **macOS Calendar** — Add Account → Other CalDAV Account, account type |
| 39 | + *Manual*: Server `pod.example`, same user/password. |
| 40 | +- **Thunderbird** — New → Calendar → On the Network. Location: |
| 41 | + `https://pod.example/caldav/alice/calendar/` (or the well-known URL); it will |
| 42 | + ask for a username (anything) and password (the token). |
| 43 | +- **DAVx5 (Android)** — Add account → *Login with URL and username*. Base URL: |
| 44 | + `https://pod.example/caldav/alice/calendar/`, username anything, password the |
| 45 | + token. DAVx5 also honours the `/.well-known/caldav` redirect. |
| 46 | + |
| 47 | +A raw `Authorization: Bearer <token>` header is forwarded verbatim for clients |
| 48 | +that can send one. |
| 49 | + |
| 50 | +## The sync vertical slice |
| 51 | + |
| 52 | +`OPTIONS` (advertises `calendar-access`) → `MKCALENDAR` the calendar → `PUT` a |
| 53 | +VEVENT `.ics` (ETag returned) → `PROPFIND Depth 1` lists it with that ETag + |
| 54 | +`text/calendar` → `GET` returns the VEVENT → `REPORT calendar-multiget` returns |
| 55 | +it inside `<CAL:calendar-data>` → `DELETE` → it's gone from the next `PROPFIND`. |
| 56 | +All driven with a real pod Bearer through real WAC in `test.js` (15 tests). |
| 57 | + |
| 58 | +## What maps |
| 59 | + |
| 60 | +| CalDAV / WebDAV | LDP over loopback | notes | |
| 61 | +|---|---|---| |
| 62 | +| `OPTIONS` | answered locally | `DAV: 1, 3, calendar-access`, `Allow`, `MS-Author-Via: DAV` | |
| 63 | +| `PROPFIND` Depth 0/1 | `GET` (`Accept: ld+json`); `ldp:contains` → 207 | collection gets `<CAL:calendar/>` + `supported-calendar-component-set` (VEVENT); events get `getetag` + `getcontenttype: text/calendar` | |
| 64 | +| `PROPFIND` discovery | derives pod from `api.auth.getAgent` | `current-user-principal`, `principal-URL`, `calendar-home-set` | |
| 65 | +| `REPORT` `calendar-multiget` | `GET` per `<D:href>` | returns `getetag` + `<CAL:calendar-data>` | |
| 66 | +| `REPORT` `calendar-query` | lists the collection | returns **all** events (filter not evaluated — below) | |
| 67 | +| `GET`/`HEAD` `.ics` | `GET`/`HEAD`, body passthrough | `text/calendar`, content-hash `ETag` header | |
| 68 | +| `PUT` `.ics` | `PUT` (auto-creates the container) | stores the VEVENT, returns content-hash `ETag` | |
| 69 | +| `DELETE` `.ics` | `DELETE` | 204 | |
| 70 | +| `MKCALENDAR` / `MKCOL` / extended MKCOL | `PUT` to the trailing-slash URL | 201; host 409 "exists" → 405; body accepted but its props dropped | |
| 71 | +| `/.well-known/caldav` | 301 → `<prefix>/` (guarded attempt) | PROPFIND there serves discovery | |
| 72 | + |
| 73 | +## What doesn't map |
| 74 | + |
| 75 | +- **`calendar-query` filters** — the `<CAL:filter>` grammar (`comp-filter`, |
| 76 | + `time-range`, `prop-filter`, `text-match`) is **not evaluated**; a query |
| 77 | + returns every VEVENT in the collection and the client filters locally. Correct |
| 78 | + but not selective — the filter/time-range engine is a sensible follow-up, not |
| 79 | + "minimum usable sync". |
| 80 | +- **Recurrence, free-busy, scheduling** — `RRULE` expansion, `calendar-data` |
| 81 | + with `<CAL:expand>`, `free-busy-query` REPORT, and the iTIP/iMIP scheduling |
| 82 | + inbox/outbox (RFC 6638) are all **out of scope**. Events are stored and served |
| 83 | + as opaque `.ics` bodies; a recurring VEVENT round-trips verbatim but the bridge |
| 84 | + does not expand or compute occurrences. |
| 85 | +- **`sync-collection` / CTag** — no incremental sync token. Clients fall back to |
| 86 | + a full `PROPFIND` + ETag diff each poll, which works but is chattier. A real |
| 87 | + CTag/sync-token needs a change feed the plugin api doesn't expose |
| 88 | + (`api.events`, the same seam `notifications/`, `sparql/` and `carddav/` want). |
| 89 | +- **VTODO / VJOURNAL** — only VEVENT is advertised in |
| 90 | + `supported-calendar-component-set`. Other components would be stored opaquely |
| 91 | + but the calendar declares itself events-only. |
| 92 | +- **Multiple calendars / principal collection** — one calendar per pod (the |
| 93 | + container named by `config.calendar`); `calendar-home-set` points at the pod |
| 94 | + root and the named child is flagged as the calendar. |
| 95 | +- **iCalendar validation / normalisation** — bodies are stored and returned |
| 96 | + byte-for-byte; the bridge does not parse or canonicalise iCalendar. |
| 97 | +- **LOCK/UNLOCK/COPY/MOVE/PROPPATCH** — not part of the event-sync slice |
| 98 | + (`webdav/` documents the same class-1-only boundary). |
| 99 | + |
| 100 | +## Findings |
| 101 | + |
| 102 | +1. **The DAV-bridge generalisation is now proven 3×.** `caldav/` is `carddav/` |
| 103 | + with three mechanical substitutions: the `<CARD:addressbook/>` resourcetype → |
| 104 | + `<CAL:calendar/>`, `text/vcard`/`.vcf` → `text/calendar`/`.ics`, and the |
| 105 | + `addressbook-*` REPORT/discovery verbs → `calendar-*`. The entire transport — |
| 106 | + loopback with forwarded auth, `config.baseUrl`/`loopbackUrl`, hand-rolled |
| 107 | + multistatus XML, Basic→Bearer, the container-vs-resource HEAD sniff, the |
| 108 | + content-hash ETag, the guarded `/.well-known` claim — is **identical**. What |
| 109 | + `carddav/`'s finding 1 predicted ("CalDAV would be the same again") is now |
| 110 | + observed: the loopback-bridge shape is a reusable substrate for the **whole** |
| 111 | + WebDAV family (`webdav` + `carddav` + `caldav`), and each new member costs |
| 112 | + only its resourcetype + content-type + REPORT/discovery vocabulary. That the |
| 113 | + generalisation held a third time — with no new seam, no new import — is the |
| 114 | + finding. |
| 115 | + |
| 116 | +2. **Same reserved-path finding, now confirmed across the whole DAV family.** |
| 117 | + RFC 6764 wants an absolute `/.well-known/caldav` that redirects to the |
| 118 | + calendar context. As with `nip05/` and `carddav/`: the loader does *not* |
| 119 | + confine `api.fastify` routes to the plugin prefix, and core's auth preHandler |
| 120 | + blanket-exempts `/.well-known/*`, so an exact-path `route()` registers, is |
| 121 | + reachable unauthenticated, and outranks core's LDP `GET /*` wildcard on |
| 122 | + specificity. Load-bearing accidents of core's routing, none promised by the |
| 123 | + plugin **contract**, so the registration is `try/catch`-guarded and treated as |
| 124 | + degraded-not-fatal (clients can always be pointed straight at |
| 125 | + `<prefix>/<pod>/<calendar>/`). Three plugins now depend on this same accident; |
| 126 | + a plugin-api way to *claim* a well-known name would turn it into a guarantee. |
| 127 | + |
| 128 | +3. **Same ETag finding — the bridge owns the content hash — confirmed a third |
| 129 | + time.** Clients cannot sync without a strong per-event ETag on every surface |
| 130 | + (PUT response, PROPFIND `getetag`, GET header, REPORT), and the plugin api |
| 131 | + still exposes **no hook** onto whatever ETag core does or doesn't emit. So the |
| 132 | + bridge computes `"<sha256(bytes)>"` and, because it hashes the exact stored |
| 133 | + bytes, PUT/GET/PROPFIND/REPORT all report an *identical* ETag with no shared |
| 134 | + state. Cost, identically to `carddav/`: a Depth-1 PROPFIND and a |
| 135 | + `calendar-query` do one loopback `GET` per event to hash it. A core content |
| 136 | + hash on the listing — or an `api.storage` digest hook — would remove those N |
| 137 | + reads. Correctness win of hashing over an mtime ETag: an identical re-PUT is |
| 138 | + correctly seen as unchanged (matters for calendar clients that re-upload on |
| 139 | + every edit). |
| 140 | + |
| 141 | +4. **Same `api.serverInfo` gap.** The plugin refuses to boot without |
| 142 | + `config.baseUrl` because the api exposes no server origin — the identical |
| 143 | + finding `webdav/`, `carddav/` and `notifications/` document. The |
| 144 | + probe-port-then-boot dance in `test.js` exists solely to feed the origin into |
| 145 | + config before `listen`. |
| 146 | + |
| 147 | +5. **`MKCALENDAR` dispatches with no new seam.** `MKCALENDAR` is a CalDAV-only |
| 148 | + verb (RFC 4791), yet `api.fastify.route({ method: ['MKCALENDAR', …] })` |
| 149 | + registers and dispatches unchanged — `find-my-way` already lists it among the |
| 150 | + Node HTTP methods, extending `carddav/`'s "Fastify already routes the DAV |
| 151 | + verbs" (REPORT/PROPFIND/MKCOL) one verb further. Like extended MKCOL, its |
| 152 | + request body (calendar props: resourcetype, displayname, |
| 153 | + `supported-calendar-component-set`, `calendar-color`) is **accepted but |
| 154 | + dropped** — JSS has nowhere to persist those dead properties, so the calendar |
| 155 | + resourcetype is derived at read time from the container name instead. A dead- |
| 156 | + property store (`.meta`, the gap `webdav/` and `carddav/` both note) would let |
| 157 | + the calendar flag and colour be set explicitly rather than inferred/config- |
| 158 | + driven. |
| 159 | + |
| 160 | +6. **Discovery reuses `api.auth.getAgent` verbatim.** `current-user-principal` / |
| 161 | + `calendar-home-set` must name a per-user URL; the bridge has no config-time |
| 162 | + knowledge of which pod a request belongs to, so it reads the caller's WebID |
| 163 | + from `api.auth.getAgent(request)` (the `#584` auth contract) and takes its |
| 164 | + first path segment as the pod. This is the one place CalDAV must *know the |
| 165 | + caller* rather than merely *forward the caller's bytes* — identical to |
| 166 | + `carddav/`, covered by the public auth seam with no reach into `src/`. (A |
| 167 | + `did:…` WebID has no pod path segment; there the discovery props fall back to |
| 168 | + echoing the requested path.) |
0 commit comments