heron/lanternfall Public Public: anyone can read this; nobody can change it.

workbenchthis machine

Start lanternfall

benjivers committed 06a5f7d Browse files
8 files changed +426 −0
.gitignore added +4
@@ -0,0 +1,4 @@
1 node_modules/
2 *.db
3 *.db-wal
4 *.db-shm
AGENTS.md added +52
@@ -0,0 +1,52 @@
1 # Working on this app
2
3 You are one of the crew on this repository. The forge runs the app for you:
4 production follows the default branch, and every pull you open gets a
5 preview at its own address, deployed from your branch as you push.
6
7 ## The rules
8
9 - Work on the branch you were given. You cannot move the default branch;
10 a person merges your pull once it is reviewed.
11 - Keep state under `FORGE_APP_DATA`. Never write elsewhere at runtime.
12 - Run `bun test` before you push. It is the check the pull shows, and every
13 behaviour you add gets a test that would fail without it.
14 - `src/tty.ts` is the app's terminal interface. The exact command that
15 reaches it is `FORGE_APP_SSH` at runtime; print that verbatim wherever the
16 app tells people how to connect. Keep it working and keep it useful: what
17 a person can do on the web page they should be able to do here too.
18 - Keep it dependency-free unless a dependency is clearly worth it, and say
19 why in the pull. A dependency is code you now maintain.
20 - When you are done, push and stop. Your last message becomes the pull's
21 description: say what changed, what you chose not to do, and what to look
22 at in the preview.
23
24 ## The bar
25
26 - Shape: small modules named after one concept, plain data in and out. When
27 `src/server.ts` stops fitting in one read, split it — `src/db.ts` for
28 the schema and queries, `src/pages/<page>.ts` for rendering,
29 `src/routes.ts` for the table of paths — and keep the pattern you find.
30 - Security: every input is validated at the boundary and every output is
31 escaped; every response carries the security floor in `src/server.ts`
32 (keep it, extend it); a POST that is not same-origin is refused; secrets
33 come from the environment, never the repository; no `eval`, no shell
34 built from user text; a private thing needs a session in an HttpOnly cookie.
35 Every request body is capped by the bytes actually read (`readBody` in
36 `src/server.ts`) — never by a Content-Length, which a chunked request does
37 not have and a lie does not honour. `HEAD` answers like `GET`.
38 An app with an owner or admin is never claimed by whoever visits first:
39 the first sign-in needs a secret only the operator holds — a password from
40 the environment, or a one-time token printed to the run log and the app's
41 terminal — and a generated password is shown there once, never on a page.
42 - Secrets come from the environment: the app's Settings → Environment
43 (write-only), read at start-up; never the repository.
44 - Speed: the first paint is server-rendered HTML; JavaScript is small, in
45 files under `static/` named by content and cached for a year; no layout
46 shift; the common request answers in milliseconds; work that can wait is
47 done after the response.
48 - A phone is a first-class client: the page works at 360px wide with no
49 horizontal scroll, every control is at least 44px on a side, and the
50 keyboard reaches everything a mouse does.
51 - Finish: the app boots, `/` answers, the README says how to use it and
52 what is real versus simulated, `forge.json` says how to run it.
README.md added +23
@@ -0,0 +1,23 @@
1 # lanternfall
2
3 One dungeon, many delvers. The map stays explored for whoever comes next, the dead leave graves where they fell, and everything you are carrying is lost unless you climb back out with it.
4
5 This is an app: the forge runs it. Production follows the default branch;
6 every open pull gets a preview at its own address with its own copy of the
7 data. Everything the app keeps lives under `FORGE_APP_DATA`, which is what
8 makes "production data" and "preview data" two different things.
9
10 ```sh
11 PORT=8080 FORGE_APP_DATA=./data bun run src/server.ts # run it here
12 bun test # the check every pull runs
13 FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface
14 ```
15
16 It also has a terminal: the exact command is in `FORGE_APP_SSH` when the app
17 runs (`ssh -p 2222 <name>@<forge>` — the app is the login name, the forge is
18 the host; `<name>--<pull>@<forge>` for a preview's). It opens `src/tty.ts` on
19 a real pty over the same data.
20
21 The forge sets `PORT`, `FORGE_APP_DATA`, `FORGE_APP_ENV`
22 (`production` or `preview`), `FORGE_APP_URL` and `FORGE_APP_COMMIT`
23 when it starts the app. `forge.json` says how.
forge.json added +7
@@ -0,0 +1,7 @@
1 {
2 "app": {
3 "run": "bun run src/server.ts",
4 "test": "bun test",
5 "tty": "bun run src/tty.ts"
6 }
7 }
package.json added +9
@@ -0,0 +1,9 @@
1 {
2 "name": "lanternfall",
3 "private": true,
4 "type": "module",
5 "scripts": {
6 "start": "bun run src/server.ts",
7 "test": "bun test"
8 }
9 }
src/server.test.ts added +70
@@ -0,0 +1,70 @@
1 import { expect, test } from "bun:test";
2 import { mkdtempSync } from "node:fs";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5
6 process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-"));
7 process.env.PORT = "0";
8 const { server } = await import("./server.ts");
9 const base = `http://${server.hostname}:${server.port}`;
10
11 test("serves the page", async () => {
12 const response = await fetch(base + "/");
13 expect(response.status).toBe(200);
14 expect(await response.text()).toContain("<h1>");
15 });
16
17 test("keeps a note", async () => {
18 const form = new FormData();
19 form.set("body", "hello from a test");
20 const posted = await fetch(base + "/notes", { method: "POST", body: form, redirect: "manual" });
21 expect(posted.status).toBe(303);
22 expect(await (await fetch(base + "/")).text()).toContain("hello from a test");
23 });
24
25 test("stands on the security floor", async () => {
26 const response = await fetch(base + "/");
27 expect(response.headers.get("content-security-policy")).toContain("default-src 'self'");
28 expect(response.headers.get("x-content-type-options")).toBe("nosniff");
29 });
30
31 test("a bug answers with the floor, not the runtime's page", async () => {
32 // A percent-escape that is not one makes the search params throw.
33 const response = await fetch(base + "/notes", {
34 method: "POST",
35 headers: { "content-type": "application/x-www-form-urlencoded" },
36 body: "body=%E0%A4%A",
37 });
38 expect(response.status).toBe(200);
39 expect(response.headers.get("content-security-policy")).toContain("default-src 'self'");
40 });
41
42 test("a body is capped by the bytes read, whatever it claims", async () => {
43 // The cap counts bytes as they arrive and never reads Content-Length, so
44 // a chunked body with no length is refused the same way this one is.
45 const response = await fetch(base + "/notes", {
46 method: "POST",
47 headers: { "content-type": "application/x-www-form-urlencoded" },
48 body: "body=" + "x".repeat(1024 * 1024),
49 });
50 expect(response.status).toBe(413);
51 });
52
53 test("HEAD sees the app up", async () => {
54 const response = await server.fetch(new Request(base + "/", { method: "HEAD" }));
55 expect(response.status).toBe(200);
56 expect(await response.text()).toBe("");
57 });
58
59 test("refuses a cross-site post", async () => {
60 const form = new FormData();
61 form.set("body", "from another site");
62 const posted = await fetch(base + "/notes", {
63 method: "POST",
64 body: form,
65 redirect: "manual",
66 headers: { origin: "https://evil.example", "sec-fetch-site": "cross-site" },
67 });
68 expect(posted.status).toBe(403);
69 expect(await (await fetch(base + "/")).text()).not.toContain("from another site");
70 });
src/server.ts added +187
@@ -0,0 +1,187 @@
1 /**
2 * lanternfall — a small app with real data.
3 *
4 * Notes live in a SQLite file under FORGE_APP_DATA. Production and each
5 * preview get their own directory, so a preview starts with production's
6 * notes and never writes back to them.
7 */
8
9 import { Database } from "bun:sqlite";
10 import { mkdirSync } from "node:fs";
11 import { join } from "node:path";
12
13 const dataDir = process.env.FORGE_APP_DATA ?? "./data";
14 mkdirSync(dataDir, { recursive: true });
15 const db = new Database(join(dataDir, "app.db"));
16 db.exec("PRAGMA journal_mode = WAL");
17 db.exec(
18 "CREATE TABLE IF NOT EXISTS note (id INTEGER PRIMARY KEY, body TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')))",
19 );
20
21 const env = process.env.FORGE_APP_ENV ?? "local";
22 const pull = process.env.FORGE_APP_PULL;
23 const commit = (process.env.FORGE_APP_COMMIT ?? "").slice(0, 7);
24
25 const escape = (s: string): string =>
26 s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] ?? c);
27
28 // The security floor every response stands on. Scripts and styles come from
29 // this origin (inline styles allowed, inline scripts not), nothing frames us,
30 // browsers never guess a content type, and referrers stay on this site.
31 // The app's public address is what the forge told us, never the socket's.
32 const publicOrigin = new URL(process.env.FORGE_APP_URL ?? "http://localhost").origin;
33
34 const SECURITY = {
35 "content-security-policy":
36 "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'",
37 "x-content-type-options": "nosniff",
38 "referrer-policy": "strict-origin-when-cross-origin",
39 "permissions-policy": "camera=(), microphone=(), geolocation=()",
40 // Once served over https, always: browsers remember for a year.
41 ...(publicOrigin.startsWith("https:")
42 ? { "strict-transport-security": "max-age=31536000" }
43 : {}),
44 };
45 const html = (body: string, status = 200): Response =>
46 new Response(body, { status, headers: { "content-type": "text/html; charset=utf-8", ...SECURITY } });
47
48 // A browser on another site can make a person's browser POST here; it cannot
49 // make that request look same-origin. Browsers say where a request came from
50 // (Sec-Fetch-Site, or Origin); a request that names another site is refused.
51 const sameOrigin = (request: Request): boolean => {
52 const site = request.headers.get("sec-fetch-site");
53 if (site !== null) return site === "same-origin" || site === "none";
54 const origin = request.headers.get("origin");
55 return origin === null || origin === publicOrigin;
56 };
57
58 // A request body is read up to a cap and no further, counting the bytes as
59 // they arrive. A declared Content-Length is a claim: a chunked request has
60 // none, and a lie is free. Undefined means "too large" — refuse it. Past the
61 // cap the rest is drained and dropped so the connection stays usable for
62 // the next request; past eight times the cap it is cut off instead.
63 const BODY_MAX = 64 * 1024;
64 async function readBody(request: Request, max = BODY_MAX): Promise<Uint8Array | undefined> {
65 const reader = request.body?.getReader();
66 if (reader === undefined) return new Uint8Array();
67 const parts: Uint8Array[] = [];
68 let size = 0;
69 for (;;) {
70 const { done, value } = await reader.read();
71 if (done) break;
72 size += value.byteLength;
73 if (size > max) {
74 for (;;) {
75 const next = await reader.read();
76 if (next.done) return undefined;
77 size += next.value.byteLength;
78 if (size > max * 8) {
79 await reader.cancel();
80 return undefined;
81 }
82 }
83 }
84 parts.push(value);
85 }
86 const body = new Uint8Array(size);
87 let at = 0;
88 for (const part of parts) {
89 body.set(part, at);
90 at += part.byteLength;
91 }
92 return body;
93 }
94
95 // The form fields of a capped body, decoded the way the browser sent them
96 // (a plain form or a multipart one), by handing the bytes back to a Request.
97 async function readForm(request: Request): Promise<FormData | undefined> {
98 const body = await readBody(request);
99 if (body === undefined) return undefined;
100 return new Request(request.url, {
101 method: "POST",
102 headers: { "content-type": request.headers.get("content-type") ?? "" },
103 body,
104 }).formData();
105 }
106
107 function page(): string {
108 const notes = db
109 .query<{ id: number; body: string; created_at: string }, []>(
110 "SELECT id, body, created_at FROM note ORDER BY id DESC LIMIT 100",
111 )
112 .all();
113 const where =
114 env === "preview" ? `preview of pull #${pull ?? "?"}` : env;
115 return `<!doctype html>
116 <meta charset="utf-8">
117 <meta name="viewport" content="width=device-width, initial-scale=1">
118 <title>lanternfall</title>
119 <style>
120 :root { color-scheme: light dark; font: 16px/1.5 system-ui, sans-serif; }
121 body { max-width: 40rem; margin: 3rem auto; padding: 0 1rem; }
122 .env { display: inline-block; padding: .1rem .5rem; border-radius: 999px; font-size: .8rem;
123 background: ${env === "production" ? "#16a34a" : "#2563eb"}; color: white; }
124 form { display: flex; gap: .5rem; margin: 1.5rem 0; }
125 input { flex: 1; padding: .6rem .8rem; font: inherit; border: 1px solid #8884; border-radius: .5rem; }
126 button { padding: .6rem 1rem; font: inherit; border: 0; border-radius: .5rem; background: #111; color: white; }
127 li { padding: .5rem 0; border-top: 1px solid #8883; list-style: none; }
128 small { opacity: .6; }
129 </style>
130 <h1>lanternfall</h1>
131 <p>One dungeon, many delvers. The map stays explored for whoever comes next, the dead leave graves where they fell, and everything you are carrying is lost unless you climb back out with it.</p>
132 <p><span class="env">${escape(where)}</span> <small>${commit ? "@ " + commit : ""}</small></p>
133 <form method="post" action="/notes">
134 <input name="body" placeholder="Write a note" required maxlength="500" autofocus>
135 <button>Add</button>
136 </form>
137 <ul>${notes
138 .map((n) => `<li>${escape(n.body)} <small>${n.created_at}</small></li>`)
139 .join("")}</ul>
140 <p><small>${notes.length === 0 ? "No notes yet. Notes written here stay in this environment's data." : `${notes.length} note${notes.length === 1 ? "" : "s"} in ${escape(where)} data.`}</small></p>
141 `;
142 }
143
144 export const server = Bun.serve({
145 port: Number(process.env.PORT ?? 8080),
146 hostname: process.env.HOST ?? "127.0.0.1",
147 async fetch(request) {
148 const url = new URL(request.url);
149 if (request.method === "POST" && !sameOrigin(request))
150 return new Response("forbidden", { status: 403 });
151 if (request.method === "POST" && url.pathname === "/notes") {
152 const form = await readForm(request);
153 if (form === undefined)
154 return new Response("too large", { status: 413, headers: SECURITY });
155 const body = String(form.get("body") ?? "").trim().slice(0, 500);
156 if (body) db.query("INSERT INTO note (body) VALUES (?1)").run(body);
157 return Response.redirect("/", 303);
158 }
159 // HEAD is GET without the body: a health check sees the app up.
160 if (url.pathname === "/" && (request.method === "GET" || request.method === "HEAD")) {
161 const full = html(page());
162 return request.method === "HEAD"
163 ? new Response(null, { status: full.status, headers: full.headers })
164 : full;
165 }
166 // Files under ./static are served as they are, cached for a year: name
167 // them by content (app.3f2a9c.js) and a change is a new name, never a
168 // stale cache. The path is confined to the directory.
169 if (url.pathname.startsWith("/static/") && !url.pathname.includes("..")) {
170 const file = Bun.file(join(import.meta.dir, "..", url.pathname.slice(1)));
171 if (await file.exists())
172 return new Response(file, {
173 headers: { "cache-control": "public, max-age=31536000, immutable", ...SECURITY },
174 });
175 }
176 return new Response("not found", { status: 404, headers: SECURITY });
177 },
178 // A bug answers with the floor and nothing else: never the runtime's own
179 // error page, which carries source and paths, and never without the
180 // headers. The detail goes to the log, where it belongs.
181 error(error) {
182 console.error(error);
183 return html("<h1>Something went wrong</h1>", 500);
184 },
185 });
186
187 console.log(`lanternfall listening on http://${server.hostname}:${server.port} (${env})`);
src/tty.ts added +74
@@ -0,0 +1,74 @@
1 /**
2 * lanternfall, in a terminal. The forge runs this on a pty when someone
3 * `ssh`es to the app, with FORGE_APP_DATA pointing at the same data the
4 * web page uses. j/k move, a adds a note, d deletes the one under the
5 * cursor, q leaves. Plain ANSI, no dependencies.
6 */
7
8 import { Database } from "bun:sqlite";
9 import { mkdirSync } from "node:fs";
10 import { join } from "node:path";
11
12 const dataDir = process.env.FORGE_APP_DATA ?? "./data";
13 mkdirSync(dataDir, { recursive: true });
14 const db = new Database(join(dataDir, "app.db"));
15 db.exec("PRAGMA journal_mode = WAL");
16 db.exec(
17 "CREATE TABLE IF NOT EXISTS note (id INTEGER PRIMARY KEY, body TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')))",
18 );
19 const where = process.env.FORGE_APP_ENV === "preview" ? `preview of #${process.env.FORGE_APP_PULL ?? "?"}` : (process.env.FORGE_APP_ENV ?? "local");
20
21 type Note = { id: number; body: string; created_at: string };
22 const notes = (): Note[] => db.query<Note, []>("SELECT id, body, created_at FROM note ORDER BY id DESC").all();
23
24 let cursor = 0;
25 let typing: string | undefined;
26 const out = (s: string): void => { process.stdout.write(s); };
27 const esc = "\x1b[";
28
29 function draw(): void {
30 const rows = notes();
31 const lines: string[] = [];
32 lines.push(`${esc}1mlanternfall${esc}0m ${esc}2m${where}${esc}0m`);
33 lines.push("");
34 if (rows.length === 0) lines.push(`${esc}2mNo notes yet. Press a to add one.${esc}0m`);
35 rows.forEach((n, i) => {
36 const mark = i === cursor ? "▸" : " ";
37 const body = i === cursor ? `${esc}1m${n.body}${esc}0m` : n.body;
38 lines.push(`${mark} ${body} ${esc}2m${n.created_at}${esc}0m`);
39 });
40 lines.push("");
41 lines.push(typing === undefined ? `${esc}2mj/k move · a add · d delete · q quit${esc}0m` : `New note: ${typing}${esc}7m ${esc}0m`);
42 out(`${esc}2J${esc}H` + lines.join("\r\n"));
43 }
44
45 process.stdin.setRawMode?.(true);
46 process.stdin.resume();
47 process.stdin.setEncoding("utf8");
48 draw();
49 process.stdin.on("data", (key: string) => {
50 if (typing !== undefined) {
51 if (key === "\r") {
52 const body = typing.trim();
53 if (body) db.query("INSERT INTO note (body) VALUES (?1)").run(body.slice(0, 500));
54 typing = undefined;
55 cursor = 0;
56 } else if (key === "\x1b") typing = undefined;
57 else if (key === "\x7f") typing = typing.slice(0, -1);
58 else if (key >= " ") typing += key;
59 draw();
60 return;
61 }
62 if (key === "q" || key === "\x03") { out(`${esc}2J${esc}H`); process.exit(0); }
63 const count = notes().length;
64 if (key === "j" || key === "\x1b[B") cursor = Math.min(cursor + 1, Math.max(0, count - 1));
65 if (key === "k" || key === "\x1b[A") cursor = Math.max(cursor - 1, 0);
66 if (key === "a") typing = "";
67 if (key === "d") {
68 const n = notes()[cursor];
69 if (n) db.query("DELETE FROM note WHERE id = ?1").run(n.id);
70 cursor = Math.min(cursor, Math.max(0, notes().length - 1));
71 }
72 draw();
73 });
74 process.stdout.on("resize", draw);
↑↓ navigate ↵ open esc close

Keyboard shortcuts