Skip to content
File

Blob: SPEC.md

Markdown3209 lines

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:

https://<five_word_host_label>.dav.example.com/

The root host is the control-plane host:

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:

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:

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:

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:

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: <five_word_host_label>.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:

<five_word_host_label>.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:

river-copper-lantern-velvet-maple.dav.example.com

The Worker MUST reject unknown or malformed host labels with 404 Not Found.

Authorization invariant:

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:

/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:

/
/.well-known/caldav
/.well-known/carddav
/files/
/calendars/
/calendars/default/
/addressbooks/
/addressbooks/default/
/principals/
/principals/me/
/principals/<five_word_host_label>/

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/<five_word_host_label>/ 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

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:<storage_id>)
  |
  +--> FILE_DAV(files:<storage_id>)
  |
  +--> CAL_DAV(cal:<storage_id>)
  |
  +--> CARD_DAV(card:<storage_id>)
  |
  +--> 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:

Worker -> D1
Worker -> R2
Worker -> Durable Object
Worker -> Queue

Avoid for large bodies:

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:

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:

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:

AuthObject
FileDavObject
CalDavObject
CardDavObject

Wrangler binding names should remain:

AUTH
FILE_DAV
CAL_DAV
CARD_DAV

6.5 Wrangler configuration

wrangler.jsonc is the source of truth for runtime bindings.

Required shape:

{
  "$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:

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:

blobs/<storage_id>/<blob_id>

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:

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:

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<Record<string, unknown>>().
  • digests are lowercase hex unless a schema comment says otherwise.
  • enum-like columns use typed text(...).$type<MyUnion>() 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:

import { sql } from "drizzle-orm";
import {
  check,
  index,
  integer,
  primaryKey,
  sqliteTable,
  text,
  uniqueIndex,
  type AnySQLiteColumn,
} from "drizzle-orm/sqlite-core";

Shared schema types:

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<string, unknown>;
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.
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<DavScope[]>(),
    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<JsonObject>(),
  },
  (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.

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<DavScope[]>(),
    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<JsonObject>(),
  },
  (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.

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<NodeKind>(),
    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<LockScope>(),
    depth: text("depth").notNull().$type<LockDepth>(),
    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<PendingUploadState>(),
  },
  (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<FileChangeType>(),
    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.

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<CalendarComponentType>(),
    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<CalendarComponentType>(),
    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<Extract<DavResourceKind, "home" | "calendar" | "object">>(),
    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<DavChangeType>(),
    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.

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<string[]>(),
    tels: text("tels_json", { mode: "json" }).notNull().$type<string[]>(),
  },
  (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<Extract<DavResourceKind, "home" | "addressbook" | "object">>(),
    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<DavChangeType>(),
    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:

.
  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:

AuthObject
FileDavObject
CalDavObject
CardDavObject
default fetch handler from the Hono app

src/worker/app.ts should compose:

Host: dav.example.com
  /healthz              -> health check
  /api/v1/*             -> JSON API routes

Host: <five_word_host_label>.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:

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:

GET /api/v1/openapi.json
GET /api/v1/openapi.yaml

Also implement an unauthenticated control-plane health route outside the JSON API version tree:

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:

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:

TESSERA_OIDC_ISSUER

Use exactly these secret names:

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:

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:

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:

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:

state
nonce
codeVerifier
returnTo
createdAtMs
expiresAtMs

Cookie format:

base64url(json_payload).base64url(hmac_signature)

Signature key:

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:

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:

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:

dab_pat_<public_id>_<secret>

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:

PAT row id:  dab_pat_<24 or more lowercase hex chars>
PAT token:   dab_pat_<same public_id>_<52 or more lowercase base32 chars>
PAT regex:   ^dab_pat_([0-9a-f]{24,})_([a-z2-7]{52,})$

Stored verifier:

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:

Authorization: Basic base64(<username>:<pat>)

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:

dav:files:read
dav:files:write
dav:caldav:read
dav:caldav:write
dav:carddav:read
dav:carddav:write

Convenience presets for the API:

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:

{
  "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:

{
  "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:

{
  "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 <five_word_host_label>.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:

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:

OPTIONS
PROPFIND
PROPPATCH
MKCOL
GET
HEAD
PUT
DELETE
COPY
MOVE
LOCK
UNLOCK

Optional later:

PATCH
REPORT sync-collection

Do not implement WebDAV SEARCH in this version.

17.2 OPTIONS

For /files/, return headers similar to:

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:

DAV:resourcetype
DAV:getcontentlength
DAV:getcontenttype
DAV:getetag
DAV:getlastmodified
DAV:creationdate
DAV:displayname
DAV:supportedlock
DAV:lockdiscovery

Live properties for collections:

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:

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:

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:

opaquelocktoken:<uuid>

The response to a new lock MUST include:

Lock-Token: <opaquelocktoken:...>
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:

If: (<opaquelocktoken:...>)
If: </files/a.txt> (<opaquelocktoken:...>)
If: (<opaquelocktoken:...> ["etag"])
If: (Not <opaquelocktoken:...>)

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:

OPTIONS
PROPFIND
REPORT
GET
HEAD
PUT
DELETE
MKCALENDAR
PROPPATCH

May support later:

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/<five_word_host_label>/
  • calendar home collection under /calendars/
  • default calendar collection under /calendars/default/

Root or principal PROPFIND should allow clients to discover:

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:

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:

<D:resourcetype>
  <D:collection/>
  <C:calendar/>
</D:resourcetype>

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:

/calendars/default/<object_id>.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/calendar; charset=utf-8

18.5 CalDAV REPORT

Must implement:

CALDAV:calendar-query
CALDAV:calendar-multiget
DAV:sync-collection

Should implement after core compatibility:

CALDAV:free-busy-query

Initial calendar-query filter support:

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:

OPTIONS
PROPFIND
REPORT
GET
HEAD
PUT
DELETE
PROPPATCH

Should support:

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/<five_word_host_label>/
  • address book home collection under /addressbooks/
  • default address book under /addressbooks/default/

Root or principal PROPFIND should allow clients to discover:

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:

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:

<D:resourcetype>
  <D:collection/>
  <CARD:addressbook/>
</D:resourcetype>

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:

/addressbooks/default/<object_id>.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/vcard; charset=utf-8

Also accept common client variants like:

text/x-vcard
text/vcard

19.5 CardDAV REPORT

Must implement:

CARDDAV:addressbook-query
CARDDAV:addressbook-multiget
DAV:sync-collection

Initial addressbook-query filter support:

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:

/principals/me/
/principals/<five_word_host_label>/

Live properties to support where requested:

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:

sync:<seq>

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:

{
  "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:

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:

{
  "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:

eventType
requestId
subjectId
hostLabel
method
pathPrefix
status
latencyMs
userAgentHash
ipHash
davDepth
davDestinationHost
davOverwrite
davIfPresent
litmusHeader
errorCode

For litmus debugging, log:

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:

tests/worker/

Use:

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:

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:

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.<env> 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:

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 <temp_dir>,
  • write .dev.vars.<cloudflare_env> with local-only fixture settings,
  • spawn node node_modules/vite/bin/vite.js dev --host 127.0.0.1 --port <port> --strictPort,
  • set CLOUDFLARE_ENV=<cloudflare_env> and a project-specific persistence env var such as DAB_PERSIST_STATE_PATH=<temp_dir> 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.<cloudflare_env> 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:

LITMUS_URL=http://<host_label>.dab.localhost:<port>/files/
LITMUS_USERNAME=<host_label>
LITMUS_PAT=<plaintext PAT shown once for this fixture>

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:

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:

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:

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:

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.

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/