tasks-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 9b0d0f7b9a93
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 f6f2112e77bc8c011e38c83a
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
// Tasks' public HTTP API: no account. Anyone can make a board and read one by its id (the same secret
// as its link). Changing a board needs its key, handed back once when the board is made (and inside
// the admin link): every API caller shares this route's identity, so the key is what tells callers apart.
// Writes go through the same policy rules as the web app. GET / describes the routes.
import { SITE_URL } from "./lib/config";
import {
  readSeed, readCard, readDue, readChecklist, readColor, fromTrello, isTrello, seedWrites, chunk, exportBoard, boardCsv, fileName,
  templateById, TEMPLATES, randomId, parseIds, parseChecklist, posBetween, tooClose, byPos, localDay, COLOR_IDS, LIMITS, SeedError,
  type Board, type List, type Card, type Label, type Member, type Comment, type BoardSeed, type Write,
} from "./lib/tasks";

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, DELETE, OPTIONS",
  "access-control-allow-headers": "content-type, x-board-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 < 40; 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: String(r._id ?? r.pathId ?? r.id ?? "").split("/").pop() });
    cursor = res?.nextCursor;
    if (!cursor) break;
  }
  return rows;
}

const boardUrl = (id: string) => `${SITE_URL}/b/${id}`;
const cardUrl = (id: string, cardId: string) => `${SITE_URL}/b/${id}/c/${cardId}`;
const adminUrl = (id: string, key: string) => `${SITE_URL}/b/${id}/admin#k=${key}`;

interface Loaded { board: Board; lists: List[]; cards: Card[]; labels: Label[]; members: Member[] }

/** Joins the board as this route (knowing the id is the permission to read it), then reads all of it. */
async function openBoard(ctx: any, boardId: string): Promise<Loaded> {
  if (!ID_RE.test(boardId)) throw new HttpError(404, "No board with that id.");
  const me = ctx.user.id;
  if (!(await ctx.bounded.get(`boards/${boardId}/joins/${me}`))) {
    try {
      await ctx.bounded.set(`boards/${boardId}/joins/${me}`, { at: NOW, via: "api" });
    } catch {
      throw new HttpError(404, "No board with that id.");
    }
  }
  const board = (await ctx.bounded.get(`boards/${boardId}`)) as Board | null;
  if (!board) throw new HttpError(404, "No board with that id. It may have been deleted.");
  const [lists, cards, labels, members] = await Promise.all(["lists", "cards", "labels", "members"].map((c) => listAll(ctx, `boards/${boardId}/${c}`)));
  return { board, lists, cards, labels, members } as Loaded;
}

/** Checks the board key, then makes this route an owner of the board (once). Every write needs it. */
async function withKey(ctx: any, req: Request, boardId: string): Promise<Loaded> {
  const key = (req.headers.get("x-board-key") || "").trim();
  if (!key) throw new HttpError(401, "Send the board's key in the x-board-key header. It came back when the board was made, and it is the part after #k= in the admin link.");
  if (!ID_RE.test(boardId)) throw new HttpError(404, "No board with that id.");
  const stored = await ctx.bounded.get(`boardkeys/${boardId}`);
  if (!stored) throw new HttpError(404, "No board with that id.");
  if (stored.key !== key) throw new HttpError(403, "That key does not match this board.");
  const loaded = await openBoard(ctx, boardId);
  const me = ctx.user.id;
  if (!(await ctx.bounded.get(`boards/${boardId}/owners/${me}`))) {
    await ctx.bounded.setMany([
      { path: `boardclaims/${boardId}/c/${me}`, document: { key } },
      { path: `boards/${boardId}/owners/${me}`, document: { at: NOW } },
    ]);
  }
  return loaded;
}

function readOut(boardId: string, d: Loaded, archived: boolean, comments?: Comment[]) {
  const e = exportBoard({ ...d, comments }, (cardId) => cardUrl(boardId, cardId));
  const lists = e.lists.filter((l) => archived || !l.archived).map((l) => ({ ...l, cards: l.cards.filter((c) => archived || !c.archived) }));
  return {
    board: { id: boardId, title: d.board.title, url: boardUrl(boardId), archived: d.board.archived },
    labels: e.labels,
    members: e.members,
    lists,
    counts: { lists: lists.length, cards: lists.reduce((a, l) => a + l.cards.length, 0) },
  };
}

