Add secret-gated public donor-eligibility lookup for checkout discount

GET /api/public/donor-eligibility?key=&email= returns only {eligible, tier}
(member/donor) — no names or dollar amounts — gated by PUBLIC_LOOKUP_SECRET,
rate-limited (30/min), and CORS-restricted to PUBLIC_LOOKUP_ORIGIN. Lets the
FluentForms checkout unlock a donor discount by email. Docs + ready-to-paste
form snippet in docs/fluentforms-donor-discount.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hank 2026-07-08 22:09:25 +00:00
parent 774d00ff5b
commit fb2fdcb6b8
4 changed files with 169 additions and 0 deletions

View file

@ -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

View file

@ -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<void> {
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 };
}
},
);
}

View file

@ -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");