Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .changeset/signed-requests-preflight-fix.md
Original file line number Diff line number Diff line change
@@ -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.
70 changes: 49 additions & 21 deletions src/lib/server/create-adcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down Expand Up @@ -3645,26 +3660,39 @@ export function createAdcpServer<TAccount = unknown>(config: AdcpServerConfig<TA
}
}

// Enforce lock-step between the `signed-requests` specialism claim and the
// verifier config for the auto-wiring path. When `signedRequests` is set
// but the specialism isn't declared, buyers can't discover the signing
// requirement from `get_adcp_capabilities` — they won't sign, the
// verifier rejects every mutating call, and the agent is dead on arrival.
// That's unambiguously wrong, so we throw.
// Enforce that the verifier config has a buyer-visible discovery surface.
// When `signedRequests` is set but neither the deprecated `signed-requests`
// specialism claim NOR `capabilities.request_signing.supported: true` is
// declared, buyers can't discover the signing requirement from
// `get_adcp_capabilities` — they won't sign, the verifier rejects every
// mutating call, and the agent is dead on arrival. Two discovery surfaces
// are accepted:
//
// - **3.1+ canonical (recommended):** `capabilities.request_signing.supported: true`.
// The universal signed_requests storyboard runs on this signal alone and
// the runner emits `request_signing.required` notices for buyers.
// - **Back-compat:** `specialisms: ['signed-requests']`. The 3.0-era enum
// is preserved through the AdCP 4.0 deprecation cycle (adcp#3075). The
// runner emits `signed_requests_specialism_deprecated` notice when this
// path is taken (adcp-client#2082, adcp#4796).
//
// The opposite direction — claiming the specialism without a
// `signedRequests` config — is only wrong when the agent also doesn't
// wire a verifier via `serve({ preTransport })`. Legacy servers that
// hand-build the middleware fall into this case and are still conformant.
// We log a loud error so operators notice (matching the idempotency
// guardrail precedent) but don't throw, leaving the manual path working.
// The opposite direction — advertising signing without a `signedRequests`
// config — is only wrong when the agent also doesn't wire a verifier via
// `serve({ preTransport })`. Legacy servers that hand-build the middleware
// fall into this case and are still conformant. We log a loud error so
// operators notice (matching the idempotency guardrail precedent) but
// don't throw, leaving the manual path working.
const specialismsClaimed = capConfig?.specialisms ?? [];
const claimsSignedRequests = specialismsClaimed.includes('signed-requests');
if (signedRequests && !claimsSignedRequests) {
const declaresRequestSigningCapability = capConfig?.request_signing?.supported === true;
if (signedRequests && !claimsSignedRequests && !declaresRequestSigningCapability) {
throw new Error(
'createAdcpServer: `signedRequests` is configured but `capabilities.specialisms` does not include "signed-requests". ' +
'Add "signed-requests" to the specialisms list — buyers discover the signing requirement from get_adcp_capabilities, ' +
"and omitting the claim means they won't sign their requests."
'createAdcpServer: `signedRequests` is configured but neither ' +
'`capabilities.request_signing.supported: true` nor `capabilities.specialisms: ["signed-requests"]` is declared. ' +
'Buyers discover the signing requirement from get_adcp_capabilities — ' +
'set `capabilities.request_signing = { supported: true, ... }` (canonical 3.1+ form, recommended) ' +
'or claim the deprecated `signed-requests` specialism (back-compat). ' +
"Omitting both surfaces means buyers won't sign their requests."
);
}
if (claimsSignedRequests && !signedRequests) {
Expand Down
30 changes: 30 additions & 0 deletions src/lib/testing/storyboard/compliance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,27 @@ export const UNBASELINED_SUPPORTED_PROTOCOLS: ReadonlySet<string> = 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/<file>.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/<name>.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<Record<string, string>> = Object.freeze({
'signed-requests': 'signed-requests',
});

export interface ComplianceIndexProtocol {
id: string;
title: string | null;
Expand Down Expand Up @@ -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,
Expand Down
62 changes: 62 additions & 0 deletions test/lib/storyboard-security.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
});
});

// ────────────────────────────────────────────────────────────
Expand Down
57 changes: 50 additions & 7 deletions test/server-auto-signed-requests.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);
});

Expand Down Expand Up @@ -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({
Expand Down
Loading