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 334801de44cb0fe911d5a88b
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
// Polls' public HTTP API: no account. An assistant can create an event with its polls in one call and
// read the results later with the owner key it got back. Voting happens in the browser, where each person
// is their own guest account; the web app sends audience questions through this API. GET / describes the routes.
// Writes go through the same policy rules as the web app, as this route's public principal.
import { SITE_URL } from "./lib/config";
import {
checkAsk, checkPoll, checkTitle, eventWrites, toPoll, byPos, summarize, rankedOptions, resultsCsv, questionsCsv, byVotes, fileName, randomId, randomCode, formatCode,
POLL_TYPES, LIMITS, EVENT_ID_RE, QUESTION_ID_RE, QUESTION_STATUS, type EventDoc, type PollDoc, type PollInput, type Poll, type QuestionDoc, type Summary,
} from "./lib/polls";
const NOW = { operation: "time", value: "now" };
const cors = {
"access-control-allow-origin": "*",
"access-control-allow-methods": "GET, POST, OPTIONS",
"access-control-allow-headers": "content-type, x-owner-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);
const csvFile = (body: string, name: string) =>
new Response(body, { headers: { "content-type": "text/csv; charset=utf-8", "content-disposition": `attachment; filename="${name}"`, ...cors } });
class HttpError extends Error { constructor(public status: number, message: string) { super(message); } }
async function jsonBody(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 < 200; page++) {
const res = await ctx.bounded.get(path, { limit: 1000, ...(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 event as this route (knowing the id is the permission to read it), then reads it. */
async function openEvent(ctx: any, eventId: string): Promise<EventDoc> {
if (!EVENT_ID_RE.test(eventId)) throw new HttpError(404, "No event with that id.");
const me = ctx.user.id;
if (!(await ctx.bounded.get(`events/${eventId}/joins/${me}`))) {
try {
await ctx.bounded.set(`events/${eventId}/joins/${me}`, { at: NOW });
} catch {
throw new HttpError(404, "No event with that id.");
}
}
const event = await ctx.bounded.get(`events/${eventId}`);
if (!event) throw new HttpError(404, "No event with that id.");
return event;
}
/** Checks the owner key, then makes this route an owner of the event (once) so it can read votes. */
async function asOwner(ctx: any, req: Request, url: URL, eventId: string): Promise<EventDoc> {
const key = (req.headers.get("x-owner-key") || url.searchParams.get("key") || "").trim();
if (!key) throw new HttpError(401, "Send the event's owner key in the x-owner-key header (or as ?key=).");
const event = await openEvent(ctx, eventId);
const stored = await ctx.bounded.get(`eventkeys/${eventId}`);
if (!stored?.key || stored.key !== key) throw new HttpError(403, "That owner key does not match this event.");
const me = ctx.user.id;
if (!(await ctx.bounded.get(`events/${eventId}/owners/${me}`))) {
await ctx.bounded.setMany([
{ path: `eventclaims/${eventId}/c/${me}`, document: { key } },
{ path: `events/${eventId}/owners/${me}`, document: { at: NOW } },
]);
}
return event;
}
const links = (eventId: string, code: string, key?: string) => ({
code,
joinUrl: `${SITE_URL}/e/${eventId}`,
joinAt: `Go to ${SITE_URL.replace("https://", "")} and enter ${formatCode(code)}`,
...(key ? { adminUrl: `${SITE_URL}/e/${eventId}/admin#k=${key}`, presentUrl: `${SITE_URL}/e/${eventId}/present#k=${key}` } : {}),
});
const pollOut = (p: Poll) => ({
id: p.id, type: p.type, question: p.question, status: p.status,
...(p.options.length ? { options: p.options } : {}),
...(p.type === "choice" ? { multi: p.multi } : {}),
...(p.type === "rating" ? { scale: p.max } : {}),
...(p.type === "wordcloud" ? { wordsPerPerson: p.max } : {}),
});
/** A summary in words an assistant can repeat: options with counts, averages, top words, answers. */
function readable(p: Poll, s: Summary) {
const base = { ...pollOut(p), answered: s.n };
switch (p.type) {
case "choice": return { ...base, results: p.options.map((option, i) => ({ option, votes: s.counts?.[i] ?? 0 })) };
case "rating": return { ...base, average: s.avg ?? null, distribution: (s.dist || []).map((votes, i) => ({ rating: i + 1, votes })) };
case "wordcloud": return { ...base, words: (s.words || []).slice(0, 100).map(([word, count]) => ({ word, count })) };
case "open": return { ...base, answers: s.texts || [] };
case "ranking": return { ...base, ranking: rankedOptions(p, s).map((r, i) => ({ place: i + 1, option: r.option, averagePosition: r.avg })) };
}
}
async function readPolls(ctx: any, eventId: string): Promise<Poll[]> {
return (await listAll(ctx, `events/${eventId}/polls`) as (PollDoc & { id: string })[]).map((r) => toPoll(r.id, r)).sort(byPos);
}
const routes = {
name: "Polls by OpenApps",
about: "Free live polls and audience Q&A for meetings, classes and talks. No account and no caps. Creating an event returns a 6-digit join code, the audience link, and an owner key: keep the key to read results.",
docs: `${SITE_URL}/llms.txt`,
openapi: `${SITE_URL}/openapi.json`,
pollTypes: Object.keys(POLL_TYPES),
routes: [
"POST /events {title, polls: [{type, question, options?, multi?, scale?, words?, open?}], qa?, moderate?, allowChange?} -> eventId, code, joinUrl, adminUrl, presentUrl, ownerKey",
"GET /events/:id -> the event as the audience sees it: title, code, open and closed polls",
"POST /events/:id/questions {text, name?, id?} -> asks the speaker a question; it waits for approval if the host moderates",
"GET /events/:id/results?format=json|csv (header x-owner-key) -> every poll's results and the Q&A",
"GET /events/:id/questions?format=json|csv (header x-owner-key) -> every audience question, most upvoted first",
],
};
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 parts = url.pathname.split("/").filter(Boolean);
// Accept both /events/... and /api/events/... 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] !== "events") return fail(404, "Unknown route. GET / lists the routes.");
const me = ctx.user.id;
if (parts.length === 1 && req.method === "POST") {
const input = await jsonBody(req);
const title = checkTitle(input.title ?? input.name);
if ("error" in title) return fail(400, title.error);
const rawPolls = input.polls ?? [];
if (!Array.isArray(rawPolls)) return fail(400, "polls must be a list.");
if (rawPolls.length > LIMITS.polls) return fail(400, `One call can add up to ${LIMITS.polls} polls.`);
const polls: { input: PollInput; id: string; status: "draft" | "open" }[] = [];
for (const [i, raw] of rawPolls.entries()) {
const checked = checkPoll(raw, `Poll ${i + 1}`);
if ("error" in checked) return fail(400, checked.error);
polls.push({ input: checked.poll, id: randomId(12), status: raw?.open === true ? "open" : "draft" });
}
for (const k of ["qa", "moderate", "allowChange"]) if (input[k] !== undefined && typeof input[k] !== "boolean") return fail(400, `${k} must be true or false.`);
const eventId = randomId(20);
const key = randomId(24);
// Codes are never reused, so a taken code refuses the batch; try a few fresh ones.
let code = "";
for (let attempt = 0; attempt < 8 && !code; attempt++) {
const candidate = randomCode();
try {
await ctx.bounded.setMany(eventWrites({ eventId, code: candidate, key, me, title: title.title, polls, qa: input.qa, moderate: input.moderate, allowChange: input.allowChange }));
code = candidate;
} catch (err) {
if (attempt === 7) throw err;
}
}
return json({
eventId, ...links(eventId, code, key), ownerKey: key, title: title.title,
polls: polls.map((p, i) => ({ id: p.id, type: p.input.type, question: p.input.question, status: p.status, position: i + 1 })),
note: "Show the audience the code or joinUrl. Keep ownerKey, adminUrl and presentUrl private: they run the event and read every answer. Open presentUrl on the screen you share; arrow keys move between polls and R shows results.",
}, 201);
}
const eventId = parts[1];
if (parts.length === 2 && req.method === "GET") {
const event = await openEvent(ctx, eventId);
// This route owns events made through the API, so it can read drafts: keep them out of the public view.
const polls = (await readPolls(ctx, eventId)).filter((p) => p.status !== "draft");
return json({
eventId, title: event.title, ...links(eventId, event.code), qa: event.qa,
polls: polls.map(pollOut),
note: "People vote and ask questions in the browser at joinUrl. Results need the owner key: GET /events/:id/results.",
});
}
// Audience questions are stored by this route, not by the person asking: every stored document names the
// account that wrote it, and owners read votes by account, so a question written from the asker's own
// account would tie it to them. The web app first records the id in events/:id/asked/<them> (readable
// only by them), so they still see their own question while it waits for approval.
if (parts.length === 3 && req.method === "POST" && parts[2] === "questions") {
const input = await jsonBody(req);
const asked = checkAsk(input.text ?? input.question, input.name);
if ("error" in asked) return fail(400, asked.error);
const id = input.id === undefined ? randomId(16) : String(input.id);
if (!QUESTION_ID_RE.test(id)) return fail(400, "id must be 16 letters or digits. Leave it out to get a new one.");
const event = await openEvent(ctx, eventId);
if (!event.qa) return fail(403, "This event is not taking questions right now.");
const path = `events/${eventId}/questions/${id}`;
if (await ctx.bounded.get(path)) return fail(409, "A question with that id already exists. Send a new id.");
const status = event.moderate ? "pending" : "live";
await ctx.bounded.set(path, { text: asked.text, name: asked.name, status, upvotes: 0, createdAt: NOW });
return json({ id, status, note: status === "pending" ? "The host approves questions before everyone sees them." : "Everyone in the event can see and upvote it." }, 201);
}
if (parts.length === 3 && req.method === "GET" && (parts[2] === "results" || parts[2] === "questions")) {
const event = await asOwner(ctx, req, url, eventId);
const format = (url.searchParams.get("format") || "json").toLowerCase();
if (format !== "json" && format !== "csv") return fail(400, "format must be json or csv.");
const questions = (await listAll(ctx, `events/${eventId}/questions`) as QuestionDoc[]).sort(byVotes);
const qaOut = questions.map((q) => ({ question: q.text, name: q.name || null, upvotes: q.upvotes, status: q.status, statusLabel: QUESTION_STATUS[q.status], askedAt: new Date(q.createdAt * 1000).toISOString() }));
if (parts[2] === "questions") {
if (format === "csv") return csvFile(questionsCsv(questions), fileName(event.title, "questions"));
return json({ title: event.title, count: questions.length, questions: qaOut });
}
const polls = await readPolls(ctx, eventId);
const summaries = await Promise.all(polls.map(async (p) => summarize(p, await listAll(ctx, `events/${eventId}/polls/${p.id}/votes`))));
if (format === "csv") return csvFile(resultsCsv(polls, summaries), fileName(event.title, "results"));
return json({
eventId, title: event.title, ...links(eventId, event.code),
polls: polls.map((p, i) => readable(p, summaries[i])),
questions: qaOut,
});
}
return fail(404, "Unknown route. GET / lists the routes.");
} catch (err) {
const status = err instanceof HttpError ? err.status : 500;
const message = err instanceof HttpError ? err.message : `Something went wrong: ${err instanceof Error ? err.message : String(err)}`;
return fail(status, message);
}
}
Change history
Point OpenApps links to openapps.free
Bind agent address in policy constants
Use OpenApps service URLs and add contribution strip
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync