Skip to content

fix(share-backend): publish-data hardening — revoke idempotency, hidden_turns cap#369

Merged
graydawnc merged 1 commit into
mainfrom
feat/share-backend-publish-data
Jun 8, 2026
Merged

fix(share-backend): publish-data hardening — revoke idempotency, hidden_turns cap#369
graydawnc merged 1 commit into
mainfrom
feat/share-backend-publish-data

Conversation

@graydawnc

@graydawnc graydawnc commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

What

Two follow-ups in the publish-data path, surfaced in the post-launch audit.

Idempotent revoke

The ownership SELECT had no revoked_at IS NULL clause. Re-revoking a tombstoned share would overwrite the original revoked_at with a newer timestamp:

  • corrupts the audit trail
  • shifts the row outside the 7-day orphan-sweep window — R2 artefacts go undeleted long after the share went 410

Plus a renderer-side consequence: the IPC handler does if (!r.ok) throw. The cross-device case (revoked on web, retried on desktop with stale local cache) used to toast revoke 404 for what was actually a successful no-op.

stateDiagram-v2
    [*] --> Live: publish
    Live --> Revoked: POST /api/revoke/:id<br/>200 + revoked_at=T0
    Revoked --> Revoked: POST /api/revoke/:id (retry)<br/>200, no write<br/>(revoked_at stays T0)
    Live --> [*]: 410 Gone (reader path)
    Revoked --> [*]: 410 Gone (reader path)<br/>orphan-sweep at T0 + 7d

    state Revoked {
        [*] --> AuditTrailPreserved
    }
Loading

Fix shape:

  • not owned / doesn't exist → 404 NOT_FOUND
  • owned but already revoked → 200 { ok: true } (no D1/KV/R2/audit write)
  • owned and live → revoke as before (D1 → KV → R2 + audit)

The renderer's existing if (!r.ok) throw converges cleanly without any client-side change.

Array bounds on turns / turn_order / hidden_turns

All three previously capped at 500. Measured against real-world session-length distribution on actual power-user dbs:

Distribution turns
p50 36
p90 408
p95 774
p99 1,740
max 4,565

9.2% of real sessions exceed 500 turns. 500 was far too tight; the cap was rejecting legitimate publishes.

At the same time hidden_turns had NO cap at all — a crafted payload could ship a multi-million-entry array and bloat zod validation memory before the cross-reference refine ran.

Fix:

  • turns, turn_order, hidden_turns.max(20_000)
  • ~4× the largest real session as headroom (nobody legitimate hits it)
  • still acts as a validator-CPU sanity guard before MAX_SNAPSHOT_BYTES (2 MB) would have rejected the payload at the body-cap stage
  • the body cap remains the real defense

Verification

  • revoke: 200 on already-revoked + assert original revoked_at preserved
  • tests/_helpers/fakes.ts: new matcher for SELECT revoked_at FROM published_shares so the regression exercises the production query path

pnpm --filter @spool/share-backend test → 175/175 green.

Risk

Pure correctness improvement on both fronts. The 200-idempotent shape matches what most callers already expect from an unpublish action. Raising the turns cap from 500 → 20,000 unblocks ~9% of real sessions that previously failed validation; the body-bytes ceiling (2 MB) is unchanged.

Submitted by @graydawnc.

…s/hidden_turns caps

Two follow-ups in the publish-data path, surfaced in the post-launch
audit.

revoke/[id] not idempotent against an already-revoked row:
  The ownership SELECT had no revoked_at IS NULL clause. Re-revoking
  a tombstoned share would overwrite the original revoked_at with a
  newer timestamp, corrupting the audit trail AND shifting the row
  outside the 7-day orphan-sweep window — leaving R2 artefacts
  undeleted long after the share went 410.

  Fix is fully idempotent: SELECT revoked_at FROM published_shares
  WHERE id=? AND user_id=?. If not owned → 404. If owned but already
  revoked → 200 with no DB/KV/R2/audit write (preserves the original
  revoked_at). If owned and live → revoke as before. This shape is
  important on the renderer side: the cross-device case (user revokes
  on web, retries on desktop with a stale local cache) used to throw
  `revoke 404` through the IPC handler; now it returns clean and the
  desktop UI converges silently.

Array bounds on turns / turn_order / hidden_turns:
  All three previously capped at 500 — far too tight against real-
  world distribution (p99 ≈ 1,740, max observed ≈ 4,565 on actual
  power-user dbs; 9% of sessions exceed 500). At the same time
  hidden_turns had no cap at all, so a crafted payload could ship a
  multi-million-entry array and bloat zod validation memory before
  the cross-reference refine ran.

  Set all three to .max(20_000): ~4× the largest real session as a
  legitimate-use headroom, while still acting as a sanity guard
  before MAX_SNAPSHOT_BYTES (2MB) would have rejected the payload at
  the body-cap stage. The body cap remains the real defense; this is
  the validator-CPU bound.

Tests:
  - revoke: 200 idempotent on already-revoked + assert original
    revoked_at preserved
  - tests/_helpers/fakes.ts: new matcher for SELECT revoked_at FROM
    published_shares so the regression exercises the production
    query path

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@graydawnc
graydawnc force-pushed the feat/share-backend-publish-data branch from 99c2ffa to 98129ca Compare June 8, 2026 08:03
@graydawnc
graydawnc added this pull request to the merge queue Jun 8, 2026
Merged via the queue into main with commit abfcf4e Jun 8, 2026
6 checks passed
@graydawnc
graydawnc deleted the feat/share-backend-publish-data branch June 8, 2026 08:13
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