@meddleware/nft-gate-gateway 0.0.1

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/src/crypto.ts ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Low-level hashing + encoding helpers, mirroring the Rust gateway's `verify.rs` primitives.
3
+ * Uses the audited `@noble/*` primitives (the same family `@mysten/sui` builds on) so the
4
+ * Worker needs no WebCrypto async quirks and stays consistent across ed25519/secp256k1/p256.
5
+ */
6
+
7
+ import { blake2b } from '@noble/hashes/blake2.js'
8
+ import { sha256 as nobleSha256 } from '@noble/hashes/sha2.js'
9
+
10
+ export function blake2b256(data: Uint8Array): Uint8Array {
11
+ return blake2b(data, { dkLen: 32 })
12
+ }
13
+
14
+ export function sha256(data: Uint8Array): Uint8Array {
15
+ return nobleSha256(data)
16
+ }
17
+
18
+ const HEX = '0123456789abcdef'
19
+
20
+ export function bytesToHex(bytes: Uint8Array): string {
21
+ let out = ''
22
+ for (let i = 0; i < bytes.length; i++) {
23
+ out += HEX[bytes[i] >> 4] + HEX[bytes[i] & 0x0f]
24
+ }
25
+ return out
26
+ }
27
+
28
+ export function hexToBytes(hex: string): Uint8Array {
29
+ const s = hex.startsWith('0x') ? hex.slice(2) : hex
30
+ if (s.length % 2 !== 0) throw new Error('odd-length hex')
31
+ const out = new Uint8Array(s.length / 2)
32
+ for (let i = 0; i < out.length; i++) {
33
+ out[i] = parseInt(s.slice(i * 2, i * 2 + 2), 16)
34
+ }
35
+ return out
36
+ }
37
+
38
+ /** base64 (standard) → bytes. Uses `atob` (present in the Workers runtime). */
39
+ export function base64ToBytes(b64: string): Uint8Array {
40
+ const bin = atob(b64.trim())
41
+ const out = new Uint8Array(bin.length)
42
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
43
+ return out
44
+ }
45
+
46
+ export function bytesToBase64(bytes: Uint8Array): string {
47
+ let s = ''
48
+ for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i])
49
+ return btoa(s)
50
+ }
51
+
52
+ /** LEB128 unsigned encoding of `v` (matches Rust `write_uleb128`). */
53
+ export function uleb128(v: number): Uint8Array {
54
+ const out: number[] = []
55
+ let n = v >>> 0
56
+ // Support lengths beyond 32 bits defensively, though messages are tiny.
57
+ let big = v
58
+ do {
59
+ let byte = big & 0x7f
60
+ big = Math.floor(big / 128)
61
+ if (big !== 0) byte |= 0x80
62
+ out.push(byte)
63
+ n = big
64
+ } while (n !== 0)
65
+ return Uint8Array.from(out)
66
+ }
67
+
68
+ export function concatBytes(...parts: Uint8Array[]): Uint8Array {
69
+ let len = 0
70
+ for (const p of parts) len += p.length
71
+ const out = new Uint8Array(len)
72
+ let off = 0
73
+ for (const p of parts) {
74
+ out.set(p, off)
75
+ off += p.length
76
+ }
77
+ return out
78
+ }
package/src/index.ts ADDED
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Generic NFT-gated reverse proxy — Cloudflare Workers implementation.
3
+ *
4
+ * A drop-in, wire-identical sibling of the Rust gateway (`../rust`): same routes, status
5
+ * codes/messages, proof format, Sui RPC calls, and env-var config. Serves `GET /v1/challenge`,
6
+ * proxies configured public paths unauthenticated, and requires a valid wallet-signed access
7
+ * proof (verified against on-chain NFT ownership) for everything else. Mirror of `main.rs`.
8
+ */
9
+
10
+ import type { Config, Env } from './config.js'
11
+ import { loadConfig, isPublicPath } from './config.js'
12
+ import type { NonceBackend } from './state/types.js'
13
+ import { makeBackend } from './state/select.js'
14
+ import { SuiRpc } from './chain.js'
15
+ import { verifyAccessRequest, deniedReason } from './verify.js'
16
+ import { forward } from './proxy.js'
17
+ import { runQuotaGuard } from './quota.js'
18
+
19
+ export { NonceRateState } from './state/durable_object.js'
20
+
21
+ /**
22
+ * Per-isolate cached gateway state. Holds the fully resolved config, the chosen nonce
23
+ * backend, and the Sui RPC client so they persist across requests within the same isolate.
24
+ */
25
+ interface GatewayState {
26
+ cfg: Config
27
+ backend: NonceBackend
28
+ chain: SuiRpc
29
+ }
30
+
31
+ /** Lazily initialised once per isolate; `null` before the first request. */
32
+ let cached: GatewayState | null = null
33
+
34
+ /**
35
+ * Return (or lazily build) the per-isolate {@link GatewayState}. Checks a KV-based
36
+ * quota-degrade flag once per isolate and may switch to the KV backend when set.
37
+ *
38
+ * @param env - The Worker environment bindings for this deployment.
39
+ * @returns The resolved gateway state.
40
+ * @throws If required config vars are missing or the chosen nonce-backend binding is absent.
41
+ */
42
+ async function getState(env: Env): Promise<GatewayState> {
43
+ if (cached) return cached
44
+ const cfg = loadConfig(env)
45
+ // Honour an operator/quota-guard "degrade" flag once per isolate: prefer KV before DO
46
+ // free-tier limits bite (only when a KV binding is available).
47
+ let effective = cfg
48
+ if (cfg.nonceBackend === 'durable-object' && env.NONCE_KV) {
49
+ try {
50
+ if (await env.NONCE_KV.get('quota:degrade')) effective = { ...cfg, nonceBackend: 'kv' }
51
+ } catch {
52
+ /* ignore — stay on the configured backend */
53
+ }
54
+ }
55
+ cached = {
56
+ cfg,
57
+ backend: makeBackend(effective, env),
58
+ chain: new SuiRpc(cfg.suiRpcUrl, cfg.ownershipCacheTtlMs, cfg.suiRpcAuthHeader),
59
+ }
60
+ return cached
61
+ }
62
+
63
+ /**
64
+ * Build a JSON response with the given status code.
65
+ *
66
+ * @param status - HTTP status code.
67
+ * @param body - Value to serialize as the response body.
68
+ * @returns A `Response` with `content-type: application/json`.
69
+ */
70
+ function json(status: number, body: unknown): Response {
71
+ return new Response(JSON.stringify(body), {
72
+ status,
73
+ headers: { 'content-type': 'application/json' },
74
+ })
75
+ }
76
+
77
+ /**
78
+ * Build a JSON `{"error": reason}` response with the given status code.
79
+ *
80
+ * @param status - HTTP status code.
81
+ * @param reason - Short, client-visible error description.
82
+ * @returns A JSON error response.
83
+ */
84
+ function deny(status: number, reason: string): Response {
85
+ return json(status, { error: reason })
86
+ }
87
+
88
+ /** Prefer `Authorization: Bearer <token>`; fall back to an explicit `X-Access-Proof` header. */
89
+ function extractProofToken(request: Request): string | undefined {
90
+ const auth = request.headers.get('authorization')
91
+ if (auth) {
92
+ const m = auth.match(/^Bearer\s+(.+)$/i)
93
+ if (m) return m[1].trim()
94
+ }
95
+ const x = request.headers.get('x-access-proof')
96
+ return x ? x.trim() : undefined
97
+ }
98
+
99
+ /**
100
+ * Derive the nonce-shard region tag for a request.
101
+ *
102
+ * @param cfg - The resolved gateway config.
103
+ * @param request - The incoming Worker request (Cloudflare `cf` metadata is used when present).
104
+ * @returns A short string identifying the region (continent code or `"g"` for global).
105
+ */
106
+ function regionOf(cfg: Config, request: Request): string {
107
+ if (cfg.nonceShard === 'global') return 'g'
108
+ const cf = (request as { cf?: { continent?: string } }).cf
109
+ const continent = cf?.continent
110
+ return continent ? continent.toLowerCase() : 'g'
111
+ }
112
+
113
+ /**
114
+ * Main request dispatcher. Handles `/healthz`, `/v1/challenge`, configured public paths,
115
+ * and gated paths (signature + ownership verification before proxying to the upstream).
116
+ *
117
+ * @param request - The incoming HTTP request.
118
+ * @param env - The Worker environment bindings.
119
+ * @returns A `Response` to send to the client.
120
+ */
121
+ async function handle(request: Request, env: Env): Promise<Response> {
122
+ const url = new URL(request.url)
123
+ const path = url.pathname
124
+
125
+ if (path === '/healthz') return new Response('ok', { status: 200 })
126
+
127
+ let state: GatewayState
128
+ try {
129
+ state = await getState(env)
130
+ } catch (e) {
131
+ // Missing/invalid required config — fail closed (analogous to Rust's startup abort).
132
+ return deny(500, `gateway misconfigured: ${(e as Error).message}`)
133
+ }
134
+ const { cfg, backend, chain } = state
135
+
136
+ if (request.method === 'GET' && path === '/v1/challenge') {
137
+ const { nonce, expiresAt } = await backend.issue(regionOf(cfg, request), cfg.challengeTtlSecs)
138
+ return json(200, { nonce, expiresAt })
139
+ }
140
+
141
+ // Public passthrough (e.g. /v1/tip-config): forward without auth.
142
+ if (isPublicPath(cfg, path)) return forward(cfg, request)
143
+
144
+ const token = extractProofToken(request)
145
+ if (!token) return deny(401, 'missing access proof')
146
+
147
+ const result = await verifyAccessRequest(cfg, backend, token, chain)
148
+ if (!result.ok) {
149
+ const status = result.denied === 'ChainError' ? 502 : 403
150
+ return deny(status, deniedReason(result.denied))
151
+ }
152
+
153
+ if (!(await backend.rateCheck(result.address, cfg.rateLimitPerMin, regionOf(cfg, request)))) {
154
+ return deny(429, 'rate limit exceeded')
155
+ }
156
+
157
+ return forward(cfg, request)
158
+ }
159
+
160
+ /**
161
+ * Cloudflare Worker entry point. The `fetch` handler routes all HTTP traffic through
162
+ * {@link handle}; the `scheduled` handler runs the optional quota guard on a cron trigger.
163
+ */
164
+ export default {
165
+ fetch(request: Request, env: Env): Promise<Response> {
166
+ return handle(request, env)
167
+ },
168
+ async scheduled(_controller: ScheduledController, env: Env): Promise<void> {
169
+ if ((env.QUOTA_GUARD_ENABLED ?? 'false').toLowerCase() === 'true') {
170
+ await runQuotaGuard(env)
171
+ }
172
+ },
173
+ }
package/src/proxy.ts ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Reverse-proxy an authorised request to the configured upstream. Mirror of the Rust
3
+ * gateway's `proxy.rs`: body capped at `maxBodyBytes` before forwarding; `Host` /
4
+ * `Authorization` / `Content-Length` stripped from the request; hop-unsafe headers stripped
5
+ * from the response.
6
+ *
7
+ * `cfg.upstreamAuthHeaders` are injected on the upstream fetch — use this to pass
8
+ * Cloudflare Access service-token headers when the relay origin is Access-locked.
9
+ */
10
+
11
+ import type { Config } from './config.js'
12
+
13
+ /**
14
+ * Build a JSON `{"error": reason}` response with the given status code.
15
+ *
16
+ * @param status - HTTP status code.
17
+ * @param reason - Short, client-visible error description.
18
+ * @returns A JSON error response.
19
+ */
20
+ function errorResponse(status: number, reason: string): Response {
21
+ return new Response(JSON.stringify({ error: reason }), {
22
+ status,
23
+ headers: { 'content-type': 'application/json' },
24
+ })
25
+ }
26
+
27
+ /**
28
+ * Forward an authorised request to the configured upstream origin.
29
+ *
30
+ * Strips `Host`, `Authorization`, `X-Access-Proof`, and `Content-Length` from the request;
31
+ * strips `Content-Length`, `Transfer-Encoding`, and `Connection` from the response.
32
+ * Injects `cfg.upstreamAuthHeaders` on the upstream fetch (e.g. CF Access service-token headers).
33
+ * Returns 413 if the body exceeds `cfg.maxBodyBytes`.
34
+ *
35
+ * @param cfg - The resolved gateway config.
36
+ * @param request - The original incoming Worker request.
37
+ * @returns The upstream response, or an error response on failure.
38
+ * @throws Never — upstream failures are caught and returned as 502.
39
+ */
40
+ export async function forward(cfg: Config, request: Request): Promise<Response> {
41
+ const url = new URL(request.url)
42
+ const pathAndQuery = url.pathname + url.search
43
+ const upstreamUrl = cfg.upstreamUrl + pathAndQuery
44
+
45
+ // Read + cap the body (a griefing guard) before forwarding.
46
+ let body: ArrayBuffer | undefined
47
+ const method = request.method.toUpperCase()
48
+ if (method !== 'GET' && method !== 'HEAD') {
49
+ const buf = await request.arrayBuffer()
50
+ if (buf.byteLength > cfg.maxBodyBytes) {
51
+ return errorResponse(413, 'request body too large')
52
+ }
53
+ body = buf
54
+ }
55
+
56
+ const headers = new Headers(request.headers)
57
+ headers.delete('host')
58
+ headers.delete('authorization')
59
+ headers.delete('x-access-proof')
60
+ headers.delete('content-length')
61
+
62
+ // Inject upstream auth headers (e.g. CF Access service token for the locked relay origin).
63
+ for (const { name, value } of cfg.upstreamAuthHeaders) {
64
+ headers.set(name, value)
65
+ }
66
+
67
+ let upstream: Response
68
+ try {
69
+ upstream = await fetch(upstreamUrl, { method: request.method, headers, body })
70
+ } catch {
71
+ return errorResponse(502, 'upstream error')
72
+ }
73
+
74
+ // Strip hop-unsafe / recomputed headers from the response.
75
+ const outHeaders = new Headers(upstream.headers)
76
+ outHeaders.delete('content-length')
77
+ outHeaders.delete('transfer-encoding')
78
+ outHeaders.delete('connection')
79
+
80
+ return new Response(upstream.body, { status: upstream.status, headers: outHeaders })
81
+ }
package/src/quota.ts ADDED
@@ -0,0 +1,116 @@
1
+ /**
2
+ * OPTIONAL free-tier quota guard (off by default; `QUOTA_GUARD_ENABLED=true`). Cloudflare
3
+ * exposes account **usage** via the GraphQL Analytics API but **not** per-account *limit*
4
+ * values, so this compares current usage against the published Workers-Free thresholds
5
+ * (constants below — verify against the current pricing docs at deploy time).
6
+ *
7
+ * It (a) logs a warning as usage approaches a threshold and (b) can set a KV "degrade" flag
8
+ * that {@link ../config} consumers may honour to prefer the KV backend before DO limits bite.
9
+ * It never pre-pays or maintains a balance; it needs only a read-only Analytics API token.
10
+ */
11
+
12
+ import type { Env } from './config.js'
13
+
14
+ /** Published Workers Free plan monthly request limit. Verify against current pricing docs at deploy time. */
15
+ const FREE_REQUESTS_PER_MONTH = 3_000_000
16
+ /** Published Workers Free plan monthly Durable Object GB-second limit. Verify against current pricing docs. */
17
+ const FREE_DO_GB_S_PER_MONTH = 390_000
18
+ /** Log a warning when usage reaches this fraction of any free-tier limit. */
19
+ const WARN_AT = 0.8
20
+ /** Set the KV degrade flag when usage reaches this fraction of any free-tier limit. */
21
+ const DEGRADE_AT = 0.95
22
+
23
+ /** Cloudflare GraphQL Analytics API endpoint. */
24
+ const GRAPHQL_URL = 'https://api.cloudflare.com/client/v4/graphql'
25
+
26
+ /**
27
+ * Aggregated monthly usage snapshot for this Cloudflare account.
28
+ * `requests` — total Workers + DO invocations this calendar month.
29
+ * `doGbSeconds` — Durable Object GB-seconds of active time this calendar month.
30
+ */
31
+ interface Usage {
32
+ requests: number
33
+ doGbSeconds: number
34
+ }
35
+
36
+ /**
37
+ * Run the optional free-tier quota guard. Reads monthly usage via the Cloudflare GraphQL
38
+ * Analytics API, logs a warning near a limit, and sets (or clears) a KV `quota:degrade` flag
39
+ * that the isolate-state bootstrap may honour to prefer the KV nonce backend.
40
+ *
41
+ * @param env - Worker environment bindings. Requires `CF_ANALYTICS_TOKEN` and `CF_ACCOUNT_ID`;
42
+ * optionally uses `NONCE_KV` to set the degrade flag.
43
+ * @returns Resolves when the check completes (never rejects — errors are logged and suppressed).
44
+ */
45
+ export async function runQuotaGuard(env: Env): Promise<void> {
46
+ if (!env.CF_ANALYTICS_TOKEN || !env.CF_ACCOUNT_ID) {
47
+ console.warn('quota-guard: CF_ANALYTICS_TOKEN / CF_ACCOUNT_ID not set; skipping')
48
+ return
49
+ }
50
+ let usage: Usage
51
+ try {
52
+ usage = await fetchMonthlyUsage(env.CF_ANALYTICS_TOKEN, env.CF_ACCOUNT_ID)
53
+ } catch (e) {
54
+ console.warn('quota-guard: usage query failed:', (e as Error).message)
55
+ return
56
+ }
57
+
58
+ const reqPct = usage.requests / FREE_REQUESTS_PER_MONTH
59
+ const doPct = usage.doGbSeconds / FREE_DO_GB_S_PER_MONTH
60
+ const worst = Math.max(reqPct, doPct)
61
+
62
+ if (worst >= WARN_AT) {
63
+ console.warn(
64
+ `quota-guard: usage at ${(worst * 100).toFixed(0)}% of a Workers-Free limit ` +
65
+ `(requests ${(reqPct * 100).toFixed(0)}%, DO ${(doPct * 100).toFixed(0)}%)`,
66
+ )
67
+ }
68
+ if (env.NONCE_KV) {
69
+ if (worst >= DEGRADE_AT) {
70
+ await env.NONCE_KV.put('quota:degrade', '1', { expirationTtl: 3600 })
71
+ console.warn('quota-guard: set KV degrade flag (prefer KV backend)')
72
+ } else {
73
+ await env.NONCE_KV.delete('quota:degrade')
74
+ }
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Fetch aggregated Workers + DO usage for the current UTC calendar month.
80
+ *
81
+ * @param token - Read-only Cloudflare Analytics API token.
82
+ * @param accountId - Cloudflare account ID (`CF_ACCOUNT_ID`).
83
+ * @returns A {@link Usage} snapshot for the month so far.
84
+ * @throws If the GraphQL HTTP response is not OK.
85
+ */
86
+ async function fetchMonthlyUsage(token: string, accountId: string): Promise<Usage> {
87
+ const since = new Date()
88
+ since.setUTCDate(1)
89
+ since.setUTCHours(0, 0, 0, 0)
90
+ const query = `query($accountTag: String!, $since: Time!) {
91
+ viewer { accounts(filter: { accountTag: $accountTag }) {
92
+ workersInvocationsAdaptive(limit: 10000, filter: { datetime_geq: $since }) { sum { requests } }
93
+ durableObjectsInvocationsAdaptiveGroups(limit: 10000, filter: { datetime_geq: $since }) { sum { requests } }
94
+ durableObjectsPeriodicGroups(limit: 10000, filter: { datetime_geq: $since }) { sum { activeTime } }
95
+ } }
96
+ }`
97
+ const resp = await fetch(GRAPHQL_URL, {
98
+ method: 'POST',
99
+ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
100
+ body: JSON.stringify({ query, variables: { accountTag: accountId, since: since.toISOString() } }),
101
+ })
102
+ if (!resp.ok) throw new Error(`graphql http ${resp.status}`)
103
+ const json = (await resp.json()) as {
104
+ data?: { viewer?: { accounts?: Array<Record<string, Array<{ sum?: { requests?: number; activeTime?: number } }>>> } }
105
+ }
106
+ const acct = json.data?.viewer?.accounts?.[0]
107
+ const sumField = (rows: Array<{ sum?: { requests?: number; activeTime?: number } }> | undefined, key: 'requests' | 'activeTime'): number =>
108
+ (rows ?? []).reduce((n, r) => n + (r.sum?.[key] ?? 0), 0)
109
+ const requests =
110
+ sumField(acct?.workersInvocationsAdaptive, 'requests') +
111
+ sumField(acct?.durableObjectsInvocationsAdaptiveGroups, 'requests')
112
+ // activeTime is reported in microseconds → GB-seconds requires the memory tier; approximate
113
+ // with seconds of active time as a conservative signal (documented; refine per pricing docs).
114
+ const doGbSeconds = sumField(acct?.durableObjectsPeriodicGroups, 'activeTime') / 1_000_000
115
+ return { requests, doGbSeconds }
116
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Region-sharded, SQLite-backed Durable Object state — the default nonce store + rate limiter.
3
+ *
4
+ * Each region gets its own DO instance (id = `nonce:<region>`), which Cloudflare places near
5
+ * the request that first initializes it — so the shard sits close to its users. The issued
6
+ * nonce embeds the region tag (`<region>.<hex>`) so a later consume always routes back to the
7
+ * ISSUING shard, even under anycast drift.
8
+ *
9
+ * Single-threaded per DO ⇒ `takeIfValid` is atomic (the Redis-`GETDEL` analog): a nonce is
10
+ * consumed exactly once. Free-tier eligible (SQLite storage on the Workers Free plan).
11
+ */
12
+
13
+ import { DurableObject } from 'cloudflare:workers'
14
+ import type { NonceBackend } from './types.js'
15
+ import { randomHex24, shardOfNonce } from './types.js'
16
+
17
+ /** SQLite row shape for the `nonces` table. */
18
+ interface NonceRow {
19
+ expiry: number
20
+ }
21
+ /** SQLite row shape for the `rate` table. */
22
+ interface RateRow {
23
+ start: number
24
+ count: number
25
+ }
26
+ /** Result row for `SELECT COUNT(*) AS c`. */
27
+ interface CountRow {
28
+ c: number
29
+ }
30
+
31
+ /**
32
+ * Durable Object that provides the SQLite-backed nonce store and per-address rate limiter.
33
+ * Each instance corresponds to one region shard (id = `nonce:<region>`). Single-threaded
34
+ * per instance, so `takeIfValid` is naturally atomic (the Redis-`GETDEL` analog).
35
+ */
36
+ export class NonceRateState extends DurableObject {
37
+ private readonly sql: SqlStorage
38
+
39
+ /**
40
+ * @param ctx - Durable Object state, provides the SQLite storage handle.
41
+ * @param env - Worker environment (not used directly; passed through to the base class).
42
+ */
43
+ constructor(ctx: DurableObjectState, env: unknown) {
44
+ super(ctx as DurableObjectState, env as never)
45
+ this.sql = ctx.storage.sql
46
+ this.sql.exec(
47
+ 'CREATE TABLE IF NOT EXISTS nonces (nonce TEXT PRIMARY KEY, expiry INTEGER NOT NULL)',
48
+ )
49
+ this.sql.exec(
50
+ 'CREATE TABLE IF NOT EXISTS rate (addr TEXT PRIMARY KEY, start INTEGER NOT NULL, count INTEGER NOT NULL)',
51
+ )
52
+ }
53
+
54
+ /** Store a fresh nonce with a hard entry cap (evict soonest-to-expire). Returns expiry ms. */
55
+ issue(nonce: string, ttlSecs: number, maxEntries: number): number {
56
+ const now = Date.now()
57
+ const expiry = now + ttlSecs * 1000
58
+ // Prune expired first (bounded growth independent of issue cadence).
59
+ this.sql.exec('DELETE FROM nonces WHERE expiry <= ?', now)
60
+ const count = (this.sql.exec('SELECT COUNT(*) AS c FROM nonces').one() as unknown as CountRow).c
61
+ if (count >= Math.max(1, maxEntries)) {
62
+ this.sql.exec(
63
+ 'DELETE FROM nonces WHERE nonce = (SELECT nonce FROM nonces ORDER BY expiry ASC LIMIT 1)',
64
+ )
65
+ }
66
+ this.sql.exec('INSERT OR REPLACE INTO nonces (nonce, expiry) VALUES (?, ?)', nonce, expiry)
67
+ return expiry
68
+ }
69
+
70
+ /** Atomically consume a nonce: true iff present and unexpired. Deletes on read (single-use). */
71
+ takeIfValid(nonce: string): boolean {
72
+ const now = Date.now()
73
+ const rows = this.sql.exec('SELECT expiry FROM nonces WHERE nonce = ?', nonce).toArray() as unknown as NonceRow[]
74
+ if (rows.length === 0) return false
75
+ // Consume unconditionally (present ⇒ gone), so it can never be replayed.
76
+ this.sql.exec('DELETE FROM nonces WHERE nonce = ?', nonce)
77
+ return Number(rows[0].expiry) > now
78
+ }
79
+
80
+ /** Fixed 60s window per address. */
81
+ rateCheck(addr: string, maxPerMin: number): boolean {
82
+ if (maxPerMin === 0) return true
83
+ const now = Date.now()
84
+ const rows = this.sql.exec('SELECT start, count FROM rate WHERE addr = ?', addr).toArray() as unknown as RateRow[]
85
+ let start = now
86
+ let count = 0
87
+ if (rows.length > 0) {
88
+ const r = rows[0]
89
+ if (now - Number(r.start) < 60000) {
90
+ start = Number(r.start)
91
+ count = Number(r.count)
92
+ }
93
+ }
94
+ if (count >= maxPerMin) return false
95
+ this.sql.exec('INSERT OR REPLACE INTO rate (addr, start, count) VALUES (?, ?, ?)', addr, start, count + 1)
96
+ return true
97
+ }
98
+ }
99
+
100
+ /** {@link NonceBackend} that fans out to per-region {@link NonceRateState} DO shards. */
101
+ export class DurableObjectBackend implements NonceBackend {
102
+ /**
103
+ * @param ns - The Durable Object namespace binding for `NonceRateState`.
104
+ * @param shardMode - `"region"` places each shard near its users; `"global"` uses one instance.
105
+ * @param maxEntries - Hard cap on live nonce entries per shard (evicts oldest when reached).
106
+ */
107
+ constructor(
108
+ private readonly ns: DurableObjectNamespace<NonceRateState>,
109
+ private readonly shardMode: 'region' | 'global',
110
+ private readonly maxEntries: number,
111
+ ) {}
112
+
113
+ /**
114
+ * Retrieve the DO stub for the given region, creating a new shard if needed.
115
+ *
116
+ * @param region - The region tag used to derive the DO instance name (`nonce:<region>`).
117
+ * @returns A `DurableObjectStub` for that region's shard.
118
+ */
119
+ private stub(region: string): DurableObjectStub<NonceRateState> {
120
+ const id = this.ns.idFromName('nonce:' + region)
121
+ return this.ns.get(id)
122
+ }
123
+
124
+ async issue(region: string, ttlSecs: number): Promise<{ nonce: string; expiresAt: number }> {
125
+ const shard = this.shardMode === 'global' ? 'g' : region
126
+ const nonce = `${shard}.${randomHex24()}`
127
+ const expiresAt = await this.stub(shard).issue(nonce, ttlSecs, this.maxEntries)
128
+ return { nonce, expiresAt }
129
+ }
130
+
131
+ async takeIfValid(nonce: string): Promise<boolean> {
132
+ return this.stub(shardOfNonce(nonce)).takeIfValid(nonce)
133
+ }
134
+
135
+ async rateCheck(address: string, maxPerMin: number, region: string): Promise<boolean> {
136
+ const shard = this.shardMode === 'global' ? 'g' : region
137
+ return this.stub(shard).rateCheck(address, maxPerMin)
138
+ }
139
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Workers KV nonce store + best-effort rate limiter — the pure free-tier fallback backend
3
+ * (`NONCE_BACKEND=kv`). KV has native per-key TTL, but is **eventually consistent**: `get`
4
+ * then `delete` is not atomic, so a nonce could momentarily read valid in two regions within
5
+ * its TTL — a weaker cross-region replay window in the `SINGLE_USE=false` ownership mode. (In
6
+ * `SINGLE_USE=true` mode the on-chain `AccessConsumedEvent` remains the authoritative single-
7
+ * use bind, so that mode is unaffected.) Use the Durable Object backend when this matters.
8
+ */
9
+
10
+ import type { NonceBackend } from './types.js'
11
+ import { randomHex24 } from './types.js'
12
+
13
+ /** KV key prefix for nonce entries. */
14
+ const NONCE_PREFIX = 'nonce:'
15
+ /** KV key prefix for per-address rate-limit windows. */
16
+ const RATE_PREFIX = 'rate:'
17
+ /** Workers KV minimum `expirationTtl` (seconds). Sub-60s logical TTLs are enforced in-value. */
18
+ const KV_MIN_TTL_SECS = 60
19
+
20
+ /**
21
+ * {@link NonceBackend} implementation backed by Workers KV. Purely free-tier with no
22
+ * additional bindings beyond a `KVNamespace`. See the module doc for consistency caveats.
23
+ */
24
+ export class KvBackend implements NonceBackend {
25
+ /**
26
+ * @param kv - The `KVNamespace` binding to use for nonce and rate-limit storage.
27
+ */
28
+ constructor(private readonly kv: KVNamespace) {}
29
+
30
+ async issue(_region: string, ttlSecs: number): Promise<{ nonce: string; expiresAt: number }> {
31
+ // KV is global; the shard tag is cosmetic here (kept for a uniform nonce format).
32
+ const nonce = `g.${randomHex24()}`
33
+ const expiresAt = Date.now() + ttlSecs * 1000
34
+ // The logical expiry is stored IN the value and enforced on read, so sub-60s TTLs are
35
+ // honoured exactly; KV's own `expirationTtl` (min 60s) is only a GC backstop.
36
+ await this.kv.put(NONCE_PREFIX + nonce, JSON.stringify({ e: expiresAt }), {
37
+ expirationTtl: Math.max(KV_MIN_TTL_SECS, ttlSecs),
38
+ })
39
+ return { nonce, expiresAt }
40
+ }
41
+
42
+ async takeIfValid(nonce: string): Promise<boolean> {
43
+ const key = NONCE_PREFIX + nonce
44
+ const v = await this.kv.get(key)
45
+ if (v === null) return false
46
+ // Best-effort single-use: delete after a positive read (not atomic — see module note).
47
+ await this.kv.delete(key)
48
+ let expiry = 0
49
+ try {
50
+ expiry = (JSON.parse(v) as { e: number }).e
51
+ } catch {
52
+ return false
53
+ }
54
+ return expiry > Date.now()
55
+ }
56
+
57
+ async rateCheck(address: string, maxPerMin: number, _region: string): Promise<boolean> {
58
+ if (maxPerMin === 0) return true
59
+ const key = RATE_PREFIX + address
60
+ const raw = await this.kv.get(key)
61
+ const now = Date.now()
62
+ let start = now
63
+ let count = 0
64
+ if (raw) {
65
+ try {
66
+ const p = JSON.parse(raw) as { start: number; count: number }
67
+ if (now - p.start < 60000) {
68
+ start = p.start
69
+ count = p.count
70
+ }
71
+ } catch {
72
+ /* treat as a fresh window */
73
+ }
74
+ }
75
+ if (count >= maxPerMin) return false
76
+ await this.kv.put(key, JSON.stringify({ start, count: count + 1 }), {
77
+ expirationTtl: KV_MIN_TTL_SECS,
78
+ })
79
+ return true
80
+ }
81
+ }