@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,149 @@
1
+ /**
2
+ * The in-memory nonce store, and the shared store contract (re-exported).
3
+ *
4
+ * `reserve` is the load-bearing primitive and MUST be atomic: the check and the set
5
+ * are never split by an `await`. The in-memory store below is atomic by construction
6
+ * (a synchronous body runs to completion); a distributed store must use a genuine
7
+ * atomic compare-and-set (a Durable Object, Redis `SET .. NX`, a unique constraint).
8
+ * Get-then-put stores (Workers KV, S3) reopen the race and are not safe here.
9
+ *
10
+ * `reserve` and `release` return a `Result`, so a store failure is a value the guard
11
+ * turns into a fail-closed deny. Replay keys on the nonce, never the signature, which
12
+ * is what makes it immune to signature malleability. Full rationale: docs/hardening.md.
13
+ * The contract itself lives in `store-types.ts` (Node-free, so non-Node adapters can
14
+ * import it); this module owns the Node in-memory implementation.
15
+ */
16
+ import { guardError } from "./error.js";
17
+ import { err, ok, type Result } from "./result.js";
18
+ import type {
19
+ NonceStore,
20
+ ReleaseOutcome,
21
+ ReserveError,
22
+ ReserveOutcome,
23
+ ReserveParams,
24
+ StoreError,
25
+ } from "./store-types.js";
26
+
27
+ export type {
28
+ NonceStore,
29
+ ReleaseOutcome,
30
+ ReserveError,
31
+ ReserveOutcome,
32
+ ReserveParams,
33
+ StoreError,
34
+ } from "./store-types.js";
35
+
36
+ interface Entry {
37
+ readonly resource: string;
38
+ readonly expiresAt: number;
39
+ readonly token: string;
40
+ }
41
+
42
+ /** Construction options for {@link MemoryNonceStore} / {@link createMemoryNonceStore}. */
43
+ export interface MemoryNonceStoreOptions {
44
+ /** Clock as unix seconds. Defaults to the system clock. Inject for tests. */
45
+ readonly now?: () => number;
46
+ /** Hard cap on retained reservations; a fresh reserve past it fails closed. */
47
+ readonly maxEntries?: number;
48
+ }
49
+
50
+ /** How often (in the store's clock, unix seconds) to sweep expired reservations. */
51
+ const SWEEP_INTERVAL_SECONDS = 60;
52
+
53
+ /**
54
+ * Hard ceiling on retained reservations: a fresh reserve past this fails closed
55
+ * rather than growing without bound (`expiresAt` is attacker-signed, so the sweep
56
+ * cannot reclaim a far-future entry). Override for the deployment.
57
+ */
58
+ const DEFAULT_MAX_ENTRIES = 1_000_000;
59
+
60
+ const nowSeconds = (): number => Math.floor(Date.now() / 1000);
61
+
62
+ /**
63
+ * In-memory nonce store for a single process. Atomic because the check and the set
64
+ * share one synchronous tick.
65
+ *
66
+ * Memory is bounded two ways: expired reservations are swept (lossless past
67
+ * `validBefore`), and because `expiresAt` is the attacker-signed `validBefore` the
68
+ * sweep alone is no bound, so a hard `maxEntries` cap rejects fresh reservations once
69
+ * reached (fail closed). Not for serverless isolates; use a shared store with a native
70
+ * compare-and-set there. Assumes a monotonic clock (the on-chain `validBefore` check is
71
+ * the end-to-end backstop). Exported for observability in tests; production code should
72
+ * use `createMemoryNonceStore`.
73
+ */
74
+ export class MemoryNonceStore implements NonceStore {
75
+ private readonly reserved = new Map<string, Entry>();
76
+ private lastSweep = Number.NEGATIVE_INFINITY;
77
+ private readonly now: () => number;
78
+ private readonly maxEntries: number;
79
+
80
+ constructor(options: MemoryNonceStoreOptions = {}) {
81
+ this.now = options.now ?? nowSeconds;
82
+ this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
83
+ }
84
+
85
+ /** Number of reservations currently retained. */
86
+ get size(): number {
87
+ return this.reserved.size;
88
+ }
89
+
90
+ reserve({
91
+ nonce,
92
+ resource,
93
+ expiresAt,
94
+ }: ReserveParams): Promise<Result<ReserveOutcome, ReserveError>> {
95
+ const now = this.now();
96
+ this.maybeSweep(now);
97
+
98
+ // An already-expired authorization is never grantable: refuse it in the same
99
+ // atomic step, before touching the reservation map.
100
+ if (expiresAt <= now) {
101
+ return Promise.resolve(ok({ status: "expired" }));
102
+ }
103
+
104
+ const existing = this.reserved.get(nonce);
105
+ if (existing !== undefined && existing.expiresAt > now) {
106
+ return Promise.resolve(ok({ status: "already-reserved", boundResource: existing.resource }));
107
+ }
108
+ // Reject a fresh reservation once full rather than grow unbounded or evict a live
109
+ // entry (which reopens the race). Overwriting a dead entry is exempt (no growth).
110
+ if (existing === undefined && this.reserved.size >= this.maxEntries) {
111
+ return Promise.resolve(err(guardError("store-at-capacity", "nonce store at capacity")));
112
+ }
113
+ const token = crypto.randomUUID();
114
+ this.reserved.set(nonce, { resource, expiresAt, token });
115
+ return Promise.resolve(ok({ status: "reserved", token }));
116
+ }
117
+
118
+ release(nonce: string, token: string): Promise<Result<ReleaseOutcome, StoreError>> {
119
+ const entry = this.reserved.get(nonce);
120
+ // Fencing: free the nonce only for the holder of the matching token. A missing
121
+ // entry (expired, swept, already released) or a wrong token frees nothing.
122
+ if (entry === undefined || entry.token !== token) {
123
+ return Promise.resolve(ok({ status: "not-held" }));
124
+ }
125
+ this.reserved.delete(nonce);
126
+ return Promise.resolve(ok({ status: "released" }));
127
+ }
128
+
129
+ /** Periodically drop expired reservations so unique-nonce floods stay bounded. */
130
+ private maybeSweep(now: number): void {
131
+ if (now - this.lastSweep < SWEEP_INTERVAL_SECONDS) {
132
+ return;
133
+ }
134
+ this.lastSweep = now;
135
+ for (const [nonce, entry] of this.reserved) {
136
+ if (entry.expiresAt <= now) {
137
+ this.reserved.delete(nonce);
138
+ }
139
+ }
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Create an in-memory nonce store. Optionally inject a clock (`() => unixSeconds`)
145
+ * and a hard `maxEntries` cap (fresh reserves past it fail closed).
146
+ */
147
+ export function createMemoryNonceStore(options: MemoryNonceStoreOptions = {}): NonceStore {
148
+ return new MemoryNonceStore(options);
149
+ }
package/src/protect.ts ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The secure flow as one call: reserve, settle, confirm, grant.
3
+ *
4
+ * `protect` is the framework-agnostic "wrap your endpoint" core. Verify the payment
5
+ * with your facilitator first, then call `protect` with the payment's nonce, the
6
+ * resource being served, and callbacks to settle and deliver. It runs the safe order
7
+ *
8
+ * reserve -> settle -> (confirm finality) -> deliver
9
+ *
10
+ * and releases the reservation if the settle fails or finality is not reached, so the
11
+ * payer can retry. On a grant the caller MUST apply the returned `cacheControl` to the
12
+ * response: that header is the cache-leak mitigation, and `protect` cannot set it
13
+ * across an unknown framework. Verify BEFORE calling; the nonce reserved must be the
14
+ * one the facilitator authenticated (see the malleability note in the nonce store).
15
+ */
16
+ import { paidResponseCacheDirectives } from "./cache.js";
17
+ import { type GuardError, guardError } from "./error.js";
18
+ import type { Guard, GuardErrorCode } from "./guard.js";
19
+ import type { ReserveParams } from "./nonce-store.js";
20
+ import { tryCatchAsync } from "./result.js";
21
+
22
+ /** Why a `protect` call did not grant: a guard deny, a failed settle, or non-finality. */
23
+ export type ProtectDenyReason = GuardErrorCode | "settle-failed" | "not-final";
24
+
25
+ interface ProtectHandlersBase<TResource> {
26
+ /** Settle the payment through your facilitator. Resolve `true` iff it settled. */
27
+ settle(): Promise<boolean>;
28
+ /** Deliver the resource once the payment is safe to grant. */
29
+ deliver(): TResource | Promise<TResource>;
30
+ }
31
+
32
+ /**
33
+ * The settle/deliver callbacks plus an explicit finality posture. Finality is a
34
+ * required discriminant, not an optional callback, so granting at zero confirmations
35
+ * is a decision made at the call site, never an implicit default for a security toggle.
36
+ *
37
+ * - `finality: "facilitator"` grants on settle success; finality then rests with the
38
+ * facilitator and the chain.
39
+ * - `finality: "confirm"` holds the grant until `confirm()` resolves `true`. A
40
+ * `confirm` that rejects or resolves `false` is treated as not-yet-final: the
41
+ * reservation is released and the grant withheld.
42
+ */
43
+ export type ProtectHandlers<TResource> =
44
+ | (ProtectHandlersBase<TResource> & { readonly finality: "facilitator" })
45
+ | (ProtectHandlersBase<TResource> & {
46
+ readonly finality: "confirm";
47
+ confirm(): Promise<boolean>;
48
+ });
49
+
50
+ /** The decision `protect` returns: granted with the resource, or denied with a reason. */
51
+ export type ProtectDecision<TResource> =
52
+ | { readonly granted: true; readonly resource: TResource; readonly cacheControl: string }
53
+ | { readonly granted: false; readonly reason: GuardError<ProtectDenyReason> };
54
+
55
+ /**
56
+ * Run the guarded secure flow for one already-verified payment. `request` is the
57
+ * reservation input (the authenticated nonce, the served resource as a canonical
58
+ * key, and the authorization's `validBefore` as `expiresAt`).
59
+ */
60
+ export async function protect<TResource>(
61
+ guard: Guard,
62
+ request: ReserveParams,
63
+ handlers: ProtectHandlers<TResource>,
64
+ ): Promise<ProtectDecision<TResource>> {
65
+ const reservation = await guard.reserve(request);
66
+ if (!reservation.reserved) {
67
+ return { granted: false, reason: reservation.reason };
68
+ }
69
+
70
+ // A settle callback that rejects (a transient facilitator/RPC error) is a failed
71
+ // settle, not a grant: release so the payer can retry.
72
+ const settled = await tryCatchAsync(() => handlers.settle());
73
+ if (!settled.ok || !settled.value) {
74
+ await reservation.release();
75
+ return { granted: false, reason: guardError("settle-failed", "payment did not settle") };
76
+ }
77
+
78
+ // Fail closed on finality: only the explicit "facilitator" posture grants at settle
79
+ // success. Any other value (a missing or misspelled `finality` from a non-TS caller)
80
+ // must clear the confirm gate; a missing `confirm` throws and becomes a not-final
81
+ // deny, never an unintended zero-confirmation grant.
82
+ if (handlers.finality !== "facilitator") {
83
+ // A confirm that rejects is not-yet-final too: withhold and free the nonce.
84
+ const final = await tryCatchAsync(() => handlers.confirm());
85
+ if (!final.ok || !final.value) {
86
+ await reservation.release();
87
+ return { granted: false, reason: guardError("not-final", "settlement not final") };
88
+ }
89
+ }
90
+
91
+ return {
92
+ granted: true,
93
+ resource: await handlers.deliver(),
94
+ cacheControl: paidResponseCacheDirectives().cacheControl,
95
+ };
96
+ }
package/src/result.ts ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Result: errors as values.
3
+ *
4
+ * The library does not throw for expected failures. An operation that can fail returns
5
+ * a `Result`, so the failure is a typed value the caller has to handle, and nothing is
6
+ * silently swallowed by a stray `try/catch` and turned into an accidental grant.
7
+ *
8
+ * Operational failure (parsing, store I/O) is a `Result<T, E>`. Domain decisions
9
+ * (allow / deny-with-reason) are their own discriminated unions, not `Result`: a deny
10
+ * is a normal decision, not an error.
11
+ *
12
+ * `try/catch` is banned everywhere except the two converters below (see
13
+ * `.biome/plugins/no-try-catch.grit`). Wrap throwing code once, here.
14
+ *
15
+ * Prior art: Rust's `Result`, Haskell's `Either`, and in TypeScript neverthrow and
16
+ * Effect. We bake our own four primitives rather than take a runtime dependency, which
17
+ * would break the zero-dependency guarantee that is part of the security pitch.
18
+ */
19
+
20
+ export type Result<T, E> =
21
+ | { readonly ok: true; readonly value: T }
22
+ | { readonly ok: false; readonly error: E };
23
+
24
+ /** Wrap a success value. */
25
+ export function ok<T>(value: T): Result<T, never> {
26
+ return { ok: true, value };
27
+ }
28
+
29
+ /** Wrap a failure value. */
30
+ export function err<E>(error: E): Result<never, E> {
31
+ return { ok: false, error };
32
+ }
33
+
34
+ // biome-ignore lint/plugin: a caught value is typed unknown by the language
35
+ function toError(caught: unknown): Error {
36
+ return caught instanceof Error ? caught : new Error(String(caught));
37
+ }
38
+
39
+ /**
40
+ * The single sanctioned synchronous throw-boundary. Runs `fn`, returning its
41
+ * value as `ok` or any thrown value coerced to an `Error` as `err`.
42
+ */
43
+ export function tryCatch<T>(fn: () => T): Result<T, Error> {
44
+ // biome-ignore lint/plugin: the one sanctioned synchronous throw-boundary
45
+ try {
46
+ return ok(fn());
47
+ } catch (caught) {
48
+ return err(toError(caught));
49
+ }
50
+ }
51
+
52
+ /** The single sanctioned asynchronous throw-boundary. */
53
+ export async function tryCatchAsync<T>(fn: () => Promise<T>): Promise<Result<T, Error>> {
54
+ // biome-ignore lint/plugin: the one sanctioned asynchronous throw-boundary
55
+ try {
56
+ return ok(await fn());
57
+ } catch (caught) {
58
+ return err(toError(caught));
59
+ }
60
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The nonce store contract: the shape every store implements.
3
+ *
4
+ * Kept free of any runtime import (no `node:crypto`, no Node globals) so a
5
+ * non-Node adapter (a Cloudflare Durable Object, Deno, an edge runtime) can import
6
+ * the contract without dragging Node built-ins into its build. The in-memory store
7
+ * and its Node-specific bits live in `nonce-store.ts`, which re-exports these.
8
+ */
9
+ import type { GuardError } from "./error.js";
10
+ import type { Result } from "./result.js";
11
+
12
+ /**
13
+ * The outcome of a reserve. `reserved` carries a fencing `token`: only its holder can
14
+ * later `release`, so releasing an in-flight hold is not a griefing primitive.
15
+ */
16
+ export type ReserveOutcome =
17
+ | { readonly status: "reserved"; readonly token: string }
18
+ | { readonly status: "already-reserved"; readonly boundResource: string }
19
+ | { readonly status: "expired" };
20
+
21
+ /** The outcome of a release. `not-held` means no matching token: nothing was freed. */
22
+ export type ReleaseOutcome = { readonly status: "released" } | { readonly status: "not-held" };
23
+
24
+ /** The only error a `release` reports, and what the guard collapses any unrecognized store failure to (fail closed). */
25
+ export type StoreError = GuardError<"store-unavailable">;
26
+
27
+ /** What a `reserve` can report: a `StoreError`, or the store being at its hard `maxEntries` capacity. Branch on `code`. */
28
+ export type ReserveError = StoreError | GuardError<"store-at-capacity">;
29
+
30
+ export interface ReserveParams {
31
+ /**
32
+ * The payment's nonce; the x402 exact scheme uses a random 32-byte value. The guard
33
+ * keys on this nonce alone (folded to a canonical form by default, see canonical.ts).
34
+ * It does not scope by chain or asset, so a store shared across several chains or
35
+ * assets should namespace the nonce per (chain, asset) itself to keep two distinct
36
+ * payments from colliding; a direct store caller must also pre-fold it.
37
+ */
38
+ readonly nonce: string;
39
+ /**
40
+ * The resource this payment is spent on, as a canonical key. Substitution compares
41
+ * this to the resource the nonce first bound to, so equal resources must produce an
42
+ * equal string. The guard canonicalizes URL casing by default.
43
+ */
44
+ readonly resource: string;
45
+ /**
46
+ * Unix seconds, the authorization's `validBefore`. Used two ways: `reserve` refuses
47
+ * an authorization whose window has closed (`expiresAt <= now`), and past this time a
48
+ * live reservation may be evicted (unreplayable on-chain, so dropping it is lossless).
49
+ */
50
+ readonly expiresAt: number;
51
+ }
52
+
53
+ /** A store of reserved payment nonces. `reserve` must be atomic. */
54
+ export interface NonceStore {
55
+ reserve(params: ReserveParams): Promise<Result<ReserveOutcome, ReserveError>>;
56
+ /**
57
+ * Release a reservation so the nonce can be reserved again (e.g. after a failed or
58
+ * reorged settlement, so a legitimate payer can retry). Releases only if `token`
59
+ * matches the one `reserve` returned (fencing); otherwise frees nothing (`not-held`).
60
+ */
61
+ release(nonce: string, token: string): Promise<Result<ReleaseOutcome, StoreError>>;
62
+ }