# dab on Cloudflare - Implementation Spec Status: ready Target runtime: Cloudflare Workers Language: TypeScript 6.x Framework: Hono Supported protocols: WebDAV, CalDAV, CardDAV OIDC provider name: tessera UI: none in this implementation UI support: JSON API routes and OpenAPI description must be implemented for a future UI Text constraints: this document is ASCII-safe markdown; keep it ASCII-only ## 0. Handoff invariants Before implementation starts, resolve and keep these invariants fixed: * Project/service name is `dab`, always lowercase. * `tessera` is the OIDC provider name, always lowercase. Do not use tessera as the service name. * `MAX_FILE_BYTES` is configurable and defaults to `100000000` bytes. Do not set it above the deployed Worker request body limit. * Control-plane health is `GET /healthz`, unauthenticated, outside `/api/v1/`, and served only on the control-plane host. * D1 Sessions API, D1 bookmarks, D1 bookmark headers, and D1 bookmark cookies are out of scope for v1. * `subjects.id` is the tessera OIDC `sub`; `subjects.storageId` is the internal storage and Durable Object routing id. * R2 blob keys and Durable Object names use `storageId`, not raw tessera `sub`. * OIDC transaction cookies are signed with a key derived from `TESSERA_OIDC_CLIENT_SECRET`. * dab session cookies are signed with a key derived from `DAB_SESSION_SECRET`. * PAT ids and tokens use the `dab_pat_` prefix. * `DELETE /api/v1/pats/{pat_id}` revokes the PAT; it does not physically delete the PAT row. * Storage capacity accounting and related live properties are out of scope for v1. * TypeScript scripts under `scripts/` run through `tsx`. ## 1. Purpose Build dab, a Cloudflare-native DAV service that gives each tessera subject a private DAV host with an opaque DNS label: ```text https://.dav.example.com/ ``` The root host is the control-plane host: ```text https://dav.example.com/ ``` The control-plane host owns tessera OIDC login, API session cookies, PAT management, JSON API routes, and OpenAPI routes. Subject hosts own DAV protocol traffic. The service must support: * WebDAV file storage under `/files/` * CalDAV calendar storage under `/calendars/` * CardDAV address book storage under `/addressbooks/` * DAV principal discovery under `/principals/` * tessera OIDC login for browser/API sessions * Personal Access Token management for DAV clients * Basic auth using PATs for WebDAV, CalDAV, and CardDAV clients * JSON API routes for future UI integration * OpenAPI description for all JSON API routes * R2-backed file bytes * Durable Object-backed protocol metadata and state * Drizzle ORM for D1 and all Durable Object SQLite databases * Queues for async cleanup and repair The first WebDAV milestone must pass the notroj litmus WebDAV test suite against `/files/`. ## 2. Non-goals Do not implement these in the first complete version unless explicitly requested later: * HTML UI * tenant/org/workspace model * public unauthenticated shares * cross-subject COPY or MOVE * CalDAV scheduling delivery, inbox, outbox, iTIP, or iMIP * user-editable WebDAV ACL sharing UI * full-text search over files, contacts, or calendar objects * R2 prefix listing as filesystem truth * sharding one subject's file namespace across multiple FileDAV Durable Objects * WebDAV SEARCH method * WebDAV DeltaV/versioning * server-side sync clients or background imports from third-party providers ## 3. Implementation stack ### 3.1 Runtime and language Use: ```text TypeScript 6.x Cloudflare Workers Hono vite @cloudflare/vite-plugin prettier ``` The Worker must be an ES module Worker. Use Hono for request routing, middleware composition, cookie helpers, request helpers, and response helpers. DAV protocol handlers may be plain functions called from Hono routes; do not force every DAV method into tiny Hono handlers if that makes the code harder to reason about. ### 3.2 Runtime dependencies Use these libraries unless a blocking compatibility issue is found: ```text hono openid-client fast-xml-parser drizzle-orm hono-openapi @hono/zod-validator zod zod-openapi yaml ``` Do not add iCalendar, recurrence, or vCard parser dependencies until the Phase 0 compatibility spike verifies that the candidates are pure ESM, Worker-compatible, bounded under hostile input, and maintained. The selected packages must be named in the implementation PR before CalDAV or CardDAV storage is implemented. ### 3.3 Dev and test dependencies Use: ```text typescript vite @cloudflare/vite-plugin wrangler drizzle-kit prettier vitest @cloudflare/vitest-pool-workers @mongodb-js/oidc-mock-provider tsx ``` TypeScript scripts under `scripts/` MUST be run through `tsx` in package scripts rather than plain `node`. Optional but useful: ```text openapi-typescript swagger-cli or another OpenAPI validator ``` ## 4. Terminology * dab: this DAV service and project. Always lowercase. Do not call the service tessera. * tessera: the OIDC provider. Always lowercase. It is the only OIDC issuer supported by this deployment. * Subject: one tessera account. The subject id is the tessera OIDC `sub` value. * Control-plane host: `dav.example.com`. * Opaque host label: a generated five-word public DNS label that maps to exactly one subject. * Subject host: `.dav.example.com`. * Namespace: an internal consistency and coordination boundary, not a WebDAV protocol object. * Collection: a WebDAV directory-like resource. * Resource: a file, calendar object, address book object, principal resource, or collection. * Dead property: arbitrary client-set WebDAV property stored by the server. * Live property: server-computed WebDAV property. * PAT: Personal Access Token for DAV clients. There is no tenant layer. Subject is the top-level ownership boundary. The service intentionally does not model multiple OIDC issuers. Changing `TESSERA_OIDC_ISSUER` after production launch is a data migration event, not a runtime identity-linking feature. ## 5. Host and URL layout ### 5.1 Host format The canonical subject host is: ```text .dav.example.com ``` Opaque host label requirements: * MUST be generated by this service. * MUST be a random word label, not a random-looking base32/base36/UUID label. * MUST be DNS-label safe: lowercase ASCII letters and hyphen only. * MUST use exactly five words joined by hyphens. * MUST draw words independently from a curated wordlist with at least 8192 entries. * MUST provide at least 65 bits of entropy before uniqueness rejection. * MUST be shorter than 64 characters. * MUST reject and redraw labels longer than 63 characters. * MUST NOT encode the tessera subject id. * MUST NOT be reversible to the tessera subject id. * MUST NOT include offensive, medical, political, religious, sexually explicit, trademarked, brand-like, confusing, or visually ambiguous words. * MUST be stable unless the subject explicitly rotates the DAV host later. * MUST be unique in `DAV_CONTROL_PLANE.subjects.hostLabel`. * MUST be generated from `crypto.getRandomValues()` using rejection sampling or another unbiased index-selection method. * MUST be inserted under a uniqueness constraint; if the insert conflicts, redraw a new label. Wordlist requirements: * Use lowercase ASCII words only, ideally 3 to 8 letters each. * Avoid plurals, homophones, profanity-adjacent words, and words that are hard to spell over audio. * Keep the checked-in wordlist deterministic and reviewed. * Do not generate words from tessera profile fields, email addresses, subject ids, timestamps, or hashes. Validation requirements: * The syntactic shape is `^[a-z]+(-[a-z]+){4}$`. * Every word must exist in the checked-in wordlist. * Reject labels longer than 63 characters before D1 lookup. * Unknown or malformed word labels return `404 Not Found`. Host normalization requirements: * Lowercase the host before classification. * Strip exactly one trailing dot before classification. * Strip the port from `Host` for matching, but preserve the original request URL for redirects where needed. * Reject non-ASCII subject labels and reject IDNA/punycode subject labels in v1. * Accept local development subject hosts under `.dab.localhost` with an optional port. * Do not relax production host validation to make local testing easier. Security note: The host label is an unguessable routing label, not an authorization secret. Five words from an 8192-word list gives roughly 65 bits of entropy, which is enough to make online enumeration impractical when unknown labels return `404 Not Found` and all subject-host access still requires a subject-bound PAT. Do not weaken the host owner subject check because the host label is high entropy. Example: ```text river-copper-lantern-velvet-maple.dav.example.com ``` The Worker MUST reject unknown or malformed host labels with `404 Not Found`. Authorization invariant: ```text host owner subject id == authenticated subject id ``` If the invariant fails, return `403 Forbidden`. ### 5.2 URL tree The control-plane host exposes this tree: ```text /api/v1/... ``` The control-plane host MUST NOT serve subject DAV storage paths. Requests to `/files/`, `/calendars/`, `/addressbooks/`, or `/principals/` on `dav.example.com` should return `404 Not Found`. Each subject host exposes this tree: ```text / /.well-known/caldav /.well-known/carddav /files/ /calendars/ /calendars/default/ /addressbooks/ /addressbooks/default/ /principals/ /principals/me/ /principals// ``` Rules: * `/files/` is the WebDAV file root. * `/calendars/` is the CalDAV calendar home set. * `/calendars/default/` is created automatically for every subject. * `/addressbooks/` is the CardDAV address book home set. * `/addressbooks/default/` is created automatically for every subject. * `/principals/me/` is a stable alias for the authenticated subject principal. * `/principals//` is the canonical public principal URL. * Subject ids MUST NOT appear in public URLs. * JSON API routes MUST live on `dav.example.com`, not subject hosts. * `/.well-known/caldav` MUST redirect to `/` or `/calendars/`. Prefer `/` if principal discovery is implemented at root. * `/.well-known/carddav` MUST redirect to `/` or `/addressbooks/`. Prefer `/` if principal discovery is implemented at root. * Use `301`, `302`, or `307` for well-known redirects. Avoid relying on `308` until tested with target clients. ## 6. Cloudflare architecture ### 6.1 High-level architecture ```text Client | v Router Worker, Hono - parse host and path - authenticate tessera session or PAT - normalize paths and headers - dispatch by URL prefix and HTTP method - stream file bodies to/from R2 directly | +--> AUTH(auth:) | +--> FILE_DAV(files:) | +--> CAL_DAV(cal:) | +--> CARD_DAV(card:) | +--> FILE_BLOBS R2 bucket | +--> DAV_CONTROL_PLANE D1 database | +--> BLOB_GC Queue | +--> REPAIR_JOBS Queue ``` ### 6.2 One-hop rule The Router Worker MUST orchestrate D1, R2, Queues, and Durable Objects directly. Allowed: ```text Worker -> D1 Worker -> R2 Worker -> Durable Object Worker -> Queue ``` Avoid for large bodies: ```text Worker -> Durable Object -> R2 ``` Durable Objects own metadata and protocol correctness. R2 owns file bytes. The Worker streams bytes. Cross-runtime mutation rules: * Do not design distributed transactions across Worker, D1, R2, and Durable Objects. * Do not perform `Worker reads D1, decides conflict resolution, then calls Durable Object to mutate` as a logical transaction. * If a mutation needs serialized conflict resolution, the owning Durable Object resolves it inside one Durable Object transaction and returns a tagged union result. * Durable Object RPC methods MUST return tagged unions for expected errors. Do not throw HTTP errors from Durable Objects; reserve `throw` for unrecoverable failures. * Durable Objects MUST NOT query or mutate `DAV_CONTROL_PLANE` D1 from normal RPC methods. If reconciliation with D1 is required, use an idempotent alarm or queue-driven reconciliation path. * Use `blockConcurrencyWhile` only in Durable Object constructors for startup and migration work. * Every request-scoped R2 operation and outbound Durable Object RPC SHOULD go through a small request-local limiter so large Depth, COPY, MOVE, REPORT, or multiget requests cannot exceed Cloudflare connection and subrequest limits. Lazy Durable Object initialization: * D1 subject bootstrap only creates global subject state. * Each AUTH, FILE_DAV, CAL_DAV, and CARD_DAV object has an idempotent `ensureInitialized(subject)` path. * The first RPC to each object creates its own root/default rows inside that object's own transaction. * Repair jobs may call `ensureInitialized` again. * Do not try to make D1 plus multiple Durable Objects commit atomically. ### 6.3 Binding names Required bindings: ```text DAV_CONTROL_PLANE D1 database for global/control-plane state FILE_BLOBS R2 bucket for immutable file blobs AUTH Durable Object namespace for auth state FILE_DAV Durable Object namespace for WebDAV file metadata CAL_DAV Durable Object namespace for CalDAV state CARD_DAV Durable Object namespace for CardDAV state BLOB_GC Queue for R2 blob cleanup REPAIR_JOBS Queue for consistency repair and async projections RL_AUTH Rate Limit binding for OIDC and session routes RL_DAV_AUTH Rate Limit binding for failed PAT verification RL_REPORT Rate Limit binding for expensive DAV REPORT/PROPFIND requests ``` Do not use a generic D1 binding name like `DB`. Do not add `_DO` suffixes to Durable Object binding names. Optional later: ```text AUDIT_EVENTS Queue for audit projection SUBJECT_JOBS Workflow binding for long-running subject deletion/export ``` ### 6.4 Durable Object class names Binding names and class names are different concerns. Suggested class names: ```text AuthObject FileDavObject CalDavObject CardDavObject ``` Wrangler binding names should remain: ```text AUTH FILE_DAV CAL_DAV CARD_DAV ``` ### 6.5 Wrangler configuration `wrangler.jsonc` is the source of truth for runtime bindings. Required shape: ```jsonc { "$schema": "node_modules/wrangler/config-schema.json", "name": "dab", "main": "src/worker/index.ts", "compatibility_date": "REPLACE_WITH_CURRENT_DATE", "compatibility_flags": ["nodejs_als"], "workers_dev": false, "preview_urls": false, "observability": { "enabled": true }, "routes": [ { "pattern": "dav.example.com", "custom_domain": true }, { "pattern": "*.dav.example.com/*", "zone_name": "dav.example.com" } ], "vars": { "LOG_LEVEL": "info", "CONTROL_PLANE_HOST": "dav.example.com", "SUBJECT_HOST_SUFFIX": ".dav.example.com", "TESSERA_OIDC_ISSUER": "https://auth.limic.dev", "MAX_FILE_BYTES": "100000000", "MAX_XML_BODY_BYTES": "1048576", "MAX_CALENDAR_OBJECT_BYTES": "1048576", "MAX_ADDRESS_OBJECT_BYTES": "1048576", "MAX_DAV_DEPTH_INFINITY_NODES": "10000", "MAX_REPORT_RESULTS": "5000", "MAX_RECURRENCE_YEARS": "5", "MAX_RECURRENCE_INSTANCES": "10000" }, "secrets": { "required": [ "TESSERA_OIDC_CLIENT_ID", "TESSERA_OIDC_CLIENT_SECRET", "DAB_SESSION_SECRET" ] }, "d1_databases": [ { "binding": "DAV_CONTROL_PLANE", "database_name": "dab-control-plane", "database_id": "REPLACE_WITH_DATABASE_ID", "migrations_dir": "drizzle/d1" } ], "r2_buckets": [ { "binding": "FILE_BLOBS", "bucket_name": "dab-file-blobs" } ], "durable_objects": { "bindings": [ { "name": "AUTH", "class_name": "AuthObject" }, { "name": "FILE_DAV", "class_name": "FileDavObject" }, { "name": "CAL_DAV", "class_name": "CalDavObject" }, { "name": "CARD_DAV", "class_name": "CardDavObject" } ] }, "migrations": [ { "tag": "v1", "new_sqlite_classes": [ "AuthObject", "FileDavObject", "CalDavObject", "CardDavObject" ] } ], "queues": { "producers": [ { "binding": "BLOB_GC", "queue": "dab-blob-gc" }, { "binding": "REPAIR_JOBS", "queue": "dab-repair-jobs" } ], "consumers": [ { "queue": "dab-blob-gc", "max_batch_size": 10, "max_batch_timeout": 5 }, { "queue": "dab-repair-jobs", "max_batch_size": 5, "max_batch_timeout": 5 } ] }, "ratelimits": [ { "name": "RL_AUTH", "namespace_id": "83800", "simple": { "limit": 10, "period": 60 } }, { "name": "RL_DAV_AUTH", "namespace_id": "83801", "simple": { "limit": 30, "period": 60 } }, { "name": "RL_REPORT", "namespace_id": "83802", "simple": { "limit": 60, "period": 60 } } ] } ``` Config notes: * `MAX_FILE_BYTES` is a product limit and must be parsed as a configurable byte count. The default is `100000000` bytes, or 100 MB. Operators may lower it. Operators may raise it only up to the effective Cloudflare Worker request body limit for the deployed plan. * `MAX_CALENDAR_OBJECT_BYTES` and `MAX_ADDRESS_OBJECT_BYTES` cap stored `.ics` and `.vcf` bodies. The default is `1048576` bytes each. * The rate-limit `namespace_id` values shown above are stable example ids for this project. Change them only if the Cloudflare account requires different namespace ids. Secrets MUST NOT be committed in `wrangler.jsonc`. Required secrets: ```text TESSERA_OIDC_CLIENT_ID TESSERA_OIDC_CLIENT_SECRET DAB_SESSION_SECRET ``` Development-only defaults belong in `.dev.vars.example`. Production values are set with `wrangler secret put` or the Cloudflare dashboard. After any binding change, run `wrangler types --env-file .dev.vars.example` and treat `worker-configuration.d.ts` as generated output. ## 7. Data ownership ### 7.1 DAV_CONTROL_PLANE D1 owns global state D1 is the canonical global database for: * subjects * opaque host labels * last-seen tessera profile projection * API audit event projection * optional PAT list projection for future UI D1 MUST NOT be the hot filesystem metadata store. Use Drizzle ORM with the Cloudflare D1 driver for all D1 access. ### 7.2 AUTH owns hot auth state One AuthObject per subject owns: * active API sessions * PAT verifier state * PAT revocation state * PAT last-used updates * subject auth flags * short auth event history AUTH is authoritative for PAT verification. D1 may hold a projection for UI listing, but verification must use AUTH or a very short-lived in-memory cache backed by AUTH. Use Drizzle ORM with the Durable Object SQLite driver for AUTH storage. ### 7.3 FILE_DAV owns file DAV truth One FileDavObject per subject owns: * `/files/` tree metadata * file and collection nodes * dead properties * WebDAV locks * file ETags * blob references * blob refcounts * pending upload intents * file change log and sync tokens FILE_DAV MUST NOT stream large file bodies through itself. Use Drizzle ORM with the Durable Object SQLite driver for FILE_DAV storage. ### 7.4 CAL_DAV owns calendar truth One CalDavObject per subject owns: * `/calendars/` collections * calendar object bodies * parsed iCalendar indexes * calendar properties * CalDAV reports * CalDAV sync tokens and change log Calendar object bodies SHOULD be stored in Durable Object SQLite because they are usually small and query-heavy. R2 is only for future oversized attachments. Use Drizzle ORM with the Durable Object SQLite driver for CAL_DAV storage. ### 7.5 CARD_DAV owns address book truth One CardDavObject per subject owns: * `/addressbooks/` collections * vCard object bodies * parsed vCard indexes * address book properties * CardDAV reports * CardDAV sync tokens and change log Address book object bodies SHOULD be stored in Durable Object SQLite because they are usually small and query-heavy. Use Drizzle ORM with the Durable Object SQLite driver for CARD_DAV storage. ### 7.6 FILE_BLOBS owns file bytes only R2 stores immutable file blobs under keys like: ```text blobs// ``` `storage_id` is `subjects.storageId`, not the tessera OIDC `sub` value. R2 MUST NOT be treated as the source of truth for: * path tree * directories * dead properties * locks * WebDAV collection existence * CalDAV/CardDAV objects * permissions ## 8. Drizzle and migrations ### 8.1 Drizzle-only database access Drizzle schema declarations are the source of truth for D1 and every Durable Object SQLite database. All application database operations MUST go through Drizzle. Required access rules: * Do not call `env.DAV_CONTROL_PLANE.prepare()` directly from application code. * Do not call `ctx.storage.sql.exec()` directly from application code except inside generated or explicit migration helpers. * Use Drizzle query builders for normal selects, inserts, updates, deletes, joins, ordering, pagination, and batchable statements. * Use Drizzle's `sql` tagged template only when Drizzle cannot express a required SQLite feature, such as a recursive CTE for subtree traversal. The `sql` usage must stay isolated in the owning repository module, must bind user input through the template, must be commented, and must have focused tests. * D1 does not support `.transaction()` in application code. D1 multi-statement atomic writes MUST use D1 `.batch()` semantics through the D1 client. Durable Object SQLite may use `.transaction()` inside the owning Durable Object. * Do not use the D1 Sessions API, D1 bookmarks, bookmark headers, or bookmark cookies in v1. Add them only in a later consistency/read-replica phase. * Every repository module must export named row types from Drizzle, for example `export type SubjectRow = typeof subjects.$inferSelect`. ### 8.2 Migration directories Generated Drizzle migrations live under: ```text drizzle/ d1/ auth-do/ file-dav-do/ cal-dav-do/ card-dav-do/ ``` Each directory is generated from a corresponding schema module under `src/worker/db/`. Do not hand-edit generated migration snapshots unless explicitly repairing generator output in review. ### 8.3 Drizzle schema modules Required schema modules: ```text src/worker/db/d1/schema.ts src/worker/db/auth-do/schema.ts src/worker/db/file-dav-do/schema.ts src/worker/db/cal-dav-do/schema.ts src/worker/db/card-dav-do/schema.ts ``` Each schema module MUST use Drizzle `sqliteTable` declarations following the Drizzle SQL schema declaration style. ### 8.4 Shared schema conventions Column conventions: * Database column names use snake_case. * TypeScript property names use lower camel case. * ids are `text`. * timestamps are `integer` milliseconds since Unix epoch. * booleans use `integer("column_name", { mode: "boolean" })`; do not manually expose `0` and `1` as application booleans. * JSON payloads use Drizzle text JSON mode, for example `text("data_json", { mode: "json" }).$type>()`. * digests are lowercase hex unless a schema comment says otherwise. * enum-like columns use typed `text(...).$type()` plus a Drizzle `check` constraint where practical. * every foreign key specifies the intended delete behavior. * indexes, unique indexes, primary keys, and check constraints are declared in Drizzle, not only in prose. Shared imports used by the schema declarations: ```ts import { sql } from "drizzle-orm"; import { check, index, integer, primaryKey, sqliteTable, text, uniqueIndex, type AnySQLiteColumn, } from "drizzle-orm/sqlite-core"; ``` Shared schema types: ```ts export type DavScope = | "dav:files:read" | "dav:files:write" | "dav:caldav:read" | "dav:caldav:write" | "dav:carddav:read" | "dav:carddav:write"; export type JsonObject = Record; export type NodeKind = "collection" | "file"; export type LockScope = "exclusive" | "shared"; export type LockDepth = "0" | "infinity"; export type PendingUploadState = "pending" | "committed" | "aborted"; export type FileChangeType = "created" | "updated" | "deleted" | "moved"; export type CalendarComponentType = "VEVENT" | "VTODO" | "VJOURNAL"; export type DavResourceKind = "home" | "calendar" | "addressbook" | "object"; export type DavChangeType = "created" | "updated" | "deleted"; ``` ### 8.5 D1 access in v1 `DAV_CONTROL_PLANE` access is intentionally simple in v1. Rules: * Use Drizzle with the Cloudflare D1 driver for D1 queries. * Do not use the D1 Sessions API in v1. * Do not accept, sanitize, emit, or store D1 bookmarks in v1. * Do not add D1 bookmark headers or cookies. * Use D1 `.batch()` for required multi-statement D1 atomicity, such as subject bootstrap plus related projection rows. * Keep D1 batch operations small, explicit, and idempotent where retry can happen. * Do not use D1 as a high-contention lock. ### 8.6 D1 schema `DAV_CONTROL_PLANE` stores one row per tessera subject. `subjects.id` is exactly the tessera OIDC `sub` claim. `subjects.storageId` is an internal generated storage identifier used in R2 keys and other non-public storage paths. Do not add an identity-linking table unless the product later supports multiple issuers. Storage id requirements: * MUST be generated by dab, not supplied by tessera. * MUST be stable for the subject unless a future migration explicitly rotates storage keys. * MUST NOT appear in public DAV URLs. * SHOULD be random high-entropy text such as `stg_` plus at least 128 bits of lowercase base32 or lowercase hex randomness. * MAY alternatively be `base64url(SHA-256("dab:subject-storage-id:v1\0" + sub))` if deterministic repair is more important than unlinkability. Prefer random generation for v1. ```ts export const subjects = sqliteTable( "subjects", { id: text("id").primaryKey(), storageId: text("storage_id").notNull(), hostLabel: text("host_label").notNull(), email: text("email"), displayName: text("display_name"), createdAtMs: integer("created_at_ms").notNull(), lastLoginAtMs: integer("last_login_at_ms"), hostRotatedAtMs: integer("host_rotated_at_ms"), disabledAtMs: integer("disabled_at_ms"), }, (table) => [ uniqueIndex("subjects_storage_id_unique").on(table.storageId), uniqueIndex("subjects_host_label_unique").on(table.hostLabel), index("subjects_disabled_idx").on(table.disabledAtMs), ], ); export const patProjections = sqliteTable( "pat_projections", { id: text("id").primaryKey(), subjectId: text("subject_id") .notNull() .references(() => subjects.id, { onDelete: "cascade" }), name: text("name").notNull(), scopes: text("scopes_json", { mode: "json" }).notNull().$type(), createdAtMs: integer("created_at_ms").notNull(), expiresAtMs: integer("expires_at_ms"), revokedAtMs: integer("revoked_at_ms"), lastUsedAtMs: integer("last_used_at_ms"), }, (table) => [ index("pat_projections_subject_created_idx").on(table.subjectId, table.createdAtMs), index("pat_projections_subject_active_idx").on(table.subjectId, table.revokedAtMs, table.expiresAtMs), ], ); export const auditEvents = sqliteTable( "audit_events", { id: text("id").primaryKey(), subjectId: text("subject_id").references(() => subjects.id, { onDelete: "set null" }), eventType: text("event_type").notNull(), actorSubjectId: text("actor_subject_id"), ipHash: text("ip_hash"), userAgentHash: text("user_agent_hash"), createdAtMs: integer("created_at_ms").notNull(), data: text("data_json", { mode: "json" }).notNull().$type(), }, (table) => [ index("audit_events_subject_created_idx").on(table.subjectId, table.createdAtMs), index("audit_events_type_created_idx").on(table.eventType, table.createdAtMs), index("audit_events_actor_created_idx").on(table.actorSubjectId, table.createdAtMs), ], ); export type SubjectRow = typeof subjects.$inferSelect; export type NewSubjectRow = typeof subjects.$inferInsert; export type PatProjectionRow = typeof patProjections.$inferSelect; export type AuditEventRow = typeof auditEvents.$inferSelect; ``` ### 8.7 AUTH Durable Object schema Each `AuthObject` is named from `subjects.storageId` and stores only that subject's auth state. It may store the tessera subject id in local metadata for audit and invariant checks, but the Durable Object name must not be the raw OIDC `sub`. ```ts export const authMeta = sqliteTable("meta", { key: text("key").primaryKey(), value: text("value").notNull(), }); export const sessions = sqliteTable( "sessions", { id: text("id").primaryKey(), createdAtMs: integer("created_at_ms").notNull(), expiresAtMs: integer("expires_at_ms").notNull(), revokedAtMs: integer("revoked_at_ms"), lastUsedAtMs: integer("last_used_at_ms"), }, (table) => [ index("sessions_expires_idx").on(table.expiresAtMs), index("sessions_revoked_idx").on(table.revokedAtMs), ], ); export const pats = sqliteTable( "pats", { id: text("id").primaryKey(), name: text("name").notNull(), tokenDigest: text("token_digest").notNull(), scopes: text("scopes_json", { mode: "json" }).notNull().$type(), createdAtMs: integer("created_at_ms").notNull(), expiresAtMs: integer("expires_at_ms"), revokedAtMs: integer("revoked_at_ms"), lastUsedAtMs: integer("last_used_at_ms"), }, (table) => [ index("pats_active_idx").on(table.revokedAtMs, table.expiresAtMs), index("pats_created_idx").on(table.createdAtMs), ], ); export const authEvents = sqliteTable( "auth_events", { id: text("id").primaryKey(), eventType: text("event_type").notNull(), createdAtMs: integer("created_at_ms").notNull(), data: text("data_json", { mode: "json" }).notNull().$type(), }, (table) => [ index("auth_events_created_idx").on(table.createdAtMs), index("auth_events_type_created_idx").on(table.eventType, table.createdAtMs), ], ); export type SessionRow = typeof sessions.$inferSelect; export type PatRow = typeof pats.$inferSelect; export type AuthEventRow = typeof authEvents.$inferSelect; ``` ### 8.8 FILE_DAV Durable Object schema Each `FileDavObject` is named from `subjects.storageId` and stores that subject's `/files/` namespace. The Durable Object name must not be the raw OIDC `sub`. ```ts export const fileMeta = sqliteTable("meta", { key: text("key").primaryKey(), value: text("value").notNull(), }); export const blobs = sqliteTable( "blobs", { blobId: text("blob_id").primaryKey(), blobKey: text("blob_key").notNull(), size: integer("size").notNull(), r2Etag: text("r2_etag"), sha256: text("sha256"), refcount: integer("refcount").notNull().default(0), createdAtMs: integer("created_at_ms").notNull(), }, (table) => [ uniqueIndex("blobs_key_unique").on(table.blobKey), index("blobs_refcount_idx").on(table.refcount), index("blobs_created_idx").on(table.createdAtMs), ], ); export const nodes = sqliteTable( "nodes", { id: text("id").primaryKey(), parentId: text("parent_id").references((): AnySQLiteColumn => nodes.id, { onDelete: "cascade" }), name: text("name").notNull(), kind: text("kind").notNull().$type(), blobId: text("blob_id").references(() => blobs.blobId, { onDelete: "restrict" }), size: integer("size").notNull().default(0), contentType: text("content_type"), contentLanguage: text("content_language"), etag: text("etag"), rootMarker: text("root_marker").$type<"root">(), createdAtMs: integer("created_at_ms").notNull(), modifiedAtMs: integer("modified_at_ms").notNull(), version: integer("version").notNull().default(1), }, (table) => [ uniqueIndex("nodes_parent_name_unique").on(table.parentId, table.name), uniqueIndex("nodes_root_marker_unique").on(table.rootMarker), index("nodes_parent_kind_idx").on(table.parentId, table.kind), index("nodes_blob_idx").on(table.blobId), index("nodes_modified_idx").on(table.modifiedAtMs), check("nodes_kind_check", sql`${table.kind} in ('collection', 'file')`), check("nodes_root_marker_check", sql`${table.rootMarker} is null or ${table.rootMarker} = 'root'`), check("nodes_collection_blob_check", sql`${table.kind} != 'collection' or ${table.blobId} is null`), ], ); export const deadProps = sqliteTable( "dead_props", { nodeId: text("node_id") .notNull() .references(() => nodes.id, { onDelete: "cascade" }), nsUri: text("ns_uri").notNull(), localName: text("local_name").notNull(), xmlValue: text("xml_value").notNull(), }, (table) => [primaryKey({ columns: [table.nodeId, table.nsUri, table.localName] })], ); export const locks = sqliteTable( "locks", { token: text("token").primaryKey(), rootNodeId: text("root_node_id").references(() => nodes.id, { onDelete: "cascade" }), rootPath: text("root_path").notNull(), ownerXml: text("owner_xml").notNull(), principalSubjectId: text("principal_subject_id").notNull(), scope: text("scope").notNull().$type(), depth: text("depth").notNull().$type(), createdAtMs: integer("created_at_ms").notNull(), expiresAtMs: integer("expires_at_ms").notNull(), }, (table) => [ index("locks_root_node_idx").on(table.rootNodeId), index("locks_root_path_idx").on(table.rootPath), index("locks_expires_idx").on(table.expiresAtMs), index("locks_principal_idx").on(table.principalSubjectId), check("locks_scope_check", sql`${table.scope} in ('exclusive', 'shared')`), check("locks_depth_check", sql`${table.depth} in ('0', 'infinity')`), ], ); export const pendingUploads = sqliteTable( "pending_uploads", { uploadId: text("upload_id").primaryKey(), nodeId: text("node_id").references(() => nodes.id, { onDelete: "set null" }), path: text("path").notNull(), blobId: text("blob_id").notNull(), blobKey: text("blob_key").notNull(), createdAtMs: integer("created_at_ms").notNull(), expiresAtMs: integer("expires_at_ms").notNull(), state: text("state").notNull().$type(), }, (table) => [ index("pending_uploads_state_expires_idx").on(table.state, table.expiresAtMs), index("pending_uploads_path_idx").on(table.path), index("pending_uploads_blob_idx").on(table.blobId), check("pending_uploads_state_check", sql`${table.state} in ('pending', 'committed', 'aborted')`), ], ); export const changes = sqliteTable( "changes", { seq: integer("seq").primaryKey({ autoIncrement: true }), nodeId: text("node_id").references(() => nodes.id, { onDelete: "set null" }), href: text("href").notNull(), changeType: text("change_type").notNull().$type(), changedAtMs: integer("changed_at_ms").notNull(), }, (table) => [ index("changes_href_idx").on(table.href), index("changes_changed_idx").on(table.changedAtMs), check("changes_type_check", sql`${table.changeType} in ('created', 'updated', 'deleted', 'moved')`), ], ); export type FileNodeRow = typeof nodes.$inferSelect; export type FileBlobRow = typeof blobs.$inferSelect; export type FileLockRow = typeof locks.$inferSelect; ``` The root collection row MUST have `rootMarker = "root"`, `parentId = null`, `name = ""`, and `kind = "collection"`. Because SQLite unique indexes allow multiple `NULL` values, root uniqueness is enforced by `nodes_root_marker_unique`, not by `unique(parentId, name)`. ### 8.9 CAL_DAV Durable Object schema Each `CalDavObject` is named from `subjects.storageId` and stores that subject's `/calendars/` namespace. The Durable Object name must not be the raw OIDC `sub`. ```ts export const calMeta = sqliteTable("meta", { key: text("key").primaryKey(), value: text("value").notNull(), }); export const calendars = sqliteTable( "calendars", { id: text("id").primaryKey(), name: text("name").notNull(), displayName: text("display_name").notNull(), description: text("description"), timezoneIcal: text("timezone_ical"), color: text("color"), orderIndex: integer("order_index").notNull().default(0), createdAtMs: integer("created_at_ms").notNull(), modifiedAtMs: integer("modified_at_ms").notNull(), syncSeq: integer("sync_seq").notNull().default(0), }, (table) => [ uniqueIndex("calendars_name_unique").on(table.name), index("calendars_order_idx").on(table.orderIndex), index("calendars_modified_idx").on(table.modifiedAtMs), ], ); export const calendarObjects = sqliteTable( "calendar_objects", { id: text("id").primaryKey(), calendarId: text("calendar_id") .notNull() .references(() => calendars.id, { onDelete: "cascade" }), name: text("name").notNull(), uid: text("uid").notNull(), componentType: text("component_type").notNull().$type(), body: text("body").notNull(), etag: text("etag").notNull(), size: integer("size").notNull(), createdAtMs: integer("created_at_ms").notNull(), modifiedAtMs: integer("modified_at_ms").notNull(), version: integer("version").notNull().default(1), }, (table) => [ uniqueIndex("calendar_objects_calendar_name_unique").on(table.calendarId, table.name), uniqueIndex("calendar_objects_calendar_uid_unique").on(table.calendarId, table.uid), index("calendar_objects_calendar_modified_idx").on(table.calendarId, table.modifiedAtMs), index("calendar_objects_component_idx").on(table.calendarId, table.componentType), check("calendar_objects_component_check", sql`${table.componentType} in ('VEVENT', 'VTODO', 'VJOURNAL')`), ], ); export const calendarIndex = sqliteTable( "calendar_index", { objectId: text("object_id") .primaryKey() .references(() => calendarObjects.id, { onDelete: "cascade" }), calendarId: text("calendar_id") .notNull() .references(() => calendars.id, { onDelete: "cascade" }), uid: text("uid").notNull(), componentType: text("component_type").notNull().$type(), dtstartMs: integer("dtstart_ms"), dtendMs: integer("dtend_ms"), dueMs: integer("due_ms"), completedMs: integer("completed_ms"), summary: text("summary"), hasRecurrence: integer("has_recurrence", { mode: "boolean" }).notNull().default(false), recurrenceMinMs: integer("recurrence_min_ms"), recurrenceMaxMs: integer("recurrence_max_ms"), }, (table) => [ index("calendar_index_timerange_idx").on(table.calendarId, table.componentType, table.dtstartMs, table.dtendMs), index("calendar_index_due_idx").on(table.calendarId, table.dueMs), index("calendar_index_completed_idx").on(table.calendarId, table.completedMs), index("calendar_index_uid_idx").on(table.calendarId, table.uid), index("calendar_index_recurrence_idx").on(table.calendarId, table.hasRecurrence, table.recurrenceMinMs, table.recurrenceMaxMs), check("calendar_index_component_check", sql`${table.componentType} in ('VEVENT', 'VTODO', 'VJOURNAL')`), ], ); export const calendarDeadProps = sqliteTable( "calendar_dead_props", { resourceKind: text("resource_kind").notNull().$type>(), resourceId: text("resource_id").notNull(), nsUri: text("ns_uri").notNull(), localName: text("local_name").notNull(), xmlValue: text("xml_value").notNull(), }, (table) => [ primaryKey({ columns: [table.resourceKind, table.resourceId, table.nsUri, table.localName] }), check("calendar_dead_props_kind_check", sql`${table.resourceKind} in ('home', 'calendar', 'object')`), ], ); export const calendarChanges = sqliteTable( "calendar_changes", { seq: integer("seq").primaryKey({ autoIncrement: true }), calendarId: text("calendar_id").references(() => calendars.id, { onDelete: "cascade" }), href: text("href").notNull(), changeType: text("change_type").notNull().$type(), changedAtMs: integer("changed_at_ms").notNull(), }, (table) => [ index("calendar_changes_calendar_seq_idx").on(table.calendarId, table.seq), index("calendar_changes_changed_idx").on(table.changedAtMs), index("calendar_changes_href_idx").on(table.href), check("calendar_changes_type_check", sql`${table.changeType} in ('created', 'updated', 'deleted')`), ], ); ``` ### 8.10 CARD_DAV Durable Object schema Each `CardDavObject` is named from `subjects.storageId` and stores that subject's `/addressbooks/` namespace. The Durable Object name must not be the raw OIDC `sub`. ```ts export const cardMeta = sqliteTable("meta", { key: text("key").primaryKey(), value: text("value").notNull(), }); export const addressbooks = sqliteTable( "addressbooks", { id: text("id").primaryKey(), name: text("name").notNull(), displayName: text("display_name").notNull(), description: text("description"), createdAtMs: integer("created_at_ms").notNull(), modifiedAtMs: integer("modified_at_ms").notNull(), syncSeq: integer("sync_seq").notNull().default(0), }, (table) => [ uniqueIndex("addressbooks_name_unique").on(table.name), index("addressbooks_modified_idx").on(table.modifiedAtMs), ], ); export const addressObjects = sqliteTable( "address_objects", { id: text("id").primaryKey(), addressbookId: text("addressbook_id") .notNull() .references(() => addressbooks.id, { onDelete: "cascade" }), name: text("name").notNull(), uid: text("uid").notNull(), body: text("body").notNull(), etag: text("etag").notNull(), size: integer("size").notNull(), createdAtMs: integer("created_at_ms").notNull(), modifiedAtMs: integer("modified_at_ms").notNull(), version: integer("version").notNull().default(1), }, (table) => [ uniqueIndex("address_objects_book_name_unique").on(table.addressbookId, table.name), uniqueIndex("address_objects_book_uid_unique").on(table.addressbookId, table.uid), index("address_objects_book_modified_idx").on(table.addressbookId, table.modifiedAtMs), ], ); export const addressIndex = sqliteTable( "address_index", { objectId: text("object_id") .primaryKey() .references(() => addressObjects.id, { onDelete: "cascade" }), addressbookId: text("addressbook_id") .notNull() .references(() => addressbooks.id, { onDelete: "cascade" }), uid: text("uid").notNull(), fn: text("fn"), nFamily: text("n_family"), nGiven: text("n_given"), org: text("org"), emails: text("emails_json", { mode: "json" }).notNull().$type(), tels: text("tels_json", { mode: "json" }).notNull().$type(), }, (table) => [ index("address_index_uid_idx").on(table.addressbookId, table.uid), index("address_index_fn_idx").on(table.addressbookId, table.fn), index("address_index_family_idx").on(table.addressbookId, table.nFamily), index("address_index_given_idx").on(table.addressbookId, table.nGiven), index("address_index_org_idx").on(table.addressbookId, table.org), ], ); export const addressDeadProps = sqliteTable( "address_dead_props", { resourceKind: text("resource_kind").notNull().$type>(), resourceId: text("resource_id").notNull(), nsUri: text("ns_uri").notNull(), localName: text("local_name").notNull(), xmlValue: text("xml_value").notNull(), }, (table) => [ primaryKey({ columns: [table.resourceKind, table.resourceId, table.nsUri, table.localName] }), check("address_dead_props_kind_check", sql`${table.resourceKind} in ('home', 'addressbook', 'object')`), ], ); export const addressChanges = sqliteTable( "address_changes", { seq: integer("seq").primaryKey({ autoIncrement: true }), addressbookId: text("addressbook_id").references(() => addressbooks.id, { onDelete: "cascade" }), href: text("href").notNull(), changeType: text("change_type").notNull().$type(), changedAtMs: integer("changed_at_ms").notNull(), }, (table) => [ index("address_changes_book_seq_idx").on(table.addressbookId, table.seq), index("address_changes_changed_idx").on(table.changedAtMs), index("address_changes_href_idx").on(table.href), check("address_changes_type_check", sql`${table.changeType} in ('created', 'updated', 'deleted')`), ], ); ``` ## 9. Project structure The implementation should use a modular structure. Avoid files over roughly 300 lines unless the code is mostly declarations or generated output. Avoid any single 1000-line application file. Proposed structure: ```text . drizzle/ d1/ auth-do/ file-dav-do/ cal-dav-do/ card-dav-do/ scripts/ litmus/ setup.ts fixture.ts files.ts src/ worker/ index.ts app.ts env.ts types.ts routes/ api/ index.ts auth.ts me.ts pats.ts files.ts calendars.ts addressbooks.ts openapi.ts dav/ index.ts well-known.ts root.ts principals.ts files.ts calendars.ts addressbooks.ts auth/ index.ts basic.ts oidc.ts oidc-transaction-cookie.ts session-cookie.ts pats.ts scopes.ts subject.ts crypto.ts dav/ http.ts methods.ts paths.ts destination.ts if-header.ts locks.ts props.ts multistatus.ts errors.ts etag.ts xml/ parser.ts builder.ts namespaces.ts dead-props.ts dav-request.ts dav-response.ts webdav/ handler.ts propfind.ts proppatch.ts mkcol.ts get-head.ts put.ts delete.ts copy.ts move.ts lock.ts unlock.ts caldav/ handler.ts discovery.ts props.ts reports.ts calendar-query.ts calendar-multiget.ts sync-collection.ts ical.ts recurrence.ts validation.ts carddav/ handler.ts discovery.ts props.ts reports.ts addressbook-query.ts addressbook-multiget.ts sync-collection.ts vcard.ts validation.ts db/ d1/ client.ts schema.ts repository.ts auth-do/ client.ts schema.ts repository.ts file-dav-do/ client.ts schema.ts repository.ts cal-dav-do/ client.ts schema.ts repository.ts card-dav-do/ client.ts schema.ts repository.ts objects/ auth-object.ts file-dav-object.ts cal-dav-object.ts card-dav-object.ts common/ migrations.ts rpc.ts errors.ts r2/ blobs.ts gc.ts queues/ blob-gc.ts repair-jobs.ts openapi/ schemas.ts document.ts serve.ts validate.ts util/ ids.ts time.ts json.ts logging.ts response.ts assert.ts tests/ worker/ setup/ env.ts oidc-mock.ts fixtures.ts auth/ oidc.test.ts pats.test.ts basic.test.ts api/ openapi.test.ts me.test.ts pats.test.ts webdav/ paths.test.ts propfind.test.ts proppatch.test.ts put-get.test.ts copy-move.test.ts locks.test.ts if-header.test.ts caldav/ discovery.test.ts calendar-query.test.ts calendar-multiget.test.ts sync-collection.test.ts carddav/ discovery.test.ts addressbook-query.test.ts addressbook-multiget.test.ts sync-collection.test.ts fixtures/ ics/ vcf/ xml/ litmus/ harness.ts .dev.vars.example .gitignore vite.config.ts vitest.config.ts wrangler.jsonc worker-configuration.d.ts drizzle-d1.config.ts drizzle-auth-do.config.ts drizzle-file-dav-do.config.ts drizzle-cal-dav-do.config.ts drizzle-card-dav-do.config.ts package.json tsconfig.json prettier.config.js ``` `vendor/litmus/` is a local-only ignored directory created by `npm run litmus:setup`; it is intentionally not part of the committed project structure. ## 10. Hono app shape ### 10.1 Composition `src/worker/index.ts` should export: ```text AuthObject FileDavObject CalDavObject CardDavObject default fetch handler from the Hono app ``` `src/worker/app.ts` should compose: ```text Host: dav.example.com /healthz -> health check /api/v1/* -> JSON API routes Host: .dav.example.com /.well-known/caldav -> well-known handler /.well-known/carddav -> well-known handler /principals/* -> principal DAV handler /files/* -> WebDAV file handler /calendars/* -> CalDAV handler /addressbooks/* -> CardDAV handler / -> root DAV discovery handler ``` Use shared middleware for: * request id * structured logging * secure headers for API routes * host label parsing * auth context injection * error normalization ### 10.2 DAV methods in Hono Hono route files may use `app.on()` for DAV methods, or a catch-all DAV route that dispatches by method. Keep protocol logic outside route registration files. The route file should only: * parse route prefix * call authentication middleware * call the relevant protocol handler * return the handler response ## 11. OpenAPI requirements ### 11.1 Source of truth All JSON API routes under `/api/v1/` MUST be defined code-first in Hono route modules with Zod request and response schemas. Use: ```text hono-openapi @hono/zod-validator zod zod-openapi ``` Hono's `hono-openapi` example documents route metadata with `describeRoute`, validation with `validator`, and generated OpenAPI output with `openAPIRouteHandler`. Use that shape, or an equivalent `hono-openapi` plus Zod setup, so route validation and OpenAPI stay in one place. Do not use OpenAPI-to-server code generation as the implementation source of truth. `openapi-typescript` MAY be used later to generate a client from the generated OpenAPI document. Use OpenAPI 3.1.x if supported cleanly by the chosen generator and validator. If a chosen Hono OpenAPI helper only emits 3.0.x, use 3.0.x initially and document that limitation in the OpenAPI tests. The Hono route schema metadata is the source of truth for: * API paths * HTTP methods * request bodies * response bodies * response status codes * auth schemes * common error shapes DAV protocol endpoints do not need to be modeled in OpenAPI unless useful for documentation. The required OpenAPI scope is the JSON API surface under `/api/v1/`. ### 11.2 Served spec routes Implement: ```text GET /api/v1/openapi.json GET /api/v1/openapi.yaml ``` Also implement an unauthenticated control-plane health route outside the JSON API version tree: ```text GET /healthz ``` `GET /healthz` should return a tiny JSON payload such as `{ "ok": true, "service": "dab" }`. It is not required to appear in the OpenAPI document. These routes are served from `dav.example.com`. Subject hosts should not expose JSON API or OpenAPI routes. No Swagger UI is required. ### 11.3 API routes The OpenAPI document must define at least: ```text GET /api/v1/auth/oidc/login GET /api/v1/auth/oidc/callback POST /api/v1/auth/logout GET /api/v1/me GET /api/v1/pats POST /api/v1/pats GET /api/v1/pats/{pat_id} PATCH /api/v1/pats/{pat_id} DELETE /api/v1/pats/{pat_id} GET /api/v1/calendars POST /api/v1/calendars GET /api/v1/calendars/{calendar_id} PATCH /api/v1/calendars/{calendar_id} DELETE /api/v1/calendars/{calendar_id} GET /api/v1/addressbooks POST /api/v1/addressbooks GET /api/v1/addressbooks/{addressbook_id} PATCH /api/v1/addressbooks/{addressbook_id} DELETE /api/v1/addressbooks/{addressbook_id} GET /api/v1/files/metadata GET /api/v1/files/list POST /api/v1/files/mkdir POST /api/v1/files/rename DELETE /api/v1/files/delete ``` ### 11.4 Contract tests Worker tests MUST validate: * OpenAPI document parses successfully. * Generated YAML and JSON documents describe the same API. * Every `/api/v1/` Hono route has an OpenAPI path entry. * Common error responses match the OpenAPI schema. * PAT creation response includes the token only on create, never on list/detail. ## 12. tessera OIDC ### 12.1 Configuration Use exactly this committed non-secret OIDC environment variable name: ```text TESSERA_OIDC_ISSUER ``` Use exactly these secret names: ```text TESSERA_OIDC_CLIENT_ID TESSERA_OIDC_CLIENT_SECRET DAB_SESSION_SECRET ``` `TESSERA_OIDC_CLIENT_ID` may appear in `.dev.vars.example` for local development, but production values still live in Cloudflare secrets or dashboard-managed variables. `TESSERA_OIDC_CLIENT_SECRET` and `DAB_SESSION_SECRET` MUST NOT be committed. Development examples: ```text TESSERA_OIDC_ISSUER=http://localhost:6174 TESSERA_OIDC_CLIENT_ID=dab-dev ``` ### 12.2 openid-client requirement Use `openid-client` for OIDC. Do not hand-roll provider metadata discovery, token exchange, ID token validation, or callback parameter validation if `openid-client` supports the needed operation. Discovery is done by `openid-client` from: ```text TESSERA_OIDC_ISSUER ``` Do not configure a separate JWKS URI. Do not manually fetch JWKS unless forced by a library bug. Normalize the issuer URL by trimming trailing slashes and rejecting query strings, fragments, usernames, or passwords. Production issuers MUST use HTTPS. Plain HTTP is allowed only for loopback local development issuers such as `localhost`, `127.0.0.1`, or `[::1]`. ### 12.3 Login flow The API login flow: ```text GET https://dav.example.com/api/v1/auth/oidc/login - create OIDC transaction - create state, nonce, PKCE verifier - store transaction in signed encoded cookie - redirect to tessera authorization endpoint GET https://dav.example.com/api/v1/auth/oidc/callback - read and verify OIDC transaction cookie - use openid-client to process callback and exchange code - verify issuer, audience, nonce, state, and PKCE through openid-client - read `sub` from ID token claims - bootstrap subject, storage id, and opaque host label if first login - create API session in AUTH - set signed session cookie - clear transaction cookie - redirect to a configured post-login URL or return JSON if requested ``` ### 12.4 OIDC transaction cookie The OIDC transaction cookie MUST be encoded and signed. Cookie contents: ```text state nonce codeVerifier returnTo createdAtMs expiresAtMs ``` Cookie format: ```text base64url(json_payload).base64url(hmac_signature) ``` Signature key: ```text HKDF-SHA-256( input key material = TESSERA_OIDC_CLIENT_SECRET, salt = "dab:oidc-transaction:v1", info = "cookie-signing" ) ``` Cookie requirements: * `HttpOnly` * `Secure` * `SameSite=Lax` or stricter * short TTL, recommended 5 to 10 minutes * `__Host-` cookie prefix through Hono's `prefix: "host"` option * `Path=/` * no `Domain` attribute * host-only on `dav.example.com` * cleared on successful callback and on invalid callback `returnTo` MUST be either a relative path on the control-plane host or an explicitly allowlisted same-origin URL. Reject foreign origins to avoid open redirects. Use Web Crypto APIs available in Workers for HKDF and HMAC. ### 12.5 Session cookie The API session cookie contains only routing material and a signature, not the session secret itself. Recommended cookie payload: ```text subjectId storageId sessionId createdAtMs expiresAtMs ``` AUTH stores the authoritative session row for expiry, revocation, and last-used updates. `storageId` is signed routing material only; it is not an authorization secret and must not be returned by JSON API responses. Use DAB_SESSION_SECRET as the input key material. Use a different HKDF context from the OIDC transaction cookie, for example: ```text HKDF-SHA-256( input key material = DAB_SESSION_SECRET, salt = "dab:api-session:v1", info = "cookie-signing" ) ``` Session TTL SHOULD default to 6 hours. The session cookie MUST use the `__Host-` prefix through Hono's `prefix: "host"` option, `Path=/`, no `Domain` attribute, `HttpOnly`, `Secure`, and `SameSite=Lax` or stricter. The cookie is host-only on `dav.example.com`. Subject-host DAV traffic should use PAT Basic auth instead of relying on the control-plane session cookie. ## 13. PAT authentication ### 13.1 PAT format PATs are used by WebDAV, CalDAV, and CardDAV clients. Token format: ```text dab_pat__ ``` Where: * `public_id` is lowercase hex and contains at least 96 bits of entropy. * `secret` is lowercase base32 without padding and contains at least 256 bits of entropy. * The full PAT is shown only once at creation time. Exact grammar: ```text PAT row id: dab_pat_<24 or more lowercase hex chars> PAT token: dab_pat__<52 or more lowercase base32 chars> PAT regex: ^dab_pat_([0-9a-f]{24,})_([a-z2-7]{52,})$ ``` Stored verifier: ```text token_digest = SHA-256(full PAT plaintext) ``` Do not store plaintext PAT secrets. A separate PAT pepper is not required because the secret portion of every PAT has at least 256 bits of randomness; offline guessing remains infeasible from a leaked digest table. If a future deployment requires a keyed digest for defense in depth, use a DAV-owned secret name such as `DAB_PAT_DIGEST_KEY`, not a tessera-named secret, and document the rotation behavior. ### 13.2 Basic auth for DAV clients DAV clients authenticate using HTTP Basic: ```text Authorization: Basic base64(:) ``` Rules: * Username MAY be ignored. * Username SHOULD be accepted if it equals email, subject id, or host label. * Password MUST be the PAT. * Bearer PATs MAY be accepted for API/debug clients but Basic is required for DAV clients. * A PAT MUST be subject-bound. * A PAT MUST NOT authenticate against a different subject host. ### 13.3 PAT scopes Initial scopes: ```text dav:files:read dav:files:write dav:caldav:read dav:caldav:write dav:carddav:read dav:carddav:write ``` Convenience presets for the API: ```text files.readonly -> dav:files:read files.full -> dav:files:read,dav:files:write caldav.readonly -> dav:caldav:read caldav.full -> dav:caldav:read,dav:caldav:write carddav.readonly -> dav:carddav:read carddav.full -> dav:carddav:read,dav:carddav:write dav.full -> all dav:* scopes ``` Mapping: * safe read methods require read scope * write methods require write scope * `LOCK` and `UNLOCK` require write scope * `PROPPATCH` requires write scope * `REPORT` requires read scope unless it mutates state, which it should not ## 14. API behavior ### 14.1 API sessions API routes use tessera OIDC sessions, not PATs, unless explicitly documented otherwise. Session requirements: * Session cookie MUST be `HttpOnly`. * Session cookie MUST be `Secure`. * Session cookie MUST use `SameSite=Lax` or stricter. * Session cookie MUST be host-only and use the `__Host-` prefix. * Session ids MUST be random and high entropy. * Session verifier state MUST live in AUTH. * Session TTL SHOULD default to 6 hours. Cookie-authenticated mutating API routes MUST enforce same-origin requests: * Safe methods `GET`, `HEAD`, and `OPTIONS` skip the check. * Non-safe methods require an `Origin` header whose origin exactly matches the request origin. * Run the same-origin check before session lookup so foreign-origin POSTs fail without spending auth work. * PAT credentials MUST NOT authorize browser account or PAT-management API mutations. ### 14.2 GET /api/v1/me Response includes the subject's public DAV URLs, using the opaque host label: ```json { "subject_id": "tessera-sub", "host_label": "river-copper-lantern-velvet-maple", "control_plane_host": "dav.example.com", "dav_host": "river-copper-lantern-velvet-maple.dav.example.com", "urls": { "api": "https://dav.example.com/api/v1/", "root": "https://river-copper-lantern-velvet-maple.dav.example.com/", "files": "https://river-copper-lantern-velvet-maple.dav.example.com/files/", "caldav": "https://river-copper-lantern-velvet-maple.dav.example.com/calendars/", "carddav": "https://river-copper-lantern-velvet-maple.dav.example.com/addressbooks/" } } ``` ### 14.3 PAT API `POST /api/v1/pats` request: ```json { "name": "macos contacts and calendar", "scopes": ["dav:caldav:read", "dav:caldav:write", "dav:carddav:read", "dav:carddav:write"], "expires_at": "2027-01-01T00:00:00Z" } ``` `POST /api/v1/pats` response: ```json { "id": "dab_pat_0123456789abcdef01234567", "name": "macos contacts and calendar", "token": "dab_pat_0123456789abcdef01234567_mfrggzdfmztwq2lknnqxs33vov2gs43uomxgg33nobwgk3tumfrggzdf", "scopes": ["dav:caldav:read", "dav:caldav:write", "dav:carddav:read", "dav:carddav:write"], "created_at": "2026-01-01T00:00:00Z", "expires_at": "2027-01-01T00:00:00Z" } ``` List and detail responses MUST NOT include token secrets. `DELETE /api/v1/pats/{pat_id}` is revoke, not physical deletion. It sets `revoked_at` in AUTH and updates the D1 projection. There is no separate `POST /api/v1/pats/{pat_id}/revoke` route in v1. ### 14.4 Calendar, address book, and file APIs These APIs are wrappers around the same Durable Object state used by DAV protocols. Do not duplicate semantics outside the Durable Objects. For example: * Calendar API operations call CAL_DAV. * Address book API operations call CARD_DAV. * File API operations call FILE_DAV. ## 15. Request routing ### 15.1 Request classifier The Router Worker should classify requests in this order: 1. Classify host as control-plane host `dav.example.com` or subject host `.dav.example.com`. 2. For control-plane host requests: * route `/healthz` to the unauthenticated health check * route `/api/v1/*` to JSON API routes * use API session auth unless the endpoint is OIDC login/callback/openapi * return `404 Not Found` for DAV storage paths 3. For subject host requests, parse and validate opaque host label. 4. Resolve host label to subject id and storage id via DAV_CONTROL_PLANE or a short-lived cache backed by DAV_CONTROL_PLANE. 5. If path is `/.well-known/caldav` or `/.well-known/carddav`, handle discovery redirect. 6. Otherwise authenticate as DAV using PAT Basic auth. 7. Enforce host owner subject id equals authenticated subject id. 8. Route by path prefix: * `/files/` -> FILE_DAV plus FILE_BLOBS as needed * `/calendars/` -> CAL_DAV * `/addressbooks/` -> CARD_DAV * `/principals/` -> principal handler * `/` -> root DAV handler 9. Return 404 for unknown roots. ### 15.2 Path normalization All DAV paths MUST be normalized before use. Rules: * Parse URL path as UTF-8 percent-decoded path segments. * Reject invalid percent encoding. * Reject encoded slash in a path segment if the platform exposes it ambiguously. * Reject NUL and ASCII control characters. * Remove dot segments before lookup. * Preserve case. * Collections MUST have canonical hrefs ending in `/`. * Non-collections MUST have canonical hrefs without trailing `/`. * Multiple slashes SHOULD normalize to one slash, except do not alter escaped slash behavior. * Do not use raw string prefix checks for authorization after decoding. ## 16. XML implementation Use `fast-xml-parser` for DAV, CalDAV, and CardDAV XML parsing and serialization. Requirements: * XML parsing must disable or reject dangerous entity behavior. * XML body size must be capped. * Parser must preserve enough structure to distinguish namespace URI and local name. * XML prefixes are not semantic identifiers. * DAV logic must compare namespace URI plus local name, not prefix plus local name. * Dead properties must be stored by `(resource id, namespace URI, local name)`. * Dead property values should be stored as XML fragments, not JSON. * For dead property round trips, preserve the original XML fragment where possible. * XML builder must emit valid `D:multistatus`, `D:response`, `D:propstat`, `D:prop`, `D:status`, and `D:href` elements. Suggested module boundaries: ```text src/worker/xml/parser.ts -> fast-xml-parser setup and safe parse helpers src/worker/xml/builder.ts -> XMLBuilder setup and DAV response helpers src/worker/xml/namespaces.ts -> namespace constants and QName utilities src/worker/xml/dead-props.ts -> dead property extraction and serialization src/worker/xml/dav-request.ts -> PROPFIND, PROPPATCH, REPORT request parsing src/worker/xml/dav-response.ts -> multistatus response construction ``` ## 17. WebDAV file support ### 17.1 Compliance target `/files/` MUST pass the notroj litmus suite. Minimum WebDAV support: ```text OPTIONS PROPFIND PROPPATCH MKCOL GET HEAD PUT DELETE COPY MOVE LOCK UNLOCK ``` Optional later: ```text PATCH REPORT sync-collection ``` Do not implement WebDAV SEARCH in this version. ### 17.2 OPTIONS For `/files/`, return headers similar to: ```text DAV: 1, 2 Allow: OPTIONS, PROPFIND, PROPPATCH, MKCOL, GET, HEAD, PUT, DELETE, COPY, MOVE, LOCK, UNLOCK MS-Author-Via: DAV ``` Do not claim DAV features before they work. ### 17.3 PROPFIND MUST support: * `Depth: 0` * `Depth: 1` * `Depth: infinity`, at least with sane limits * `allprop` * `propname` * explicit `prop` * `207 Multi-Status` * live props * dead props * namespace-aware XML Live properties for files: ```text DAV:resourcetype DAV:getcontentlength DAV:getcontenttype DAV:getetag DAV:getlastmodified DAV:creationdate DAV:displayname DAV:supportedlock DAV:lockdiscovery ``` Live properties for collections: ```text DAV:resourcetype with DAV:collection DAV:getlastmodified DAV:creationdate DAV:displayname DAV:supportedlock DAV:lockdiscovery ``` ### 17.4 PROPPATCH MUST support arbitrary dead property set/remove. Rules: * Process instructions in document order. * Apply atomically: either all valid changes commit or none commit. * Protected live properties MUST NOT be modified. * Return a `207 Multi-Status` response with per-property status. * Store dead property values as namespace-aware XML fragments. * Preserve namespaces well enough to round-trip through litmus. ### 17.5 PUT one-hop flow PUT flow: ```text 1. Worker authenticates and normalizes path. 2. Worker calls FILE_DAV beginWrite(path, headers, authz). 3. FILE_DAV validates parent, locks, conditional headers, configured size limits, and resource type. 4. FILE_DAV creates a pending upload intent and returns blobKey and uploadId. 5. Worker streams request body directly to FILE_BLOBS.put(blobKey, request.body). 6. Worker calls FILE_DAV commitWrite(uploadId, R2 metadata). 7. If R2 succeeds but commit fails, Worker enqueues BLOB_GC for blobKey. 8. If R2 fails, Worker calls FILE_DAV abortWrite(uploadId). ``` Rules: * Existing collection target plus PUT MUST fail. * PUT to an unmapped URL creates a file if parent exists. * PUT to an existing file replaces the file body and updates ETag. * Honor `If-Match`, `If-None-Match`, and WebDAV `If` lock tokens. * Do not buffer full request bodies in Worker memory. * Enforce `MAX_FILE_BYTES` using `Content-Length` preflight when present and streaming byte counting when absent or unreliable. ### 17.6 GET and HEAD one-hop flow GET/HEAD flow: ```text 1. Worker authenticates and normalizes path. 2. Worker asks FILE_DAV to resolve metadata and authz. 3. FILE_DAV returns blobKey, ETag, size, content type, and last modified. 4. Worker fetches directly from FILE_BLOBS. 5. Worker streams the R2 body to the client. ``` Support Range requests if practical. ### 17.7 DELETE, COPY, MOVE Rules: * DELETE file removes metadata and decrements blob refcount. * DELETE collection recursively removes descendants. * R2 blob deletion happens later through BLOB_GC. * COPY must parse `Destination` and `Overwrite`. * COPY must reject cross-host and cross-subject destinations. * COPY must clone dead properties. * COPY must share immutable blob references and increment refcount. * COPY must not copy active locks. * MOVE must be metadata-only within one FILE_DAV namespace. * MOVE must reject moving a collection into its own descendant. * MOVE must preserve dead properties. * MOVE must check locks on source, destination, overwritten subtree, and relevant ancestors. ### 17.8 LOCK and UNLOCK Implement Class 2 locking correctly. MUST support: * exclusive write locks * shared write locks, or reject unsupported shared locks consistently * `Depth: 0` * `Depth: infinity` for collections * lock refresh using `If` header and empty request body * timeout negotiation * lockdiscovery property * supportedlock property * second-session lock denial * unmapped URL locks, creating a locked empty non-collection resource Lock tokens: ```text opaquelocktoken: ``` The response to a new lock MUST include: ```text Lock-Token: Content-Type: application/xml; charset=utf-8 ``` UNLOCK requires a `Lock-Token` header and returns `204 No Content` on success. ### 17.9 WebDAV If header Implement a real parser for the WebDAV `If` header. Do not regex only for lock tokens. Must support: ```text If: () If: () If: ( ["etag"]) If: (Not ) ``` The evaluator must handle tagged and untagged lists, `Not`, lock tokens, and ETags. ## 18. CalDAV support ### 18.1 Scope Implement storage and synchronization CalDAV, not scheduling CalDAV. Must support: ```text OPTIONS PROPFIND REPORT GET HEAD PUT DELETE MKCALENDAR PROPPATCH ``` May support later: ```text COPY MOVE LOCK UNLOCK free-busy-query scheduling inbox/outbox ``` ### 18.2 Discovery The following must work: * `/.well-known/caldav` redirect * `DAV:current-user-principal` * `CALDAV:calendar-home-set` * principal resource under `/principals//` * calendar home collection under `/calendars/` * default calendar collection under `/calendars/default/` Root or principal PROPFIND should allow clients to discover: ```text DAV:current-user-principal DAV:principal-URL DAV:displayname CALDAV:calendar-home-set CALDAV:calendar-user-address-set ``` Calendar home and calendar collection PROPFIND responses should support common compatibility properties where practical: ```text CALDAV:supported-calendar-component-set CALDAV:supported-calendar-data CALDAV:max-resource-size CS:getctag ``` ### 18.3 Calendar collections A calendar collection is a WebDAV collection with resource type: ```xml ``` Rules: * Calendar collections MUST NOT be nested under other calendar collections. * `/calendars/default/` MUST exist for each subject. * Calendar object resources MUST live directly inside a calendar collection. ### 18.4 Calendar object resources Calendar object paths should look like: ```text /calendars/default/.ics ``` PUT requirements: * Body must be valid iCalendar. * Top-level component must be `VCALENDAR`. * Object must not contain `METHOD` for stored calendar resources. * Object must contain exactly one primary component type except `VTIMEZONE`. * Supported primary components initially: `VEVENT`, `VTODO`, `VJOURNAL`. * Every stored object must have a stable `UID`. * `UID` must be unique per calendar collection. * Store the normalized iCalendar body bytes. Initial normalization is limited to UTF-8 validation and CRLF line endings unless the selected parser safely canonicalizes without semantic loss. Do not reorder properties or rewrite client data except where explicitly documented, such as adding a generated UID. * Enforce `MAX_CALENDAR_OBJECT_BYTES` before storing the body. * Compute strong ETag from the exact stored bytes plus version. * Update parsed indexes in the same transaction as the body write. Content type: ```text text/calendar; charset=utf-8 ``` ### 18.5 CalDAV REPORT Must implement: ```text CALDAV:calendar-query CALDAV:calendar-multiget DAV:sync-collection ``` Should implement after core compatibility: ```text CALDAV:free-busy-query ``` Initial `calendar-query` filter support: ```text comp-filter VCALENDAR comp-filter VEVENT comp-filter VTODO comp-filter VJOURNAL time-range on VEVENT/VTODO/VJOURNAL when indexed prop-filter UID prop-filter SUMMARY, optional ``` Recurrence: * Correct recurrence expansion is required for serious CalDAV compatibility. * Use a pure ESM iCalendar parser/recurrence library that works in Workers, or implement a bounded recurrence expander. * Bound recurrence expansion by configured max years or max instances to prevent abuse. ### 18.6 Scheduling Scheduling is out of scope for this version. Do not advertise scheduling support. Do not expose scheduling inbox/outbox. Store attendee fields as ordinary iCalendar data only. ## 19. CardDAV support ### 19.1 Scope Implement storage and synchronization CardDAV for address books. Must support: ```text OPTIONS PROPFIND REPORT GET HEAD PUT DELETE PROPPATCH ``` Should support: ```text MKCOL with addressbook resource type, or an API-created address book DAV:sync-collection ``` ### 19.2 Discovery The following must work: * `/.well-known/carddav` redirect * `DAV:current-user-principal` * `CARDDAV:addressbook-home-set` * principal resource under `/principals//` * address book home collection under `/addressbooks/` * default address book under `/addressbooks/default/` Root or principal PROPFIND should allow clients to discover: ```text DAV:current-user-principal DAV:principal-URL DAV:displayname CARDDAV:addressbook-home-set ``` Address book home and address book collection PROPFIND responses should support common compatibility properties where practical: ```text CARDDAV:supported-address-data CARDDAV:max-resource-size CS:getctag ``` ### 19.3 Address book collections An address book collection is a WebDAV collection with resource type: ```xml ``` Rules: * Address book collections MUST NOT be nested inside address book collections. * `/addressbooks/default/` MUST exist for each subject. * Address object resources SHOULD live directly inside an address book collection. ### 19.4 Address object resources Address object paths should look like: ```text /addressbooks/default/.vcf ``` PUT requirements: * Body must be valid vCard. * Initial supported version SHOULD be vCard 3.0 and 4.0 if the parser supports both. * Every stored object must have a stable UID. If a vCard lacks UID, reject it or generate one only if this behavior is explicitly documented. * UID must be unique per address book collection. * Store the normalized vCard body bytes. Initial normalization is limited to UTF-8 validation and CRLF line endings unless the selected parser safely canonicalizes without semantic loss. Do not reorder properties or rewrite client data except where explicitly documented, such as adding a generated UID. * Enforce `MAX_ADDRESS_OBJECT_BYTES` before storing the body. * Compute strong ETag from the exact stored bytes plus version. * Update parsed indexes in the same transaction as the body write. Content type: ```text text/vcard; charset=utf-8 ``` Also accept common client variants like: ```text text/x-vcard text/vcard ``` ### 19.5 CardDAV REPORT Must implement: ```text CARDDAV:addressbook-query CARDDAV:addressbook-multiget DAV:sync-collection ``` Initial `addressbook-query` filter support: ```text FN N EMAIL TEL UID ORG ``` Text matching: * Implement case-insensitive substring matching for initial version. * Document unsupported collations clearly in error responses. ## 20. Principal and minimal ACL behavior CalDAV and CardDAV clients expect principal and privilege properties even if user-managed sharing is not implemented. Implement fixed self-owner semantics: * Each subject owns all resources on their subject host. * No other subject has access. * No groups in initial version. * No editable ACLs in initial version. Principal URLs: ```text /principals/me/ /principals// ``` Live properties to support where requested: ```text DAV:current-user-principal DAV:principal-URL DAV:displayname DAV:owner DAV:current-user-privilege-set DAV:supported-privilege-set DAV:acl, optional DAV:acl-restrictions, optional DAV:inherited-acl-set, optional DAV:principal-collection-set ``` Scoped discovery rules: * Any valid DAV PAT may access root and principal discovery. * Discovery responses only advertise homes and privileges allowed by the PAT scopes. * A files-only PAT does not receive writable CalDAV or CardDAV privileges. * A CalDAV-only PAT can discover `DAV:current-user-principal` and `CALDAV:calendar-home-set`. * A CardDAV-only PAT can discover `DAV:current-user-principal` and `CARDDAV:addressbook-home-set`. Privilege model: * Authenticated matching subject gets read and write privileges according to PAT scopes or session auth. * A read-only PAT gets read privilege only. * A write PAT gets read and write privilege for its protocol area. If `ACL` method is implemented: * Return `403 Forbidden` for attempts to alter fixed ACLs. * Do not advertise mutable ACL support. ## 21. ETags and sync tokens ### 21.1 ETags ETags MUST be strong validators unless explicitly documented otherwise. Rules: * Any body change changes ETag. * Any relevant metadata change that clients observe as resource state SHOULD change ETag. * Collection ETags are optional but modified time/sync token must update when children change. ### 21.2 Sync tokens Use monotonic integer sequence tokens encoded as strings: ```text sync: ``` Rules: * Increment sequence on create/update/delete. * Store one change entry per changed href. * Keep tombstones for a retention window. * If client token is older than retained history, return a sync error requiring full resync. ## 22. Queues and background work ### 22.1 Use Queues for these tasks Use queues for idempotent, retryable, non-interactive tasks: * delete orphan R2 blobs * delete unreferenced R2 blobs after refcount reaches zero * clean expired pending uploads * write audit projections * run consistency repair checks * recompute optional search indexes later Queue messages must be small and idempotent. Example GC message shape: ```json { "type": "r2_blob_gc", "subject_id": "tessera-sub", "storage_id": "stg_example", "blob_id": "blob_abc123", "blob_key": "blobs/stg_example/blob_abc123", "not_before_ms": 1767225600000 } ``` Before deleting a blob, the consumer MUST verify with FILE_DAV that the blob is still unreferenced. ### 22.2 Workflows Do not require Workflows for the initial implementation. Workflows MAY be added later for: * full subject deletion across all namespaces and R2 blobs * export archive generation * long-running import jobs * schema migration across many subjects * repair jobs that need durable progress and observability ## 23. Error handling ### 23.1 DAV errors Use correct HTTP status codes: ```text 200 OK 201 Created 204 No Content 207 Multi-Status 400 Bad Request 401 Unauthorized 403 Forbidden 404 Not Found 405 Method Not Allowed 409 Conflict 412 Precondition Failed 415 Unsupported Media Type 423 Locked 500 Internal Server Error 507 Insufficient Storage ``` For DAV XML errors, return XML bodies where clients expect XML. ### 23.2 API errors API routes return JSON error objects: ```json { "error": { "code": "forbidden", "message": "The authenticated subject does not own this DAV host." } } ``` Do not expose secrets, token digests, internal Durable Object ids, stack traces, or raw database errors. ## 24. Security requirements * Enforce HTTPS only in production. * Enforce host owner subject match for every request. * Enforce same-origin checks on cookie-authenticated mutating API routes. * Do not store plaintext PATs. * Do not log PATs, Authorization headers, session cookies, OIDC codes, or OIDC transaction cookies. * Cap request body sizes by product policy and platform limits. * Enforce `MAX_FILE_BYTES` before or during R2 upload streaming. * Cap XML body sizes. * Disable or reject unsafe XML entity behavior. * Defend against XML entity expansion attacks. * Bound Depth: infinity responses. * Bound COPY, MOVE, PROPFIND, multiget, and sync responses by node/object count. * Bound CalDAV recurrence expansion. * Bound CardDAV text-match work. * Use request-local subrequest and concurrency limiting for R2 and Durable Object operations. * Use constant-time comparison for token digests. * Rate limit login, PAT verification failures, and expensive REPORT requests. * Reject cross-host Destination headers. * Reject path traversal and malformed percent encodings. * Use separate HKDF context strings for OIDC transaction cookies and session cookies. ## 25. Observability Log structured events with these fields where applicable: ```text eventType requestId subjectId hostLabel method pathPrefix status latencyMs userAgentHash ipHash davDepth davDestinationHost davOverwrite davIfPresent litmusHeader errorCode ``` For litmus debugging, log: ```text X-Litmus X-Litmus-Second Depth Destination Overwrite If Lock-Token ``` Never log full Authorization headers or PAT token strings. ## 26. Testing plan ### 26.1 Test location Worker tests live under: ```text tests/worker/ ``` Use: ```text vitest @cloudflare/vitest-pool-workers @mongodb-js/oidc-mock-provider tsx ``` The test environment must run tests inside the Workers runtime through the Cloudflare Vitest pool. ### 26.2 litmus local tool Before implementing `/files/`, clone and compile the notroj litmus WebDAV test suite into an ignored local tools directory. Required local layout: ```text vendor/litmus/ -- git clone of https://github.com/notroj/litmus vendor/litmus/src/ -- litmus source tree vendor/litmus/litmus -- compiled binary, or the platform-specific build output ``` `.gitignore` MUST ignore: ```text vendor/litmus/ ``` The implementation repository MUST NOT commit the litmus third-party source, generated configure output, object files, or compiled binary. Add a short script or documented command that: * clones `https://github.com/notroj/litmus` if `vendor/litmus/` is absent, * builds litmus for the local platform, * prints the resolved litmus binary path. Add a separate local runner command for the DAV server under test. Follow the sister-project E2E harness pattern used by the existing limic.dev projects: allocate a free loopback port, create a per-run temp persistence directory, apply local D1 migrations into that directory, write a per-run `.dev.vars.` file, spawn Vite directly, wait for a health endpoint, run the compatibility client, then terminate the process tree and remove the temp state unless preservation is requested. Recommended command names: ```text npm run litmus:setup npm run test:litmus npm run litmus:fixture npm run litmus:files ``` `npm run test:litmus` is the primary command the implementation agent runs. It MUST: * run or require `npm run litmus:setup`, * create `mkdtemp(os.tmpdir(), "dab-litmus-")`, * allocate a free port by briefly binding `127.0.0.1:0`, * apply D1 migrations with `wrangler d1 migrations apply ... --local --persist-to `, * write `.dev.vars.` with local-only fixture settings, * spawn `node node_modules/vite/bin/vite.js dev --host 127.0.0.1 --port --strictPort`, * set `CLOUDFLARE_ENV=` and a project-specific persistence env var such as `DAB_PERSIST_STATE_PATH=` on the spawned process, * pipe stdout and stderr to files inside the temp directory, * wait until `/healthz` returns 200 and exits early if the process dies before readiness, * create or reset the litmus fixture against that server, * run `vendor/litmus/litmus` against the fixture URL, * stop the process tree with SIGTERM then SIGKILL on timeout, * remove `.dev.vars.` and the temp directory unless `DAB_LITMUS_PRESERVE=1`. `npm run litmus:fixture` creates or resets a throwaway local subject and PAT for litmus. When called by `test:litmus`, it receives the local control-plane URL and port from the harness. It must print: ```text LITMUS_URL=http://.dab.localhost:/files/ LITMUS_USERNAME= LITMUS_PAT= ``` The fixture path may be implemented as a dev/test-only script or route, but it MUST be gated so production cannot create arbitrary subjects or PATs: * enabled only when `DAB_ENABLE_TEST_FIXTURES=1`, * allowed only for loopback requests or direct local script execution, * never deployed as an enabled production capability, * creates a throwaway subject id such as `litmus-local-subject`, * creates a subject host label, storage id, AUTH PAT row, D1 subject row, and FILE_DAV root, * cleans or rotates the fixture before every run so litmus starts with an empty `/files/` namespace. For local host routing, use the reserved localhost domain. `*.dab.localhost:<port>` resolves to loopback: ```text CONTROL_PLANE_HOST=dab.localhost SUBJECT_HOST_SUFFIX=.dab.localhost ``` The harness starts one local Worker on the discovered port and sends both control-plane and subject-host traffic to that process: ```text http://dab.localhost:49321/healthz http://river-copper-lantern-velvet-maple.dab.localhost:49321/files/ ``` If the Vite/Worker dev server rejects non-localhost Host headers, configure `server.allowedHosts` for `dab.localhost` and `.dab.localhost`. Do not change production host validation to make local testing easier. `npm run litmus:files` is the lower-level command used by `test:litmus` after fixture creation. It runs approximately: ```text vendor/litmus/litmus -k "$LITMUS_URL" "$LITMUS_USERNAME" "$LITMUS_PAT" ``` litmus is destructive: it creates and deletes resources, including a `litmus` collection, under the target collection. Always run it against the throwaway fixture subject, never against a real subject. Do not run remote DAV tests automatically in normal `npm test`; litmus is an explicit compatibility command because it needs a running Worker and real DAV credentials. ### 26.3 OIDC tests Use `@mongodb-js/oidc-mock-provider` for tessera OIDC tests. Tests must cover: * discovery through `TESSERA_OIDC_ISSUER` * login redirect creation * encoded and signed OIDC transaction cookie * callback state validation * nonce validation * PKCE handling through openid-client * subject bootstrap * five-word opaque host label creation, validation, uniqueness retry, and malformed-label rejection * session creation * session cookie attributes * same-origin rejection on cookie-authenticated mutating routes * invalid callback rejection ### 26.4 Unit tests Must cover: * path normalization * host label parsing * host label to subject authorization * OIDC transaction cookie signing and verification * session cookie signing and verification * exact PAT grammar parsing and digest verification * Basic auth parsing * XML parse/serialize helpers * PROPFIND request parsing * PROPPATCH atomic behavior * WebDAV If header parser * Destination header parsing * lock conflict detection * ETag precondition evaluation * iCalendar validation and indexing * vCard validation and indexing * CalDAV filter evaluation * CardDAV filter evaluation * OpenAPI document validation * isolated recursive CTE helpers, if any are added through Drizzle `sql` ### 26.5 Integration tests Must cover: * new subject bootstrap * API OIDC callback mock creates subject/session * PAT create/list/revoke * PAT create returns plaintext once and stores only `token_digest` * Basic auth PAT grants DAV access * Basic auth PAT rejected on wrong subject host * D1 `.batch()` bootstrap leaves no partial subject/PAT projection state on failure where D1 atomicity is required * WebDAV file CRUD * WebDAV MKCOL, COPY, MOVE, DELETE * WebDAV LOCK/UNLOCK and lock refresh * dead props round trip * CalDAV default calendar discovery * CalDAV PUT/GET/REPORT calendar-query * CalDAV calendar-multiget * CardDAV default address book discovery * CardDAV PUT/GET/REPORT addressbook-query * CardDAV addressbook-multiget * sync-collection create/update/delete ### 26.6 External compatibility tests Required: ```text vendor/litmus/litmus https://<five_word_host_label>.dav.example.com/files/ <username> <pat> ``` Also test manually with at least: * macOS Finder WebDAV, if practical * macOS Calendar with CalDAV * macOS Contacts with CardDAV * Thunderbird or another open CalDAV/CardDAV client * curl for raw PROPFIND/REPORT examples ## 27. Implementation phases ### Phase 0 - Foundation and risk spikes Goal: Create a deployable Worker skeleton and retire the highest compatibility unknowns before product logic depends on them. Deliverables: * TypeScript 6 Worker project. * Hono app shell with host classifier and route groups. * Vite config with `@cloudflare/vite-plugin`. * `wrangler.jsonc` with D1, R2, Durable Object, Queue, rate limit, observability, and route bindings. * `.dev.vars.example` with local non-secret defaults. * `worker-configuration.d.ts` generated by Wrangler. * Drizzle config for D1 and each Durable Object schema. * Empty Drizzle schema modules and generated initial migrations. * Durable Object classes exported from `src/worker/index.ts`. * Prettier config. * Vitest config with `@cloudflare/vitest-pool-workers`. * Test tree under `tests/worker/`. * Code-first OpenAPI route/schema skeleton. * `.gitignore` entry for `vendor/litmus/`. * Local litmus setup command or script that clones and compiles `https://github.com/notroj/litmus` into `vendor/litmus/`. * Local isolated `test:litmus` harness that starts a temp-state Worker on `127.0.0.1:<free_port>` and tests a subject host under `*.dab.localhost:<free_port>`. * Compatibility spike for `fast-xml-parser` namespace preservation and dead-property round trips. * Compatibility spike selecting Worker-safe iCalendar, recurrence, and vCard parser libraries, or documenting why bounded local parsers will be implemented. Acceptance criteria: * `npm run typecheck` passes after `wrangler types`. * `npm run format:check` passes. * A worker-runtime smoke test reaches `/api/v1/openapi.json`. * Durable Object constructor migrations run in the worker test pool. * Drizzle migrations are generated from schema sources, not hand-written table prose. * `vendor/litmus/` is ignored by git. * The local litmus setup command produces a runnable litmus binary or documents missing system build prerequisites. * `npm run test:litmus` can start and stop an empty isolated Worker, write and remove its per-run `.dev.vars.<env>` file, and reach `/healthz` through `http://dab.localhost:<free_port>/`. * XML spike proves namespace URI plus local-name handling, rejects unsafe entity behavior, and round-trips a namespaced dead property. * Calendar/contact parser spike documents the chosen packages and includes a minimal Worker-runtime import test. ### Phase 1 - Auth, subject bootstrap, and control-plane API Goal: Implement secure control-plane identity, subject provisioning, and PAT management without any DAV protocol storage dependency. Deliverables: * tessera OIDC login/callback/logout API using `openid-client`. * OIDC transaction cookie with HKDF-derived signing key and `__Host-` cookie settings. * API session cookie with `__Host-` cookie settings. * Same-origin enforcement for cookie-authenticated mutating API routes. * AUTH Durable Object Drizzle schema, migrations, and session/PAT repository. * D1 `subjects`, `patProjections`, and `auditEvents` schema, migrations, and repositories. * Subject bootstrap in `DAV_CONTROL_PLANE` using D1 `.batch()` where multiple D1 statements must commit together. * Five-word opaque host label generation. * PAT create/list/detail/update/delete-as-revoke API. * Exact PAT grammar, SHA-256 full-token digest storage, one-time plaintext return, and constant-time digest verification. * Basic auth PAT verification through AUTH. * Host-label owner authorization invariant. * Code-first OpenAPI descriptions for all Phase 1 JSON API routes. Acceptance criteria: * Worker tests cover OIDC discovery, state, nonce, PKCE, transaction cookie signing, invalid callback rejection, and subject bootstrap. * Login callback creates one subject row, one stable internal storage id, and one stable opaque host label for a new tessera `sub`. * Repeated login for the same `sub` does not rotate the host label. * Generated host labels use exactly five checked-in wordlist words and do not exceed 63 characters. * Session cookie and OIDC transaction cookie are `__Host-`, `HttpOnly`, `Secure`, `SameSite=Lax` or stricter, `Path=/`, and host-only. * Non-safe API requests with missing or foreign `Origin` fail before session lookup. * PAT create returns plaintext exactly once; list/detail responses never include token plaintext. * Revoked, expired, malformed, or wrong-subject PATs fail closed. * OpenAPI JSON and YAML parse and describe every implemented `/api/v1/` route. ### Phase 2 - WebDAV files and litmus Goal: Deliver a standards-compatible `/files/` WebDAV implementation with Durable Object metadata, R2 byte streaming, and no large-body DO hop. Deliverables: * FILE_DAV Drizzle schema and migrations. * File namespace root creation and metadata repositories. * Path normalization, Destination parsing, ETag precondition evaluation, and WebDAV `If` header parser. * `/files/` support for `OPTIONS`, `PROPFIND`, `PROPPATCH`, `MKCOL`, `GET`, `HEAD`, `PUT`, `DELETE`, `COPY`, `MOVE`, `LOCK`, and `UNLOCK`. * R2 one-hop streaming for GET/HEAD/PUT. * Pending upload intent, commit, abort, and orphan GC flow. * BLOB_GC queue consumer for orphaned and unreferenced blobs. * Dead property storage and namespace-aware multistatus serialization. * Class 2 lock support required by litmus. * Documented `npm run test:litmus` command using the Phase 0 local binary and isolated server harness. * Request-local limiter around R2 and Durable Object calls. * Bounds for `Depth: infinity`, recursive DELETE, COPY, MOVE, and PROPFIND response size. Acceptance criteria: * `npm run test:litmus` passes notroj litmus from `vendor/litmus/` against `/files/` on a throwaway `*.dab.localhost:<free_port>` subject. * Worker tests cover file CRUD, MKCOL, PROPPATCH atomicity, dead property round trip, COPY/MOVE overwrite behavior, locks, lock refresh, `If` header evaluation, and wrong-host Destination rejection. * PUT streams to R2 without buffering the full body in Worker memory and without routing bytes through FILE_DAV. * If R2 succeeds but FILE_DAV commit fails, BLOB_GC receives an idempotent cleanup message. * Recursive operations use Drizzle query builders except for an isolated, tested `sql` recursive CTE if needed. * OPTIONS does not advertise features that are not implemented. ### Phase 3 - Principal discovery and fixed ACL properties Goal: Make CalDAV/CardDAV/WebDAV clients able to discover the authenticated principal and protocol homes before protocol-specific object storage ships. Deliverables: * `/principals/`, `/principals/me/`, and `/principals/<five_word_host_label>/`. * Root discovery handler on subject hosts. * `/.well-known/caldav` and `/.well-known/carddav` redirects. * `DAV:current-user-principal`. * `DAV:principal-URL`. * `DAV:owner`. * `DAV:current-user-privilege-set`. * `DAV:supported-privilege-set`. * `CALDAV:calendar-home-set`. * `CARDDAV:addressbook-home-set`. * Fixed self-owner ACL behavior and optional immutable ACL property responses. Acceptance criteria: * Worker tests cover root/principal PROPFIND for files-only, CalDAV, and CardDAV discovery properties. * Read-only PATs produce read-only current-user privileges for the matching protocol area. * Write PATs produce read/write privileges only for their scoped protocol area. * Unknown host labels, wrong-subject PATs, and control-plane DAV paths fail closed. * No tessera subject id appears in public DAV hrefs. ### Phase 4 - CardDAV storage and reports Goal: Deliver address book storage and synchronization for common CardDAV clients. Deliverables: * CARD_DAV Drizzle schema and migrations. * Default address book creation for every subject. * Address book PROPFIND and PROPPATCH. * vCard parser/validator integration or bounded local parser from Phase 0. * vCard PUT/GET/HEAD/DELETE. * UID uniqueness enforcement per address book. * Parsed address index updates in the same Durable Object transaction as body writes. * `CARDDAV:addressbook-query`. * `CARDDAV:addressbook-multiget`. * `DAV:sync-collection`. * JSON API routes for address book management. * OpenAPI descriptions for address book JSON API routes. Acceptance criteria: * Worker tests cover default address book discovery, vCard PUT/GET/DELETE, UID conflicts, malformed vCards, addressbook-query filters, addressbook-multiget, and sync-collection tombstones. * macOS Contacts or Thunderbird can discover the service and read/write a contact in manual testing, if practical. * Address object bodies and parsed indexes commit atomically in CARD_DAV. * REPORT handlers enforce result and text-match bounds. * Read-only CardDAV PATs cannot write address books or objects. ### Phase 5 - CalDAV storage and reports Goal: Deliver calendar storage and synchronization for common CalDAV clients without scheduling support. Deliverables: * CAL_DAV Drizzle schema and migrations. * Default calendar creation for every subject. * Calendar PROPFIND, PROPPATCH, and MKCALENDAR. * iCalendar parser/validator and bounded recurrence expansion from Phase 0. * iCalendar PUT/GET/HEAD/DELETE. * UID uniqueness enforcement per calendar. * Parsed calendar index updates in the same Durable Object transaction as body writes. * `CALDAV:calendar-query`. * `CALDAV:calendar-multiget`. * `DAV:sync-collection`. * JSON API routes for calendar management. * OpenAPI descriptions for calendar JSON API routes. Acceptance criteria: * Worker tests cover default calendar discovery, MKCALENDAR, iCalendar PUT/GET/DELETE, UID conflicts, malformed iCalendar, calendar-query filters, recurrence bounds, calendar-multiget, and sync-collection tombstones. * macOS Calendar or Thunderbird can discover the service and read/write an event in manual testing, if practical. * Scheduling inbox/outbox and scheduling capabilities are not advertised. * Calendar object bodies and parsed indexes commit atomically in CAL_DAV. * Read-only CalDAV PATs cannot write calendars or objects. ### Phase 6 - Hardening and operations Goal: Make the implementation operationally safe: bounded, observable, repairable, and ready for real client compatibility testing. Deliverables: * BLOB_GC retry and stale-intent hardening. * REPAIR_JOBS queue consumer. * Expired pending upload cleanup. * Expired lock cleanup. * Audit projection queue or synchronous audit writes with bounded failure behavior. * Structured logging with request IDs and safe hashed user-agent/IP fields. * Rate-limit wiring for OIDC, PAT failures, expensive PROPFIND, and REPORT. * OpenAPI contract tests. * External client testing matrix. * Operator notes for required secrets, resource creation, migrations, and litmus/manual DAV verification. * Storage repair commands or documented queue message shapes for operator-triggered repair. Acceptance criteria: * Worker tests cover queue message idempotency, stale upload cleanup, expired lock cleanup, repair no-ops, and failure retry paths. * OpenAPI JSON/YAML equivalence test passes. * Load-shaped tests or focused worker tests prove configured bounds are enforced for large XML bodies, Depth infinity, multiget, REPORT, recurrence expansion, and CardDAV text match. * Logs include enough structured context to debug litmus without logging Authorization headers, PATs, cookies, OIDC codes, file bodies, calendar bodies, or vCards. * Manual compatibility results are recorded for litmus and at least one CalDAV/CardDAV client, or the missing manual coverage is explicitly documented. ## 28. Agent implementation rules An implementation agent MUST follow these rules: * Do not introduce a tenant model. * Do not expose tessera subject ids in public DAV URLs. * Do use opaque host labels. * Do not route large file bodies through Durable Objects. * Do not use R2 listing as filesystem truth. * Do not store plaintext PATs. * Do not skip WebDAV lock semantics for litmus. * Do not implement CalDAV/CardDAV as plain files under `/files/`. * Do not claim DAV capabilities in OPTIONS before they work. * Do not add UI pages. * Do implement JSON APIs for future UI use. * Do define JSON APIs with code-first Hono/Zod OpenAPI route schemas. * Do use Hono for routing and middleware. * Do use openid-client for tessera OIDC. * Do use fast-xml-parser for XML parsing/building. * Do use Drizzle ORM for D1 and Durable Object SQLite. * Do define schemas with Drizzle `sqliteTable` declarations. * Do use Drizzle boolean mode for boolean-shaped SQLite columns. * Do use typed `text(...).$type<Union>()` plus check constraints for enum-shaped columns. * Do use D1 `.batch()` for required multi-statement D1 atomicity. * Do not use D1 `.transaction()`. * Do not bypass Drizzle for application database access. * Do not use raw SQL except for isolated, tested Drizzle `sql` helpers when required for recursive CTEs or another SQLite feature that Drizzle query builders cannot express. * Do keep XML namespace handling correct. * Do keep schemas migration-friendly. * Do use R2 only for file blobs in the initial version. * Do make every queue job idempotent. * Do keep worker tests under `tests/worker/`. * Do keep source files modular and avoid 1000-line files. ## 29. Reference specs and docs Use these as primary references during implementation: Local plaintext copies of the required RFCs live under `reference/rfcNNNN.txt`. ```text WebDAV RFC 4918: https://datatracker.ietf.org/doc/html/rfc4918 CalDAV RFC 4791: https://datatracker.ietf.org/doc/html/rfc4791 CardDAV RFC 6352: https://www.rfc-editor.org/rfc/rfc6352.html CalDAV/CardDAV service discovery RFC 6764: https://datatracker.ietf.org/doc/html/rfc6764 WebDAV ACL RFC 3744: https://datatracker.ietf.org/doc/html/rfc3744 WebDAV Sync RFC 6578: https://datatracker.ietf.org/doc/html/rfc6578 Extended MKCOL RFC 5689: https://datatracker.ietf.org/doc/html/rfc5689 vCard RFC 6350: https://datatracker.ietf.org/doc/html/rfc6350 iCalendar RFC 5545: https://datatracker.ietf.org/doc/html/rfc5545 Basic auth RFC 7617: https://datatracker.ietf.org/doc/html/rfc7617 OpenAPI Specification: https://swagger.io/specification/ openid-client: https://www.npmjs.com/package/openid-client fast-xml-parser: https://www.npmjs.com/package/fast-xml-parser Drizzle Cloudflare D1: https://orm.drizzle.team/docs/connect-cloudflare-d1 Drizzle Cloudflare Durable Objects SQLite: https://orm.drizzle.team/docs/connect-cloudflare-do Hono Cloudflare Workers: https://hono.dev/docs/getting-started/cloudflare-workers Cloudflare Vite plugin: https://developers.cloudflare.com/workers/vite-plugin/ Cloudflare Workers Vitest integration: https://developers.cloudflare.com/workers/testing/vitest-integration/ litmus WebDAV test suite: https://notroj.github.io/litmus/ ```