diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 81a59a5..d48427a 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -29,7 +29,9 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e with: - node-version: 20 + # pnpm 10.32.1 requires Node >= 22.13 (it loads node:sqlite); the + # deployed runtime stays Node 20 via Vercel / package.json engines. + node-version: 22 cache: pnpm cache-dependency-path: pnpm-lock.yaml diff --git a/api/_shared/rate-limit.ts b/api/_shared/rate-limit.ts index 049e657..f1ace30 100644 --- a/api/_shared/rate-limit.ts +++ b/api/_shared/rate-limit.ts @@ -38,15 +38,6 @@ const buckets = globalRateLimitState.__mediaSmartRateLimitBuckets ?? (globalRateLimitState.__mediaSmartRateLimitBuckets = new Map()); -function normalizeUserAgent(headers: IncomingHttpHeaders) { - const userAgent = headers['user-agent']; - const raw = Array.isArray(userAgent) ? userAgent[0] : userAgent; - - return typeof raw === 'string' - ? raw.trim().toLowerCase().slice(0, 160) - : ''; -} - function maybeCleanupBuckets(now: number) { const lastCleanup = globalRateLimitState.__mediaSmartRateLimitLastCleanup ?? 0; const shouldCleanupByAge = now - lastCleanup >= CLEANUP_INTERVAL_MS; @@ -77,14 +68,12 @@ function maybeCleanupBuckets(now: number) { } export function getRateLimitIdentifier(headers: IncomingHttpHeaders) { - const clientIp = extractClientIp(headers); - const userAgent = normalizeUserAgent(headers); - - if (clientIp && userAgent) { - return `${clientIp}:${userAgent}`; - } - - return clientIp || userAgent || 'anonymous'; + // Key on the client IP only. On Vercel `x-forwarded-for` is overwritten with + // the real client IP and client-supplied values are not forwarded, so the IP + // is trustworthy. We deliberately do NOT fold in the User-Agent: it is fully + // client-controlled, so mixing it into the key let an attacker mint a fresh + // rate-limit bucket on every request just by rotating the header. + return extractClientIp(headers) || 'anonymous'; } export function checkRateLimit({ diff --git a/api/booking/_lib/d1.ts b/api/booking/_lib/d1.ts index 0033d5d..4b137d8 100644 --- a/api/booking/_lib/d1.ts +++ b/api/booking/_lib/d1.ts @@ -33,6 +33,14 @@ export class D1Error extends Error { } } +// SQLite (D1) surfaces a partial-unique-index violation as a +// "UNIQUE constraint failed: ..." error. Callers use this to turn a lost race +// for a booking slot into a clean 409 instead of a 500. +export function isUniqueConstraintError(err: unknown): boolean { + const message = err instanceof Error ? err.message : String(err); + return /UNIQUE constraint failed/i.test(message); +} + async function rawQuery( sql: string, params: Array, diff --git a/api/booking/_lib/mailer.ts b/api/booking/_lib/mailer.ts index 18e274f..d700d7f 100644 --- a/api/booking/_lib/mailer.ts +++ b/api/booking/_lib/mailer.ts @@ -60,7 +60,23 @@ function formatIcsDate(date: Date): string { } function escapeIcs(value: string): string { - return value.replace(/\\/g, '\\\\').replace(/,/g, '\\,').replace(/;/g, '\\;').replace(/\n/g, '\\n'); + return value + .replace(/\\/g, '\\\\') + .replace(/,/g, '\\,') + .replace(/;/g, '\\;') + .replace(/\r\n|\r|\n/g, '\\n'); +} + +function quoteIcsParam(value: string): string { + // RFC 5545 param values are DQUOTE-wrapped and must not contain DQUOTE or any + // control character (C0 range + DEL). + const stripped = Array.from(value) + .filter((char) => { + const code = char.codePointAt(0) ?? 0; + return code >= 0x20 && code !== 0x7f && char !== String.fromCharCode(0x22); + }) + .join(""); + return `"${stripped}"`; } function buildIcs(input: SendInputs): string { @@ -90,7 +106,7 @@ function buildIcs(input: SendInputs): string { ), input.meetLink ? `LOCATION:${escapeIcs(input.meetLink)}` : 'LOCATION:Online', `ORGANIZER;CN=MediaSmart:mailto:${NOTIFICATION_EMAIL}`, - `ATTENDEE;CN=${escapeIcs(input.attendeeName)};RSVP=TRUE:mailto:${input.attendeeEmail}`, + `ATTENDEE;CN=${quoteIcsParam(input.attendeeName)};RSVP=TRUE:mailto:${input.attendeeEmail}`, 'END:VEVENT', 'END:VCALENDAR', ]; diff --git a/api/booking/_lib/tokens.ts b/api/booking/_lib/tokens.ts index bfe2ced..5d0c311 100644 --- a/api/booking/_lib/tokens.ts +++ b/api/booking/_lib/tokens.ts @@ -2,35 +2,71 @@ import { createHmac, randomUUID, timingSafeEqual } from 'crypto'; import { getRuntimeEnv } from './config'; -// Cancellation / reschedule links are stateless: each booking gets a token -// derived from `HMAC(secret, bookingId + ":" + purpose)`. Storing nothing -// extra in the DB means we can't accidentally leak tokens via a DB dump and -// rotating the HMAC secret invalidates every outstanding link at once. +// Cancellation / reschedule links are stateless-ish: each booking gets a token +// derived from `HMAC(secret, bookingId + ":" + purpose + ":" + exp + ":" + version)`. +// +// Two properties are bound into the signed message so a leaked link is not a +// permanent, unrevocable bearer credential: +// - `exp` — a unix-seconds expiry. verifyToken refuses expired tokens, so +// a link that leaks (forwarded email, referrer, proxy log) only +// grants access for a bounded window. +// - `version` — the booking's `token_version` column. Rescheduling bumps it, +// which invalidates every previously-issued link for that +// booking without touching the global HMAC secret. +// +// The wire format is `${exp}.${signature}` where signature is base64url. The +// server still stores nothing token-specific beyond the small integer +// token_version, so a DB dump cannot leak usable tokens. type TokenPurpose = 'cancel' | 'reschedule'; -function compute(bookingId: string, purpose: TokenPurpose): string { +// Manage links stay valid comfortably past the 28-day booking horizon so a +// visitor can always cancel/reschedule up to (and shortly after) their slot. +const MANAGE_TOKEN_TTL_MS = 45 * 24 * 60 * 60 * 1000; + +function compute( + bookingId: string, + purpose: TokenPurpose, + exp: number, + version: number, +): string { const secret = getRuntimeEnv().bookingSecret; return createHmac('sha256', secret) - .update(`${bookingId}:${purpose}`) + .update(`${bookingId}:${purpose}:${exp}:${version}`) .digest('base64url'); } -export function generateToken(bookingId: string, purpose: TokenPurpose): string { - return compute(bookingId, purpose); +export function generateToken( + bookingId: string, + purpose: TokenPurpose, + version: number, + ttlMs: number = MANAGE_TOKEN_TTL_MS, +): string { + const exp = Math.floor((Date.now() + ttlMs) / 1000); + return `${exp}.${compute(bookingId, purpose, exp, version)}`; } export function verifyToken( bookingId: string, purpose: TokenPurpose, + version: number, candidate: string, ): boolean { - const expected = compute(bookingId, purpose); + const dot = candidate.indexOf('.'); + if (dot <= 0) return false; + + const expStr = candidate.slice(0, dot); + const signature = candidate.slice(dot + 1); + const exp = Number(expStr); + // Reject non-integer or already-expired tokens before touching the HMAC. + if (!Number.isInteger(exp) || exp * 1000 < Date.now()) return false; + + const expected = compute(bookingId, purpose, exp, version); // Constant-time comparison guards against the timing side-channel that // string equality would expose. Length mismatch is its own short-circuit // because timingSafeEqual throws otherwise. const a = Buffer.from(expected); - const b = Buffer.from(candidate); + const b = Buffer.from(signature); if (a.length !== b.length) return false; return timingSafeEqual(a, b); } diff --git a/api/booking/cancel.ts b/api/booking/cancel.ts index a7f9618..baadeff 100644 --- a/api/booking/cancel.ts +++ b/api/booking/cancel.ts @@ -24,6 +24,7 @@ interface BookingRow { start_at: number; status: string; calendar_event_id: string | null; + token_version: number; } export default async function handler(req: ApiRequest, res: ApiResponse) { @@ -59,18 +60,17 @@ export default async function handler(req: ApiRequest, res: ApiResponse) { return res.status(400).json({ success: false, message: 'Missing id or token' }); } - if (!verifyToken(id, 'cancel', token)) { - return res.status(403).json({ success: false, message: 'Invalid token' }); - } - + // Fetch the row first: the token is versioned, so verification needs the + // booking's current token_version. A missing row and a bad token return the + // same 403 so an outsider cannot probe which booking ids exist. const row = await queryFirst( - `SELECT id, attendee_name, attendee_email, attendee_language, start_at, status, calendar_event_id + `SELECT id, attendee_name, attendee_email, attendee_language, start_at, status, calendar_event_id, token_version FROM bookings WHERE id = ?`, [id], ); - if (!row) { - return res.status(404).json({ success: false, message: 'Booking not found' }); + if (!row || !verifyToken(id, 'cancel', row.token_version, token)) { + return res.status(403).json({ success: false, message: 'Invalid token' }); } // Already cancelled = success (idempotent). Saves us reasoning about double diff --git a/api/booking/create.ts b/api/booking/create.ts index 5271521..dc7979c 100644 --- a/api/booking/create.ts +++ b/api/booking/create.ts @@ -4,19 +4,32 @@ import { checkRateLimit, getRateLimitIdentifier, } from '../_shared/rate-limit'; +import recaptcha from '../_shared/recaptcha.js'; import { BOOKING_TIMEZONE, MEETING_DURATION_MIN, getRuntimeEnv, } from './_lib/config'; -import { exec, queryFirst } from './_lib/d1'; -import { createEvent, getBusyIntervals } from './_lib/google-calendar'; +import { exec, isUniqueConstraintError, queryFirst } from './_lib/d1'; +import { createEvent, deleteEvent, getBusyIntervals } from './_lib/google-calendar'; import { sendBookingConfirmation } from './_lib/mailer'; import { isSlotValid } from './_lib/slots'; import { generateToken, newBookingId } from './_lib/tokens'; import { validateCreatePayload } from './_lib/validators'; +const { extractClientIp, verifyRecaptcha } = recaptcha; + +// Best-effort release of a reserved-but-not-finalised booking row. Awaited by +// callers so the DELETE actually runs before the serverless response is flushed. +async function releaseBookingRow(bookingId: string): Promise { + try { + await exec('DELETE FROM bookings WHERE id = ?', [bookingId]); + } catch (err) { + console.error('booking/create failed to release reserved slot', bookingId, err); + } +} + const CREATE_RATE_LIMIT = { limit: 5, windowMs: 30 * 60 * 1000, @@ -52,6 +65,20 @@ export default async function handler(req: ApiRequest, res: ApiResponse) { return res.status(200).json({ success: true, bookingId: 'honeypot' }); } + // Anti-automation: create is the only unauthenticated booking endpoint (the + // others require a signed manage token), so it gets reCAPTCHA v3 like the + // contact form. The honeypot above stays as cheap defense-in-depth. + const recaptchaResult = await verifyRecaptcha({ + token: (req.body as { recaptchaToken?: unknown } | undefined)?.recaptchaToken, + expectedAction: 'booking_create', + remoteIp: extractClientIp(req.headers), + }); + if (!recaptchaResult.ok) { + return res + .status(recaptchaResult.status ?? 400) + .json({ success: false, message: recaptchaResult.message }); + } + const start = parsed.value.startUtc; const end = new Date(start.getTime() + MEETING_DURATION_MIN * 60_000); @@ -89,6 +116,46 @@ export default async function handler(req: ApiRequest, res: ApiResponse) { .filter(Boolean) .join('\n\n'); + const nowMs = Math.floor(Date.now() / 1000); + const startSec = Math.floor(start.getTime() / 1000); + const endSec = Math.floor(end.getTime() / 1000); + + // 1. Claim the slot in the database FIRST. The partial unique index + // `(start_at) WHERE status='confirmed'` is the authoritative mutual- + // exclusion guarantee: if a concurrent request already booked this slot the + // insert fails and we return 409 instead of silently double-booking. + // calendar_event_id is filled in once the event below is created. + try { + await exec( + `INSERT INTO bookings ( + id, calendar_event_id, attendee_name, attendee_email, attendee_message, + attendee_language, start_at, end_at, status, token_version, created_at, updated_at + ) VALUES (?, NULL, ?, ?, ?, ?, ?, ?, 'confirmed', 0, ?, ?)`, + [ + bookingId, + parsed.value.name, + parsed.value.email, + parsed.value.message, + parsed.value.language, + startSec, + endSec, + nowMs, + nowMs, + ], + ); + } catch (err) { + if (isUniqueConstraintError(err)) { + return res + .status(409) + .json({ success: false, message: 'Slot is no longer available' }); + } + console.error('booking/create db claim failed', err); + return res.status(500).json({ success: false, message: 'Could not save booking' }); + } + + // 2. Create the calendar event now that the slot is durably reserved. On + // failure we release the claimed row (awaited) so it never lingers and the + // slot is freed for someone else. let event; try { event = await createEvent({ @@ -101,48 +168,36 @@ export default async function handler(req: ApiRequest, res: ApiResponse) { attendeeEmail: parsed.value.email, }); } catch (err) { - console.error('booking/create calendar insert failed', err); + console.error('booking/create calendar insert failed; releasing slot', err); + await releaseBookingRow(bookingId); return res.status(502).json({ success: false, message: 'Could not create event' }); } - // Persist after the calendar write so we never have a row pointing at a - // non-existent event. If the DB write fails after the event was created, - // we fall through to the catch and undo the event — better to lose a row - // than leave a phantom booking on the user's calendar. - const nowMs = Math.floor(Date.now() / 1000); + // 3. Attach the calendar event id to the reserved row. If this fails we undo + // both the event and the row (awaited) so we never leave a confirmed slot + // without a matching calendar entry — the previous fire-and-forget rollback + // could be dropped when the serverless instance froze after responding. try { - await exec( - `INSERT INTO bookings ( - id, calendar_event_id, attendee_name, attendee_email, attendee_message, - attendee_language, start_at, end_at, status, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'confirmed', ?, ?)`, - [ - bookingId, - event.id, - parsed.value.name, - parsed.value.email, - parsed.value.message, - parsed.value.language, - Math.floor(start.getTime() / 1000), - Math.floor(end.getTime() / 1000), - nowMs, - nowMs, - ], + const updateMeta = await exec( + `UPDATE bookings SET calendar_event_id = ?, updated_at = ? WHERE id = ?`, + [event.id, Math.floor(Date.now() / 1000), bookingId], ); + // D1 does not throw when the WHERE matches zero rows, so confirm the + // reserved row is still present before reporting success. + if (updateMeta.changes === 0) { + throw new Error(`reserved booking row ${bookingId} vanished before event link`); + } } catch (err) { - console.error('booking/create db write failed; rolling back event', err); - // Best-effort rollback — we don't await this because we want to respond - // quickly. A leftover event is a degraded state we'll log and recover. - void import('./_lib/google-calendar') - .then((mod) => mod.deleteEvent(event.id)) - .catch((rollbackErr) => - console.error('booking/create rollback failed', rollbackErr), - ); + console.error('booking/create db update failed; rolling back', err); + await Promise.allSettled([ + deleteEvent(event.id), + releaseBookingRow(bookingId), + ]); return res.status(500).json({ success: false, message: 'Could not save booking' }); } - const cancelToken = generateToken(bookingId, 'cancel'); - const rescheduleToken = generateToken(bookingId, 'reschedule'); + const cancelToken = generateToken(bookingId, 'cancel', 0); + const rescheduleToken = generateToken(bookingId, 'reschedule', 0); // Manage links are language-prefixed so the visitor lands on the page in // the language they used to book; the rest of the site is also localised. const langPrefix = parsed.value.language; diff --git a/api/booking/lookup.ts b/api/booking/lookup.ts index b3d143d..eda2841 100644 --- a/api/booking/lookup.ts +++ b/api/booking/lookup.ts @@ -23,6 +23,7 @@ interface BookingRow { end_at: number; status: string; calendar_event_id: string | null; + token_version: number; } // Read-only endpoint backing the `/booking/manage?id=&token=` page. The token @@ -54,19 +55,22 @@ export default async function handler(req: ApiRequest, res: ApiResponse) { return res.status(400).json({ success: false, message: 'Missing id or token' }); } - if (!verifyToken(id, 'cancel', token) && !verifyToken(id, 'reschedule', token)) { - return res.status(403).json({ success: false, message: 'Invalid token' }); - } - + // Fetch first so we can verify the versioned token against the booking's + // current token_version. A missing row and an invalid/expired token return an + // identical 403 so this endpoint cannot be used to enumerate booking ids. const row = await queryFirst( `SELECT id, attendee_name, attendee_email, attendee_message, attendee_language, - start_at, end_at, status, calendar_event_id + start_at, end_at, status, calendar_event_id, token_version FROM bookings WHERE id = ?`, [id], ); - if (!row) { - return res.status(404).json({ success: false, message: 'Booking not found' }); + if ( + !row || + (!verifyToken(id, 'cancel', row.token_version, token) && + !verifyToken(id, 'reschedule', row.token_version, token)) + ) { + return res.status(403).json({ success: false, message: 'Invalid token' }); } return res.status(200).json({ diff --git a/api/booking/reschedule.ts b/api/booking/reschedule.ts index af6b216..2e629cd 100644 --- a/api/booking/reschedule.ts +++ b/api/booking/reschedule.ts @@ -10,7 +10,7 @@ import { MEETING_DURATION_MIN, getRuntimeEnv, } from './_lib/config'; -import { exec, queryFirst } from './_lib/d1'; +import { exec, isUniqueConstraintError, queryFirst } from './_lib/d1'; import { getBusyIntervals, updateEventTime } from './_lib/google-calendar'; import { sendBookingConfirmation } from './_lib/mailer'; import { isSlotValid } from './_lib/slots'; @@ -31,6 +31,7 @@ interface BookingRow { end_at: number; status: string; calendar_event_id: string | null; + token_version: number; } export default async function handler(req: ApiRequest, res: ApiResponse) { @@ -65,25 +66,24 @@ export default async function handler(req: ApiRequest, res: ApiResponse) { return res.status(400).json({ success: false, message: 'Missing id, token, or startUtc' }); } - if (!verifyToken(id, 'reschedule', token)) { - return res.status(403).json({ success: false, message: 'Invalid token' }); - } - const newStart = new Date(startRaw); if (Number.isNaN(newStart.getTime())) { return res.status(400).json({ success: false, message: 'Invalid startUtc' }); } const newEnd = new Date(newStart.getTime() + MEETING_DURATION_MIN * 60_000); + // Fetch the row first: the token is versioned, so verification needs the + // booking's current token_version. Missing row and bad/expired token collapse + // to the same 403 (no id enumeration). const row = await queryFirst( `SELECT id, attendee_name, attendee_email, attendee_message, attendee_language, - start_at, end_at, status, calendar_event_id + start_at, end_at, status, calendar_event_id, token_version FROM bookings WHERE id = ?`, [id], ); - if (!row) { - return res.status(404).json({ success: false, message: 'Booking not found' }); + if (!row || !verifyToken(id, 'reschedule', row.token_version, token)) { + return res.status(403).json({ success: false, message: 'Invalid token' }); } if (row.status !== 'confirmed') { return res.status(409).json({ success: false, message: 'Booking is not active' }); @@ -120,27 +120,57 @@ export default async function handler(req: ApiRequest, res: ApiResponse) { }); } + const nowMs = Math.floor(Date.now() / 1000); + const newVersion = row.token_version + 1; + const newStartSec = Math.floor(newStart.getTime() / 1000); + const newEndSec = Math.floor(newEnd.getTime() / 1000); + + // 1. Claim the new slot in the DB first (partial unique index prevents two + // confirmed bookings sharing a start_at) and rotate token_version in the + // same write so previously-issued manage links stop working once the move + // succeeds. A lost race returns 409 without ever touching the calendar. + let claimMeta; + try { + claimMeta = await exec( + `UPDATE bookings SET start_at = ?, end_at = ?, token_version = ?, updated_at = ? + WHERE id = ? AND status = 'confirmed'`, + [newStartSec, newEndSec, newVersion, nowMs, id], + ); + } catch (err) { + if (isUniqueConstraintError(err)) { + return res + .status(409) + .json({ success: false, message: 'Slot is no longer available' }); + } + console.error('booking/reschedule db claim failed', err); + return res.status(500).json({ success: false, message: 'Could not update booking' }); + } + // D1 does not throw on a zero-row UPDATE: if the booking was cancelled between + // the SELECT and this claim, abort before touching the calendar or emailing. + if (claimMeta.changes === 0) { + return res.status(409).json({ success: false, message: 'Booking is not active' }); + } + + // 2. Move the calendar event. On failure, revert the DB claim (start/end AND + // the token_version bump) so the booking and its still-valid links survive. try { await updateEventTime(row.calendar_event_id, newStart, newEnd, BOOKING_TIMEZONE); } catch (err) { - console.error('booking/reschedule calendar update failed', err); + console.error('booking/reschedule calendar update failed; reverting', err); + try { + await exec( + `UPDATE bookings SET start_at = ?, end_at = ?, token_version = ?, updated_at = ? WHERE id = ?`, + [row.start_at, row.end_at, row.token_version, nowMs, id], + ); + } catch (revertErr) { + console.error('booking/reschedule revert failed', revertErr); + } return res.status(502).json({ success: false, message: 'Could not update event' }); } - const nowMs = Math.floor(Date.now() / 1000); - await exec( - `UPDATE bookings SET start_at = ?, end_at = ?, updated_at = ? WHERE id = ?`, - [ - Math.floor(newStart.getTime() / 1000), - Math.floor(newEnd.getTime() / 1000), - nowMs, - id, - ], - ); - const env = getRuntimeEnv(); - const cancelToken = generateToken(id, 'cancel'); - const rescheduleToken = generateToken(id, 'reschedule'); + const cancelToken = generateToken(id, 'cancel', newVersion); + const rescheduleToken = generateToken(id, 'reschedule', newVersion); const langPrefix = row.attendee_language; const manageUrl = `${env.siteOrigin}/${langPrefix}/booking/manage?id=${encodeURIComponent(id)}&token=${encodeURIComponent(rescheduleToken)}`; const cancelUrl = `${env.siteOrigin}/${langPrefix}/booking/manage?id=${encodeURIComponent(id)}&token=${encodeURIComponent(cancelToken)}&action=cancel`; diff --git a/migrations/0001_add_token_version_and_slot_uniqueness.sql b/migrations/0001_add_token_version_and_slot_uniqueness.sql new file mode 100644 index 0000000..517a6f6 --- /dev/null +++ b/migrations/0001_add_token_version_and_slot_uniqueness.sql @@ -0,0 +1,19 @@ +-- Migration 0001 — token versioning + slot uniqueness +-- +-- Apply to the Cloudflare D1 database backing the booking system BEFORE +-- deploying the matching API changes. +-- +-- 1. token_version: per-booking counter folded into the HMAC of the manage +-- tokens. Rescheduling bumps it, which invalidates every previously-issued +-- cancel/reschedule link for that booking (see api/booking/_lib/tokens.ts). +-- +-- 2. idx_bookings_active_slot: a PARTIAL unique index that lets the database be +-- the authoritative guard against two confirmed bookings on the same start +-- time. api/booking/create.ts and reschedule.ts claim the row before writing +-- to Google Calendar and turn a violation into an HTTP 409. + +ALTER TABLE bookings ADD COLUMN token_version INTEGER NOT NULL DEFAULT 0; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_bookings_active_slot + ON bookings (start_at) + WHERE status = 'confirmed'; diff --git a/migrations/README.md b/migrations/README.md new file mode 100644 index 0000000..094b252 --- /dev/null +++ b/migrations/README.md @@ -0,0 +1,53 @@ +# D1 migrations (booking system) + +The booking API stores data in a Cloudflare D1 database, reached over the D1 +HTTP query API (`api/booking/_lib/d1.ts`). These `.sql` files are the schema +changes that must be applied to that database, in order. + +## Applying a migration + +With Wrangler pointed at the booking database: + +```bash +# Preview +wrangler d1 execute --file=migrations/0001_add_token_version_and_slot_uniqueness.sql --local + +# Production (the same DB id as CLOUDFLARE_D1_DATABASE_ID) +wrangler d1 execute --file=migrations/0001_add_token_version_and_slot_uniqueness.sql --remote +``` + +Or paste the SQL into the D1 console for the database in the Cloudflare +dashboard. + +## Ordering + +Apply `0001_*` **before** deploying the API changes that depend on it: + +- `token_version` — read by every booking endpoint and written by + `reschedule`. Deploying the code first would make every query reference a + missing column. +- `idx_bookings_active_slot` — `create`/`reschedule` rely on the unique index + to detect a lost race; without it, concurrent double-bookings are still + possible (the code just no longer double-writes once the index exists). + +## Reference: expected `bookings` shape + +For convenience, the columns the code expects (the table itself predates these +migration files): + +| column | type | notes | +|--------------------|---------|------------------------------------------| +| id | TEXT | primary key, UUIDv4 | +| calendar_event_id | TEXT | nullable; set after the calendar write | +| attendee_name | TEXT | | +| attendee_email | TEXT | | +| attendee_message | TEXT | nullable | +| attendee_language | TEXT | 'fr' \| 'en' | +| start_at | INTEGER | unix seconds | +| end_at | INTEGER | unix seconds | +| status | TEXT | 'confirmed' \| 'cancelled' | +| token_version | INTEGER | NOT NULL DEFAULT 0 (added in 0001) | +| created_at | INTEGER | unix seconds | +| updated_at | INTEGER | unix seconds | +| cancelled_at | INTEGER | nullable | +| cancel_reason | TEXT | nullable | diff --git a/package.json b/package.json index 67e7ac5..f0da0f1 100644 --- a/package.json +++ b/package.json @@ -30,8 +30,7 @@ "@vercel/speed-insights": "^2.0.0", "antd": "^6.3.4", "aos": "^2.3.4", - "axios": "^1.15.2", - "dompurify": "^3.4.0", + "dompurify": "^3.4.11", "esprima": "^4.0.1", "js-cookie": "^3.0.5", "levenary": "^1.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 08fe48e..9c46ad2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,12 +62,9 @@ importers: aos: specifier: ^2.3.4 version: 2.3.4 - axios: - specifier: ^1.15.2 - version: 1.16.1 dompurify: - specifier: ^3.4.0 - version: 3.4.3 + specifier: ^3.4.11 + version: 3.4.11 esprima: specifier: ^4.0.1 version: 4.0.1 @@ -1027,10 +1024,6 @@ packages: '@xstate/fsm@1.6.5': resolution: {integrity: sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==} - agent-base@6.0.2: - resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} - engines: {node: '>= 6.0.0'} - ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -1084,9 +1077,6 @@ packages: ast-v8-to-istanbul@1.0.0: resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==} - asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - autoprefixer@10.5.0: resolution: {integrity: sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==} engines: {node: ^10 || ^12 || >=14} @@ -1094,9 +1084,6 @@ packages: peerDependencies: postcss: ^8.1.0 - axios@1.16.1: - resolution: {integrity: sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==} - base64-arraybuffer@1.0.2: resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==} engines: {node: '>= 0.6.0'} @@ -1126,10 +1113,6 @@ packages: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} - call-bind-apply-helpers@1.0.2: - resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} - engines: {node: '>= 0.4'} - camelcase-css@2.0.1: resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} engines: {node: '>= 6'} @@ -1174,10 +1157,6 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} - engines: {node: '>= 0.8'} - commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -1247,10 +1226,6 @@ packages: resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} engines: {node: '>=12'} - delayed-stream@1.0.0: - resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} - engines: {node: '>=0.4.0'} - dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -1271,12 +1246,8 @@ packages: dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} - dompurify@3.4.3: - resolution: {integrity: sha512-VVwJidIJcp1hpg2OMXML3ZVRPYSZiq4aX7qBh83BSIpOaRDqI+qxhXjjIWnpzkOXhmp0L81lnoME1mnCc9H48A==} - - dunder-proto@1.0.1: - resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} - engines: {node: '>= 0.4'} + dompurify@3.4.11: + resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==} electron-to-chromium@1.5.357: resolution: {integrity: sha512-NHlTIQDK8fmVwHwuIzmXYEJ1Ewq3D9wDNc0cWXxDGysP6Pb21giwGNkxiTifyKy/4SoPuN5l6GLP1W9Sv7zB2g==} @@ -1288,10 +1259,6 @@ packages: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} - es-define-property@1.0.1: - resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} - engines: {node: '>= 0.4'} - es-errors@1.3.0: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} @@ -1299,14 +1266,6 @@ packages: es-module-lexer@2.1.0: resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} - engines: {node: '>= 0.4'} - - es-set-tostringtag@2.1.0: - resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} - engines: {node: '>= 0.4'} - escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -1357,19 +1316,6 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} - follow-redirects@1.16.0: - resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} - engines: {node: '>=4.0'} - peerDependencies: - debug: '*' - peerDependenciesMeta: - debug: - optional: true - - form-data@4.0.5: - resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} - engines: {node: '>= 6'} - fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} @@ -1389,14 +1335,6 @@ packages: resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} engines: {node: '>=18'} - get-intrinsic@1.3.0: - resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} - engines: {node: '>= 0.4'} - - get-proto@1.0.1: - resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} - engines: {node: '>= 0.4'} - glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -1408,10 +1346,6 @@ packages: globrex@0.1.2: resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} - gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} - engines: {node: '>= 0.4'} - graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -1419,14 +1353,6 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} - has-symbols@1.1.0: - resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} - engines: {node: '>= 0.4'} - - has-tostringtag@1.0.2: - resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} - engines: {node: '>= 0.4'} - hasown@2.0.3: resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} engines: {node: '>= 0.4'} @@ -1441,10 +1367,6 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} - https-proxy-agent@5.0.1: - resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} - engines: {node: '>= 6'} - immer@11.1.8: resolution: {integrity: sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==} @@ -1695,10 +1617,6 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} - math-intrinsics@1.1.0: - resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} - engines: {node: '>= 0.4'} - mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} @@ -1710,14 +1628,6 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} - mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} - - mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} - min-indent@1.0.1: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} @@ -1858,10 +1768,6 @@ packages: resolution: {integrity: sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - proxy-from-env@2.1.0: - resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} - engines: {node: '>=10'} - punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -3187,12 +3093,6 @@ snapshots: '@xstate/fsm@1.6.5': {} - agent-base@6.0.2: - dependencies: - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - ansi-regex@5.0.1: {} ansi-regex@6.2.2: {} @@ -3290,8 +3190,6 @@ snapshots: estree-walker: 3.0.3 js-tokens: 10.0.0 - asynckit@0.4.0: {} - autoprefixer@10.5.0(postcss@8.5.14): dependencies: browserslist: 4.28.2 @@ -3301,16 +3199,6 @@ snapshots: postcss: 8.5.14 postcss-value-parser: 4.2.0 - axios@1.16.1: - dependencies: - follow-redirects: 1.16.0 - form-data: 4.0.5 - https-proxy-agent: 5.0.1 - proxy-from-env: 2.1.0 - transitivePeerDependencies: - - debug - - supports-color - base64-arraybuffer@1.0.2: {} baseline-browser-mapping@2.10.30: {} @@ -3337,11 +3225,6 @@ snapshots: dependencies: run-applescript: 7.1.0 - call-bind-apply-helpers@1.0.2: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - camelcase-css@2.0.1: {} caniuse-lite@1.0.30001792: {} @@ -3385,10 +3268,6 @@ snapshots: color-name@1.1.4: {} - combined-stream@1.0.8: - dependencies: - delayed-stream: 1.0.0 - commander@4.1.1: {} compute-scroll-into-view@3.1.1: {} @@ -3443,8 +3322,6 @@ snapshots: define-lazy-prop@3.0.0: {} - delayed-stream@1.0.0: {} - dequal@2.0.3: {} detect-libc@2.1.2: {} @@ -3457,39 +3334,20 @@ snapshots: dom-accessibility-api@0.6.3: {} - dompurify@3.4.3: + dompurify@3.4.11: optionalDependencies: '@types/trusted-types': 2.0.7 - dunder-proto@1.0.1: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-errors: 1.3.0 - gopd: 1.2.0 - electron-to-chromium@1.5.357: {} emoji-regex@10.6.0: {} entities@8.0.0: {} - es-define-property@1.0.1: {} - es-errors@1.3.0: {} es-module-lexer@2.1.0: {} - es-object-atoms@1.1.1: - dependencies: - es-errors: 1.3.0 - - es-set-tostringtag@2.1.0: - dependencies: - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - has-tostringtag: 1.0.2 - hasown: 2.0.3 - escalade@3.2.0: {} escape-string-regexp@2.0.0: {} @@ -3535,16 +3393,6 @@ snapshots: dependencies: to-regex-range: 5.0.1 - follow-redirects@1.16.0: {} - - form-data@4.0.5: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - hasown: 2.0.3 - mime-types: 2.1.35 - fraction.js@5.3.4: {} fsevents@2.3.3: @@ -3556,24 +3404,6 @@ snapshots: get-east-asian-width@1.6.0: {} - get-intrinsic@1.3.0: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - function-bind: 1.1.2 - get-proto: 1.0.1 - gopd: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.3 - math-intrinsics: 1.1.0 - - get-proto@1.0.1: - dependencies: - dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 - glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -3584,18 +3414,10 @@ snapshots: globrex@0.1.2: {} - gopd@1.2.0: {} - graceful-fs@4.2.11: {} has-flag@4.0.0: {} - has-symbols@1.1.0: {} - - has-tostringtag@1.0.2: - dependencies: - has-symbols: 1.1.0 - hasown@2.0.3: dependencies: function-bind: 1.1.2 @@ -3612,13 +3434,6 @@ snapshots: html-escaper@2.0.2: {} - https-proxy-agent@5.0.1: - dependencies: - agent-base: 6.0.2 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - immer@11.1.8: {} indent-string@4.0.0: {} @@ -3851,8 +3666,6 @@ snapshots: dependencies: semver: 7.8.0 - math-intrinsics@1.1.0: {} - mdn-data@2.27.1: {} merge2@1.4.1: {} @@ -3862,12 +3675,6 @@ snapshots: braces: 3.0.3 picomatch: 2.3.2 - mime-db@1.52.0: {} - - mime-types@2.1.35: - dependencies: - mime-db: 1.52.0 - min-indent@1.0.1: {} mitt@3.0.1: {} @@ -3979,8 +3786,6 @@ snapshots: react-is-18: react-is@18.3.1 react-is-19: react-is@19.2.6 - proxy-from-env@2.1.0: {} - punycode@2.3.1: {} queue-microtask@1.2.3: {} diff --git a/scripts/google-oauth-token.mjs b/scripts/google-oauth-token.mjs new file mode 100644 index 0000000..0abff2a --- /dev/null +++ b/scripts/google-oauth-token.mjs @@ -0,0 +1,160 @@ +// Regenerate the single-user Google Calendar OAuth refresh token used by the +// booking backend (api/booking/_lib/google-calendar.ts). +// +// Why you're here: /api/booking/availability returns 502 "Calendar unavailable" +// because the Google OAuth refresh returns `invalid_grant` — the stored +// GOOGLE_OAUTH_REFRESH_TOKEN is expired or revoked. Refresh tokens expire after +// 7 days while the OAuth consent screen is in "Testing" mode, so also set the +// consent screen to "In production" to stop this from recurring. +// +// Usage: +// node scripts/google-oauth-token.mjs +// +// Prereqs: +// - GOOGLE_OAUTH_CLIENT_ID and GOOGLE_OAUTH_CLIENT_SECRET set in .env.local. +// - The redirect URI below must be authorized on the OAuth client: +// * "Desktop app" client type: any http://localhost: is allowed. +// * "Web application" client type: add exactly +// http://localhost:4390/oauth2callback +// under "Authorized redirect URIs" in Google Cloud Console. +// +// After it prints a refresh token, paste it into GOOGLE_OAUTH_REFRESH_TOKEN in +// .env.local AND in the Vercel project env (Production/Preview), then redeploy. + +import { createServer } from 'http'; +import { readFileSync, writeFileSync } from 'fs'; +import { spawn } from 'child_process'; + +// The redirect URI MUST be one authorized on the OAuth client. Override it to +// reuse a URI you already registered, e.g.: +// OAUTH_REDIRECT_URI="http://localhost:8080/callback" node scripts/google-oauth-token.mjs +const REDIRECT_URI = + process.env.OAUTH_REDIRECT_URI || 'http://localhost:4390/oauth2callback'; +const REDIRECT = new URL(REDIRECT_URI); +const PORT = Number(REDIRECT.port) || 80; +const CALLBACK_PATH = REDIRECT.pathname; +const SCOPE = 'https://www.googleapis.com/auth/calendar'; + +function loadEnvLocal() { + const env = {}; + try { + for (const line of readFileSync('.env.local', 'utf-8').split('\n')) { + const m = line.match(/^([A-Z0-9_]+)=(.*)$/); + if (m) env[m[1]] = m[2].trim().replace(/^["']|["']$/g, ''); + } + } catch { + // fall back to process.env below + } + return env; +} + +const env = loadEnvLocal(); +const CLIENT_ID = env.GOOGLE_OAUTH_CLIENT_ID || process.env.GOOGLE_OAUTH_CLIENT_ID; +const CLIENT_SECRET = env.GOOGLE_OAUTH_CLIENT_SECRET || process.env.GOOGLE_OAUTH_CLIENT_SECRET; + +if (!CLIENT_ID || !CLIENT_SECRET) { + console.error('❌ Missing GOOGLE_OAUTH_CLIENT_ID / GOOGLE_OAUTH_CLIENT_SECRET in .env.local'); + process.exit(1); +} + +const authUrl = + 'https://accounts.google.com/o/oauth2/v2/auth?' + + new URLSearchParams({ + client_id: CLIENT_ID, + redirect_uri: REDIRECT_URI, + response_type: 'code', + scope: SCOPE, + access_type: 'offline', + prompt: 'consent', // force a fresh refresh_token every run + }).toString(); + +async function exchangeCode(code) { + const res = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + code, + grant_type: 'authorization_code', + redirect_uri: REDIRECT_URI, + }).toString(), + }); + const data = await res.json(); + if (!res.ok) { + throw new Error(`Token exchange failed (${res.status}): ${JSON.stringify(data)}`); + } + return data; +} + +const server = createServer(async (req, res) => { + if (!req.url.startsWith(CALLBACK_PATH)) { + res.writeHead(404).end(); + return; + } + const url = new URL(req.url, `http://localhost:${PORT}`); + const code = url.searchParams.get('code'); + const error = url.searchParams.get('error'); + + if (error) { + res.writeHead(400, { 'Content-Type': 'text/plain' }).end(`OAuth error: ${error}`); + console.error(`\n❌ OAuth error: ${error}`); + server.close(); + process.exit(1); + } + + if (!code) { + res.writeHead(400, { 'Content-Type': 'text/plain' }).end('Missing authorization code.'); + console.error('\n❌ Callback hit without an authorization code — check the redirect URI.'); + server.close(); + setTimeout(() => process.exit(1), 250); + return; + } + + try { + const tokens = await exchangeCode(code); + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }).end( + '

