v0.2.0 — a lock you can change, a timeline, and a UI that looks like something you'd want to write in
Everything since the spin-out. The store is unchanged in shape — node, vocabulary,
term, join table, on one Worker and one D1 — but almost everything you touch is
different.
Warm, because of who is using it
The old palette was blue-black with a cyan accent: a monitoring dashboard for a
server you do not love. The scene here is one person writing into their own
corpus, often at night. So: warm charcoal ground, an ember accent instead of a
status LED, a lamp glow behind the header, and a serif reading voice for node
titles and bodies — the one thing on the page a human wrote.
Themed the surfaces nobody draws, which is the cheapest signal a page was built
rather than assembled: selection, caret, scrollbar, focus ring, underline offset,
tabular numerals. One authored motion — rows settle in, and stop entirely under
prefers-reduced-motion.
React + Tailwind, still no build step. And the client moved out of a
TypeScript template literal into a real .html file imported as text — verified
in both runtimes before being relied on. That shape had already cost a production
bug: a literal \n in the client became a real newline on evaluation, cut a JS
string in half, and left every route answering 200 with an inert page. The
workaround was String.fromCharCode(10); the fix is a file where a backtick is a
backtick.
A lock you can actually change
A Cloudflare secret is injected per request and read-only to the Worker, so
while OWNER_PASSPHRASE was the only source of truth, "change it from the UI"
could not exist — rotation meant wrangler secret put and a terminal.
The passphrase now lives in D1 as PBKDF2-SHA256, 210k iterations, salted —
deliberately not the bare SHA-256 used for tokens elsewhere here. A 32-byte token
has nothing to guess; a phrase chosen to be remembered falls to a wordlist. The
deployed secret becomes the recovery path, so forgetting the one you set is
not the same as losing the corpus.
Changing it signs out every other browser and re-issues yours in the same
response. Lock in the header signs out on demand.
Rate limiting, because the weak link was the guessing budget
Five wrong guesses per address per door, then 2m/4m/8m… capped at an hour. The
throttle gates the attempt, not the verdict — a locked-out caller holding the
right passphrase still gets a 429, or the lockout would tell them the moment they
hit it. Optional (RATE_LIMIT=off), on by default whenever auth is on.
The corpus as a chronology
Nodes and tool calls interleaved by event time, merged in SQL so
ORDER BY at DESC, rowid DESC is possible — the client cannot see rowid, and
wall-clock time is not unique at machine speed.
That tiebreaker is not fussiness. A dig across this fleet's own stores found six
ordering keys for one kind of record and only one correct: empty timestamps,
duplicate sequence numbers, a journal with no time field at all, and session
transcripts whose records are not in time order despite being append-only files.
The page prints its ordering rule under the list rather than leaving it assumed.
The escape hatch the errors had been promising
"supercool" is not an allowed content type used to end with "or delete the
controlled type vocabulary" — an operation this codebase did not have. Now:
- the refusal renders the button that grants the type
- Settings → Content types adds one deliberately
vocabulary_deleteexists (REST + MCP, 19 tools) and refuses by default,
reporting the exact cost first — terms and tag assignments both cascade, so
"delete the vocabulary" silently means "delete every tag anyone applied from it"
Also
- Settings is its own route, not a third segment in the
content | timeline
toggle — those two are views of your data; configuration is not. - The lock screen and OAuth consent page now share the app's palette. They were
byte-identical after a re-shoot, which is how the drift was found: the first
page a visitor loads was the one that did not look like the product. - An empty JSON body is an empty object, not
Unexpected end of JSON input. bun testis pinned to./test— barebun testcan discover zero files
and still exit 0 when a symlink sits in the repo root.
128 tests, tsc clean, verified in a browser at both breakpoints and against
production — not only in tests.
Visual tour ·
Connecting claude.ai ·
DESIGN.md
MIT.


