ZigBase 0.10.0
·
600 commits
to main
since this release
Immutable
release. Only release title and notes can be modified.
[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's
index.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 → serve
rewrites 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 stock
max-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 (
Builder
was materialized as a ~3 MB all-zero.rodatatemplate copied at runtime on every
compile) — 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
returnserror.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-Match
with304 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.