Skip to content

Close the _attachment@1 hash-oracle with an optional atomic-upload adapter capability (#106) - #123

Open
cuibonobo wants to merge 8 commits into
mainfrom
claude/pr-122-file-oracle-design-ys2guu
Open

Close the _attachment@1 hash-oracle with an optional atomic-upload adapter capability (#106)#123
cuibonobo wants to merge 8 commits into
mainfrom
claude/pr-122-file-oracle-design-ys2guu

Conversation

@cuibonobo

@cuibonobo cuibonobo commented Aug 3, 2026

Copy link
Copy Markdown
Member

Summary

Implements #106, superseding #122. The security fix is the same as #122's: putAttachment() becomes the only non-owner path to an _attachment@1 record, 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 composed StackAdapter type, following the repo's existing optional-capability idiom, instead of a required tryPutAttachmentWithMetadata() on StackBlobAdapter that three of four implementations could only stub.

The security fix (unchanged from #122)

  • Non-owner _attachment@1 refusal: ScopedStack.create() refuses _attachment@1 (matched by baseId) for any non-owner requester, so a bare create grant can no longer turn a guessed SHA-256 into read access via canAccessFile()'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@1 record, to prevent one successful guess bootstrapping unlimited further ones.
  • Wire change: POST /attachments carries Content-Type/Content-Disposition and creates the _attachment@1 record 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.
  • Anti-oracle cleanup: the mimeType-conflict message no longer names the established mimeType, so a guessed fileId can't leak existence or metadata through the error.
  • E1 fix: ScopedStack.putAttachment() normalizes owner uploads to no entityId, matching create()'s normalization (Polish batch: owner entityId stamping, unused timezone, lenient date validation, silent null content filter #69).

The design change (vs. #122)

StackBlobAdapter stays bytes-only, exactly as its contract states. The atomic upload is declared on the composed type:

type StackAdapter = StackRecordAdapter &
  StackBlobAdapter & {
    putAttachmentWithMetadata?(data, mimeType, filename?): Promise<StackRecord>;
  };
  • "Bytes + record in one operation" is a property only a whole adapter can have — neither half can ever offer it, so a blob adapter can't even declare it. This deletes all the local-adapter stubs, the try prefix, and the { fileId, record? } optional-record result from Implement atomic attachment upload with metadata (#106) #122.
  • It follows the established 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. APIAdapter is the one implementation, as a single POST /attachments.

Bytes-only upload is no longer public API

Since POST /attachments always creates a record, there is no bytes-only upload anywhere on the wire — and that emptied putAttachmentBytes()'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 removes 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.

At the adapter layer, StackBlobAdapter.putAttachment() stays required — it's the storage primitive Stack.putAttachment()'s fallback writes bytes through, and within blob storage every implementation can store bytes. On APIAdapter it throws APIAdapterError before issuing any request (there is no wire mode to map it to; approximating one would mint a default application/octet-stream record as a side effect, #122's behavior). Since Stack.putAttachment() always takes the atomic path on that adapter, the throw is unreachable from Stack's public surface and guards direct adapter-level callers only.

Note — deliberate deviation from #106 residual decision 2, which said to keep putAttachmentBytes for 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: ScopedStack receives the adapter from Stack.asEntity() and composes bytes + its own entityId-stamped create() 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 to putAttachmentWithMetadata() when present — arguments forwarded, no separate create() call — and falls back when absent
  • combineAdapters() never synthesizes putAttachmentWithMetadata from parts
  • APIAdapter.putAttachment() rejects with APIAdapterError and never touches the network
  • The GC bare-bytes-orphan tests write through the adapter directly (no Stack method produces bare bytes on purpose anymore); the bytes-only gating tests are dropped as exact duplicates of the putAttachment() suite's coverage

Full 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

claude added 8 commits July 21, 2026 02:54
#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
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.

Close the _attachment@1 hash-oracle: make putAttachment the only non-owner path to a metadata record

2 participants