@wtfalch/authz-store
v0.2.1
Published
Persistence and lifecycle for @wtfalch/authz: the storage a host would otherwise write itself.
Readme
@wtfalch/authz-store
@wtfalch/authz decides. This stores.
Both applications on the engine wrote their own persistence and lifecycle layer — the schema, the audit writer, the tenant and team trees, invitations, credentials, break-glass, the boot checks — and arrived at the same one. Of the 13,107 shared lines, 106 differ, and three quarters of those are one application lagging the other or a stale comment. This package is where that code goes so it exists once.
/Users/william.falch/Documents/dev/authz/docs/shared-persistence-map.md is the
map: what moves here, what stays per-application, and why this is a second
package rather than a subpath on the engine.
Status
Unadopted, unpublished, private. Moved so far: credential secrets, the
ScopedDb seam, every table definition, and ownerCoverage, the first module
that reads rows. Neither application depends on this yet.
New here, not moved: authz_grants, grants stored per person rather than
compiled from a role, and guestGrants, which reads a tenant's guest grants
with each one's status (current, lapsed or revoked). A guest row must
have expires_at. Chat reads this to act on expiry; see
https://github.com/wtfalch/authz/issues/42.
Tests run against a real Postgres — PGlite, compiled to wasm and started in the
test process — so no container, service or network is needed. Most tests build
the tables from the drizzle definitions. src/migrate.test.ts applies the
shipped SQL instead and fails when the two disagree on a column, a type,
nullability or an index name.
Migrations
The package ships its own SQL in migrations/, and migrateStore(db) applies
it. Run it before the app's own migrations, so every table the package owns
exists before an app migration references it. Each file runs once, in one
transaction, and is recorded in authz_store_migrations. Never edit a shipped
file; a change is a new numbered one.
0001_baseline.sql is every store table as manage's live schema had it on
2026-09-22. Three things stay with each app, because each lists something the
app owns:
tenants_ceiling_offered_checkandtenants_self_denied_offered_check, which list that app's offered permissions.- The foreign key from
break_glass_sessions.operator_idtoprofiles.
The baseline is for a new database. manage and otf web/ already have these
tables, so each needs a one-off reconciliation: bring the schema to the
baseline, then insert 0001_baseline.sql into authz_store_migrations by
hand. otf also lacks authz_roles.updated_by and the 'activation'
assignment source.
What a host must supply is growing, and it is all explicit: a module that needs
the host's APPLICATION_ID and PLATFORM_ID takes them as a PolicyBinding
argument rather than importing them, because the two applications differ there.
The remaining ~12,800 lines, and the one-off migration each application needs to adopt them, are not started.
Why it is not part of @wtfalch/authz
The engine has no runtime dependencies, and a test scans its whole published
dist for anything Node-only — a node: import, require(, process.,
Buffer — then runs it inside a real workerd process. This package's first file
imports node:crypto, which is precisely what that scan rejects. The separation
is asserted, not intended: see the store package exports exactly what it
promises, and is Node-only by nature in
/Users/william.falch/Documents/dev/authz/scripts/tests/package-contract.test.mjs.
A note for a Next.js host
Both applications guard this code with Next's server-only marker. A
framework-neutral package cannot import it, so that import is dropped here. A
host that wants the guard re-exports these functions through its own
server-only module. generateCredentialSecret reaching a client bundle is the
thing worth preventing.
