@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 +14 -8
- package/src/chain.ts +109 -105
- package/src/config.ts +41 -0
- package/src/cors.ts +25 -9
- package/src/index.ts +89 -12
- package/src/state/durable_object.ts +77 -1
- package/src/state/kv.ts +44 -1
- package/src/state/types.ts +26 -1
- package/src/verify.ts +40 -27
- package/wrangler.toml +14 -0
package/package.json
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
|
-
|
|
1
|
+
{
|
|
2
2
|
"name": "@meddleware/nft-gate-gateway",
|
|
3
|
-
"version": "0.0.
|
|
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": [
|
|
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.
|
|
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.
|
|
31
|
-
"typescript": "
|
|
32
|
-
"vitest": "~4.1.
|
|
33
|
-
"wrangler": "
|
|
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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
/**
|
|
24
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
37
|
-
) {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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 `
|
|
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
|
|
96
|
-
|
|
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 =
|
|
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
|
-
*
|
|
132
|
-
*
|
|
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
|
|
136
|
-
* @
|
|
137
|
-
* @
|
|
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
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
160
|
-
*
|
|
161
|
-
*
|
|
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
|
|
164
|
-
* @param address - The
|
|
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
|
-
* @
|
|
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
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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;
|
|
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 =
|
|
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
|
|
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
|
-
/**
|
|
255
|
-
|
|
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
|
|
258
|
-
const
|
|
259
|
-
|
|
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
|
|
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
|
-
*
|
|
21
|
-
*
|
|
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
|
-
|
|
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
|
|
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:
|
|
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 {
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/state/types.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
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
|
-
/**
|
|
160
|
-
|
|
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.
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
|
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]]
|