@meddleware/nft-gate-gateway 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,14 @@
1
+ BSD Zero Clause License
2
+
3
+ Copyright (c) 2026 MeddleWare
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted.
7
+
8
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
9
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
10
+ AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
11
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
12
+ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
13
+ OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
14
+ PERFORMANCE OF THIS SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,205 @@
1
+ # @meddleware/nft-gate-gateway-workers
2
+
3
+ [![npm](https://img.shields.io/npm/v/%40meddleware%2Fnft-gate-gateway-workers)](https://www.npmjs.com/package/@meddleware/nft-gate-gateway-workers)
4
+ [![License: 0BSD](https://img.shields.io/badge/license-0BSD-blue)](LICENSE)
5
+
6
+ Cloudflare Workers implementation of the [nft-gate](../README.md) NFT-gated reverse proxy.
7
+ Wire-identical to the [Rust gateway](../gateway-rust/): same routes, status codes, proof format,
8
+ Sui RPC calls, and env-var names. Deploy it at the same URL the Rust gateway serves and nothing
9
+ else changes.
10
+
11
+ ## Prerequisites
12
+
13
+ | Tool | Minimum version | Install |
14
+ | --- | --- | --- |
15
+ | Node.js | 22.x | [nodejs.org](https://nodejs.org) |
16
+ | npm | 10.x | bundled with Node |
17
+ | Wrangler | 4.x | `npm install -g wrangler` |
18
+ | Cloudflare account | — | [dash.cloudflare.com](https://dash.cloudflare.com) |
19
+
20
+ ## Getting started
21
+
22
+ ### 1. Get the source
23
+
24
+ Clone or fork the repo, then work from the `gateway-workers/` directory:
25
+
26
+ ```bash
27
+ git clone https://github.com/meddleware-org/nft-gate.git
28
+ cd nft-gate/gateway-workers
29
+ npm install
30
+ ```
31
+
32
+ Alternatively, the package is published to npm for reference:
33
+
34
+ ```bash
35
+ npm install @meddleware/nft-gate-gateway-workers
36
+ ```
37
+
38
+ ### 2. Configure `wrangler.toml`
39
+
40
+ Edit the `[[routes]]` stanza to bind the Worker to your hostname:
41
+
42
+ ```toml
43
+ [[routes]]
44
+ pattern = "gate.example.com/*"
45
+ zone_name = "example.com"
46
+ ```
47
+
48
+ The default `[vars]` block already contains safe defaults for all optional settings. Review
49
+ `SUI_RPC_URL` and change it to your preferred Sui fullnode:
50
+
51
+ | Network | Public fullnode URL |
52
+ | --- | --- |
53
+ | Mainnet | `https://fullnode.mainnet.sui.io:443` |
54
+ | Testnet | `https://fullnode.testnet.sui.io:443` |
55
+
56
+ ### 3. Create the Worker (first deploy)
57
+
58
+ ```bash
59
+ npm run deploy
60
+ ```
61
+
62
+ Wrangler creates the Worker and the Durable Object namespace in your account. The Worker will
63
+ respond to requests immediately but will return errors until the required secrets are set.
64
+
65
+ ### 4. Set required secrets
66
+
67
+ Run these once from the `gateway-workers/` directory after the Worker exists:
68
+
69
+ ```bash
70
+ # The upstream origin the Worker proxies to.
71
+ wrangler secret put UPSTREAM_URL
72
+ # Value: https://your-relay.example.com
73
+
74
+ # The on-chain NFT type to check ownership against.
75
+ wrangler secret put NFT_TYPE
76
+ # Value: 0x<PACKAGE_ID>::access_gate::SoulboundAccessNFT
77
+
78
+ # CF Access service-token headers to authenticate to your origin (if Access-locked).
79
+ # Omit if your upstream is publicly reachable or uses another auth mechanism.
80
+ wrangler secret put UPSTREAM_AUTH_HEADERS
81
+ # Value: CF-Access-Client-Id: <id>, CF-Access-Client-Secret: <secret>
82
+ ```
83
+
84
+ Secrets are encrypted at rest and never appear in `wrangler.toml` or workflow logs. They survive
85
+ `wrangler deploy` — setting them once is sufficient.
86
+
87
+ ### 5. Verify
88
+
89
+ ```bash
90
+ curl https://gate.example.com/v1/challenge
91
+ # → {"nonce":"...","expiresAt":...}
92
+ ```
93
+
94
+ Any gated route without a valid proof token returns `401 Unauthorized`.
95
+
96
+ ## Upstream reachability
97
+
98
+ The Rust gateway usually runs **inside** your network and reaches a private upstream directly.
99
+ This Worker runs at Cloudflare's **edge**, so `UPSTREAM_URL` must be **publicly routable**.
100
+
101
+ **Recommended pattern**: expose your upstream via a dedicated `cloudflared` public hostname
102
+ locked to this Worker via Cloudflare Access, then point `UPSTREAM_URL` at it.
103
+
104
+ ### Locking the origin with a Cloudflare Access service token
105
+
106
+ 1. Cloudflare Zero Trust → Access → Service Tokens → **Create Service Token**.
107
+ 2. Create an Access **application** for your relay hostname (e.g. `relay.example.com`).
108
+ 3. Add a policy: allow requests where **Service Token** is the token you just created.
109
+ 4. Set the Worker secret — both token headers, comma-separated:
110
+
111
+ ```bash
112
+ wrangler secret put UPSTREAM_AUTH_HEADERS
113
+ # CF-Access-Client-Id: <id>, CF-Access-Client-Secret: <secret>
114
+ ```
115
+
116
+ 5. The Worker injects these headers on every upstream `fetch`. Direct browser or bot traffic to
117
+ the origin gets an Access login page or `403`, depending on the policy fallback.
118
+
119
+ ## State backend
120
+
121
+ Single-use nonces and per-address rate limits need atomicity that a stateless isolate cannot
122
+ provide. Two backends are available:
123
+
124
+ | Backend | Binding | Consistency | Plan |
125
+ | --- | --- | --- | --- |
126
+ | `durable-object` (default) | `NONCE_STATE` (auto-created) | Strong — atomic single-use consume | Free tier |
127
+ | `kv` | `NONCE_KV` (create manually) | Eventual — weak cross-region replay window in `SINGLE_USE=false` | Free tier |
128
+
129
+ To use the KV backend, create a namespace and uncomment the binding in `wrangler.toml`:
130
+
131
+ ```bash
132
+ wrangler kv namespace create NONCE_KV
133
+ # Copy the returned ID into wrangler.toml:
134
+ # [[kv_namespaces]]
135
+ # binding = "NONCE_KV"
136
+ # id = "<returned-id>"
137
+ ```
138
+
139
+ Then set `NONCE_BACKEND = "kv"` in `[vars]`.
140
+
141
+ ## Full configuration reference
142
+
143
+ | Var | Required | Default | Notes |
144
+ | --- | --- | --- | --- |
145
+ | `UPSTREAM_URL` | ✓ | — | Base URL of the protected upstream (set via `wrangler secret put`) |
146
+ | `SUI_RPC_URL` | ✓ | `https://fullnode.testnet.sui.io:443` | Sui JSON-RPC endpoint; change to mainnet for production |
147
+ | `NFT_TYPE` | ✓ | — | `<pkg>::access_gate::AccessNFT` or `SoulboundAccessNFT` (set via `wrangler secret put`) |
148
+ | `GATE_ID` | | — | Restrict ownership checks to a specific gate registry object |
149
+ | `SINGLE_USE` | | `false` | Require an on-chain `AccessConsumedEvent` bound to the nonce |
150
+ | `PUBLIC_PATHS` | | `/v1/tip-config` | Comma-separated paths served without authentication |
151
+ | `RATE_LIMIT_PER_MIN` | | `30` | Requests per verified address per 60s window (`0` disables) |
152
+ | `MAX_BODY_BYTES` | | `262144` | Request body cap before proxying (256 KiB) |
153
+ | `CHALLENGE_TTL_SECS` | | `300` | Nonce lifetime in seconds |
154
+ | `OWNERSHIP_CACHE_TTL_MS` | | `0` | Ownership-check cache TTL (`0` = live check on every request) |
155
+ | `NONCE_BACKEND` | | `durable-object` | `durable-object` or `kv` |
156
+ | `NONCE_SHARD` | | `region` | DO shard granularity: `region` (near users) or `global` (one instance) |
157
+ | `NONCE_MAX_ENTRIES` | | `1000000` | Hard nonce entry cap per DO shard (evict oldest when reached) |
158
+ | `UPSTREAM_AUTH_HEADERS` | | — | Comma-separated `Name: value` pairs injected on every upstream request (secret) |
159
+ | `SUI_RPC_AUTH_HEADER` | | — | `Name: value` header added to Sui RPC calls (secret, for authenticated nodes) |
160
+ | `QUOTA_GUARD_ENABLED` | | `false` | Enable the scheduled free-tier quota guard |
161
+
162
+ Vars listed in `wrangler.toml` are overwritten on every `wrangler deploy`. Values that must
163
+ survive deployments (credentials, contract addresses) must be set with `wrangler secret put`.
164
+
165
+ ## Local development
166
+
167
+ ```bash
168
+ npm run dev # wrangler dev — local Worker runtime with DO + KV stubs
169
+ npm test # Vitest in the workerd runtime (tests DO, KV, crypto, routing)
170
+ npm run type-check # tsc --noEmit
171
+ ```
172
+
173
+ ## Optional: quota guard
174
+
175
+ Setting `QUOTA_GUARD_ENABLED=true` and adding a cron trigger activates `src/quota.ts`. It reads
176
+ your account's Workers usage via the Cloudflare GraphQL Analytics API and logs a warning when
177
+ approaching the free-tier request or CPU-time limits. It can set a `quota:degrade` flag in KV to
178
+ prefer the lighter KV backend during high-load periods.
179
+
180
+ Uncomment in `wrangler.toml`:
181
+
182
+ ```toml
183
+ [triggers]
184
+ crons = ["*/15 * * * *"]
185
+ ```
186
+
187
+ Set the required secrets:
188
+
189
+ ```bash
190
+ wrangler secret put CF_ANALYTICS_TOKEN # read-only Analytics API token
191
+ wrangler secret put CF_ACCOUNT_ID # your Cloudflare account ID
192
+ ```
193
+
194
+ ## Module layout
195
+
196
+ | Module | Rust analog | Role |
197
+ | --- | --- | --- |
198
+ | `src/index.ts` | `main.rs` | Request router (challenge / public / gated paths) |
199
+ | `src/config.ts` | `config.rs` | `env` → typed config (same var names as Rust) |
200
+ | `src/wire.ts` | `proof.rs` | Proof helpers reused from `@meddleware/nft-gate-client` |
201
+ | `src/verify.ts` | `verify.rs` | Signature verification + allow/deny decision |
202
+ | `src/chain.ts` | `sui_rpc.rs` | Sui JSON-RPC ownership and consume-event queries |
203
+ | `src/proxy.ts` | `proxy.rs` | Body-capped reverse proxy; strips `Host`/`Authorization` |
204
+ | `src/state/` | `challenge.rs` + `ratelimit.rs` | Pluggable nonce store and rate limiter |
205
+ | `src/quota.ts` | — | Optional free-tier quota guard (scheduled cron) |
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@meddleware/nft-gate-gateway",
3
+ "version": "0.0.1",
4
+ "type": "module",
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
+ "license": "0BSD",
7
+ "main": "src/index.ts",
8
+ "files": ["src", "wrangler.toml", "tsconfig.json"],
9
+ "publishConfig": {
10
+ "access": "public"
11
+ },
12
+ "scripts": {
13
+ "type-check": "tsc --noEmit",
14
+ "test": "vitest run --project unit",
15
+ "test:unit": "vitest run --project unit",
16
+ "test:integration": "vitest run --project cloudflare-integration",
17
+ "test:all": "vitest run",
18
+ "test:watch": "vitest --project unit",
19
+ "dev": "wrangler dev",
20
+ "deploy": "wrangler deploy",
21
+ "generate:vectors": "node scripts/gen-vectors.mjs > ../conformance/vectors.json"
22
+ },
23
+ "dependencies": {
24
+ "@meddleware/nft-gate-client": "^0.0.1",
25
+ "@noble/curves": "~2.4.0",
26
+ "@noble/hashes": "~2.4.0"
27
+ },
28
+ "devDependencies": {
29
+ "@cloudflare/vitest-pool-workers": "~0.22.0",
30
+ "@cloudflare/workers-types": "~5.20260827.1",
31
+ "typescript": "~7.0.2",
32
+ "vitest": "~4.1.11",
33
+ "wrangler": "~4.127.0"
34
+ }
35
+ }
package/src/chain.ts ADDED
@@ -0,0 +1,260 @@
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.
6
+ */
7
+
8
+ import type { ChainQuery } from './verify.js'
9
+ import { base64ToBytes } from './crypto.js'
10
+
11
+ type Json = unknown
12
+
13
+ /**
14
+ * A cached result of a single ownership query.
15
+ * `owns` — whether the address held the NFT at query time.
16
+ * `expiry` — unix-ms timestamp after which this entry must be discarded.
17
+ */
18
+ interface CacheEntry {
19
+ owns: boolean
20
+ expiry: number
21
+ }
22
+
23
+ export class SuiRpc implements ChainQuery {
24
+ private readonly cache = new Map<string, CacheEntry>()
25
+
26
+ /**
27
+ * @param rpcUrl - Sui JSON-RPC endpoint URL.
28
+ * @param cacheTtlMs - Ownership-cache TTL in ms. 0 disables the cache (live check every request).
29
+ * @param authHeader - Optional header injected on every RPC call (e.g. credentialed fullnode auth).
30
+ */
31
+ constructor(
32
+ private readonly rpcUrl: string,
33
+ /** Ownership-cache TTL (ms). 0 = disabled (every gated check is live on-chain). */
34
+ private readonly cacheTtlMs: number = 0,
35
+ private readonly authHeader?: { name: string; value: string },
36
+ ) {}
37
+
38
+ /**
39
+ * Send a Sui JSON-RPC 2.0 request and return the `result` field.
40
+ *
41
+ * @param method - JSON-RPC method name (e.g. `suix_getOwnedObjects`).
42
+ * @param params - Positional parameters array.
43
+ * @returns The `result` value from the RPC response, or `null`.
44
+ * @throws If the HTTP response is not OK or the RPC body contains an `error` field.
45
+ */
46
+ private async call(method: string, params: Json): Promise<Json> {
47
+ const headers: Record<string, string> = { 'content-type': 'application/json' }
48
+ if (this.authHeader) headers[this.authHeader.name] = this.authHeader.value
49
+ const resp = await fetch(this.rpcUrl, {
50
+ method: 'POST',
51
+ headers,
52
+ body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
53
+ })
54
+ if (!resp.ok) throw new Error(`rpc http ${resp.status} from ${method}`)
55
+ const json = (await resp.json()) as Record<string, Json>
56
+ if (json && typeof json === 'object' && 'error' in json && json.error) {
57
+ throw new Error(`rpc error from ${method}: ${JSON.stringify(json.error)}`)
58
+ }
59
+ return (json as { result?: Json }).result ?? null
60
+ }
61
+
62
+ /**
63
+ * Extract the package address from a `<pkg>::module::Type` string.
64
+ *
65
+ * @param nftType - Fully-qualified Move type string.
66
+ * @returns The package address, or `undefined` if the string cannot be parsed.
67
+ */
68
+ static packageOf(nftType: string): string | undefined {
69
+ const head = nftType.split('::')[0]
70
+ return head && head.length > 0 ? head : undefined
71
+ }
72
+
73
+ /**
74
+ * Build a stable cache key from the three ownership-query parameters.
75
+ *
76
+ * @param address - Sui address.
77
+ * @param nftType - Fully-qualified NFT type string.
78
+ * @param gateId - Optional gate object ID constraint.
79
+ * @returns A pipe-delimited string suitable for use as a `Map` key.
80
+ */
81
+ static cacheKey(address: string, nftType: string, gateId?: string): string {
82
+ return `${address}|${nftType}|${gateId ?? '-'}`
83
+ }
84
+
85
+ /**
86
+ * Uncached, live `suix_getOwnedObjects` ownership query.
87
+ *
88
+ * @param address - Sui address to query.
89
+ * @param nftType - NFT struct type to filter by.
90
+ * @param gateId - If given, only count objects whose `gate_id` field matches.
91
+ * @returns `true` if at least one qualifying NFT is owned.
92
+ */
93
+ private async ownsNftLive(address: string, nftType: string, gateId?: string): Promise<boolean> {
94
+ const params = [
95
+ address,
96
+ { filter: { StructType: nftType }, options: { showContent: true } },
97
+ null,
98
+ 50,
99
+ ]
100
+ const result = (await this.call('suix_getOwnedObjects', params)) as { data?: Json[] } | null
101
+ const data = (result && Array.isArray(result.data) ? result.data : []) as Json[]
102
+ if (gateId === undefined) return data.length > 0
103
+ return data.some((entry) => pointerStr(entry, ['data', 'content', 'fields', 'data', 'fields', 'gate_id']) === gateId)
104
+ }
105
+
106
+ /**
107
+ * Check whether `address` owns at least one NFT of `nftType`. Uses the in-process ownership
108
+ * cache when `cacheTtlMs > 0`; otherwise every call is live on-chain.
109
+ *
110
+ * @param address - Sui address to check.
111
+ * @param nftType - Fully-qualified NFT type string.
112
+ * @param gateId - Optional gate object ID constraint.
113
+ * @returns `true` if the address owns a qualifying NFT.
114
+ */
115
+ async ownsNft(address: string, nftType: string, gateId?: string): Promise<boolean> {
116
+ // Cache is OFF by default (ttl 0) so a gated action is confirmed live on-chain.
117
+ if (this.cacheTtlMs > 0) {
118
+ const key = SuiRpc.cacheKey(address, nftType, gateId)
119
+ const hit = this.cache.get(key)
120
+ const now = Date.now()
121
+ if (hit && hit.expiry > now) return hit.owns
122
+ const owns = await this.ownsNftLive(address, nftType, gateId)
123
+ this.cache.set(key, { owns, expiry: now + this.cacheTtlMs })
124
+ return owns
125
+ }
126
+ return this.ownsNftLive(address, nftType, gateId)
127
+ }
128
+
129
+ /**
130
+ * Directly verify the consume transaction named by `digest` via `sui_getTransactionBlock`.
131
+ * The tx must have succeeded and emitted a matching `AccessConsumedEvent`. Defence-in-depth
132
+ * atop the sender+nonce event query.
133
+ *
134
+ * @param digest - Transaction digest of the on-chain consume.
135
+ * @param nonce - The challenge nonce that was consumed.
136
+ * @param address - Expected transaction sender.
137
+ * @param gateId - Optional gate object ID constraint.
138
+ * @returns `true` if the transaction confirms the consume.
139
+ */
140
+ private async consumeTxMatches(
141
+ digest: string,
142
+ nonce: string,
143
+ address: string,
144
+ gateId?: string,
145
+ ): Promise<boolean> {
146
+ const params = [digest, { showEvents: true, showEffects: true }]
147
+ const result = (await this.call('sui_getTransactionBlock', params)) as Json
148
+ const status = pointerStr(result, ['effects', 'status', 'status'])
149
+ if (status !== 'success') return false
150
+ const events = (pointer(result, ['events']) as Json[] | undefined) ?? []
151
+ return (
152
+ Array.isArray(events) &&
153
+ events.some((ev) => isConsumedEvent(ev) && eventMatches(ev, nonce, address, gateId))
154
+ )
155
+ }
156
+
157
+ /**
158
+ * Verify a single-use consume via `suix_queryEvents`. Queries for an `AccessConsumedEvent`
159
+ * emitted by `address` carrying `nonce`; if `consumeDigest` is also provided, additionally
160
+ * verifies that exact transaction (F4: defence in depth).
161
+ *
162
+ * @param nonce - The challenge nonce that was consumed.
163
+ * @param address - The Sui address that submitted the consume transaction.
164
+ * @param nftType - Fully-qualified NFT type used to derive the event package.
165
+ * @param gateId - Optional gate object ID constraint.
166
+ * @param consumeDigest - Optional transaction digest for direct tx verification.
167
+ * @returns `true` if a matching consume event (and, when given, a matching tx) is found.
168
+ */
169
+ async consumeEventMatches(
170
+ nonce: string,
171
+ address: string,
172
+ nftType: string,
173
+ gateId?: string,
174
+ consumeDigest?: string,
175
+ ): Promise<boolean> {
176
+ const pkg = SuiRpc.packageOf(nftType)
177
+ if (!pkg) throw new Error('cannot derive package from nft_type')
178
+ const eventType = `${pkg}::access_gate::AccessConsumedEvent`
179
+ // Filter by BOTH the event type AND the emitting Sender (F5): O(this user's events).
180
+ const params = [{ All: [{ MoveEventType: eventType }, { Sender: address }] }, null, 50, true]
181
+ const result = (await this.call('suix_queryEvents', params)) as { data?: Json[] } | null
182
+ const data = (result && Array.isArray(result.data) ? result.data : []) as Json[]
183
+ const primary = data.some((ev) => eventMatches(ev, nonce, address, gateId))
184
+ if (!primary) return false
185
+ // F4: if a consume tx digest was supplied, verify that exact transaction too.
186
+ if (consumeDigest !== undefined) {
187
+ return this.consumeTxMatches(consumeDigest, nonce, address, gateId)
188
+ }
189
+ return true
190
+ }
191
+ }
192
+
193
+ // ── pure helpers (unit-tested; mirror sui_rpc.rs) ────────────────────────────
194
+
195
+ /**
196
+ * Traverse a nested JSON value by a sequence of object keys.
197
+ *
198
+ * @param v - The root JSON value.
199
+ * @param path - Sequence of object keys to follow.
200
+ * @returns The value at the path, or `undefined` if any step is missing or non-object.
201
+ */
202
+ function pointer(v: Json, path: string[]): Json {
203
+ let cur: Json = v
204
+ for (const key of path) {
205
+ if (cur && typeof cur === 'object' && !Array.isArray(cur) && key in (cur as Record<string, Json>)) {
206
+ cur = (cur as Record<string, Json>)[key]
207
+ } else {
208
+ return undefined
209
+ }
210
+ }
211
+ return cur
212
+ }
213
+
214
+ /**
215
+ * Like {@link pointer} but returns `undefined` if the resolved value is not a string.
216
+ *
217
+ * @param v - The root JSON value.
218
+ * @param path - Sequence of object keys to follow.
219
+ * @returns The string value at the path, or `undefined`.
220
+ */
221
+ function pointerStr(v: Json, path: string[]): string | undefined {
222
+ const r = pointer(v, path)
223
+ return typeof r === 'string' ? r : undefined
224
+ }
225
+
226
+ /** True if the event's type ends with `::access_gate::AccessConsumedEvent`. */
227
+ export function isConsumedEvent(ev: Json): boolean {
228
+ const t = pointerStr(ev, ['type'])
229
+ return t !== undefined && t.endsWith('::access_gate::AccessConsumedEvent')
230
+ }
231
+
232
+ /**
233
+ * Match the on-chain `AccessConsumedEvent.nonce` (`vector<u8>`), rendered by RPC as either an
234
+ * array of byte numbers or a base64 string, against the challenge nonce's UTF-8 bytes.
235
+ */
236
+ export function nonceMatches(eventNonce: Json, nonce: string): boolean {
237
+ const want = new TextEncoder().encode(nonce)
238
+ if (Array.isArray(eventNonce)) {
239
+ if (eventNonce.length !== want.length) return false
240
+ return eventNonce.every((b, i) => typeof b === 'number' && b === want[i])
241
+ }
242
+ if (typeof eventNonce === 'string') {
243
+ try {
244
+ const decoded = base64ToBytes(eventNonce)
245
+ return decoded.length === want.length && decoded.every((b, i) => b === want[i])
246
+ } catch {
247
+ return false
248
+ }
249
+ }
250
+ return false
251
+ }
252
+
253
+ /** sender == address, nonce matches, and (if given) gate_id matches. */
254
+ export function eventMatches(ev: Json, nonce: string, address: string, gateId?: string): boolean {
255
+ const senderOk = pointerStr(ev, ['sender']) === address
256
+ const nonceVal = pointer(ev, ['parsedJson', 'nonce'])
257
+ const nonceOk = nonceVal !== undefined && nonceMatches(nonceVal, nonce)
258
+ const gateOk = gateId === undefined ? true : pointerStr(ev, ['parsedJson', 'gate_id']) === gateId
259
+ return senderOk && nonceOk && gateOk
260
+ }
package/src/config.ts ADDED
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Gateway configuration, loaded from Worker `env` bindings. Mirror of the Rust gateway's
3
+ * `config.rs` — the SAME env-var names and defaults, so a deployment's settings map 1:1
4
+ * between the two implementations (the interchangeability contract).
5
+ *
6
+ * Omitted vs. Rust (runtime-specific): `BIND_ADDR` (Workers has no listen socket);
7
+ * `REDIS_URL` / `NONCE_MAX_ENTRIES` (the in-memory cap lives in the DO) /
8
+ * `NONCE_PRUNE_INTERVAL_SECS` (the DO prunes on access) are superseded by the DO/KV backend.
9
+ * Added (Workers-specific): `NONCE_BACKEND`, `NONCE_SHARD`, `SUI_RPC_AUTH_HEADER`,
10
+ * `UPSTREAM_AUTH_HEADERS`, `QUOTA_GUARD_ENABLED`.
11
+ */
12
+
13
+ export interface Env {
14
+ // ── config (parity with the Rust gateway) ─────────────────────────────────
15
+ UPSTREAM_URL: string
16
+ SUI_RPC_URL: string
17
+ NFT_TYPE: string
18
+ GATE_ID?: string
19
+ SINGLE_USE?: string
20
+ PUBLIC_PATHS?: string
21
+ RATE_LIMIT_PER_MIN?: string
22
+ MAX_BODY_BYTES?: string
23
+ CHALLENGE_TTL_SECS?: string
24
+ OWNERSHIP_CACHE_TTL_MS?: string
25
+ // ── Workers-specific ──────────────────────────────────────────────────────
26
+ /** `durable-object` (default) | `kv`. */
27
+ NONCE_BACKEND?: string
28
+ /** `region` (default) | `global` — DO shard granularity. */
29
+ NONCE_SHARD?: string
30
+ /** Hard cap on nonce rows per DO shard (evict soonest-to-expire beyond it). */
31
+ NONCE_MAX_ENTRIES?: string
32
+ /** Optional `Name: value` header line added to every Sui RPC call (secret). */
33
+ SUI_RPC_AUTH_HEADER?: string
34
+ /**
35
+ * Comma-separated `Name: value` header lines injected into every upstream (relay) request.
36
+ * Use this to pass Cloudflare Access service-token headers when the relay origin is
37
+ * Access-locked (your CF-Access-protected origin hostname).
38
+ *
39
+ * Format: `"CF-Access-Client-Id: <id>, CF-Access-Client-Secret: <secret>"`
40
+ *
41
+ * Set via `wrangler secret put UPSTREAM_AUTH_HEADERS` — never in wrangler.toml.
42
+ */
43
+ UPSTREAM_AUTH_HEADERS?: string
44
+ /** `true` enables the scheduled quota guard. */
45
+ QUOTA_GUARD_ENABLED?: string
46
+ // ── bindings ──────────────────────────────────────────────────────────────
47
+ NONCE_STATE?: DurableObjectNamespace
48
+ NONCE_KV?: KVNamespace
49
+ // ── quota-guard secrets (optional) ────────────────────────────────────────
50
+ CF_ANALYTICS_TOKEN?: string
51
+ CF_ACCOUNT_ID?: string
52
+ }
53
+
54
+ export type NonceBackendKind = 'durable-object' | 'kv'
55
+ export type NonceShardMode = 'region' | 'global'
56
+
57
+ export interface Config {
58
+ upstreamUrl: string
59
+ suiRpcUrl: string
60
+ suiRpcAuthHeader?: { name: string; value: string }
61
+ /** Headers injected into every upstream relay request (e.g. CF Access service token). */
62
+ upstreamAuthHeaders: Array<{ name: string; value: string }>
63
+ nftType: string
64
+ gateId?: string
65
+ challengeTtlSecs: number
66
+ singleUse: boolean
67
+ publicPaths: string[]
68
+ rateLimitPerMin: number
69
+ maxBodyBytes: number
70
+ ownershipCacheTtlMs: number
71
+ nonceBackend: NonceBackendKind
72
+ nonceShard: NonceShardMode
73
+ nonceMaxEntries: number
74
+ quotaGuardEnabled: boolean
75
+ }
76
+
77
+ function req(env: Env, key: keyof Env): string {
78
+ const v = env[key]
79
+ if (typeof v !== 'string' || v.length === 0) {
80
+ throw new Error(`${key} is required`)
81
+ }
82
+ return v
83
+ }
84
+
85
+ function numOr(v: string | undefined, dflt: number): number {
86
+ if (v === undefined) return dflt
87
+ const n = Number(v)
88
+ return Number.isFinite(n) ? n : dflt
89
+ }
90
+
91
+ function parseAuthHeader(v: string | undefined): { name: string; value: string } | undefined {
92
+ if (!v) return undefined
93
+ const idx = v.indexOf(':')
94
+ // "Name: value" → {name, value}; a bare value defaults to an Authorization header.
95
+ if (idx > 0) return { name: v.slice(0, idx).trim(), value: v.slice(idx + 1).trim() }
96
+ return { name: 'Authorization', value: v.trim() }
97
+ }
98
+
99
+ /**
100
+ * Parse `UPSTREAM_AUTH_HEADERS`: comma-separated `Name: value` pairs.
101
+ * Each entry follows the same `Name: value` format as `SUI_RPC_AUTH_HEADER`.
102
+ * Entries that cannot be parsed (no colon) are silently skipped.
103
+ */
104
+ function parseUpstreamAuthHeaders(v: string | undefined): Array<{ name: string; value: string }> {
105
+ if (!v || v.trim().length === 0) return []
106
+ return v
107
+ .split(',')
108
+ .map((entry) => parseAuthHeader(entry.trim()))
109
+ .filter((h): h is { name: string; value: string } => h !== undefined)
110
+ }
111
+
112
+ /** Build the typed config from `env`. Throws if a required var is missing (fail fast). */
113
+ export function loadConfig(env: Env): Config {
114
+ const backendRaw = (env.NONCE_BACKEND ?? 'durable-object').toLowerCase()
115
+ const nonceBackend: NonceBackendKind = backendRaw === 'kv' ? 'kv' : 'durable-object'
116
+ const shardRaw = (env.NONCE_SHARD ?? 'region').toLowerCase()
117
+ const nonceShard: NonceShardMode = shardRaw === 'global' ? 'global' : 'region'
118
+
119
+ return {
120
+ upstreamUrl: req(env, 'UPSTREAM_URL').replace(/\/+$/, ''),
121
+ suiRpcUrl: req(env, 'SUI_RPC_URL'),
122
+ suiRpcAuthHeader: parseAuthHeader(env.SUI_RPC_AUTH_HEADER),
123
+ upstreamAuthHeaders: parseUpstreamAuthHeaders(env.UPSTREAM_AUTH_HEADERS),
124
+ nftType: req(env, 'NFT_TYPE'),
125
+ gateId: env.GATE_ID && env.GATE_ID.length > 0 ? env.GATE_ID : undefined,
126
+ challengeTtlSecs: numOr(env.CHALLENGE_TTL_SECS, 300),
127
+ singleUse: (env.SINGLE_USE ?? 'false').toLowerCase() === 'true',
128
+ publicPaths: (env.PUBLIC_PATHS ?? '/v1/tip-config')
129
+ .split(',')
130
+ .map((s) => s.trim())
131
+ .filter((s) => s.length > 0),
132
+ rateLimitPerMin: numOr(env.RATE_LIMIT_PER_MIN, 30),
133
+ maxBodyBytes: numOr(env.MAX_BODY_BYTES, 262144),
134
+ ownershipCacheTtlMs: numOr(env.OWNERSHIP_CACHE_TTL_MS, 0),
135
+ nonceBackend,
136
+ nonceShard,
137
+ nonceMaxEntries: numOr(env.NONCE_MAX_ENTRIES, 1000000),
138
+ quotaGuardEnabled: (env.QUOTA_GUARD_ENABLED ?? 'false').toLowerCase() === 'true',
139
+ }
140
+ }
141
+
142
+ export function isPublicPath(cfg: Config, path: string): boolean {
143
+ return cfg.publicPaths.some((p) => p === path)
144
+ }