@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.
- package/LICENSE +21 -0
- package/README.md +178 -0
- package/SECURITY.md +54 -0
- package/dist/cache.d.ts +36 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +31 -0
- package/dist/cache.js.map +1 -0
- package/dist/canonical.d.ts +31 -0
- package/dist/canonical.d.ts.map +1 -0
- package/dist/canonical.js +38 -0
- package/dist/canonical.js.map +1 -0
- package/dist/cloudflare/durable-object.d.ts +52 -0
- package/dist/cloudflare/durable-object.d.ts.map +1 -0
- package/dist/cloudflare/durable-object.js +83 -0
- package/dist/cloudflare/durable-object.js.map +1 -0
- package/dist/error.d.ts +22 -0
- package/dist/error.d.ts.map +1 -0
- package/dist/error.js +17 -0
- package/dist/error.js.map +1 -0
- package/dist/guard.d.ts +46 -0
- package/dist/guard.d.ts.map +1 -0
- package/dist/guard.js +88 -0
- package/dist/guard.js.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +23 -0
- package/dist/index.js.map +1 -0
- package/dist/nonce-store.d.ts +41 -0
- package/dist/nonce-store.d.ts.map +1 -0
- package/dist/nonce-store.js +103 -0
- package/dist/nonce-store.js.map +1 -0
- package/dist/protect.d.ts +45 -0
- package/dist/protect.d.ts.map +1 -0
- package/dist/protect.js +54 -0
- package/dist/protect.js.map +1 -0
- package/dist/result.d.ts +37 -0
- package/dist/result.d.ts.map +1 -0
- package/dist/result.js +54 -0
- package/dist/result.js.map +1 -0
- package/dist/store-types.d.ts +66 -0
- package/dist/store-types.d.ts.map +1 -0
- package/dist/store-types.js +2 -0
- package/dist/store-types.js.map +1 -0
- package/package.json +90 -0
- package/src/cache.ts +48 -0
- package/src/canonical.ts +40 -0
- package/src/cloudflare/durable-object.ts +128 -0
- package/src/error.ts +31 -0
- package/src/guard.ts +153 -0
- package/src/index.ts +49 -0
- package/src/nonce-store.ts +149 -0
- package/src/protect.ts +96 -0
- package/src/result.ts +60 -0
- package/src/store-types.ts +62 -0
package/dist/guard.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type GuardError } from "./error.js";
|
|
2
|
+
import { type NonceStore, type ReleaseOutcome, type ReserveParams, type StoreError } from "./nonce-store.js";
|
|
3
|
+
import { type Result } from "./result.js";
|
|
4
|
+
/** The deny reasons a reservation can carry. */
|
|
5
|
+
export type GuardErrorCode = "nonce-already-reserved" | "nonce-resource-mismatch" | "nonce-expired" | "store-unavailable" | "store-at-capacity";
|
|
6
|
+
export interface GuardOptions {
|
|
7
|
+
/** Where reserved nonces are tracked. Defaults to an in-memory store. */
|
|
8
|
+
store?: NonceStore;
|
|
9
|
+
/**
|
|
10
|
+
* Fold a nonce to a canonical key before it is reserved, so two encodings of one
|
|
11
|
+
* nonce cannot become two grants. Defaults to {@link canonicalNonce}. Pass the
|
|
12
|
+
* identity `(n) => n` to opt out and key on the exact bytes.
|
|
13
|
+
*/
|
|
14
|
+
canonicalizeNonce?: (nonce: string) => string;
|
|
15
|
+
/**
|
|
16
|
+
* Fold the resource to a canonical key before it is bound. Defaults to
|
|
17
|
+
* {@link canonicalResource}. Pass the identity `(r) => r` to opt out.
|
|
18
|
+
*/
|
|
19
|
+
canonicalizeResource?: (resource: string) => string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* A guard decision: reserved (grant may proceed) or denied with a typed reason.
|
|
23
|
+
*
|
|
24
|
+
* The reserved handle carries `release`, to free the nonce if the payment does not go
|
|
25
|
+
* through so the payer can retry the same authorization. The fencing token that
|
|
26
|
+
* authorizes the release is held inside the handle, never exposed. Not calling
|
|
27
|
+
* `release` is safe: the reservation expires with the authorization.
|
|
28
|
+
*/
|
|
29
|
+
export type Reservation = {
|
|
30
|
+
readonly reserved: true;
|
|
31
|
+
release(): Promise<Result<ReleaseOutcome, StoreError>>;
|
|
32
|
+
} | {
|
|
33
|
+
readonly reserved: false;
|
|
34
|
+
readonly reason: GuardError<GuardErrorCode>;
|
|
35
|
+
};
|
|
36
|
+
export interface Guard {
|
|
37
|
+
/**
|
|
38
|
+
* Reserve a payment's nonce before granting. The first caller for a nonce is
|
|
39
|
+
* reserved; any later caller (replay or concurrent race) is denied. A store
|
|
40
|
+
* failure denies (fail closed).
|
|
41
|
+
*/
|
|
42
|
+
reserve(params: ReserveParams): Promise<Reservation>;
|
|
43
|
+
}
|
|
44
|
+
/** Create a guard backed by an in-memory nonce store (or a supplied one). */
|
|
45
|
+
export declare function createGuard(options?: GuardOptions): Guard;
|
|
46
|
+
//# sourceMappingURL=guard.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"guard.d.ts","sourceRoot":"","sources":["../src/guard.ts"],"names":[],"mappings":"AAUA,OAAO,EAAE,KAAK,UAAU,EAAc,MAAM,YAAY,CAAC;AACzD,OAAO,EAEL,KAAK,UAAU,EACf,KAAK,cAAc,EACnB,KAAK,aAAa,EAClB,KAAK,UAAU,EAChB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAO,KAAK,MAAM,EAAiB,MAAM,aAAa,CAAC;AAE9D,gDAAgD;AAChD,MAAM,MAAM,cAAc,GACtB,wBAAwB,GACxB,yBAAyB,GACzB,eAAe,GACf,mBAAmB,GACnB,mBAAmB,CAAC;AAExB,MAAM,WAAW,YAAY;IAC3B,yEAAyE;IACzE,KAAK,CAAC,EAAE,UAAU,CAAC;IACnB;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC;IAC9C;;;OAGG;IACH,oBAAoB,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,MAAM,CAAC;CACrD;AAED;;;;;;;GAOG;AACH,MAAM,MAAM,WAAW,GACnB;IAAE,QAAQ,CAAC,QAAQ,EAAE,IAAI,CAAC;IAAC,OAAO,IAAI,OAAO,CAAC,MAAM,CAAC,cAAc,EAAE,UAAU,CAAC,CAAC,CAAA;CAAE,GACnF;IAAE,QAAQ,CAAC,QAAQ,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAC,cAAc,CAAC,CAAA;CAAE,CAAC;AAE9E,MAAM,WAAW,KAAK;IACpB;;;;OAIG;IACH,OAAO,CAAC,MAAM,EAAE,aAAa,GAAG,OAAO,CAAC,WAAW,CAAC,CAAC;CACtD;AAED,6EAA6E;AAC7E,wBAAgB,WAAW,CAAC,OAAO,GAAE,YAAiB,GAAG,KAAK,CA6D7D"}
|
package/dist/guard.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
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 { guardError } from "./error.js";
|
|
12
|
+
import { createMemoryNonceStore, } from "./nonce-store.js";
|
|
13
|
+
import { err, tryCatchAsync } from "./result.js";
|
|
14
|
+
/** Create a guard backed by an in-memory nonce store (or a supplied one). */
|
|
15
|
+
export function createGuard(options = {}) {
|
|
16
|
+
const store = options.store ?? createMemoryNonceStore();
|
|
17
|
+
const canonicalizeNonce = options.canonicalizeNonce ?? canonicalNonce;
|
|
18
|
+
const canonicalizeResource = options.canonicalizeResource ?? canonicalResource;
|
|
19
|
+
return {
|
|
20
|
+
async reserve(params) {
|
|
21
|
+
// Fold both keys to canonical form up front; everything below keys on the
|
|
22
|
+
// canonical values (see canonical.ts for why two encodings must not split).
|
|
23
|
+
const nonce = canonicalizeNonce(params.nonce);
|
|
24
|
+
const resource = canonicalizeResource(params.resource);
|
|
25
|
+
const result = await callStore(() => store.reserve({ nonce, resource, expiresAt: params.expiresAt }));
|
|
26
|
+
if (!result.ok) {
|
|
27
|
+
// callStore already mapped throws to store-unavailable; surface a known store
|
|
28
|
+
// code as-is, but collapse an unrecognized one (a misbehaving adapter) to
|
|
29
|
+
// store-unavailable so it still fails closed.
|
|
30
|
+
const error = result.error;
|
|
31
|
+
const reason = error.code === "store-at-capacity" || error.code === "store-unavailable"
|
|
32
|
+
? error
|
|
33
|
+
: guardError("store-unavailable", "nonce store unavailable", error);
|
|
34
|
+
return { reserved: false, reason };
|
|
35
|
+
}
|
|
36
|
+
switch (result.value.status) {
|
|
37
|
+
case "reserved": {
|
|
38
|
+
const { token } = result.value;
|
|
39
|
+
return { reserved: true, release: () => releaseReservation(store, nonce, token) };
|
|
40
|
+
}
|
|
41
|
+
case "already-reserved":
|
|
42
|
+
// Nonce taken. If first bound to a DIFFERENT resource this is a
|
|
43
|
+
// cross-resource substitution, not a replay: report it distinctly. Both
|
|
44
|
+
// sides are canonical keys (boundResource was stored canonical).
|
|
45
|
+
if (result.value.boundResource !== resource) {
|
|
46
|
+
return {
|
|
47
|
+
reserved: false,
|
|
48
|
+
reason: guardError("nonce-resource-mismatch", "payment nonce is bound to a different resource"),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
reserved: false,
|
|
53
|
+
reason: guardError("nonce-already-reserved", "payment nonce is already reserved"),
|
|
54
|
+
};
|
|
55
|
+
case "expired":
|
|
56
|
+
return {
|
|
57
|
+
reserved: false,
|
|
58
|
+
reason: guardError("nonce-expired", "payment authorization has expired"),
|
|
59
|
+
};
|
|
60
|
+
default:
|
|
61
|
+
// Defensive: an off-contract status fails closed here rather than falling
|
|
62
|
+
// through to an undefined return (a throw).
|
|
63
|
+
return {
|
|
64
|
+
reserved: false,
|
|
65
|
+
reason: guardError("store-unavailable", "nonce store returned an unknown status"),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Run a store op with the fail-closed guarantee in one place: a store that THROWS or
|
|
73
|
+
* REJECTS becomes a `store-unavailable` value rather than an uncaught rejection, so
|
|
74
|
+
* fail-closed does not depend on each adapter author. A returned error value is passed
|
|
75
|
+
* through for the caller to map.
|
|
76
|
+
*/
|
|
77
|
+
async function callStore(op) {
|
|
78
|
+
const outcome = await tryCatchAsync(op);
|
|
79
|
+
if (!outcome.ok) {
|
|
80
|
+
return err(guardError("store-unavailable", "nonce store unavailable", outcome.error));
|
|
81
|
+
}
|
|
82
|
+
return outcome.value;
|
|
83
|
+
}
|
|
84
|
+
/** Release a reservation through the store, mapping a store throw to a fail-closed error. */
|
|
85
|
+
function releaseReservation(store, nonce, token) {
|
|
86
|
+
return callStore(() => store.release(nonce, token));
|
|
87
|
+
}
|
|
88
|
+
//# sourceMappingURL=guard.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"guard.js","sourceRoot":"","sources":["../src/guard.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
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
|
+
/** Current package version (semver). */
|
|
15
|
+
export declare const VERSION: string;
|
|
16
|
+
export { type CacheDirectives, type CacheDirectivesOptions, isStorableBySharedCache, paidResponseCacheDirectives, } from "./cache.js";
|
|
17
|
+
export { canonicalNonce, canonicalResource } from "./canonical.js";
|
|
18
|
+
export { type GuardError, guardError } from "./error.js";
|
|
19
|
+
export { createGuard, type Guard, type GuardErrorCode, type GuardOptions, type Reservation, } from "./guard.js";
|
|
20
|
+
export { createMemoryNonceStore, type MemoryNonceStoreOptions, type NonceStore, type ReleaseOutcome, type ReserveError, type ReserveOutcome, type ReserveParams, type StoreError, } from "./nonce-store.js";
|
|
21
|
+
export { type ProtectDecision, type ProtectDenyReason, type ProtectHandlers, protect, } from "./protect.js";
|
|
22
|
+
export { err, ok, type Result, tryCatch, tryCatchAsync } from "./result.js";
|
|
23
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,wCAAwC;AACxC,eAAO,MAAM,OAAO,EAAE,MAAgB,CAAC;AAEvC,OAAO,EACL,KAAK,eAAe,EACpB,KAAK,sBAAsB,EAC3B,uBAAuB,EACvB,2BAA2B,GAC5B,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,cAAc,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AACnE,OAAO,EAAE,KAAK,UAAU,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AACzD,OAAO,EACL,WAAW,EACX,KAAK,KAAK,EACV,KAAK,cAAc,EACnB,KAAK,YAAY,EACjB,KAAK,WAAW,GACjB,MAAM,YAAY,CAAC;AACpB,OAAO,EACL,sBAAsB,EACtB,KAAK,uBAAuB,EAC5B,KAAK,UAAU,EACf,KAAK,cAAc,EACnB,KAAK,YAAY,EACjB,KAAK,cAAc,EACnB,KAAK,aAAa,EAClB,KAAK,UAAU,GAChB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EACL,KAAK,eAAe,EACpB,KAAK,iBAAiB,EACtB,KAAK,eAAe,EACpB,OAAO,GACR,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,GAAG,EAAE,EAAE,EAAE,KAAK,MAAM,EAAE,QAAQ,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
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
|
+
/** Current package version (semver). */
|
|
15
|
+
export const VERSION = "0.1.0";
|
|
16
|
+
export { isStorableBySharedCache, paidResponseCacheDirectives, } from "./cache.js";
|
|
17
|
+
export { canonicalNonce, canonicalResource } from "./canonical.js";
|
|
18
|
+
export { guardError } from "./error.js";
|
|
19
|
+
export { createGuard, } from "./guard.js";
|
|
20
|
+
export { createMemoryNonceStore, } from "./nonce-store.js";
|
|
21
|
+
export { protect, } from "./protect.js";
|
|
22
|
+
export { err, ok, tryCatch, tryCatchAsync } from "./result.js";
|
|
23
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,wCAAwC;AACxC,MAAM,CAAC,MAAM,OAAO,GAAW,OAAO,CAAC;AAEvC,OAAO,EAGL,uBAAuB,EACvB,2BAA2B,GAC5B,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,cAAc,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AACnE,OAAO,EAAmB,UAAU,EAAE,MAAM,YAAY,CAAC;AACzD,OAAO,EACL,WAAW,GAKZ,MAAM,YAAY,CAAC;AACpB,OAAO,EACL,sBAAsB,GAQvB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAIL,OAAO,GACR,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,GAAG,EAAE,EAAE,EAAe,QAAQ,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC"}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type Result } from "./result.js";
|
|
2
|
+
import type { NonceStore, ReleaseOutcome, ReserveError, ReserveOutcome, ReserveParams, StoreError } from "./store-types.js";
|
|
3
|
+
export type { NonceStore, ReleaseOutcome, ReserveError, ReserveOutcome, ReserveParams, StoreError, } from "./store-types.js";
|
|
4
|
+
/** Construction options for {@link MemoryNonceStore} / {@link createMemoryNonceStore}. */
|
|
5
|
+
export interface MemoryNonceStoreOptions {
|
|
6
|
+
/** Clock as unix seconds. Defaults to the system clock. Inject for tests. */
|
|
7
|
+
readonly now?: () => number;
|
|
8
|
+
/** Hard cap on retained reservations; a fresh reserve past it fails closed. */
|
|
9
|
+
readonly maxEntries?: number;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* In-memory nonce store for a single process. Atomic because the check and the set
|
|
13
|
+
* share one synchronous tick.
|
|
14
|
+
*
|
|
15
|
+
* Memory is bounded two ways: expired reservations are swept (lossless past
|
|
16
|
+
* `validBefore`), and because `expiresAt` is the attacker-signed `validBefore` the
|
|
17
|
+
* sweep alone is no bound, so a hard `maxEntries` cap rejects fresh reservations once
|
|
18
|
+
* reached (fail closed). Not for serverless isolates; use a shared store with a native
|
|
19
|
+
* compare-and-set there. Assumes a monotonic clock (the on-chain `validBefore` check is
|
|
20
|
+
* the end-to-end backstop). Exported for observability in tests; production code should
|
|
21
|
+
* use `createMemoryNonceStore`.
|
|
22
|
+
*/
|
|
23
|
+
export declare class MemoryNonceStore implements NonceStore {
|
|
24
|
+
private readonly reserved;
|
|
25
|
+
private lastSweep;
|
|
26
|
+
private readonly now;
|
|
27
|
+
private readonly maxEntries;
|
|
28
|
+
constructor(options?: MemoryNonceStoreOptions);
|
|
29
|
+
/** Number of reservations currently retained. */
|
|
30
|
+
get size(): number;
|
|
31
|
+
reserve({ nonce, resource, expiresAt, }: ReserveParams): Promise<Result<ReserveOutcome, ReserveError>>;
|
|
32
|
+
release(nonce: string, token: string): Promise<Result<ReleaseOutcome, StoreError>>;
|
|
33
|
+
/** Periodically drop expired reservations so unique-nonce floods stay bounded. */
|
|
34
|
+
private maybeSweep;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Create an in-memory nonce store. Optionally inject a clock (`() => unixSeconds`)
|
|
38
|
+
* and a hard `maxEntries` cap (fresh reserves past it fail closed).
|
|
39
|
+
*/
|
|
40
|
+
export declare function createMemoryNonceStore(options?: MemoryNonceStoreOptions): NonceStore;
|
|
41
|
+
//# sourceMappingURL=nonce-store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"nonce-store.d.ts","sourceRoot":"","sources":["../src/nonce-store.ts"],"names":[],"mappings":"AAgBA,OAAO,EAAW,KAAK,MAAM,EAAE,MAAM,aAAa,CAAC;AACnD,OAAO,KAAK,EACV,UAAU,EACV,cAAc,EACd,YAAY,EACZ,cAAc,EACd,aAAa,EACb,UAAU,EACX,MAAM,kBAAkB,CAAC;AAE1B,YAAY,EACV,UAAU,EACV,cAAc,EACd,YAAY,EACZ,cAAc,EACd,aAAa,EACb,UAAU,GACX,MAAM,kBAAkB,CAAC;AAQ1B,0FAA0F;AAC1F,MAAM,WAAW,uBAAuB;IACtC,6EAA6E;IAC7E,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IAC5B,+EAA+E;IAC/E,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;CAC9B;AAcD;;;;;;;;;;;GAWG;AACH,qBAAa,gBAAiB,YAAW,UAAU;IACjD,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAA4B;IACrD,OAAO,CAAC,SAAS,CAA4B;IAC7C,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAe;IACnC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IAEpC,YAAY,OAAO,GAAE,uBAA4B,EAGhD;IAED,iDAAiD;IACjD,IAAI,IAAI,IAAI,MAAM,CAEjB;IAED,OAAO,CAAC,EACN,KAAK,EACL,QAAQ,EACR,SAAS,GACV,EAAE,aAAa,GAAG,OAAO,CAAC,MAAM,CAAC,cAAc,EAAE,YAAY,CAAC,CAAC,CAsB/D;IAED,OAAO,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,cAAc,EAAE,UAAU,CAAC,CAAC,CASjF;IAED,kFAAkF;IAClF,OAAO,CAAC,UAAU;CAWnB;AAED;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,GAAE,uBAA4B,GAAG,UAAU,CAExF"}
|
|
@@ -0,0 +1,103 @@
|
|
|
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 } from "./result.js";
|
|
18
|
+
/** How often (in the store's clock, unix seconds) to sweep expired reservations. */
|
|
19
|
+
const SWEEP_INTERVAL_SECONDS = 60;
|
|
20
|
+
/**
|
|
21
|
+
* Hard ceiling on retained reservations: a fresh reserve past this fails closed
|
|
22
|
+
* rather than growing without bound (`expiresAt` is attacker-signed, so the sweep
|
|
23
|
+
* cannot reclaim a far-future entry). Override for the deployment.
|
|
24
|
+
*/
|
|
25
|
+
const DEFAULT_MAX_ENTRIES = 1_000_000;
|
|
26
|
+
const nowSeconds = () => Math.floor(Date.now() / 1000);
|
|
27
|
+
/**
|
|
28
|
+
* In-memory nonce store for a single process. Atomic because the check and the set
|
|
29
|
+
* share one synchronous tick.
|
|
30
|
+
*
|
|
31
|
+
* Memory is bounded two ways: expired reservations are swept (lossless past
|
|
32
|
+
* `validBefore`), and because `expiresAt` is the attacker-signed `validBefore` the
|
|
33
|
+
* sweep alone is no bound, so a hard `maxEntries` cap rejects fresh reservations once
|
|
34
|
+
* reached (fail closed). Not for serverless isolates; use a shared store with a native
|
|
35
|
+
* compare-and-set there. Assumes a monotonic clock (the on-chain `validBefore` check is
|
|
36
|
+
* the end-to-end backstop). Exported for observability in tests; production code should
|
|
37
|
+
* use `createMemoryNonceStore`.
|
|
38
|
+
*/
|
|
39
|
+
export class MemoryNonceStore {
|
|
40
|
+
reserved = new Map();
|
|
41
|
+
lastSweep = Number.NEGATIVE_INFINITY;
|
|
42
|
+
now;
|
|
43
|
+
maxEntries;
|
|
44
|
+
constructor(options = {}) {
|
|
45
|
+
this.now = options.now ?? nowSeconds;
|
|
46
|
+
this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
|
47
|
+
}
|
|
48
|
+
/** Number of reservations currently retained. */
|
|
49
|
+
get size() {
|
|
50
|
+
return this.reserved.size;
|
|
51
|
+
}
|
|
52
|
+
reserve({ nonce, resource, expiresAt, }) {
|
|
53
|
+
const now = this.now();
|
|
54
|
+
this.maybeSweep(now);
|
|
55
|
+
// An already-expired authorization is never grantable: refuse it in the same
|
|
56
|
+
// atomic step, before touching the reservation map.
|
|
57
|
+
if (expiresAt <= now) {
|
|
58
|
+
return Promise.resolve(ok({ status: "expired" }));
|
|
59
|
+
}
|
|
60
|
+
const existing = this.reserved.get(nonce);
|
|
61
|
+
if (existing !== undefined && existing.expiresAt > now) {
|
|
62
|
+
return Promise.resolve(ok({ status: "already-reserved", boundResource: existing.resource }));
|
|
63
|
+
}
|
|
64
|
+
// Reject a fresh reservation once full rather than grow unbounded or evict a live
|
|
65
|
+
// entry (which reopens the race). Overwriting a dead entry is exempt (no growth).
|
|
66
|
+
if (existing === undefined && this.reserved.size >= this.maxEntries) {
|
|
67
|
+
return Promise.resolve(err(guardError("store-at-capacity", "nonce store at capacity")));
|
|
68
|
+
}
|
|
69
|
+
const token = crypto.randomUUID();
|
|
70
|
+
this.reserved.set(nonce, { resource, expiresAt, token });
|
|
71
|
+
return Promise.resolve(ok({ status: "reserved", token }));
|
|
72
|
+
}
|
|
73
|
+
release(nonce, token) {
|
|
74
|
+
const entry = this.reserved.get(nonce);
|
|
75
|
+
// Fencing: free the nonce only for the holder of the matching token. A missing
|
|
76
|
+
// entry (expired, swept, already released) or a wrong token frees nothing.
|
|
77
|
+
if (entry === undefined || entry.token !== token) {
|
|
78
|
+
return Promise.resolve(ok({ status: "not-held" }));
|
|
79
|
+
}
|
|
80
|
+
this.reserved.delete(nonce);
|
|
81
|
+
return Promise.resolve(ok({ status: "released" }));
|
|
82
|
+
}
|
|
83
|
+
/** Periodically drop expired reservations so unique-nonce floods stay bounded. */
|
|
84
|
+
maybeSweep(now) {
|
|
85
|
+
if (now - this.lastSweep < SWEEP_INTERVAL_SECONDS) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
this.lastSweep = now;
|
|
89
|
+
for (const [nonce, entry] of this.reserved) {
|
|
90
|
+
if (entry.expiresAt <= now) {
|
|
91
|
+
this.reserved.delete(nonce);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Create an in-memory nonce store. Optionally inject a clock (`() => unixSeconds`)
|
|
98
|
+
* and a hard `maxEntries` cap (fresh reserves past it fail closed).
|
|
99
|
+
*/
|
|
100
|
+
export function createMemoryNonceStore(options = {}) {
|
|
101
|
+
return new MemoryNonceStore(options);
|
|
102
|
+
}
|
|
103
|
+
//# sourceMappingURL=nonce-store.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"nonce-store.js","sourceRoot":"","sources":["../src/nonce-store.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type GuardError } from "./error.js";
|
|
2
|
+
import type { Guard, GuardErrorCode } from "./guard.js";
|
|
3
|
+
import type { ReserveParams } from "./nonce-store.js";
|
|
4
|
+
/** Why a `protect` call did not grant: a guard deny, a failed settle, or non-finality. */
|
|
5
|
+
export type ProtectDenyReason = GuardErrorCode | "settle-failed" | "not-final";
|
|
6
|
+
interface ProtectHandlersBase<TResource> {
|
|
7
|
+
/** Settle the payment through your facilitator. Resolve `true` iff it settled. */
|
|
8
|
+
settle(): Promise<boolean>;
|
|
9
|
+
/** Deliver the resource once the payment is safe to grant. */
|
|
10
|
+
deliver(): TResource | Promise<TResource>;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The settle/deliver callbacks plus an explicit finality posture. Finality is a
|
|
14
|
+
* required discriminant, not an optional callback, so granting at zero confirmations
|
|
15
|
+
* is a decision made at the call site, never an implicit default for a security toggle.
|
|
16
|
+
*
|
|
17
|
+
* - `finality: "facilitator"` grants on settle success; finality then rests with the
|
|
18
|
+
* facilitator and the chain.
|
|
19
|
+
* - `finality: "confirm"` holds the grant until `confirm()` resolves `true`. A
|
|
20
|
+
* `confirm` that rejects or resolves `false` is treated as not-yet-final: the
|
|
21
|
+
* reservation is released and the grant withheld.
|
|
22
|
+
*/
|
|
23
|
+
export type ProtectHandlers<TResource> = (ProtectHandlersBase<TResource> & {
|
|
24
|
+
readonly finality: "facilitator";
|
|
25
|
+
}) | (ProtectHandlersBase<TResource> & {
|
|
26
|
+
readonly finality: "confirm";
|
|
27
|
+
confirm(): Promise<boolean>;
|
|
28
|
+
});
|
|
29
|
+
/** The decision `protect` returns: granted with the resource, or denied with a reason. */
|
|
30
|
+
export type ProtectDecision<TResource> = {
|
|
31
|
+
readonly granted: true;
|
|
32
|
+
readonly resource: TResource;
|
|
33
|
+
readonly cacheControl: string;
|
|
34
|
+
} | {
|
|
35
|
+
readonly granted: false;
|
|
36
|
+
readonly reason: GuardError<ProtectDenyReason>;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Run the guarded secure flow for one already-verified payment. `request` is the
|
|
40
|
+
* reservation input (the authenticated nonce, the served resource as a canonical
|
|
41
|
+
* key, and the authorization's `validBefore` as `expiresAt`).
|
|
42
|
+
*/
|
|
43
|
+
export declare function protect<TResource>(guard: Guard, request: ReserveParams, handlers: ProtectHandlers<TResource>): Promise<ProtectDecision<TResource>>;
|
|
44
|
+
export {};
|
|
45
|
+
//# sourceMappingURL=protect.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"protect.d.ts","sourceRoot":"","sources":["../src/protect.ts"],"names":[],"mappings":"AAgBA,OAAO,EAAE,KAAK,UAAU,EAAc,MAAM,YAAY,CAAC;AACzD,OAAO,KAAK,EAAE,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AACxD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAGtD,0FAA0F;AAC1F,MAAM,MAAM,iBAAiB,GAAG,cAAc,GAAG,eAAe,GAAG,WAAW,CAAC;AAE/E,UAAU,mBAAmB,CAAC,SAAS;IACrC,kFAAkF;IAClF,MAAM,IAAI,OAAO,CAAC,OAAO,CAAC,CAAC;IAC3B,8DAA8D;IAC9D,OAAO,IAAI,SAAS,GAAG,OAAO,CAAC,SAAS,CAAC,CAAC;CAC3C;AAED;;;;;;;;;;GAUG;AACH,MAAM,MAAM,eAAe,CAAC,SAAS,IACjC,CAAC,mBAAmB,CAAC,SAAS,CAAC,GAAG;IAAE,QAAQ,CAAC,QAAQ,EAAE,aAAa,CAAA;CAAE,CAAC,GACvE,CAAC,mBAAmB,CAAC,SAAS,CAAC,GAAG;IAChC,QAAQ,CAAC,QAAQ,EAAE,SAAS,CAAC;IAC7B,OAAO,IAAI,OAAO,CAAC,OAAO,CAAC,CAAC;CAC7B,CAAC,CAAC;AAEP,0FAA0F;AAC1F,MAAM,MAAM,eAAe,CAAC,SAAS,IACjC;IAAE,QAAQ,CAAC,OAAO,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,QAAQ,EAAE,SAAS,CAAC;IAAC,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;CAAE,GACvF;IAAE,QAAQ,CAAC,OAAO,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAC,iBAAiB,CAAC,CAAA;CAAE,CAAC;AAEhF;;;;GAIG;AACH,wBAAsB,OAAO,CAAC,SAAS,EACrC,KAAK,EAAE,KAAK,EACZ,OAAO,EAAE,aAAa,EACtB,QAAQ,EAAE,eAAe,CAAC,SAAS,CAAC,GACnC,OAAO,CAAC,eAAe,CAAC,SAAS,CAAC,CAAC,CAgCrC"}
|
package/dist/protect.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
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 { guardError } from "./error.js";
|
|
18
|
+
import { tryCatchAsync } from "./result.js";
|
|
19
|
+
/**
|
|
20
|
+
* Run the guarded secure flow for one already-verified payment. `request` is the
|
|
21
|
+
* reservation input (the authenticated nonce, the served resource as a canonical
|
|
22
|
+
* key, and the authorization's `validBefore` as `expiresAt`).
|
|
23
|
+
*/
|
|
24
|
+
export async function protect(guard, request, handlers) {
|
|
25
|
+
const reservation = await guard.reserve(request);
|
|
26
|
+
if (!reservation.reserved) {
|
|
27
|
+
return { granted: false, reason: reservation.reason };
|
|
28
|
+
}
|
|
29
|
+
// A settle callback that rejects (a transient facilitator/RPC error) is a failed
|
|
30
|
+
// settle, not a grant: release so the payer can retry.
|
|
31
|
+
const settled = await tryCatchAsync(() => handlers.settle());
|
|
32
|
+
if (!settled.ok || !settled.value) {
|
|
33
|
+
await reservation.release();
|
|
34
|
+
return { granted: false, reason: guardError("settle-failed", "payment did not settle") };
|
|
35
|
+
}
|
|
36
|
+
// Fail closed on finality: only the explicit "facilitator" posture grants at settle
|
|
37
|
+
// success. Any other value (a missing or misspelled `finality` from a non-TS caller)
|
|
38
|
+
// must clear the confirm gate; a missing `confirm` throws and becomes a not-final
|
|
39
|
+
// deny, never an unintended zero-confirmation grant.
|
|
40
|
+
if (handlers.finality !== "facilitator") {
|
|
41
|
+
// A confirm that rejects is not-yet-final too: withhold and free the nonce.
|
|
42
|
+
const final = await tryCatchAsync(() => handlers.confirm());
|
|
43
|
+
if (!final.ok || !final.value) {
|
|
44
|
+
await reservation.release();
|
|
45
|
+
return { granted: false, reason: guardError("not-final", "settlement not final") };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
granted: true,
|
|
50
|
+
resource: await handlers.deliver(),
|
|
51
|
+
cacheControl: paidResponseCacheDirectives().cacheControl,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
//# sourceMappingURL=protect.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"protect.js","sourceRoot":"","sources":["../src/protect.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,EAAE,2BAA2B,EAAE,MAAM,YAAY,CAAC;AACzD,OAAO,EAAmB,UAAU,EAAE,MAAM,YAAY,CAAC;AAGzD,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAmC5C;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,OAAO,CAC3B,KAAY,EACZ,OAAsB,EACtB,QAAoC;IAEpC,MAAM,WAAW,GAAG,MAAM,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;IACjD,IAAI,CAAC,WAAW,CAAC,QAAQ,EAAE,CAAC;QAC1B,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,WAAW,CAAC,MAAM,EAAE,CAAC;IACxD,CAAC;IAED,iFAAiF;IACjF,uDAAuD;IACvD,MAAM,OAAO,GAAG,MAAM,aAAa,CAAC,GAAG,EAAE,CAAC,QAAQ,CAAC,MAAM,EAAE,CAAC,CAAC;IAC7D,IAAI,CAAC,OAAO,CAAC,EAAE,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,CAAC;QAClC,MAAM,WAAW,CAAC,OAAO,EAAE,CAAC;QAC5B,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,UAAU,CAAC,eAAe,EAAE,wBAAwB,CAAC,EAAE,CAAC;IAC3F,CAAC;IAED,oFAAoF;IACpF,qFAAqF;IACrF,kFAAkF;IAClF,qDAAqD;IACrD,IAAI,QAAQ,CAAC,QAAQ,KAAK,aAAa,EAAE,CAAC;QACxC,4EAA4E;QAC5E,MAAM,KAAK,GAAG,MAAM,aAAa,CAAC,GAAG,EAAE,CAAC,QAAQ,CAAC,OAAO,EAAE,CAAC,CAAC;QAC5D,IAAI,CAAC,KAAK,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,KAAK,EAAE,CAAC;YAC9B,MAAM,WAAW,CAAC,OAAO,EAAE,CAAC;YAC5B,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,UAAU,CAAC,WAAW,EAAE,sBAAsB,CAAC,EAAE,CAAC;QACrF,CAAC;IACH,CAAC;IAED,OAAO;QACL,OAAO,EAAE,IAAI;QACb,QAAQ,EAAE,MAAM,QAAQ,CAAC,OAAO,EAAE;QAClC,YAAY,EAAE,2BAA2B,EAAE,CAAC,YAAY;KACzD,CAAC;AACJ,CAAC"}
|
package/dist/result.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
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
|
+
export type Result<T, E> = {
|
|
20
|
+
readonly ok: true;
|
|
21
|
+
readonly value: T;
|
|
22
|
+
} | {
|
|
23
|
+
readonly ok: false;
|
|
24
|
+
readonly error: E;
|
|
25
|
+
};
|
|
26
|
+
/** Wrap a success value. */
|
|
27
|
+
export declare function ok<T>(value: T): Result<T, never>;
|
|
28
|
+
/** Wrap a failure value. */
|
|
29
|
+
export declare function err<E>(error: E): Result<never, E>;
|
|
30
|
+
/**
|
|
31
|
+
* The single sanctioned synchronous throw-boundary. Runs `fn`, returning its
|
|
32
|
+
* value as `ok` or any thrown value coerced to an `Error` as `err`.
|
|
33
|
+
*/
|
|
34
|
+
export declare function tryCatch<T>(fn: () => T): Result<T, Error>;
|
|
35
|
+
/** The single sanctioned asynchronous throw-boundary. */
|
|
36
|
+
export declare function tryCatchAsync<T>(fn: () => Promise<T>): Promise<Result<T, Error>>;
|
|
37
|
+
//# sourceMappingURL=result.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"result.d.ts","sourceRoot":"","sources":["../src/result.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,MAAM,MAAM,MAAM,CAAC,CAAC,EAAE,CAAC,IACnB;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,CAAC,CAAA;CAAE,GACxC;IAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,CAAC,CAAA;CAAE,CAAC;AAE9C,4BAA4B;AAC5B,wBAAgB,EAAE,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,GAAG,MAAM,CAAC,CAAC,EAAE,KAAK,CAAC,CAEhD;AAED,4BAA4B;AAC5B,wBAAgB,GAAG,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,GAAG,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC,CAEjD;AAOD;;;GAGG;AACH,wBAAgB,QAAQ,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,CAAC,GAAG,MAAM,CAAC,CAAC,EAAE,KAAK,CAAC,CAOzD;AAED,yDAAyD;AACzD,wBAAsB,aAAa,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC,CAOtF"}
|
package/dist/result.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
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
|
+
/** Wrap a success value. */
|
|
20
|
+
export function ok(value) {
|
|
21
|
+
return { ok: true, value };
|
|
22
|
+
}
|
|
23
|
+
/** Wrap a failure value. */
|
|
24
|
+
export function err(error) {
|
|
25
|
+
return { ok: false, error };
|
|
26
|
+
}
|
|
27
|
+
// biome-ignore lint/plugin: a caught value is typed unknown by the language
|
|
28
|
+
function toError(caught) {
|
|
29
|
+
return caught instanceof Error ? caught : new Error(String(caught));
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The single sanctioned synchronous throw-boundary. Runs `fn`, returning its
|
|
33
|
+
* value as `ok` or any thrown value coerced to an `Error` as `err`.
|
|
34
|
+
*/
|
|
35
|
+
export function tryCatch(fn) {
|
|
36
|
+
// biome-ignore lint/plugin: the one sanctioned synchronous throw-boundary
|
|
37
|
+
try {
|
|
38
|
+
return ok(fn());
|
|
39
|
+
}
|
|
40
|
+
catch (caught) {
|
|
41
|
+
return err(toError(caught));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** The single sanctioned asynchronous throw-boundary. */
|
|
45
|
+
export async function tryCatchAsync(fn) {
|
|
46
|
+
// biome-ignore lint/plugin: the one sanctioned asynchronous throw-boundary
|
|
47
|
+
try {
|
|
48
|
+
return ok(await fn());
|
|
49
|
+
}
|
|
50
|
+
catch (caught) {
|
|
51
|
+
return err(toError(caught));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
//# sourceMappingURL=result.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"result.js","sourceRoot":"","sources":["../src/result.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAMH,4BAA4B;AAC5B,MAAM,UAAU,EAAE,CAAI,KAAQ;IAC5B,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC;AAC7B,CAAC;AAED,4BAA4B;AAC5B,MAAM,UAAU,GAAG,CAAI,KAAQ;IAC7B,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC;AAC9B,CAAC;AAED,4EAA4E;AAC5E,SAAS,OAAO,CAAC,MAAe;IAC9B,OAAO,MAAM,YAAY,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC;AACtE,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,QAAQ,CAAI,EAAW;IACrC,0EAA0E;IAC1E,IAAI,CAAC;QACH,OAAO,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC;IAClB,CAAC;IAAC,OAAO,MAAM,EAAE,CAAC;QAChB,OAAO,GAAG,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC;IAC9B,CAAC;AACH,CAAC;AAED,yDAAyD;AACzD,MAAM,CAAC,KAAK,UAAU,aAAa,CAAI,EAAoB;IACzD,2EAA2E;IAC3E,IAAI,CAAC;QACH,OAAO,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;IACxB,CAAC;IAAC,OAAO,MAAM,EAAE,CAAC;QAChB,OAAO,GAAG,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC;IAC9B,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1,66 @@
|
|
|
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
|
+
* The outcome of a reserve. `reserved` carries a fencing `token`: only its holder can
|
|
13
|
+
* later `release`, so releasing an in-flight hold is not a griefing primitive.
|
|
14
|
+
*/
|
|
15
|
+
export type ReserveOutcome = {
|
|
16
|
+
readonly status: "reserved";
|
|
17
|
+
readonly token: string;
|
|
18
|
+
} | {
|
|
19
|
+
readonly status: "already-reserved";
|
|
20
|
+
readonly boundResource: string;
|
|
21
|
+
} | {
|
|
22
|
+
readonly status: "expired";
|
|
23
|
+
};
|
|
24
|
+
/** The outcome of a release. `not-held` means no matching token: nothing was freed. */
|
|
25
|
+
export type ReleaseOutcome = {
|
|
26
|
+
readonly status: "released";
|
|
27
|
+
} | {
|
|
28
|
+
readonly status: "not-held";
|
|
29
|
+
};
|
|
30
|
+
/** The only error a `release` reports, and what the guard collapses any unrecognized store failure to (fail closed). */
|
|
31
|
+
export type StoreError = GuardError<"store-unavailable">;
|
|
32
|
+
/** What a `reserve` can report: a `StoreError`, or the store being at its hard `maxEntries` capacity. Branch on `code`. */
|
|
33
|
+
export type ReserveError = StoreError | GuardError<"store-at-capacity">;
|
|
34
|
+
export interface ReserveParams {
|
|
35
|
+
/**
|
|
36
|
+
* The payment's nonce; the x402 exact scheme uses a random 32-byte value. The guard
|
|
37
|
+
* keys on this nonce alone (folded to a canonical form by default, see canonical.ts).
|
|
38
|
+
* It does not scope by chain or asset, so a store shared across several chains or
|
|
39
|
+
* assets should namespace the nonce per (chain, asset) itself to keep two distinct
|
|
40
|
+
* payments from colliding; a direct store caller must also pre-fold it.
|
|
41
|
+
*/
|
|
42
|
+
readonly nonce: string;
|
|
43
|
+
/**
|
|
44
|
+
* The resource this payment is spent on, as a canonical key. Substitution compares
|
|
45
|
+
* this to the resource the nonce first bound to, so equal resources must produce an
|
|
46
|
+
* equal string. The guard canonicalizes URL casing by default.
|
|
47
|
+
*/
|
|
48
|
+
readonly resource: string;
|
|
49
|
+
/**
|
|
50
|
+
* Unix seconds, the authorization's `validBefore`. Used two ways: `reserve` refuses
|
|
51
|
+
* an authorization whose window has closed (`expiresAt <= now`), and past this time a
|
|
52
|
+
* live reservation may be evicted (unreplayable on-chain, so dropping it is lossless).
|
|
53
|
+
*/
|
|
54
|
+
readonly expiresAt: number;
|
|
55
|
+
}
|
|
56
|
+
/** A store of reserved payment nonces. `reserve` must be atomic. */
|
|
57
|
+
export interface NonceStore {
|
|
58
|
+
reserve(params: ReserveParams): Promise<Result<ReserveOutcome, ReserveError>>;
|
|
59
|
+
/**
|
|
60
|
+
* Release a reservation so the nonce can be reserved again (e.g. after a failed or
|
|
61
|
+
* reorged settlement, so a legitimate payer can retry). Releases only if `token`
|
|
62
|
+
* matches the one `reserve` returned (fencing); otherwise frees nothing (`not-held`).
|
|
63
|
+
*/
|
|
64
|
+
release(nonce: string, token: string): Promise<Result<ReleaseOutcome, StoreError>>;
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=store-types.d.ts.map
|