Publish and API Tokens
This page covers two bridge namespaces that enable the "publish note to a public URL" feature:
bridge.publish— manage the user's public profile and published pages. Backed by thepublish-serverCloudflare Worker (workers/publish-server/).bridge.apiTokens— manage Personal Access Tokens (PATs) for headless API access. The server-side PAT routes are documented in Sync Server API.
Neither namespace has Tauri commands — they call the publish and sync Workers directly over HTTPS.
Data Structures
PublicProfile
interface PublicProfile {
userId: string;
username: string; // Unique URL slug (e.g. "alice")
displayName: string;
bio: string;
isActive: boolean; // false if the profile was deactivated via deleteProfile
publicUrl: string; // PUBLISH_BASE_URL + "/u/<username>"
}PublishedPage
interface PublishedPage {
id: string;
slug: string;
title: string;
description: string;
contentHash: string; // SHA-256 of the published markdown content
isPublished: boolean;
publishedAt: string; // ISO 8601
updatedAt: string; // ISO 8601
archivedAt: string | null; // ISO 8601; non-null only while retained in Archives
passwordProtected: boolean; // safe gate-state flag; no verifier fields are exposed
publishRevision: number; // owner-scoped CAS revision; advances per successful state change
publicUrl: string; // PUBLISH_BASE_URL + "/u/<username>/<slug>"
/** Workspace that owns the source note; both provenance fields are either null or set. */
sourceWorkspaceId: string | null;
/** Workspace-relative source-note path, paired with sourceWorkspaceId. */
sourceNotePath: string | null;
}PublishedPageSnapshot
Owner-only content used by the Published Items copy flow. A modern record returns its original Markdown. A legacy record that predates the Markdown snapshot returns the rendered HTML explicitly marked as a fallback.
interface PublishedPageSnapshot {
content: string;
format: "markdown" | "html-fallback";
}Neither format is included in ordinary page list/get metadata. The snapshot route requires the authenticated owner and returns 404 for a missing or foreign page.
ApiTokenMeta
Metadata for a Personal Access Token. Raw token values are never returned in list or get responses.
type ApiTokenScope =
| "full"
| "documents:read"
| "documents:write"
| "agent:invoke"
| "assets:read"
| "assets:write";
interface ApiTokenMeta {
id: string;
name: string;
createdAt: string; // ISO 8601
lastUsedAt: string | null; // null if never used
expiresAt: string | null; // null for non-expiring tokens
revokedAt: string | null; // null if still active; set on soft-revoke
scopes: ApiTokenScope[]; // never empty — omitted at mint time defaults to ["full"]
workspaceId: string | null; // single workspace this token is restricted to, or null if unrestricted
}See Personal Access Tokens for what each scope grants, and the Automation API for the headless document surface these scopes gate.
ApiTokenMintResult
Returned only by apiTokens.create. Extends ApiTokenMeta with the raw token value.
interface ApiTokenMintResult extends ApiTokenMeta {
token: string; // Raw PAT — returned exactly once; cannot be recovered later
}Publish Bridge (bridge.publish)
bridge.publish.getProfile
Get the current user's public profile.
const profile = await bridge.publish.getProfile(); // PublicProfile | nullReturns: PublicProfile if the user has set up a profile, null if no profile exists.
Server route: GET /
bridge.publish.updateProfile
Create or update the user's public profile.
const profile = await bridge.publish.updateProfile({
username: 'alice',
displayName: 'Alice Smith',
bio: 'Writing about productivity and tools.',
});Parameters:
| Name | Type | Description |
|---|---|---|
username | string | URL slug (must be unique across all users) |
displayName | string | Display name shown on the public profile page |
bio | string | Short biography |
Returns: PublicProfile — the updated profile.
Requirements: Requires an active Pro subscription. Throws if the username is already taken.
Server route: PUT /
bridge.publish.deleteProfile
Deactivate the user's public profile.
await bridge.publish.deleteProfile();Returns: void on success.
Behavior: Sets is_active = 0 on the server — the profile row is not deleted. The public profile URL stops responding. getProfile will return a profile with isActive: false.
Server route: DELETE /
bridge.publish.publishPage
Publish a new note page.
const page = await bridge.publish.publishPage({
title: 'My Note',
slug: 'my-note',
contentMarkdown: '# My Note\n\nContent here.',
description: 'A short summary.',
sourceWorkspaceId: 'workspace-uuid',
sourceNotePath: 'inbox/1.md',
});Parameters:
| Name | Type | Description |
|---|---|---|
title | string | Page title |
slug | string | URL slug (must be unique for this user) |
contentMarkdown | string | Full markdown content |
description | string (optional) | Short summary shown in link previews |
sourceWorkspaceId | string (optional) | ID of the workspace that owns the source note |
sourceNotePath | string (optional) | Workspace-relative path of the source note; use forward slashes |
Returns: PublishedPage — the newly created page. New records are active and published (archivedAt: null, isPublished: true, passwordProtected: false).
Requirements: Requires an active Pro subscription. A slug conflict is not silently resolved; the server returns the typed publish_decision_required response described below.
The two source fields are an all-or-nothing pair: provide both or omit both. When provided, sourceWorkspaceId must be a non-empty string of at most 64 characters, and sourceNotePath must be a non-empty workspace-relative path of at most 512 characters that uses /, contains no empty, . or .. path segments, and ends in .md. A half-specified pair or any invalid value returns 400. If the workspace is not owned by the authenticated user, the server returns 403 with { "error": "workspace_not_owned" }.
The normal request creates a row only when the authoritative server classifier finds no matching source candidate and no slug conflict. An existing unpublished, archived, or published row never gets selected implicitly: the request returns 409 publish_decision_required. After the caller explicitly chooses the exact-source replace intent, that replace endpoint reactivates an unpublished or archived row, refreshes its Markdown/HTML snapshots and provenance, and preserves its ID, slug/public URL, original publishedAt, and password configuration. A different-source or legacy/unknown-source conflict cannot be replaced.
Server route: POST / → 201
Publish decisions and explicit mutation intents
POST / is an authoritative classification request. The Worker checks the owner-scoped slug and, when both source-provenance fields are present, all exact-source rows whose workspace is still owned by the caller. It never infers permission to overwrite from a slug or from a client-side list. If a safe choice is not unique, it returns 409 with this versioned shape instead of mutating a row:
interface PublishDecisionRequiredResponse {
error: "publish_decision_required";
contractVersion: 1;
exactSourceCandidates: PublishDecisionPage[];
slugConflict: {
relation: "same_source" | "different_source" | "legacy_unknown";
page: PublishDecisionPage;
} | null;
suggestedAlternateSlug: string | null;
allowedIntents: ("replace" | "create_additional")[];
endpoints: {
replace: "/api/v1/pages/:pageId/replace";
createAdditional: "/api/v1/pages/additional";
};
}PublishDecisionPage is a safe owner-visible projection containing the page ID, slug, title, lifecycle timestamps, passwordProtected, publishRevision, and public URL. It never contains Markdown, rendered HTML, verifier bytes, salts, epochs, or any other password material. Exact-source candidates are separate choices; clients must ask the user to select a page when more than one is returned. different_source and legacy_unknown conflicts never permit replacement, even if a slug happens to match.
Replace in place
POST / accepts an explicit intent:
interface ReplacePublishedPageIntent {
intent: "replace";
pageId: string;
expectedRevision: number;
idempotencyKey: string;
title: string;
description?: string;
contentMarkdown: string;
sourceWorkspaceId: string;
sourceNotePath: string;
}pageId, the complete source pair, and expectedRevision must still match the owner-scoped candidate. The compare-and-swap succeeds only for that exact revision and source, then increments publishRevision once. A successful replace preserves the page ID, slug, public URL, original publishedAt, and existing password configuration while replacing the content/HTML snapshot and metadata. It sets the row published and clears archivedAt, so an unpublished or archived exact-source row is deliberately reactivated.
The endpoint returns 200 with a safe PublishedPage. It is the only route that performs this replace/reactivate operation; the normal POST /pages route does not choose it implicitly.
Create an additional page
POST / accepts:
interface CreateAdditionalPublishedPageIntent {
intent: "create_additional";
idempotencyKey: string;
title: string;
description?: string;
slug: string;
contentMarkdown: string;
sourceWorkspaceId: string;
sourceNotePath: string;
}The slug must be unique for the owner and the source pair must be valid and owned. The endpoint returns 201 with a new PublishedPage at publishRevision: 1 and passwordProtected: false. Existing pages are untouched. If the selected alternate slug collides, the endpoint returns the same publish_decision_required contract with the occupying page; callers can keep the decision dialog open, change the slug, and retry without losing the captured content.
Staleness, validation, and retry errors
The bridge exposes these safe, typed responses rather than passing arbitrary Worker prose through to UI code:
| Status | Error | Meaning |
|---|---|---|
| 409 | publish_decision_required | Normal publish or an additional-page retry needs an explicit target/slug decision. |
| 409 | publish_decision_stale | A replace CAS lost a race. reason is revision_changed, source_changed, or page_missing; target is fresh safe metadata when available, and decision is a refreshed choice when one can still be made. |
| 409 | publish_idempotency_conflict | The same key was reused with a different operation or request fingerprint. |
| 400 | validation_failed | The derived slug is invalid; fieldErrors.slug carries deterministic code, bounds, and pattern metadata. |
| 403 | workspace_not_owned | The supplied source workspace is not owned by the authenticated caller. |
| 403 | subscription_required | The caller does not have the required publishing entitlement. |
| 404 | page_not_found | An explicit page target is missing or belongs to another caller. |
idempotencyKey must be 8–128 ASCII letters, digits, ., _, :, or -. The Worker fingerprints every behavior-affecting field, reserves the key in the shared D1 ledger, and stores the safe terminal response. After transport loss, retry the unchanged operation with the same key to replay the same result without creating a duplicate page. Once a typed response changes the target, alternate slug, or expected revision, the bridge uses a new request fingerprint and key; reusing the old key is rejected instead of replaying an outdated decision.
The renderer also binds the decision to an immutable account/workspace/note path/content/title/slug snapshot. If that context changes while a dialog is open, it discards the old choice and requires a new classification. This keeps the user confirmation, the server CAS, and idempotent retries on the same publication intent.
bridge.publish.updatePage
Update an existing published page.
const page = await bridge.publish.updatePage(pageId, {
title: 'Updated Title',
contentMarkdown: '# Updated\n\nNew content.',
});Parameters:
| Name | Type | Description |
|---|---|---|
id | string | Page ID returned by publishPage or listPages |
data.title | string (optional) | New title |
data.slug | string (optional) | New URL slug |
data.contentMarkdown | string (optional) | New markdown content |
data.description | string (optional) | New description |
data.sourceWorkspaceId | string (optional) | Replacement source workspace ID; provide with data.sourceNotePath |
data.sourceNotePath | string (optional) | Replacement workspace-relative source-note path; provide with data.sourceWorkspaceId |
Returns: PublishedPage — the updated page.
The source pair follows the same both-or-neither and validation rules as publishPage, including 400 for invalid input and 403 workspace_not_owned for a workspace the caller does not own. Omit both source fields to leave existing provenance unchanged. The id is the authoritative target; this method never chooses a page by slug or by list position. Each successful state change advances publishRevision once; an idempotent no-op leaves it unchanged. Updating a retained row does not publish it.
Server route: PUT /
bridge.publish.unpublishPage
Take a page offline without deleting it.
await bridge.publish.unpublishPage(pageId);Returns: void on success.
Behavior: Sets is_published = 0 while leaving the active record and its archivedAt: null state intact. The public URL stops responding. Re-publish it with a fresh publishPage request and review its decision, then choose the explicit replace intent if it is an exact-source candidate. The replace endpoint reactivates the unpublished row; updatePage does not re-publish a row.
Server route: POST / → { ok: true } (the bridge method returns void).
bridge.publish.archivePage
Archive an active page and take it offline atomically.
const page = await bridge.publish.archivePage(pageId);Returns: the updated safe PublishedPage with isPublished: false and a non-null archivedAt. The server sets is_published = 0 and records the first archive timestamp; repeated archive requests preserve that first timestamp. The page record, snapshot, source provenance, and password configuration are retained. The owner-only route is idempotent for an already archived row.
Server route: POST /
bridge.publish.restorePage
Move an archived record back to the active list without making it public.
const page = await bridge.publish.restorePage(pageId);Returns: the updated safe PublishedPage with archivedAt: null and isPublished: false. Restore is owner-only and idempotent for an already active unpublished row. It preserves the saved snapshot, source provenance, and password configuration.
Server route: POST /
bridge.publish.deletePage
Permanently delete one published-page record.
await bridge.publish.deletePage(pageId);The server accepts this only when the authenticated caller owns the record and archivedAt is non-null. Deleting an active/unarchived record returns 409 { "error": "page_not_archived" }; a missing or foreign record returns 404. The deletion removes the record, its Markdown/HTML snapshot, provenance, and password metadata. This operation is irreversible.
Server route: DELETE /
bridge.publish.emptyArchivedPages
Permanently delete every archived record owned by the caller.
const result = await bridge.publish.emptyArchivedPages();
// { deleted: number }Unarchived records and every other user's records are not affected. The route is the server-side equivalent of the Archives drawer's confirmed Empty archives… action and is irreversible.
Server route: DELETE /
bridge.publish.getPageSnapshot
Read the owner-only published content snapshot for copy/recovery.
const snapshot = await bridge.publish.getPageSnapshot(pageId);
// { content: string, format: "markdown" | "html-fallback" }format: "markdown" is returned when the row has the raw Markdown snapshot. Legacy rows with no raw snapshot return their stored rendered HTML as format: "html-fallback", so clients can warn before copying it. The route never exposes snapshot bytes through ordinary list/get metadata.
Server route: GET /
bridge.publish.setPagePassword
Enable or rotate the shared password for an owned page. Published Items exposes this control only for active rows; the authenticated route itself is owner scoped and does not require isPublished or archivedAt to have a particular value.
const page = await bridge.publish.setPagePassword(pageId, newPassword);The request is PUT / with JSON { "password": string }. Success returns only the safe PublishedPage, with passwordProtected: true; the password, verifier, salt, pepper, capability, and PBKDF2 values are never returned. Enabling and rotating increment the gate epoch, immediately invalidating previously issued visitor capabilities. The public URL does not change.
The body is limited to 1,024 bytes before parsing/hashing. The password must be 8–256 UTF-8 bytes, inclusive; whitespace is significant and is not trimmed.
Server route: PUT /
bridge.publish.removePagePassword
Disable shared-password protection for an owned page. The Published Items UI offers this only from an active row; the authenticated route remains owner scoped and preserves the same safe response contract for a retained record.
const page = await bridge.publish.removePagePassword(pageId);The request is DELETE /. Success returns only safe metadata with passwordProtected: false, clears verifier material, and increments the gate epoch so old capabilities stop working. The operation is safe for an already-unprotected owned page; configuration is still required.
Server route: DELETE /
bridge.publish.listPages
List all of the current user's pages.
const { pages, total } = await bridge.publish.listPages();Returns: { pages: PublishedPage[], total: number } — includes published, unpublished, and archived records. The bridge follows the server's pages until hasMore is false (or the collected count reaches total for compatibility with older servers) and returns the complete account history; callers do not need to paginate the bridge method. It requests at most 50 server pages and throws if more results remain. The Published Items UI partitions rows by archivedAt before applying its active-list filters.
Server route: GET /
bridge.publish.getPage
Get a single page by ID.
const page = await bridge.publish.getPage(pageId);Returns: PublishedPage — the standard bridge fields, including provenance. The authenticated route does not include contentHtml; the rendered HTML is available from the public JSON page route /.
Server route: GET /
bridge.publish.onPagesChanged
Subscribe to server-pushed page-change notifications.
const unlisten = bridge.publish.onPagesChanged(() => {
// Re-fetch listPages
});Returns: Unsubscribe function.
API Tokens Bridge (bridge.apiTokens)
Personal Access Tokens (PATs) allow headless API access to the sync server. The server-side token management routes (GET/, DELETE /) are documented in Sync Server API.
bridge.apiTokens.list
List all PATs for the current user (metadata only — no raw token values).
const tokens = await bridge.apiTokens.list(); // ApiTokenMeta[]Returns: All tokens including revoked ones (revokedAt set). Sorted newest first.
bridge.apiTokens.create
Mint a new PAT.
const result = await bridge.apiTokens.create({
name: 'CI pipeline',
expiresAt: '2027-01-01T00:00:00Z', // omit for a non-expiring token
scopes: [
'documents:read',
'documents:write',
'assets:read',
'assets:write',
],
workspaceId: 'workspace-uuid', // omit to leave the token unbound across the owner's workspaces
});
// result.token — raw PAT value; show to user immediatelyParameters:
| Name | Type | Description |
|---|---|---|
name | string | Human-readable label for the token |
expiresAt | string (optional) | ISO 8601 expiry timestamp; omit for non-expiring |
scopes | ApiTokenScope[] (optional) | Restricts the token. The current UI defaults to the four document/asset authoring scopes; the server treats omission as ["full"] for pre-scoped compatibility. |
workspaceId | string (optional) | Restricts the token to one workspace the caller owns; omit for unrestricted across the owner's workspaces |
Returns: ApiTokenMintResult — includes the raw token value. The raw token is returned exactly once and cannot be retrieved again — callers must display it to the user immediately.
assets:read permits opaque list/usage/download and the ownership-checked salt metadata exception; assets:write permits POST create only. Asset PATCH, DELETE, and batch-delete remain interactive/full. Add agent:invoke only for the agent-server. Full access is an explicit compatibility grant, not the local-agent default.
bridge.apiTokens.revoke
Soft-revoke a PAT.
await bridge.apiTokens.revoke(tokenId);Returns: void on success. Throws if the ID is not found or belongs to another user (server returns 404).
Behavior: Sets revokedAt on the server — the token row stays listed with revokedAt populated. The revoked token can no longer be used to authenticate.
Publish Server Routes
The publish Worker (workers/publish-server/) exposes these routes:
| Method | Path | Auth | Description |
|---|---|---|---|
GET | / | none | Health check |
GET | / | none | Public profile page (HTML) |
GET | / | none | Public page (HTML) |
POST | / | none | Password unlock form (HTML; protected pages only) |
GET | / | none | Public profile and page list (JSON) |
GET | / | none | Public page (JSON, including contentHtml) |
GET | / | JWT | Get own profile |
PUT | / | JWT | Create/update profile |
DELETE | / | JWT | Deactivate profile |
POST | / | JWT | Publish new page |
POST | / | JWT | Explicitly create an additional page at a unique alternate slug |
POST | / | JWT | Explicit exact-source, revision-guarded replace/reactivate |
GET | / | JWT | List own pages |
GET | / | JWT | Get single page |
PUT | / | JWT | Update page |
POST | / | JWT | Take an active page offline without archiving |
POST | / | JWT | Take a page offline and retain it in Archives |
POST | / | JWT | Restore an archived page as unpublished |
DELETE | / | JWT | Permanently delete an archived page only |
DELETE | / | JWT | Permanently delete all caller-owned archived pages |
GET | / | JWT | Read an owner-only Markdown/legacy-HTML snapshot |
PUT | / | JWT | Enable or rotate page shared-password protection |
DELETE | / | JWT | Disable page shared-password protection |
Authenticated / routes use the shared Better Auth service JWT. Public routes (/, /) are registered before the auth middleware and do not require a JWT.
All authenticated page lifecycle, snapshot, and password routes scope their queries and mutations to the JWT's user ID. Missing or foreign page IDs use the same 404 response as an absent page; the permanent-delete route additionally returns 409 { "error": "page_not_archived" } when an owned record is active. The static / route purges only archived rows for the authenticated user.
Public profile/list responses select only published, unprotected rows. A protected page is therefore omitted from public HTML/JSON lists; callers must use its direct URL. Archive and unpublish both take a page offline because public routes require is_published = 1. Restore clears archivedAt but leaves isPublished: false; republishing starts with POST /, and an exact-source caller must then use the explicit replace endpoint to refresh both stored Markdown and rendered HTML snapshots.
GET / accepts limit and offset query parameters. limit defaults to 20; it must be a non-negative integer no greater than 1000, and the effective limit is capped at 100. offset defaults to 0 and must be a non-negative integer. The route orders rows by updated_at DESC, id DESC and returns pages, total, limit, offset, and hasMore. Invalid limits or offsets return 400. The bridge requests pages with limit=100 and follows hasMore (or the total count) until it has collected every row.
The publicUrl values are built from PUBLISH_BASE_URL, for example https:; the zudo.pub domain is not configured by this Worker.
Public password behavior
Page password protection is a shared preview boundary, not login or named-user authorization. It cannot identify visitors, provide a visitor list or audit trail, or revoke one visitor independently. Anyone who knows the shared password may forward it. The UI and API never display or return the current password.
Protected HTML
GET /u/:username/:slug first reads safe page/gate metadata. A protected page without a current capability returns status 401 and a platform-owned, no-JavaScript form containing the page title, password field, and submit button. The challenge does not include description, author profile details, source workspace/note provenance, rendered content, verifier material, or other protected metadata.
The form posts application/x-www-form-urlencoded with a password field to the same path. The request body is capped at 1,024 bytes before verification; the password is 8–256 UTF-8 bytes, inclusive. Missing, malformed, short, long, or wrong values receive generic denial HTML. Well-formed wrong values return 401; malformed or bound failures return 400. An unknown, missing, or unprotected POST target returns generic no-store 404. Rate denial is generic 429 with Retry-After: 60. If the pepper or rate-limit binding is unavailable, unlock fails closed with generic 503 HTML.
Successful unlock returns 303 to the same page path and sets an HttpOnly capability cookie. The cookie is page-derived and has this shape (the digest and signature are opaque):
__Host-zudo-page-<page-hash>=v1.<unix-expiry>.<base64url-hmac>;
Max-Age=900; Path=/; Secure; HttpOnly; SameSite=StrictThere is no Domain attribute. The HMAC binds the capability to its page ID, lowercase request hostname, current gate epoch, and expiry. It is rejected when tampered, expired, used on another page or host, or issued before a password enable/rotation/removal. Different pages use different cookie names.
Protected JSON
GET / uses the same capability cookie. Without one, it returns the safe JSON challenge:
401
Cache-Control: private, no-store
{ "error": "password_required" }If the required pepper is missing or invalid, it returns 503 with { "error": "password_gate_unavailable" }. Once authorized, the response contains the page metadata, rendered contentHtml, and public author fields; it remains private and is never CDN-cached. The server performs the content query only after capability authorization succeeds.
Cache and CSP headers
Challenge, denial, unlock, and authorized protected HTML/JSON responses carry:
Cache-Control: private, no-store
CDN-Cache-Control: no-store
Vary: Cookie
X-Robots-Tag: noindex, nofollowOnly challenge/denial HTML changes CSP form-action to 'self' so the form can submit to its own path. Ordinary public HTML, including authorized protected page HTML, keeps the strict existing form-action 'none' policy. Unprotected profile HTML/JSON remains public, max-age=60, s-maxage=300; unprotected page HTML/JSON remains public, max-age=60, s-maxage=600.
Server-side password parameters
These values are implementation parameters, not bridge fields. The locked verifier v2 contract uses a domain-separated HMAC-SHA-256 prehash with the required Worker pepper, then PBKDF2-SHA-256 with exactly 100,000 iterations, a fresh random 16-byte per-page salt, and a 32-byte verifier. workerd rejects the former 600,000-iteration derivation before doing the work; a paid CPU plan does not lift that crypto-runtime ceiling, and the Worker must not emulate the old value in JavaScript or WASM. The lower runtime-compatible work factor is accepted because the prehash requires an independent server pepper: a D1-only compromise does not expose an independently testable verifier, while a compromise of both D1 and the Worker secret still permits offline guessing.
The complete storage tuple, fail-closed v2 validation, and the fact that no v1 conversion exists are recorded in the publish-server verifier contract. Fixed-size verifier and capability comparisons use Workers' timing-safe Web Crypto comparison. PUBLISH_PASSWORD_PEPPER is a required independent Worker secret; its value is never returned, logged, committed, or included in this page. Validation and deploy workflows must never read, regenerate, print, or change that secret. Password submissions use the configured Cloudflare Rate Limiting binding (5 calls per 60 seconds, location-local/eventually consistent defense in depth).