✅ Done — you can close this tab and return to the terminal.

', + ); + console.log('\n✅ Success.'); + if (tokens.refresh_token) { + // Write it straight into .env.local so there is no copy-paste mistake + // (an authorization code `4/0A...` is NOT a refresh token `1//...`). + try { + const contents = readFileSync('.env.local', 'utf-8'); + const line = `GOOGLE_OAUTH_REFRESH_TOKEN=${tokens.refresh_token}`; + const updated = /^GOOGLE_OAUTH_REFRESH_TOKEN=.*$/m.test(contents) + ? contents.replace(/^GOOGLE_OAUTH_REFRESH_TOKEN=.*$/m, line) + : contents.replace(/\n?$/, `\n${line}\n`); + writeFileSync('.env.local', updated); + console.log(' → Written to .env.local (GOOGLE_OAUTH_REFRESH_TOKEN).'); + } catch (e) { + console.log(' ⚠️ Could not write .env.local:', e.message); + } + console.log('\nAlso set this in the Vercel project env (Production + Preview), then redeploy:'); + console.log('GOOGLE_OAUTH_REFRESH_TOKEN=' + tokens.refresh_token); + console.log('\nThen restart `vercel dev` so it reloads .env.local.'); + } else { + console.log( + '⚠️ No refresh_token returned (only an access token). Revoke prior access at ' + + 'https://myaccount.google.com/permissions and run again — prompt=consent is set to force one.', + ); + } + } catch (err) { + res.writeHead(500, { 'Content-Type': 'text/plain' }).end(String(err)); + console.error('\n❌', err.message); + } finally { + server.close(); + setTimeout(() => process.exit(0), 250); + } +}); + +server.listen(PORT, () => { + console.log('🔑 Google Calendar refresh-token helper'); + console.log(` Redirect URI (must be authorized on the client): ${REDIRECT_URI}\n`); + console.log('Opening the consent screen in your browser. If it does not open, visit:\n'); + console.log(authUrl + '\n'); + // Best-effort auto-open (macOS `open`, Linux `xdg-open`). + const opener = process.platform === 'darwin' ? 'open' : 'xdg-open'; + spawn(opener, [authUrl], { stdio: 'ignore', detached: true }).on('error', () => {}); +}); diff --git a/src/components/booking/BookingButton.tsx b/src/components/booking/BookingButton.tsx index 0a7c654..372fba5 100644 --- a/src/components/booking/BookingButton.tsx +++ b/src/components/booking/BookingButton.tsx @@ -1,5 +1,6 @@ import React from 'react'; +import ScopedRecaptchaProvider from 'components/common/ScopedRecaptchaProvider'; import BookingModal from './BookingModal'; interface BookingButtonProps { @@ -27,7 +28,17 @@ const BookingButton: React.FC = ({ > {text} - setOpen(false)} /> + {/* + Mount reCAPTCHA only while the modal is open so the widget/script (and + its badge) load on demand rather than on every page that renders a + booking button. The multi-step flow gives the script ample time to be + ready before the visitor reaches the submit step. + */} + {open && ( + + setOpen(false)} /> + + )} ); }; diff --git a/src/components/booking/BookingModal.tsx b/src/components/booking/BookingModal.tsx index a35b1ca..4589fc6 100644 --- a/src/components/booking/BookingModal.tsx +++ b/src/components/booking/BookingModal.tsx @@ -1,8 +1,11 @@ import React from 'react'; import { ArrowLeft, CheckCircle2, Clock, Loader2, Video, X } from 'lucide-react'; +import { useGoogleReCaptcha } from 'react-google-recaptcha-v3'; + import { useAppSelector } from 'services/hooks/hooks'; import { useTranslations } from 'services/locales/safe'; +import { getRecaptchaToken } from 'services/api/recaptcha'; import { BookingSlot, createBooking, @@ -83,6 +86,7 @@ const BookingModal: React.FC = ({ open, onClose }) => { const language = useAppSelector((state) => state.language.currentLanguage); const theme = useAppSelector((state) => state.theme.currentTheme); const t = useTranslations(language); + const { executeRecaptcha } = useGoogleReCaptcha(); useBodyScrollLock(open); @@ -211,6 +215,13 @@ const BookingModal: React.FC = ({ open, onClose }) => { setSubmitError(null); try { + // reCAPTCHA v3 token — the create endpoint requires it. null means the + // widget failed/was not ready; "" is the intentional local bypass. + const recaptchaToken = await getRecaptchaToken(executeRecaptcha, 'booking_create'); + if (recaptchaToken === null) { + setSubmitError(t.text('booking.error')); + return; + } const response = await createBooking({ name: input.name, email: input.email, @@ -218,6 +229,7 @@ const BookingModal: React.FC = ({ open, onClose }) => { language, startUtc: selectedSlot.startUtc, website: input.website, + recaptchaToken, }); if (!response.success || !response.booking) { setSubmitError(response.error?.message ?? response.message ?? t.text('booking.error')); diff --git a/src/pages/BookingManage.tsx b/src/pages/BookingManage.tsx index f60a6e8..de1d8d8 100644 --- a/src/pages/BookingManage.tsx +++ b/src/pages/BookingManage.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { Helmet } from 'react-helmet-async'; import { Link, useSearchParams } from 'react-router-dom'; import { AlertCircle, @@ -286,6 +287,14 @@ const ManageShell: React.FC = ({ children, theme }) => {
+ {/* + The manage link carries the booking id + token in the URL. Suppress the + Referer entirely on this page so the token never leaks to any resource + the page loads. + */} + + +
{children}
); diff --git a/src/services/api/booking.ts b/src/services/api/booking.ts index 2e54589..d304dba 100644 --- a/src/services/api/booking.ts +++ b/src/services/api/booking.ts @@ -19,6 +19,8 @@ export interface CreateBookingPayload { startUtc: string; /** Honeypot: must be left empty by a real human. */ website?: string; + /** reCAPTCHA v3 token (action `booking_create`); "" when bypassed locally. */ + recaptchaToken?: string; } export interface CreateBookingResponse { diff --git a/vite.config.ts b/vite.config.ts index c37ecbd..6bb32cc 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -33,8 +33,15 @@ function dotLottieWasmPlugin(): Plugin { if (id !== RESOLVED_ID) return null; // Resolve via the package entrypoint so we stay compatible with package - // exports and pnpm's symlinked node_modules layout. - const dotLottieEntry = _require.resolve("@lottiefiles/dotlottie-web"); + // exports and pnpm's symlinked node_modules layout. dotlottie-web is a + // transitive dep of dotlottie-react, so resolve it from that package's + // location — pnpm does not expose transitive deps at the project root. + const dotLottieReactEntry = _require.resolve( + "@lottiefiles/dotlottie-react" + ); + const dotLottieEntry = createRequire(dotLottieReactEntry).resolve( + "@lottiefiles/dotlottie-web" + ); const wasmSrc = path.join( path.dirname(dotLottieEntry), "dotlottie-player.wasm"