Skip to content

fix(gateway,dashboard): batch slot claims belong to the authenticated caller (N49) - #375

Open
LamaSu wants to merge 10 commits into
masterfrom
fix/n49-batch-claim-identity
Open

LamaSu wants to merge 10 commits into
masterfrom
fix/n49-batch-claim-identity

Conversation

@LamaSu

@LamaSu LamaSu commented Sep 24, 2026

Copy link
Copy Markdown
Owner

Board row N49: a shared-batch slot claim belongs to the authenticated caller

Raised by product-steward as #2557 and assigned to refvertical (66f65db1) by the steward in #2638. Verified at ac86a404.

The hole

POST /api/batches/shared/:batchId/claim (packages/gateway/src/routes/batches.ts:144-152 at ac86a404) took agentId from the request body and never checked it against the caller. Any key holder could therefore file claims, each carrying a payment amount, under any identity. BatchBoardPage.tsx:266 sent the fixture identity "demo-user" on every real claim.

These gaps sat in the same routes:

  • DELETE …/claim/:claimId let any caller release anyone's claim.
  • A negative slotCount produced a claim with a negative amount.

Gateway: routes/batches.ts

  • Identity from auth.
    • The claim and release routes run the existing requireAuth preHandler.
    • The claimant is req.userId, which apiGate sets from the API key's operatorId or the wallet session.
    • With no identity the route returns 401. This holds even on an app mounted without apiGate.
  • No spoofing, no breakage. A body agentId is accepted only when it equals the caller, so a client that echoes its own id still works. Anything else, "demo-user" included, gets 403 agent_mismatch.
  • Release is claimant-only. Anyone else gets 403 not_claimant.
  • Input validation.
    • slotCount must be a positive integer (400 otherwise).
    • preferredIndices must be distinct integers in [0, totalSlots).
  • Privacy. Once the real identity is bound, it must not leak. Every read (the detail, the open list and /availability) shows a claimant's agentId, which is an email or wallet, and its sampleLabels only to that claimant. Other callers see agentId: null, own: false and no labels. The dashboard never rendered other claimants' ids, so nothing visible changes.

Dashboard: BatchBoardPage

  • The claim body is { slotCount } only (claimRequestBody): no identity.
  • New batch-board-logic.ts, following the kernel-leaderboard-logic.ts pattern, maps the server shape to the page's view.
    • Pre-existing crash, fixed here. The page rendered the server's claim array as a React child, which throws once a batch has any claim. claimedSlots is now a count.
    • It also called .toFixed on pricePerSlot, which is a string when a batch is created through the API. The price is now a number, and an unknown price renders as "—" rather than an invented "$0.00".

Verification (DGX Spark; deps from /mnt/sparkbulk/pnpm-store)

Check Result
vitest run src/__tests__/batches-shared-claim.test.ts 9/9 pass. Behind apiGate with real API keys minted by provisionApiKey, plus one bare-app test without apiGate.
Negative control: the same file against master's batches.ts 7 of 9 fail, as they should. The 2 that pass are deliberate regression guards: the anonymous 401 comes from apiGate, and a self-echoed agentId is still accepted.
Full @pcc/gateway suite 188 files; 2,990 passed, 6 skipped, 0 failed
@pcc/gateway tsc --noEmit exit 0
vitest run src/pages/__tests__/BatchBoardPage.test.ts 6/6 pass
Full @pcc/dashboard suite 10 files, 226/226 pass
@pcc/dashboard tsc --noEmit exit 0

The typechecks were run after building the workspace dependencies (pnpm --filter "@pcc/dashboard^..." build and the same for gateway).

Not in scope

  • POST /api/batches/shared (create) has no creator or kernel-owner binding: anyone can open a batch on any kernel at any price. That needs a separate row.
  • The shared-batch store is in memory.
  • Identity strength is inherited from auth. operatorId is self-asserted at key provisioning (board row N2) until gateway's WP-A lands.

Review routing (board rule: auth or identity)

  • Reviewers: gateway (0600b204) and coord-watch (d749deff).
  • Merging to master is the operator's call.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FFEKdAcSGUnfrgiwtP21GP

… caller (N49)

Board row N49. POST /api/batches/shared/:batchId/claim took agentId from the
request body with no check against the caller, so any key holder could file
claims, and their payment amounts, under any identity. The dashboard sent the
fixture identity 'demo-user'.

Gateway (routes/batches.ts):
- The claim and release routes run requireAuth. The claimant is req.userId,
  which apiGate sets from the API key's operatorId or the wallet session.
  Without one the route returns 401.
- A body agentId is accepted only when it equals the caller. Anything else
  is 403 agent_mismatch, so older clients that echo their own id keep working.
- Only the claimant can release a claim (403 not_claimant). Before, any
  caller could release anyone's claim.
- slotCount must be a positive integer. A negative count used to produce a
  claim with a negative amount. preferredIndices must be distinct integers
  in range.
- Binding the real identity would have exposed it, so every read (detail,
  open list, availability) shows a claimant's identity and sample labels only
  to that claimant.

Dashboard (BatchBoardPage):
- The claim body is { slotCount } only (claimRequestBody); no identity.
- batch-board-logic.ts maps the server shape to the page's view: the claim
  array becomes a count and the price becomes a number. The page rendered the
  claim array as a React child, which crashed it once a batch had a claim, and
  called toFixed on a string price. An unknown price shows a dash, not $0.00.

Tests: 9 gateway route tests behind apiGate with real API keys (7 of them fail
on the previous route), 6 dashboard logic tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FFEKdAcSGUnfrgiwtP21GP
…on-owner views, legacy batch routes bound to the caller

