diff --git a/.changeset/signed-requests-preflight-fix.md b/.changeset/signed-requests-preflight-fix.md new file mode 100644 index 000000000..98fc1a715 --- /dev/null +++ b/.changeset/signed-requests-preflight-fix.md @@ -0,0 +1,34 @@ +--- +'@adcp/sdk': patch +--- + +Unblock 3.1 `signed-requests` adopters. Two coupled fixes; the runner-side fix alone leaves adopters stuck on the boot guard, and vice versa (per #2237 triage). + +**1. Pre-flight `resolveStoryboardsForCapabilities` (`compliance.ts`).** +`resolveStoryboardsForCapabilities` was throwing `unknown_specialism` on the +deprecated `signed-requests` claim because the bundle lives under +`universal/signed-requests.yaml`, not `specialisms/signed-requests/`. That +blocked every other storyboard from running and prevented the +`signed_requests_specialism_deprecated` notice (#2082) from ever firing. + +Adds `DEPRECATED_SPECIALISM_UNIVERSAL_ALIASES` mapping the deprecated +specialism enum value to its universal bundle base name. When the deprecated +alias is declared AND the universal bundle is present in the cache, +resolution continues silently (the universal storyboard is pushed +unconditionally and the deprecation notice fires from runner.ts). +Otherwise the throw is preserved — unknown specialism without a universal +fallback is still a configuration error. + +**2. Boot guard `createAdcpServer` (`create-adcp-server.ts`).** +The previous guard required the deprecated `signed-requests` specialism claim +whenever `signedRequests` was configured, contradicting the universal +storyboard's "drop the now-redundant specialism claim and rely solely on +`request_signing.supported: true`" guidance. Widens the guard to accept +either discovery surface: the canonical 3.1+ form +(`capabilities.request_signing.supported: true`, no deprecated claim) or +the back-compat form (`specialisms: ['signed-requests']`). Still rejects +the "config present, nothing advertised" case so buyers can't be left +unable to discover the signing requirement. JSDoc on `SignedRequestsConfig` +updated to document both paths. + +Closes #2237. diff --git a/src/lib/server/create-adcp-server.ts b/src/lib/server/create-adcp-server.ts index 386f756ff..6615034fd 100644 --- a/src/lib/server/create-adcp-server.ts +++ b/src/lib/server/create-adcp-server.ts @@ -1080,11 +1080,26 @@ export interface AdcpCapabilitiesOverrides { * mounts it as the transport-layer `preTransport` hook, so every inbound MCP * request passes the verifier before reaching the JSON-RPC router. * - * A seller that declares the `signed-requests` specialism in - * `capabilities.specialisms` MUST provide this config, and vice-versa — both - * together or neither. `createAdcpServer` throws at construction time when - * only one is set, closing the footgun where claiming the specialism - * accepts unsigned mutating traffic. + * A seller wiring this config MUST also publish a buyer-visible discovery + * surface in `capabilities`, one of: + * + * - **3.1+ canonical (recommended):** set + * `capabilities.request_signing.supported: true`. Buyers learn the agent + * verifies signatures from `get_adcp_capabilities`; no deprecated specialism + * claim required. The universal `signed_requests` storyboard grades on this + * signal alone. + * - **Back-compat:** add `'signed-requests'` to `capabilities.specialisms`. + * The 3.0-era enum value is preserved through the AdCP 4.0 deprecation cycle + * (adcp#3075); when this path is taken the runner emits + * `signed_requests_specialism_deprecated` (adcp-client#2082, adcp#4796). + * + * `createAdcpServer` throws at construction time when `signedRequests` is set + * but neither discovery surface is declared, closing the footgun where the + * verifier silently rejects every signed request from buyers who never learned + * to sign. The inverse (specialism or capability declared without a + * `signedRequests` config) is logged loudly but not thrown — legacy servers + * that hand-build the middleware via `serve({ preTransport })` stay + * conformant. * * `jwks`, `replayStore`, and `revocationStore` should be hoisted outside * the agent factory so a single verifier instance serves every request — @@ -3645,26 +3660,39 @@ export function createAdcpServer(config: AdcpServerConfig = new Set([ 'measurement', ]); +/** + * Specialism enum values that are deprecated aliases for universal-bundle + * storyboards. When an agent declares one of these in + * `get_adcp_capabilities.specialisms`, resolution MUST NOT throw + * `unknown_specialism` — the bundle lives in `universal/.yaml` and + * runs unconditionally already. The deprecated claim is honored for + * backward compatibility per the universal storyboard's + * "Backward-compatible specialism claims" clause (currently + * `signed-requests`; see `universal/signed-requests.yaml` and the + * `signed_requests_specialism_deprecated` notice emitted from + * `runner.ts:collectCapabilityNotices`). + * + * Map shape: deprecated specialism enum value → universal bundle base name + * (file under `universal/.yaml`, matched against `index.universal`). + * Removed in AdCP 4.0 along with the enum value itself + * (adcontextprotocol/adcp#3075). + */ +export const DEPRECATED_SPECIALISM_UNIVERSAL_ALIASES: Readonly> = Object.freeze({ + 'signed-requests': 'signed-requests', +}); + export interface ComplianceIndexProtocol { id: string; title: string | null; @@ -821,6 +842,15 @@ export function resolveStoryboardsForCapabilities( for (const specialism of declaredSpecialisms) { const entry = index.specialisms.find(s => s.id === specialism); if (!entry) { + // Deprecated alias path: some 3.0-era specialism enum values now resolve + // to a `universal/` bundle (e.g. `signed-requests` → `universal/signed-requests.yaml`). + // The universal storyboard is pushed unconditionally above; the deprecated + // claim is graded there and the deprecation notice fires from runner.ts. + // Don't throw — that would block every other storyboard. + const universalAlias = DEPRECATED_SPECIALISM_UNIVERSAL_ALIASES[specialism]; + if (universalAlias && index.universal.includes(universalAlias)) { + continue; + } throw new CapabilityResolutionError({ code: 'unknown_specialism', specialism, diff --git a/test/lib/storyboard-security.test.js b/test/lib/storyboard-security.test.js index 4bb984462..feb952385 100644 --- a/test/lib/storyboard-security.test.js +++ b/test/lib/storyboard-security.test.js @@ -2390,6 +2390,68 @@ describe('resolveStoryboardsForCapabilities: typed errors', () => { fs.rmSync(dir, { recursive: true, force: true }); } }); + + // Deprecated `signed-requests` specialism resolves to `universal/signed-requests.yaml` + // — must NOT throw unknown_specialism. The universal storyboard runs and the + // deprecation notice fires from runner.ts. See adcp-client#2237. + it('does NOT throw on deprecated `signed-requests` specialism alias when universal bundle exists', () => { + const dir = makeFakeComplianceCache({ + universalStoryboards: [{ id: 'signed-requests', title: 'Signed requests' }], + }); + try { + const { storyboards, not_applicable } = resolveStoryboardsForCapabilities( + { specialisms: ['signed-requests'] }, + { complianceDir: dir } + ); + assert.deepStrictEqual( + storyboards.map(s => s.id), + ['signed-requests'] + ); + assert.deepStrictEqual(not_applicable, []); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('still throws unknown_specialism for non-aliased specialisms without a bundle', () => { + const dir = makeFakeComplianceCache({ + universalStoryboards: [{ id: 'signed-requests', title: 'Signed requests' }], + }); + try { + assert.throws( + () => resolveStoryboardsForCapabilities({ specialisms: ['not-a-real-specialism'] }, { complianceDir: dir }), + err => { + assert.ok(err instanceof CapabilityResolutionError); + assert.strictEqual(err.code, 'unknown_specialism'); + assert.strictEqual(err.specialism, 'not-a-real-specialism'); + return true; + } + ); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('throws unknown_specialism for deprecated alias when the universal bundle is absent (stale cache)', () => { + // Cache has neither specialisms/signed-requests/ nor universal/signed-requests.yaml + // (e.g. cache pinned to a pre-3.1 version). The deprecated-alias fast path + // must require the universal bundle's presence — otherwise we'd silently + // skip a real configuration error. + const dir = makeFakeComplianceCache({ universalStoryboards: [] }); + try { + assert.throws( + () => resolveStoryboardsForCapabilities({ specialisms: ['signed-requests'] }, { complianceDir: dir }), + err => { + assert.ok(err instanceof CapabilityResolutionError); + assert.strictEqual(err.code, 'unknown_specialism'); + assert.strictEqual(err.specialism, 'signed-requests'); + return true; + } + ); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); }); // ──────────────────────────────────────────────────────────── diff --git a/test/server-auto-signed-requests.test.js b/test/server-auto-signed-requests.test.js index 24e94dae4..92c3375f6 100644 --- a/test/server-auto-signed-requests.test.js +++ b/test/server-auto-signed-requests.test.js @@ -181,10 +181,42 @@ async function postSigned({ url, body, sign, nonce }) { describe('createAdcpServer: signedRequests auto-wiring', () => { describe('startup validation', () => { - it('throws when signedRequests config is provided without the specialism', () => { + // Canonical 3.1+ path: signedRequests config + request_signing.supported:true, + // no deprecated `signed-requests` specialism claim. Should boot. The universal + // signed_requests storyboard grades on the capability flag alone — the deprecated + // claim is only kept for back-compat through the AdCP 4.0 deprecation window. + // See adcp-client#2237. + it('does not throw when signedRequests + request_signing.supported:true (no deprecated specialism claim)', () => { + assert.doesNotThrow(() => createAdcpServer(sellerConfig({ withSignedRequests: true, withSpecialism: false }))); + }); + + it('throws when signedRequests config is provided with neither discovery surface declared', () => { assert.throws( - () => createAdcpServer(sellerConfig({ withSignedRequests: true, withSpecialism: false })), - /specialisms.*does not include "signed-requests"/ + () => + createAdcpServer({ + ...sellerConfig({ withSignedRequests: true, withSpecialism: false }), + capabilities: { + features: { inlineCreativeManagement: false }, + specialisms: [], + // request_signing omitted — no discovery surface at all. + }, + }), + err => /request_signing\.supported: true.*specialisms.*signed-requests/.test(err.message) + ); + }); + + it('throws when signedRequests + specialism omitted + request_signing.supported:false', () => { + assert.throws( + () => + createAdcpServer({ + ...sellerConfig({ withSignedRequests: true, withSpecialism: false }), + capabilities: { + features: { inlineCreativeManagement: false }, + specialisms: [], + request_signing: { supported: false }, + }, + }), + err => /request_signing\.supported: true.*specialisms.*signed-requests/.test(err.message) ); }); @@ -243,12 +275,23 @@ describe('createAdcpServer: signedRequests auto-wiring', () => { }); it('gives the expected error message shape for each misconfiguration pattern', () => { - // config + no claim + // config + no discovery surface (no claim AND request_signing absent/supported:false) assert.throws( - () => createAdcpServer(sellerConfig({ withSignedRequests: true, withSpecialism: false })), - err => /signedRequests.*is configured but.*specialisms.*does not include "signed-requests"/.test(err.message) + () => + createAdcpServer({ + ...sellerConfig({ withSignedRequests: true, withSpecialism: false }), + capabilities: { + features: { inlineCreativeManagement: false }, + specialisms: [], + request_signing: { supported: false }, + }, + }), + err => + /signedRequests.*is configured but neither.*request_signing\.supported: true.*specialisms.*signed-requests/.test( + err.message + ) ); - // claim + supported:false — third guard + // claim + supported:false — third guard (back-compat path requires capability flag too) assert.throws( () => createAdcpServer({