Documentation
Changelog — ZigBase
Release history for ZigBase, following Keep a Changelog and Semantic Versioning.
All notable changes to ZigBase are documented here. The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[0.13.0] - 2026-08-09
Breaking
- The API error envelope changed shape. It is now
{"status": <int>, "code": "<string>", "message": "…", "data": {…}}— the top-levelcodeused to repeat the integer HTTP status and is now a frozen machine string (the integer moved tostatus). The three divergent shapes are gone: typed (rpc.*) routes, auth-method endpoints, andctx.jsonErrorall emit this one envelope instead of the old bare{"message": …}and{"error": …}bodies. Branch oncode;messagetext is not contract. Every built-in error response now carries acodethat matches itsstatus(401 →unauthorized, 403 →forbidden, 404 →not_found, 409 →conflict, 429 →too_many_requests, etc.) instead of silently defaulting tointernalat ~30 call sites across the auth, files, mail, accounts, senders, and analytics APIs. The official SDKs readmessageanddataonly and need no change. ctx.jsonError(status, code)now takes a message:ctx.jsonError(status, code, message).zigbase servenow takes an exclusive lock on its data dir and refuses to start when anotherserveprocess already owns it. Two servers sharing one data dir silently half-worked before (two JWT secrets, two schedulers running the same cron, two provisioners racing the same DDL). Pass--ignore-lockfor the old behavior — that instance is untracked and invisible tozigbase serve status/stop/logs.
Features
GET /api/meta— a public, unauthenticated capability probe reporting the running version and commit, which optional route groups this binary carries (admin, OAuth2, WebAuthn, magic link, tenancy, analytics, senders, mail webhook/unsubscribe), which build flags are on (Postgres, S3, vector, dev mode), whether collection metadata is frozen, where the public feature-state route is mounted, and the upload size limit. Tools no longer have to infer frozen mode by string-matching a 403. It exposes only facts an anonymous client could already establish by probing — never a config value, path, or credential — and it is deliberately separate from/api/health(liveness) and/api/state(per-subject feature flags).zigbase version --jsonandzigbase migrate status --jsonemit exactly one JSON object on stdout (prose and warnings go to stderr), for scripts and agents that would otherwise parse the human report.- Added
-Ddev-tools(default on): a build flag that comptime-gates the three pure development-time CLI verbsinit,agents-md, andtypegen(project scaffolding + schema-to-client codegen —src/scaffold*.zigandsrc/codegen/**, ~490 KiB in aReleaseSafe, stripped binary). Every artifact we publish — the GitHub release tarballs, the Docker image, and the@zigbase/servernpm packages — builds at this default and ships all three;-Ddev-tools=falseis an opt-out for a consumer compiling their own binary for their own deployment who has no use for scaffolding or codegen on a running server. A binary built that way still recognizes the verb names but exits non-zero with a message pointing back at-Ddev-tools=trueinstead ofUnknownCommand. zigbase doctorruns nine preflight checks over a deployment — JWT-secret persistence, every@publicaccess rule enumerated by name, cookie security, bind address, reverse-proxy coherence, mailer configuration, pending migrations, data-dir writability, and auth records still carrying a legacy password hash — and exits1when any of them is an error,2when it found warnings only, and0when the deployment is fully clean.--productionjudges the same facts as a production deployment, escalating the risky warnings (an anonymously writable collection, insecure cookies, an unconfigured mailer, a spoofable--trust-proxy) into errors.--jsonemits NDJSON findings plus one summary object, and the check ids are frozen, so a script can match on them forever. It never opens or creates anything under a data dir it already found unwritable — the two DB-backed checks reportskippedinstead — so a read-only diagnostic never mutates a deployment it can’t safely reach. The legacy-password-hash check stays a warning even under--production: a legacy hash is a normal transitional state that re-hashes itself on the owner’s next successful login, so what a migrating operator wants is to watch the count fall to zero, not to be blocked from deploying.- Two new frozen error codes.
payload_too_largereplaces a genericinternalon the 413 an over-size upload returns — a client fixes that by sending less data, so it must never be indistinguishable from a server fault.email_not_verifiedreplaces a genericforbiddenwhen login succeeds against an account whose email is unverified, so a client can route to its verify-email flow by matching a code instead of the message text. - The error code is now exposed on every SDK’s error type —
ZigbaseError.code(TypeScript, Python) andZigbaseException.code(Dart, Kotlin). This is the handle that makes the frozen registry usable from a client: branch oncode, never onmessage. It is empty when the server sent no code, and a pre-unification integercodeis ignored rather than surfaced as one. - Error codes are now a frozen, documented registry (
src/error-codes.frozen): every code ZigBase emits — the ten top-level envelope codes and the 27 field-levelvalidation_*codes — is append-only and permanent, enforced by unit tests and by a CI guard that diffs the ledger against the base branch so a code can never be quietly deleted. Match oncode;messagetext is explicitly not part of the contract and may be reworded at any time. zigbase explain-code [CODE] [--json]— print the summary and long-form explanation for any frozen API error code, or list every code withexplain-codealone.--jsonemits exactly one JSON object on stdout (prose goes to stderr); the exit code is 0 for a registered code and 1 for an unknown one.zigbase importnow supports migration-scale NDJSON loads:--dry-runexecutes and validates every row through the full engine (defaults, encryption, auth transforms) then rolls back instead of committing, so a rehearsal writes nothing;--continue-on-errorisolates each row in its ownSAVEPOINTand skips a failing one instead of aborting the whole run, logging its finding to--error-logas NDJSON ({"line":N,"code":…,"detail":…});--progress Nprints a heartbeat to stderr;--jsonprints the run summary as one JSON object on stdout (created/updated/failed/total). A lossy import (failed > 0) now exits3, never0, so an agent or script cannot mistake skipped rows for success.zigbase import --manifest FILEloads a whole dataset — several NDJSON files, one per collection — in one command. Collections load in relation order (from a JSON manifest:{"zigbaseImportManifest":1,"collections":[{"collection":"authors","file":"authors.ndjson"},{"collection":"posts","file":"posts.ndjson","upsertKey":"slug"}]}), withfilepaths resolving against the manifest’s own directory so a migration bundle is relocatable. Relation cycles and self-relations — which defeat any static load order — are loaded with the offending values stripped, then patched in by record id once every target row exists (a deferred row must carry its ownid). Arequiredfield on one of those relations has no legal two-pass row order, so it’s refused up front instead of failing row by row.--manifestis mutually exclusive with--collection/--upsert-key/the positional file (the manifest supplies those per entry) and composes with the existing--dry-run/--continue-on-error/--error-log/--progress/--jsonflags. See docs/migration-tools.md.zigbase import --legacy-hashes bcryptimports users with their existing bcrypt password hashes, stored tagged as$zblegacy$bcrypt$<hash>(e.g.$zblegacy$bcrypt$$2b$10$...— the tag’s trailing$immediately precedes the bcrypt hash’s own leading$). On the user’s first successful login the credential is verified against the source hash and transparently rewritten as argon2id.$2a$,$2b$and$2y$hashes are accepted ($2x$is refused — its deliberately-buggy 8-bit handling is not reproduced here). Requires an auth collection, requires source ids to be preserved (the credential is matched to its row by id), refuses_superusers, and carries each row’sverifiedflag over so a cutover does not mail a verification demand to the whole user base. It is create-only: combining it with--upsert-key(or a manifest entry’supsertKey) is refused, because an updated row would land with no credential installed. See docs/migration-tools.md.--log-format text|json/ZIGBASE_LOG_FORMATswitches the whole log stream to one JSON object per line on stderr, and--log-level/ZIGBASE_LOG_LEVELsets the minimum severity (debug,info,warn,error). The env vars apply to every subcommand; the flags areserve-only.- New guide: Observability & machine-readable output (
docs/observability.md) — the log formats, the NDJSON consumption rule, the frozen error-code registry, and the--jsonCLI conventions. - The server is now distributed under the bare npm name
zigbasein addition to@zigbase/server, sonpx zigbase serve …works andrequire("zigbase")re-exportsbinaryPath(). The alias ships no binary and pins one exact@zigbase/serverversion, which stays the canonical package to depend on; installing both is harmless even though both provide azigbasecommand. The unscoped name is claimed by a one-time manual publish — seeclients/typescript/npm/RELEASING.md. - Added
tools/replay/zb_replay.py, a dependency-free parity-replay harness: record a backend’s HTTP behaviour, replay it against its replacement, and diff. Matching is a recursive subset with volatile keys (ids, timestamps, tokens) stripped at record time; an expectation ofnullstill requires the key to exist, so a migration that silently drops a field is caught rather than passing. Findings are NDJSON, the summary is one JSON object on stdout, and a parity failure exits 2 (a fully-dead replay target exits 1 instead — nothing was actually exercised). See docs/migration-tools.md. - Per-request access logging: every HTTP request now emits one line with its method, path, status, and duration — as a structured record under
--log-format json, orGET /api/health 200 3msin text mode. Turn it off with--no-request-log/ZIGBASE_LOG_REQUESTS=falsewhen a reverse proxy already ships access logs. zigbase schema dump [--json] [--out FILE]writes a canonical, deterministic JSON document of every non-system collection — fields with their stable ids, indexes, access rules and options — that diffs cleanly in git. OAuth client secrets are redacted, so it is not a secrets backup.zigbase schema apply FILE [--dry-run] [--allow-destructive] [--prune]executes the difference between a document and the live schema through the same validation and DDL path as the REST collections API.dump→applyis a no-op, and stays one across repeated re-applies of an already-converged document — including one with a relation cycle. Destructive changes (drops, retypes) are refused without--allow-destructive;--dry-runexits 2 when it finds them. Collections absent from the document are left alone unless--prune. Refused under.collections_frozen. See docs/migration-tools.md.zigbase schema check-rules [FILE]lints access-rule expressions, which nothing validated when they were written: rules were bound into_collectionsas opaque strings and first parsed by the request that had to evaluate them, where a parse failure fails closed (500) — so a typo shipped silently and broke the first request touching the collection. The linter runs each rule through the real pipeline (no second grammar): given a data dir it is full depth (rules.compileGuard, the request path’s own entry point — catches unknown fields and bad relation traversals as well as syntax); given a document it is syntax depth (lexer + parser only, since resolving a field name needs a live schema), and every run states which depth ran. Blank rules (null/"") are Locked and never reported; a rule of exactly"@public"is a warning. Output isdoctor-shaped NDJSON findings plus one summary, exiting 0/2/1 for clean/warnings-only/errors.zigbase schema applynow syntax-checks every access rule in the document before it writes anything, and refuses the entire apply — nothing written, offending collection/rule/error code on stderr, exit 1 — if any of them fails to parse. It runs in--dry-runtoo and ahead of the destructive check, so a document that is both unparseable and destructive exits 1 rather than 2. The gate is syntax-only and has no opt-out: it does not resolve field or relation names (a rule may legitimately name a field the same apply is about to add) and does not report@public(apply’s exit 2 is frozen as “dry-run found destructive changes”). Those judgment-shaped findings stay inschema check-rules.zigbase serve --backgrounddetaches the server into its own process group, writing its output to<data-dir>/serve.logand exiting 0 only once the server actually answersGET /api/health— so a script or agent can start a server and immediately use it. Manage it withzigbase serve status [--json],zigbase serve stop(idempotent), andzigbase serve logs [--follow]. Liveness is anflock(2)held for the process lifetime, so akill -9’d session is detected as gone rather than lingering as a stale pid file.- A detected AI-agent environment (
CLAUDECODE,CODEX_THREAD_ID,GEMINI_CLI, and the rest of the usual table) makeszigbase servebackground itself automatically, printing which provider was detected and how to turn it off. SetZIGBASE_SERVE_BACKGROUND=0to opt out, or=1to force background mode anywhere. zigbase serve --ephemeralstarts a throwaway server on a fresh temp data dir and a free port, printing one JSON object —{"url","port","data_dir","pid"}— on stdout once it is actually answering. This is the zero-Zig test-backend story: an SDK test suite or a frontend dev script can spawn a real ZigBase, read one line, and use it. It composes with--background, and the temp dir is deleted on graceful shutdown and byzigbase serve stop.zigbase serve logs --jsonprints only the structured NDJSON records fromserve.log, dropping the plain-text startup banner the HTTP layer writes to the same file — sozigbase serve logs --json | jqworks against a real log file instead of failing on the first non-JSON line. It composes with--follow, and says so on stderr when the file holds no records at all (the usual cause being a session started without--log-format json).zigbase initscaffolds a starting-point project in one command —--box(no Zig toolchain:docker-compose.yml, aschema/collections.jsondocument,AGENTS.md/CLAUDE.md,.gitignore,README.md) or--framework(a Zig package withbuild.zig,build.zig.zon, a comptime schema, and in-process tests already wired). Box mode’s schema is a{"zigbaseSchema": 1, "collections": [...]}document you apply withzigbase schema apply— the same declarative surfacedocs/migration-tools.mddocuments, needing no superuser session token and no hand-scripted auth dance;docker-compose.ymlcarries a read-only./schema:/schema:robind mount sodocker compose exec zigbase /zigbase schema apply /schema/collections.jsonworks out of the box. Existing files are never overwritten — they are reported as skipped, and there is no--force. Reachable with no install at all vianpx zigbase init.zigbase agents-mdwrites a trap-orientedAGENTS.md(plus a one-lineCLAUDE.md) into a project that already exists, inferring box vs framework content from the directory.--stdoutprints instead of writing, for diffing.- New build helpers on ZigBase’s
build.zig, usable from a consumer’sbuild.zigvia@import("zigbase"):addTo(dep, mod)adds thezigbaseimport and setslink_libctogether, so the two cannot drift apart;addTest(b, dep, .{ .root_module = … })creates a test artifact wired with a.simple-mode test runner ZigBase now ships, which avoids the upstream Zig 0.16--listen=-build-runner race and fails the build on a leaked allocation.docs/framework.md§2 and the README now lead withaddToinstead of a separateaddImport+ “rememberlink_libc” pair, and §15 hands outaddTestin place of the previous advice to copy Zig’s own test runner into your project. - New docs: Testing — which test surface covers what, the build wiring, and what an in-process test structurally cannot see; and For coding agents — a ~2k-token entry point.
- The docs site now publishes
llms.txtand a machine-readabledocs-index.json, both generated from the same registry that drives the published pages. - Structured logging. Every log line is now timestamped and leveled, and the whole stream can be switched to one JSON object per line. Embedding consumers opt in from their own binary root with
pub const std_options = zigbase.std_options;.
Fixes
- A 5xx from an auth method no longer forwards its internal message to the caller. The frozen registry documents
internalas leaking no detail; the detail now goes to the log, and the response carries the generic body. The same applies to WebAuthn’s misconfiguration responses, which previously told an anonymous caller whether a collection’srp_id/originwere set. - A static-file read that fails for an internal reason (OOM) is now reported as a 500 incident instead of a silent 404. Genuine filesystem misses still return 404 without raising an incident, unchanged.
- A request that runs out of memory while parsing a multipart body now answers 500 instead of dropping the connection with no response at all (which also logged a status the client never received).
- The full-text provisioner no longer skips a collection in silence. A collection whose name is not a valid identifier is still not indexed — the read path deliberately answers
?search=on it withNotSearchablerather than returning unfiltered rows — but when such a collection actually declares.searchablefields, startup now logs what was skipped, why, and how to fix it, instead of leaving?search=failing with nothing in the logs. - Record reads of the
_-prefixed system collections (_superusers,_memberships,_invitations,_events, …) no longer degrade silently. Their names fail theschema.isValidIdentifiercharset gate (it requires an alphabetic first byte), which maderecords.getAtRestreport a phantom “record not found” — dropping the cross-instance realtime DELETE authorization snapshot on Postgres, so subscribers on other instances silently missed those deletes — and made all three TTL paths (get,list, and the_ttl_gcsweep) skip their expiry handling, which would have served expired rows and never reaped them. Every one of these now escapes the identifier viaddl.quoteIdent(the discipline already used for column names), so the read, list, and GC paths agree; the charset gate stays where it belongs: on user-supplied names at creation time. - Corrected the documented identifier-safety model.
CLAUDE.mdanddocs/security-audit.mdboth stated that every interpolated SQL identifier is gated throughschema.isValidIdentifier— which the query layer never calls. The guarantee is real but is three mechanisms, not one: user-supplied names are charset-gated at creation;?filter=/?sort=path segments are membership-checked against the collection schema (an exact-name whitelist, stronger than a charset check); and engine-owned names are escaped at interpolation. Both documents now describe what the code actually does, so a reader assessing injection risk is not reasoning from an inaccurate model. - A running server now notices collection changes made by another process.
zigbase schema apply,zigbase migrate,zigbase import, andzigbase migrate-dbmutate a data dir that a server may be serving from, but the collection-metadata cache had no TTL and was invalidated only by this process’s own REST DDL — so the server kept serving stale definitions (stale access rules included) until it was restarted. Because negative lookups were cached too, a newly created collection kept returning 404 rather than merely looking out of date. A one-row_schema_stategeneration marker is now bumped by every engine write to_collections, inside that write’s own transaction, and a background observer drops the cache within 5 seconds of the value changing. No new config key and no new environment variable. - Hand-written metadata SQL (e.g.
UPDATE "_collections" …viactx.records().queryAs()) can announce itself with the newctx.markSchemaChanged()/tx.markSchemaChanged(); see framework.md. - A failing built-in endpoint no longer returns an unexplained 500 in silence. Errors escaping the built-in route table, the feature-state route, and custom-route dispatch are now logged and delivered to your
onErrorhook (and to Sentry, when configured) exactly as consumer-route errors already were. - Custom-route dispatch failures (connection-pool acquisition, authentication) used to be swallowed and fall through to static-file handling, answering with the wrong status; they now return 500.
Changed
zigbase migrate statusnow exits 1 when any migration is pending or orphaned, and 0 otherwise, so it can gate a deploy:zigbase migrate status || zigbase migrate. It previously always exited 0. The JSON form carries the same signal asok.- Every CLI usage error (an unrecognized command, an unknown flag, a bad flag value) now exits 1, program-wide. It previously exited 0 after printing the error and usage — silently telling a script or deploy step that a rejected invocation had succeeded.
- A malformed environment variable now aborts startup with a message naming the variable, the offending value, and the accepted form, instead of dying with a bare parse error or silently falling back to a default.
- Boolean environment variables accept exactly
true,false,1, or0. Any other spelling is now a startup error. Previously anything that was nottrueor1silently meantfalse, soZIGBASE_TRUST_PROXY=yesquietly left the knob off. - An unrecognized
ZIGBASE_*variable now logs a startup warning naming it (never its value), so a typo’d knob is visible instead of ignored. - The
recipes.mdtesting recipe now teacheszigbase.testing(in-process,StartOptions-based determinism,captureMail). The process-globalzigbase.testcaptureseam is still documented, under a heading that says it is for a spawned server.
Security
- Legacy credentials are only ever installable through the offline CLI import: the HTTP path continues to strip
passwordHashandverifiedfrom every client payload, so no request can install one. The algorithm allowlist is matched against the explicit tag, never inferred from a hash’s own prefix, so an untagged foreign hash matches no verifier and fails closed. Nothing ever writes a legacy hash back after an upgrade. - Startup provisioning now persists an access-rule change to an existing collection. A rule edited in a comptime
.collectionsliteral was silently dropped unless the same startup also added a field or changed.ttl_field— so a developer who tightened a rule in code and redeployed kept enforcing the old, looser rule until the collection was touched some other way (access rules are enforced from the persisted_collectionsrow, not from the comptime literal). Rule changes are now written on every startup, via a metadata-only update: no table rebuild, no row copy, and no risk to indexes the provisioner doesn’t manage. A rule left unset (null) still means “leave the live value alone”, andnullvs""is not treated as a change (both mean locked/superusers-only). Note that.indexeschanges on an existing collection are still not re-applied — see Known limitations. - Hardened attacker-facing parsers and auth/WebAuthn owned-result builders against leaks on allocation failures, and made multipart request-arena ownership explicit.
- Tenant scoping can no longer fail open on an identifier check.
tenancy.scopeAppliesdecided tenant-ownership partly fromschema.isValidIdentifier(col.name), so a tenant-owned collection whose name that gate rejects — every_-prefixed system collection, plus any collection with atenant_fieldplanted by writing_collectionsdirectly — reported “tenant scoping does not apply”, dropping both the forced per-row check and the bound scope predicate and serving the collection un-scoped across all tenants. Tenant-ownership is now decided by the schema alone and the identifier is escaped viaddl.quoteIdent, so no identifier shape can widen scope; atenant_fieldnaming no column now fails the query instead of returning every tenant’s rows. (Audit finding F18’s documented residual, now closed rather than merely unreachable.)
Internal
changelog.d/README.mdnow warns up front thatscripts/assemble-changelog.shis destructive — it rewritesCHANGELOG.mdandgit rms every fragment in the directory, including ones belonging to other open PRs — and that it is a release-time tool, not a way to check that your own fragment parses. Its name reads like a validator, which is exactly the trap.CLAUDE.mdno longer describes the assembled block as being inserted “below## [Unreleased]”. There is deliberately no## [Unreleased]section inCHANGELOG.md, and the assembler anchors on the most recent released-version heading rather than requiring one — the old wording invited a contributor to re-add a section the project removed on purpose.- Added the standard open-source community health files:
CONTRIBUTING.md(toolchain setup, the two test suites and why a greenzig build testdoesn’t imply a green browser suite, theNO_SLOP.mdquality bar, changelog fragments, docs/examples sync, PR process),CODE_OF_CONDUCT.md, andSECURITY.md(supported versions, private vulnerability reporting, what to include, coordinated-disclosure expectations, and an explicit out-of-scope list covering documented trade-offs like--insecure-cookies,@publicrules, and unauthenticated static serving). The code of conduct is the canonical Contributor Covenant 2.1 text verbatim, differing only in the[INSERT CONTACT METHOD]substitution, so GitHub’s community profile identifies it as Contributor Covenant rather than “Other”; the routing guidance — conduct concerns go to the enforcement contact, not to a public issue and not to the security advisory form — lives inCONTRIBUTING.md, which already handles the other “never file this publicly” case. CONTRIBUTING.mdstates the project’s position on AI-assisted contributions explicitly: they are welcome, judged on code properties againstNO_SLOP.mdrather than authorship, with the contributor responsible for every line — a deliberate departure from the upstream Zig project’s no-LLM policy, noted so contributors aren’t confused about which applies where.- Added GitHub issue forms under
.github/ISSUE_TEMPLATE/(bug report, feature request, documentation) plus aconfig.ymlthat disables blank issues and links to the docs site, private security reporting,KNOWN_LIMITATIONS.md, and the contributing guide. The bug form requires the version, build flags, and backend, since several subsystems are comptime-gated and absent from a stock build. - The four client SDKs’ error-parsing fixtures now carry the current
{status, code, message, data}envelope. They passed either way — none of the SDKs reads the top-levelcode— so they were quietly teaching the pre-unification shape to anyone reading them. scripts/check-gating.shgains a fourth reference binary (the stockzigbasebinary rebuilt with-Ddev-tools=false) and two patterns (scaffold.,codegen.) proving the gate actually removes the scaffolding/codegen subtree rather than just making it unreachable dead code. CI adds a matching-Ddev-tools=falsebuild + full test-suite run so the stripped configuration can’t rot silently.- Every SQL identifier the engine interpolates is now escaped through
ddl.quoteIdentinstead of being wrapped in bare quotes and relying on an upstream charset gate — acrossddl.zig(which defined the helper and mostly did not use it),query/joiner.zig,api/auth.zig,api/oauth.zig,rules.zig,realtime/hub.zig,migrator.zig,provision.zig,import.zig,collections.zig,search/vector.zig, andanalytics/api.zig. The emitted SQL is byte-identical for every name that can exist, so this is a consistency and defence-in-depth change rather than a behaviour change; composite index/constraint names are assembled and then escaped as one unit so aCREATEand its laterDROPagree on the name. - The three example apps opt into structured logging (
pub const std_options = zigbase.std_options;), so--log-formatand--log-levelwork when running them. gen-server-packages.mjsemits the alias manifest from the samebuild.zig.zonversion as the meta package, so the alias and its@zigbase/serverpin cannot drift, andpublish.mjsre-checks the pin before publishing. It gains a--what aliasscope (no cross-build, publishes onlyzigbase) which is also how the name gets claimed;release.ymlpublishes the alias after the meta on av*tag. Newtest-alias-install.mjspacks real tarballs and installs them with no registry access, covering thenpxpath that requires the alias to declare its own bin, argv and exit-code forwarding through both shims, and the duplicate-bin case.- New end-to-end suites
tests/admin/test_schema_cli.py,tests/admin/test_import_manifest.py,tests/admin/test_legacy_auth.pyandtests/tools/test_replay.py;tests/toolsruns in thebrowserCI job. - Corrected the documented release process and moved it out of
CLAUDE.mdinto a lazy-loadedreleasingskill (.claude/skills/releasing/SKILL.md).CLAUDE.mdhad describedscripts/release.sh [--publish]as the way to cut a release; it is the manual bootstrap/offline/emergency fallback (as the script’s own header states) and it publishes neither the Docker image nor the npm packages. The primary path is pushing av<version>tag, which.github/workflows/release.ymlturns into one 4-target build fanned out to the GitHub release (tarballs +SHA256SUMS, body extracted fromCHANGELOG.md), the multi-archghcr.io/valthon/zigbaseimage, and the@zigbase/server*npm packages — with every job asserting the tag matchesbuild.zig.zon. The client SDKs each release on their own tag, anddocs/security-audit.md’s dependency-bump checklist no longer points at the fallback script. All of this is needed only when cutting a release, but as always-loaded memory it cost roughly 890 tokens of context in every session, soCLAUDE.mdnow keeps just a pointer; the non-releasegh pr editgotcha moved up into “Conventions that bite”, where it applies to any PR. changelog.d/README.mdno longer claimsassemble-changelog.shwrites the published mirrorsite/src/content/docs/changelog.md— the script stopped touching it (it is a generated build artifact regenerated bysite/scripts/gen-docs-mirror.mjs), and the file’s intro paragraph had gone on contradicting its own “At release” section a few lines below. It now also notes that the assembled block becomes the GitHub release body, which is why the pre-merge consistency review matters.- Added bounded coverage-guided fuzz targets for filter, query-string, PostgreSQL connection-string, and WebAuthn parsers, and corrected optimized-mode configuration in the benchmark harness.
- Fixed a latent port collision in the live SMTP-over-TLS test fixture (
tests/smtp). It chose its SMTP and HTTP ports with two sequentialbind(0)-then-close calls, which returns each port to the ephemeral pool before it is used, so both roles could be handed the same number:aiosmtpdbound it,zigbase servethen failed to bind, and the test’s HTTP request reached the SMTP listener — surfacing three steps later asBadStatusLine: 220 ... Python SMTP. Both ports are now reserved together, with every socket held open until all are chosen, which removes the duplicate rather than making it rarer. The readiness guard was also complicit: it only opened a TCP connection, so an SMTP listener satisfied “zigbase serve did not come up”; the HTTP role now requires a real HTTP response, failing at the point of failure instead of three steps later. examples/bloggained azig build teststep usingzigbase.testing, and itsAppis hoisted to apub constso tests can reach it; the step runs in CI. Its vitest e2e stays — it is the only end-to-end coverage of@zigbase/clientover a real socket.tests/admin/test_docs_parity.pynow fails when adocs/*.mdis only half-registered on the site (registry, the mirror generator’sPUBLISHEDset,site/.gitignore, and the sidebar must agree).
[0.12.0] - 2026-07-26
Breaking
The three request-scoped allocator seams are now the typed
zigbase.RequestArenainstead of a barestd.mem.Allocator:RecordEvent.arena(ev.arenain hooks),Ctx.arena(ctx.arena/req.ctx.arenain custom routes and jobs), andRequestCtx.allocator(ctx.allocator). The allocator itself is the field.aon the wrapper, so every place you passed one of these seams to something that wants an allocator, append.a:ev.arena.alloc(...)→ev.arena.a.alloc(...),ev.record.object.put(ev.arena, …)→ev.record.object.put(ev.arena.a, …),std.fmt.allocPrint(req.ctx.arena, …)→std.fmt.allocPrint(req.ctx.arena.a, …),ac.ctx.allocator→ac.ctx.allocator.a. Passing a seam straight through to another ZigBase API that takes aRequestArenaneeds no change; the compiler flags every site that does.Why the break is worth it: these arenas die at the end of the request, and the old bare-
Allocatortype made the two easiest lifetime bugs invisible — handing an arena-scoped API a long-lived general-purpose allocator (a leak), or stashing a request arena somewhere that outlives the request (a dangling read).RequestArenais constructible only from a realstd.heap.ArenaAllocatorat the boundary that owns it, so the first mistake no longer compiles, and the deliberate.aescape hatch makes the second one greppable instead of the default path.The dev-only build option
-Ddev-clockis renamed-Ddev-mode(it already gated the frozen clock, seeded entropy, and test-capture; it now also gates the new fake field-crypto). Update any CI/e2e invocation of-Ddev-clock=…to-Ddev-mode=….A file download URL built with a raw
.authsession token in?token=(rather than a.filetoken fromPOST /api/files/token) is no longer authenticated by that token. No first-party client did this — the SDKs and admin UI already use.filetokens or the auth cookie/header — but a hand-built URL relying on the old behavior must switch to a.filetoken.jwt.signnow returnserror.TokenTooLargerather than minting a token that exceedsjwt.max_token_len, so this module can never produce a token it would itself refuse. An application putting more than ~3 KB into the caller-suppliedplclaim now fails at sign time instead of at the next request. Applications within that budget are unaffected.
Features
zigbase.checkSql/checkedSql: comptime validation of raw-SQL table/column identifiers against the.collectionsschema, failing the build on an unknown table or a mistyped qualified column. Best-effort by design (tables strict, qualified columns checked, unqualified columns/functions untouched) to guarantee zero false compile errors on valid SQL — including upserts: theUPDATEinON CONFLICT ... DO UPDATE SETis recognized as a conflict clause (no table operand), not anUPDATE <table>statement.zigbase.Query.select: a comptime, schema-checked single-table SELECT builder that emits validated SQL + positional binds forqueryAs— an unknown table/column is a build error, and binds are positional by construction. SELECT-only / single-table in v1 (joins, writes, andinare noted as future work).- Official Dart client SDK (
clients/dart, pub packagezigbase_client): REST records API with offset + cursor pagination, injection-safe filters, per-collection auth (password, OAuth2/PKCE, sessions), pluggable auth stores, file uploads/URLs, accounts/analytics/senders services, and realtime subscriptions over WebSocket with auto-reconnect. Dart VM, Flutter, and Flutter web. - Dart codegen. The client generator now emits Dart alongside TypeScript. Pass
--lang darttozigbase typegen(runtime introspection) orzig build gen-client(comptime, via thegenClientSteplangoption) to generate azbase.gen.dart— concrete typed record classes, per-collection typed services (typed CRUD, a fluent where-builder that compiles to server filter strings, int/fixed decimal-string coercion, typed expand, files, and realtime) over the base@zigbase/clientDart SDK’s newpackage:zigbase_client/typed.dartruntime. Typedrpc.*, auth-method, and feature-flag surfaces remain TypeScript-only for now. zigbase.testingcan now boot apps that declare.encryptedfields (#260): passStartOptions.field_keyfor real AES-GCM, or let it default to a dev-only fake-encrypt mode that stores readablefake:<key>:<value>at rest (label defaults to@test@) so encrypted values are eyeball-able while debugging. Also selectable onzigbase serveviaZIGBASE_FIELD_CRYPTO=fake. Fake crypto is compiled out of release binaries (thedev_modegate) and its envelopes are mutually unreadable with real ciphertext, so a fake DB can never be served by a production binary.- Added
jwt.verifyIntoandjwt.peekClaimsInto, which decode and verify a token into a caller-provided scratch buffer with zero heap allocation. An over-large token fails closed witherror.TokenTooLarge.jwt.scratch_size(16384) sizes that buffer for the measured worst case — escape-heavy claims forcestd.jsonto copy rather than borrow, and the consumption-to-token-length ratio rises with size (>4x at ~5.7 KB), so it covers 14800 bytes for any token withinmax_token_len. The allocator-takingjwt.verify/jwt.peekClaimsremain for callers already holding a request arena. zigbase.jwt,zigbase.crypto, andzigbase.RequestArenaare now public exports of the framework module, for consumers that need to mint/verify tokens, derive keys, or take the compile-enforced request-arena contract type directly.- Kotlin client SDK (
clients/kotlin, Mavenio.github.valthon:zigbase-client0.1.0): coroutines-firstZigbaseClientcovering auth (password, refresh, OAuth2/PKCE, sessions), records CRUD with offset + cursor pagination and an injection-safe filter builder, multipart file uploads, file URLs/tokens, and accounts/analytics/senders services. Realtime and typed codegen tiers follow. - Kotlin SDK realtime tier (
zb.realtime, bundled — no extra dependency): ack-gatedsubscribe/subscribeTopicwith an unsubscribe-function return,stream()/streamTopic()coldFlows, custom broadcast topics (signal/message), automatic re-auth fromauthStoreon login/logout/refresh, and exponential-backoff reconnection with full resubscribe. - Kotlin SDK typed tier:
zigbase typegen --lang kotlingenerates@Serializablerecord data classes withfromRecordcoercion,Create/Updatepayloads withtoMapwire encoding, injection-safe fluent filter builders, and typed collection services (plus Flow-based typed realtime) over the newio.github.valthon.zigbase.typedruntime — golden-gated in CI against the dating fixture. zigbase typegen --lang kotlingains a--package <name>flag that sets the emittedpackagedeclaration, honored on both the CLI and the comptimegen-clientbuild step, so a consumer wiringgenClientStepwithlang: "kotlin"targets their own app’s package instead of getting an unoverridable namespace in a file marked “do not edit”. Unqualified invocations still default to the dating fixture’sio.github.valthon.zigbase.codegen.datingnamespace (keeping the committed golden andzig build gen-dating-kotlin-clientbyte-stable).captcha.Resultandoauth.discovery.Endpointsgain adeinit(allocator)that frees their owned strings, so a result produced with a non-arena allocator can be released. Callers on the request-arena path (the usualctx.verifyCaptcha,resolve/parseDocument) do not need it.- New
zigbase importsubcommand +zigbase.Importlibrary entrypoint: encryption-aware, offline (no HTTP server) bulk NDJSON record import that streams and batches through the record engine — validation, defaults,.encryptedfield envelope, and auth password hashing all applied — with optional--upsert-keyidempotency and source-id preservation. - Python client SDK (
clients/python, PyPIzigbase0.1.0): syncZigBaseand asyncAsyncZigBaseclients covering auth (password, refresh, OAuth2/PKCE, sessions), records CRUD with offset + cursor pagination and an injection-safe filter builder, file URLs/tokens, and accounts/analytics/senders services. Realtime and typed codegen tiers follow. - Python SDK realtime tier (
zigbase[realtime]):AsyncZigBase.realtimewith ack-gatedsubscribe/unsubscribe,stream()async iteration, custom broadcast topics (signal/message), automatic re-auth on auth-store changes, and exponential-backoff reconnection with full resubscribe. - Python SDK typed tier (
zigbase[typed]):zigbase typegen --lang pythongenerates Pydantic v2 record models, injection-safe fluent filter builders, and typed sync/async collection services (plus async typed realtime) over the newzigbase.typedruntime, golden-gated in CI against the dating fixture. Aselect-typed field’seq/neq/in_listacceptNonefor null filtering; a generated record’sexpandattribute (and each relation on its<Rec>Expandsubmodel) defaults to an empty value, so manual instantiation (tests, mocks) never requires building an expand submodel by hand. - Vendored/native component versions (SQLite, sqlite-vec, zap, facil.io, zigbase) are now discoverable via
zig build versions, the enriched--versionoutput + a startup log line, and aversionsobject onGET /api/health. - Building against an unsupported Zig version now fails at compile time with a clear required-vs-actual message instead of an opaque deep-compilation error.
Fixes
- Many of the memory-leak fixes below were surfaced by the allocator-ownership migration described under Internal. They share a shape: the leaked scratch was always reclaimed by the per-request or per-job arena a deployed server passes, so running servers were unaffected — but the leak was real for a framework consumer calling the same API with a general-purpose allocator, and it blinded the leak detector on that path. Each entry says which case it is.
- Static-file range handling:
normalizeRangeno longer leaks its scratchbytes=a-bstring on the already-canonical passthrough path (returnednullwithout freeing the freshly allocated buffer). A no-op under the request arena every production caller passes, but a genuine leak under any non-arena allocator. - Made several framework helpers self-freeing under any allocator (allocator ownership contracts 1 & 2), removing latent scratch/result leaks that were reclaimed only when the caller passed a request/job arena — as every in-tree call site does, so deployed servers were unaffected, but the leaks were real for a general-purpose-allocator caller:
sms/twilio.zigTwilioSender.sendnow routes its URL/auth/body build and theHttpClientresponse scratch (a fixedmax_response_bytesbuffer that has nodeinit) through a function-local arena.analytics/analytics.zigrunRollupbuilds its summary-table/watermark/aggregation-SQL scratch on a function-local arena.api/senders.ziglistBodybuilds its intermediate JSON envelope on a function-local arena (only the stringified body escapes).queue/durable.zigclaimBatchself-frees its dynamicIN (…)SQL scratch and returns an owned[]Claimedfreed via the newfreeClaimed(contract-2), with per-row error-path cleanup.authz/abilities.zigabilityPredicatefrees theallocPrint-built"<col>"."<via>" IN (prefix it leaked on every non-empty ability predicate (it was passed straight intoappendSlice, which copies, without freeing the temporary), and adds error-path frees for its predicate buffers.auth/challenge_store.zigtakeByIdentityfrees its intermediate challenge id (previously leaked on every call), andputfrees the generated id on a mid-insert error.route_types.zigtyped-route dispatch thunk (makeThunk) now frees its params view and — the real fix — keeps and deinits the JSONParsedhandle it previously discarded (parseFromSlice(…).value), which leaked that parse arena; only the serialized response body escapes.
- Vector search (
-Dvector)build: on an allocation failure while composing theORDER BYdistance expression, the already-allocatedWHEREfragment is now freed (addederrdefer), closing an out-of-memory-path leak. - A collection field’s
hiddenflag is now persisted and round-trips through a reload. It was never written to the stored schema, so every collection load silently reset user fields tohidden = false(the API and admin then reported hidden fields as visible); it now survives create/update/get correctly. - Fixed a memory leak on the auth-collection load path:
get(and the create/update it now backs) leaked the inner fields array when prepending the auth system columns, on non-arena allocators. - Fixed a memory leak in the
Datafacade’s typed record I/O:createAs/getAs/updateAsparsed the intermediatestd.json.Valuerecord returned bycreate/findById/updateintoTbut never freed that intermediate, leaking every owned string in the discarded record (~46 allocations per call) on non-arena allocators. The intermediate is now freed after the parse deep-copies intoT. dumpload.planCreateOrder(themigrate loaddependency-order planner) leaked its Kahn-algorithmplacedscratch buffer on every call. Always masked by the migration’s request arena; now freed explicitly.schema_dump.pgColumnType’s array-type branch (Postgres schema dump,_<udt>columns) leaked the base type string it formats into the final<base>[]result. Always masked by the per-dump scratch arena; now freed explicitly.- Fix a memory leak in stored-filename sanitization (
files/naming.zig):sanitizeBasefreed its scratchArrayListonly viaerrdefer, so every successful call leaked the buffer, andstoredNamenever freed the sanitized intermediate. Latent behind the upload request-arena, a real leak under any non-arena allocator. Now contract-1 (defer-freed; the return is always a fresh copy). - JWT signing no longer leaks its intermediate buffers when handed a non-arena allocator:
jwt.signnow frees the payload JSON, both base64 encodings, and the signing input, leaving only the returned token allocated. - Fix a memory leak in captcha response parsing.
captcha.parseResponse(reached viactx.verifyCaptcha) parsed the provider’s JSON with a leaky parser and never freed the tree, and returnedResultfields that borrowed it — including anerrorsslice that was an un-freeable sub-slice of a larger allocation. It now frees the parse tree and returns independently-owned dupes. Requests served through a per-request arena were unaffected in practice (arena teardown reclaimed the tree); the leak bit any caller using a general-purpose allocator. - Fix several memory leaks in the mail subsystem, latent in the request/job-arena path but real under any non-arena allocator:
- Bulk email:
bulk.sendBulknever freed the per-recipientvars_json/durable-jobpayloadscratch (freed per iteration now that SQLite/enqueue copy it), andbulk.jobHandlernever freed the ~9 fields it rendered per delivery (now routed through a function-scoped scratch arena freed on every return path). - Inbound webhooks:
suppression.parseProvider/mapSes/mapPostmarknever freed the provider JSON parse tree and returnedEvent.emailas a slice borrowed from it (now duped before the tree is freed);inbound.ingestdiscarded the suppressionEventslice without freeing it; andinbound.webhook_handlerleaked its responseObjectMap.
- Bulk email:
- Multipart form-data parsing no longer leaks its per-request delimiter scratch:
files/multipart.parseallocated two derived boundary-matching strings on every call and never freed them, leaking that memory for any caller that does not pass an arena allocator (the production HTTP upload path is arena-backed, so served requests were unaffected). - Creating or updating a record with file uploads no longer orphans the uploaded bytes in storage when the write is rolled back (a failed commit, a denied access-rule guard, or a validation error) — the just-written files are now always removed on any pre-commit failure.
- Failures while cleaning up files after a delete/update (e.g. a transient object-store error) are now logged instead of silently swallowed, so orphaned-file accumulation is diagnosable.
- A malformed or out-of-range numeric value in a
filter,sort, or cursor (e.g.price=99999999999999999999) now returns400 Invalid filter or sort.instead of500. - The request error path no longer risks panicking the server (or invoking undefined behavior in a
ReleaseFastembed) when the machine is out of memory: rendering a 500 that itself fails to allocate now falls back to a preallocated static error body. - Malformed
.cronschedule strings are now rejected at compile time (wrong field count, full day names likeMONDAY, a trailing/doubled space) instead of silently making a job fire once at boot and then retire without ever running on schedule. The same validation applies to.auth.session.gc_cron. - A cron/interval job whose schedule has no future fire (e.g. an impossible date like Feb 30) is now retired at startup and logged, instead of being treated like a reactive job and run at an arbitrary boot time; jobs that retire for having no next fire are now logged rather than vanishing silently.
- Cron expressions now support the full standard grammar:
<lo>-<hi>/<step>ranges (e.g.0-23/2for every other hour) and day-of-week7as a Sunday alias (0and7both mean Sunday). Out-of-range field values (minute > 59, hour > 23, month > 12, day-of-month0, day-of-week > 7, etc.) are now rejected at compile time rather than compiling into a job that silently never fires. - Typo’d keys in more config surfaces are now a loud
@compileErrorinstead of being silently ignored: route specs (.rate_limit/.rate_limit_key/etc.), background job specs,.auth.methodsand each built-in method’s options (.magic_link/.otp/.password/.webauthn),.auth.oauth2and its provider literals (e.g..tokenURL), collection.indexesentries (.unique/.collation/.where), and the.poolstuning group. - Duplicate or empty
.migrationsids are now a compile error; previously a copy-pasted id silently skipped the second migration on every environment. - A failure to create the data directory at startup (permissions, read-only filesystem, out of space) is now logged with the path and cause instead of being swallowed and later surfacing only as an opaque database-open error.
- Realtime: a failed
subscribeis no longer acked to the client as success. Neither a facil.iosubscribefailure nor a failure to record the just-created transport subscription (under memory pressure) can now strand the client — previously the first left a silent dead subscription and the second left a live subscription a later unsubscribe could never cancel. Both now roll back the logical subscription, log, and return an error frame the client can retry, on both the WebSocket and SSE transports. - Realtime: a dropped broadcast/signal/message frame (allocation failure on an already-committed write) is now logged with the collection/topic and action, matching the cross-instance paths, so a client-reported “missed update” is diagnosable instead of vanishing silently.
- Realtime (Postgres): cross-instance delete-snapshot and broadcast side-table failures now distinguish a genuinely-absent row (a forged/expired token — a quiet fail-closed drop) from a real database/parse error, which is now logged instead of collapsed into the same silent null.
- Realtime (Postgres): the cross-instance
LISTENreconnect backoff now resets only after a connection has stayed healthy for several seconds, and sleeps before reconnecting after a short-lived session. A proxy or mid-failover node that accepts the connection andLISTENbut drops it on the first wait no longer drives a zero-delay connect/reconnect loop. - A write that violates a database integrity constraint — most commonly a duplicate value on a unique field, such as signing up with an email that is already registered — now returns 409 Conflict instead of 500 Internal Server Error. Clients, SDKs, and error monitoring can now tell a routine user conflict apart from a genuine server fault. This applies to record create/update, runtime collection create/update, and WebAuthn credential registration, on both the SQLite and Postgres backends. Underneath, the internal database error set gained a distinct
error.Constraint(SQLiteSQLITE_CONSTRAINT; Postgres SQLSTATE class 23), raised from the prepared-statementstep()path instead of collapsing every failure intoerror.StepFailed, so a custom route that lets actx.records()write propagate surfaces the 409 automatically. Framework consumers who matched onerror.StepFailedfor a unique-violation race should matcherror.Constraint. Theexec()/COMMIT path (including deferred-constraint failures) is unchanged and still reportserror.ExecFailed. - Additive
ADD COLUMNsteps in the system migrations (sessiontoken_epoch,_collections.options,_suppressions.updated) no longer swallow every error as if it were the benign “duplicate column” case. A genuine DDL failure (lock timeout, disk full, connection drop) now propagates and aborts the migration instead of being recorded as applied with the column still missing — which could permanently break token issue/verify for an auth collection with no migration-based repair. Idempotence now comes from a backend-catalog column-existence check. - On Postgres builds, a pathological placeholder count in developer-authored raw SQL now surfaces a prepare error instead of panicking the process. This covers both a numbered
?Nwith an out-of-range or overflowing index (for example?10000000or a 20-plus-digit run), which previously panicked at statement-execution time, and an extreme number of anonymous?placeholders, whose running counter is now bounded by the same param cap rather than overflowing the placeholder buffer during renumbering. - Outbound webhook delivery now bounds the total time one attempt sequence spends sleeping between retries, so a receiver returning a large
Retry-After(or a long configured backoff) can no longer keep a delivery running past the queue’svisibility_timeout_s— which previously let the job be re-dispatched as a concurrent duplicate and stalled other jobs on the worker for minutes. - Fixed a table-name string leak on the out-of-memory error path of the database dump-load (
migrate load) copy loop. - S3 storage: the spool cache-fill path no longer leaks the joined cache path for a non-arena caller when the spool directory is unwritable or full; a failed per-object remote delete (orphaning a billed object after its record is gone) and a cache directory that becomes unlistable at runtime (silently disabling spool eviction) are now logged instead of swallowed.
- SMTP mailer: a partially-built CA bundle is freed when a system-trust-store rescan fails mid-load, closing a leak on hosts with a malformed or unreadable certificate.
GET /api/features: an out-of-memory error while rendering the403 Forbiddenbody now propagates to the500backstop instead of hitting anunreachable(a panic in safe builds).- Comptime
.migrationsbare-tuple entries now reject an unknown key (a typo’d.transational/.donwwas silently dropped) with a loud@compileError, matching every other list-shaped config key. - Webhook deliveries: at startup, warn about any declared queue whose
visibility_timeout_sis too small to safely host a webhook delivery’s in-handler retry backoff, which could otherwise let the queue re-dispatch an in-flight delivery as a concurrent duplicate. - The “per-route rate limit cannot identify the client” startup warning now fires once per distinct route pattern instead of once per process, so a second unprotected route is no longer silently skipped from the log.
- Fixed a rare crash where enqueuing a background job (for example an error report) at the moment the in-memory job pool was shutting down could dereference a just-cleared pool pointer and panic the process.
App.submitnow null-checks the pool rather than asserting it, so a submit that races shutdown fails cleanly (the job is dropped) instead of crashing. - Fix memory leaks in the shared AES-256-GCM envelope (
aead.seal/aead.open) that backs every at-rest secret — OAuth client secrets and.encryptedrecord fields.sealnever freed its ciphertext/raw/base64 scratch buffers (three allocations per call) andopennever freed its decode buffer (plus the plaintext on a decrypt-verify failure). Served through a per-request arena the buffers were reclaimed at request end, but any non-arena caller (e.g. a batch re-encrypt) leaked on every encrypt/decrypt. Now contract-1: all scratch is freed, only the result escapes. - Fix leaks on the OAuth login path:
oauth.client.fetchIdentitynever freed theBearer <token>authorization header it built, andoauth.providers.extractIdentitynever freed the JSON parse tree of the provider’s userinfo response (leaked on every third-party login). provision.appliedConsumerMigrations/recentConsumerMigrations(themigrate status/migrate rollbackledger readers) leaked the lowered SQL scratchMigrator.preparebuilds on every call. Always masked by the CLI’s request arena; both now lower onto a function-local scratch arena instead.provision.migrationStatus’sorphaned[i].namewas an un-freeable mid-buffer offset into an internal ledger-read array the caller never saw (freeing it directly would have been an invalid free of a non-base pointer) —MigrationStatusnow dupes every retained string fresh and ships adeinit, so the result is a normal owned graph instead of an implicit arena-only value.provision.resolveDiscoveryProvidersleaked its partially-built collection/provider arrays when an OIDC discovery fetch failed mid-batch. Inert in production (the caller aborts startup on this error), but a real leak on any other caller; now freed via a tracked rollback on error.- Fixed a memory leak when reading or writing
jsonand multi-value (select/relation/file) record fields on a non-arena allocator:readValueused to return astd.json.Valuesub-tree from a discardedstd.json.Parsedwrapper (freeable only under an arena), andbindValuenever freed theStringify(and encrypted-seal) scratch it allocated.readValuenow returns a fully-owned, individually-freeable tree, andbindValuefrees its bind scratch — sorecords.freeRecord/ListResult.deinitreclaim a whole record (nested json/array sub-trees included) off any allocator. - Fix memory leaks throughout the realtime subsystem, latent behind the per-connection/per-request arena but real under any non-arena allocator:
realtime/connection.removeSubusedHashMap.remove, silently dropping the entry without freeing the duped subscription key/filter — a connection that subscribed/unsubscribed repeatedly accumulated leaked topics until disconnect. NowfetchRemove+ explicit frees, with a newConn.deinitfreeing all remaining subscriptions.- Realtime frame building (
protocol.zig,ws.zig,pg_bridge.zig,hub.zig) leaked scratchObjectMaps and JSON stringify/parse buffers on nearly every emitted event/signal/ack frame. realtime/pg_bridge.decode/decodeAny(the Postgres realtime bridge) parsed with a leaky parser and returned struct fields aliased into the never-freed tree; now dupes each field fresh withEvent/Signal/MessageRef/Payloaddeinitmethods. Also fixes an unfreed delete-snapshot JSON buffer instoreDeleteSnapshotInner.
- Fixed a long-standing memory leak in the
Datafacade:findById/create/update/delete/listloaded the collection metadata viacollections.getand never freed it, leaking on every call when driven by a general-purpose (non-request-arena) allocator. RecordValues now own their top-level keys, so the facade can free the collection safely. - Fixed a cursor-mode
records.listresult that returned a capacity-padded item slice and left the pagination probe row unfreed — harmless under a request arena but a wrong-size free / leak on a non-arena allocator. The result is now an exact-length owned slice with adeinit. - Fixed a memory leak on the record read path: when an encrypted field failed to decrypt part-way through building a row (fail-closed
error.BadEnvelope),records.get/getAtRest/create/update/listleaked the partially-decoded record.rowToObject/rowToObjectAtRestnow free the partial record on a mid-row read error. The same functions also leaked a hidden field’s decoded value (read but never stored); that value is now freed too. Both matter for a framework consumer that calls these APIs with a plain (non-arena) allocator. parseCollectionInput(the runtime collection create/update request parser) leaked its filtered field-array scratch on every call: the intermediate arrayfieldsFromJsonreturns was discarded without freeing its backing allocation, and a submitted field whose name collided with a reserved system name (e.g."email"on a base collection) leaked that field’s own id/name/options dupe entirely. It also leaked the escapingname/fields/indexesthemselves on a trailing error path (any ofindexesFromJson, the rule-string dupes, oroptionsFromJsonfailing after they were built) — none of these were reachable viaerrdeferonce each value’s own local guard went out of scope. All are always masked in production by the request arena; all are now freed explicitly.- Fixed memory leaks throughout the collection and record write/read paths (
collections.create/update/delete, record insert/update/delete, and record reads) when driven by a non-arena allocator. The HTTP request path reclaims this scratch through its per-request arena, but callers that pass a general-purpose allocator — the Postgres backend’s collection-cache fallback and directData-facade use — leaked the DDL, SQL, column-list and$nplaceholder-rewrite scratch on every call. These operations now free that scratch internally on both success and error paths, and validation failures hand their error list back as an owned, freeable slice, so each operation is leak-correct under any allocator.
Changed
Data.createAs/getAs/updateAsnow reject aTthat (recursively) contains a raw-JSON field (std.json.Value/ObjectMap/Array) at compile time with an actionable message.parseFromValueLeaky(used by these methods) returns such a field as an alias into the intermediate record they now free — which would dangle a string field (use-after-free) — so typed I/O is restricted to concrete field types; use the untypedcreate/findById/updatefor raw JSON.
Performance
- Realtime: per-subscriber event delivery now parses only the three envelope fields it needs (
action,record.id, and the delete-authorization snapshot) with a typed, unknown-field-skipping parse, instead of materializing the entire record body into a throwaway JSON tree for every subscriber. A create/update of a large record fanned out to many subscribers no longer does O(subscribers × record-size) redundant allocation. - List-endpoint
?expand=now resolves each relation target’s schema once per page and reuses each(collection, id)view-authorization decision across rows, instead of re-loading and re-parsing the target collection and re-authorizing on every returned record. This removes redundant_collectionsreads/parses and duplicate rule queries onGET …/records?expand=…, most impactful on the Postgres backend where each was a network round trip. - Realtime delete authorization: the per-subscriber in-memory authorization sandbox for a deleted record is now reused across every subscriber of the same delete event that is served on a given worker thread, instead of being rebuilt once per subscriber. Large delete fan-outs do far less redundant work; per-subscriber authorization decisions are unchanged.
- Pre-size the record-read hot path’s growing collections so their backing is allocated once instead of reallocating as they fill.
records.rowToObject(and its at-rest sibling) pre-sizes the per-recordstd.json.ObjectMapto id/created/updated + the collection’s fields;records.listpre-sizes its result-item list to the page limit. Measured withzig build bench, a 30-record list read dropped from ~260 to ~226 allocations per page (~13%) — the 512-byte size bucket fell from 38 to 6 — and a single-record read drops an allocation. Output is byte-identical (insertion/append order is unchanged), verified by the record + cursor-pagination browser tests.
Security
- Closed an account-enumeration oracle across every token-mail endpoint: OTP initiate, magic-link initiate,
request-verification, andrequest-password-reset. Each previously sent its code/link synchronously and only for an existing (or auto-created) account, so both the response timing and a propagated SMTP failure (500vs204) revealed whether an email was registered — and a mailer outage turned the endpoint into a boolean existence oracle. Delivery now goes through the non-blocking token-mail queue on all four, so each returns204with identical timing and status regardless of whether the email matched a record. - File downloads via the
?token=query parameter now accept only purpose-built.filetokens (minted byPOST /api/files/token), not full.authsession tokens. A session token in a URL query travels into access logs,Refererheaders, and browser history, so permitting it there invited long-lived credentials into those sinks. Session-authenticated downloads continue to work via theAuthorization: Bearerheader or the auth cookie. - Bound JWT token length before any allocation.
jwt.verifyandjwt.peekClaimsnow reject a token longer thanjwt.max_token_len(4096 bytes) aserror.TokenTooLargeas their first statement. Previously nothing on the request path bounded token length, while a request body may bemax_upload_size(50 MiB by default) and a realtime frame 256 KiB — and because a token is decoded before its signature is checked, an unauthenticated request could drive allocation proportional to the token it supplied. - Realtime: a duplicate
subscribeto a topic a socket already holds now REPLACES its subscription in place instead of stacking a second facil.io subscription. The old behavior let an (even anonymous) client loop subscribes on any public collection to bypass the per-connectionMAX_SUBScap entirely, grow per-connection memory without bound, multiply every published event’s server-side authorization/delivery work N×, and orphan all-but-the-last facil.io subscription until socket close — a connection-scoped denial-of-service. Applies to both the WebSocket and SSE transports. - Realtime: a connection can no longer be driven to unbounded memory growth by looping
authframes — closing an inbound-driven single-connection memory-exhaustion vector.authframes are now verified on a throwaway scratch arena, with only a successful identity persisted, and that verified identity is held in a dedicated arena reclaimed on each re-authentication. Previously everyauthframe leaked permanently into the connection-durable arena (freed only at connection close) — including a garbage token, which allocated during pre-validation claim parsing, so the attack needed no credentials at all — and even valid repeated re-authentication grew the connection without bound. - Client-supplied
?filter=and?sort=can no longer reference hidden fields (passwordHash,tokenKey,token_epoch, or any field markedhidden). Previously such a query on a non-locked collection turned row presence/absence into a boolean oracle, allowing character-by-character extraction of a per-user server secret the API never serializes. The query builder now rejects hidden fields in client input the same way it already rejects encrypted ones (closed, with a400), matching the read layer’s visibility rule exactly so no serialized column is affected. Trusted, operator-authored access rules may still gate on a hidden field — a rule is a server-sideWHEREclause whose truth is never returned to the client, so it is no oracle. - Per-route rate limiting no longer collapses every client into a single shared bucket when the client cannot be identified (a
.customroute limit with norate_limit_key, on a directly-exposed server whereZIGBASE_TRUST_PROXYis off and the client IP is unknown). Previously one anonymous caller could exhaust the shared bucket and 429 the route for everyone. The bucket is now keyed per client — the app-supplied key function, else the trusted-proxy client IP, else the authenticated principal — and when none of those can distinguish the caller the limit is skipped (fail-open) with a one-time warning rather than enforced as a poisonable global bucket. - The WebAuthn challenge check now uses the shared constant-time
crypto.timingSafeEqlhelper instead of a private byte-for-byte copy, so future hardening of the constant-time primitive reaches the ceremony verification. - Hardened the fail-closed
deny_lockedauthorization floor for Postgres dialect-portability. It hardcoded the SQLite-only0false-literal (WHERE 0), which Postgres rejects (argument of WHERE must be type boolean); it now uses the dialect’sconstFalse()(falseon Postgres) — the same constant the ability/tenant composition already emits — so the fail-closed floor is guaranteed-valid SQL on both backends. (In current code this branch is short-circuited byauthorizesbefore it reaches a statement, so no live query was affected; the fix hardens the path against any evaluator that runs the guard directly.) - Realtime (WebSocket/SSE) delivery now enforces relationship abilities, not just the access rule and tenant scope. A collection that was
@publicfor its view rule but visibility-narrowed by aviewability previously delivered every record to every subscriber, bypassing the ability on the realtime channel (REST reads were unaffected). Effective realtime visibility is now(rule) AND (ability) AND (tenant), matching the documented guarantee and the REST list/read paths. - Record validation now rejects an over-
maxSelectrelationorselectvalue on the element count, before running the per-element existence checks. Previously an over-limitrelationarray still ran one existenceSELECTper submitted id under the writer lock, so an attacker-sized array (bounded only by the request body limit) could drive a large number of queries from input already known to be invalid. - The runtime collections API now validates
tenant_field(andttl_field): it must be a valid identifier that names an existing field, rejected with an actionable error otherwise. Previously a superuser could set an invalid or danglingtenant_fieldvia the admin API; becausetenancy.scopeAppliestreats an invalid identifier as “scoping does not apply”, the tenant-owned collection would then be served un-scoped — a cross-tenant row leak. The comptime.collectionspath already enforced this; the runtime API now mirrors it, keeping the fail-open state unreachable. zig build auditcompares pinned dependency versions against a curated in-repo advisory table (docs/security-advisories.md); documented update process for vendored C security fixes.
Internal
- Allocator-ownership contract migration. CI now ratchets against leak-masked tests (
scripts/allocator-allowlist.txt): a test may wrapstd.testing.allocatorin an arena only where the code under test genuinely takes aRequestArena(contract 4), and every remaining line carries a written justification. Driving that ratchet down took the bulk of this release — from 121 files / 889 masked tests at introduction to 44 / 299 — by giving the framework’s internals explicit ownership contracts: a function either frees all of its own scratch and returns one caller-owned value (contract 1), or returns an owned handle with adeinit(contract 2). Converted subsystems includerecords/collections/schema/ddl, the wholequery/stack (lexer, parser, compiler, joiner, sort, keyset, params),policy/rules/authz,realtime/,mail/,oauth/+aead,files/,push/,provision,import,codegen/, and the leaf libraries. This is a correctness change, not a memory or performance improvement: every in-tree caller already passes a request/job arena that reclaimed the scratch and still does (an arena-backed scratch arena frees no capacity on deinit), so deployed servers are unaffected. The value is that the leak detector can now see these paths, and that a framework consumer driving them with a general-purpose allocator no longer leaks. The consumer-visible leaks the conversion surfaced are listed under Fixes. - New ownership primitives added along the way, usable directly by framework consumers:
schema.Collection.deinit(plusfreeFieldsOwned/freeIndexesOwnedfor a standalone[]Field/[]Index),records.ListResult.deinit,query.Joiner.deinit,Cursor.deinit,Guard.own/Guard.deinit,auth.Verified/Authed.deinit,tenancy.Resolution.deinit,features_resolver.Resolved.deinit,search.Vector.deinit,mail.unsubscribe.Parts.deinit,provision.MigrationStatus.deinit/RollbackOutcome.deinit/freeAppliedMigrations,queue.freeClaimed,values.freeValue, and anAcquiredhandle ({ arena, collections }, mirroringstd.json.Parsed) for the codegen acquire adapters.collections.create/updatenow return a fully-owned reload of the just-written row rather than a mixed-ownership hand-assembled value, and recordValues returned byget/create/updateown their top-level keys, whilerecords.listinterns one shared key set per query (borrowed by every row, freed once viaListResult.deinit) so the list read path adds no per-row key allocation. - Un-masking
src/codegen/(86 tests) surfaced pervasive ownership bugs in the client generators that the masking arena had hidden:identifiers.recordNamereturned a sub-slice of an internal allocation (freeing it was an invalid free), the sharedemit.putfformat helper leaked at every call site, every language generator leaked all of its scratch, andgen_client.generateleaked the whole generated buffer on its reachableerror.RpcTypeNameCollisionpath. All are fixed — generators own their scratch internally and return a single caller-owned slice, and thets/dart/python/kotlintype mappers return a uniform always-owned string. Generated client output is byte-identical (golden snapshots unchanged). This is a build-time tool, so none of these bugs affected the shipped server. - Documented the arena-scoped ownership contract (contract 4) on
http_client.HttpResponseandDownloadResult: the response body is a sub-slice of the fixedmax_response_bytesbuffer andrequest()/download()leave their scratch on the passed allocator, so there is intentionally nodeinitand callers must pass a request-scoped/arena allocator. - Converted the two JWT verification call sites whose claims are consumed internally (
auth.authenticate,api/auth.carrySessionCreated) from the arena-scopedpeekClaimsto the caller-bufferpeekClaimsInto, removing an allocation from the per-request auth path. The remaining four sites return borrowed claims and stay on the arena (safely bounded byjwt.max_token_len). Inauthenticatethe peek is scoped to a block so the stack-borrowed claims cannot escape into the returnedAuthed— a compiler-enforced guard rather than a prose one. - Added
NO_SLOP.md, a Zig code-review standard for AI reviewers distilled from Andrew Kelley’s positions and the official Zig docs, and referenced it fromCLAUDE.md. Its §4 also records the outcome of a data-oriented-design audit of the four structures it names: none is currently high-cardinality enough for §4 to apply, so the guidance is now “do not ‘fix’ these without a profile” rather than an open invitation to reflexive DoD. - Pointed the benchmark harness (
zig build bench) at the real record read paths for the first time (it had only measured the jwt exemplar), reached through adev_mode-gatedinternalseam inroot.zigthat folds tostruct {}in any release build, so it adds nothing to the shipped public surface.data/queryAs-50rowsmeasures the typed row-decode path (~3 small allocations per row);records/findById-jsonandrecords/list-json-30measure the JSON path every REST read returns — ~3x more allocations and ~6x more bytes per record than the typed path, and ~8.7 allocs/record for a batchedlistvs ~13 for individualfindById(the per-record prepare/SQL that N+1 gets each pay). A newharness.runArenameasures an op under a request-style arena reset between iterations: identical allocation count but ~15x faster than raw malloc — which is the point, since the allocation count/size distribution is the real backing-independent signal while the raw-malloc ns is overhead the arena erases. Aquery/filter-compilebenchmark of the SQL-injection-critical filter path is a useful negative result: ~24 allocations / ~6us, ~40x cheaper than a record list read, confirming filter compilation is not a hotspot. - Postgres full-text search now concatenates its text-search configuration into the emitted SQL at comptime (
++) instead of formatting it with{s}. The value lands inside a single-quoted SQL literal — an escaping contextschema.isValidIdentifierdoes not cover — so making it configurable later now fails to compile rather than silently opening an injection, and a comptime guard additionally rejects a quote or backslash in the literal. The emitted SQL is byte-identical (verified by running the new test against the previous implementation). Added alongside it, the first unit coverage for the Postgres full-text lowering:buildPostgresis pure, but nothing asserted its emitted SQL, so the read-side lowering had been exercised only by the live-Postgres suites (skipped in a default build). jwt.peekClaimsnow rejects a token carrying a 4th segment, matchingjwt.verify. Not a vulnerability (verifyis authoritative and always refused such a token), but the two parsers read the same bytes and should not disagree about what a well-formed token is.- Added
release-dart-sdk.yml, adart-client-v*-tag-triggered workflow that verifies (dart analyze, format check, unit tests, tag/pubspec.yamlversion consistency,dart pub publish --dry-run) and then publisheszigbase_clientto pub.dev via the official OIDC-based automated-publishing flow. The first publish still needs one-time owner setup on pub.dev — seeclients/dart/RELEASING.md. - Pinned
ruff==0.15.21in thepython-sdkCI job and the Python SDK release workflow’s format/lint gates (matching the codegen job), so a floatingruff>=0.8release can no longer turn a greenmainred on a later PR without any code change — as happened when post-0.15.21 markdown code-block formatting reflowedclients/python/README.md. - The live SASLprep/SCRAM PostgreSQL tests now skip when the suite role lacks the
CREATEROLEprivilege their throwaway login-role fixtures require, instead of failing with an opaqueExecFailedout of the setup DDL. Running the suite against a plain dev PostgreSQL whose suite role is not a superuser no longer reports two misleading SCRAM failures; CI (whose suite role is a superuser) still runs them. The module header documents both preconditions and how to pointZIGBASE_PG_TEST_URLat a privileged role to run them locally. - Deduplication sweep: consolidated three byte-identical RFC 3986 percent-encoders (captcha, Twilio SMS, OAuth token exchange) into one
url.percentEncode; the copy-pasted JSON body plumbing (parseBody/strField/jsonResponse) shared across the auth/OAuth handlers intoapi/common.zig; the near-identicalrequest-verification/request-password-resethandlers and thefindByEmail/findByIdentitylookup loop into a singlefindByFieldhelper (preserving the subtle nocase / guarded-free memory semantics);records.zig’s privatecoerceCloneinto the canonicalvalues.cloneValueit was a byte-for-byte copy of; and the record create/update file-cleanup block that had been copy-pasted into four return branches into one commit-guarded scope guard. - Hoisted the byte-identical, language-neutral schema-query helpers that the four client emitters (
emit.zig/emit_dart.zig/emit_kotlin.zig/emit_python.zig) each kept their own copy of into a singlesrc/codegen/schema_query.zig. The visible auth fields are now derived from the canonicalschema.authSystemFields()(filtered to its non-hidden subset) rather than a hand-maintained triple in each emitter, so a new non-hidden auth system field flows into every generated SDK automatically instead of silently diverging until all four copies are edited. Pure refactor — generated client bytes are unchanged. Also corrected the Dart/Python/Kotlin generator module docs, which claimed the shared identifier guard is “language-neutral”: it is TS-derived (TS identifier validity + the TS typed-core reserved-name set) and applied to every language as a conservative lowest common denominator, with the actual per-language keyword/member sanitizing living in each emitter. records.last_errors(the validation-detail threadlocal) is cleared once consumed, so it never outlives the per-request arena it points into.gcExpiredRecords(the TTL sweep) allocates its per-collection scratch from an internal arena, and system migration 0010 allocates its collection-name scratch from the run-scoped migrator arena instead ofstd.heap.page_allocator— restoringstd.testing.allocatorleak visibility for both paths and removing ~15 lines of manual cleanup from the latter.
[0.11.0] - 2026-07-07
Breaking
- The
onBootstrap/onBeforeServe/onBeforeTerminatelifecycle hooks now returnanyerror!void(wasvoid) — update existing hook signatures (afn (...) voidno longer coerces). A returned error fromonBootstrap/onBeforeServefails the boot; anonBeforeTerminateerror is logged (it fires in a shutdown defer).
Features
- Admin UI:
editorfields now use a rich-text WYSIWYG editor (bold, italic, headings, lists, links, blockquote, inline code) that stores sanitized HTML, andjsonfields use a code editor with live validation, a Format button, and Save disabled while the JSON is invalid. - App-scoped context: declare a context type at comptime with
App(.{ .app_context = T }), install it once inonBootstrapviactx.setAppData(T, &value), and read it anywhere (handler/hook/job/cron) as a*Twithctx.appData(T)— one explicit, typed handle replacing module-level globals + bootstrap setter rituals. Declaring.app_contextmakes setting it a boot contract (the server refuses to start ifonBootstrapnever installs the handle); apps that don’t declare it pay nothing. - Route-level auth-collection gating:
.auth = .{ .authed = "<collection>" }requires a route’s principal to belong to a specific auth collection (with an optional.allow_superuser = trueto additionally admit superusers). The gate is fail-closed — a token from any other collection, a superuser without opt-in, or an empty-id principal is rejected with the same401as no token at all (no oracle) — and comptime-validated: the named collection must be declared in.collectionsand be of.type = .auth, else the build fails. Plain.authedstill accepts any authenticated principal. - New
App(.{ .collections_frozen = true })config key asserts that collections do not change after boot + migrations. Frozen apps get the parsed-collection-metadata cache on every backend — including Postgres, where it is otherwise skipped because a concurrent instance couldALTERcollections unseen — and the runtime collection create/update/delete endpoints return403(schema then evolves via.migrations+ a redeploy). Defaultfalseleaves today’s behavior unchanged (cache SQLite-only, DDL endpoints live). - Cron expressions now accept case-insensitive 3-letter month (
JAN..DEC) and day-of-week (SUN..SAT) names in the month and day-of-week fields (e.g."0 9 * * MON-FRI"), in addition to numbers. Steps (*/n) remain numeric. - Pluggable error reporter: the terminal backstop every framework-swallowed error routes through is now a swappable plugin selected via
App(.{ .reporter = MyReporterPlugin }), mirroring.storage/.mailer. The default picksSentryReporterwhenZIGBASE_SENTRY_DSNis set (POSTs a Sentry envelope) andLogReporterotherwise (a structured backstop line[phase] err_name: message); a custom plugin implementscreate/interface/deinitand returns aReporterwhosereportreceives aReport{ .message, .err_name, .phase, .level }(theReporter,Report,LogReporter,SentryReporter, andDefaultReporterPlugintypes are re-exported). Consumers route their own swallowed-but-notable errors through the SAME backstop withctx.reportError(err, "fmt", .{args})— theonErrorhandler then the reporter — tagged with the new.apperror phase; it is best-effort and non-failing (never blocks or fails the caller, swallows its own allocation failure) and works from a route handler, hook, job, or cron. - Error reports deliver non-blocking with TTL dedup: the Sentry POST is enqueued on the in-process memory queue and performed on a pool worker — never inline on the thread that swallowed the error and never on the DB writer, so reporting never blocks a request/job/cron path (a failed POST is logged and dropped, never retried into a loop). A repeat of the same
(message, phase)withinApp(.{ .reporter_dedup = .{ .window_s = 60 } })(the default) is suppressed so a hot error path reports once per window instead of flooding Sentry;.reporter_dedup = .offreports every swallowed error and compiles the dedup map out entirely. - Record read endpoints (
GETlist and get-one) accept afields=query param for response projection: a comma-separated list of dot-paths selects which keys are returned (e.g.fields=id,title,expand.author.name), with*for all keys at a level and a leading-to exclude. Projection descends intoexpanded relations (objects and arrays) and is a pure output filter applied after expand and access rules — it can only narrow a response, never reveal a field the record wouldn’t otherwise return. - Programmatic list filters accept bound placeholder values: put
?tokens infilterand pass a parallelfilter_argsslice (ctx.records().list(...)). Each?binds its value (.string/.int/.float/.bool/.null) as a literal SQL parameter that is never re-parsed as filter grammar — the injection-safe way to splice a runtime value into a filter. A placeholder is coerced by the target field’s type exactly as an inline literal would be (soprice = ?with.{ .float = 5.0 }matches the same rows asprice = 5.00). Placeholders bind 0-based left-to-right; a placeholder-count vs.filter_args.lenmismatch is a louderror.BadFilter(so a stray?on the REST?filter=path fails closed). - Mail:
ctx.mail()messages can now carry file attachments (#219) — setMailMessage.attachmentsto a slice of{ filename, content_type, data }(the canonical use is a.icscalendar invite). The message body is wrapped inmultipart/mixedwith one base64 part per attachment; the default (&.{}) leaves existing mail byte-for-byte unchanged. Attachments ride through every backend (SMTP/Command get the raw MIME, SES switches to Raw MIME, Postmark uses its nativeAttachmentsarray) and survive the durable queue round-trip.filename/content_typeare CRLF/control-char checked, and a new.mail.max_message_bytescap (default 10 MiB) rejects an over-sizedsend/enqueueat the call site witherror.MailTooLarge. Onlycid:inline images remain unsupported. zigbase migrate dump [--out <file>]introspects the live database and writes a canonical, dialect-nativestructure.sql(stdout by default;--outwrites a file). SQLite emits the exact stored DDL; Postgres reconstructs it from the system catalogs — no externalpg_dump. The output is deterministic (no timestamps) so it diffs cleanly and re-runs to recreate the schema for a fast test DB; it also emits the applied-migration ledger so a restore lands at the same migration state. It is a snapshot for inspection/diffing/test-setup, NOT a schema source (that is.collections), and is never loaded at boot. This completes themigrateCLI trio alongsidestatusandrollback.zigbase migrate statusreports your comptime.migrationsas applied (with the ledger timestamp) or pending in declared order, and separately flags orphaned ledger rows — applied migrations no longer present in the binary — with a conciseN applied, M pending, K orphanedsummary. It reads the_migrationsledger only and applies nothing.zigbase migrate rollback [N]reverses the N most-recently-applied consumer migrations, newest first (N is a positional integer, default 1); system migrations are never touched. The reverse of a migration isdown orelse change(the mirror of the forwardchange orelse up): an explicitdownruns as-is, otherwise thechangere-runs inverted. Each migration’s reverse body and its ledger-row delete commit in one transaction (honoring.transactional), so re-applying afterward works. It fails loudly and changes nothing it cannot undo: a lone-upmigration, a non-transactionalchange, or an orphaned ledger row is refused; achangethat reverses into an irreversible op (raw/records()/a.was-less drop, oraddForeignKeyon SQLite) is rolled back by its transaction and named.Nbeyond the applied count rolls back all of them.- Migrations gain a dialect-aware schema DSL (
m.createTable/addColumn/addIndex/renameColumn/addForeignKey, …) and auto-reversiblechangemigrations: write the forward change once and it inverts for rollback.up/downremain for irreversible steps; a per-statementm.raw(.{ .sqlite, .postgres })breakout and records-awarem.records()data transforms (#241) round it out. Migrations stay transactional by default with a per-migration.transactional = falseopt-out. (A schema dump lands next.) data.queryAs(T, conn, alloc, sql, args)(and thectx.records().queryAs(T, sql, args)wrapper) decode raw-SQL result rows into a structTby matching each field to the result column of the same name (respectingASaliases) instead of by position — so a reordered or newly-insertedSELECTcolumn can no longer silently misalign a hand-writtencolumnText(n)mapping. Args bind positionally (?1..?N, rewritten to$non Postgres); fields decode by Zig type ([]const u8, integers, floats,bool, and?Tover nullable columns, with SQLNULL→null); extra result columns are ignored; a non-optional field with no matching column errors witherror.ColumnNotFound. Works on both the SQLite and Postgres backends.- Optional S3 presigned-URL serving: with the comptime
App(.{ .files = .{ .s3_presign_redirect = true } })option, authorized file downloads on the S3 backend are served as a 302 redirect to a time-limited presigned GET URL (s3_presign_ttl_s, default 900s) instead of proxying the bytes through the server — offloading bandwidth/CPU. Default is unchanged (proxy). Authorization still runs per-request before the redirect; the issued URL is a bearer capability valid until it expires. ctx.sms()— transactional SMS, the outbound-text analog ofctx.mail().ctx.sms().send(.{ .to, .body })delivers synchronously andctx.sms().enqueue(...)rides the background queue for durable retry/backoff. Provider is pluggable behind anSmsSendervtable (Twilio first, viaApp(.{ .sms_provider = … })or theZIGBASE_TWILIO_ACCOUNT_SID/_AUTH_TOKEN/_FROMenv vars); unconfigured it is a network-free logging no-op, so dev/CI need no credentials. Framework-owned E.164 normalization/rejection runs before any byte reaches a provider or a queue row (.sms = .{ .default_region = .us }sets the country code prefixed onto national numbers). Ships aCaptureSmsin-memory test double for asserting sent messages with no network.- Static file serving now percent-decodes the request path, so files whose names need encoding (e.g.
my%20file.pdf) are servable. Decoding is single-pass and happens before the traversal checks, so encoded traversal (%2e%2e,%2f,%00,%5c) is decoded and then rejected fail-closed, and double-encoding is never recursively decoded; the symlink guard is unchanged. - In-process test harness (
zigbase.testing): boot a comptime-configuredApp(.{...})against a throwaway tempdir data dir and inject requests through the REAL pipeline — the same router, access rules, auth, hooks, and custom routes the socket server runs — with no socket, port, or background threads.testing.start(App, .{})runs migrations +onBootstrap;t.request(method, path, .{ .json = .{...}, .auth = bearer })returns a genuine response you assert on and parse viar.json(T). Auth helpers cover both fidelities:mintSession(direct deterministic JWT) andloginPassword/loginSuperuser(the real auth-with-password endpoint);createSuperuser/createRecordseed rows andcaptureMailswaps in an in-memory mailer to assert outbound mail. See docs/framework.md §15. - The TTL garbage-collection sweep cadence is now configurable via the comptime
.ttl_gc_intervalApp config key (aschedule.Interval, default.{ .minutes = 5 }); expired rows are still hidden from reads immediately regardless of sweep cadence. ctx.txWith(T, payload, fn)(#237) — actx.txcompanion that threads a caller-supplied payload directly into the transaction callback, so a route/hook/job that needs request data inside a transaction no longer has to smuggle it through athreadlocalglobal.- Typed record I/O on the records handle:
ctx.records().createAs(T, col, .{…}),getAs(T, col, id), andupdateAs(T, col, id, .{…})reflect a plain Zig struct into a write and parse the resulting record back intoT— no more hand-assemblingObjectMaps or unwrapping union tags. Struct fields map to schema fields by name, optionals map to nullable columns, and every literal field is comptime-verified to exist onT(a typo is a build error). Thestd.json.ValueAPI stays for dynamic callers. - Web Push notifications (
ctx.push(), #223). Send browser push notifications with RFC 8291 (aes128gcm) payload encryption and RFC 8292 VAPID authentication.ctx.push().send(subscription, message)returns a tri-state (.delivered/.gone/.failed) so a dead subscription (HTTP 404/410) is pruned and never retried;ctx.push().enqueue(...)delivers durably in the background via the built-in"push"job kind (registered when.pushis configured). Enable withApp(.{ .push = .{ .subject = "mailto:ops@example.com" } })plus a VAPID keypair inZIGBASE_VAPID_PUBLIC_KEY/ZIGBASE_VAPID_PRIVATE_KEY; without the keysctx.push()is a network-free logging no-op. New CLI subcommandzigbase vapid-keygengenerates a keypair.
Fixes
- The standalone
WriterData/ReaderDataDB-access handles (ev.writer()/ev.reader()) no longer leak on the process allocator: theirdata()accessor now allocates on an arena OWNED BY THE HANDLE, so a record op’s collection metadata, SQL scratch, and returned records are all freed together when the handle’sdeinit()runs. Results are valid untildeinit(). (ctx.records()was never affected — it already uses the per-request arena.) - Setting
.auth.session.gc_cronwithout.auth.session.store = .tableis now the compile error it was always meant to be. The guard lived in a lazy comptime value referenced only by the.table-mode session-GC job, so in the misuse case (.epochstore) it was never analyzed and the misconfiguration silently compiled and did nothing; it now fails loudly at build time. .intand.fixed-mode.numberfields now accept a JSON number on write, not only a string — symmetric with reads, which return a string.price_cents = 500andprice = 5.0(scaled to afixedfield) bind correctly instead of failing validation; a fractional float on an.intfield is still rejected.
Changed
zigbase migratenow applies the app’s comptime.migrations(the consumer escape-hatch migrations) after the system migrations, so migrating from the CLI ahead of a deploy applies the same migration pass the server would. Previouslymigrateapplied only the built-in system migrations. (Collection tables from.collectionsare still provisioned when the server starts, not bymigrate.) It remains idempotent (already-applied migrations are skipped via the_migrationsledger).- The S3 spool cache now bumps an entry’s mtime on a cache hit, so size-triggered eviction approximates last-access LRU (a frequently-read file survives over a rarely-read newer one) instead of being purely create-time ordered.
-Ds3builds only.
Performance
- Feature-state resolution (
ctx.flags().resolveAll/ the public/api/stateprojection) now reads every sticky experiment’s persisted assignment in a single batched query, so a resolve is a constant 2 queries regardless of how many.stickyexperiments an app declares (previously 1 + N — one assignment read per sticky experiment). Variants and miss-persist behavior are byte-identical; the single-accessorApp.experimentpath is unchanged. - Steady-state feature-flag and experiment resolution now costs zero
_kvreads: an in-process cache serves the currentflag:*/exp:*:weightsoverride set to bothctx.flags().resolveAlland the per-flag/App.flag/App.experimentlookups. A same-instance override write (App.setFlag, the admin settings verbs) invalidates it instantly, so a kill-switch flip still takes effect on the next request; on Postgres, another instance’s write self-heals within a 5 s staleness bound (so it runs on both backends, unlike the SQLite-only collection cache). - Custom-route dispatch now skips authentication resolution — and its pooled reader acquire — on credential-less requests (no bearer header and no
zb_authcookie).authenticatealready returns null in that case, so the reader round-trip was pure overhead on the highest-volume shape most apps serve (anonymous traffic on public routes); hoisting the credential check above the acquire lowers the per-request floor and cuts reader-pool contention for the requests that actually need a connection. Semantics are unchanged —.authed/.superuserroutes without credentials still 401/403.
[0.10.0] - 2026-07-04
Breaking
- Auth configuration is now grouped under one comptime
App(.{ .auth = .{ … } })key. The previously-scattered top-level auth keys moved under it:.auth = .{ .beforeRegister = fn, … }(the flat lifecycle-hook group) →.auth = .{ .hooks = .{ .beforeRegister = fn, … } }.auth_methods = .{ … }→.auth = .{ .methods = .{ … } }(both the bare-tuple and.{ .builtins, .custom }forms).captcha = .{ .provider, .secret }→.auth = .{ .captcha = .{ … } }.session_store = .epoch | .table→.auth = .{ .session = .{ .store = … } }.session_gc_cron = "…"→.auth = .{ .session = .{ .gc_cron = "…" } }Each old spelling is now a pointed@compileErrornaming its new location, so consumers get an actionable migration message rather than a silent no-op. Runtime auth knobs (ZIGBASE_AUTH_TOKEN_TTL,ZIGBASE_OAUTH_STATE_*, cookie security,ZIGBASE_RATE_LIMIT_*) intentionally remain env-configured and are not part of the.authgroup.
beforeAuthSuccessnow fires on the legacyPOST …/auth-with-passwordandPOST …/auth-refreshroutes — including_superusers(the admin SPA login). A hook that errors unconditionally will lock superusers out of the admin UI (fail closed, by design); fix the hook and rebuild.events.AuthMethodgained a.refreshvariant; exhaustiveswitches over the enum must add an arm (compile error).- Custom-route surface:
http.Response.file_pathis nowResponse.file(.file_path = p→.file = .{ .path = p }). Plain-path delegation behavior is unchanged; the new optionaloffset/lenwindow enables handler-planned partial responses. - Postgres backend (
-Dpostgresbuilds): the defaultsslmodeforpostgres://URLs is nowverify-full(the server certificate chain and hostname are verified — see the TLS entry under Security). A server without TLS (e.g. a docker-compose dev database) now fails at startup with an error naming the one-parameter fix: append?sslmode=disable(plaintext) or?sslmode=require(encrypted, unverified) toZIGBASE_DB_URL. Explicitly configured modes belowverify-fullkeep working and log one startup warning. - Side-effect auth successes are now uniform 204 No Content:
confirm-verification(was{"verified":true}),confirm-password-reset(was{"success":true}),webauthn/register/finish(was{"registered":true}). Treat any 2xx as success;@zigbase/clienttypes updated toPromise<void>. - The magic-link consume URL is now dash-case:
GET …/auth/magic-link/consume(wasauth/magic_link/consume). Hard cutover — links emailed by pre-upgrade servers 404 (tokens are short-lived). The method slug (/auth/magic_link/initiate|complete,onAuthtag) is unchanged. - The built-in job kinds are now config-gated (embedded consumers):
ctx.webhookrequires.webhooks = true;ctx.mail().enqueuerequires.mail(use.mail = .{}for defaults) or a.mailerplugin. Without the key the kind is not compiled in and enqueue fails loudly with a hint. Direct mailer delivery (verification/password-reset emails) is unaffected. The kind namesmail/webhookremain reserved either way. - Removed the legacy
.jobs = .{ .pool_size = N }spelling; set.pools = .{ .jobs = N }. The old key is now a pointed compile error (N1). RecordEvent.ctxis nowRecordEvent.rctx(ctxalways means*Ctxin a hook signature). Mechanical migration:ev.ctx.→ev.rctx..RecordEvent.appwas removed — it put the UB footgun (ev.app.allocatorvsev.arena) one dot from every hook. Use the hook’sctx.app; allocate record data withev.arena. (JobEvent.app/ErrorEvent.appare unchanged.)RouteEventwas deleted. It was never passed to a live route (handlers take*Ctx); it existed only in tests. Events carry data;ctxcarries capabilities.GET /api/collectionsandGET /api/settingsnow return{"items":[…]}instead of a bare JSON array (superuser endpoints; admin SPA + typegen updated).zigbase typegen --urlrequires a server from this release.GET /api/collections/:col/auth/oauth2/providersreturns{"items":[…]}(was{"providers":[…]});@zigbase/client’slistAuthProviderstypes updated.zigbase.Serveris now a genericpub fn Server(comptime gates: Gates) typeinstead of a concrete struct — the built-in route table is assembled per-app fromGates(R2-3). Framework consumers reach it exclusively throughApp(cfg).runCli/serve, which thread the newgatesconfig automatically; only code that namedzigbase.Serverdirectly (bypassingApp) needs an update, e.g.server.Server(.{})for the historical all-on table.- Storage plugin vtable:
localPath(ctx, alloc, col, record_id, filename)is nowfetch(ctx, io, alloc, col, record_id, filename)— return a local filesystem path whose contents are the file, materializing it locally if necessary;null= the backend has no such object. Local-disk backends migrate mechanically (rename + theioparameter). GET /api/sendersnow returns{"items":[…]}instead of a bare JSON array (unified with the analytics endpoints’ envelope).- The
__featuresrealtime channel now emits the standard{"type":"signal","topic":"__features"}frame instead of the bespoke{"type":"features.changed"}frame.
Features
- Admin UI: an Email view — manage verified sender identities (list / invite / delete), the suppression list (add / remove / filter by reason, incl. one-click-unsubscribe entries), and read-only bulk-send batch progress, with a read-only mail-policy strip. Backed by the existing mail APIs plus a new superuser
GET /api/mail/config(booleans only, no secrets). - Admin UI: a Files view — browse per-collection file fields with image previews, upload/replace files, and remove them, plus a read-only storage-backend strip (local disk vs S3). Backed by the existing records + file-serve APIs plus a new superuser
GET /api/files/config(non-secret backend info only — never the S3 credentials). - Admin UI: a Logs & realtime view — browse app analytics events with name/actor/since filters and cursor pagination, view an app-declared rollup’s aggregated series, and a read-only realtime health strip (live connection count + caps). Backed by the existing analytics APIs plus a new superuser
GET /api/realtime/stats. The Logs tab is capability-gated: it only appears when the app enables.analytics(the stockzigbase servebinary doesn’t, so the tab is hidden there). - Admin UI: a Users view for managing superusers and auth-collection users — list, search, create/edit/delete, admin password reset, and a read-only OAuth-providers panel. The admin SPA is now split into browser-native ES modules (no build step) and every asset is served with a CRC32
ETag. - Self-service password change via
PATCH /api/collections/:col/records/:id: non-superusers must include a verifyingoldPassword— a non-oracle check (wrong/missing values, unknown records, and passwordless targets all return the login-identical400 "Invalid credentials."with argon2 timing padding), rate-limited under a new"pwchange"scope before any argon2 work runs. On success every other session for the record is invalidated (tokenKey rotation, plus_sessionspurge in table mode) while a self-change keeps the calling device signed in via freshSet-Cookieheaders. ThebeforePasswordChange/afterPasswordChangelifecycle hooks now fire on this path too.@zigbase/clientgainscollection(col).changePassword(id, oldPassword, newPassword)(transparent re-auth in token mode). - Official multi-arch Docker image,
ghcr.io/valthon/zigbase— built from the existing static-musl release binaries (no in-image compilation),distroless/staticbase, non-root by default. The supported deployment path for Windows-hardware users, since ZigBase has no native Windows build. Seedocs/docker.md. migrate-dbnow fully supports circular relations (self-relations and mutual/N-node cycles) end-to-end, not just provisioning: cycle-edge foreign keys are omitted from the initialCREATE TABLEand added back asDEFERRABLE INITIALLY IMMEDIATEconstraints (Postgres cannot create tables with circular inlineREFERENCESin any order), and the load transaction defers those constraints toCOMMIT(SET CONSTRAINTS ALL DEFERREDon Postgres,PRAGMA defer_foreign_keys=ONon SQLite) so rows load in any order regardless of reference direction. Previously this schema shape failed outright during provisioning; SQLite targets were always cycle-capable (inline FK DDL tolerates cycles) but are now verified round-trip end to end. A dataset with a genuinely dangling reference fails clearly atCOMMIT, naming the affected collections, and rolls the whole load back.- Bulk list sends:
ctx.mail().sendBulk(...)fans one templated message out as per-recipient-rendered emails over the durable queue, with submit-time validation/dedup, per-recipient suppression checks, idempotent redelivery, and a durable send-report (_mail_batches/_mail_batch_recipients, readable as superuser via the records API) plusbatchStatus/cancelBatch. - Scheduled sends:
ctx.mail().deliverAt(msg, .{ .at | .delay_s })returns a cancellable job id,ctx.mail().cancel(id)calls a pending send off, andsendBulkaccepts.at— the documented drip-sequence primitives. - One-click unsubscribe (RFC 8058): configure
.mail.unsubscribe_base_url(orZIGBASE_UNSUBSCRIBE_BASE_URL) and bulk mail automatically carriesList-Unsubscribe/List-Unsubscribe-Postheaders pointing at the new signed publicPOST/GET /api/mail/unsubscribeendpoint; one-click opt-outs are recorded asunsubscribesuppressions that block list mail only (transactional mail is unaffected). - Per-queue rate throttling: durable queues accept
.rate = .{ .per_second = N }— a token-bucket ceiling enforced at claim time (e.g. match SES’s 14 msg/s). ctx.mail()warns when an HTML body exceeds ~100 KB (Gmail clipping threshold).- Record-file downloads (
GET /api/files/:col/:rec/:name) support HTTP Range and conditional requests:206withContent-Rangeforbytes=a-b/bytes=a-/bytes=-n,Accept-Ranges: bytes, a strong content-immutableETagwith304revalidation,If-Range,416for unsatisfiable ranges, andHEADparity. - Generic OIDC discovery for OAuth providers: set
.discoveryURL = "https://…/.well-known/openid-configuration"on a provider (mutually exclusive with explicit endpoint URLs) and the endpoints are resolved once at startup — https-only, issuer-checked, and fail-fast (a failed discovery refuses to start). Covers Auth0/Okta/Keycloak/Entra-custom-tenant/Zitadel-class IdPs with one config line; scopes default toopenid email profilewith the standard OIDC claim mapping. .migrationsaccepts a bare tuple (.migrations = .{ .{ .id = "...", .up = f } }) like every other list-shaped config key; the typed-slice form still works (E1).- New
-Dfts5build flag (default on): lean custom builds can drop SQLite’s FTS5 (~250-400 KB). With-Dfts5=false,?search=answers 400 and a.searchableSQLite schema refuses at startup. Default builds are unchanged; Postgres full-text search is independent of the flag. - New comptime
.admin = .disabledkey: headless/embedded consumers can drop the admin SPA (dispatch + ~58 KiB embedded assets) from their binary. Default unchanged — the admin UI serves at/_/. .auth.methods(the app-level auth-method registry) gains an exact-set form:.{ .builtins = .{ .password, .otp }, .custom = .{ MyMethod } }. Deselected built-ins (WebAuthn’s CBOR/COSE stack, magic-link, OAuth2, OTP) are excluded from the binary together with their routes. Absent key / bare-tuple form keep today’s all-five behavior — non-breaking.- Cross-instance custom-topic realtime on Postgres (#188):
ctx.realtime().signal(topic)/ctx.realtime().broadcast(topic, payload)and the__featuresflag/experiment signal now fan out across every app instance sharing one Postgres database (best-effort, at-most-once, unordered), not just the emitting process. No app data ever rides theLISTEN/NOTIFYwire: signals carry only the topic name, and message broadcasts store the enveloped frame in a new_rt_broadcastsside table keyed by a random CSPRNG token (TTL-GC’d), NOTIFYing only the token — the receiving instance reads the frame back over its own connection and re-delivers it through the same per-subscriber authorization chokepoint. A forged or expired token finds no row and is dropped (fail closed). Thectx.realtime()public API is unchanged; on SQLite (single-process) behavior is byte-identical. - Opt-in S3-compatible storage backend (
-Ds3build flag; AWS S3, MinIO, Cloudflare R2), selected by configuration alone — setZIGBASE_S3_*env vars on an-Ds3binary, no code change. Downloads are served through a local spool cache, so Range/ETag/tenancy behavior is byte-identical to local storage. A stock binary withZIGBASE_S3_BUCKETset warns loudly and falls back to local storage. Startup runs a fail-fast HeadObject probe (DNS/TLS/SigV4/bucket/permissions verified before serving). @zigbase/client0.3.0: full-textsearch+ structuredvectorqueries (vectorSpec) on list reads, with per-collection compile-time gating in the generated tiers.@zigbase/client0.3.0: multi-tenant account scoping —accountIdoption,client.withAccount(id)scoped views (shared auth store), andaccounts.activate(id).@zigbase/client0.3.0: per-record abilities —getAbilities(id)on the base and every generated collection service.@zigbase/client0.3.0: analytics read APIs —client.analytics.events(...)andclient.analytics.rollup(name, ...).@zigbase/client0.3.0: verified sender management —client.senders.list/create/verify(list requires ZigBase >= 0.10.0).@zigbase/client0.3.0: realtime custom topics —subscribeTopic/unsubscribeTopicdeliversignalandmessageframes (feature-change notifications aresubscribeTopic("__features", cb)).- Generated TS clients surface
searchable/tenantschema metadata: typedsearch/vectoroptions, per-collection sort unions (sort: "-age" | [...]), tenant fields omitted from*Create/*Update, andaccounts/analytics/senders/withAccounton the generated client. - Per-device session REST + SDK for
.auth.session.store = .table:GET /api/collections/:col/auth/sessions({"items":[…]}, newest first,is_currentmarked),DELETE …/auth/sessions/:sid(204; non-owned/absent ids are an indistinguishable404), andDELETE …/auth/sessions(“log out everywhere”, works in both session-store modes, clears the session cookies). In the default.epochmode the per-device routes answer404.@zigbase/clientgainslistSessions(),revokeSession(id),revokeAllSessions()and theSessionInfotype. Thesessionsauth-method slug is now reserved. - SPA fallback routing (#183): a presence-only
.spamarker file makes its static directory an SPA root — GET/HEAD misses at or below it serve that directory’sindex.html(200), so client-routed apps survive deep links and hard refreshes. Works for both--serve-static/.dirtrees and embedded manifests; real files,/api(including via normalized/double-slash paths), admin, and custom routes always win. In dir mode the marker is resolved live against the filesystem on every miss — adding, removing, or editing a.spa/index.htmltakes effect on the next request, no restart needed; startup only fails fast (with a clear, path-naming error) when a.spa-marked directory has noindex.html, and an unreadable subdirectory is skipped with a warning rather than aborting boot. Embedded manifests keep a startup-derived, comptime-static marker set (there’s no live filesystem to go stale). The fallback shell is servedCache-Control: no-cachewith a revalidation ETag so a redeploy never strands deep links on a stale cached shell, and a file literally named.spadenotes this marker (ASCII case-insensitive) rather than being served. - Comptime
static_routesfor custom builds (#183): declarematch → serverewrites onApp(.{ .static_routes = &.{...} })with minimal segment matching (:nameone segment,*one-or-more rest,**zero-or-more rest; first match wins). Patterns and embedded serve targets are validated at compile time; dir targets at startup. A newenable_spa_markerkey gates the marker (default: on without routes, off with routes). - Realtime over Server-Sent Events (#188):
GET /api/realtime/sse(EventSource-compatible — no SDK required) +POST /api/realtime/sse/:clientIduplink speaking the same verb grammar as WebSocket. Same frames, same per-record delivery authorization, same Origin policy, same shared connection cap. New--sse-heartbeat-seconds/ZIGBASE_SSE_HEARTBEAT_SECONDSknob for the: pingheartbeat interval. - Tunable Cache-Control for static file serving:
App(.{ .static_cache_control = "…" })sets a comptime default, and--static-cache-control <value>/ZIGBASE_STATIC_CACHE_CONTROLoverride it at runtime (flag wins over env, both win over the comptime default). Applies only to static serving (dir/embedded/--serve-static) — record-file downloads keep their authorization-derived Cache-Control unchanged. Unset (the default) is byte-identical to today’s stockmax-age=3600. The value must be non-empty, CR/LF-free, and at most 256 bytes; an invalid value fails startup with a clear error instead of silently clamping or ignoring it. - Static file serving now supports HTTP Range:
bytes=X-(video seek),bytes=-n, and overlong ranges return correct206responses, unsatisfiable ranges416(previously these fell through to a full200or worse), and embedded static assets gain single-range206. A ranged dir-mode request with a matchingIf-Rangenow resumes with206instead of restarting as a full200: zigbase neutralizes an invertedIf-Rangebranch in the vendored facil.io that deleted theRangeheader on a match (RFC 9110 §13.1.5), so interrupted downloads resume instead of re-downloading from scratch. Owned record-file (/api/files/…) and embedded serving were already RFC-correct here. (#192)
Fixes
onAuthonPOST …/auth-refreshnow reports.refreshinstead of the mislabeled.password.migrate-dbonto a non-superuser Postgres target (the common case for managed Postgres like RDS/Cloud SQL) no longer silently corrupts the load: the best-effortSET session_replication_role = replicaFK-suspension attempt, when rejected for lack of privilege, was leaving the load transaction itself in Postgres’s aborted state — so every subsequent statement in the load failed, regardless of whether the schema had any cycles at all. The attempt is now wrapped in aSAVEPOINTso a rejected privilege check no longer poisons the load._suppressionsgained theupdatedcolumn the records engine’s base-column SELECT requires, so superusers can actually browse it via the records API (migration0019_bulk_mail).- Record-file downloads no longer emit a duplicate
Cache-Controlheader (the handler’s per-collection value used to be joined on the wire by facil.io’s globalmax-age=3600). - Shipped-binary size: fixed a code-gen accident in the bundled regex engine (
Builderwas materialized as a ~3 MB all-zero.rodatatemplate copied at runtime on everycompile) — the default ReleaseSafe binary shrinks ~40%, from ~7.6 MB to ~4.6 MB, with identical behavior. app.submittasks and memory-queue jobs are now drained and joined at shutdown (a task submitted before shutdown completes instead of being cut off), andapp.submitworks whenever the server is running — a configured scheduler is no longer required.- Postgres SCRAM authentication now applies RFC 4013 SASLprep to passwords: soft hyphens are stripped and non-ASCII spaces map to space before PBKDF2, prohibited/bidi-invalid and non-UTF-8 passwords keep PostgreSQL’s own use-verbatim parity, and a password that would require NFKC normalization fails loudly at connect with a message naming the fix (previously: verbatim bytes and a mysterious
password authentication failed). Printable-ASCII passwords are byte-identical fast-path (zero allocation). - Postgres backend (
-Dpostgresbuilds): apostgres://URL whose host is a DNS name (e.g.localhost,db.internal) now resolves through the OS resolver (/etc/hosts+resolv.conf) instead of failing to connect — previously only IP-literal hosts (127.0.0.1,::1) worked, soverify-fullagainst a hostname could never complete its handshake. - The
App(.{…})config-key table in docs/framework.md claimed to be exhaustive while omitting 9 keys (captcha,tenancy,abilities,mail,analytics,static_routes,enable_spa_marker,onFeatureExposure,features); it is now complete, states each key’s binary-size contract (“unset ⇒ excluded/data-only/always”), and documents the config-plane assignment rule + laziness contract. ZIGBASE_DB_URL(the SQLite-vs-Postgres selector),ZIGBASE_PUBLIC_URL(magic-link URL base), andZIGBASE_SENDMAIL_COMMANDare now documented in the README env table andzigbase help— they were previously undiscoverable.- Field-encryption (
ZIGBASE_FIELD_KEY,ZIGBASE_FIELD_KEY_GENERATION,ZIGBASE_FIELD_KEY_V<n>) env vars are now in the README env table (previously only inzigbase help). OAuth (ZIGBASE_OAUTH_STATE_SERVER/_STATE_TTL), rate-limit (ZIGBASE_RATE_LIMIT_MAX/_WINDOW), and SMTP (ZIGBASE_SMTP_*) env vars are now inzigbase help(previously only in the README). - README documented the
ZIGBASE_OAUTH_STATE_SERVERdefault backwards (false); the server-side OAuth state store has defaulted on since it shipped. The env table now matches the code (set=falseto opt out). - Outbound HTTP client (
http_client.zig, shared by S3, webhooks, OAuth2, and CAPTCHA verification): a response DEFINED to carry no body (aHEADresponse, any1xx,204 No Content, or304 Not Modified) was still read as if it might have one, using whateverContent-Lengthit happened to arrive with — or, absent that, “read until the connection closes.” Real S3 servers don’t close keep-alive connections, so every S3DELETE(always204, noContent-Length) and everyHEADon an existing key blocked for ~30 seconds (an unrelated idle-connection timeout eventually unblocking it) before this was caught by the new live MinIO tests. .analytics.rollupsin theAppconfig could never compile — a job-wrapper signature mismatch made the option dead-on-arrival since it was introduced.- The TypeScript code generator emitted an orphan
Expandtype key (breakingtsc) for relations that target a collection outside the generated set; it now emitsneverfor those relations instead. @zigbase/clientrealtime: concurrentsubscribe/subscribeTopiccalls for the same topic while the socket is open no longer send duplicate subscribe frames — later callers join the pending ack instead.- Embedded static assets now send a
Cache-Controlheader (previously none — revalidation still works via the unchanged CRC32 ETag). .gzsidecar responses now carryVary: Accept-Encoding(shared-cache correctness).
Changed
- Stale docs corrected: Postgres backend status in configuration, README backend description, tenancy example harmonization.
Email/MailMessagegained an additivelist_unsubscribefield (defaultnull; CRLF-checked like every header field) emitted as RFC 8058 headers by all backends (SMTP/Command/SES/Postmark).durable.enqueuenow returns the generated job id, and the queue GC reapscanceledjobs (internal signature change, pre-1.0).CaptureMailerrecordsreply_to/list_unsubscribeand gainedall()/countTo()accessors.- The release binary no longer ships the demo feature flags/experiment (
dark_mode,maintenance,onboarding_flow) — they were Playwright fixtures riding in production.GET /api/featureson a stock binary is now empty until you declare your own. GET /api/analytics/eventsadopts the house cursor pagination:?cursor=request param andnextCursor/hasNextresponse keys (additive;limitcap 200 unchanged).- Built-in routes are now comptime-assembled from your
App(.{…})config: analytics, senders, the inbound mail webhook, one-click unsubscribe, andaccounts/:id/activateare registered (and compiled) only when.analytics,.mail, or.tenancyis configured. Previously these routes always existed and answered 404/fail-closed when unconfigured; now they 404 as unknown routes. The standalonezigbase servebinary opts into.mail = .{}, so its mail routes (verified senders, the inbound webhook, RFC 8058 unsubscribe) stay registered and behave exactly as before; only the still-unconfigured analytics and tenancy routes now 404 uniformly. - The typed where-DSL
inoperator now compiles to the nativefield in (…)filter operator (requires ZigBase >= 0.9.0; against older servers it is a 400). - Clients regenerated by this release require
@zigbase/client>= 0.3.0 (enforced by aCoreSupports_0_3marker type with a self-explaining typecheck error). - The docs site gained dedicated feature guides for the 0.9.0 features (PostgreSQL, tenancy, abilities, search, analytics, email, jobs & webhooks, realtime broadcast), a CAPTCHA recipe, a refreshed landing page, and a competitor comparison page.
Performance
- Collection-metadata cache:
invalidate()no longer allocates while holding the cache spinlock. Detached entries are threaded onto an intrusive list and their arenas/keys are freed only after the lock is released, removing an alloc-under-spinlock latency/contention hazard (and any re-entrant-allocator deadlock risk) on the DDL path. - Memory-backend queues no longer spawn one detached OS thread (with a 1 MiB stack) per enqueued job: jobs run on a small fixed worker pool with a bounded ring. Overflow returns
error.QueueFullinstead of unbounded thread creation, so enqueue bursts can no longer exhaust threads or address space. - Realtime delete fan-out: the per-subscriber authorization sandbox for delete events now creates only the tables it needs (2 statements) instead of running the full ~28-table migration suite once per subscriber per delete — removing the worst per-event fan-out cost on the shared HTTP threads.
- Collection metadata (the parsed schema consulted by every record API request and every realtime delivery) is now served from a versioned in-process cache invalidated on collection create/update/delete (SQLite backend; Postgres deployments keep direct reads so multi-instance DDL stays coherent) — removing a
_collectionsSELECT plus a full schema-JSON parse per request and per realtime fan-out delivery. - The embedded admin UI’s assets now carry build-time
ETags and answerIf-None-Matchwith304 Not Modified, so revisiting the admin no longer re-downloads the SPA bundle on every load.
Security
- The SASLprep mapping/prohibited/bidi/NFKC-quick-check sets are vendored-generated range tables (
scripts/gen-saslprep-tables.pyover the frozen RFC 3454 appendices + Unicode 16.0.0 UCD extracts) — auditable binary-search tables, mechanical to bump. - Postgres TLS supports real server-certificate verification:
sslmode=verify-ca/verify-fullare accepted (previously rejected at parse time), a newsslrootcert=<path|system>URL parameter selects the CA bundle (built once at startup, shared by all pooled connections, fail-fast on a missing/empty bundle), certificate validity is checked against real wall-clock time, and handshake failures surface actionable startup errors (untrusted chain, hostname mismatch, expired / not-yet-valid certificate, server refused TLS) that never include the connection URL. - Realtime slow-consumer backpressure (issue #203): each WebSocket/SSE connection now has a per-connection outbound high-water-mark. A client that reads slowly or stalls without closing used to let the server buffer its outbound frames without bound (an OOM/DoS risk); once a connection’s queued outbound frames exceed the bound it is now disconnected (the standard pub/sub choice — a clean reconnect + re-fetch, never a silent frame drop). Default
1024frames; tune with--realtime-outbound-hwm N/ZIGBASE_REALTIME_OUTBOUND_HWM(0disables). - Fixed an unauthenticated, remotely-triggerable heap double-free (and double connection-slot release) on the realtime WebSocket upgrade path: a malformed
Sec-WebSocket-Versionhandshake drives facil.io’sbad_requestbranch, which already invokes the connection’son_closeteardown before returning failure — the adapter then tore the connection down a second time. In a release build this was a potential denial of service. The SSE upgrade path (new in 0.10.0) is hardened identically. Both transports now leave failure-path teardown solely to facil.io’son_close. - Comptime custom routes (an app’s
.routesconfig) now resolve the active account exactly like the REST record/analytics/senders endpoints. PreviouslydispatchCustomnever resolved tenancy:ctx.track()calls from a custom route stamped an empty account, and — more seriously — reads of tenant-owned collections made through a custom route were served unscoped, exposing cross-tenant data to any caller who could reach the route. Custom routes now resolve tenancy identically to the REST chokepoints. File serving (GET /api/files/:col/:rec/:name) had the same gap and is fixed the same way: it now resolves the active account before evaluatingviewRule, so a file on a tenant-owned collection is no longer reachable cross-tenant by a caller who merely knows the collection/record/filename, and@request.account.*/cookie-activated rules now see the correct scope.
Internal
- CI now enforces formatting: a
zig fmt --check src build.ziggate in theunitjob fails the build on any unformatted file, paired with a one-shot tree-widezig fmtsweep so the tree starts clean. - Scoped the
zig-local-*build/test caches by branch (github.ref_namefolded into both thekey:andrestore-keys:prefixes of every job) so one branch can no longer restore and reference another branch’s cached objects — the cross-branch cache poisoning that surfaced a phantom symbol error in unrelated CI. The content-hash-keyedzig-global-*caches stay shared. - Added a multi-threaded stress test for the collection-metadata cache: N threads hammer
lease()/invalidate()/release concurrently, asserting no use-after-free, no leak (via the leak-checking test allocator), and correct post-invalidation reload. dumpload.zig’s collection-creation ordering is now a proper Kahn topological sort (planCreateOrder), with unit-tested, deterministic handling of relation cycles (self-relations and mutual/N-node cycles) that surfaces the in-cycle relation fields instead of just falling back to declaration order. Observable dump/load behavior for acyclic schemas (the common case) is unchanged; this lands the pure ordering primitive that Postgres deferred-FK cycle support (a follow-up task) builds on.- Parallelized the Playwright/browser test suite (
tests/admin/) with pytest-xdist (-n auto) in CI and reworked the harness fixtures to reuse a per-worker Chromium browser and a template superuser data dir, cutting the suite’s serial wall time (~4:53) to ~18s on a 32-core box. No consumer-visible change. - Fix a race in the admin browser test
test_shell.py::test_login_then_sidebar_lists_builtin_collections: it counted thenav-_superuserssidebar link immediately afterlogin(), butlogin()only waits for the staticnav-collectionslink while the built-in-collection nav items render asynchronously just after — so the barecount()read 0 and thebrowserjob flaked. It now waits for the selector before counting. - Fix a ~2.4%-per-run flake in the Postgres realtime cross-instance tests (
realtime_pg_test.zig): the delete-snapshot leak-canary asserted a bare owner valueu9was absent from the NOTIFY payload, but the payload embeds a 32-char random base36 token that coincidentally containsu9~2.4% of runs. The canaries are now anchored to their JSON string quotes ("u9","ssn"), which a quote-less token/id can never forge, while still catching a real leak. The cross-instance waits also now loop over benign non-notification async messages (matching the productionpg_bridgelistener’s tolerant contract) instead of failing on the first one. - Corrected a false load-bearing comment in
static_files.zig(facil.io does NOT percent-decode request paths; the..check is safe because encoded traversal stays a literal segment) and documented whyquery/params.zigkeeps its own query parser (fio type-guesses values; zap returns them undecoded). - Postgres backend: added
scripts/gen-saslprep-tables.py, vendored RFC 3454 / Unicode 16.0.0 UCD source extracts (vendor/unicode/), and the generatedsrc/backend/postgres/saslprep_tables.zigrange tables (RFC 3454 B.1/C.1.2/C.2.x/C.3–C.9/D.1/D.2, plus UCDNFKC_QCand canonical-combining-class data) that a follow-up SASLprep normalization pass will consume. Not yet wired into any code path. - A table↔
allowed-tuple parity test (tests/admin/test_docs_parity.py::test_config_key_table_matches_allowed_tuple) guards the config-key table against future drift. - Tightened the env-var help-parity test’s text slice to end at
EXAMPLES:instead of running to EOF — the old unbounded slice would false-pass aZIGBASE_*name that only appeared in a laterstd.logmessage, not in the actual help text. - Browser feature tests drive a dedicated
features-fixturebinary (fixtures/features/). - Doc-drift guard:
tests/admin/test_docs_parity.pyfails CI when aZIGBASE_*var referenced insrc/is missing from the README table or the help text. - CI now enforces the gating invariant: a minimal consumer build (
fixtures/minimal/) is nm-scanned to prove deselected subsystems (WebAuthn, magic-link, OAuth2, analytics API, senders, mail webhook, webhook/mail job kinds, admin SPA) leave zero symbols (scripts/check-gating.sh), self-checked against a positive-control build (fixtures/full/) so a renamed/vacuous pattern also fails the check. - Realtime delivery/verb authorization extracted from the WebSocket adapter into transport-neutral
hub.frameForDelivery/hub.authVerb/hub.subscribeCheck(behavior-preserving; WS wire byte-identical) — groundwork for the SSE transport. - SSE connection registry scaffolding (
realtime/sse.zig):SseConn+ pin/unref refcount, closed-flag lifecycle, and the per-delivery snapshot, with a strictregistry_mu/conn.munever-nested lock-ordering law and threaded-stress unit tests. Internal until the transport is wired end-to-end. - SSE stream lifecycle wired onto the shared realtime upgrade path (
realtime/ws.zighandleUpgradenow dispatchesssetargets on/api/realtime/sse):on_opendups the handle, registers, and writes the connect frame;on_closeruns the single authoritative reap; delivery snapshots underconn.muand authorizes through the samehub.frameForDeliverychokepoint as WebSocket. Not yet a usable transport (no subscribe uplink until the next slice); Internal until then. - New
s3CI job: MinIO viadocker run+ gated live Zig tests + a raw-HTTP upload→Range→delete e2e (tests/s3/). - Generalized the AWS SigV4 signer (
src/mail/sigv4.zig→src/aws/sigv4.zig): parameterized method / canonical URI (S3UriEncode) / signed-header list / service, SES signatures pinned byte-identical. Groundwork for the S3 storage backend; zero behavior change. - Dual-transport (ws/sse) realtime e2e delivery matrix in the browser suite.
- Static Range support is a ~20-line request-header normalization shim +
HTTP_HVALUE_MAX_AGEFIOBJ swap atFIO_CALL_PRE_START— facil.io keeps ALL static serving (directive 1); no owned static layer.
[0.9.0] - 2026-06-30
A large release: a PostgreSQL backend alongside the default embedded SQLite, plus multi-tenancy, relationship-based authorization, full-text & vector search, product analytics, a transactional email subsystem, background job queues, outbound webhooks, CAPTCHA verification, and a realtime broadcast API.
Breaking
- Consumer migrations (
.migrations) now receive a*zigbase.Migratorinstead of(alloc, io, w). Change eachuptofn (m: *zigbase.Migrator) anyerror!void: the writer ism.db, the arenam.arena, the requeststd.Ioism.io.Migratorcarries the active SQL dialect so one migration runs on either backend —m.execLowered(sql)lowers SQLite-flavored DDL/seeds to the active backend (byte-identical on SQLite),m.exec(sql)runs raw backend-specific SQL, andm.dialect.kind/m.rawFor(.postgres, …)branch per backend. SQLite-only consumers just swapw→m.db. ErrorPhasegained a.webhookvariant (additive). AnonErrorhandler that switches exhaustively overErrorPhasemust add a.webhookarm.
Features
- PostgreSQL backend (opt-in). ZigBase can now run on PostgreSQL instead of the default embedded SQLite, selected by configuration alone — a
postgres://ZIGBASE_DB_URLin a-Dpostgresbuild; application code and collection definitions are unchanged.- Full feature parity: record CRUD and the typed filter/sort/expand/search query engine, the access-rule + abilities + tenancy authorization stack, analytics rollups, the KV/TTL/rate-limit/feature-flag stores, field encryption + key rotation, the deterministic test-clock, and typed-client codegen all work identically on Postgres — verified against a live server in CI.
- Realtime across app instances: a Postgres deployment can run multiple stateless app instances against one database, and record-change events fan out to subscribers on every instance via
LISTEN/NOTIFY. The NOTIFY payload carries only an opaque token — never row data — so encrypted fields never leave the database in plaintext. - Pure-Zig wire driver: no libpq, C, or OpenSSL dependency (TLS via
std.crypto.tls.Client, SCRAM-SHA-256 viastd.crypto); the default SQLite build links zero new symbols. Transport is encrypted but the server certificate is not yet verified in any sslmode (verify-fullis a tracked follow-up) — use the Postgres backend over a trusted network path until then. migrate-dbCLI:zigbase migrate-db --from ./data.db --to "postgres://…"copies an existing SQLite instance (schema and data) into a fresh Postgres database — provisions the equivalent schema, bulk-loads every table in one atomic transaction, preserves ids/timestamps/metadata, and carries encrypted-field envelopes byte-for-byte (no key needed). FK suspension requires a superuser target; a managed non-superuser Postgres uses a lightly-tested topological-order fallback.- Vector search on Postgres via pgvector, behind the same
-Dvectorflag and?vector=API as SQLite’s sqlite-vec — one flag enables KNN on both backends. - Admin backend badge: the admin UI shows a “SQLite”/“Postgres” badge, sourced from a new
backendfield onGET /api/health(the kind only — never the connection string or credentials). - The default SQLite single-file deployment is unchanged. One safeguard: a stock (non-
-Dpostgres) binary now readsZIGBASE_DB_URLand logs a prominent warning if it is apostgres://URL, rather than silently writing to local SQLite.
- Account-scoped multi-tenancy (#156).
App(.{ .tenancy = .{ .enabled = true, .auth_collection = "users" } })plus a collection’s.tenant_field = "account"auto-scopes every read/write (and realtime delivery) of a tenant-owned collection to the request’s active account via a boundtenant_field = ?predicate; create stamps the owning account and update rejects cross-tenant moves. The active account resolves from anX-Account-Idheader or a signedzb_accountcookie, verified against an active_membershipsrow (fail-closed). Adds built-in_accounts/_memberships/_invitationscollections, a configurable role order (viewer < editor < admin < owner),POST /api/accounts/:id/activate, and the@request.account.id/.role/.idsrule macros. Superusers bypass;zigbase.crossTenant(rctx)is the explicit admin override. Apps with no.tenancyare byte-identical to before. - Relationship-based row abilities (#155). Declare per-collection, per-action authorization by the principal’s relationship to the row:
App(.{ .abilities = .{ .projects = .{ .update = .{ .relationship = .{ .via = "account", .min_role = .editor } } } } })authorizes a row when the principal holds a membership (role ≥.min_role) of the account it belongs to. Abilities compose into the existing guard stack, narrow the LIST endpoint, are fail-closed and comptime-validated, andctx.can(.action, "col", id)+GET …/records/:id/abilitiesexpose them to custom routes. Collections with no.abilitiesare byte-identical to before. - Search on the list endpoint (#157).
- Full-text search ships in the default build: mark a
text/editorfield.searchable = trueand query with?search=<terms>— ranked by relevance, withAND/OR/NOT/prefix operators, provisioned automatically (SQLite FTS5; Postgrestsvector+ GIN). Search composes with the full authorization stack and structured filters:?search=X&filter=Yreturns the scoped intersection (never an unscoped query) and terms are always bound (no injection). - Vector / nearest-neighbor search behind an opt-in
-Dvectorflag:?vector=<field>[:cosine|:l2]:<embedding>KNN ordering composed into the same scoped query (sqlite-vec on SQLite, pgvector on Postgres). Not compiled into the default build.
- Full-text search ships in the default build: mark a
- Product analytics (#158).
ctx.track("user.signup", .{ .plan = "pro" })appends an immutable event — actor, tenant, and timestamp stamped server-side — to the new_eventscollection. Declarative rollups (App(.{ .analytics = .{ .rollups = … } })) incrementally aggregate events into summary tables on the scheduler. Tenant-scoped, fail-closed read API:GET /api/analytics/events(raw feed) andGET /api/analytics/rollups/:name. Usable standalone with no config. - Email subsystem (#154) on
ctx.mail():- A safe multipart HTML + plain-text template engine (HTML-escaped by default, named partials + shared layout, no code evaluation).
- First-class SES and Postmark HTTP providers behind the
Mailervtable (SMTP/Command unchanged), a per-messageFromoverride, and aCaptureMailerfor asserting outbound mail in tests with no network. - Verified per-account sender identities and bounce/complaint suppression with an inbound provider webhook, all tenant-scoped. Enforcement (
.mail.require_verified_sender,.check_suppression) defaults off, so an app that only calls the existing mailer is unaffected. ctx.mail().send(...)/.enqueue(...)/.deliverLater(...);mail.Emailgainshtml_body/reply_to; the framework owns header-injection (CRLF) defense for every backend.
- Background jobs & queues. A generic multi-queue/worker/job engine: declare named
.queues(memory or durable, prioritized, per-queue retry),.workers(bound to queues, strict-priority drain, concurrency), and a.jobskind→handler registry, then enqueue from anywhere withctx.enqueue(.queue, .kind, payload). Durable queues persist to_queue_jobswith at-least-once delivery, crash-reclaim, and GC; memory queues need zero schema. Powers the built-in"mail"and"webhook"job kinds. - Outbound webhooks.
ctx.webhook(url, payload, .{…})delivers in the background on the queue engine with retry/backoff (honoringRetry-After, capped), optional HMAC-SHA256 signing, and a stable per-deliveryIdempotency-Key; TLS certificate verification stays on. - Realtime broadcast API for custom (non-record) channels, from a route or job:
ctx.realtime().signal(topic)(a payload-less re-fetch trigger, the default for private state) and.broadcast(topic, payload)(delivered verbatim), over the same WebSocket subscribe protocol clients already use. NewApp(.{ .realtime = .{ .canSubscribe = fn } })gates custom-topic subscriptions; a custom topic can never reach a real collection’s record channel. - CAPTCHA verification (#140).
ctx.verifyCaptcha(provider, token)for reCAPTCHA v2/v3, hCaptcha, and Cloudflare Turnstile, configured viaApp(.{ .captcha = … })(dev-bypass when the secret is empty). - Custom-route ergonomics. Response builders (
ctx.json/jsonError/html/redirect/notFound), deferredctx.setCookie/addHeader(merged on both the success and error paths), lazyctx.query(),ctx.randomToken/randomHex, andctx.subjectCookie(an anonymous per-visitor id). A declarative route guard pipeline:.authnow also accepts apath_secretguard (constant-time shared-secret gate, bare-404 on mismatch) and.rate_limitadds per-route buckets keyed on the trust-proxy client IP.http.Cookiegains an optionaldomain. - Filter/rule grammar: a new
inset-membership operator (field in ("a", "b"), compiled to a boundIN (?, …), empty set fail-closed) and the@request.account.id/.role/.idsmacros that underpin tenancy and abilities.
Changed
- A
.nocase(case-insensitive) index now makes both uniqueness and lookups case-insensitive on SQLite. Previously a.nocaseUNIQUE index treatedBob@x.com/bob@x.comas the same identity, but the lookup was case-sensitive — so a user registered asBob@x.comcould not log in asbob@x.com. Identity/email lookups and=/!=/incomparisons against a.nocasecolumn are now case-insensitive, agreeing with the index (and matching the Postgres backend, which uses alower()functional index). The built-in auth identity index remains case-sensitive — case-insensitive identity stays opt-in via a.nocaseindex.
Security
- The shared one-time-code comparison was unified on the audited constant-time
crypto.timingSafeEqlprimitive (the OTP auth method now uses it too). - Webhook retry backoff (including a server-supplied
Retry-After) is capped at the queue’s maximum, so a hostile or misconfigured receiver cannot park a worker thread and starve the background pool. - The new subsystems are fail-closed by design — tenant/ability/search scoping, the email verified-sender + suppression + CRLF-injection defenses, the realtime no-row-data-on-the-NOTIFY-wire guarantee, the
path_secretconstant-time gate, and per-route rate-limit IP keying are detailed under their features above.
Internal
- CI now runs a
-Ddev-clock=falseproduction-gate test pass, so the tests asserting thatZIGBASE_FAKE_NOW/ZIGBASE_FAKE_SEED/test-capture are compiled out of production builds actually execute (they were previously skipped in the only CI test run). - The e2e test harnesses now retry server startup on a port-bind race (fresh OS-assigned port + fast
ListenErrordetection + cleanup between attempts), fixing an intermittentListenError→ “server did not become healthy” flake in thets-sdk/browserjobs. - New
policy.zigauthorization-composition layer andsrc/sql/dialect.zigSQL-dialect layer are the architectural seams the abilities/tenancy and the Postgres backend compose through. - GitHub release descriptions now contain only the released version’s changelog section (
scripts/extract-release-notes.sh), not the entireCHANGELOG.md.
[0.8.0] - 2026-06-28
Breaking
- Feature flags are now declared-only. Flags must be declared in the
App(.{ .flags = .{ … } })literal; only declared flags resolve. The v0.7 runtime-string APIctx.flag("arbitrary")(KV-or-false) has been removed — use the typedApp.flag(ctx, .name)for known flags, orctx.flagByName("name")(returns?bool, null when undeclared) for dynamic names. ctx.setFlagnow writes a declared-flag override. It writes theflag:<name>override key for a DECLARED flag and errorserror.UndeclaredFlagotherwise (the typed, compile-checked form isApp.setFlag(ctx, .name, enabled)). Previously it set an arbitrary<name>KV value.
Features
- Comptime feature-flag + experiment registry (#128/#129/#130). Declare
.flags(bare-bool default or.{ .default, .description }) and.experiments(.{ .variants, .weights, .sticky, .description }) in theApp(cfg)literal. Malformed declarations (unknown sub-key, non-bool flag, variants/weights length mismatch, empty/duplicate variants, all-zero weights) are loud@compileErrors. - Typed, compile-checked accessors.
App.flag(ctx, .name) bool,App.setFlag(ctx, .name, enabled) !void, andApp.experiment(ctx, .name, subject) ![]const u8— a typo’d flag/experiment name is a compile error (generatedApp.Flag/App.Experimentenums). - Runtime resolution.
ctx.flagByName(name) ?bool(dynamic read),ctx.flags().resolveAll(subject)resolves every declared flag + experiment in a single batched_kvscan, and deterministic experiment bucketing (FNV1a-64(name ++ 0x00 ++ subject)over cumulative weights) gives a stable variant per(name, subject). Per-flag overrides live in_kvunderflag:<name>; experiment weight overrides underexp:<name>:weights(JSON). - Admin UI gains a Feature Flags & Experiments screen (
/_/#/features) showing every declared flag (name, default, description, effective value) with a toggle to set/clear theflag:<name>override, and each declared experiment’s variants with editable weight sliders that write theexp:<name>:weightsoverride; a “Reset to declared” action clears the override. Superuser-only; backed by the newGET /api/featuresendpoint. - New
GET /api/featuresendpoint (superuser) returns the comptime-declared flag + experiment registry alongside each entry’s current_kvoverride — useful for custom admin tooling. - Feature exposure events: register
.onFeatureExposureto receive anExposureEvent({ kind: .flag | .experiment, name, subject, value, variant }) each time a declared flag or experiment is resolved. The hook is notify-only and zero-cost when unregistered (the resolver never builds the event without a handler). - Realtime feature signal: any flag/experiment override change (
ctx.setFlag/App.setFlagor an adminPUT/DELETEof aflag:<name>/exp:<name>:weightssetting) broadcasts a signal-only{"type":"features.changed"}frame on the public__featureschannel. Clients may subscribe anonymously and re-GET /api/stateon receipt; no per-subject state or experiment assignment is ever pushed over the socket. - Public feature-state endpoint (#130).
GET /api/state?subject=<id>is an unauthenticated, read-only projection of resolved flags + experiments:{ "flags": { "<name>": <bool>, … }, "experiments": { "<name>": "<variant>", … } }. It exposes resolved values ONLY — never the_kvkeys, defaults, weights, timestamps, or any superuser settings verb (those stay behindrequireSuperuser). A.stickyexperiment returns its persisted assignment here too (agreeing withApp.experiment), resolved reader-first so a caller-supplied subject can’t storm the writer lock. Auto-mounts at/api/state; configure with.features = .{ .public_route = "/state" }to remap or.{ .public_route = .disabled }to turn off. - Typed
zb.flags.resolveAll(subject)in the TypeScript SDK.zig build gen-clientnow emits a fully-typed feature-state surface from yourApp(.{ .flags, .experiments }): flags as namedbooleans and each experiment as a string-literal union of its declared variants (FeatureState).await zb.flags.resolveAll("user-42")callsGET /api/stateand returns{ flags: { … }, experiments: { … } }with noany. Emitted only when flags/experiments are declared; the runtime-introspection tier omits it (no comptime metadata), matching typed routes and custom auth methods. - Sticky experiment assignments (#129): declare an experiment
.sticky = trueto persist a subject’s first variant in_experiment_assignmentsso it survives later weight changes (new subjects still follow the current weights; empty subjects are never persisted). A framework-internal_experiment_gcjob — installed only when a.stickyexperiment is declared — reaps assignments older than the new.experiment_assignment_ttlconfig (in days, default90) hourly in bounded batches.
[0.7.1] - 2026-06-28
Features
- TypeScript client codegen now emits precise typed I/O for custom auth methods. Enable a custom method in the new struct form —
.custom = &.{ .{ .slug = "corp-sso", .Initiate = .{ .Input = …, .Output = … }, .Complete = .{ .Input = …, .Output = … } } }— andzig build gen-clientreflects the declared Zig types intozb.auth.<col>.<method>.{initiate,complete}interfaces (named by the Zig type, like the typedzb.rpc.*route surface). AvoidInput omits the input argument; avoidOutput maps toPromise<void>. Bare-string slugs (.custom = .{"slug"}) stay fully back-compatible and untyped. Typed customs are a build-time feature (the runtime-introspection typegen tier keeps them untyped, exactly like typed routes).
Fixes
- Exported
zigbase.Tx— the transaction scope passed to actx.tx(T, fn(*Tx) ...)callback. It was referenced in the docs but never re-exported from the public API, so consumers could not name the callback’s parameter type. - The comptime per-auth-method
.rate_limit = .{ .custom = .{ .max = …, .window_s = … } }config form now compiles (it previously failed with a@tagName-on-a-struct error; only the.default/.offenum-literal forms worked). - The TypeScript client generator (
zig build gen-client) no longer hits the comptime branch-quota limit on apps with larger custom-route tables.
Internal
- golfsim example: added demos for per-device session management (
.session_store = .table+ctx.auth().revokeAllSessions/listActiveSessions/revoke), an atomic hold→booking convert viactx.tx(), a best-effort booking-confirmation webhook viactx.http(), and KV write-side seeding fromonBootstrap. Added a deterministic e2e suite that freezes time withZIGBASE_FAKE_NOWand captures the outbound webhook. Fixed a latent date-formatting bug in golfsim’sisoFromEpoch(signed-integer{d:0>N}emitted a+sign, breaking hold creation). - plugins example: demonstrates the comptime
.rate_limit = .{ .custom = … }per-method config, and documents field-key rotation (ZIGBASE_FIELD_KEY_V<n>+zigbase rewrap) in its README.
[0.7.0] - 2026-06-28
Breaking
- Custom handler/hook/job signatures now receive a unified per-request
*Ctx:- Untyped routes are
fn(ctx: *zigbase.Ctx) anyerror!zigbase.http.Response(wasfn(*RouteEvent)). - Record hooks are
fn(ctx: *zigbase.Ctx, ev: *zigbase.RecordEvent) anyerror!void(was one-argfn(*RecordEvent)). - Jobs are
fn(ctx: *zigbase.Ctx, ev: *zigbase.events.JobEvent) anyerror!void(was one-argfn(*JobEvent)). - Lifecycle hooks are
fn(ctx: *zigbase.Ctx, ev: *zigbase.events.LifecycleEvent) void. - Typed routes keep
fn(req: *zigbase.Req(In)) zigbase.RouteError!Out, but reach capabilities viareq.ctx(req.ctx.records(),req.ctx.http(),req.ctx.arena,req.ctx.app).
- Untyped routes are
- DB access is now uniform through the
Ctxcapability object:ctx.records()(list/get/create/update/delete),ctx.tx()(atomic writes), andctx.http()(outbound client). In abefore*hook,ctx.records()is bound to the triggering write’s in-transaction connection, so a side-write commits/rolls back atomically with it. - Removed
RecordEvent.data,JobEvent.reader()/JobEvent.writer(),ev.caps(), and the publiczigbase.Datare-export. Migrate hook/job DB access toctx.records(); for raw SQL on a migration-owned table use the pooled writer viactx.app.pool.acquireWriter().
Features
ctx.tx(T, fn)runs several record writes in one atomic transaction — all commit, or all roll back on any returned error. The callback receives a*Txwhoset.records()exposes the fullRecordsAPI; all writes share the in-transaction connection with no deadlock. Nesting is rejected immediately (error.NestedTransaction).- Admin UI now includes a “Settings / Feature Flags” section (
#/settings) where superusers can list, create, edit, and delete KV entries, and toggle boolean feature flags with a checkbox — backed by the existing/api/settingsREST surface. - Auth lifecycle hooks (#98): a new
.authconfig group adds before/after hooks forregister,logout,refresh, andpassword-change, extending the Theme DbeforeAuthSuccessdiscipline into a uniform lifecycle. Before-hooks run with a*Ctxbound to the action’s connection (in-transaction for register / refresh / password-change), soctx.records()writes commit atomically with the action; returning an error aborts and fails closed (rolling back where a write transaction exists — e.g. an abortingbeforePasswordChangeleaves the password unchanged and the reset token un-consumed, an abortingbeforeRegistercreates no account). After-hooks are notify-only. Hooks fire onregister(auth-collection record create),POST …/auth-logout,POST …/auth-refresh, andPOST …/confirm-password-reset. A typo’d hook name or a wrong-typed handler is a compile error. The existingbeforeAuthSuccessandonAuthhooks are unchanged. - Handler/hook/job capability object: handlers, hooks, and jobs now receive a
*Ctxdirectly, exposingctx.records()(filtered/sorted/paginated list + get/create/update/delete, withexpand/relations), an outboundctx.http()client, and a standard error model (ctx.fail/ctx.invalid, error→status mapping over the existing{code,message,data}envelope). Custom handlers no longer need to drop to raw SQL or vendor an HTTP stack. - Encryption key rotation — at-rest field encryption now supports a primary (write) key plus older read-only key generations. The primary key is
ZIGBASE_FIELD_KEYat generationZIGBASE_FIELD_KEY_GENERATION(default 1, = thev<N>:envelope version written); older generations are supplied viaZIGBASE_FIELD_KEY_V<n>. Writes use the primary generation; reads dispatch on each value’s envelope version. The single-key default is unchanged and fully backward compatible. zigbase rewrapcommand — re-encrypts every.encryptedfield across all collections under the primary key, and migrates legacy plaintext into ciphertext (the supported way to enable.encryptedon a column that already holds plaintext). Idempotent, transactional per collection, with--dry-run.ZIGBASE_FAKE_NOWnow also freezesCURRENT_TIMESTAMPand column DEFAULTs. The dev-only test clock previously froze the framework’s own timestamps and a consumer’s rawdatetime('now')/unixepoch('now')/strftime(…, 'now'), but the SQL keywordsCURRENT_TIMESTAMP/CURRENT_TIME/CURRENT_DATEand columnDEFAULT CURRENT_TIMESTAMPstill read the OS clock (they go through SQLite’s VFS, not the SQL-function layer). On dev builds, connections now open against a wrapping VFS — a byte-for-byte copy of the default VFS with only its current-time hooks overridden — so those keywords and defaults honor the frozen instant too, making tables with timestamp defaults deterministically snapshot-testable. All file I/O still delegates to the genuine OS VFS unchanged, and the wrapper is compiled out entirely on a production build (-Ddev-clock=false).- Seeded entropy for deterministic IDs/tokens in test mode (
ZIGBASE_FAKE_SEED) — setZIGBASE_FAKE_SEEDto a decimalu64on a dev build to make record/field ID and token key generation reproducible across runs with the same seed, enabling stable snapshot tests. Gated by the samedev_clockbuild option asZIGBASE_FAKE_NOW: compiled out on production builds, so a production binary always uses the OS CSPRNG and cannot be seeded. Closes #95. - Expired-session garbage collection for
.session_store = .table(#114). Enabling the table-mode session store now auto-installs a framework-internal recurring job that deletes expired_sessionsrows in bounded batches on the writer — no opt-in required. The default cadence is hourly; override it withApp(.{ .session_store = .table, .session_gc_cron = "…" })(UTC, minute-granularity cron syntax). Nothing is installed in the default.epochmode (no job, no timer — the zero-overhead guarantee is preserved). - Session management verbs on
ctx.auth()(#99):revokeAllSessions()(“log out everywhere”),refresh()(sliding re-mint, other sessions stay valid), androtate()(bump + re-mint, keep this session and kill every other). Free-function formszigbase.auth.revokeAllSessions/refresh/rotate(ctx). Sessions remain stateless JWTs but are now revocable via a per-auth-record token epoch (the defaultApp(.{ .session_store = .epoch })model) — no extra query on either the verify hot path or login: the epoch is folded into the singletokenKeySELECT each already performs. Existing valid tokens keep working: tokens minted before the epoch existed and freshly created records both read as epoch 0. - New comptime config key
.session_store(.epochdefault, or.table). The.tablevariant adds a server-side_sessionsstore for full per-device management:ctx.auth().listActiveSessions()(withis_current) andctx.auth().revoke(sessionId)(“log out THIS device”, owner-or-superuser authorized). In table mode each token carries an opaquesidand verification additionally requires a live (unexpired) session row — one extra indexed read per authenticated request..epochstays the default and is unchanged: zero extra DB work, and enabling.tabledoes not alter the.epoch-mode token shape (thesidclaim is simply omitted when absent). In.epochmode the per-device verbs returnerror.SessionStoreNotEnabled. - Field/collection policy pipeline — a value-transform seam at the records read/write path, applied transparently with the field schema in hand. Its first behavior ships below.
- Transparent at-rest field encryption — mark a
text/editor/jsonfield.encrypted = trueto store it encrypted (AES-256-GCM) in SQLite while handlers, the records API, and HTTP responses see plaintext. Encrypted fields cannot be indexed, marked.unique, or used in a?filter/?sort(compile error / 400). Key rotation is designed into the versionedv<N>:envelope. - TTL records. A collection may declare
.ttl_field = "<field>"naming an existingdate/autodatefield as the row’s expiry timestamp. A framework-internal GC reaps expired rows automatically — once at startup and then on a 5-minute interval — across every TTL-enabled collection. Opt-in and additive; collections without.ttl_fieldare untouched. - Framework-internal scheduled jobs. Added an internal scheduled-job mechanism (
scheduler.concatJobs) so the framework can run its own jobs (such as the new_ttl_gcsweep) alongside consumer.cronjobs. The scheduler now starts whenever a TTL collection is declared, even with no user cron configured. - Built-in key→value/settings store (#87):
ctx.kv().get/set/delete(and the curateddata.kvGet/kvSet/kvDelete/kvList) over a new internal_kvtable — small server-managed values with no collection, schema, or access rules. Superuser-managed and not public by default. - Typed feature flags (#88):
ctx.flag(name) -> boolandctx.setFlag(name, enabled), a typed boolean view over the same KV store ("true"/"1"truthy, unset = false). - Superuser-only settings HTTP API:
GET /api/settings,GET/PUT/DELETE /api/settings/:keyfor managing KV/settings values. - The
ZIGBASE_FAKE_NOWdev test clock now also freezes a consumer’s own raw SQLdatetime('now')/unixepoch('now')/strftime(…, 'now')(anddate/time/julianday, including their zero-argument implicit-'now'forms). SQLite’s date/time builtins are shadowed on every reader and writer connection so they resolve to the frozen instant, while explicit datetimes and modifiers ('+1 day', thestrftimeformat string) pass through to genuine SQLite. This makes e2e/snapshot tests of consumer routes that use raw time SQL fully deterministic (#84). Like the rest of the test clock it is compiled out of production builds (dev_clockbuild option; off in any release build) — a prod binary is byte-for-byte unaffected and never reads the env var. - Dev-only test-mode capture for outbound mail + HTTP (
zigbase.testcapture) — for deterministic e2e/integration tests, the framework can now capture what it sent and inject canned responses: an in-memory mail outbox (testcapture.mail) records everyMailer.send(from/to/subject/body, optionally suppressing real delivery), and an HTTP capture/mock seam (testcapture.http) records every outboundctx.http()call and returns canned responses matched by URL substring — with no network — mirroring the OAuthTransportinjection. Tests read/assert the captures via a small API (mail.count/get/ find,http.mock/requestAt/requests). Like the test clock, it shares the same comptime gate (dev_clockbuild option; on inDebug, off in any release build): on a production buildtestcapture.enablediscomptime false, both seams fold away, and the binary is byte-for-byte unaffected with no runtime branch or perf cost (#96). - Auth lifecycle hook
beforeAuthSuccess(#80): a writable, transactional, abortable hook that runs after credentials/token verification and before the session is issued, with a*Ctxbound to the login’s in-transaction writer. Itsctx.records()writes commit atomically with the login; returning an error rolls them back (and, for magic-link, un-consumes the link token) and blocks the session (fail closed). Fires on the unifiedPOST /api/collections/:col/auth/:method/completeendpoint (password / otp / webauthn / oauth2 / custom) and the magic-linkconsumelink. The existing notify-onlyonAuthis unchanged and still fires once, after issuance. Motivating use case: claim anonymous records on a user’s first login. - Session management surface
ctx.auth()withclearSession(#86):ctx.auth().clearSession()andzigbase.auth.clearSession(ctx)return the clearedzb_auth/zb_csrfcookies built from the framework’s own cookie policy, so a logout handler is one line and can never drift from the built-in logout. - TTL collections (
.ttl_field) now exclude expired rows from every read (list, get, expand,ctx.records()). The predicate is ANDed with any filter, access rule, and keyset cursor automatically — no manualexpires_at > @nowfilter needed. Semantics match the GC:NULLttl = never expired; unparseable ttl = fail-safe visible; non-canonical date forms (offsets, space separator, date-only) compared correctly as instants viastrftime. - Typed TypeScript client for built-in auth methods:
zig build gen-clientnow emits precise input/result types for theclient.auth.<collection>.<method>.initiate/completesurface of the three built-in non-password methods, replacing the previous untypedRecord<string, unknown>/unknownstubs.magic_linkinitiate takes{ identity }and resolvesvoid(204);otpinitiate takes{ identity }(→void) and complete takes{ identity, code };webauthninitiate takes{ identity? }and resolves{ challenge, rpId, ceremonyId, timeout }, complete takes{ ceremonyId, credentialId, authenticatorData, clientDataJSON, signature }. Every built-incompleteresolves to{ token }(AuthMethodResult). Custom methods (.customslugs) remain on the untyped stubs for now (a typed-I/O declaration API for custom methods is a planned follow-up).
Fixes
before*record hooks now run INSIDE the triggering write’s transaction on the HTTP create/update/delete path. A before-hook’s ownctx.records()side-writes and the primary row write now commit atomically, and a before-hook that returns an error — or a denied access-rule guard — rolls the whole transaction back, so a rejected write persists nothing (fail closed). Previously a before-hook side-write committed independently, before the triggering write.ctx.records()now allocates its results on a per-invocation arena instead of the long-lived process allocator. This fixes a heap leak that grew per request on routes and unboundedly for per-minute cron jobs. Route results live on the request arena; job,App.submit, and lifecycle-hook results live on a per-invocation arena freed when the invocation ends. No API change.- Unknown collection/field keys now fail the build. A typo’d key in a comptime
.collectionsspec — collection-level (e.g..ttl_filed), under.rules/.auth(e.g..viewRul), or on a field (e.g..requied,.encrypte) — was silently ignored; it is now a@compileErrorthat lists the recognized keys for that spec. onError/ Sentry integration now fires only for server-side (5xx) errors; client errors (4xx) no longer trigger the error handler or Sentry reports.- Auth methods configured with a custom
rate_limit(.{ .custom = .{ .max, .window_s } }) now actually honor thatmax/window_sinstead of silently falling back to the global limiter. Each method gets a dedicated bucket scoped by collection + method slug (keyed on the same IP/identity subject as the global limiter), so distinct methods and collections never share a budget, and a custom limit applies even when the global limiter is disabled (ZIGBASE_RATE_LIMIT_MAX=0). - Uploaded files are now cleaned up when a record update fails validation, preventing orphaned files from accumulating in storage.
Performance
- Skipped a redundant buffer duplication on the non-encrypted JSON field read path, reducing per-request allocations for records with JSON fields.
Security
- Each key generation derives an independent AES-256 key via domain-separated HKDF (
zigbase-field-encryption-v<n>), so generations never share key material; generation 1 keeps the original domain for backward compatibility. Reads remain strict and fail-closed: a value whose envelope version has no configured key (unknown/missing generation), a wrong key, or a tampered/malformed value never yields plaintext.rewrapis fail-closed too — a cell it cannot decrypt aborts the run with the offending row reported and that collection’s transaction rolled back, so no data is lost. Rotation keys come only from the environment and are never persisted or logged. - Startup now fails closed for runtime-created encrypted fields. A server with an
.encryptedfield added at runtime (via the collections API while a key was set) would previously start on a later restart withoutZIGBASE_FIELD_KEY. Startup now scans the live database schema after provisioning and refuses to start (error.FieldKeyRequired) if any DB-resident collection declares an encrypted field while no key is configured — matching the existing comptime guard. (The value layer already failed closed on read/write, so plaintext never leaked; this just turns a silently half-broken server into a loud refusal.) - Outstanding session tokens can now be invalidated server-side before they expire. A bumped token epoch causes verification to reject every prior
.authtoken for that principal (fail closed — the epoch is trusted only after signature verification). Use it on password change, suspected compromise, or an explicit “sign out of all devices”. - With
.session_store = .table, a revoked or expired per-device session is rejected at verify time (fail closed), and per-sessionrevokeis authorized to the owning user or a superuser (a user cannot revoke another user’s session). - Field encryption uses an authenticated AES-256-GCM envelope (
v1:+ base64url(nonce‖ciphertext‖tag)) with a fresh per-write nonce, sharing one audited primitive with OAuth-secret encryption via domain-separated key derivation. The key comes only fromZIGBASE_FIELD_KEY(HKDF-derived, never persisted or logged); the server refuses to start if an.encryptedfield is declared without it. Reads are strict and fail-closed: a non-envelope (legacy plaintext), wrong key, or tampered value never yields plaintext.
Internal
- Remove the deferred legacy
app/arenafields fromReq(Input)inroute_types.zig(Theme A cleanup: examples/blog and examples/golfsim both already readreq.ctx.arena/req.ctx.app; the fields were never needed and the migration comment is now moot). - Update the stale
AuthApidoc comment inctx.zigthat calledrefresh,rotate,listActiveSessions, andrevoke“deferred” — all four were shipped in PRs #111/#112 (session management, Variant B); the comment now documents the full surface including thesession_store = .tablerequirement for per-device verbs.
[0.6.0] - 2026-06-23
Features
- Auth-aware
Data.create—Data.createon an auth collection now runs the same credential transforms as the HTTP records handler (generates the per-recordtokenKey, forcesverified=false, hashespasswordwhen supplied), so a programmatically-created record works withzigbase.auth.issueSession/mintLinkTokenimmediately.passwordis optional, enabling passwordless (magic-link) sign-up to provision an account without hand-writing credential columns. Non-auth collections are unaffected; the lower-level enginerecords.createstill does a raw insert for imports/migrations. magic_linkandotpauth methods now honourauto_create: true— when an unknown identity callsinitiate, a passwordless account is provisioned automatically (email set from the identity,verified = false) and the link or code is sent as usual. Enables “sign up or sign in” in one step. Accounts are created withverified = false; pair withrequire_verifiedonly when a verification flow is in place.CommandMailer(local-command / sendmail mailer) — a built-in mailer that pipes the serialized RFC822 message to a local MTA’s stdin (e.g.sendmail -t -iormsmtp -t) and treats exit 0 as success. The standard “delegate delivery to a local relay, hold no SMTP credentials in the app” setup. Selected via the newZIGBASE_SENDMAIL_COMMANDenv var (whitespace-split into argv;From:fromZIGBASE_SMTP_FROM), which takes precedence over SMTP inDefaultMailerPlugin. Re-exported aszigbase.CommandMailer.- Comptime
.indexeson collection literals — azigbase.App(.{ .collections = … })collection may now declare.indexes = .{ .{ .name, .fields, .unique?, .collation?, .where? }, … }, lowered into the provisioned schema and emitted asCREATE INDEXDDL (case-insensitive via.collation = .nocase; conditional-unique via.where). Index.fieldsreference fields by their declared name. ZIGBASE_PUBLIC_URL→ clickable magic-link emails — setpublic_url(envZIGBASE_PUBLIC_URL) and the built-inmagic_linkmethod emails an absolute link to its consume endpoint (which sets the session cookie and redirects) instead of a bare token. Unset preserves the previous raw-token email. Lets a stock binary offer real magic-link login by configuration alone.- Comptime OAuth2 providers: declare
.auth.oauth2 = .{ .enabled = true, .providers = .{ .{ .name = "google", .redirectUrls = .{…} } } }on an auth collection in.collections. The runtimeclientId/clientSecretare sourced fromZIGBASE_OAUTH_<NAME>_CLIENT_ID/ZIGBASE_OAUTH_<NAME>_CLIENT_SECRETat provisioning time and the secret is encrypted (AES-256-GCM) before it is persisted — secrets never live in the binary. (Applied on first creation only; rotate via the admin API.) - Dev-only injectable test clock (
ZIGBASE_FAKE_NOW) — freeze the framework’s “now” to an ISO-8601 UTC instant (e.g.2029-03-07T16:00:00Z) so time-boundary scenarios (token expiry, scheduling, challenge/cursor TTLs) are deterministic in e2e suites. Every framework-controlled timestamp routes through one clock seam (src/clock.zig) that honors the override. Gated off in production: compiled in only on adev_clockbuild (on inDebug, off in any release build / shipped binary), so a production binary never reads the env var and time can never be frozen. Scope and the production gate are documented in Known limitations → Testing. Closes #58. golfsimexample:require_verified = trueon theusersauth collection — guests must verify their email before a session is minted (booking/payments justification).golfsimexample: OTP passwordless login (auto_create = false) for existing verified accounts; first-time onboarding remains password signup + email verification.golfsimexample: comptime indexes —NOCASEunique onusers.email(prevents case-variant duplicate accounts) and a partial composite index onbookings(listing, starts_at) WHERE status != 'cancelled'(backs the double-booking overlap check and availability route).golfsimexample: OAuth2 “Sign in with Google” via comptime.auth.oauth2; client credentials sourced fromZIGBASE_OAUTH_GOOGLE_CLIENT_ID/ZIGBASE_OAUTH_GOOGLE_CLIENT_SECRETat provision time; Google-verified accounts are createdverified=true.golfsimfrontend: multi-stepAuthcomponent covering password sign-in, OTP initiate/complete, signup, email-verification, and Google OAuth2 flows.- Blog example: adds built-in
magic_linkauth onusers(passwordless login via emailed link,auto_create = true, 1 h TTL, server-redirects to/). - Blog example:
NOCASEunique comptime index onusers.emailvia.indexes = .{...}— prevents case-variant duplicate accounts. examples/pluginsshowcases the full advanced auth surface:authorsauth collection with WebAuthn (passkeys) + a customApiTokenMethodplugin;commentersauth collection with magic-link (auto_create=true);onAuthhook logging all three methods; comptimeNOCASEcollation index onauthors.contact_email; frontend magic-link comment flow;beforeCreatehook auto-populatingcommenterfrom session.- Comptime index collation + partial predicates —
schema.Indexgainscollation(.binarydefault /.nocase, applied per indexed column) and an optionalwhere: ?[]const u8partial-index predicate. Case-insensitive indexes (CREATE INDEX ... ("email" COLLATE NOCASE)) and conditional-unique indexes (... WHERE deleted_at IS NULL) are now expressible in the comptime.collectionsschema and emitted in the generatedCREATE INDEXDDL, instead of requiring an out-of-band raw-SQL bootstrap. Defaults preserve existing DDL and JSON round-trip behavior. mintLinkTokenopaque bound payload —zigbase.auth.mintLinkTokentakes a trailingopts: MintOptionsarg whosepayload(default"") binds a small opaque string into the single-use token’s signedplclaim, returned byverifyLinkTokenasclaims.pl. Lets a magic-link flow carry tamper-proof bound state (e.g. a post-login redirect target) in the one token instead of an unsigned&next=URL param. Signed, not encrypted — readable-but-tamper-proof; keep it small. Existing call sites add.{}.GET .../auth/magic_link/consume— browser-friendly email-link login —GET /api/collections/:col/auth/magic_link/consume?token=…&redirect=/appverifies and consumes the single-use link token (same replay guard ascomplete), mints the session through the sharedissueSessionseam (soonAuth(.magic_link)fires and thezb_auth/zb_csrfcookies are set), honors therequire_verifiedgate, and302s to the redirect target. Two new per-methodmagic_linkoptions shape the redirect:redirect_default(fallback path when?redirect=is absent or rejected; defaults to/) andredirect_allow(allow-list of exact paths or/-suffixed prefixes; an empty list permits any safe relative path).
Fixes
- Comptime
.indexesis no longer silently ignored — the documented.indexeskey on collection literals was never lowered by the provisioner; it is now applied. - Corrected false claim in
examples/pluginsmigration 0002 comment: provisioned collection columns are human-named (field.name), not id-named. Raw migrations targeting migration-owned tables remain valid; the rationale is now accurate.
Changed
- Documented the CSRF double-submit contract for cookie sessions — the API reference now spells out that cookie-session clients must echo the readable
zb_csrfcookie in theX-CSRF-Tokenheader on unsafe methods (POST/PUT/PATCH/DELETE);GET/HEAD/OPTIONSare exempt. A failed CSRF check makes the request anonymous, so the response status follows the collection’s access rules —403on a create denial,404on an update/delete denial against a protected record (existence-hiding) — not a flat403. Documentation only; no behavior change.
Performance
- Trim unused subsystems from the vendored SQLite amalgamation (
OMIT_UTF16,OMIT_DECLTYPE,OMIT_DEPRECATED,OMIT_PROGRESS_CALLBACK,OMIT_TRACE,OMIT_SHARED_CACHE,DEFAULT_MEMSTATUS=0). The framework uses only SQLite’s UTF-8 prepare/step/bind/column/exec surface, so this is a pure build-cost/size win — a smaller shipped binary and ~10% faster SQLite C compile — with no behavior change. FTS5 is intentionally retained.
Security
- Server-side open-redirect guard on magic_link consume — the
?redirect=target is validated server-side so consumers never re-implement the guard: only same-origin relative paths are honored. Off-origin, protocol-relative (//host), scheme, CRLF/control-byte, backslash,./..path-traversal segments, and still-encoded%2e/%2f/%5cpayloads are all rejected and fall back toredirect_default.
Internal
- Changelog-fragments workflow — changes now add a
changelog.d/<slug>.mdfragment (with one or more### <Section>headings) instead of editingCHANGELOG.md, so parallel PRs never conflict on the shared changelog.scripts/assemble-changelog.shaggregates the fragments per section into a new version block inCHANGELOG.md(and itssite/mirror) at release time (run fromscripts/release.sh) and deletes them. Seechangelog.d/README.md. - Corrected the “provisioned columns are named by a stable field id” claim in
CLAUDE.mdanddocs/framework.md: physical SQLite columns use the human field name; the stable field id only matches columns across additive rebuilds. - Blog example frontend: new magic-link login form in
Editor.tsx(email → initiate → “Check your email” state), cookie-session detection viagetMe()on mount, and anAuthStatusnav island for logged-in display after consume redirect. - Blog example README: document
ZIGBASE_PUBLIC_URL, the fakeblog.testURL, and the email index. - Cache Zig’s local cache dir (
ZIG_LOCAL_CACHE_DIR) across CI runs, where the compiled SQLite object actually lives. The previous “global cache” step only persisted toolchain artifacts (compiler_rt/translate-c), so every CI run recompiled the SQLite amalgamation once perzig buildinvocation (~6×/run: main + each example + the unit job). All builds in a job now share one cached local dir, eliminating those recompiles on warm cache. Corrected the misleading comment on the global-cache step. - Use deliberately weak argon2id parameters in test builds only (keyed on
builtin.is_test). The unit suite hashes/verifies passwords across ~700 tests; at production cost (interactive_2id, 64 MiB) that KDF work alone was ~25 s of everyzig build test. A warmzig build testnow runs in ~6 s (was ~32 s). The shipped server binary and the Playwright browser suite (which drives the real binary) are unaffected and keep full-strength params.
[0.5.0] - 2026-06-21
Removed
- BREAKING: legacy OAuth2 endpoints removed —
GET .../oauth2-providers,POST .../oauth2-init, andPOST .../auth-with-oauth2no longer exist. OAuth2 is now exclusively the contract method:POST .../auth/oauth2/initiate,POST .../auth/oauth2/complete, andGET .../auth/oauth2/providers(discovery).
Added
- OAuth2 as a first-class
AuthMethod— exclusively at the contract endpointsPOST /auth/oauth2/initiate,POST /auth/oauth2/complete, andGET /auth/oauth2/providers(discovery); all paths share one implementation and the singleonAuthsession seam. See docs/api.md for request/response shapes. - Pluggable auth-method system — the
AuthMethodcontract (initiate/complete+AuthCtxblessed helpers +Resolution) lets the framework own session issuance while methods plug in verification logic. Built-ins implement the same contract with no privileged path. - Per-collection
.auth.methodsconfig — enable and configure built-in methods per auth collection:password(backward-compat default when.methodsis absent),magic_link(TTL, auto-create flag),otp(code length, TTL),webauthn(rp_id, rp_name, origin, credentials_collection). Each method has arate_limitknob (.default|.off|.{ .custom = .{ .max, .window_s } }). - App-level
.auth_methods— register customAuthMethodplugin TYPES at comptime (same pattern as.storage/.mailer); a type missingcreate/method/deinitis a compile error. - Auto-mounted auth endpoints — for every enabled method, the framework auto-mounts
POST /api/collections/:col/auth/:method/initiateand.../complete; the dispatch enforces enablement (404 for disabled/unknown methods) and default rate-limits. magic_linkbuilt-in — enumeration-safeinitiate(always 204), single-use link token emailed via the configured mailer,completeverifies+consumes and mints the session.otpbuilt-in — enumeration-safeinitiateemails a 6-digit code stored in theChallengeStore,completeverifies the code.webauthnbuilt-in — passkey login via the two-phase contract (initiate returnsPublicKeyCredentialRequestOptions; complete verifies the signed assertion). Passkey registration via two authed endpoints (register/begin/register/finish). ES256 (P-256, COSE -7) and Ed25519 (COSE -8) supported; attestationfmt:"none"(v1); signCount clone detection (fail-closed); credentials stored in_webauthnCredentials.ChallengeStore(_authChallenges) — TTL’d, GC’d single-use server-side challenge storage used byotpandwebauthn, and accessible to custom plugins viaAuthCtx.challengeStore().onAuthmethod tagging extended —AuthEvent.methodis an enum:.password,.oauth2,.magic_link,.otp,.webauthn, or.customfor custom plugins.- RPC client generation for auth endpoints — the generated TypeScript client exposes non-password auth-method endpoints under an
authsurface (initiate/complete stubs, currently untyped). zigbase.authconsumer surface for custom auth flows —issueSession(andRouteEvent.issueSession), single-use magic-link tokens (mintLinkToken/verifyLinkToken/consumeLinkToken),deliverAuthMail, andrateLimit. All session minting now funnels through one seam that always firesonAuth.
Security
- OAuth2 server-side CSRF
stateis now ON by default (ZIGBASE_OAUTH_STATE_SERVERdefaults totrue). Theinitiateendpoint issues astatevalue andcompleterequires and consumes it before contacting the provider. Behavior change: OAuth2 clients must use theinitiate→completeflow; barecompletecalls without a validstateare rejected with400. SetZIGBASE_OAUTH_STATE_SERVER=falseto restore the previous client-driven mode. - New
require_verifiedper-collection auth option (defaultfalse). Whentrue, any login attempt for an unverified record is rejected with403. This gate applies to all methods — including WebAuthn/passkey and OAuth2 accounts whose provider email was unverified (those are createdverified=false). Enabling it will lock out such users until they complete email verification. - OAuth2 no longer claims unverified provider emails — when a provider does not mark the email as verified, the new account is created with
verified=falseand theemailfield is left unpopulated. This prevents email-squatting via an OAuth2 provider that does not verify addresses. - WebAuthn credential binding — a passkey is now bound to the collection it was registered on; presenting it on a different collection returns
401. - WebAuthn
require_uvoption (defaultfalse). Whentrue, the server rejects assertions that do not set the user-verification bit (UV=1), requiring biometrics or PIN at the authenticator. - WebAuthn COSE key curve validation — ES256 credentials must use the P-256 curve; EdDSA credentials must use Ed25519. A mismatched algorithm/curve is rejected.
Performance
- Auth I/O off the write lock.
otpandmagic_linkrelease the DB connection before the SMTP send. WebAuthn signature verification runs before acquiring the write lock (only the signCount update and challenge consume hold it).oauth2Providersuses a reader connection. The authenticated-request fast path no longer does a redundant collection lookup. No auth method holds the single writer across blocking I/O or CPU-heavy verification.
Changed
- Auth methods now manage their own DB connections — each method holds one connection across its work; OAuth2
completereleases the writer during the provider HTTP exchange (no write-throughput stall); passwordcompleteuses a reader (argon2 is read-only). Neither method blocks writes during I/O. - Session issuance (password, refresh, OAuth2) routes through a single
issueSession+emitAuthseam — custom routes can no longer mint a session that skips theonAuthhook.
0.4.1 - 2026-06-19
Added
zigbase --version(and theversionsubcommand) prints build provenance — thebuild.zig.zonversion, the git commit, the build mode, the target triple, and the Zig version. Implemented at the framework level, so every binary built on ZigBase (including the examples and downstream apps) inherits it.
Changed
- Prebuilt server binaries are now stripped — release builds drop debug symbols, cutting each
@zigbase/server-<platform>package and GitHub-release tarball from ~24 MiB to ~7 MiB (about 73% smaller) with no API or behavior change.npm install @zigbase/serverandnpx @zigbase/typegendownload much less.
Added
- Untyped route handlers in framework mode.
.routesnow accepts the rawfn(*RouteEvent) anyerror!http.Responsehandler form alongside typedReq(Input)/Outputhandlers. An untyped handler owns its full response, so it can set/clear a session cookie, return a redirect (307), or serve a non-JSON content-type (e.g.text/calendar, an HTML OAuth handoff) — things the typed JSON thunk cannot express. Untyped routes carry no typedInput/Outputand are excluded from the generated TypeScriptzb.rpc.*client, so they never produce a client method that would mis-parse their response. text.patternis now enforced on record writes via a pure-Zig, linear-time (DoS-safe) Thompson-NFA matcher (src/regex.zig). Matching is unanchored (substring); anchor with^…$for a full-string match. Supported syntax: literals,.(any codepoint except\n), anchors^/$, character classes[…]/[^…]/ranges, predefined classes\d \D \w \W \s \S(ASCII), escapes\t \n \r \f \vand\-escaped metacharacters, alternation|, groups(…)/(?:…), and quantifiers* + ? {m} {m,} {m,n}. Patterns are validated when a collection is saved (a bad regex is a400field error), and at build time (@compileError) for comptime schema literals.datefieldmin/maxare now enforced on record writes, with date normalization (src/datetime.zig) so mixed formats (e.g.2026-06-10 08:00:00vs2026-06-10T08:00:00Z) compare correctly. Malformed or out-of-range date values are rejected with400(validation_date). Bounds are validated at collection-save time and at build time (@compileError) for comptime schema literals.
Fixed
- The HTTP status line now matches the response body for every status a handler returns, not a hand-picked subset.
setZapStatuspreviously mapped a short list of codes and sent everything else as500, so a custom route’s401auth rejection, a307magic-link redirect,410,502, and similar went out with a500status line even though the JSON body still said e.g.401. The mapping now derives fromzap.http.StatusCode’s named values, so any standard code zap defines is passed through and only genuinely-unknown codes fall back to500.
[0.4.0] - 2026-06-13
This round makes ZigBase safe-by-default: a security audit’s findings were fixed and the access-rule and deployment defaults were hardened. It contains breaking changes — read the migration notes below before upgrading. The full audit is in docs/security-audit.md.
⚠ Breaking changes
- Access rules are now safe-by-default. A blank rule —
nullor the empty string""— now means Locked (superusers only). Previously""meant allow-all (public) whilenullmeant locked; that inverted-from-intuition default was the single easiest way to ship a collection wide open. The only way to make a rule public is now the explicit sentinel"@public", and ZigBase logs a prominent startup warning for every@publicrule so a wide-open collection is never silent.- How to migrate: audit every collection’s
list/view/create/update/deleterules. Any rule that was""intending “anyone” must become"@public". Any rule that was""merely as a placeholder is now correctly Locked (superuser-only) — no change needed unless you relied on it being open. The admin UI rule editor is now a three-state selector (Locked / Expression / Public) and confirms before opening a rule to the public.
- How to migrate: audit every collection’s
- Secure-by-default deployment.
- Bind defaults to
127.0.0.1:8090(loopback); was0.0.0.0. Expose all interfaces explicitly with--http-host 0.0.0.0(ZIGBASE_HTTP_HOST), behind a firewall / reverse proxy. ZIGBASE_JWT_SECRETis auto-generated and persisted under the data dir on first run when unset. The shareddev-insecure-secret-change-medefault is gone, and a provided secret shorter than 32 bytes is refused at startup.- Auth cookies are
Secure(HTTPS-only) by default. For plain-HTTP local dev pass--insecure-cookies(ZIGBASE_COOKIE_SECURE=false). - An empty
ZIGBASE_REALTIME_ORIGINSnow denies cross-origin browser WebSocket upgrades. Same-origin upgrades (the embedded admin UI and any frontend served from the same binary) are always allowed; set--realtime-originsonly for a separate-origin browser app. - The rate limiter ignores
X-Forwarded-For/X-Real-IPunless--trust-proxy(ZIGBASE_TRUST_PROXY=true) is set. Direct exposure is now safe by default; enable--trust-proxyonly behind a trusted reverse proxy.
- Bind defaults to
perPageon record list queries is clamped to 500.
Security
- SMTP/RFC5322 header injection fixed — CR/LF/NUL rejected in mail
to/subject/fromand in the SMTP command path. email-field validation — rejects control characters and obviously-malformed addresses.- Realtime delete authorization — delete events are authorized against a pre-delete snapshot, so owner-scoped collections no longer leak deleted record ids to other subscribers.
- Realtime subscribe auth — subscribing to a non-
@publiccollection now requires auth. - Single-use tokens — verification and password-reset tokens are now strictly single-use.
- DoS caps — a global WebSocket connection cap and a multipart part-count cap (plus the
perPageclamp above). - Static symlink escapes refused — served files are canonicalized and must resolve within the static root.
- Optional server-side OAuth
state— an opt-in CSRFstatestore (ZIGBASE_OAUTH_STATE_SERVER); PKCE remains required in both modes.
Added
- New CLI flags / env vars:
--http-host(ZIGBASE_HTTP_HOST),--insecure-cookies(ZIGBASE_COOKIE_SECURE),--trust-proxy(ZIGBASE_TRUST_PROXY),--realtime-origins(ZIGBASE_REALTIME_ORIGINS),ZIGBASE_OAUTH_STATE_SERVER. - Admin UI: a three-state API-rule editor (Locked / Expression / Public) that confirms before making a rule public.
- Framework:
zigbase.JobEventis re-exported at the top level (alongsideRecordEvent/RouteEvent/ErrorEvent); comptime guards now give actionable compile errors for a mistyped.migrationsvalue or a storage/mailer plugin missing a contract method.
Fixed
- Large comptime
.collectionsschemas (~5+ collections) no longer fail to build with “evaluation exceeded 1000 backwards branches” — the lowering raises its own eval-branch quota (a downstream@setEvalBranchQuotacould not reach it).
[0.3.0] - 2026-06-11
Fixed
- Multipart form values are no longer type-guessed by the HTTP layer. The multipart parser was rewritten as a self-contained RFC 2046 parser over the raw request body. Previously, facil.io coerced form values at parse time (
45.00→ float,"true"→ bool,"123"→ int,"007"→7with the original text destroyed), so string-expecting fields failed validation and fixed-mode number fields could not be set in a file-upload request at all. Values now arrive byte-for-byte as sent, then a schema-aware coercion pass makes multipart input behave exactly like a well-formed JSON client. - Malformed multipart bodies return a clear
400(“Invalid multipart body.”) instead of falling through to the JSON parser’s misleading “Invalid JSON body.”; out-of-memory during parsing propagates instead of masquerading as a 400. - Multipart parser edge cases: boundary delimiters are validated per RFC 2046, so content containing a boundary-prefixed decoy can no longer truncate a value or smuggle extra form fields; flag-style (valueless)
Content-Dispositionparameters no longer drop the part;name[]bracket notation (PHP/jQuery convention) is normalized again; repeated<field>-removal keys delete all listed files; zero-byte file parts are skipped (matching the previous behavior); LWSP around=in the boundary parameter is tolerated.
Added
- Admin UI: a
scaleinput for fixed-mode number fields in the schema editor (shown when the mode dropdown is set tofixed). Together with the multipart fix above, fixed-point (money) fields are now fully usable from the admin UI — creatable in the editor and editable in the record drawer, file uploads included.
Changed
min/maxon text and number fields are now enforced on record writes (previously stored but silently ignored). Violations return400withvalidation_min/validation_maxon the offending field; text length is counted in unicode codepoints; number bounds are inclusive. Note: pre-existing records that violate their declared bounds will fail full-record re-saves (e.g. from the admin UI drawer) until corrected.- Multipart input semantics: an empty value clears an optional non-text field to
null(matching JSONnull); a single occurrence of a multi-value field wraps into a one-element array (repeated keys already became arrays); repeated non-file keys are preserved as arrays instead of being dropped.
0.2.0 - 2026-06-10
Fixed
- Provisioning no longer leaks at shutdown:
applySpecsandrunMigrationsnow wrap all internal allocations in a short-lived arena (backed by the caller’s allocator), so intermediate allocations fromtopoOrder,collections.create/ddl.quoteIdent,schema.indexesToJson, and theprov:migration-name string are all freed before the call returns. The long-lived gpa accumulates nothing during startup provisioning. - Reserved field names in comptime
.collectionsare rejected at compile time: declaring a field whose name is reserved by the engine (id,created,updated,email,username,passwordHash,tokenKey,verified) now produces a clear@compileErrorat build time rather than an opaque validation failure at startup.
Added
- Static file serving: root-path fallback with four comptime modes — runtime
--serve-static <dir>flag (default),.disabled, comptime-hardcoded.dir, or assets fully.embeddedin the binary via the newembedStaticDirbuild helper inbuild.zig. Embedded mode computes a CRC32 contentETagat build time and handlesIf-None-Match/304 itself. Dir mode (--serve-staticor comptime.dir) delegates caching to facil.io’ssendFile(ETag,Last-Modified,Cache-Control: max-age=3600, 304). All modes addX-Content-Type-Options: nosniffand lexical traversal protection (.., backslash, NUL). Static misses return plain-text 404;/api/*misses keep the JSON envelope. - Example frontends: all three examples now ship an Astro + React-islands frontend (one per static mode: blog = runtime flag, golfsim = hardcoded dir, plugins = embedded). Blog and golfsim also gain comptime
.collectionsschemas so the examples provision themselves at startup.
0.1.0 - 2026-06-10
First public release: a single-binary, PocketBase-inspired (not API-compatible) backend-as-a-service in Zig 0.16, plus an embeddable Zig framework.
Added
- Collections & schema engine with migrations and a
migrateCLI command. - Records CRUD with a typed query API:
filter(comparison +&&/||+ relation-path traversal +@request.*macros),sort,expand, and pagination. - Per-collection API access rules (list/view/create/update/delete): superuser-only, public, or filter-expression.
- Authentication — argon2id password hashing, JWT sessions over an httpOnly cookie with double-submit CSRF (and bearer tokens), and a
superuser createCLI command. - OAuth2 — client-driven PKCE with Google / GitHub / Microsoft / Discord presets; AES-GCM-encrypted client secrets at rest.
- Realtime over WebSocket — rule-filtered create/update/delete events, per-subscription filters.
- File storage — local-disk backend behind an S3-ready storage interface; protected files via short-lived tokens.
- Embedded admin UI at
/_/— a no-build Preact SPA (collections, records, schema editor, realtime live-view, OAuth2 config). - Embeddable Zig framework — extend ZigBase from your own Zig app via comptime configuration: record lifecycle hooks, custom HTTP routes (with
public/authed/superusergating), auth/file/lifecycle/error events, a cron/interval/reactive job scheduler with backoff-retry and a worker pool, andapp.submitfor ad-hoc background work. Events exposewriter()/reader()RAII DB accessors. Misconfiguration (unknown config keys, typo’d hook phases) is a compile error. - Comptime schema definition — declare collections in Zig via
App(.{ .collections = .{ ... } }), provisioned at startup with additive auto-migration (creates missing collections, adds new fields, resolves relations by name); non-additive changes go through an explicit.migrationsescape hatch. - Pluggable storage & mailer backends —
App(.{ .storage = T, .mailer = T })selects a comptime backend type; defaults are local-disk storage and a log/SMTP mailer. - SMTP mailer with TLS — verification and password-reset email is delivered over SMTP (plaintext / STARTTLS / implicit TLS) when configured; logs the tokens in dev when SMTP is unset.
- Auth rate limiting — login, verification, and password-reset endpoints are rate limited (fixed window, configurable, disable-able), keyed on the proxy-supplied client IP with a per-identity fallback.
- Comptime footprint levers —
App(.{ .pools = .{ ... } })tunes the warm-reader pool, job-worker pool, per-thread stack size, and SQLite page cache. - Performance — a warm reader-connection pool and a blocking-mutex writer for higher write throughput under contention.
- Apache-2.0 license and cross-platform release binaries (Linux + macOS).
Known limitations
See KNOWN_LIMITATIONS.md — notably: SMTP must be configured for email delivery in production (tokens are logged otherwise); rate limiting trusts proxy-supplied client IPs; auto-migration is additive-only; and the scheduler is single-process.