Skip to content

Releases: javimosch/bkn

v0.3.2 — signed URLs for private file namespaces

Choose a tag to compare

@javimosch javimosch released this 06 Sep 23:12

A private namespace with a signing_key can serve its files through time-limited signed URLs — no auth header, no guessing, and an expiry.

bkn files ns create reports --signing-key auto
bkn files sign reports q3.pdf --ttl 24h
# -> /v1/files/reports/q3.pdf?sig=...&exp=...
const url = bkn.files.sign("reports", "q3.pdf", { ttl: "24h" });

The signature is HMAC-SHA256(signing_key, "ns/name|exp") in URL-safe base64, compared in constant time. The key is generated with crypto/rand when you pass auto, and carries json:"-" so it is never serialised into an API response — files ns list does not leak it.

The use case is an email gate. A hook stores the lead, signs a URL and returns it. The recipient downloads without an account; the link expires on its own and cannot be forged.

Credit and review

The feature is Devin's. It is released after review — constant-time compare, expiry checked before the signature, migration present for existing databases — and an end-to-end check on a real build: unsigned request 404, signed 200, tampered signature 404, expired URL 404, key absent from ns list.

Also in this release, for anyone on v0.3.0

v0.3.1's two files fixes:

  • A name claim was a check followed by a write. Two uploads of the same free name both saw it free and the second silently replaced the first — twelve concurrent writers all reported success against the old code. Now exactly one wins.
  • --verify-type makes a namespace decide a file's type from its bytes instead of from what the uploader declared. Off by default.

Upgrading

Two ALTER TABLEs on file_namespaces (verify_type, signing_key), both defaulted, both instant. Existing namespaces behave exactly as before: no signing key means no signed access, and private namespaces still require auth.

v0.3.1 — files: atomic name claim, type verification from bytes

Choose a tag to compare

@javimosch javimosch released this 06 Sep 16:27

Two defects in files, both harmless only because the admin token is currently the only thing that can write — and both preconditions for ever letting a tenant write.

A name claim was a check followed by a write

files put refuses an existing name unless --overwrite, and that refusal was a Show returning ErrExists followed, some way down, by an unconditional INSERT ... ON CONFLICT DO UPDATE. Two uploads of the same free name both saw it free, both wrote, and the second silently replaced the first.

Nothing looks wrong afterwards, which is what makes it worth fixing: the name still resolves, it just points at the other file. A test runs twelve writers at one name — against the previous code all twelve reported success and eleven files vanished. Now exactly one wins and the rest get already_exists (409).

--verify-type

The allow-list was checked against a string the caller sent: declare Content-Type: image/png over any bytes at all and an image-only namespace accepted them and recorded them as an image.

bkn files ns create avatars --allow-type 'image/*' --verify-type

Off by default, so every existing namespace behaves exactly as before.

The limits are documented rather than hidden, because they decide whether you want it. A sniffer sees bytes, not intentions, so a verifying namespace allow-lists what files look like: a .docx is a zip, a .css sniffs as text/plain. A declared type survives when it is a more specific truth about bytes the sniffer can only call a container (docx over zip yes, image/png over zip no), and bytes resembling no known format sniff as application/octet-stream — the sniffer declining to answer, not evidence of a lie. What it removes is the class the sniffer can name: HTML, JavaScript, SVG, and every text format a browser would act on.

New error: type_mismatch (415).

Upgrading from v0.3.0

One ALTER TABLE file_namespaces ADD COLUMN verify_type. No behaviour changes for existing namespaces, and the previous binary reads a migrated database.

v0.3.0 — collection access policies

Choose a tag to compare

@javimosch javimosch released this 06 Sep 14:58
f6371de

A browser or mobile client can now talk to bkn directly. Every data route used to require the admin token, so the application in between re-implemented verify-token / read-subject / add-tenant-filter at every endpoint — and the day one was forgotten, that endpoint returned everybody's rows.

Collections that know who is asking

bkn store create app/notes --owner-field user_id \
  --access read=owner,create=owner,update=owner,delete=owner

Four verbs × five audiences (admin, user, owner, org, public). admin is the default, so a collection nobody has declared behaves exactly as it did in v0.2.0. A policy grants and never revokes.

  • A scoped create takes its tenancy from the token, not the body. A document cannot be written into the wrong tenant, because the tenant is not something the client gets to say.
  • The scope rides inside the operation — a filter for lists and rollups, a precondition on the compare-and-set patch already performed, ON CONFLICT ... WHERE for upserts. A check that is not part of the write is not a check.
  • Cross-tenant reads, patches and deletes answer 404, not 403: a 403 confirms the id is real to somebody with no right to know it.

