feat(service-storage): confine the S3 adapter to a required key namespace (Part of #17571) - #17599
Conversation
…ace (wip) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…irections Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 7 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 24bf79036986bf835fd85cb91a10ded03e3b40e6 && git checkout 24bf79036986bf835fd85cb91a10ded03e3b40e6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b64ea610313df2c3042d0c3523f5e16c007cf252 240f9159790288ac573963c38ae76cfe35a2bd78 && git checkout -B drift-repro b64ea610313df2c3042d0c3523f5e16c007cf252 && git merge --no-ff 240f9159790288ac573963c38ae76cfe35a2bd78
node scripts/docs-audit/affected-docs.mjs --json b64ea610313df2c3042d0c3523f5e16c007cf252
|
Part of #17571
Clause-②: yesWhat this is
S3StorageAdapterOptionsgains a requiredkeyPrefix: string | null— the key namespace an adapter instance is confined to, ornullfor bucket-root keys — applied at every door and stripped on every way back, so a caller holding the adapter has no door through which it can reach an unprefixed key.The ruled shape (cloud#1969 option B, quoted on the card by the seat that holds the ruling) is structural isolation:
Everything below is this repo's call, which the ruling explicitly left open on three of the four points.
Premise re-verified
Measured on today's
origin/main=29d00cc539c2822be79d4d728779312094b35525(the branch point), not on theabc4b83cethedomain:servicesseat measured:S3StorageAdapterOptions(packages/services/service-storage/src/s3-storage-adapter.ts:36-51at that sha) carried no key-prefix option of any spelling — a case-insensitiveprefix|namespace|root|scopegrep over those lines returned nothing, with a positive control (the same word matches 4× elsewhere in the file, so both the grep and the file were live).Key: keyat:169 :183 :193 :203 :217 :344 :370 :389 :413 :434 :452andPrefix: prefixat:283.buildKey(scope, fileId, filename)(storage-routes.ts:962) composes${scope}/${fileId}${ext}withscopedefaulting to'user'— no environment dimension.The premise holds. The card's own line references were still byte-accurate at the branch point.
The shape chosen, and why — this is the part to scrutinise
Name:
keyPrefix. Not because the card suggested it (it carries no authority and says so), but because it is what this repo's conventions produce. Prime Directive #3 puts TS config keys in camelCase; the word has to distinguish the thing from two neighbours that already exist in this package —basePath, whichStorageTargetInputdocuments as "URL prefix the adapter signs against — not a storage location", andlist(prefix, …)'s own caller-facing argument. A bareprefixwould collide with both.keyPrefixnames the S3 key namespace and matches theKey/Prefixvocabulary the file already speaks.Enforcement: a required key whose type is
string | null. The ruling allows a required option, a refusing constructor, or a non-empty default a host cannot silently inherit. This is the first, with the opt-out made explicit:list(prefix)retirement: "a storage adapter is CODE, never stack metadata … The enforced channel is tsc, and it reports at the call site." Measured here, on the two in-repo call sites:error TS2345 … Property 'keyPrefix' is missing in type '{ bucket: string; region: string; }' but required in type 'S3StorageAdapterOptions'.| nullrather than''. A single-tenant host must not be forced to invent a namespace and migrate its bucket — that pressure is exactly what would later reopen the option to an empty value, which is the gap. WritingkeyPrefix: nullmakes opting out a deliberate, greppable act; a fleet can audit unprefixed buckets with one grep.''and whitespace-only are refused at construction, loudly. That string is what an unset environment variable looks like after interpolation, and quietly reading it as bucket-root is the silent-degradation shape the ruling rejects. A leading/and any..segment are refused too./is appended, and this is load-bearing. S3Prefixis a raw string match, sotenant_1also matchestenant_10/…: without the normalisation, one namespace would enumerate its neighbour through the isolation mechanism itself. Pinned by a case, not a comment.Keys are concatenated, never path-joined, so a caller key of
../elsewherestays a literal key inside the namespace instead of escaping it. Pinned.Two seams that had to move, or the option would have been a lie
Neither is in the card. Both are the same defect class as the card, inside the same package, and leaving either would have shipped an option that a deployment can lose without noticing — PD #10, never advertise a capability the runtime does not deliver.
StorageServicePlugin.buildAdapterFromValuesrebuilds the adapter from thestoragesettings namespace, constructing a freshS3StorageAdapterOptionsfromvaluesand ignoringthis.options.s3entirely. A constructor-only prefix would therefore be dropped by the first settings save, returning a hosted deployment to a shared, unprefixed key space. The host's namespace is now carried onto every rebuilt adapter — and deliberately not read out ofvalues: a boundary an administrator inside the deployment can set or clear is a preference, not a boundary. There is correspondingly nos3_key_prefixkey in the storage settings manifest, and a test pins that one appearing there later still changes nothing.resolveStorageTargetdecides whether a swap strands existing bytes. Two prefixes in one bucket are two disjoint object sets, so the namespace belongs inlocation, not merely in the fingerprint — otherwise a prefix change swaps the adapter with no migration warning at all.env_7andenv_7/normalise through the adapter's own function so the same namespace spelled two ways is not read as a move (the StorageServicePlugin warns "storage adapter swapped (LocalStorageAdapter → LocalStorageAdapter) … files may be unreachable" on every clean boot #4096 shape, one field along).LocalStorageAdapterOptions— deliberately unchangedThe ruling left this to this repo, noting
rootDirmay already do the job. It does, structurally:LocalStorageAdapter.resolvePath()refuses any key containing..andjoin(rootDir, key)s everything, and every door goes through it. That is a containment boundary of the same kind, already enforced. A second mechanism would be two ways to say one thing.Verification
Declared narrowing — verification ran UNLOCKED.
scripts/pm/os-verify-lock.shcould not take the shared verify lock on this host: no usable
flock. The sharedverify lock is declared Linux-only (
flockis util-linux, and a stock macOS doesnot ship it), so the command below was run directly, without the lock —
a declared narrowing, not a silent one. No serialization guarantee held for this
run, nor for any sibling agent in this container while it ran.
pnpm --filter @objectstack/service-storage testTest Files 40 passed (40) · Tests 627 passed (627)pnpm --filter @objectstack/service-storage typecheckcheck:test-typecheck: OK … 0 file(s) / 0 error(s)pnpm --filter @objectstack/service-storage buildcheck-dts-emitted: 2/2 declared declaration file(s) presentpnpm --filter '@objectstack/service-storage^...' buildpnpm --filter '...@objectstack/service-storage' typecheckScope: 9 of 81 workspace projects. Honest reading: no in-repo consumer constructs the S3 adapter or passess3:options, so this green is a statement that nothing broke, not evidence the narrowing bites downstream. What proves the narrowing shipped is the rebuiltdist/index.d.ts, which carries `keyPrefix: stringnode scripts/pm/dispatch-gates.mjs --commands--ran, exit codes recorded). Three needed a repo-wide build first (check:dual-build-cjs-loads,check:i18n,check:type-check-debt— all exit 3PREREQUISITE NOT MET, not failures); the closure was built and all three then exited 0.node scripts/check-adr-0087-registration.mjs --base origin/mainnot-required (runtime-interface-only) -- verified: …#S3StorageAdapterOptions (interface)pnpm lint(whole repo,eslint . --no-inline-config)Ablation — these pins can fail
Run from the committed state, each leg proving the mutation reached disk before the run and proving the restore by blob hash:
Prefix: this.storageKey(prefix)→Prefix: prefixconst key = this.callerKey(bucketKey)→const key = bucketKeyOn-disk proof per leg: deleted-text count 1 → 0 and injected-text count 0 → 1, anchored on the exact text. Restore proof per leg:
git diff HEADempty andgit hash-objectback toab068bf93da82d65c5185042428b5b58e6075f72, the file's HEAD blob. The ablation script carriedtrap … EXIT INT TERMwith absolute paths; no ablation artefact is left in the tree.Landing
Part of, notFixes. The implementation half is complete and the card's acceptance criterion is driven end to end, but two things outlive this PR and both live on the card: the Clause-② contract review owed before it may be queued, and the downstream obligation thedomain:servicesseat recorded — when this lands, the consuming card is filed inobjectstack-ai/cloudby the cloud epic seat, which accepted that in writing. Leaving feat(service-storage): S3 适配器没有 key 前缀选项 —— 多租户共享 bucket 时对象存储层没有第二道隔离防线(cloud#1969 已裁形状的落点) #17571 open keeps both visible.keyPrefixin the plugin'ss3options (new StorageServicePlugin({ adapter: 's3', s3: { bucket, region, keyPrefix: '<environment id>' } })). It must be non-empty — an interpolated empty env var throws at construction rather than writing to the bucket root — and it must not be plumbed through thestoragesettings namespace, which would hand the boundary to the tenant. Existing objects are not migrated into the new namespace; changing the prefix on a live environment strands what the old one held, and the swap says so.docs/adr/**·.claude/**·skills/**·AGENTS.md·CLAUDE.md).Acceptance notes
packages/services/service-storage/src/s3-storage-adapter.tscarries anuploadChunkdocblock that narrates aWeakMapdesign the code does not implement (it is a plainMap,_uploadKeys) and reads as in-progress reasoning rather than description. It is stale prose, not a defect, a contract violation, or a metadata-authoring trap, so it is out of the three filing classes. Successor: the next PR touching the multipart doors in this file — there is no other one queued.Generated by Claude Code