feat!: ship tree-shakeable dual ESM + CJS build (#2344)#2355
Conversation
96b0637 to
1a87431
Compare
There was a problem hiding this comment.
Request changes. One line in an otherwise sound dual-package build turns a fail-closed provenance brand into a fail-open one on the signed-requests trust boundary — the wrong direction for a security control (fail-closed beats fail-open).
The build machinery is right. bundle: false + one-file-per-module preserves the graph for tree-shaking and keeps the CLI's deep require paths and __dirname schema lookups working; the per-file ESM banner (tsup.config.ts:88-99) and the companion dynamic-import() extension fixer (tsup.config.ts:14-46) close the two gaps esbuild-fix-imports-plugin leaves. The AsyncLocalStorage global-registry anchoring is the correct fix for silently-unsigned requests across a mixed ESM/CJS load. All of that ships as-is. The one blocking issue is buyer-agent.ts.
MUST FIX
Forgeable verified-signature brand — auth bypass on signingOnly/mixed (src/lib/server/decisioning/buyer-agent.ts:403). The change flips the provenance brand from a module-private Symbol() to Symbol.for('@adcp/sdk.verifiedHttpSig'). That symbol is const and unexported (used only at :403/:406/:424/:441), so under the old private Symbol() no code outside this module could construct it — which is exactly what the retained docstring at :382-388 still asserts: "NOT registered via Symbol.for(...) — code outside this module cannot synthesize the symbol value, which is what makes it a usable provenance check." The code now contradicts its own security invariant.
The brand is the sole gate. isVerifiedHttpSig (:441) is the only thing separating a verifier-produced http_sig credential from a literal-shape impostor — isVerifiedHttpSigPayload (:377) checks nothing but that agent_url is a non-empty string. So any in-process code (a compromised dependency, or the custom authenticate callback the docstring at :461-465 names as the exact forgery vector the brand exists to close) can now do:
const b = Symbol.for('@adcp/sdk.verifiedHttpSig');
Object.defineProperty(cred, b, { value: true, enumerable: false });and a signingOnly seller — documented as "traffic that doesn't sign is automatically refused" — routes an unsigned, never-cryptographically-verified credential to resolveByAgentUrl as if it signed. That is MUST FIX #2 (auth bypass on a trust boundary).
The dual-package justification doesn't hold for this path the way it does for the ALS stores. markVerifiedHttpSig (auth-signature.ts) and isVerifiedHttpSig (buyer-agent.ts) live in the same server subpath graph and are instantiated from the same copy on any normal @adcp/sdk/server import; cross-copy mismatch requires wiring the verifier and the registry from two different copies, which no ordinary adopter does. Even if it did, the old failure was fail-closed (reject a valid credential → surfaced error), and the new one is fail-open (accept a forged one → silent bypass). Trade the wrong way and the constitution's fail-closed rule loses.
Fix: revert :403 to a private Symbol('@adcp/sdk.verifiedHttpSig'). If cross-copy matching for this brand is a real requirement (I don't think it is), anchor a private symbol behind a globalThis rendezvous the way globalAsyncLocalStorage does — but note that rendezvous is itself discoverable, so revert is the cleaner, fail-closed answer. Either way the :382-388 docstring must match the code.
Things I checked
- ALS global anchoring (
src/lib/utils/global-async-local-storage.ts) —Symbol.forrendezvous onglobalThis, returns the shared instance if present, one unique key per store. The four call sites (signing/agent-context.ts,rawResponseCapture.ts,responseSizeLimit.ts,transportDiagnostics.ts) swapnew AsyncLocalStorage()→globalAsyncLocalStorage('<key>')with distinct keys. Correct and fail-closed. a2a.tstransport —require('@a2a-js/sdk/client')→import { A2AClient as A2AClientImpl }(a2a.ts:1), still the official@a2a-js/sdkclient, no custom HTTP/SSE.callContextStorage(a2a.ts:46) is left un-anchored — correct: it's per-call transient state whose.run()and.getStore()always execute in the same copy, so it has no cross-boundary handoff.- Changeset-vs-wire audit — two changesets present.
tree-shakeable-esm-cjs.mdismajorand correctly names theexports-map rework as the breaking reason (MUST FIX #7 satisfied).package-artifact-verification.mdispatchfor verification tooling + peer-floor corrections.package.jsonversionstays11.2.0on both base and head — no manual bump (MUST FIX #6 clear). default-invariants.ts(2737 net lines, largest non-generated file) — mechanical wrap intoregisterDefaultInvariants()(:72), guarded byregisterOnce/REGISTERED(:56-60) so the load-time self-call and the explicit calls fromcomply.tsandstoryboard/index.tsare idempotent againstregisterAssertion's duplicate-throw.git diff -wconfirms the body is unchanged.package.json(381 net lines) — every subpath now emits per-condition types (import→.d.mts/.mjs,require→.d.ts/.js);enums,testing/personas,server/legacy/v5gained their missingtypesVersionsentries;sideEffectsscoped to**/default-invariants.*.
Follow-ups (non-blocking — file as issues)
- Assertion registry isn't globally anchored the way the ALS stores are.
REGISTERED(default-invariants.ts:54) and the underlyingregisterAssertionregistry are per-copy. In a genuine dual-load, testing-harness registrations from one copy are invisible toresolveAssertionsin the other. Failure mode is fail-closed (throws on unresolved id) and the path is test-only, so it ships — but if the dual-package concern was worth anchoring the signing ALS, it's worth a note here for symmetry. create-adcp-server.tsmovedrequire('../schemas')to a top-levelimport. Relies on ESM live bindings to populateTOOL_INPUT_SHAPESbeforegetToolInputShapes()runs (it does — accessed lazily inside the fn, not at load). Worth a comment noting the circular-import reason therequireoriginally existed, so a future edit doesn't hoist the access to load time.
Minor nits (non-blocking)
- Peer-floor raises split across bump categories. The
@a2a-js/sdkfloor (^0.3.4→^0.3.13) rides themajorchangeset; the@modelcontextprotocol/sdkfloor (^1.17.5→^1.24.0) rides thepatchone. Raising a peer floor is breaking-shaped; both are only correct here because the aggregate release ismajor. Fine to ship, but a one-line note in the patch changeset that the floor bump is safe only under the co-released major would age better.
Fix the brand and the docstring at buyer-agent.ts:403/:382-388 and this is a clean, well-argued major.
Build the library with tsup (`bundle: false`, one output file per source
module) so it ships ESM (`.mjs`, via the `import` condition) alongside CJS
(`.js`, via `require`), and declare `sideEffects` for tree-shaking. Importing
one symbol no longer pulls the whole barrel: `import { EventTypeValues } from
'@adcp/sdk/types'` bundles to under 1 KB with zod absent (was ~1.9 MB).
`require('@adcp/sdk')` and the CLI keep working. Dual-package safety: the
signing and response-capture AsyncLocalStorage stores are anchored on the
global symbol registry, and the verified-signature brand uses `Symbol.for`.
Default invariants register via an explicit `registerDefaultInvariants()`
(with a load-time self-call) instead of a bare side-effect import, so ESM
extension rewriting and tree-shaking cannot silently drop them.
BREAKING CHANGE: the package `exports` map now resolves `import` to ESM
(`.mjs`) and `require` to CJS (`.js`); every consumer's module resolution goes
through the reworked map. Major version bump.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Guard the publish artifact so packaging regressions fail CI instead of reaching consumers: - check:package (publint --strict + attw --pack .) on every PR - verify:package: clean-room npm-install of the packed tarball with required peers at their range floors, loaded via real ESM + CJS (gated CI job) - emit ESM-format .d.mts declarations alongside .d.ts and give each subpath per-condition types, fixing attw "masquerading as CJS"; enums import now points at enums.mjs; add missing typesVersions entries - raise @modelcontextprotocol/sdk peer floor ^1.17.5 -> ^1.24.0 (main entry eagerly loads server/tasks -> experimental/tasks/*, absent before 1.24.0) Verification tooling is devDependencies only; no new runtime deps. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… npm pack --json npm runs the `prepare` lifecycle during `npm pack` (even with --ignore-scripts on some npm versions), and its banner pollutes stdout — breaking JSON.parse of the --json payload in CI. Pack into the temp dir with --pack-destination and read the emitted .tgz from disk, sidestepping stdout parsing entirely. Quiet the pack file-listing notice with --loglevel=error. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The exports map moved from flat { types, import, require } to per-condition
{ import: { types, default }, require: { types, default } } to give the ESM
`import` condition its own .d.mts. Update the four tests that pinned the old
flat shape (net, brand, canonical-references public exports, and the
./types/v2-5 bundle) to assert the nested shape.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
a5e1eed to
13d4949
Compare
The dual-build work switched the http_sig provenance brand from a
module-private `Symbol()` to `Symbol.for('@adcp/sdk.verifiedHttpSig')`. That
made the brand forgeable: any in-process code (a compromised dependency, or a
custom `authenticate` callback) could obtain the same global-registry symbol
and stamp an unverified `{ kind: 'http_sig' }` credential as verifier-produced,
so `signingOnly`/`mixed` registries would route an unsigned agent_url as if it
had passed the RFC 9421 check — a fail-open auth bypass on the signed-requests
trust boundary.
Revert to a private `Symbol()`. The verifier (`markVerifiedHttpSig` in
auth-signature.ts) and the checker (`isVerifiedHttpSig` here) live in the same
`server` subpath graph and load from the same copy on a normal
`@adcp/sdk/server` import, so the dual-copy mismatch the change tried to fix
does not arise in practice; even if it did, a mismatch fails closed (a valid
credential is rejected with a surfaced error) rather than open. The docstring
at the brand definition already describes this private-Symbol behavior.
Also note in the changeset that the peer-floor raise is safe only under the
co-released major.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks @aao-secretariat — addressed the MUST FIX and the changeset nit in MUST FIX — forgeable verified-signature brand ( Minor nit #1 — peer-floor bump across changeset categories: added a line to Non-blocking follow-ups (assertion registry not globally anchored; the The build machinery, ALS anchoring, and changeset audit you confirmed are unchanged. |
|
Thanks for the rundown. The Ready for merge when CI is green. Generated by Claude Code |
|
Acknowledged — awaiting CI green before merge. No action needed from this routine. Generated by Claude Code |
Addressed in 6728ce2: the http_sig provenance brand was reverted from Symbol.for(...) back to a private Symbol() (fail-closed), and the docstring now matches. The reviewer auto-re-ran on the fix commit (run 29280360665) with no new findings, but the Argus action only posts when it has blocking findings, so it couldn't self-clear this review. Dismissing to reflect the resolved state.
What this does (#2344)
Makes
@adcp/sdktree-shakeable: ships a dual ESM + CJS build and marks the packagesideEffects-aware, so importing one symbol no longer drags in the whole library.require('@adcp/sdk')and the CLI keep working. Major version (theexportsmap changes for every consumer).The win (measured):
import { EventTypeValues } from '@adcp/sdk/types'→ ~0.6 KB, zod absent (was ~1.9 MB with zod).Status — all green ✅
How it works
bundle: false→ one output file per source module (mirrors the oldtsclayout, so tree-shaking works across the preserved graph and the CLI/tests that deep-import build internals still resolve).esbuild-fix-imports-pluginadds ESM import extensions + directory/indexresolution; a small companion plugin does the same for dynamicimport()(the standard plugin misses those, which is what caused lazyawait import()to fail — now fixed).__dirname/requirefromimport.meta.url(per-file, not a shared shim chunk).exports:import→.mjs,require→.js,types→.d.ts.typestayscommonjs. Declarations viatsc --emitDeclarationOnly.AsyncLocalStoragestores are anchored on the global symbol registry (a process that loads both builds shares one instance — no silently-unsigned requests); the verified-signature brand usesSymbol.for.registerDefaultInvariants()(plus a load-time self-call) instead of a bare side-effect import, so bundlers can't drop them.Notes for review
preserveModuleswas evaluated as an alternative and also works; tsup wins on config simplicity.default-invariants.tsshows a large diff — it's a mechanical wrap of the existing registrations intoregisterDefaultInvariants()(review withgit diff -w).