feat(share-backend): publish, snapshot read, revoke, republish#359
Merged
Conversation
…rescan + fail-closed read
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Adds the three endpoints that put / get / withdraw a share:
POST /api/publish— first-publish or republish (uses optionaloverride_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 / expiredPOST /api/revoke/<id>— owner-only tombstone of a live sharePlus supporting code:
src/publish/validators.ts— Zod schema for the publish body (snapshot envelope, visibility, expires_at, optionaloverride_slugfor republish)src/publish/slug.ts—nanoidSlug()(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_slugis 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]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 --> [*]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)Correctness notes
(user_id, client_request_id) WHERE revoked_at IS NULLlets a publish-then-revoke-then-republish recycle the same content hash onto a fresh slug instead of resurrecting the tombstoned one.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.UPDATE … WHERE version=?against the version we just SELECTed. Two concurrent republishers can't silently overwrite each other — the second seeschanges=0and gets409 CONFLICT.draft_idhealing. Pre-PR rows havedraft_id IS NULL. A republish carrying adraft_idwrites it through; once written it's immutable (any mismatch is400 bad request). This keeps "which draft owns which slug" stable for the slug's lifetime without forcing a schema backfill.expires_atbounds. ≥5 min (clock-skew guard, blocks instant-tombstoned publishes) and ≤1 year (matches retention policy).410 Gonewith{ 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.owner_user_idin 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/*.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 → 404curlthe public read, revoke, public read returns 410Risk
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.tsshare: DEV || envFlag('SHARE')— prod doesn't set the env, so this PR is dormant onmainuntil the gate flips.Submitted by @graydawnc.