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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,15 @@ jobs:
working-directory: adcp/v3
run: go test -race -count=1 ./...

# Unit tests only (sqlmock-backed) — no Docker in this job. The
# Postgres-testcontainer suite is gated behind `-tags=integration`
# (see adcp/v3/signing/pgreplay/README.md) and runs locally / wherever
# Docker is available, matching registry/redisstore's and
# registry/glidestore's integration test convention.
- name: Test adcp/v3/signing/pgreplay module
working-directory: adcp/v3/signing/pgreplay
run: go test -race -count=1 ./...

- name: Test targeting module
working-directory: targeting
run: go test -race -count=1 ./...
Expand Down
6 changes: 5 additions & 1 deletion adcp/v3/signing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,11 @@ For custom deployments, implement the two-method `JWKSResolver` interface.

## Replay cache

`NewMemoryReplayStore(perKeyIDCap)` — LRU with TTL eviction and the profile's per-keyid entry cap (default 1,000,000). Implement the three-method `ReplayStore` interface to plug in Redis or another shared store for distributed deployments; the spec requires the step-13 insert to be atomic with a cap check in distributed setups to prevent cap drift.
`NewMemoryReplayStore(perKeyIDCap)` — LRU with TTL eviction and the profile's per-keyid entry cap (default 1,000,000). Fine for a single verifier process; a per-process cache cannot enforce RFC 9421 §11.1's replay-rejection MUST once a verifier runs as more than one process behind a load balancer, since a captured signature replayed against a sibling instance's cache is accepted.

For multi-instance deployments, use [`adcp/v3/signing/pgreplay`](pgreplay/) — a Postgres-backed `ReplayStore` (own `go.mod`, zero third-party deps beyond `database/sql` in production code) that every verifier instance shares via one `adcp_replay_cache` table, closing that gap. See its package doc for the eager-connection-probe behavior and the test-vs-production wiring pattern.

Implementing the three-method `ReplayStore` interface directly also plugs in Redis or another shared store; the spec requires the step-13 insert to be atomic with a cap check in distributed setups to prevent cap drift.

## Key generation

Expand Down
112 changes: 112 additions & 0 deletions adcp/v3/signing/pgreplay/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# adcp/v3/signing/pgreplay

Postgres-backed `adcp/v3/signing.ReplayStore` for multi-instance RFC 9421 verifier deployments.

