pike/val Public Public: anyone can read this; nobody can change it.

forked from benjivers/val

workbenchthis machine

Start val

benjivers committed 73f2147 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 # val
2
3 Five attackers with the spike, five defenders, and abilities on cooldown. First to 13.
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": "val",
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 * val — 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>val</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>val</h1>
131 <p>Five attackers with the spike, five defenders, and abilities on cooldown. First to 13.</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(`val listening on http://${server.hostname}:${server.port} (${env})`);
src/tty.ts added +74
@@ -0,0 +1,74 @@
1 /**
2 * val, 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}1mval${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