lists-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 ebccaf0b13fe
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.
← All files
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
ebccaf0b13 · 17 hours ago
Bind agent address in policy constants
519855687c · 19 hours ago
Use OpenApps service URLs and add contribution strip
2c353edfdc · 19 hours ago
Bounded source sync
58ac7e08ec · 1 day ago
Bounded source sync
150962f3d9 · 1 day ago
Bounded source sync
e52e4676b9 · 1 day ago