Blob: SPEC.md
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. tesserais the OIDC provider name, always lowercase. Do not use tessera as the service name.MAX_FILE_BYTESis configurable and defaults to100000000bytes. 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.idis the tessera OIDCsub;subjects.storageIdis the internal storage and Durable Object routing id.- R2 blob keys and Durable Object names use
storageId, not raw tesserasub. - 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 throughtsx.
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
prettierThe 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
yamlDo 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
tsxTypeScript 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 validator4. 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
subvalue. - 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.comOpaque 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
Hostfor 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.localhostwith 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.comThe Worker MUST reject unknown or malformed host labels with 404 Not Found.
Authorization invariant:
host owner subject id == authenticated subject idIf 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/caldavMUST redirect to/or/calendars/. Prefer/if principal discovery is implemented at root./.well-known/carddavMUST redirect to/or/addressbooks/. Prefer/if principal discovery is implemented at root.- Use
301,302, or307for well-known redirects. Avoid relying on308until 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 Queue6.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 -> QueueAvoid for large bodies:
Worker -> Durable Object -> R2Durable 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 mutateas 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
throwfor unrecoverable failures. - Durable Objects MUST NOT query or mutate
DAV_CONTROL_PLANED1 from normal RPC methods. If reconciliation with D1 is required, use an idempotent alarm or queue-driven reconciliation path. - Use
blockConcurrencyWhileonly 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
ensureInitializedagain. - 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 requestsDo 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/export6.4 Durable Object class names
Binding names and class names are different concerns.
Suggested class names:
AuthObject
FileDavObject
CalDavObject
CardDavObjectWrangler binding names should remain:
AUTH
FILE_DAV
CAL_DAV
CARD_DAV6.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_BYTESis a product limit and must be parsed as a configurable byte count. The default is100000000bytes, 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_BYTESandMAX_ADDRESS_OBJECT_BYTEScap stored.icsand.vcfbodies. The default is1048576bytes each.- The rate-limit
namespace_idvalues 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_SECRETDevelopment-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
sqltagged template only when Drizzle cannot express a required SQLite feature, such as a recursive CTE for subtree traversal. Thesqlusage 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.tsEach 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
integermilliseconds since Unix epoch. - booleans use
integer("column_name", { mode: "boolean" }); do not manually expose0and1as 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 Drizzlecheckconstraint 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.jsvendor/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 appsrc/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 handlerUse 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-openapiHono'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.yamlAlso implement an unauthenticated control-plane health route outside the JSON API version tree:
GET /healthzGET /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/delete11.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_ISSUERUse exactly these secret names:
TESSERA_OIDC_CLIENT_ID
TESSERA_OIDC_CLIENT_SECRET
DAB_SESSION_SECRETTESSERA_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-dev12.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_ISSUERDo 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 requested12.4 OIDC transaction cookie
The OIDC transaction cookie MUST be encoded and signed.
Cookie contents:
state
nonce
codeVerifier
returnTo
createdAtMs
expiresAtMsCookie 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:
HttpOnlySecureSameSite=Laxor stricter- short TTL, recommended 5 to 10 minutes
__Host-cookie prefix through Hono'sprefix: "host"optionPath=/- no
Domainattribute - 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
expiresAtMsAUTH 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_idis lowercase hex and contains at least 96 bits of entropy.secretis 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:writeConvenience 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:* scopesMapping:
- safe read methods require read scope
- write methods require write scope
LOCKandUNLOCKrequire write scopePROPPATCHrequires write scopeREPORTrequires 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=Laxor 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, andOPTIONSskip the check. - Non-safe methods require an
Originheader 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:
Classify host as control-plane host
dav.example.comor subject host<five_word_host_label>.dav.example.com.For control-plane host requests:
- route
/healthzto 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 Foundfor DAV storage paths
- route
For subject host requests, parse and validate opaque host label.
Resolve host label to subject id and storage id via DAV_CONTROL_PLANE or a short-lived cache backed by DAV_CONTROL_PLANE.
If path is
/.well-known/caldavor/.well-known/carddav, handle discovery redirect.Otherwise authenticate as DAV using PAT Basic auth.
Enforce host owner subject id equals authenticated subject id.
Route by path prefix:
/files/-> FILE_DAV plus FILE_BLOBS as needed/calendars/-> CAL_DAV/addressbooks/-> CARD_DAV/principals/-> principal handler/-> root DAV handler
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, andD:hrefelements.
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 construction17. 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
UNLOCKOptional later:
PATCH
REPORT sync-collectionDo 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: DAVDo not claim DAV features before they work.
17.3 PROPFIND
MUST support:
Depth: 0Depth: 1Depth: infinity, at least with sane limitsallproppropname- 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:lockdiscoveryLive properties for collections:
DAV:resourcetype with DAV:collection
DAV:getlastmodified
DAV:creationdate
DAV:displayname
DAV:supportedlock
DAV:lockdiscovery17.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-Statusresponse 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 WebDAVIflock tokens. - Do not buffer full request bodies in Worker memory.
- Enforce
MAX_FILE_BYTESusingContent-Lengthpreflight 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
DestinationandOverwrite. - 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: 0Depth: infinityfor collections- lock refresh using
Ifheader 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-8UNLOCK 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
PROPPATCHMay support later:
COPY
MOVE
LOCK
UNLOCK
free-busy-query
scheduling inbox/outbox18.2 Discovery
The following must work:
/.well-known/caldavredirectDAV:current-user-principalCALDAV: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-setCalendar 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:getctag18.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>.icsPUT requirements:
- Body must be valid iCalendar.
- Top-level component must be
VCALENDAR. - Object must not contain
METHODfor 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. UIDmust 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_BYTESbefore 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-818.5 CalDAV REPORT
Must implement:
CALDAV:calendar-query
CALDAV:calendar-multiget
DAV:sync-collectionShould implement after core compatibility:
CALDAV:free-busy-queryInitial 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, optionalRecurrence:
- 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
PROPPATCHShould support:
MKCOL with addressbook resource type, or an API-created address book
DAV:sync-collection19.2 Discovery
The following must work:
/.well-known/carddavredirectDAV:current-user-principalCARDDAV: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-setAddress book home and address book collection PROPFIND responses should support common compatibility properties where practical:
CARDDAV:supported-address-data
CARDDAV:max-resource-size
CS:getctag19.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>.vcfPUT 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_BYTESbefore 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-8Also accept common client variants like:
text/x-vcard
text/vcard19.5 CardDAV REPORT
Must implement:
CARDDAV:addressbook-query
CARDDAV:addressbook-multiget
DAV:sync-collectionInitial addressbook-query filter support:
FN
N
EMAIL
TEL
UID
ORGText 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-setScoped 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-principalandCALDAV:calendar-home-set. - A CardDAV-only PAT can discover
DAV:current-user-principalandCARDDAV: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 Forbiddenfor 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 StorageFor 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_BYTESbefore 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
errorCodeFor litmus debugging, log:
X-Litmus
X-Litmus-Second
Depth
Destination
Overwrite
If
Lock-TokenNever 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
tsxThe 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/litmusifvendor/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:filesnpm 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 asDAB_PERSIST_STATE_PATH=<temp_dir>on the spawned process, - pipe stdout and stderr to files inside the temp directory,
- wait until
/healthzreturns 200 and exits early if the process dies before readiness, - create or reset the litmus fixture against that server,
- run
vendor/litmus/litmusagainst the fixture URL, - stop the process tree with SIGTERM then SIGKILL on timeout,
- remove
.dev.vars.<cloudflare_env>and the temp directory unlessDAB_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.localhostThe 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.jsoncwith D1, R2, Durable Object, Queue, rate limit, observability, and route bindings..dev.vars.examplewith local non-secret defaults.worker-configuration.d.tsgenerated 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.
.gitignoreentry forvendor/litmus/.- Local litmus setup command or script that clones and compiles
https://github.com/notroj/litmusintovendor/litmus/. - Local isolated
test:litmusharness that starts a temp-state Worker on127.0.0.1:<free_port>and tests a subject host under*.dab.localhost:<free_port>. - Compatibility spike for
fast-xml-parsernamespace 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 typecheckpasses afterwrangler types.npm run format:checkpasses.- 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:litmuscan start and stop an empty isolated Worker, write and remove its per-run.dev.vars.<env>file, and reach/healthzthroughhttp://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, andauditEventsschema, migrations, and repositories. - Subject bootstrap in
DAV_CONTROL_PLANEusing 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
subdoes 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=Laxor stricter,Path=/, and host-only. - Non-safe API requests with missing or foreign
Originfail 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
Ifheader parser. /files/support forOPTIONS,PROPFIND,PROPPATCH,MKCOL,GET,HEAD,PUT,DELETE,COPY,MOVE,LOCK, andUNLOCK.- 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:litmuscommand 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:litmuspasses notroj litmus fromvendor/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,
Ifheader 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
sqlrecursive 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/caldavand/.well-known/carddavredirects.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
sqliteTabledeclarations. - 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
sqlhelpers 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/