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 54b9c9d7fdc2b68029ce2e57
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
// Feedback's public HTTP API: no account and no key to read or post. A board's id is its secret, like its
// link; the owner key (handed back once, at creation) is what lets a caller change statuses and write the
// changelog. POST /boards/:id/posts also takes a plain HTML form post, so a website can collect ideas in
// place. Writes go through the same policy rules as the web app, as this route's public principal, which
// every caller shares: so owner actions always check the key first, and hidden posts stay out of answers
// unless the key comes with the request.
import { SITE_URL } from "./lib/config";
import {
STATUSES, STATUS_LABEL, ROADMAP, ACTIVE, LIMITS, SORTS, DEFAULT_TAGS, randomId, isStatus, parseTags, parseIds, cleanTagNames,
sortPosts, matchesSearch, listed, type Board, type Post, type Comment, type Entry, type Status, type Sort, type Tag,
} from "./lib/feedback";
const NOW = { operation: "time", value: "now" };
const BOARD_RE = /^[A-Za-z0-9]{8,32}$/;
const ITEM_RE = /^[A-Za-z0-9]{6,32}$/;
const cors = {
"access-control-allow-origin": "*",
"access-control-allow-methods": "GET, POST, PATCH, OPTIONS",
"access-control-allow-headers": "content-type, x-owner-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, extra?: Record<string, unknown>) => json({ error, ...extra }, status);
class HttpError extends Error { constructor(public status: number, message: string) { super(message); } }
async function jsonBody(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.");
}
const isFormPost = (type: string) => type.includes("application/x-www-form-urlencoded") || type.includes("multipart/form-data");
/** A JSON body, or the fields of an HTML form post. */
async function postBody(req: Request, type: string): Promise<Record<string, any>> {
if (!isFormPost(type)) return jsonBody(req);
const fields: Record<string, string> = {};
if (type.includes("multipart/form-data")) {
let data: FormData;
try { data = await req.formData(); } catch { throw new HttpError(400, "The form data could not be read."); }
data.forEach((v, k) => { if (typeof v === "string") fields[k] = v; });
} else {
new URLSearchParams(await req.text()).forEach((v, k) => { fields[k] = v; });
}
return fields;
}
async function listAll(ctx: any, path: string): Promise<any[]> {
const rows: any[] = [];
let cursor: string | undefined;
for (let page = 0; page < 50; page++) {
const res = await ctx.bounded.get(path, { limit: 1000, ...(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: r.id ?? String(r._id ?? r.pathId ?? "").split("/").pop() });
cursor = res?.nextCursor;
if (!cursor) break;
}
return rows;
}
/** Joins the board as this route (knowing the id is the permission), then reads it. */
async function openBoard(ctx: any, boardId: string): Promise<Board> {
if (!BOARD_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 });
} catch {
throw new HttpError(404, "No board with that id.");
}
}
const board = await ctx.bounded.get(`boards/${boardId}`);
if (!board) throw new HttpError(404, "No board with that id.");
return board;
}
/** True when the request carries this board's owner key. A wrong key is an error, not a quiet "no". */
async function hasOwnerKey(ctx: any, req: Request, boardId: string): Promise<boolean> {
const key = (req.headers.get("x-owner-key") || "").trim();
if (!key) return false;
const stored = await ctx.bounded.get(`boardkeys/${boardId}`);
if (!stored?.key || stored.key !== key) throw new HttpError(403, "That owner key does not match this board.");
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 true;
}
async function requireOwner(ctx: any, req: Request, boardId: string) {
if (!(await hasOwnerKey(ctx, req, boardId))) throw new HttpError(401, "Send the board's owner key in the x-owner-key header.");
}
const boardUrl = (boardId: string) => `${SITE_URL}/b/${boardId}`;
const postUrl = (boardId: string, postId: string) => `${SITE_URL}/b/${boardId}/p/${postId}`;
const iso = (s: number) => new Date(s * 1000).toISOString();
function describePost(boardId: string, p: Post, tags: Tag[]) {
const ids = parseIds(p.tags);
return {
id: p.id,
title: p.title,
details: p.details,
status: p.status,
statusLabel: STATUS_LABEL[p.status] ?? p.status,
votes: p.votes,
comments: p.comments,
tags: tags.filter((t) => ids.includes(t.id)).map((t) => t.name),
pinned: p.pinned,
...(p.hidden ? { hidden: true } : {}),
...(p.merged ? { mergedInto: p.merged } : {}),
author: p.name || null,
createdAt: iso(p.createdAt),
url: postUrl(boardId, p.id),
};
}
function describeBoard(boardId: string, board: Board, posts: Post[]) {
const counts = Object.fromEntries(STATUSES.map((s) => [s, 0])) as Record<Status, number>;
for (const p of posts) counts[p.status] = (counts[p.status] ?? 0) + 1;
return {
id: boardId,
name: board.name,
intro: board.intro || null,
url: boardUrl(boardId),
roadmapUrl: `${boardUrl(boardId)}/roadmap`,
changelogUrl: `${boardUrl(boardId)}/changelog`,
tags: parseTags(board.tags).map((t) => t.name),
posts: posts.length,
byStatus: counts,
};
}
/** Every post on the board a caller may see: hidden ones only with the owner key, merged ones never in lists. */
async function readPosts(ctx: any, boardId: string, owner: boolean): Promise<Post[]> {
const rows = (await listAll(ctx, `boards/${boardId}/posts`)) as Post[];
return listed(rows).filter((p) => owner || !p.hidden);
}
function normalizeStatus(value: unknown): Status | null {
const s = String(value ?? "").trim().toLowerCase().replace(/[\s_-]+/g, "");
const map: Record<string, Status> = {
new: "new", open: "new", underreview: "review", review: "review", planned: "planned", inprogress: "progress", progress: "progress",
done: "done", complete: "done", completed: "done", shipped: "done", closed: "closed",
};
return map[s] ?? null;
}
const STATUS_HELP = `status must be one of: ${STATUSES.join(", ")} (new, under review, planned, in progress, done, closed).`;
/** Tag names or ids from the caller, matched to the board's tags. */
function pickTags(input: unknown, tags: Tag[]): string[] {
if (input == null || input === "") return [];
const list = Array.isArray(input) ? input : String(input).split(",");
const ids: string[] = [];
for (const raw of list) {
const ref = String(raw ?? "").trim().toLowerCase();
if (!ref) continue;
const tag = tags.find((t) => t.id.toLowerCase() === ref || t.name.toLowerCase() === ref);
if (!tag) throw new HttpError(400, `"${raw}" is not a tag on this board. Its tags are: ${tags.map((t) => t.name).join(", ") || "none"}.`);
if (!ids.includes(tag.id)) ids.push(tag.id);
}
return ids;
}
function text(input: Record<string, any>, field: string, max: number, required = false): string {
if (input[field] != null && typeof input[field] === "object") throw new HttpError(400, `${field} must be text.`);
const value = input[field] == null ? "" : String(input[field]).trim();
if (required && !value) throw new HttpError(400, `${field} is required.`);
if (value.length > max) throw new HttpError(400, `Keep ${field} under ${max} characters.`);
return value;
}
function bool(input: Record<string, any>, field: string): boolean | undefined {
if (!(field in input)) return undefined;
if (typeof input[field] !== "boolean") throw new HttpError(400, `${field} must be true or false.`);
return input[field];
}
const esc = (s: string) => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!);
/** The small page a browser sees after a website's HTML form posts an idea straight to the API. */
function htmlPage(status: number, title: string, body: string, referer: string | null) {
let back = "";
try {
const u = referer ? new URL(referer) : null;
if (u && (u.protocol === "https:" || u.protocol === "http:")) back = `<a class="btn btn-line" href="${esc(u.href)}">Back to ${esc(u.host)}</a>`;
} catch { /* no link back */ }
const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="robots" content="noindex"><title>${esc(title)}</title>
<style>body{margin:0;min-height:100vh;display:grid;place-items:center;background:#fdfdfc;color:#15171b;font:16px/1.6 system-ui,-apple-system,"Segoe UI",sans-serif;padding:24px;box-sizing:border-box}main{max-width:520px;width:100%;background:#fff;border:1px solid #e3e6ea;border-radius:18px;padding:32px 28px;box-shadow:0 8px 24px -12px #1a1d2426}h1{margin:0 0 10px;font:400 30px/1.15 Georgia,"Times New Roman",serif;letter-spacing:-.02em}p{color:#4a4e56}.row{display:flex;flex-wrap:wrap;gap:10px;margin-top:18px}.btn{display:inline-block;padding:12px 18px;border-radius:10px;background:#0b0b0c;color:#fff;font-weight:600;text-decoration:none;border:1px solid #0b0b0c}.btn-line{background:#fff;color:#0b0b0c;border-color:#cfd5dd}.foot{margin-top:24px;font-size:13px;color:#5d6169}.foot a{color:#2e5ec4;text-decoration:none}</style></head>
<body><main><h1>${esc(title)}</h1>${body}<div class="row">${back}</div><p class="foot">Collected with <a href="${SITE_URL}/">Feedback</a>, free from OpenApps.</p></main></body></html>`;
return new Response(html, { status, headers: { "content-type": "text/html; charset=utf-8", ...cors } });
}
const routes = {
name: "Feedback by OpenApps",
about: "Free feedback boards with voting, a public roadmap and a changelog. No key and no account: a board's id is its secret, like its link. The ownerKey from POST /boards changes statuses and writes the changelog.",
docs: `${SITE_URL}/llms.txt`,
openapi: `${SITE_URL}/openapi.json`,
statuses: STATUSES,
routes: [
"POST /boards {name, intro?, tags?: [\"Idea\", \"Bug\"]} -> boardId, url, adminUrl, ownerKey",
"GET /boards/:id?sort=top|trending|new&status=active|all|<status>&tag=&q=&limit= -> the board and its posts, most voted first",
"GET /boards/:id/roadmap -> posts grouped by status: review, planned, progress, done",
"POST /boards/:id/posts {title, details?, tags?, name?} -> the new post (also takes an HTML form post)",
"GET /boards/:id/posts/:postId -> the post, its comments and the changelog entries that shipped it",
"POST /boards/:id/posts/:postId/comments {body, name?} (with x-owner-key the reply is marked as from the team)",
"PATCH /boards/:id/posts/:postId {status?, tags?, pinned?, hidden?, title?, details?} with header x-owner-key",
"GET /boards/:id/changelog -> entries, newest first, with the posts each one shipped",
"POST /boards/:id/changelog {title, body?, posts?: [postId], markDone?: true} with header x-owner-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 type = (req.headers.get("content-type") || "").toLowerCase();
const htmlPost = req.method === "POST" && parts.length === 3 && parts[2] === "posts" && isFormPost(type)
&& !(req.headers.get("accept") || "").includes("application/json");
const referer = req.headers.get("referer");
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 jsonBody(req);
const name = text(input, "name", LIMITS.boardName, true);
const intro = text(input, "intro", LIMITS.intro);
if (input.tags != null && !Array.isArray(input.tags)) return fail(400, "tags must be a list of names, like [\"Idea\", \"Bug\"].");
const tags: Tag[] = cleanTagNames(input.tags ?? DEFAULT_TAGS).map((n) => ({ id: randomId(6), name: n }));
const boardId = randomId(20);
const ownerKey = randomId(24);
const board = { name, intro, tags: JSON.stringify(tags) };
await ctx.bounded.setMany([
{ path: `boards/${boardId}`, document: { ...board, createdBy: me, createdAt: NOW } },
{ path: `boards/${boardId}/joins/${me}`, document: { at: NOW } },
{ path: `boards/${boardId}/owners/${me}`, document: { at: NOW } },
{ path: `boardkeys/${boardId}`, document: { key: ownerKey } },
]);
return json({
boardId,
url: boardUrl(boardId),
adminUrl: `${boardUrl(boardId)}/admin#k=${ownerKey}`,
ownerKey,
board: describeBoard(boardId, { ...board, createdBy: me, createdAt: 0 }, []),
note: "Share url with the people you want ideas from. Keep adminUrl and ownerKey private: they let anyone set statuses, hide posts and write the changelog.",
}, 201);
}
const boardId = parts[1];
const board = await openBoard(ctx, boardId);
const tags = parseTags(board.tags);
if (parts.length === 2 && req.method === "GET") {
const owner = await hasOwnerKey(ctx, req, boardId);
const all = await readPosts(ctx, boardId, owner);
const sortParam = (url.searchParams.get("sort") || "top").toLowerCase();
if (!SORTS.includes(sortParam as Sort)) return fail(400, `sort must be one of: ${SORTS.join(", ")}.`);
const statusParam = (url.searchParams.get("status") || "active").toLowerCase();
const status = statusParam === "active" || statusParam === "all" ? statusParam : normalizeStatus(statusParam);
if (!status) return fail(400, `status must be active, all, or one of: ${STATUSES.join(", ")}.`);
const tagParam = url.searchParams.get("tag");
const tagIds = tagParam ? pickTags(tagParam, tags) : [];
const q = (url.searchParams.get("q") || "").trim();
const limit = Math.min(Math.max(Number(url.searchParams.get("limit") || 50) || 50, 1), 500);
const names = (p: Post) => tags.filter((t) => parseIds(p.tags).includes(t.id)).map((t) => t.name).join(" ");
const matching = sortPosts(all, sortParam as Sort).filter((p) =>
(status === "all" || (status === "active" ? ACTIVE.includes(p.status) : p.status === status))
&& (!tagIds.length || parseIds(p.tags).some((id) => tagIds.includes(id)))
&& matchesSearch(p, q, names(p)));
return json({
board: describeBoard(boardId, board, all),
filter: { sort: sortParam, status, ...(tagParam ? { tag: tagParam } : {}), ...(q ? { q } : {}) },
matching: matching.length,
posts: matching.slice(0, limit).map((p) => describePost(boardId, p, tags)),
});
}
if (parts.length === 3 && parts[2] === "roadmap" && req.method === "GET") {
const owner = await hasOwnerKey(ctx, req, boardId);
const all = await readPosts(ctx, boardId, owner);
const columns = Object.fromEntries(ROADMAP.map((s) => [s, {
label: STATUS_LABEL[s],
posts: (s === "done"
? [...all].filter((p) => p.status === s).sort((a, b) => b.statusAt - a.statusAt)
: sortPosts(all.filter((p) => p.status === s), "top")).map((p) => describePost(boardId, p, tags)),
}]));
return json({ board: describeBoard(boardId, board, all), url: `${boardUrl(boardId)}/roadmap`, columns });
}
if (parts.length === 3 && parts[2] === "changelog" && req.method === "GET") {
const owner = await hasOwnerKey(ctx, req, boardId);
const entries = ((await listAll(ctx, `boards/${boardId}/changelog`)) as Entry[]).sort((a, b) => b.at - a.at);
const posts = new Map((await readPosts(ctx, boardId, owner)).map((p) => [p.id, p]));
return json({
board: { id: boardId, name: board.name, url: boardUrl(boardId) },
url: `${boardUrl(boardId)}/changelog`,
entries: entries.map((e) => ({
id: e.id, title: e.title, body: e.body, publishedAt: iso(e.at),
shipped: parseIds(e.posts).map((id) => posts.get(id)).filter((p): p is Post => Boolean(p)).map((p) => describePost(boardId, p, tags)),
})),
});
}
if (parts.length === 3 && parts[2] === "changelog" && req.method === "POST") {
await requireOwner(ctx, req, boardId);
const input = await jsonBody(req);
const title = text(input, "title", LIMITS.entryTitle, true);
const body = text(input, "body", LIMITS.entryBody);
if (input.posts != null && !Array.isArray(input.posts)) return fail(400, "posts must be a list of post ids.");
const ids = [...new Set((input.posts ?? []).map((x: unknown) => String(x)))] as string[];
if (ids.length > LIMITS.entryPosts) return fail(400, `Link up to ${LIMITS.entryPosts} posts in one entry.`);
const markDone = input.markDone == null ? true : input.markDone;
if (typeof markDone !== "boolean") return fail(400, "markDone must be true or false.");
const posts = new Map((await readPosts(ctx, boardId, true)).map((p) => [p.id, p]));
for (const id of ids) if (!posts.has(id)) return fail(400, `"${id}" is not a post on this board. GET /boards/${boardId}?status=all lists them.`);
const entryId = randomId(12);
await ctx.bounded.setMany([
{ path: `boards/${boardId}/changelog/${entryId}`, document: { title, body, posts: JSON.stringify(ids), at: NOW, by: me } },
...(markDone ? ids.filter((id) => posts.get(id)!.status !== "done").map((id) => ({ path: `boards/${boardId}/posts/${id}`, document: { status: "done", statusAt: NOW } })) : []),
]);
return json({
id: entryId, title, body, url: `${boardUrl(boardId)}/changelog`,
shipped: ids.map((id) => describePost(boardId, { ...posts.get(id)!, ...(markDone ? { status: "done" as Status } : {}) }, tags)),
}, 201);
}
if (parts.length === 3 && parts[2] === "posts" && req.method === "POST") {
const input = await postBody(req, type);
// A filled honeypot field means a bot: answer as if it worked and store nothing.
if (String(input._gotcha ?? "").trim()) {
return htmlPost ? htmlPage(200, "Thank you", "<p>Your idea was sent.</p>", referer) : json({ ok: true }, 201);
}
const title = text(input, "title", LIMITS.title, true);
const details = text(input, "details", LIMITS.details);
const name = text(input, "name", LIMITS.name);
const tagIds = pickTags(input.tags ?? input.tag, tags);
const postId = randomId(12);
const doc = {
title, details, tags: JSON.stringify(tagIds), status: "new" as Status, statusAt: NOW, votes: 1, comments: 0,
pinned: false, hidden: false, name, by: me, createdAt: NOW,
};
await ctx.bounded.setMany([
{ path: `boards/${boardId}/posts/${postId}`, document: doc },
{ path: `boards/${boardId}/posts/${postId}/votes/${me}`, document: { at: NOW } },
{ path: `boards/${boardId}/people/${me}/votes/${postId}`, document: { at: NOW } },
]);
const now = Math.floor(Date.now() / 1000);
const post = describePost(boardId, { ...doc, id: postId, statusAt: now, createdAt: now }, tags);
if (htmlPost) {
return htmlPage(201, "Thank you", `<p>Your idea "${esc(title)}" is on the board. People can vote for it there, and you can follow its status.</p><p><a class="btn" href="${esc(post.url)}">See it on the board</a></p>`, referer);
}
return json({ ...post, note: "Posted. It starts with one vote, from the API. People vote on it at url." }, 201);
}
if (parts.length >= 4 && parts[2] === "posts") {
const postId = parts[3];
if (!ITEM_RE.test(postId)) return fail(404, "No post with that id on this board.");
const owner = await hasOwnerKey(ctx, req, boardId);
const raw = (await ctx.bounded.get(`boards/${boardId}/posts/${postId}`)) as Post | null;
if (!raw || (raw.hidden && !owner)) return fail(404, "No post with that id on this board.");
const post: Post = { ...raw, id: postId };
if (parts.length === 4 && req.method === "GET") {
const comments = ((await listAll(ctx, `boards/${boardId}/posts/${postId}/comments`)) as Comment[]).sort((a, b) => a.at - b.at);
const entries = ((await listAll(ctx, `boards/${boardId}/changelog`)) as Entry[]).filter((e) => parseIds(e.posts).includes(postId)).sort((a, b) => b.at - a.at);
return json({
...describePost(boardId, post, tags),
...(post.merged ? { note: `This post was merged into ${postUrl(boardId, post.merged)}, and its votes moved there.` } : {}),
commentList: comments.map((c) => ({ id: c.id, body: c.body, author: c.name || null, team: c.team, at: iso(c.at) })),
shippedIn: entries.map((e) => ({ id: e.id, title: e.title, publishedAt: iso(e.at), url: `${boardUrl(boardId)}/changelog` })),
});
}
if (parts.length === 5 && parts[4] === "comments" && req.method === "POST") {
const input = await jsonBody(req);
const body = text(input, "body", LIMITS.comment, true);
const name = text(input, "name", LIMITS.name);
const commentId = randomId(12);
await ctx.bounded.setMany([
{ path: `boards/${boardId}/posts/${postId}/comments/${commentId}`, document: { body, name, team: owner, by: me, at: NOW } },
{ path: `boards/${boardId}/posts/${postId}`, document: { comments: { operation: "increment", value: 1 }, lastComment: commentId } },
]);
return json({ id: commentId, body, author: name || null, team: owner, url: postUrl(boardId, postId) }, 201);
}
if (parts.length === 4 && req.method === "PATCH") {
if (!owner) return fail(401, "Send the board's owner key in the x-owner-key header.");
const input = await jsonBody(req);
const next: Record<string, unknown> = {};
if ("status" in input) {
const status = normalizeStatus(input.status);
if (!status || !isStatus(status)) return fail(400, STATUS_HELP);
if (status !== post.status) Object.assign(next, { status, statusAt: NOW });
}
if ("title" in input) next.title = text(input, "title", LIMITS.title, true);
if ("details" in input) next.details = text(input, "details", LIMITS.details);
if ("tags" in input) next.tags = JSON.stringify(pickTags(input.tags, tags));
const pinned = bool(input, "pinned");
const hidden = bool(input, "hidden");
if (pinned !== undefined) next.pinned = pinned;
if (hidden !== undefined) next.hidden = hidden;
if (!Object.keys(next).length) return json(describePost(boardId, post, tags));
await ctx.bounded.set(`boards/${boardId}/posts/${postId}`, next);
const now = Math.floor(Date.now() / 1000);
return json(describePost(boardId, { ...post, ...next, ...(next.statusAt ? { statusAt: now } : {}) } as Post, tags));
}
}
return fail(404, "Unknown route. GET / lists the routes.");
} catch (err) {
const status = err instanceof HttpError ? err.status : 500;
const message = err instanceof HttpError ? err.message : `Something went wrong: ${err instanceof Error ? err.message : String(err)}`;
if (htmlPost) return htmlPage(status, "That did not go through", `<p>${esc(message)}</p>`, referer);
return fail(status, message);
}
}
Change history
Point store links to openapps.free
Release with preview
Retry release
Bind app agent address in policy
Use OpenApps service URLs and add contribution strip
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync