Worker API
Source lives in src/cloudflare/worker/; scripts/build-worker.mjs writes the deployable module to .generated/cloudflare/worker.mjs. The Vite production build embeds a compressed, hashed copy of that generated module for the in-plugin OAuth deployment.
App mutations require X-Crate-Protocol: 1; check /.well-known/crate before writing. Missing/incompatible protocols receive 428. The versioned native Shortcut transport has its own compatibility contract, described below. POST metadata and batch-download requests are reads. See the protocol contract for retry, revision, storage, and notification guarantees. Responses carry X-Crate-Request-Id for diagnostics. Authenticated responses also carry X-Crate-Encryption-State: on|off; clients skip the /encryption probe before plaintext requests while it reports off, and the server still rejects plaintext into an encrypted vault with 428.
Authentication
Section titled “Authentication”All non-public API endpoints require an Authorization: Bearer <token> header. Tokens have vault, reminders, reading, or reading_capture scope, and may have an expiry. Reading grants are also bound to the active Reading policy generation. The Worker hashes the bearer token with SHA-256 and looks up the hash in the auth_tokens D1 table. Authentication fails closed with 503 when D1 is unavailable.
Vault device tokens are registered only through a temporary Cloudflare OAuth authorization; the Worker exposes no public or device-authorized vault-enrollment endpoint. PWA exchanges create 90-day reminders tokens bound to the enrolled folder. Once a Reading folder is configured, the same token can use Reading library, save, update, and shortcut-pairing routes. It cannot issue Reading setup links or change the Reading folder, and it cannot call sync, settings, device-management or push-administration routes. Public compatibility, PWA assets, and reminder-enrollment endpoints are listed separately below. CORS headers are included on all JSON/API responses.
Self-hosted installations additionally have a Node gateway, outside the Worker:
GET /__crate/ready returns a per-process readiness identifier, and
POST /__crate/pair exchanges { code } for { authToken }. Pairing codes have
256 bits of randomness, expire after ten minutes, and are claimed once before
credential creation. Only a local authenticated administration socket can issue
codes; this socket is never forwarded through the public tunnel. The gateway
does not enable device registration on Cloudflare-hosted Workers. Existing vault
tokens and scoped PWA enrollment keep their current behavior.
Endpoints
Section titled “Endpoints”| Method | Path | Purpose |
|---|---|---|
GET | /.well-known/crate | Public service, version, protocol range, and capability metadata |
GET | /health | Health check, returns { status, timestamp } |
POST | /links/title | Authenticated page-title lookup { url }, returns { title: string | null }; allowed for vault and reminder tokens |
GET | /sync/check?since=<seq> | Lightweight check: are there changes since this sequence? |
GET | /sync/changes?since=<seq> | Paginated changelog entries (limit 5000 per page) |
GET | /sync/manifest?limit=<n>&after=<path>&snapshotSeq=<seq> | Stable, cursor-paginated remote manifest |
POST | /sync/metadata | Fetch current metadata for up to 50 selected paths |
PUT | /sync/upload?path=<path> | Upload one conditionally-versioned file (binary body, max 25 MB) |
GET | /sync/download?path=<path> | Download single file (streaming from R2) |
POST | /sync/delete | Delete single file with expected hash and revision |
POST | /sync/batch-upload | Batch upload { files: [...] } (max 3 files, 10 MiB total) |
POST | /sync/batch-download | Batch download { paths: [...] } (max 50 paths and 8 MB decoded) |
POST | /sync/batch-delete | Conditional batch delete { files: [...] } (max 4 files) |
GET | /sync/checkpoints | List up to 20 unexpired shared checkpoints |
POST | /sync/checkpoints | Capture the authoritative server inventory; unchanged generations reuse their checkpoint |
GET | /sync/checkpoint?id=<id> | Read the complete inventory of a live shared checkpoint |
GET | /sync/checkpoint-file?id=<id>&path=<path>&revision=<revision> | Stream an exact current/retained file revision while the checkpoint remains live; vault scope only |
GET | /sync/versions?path=<path>&search=<text>&cursor=<cursor> | Page through unexpired recoverable file versions (100 per page) |
POST | /sync/restore-version | Restore a retained version with expected-hash compare-and-swap |
GET | /diagnostics | Backend counts, delivery failures, queue pressure, and scheduled-maintenance state |
DELETE | /auth/tokens | Revoke an auth token { id } |
GET | /auth/tokens | List all registered auth tokens |
DELETE | /auth/session | Atomically revoke the current bearer token and its owned push subscriptions |
GET | /settings | Get shared settings from R2 |
PUT | /settings | Store shared settings to R2 |
GET | /reminders/list?folderPath=<path> | List reminders and projects from synced Markdown files |
POST | /reminders/create | Create a reminder in a project Markdown file |
POST | /reminders/update | Update or move a reminder |
POST | /reminders/set-completed | Toggle reminder completion |
DELETE | /reminders/delete | Delete a reminder from Markdown |
POST | /reminders/reorder | Reorder reminders inside a project file |
GET, POST, PUT | /reminders/notification-policy | Read, initialize, or explicitly update shared notification policy (vault scope) |
POST | /notifications/reminders-enrollment-token | Create one-time browser and install tokens for a reminders web app link (vault tokens only) |
POST | /notifications/subscribe | Save a push subscription |
DELETE | /notifications/subscribe | Remove a push subscription |
GET | /notifications/subscriptions | List push subscriptions |
POST | /notifications/test | Send a test push notification |
Public and PWA Endpoints
Section titled “Public and PWA Endpoints”| Method | Path | Purpose |
|---|---|---|
GET | / | Returns public service, version, protocol range, and capability metadata |
GET | /notifications | Serves the reminders PWA HTML |
GET | /notifications/app.js | Serves the bundled PWA client |
GET | /notifications/sw.js | Serves the PWA service worker |
GET | /notifications/manifest.json | Serves the PWA manifest |
GET | /notifications/version.json | Returns the current PWA asset version |
GET | /notifications/icon.svg | Serves the PWA icon |
GET | /notifications/open-obsidian | Serves the browser handoff page for opening Obsidian |
GET | /notifications/vapid-public-key | Returns the Web Push VAPID public key |
POST | /notifications/reminders-exchange | Exchanges a one-time web enrollment token for a PWA auth token |
Request/Response Details
Section titled “Request/Response Details”Page titles
Section titled “Page titles”POST /links/title is a read operation and does not require mutation protocol negotiation. Pasting a standalone web URL automatically uses it. It accepts public HTTP(S) DNS names on standard ports, excludes credentials and fragments, validates each redirect (maximum three), and reads at most 256 KiB of HTML within five seconds. It returns the HTML title as text with entities retained; clients decode entities as text. Missing titles, unsupported responses, and network failures return title: null. Invalid destinations return 400; the edge request limiter can return 429. No page scripts execute, no caller credentials are forwarded, and responses are not cached.
Self-hosted title requests use the native public-network binding, including every
redirect. A rejected connection returns title: null without retrying through
another transport. The local server also forwards global Worker fetches through
that binding; Miniflare’s default outbound service is not the security boundary.
Hosted Workers retain global_fetch_strictly_public. URL validation alone does
not protect against a public-looking hostname resolving to a private address.
Reading capture handoffs
Section titled “Reading capture handoffs”Reading list and mutation requests return 503 while source indexing is incomplete or source bytes cannot be verified. A temporary D1/R2 failure does not mark the source revision as successfully indexed or permit capture deduplication against an incomplete index. Background storage-failure retries use a 30-second delay; ordinary bounded indexing backlog can continue immediately. Verified malformed or oversized notes instead produce stable source issues until the source changes.
POST /reading/shortcut/v1/prepare accepts only capture credentials when the
Reading library is configured and available. Its five-minute launch URL
contains an opaque capability in the fragment. POST /reading/handoff redeems it
through X-Crate-Capture, using the same issuer-scope policy. Redemption checks
issuer revocation/expiry, capability expiry, the Reading folder generation, and
feature availability. Repeating a live capability reuses its exact operation ID;
it does not create another note. Revoking the issuer blocks even a committed
capability’s subsequent replay.
Native Shortcut compatibility
Section titled “Native Shortcut compatibility”POST /reading/shortcut/v1/prepare accepts only capture credentials and
{ url, title? }. POST /reading/shortcut/v1/exchange redeems the same one-use
pairing code with { token }. These routes do not require the app wire header.
X-Crate-Shortcut-Revision identifies the installed template; it defaults to 1.
The capture contract version is in the path. Metadata at /.well-known/crate
exposes shortcut: { version, revision, minimumRevision } and the
reading-shortcut-transport-v1 capability.
Recognized native requests return shortcut: { version, revision, updateAvailable, downloadUrl }. Preparation successes return the opaque launch
URL; failures return an error launch URL even for authentication, admission, or
compatibility rejection. Its fragment contains only allowlisted diagnostic
fields. Only the current contract’s routes are admitted; other contract
versions receive 404. A template below the supported minimum revision receives
426 shortcut_update_required. The live, uncached save page performs the handoff
with the server’s current app wire protocol.
Only the versioned shortcut routes bypass the general app protocol. Other routes enforce the normal protocol checks.
See shortcut maintenance for release and support rules.
POST /sync/import/readiness
Section titled “POST /sync/import/readiness”Request: { token }, using the completed initial-import token. Returns { ready },
optional { progress: { scanning, remainingFiles, remainingSchedules } }, and an
error string when reminder setup needs attention. Source failures additionally
return issues: [{ path, message, code?, line? }] (up to 20 notes) and moreIssues.
These fields are additive; responses without source failures contain only error.
Paths identify vault notes; malformed description messages include a one-based
source line, also returned as line. code is one of the
stable issue codes when the Worker recognizes
the failure. Repairing a note and syncing again resumes setup without repeating
completed file uploads. See initial import readiness.
PUT /sync/upload
Section titled “PUT /sync/upload”- Query:
?path=<url-encoded-path> - Headers:
X-File-Hash,X-File-Size,X-Crate-Expected-Hash,X-Crate-Upload-Operation,Content-Type X-Crate-Expected-Hashis the 64-character remote hash observed while planning, orabsentfor a new path- Body: raw binary, consumed incrementally with a hard 25 MB cap; oversized declared or chunked requests stop before the remainder is buffered
- Response:
{ success, path, hash, revision } - A stale expected hash returns
409with{ success: false, path, error, code: "version_conflict", currentHash }and does not replace the committed object - Persist the exact request and its server-dated operation ID before dispatch. Replaying it returns the original success or rejected precondition, including after deletion. Missing IDs receive 428; mismatched payloads receive 409
operation_mismatch; expired IDs without receipts receive 410operation_expired. See upload recovery.
GET /sync/download
Section titled “GET /sync/download”- Query:
?path=<url-encoded-path> - Response headers:
Content-Type,Content-Length,X-File-Hash,X-Crate-Revision - Body: raw binary (streamed from R2)
POST /sync/batch-upload
Section titled “POST /sync/batch-upload”{ "files": [ { "operationId": "e1_<eight-digit-server-day>_<uuid>", "path": "notes/file.md", "content": "<base64>", "hash": "sha256...", "size": 1024, "contentType": "text/markdown", "expectedHash": "sha256..." } ]}Worker validates: max 3 files, total decoded content <= 10 MiB. Each file requires its own stable operationId with the same receipt and expiry rules as a single upload. Mutation batches are deliberately smaller than download batches to stay within Workers Free D1 query limits even on stale-write cleanup paths.
Response: { success, results: [{ path, success, hash?, revision?, error?, code?, status?, currentHash? }] }. A stale per-file write uses code: "version_conflict", status: 409, and the current remote hash; storage failures use code: "storage" and status: 503.
POST /sync/batch-download
Section titled “POST /sync/batch-download”Request: { paths: ["notes/file.md", ...] } (max 50, no duplicates)
Response: { files: [{ path, content, hash, size, contentType, revision, error? }] }
Content is base64-encoded. The Worker rejects a batch before reading R2 if D1 metadata shows that it exceeds 8 MB. The client batches only files smaller than 1 MB, validates the exact response path set, size, and SHA-256 hash, and falls back to individual streaming downloads when a batch is too large or unavailable.
POST /sync/delete / batch-delete
Section titled “POST /sync/delete / batch-delete”Single: { path: "notes/file.md", expectedHash: "sha256...", expectedRevision: "opaque-key" } -> { success, path }
Batch: { files: [{ path, expectedHash, expectedRevision }, ...] } (max 4) -> { success, deleted: [...], errors?: [{ path, error, code?, status?, currentHash? }] }
Uploads compare the observed D1 content hash. Deletes also compare the original opaque revision, rejecting same-content recreations. Reminder edits compare a semantic revision captured by the client, then apply file-level preconditions. D1 commits the file row, changelog, retention, operation receipt, and notification projection intent atomically; stale writers receive 409.
GET /sync/check
Section titled “GET /sync/check”Query: ?since=<seq>
Response: { lastSeq, hasChanges, cursorExpired? }
cursorExpired is true when since points to a pruned changelog region, signaling the client should fall back to full sync.
GET /sync/changes
Section titled “GET /sync/changes”Query: ?since=<seq>
Response: { changes: [...], lastSeq, hasMore, cursorExpired? }
Paginated at 5000 entries. Client loops until hasMore is false.
GET /sync/manifest
Section titled “GET /sync/manifest”The plugin requests pages of at most 2,000 entries. The first page establishes snapshotSeq; later pages reuse it, and the plugin replays the changelog after that sequence before accepting the manifest.
Response:
{ "version": 1, "files": { "path": { "hash": "...", "size": 1024, "modified": "datetime", "revision": "opaque-key" } }, "lastSeq": 42, "snapshotSeq": 42, "hasMore": false, "nextCursor": "optional/last/path.md"}POST /sync/metadata
Section titled “POST /sync/metadata”Request: { paths: ["notes/file.md", ...] } (max 50, no duplicates)
Response: { files: { "notes/file.md": { hash, size, modified } } }. Missing paths are omitted. Queue conflict recovery uses this endpoint so a version race does not require loading the entire remote manifest.
DELETE /auth/tokens
Section titled “DELETE /auth/tokens”Revoke a token by its ID.
Request: { id: "<uuid>" }
Response: { success: true }
GET /auth/tokens
Section titled “GET /auth/tokens”List all registered tokens (does not expose hashes). The current token is marked with is_current.
Response: { tokens: [{ id, device_id, device_name, platform, created_at, last_seen_at, is_current }] }
DELETE /auth/session
Section titled “DELETE /auth/session”Revokes the bearer token used for the request. Crate calls this before clearing its local credential when the user disconnects the current device.
Response: { success: true }
GET /settings
Section titled “GET /settings”Returns shared plugin preferences stored in R2 as __crate__/settings.json. Used by second devices to inherit settings during setup.
Response: { settings: { ignorePatterns, syncInterval, pushEnabled }, settingsVersion } or { settings: null, settingsVersion } if not yet stored or corrupt.
Plugins use the device-local automaticSync preference for automatic triggers;
that preference is not part of this shared-settings contract.
PUT /settings
Section titled “PUT /settings”Stores shared plugin preferences to R2.
Request: { settings: { ignorePatterns: [...], syncInterval: 300, pushEnabled: false }, expectedVersion }
Response: { success: true, settingsVersion }. A stale version returns 409 instead of overwriting another device’s edit.
expectedVersion: null stores settings only when none exist. Connecting a Cloudflare device uses it to seed a fresh deployment with that device’s preferences. A connecting device applies settings it reads and never writes over settings it could not read, including an encrypted vault’s.
Closing Crate settings writes only the shared settings changed on this device since it last synced them. It merges those changes onto the server’s settings as it last read or wrote them, reading first when it has not, and writes against that version. A newer version, or a setting both devices changed, loads the server’s settings with a short notice instead of overwriting them.
Reminder handlers live in src/cloudflare/worker/reminders/routes/. Mutations apply the shared Markdown command described in the architecture.
Create, update and set-completed respond with the saved reminder as { success: true, reminder }. reminder has the fields of a listed reminder: revision, id, content, optional description, dueDate and dueDatetime, priority, completed, project, optional recurrence, filePath and lineNumber. A retried operation returns its original response.
Mutation failures respond with { error } and, where listed, a stable code. Nothing is written on any failure.
400invalid_reminder_input, with the rejectedfield: invalid reminder fields. This includes a title containing Crate’s<!-- crate-id:or<!-- crate-rule:comments, and a title that would not read back as the saved reminder, such as an undated title consisting only of a date or priority marker. Clients should treat it as a definite rejection that needs a corrected request, not a retry.400without a code: route checks such as a missingfolderPath,idorfilePath, an invalid project on create, an invalid repeat, or unreadableorderedIdsorexpectedOrder.404: the reminder or project note no longer exists.409version_conflictfor a staleexpectedRevision;reminder_markdown_contextfor duplicate identities or Markdown that cannot be edited safely;reminder_order_conflictfor a reorder the note cannot apply safely, such as one that includes completed or nested reminders;duplicate_reminder_identitywhen the identity occurs more than once in the reminders folder;operation_mismatchwhen an operation ID is reused for different changes. A create whose identity already exists, a changed project order and a concurrent note write return409without a code.410operation_expired: the operation ID is outside the retry window. See retention.413: a note the edit reads or writes is larger than 1 MiB. This applies to deletes and reorders too, because lists report such notes as issues instead of returning their reminders.428: a missingoperationIdorexpectedOrder, orrevision_requiredfor a missing or malformedexpectedRevision.
GET /reminders/list
Section titled “GET /reminders/list”Query: ?folderPath=<reminders-folder>
Reads synced Markdown reminder files from the configured folder and returns web-ready reminder records plus known projects. Conditional requests use an ETag that pairs a vault change token (changelog sequence, import and reset generations, folder and parser version) with the folder’s file-metadata revision. An unchanged token answers 304 without listing the folder; an unchanged folder revision answers 304 with a refreshed ETag. On an ETag miss, D1-cached parses are reused by file hash, so only new or changed Markdown objects are fetched from R2 and parsed.
Cold indexes are warmed in batches of at most 20 files and 2 MiB per request. While more files remain, the endpoint responds with 202 and { warming: true, remainingFiles }; clients should repeat the request until it returns 200. Individual reminder Markdown files must be no larger than 1 MiB.
Response: { reminders: [...], projects: [...], issues: [...] }. Each reminder includes its semantic revision. Oversized notes or cache records yield per-file issues while healthy reminders remain usable; responses with issues omit ETag. Warming responses include totalFiles and Retry-After: 1.
Complete (issue-free) responses and 304s include X-Crate-Delta-Base: a changelog position bound to the folder, parser version, initial import and reset generation. A client holding that complete revision may send it as since. When the changelog can still prove every change since then, the response is { delta: true, changedPaths: [...], reminders: [...], projects: [...], issues: [] }: the client replaces all reminders from changedPaths (written, renamed or deleted notes) with reminders and keeps the rest. A pruned or imported changelog, more than 20,000 vault changes, a different view, or a response with issues returns the full list instead.
POST /reminders/create
Section titled “POST /reminders/create”Creates a reminder in the selected project Markdown file, creating that file if needed.
Request includes a versioned operationId, folderPath, content, optional project, description, priority, dueDate, dueDatetime, recurrence, and required id equal to operationId. Recurrence time fields include optional second (0–59) and millisecond (0–999), alongside hour and minute. Obtain the UTC operation day from server metadata and encode it as specified in the retry policy.
Response: { success: true, reminder }
POST /reminders/update
Section titled “POST /reminders/update”Updates an existing reminder by id. The request requires a versioned operationId, expectedRevision, folderPath, and the reminder’s original filePath, plus any mutable reminder fields: content, description, priority, project, dueDate, dueDatetime, recurrence.
If project changes, the worker commits the source and destination Markdown files atomically with hash-based compare-and-swap checks.
Response: { success: true, reminder }. After a move, reminder.filePath and reminder.project name the destination.
POST /reminders/set-completed
Section titled “POST /reminders/set-completed”Request: { operationId, expectedRevision, folderPath, filePath, id, completed }
Response: { success: true, reminder }. Completing a repeating reminder with later occurrences returns the next one, still open.
DELETE /reminders/delete
Section titled “DELETE /reminders/delete”Request: { operationId, expectedRevision, folderPath, filePath, id }
Response: { success: true, id }
POST /reminders/reorder
Section titled “POST /reminders/reorder”Request: { operationId, folderPath, project, orderedIds, expectedOrder }. expectedOrder is the previously observed complete project order; a concurrent reorder or changed membership returns 409.
Response: { success: true }
GET / POST / PUT /reminders/notification-policy
Section titled “GET / POST / PUT /reminders/notification-policy”Vault credentials only. GET reads the shared folder, IANA timezone, all-day time, enabled state, and revision. POST initializes only an absent policy. PUT changes it explicitly with expectedRevision; stale changes receive 409. Starting a client never overwrites another device’s policy.
Schedules derive from committed Markdown and the shared notification policy. File commits enqueue projection intent in the same D1 transaction. The Durable Object coordinator drains at most three files and five outbox jobs per alarm, fences stale work with file/policy/job revisions, and rearms while work remains. Maintenance recovers missed wakeups.
Notifications and PWA Endpoints
Section titled “Notifications and PWA Endpoints”GET /notifications (public)
Section titled “GET /notifications (public)”Serves the reminders PWA HTML page. The plugin generates short-lived links with query params such as token, browserToken, folder, upcomingDays, allDayTime, project, tab, and reminderId.
The token query parameter is a web enrollment token, not a sync bearer token.
GET /notifications/app.js, /sw.js, /manifest.json, /version.json, /icon.svg, /open-obsidian (public)
Section titled “GET /notifications/app.js, /sw.js, /manifest.json, /version.json, /icon.svg, /open-obsidian (public)”Serve the PWA client bundle, service worker, manifest, version metadata, icon, and Obsidian handoff page. Versioned static assets use long-lived cache headers when a v query param is present.
GET /notifications/vapid-public-key (public)
Section titled “GET /notifications/vapid-public-key (public)”Returns the VAPID public key, lazily generated and stored in D1.
Response: { publicKey: "base64url-encoded-key" }
POST /notifications/reminders-exchange (public)
Section titled “POST /notifications/reminders-exchange (public)”Consumes a one-time web enrollment token and creates a per-device PWA auth token in auth_tokens in one transaction. A failed transaction preserves the enrollment token. Concurrent exchanges can create only one session. A successful transaction whose response is lost still consumes the token; recovery requires a new enrollment link.
Request: { token, deviceName?, previousAuthToken? }
When replacing a browser session, the optional previous reminder credential revokes that session and its push subscriptions in the transaction that creates the replacement. Expired reminder credentials can be replaced; vault credentials cannot be revoked through this endpoint.
Response: { authToken }
POST /notifications/reminders-enrollment-token
Section titled “POST /notifications/reminders-enrollment-token”Vault-token authenticated. Creates separate short-lived, one-time tokens for previewing a reminders app link in the browser and activating the installed app. Reminders web app tokens cannot mint replacement sessions. Keeping the install token unused by the browser prevents iOS from launching a Home Screen app with an already-consumed credential.
Response: { token, browserToken, expiresAt }, where token is reserved for the installed app.
POST /notifications/subscribe
Section titled “POST /notifications/subscribe”Save a push subscription owned by the authenticated session. Public requests are rejected.
Request: { endpoint, keys: { p256dh, auth }, deviceName? }
Response: { id, notificationsEnabled }. Registration confirms the device’s push
endpoint; notificationsEnabled separately confirms that the shared notification
policy and Reminders feature allow scheduling for its enrolled folder. Registering
a device does not enable a paused policy. Clients refresh this status on foreground
and must not infer that delivery is enabled from device registration alone.
DELETE /notifications/subscribe
Section titled “DELETE /notifications/subscribe”Authenticated. Remove a push subscription.
Request: { id }
Response: { success: true }
GET /notifications/subscriptions
Section titled “GET /notifications/subscriptions”Authenticated. List all push subscriptions.
Response: { subscriptions: [{ id, device_name, created_at }] }
POST /notifications/test
Section titled “POST /notifications/test”Authenticated. Send a test push to all subscriptions.
Response: { sent, failed, pruned, errors }
ReminderAlarm Durable Object
Section titled “ReminderAlarm Durable Object”Exported class ReminderAlarm from the worker. Each reminder gets its own DO instance keyed by reminderId. When the alarm fires, the DO sends Web Push notifications to all subscribed devices via web-push-browser, then clears the completed schedule from D1 while retaining its occurrence receipt in Durable Object storage.
Requires a durable_object_namespace binding (REMINDER_ALARMS) and a declarative exports.ReminderAlarm entry with SQLite storage on deploy.
D1 database schema
Section titled “D1 database schema”The complete current schema is src/cloudflare/schema.sql. It is hash-verified before provisioning. An empty database is initialized with crate_schema version 1; no migrations are registered. Unsupported schemas are rejected without modifying data. Worker requests never execute DDL.
The schema stores file metadata, changelog entries, retained versions, upload/deletion receipts and cleanup work; device credentials and folder-bound web enrollment tokens; reminder receipts and identity reservations; shared notification policy, projections, and delivery jobs; authenticated push subscriptions and request limits. Derived schedules are rebuilt from committed Markdown during an isolated restore.
Reminder mutations replay a matching operation receipt before rereading a moved or deleted source; a different payload under the same operation ID returns 409. Notification time comes from the saved server policy. Successful create/update/completion responses include the acknowledged reminder and its new revision.
Path Sanitization
Section titled “Path Sanitization”sanitizePath() rejects null bytes, traversal, empty segments, backslashes, Windows-reserved names and characters, and trailing dots/spaces. A normalized unique D1 key additionally prevents case-insensitive and Unicode-normalizing collisions.
Changelog Pruning
Section titled “Changelog Pruning”Activity schedules one cleanup alarm 24 hours later, which deletes entries older than 30 days (CHANGELOG_RETENTION_DAYS). Cleanup runs at most ten passes per activity-triggered episode. It can continue an advancing backlog or wait for a pending upload deadline; unconfirmed upload cleanup pauses after eight attempts. Expired retained versions drain 500 objects per pass in queue order. While that queue advances, passes continue every five seconds beyond the ten-pass limit, up to 200 passes (100,000 objects) per episode. Failures are recorded for diagnostics.
When a client’s since cursor points to pruned entries, the cursorExpired flag is returned so the plugin falls back to full sync.
Bindings Table
Section titled “Bindings Table”See docs/architecture.md for the full bindings table. When adding new bindings, update the Worker upload metadata in src/cloudflare/cloudflare-api.ts and the deployment flow in src/cloudflare/deployment/provisioner.ts.
Retained file history pagination
Section titled “Retained file history pagination”GET /sync/versions returns { versions, hasMore, nextCursor? }. path is an optional exact filename and search is an optional literal substring (ASCII case insensitive), including folder names. Results sort by (created_at, storage_key) descending, and nextCursor continues that ordering without offset shifts when newer rows arrive. Reuse the same search and path with the opaque cursor; a changed query or malformed cursor receives HTTP 400. Expired versions are omitted from every page. The plugin’s File history view requests pages for one exact file path; search remains available in the API. Restores retain their existing conditional-write and integrity checks.
Authoritative manifest, targeted metadata and changelog reads are validated at runtime, including required maps, version and cursor framing, path/hash/size/date fields and ordering. Missing or malformed state aborts synchronization before file changes. A valid empty map remains an explicit empty state. Known historical changelog entries may lack revisions; current file metadata must include one.
GET /sync/version-preview
Section titled “GET /sync/version-preview”Vault-authenticated, read-only access to one unexpired retained version. Query parameters path and storageKey must identify the same retained record. Returns integrity-verified binary content with X-File-Hash, Content-Length, and Cache-Control: private, no-store. Missing, expired, or mismatched entries return 404; versions larger than 256 KB return 413; missing or corrupt content returns 503. Arbitrary R2 keys are never served. No file, changelog, or retention state is modified.
File history decodes text locally and compares the saved version with a snapshot of the current local file. Binary/large files can still be restored without a preview.
The History view groups retained update/deletion versions by day, rather than presenting whole-vault snapshots or inferring restore events. Files reads the existing paginated manifest and replays subsequent changes to list current synced files, including those with no retained versions. Its current preview reads targeted metadata and downloads the remote copy only for text files up to 256 KB, verifying the size, hash and revision before display. It never substitutes unsynced local contents. The file list renders in batches of 200; Refresh files refreshes the remote inventory. Historical comparisons continue to use the local snapshot and never modify the sync manifest or queue.
Shared history checkpoints
Section titled “Shared history checkpoints”The server advertises shared-history-checkpoints-v1. Checkpoints store metadata in R2, not extra copies of vault
files. Creation snapshots the complete server inventory, not a client-supplied list.
The 20-entry index uses conditional ETags, and stale inventory captures receive 409.
Expired or evicted IDs receive 410. Captures are bounded to 100,000 files and 32 MiB of
metadata (413 beyond that limit), and in-progress initial imports receive 409. The Worker
encodes inventory pages as it reads them and streams stored documents after matching the
object’s metadata to the index, so neither path parses the whole inventory.
All routes require vault credentials and return private, non-cacheable responses.
See the sync pipeline for
retention, cleanup, and restore validation.
Reading shortcut pairing
Section titled “Reading shortcut pairing”Server revision 67 advertises reading-shortcut-pairing-v1. POST /reading/shortcut-pairing requires an active Reading or vault credential and HTTPS. It returns { pairingCode, expiresAt }, with a ten-minute, single-use secret in the code’s URL fragment. Only one outstanding code per issuer is retained. The browser cannot issue library or vault access through this route.
POST /reading/shortcut/v1/exchange accepts { token } without a bearer credential. The Shortcut strips the fragment locally and sends it only in this body. Atomic redemption rechecks the issuer, policy, folder and generation and returns { endpoint, authorization } for a new capture-only token. Its expiry is the earlier of 90 days and the issuer’s expiry. Replay, expiry, issuer revocation or a changed policy generation returns 410. A lost reply requires a new code. Pairing creation requires the app protocol; exchange uses the independent shortcut contract. Both have separate per-minute action limits and return Cache-Control: no-store. Grant hashes use existing reading_enrollments rows with scope reading_capture:<issuer-id>; normal library/install exchanges reject this scope.
Reading updates also accept optional highlights alongside their before.highlights
precondition. Each entry contains start, end (UTF-16 offsets in sanitized reader
text), and the exact text. Optional id, createdAt, prefix, suffix, and
note fields retain identity, context, and personal annotations. Optional
codeAnchor: true records exact code selections that cannot contain inline markers.
textAnchor: true preserves other source-sensitive selections, such as partial
autolinks, HTML, escaped table cells, literal ==, and Obsidian constructs. The
writer recalculates both flags from the selected Markdown syntax. Limits are 100
entries, 4,000 characters per excerpt or note, 16,000 excerpt characters, and 48 KiB
of serialized highlight metadata. Update requests allow 256 KiB for the before
and after values, including JSON escaping. The existing mutation receipts and
conflict checks apply.
Highlight updates insert/remove == delimiters in the original Markdown body;
they never serialize the article back from HTML. highlight_format: markdown-v1
marks notes whose inline highlights are authoritative for prose and complete
inline code and complete autolinks. Partial or multiline inline code and code-block selections use
anchored metadata, preserving the code bytes. Code anchors can authorize only
matching code characters, never unmarked prose. A rebuildable projection
collects native markers, including changes made in Obsidian, and associates stored
metadata by text and surrounding context. Unmatched excerpts and orphaned personal annotations
remain in highlight_recovery and are visible in the article’s Highlights view.
Unresolved code and text anchors also retain their excerpts for recovery. Text
anchors can paint exact, contextually resolved visible excerpts while leaving the
article source unchanged; native marker removal remains authoritative for entries
without these explicit fallback flags. The writer verifies rendered structure as
well as text and falls back to text annotations when new markers would alter it.
See Reading highlights.
Article fetching permission
Section titled “Article fetching permission”The server advertises reading-fetching-consent-v1. Reading libraries remain
accessible when article fetching is off. The reading_policy.enabled field
controls extraction only. A newly configured
plugin initializes extraction when Reading is enabled. A library capture with
fetchArticle: true also enables extraction as part of that explicit save.
Capture-only credentials cannot enable it. The plugin does not expose a separate
fetching switch.
GET /reading/fetching returns { enabled, revision }. POST /reading/fetching
accepts { enabled: boolean, revision } and returns the updated state. Vault, Reading,
and reminders sessions can explicitly change this permission; capture-only credentials
cannot. Stale revisions receive 409. This endpoint cannot change the Reading folder.
With fetching off, new URL captures become bookmarks (extraction_status: unavailable),
existing articles remain readable and editable, and retries are rejected. Capture
requests may also supply fetchArticle: false to save a bookmark regardless of the
shared permission, including when an offline save is replayed later. Omitting the
field applies the shared permission.
Shared feature policy
Section titled “Shared feature policy”The server advertises shared-features-v1. GET /features returns
{ reading: boolean, reminders: boolean, revision: string | null }. Vault,
Reading-library and reminders credentials can read; only vault credentials can
POST { feature: "reading" | "reminders", enabled: boolean, revision }. Capture-only
credentials cannot access this policy. Stale writes return 409. Writes are
serialized through the projection coordinator, and a successful write wakes
background work; reads return the last committed policy without entering the
coordinator. Both features default on until explicitly changed.
src/protocol/feature-policy.ts defines and validates this response for the
Worker, plugin and PWA.
Paused feature APIs return 423 with code: feature_paused; sync and feature-policy
access remain available. Reading policy and capture operations cannot override
the shared pause. Push opt-in remains unchanged by reminder pause/resume. Queued
operations, saved content, library identity, and scoped credentials are retained.
Admission before database work
Section titled “Admission before database work”Unknown routes and unsupported methods return 404 before authentication or coordinator work. Public static assets and OPTIONS remain available without an API budget. Other requests with unrecognized credentials, plus the sensitive Reading/notification actions, use the edge admission binding: 60 requests per minute per hashed source address and deployment host. Invalid rotating bearer tokens share that budget. The local server replaces client-supplied forwarding headers with the socket address.
A successful D1 authentication records only a bounded, 60-second credential-hash hint in the isolate. Subsequent ordinary requests use a separate local budget of 6,000 per minute per credential so vault bootstrap can proceed. Every request still checks token validity, expiry and authorization against D1; an authentication failure removes the hint. This is not an authentication cache. Action/day limits remain enforced after authentication. Denial returns 429 with Retry-After: 60.
Without the edge binding, the restrictive budget also runs in bounded isolate memory keyed by the original D1 binding, before request-local metering wrappers. The authenticated sync budget and fallback are per isolate, not global quotas; Cloudflare’s edge limits are also location-scoped abuse protection, not exact account-wide billing caps. Keep Cloudflare account usage alerts enabled. Ordinary Reading requests authenticate and authorize before entering the shared coordinator. Public one-use Reading grants enter it after admission because redemption and capture must be serialized together.
Encryption API
Section titled “Encryption API”GET /encryption returns public configuration and an encrypted recovery bundle to vault credentials. Reminders credentials receive their own scope metadata plus the same encrypted recovery envelope; /reading/encryption does the same after validating Reading enrollment. The user’s recovery code decrypts the bundle locally and is never uploaded. Session route permissions remain scoped; sharing this ciphertext does not grant sync, settings or device-management access. GET /encryption/folders inventories existing enrollment folders for setup.
Vault-only POST /encryption/conversion starts a frozen, resumable conversion. GET /encryption/conversion pages unfinished files and stored receipts. Its optional includeProgress=1 query adds remainingFiles, a count of unfinished live and retained file objects. Clients request it once at the start of each conversion or resume; it performs one read and no additional writes. The /file, /settings, /receipt, /checkpoints and /checkpoint conversion endpoints read originals and accept encrypted replacements; POST /encryption/conversion/finish completes bounded cleanup and activates encryption. Matching vault and generation headers are required after start. Ordinary reads/writes are blocked while converting. Original hashes and sizes distinguish plaintext from interrupted encrypted replacements, including plaintext that begins with CRATE-E2EE/1 or uses the encrypted MIME type.
POST /encryption/metadata returns descriptors for up to 100 revisions. GET /encryption/upload-receipt?operationId=… returns a converted pre-encryption receipt plus retry-window validity; /encryption/deletion-precondition?path=…&revision=… resolves an accepted encrypted deletion. Shared /settings becomes a conditional encrypted envelope endpoint.
Encrypted settings support all preferences accepted by the plaintext API’s 1 MiB request limit. The envelope allowance is ceil(1 MiB × 4 / 3) + 4096 characters for base64url and JWE overhead; encrypted settings requests allow another 4096 bytes for the JSON wrapper. Conversion checks the stored plaintext settings size before freezing the vault and rejects unsupported oversized data with 413, leaving ordinary settings edits available.
Scoped reminders use GET /reminders/encrypted-files?folderPath=…&after=… (1,000 files/page in portable-path order; after is the previous page’s opaque nextCursor), GET /reminders/encrypted-file?folderPath=…&path=…&revision=…, POST /reminders/encrypted-commit, and GET /reminders/encrypted-receipt?folderPath=…&operationId=…. Every route checks the enrolled folder. Commits contain an operation ID, encrypted acknowledgment and one or two encrypted Markdown replacements with hash/revision preconditions. Whole-note bytes are encrypted on the client. Encrypted acknowledgments allow the JSON/base64 expansion of a 1 MiB reminder note (ceil((6 MiB + 16 KiB) × 4 / 3) + 4096 characters); commit requests are capped at 24 MiB. Pre-encryption receipt conversion requests allow 6 MiB for vault and folder copies. Responses above 512 KiB are stored in bounded ciphertext chunks in maintenance_state, committed atomically with the receipt and file changes. Retries reconstruct the exact original response; missing chunks fail without replaying the mutation. Scheduled maintenance removes chunks only after their parent receipt expires. Plaintext semantic mutation and server URL-title routes are unavailable after activation.
Uploads to an active encrypted vault validate both the encrypted content type and file envelope before registering staging leases or writing to R2. Single and batch uploads reject plaintext, malformed envelopes, and mismatched encryption keys with an encryption error; rejected batches stage no candidate files. Encryption identity and reset generation are rechecked transactionally at staging and publication.
Single and batch uploads stream through the existing eight-object transfer pool, where hashing, envelope validation and storage use the Durable Object CPU allowance. Each object processes one upload at a time. The public Worker checks authorization before forwarding; the transfer executor repeats authentication after queuing and captures current encryption/reset authority before reading the body. Markdown publication still uses the separate notification coordinator. This applies to hosted and self-hosted deployments without a new binding or schema migration.
Encrypted file transfers permit up to 38 MiB wire bytes for the existing 25 MiB plaintext limit. Encrypted previews transfer ciphertext; the client applies the existing 256 KiB plaintext preview limit. Reminder clients enforce the existing 1 MiB plaintext note limit. See encryption for metadata disclosure and recovery.
Encrypted web app approval
Section titled “Encrypted web app approval”Servers advertising web-pairing-v1 expose non-cacheable GET and POST
/encryption/pairing. An active encrypted vault is required. This transfers
ciphertext to an already authenticated app; it never issues login credentials.
GET ?id=<request-id>returns{ requests }for the requesting app or a vault device. A vault device may omit the ID to list pending requests. Other app credentials cannot inspect or cancel that request.POST { action: "start", context, commitment }creates an app request. Context containsversion: 1, uniqueid, canonicalorigin,vaultId,generationand enrolledscopeId. A Reminders or Reading credential must own that scope; Reading capture credentials and vault devices cannot start app requests.acceptsupplies{ id, key }from one vault device;revealsupplies the committed requester public key from the owning app. Public keys are raw P-256 points encoded as base64url.approvesupplies{ id, payload }from that same vault device after comparison. The server handles only encrypted packets.cancelcloses a request;finishacknowledges durable key import from its owning app. Both discard the packet. POST responses return{ request }.
Records expire in five minutes, with three slots per app and 32 server-wide. Cancelled slots count until expiry. Polling does not update these records; repeated identical steps are idempotent. Conditional updates check the current actor, owner, vault state and generation. Closed, expired, revoked or mismatched requests cannot publish a new packet. See the approval protocol for key derivation and comparison requirements.
Destructive encryption reset
Section titled “Destructive encryption reset”Servers advertise e2ee-reset-v1. Vault-only POST /encryption/reset?id=<unique-id> starts a reset with current vault/generation headers and { "confirmation": "delete-remote-data", "replacementToken": "<new 64-character lowercase hex token>" }. The client durably saves that identity and token before dispatch. The transaction switches encryption to resetting, replaces all credentials with the supplied vault token, removes enrollment grants/subscriptions, queues alarm cancellation, and invalidates upload leases. Upload staging/publication checks both the encryption identity and a persistent reset generation captured atomically at authentication. The reset generation survives encryption removal, fencing even plaintext requests delayed across encryption activation and reset. These checks apply to single, batch and bulk uploads without new client headers.
Using the replacement token, GET /encryption/reset?id=… reads the receipt and repeated POST requests with {} advance bounded alarm/object deletion. Responses contain { id, vaultId, generation, complete }. Ordinary routes return 423 while resetting; revoked clients return 401. Completion atomically clears vault tables and encryption metadata, preserving infrastructure metadata, cleanup authority for late R2 writes and idempotent reset receipts. Repeating a completed request only returns its receipt. The client then invalidates local sync checkpoints/journals, saves its replacement token, removes encryption keys and uploads its local vault. External backups are not rewritten or erased.
Encrypted Reading
Section titled “Encrypted Reading”Servers advertising e2ee-reading-v1 support Reading scopes (purpose: "reading"). Authenticated GET /reading/encryption discovers only the authorized Reading scope. It and the public feature/session control-plane reads can bootstrap without encryption headers; content operations still require the current vault and generation.
GET /reading/encrypted-files?folderPath=…&after=…, GET /reading/encrypted-file?folderPath=…&path=…&revision=…, and POST /reading/encrypted-commit reuse the conditional encrypted-file protocol above. Reading authority additionally fixes the folder to the current Reading policy and rejects capture-only credentials. GET /reading/encrypted-receipt?operationId=… returns only the current Reading scope’s converted receipt for the current policy generation. Reading plaintext list/item/capture/update/retry, prepare/handoff and background extraction paths fail closed while any encryption state exists.
Conversion pages include reading pre-encryption receipts and captures queued notes. PUT /encryption/conversion/reading-capture publishes one encrypted queued bookmark and removes the original atomically. Receipt conversion accepts kind: "reading". Activation requires both inventories to be converted. An active configuration can enter conversion through a narrow Reading-scope extension: same vault/root keys and existing scopes, one additional Reading scope, and generation incremented by one. Saved original descriptors authenticate interrupted scope replacements. Reset clears Reading policy, sources, jobs, receipts, captures, enrollments and handoffs at both boundaries.
With e2ee-folder-moves-v1, POST /encryption/conversion also accepts changed paths for existing scopes: same vault/root key, scope count, scope IDs, purposes and data/notification key IDs, with generation incremented by one. It atomically updates matching feature-policy paths and preserves original descriptors for live and retained files under both old and new prefixes. An unchanged scope access identity plus an encrypted binding retains browser credentials, Reading policy generation and push subscriptions, updating their folder paths atomically. Selecting a different folder supplies a new accessId and revokes previous credentials/subscriptions. Unused setup links and handoffs are invalidated. Clients verify the scope binding before following a moved path. Conversion re-encrypts those files using the destination path’s current authority; it does not rename file records. Normal conditional sync performs local file moves afterward. This API does not rotate content keys.
Encrypted browser wire attempts retain their generation. After a generation change, GET /reading/encrypted-receipt?operationId=…&wire=1 returns the converted encrypted-file receipt from the shared operation ledger; the ordinary Reading receipt endpoint continues to serve Reading semantic receipts. Clients authenticate both the exact wire hash and the inner semantic acknowledgment before settling a save. Reminders uses its existing encrypted receipt endpoint for the same recovery.