function cardOut(boardId: string, d: Loaded, card: Card, comments?: Comment[]) {
  const list = d.lists.find((l) => l.id === card.listId);
  const label = new Map(d.labels.map((l) => [l.id, l.name]));
  const member = new Map(d.members.filter((m) => !m.removed).map((m) => [m.id, m.name]));
  const siblings = d.cards.filter((c) => c.listId === card.listId && !c.archived).sort(byPos);
  return {
    id: card.id,
    title: card.title,
    list: list?.title ?? null,
    listId: card.listId,
    position: Math.max(0, siblings.findIndex((c) => c.id === card.id)),
    description: card.description,
    due: card.due,
    done: card.done,
    labels: parseIds(card.labels).map((id) => label.get(id)).filter((n) => n != null),
    assignees: parseIds(card.assignees).map((id) => member.get(id)).filter((n) => n != null),
    checklist: parseChecklist(card.checklist).map((x) => ({ text: x.text, done: x.done })),
    cover: card.cover,
    archived: card.archived,
    url: cardUrl(boardId, card.id),
    ...(comments ? { comments: comments.filter((c) => c.cardId === card.id).sort((a, b) => a.createdAt - b.createdAt).map((c) => ({ id: c.id, name: c.name, text: c.text, at: new Date(c.createdAt * 1000).toISOString() })) } : {}),
  };
}

function findList(d: Loaded, ref: unknown): List {
  const s = String(ref ?? "").trim();
  if (!s) throw new HttpError(400, "list is required: a list's title or id from GET /boards/<id>.");
  const open = d.lists.filter((l) => !l.archived);
  const hit = d.lists.find((l) => l.id === s) || open.find((l) => l.title.toLowerCase() === s.toLowerCase());
  if (!hit) throw new HttpError(404, `No list "${s}" on this board. Lists: ${open.sort(byPos).map((l) => l.title).join(", ") || "none yet"}.`);
  return hit;
}

function findCard(d: Loaded, cardId: string): Card {
  const card = d.cards.find((c) => c.id === cardId);
  if (!card) throw new HttpError(404, "No card with that id on this board.");
  return card;
}

/** Label and people names to ids, adding labels and people the board does not have yet. */
function namesToIds(names: string[], existing: { id: string; name: string }[], make: (name: string, i: number) => Write, writes: Write[]): string[] {
  return names.map((name, i) => {
    const hit = existing.find((x) => x.id === name || x.name.toLowerCase() === name.toLowerCase());
    if (hit) return hit.id;
    const w = make(name, i);
    writes.push(w);
    const id = w.path.split("/").pop()!;
    existing.push({ id, name });
    return id;
  });
}

/** The position for a card going to index `at` (0 = top; past the end = bottom), renumbering the list when needed. */
function placeIn(cards: { id: string; pos: number; createdAt: number }[], movingId: string | null, at: unknown, base: string, writes: Write[]): number {
  const rest = cards.filter((c) => c.id !== movingId).sort(byPos);
  let index = rest.length;
  if (at === "top" || at === 0 || at === "0") index = 0;
  else if (at != null && at !== "bottom") {
    const n = Number(at);
    if (!Number.isInteger(n) || n < 0) throw new HttpError(400, `position must be "top", "bottom" or a whole number from 0.`);
    index = Math.min(n, rest.length);
  }
  const before = rest[index - 1]?.pos;
  const after = rest[index]?.pos;
  if (before != null && after != null && tooClose(before, after)) {
    rest.forEach((c, i) => writes.push({ path: `${base}/${c.id}`, document: { pos: (i + (i >= index ? 2 : 1)) * 1024 } }));
    return (index + 1) * 1024;
  }
  return posBetween(before, after);
}

