feedback-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 4a4c54cd95c6
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.
← All files
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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
4a4c54cd95 · 18 hours ago
Release with preview
1d0728818d · 19 hours ago
Retry release
1abc843b3f · 19 hours ago
Bind app agent address in policy
00eb15f50d · 19 hours ago
Use OpenApps service URLs and add contribution strip
347a7b8ca0 · 19 hours ago
Bounded source sync
a0aaceae63 · 1 day ago
Bounded source sync
3125b8113f · 1 day ago
Bounded source sync
882a76ba95 · 1 day ago
Bounded source sync
852d9782ee · 1 day ago
Bounded source sync
a8ee705109 · 1 day ago