@craigruks/x402-server-guard 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +178 -0
  3. package/SECURITY.md +54 -0
  4. package/dist/cache.d.ts +36 -0
  5. package/dist/cache.d.ts.map +1 -0
  6. package/dist/cache.js +31 -0
  7. package/dist/cache.js.map +1 -0
  8. package/dist/canonical.d.ts +31 -0
  9. package/dist/canonical.d.ts.map +1 -0
  10. package/dist/canonical.js +38 -0
  11. package/dist/canonical.js.map +1 -0
  12. package/dist/cloudflare/durable-object.d.ts +52 -0
  13. package/dist/cloudflare/durable-object.d.ts.map +1 -0
  14. package/dist/cloudflare/durable-object.js +83 -0
  15. package/dist/cloudflare/durable-object.js.map +1 -0
  16. package/dist/error.d.ts +22 -0
  17. package/dist/error.d.ts.map +1 -0
  18. package/dist/error.js +17 -0
  19. package/dist/error.js.map +1 -0
  20. package/dist/guard.d.ts +46 -0
  21. package/dist/guard.d.ts.map +1 -0
  22. package/dist/guard.js +88 -0
  23. package/dist/guard.js.map +1 -0
  24. package/dist/index.d.ts +23 -0
  25. package/dist/index.d.ts.map +1 -0
  26. package/dist/index.js +23 -0
  27. package/dist/index.js.map +1 -0
  28. package/dist/nonce-store.d.ts +41 -0
  29. package/dist/nonce-store.d.ts.map +1 -0
  30. package/dist/nonce-store.js +103 -0
  31. package/dist/nonce-store.js.map +1 -0
  32. package/dist/protect.d.ts +45 -0
  33. package/dist/protect.d.ts.map +1 -0
  34. package/dist/protect.js +54 -0
  35. package/dist/protect.js.map +1 -0
  36. package/dist/result.d.ts +37 -0
  37. package/dist/result.d.ts.map +1 -0
  38. package/dist/result.js +54 -0
  39. package/dist/result.js.map +1 -0
  40. package/dist/store-types.d.ts +66 -0
  41. package/dist/store-types.d.ts.map +1 -0
  42. package/dist/store-types.js +2 -0
  43. package/dist/store-types.js.map +1 -0
  44. package/package.json +90 -0
  45. package/src/cache.ts +48 -0
  46. package/src/canonical.ts +40 -0
  47. package/src/cloudflare/durable-object.ts +128 -0
  48. package/src/error.ts +31 -0
  49. package/src/guard.ts +153 -0
  50. package/src/index.ts +49 -0
  51. package/src/nonce-store.ts +149 -0
  52. package/src/protect.ts +96 -0
  53. package/src/result.ts +60 -0
  54. package/src/store-types.ts +62 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store-types.d.ts","sourceRoot":"","sources":["../src/store-types.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAC7C,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAE1C;;;GAGG;AACH,MAAM,MAAM,cAAc,GACtB;IAAE,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GACvD;IAAE,QAAQ,CAAC,MAAM,EAAE,kBAAkB,CAAC;IAAC,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAA;CAAE,GACvE;IAAE,QAAQ,CAAC,MAAM,EAAE,SAAS,CAAA;CAAE,CAAC;AAEnC,uFAAuF;AACvF,MAAM,MAAM,cAAc,GAAG;IAAE,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAA;CAAE,GAAG;IAAE,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAA;CAAE,CAAC;AAE/F,wHAAwH;AACxH,MAAM,MAAM,UAAU,GAAG,UAAU,CAAC,mBAAmB,CAAC,CAAC;AAEzD,2HAA2H;AAC3H,MAAM,MAAM,YAAY,GAAG,UAAU,GAAG,UAAU,CAAC,mBAAmB,CAAC,CAAC;AAExE,MAAM,WAAW,aAAa;IAC5B;;;;;;OAMG;IACH,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B;;;;OAIG;IACH,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B;AAED,oEAAoE;AACpE,MAAM,WAAW,UAAU;IACzB,OAAO,CAAC,MAAM,EAAE,aAAa,GAAG,OAAO,CAAC,MAAM,CAAC,cAAc,EAAE,YAAY,CAAC,CAAC,CAAC;IAC9E;;;;OAIG;IACH,OAAO,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,cAAc,EAAE,UAAU,CAAC,CAAC,CAAC;CACpF"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=store-types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store-types.js","sourceRoot":"","sources":["../src/store-types.ts"],"names":[],"mappings":""}
package/package.json ADDED
@@ -0,0 +1,90 @@
1
+ {
2
+ "name": "@craigruks/x402-server-guard",
3
+ "version": "0.1.0",
4
+ "description": "Server-side hardening middleware for x402 payment endpoints. Mitigations for settlement races, replay, cross-resource substitution, and cache leakage. Not a security guarantee.",
5
+ "keywords": [
6
+ "x402",
7
+ "payments",
8
+ "security",
9
+ "middleware",
10
+ "hardening",
11
+ "replay-protection",
12
+ "agentic-payments",
13
+ "web3"
14
+ ],
15
+ "license": "MIT",
16
+ "author": "Craig Ruks",
17
+ "type": "module",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/craigruks/x402-server-guard.git"
21
+ },
22
+ "homepage": "https://github.com/craigruks/x402-server-guard#readme",
23
+ "bugs": {
24
+ "url": "https://github.com/craigruks/x402-server-guard/issues"
25
+ },
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.ts",
29
+ "default": "./dist/index.js"
30
+ },
31
+ "./cloudflare": {
32
+ "types": "./dist/cloudflare/durable-object.d.ts",
33
+ "default": "./dist/cloudflare/durable-object.js"
34
+ },
35
+ "./package.json": "./package.json"
36
+ },
37
+ "main": "./dist/index.js",
38
+ "types": "./dist/index.d.ts",
39
+ "sideEffects": false,
40
+ "files": [
41
+ "dist",
42
+ "src",
43
+ "!src/**/*.test.ts",
44
+ "SECURITY.md"
45
+ ],
46
+ "engines": {
47
+ "node": ">=22"
48
+ },
49
+ "publishConfig": {
50
+ "access": "public",
51
+ "provenance": true
52
+ },
53
+ "scripts": {
54
+ "build": "tsc -p tsconfig.build.json && tsc -p tsconfig.cloudflare.json",
55
+ "version": "node scripts/sync-version.mjs && git add src/index.ts",
56
+ "typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.cloudflare.json --noEmit",
57
+ "lint": "biome check .",
58
+ "lint:fix": "biome check --write .",
59
+ "lint:filelength": "tsx scripts/lint-file-length.ts",
60
+ "test": "vitest run",
61
+ "test:watch": "vitest",
62
+ "check": "npm run typecheck && npm run lint && npm run lint:filelength && npm run test",
63
+ "check:pkg": "publint --strict && attw --pack . --profile esm-only",
64
+ "prepublishOnly": "npm run build && npm run check:pkg",
65
+ "prepare": "git config core.hooksPath .githooks || true",
66
+ "test:coverage": "vitest run --coverage",
67
+ "smoke:dist": "npm run build && node scripts/smoke-dist.mjs",
68
+ "example": "tsx examples/secure-flow.ts",
69
+ "example:hono": "tsx examples/hono-server.ts"
70
+ },
71
+ "devDependencies": {
72
+ "@arethetypeswrong/cli": "0.18.5",
73
+ "@biomejs/biome": "2.5.3",
74
+ "@cloudflare/workers-types": "5.20260714.1",
75
+ "@types/node": "26.1.1",
76
+ "@vitest/coverage-v8": "4.1.10",
77
+ "@x402/core": "2.18.0",
78
+ "esbuild": "0.28.1",
79
+ "hono": "4.12.29",
80
+ "miniflare": "4.20260708.1",
81
+ "publint": "0.3.21",
82
+ "tsx": "4.23.0",
83
+ "typescript": "7.0.2",
84
+ "vitest": "4.1.10"
85
+ },
86
+ "allowScripts": {
87
+ "esbuild@0.28.1": true,
88
+ "workerd@1.20260708.1": true
89
+ }
90
+ }
package/src/cache.ts ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Cache directives for paid responses.
3
+ *
4
+ * A shared cache (CDN or reverse proxy) keys on the URL and knows nothing about
5
+ * payment, so a cacheable paid 200 can be stored and served to the next caller, paid
6
+ * or not. The reference x402 adapters set no cache directive, leaving that door open.
7
+ *
8
+ * The mitigation is a response directive, not a decision about a nonce, so it lives
9
+ * here as a small pure helper the server or adapter applies to every paid response.
10
+ * `no-store` is load-bearing (no conforming cache stores it); `private` and `Vary` on
11
+ * the payment header are defense in depth.
12
+ */
13
+
14
+ /** HTTP header directives to attach to a paid response so no cache serves it onward. */
15
+ export interface CacheDirectives {
16
+ /** `Cache-Control` value. */
17
+ readonly cacheControl: string;
18
+ /** `Vary` value: the request headers a stored response must key on. */
19
+ readonly vary: string;
20
+ }
21
+
22
+ export interface CacheDirectivesOptions {
23
+ /**
24
+ * The request header the payment travels in, added to `Vary` so a cache that
25
+ * stores despite `no-store` still keys by payer. Defaults to `X-PAYMENT`.
26
+ */
27
+ readonly paymentHeader?: string;
28
+ }
29
+
30
+ /** The cache directives to attach to a paid (200) response. */
31
+ export function paidResponseCacheDirectives(options: CacheDirectivesOptions = {}): CacheDirectives {
32
+ const paymentHeader = options.paymentHeader ?? "X-PAYMENT";
33
+ return { cacheControl: "no-store, private", vary: paymentHeader };
34
+ }
35
+
36
+ /**
37
+ * Whether a shared (CDN / reverse-proxy) cache may store a response with this
38
+ * `Cache-Control`. A conforming shared cache refuses to store a `no-store` or
39
+ * `private` response. Used by the caching model in the tests. A CDN in force-cache
40
+ * mode ignores `Cache-Control`; this models a cache that honors it.
41
+ */
42
+ export function isStorableBySharedCache(cacheControl: string | undefined): boolean {
43
+ if (cacheControl === undefined || cacheControl === "") {
44
+ return true;
45
+ }
46
+ const normalized = cacheControl.toLowerCase();
47
+ return !normalized.includes("no-store") && !normalized.includes("private");
48
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Canonical keys for the nonce and the resource.
3
+ *
4
+ * The guard uses the nonce and resource strings directly as map keys. If one nonce (or
5
+ * resource) reaches the guard in two encodings, the two forms become two keys for one
6
+ * payment: the string-encoding cousin of signature malleability. `createGuard` folds
7
+ * these encodings by default; a caller with a case- or prefix-sensitive scope can
8
+ * override or opt out (see GuardOptions).
9
+ */
10
+
11
+ import { tryCatch } from "./result.js";
12
+
13
+ /**
14
+ * Canonicalize a payment nonce: lowercase it and drop a leading `0x`, so `0xABCD`,
15
+ * `0xabcd`, and `abcd` are one key. Hex case and the `0x` prefix are not part of the
16
+ * value (the same bytes either way), so folding them cannot merge two distinct nonces.
17
+ * A caller whose nonce scope is case- or prefix-sensitive should pass their own function.
18
+ */
19
+ export function canonicalNonce(nonce: string): string {
20
+ const lower = nonce.toLowerCase();
21
+ return lower.startsWith("0x") ? lower.slice(2) : lower;
22
+ }
23
+
24
+ /**
25
+ * Canonicalize a resource key. When it is an absolute URL, return the WHATWG-URL
26
+ * canonical form: scheme and host case-folded, the default port dropped, and
27
+ * dot-segments resolved. It does NOT decode percent-encoded octets (`%41` stays
28
+ * `%41`, not `A`), so this is not full RFC 3986 equivalence; two RFC-equivalent paths
29
+ * that differ only in percent-encoding get different keys. Path, query, and fragment
30
+ * CASE is preserved, so `/Foo` and `/foo` stay distinct and merging cannot open a
31
+ * substitution hole; normalize trailing slashes and query order yourself if a resource
32
+ * needs it. A non-URL key (a bare path or an opaque string) is returned unchanged.
33
+ * Note: the URL parser's IDN/punycode output can vary with the runtime's ICU, so a
34
+ * persistent distributed store should pin a stable resource form rather than rely on
35
+ * this across engine upgrades.
36
+ */
37
+ export function canonicalResource(resource: string): string {
38
+ const parsed = tryCatch(() => new URL(resource));
39
+ return parsed.ok ? parsed.value.href : resource;
40
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Cloudflare Durable Object nonce store: atomic compare-and-set across isolates.
3
+ *
4
+ * The default in-memory store protects one process; on Workers each isolate has its
5
+ * own Map, so N isolates would each grant one nonce N times. This routes every nonce
6
+ * to its own Durable Object (`idFromName(nonce)`). A Durable Object serves one request
7
+ * at a time and holds delivery of other events while a storage op to the same object
8
+ * is in flight (input gating), so the reserve check-and-set runs atomically with no
9
+ * lock. That is the compare-and-set the store contract requires. See the Cloudflare
10
+ * store docs for the wrangler binding and deployment shape.
11
+ */
12
+ import { DurableObject } from "cloudflare:workers";
13
+ import { guardError } from "../error.js";
14
+ import { err, ok, type Result, tryCatchAsync } from "../result.js";
15
+ import type {
16
+ NonceStore,
17
+ ReleaseOutcome,
18
+ ReserveError,
19
+ ReserveOutcome,
20
+ ReserveParams,
21
+ StoreError,
22
+ } from "../store-types.js";
23
+
24
+ interface StoredEntry {
25
+ readonly resource: string;
26
+ readonly expiresAt: number;
27
+ readonly token: string;
28
+ }
29
+
30
+ const ENTRY_KEY = "entry";
31
+
32
+ /**
33
+ * The Durable Object the guard reserves against. Bind it in wrangler and export it
34
+ * from your Worker entry; the adapter routes each nonce to its own instance, so the
35
+ * single stored entry per object is the reservation for that one nonce.
36
+ */
37
+ export class NonceReservationDO extends DurableObject {
38
+ /** Atomic reserve: input gating serializes concurrent calls to this same object. */
39
+ async reserve(resource: string, expiresAt: number): Promise<ReserveOutcome> {
40
+ const now = nowSeconds();
41
+ // Refuse an already-closed window before touching storage (matches the memory store).
42
+ if (expiresAt <= now) {
43
+ return { status: "expired" };
44
+ }
45
+ const existing = await this.ctx.storage.get<StoredEntry>(ENTRY_KEY);
46
+ // No `await` on anything but storage between the get and the put, so input gating
47
+ // keeps this read-modify-write atomic against a concurrent reserve of the same nonce.
48
+ if (existing !== undefined && existing.expiresAt > now) {
49
+ return { status: "already-reserved", boundResource: existing.resource };
50
+ }
51
+ const token = crypto.randomUUID();
52
+ await this.ctx.storage.put<StoredEntry>(ENTRY_KEY, { resource, expiresAt, token });
53
+ // Evict once the window closes: past `validBefore` the nonce is unreplayable on-chain.
54
+ await this.ctx.storage.setAlarm(expiresAt * 1000);
55
+ return { status: "reserved", token };
56
+ }
57
+
58
+ /** Fenced release: free the nonce only for the holder of the matching token. */
59
+ async release(token: string): Promise<ReleaseOutcome> {
60
+ const entry = await this.ctx.storage.get<StoredEntry>(ENTRY_KEY);
61
+ if (entry === undefined || entry.token !== token) {
62
+ return { status: "not-held" };
63
+ }
64
+ await this.ctx.storage.deleteAll();
65
+ return { status: "released" };
66
+ }
67
+
68
+ /** Cleanup: drop the entry once its window has closed. */
69
+ override async alarm(): Promise<void> {
70
+ const entry = await this.ctx.storage.get<StoredEntry>(ENTRY_KEY);
71
+ if (entry !== undefined && entry.expiresAt <= nowSeconds()) {
72
+ await this.ctx.storage.deleteAll();
73
+ }
74
+ }
75
+ }
76
+
77
+ function nowSeconds(): number {
78
+ return Math.floor(Date.now() / 1000);
79
+ }
80
+
81
+ /** An opaque Durable Object id. A real `DurableObjectId` satisfies this. */
82
+ interface NonceReservationId {
83
+ toString(): string;
84
+ }
85
+
86
+ /** The RPC surface the adapter calls on a {@link NonceReservationDO} stub. */
87
+ interface NonceReservationStub {
88
+ reserve(resource: string, expiresAt: number): Promise<ReserveOutcome>;
89
+ release(token: string): Promise<ReleaseOutcome>;
90
+ }
91
+
92
+ /**
93
+ * The Durable Object namespace binding the adapter needs. A real
94
+ * `DurableObjectNamespace<NonceReservationDO>` satisfies this structurally; type your
95
+ * binding with that generic so the stub carries the reserve/release RPC methods.
96
+ */
97
+ export interface NonceReservationNamespace {
98
+ idFromName(name: string): NonceReservationId;
99
+ get(id: NonceReservationId): NonceReservationStub;
100
+ }
101
+
102
+ /**
103
+ * Build a {@link NonceStore} backed by the Durable Object namespace. Pass it to
104
+ * `createGuard({ store })` on any multi-isolate deploy. A transport failure (the object
105
+ * is unreachable) becomes a `store-unavailable` value, so the guard fails closed.
106
+ */
107
+ export function createDurableObjectNonceStore(namespace: NonceReservationNamespace): NonceStore {
108
+ const stubFor = (nonce: string): NonceReservationStub =>
109
+ namespace.get(namespace.idFromName(nonce));
110
+ return {
111
+ async reserve({
112
+ nonce,
113
+ resource,
114
+ expiresAt,
115
+ }: ReserveParams): Promise<Result<ReserveOutcome, ReserveError>> {
116
+ const outcome = await tryCatchAsync(() => stubFor(nonce).reserve(resource, expiresAt));
117
+ return outcome.ok
118
+ ? ok(outcome.value)
119
+ : err(guardError("store-unavailable", "durable object reserve failed", outcome.error));
120
+ },
121
+ async release(nonce: string, token: string): Promise<Result<ReleaseOutcome, StoreError>> {
122
+ const outcome = await tryCatchAsync(() => stubFor(nonce).release(token));
123
+ return outcome.ok
124
+ ? ok(outcome.value)
125
+ : err(guardError("store-unavailable", "durable object release failed", outcome.error));
126
+ },
127
+ };
128
+ }
package/src/error.ts ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Typed errors: viem's error taste, adapted to errors-as-values.
3
+ *
4
+ * viem gives every failure a structured error you branch on by identity rather than by
5
+ * string-matching a message. We want that discipline, but viem THROWS and we carry
6
+ * errors as values (result.ts), so a `GuardError` is a plain typed object, not a class:
7
+ * it serializes, needs no `instanceof`, and drops into a discriminated union or a
8
+ * `Result`'s error slot. The load-bearing field is `code`, a stable branchable
9
+ * discriminant; `cause` preserves the original throw so wrapping never loses the chain.
10
+ */
11
+
12
+ /** A typed error carried as a value. Branch on `code`, not on `message`. */
13
+ export interface GuardError<Code extends string = string> {
14
+ /** Stable, branchable discriminant. */
15
+ readonly code: Code;
16
+ /** Human-readable detail, for logs and messages, not for control flow. */
17
+ readonly message: string;
18
+ /** The originating error, when this wraps a throw. Preserves the chain. */
19
+ // biome-ignore lint/plugin: a wrapped cause can be any thrown value
20
+ readonly cause?: unknown;
21
+ }
22
+
23
+ /** Construct a typed `GuardError`, omitting `cause` when there is none. */
24
+ export function guardError<const Code extends string>(
25
+ code: Code,
26
+ message: string,
27
+ // biome-ignore lint/plugin: a wrapped cause can be any thrown value
28
+ cause?: unknown,
29
+ ): GuardError<Code> {
30
+ return cause === undefined ? { code, message } : { code, message, cause };
31
+ }
package/src/guard.ts ADDED
@@ -0,0 +1,153 @@
1
+ /**
2
+ * The guard: server-side x402 hardening as decisions, not exceptions.
3
+ *
4
+ * A merchant reserves a payment's nonce before granting the resource, so a replay, a
5
+ * concurrent race, or a cross-resource substitution is denied. The decision is a value
6
+ * (`Reservation`), never a throw, so a stray `try/catch` cannot turn a deny into an
7
+ * accidental grant; a store failure fails closed (denies, never grants). The attack
8
+ * narratives are in the docs site; this is the framework-agnostic core.
9
+ */
10
+ import { canonicalNonce, canonicalResource } from "./canonical.js";
11
+ import { type GuardError, guardError } from "./error.js";
12
+ import {
13
+ createMemoryNonceStore,
14
+ type NonceStore,
15
+ type ReleaseOutcome,
16
+ type ReserveParams,
17
+ type StoreError,
18
+ } from "./nonce-store.js";
19
+ import { err, type Result, tryCatchAsync } from "./result.js";
20
+
21
+ /** The deny reasons a reservation can carry. */
22
+ export type GuardErrorCode =
23
+ | "nonce-already-reserved"
24
+ | "nonce-resource-mismatch"
25
+ | "nonce-expired"
26
+ | "store-unavailable"
27
+ | "store-at-capacity";
28
+
29
+ export interface GuardOptions {
30
+ /** Where reserved nonces are tracked. Defaults to an in-memory store. */
31
+ store?: NonceStore;
32
+ /**
33
+ * Fold a nonce to a canonical key before it is reserved, so two encodings of one
34
+ * nonce cannot become two grants. Defaults to {@link canonicalNonce}. Pass the
35
+ * identity `(n) => n` to opt out and key on the exact bytes.
36
+ */
37
+ canonicalizeNonce?: (nonce: string) => string;
38
+ /**
39
+ * Fold the resource to a canonical key before it is bound. Defaults to
40
+ * {@link canonicalResource}. Pass the identity `(r) => r` to opt out.
41
+ */
42
+ canonicalizeResource?: (resource: string) => string;
43
+ }
44
+
45
+ /**
46
+ * A guard decision: reserved (grant may proceed) or denied with a typed reason.
47
+ *
48
+ * The reserved handle carries `release`, to free the nonce if the payment does not go
49
+ * through so the payer can retry the same authorization. The fencing token that
50
+ * authorizes the release is held inside the handle, never exposed. Not calling
51
+ * `release` is safe: the reservation expires with the authorization.
52
+ */
53
+ export type Reservation =
54
+ | { readonly reserved: true; release(): Promise<Result<ReleaseOutcome, StoreError>> }
55
+ | { readonly reserved: false; readonly reason: GuardError<GuardErrorCode> };
56
+
57
+ export interface Guard {
58
+ /**
59
+ * Reserve a payment's nonce before granting. The first caller for a nonce is
60
+ * reserved; any later caller (replay or concurrent race) is denied. A store
61
+ * failure denies (fail closed).
62
+ */
63
+ reserve(params: ReserveParams): Promise<Reservation>;
64
+ }
65
+
66
+ /** Create a guard backed by an in-memory nonce store (or a supplied one). */
67
+ export function createGuard(options: GuardOptions = {}): Guard {
68
+ const store = options.store ?? createMemoryNonceStore();
69
+ const canonicalizeNonce = options.canonicalizeNonce ?? canonicalNonce;
70
+ const canonicalizeResource = options.canonicalizeResource ?? canonicalResource;
71
+ return {
72
+ async reserve(params: ReserveParams): Promise<Reservation> {
73
+ // Fold both keys to canonical form up front; everything below keys on the
74
+ // canonical values (see canonical.ts for why two encodings must not split).
75
+ const nonce = canonicalizeNonce(params.nonce);
76
+ const resource = canonicalizeResource(params.resource);
77
+ const result = await callStore(() =>
78
+ store.reserve({ nonce, resource, expiresAt: params.expiresAt }),
79
+ );
80
+ if (!result.ok) {
81
+ // callStore already mapped throws to store-unavailable; surface a known store
82
+ // code as-is, but collapse an unrecognized one (a misbehaving adapter) to
83
+ // store-unavailable so it still fails closed.
84
+ const error = result.error;
85
+ const reason =
86
+ error.code === "store-at-capacity" || error.code === "store-unavailable"
87
+ ? error
88
+ : guardError("store-unavailable", "nonce store unavailable", error);
89
+ return { reserved: false, reason };
90
+ }
91
+ switch (result.value.status) {
92
+ case "reserved": {
93
+ const { token } = result.value;
94
+ return { reserved: true, release: () => releaseReservation(store, nonce, token) };
95
+ }
96
+ case "already-reserved":
97
+ // Nonce taken. If first bound to a DIFFERENT resource this is a
98
+ // cross-resource substitution, not a replay: report it distinctly. Both
99
+ // sides are canonical keys (boundResource was stored canonical).
100
+ if (result.value.boundResource !== resource) {
101
+ return {
102
+ reserved: false,
103
+ reason: guardError(
104
+ "nonce-resource-mismatch",
105
+ "payment nonce is bound to a different resource",
106
+ ),
107
+ };
108
+ }
109
+ return {
110
+ reserved: false,
111
+ reason: guardError("nonce-already-reserved", "payment nonce is already reserved"),
112
+ };
113
+ case "expired":
114
+ return {
115
+ reserved: false,
116
+ reason: guardError("nonce-expired", "payment authorization has expired"),
117
+ };
118
+ default:
119
+ // Defensive: an off-contract status fails closed here rather than falling
120
+ // through to an undefined return (a throw).
121
+ return {
122
+ reserved: false,
123
+ reason: guardError("store-unavailable", "nonce store returned an unknown status"),
124
+ };
125
+ }
126
+ },
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Run a store op with the fail-closed guarantee in one place: a store that THROWS or
132
+ * REJECTS becomes a `store-unavailable` value rather than an uncaught rejection, so
133
+ * fail-closed does not depend on each adapter author. A returned error value is passed
134
+ * through for the caller to map.
135
+ */
136
+ async function callStore<T, E extends GuardError>(
137
+ op: () => Promise<Result<T, E>>,
138
+ ): Promise<Result<T, E | StoreError>> {
139
+ const outcome = await tryCatchAsync(op);
140
+ if (!outcome.ok) {
141
+ return err(guardError("store-unavailable", "nonce store unavailable", outcome.error));
142
+ }
143
+ return outcome.value;
144
+ }
145
+
146
+ /** Release a reservation through the store, mapping a store throw to a fail-closed error. */
147
+ function releaseReservation(
148
+ store: NonceStore,
149
+ nonce: string,
150
+ token: string,
151
+ ): Promise<Result<ReleaseOutcome, StoreError>> {
152
+ return callStore(() => store.release(nonce, token));
153
+ }
package/src/index.ts ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * @craigruks/x402-server-guard
3
+ *
4
+ * Server-side hardening middleware for x402 payment endpoints.
5
+ *
6
+ * SECURITY DISCLAIMER: This software is provided "AS IS", without warranty of
7
+ * any kind. It is NOT audited and is NOT a security guarantee. It mitigates
8
+ * specific, enumerated attack classes only. It cannot make an insecure payment
9
+ * endpoint safe on its own. See SECURITY.md for scope and reporting.
10
+ *
11
+ * Status: v0.1, in progress. The nonce reservation that stops the settlement
12
+ * race and payment replay is the first mitigation to land.
13
+ */
14
+
15
+ /** Current package version (semver). */
16
+ export const VERSION: string = "0.1.0";
17
+
18
+ export {
19
+ type CacheDirectives,
20
+ type CacheDirectivesOptions,
21
+ isStorableBySharedCache,
22
+ paidResponseCacheDirectives,
23
+ } from "./cache.js";
24
+ export { canonicalNonce, canonicalResource } from "./canonical.js";
25
+ export { type GuardError, guardError } from "./error.js";
26
+ export {
27
+ createGuard,
28
+ type Guard,
29
+ type GuardErrorCode,
30
+ type GuardOptions,
31
+ type Reservation,
32
+ } from "./guard.js";
33
+ export {
34
+ createMemoryNonceStore,
35
+ type MemoryNonceStoreOptions,
36
+ type NonceStore,
37
+ type ReleaseOutcome,
38
+ type ReserveError,
39
+ type ReserveOutcome,
40
+ type ReserveParams,
41
+ type StoreError,
42
+ } from "./nonce-store.js";
43
+ export {
44
+ type ProtectDecision,
45
+ type ProtectDenyReason,
46
+ type ProtectHandlers,
47
+ protect,
48
+ } from "./protect.js";
49
+ export { err, ok, type Result, tryCatch, tryCatchAsync } from "./result.js";