Source
This app’s code and revision history are public. Download a copy or explore the files below.
Public
The app’s code and revision history are public.
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone c7891873549c9ca1004894b3
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
functions/api.ts
// Santa's public HTTP API: no account. An exchange's id is its join link's secret: anyone with it sees
// the exchange and the names in it, never who gives to whom. The owner key (handed back once, when an
// exchange is made) manages it and draws names. Each person's key (in their personal link) opens their
// page: who they give to, that person's wish list, and questions. Writes go through the same policy
// rules as the web app, as this route's public principal, which every caller shares: so every owner
// or person action checks the key first, and the policy checks it again through the claim it writes.
// Only this function can write matches, so the draw happens here and never in an organizer's browser.
import { SITE_URL } from "./lib/config";
import {
drawNames, readPeople, parsePairs, cleanText, validDate, randomId, inviteMessage, personMessage, drawnMessage, privacyLine,
exchangeIcs, fileName, Problem, LIMITS,
type Exchange, type Person, type Open, type Match, type Ask, type Pair, type PersonInput,
} from "./lib/santa";
const NOW = { operation: "time", value: "now" };
const ID_RE = /^[A-Za-z0-9]{8,32}$/;
const cors = {
"access-control-allow-origin": "*",
"access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
"access-control-allow-headers": "content-type, x-owner-key, x-person-key",
};
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body, null, 2), { status, headers: { "content-type": "application/json; charset=utf-8", ...cors } });
const fail = (status: number, error: string) => json({ error }, status);
class HttpError extends Error { constructor(public status: number, message: string) { super(message); } }
async function body(req: Request): Promise<Record<string, any>> {
try {
const parsed = await req.json();
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
} catch { /* fall through */ }
throw new HttpError(400, "Send a JSON object body.");
}
async function listAll(ctx: any, path: string): Promise<any[]> {
const rows: any[] = [];
let cursor: string | undefined;
for (let page = 0; page < 20; page++) {
const res = await ctx.bounded.get(path, { limit: 500, ...(cursor ? { cursor } : {}) });
// Rows name their path in _id (or pathId); the bare id is its last segment.
for (const r of res?.data || []) rows.push({ ...r, id: String(r._id ?? r.pathId ?? r.id ?? "").split("/").pop() });
cursor = res?.nextCursor;
if (!cursor) break;
}
return rows;
}
const joinUrl = (id: string) => `${SITE_URL}/x/${id}`;
const adminUrl = (id: string, key: string) => `${SITE_URL}/x/${id}/admin#k=${key}`;
const personUrl = (id: string, pid: string, key: string) => `${SITE_URL}/x/${id}/p/${pid}#k=${key}`;
const iso = (sec: number) => (sec ? new Date(sec * 1000).toISOString() : null);
function keyFrom(req: Request, url: URL, header: string): string {
return (req.headers.get(header) || url.searchParams.get("key") || "").trim();
}
/** A reveal moment: an ISO date and time with its offset, epoch seconds, or nothing (no reveal). */
function readReveal(value: unknown): number {
if (value == null || value === "" || value === false || value === 0) return 0;
let sec = NaN;
if (typeof value === "number") sec = value > 1e12 ? Math.floor(value / 1000) : Math.floor(value);
else if (typeof value === "string" && /T\d{2}:\d{2}/.test(value) && /(Z|[+-]\d{2}:?\d{2})$/.test(value)) sec = Math.floor(Date.parse(value) / 1000);
if (!Number.isFinite(sec) || sec <= 0) throw new HttpError(400, `revealAt must be a date and time with its time zone offset, like "2026-12-18T19:00:00-05:00", or null for no reveal.`);
if (sec <= Date.now() / 1000) throw new HttpError(400, "revealAt is in the past. Pick a moment after now, or null for no reveal.");
return sec;
}
function readFields(input: Record<string, any>, base?: Exchange) {
const has = (k: string) => input[k] !== undefined;
const date = has("date") || !base ? cleanText(input.date, 10, "date") : base.date;
if (date && !validDate(date)) throw new HttpError(400, `date must look like 2026-12-18.`);
return {
name: has("name") || !base ? cleanText(input.name, LIMITS.name, "name", true) : base.name,
budget: has("budget") || !base ? cleanText(input.budget, LIMITS.budget, "budget") : base.budget,
date,
notes: has("notes") || !base ? cleanText(input.notes, LIMITS.notes, "notes") : base.notes,
allowJoin: has("allowJoin") ? input.allowJoin !== false : base?.allowJoin ?? true,
organizerSees: has("organizerSees") ? input.organizerSees === true : base?.organizerSees ?? false,
revealAt: has("revealAt") ? readReveal(input.revealAt) : base?.revealAt ?? 0,
};
}
/** Pairs kept apart, given by name or person id: [["Ann", "Ben"]]. */
function readPairs(input: unknown, people: { id: string; name: string }[]): Pair[] {
if (input == null) return [];
if (!Array.isArray(input)) throw new HttpError(400, `keepApart must be a list of pairs, like [["Ann", "Ben"], ["Cat", "Dan"]].`);
const find = (ref: unknown) => {
const r = String(ref ?? "").trim();
const p = people.find((x) => x.id === r) || people.find((x) => x.name.toLowerCase() === r.toLowerCase());
if (!p) throw new HttpError(400, `keepApart names "${r}", who is not in this exchange.`);
return p.id;
};
return parsePairs(JSON.stringify(input.map((pair) => {
if (!Array.isArray(pair) || pair.length !== 2) throw new HttpError(400, `Each keepApart entry is a pair of two names, like ["Ann", "Ben"].`);
const [a, b] = pair.map(find);
if (a === b) throw new HttpError(400, `A keepApart pair needs two different people.`);
return [a, b];
})));
}
/** Joins the exchange as this route (knowing the id is the permission), then reads it. */
async function openExchange(ctx: any, id: string): Promise<Exchange> {
if (!ID_RE.test(id)) throw new HttpError(404, "No exchange with that id.");
const me = ctx.user.id;
if (!(await ctx.bounded.get(`exchanges/${id}/joins/${me}`))) {
try {
await ctx.bounded.set(`exchanges/${id}/joins/${me}`, { at: NOW });
} catch {
throw new HttpError(404, "No exchange with that id.");
}
}
const ex = (await ctx.bounded.get(`exchanges/${id}`)) as Exchange | null;
if (!ex) throw new HttpError(404, "No exchange with that id.");
return ex;
}
/** Checks the owner key, then makes this route an owner of the exchange if it is not one yet. */
async function requireOwner(ctx: any, req: Request, url: URL, id: string) {
const key = keyFrom(req, url, "x-owner-key");
if (!key) throw new HttpError(401, "Send the exchange's owner key in the x-owner-key header (or as ?key=).");
const stored = ID_RE.test(id) ? await ctx.bounded.get(`exchangekeys/${id}`) : null;
if (!stored) throw new HttpError(404, "No exchange with that id.");
if (stored.key !== key) throw new HttpError(403, "That owner key does not match this exchange.");
const me = ctx.user.id;
await openExchange(ctx, id);
if (!(await ctx.bounded.get(`exchanges/${id}/owners/${me}`))) {
await ctx.bounded.setMany([
{ path: `exchangeclaims/${id}/c/${me}`, document: { key } },
{ path: `exchanges/${id}/owners/${me}`, document: { at: NOW } },
]);
}
}
/** Checks a person's key, then opens their page as this route (recorded as opened through the API). */
async function requirePerson(ctx: any, req: Request, url: URL, pid: string): Promise<string> {
const key = keyFrom(req, url, "x-person-key");
if (!key) throw new HttpError(401, "Send the person's key in the x-person-key header (or as ?key=). It is the part after #k= in their personal link.");
const stored = ID_RE.test(pid) ? await ctx.bounded.get(`personkeys/${pid}`) : null;
if (!stored) throw new HttpError(404, "No person with that id.");
if (stored.key !== key) throw new HttpError(403, "That key does not match this person.");
const id = String(stored.exchangeId);
const me = ctx.user.id;
await openExchange(ctx, id);
const openId = `${pid}_${me}`;
if (!(await ctx.bounded.get(`exchanges/${id}/opens/${openId}`))) {
await ctx.bounded.setMany([
{ path: `personclaims/${pid}/c/${me}`, document: { key, exchangeId: id, openId } },
{ path: `exchanges/${id}/opens/${openId}`, document: { pid, by: me, via: "api", at: NOW } },
]);
} else {
// Every API caller shares this one record, so it carries the latest call: a later peek still shows on the page.
await ctx.bounded.set(`exchanges/${id}/opens/${openId}`, { at: NOW });
}
return id;
}
async function load(ctx: any, id: string) {
const people = ((await listAll(ctx, `exchanges/${id}/people`)) as Person[]).sort((a, b) => a.createdAt - b.createdAt);
const opens = (await listAll(ctx, `exchanges/${id}/opens`)) as Open[];
return { people, opens };
}
function openedBy(opens: Open[], pid: string) {
const mine = opens.filter((o) => o.pid === pid);
return { devices: mine.filter((o) => o.via === "web").length, api: mine.some((o) => o.via === "api") };
}
function publicExchange(id: string, ex: Exchange, people: Person[], opens: Open[]) {
return {
id,
url: joinUrl(id),
name: ex.name,
budget: ex.budget || null,
date: ex.date || null,
notes: ex.notes || null,
status: ex.status,
drawnAt: iso(ex.drawnAt),
timesDrawn: ex.round,
joinByLink: ex.allowJoin && ex.status === "open",
organizerSeesList: ex.organizerSees,
revealAt: iso(ex.revealAt),
privacy: privacyLine(ex),
people: people.map((p) => ({ name: p.name, joinedByLink: p.self, opened: openedBy(opens, p.id) })),
};
}
const revealed = (ex: Exchange) => ex.revealAt > 0 && ex.revealAt <= Date.now() / 1000;
/** Writes people with their keys (and contacts) in batches the data plane takes comfortably. */
async function addPeople(ctx: any, id: string, list: (PersonInput & { id: string; key: string })[]) {
const me = ctx.user.id;
// 30 people (at most 90 documents) per batch; a person and their key always go together.
for (let i = 0; i < list.length; i += 30) {
await ctx.bounded.setMany(list.slice(i, i + 30).flatMap((p) => [
{ path: `exchanges/${id}/people/${p.id}`, document: { name: p.name, addedBy: me, self: false, createdAt: NOW } },
{ path: `personkeys/${p.id}`, document: { key: p.key, exchangeId: id } },
...(p.contact ? [{ path: `exchanges/${id}/contacts/${p.id}`, document: { contact: p.contact, by: me } }] : []),
]));
}
}
async function writeDraw(ctx: any, id: string, ex: Exchange, matches: Record<string, string>) {
const round = ex.round + 1;
await ctx.bounded.setMany([
{ path: `exchanges/${id}`, document: { status: "drawn", round, drawnAt: NOW } },
...Object.entries(matches).map(([from, to]) => ({ path: `exchanges/${id}/matches/${from}`, document: { to, round, at: NOW } })),
]);
return round;
}
function personLink(ex: Pick<Exchange, "name" | "budget" | "date" | "status">, id: string, p: { id: string; name: string }, key: string) {
const url = personUrl(id, p.id, key);
return { url, message: personMessage(ex, p.name, url, "en-US") };
}
const routes = {
name: "Santa by OpenApps",
about: "Free Secret Santa generator, a free Elfster alternative with no ads. No account. An exchange's id is its join link's secret. The owner key from POST /exchanges manages the exchange and draws names; each person's key (in their personal link) opens that person's page. Nobody, including the organizer, can see who gives to whom unless the organizer chose to before the draw.",
docs: `${SITE_URL}/llms.txt`,
openapi: `${SITE_URL}/openapi.json`,
routes: [
"POST /exchanges {name, people, keepApart?, budget?, date?, notes?, allowJoin?, organizerSees?, revealAt?, draw?} -> url, adminUrl, ownerKey, each person's url and message",
"GET /exchanges/:id -> the exchange as everyone in it sees it (names, never matches)",
"GET /exchanges/:id/admin with x-owner-key -> people, contacts, personal links, keepApart, and matches only if the organizer chose to see them",
"PATCH /exchanges/:id {name?, budget?, date?, notes?, allowJoin?, organizerSees?, revealAt?, keepApart?} with x-owner-key",
"POST /exchanges/:id/people {people} with x-owner-key -> new people with their links (before the draw)",
"DELETE /exchanges/:id/people/:personId with x-owner-key (before the draw)",
"POST /exchanges/:id/draw {again?} with x-owner-key -> draws names",
"POST /exchanges/:id/reopen with x-owner-key -> undoes the draw so people can change",
"GET /exchanges/:id/calendar.ics -> a calendar file for the exchange date",
"GET /people/:personId with x-person-key -> who they give to, that person's wish list and answers, their own wish list and questions",
"PUT /people/:personId/wishlist {text} with x-person-key",
"POST /people/:personId/questions {text} with x-person-key -> asks their giftee anonymously",
"POST /people/:personId/answers {questionId, answer} with x-person-key",
"POST /questions/:questionId -> sends the question a Secret Santa's browser left in askrequests/:questionId (the web app's way to ask anonymously)",
],
};
export default async function api(req: Request, ctx: any) {
if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: cors });
const url = new URL(req.url);
const m = req.method;
try {
let parts: string[];
try {
parts = url.pathname.split("/").filter(Boolean).map(decodeURIComponent);
} catch {
return fail(400, "The path has a broken %-escape. GET / lists the routes.");
}
// Accept both /exchanges/... and /api/exchanges/... so a copied path works either way.
if (parts[0] === "api") parts.shift();
if (!parts.length && m === "GET") return json(routes);
const me = ctx.user.id;
if (parts[0] === "exchanges" && parts.length === 1 && m === "POST") {
const input = await body(req);
const fields = readFields(input);
const people = readPeople(input.people).map((p) => ({ ...p, id: randomId(20), key: randomId(24) }));
if (people.length > LIMITS.people) return fail(400, `An exchange holds up to ${LIMITS.people} people. Split the group into two exchanges.`);
const pairs = readPairs(input.keepApart ?? input.exclusions, people);
const wantDraw = input.draw === undefined ? people.length >= 3 : input.draw === true;
const drawn = wantDraw ? drawNames(people, pairs) : null;
if (drawn && !drawn.ok) return fail(409, `${drawn.problem} Nothing was created.`);
const id = randomId(20);
const ownerKey = randomId(24);
await ctx.bounded.setMany([
{ path: `exchanges/${id}`, document: { ...fields, status: "open", round: 0, drawnAt: 0, createdBy: me, createdAt: NOW } },
{ path: `exchanges/${id}/joins/${me}`, document: { at: NOW } },
{ path: `exchanges/${id}/owners/${me}`, document: { at: NOW } },
{ path: `exchangekeys/${id}`, document: { key: ownerKey } },
{ path: `keepapart/${id}`, document: { pairs: JSON.stringify(pairs) } },
]);
await addPeople(ctx, id, people);
let ex: Exchange = { ...fields, status: "open", round: 0, drawnAt: 0, createdBy: me, createdAt: 0 };
if (drawn?.ok) {
await writeDraw(ctx, id, ex, drawn.matches);
ex = { ...ex, status: "drawn", round: 1 };
}
const byId = new Map(people.map((p) => [p.id, p.name]));
return json({
id,
url: joinUrl(id),
adminUrl: adminUrl(id, ownerKey),
ownerKey,
status: ex.status,
people: people.map((p) => ({ id: p.id, name: p.name, ...personLink(ex, id, p, p.key) })),
...(drawn?.ok && ex.organizerSees ? { matches: Object.entries(drawn.matches).map(([a, b]) => ({ from: byId.get(a), to: byId.get(b) })) } : {}),
...(ex.status === "open" && ex.allowJoin ? { groupMessage: inviteMessage(ex, joinUrl(id), "en-US") } : {}),
privacy: privacyLine(ex),
nextStep: ex.status === "drawn"
? "Santa sends no emails or texts. Send each person their own url (the message is ready to copy): it shows who they give to. Do not open other people's urls: each page lists every device that opened it. Keep adminUrl and ownerKey private."
: `Names are not drawn yet${people.length < 3 ? " (it takes at least 3 people)" : ""}. Share url so people add themselves, or add them with POST /exchanges/${id}/people, then POST /exchanges/${id}/draw. Keep adminUrl and ownerKey private.`,
}, 201);
}
if (parts[0] === "exchanges" && parts.length >= 2) {
const id = parts[1];
const [len, sub] = [parts.length, parts[2]];
const isPublic = (len === 2 && m === "GET") || (len === 3 && sub === "calendar.ics" && m === "GET");
const isOwner = (len === 2 && m === "PATCH") || (len === 3 && sub === "admin" && m === "GET") || (len === 3 && sub === "people" && m === "POST")
|| (len === 4 && sub === "people" && m === "DELETE") || (len === 3 && (sub === "draw" || sub === "reopen") && m === "POST");
if (!isPublic && !isOwner) return fail(404, "Unknown route. GET / lists the routes.");
if (isOwner) await requireOwner(ctx, req, url, id);
const ex = await openExchange(ctx, id);
if (len === 3 && sub === "calendar.ics") {
if (!ex.date) return fail(404, "This exchange has no date yet, so there is no calendar file.");
return new Response(exchangeIcs(id, ex, joinUrl(id)), {
headers: { "content-type": "text/calendar; charset=utf-8", "content-disposition": `attachment; filename="${fileName(ex.name)}.ics"`, ...cors },
});
}
const { people, opens } = await load(ctx, id);
if (len === 2 && m === "GET") return json(publicExchange(id, ex, people, opens));
const pairsDoc = await ctx.bounded.get(`keepapart/${id}`);
const ids = new Set(people.map((p) => p.id));
const pairs = parsePairs(pairsDoc?.pairs, ids);
const byId = new Map(people.map((p) => [p.id, p.name]));
const mustBeOpen = (what: string) => {
if (ex.status !== "open") throw new HttpError(409, `Names are already drawn, so ${what}. POST /exchanges/${id}/reopen first; after the next draw, everyone's page says names were drawn again.`);
};
const notFinal = (what: string) => {
if (ex.status === "drawn" && revealed(ex)) throw new HttpError(409, `The reveal has passed, so the draw is final and ${what}.`);
};
if (len === 3 && sub === "admin") {
const contacts = new Map((await listAll(ctx, `exchanges/${id}/contacts`)).map((c: any) => [c.id, String(c.contact || "")]));
const keys = new Map<string, string>();
for (const p of people.filter((p) => !p.self)) {
const k = await ctx.bounded.get(`personkeys/${p.id}`);
if (k?.key) keys.set(p.id, String(k.key));
}
const canSee = ex.status === "drawn" && (ex.organizerSees || revealed(ex));
const matches = canSee ? ((await listAll(ctx, `exchanges/${id}/matches`)) as Match[]).filter((x) => x.round === ex.round && byId.has(x.id)) : [];
return json({
...publicExchange(id, ex, people, opens),
people: people.map((p) => ({
id: p.id,
name: p.name,
contact: contacts.get(p.id) || null,
joinedByLink: p.self,
opened: openedBy(opens, p.id),
...(keys.has(p.id) ? personLink(ex, id, p, keys.get(p.id)!) : { url: null, message: null }),
})),
keepApart: pairs.map(([a, b]) => [byId.get(a), byId.get(b)]),
...(canSee ? { matches: matches.map((x) => ({ from: byId.get(x.id), to: byId.get(x.to) || null })) } : {}),
groupMessage: ex.status === "drawn" ? drawnMessage(ex, joinUrl(id), "en-US") : inviteMessage(ex, joinUrl(id), "en-US"),
note: "People who joined by link hold their own key, so their url is null here: they open the join link on the device they joined with.",
});
}
if (len === 2 && m === "PATCH") {
const input = await body(req);
const fields = readFields(input, ex);
if (fields.organizerSees !== ex.organizerSees) mustBeOpen("organizerSees cannot change");
if (fields.revealAt !== ex.revealAt) notFinal("revealAt cannot change");
if (fields.revealAt !== ex.revealAt && ex.status === "drawn" && fields.revealAt !== 0 && !(ex.revealAt > 0 && fields.revealAt > ex.revealAt)) {
return fail(409, "Names are already drawn, so the reveal can only move later or be turned off (revealAt: null).");
}
const writes: { path: string; document: unknown }[] = [{ path: `exchanges/${id}`, document: fields }];
if (input.keepApart !== undefined || input.exclusions !== undefined) {
writes.push({ path: `keepapart/${id}`, document: { pairs: JSON.stringify(readPairs(input.keepApart ?? input.exclusions, people)) } });
}
await ctx.bounded.setMany(writes);
return json(publicExchange(id, { ...ex, ...fields }, people, opens));
}
if (len === 3 && sub === "people" && m === "POST") {
mustBeOpen("people cannot change");
const input = await body(req);
const added = readPeople(input.people ?? (input.name ? [input] : null), people.map((p) => p.name)).map((p) => ({ ...p, id: randomId(20), key: randomId(24) }));
if (!added.length) return fail(400, `Send people, like {"people": ["Ann", {"name": "Ben", "contact": "ben@example.com"}]}.`);
if (people.length + added.length > LIMITS.people) return fail(400, `An exchange holds up to ${LIMITS.people} people.`);
await addPeople(ctx, id, added);
return json({ added: added.map((p) => ({ id: p.id, name: p.name, ...personLink(ex, id, p, p.key) })), count: people.length + added.length }, 201);
}
if (len === 4 && sub === "people" && m === "DELETE") {
mustBeOpen("people cannot be removed");
const pid = parts[3];
if (!byId.has(pid)) return fail(404, "No person with that id in this exchange. GET /exchanges/<id>/admin lists them.");
const contact = await ctx.bounded.get(`exchanges/${id}/contacts/${pid}`);
await ctx.bounded.setMany([
{ path: `exchanges/${id}/people/${pid}`, document: null },
...(contact ? [{ path: `exchanges/${id}/contacts/${pid}`, document: null }] : []),
{ path: `keepapart/${id}`, document: { pairs: JSON.stringify(pairs.filter((pr) => !pr.includes(pid))) } },
]);
return json({ removed: byId.get(pid), count: people.length - 1 });
}
if (len === 3 && sub === "draw") {
const input = await req.json().catch(() => ({}));
notFinal("names cannot be drawn again");
if (ex.status === "drawn" && input?.again !== true) {
return fail(409, `Names are already drawn. To draw again, send {"again": true}: everyone's match changes and their pages say names were drawn again.`);
}
if (revealed(ex)) return fail(409, `The reveal time has passed, so drawing now would show everyone the list at once. Move revealAt later or set it to null with PATCH /exchanges/${id} first.`);
const result = drawNames(people, pairs);
if (!result.ok) return fail(409, result.problem);
const round = await writeDraw(ctx, id, ex, result.matches);
const after = { ...ex, status: "drawn" as const, round };
return json({
...publicExchange(id, after, people, opens),
...(after.organizerSees ? { matches: Object.entries(result.matches).map(([a, b]) => ({ from: byId.get(a), to: byId.get(b) })) } : {}),
groupMessage: drawnMessage(after, joinUrl(id), "en-US"),
nextStep: `Names are drawn. GET /exchanges/${id}/admin has the personal link and message for each person you added; people who joined by link open ${joinUrl(id)} on the device they joined with. Santa sends no emails or texts.`,
});
}
if (len === 3 && sub === "reopen") {
if (ex.status === "open") return json(publicExchange(id, ex, people, opens));
notFinal("it cannot be undone");
await ctx.bounded.set(`exchanges/${id}`, { status: "open" });
return json({ ...publicExchange(id, { ...ex, status: "open" }, people, opens), nextStep: `Change people or pairs, then POST /exchanges/${id}/draw. Every match will change.` });
}
}
if (parts[0] === "people" && parts.length >= 2) {
const pid = parts[1];
const [len, sub] = [parts.length, parts[2]];
const known = (len === 2 && m === "GET") || (len === 3 && sub === "wishlist" && (m === "PUT" || m === "POST")) || (len === 3 && (sub === "questions" || sub === "answers") && m === "POST");
if (!known) return fail(404, "Unknown route. GET / lists the routes.");
const id = await requirePerson(ctx, req, url, pid);
const ex = await openExchange(ctx, id);
const person = (await ctx.bounded.get(`exchanges/${id}/people/${pid}`)) as Person | null;
if (!person) return fail(410, "This person was removed from the exchange.");
const drawnNow = ex.status === "drawn";
const match = drawnNow ? ((await ctx.bounded.get(`exchanges/${id}/matches/${pid}`)) as Match | null) : null;
const to = match && match.round === ex.round ? match.to : null;
/** Proves this route is the Santa of `to`, which opens their wish list and questions. */
const beSanta = async () => {
if (!to) return;
const s = await ctx.bounded.get(`exchanges/${id}/santas/${to}/s/${me}`);
if (!s || s.round !== ex.round || s.from !== pid) await ctx.bounded.set(`exchanges/${id}/santas/${to}/s/${me}`, { from: pid, round: ex.round });
};
if (len === 2) {
await beSanta();
const { people, opens } = await load(ctx, id);
const byId = new Map(people.map((p) => [p.id, p.name]));
const wish = await ctx.bounded.get(`exchanges/${id}/wishes/${pid}`);
const forMe = ((await listAll(ctx, `exchanges/${id}/asks/${pid}/q`)) as Ask[]).filter((q) => q.round === ex.round).sort((a, b) => a.askedAt - b.askedAt);
let givesTo = null;
if (to) {
const theirWish = await ctx.bounded.get(`exchanges/${id}/wishes/${to}`);
const asked = ((await listAll(ctx, `exchanges/${id}/asks/${to}/q`)) as Ask[]).filter((q) => q.round === ex.round).sort((a, b) => a.askedAt - b.askedAt);
givesTo = { name: byId.get(to) || null, wishList: theirWish?.text || null, questions: asked.map((q) => ({ question: q.text, answer: q.answer || null })) };
}
let santa = null;
if (drawnNow && revealed(ex)) {
const all = (await listAll(ctx, `exchanges/${id}/matches`)) as Match[];
const from = all.find((x) => x.round === ex.round && x.to === pid);
santa = from ? byId.get(from.id) || null : null;
}
return json({
id: pid,
name: person.name,
exchange: publicExchange(id, ex, people, opens),
givesTo,
...(drawnNow ? {} : { waiting: "Names are not drawn yet. Write a wish list now; once names are drawn, this shows who they give to." }),
wishList: wish?.text || null,
questionsForYou: forMe.map((q) => ({ questionId: q.id, question: q.text, answer: q.answer || null })),
...(santa ? { yourSecretSanta: santa } : {}),
openedOn: openedBy(opens, pid),
privacy: privacyLine(ex),
});
}
const input = await body(req);
if (sub === "wishlist") {
const text = cleanText(input.text ?? input.wishList, LIMITS.wish, "text");
await ctx.bounded.set(`exchanges/${id}/wishes/${pid}`, { text, at: NOW });
return json({ wishList: text || null });
}
if (sub === "questions") {
if (!to) return fail(409, "Names are not drawn yet, so there is nobody to ask.");
const text = cleanText(input.text ?? input.question, LIMITS.question, "text", true);
await beSanta();
const qid = randomId(12);
await ctx.bounded.set(`exchanges/${id}/asks/${to}/q/${qid}`, { text, answer: "", round: ex.round, askedAt: NOW, answeredAt: 0 });
return json({ questionId: qid, question: text, note: "Asked anonymously. The answer shows on this person's page (GET /people/<personId>) once they reply." }, 201);
}
if (sub === "answers") {
const qid = cleanText(input.questionId, 40, "questionId", true);
const answer = cleanText(input.answer, LIMITS.answer, "answer", true);
const q = ID_RE.test(qid) ? ((await ctx.bounded.get(`exchanges/${id}/asks/${pid}/q/${qid}`)) as Ask | null) : null;
if (!q) return fail(404, "No question with that id for this person. GET /people/<personId> lists questionsForYou.");
await ctx.bounded.set(`exchanges/${id}/asks/${pid}/q/${qid}`, { answer, answeredAt: NOW });
return json({ questionId: qid, question: q.text, answer });
}
}
// A browser cannot write a question itself (every reader sees a document's writer), so it leaves the
// question in askrequests/<qid> and this route writes it as the API. The policy checks it is the current Santa's.
if (parts[0] === "questions" && parts.length === 2 && m === "POST") {
const qid = parts[1];
const r = ID_RE.test(qid) ? await ctx.bounded.get(`askrequests/${qid}`) : null;
if (!r) return fail(404, "No question waiting with that id. Write it to askrequests/<questionId> first.");
try {
await ctx.bounded.set(`exchanges/${r.exchangeId}/asks/${r.to}/q/${qid}`, { text: r.text, answer: "", round: r.round, askedAt: NOW, answeredAt: 0 });
} catch {
return fail(409, "That question was already sent, or the draw changed since it was written.");
}
return json({ questionId: qid, question: r.text }, 201);
}
return fail(404, "Unknown route. GET / lists the routes.");
} catch (err) {
if (err instanceof HttpError) return fail(err.status, err.message);
if (err instanceof Problem) return fail(400, err.message);
const message = err instanceof Error ? err.message : String(err);
return fail(500, `Something went wrong: ${message}`);
}
}
Change history
Point OpenApps links to openapps.free
Bind agent address in policy constants
Use functions.openapps.xyz alias, add OpenApp contribution strip
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync