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 788ba0fa01083b300ad50913
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
// Canvas's public HTTP API: no account. An assistant can start a board from a flowchart, a mind map,
// columns of sticky notes or a template, read a board back, and (with the admin key) rename it,
// turn its links off or delete it. Every route answers JSON; GET / describes the routes.
// Writes go through the same policy rules as the web app, as this route's public principal. Every
// caller shares that principal, so the routes check the key a caller presents before acting.
import { SITE_URL } from "./lib/config";
import { LIMITS, TEMPLATES, byteLength, expandSeed, isElement, parseSeed, randomId, seedFromInput, summarize, templateById, type Seed } from "./lib/canvas";
const NOW = { operation: "time", value: "now" };
const ID_RE = /^[A-Za-z0-9]{16,32}$/;
const cors = {
"access-control-allow-origin": "*",
"access-control-allow-methods": "GET, POST, PATCH, DELETE, OPTIONS",
"access-control-allow-headers": "content-type, x-edit-key, x-admin-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): 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.");
}
/** Reads a collection page by page, keeping only what `keep` returns for each row, for up to `max` rows. */
async function listAll<T>(ctx: any, path: string, keep: (row: any, id: string) => T | null, max = 50_000): Promise<{ items: T[]; complete: boolean }> {
const items: T[] = [];
let cursor: string | undefined;
for (let page = 0; page * 200 < max; page++) {
const res = await ctx.bounded.get(path, { limit: 200, ...(cursor ? { cursor } : {}) });
// Rows name their path in _id (or pathId); the bare id is its last segment.
for (const r of res?.data || []) {
const item = keep(r, r.id ?? String(r._id ?? r.pathId ?? "").split("/").pop());
if (item != null) items.push(item);
}
cursor = res?.nextCursor;
if (!cursor) return { items, complete: true };
}
return { items, complete: false };
}
/** The parts of a stored element the summary reads, cut to size: an element can be 100 KB of anything. */
function readElement(json: string) {
let e: any;
try { e = JSON.parse(json); } catch { return null; }
if (!isElement(e)) return null;
const cut = (s: unknown, max: number) => (typeof s === "string" ? s.slice(0, max) : undefined);
const bound = (b: any) => (typeof b?.elementId === "string" ? { elementId: b.elementId.slice(0, 64) } : null);
return {
id: cut(e.id, 64)!, type: e.type, x: e.x, y: e.y, width: e.width, height: e.height, isDeleted: e.isDeleted,
text: cut(e.originalText ?? e.text, LIMITS.readText), name: cut(e.name, LIMITS.readText), backgroundColor: cut(e.backgroundColor, 32),
containerId: cut(e.containerId, 64), frameId: cut(e.frameId, 64), startBinding: bound(e.startBinding), endBinding: bound(e.endBinding),
// The summary looks only for a shape's text label among what is bound to it.
boundElements: (e.boundElements || []).filter((b: any) => b.type === "text").slice(0, 5).map((b: any) => ({ id: b.id.slice(0, 64), type: b.type })),
};
}
const links = (id: string, editKey?: string, adminKey?: string) => ({
viewUrl: `${SITE_URL}/b/${id}`,
...(editKey ? { editUrl: `${SITE_URL}/b/${id}#edit=${editKey}` } : {}),
...(adminKey ? { adminUrl: `${SITE_URL}/b/${id}/admin#k=${adminKey}` } : {}),
});
function describeSeed(seed: Seed | null): string {
if (!seed) return "an empty board";
if ("template" in seed) return `the ${templateById(seed.template)?.name ?? seed.template} template`;
const { graph, columns } = expandSeed(seed);
if (graph) return `a diagram of ${graph.nodes.length} shapes and ${graph.edges.length} arrows, laid out ${graph.direction === "right" ? "left to right" : "top to bottom"}`;
if (columns) return `${columns.length} columns of sticky notes`;
return "an empty board";
}
type Role = "admin" | "editor" | null;
/** Opens a board as this route, checking the key the caller sent. Knowing the id is enough to view while the view link is on. */
async function openBoard(ctx: any, req: Request, id: string) {
if (!ID_RE.test(id)) throw new HttpError(404, "No board with that id. The id is the part of the link after /b/.");
const me = ctx.user.id;
const keys = await ctx.bounded.get(`boardkeys/${id}`);
if (!keys) throw new HttpError(404, "No board with that id. The id is the part of the link after /b/.");
const join = await ctx.bounded.get(`boards/${id}/joins/${me}`);
if (!join) {
try {
await ctx.bounded.set(`boards/${id}/joins/${me}`, { at: NOW });
} catch {
throw new HttpError(404, "This board was deleted.");
}
}
const adminKey = (req.headers.get("x-admin-key") || "").trim();
const editKey = (req.headers.get("x-edit-key") || "").trim();
let role: Role = null;
if (adminKey) {
if (adminKey !== keys.key) throw new HttpError(403, "That admin key does not match this board. It is the part of the admin link after #k=.");
if (!(await ctx.bounded.get(`boards/${id}/owners/${me}`))) {
await ctx.bounded.setMany([
{ path: `boardclaims/${id}/c/${me}`, document: { key: adminKey } },
{ path: `boards/${id}/owners/${me}`, document: { at: NOW } },
]);
}
role = "admin";
}
const edit = await ctx.bounded.get(`editkeys/${id}`);
if (!role && editKey) {
if (!edit || editKey !== edit.key) throw new HttpError(403, "That edit key does not match this board. It is the part of the edit link after #edit=; the owner may have made a new edit link.");
role = "editor";
// This route reads as an editor only after checking the key, so other callers sharing it gain nothing.
if ((join?.key ?? null) !== edit.key) await ctx.bounded.set(`boards/${id}/joins/${me}`, { key: edit.key });
}
const board = await ctx.bounded.get(`boards/${id}`);
if (!board) throw new HttpError(404, "This board was deleted.");
if (role === "editor" && !board.editOn) role = null;
if (!role && !board.viewOn) throw new HttpError(403, "This board's view link is off. Send its admin key in x-admin-key, or its edit key in x-edit-key while the edit link is on.");
return { board, role, editKey: edit?.key as string | undefined, adminKey: role === "admin" ? adminKey : undefined };
}
function boardInfo(id: string, board: any, role: Role, editKey?: string, adminKey?: string) {
const canSeeEdit = role === "admin" || (role === "editor" && board.editOn);
return {
id,
title: board.title || "Untitled board",
editLink: board.editOn ? "on" : "off",
viewLink: board.viewOn ? "on" : "off",
...links(id, canSeeEdit && board.editOn ? editKey : undefined, adminKey),
};
}
const routes = {
name: "Canvas by OpenApps",
about: "A free online whiteboard. No key and no account. Start a board from a diagram, a mind map, sticky notes or a template, and send the person its link.",
docs: `${SITE_URL}/llms.txt`,
openapi: `${SITE_URL}/openapi.json`,
routes: [
"POST /boards {title?, nodes?, edges?, direction?} | {title?, columns?} | {title?, template?} -> the board's edit, view and admin links",
"GET /boards/:id -> what is on the board (needs x-edit-key or x-admin-key when the view link is off)",
"PATCH /boards/:id {title?, editLink?, viewLink?, newEditLink?} with x-admin-key",
"DELETE /boards/:id with x-admin-key",
],
templates: TEMPLATES.map((t) => ({ id: t.id, name: t.name, summary: t.summary })),
};
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 /boards/... and /api/boards/... 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] !== "boards") return fail(404, "Unknown route. GET / lists the routes.");
const me = ctx.user.id;
if (parts.length === 1 && req.method === "POST") {
const input = await body(req);
const made = seedFromInput(input);
if ("error" in made) return fail(400, made.error);
const seed = made.seed;
const fallback = seed && "template" in seed ? templateById(seed.template)?.title : undefined;
const title = String(input.title ?? fallback ?? "Untitled board").replace(/\s+/g, " ").trim().slice(0, LIMITS.title) || "Untitled board";
const seedJson = seed ? JSON.stringify(seed) : "";
if (byteLength(seedJson) > 100_000) return fail(400, "That is too much to start a board with. Send fewer or shorter nodes or notes.");
const id = randomId(20);
const editKey = randomId(24);
const adminKey = randomId(24);
await ctx.bounded.setMany([
{ path: `boards/${id}`, document: { title, editOn: true, viewOn: true, seed: seedJson, createdBy: me, createdAt: NOW } },
{ path: `boards/${id}/joins/${me}`, document: { at: NOW, key: editKey } },
{ path: `boards/${id}/owners/${me}`, document: { at: NOW } },
{ path: `boardkeys/${id}`, document: { key: adminKey } },
{ path: `editkeys/${id}`, document: { key: editKey } },
]);
return json({
id, title, ...links(id, editKey, adminKey), editKey, adminKey,
startsWith: describeSeed(seed),
share: "Send editUrl to people who should draw on the board and viewUrl to people who should only look. Keep adminUrl private: it renames the board, turns its links off and deletes it. The board is drawn the first time someone opens editUrl.",
}, 201);
}
const id = parts[1];
if (parts.length !== 2) return fail(404, "Unknown route. GET / lists the routes.");
if (req.method === "GET") {
const { board, role, editKey, adminKey } = await openBoard(ctx, req, id);
const { items: elements, complete } = await listAll(ctx, `boards/${id}/els`, (r) => readElement(r.json), LIMITS.readElements);
const seed = parseSeed(board.seed);
return json({
board: boardInfo(id, board, role, editKey, adminKey),
...(elements.length
? { contents: summarize(elements), ...(complete ? {} : { note: `This board is large: contents covers its first ${LIMITS.readElements} elements.` }) }
: { contents: null, startsWith: describeSeed(seed), note: seed ? "Nothing has been drawn yet: the board is drawn from what it was started with the first time someone opens the edit link." : "The board is empty." }),
});
}
if (req.method === "PATCH") {
const { board, role, editKey } = await openBoard(ctx, req, id);
if (role !== "admin") return fail(401, "Changing a board needs its admin key in the x-admin-key header.");
const input = await body(req);
const next: Record<string, unknown> = {};
if (input.title != null) {
const title = String(input.title).replace(/\s+/g, " ").trim().slice(0, LIMITS.title);
if (!title) return fail(400, "title cannot be empty.");
next.title = title;
}
for (const [field, key] of [["editLink", "editOn"], ["viewLink", "viewOn"]] as const) {
if (input[field] == null) continue;
if (typeof input[field] !== "boolean") return fail(400, `${field} must be true (on) or false (off).`);
next[key] = input[field];
}
if (input.newEditLink != null && input.newEditLink !== true) return fail(400, "newEditLink must be true, or left out.");
if (!Object.keys(next).length && !input.newEditLink) return fail(400, "Send title, editLink, viewLink or newEditLink.");
if (Object.keys(next).length) await ctx.bounded.set(`boards/${id}`, next);
let key = editKey;
if (input.newEditLink) {
key = randomId(24);
await ctx.bounded.set(`editkeys/${id}`, { key });
await ctx.bounded.set(`boards/${id}/joins/${me}`, { key });
}
return json({ board: boardInfo(id, { ...board, ...next }, role, key, req.headers.get("x-admin-key")!.trim()) });
}
if (req.method === "DELETE") {
const { role } = await openBoard(ctx, req, id);
if (role !== "admin") return fail(401, "Deleting a board needs its admin key in the x-admin-key header.");
// Links off first, so nobody keeps drawing while the board is cleared.
await ctx.bounded.set(`boards/${id}`, { editOn: false, viewOn: false });
for (const sub of ["els", "cursors", "files"]) {
const { items: ids } = await listAll(ctx, `boards/${id}/${sub}`, (_r, rowId) => rowId);
for (let i = 0; i < ids.length; i += 100) {
await ctx.bounded.setMany(ids.slice(i, i + 100).map((rowId) => ({ path: `boards/${id}/${sub}/${rowId}`, document: null })));
}
}
await ctx.bounded.set(`boards/${id}`, null);
return json({ deleted: true, id, note: "The board and everything on it are gone. Its links no longer open anything." });
}
return fail(405, "Use GET, PATCH or DELETE on /boards/:id, and POST on /boards.");
} 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 functions.openapps.xyz alias, add OpenApp contribution strip
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync