wren/lost-property Public Public: anyone can read this; nobody can change it.

workbenchthis machine

Merge pull request #2 from wren/1-build-lost-property

Build lost-property
benjivers committed b059c95 merge Browse files
10 files changed +559 −119
README.md +13 −1
@@ -1,6 +1,18 @@
11 # lost-property
22
3 A museum of lost things. Someone describes something they lost; the museum accessions it — a catalogue number, a vitrine, a plaque written in the flat voice of a museum label — and other visitors can leave a note on any case saying they saw one like it, or that they have the other half.
3 A museum of lost things. Someone describes something they lost, at `/`; the
4 museum accessions it — a catalogue number (`LP 2026.0003`), a vitrine, a
5 plaque written in the flat voice of a museum label — and gives it a case at
6 `/items/:id`. Other visitors can leave a note on any case: that they saw one
7 like it, or that they have the other half.
8
9 A catalogue number and a condition line are derived from the item's id; a
10 vitrine is chosen by matching a handful of keywords in the description
11 against a fixed list of themes (glasses go to *Spectacles and Lenses*, keys
12 to *Keys and Fastenings*, anything unmatched to *Miscellanea*). That's the
13 whole curator: no image recognition, no AI, no human judgement — a small
14 deterministic table in `src/museum.ts`, so the same item always reads the
15 same way and it's easy to see exactly what it will say before you submit it.
416
517 This is an app: the forge runs it. Production follows the default branch;
618 every open pull gets a preview at its own address with its own copy of the
src/db.test.ts added +30
@@ -0,0 +1,30 @@
1 import { expect, test } from "bun:test";
2 import { Database } from "bun:sqlite";
3 import { mkdtempSync } from "node:fs";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6
7 // Seed a data directory with the note table an earlier version of this app
8 // left behind: no item_id, so it does not fit the current schema.
9 const dir = mkdtempSync(join(tmpdir(), "app-"));
10 process.env.FORGE_APP_DATA = dir;
11 const seed = new Database(join(dir, "app.db"));
12 seed.exec(
13 "CREATE TABLE note (id INTEGER PRIMARY KEY, body TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')))",
14 );
15 seed.exec("INSERT INTO note (body) VALUES ('an old note from before items existed')");
16 seed.close();
17
18 const { db, createItem, addNote, listNotes, listItems } = await import("./db.ts");
19
20 test("moves an old, incompatible note table aside instead of crashing", () => {
21 const row = db.query<{ body: string }, []>("SELECT body FROM note_legacy").get();
22 expect(row?.body).toBe("an old note from before items existed");
23 });
24
25 test("the collection works normally after migrating", () => {
26 const id = createItem("a test item");
27 addNote(id, "sighting", "hello");
28 expect(listNotes(id)).toHaveLength(1);
29 expect(listItems()[0]?.note_count).toBe(1);
30 });
src/db.ts added +78
@@ -0,0 +1,78 @@
1 /**
2 * The collection: items that were lost and the notes visitors leave on
3 * them. One SQLite file under FORGE_APP_DATA; production and each preview
4 * get their own, so a preview starts from production's data and never
5 * writes back to it.
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 export const db = new Database(join(dataDir, "app.db"));
15 db.exec("PRAGMA journal_mode = WAL");
16
17 // An earlier version of this app kept a flat note table with no item to
18 // belong to. `CREATE TABLE IF NOT EXISTS` below would leave that shape in
19 // place forever, so a table missing item_id is moved aside once, keeping
20 // its rows rather than losing them.
21 const existingNoteColumns = db.query<{ name: string }, []>("PRAGMA table_info(note)").all();
22 if (existingNoteColumns.length > 0 && !existingNoteColumns.some((c) => c.name === "item_id")) {
23 db.exec("ALTER TABLE note RENAME TO note_legacy");
24 }
25
26 db.exec(
27 `CREATE TABLE IF NOT EXISTS item (
28 id INTEGER PRIMARY KEY,
29 description TEXT NOT NULL,
30 created_at TEXT NOT NULL DEFAULT (datetime('now'))
31 )`,
32 );
33 db.exec(
34 `CREATE TABLE IF NOT EXISTS note (
35 id INTEGER PRIMARY KEY,
36 item_id INTEGER NOT NULL REFERENCES item(id),
37 kind TEXT NOT NULL CHECK (kind IN ('sighting', 'match')),
38 body TEXT NOT NULL,
39 created_at TEXT NOT NULL DEFAULT (datetime('now'))
40 )`,
41 );
42
43 export type Item = { id: number; description: string; created_at: string };
44 export type ItemWithCount = Item & { note_count: number };
45 export type NoteKind = "sighting" | "match";
46 export type Note = { id: number; item_id: number; kind: NoteKind; body: string; created_at: string };
47
48 export function createItem(description: string): number {
49 const row = db
50 .query<{ id: number }, [string]>("INSERT INTO item (description) VALUES (?1) RETURNING id")
51 .get(description);
52 return row!.id;
53 }
54
55 export function getItem(id: number): Item | undefined {
56 return db.query<Item, [number]>("SELECT id, description, created_at FROM item WHERE id = ?1").get(id) ?? undefined;
57 }
58
59 export function listItems(): ItemWithCount[] {
60 return db
61 .query<
62 ItemWithCount,
63 []
64 >(`SELECT item.id, item.description, item.created_at,
65 (SELECT count(*) FROM note WHERE note.item_id = item.id) AS note_count
66 FROM item ORDER BY item.id DESC LIMIT 100`)
67 .all();
68 }
69
70 export function listNotes(itemId: number): Note[] {
71 return db
72 .query<Note, [number]>("SELECT id, item_id, kind, body, created_at FROM note WHERE item_id = ?1 ORDER BY id ASC")
73 .all(itemId);
74 }
75
76 export function addNote(itemId: number, kind: NoteKind, body: string): void {
77 db.query("INSERT INTO note (item_id, kind, body) VALUES (?1, ?2, ?3)").run(itemId, kind, body);
78 }
src/html.ts added +3
@@ -0,0 +1,3 @@
1 /** Escaping shared by every page: the one place user text touches HTML. */
2 export const escape = (s: string): string =>
3 s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] ?? c);
src/museum.test.ts added +24
@@ -0,0 +1,24 @@
1 import { expect, test } from "bun:test";
2 import { catalogueNumber, conditionOf, vitrineOf, VITRINES } from "./museum.ts";
3
4 test("sorts a description into its vitrine by keyword", () => {
5 expect(VITRINES[vitrineOf("a single brown leather glove")]).toBe("Outerwear and Umbrellas");
6 expect(VITRINES[vitrineOf("a set of house keys on a red fob")]).toBe("Keys and Fastenings");
7 expect(VITRINES[vitrineOf("reading glasses, tortoiseshell frame")]).toBe("Spectacles and Lenses");
8 expect(VITRINES[vitrineOf("an iphone charger, white")]).toBe("Small Electronics");
9 expect(VITRINES[vitrineOf("a silver ring, engraved")]).toBe("Jewellery and Adornment");
10 expect(VITRINES[vitrineOf("a paperback book, dog-eared")]).toBe("Papers and Correspondence");
11 expect(VITRINES[vitrineOf("a stuffed teddy bear")]).toBe("Children's Effects");
12 });
13
14 test("falls back to Miscellanea when nothing matches", () => {
15 expect(VITRINES[vitrineOf("a smooth grey stone")]).toBe("Miscellanea");
16 });
17
18 test("gives the same item the same condition every time", () => {
19 expect(conditionOf(7)).toBe(conditionOf(7));
20 });
21
22 test("builds a catalogue number from the year and id", () => {
23 expect(catalogueNumber(3, "2026-09-14 10:00:00")).toBe("LP 2026.0003");
24 });
src/museum.ts added +55
@@ -0,0 +1,55 @@
1 /**
2 * The curatorial rules: pure functions, no I/O, no randomness. Everything a
3 * plaque says about an object — its catalogue number, its vitrine, its
4 * condition — is derived only from the object's id and the description its
5 * loser gave, so the same item always reads the same way.
6 */
7
8 export const VITRINES: readonly string[] = [
9 "Outerwear and Umbrellas",
10 "Keys and Fastenings",
11 "Spectacles and Lenses",
12 "Small Electronics",
13 "Jewellery and Adornment",
14 "Papers and Correspondence",
15 "Children's Effects",
16 "Miscellanea",
17 ];
18
19 // Checked in order; the first match wins. Anything that matches nothing
20 // goes to Miscellanea, the last vitrine.
21 const KEYWORDS: readonly [RegExp, number][] = [
22 [/\b(umbrellas?|coats?|jackets?|gloves?|scarves?|scarfs?|hats?|mittens?)\b/i, 0],
23 [/\b(keys?|padlocks?|keyrings?|locks?)\b/i, 1],
24 [/\b(glasses|spectacles|sunglasses|lenses?)\b/i, 2],
25 [/\b(phones?|chargers?|laptops?|tablets?|earbuds?|headphones?|cameras?|cables?|airpods?)\b/i, 3],
26 [/\b(rings?|necklaces?|bracelets?|earrings?|watches?|brooches?)\b/i, 4],
27 [/\b(books?|letters?|notebooks?|diaries|diary|papers?|envelopes?|postcards?)\b/i, 5],
28 [/\b(toys?|teddy|teddies|dolls?|dummies|dummy|pacifiers?)\b/i, 6],
29 ];
30
31 export function vitrineOf(description: string): number {
32 for (const [pattern, index] of KEYWORDS) if (pattern.test(description)) return index;
33 return VITRINES.length - 1;
34 }
35
36 const CONDITIONS: readonly string[] = [
37 "Wear consistent with ordinary use.",
38 "Good condition; minor surface wear.",
39 "Handled; edges softened with use.",
40 "Condition at time of loss not recorded.",
41 "As described by the finder; not further examined.",
42 "Stable condition; no active deterioration noted.",
43 ];
44
45 export function conditionOf(id: number): string {
46 return CONDITIONS[id % CONDITIONS.length]!;
47 }
48
49 // A museum accession number: LP, the year it entered the collection, and
50 // its sequence within the collection — not within that year, since this
51 // museum is smaller than that distinction is worth.
52 export function catalogueNumber(id: number, createdAt: string): string {
53 const year = createdAt.slice(0, 4);
54 return `LP ${year}.${String(id).padStart(4, "0")}`;
55 }
src/pages.ts added +105
@@ -0,0 +1,105 @@
1 /** The two pages: the entrance (catalogue and intake form) and a case. */
2
3 import { escape } from "./html.ts";
4 import { catalogueNumber, conditionOf, vitrineOf, VITRINES } from "./museum.ts";
5 import type { Item, ItemWithCount, Note } from "./db.ts";
6
7 function layout(env: string, commit: string, body: string): string {
8 return `<!doctype html>
9 <meta charset="utf-8">
10 <meta name="viewport" content="width=device-width, initial-scale=1">
11 <title>lost-property</title>
12 <style>
13 :root { color-scheme: light dark; font: 16px/1.5 system-ui, sans-serif; }
14 body { max-width: 40rem; margin: 3rem auto; padding: 0 1rem; }
15 .env { display: inline-block; padding: .1rem .5rem; border-radius: 999px; font-size: .8rem;
16 background: ${env === "production" ? "#16a34a" : "#2563eb"}; color: white; }
17 textarea, input[type=text] { width: 100%; box-sizing: border-box; padding: .6rem .8rem; font: inherit;
18 border: 1px solid #8884; border-radius: .5rem; }
19 button { margin-top: .6rem; padding: .7rem 1.2rem; font: inherit; border: 0; border-radius: .5rem;
20 background: #111; color: white; min-height: 44px; }
21 a { color: inherit; }
22 ul.catalogue { padding: 0; }
23 li.case { padding: .8rem 0; border-top: 1px solid #8883; list-style: none; }
24 .tag { display: inline-block; font-size: .75rem; opacity: .7; border: 1px solid #8886; border-radius: 999px;
25 padding: .05rem .5rem; margin: .3rem .4rem 0 0; }
26 .plaque { border: 1px solid #8886; border-radius: .5rem; padding: 1rem 1.2rem; margin: 1.5rem 0; }
27 .plaque .catalogue-number { font-size: .8rem; opacity: .7; letter-spacing: .05em; text-transform: uppercase; }
28 .plaque h2 { margin: .2rem 0 .8rem; }
29 .plaque dl { display: grid; grid-template-columns: auto 1fr; gap: .2rem 1rem; font-size: .9rem; opacity: .85; margin: 0; }
30 .plaque dt { font-weight: 600; }
31 .plaque dd { margin: 0; }
32 .note { padding: .6rem 0; border-top: 1px solid #8883; }
33 .note .kind { font-weight: 600; font-size: .85rem; }
34 small { opacity: .6; }
35 fieldset { border: 0; padding: 0; margin: 1rem 0 .8rem; display: flex; gap: 1rem; flex-wrap: wrap; }
36 label.option { display: flex; align-items: center; gap: .4rem; min-height: 44px; }
37 </style>
38 ${body}
39 <p><small>lost-property <span class="env">${escape(env)}</span> ${commit ? "@ " + escape(commit) : ""}</small></p>
40 `;
41 }
42
43 export function renderHome(items: ItemWithCount[], env: string, commit: string): string {
44 const rows = items
45 .map((item) => {
46 const vitrine = vitrineOf(item.description);
47 const short = item.description.length > 80 ? item.description.slice(0, 80) + "…" : item.description;
48 return `<li class="case">
49 <a href="/items/${item.id}">${escape(short)}</a><br>
50 <span class="tag">${escape(catalogueNumber(item.id, item.created_at))}</span><span class="tag">${escape(VITRINES[vitrine]!)}</span>
51 <br><small>${item.note_count} note${item.note_count === 1 ? "" : "s"} · accessioned ${escape(item.created_at)}</small>
52 </li>`;
53 })
54 .join("");
55 return layout(
56 env,
57 commit,
58 `<h1>lost-property</h1>
59 <p>A museum of lost things. Describe what you lost; the museum accessions it — a catalogue number, a vitrine, a plaque written in the flat voice of a museum label. Other visitors can leave a note on any case: that they saw one like it, or that they have the other half.</p>
60 <form method="post" action="/items">
61 <label for="description">Describe what you lost</label>
62 <textarea id="description" name="description" rows="3" maxlength="500" required
63 placeholder="A single brown leather glove, left hand, found beneath a train seat."></textarea>
64 <button>Accession it</button>
65 </form>
66 <ul class="catalogue">${rows || `<li class="case"><small>Nothing accessioned yet.</small></li>`}</ul>`,
67 );
68 }
69
70 export function renderCase(item: Item, notes: Note[], env: string, commit: string): string {
71 const vitrineIndex = vitrineOf(item.description);
72 const noteRows = notes
73 .map(
74 (n) => `<div class="note">
75 <span class="kind">${n.kind === "match" ? "Has the other half" : "Saw one like it"}</span>
76 <p>${escape(n.body)}</p>
77 <small>${escape(n.created_at)}</small>
78 </div>`,
79 )
80 .join("");
81 return layout(
82 env,
83 commit,
84 `<p><a href="/">&larr; lost-property</a></p>
85 <article class="plaque">
86 <p class="catalogue-number">${escape(catalogueNumber(item.id, item.created_at))} · Vitrine ${vitrineIndex + 1}</p>
87 <h2>${escape(VITRINES[vitrineIndex]!)}</h2>
88 <p>${escape(item.description)}</p>
89 <dl>
90 <dt>Accessioned</dt><dd>${escape(item.created_at)}</dd>
91 <dt>Condition</dt><dd>${escape(conditionOf(item.id))}</dd>
92 </dl>
93 </article>
94 <h3>Notes</h3>
95 ${noteRows || `<p><small>No notes yet.</small></p>`}
96 <form method="post" action="/items/${item.id}/notes">
97 <fieldset>
98 <label class="option"><input type="radio" name="kind" value="sighting" checked required> I saw one like it</label>
99 <label class="option"><input type="radio" name="kind" value="match" required> I have the other half</label>
100 </fieldset>
101 <input type="text" name="body" maxlength="500" required placeholder="Where, when, or how to get in touch">
102 <button>Leave a note</button>
103 </form>`,
104 );
105 }
src/server.test.ts +94 −12
@@ -8,18 +8,86 @@ process.env.PORT = "0";
88 const { server } = await import("./server.ts");
99 const base = `http://${server.hostname}:${server.port}`;
1010
11 test("serves the page", async () => {
11 async function accession(description: string): Promise<string> {
12 const form = new FormData();
13 form.set("description", description);
14 const posted = await fetch(base + "/items", { method: "POST", body: form, redirect: "manual" });
15 expect(posted.status).toBe(303);
16 return posted.headers.get("location")!;
17 }
18
19 test("serves the entrance", async () => {
1220 const response = await fetch(base + "/");
1321 expect(response.status).toBe(200);
14 expect(await response.text()).toContain("<h1>");
22 expect(await response.text()).toContain("<h1>lost-property</h1>");
1523 });
1624
17 test("keeps a note", async () => {
25 test("accessions a lost item with a catalogue number and a vitrine", async () => {
26 const location = await accession("a single brown leather glove, left hand");
27 expect(location).toMatch(/^\/items\/\d+$/);
28 const page = await (await fetch(base + location)).text();
29 expect(page).toContain("a single brown leather glove");
30 expect(page).toMatch(/LP \d{4}\.\d{4}/);
31 expect(page).toContain("Outerwear and Umbrellas");
32 });
33
34 test("lists an accessioned item on the entrance", async () => {
35 await accession("a set of house keys on a red fob");
36 const page = await (await fetch(base + "/")).text();
37 expect(page).toContain("a set of house keys on a red fob");
38 });
39
40 test("an empty description is not accessioned", async () => {
1841 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" });
42 form.set("description", " ");
43 const posted = await fetch(base + "/items", { method: "POST", body: form, redirect: "manual" });
2144 expect(posted.status).toBe(303);
22 expect(await (await fetch(base + "/")).text()).toContain("hello from a test");
45 expect(posted.headers.get("location")).toBe("/");
46 });
47
48 test("a case that does not exist answers 404", async () => {
49 const response = await fetch(base + "/items/999999");
50 expect(response.status).toBe(404);
51 });
52
53 test("a visitor leaves a note saying they saw one like it", async () => {
54 const location = await accession("reading glasses, tortoiseshell frame");
55 const form = new FormData();
56 form.set("kind", "sighting");
57 form.set("body", "Saw one just like this at the market on Tuesday.");
58 const posted = await fetch(base + location + "/notes", { method: "POST", body: form, redirect: "manual" });
59 expect(posted.status).toBe(303);
60 const page = await (await fetch(base + location)).text();
61 expect(page).toContain("Saw one like it");
62 expect(page).toContain("Saw one just like this at the market on Tuesday.");
63 });
64
65 test("a visitor leaves a note saying they have the other half", async () => {
66 const location = await accession("a single earring, gold hoop");
67 const form = new FormData();
68 form.set("kind", "match");
69 form.set("body", "I have the matching earring, email me.");
70 await fetch(base + location + "/notes", { method: "POST", body: form, redirect: "manual" });
71 const page = await (await fetch(base + location)).text();
72 expect(page).toContain("Has the other half");
73 expect(page).toContain("I have the matching earring, email me.");
74 });
75
76 test("a note needs a valid kind", async () => {
77 const location = await accession("a paperback book, dog-eared");
78 const form = new FormData();
79 form.set("kind", "not-a-real-kind");
80 form.set("body", "hello");
81 const posted = await fetch(base + location + "/notes", { method: "POST", body: form, redirect: "manual" });
82 expect(posted.status).toBe(400);
83 });
84
85 test("a note cannot be left on a case that does not exist", async () => {
86 const form = new FormData();
87 form.set("kind", "sighting");
88 form.set("body", "hello");
89 const posted = await fetch(base + "/items/999999/notes", { method: "POST", body: form, redirect: "manual" });
90 expect(posted.status).toBe(404);
2391 });
2492
2593 test("stands on the security floor", async () => {
@@ -30,10 +98,10 @@ test("stands on the security floor", async () => {
3098
3199 test("a bug answers with the floor, not the runtime's page", async () => {
32100 // A percent-escape that is not one makes the search params throw.
33 const response = await fetch(base + "/notes", {
101 const response = await fetch(base + "/items", {
34102 method: "POST",
35103 headers: { "content-type": "application/x-www-form-urlencoded" },
36 body: "body=%E0%A4%A",
104 body: "description=%E0%A4%A",
37105 });
38106 expect(response.status).toBe(200);
39107 expect(response.headers.get("content-security-policy")).toContain("default-src 'self'");
@@ -42,10 +110,10 @@ test("a bug answers with the floor, not the runtime's page", async () => {
42110 test("a body is capped by the bytes read, whatever it claims", async () => {
43111 // The cap counts bytes as they arrive and never reads Content-Length, so
44112 // a chunked body with no length is refused the same way this one is.
45 const response = await fetch(base + "/notes", {
113 const response = await fetch(base + "/items", {
46114 method: "POST",
47115 headers: { "content-type": "application/x-www-form-urlencoded" },
48 body: "body=" + "x".repeat(1024 * 1024),
116 body: "description=" + "x".repeat(1024 * 1024),
49117 });
50118 expect(response.status).toBe(413);
51119 });
@@ -56,10 +124,17 @@ test("HEAD sees the app up", async () => {
56124 expect(await response.text()).toBe("");
57125 });
58126
127 test("HEAD on a case answers like GET without a body", async () => {
128 const location = await accession("a stuffed teddy bear, missing an eye");
129 const response = await server.fetch(new Request(base + location, { method: "HEAD" }));
130 expect(response.status).toBe(200);
131 expect(await response.text()).toBe("");
132 });
133
59134 test("refuses a cross-site post", async () => {
60135 const form = new FormData();
61 form.set("body", "from another site");
62 const posted = await fetch(base + "/notes", {
136 form.set("description", "from another site");
137 const posted = await fetch(base + "/items", {
63138 method: "POST",
64139 body: form,
65140 redirect: "manual",
@@ -68,3 +143,10 @@ test("refuses a cross-site post", async () => {
68143 expect(posted.status).toBe(403);
69144 expect(await (await fetch(base + "/")).text()).not.toContain("from another site");
70145 });
146
147 test("escapes a description that carries markup", async () => {
148 const location = await accession("<script>alert(1)</script>");
149 const page = await (await fetch(base + location)).text();
150 expect(page).not.toContain("<script>alert(1)</script>");
151 expect(page).toContain("&lt;script&gt;");
152 });
src/server.ts +45 −66
@@ -1,29 +1,20 @@
11 /**
2 * lost-property — a small app with real data.
2 * lost-property — a museum of lost things, as a small HTTP server.
33 *
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.
4 * Data lives in SQLite under FORGE_APP_DATA (src/db.ts). The catalogue
5 * number, vitrine and condition on a plaque are derived deterministically
6 * from an item's id and description (src/museum.ts) — no AI, no lookup,
7 * just pattern matching, so the same object always reads the same way.
78 */
89
9 import { Database } from "bun:sqlite";
10 import { mkdirSync } from "node:fs";
1110 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 );
11 import { addNote, createItem, getItem, listItems, listNotes } from "./db.ts";
12 import { renderCase, renderHome } from "./pages.ts";
2013
2114 const env = process.env.FORGE_APP_ENV ?? "local";
2215 const pull = process.env.FORGE_APP_PULL;
2316 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);
17 const envLabel = env === "preview" ? `preview of pull #${pull ?? "?"}` : env;
2718
2819 // The security floor every response stands on. Scripts and styles come from
2920 // this origin (inline styles allowed, inline scripts not), nothing frames us,
@@ -44,6 +35,9 @@ const SECURITY = {
4435 };
4536 const html = (body: string, status = 200): Response =>
4637 new Response(body, { status, headers: { "content-type": "text/html; charset=utf-8", ...SECURITY } });
38 const text = (body: string, status: number): Response => new Response(body, { status, headers: SECURITY });
39 const asGet = (full: Response, method: string): Response =>
40 method === "HEAD" ? new Response(null, { status: full.status, headers: full.headers }) : full;
4741
4842 // A browser on another site can make a person's browser POST here; it cannot
4943 // make that request look same-origin. Browsers say where a request came from
@@ -104,65 +98,50 @@ async function readForm(request: Request): Promise<FormData | undefined> {
10498 }).formData();
10599 }
106100
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>lost-property</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>lost-property</h1>
131 <p>A museum of lost things. Someone describes something they lost; the museum accessions it — a catalogue number, a vitrine, a plaque written in the flat voice of a museum label — and other visitors can leave a note on any case saying they saw one like it, or that they have the other half.</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 }
101 const ITEM_PATH = /^\/items\/(\d+)$/;
102 const NOTES_PATH = /^\/items\/(\d+)\/notes$/;
143103
144104 export const server = Bun.serve({
145105 port: Number(process.env.PORT ?? 8080),
146106 hostname: process.env.HOST ?? "127.0.0.1",
147107 async fetch(request) {
148108 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") {
109 const method = request.method;
110 if (method === "POST" && !sameOrigin(request)) return text("forbidden", 403);
111
112 if (method === "POST" && url.pathname === "/items") {
152113 const form = await readForm(request);
153 if (form === undefined)
154 return new Response("too large", { status: 413, headers: SECURITY });
114 if (form === undefined) return text("too large", 413);
115 const description = String(form.get("description") ?? "").trim().slice(0, 500);
116 if (!description) return Response.redirect("/", 303);
117 const id = createItem(description);
118 return Response.redirect(`/items/${id}`, 303);
119 }
120
121 const notesMatch = url.pathname.match(NOTES_PATH);
122 if (method === "POST" && notesMatch) {
123 const item = getItem(Number(notesMatch[1]));
124 if (item === undefined) return text("not found", 404);
125 const form = await readForm(request);
126 if (form === undefined) return text("too large", 413);
127 const kind = String(form.get("kind") ?? "");
155128 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);
129 if ((kind !== "sighting" && kind !== "match") || !body) return text("bad request", 400);
130 addNote(item.id, kind, body);
131 return Response.redirect(`/items/${item.id}`, 303);
158132 }
133
159134 // 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;
135 if (url.pathname === "/" && (method === "GET" || method === "HEAD"))
136 return asGet(html(renderHome(listItems(), envLabel, commit)), method);
137
138 const itemMatch = url.pathname.match(ITEM_PATH);
139 if (itemMatch && (method === "GET" || method === "HEAD")) {
140 const item = getItem(Number(itemMatch[1]));
141 if (item === undefined) return text("not found", 404);
142 return asGet(html(renderCase(item, listNotes(item.id), envLabel, commit)), method);
165143 }
144
166145 // Files under ./static are served as they are, cached for a year: name
167146 // them by content (app.3f2a9c.js) and a change is a new name, never a
168147 // stale cache. The path is confined to the directory.
@@ -173,7 +152,7 @@ export const server = Bun.serve({
173152 headers: { "cache-control": "public, max-age=31536000, immutable", ...SECURITY },
174153 });
175154 }
176 return new Response("not found", { status: 404, headers: SECURITY });
155 return text("not found", 404);
177156 },
178157 // A bug answers with the floor and nothing else: never the runtime's own
179158 // error page, which carries source and paths, and never without the
src/tty.ts +112 −40
@@ -1,73 +1,145 @@
11 /**
22 * lost-property, in a terminal. The forge runs this on a pty when someone
33 * `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.
4 * web page uses.
5 *
6 * j/k move, enter opens a case, a accessions something new, s/m leave a
7 * note on an open case (saw one / have the other half), b or esc goes
8 * back, q leaves. Plain ANSI, no dependencies.
69 */
710
8 import { Database } from "bun:sqlite";
9 import { mkdirSync } from "node:fs";
10 import { join } from "node:path";
11 import { addNote, getItem, listItems, listNotes, createItem, type NoteKind } from "./db.ts";
12 import { catalogueNumber, conditionOf, vitrineOf, VITRINES } from "./museum.ts";
1113
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();
14 const where =
15 process.env.FORGE_APP_ENV === "preview"
16 ? `preview of #${process.env.FORGE_APP_PULL ?? "?"}`
17 : (process.env.FORGE_APP_ENV ?? "local");
2318
19 let screen: "list" | "case" = "list";
2420 let cursor = 0;
25 let typing: string | undefined;
26 const out = (s: string): void => { process.stdout.write(s); };
21 let caseId: number | undefined;
22 let typing: { for: "description" } | { for: "note"; kind: NoteKind } | undefined;
23 let input = "";
24
25 const out = (s: string): void => {
26 process.stdout.write(s);
27 };
2728 const esc = "\x1b[";
2829
29 function draw(): void {
30 const rows = notes();
30 function drawList(): void {
31 const items = listItems();
3132 const lines: string[] = [];
3233 lines.push(`${esc}1mlost-property${esc}0m ${esc}2m${where}${esc}0m`);
3334 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) => {
35 if (items.length === 0) lines.push(`${esc}2mNothing accessioned yet. Press a to describe something you lost.${esc}0m`);
36 items.forEach((item, i) => {
3637 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`);
38 const short = item.description.length > 60 ? item.description.slice(0, 60) + "…" : item.description;
39 const label = i === cursor ? `${esc}1m${short}${esc}0m` : short;
40 lines.push(`${mark} ${label} ${esc}2m${catalogueNumber(item.id, item.created_at)} · ${item.note_count} note${item.note_count === 1 ? "" : "s"}${esc}0m`);
3941 });
4042 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`);
43 lines.push(
44 typing?.for === "description"
45 ? `Describe what you lost: ${input}${esc}7m ${esc}0m`
46 : `${esc}2mj/k move · enter open · a accession · q quit${esc}0m`,
47 );
4248 out(`${esc}2J${esc}H` + lines.join("\r\n"));
4349 }
4450
51 function drawCase(): void {
52 const item = caseId === undefined ? undefined : getItem(caseId);
53 if (item === undefined) {
54 screen = "list";
55 drawList();
56 return;
57 }
58 const vitrineIndex = vitrineOf(item.description);
59 const notes = listNotes(item.id);
60 const lines: string[] = [];
61 lines.push(`${esc}1m${catalogueNumber(item.id, item.created_at)}${esc}0m ${esc}2m${where}${esc}0m`);
62 lines.push(`${esc}1m${VITRINES[vitrineIndex]}${esc}0m — Vitrine ${vitrineIndex + 1}`);
63 lines.push("");
64 lines.push(item.description);
65 lines.push("");
66 lines.push(`${esc}2mAccessioned ${item.created_at}${esc}0m`);
67 lines.push(`${esc}2mCondition: ${conditionOf(item.id)}${esc}0m`);
68 lines.push("");
69 lines.push(`${esc}1mNotes${esc}0m`);
70 if (notes.length === 0) lines.push(`${esc}2mNo notes yet.${esc}0m`);
71 for (const n of notes) {
72 lines.push(`${n.kind === "match" ? "Has the other half" : "Saw one like it"}: ${n.body}`);
73 lines.push(` ${esc}2m${n.created_at}${esc}0m`);
74 }
75 lines.push("");
76 lines.push(
77 typing?.for === "note"
78 ? `${typing.kind === "match" ? "Have the other half" : "Saw one like it"}: ${input}${esc}7m ${esc}0m`
79 : `${esc}2ms saw one like it · m have the other half · b back · q quit${esc}0m`,
80 );
81 out(`${esc}2J${esc}H` + lines.join("\r\n"));
82 }
83
84 function draw(): void {
85 if (screen === "list") drawList();
86 else drawCase();
87 }
88
4589 process.stdin.setRawMode?.(true);
4690 process.stdin.resume();
4791 process.stdin.setEncoding("utf8");
4892 draw();
93
4994 process.stdin.on("data", (key: string) => {
5095 if (typing !== undefined) {
5196 if (key === "\r") {
52 const body = typing.trim();
53 if (body) db.query("INSERT INTO note (body) VALUES (?1)").run(body.slice(0, 500));
97 const value = input.trim().slice(0, 500);
98 if (typing.for === "description") {
99 if (value) {
100 caseId = createItem(value);
101 screen = "case";
102 }
103 } else if (value && caseId !== undefined) {
104 addNote(caseId, typing.kind, value);
105 }
54106 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;
107 input = "";
108 } else if (key === "\x1b") {
109 typing = undefined;
110 input = "";
111 } else if (key === "\x7f") input = input.slice(0, -1);
112 else if (key >= " ") input += key;
59113 draw();
60114 return;
61115 }
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));
116 if (screen === "list") {
117 if (key === "q" || key === "\x03") {
118 out(`${esc}2J${esc}H`);
119 process.exit(0);
120 }
121 const count = listItems().length;
122 if (key === "j" || key === "\x1b[B") cursor = Math.min(cursor + 1, Math.max(0, count - 1));
123 if (key === "k" || key === "\x1b[A") cursor = Math.max(cursor - 1, 0);
124 if (key === "a") typing = { for: "description" };
125 if (key === "\r") {
126 const item = listItems()[cursor];
127 if (item) {
128 caseId = item.id;
129 screen = "case";
130 }
131 }
132 } else {
133 if (key === "q" || key === "\x03") {
134 out(`${esc}2J${esc}H`);
135 process.exit(0);
136 }
137 if (key === "b" || key === "\x1b") {
138 screen = "list";
139 caseId = undefined;
140 }
141 if (key === "s") typing = { for: "note", kind: "sighting" };
142 if (key === "m") typing = { for: "note", kind: "match" };
71143 }
72144 draw();
73145 });
↑↓ navigate ↵ open esc close

Keyboard shortcuts