@meddleware/nft-gate-gateway 0.0.8 → 0.0.9
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 +13 -8
- package/src/config.ts +31 -0
- package/src/cors.ts +25 -9
- package/src/index.ts +53 -6
- package/src/state/types.ts +3 -1
- package/src/verify.ts +12 -4
- package/wrangler.toml +7 -0
package/package.json
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
|
-
|
|
1
|
+
{
|
|
2
2
|
"name": "@meddleware/nft-gate-gateway",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.9",
|
|
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": [
|
|
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.
|
|
29
|
+
"@meddleware/nft-gate-client": "^0.0.9",
|
|
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.
|
|
32
|
-
"typescript": "
|
|
33
|
-
"vitest": "~4.1.
|
|
34
|
-
"wrangler": "
|
|
36
|
+
"@cloudflare/workers-types": "~5.20260920.1",
|
|
37
|
+
"typescript": "^6.0.0",
|
|
38
|
+
"vitest": "~4.1.0",
|
|
39
|
+
"wrangler": "^4.135.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
|
|
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
|
-
*
|
|
21
|
-
*
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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') {
|
package/src/state/types.ts
CHANGED
|
@@ -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,
|
|
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
|
|
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(
|
|
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
|
|
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://sui.meddleware.co.uk"
|
|
67
74
|
|
|
68
75
|
# ── nonce store + rate limiter: region-sharded, SQLite-backed Durable Objects (free-tier) ────
|
|
69
76
|
[[durable_objects.bindings]]
|