@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 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 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 |
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 JSON-RPC ownership and consume-event queries |
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.10",
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.11",
30
- "@mysten/sui": "~2.30.0",
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.142.0"
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
- gateId?: string
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
- /** Build the typed config from `env`. Throws if a required var is missing (fail fast). */
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: req(env, 'NFT_TYPE'),
164
- gateId: env.GATE_ID && env.GATE_ID.length > 0 ? env.GATE_ID : undefined,
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"