Skip to content

fix(service-storage): presigned S3 PUT URLs no longer carry the CRC32 of an empty body - #21164

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-21147-s3-presign-checksum
Oct 1, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-21147-s3-presign-checksum

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21147

Clause-②: no

What changed

The S3 adapter's S3Client now sets requestChecksumCalculation and responseChecksumValidation to WHEN_REQUIRED (packages/services/service-storage/src/s3-storage-adapter.ts, getClient()). Before, the SDK default (WHEN_SUPPORTED) put x-amz-checksum-crc32=AAAAAA== (the CRC32 of an empty body) and x-amz-sdk-checksum-algorithm=CRC32 into the signed query of every presigned PUT, because a presign prepares the command before any byte exists. A store that enforces a query-signed checksum against the uploaded body would refuse every browser upload while server-side upload() kept working.

Landing is exactly the card's file surface: the producer is this adapter, no other package builds an S3Client.

File Change
packages/services/service-storage/src/s3-storage-adapter.ts the two client options plus the reason
packages/services/service-storage/src/s3-storage-adapter.presign-checksum.test.ts new offline pin, real SDK
.changeset/21147-s3-presign-no-checksum.md patch for @objectstack/service-storage (not private; dist ships the option, grep hit in dist/index.js and dist/index.cjs)

3 files, +164 / -1.

Evidence

Red before, green after. The new test drives the real S3Client and the real presigner, fully offline: fake credentials, fixed endpoint http://127.0.0.1:9000, no socket opened. A test that mocks @aws-sdk/client-s3 (as s3-storage-adapter.key-prefix.test.ts does) cannot see this defect, because the checksum is stamped by the SDK's own middleware.

  • On origin/main's adapter (base 70dae533), the new file: 2 failed.
    • upload case: expected [ 'x-amz-checksum-crc32', …(1) ] to deeply equal [], received x-amz-checksum-crc32, x-amz-sdk-checksum-algorithm.
    • download case: expected [ 'x-amz-checksum-mode' ] to deeply equal [].
  • With the fix: Tests 2 passed (2); whole package pnpm --filter @objectstack/service-storage test: Test Files 41 passed (41), Tests 629 passed (629); typecheck (including the test-layer check) exit 0.

Ablation of the pin (committed fix at HEAD, each leg through scripts/ablation-replace.mjs: anchor hit once, blob hash moved, restore proven by blob equal to HEAD and an empty git diff HEAD). The subject resolves to src/, not a dependency's dist/, so no rebuild leg applies.

Removed upload case download case
both options red (crc32, sdk-checksum-algorithm) red (checksum-mode)
requestChecksumCalculation only red red (crc32 on the GET URL)
responseChecksumValidation only green red (checksum-mode)

The two options are a pair. Presigning with @aws-sdk/client-s3 3.1090.0 under each combination:

request / response PUT URL GET URL
default / default crc32, sdk-checksum-algorithm checksum-mode
WHEN_REQUIRED / default none checksum-mode
default / WHEN_REQUIRED crc32, sdk-checksum-algorithm crc32 (worse than default)
WHEN_REQUIRED / WHEN_REQUIRED none none

Setting only the response option puts a CRC32 on a GET URL that had none, so the pair is set together and the test header records the table.

What changes for upload() and the other doors (measured against a loopback server over both plain HTTP and a self-signed HTTPS listener, same result): a server-side PutObject with a Buffer body and an UploadPart no longer send x-amz-sdk-checksum-algorithm / x-amz-checksum-crc32; x-amz-content-sha256 (the SigV4 payload hash) is identical before and after, and the request still succeeds. A GetObject no longer sends x-amz-checksum-mode: ENABLED, so download() stops asking for and validating a response checksum. getPresignedDownload loses the x-amz-checksum-mode=ENABLED query parameter. This is the pre-flexible-checksum behaviour; the SDK's default added it. None of the nine commands the adapter issues is checksum-required: in @aws-sdk/client-s3 3.1090.0 requestChecksumRequired: true is bound to bucket-config, ACL, tagging, retention, DeleteObjects and similar commands, and PutObject, GetObject, DeleteObject, HeadObject, ListObjectsV2 and the four multipart commands are not among them. Peer range is ^3.0.0: SDKs older than the flexible-checksum release ignore the two options and never stamped a checksum.

Local verification (HEAD 33e30baf)

  • Dependency closure built first: pnpm --workspace-concurrency=2 --filter '@objectstack/service-storage^...' build exit 0 (direction: upstream dependencies of this package, the closure the test imports through dist).
  • Gates: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 63 commands, each run with its exit code recorded; --ran reconciliation: 63 derived famil(ies) accounted for — 61 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3). 61 exited 0.
  • NOT MEASURED: pnpm check:dual-build-cjs-loads and pnpm check:i18n, both exit 3, PREREQUISITE NOT MET (they read built output of dozens of packages outside this closure; CI builds them). Substitute for the first, narrower than the gate: pnpm --filter @objectstack/service-storage build exit 0 and node -e "require('./dist/index.cjs')" loads, S3StorageAdapter is a function. The diff touches no translation, extract config or metadata form, so check:i18n's population is not moved by it, but that is a reading of the diff, not a measurement.
  • One turbo-driven gate appended its managed block to AGENTS.md; restored with git checkout HEAD -- AGENTS.md, never staged.

Optional live leg

Not done. The repo has no container pattern for an enforcing store: no testcontainers or localstack dependency and no MinIO service in any workflow (git grep hits for MinIO are config prose and settings labels only). Whether AWS S3 or MinIO enforce a query-signed checksum stays NOT MEASURED, as on the card; this change removes the dependence on the answer.

Acceptance notes

  • Observation, not filed: the option-pair coupling in the table above is an SDK behaviour; it is recorded only in the test header and the adapter comment, so a future SDK bump that changes it surfaces as the upload or download case going red.
  • The new test file is deliberately a single self-contained file with no mock of the SDK; it does not replace the mocked command-level coverage in s3-storage-adapter.key-prefix.test.ts.

Generated by Claude Code

claude added 3 commits October 1, 2026 10:43
…arams (WIP, red on base)

Offline pin driving the real S3Client and presigner with fake credentials
and a fixed endpoint. Red against the unmodified adapter; the fix follows.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…, so presigned PUT URLs carry no empty-body CRC32

The SDK default (WHEN_SUPPORTED) stamps x-amz-checksum-crc32=AAAAAA== (the
CRC32 of an empty body) and x-amz-sdk-checksum-algorithm=CRC32 into the signed
query of a presigned PutObject, because a presign prepares the command with no
body. Set requestChecksumCalculation and responseChecksumValidation to
WHEN_REQUIRED on the adapter's S3Client; none of its commands is
checksum-required. Pinned offline against the real SDK.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
… together

Measured on the real SDK: the response option alone puts a CRC32 on a
presigned GET URL, so the pair cannot be set one at a time.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Oct 1, 2026
@github-actions

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 7 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json ae1e95010a25350fb50c4939815387975810cfb1 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 8a1c73792d80dc4fe3289ddf438ccde9b70c1fe2 — the merge of head 33e30baf741d6106f3c1b092d8b2129d5cca3edf into base ae1e95010a25350fb50c4939815387975810cfb1, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 8a1c73792d80dc4fe3289ddf438ccde9b70c1fe2 && git checkout 8a1c73792d80dc4fe3289ddf438ccde9b70c1fe2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ae1e95010a25350fb50c4939815387975810cfb1 33e30baf741d6106f3c1b092d8b2129d5cca3edf && git checkout -B drift-repro ae1e95010a25350fb50c4939815387975810cfb1 && git merge --no-ff 33e30baf741d6106f3c1b092d8b2129d5cca3edf

node scripts/docs-audit/affected-docs.mjs --json ae1e95010a25350fb50c4939815387975810cfb1

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 1, 2026 11:28
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 1, 2026
Merged via the queue into main with commit f0cc16e Oct 1, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21147-s3-presign-checksum branch October 1, 2026 11:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

service-storage S3 adapter: presigned PUT URLs bake in the CRC32 of an empty body, so a store that enforces it refuses every presigned upload

1 participant