Skip to content

chore: release package#1014

Merged
bokelley merged 1 commit into
mainfrom
changeset-release/main
Apr 26, 2026
Merged

chore: release package#1014
bokelley merged 1 commit into
mainfrom
changeset-release/main

Conversation

@github-actions

@github-actions github-actions Bot commented Apr 26, 2026

Copy link
Copy Markdown
Contributor

This PR was opened by the Changesets release GitHub action. When you're ready to do a release, you can merge this and the packages will be published to npm automatically. If you're not ready to do a release yet, that's fine, whenever you add more changesets to main, this PR will be updated.

Releases

@adcp/client@5.20.0

Minor Changes

  • b43b39d: feat(signing): PostgresReplayStore for distributed verifier deployments

    Adds a Postgres-backed ReplayStore so multi-instance verifier deployments share replay-protection state. The default InMemoryReplayStore is per-process; on a fleet, an attacker who captures a signed request can replay it against a sibling whose cache hasn't seen the nonce — RFC 9421's 5-minute expiry bounds the window but that's plenty of time for an in-flight replay. PostgresReplayStore closes that hole using a (keyid, scope, nonce) primary key the verifier checks on every signed request.

    New exports from @adcp/client/signing/server:

    • PostgresReplayStoreReplayStore implementation against the structural PgQueryable interface (same pattern as PostgresTaskStore and PostgresStateStore; the SDK stays free of a hard pg dependency).
    • getReplayStoreMigration(tableName?) — idempotent DDL for the cache table plus indexes on expires_at and (keyid, scope, expires_at).
    • sweepExpiredReplays(pool, options?) — exported helper for callers to schedule (cron, app timer, pg_cron, etc.); Postgres has no native row-level TTL, so expired rows have to be deleted explicitly.

    The insert path is a single CTE statement that handles replay/cap/insert decision atomically. ON CONFLICT DO UPDATE WHERE existing-is-expired recycles expired rows in place — a same-nonce insert after the previous registration's TTL elapsed (but before the sweeper ran) correctly returns 'ok' rather than falsely reporting 'replayed'. Concurrent same-nonce inserts (10 parallel) consistently produce exactly one 'ok' and the rest 'replayed', matching InMemoryReplayStore semantics.

    Wire format unchanged. No AdCP version bump.

    See docs/guides/SIGNING-GUIDE.md § Verify Inbound Signatures for the multi-instance failure mode and the wire-up.

    Closes Distributed ReplayStore adapter (Redis) for multi-instance verifier deployments #1015.

  • 78fdb54: feat(testing): adcp grade signer — validate a signer end-to-end before going live

    Adds a CLI grader and matching library function that exercises a signer (typically KMS-backed) end-to-end: produces a sample signed AdCP request through the operator's signer, then verifies the result against the operator's published JWKS via the SDK's RFC 9421 verifier. Pass means a counterparty verifier will accept your signatures; fail produces a specific error_code + step matching the verifier-checklist semantics, so DER-vs-P1363 / kid-mismatch / wrong-key / algorithm-mismatch each surface as a distinct diagnostic instead of the generic request_signature_invalid you'd see in the seller's monitoring after pushing live traffic.

    Two signer-source modes:

    • --key-file <path> — local JWK file. Easy path for local dev / non-KMS testing.
    • --signer-url <url> — HTTP signing oracle for KMS-backed signers. Wire contract is intentionally minimal — POST {payload_b64, kid, alg} returns {signature_b64} (raw wire-format bytes, not DER) — so any KMS adapter can put a small handler in front of provider.sign() for grading without exposing the underlying KMS to the grader.

    Programmatic API: gradeSigner(options) exported from @adcp/client/testing/storyboard/signer-grader. Returns a SignerGradeReport with passed, step.{status,error_code,diagnostic}, the JWKS URI it resolved against, and the sample request the signer produced headers for (useful for operator-side diagnostics).

    Pairs with the SigningProvider abstraction (also in 5.20.0) — that release added the surface for KMS-backed signing; this one closes the loop by giving operators a way to validate their adapter before going live.

    Closes signing: signer-side conformance grader (complement to the verifier grader in #585) #610.

  • c4afc75: feat(signing): add SigningProvider abstraction for KMS-backed RFC 9421 signing

    Adds a pluggable SigningProvider interface so private keys can live in a
    managed key store (GCP KMS, AWS KMS, Azure Key Vault, HashiCorp Vault Transit)
    instead of process memory. The async sign(payload) boundary matches RFC
    9421 §3.1 — the SDK produces the canonical signature base, the provider
    returns wire-format signature bytes.

    New surface:

    • SigningProvider interface and AdcpSignAlg type (exported from
      @adcp/client/signing).
    • signRequestAsync / signWebhookAsync — async variants that accept a
      provider; sync signRequest / signWebhook are unchanged.
    • createSigningFetchAsync(upstream, provider, options) — async-signing
      fetch wrapper, paired with the existing sync createSigningFetch. Two
      symbols rather than one overload so the latency-cost distinction is
      visible at integration time.
    • derEcdsaToP1363(der, componentLen) — DER → IEEE P1363 ECDSA signature
      converter for KMS adapters whose sign API returns DER (GCP, AWS, Azure).
    • SigningProviderAlgorithmMismatchError — typed error adapters throw when
      the declared algorithm doesn't match the underlying key, so misconfigurations
      fail fast at adapter construction rather than producing signatures verifiers
      reject downstream.
    • @adcp/client/signing/testing sub-path exporting InMemorySigningProvider
      and signerKeyToProvider. Constructor refuses to instantiate when
      NODE_ENV=production unless ADCP_ALLOW_IN_MEMORY_SIGNER=1 is set.

    AgentRequestSigningConfig is now a discriminated union on kind:

    • kind: 'inline' (default — kind is optional on this shape so existing
      literals work unchanged) holds a private JWK in process.
    • kind: 'provider' delegates sign() to a SigningProvider.

    buildAgentSigningContext defensively hashes the provider-supplied
    fingerprint together with algorithm and kid before composing
    transport- and capability-cache keys, preserving the multi-tenant isolation
    property the in-memory path has always provided. The signing identity is
    snapshotted at context-build time so a provider object whose fields drift
    between build and outbound request cannot desynchronize the on-wire keyid
    from the cache key the connection was bound to.

    Behavior change for non-UTF-8 byte bodies: createSigningFetch and
    createSigningFetchAsync now throw TypeError on Uint8Array /
    ArrayBuffer request bodies that aren't valid UTF-8. Previously, invalid
    bytes were silently replaced with U+FFFD by Buffer.toString('utf8')
    verification still passed because the wire and the digest agreed on the
    lossy string, but the seller received mangled content. Callers hitting
    this should pass a string body, ensure their bytes are UTF-8, or sign
    manually with signRequest / signRequestAsync against the exact wire
    bytes they intend to send. Error message names the escape hatch.

    Wire format unchanged. No AdCP version bump.

    A reference GCP KMS adapter ships at examples/gcp-kms-signing-provider.ts,
    type-checked under npm run typecheck:examples. AWS KMS and Azure Key Vault
    adapters can mirror the same pattern; users npm i the cloud SDK they need.

    See Add SigningProvider abstraction for external key management (KMS/HSM/Vault) #1009.

Patch Changes

  • a8e50ac: fix(hints): drop AJV-prose fallback in groupRequiredIssues

    MissingRequiredFieldHint.missing_fields is documented as "Field name(s) the parent object was required to carry." When the field-name extraction regex did not match an AJV required error message (e.g. a reworded or locale-variant message), the fallback ?? issue.message wrote the entire AJV prose string into missing_fields[] as if it were a field name. Downstream renderers (CLI, Addie, JUnit) wrap entries in backticks and generate "add the X field" coaching, so they would produce nonsense output for these entries.

    The fallback is now removed. When the regex does not match, the issue is skipped — missing_fields contains only clean field identifiers. Unextractable issues remain visible via ValidationResult.warning.

  • 976c6e0: docs(testing): add @provenance annotations to StoryboardStepHint fields

    Each field on the five hint kinds (ContextValueRejectedHint, ShapeDriftHint,
    MissingRequiredFieldHint, FormatMismatchHint, MonotonicViolationHint) now
    carries a @provenance seller|storyboard|runner tag so downstream renderers
    (Addie, CLI, JUnit) can identify which fields contain seller-controlled bytes
    that must be sanitized before reaching prompt-injection-vulnerable surfaces.

    Also annotates StoryboardStepHintBase.message with an explicit warning that
    the pre-formatted string embeds seller bytes for context_value_rejected and
    monotonic_violation kinds; and adds @provenance to typedoc.json blockTags so
    the TypeDoc build recognises the new tag.

    Motivated by adcp#3084 and adcp#3220, where undocumented seller provenance on
    request_field and from_status produced prompt-injection vectors in downstream
    renderers.

@github-actions github-actions Bot force-pushed the changeset-release/main branch from baa19a2 to 66b9890 Compare April 26, 2026 00:04
@github-actions github-actions Bot requested a review from bokelley as a code owner April 26, 2026 00:04
@github-actions github-actions Bot force-pushed the changeset-release/main branch 2 times, most recently from fb793c4 to a4d4ae9 Compare April 26, 2026 14:13
bokelley added a commit that referenced this pull request Apr 26, 2026
…ts (#1018)

* feat(signing): PostgresReplayStore for distributed verifier deployments

The default InMemoryReplayStore is per-process — multi-instance verifier
fleets leak replay protection across the boundary because each box has
its own cache. RFC 9421's 5-minute window bounds the gap but it's plenty
of time for an in-flight replay. PostgresReplayStore closes the hole
using a (keyid, scope, nonce) primary key the verifier checks on every
signed request.

New exports from @adcp/client/signing/server:
- PostgresReplayStore (against the structural PgQueryable interface; same
  pattern as PostgresTaskStore / PostgresStateStore)
- getReplayStoreMigration(tableName?) — idempotent DDL with indexes on
  expires_at and (keyid, scope, expires_at)
- sweepExpiredReplays(pool, options?) — Postgres has no native row-level
  TTL; callers schedule this helper themselves (cron, app timer, pg_cron)

The insert path is one CTE statement that handles replay/cap/insert
atomically. ON CONFLICT DO UPDATE WHERE existing-is-expired recycles
expired rows in place — a same-nonce insert after the previous
registration's TTL elapsed (but before the sweeper ran) correctly returns
'ok' rather than falsely 'replayed'. Concurrent same-nonce inserts (10
parallel) consistently produce exactly one 'ok' and the rest 'replayed',
matching InMemoryReplayStore semantics.

13 integration tests against real Postgres (skipped when DATABASE_URL is
unset, same convention as postgres-task-store.test.js):
- Migration + custom table names + SQL-injection-shaped name rejection
- Replay precedence + (keyid, scope) partitioning
- TTL boundary (has() respects expiry; insert recycles expired nonces)
- Cap precedence: replay wins over rate_abuse wins over ok
- Sweeper: full + batched
- Concurrency: 10 parallel inserts produce exactly one ok
- E2E: signed request → verifier with PostgresReplayStore → second
  attempt rejected as replay

Wire format unchanged. SDK minor: accumulates into Release PR #1014.

Closes #1015.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(signing): tighten PostgresReplayStore per expert review

Three reviewers (security, code, protocol) cleared PR #1018 with no
blockers but flagged consensus follow-ups:

- Add `now`/`ttlSeconds` validation (security M2). PG `to_timestamp(NaN)`
  raises a parse error and `to_timestamp(±Infinity)` silently produces an
  `infinity` timestamp; a buggy `options.now()` injection point could DoS
  the verifier with PG errors. New `assertFiniteSeconds` helper rejects
  non-finite or out-of-range inputs at the three method boundaries with
  a clear TypeError.

- Add `REPLAY_CACHE_MIGRATION` constant alongside `getReplayStoreMigration`
  to match the `MCP_TASKS_MIGRATION` / `ADCP_STATE_MIGRATION` convention
  in the sibling Postgres stores.

- JSDoc note that `cap` is best-effort under concurrency — two concurrent
  inserts at `cap-1` can both observe the same MVCC snapshot and briefly
  overshoot. Matches `InMemoryReplayStore` semantics; the cap is a soft
  DoS guard, not a hard invariant.

Five new tests:

- Cap clears once entries pass expiry without the sweeper running (the
  prune-then-check semantic from `InMemoryReplayStore`, locked in so a
  future schema change can't quietly regress it — protocol blocker).
- Concurrent recycle of an expired same-nonce: 10 parallel inserts past
  expiry produce exactly 1 `ok` and 9 `replayed` (the security review
  asked for this; the SQL was already correct, just untested).
- `sweepExpiredReplays` with `batchSize` larger than the active expired
  count returns the actual count without error.
- `now` / `ttlSeconds` validation: NaN, ±Infinity, negative all rejected.
- End-to-end rate-abuse via the verifier pipeline: cap of 2, third
  unique signed request rejected with `request_signature_rate_abuse`
  (covers the conformance-vector-`negative/020` rejection path).

`setCapHitForTesting` hook intentionally omitted — the protocol reviewer
suggested option (a) "document the limitation" over (b) "add the hook,"
and white-box conformance grading against Postgres can use `cap: small`
+ real inserts to reach the cap-hit state.

Test totals: 18/18 PostgresReplayStore integration tests + 175 existing
signing tests — all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot force-pushed the changeset-release/main branch from a4d4ae9 to 8924329 Compare April 26, 2026 15:10
bokelley added a commit that referenced this pull request Apr 26, 2026
#1019)

* feat(testing): adcp grade signer — validate a signer end-to-end before going live

Pairs with #1017's SigningProvider abstraction. After standing up a KMS
adapter, operators need a way to validate "is this thing producing valid
AdCP signatures?" before pushing live signed traffic — otherwise the only
feedback channel is the seller's `request_signature_invalid` rejection,
which is generic and arrives only after the misconfiguration is in
production.

The grader produces a sample signed AdCP request through the operator's
signer, then verifies it against the operator's published JWKS via the
SDK's RFC 9421 verifier. Pass means a counterparty will accept your
signatures; fail surfaces a specific verifier error_code + step, so
DER-vs-P1363 / kid-mismatch / wrong-key / algorithm-mismatch each show
up as distinct diagnostics.

Two signer-source modes:

- `--key-file <path>` for local dev (JWK with `d`)
- `--signer-url <url>` for KMS-backed signers — minimal HTTP oracle
  protocol (POST {payload_b64, kid, alg} → {signature_b64}) so any KMS
  adapter can put a small handler in front of provider.sign() without
  exposing the underlying KMS to the grader

Programmatic API: `gradeSigner(options)` exported from
`@adcp/client/testing/storyboard/signer-grader`. Returns
`SignerGradeReport` with passed/step/diagnostic + the sample request the
signer produced headers for.

CLI:
  adcp grade signer <agent-url>
    --signer-url ... | --key-file ...
    --kid <kid> --alg <ed25519|ecdsa-p256-sha256>
    --jwks-url <url>
    [--operation <name>] [--signer-auth <header>] [--timeout <ms>]
    [--allow-http] [--json]

9 unit/integration tests cover both signer-source modes, the verifier
failure paths a misconfigured adapter would hit (algorithm mismatch,
wrong-key signing, signer-url 401, JWKS unreachable), and the input
validation (both modes / missing modes / missing key file).

Local CLI smoke test:
  $ adcp grade signer http://127.0.0.1:9999/agent \\
      --key-file ./key.jwk --kid test-ed25519-2026 \\
      --alg ed25519 --jwks-url http://127.0.0.1:N/.well-known/jwks.json \\
      --allow-http
  Result: PASS
    diagnostic: Signature verified end-to-end against JWKS.

Closes #610. Accumulates into Release PR #1014 (still 5.20.0 minor).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(testing): tighten signer grader per expert review (SSRF, digest, errors)

Three reviewers (security, code, protocol). Security flagged two HIGH
blockers — both collapse into routing the signing-oracle POST through
ssrfSafeFetch. Protocol flagged two BLOCKERS around content-digest
policy (the most common KMS-adapter misconfiguration was being masked).
Plus the should-fix items from code review.

Security blockers (HIGH):

- httpOracleProvider used plain fetch(), bypassing the SDK's SSRF guard.
  Concrete attack: an internal attacker sets --signer-url to IMDS or
  redirects the POST to capture the --signer-auth Bearer token. Now
  routes through ssrfSafeFetch — gets DNS pinning, IMDS-block,
  manual-redirect (no Location-following), and a 64 KiB body cap.
- --signer-auth header could leak via 302 redirect. Same fix —
  ssrfSafeFetch refuses to follow redirects.
- No response-body size cap. Same fix — ssrfSafeFetch caps at 64 KiB.

Protocol blockers:

- Hardcoded covers_content_digest: 'either' masked the digest-policy
  misconfiguration KMS adapters most commonly hit (forgetting to emit
  Content-Digest against a 'required' verifier). Now defaults to
  'required' (recommended posture for spend-committing operations) and
  exposes --covers-content-digest [required|forbidden|either] so
  operators match what their verifier actually advertises.
- Sample body now reliably triggers step 11 (digest recompute) when
  policy is 'required' — the signer emits Content-Digest, the verifier
  exercises the digest path. Test asserts both branches: the header
  is present under 'required', omitted under 'forbidden'.

Code-review fixes:

- Dead branch for verifier_returned_unsigned (unreachable —
  signRequestAsync always emits headers) replaced with a hard throw so
  an SDK bug surfaces loudly rather than masquerading as a custom code.
- error_code typed as a discriminated union (SignerGradeErrorCode =
  RequestSignatureErrorCode | SignerGraderErrorCode) so consumers
  writing exhaustive switch over the report see grader-side and
  verifier-side failures as distinct categories.
- Case-insensitive header lookup in the CLI report printer (a future
  signer that emits lowercase header names won't silently elide them).
- Lib + CLI error messages now agree on flag names (--key-file /
  --signer-url) so --json reports use names operators recognize.

Protocol note:

- Layered DER-vs-P1363 hint on top of step-10 errors when algorithm is
  ECDSA-P256. The most likely cause of a generic
  request_signature_invalid in that case is a KMS adapter that didn't
  run output through derEcdsaToP1363; surfacing this inline saves
  operators a debugging cycle.

Security MED:

- Stderr breadcrumb when --key-file is loaded so CI logs make the
  dev-tool nature visible — operators reviewing the log can confirm
  the file isn't checked in.

Test totals: 11/11 (was 9). New tests: required-policy emits
Content-Digest; forbidden-policy omits it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot force-pushed the changeset-release/main branch from 8924329 to a4dfddc Compare April 26, 2026 15:47
@bokelley bokelley merged commit 6d8a089 into main Apr 26, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant