@meddleware/nft-gate-gateway 0.0.10 → 0.0.11
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/README.md +4 -4
- package/package.json +4 -4
- package/src/chain.ts +21 -0
- package/src/config.ts +21 -4
- package/src/verify.ts +19 -0
- package/wrangler.toml +5 -0
package/README.md
CHANGED
|
@@ -143,9 +143,9 @@ Then set `NONCE_BACKEND = "kv"` in `[vars]`.
|
|
|
143
143
|
| Var | Required | Default | Notes |
|
|
144
144
|
| --- | --- | --- | --- |
|
|
145
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
|
|
147
|
-
| `NFT_TYPE` | ✓ | — | `<pkg>::access_gate::AccessNFT` or `SoulboundAccessNFT` (set via `wrangler secret put`) |
|
|
148
|
-
| `GATE_ID` | | — |
|
|
146
|
+
| `SUI_RPC_URL` | ✓ | `https://fullnode.testnet.sui.io:443` | Sui fullnode (queried over gRPC-web); change to mainnet for production |
|
|
147
|
+
| `NFT_TYPE` | ✓ | — | `<pkg>::access_gate::AccessNFT` or `SoulboundAccessNFT` (set via `wrangler secret put`); any other type — e.g. a fungible `Coin<T>` — is rejected at startup |
|
|
148
|
+
| `GATE_ID` | ✓ | — | The gate whose passes are accepted; also read live so a paused gate with `pause_blocks_access` denies holders (`403 the gate is paused`) |
|
|
149
149
|
| `SINGLE_USE` | | `false` | Require an on-chain `AccessConsumedEvent` bound to the nonce |
|
|
150
150
|
| `PUBLIC_PATHS` | | `/v1/tip-config` | Comma-separated paths served without authentication |
|
|
151
151
|
| `RATE_LIMIT_PER_MIN` | | `30` | Requests per verified address per 60s window (`0` disables) |
|
|
@@ -199,7 +199,7 @@ wrangler secret put CF_ACCOUNT_ID # your Cloudflare account ID
|
|
|
199
199
|
| `src/config.ts` | `config.rs` | `env` → typed config (same var names as Rust) |
|
|
200
200
|
| `src/wire.ts` | `proof.rs` | Proof helpers reused from `@meddleware/nft-gate-client` |
|
|
201
201
|
| `src/verify.ts` | `verify.rs` | Signature verification + allow/deny decision |
|
|
202
|
-
| `src/chain.ts` | `sui_rpc.rs` | Sui
|
|
202
|
+
| `src/chain.ts` | `sui_rpc.rs` | Sui gRPC ownership and consume-event queries |
|
|
203
203
|
| `src/proxy.ts` | `proxy.rs` | Body-capped reverse proxy; strips `Host`/`Authorization` |
|
|
204
204
|
| `src/state/` | `challenge.rs` + `ratelimit.rs` | Pluggable nonce store and rate limiter |
|
|
205
205
|
| `src/quota.ts` | — | Optional free-tier quota guard (scheduled cron) |
|
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.11",
|
|
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",
|
|
@@ -26,8 +26,8 @@
|
|
|
26
26
|
"generate:vectors": "node scripts/gen-vectors.mjs > ../conformance/vectors.json"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@meddleware/nft-gate-client": "^0.0.
|
|
30
|
-
"@mysten/sui": "~2.
|
|
29
|
+
"@meddleware/nft-gate-client": "^0.0.12",
|
|
30
|
+
"@mysten/sui": "~2.33.0",
|
|
31
31
|
"@noble/curves": "~2.4.0",
|
|
32
32
|
"@noble/hashes": "~2.4.0"
|
|
33
33
|
},
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"@cloudflare/workers-types": "~5.20260927.1",
|
|
37
37
|
"typescript": "^6.0.0",
|
|
38
38
|
"vitest": "~4.1.0",
|
|
39
|
-
"wrangler": "^4.
|
|
39
|
+
"wrangler": "^4.143.0"
|
|
40
40
|
},
|
|
41
41
|
"overrides": {
|
|
42
42
|
"sharp": "0.35.4"
|
package/src/chain.ts
CHANGED
|
@@ -124,6 +124,18 @@ export class SuiGrpc implements ChainQuery {
|
|
|
124
124
|
return this.ownsNftLive(address, nftType, gateId)
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Live read of the gate: blocked while paused if its policy has `pause_blocks_access`. Not
|
|
129
|
+
* cached — pausing takes effect on the next request.
|
|
130
|
+
*
|
|
131
|
+
* @param gateId - The `Gate` shared object ID.
|
|
132
|
+
* @throws If the gate cannot be read (surfaces as ChainError → 502; fail closed).
|
|
133
|
+
*/
|
|
134
|
+
async gateAccessBlocked(gateId: string): Promise<boolean> {
|
|
135
|
+
const { object } = await this.client.core.getObject({ objectId: gateId, include: { json: true } })
|
|
136
|
+
return gateBlocksAccess(object.json)
|
|
137
|
+
}
|
|
138
|
+
|
|
127
139
|
/**
|
|
128
140
|
* Fetch a transaction by digest via gRPC, retrying briefly to absorb the window between the
|
|
129
141
|
* client's finality wait and the gateway fullnode indexing the transaction.
|
|
@@ -226,6 +238,15 @@ function eventFields(ev: Json): Json {
|
|
|
226
238
|
}
|
|
227
239
|
|
|
228
240
|
/** True if the event's type ends with `::access_gate::AccessConsumedEvent`. */
|
|
241
|
+
/**
|
|
242
|
+
* True if a `Gate` object's JSON (gRPC core shape: fields flat) is paused and its policy has
|
|
243
|
+
* `pause_blocks_access`. Gates without a `policy` (pre-policy package versions) never block.
|
|
244
|
+
*/
|
|
245
|
+
export function gateBlocksAccess(gateJson: Json): boolean {
|
|
246
|
+
const g = gateJson as { paused?: unknown; policy?: { pause_blocks_access?: unknown } } | null
|
|
247
|
+
return Boolean(g && g.paused === true && g.policy && g.policy.pause_blocks_access === true)
|
|
248
|
+
}
|
|
249
|
+
|
|
229
250
|
export function isConsumedEvent(ev: Json): boolean {
|
|
230
251
|
const t = eventType(ev)
|
|
231
252
|
return t !== undefined && t.endsWith('::access_gate::AccessConsumedEvent')
|
package/src/config.ts
CHANGED
|
@@ -77,7 +77,8 @@ export interface Config {
|
|
|
77
77
|
/** Headers injected into every upstream relay request (e.g. CF Access service token). */
|
|
78
78
|
upstreamAuthHeaders: Array<{ name: string; value: string }>
|
|
79
79
|
nftType: string
|
|
80
|
-
|
|
80
|
+
/** The access_gate `Gate` whose passes are accepted (required). */
|
|
81
|
+
gateId: string
|
|
81
82
|
challengeTtlSecs: number
|
|
82
83
|
singleUse: boolean
|
|
83
84
|
publicPaths: string[]
|
|
@@ -148,8 +149,24 @@ function parseUpstreamAuthHeaders(v: string | undefined): Array<{ name: string;
|
|
|
148
149
|
.filter((h): h is { name: string; value: string } => h !== undefined)
|
|
149
150
|
}
|
|
150
151
|
|
|
151
|
-
/**
|
|
152
|
+
/**
|
|
153
|
+
* `NFT_TYPE` must be an access_gate pass type: `<pkg>::access_gate::AccessNFT` or
|
|
154
|
+
* `…::SoulboundAccessNFT`. Anything else (a fungible `Coin<T>`, an arbitrary NFT) would gate on
|
|
155
|
+
* mere ownership of an object that access_gate never sold, so it is rejected at startup.
|
|
156
|
+
*/
|
|
157
|
+
const NFT_TYPE_RE = /^0x[0-9a-fA-F]{1,64}::access_gate::(?:AccessNFT|SoulboundAccessNFT)$/
|
|
158
|
+
/** A Sui object ID. */
|
|
159
|
+
const OBJECT_ID_RE = /^0x[0-9a-fA-F]{1,64}$/
|
|
160
|
+
|
|
161
|
+
/** Build the typed config from `env`. Throws if a required var is missing or invalid (fail fast). */
|
|
152
162
|
export function loadConfig(env: Env): Config {
|
|
163
|
+
const nftType = req(env, 'NFT_TYPE').trim()
|
|
164
|
+
if (!NFT_TYPE_RE.test(nftType)) {
|
|
165
|
+
throw new Error('NFT_TYPE must be <pkg>::access_gate::AccessNFT or <pkg>::access_gate::SoulboundAccessNFT')
|
|
166
|
+
}
|
|
167
|
+
const gateId = req(env, 'GATE_ID').trim()
|
|
168
|
+
if (!OBJECT_ID_RE.test(gateId)) throw new Error('GATE_ID must be a 0x-prefixed object ID')
|
|
169
|
+
|
|
153
170
|
const backendRaw = (env.NONCE_BACKEND ?? 'durable-object').toLowerCase()
|
|
154
171
|
const nonceBackend: NonceBackendKind = backendRaw === 'kv' ? 'kv' : 'durable-object'
|
|
155
172
|
const shardRaw = (env.NONCE_SHARD ?? 'region').toLowerCase()
|
|
@@ -160,8 +177,8 @@ export function loadConfig(env: Env): Config {
|
|
|
160
177
|
suiRpcUrl: req(env, 'SUI_RPC_URL'),
|
|
161
178
|
suiRpcAuthHeader: parseAuthHeader(env.SUI_RPC_AUTH_HEADER),
|
|
162
179
|
upstreamAuthHeaders: parseUpstreamAuthHeaders(env.UPSTREAM_AUTH_HEADERS),
|
|
163
|
-
nftType
|
|
164
|
-
gateId
|
|
180
|
+
nftType,
|
|
181
|
+
gateId,
|
|
165
182
|
challengeTtlSecs: numOr(env.CHALLENGE_TTL_SECS, 300),
|
|
166
183
|
singleUse: (env.SINGLE_USE ?? 'false').toLowerCase() === 'true',
|
|
167
184
|
publicPaths: (env.PUBLIC_PATHS ?? '/v1/tip-config')
|
package/src/verify.ts
CHANGED
|
@@ -126,6 +126,12 @@ export interface ChainQuery {
|
|
|
126
126
|
* — single-use is enforced by the redemption store keying on the digest.
|
|
127
127
|
*/
|
|
128
128
|
consumeTxValid(consumeDigest: string, address: string, gateId?: string): Promise<boolean>
|
|
129
|
+
/**
|
|
130
|
+
* True if `gateId` is paused AND its immutable `GatePolicy` has `pause_blocks_access` — holders
|
|
131
|
+
* must then be denied until the gate is unpaused. False for gates without that policy flag
|
|
132
|
+
* (including gates of package versions that predate policies).
|
|
133
|
+
*/
|
|
134
|
+
gateAccessBlocked(gateId: string): Promise<boolean>
|
|
129
135
|
}
|
|
130
136
|
|
|
131
137
|
/** The reason a request was rejected by {@link verifyAccessRequest}. Mirror of Rust `Denied`. */
|
|
@@ -136,6 +142,7 @@ export type Denied =
|
|
|
136
142
|
| 'NotOwner'
|
|
137
143
|
| 'ConsumeMissing'
|
|
138
144
|
| 'RedeemConflict'
|
|
145
|
+
| 'GatePaused'
|
|
139
146
|
| 'ChainError'
|
|
140
147
|
|
|
141
148
|
/** A short, client-visible description of why access was denied. */
|
|
@@ -153,6 +160,8 @@ export function deniedReason(d: Denied): string {
|
|
|
153
160
|
return 'no matching single-use consume for this address'
|
|
154
161
|
case 'RedeemConflict':
|
|
155
162
|
return 'this consume is already redeemed or an upload for it is in progress'
|
|
163
|
+
case 'GatePaused':
|
|
164
|
+
return 'the gate is paused'
|
|
156
165
|
case 'ChainError':
|
|
157
166
|
return 'on-chain verification failed'
|
|
158
167
|
}
|
|
@@ -203,6 +212,16 @@ export async function verifyAccessRequest(
|
|
|
203
212
|
return { ok: false, denied: 'NonceInvalid' }
|
|
204
213
|
}
|
|
205
214
|
|
|
215
|
+
// A gate whose policy has `pause_blocks_access` admits no one while paused. Checked before the
|
|
216
|
+
// single-use lease, so a consume made just before a pause stays redeemable after unpausing.
|
|
217
|
+
let blocked: boolean
|
|
218
|
+
try {
|
|
219
|
+
blocked = await chain.gateAccessBlocked(cfg.gateId)
|
|
220
|
+
} catch {
|
|
221
|
+
return { ok: false, denied: 'ChainError' }
|
|
222
|
+
}
|
|
223
|
+
if (blocked) return { ok: false, denied: 'GatePaused' }
|
|
224
|
+
|
|
206
225
|
if (cfg.singleUse) {
|
|
207
226
|
if (proof.consumeDigest === undefined) {
|
|
208
227
|
return { ok: false, denied: 'ConsumeMissing' }
|
package/wrangler.toml
CHANGED
|
@@ -45,6 +45,11 @@ compatibility_flags = ["nodejs_compat"]
|
|
|
45
45
|
# Public fullnode — safe to keep as a plain var (no sensitive information).
|
|
46
46
|
SUI_RPC_URL = "https://fullnode.testnet.sui.io:443"
|
|
47
47
|
|
|
48
|
+
# Required: the gate whose passes are accepted. This is the live testnet relay gate on the
|
|
49
|
+
# pre-policy access_gate 0x0bedd0…; switch to the policy-aware gate
|
|
50
|
+
# 0xcb8206cab55902247daba61bad3d6c92f072425fe4034a98d6a22400d25a5f50 (package 0x1a81ca…, with
|
|
51
|
+
# NFT_TYPE = 0x1a81ca…::access_gate::SoulboundAccessNFT) together with the walrus-ui release that
|
|
52
|
+
# targets the new package — never one without the other.
|
|
48
53
|
GATE_ID = "0x0485c1fa80e4c355c85ab99c0281a328d8fb5c60ac50ab64f10be0f8be792aba"
|
|
49
54
|
SINGLE_USE = "true"
|
|
50
55
|
PUBLIC_PATHS = "/v1/tip-config"
|