From 6cbf8eaed9a46247eefd4b464721e4b356a1e4d6 Mon Sep 17 00:00:00 2001 From: ramseywiz Date: Fri, 5 Jun 2026 17:30:28 -0500 Subject: [PATCH 1/7] checkin --- src/db/checkin.ts | 66 ++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 7 ++++- src/routes/checkin.ts | 67 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 src/db/checkin.ts create mode 100644 src/routes/checkin.ts diff --git a/src/db/checkin.ts b/src/db/checkin.ts new file mode 100644 index 0000000..a9bd965 --- /dev/null +++ b/src/db/checkin.ts @@ -0,0 +1,66 @@ +import type { SupabaseClient } from "@supabase/supabase-js"; + +export interface CheckinTokenRow { + token_id: string; + event_id: string; + expires_at: string; +} + +export async function findCheckinToken( + client: SupabaseClient, + token: string, +): Promise { + const { data, error } = await client + .from("event_checkin_tokens") + .select("token_id, event_id, expires_at") + .eq("token", token) + .single(); + + if (error || !data) return null; + return data as CheckinTokenRow; +} + +export async function findExistingCheckin( + client: SupabaseClient, + eventId: string, + contactId: string, +): Promise { + const { data, error } = await client + .from("event_checkins") + .select("checkin_id") + .eq("event_id", eventId) + .eq("contact_id", contactId) + .maybeSingle(); + + if (error) return false; + return data !== null; +} + +export async function insertCheckin( + client: SupabaseClient, + eventId: string, + contactId: string, +): Promise<{ checkin_id: string; checked_in_at: string } | null> { + const { data, error } = await client + .from("event_checkins") + .insert({ event_id: eventId, contact_id: contactId }) + .select("checkin_id, checked_in_at") + .single(); + + if (error || !data) return null; + return data as { checkin_id: string; checked_in_at: string }; +} + +export async function getEventName( + client: SupabaseClient, + eventId: string, +): Promise { + const { data, error } = await client + .from("events") + .select("event_name") + .eq("id", eventId) + .single(); + + if (error || !data) return null; + return (data as { event_name: string }).event_name; +} diff --git a/src/index.ts b/src/index.ts index ed9f187..d951642 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,12 +19,13 @@ import { postExperienceHandler, type RouteRequest, } from "./routes/profile"; +import { postCheckinHandler } from "./routes/checkin"; import type { Env } from "./types/env"; const router = Router({ base: "/api" }); const allowedOrigins = new Set([ - "http://localhost:5173", + "http://localhost:5174", "https://member.cougarcs.com", ]); @@ -140,6 +141,10 @@ router.delete("/profile/experience/:experienceId", requireAuth, requireRole("mem deleteExperienceHandler(request as RouteRequest, createClient(env)), ); +router.post("/checkin", requireAuth, requireRole("member", "officer", "admin"), (request: Request, env: Env) => + postCheckinHandler(request as AuthenticatedRequest, createClient(env)), +); + router.all("*", () => new Response("Not Found", { status: 404 })); export default { diff --git a/src/routes/checkin.ts b/src/routes/checkin.ts new file mode 100644 index 0000000..28bdbe8 --- /dev/null +++ b/src/routes/checkin.ts @@ -0,0 +1,67 @@ +import type { SupabaseClient } from "@supabase/supabase-js"; +import type { AuthenticatedRequest } from "../middleware/auth"; +import { findContactByUserId } from "../db/profile"; +import { + findCheckinToken, + findExistingCheckin, + insertCheckin, + getEventName, +} from "../db/checkin"; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +export async function postCheckinHandler( + request: AuthenticatedRequest, + client: SupabaseClient, +): Promise { + // Parse and validate request body + let token: string; + try { + const body = (await request.json()) as { token?: unknown }; + if (typeof body.token !== "string" || !body.token.trim()) { + return jsonResponse({ error: "Missing token" }, 400); + } + token = body.token.trim(); + } catch { + return jsonResponse({ error: "Invalid request body" }, 400); + } + + // Resolve contact_id from the user's JWT + const contactId = await findContactByUserId(client, request.userId); + if (!contactId) { + return jsonResponse({ error: "Contact not found for this user" }, 404); + } + + // Look up the token and verify it hasn't expired + const tokenRow = await findCheckinToken(client, token); + if (!tokenRow || new Date(tokenRow.expires_at) <= new Date()) { + return jsonResponse({ error: "Token expired or not found" }, 400); + } + + const { event_id: eventId } = tokenRow; + + // Reject duplicate check-ins + const alreadyCheckedIn = await findExistingCheckin(client, eventId, contactId); + if (alreadyCheckedIn) { + return jsonResponse({ error: "Already checked in" }, 409); + } + + // Record the check-in + const checkin = await insertCheckin(client, eventId, contactId); + if (!checkin) { + return jsonResponse({ error: "Failed to record check-in" }, 500); + } + + // Fetch the event name for the confirmation response + const eventName = await getEventName(client, eventId); + + return jsonResponse({ + event_name: eventName ?? "Event", + checked_in_at: checkin.checked_in_at, + }); +} From da107afee0b424f5f60a35e20da1b1a1f496bf07 Mon Sep 17 00:00:00 2001 From: ramseywiz Date: Sat, 6 Jun 2026 11:16:02 -0500 Subject: [PATCH 2/7] added name --- src/routes/checkin.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/routes/checkin.ts b/src/routes/checkin.ts index 28bdbe8..7f06dc1 100644 --- a/src/routes/checkin.ts +++ b/src/routes/checkin.ts @@ -48,7 +48,8 @@ export async function postCheckinHandler( // Reject duplicate check-ins const alreadyCheckedIn = await findExistingCheckin(client, eventId, contactId); if (alreadyCheckedIn) { - return jsonResponse({ error: "Already checked in" }, 409); + const eventName = await getEventName(client, eventId); + return jsonResponse({ error: "Already checked in", event_name: eventName ?? "Event" }, 409); } // Record the check-in From 7b73577da2713d1eb1dbf059a268ae6a4abfd235 Mon Sep 17 00:00:00 2001 From: ramseywiz Date: Sat, 6 Jun 2026 11:17:09 -0500 Subject: [PATCH 3/7] no double call lol --- src/routes/checkin.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/routes/checkin.ts b/src/routes/checkin.ts index 7f06dc1..8770845 100644 --- a/src/routes/checkin.ts +++ b/src/routes/checkin.ts @@ -44,11 +44,11 @@ export async function postCheckinHandler( } const { event_id: eventId } = tokenRow; + const eventName = getEventName(client, eventId); // Reject duplicate check-ins const alreadyCheckedIn = await findExistingCheckin(client, eventId, contactId); if (alreadyCheckedIn) { - const eventName = await getEventName(client, eventId); return jsonResponse({ error: "Already checked in", event_name: eventName ?? "Event" }, 409); } @@ -59,7 +59,6 @@ export async function postCheckinHandler( } // Fetch the event name for the confirmation response - const eventName = await getEventName(client, eventId); return jsonResponse({ event_name: eventName ?? "Event", From 0c029993c51bdd52be556cc595bb568510afda21 Mon Sep 17 00:00:00 2001 From: ramseywiz Date: Thu, 11 Jun 2026 11:29:23 -0500 Subject: [PATCH 4/7] bugfix --- src/routes/checkin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/checkin.ts b/src/routes/checkin.ts index 8770845..15e83fe 100644 --- a/src/routes/checkin.ts +++ b/src/routes/checkin.ts @@ -44,7 +44,7 @@ export async function postCheckinHandler( } const { event_id: eventId } = tokenRow; - const eventName = getEventName(client, eventId); + const eventName = await getEventName(client, eventId); // Reject duplicate check-ins const alreadyCheckedIn = await findExistingCheckin(client, eventId, contactId); From f8eb499f2dc9539b02b1adcf481dd3cd884278f4 Mon Sep 17 00:00:00 2001 From: ramseywiz Date: Thu, 11 Jun 2026 11:31:53 -0500 Subject: [PATCH 5/7] package update --- package-lock.json | 181 +++++++++++++++++++++++++++++----------------- package.json | 2 +- 2 files changed, 116 insertions(+), 67 deletions(-) diff --git a/package-lock.json b/package-lock.json index 36928f8..c0aaaf5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "zod": "^4.3.6" }, "devDependencies": { - "@cloudflare/vitest-pool-workers": "^0.15.0", + "@cloudflare/vitest-pool-workers": "^0.16.15", "@types/node": "^25.6.0", "typescript": "^5.5.2", "vitest": "^4.1.5", @@ -21,13 +21,13 @@ } }, "node_modules/@cloudflare/kv-asset-handler": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.2.tgz", - "integrity": "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", "dev": true, "license": "MIT OR Apache-2.0", "engines": { - "node": ">=18.0.0" + "node": ">=22.0.0" } }, "node_modules/@cloudflare/unenv-preset": { @@ -47,17 +47,17 @@ } }, "node_modules/@cloudflare/vitest-pool-workers": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@cloudflare/vitest-pool-workers/-/vitest-pool-workers-0.15.0.tgz", - "integrity": "sha512-RldzOt2az3mxICTxT7GTSBpm6f61lx4LWSilRHm4pJlYAGmfGu1pyinqJw3UmPZS9N/mrN7XwdZAqFV6hhmWaQ==", + "version": "0.16.15", + "resolved": "https://registry.npmjs.org/@cloudflare/vitest-pool-workers/-/vitest-pool-workers-0.16.15.tgz", + "integrity": "sha512-R0kZhIm4uSxOeTWPHY9xYIFPGRBEHPzl/n9BbHZSY/gk0n16uDU7T1JZe372oTF+diXG1uVBWqiiRc7Hxstdow==", "dev": true, "license": "MIT", "dependencies": { - "cjs-module-lexer": "^1.2.3", + "cjs-module-lexer": "1.2.3", "esbuild": "0.27.3", - "miniflare": "4.20260424.0", - "wrangler": "4.85.0", - "zod": "^3.25.76" + "miniflare": "4.20260611.0", + "wrangler": "4.100.0", + "zod": "3.25.76" }, "peerDependencies": { "@vitest/runner": "^4.1.0", @@ -76,9 +76,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20260424.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260424.1.tgz", - "integrity": "sha512-yFR1XaJbSDLg/qbwtrYaU2xwFXatIPKR5nrMQCN1q/m6+Qe/j6r+kCnFEvOJjMZOm9iCKsE6Qly5clgl4u32qw==", + "version": "1.20260611.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260611.1.tgz", + "integrity": "sha512-iJICldmi4sBGgi7IrQles8cStOGXM/Tmv95C4OODVs6VIbMsJPqThUM5h3uYVQNULuJ8I/aVvnJ3Eh/wZCKwuA==", "cpu": [ "x64" ], @@ -93,9 +93,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20260424.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260424.1.tgz", - "integrity": "sha512-LqWKcE7x/9KyC2iQvKPeb20hKST3dYXDZlYTvFymgR1DfLS0OFOCzVGTloVNd7WqvK4SkdzBYfxo7QMIAeBK0w==", + "version": "1.20260611.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260611.1.tgz", + "integrity": "sha512-yBbVXvbZyltR3I7NJdC4C4ItkItjZSiabcA/3HzEWOUQjLVKFqRh4so6ToHr70VCYh8VGeR8EDZL23igLhXqFQ==", "cpu": [ "arm64" ], @@ -110,9 +110,9 @@ } }, "node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20260424.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260424.1.tgz", - "integrity": "sha512-YlEBFbAYZHe/ylzl8WEYQEU/jr+0XMqXaST2oBk5oVjksdb1NGuJaggluCdZAzuJJ8UqdTmyhY5u/qrasbiFWA==", + "version": "1.20260611.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260611.1.tgz", + "integrity": "sha512-PfNjpxOlaIgZFYuhD7+neEEewCN2Ud993wEEN0fmbtSOax1AK53LGqmXUDvFhnbkHxJLFAxYCSNISW8QbzaAIg==", "cpu": [ "x64" ], @@ -127,9 +127,9 @@ } }, "node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20260424.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260424.1.tgz", - "integrity": "sha512-qJ0X0m6cL8fWDUPDg8K4IxYZXNJI6XbeOihqjnqKbAClrjdPDn8VUSd+z2XiCQ5NylMtMrpa/skC9UfaR6mh8g==", + "version": "1.20260611.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260611.1.tgz", + "integrity": "sha512-GEp4XbuIKjlF8pakqXcUDJfKiJosD/Q7S83J0d+r+z9XIlYGfF3ntm08e2aiF5TFTwp3fnG4yMoPUAKNhNJpvQ==", "cpu": [ "arm64" ], @@ -144,9 +144,9 @@ } }, "node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20260424.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260424.1.tgz", - "integrity": "sha512-tZ7Z9qmYNAP6z1/+8r/zKbk8F8DZmpmwNzMeN+zkde2Wnhfr3FBqOkJXT/5zmli8HPoWrIXxSiyqcNDMy8V2Zg==", + "version": "1.20260611.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260611.1.tgz", + "integrity": "sha512-S6JkS0kEbcCKs19RGqEPhjCRbP8GBkQwqYLp2fhBJtD/KTlwqLzOJ9E6PQ7gQKgWHtxy1NBG3oXarlNFRNU/dw==", "cpu": [ "x64" ], @@ -747,6 +747,9 @@ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -764,6 +767,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -781,6 +787,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -798,6 +807,9 @@ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -815,6 +827,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -832,6 +847,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -849,6 +867,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -866,6 +887,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -883,6 +907,9 @@ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -906,6 +933,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -929,6 +959,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -952,6 +985,9 @@ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -975,6 +1011,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -998,6 +1037,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1021,6 +1063,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1044,6 +1089,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1503,9 +1551,9 @@ } }, "node_modules/@speed-highlight/core": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.15.tgz", - "integrity": "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.16.tgz", + "integrity": "sha512-yNm/fYEcnpRjYduLMaddTK9XKYil6xB88+qFg79ZdZhHu1PadfoQmFW7pVTx7FZqMBNcUuThiAhxhENgtAO2/w==", "dev": true, "license": "CC0-1.0" }, @@ -1574,9 +1622,9 @@ } }, "node_modules/@supabase/realtime-js/node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -1818,9 +1866,9 @@ } }, "node_modules/cjs-module-lexer": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", - "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz", + "integrity": "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==", "dev": true, "license": "MIT" }, @@ -2264,24 +2312,24 @@ } }, "node_modules/miniflare": { - "version": "4.20260424.0", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260424.0.tgz", - "integrity": "sha512-B6MKBBd5TJ19daUc3Ae9rWctn1nDA/VCXykXfCsp9fTxyfGxnZY27tJs1caxgE9MWEMMKGbGHouqVtgKbKGxmw==", + "version": "4.20260611.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260611.0.tgz", + "integrity": "sha512-i+JwEo8vN96naz1WL3ntFgFyRluBDYL408zwhHKvR2jefJ464KsZ/gCmJAQ5k+oaWeb5Ug+s7yne5AyiAEswjg==", "dev": true, "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "0.8.1", - "sharp": "^0.34.5", + "sharp": "0.34.5", "undici": "7.24.8", - "workerd": "1.20260424.1", - "ws": "8.18.0", + "workerd": "1.20260611.1", + "ws": "8.20.1", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" }, "engines": { - "node": ">=18.0.0" + "node": ">=22.0.0" } }, "node_modules/nanoid": { @@ -2412,9 +2460,9 @@ } }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", "dev": true, "license": "ISC", "bin": { @@ -2789,9 +2837,9 @@ } }, "node_modules/workerd": { - "version": "1.20260424.1", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260424.1.tgz", - "integrity": "sha512-oKsB0Xo/mfkYMdSACoS06XZg09VUK4rXwHfF/1t3P++sMbwzf4UHQvMO57+zxpEB2nVrY/ZkW0bYFGq4GdAFSQ==", + "version": "1.20260611.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260611.1.tgz", + "integrity": "sha512-CS/640T7pIJ2HYX6x2DwKFGbcSckAWN3tgcdq+ptB6SaqjWUhlzIgA/YhPuwIU+/NnMnGpqOFX/hC18Oyge63w==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -2802,41 +2850,42 @@ "node": ">=16" }, "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20260424.1", - "@cloudflare/workerd-darwin-arm64": "1.20260424.1", - "@cloudflare/workerd-linux-64": "1.20260424.1", - "@cloudflare/workerd-linux-arm64": "1.20260424.1", - "@cloudflare/workerd-windows-64": "1.20260424.1" + "@cloudflare/workerd-darwin-64": "1.20260611.1", + "@cloudflare/workerd-darwin-arm64": "1.20260611.1", + "@cloudflare/workerd-linux-64": "1.20260611.1", + "@cloudflare/workerd-linux-arm64": "1.20260611.1", + "@cloudflare/workerd-windows-64": "1.20260611.1" } }, "node_modules/wrangler": { - "version": "4.85.0", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.85.0.tgz", - "integrity": "sha512-93cwt2RPb1qdcmEgPzH7ybiLN4BIKoWpscIX6SywjHrQOeIZrQk2haoc3XMLKtQTmzapxza9OuDD+kMHpsuuhg==", + "version": "4.100.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.100.0.tgz", + "integrity": "sha512-dSQO7DO+mD6XDzkVWIWBoGLO3yw+lacWSc/KhFvd7pgfpth+kX98qb5SGRHZN8ACCDhhfwzDLXwB6qHsIHhfBg==", "dev": true, "license": "MIT OR Apache-2.0", "dependencies": { - "@cloudflare/kv-asset-handler": "0.4.2", + "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", - "miniflare": "4.20260424.0", + "miniflare": "4.20260611.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", - "workerd": "1.20260424.1" + "workerd": "1.20260611.1" }, "bin": { + "cf-wrangler": "bin/cf-wrangler.js", "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" }, "engines": { - "node": ">=20.3.0" + "node": ">=22.0.0" }, "optionalDependencies": { - "fsevents": "~2.3.2" + "fsevents": "2.3.3" }, "peerDependencies": { - "@cloudflare/workers-types": "^4.20260424.1" + "@cloudflare/workers-types": "^4.20260611.1" }, "peerDependenciesMeta": { "@cloudflare/workers-types": { @@ -2845,9 +2894,9 @@ } }, "node_modules/ws": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index aa673f8..57fa758 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "cf-typegen": "wrangler types" }, "devDependencies": { - "@cloudflare/vitest-pool-workers": "^0.15.0", + "@cloudflare/vitest-pool-workers": "^0.16.15", "@types/node": "^25.6.0", "typescript": "^5.5.2", "vitest": "^4.1.5", From 95dcbdb86d27e1d50433024bc6ba3db51859293c Mon Sep 17 00:00:00 2001 From: ramseywiz Date: Tue, 16 Jun 2026 14:18:48 -0500 Subject: [PATCH 6/7] working guest checkin --- src/db/checkin.ts | 84 +++- src/index.ts | 16 +- src/routes/checkin.ts | 195 ++++++-- src/types/env.ts | 2 + worker-configuration.d.ts | 990 ++++++++++++++++++++++++++++++++++++-- wrangler.jsonc | 13 + 6 files changed, 1211 insertions(+), 89 deletions(-) diff --git a/src/db/checkin.ts b/src/db/checkin.ts index a9bd965..0cc8bab 100644 --- a/src/db/checkin.ts +++ b/src/db/checkin.ts @@ -6,6 +6,21 @@ export interface CheckinTokenRow { expires_at: string; } +interface CheckinRow { + checkin_id: string; + checked_in_at: string; +} + +interface EventNameRow { + event_name: string; +} + +function throwIfError(error: unknown): void { + if (error) { + throw error; + } +} + export async function findCheckinToken( client: SupabaseClient, token: string, @@ -14,41 +29,62 @@ export async function findCheckinToken( .from("event_checkin_tokens") .select("token_id, event_id, expires_at") .eq("token", token) - .single(); + .maybeSingle(); - if (error || !data) return null; - return data as CheckinTokenRow; + throwIfError(error); + + return data as CheckinTokenRow | null; } -export async function findExistingCheckin( +export async function insertCheckin( client: SupabaseClient, eventId: string, contactId: string, -): Promise { +): Promise { const { data, error } = await client .from("event_checkins") - .select("checkin_id") - .eq("event_id", eventId) - .eq("contact_id", contactId) - .maybeSingle(); + .insert({ event_id: eventId, contact_id: contactId }) + .select("checkin_id, checked_in_at") + .single(); - if (error) return false; - return data !== null; + // 23505 = Postgres unique violation: the UNIQUE (event_id, contact_id) + // constraint was hit. Treat as duplicate, not a server error. + if (error?.code === "23505") return "duplicate"; + throwIfError(error); + + return data as CheckinRow; } -export async function insertCheckin( +export interface AttendeeInsert { + event_id: string; + token_id: string; + psid: number; + email: string; + first_name: string; + last_name: string | null; +} + +interface AttendeeRow { + attendee_id: string; + checked_in_at: string; +} + +export async function insertAttendee( client: SupabaseClient, - eventId: string, - contactId: string, -): Promise<{ checkin_id: string; checked_in_at: string } | null> { + attendee: AttendeeInsert, +): Promise { const { data, error } = await client - .from("event_checkins") - .insert({ event_id: eventId, contact_id: contactId }) - .select("checkin_id, checked_in_at") + .from("event_attendees") + .insert(attendee) + .select("attendee_id, checked_in_at") .single(); - if (error || !data) return null; - return data as { checkin_id: string; checked_in_at: string }; + // 23505 = unique violation on UNIQUE (event_id, psid): this guest already + // checked in for this event. Treat as duplicate, not a server error. + if (error?.code === "23505") return "duplicate"; + throwIfError(error); + + return data as AttendeeRow; } export async function getEventName( @@ -59,8 +95,10 @@ export async function getEventName( .from("events") .select("event_name") .eq("id", eventId) - .single(); + .maybeSingle(); + + throwIfError(error); - if (error || !data) return null; - return (data as { event_name: string }).event_name; + const row = data as EventNameRow | null; + return row?.event_name ?? null; } diff --git a/src/index.ts b/src/index.ts index d951642..b5f025f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,7 +19,7 @@ import { postExperienceHandler, type RouteRequest, } from "./routes/profile"; -import { postCheckinHandler } from "./routes/checkin"; +import { postCheckinHandler, postGuestCheckinHandler } from "./routes/checkin"; import type { Env } from "./types/env"; const router = Router({ base: "/api" }); @@ -145,6 +145,20 @@ router.post("/checkin", requireAuth, requireRole("member", "officer", "admin"), postCheckinHandler(request as AuthenticatedRequest, createClient(env)), ); +// Unauthenticated guest check-in. Rate limited per client IP (CF-Connecting-IP +// is set by Cloudflare and not client-spoofable) before any work is done. +router.post("/checkin/guest", async (request: Request, env: Env) => { + const ip = request.headers.get("CF-Connecting-IP") ?? "unknown"; + const { success } = await env.GUEST_CHECKIN_RATE_LIMITER.limit({ key: ip }); + if (!success) { + return new Response(JSON.stringify({ error: "rate_limited" }), { + status: 429, + headers: { "Content-Type": "application/json" }, + }); + } + return postGuestCheckinHandler(request, createClient(env)); +}); + router.all("*", () => new Response("Not Found", { status: 404 })); export default { diff --git a/src/routes/checkin.ts b/src/routes/checkin.ts index 15e83fe..09c45ee 100644 --- a/src/routes/checkin.ts +++ b/src/routes/checkin.ts @@ -3,15 +3,21 @@ import type { AuthenticatedRequest } from "../middleware/auth"; import { findContactByUserId } from "../db/profile"; import { findCheckinToken, - findExistingCheckin, insertCheckin, + insertAttendee, getEventName, } from "../db/checkin"; +const PSID_MIN = 1_000_000; +const PSID_MAX = 9_999_999; +const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; + function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + }, }); } @@ -19,49 +25,166 @@ export async function postCheckinHandler( request: AuthenticatedRequest, client: SupabaseClient, ): Promise { - // Parse and validate request body - let token: string; try { - const body = (await request.json()) as { token?: unknown }; - if (typeof body.token !== "string" || !body.token.trim()) { - return jsonResponse({ error: "Missing token" }, 400); + // Parse and validate request body + let token: string; + try { + const body = (await request.json()) as { token?: unknown }; + if (typeof body.token !== "string" || !body.token.trim()) { + return jsonResponse({ error: "Missing token" }, 400); + } + token = body.token.trim(); + } catch { + return jsonResponse({ error: "Invalid request body" }, 400); } - token = body.token.trim(); - } catch { - return jsonResponse({ error: "Invalid request body" }, 400); - } - // Resolve contact_id from the user's JWT - const contactId = await findContactByUserId(client, request.userId); - if (!contactId) { - return jsonResponse({ error: "Contact not found for this user" }, 404); - } + // Resolve contact_id from the user's JWT + const contactId = await findContactByUserId(client, request.userId); + if (!contactId) { + return jsonResponse({ error: "no_contact" }, 404); + } - // Look up the token and verify it hasn't expired - const tokenRow = await findCheckinToken(client, token); - if (!tokenRow || new Date(tokenRow.expires_at) <= new Date()) { - return jsonResponse({ error: "Token expired or not found" }, 400); - } + // Look up the token and verify it hasn't expired + const tokenRow = await findCheckinToken(client, token); + if (!tokenRow || new Date(tokenRow.expires_at) <= new Date()) { + return jsonResponse({ error: "expired" }, 400); + } + + const { event_id: eventId } = tokenRow; + const eventName = await getEventName(client, eventId); - const { event_id: eventId } = tokenRow; - const eventName = await getEventName(client, eventId); + // Insert the check-in; the UNIQUE (event_id, contact_id) constraint handles + // both normal and concurrent duplicates — no pre-check needed. + const checkin = await insertCheckin(client, eventId, contactId); + if (checkin === "duplicate") { + return jsonResponse( + { error: "Already checked in", event_name: eventName ?? "Event" }, + 409, + ); + } - // Reject duplicate check-ins - const alreadyCheckedIn = await findExistingCheckin(client, eventId, contactId); - if (alreadyCheckedIn) { - return jsonResponse({ error: "Already checked in", event_name: eventName ?? "Event" }, 409); + return jsonResponse({ + event_name: eventName ?? "Event", + checked_in_at: checkin.checked_in_at, + }); + } catch (error) { + console.error("Checkin error:", error); + return jsonResponse({ error: "Internal server error" }, 500); } +} - // Record the check-in - const checkin = await insertCheckin(client, eventId, contactId); - if (!checkin) { - return jsonResponse({ error: "Failed to record check-in" }, 500); +interface GuestCheckinBody { + token?: unknown; + psid?: unknown; + email?: unknown; + firstName?: unknown; + lastName?: unknown; +} + +interface ValidGuestInput { + token: string; + psid: number; + email: string; + firstName: string; + lastName: string | null; +} + +/** Returns the cleaned input, or an error message string if invalid. */ +function validateGuestBody(body: GuestCheckinBody): ValidGuestInput | string { + if (typeof body.token !== "string" || !body.token.trim()) { + return "Token is required."; + } + if ( + typeof body.psid !== "number" || + !Number.isInteger(body.psid) || + body.psid < PSID_MIN || + body.psid > PSID_MAX + ) { + return "PSID must be a 7-digit number."; + } + if (typeof body.email !== "string" || !EMAIL_RE.test(body.email.trim())) { + return "A valid email is required."; + } + if ( + typeof body.firstName !== "string" || + !body.firstName.trim() || + body.firstName.trim().length > 100 + ) { + return "First name must be 1–100 characters."; + } + if ( + body.lastName != null && + (typeof body.lastName !== "string" || body.lastName.trim().length > 100) + ) { + return "Last name must be 100 characters or fewer."; } - // Fetch the event name for the confirmation response + return { + token: body.token.trim(), + psid: body.psid, + email: body.email.trim().toLowerCase(), + firstName: body.firstName.trim(), + lastName: + typeof body.lastName === "string" && body.lastName.trim() + ? body.lastName.trim() + : null, + }; +} - return jsonResponse({ - event_name: eventName ?? "Event", - checked_in_at: checkin.checked_in_at, - }); +/** + * Unauthenticated guest (non-member) check-in. The event is derived solely + * from the token — never from the request body — so a token for event A + * can't be used to check into event B. Writes a pending row to + * event_attendees; officers reconcile it to a member later via pigeon-api. + * + * Deliberately performs no contact lookup: the response is identical whether + * or not the PSID/email belongs to a member, so the endpoint is not a + * membership oracle. + */ +export async function postGuestCheckinHandler( + request: Request, + client: SupabaseClient, +): Promise { + try { + let body: GuestCheckinBody; + try { + body = (await request.json()) as GuestCheckinBody; + } catch { + return jsonResponse({ error: "Invalid request body" }, 400); + } + + const input = validateGuestBody(body); + if (typeof input === "string") { + return jsonResponse({ error: input }, 400); + } + + const tokenRow = await findCheckinToken(client, input.token); + if (!tokenRow || new Date(tokenRow.expires_at) <= new Date()) { + return jsonResponse({ error: "expired" }, 400); + } + + const eventName = await getEventName(client, tokenRow.event_id); + + const result = await insertAttendee(client, { + event_id: tokenRow.event_id, + token_id: tokenRow.token_id, + psid: input.psid, + email: input.email, + first_name: input.firstName, + last_name: input.lastName, + }); + + if (result === "duplicate") { + return jsonResponse({ status: "already", event_name: eventName ?? "Event" }); + } + + return jsonResponse({ + status: "ok", + event_name: eventName ?? "Event", + checked_in_at: result.checked_in_at, + }); + } catch (error) { + console.error("Guest checkin error:", error); + return jsonResponse({ error: "Internal server error" }, 500); + } } diff --git a/src/types/env.ts b/src/types/env.ts index 0039fa2..f4f6626 100644 --- a/src/types/env.ts +++ b/src/types/env.ts @@ -1,4 +1,6 @@ export interface Env { SUPABASE_URL: string; SUPABASE_SECRET_KEY: string; + /** Rate Limiting binding (configured in wrangler.jsonc) for guest check-in. */ + GUEST_CHECKIN_RATE_LIMITER: RateLimit; } diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index 19623b3..38ad21f 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -1,16 +1,18 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: 611619a263dbc0adde97d88c63e5804d) -// Runtime types generated with workerd@1.20260424.1 2026-04-16 nodejs_compat +// Generated by Wrangler by running `wrangler types` (hash: ea04e63827d82b930a709f4215550ce1) +// Runtime types generated with workerd@1.20260611.1 2026-04-16 nodejs_compat +interface __BaseEnv_Env { + GUEST_CHECKIN_RATE_LIMITER: RateLimit; + SUPABASE_URL: string; + SUPABASE_SECRET_KEY: string; +} declare namespace Cloudflare { interface GlobalProps { mainModule: typeof import("./src/index"); } - interface Env { - SUPABASE_URL: string; - SUPABASE_SECRET_KEY: string; - } + interface Env extends __BaseEnv_Env {} } -interface Env extends Cloudflare.Env {} +interface Env extends __BaseEnv_Env {} type StringifyValues> = { [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; }; @@ -923,7 +925,7 @@ interface CustomEventCustomEventInit { * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob) */ declare class Blob { - constructor(type?: ((ArrayBuffer | ArrayBufferView) | string | Blob)[], options?: BlobOptions); + constructor(bits?: ((ArrayBuffer | ArrayBufferView) | string | Blob)[], options?: BlobOptions); /** * The **`size`** read-only property of the Blob interface returns the size of the Blob or File in bytes. * @@ -2010,7 +2012,7 @@ interface R2ListOptions { startAfter?: string; include?: ("httpMetadata" | "customMetadata")[]; } -declare abstract class R2Bucket { +interface R2Bucket { head(key: string): Promise; get(key: string, options: R2GetOptions & { onlyIf: R2Conditional | Headers; @@ -3490,6 +3492,229 @@ declare abstract class Span { get isTraced(): boolean; setAttribute(key: string, value?: (boolean | number | string)): void; } +// ============================================================================ +// Agent Memory +// +// Public type surface for user Workers binding to an Agent Memory namespace. +// ============================================================================ +/** Memory type — every memory is classified into exactly one. */ +type AgentMemoryMemoryType = "fact" | "event" | "instruction" | "task"; +/** Search intensity for recall. */ +type AgentMemoryThinkingLevel = "low" | "medium" | "high"; +/** Response verbosity for recall. */ +type AgentMemoryResponseLength = "short" | "medium" | "long"; +/** A conversation message passed to ingest(). */ +interface AgentMemoryMessage { + role: "system" | "user" | "assistant"; + content: string; + /** Optional message timestamp. */ + timestamp?: Date; +} +/** Raw memory content passed to remember(). */ +interface AgentMemoryIncomingMemory { + /** Raw memory content. The service classifies and summarizes automatically. */ + content: string; + /** Optional session identifier to associate with this memory. */ + sessionId?: string | null | undefined; +} +/** A stored memory returned from remember(), get(), and delete(). */ +interface AgentMemoryMemory { + /** Memory ID. */ + id: string; + /** Memory type. */ + type: AgentMemoryMemoryType; + /** Text summary. */ + summary: string; + /** Memory text. */ + content: string; + /** Session that created this memory. */ + sessionId: string | null; + /** Memory creation time. */ + createdAt: Date; + /** Memory last-update time. */ + updatedAt: Date; +} +/** Single entry in a list() response. Same shape as Memory minus full content. */ +type AgentMemoryMemoryListEntry = Omit; +/** A scored memory candidate in a recall result. */ +interface AgentMemoryScoredCandidate { + /** Candidate ID. */ + id: string; + /** Text summary. */ + summary: string; + /** Session that created this candidate, when known. */ + sessionId: string | null; + /** Relevance score (higher is better). Comparable only within a single query. */ + score: number; +} +/** Options for the ingest() method. */ +interface AgentMemoryIngestOptions { + /** Session identifier to associate with memories created during ingestion. */ + sessionId?: string | null | undefined; +} +/** Options for the getSummary() method. */ +interface AgentMemoryGetSummaryOptions { + /** Session identifier to retrieve session summary for. */ + sessionId?: string | null | undefined; +} +/** Response from the getSummary() method. */ +interface AgentMemoryGetSummaryResponse { + /** Markdown summary. */ + summary: string; +} +/** + * Options for the recall() method. + * + * `referenceDate` accepts a Date object, an ISO-8601 date string + * (YYYY-MM-DD), or a full ISO-8601 datetime string. When provided, this + * date is used as "today" for resolving relative time references + * ("how many days ago", "last week") instead of the server's wall-clock time. + */ +interface AgentMemoryRecallOptions { + /** Recall intensity: "low" (default), "medium", or "high". */ + thinkingLevel?: AgentMemoryThinkingLevel; + /** Response verbosity: "short", "medium" (default), or "long". */ + responseLength?: AgentMemoryResponseLength; + /** Temporal anchor for date arithmetic. */ + referenceDate?: Date | string; +} +/** Response from the recall() method. */ +interface AgentMemoryRecallResult { + /** Number of memories retrieved. */ + count: number; + /** LLM-generated answer synthesizing the matching memories. */ + answer: string; + /** Matching memories ranked by relevance. */ + candidates: AgentMemoryScoredCandidate[]; +} +/** + * Options for the list() method. + * + * `cursor` is the opaque continuation token returned by the previous page; + * pass it back unchanged to fetch the next page. `sessionId` and `type` + * are exact-match filters; combining them is allowed. + */ +interface AgentMemoryListMemoriesOptions { + /** Maximum number of memories to return. Default 20, max 500. */ + limit?: number; + /** Opaque cursor from a previous page. */ + cursor?: string; + /** Exact-match session filter. */ + sessionId?: string; + /** Exact-match memory-type filter. */ + type?: AgentMemoryMemoryType; +} +/** Response from the list() method. */ +interface AgentMemoryListMemoriesResult { + memories: AgentMemoryMemoryListEntry[]; + /** Continuation cursor; absent when this page exhausted the result set. */ + cursor?: string; +} +/** + * A single Agent Memory profile, scoped to a profile name. + * + * Returned by {@link AgentMemoryNamespace.getProfile}. + */ +declare abstract class AgentMemoryProfile { + /** + * Retrieve a memory by ID. + * + * @param memoryId - ULID of the memory to retrieve. + * @throws if the memory does not exist. + */ + get(memoryId: string): Promise; + /** + * Delete a memory by ID. + * + * Removes the memory and any source messages linked by the memory's + * source message IDs. + * + * @param memoryId - ULID of the memory to delete. + * @throws if the memory does not exist. + */ + delete(memoryId: string): Promise; + /** + * Store a memory in this profile. The content is automatically classified, + * summarized, and indexed. + * + * @param memory - Raw memory content to persist. + */ + remember(memory: AgentMemoryIncomingMemory): Promise; + /** + * Extract memories from a conversation. + * + * @param messages - Conversation messages to extract memories from. + * @param options - Optional ingest options. + */ + ingest(messages: Iterable, options?: AgentMemoryIngestOptions): Promise; + /** + * Get a profile summary. + * + * @param options - Optional getSummary options. + */ + getSummary(options?: AgentMemoryGetSummaryOptions): Promise; + /** + * Recall memories in this profile. + * + * @param query - Recall query matched against memory content and keywords. + * @param options - Optional recall parameters. + * @returns Matching memories with relevance scores and a synthesized answer. + */ + recall(query: string, options?: AgentMemoryRecallOptions): Promise; + /** + * List active memories in this profile. + * + * Returns a paginated, filterable view of stored memories. Superseded + * versions are excluded. Use the returned `cursor` (when present) to + * fetch the next page. + * + * @param options - Optional pagination and filter options. + */ + list(options?: AgentMemoryListMemoriesOptions): Promise; + /** + * Soft-delete every memory and message in this profile that is tagged + * with `sessionId`. + * + * Idempotent: deleting a sessionId that has no rows is a no-op. + * + * @param sessionId - Session to delete. + */ + deleteSession(sessionId: string): Promise; +} +/** + * Namespace-level Agent Memory binding. + * + * Used as the type of an `env.MEMORY`-style binding backed by the Agent + * Memory product. + * + * @example + * ```ts + * export default { + * async fetch(_request: Request, env: Env): Promise { + * const profile = await env.MEMORY.getProfile("wrangler-e2e"); + * const summary = await profile.getSummary(); + * return Response.json(summary); + * }, + * }; + * ``` + */ +declare abstract class AgentMemoryNamespace { + /** + * Get a memory profile by name. Profiles are isolated by namespace and + * addressed by a compound key (namespaceId:profileName). + * + * @param profileName - Profile name (validated against naming rules). + * @returns RPC target for interacting with the profile. + */ + getProfile(profileName: string): Promise; + /** + * Soft-delete a profile and schedule deferred purge. Marks all + * memories and messages as deleted. + * + * @param profileName - Name of the profile to delete. + */ + deleteProfile(profileName: string): Promise; +} // ============ AI Search Error Interfaces ============ interface AiSearchInternalError extends Error { } @@ -4835,9 +5060,6 @@ type ChatCompletionChoice = { finish_reason: "stop" | "length" | "tool_calls" | "content_filter" | "function_call"; logprobs: ChatCompletionLogprobs | null; }; -type ChatCompletionsPromptInput = { - prompt: string; -} & ChatCompletionsCommonOptions; type ChatCompletionsMessagesInput = { messages: Array; } & ChatCompletionsCommonOptions; @@ -8767,11 +8989,11 @@ declare abstract class Base_Ai_Cf_Pipecat_Ai_Smart_Turn_V2 { postProcessedOutputs: Ai_Cf_Pipecat_Ai_Smart_Turn_V2_Output; } declare abstract class Base_Ai_Cf_Openai_Gpt_Oss_120B { - inputs: XOR; + inputs: XOR; postProcessedOutputs: XOR; } declare abstract class Base_Ai_Cf_Openai_Gpt_Oss_20B { - inputs: XOR; + inputs: XOR; postProcessedOutputs: XOR; } interface Ai_Cf_Leonardo_Phoenix_1_0_Input { @@ -9747,6 +9969,10 @@ declare abstract class Base_Ai_Cf_Moonshotai_Kimi_K2_5 { inputs: ChatCompletionsInput; postProcessedOutputs: ChatCompletionsOutput; } +declare abstract class Base_Ai_Cf_Moonshotai_Kimi_K2_6 { + inputs: ChatCompletionsInput; + postProcessedOutputs: ChatCompletionsOutput; +} declare abstract class Base_Ai_Cf_Nvidia_Nemotron_3_120B_A12B { inputs: ChatCompletionsInput; postProcessedOutputs: ChatCompletionsOutput; @@ -9844,7 +10070,9 @@ interface AiModels { "@cf/black-forest-labs/flux-2-klein-9b": Base_Ai_Cf_Black_Forest_Labs_Flux_2_Klein_9B; "@cf/zai-org/glm-4.7-flash": Base_Ai_Cf_Zai_Org_Glm_4_7_Flash; "@cf/moonshotai/kimi-k2.5": Base_Ai_Cf_Moonshotai_Kimi_K2_5; + "@cf/moonshotai/kimi-k2.6": Base_Ai_Cf_Moonshotai_Kimi_K2_6; "@cf/nvidia/nemotron-3-120b-a12b": Base_Ai_Cf_Nvidia_Nemotron_3_120B_A12B; + "@cf/google/gemma-4-26b-a4b-it": Base_Ai_Cf_Google_Gemma_4_26B_A4B_IT; } type AiOptions = { /** @@ -9897,10 +10125,8 @@ type AiModelsSearchObject = { value: string; }[]; }; -type ChatCompletionsBase = XOR; -type ChatCompletionsInput = XOR; +type ChatCompletionsBase = ChatCompletionsMessagesInput; +type ChatCompletionsInput = ChatCompletionsMessagesInput; interface InferenceUpstreamError extends Error { } interface AiInternalError extends Error { @@ -9945,8 +10171,15 @@ declare abstract class Ai { }, options?: AiOptions): Promise; // Normal (default) - known model run(model: Name, inputs: AiModelList[Name]['inputs'], options?: AiOptions): Promise; - // Unknown model (gateway fallback) - run(model: string & {}, inputs: Record, options?: AiOptions): Promise>; + // Unknown model (fallback). + // + // The `Exclude<..., keyof AiModelList>` constraint forces TypeScript to + // route any model name that is a literal key of `AiModelList` to one of + // the known-model overloads above (so input/output mismatches surface as + // type errors rather than silently falling back to `Record`). + // Names that aren't in `AiModelList` — e.g. third-party gateway models + // like `"google/nano-banana"` — still hit this overload. + run(model: Model extends keyof AiModelList ? never : Model, inputs: Record, options?: AiOptions): Promise>; models(params?: AiModelsSearchParams): Promise; toMarkdown(): ToMarkdownService; toMarkdown(files: MarkdownDocument[], options?: ConversionRequestOptions): Promise; @@ -10137,12 +10370,17 @@ interface ArtifactsTokenListResult { /** Total number of tokens for the repository. */ total: number; } -/** Handle for a single repository. Returned by Artifacts.get(). */ +/** + * Handle for a single repository. Returned by Artifacts.get(). + * + * Methods may throw `ArtifactsError` with code `INTERNAL_ERROR` if an unexpected service error occurs. + */ interface ArtifactsRepo extends ArtifactsRepoInfo { /** * Create an access token for this repo. * @param scope Token scope: "write" (default) or "read". * @param ttl Time-to-live in seconds (default 86400, min 60, max 31536000). + * @throws {ArtifactsError} with code `INVALID_TTL` if ttl is out of range. */ createToken(scope?: 'write' | 'read', ttl?: number): Promise; /** List tokens for this repo (metadata only, no plaintext). */ @@ -10151,6 +10389,7 @@ interface ArtifactsRepo extends ArtifactsRepoInfo { * Revoke a token by plaintext or ID. * @param tokenOrId Plaintext token or token ID. * @returns true if revoked, false if not found. + * @throws {ArtifactsError} with code `INVALID_INPUT` if tokenOrId is empty. */ revokeToken(tokenOrId: string): Promise; // ── Fork ── @@ -10158,6 +10397,9 @@ interface ArtifactsRepo extends ArtifactsRepoInfo { * Fork this repo to a new repo. * @param name Target repository name. * @param opts Optional: description, readOnly flag, defaultBranchOnly (default true). + * @throws {ArtifactsError} with code `INVALID_REPO_NAME` if name is invalid. + * @throws {ArtifactsError} with code `ALREADY_EXISTS` if the target repo already exists. + * @throws {ArtifactsError} with code `FORK_IN_PROGRESS` if a fork is already running. */ fork(name: string, opts?: { description?: string; @@ -10165,13 +10407,41 @@ interface ArtifactsRepo extends ArtifactsRepoInfo { defaultBranchOnly?: boolean; }): Promise; } -/** Artifacts binding — namespace-level operations. */ +// ── Error types ────────────────────────────────────────────────────────────── +/** + * Error codes returned by Artifacts binding operations. + * + * Each code maps to a numeric code available on `ArtifactsError.numericCode`. + */ +type ArtifactsErrorCode = 'ALREADY_EXISTS' | 'NOT_FOUND' | 'IMPORT_IN_PROGRESS' | 'FORK_IN_PROGRESS' | 'INVALID_INPUT' | 'INVALID_REPO_NAME' | 'INVALID_TTL' | 'INVALID_URL' | 'REMOTE_AUTH_REQUIRED' | 'UPSTREAM_UNAVAILABLE' | 'MEMORY_LIMIT' | 'INTERNAL_ERROR'; +/** + * Error thrown by Artifacts binding operations. + * + * Uses a string `.code` discriminator following the Cloudflare platform + * convention (StreamError, ImagesError, etc.). The `.numericCode` matches + * the REST API `errors[].code` values. + */ +interface ArtifactsError extends Error { + readonly name: 'ArtifactsError'; + /** String error code for programmatic matching. */ + readonly code: ArtifactsErrorCode; + /** Numeric error code matching the REST API. */ + readonly numericCode: number; +} +// ── Binding ────────────────────────────────────────────────────────────────── +/** + * Artifacts binding — namespace-level operations. + * + * Methods may throw `ArtifactsError` with code `INTERNAL_ERROR` if an unexpected service error occurs. + */ interface Artifacts { /** * Create a new repository with an initial access token. * @param name Repository name (alphanumeric, dots, hyphens, underscores). * @param opts Optional: readOnly flag, description, default branch name. * @returns Repo metadata with initial token. + * @throws {ArtifactsError} with code `INVALID_REPO_NAME` if name is invalid. + * @throws {ArtifactsError} with code `ALREADY_EXISTS` if the repo already exists. */ create(name: string, opts?: { readOnly?: boolean; @@ -10182,12 +10452,23 @@ interface Artifacts { * Get a handle to an existing repository. * @param name Repository name. * @returns Repo handle. + * @throws {ArtifactsError} with code `NOT_FOUND` if the repo does not exist. + * @throws {ArtifactsError} with code `IMPORT_IN_PROGRESS` if the repo is still importing. + * @throws {ArtifactsError} with code `FORK_IN_PROGRESS` if the repo is still forking. */ get(name: string): Promise; /** * Import a repository from an external git remote. * @param params Source URL and optional branch/depth, plus target name and options. * @returns Repo metadata with initial token. + * @throws {ArtifactsError} with code `INVALID_REPO_NAME` if the target name is invalid. + * @throws {ArtifactsError} with code `INVALID_INPUT` if the source URL is not valid HTTPS. + * @throws {ArtifactsError} with code `INVALID_URL` if the source URL does not point to a git repository. + * @throws {ArtifactsError} with code `REMOTE_AUTH_REQUIRED` if the remote requires authentication. + * @throws {ArtifactsError} with code `NOT_FOUND` if the remote repository does not exist. + * @throws {ArtifactsError} with code `UPSTREAM_UNAVAILABLE` if the remote cannot be reached. + * @throws {ArtifactsError} with code `MEMORY_LIMIT` if the import exceeds service memory limits. + * @throws {ArtifactsError} with code `ALREADY_EXISTS` if the target repo already exists. */ import(params: { source: { @@ -10215,6 +10496,7 @@ interface Artifacts { * Delete a repository and all associated tokens. * @param name Repository name. * @returns true if deleted, false if not found. + * @throws {ArtifactsError} with code `INVALID_REPO_NAME` if name is invalid. */ delete(name: string): Promise; } @@ -10355,6 +10637,452 @@ declare abstract class AutoRAG { */ aiSearch(params: AutoRagAiSearchRequest): Promise; } +type BrowserRunLifecycleEvent = 'load' | 'domcontentloaded' | 'networkidle0' | 'networkidle2'; +type BrowserRunResourceType = 'document' | 'stylesheet' | 'image' | 'media' | 'font' | 'script' | 'texttrack' | 'xhr' | 'fetch' | 'prefetch' | 'eventsource' | 'websocket' | 'manifest' | 'signedexchange' | 'ping' | 'cspviolationreport' | 'preflight' | 'other'; +/** Options fields shared by all quick actions. */ +interface BrowserRunBaseOptions { + /** Adds `