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 ee59501e91bae6d2c23a8370
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
// Lists' public HTTP API, for AI and voice assistants. Making a list needs no key. Everything else
// needs the list key (from the list's Share screen) in the x-list-key header, and every route checks
// it before reading or writing, because every API caller acts as the same public principal.
// Writes go through the same policy rules as the web app.
import { SITE_URL } from "./lib/config";
import {
LIMITS, describeItem, findSection, groupItems, guessSection, isSection, itemKey, listKey, parseEntry, randomId, sectionLabel,
splitEntries, splitListKey, tidyName, type Item, type Kind,
} from "./lib/lists";
const NOW = { operation: "time", value: "now" };
const ONE = { operation: "increment", value: 1 };
const nowSec = () => Math.floor(Date.now() / 1000);
const cors = {
"access-control-allow-origin": "*",
"access-control-allow-methods": "GET, POST, PATCH, DELETE, OPTIONS",
"access-control-allow-headers": "content-type, x-list-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, optional = false): Promise<Record<string, any>> {
const text = await req.text();
if (optional && !text.trim()) return {};
try {
const parsed = JSON.parse(text);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
} catch { /* fall through */ }
throw new HttpError(400, "Send a JSON object body.");
}
const bareId = (r: any) => r.id ?? String(r._id ?? r.pathId ?? "").split("/").pop();
async function listAll(ctx: any, path: string): Promise<any[]> {
const rows: any[] = [];
let cursor: string | undefined;
for (let page = 0; page < 10; page++) {
const res = await ctx.bounded.get(path, { limit: 500, ...(cursor ? { cursor } : {}) });
for (const r of res?.data || []) rows.push({ ...r, id: bareId(r) });
cursor = res?.nextCursor;
if (!cursor) break;
}
return rows;
}
interface Opened { id: string; name: string; kind: Kind; order: string; items: Item[] }
/**
* Checks the list key, then opens the list. The claim write only succeeds with the list's current
* key, so it is the check. The route joins the list the first time, as "Assistant".
*/
async function openList(ctx: any, req: Request): Promise<Opened> {
const raw = req.headers.get("x-list-key") || "";
const parts = splitListKey(raw);
if (!parts) throw new HttpError(401, "Send the list key in the x-list-key header. It is 44 letters and digits; the person finds it in the app under Share, For assistants.");
const { listId: id, secret } = parts;
const me = ctx.user.id;
try {
await ctx.bounded.set(`lists/${id}/claims/${me}`, { key: secret, n: randomId(12) });
} catch {
throw new HttpError(403, "That list key does not work. The list's link may have been changed: ask the person for the current key from the list's Share screen.");
}
if (!(await ctx.bounded.get(`lists/${id}/members/${me}`))) {
try {
await ctx.bounded.set(`lists/${id}/members/${me}`, { name: "Assistant", at: NOW });
} catch {
throw new HttpError(410, "This list was deleted.");
}
}
const list = await ctx.bounded.get(`lists/${id}`);
if (!list) throw new HttpError(404, "That list no longer exists.");
if (list.deleted) throw new HttpError(410, "This list was deleted.");
const items = (await listAll(ctx, `lists/${id}/items`)) as Item[];
return { id, name: list.name, kind: list.kind === "todo" ? "todo" : "grocery", order: list.order || "", items };
}
const itemOut = (i: Item) => ({
id: i.id, name: i.name, quantity: i.qty || null, note: i.note || null, section: isSection(i.section) ? i.section : "other", sectionName: sectionLabel(i.section), checked: Boolean(i.checked),
});
function summary(list: Opened, extra: Record<string, unknown> = {}) {
const { groups, checked } = groupItems(list.items, list.kind, list.order);
const toGet = groups.flatMap((g) => g.items);
const names = toGet.map(describeItem);
const what = list.kind === "todo" ? (toGet.length === 1 ? "thing left" : "things left") : (toGet.length === 1 ? "item to get" : "items to get");
const say = toGet.length
? `${list.name} has ${toGet.length} ${what}: ${names.slice(0, 15).join(", ")}${names.length > 15 ? `, and ${names.length - 15} more` : ""}.`
: `${list.name} is empty${checked.length ? `; ${checked.length} checked off` : ""}.`;
return {
...extra,
list: { id: list.id, name: list.name, kind: list.kind, url: `${SITE_URL}/l/${list.id}` },
toGet: toGet.map(itemOut),
checkedOff: checked.map(itemOut),
say,
};
}
interface Wanted { name: string; qty: string; note: string; section: string | null }
/** Turns {items: ["2 lb chicken", {name, quantity, note, section}]} or {text: "milk and eggs"} into entries. */
function wanted(input: Record<string, any>): Wanted[] {
const out: Wanted[] = [];
const add = (raw: unknown) => {
if (typeof raw === "string") {
for (const part of splitEntries(raw)) {
const e = parseEntry(part);
out.push({ name: tidyName(e.name), qty: e.qty, note: "", section: null });
}
return;
}
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new HttpError(400, 'Each item is a string like "2 lb chicken" or an object like {"name": "chicken", "quantity": "2 lb"}.');
const o = raw as Record<string, unknown>;
const e = parseEntry(String(o.name ?? ""));
const section = o.section == null || o.section === "" ? null : findSection(o.section);
if (o.section != null && o.section !== "" && !section) throw new HttpError(400, `"${o.section}" is not a section. Use one of: produce, bakery, deli, meat, dairy, frozen, pantry, baking, breakfast, snacks, drinks, household, personal, baby, pets, other.`);
out.push({ name: tidyName(e.name), qty: String(o.quantity ?? o.qty ?? "").trim() || e.qty, note: String(o.note ?? "").trim(), section });
};
if (typeof input.text === "string") add(input.text);
if (Array.isArray(input.items)) input.items.forEach(add);
else if (input.items != null) throw new HttpError(400, 'items must be a list, like ["milk", "2 lb chicken"].');
if (input.name != null && input.items == null && input.text == null) add(input);
const clean = out.filter((w) => w.name);
if (!clean.length) throw new HttpError(400, 'Say what to add: {"items": ["milk", "eggs"]} or {"text": "milk and eggs"}.');
if (clean.length > 50) throw new HttpError(400, "Add up to 50 items at a time.");
for (const w of clean) {
if (w.name.length > LIMITS.itemName) throw new HttpError(400, `Keep item names under ${LIMITS.itemName} characters.`);
if (w.qty.length > LIMITS.qty) throw new HttpError(400, `Keep quantities under ${LIMITS.qty} characters.`);
if (w.note.length > LIMITS.note) throw new HttpError(400, `Keep notes under ${LIMITS.note} characters.`);
}
return clean;
}
/**
* The writes that add entries to a list: a new item, or the item already there (checked off items come
* back). Also remembers each item for suggestions and its section for next time.
*/
async function addWrites(ctx: any, listId: string, items: Item[], entries: Wanted[]) {
const writes: { path: string; document: Record<string, unknown> }[] = [];
const added: Item[] = [];
const already: string[] = [];
const seen = new Set<string>();
let addedAt = Date.now();
for (const w of entries) {
const id = itemKey(w.name);
if (seen.has(id)) continue;
seen.add(id);
const existing = items.find((i) => i.id === id);
const known = w.section ? null : await ctx.bounded.get(`lists/${listId}/known/${id}`);
const section = w.section || existing?.section || known?.section || guessSection(w.name);
if (existing && !existing.checked && !w.qty && !w.note && !w.section) {
already.push(existing.name);
continue;
}
if (existing) {
const patch = { checked: false, checkedAt: null, section, ...(w.qty ? { qty: w.qty } : {}), ...(w.note ? { note: w.note } : {}) };
writes.push({ path: `lists/${listId}/items/${id}`, document: patch });
added.push({ ...existing, ...patch });
} else {
writes.push({ path: `lists/${listId}/items/${id}`, document: { name: w.name, qty: w.qty, note: w.note, section, checked: false, addedAt } });
added.push({ id, name: w.name, qty: w.qty, note: w.note, section, checked: false, addedAt: addedAt++ });
}
writes.push({ path: `lists/${listId}/known/${id}`, document: { name: existing?.name ?? w.name, section, uses: ONE, at: nowSec() } });
}
return { writes, added, already };
}
function findItem(list: Opened, ref: string): Item {
let s: string;
try { s = decodeURIComponent(ref).trim(); } catch { throw new HttpError(400, 'The item in the path is not URL-encoded correctly. Encode spaces as %20, like "olive%20oil".'); }
const hit = list.items.find((i) => i.id === s) || list.items.find((i) => i.id === itemKey(s));
if (!hit) throw new HttpError(404, `"${s}" is not on ${list.name}. GET /list shows what is.`);
return hit;
}
function merge(list: Opened, changed: Item[], removed: string[] = []): Opened {
const byId = new Map(list.items.map((i) => [i.id, i]));
for (const id of removed) byId.delete(id);
for (const c of changed) byId.set(c.id, { ...byId.get(c.id), ...c });
return { ...list, items: [...byId.values()] };
}
const routes = {
name: "Lists by OpenApps",
about: "Free shared grocery and to-do lists that update live for everyone in the house. Making a list needs no key; reading and changing one needs its list key in the x-list-key header.",
docs: `${SITE_URL}/llms.txt`,
openapi: `${SITE_URL}/openapi.json`,
routes: [
"POST /lists {name?, kind?: grocery|todo, items?: [...]} -> the new list, its key, its share link and its admin link",
"GET /list (x-list-key) -> what to get, grouped in store order, what is checked off, and a sentence to read out",
"POST /list/items (x-list-key) {items: [\"milk\", \"2 lb chicken\"]} or {text: \"milk and eggs\"}",
"PATCH /list/items/:item (x-list-key) {checked?, name?, quantity?, note?, section?} :item is the item's id or name",
"DELETE /list/items/:item (x-list-key)",
"POST /list/clear-checked (x-list-key) -> removes everything checked off",
],
};
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 /list/... and /api/list/... so a copied path works either way.
if (parts[0] === "api") parts.shift();
try {
if (!parts.length && req.method === "GET") return json(routes);
const me = ctx.user.id;
if (parts[0] === "lists" && parts.length === 1 && req.method === "POST") {
const input = await body(req, true);
const name = String(input.name ?? "Groceries").trim();
const kind: Kind = input.kind == null || input.kind === "" ? (/^(to ?do|todo|tasks?|packing)/i.test(name) ? "todo" : "grocery") : input.kind;
if (!name || name.length > LIMITS.listName) return fail(400, `name must be 1 to ${LIMITS.listName} characters.`);
if (kind !== "grocery" && kind !== "todo") return fail(400, 'kind must be "grocery" (items group by store section) or "todo".');
const entries = input.items == null && input.text == null ? [] : wanted({ items: input.items, text: input.text });
const id = randomId(20);
const secret = randomId(24);
const ownerKey = randomId(24);
const { writes, added } = await addWrites(ctx, id, [], entries);
await ctx.bounded.setMany([
{ path: `lists/${id}`, document: { name, kind, order: "", createdBy: me, createdAt: NOW } },
{ path: `lists/${id}/members/${me}`, document: { name: "Assistant", at: NOW } },
{ path: `lists/${id}/owners/${me}`, document: { at: NOW } },
{ path: `listkeys/${id}`, document: { key: secret } },
{ path: `ownerkeys/${id}`, document: { key: ownerKey } },
]);
// One write holds at most 100 documents and 50 items are 100 (item plus history), so items go in their own write.
if (writes.length) await ctx.bounded.setMany(writes);
const list: Opened = { id, name, kind, order: "", items: added };
return json(summary(list, {
key: listKey(id, secret),
shareUrl: `${SITE_URL}/l/${id}#${secret}`,
adminUrl: `${SITE_URL}/l/${id}/admin#${ownerKey}`,
next: "Send shareUrl to the person: they and anyone they share it with can open the list and add to it live. Give adminUrl only to the person who owns the list: it lets them remove people and delete the list. Keep key to read and change the list later.",
}), 201);
}
if (parts[0] !== "list") return fail(404, "Unknown route. GET / lists the routes.");
const list = await openList(ctx, req);
if (parts.length === 1 && req.method === "GET") return json(summary(list));
if (parts[1] === "items" && parts.length === 2 && req.method === "POST") {
const entries = wanted(await body(req));
const { writes, added, already } = await addWrites(ctx, list.id, list.items, entries);
if (writes.length) await ctx.bounded.setMany(writes);
return json(summary(merge(list, added), { added: added.map(itemOut), alreadyOnList: already }), writes.length ? 201 : 200);
}
if (parts[1] === "clear-checked" && parts.length === 2 && req.method === "POST") {
const done = list.items.filter((i) => i.checked);
for (let i = 0; i < done.length; i += 100) {
await ctx.bounded.setMany(done.slice(i, i + 100).map((x) => ({ path: `lists/${list.id}/items/${x.id}`, document: null })));
}
return json(summary(merge(list, [], done.map((x) => x.id)), { removed: done.length }));
}
if (parts[1] === "items" && parts.length === 3 && req.method === "DELETE") {
const item = findItem(list, parts[2]);
await ctx.bounded.set(`lists/${list.id}/items/${item.id}`, null);
return json(summary(merge(list, [], [item.id]), { removed: itemOut(item) }));
}
if (parts[1] === "items" && parts.length === 3 && req.method === "PATCH") {
const item = findItem(list, parts[2]);
const input = await body(req);
const patch: Record<string, unknown> = {};
if (input.checked != null) {
if (typeof input.checked !== "boolean") return fail(400, "checked must be true or false.");
patch.checked = input.checked;
patch.checkedAt = input.checked ? nowSec() : null;
}
const qty = input.quantity ?? input.qty;
if (qty !== undefined) patch.qty = String(qty ?? "").trim();
if (input.note !== undefined) patch.note = String(input.note ?? "").trim();
if (input.section !== undefined) {
const s = findSection(input.section);
if (!s) return fail(400, `"${input.section}" is not a section. Use one of: produce, bakery, deli, meat, dairy, frozen, pantry, baking, breakfast, snacks, drinks, household, personal, baby, pets, other.`);
patch.section = s;
}
if (String(patch.qty ?? "").length > LIMITS.qty) return fail(400, `Keep quantities under ${LIMITS.qty} characters.`);
if (String(patch.note ?? "").length > LIMITS.note) return fail(400, `Keep notes under ${LIMITS.note} characters.`);
const name = input.name == null ? item.name : tidyName(String(input.name));
if (!name || name.length > LIMITS.itemName) return fail(400, `name must be 1 to ${LIMITS.itemName} characters.`);
if (!Object.keys(patch).length && name === item.name) return fail(400, "Send at least one of checked, name, quantity, note or section.");
const next: Item = { ...item, ...patch, name } as Item;
const id = itemKey(name);
const writes: { path: string; document: Record<string, unknown> | null }[] = [];
if (id !== item.id) {
// A new name is a new item id (ids come from names), so the item moves.
if (list.items.some((i) => i.id === id)) return fail(409, `${name} is already on the list.`);
writes.push({ path: `lists/${list.id}/items/${item.id}`, document: null });
writes.push({ path: `lists/${list.id}/items/${id}`, document: { name, qty: next.qty, note: next.note, section: next.section, checked: next.checked, ...(next.checked ? { checkedAt: next.checkedAt ?? nowSec() } : {}), addedAt: item.addedAt ?? Date.now() } });
} else {
writes.push({ path: `lists/${list.id}/items/${id}`, document: { ...patch, ...(name !== item.name ? { name } : {}) } });
}
if (patch.section) writes.push({ path: `lists/${list.id}/known/${id}`, document: { name, section: patch.section as string, uses: ONE, at: nowSec() } });
await ctx.bounded.setMany(writes);
const moved = { ...next, id };
return json(summary(merge(list, [moved], id !== item.id ? [item.id] : []), { updated: itemOut(moved) }));
}
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
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