Close the _attachment@1 hash-oracle with an optional atomic-upload adapter capability (#106) - #123
Open
cuibonobo wants to merge 8 commits into
Open
Close the _attachment@1 hash-oracle with an optional atomic-upload adapter capability (#106)#123cuibonobo wants to merge 8 commits into
cuibonobo wants to merge 8 commits into
Conversation
#106) ScopedStack.create() applied no special check for _attachment@1, so a non-owner holding nothing but a bare create grant on the type — every uploader, by design — could name an arbitrary guessed fileId and, via canAccessFile()'s uploader clause, turn a correct guess into a read. putAttachment() was always the safe primitive (it derives fileId from bytes it just hashed, so possession is proven by construction); this closes the other path. - ScopedStack.create() refuses _attachment@1 for non-owners, with a carve-out: a readable record already referencing the fileId may get a second metadata record (e.g. a second filename) without re-uploading — never via the uploader clause, which would reintroduce the same circularity. - ScopedStack.putAttachment() now omits entityId for owner uploads, matching the normalization create() already applies (E1). - The mimeType-conflict validation error no longer names the established mimeType, closing a secondary anti-oracle leak. - Updates docs/spec.md (§Attachments, §API Adapter Wire Format) and @haverstack/conformance-fixtures to match: POST /attachments is documented as creating the _attachment@1 record atomically (the non-owner-safe combined primitive, not an efficiency optimization), and generic POST /records for _attachment@1 is owner-only. Scope note: the atomicity portion of #106 (folding bytes+metadata into one local-adapter transaction, closing the bare-bytes orphan window and the F3 mimeType race) depends on #112 and is deliberately not included here, per the issue's own sequencing. @haverstack/adapter-api is unchanged in this PR. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LSNFMXRWseS34w8U2rt1u5
…T /attachments endpoint (#106) Follow-up to the ScopedStack.create() fix: POST /attachments now creates the _attachment@1 record atomically, but Stack.putAttachment() (client-side, wrapping APIAdapter) still made two separate wire calls under the hood — a bytes-only POST /attachments followed by a generic POST /records, which is now refused for non-owners. That left non-owner grantees using the SDK remotely with no working upload-with-metadata path. - StackBlobAdapter gains a required (not optional/capability-flagged) putAttachmentWithMetadata(data, mimeType, filename?) returning { fileId, record? }. Local storage adapters (disk, sqljs, memory) can't create a record themselves — a different backend — so they return no record; Stack.putAttachment() falls back to its existing create() call, unchanged. APIAdapter is the one implementation that populates record, via a single POST /attachments request. - Stack.putAttachment() calls the new method first and skips its own create() call whenever a record comes back, avoiding a redundant, potentially conflicting second write. - APIAdapter.putAttachment() (bytes-only) and putAttachmentWithMetadata() both parse the record POST /attachments now returns. - Wires @haverstack/conformance-fixtures' attachmentUploadFixtures into adapter-api's conformance test suite. - Updates docs/spec.md to describe the SDK's use of the endpoint and the resulting putAttachmentBytes()-over-HTTP quirk. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LSNFMXRWseS34w8U2rt1u5
…adapters The trivial local-adapter implementations from the previous commit declared narrower types than the StackBlobAdapter interface: return types omitted the optional `record` field, and mimeType/filename params were dropped entirely from the disk and in-memory adapters. Both were structurally assignable to the interface (fewer required params, a return subtype), so they built and passed vitest — which transforms test files without full type-checking — but failed `tsc --noEmit` on the concrete class types: mocking a record in a test, or calling with the full argument list, doesn't type-check against a narrower concrete signature. Also fixes an adapter-api test-only cast where WireRecord | WireError didn't overlap enough with the narrower shape being asserted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LSNFMXRWseS34w8U2rt1u5
…achmentWithMetadata() Both methods hit the same POST /attachments endpoint and got back a full record; putAttachment() had its own separate uploadBinary()+ parseRecord() call instead of reusing putAttachmentWithMetadata()'s, discarding the record for no reason other than that it was written before the second method existed. Now it's a two-line wrapper: call the richer method with the default mimeType, keep the fileId. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LSNFMXRWseS34w8U2rt1u5
…ithMetadata The method's name promised metadata gets applied, but that's only ever true for the API adapter — every local storage adapter (disk, sqljs, memory) silently ignores mimeType/filename, since it has no access to record creation (a different backend). The `try` prefix makes that explicit: callers must check `record` in the result, not the method name, to know whether metadata was actually used. Pure rename, no behavior change. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LSNFMXRWseS34w8U2rt1u5
…r capability (#106) The combined bytes+record upload was a required method on StackBlobAdapter, an interface whose contract is bytes-only — so three of four implementations were stubs that ignored two of their three parameters, the method needed a 'try' prefix and a { fileId, record? } result to model half-support, and the spec needed a paragraph explaining why this wasn't the repo's optional-capability pattern. Move it to where the capability actually lives: putAttachmentWithMetadata? is now an optional method on the composed StackAdapter type, following the deleteUnreferencedAttachmentRecords()/listFiles() idiom — implemented only by APIAdapter (one atomic POST /attachments), never synthesized by combineAdapters() (a record backend glued to a blob backend has no shared transaction), and truthiness-checked by Stack.putAttachment(), which otherwise falls back to its pre-existing bytes-then-create() sequence. Declaring it on the intersection type rather than either half encodes the structural fact driving #106: 'bytes + record in one operation' is a property only a whole adapter can have — a blob adapter cannot even declare it. Deletes all local-adapter stubs, the try prefix, the optional- record result type, and the spec's pattern-deviation paragraph; the ScopedStack create() guard, carve-out, anti-oracle message, and E1 fix are unchanged. On the atomic path the returned record is server-authoritative: client-side validation and the mimeType-conflict check are documented as deliberately skipped (the server runs both; a client-side conflict check against remote state would be racy and itself a mini-oracle). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QQBTuf3CnEwdvH76nvazJF
…nting a default record POST /attachments always creates the _attachment@1 record now (#106), so there is no bytes-only wire mode for APIAdapter.putAttachment() to map to. It previously approximated the contract by issuing the combined request and discarding the record — silently creating one with a default application/octet-stream mimeType and no filename, while Stack.putAttachmentBytes() documented 'no record created'. Throw APIAdapterError up front (no request issued) instead: bytes-only upload is a local-storage primitive, and remote callers use Stack.putAttachment(). Spec updated to state the contract plainly in both the Stack-methods list and the wire section, replacing the 'approximate over APIAdapter' caveat. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QQBTuf3CnEwdvH76nvazJF
The wire lost its bytes-only upload mode in #106 — POST /attachments always creates the _attachment@1 record — which emptied the public bytes-only method's constituency: its only wire consumer was the old bytes-only endpoint, non-owner bytes-only upload was already a documented dead end, and owner bytes-only upload just manufactures the bare-bytes orphans the GC sweep exists to clean up. A public method whose docs warn most callers away and which throws on the most common remote deployment is a landmine, not an API. Remove Stack.putAttachmentBytes(), ScopedStack.putAttachmentBytes(), and the StackClient entry. The upload story is now one sentence: putAttachment(data, mimeType, filename?) is the upload operation, everywhere, for everyone. StackBlobAdapter.putAttachment() stays required at the adapter layer — it's the storage primitive Stack.putAttachment()'s fallback writes through, and within blob storage every implementation can store bytes. APIAdapter's throwing implementation is now unreachable from Stack's public surface (the atomic capability always takes precedence there), so it guards direct adapter-level callers only. ScopedStack now receives the adapter from Stack.asEntity() and composes bytes + its own entityId-stamped create() directly; the orphan- manufacturing GC tests write through the adapter, since no Stack method produces bare bytes on purpose; the bytes-only gating tests are dropped as exact duplicates of the putAttachment() suite's coverage. This goes one step past #106's residual decision 2 ("keep putAttachmentBytes for owner/server-internal use") — that resolution predates the wire losing bytes-only upload entirely, which is what left the method without a legitimate public consumer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QQBTuf3CnEwdvH76nvazJF
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.
Summary
Implements #106, superseding #122. The security fix is the same as #122's:
putAttachment()becomes the only non-owner path to an_attachment@1record, closing the guessed-fileId confirmation oracle. What changed is the adapter-level design that carries it — the combined bytes+record upload is now an optional capability on the composedStackAdaptertype, following the repo's existing optional-capability idiom, instead of a requiredtryPutAttachmentWithMetadata()onStackBlobAdapterthat three of four implementations could only stub.The security fix (unchanged from #122)
_attachment@1refusal:ScopedStack.create()refuses_attachment@1(matched by baseId) for any non-owner requester, so a barecreategrant can no longer turn a guessed SHA-256 into read access viacanAccessFile()'s uploader clause. One carve-out (Close the _attachment@1 hash-oracle: make putAttachment the only non-owner path to a metadata record #106 residual decision 1): a non-owner who can already read a record referencing the fileId may add another metadata record (e.g. their own filename) without re-uploading — satisfied only by a readable referencing record, never by the requester's own prior_attachment@1record, to prevent one successful guess bootstrapping unlimited further ones.POST /attachmentscarriesContent-Type/Content-Dispositionand creates the_attachment@1record in the same request, returning the record. The fileId is established from bytes the server received in this request — possession proven by construction, with no seam for a caller-supplied string.ScopedStack.putAttachment()normalizes owner uploads to noentityId, matchingcreate()'s normalization (Polish batch: owner entityId stamping, unused timezone, lenient date validation, silent null content filter #69).The design change (vs. #122)
StackBlobAdapterstays bytes-only, exactly as its contract states. The atomic upload is declared on the composed type:tryprefix, and the{ fileId, record? }optional-record result from Implement atomic attachment upload with metadata (#106) #122.deleteUnreferencedAttachmentRecords()/listFiles()idiom (which Close the _attachment@1 hash-oracle: make putAttachment the only non-owner path to a metadata record #106 itself pointed to): optional method, truthiness-checked at the call site, described fallback. The spec paragraph explaining why the method deviated from that pattern is replaced by a third example of it.combineAdapters()never synthesizes it from parts — a record backend glued to a blob backend has no shared transaction to honor the atomicity promise with.Stack.putAttachment()delegates when the capability is present (trusting the returned record as server-authoritative; client-side validation and the mimeType-conflict check are documented as deliberately skipped — the server runs both, and a client-side conflict check against remote state would be racy and itself a mini-oracle), and otherwise falls back to its pre-existing bytes-then-create()sequence.APIAdapteris the one implementation, as a singlePOST /attachments.Bytes-only upload is no longer public API
Since
POST /attachmentsalways creates a record, there is no bytes-only upload anywhere on the wire — and that emptiedputAttachmentBytes()'s constituency entirely: its only wire consumer was the old bytes-only endpoint, non-owner bytes-only upload was already a documented dead end, and owner bytes-only upload just manufactures the bare-bytes orphans the GC sweep exists to clean up. So this PR removesStack.putAttachmentBytes(),ScopedStack.putAttachmentBytes(), and theStackCliententry. The upload story is now one sentence:putAttachment(data, mimeType, filename?)is the upload operation, everywhere, for everyone.At the adapter layer,
StackBlobAdapter.putAttachment()stays required — it's the storage primitiveStack.putAttachment()'s fallback writes bytes through, and within blob storage every implementation can store bytes. OnAPIAdapterit throwsAPIAdapterErrorbefore issuing any request (there is no wire mode to map it to; approximating one would mint a defaultapplication/octet-streamrecord as a side effect, #122's behavior). SinceStack.putAttachment()always takes the atomic path on that adapter, the throw is unreachable fromStack's public surface and guards direct adapter-level callers only.Note — deliberate deviation from #106 residual decision 2, which said to keep
putAttachmentBytesfor owner/server-internal use with no behavior change. That resolution predates the realization that the wire would lose bytes-only upload entirely, which is what left the method without a legitimate public consumer. Server-internal composition still works:ScopedStackreceives the adapter fromStack.asEntity()and composes bytes + its own entityId-stampedcreate()directly. SDK-surface change only — no wire contract, fixtures, or stored data are affected (#57).Tests
All #122 coverage is retained (non-owner refusal scenarios, carve-out behavior, exploit regression, anti-oracle message, E1, conformance fixtures for the wire contract), plus:
Stack.putAttachment()delegates toputAttachmentWithMetadata()when present — arguments forwarded, no separatecreate()call — and falls back when absentcombineAdapters()never synthesizesputAttachmentWithMetadatafrom partsAPIAdapter.putAttachment()rejects withAPIAdapterErrorand never touches the networkStackmethod produces bare bytes on purpose anymore); the bytes-only gating tests are dropped as exact duplicates of theputAttachment()suite's coverageFull suite green across all packages (855 tests), plus typecheck, lint, and format checks.
Closes #106.
🤖 Generated with Claude Code
https://claude.ai/code/session_01QQBTuf3CnEwdvH76nvazJF