CardDAV (RFC 6352) over a Solid pod
as a #206 loader plugin, for JSS #157.
CardDAV is WebDAV plus addressbook semantics, so this is the webdav/
bridge specialised for contacts: clients (iOS/macOS Contacts, Thunderbird,
DAVx5) talk to /carddav/<path> and every request is replayed as a Solid/LDP
request against the host over loopback HTTP, carrying the client's own
credentials. The bridge holds no authority — the host's real auth + WAC
decide every call, so it cannot disagree with the server's policy (the loopback
pattern from notifications/, generalised to the data plane by webdav/).
Each contact is a .vcf resource (vCard 3.0/4.0, stored verbatim) under an
addressbook container, e.g. <pod>/contacts/.
plugins: [{ module: 'carddav/plugin.js', prefix: '/carddav',
config: {
baseUrl: 'https://pod.example', // REQUIRED (finding 1)
loopbackUrl: 'http://127.0.0.1:3000', // optional (defaults to baseUrl)
addressbook: 'contacts', // optional container name
} }]Get a pod Bearer token first (POST /.pods → { token }). Because CardDAV
account dialogs only prompt for username + password, the bridge maps
Basic → Bearer: any username, the token as the password.
- iOS / iPadOS — Settings → Contacts → Accounts → Add Account → Other →
Add CardDAV Account. Server:
pod.example(or the fullhttps://pod.example/carddav/alice/contacts/), User: anything, Password: the pod token. iOS probes/.well-known/carddavthen discovers the addressbook. - macOS Contacts — Add Account → Other contacts account → CardDAV, account
type Manual: Server
pod.example, same user/password. - Thunderbird — New → Address Book → CardDAV. Location:
https://pod.example/carddav/alice/contacts/(or the well-known URL); it will ask for a username (anything) and password (the token). - DAVx5 (Android) — Add account → Login with URL and username. Base URL:
https://pod.example/carddav/alice/contacts/, username anything, password the token. DAVx5 also honours the/.well-known/carddavredirect.
A raw Authorization: Bearer <token> header is forwarded verbatim for clients
that can send one.
OPTIONS (advertises addressbook) → MKCOL the book → PUT a vCard (ETag
returned) → PROPFIND Depth 1 lists it with that ETag + text/vcard → GET
returns the body → REPORT addressbook-multiget returns it → DELETE → it's
gone from the next PROPFIND. All driven with a real pod Bearer through real
WAC in test.js (15 tests).
| CardDAV / WebDAV | LDP over loopback | notes |
|---|---|---|
OPTIONS |
answered locally | DAV: 1, 3, addressbook, Allow, MS-Author-Via: DAV |
PROPFIND Depth 0/1 |
GET (Accept: ld+json); ldp:contains → 207 |
collection gets <CARD:addressbook/>; contacts get getetag + getcontenttype: text/vcard |
PROPFIND discovery |
derives pod from api.auth.getAgent |
current-user-principal, principal-URL, addressbook-home-set |
REPORT addressbook-multiget |
GET per <D:href> |
returns getetag + <CARD:address-data> |
REPORT addressbook-query |
lists the collection | returns all vCards (filter not evaluated — below) |
GET/HEAD .vcf |
GET/HEAD, body passthrough |
text/vcard, content-hash ETag header |
PUT .vcf |
PUT (auto-creates the container) |
stores the vCard, returns content-hash ETag |
DELETE .vcf |
DELETE |
204 |
MKCOL / extended MKCOL |
PUT to the trailing-slash URL |
201; host 409 "exists" → 405; extended-MKCOL body accepted but its props dropped |
/.well-known/carddav |
301 → <prefix>/ (guarded attempt) |
PROPFIND there serves discovery |
addressbook-queryfilters — the<CARD:filter>grammar (prop-filter,text-match,param-filter) is not evaluated; a query returns every vCard in the collection and the client filters locally. Correct but not selective — the filter engine is a sensible follow-up, not "minimum usable sync".sync-collection/ CTag — no incremental sync token. Clients fall back to a fullPROPFIND+ ETag diff each poll, which works but is chattier. A real CTag/sync-token needs a change feed the plugin api doesn't expose (api.events, the same seamnotifications/andsparql/want).- Multiple addressbooks / principal collection — one addressbook per pod
(the container named by
config.addressbook);addressbook-home-setpoints at the pod root and the named child is flagged as the addressbook. - vCard validation / normalisation — bodies are stored and returned byte-for-byte; the bridge does not parse or canonicalise vCard.
- LOCK/UNLOCK/COPY/MOVE/PROPPATCH — not part of the contact-sync slice
(
webdav/documents the same class-1-only boundary).
-
Reuse: CardDAV is
webdav/+ a resourcetype + a REPORT. The whole transport — loopback with forwarded auth,config.baseUrl/loopbackUrl, hand-rolled multistatus XML, Basic→Bearer, the container-vs-resource HEAD sniff — iswebdav/verbatim. CardDAV adds exactly three things: the<CARD:addressbook/>resourcetype on one container,getetag/text/vcardon.vcfchildren, and theREPORTverb carrying<CARD:address-data>. That the port is this thin is itself the finding: the loopback-bridge shape generalises across the whole WebDAV family (CalDAV #… would be the same again). -
ETag generation without a core hook — the bridge owns it. Clients cannot sync without a strong per-contact ETag on every surface (PUT response, PROPFIND
getetag, GET header, REPORT), and the plugin api exposes no hook onto whatever ETag core does or doesn't emit for a resource. So the bridge computes its own —"<sha256(bytes)>"— and because it hashes the exact stored bytes, PUT, GET, PROPFIND and REPORT all report an identical ETag for identical content, with no shared state to keep in sync. Cost: a Depth-1 PROPFIND and anaddressbook-querydo one loopbackGETper contact to hash it (JSS's container JSON-LD carriesstat:size/dcterms:modifiedbut not a content hash). A coreETag/content-hash exposed on the listing — or anapi.storagedigest hook — would remove those N extra loopback reads. Note a subtle correctness win of hashing over reusing an mtime-based ETag: identical re-PUTs are correctly seen as unchanged. -
The
/.well-known/carddavreserved-path attempt works today but on borrowed ground — same accidentnip05/documented. RFC 6764 discovery wants an absolute/.well-known/carddavthat redirects to the addressbook context. A plugin is mounted under one prefix, yet the loader does not confineapi.fastifyroutes to that prefix, and core's auth preHandler blanket-exempts/.well-known/*— so an exact-pathroute()registers, is reachable unauthenticated, and outranks core's LDPGET /*wildcard on specificity. All load-bearing accidents of core's current routing, none promised by the plugin contract, so the registration is wrapped intry/catchand treated as degraded-not-fatal: clients can always be pointed straight at<prefix>/<pod>/<addressbook>/. A plugin-api way to claim a well-known name (or declare extra unauthenticated paths) would turn this accident into a guarantee. -
Discovery needs the pod identity, and
api.auth.getAgentsupplies it.current-user-principal/addressbook-home-setmust name a per-user URL, but the bridge has no config-time knowledge of which pod a request belongs to.api.auth.getAgent(request)(the documented#584auth contract) returns the caller's WebID; its first path segment is the pod, which the bridge turns into<prefix>/<pod>/. This is the one place CardDAV needs to know the caller rather than merely forward the caller's bytes — and the public auth seam covered it without reaching intosrc/. (Adid:…WebID has no pod path segment; there the discovery props fall back to echoing the requested path.) -
vCard ↔ LDP is low-friction — a
.vcfis just an opaque resource. Unlike a Turtle/JSON-LD contact, a vCard body has no LDP-native representation to reconcile: the bridge stores and serves the exact bytes withContent-Type: text/vcard. The only awkwardness is collection identity — an addressbook is "a container whose name iscontacts" (config-driven), because JSS has nowhere to persist the<CARD:addressbook/>resourcetype an extended-MKCOL would set. So the resourcetype is derived at read time from the container name, and the extended-MKCOL request body is accepted but its props are dropped. A dead-property store (.meta, the same gapwebdav/notes) would let the addressbook flag be set explicitly instead of inferred. -
Fastify 4 already dispatches
REPORT.REPORTis a CardDAV/CalDAV verb, not plain WebDAV, yetapi.fastify.route({ method: ['REPORT', …] })registers and dispatches with no new seam (verified by injection and by the live tests) — the same "Fastify already routes the DAV verbs" observationwebdav/finding 5 makes, extended one verb further. Request bodies forREPORT/PROPFINDarrive via the host's inherited catch-all content-type parser, so the plugin readsrequest.body(Buffer) directly; a plugin-api guarantee about body parsing would make that less incidental.