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 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.
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
Bind agent address in policy
Use OpenApps service URLs and add contribution strip
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync