zudo-text

検索したい単語を入力

いつでも検索バーを開ける

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 the publish-server Cloudflare 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 | null

Returns: PublicProfile if the user has set up a profile, null if no profile exists.

Server route: GET /api/v1/profile

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:

NameTypeDescription
usernamestringURL slug (must be unique across all users)
displayNamestringDisplay name shown on the public profile page
biostringShort biography

Returns: PublicProfile — the updated profile.

Requirements: Requires an active Pro subscription. Throws if the username is already taken.

Server route: PUT /api/v1/profile

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 /api/v1/profile

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:

NameTypeDescription
titlestringPage title
slugstringURL slug (must be unique for this user)
contentMarkdownstringFull markdown content
descriptionstring (optional)Short summary shown in link previews
sourceWorkspaceIdstring (optional)ID of the workspace that owns the source note
sourceNotePathstring (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 /api/v1/pages → 201

Publish decisions and explicit mutation intents

POST /api/v1/pages 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 /api/v1/pages/:pageId/replace 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 /api/v1/pages/additional 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:

StatusErrorMeaning
409publish_decision_requiredNormal publish or an additional-page retry needs an explicit target/slug decision.
409publish_decision_staleA 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.
409publish_idempotency_conflictThe same key was reused with a different operation or request fingerprint.
400validation_failedThe derived slug is invalid; fieldErrors.slug carries deterministic code, bounds, and pattern metadata.
403workspace_not_ownedThe supplied source workspace is not owned by the authenticated caller.
403subscription_requiredThe caller does not have the required publishing entitlement.
404page_not_foundAn 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:

NameTypeDescription
idstringPage ID returned by publishPage or listPages
data.titlestring (optional)New title
data.slugstring (optional)New URL slug
data.contentMarkdownstring (optional)New markdown content
data.descriptionstring (optional)New description
data.sourceWorkspaceIdstring (optional)Replacement source workspace ID; provide with data.sourceNotePath
data.sourceNotePathstring (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 /api/v1/pages/:id

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 /api/v1/pages/:id/unpublish{ 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 /api/v1/pages/:id/archive

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 /api/v1/pages/:id/restore

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 /api/v1/pages/:id

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 /api/v1/pages/archives

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 /api/v1/pages/:id/snapshot

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 /api/v1/pages/:id/password 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 /api/v1/pages/:id/password

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 /api/v1/pages/:id/password. 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 /api/v1/pages/:id/password

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 /api/v1/pages

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 /api/v1/public/u/:username/:slug.

Server route: GET /api/v1/pages/:id

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/POST /api/v1/auth/tokens, DELETE /api/v1/auth/tokens/:id) 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 immediately

Parameters:

NameTypeDescription
namestringHuman-readable label for the token
expiresAtstring (optional)ISO 8601 expiry timestamp; omit for non-expiring
scopesApiTokenScope[] (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.
workspaceIdstring (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:

MethodPathAuthDescription
GET/healthnoneHealth check
GET/u/:usernamenonePublic profile page (HTML)
GET/u/:username/:slugnonePublic page (HTML)
POST/u/:username/:slugnonePassword unlock form (HTML; protected pages only)
GET/api/v1/public/u/:usernamenonePublic profile and page list (JSON)
GET/api/v1/public/u/:username/:slugnonePublic page (JSON, including contentHtml)
GET/api/v1/profileJWTGet own profile
PUT/api/v1/profileJWTCreate/update profile
DELETE/api/v1/profileJWTDeactivate profile
POST/api/v1/pagesJWTPublish new page
POST/api/v1/pages/additionalJWTExplicitly create an additional page at a unique alternate slug
POST/api/v1/pages/:pageId/replaceJWTExplicit exact-source, revision-guarded replace/reactivate
GET/api/v1/pagesJWTList own pages
GET/api/v1/pages/:idJWTGet single page
PUT/api/v1/pages/:idJWTUpdate page
POST/api/v1/pages/:id/unpublishJWTTake an active page offline without archiving
POST/api/v1/pages/:id/archiveJWTTake a page offline and retain it in Archives
POST/api/v1/pages/:id/restoreJWTRestore an archived page as unpublished
DELETE/api/v1/pages/:idJWTPermanently delete an archived page only
DELETE/api/v1/pages/archivesJWTPermanently delete all caller-owned archived pages
GET/api/v1/pages/:id/snapshotJWTRead an owner-only Markdown/legacy-HTML snapshot
PUT/api/v1/pages/:id/passwordJWTEnable or rotate page shared-password protection
DELETE/api/v1/pages/:id/passwordJWTDisable page shared-password protection

Authenticated /api/* routes use the shared Better Auth service JWT. Public routes (/u/*, /api/v1/public/*) 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 /api/v1/pages/archives 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 /api/v1/pages, and an exact-source caller must then use the explicit replace endpoint to refresh both stored Markdown and rendered HTML snapshots.

GET /api/v1/pages 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://zudo-publish-server.takazudo.workers.dev/u/<username>/<slug>; 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=Strict

There 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 /api/v1/public/u/:username/:slug 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, nofollow

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