ZigBase 0.7.0
·
905 commits
to main
since this release
Immutable
release. Only release title and notes can be modified.
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*Tx
whoset.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 for
register,logout,refresh, andpassword-change, extending the Theme D
beforeAuthSuccessdiscipline into a uniform lifecycle. Before-hooks run with a*Ctx
bound 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 existingbeforeAuthSuccessandonAuth
hooks 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 forms
zigbase.auth.revokeAllSessions/refresh/rotate(ctx). Sessions remain stateless JWTs but
are now revocable via a per-auth-record token epoch (the default
App(.{ .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.table
variant 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 SQL
datetime('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 every
Mailer.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 OAuth
Transportinjection. 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 unified
POST /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/complete
surface of the three built-in non-password methods, replacing the previous untyped
Record<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.