@meddleware/nft-gate-gateway 0.0.6 → 0.0.8
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 +2 -1
- package/src/chain.ts +109 -105
- package/src/config.ts +10 -0
- package/src/index.ts +36 -6
- package/src/state/durable_object.ts +77 -1
- package/src/state/kv.ts +44 -1
- package/src/state/types.ts +23 -0
- package/src/verify.ts +31 -26
- package/wrangler.toml +7 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meddleware/nft-gate-gateway",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.8",
|
|
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",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
24
|
"@meddleware/nft-gate-client": "^0.0.6",
|
|
25
|
+
"@mysten/sui": "~2.30.0",
|
|
25
26
|
"@noble/curves": "~2.4.0",
|
|
26
27
|
"@noble/hashes": "~2.4.0"
|
|
27
28
|
},
|
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
|
@@ -22,6 +22,10 @@ export interface Env {
|
|
|
22
22
|
MAX_BODY_BYTES?: string
|
|
23
23
|
CHALLENGE_TTL_SECS?: string
|
|
24
24
|
OWNERSHIP_CACHE_TTL_MS?: string
|
|
25
|
+
/** Single-use: seconds a consume-digest redemption lease is held during an in-flight upload. */
|
|
26
|
+
REDEMPTION_LEASE_TTL_SECS?: string
|
|
27
|
+
/** Single-use: seconds a committed (spent) consume-digest is remembered to block re-redemption. */
|
|
28
|
+
REDEMPTION_RETENTION_SECS?: string
|
|
25
29
|
// ── Workers-specific ──────────────────────────────────────────────────────
|
|
26
30
|
/** `durable-object` (default) | `kv`. */
|
|
27
31
|
NONCE_BACKEND?: string
|
|
@@ -71,6 +75,10 @@ export interface Config {
|
|
|
71
75
|
rateLimitPerMin: number
|
|
72
76
|
maxBodyBytes: number
|
|
73
77
|
ownershipCacheTtlMs: number
|
|
78
|
+
/** Single-use: lease TTL (s) for an in-flight consume-digest redemption. */
|
|
79
|
+
redemptionLeaseTtlSecs: number
|
|
80
|
+
/** Single-use: retention (s) of a committed (spent) consume-digest. */
|
|
81
|
+
redemptionRetentionSecs: number
|
|
74
82
|
nonceBackend: NonceBackendKind
|
|
75
83
|
nonceShard: NonceShardMode
|
|
76
84
|
nonceMaxEntries: number
|
|
@@ -135,6 +143,8 @@ export function loadConfig(env: Env): Config {
|
|
|
135
143
|
rateLimitPerMin: numOr(env.RATE_LIMIT_PER_MIN, 30),
|
|
136
144
|
maxBodyBytes: numOr(env.MAX_BODY_BYTES, 262144),
|
|
137
145
|
ownershipCacheTtlMs: numOr(env.OWNERSHIP_CACHE_TTL_MS, 0),
|
|
146
|
+
redemptionLeaseTtlSecs: numOr(env.REDEMPTION_LEASE_TTL_SECS, 120),
|
|
147
|
+
redemptionRetentionSecs: numOr(env.REDEMPTION_RETENTION_SECS, 2592000),
|
|
138
148
|
nonceBackend,
|
|
139
149
|
nonceShard,
|
|
140
150
|
nonceMaxEntries: numOr(env.NONCE_MAX_ENTRIES, 1000000),
|
package/src/index.ts
CHANGED
|
@@ -11,7 +11,7 @@ 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'
|
|
@@ -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. */
|
|
@@ -160,6 +162,34 @@ async function handle(request: Request, env: Env): Promise<Response> {
|
|
|
160
162
|
return deny(429, 'rate limit exceeded')
|
|
161
163
|
}
|
|
162
164
|
|
|
165
|
+
// Single-use: the permanent on-chain `consumeDigest` is the one-time redemption token. Lease it,
|
|
166
|
+
// proxy, then COMMIT on a successful upload or RELEASE on failure — so an interrupted upload
|
|
167
|
+
// leaves the consume redeemable (the use is never lost) while a duplicate can't double-spend it.
|
|
168
|
+
const redemptionKey = result.redemptionKey
|
|
169
|
+
if (redemptionKey !== undefined) {
|
|
170
|
+
const lease = await backend.tryLeaseRedemption(redemptionKey, cfg.redemptionLeaseTtlSecs)
|
|
171
|
+
if (lease === 'redeemed') {
|
|
172
|
+
return deny(409, 'this consume has already been redeemed for an upload', 'redeemed')
|
|
173
|
+
}
|
|
174
|
+
if (lease === 'leased') {
|
|
175
|
+
return deny(409, 'an upload for this consume is already in progress', 'leased')
|
|
176
|
+
}
|
|
177
|
+
let resp: Response
|
|
178
|
+
try {
|
|
179
|
+
resp = await forward(cfg, request)
|
|
180
|
+
} catch (e) {
|
|
181
|
+
// Network/exception before a definitive upstream result — release so the user can retry.
|
|
182
|
+
await backend.releaseRedemption(redemptionKey)
|
|
183
|
+
throw e
|
|
184
|
+
}
|
|
185
|
+
if (resp.ok) {
|
|
186
|
+
await backend.commitRedemption(redemptionKey, cfg.redemptionRetentionSecs)
|
|
187
|
+
} else {
|
|
188
|
+
await backend.releaseRedemption(redemptionKey)
|
|
189
|
+
}
|
|
190
|
+
return resp
|
|
191
|
+
}
|
|
192
|
+
|
|
163
193
|
return forward(cfg, request)
|
|
164
194
|
}
|
|
165
195
|
|
|
@@ -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
|
/**
|
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
|
|
@@ -193,26 +201,23 @@ export async function verifyAccessRequest(
|
|
|
193
201
|
}
|
|
194
202
|
let ok: boolean
|
|
195
203
|
try {
|
|
196
|
-
ok = await chain.
|
|
197
|
-
proof.nonce,
|
|
198
|
-
proof.address,
|
|
199
|
-
cfg.nftType,
|
|
200
|
-
cfg.gateId,
|
|
201
|
-
proof.consumeDigest,
|
|
202
|
-
)
|
|
204
|
+
ok = await chain.consumeTxValid(proof.consumeDigest, proof.address, cfg.gateId)
|
|
203
205
|
} catch {
|
|
204
206
|
return { ok: false, denied: 'ChainError' }
|
|
205
207
|
}
|
|
206
208
|
if (!ok) return { ok: false, denied: 'ConsumeMissing' }
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
209
|
+
// The consume is valid on-chain; the dispatcher leases/commits this digest so the use is
|
|
210
|
+
// spent only on a successful upload (and a duplicate can't double-spend it).
|
|
211
|
+
return { ok: true, address: proof.address, redemptionKey: proof.consumeDigest }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
let ok: boolean
|
|
215
|
+
try {
|
|
216
|
+
ok = await chain.ownsNft(proof.address, cfg.nftType, cfg.gateId)
|
|
217
|
+
} catch {
|
|
218
|
+
return { ok: false, denied: 'ChainError' }
|
|
215
219
|
}
|
|
220
|
+
if (!ok) return { ok: false, denied: 'NotOwner' }
|
|
216
221
|
|
|
217
222
|
return { ok: true, address: proof.address }
|
|
218
223
|
}
|
package/wrangler.toml
CHANGED
|
@@ -52,6 +52,13 @@ RATE_LIMIT_PER_MIN = "30"
|
|
|
52
52
|
MAX_BODY_BYTES = "104857600"
|
|
53
53
|
CHALLENGE_TTL_SECS = "300"
|
|
54
54
|
OWNERSHIP_CACHE_TTL_MS = "0"
|
|
55
|
+
# Single-use redemption: the permanent on-chain consumeDigest is the one-time token. A use is only
|
|
56
|
+
# spent when an upload actually succeeds (commit); an interrupted upload releases the lease so the
|
|
57
|
+
# consume stays redeemable (the use is never lost). LEASE_TTL bounds an in-flight upload; RETENTION
|
|
58
|
+
# is how long a spent consume is remembered to block re-redemption (30d; a consume older than this
|
|
59
|
+
# could be re-redeemed — an obscure, low-value edge).
|
|
60
|
+
REDEMPTION_LEASE_TTL_SECS = "120"
|
|
61
|
+
REDEMPTION_RETENTION_SECS = "2592000"
|
|
55
62
|
# Workers-specific:
|
|
56
63
|
NONCE_BACKEND = "durable-object" # "durable-object" (default) | "kv"
|
|
57
64
|
NONCE_SHARD = "region" # "region" (default) | "global"
|