Also

  • Self-service POST /v1/auth/register (off unless BKN_OPEN_SIGNUP=1) and /v1/auth/password.
  • Organizations over HTTP — create a workspace, invite members, gated by org role rather than the admin token.
  • PUT /v1/store/{ns}/{coll} declares a collection over HTTP, so a deployment behind a reverse proxy can be administered without a shell on the box.
  • BKN_CORS_ORIGIN — an explicit allow-list, never a reflected origin.
  • Fix: a normalizer on a nested field was accepted and then ignored, leaving a document unfindable by the very field its collection declared as normalized.

Upgrading from v0.2.0

One ALTER TABLE collections ADD COLUMN access. Instant, and rollback needs no undo — the previous binary reads and writes a migrated database.

Verified before release: a differential against a consistent snapshot of production (both binaries, each against its own copy) came back 49 identical, 0 diverged across every collection's records, kv, cron, hooks, scripts, files, auth, locks and event stats; 140 records / 26 collections / 29,757 events preserved; a patch on a real production document byte-identical.

Not in this release

Files are not scoped — user uploads still go through a hook that checks for itself. There is no realtime and no text search.

v0.2.0 — writes that do not lose each other

Choose a tag to compare

@javimosch javimosch released this 05 Sep 20:38
1a5fe3c

Atomic update operators, preconditions, self-bounding collections, grouped counts, and a scheduler fix — all of it from fit-checking bkn against a 131k-line control plane, asking not "could it run on bkn" but "would it be less code on bkn".

patch was a lost update

It read a document, merged in Go, and wrote the whole thing back, so concurrent patches erased each other. Measured against the previous code:

before now
16 concurrent $inc tries = 1 — 15 updates lost tries = 16
two patches, two fields one erased the other both survive
12 contenders claiming a job all 12 "won" it exactly 1

Fields may now carry operators computed from the current value, written under compare-and-set:

bkn store patch app/runs r1 --data '{"tries":{"$inc":1},"log":{"$append":"started\n"}}'
bkn store patch app/runs r1 --data '{"status":"done"}' --if status=running
bkn store patch app/runs r1 --data '{"worker":"w1"}'   --if-absent worker   # claims it exactly once

A plain object is still a plain value — only a single $-prefixed key is an operator. A failed precondition writes nothing and exits 95.

Collections that bound themselves

The trim query and the job that ran it both go away:

bkn store create app/memories --retain-last 20 --retain-per tag,repo_id,user_id

Enforced on every write and the moment the policy is declared, so a bound set on a collection that already holds a million documents applies immediately.

Rollups

How many, not which — one field, one aggregate, never documents:

bkn store count app/runs --where repo_id=7 --by status

total counts matching documents and groups counts distinct values before any limit, so truncation is visible rather than silent.

@every fired early, and sometimes twice

Schedule.Next rounded intervals down to the second to match second-resolution storage, so an @every 1s job ticked at :01.999 was scheduled 1 ms later and re-fired for the rest of that second. It rounds up now: late is harmless, early is a bug. Observed in a live deployment — one pair of runs in the same second across 11,246.

What it deliberately will not do

AGENTS.md gained a Deliberate omissions section, each entry argued from the codebase that asked for it: no transactions (a transaction is caller-held state, and a one-shot CLI over a stateless API has nowhere to hold it — locks is the multi-statement answer), no joins (denormalize), no regex/LIKE, no multi-field sort (ordering is already total), no age-based retention.

Together these took the fit-checked codebase from 33 statements beyond the store's surface to 11 — and all 11 were refused rather than absorbed. Three primitives, no query language.

Upgrading

Schema change: two columns on collections and one index, applied automatically on open. Verified against a copy of a live 16 MB database — 140 documents and 23,156 events read back byte-identical, and the previous binary can still read and write the migrated file, so a rollback is safe.

curl -L https://github.com/javimosch/bkn/releases/download/v0.2.0/bkn -o bkn && chmod +x bkn
./bkn install

Linux x86_64, statically linked, no runtime dependencies.

bkn update does not use these tags — versions are content hashes served at GET /version and /dl/bkn. A tag is for people; the hash is what a machine updates against.