/** The stored fields a card update or create asks for. */
function cardFields(input: Record<string, any>, d: Loaded, boardId: string, writes: Write[], current?: Card) {
  const out: Record<string, unknown> = {};
  const has = (k: string) => input[k] !== undefined;
  const base = `boards/${boardId}`;
  if (has("title") || !current) {
    const seed = (() => { try { return readCard({ title: input.title }); } catch (e) { throw new HttpError(400, (e as Error).message); } })();
    out.title = seed.title;
  }
  if (has("description")) {
    const v = String(input.description ?? "");
    if (v.length > LIMITS.description) throw new HttpError(400, `Keep the description under ${LIMITS.description} characters.`);
    out.description = v;
  }
  if (has("due")) {
    const v = readDue(input.due);
    if (v === null) throw new HttpError(400, `due must be a date like "2026-10-30" or "2026-10-30T15:00", or null to clear it.`);
    out.due = v;
  }
  if (has("done")) {
    if (typeof input.done !== "boolean") throw new HttpError(400, "done must be true or false.");
    out.done = input.done;
  }
  if (has("archived")) {
    if (typeof input.archived !== "boolean") throw new HttpError(400, "archived must be true or false.");
    out.archived = input.archived;
  }
  if (has("cover")) out.cover = input.cover ? readColor(input.cover, "") : "";
  if (has("checklist")) {
    try {
      out.checklist = JSON.stringify(readChecklist(input.checklist ?? []).map((x) => ({ id: randomId(8), ...x })));
    } catch (e) { throw new HttpError(400, (e as Error).message); }
  }
  const names = (k: string, max: number, most: number) => {
    const v = input[k];
    if (v == null) return [];
    const list = Array.isArray(v) ? v : typeof v === "string" ? v.split(",") : null;
    if (!list) throw new HttpError(400, `${k} must be a list of names.`);
    const clean = [...new Set(list.map((x) => String(x ?? "").trim()).filter(Boolean))];
    if (clean.some((n) => n.length > max)) throw new HttpError(400, `Keep each name in ${k} under ${max} characters.`);
    if (clean.length > most) throw new HttpError(400, `A card can have up to ${most} ${k}.`);
    return clean;
  };
  if (has("labels")) {
    const ids = namesToIds(names("labels", LIMITS.labelName, LIMITS.labels), d.labels, (name, i) => ({
      path: `${base}/labels/${randomId(10)}`, document: { name, color: COLOR_IDS[(d.labels.length + i) % COLOR_IDS.length], createdAt: NOW },
    }), writes);
    out.labels = JSON.stringify(ids);
  }
  if (has("assignees")) {
    const active = d.members.filter((m) => !m.removed);
    const ids = namesToIds(names("assignees", LIMITS.memberName, LIMITS.members), active, (name) => {
      const id = randomId(10);
      d.members.push({ id, name, removed: false, createdAt: 0 });
      return { path: `${base}/members/${id}`, document: { name, removed: false, createdAt: NOW } };
    }, writes);
    out.assignees = JSON.stringify(ids);
  }
  return out;
}

async function createBoard(ctx: any, input: Record<string, any>) {
  let seed: BoardSeed;
  try {
    if (input.trello) {
      if (!isTrello(input.trello)) throw new SeedError("trello must be a Trello board export (in Trello: Menu, Print, export and share, Export as JSON).");
      seed = fromTrello(input.trello);
      if (input.title) seed.title = readSeed({ title: input.title }).title;
    } else if (input.template && !input.lists) {
      const t = templateById(String(input.template));
      if (!t) throw new SeedError(`template must be one of ${TEMPLATES.map((x) => x.id).join(", ")}.`);
      const s = t.seed(localDay(new Date()));
      seed = readSeed({ ...s, title: input.title || s.title || t.name });
    } else {
      seed = readSeed({ ...input, lists: input.lists ?? ["To do", "Doing", "Done"] });
    }
  } catch (e) {
    if (e instanceof SeedError) throw new HttpError(400, e.message);
    if (input.trello) throw new HttpError(400, "trello is not in the shape Trello exports. Send the file from Trello's board menu: Print, export and share, Export as JSON.");
    throw e;
  }
  const me = ctx.user.id;
  const boardId = randomId(20);
  const key = randomId(24);
  const { writes } = seedWrites(boardId, seed, me, NOW);
  const batches = [writes.slice(0, 96), ...chunk(writes.slice(96), 100)];
  await ctx.bounded.setMany([
    { path: `boards/${boardId}`, document: { title: seed.title, archived: false, createdBy: me, createdAt: NOW } },
    { path: `boards/${boardId}/joins/${me}`, document: { at: NOW, via: "api" } },
    { path: `boards/${boardId}/owners/${me}`, document: { at: NOW } },
    { path: `boardkeys/${boardId}`, document: { key } },
    ...(batches.shift() || []),
  ]);
  for (const b of batches) await ctx.bounded.setMany(b);
  const loaded = await openBoard(ctx, boardId);
  return {
    ...readOut(boardId, loaded, true),
    boardKey: key,
    adminUrl: adminUrl(boardId, key),
    note: "Give the person the board url: anyone with it can open the board and edit it with them, no account needed. Keep boardKey and adminUrl private: send boardKey in the x-board-key header to change the board, and give adminUrl only to the person who owns the board (it lets them rename, archive, delete the board and remove people).",
  };
}

