@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
|
@@ -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
|
+
}
|