polls-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 c06cea4889e9
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.
← All files
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
c06cea4889 · 17 hours ago
Bind agent address in policy constants
a7de116d52 · 19 hours ago
Use OpenApps service URLs and add contribution strip
e8ce0b0bf0 · 19 hours ago
Bounded source sync
dcdc4d56e6 · 1 day ago
Bounded source sync
7acdace1e1 · 1 day ago
Bounded source sync
2140a66ea0 · 1 day ago
Bounded source sync
0db2fe3ca0 · 1 day ago
Bounded source sync
996f633a3c · 1 day ago