async function deleteBoard(ctx: any, boardId: string) {
  const me = ctx.user.id;
  const base = `boards/${boardId}`;
  const children = (await Promise.all(["cards", "comments", "lists", "labels", "members"].map(async (c) => (await listAll(ctx, `${base}/${c}`)).map((r) => `${base}/${c}/${r.id}`)))).flat();
  const people = (await Promise.all(["joins", "blocks", "owners"].map(async (c) => (await listAll(ctx, `${base}/${c}`)).filter((r) => r.id !== me).map((r) => `${base}/${c}/${r.id}`)))).flat();
  // The board goes first, so nobody can read anything on it from that moment. It goes on its own, before
  // any device's access, so pages open on it still receive the delete and say so.
  await ctx.bounded.setMany([{ path: base, document: null }]);
  for (const b of chunk([...children, ...people], 100)) await ctx.bounded.setMany(b.map((path) => ({ path, document: null })));
  await ctx.bounded.setMany([{ path: `${base}/joins/${me}`, document: null }, { path: `${base}/owners/${me}`, document: null }]);
}

const routes = {
  name: "Tasks by OpenApps",
  about: "Free kanban boards, a free Trello alternative. No account. Reading a board needs only its id (the same secret as its link). Changing it needs the board key handed back when it is made, in the x-board-key header.",
  docs: `${SITE_URL}/llms.txt`,
  openapi: `${SITE_URL}/openapi.json`,
  templates: TEMPLATES.map((t) => t.id),
  routes: [
    "GET /templates -> the board templates and what they contain",
    "POST /boards {title, template?, lists?: [\"To do\" | {title, cards: [\"Card\" | {title, description?, due?, labels?, assignees?, checklist?}]}], labels?, members?} or {trello: <Trello JSON export>} -> board, url, boardKey, adminUrl",
    "GET /boards/:id?archived=true&comments=true&format=json|csv -> lists with their cards, labels and people",
    "PATCH /boards/:id {title?, archived?} (x-board-key)",
    "DELETE /boards/:id (x-board-key) -> deletes the board and everything on it",
    "POST /boards/:id/lists {title, position?} (x-board-key)",
    "PATCH /boards/:id/lists/:listId {title?, archived?, position?} (x-board-key)",
    "POST /boards/:id/cards {list, title, description?, due?, done?, labels?, assignees?, checklist?, cover?, position?} (x-board-key)",
    "GET /boards/:id/cards/:cardId -> the card with its comments",
    "PATCH /boards/:id/cards/:cardId {title?, description?, due?, done?, labels?, assignees?, checklist?, cover?, archived?, list?, position?} (x-board-key)",
    "POST /boards/:id/cards/:cardId/move {list, position?} (x-board-key)",
    "POST /boards/:id/cards/:cardId/comments {text, name?} (x-board-key)",
    "DELETE /boards/:id/cards/:cardId (x-board-key)",
  ],
};

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 /boards/... and /api/boards/... so a copied path works either way.
  if (parts[0] === "api") parts.shift();
  const m = req.method;
  try {
    if (!parts.length && m === "GET") return json(routes);
    if (parts[0] === "templates" && parts.length === 1 && m === "GET") {
      const today = localDay(new Date());
      return json({ templates: TEMPLATES.map((t) => {
        const s = t.seed(today);
        return { id: t.id, name: t.name, summary: t.summary, lists: s.lists.map((l) => ({ title: l.title, cards: l.cards.map((c) => c.title) })), labels: s.labels };
      }) });
    }
    if (parts[0] !== "boards") return fail(404, "Unknown route. GET / lists the routes.");
    const boardId = parts[1];
    const base = `boards/${boardId}`;

    if (parts.length === 1 && m === "POST") return json(await createBoard(ctx, await body(req)), 201);

    if (parts.length === 2 && m === "GET") {
      const d = await openBoard(ctx, boardId);
      const format = (url.searchParams.get("format") || "json").toLowerCase();
      if (format === "csv") {
        return new Response(boardCsv(d), { headers: { "content-type": "text/csv; charset=utf-8", "content-disposition": `attachment; filename="${fileName(d.board.title, "csv")}"`, ...cors } });
      }
      if (format !== "json") return fail(400, "format must be json or csv.");
      const withComments = url.searchParams.get("comments") === "true";
      const comments = withComments ? await listAll(ctx, `${base}/comments`) : undefined;
      return json(readOut(boardId, d, url.searchParams.get("archived") === "true", comments));
    }

    if (parts.length === 2 && m === "PATCH") {
      const d = await withKey(ctx, req, boardId);
      const input = await body(req);
      const update: Record<string, unknown> = {};
      if (input.title !== undefined) {
        const t = String(input.title ?? "").trim();
        if (!t || t.length > LIMITS.boardTitle) return fail(400, `title must be 1 to ${LIMITS.boardTitle} characters.`);
        update.title = t;
      }
      if (input.archived !== undefined) {
        if (typeof input.archived !== "boolean") return fail(400, "archived must be true or false.");
        update.archived = input.archived;
      }
      if (!Object.keys(update).length) return fail(400, "Send title or archived to change.");
      await ctx.bounded.set(base, update);
      return json(readOut(boardId, { ...d, board: { ...d.board, ...update } as Board }, false));
    }

    if (parts.length === 2 && m === "DELETE") {
      await withKey(ctx, req, boardId);
      await deleteBoard(ctx, boardId);
      return json({ ok: true, deleted: boardId });
    }

    if (parts[2] === "lists" && parts.length === 3 && m === "POST") {
      const d = await withKey(ctx, req, boardId);
      if (d.board.archived) return fail(409, "This board is archived, so it is read only. PATCH it with {\"archived\": false} first.");
      const input = await body(req);
      const title = String(input.title ?? "").trim();
      if (!title || title.length > LIMITS.listTitle) return fail(400, `title must be 1 to ${LIMITS.listTitle} characters.`);
      const writes: Write[] = [];
      const pos = placeIn(d.lists.filter((l) => !l.archived), null, input.position, `${base}/lists`, writes);
      const listId = randomId(10);
      await ctx.bounded.setMany([...writes, { path: `${base}/lists/${listId}`, document: { title, pos, archived: false, createdAt: NOW } }]);
      return json({ id: listId, title, archived: false, cards: [] }, 201);
    }

    if (parts[2] === "lists" && parts.length === 4 && m === "PATCH") {
      const d = await withKey(ctx, req, boardId);
      if (d.board.archived) return fail(409, "This board is archived, so it is read only. PATCH it with {\"archived\": false} first.");
      const list = d.lists.find((l) => l.id === parts[3]);
      if (!list) return fail(404, "No list with that id on this board.");
      const input = await body(req);
      const update: Record<string, unknown> = {};
      const writes: Write[] = [];
      if (input.title !== undefined) {
        const t = String(input.title ?? "").trim();
        if (!t || t.length > LIMITS.listTitle) return fail(400, `title must be 1 to ${LIMITS.listTitle} characters.`);
        update.title = t;
      }
      if (input.archived !== undefined) {
        if (typeof input.archived !== "boolean") return fail(400, "archived must be true or false.");
        update.archived = input.archived;
      }
      if (input.position !== undefined) update.pos = placeIn(d.lists.filter((l) => !l.archived), list.id, input.position, `${base}/lists`, writes);
      if (!Object.keys(update).length) return fail(400, "Send title, archived or position to change.");
      await ctx.bounded.setMany([...writes, { path: `${base}/lists/${list.id}`, document: update }]);
      return json({ id: list.id, title: update.title ?? list.title, archived: update.archived ?? list.archived });
    }

    if (parts[2] === "cards" && parts.length === 3 && m === "POST") {
      const d = await withKey(ctx, req, boardId);
      if (d.board.archived) return fail(409, "This board is archived, so it is read only. PATCH it with {\"archived\": false} first.");
      const input = await body(req);
      const list = findList(d, input.list ?? input.listId);
      const writes: Write[] = [];
      const fields = cardFields(input, d, boardId, writes);
      const pos = placeIn(d.cards.filter((c) => c.listId === list.id && !c.archived), null, input.position, `${base}/cards`, writes);
      const cardId = randomId(12);
      const doc = {
        listId: list.id, description: "", due: "", done: false, labels: "[]", assignees: "[]", checklist: "[]", cover: "", archived: false,
        ...fields, pos, createdBy: ctx.user.id, createdAt: NOW,
      };
      await ctx.bounded.setMany([...writes, { path: `${base}/cards/${cardId}`, document: doc }]);
      const card = { ...doc, id: cardId, createdAt: Math.floor(Date.now() / 1000) } as Card;
      return json(cardOut(boardId, { ...d, cards: [...d.cards, card] }, card), 201);
    }

    if (parts[2] === "cards" && parts.length === 4 && m === "GET") {
      const d = await openBoard(ctx, boardId);
      const card = findCard(d, parts[3]);
      const comments = (await ctx.bounded.get(`${base}/comments`, { filter: { cardId: card.id }, limit: 1000 }))?.data || [];
      return json(cardOut(boardId, d, card, comments.map((r: any) => ({ ...r, id: String(r._id ?? r.id).split("/").pop() }))));
    }

    if (parts[2] === "cards" && (parts.length === 4 && m === "PATCH" || parts.length === 5 && parts[4] === "move" && m === "POST")) {
      const d = await withKey(ctx, req, boardId);
      if (d.board.archived) return fail(409, "This board is archived, so it is read only. PATCH it with {\"archived\": false} first.");
      const card = findCard(d, parts[3]);
      const input = await body(req);
      const moving = parts[4] === "move";
      if (moving && input.list === undefined && input.listId === undefined && input.position === undefined) return fail(400, "Send list (a title or id) and optionally position.");
      const writes: Write[] = [];
      const fields = moving ? {} : cardFields(input, d, boardId, writes, card);
      if (input.list !== undefined || input.listId !== undefined || input.position !== undefined) {
        const list = input.list !== undefined || input.listId !== undefined ? findList(d, input.list ?? input.listId) : d.lists.find((l) => l.id === card.listId)!;
        Object.assign(fields, { listId: list.id, pos: placeIn(d.cards.filter((c) => c.listId === list.id && !c.archived), card.id, input.position, `${base}/cards`, writes) });
      }
      if (!Object.keys(fields).length) return fail(400, "Send at least one field to change. GET / lists them.");
      await ctx.bounded.setMany([...writes, { path: `${base}/cards/${card.id}`, document: fields }]);
      const next = { ...card, ...fields } as Card;
      return json(cardOut(boardId, { ...d, cards: d.cards.map((c) => (c.id === card.id ? next : c)) }, next));
    }

    if (parts[2] === "cards" && parts.length === 4 && m === "DELETE") {
      const d = await withKey(ctx, req, boardId);
      if (d.board.archived) return fail(409, "This board is archived, so it is read only. PATCH it with {\"archived\": false} first.");
      const card = findCard(d, parts[3]);
      const comments = (await ctx.bounded.get(`${base}/comments`, { filter: { cardId: card.id }, limit: 1000 }))?.data || [];
      const paths = [`${base}/cards/${card.id}`, ...comments.map((r: any) => `${base}/comments/${String(r._id ?? r.id).split("/").pop()}`)];
      for (const b of chunk(paths, 100)) await ctx.bounded.setMany(b.map((path) => ({ path, document: null })));
      return json({ ok: true, deleted: card.id });
    }

    if (parts[2] === "cards" && parts.length === 5 && parts[4] === "comments" && m === "POST") {
      const d = await withKey(ctx, req, boardId);
      if (d.board.archived) return fail(409, "This board is archived, so it is read only. PATCH it with {\"archived\": false} first.");
      const card = findCard(d, parts[3]);
      const input = await body(req);
      const text = String(input.text ?? "").trim();
      if (!text || text.length > LIMITS.comment) return fail(400, `text must be 1 to ${LIMITS.comment} characters.`);
      const name = String(input.name ?? "").trim() || "Assistant";
      if (name.length > LIMITS.memberName) return fail(400, `Keep name under ${LIMITS.memberName} characters.`);
      const id = randomId(14);
      await ctx.bounded.set(`${base}/comments/${id}`, { cardId: card.id, text, by: ctx.user.id, name, createdAt: NOW });
      return json({ id, cardId: card.id, name, text, at: new Date().toISOString(), url: cardUrl(boardId, card.id) }, 201);
    }

    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);
    if (/would violate|denied|not allowed|403/i.test(message)) return fail(403, "The board refused that change. It may be archived, or the request broke a limit. GET the board and try again.");
    return fail(500, `Something went wrong: ${message}`);
  }
}

Change history

Point OpenApps links to openapps.free
9b0d0f7b9a · 17 hours ago
Bind agent address in policy
25f84633c0 · 19 hours ago
Use OpenApps service URLs and add contribution strip
07519eb348 · 19 hours ago
Bounded source sync
3296872001 · 1 day ago
Bounded source sync
ac8d2f5d18 · 1 day ago
Bounded source sync
2a5b08a001 · 1 day ago
Bounded source sync
d2242ddf34 · 1 day ago