santa-free.openapps.xyz

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.
Owned app · rev b8b257a837b1
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.
← All files
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
b8b257a837 · 18 hours ago
Bind agent address in policy constants
9a72d0200b · 20 hours ago
Use functions.openapps.xyz alias, add OpenApp contribution strip
60038fa63d · 20 hours ago
Bounded source sync
a2e0247232 · 1 day ago
Bounded source sync
c84c736f8b · 1 day ago
Bounded source sync
3a54a80cdc · 1 day ago
Bounded source sync
2eed41b4de · 1 day ago