Vendor webhooks, free kids through 12, multi-origin lookup CORS

Children now free through age 12:
- Scannable/paid ticket total = adults + youth 13-16 only; kids 12 &
  under (0-4, 5-9, 10-12) are stored but not counted (charging starts
  at 13). computeTotal + freeKidsCount in fields.ts, webhook guard,
  scan/admin badges, event-report labels, docs, and personas updated.

Vendor booth webhooks (vendors.beartariacampgrounds.com):
- New /vendor-webhook/food (2 named pass-holders) and
  /vendor-webhook/non-food (1 pass-holder), reusing WEBHOOK_SECRET.
  Booth name -> ticket title; each named person = one entry pass;
  tagged with a "Food Vendor"/"Vendor" Ticket Type (badge on scan +
  event-report rollup). Idempotent + QR email like the attendee hook.
- Extracted shared FluentForms parsing (nameGroup/qty/selected/
  addressLine/readDonor) into fluentforms.ts; attendee webhook now
  imports it. 13 new unit tests.

Public lookup CORS is now a comma-separated allowlist; the caller's
Origin is echoed only if it matches. tickets + vendors both allowed
on donor-eligibility and ticket-vouchers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hank 2026-07-16 05:13:07 +00:00
parent 43fddec286
commit 7296555964
15 changed files with 368 additions and 91 deletions

View file

@ -0,0 +1,68 @@
import { timingSafeEqual } from "node:crypto";
import { toBool, toNumber } from "./fields.js";
/** Constant-time string compare for shared webhook secrets. */
export function safeEqual(a: string, b: string): boolean {
const ba = Buffer.from(a || "");
const bb = Buffer.from(b || "");
if (ba.length !== bb.length) return false;
return timingSafeEqual(ba, bb);
}
/** Read a FluentForms compound name field, given as a nested object
* (`names: {first_name,...}`) or flattened bracket keys (`names[first_name]`). */
export function nameGroup(body: Record<string, any>, base: string): string {
const obj = body[base];
let first: any, middle: any, last: any;
if (obj && typeof obj === "object") {
({ first_name: first, middle_name: middle, last_name: last } = obj);
} else {
first = body[`${base}[first_name]`];
middle = body[`${base}[middle_name]`];
last = body[`${base}[last_name]`];
}
return [first, middle, last]
.map((x) => (x == null ? "" : String(x).trim()))
.filter(Boolean)
.join(" ");
}
/** Read an item_quantity / payment field's numeric value (handles nested
* objects like {quantity} / {value} and money strings like "$40.00"). */
export function qty(v: any): number {
if (v == null || v === "") return 0;
if (typeof v === "object") return toNumber(v.quantity ?? v.value ?? v.item_quantity ?? v.amount ?? 0);
if (typeof v === "string") return toNumber(v.replace(/[^0-9.\-]/g, ""));
return toNumber(v);
}
/** A payment/extra field counts as "selected" if it has a meaningful value.
* Donor (free) items can be $0, so a non-empty, non-"no"/"0" value also counts. */
export function selected(v: any): boolean {
if (v == null || v === "") return false;
if (typeof v === "object") {
if ("selected" in v) return toBool((v as any).selected);
return qty(v) > 0 || Object.keys(v).length > 0;
}
const s = String(v).trim().toLowerCase();
if (!s || s === "no" || s === "0" || s === "$0" || s === "$0.00" || s === "false" || s === "none") return false;
return true;
}
/** Flatten a FluentForms compound address (`address_1`) to a single line. */
export function addressLine(v: any): string | undefined {
if (v && typeof v === "object") return Object.values(v).filter(Boolean).join(", ");
if (v !== undefined) return String(v);
return undefined;
}
/** Donor status from the hidden lookup fields + the "are you a donor?" radio. */
export function readDonor(body: Record<string, any>): { isDonor: boolean; donorTier: string } {
const donorTier = String(body.donor_tier ?? "").trim();
const isDonor =
donorTier === "member" ||
donorTier === "donor" ||
toBool(body.donor_eligible) ||
selected(body.input_radio); // "Are you a campground donor?"
return { isDonor, donorTier };
}