| 1 | /** |
| 2 | * Private, unverified merch launch opt-ins, separate from orders and payment. |
| 3 | * Real KV and the native rate limiter are mandatory: no in-memory success path. |
| 4 | * A new submission replaces that email's choices and expires after 180 days. |
| 5 | * No email verification, campaign delivery, public counts, or inventory promise. |
| 6 | * KV is eventually consistent; the maintainer list may lag a successful write. |
| 7 | */ |
| 8 | import { readBoundedBody, BodyReadError } from "../bounded-body"; |
| 9 | import { getAgentEnv, validateSession, type CommunityAgentEnv } from "../community-agent"; |
| 10 | import { isValidLocale } from "../i18n/config"; |
| 11 | import { getEnv, type KVNamespace } from "../kv"; |
| 12 | import { MERCH_INTEREST_ITEMS, MERCH_INTEREST_PRICE_CHOICES, type MerchInterestPriceChoice, type MerchInterestCurrency } from "./interest-options"; |
| 13 | |
| 14 | const PREFIX = "private:merch-interest:v1:"; |
| 15 | const RETENTION_SECONDS = 180 * 24 * 60 * 60; |
| 16 | const MAX_BODY_BYTES = 8192; |
| 17 | type PriceChoice = MerchInterestPriceChoice; |
| 18 | type Currency = MerchInterestCurrency; |
| 19 | type Pick = { id: string; priceChoice: PriceChoice; surveyAmount: number | null }; |
| 20 | export type MerchInterest = { |
| 21 | schemaVersion: 1; |
| 22 | email: string; |
| 23 | country: string; |
| 24 | currency: Currency; |
| 25 | locale: string; |
| 26 | consent: true; |
| 27 | emailVerified: false; |
| 28 | submittedAt: string; |
| 29 | expiresAt: string; |
| 30 | picks: Pick[]; |
| 31 | }; |
| 32 | export interface MerchInterestEnv { |
| 33 | CURATED_KV?: KVNamespace; |
| 34 | MERCH_INTEREST_LIMITER?: { limit(options: { key: string }): Promise<{ success: boolean }> }; |
| 35 | MERCH_INTEREST_SITE_ORIGIN?: string; |
| 36 | } |
| 37 | class InterestError extends Error { |
| 38 | constructor(readonly status: number, readonly code: string, message: string) { super(message); } |
| 39 | } |
| 40 | function json(data: unknown, status = 200) { |
| 41 | return Response.json(data, { status, headers: { "Cache-Control": "no-store", ...(status === 429 ? { "Retry-After": "60" } : {}) } }); |
| 42 | } |
| 43 | function invalid(): never { throw new InterestError(422, "invalid_request", "Check the interest form and try again."); } |
| 44 | function object(value: unknown): Record<string, unknown> { |
| 45 | if (!value || typeof value !== "object" || Array.isArray(value)) invalid(); |
| 46 | return value as Record<string, unknown>; |
| 47 | } |
| 48 | async function body(request: Request) { |
| 49 | const mediaType = request.headers.get("content-type")?.split(";", 1)[0].trim().toLowerCase(); |
| 50 | if (mediaType !== "application/json") throw new InterestError(415, "invalid_request", "Use the interest form on the website."); |
| 51 | const bytes = await readBoundedBody(request, MAX_BODY_BYTES); |
| 52 | try { return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown; } |
| 53 | catch { invalid(); } |
| 54 | } |
| 55 | function loopback(url: URL) { return ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname); } |
| 56 | export function interestOriginAllowed(request: Request, env: MerchInterestEnv, development = process.env.NODE_ENV === "development") { |
| 57 | const origin = request.headers.get("origin"); |
| 58 | if (!origin) return false; |
| 59 | try { |
| 60 | const requestUrl = new URL(request.url), incoming = new URL(origin); |
| 61 | if (incoming.origin !== origin) return false; |
| 62 | if (development && loopback(requestUrl) && origin === requestUrl.origin) return true; |
| 63 | if (env.MERCH_INTEREST_SITE_ORIGIN) { |
| 64 | const configured = new URL(env.MERCH_INTEREST_SITE_ORIGIN); |
| 65 | if (configured.origin !== env.MERCH_INTEREST_SITE_ORIGIN || origin !== configured.origin) return false; |
| 66 | // OpenNext's local production preview also needs its real bindings. An |
| 67 | // explicit loopback origin remains confined to that same local request. |
| 68 | if (loopback(configured)) return ["http:", "https:"].includes(configured.protocol) && requestUrl.origin === configured.origin; |
| 69 | return configured.protocol === "https:"; |
| 70 | } |
| 71 | return origin === "https://codewhale.net" || origin === "https://www.codewhale.net"; |
| 72 | } catch { return false; } |
| 73 | } |
| 74 | function requireOrigin(request: Request, env: MerchInterestEnv) { |
| 75 | if (!interestOriginAllowed(request, env)) throw new InterestError(403, "request_blocked", "Use the interest form on the website."); |
| 76 | } |
| 77 | function available(env: MerchInterestEnv) { return Boolean(env.CURATED_KV && env.MERCH_INTEREST_LIMITER); } |
| 78 | function requireStorage(env: MerchInterestEnv): KVNamespace { |
| 79 | if (!available(env)) throw new InterestError(503, "interest_unavailable", "The interest list is temporarily unavailable. Please try again later."); |
| 80 | return env.CURATED_KV!; |
| 81 | } |
| 82 | async function digest(value: string) { |
| 83 | const bytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)); |
| 84 | return [...new Uint8Array(bytes)].map(value => value.toString(16).padStart(2, "0")).join(""); |
| 85 | } |
| 86 | async function limited(env: MerchInterestEnv, key: string) { |
| 87 | const result = await env.MERCH_INTEREST_LIMITER!.limit({ key }); |
| 88 | if (!result.success) throw new InterestError(429, "try_later", "Please wait a minute and try again."); |
| 89 | } |
| 90 | function validated(value: unknown, now: number): MerchInterest { |
| 91 | const input = object(value); |
| 92 | if (input.website !== undefined && input.website !== "") invalid(); |
| 93 | if (typeof input.email !== "string" || input.email.length > 254) invalid(); |
| 94 | const email = input.email.trim().toLowerCase(); |
| 95 | if (!/^[^\s@<>\x00-\x1f\x7f]+@[^\s@<>\x00-\x1f\x7f]+\.[^\s@<>\x00-\x1f\x7f]+$/.test(email)) invalid(); |
| 96 | if (typeof input.country !== "string" || !input.country.trim() || input.country.length > 80 || /[\x00-\x1f\x7f]/.test(input.country)) invalid(); |
| 97 | if (input.currency !== "cny" && input.currency !== "usd") invalid(); |
| 98 | if (typeof input.locale !== "string" || input.locale.length > 16 || !isValidLocale(input.locale) || input.consent !== true) invalid(); |
| 99 | if (!Array.isArray(input.picks) || !input.picks.length || input.picks.length > Math.min(12, MERCH_INTEREST_ITEMS.length)) invalid(); |
| 100 | const seen = new Set<string>(), currency = input.currency; |
| 101 | const picks = input.picks.map(value => { |
| 102 | const pick = object(value); |
| 103 | if (typeof pick.id !== "string" || seen.has(pick.id) || Object.keys(pick).some(key => !["id", "priceChoice"].includes(key))) invalid(); |
| 104 | const item = MERCH_INTEREST_ITEMS.find(item => item.id === pick.id); |
| 105 | const priceChoice = pick.priceChoice ?? "unsure"; |
| 106 | if (!item || !MERCH_INTEREST_PRICE_CHOICES.includes(priceChoice as PriceChoice)) invalid(); |
| 107 | seen.add(pick.id); |
| 108 | const index = ["low", "mid", "high"].indexOf(priceChoice as string); |
| 109 | const surveyAmount = index === -1 ? null : item.prices[currency][index]; |
| 110 | if (surveyAmount !== null && (!Number.isFinite(surveyAmount) || surveyAmount <= 0)) throw new InterestError(503, "interest_unavailable", "The interest list is temporarily unavailable."); |
| 111 | return { id: pick.id, priceChoice: priceChoice as PriceChoice, surveyAmount }; |
| 112 | }); |
| 113 | return { schemaVersion: 1, email, country: input.country.trim(), currency, locale: input.locale, consent: true, emailVerified: false, submittedAt: new Date(now).toISOString(), expiresAt: new Date(now + RETENTION_SECONDS * 1000).toISOString(), picks }; |
| 114 | } |
| 115 | function errorResponse(error: unknown) { |
| 116 | if (error instanceof InterestError) return json({ error: error.message, code: error.code }, error.status); |
| 117 | if (error instanceof BodyReadError) return json({ error: "The interest form is too large or invalid.", code: "invalid_request" }, error.status); |
| 118 | // Never echo provider errors, keys, addresses, or request payloads. |
| 119 | return json({ error: "The interest list is temporarily unavailable. Please try again later.", code: "interest_unavailable" }, 503); |
| 120 | } |
| 121 | export async function handleMerchInterest(request: Request, env?: MerchInterestEnv): Promise<Response> { |
| 122 | try { |
| 123 | const current = env ?? await getEnv(); |
| 124 | if (request.method === "GET") return json({ available: available(current) }); |
| 125 | if (request.method !== "POST") return json({ error: "Method not allowed." }, 405); |
| 126 | requireOrigin(request, current); |
| 127 | const kv = requireStorage(current); |
| 128 | // Cloudflare supplies this trusted header at the edge. Missing headers share |
| 129 | // one bucket; X-Forwarded-For and user-provided identifiers are not accepted. |
| 130 | const ip = request.headers.get("cf-connecting-ip")?.slice(0, 64) ?? "unattributed"; |
| 131 | await limited(current, "merch-interest:ip:" + await digest(ip)); |
| 132 | const record = validated(await body(request), Date.now()); |
| 133 | const emailHash = await digest(record.email); |
| 134 | await limited(current, "merch-interest:email:" + emailHash); |
| 135 | await kv.put(PREFIX + emailHash, JSON.stringify(record), { expirationTtl: RETENTION_SECONDS }); |
| 136 | return json({ ok: true }); |
| 137 | } catch (error) { return errorResponse(error); } |
| 138 | } |
| 139 | async function requireAdmin(request: Request, auth: CommunityAgentEnv) { |
| 140 | const sid = request.headers.get("cookie")?.split(";").map(cookie => cookie.trim()).find(cookie => cookie.startsWith("mt_sid="))?.slice(7); |
| 141 | if (!auth.MAINTAINER_TOKEN || !await validateSession(auth.CURATED_KV, sid)) throw new InterestError(401, "unauthorized", "Maintainer sign-in is required."); |
| 142 | } |
| 143 | export async function handleAdminMerchInterest(request: Request, env?: MerchInterestEnv, authEnv?: CommunityAgentEnv): Promise<Response> { |
| 144 | try { |
| 145 | const current = env ?? await getEnv(); |
| 146 | await requireAdmin(request, authEnv ?? await getAgentEnv()); |
| 147 | if (!current.CURATED_KV) throw new InterestError(503, "interest_unavailable", "Interest storage is unavailable."); |
| 148 | const kv = current.CURATED_KV; |
| 149 | if (request.method === "GET") { |
| 150 | const url = new URL(request.url), cursor = url.searchParams.get("cursor") ?? undefined; |
| 151 | const rawLimit = url.searchParams.get("limit") ?? "50"; |
| 152 | if ((cursor && (cursor.length > 2048 || /[\x00-\x1f\x7f]/.test(cursor))) || !/^\d{1,2}$/.test(rawLimit)) invalid(); |
| 153 | const limit = Number(rawLimit); |
| 154 | if (limit < 1 || limit > 50) invalid(); |
| 155 | const listed = await kv.list({ prefix: PREFIX, limit, ...(cursor ? { cursor } : {}) }); |
| 156 | const entries = await Promise.all(listed.keys.slice(0, limit).map(async ({ name }) => { |
| 157 | if (!name.startsWith(PREFIX) || !/^[a-f0-9]{64}$/.test(name.slice(PREFIX.length))) return null; |
| 158 | const raw = await kv.get(name); |
| 159 | if (!raw || raw.length > MAX_BODY_BYTES) return null; |
| 160 | try { |
| 161 | const record = JSON.parse(raw) as MerchInterest; |
| 162 | if (record.schemaVersion !== 1 || Date.parse(record.expiresAt) <= Date.now() || !Number.isFinite(Date.parse(record.expiresAt))) return null; |
| 163 | return { id: name.slice(PREFIX.length), ...record }; |
| 164 | } catch { return null; } |
| 165 | })); |
| 166 | return json({ entries: entries.filter(entry => entry !== null), cursor: listed.list_complete === false ? listed.cursor ?? null : null }); |
| 167 | } |
| 168 | if (request.method === "DELETE") { |
| 169 | requireOrigin(request, current); |
| 170 | const input = object(await body(request)); |
| 171 | if (typeof input.id !== "string" || !/^[a-f0-9]{64}$/.test(input.id)) invalid(); |
| 172 | await kv.delete(PREFIX + input.id); |
| 173 | return json({ ok: true }); |
| 174 | } |
| 175 | return json({ error: "Method not allowed." }, 405); |
| 176 | } catch (error) { return errorResponse(error); } |
| 177 | } |
| 178 |