From bfacfeb66178d161254830faef48f36a5a6a629e Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 14 Sep 2026 14:33:56 +0000 Subject: [PATCH] fix(accounts)!: rework derived resolution as an upstream-managed account-id namespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AccountStore.resolution: 'derived'` shipped with inverted wire semantics: `account_id` was refused and the brand+operator natural key permitted, which is unusable for the adapters the mode exists for — an agent fronting an upstream-managed roster cannot accept the id its own upstream assigned, and buyers who called `list_accounts` got ids every later call rejected. Corrects the SDK to the account-reference model settled upstream in adcontextprotocol/adcp#5062: - `{ account_id }` accepted (and verified); the `{ brand, operator }` arm refused with INVALID_REQUEST + a list_accounts suggestion. - `list_accounts` required for the mode (PlatformConfigError at construction; the merge-seam `opts.accounts.listAccounts` satisfies it). - sync_accounts is not categorically blocked: natural-key provisioning entries fail per-row with UNSUPPORTED_PROVISIONING, natural-key references with INVALID_REQUEST, and `account: { account_id }` settings-update entries pass through. - A declared 'derived' projects `account.require_operator_auth: true`. Tenant-isolation hardening, since the buyer now supplies the id: - The framework refuses a resolved account whose id isn't the one the buyer named, so an un-migrated ref-ignoring resolver fails closed. - sync_accounts / sync_governance entries — the two account references that never passed through accounts.resolve — are resolved against the caller's reachable set before any write, on both the platform and merge-seam paths. - The comply-controller sandbox gate no longer accepts a wire `sandbox: true` claim alongside an account_id the resolver refused. - createDerivedAccountStore verifies by construction, gains `listAccounts` / `lookupAccount` for multi-account credentials, and wires a filtered, paged `list_accounts`. BREAKING CHANGE: `accounts.resolution: 'derived'` now accepts inline `account.account_id` and refuses the `{ brand, operator }` arm, requires `list_accounts`, and projects `account.require_operator_auth: true`. Resolvers must verify the buyer-supplied id against what the caller's credential can reach. See docs/migration-13-to-14.md. Fixes #1647 Fixes #1628 Co-Authored-By: Claude Opus 5 (1M context) --- .../derived-upstream-managed-namespace.md | 25 + CLAUDE.md | 4 +- docs/guides/BUILD-AN-AGENT.md | 15 +- docs/guides/account-resolution.md | 208 +++- docs/llms.txt | 4 +- docs/migration-13-to-14.md | 179 +++- docs/migration-5.x-to-6.x.md | 6 +- docs/migration-6.6-to-6.7.md | 14 +- docs/migration-6.7-to-6.9.md | 17 +- .../decisioning-platform-derived-accounts.ts | 184 ++++ examples/decisioning-platform-mock-seller.ts | 8 +- scripts/generate-agent-docs.ts | 4 +- scripts/skill-examples.baseline.json | 10 - skills/build-decisioning-platform/SKILL.md | 13 +- .../advanced/REFERENCE.md | 59 +- skills/cross-cutting.md | 2 +- src/lib/adapters/derived-account-store.ts | 445 +++++++-- src/lib/adapters/implicit-account-store.ts | 5 +- src/lib/adapters/index.ts | 19 +- .../adapters/oauth-passthrough-resolver.ts | 5 +- src/lib/adapters/roster-account-store.ts | 11 +- src/lib/index.ts | 6 +- src/lib/server/create-adcp-server.ts | 7 +- src/lib/server/decisioning/account.ts | 218 ++++- .../decisioning/decisioning.type-checks.ts | 10 +- src/lib/server/decisioning/index.ts | 10 +- .../decisioning/runtime/from-platform.ts | 770 ++++++++++++++- .../server/decisioning/runtime/to-context.ts | 5 +- .../decisioning/runtime/validate-platform.ts | 15 + .../server/decisioning/specialisms/sales.ts | 6 +- src/lib/server/index.ts | 8 +- .../testing/compliance/spec-conformance.ts | 37 +- src/lib/testing/storyboard/runner.ts | 6 +- test/derived-account-store.test.js | 403 ++++++-- .../account-resolution-alias-drift.test.js | 81 ++ test/lib/proposal-manager-e2e.test.js | 5 +- test/lib/proposal-negotiation-server.test.js | 1 + ...spec-conformance-account-discovery.test.js | 70 ++ ...decisioning-capability-projections.test.js | 29 + test/server-decisioning-comply-gate.test.js | 34 + test/server-decisioning-from-platform.test.js | 6 + ...er-decisioning-implicit-account-id.test.js | 920 ++++++++++++++++-- test/server-pool-shortcut.test.js | 1 + 43 files changed, 3385 insertions(+), 500 deletions(-) create mode 100644 .changeset/derived-upstream-managed-namespace.md create mode 100644 examples/decisioning-platform-derived-accounts.ts create mode 100644 test/lib/account-resolution-alias-drift.test.js diff --git a/.changeset/derived-upstream-managed-namespace.md b/.changeset/derived-upstream-managed-namespace.md new file mode 100644 index 000000000..c69cd68d6 --- /dev/null +++ b/.changeset/derived-upstream-managed-namespace.md @@ -0,0 +1,25 @@ +--- +'@adcp/sdk': major +--- + +**Breaking (correction inside the unreleased v14):** `AccountStore.resolution: 'derived'` is now an upstream-managed account-id namespace, matching the account-reference model settled in [adcp#5062](https://github.com/adcontextprotocol/adcp/pull/5062). The previous wire semantics were inverted and unusable for the adapters the mode exists for — buyers who called `list_accounts` got ids that every subsequent call rejected. + +Wire contract: + +- `{ account_id }` is the durable reference for `'derived'` and is now **accepted** (it was refused with `INVALID_REQUEST`); the `{ brand, operator }` natural-key arm is now **refused** with `INVALID_REQUEST` (`field: 'account.brand'`) and a `list_accounts` suggestion. +- `list_accounts` is **required** for `'derived'` — `createAdcpServerFromPlatform` throws `PlatformConfigError` when neither `accounts.list` nor `opts.accounts.listAccounts` is wired. It is the discovery contract for a namespace the agent doesn't own; credential-bound singletons expose one row. +- `sync_accounts` is not categorically blocked: natural-key provisioning entries fail per-row with `UNSUPPORTED_PROVISIONING`, natural-key _references_ fail per-row with `INVALID_REQUEST`, and `account: { account_id }` settings-update entries pass through. +- A declared `'derived'` resolution projects `account.require_operator_auth: true` on `get_adcp_capabilities`, like a declared `'explicit'`. + +Tenant-isolation hardening (`'derived'` only): + +- `accounts.resolve` must verify buyer-supplied ids; the framework backstops it — a resolved account whose `id` isn't the one the buyer named is refused with `ACCOUNT_NOT_FOUND` (with a dev-mode warning), so an un-migrated resolver that ignores `ref` fails closed instead of cross-serving. +- `sync_accounts` and `sync_governance` entries — the two account references that never passed through `accounts.resolve` — are now resolved against the caller's reachable set before any write, on both the platform and merge-seam wirings. +- The `comply_test_controller` sandbox gate no longer accepts a wire `sandbox: true` claim alongside an `account_id` the resolver refused. + +API: + +- `createDerivedAccountStore` verifies buyer-supplied `account_id` against what the caller's credential can reach (fail closed → `ACCOUNT_NOT_FOUND`), auto-selects the account on ref-less tools only when exactly one is reachable, and wires a filtered, paged `list_accounts`. New `listAccounts` / `lookupAccount` options support credentials that reach many accounts; `toAccount` keeps its signature for the single-account case. +- Added `AccountResolutionMode` / `CanonicalAccountResolutionMode` types and the `normalizeAccountResolution()`, `isAccountResolutionMode()`, `refHasNaturalKey()` helpers. An unrecognized `resolution` value is now a `PlatformConfigError` rather than silently inheriting another mode's enforcement. No alias spelling is introduced — `'derived'` stays the single name. + +Migration: [13 → 14 § derived account resolution](https://github.com/adcontextprotocol/adcp-client/blob/main/docs/migration-13-to-14.md#derived-account-resolution-is-now-an-upstream-managed-account-id-namespace). Fixes #1647 and #1628. diff --git a/CLAUDE.md b/CLAUDE.md index 24815ed88..fbef5aebd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,11 +86,13 @@ Pick the specialisms you want to claim in `get_adcp_capabilities`. Each maps to **Adding wire-version compat (e.g. SDK pin moves to v4, or a new legacy seller version)?** See [`docs/development/WIRE-VERSION-COMPAT.md`](./docs/development/WIRE-VERSION-COMPAT.md) — playbook for the schema cache, codegen, adapter registry, validation pinning, conformance fixtures, and smoke harness. Reference whenever you touch `src/lib/adapters/legacy/`, `src/lib/types/v*-*/`, or `schemas/cache//`. +**Migrating from 13 to 14 (prerelease)?** See [`docs/migration-13-to-14.md`](./docs/migration-13-to-14.md). One account-model breaking change to audit: **`accounts.resolution: 'derived'` is now an upstream-managed account-id namespace** (adcp-client#1647, upstream adcp#5062). It accepts `{ account_id }` and refuses the `{ brand, operator }` arm — the exact inverse of the 6.7-era behavior described further down this list. `accounts.list` (or `opts.accounts.listAccounts`) is required for the mode, `accounts.resolve` must verify the buyer-supplied id against what the caller's credential can reach (the framework fails a mismatch closed), `sync_accounts` natural-key provisioning entries fail per-row with `UNSUPPORTED_PROVISIONING` while `account: { account_id }` settings-update entries pass through after a reachability check, and a declared `'derived'` projects `account.require_operator_auth: true`. `createDerivedAccountStore` does the verification, the paging, and the `list_accounts` wiring for you — `toAccount` for one account per credential, `listAccounts` (+ optional `lookupAccount`) for a roster. + **Migrating from 7.9 to 7.10?** See [`docs/migration-7.9-to-7.10.md`](./docs/migration-7.9-to-7.10.md) — **pure additive walkthrough; no breaking recipes.** Headlines: `AgentClient.getProducts()` auto-augments responses with V2 `format_options[]` (read-side mental model), `packageRefsForCapabilities(product, [capId])` returns `{capability_ids, format_ids?}` to spread into a `PackageRequest` (write-side, dual emission per adcp#4844 in 3.1.0-beta.2), `fetchFormatSchema` + `resolveSchemaRefs` for the `format_schema` URI+digest contract (HTTPS+SSRF+digest+bounded `$ref`), and `extractPublisherFormats` / `scopePublisherFormats` / `resolveCapabilityId` on top of `validateAdAgents()` for the `adagents.json#/formats` AAO 3.1 catalog. V1-only adopters do nothing — `format_ids[]` preserved on every product, `legacy*` helpers (semantic narrowing, not deprecation) cover the single-target v1 write path. Opt-in 3.1.0-beta.2 schemas via `adcpVersion: '3.1-beta'` / `'3.1.0-beta.2'`; primary pin stays at the GA `ADCP_VERSION`. `DEFAULT_MIRROR_HOSTS` collapsed to `creative.adcontextprotocol.org` per adcp#4866 (legacy host was never provisioned). **Migrating from 6.7 to 6.9?** See [`docs/migration-6.7-to-6.9.md`](./docs/migration-6.7-to-6.9.md) — **skips past 6.8.0** (deprecated same-day; 6.7 → 6.9 is the supported path). Two breaking recipes to audit before bumping: **#1** the framework now auto-wires the sandbox-authority gate inside `createAdcpServerFromPlatform` for `comply_test_controller` (Phase 2 of #1435 — resolved-account `mode` is the trust boundary, not buyer-supplied `account.sandbox`), and **#2** `npm run compliance:skill-matrix` is removed in favour of `compliance:fork-matrix` (~10 s deterministic vs ~50 min LLM-variance). Thirteen additive recipes covering `Account.mode` helpers (with `Account.sandbox` server-side `@deprecated`), `createAdcpServer.instructions` async, `BuyerAgentRegistry.extra` forwarding, `composeMethod` variadic, `ComplyControllerConfig.force` extension + `queryUpstreamTraffic` adapter, `createDerivedAccountStore` Shape D, `RosterAccountStoreOptions.resolveWithoutRef`, `ConformanceClient` Socket Mode, AdCP 3.0.6 schema bump + `tasks/get` slash registration + `AgentClient.executor`, storyboard runner symmetric account resolution, codegen `asset_type` discriminator on `Individual*Asset` slots, skill prose collapse + `skills/cross-cutting.md`, five new worked references / mock-servers. Self-grade checklist with grep-based audits. -**Migrating from 6.6 to 6.7?** See [`docs/migration-6.6-to-6.7.md`](./docs/migration-6.6-to-6.7.md) — seventeen adopter recipes plus two breaking changes to audit before bumping: **#10** `accounts.resolution: 'implicit'` now actually refuses inline `{account_id}` references (was aspirational pre-6.7), **#10b** same refusal extended to `'derived'` mode (recipe backfilled in 6.9 via #1492 — runtime shipped in 6.7 via #1475), and **#11** `SalesPlatform` split into `SalesCorePlatform & SalesIngestionPlatform` (all methods individually optional, self-announcing under `tsc --noEmit`). The other fifteen recipes are additive: `definePlatform` family, `refAccountId`, `composeMethod`, `accounts.resolve` security presets, `accounts.upsert/list/syncGovernance(ctx)`, typed errors, `issues[].hint`, `BuyerAgentRegistry`, `createTenantRegistry`, `createOAuthPassthroughResolver` (Shape B), `createRosterAccountStore` (Shape C), `createDerivedAccountStore` (Shape D — single-tenant `'derived'`), `createTenantStore` (multi-tenant w/ built-in security gate), `MEDIA_BUY_TRANSITIONS` + `assertMediaBuyTransition` lifecycle helpers, `createMediaBuyStore` for `targeting_overlay` echo. Worked diff + self-grade checklist included. +**Migrating from 6.6 to 6.7?** See [`docs/migration-6.6-to-6.7.md`](./docs/migration-6.6-to-6.7.md) — seventeen adopter recipes plus two breaking changes to audit before bumping: **#10** `accounts.resolution: 'implicit'` now actually refuses inline `{account_id}` references (was aspirational pre-6.7), **#10b** same refusal extended to `'derived'` mode (recipe backfilled in 6.9 via #1492 — runtime shipped in 6.7 via #1475; **reversed in SDK 14 by #1647**, where `'derived'` accepts `account_id` and refuses the natural key instead), and **#11** `SalesPlatform` split into `SalesCorePlatform & SalesIngestionPlatform` (all methods individually optional, self-announcing under `tsc --noEmit`). The other fifteen recipes are additive: `definePlatform` family, `refAccountId`, `composeMethod`, `accounts.resolve` security presets, `accounts.upsert/list/syncGovernance(ctx)`, typed errors, `issues[].hint`, `BuyerAgentRegistry`, `createTenantRegistry`, `createOAuthPassthroughResolver` (Shape B), `createRosterAccountStore` (Shape C), `createDerivedAccountStore` (Shape D — `'derived'`; reworked in SDK 14 from single-tenant to upstream-managed account-id namespace), `createTenantStore` (multi-tenant w/ built-in security gate), `MEDIA_BUY_TRANSITIONS` + `assertMediaBuyTransition` lifecycle helpers, `createMediaBuyStore` for `targeting_overlay` echo. Worked diff + self-grade checklist included. **Migrating from 4.x?** See [`docs/migration-4.x-to-5.x.md`](./docs/migration-4.x-to-5.x.md) for the full 4.x → 5.x path. Covers framework shape (5.0's `TaskResult` discriminated union + `createAdcpServer`), exports cleanup (5.1's `platform_type` removal + storyboard-tarball move), AdCP 3.0 GA alignment (5.2's `authority_level` → `human_review_required`, `inventory-lists` → `property-lists`, `idempotency_key` requirement, `serve({ authenticate })` surface), downstream ergonomics (5.3–5.4 `AdcpServer` return type + `dispatchTestRequest`), signed-requests composition (5.5–5.6 `requireSignatureWhenPresent` + `capabilities.overrides`), conformance runner wiring (5.7 `createExpressAdapter`), conformance defaults (5.8 `createComplyController`), A2A session continuity + typed errors (5.9), OAuth client credentials + strict validation defaults (5.10), and the pin to AdCP 3.0.0 GA (5.13: `ADCP_VERSION` switched from `latest` to the published `3.0.0` release; `validate_property_delivery` response is now wired to its generated schema). Includes a wire-interop matrix for mixed-version (beta.3 / rc.2 / GA) traffic. diff --git a/docs/guides/BUILD-AN-AGENT.md b/docs/guides/BUILD-AN-AGENT.md index ae685ea87..98f03e697 100644 --- a/docs/guides/BUILD-AN-AGENT.md +++ b/docs/guides/BUILD-AN-AGENT.md @@ -434,9 +434,11 @@ import { definePlatform, refAccountId, AccountNotFoundError } from '@adcp/sdk/se const platform = definePlatform({ capabilities: { specialisms: ['sales-non-guaranteed'] as const, /* ... */ }, accounts: { - // 'explicit' (default), 'implicit' (sync_accounts-first), or 'derived' (single-tenant). - // 'implicit' adopters: framework refuses inline {account_id} references with INVALID_REQUEST - // *before* reaching your resolver (post-6.7). + // 'explicit' (default), 'implicit' (sync_accounts-first), or 'derived' + // (upstream-managed account-id namespace, discovered via list_accounts). + // The framework enforces each mode's durable reference shape *before* + // reaching your resolver: 'implicit' refuses inline {account_id}; + // 'derived' refuses the {brand, operator} natural key. resolution: 'explicit', resolve: async (ref, ctx) => { const id = refAccountId(ref); @@ -463,7 +465,7 @@ Three resolution modes: - **`'explicit'`** (default) — buyer passes `{account_id}` inline on every request. Snap, Meta, GAM-style sellers. The framework calls `resolve(ref, ctx)` with the inline ref. - **`'implicit'`** — buyer must call `sync_accounts` first; subsequent requests resolve from the auth-principal linkage your `upsert` populated. LinkedIn-shaped sellers. The framework refuses inline `{account_id}` references with `INVALID_REQUEST` (post-6.7 — pre-6.7 the docstring claimed this but nothing checked it). Use [`InMemoryImplicitAccountStore`](https://github.com/adcontextprotocol/adcp-client/blob/main/src/lib/adapters/implicit-account-store.ts) for the reference shape. -- **`'derived'`** — single-tenant agents where the auth principal alone identifies the tenant. Self-hosted broadcasters, retail-media operators in proxy mode. `resolve(undefined, ctx)` returns the singleton. +- **`'derived'`** — an upstream-managed account-id namespace: you front a platform that owns the roster (Meta / Snap ad accounts, AudioStack workspaces, a retail-media proxy), or the credential is bound to a single account. Buyers discover ids via `list_accounts` and pass `{account_id}`; the framework refuses the `{brand, operator}` arm, `accounts.list` is required, and `resolve` must verify the buyer-supplied id against what the caller's credential can reach. Use [`createDerivedAccountStore`](https://github.com/adcontextprotocol/adcp-client/blob/main/src/lib/adapters/derived-account-store.ts), which does both. `resolve(undefined, ctx)` auto-selects when exactly one account is reachable. **Changed in SDK 14** — see the [migration guide](../migration-13-to-14.md#derived-account-resolution-is-now-an-upstream-managed-account-id-namespace). **Stateless BYOK provider adapters.** For single-account API-key or bearer-token BYOK, the provider credential can be the AdCP request credential @@ -474,8 +476,9 @@ derives the account from request auth, and uses the same request-local token for upstream provider calls. No SDK-managed OAuth flow, refresh-token store, provider-token store, or callback route is required when the caller owns the provider credential lifecycle. If the provider credential can see multiple -upstream accounts, use an explicit account roster pattern such as -`createOAuthPassthroughResolver` instead of `'derived'`. Handlers with a +upstream accounts, stay in `'derived'` and supply `listAccounts` so buyers +can discover and name one, or use `createOAuthPassthroughResolver` under +`'explicit'` when you own the id namespace. Handlers with a resolved account should read the active token from `ctx.account.authInfo?.token`; refresh hooks update `account.authInfo`. Handlers without a resolved account can read the request token from diff --git a/docs/guides/account-resolution.md b/docs/guides/account-resolution.md index 1eedb4141..8cb25b1bf 100644 --- a/docs/guides/account-resolution.md +++ b/docs/guides/account-resolution.md @@ -8,11 +8,11 @@ How sellers implement the three `AccountStore.resolution` modes — `'explicit'` ## Quick reference -| Mode | Buyer sends | Seller resolves via | -|---|---|---| -| `'explicit'` (default) | `ext.account_ref` on every request | `ref.account_id` or `ref.brand`/`ref.operator` | -| `'implicit'` | Nothing (no `ext.account_ref`) — but must call `sync_accounts` first | `ctx.authInfo` credential key | -| `'derived'` | Nothing | Single-tenant singleton; no per-request resolution needed | +| Mode | Roster owner | Onboarding / discovery | Durable wire reference | +|---|---|---|---| +| `'explicit'` (default) | You (the seller) | ids issued out-of-band, or an optional `list_accounts` | `{ account_id }` or the `{ brand, operator }` natural key | +| `'implicit'` | Buyer-declared | `sync_accounts` (required) | `{ brand, operator }` — inline `account_id` is refused | +| `'derived'` | An upstream platform you front, or the credential itself | `list_accounts` (**required**) | `{ account_id }` — the `{ brand, operator }` arm is refused | Declare the mode on `AccountStore`: @@ -24,6 +24,23 @@ accounts: { } ``` +**Each mode has exactly one spelling.** `'derived'` keeps its name even +though [adcp#5062](https://github.com/adcontextprotocol/adcp/pull/5062) +calls the shape an *upstream-managed account-id namespace*: `resolution` is +SDK-local configuration, never a wire value, so a second spelling would buy +nothing on the wire while silently defeating adopter code written as +`resolution === 'derived'` and inviting divergence from the Python SDK. +`'account-id-namespace'` is rejected for the extra reason that it doesn't +discriminate — under #5062 both `'explicit'` and `'derived'` are account-id +namespaces. An unrecognized value is a `PlatformConfigError` at +construction, not a silent fallback. + +> **Breaking change in SDK 14 (adcp-client#1647).** `'derived'` used to mean +> "single-tenant; no `account_id` on the wire", and the framework refused +> inline `account_id` for it. That was inverted — see +> [`'derived'` (upstream-managed account-id namespace)](#derived-upstream-managed-account-id-namespace) +> and the [13 → 14 migration guide](../migration-13-to-14.md#derived-account-resolution-is-now-an-upstream-managed-account-id-namespace). + --- ## `'implicit'` deep dive @@ -181,18 +198,42 @@ accountStore.size; // number of stored linkages --- -## `'derived'` (single-tenant) - -Return a fixed singleton regardless of `ref`. For the canonical Shape D -pattern (auth principal IS the tenant — audiostack, flashtalking, -single-namespace retail-media), use `createDerivedAccountStore`: +## `'derived'` (upstream-managed account-id namespace) + +Pick `'derived'` when the account roster is **not yours to provision**: you +front an upstream platform that owns it (Meta / Snap business accounts, +AudioStack workspaces, a retail-media proxy), or the buyer's credential is +bound to exactly one account on your side. Roster size is an operational +property of the upstream — N=1 and N=many share one wire contract. + +The contract: + +1. The buyer calls **`list_accounts`** to discover what its credential can + reach. This is required for the mode, not optional polish — + `createAdcpServerFromPlatform` throws `PlatformConfigError` when neither + `accounts.list` nor `opts.accounts.listAccounts` is wired. A credential + bound to one account returns one row, so buyer SDKs can auto-select it. +2. The buyer sends **`account: { account_id }`** on every account-scoped + call. The framework refuses the `{ brand, operator }` arm for this mode + with `AdcpError('INVALID_REQUEST', { field: 'account.brand' })` and a + `list_accounts` suggestion — a natural key is not a durable reference into + someone else's roster. +3. Your `resolve` **verifies** that id against what the *caller's credential* + can reach, and returns `null` on a miss (framework → `ACCOUNT_NOT_FOUND`). + `createDerivedAccountStore` does this for you. As a backstop the framework + also refuses a resolved account whose `id` isn't the one the buyer named — + that catches a resolver that ignores `ref`, but it can't tell whether your + lookup was credential-scoped, so it is not a substitute for step 3. ```ts import { createDerivedAccountStore } from '@adcp/sdk/server'; +// One account per credential (AudioStack, flashtalking, single-namespace +// retail-media). The factory publishes the one-row list_accounts, verifies +// buyer-supplied ids against it, and auto-selects it on ref-less tools. const accounts = createDerivedAccountStore({ - toAccount: ctx => ({ - id: 'tenant_singleton', + toAccount: async ctx => ({ + id: await upstream.workspaceId(ctx?.authInfo), // the id buyers send back name: 'My Platform', status: 'active', ctx_metadata: {}, @@ -200,25 +241,123 @@ const accounts = createDerivedAccountStore({ }); ``` -The factory sets `resolution: 'derived'`, still throws legacy-compatible -`AdcpError('AUTH_REQUIRED')` when `ctx.authInfo.credential` is absent -(skip with `skipAuthCheck: true` for genuinely unauthenticated agents), -and ignores any buyer-supplied `account_id` (single-tenant by definition). -New hand-rolled stores can throw `AuthMissingError` when they intentionally -emit the AdCP 3.1 missing-request-credential code. Hand-rolled equivalent: +```ts +// Many accounts per credential (Meta / Snap shaped). `listAccounts` is the +// tenant-isolation boundary: scope it by the caller's credential. +const accounts = createDerivedAccountStore<{ upstreamId: string }>({ + listAccounts: async ctx => { + const rows = await meta.adAccountsFor(ctx?.authInfo); + return rows.map(r => ({ + id: r.account_id, + name: r.name, + status: 'active', + ctx_metadata: { upstreamId: r.id }, + })); + }, + // Optional point lookup for large rosters — MUST filter by the caller too. + lookupAccount: (id, ctx) => meta.adAccountForCaller(id, ctx?.authInfo), +}); +``` + +For a runnable example see `examples/decisioning-platform-derived-accounts.ts`. + +The factory also throws legacy-compatible `AdcpError('AUTH_REQUIRED')` when +`ctx.authInfo` carries no credential (skip with `skipAuthCheck: true` for +genuinely unauthenticated agents). New hand-rolled stores can throw +`AuthMissingError` when they intentionally emit the AdCP 3.1 +missing-request-credential code. + +Hand-rolled equivalent — you own both obligations, the `list` and the check: ```ts +import { refAccountId } from '@adcp/sdk/server'; + accounts: { resolution: 'derived', - resolve: async () => ({ - id: 'tenant_singleton', - name: 'My Platform', - status: 'active', - ctx_metadata: {}, - }), + resolve: async (ref, ctx) => { + const reachable = await upstream.accountsFor(ctx?.authInfo); // credential-scoped + const id = refAccountId(ref); + // Fail closed. Never "we only have one account, so serve it anyway". + // (The framework also refuses a resolved account whose id isn't the one + // the buyer named — but own the check; it's your tenant boundary.) + if (id !== undefined) return reachable.find(a => a.id === id) ?? null; + // Ref-less tools: auto-select only when there is exactly one. + return reachable.length === 1 ? reachable[0] : null; + }, + // The framework does not filter or page for you: honor req.account / + // req.status / req.sandbox and req.pagination in your own query. + list: async (req, ctx) => ({ items: await upstream.accountsFor(ctx?.authInfo) }), } ``` +### Ignoring `ref` is a tenant-isolation bug + +A resolver that returns a fixed account regardless of `ref` was the +pre-SDK-14 Shape D pattern. Keep writing it and the first time your +deployment serves two accounts, caller A's `{ account_id: 'B' }` request runs +against account A's data — no error, no log. Verify the ref, or let +`createDerivedAccountStore` verify it. + +### Writes are verified too + +`sync_accounts` and `sync_governance` carry their account reference *inside* +the batch, so they never went through `accounts.resolve`. For `'derived'` +platforms the framework now resolves every entry's `account_id` against what +the caller's credential can reach before any write runs — an unreachable id +fails the `sync_accounts` operation with `ACCOUNT_NOT_FOUND` (the response +row schema requires `brand` + `operator`, which we don't have for an account +we refused to resolve) and fails the individual `sync_governance` row. + +### `sync_accounts` in derived mode + +Not forbidden, but narrowed to what [adcp#5062](https://github.com/adcontextprotocol/adcp/pull/5062) +allows: + +- **Natural-key provisioning entries** (`{ brand, operator, billing }` at the + entry root, or a settings-update entry keyed by the natural key) are failed + per-row by the framework with `UNSUPPORTED_PROVISIONING` before reaching + `accounts.upsert`. There is nothing to provision in a namespace you don't + own. +- **Settings-update entries** keyed by `account: { account_id }` pass + through, after the reachability check above. Wire `accounts.upsert` if your + upstream exposes account-settings writes; leave it unimplemented and buyers + calling `sync_accounts` get the framework's unimplemented-tool path, as + before. +- A natural-key *reference* (`account: { brand, operator }`) is refused + per-row with `INVALID_REQUEST` — the ref shape is wrong for the namespace, + which is a different fault from the provisioning mode being unsupported. +- An entry carrying **two** references (root `account_id` plus a nested + `account`, or an `account` mixing `account_id` with `brand`/`operator`) is + refused with `INVALID_REQUEST`, never disambiguated by precedence. The + schema's per-entry `oneOf` forbids those shapes, but validation is + relaxable and accepting on one reference while writing against another is + a bypass. +- **Response rows still require `brand` + `operator`** per + `sync-accounts-response.json`, so a settings-update row echoes them from + your own account record. If your upstream accounts have no brand/operator + you can express, don't wire `upsert` — see + [adcontextprotocol/adcp#7517](https://github.com/adcontextprotocol/adcp/issues/7517). + +### Capability projection + +A declared `resolution: 'derived'` projects `account.require_operator_auth: +true` on `get_adcp_capabilities` — the capability bit for account-id +namespaces, same as declared `'explicit'`. Conformance runners read it and +grade `sync_accounts` storyboard steps as `not_applicable` rather than +`missing_tool`. Override with `capabilities.requireOperatorAuth` if your +deployment's auth model differs. + +Emitting the account block also emits `account.supported_billing`, which +defaults to `['agent']`. Declare `capabilities.supportedBillings` explicitly +— an upstream-managed namespace usually bills the operator, and the default +will route buyers into agent-billed flows. + +### If you have no namespace to discover + +An agent that hands out account ids out-of-band and has nothing to enumerate +is a **seller-defined** account-id namespace: declare `'explicit'`. That mode +accepts `{ account_id }` (and the natural key) with no discovery obligation. + ### Stateless BYOK provider auth For single-account API-key or bearer-token BYOK adapters, the provider @@ -236,9 +375,10 @@ with the caller-presented provider credential, derives the singleton account from that request auth, and uses the same request-local token for upstream API calls. No SDK-managed OAuth flow, refresh-token store, provider-token store, or callback route is required when the caller owns the provider credential -lifecycle. If the provider credential can see multiple upstream accounts, use -an explicit account roster pattern such as `createOAuthPassthroughResolver` -instead of `'derived'`. +lifecycle. If the provider credential can see multiple upstream accounts, +stay in `'derived'` but supply `listAccounts` instead of `toAccount` so the +buyer can discover and name one — or use `createOAuthPassthroughResolver` +under `'explicit'` if you also own the id namespace. Handlers with a resolved account should read the active token from `ctx.account.authInfo?.token`; refresh hooks update `account.authInfo`. @@ -260,18 +400,8 @@ two credentials: one credential to authorize the caller to the AdCP agent and another credential to authorize the upstream provider tenant. That dual-auth proxy shape is optional; it is not the baseline BYOK model. -No `upsert` needed. The framework returns `UNSUPPORTED_FEATURE` to any -buyer that calls `sync_accounts`. - -**Inline `account_id` refusal.** Since adcp-client#1468, the framework -refuses inline `{ account_id }` references for `'derived'` platforms with -`AdcpError('INVALID_REQUEST', { field: 'account.account_id' })` *before* -reaching `accounts.resolve` — same shape as `'implicit'`'s refusal (#1364), -with a single-tenant message instead of the `sync_accounts`-first guidance. -Hand-rolled `'derived'` stores get this for free; the -`createDerivedAccountStore` factory's defensive ignore is a belt + braces -fallback. The brand+operator union arm is still permitted (route through -your resolver verbatim). +No `upsert` needed — see [`sync_accounts` in derived mode](#sync_accounts-in-derived-mode) +for what the framework does with each entry shape when you do wire one. --- diff --git a/docs/llms.txt b/docs/llms.txt index dca24913b..8b10d4a66 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -70,9 +70,9 @@ Lower-level option: `createAdcpServer({ signals: { getSignals: ... } })` from `@ **`composeMethod` cookbook.** To layer `before`/`after` hooks on a single platform method — short-circuit for caching, enrichment under `ext.*`, typed-error guards — use `composeMethod(inner, { before?, after? })` from `@adcp/sdk/server`. Stacking multiple guards: nest `composeMethod` calls (outer `before` runs first). Test patterns (mocking inner, asserting short-circuit, chained hooks, typed-error propagation): see [`docs/recipes/composeMethod-testing.md`](./recipes/composeMethod-testing.md). Pre-built `accounts.resolve` guards from the same package: `requireAccountMatch(predicate, opts)`, `requireAdvertiserMatch(getRoster, opts)`, `requireOrgScope(getAccountOrg, getCtxOrg, opts)`. Default deny returns `null` (indistinguishable from "not found"; guards against principal enumeration); opt in to `onDeny: 'throw'` for typed `PermissionDeniedError`. -**Four reference `AccountStore` shapes.** Pick the one whose onboarding model matches yours. **Shape A — `InMemoryImplicitAccountStore`**: `resolution: 'implicit'`, buyer-driven `sync_accounts` populates the auth-principal → accounts map. **Shape B — `createOAuthPassthroughResolver`**: `resolution: 'explicit'`, returns just the `resolve` function for adapters fronting an upstream OAuth listing endpoint (Snap, Meta, TikTok, LinkedIn — `extract bearer → GET /me/adaccounts → match by id`). **Shape C — `createRosterAccountStore`**: `resolution: 'explicit'`, returns a complete `AccountStore` for adopters who own the roster (storefront table, admin-UI-managed JSON). Supports `resolveWithoutRef` for tools that send no `account` field on the wire (`list_creative_formats`, `preview_creative`, `provide_performance_feedback`) — set it to return a synthetic publisher-wide entry instead of `null`. **Shape D — `createDerivedAccountStore`**: `resolution: 'derived'`, single-tenant agents where there is no `account_id` on the wire and the auth principal alone identifies the tenant (audiostack, flashtalking, single-namespace retail-media). Provide `toAccount(ctx)`; the factory still emits legacy-compatible `AUTH_REQUIRED` on missing-credential calls and ignores buyer-supplied `account_id` (single-tenant by definition). Buyer code must continue to handle `AUTH_REQUIRED` alongside `AUTH_MISSING` / `AUTH_INVALID`. All four live at `@adcp/sdk/server`. +**Four reference `AccountStore` shapes.** Pick the one whose onboarding model matches yours. **Shape A — `InMemoryImplicitAccountStore`**: `resolution: 'implicit'`, buyer-driven `sync_accounts` populates the auth-principal → accounts map. **Shape B — `createOAuthPassthroughResolver`**: `resolution: 'explicit'`, returns just the `resolve` function for adapters fronting an upstream OAuth listing endpoint (Snap, Meta, TikTok, LinkedIn — `extract bearer → GET /me/adaccounts → match by id`). **Shape C — `createRosterAccountStore`**: `resolution: 'explicit'`, returns a complete `AccountStore` for adopters who own the roster (storefront table, admin-UI-managed JSON). Supports `resolveWithoutRef` for tools that send no `account` field on the wire (`list_creative_formats`, `preview_creative`, `provide_performance_feedback`) — set it to return a synthetic publisher-wide entry instead of `null`. **Shape D — `createDerivedAccountStore`**: `resolution: 'derived'`, an upstream-managed account-id namespace — the platform you front owns the roster (Meta / Snap ad accounts, audiostack, flashtalking, single-namespace retail-media). Buyers discover ids through `list_accounts` and send `account: { account_id }`; the framework refuses the `{ brand, operator }` arm for this mode and `accounts.list` is required (`createAdcpServerFromPlatform` throws `PlatformConfigError` without it). Provide `toAccount(ctx)` when a credential reaches exactly one account or `listAccounts(ctx)` when it reaches many (plus optional `lookupAccount(id, ctx)` for large rosters); the factory verifies buyer-supplied `account_id` against what the caller's credential can reach, returns `null` on a miss, auto-selects the account on ref-less tools only when exactly one is reachable, wires a filtered and paged `list_accounts`, and still emits legacy-compatible `AUTH_REQUIRED` on missing-credential calls. The framework backstops hand-rolled `'derived'` stores: a resolved account whose `id` isn't the one the buyer named is refused with `ACCOUNT_NOT_FOUND`, and `sync_accounts` / `sync_governance` entries are resolved against the caller's reachable set before any write. Buyer code must continue to handle `AUTH_REQUIRED` alongside `AUTH_MISSING` / `AUTH_INVALID`. Changed in SDK 14 (adcp-client#1647 / adcp#5062) — Shape D previously refused inline `account_id` and was documented as single-tenant-only. All four live at `@adcp/sdk/server`. -**Stateless BYOK provider auth.** For single-account API-key or bearer-token BYOK, the provider credential can be the AdCP request credential for that endpoint: `Authorization: Bearer `. This keeps the baseline seller-agent wrapper pattern single-plane: the seller agent authenticates the request with the caller-presented provider credential, derives the account from request auth, and uses the same request-local token for upstream provider calls. No SDK-managed OAuth flow, refresh-token store, provider-token store, or callback route is required when the caller owns the provider credential lifecycle. If the provider credential can see multiple upstream accounts, use an explicit account roster pattern such as `createOAuthPassthroughResolver` instead of `'derived'`. Handlers with a resolved account should read the active token from `ctx.account.authInfo?.token`; refresh hooks update `account.authInfo`. Handlers without a resolved account can read the request token from `ctx.authInfo.token`. Use a stable non-secret identity such as `ctx.authInfo.credential.key_id`, `ctx.authInfo.credential.client_id`, or an adopter-supplied `principal` string for cache/idempotency scoping. Treat both token paths as request-local: do not copy provider tokens into persisted Account rows, `ctx_metadata`, `ctx.authInfo.extra`, request `ext` / body fields, or log lines. Add a separate provider-auth channel only for dual-auth proxy deployments where one request carries both caller-to-agent auth and a distinct upstream-provider credential. +**Stateless BYOK provider auth.** For single-account API-key or bearer-token BYOK, the provider credential can be the AdCP request credential for that endpoint: `Authorization: Bearer `. This keeps the baseline seller-agent wrapper pattern single-plane: the seller agent authenticates the request with the caller-presented provider credential, derives the account from request auth, and uses the same request-local token for upstream provider calls. No SDK-managed OAuth flow, refresh-token store, provider-token store, or callback route is required when the caller owns the provider credential lifecycle. If the provider credential can see multiple upstream accounts, stay in `'derived'` and supply `listAccounts` so buyers can discover and name one, or use `createOAuthPassthroughResolver` under `'explicit'` when you own the id namespace. Handlers with a resolved account should read the active token from `ctx.account.authInfo?.token`; refresh hooks update `account.authInfo`. Handlers without a resolved account can read the request token from `ctx.authInfo.token`. Use a stable non-secret identity such as `ctx.authInfo.credential.key_id`, `ctx.authInfo.credential.client_id`, or an adopter-supplied `principal` string for cache/idempotency scoping. Treat both token paths as request-local: do not copy provider tokens into persisted Account rows, `ctx_metadata`, `ctx.authInfo.extra`, request `ext` / body fields, or log lines. Add a separate provider-auth channel only for dual-auth proxy deployments where one request carries both caller-to-agent auth and a distinct upstream-provider credential. **Multi-tenant.** Two helpers, pick by deployment shape. **Host-routed**: `createTenantRegistry({...})` — one server per tenant, tenant-id keyed lookup with `registry.get(tenantId)`. **Account-routed**: `createTenantStore({...})` — one server, per-entry tenant-isolation gate built in (cross-tenant entries on `upsert` / `syncGovernance` rejected with `PERMISSION_DENIED` BEFORE adopter callbacks run; fail-closed when the auth principal can't be resolved). `createTenantStore` mitigates the canonical multi-tenant write-across-tenants bug class at the SDK layer rather than relying on adopter discipline. diff --git a/docs/migration-13-to-14.md b/docs/migration-13-to-14.md index 98c112b64..6186d4361 100644 --- a/docs/migration-13-to-14.md +++ b/docs/migration-13-to-14.md @@ -297,6 +297,166 @@ An unmet predicate produces one whole-storyboard `not_applicable` result and no steps are dispatched. Empty and single-entry lists fail during storyboard loading; keep using `requires_capability` for a singular predicate. +## `derived` account resolution is now an upstream-managed account-id namespace + +**Breaking, and the fix is mechanical.** SDK 13 documented +`AccountStore.resolution: 'derived'` as "single-tenant; there is no +`account_id` on the wire", and the framework refused inline `account_id` +references for it (`INVALID_REQUEST`, `field: 'account.account_id'`). Those +wire semantics were inverted relative to the adopters the mode exists for. +Upstream [adcp#5062](https://github.com/adcontextprotocol/adcp/pull/5062) +settled the account-reference model, and SDK 14 corrects the SDK to match +(adcp-client#1647, which also resolves adcp-client#1628): + +| | SDK 13 (`'derived'`) | SDK 14 (`'derived'`) | +|---|---|---| +| Durable wire reference | none — account came from the credential | `{ account_id }` | +| Inline `account_id` | refused with `INVALID_REQUEST` | **accepted**, and verified against the caller's reachable set | +| `{ brand, operator }` arm | accepted, passed to your resolver | **refused** with `INVALID_REQUEST` (`field: 'account.brand'`) + a `list_accounts` suggestion | +| `accounts.list` | optional | **required** — `createAdcpServerFromPlatform` throws `PlatformConfigError` without it | +| `sync_accounts` | whatever your `upsert` did | natural-key provisioning entries fail per-row with `UNSUPPORTED_PROVISIONING`; `account: { account_id }` settings-update entries pass through | +| `get_adcp_capabilities` | `require_operator_auth` unset | projects `account.require_operator_auth: true`, like a declared `'explicit'` (which also emits `supported_billing`, defaulting to `['agent']` — declare `capabilities.supportedBillings`) | +| `accounts.resolve` returning a different id than the buyer named | served | refused with `ACCOUNT_NOT_FOUND` (framework-side backstop) | + +Why it had to change: an upstream-managed adapter (Meta / Snap ad accounts, +AudioStack workspaces, a retail-media proxy) has no way to accept the id its +own upstream assigned — buyers who called `list_accounts` got ids that every +subsequent call rejected. The old contract was only usable by ignoring `ref` +entirely, which is a tenant-isolation bug the moment a deployment serves more +than one account. + +No alias spelling is introduced: `'derived'` stays the single name for the +mode. A second spelling would silently defeat adopter code written as +`resolution === 'derived'` without buying anything on the wire (`resolution` +is SDK-local config), and `'account-id-namespace'` wouldn't discriminate +anyway — under adcp#5062 both `'explicit'` and `'derived'` are account-id +namespaces. An unrecognized `resolution` value is now a +`PlatformConfigError` at construction rather than silently inheriting +another mode's enforcement. + +### Recipe 1 — one account per credential (audiostack / flashtalking shape) + +Before: + +```ts +const accounts = createDerivedAccountStore({ + toAccount: ctx => ({ id: 'audiostack', name: 'AudioStack', status: 'active', ctx_metadata: {} }), +}); +``` + +After — unchanged call shape. The factory now also publishes the one-row +`list_accounts` the mode requires, verifies any buyer-supplied `account_id` +against `toAccount(ctx).id` (mismatch → `ACCOUNT_NOT_FOUND`), and auto-selects +the account on ref-less tools. Make sure `id` is the id you want buyers to +read from `list_accounts` and send back — it is now on the wire in both +directions. + +### Recipe 2 — many accounts per credential (Meta / Snap shape) + +This shape had no working Shape D before. Supply `listAccounts` instead of +`toAccount`: + +```ts +const accounts = createDerivedAccountStore<{ upstreamId: string }>({ + // Scope by the caller's credential — this is the tenant-isolation boundary. + listAccounts: async ctx => { + const rows = await meta.adAccountsFor(ctx?.authInfo); + return rows.map(r => ({ id: r.account_id, name: r.name, status: 'active', ctx_metadata: { upstreamId: r.id } })); + }, + // Optional, for rosters too large to enumerate per request. MUST filter by caller. + lookupAccount: (id, ctx) => meta.adAccountForCaller(id, ctx?.authInfo), +}); +``` + +`toAccount` and `listAccounts` are mutually exclusive; supplying both (or +neither) throws `TypeError` at construction. + +### Recipe 3 — hand-rolled `'derived'` stores + +Two things to add: + +```ts +import { refAccountId } from '@adcp/sdk/server'; + +accounts: { + resolution: 'derived', + resolve: async (ref, ctx) => { + const reachable = await upstream.accountsFor(ctx?.authInfo); // credential-scoped + const id = refAccountId(ref); + if (id !== undefined) return reachable.find(a => a.id === id) ?? null; // verify, fail closed + return reachable.length === 1 ? reachable[0] : null; // no arbitrary default + }, + // Required — construction throws without it. `opts.accounts.listAccounts` + // at the merge seam satisfies it too. The framework does not filter or + // page for you: honor req.account / req.status / req.sandbox and + // req.pagination in your own query. + list: async (req, ctx) => ({ items: await upstream.accountsFor(ctx?.authInfo) }), +} +``` + +Belt and braces: if a `'derived'` resolver returns an account whose `id` +isn't the one the buyer named, the framework refuses it with +`ACCOUNT_NOT_FOUND` (and warns outside production) rather than serving the +request against the wrong account. Don't rely on that instead of verifying — +it can't tell whether *your* lookup was credential-scoped. + +If your ids are only ever issued out-of-band and there is nothing to +enumerate, you are a seller-defined account-id namespace: declare +`'explicit'`. Note two consequences: `'explicit'` applies **no** framework +reference-shape enforcement, so a resolver that ignores `ref` will happily +serve any `account_id` a buyer sends — keep the verification from the snippet +above — and a *declared* `'explicit'` also projects +`account.require_operator_auth: true`, like `'derived'`. + +### Recipe 3b — `sync_accounts` / `sync_governance` entries are verified + +Both tools carry their account reference inside the batch, so they never went +through `accounts.resolve`. On a `'derived'` platform the framework now +resolves each entry's `account_id` against the caller's reachable set before +any write runs: + +- `sync_accounts` — an unreachable id fails the whole operation with + `ACCOUNT_NOT_FOUND` before `accounts.upsert` is called (the response row + schema requires `brand` + `operator`, which we don't have for an account we + refused to resolve, and failing the batch means no partial writes). +- `sync_governance` — the individual row fails with `ACCOUNT_NOT_FOUND`; + reachable entries in the same batch still persist. + +Entries are also normalized to exactly one account reference first: an entry +carrying two (a root `account_id` plus a nested `account`, or an `account` +mixing `account_id` with `brand`/`operator`) is refused with +`INVALID_REQUEST` rather than disambiguated by precedence. The schema's +per-entry `oneOf` already forbids those shapes, but request validation is +relaxable and a gate that reads one reference while the write uses another +is a bypass. + +This applies to the merge-seam wiring (`opts.accounts.syncAccounts` / +`syncGovernance`) as well as the platform interface. If you were relying on +your own per-entry tenant gate, keep it — this is a framework floor, not a +replacement. + +Settings-update rows you return must carry `brand` + `operator` (required by +`sync-accounts-response.json`); echo them from your own account record. If +your upstream accounts have no brand/operator you can express, don't wire +`upsert` — tracked upstream at +[adcontextprotocol/adcp#7517](https://github.com/adcontextprotocol/adcp/issues/7517). + +### Recipe 4 — buyers + +Buyers that special-cased "derived agents reject `account_id`" can drop the +branch: against an SDK 14 derived agent, call `list_accounts`, then send +`account: { account_id }` like any other account-id-namespace seller. Buyers +that sent `{ brand, operator }` to a derived agent now get `INVALID_REQUEST` +with a `list_accounts` suggestion in `error.suggestion`. + +### Conformance + +The cross-storyboard account-discovery gate no longer accepts `sync_accounts` +as discovery for an agent declaring `account.require_operator_auth: true` — +those agents must advertise `list_accounts`. Agents whose capabilities can't +be read are classified as before (no new failures from an unparseable +`get_adcp_capabilities`). + ## Upgrade checklist 1. Pin SDK 14 with the `beta` tag or an exact `14.0.0-beta.*` version. Do not rely on npm `latest` for beta rollout. @@ -307,15 +467,16 @@ loading; keep using `requires_capability` for a singular predicate. 6. Re-run TypeScript against generated schema imports. Prefer per-tool type slices if the complete schema barrel exhausts the default Node heap. 7. Exercise mixed-version tests before rollout: 14→3.0, 14→3.1, 14→3.2 beta, and older buyer→14 server where applicable. 8. If a legacy brief may return products without a proposal, configure a durable `LegacyPurchaseContinuationStore`, stable `principalScope`, and application-owned `reconcileLegacyPurchase(record, exactInput)` callback before offering `continueLegacyPurchase()`. Keep reverse compact-seller → older-buyer handlers application-owned. -9. If established 3.0/3.1 proposal discovery and mutation can land on different processes, configure the same durable `EstablishedProposalStore`, stable `principalScope`, and stable non-secret `legacyPurchaseSellerSessionScope` on every coordinator. Add store-clock `completedAt` and `retainUntil` fields to refinement/decline completion tombstones, index `retainUntil`, and retain each proof for at least `ESTABLISHED_PROPOSAL_COMPLETION_TOMBSTONE_RETENTION_MS`. Conservatively backfill pre-upgrade tombstones to a future seven-day horizon, then run a database-clock sweeper that atomically prunes only expired rows. Recover submitted work with `reconcileEstablishedProposalTask({ account, sellerTaskId })`; see [Media-buy compatibility: durable established proposal state](./guides/MEDIA-BUY-3.2-COMPATIBILITY.md#durable-established-proposal-state). The bundled in-memory store is a non-durable reference implementation. -10. Upgrade durable idempotency storage before application traffic: add the nullable PostgreSQL `retain_until` column/index, preserve `IdempotencyCacheEntry.retainUntil`, and add atomic `putIfAbsent()`, `replaceIfPayloadHash()`, `replaceIfPayloadHashAndExpired()`, and `deleteIfPayloadHash()` to every custom backend. -11. Upgrade custom deferred-task storage with `putForSettlementOperationIfAbsent()`, `getBySettlementOperationId()`, and `replaceForSettlementOperationIfVersion()`. The initial token/index write and nested A→B index move must each be atomic. -12. Replace webhook emitter `operation_id` arguments with SDK-local `delivery_id` values and upgrade custom stores to `WebhookDeliveryStore`. One delivery ID binds one canonical payload and key; use a fresh delivery ID for each changed status observation while retaining the AdCP `operation_id` inside the payload. -13. Ensure custom 3.2 buyers include `push_notification_config.operation_id`, and update A2A integrations to keep the AdCP registration in skill parameters even when native A2A push configuration is also present. -14. Treat failed/rejected task results as canonical terminal artifacts when `include_result` is requested; do not discard them while preserving only the summary error. -15. Persist the complete `ScopedTaskRef` for out-of-process task settlement and acknowledge durable queue items only after `applied` or after reading back an `already_terminal` task and proving its exact result/error artifact. Matching terminal status alone is insufficient. Retry or dead-letter scoped misses and conflicting terminal outcomes. Upgrade populated PostgreSQL task registries with the phased [`getDecisioningTaskRegistryScopeV1Upgrade()` runbook](./migration-task-registry-scoping.md#populated-postgresql-upgrade), not application-boot bootstrap DDL. -16. For out-of-process settlement, return `ctx.handoffToTask(producer, { settlement: 'external' })`; the producer must durably queue the complete handle before returning, and the framework withholds `submitted` until that commit succeeds. External settlement is polling-only: omit `push_notification_config`. Its custom durable registry must expose a stable non-empty `registryId` and return that exact ID in every `create()` reference; a mismatch is a custom create-contract violation, rejected before the producer runs or `submitted` is acknowledged. Framework-settled push handoffs configure production `webhooks`; `taskWebhookEmitter` is test/development-only. `dispatchHitl` owns their terminal task mutation and delivery. `createPostgresTaskSettlementCoordinator()` with `completeScopedPushTask()`, `failScopedPushTask()`, or `rejectScopedPushTask()` is a lower-level application-managed push-settlement path, not a requirement for framework-settled handoffs and not a way to make external settlement push-capable. Before first use of rejection, run `getDecisioningTaskRegistryStatusWidenV61Migration()` against legacy task tables; its bounded `ACCESS EXCLUSIVE` lock can briefly block task reads and writes. See [task registry scope migration](./migration-task-registry-scoping.md#out-of-process-settlement). -17. Upgrade to Node `^20.19.0 || >=22.12.0`, whose two boundaries enable the `require(esm)` support needed by the SDK's CommonJS dependency graph. Node 21 and Node 22.0–22.11 are not supported. Keep Undici 6 for the fully supported configuration, or use the tested best-effort Undici 7 override on Node 20.19+. See the [Node/Undici compatibility policy](./guides/NODE-UNDICI-COMPATIBILITY.md). +9. If you declare `accounts.resolution: 'derived'`, wire `accounts.list` (or move to `createDerivedAccountStore`), make `accounts.resolve` verify buyer-supplied `account_id` values, and expect `account.require_operator_auth: true` in your capability payload. See [`derived` account resolution is now an upstream-managed account-id namespace](#derived-account-resolution-is-now-an-upstream-managed-account-id-namespace). +10. If established 3.0/3.1 proposal discovery and mutation can land on different processes, configure the same durable `EstablishedProposalStore`, stable `principalScope`, and stable non-secret `legacyPurchaseSellerSessionScope` on every coordinator. Add store-clock `completedAt` and `retainUntil` fields to refinement/decline completion tombstones, index `retainUntil`, and retain each proof for at least `ESTABLISHED_PROPOSAL_COMPLETION_TOMBSTONE_RETENTION_MS`. Conservatively backfill pre-upgrade tombstones to a future seven-day horizon, then run a database-clock sweeper that atomically prunes only expired rows. Recover submitted work with `reconcileEstablishedProposalTask({ account, sellerTaskId })`; see [Media-buy compatibility: durable established proposal state](./guides/MEDIA-BUY-3.2-COMPATIBILITY.md#durable-established-proposal-state). The bundled in-memory store is a non-durable reference implementation. +11. Upgrade durable idempotency storage before application traffic: add the nullable PostgreSQL `retain_until` column/index, preserve `IdempotencyCacheEntry.retainUntil`, and add atomic `putIfAbsent()`, `replaceIfPayloadHash()`, `replaceIfPayloadHashAndExpired()`, and `deleteIfPayloadHash()` to every custom backend. +12. Upgrade custom deferred-task storage with `putForSettlementOperationIfAbsent()`, `getBySettlementOperationId()`, and `replaceForSettlementOperationIfVersion()`. The initial token/index write and nested A→B index move must each be atomic. +13. Replace webhook emitter `operation_id` arguments with SDK-local `delivery_id` values and upgrade custom stores to `WebhookDeliveryStore`. One delivery ID binds one canonical payload and key; use a fresh delivery ID for each changed status observation while retaining the AdCP `operation_id` inside the payload. +14. Ensure custom 3.2 buyers include `push_notification_config.operation_id`, and update A2A integrations to keep the AdCP registration in skill parameters even when native A2A push configuration is also present. +15. Treat failed/rejected task results as canonical terminal artifacts when `include_result` is requested; do not discard them while preserving only the summary error. +16. Persist the complete `ScopedTaskRef` for out-of-process task settlement and acknowledge durable queue items only after `applied` or after reading back an `already_terminal` task and proving its exact result/error artifact. Matching terminal status alone is insufficient. Retry or dead-letter scoped misses and conflicting terminal outcomes. Upgrade populated PostgreSQL task registries with the phased [`getDecisioningTaskRegistryScopeV1Upgrade()` runbook](./migration-task-registry-scoping.md#populated-postgresql-upgrade), not application-boot bootstrap DDL. +17. For out-of-process settlement, return `ctx.handoffToTask(producer, { settlement: 'external' })`; the producer must durably queue the complete handle before returning, and the framework withholds `submitted` until that commit succeeds. External settlement is polling-only: omit `push_notification_config`. Its custom durable registry must expose a stable non-empty `registryId` and return that exact ID in every `create()` reference; a mismatch is a custom create-contract violation, rejected before the producer runs or `submitted` is acknowledged. Framework-settled push handoffs configure production `webhooks`; `taskWebhookEmitter` is test/development-only. `dispatchHitl` owns their terminal task mutation and delivery. `createPostgresTaskSettlementCoordinator()` with `completeScopedPushTask()`, `failScopedPushTask()`, or `rejectScopedPushTask()` is a lower-level application-managed push-settlement path, not a requirement for framework-settled handoffs and not a way to make external settlement push-capable. Before first use of rejection, run `getDecisioningTaskRegistryStatusWidenV61Migration()` against legacy task tables; its bounded `ACCESS EXCLUSIVE` lock can briefly block task reads and writes. See [task registry scope migration](./migration-task-registry-scoping.md#out-of-process-settlement). +18. Upgrade to Node `^20.19.0 || >=22.12.0`, whose two boundaries enable the `require(esm)` support needed by the SDK's CommonJS dependency graph. Node 21 and Node 22.0–22.11 are not supported. Keep Undici 6 for the fully supported configuration, or use the tested best-effort Undici 7 override on Node 20.19+. See the [Node/Undici compatibility policy](./guides/NODE-UNDICI-COMPATIBILITY.md). ### Webhook delivery identity and retry horizons diff --git a/docs/migration-5.x-to-6.x.md b/docs/migration-5.x-to-6.x.md index f451aa561..a6b940ab7 100644 --- a/docs/migration-5.x-to-6.x.md +++ b/docs/migration-5.x-to-6.x.md @@ -322,7 +322,11 @@ before starting scoped writers. (acquire_rights), `req.creative` (provide_performance_feedback). - **`accounts.resolve()` is mandatory.** Even single-tenant agents must declare `resolution: 'derived'` and return a synthetic singleton. The - framework calls `resolve()` on every request. + framework calls `resolve()` on every request. (**SDK 14 note:** + `'derived'` is now an upstream-managed account-id namespace — it also + requires `list_accounts` and a resolver that verifies the buyer-supplied + `account_id`. See + [13 → 14 § derived account resolution](./migration-13-to-14.md#derived-account-resolution-is-now-an-upstream-managed-account-id-namespace).) - **`mergeSeam: 'strict'` from day 1.** The default is `'warn'` for back-compat, but `'strict'` is what you want during migration — it surfaces collisions as `PlatformConfigError` at construction time diff --git a/docs/migration-6.6-to-6.7.md b/docs/migration-6.6-to-6.7.md index 83d8bb37a..9840b9085 100644 --- a/docs/migration-6.6-to-6.7.md +++ b/docs/migration-6.6-to-6.7.md @@ -13,7 +13,11 @@ > applies to derived-mode (single-tenant) platforms — buyers passing > inline `{account_id}` against a derived-mode agent reject with > `INVALID_REQUEST` instead of being silently dropped. See recipe -> **#10b**. +> **#10b**. **Superseded in SDK 14** — the derived-mode polarity was +> inverted and `account_id` is now the durable reference for that mode. +> Read +> [13 → 14 § derived account resolution](./migration-13-to-14.md#derived-account-resolution-is-now-an-upstream-managed-account-id-namespace) +> before applying #10b on a modern SDK. > - **Adopters with `: SalesPlatform` field annotations claiming > `sales-guaranteed` / `sales-non-guaranteed` / `sales-broadcast-tv` / > `sales-catalog-driven`**: `SalesPlatform` is now structurally @@ -42,7 +46,9 @@ Everything else is additive and can be applied incrementally. single-tenant agents. Buyers passing inline `account_id` against a declared `'derived'` platform now reject with `INVALID_REQUEST` instead of being silently dropped. The `{brand, operator}` arm is - still permitted. + still permitted. **Historical — reversed in SDK 14** (adcp-client#1647): + derived mode now accepts `account_id` and refuses the `{brand, + operator}` arm. This recipe describes 6.7-era behavior only. - **#11 — `SalesPlatform` split into `SalesCorePlatform & SalesIngestionPlatform`** (TS-only, self-announcing under `tsc --noEmit`). Adopters with `: SalesPlatform` field @@ -566,6 +572,8 @@ platforms now reject inline `{account_id}` references with `{brand, operator}` arm is still permitted (it's used during the initial `sync_accounts` flow). +> **Historical — reversed in SDK 14.** `'derived'` now accepts `{ account_id }` and refuses the `{ brand, operator }` arm (adcp-client#1647). Read [13 → 14 § derived account resolution](./migration-13-to-14.md#derived-account-resolution-is-now-an-upstream-managed-account-id-namespace) before applying anything in this section on a modern SDK. + **Action required.** Audit `accounts.resolution`: | Your pre-6.7 setup | What to do | @@ -730,7 +738,7 @@ The four-shape map adopters now reach for: | **A** | `'implicit'` | `InMemoryImplicitAccountStore` | Buyer drives onboarding via `sync_accounts`; framework owns the linkage map. | | **B** | `'explicit'` | `createOAuthPassthroughResolver` | Adapter fronts a vendor OAuth + `/me/adaccounts` listing endpoint (Snap, Meta). | | **C** | `'explicit'` | `createRosterAccountStore` | Publisher owns the roster (storefront table, admin UI). Adopter brings `lookup`. | -| **D** | `'derived'` | `createDerivedAccountStore` | Single-tenant agent — auth principal IS the tenant; no `account_id` on the wire (audiostack, flashtalking, single-namespace retail-media). | +| **D** | `'derived'` | `createDerivedAccountStore` | **(SDK 14: upstream-managed account-id namespace — see 13 → 14.)** Single-tenant agent — auth principal IS the tenant; no `account_id` on the wire (audiostack, flashtalking, single-namespace retail-media). | ### 11. **breaking** (TS-only) — `SalesPlatform` split into core + ingestion diff --git a/docs/migration-6.7-to-6.9.md b/docs/migration-6.7-to-6.9.md index 1141c5dfb..b74211ee5 100644 --- a/docs/migration-6.7-to-6.9.md +++ b/docs/migration-6.7-to-6.9.md @@ -53,7 +53,9 @@ additive and can be applied incrementally. > `migration-6.6-to-6.7.md` — `accounts.resolution: 'derived'` now > refuses inline `account_id` references with `INVALID_REQUEST` (the > runtime change shipped in 6.7 via #1475 and the migration doc was -> backfilled in 6.9 via #1492). +> backfilled in 6.9 via #1492). **Reversed in SDK 14** (adcp-client#1647): +> `'derived'` now accepts `{ account_id }` and refuses the natural key — +> see [13 → 14 § derived account resolution](./migration-13-to-14.md#derived-account-resolution-is-now-an-upstream-managed-account-id-namespace). ## What you get for free (additive headlines) @@ -67,7 +69,7 @@ after bumping. Recipes for the load-bearing ones are below. | Buyer-agent registry | Authenticator-stamped `extra` flows into `BuyerAgentRegistry.resolveByCredential`'s second arg | | Method composition | Variadic `composeMethod(inner, ...hooks)` overload | | Comply controller | `ComplyControllerConfig.force` extended with `create_media_buy_arm` + `task_completion` slots; new `queryUpstreamTraffic` adapter alongside `seed`/`force`/`simulate`/`sandboxGate` (closes the structured-config / dispatcher gap) | -| Account stores | `createDerivedAccountStore` (Shape D — single-tenant, auth-derived); `RosterAccountStoreOptions.resolveWithoutRef` for ref-less tools | +| Account stores | `createDerivedAccountStore` (Shape D — single-tenant, auth-derived; SDK 14 reworks it into an upstream-managed account-id namespace); `RosterAccountStoreOptions.resolveWithoutRef` for ref-less tools | | Conformance | `ConformanceClient` outbound-WebSocket Socket Mode primitive (dev/staging only) | | New worked references | 5 new hello adapters: `hello_seller_adapter_non_guaranteed`, `hello_creative_adapter_ad_server`, `hello_si_adapter_brand`, plus mock-servers for `sales-non-guaranteed`, `creative-ad-server`, `sponsored-intelligence`. Multi-tenant adapter passes strict-tsc gate. | | SI v6 | `SponsoredIntelligencePlatform` shape (protocol-keyed dispatch, auto-hydrated session) | @@ -359,7 +361,11 @@ agents (rare; public format catalogs). Framework-side refusal of buyer-supplied `account_id` is the same behaviour `'implicit'` adopters get — see `migration-6.6-to-6.7.md` -recipe **#10b**. +recipe **#10b**. **SDK 14 reverses this for `'derived'`**: the mode is an +upstream-managed account-id namespace, `account_id` is accepted (and +verified), the natural key is refused, and the factory gained +`listAccounts` / `lookupAccount` plus a required `list_accounts`. See +[13 → 14 § derived account resolution](./migration-13-to-14.md#derived-account-resolution-is-now-an-upstream-managed-account-id-namespace). ### 9. `RosterAccountStoreOptions.resolveWithoutRef` for ref-less tools @@ -547,8 +553,9 @@ landing: 6.6→6.7 audit; the throw still fires inside `createAdcpServer` construction. See `migration-6.6-to-6.7.md` recipe **#16**. - [ ] `grep -rn "resolution: 'derived'" src/` — leftover from 6.6→6.7 - audit; the framework now refuses inline `account_id`. See - `migration-6.6-to-6.7.md` recipe **#10b**. + audit; in 6.7–13.x the framework refuses inline `account_id`. See + `migration-6.6-to-6.7.md` recipe **#10b** — and note SDK 14 reverses + it (`docs/migration-13-to-14.md`). - [ ] `npm test` — pre-existing pass rate maintained. - [ ] `npm run compliance:fork-matrix` — 23/23 against the seven hello adapters + multi-tenant. diff --git a/examples/decisioning-platform-derived-accounts.ts b/examples/decisioning-platform-derived-accounts.ts new file mode 100644 index 000000000..b160a64d3 --- /dev/null +++ b/examples/decisioning-platform-derived-accounts.ts @@ -0,0 +1,184 @@ +/** + * decisioning-platform-derived-accounts — reference implementation of + * `AccountStore.resolution: 'derived'`: an agent fronting an **upstream-managed + * account namespace**. + * + * Use this when the roster is not yours to provision — you proxy a platform + * that owns it (Meta / Snap business accounts, an audio-generation vendor's + * workspaces, a retail-media network in proxy mode), or the buyer's credential + * is bound to exactly one account on your side. + * + * Wire contract (corrected in SDK 14 — adcp-client#1647, upstream adcp#5062): + * 1. Buyer calls `list_accounts` → the accounts that credential can reach. + * 2. Buyer passes `account: { account_id }` on every account-scoped call. + * The `{ brand, operator }` natural-key arm is refused by the framework. + * 3. `accounts.resolve` verifies the buyer-supplied id against the same + * credential-scoped set, and returns `null` on a miss → `ACCOUNT_NOT_FOUND`. + * 4. Tools with no `account` on the wire (`list_creative_formats`, + * `provide_performance_feedback`) auto-select the account when the + * credential reaches exactly one. + * + * `createDerivedAccountStore` implements 2–4; the only thing you write is the + * credential-scoped roster lookup. + * + * @see docs/guides/account-resolution.md + * @see docs/migration-13-to-14.md + */ + +import { + createAdcpServer, + createDerivedAccountStore, + createIdempotencyStore, + serve, + verifyApiKey, + type Account, + type ResolveContext, +} from '@adcp/sdk/server'; + +const PORT = Number(process.env['PORT'] ?? 3011); +const ADCP_AUTH_TOKEN = process.env['ADCP_AUTH_TOKEN'] ?? 'sk_harness_do_not_use_in_prod'; + +// --------------------------------------------------------------------------- +// Platform account metadata — replace with your upstream model +// --------------------------------------------------------------------------- + +interface UpstreamMeta { + /** The id this account has on the upstream platform. */ + upstreamId: string; +} + +// --------------------------------------------------------------------------- +// The upstream roster. +// +// REPLACE this with a call to the platform you front — e.g. +// `GET /me/adaccounts` with the caller's OAuth token. The shape that matters +// is the scoping: this function answers "what can THIS credential reach?", +// and everything else (id verification, singleton auto-select, list_accounts +// filtering + paging) is derived from its answer. An unscoped roster here is +// a cross-tenant read on every surface at once. +// --------------------------------------------------------------------------- + +const UPSTREAM_ROSTER: Record>> = { + sk_harness_do_not_use_in_prod: [ + { + id: 'act_1001', + name: 'Acme — paid social', + status: 'active', + brand: { domain: 'acme-corp.example.com' }, + operator: 'pinnacle-media.example.com', + ctx_metadata: { upstreamId: 'urn:upstream:1001' }, + }, + { + id: 'act_1002', + name: 'Acme — retail media', + status: 'active', + brand: { domain: 'acme-corp.example.com' }, + operator: 'acme-corp.example.com', + ctx_metadata: { upstreamId: 'urn:upstream:1002' }, + }, + ], +}; + +function credentialKey(ctx: ResolveContext | undefined): string | undefined { + const cred = ctx?.authInfo?.credential; + if (cred?.kind === 'api_key') return cred.key_id; + if (cred?.kind === 'oauth') return cred.client_id; + return undefined; +} + +const accounts = createDerivedAccountStore({ + listAccounts: async ctx => { + const key = credentialKey(ctx); + if (key === undefined) return []; + // ── REPLACE: await myUpstream.accountsFor(ctx?.authInfo) ── + return UPSTREAM_ROSTER[key] ?? []; + }, + // Optional: point lookup for rosters too large to enumerate per request. + // It MUST filter by the caller too — the factory only re-checks that the + // returned id is the one that was asked for. + // + // lookupAccount: async (id, ctx) => myUpstream.accountForCaller(id, ctx?.authInfo), +}); + +// --------------------------------------------------------------------------- +// AdCP server +// --------------------------------------------------------------------------- + +const server = createAdcpServer({ + name: 'Upstream-Managed Accounts Demo Seller', + version: '1.0.0', + + accounts, + + // Minimal media-buy surface — replace with your real platform methods. + mediaBuy: { + getProducts: async (_req, _ctx) => ({ + products: [ + { + id: 'prod_social_feed', + name: 'Social feed inventory', + product_type: 'display' as const, + pricing: { model: 'cpm' as const, rate: 8.5, currency: 'USD' }, + }, + ], + }), + + createMediaBuy: async (req, ctx) => ({ + // ctx.account is the verified upstream account — the buyer's + // `account_id` was checked against the roster above before this ran. + media_buy: { + id: `buy_${Date.now()}`, + name: req.name, + status: 'pending_review' as const, + account: { account_id: ctx.account.id }, + product_id: req.product_id, + budget: req.budget, + targeting: req.targeting, + }, + }), + + updateMediaBuy: async (req, ctx) => ({ + media_buy: { + id: req.id, + name: req.name ?? 'Updated Buy', + status: 'active' as const, + account: { account_id: ctx.account.id }, + product_id: 'prod_social_feed', + budget: req.budget ?? { total: 0, currency: 'USD' }, + targeting: req.targeting ?? {}, + }, + }), + + getMediaBuyDelivery: async (_req, _ctx) => { + const today = new Date().toISOString().split('T')[0] ?? new Date().toISOString(); + return { + reporting_period: { start: today, end: today }, + currency: 'USD', + media_buy_deliveries: [], + }; + }, + + getMediaBuys: async (_req, _ctx) => ({ media_buys: [] }), + }, +}); + +// --------------------------------------------------------------------------- +// HTTP server +// --------------------------------------------------------------------------- + +serve({ + servers: [server], + port: PORT, + authenticate: verifyApiKey(ADCP_AUTH_TOKEN), + idempotency: createIdempotencyStore(), +}) + .then(({ stop }) => { + console.log(`Upstream-managed-accounts seller running on :${PORT}`); + console.log(` resolution: 'derived' — buyers call list_accounts, then pass account: { account_id }`); + console.log(` auth: Bearer ${ADCP_AUTH_TOKEN}`); + process.on('SIGTERM', () => stop()); + }) + .catch(err => { + console.error('Failed to start:', err); + process.exit(1); + }); diff --git a/examples/decisioning-platform-mock-seller.ts b/examples/decisioning-platform-mock-seller.ts index 8ff9022f7..60a12424f 100644 --- a/examples/decisioning-platform-mock-seller.ts +++ b/examples/decisioning-platform-mock-seller.ts @@ -93,9 +93,11 @@ const DEFAULT_CONFIG: MockSellerConfig = { function makeAccounts(): AccountStore { return { - // Multi-tenant: MockSeller accepts buyer-supplied account_id refs. - // Single-tenant adopters declare resolution: 'derived' instead and - // ignore `ref`. See SKILL § "Account resolution". + // Seller-owned account-id namespace: MockSeller issues the ids and + // accepts buyer-supplied refs. Adopters fronting an upstream platform + // that owns the roster (or bound to one account per credential) + // declare resolution: 'derived' and use createDerivedAccountStore. + // See SKILL § "Account resolution". resolution: 'explicit', resolve: async (ref: AccountReference) => { const id = 'account_id' in ref ? ref.account_id : 'mock_acc_1'; diff --git a/scripts/generate-agent-docs.ts b/scripts/generate-agent-docs.ts index 58f716c0d..1374919ba 100644 --- a/scripts/generate-agent-docs.ts +++ b/scripts/generate-agent-docs.ts @@ -686,11 +686,11 @@ function generateLlmsTxt( ); ln(); ln( - `**Four reference \`AccountStore\` shapes.** Pick the one whose onboarding model matches yours. **Shape A — \`InMemoryImplicitAccountStore\`**: \`resolution: 'implicit'\`, buyer-driven \`sync_accounts\` populates the auth-principal → accounts map. **Shape B — \`createOAuthPassthroughResolver\`**: \`resolution: 'explicit'\`, returns just the \`resolve\` function for adapters fronting an upstream OAuth listing endpoint (Snap, Meta, TikTok, LinkedIn — \`extract bearer → GET /me/adaccounts → match by id\`). **Shape C — \`createRosterAccountStore\`**: \`resolution: 'explicit'\`, returns a complete \`AccountStore\` for adopters who own the roster (storefront table, admin-UI-managed JSON). Supports \`resolveWithoutRef\` for tools that send no \`account\` field on the wire (\`list_creative_formats\`, \`preview_creative\`, \`provide_performance_feedback\`) — set it to return a synthetic publisher-wide entry instead of \`null\`. **Shape D — \`createDerivedAccountStore\`**: \`resolution: 'derived'\`, single-tenant agents where there is no \`account_id\` on the wire and the auth principal alone identifies the tenant (audiostack, flashtalking, single-namespace retail-media). Provide \`toAccount(ctx)\`; the factory still emits legacy-compatible \`AUTH_REQUIRED\` on missing-credential calls and ignores buyer-supplied \`account_id\` (single-tenant by definition). Buyer code must continue to handle \`AUTH_REQUIRED\` alongside \`AUTH_MISSING\` / \`AUTH_INVALID\`. All four live at \`@adcp/sdk/server\`.` + `**Four reference \`AccountStore\` shapes.** Pick the one whose onboarding model matches yours. **Shape A — \`InMemoryImplicitAccountStore\`**: \`resolution: 'implicit'\`, buyer-driven \`sync_accounts\` populates the auth-principal → accounts map. **Shape B — \`createOAuthPassthroughResolver\`**: \`resolution: 'explicit'\`, returns just the \`resolve\` function for adapters fronting an upstream OAuth listing endpoint (Snap, Meta, TikTok, LinkedIn — \`extract bearer → GET /me/adaccounts → match by id\`). **Shape C — \`createRosterAccountStore\`**: \`resolution: 'explicit'\`, returns a complete \`AccountStore\` for adopters who own the roster (storefront table, admin-UI-managed JSON). Supports \`resolveWithoutRef\` for tools that send no \`account\` field on the wire (\`list_creative_formats\`, \`preview_creative\`, \`provide_performance_feedback\`) — set it to return a synthetic publisher-wide entry instead of \`null\`. **Shape D — \`createDerivedAccountStore\`**: \`resolution: 'derived'\`, an upstream-managed account-id namespace — the platform you front owns the roster (Meta / Snap ad accounts, audiostack, flashtalking, single-namespace retail-media). Buyers discover ids through \`list_accounts\` and send \`account: { account_id }\`; the framework refuses the \`{ brand, operator }\` arm for this mode and \`accounts.list\` is required (\`createAdcpServerFromPlatform\` throws \`PlatformConfigError\` without it). Provide \`toAccount(ctx)\` when a credential reaches exactly one account or \`listAccounts(ctx)\` when it reaches many (plus optional \`lookupAccount(id, ctx)\` for large rosters); the factory verifies buyer-supplied \`account_id\` against what the caller's credential can reach, returns \`null\` on a miss, auto-selects the account on ref-less tools only when exactly one is reachable, wires a filtered and paged \`list_accounts\`, and still emits legacy-compatible \`AUTH_REQUIRED\` on missing-credential calls. The framework backstops hand-rolled \`'derived'\` stores: a resolved account whose \`id\` isn't the one the buyer named is refused with \`ACCOUNT_NOT_FOUND\`, and \`sync_accounts\` / \`sync_governance\` entries are resolved against the caller's reachable set before any write. Buyer code must continue to handle \`AUTH_REQUIRED\` alongside \`AUTH_MISSING\` / \`AUTH_INVALID\`. Changed in SDK 14 (adcp-client#1647 / adcp#5062) — Shape D previously refused inline \`account_id\` and was documented as single-tenant-only. All four live at \`@adcp/sdk/server\`.` ); ln(); ln( - `**Stateless BYOK provider auth.** For single-account API-key or bearer-token BYOK, the provider credential can be the AdCP request credential for that endpoint: \`Authorization: Bearer \`. This keeps the baseline seller-agent wrapper pattern single-plane: the seller agent authenticates the request with the caller-presented provider credential, derives the account from request auth, and uses the same request-local token for upstream provider calls. No SDK-managed OAuth flow, refresh-token store, provider-token store, or callback route is required when the caller owns the provider credential lifecycle. If the provider credential can see multiple upstream accounts, use an explicit account roster pattern such as \`createOAuthPassthroughResolver\` instead of \`'derived'\`. Handlers with a resolved account should read the active token from \`ctx.account.authInfo?.token\`; refresh hooks update \`account.authInfo\`. Handlers without a resolved account can read the request token from \`ctx.authInfo.token\`. Use a stable non-secret identity such as \`ctx.authInfo.credential.key_id\`, \`ctx.authInfo.credential.client_id\`, or an adopter-supplied \`principal\` string for cache/idempotency scoping. Treat both token paths as request-local: do not copy provider tokens into persisted Account rows, \`ctx_metadata\`, \`ctx.authInfo.extra\`, request \`ext\` / body fields, or log lines. Add a separate provider-auth channel only for dual-auth proxy deployments where one request carries both caller-to-agent auth and a distinct upstream-provider credential.` + `**Stateless BYOK provider auth.** For single-account API-key or bearer-token BYOK, the provider credential can be the AdCP request credential for that endpoint: \`Authorization: Bearer \`. This keeps the baseline seller-agent wrapper pattern single-plane: the seller agent authenticates the request with the caller-presented provider credential, derives the account from request auth, and uses the same request-local token for upstream provider calls. No SDK-managed OAuth flow, refresh-token store, provider-token store, or callback route is required when the caller owns the provider credential lifecycle. If the provider credential can see multiple upstream accounts, stay in \`'derived'\` and supply \`listAccounts\` so buyers can discover and name one, or use \`createOAuthPassthroughResolver\` under \`'explicit'\` when you own the id namespace. Handlers with a resolved account should read the active token from \`ctx.account.authInfo?.token\`; refresh hooks update \`account.authInfo\`. Handlers without a resolved account can read the request token from \`ctx.authInfo.token\`. Use a stable non-secret identity such as \`ctx.authInfo.credential.key_id\`, \`ctx.authInfo.credential.client_id\`, or an adopter-supplied \`principal\` string for cache/idempotency scoping. Treat both token paths as request-local: do not copy provider tokens into persisted Account rows, \`ctx_metadata\`, \`ctx.authInfo.extra\`, request \`ext\` / body fields, or log lines. Add a separate provider-auth channel only for dual-auth proxy deployments where one request carries both caller-to-agent auth and a distinct upstream-provider credential.` ); ln(); ln( diff --git a/scripts/skill-examples.baseline.json b/scripts/skill-examples.baseline.json index a09ec4c9d..795909c93 100644 --- a/scripts/skill-examples.baseline.json +++ b/scripts/skill-examples.baseline.json @@ -44,16 +44,6 @@ "errorCode": "TS2552", "messagePrefix": "Cannot find name 'server'. Did you mean 'serve'?" }, - { - "source": "skills/build-decisioning-platform/advanced/REFERENCE.md", - "errorCode": "TS2322", - "messagePrefix": "Type 'Promise<{ id: string; name: string; status: \"active\"; metadata: {}; authIn" - }, - { - "source": "skills/build-decisioning-platform/advanced/REFERENCE.md", - "errorCode": "TS2345", - "messagePrefix": "Argument of type '{ capabilities: { specialisms: readonly [\"sales-non-guaranteed" - }, { "source": "skills/build-decisioning-platform/SKILL.md", "errorCode": "TS1309", diff --git a/skills/build-decisioning-platform/SKILL.md b/skills/build-decisioning-platform/SKILL.md index 953db75a2..98857a641 100644 --- a/skills/build-decisioning-platform/SKILL.md +++ b/skills/build-decisioning-platform/SKILL.md @@ -70,6 +70,7 @@ The full export list is in `@adcp/sdk/server`. Surfaces marked `@deprecated` wil import { createAdcpServerFromPlatform, createCtxMetadataStore, + createDerivedAccountStore, memoryCtxMetadataStore, DEFAULT_REPORTING_CAPABILITIES, PackageNotFoundError, @@ -92,12 +93,12 @@ class MyPlatform implements DecisioningPlatform { idempotency: { replay_ttl_seconds: 86400 }, }; - accounts = { - resolution: 'derived' as const, // single tenant; framework returns the same Account every call - resolve: async () => ({ id: 'pub_main', operator: 'mypub', ctx_metadata: {} }), - upsert: async () => ({ ok: true, items: [] }), - list: async () => ({ items: [], nextCursor: null }), - }; + // 'derived' = account-id namespace discovered through list_accounts. The + // factory publishes the row, verifies buyer-supplied account_ids against + // what the credential can reach, and auto-selects it on ref-less tools. + accounts = createDerivedAccountStore({ + toAccount: () => ({ id: 'pub_main', name: 'My Publisher', status: 'active' as const, ctx_metadata: {} }), + }); sales: SalesPlatform = { // 1. Catalog lookup. Brief in, products out. diff --git a/skills/build-decisioning-platform/advanced/REFERENCE.md b/skills/build-decisioning-platform/advanced/REFERENCE.md index 69e5d8441..4f5ddbacc 100644 --- a/skills/build-decisioning-platform/advanced/REFERENCE.md +++ b/skills/build-decisioning-platform/advanced/REFERENCE.md @@ -27,7 +27,12 @@ The framework owns wire mapping, account resolution, idempotency, signing, async Minimal copy-paste-runnable example. Single tenant, one product, sync `create_media_buy`. Substitute your real lookups inside the bodies. ```ts -import { AdcpError, createAdcpServerFromPlatform, type SalesPlatform, type AccountStore } from '@adcp/sdk/server'; +import { + AdcpError, + createAdcpServerFromPlatform, + createDerivedAccountStore, + type SalesPlatform, +} from '@adcp/sdk/server'; // Don't annotate `platform: DecisioningPlatform` — let TS infer the // `specialisms: ['sales-non-guaranteed']` literal so RequiredPlatformsFor @@ -41,18 +46,18 @@ const platform = { config: {}, }, - // Single-tenant: one synthetic account; framework still routes everything - // through resolve(). See § "accounts.resolve() is mandatory". - accounts: { - resolution: 'derived', - resolve: async () => ({ + // One account per credential; framework still routes everything through + // resolve(). The factory verifies buyer-supplied account_ids and publishes + // the one-row list_accounts that 'derived' requires. See + // § "accounts.resolve() is mandatory". + accounts: createDerivedAccountStore({ + toAccount: () => ({ id: 'tenant_singleton', name: 'My Ad Network', status: 'active', - metadata: {}, - authInfo: { kind: 'api_key' }, + ctx_metadata: {}, }), - } satisfies AccountStore, + }), sales: { getProducts: async (req, ctx) => ({ @@ -416,9 +421,11 @@ Coercion rules (per-entry): | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | | `'explicit'` (default) | Multi-tenant; buyer passes `account_id` on every request (Snap, Meta, GAM via Network/Company id). | `ref = { account_id }` (or `{ brand, operator }`) on every call. | | `'implicit'` | Buyer pre-syncs accounts via `sync_accounts`; subsequent calls resolved by `ctx.authInfo` lookup against pre-synced linkage (LinkedIn, some retail-media operators). | `ref` may be undefined; use `ctx.authInfo.clientId` to look up. | -| `'derived'` | Single-tenant; one logical advertiser per agent process. Auth principal alone identifies the tenant. | `ref` typically undefined; return the singleton regardless. | +| `'derived'` | Account-id namespace discovered through `list_accounts` — an upstream platform owns the roster (Meta / Snap / AudioStack), or your credential is bound to a single account. `accounts.list` is required. | `ref = { account_id }` after discovery, or undefined on ref-less tools. Verify the id against what the caller's credential can reach; the framework refuses `{ brand, operator }` for this mode. | -**If you have one tenant, declare `resolution: 'derived'`.** The default is `'explicit'`. A single-tenant agent that omits `resolution` falls into `'explicit'` mode where tools whose buyer omits the `account` field (`provide_performance_feedback`, `list_creative_formats`, `report_usage`, `tasks_get` without explicit account) silently fail with `ACCOUNT_NOT_FOUND` because the framework expects the buyer to pass an account on those tools too. +**If one credential reaches one account, declare `resolution: 'derived'`.** The default is `'explicit'`. An agent that omits `resolution` falls into `'explicit'` mode where tools whose buyer omits the `account` field (`provide_performance_feedback`, `list_creative_formats`, `report_usage`, `tasks_get` without explicit account) silently fail with `ACCOUNT_NOT_FOUND` because the framework expects the buyer to pass an account on those tools too. In `'derived'` mode the framework auto-selects the one account the credential reaches. + +**`'derived'` changed in SDK 14 (adcp-client#1647 / upstream adcp#5062).** It used to mean "single-tenant; `account_id` is meaningless on the wire" and the framework refused inline `account_id`. Corrected: `'derived'` is an upstream-managed account-id namespace — buyers discover ids with `list_accounts` and send `{ account_id }`; the `{ brand, operator }` arm is refused; `accounts.list` is mandatory (`createAdcpServerFromPlatform` throws `PlatformConfigError` without it); and `accounts.resolve` must verify a buyer-supplied id against the caller's reachable set. Use `createDerivedAccountStore`, which does the verification and wires `list` for you. ```ts // Multi-tenant @@ -452,20 +459,36 @@ Throwing `AccountNotFoundError` only from `resolve()` — never from specialism ### `accounts.resolve()` is mandatory — even for "no tenant" agents -The framework calls `accounts.resolve()` on every request before dispatching to a specialism method. Single-tenant agents that historically skipped account resolution (no per-buyer scoping; the agent serves one logical advertiser) MUST still implement `resolve()` — declare `resolution: 'derived'` and return a single synthetic `Account` regardless of the input ref: +The framework calls `accounts.resolve()` on every request before dispatching to a specialism method. Agents that historically skipped account resolution (no per-buyer scoping; one logical advertiser per credential) MUST still implement `resolve()`. Declare `resolution: 'derived'` and let the Shape D factory build the store — it verifies buyer-supplied ids, auto-selects the singleton on ref-less tools, and publishes the one-row `list_accounts` the mode requires: ```ts -accounts: AccountStore = { - resolution: 'derived', // single-tenant; auth principal alone identifies the tenant - resolve: async () => ({ - id: 'singleton', +import { createDerivedAccountStore } from '@adcp/sdk/server'; + +accounts: AccountStore = createDerivedAccountStore({ + toAccount: () => ({ + id: 'singleton', // the id buyers read from list_accounts and send back name: 'My Agent', status: 'active', - metadata: { + ctx_metadata: { /* whatever your handlers want to read off ctx.account.ctx_metadata */ }, - authInfo: { kind: 'api_key' }, }), +}); +``` + +Hand-rolling it is fine too, but then you own both obligations — `list` and the id check: + +```ts +accounts: AccountStore = { + resolution: 'derived', + resolve: async (ref, _ctx) => { + const account = { id: 'singleton', name: 'My Agent', status: 'active' as const, ctx_metadata: {} }; + const id = refAccountId(ref); + // Fail closed: a buyer-supplied id that isn't ours resolves to + // ACCOUNT_NOT_FOUND, never to "the account we happen to have". + return id !== undefined && id !== account.id ? null : account; + }, + list: async () => ({ items: [{ id: 'singleton', name: 'My Agent', status: 'active', ctx_metadata: {} }] }), }; ``` diff --git a/skills/cross-cutting.md b/skills/cross-cutting.md index 133c653e5..dfc2520da 100644 --- a/skills/cross-cutting.md +++ b/skills/cross-cutting.md @@ -65,7 +65,7 @@ The hello adapters use simple in-memory `accounts.resolution: 'lookup'` against - `createOAuthPassthroughResolver` — buyer-OAuth-passes-through (Shape B) - `createRosterAccountStore` — pre-loaded roster (Shape C) -- `createDerivedAccountStore` — single-tenant `'derived'` mode (Shape D) +- `createDerivedAccountStore` — `'derived'` mode (Shape D): an upstream-managed account-id namespace (or a credential bound to one account). Buyers discover ids via `list_accounts`; the factory verifies buyer-supplied `account_id` against the caller's reachable set - `createTenantStore` — multi-tenant, with a built-in isolation gate on the account-sync tools and a **required** `refAccess` choice for `resolve` `createTenantStore` is the right default for any adopter handling more than one advertiser. It refuses inline `{account_id}` references unless your store explicitly lists them — that's a hard security gate, not a soft warning. diff --git a/src/lib/adapters/derived-account-store.ts b/src/lib/adapters/derived-account-store.ts index a106389a4..446d081a5 100644 --- a/src/lib/adapters/derived-account-store.ts +++ b/src/lib/adapters/derived-account-store.ts @@ -1,45 +1,95 @@ /** - * Derived `AccountStore` factory for `resolution: 'derived'` single-tenant - * agents — there is no `account_id` on the wire and the auth principal alone - * identifies the tenant. Most self-hosted broadcasters, retail-media operators - * in proxy mode, and creative-only adapters (audiostack, flashtalking) where - * every API call is scoped by the buyer's per-request credential. - * - * Pairs with the existing reference adapters. Pick by asking *who creates the - * account?*: - * - **Buyer self-onboards via `sync_accounts`** → {@link InMemoryImplicitAccountStore} + * Derived `AccountStore` factory for `resolution: 'derived'` — agents that + * front an **upstream-managed account namespace**. The upstream platform + * (Meta / Snap business accounts, AudioStack workspaces, a retail-media + * proxy, a flashtalking tenant) owns the roster; the buyer discovers ids with + * `list_accounts` and passes `{ account_id }` on every account-scoped call. + * + * Roster size is an operational property of the upstream, not a different SDK + * pattern: a credential that reaches exactly one account (AudioStack-shaped) + * and a credential that reaches two hundred (Meta-shaped) speak the same wire + * contract. Pick the option shape that matches — `toAccount` for the + * credential-bound singleton, `listAccounts` for a real roster. + * + * Pairs with the other reference adapters. Pick by asking *who owns the + * roster?*: + * - **Buyer declares accounts via `sync_accounts`** → {@link InMemoryImplicitAccountStore} * (Shape A, `resolution: 'implicit'`). - * - **Upstream OAuth API owns the roster** → {@link createOAuthPassthroughResolver} - * (Shape B, `resolution: 'explicit'`). - * - **Publisher ops curates the roster** → {@link createRosterAccountStore} + * - **Upstream OAuth API owns the roster, you only need `resolve`** → + * {@link createOAuthPassthroughResolver} (Shape B, `resolution: 'explicit'`). + * - **You own the roster (storefront table, admin UI)** → {@link createRosterAccountStore} * (Shape C, `resolution: 'explicit'`). - * - **No account concept — auth principal IS the tenant** → `createDerivedAccountStore` - * (this file, Shape D, `resolution: 'derived'`). + * - **An upstream platform owns the roster and you front it** → + * `createDerivedAccountStore` (this file, Shape D, `resolution: 'derived'`). + * + * **Changed in SDK 14 (breaking, adcp-client#1647 / upstream adcp#5062).** + * Shape D used to be documented as "single-tenant; `account_id` is meaningless + * on the wire" and the framework refused inline `account_id` for it. The wire + * semantics were inverted: `account_id` is now the durable reference, the + * brand+operator arm is refused, `list_accounts` is required, and this factory + * verifies buyer-supplied ids instead of ignoring them. * - * Closes adcp-client#1462. Replaces the ~25–30 LOC of `if (!authInfo.token) throw - * AUTH_REQUIRED; return { id: ..., ctx_metadata: { accessToken } }` boilerplate - * Shape D adopters write today. + * Closes adcp-client#1462, reworked by adcp-client#1647. * * @see docs/guides/account-resolution.md * @public */ -import type { AccountReference } from '../types/tools.generated'; -import type { Account, AccountStore, ResolveContext } from '../server/decisioning/account'; +import type { AccountReference, ListAccountsRequest } from '../types/tools.generated'; +import type { Account, AccountStore, ListAccountsHandlerResult, ResolveContext } from '../server/decisioning/account'; +import { refAccountId } from '../server/decisioning/account'; +import { isSandboxOrMockAccount } from '../server/account-mode'; import { AdcpError } from '../server/decisioning/async-outcome'; +/** Options shared by both Shape D variants. @public */ +export interface DerivedAccountStoreBaseOptions { + /** + * Skip the `AUTH_REQUIRED` precheck. Defaults to `false` — the factory + * throws `AdcpError('AUTH_REQUIRED')` when `ctx.authInfo` is absent or + * carries no credential, matching the canonical Shape D pattern (every + * call must authenticate, because the credential is what scopes the + * reachable roster). + * + * Set to `true` for genuinely unauthenticated agents (rare — public format + * catalogs, signed-request-only agents that authenticate out-of-band). + * When `true`, the account callbacks run unconditionally. + * + * If you're tempted to set this because tests don't carry `authInfo`, + * fix the tests instead — `serve({ authenticate })` should populate + * `ctx.authInfo` from your test harness (or use `dispatchTestRequest` + * which threads a synthetic principal). The escape hatch is for + * production agents that legitimately accept unauthenticated traffic, + * not for working around fixture gaps. + * + * @default false + */ + skipAuthCheck?: boolean; +} + /** - * Options for {@link createDerivedAccountStore}. + * Shape D options for a credential-bound singleton — one upstream account per + * credential (AudioStack, flashtalking, a single-namespace retail-media + * proxy). * * @public */ -export interface DerivedAccountStoreOptions> { +export interface DerivedSingletonAccountStoreOptions< + TCtxMeta = Record, +> extends DerivedAccountStoreBaseOptions { /** - * Build the singleton account from the request context. Called on every - * `resolve()` (no caching — the tenant is per-request because the auth - * principal varies). The returned account's `id` is typically a stable - * literal (e.g. `'audiostack'`, `'__publisher_wide__'`); single-tenant - * adapters have no buyer-supplied account_id to reflect back. + * Build the one account this credential can reach. Called on every + * `resolve()` and `list()` (no caching — the tenant is per-request because + * the auth principal varies). + * + * The factory verifies buyer-supplied references against the returned + * `id`: a request carrying a different `account_id` resolves to `null` + * (framework → `ACCOUNT_NOT_FOUND`) rather than being silently serviced + * against this account. You do not implement that check yourself. + * + * `id` must be the id the upstream namespace actually uses — it is what + * `list_accounts` publishes and what buyers will send back. A placeholder + * like `'__singleton__'` is fine only if that is genuinely the id you want + * on the wire. * * **DO NOT put credentials in `ctx_metadata`.** See * `docs/guides/CTX-METADATA-SAFETY.md` for the rationale. The wire-strip @@ -52,33 +102,73 @@ export interface DerivedAccountStoreOptions> * auto-attaches the principal from `ctx.authInfo` when absent (matches * Shape A/B/C semantics). */ - toAccount: (ctx: ResolveContext | undefined) => Account; + toAccount: (ctx: ResolveContext | undefined) => Account | Promise>; + /** Mutually exclusive with `toAccount` — supply the roster options instead. */ + listAccounts?: never; + /** Applies to `listAccounts` rosters only. */ + lookupAccount?: never; +} +/** + * Shape D options for a credential-scoped roster — the upstream exposes many + * accounts to one credential (Meta / Snap business managers, an agency seat). + * + * @public + */ +export interface DerivedRosterAccountStoreOptions< + TCtxMeta = Record, +> extends DerivedAccountStoreBaseOptions { /** - * Skip the `AUTH_REQUIRED` precheck. Defaults to `false` — the factory - * throws `AdcpError('AUTH_REQUIRED')` when `ctx.authInfo` is absent or - * carries no credential, matching the canonical Shape D pattern (every - * call must authenticate). + * Enumerate every account the **caller's credential** can reach. Backs + * `list_accounts` and, unless `lookupAccount` is supplied, the verification + * of buyer-supplied `account_id` references. * - * Set to `true` for genuinely unauthenticated single-tenant agents (rare — - * public format catalogs, signed-request-only agents that authenticate - * out-of-band). When `true`, `toAccount` runs unconditionally. + * This is the tenant-isolation boundary for the whole namespace: scope the + * query by `ctx.authInfo` (OAuth `client_id`, API-key `key_id`, the + * upstream token you exchange for it). Returning the full upstream roster + * regardless of caller is a cross-tenant enumeration *and* authorization + * bug — every id returned here becomes an id that caller can transact on. + */ + listAccounts: ( + ctx: ResolveContext | undefined + ) => readonly Account[] | Promise[]>; + + /** + * Optional point-lookup for large rosters — avoids materializing the full + * list on every `resolve()`. Return the account for `accountId`, or `null` + * when the caller's credential cannot reach it. * - * If you're tempted to set this because tests don't carry `authInfo`, - * fix the tests instead — `serve({ authenticate })` should populate - * `ctx.authInfo` from your test harness (or use `dispatchTestRequest` - * which threads a synthetic principal). The escape hatch is for - * production agents that legitimately accept unauthenticated traffic, - * not for working around fixture gaps. + * **Must be credential-scoped.** The `accountId` is a buyer-supplied claim; + * an unscoped `SELECT ... WHERE id = $1` here is a tenant-isolation bypass. + * The factory defends what it can — a returned account whose `id` differs + * from the requested one is discarded — but it cannot tell whether your + * query filtered on the caller. * - * @default false + * Omit it and the factory verifies against `listAccounts(ctx)` instead, + * which is safe by construction. */ - skipAuthCheck?: boolean; + lookupAccount?: ( + accountId: string, + ctx: ResolveContext | undefined + ) => Account | null | undefined | Promise | null | undefined>; + /** Mutually exclusive with `listAccounts` — supply the singleton option instead. */ + toAccount?: never; } /** - * Build an `AccountStore` for single-tenant agents whose tenant is - * derived from the auth principal alone (no `account_id` on the wire). + * Options for {@link createDerivedAccountStore}. Supply `toAccount` (one + * account per credential) or `listAccounts` (a credential-scoped roster) — + * never both. + * + * @public + */ +export type DerivedAccountStoreOptions> = + | DerivedSingletonAccountStoreOptions + | DerivedRosterAccountStoreOptions; + +/** + * Build an `AccountStore` for an agent fronting an upstream-managed + * account namespace. * * The factory: * 1. Sets `resolution: 'derived'`. @@ -87,93 +177,228 @@ export interface DerivedAccountStoreOptions> * discriminated `credential` shape (preferred) AND the deprecated * `token` / `clientId` fields populated by pre-#1269 authenticators — * fail-closed only when none of the three are present. - * 3. Calls `toAccount(ctx)` and returns the result. Buyer-supplied - * `AccountReference` is ignored — single-tenant by definition. - * 4. Omits `list` / `upsert` — single-tenant adapters have nothing to - * enumerate or write. Adopters who want either compose via spread. - * - * **When NOT to use this.** If two different auth principals should resolve - * to two different `account.id` values, you want Shape B - * ({@link createOAuthPassthroughResolver}) or `createTenantStore` — NOT this - * factory. Misusing Shape D in a multi-tenant deployment routes every buyer - * to the same singleton id and breaks tenant isolation silently (no error, - * no log). The factory is single-tenant by design — `toAccount(ctx)` should - * return the same `id` for every principal that calls it. - * - * @example AudioStack-shaped creative adapter: + * 3. **Verifies buyer-supplied `account_id` against what the credential can + * reach**, and returns `null` on any miss so the framework emits the + * spec's fixed `ACCOUNT_NOT_FOUND` envelope. Verification is performed by + * the factory, not delegated to adopter code — an adopter cannot forget + * it and silently serve cross-tenant requests. + * 4. Auto-selects the singleton for ref-less calls + * (`list_creative_formats`, `provide_performance_feedback`, …) when the + * credential reaches exactly one account; returns `null` when it reaches + * several, because "whichever one" is not a defensible default. + * 5. Wires `list` (`list_accounts`) — required for `'derived'` platforms, and + * the only way buyers learn ids. Honors the wire filters the framework + * passes through (`account`, `status`, `sandbox`) and pages the result + * (`pagination.max_results`, capped at 100, with an opaque cursor). + * 6. Omits `upsert`. Natural-key provisioning is out of scope for this mode + * (the framework fails those `sync_accounts` entries per-row with + * `UNSUPPORTED_PROVISIONING`); adopters whose upstream supports + * settings-update writes compose `upsert` on top via spread. + * + * **Refuses the brand+operator arm.** The framework rejects `{ brand, + * operator }` references for `'derived'` platforms with + * `AdcpError('INVALID_REQUEST', { field: 'account.brand' })` before reaching + * this resolver, pointing the buyer at `list_accounts`. The factory also + * returns `null` for such refs defensively. + * + * @example AudioStack-shaped adapter (one workspace per credential): * ```ts * import { createDerivedAccountStore } from '@adcp/sdk/server'; * * const accounts = createDerivedAccountStore({ - * toAccount: (ctx) => ({ - * id: 'audiostack', - * name: 'AudioStack', - * status: 'active', - * ctx_metadata: {}, // tokens stay on ctx.authInfo, not here - * }), + * toAccount: async (ctx) => { + * const workspace = await audiostack.currentWorkspace(ctx?.authInfo); + * return { + * id: workspace.id, // the id buyers will send back + * name: workspace.name, + * status: 'active', + * ctx_metadata: {}, // tokens stay on ctx.authInfo, not here + * }; + * }, * }); * ``` * - * @example Single-tenant retail-media proxy with derived display name: + * @example Meta-shaped adapter (many ad accounts per credential): * ```ts - * const accounts = createDerivedAccountStore<{ tenantId: string }>({ - * toAccount: (ctx) => { - * const cred = ctx?.authInfo?.credential; - * const tenantId = cred?.kind === 'oauth' ? cred.client_id - * : cred?.kind === 'api_key' ? cred.key_id - * : 'public'; - * return { - * id: 'criteo', - * name: `Criteo (${tenantId})`, - * status: 'active', - * ctx_metadata: { tenantId }, - * }; + * const accounts = createDerivedAccountStore<{ upstreamId: string }>({ + * listAccounts: async (ctx) => { + * const rows = await meta.adAccountsFor(ctx?.authInfo); // credential-scoped + * return rows.map(r => ({ + * id: r.account_id, + * name: r.name, + * status: r.disabled ? 'suspended' : 'active', + * ctx_metadata: { upstreamId: r.id }, + * })); * }, * }); * ``` * - * @example Compose `upsert` for adapters that ALSO want a buyer-driven - * write path (rare for Shape D, but possible): + * @example Large roster with a point lookup: + * ```ts + * const accounts = createDerivedAccountStore({ + * listAccounts: (ctx) => upstream.page(ctx?.authInfo), + * // MUST filter by the caller, not just by id: + * lookupAccount: (id, ctx) => upstream.accountForCaller(id, ctx?.authInfo), + * }); + * ``` + * + * @example Compose `upsert` for an upstream that supports settings updates: * ```ts * const accounts: AccountStore = { - * ...createDerivedAccountStore({ toAccount }), - * upsert: async (refs, ctx) => mySync(refs, ctx), + * ...createDerivedAccountStore({ listAccounts }), + * // Entries reaching this are `account: { account_id }`-keyed AND already + * // resolved against the caller's reachable set — the framework refuses + * // natural-key provisioning and unreachable ids for 'derived'. Returned + * // rows must still carry `brand` + `operator` (schema-required); echo + * // them from your own account record. + * upsert: async (refs, ctx) => myUpstream.updateSettings(refs, ctx), * }; * ``` * - * **Buyer-supplied `account_id` is refused at the framework boundary.** - * Since adcp-client#1468, the framework throws `AdcpError('INVALID_REQUEST', - * { field: 'account.account_id' })` BEFORE reaching this resolver when a - * `'derived'`-mode platform receives an inline `account_id` — same shape - * as `'implicit'`'s long-standing refusal (#1364), with a single-tenant - * message instead of the `sync_accounts`-first guidance. The factory - * itself ignores any `account_id` that does reach it (defensive belt + - * braces), but adopters can rely on the framework refusal as the - * canonical surface. Hand-rolled `'derived'` stores get the same - * enforcement automatically. - * * @public */ export function createDerivedAccountStore>( options: DerivedAccountStoreOptions ): AccountStore { - const skipAuthCheck = options.skipAuthCheck ?? false; + // Strict `true` — an untyped `skipAuthCheck: 'false'` must not disable + // the credential gate. + const skipAuthCheck = options.skipAuthCheck === true; + const singleton = (options as DerivedSingletonAccountStoreOptions).toAccount; + const roster = (options as DerivedRosterAccountStoreOptions).listAccounts; + const lookup = (options as DerivedRosterAccountStoreOptions).lookupAccount; + + if (singleton === undefined && roster === undefined) { + throw new TypeError( + 'createDerivedAccountStore: supply toAccount (one account per credential) or listAccounts (a ' + + 'credential-scoped roster). Neither was provided.' + ); + } + if (singleton !== undefined && roster !== undefined) { + throw new TypeError( + 'createDerivedAccountStore: supply toAccount or listAccounts, not both — two sources of truth for ' + + 'the same namespace cannot be verified against each other.' + ); + } + if (singleton !== undefined && lookup !== undefined) { + throw new TypeError('createDerivedAccountStore: lookupAccount applies to listAccounts rosters, not to toAccount.'); + } + + /** Every account the caller's credential can reach. */ + const reachable = async (ctx: ResolveContext | undefined): Promise[]> => + singleton !== undefined ? [await singleton(ctx)] : [...(await roster!(ctx))]; + + const requireAuth = (ctx: ResolveContext | undefined): void => { + if (skipAuthCheck || hasAuthSignal(ctx)) return; + throw new AdcpError('AUTH_REQUIRED', { + message: + 'This agent fronts an upstream-managed account namespace and requires an authenticated principal; ' + + 'no credential on ctx.authInfo.', + recovery: 'correctable', + }); + }; return { resolution: 'derived', - async resolve(_ref: AccountReference | undefined, ctx?: ResolveContext): Promise | null> { - if (!skipAuthCheck && !hasAuthSignal(ctx)) { - throw new AdcpError('AUTH_REQUIRED', { - message: 'Single-tenant agent requires an authenticated principal; no credential on ctx.authInfo.', - recovery: 'correctable', - }); + async resolve(ref: AccountReference | undefined, ctx?: ResolveContext): Promise | null> { + requireAuth(ctx); + + const accountId = refAccountId(ref); + if (accountId !== undefined) { + if (lookup !== undefined) { + const hit = await lookup(accountId, ctx); + // Defense in depth: an adopter lookup that ignores the id (or + // returns a neighbouring row) must not become a silent + // cross-account substitution. + return hit != null && hit.id === accountId ? hit : null; + } + return (await reachable(ctx)).find(account => account.id === accountId) ?? null; } - return options.toAccount(ctx); + + // Brand+operator refs are refused at the framework boundary for this + // mode; `null` here is the defensive path for direct resolver calls. + if (ref !== undefined) return null; + + // Ref-less call (tools with no `account` field on the wire). Auto-select + // only when the credential reaches exactly one account — ambiguity + // resolves to ACCOUNT_NOT_FOUND, never to an arbitrary pick. + const accounts = await reachable(ctx); + return accounts.length === 1 ? accounts[0]! : null; + }, + + async list(request: ListAccountsRequest, ctx?: ResolveContext): Promise> { + requireAuth(ctx); + const filterId = refAccountId(request?.account); + const filterKey = naturalKeyFilter(request?.account); + const items = (await reachable(ctx)).filter(account => { + if (filterId !== undefined && account.id !== filterId) return false; + // A natural-key filter is a narrowing request, not a no-op: match it + // against the row's own brand/operator, and drop rows that can't + // satisfy it. (Ignoring the filter would answer "which account is + // brand X's?" with the entire roster.) + if (filterKey !== undefined) { + if (filterKey.brandDomain !== undefined && account.brand?.domain !== filterKey.brandDomain) return false; + if (filterKey.brandId !== undefined && account.brand?.brand_id !== filterKey.brandId) return false; + if (filterKey.operator !== undefined && account.operator !== filterKey.operator) return false; + } + if (request?.status !== undefined && account.status !== request.status) return false; + // Wire `sandbox` filter maps to the resolved account's operational + // mode through the shared predicate (which honors the deprecated + // `sandbox` flag and treats `mode: 'mock'` as non-live), so + // "sandbox" means one thing across the SDK. + if (request?.sandbox !== undefined && isSandboxOrMockAccount(account) !== request.sandbox) return false; + return true; + }); + // Bounded pages: the roster shape is explicitly for credentials that + // reach many accounts, so an unbounded page is both a wire-contract + // miss (`pagination.has_more` would always be false) and an + // allocation amplifier. Offset cursors are correct here because the + // page is materialized from one snapshot per request. + const offset = decodePageCursor(request?.pagination?.cursor); + const limit = pageLimit(request?.pagination?.max_results); + const page = items.slice(offset, offset + limit); + const nextOffset = offset + page.length; + return { + items: page, + ...(nextOffset < items.length && { nextCursor: encodePageCursor(nextOffset) }), + totalCount: items.length, + }; }, }; } +/** Spec cap on `pagination.max_results` (`core/pagination-request.json`). */ +const MAX_PAGE_SIZE = 100; + +/** Clamp a buyer-supplied `max_results` into `[1, MAX_PAGE_SIZE]`. */ +function pageLimit(maxResults: number | undefined): number { + if (typeof maxResults !== 'number' || !Number.isFinite(maxResults)) return MAX_PAGE_SIZE; + return Math.min(Math.max(Math.trunc(maxResults), 1), MAX_PAGE_SIZE); +} + +/** + * Opaque continuation cursor. Offset-encoded and prefixed so a cursor + * minted elsewhere (or a hand-crafted one) doesn't silently read as a + * position — unparseable cursors restart at 0 rather than throwing, which + * keeps a stale cursor from turning into a dispatch error. + */ +function encodePageCursor(offset: number): string { + return Buffer.from(`derived:${offset}`, 'utf8').toString('base64url'); +} + +function decodePageCursor(cursor: string | undefined): number { + if (cursor === undefined) return 0; + try { + const decoded = Buffer.from(cursor, 'base64url').toString('utf8'); + const match = /^derived:(\d+)$/.exec(decoded); + if (match === null) return 0; + const offset = Number.parseInt(match[1]!, 10); + return Number.isSafeInteger(offset) && offset >= 0 ? offset : 0; + } catch { + return 0; + } +} + /** * True when `ctx.authInfo` carries any usable credential signal — the * discriminated `credential` shape (preferred, post-#1269) OR the deprecated @@ -185,3 +410,23 @@ function hasAuthSignal(ctx: ResolveContext | undefined): boolean { if (!authInfo) return false; return authInfo.credential !== undefined || authInfo.token !== undefined || authInfo.clientId !== undefined; } + +/** + * Extract the `{ brand, operator }` narrowing terms from a `list_accounts` + * filter. Returns `undefined` when the filter carries no natural key. + */ +function naturalKeyFilter( + ref: AccountReference | undefined +): { brandDomain?: string; brandId?: string; operator?: string } | undefined { + if (ref === undefined) return undefined; + const brand = (ref as { brand?: { domain?: unknown; brand_id?: unknown } }).brand; + const operator = (ref as { operator?: unknown }).operator; + const brandDomain = typeof brand?.domain === 'string' ? brand.domain : undefined; + const brandId = typeof brand?.brand_id === 'string' ? brand.brand_id : undefined; + if (brandDomain === undefined && brandId === undefined && typeof operator !== 'string') return undefined; + return { + ...(brandDomain !== undefined && { brandDomain }), + ...(brandId !== undefined && { brandId }), + ...(typeof operator === 'string' && { operator }), + }; +} diff --git a/src/lib/adapters/implicit-account-store.ts b/src/lib/adapters/implicit-account-store.ts index e17b74367..144dcdebd 100644 --- a/src/lib/adapters/implicit-account-store.ts +++ b/src/lib/adapters/implicit-account-store.ts @@ -19,8 +19,9 @@ * (Shape B, returns just `resolve`). * - **Publisher ops curates the roster** → `createRosterAccountStore` * (Shape C, complete AccountStore). - * - **No account concept — auth principal IS the tenant** → `createDerivedAccountStore` - * (Shape D, `resolution: 'derived'`). + * - **An upstream platform owns the roster and you front it** → `createDerivedAccountStore` + * (Shape D, `resolution: 'derived'` — buyers discover ids via `list_accounts` + * and address accounts by `account_id`). * * @see docs/guides/account-resolution.md * @public diff --git a/src/lib/adapters/index.ts b/src/lib/adapters/index.ts index 2481cb78a..4db0c81f8 100644 --- a/src/lib/adapters/index.ts +++ b/src/lib/adapters/index.ts @@ -77,8 +77,17 @@ export { createOAuthPassthroughResolver, type OAuthPassthroughResolverOptions } // where the roster lives. export { createRosterAccountStore, type RosterAccountStoreOptions } from './roster-account-store'; -// Derived AccountStore — Shape D factory for `resolution: 'derived'` single- -// tenant agents (no account_id on the wire; auth principal alone identifies -// the tenant). Closes adcp-client#1462. Replaces ~25–30 LOC of bearer-extract + -// throw-AUTH_REQUIRED + return-singleton boilerplate Shape D adopters write today. -export { createDerivedAccountStore, type DerivedAccountStoreOptions } from './derived-account-store'; +// Derived AccountStore — Shape D factory for `resolution: 'derived'` agents +// fronting an upstream-managed account namespace (the upstream owns the +// roster; buyers discover ids via `list_accounts` and send `{ account_id }`). +// Verifies buyer-supplied ids against what the caller's credential can reach +// and wires `list_accounts` for both the credential-bound singleton +// (`toAccount`) and roster (`listAccounts`) shapes. Closes adcp-client#1462; +// reworked by adcp-client#1647. +export { + createDerivedAccountStore, + type DerivedAccountStoreOptions, + type DerivedAccountStoreBaseOptions, + type DerivedSingletonAccountStoreOptions, + type DerivedRosterAccountStoreOptions, +} from './derived-account-store'; diff --git a/src/lib/adapters/oauth-passthrough-resolver.ts b/src/lib/adapters/oauth-passthrough-resolver.ts index 6855b328a..12d126875 100644 --- a/src/lib/adapters/oauth-passthrough-resolver.ts +++ b/src/lib/adapters/oauth-passthrough-resolver.ts @@ -22,8 +22,9 @@ * (this file, Shape B, returns just `resolve`). * - **Publisher ops curates the roster** → `createRosterAccountStore` * (Shape C, complete AccountStore). - * - **No account concept — auth principal IS the tenant** → `createDerivedAccountStore` - * (Shape D, `resolution: 'derived'`). + * - **An upstream platform owns the roster and you front it** → `createDerivedAccountStore` + * (Shape D, `resolution: 'derived'` — same upstream-managed model as this + * resolver, plus verified `account_id` resolution and `list_accounts`). * * @public */ diff --git a/src/lib/adapters/roster-account-store.ts b/src/lib/adapters/roster-account-store.ts index e2e8d4bd7..782ce3fa8 100644 --- a/src/lib/adapters/roster-account-store.ts +++ b/src/lib/adapters/roster-account-store.ts @@ -19,11 +19,12 @@ * in-memory map); the SDK provides the AccountStore plumbing. Most * SSPs, broadcasters, and retail-media networks where AE/CSM provisions * the account in an internal admin tool before the buyer ever calls. - * - **No account concept — auth principal IS the tenant** → `createDerivedAccountStore` - * (Shape D, `resolution: 'derived'`). Single-tenant agents - * (audiostack, flashtalking, single-namespace retail-media) where there - * is no `account_id` on the wire and every call authenticates with the - * per-request bearer. + * - **An upstream platform owns the roster and you front it** → + * `createDerivedAccountStore` (Shape D, `resolution: 'derived'`). Agents + * proxying Meta / Snap / AudioStack / flashtalking, where the upstream + * assigns the ids, the buyer discovers them through `list_accounts`, and + * every call authenticates with the per-request credential that scopes the + * reachable roster. * * Design notes: * - Lookup is a point function, not a roster getter. An adopter with diff --git a/src/lib/index.ts b/src/lib/index.ts index 7fd55930f..966fd680f 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -2153,9 +2153,13 @@ export { // Roster-backed AccountStore — Shape C factory for publisher-curated explicit platforms createRosterAccountStore, type RosterAccountStoreOptions, - // Derived AccountStore — Shape D factory for single-tenant `resolution: 'derived'` agents + // Derived AccountStore — Shape D factory for `resolution: 'derived'` agents + // fronting an upstream-managed account namespace createDerivedAccountStore, type DerivedAccountStoreOptions, + type DerivedAccountStoreBaseOptions, + type DerivedSingletonAccountStoreOptions, + type DerivedRosterAccountStoreOptions, } from './adapters'; // ====== BACKWARD COMPATIBILITY & ENVIRONMENT LOADING ====== diff --git a/src/lib/server/create-adcp-server.ts b/src/lib/server/create-adcp-server.ts index 62204327b..a81d35dbd 100644 --- a/src/lib/server/create-adcp-server.ts +++ b/src/lib/server/create-adcp-server.ts @@ -1822,9 +1822,10 @@ export interface AdcpServerConfig { * (`provide_performance_feedback`, `list_creative_formats`, the * `tasks/get` polling path, etc.), the framework can't extract a wire * ref. When this resolver is configured, the framework calls it with the - * caller's `authInfo` instead so single-tenant agents (`resolution: 'derived'`) - * and principal-keyed agents (`resolution: 'implicit'`) still get a - * tenant-scoped `ctx.account`. + * caller's `authInfo` instead so upstream-managed-namespace agents + * (`resolution: 'derived'`, when the credential reaches exactly one + * account) and principal-keyed agents (`resolution: 'implicit'`) still get + * a tenant-scoped `ctx.account`. * * Returns `null` when no account can be derived. The handler then runs * with `ctx.account` undefined — appropriate for tools that legitimately diff --git a/src/lib/server/decisioning/account.ts b/src/lib/server/decisioning/account.ts index c3e22c601..f2905f7c5 100644 --- a/src/lib/server/decisioning/account.ts +++ b/src/lib/server/decisioning/account.ts @@ -511,30 +511,112 @@ export interface AuthPrincipal { claims?: Record; } +/** + * Account-reference model a seller declares on {@link AccountStore.resolution}. + * + * One spelling per model, deliberately. `'derived'` keeps its name even + * though adcp#5062 calls the shape an *upstream-managed account-id + * namespace*: `resolution` is SDK-local configuration, not a wire value, and + * a second spelling would silently defeat every adopter comparison written + * as `resolution === 'derived'` while inviting divergence from the Python + * SDK. `'account-id-namespace'` is rejected for the additional reason that + * it doesn't discriminate — under adcp#5062 both `'explicit'` and + * `'derived'` are account-id namespaces. + * + * @public + */ +export type AccountResolutionMode = 'explicit' | 'implicit' | 'derived'; + +/** + * Resolution mode with the documented default applied. Distinct from + * {@link AccountResolutionMode} only in that it cannot be `undefined`. + * + * @public + */ +export type CanonicalAccountResolutionMode = AccountResolutionMode; + +/** + * Apply the documented `'explicit'` default to a declared resolution mode. + * + * Every framework read of `accounts.resolution` goes through this helper so + * "omitted means explicit" is stated once rather than re-derived (usually + * correctly, occasionally not) at each branch. + * + * @public + */ +export function normalizeAccountResolution( + resolution: AccountResolutionMode | undefined +): CanonicalAccountResolutionMode { + return isAccountResolutionMode(resolution) ? resolution : 'explicit'; +} + +/** + * Runtime guard for the `resolution` union. Untyped JavaScript adopters can + * put anything here, and "unrecognized" must not silently inherit another + * mode's enforcement — `createAdcpServerFromPlatform` rejects unknown values + * at construction, and {@link normalizeAccountResolution} falls back to the + * documented `'explicit'` default if one ever reaches dispatch. + * + * @public + */ +export function isAccountResolutionMode(value: unknown): value is AccountResolutionMode { + return value === 'explicit' || value === 'implicit' || value === 'derived'; +} + export interface AccountStore> { /** - * How buyers reference accounts on this platform. - * - `'explicit'` — buyer passes `account_id` inline on every request (Snap, - * Meta, GAM via Network/Company id). The default. - * - `'implicit'` — buyer must `sync_accounts` first; subsequent requests are - * resolved from the auth principal's pre-synced linkage (LinkedIn, some - * retail-media operators). Framework refuses inline `account_id` references - * for these platforms — emits `AdcpError('INVALID_REQUEST', { field: - * 'account.account_id' })` before reaching `accounts.resolve`. The - * brand+operator union arm is permitted (used during the initial - * `sync_accounts` flow); only `account_id`-shaped references are rejected. - * - `'derived'` — single-tenant agents where there is no account_id on the - * wire at all and the auth principal alone identifies the tenant. Most - * self-hosted broadcasters and retail-media operators in proxy mode. - * Framework refuses inline `account_id` references for these platforms — - * same `AdcpError('INVALID_REQUEST', { field: 'account.account_id' })` - * shape as `'implicit'`, but with a single-tenant message instead of the - * `sync_accounts`-first guidance (no `sync_accounts` step exists in - * derived mode). The brand+operator union arm is permitted. + * How buyers reference accounts on this platform. Three onboarding models, + * aligned with the account-reference taxonomy clarified upstream in + * [adcp#5062](https://github.com/adcontextprotocol/adcp/pull/5062): + * + * | Mode | Roster owner | Discovery | Durable wire reference | + * |---|---|---|---| + * | `'explicit'` (default) | Seller (you) | none required — IDs issued out-of-band, or `list_accounts` when you wire it | `{ account_id }` or the `{ brand, operator }` natural key | + * | `'implicit'` | Buyer-declared | `sync_accounts` | `{ brand, operator }` natural key | + * | `'derived'` | Upstream platform you front | `list_accounts` (required) | `{ account_id }` | + * + * - `'explicit'` — a seller-owned account-id namespace. You issue the ids + * (Snap, Meta, GAM via Network/Company id, a publisher storefront table). + * The framework applies no reference-shape constraint: both union arms + * reach `accounts.resolve`. + * - `'implicit'` — buyer-declared accounts. The buyer must `sync_accounts` + * first; subsequent requests resolve from the auth principal's pre-synced + * linkage (LinkedIn, some retail-media operators). Framework refuses + * inline `account_id` references for these platforms — emits + * `AdcpError('INVALID_REQUEST', { field: 'account.account_id' })` before + * reaching `accounts.resolve`. The brand+operator union arm is permitted + * (it is the durable key for this mode); only `account_id`-shaped + * references are rejected. + * - `'derived'` — an **upstream-managed account-id namespace**. The agent + * fronts a platform that owns its own roster (Meta / Snap business + * accounts, AudioStack workspaces, a retail-media proxy). Roster size is + * an operational property of the upstream, not a different SDK pattern: + * N=1 and N=many adopters share one wire contract. The buyer calls + * `list_accounts` to discover ids, then passes `{ account_id }` on every + * account-scoped request. Framework refuses the brand+operator arm for + * these platforms — `AdcpError('INVALID_REQUEST', { field: + * 'account.brand' })` with a `list_accounts` suggestion — because the + * natural key is not a durable reference into someone else's namespace. + * `accounts.list` is **required** (`createAdcpServerFromPlatform` throws + * `PlatformConfigError` without it); `accounts.resolve` MUST verify that + * a buyer-supplied `account_id` is one the caller's credential can + * actually reach. `createDerivedAccountStore` does both for you. + * + * **Changed in SDK 14 (breaking, adcp-client#1647).** `'derived'` used to + * be documented as "single-tenant; no `account_id` on the wire" and the + * framework refused inline `account_id` for it. That was inverted: it made + * the mode unusable for the upstream-managed adopters it exists for, and + * left buyers who called `list_accounts` with ids that every subsequent + * call rejected. Agents that genuinely have no discoverable namespace and + * hand out ids out-of-band belong in `'explicit'`. + * + * The mode keeps the name `'derived'` even though the spec calls the + * shape an upstream-managed account-id namespace — see + * {@link AccountResolutionMode} for why no alias is offered. * * Defaults to `'explicit'` when omitted. */ - readonly resolution?: 'explicit' | 'implicit' | 'derived'; + readonly resolution?: AccountResolutionMode; /** * Resolve buyer's AccountReference into the platform's tenant model. @@ -542,7 +624,10 @@ export interface AccountStore> { * `ref` is `undefined` when the wire request didn't carry an account * field — `provide_performance_feedback` and `list_creative_formats` are * the canonical examples. Per `resolution` mode: - * - `'derived'` (single-tenant): return the singleton account regardless. + * - `'derived'`: return the account the credential can reach when exactly + * one is reachable (the singleton shortcut); return `null` when the + * credential can reach several and the buyer named none — ambiguity is + * not a default. * - `'implicit'`: look up the account from the auth principal. * - `'explicit'` (default): no account is available; either throw * `AccountNotFoundError` to signal "tool requires account" OR return @@ -550,6 +635,24 @@ export interface AccountStore> { * tenant scoping (e.g., publisher-wide format catalog from * `list_creative_formats`). * + * **`'derived'` resolvers MUST verify the reference (tenant isolation).** + * A buyer-supplied `account_id` on an upstream-managed namespace is an + * untrusted claim: resolve it against the set the *caller's credential* + * can reach and return `null` on any miss. A resolver that ignores `ref` + * and returns a fixed account hands caller A's request to caller B's + * tenant the moment the deployment grows past one account. Prefer + * `createDerivedAccountStore`, which performs the check itself. + * + * ```ts + * // 'derived' — verified lookup, fail closed. + * resolve: async (ref, ctx) => { + * const reachable = await upstream.accountsFor(ctx?.authInfo); // credential-scoped + * const id = refAccountId(ref); + * if (id !== undefined) return reachable.find(a => a.id === id) ?? null; + * return reachable.length === 1 ? reachable[0] : null; + * }, + * ``` + * * `ctx.authInfo` is the caller's authenticated principal (when * `serve({ authenticate })` is wired). Adapters fronting an upstream * platform API (Snap, Meta, retail-media) translate auth to tenant ID: @@ -583,6 +686,29 @@ export interface AccountStore> { * upserts and returns per-account result rows. `throw new AdcpError(...)` * for buyer-facing rejection. * + * **`'derived'` platforms may still wire this.** Natural-key provisioning + * (`{ brand, operator, billing }` at the entry root) is out of scope for an + * upstream-managed account-id namespace — the framework fails those entries + * per-row with `UNSUPPORTED_PROVISIONING` before calling `upsert`, per + * adcp#5062. Settings-update entries keyed by `account: { account_id }` + * pass through, so adopters whose upstream exposes account-settings writes + * keep that surface. + * + * For `'derived'`, the framework additionally resolves each surviving + * entry's `account_id` against what the caller's credential can reach and + * fails the operation with `ACCOUNT_NOT_FOUND` before any write when one + * doesn't — `sync_accounts` is otherwise the one account-scoped surface + * whose reference never passes through `accounts.resolve`. That is a floor, + * not a substitute for your own authorization: keep any per-entry gate you + * already run, and note the framework performs no such check for + * `'explicit'` / `'implicit'`, where the entry key is buyer-declared. + * + * **Rows must carry `brand` + `operator`** (`sync-accounts-response.json` + * requires them on every row, including `action: 'failed'`). Echo them from + * your own account record on settings-update results. If your upstream + * accounts have no expressible natural key, don't wire this method — see + * adcontextprotocol/adcp#7517. + * * In AdCP 3.1 beta, adopters that need settings-update entries with * `notification_configs[]` can read the full wire body from `ctx.input` * or supply a top-level `accounts.syncAccounts` handler. @@ -637,6 +763,13 @@ export interface AccountStore> { * adopter returns a loosely-typed row that spreads the input. Do not rely * on TypeScript narrowing alone — the strip is enforced at the dispatcher. * + * For `'derived'` platforms the framework resolves each entry's + * `account_id` against the caller's reachable set first and fails that row + * with `ACCOUNT_NOT_FOUND` when it doesn't resolve (natural-key references + * fail with `INVALID_REQUEST`), so entries reaching this method are + * credential-verified. That floor does not exist for `'explicit'` / + * `'implicit'`, where the entry key is buyer-declared. + * * `ctx.authInfo` and `ctx.agent` carry the caller's principal — same * threading as `upsert`. Adopters MUST gate per-entry persistence by the * caller's tenant: each entry's `account.operator` (or `account_id`) must @@ -654,7 +787,25 @@ export interface AccountStore> { /** * list_accounts API surface. Framework wraps with cursor envelope. * - * **Optional.** Same rationale as `upsert` — stateless platforms can omit. + * **Optional for `'explicit'` / `'implicit'`.** Same rationale as `upsert` + * — stateless platforms can omit. + * + * **Required for `'derived'`.** It is the discovery contract for an + * upstream-managed namespace: without it a buyer has no way to learn the + * `account_id` that every account-scoped call must carry. + * `createAdcpServerFromPlatform` throws `PlatformConfigError` when a + * `'derived'` store omits it. Credential-bound singletons still expose it + * (one row) so buyer SDKs can auto-select — per adcp#5062, sellers SHOULD + * return the singleton rather than omit the tool. If your ids are only ever + * issued out-of-band and there is nothing to enumerate, you are an + * `'explicit'` seller-defined namespace, not `'derived'`. + * + * Implementations MUST scope the page to what the caller's credential can + * reach — this is the enumeration boundary for the whole namespace — and + * own filtering and paging: the framework passes `request` through + * verbatim and only projects the `pagination` envelope from the returned + * `CursorPage`. (`createDerivedAccountStore` and `createRosterAccountStore` + * handle that for you.) * * `ctx.authInfo` and `ctx.agent` carry the caller's principal — adopters * scope the listing per-principal (e.g., return only accounts visible to @@ -956,7 +1107,7 @@ function projectAccountAuthorization(authorization: AccountAuthorization): Accou return projected; } -type WireSyncAccountRow = SyncAccountsSuccess['accounts'][number]; +export type WireSyncAccountRow = SyncAccountsSuccess['accounts'][number]; /** * Project an adopter `SyncAccountsResultRow` to the wire shape returned by @@ -1015,7 +1166,7 @@ function projectNotificationConfig(config: NotificationConfig): WireNotification return { ...rest, authentication: authenticationWithoutCredentials }; } -type WireSyncGovernanceRow = SyncGovernanceSuccess['accounts'][number]; +export type WireSyncGovernanceRow = SyncGovernanceSuccess['accounts'][number]; /** * Project a `sync_governance` response row to the wire shape, stripping @@ -1109,3 +1260,24 @@ function projectGovernanceAgent(agent: WireGovernanceAgent): WireGovernanceAgent export function refAccountId(ref?: AccountReference): string | undefined { return ref && 'account_id' in ref ? (ref as { account_id?: string }).account_id : undefined; } + +/** + * True when `ref` carries the `{ brand, operator }` natural-key arm of + * `AccountReference` — the durable reference shape for buyer-declared + * (`'implicit'`) accounts, and the shape an upstream-managed (`'derived'`) + * namespace refuses because the tuple is not a key into someone else's + * roster. + * + * Returns `false` for `undefined` refs and for `{ account_id }` refs. + * + * @public + */ +export function refHasNaturalKey(ref?: AccountReference): boolean { + if (ref === undefined) return false; + // Symmetric presence test on both arms. A malformed value (`brand: null`, + // `operator: 123`) still counts as "the buyer reached for the natural-key + // arm" — the modes that refuse that arm should refuse it rather than let a + // type-confused value slip past into the resolver when request validation + // is relaxed. + return (ref as { brand?: unknown }).brand !== undefined || (ref as { operator?: unknown }).operator !== undefined; +} diff --git a/src/lib/server/decisioning/decisioning.type-checks.ts b/src/lib/server/decisioning/decisioning.type-checks.ts index 33db1de4b..e951b6e69 100644 --- a/src/lib/server/decisioning/decisioning.type-checks.ts +++ b/src/lib/server/decisioning/decisioning.type-checks.ts @@ -340,7 +340,7 @@ function _account_not_found_throw_pattern(): Promise | n throw new AccountNotFoundError(); } -// ── AccountStore.resolution is 'explicit' | 'implicit' (or absent) ──── +// ── AccountStore.resolution modes ──────────────────────────────────── function _account_store_resolution_implicit(): Pick, 'resolution'> { return { resolution: 'implicit' }; @@ -355,6 +355,14 @@ function _account_store_resolution_invalid_value(): Pick, 'resolution'> { + // @ts-expect-error — 'derived' has exactly one spelling. No + // 'account-id-namespace' (it doesn't discriminate 'explicit' from + // 'derived') and no 'upstream-managed' (a second spelling silently + // defeats adopter `resolution === 'derived'` comparisons). + return { resolution: 'upstream-managed' }; +} + // ── Signals-only platforms omit media-buy fields ───────────────────── // Positive: a signals-only platform doesn't need creative_agents, channels, diff --git a/src/lib/server/decisioning/index.ts b/src/lib/server/decisioning/index.ts index a3e09d324..31b027c85 100644 --- a/src/lib/server/decisioning/index.ts +++ b/src/lib/server/decisioning/index.ts @@ -94,6 +94,8 @@ export { normalizePostalAreaSupport, normalizeTargetingCapabilities } from './ca // Account model export type { Account, + AccountResolutionMode, + CanonicalAccountResolutionMode, AuthPrincipal, AccountStore, AccountFilter, @@ -121,7 +123,13 @@ export type { ResolvedAuthInfo, } from './account'; -export { AccountNotFoundError, refAccountId } from './account'; +export { + AccountNotFoundError, + isAccountResolutionMode, + normalizeAccountResolution, + refAccountId, + refHasNaturalKey, +} from './account'; // Multi-tenant AccountStore builder. Bakes in the two-path resolution // (operator-routed + auth-derived) and the per-entry tenant-isolation gate diff --git a/src/lib/server/decisioning/runtime/from-platform.ts b/src/lib/server/decisioning/runtime/from-platform.ts index ee615bf92..853e2b4b3 100644 --- a/src/lib/server/decisioning/runtime/from-platform.ts +++ b/src/lib/server/decisioning/runtime/from-platform.ts @@ -72,12 +72,16 @@ import type { DecisioningPlatform, RequiredPlatformsFor, RequiredCapabilitiesFor import type { ComplianceTestingCapabilities } from '../capabilities'; import { normalizeTargetingCapabilities } from '../capabilities'; import { isAdcpVersionAtLeast } from '../../../utils/adcp-version-config'; -import type { Account, ResolvedAuthInfo, ResolveContext } from '../account'; +import type { Account, AccountResolutionMode, ResolvedAuthInfo, ResolveContext } from '../account'; import { AccountNotFoundError, + normalizeAccountResolution, refAccountId, + refHasNaturalKey, toWireAccount, toWireSyncAccountRow, + type WireSyncAccountRow, + type WireSyncGovernanceRow, toWireSyncGovernanceRow, type SyncAccountsResultRow, } from '../account'; @@ -1778,33 +1782,38 @@ function asProductResponseForWire( } /** - * Enforce the documented inline-`account_id` refusal for resolution modes - * that declare the field meaningless on the wire — `'implicit'` (since - * #1364) and `'derived'` (since adcp-client#1468). Both modes share the - * same wire contract: the buyer does not pass `account_id` inline; the - * framework derives the tenant from the authenticated principal (after a - * `sync_accounts` step for `'implicit'`; directly for `'derived'`). + * Enforce the account-reference shape each resolution mode declares durable + * on the wire, before the adopter's `accounts.resolve` runs. Keeps one + * consistent envelope across modes instead of every adopter reimplementing + * `if (ref?.account_id) return null`. * - * Throws `AdcpError('INVALID_REQUEST')` before reaching the adopter's - * `accounts.resolve`, so each adopter doesn't reimplement the same - * `if (ref?.account_id) return null` branch and the wire response is - * consistent across both modes. The brand+operator union arm is - * permitted — only `account_id`-shaped references are refused. + * - `'implicit'` (#1364) — buyer-declared accounts. The `{ brand, operator }` + * natural key is the durable reference; inline `account_id` is refused + * (`field: 'account.account_id'`) with `sync_accounts`-first guidance. + * - `'derived'` (#1647, upstream adcp#5062) — an upstream-managed account-id + * namespace. `account_id` is the durable reference and is **accepted**; + * the natural-key arm is refused (`field: 'account.brand'`) because + * `(brand, operator)` is not a key into a roster this agent doesn't own. + * Buyers recover with `list_accounts`. + * - `'explicit'` — seller-owned namespace; no shape constraint, both arms + * reach the resolver. * - * Mode-specific message and suggestion: `'implicit'` adopters get the - * `sync_accounts`-first guidance; `'derived'` adopters get the single- - * tenant explanation (no `sync_accounts` step exists in derived mode — - * the auth principal alone identifies the tenant). + * Before SDK 14 this function refused `account_id` for `'derived'` too. That + * was the inverted contract corrected by adcp-client#1647: it made the mode + * unusable for the adapters it exists for (the upstream hands out ids; the + * buyer had no way to send one back) and left `list_accounts` results + * un-referenceable. * - * Documented at `AccountStore.resolution` in `account.ts`. + * Throws `AdcpError('INVALID_REQUEST')`. Documented at + * `AccountStore.resolution` in `account.ts`. */ -function refuseInlineAccountIdWhenForbidden( - resolution: 'explicit' | 'implicit' | 'derived' | undefined, +function enforceAccountRefShapeForResolution( + resolution: AccountResolutionMode | undefined, ref: AccountReference | undefined ): void { - if (resolution !== 'implicit' && resolution !== 'derived') return; - if (refAccountId(ref) === undefined) return; - if (resolution === 'implicit') { + const mode = normalizeAccountResolution(resolution); + if (mode === 'implicit') { + if (refAccountId(ref) === undefined) return; throw new AdcpError('INVALID_REQUEST', { message: 'This platform resolves accounts from the authenticated principal — call sync_accounts first; do not pass account.account_id inline.', @@ -1813,14 +1822,79 @@ function refuseInlineAccountIdWhenForbidden( 'Call sync_accounts to associate accounts with your principal, then omit account_id on subsequent calls.', }); } + // 'derived' — upstream-managed account-id namespace. Branch explicitly + // rather than as an `else`: an unrecognized mode must not inherit this + // enforcement (it normalizes to `'explicit'`, which constrains nothing). + if (mode !== 'derived') return; + if (refAccountId(ref) !== undefined) return; + if (!refHasNaturalKey(ref)) return; throw new AdcpError('INVALID_REQUEST', { message: - 'This single-tenant agent identifies the tenant from the authenticated principal alone — do not pass account.account_id inline; the field is meaningless on the wire for derived-resolution agents.', - field: 'account.account_id', - suggestion: 'Omit the account field; the framework derives the tenant from your authenticated credential.', + 'This agent fronts an upstream-managed account namespace: accounts are addressed by account.account_id, ' + + 'not by the brand + operator natural key. The upstream owns the roster, so (brand, operator) is not a ' + + 'durable reference here.', + field: 'account.brand', + suggestion: + 'Call list_accounts to discover the account_id visible to your credential, then send account: { account_id }.', }); } +/** + * Recovery hint for an unresolved account, by resolution mode. The envelope + * itself stays the spec's fixed `ACCOUNT_NOT_FOUND` (no signal about whether + * the named account exists); only the remedy differs, and on an + * upstream-managed namespace the remedy is always the same one sentence. + */ +function accountNotFoundSuggestion(resolution: AccountResolutionMode | undefined): string | undefined { + switch (normalizeAccountResolution(resolution)) { + case 'derived': + return 'Call list_accounts to discover the accounts your credential can reach, then send account: { account_id }.'; + case 'implicit': + return 'Call sync_accounts to associate this brand + operator with your principal first.'; + default: + return undefined; + } +} + +/** + * Defense in depth for `resolution: 'derived'`: the account a resolver hands + * back for a buyer-named `{ account_id }` MUST be that account. + * + * On an upstream-managed namespace the id is a buyer-supplied claim, so + * verification is the resolver's job — but "verify the ref" is a contract an + * adopter can forget, and the pre-SDK-14 shape of this mode actively taught + * the opposite (the framework refused `account_id`, so ignoring `ref` and + * returning the one account was safe). An un-updated resolver would + * otherwise serve caller A's request against whatever account it returns, + * silently, the first time a deployment holds more than one. + * + * A mismatch resolves to `null`, which the callers map to the spec's fixed + * `ACCOUNT_NOT_FOUND` envelope — no signal about whether the named account + * exists. `createDerivedAccountStore` already fails closed here; this makes + * hand-rolled stores fail closed too. + */ +function assertResolvedAccountMatchesRef( + resolution: AccountResolutionMode | undefined, + ref: AccountReference | undefined, + account: T | null, + logger: AdcpLogger +): T | null { + if (account == null) return null; + if (normalizeAccountResolution(resolution) !== 'derived') return account; + const requestedId = refAccountId(ref); + if (requestedId === undefined || account.id === requestedId) return account; + if (process.env.NODE_ENV !== 'production') { + logger.warn( + `[adcp/sdk] accounts.resolve returned account '${account.id}' for a request naming ` + + `'${requestedId}' on a resolution: 'derived' platform. The framework is refusing it with ` + + `ACCOUNT_NOT_FOUND. A 'derived' resolver must look the buyer-supplied account_id up against ` + + `what the caller's credential can reach and return null on a miss — see AccountStore.resolve, ` + + `or use createDerivedAccountStore which does it for you.` + ); + } + return null; +} + /** * Dev-mode warning when a multi-id read tool returns fewer rows than * the buyer requested — the canonical signal that the platform is @@ -2506,6 +2580,36 @@ export function createAdcpServerFromPlatform

