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 3e1cb86700a5e96238d7a2f1
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
// When's public HTTP API: no account. A poll's id is its secret, exactly like its link, and the
// owner key (handed back once, at creation) is what lets a caller manage the poll. Every route
// answers JSON except the calendar file; GET / describes the routes. Writes go through the same
// policy rules as the web app, as this route's public principal, which every caller shares:
// so owner actions always check the key first.
import { SITE_URL, API_BASE } from "./lib/config";
import {
parseOptions, inDayOrder, stringifyOptions, keepIds, parseAnswers, tally, bestOptions, validTimeZone, zonedToUtc, isoInZone,
dateToUtcDay, utcDayToDate, optionLabel, icsFile, googleCalendarUrl, outlookCalendarUrl, fileName, randomId, LIMITS, ANSWERS,
type Answer, type Poll, type PollOption, type Vote, type Comment,
} from "./lib/when";
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, PATCH, OPTIONS",
"access-control-allow-headers": "content-type, x-owner-key, idempotency-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: r.id ?? String(r._id ?? r.pathId ?? "").split("/").pop() });
cursor = res?.nextCursor;
if (!cursor) break;
}
return rows;
}
/** Joins the poll as this route (knowing the id is the permission), then reads it, its answers and its comments. */
async function openPoll(ctx: any, pollId: string) {
if (!ID_RE.test(pollId)) throw new HttpError(404, "No poll with that id.");
const me = ctx.user.id;
const joined = await ctx.bounded.get(`polls/${pollId}/joins/${me}`);
if (!joined) {
try {
await ctx.bounded.set(`polls/${pollId}/joins/${me}`, { at: NOW });
} catch {
throw new HttpError(404, "No poll with that id.");
}
}
const poll = (await ctx.bounded.get(`polls/${pollId}`)) as Poll | null;
if (!poll) throw new HttpError(404, "No poll with that id.");
const [votes, comments] = await Promise.all([listAll(ctx, `polls/${pollId}/votes`), listAll(ctx, `polls/${pollId}/comments`)]);
return { poll, votes: votes as Vote[], comments: comments as Comment[] };
}
/** Checks the owner key, then makes this route an owner of the poll if it is not one yet. */
async function requireOwner(ctx: any, req: Request, pollId: string) {
const key = (req.headers.get("x-owner-key") || "").trim();
if (!key) throw new HttpError(401, "Send the poll's owner key in the x-owner-key header.");
const stored = await ctx.bounded.get(`pollkeys/${pollId}`);
if (!stored || stored.key !== key) throw new HttpError(403, "That owner key does not match this poll.");
const me = ctx.user.id;
if (!(await ctx.bounded.get(`polls/${pollId}/owners/${me}`))) {
await ctx.bounded.setMany([
{ path: `pollclaims/${pollId}/c/${me}`, document: { key } },
{ path: `polls/${pollId}/owners/${me}`, document: { at: NOW } },
]);
}
}
const pollUrl = (pollId: string) => `${SITE_URL}/p/${pollId}`;
const isoTime = (sec: number) => new Date(sec * 1000).toISOString().replace(/\.\d{3}Z$/, "Z");
function summary(pollId: string, poll: Poll, votes: Vote[], comments: Comment[]) {
const tz = poll.timezone;
const options = inDayOrder(parseOptions(poll.options), tz);
const counts = tally(options, votes);
const best = bestOptions(options, counts);
const url = pollUrl(pollId);
const describe = (o: PollOption) => ({
id: o.id,
...(o.allDay
? { date: utcDayToDate(o.start), allDay: true }
: { start: isoInZone(o.start, tz), ...(o.end != null ? { end: isoInZone(o.end, tz) } : {}) }),
label: optionLabel(o, tz),
...counts[o.id],
best: best.includes(o.id),
});
const chosen = options.find((o) => o.id === poll.chosen);
return {
poll: {
id: pollId, title: poll.title, description: poll.description ?? null, location: poll.location ?? null,
timezone: tz, url, status: chosen ? "decided" : poll.closed ? "closed" : "open",
},
options: options.map(describe),
participants: [...votes].sort((a, b) => (a.updatedAt ?? 0) - (b.updatedAt ?? 0)).map((v) => {
const a = parseAnswers(v.answers);
return { name: v.name, answers: Object.fromEntries(options.filter((o) => a[o.id]).map((o) => [o.id, a[o.id]])) };
}),
best,
chosen: chosen
? { ...describe(chosen), ics: `${API_BASE}/polls/${pollId}/ics`, googleCalendar: googleCalendarUrl(poll, chosen, url), outlookCalendar: outlookCalendarUrl(poll, chosen, url) }
: null,
comments: [...comments].sort((a, b) => (a.at ?? 0) - (b.at ?? 0)).map((c) => ({ name: c.name, text: c.text, at: isoTime(c.at ?? 0) })),
};
}
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
const LOCAL_RE = /^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2})(:\d{2})?$/;
/** An ISO time as UTC seconds. Without an offset it is read in the poll's time zone. */
function parseInstant(value: unknown, tz: string, field: string): number {
const s = String(value ?? "").trim();
const local = s.match(LOCAL_RE);
if (local) return zonedToUtc(local[1], local[2], tz);
const t = Date.parse(s);
if (!s || !/^\d{4}-\d{2}-\d{2}T/.test(s) || !Number.isFinite(t)) throw new HttpError(400, `${field} must be a time like "2026-10-21T15:00:00-04:00".`);
return Math.floor(t / 1000);
}
/** Turns one friendly option (an ISO time, a date, or {start, end} / {date, allDay}) into a stored slot. */
function parseOption(input: unknown, tz: string): Omit<PollOption, "id"> {
if (typeof input === "string") {
if (DATE_RE.test(input.trim())) return { start: dateToUtcDay(input.trim()), allDay: true };
return { start: parseInstant(input, tz, "Each option") };
}
if (input && typeof input === "object" && !Array.isArray(input)) {
const o = input as Record<string, unknown>;
if (o.date != null && (o.allDay || o.start == null)) {
if (!DATE_RE.test(String(o.date))) throw new HttpError(400, `date must look like "2026-10-22".`);
return { start: dateToUtcDay(String(o.date)), allDay: true };
}
if (typeof o.start === "string" && DATE_RE.test(o.start.trim())) return { start: dateToUtcDay(o.start.trim()), allDay: true };
const start = parseInstant(o.start, tz, "start");
if (o.end == null || o.end === "") return { start };
const end = parseInstant(o.end, tz, "end");
if (end <= start) throw new HttpError(400, "Each option's end must be after its start.");
if (end - start > 7 * 86400) throw new HttpError(400, "An option can last at most a week.");
return { start, end };
}
throw new HttpError(400, `Each option is an ISO time ("2026-10-21T15:00:00-04:00"), a date ("2026-10-22") for all day, or {"start", "end"}.`);
}
function parseOptionList(list: unknown, tz: string, existing: PollOption[]): PollOption[] {
if (!Array.isArray(list) || !list.length) throw new HttpError(400, "options must be a list with at least one time.");
if (list.length > LIMITS.options) throw new HttpError(400, `A poll can have up to ${LIMITS.options} time options.`);
return keepIds(list.map((x) => parseOption(x, tz)), existing);
}
/** Finds an option by id, or by its start time ("2026-10-21T15:00:00-04:00") or date ("2026-10-22"). */
function findOption(options: PollOption[], ref: string, tz: string): PollOption | undefined {
const byId = options.find((o) => o.id === ref);
if (byId) return byId;
try {
const slot = parseOption(ref, tz);
return options.find((o) => Boolean(o.allDay) === Boolean(slot.allDay) && o.start === slot.start);
} catch {
return undefined;
}
}
function parseAnswer(value: unknown): Answer | null {
const s = String(value ?? "").trim().toLowerCase().replace(/[\s_-]+/g, "");
if (s === "ifneedbe") return "maybe";
return ANSWERS.includes(s as Answer) ? (s as Answer) : null;
}
function text(input: Record<string, any>, field: string, max: number, required = false): string | undefined {
const value = input[field] == null ? "" : String(input[field]).trim();
if (required && !value) throw new HttpError(400, `${field} is required.`);
if (value.length > max) throw new HttpError(400, `Keep ${field} under ${max} characters.`);
return value || undefined;
}
const routes = {
name: "When by OpenApps",
about: "Free group scheduling polls. No key and no account: a poll's id is its secret, like its link. The owner key from POST /polls manages the poll.",
docs: `${SITE_URL}/llms.txt`,
openapi: `${SITE_URL}/openapi.json`,
routes: [
"POST /polls {title, description?, location?, timezone?, options: [\"2026-10-21T15:00:00-04:00\", {start, end}, {date, allDay: true}]} -> pollId, url, adminUrl, ownerKey",
"GET /polls/:id -> the poll, each option with yes/maybe/no counts, participants and their answers, the best option(s), the chosen time, comments",
"POST /polls/:id/votes {name, answers: {<optionId or start time>: \"yes\" | \"maybe\" | \"no\"}, voteId?} -> voteId",
"POST /polls/:id/comments {name, text} -> adds a comment everyone with the link sees",
"PATCH /polls/:id {title?, description?, location?, timezone?, options?} with header x-owner-key",
"POST /polls/:id/choose {optionId} with header x-owner-key -> the final time (closes the poll)",
"POST /polls/:id/close and POST /polls/:id/reopen with header x-owner-key",
"GET /polls/:id/ics -> a calendar file for the chosen time (404 until one is chosen)",
],
};
export default async function api(req: Request, ctx: any) {
if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: cors });
const parts = new URL(req.url).pathname.split("/").filter(Boolean);
// Accept both /polls/... and /api/polls/... so a copied path works either way.
if (parts[0] === "api") parts.shift();
try {
if (!parts.length && req.method === "GET") return json(routes);
if (parts[0] !== "polls") return fail(404, "Unknown route. GET / lists the routes.");
const me = ctx.user.id;
if (parts.length === 1 && req.method === "POST") {
const input = await body(req);
const title = text(input, "title", LIMITS.title, true)!;
const description = text(input, "description", LIMITS.description);
const location = text(input, "location", LIMITS.location);
const timezone = input.timezone == null || input.timezone === "" ? "UTC" : String(input.timezone).trim();
if (!validTimeZone(timezone)) return fail(400, `timezone must be an IANA time zone like "America/New_York".`);
const options = parseOptionList(input.options, timezone, []);
const pollId = randomId(20);
const ownerKey = randomId(24);
const poll = { title, ...(description ? { description } : {}), ...(location ? { location } : {}), timezone, options: stringifyOptions(options), closed: false };
await ctx.bounded.setMany([
{ path: `polls/${pollId}`, document: { ...poll, createdBy: me, createdAt: NOW } },
{ path: `polls/${pollId}/joins/${me}`, document: { at: NOW } },
{ path: `polls/${pollId}/owners/${me}`, document: { at: NOW } },
{ path: `pollkeys/${pollId}`, document: { key: ownerKey } },
]);
return json({
pollId,
url: pollUrl(pollId),
adminUrl: `${pollUrl(pollId)}/admin#k=${ownerKey}`,
ownerKey,
share: `Send ${pollUrl(pollId)} to the group. Keep the adminUrl and ownerKey private: they let you edit the poll and pick the final time.`,
...summary(pollId, { ...poll, createdBy: me, createdAt: Math.floor(Date.now() / 1000) }, [], []),
}, 201);
}
const pollId = parts[1];
const { poll, votes, comments } = await openPoll(ctx, pollId);
const options = parseOptions(poll.options);
if (parts.length === 2 && req.method === "GET") return json(summary(pollId, poll, votes, comments));
if (parts.length === 3 && parts[2] === "ics" && req.method === "GET") {
const chosen = options.find((o) => o.id === poll.chosen);
if (!chosen) return fail(404, "No final time has been picked for this poll yet.");
return new Response(icsFile(pollId, poll, chosen, pollUrl(pollId)), {
headers: {
"content-type": "text/calendar; charset=utf-8",
"content-disposition": `attachment; filename="${fileName(poll.title)}.ics"`,
...cors,
},
});
}
if (parts.length === 3 && parts[2] === "votes" && req.method === "POST") {
const input = await body(req);
if (poll.closed) return fail(409, "This poll is closed, so answers can no longer change.");
const name = text(input, "name", LIMITS.name, true)!;
if (!input.answers || typeof input.answers !== "object" || Array.isArray(input.answers)) {
return fail(400, `answers must be an object of option id (or start time) to "yes", "maybe" or "no".`);
}
const answers: Record<string, Answer> = Object.fromEntries(options.map((o) => [o.id, "no" as Answer]));
for (const [ref, value] of Object.entries(input.answers)) {
const option = findOption(options, ref, poll.timezone);
if (!option) return fail(400, `"${ref}" is not one of this poll's options. GET /polls/${pollId} lists them.`);
const answer = parseAnswer(value);
if (!answer) return fail(400, `The answer for "${ref}" must be "yes", "maybe" or "no".`);
answers[option.id] = answer;
}
let voteId = input.voteId == null || input.voteId === "" ? null : String(input.voteId);
if (voteId) {
const existing = votes.find((v) => v.id === voteId);
if (!existing) return fail(404, "No answers with that voteId in this poll.");
if (existing.by !== me) return fail(403, "That row was added in the web app, so only its author can change it there.");
}
voteId ??= randomId(16);
await ctx.bounded.set(`polls/${pollId}/votes/${voteId}`, { name, answers: JSON.stringify(answers), by: me, updatedAt: NOW });
const updated = [...votes.filter((v) => v.id !== voteId), { id: voteId, name, answers: JSON.stringify(answers), by: me, updatedAt: Math.floor(Date.now() / 1000) }];
return json({ voteId, ...summary(pollId, poll, updated, comments) }, input.voteId ? 200 : 201);
}
if (parts.length === 3 && parts[2] === "comments" && req.method === "POST") {
const input = await body(req);
const name = text(input, "name", LIMITS.name, true)!;
const said = text(input, "text", LIMITS.comment, true)!;
const commentId = randomId(16);
await ctx.bounded.set(`polls/${pollId}/comments/${commentId}`, { name, text: said, by: me, at: NOW });
const added = { id: commentId, name, text: said, by: me, at: Math.floor(Date.now() / 1000) };
return json({ commentId, ...summary(pollId, poll, votes, [...comments, added]) }, 201);
}
if (parts.length === 2 && req.method === "PATCH") {
await requireOwner(ctx, req, pollId);
const input = await body(req);
const next: Record<string, unknown> = {};
if ("title" in input) next.title = text(input, "title", LIMITS.title, true);
if ("description" in input) next.description = text(input, "description", LIMITS.description) ?? null;
if ("location" in input) next.location = text(input, "location", LIMITS.location) ?? null;
const timezone = input.timezone == null ? poll.timezone : String(input.timezone).trim();
if (!validTimeZone(timezone)) return fail(400, `timezone must be an IANA time zone like "America/New_York".`);
next.timezone = timezone;
if ("options" in input) {
const list = parseOptionList(input.options, timezone, options);
next.options = stringifyOptions(list);
if (poll.chosen && !list.some((o) => o.id === poll.chosen)) next.chosen = null;
}
await ctx.bounded.set(`polls/${pollId}`, next);
return json(summary(pollId, { ...poll, ...next } as Poll, votes, comments));
}
if (parts.length === 3 && ["choose", "close", "reopen"].includes(parts[2]) && req.method === "POST") {
await requireOwner(ctx, req, pollId);
let next: Partial<Poll>;
if (parts[2] === "choose") {
const input = await body(req);
const ref = String(input.optionId ?? input.option ?? "").trim();
const option = ref ? findOption(options, ref, poll.timezone) : undefined;
if (!option) return fail(400, `optionId must be one of this poll's option ids. GET /polls/${pollId} lists them.`);
next = { chosen: option.id, closed: true };
} else {
next = parts[2] === "close" ? { closed: true } : { closed: false, chosen: null };
}
await ctx.bounded.set(`polls/${pollId}`, next);
return json(summary(pollId, { ...poll, ...next }, votes, comments));
}
return fail(404, "Unknown route. GET / lists the routes.");
} catch (err) {
if (err instanceof HttpError) return fail(err.status, err.message);
const message = err instanceof Error ? err.message : String(err);
return fail(500, `Something went wrong: ${message}`);
}
}
Change history
Quality pass: comments, CSV export, share the final time, calendar menu, fixes
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync