@meddleware/nft-gate-gateway 0.0.8 → 0.0.10

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 CHANGED
@@ -1,11 +1,15 @@
1
- {
1
+ {
2
2
  "name": "@meddleware/nft-gate-gateway",
3
- "version": "0.0.8",
3
+ "version": "0.0.10",
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",
7
7
  "main": "src/index.ts",
8
- "files": ["src", "wrangler.toml", "tsconfig.json"],
8
+ "files": [
9
+ "src",
10
+ "wrangler.toml",
11
+ "tsconfig.json"
12
+ ],
9
13
  "publishConfig": {
10
14
  "access": "public"
11
15
  },
@@ -14,6 +18,7 @@
14
18
  "test": "vitest run --project unit",
15
19
  "test:unit": "vitest run --project unit",
16
20
  "test:integration": "vitest run --project cloudflare-integration",
21
+ "test:grpc": "GRPC_TESTNET=1 vitest run test/integration/grpc-chain.integration.test.ts",
17
22
  "test:all": "vitest run",
18
23
  "test:watch": "vitest --project unit",
19
24
  "dev": "wrangler dev",
@@ -21,17 +26,17 @@
21
26
  "generate:vectors": "node scripts/gen-vectors.mjs > ../conformance/vectors.json"
22
27
  },
23
28
  "dependencies": {
24
- "@meddleware/nft-gate-client": "^0.0.6",
29
+ "@meddleware/nft-gate-client": "^0.0.11",
25
30
  "@mysten/sui": "~2.30.0",
26
31
  "@noble/curves": "~2.4.0",
27
32
  "@noble/hashes": "~2.4.0"
28
33
  },
29
34
  "devDependencies": {
30
35
  "@cloudflare/vitest-pool-workers": "~0.22.0",
31
- "@cloudflare/workers-types": "~5.20260910.1",
32
- "typescript": "~7.0.2",
33
- "vitest": "~4.1.11",
34
- "wrangler": "~4.130.0"
36
+ "@cloudflare/workers-types": "~5.20260927.1",
37
+ "typescript": "^6.0.0",
38
+ "vitest": "~4.1.0",
39
+ "wrangler": "^4.142.0"
35
40
  },
36
41
  "overrides": {
37
42
  "sharp": "0.35.4"
package/src/config.ts CHANGED
@@ -19,6 +19,8 @@ export interface Env {
19
19
  SINGLE_USE?: string
20
20
  PUBLIC_PATHS?: string
21
21
  RATE_LIMIT_PER_MIN?: string
22
+ PUBLIC_RATE_LIMIT_PER_MIN?: string
23
+ PUBLIC_CACHE_TTL_SECS?: string
22
24
  MAX_BODY_BYTES?: string
23
25
  CHALLENGE_TTL_SECS?: string
24
26
  OWNERSHIP_CACHE_TTL_MS?: string
@@ -47,6 +49,13 @@ export interface Env {
47
49
  UPSTREAM_AUTH_HEADERS?: string
48
50
  /** `true` enables the scheduled quota guard. */
49
51
  QUOTA_GUARD_ENABLED?: string
52
+ /**
53
+ * Comma-separated list of browser origins allowed to make cross-origin requests.
54
+ * Only origins in this list receive an `Access-Control-Allow-Origin` header.
55
+ * Defaults to the two Meddleware app origins when absent.
56
+ * Example: `"https://sui-walrus.meddleware.co.uk,https://sui.meddleware.co.uk"`
57
+ */
58
+ ALLOWED_ORIGINS?: string
50
59
  // ── bindings ──────────────────────────────────────────────────────────────
51
60
  NONCE_STATE?: DurableObjectNamespace
52
61
  NONCE_KV?: KVNamespace
@@ -73,6 +82,10 @@ export interface Config {
73
82
  singleUse: boolean
74
83
  publicPaths: string[]
75
84
  rateLimitPerMin: number
85
+ /** Per-client-IP request cap for unauthenticated public paths (e.g. /v1/tip-config). */
86
+ publicRateLimitPerMin: number
87
+ /** Edge-cache TTL (s) for cacheable GET responses on public paths. 0 disables caching. */
88
+ publicCacheTtlSecs: number
76
89
  maxBodyBytes: number
77
90
  ownershipCacheTtlMs: number
78
91
  /** Single-use: lease TTL (s) for an in-flight consume-digest redemption. */
@@ -83,6 +96,8 @@ export interface Config {
83
96
  nonceShard: NonceShardMode
84
97
  nonceMaxEntries: number
85
98
  quotaGuardEnabled: boolean
99
+ /** Allowed CORS origins — only these are reflected in Access-Control-Allow-Origin. */
100
+ allowedOrigins: string[]
86
101
  }
87
102
 
88
103
  function req(env: Env, key: keyof Env): string {
@@ -112,6 +127,19 @@ function parseAuthHeader(v: string | undefined): { name: string; value: string }
112
127
  * Each entry follows the same `Name: value` format as `SUI_RPC_AUTH_HEADER`.
113
128
  * Entries that cannot be parsed (no colon) are silently skipped.
114
129
  */
130
+ const DEFAULT_ALLOWED_ORIGINS = [
131
+ 'https://sui-walrus.meddleware.co.uk',
132
+ 'https://sui.meddleware.co.uk',
133
+ ]
134
+
135
+ function parseAllowedOrigins(v: string | undefined): string[] {
136
+ if (!v || v.trim().length === 0) return DEFAULT_ALLOWED_ORIGINS
137
+ return v
138
+ .split(',')
139
+ .map((s) => s.trim())
140
+ .filter((s) => s.length > 0)
141
+ }
142
+
115
143
  function parseUpstreamAuthHeaders(v: string | undefined): Array<{ name: string; value: string }> {
116
144
  if (!v || v.trim().length === 0) return []
117
145
  return v
@@ -141,6 +169,8 @@ export function loadConfig(env: Env): Config {
141
169
  .map((s) => s.trim())
142
170
  .filter((s) => s.length > 0),
143
171
  rateLimitPerMin: numOr(env.RATE_LIMIT_PER_MIN, 30),
172
+ publicRateLimitPerMin: numOr(env.PUBLIC_RATE_LIMIT_PER_MIN, 120),
173
+ publicCacheTtlSecs: numOr(env.PUBLIC_CACHE_TTL_SECS, 60),
144
174
  maxBodyBytes: numOr(env.MAX_BODY_BYTES, 262144),
145
175
  ownershipCacheTtlMs: numOr(env.OWNERSHIP_CACHE_TTL_MS, 0),
146
176
  redemptionLeaseTtlSecs: numOr(env.REDEMPTION_LEASE_TTL_SECS, 120),
@@ -149,6 +179,7 @@ export function loadConfig(env: Env): Config {
149
179
  nonceShard,
150
180
  nonceMaxEntries: numOr(env.NONCE_MAX_ENTRIES, 1000000),
151
181
  quotaGuardEnabled: (env.QUOTA_GUARD_ENABLED ?? 'false').toLowerCase() === 'true',
182
+ allowedOrigins: parseAllowedOrigins(env.ALLOWED_ORIGINS),
152
183
  }
153
184
  }
154
185
 
package/src/cors.ts CHANGED
@@ -6,10 +6,14 @@
6
6
  * Access-Control-Allow-Origin headers the browser blocks the response even when
7
7
  * the Worker returns 200, and OPTIONS preflights (required before non-simple requests
8
8
  * such as PUT uploads with an Authorization header) receive no preflight grant.
9
+ *
10
+ * Origin allowlist: rather than reflecting `*`, only origins present in the configured
11
+ * ALLOWED_ORIGINS list receive the Access-Control-Allow-Origin header. An absent or
12
+ * disallowed origin gets no CORS header — the browser blocks the cross-origin request,
13
+ * which is the correct fail-closed behaviour.
9
14
  */
10
15
 
11
- const CORS_HEADERS: Record<string, string> = {
12
- 'access-control-allow-origin': '*',
16
+ const CORS_STATIC_HEADERS: Record<string, string> = {
13
17
  'access-control-allow-methods': 'GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS',
14
18
  'access-control-allow-headers': 'authorization, content-type, x-access-proof',
15
19
  'access-control-expose-headers': 'location, upload-offset',
@@ -17,12 +21,25 @@ const CORS_HEADERS: Record<string, string> = {
17
21
  }
18
22
 
19
23
  /**
20
- * Clone `res` and add CORS headers. Using `set()` so calling this on an upstream
21
- * response that already carries CORS headers just overwrites them consistently.
24
+ * Resolve the reflected `Access-Control-Allow-Origin` value for a request.
25
+ * Returns the request origin if it appears in `allowedOrigins`, otherwise `null`.
26
+ */
27
+ export function resolveAllowedOrigin(
28
+ requestOrigin: string | null,
29
+ allowedOrigins: string[],
30
+ ): string | null {
31
+ if (!requestOrigin || !allowedOrigins.includes(requestOrigin)) return null
32
+ return requestOrigin
33
+ }
34
+
35
+ /**
36
+ * Clone `res` and add CORS headers. If `allowedOrigin` is non-null, it is reflected
37
+ * as `Access-Control-Allow-Origin`; otherwise that header is omitted (fail closed).
22
38
  */
23
- export function withCors(res: Response): Response {
39
+ export function withCors(res: Response, allowedOrigin: string | null): Response {
24
40
  const out = new Response(res.body, res)
25
- for (const [k, v] of Object.entries(CORS_HEADERS)) {
41
+ if (allowedOrigin) out.headers.set('access-control-allow-origin', allowedOrigin)
42
+ for (const [k, v] of Object.entries(CORS_STATIC_HEADERS)) {
26
43
  out.headers.set(k, v)
27
44
  }
28
45
  return out
@@ -30,9 +47,8 @@ export function withCors(res: Response): Response {
30
47
 
31
48
  /**
32
49
  * Return a minimal 204 preflight response for OPTIONS requests.
33
- * The browser requires this before sending non-simple cross-origin requests
34
- * (e.g. PUT/POST with Authorization or Content-Type).
50
+ * The Access-Control-Allow-Origin header is added by the outer withCors wrapper.
35
51
  */
36
52
  export function corsPreflightResponse(): Response {
37
- return new Response(null, { status: 204, headers: CORS_HEADERS })
53
+ return new Response(null, { status: 204, headers: CORS_STATIC_HEADERS })
38
54
  }
package/src/index.ts CHANGED
@@ -15,7 +15,7 @@ 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'
18
- import { withCors, corsPreflightResponse } from './cors.js'
18
+ import { withCors, corsPreflightResponse, resolveAllowedOrigin } from './cors.js'
19
19
 
20
20
  export { NonceRateState } from './state/durable_object.js'
21
21
 
@@ -113,6 +113,45 @@ function regionOf(cfg: Config, request: Request): string {
113
113
  return continent ? continent.toLowerCase() : 'g'
114
114
  }
115
115
 
116
+ /**
117
+ * Forward an UNAUTHENTICATED public path (e.g. `/v1/tip-config`) to the upstream, hardened so it
118
+ * can't be used to hammer the single relay origin:
119
+ * 1. Per-client-IP rate limit (keyed on `CF-Connecting-IP`) — a higher ceiling than the gated
120
+ * per-address limit since these are cheap GETs, but bounded so a flood is rejected at the edge.
121
+ * 2. Edge cache of successful GET responses (tip-config is near-static) via the Cache API, so
122
+ * repeat/flood reads are served from Cloudflare without reaching the origin at all.
123
+ * Non-GET public requests are still rate-limited but not cached.
124
+ */
125
+ async function forwardPublic(
126
+ cfg: Config,
127
+ backend: NonceBackend,
128
+ request: Request,
129
+ region: string,
130
+ ctx: ExecutionContext,
131
+ ): Promise<Response> {
132
+ const ip = request.headers.get('CF-Connecting-IP') ?? 'unknown'
133
+ if (!(await backend.rateCheck(`ip:${ip}`, cfg.publicRateLimitPerMin, region))) {
134
+ return deny(429, 'rate limit exceeded')
135
+ }
136
+
137
+ if (request.method !== 'GET' || cfg.publicCacheTtlSecs <= 0) return forward(cfg, request)
138
+
139
+ // Cache key is the URL alone (public GET, no auth/cookies to vary on).
140
+ const cache = (caches as unknown as { default: Cache }).default
141
+ const cacheKey = new Request(new URL(request.url).toString(), { method: 'GET' })
142
+ const hit = await cache.match(cacheKey)
143
+ if (hit) return hit
144
+
145
+ const resp = await forward(cfg, request)
146
+ if (resp.ok) {
147
+ const cached = new Response(resp.body, resp)
148
+ cached.headers.set('Cache-Control', `public, max-age=${cfg.publicCacheTtlSecs}`)
149
+ ctx.waitUntil(cache.put(cacheKey, cached.clone()))
150
+ return cached
151
+ }
152
+ return resp
153
+ }
154
+
116
155
  /**
117
156
  * Main request dispatcher. Handles `/healthz`, `/v1/challenge`, configured public paths,
118
157
  * and gated paths (signature + ownership verification before proxying to the upstream).
@@ -121,7 +160,7 @@ function regionOf(cfg: Config, request: Request): string {
121
160
  * @param env - The Worker environment bindings.
122
161
  * @returns A `Response` to send to the client.
123
162
  */
124
- async function handle(request: Request, env: Env): Promise<Response> {
163
+ async function handle(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
125
164
  const url = new URL(request.url)
126
165
  const path = url.pathname
127
166
 
@@ -146,8 +185,11 @@ async function handle(request: Request, env: Env): Promise<Response> {
146
185
  return json(200, { nonce, expiresAt })
147
186
  }
148
187
 
149
- // Public passthrough (e.g. /v1/tip-config): forward without auth.
150
- if (isPublicPath(cfg, path)) return forward(cfg, request)
188
+ // Public passthrough (e.g. /v1/tip-config): forward without auth, but protect the single relay
189
+ // origin — these bypass the NFT gate. Per-client-IP rate limit + edge-cache of GET responses.
190
+ if (isPublicPath(cfg, path)) {
191
+ return forwardPublic(cfg, backend, request, regionOf(cfg, request), ctx)
192
+ }
151
193
 
152
194
  const token = extractProofToken(request)
153
195
  if (!token) return deny(401, 'missing access proof')
@@ -198,8 +240,13 @@ async function handle(request: Request, env: Env): Promise<Response> {
198
240
  * {@link handle}; the `scheduled` handler runs the optional quota guard on a cron trigger.
199
241
  */
200
242
  export default {
201
- fetch(request: Request, env: Env): Promise<Response> {
202
- return handle(request, env).then(withCors)
243
+ async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
244
+ const origin = request.headers.get('origin')
245
+ const res = await handle(request, env, ctx)
246
+ // State is cached after handle() completes; a second call is free.
247
+ const state = await getState(env).catch(() => null)
248
+ const allowedOrigin = resolveAllowedOrigin(origin, state?.cfg.allowedOrigins ?? [])
249
+ return withCors(res, allowedOrigin)
203
250
  },
204
251
  async scheduled(_controller: ScheduledController, env: Env): Promise<void> {
205
252
  if ((env.QUOTA_GUARD_ENABLED ?? 'false').toLowerCase() === 'true') {
@@ -59,7 +59,9 @@ export function shardOfNonce(nonce: string): string {
59
59
 
60
60
  /**
61
61
  * Generate a cryptographically random 48-character hex string (24 bytes of entropy). Matches
62
- * the Rust gateway's `random_nonce()` entropy so conformance vectors apply to both.
62
+ * the Rust gateway's `random_nonce()` entropy so conformance vectors apply to both. Nonces are
63
+ * **ASCII by contract** — this hex plus an ASCII `<region>.` prefix — and the client's
64
+ * `decodeAccessProof` enforces ASCII, so the proof-token base64 never carries non-ASCII bytes.
63
65
  *
64
66
  * @returns A 48-character lowercase hex string.
65
67
  */
package/src/verify.ts CHANGED
@@ -190,6 +190,14 @@ export async function verifyAccessRequest(
190
190
  return { ok: false, denied: 'BadSignature' }
191
191
  }
192
192
 
193
+ // Canonicalise the address once the signature is proven, then use ONLY the normalised form for
194
+ // on-chain comparisons and the returned value. The client emits the raw caller address; on-chain
195
+ // owners are canonical (lower-case, 0x-prefixed, 64-hex), so comparing the raw string would
196
+ // fail-closed for a non-canonical input (e.g. missing leading zeros). Owning canonicalisation
197
+ // here — the security boundary — keeps the client wire format unchanged. (See conformance
198
+ // vectors `addressNormalization`; matched by the Rust gateway.)
199
+ const address = normalizeAddress(proof.address)
200
+
193
201
  // Consume the nonce exactly once (fresh, unexpired, unused) — before the chain call.
194
202
  if (!(await store.takeIfValid(proof.nonce))) {
195
203
  return { ok: false, denied: 'NonceInvalid' }
@@ -201,25 +209,25 @@ export async function verifyAccessRequest(
201
209
  }
202
210
  let ok: boolean
203
211
  try {
204
- ok = await chain.consumeTxValid(proof.consumeDigest, proof.address, cfg.gateId)
212
+ ok = await chain.consumeTxValid(proof.consumeDigest, address, cfg.gateId)
205
213
  } catch {
206
214
  return { ok: false, denied: 'ChainError' }
207
215
  }
208
216
  if (!ok) return { ok: false, denied: 'ConsumeMissing' }
209
217
  // The consume is valid on-chain; the dispatcher leases/commits this digest so the use is
210
218
  // spent only on a successful upload (and a duplicate can't double-spend it).
211
- return { ok: true, address: proof.address, redemptionKey: proof.consumeDigest }
219
+ return { ok: true, address, redemptionKey: proof.consumeDigest }
212
220
  }
213
221
 
214
222
  let ok: boolean
215
223
  try {
216
- ok = await chain.ownsNft(proof.address, cfg.nftType, cfg.gateId)
224
+ ok = await chain.ownsNft(address, cfg.nftType, cfg.gateId)
217
225
  } catch {
218
226
  return { ok: false, denied: 'ChainError' }
219
227
  }
220
228
  if (!ok) return { ok: false, denied: 'NotOwner' }
221
229
 
222
- return { ok: true, address: proof.address }
230
+ return { ok: true, address }
223
231
  }
224
232
 
225
233
  export { FLAG_ED25519, FLAG_SECP256K1, FLAG_SECP256R1, FLAG_MULTISIG, FLAG_ZKLOGIN }
package/wrangler.toml CHANGED
@@ -49,6 +49,10 @@ GATE_ID = "0x0485c1fa80e4c355c85ab99c0281a328d8fb5c60ac50ab64f10be0f8be792aba"
49
49
  SINGLE_USE = "true"
50
50
  PUBLIC_PATHS = "/v1/tip-config"
51
51
  RATE_LIMIT_PER_MIN = "30"
52
+ # Unauthenticated public paths (/v1/tip-config) are protected separately: a higher per-client-IP
53
+ # request ceiling (cheap GETs) plus an edge cache so floods are absorbed before reaching the relay.
54
+ PUBLIC_RATE_LIMIT_PER_MIN = "120"
55
+ PUBLIC_CACHE_TTL_SECS = "60"
52
56
  MAX_BODY_BYTES = "104857600"
53
57
  CHALLENGE_TTL_SECS = "300"
54
58
  OWNERSHIP_CACHE_TTL_MS = "0"
@@ -64,6 +68,9 @@ NONCE_BACKEND = "durable-object" # "durable-object" (default) | "kv"
64
68
  NONCE_SHARD = "region" # "region" (default) | "global"
65
69
  NONCE_MAX_ENTRIES = "1000000"
66
70
  QUOTA_GUARD_ENABLED = "false"
71
+ # Comma-separated list of browser origins allowed to make cross-origin requests.
72
+ # Only these origins receive Access-Control-Allow-Origin in responses.
73
+ ALLOWED_ORIGINS = "https://sui-walrus.meddleware.co.uk,https://dash.meddleware.co.uk"
67
74
 
68
75
  # ── nonce store + rate limiter: region-sharded, SQLite-backed Durable Objects (free-tier) ────
69
76
  [[durable_objects.bindings]]