0; // Schema requires `supported_billing` (minItems: 1) whenever the account @@ -3027,15 +3146,19 @@ export function createAdcpServerFromPlatform

{ + const platformAccountHandlers = buildAccountHandlers(platform, ctxFor, fwLogger); + const merged = mergeHandlers(opts.accounts, platformAccountHandlers, 'accounts', mergeOpts); + return guardDerivedSyncGovernance( + guardAdopterSyncAccounts(merged, platform, platformAccountHandlers, fwLogger), + platform, + fwLogger + ); + })(), brandRights: mergeHandlers( runtimeLegacyHandlers.brandRights, buildBrandRightsHandlers(platform, ctxFor, effectiveCtxMetadata, fwLogger), @@ -3511,6 +3644,15 @@ export function createAdcpServerFromPlatform

>( let resolvedAccountId: string | undefined; let resolvedAccount: Account | undefined; if (ref) { - refuseInlineAccountIdWhenForbidden(platform.accounts.resolution, ref as AccountReference); + enforceAccountRefShapeForResolution(platform.accounts.resolution, ref as AccountReference); try { - const resolved = await platform.accounts.resolve(ref as AccountReference, resolveCtx); + const resolved = assertResolvedAccountMatchesRef( + platform.accounts.resolution, + ref as AccountReference, + await platform.accounts.resolve(ref as AccountReference, resolveCtx), + logger + ); if (resolved) { resolvedAccountId = resolved.id; resolvedAccount = resolved; } } catch (err) { + if (err instanceof AdcpError) { + // Typed adopter/framework errors carry their own buyer-facing + // verdict — `AUTH_REQUIRED` from a credential-gated store is the + // common one. Mapping those to SERVICE_UNAVAILABLE told an + // unauthenticated poller "server problem" instead of + // "authenticate", which is what the main dispatcher does. + throw err; + } if (!(err instanceof AccountNotFoundError)) { logger.error?.('Account resolution failed during tasks_get poll', { error: err instanceof Error ? err.message : String(err), @@ -3960,9 +4132,11 @@ function buildTasksGetTool

>( } } if (!resolvedAccountId) { + const suggestion = accountNotFoundSuggestion(platform.accounts.resolution); return adcpError('ACCOUNT_NOT_FOUND', { message: 'The specified account does not exist', field: 'account', + ...(suggestion !== undefined && { suggestion }), }); } } else { @@ -7902,6 +8076,473 @@ function entryHasAccountId(entry: SyncAccountsEntry): boolean { return entry.account !== undefined && refAccountId(entry.account) !== undefined; } +/** + * Per-entry refusal for `sync_accounts` against an upstream-managed + * account-id namespace (`resolution: 'derived'`), per adcp#5062. + * + * Natural-key provisioning — `{ brand, operator, billing }` at the entry + * root, or a settings-update entry whose `account` ref is the natural key — + * is out of scope for a namespace this agent doesn't own: there is nothing + * for the seller to provision and `(brand, operator)` doesn't address an + * upstream row. The spec's per-entry code for "the seller does not support + * the mode this entry requested" is `UNSUPPORTED_PROVISIONING`. + * + * Settings-update entries keyed by `account: { account_id }` are NOT + * refused: #5062 keeps that mode available when the seller exposes it, so + * an adopter who wires `accounts.upsert` still receives them. This is the + * narrower rule that replaced "derived forbids sync_accounts outright". + */ +/** + * Per-entry `UNSUPPORTED_PROVISIONING` for natural-key provisioning against + * an upstream-managed account namespace. Spec code for "the seller does not + * support the `sync_accounts` mode this entry requested" (adcp#5062). + */ +function buildUnsupportedProvisioningError(index: number): AdcpStructuredError { + return new AdcpError('UNSUPPORTED_PROVISIONING', { + message: + 'This agent fronts an upstream-managed account namespace: sync_accounts cannot provision ' + + 'accounts by the brand + operator natural key. Accounts are created on the upstream platform and ' + + 'discovered through list_accounts.', + recovery: 'correctable', + field: `accounts[${index}].brand`, + suggestion: + 'Call list_accounts to discover the account_id, then re-issue settings-update entries as ' + + 'account: { account_id } — or drop the entry if you only meant to provision.', + }).toStructuredError(); +} + +/** + * Normalize a `sync_accounts` entry to the single account reference it + * carries — or report that it carries none, or more than one. + * + * The schema models entry keys as a per-entry `oneOf` (provisioning trio at + * the root XOR an `account` ref), but request validation is relaxable, and a + * gate that reads one location while a later step reads another is a + * bypass: an entry carrying a root `account_id` *and* a nested + * `account: { brand, operator }` would be accepted on the id it never uses + * and then written against the natural key nobody verified. So the + * reference location is resolved exactly once, here, and anything + * ambiguous is refused rather than disambiguated by precedence. + */ +function normalizeSyncAccountsReference( + entry: SyncAccountsEntry +): + | { kind: 'account_id'; accountId: string } + | { kind: 'natural_key_provisioning' } + | { kind: 'natural_key_reference' } + | { kind: 'ambiguous' } + | { kind: 'none' } { + const rootAccountId = refAccountId(entry); + const rootNaturalKey = entry.brand !== undefined || entry.operator !== undefined; + const nested = entry.account; + const nestedAccountId = refAccountId(nested); + const nestedNaturalKey = refHasNaturalKey(nested); + + const locations = [rootAccountId !== undefined, rootNaturalKey, nested !== undefined].filter(Boolean).length; + if (locations > 1) return { kind: 'ambiguous' }; + if (nestedAccountId !== undefined && nestedNaturalKey) return { kind: 'ambiguous' }; + + if (rootAccountId !== undefined) return { kind: 'account_id', accountId: rootAccountId }; + if (rootNaturalKey) return { kind: 'natural_key_provisioning' }; + if (nestedAccountId !== undefined) return { kind: 'account_id', accountId: nestedAccountId }; + if (nestedNaturalKey) return { kind: 'natural_key_reference' }; + return { kind: 'none' }; +} + +/** + * Classify one `sync_accounts` entry against the upstream-managed + * (`resolution: 'derived'`) account model, per adcp#5062. + * + * - exactly one `account_id` (root or under `account`) — settings-update + * mode. Accepted; the seller's own `upsert` decides whether it supports + * the write. The resolved id is returned so the reachability check reads + * the same reference this classification accepted. + * - flat `{ brand, operator, billing }` — natural-key provisioning. Refused + * with the spec's per-entry `UNSUPPORTED_PROVISIONING`: there is nothing + * to provision in a namespace the agent doesn't own. + * - `account: { brand, operator }` — a natural-key *reference* into an + * account-id namespace. Refused as `INVALID_REQUEST` (not + * `UNSUPPORTED_PROVISIONING`, which is about the mode, not the ref shape) + * with the same field pointer discipline as the task-level refusal. + * - more than one reference, or none — malformed against the request + * schema's per-entry `oneOf`. `INVALID_REQUEST`, raised at operation level + * when no response row can be built for it (`sync-accounts-response.json` + * requires `brand` + `operator` on every row). + */ +function classifyUpstreamManagedSyncEntry( + entry: SyncAccountsEntry, + index: number +): { accepted: true; accountId: string } | { accepted: false; error: AdcpStructuredError } { + const reference = normalizeSyncAccountsReference(entry); + + switch (reference.kind) { + case 'account_id': + return { accepted: true, accountId: reference.accountId }; + + case 'natural_key_reference': + return { + accepted: false, + error: new AdcpError('INVALID_REQUEST', { + message: + 'This agent fronts an upstream-managed account namespace: accounts are addressed by ' + + 'account.account_id, not by the brand + operator natural key.', + recovery: 'correctable', + field: `accounts[${index}].account.brand`, + suggestion: 'Call list_accounts to discover the account_id, then send account: { account_id }.', + }).toStructuredError(), + }; + + case 'natural_key_provisioning': + if (entryBrandOperator(entry) === undefined) { + // Half a natural key (brand without operator, or vice versa). That's + // a malformed entry, not an unsupported mode — and no response row + // can be built for it, so it's operation-level. + throw new AdcpError('INVALID_REQUEST', { + message: `sync_accounts entry ${index} carries an incomplete natural key: brand and operator are both required.`, + recovery: 'correctable', + field: `accounts[${index}]`, + suggestion: 'Send account: { account_id } — this agent addresses accounts by id.', + }); + } + return { accepted: false, error: buildUnsupportedProvisioningError(index) }; + + case 'ambiguous': + // Never disambiguate by precedence — the entry claims two account + // references and we cannot know which one the buyer meant to write to. + throw new AdcpError('INVALID_REQUEST', { + message: + `sync_accounts entry ${index} carries more than one account reference. Send exactly one: either ` + + `a brand + operator natural key at the entry root, or account: { account_id }.`, + recovery: 'correctable', + field: `accounts[${index}]`, + suggestion: 'Drop the extra reference — this agent addresses accounts by account.account_id.', + }); + + default: + throw new AdcpError('INVALID_REQUEST', { + message: `sync_accounts entry ${index} carries neither a brand + operator natural key nor an account reference.`, + recovery: 'correctable', + field: `accounts[${index}]`, + suggestion: 'Send account: { account_id } — this agent addresses accounts by id.', + }); + } +} + +/** + * Verify every accepted (`account_id`-keyed) `sync_accounts` entry against + * what the caller's credential can actually reach, before any write runs. + * + * `sync_accounts` and `sync_governance` are the two account-scoped surfaces + * that never called `accounts.resolve` — the account reference travels + * inside the batch rather than as the request's `account`. On an + * upstream-managed namespace `account_id` is now the *only* accepted entry + * shape, so without this every accepted entry would be an unverified, + * buyer-chosen write target (settings, payment terms, notification webhook + * destinations) on someone else's account. + * + * Operation-level `ACCOUNT_NOT_FOUND` rather than a per-entry row: the + * response row schema requires `brand` + `operator`, which by definition we + * don't have for an account we just refused to resolve. Failing the batch + * also means no partial writes ran before the refusal. + */ +async function assertDerivedSyncAccountEntriesReachable( + accounts: DecisioningPlatform['accounts'], + entries: readonly SyncAccountsEntry[], + resolveCtx: ResolveContext, + logger: AdcpLogger +): Promise { + for (const [index, entry] of entries.entries()) { + // Re-normalize rather than trusting a caller-passed id: this and the + // classification above must read the same reference, or the gate can + // accept on one claim and write against another. + const verdict = classifyUpstreamManagedSyncEntry(entry, index); + if (!verdict.accepted) { + // Unreachable via the two call sites (both filter refusals out first); + // fail closed rather than silently skipping an unverified entry. + throw new AdcpError(verdict.error.code, { + message: verdict.error.message, + recovery: verdict.error.recovery, + ...(verdict.error.field !== undefined && { field: verdict.error.field }), + ...(verdict.error.suggestion !== undefined && { suggestion: verdict.error.suggestion }), + }); + } + let resolved: Account | null = null; + try { + resolved = assertResolvedAccountMatchesRef( + accounts.resolution, + { account_id: verdict.accountId }, + await accounts.resolve({ account_id: verdict.accountId }, resolveCtx), + logger + ); + } catch (err) { + if (!(err instanceof AccountNotFoundError)) throw err; + resolved = null; + } + if (resolved == null) { + // Unindexed pointer on purpose: this helper sees the post-refusal + // subset, so an index here would not be the buyer's request index. + throw new AdcpError('ACCOUNT_NOT_FOUND', { + message: 'The specified account does not exist', + recovery: 'terminal', + field: 'accounts[].account.account_id', + suggestion: 'Call list_accounts to discover the accounts your credential can reach.', + }); + } + } +} + +/** + * Apply the upstream-managed account gate to an **adopter-supplied** + * `sync_accounts` handler (`opts.accounts.syncAccounts` at the merge seam). + * + * The platform-derived handler applies the same rules inside + * `enforceSyncAccountsCommercialPolicy`, where it can keep per-entry error + * indices aligned with the buyer's request. This wrapper exists because the + * merge seam is a documented wiring: gating only the platform path left an + * adopter handler receiving natural-key provisioning entries the contract + * says it never sees. + * + * Refused entries never reach the inner handler — the refusal is a key-shape + * verdict, so no provisioning side effects may run for them. Their rows are + * spliced back at their original request indices so the response stays + * positionally aligned with `accounts[]`. The inner handler sees a filtered + * array, so any index it reports in its own per-entry errors is relative to + * that array; adopters who need request-aligned indices should key errors + * off the entry's account reference instead. + */ +function guardAdopterSyncAccounts( + handlers: AccountHandlers | undefined, + platform: DecisioningPlatform, + platformDerived: AccountHandlers, + logger: AdcpLogger +): AccountHandlers | undefined { + const inner = handlers?.syncAccounts; + if (handlers === undefined || inner === undefined) return handlers; + if (normalizeAccountResolution(platform.accounts.resolution) !== 'derived') return handlers; + // Platform-derived handler won the merge — its own policy pass already + // applies the gate with request-aligned indices. Don't double-wrap. + if ((inner as unknown) === (platformDerived.syncAccounts as unknown)) return handlers; + + return { + ...handlers, + syncAccounts: async (params, ctx) => { + const entries = (Array.isArray(params.accounts) ? params.accounts : []) as SyncAccountsEntry[]; + const refusedRows = new Map(); + const accepted: SyncAccountsEntry[] = []; + entries.forEach((entry, index) => { + const verdict = classifyUpstreamManagedSyncEntry(entry, index); + if (verdict.accepted) { + accepted.push(entry); + return; + } + refusedRows.set(index, toWireSyncAccountRow(failedSyncAccountRow(entry, verdict.error))); + }); + + const resolveCtx = toResolveCtx(ctx, 'sync_accounts', params as Record); + await assertDerivedSyncAccountEntriesReachable(platform.accounts, accepted, resolveCtx, logger); + + if (refusedRows.size === 0) return inner(params, ctx); + + const dryRun = (params as { dry_run?: unknown }).dry_run === true; + if (accepted.length === 0) { + return { + ...(dryRun && { dry_run: true }), + accounts: entries.map((_entry, index) => refusedRows.get(index)!), + } as Awaited['syncAccounts']>>>; + } + + const result = await inner({ ...params, accounts: accepted } as typeof params, ctx); + const rows = readSyncAccountRows(result); + if (rows === undefined) { + // The inner handler already failed the operation (error envelope) — + // there is nothing to splice into and the whole batch failed anyway; + // surface its verdict unchanged. + if (isErrorShapedResult(result)) return result; + throw new Error( + 'sync_accounts: cannot apply the upstream-managed account gate — the handler returned no accounts[] ' + + 'array to merge the per-entry refusal rows into.' + ); + } + if (rows.length !== accepted.length) { + throw new Error( + `sync_accounts: handler returned ${rows.length} row(s) for ${accepted.length} accepted entry/entries; ` + + 'cannot realign the per-entry refusal rows with the request.' + ); + } + let acceptedIndex = 0; + const combined = entries.map((_entry, index) => refusedRows.get(index) ?? rows[acceptedIndex++]!); + // New objects throughout: adopter handlers legitimately return cached + // or frozen results (the standard idempotency-replay shape), and + // splicing into their array would corrupt the cache. + return withSyncAccountRows(result, combined); + }, + }; +} + +/** + * Apply the upstream-managed account gate to the merged `sync_governance` + * handler (`resolution: 'derived'` only). + * + * Same reasoning as `sync_accounts`: the account reference travels inside + * the batch, so `accounts.resolve` never ran for it, and `sync_governance` + * writes the governance agent that authorizes spend on the account. Every + * entry is therefore verified against what the caller's credential can + * reach before the seller persists anything. + * + * Per-entry here (unlike `sync_accounts`) because the response row echoes + * the `account` reference — `sync-governance-response.json` needs no + * natural key — so a refusal is expressible without inventing one. Refused + * entries never reach the inner handler; their rows are spliced back at + * their original request indices. + */ +function guardDerivedSyncGovernance( + handlers: AccountHandlers | undefined, + platform: DecisioningPlatform, + logger: AdcpLogger +): AccountHandlers | undefined { + const inner = handlers?.syncGovernance; + if (handlers === undefined || inner === undefined) return handlers; + if (normalizeAccountResolution(platform.accounts.resolution) !== 'derived') return handlers; + + return { + ...handlers, + syncGovernance: async (params, ctx) => { + const entries = (Array.isArray(params.accounts) ? params.accounts : []) as SyncGovernanceRequest['accounts']; + const resolveCtx = toResolveCtx(ctx, 'sync_governance', params as Record); + const refusedRows = new Map(); + const accepted: SyncGovernanceRequest['accounts'] = []; + + for (const [index, entry] of entries.entries()) { + const ref = entry.account as AccountReference | undefined; + const accountId = refAccountId(ref); + // An entry claiming both arms is refused rather than resolved by + // precedence: we would verify the id while the adopter routes on the + // natural key (or vice versa). Same rule as `sync_accounts`. + if (accountId !== undefined && refHasNaturalKey(ref)) { + refusedRows.set(index, { + account: ref as WireSyncGovernanceRow['account'], + status: 'failed', + errors: [ + new AdcpError('INVALID_REQUEST', { + message: + `sync_governance entry ${index} carries both an account_id and a brand + operator natural ` + + `key. Send exactly one — this agent addresses accounts by account_id.`, + recovery: 'correctable', + field: `accounts[${index}].account`, + suggestion: 'Drop the brand + operator fields and send account: { account_id }.', + }).toStructuredError(), + ], + }); + continue; + } + if (accountId === undefined) { + refusedRows.set(index, { + account: ref as WireSyncGovernanceRow['account'], + status: 'failed', + errors: [ + new AdcpError('INVALID_REQUEST', { + message: + 'This agent fronts an upstream-managed account namespace: accounts are addressed by ' + + 'account.account_id, not by the brand + operator natural key.', + recovery: 'correctable', + field: `accounts[${index}].account.brand`, + suggestion: 'Call list_accounts to discover the account_id, then send account: { account_id }.', + }).toStructuredError(), + ], + }); + continue; + } + let resolved: Account | null = null; + try { + resolved = assertResolvedAccountMatchesRef( + platform.accounts.resolution, + { account_id: accountId }, + await platform.accounts.resolve({ account_id: accountId }, resolveCtx), + logger + ); + } catch (err) { + if (!(err instanceof AccountNotFoundError)) throw err; + } + if (resolved == null) { + refusedRows.set(index, { + account: ref as WireSyncGovernanceRow['account'], + status: 'failed', + errors: [ + new AdcpError('ACCOUNT_NOT_FOUND', { + message: 'The specified account does not exist', + recovery: 'terminal', + field: `accounts[${index}].account.account_id`, + suggestion: 'Call list_accounts to discover the accounts your credential can reach.', + }).toStructuredError(), + ], + }); + continue; + } + accepted.push(entry); + } + + if (refusedRows.size === 0) return inner(params, ctx); + if (accepted.length === 0) { + return { accounts: entries.map((_entry, index) => refusedRows.get(index)!) } as Awaited< + ReturnType['syncGovernance']>> + >; + } + + const result = await inner({ ...params, accounts: accepted } as typeof params, ctx); + const rows = readSyncAccountRows(result); + if (rows === undefined) { + if (isErrorShapedResult(result)) return result; + throw new Error( + 'sync_governance: cannot apply the upstream-managed account gate — the handler returned no accounts[] ' + + 'array to merge the per-entry refusal rows into.' + ); + } + if (rows.length !== accepted.length) { + throw new Error( + `sync_governance: handler returned ${rows.length} row(s) for ${accepted.length} accepted entry/entries; ` + + 'cannot realign the per-entry refusal rows with the request.' + ); + } + let acceptedIndex = 0; + const combined = entries.map((_entry, index) => refusedRows.get(index) ?? rows[acceptedIndex++]!); + return withSyncAccountRows(result, combined); + }, + }; +} + +/** + * Read the `accounts[]` row array off a `sync_accounts` handler result — + * either a bare result object or an MCP tool response carrying + * `structuredContent`. + */ +function readSyncAccountRows(result: unknown): readonly unknown[] | undefined { + if (!isPlainObject(result)) return undefined; + if (Array.isArray(result.accounts)) return result.accounts; + const structured = (result as { structuredContent?: unknown }).structuredContent; + if (isPlainObject(structured) && Array.isArray(structured.accounts)) return structured.accounts; + return undefined; +} + +/** Rebuild a handler result with a replacement `accounts[]` array. */ +function withSyncAccountRows(result: T, rows: readonly unknown[]): T { + if (!isPlainObject(result)) return result; + if (Array.isArray(result.accounts)) return { ...result, accounts: [...rows] } as T; + const structured = (result as { structuredContent?: unknown }).structuredContent; + if (isPlainObject(structured) && Array.isArray(structured.accounts)) { + return { ...result, structuredContent: { ...structured, accounts: [...rows] } } as T; + } + return result; +} + +/** True when a handler result is already an error envelope. */ +function isErrorShapedResult(result: unknown): boolean { + if (!isPlainObject(result)) return false; + if (result.isError === true) return true; + if (result.adcp_error !== undefined) return true; + const structured = (result as { structuredContent?: unknown }).structuredContent; + return isPlainObject(structured) && structured.adcp_error !== undefined; +} + function failedSyncAccountRow(entry: SyncAccountsEntry, error: AdcpStructuredError): SyncAccountsResultRow { const key = entryBrandOperator(entry); if (key === undefined) { @@ -7965,7 +8606,22 @@ function enforceSyncAccountsCommercialPolicy

(); const acceptedEntries: SyncAccountsEntry[] = []; + const isUpstreamManagedNamespace = normalizeAccountResolution(platform.accounts.resolution) === 'derived'; + entries.forEach((entry, index) => { + // Account-model gate first: an entry is refused on its key shape + // regardless of whether its billing values would have passed. Per-entry + // (not operation-level) so a mixed batch still applies the entries the + // seller can honor, and evaluated here — inside the policy pass — so the + // `accounts[i]` pointers stay aligned with the buyer's request array. + if (isUpstreamManagedNamespace) { + const verdict = classifyUpstreamManagedSyncEntry(entry, index); + if (!verdict.accepted) { + failedRows.set(index, failedSyncAccountRow(entry, verdict.error)); + return; + } + } + const hasBillableFields = entry.billing !== undefined || entry.payment_terms !== undefined || entry.billing_entity !== undefined; if (hasBillableFields && entryBrandOperator(entry) === undefined && !entryHasAccountId(entry)) { @@ -8024,7 +8680,8 @@ function enforceSyncAccountsCommercialPolicy

>( platform: P, - ctxFor: CtxForFn + ctxFor: CtxForFn, + logger: AdcpLogger = DEFAULT_FRAMEWORK_LOGGER ): AccountHandlers { const accounts = platform.accounts; @@ -8044,6 +8701,9 @@ function buildAccountHandlers

>( handlers.syncAccounts = async (params, ctx) => { const resolveCtx = toResolveCtx(ctx, 'sync_accounts', params); const policy = enforceSyncAccountsCommercialPolicy(platform, params as Record, resolveCtx); + if (normalizeAccountResolution(accounts.resolution) === 'derived') { + await assertDerivedSyncAccountEntriesReachable(accounts, policy.acceptedEntries, resolveCtx, logger); + } const dispatchCtx = policy.failedRows.size === 0 ? resolveCtx : toResolveCtx(ctx, 'sync_accounts', policy.acceptedParams); return projectSync( @@ -8136,12 +8796,19 @@ function buildAccountHandlers

>( const request = params as ListAccountChangesRequest; const resolveCtx = toResolveCtx(ctx, 'list_account_changes', params); const accountRef = asValidatedDomainRequest(params.account); - refuseInlineAccountIdWhenForbidden(accounts.resolution, accountRef); - const resolved = await accounts.resolve(accountRef, resolveCtx); + enforceAccountRefShapeForResolution(accounts.resolution, accountRef); + const resolved = assertResolvedAccountMatchesRef( + accounts.resolution, + accountRef, + await accounts.resolve(accountRef, resolveCtx), + logger + ); if (!resolved) { + const suggestion = accountNotFoundSuggestion(accounts.resolution); throw new AdcpError('ACCOUNT_NOT_FOUND', { message: 'Account not found', recovery: 'terminal', + ...(suggestion !== undefined && { suggestion }), }); } const account = cloneAccountForRequest(resolved); @@ -8172,12 +8839,19 @@ function buildAccountHandlers

>( // having to re-resolve from `params.account`. const resolveCtx = toResolveCtx(ctx, 'get_account_financials', params); const accountRef = asValidatedDomainRequest(params.account); - refuseInlineAccountIdWhenForbidden(accounts.resolution, accountRef); - const resolved = await accounts.resolve(accountRef, resolveCtx); + enforceAccountRefShapeForResolution(accounts.resolution, accountRef); + const resolved = assertResolvedAccountMatchesRef( + accounts.resolution, + accountRef, + await accounts.resolve(accountRef, resolveCtx), + logger + ); if (!resolved) { + const suggestion = accountNotFoundSuggestion(accounts.resolution); throw new AdcpError('ACCOUNT_NOT_FOUND', { message: 'Account not found', recovery: 'terminal', + ...(suggestion !== undefined && { suggestion }), }); } // Request-local clone: refreshToken mutates account.authInfo before the diff --git a/src/lib/server/decisioning/runtime/to-context.ts b/src/lib/server/decisioning/runtime/to-context.ts index 641322af7..c76f2eebb 100644 --- a/src/lib/server/decisioning/runtime/to-context.ts +++ b/src/lib/server/decisioning/runtime/to-context.ts @@ -226,8 +226,9 @@ export function buildRequestContext>( // — adopters writing handlers for the 90% case (tools with `account` on // the wire) shouldn't have to optional-chain everywhere. Adopters of // no-account tools either: - // 1. Declare `resolution: 'derived'` and return a singleton from - // `accounts.resolve(undefined)` — `ctx.account` is always set + // 1. Declare `resolution: 'derived'` and resolve the credential's + // single reachable account from `accounts.resolve(undefined)` — + // `ctx.account` is set whenever that credential reaches exactly one // 2. Implement only `'explicit'` and never claim no-account // specialisms — the tool is unreachable // 3. Read `ctx.account` defensively (`as Account | undefined` cast) diff --git a/src/lib/server/decisioning/runtime/validate-platform.ts b/src/lib/server/decisioning/runtime/validate-platform.ts index 5ec472817..1a763741a 100644 --- a/src/lib/server/decisioning/runtime/validate-platform.ts +++ b/src/lib/server/decisioning/runtime/validate-platform.ts @@ -13,6 +13,7 @@ */ import type { DecisioningPlatform } from '../platform'; +import { isAccountResolutionMode } from '../account'; import type { AdCPSpecialism } from '../../../types/tools.generated'; // Sales specialisms require channels + pricingModels on capabilities because @@ -167,6 +168,20 @@ export function validatePlatform( } } + // 3. `resolution` must be a recognized mode. + // + // Untyped adopters can put anything here, and a typo must not silently + // inherit a different mode's wire enforcement — `'Derived'` or `'implict'` + // would otherwise normalize to `'explicit'` and quietly drop the + // reference-shape contract the adopter meant to declare. + const declaredResolution = platform.accounts?.resolution; + if (declaredResolution !== undefined && !isAccountResolutionMode(declaredResolution)) { + errors.push( + `accounts.resolution: '${String(declaredResolution)}' is not a recognized resolution mode ` + + `('explicit' | 'implicit' | 'derived')` + ); + } + if (errors.length > 0) { throw new PlatformConfigError(`DecisioningPlatform configuration is incomplete:\n - ${errors.join('\n - ')}`); } diff --git a/src/lib/server/decisioning/specialisms/sales.ts b/src/lib/server/decisioning/specialisms/sales.ts index 4cbb8a6b3..365ba85c4 100644 --- a/src/lib/server/decisioning/specialisms/sales.ts +++ b/src/lib/server/decisioning/specialisms/sales.ts @@ -43,9 +43,9 @@ * `ctx.account` may be `undefined` when `accounts.resolution === 'explicit'`. * Three safe patterns: * - * 1. **`'derived'` resolution** — `accounts.resolve(undefined)` returns a - * singleton; `ctx.account` is always set. Best for single-tenant - * deployers. + * 1. **`'derived'` resolution** — `accounts.resolve(undefined)` returns the + * credential's single reachable account; `ctx.account` is set whenever + * the credential is bound to exactly one upstream account. * 2. **Don't implement the method** — the framework returns * `UNSUPPORTED_FEATURE`; buyers using the merge-seam custom handler or * external creative agents still receive a response. diff --git a/src/lib/server/index.ts b/src/lib/server/index.ts index 98b246abc..7271170b9 100644 --- a/src/lib/server/index.ts +++ b/src/lib/server/index.ts @@ -777,7 +777,13 @@ export { export { createRosterAccountStore, type RosterAccountStoreOptions } from '../adapters/roster-account-store'; -export { createDerivedAccountStore, type DerivedAccountStoreOptions } from '../adapters/derived-account-store'; +export { + createDerivedAccountStore, + type DerivedAccountStoreOptions, + type DerivedAccountStoreBaseOptions, + type DerivedSingletonAccountStoreOptions, + type DerivedRosterAccountStoreOptions, +} from '../adapters/derived-account-store'; // --------------------------------------------------------------------------- // Socket Mode — outbound WebSocket bridge for adopter dev environments diff --git a/src/lib/testing/compliance/spec-conformance.ts b/src/lib/testing/compliance/spec-conformance.ts index ce7f6fa0d..00e9c62d8 100644 --- a/src/lib/testing/compliance/spec-conformance.ts +++ b/src/lib/testing/compliance/spec-conformance.ts @@ -19,7 +19,18 @@ * agent (any specialism in `sales-*` / `audience-sync` / `governance-*`) * MUST advertise at least one of `list_accounts` or `sync_accounts`. * Spec normative as of AdCP 3.0.9; the requirement existed before the - * explicit MUST landed. The runner honors AdCP 3.1 + * explicit MUST landed. + * + * Deliberately NOT tightened for account-id namespaces (adcp-client#1647). + * adcp#5062 makes `list_accounts` a MUST only when a credential can reach + * more than one account, and a SHOULD (with an explicit out-of-band + * escape) for credential-bound singletons — neither condition is + * observable from a static `get_adcp_capabilities` read, and + * `require_operator_auth: true` covers seller-defined namespaces that + * legitimately hand out ids out-of-band. Keying a run-blocking gate on + * that bit would fail conformant sellers. The SDK enforces the stricter + * rule where it *is* knowable — at `createAdcpServerFromPlatform`, where + * a declared `resolution: 'derived'` requires `list_accounts`. The runner honors AdCP 3.1 * `required_any_of_tools` tags when present, but this fallback remains * until upstream storyboards carry the tag consistently. */ @@ -91,6 +102,24 @@ export function isAccountBearingSpecialism(specialism: string): boolean { * storyboards in `compliance/cache/` carry `required_any_of_tools` * consistently for every account-bearing scenario. */ +/** + * True when the agent's capabilities declare an account-id namespace — + * `account.require_operator_auth: true`. Read from `raw_capabilities` + * because the normalized `AgentProfile` doesn't carry the account block. + * + * Used only to steer the failure *message* toward the right remedy. It + * deliberately does not change whether the gate fires — see the + * module-level note on why the account-id-namespace rule isn't enforced + * from a static capability read. + */ +function declaresAccountIdNamespace(profile: AgentProfile): boolean { + const raw = profile.raw_capabilities; + if (raw === null || typeof raw !== 'object') return false; + const account = (raw as { account?: unknown }).account; + if (account === null || typeof account !== 'object') return false; + return (account as { require_operator_auth?: unknown }).require_operator_auth === true; +} + export function checkAccountDiscoveryGate(profile: AgentProfile, agentUrl: string): StoryboardResult | null { const accountBearing = (profile.specialisms ?? []).filter(isAccountBearingSpecialism); if (accountBearing.length === 0) return null; @@ -103,7 +132,11 @@ export function checkAccountDiscoveryGate(profile: AgentProfile, agentUrl: strin const detail = `Agent declared account-bearing specialism(s) [${accountBearing.join(', ')}] but advertises ` + `neither list_accounts nor sync_accounts. AdCP 3.0.9 §accounts/overview requires every seller ` + - `agent to expose at least one of these tools. Agent tools: [${profile.tools.join(', ')}].`; + `agent to expose at least one of these tools. Agent tools: [${profile.tools.join(', ')}].` + + (declaresAccountIdNamespace(profile) + ? ` This agent declares account.require_operator_auth: true, so list_accounts is the discovery ` + + `tool that fits its account model — buyers reference these accounts by account_id.` + : ''); return { storyboard_id: ACCOUNT_DISCOVERY_GATE_STORYBOARD_ID, diff --git a/src/lib/testing/storyboard/runner.ts b/src/lib/testing/storyboard/runner.ts index 59f390440..fd6ecf7de 100644 --- a/src/lib/testing/storyboard/runner.ts +++ b/src/lib/testing/storyboard/runner.ts @@ -4816,8 +4816,8 @@ async function executeStep( }; } - // Account-mode capability gate: when the seller declared explicit mode - // (require_operator_auth: true) and does not advertise sync_accounts, + // Account-mode capability gate: when the seller declared an account-id + // namespace (require_operator_auth: true) and does not advertise sync_accounts, // sync_accounts does not apply — grade not_applicable rather than // missing_tool so adopters can distinguish "your capability declaration // says this path isn't yours" from "you forgot to implement a required @@ -4837,7 +4837,7 @@ async function executeStep( const syncAccountsAdvertised = options.agentTools?.includes('sync_accounts') === true; if (requireOperatorAuth === true && !syncAccountsAdvertised) { const detail = - `Agent declared explicit account mode (require_operator_auth: true); ` + + `Agent declared an account-id namespace (require_operator_auth: true); ` + `sync_accounts is not applicable — list_accounts is the correct tool for this account shape.`; const next = getNextStepPreview(step.id, allSteps, context, runState.runnerVars); return { diff --git a/test/derived-account-store.test.js b/test/derived-account-store.test.js index 8416eb15c..8c919b39f 100644 --- a/test/derived-account-store.test.js +++ b/test/derived-account-store.test.js @@ -1,10 +1,11 @@ 'use strict'; // `createDerivedAccountStore` reference adapter — Shape D for `resolution: -// 'derived'` single-tenant agents (no account_id on the wire; auth principal -// alone identifies the tenant). Covers the resolution declaration, AUTH_REQUIRED -// gate, ctx-threading into toAccount, ignored buyer-supplied account_id, and -// the omission of write/list paths. Closes adcp-client#1462. +// 'derived'`, an upstream-managed account-id namespace. Covers the resolution +// declaration, the AUTH_REQUIRED gate, ctx threading, verified resolution of +// buyer-supplied `account_id` (the tenant-isolation contract), singleton +// auto-selection on ref-less calls, and the `list_accounts` surface the mode +// requires. Closes adcp-client#1462; reworked by adcp-client#1647. const { describe, it } = require('node:test'); const assert = require('node:assert/strict'); @@ -25,40 +26,65 @@ const apiKeyCtx = keyId => ({ }, }); -describe('createDerivedAccountStore (#1462)', () => { +const account = (id, extra = {}) => ({ id, name: id, status: 'active', ctx_metadata: {}, ...extra }); + +const singletonStore = (id = 'audiostack', opts = {}) => + createDerivedAccountStore({ toAccount: () => account(id), ...opts }); + +describe('createDerivedAccountStore (#1462, reworked by #1647)', () => { it('declares resolution: derived', () => { - const store = createDerivedAccountStore({ - toAccount: () => ({ id: 'x', name: 'x', status: 'active', ctx_metadata: {} }), - }); - assert.equal(store.resolution, 'derived'); + assert.equal(singletonStore().resolution, 'derived'); }); - it('omits list/upsert/refreshToken/getAccountFinancials/reportUsage', () => { - const store = createDerivedAccountStore({ - toAccount: () => ({ id: 'x', name: 'x', status: 'active', ctx_metadata: {} }), - }); - assert.equal(store.list, undefined); + it('wires list (required for derived) and omits upsert/refreshToken/getAccountFinancials/reportUsage', () => { + const store = singletonStore(); + assert.equal(typeof store.list, 'function'); assert.equal(store.upsert, undefined); assert.equal(store.refreshToken, undefined); assert.equal(store.getAccountFinancials, undefined); assert.equal(store.reportUsage, undefined); }); - describe('resolve', () => { - it('returns the singleton account from toAccount on authenticated calls', async () => { + describe('option validation', () => { + it('refuses construction with neither toAccount nor listAccounts', () => { + assert.throws(() => createDerivedAccountStore({}), /toAccount .* or listAccounts/); + }); + + it('refuses construction with both', () => { + assert.throws( + () => createDerivedAccountStore({ toAccount: () => account('a'), listAccounts: () => [account('a')] }), + /not both/ + ); + }); + + it('refuses lookupAccount alongside toAccount', () => { + assert.throws( + () => createDerivedAccountStore({ toAccount: () => account('a'), lookupAccount: () => account('a') }), + /lookupAccount applies to listAccounts/ + ); + }); + }); + + describe('resolve — singleton (toAccount)', () => { + it('returns the account for ref-less calls', async () => { const store = createDerivedAccountStore({ - toAccount: ctx => ({ - id: 'audiostack', - name: 'AudioStack', - status: 'active', - ctx_metadata: { tenantId: ctx?.authInfo?.credential?.client_id ?? null }, - }), + toAccount: ctx => account('audiostack', { ctx_metadata: { tenantId: ctx?.authInfo?.credential?.client_id } }), }); - const account = await store.resolve(undefined, oauthCtx('buyer-xyz')); - assert.ok(account); - assert.equal(account.id, 'audiostack'); - assert.equal(account.name, 'AudioStack'); - assert.deepEqual(account.ctx_metadata, { tenantId: 'buyer-xyz' }); + const resolved = await store.resolve(undefined, oauthCtx('buyer-xyz')); + assert.ok(resolved); + assert.equal(resolved.id, 'audiostack'); + assert.deepEqual(resolved.ctx_metadata, { tenantId: 'buyer-xyz' }); + }); + + it('accepts a matching buyer-supplied account_id', async () => { + const resolved = await singletonStore().resolve({ account_id: 'audiostack' }, oauthCtx('b1')); + assert.ok(resolved); + assert.equal(resolved.id, 'audiostack'); + }); + + it('fails closed on a non-matching account_id instead of serving the singleton', async () => { + const resolved = await singletonStore().resolve({ account_id: 'someone-elses-account' }, oauthCtx('b1')); + assert.equal(resolved, null, 'a mismatched id must not be silently serviced against the one account we have'); }); it('passes ctx through to toAccount', async () => { @@ -66,7 +92,7 @@ describe('createDerivedAccountStore (#1462)', () => { const store = createDerivedAccountStore({ toAccount: ctx => { seenCtx = ctx; - return { id: 'x', name: 'x', status: 'active', ctx_metadata: {} }; + return account('x'); }, }); const ctx = apiKeyCtx('key-1'); @@ -74,12 +100,100 @@ describe('createDerivedAccountStore (#1462)', () => { assert.equal(seenCtx, ctx); }); - it('throws AUTH_REQUIRED when ctx.authInfo is undefined', async () => { + it('supports async toAccount', async () => { + const store = createDerivedAccountStore({ + toAccount: async () => { + await new Promise(r => setImmediate(r)); + return account('x'); + }, + }); + assert.equal((await store.resolve(undefined, oauthCtx('b1'))).id, 'x'); + }); + + it('propagates toAccount throws (framework projects non-AdcpError to SERVICE_UNAVAILABLE)', async () => { + const store = createDerivedAccountStore({ + toAccount: () => { + throw new Error('upstream is down'); + }, + }); + await assert.rejects(() => store.resolve(undefined, oauthCtx('b1')), /upstream is down/); + }); + + it('returns null for brand+operator refs (framework refuses them earlier)', async () => { + const resolved = await singletonStore().resolve( + { brand: { domain: 'acme.com' }, operator: 'agency.com' }, + oauthCtx('b1') + ); + assert.equal(resolved, null); + }); + }); + + describe('resolve — roster (listAccounts)', () => { + const rosterStore = (rows, opts = {}) => createDerivedAccountStore({ listAccounts: () => rows, ...opts }); + + it('resolves an account_id that is in the credential-scoped roster', async () => { + const store = rosterStore([account('act_1'), account('act_2')]); + assert.equal((await store.resolve({ account_id: 'act_2' }, oauthCtx('b1'))).id, 'act_2'); + }); + + it('returns null for an account_id outside the credential-scoped roster', async () => { + const store = rosterStore([account('act_1')]); + assert.equal(await store.resolve({ account_id: 'act_99' }, oauthCtx('b1')), null); + }); + + it('scopes the roster per caller — another credential cannot reach the first credential’s accounts', async () => { + const byCaller = { + 'buyer-a': [account('act_a')], + 'buyer-b': [account('act_b')], + }; const store = createDerivedAccountStore({ - toAccount: () => ({ id: 'x', name: 'x', status: 'active', ctx_metadata: {} }), + listAccounts: ctx => byCaller[ctx?.authInfo?.credential?.client_id] ?? [], + }); + assert.equal((await store.resolve({ account_id: 'act_a' }, oauthCtx('buyer-a'))).id, 'act_a'); + assert.equal(await store.resolve({ account_id: 'act_a' }, oauthCtx('buyer-b')), null); + }); + + it('auto-selects the account on ref-less calls when the credential reaches exactly one', async () => { + const store = rosterStore([account('only')]); + assert.equal((await store.resolve(undefined, oauthCtx('b1'))).id, 'only'); + }); + + it('returns null on ref-less calls when the credential reaches several (no arbitrary default)', async () => { + const store = rosterStore([account('a'), account('b')]); + assert.equal(await store.resolve(undefined, oauthCtx('b1')), null); + }); + + it('returns null on ref-less calls when the credential reaches none', async () => { + assert.equal(await rosterStore([]).resolve(undefined, oauthCtx('b1')), null); + }); + + it('uses lookupAccount when supplied', async () => { + const seen = []; + const store = rosterStore([account('act_1')], { + lookupAccount: (id, ctx) => { + seen.push([id, ctx?.authInfo?.credential?.client_id]); + return id === 'act_1' ? account('act_1') : null; + }, + }); + assert.equal((await store.resolve({ account_id: 'act_1' }, oauthCtx('b1'))).id, 'act_1'); + assert.deepEqual(seen, [['act_1', 'b1']]); + assert.equal(await store.resolve({ account_id: 'nope' }, oauthCtx('b1')), null); + }); + + it('discards a lookupAccount result whose id does not match the request (defense in depth)', async () => { + const store = rosterStore([account('act_1')], { + // An adopter lookup that ignores the id — the factory must not let + // that become a silent cross-account substitution. + lookupAccount: () => account('some_other_account'), }); + assert.equal(await store.resolve({ account_id: 'act_1' }, oauthCtx('b1')), null); + }); + }); + + describe('auth gate', () => { + it('throws AUTH_REQUIRED when ctx.authInfo is undefined', async () => { await assert.rejects( - () => store.resolve(undefined, undefined), + () => singletonStore().resolve(undefined, undefined), err => { assert.equal(err.name, 'AdcpError'); assert.equal(err.code, 'AUTH_REQUIRED'); @@ -90,13 +204,19 @@ describe('createDerivedAccountStore (#1462)', () => { }); it('throws AUTH_REQUIRED when ctx.authInfo carries no credential / token / clientId', async () => { - const store = createDerivedAccountStore({ - toAccount: () => ({ id: 'x', name: 'x', status: 'active', ctx_metadata: {} }), - }); await assert.rejects( - () => store.resolve(undefined, { authInfo: { kind: 'public' } }), + () => singletonStore().resolve(undefined, { authInfo: { kind: 'public' } }), + err => { + assert.equal(err.code, 'AUTH_REQUIRED'); + return true; + } + ); + }); + + it('guards list_accounts with the same gate', async () => { + await assert.rejects( + () => singletonStore().list({}, undefined), err => { - assert.equal(err.name, 'AdcpError'); assert.equal(err.code, 'AUTH_REQUIRED'); return true; } @@ -104,115 +224,208 @@ describe('createDerivedAccountStore (#1462)', () => { }); it('accepts legacy ResolvedAuthInfo.token shape (pre-#1269 deprecation window)', async () => { - const store = createDerivedAccountStore({ - toAccount: () => ({ id: 'x', name: 'x', status: 'active', ctx_metadata: {} }), - }); - const account = await store.resolve(undefined, { authInfo: { token: 'legacy-bearer' } }); - assert.ok(account, 'must not refuse a request whose authenticator only stamps the deprecated token field'); + assert.ok(await singletonStore().resolve(undefined, { authInfo: { token: 'legacy-bearer' } })); }); it('accepts legacy ResolvedAuthInfo.clientId shape', async () => { - const store = createDerivedAccountStore({ - toAccount: () => ({ id: 'x', name: 'x', status: 'active', ctx_metadata: {} }), - }); - const account = await store.resolve(undefined, { authInfo: { clientId: 'legacy-oauth-client' } }); - assert.ok(account); + assert.ok(await singletonStore().resolve(undefined, { authInfo: { clientId: 'legacy-oauth-client' } })); }); - it('does NOT call toAccount when auth check fails', async () => { + it('does NOT call the account callback when the auth check fails', async () => { let called = false; const store = createDerivedAccountStore({ toAccount: () => { called = true; - return { id: 'x', name: 'x', status: 'active', ctx_metadata: {} }; + return account('x'); }, }); await assert.rejects(() => store.resolve(undefined, undefined)); assert.equal(called, false); }); + it('skips the auth check for public-credential calls when skipAuthCheck: true', async () => { + const store = singletonStore('public-cat', { skipAuthCheck: true }); + assert.ok(await store.resolve(undefined, { authInfo: { kind: 'public' } })); + }); + + it('only a strict `true` disables the gate (untyped truthy strings must not)', async () => { + const store = singletonStore('x', { skipAuthCheck: 'false' }); + await assert.rejects( + () => store.resolve(undefined, undefined), + err => err.code === 'AUTH_REQUIRED' + ); + }); + it('skips the auth check when skipAuthCheck: true', async () => { - const store = createDerivedAccountStore({ - toAccount: () => ({ id: 'public-cat', name: 'Public Catalog', status: 'active', ctx_metadata: {} }), - skipAuthCheck: true, - }); - const account = await store.resolve(undefined, undefined); - assert.ok(account); - assert.equal(account.id, 'public-cat'); + const store = singletonStore('public-cat', { skipAuthCheck: true }); + assert.equal((await store.resolve(undefined, undefined)).id, 'public-cat'); + assert.equal((await store.list({}, undefined)).items.length, 1); }); + }); - it('skips the auth check for public-credential calls when skipAuthCheck: true', async () => { + describe('list', () => { + it('publishes the singleton as one row', async () => { + const page = await singletonStore('audiostack').list({}, oauthCtx('b1')); + assert.deepEqual( + page.items.map(a => a.id), + ['audiostack'] + ); + assert.equal(page.totalCount, 1); + }); + + it('publishes the credential-scoped roster', async () => { + const store = createDerivedAccountStore({ listAccounts: () => [account('a'), account('b')] }); + const page = await store.list({}, oauthCtx('b1')); + assert.deepEqual( + page.items.map(a => a.id), + ['a', 'b'] + ); + }); + + it('honors the account_id filter', async () => { + const store = createDerivedAccountStore({ listAccounts: () => [account('a'), account('b')] }); + const page = await store.list({ account: { account_id: 'b' } }, oauthCtx('b1')); + assert.deepEqual( + page.items.map(a => a.id), + ['b'] + ); + }); + + it('narrows on a natural-key filter instead of ignoring it', async () => { const store = createDerivedAccountStore({ - toAccount: () => ({ id: 'public-cat', name: 'Public Catalog', status: 'active', ctx_metadata: {} }), - skipAuthCheck: true, + listAccounts: () => [ + account('a', { brand: { domain: 'acme.com' }, operator: 'pinnacle.com' }), + account('b', { brand: { domain: 'other.com' }, operator: 'pinnacle.com' }), + account('c'), + ], }); - const account = await store.resolve(undefined, { authInfo: { kind: 'public' } }); - assert.ok(account); + const byBrand = await store.list({ account: { brand: { domain: 'acme.com' } } }, oauthCtx('b1')); + assert.deepEqual( + byBrand.items.map(a => a.id), + ['a'] + ); + const byOperator = await store.list({ account: { operator: 'pinnacle.com' } }, oauthCtx('b1')); + assert.deepEqual( + byOperator.items.map(a => a.id), + ['a', 'b'] + ); }); - it('ignores buyer-supplied account_id (single-tenant by definition)', async () => { + it('narrows on brand_id as well as brand.domain', async () => { const store = createDerivedAccountStore({ - toAccount: () => ({ - id: 'audiostack', - name: 'AudioStack', - status: 'active', - ctx_metadata: {}, - }), + listAccounts: () => [ + account('a', { brand: { domain: 'acme.com', brand_id: 'spark' } }), + account('b', { brand: { domain: 'acme.com', brand_id: 'ember' } }), + ], }); - const account = await store.resolve({ account_id: 'whatever-buyer-sent' }, oauthCtx('buyer-1')); - assert.ok(account); - assert.equal(account.id, 'audiostack', 'singleton id wins, not the buyer-supplied account_id'); + const page = await store.list({ account: { brand: { domain: 'acme.com', brand_id: 'ember' } } }, oauthCtx('b1')); + assert.deepEqual( + page.items.map(a => a.id), + ['b'] + ); }); - it('ignores brand+operator-shaped refs', async () => { + it('honors the status filter', async () => { const store = createDerivedAccountStore({ - toAccount: () => ({ id: 'audiostack', name: 'AudioStack', status: 'active', ctx_metadata: {} }), + listAccounts: () => [account('a'), account('b', { status: 'suspended' })], }); - const account = await store.resolve( - { brand: { domain: 'acme.com' }, operator: 'agency.com' }, - oauthCtx('buyer-1') + const page = await store.list({ status: 'suspended' }, oauthCtx('b1')); + assert.deepEqual( + page.items.map(a => a.id), + ['b'] ); - assert.ok(account); - assert.equal(account.id, 'audiostack'); }); - it('supports async toAccount', async () => { + it('honors the sandbox filter via account mode (incl. the deprecated sandbox flag)', async () => { const store = createDerivedAccountStore({ - toAccount: async () => { - await new Promise(r => setImmediate(r)); - return { id: 'x', name: 'x', status: 'active', ctx_metadata: {} }; - }, + listAccounts: () => [ + account('live_1'), + account('sandbox_mode', { mode: 'sandbox' }), + account('legacy_flag', { sandbox: true }), + ], }); - const account = await store.resolve(undefined, oauthCtx('b1')); - assert.ok(account); - assert.equal(account.id, 'x'); + const sandboxOnly = await store.list({ sandbox: true }, oauthCtx('b1')); + assert.deepEqual( + sandboxOnly.items.map(a => a.id), + ['sandbox_mode', 'legacy_flag'] + ); + const productionOnly = await store.list({ sandbox: false }, oauthCtx('b1')); + assert.deepEqual( + productionOnly.items.map(a => a.id), + ['live_1'] + ); }); + }); - it('propagates toAccount throws (framework projects to SERVICE_UNAVAILABLE for non-AdcpError)', async () => { + describe('list pagination', () => { + const roster = n => + createDerivedAccountStore({ listAccounts: () => Array.from({ length: n }, (_v, i) => account(`act_${i}`)) }); + + it('caps the page at max_results and emits a continuation cursor', async () => { + const store = roster(5); + const first = await store.list({ pagination: { max_results: 2 } }, oauthCtx('b1')); + assert.deepEqual( + first.items.map(a => a.id), + ['act_0', 'act_1'] + ); + assert.equal(first.totalCount, 5); + assert.ok(first.nextCursor, 'a partial page must carry a cursor'); + }); + + it('continues from the cursor and drops it on the last page', async () => { + const store = roster(5); + const first = await store.list({ pagination: { max_results: 3 } }, oauthCtx('b1')); + const second = await store.list({ pagination: { max_results: 3, cursor: first.nextCursor } }, oauthCtx('b1')); + assert.deepEqual( + second.items.map(a => a.id), + ['act_3', 'act_4'] + ); + assert.equal(second.nextCursor, undefined); + }); + + it('clamps max_results into [1, 100] and tolerates junk', async () => { + const store = roster(150); + assert.equal((await store.list({ pagination: { max_results: 0 } }, oauthCtx('b1'))).items.length, 1); + assert.equal((await store.list({ pagination: { max_results: 5000 } }, oauthCtx('b1'))).items.length, 100); + assert.equal((await store.list({}, oauthCtx('b1'))).items.length, 100); + }); + + it('treats an unparseable cursor as the first page instead of throwing', async () => { + const store = roster(3); + const page = await store.list({ pagination: { cursor: 'not-a-cursor' } }, oauthCtx('b1')); + assert.deepEqual( + page.items.map(a => a.id), + ['act_0', 'act_1', 'act_2'] + ); + }); + + it('applies filters before paging', async () => { const store = createDerivedAccountStore({ - toAccount: () => { - throw new Error('upstream is down'); - }, + listAccounts: () => [account('a'), account('b', { status: 'suspended' }), account('c')], }); - await assert.rejects(() => store.resolve(undefined, oauthCtx('b1')), /upstream is down/); + const page = await store.list({ status: 'active', pagination: { max_results: 1 } }, oauthCtx('b1')); + assert.deepEqual( + page.items.map(a => a.id), + ['a'] + ); + assert.equal(page.totalCount, 2, 'total reflects the filtered set, not the whole roster'); }); }); describe('composition', () => { - it('supports spreading to add upsert without losing resolution', async () => { + it('supports spreading to add upsert without losing resolution or list', async () => { const upsertCalls = []; const accounts = { - ...createDerivedAccountStore({ - toAccount: () => ({ id: 'x', name: 'x', status: 'active', ctx_metadata: {} }), - }), + ...singletonStore(), upsert: async refs => { upsertCalls.push(refs); return []; }, }; assert.equal(accounts.resolution, 'derived'); + assert.equal(typeof accounts.list, 'function'); assert.equal(typeof accounts.upsert, 'function'); - await accounts.upsert([{ brand: { domain: 'a.com' }, operator: 'b.com' }]); + await accounts.upsert([{ account: { account_id: 'act_1' } }]); assert.equal(upsertCalls.length, 1); }); }); diff --git a/test/lib/account-resolution-alias-drift.test.js b/test/lib/account-resolution-alias-drift.test.js new file mode 100644 index 000000000..e9776a372 --- /dev/null +++ b/test/lib/account-resolution-alias-drift.test.js @@ -0,0 +1,81 @@ +// #1647 — `AccountStore.resolution` has exactly one spelling per mode. +// +// The PR that corrected `'derived'` prototyped `'upstream-managed'` as a +// spec-aligned alias and then removed it: `resolution` is SDK-local config, +// never a wire value, so a second spelling buys nothing on the wire while +// silently defeating adopter code written as `resolution === 'derived'` and +// inviting divergence from the Python SDK. `'account-id-namespace'` was +// rejected separately — it doesn't discriminate `'explicit'` from +// `'derived'`. +// +// That decision is easy to un-make by accident: a doc sentence saying an +// alias is "accepted" costs nothing to write and is wrong the moment it +// lands, because the union has no such member. This test pins the union and +// the prose to each other. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { readFileSync, readdirSync, statSync } = require('node:fs'); +const { join } = require('node:path'); + +const REJECTED_SPELLINGS = ['upstream-managed', 'account-id-namespace']; + +// Docs and source that describe the mode to adopters. `decisioning.type-checks.ts` +// is exempt: it names the rejected spellings under `@ts-expect-error`, which is +// exactly the assertion we want to keep. +const SEARCH_ROOTS = ['docs', 'skills', 'src/lib', 'examples']; +const EXEMPT = new Set(['src/lib/server/decisioning/decisioning.type-checks.ts', 'test']); +const TEXT_EXTENSIONS = ['.md', '.txt', '.ts', '.js', '.mjs']; + +function* walk(dir) { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (EXEMPT.has(full)) continue; + if (statSync(full).isDirectory()) { + if (entry === 'node_modules' || entry === 'dist' || entry === 'cache') continue; + yield* walk(full); + continue; + } + if (TEXT_EXTENSIONS.some(ext => entry.endsWith(ext))) yield full; + } +} + +test("AccountResolutionMode declares 'explicit' | 'implicit' | 'derived' and nothing else", () => { + const source = readFileSync('src/lib/server/decisioning/account.ts', 'utf8'); + const declaration = /export type AccountResolutionMode = ([^;]+);/.exec(source); + assert.ok(declaration, 'AccountResolutionMode must be declared in account.ts'); + const members = declaration[1] + .split('|') + .map(part => part.trim().replace(/^'|'$/g, '')) + .filter(Boolean) + .sort(); + assert.deepEqual(members, ['derived', 'explicit', 'implicit']); +}); + +test('no doc or source file claims a rejected resolution spelling is accepted', () => { + // A quoted rejected spelling is fine when the surrounding paragraph says + // it is rejected — that's the decision record. It is NOT fine in a + // paragraph that reads as an acceptance claim ("accepted as", "alias for", + // "behaves identically"), which is the drift this pins down. + const NEGATION = + /\bnot an alias\b|\brejected\b|\bno alias\b|\bsingle (?:spelling|name)\b|one spelling|deliberately|ts-expect-error|wouldn't|would not|PlatformConfigError/i; + const offenders = []; + for (const root of SEARCH_ROOTS) { + for (const file of walk(root)) { + const contents = readFileSync(file, 'utf8'); + for (const paragraph of contents.split(/\n\s*\n/)) { + const quotes = REJECTED_SPELLINGS.filter( + spelling => paragraph.includes(`'${spelling}'`) || paragraph.includes(`"${spelling}"`) + ); + if (quotes.length === 0) continue; + if (NEGATION.test(paragraph)) continue; + offenders.push(`${file}: ${quotes.join(', ')}`); + } + } + } + assert.deepEqual( + offenders, + [], + `resolution has one spelling per mode; these passages read as accepting a rejected alias:\n ${offenders.join('\n ')}` + ); +}); diff --git a/test/lib/proposal-manager-e2e.test.js b/test/lib/proposal-manager-e2e.test.js index d1ac4953e..8c93b794e 100644 --- a/test/lib/proposal-manager-e2e.test.js +++ b/test/lib/proposal-manager-e2e.test.js @@ -26,7 +26,10 @@ function buildPlatform({ proposalManager, sales, capabilities = {} }) { ...capabilities, }, accounts: { - resolution: 'derived', + // Seller-owned namespace: this fixture accepts both AccountReference + // arms, including the natural-key request at line ~284. (It declared + // 'derived' before #1647 inverted that mode to account-id-only.) + resolution: 'explicit', resolve: async () => ({ id: 'acct_1', metadata: {} }), }, sales, diff --git a/test/lib/proposal-negotiation-server.test.js b/test/lib/proposal-negotiation-server.test.js index 72d74306e..9bc77e850 100644 --- a/test/lib/proposal-negotiation-server.test.js +++ b/test/lib/proposal-negotiation-server.test.js @@ -103,6 +103,7 @@ describe('refine_proposals server integration', () => { capabilities: { specialisms: [], supported_versions: ['3.2-rc.2'] }, accounts: { resolution: 'derived', + list: async () => ({ items: [] }), resolve: async () => ({ id: 'account-1', metadata: {} }), }, }, diff --git a/test/lib/spec-conformance-account-discovery.test.js b/test/lib/spec-conformance-account-discovery.test.js index 80926c0b6..ec52b1831 100644 --- a/test/lib/spec-conformance-account-discovery.test.js +++ b/test/lib/spec-conformance-account-discovery.test.js @@ -222,3 +222,73 @@ describe('account-discovery gate routes synthetic result to core track (#1624 re assert.equal(failedSteps.length, 1, 'one failed step from the synthetic gate'); }); }); + +describe('checkAccountDiscoveryGate — account-id namespaces (#1647 / adcp#5062)', () => { + const accountIdNamespaceProfile = (tools, extra = {}) => + profile({ + specialisms: ['sales-guaranteed'], + tools: ['get_adcp_capabilities', ...tools], + raw_capabilities: { account: { require_operator_auth: true } }, + ...extra, + }); + + test('sync_accounts alone still passes for an account-id namespace (deliberate: the spec MUST is conditional)', () => { + // adcp#5062 makes list_accounts a MUST only when a credential can reach + // more than one account, and a SHOULD with an out-of-band escape for + // credential-bound singletons. Neither condition is observable from a + // static capability read, and require_operator_auth: true also covers + // seller-defined namespaces that hand out ids out-of-band — so this gate + // must not fail them. The stricter rule is enforced where it is knowable: + // createAdcpServerFromPlatform requires accounts.list for a declared + // resolution: 'derived'. + assert.equal( + checkAccountDiscoveryGate(accountIdNamespaceProfile(['sync_accounts']), 'https://agent.example/mcp'), + null + ); + }); + + test('list_accounts satisfies discovery for an account-id namespace', () => { + assert.equal( + checkAccountDiscoveryGate(accountIdNamespaceProfile(['list_accounts']), 'https://agent.example/mcp'), + null + ); + }); + + test('an account-id namespace advertising neither tool gets list_accounts-specific remediation', () => { + const result = checkAccountDiscoveryGate(accountIdNamespaceProfile([]), 'https://agent.example/mcp'); + assert.ok(result, 'gate should fire — neither discovery tool is advertised'); + const step = result.phases[0].steps[0]; + assert.match(step.error, /require_operator_auth/); + assert.match(step.error, /account_id/); + }); + + test('buyer-declared sellers advertising neither tool get the generic message', () => { + const result = checkAccountDiscoveryGate( + profile({ + specialisms: ['sales-guaranteed'], + tools: ['get_adcp_capabilities'], + raw_capabilities: { account: { require_operator_auth: false } }, + }), + 'https://agent.example/mcp' + ); + assert.ok(result); + assert.doesNotMatch(result.phases[0].steps[0].error, /require_operator_auth: true/); + }); + + test('unreadable capabilities never change the verdict', () => { + for (const raw of [undefined, null, 'nope', {}, { account: null }]) { + assert.equal( + checkAccountDiscoveryGate( + profile({ + specialisms: ['sales-guaranteed'], + tools: ['get_adcp_capabilities', 'sync_accounts'], + raw_capabilities: raw, + }), + 'https://agent.example/mcp' + ), + null, + `raw_capabilities=${JSON.stringify(raw)} must not newly fail the gate` + ); + } + }); +}); diff --git a/test/server-decisioning-capability-projections.test.js b/test/server-decisioning-capability-projections.test.js index 81da91279..4b42395b8 100644 --- a/test/server-decisioning-capability-projections.test.js +++ b/test/server-decisioning-capability-projections.test.js @@ -534,6 +534,8 @@ describe('Capability projections — declarative capability blocks on Decisionin // Either signal alone projects to require_operator_auth: true. const platform = basePlatform({ requireOperatorAuth: true }); platform.accounts.resolution = 'derived'; + // 'derived' (upstream-managed account-id namespace) requires list_accounts — #1647. + platform.accounts.list = async () => ({ items: [] }); const server = createAdcpServerFromPlatform(platform, { name: 'h', version: '0.0.1', @@ -543,6 +545,33 @@ describe('Capability projections — declarative capability blocks on Decisionin assert.strictEqual(result.structuredContent?.account?.require_operator_auth, true); }); + it('accounts.resolution: derived projects account.require_operator_auth (account-id namespace, #1647)', async () => { + const platform = basePlatform(); + platform.accounts.resolution = 'derived'; + platform.accounts.list = async () => ({ items: [] }); + const server = createAdcpServerFromPlatform(platform, { + name: 'h', + version: '0.0.1', + validation: { requests: 'off', responses: 'off' }, + }); + const result = await dispatchCapabilities(server); + assert.strictEqual(result.structuredContent?.account?.require_operator_auth, true); + }); + + it('omitted accounts.resolution still does NOT project the account block', async () => { + // Defaulting to 'explicit' for dispatch must not silently start + // advertising an operator-auth account model for adopters who never + // declared a mode. + const platform = basePlatform(); + const server = createAdcpServerFromPlatform(platform, { + name: 'h', + version: '0.0.1', + validation: { requests: 'off', responses: 'off' }, + }); + const result = await dispatchCapabilities(server); + assert.notStrictEqual(result.structuredContent?.account?.require_operator_auth, true); + }); + it('accounts.resolution: implicit does NOT project account block (sync_accounts is the correct tool)', async () => { const platform = basePlatform(); platform.accounts.resolution = 'implicit'; diff --git a/test/server-decisioning-comply-gate.test.js b/test/server-decisioning-comply-gate.test.js index a5f1f137d..06384177f 100644 --- a/test/server-decisioning-comply-gate.test.js +++ b/test/server-decisioning-comply-gate.test.js @@ -688,6 +688,40 @@ describe('createAdcpServerFromPlatform — sandbox-authority gate (accountRef.sa assertPermissionDenied(result); }); + it('does NOT admit on accountRef.sandbox when the buyer named an account the resolver refused (#1647)', async () => { + // Fail-closed resolvers make "unresolved" reachable on demand: a caller + // just names an account their credential cannot reach. Without scoping + // the wire-claim fallback to refs that name no account, `{ account_id: + // '', sandbox: true }` would admit the controller with no + // framework account authority attached — and the auto-seed adapters + // fall back to the buyer-supplied id for their storage namespace. + const server = buildServer(async ref => { + if (ref == null) { + return { id: 'principal_sb', mode: 'sandbox', ctx_metadata: {}, authInfo: { kind: 'api_key' } }; + } + return ref.account_id === 'principal_sb' + ? { id: 'principal_sb', mode: 'sandbox', ctx_metadata: {}, authInfo: { kind: 'api_key' } } + : null; + }); + + const result = await callForceCreative(server, { + account: { account_id: 'victim_workspace', sandbox: true }, + }); + + assertPermissionDenied(result); + }); + + it('still admits on accountRef.sandbox when the ref names no account', async () => { + const server = buildServer(async ref => + ref == null ? { id: 'principal_sb', mode: 'sandbox', ctx_metadata: {}, authInfo: { kind: 'api_key' } } : null + ); + + const result = await callForceCreative(server, { account: { sandbox: true } }); + + assert.notStrictEqual(result.isError, true); + assert.strictEqual(result.structuredContent.success, true); + }); + it('does NOT admit on accountRef.sandbox when the resolver names a live account', async () => { // The wire flag is a fallback for the *unresolved* path. Once the // resolver names the account, the resolver wins and the buyer's wire diff --git a/test/server-decisioning-from-platform.test.js b/test/server-decisioning-from-platform.test.js index c1f2d6e14..70984f895 100644 --- a/test/server-decisioning-from-platform.test.js +++ b/test/server-decisioning-from-platform.test.js @@ -4068,6 +4068,7 @@ describe('CreativeBuilderPlatform + AudiencePlatform wiring', () => { }, accounts: { resolution: 'derived', + list: async () => ({ items: [] }), resolve: async () => ({ id: 'singleton', name: 'Acme', @@ -4118,6 +4119,7 @@ describe('CreativeBuilderPlatform + AudiencePlatform wiring', () => { }, accounts: { resolution: 'derived', + list: async () => ({ items: [] }), resolve: async () => ({ id: 'singleton', name: 'Acme', @@ -5832,6 +5834,7 @@ describe('SalesPlatform optional methods (v1.0 gap-fill for rc.1)', () => { const platform = buildPlatform({ accounts: { resolution: 'derived', + list: async () => ({ items: [] }), resolve: async ref => { resolveCalledWithRef = ref; return { id: 'singleton', name: 'Acme', status: 'active', metadata: {}, authInfo: { kind: 'api_key' } }; @@ -5875,6 +5878,7 @@ describe('SalesPlatform optional methods (v1.0 gap-fill for rc.1)', () => { const platform = buildPlatform({ accounts: { resolution: 'derived', + list: async () => ({ items: [] }), resolve: async () => ({ id: 'singleton', name: 'Acme', @@ -7276,6 +7280,7 @@ describe('SalesPlatform retail-media tools (M2)', () => { const platform = buildPlatform({ accounts: { resolution: 'derived', + list: async () => ({ items: [] }), resolve: async () => ({ id: 'acc_1', name: 'Acme', @@ -7619,6 +7624,7 @@ describe('Observability hooks (DecisioningObservabilityHooks)', () => { const platform = buildPlatform({ accounts: { resolution: 'derived', + list: async () => ({ items: [] }), resolve: async () => ({ id: 'singleton', name: 'X', diff --git a/test/server-decisioning-implicit-account-id.test.js b/test/server-decisioning-implicit-account-id.test.js index 62b63b5b3..435d5de14 100644 --- a/test/server-decisioning-implicit-account-id.test.js +++ b/test/server-decisioning-implicit-account-id.test.js @@ -1,7 +1,13 @@ -// Tests for #1364 — framework refusal of inline account_id references -// against `accounts.resolution: 'implicit'` platforms. Documented behavior -// at AccountStore.resolution; previously aspirational (docstring claim -// without enforcement). +// Framework enforcement of the per-mode account-reference contract +// documented at `AccountStore.resolution`. +// +// - #1364: `'implicit'` (buyer-declared accounts) refuses inline +// `account_id`; the `{ brand, operator }` natural key is durable. +// - #1647: `'derived'` (upstream-managed account-id namespace) is the +// mirror image — `account_id` is durable and accepted, the natural key is +// refused, `accounts.list` is mandatory, and `sync_accounts` natural-key +// provisioning entries fail per-row with `UNSUPPORTED_PROVISIONING`. +// This replaces #1468, which had the polarity backwards. process.env.NODE_ENV = 'test'; @@ -288,16 +294,35 @@ describe("#1364 — accounts.resolution: 'implicit' refuses inline account_id", }); }); -describe("#1468 — accounts.resolution: 'derived' refuses inline account_id (mirrors #1364)", () => { - it('rejects { account_id } reference with INVALID_REQUEST and field=account.account_id', async () => { - let resolveCalled = false; - const platform = buildImplicitPlatform({ - accounts: { - resolution: 'derived', - resolve: async () => { - resolveCalled = true; - return null; - }, +// #1647 (supersedes #1468) — `accounts.resolution: 'derived'` is an +// upstream-managed account-id namespace: `account_id` is the durable wire +// reference and the `{ brand, operator }` natural key is refused. The +// pre-14 behavior was the exact inverse; see docs/migration-13-to-14.md. +function buildDerivedPlatform(accountOverrides = {}) { + const singleton = { + id: 'upstream_acct_1', + name: 'Upstream Account', + status: 'active', + ctx_metadata: {}, + }; + return buildImplicitPlatform({ + accounts: { + resolution: 'derived', + // Verified resolution: only ids the credential can reach resolve. + resolve: async ref => (ref?.account_id === undefined || ref.account_id === singleton.id ? singleton : null), + list: async () => ({ items: [singleton] }), + ...accountOverrides, + }, + }); +} + +describe("#1647 — accounts.resolution: 'derived' accepts account_id and refuses the natural key", () => { + it('accepts an inline { account_id } reference and threads it to resolve', async () => { + let sawRef; + const platform = buildDerivedPlatform({ + resolve: async ref => { + sawRef = ref; + return { id: 'upstream_acct_1', name: 'Upstream Account', status: 'active', ctx_metadata: {} }; }, }); const server = createAdcpServerFromPlatform(platform, SERVER_OPTS); @@ -308,21 +333,35 @@ describe("#1468 — accounts.resolution: 'derived' refuses inline account_id (mi arguments: { brief: 'premium', promoted_offering: 'cars', - account: { account_id: 'audiostack_singleton' }, + account: { account_id: 'upstream_acct_1' }, }, }, }); - assert.strictEqual(result.isError, true); - assert.strictEqual(result.structuredContent.adcp_error.code, 'INVALID_REQUEST'); - assert.strictEqual(result.structuredContent.adcp_error.field, 'account.account_id'); - assert.strictEqual(resolveCalled, false, 'resolve must not be invoked when derived-mode rejects upfront'); + assert.notStrictEqual(result.isError, true, `expected success, got ${JSON.stringify(result.structuredContent)}`); + assert.deepStrictEqual(sawRef, { account_id: 'upstream_acct_1' }); }); - it('derived-mode error message names single-tenant / derived (not sync_accounts, which does not exist in derived mode)', async () => { + it('an unrecognized resolution string is refused at construction, never silently gated as derived', () => { + // Untyped JS can put anything here. A typo must not inherit another + // mode's wire enforcement — pre-#1647 it meant "no enforcement", and + // after the inversion it would have meant "derived enforcement", the + // exact inverse of what an 'Implicit' typo intends. const platform = buildImplicitPlatform({ accounts: { - resolution: 'derived', - resolve: async () => ({ id: 'singleton', name: 'Singleton', status: 'active', ctx_metadata: {} }), + resolution: 'Implicit', + resolve: async () => null, + list: async () => ({ items: [] }), + }, + }); + assert.throws(() => createAdcpServerFromPlatform(platform, SERVER_OPTS), /not a recognized resolution mode/); + }); + + it('refuses the brand+operator arm with INVALID_REQUEST, field=account.brand, before resolve runs', async () => { + let resolveCalled = false; + const platform = buildDerivedPlatform({ + resolve: async () => { + resolveCalled = true; + return null; }, }); const server = createAdcpServerFromPlatform(platform, SERVER_OPTS); @@ -333,39 +372,48 @@ describe("#1468 — accounts.resolution: 'derived' refuses inline account_id (mi arguments: { brief: 'premium', promoted_offering: 'cars', - account: { account_id: 'foo' }, + account: { brand: { domain: 'acme.com' }, operator: 'pinnacle.com' }, }, }, }); assert.strictEqual(result.isError, true); - assert.match( - result.structuredContent.adcp_error.message, - /single-tenant|derived/i, - 'derived-mode message should explain single-tenant semantics, not point at sync_accounts' - ); + assert.strictEqual(result.structuredContent.adcp_error.code, 'INVALID_REQUEST'); + assert.strictEqual(result.structuredContent.adcp_error.field, 'account.brand'); + assert.match(result.structuredContent.adcp_error.suggestion, /list_accounts/); assert.doesNotMatch( result.structuredContent.adcp_error.message, /sync_accounts/, - 'derived-mode message must not suggest sync_accounts (no such step in derived mode)' + 'derived-mode recovery is list_accounts, not sync_accounts' ); + assert.strictEqual(resolveCalled, false, 'resolve must not run when the ref shape is refused upfront'); }); - it('derived + omitted account flows to auth-derived resolution (no enforcement, no rejection)', async () => { - let sawRef; - const platform = buildImplicitPlatform({ - accounts: { - resolution: 'derived', - resolve: async ref => { - sawRef = ref; - return { - id: 'singleton', - name: 'Singleton', - status: 'active', - ctx_metadata: {}, - }; + it('an account_id the resolver cannot reach becomes ACCOUNT_NOT_FOUND (fail closed, no detail leak)', async () => { + const platform = buildDerivedPlatform(); + const server = createAdcpServerFromPlatform(platform, SERVER_OPTS); + const result = await server.dispatchTestRequest({ + method: 'tools/call', + params: { + name: 'get_products', + arguments: { + brief: 'premium', + promoted_offering: 'cars', + account: { account_id: 'someone_elses_account' }, }, }, }); + assert.strictEqual(result.isError, true); + assert.strictEqual(result.structuredContent.adcp_error.code, 'ACCOUNT_NOT_FOUND'); + }); + + it('derived + omitted account flows to auth-derived resolution (no enforcement, no rejection)', async () => { + let sawRef = 'unset'; + const platform = buildDerivedPlatform({ + resolve: async ref => { + sawRef = ref; + return { id: 'upstream_acct_1', name: 'Upstream Account', status: 'active', ctx_metadata: {} }; + }, + }); const server = createAdcpServerFromPlatform(platform, SERVER_OPTS); const result = await server.dispatchTestRequest({ method: 'tools/call', @@ -382,70 +430,487 @@ describe("#1468 — accounts.resolution: 'derived' refuses inline account_id (mi assert.strictEqual(sawRef, undefined, 'auth-derived path passes undefined ref'); }); - it('tasks_get rejects { account_id } with INVALID_REQUEST on derived platforms', async () => { - const platform = buildImplicitPlatform({ - accounts: { - resolution: 'derived', - resolve: async () => null, + it('tasks_get accepts { account_id } and refuses the natural key on derived platforms', async () => { + const platform = buildDerivedPlatform(); + const server = createAdcpServerFromPlatform(platform, SERVER_OPTS); + const refused = await server.dispatchTestRequest({ + method: 'tools/call', + params: { + name: 'tasks_get', + arguments: { + task_id: 'task_does_not_matter', + account: { brand: { domain: 'acme.com' }, operator: 'pinnacle.com' }, + }, }, }); - const server = createAdcpServerFromPlatform(platform, SERVER_OPTS); - const result = await server.dispatchTestRequest({ + assert.strictEqual(refused.isError, true); + assert.strictEqual(refused.structuredContent.adcp_error.code, 'INVALID_REQUEST'); + assert.strictEqual(refused.structuredContent.adcp_error.field, 'account.brand'); + + const accepted = await server.dispatchTestRequest({ method: 'tools/call', params: { name: 'tasks_get', arguments: { task_id: 'task_does_not_matter', - account: { account_id: 'audiostack_singleton' }, + account: { account_id: 'upstream_acct_1' }, + }, + }, + }); + // The task itself doesn't exist; what matters is that the account ref + // shape was accepted rather than refused as INVALID_REQUEST. + assert.notStrictEqual(accepted.structuredContent?.adcp_error?.code, 'INVALID_REQUEST'); + }); + + it('get_account_financials accepts { account_id } on derived platforms', async () => { + const platform = buildDerivedPlatform({ + getAccountFinancials: async () => ({ + financials: { spend: { amount: 0, currency: 'USD' } }, + }), + }); + const server = createAdcpServerFromPlatform(platform, SERVER_OPTS); + const result = await server.dispatchTestRequest({ + method: 'tools/call', + params: { + name: 'get_account_financials', + arguments: { + account: { account_id: 'upstream_acct_1' }, + }, + }, + }); + assert.notStrictEqual(result.isError, true, `expected success, got ${JSON.stringify(result.structuredContent)}`); + }); + + it('refusal fires regardless of auth posture — authenticated natural-key refs are still refused', async () => { + // Locks the contract: an authenticated buyer that sends the natural key + // to a derived agent gets the same INVALID_REQUEST as an unauthenticated + // one. Prevents future drift where someone softens the refusal "for + // authenticated principals only" — the upstream owns the namespace + // regardless of who is asking. + let resolveCalled = false; + const platform = buildDerivedPlatform({ + resolve: async () => { + resolveCalled = true; + return null; + }, + }); + const server = createAdcpServerFromPlatform(platform, { + ...SERVER_OPTS, + authenticate: () => ({ + kind: 'oauth', + credential: { kind: 'oauth', client_id: 'authed-buyer', scopes: [] }, + }), + }); + const result = await server.dispatchTestRequest({ + method: 'tools/call', + params: { + name: 'get_products', + arguments: { + brief: 'premium', + promoted_offering: 'cars', + account: { brand: { domain: 'acme.com' }, operator: 'pinnacle.com' }, }, }, }); assert.strictEqual(result.isError, true); assert.strictEqual(result.structuredContent.adcp_error.code, 'INVALID_REQUEST'); - assert.strictEqual(result.structuredContent.adcp_error.field, 'account.account_id'); + assert.strictEqual(result.structuredContent.adcp_error.field, 'account.brand'); + assert.strictEqual(resolveCalled, false, 'authed principal does not soften the refusal'); }); +}); - it('get_account_financials rejects { account_id } with INVALID_REQUEST on derived platforms', async () => { +describe("#1647 — 'derived' platform configuration gates", () => { + it('throws PlatformConfigError when a derived store omits accounts.list', () => { const platform = buildImplicitPlatform({ accounts: { resolution: 'derived', resolve: async () => null, - getAccountFinancials: async () => ({ - financials: { spend: { amount: 0, currency: 'USD' } }, - }), }, }); + assert.throws( + () => createAdcpServerFromPlatform(platform, SERVER_OPTS), + err => { + assert.strictEqual(err.name, 'PlatformConfigError'); + assert.match(err.message, /accounts\.list/); + assert.match(err.message, /list_accounts/); + return true; + } + ); + }); + + it('accepts list_accounts wired through the merge seam instead of the platform interface', () => { + const platform = buildImplicitPlatform({ + accounts: { + resolution: 'derived', + resolve: async () => null, + }, + }); + // `opts.accounts.listAccounts` is a documented wiring — the gate is + // about serving list_accounts, not about which seam provides it. + assert.doesNotThrow(() => + createAdcpServerFromPlatform(platform, { + ...SERVER_OPTS, + accounts: { listAccounts: async () => ({ accounts: [] }) }, + }) + ); + }); + + it('projects account.require_operator_auth: true for derived platforms', async () => { + const platform = buildDerivedPlatform(); + const server = createAdcpServerFromPlatform(platform, SERVER_OPTS); + const result = await server.dispatchTestRequest({ + method: 'tools/call', + params: { name: 'get_adcp_capabilities', arguments: {} }, + }); + assert.strictEqual(result.structuredContent.account.require_operator_auth, true); + }); +}); + +describe('#1647 — sync_accounts on a derived platform', () => { + const UPSTREAM_ACCOUNT = { + id: 'upstream_acct_1', + name: 'Upstream', + status: 'active', + brand: { domain: 'acme.com' }, + operator: 'pinnacle.com', + ctx_metadata: {}, + }; + // Verified resolution: only the credential's own account resolves. + const derivedSyncPlatform = (upsert, extra = {}) => + buildImplicitPlatform({ + accounts: { + resolution: 'derived', + resolve: async ref => + ref?.account_id === undefined || ref.account_id === UPSTREAM_ACCOUNT.id ? UPSTREAM_ACCOUNT : null, + list: async () => ({ items: [UPSTREAM_ACCOUNT] }), + upsert, + ...extra, + }, + }); + // A settings-update row still has to satisfy `sync-accounts-response.json`, + // which requires brand + operator on every row — the seller echoes them + // from its own account record. + const settingsUpdateRow = () => ({ + account_id: UPSTREAM_ACCOUNT.id, + brand: UPSTREAM_ACCOUNT.brand, + operator: UPSTREAM_ACCOUNT.operator, + action: 'updated', + status: 'active', + }); + + it('fails natural-key provisioning entries per-row with UNSUPPORTED_PROVISIONING, without calling upsert', async () => { + let upsertCalled = false; + const platform = derivedSyncPlatform(async () => { + upsertCalled = true; + return []; + }); const server = createAdcpServerFromPlatform(platform, SERVER_OPTS); const result = await server.dispatchTestRequest({ method: 'tools/call', params: { - name: 'get_account_financials', + name: 'sync_accounts', + arguments: { + idempotency_key: 'idem-derived-1-0000000000000000', + accounts: [{ brand: { domain: 'acme.com' }, operator: 'pinnacle.com', billing: 'agent' }], + }, + }, + }); + const rows = result.structuredContent.accounts; + assert.strictEqual(rows.length, 1); + assert.strictEqual(rows[0].action, 'failed'); + assert.strictEqual(rows[0].errors[0].code, 'UNSUPPORTED_PROVISIONING'); + assert.match(rows[0].errors[0].suggestion, /list_accounts/); + assert.strictEqual(upsertCalled, false, 'natural-key entries must not reach the adopter upsert'); + }); + + it('passes account_id-keyed settings-update entries through to upsert', async () => { + let sawEntries; + const platform = derivedSyncPlatform(async entries => { + sawEntries = entries; + return entries.map(settingsUpdateRow); + }); + const server = createAdcpServerFromPlatform(platform, SERVER_OPTS); + const result = await server.dispatchTestRequest({ + method: 'tools/call', + params: { + name: 'sync_accounts', + arguments: { + idempotency_key: 'idem-derived-2-0000000000000000', + accounts: [{ account: { account_id: 'upstream_acct_1' } }], + }, + }, + }); + assert.notStrictEqual(result.isError, true, `expected success, got ${JSON.stringify(result.structuredContent)}`); + assert.strictEqual(sawEntries.length, 1); + assert.deepStrictEqual(sawEntries[0].account, { account_id: 'upstream_acct_1' }); + }); + + it('mixed batches apply the settings-update entry and fail only the natural-key entry', async () => { + const platform = derivedSyncPlatform(async entries => entries.map(settingsUpdateRow)); + const server = createAdcpServerFromPlatform(platform, SERVER_OPTS); + const result = await server.dispatchTestRequest({ + method: 'tools/call', + params: { + name: 'sync_accounts', + arguments: { + idempotency_key: 'idem-derived-3-0000000000000000', + accounts: [ + { brand: { domain: 'acme.com' }, operator: 'pinnacle.com', billing: 'agent' }, + { account: { account_id: 'upstream_acct_1' } }, + ], + }, + }, + }); + const rows = result.structuredContent.accounts; + assert.strictEqual(rows.length, 2); + assert.strictEqual(rows[0].action, 'failed'); + assert.strictEqual(rows[0].errors[0].code, 'UNSUPPORTED_PROVISIONING'); + assert.strictEqual(rows[1].action, 'updated'); + }); + + it('produces a schema-valid response under strict validation (rows carry brand + operator)', async () => { + // The settings-update path is only usable if the seller can emit rows + // that satisfy `sync-accounts-response.json` (brand + operator are + // required on every row). Runs with validation ON — the rest of this + // file runs with it off. + const platform = derivedSyncPlatform(async entries => entries.map(settingsUpdateRow)); + const server = createAdcpServerFromPlatform(platform, { + ...SERVER_OPTS, + validation: { requests: 'strict', responses: 'strict' }, + }); + const result = await server.dispatchTestRequest({ + method: 'tools/call', + params: { + name: 'sync_accounts', arguments: { - account: { account_id: 'audiostack_singleton' }, + idempotency_key: 'idem-derived-strict-00000000000', + accounts: [{ account: { account_id: 'upstream_acct_1' } }], + }, + }, + }); + assert.notStrictEqual(result.isError, true, `expected success, got ${JSON.stringify(result.structuredContent)}`); + assert.strictEqual(result.structuredContent.accounts[0].action, 'updated'); + }); + + it('refuses a settings-update entry naming an account the credential cannot reach, before any write', async () => { + let upsertCalled = false; + const platform = derivedSyncPlatform(async entries => { + upsertCalled = true; + return entries.map(settingsUpdateRow); + }); + const server = createAdcpServerFromPlatform(platform, SERVER_OPTS); + const result = await server.dispatchTestRequest({ + method: 'tools/call', + params: { + name: 'sync_accounts', + arguments: { + idempotency_key: 'idem-derived-foreign-000000000', + accounts: [{ account: { account_id: 'someone_elses_account' } }], + }, + }, + }); + assert.strictEqual(result.isError, true); + assert.strictEqual(result.structuredContent.adcp_error.code, 'ACCOUNT_NOT_FOUND'); + assert.strictEqual(upsertCalled, false, 'no write may run for an account the caller cannot reach'); + }); + + it('refuses a natural-key settings-update reference with INVALID_REQUEST (ref shape, not mode)', async () => { + const platform = derivedSyncPlatform(async entries => entries.map(settingsUpdateRow)); + const server = createAdcpServerFromPlatform(platform, SERVER_OPTS); + const result = await server.dispatchTestRequest({ + method: 'tools/call', + params: { + name: 'sync_accounts', + arguments: { + idempotency_key: 'idem-derived-natkeyref-0000000', + accounts: [{ account: { brand: { domain: 'acme.com' }, operator: 'pinnacle.com' } }], + }, + }, + }); + const row = result.structuredContent.accounts[0]; + assert.strictEqual(row.action, 'failed'); + assert.strictEqual(row.errors[0].code, 'INVALID_REQUEST'); + assert.strictEqual(row.errors[0].field, 'accounts[0].account.brand'); + }); + + it('keeps per-entry error indices aligned with the request array', async () => { + const platform = derivedSyncPlatform(async entries => entries.map(settingsUpdateRow), {}); + const server = createAdcpServerFromPlatform(platform, SERVER_OPTS); + const result = await server.dispatchTestRequest({ + method: 'tools/call', + params: { + name: 'sync_accounts', + arguments: { + idempotency_key: 'idem-derived-indices-000000000', + accounts: [ + { account: { account_id: 'upstream_acct_1' } }, + { brand: { domain: 'acme.com' }, operator: 'pinnacle.com', billing: 'agent' }, + ], + }, + }, + }); + const rows = result.structuredContent.accounts; + assert.strictEqual(rows[0].action, 'updated'); + assert.strictEqual(rows[1].errors[0].field, 'accounts[1].brand', 'index must be the buyer request index'); + }); + + it('refuses a hybrid entry claiming two account references, with no write (relaxed validation)', async () => { + // Request validation is relaxable, so the gate cannot assume the + // schema's per-entry oneOf held. An entry carrying a root account_id AND + // a nested natural-key ref must not be accepted on the id and then + // written against the key nobody verified. + let upsertCalled = false; + const platform = derivedSyncPlatform(async entries => { + upsertCalled = true; + return entries.map(settingsUpdateRow); + }); + const server = createAdcpServerFromPlatform(platform, SERVER_OPTS); + const result = await server.dispatchTestRequest({ + method: 'tools/call', + params: { + name: 'sync_accounts', + arguments: { + idempotency_key: 'idem-derived-hybrid-000000000', + accounts: [ + { + account_id: 'upstream_acct_1', + account: { brand: { domain: 'acme.com' }, operator: 'pinnacle.com' }, + }, + ], }, }, }); assert.strictEqual(result.isError, true); assert.strictEqual(result.structuredContent.adcp_error.code, 'INVALID_REQUEST'); - assert.strictEqual(result.structuredContent.adcp_error.field, 'account.account_id'); + assert.match(result.structuredContent.adcp_error.message, /more than one account reference/); + assert.strictEqual(upsertCalled, false, 'an ambiguous entry must not reach the adopter'); }); - it('derived-mode permits brand+operator union arm (no account_id present)', async () => { - // Brand+operator refs are not refused by the framework regardless of - // resolution mode — only the `{ account_id }` arm is refused. Adopters - // who want to additionally reject brand-arm refs do so in their resolver. - let sawRef; + it('refuses a hybrid entry that mixes id and natural key inside `account`', async () => { + let upsertCalled = false; + const platform = derivedSyncPlatform(async entries => { + upsertCalled = true; + return entries.map(settingsUpdateRow); + }); + const server = createAdcpServerFromPlatform(platform, SERVER_OPTS); + const result = await server.dispatchTestRequest({ + method: 'tools/call', + params: { + name: 'sync_accounts', + arguments: { + idempotency_key: 'idem-derived-hybrid2-00000000', + accounts: [ + { + account: { account_id: 'upstream_acct_1', brand: { domain: 'acme.com' }, operator: 'pinnacle.com' }, + }, + ], + }, + }, + }); + assert.strictEqual(result.isError, true); + assert.strictEqual(result.structuredContent.adcp_error.code, 'INVALID_REQUEST'); + assert.strictEqual(upsertCalled, false); + }); + + it('refuses a provisioning trio that also carries a nested account ref', async () => { + let upsertCalled = false; + const platform = derivedSyncPlatform(async entries => { + upsertCalled = true; + return entries.map(settingsUpdateRow); + }); + const server = createAdcpServerFromPlatform(platform, SERVER_OPTS); + const result = await server.dispatchTestRequest({ + method: 'tools/call', + params: { + name: 'sync_accounts', + arguments: { + idempotency_key: 'idem-derived-hybrid3-00000000', + accounts: [ + { + brand: { domain: 'acme.com' }, + operator: 'pinnacle.com', + billing: 'agent', + account: { account_id: 'upstream_acct_1' }, + }, + ], + }, + }, + }); + assert.strictEqual(result.isError, true); + assert.strictEqual(result.structuredContent.adcp_error.code, 'INVALID_REQUEST'); + assert.strictEqual(upsertCalled, false); + }); + + it('reports an incomplete natural key as INVALID_REQUEST, not "mode unsupported"', async () => { + const platform = derivedSyncPlatform(async entries => entries.map(settingsUpdateRow)); + const server = createAdcpServerFromPlatform(platform, SERVER_OPTS); + const result = await server.dispatchTestRequest({ + method: 'tools/call', + params: { + name: 'sync_accounts', + arguments: { + idempotency_key: 'idem-derived-halfkey-00000000', + accounts: [{ operator: 'pinnacle.com', billing: 'agent' }], + }, + }, + }); + assert.strictEqual(result.isError, true); + assert.strictEqual(result.structuredContent.adcp_error.code, 'INVALID_REQUEST'); + assert.match(result.structuredContent.adcp_error.message, /incomplete natural key/); + }); + + it('accepts a root-level account_id when it is the only reference', async () => { + let sawEntries; + const platform = derivedSyncPlatform(async entries => { + sawEntries = entries; + return entries.map(settingsUpdateRow); + }); + const server = createAdcpServerFromPlatform(platform, SERVER_OPTS); + const result = await server.dispatchTestRequest({ + method: 'tools/call', + params: { + name: 'sync_accounts', + arguments: { + idempotency_key: 'idem-derived-rootid-00000000', + accounts: [{ account_id: 'upstream_acct_1' }], + }, + }, + }); + assert.notStrictEqual(result.isError, true, `expected success, got ${JSON.stringify(result.structuredContent)}`); + assert.strictEqual(sawEntries.length, 1); + }); + + it('verifies a root-level account_id against the reachable set', async () => { + let upsertCalled = false; + const platform = derivedSyncPlatform(async entries => { + upsertCalled = true; + return entries.map(settingsUpdateRow); + }); + const server = createAdcpServerFromPlatform(platform, SERVER_OPTS); + const result = await server.dispatchTestRequest({ + method: 'tools/call', + params: { + name: 'sync_accounts', + arguments: { + idempotency_key: 'idem-derived-rootid2-0000000', + accounts: [{ account_id: 'someone_elses_account' }], + }, + }, + }); + assert.strictEqual(result.structuredContent.adcp_error.code, 'ACCOUNT_NOT_FOUND'); + assert.strictEqual(upsertCalled, false); + }); + + it('implicit platforms still provision by natural key (no cross-mode leak)', async () => { + let sawEntries; const platform = buildImplicitPlatform({ accounts: { - resolution: 'derived', - resolve: async ref => { - sawRef = ref; - return { - id: 'singleton', - name: 'Singleton', - status: 'active', - ctx_metadata: {}, - }; + resolution: 'implicit', + resolve: async () => ({ id: 'acc', name: 'Acme', status: 'active', ctx_metadata: {} }), + list: async () => ({ items: [] }), + upsert: async entries => { + sawEntries = entries; + return entries.map(e => ({ brand: e.brand, operator: e.operator, action: 'created', status: 'active' })); }, }, }); @@ -453,41 +918,289 @@ describe("#1468 — accounts.resolution: 'derived' refuses inline account_id (mi const result = await server.dispatchTestRequest({ method: 'tools/call', params: { - name: 'get_products', + name: 'sync_accounts', arguments: { - brief: 'premium', - promoted_offering: 'cars', - account: { brand: { domain: 'acme.com' }, operator: 'pinnacle.com' }, + idempotency_key: 'idem-implicit-1-0000000000000000', + accounts: [{ brand: { domain: 'acme.com' }, operator: 'pinnacle.com', billing: 'agent' }], }, }, }); assert.notStrictEqual(result.isError, true, `expected success, got ${JSON.stringify(result.structuredContent)}`); - assert.deepStrictEqual(sawRef, { brand: { domain: 'acme.com' }, operator: 'pinnacle.com' }); + assert.strictEqual(sawEntries.length, 1); + assert.strictEqual(result.structuredContent.accounts[0].action, 'created'); }); +}); - it('refusal fires regardless of auth posture — authenticated requests with inline account_id still rejected', async () => { - // Locks the contract: an authenticated buyer that sends inline account_id - // to a derived agent gets the same INVALID_REQUEST as an unauthenticated - // one. Prevents future drift where someone might soften the refusal "for - // authenticated principals only" — the field is meaningless on the wire - // for derived-mode regardless of who's asking. - let resolveCalled = false; - const platform = buildImplicitPlatform({ +describe('#1647 — merge-seam sync_accounts handler is gated too', () => { + const ACCOUNT = { + id: 'upstream_acct_1', + name: 'Upstream', + status: 'active', + brand: { domain: 'acme.com' }, + operator: 'pinnacle.com', + ctx_metadata: {}, + }; + const row = () => ({ + account_id: ACCOUNT.id, + brand: ACCOUNT.brand, + operator: ACCOUNT.operator, + action: 'updated', + status: 'active', + }); + // No `accounts.upsert` on the platform: sync_accounts is served entirely + // from `opts.accounts.syncAccounts`, the documented merge-seam wiring. + const seamPlatform = () => + buildImplicitPlatform({ accounts: { resolution: 'derived', - resolve: async () => { - resolveCalled = true; - return null; + resolve: async ref => (ref?.account_id === undefined || ref.account_id === ACCOUNT.id ? ACCOUNT : null), + list: async () => ({ items: [ACCOUNT] }), + }, + }); + + it('refuses natural-key provisioning before the adopter handler runs', async () => { + let seen; + const server = createAdcpServerFromPlatform(seamPlatform(), { + ...SERVER_OPTS, + accounts: { + syncAccounts: async params => { + seen = params.accounts; + return { accounts: params.accounts.map(row) }; }, }, }); - const server = createAdcpServerFromPlatform(platform, { + const result = await server.dispatchTestRequest({ + method: 'tools/call', + params: { + name: 'sync_accounts', + arguments: { + idempotency_key: 'idem-seam-1-000000000000000000', + accounts: [{ brand: { domain: 'acme.com' }, operator: 'pinnacle.com', billing: 'agent' }], + }, + }, + }); + assert.strictEqual(result.structuredContent.accounts[0].errors[0].code, 'UNSUPPORTED_PROVISIONING'); + assert.strictEqual(seen, undefined, 'the adopter handler must not see refused entries'); + }); + + it('realigns rows in a mixed batch without mutating the handler’s own array', async () => { + // Adopters legitimately return cached results (the idempotency-replay + // shape). Splicing into their array would corrupt the cache. + const cached = { accounts: [row()] }; + const server = createAdcpServerFromPlatform(seamPlatform(), { ...SERVER_OPTS, - authenticate: () => ({ - kind: 'oauth', - credential: { kind: 'oauth', client_id: 'authed-buyer', scopes: [] }, + accounts: { syncAccounts: async () => cached }, + }); + const result = await server.dispatchTestRequest({ + method: 'tools/call', + params: { + name: 'sync_accounts', + arguments: { + idempotency_key: 'idem-seam-2-000000000000000000', + accounts: [ + { brand: { domain: 'acme.com' }, operator: 'pinnacle.com', billing: 'agent' }, + { account: { account_id: 'upstream_acct_1' } }, + ], + }, + }, + }); + const rows = result.structuredContent.accounts; + assert.strictEqual(rows.length, 2); + assert.strictEqual(rows[0].errors[0].code, 'UNSUPPORTED_PROVISIONING'); + assert.strictEqual(rows[1].action, 'updated'); + assert.strictEqual(cached.accounts.length, 1, 'adopter-owned array must be untouched'); + }); + + it('refuses a hybrid entry before the adopter handler runs (relaxed validation)', async () => { + let called = false; + const server = createAdcpServerFromPlatform(seamPlatform(), { + ...SERVER_OPTS, + accounts: { + syncAccounts: async params => { + called = true; + return { accounts: params.accounts.map(row) }; + }, + }, + }); + const result = await server.dispatchTestRequest({ + method: 'tools/call', + params: { + name: 'sync_accounts', + arguments: { + idempotency_key: 'idem-seam-hybrid-0000000000000', + accounts: [ + { + account_id: 'upstream_acct_1', + account: { brand: { domain: 'acme.com' }, operator: 'pinnacle.com' }, + }, + ], + }, + }, + }); + assert.strictEqual(result.isError, true); + assert.strictEqual(result.structuredContent.adcp_error.code, 'INVALID_REQUEST'); + assert.strictEqual(called, false, 'the adopter handler must not run for an ambiguous entry'); + }); + + it('refuses an unreachable account_id before the adopter handler runs', async () => { + let called = false; + const server = createAdcpServerFromPlatform(seamPlatform(), { + ...SERVER_OPTS, + accounts: { + syncAccounts: async params => { + called = true; + return { accounts: params.accounts.map(row) }; + }, + }, + }); + const result = await server.dispatchTestRequest({ + method: 'tools/call', + params: { + name: 'sync_accounts', + arguments: { + idempotency_key: 'idem-seam-3-000000000000000000', + accounts: [{ account: { account_id: 'someone_elses_account' } }], + }, + }, + }); + assert.strictEqual(result.structuredContent.adcp_error.code, 'ACCOUNT_NOT_FOUND'); + assert.strictEqual(called, false); + }); +}); + +describe('#1647 — sync_governance on a derived platform', () => { + const ACCOUNT = { id: 'upstream_acct_1', name: 'Upstream', status: 'active', ctx_metadata: {} }; + const govPlatform = syncGovernance => + buildImplicitPlatform({ + accounts: { + resolution: 'derived', + resolve: async ref => (ref?.account_id === undefined || ref.account_id === ACCOUNT.id ? ACCOUNT : null), + list: async () => ({ items: [ACCOUNT] }), + syncGovernance, + }, + }); + const agents = [{ url: 'https://gov.example/mcp' }]; + + it('persists an entry whose account the credential can reach', async () => { + let seen; + const server = createAdcpServerFromPlatform( + govPlatform(async entries => { + seen = entries; + return entries.map(e => ({ account: e.account, status: 'synced', governance_agents: agents })); + }), + SERVER_OPTS + ); + const result = await server.dispatchTestRequest({ + method: 'tools/call', + params: { + name: 'sync_governance', + arguments: { + idempotency_key: 'idem-gov-1-0000000000000000000', + accounts: [{ account: { account_id: 'upstream_acct_1' }, governance_agents: agents }], + }, + }, + }); + assert.strictEqual(result.structuredContent.accounts[0].status, 'synced'); + assert.strictEqual(seen.length, 1); + }); + + it('fails an entry naming an account the credential cannot reach, without persisting it', async () => { + let seen = []; + const server = createAdcpServerFromPlatform( + govPlatform(async entries => { + seen = entries; + return entries.map(e => ({ account: e.account, status: 'synced', governance_agents: agents })); + }), + SERVER_OPTS + ); + const result = await server.dispatchTestRequest({ + method: 'tools/call', + params: { + name: 'sync_governance', + arguments: { + idempotency_key: 'idem-gov-2-0000000000000000000', + accounts: [ + { account: { account_id: 'someone_elses_account' }, governance_agents: agents }, + { account: { account_id: 'upstream_acct_1' }, governance_agents: agents }, + ], + }, + }, + }); + const rows = result.structuredContent.accounts; + assert.strictEqual(rows[0].status, 'failed'); + assert.strictEqual(rows[0].errors[0].code, 'ACCOUNT_NOT_FOUND'); + assert.strictEqual(rows[1].status, 'synced'); + assert.strictEqual(seen.length, 1, 'only the reachable entry reaches the adopter'); + }); + + it('fails a hybrid reference (id + natural key) without persisting it', async () => { + let seen = []; + const server = createAdcpServerFromPlatform( + govPlatform(async entries => { + seen = entries; + return entries.map(e => ({ account: e.account, status: 'synced', governance_agents: agents })); }), + SERVER_OPTS + ); + const result = await server.dispatchTestRequest({ + method: 'tools/call', + params: { + name: 'sync_governance', + arguments: { + idempotency_key: 'idem-gov-hybrid-000000000000', + accounts: [ + { + account: { account_id: 'upstream_acct_1', brand: { domain: 'acme.com' }, operator: 'pinnacle.com' }, + governance_agents: agents, + }, + ], + }, + }, + }); + const row = result.structuredContent.accounts[0]; + assert.strictEqual(row.status, 'failed'); + assert.strictEqual(row.errors[0].code, 'INVALID_REQUEST'); + assert.strictEqual(seen.length, 0, 'nothing may persist for an ambiguous reference'); + }); + + it('fails a natural-key reference with INVALID_REQUEST', async () => { + const server = createAdcpServerFromPlatform( + govPlatform(async entries => + entries.map(e => ({ account: e.account, status: 'synced', governance_agents: agents })) + ), + SERVER_OPTS + ); + const result = await server.dispatchTestRequest({ + method: 'tools/call', + params: { + name: 'sync_governance', + arguments: { + idempotency_key: 'idem-gov-3-0000000000000000000', + accounts: [ + { account: { brand: { domain: 'acme.com' }, operator: 'pinnacle.com' }, governance_agents: agents }, + ], + }, + }, + }); + const row = result.structuredContent.accounts[0]; + assert.strictEqual(row.status, 'failed'); + assert.strictEqual(row.errors[0].code, 'INVALID_REQUEST'); + }); +}); + +describe('#1647 — framework verifies the resolver honored the reference', () => { + it('refuses a resolved account whose id is not the one the buyer named', async () => { + // The pre-14 Shape D pattern: ignore `ref`, return the one account. + // After the inversion that would serve caller A against whatever the + // resolver returns, silently. The framework fails it closed instead. + const platform = buildImplicitPlatform({ + accounts: { + resolution: 'derived', + resolve: async () => ({ id: 'the_only_account', name: 'Singleton', status: 'active', ctx_metadata: {} }), + list: async () => ({ items: [] }), + }, }); + const server = createAdcpServerFromPlatform(platform, SERVER_OPTS); const result = await server.dispatchTestRequest({ method: 'tools/call', params: { @@ -495,13 +1208,28 @@ describe("#1468 — accounts.resolution: 'derived' refuses inline account_id (mi arguments: { brief: 'premium', promoted_offering: 'cars', - account: { account_id: 'foo' }, + account: { account_id: 'a_different_account' }, }, }, }); assert.strictEqual(result.isError, true); - assert.strictEqual(result.structuredContent.adcp_error.code, 'INVALID_REQUEST'); - assert.strictEqual(result.structuredContent.adcp_error.field, 'account.account_id'); - assert.strictEqual(resolveCalled, false, 'authed principal does not soften the refusal'); + assert.strictEqual(result.structuredContent.adcp_error.code, 'ACCOUNT_NOT_FOUND'); + assert.match(result.structuredContent.adcp_error.suggestion, /list_accounts/); + }); + + it('still serves the ref-less path for the same resolver', async () => { + const platform = buildImplicitPlatform({ + accounts: { + resolution: 'derived', + resolve: async () => ({ id: 'the_only_account', name: 'Singleton', status: 'active', ctx_metadata: {} }), + list: async () => ({ items: [] }), + }, + }); + const server = createAdcpServerFromPlatform(platform, SERVER_OPTS); + const result = await server.dispatchTestRequest({ + method: 'tools/call', + params: { name: 'get_products', arguments: { brief: 'premium', promoted_offering: 'cars' } }, + }); + assert.notStrictEqual(result.isError, true, `expected success, got ${JSON.stringify(result.structuredContent)}`); }); }); diff --git a/test/server-pool-shortcut.test.js b/test/server-pool-shortcut.test.js index a03047599..73ea68eea 100644 --- a/test/server-pool-shortcut.test.js +++ b/test/server-pool-shortcut.test.js @@ -164,6 +164,7 @@ describe('createAdcpServerFromPlatform — pool shortcut', () => { }, accounts: { resolution: 'derived', + list: async () => ({ items: [] }), resolve: async () => ({ id: 'pub_main', operator: 'mypub', ctx_metadata: {} }), }, sales: {