@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 +14 -0
- package/README.md +205 -0
- package/package.json +35 -0
- package/src/chain.ts +260 -0
- package/src/config.ts +144 -0
- package/src/crypto.ts +78 -0
- package/src/index.ts +173 -0
- package/src/proxy.ts +81 -0
- package/src/quota.ts +116 -0
- package/src/state/durable_object.ts +139 -0
- package/src/state/kv.ts +81 -0
- package/src/state/select.ts +35 -0
- package/src/state/types.ts +49 -0
- package/src/verify.ts +216 -0
- package/src/wire.ts +20 -0
- package/tsconfig.json +19 -0
- package/wrangler.toml +83 -0
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
|
+
[](https://www.npmjs.com/package/@meddleware/nft-gate-gateway-workers)
|
|
4
|
+
[](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
|
+
}
|