Answers coord-watch's DO-NOT-SHIP on #375 (sol, #2939) and gateway's LOW-1/LOW-2 (#2830).

- Shared create requires auth and records the creator (shown only to the creator). totalSlots must be an integer 1..1536, minSlotsToRun 1..totalSlots, the price a non-negative amount with at most 6 decimals (a number from the dashboard is normalised), currency 3-5 capitals, closesAt an ISO date, text fields at most 200 chars. Binding the batch to the kernel operator is N55 (needs requireKernelOperator from #335/WP-C).
- Claim: supplied preferredIndices must be an array of slotCount distinct in-range integers (malformed input is refused, never auto-assigned). sampleLabels: at most slotCount strings of at most 200 chars; the rest are padded.
- A non-owner sees a claim as slotIndices, status and own:false only: no claim id, amount, escrow or time.
- Legacy batch-manifest routes: adding a sample needs auth and binds userId to the caller (403 agent_mismatch otherwise), with validated fields. List, detail, events and by-job show other users' samples without owner, label, job or result refs. An unknown batch is 404.
- Dashboard mapper keeps agentId only on the viewer's own claims.

Tests: route 21/21 (13 new or changed; all 13 fail against 815e554), dashboard mapper 7/7 (the new case fails on the old mapper). Gateway 188 files, 3002 passed / 6 skipped / 0 failed. Dashboard 227/227. tsc exit 0 for both.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FFEKdAcSGUnfrgiwtP21GP
@LamaSu

LamaSu commented Sep 24, 2026

Copy link
Copy Markdown
Owner Author

Round 2 @ 4a0f4120 answers coord-watch's DO-NOT-SHIP (sol, bus #2939) and gateway's LOW-1/LOW-2 (#2830).

  • Malformed preferredIndices were ignored. Any supplied value must now be an array of slotCount distinct integers in range, or the claim is refused with 400. It is never replaced by an automatic assignment.
  • Batch input was unvalidated. Create now requires:
    • totalSlots an integer from 1 to 1536 (the largest standard plate), which bounds the allocation loop;
    • minSlotsToRun from 1 to totalSlots;
    • price a non-negative amount with at most 6 decimals (the dashboard's number is normalised to a string);
    • currency of 3-5 capital letters;
    • closesAt an ISO date;
    • text fields of at most 200 characters.
  • Shared create had no auth. It now needs requireAuth and records the creator, who is shown only to themselves. Binding the batch to the kernel's operator is board row N55, mine; it needs requireKernelOperator from fix(gateway): honest operator-relay evidence, claim authentication, immutable authorized ceiling (LO-GW-4/3a, R-06) #335/WP-C and merges after WP-A.
  • Legacy batch-manifest routes.
    • POST /api/batches/:id/slots needs auth, binds userId to the caller (403 agent_mismatch otherwise), and validates its fields.
    • List, detail, events and by-job show other users' samples without their owner, label, job or result references.
    • An unknown batch returns 404 instead of 200.
  • viewClaim spread the whole claim. A non-owner now sees {slotIndices, status, agentId: null, sampleLabels: [], own: false}: no claim id (LOW-2), amount, escrow or time. That is now an explicit decision.
  • The dashboard mapper kept agentId. It now drops it unless own === true.
  • LOW-1. sampleLabels must be at most slotCount strings of at most 200 characters; missing labels are padded.

Scoped out, with reasons:

Tests:

  • Route 21/21: 13 new or changed, and all 13 fail against 815e5547.
  • Dashboard mapper 7/7; the new case fails on the old mapper.
  • Gateway 188 files, 3,002 passed / 6 skipped / 0 failed. Dashboard 227/227.
  • tsc exit 0 for both.

LamaSu and others added 7 commits September 29, 2026 13:06
…ob is no oracle; strict create input (N49 r3)

Round 3 of #375, for the cross-family DO-NOT-SHIP on 4a0f412 (the weakest
link was the legacy batch privacy boundary).

Legacy batch manifests:
- viewSlot gives a non-owner only position and status: no slot id, sample
  type or acquisition times, through an explicit projection.
- viewEvents shows a viewer's own sample events in full, and leaves out
  every event about anyone else's sample. Batch-level events keep their type
  and time and only an allowlist of aggregate fields (slotCount,
  completed/failed).
- by-job: the operator of the job's kernel sees that kernel's batches
  holding the job, and anyone else only the batches holding a sample of
  theirs from it. An unauthorised caller gets the same empty list as a job
  with no batches.
- Adding a sample: jobs record no buyer, so the relationship that can be
  checked is the kernel's. Only the operator of the batch's kernel may add,
  and only for a jobId and stepId on that same kernel; a sealed batch is
  409. The tracker's error text is never forwarded.

Shared batches:
- A number price that would need rounding is refused (1.1234567,
  0.0000001), and stored prices are canonical ("0010.500000" -> "10.5").
- closesAt must be a real ISO 8601 UTC calendar time, in the future and at
  most 30 days away. A batch past it takes no claims (409 batch_closed) and
  leaves the open list.
- An explicit null minSlotsToRun or currency is refused, not defaulted, and
  whitespace-only text is refused.
- Releasing someone else's claim answers exactly like a missing claim (404),
  so there is no existence oracle.
- The in-memory store is bounded: 20 open batches per creator, 10,000 in
  total.
- The viewClaim comment states that omitted fields are not protection
  against inference (price x slot count; occupancy timing).

Verified: batch tests 28/28, of which 10 fail on 4a0f412; gateway 188 files,
3,009 passed, 0 failed; tsc clean.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…allowlists; honest amount, real labels, pruned store (N49 r3)

Cross-family (gpt-5.6-sol) r3 on b1673e4: DO-NOT-SHIP.

F1 (CRITICAL) — the shared batch stream (/sse/stream/batch/:batchId) forwarded
the entire raw BatchEvent to every authenticated subscriber, leaking another
tenant's slotId, timing, resultHash and resultRef. A new pure
projectBatchStreamEvent (sse/batch-stream-projection.ts) is now the only thing
published: batch-LEVEL events only, and only their aggregate fields
(batch_sealed:slotCount, batch_completed:completed/failed). Per-sample events
never reach the shared stream; they stay on the authenticated, owner-projected
HTTP surface (viewEvents).

F2 (CRITICAL) — viewManifest spread the whole manifest, so runConfig (free-form,
may hold proprietary protocol params or customer metadata) leaked to the public
list/detail routes. It is now an explicit allowlist (id, kernelId, deviceId,
capabilityId, status, timing, projected slots); runConfig/methodId are never
in the public view.

F4 (MEDIUM) — shared-claim labels now require isText (a whitespace-only label
is rejected), matching legacy labels.

F5 (MEDIUM) — the shared-batch store prunes no-longer-claimable batches past a
retention window before the cap check, so short-lived batches can't permanently
exhaust it (batch_store_full).

F6 (MEDIUM) — the claim amount was float*toFixed(2) (0.000001*n -> "0.00"). It
is now an honest full-precision display string via integer micro-units
(displayAmountMicros), documented display-only, never settlement.

F3 (HIGH) is N55 (the kernel-operator gate on shared-batch creation): reproduced,
NOT fixed here — it is blocked on WP-C/#335 and cannot land on this SHA.

Tests: F1 projection (per-sample dropped, batch-level aggregate-only), F2 (no
runConfig in detail/list), F4 (whitespace labels rejected), F6 ("3" and
0.000001*3 -> "0.000003"). F2/F4/F6 fail on b1673e4's route and pass here;
batch suite 33/33, tsc clean.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…am boundary (N49 r5)

Reviewer finding (r4 verdict, CRITICAL): raw sensor readings reached the
shared batch SSE topic. services.ts put {type:"batch", id: reading.batchId}
in the sensor fan-out and published the whole reading; /sse/stream/batch/:id
checks authentication only (no ownership) and setupSSE wrote every payload
verbatim. The N49 r3 projection lived only in the BatchTracker forwarder, so
any other publisher (the sensor pipeline, the mock BatchProducer, a future
producer) bypassed it. An authenticated subscriber on /sse/stream/batch/victim
received the full sensor_reading of someone else's sample: sampleId, job and
step ids, value, free-form tags.

Fix, at the shared-topic boundary instead of one producer:
- topic-sse.ts: setupSSE takes an optional per-stream projector, run on EVERY
  event the stream delivers (live and Last-Event-ID replay). A null drops the
  event; a projector that throws drops it too (fail closed). The batch
  endpoint passes projectBatchStreamEvent. Job, kernel and device streams are
  unchanged.
- services.ts: raw readings are no longer put on the batch topic at all (no
  subscriber needs them there: every other streamHub consumer subscribes to
  the global topic, and the dashboard reads readings from the kernel stream).
  The onBatchEvent projection stays; it is now redundant but harmless.
- batch-stream-projection.ts: now total, because it judges whatever any
  publisher put on the topic. A prototype-key event type ("constructor",
  "__proto__") is not a listed type (it used to throw), a non-object payload
  counts as no payload (it used to throw), and an aggregate field passes only
  as a whole-number count.

Tests (batch-sse-boundary.test.ts, a real HTTP SSE subscription against
app.listen({port:0}) with SSE_AUTH_REQUIRED on): the reviewer's end-to-end
case; a raw reading, odd payloads and prototype-key types published straight
on the topic; the real mock BatchProducer; Last-Event-ID replay; the
batch_completed aggregate keeping only allowlisted fields; the real
BatchTracker flow; kernel and job streams unchanged. 7 of the 11 SSE tests and
3 projection tests fail at 9606d66.

Not changed (separate pre-existing rows): /sse/stream/kernel/:id and
/sse/stream/device/:id still carry raw sensor_reading payloads to any
authenticated subscriber; there is no ownership check on them on master.

Agent: implementer-november

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FFEKdAcSGUnfrgiwtP21GP
…r-creator limit counts every retained batch (N49 r5)

Reviewer finding (r4 verdict, MEDIUM): pruning measured retention from
closesAt, not from when a batch stopped taking claims, and the per-creator
limit counted only claimable batches. One principal could create one-slot
batches with closesAt 30 days out, fill each one, and hold the store at its
global cap (batch_store_full, 503) for about 30 days: advance two hours and
creation still returned 503.

- A batch now records when it stopped being claimable: setStatus is the only
  way a status changes, and it stamps the moment a batch leaves open/filling
  (full today; running, completed and cancelled are covered the same way) and
  clears the stamp when a released claim reopens it. The stamp lives in a
  WeakMap beside the batch, not on it, because viewBatch spreads a batch into
  the response; a test pins the public key set.
- Retention runs from the earlier of that stamp and closesAt, so a batch that
  filled an hour ago is prunable now however far off its closesAt is, and an
  expired-open batch is kept an hour after closesAt as before.
- The per-creator limit counts every batch of the creator's that is still
  retained (claimable, or stopped less than an hour ago), not only the
  claimable ones. The global cap is kept. The error code stays
  too_many_open_batches so clients that match it keep working.
- Test seam: _setSharedBatchLimitsForTests lowers the bounds so a test can hit
  the cap without creating 10,000 batches; _clearSharedBatchesForTests
  restores the defaults. Production never calls either.

Tests use a faked Date (no real waiting). At the old retention logic two of
the five new tests fail: after two hours creation still returns 503, and the
21st creation by one creator returns 200 instead of 409. The other three pin
unchanged behavior (a released claim reopens the batch and it is not pruned;
an expired-open batch is kept an hour after closesAt; the public batch view
keeps its key set).

Not changed: N55 (shared-batch creation accepts any kernelId without
checking the caller operates it) stays open and blocked on the gateway
ownership work.

Agent: implementer-november

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FFEKdAcSGUnfrgiwtP21GP
…-only estimate, not an amount owed (N49 r5)

Reviewer finding (r4 verdict, MEDIUM): the claim response field was
claim.amount, and the exported spec type BatchSlotClaim documented it as
"Amount owed for this claim", while the gateway filled it with a
non-authoritative display calculation from the posted price. A comment does
not separate that from accepted economics; a consumer reading the typed
`amount` would treat the estimate as money owed.

- spec: BatchSlotClaim.amount is removed and replaced by displayAmount, whose
  doc says it is a display-only estimate (posted pricePerSlot x claimed slot
  count), NOT an amount owed, and that settlement decides what is owed. A
  types-only compile-time guard beside the type stops the package compiling if
  `amount` returns or displayAmount goes.
- gateway: the claim response field is displayAmount. The route's inline copy
  of BatchSlotClaim is gone: claims are the public spec type, so the response
  cannot drift from the contract (a claim literal carrying `amount` no longer
  compiles). Comments updated.
- Consumers: a repo-wide search of packages/* and apps/* found no other reader
  of the shared-batch claim's `amount`. The dashboard batch board counts
  slotIndices and reads the batch pricePerSlot; it never read the claim's
  amount. The only readers were three assertions in the gateway test, updated.

Tests: three existing assertions now read displayAmount; new tests check that
the claim, the claimant's own read and the release response carry displayAmount
and no amount, that someone else's claim shows neither, and (expectTypeOf) the
public type. At 9606d66 those fail (displayAmount is undefined) and the spec
guard fails to compile (TS2344 twice: displayAmount absent, amount present).

gateway tsc resolves @pcc/spec through its built dist, so rebuild spec before
type-checking the gateway, as with any spec type change.

Agent: implementer-november

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FFEKdAcSGUnfrgiwtP21GP
…r ids; SSE framing guard (N49 r6)

The r5 review (gpt-5.6-sol, on 6e19623) found that the SSE envelope still
bypassed the batch projection. setupSSE wrote the publisher's raw event.id into
every frame, so an allowlisted batch_sealed or batch_completed could carry a
sample id, a job id or a result hash in its id, and an id holding CR/LF could
inject whole frames. Replay matched Last-Event-ID against the same publisher
ids, so a guessed id was an oracle: found, it replayed what came after; not
found, it replayed everything.

- StreamHub now assigns each topic its own monotonic cursor at publish (1, 2,
  3, ... per topic, so a cursor says nothing about any other topic). It keeps a
  cursor-tagged replay window beside the existing buffer, whose shape the
  visualizer reads. Every callback, live or replayed, receives the event's
  cursor. subscribe() with {cursor: "seq"} reads Last-Event-ID as that cursor
  and replays only what follows it; any other value replays nothing.
- The batch stream writes only that cursor as the frame id (no cursor, no id
  line) and subscribes in cursor mode.
- Every stream guards its frame. An event whose type holds CR, LF or NUL, or
  is empty or over 256 characters, is dropped, and a publisher id like that is
  left out of the frame. Data goes through JSON.stringify, which escapes all
  three.

Tests: 8 new tests (7 over real HTTP SSE, 1 hub unit) fail at 6e19623 and pass
now. The 11 r5 boundary tests were adapted to find frames by payload and order
instead of by publisher id; they pass both before and after.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KBTXKQ2uqtcrsUtPnQdtf8
…ows (N49 r7)

The r6 review (gpt-5.6-sol, on 8a545cd) closed the envelope CRITICAL and found
one MEDIUM. The hub numbered every publication on a batch topic, including the
private events the batch projection then drops, so the gaps in the public
cursor counted hidden events: N dropped events showed up as a jump of N, and
replay kept those positions. That is a covert channel.

- StreamHub takes a cursor policy. An event the policy does not admit gets no
  cursor on that topic and stays out of the cursor window. It is still
  delivered, and still kept in the publisher-id buffer the visualizer reads. A
  policy that throws admits nothing.
- The gateway's hub admits on a batch topic only what the batch stream can show:
  isPublicBatchStreamEvent, the same projection the stream applies.
- A cursor stream never writes an event that has no cursor, so the policy and
  the projection cannot disagree visibly.

Tests: 5 new tests (3 over real HTTP SSE, 2 hub unit) fail at 8a545cd and pass
now. The 20 earlier boundary tests are unchanged and pass.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KBTXKQ2uqtcrsUtPnQdtf8
…h, and the stream writes exactly that (N49 r8)

The r7 review (gpt-5.6-sol, on 609d886) found that the cursor-gap MEDIUM was
not fully closed. The batch projection ran twice on the same mutable event:
once when the hub numbered it, and again at the SSE boundary. When the two
judged differently (a payload getter, or a mutation after publish), a numbered
event was never shown, leaving a gap that counted it, or a shown event lost
its cursor.

- StreamHub takes a CursorView, called ONCE per event and topic at publish.
  Null (or a throw) means no cursor. A view means a cursor, and the frozen view
  is stored in the cursor window and handed out with the cursor, live and on
  replay.
- batchStreamView (batch-stream-projection.ts) returns the frozen projection;
  the gateway's hub uses it for batch topics. Only batch topics carry cursors.
- setupSSE writes a cursor stream's frame from cursor.view alone and never
  reads the event again. The per-stream projector is gone.

Tests: 4 wire tests (a read-once payload shown as first judged with no gap;
replay shows the stored view; a mutation after publish doesn't change replay;
a payload that throws on first read gets no cursor and leaves no gap) and 2
hub unit tests (the view is decided once and is the same object on replay; the
batch view is frozen). On 609d886's source, 3 of the wire tests and both hub
tests fail; the read-throws-first case is a control. The r7 hub unit test now
uses the view API.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KBTXKQ2uqtcrsUtPnQdtf8

This branch has not been deployed

No deployments
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