Skip to content

feat(share-backend): publish, snapshot read, revoke, republish#359

Merged
graydawnc merged 4 commits into
mainfrom
feat/share-publish-endpoints
Jun 5, 2026
Merged

feat(share-backend): publish, snapshot read, revoke, republish#359
graydawnc merged 4 commits into
mainfrom
feat/share-publish-endpoints

Conversation

@graydawnc

Copy link
Copy Markdown
Collaborator

What

Adds the three endpoints that put / get / withdraw a share:

  • POST /api/publish — first-publish or republish (uses optional override_slug); content-hash idempotent; rate-limited per-user (30/h, 100/d)
  • GET /api/snapshots/<id> — public read; serves the snapshot body from R2 or a 410 tombstone if revoked / expired
  • POST /api/revoke/<id> — owner-only tombstone of a live share

Plus supporting code:

  • src/publish/validators.ts — Zod schema for the publish body (snapshot envelope, visibility, expires_at, optional override_slug for republish)
  • src/publish/slug.tsnanoidSlug() (21 chars) + isValidSlug() (regex ^[\w-]{21}$)

Why

These three handlers are the entire lifecycle of a share, and they all share state machines (idempotency key, version bump, tombstone semantics). Splitting them into separate PRs would mean each one carries half the helper code, half the test fixture, and the contracts would drift between landings.

The crucial invariants — D1 write first, KV tombstone second, R2 third; idempotency keyed on a client-derived sha256; override_slug is the only way to address an existing slug — only stay coherent when read as one diff.

Write order

Every state-changing path uses the same discipline: D1 (source of truth) → KV META (read-path tombstone gate) → R2 (bulk body). Partial failure leaves the row in a consistent state from the owner's point of view; the reader sees a single coherent answer; a retry can complete the missing tail without orphan rows.

flowchart TD
    P([POST /api/publish]) -->|insert / update| D1[(D1 published_shares)]
    D1 -->|on commit| MET[(KV META)]
    MET --> R2[(R2 SNAPSHOTS)]
    R2 -.OG fire-and-forget.-> OG[(R2 OG)]

    R([POST /api/revoke/:id]) -->|UPDATE revoked_at| D1
    D1 -.->|stamp revoked_at| MET
    MET -.waitUntil.-> DEL[Delete R2 SNAPSHOT + OG]

    G([GET /api/snapshots/:id]) -->|read meta first| MET
    MET -->|live| R2
    MET -->|revoked / expired| T[410 tombstone JSON]
Loading

Publish state machine

stateDiagram-v2
    [*] --> Validating
    Validating --> RateLimited: 30/h or 100/d trip
    Validating --> Sized: body ≤ 2MB
    Sized --> ContentHashed: snapshot hash = idempotency_key
    ContentHashed --> Idempotent_hit: token matches live row
    Idempotent_hit --> [*]: 200 with original slug
    ContentHashed --> Republish: override_slug set
    Republish --> ConcurrentRetry: meta.changes==0 → 409
    Republish --> Bumped: UPDATE OK, version++
    ContentHashed --> First: no override_slug
    First --> SlugMint: nanoid(21)
    SlugMint --> Inserted
    First --> ConcurrentInsertRetry: UNIQUE(user, token) → re-read row, 200
    Bumped --> Persisted
    Inserted --> Persisted
    Persisted --> KV_META: meta/<slug>
    KV_META --> R2_Body: <slug>.json
    R2_Body --> OG_FireAndForget
    OG_FireAndForget --> [*]
Loading

Revoke + read

sequenceDiagram
    autonumber
    participant O as Owner
    participant Rv as POST /api/revoke/:id
    participant D as D1
    participant K as KV META
    participant R2 as R2 SNAPSHOTS
    participant Rd as Reader (GET /api/snapshots/:id)

    O->>Rv: revoke slug
    Rv->>Rv: requireUser, rate-limit (60/h), owner-scope check
    Rv->>D: UPDATE revoked_at=now WHERE id=?
    Rv->>K: meta.revoked_at = now
    Rv-->>Rv: waitUntil(delete R2 snapshot + OG)
    Rv-->>O: 200 { ok: true }
    Rd->>K: GET meta/<id>
    K-->>Rd: { revoked_at: <ts> }
    Rd-->>Rd: 410 Gone + tombstone JSON (no-store)
Loading

