diff --git a/backend/src/config.ts b/backend/src/config.ts index 3b98337..ab39649 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -21,6 +21,12 @@ const schema = z.object({ // sends a boolean (not an explicit bag count). ICE_BAGS_DEFAULT: z.coerce.number().default(3), + // Public donor-eligibility lookup (for the FluentForms checkout discount). + // Disabled unless a secret is set. Returns only eligibility + tier, never + // names or dollar amounts. Rate-limited + CORS-restricted. + PUBLIC_LOOKUP_SECRET: z.string().optional(), + PUBLIC_LOOKUP_ORIGIN: z.string().default("https://tickets.beartariacampgrounds.com"), + // Serve GET /test with sample QR codes. Seeds test personas into the current // NocoDB table, so keep this OFF in production (only enable against a TEST table). ENABLE_TEST_PAGE: z diff --git a/backend/src/routes/publicLookup.ts b/backend/src/routes/publicLookup.ts new file mode 100644 index 0000000..8c283d5 --- /dev/null +++ b/backend/src/routes/publicLookup.ts @@ -0,0 +1,60 @@ +import { timingSafeEqual } from "node:crypto"; +import type { FastifyInstance } from "fastify"; + +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); +} + +/** + * Public, secret-gated donor-eligibility lookup for the FluentForms checkout. + * The form's JS calls this on email blur to decide whether to unlock a donor + * discount. Deliberately minimal: returns only { eligible, tier } — never + * names or dollar amounts — so even with the (page-source-visible) secret it + * can't leak donor financials. Rate-limited and CORS-restricted. + */ +export async function publicLookupRoutes(app: FastifyInstance): Promise { + const cfg = app.ctx.config; + const origin = cfg.PUBLIC_LOOKUP_ORIGIN; + + const cors = (reply: any) => { + reply.header("Access-Control-Allow-Origin", origin); + reply.header("Vary", "Origin"); + reply.header("Access-Control-Allow-Methods", "GET, OPTIONS"); + }; + + // Preflight (in case the form sends one). + app.options("/api/public/donor-eligibility", async (_req, reply) => { + cors(reply); + return reply.code(204).send(); + }); + + app.get( + "/api/public/donor-eligibility", + { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, + async (req, reply) => { + cors(reply); + // Disabled unless configured. + if (!cfg.PUBLIC_LOOKUP_SECRET || !app.ctx.donors.enabled) { + return reply.code(404).send({ error: "not_available" }); + } + const { key, email } = (req.query ?? {}) as { key?: string; email?: string }; + if (!key || !safeEqual(key, cfg.PUBLIC_LOOKUP_SECRET)) { + return reply.code(401).send({ error: "unauthorized" }); + } + const addr = String(email ?? "").trim(); + if (!addr) return { eligible: false, tier: null }; + + try { + const d = await app.ctx.donors.lookup(addr); + const tier = d.found ? (d.isMember ? "member" : "donor") : null; + return { eligible: d.found, tier }; + } catch { + // Fail closed — no discount rather than an error the form can't handle. + return { eligible: false, tier: null }; + } + }, + ); +} diff --git a/backend/src/server.ts b/backend/src/server.ts index 1b662db..79fba69 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -13,6 +13,7 @@ import { ticketRoutes } from "./routes/tickets.js"; import { testRoutes } from "./routes/test.js"; import { installRoutes } from "./routes/install.js"; import { webhookDocRoutes } from "./routes/webhookDoc.js"; +import { publicLookupRoutes } from "./routes/publicLookup.js"; export async function build() { const config = loadConfig(); @@ -34,6 +35,7 @@ export async function build() { await app.register(testRoutes); await app.register(installRoutes); await app.register(webhookDocRoutes); + await app.register(publicLookupRoutes); // Serve the exported Expo web build (if present) with SPA fallback. const webDir = config.WEB_DIR ?? join(process.cwd(), "web"); diff --git a/docs/fluentforms-donor-discount.md b/docs/fluentforms-donor-discount.md new file mode 100644 index 0000000..e45823a --- /dev/null +++ b/docs/fluentforms-donor-discount.md @@ -0,0 +1,101 @@ +# FluentForms → donor discount lookup + +FluentForms has no native way to query an external database from a field. This +wires it up with a small Custom JS block that calls our secret-gated endpoint +and unlocks a discount when the entered email belongs to a donor/member. + +## Endpoint + +``` +GET https://scan.beartariacampgrounds.com/api/public/donor-eligibility?key=&email= +``` + +- `key` = the value of `PUBLIC_LOOKUP_SECRET` (set in the backend `.env`). +- Returns minimal JSON — never names or dollar amounts: + - `{"eligible": true, "tier": "member"}` + - `{"eligible": true, "tier": "donor"}` + - `{"eligible": false, "tier": null}` +- Rate-limited (30/min/IP) and CORS-restricted to `PUBLIC_LOOKUP_ORIGIN` + (default `https://tickets.beartariacampgrounds.com`). + +> The secret is visible in page source, so treat this as *deterrence, not +> security*. It only gates a discount and reveals a yes/no + tier, so the blast +> radius is small. Rotate the secret by changing `PUBLIC_LOOKUP_SECRET` and +> redeploying. + +## Form setup + +1. On the ticket form add a **Custom HTML** element (or use FluentForms Pro's + custom JS). Give your email field a known name (default FF `email`). +2. Decide the discount mechanism. Two common options: + - **Coupon:** configure a coupon in the form's payment settings; the JS + auto-fills + applies it for eligible emails. + - **Conditional price:** add a hidden field (e.g. `donor_tier`) and use + FluentForms conditional logic to show a discounted payment option when it + equals `member`/`donor`. +3. Paste the snippet below into the Custom HTML element, editing the marked + constants and the `applyDiscount()` body to match your form. + +## Snippet + +```html +
+ +``` + +## Test + +``` +curl "https://scan.beartariacampgrounds.com/api/public/donor-eligibility?key=&email=adam21stevens@gmail.com" +# -> {"eligible":true,"tier":"member"} +```