Skip to content

Commit 2a359cc

Browse files
caldav plugin (#157-sibling): calendar sync, completing the DAV family
CalDAV (RFC 4791) over a pod calendar: OPTIONS (calendar-access), MKCALENDAR, PUT/GET/DELETE .ics VEVENTs with content-hash ETags, PROPFIND 0/1, REPORT calendar-multiget + calendar-query, current-user-principal/calendar-home-set, /.well-known/caldav 301, Basic->Bearer. iOS/macOS/Thunderbird/DAVx5. Finding: the DAV-bridge generalization is now proven 3x (webdav + carddav + caldav). CalDAV = CardDAV with three mechanical substitutions; the entire transport (loopback+forwarded-auth, multistatus XML, Basic->Bearer, content-hash ETags, guarded .well-known) is identical — carddav's 'reusable substrate for the whole DAV family' prediction now observed, no new seam. MKCALENDAR dispatches with no seam (find-my-way lists it). 15/15.
1 parent 5590bd7 commit 2a359cc

3 files changed

Lines changed: 939 additions & 0 deletions

File tree

caldav/README.md

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
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

Comments
 (0)