Correctness notes

  • Idempotency lookup excludes revoked rows. (user_id, client_request_id) WHERE revoked_at IS NULL lets a publish-then-revoke-then-republish recycle the same content hash onto a fresh slug instead of resurrecting the tombstoned one.
  • Republish requires override_slug. No "I think you meant to update slug X" inference — the renderer either targets a specific slug or asks for a new one. Eliminates a class of "wrong share got mutated" bugs.
  • Optimistic concurrency on republish. UPDATE … WHERE version=? against the version we just SELECTed. Two concurrent republishers can't silently overwrite each other — the second sees changes=0 and gets 409 CONFLICT.
  • draft_id healing. Pre-PR rows have draft_id IS NULL. A republish carrying a draft_id writes it through; once written it's immutable (any mismatch is 400 bad request). This keeps "which draft owns which slug" stable for the slug's lifetime without forcing a schema backfill.
  • Body cap. 2 MiB. ~10× a typical session export and far short of anything that would exhaust a Worker memory budget.
  • expires_at bounds. ≥5 min (clock-skew guard, blocks instant-tombstoned publishes) and ≤1 year (matches retention policy).
  • Tombstone JSON. 410 Gone with { revoked: true, at } or { expired: true, at }. Reader pages render this as a friendly "this share was withdrawn" page instead of a generic browser 404.
  • No owner_user_id in the public JSON. Anyone with a slug already knows the share exists; exposing the internal user id would hand them a pivot into a user-enumeration vector via /api/profiles/*.
  • OG render is waitUntil. A broken render logs but doesn't fail the publish. A user's session is published the moment the D1 row lands; the OG image catches up best-effort.

Verification

  • pnpm --filter @spool-lab/share-backend test — publish.test.ts covers: happy publish, republish bumps version, idempotency short-circuit, revoke-then-republish mints new slug, concurrent republish lands one 200 + one 409, body too large, expires_at bounds, profile-listed without handle 4xx, slug regex, tombstone 410 (revoked + expired), R2 missing → 404
  • Manual: wrangler dev, sign in, publish a snapshot, curl the public read, revoke, public read returns 410

Risk

Schema is the same as PR #356. Endpoints are additive. Renderer code that calls these endpoints comes in later stack PRs and is gated by featureFlags.ts share: DEV || envFlag('SHARE') — prod doesn't set the env, so this PR is dormant on main until the gate flips.

Submitted by @graydawnc.

graydawnc added 4 commits June 6, 2026 02:28
5 changes to /api/publish + /api/snapshots/[id]:

- Drop `owner_user_id` from the public snapshot JSON. Reader doesn't
  use it and exposing the internal id gives anyone with a share URL a
  free pivot point for user enumeration. Author identity belongs to
  /api/profiles/* (keyed by handle, not raw id).

- Reorder publish writes: D1 INSERT first, then META (KV), then R2.
  The D1 row is the authoritative "this slug is taken by this user"
  record — without it, a partial-failure R2 object is an orphan the
  user can't see in /me/shares and can't revoke. Now the worst-case
  partial failure leaves a DB row pointing at empty content, which
  the owner can republish to repair.

- Optimistic-concurrency guard on republish. Original code did
  SELECT version → compute v+1 → UPDATE WHERE id=?, so two concurrent
  republishes both read v=N, both wrote v=N+1, and the winner's
  content/version mismatched silently. Now UPDATE WHERE version=?
  with the SELECTed value, and `result.meta.changes === 0` raises 409.

- Bound expires_at on the server: must be ≥ 5 min in the future
  (clock skew tolerance + blocks instantly-tombstoned shares) and
  ≤ 1 year out (matches the spec retention ceiling).

- Reader cache window 60s → 30s + must-revalidate. A panic revoke is
  now effective everywhere except already-loaded browser tabs within
  half a minute.

Also name 4 magic numbers (PUBLISH_RATE_*, SNAPSHOT_CACHE_*,
SLUG_LEN) and migrate publish.test.ts to the typed `invoke()` helper —
deletes the file-local `ctxFor: any` and 25 dynamic imports.
Fake D1's run() now returns the real-shape `{ success, meta: { changes } }`
so the new race test can drive the concurrency check authentically.
Address the four items left from code review:

- (P2) Audit row moves to right after the D1 INSERT/UPDATE so the
  evidence of "who published what" is durable even when the
  downstream META.put or R2.put fails partway. audit() writes to D1
  too, so it shares the same blast radius as the row it records.

- (R1) Per-user hourly rate limit on revoke (60/hour). Owner-scoped,
  so the cap is mainly cost discipline against a leaked token
  spinning revoke writes in a loop. Same shape as publish hourly.

- (V1) Validator now enforces `turn_order.length === turns.length`
  and that every id referenced in turn_order/hidden_turns exists in
  turns. Otherwise reader page renders empty / partial frames with
  no actionable error.

- (X1) PUBLIC_BASE_URL env var feeds the share URL in the response;
  default stays `https://spool.pro`. Lets dev runs against
  http://localhost:5173 return a clickable link instead of a prod
  url that doesn't go anywhere.

Plus 6 new tests:
  expires_at exactly-at-boundary (4-min vs 6-min, 364-day vs 365+min)
  turn_order length mismatch
  turn_order unknown id
  PUBLIC_BASE_URL respected in response url
  republish of revoked slug → 404 (would-be un-tombstone)
  revoke rate limit → 429
Drop the inline DEFAULT_PUBLIC_BASE_URL constant — PR 2 just promoted
the same notion to src/public-url.ts. Single source of truth for the
deployment's public origin across OAuth + publish.
@graydawnc
graydawnc added this pull request to the merge queue Jun 5, 2026
Merged via the queue into main with commit 6ba7a2d Jun 5, 2026
6 checks passed
@graydawnc
graydawnc deleted the feat/share-publish-endpoints branch June 5, 2026 18:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant