@meddleware/nft-gate-gateway 0.0.6 → 0.0.9

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/package.json CHANGED
@@ -1,11 +1,15 @@
1
- {
1
+ {
2
2
  "name": "@meddleware/nft-gate-gateway",
3
- "version": "0.0.6",
3
+ "version": "0.0.9",
4
4
  "type": "module",
5
5
  "description": "Cloudflare Workers implementation of the nft-gate gateway: a wire-identical, drop-in sibling of the Rust gateway that gates any HTTP upstream behind an access_gate NFT.",
6
6
  "license": "0BSD",
7
7
  "main": "src/index.ts",
8
- "files": ["src", "wrangler.toml", "tsconfig.json"],
8
+ "files": [
9
+ "src",
10
+ "wrangler.toml",
11
+ "tsconfig.json"
12
+ ],
9
13
  "publishConfig": {
10
14
  "access": "public"
11
15
  },
@@ -14,6 +18,7 @@
14
18
  "test": "vitest run --project unit",
15
19
  "test:unit": "vitest run --project unit",
16
20
  "test:integration": "vitest run --project cloudflare-integration",
21
+ "test:grpc": "GRPC_TESTNET=1 vitest run test/integration/grpc-chain.integration.test.ts",
17
22
  "test:all": "vitest run",
18
23
  "test:watch": "vitest --project unit",
19
24
  "dev": "wrangler dev",
@@ -21,16 +26,17 @@
21
26
  "generate:vectors": "node scripts/gen-vectors.mjs > ../conformance/vectors.json"
22
27
  },
23
28
  "dependencies": {
24
- "@meddleware/nft-gate-client": "^0.0.6",
29
+ "@meddleware/nft-gate-client": "^0.0.9",
30
+ "@mysten/sui": "~2.30.0",
25
31
  "@noble/curves": "~2.4.0",
26
32
  "@noble/hashes": "~2.4.0"
27
33
  },
28
34
  "devDependencies": {
29
35
  "@cloudflare/vitest-pool-workers": "~0.22.0",
30
- "@cloudflare/workers-types": "~5.20260910.1",
31
- "typescript": "~7.0.2",
32
- "vitest": "~4.1.11",
33
- "wrangler": "~4.130.0"
36
+ "@cloudflare/workers-types": "~5.20260920.1",
37
+ "typescript": "^6.0.0",
38
+ "vitest": "~4.1.0",
39
+ "wrangler": "^4.135.0"
34
40
  },
35
41
  "overrides": {
36
42
  "sharp": "0.35.4"
package/src/chain.ts CHANGED
@@ -1,10 +1,20 @@
1
1
  /**
2
- * Production {@link ChainQuery} over Sui JSON-RPC (`suix_getOwnedObjects`, `suix_queryEvents`,
3
- * `sui_getTransactionBlock`) via `fetch`. A 1:1 port of the Rust gateway's `sui_rpc.rs`,
4
- * including the identical request JSON shapes. The pure match/parse helpers are exported for
5
- * unit tests; the RPC round-trips are covered by the localnet/integration loop.
2
+ * Production {@link ChainQuery} over the Sui **gRPC** API (`@mysten/sui/grpc` `SuiGrpcClient`).
3
+ *
4
+ * Public Sui fullnodes have deprecated JSON-RPC (`suix_queryEvents`, `sui_getTransactionBlock`,
5
+ * `suix_getOwnedObjects` now return `-32601 Method not found`), so the gateway queries the chain
6
+ * over gRPC — the same transport `@meddleware/walrus-client` and `@meddleware/nft-gate-client`
7
+ * already use. The pure match/parse helpers are exported for unit tests; the gRPC round-trips are
8
+ * covered by the localnet/integration loop.
9
+ *
10
+ * Single-use verification is now **digest-first**: the access proof already carries the on-chain
11
+ * `access_gate::consume` transaction digest, so the gateway fetches that exact transaction and
12
+ * verifies it succeeded and emitted a matching `AccessConsumedEvent`. This is precise (bound to the
13
+ * challenge nonce + sender + gate) and does not need the deprecated event-by-sender query.
6
14
  */
7
15
 
16
+ import { SuiGrpcClient } from '@mysten/sui/grpc'
17
+ import { fetchAccessNfts } from '@meddleware/nft-gate-client'
8
18
  import type { ChainQuery } from './verify.js'
9
19
  import { base64ToBytes } from './crypto.js'
10
20
 
@@ -20,44 +30,37 @@ interface CacheEntry {
20
30
  expiry: number
21
31
  }
22
32
 
23
- /** Production {@link ChainQuery} backed by Sui JSON-RPC, with an optional ownership cache. */
24
- export class SuiRpc implements ChainQuery {
33
+ /** Number of `getTransaction` attempts (absorbs fullnode indexing lag after the client's finality wait). */
34
+ const TX_FETCH_ATTEMPTS = 4
35
+ /** Delay between `getTransaction` retries, in ms. */
36
+ const TX_FETCH_RETRY_MS = 500
37
+
38
+ /** Production {@link ChainQuery} backed by the Sui gRPC API, with an optional ownership cache. */
39
+ export class SuiGrpc implements ChainQuery {
25
40
  private readonly cache = new Map<string, CacheEntry>()
41
+ private readonly client: SuiGrpcClient
26
42
 
27
43
  /**
28
- * @param rpcUrl - Sui JSON-RPC endpoint URL.
44
+ * @param rpcUrl - Sui gRPC endpoint base URL (e.g. `https://fullnode.testnet.sui.io:443`).
29
45
  * @param cacheTtlMs - Ownership-cache TTL in ms. 0 disables the cache (live check every request).
30
- * @param authHeader - Optional header injected on every RPC call (e.g. credentialed fullnode auth).
46
+ * @param authHeader - Optional header injected on every gRPC call (e.g. credentialed fullnode auth).
31
47
  */
32
48
  constructor(
33
49
  private readonly rpcUrl: string,
34
50
  /** Ownership-cache TTL (ms). 0 = disabled (every gated check is live on-chain). */
35
51
  private readonly cacheTtlMs: number = 0,
36
- private readonly authHeader?: { name: string; value: string },
37
- ) {}
38
-
39
- /**
40
- * Send a Sui JSON-RPC 2.0 request and return the `result` field.
41
- *
42
- * @param method - JSON-RPC method name (e.g. `suix_getOwnedObjects`).
43
- * @param params - Positional parameters array.
44
- * @returns The `result` value from the RPC response, or `null`.
45
- * @throws If the HTTP response is not OK or the RPC body contains an `error` field.
46
- */
47
- private async call(method: string, params: Json): Promise<Json> {
48
- const headers: Record<string, string> = { 'content-type': 'application/json' }
49
- if (this.authHeader) headers[this.authHeader.name] = this.authHeader.value
50
- const resp = await fetch(this.rpcUrl, {
51
- method: 'POST',
52
- headers,
53
- body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
52
+ authHeader?: { name: string; value: string },
53
+ ) {
54
+ // The `network` label is cosmetic when an explicit `baseUrl` is supplied (core RPC resolves via
55
+ // the endpoint, not the label); infer it from the URL so a mainnet fullnode is labelled correctly.
56
+ const network = /mainnet/i.test(rpcUrl) ? 'mainnet' : 'testnet'
57
+ this.client = new SuiGrpcClient({
58
+ network,
59
+ baseUrl: rpcUrl,
60
+ // gRPC-web metadata keys must be lower-case ASCII; a credentialed fullnode auth header
61
+ // (e.g. `Authorization: Bearer …`) is threaded here on every call.
62
+ ...(authHeader ? { meta: { [authHeader.name.toLowerCase()]: authHeader.value } } : {}),
54
63
  })
55
- if (!resp.ok) throw new Error(`rpc http ${resp.status} from ${method}`)
56
- const json = (await resp.json()) as Record<string, Json>
57
- if (json && typeof json === 'object' && 'error' in json && json.error) {
58
- throw new Error(`rpc error from ${method}: ${JSON.stringify(json.error)}`)
59
- }
60
- return (json as { result?: Json }).result ?? null
61
64
  }
62
65
 
63
66
  /**
@@ -84,7 +87,9 @@ export class SuiRpc implements ChainQuery {
84
87
  }
85
88
 
86
89
  /**
87
- * Uncached, live `suix_getOwnedObjects` ownership query.
90
+ * Uncached, live ownership query over gRPC `listOwnedObjects`. Reuses `fetchAccessNfts` from
91
+ * `@meddleware/nft-gate-client` (the same gRPC core-API parse the frontend uses), so the
92
+ * gateway and client agree on what counts as a held access NFT.
88
93
  *
89
94
  * @param address - Sui address to query.
90
95
  * @param nftType - NFT struct type to filter by.
@@ -92,16 +97,8 @@ export class SuiRpc implements ChainQuery {
92
97
  * @returns `true` if at least one qualifying NFT is owned.
93
98
  */
94
99
  private async ownsNftLive(address: string, nftType: string, gateId?: string): Promise<boolean> {
95
- const params = [
96
- address,
97
- { filter: { StructType: nftType }, options: { showContent: true } },
98
- null,
99
- 50,
100
- ]
101
- const result = (await this.call('suix_getOwnedObjects', params)) as { data?: Json[] } | null
102
- const data = (result && Array.isArray(result.data) ? result.data : []) as Json[]
103
- if (gateId === undefined) return data.length > 0
104
- return data.some((entry) => pointerStr(entry, ['data', 'content', 'fields', 'data', 'fields', 'gate_id']) === gateId)
100
+ const nfts = await fetchAccessNfts(this.client, address, nftType, gateId)
101
+ return nfts.length > 0
105
102
  }
106
103
 
107
104
  /**
@@ -116,7 +113,7 @@ export class SuiRpc implements ChainQuery {
116
113
  async ownsNft(address: string, nftType: string, gateId?: string): Promise<boolean> {
117
114
  // Cache is OFF by default (ttl 0) so a gated action is confirmed live on-chain.
118
115
  if (this.cacheTtlMs > 0) {
119
- const key = SuiRpc.cacheKey(address, nftType, gateId)
116
+ const key = SuiGrpc.cacheKey(address, nftType, gateId)
120
117
  const hit = this.cache.get(key)
121
118
  const now = Date.now()
122
119
  if (hit && hit.expiry > now) return hit.owns
@@ -128,70 +125,64 @@ export class SuiRpc implements ChainQuery {
128
125
  }
129
126
 
130
127
  /**
131
- * Directly verify the consume transaction named by `digest` via `sui_getTransactionBlock`.
132
- * The tx must have succeeded and emitted a matching `AccessConsumedEvent`. Defence-in-depth
133
- * atop the sender+nonce event query.
128
+ * Fetch a transaction by digest via gRPC, retrying briefly to absorb the window between the
129
+ * client's finality wait and the gateway fullnode indexing the transaction.
134
130
  *
135
- * @param digest - Transaction digest of the on-chain consume.
136
- * @param nonce - The challenge nonce that was consumed.
137
- * @param address - Expected transaction sender.
138
- * @param gateId - Optional gate object ID constraint.
139
- * @returns `true` if the transaction confirms the consume.
131
+ * @param digest - Transaction digest to fetch.
132
+ * @returns The gRPC `TransactionResult` (`$kind: 'Transaction' | 'FailedTransaction'`).
133
+ * @throws If every attempt fails (surfaces as a {@link ChainQuery} ChainError → 502).
140
134
  */
141
- private async consumeTxMatches(
142
- digest: string,
143
- nonce: string,
144
- address: string,
145
- gateId?: string,
146
- ): Promise<boolean> {
147
- const params = [digest, { showEvents: true, showEffects: true }]
148
- const result = (await this.call('sui_getTransactionBlock', params)) as Json
149
- const status = pointerStr(result, ['effects', 'status', 'status'])
150
- if (status !== 'success') return false
151
- const events = (pointer(result, ['events']) as Json[] | undefined) ?? []
152
- return (
153
- Array.isArray(events) &&
154
- events.some((ev) => isConsumedEvent(ev) && eventMatches(ev, nonce, address, gateId))
155
- )
135
+ private async getTransaction(digest: string): Promise<Json> {
136
+ let lastErr: unknown
137
+ for (let attempt = 0; attempt < TX_FETCH_ATTEMPTS; attempt++) {
138
+ try {
139
+ return (await this.client.core.getTransaction({
140
+ digest,
141
+ include: { events: true },
142
+ })) as Json
143
+ } catch (e) {
144
+ lastErr = e
145
+ if (attempt < TX_FETCH_ATTEMPTS - 1) {
146
+ await new Promise<void>((r) => setTimeout(r, TX_FETCH_RETRY_MS))
147
+ }
148
+ }
149
+ }
150
+ throw lastErr instanceof Error ? lastErr : new Error(String(lastErr))
156
151
  }
157
152
 
158
153
  /**
159
- * Verify a single-use consume via `suix_queryEvents`. Queries for an `AccessConsumedEvent`
160
- * emitted by `address` carrying `nonce`; if `consumeDigest` is also provided, additionally
161
- * verifies that exact transaction (F4: defence in depth).
154
+ * Verify a single-use consume by fetching its `consumeDigest` transaction directly and confirming
155
+ * it succeeded and emitted an `AccessConsumedEvent` for this sender + gate.
156
+ *
157
+ * The event is bound to the sender (which must equal the signature-verified proof address) and
158
+ * the gate — NOT to the challenge nonce. Decoupling from the nonce is what lets an interrupted
159
+ * upload resume with a fresh (free) challenge signature while reusing the same on-chain consume;
160
+ * single-use is then enforced by the gateway's redemption store keying on this `consumeDigest`.
161
+ * An attacker cannot present someone else's consume (the event `sender` would not match the
162
+ * signed address) nor forge one without owning the soulbound NFT.
162
163
  *
163
- * @param nonce - The challenge nonce that was consumed.
164
- * @param address - The Sui address that submitted the consume transaction.
165
- * @param nftType - Fully-qualified NFT type used to derive the event package.
164
+ * @param consumeDigest - Transaction digest of the on-chain consume.
165
+ * @param address - The signature-verified proof address; must equal the event sender.
166
166
  * @param gateId - Optional gate object ID constraint.
167
- * @param consumeDigest - Optional transaction digest for direct tx verification.
168
- * @returns `true` if a matching consume event (and, when given, a matching tx) is found.
167
+ * @returns `true` if the transaction confirms a matching consume.
169
168
  */
170
- async consumeEventMatches(
171
- nonce: string,
172
- address: string,
173
- nftType: string,
174
- gateId?: string,
175
- consumeDigest?: string,
176
- ): Promise<boolean> {
177
- const pkg = SuiRpc.packageOf(nftType)
178
- if (!pkg) throw new Error('cannot derive package from nft_type')
179
- const eventType = `${pkg}::access_gate::AccessConsumedEvent`
180
- // Filter by BOTH the event type AND the emitting Sender (F5): O(this user's events).
181
- const params = [{ All: [{ MoveEventType: eventType }, { Sender: address }] }, null, 50, true]
182
- const result = (await this.call('suix_queryEvents', params)) as { data?: Json[] } | null
183
- const data = (result && Array.isArray(result.data) ? result.data : []) as Json[]
184
- const primary = data.some((ev) => eventMatches(ev, nonce, address, gateId))
185
- if (!primary) return false
186
- // F4: if a consume tx digest was supplied, verify that exact transaction too.
187
- if (consumeDigest !== undefined) {
188
- return this.consumeTxMatches(consumeDigest, nonce, address, gateId)
189
- }
190
- return true
169
+ async consumeTxValid(consumeDigest: string, address: string, gateId?: string): Promise<boolean> {
170
+ const res = (await this.getTransaction(consumeDigest)) as Record<string, Json> | null
171
+ // The gRPC result is a oneof: `{ $kind: 'Transaction', Transaction }` on success, or
172
+ // `{ $kind: 'FailedTransaction', FailedTransaction }` when the transaction aborted.
173
+ if (!res || res.$kind !== 'Transaction') return false
174
+ const tx = res.Transaction as Record<string, Json> | undefined
175
+ const status = tx?.status as { success?: boolean } | undefined
176
+ if (!status?.success) return false
177
+ const events = (Array.isArray(tx?.events) ? (tx?.events as Json[]) : []) as Json[]
178
+ return events.some((ev) => isConsumedEvent(ev) && eventMatches(ev, address, gateId))
191
179
  }
192
180
  }
193
181
 
194
- // ── pure helpers (unit-tested; mirror sui_rpc.rs) ────────────────────────────
182
+ // ── pure helpers (unit-tested; adapted to the gRPC event shape) ───────────────
183
+ // gRPC events expose `eventType`/`sender`/`json`; JSON-RPC used `type`/`sender`/`parsedJson`.
184
+ // The helpers read both keys so they stay tolerant to transport/shape variation (the SDK documents
185
+ // that the `json` shape may differ between transports).
195
186
 
196
187
  /**
197
188
  * Traverse a nested JSON value by a sequence of object keys.
@@ -224,14 +215,24 @@ function pointerStr(v: Json, path: string[]): string | undefined {
224
215
  return typeof r === 'string' ? r : undefined
225
216
  }
226
217
 
218
+ /** The event's Move type string, from the gRPC (`eventType`) or JSON-RPC (`type`) shape. */
219
+ function eventType(ev: Json): string | undefined {
220
+ return pointerStr(ev, ['eventType']) ?? pointerStr(ev, ['type'])
221
+ }
222
+
223
+ /** The event's parsed Move struct fields, from the gRPC (`json`) or JSON-RPC (`parsedJson`) shape. */
224
+ function eventFields(ev: Json): Json {
225
+ return pointer(ev, ['json']) ?? pointer(ev, ['parsedJson'])
226
+ }
227
+
227
228
  /** True if the event's type ends with `::access_gate::AccessConsumedEvent`. */
228
229
  export function isConsumedEvent(ev: Json): boolean {
229
- const t = pointerStr(ev, ['type'])
230
+ const t = eventType(ev)
230
231
  return t !== undefined && t.endsWith('::access_gate::AccessConsumedEvent')
231
232
  }
232
233
 
233
234
  /**
234
- * Match the on-chain `AccessConsumedEvent.nonce` (`vector<u8>`), rendered by RPC as either an
235
+ * Match the on-chain `AccessConsumedEvent.nonce` (`vector<u8>`), rendered by the API as either an
235
236
  * array of byte numbers or a base64 string, against the challenge nonce's UTF-8 bytes.
236
237
  */
237
238
  export function nonceMatches(eventNonce: Json, nonce: string): boolean {
@@ -251,11 +252,14 @@ export function nonceMatches(eventNonce: Json, nonce: string): boolean {
251
252
  return false
252
253
  }
253
254
 
254
- /** sender == address, nonce matches, and (if given) gate_id matches. */
255
- export function eventMatches(ev: Json, nonce: string, address: string, gateId?: string): boolean {
255
+ /**
256
+ * `sender == address` and (if given) `gate_id` matches. The consume event is bound to the
257
+ * signature-verified sender + gate; single-use is enforced separately by redemption tracking on the
258
+ * `consumeDigest`, so the event nonce is intentionally not checked here (see `consumeTxValid`).
259
+ */
260
+ export function eventMatches(ev: Json, address: string, gateId?: string): boolean {
256
261
  const senderOk = pointerStr(ev, ['sender']) === address
257
- const nonceVal = pointer(ev, ['parsedJson', 'nonce'])
258
- const nonceOk = nonceVal !== undefined && nonceMatches(nonceVal, nonce)
259
- const gateOk = gateId === undefined ? true : pointerStr(ev, ['parsedJson', 'gate_id']) === gateId
260
- return senderOk && nonceOk && gateOk
262
+ const fields = eventFields(ev)
263
+ const gateOk = gateId === undefined ? true : pointerStr(fields, ['gate_id']) === gateId
264
+ return senderOk && gateOk
261
265
  }
package/src/config.ts CHANGED
@@ -19,9 +19,15 @@ export interface Env {
19
19
  SINGLE_USE?: string
20
20
  PUBLIC_PATHS?: string
21
21
  RATE_LIMIT_PER_MIN?: string
22
+ PUBLIC_RATE_LIMIT_PER_MIN?: string
23
+ PUBLIC_CACHE_TTL_SECS?: string
22
24
  MAX_BODY_BYTES?: string
23
25
  CHALLENGE_TTL_SECS?: string
24
26
  OWNERSHIP_CACHE_TTL_MS?: string
27
+ /** Single-use: seconds a consume-digest redemption lease is held during an in-flight upload. */
28
+ REDEMPTION_LEASE_TTL_SECS?: string
29
+ /** Single-use: seconds a committed (spent) consume-digest is remembered to block re-redemption. */
30
+ REDEMPTION_RETENTION_SECS?: string
25
31
  // ── Workers-specific ──────────────────────────────────────────────────────
26
32
  /** `durable-object` (default) | `kv`. */
27
33
  NONCE_BACKEND?: string
@@ -43,6 +49,13 @@ export interface Env {
43
49
  UPSTREAM_AUTH_HEADERS?: string
44
50
  /** `true` enables the scheduled quota guard. */
45
51
  QUOTA_GUARD_ENABLED?: string
52
+ /**
53
+ * Comma-separated list of browser origins allowed to make cross-origin requests.
54
+ * Only origins in this list receive an `Access-Control-Allow-Origin` header.
55
+ * Defaults to the two Meddleware app origins when absent.
56
+ * Example: `"https://sui-walrus.meddleware.co.uk,https://sui.meddleware.co.uk"`
57
+ */
58
+ ALLOWED_ORIGINS?: string
46
59
  // ── bindings ──────────────────────────────────────────────────────────────
47
60
  NONCE_STATE?: DurableObjectNamespace
48
61
  NONCE_KV?: KVNamespace
@@ -69,12 +82,22 @@ export interface Config {
69
82
  singleUse: boolean
70
83
  publicPaths: string[]
71
84
  rateLimitPerMin: number
85
+ /** Per-client-IP request cap for unauthenticated public paths (e.g. /v1/tip-config). */
86
+ publicRateLimitPerMin: number
87
+ /** Edge-cache TTL (s) for cacheable GET responses on public paths. 0 disables caching. */
88
+ publicCacheTtlSecs: number
72
89
  maxBodyBytes: number
73
90
  ownershipCacheTtlMs: number
91
+ /** Single-use: lease TTL (s) for an in-flight consume-digest redemption. */
92
+ redemptionLeaseTtlSecs: number
93
+ /** Single-use: retention (s) of a committed (spent) consume-digest. */
94
+ redemptionRetentionSecs: number
74
95
  nonceBackend: NonceBackendKind
75
96
  nonceShard: NonceShardMode
76
97
  nonceMaxEntries: number
77
98
  quotaGuardEnabled: boolean
99
+ /** Allowed CORS origins — only these are reflected in Access-Control-Allow-Origin. */
100
+ allowedOrigins: string[]
78
101
  }
79
102
 
80
103
  function req(env: Env, key: keyof Env): string {
@@ -104,6 +127,19 @@ function parseAuthHeader(v: string | undefined): { name: string; value: string }
104
127
  * Each entry follows the same `Name: value` format as `SUI_RPC_AUTH_HEADER`.
105
128
  * Entries that cannot be parsed (no colon) are silently skipped.
106
129
  */
130
+ const DEFAULT_ALLOWED_ORIGINS = [
131
+ 'https://sui-walrus.meddleware.co.uk',
132
+ 'https://sui.meddleware.co.uk',
133
+ ]
134
+
135
+ function parseAllowedOrigins(v: string | undefined): string[] {
136
+ if (!v || v.trim().length === 0) return DEFAULT_ALLOWED_ORIGINS
137
+ return v
138
+ .split(',')
139
+ .map((s) => s.trim())
140
+ .filter((s) => s.length > 0)
141
+ }
142
+
107
143
  function parseUpstreamAuthHeaders(v: string | undefined): Array<{ name: string; value: string }> {
108
144
  if (!v || v.trim().length === 0) return []
109
145
  return v
@@ -133,12 +169,17 @@ export function loadConfig(env: Env): Config {
133
169
  .map((s) => s.trim())
134
170
  .filter((s) => s.length > 0),
135
171
  rateLimitPerMin: numOr(env.RATE_LIMIT_PER_MIN, 30),
172
+ publicRateLimitPerMin: numOr(env.PUBLIC_RATE_LIMIT_PER_MIN, 120),
173
+ publicCacheTtlSecs: numOr(env.PUBLIC_CACHE_TTL_SECS, 60),
136
174
  maxBodyBytes: numOr(env.MAX_BODY_BYTES, 262144),
137
175
  ownershipCacheTtlMs: numOr(env.OWNERSHIP_CACHE_TTL_MS, 0),
176
+ redemptionLeaseTtlSecs: numOr(env.REDEMPTION_LEASE_TTL_SECS, 120),
177
+ redemptionRetentionSecs: numOr(env.REDEMPTION_RETENTION_SECS, 2592000),
138
178
  nonceBackend,
139
179
  nonceShard,
140
180
  nonceMaxEntries: numOr(env.NONCE_MAX_ENTRIES, 1000000),
141
181
  quotaGuardEnabled: (env.QUOTA_GUARD_ENABLED ?? 'false').toLowerCase() === 'true',
182
+ allowedOrigins: parseAllowedOrigins(env.ALLOWED_ORIGINS),
142
183
  }
143
184
  }
144
185
 
package/src/cors.ts CHANGED
@@ -6,10 +6,14 @@
6
6
  * Access-Control-Allow-Origin headers the browser blocks the response even when
7
7
  * the Worker returns 200, and OPTIONS preflights (required before non-simple requests
8
8
  * such as PUT uploads with an Authorization header) receive no preflight grant.
9
+ *
10
+ * Origin allowlist: rather than reflecting `*`, only origins present in the configured
11
+ * ALLOWED_ORIGINS list receive the Access-Control-Allow-Origin header. An absent or
12
+ * disallowed origin gets no CORS header — the browser blocks the cross-origin request,
13
+ * which is the correct fail-closed behaviour.
9
14
  */
10
15
 
11
- const CORS_HEADERS: Record<string, string> = {
12
- 'access-control-allow-origin': '*',
16
+ const CORS_STATIC_HEADERS: Record<string, string> = {
13
17
  'access-control-allow-methods': 'GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS',
14
18
  'access-control-allow-headers': 'authorization, content-type, x-access-proof',
15
19
  'access-control-expose-headers': 'location, upload-offset',
@@ -17,12 +21,25 @@ const CORS_HEADERS: Record<string, string> = {
17
21
  }
18
22
 
19
23
  /**
20
- * Clone `res` and add CORS headers. Using `set()` so calling this on an upstream
21
- * response that already carries CORS headers just overwrites them consistently.
24
+ * Resolve the reflected `Access-Control-Allow-Origin` value for a request.
25
+ * Returns the request origin if it appears in `allowedOrigins`, otherwise `null`.
26
+ */
27
+ export function resolveAllowedOrigin(
28
+ requestOrigin: string | null,
29
+ allowedOrigins: string[],
30
+ ): string | null {
31
+ if (!requestOrigin || !allowedOrigins.includes(requestOrigin)) return null
32
+ return requestOrigin
33
+ }
34
+
35
+ /**
36
+ * Clone `res` and add CORS headers. If `allowedOrigin` is non-null, it is reflected
37
+ * as `Access-Control-Allow-Origin`; otherwise that header is omitted (fail closed).
22
38
  */
23
- export function withCors(res: Response): Response {
39
+ export function withCors(res: Response, allowedOrigin: string | null): Response {
24
40
  const out = new Response(res.body, res)
25
- for (const [k, v] of Object.entries(CORS_HEADERS)) {
41
+ if (allowedOrigin) out.headers.set('access-control-allow-origin', allowedOrigin)
42
+ for (const [k, v] of Object.entries(CORS_STATIC_HEADERS)) {
26
43
  out.headers.set(k, v)
27
44
  }
28
45
  return out
@@ -30,9 +47,8 @@ export function withCors(res: Response): Response {
30
47
 
31
48
  /**
32
49
  * Return a minimal 204 preflight response for OPTIONS requests.
33
- * The browser requires this before sending non-simple cross-origin requests
34
- * (e.g. PUT/POST with Authorization or Content-Type).
50
+ * The Access-Control-Allow-Origin header is added by the outer withCors wrapper.
35
51
  */
36
52
  export function corsPreflightResponse(): Response {
37
- return new Response(null, { status: 204, headers: CORS_HEADERS })
53
+ return new Response(null, { status: 204, headers: CORS_STATIC_HEADERS })
38
54
  }
package/src/index.ts CHANGED
@@ -11,11 +11,11 @@ import type { Config, Env } from './config.js'
11
11
  import { loadConfig, isPublicPath } from './config.js'
12
12
  import type { NonceBackend } from './state/types.js'
13
13
  import { makeBackend } from './state/select.js'
14
- import { SuiRpc } from './chain.js'
14
+ import { SuiGrpc } from './chain.js'
15
15
  import { verifyAccessRequest, deniedReason } from './verify.js'
16
16
  import { forward } from './proxy.js'
17
17
  import { runQuotaGuard } from './quota.js'
18
- import { withCors, corsPreflightResponse } from './cors.js'
18
+ import { withCors, corsPreflightResponse, resolveAllowedOrigin } from './cors.js'
19
19
 
20
20
  export { NonceRateState } from './state/durable_object.js'
21
21
 
@@ -26,7 +26,7 @@ export { NonceRateState } from './state/durable_object.js'
26
26
  interface GatewayState {
27
27
  cfg: Config
28
28
  backend: NonceBackend
29
- chain: SuiRpc
29
+ chain: SuiGrpc
30
30
  }
31
31
 
32
32
  /** Lazily initialised once per isolate; `null` before the first request. */
@@ -56,7 +56,7 @@ async function getState(env: Env): Promise<GatewayState> {
56
56
  cached = {
57
57
  cfg,
58
58
  backend: makeBackend(effective, env),
59
- chain: new SuiRpc(cfg.suiRpcUrl, cfg.ownershipCacheTtlMs, cfg.suiRpcAuthHeader),
59
+ chain: new SuiGrpc(cfg.suiRpcUrl, cfg.ownershipCacheTtlMs, cfg.suiRpcAuthHeader),
60
60
  }
61
61
  return cached
62
62
  }
@@ -76,14 +76,16 @@ function json(status: number, body: unknown): Response {
76
76
  }
77
77
 
78
78
  /**
79
- * Build a JSON `{"error": reason}` response with the given status code.
79
+ * Build a JSON `{"error": reason}` response with the given status code, optionally with a stable
80
+ * machine-readable `code` (e.g. `"redeemed"` vs `"leased"`) so the client can react precisely.
80
81
  *
81
82
  * @param status - HTTP status code.
82
83
  * @param reason - Short, client-visible error description.
84
+ * @param code - Optional stable code for programmatic handling.
83
85
  * @returns A JSON error response.
84
86
  */
85
- function deny(status: number, reason: string): Response {
86
- return json(status, { error: reason })
87
+ function deny(status: number, reason: string, code?: string): Response {
88
+ return json(status, code ? { error: reason, code } : { error: reason })
87
89
  }
88
90
 
89
91
  /** Prefer `Authorization: Bearer <token>`; fall back to an explicit `X-Access-Proof` header. */
@@ -111,6 +113,45 @@ function regionOf(cfg: Config, request: Request): string {
111
113
  return continent ? continent.toLowerCase() : 'g'
112
114
  }
113
115
 
116
+ /**
117
+ * Forward an UNAUTHENTICATED public path (e.g. `/v1/tip-config`) to the upstream, hardened so it
118
+ * can't be used to hammer the single relay origin:
119
+ * 1. Per-client-IP rate limit (keyed on `CF-Connecting-IP`) — a higher ceiling than the gated
120
+ * per-address limit since these are cheap GETs, but bounded so a flood is rejected at the edge.
121
+ * 2. Edge cache of successful GET responses (tip-config is near-static) via the Cache API, so
122
+ * repeat/flood reads are served from Cloudflare without reaching the origin at all.
123
+ * Non-GET public requests are still rate-limited but not cached.
124
+ */
125
+ async function forwardPublic(
126
+ cfg: Config,
127
+ backend: NonceBackend,
128
+ request: Request,
129
+ region: string,
130
+ ctx: ExecutionContext,
131
+ ): Promise<Response> {
132
+ const ip = request.headers.get('CF-Connecting-IP') ?? 'unknown'
133
+ if (!(await backend.rateCheck(`ip:${ip}`, cfg.publicRateLimitPerMin, region))) {
134
+ return deny(429, 'rate limit exceeded')
135
+ }
136
+
137
+ if (request.method !== 'GET' || cfg.publicCacheTtlSecs <= 0) return forward(cfg, request)
138
+
139
+ // Cache key is the URL alone (public GET, no auth/cookies to vary on).
140
+ const cache = (caches as unknown as { default: Cache }).default
141
+ const cacheKey = new Request(new URL(request.url).toString(), { method: 'GET' })
142
+ const hit = await cache.match(cacheKey)
143
+ if (hit) return hit
144
+
145
+ const resp = await forward(cfg, request)
146
+ if (resp.ok) {
147
+ const cached = new Response(resp.body, resp)
148
+ cached.headers.set('Cache-Control', `public, max-age=${cfg.publicCacheTtlSecs}`)
149
+ ctx.waitUntil(cache.put(cacheKey, cached.clone()))
150
+ return cached
151
+ }
152
+ return resp
153
+ }
154
+
114
155
  /**
115
156
  * Main request dispatcher. Handles `/healthz`, `/v1/challenge`, configured public paths,
116
157
  * and gated paths (signature + ownership verification before proxying to the upstream).
@@ -119,7 +160,7 @@ function regionOf(cfg: Config, request: Request): string {
119
160
  * @param env - The Worker environment bindings.
120
161
  * @returns A `Response` to send to the client.
121
162
  */
122
- async function handle(request: Request, env: Env): Promise<Response> {
163
+ async function handle(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
123
164
  const url = new URL(request.url)
124
165
  const path = url.pathname
125
166
 
@@ -144,8 +185,11 @@ async function handle(request: Request, env: Env): Promise<Response> {
144
185
  return json(200, { nonce, expiresAt })
145
186
  }
146
187
 
147
- // Public passthrough (e.g. /v1/tip-config): forward without auth.
148
- if (isPublicPath(cfg, path)) return forward(cfg, request)
188
+ // Public passthrough (e.g. /v1/tip-config): forward without auth, but protect the single relay
189
+ // origin — these bypass the NFT gate. Per-client-IP rate limit + edge-cache of GET responses.
190
+ if (isPublicPath(cfg, path)) {
191
+ return forwardPublic(cfg, backend, request, regionOf(cfg, request), ctx)
192
+ }
149
193
 
150
194
  const token = extractProofToken(request)
151
195
  if (!token) return deny(401, 'missing access proof')
@@ -160,6 +204,34 @@ async function handle(request: Request, env: Env): Promise<Response> {
160
204
  return deny(429, 'rate limit exceeded')
161
205
  }
162
206
 
207
+ // Single-use: the permanent on-chain `consumeDigest` is the one-time redemption token. Lease it,
208
+ // proxy, then COMMIT on a successful upload or RELEASE on failure — so an interrupted upload
209
+ // leaves the consume redeemable (the use is never lost) while a duplicate can't double-spend it.
210
+ const redemptionKey = result.redemptionKey
211
+ if (redemptionKey !== undefined) {
212
+ const lease = await backend.tryLeaseRedemption(redemptionKey, cfg.redemptionLeaseTtlSecs)
213
+ if (lease === 'redeemed') {
214
+ return deny(409, 'this consume has already been redeemed for an upload', 'redeemed')
215
+ }
216
+ if (lease === 'leased') {
217
+ return deny(409, 'an upload for this consume is already in progress', 'leased')
218
+ }
219
+ let resp: Response
220
+ try {
221
+ resp = await forward(cfg, request)
222
+ } catch (e) {
223
+ // Network/exception before a definitive upstream result — release so the user can retry.
224
+ await backend.releaseRedemption(redemptionKey)
225
+ throw e
226
+ }
227
+ if (resp.ok) {
228
+ await backend.commitRedemption(redemptionKey, cfg.redemptionRetentionSecs)
229
+ } else {
230
+ await backend.releaseRedemption(redemptionKey)
231
+ }
232
+ return resp
233
+ }
234
+
163
235
  return forward(cfg, request)
164
236
  }
165
237
 
@@ -168,8 +240,13 @@ async function handle(request: Request, env: Env): Promise<Response> {
168
240
  * {@link handle}; the `scheduled` handler runs the optional quota guard on a cron trigger.
169
241
  */
170
242
  export default {
171
- fetch(request: Request, env: Env): Promise<Response> {
172
- return handle(request, env).then(withCors)
243
+ async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
244
+ const origin = request.headers.get('origin')
245
+ const res = await handle(request, env, ctx)
246
+ // State is cached after handle() completes; a second call is free.
247
+ const state = await getState(env).catch(() => null)
248
+ const allowedOrigin = resolveAllowedOrigin(origin, state?.cfg.allowedOrigins ?? [])
249
+ return withCors(res, allowedOrigin)
173
250
  },
174
251
  async scheduled(_controller: ScheduledController, env: Env): Promise<void> {
175
252
  if ((env.QUOTA_GUARD_ENABLED ?? 'false').toLowerCase() === 'true') {
@@ -11,9 +11,12 @@
11
11
  */
12
12
 
13
13
  import { DurableObject } from 'cloudflare:workers'
14
- import type { NonceBackend } from './types.js'
14
+ import type { LeaseResult, NonceBackend } from './types.js'
15
15
  import { randomHex24, shardOfNonce } from './types.js'
16
16
 
17
+ /** Fixed shard name for the redemption store (the `consumeDigest` carries no region tag). */
18
+ const REDEEM_SHARD = 'redeem'
19
+
17
20
  /** SQLite row shape for the `nonces` table. */
18
21
  interface NonceRow {
19
22
  expiry: number
@@ -27,6 +30,11 @@ interface RateRow {
27
30
  interface CountRow {
28
31
  c: number
29
32
  }
33
+ /** SQLite row shape for the `redemptions` table. */
34
+ interface RedemptionRow {
35
+ state: string
36
+ expiry: number
37
+ }
30
38
 
31
39
  /**
32
40
  * Durable Object that provides the SQLite-backed nonce store and per-address rate limiter.
@@ -49,6 +57,12 @@ export class NonceRateState extends DurableObject {
49
57
  this.sql.exec(
50
58
  'CREATE TABLE IF NOT EXISTS rate (addr TEXT PRIMARY KEY, start INTEGER NOT NULL, count INTEGER NOT NULL)',
51
59
  )
60
+ // Redemption store: `state` is 'leased' (an in-flight upload holds the consume) or 'committed'
61
+ // (the use was spent on a successful upload). `expiry` is the lease deadline / committed
62
+ // retention deadline (unix ms).
63
+ this.sql.exec(
64
+ 'CREATE TABLE IF NOT EXISTS redemptions (key TEXT PRIMARY KEY, state TEXT NOT NULL, expiry INTEGER NOT NULL)',
65
+ )
52
66
  }
53
67
 
54
68
  /** Store a fresh nonce with a hard entry cap (evict soonest-to-expire). Returns expiry ms. */
@@ -95,6 +109,54 @@ export class NonceRateState extends DurableObject {
95
109
  this.sql.exec('INSERT OR REPLACE INTO rate (addr, start, count) VALUES (?, ?, ?)', addr, start, count + 1)
96
110
  return true
97
111
  }
112
+
113
+ /**
114
+ * Atomically claim `key` for an in-flight upload (single-threaded DO ⇒ no race). Returns
115
+ * `'redeemed'` if already committed, `'leased'` if an unexpired lease is held, else `'ok'`
116
+ * after taking a fresh lease. An expired lease (crashed request) is reclaimable as `'ok'`.
117
+ */
118
+ tryLeaseRedemption(key: string, leaseTtlSecs: number, maxEntries: number): LeaseResult {
119
+ const now = Date.now()
120
+ // Bounded growth: drop expired leases and lapsed committed rows before inserting.
121
+ this.sql.exec("DELETE FROM redemptions WHERE state = 'leased' AND expiry <= ?", now)
122
+ this.sql.exec("DELETE FROM redemptions WHERE state = 'committed' AND expiry <= ?", now)
123
+ const rows = this.sql
124
+ .exec('SELECT state, expiry FROM redemptions WHERE key = ?', key)
125
+ .toArray() as unknown as RedemptionRow[]
126
+ if (rows.length > 0) {
127
+ const r = rows[0]
128
+ if (r.state === 'committed') return 'redeemed'
129
+ if (r.state === 'leased' && Number(r.expiry) > now) return 'leased'
130
+ // else: an expired lease — fall through and re-lease.
131
+ }
132
+ const count = (this.sql.exec('SELECT COUNT(*) AS c FROM redemptions').one() as unknown as CountRow).c
133
+ if (count >= Math.max(1, maxEntries)) {
134
+ // Evict the soonest-to-expire leased row (never a committed one — that would allow reuse).
135
+ this.sql.exec(
136
+ "DELETE FROM redemptions WHERE key = (SELECT key FROM redemptions WHERE state = 'leased' ORDER BY expiry ASC LIMIT 1)",
137
+ )
138
+ }
139
+ this.sql.exec(
140
+ "INSERT OR REPLACE INTO redemptions (key, state, expiry) VALUES (?, 'leased', ?)",
141
+ key,
142
+ now + leaseTtlSecs * 1000,
143
+ )
144
+ return 'ok'
145
+ }
146
+
147
+ /** Permanently mark `key` redeemed (retained `retentionSecs`) — the use is spent. */
148
+ commitRedemption(key: string, retentionSecs: number): void {
149
+ this.sql.exec(
150
+ "INSERT OR REPLACE INTO redemptions (key, state, expiry) VALUES (?, 'committed', ?)",
151
+ key,
152
+ Date.now() + retentionSecs * 1000,
153
+ )
154
+ }
155
+
156
+ /** Release a lease on `key` (upload failed) so the consume can be retried immediately. */
157
+ releaseRedemption(key: string): void {
158
+ this.sql.exec("DELETE FROM redemptions WHERE key = ? AND state = 'leased'", key)
159
+ }
98
160
  }
99
161
 
100
162
  /** {@link NonceBackend} that fans out to per-region {@link NonceRateState} DO shards. */
@@ -136,4 +198,18 @@ export class DurableObjectBackend implements NonceBackend {
136
198
  const shard = this.shardMode === 'global' ? 'g' : region
137
199
  return this.stub(shard).rateCheck(address, maxPerMin)
138
200
  }
201
+
202
+ // Redemption state has no region tag, so all redemption ops route to one fixed shard — keeping
203
+ // every operation on a given `consumeDigest` on the same single-threaded instance (atomic).
204
+ async tryLeaseRedemption(key: string, leaseTtlSecs: number): Promise<LeaseResult> {
205
+ return this.stub(REDEEM_SHARD).tryLeaseRedemption(key, leaseTtlSecs, this.maxEntries)
206
+ }
207
+
208
+ async commitRedemption(key: string, retentionSecs: number): Promise<void> {
209
+ await this.stub(REDEEM_SHARD).commitRedemption(key, retentionSecs)
210
+ }
211
+
212
+ async releaseRedemption(key: string): Promise<void> {
213
+ await this.stub(REDEEM_SHARD).releaseRedemption(key)
214
+ }
139
215
  }
package/src/state/kv.ts CHANGED
@@ -7,13 +7,15 @@
7
7
  * use bind, so that mode is unaffected.) Use the Durable Object backend when this matters.
8
8
  */
9
9
 
10
- import type { NonceBackend } from './types.js'
10
+ import type { LeaseResult, NonceBackend } from './types.js'
11
11
  import { randomHex24 } from './types.js'
12
12
 
13
13
  /** KV key prefix for nonce entries. */
14
14
  const NONCE_PREFIX = 'nonce:'
15
15
  /** KV key prefix for per-address rate-limit windows. */
16
16
  const RATE_PREFIX = 'rate:'
17
+ /** KV key prefix for redemption entries. */
18
+ const REDEEM_PREFIX = 'redeem:'
17
19
  /** Workers KV minimum `expirationTtl` (seconds). Sub-60s logical TTLs are enforced in-value. */
18
20
  const KV_MIN_TTL_SECS = 60
19
21
 
@@ -78,4 +80,45 @@ export class KvBackend implements NonceBackend {
78
80
  })
79
81
  return true
80
82
  }
83
+
84
+ // Best-effort redemption on KV (eventually consistent — the get→put lease is not atomic, so a
85
+ // narrow concurrent-duplicate window exists; the Durable Object backend is strongly consistent
86
+ // and preferred when this matters). Committed always wins over a lease on read.
87
+ async tryLeaseRedemption(key: string, leaseTtlSecs: number): Promise<LeaseResult> {
88
+ const k = REDEEM_PREFIX + key
89
+ const raw = await this.kv.get(k)
90
+ if (raw) {
91
+ try {
92
+ const r = JSON.parse(raw) as { s: string; e: number }
93
+ if (r.s === 'committed') return 'redeemed'
94
+ if (r.s === 'leased' && r.e > Date.now()) return 'leased'
95
+ } catch {
96
+ /* fall through and re-lease */
97
+ }
98
+ }
99
+ await this.kv.put(k, JSON.stringify({ s: 'leased', e: Date.now() + leaseTtlSecs * 1000 }), {
100
+ expirationTtl: Math.max(KV_MIN_TTL_SECS, leaseTtlSecs),
101
+ })
102
+ return 'ok'
103
+ }
104
+
105
+ async commitRedemption(key: string, retentionSecs: number): Promise<void> {
106
+ await this.kv.put(
107
+ REDEEM_PREFIX + key,
108
+ JSON.stringify({ s: 'committed', e: Date.now() + retentionSecs * 1000 }),
109
+ { expirationTtl: Math.max(KV_MIN_TTL_SECS, retentionSecs) },
110
+ )
111
+ }
112
+
113
+ async releaseRedemption(key: string): Promise<void> {
114
+ // Only clear a lease — never a committed marker (that would allow the use to be re-redeemed).
115
+ const raw = await this.kv.get(REDEEM_PREFIX + key)
116
+ if (!raw) return
117
+ try {
118
+ if ((JSON.parse(raw) as { s: string }).s === 'committed') return
119
+ } catch {
120
+ /* malformed — safe to delete */
121
+ }
122
+ await this.kv.delete(REDEEM_PREFIX + key)
123
+ }
81
124
  }
@@ -10,6 +10,13 @@
10
10
  * cross-region replay window in the `SINGLE_USE=false` ownership mode).
11
11
  */
12
12
 
13
+ /**
14
+ * Outcome of a redemption-lease attempt (single-use mode). `ok` — the caller now holds the lease
15
+ * and must `commit`/`release` it. `leased` — another in-flight request holds it (concurrent
16
+ * duplicate). `redeemed` — it was already committed (the use is spent).
17
+ */
18
+ export type LeaseResult = 'ok' | 'leased' | 'redeemed'
19
+
13
20
  export interface NonceBackend {
14
21
  /**
15
22
  * Issue a fresh, time-bound nonce. `region` selects the DO shard (ignored by KV, which is
@@ -20,6 +27,22 @@ export interface NonceBackend {
20
27
  takeIfValid(nonce: string): Promise<boolean>
21
28
  /** Fixed 60s window per verified address. `maxPerMin === 0` disables limiting. */
22
29
  rateCheck(address: string, maxPerMin: number, region: string): Promise<boolean>
30
+
31
+ // ── Single-use redemption (the permanent on-chain `consumeDigest` is the one-time token) ──
32
+ // A use is only spent when an upload actually succeeds: lease the digest, proxy, then commit on
33
+ // success or release on failure. An interrupted attempt leaves the digest redeemable, so a
34
+ // consumed NFT use is never lost. All three are atomic per key on the Durable Object backend.
35
+
36
+ /**
37
+ * Atomically claim `key` for an in-flight upload. `ok` on a fresh/expired-lease/released key,
38
+ * `leased` if another request holds an unexpired lease, `redeemed` if already committed. The
39
+ * lease self-expires after `leaseTtlSecs` so a crashed request cannot strand the key.
40
+ */
41
+ tryLeaseRedemption(key: string, leaseTtlSecs: number): Promise<LeaseResult>
42
+ /** Permanently mark `key` redeemed (retained `retentionSecs`), after a successful upload. */
43
+ commitRedemption(key: string, retentionSecs: number): Promise<void>
44
+ /** Release a lease on `key` (upload failed) so the same consume can be retried immediately. */
45
+ releaseRedemption(key: string): Promise<void>
23
46
  }
24
47
 
25
48
  /**
@@ -36,7 +59,9 @@ export function shardOfNonce(nonce: string): string {
36
59
 
37
60
  /**
38
61
  * Generate a cryptographically random 48-character hex string (24 bytes of entropy). Matches
39
- * the Rust gateway's `random_nonce()` entropy so conformance vectors apply to both.
62
+ * the Rust gateway's `random_nonce()` entropy so conformance vectors apply to both. Nonces are
63
+ * **ASCII by contract** — this hex plus an ASCII `<region>.` prefix — and the client's
64
+ * `decodeAccessProof` enforces ASCII, so the proof-token base64 never carries non-ASCII bytes.
40
65
  *
41
66
  * @returns A 48-character lowercase hex string.
42
67
  */
package/src/verify.ts CHANGED
@@ -117,16 +117,15 @@ function verifyEcdsa(
117
117
  return normalizeAddress(address) === deriveAddress(flag, pk)
118
118
  }
119
119
 
120
- /** On-chain lookups needed to authorise a request. Implemented by {@link ../chain.SuiRpc}. */
120
+ /** On-chain lookups needed to authorise a request. Implemented by {@link ../chain.SuiGrpc}. */
121
121
  export interface ChainQuery {
122
122
  ownsNft(address: string, nftType: string, gateId?: string): Promise<boolean>
123
- consumeEventMatches(
124
- nonce: string,
125
- address: string,
126
- nftType: string,
127
- gateId?: string,
128
- consumeDigest?: string,
129
- ): Promise<boolean>
123
+ /**
124
+ * True if `consumeDigest` names a successful `access_gate::consume` transaction that emitted an
125
+ * `AccessConsumedEvent` for `address` (the sender) on `gateId`. Not bound to the challenge nonce
126
+ * — single-use is enforced by the redemption store keying on the digest.
127
+ */
128
+ consumeTxValid(consumeDigest: string, address: string, gateId?: string): Promise<boolean>
130
129
  }
131
130
 
132
131
  /** The reason a request was rejected by {@link verifyAccessRequest}. Mirror of Rust `Denied`. */
@@ -136,6 +135,7 @@ export type Denied =
136
135
  | 'NonceInvalid'
137
136
  | 'NotOwner'
138
137
  | 'ConsumeMissing'
138
+ | 'RedeemConflict'
139
139
  | 'ChainError'
140
140
 
141
141
  /** A short, client-visible description of why access was denied. */
@@ -150,14 +150,22 @@ export function deniedReason(d: Denied): string {
150
150
  case 'NotOwner':
151
151
  return 'address does not hold the required access NFT'
152
152
  case 'ConsumeMissing':
153
- return 'no matching single-use consume for this challenge'
153
+ return 'no matching single-use consume for this address'
154
+ case 'RedeemConflict':
155
+ return 'this consume is already redeemed or an upload for it is in progress'
154
156
  case 'ChainError':
155
157
  return 'on-chain verification failed'
156
158
  }
157
159
  }
158
160
 
159
- /** Result of {@link verifyAccessRequest}: the verified address on success, or a {@link Denied} reason. */
160
- export type VerifyResult = { ok: true; address: string } | { ok: false; denied: Denied }
161
+ /**
162
+ * Result of {@link verifyAccessRequest}: the verified address on success, or a {@link Denied}
163
+ * reason. In single-use mode a successful result also carries `redemptionKey` (the `consumeDigest`)
164
+ * that the dispatcher leases/commits so the use is only spent on a successful upload.
165
+ */
166
+ export type VerifyResult =
167
+ | { ok: true; address: string; redemptionKey?: string }
168
+ | { ok: false; denied: Denied }
161
169
 
162
170
  /**
163
171
  * Authorise a request from its base64 proof token. The nonce is consumed (single-use at the
@@ -182,6 +190,14 @@ export async function verifyAccessRequest(
182
190
  return { ok: false, denied: 'BadSignature' }
183
191
  }
184
192
 
193
+ // Canonicalise the address once the signature is proven, then use ONLY the normalised form for
194
+ // on-chain comparisons and the returned value. The client emits the raw caller address; on-chain
195
+ // owners are canonical (lower-case, 0x-prefixed, 64-hex), so comparing the raw string would
196
+ // fail-closed for a non-canonical input (e.g. missing leading zeros). Owning canonicalisation
197
+ // here — the security boundary — keeps the client wire format unchanged. (See conformance
198
+ // vectors `addressNormalization`; matched by the Rust gateway.)
199
+ const address = normalizeAddress(proof.address)
200
+
185
201
  // Consume the nonce exactly once (fresh, unexpired, unused) — before the chain call.
186
202
  if (!(await store.takeIfValid(proof.nonce))) {
187
203
  return { ok: false, denied: 'NonceInvalid' }
@@ -193,28 +209,25 @@ export async function verifyAccessRequest(
193
209
  }
194
210
  let ok: boolean
195
211
  try {
196
- ok = await chain.consumeEventMatches(
197
- proof.nonce,
198
- proof.address,
199
- cfg.nftType,
200
- cfg.gateId,
201
- proof.consumeDigest,
202
- )
212
+ ok = await chain.consumeTxValid(proof.consumeDigest, address, cfg.gateId)
203
213
  } catch {
204
214
  return { ok: false, denied: 'ChainError' }
205
215
  }
206
216
  if (!ok) return { ok: false, denied: 'ConsumeMissing' }
207
- } else {
208
- let ok: boolean
209
- try {
210
- ok = await chain.ownsNft(proof.address, cfg.nftType, cfg.gateId)
211
- } catch {
212
- return { ok: false, denied: 'ChainError' }
213
- }
214
- if (!ok) return { ok: false, denied: 'NotOwner' }
217
+ // The consume is valid on-chain; the dispatcher leases/commits this digest so the use is
218
+ // spent only on a successful upload (and a duplicate can't double-spend it).
219
+ return { ok: true, address, redemptionKey: proof.consumeDigest }
220
+ }
221
+
222
+ let ok: boolean
223
+ try {
224
+ ok = await chain.ownsNft(address, cfg.nftType, cfg.gateId)
225
+ } catch {
226
+ return { ok: false, denied: 'ChainError' }
215
227
  }
228
+ if (!ok) return { ok: false, denied: 'NotOwner' }
216
229
 
217
- return { ok: true, address: proof.address }
230
+ return { ok: true, address }
218
231
  }
219
232
 
220
233
  export { FLAG_ED25519, FLAG_SECP256K1, FLAG_SECP256R1, FLAG_MULTISIG, FLAG_ZKLOGIN }
package/wrangler.toml CHANGED
@@ -49,14 +49,28 @@ GATE_ID = "0x0485c1fa80e4c355c85ab99c0281a328d8fb5c60ac50ab64f10be0f8be792aba"
49
49
  SINGLE_USE = "true"
50
50
  PUBLIC_PATHS = "/v1/tip-config"
51
51
  RATE_LIMIT_PER_MIN = "30"
52
+ # Unauthenticated public paths (/v1/tip-config) are protected separately: a higher per-client-IP
53
+ # request ceiling (cheap GETs) plus an edge cache so floods are absorbed before reaching the relay.
54
+ PUBLIC_RATE_LIMIT_PER_MIN = "120"
55
+ PUBLIC_CACHE_TTL_SECS = "60"
52
56
  MAX_BODY_BYTES = "104857600"
53
57
  CHALLENGE_TTL_SECS = "300"
54
58
  OWNERSHIP_CACHE_TTL_MS = "0"
59
+ # Single-use redemption: the permanent on-chain consumeDigest is the one-time token. A use is only
60
+ # spent when an upload actually succeeds (commit); an interrupted upload releases the lease so the
61
+ # consume stays redeemable (the use is never lost). LEASE_TTL bounds an in-flight upload; RETENTION
62
+ # is how long a spent consume is remembered to block re-redemption (30d; a consume older than this
63
+ # could be re-redeemed — an obscure, low-value edge).
64
+ REDEMPTION_LEASE_TTL_SECS = "120"
65
+ REDEMPTION_RETENTION_SECS = "2592000"
55
66
  # Workers-specific:
56
67
  NONCE_BACKEND = "durable-object" # "durable-object" (default) | "kv"
57
68
  NONCE_SHARD = "region" # "region" (default) | "global"
58
69
  NONCE_MAX_ENTRIES = "1000000"
59
70
  QUOTA_GUARD_ENABLED = "false"
71
+ # Comma-separated list of browser origins allowed to make cross-origin requests.
72
+ # Only these origins receive Access-Control-Allow-Origin in responses.
73
+ ALLOWED_ORIGINS = "https://sui-walrus.meddleware.co.uk,https://sui.meddleware.co.uk"
60
74
 
61
75
  # ── nonce store + rate limiter: region-sharded, SQLite-backed Durable Objects (free-tier) ────
62
76
  [[durable_objects.bindings]]