// Turns the Cloudflare environment into the server's context. Misconfiguration fails closed: // a missing or mismatched secret refuses requests instead of guessing. import { SETTINGS, type Cluster } from '../card.config.ts' import { addressesFor } from './addresses.ts' import { loadCosigner, type Ctx } from './card.ts' import { allow, allowInMemory, clientIp, isSameOrigin, json, tooMany } from './http.ts' import { createRpc } from './rpc.ts' export interface CardEnv { /** "devnet" or "mainnet" (wrangler.toml [vars]). */ CLUSTER?: string /** Secret. The Helius RPC URL with its key. Devnet falls back to the public endpoint. */ RPC_URL?: string /** "1" when RPC_URL speaks Helius DAS (searchAssets). */ RPC_DAS?: string /** Secret. The co-signer's 64-byte keypair as a JSON array; it holds no funds or authority. */ COSIGNER_SECRET?: string /** Rate limits and pause flags (migrations/0001_card.sql). */ DB?: D1Database /** Secret. Keys the rate-limit hash of client IPs. Required everywhere but localnet. */ RATE_SALT?: string /** * Comma-separated hosts the API answers on, e.g. "card.glitchonsol.com". Required on mainnet: * every old deployment stays reachable at its own .pages.dev address with the same * secrets, and must not keep co-signing after a fix or a change of burn amounts. */ ALLOWED_HOSTS?: string } export class ConfigError extends Error {} // The pause flags, read once per isolate every FLAGS_TTL_MS: a rush of sends would otherwise read D1 // on every request. A pause therefore takes up to two seconds longer to apply. const FLAGS_TTL_MS = 2000 const flagCache = new WeakMap }>() async function readFlags(db: D1Database): Promise> { const hit = flagCache.get(db) if (hit && Date.now() - hit.at < FLAGS_TTL_MS) return hit.flags const rows = await db.prepare("SELECT key FROM flags WHERE value = '1'").all<{ key: string }>() const flags = new Set(rows.results.map((r) => r.key)) flagCache.set(db, { at: Date.now(), flags }) return flags } /** `flags: false` for calls that never look at the pause flags (status polls), so they cost no D1 read. */ export async function contextFor(env: CardEnv, opts: { flags?: boolean } = {}): Promise { const cluster = env.CLUSTER as Cluster if (cluster !== 'devnet' && cluster !== 'mainnet' && cluster !== 'localnet') throw new ConfigError('CLUSTER must be devnet, mainnet or localnet') const url = env.RPC_URL ?? (cluster === 'devnet' ? 'https://api.devnet.solana.com' : null) if (cluster === 'localnet' && !/^http:\/\/127\.0\.0\.1:\d+\/?$/.test(url ?? '')) throw new ConfigError('localnet only talks to a local RPC') if (!url) throw new ConfigError('RPC_URL is not set') // Over plain http the Helius key travels in the clear, and anyone on the way could answer for Solana. // (A local one, as the CLI also allows, can't be reached from Cloudflare anyway.) if (cluster !== 'localnet' && !/^https:\/\//.test(url) && !/^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?(\/|$)/.test(url)) throw new ConfigError('RPC_URL must be https://') // The public devnet endpoint doesn't speak DAS: RPC_DAS=1 without our own RPC_URL would break every wallet listing. if (env.RPC_DAS === '1' && !env.RPC_URL) throw new ConfigError('RPC_DAS=1 needs RPC_URL (Helius)') if (!env.DB) throw new ConfigError('DB binding missing') // The devnet rehearsal is public too: its rate-limit hashes must not be reversible to IPs either. if (cluster !== 'localnet' && !env.RATE_SALT) throw new ConfigError(`${cluster} needs the RATE_SALT secret`) if (cluster === 'mainnet') { // Without DAS the wallet listing would scan every mpl-core account on mainnet. if (env.RPC_DAS !== '1') throw new ConfigError('mainnet needs RPC_DAS=1 (Helius)') if (!env.ALLOWED_HOSTS) throw new ConfigError('mainnet needs ALLOWED_HOSTS') } const flags = opts.flags === false ? new Set() : await readFlags(env.DB) return { cluster, settings: SETTINGS[cluster], addr: addressesFor(cluster), rpc: createRpc(url), now: new Date(), flags, das: env.RPC_DAS === '1', } } export async function cosignerFor(env: CardEnv, ctx: Ctx) { const signer = await loadCosigner(env.COSIGNER_SECRET) if (!signer || signer.address !== ctx.addr.cosigner) throw new ConfigError('COSIGNER_SECRET missing or does not match addresses file') return signer } export function configFailure(err: ConfigError): Response { console.error('card config:', err.message) return json({ error: 'The card app is not configured right. Nothing was charged.', code: 'not-ready' }, 503) } /** * Shared front door for the API: same site only, then a per-IP limit. Returns the refusal to send, * or null to go ahead. There is deliberately no per-wallet limit: a request only names a wallet, so * anyone could spend another wallet's allowance and lock it out of a sale. What one wallet can mint * is capped on-chain, and a transaction built for it is useless without its own signature. */ export async function gate(request: Request, env: CardEnv, scope: string, perIp: number, store: 'd1' | 'memory' = 'd1'): Promise { if (!isSameOrigin(request)) return json({ error: 'forbidden', code: 'forbidden' }, 403) if (!hostAllowed(request, env)) return json({ error: 'Use the card app at its published address.', code: 'forbidden' }, 403) // Checked before anything is hashed or counted: only localnet ever stores an unkeyed IP hash. if (!env.DB || (env.CLUSTER !== 'localnet' && !env.RATE_SALT)) return json({ error: 'The card app is not configured right.', code: 'not-ready' }, 503) if (store === 'memory') return (await allowInMemory(scope, clientIp(request), perIp, env.RATE_SALT)) ? null : tooMany() try { if (!(await allow(env.DB, scope, clientIp(request), perIp, env.RATE_SALT))) return tooMany() } catch (err) { console.error('rate limit failed:', String(err)) return json({ error: 'Busy. Try again in a minute.', code: 'unavailable' }, 503) } return null } /** With ALLOWED_HOSTS set, only those hosts get answers (see CardEnv). Unset: any host (dev). */ export function hostAllowed(request: Request, env: CardEnv): boolean { if (!env.ALLOWED_HOSTS) return true const host = new URL(request.url).hostname return env.ALLOWED_HOSTS.split(',').map((h) => h.trim()).includes(host) }