Skip to content

Releases: Soul-Brews-Studio/digger-node

v26.9.4 — CalVer, and a hash ceiling only production could find

Choose a tag to compare

@nazt nazt released this 04 Sep 03:37

Versions are dates now: v{yy}.{m}.{d}, so v26.9.4 is 2026-09-04.

This ships whenever something is learned — the surface changed five times today —
and a semver number would advertise a compatibility contract nobody is
maintaining. A date claims only what is true: the state of the thing on the day
it was cut. Earlier tags (v0.1.0, v0.2.0) are left alone rather than
rewritten; CalVer sorts after them.

The fix in this cut

Setting a passphrase on production failed:

Pbkdf2 failed: iteration counts above 100000 are not supported (requested 210000)

210,000 is OWASP's 2023 floor for PBKDF2-HMAC-SHA256. bun accepts it, all 128
tests passed, and workerd caps deriveBits at 100,000 — so no test could
have caught it. Same shape as the aot: false bug already documented in
app.ts: correct code, green suite, and a failure that exists only on the
runtime that serves users.

It failed closed, which is the one good thing: the change was refused, the
old passphrase kept working, nothing was half-applied.

What the lower count costs, measured rather than asserted — on one core:

stored as per guess
bare SHA-256 (what OAuth tokens use) 0.0009 ms
PBKDF2 @ 100,000 5.74 ms — 6,378× slower

You pay 5.74 ms once per login; an attacker pays it per guess. Halving the
iterations halves their cost, and the mitigations that actually carry the load
are elsewhere: /login and /authorize allow five attempts then exponential
backoff, so an online attack never reaches the hash's margin, and an offline
attack means D1 has already leaked. The stored format carries its own iteration
count, so raising this the day workerd does costs nothing — existing hashes keep
verifying at the count they were written with.

/health and the page footer report the version, so a running deployment always
names its own build.

Everything else is unchanged from
v0.2.0
that release has the screenshots and the full tour.

MIT.

v0.2.0 — a lock you can change, a timeline, and a UI that looks like something you'd want to write in

Choose a tag to compare

@nazt nazt released this 04 Sep 03:22

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.

Deploy to Cloudflare

The digger-node web UI

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

The settings page

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

The timeline

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_delete exists (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 test is pinned to ./test — bare bun test can 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.

v0.1.0 — a Drupal-shaped content store your models can write to

Choose a tag to compare

@nazt nazt released this 04 Sep 01:06

One Cloudflare Worker, one D1 database. A content store shaped like Drupal —
node, vocabulary, term, join table — served to models over MCP, with a web UI,
trigram full-text search, optional embeddings, and OAuth so claude.ai can
connect to it.

Deploy to Cloudflare

One click provisions the D1, runs the migrations, deploys the Worker, and clones
the repo into your own GitHub account so every later push redeploys itself.

The digger-node web UI

The idea

Drupal got the content model right in 2001 and most note tools have been
rediscovering it badly ever since: a node is title + body + datetime + type,
and everything else you want to say about it is taxonomy. That is the whole
schema, plus a join table.

What is new is who does the filing. Tags are the model's output, not a form
field.
The create form has no tag input; every tag in the sidebar is a filter
you click; and anything nobody has classified yet collects in an untagged
queue — not a warning, but the agent's work list.

The untagged queue and the MCP call log

Two refusals visible in that call log, both worth reading. node_tag on a
missing node answers no node with id node_nope — it used to leak
D1_ERROR: FOREIGN KEY constraint failed, which names the storage engine's
difficulty rather than the caller's mistake and which no model can act on; it
was found by photographing the call log for these very docs. And tagging into a
controlled vocabulary answers "Enginering" is not a term in the controlled vocabulary "status". Available: draft, published, review. — which is the
entire reason controlled vocabularies exist here, because a model tagging the
same idea twice produces mcp, MCP and model-context-protocol, and
nothing errors while the taxonomy quietly stops being able to answer "show me
everything about X".

Search says which engine answered

SEARCH "ความจำ" — 1 HIT(S) — MODE: FTS — a match inside a Thai sentence.
FTS5's default unicode61 tokenizer swallows a Thai sentence as a single token
and returns nothing; trigram returns the row. Measured independently four times
across this fleet before anyone wrote it down.

Thai substring search

Text search is the default because on a labelled 124k-block corpus it scored
0.765 MRR against bge-m3's 0.099 for known-item retrieval — with the honest
caveat that the probe's ground truth is substring containment, which is exactly
what a trigram index computes. Semantic mode answers the other question. Hybrid
is opt-in because equal-weight fusion measured worse (0.44) than either.

OAuth for claude.ai, a bearer for everything else

claude.ai cannot send a static header, so OAuth is the only door open to it.
Everything else reads a config file and should not have to dance. Three keys,
one gate: oauth, api-token, owner-session.

The OAuth approval page

OAuth 2.1 with PKCE S256 only (plain refused at issue, not merely
unadvertised), Dynamic Client Registration because claude.ai registers itself,
RFC 9728 / 8414 / 9207 discovery, 10-minute single-use codes, 30-day tokens, no
refresh tokens, no client secrets. Codes and tokens are stored as SHA-256
digests
— the token is the secret, so a row holding one is as sensitive as the
session it opens.

Storage is three tables in the D1 that is already bound. No KV namespace, so
the one-click install provisions nothing extra.

Auth is opt-in: with no secret set the server is open and /health reports
"auth": "none". A deploy button that produces a Worker returning 401 to its own
owner, with no screen on which to set a secret, is a broken first run.

Connecting claude.ai

What is in the box

  • 19 MCP tools over one stateless POST /mcp, echoing the client's protocolVersion
  • A JSON API and a web UI — create, filter, tag cloud, live call log
  • The call log as a first-class table, with inputs, results and durations
  • Optional embeddings (Workers AI bge-m3), in their own table keyed by (model, dim)
  • A Store port with D1 and SQLite adapters — the same app runs under bun test, on D1, and later under Tauri
  • 110 tests, tsc clean, verified live on workerd and on production

Scars, documented in place rather than smoothed over

  • aot: false is required on Workers. Elysia's AOT compiler builds handlers with new Function(); workerd forbids it. Every test passed; only wrangler dev caught it.
  • D1 returns BLOBs as number[], bun:sqlite as Uint8Array. new Float32Array(numberArray) copies each byte as a float instead of reinterpreting. Nothing throws; every production cosine sat at ~0.00 — a wrong answer wearing the costume of a right one.
  • An Elysia lifecycle hook inside a plugin is local by default. It would guard the OAuth endpoints, which need none, and leave /mcp wide open. Removing as: "global" fails 7 tests; without that test it fails none.
  • A 200 wrapping isError: true never triggers an auth prompt. The gate must run at the HTTP layer, before the JSON-RPC handler.

Docs

MIT. Spun out of digger-oracle's ψ/lab.