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;
  avatarAsset: { sha256: string; mediaType: string; width: number; height: number } | null;
  links: Array<{ id: string; label: string; href: string }>;
  branding: { mode: "attribution" | "none" } | { mode: "cta"; label: string; href: string };
  themeSelection: { formatVersion: 1; templateId: string; templateRevision: string; values: object };
  customPalette: { colors: string[]; bg: string } | null;
  sharingCardDefault: SharingCardSelection;
  thumbnailDefault: PublishedThumbnail | null;
  isActive: boolean;     // false when the profile has been deactivated
  publicUrl: string;     // PUBLISH_BASE_URL + "/u/<username>"
  profileRevision: number; // Increments on each successful profile mutation
}

PublishedPage

interface PublishedPage {
  id: string;
  slug: string;
  title: string;
  description: string;
  contentHash: string;   // SHA-256 of the published markdown content
  tags: string[];        // normalized, deduplicated, UTF-8 ordered
  visibility: "public" | "unlisted";
  thumbnail: PublishedThumbnail | null;
  thumbnailUrl: string | null; // authenticated owner-preview URL
  sharingCard: SharingCardSelection;
  themeSelection: PublishingThemeSelection | null;
  ogp: { status: "ready" | "pending" | "failed" | "none"; url?: string };
  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>"
  presentation: PublicationPresentation; // Resolved page layout, theme, options and sharing
  /** 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 design snapshot returned by GET /api/v1/pages/:id/snapshot:

interface PublishedPageSnapshot {
  markdown: string | null;  // null for a legacy record without raw Markdown
  presentation: PublicationPresentation;
  title: string;
  description: string;
  slug: string;
  tags: string[];
  visibility: "public" | "unlisted";
  thumbnailUrl: string | null;
  ogp: { status: "ready" | "pending" | "failed" | "none"; url?: string };
  thumbnail: PublishedThumbnail | null;
  sharingCard: SharingCardSelection;
  themeSelection: PublishingThemeSelection | null;
  revision: number;         // Current publishRevision for a guarded update
  documentHtml: string; // Stored snapshot, or the default-rendered document for a legacy row
  assets: PublishedSnapshotAsset[]; // Page-scoped refs and digests for owner reuse
}

Ordinary list/get metadata does not include documentHtml. The owner-only route returns 404 for a missing or foreign page. Published Items uses the snapshot for recovery and design editing; markdown: null denotes a legacy record. For a row without stored document_html, the Worker returns the default-rendered document from its stored body HTML. It becomes a frozen stored snapshot when republished.

PublicationPresentation

@takazudo/publication-renderer owns this strict, versioned shape. The Worker validates all fields, rejects unknown keys with 400 invalid_presentation, and uses defaultPresentation() when creation omits it. A built-in readingTheme slug is required; custom CSS is not accepted.

interface PublicationPresentation {
  formatVersion: 2;
  starterId: "article" | "guide" | "journal" | "plain";
  layout: "article" | "guide" | "journal" | "plain";
  readingTheme: PreviewThemeSlug;
  options: {
    readingWidth: "comfortable" | "wide";
    showAuthor: boolean;
    showPublishedDate: boolean;
    showUpdatedDate: boolean;
    contents: "off" | "auto";
  };
  language: string; // BCP-47 language tag
  sharing: { title: string | null; description: string | null };
}

formatVersion: 2 removes cover, focal point, and sharing-image fields. thumbnail is the page image; sharingCard independently selects the sharing image. Stored v1 rows return 409 snapshot_required on ordinary snapshot updates and implicit replacements. Recovery uses explicit replace with fresh Markdown, a v2 presentation, and complete tags/thumbnail/sharingCard/themeSelection, preserving the row and URL.

starterId records the starting design, while layout, readingTheme, and options are the resolved choices. A null sharing override inherits the page title or description. See Publication Design for snapshot and fingerprint semantics.

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

The authenticated response is the bare PublicProfile object; a missing profile returns 404 with JSON null. The response includes the saved publishing defaults and current profileRevision.

bridge.publish.updateProfile

Create or update the user's public profile.

const profile = await bridge.publish.updateProfile({
  expectedProfileRevision: 0, // 0 creates a profile; otherwise use the revision from getProfile()
  username: 'alice',
  displayName: 'Alice Smith',
  bio: 'Writing about productivity and tools.',
});

Parameters:

NameTypeDescription
expectedProfileRevisionnumberRequired compare-and-swap revision; use 0 for create
usernamestringURL slug (must be unique across all users)
displayNamestringDisplay name shown on the public profile page
biostringShort biography
avatar{ sha256: string } | nullCommit an owned staged image, reuse an owned avatar, or clear it
linksArray<{ id, label, href }>Up to five links; labels are plain text and destinations must use HTTPS
brandingPublishingBrandingAttribution, no profile branding, or a validated CTA
themeSelectionPublishingThemeSelectionBuilt-in reading-theme ID and bounded theme values
customPalettePublishingCustomPalette | nullPro default with one to six RGB colors and a background
sharingCardDefaultSharingCardSelectionDefault sharing-card design
thumbnailDefaultPublishedThumbnail | nullDefault thumbnail design

Returns: PublicProfile — the updated profile.

Free and Pro can create a basic profile. The server resolves capabilities for plan-gated defaults and rejects paid selections with 403 capability_required plus field; a failed capability lookup returns 503 subscription_unavailable when a gated value is being changed. Every PUT requires expectedProfileRevision. A stale revision returns 409 { error: "profile_revision_stale" }; a create must use revision 0. Unknown fields, raw CSS, unsupported theme values, oversized subtitles, and invalid links or color values are rejected. New usernames follow the shared reserved-name list; an existing reserved username may be retained unchanged. Plan-gated defaults are checked against GET /api/v1/capabilities, and an unchanged saved Pro value remains usable after downgrade. Saving profile settings never reactivates a deactivated profile. An already-taken username returns 409 username_taken.

Server route: PUT /api/v1/profile

Profile avatar uploads and reactivation

Stage an avatar by sending its raw bytes to the authenticated POST /api/v1/profile/assets route with X-Content-SHA256: <64 lowercase hex digits>. The Worker accepts PNG, JPEG, and WebP images up to 2 MiB with each dimension at most 2048 pixels. It verifies the digest and image header before returning an upload receipt. Commit the returned digest with avatar: { sha256 } in the revision-guarded profile PUT. The Worker only commits a staged upload owned by the caller or reuses an already committed avatar owned by that account.

Public avatar bytes are served from GET /p/profile/:username/:sha256 only while that profile is active and still references the asset. Both successful and denied responses use private no-store headers.

Reactivation is an explicit confirmation because it can make previously published pages public again:

{
  "expectedProfileRevision": 4,
  "acknowledgePublishedPosts": true
}

Send this body to authenticated POST /api/v1/profile/reactivate. A stale revision returns 409 profile_revision_stale; a successful reactivation increments profileRevision. The ordinary profile PUT does not reactivate.

Template catalog metadata

Authenticated GET /api/v1/publish/templates returns the approved pattern template identities visible to the caller's current capabilities, supported catalog fields, palette tokens, and the thumb / ogp slice descriptors. This is metadata only: the response reports renderReady: false while released template and font artifacts are unavailable. A visible catalog entry is not evidence that the server can render it.

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({
  idempotencyKey: crypto.randomUUID(),
  title: 'My Note',
  slug: 'my-note',
  contentMarkdown: '# My Note\n\nContent here.',
  description: 'A short summary.',
  sourceWorkspaceId: 'workspace-uuid',
  sourceNotePath: 'inbox/1.md',
  presentation: defaultPresentation(),
});

Parameters:

NameTypeDescription
idempotencyKeystringRetry key carried in the Idempotency-Key header
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
presentationPublicationPresentationFull resolved public design. The raw Worker route permits omission and then uses the default presentation.
assetsPublicationAssetDeclaration[] (optional)Exact declarations for all local body refs and chosen thumb/ogp assets; omit when none are used.
visibility"public" or "unlisted"Initial listing visibility; raw Worker omission defaults to public.
passwordstring (optional)Initial shared password, validated and committed with the first snapshot and gate epoch in the same D1 transaction.
tagsstring[]Shared normalization: up to 10 entries, each normalized tag at most 32 scalars; malformed values return 400 invalid_tag.
thumbnailPublishedThumbnail or nullApproved pattern metadata or a verified 3:2 thumb asset.
sharingCardSharingCardSelectionMode, treatment, followThumbnail and optional picture/ogpAsset.
themeSelectionPublishingThemeSelectionApproved theme identity/revision and bounded semantic overrides.

Returns: PublishedPage — the newly created page. New records are active and published (archivedAt: null, isPublished: true, passwordProtected reflects the optional initial password).

Requirements: Free and Pro can publish. The server resolves capabilities and rejects paid selections with 403 capability_required plus field; subscription lookup outages return 503 subscription_unavailable. 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.

Retry: Send a valid Idempotency-Key header (8–128 permitted ASCII characters) with the create request. An identical retry replays the prior safe response; a different request under the same key returns 409 publish_idempotency_conflict. The caller supplies idempotencyKey to publishPage; the bridge carries it in the HTTP header.

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;
  presentation: PublicationPresentation;
  sourceWorkspaceId: string;
  sourceNotePath: string;
  visibility?: "public" | "unlisted";
  password?: string;
  tags?: string[];
  thumbnail?: PublishedThumbnail | null;
  sharingCard?: SharingCardSelection;
  themeSelection?: PublishingThemeSelection;
  assets?: PublicationAssetDeclaration[];
}

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 when password is omitted; an explicit password atomically enables or rotates the verifier 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;
  presentation: PublicationPresentation;
  sourceWorkspaceId: string;
  sourceNotePath: string;
  visibility?: "public" | "unlisted";
  password?: string;
  tags?: string[];
  thumbnail?: PublishedThumbnail | null;
  sharingCard?: SharingCardSelection;
  themeSelection?: PublishingThemeSelection;
  assets?: PublicationAssetDeclaration[];
}

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; passwordProtected reflects the optional initial password. 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.
403capability_requiredA selection requires a capability; field identifies the rejected control.
503subscription_unavailableCapability lookup failed safely.
503password_gate_unavailableInitial protection cannot be configured or derived; no snapshot is committed.
400invalid_tagInvalid tag/list according to the shared fixtures.
400invalid_visibility, invalid_thumbnail, invalid_sharing_card, invalid_theme_selectionInvalid v2 metadata shape or catalog selection.
400invalid_asset_ratio, asset_upload_role_mismatch, asset_selection_dimensions_mismatchUploaded bytes do not match the selected image role or dimensions.
409snapshot_requiredStored v1 design requires an explicit fresh v2 replacement snapshot.
422publication_blockedThe renderer found blocking diagnostics (for example an unresolved or unsupported local asset); the response includes diagnostics and does not save a snapshot.
404Page not foundOwner GET/PUT/snapshot target is missing or foreign; password routes use page_not_found. Explicit replace reports publish_decision_stale with reason page_missing.

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.',
  expectedRevision: currentPage.publishRevision,
  presentation: currentPage.presentation,
});

Parameters:

NameTypeDescription
idstringPage ID returned by publishPage or listPages
data.expectedRevisionnumber (required)Current positive publishRevision; stale writes return 409 publish_decision_stale
data.titlestring (optional)New title
data.slugstring (optional)New URL slug
data.contentMarkdownstring (optional)New markdown content
data.descriptionstring (optional)New description
data.presentationPublicationPresentation (optional)Resolved design; omit to retain the saved design
data.tagsstring[] (optional)Replace normalized tags transactionally; omission retains them.
data.visibility"public" or "unlisted" (optional)Listing visibility.
data.thumbnailPublishedThumbnail or null (optional)Thumbnail selection; null removes it.
data.sharingCardSharingCardSelection (optional)Sharing image selection.
data.themeSelectionPublishingThemeSelection (optional)Canonical effective reading theme.
data.assetsPublicationAssetDeclaration[] (optional)Exact staged/published manifest; omission reuses current matching rows.
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

Revision-guarded metadata and visibility patches

PATCH /api/v1/pages/:id/metadata accepts expectedRevision and any of title, description, tags, thumbnail, sharingCard, themeSelection, and the exact assets manifest. PATCH /api/v1/pages/:id/visibility accepts only expectedRevision and visibility. Both return owner PublishedPage metadata, enforce the current revision, and bump it once for a semantic change. Tag-only and thumbnail-only edits count. Omitted fields are retained; unchanged saves retain the revision. These routes never reactivate a draft or archived page. Visibility changes work during subscription lookup outages.

PublishingThemeSelection is { formatVersion: 1, templateId, templateRevision: "1", values }. The catalog validates identity separately from picture templates. Override keys are accent (approved palette token), pattern (approved pic-* identity), font (sans | serif | mono), and subtitle (plain text, at most 120 scalars). Unknown keys, raw CSS, paths and remote font URLs are rejected. Capability validation applies to the canonical effective theme; presentation.readingTheme cannot bypass it.

Publication asset staging and reads

bridge.publish.uploadPublicationAsset({ bytes, sha256, role }) stages raw bytes at POST /api/v1/publication-assets using X-Asset-Role: image | attachment | thumb | ogp and X-Content-SHA256: <64 hex digits>. It inspects PNG/JPEG/GIF/WebP/AVIF dimensions. Thumb requires ratio 1.5 ± 0.01; OGP requires 1.905 ± 0.01. For example, 1200×800 is a thumb and 1200×630 is OGP; 1200×700 fails 400 invalid_asset_ratio. Attachments remain octet-stream with null dimensions. Each upload is at most 25 MiB; staged-byte quotas are 200 MiB Free and 500 MiB Pro, with at most 300 attempts per day. Identical owner/digest/role retries renew the staging receipt for 24 hours. A digest staged as image cannot be reused as thumb or ogp.

Create/replace/additional/PUT and metadata patches accept an exact assets manifest. Body declarations use { ref, kind?: "image" | "attachment", sha256, uploadId } or published instead of uploadId. Role declarations use { role: "thumb" | "ogp", sha256, uploadId } or published, where published is the same role token. A photo thumbnail must match a thumb upload/row and its inspected dimensions. Picture-mode sharing of that photo requires an ogpAsset digest and an ogp declaration. No cover/share wire aliases are accepted. Verified bytes copy to pages/<pageId>/<sha256> before the guarded D1 snapshot/asset/tag transaction. Failed transactions remain eligible for orphan cleanup. Counts are limited to 50 assets and 100 MiB of distinct digests per page.

409 asset_upload_expired requires restaging its listed refs with the same required role. Owner GET /api/v1/pages/:id/assets/:role/:sha256 serves inline, download, thumb and ogp roles (and read-only legacy cover/share rows) with private/no-store headers, checking page ownership and live matching asset rows. thumbnailUrl uses this authenticated route. Approved automatic pattern metadata can be saved while rendering admission remains closed; its generated OGP status is none. Verified uploaded OGP bytes report ready with an owner-preview URL. The later render pipeline owns real automatic image readiness, never a fake renderer.

Public image URLs (including thumb/ogp) are /p/:pageId/i/:sha256; attachment URLs are /p/:pageId/d/:sha256. Public routes enforce current publication/profile/password state and return no-store plus CDN no-store.

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's saved publication design and content for editing or recovery.

const snapshot = await bridge.publish.getPageSnapshot(pageId);
// { markdown, presentation, title, description, slug, revision, documentHtml }

markdown and documentHtml may be null for a legacy record. documentHtml is the saved document snapshot, not an input the client may submit as trusted HTML. This route is owner-only and the response is not present in page 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 timeline (HTML)
GET/u/:username/tag/:tagnonePublic tag timeline (HTML; empty tag is404)
GET/api/v1/public/u/:username/tag/:tagnonePublicTimeline v2 filtered by tag (JSON; empty tag is200)
GET/p/:pageId/i/:sha256page capability if protectedPublished inline/legacy cover/share image, thumb or OGP (thumb/OGP denied for protected pages)
GET/p/:pageId/d/:sha256page capability if protectedPublished attachment download
GET/p/profile/:username/:sha256noneActive-profile avatar with no-store headers
GET/u/:username/:slugnonePublic page (HTML)
POST/u/:username/:slugnonePassword unlock form (HTML; protected pages only)
GET/api/v1/public/u/:usernamenonePublicTimeline v2 (JSON)
GET/api/v1/public/u/:username/:slugnonePublicSinglePost v2 (JSON, including post.bodyHtml)
GET/api/v1/profileJWTGet own profile
PUT/api/v1/profileJWTCreate/update profile
DELETE/api/v1/profileJWTDeactivate profile
POST/api/v1/profile/reactivateJWTExplicitly reactivate profile after acknowledgement
POST/api/v1/profile/assetsJWTStage an avatar image
GET/api/v1/publish/templatesJWTRead capability-filtered template metadata
POST/api/v1/publication-assetsJWTStage publication asset bytes
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 owner-only Markdown, presentation, asset manifest, metadata, revision and rendered document snapshot
GET/api/v1/pages/:id/assets/:role/:sha256JWTRead an owner-only asset preview
PATCH/api/v1/pages/:id/metadataJWTUpdate title, description, tags and image/theme selections under expectedRevision
PATCH/api/v1/pages/:id/visibilityJWTChange public/unlisted visibility under expectedRevision
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 HTML and JSON timelines share one predicate: published, unarchived, visibility = "public", all password verifier fields null, and active profile. Unlisted and protected posts are omitted from timelines and aggregate tag chips. Direct public/unlisted URLs remain available; unlisted and protected responses carry X-Robots-Tag: noindex. Archived and unpublished posts return404.

GET /api/v1/public/u/:username and its /tag/:tag variant return {contractVersion: 2, profile, items, nextCursor}. profile contains only username, displayName, bio, avatarUrl, public links and canonicalUrl. Each item contains id, slug, title, description, tags, publishedAt, updatedAt, canonicalUrl, thumbnailUrl and ogImageUrl. No owner IDs, source paths, Markdown, password verifier material, counts or internal design configuration are exposed. The single-post endpoint returns {contractVersion: 2, profile, post} with the same summary plus bodyHtml and passwordProtected; password errors contain only {error: "password_required"} without body content.

Timelines use20 items ordered by published_at DESC, id DESC. ?cursor= is a purpose-separated HMAC-SHA256 signed, canonical base64url token, capped at2048 characters, bound to an opaque HMAC owner scope, normalized tag, limit20 and last published-at/ID tuple, expiring after24 hours. Signing uses the validated PUBLISH_PASSWORD_PEPPER with distinct cursor/owner domains; a missing signing key when pagination is required returns503 cursor_unavailable. Tampering, expiry or replay on another owner/tag returns400 invalid_cursor. Each page rechecks current visibility and uses a strict lower tuple. The cursor is not a transactional snapshot. Tag path keys are percent decoded and normalized with the shared tag rules. An unknown/empty tag yields HTML404 and empty JSON200. HTML profiles with zero posts still return200. GET trailing slashes redirect308 to the canonical path while preserving query parameters.

Every public HTML/JSON/asset response, including errors and redirects, carries both Cache-Control: no-store and CDN-Cache-Control: no-store. No shared HTTP cache is used. Asset reads authorize current page/profile/role before R2 and recheck after the read. Thumb and OGP assets never serve for protected posts, even with a capability cookie; authorized body images/downloads remain usable. Profile avatars use the profile-owned /p/profile/:username/:sha256 route. Article footer branding comes from the live profile and current plan (Free always attribution), outside saved body content. Profile reading font/accent reuse the validated theme override helper. CSP remains unchanged.

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: 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 contractVersion: 2, public profile, and post metadata with rendered bodyHtml; it remains private and is never CDN-cached. The server performs the content query only after capability authorization succeeds.

Cache and CSP headers

The new document snapshot keeps the existing public-page CSP: default-src 'none'; img-src 'self' https:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'. The renderer emits inline, template-owned CSS and no scripts. Live profile values fill escaped canonical, OG URL and author slots at serve time.

Challenge, denial, unlock, and authorized protected HTML/JSON responses carry:

Cache-Control: 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 and page HTML/JSON also carry Cache-Control: no-store plus CDN-Cache-Control: no-store.

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

2026-10 amendments

採択した実装契約(2026-10-06、#7328 / #7329)。以下は承認済み UI に合わせた追記です。この追記と以前の提案・現行説明が矛盾する場合は追記を優先します。追記自体は runtime の実装・release・deploy の証拠ではありません。上書きの全体表と型の正本は Publish Next ADR、描画境界は Publish OGP ADR を参照してください。

v1 のまま変更する wire contract

このページの以前の型・route 一覧は現行調査の記録として残します。この epic は /api/v1/profile、/api/v1/pages/*、/api/v1/publication-assets、/api/v1/public/u/:username と /api/v1/public/u/:username/:slug、HTML の /u/* を維持し、client/server を同時出荷します。GET /api/v1/capabilities を追加し、有料 field の拒否は 403 {error: "capability_required", field: "…"}。

presentation は formatVersion: 2 に切替え、options.cover、focalPoint、sharing.image を除去します。旧 version 1 の stored document_html はそのまま配信し、owner update は 409 snapshot_required。新 composer が明示 snapshot と version 2 を republish するまで自動 re-render しません。安全な停止・保護操作を妨げず、migration/backfill は作りません。

post に thumbnail と sharingCard を追加します。型の正本は Publish Next ADR の追記です。thumbnail は {kind: "pattern", picture: PictureSpec} | {kind: "asset", sha256, width, height, pictureId} | null。PictureSpec は {pictureId, templateId, templateRevision, palette, customColors?, seedCounter}、client UUID と integer 0–999 から pic:<pictureId>:<seedCounter> seed を作ります。post-level counter はありません。

sharingCard は {mode: "card" | "picture" | "picture-title", treatment: "plain" | "outline" | "plate" | "band", followThumbnail: boolean, picture?: PictureSpec, ogpAsset?: {sha256}}。unlinked non-card に picture、写真 + picture に client-made 1200×630 ogpAsset が必須。写真 + picture-title は延期。legibility verdict は client derived value で保存しません。mode / treatment / 連動 / resolved picture / derivative は OGP identity に含めます。

写真 thumbnail は client が一度だけ crop した 1200×800、sharing derivative は 1200×630。upload / declaration / snapshot / owner preview の asset roles に thumb / ogp を使い、server は inspectImage の実寸による比率 1.5 ± 0.01 / 1.905 ± 0.01、digest と ownership を検証します。旧 cover / share role は新規 version 2 の選択に使いません。pattern registry は thumb-3x2 / ogp slices、Palette catalog token と Pro の最大 6 #rrggbb + background の Custom colors、titled OGP の Title / Author / Text treatment を検証します。

public timeline は ADR の contractVersion: 2, profile, items, nextCursor shape に変わり、旧 pages は返しません。public summary / single DTO に thumbnailUrl: string | null を加え、ADR の signed cursor / tag query / visibility rules を同じ v1 route で適用します。owner listPages の旧 pages/offset 契約と public timeline の items 契約を混同しません。timeline の contractVersion: 2 と route namespace の /api/v2 は別の版です。延期するのは v2 route set と ledger fingerprint rules です。

profile defaults と初回 protection

PublicProfile に themeSelection, branding, customPalette, sharingCardDefault, thumbnailDefault を保存します。Save as defaults は一組だけ、profile 未作成では Create your public profile step を出します。profile save は expectedProfileRevision を検査し、stale は 409 profile_revision_stale、is_active は変更しません。reactivate は明示 action です。

create/replace は visibility: "public" | "unlisted" と任意 initial password を受け、snapshot と同じ atomic commit で protection を確定します。既存 password を省略で解除せず、明示解除・回転を維持します。Free は curated 4 themes、3 templates + 2 palettes、card / picture、mandatory attribution。Pro は全 themes、accent / pattern / font / subtitle overrides、treatments、custom colors、unlinked card picture、attribution / own text / CTA を許可します。

no-store と OGP admission

public HTML / JSON / /p/ / /og / thumbnail の全応答に Cache-Control: no-store と CDN-Cache-Control: no-store。unlisted / protected は X-Robots-Tag: noindex。authorization を内部 bytes cache / R2 read より先に行います。以前の public max-age / s-maxage はこの epic では採用しません。後から no-store にしても shared cache 済みの応答を取り消せず、visibility 変更後に最大 600 秒漏れるためです。cache は内部 render reuse だけに限定します。

admission open の render job は atomic commit の一部で、初回失敗は公開なし、update 失敗は旧 snapshot / association を保持。closed は publish 成功、ogp.status: "none" と generic service card。automatic pictures の delivered 判定は S20 release-consumer proof 後です。

この epic からの延期

次の項目を後続 epic へ移します。このリストは実装済み・互換経路の追加を意味しません。

  • /api/v2 route set。

  • username/slug alias tables と、それらを使う 308 aliases。

  • POST /posts/:id/rename。

  • POST /posts/archives/purge。

  • idempotency ledger v2 fingerprint rules。

  • 写真 thumbnail 上の picture-title(photo + title)。pgen が image inputs を受け付けるまで延期。

  • named presets。v1 は profile 上の一組の既定値だけを持ち、操作名は Save as defaults。

Draft picture preview

POST /api/v1/publish/preview は owner JWT と現在の capability を検査し、最大 8192 bytes の JSON を受け取ります。

{
  kind: "thumb" | "ogp";
  picture: PictureSpec;
  mode?: "card" | "picture" | "picture-title";
  title?: string; // 最大 200 Unicode scalars
  author?: string; // 最大 80 Unicode scalars、省略時は現在の display name
  treatment?: "plain" | "outline" | "plate" | "band";
  themeSelection?: PublishingThemeSelection;
}

thumb は picture と任意 themeSelection だけを使います。ogp の mode 省略時は title があれば card、なければ picture。treatment の既定値は plain。未知のキー、無効な PictureSpec、範囲外の title/author、thumb への OGP 専用キーは 400 invalid_preview、大きすぎる本文は 413 payload_too_large、権限違反は 403 capability_required と field を返します。現在の released renderer/template/font が利用できない場合は 503 {error:"ogp_not_admitted"}。client は Preview appears when rendering is enabled を表示し、ローカルで pattern を描きません。

admission open の成功結果は {url, hash, width, height}。thumbnail は 1200×800、OGP は 1200×630 の PNG を使います。owner-only GET /api/v1/publish/preview/:hash.png は JWT の owner と 24 時間の expiry を検査して bytes を返し、失効・別 owner は 404。すべての応答は Cache-Control: private, no-store と CDN-Cache-Control: no-store。保存先は og/owners/<owner>/drafts/<hash>.png、24 時間後の GC は公開 post の寿命と独立です。

preview と publish は pic:<pictureId>:<seedCounter>、registry slice、実際の fields、mode/treatment、runtime/font/policy identity を共有します。card は sharingCard.picture に preview と同じ immutable PictureSpec を保存し、post ID 発行によって seed を変えません。picture/picture-title は連動時の thumbnail.picture または独立時の sharingCard.picture を送ります。同じ resolved PictureSpec の連動フラグ自体は image hash を変えません。title/author を含む preview は publish の title と現在の display name に一致する場合に再利用できます。theme の subtitle や nondefault render font は現時点では 422 unsupported_visual_override(field 付き)です。

render miss は publish と同じ atomic lease と 30 回/時・100 回/日の owner quota を使い、超過は 429 render_quota_exceeded と retryAfter:60。同一 owner の有効な draft cache hit と publish 時の verified byte copy は miss を増やしません。公開時は draft の request identity と integrity を検査し、独立した post object にコピーして、通常の snapshot transaction で association を commit します。draft preview だけでは post や public association を作りません。

production admission は released consumer proof が済むまで閉じたままです。test-only FakeExecutor の 1×1 PNG は orchestration の検証用であり、本番の画質・寸法の証拠ではありません。