Skip to content

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.

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.

MethodPathPurpose
GET/.well-known/cratePublic service, version, protocol range, and capability metadata
GET/healthHealth check, returns { status, timestamp }
POST/links/titleAuthenticated 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/metadataFetch 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/deleteDelete single file with expected hash and revision
POST/sync/batch-uploadBatch upload { files: [...] } (max 3 files, 10 MiB total)
POST/sync/batch-downloadBatch download { paths: [...] } (max 50 paths and 8 MB decoded)
POST/sync/batch-deleteConditional batch delete { files: [...] } (max 4 files)
GET/sync/checkpointsList up to 20 unexpired shared checkpoints
POST/sync/checkpointsCapture 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-versionRestore a retained version with expected-hash compare-and-swap
GET/diagnosticsBackend counts, delivery failures, queue pressure, and scheduled-maintenance state
DELETE/auth/tokensRevoke an auth token { id }
GET/auth/tokensList all registered auth tokens
DELETE/auth/sessionAtomically revoke the current bearer token and its owned push subscriptions
GET/settingsGet shared settings from R2
PUT/settingsStore shared settings to R2
GET/reminders/list?folderPath=<path>List reminders and projects from synced Markdown files
POST/reminders/createCreate a reminder in a project Markdown file
POST/reminders/updateUpdate or move a reminder
POST/reminders/set-completedToggle reminder completion
DELETE/reminders/deleteDelete a reminder from Markdown
POST/reminders/reorderReorder reminders inside a project file
GET, POST, PUT/reminders/notification-policyRead, initialize, or explicitly update shared notification policy (vault scope)
POST/notifications/reminders-enrollment-tokenCreate one-time browser and install tokens for a reminders web app link (vault tokens only)
POST/notifications/subscribeSave a push subscription
DELETE/notifications/subscribeRemove a push subscription
GET/notifications/subscriptionsList push subscriptions
POST/notifications/testSend a test push notification
MethodPathPurpose
GET/Returns public service, version, protocol range, and capability metadata
GET/notificationsServes the reminders PWA HTML
GET/notifications/app.jsServes the bundled PWA client
GET/notifications/sw.jsServes the PWA service worker
GET/notifications/manifest.jsonServes the PWA manifest
GET/notifications/version.jsonReturns the current PWA asset version
GET/notifications/icon.svgServes the PWA icon
GET/notifications/open-obsidianServes the browser handoff page for opening Obsidian
GET/notifications/vapid-public-keyReturns the Web Push VAPID public key
POST/notifications/reminders-exchangeExchanges a one-time web enrollment token for a PWA auth token

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

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.

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.

  • 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-Hash is the 64-character remote hash observed while planning, or absent for 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 409 with { 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 410 operation_expired. See upload recovery.
  • Query: ?path=<url-encoded-path>
  • Response headers: Content-Type, Content-Length, X-File-Hash, X-Crate-Revision
  • Body: raw binary (streamed from R2)
{
"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.

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.

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.

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.

Query: ?since=<seq>

Response: { changes: [...], lastSeq, hasMore, cursorExpired? }

Paginated at 5000 entries. Client loops until hasMore is false.

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"
}

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.

Revoke a token by its ID.

Request: { id: "<uuid>" }

Response: { success: true }

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 }] }

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 }

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.

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.

  • 400 invalid_reminder_input, with the rejected field: 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.
  • 400 without a code: route checks such as a missing folderPath, id or filePath, an invalid project on create, an invalid repeat, or unreadable orderedIds or expectedOrder.
  • 404: the reminder or project note no longer exists.
  • 409 version_conflict for a stale expectedRevision; reminder_markdown_context for duplicate identities or Markdown that cannot be edited safely; reminder_order_conflict for a reorder the note cannot apply safely, such as one that includes completed or nested reminders; duplicate_reminder_identity when the identity occurs more than once in the reminders folder; operation_mismatch when an operation ID is reused for different changes. A create whose identity already exists, a changed project order and a concurrent note write return 409 without a code.
  • 410 operation_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 missing operationId or expectedOrder, or revision_required for a missing or malformed expectedRevision.

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.

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 }

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.

Request: { operationId, expectedRevision, folderPath, filePath, id, completed }

Response: { success: true, reminder }. Completing a repeating reminder with later occurrences returns the next one, still open.

Request: { operationId, expectedRevision, folderPath, filePath, id }

Response: { success: true, id }

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.

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.

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.

Authenticated. Remove a push subscription.

Request: { id }

Response: { success: true }

Authenticated. List all push subscriptions.

Response: { subscriptions: [{ id, device_name, created_at }] }

Authenticated. Send a test push to all subscriptions.

Response: { sent, failed, pruned, errors }

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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 contains version: 1, unique id, canonical origin, vaultId, generation and enrolled scopeId. A Reminders or Reading credential must own that scope; Reading capture credentials and vault devices cannot start app requests.
  • accept supplies { id, key } from one vault device; reveal supplies the committed requester public key from the owning app. Public keys are raw P-256 points encoded as base64url. approve supplies { id, payload } from that same vault device after comparison. The server handles only encrypted packets.
  • cancel closes a request; finish acknowledges 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.

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.

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.