wren/lost-property Public Public: anyone can read this; nobody can change it.
workbenchthis machine
Build the lost-property museum: items, cases, and notes
Someone describes something they lost at the entrance; it is accessioned with a deterministic catalogue number, sorted into a vitrine by keyword, and given a case page with a plaque (catalogue, vitrine, condition). Other visitors leave a note on a case: that they saw one like it, or that they have the other half. Split server.ts into db.ts (schema/queries), museum.ts (the pure curatorial rules, unit tested on their own), pages.ts (rendering), and server.ts (routing and the security floor, unchanged). Updated tty.ts so the terminal can browse cases, accession items, and leave notes, matching what the web page can do. Co-Authored-By: Claude Sonnet 5 <[email protected]>
9 files
changed +519
−119
README.md +13 −1
| @@ -1,6 +1,18 @@ | ||
| 1 | 1 | # lost-property |
| 2 | 2 | |
| 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. | |
| 4 | 16 | |
| 5 | 17 | This is an app: the forge runs it. Production follows the default branch; |
| 6 | 18 | every open pull gets a preview at its own address with its own copy of the |
src/db.ts added +68
| @@ -0,0 +1,68 @@ | ||
| 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 | db.exec( | |
| 17 | `CREATE TABLE IF NOT EXISTS item ( | |
| 18 | id INTEGER PRIMARY KEY, | |
| 19 | description TEXT NOT NULL, | |
| 20 | created_at TEXT NOT NULL DEFAULT (datetime('now')) | |
| 21 | )`, | |
| 22 | ); | |
| 23 | db.exec( | |
| 24 | `CREATE TABLE IF NOT EXISTS note ( | |
| 25 | id INTEGER PRIMARY KEY, | |
| 26 | item_id INTEGER NOT NULL REFERENCES item(id), | |
| 27 | kind TEXT NOT NULL CHECK (kind IN ('sighting', 'match')), | |
| 28 | body TEXT NOT NULL, | |
| 29 | created_at TEXT NOT NULL DEFAULT (datetime('now')) | |
| 30 | )`, | |
| 31 | ); | |
| 32 | ||
| 33 | export type Item = { id: number; description: string; created_at: string }; | |
| 34 | export type ItemWithCount = Item & { note_count: number }; | |
| 35 | export type NoteKind = "sighting" | "match"; | |
| 36 | export type Note = { id: number; item_id: number; kind: NoteKind; body: string; created_at: string }; | |
| 37 | ||
| 38 | export function createItem(description: string): number { | |
| 39 | const row = db | |
| 40 | .query<{ id: number }, [string]>("INSERT INTO item (description) VALUES (?1) RETURNING id") | |
| 41 | .get(description); | |
| 42 | return row!.id; | |
| 43 | } | |
| 44 | ||
| 45 | export function getItem(id: number): Item | undefined { | |
| 46 | return db.query<Item, [number]>("SELECT id, description, created_at FROM item WHERE id = ?1").get(id) ?? undefined; | |
| 47 | } | |
| 48 | ||
| 49 | export function listItems(): ItemWithCount[] { | |
| 50 | return db | |
| 51 | .query< | |
| 52 | ItemWithCount, | |
| 53 | [] | |
| 54 | >(`SELECT item.id, item.description, item.created_at, | |
| 55 | (SELECT count(*) FROM note WHERE note.item_id = item.id) AS note_count | |
| 56 | FROM item ORDER BY item.id DESC LIMIT 100`) | |
| 57 | .all(); | |
| 58 | } | |
| 59 | ||
| 60 | export function listNotes(itemId: number): Note[] { | |
| 61 | return db | |
| 62 | .query<Note, [number]>("SELECT id, item_id, kind, body, created_at FROM note WHERE item_id = ?1 ORDER BY id ASC") | |
| 63 | .all(itemId); | |
| 64 | } | |
| 65 | ||
| 66 | export function addNote(itemId: number, kind: NoteKind, body: string): void { | |
| 67 | db.query("INSERT INTO note (item_id, kind, body) VALUES (?1, ?2, ?3)").run(itemId, kind, body); | |
| 68 | } |
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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="/">← 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"; | ||
| 8 | 8 | const { server } = await import("./server.ts"); |
| 9 | 9 | const base = `http://${server.hostname}:${server.port}`; |
| 10 | 10 | |
| 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 () => { | |
| 12 | 20 | const response = await fetch(base + "/"); |
| 13 | 21 | expect(response.status).toBe(200); |
| 14 | expect(await response.text()).toContain("<h1>"); | |
| 22 | expect(await response.text()).toContain("<h1>lost-property</h1>"); | |
| 15 | 23 | }); |
| 16 | 24 | |
| 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 () => { | |
| 18 | 41 | 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" }); | |
| 21 | 44 | 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); | |
| 23 | 91 | }); |
| 24 | 92 | |
| 25 | 93 | test("stands on the security floor", async () => { |
| @@ -30,10 +98,10 @@ test("stands on the security floor", async () => { | ||
| 30 | 98 | |
| 31 | 99 | test("a bug answers with the floor, not the runtime's page", async () => { |
| 32 | 100 | // 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", { | |
| 34 | 102 | method: "POST", |
| 35 | 103 | headers: { "content-type": "application/x-www-form-urlencoded" }, |
| 36 | body: "body=%E0%A4%A", | |
| 104 | body: "description=%E0%A4%A", | |
| 37 | 105 | }); |
| 38 | 106 | expect(response.status).toBe(200); |
| 39 | 107 | 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 () => { | ||
| 42 | 110 | test("a body is capped by the bytes read, whatever it claims", async () => { |
| 43 | 111 | // The cap counts bytes as they arrive and never reads Content-Length, so |
| 44 | 112 | // 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", { | |
| 46 | 114 | method: "POST", |
| 47 | 115 | headers: { "content-type": "application/x-www-form-urlencoded" }, |
| 48 | body: "body=" + "x".repeat(1024 * 1024), | |
| 116 | body: "description=" + "x".repeat(1024 * 1024), | |
| 49 | 117 | }); |
| 50 | 118 | expect(response.status).toBe(413); |
| 51 | 119 | }); |
| @@ -56,10 +124,17 @@ test("HEAD sees the app up", async () => { | ||
| 56 | 124 | expect(await response.text()).toBe(""); |
| 57 | 125 | }); |
| 58 | 126 | |
| 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 | ||
| 59 | 134 | test("refuses a cross-site post", async () => { |
| 60 | 135 | 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", { | |
| 63 | 138 | method: "POST", |
| 64 | 139 | body: form, |
| 65 | 140 | redirect: "manual", |
| @@ -68,3 +143,10 @@ test("refuses a cross-site post", async () => { | ||
| 68 | 143 | expect(posted.status).toBe(403); |
| 69 | 144 | expect(await (await fetch(base + "/")).text()).not.toContain("from another site"); |
| 70 | 145 | }); |
| 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("<script>"); | |
| 152 | }); |
src/server.ts +45 −66
| @@ -1,29 +1,20 @@ | ||
| 1 | 1 | /** |
| 2 | * lost-property — a small app with real data. | |
| 2 | * lost-property — a museum of lost things, as a small HTTP server. | |
| 3 | 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. | |
| 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. | |
| 7 | 8 | */ |
| 8 | 9 | |
| 9 | import { Database } from "bun:sqlite"; | |
| 10 | import { mkdirSync } from "node:fs"; | |
| 11 | 10 | 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"; | |
| 20 | 13 | |
| 21 | 14 | const env = process.env.FORGE_APP_ENV ?? "local"; |
| 22 | 15 | const pull = process.env.FORGE_APP_PULL; |
| 23 | 16 | const commit = (process.env.FORGE_APP_COMMIT ?? "").slice(0, 7); |
| 24 | ||
| 25 | const escape = (s: string): string => | |
| 26 | s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] ?? c); | |
| 17 | const envLabel = env === "preview" ? `preview of pull #${pull ?? "?"}` : env; | |
| 27 | 18 | |
| 28 | 19 | // The security floor every response stands on. Scripts and styles come from |
| 29 | 20 | // this origin (inline styles allowed, inline scripts not), nothing frames us, |
| @@ -44,6 +35,9 @@ const SECURITY = { | ||
| 44 | 35 | }; |
| 45 | 36 | const html = (body: string, status = 200): Response => |
| 46 | 37 | 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; | |
| 47 | 41 | |
| 48 | 42 | // A browser on another site can make a person's browser POST here; it cannot |
| 49 | 43 | // 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> { | ||
| 104 | 98 | }).formData(); |
| 105 | 99 | } |
| 106 | 100 | |
| 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$/; | |
| 143 | 103 | |
| 144 | 104 | export const server = Bun.serve({ |
| 145 | 105 | port: Number(process.env.PORT ?? 8080), |
| 146 | 106 | hostname: process.env.HOST ?? "127.0.0.1", |
| 147 | 107 | async fetch(request) { |
| 148 | 108 | 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") { | |
| 152 | 113 | 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") ?? ""); | |
| 155 | 128 | 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); | |
| 158 | 132 | } |
| 133 | ||
| 159 | 134 | // 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); | |
| 165 | 143 | } |
| 144 | ||
| 166 | 145 | // Files under ./static are served as they are, cached for a year: name |
| 167 | 146 | // them by content (app.3f2a9c.js) and a change is a new name, never a |
| 168 | 147 | // stale cache. The path is confined to the directory. |
| @@ -173,7 +152,7 @@ export const server = Bun.serve({ | ||
| 173 | 152 | headers: { "cache-control": "public, max-age=31536000, immutable", ...SECURITY }, |
| 174 | 153 | }); |
| 175 | 154 | } |
| 176 | return new Response("not found", { status: 404, headers: SECURITY }); | |
| 155 | return text("not found", 404); | |
| 177 | 156 | }, |
| 178 | 157 | // A bug answers with the floor and nothing else: never the runtime's own |
| 179 | 158 | // error page, which carries source and paths, and never without the |
src/tty.ts +112 −40
| @@ -1,73 +1,145 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * lost-property, in a terminal. The forge runs this on a pty when someone |
| 3 | 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. | |
| 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. | |
| 6 | 9 | */ |
| 7 | 10 | |
| 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"; | |
| 11 | 13 | |
| 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"); | |
| 23 | 18 | |
| 19 | let screen: "list" | "case" = "list"; | |
| 24 | 20 | 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 | }; | |
| 27 | 28 | const esc = "\x1b["; |
| 28 | 29 | |
| 29 | function draw(): void { | |
| 30 | const rows = notes(); | |
| 30 | function drawList(): void { | |
| 31 | const items = listItems(); | |
| 31 | 32 | const lines: string[] = []; |
| 32 | 33 | lines.push(`${esc}1mlost-property${esc}0m ${esc}2m${where}${esc}0m`); |
| 33 | 34 | 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) => { | |
| 36 | 37 | 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`); | |
| 39 | 41 | }); |
| 40 | 42 | 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 | ); | |
| 42 | 48 | out(`${esc}2J${esc}H` + lines.join("\r\n")); |
| 43 | 49 | } |
| 44 | 50 | |
| 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 | ||
| 45 | 89 | process.stdin.setRawMode?.(true); |
| 46 | 90 | process.stdin.resume(); |
| 47 | 91 | process.stdin.setEncoding("utf8"); |
| 48 | 92 | draw(); |
| 93 | ||
| 49 | 94 | process.stdin.on("data", (key: string) => { |
| 50 | 95 | if (typing !== undefined) { |
| 51 | 96 | 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 | } | |
| 54 | 106 | 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; | |
| 59 | 113 | draw(); |
| 60 | 114 | return; |
| 61 | 115 | } |
| 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" }; | |
| 71 | 143 | } |
| 72 | 144 | draw(); |
| 73 | 145 | }); |