Closes [adcontextprotocol/adcp-go#105](https://github.com/adcontextprotocol/adcp-go/issues/105) and [#54](https://github.com/adcontextprotocol/adcp-go/issues/54). Cross-validated against the JS SDK's `PostgresReplayStore` ([adcp-client#1018](https://github.com/adcontextprotocol/adcp-client/pull/1018), in production at agenticadvertising.org) and Python's `adcp-client-python` (`src/adcp/signing/pg/replay_store.py`) — same `(keyid, scope, nonce)` schema, same atomic `INSERT ... ON CONFLICT DO NOTHING` idiom.

**Module note:** both issues' text names the pre-v3 path (`adcp/signing/replay.go`). The root README's "Modules & versioning" section and `MIGRATING.md` say that module is frozen at v2.1.1, security-backports-only — `adcp/v3/signing` is the actively developed one, so this package lives there. `PostgresReplayStore` doesn't import either `signing` package (see below), so it's a structurally valid `ReplayStore` for the frozen `adcp/signing` too, byte-for-byte identical to `adcp/v3/signing.ReplayStore`'s method set as of this writing, if you haven't migrated to v3 yet.

## Why a separate module

`adcp/v3/signing.MemoryReplayStore` dedups `(keyid, nonce)` per process. That's sufficient for one verifier process; it can't deliver RFC 9421 §11.1's replay-rejection MUST once a verifier runs as ≥2 processes behind a load balancer — a captured signature replayed against a sibling instance whose in-memory cache hasn't seen the nonce is accepted. `PostgresReplayStore` gives every instance one shared table instead of N independent caches.

This directory is its own Go module. Production code here imports only `database/sql` — no driver is linked in, so it doesn't strictly *need* isolation to stay dependency-free (`adcp/idempotency`'s own Postgres adapter lives inside the shared `adcp` module today, for exactly that reason: `database/sql` alone doesn't threaten the zero-dep guarantee). The module boundary exists because #54 asked for one explicitly, as a structural, compiler-enforced guarantee that this package can never accidentally grow a real dependency that leaks into core `signing`'s import graph.

## Install

```bash
cd your-project
go get github.com/adcontextprotocol/adcp-go/adcp/v3/signing/pgreplay
```

Bring your own driver (`github.com/jackc/pgx/v5/stdlib`, `github.com/lib/pq`, ...).

## Usage

```go
import (
"database/sql"

_ "github.com/jackc/pgx/v5/stdlib"
"github.com/adcontextprotocol/adcp-go/adcp/v3/signing"
"github.com/adcontextprotocol/adcp-go/adcp/v3/signing/pgreplay"
)

db, err := sql.Open("pgx", os.Getenv("REPLAY_DATABASE_URL"))
if err != nil {
log.Fatal(err)
}

// Apply once via your migration tooling:
// pgreplay.GetReplayStoreMigration()

replay := pgreplay.NewPostgresReplayStore(db) // panics if db is unreachable

mw := signing.Middleware(signing.MiddlewareOptions{
Resolver: resolver,
Replay: replay, // satisfies signing.ReplayStore
// ...
})
```

Run `pgreplay.SweepExpiredReplays(ctx, db)` on a cron / `pg_cron` job — this package doesn't schedule sweeping itself (out of scope per #105).

## Test and local-dev environments

`NewPostgresReplayStore` probes the connection eagerly and **panics** if it can't reach Postgres. That's deliberate — see the package doc comment for the production incident (adcp#3379) this is closing. **Do not construct a `PostgresReplayStore` in tests or local dev without a real reachable Postgres.** Use `signing.NewMemoryReplayStore(0)` there, gated behind an explicit production/staging environment check:

```go
func replayStore() signing.ReplayStore {
if os.Getenv("ENVIRONMENT") != "production" && os.Getenv("ENVIRONMENT") != "staging" {
return signing.NewMemoryReplayStore(0)
}
db, err := sql.Open("pgx", os.Getenv("REPLAY_DATABASE_URL"))
if err != nil {
log.Fatalf("replay store: %v", err)
}
return pgreplay.NewPostgresReplayStore(db)
}
```

## Distinguishing "rejected" from "database is down"

`ReplayStore.Insert` returns a single `bool`, so this package's `Insert` — like `MemoryReplayStore.Insert` — cannot tell a caller "cap rejected" apart from "couldn't reach the DB" through its return value alone ([#54](https://github.com/adcontextprotocol/adcp-go/issues/54) raised this). `PostgresReplayStore` fails closed (returns `false`, i.e. reject) in both cases — a database outage must not silently disable replay rejection. To keep visibility into *which* case happened without changing the shared interface:

- `InsertContext(ctx, keyid, nonce, ttl) (bool, error)` — returns a non-nil error, wrapping `ErrConnDown`, only when the Postgres round trip itself failed.
- `LastInsertError()`, `LastSeenError()`, `LastHitCapError()` — the most recent round-trip error observed by each `ReplayStore`-interface method, for health checks / alerting.

Widening `signing.ReplayStore.Insert` itself to `(bool, error)` was considered and deliberately left out of this PR — see the PR description for the reasoning (it's a public interface with implementers outside this repo; a signature change here can't be verified against them). It's a natural, separately-reviewable follow-up.

## Schema

```sql
CREATE TABLE adcp_replay_cache (
keyid TEXT NOT NULL,
scope TEXT NOT NULL,
nonce TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (keyid, scope, nonce)
);
CREATE INDEX idx_adcp_replay_cache_expires_at ON adcp_replay_cache(expires_at);
CREATE INDEX idx_adcp_replay_cache_keyid_scope_active ON adcp_replay_cache(keyid, scope, expires_at);
```

`scope` lets one Postgres pool back more than one RFC 9421 tag profile (`adcp/request-signing/v1`, `adcp/webhook-signing/v1`) without nonce-namespace collisions — construct one store per profile via `pgreplay.WithScope(profile.Tag)`. Defaults to `"default"`.

## Testing

```bash
go test -race -count=1 ./... # unit tests (sqlmock; no Docker needed)
go test -race -tags=integration -count=1 -v ./... # + real Postgres via testcontainers-go; needs Docker
```

The integration suite proves, against a live `postgres:16-alpine` container:

- **The actual race this package exists to close**: 50 concurrent `Insert` calls for the identical `(keyid, scope, nonce)` yield exactly one winner.
- The `Seen`-then-`Insert` verifier flow under concurrency still yields exactly one winner at the `Insert` step even when multiple callers pass the `Seen` pre-check.
- `HitCap` is enforced against rows `Insert` actually wrote.
- `SweepExpiredReplays` removes only expired rows, leaving live ones untouched.
- `NewPostgresReplayStore` panics against a real (closed) connection, not just a mocked one.
- Two stores on the same database but different `scope`s don't observe each other's nonces.

Integration tests are skipped, not failed, when Docker isn't reachable (`t.Skipf`), matching `registry/redisstore`'s and `registry/glidestore`'s convention. They are not wired into `ci.yml`'s default `go test ./...` step — `-tags=integration` opts in explicitly, same as those two packages today.
103 changes: 103 additions & 0 deletions adcp/v3/signing/pgreplay/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Package pgreplay implements a Postgres-backed adcp/v3/signing.ReplayStore
// for multi-instance RFC 9421 verifier deployments.
//
// # Which module this targets
//
// This package lives under adcp/v3/signing, the actively developed module
// for AdCP 3.x (see the repo root README's "Modules & versioning" section
// and MIGRATING.md): the legacy adcp/signing module is frozen at v2.1.1 and
// receives security backports only, and a new distributed-store feature is
// not a security backport. Both #105 and #54's issue text name the pre-v3
// path (adcp/signing/replay.go) — written before the v3 split, the same way
// #53 did (see adcp/v3/signing/signingtest's own doc comment for that
// precedent). PostgresReplayStore doesn't import either signing package
// (see "Module boundary" below), so it is structurally a drop-in
// implementation of adcp/signing.ReplayStore too, byte-for-byte identical to
// adcp/v3/signing.ReplayStore's method set as of this writing — nothing
// here prevents wiring it into the frozen module if you're not yet on v3.
//
// # Why this exists
//
// adcp/v3/signing.MemoryReplayStore dedups (keyid, nonce) pairs in a
// per-process map. That is sufficient for a single verifier process, but RFC
// 9421 §11.1 makes replay rejection a MUST, and a per-process cache cannot
// deliver it once a verifier runs as more than one process behind a load
// balancer: a captured signature replayed against a sibling instance whose
// cache hasn't seen the nonce is accepted. PostgresReplayStore closes that
// gap by giving every verifier instance one shared table
// (adcp_replay_cache) instead of N independent in-memory caches.
//
// See https://github.com/adcontextprotocol/adcp-go/issues/105 and
// https://github.com/adcontextprotocol/adcp-go/issues/54, and the reference
// implementations this package is cross-validated against: the JS SDK's
// PostgresReplayStore (adcp-client#1018, adopted in production at
// agenticadvertising.org) and Python's adcp-client-python
// (src/adcp/signing/pg/replay_store.py). All three share the
// (keyid, scope, nonce) primary key and the same atomic
// INSERT ... ON CONFLICT DO NOTHING idiom for the nonce race.
//
// # Module boundary
//
// This package is its own Go module (this directory has its own go.mod)
// rather than living inside adcp/v3/signing or the shared adcp/v3 module.
// Production code here imports only database/sql from the standard library
// — no Postgres driver is linked in; callers supply an already-open *sql.DB
// wired to whichever driver they prefer (pgx stdlib adapter, lib/pq, ...).
// That means a separate module isn't required to keep production code
// dependency-free — database/sql alone would do that even living inside
// adcp/v3/signing, the way adcp/idempotency's PgBackend does today (it lives
// in the shared adcp module with zero external deps in its non-test files).
// This package uses a separate module anyway because
// https://github.com/adcontextprotocol/adcp-go/issues/54 asked for one
// explicitly, as a structural, compiler-enforced guarantee that pgreplay can
// never accidentally grow a real dependency (a pgx-specific error type
// check, say) that leaks into core signing's import graph. Test-only
// dependencies (testcontainers-go, go-sqlmock, a driver for integration
// tests) are scoped to this module's go.mod and never reach a consumer that
// only imports the package, exactly as with adcp/idempotency's test-only
// go-sqlmock dependency today.
//
// # Test environments — read this before wiring PostgresReplayStore anywhere
//
// NewPostgresReplayStore probes the connection eagerly and panics with an
// actionable message if it cannot reach Postgres. That is deliberate: the
// JS rollout (adcp#3379) learned that when a PostgresReplayStore is
// constructed against a pool that doesn't exist in the current environment,
// every signed request fails closed identically to "the verifier is
// broken" — a debugging trap if it isn't caught at wire-up. Failing fast in
// the constructor turns that into a startup-time error instead of a
// runtime mystery.
//
// The consequence: do not construct a PostgresReplayStore in tests, local
// dev, or any environment without a real reachable Postgres. Use
// signing.NewMemoryReplayStore(0) there. A typical wiring pattern:
//
// func replayStore() signing.ReplayStore {
// if os.Getenv("ENVIRONMENT") != "production" && os.Getenv("ENVIRONMENT") != "staging" {
// return signing.NewMemoryReplayStore(0)
// }
// db, err := sql.Open("pgx", os.Getenv("REPLAY_DATABASE_URL"))
// if err != nil {
// log.Fatalf("replay store: %v", err)
// }
// return pgreplay.NewPostgresReplayStore(db)
// }
//
// Gate the Postgres path on an explicit production/staging check, not on
// "did REPLAY_DATABASE_URL happen to get set" — a misconfigured production
// deployment should fail loudly, not silently fall back to a per-process
// cache that reintroduces the multi-instance replay gap. This mirrors the
// gated fallback the JS adopter shipped (getReplayStore(), gated on
// NODE_ENV !== 'production').
//
// # Fail-closed posture
//
// HitCap, Seen, and Insert all fail closed: any Postgres error (connection
// down, query timeout, context canceled) is treated as "reject this
// request," never as "allow it through." This matches RFC 9421 §11.1's MUST
// and this SDK's broader safety-over-availability posture — an outage in
// the replay store degrades to rejecting signed requests, not to accepting
// unverified replays. See the doc comments on Insert and LastInsertError for
// how a caller can distinguish a legitimate rejection from a database
// outage without changing the fail-closed behavior itself.
package pgreplay
67 changes: 67 additions & 0 deletions adcp/v3/signing/pgreplay/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
module github.com/adcontextprotocol/adcp-go/adcp/v3/signing/pgreplay

go 1.26.2

require (
github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/jackc/pgx/v5 v5.10.0
github.com/stretchr/testify v1.11.1
github.com/testcontainers/testcontainers-go v0.44.0
)

require (
dario.cat/mergo v1.0.2 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.7.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/ebitengine/purego v0.10.1 // indirect
github.com/felixge/httpsnoop v1.1.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/compress v1.18.6 // indirect
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.2.0 // indirect
github.com/moby/moby/api v1.55.0 // indirect
github.com/moby/moby/client v0.5.0 // indirect
github.com/moby/patternmatcher v0.6.1 // indirect
github.com/moby/sys/sequential v0.7.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/shirou/gopsutil/v4 v4.26.6 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/tklauser/go-sysconf v0.4.0 // indirect
github.com/tklauser/numcpus v0.12.0 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
go.opentelemetry.io/otel v1.44.0 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
Loading
Loading