Authentication & Authorization
Companions:
- COLLECTIONS.md — lifecycle hooks (including
beforeRead/afterRead) live on the collection schema; this doc is the reference for the auth subsystem the hooks plug into. - ROUTING-API.md — server-fn transport that resolves
RequestContextand passes it down. - RELATIONSHIPS.md —
ReadContextis the seed for the actor-carryingRequestContext; populate threadsbeforeReadthrough to populated target collections. - CLIENT-SDK.md — the in-process SDK is where actor threading becomes externally visible.
Overview
Byline ships an end-to-end authentication and authorization subsystem with three load-bearing properties:
- Admin identity is a built-in subsystem, not a collection.
admin_users,admin_roles,admin_role_admin_user, andadmin_permissionsare dedicated tables owned by@byline/admin(with the Postgres adapter in@byline/db-postgres/admin). Admin users are never localized, versioned, workflowed, or rendered by the collection runtime. - Two auth realms from day one — AdminAuth and UserAuth.
Actor = AdminAuth | UserAuth | null. Today onlyAdminAuthand thenull(anonymous public) case are used at runtime;UserAuthis reserved in the type union so the contract does not have to grow a discriminator later. - Service-layer enforcement, not transport-layer enforcement. Every gate runs inside
@byline/core/@byline/adminservices, so the same gate is active no matter which transport (admin server fn, in-process client, future stable HTTP) invokes it. Transport edges only resolve and passRequestContext.
The subsystem is split across two packages by concern:
Package | Role |
| Leaf package. Actor primitives ( |
| Concrete admin subsystem. Admin user / role / permission / account modules (each as |
Postgres-backed repositories ship as the @byline/db-postgres/admin subpath, plugged into AdminStore.
Quick reference
Each entry is the minimal shape for one task. The "Edit" line tells you which file you actually change; the link at the end points at the deeper architecture section.
1. Register a new ability
Abilities are flat dotted strings. Subsystems register them once at boot so the role-ability editor can enumerate them as a checkbox tree.
Edit: the registering module — e.g. packages/admin/src/modules/admin-users/abilities.ts for built-in admin abilities, or apps/webapp/byline/server.config.ts after initBylineCore() returns for app-level additions.
import type { AbilityRegistry } from '@byline/auth'
export function registerMyPluginAbilities(registry: AbilityRegistry) { registry.register({ key: 'plugins.myThing.read', label: 'Read my-thing', group: 'My Plugin', description: 'View my-thing records.', }) registry.register({ key: 'plugins.myThing.update', label: 'Update my-thing', group: 'My Plugin', description: 'Edit my-thing records.', })}Collection abilities (collections.<path>.{read,create,update,delete,publish,changeStatus}) are auto-registered by initBylineCore() — only plugins outside the collection runtime need to register manually.
2. Assert an ability at a service-layer call site
Service-layer enforcement is the real boundary. UI cues are cosmetic. Every write entry point and every read entry point on @byline/client already routes through these helpers — you only call them directly when authoring a new service.
Edit: the new service file — e.g. packages/core/src/services/<your-service>.ts (collection scope) or packages/admin/src/modules/<module>/commands.ts (admin scope).
// Collection-scope service — gates `verb` on `collections.<path>.<verb>`.import { assertActorCanPerform } from '@byline/core/auth'
await assertActorCanPerform(requestContext, collectionPath, 'update')// Admin-scope command — always requires AdminAuth, asserts the named ability.import { assertAdminActor } from '@byline/admin'
const actor = assertAdminActor(requestContext, 'admin.users.create')3. Resolve RequestContext in a script or seed
Seeds, migrations, and one-off scripts need a RequestContext to call @byline/client or any service-layer entry point. Use createSuperAdminContext — the super-admin path is explicit in the code, never ambient.
Edit: apps/webapp/byline/seeds/<your-seed>.ts (or any script that imports byline/server.config.ts).
import { createSuperAdminContext } from '@byline/auth'import { getBylineClient } from '@byline/core'
const context = createSuperAdminContext({ id: 'seed:bootstrap' })const client = getBylineClient({ requestContext: context })
await client.collection('pages').create({ title: 'Hello world' })Inside admin server functions, use getAdminRequestContext() instead — see Actors and RequestContext.
4. Recipe — owner-only drafts
Anyone with read sees published documents. Authors additionally see their own drafts. Editors with a broader ability see everything.
Edit: the collection schema — e.g. apps/webapp/byline/collections/posts/schema.ts.
import { defineCollection } from '@byline/core'
export const Posts = defineCollection({ path: 'posts', fields: [/* … includes authorId */], hooks: { beforeRead: ({ requestContext }) => { if (requestContext.actor?.hasAbility('collections.posts.read.any')) return return { $or: [ { status: 'published' }, { status: 'draft', authorId: requestContext.actor?.id ?? '__none__' }, ], } }, },})The fallback '__none__' collapses cleanly when actor is absent — anonymous readers get the published-only branch.
→ Read-side scoping — beforeRead
5. Recipe — multi-tenant scoping
Every document belongs to a tenant. Every read clamps to the actor's tenant — full stop, no ability needed. Deny-by-default.
Edit: the collection schema.
hooks: { beforeRead: ({ requestContext }) => ({ tenantId: requestContext.actor?.tenantId ?? '__none__', }),}Anonymous readers see nothing, because no tenant matches '__none__'. If a tenant has a public storefront, expose it through a separate collection or a dedicated published-and-public flag rather than relaxing this predicate — tenant scoping should never have a forgotten escape hatch.
→ Read-side scoping — beforeRead
6. Recipe — embargo / scheduled publish
Documents go live at a specific timestamp. Non-editors must not see them before then; editors should see them in preview.
Edit: the collection schema.
hooks: { beforeRead: ({ requestContext }) => { if (requestContext.actor?.hasAbility('collections.posts.read.embargoed')) return return { publishAt: { $lte: new Date().toISOString() } } },}The predicate compares against publishAt at query time, so each request reads "now" — caching layers above this need to be cache-key-aware of time, or the embargo lifts late.
→ Read-side scoping — beforeRead
7. Recipe — soft-delete hide
Documents are soft-deleted by setting deletedAt rather than being removed from the table. Most readers never see them; an admin "trash bin" view opts in via an ability.
Edit: the collection schema.
hooks: { beforeRead: ({ requestContext }) => { if (requestContext.actor?.hasAbility('collections.posts.read.deleted')) return return { deletedAt: null } },}Pair with a delete collection method that performs the soft-delete write rather than a hard delete; otherwise the predicate has nothing to scope.
→ Read-side scoping — beforeRead
8. Recipe — department / workspace visibility
Each document is tagged with a department. Users may belong to multiple departments and see documents from any of theirs.
Edit: the collection schema.
hooks: { beforeRead: ({ requestContext }) => ({ departmentId: { $in: requestContext.actor?.departmentIds ?? [] }, }),}When departmentIds is empty, $in: [] returns no rows — deny by default. If the actor's department list is loaded asynchronously, make the hook async; the read context caches the predicate per (collectionPath, actor) so the lookup runs once per read regardless of populate fanout.
→ Read-side scoping — beforeRead
9. Recipe — self-only on user-like collections
A profiles collection (or similar user-shaped data) where ordinary users may only see their own row, but staff with a broader ability see all rows.
Edit: the collection schema.
hooks: { beforeRead: ({ requestContext }) => { if (requestContext.actor?.hasAbility('collections.profiles.read.any')) return return { id: requestContext.actor?.profileId ?? '__none__' } },}The reserved id key resolves to the logical document id. If your user model links profiles by a separate foreign key (e.g. userId rather than profileId === actor.id), filter on that field instead.
→ Read-side scoping — beforeRead
10. Mask or redact a field on read (afterRead)
Field-level visibility — masking, hashing, omitting — lives in afterRead. The hook receives the materialised document and can mutate doc.fields in place; mutations propagate through the response.
Edit: the collection schema.
hooks: { afterRead: ({ doc, requestContext }) => { if (requestContext.actor?.hasAbility('collections.users.read.pii')) return if (doc.fields.email) { doc.fields.email = doc.fields.email.replace(/^([^@]).*@/, '$1***@') } },}afterRead fires after populate on the source document, so hooks see the fully populated tree. See COLLECTIONS.md — Lifecycle hooks for the full hook contract.
→ Field-level redaction with afterRead
11. Bypass beforeRead (escape hatch)
Admin tooling, seeds, and migrations sometimes need to see everything regardless of scoping. The _bypassBeforeRead: true option on @byline/client read options is the deliberate, narrow exit.
Edit: the script or admin tool calling the SDK.
const allDocs = await client.collection('posts').find({ where: { status: 'draft' }, _bypassBeforeRead: true, // skip the beforeRead scoping predicate})Use only from internal tooling. Never inside application code paths — the whole point of beforeRead is to apply uniformly.
→ The documented escape hatches
12. Plug in a different SessionProvider
Sessions are pluggable behind SessionProvider. The built-in JwtSessionProvider is fully featured (15-min access, 30-day refresh, rotation, replay detection, argon2id), but Lucia, better-auth, WorkOS, Clerk, or institutional SSO can drop in by implementing the interface.
Edit: apps/webapp/byline/server.config.ts.
import { initBylineCore } from '@byline/core'import { MyCustomSessionProvider } from '@my-org/byline-session-mycustom'
const sessionProvider = new MyCustomSessionProvider({ /* … */ })
const core = await initBylineCore<AdminStore>({ // …db, collections, storage, adminStore, … sessionProvider,})The provider's capabilities flags (passwordChange, magicLink, sso) drive which affordances the admin UI renders.
Architecture
Actors and RequestContext
type Actor = AdminAuth | UserAuth | null
class AdminAuth { readonly id: string readonly abilities: ReadonlySet<string> readonly isSuperAdmin: boolean hasAbility(ability: string): boolean assertAbility(ability: string): void // throws AuthError if missing assertAbilities(...abilities: string[]): void}
interface RequestContext { actor: Actor requestId: string locale?: string readMode?: 'published' | 'any' // populate cache, afterReadFired set, beforeReadCache (inherited from ReadContext)}RequestContext extends ReadContext — the same context that already carries the populate cache and the afterReadFired guard. Adding actor and requestId to that seed means every read concern (populate, afterRead, beforeRead, ability checks) shares one context object per logical request.
Three classes of caller construct RequestContext:
- Admin server functions call
getAdminRequestContext()(packages/host-tanstack-start/src/auth/auth-context.ts). It reads the session cookie, callssessionProvider.verifyAccessToken, and attaches the resolvedAdminAuth. No actor → throws. - Public readers (the in-process
@byline/client) default toactor: null,readMode: 'published'. Anonymous access is permitted on read paths only when the read mode is'published'. - Scripts, seeds, and migrations call
createSuperAdminContext({ id })from@byline/auth. The fact that the caller is acting as super-admin is explicit in the code, not ambient, and every short-circuit onactor.isSuperAdmin === trueis auditable.
RequestContext is what every lifecycle service, populate call, hook, and SDK entry point receives. Auth populates the actor; access control reads it. Transport edges do not enforce.
Abilities
Abilities are flat dotted strings stored as varchar(128) in admin_permissions. Examples:
collections.pages.readcollections.pages.createcollections.pages.updatecollections.pages.deletecollections.pages.publishcollections.pages.changeStatusadmin.users.createadmin.roles.updateadmin.permissions.readThe flat-string choice is deliberate: it is what the role editor renders as a checkbox tree, what assertAbility checks, and what admin_permissions stores as one row per (role, ability) grant. CASL-style structured { subject, action } pairs were considered and rejected — they complicate the role editor without payoff at this scope.
The AbilityRegistry. AbilityRegistry (packages/auth/src/abilities.ts) is the single load-bearing abstraction. Every subsystem that wants to gate behaviour behind a permission registers its abilities at initBylineCore() time. Two consumers feed off it:
- Runtime —
assertAbility('collections.pages.publish')is a flat set-membership check onactor.abilities. The registry validates keys in dev mode (warns on unregistered keys); the check itself does not consult it. - Admin UI — the role-ability editor enumerates registered abilities, grouped by
group, as a checkbox tree. No hand-wiring per plugin.
Collections auto-contribute their abilities at registration time:
collections.<path>.{ read, create, update, delete }collections.<path>.{ publish, changeStatus } // when a workflow is configured@byline/admin registers its own abilities (admin.users.*, admin.roles.*, admin.permissions.*) the same way — via register*Abilities() exports. Future plugins follow the same pattern: register at init time, assert at call sites. The core knows nothing plugin-specific while still rendering a complete admin UI.
Two-layer access control
Layer 1 — flat abilities. Coarse-grained, table-stored, role-editable from the UI. Sufficient for "can this actor call this verb on this collection at all." Asserted at the service-layer entry point.
Layer 2 — conditional rules in hooks. Per-collection, in code, with full access to the document and the actor. The hook machinery is where ownership, state-gated, locale-masked, and tenant-scoped rules live:
CollectionHooks.beforeRead— contributes aQueryPredicateAND-merged into the SQL query. Owner-only, tenant-scoped, soft-delete-hide.CollectionHooks.afterRead— observes the materialised document and the actor; can mask fields, redact values, or tag rows.CollectionHooks.beforeUpdate/ workflow transition hooks — gate writes on document state ("publish only ifstatus === 'in-review'").
CASL's ideas (subject + action + conditions) are useful here; CASL itself is not adopted. CASL rules are code; flat abilities are data. Storing compiled CASL rules in a database and editing them from a UI was rejected as awkward at best.
The six Quick Reference recipes above cover the common Layer-2 patterns end-to-end. The deeper mechanics of the hook itself are documented in Read-side scoping.
The enforcement boundary
UI cues (hiding buttons, disabling menu items) are cosmetic and explicitly untrusted. An attacker can call the server function directly, drive @byline/client from a script, or hit a future HTTP endpoint. The real boundary is the service layer — every caller is forced through it.
Two helpers, one per realm:
Helper | Realm | Location |
| Document collections |
|
| Admin user / role / permission management |
|
assertActorCanPerform — document collections. Policy:
- No
requestContext→ERR_UNAUTHENTICATED. actor: null→ permitted only whenverb === 'read'andreadMode === 'published'. Any other null-actor call throwsERR_UNAUTHENTICATED.- Otherwise →
actor.assertAbility('collections.<path>.<verb>'). ThrowsAuthErroron miss. actor.isSuperAdmin === trueshort-circuits the ability check.
Call sites:
- Every
document-lifecycle.*write entry point (createDocument,updateDocument,updateDocumentWithPatches,changeStatus,unpublishDocument,deleteDocument,restoreDocumentVersion,duplicateDocument,copyToLocale). field-upload.uploadField— uploads are effectively a write under collection scope, gated oncreateeven whenshouldCreateDocument: false. See FILE-MEDIA-UPLOADS.md.@byline/clientCollectionHandleon every read path (find,findById,findByPath,findOne,countByStatus,history,findByVersion).- Every admin webapp document-collection server fn (
packages/host-tanstack-start/src/server-fns/collections/{list,get,history,stats,create,update,delete,status,upload,restore-version,duplicate,copy-to-locale}.ts). Writes threadrequestContextintoDocumentLifecycleContext; reads callassertActorCanPerformdirectly before the adapter call.
assertAdminActor — admin management. Policy:
- Always requires a present
AdminAuthactor — no anonymous path. - Asserts the specific module ability:
admin.users.*,admin.roles.*,admin.permissions.*.
Called inside every *Command in @byline/admin/admin-{users,roles,permissions,account}. The transport wrappers (the matching server fns under packages/host-tanstack-start/src/server-fns/admin-{users,roles,permissions,account}/) carry no policy — they resolve RequestContext and delegate.
The documented escape hatches
Two intentional bypasses exist, each on a single, well-marked seam:
- db.commands.* / db.queries.* direct calls bypass both helpers. Reserved for seeds, migrations, and internal tooling that need to bootstrap the system without an actor.
- _bypassBeforeRead: true on
@byline/clientread options skipsbeforeReadpredicate application. Reserved for the same class of caller — admin tooling that needs to see everything regardless of scoping rules.
These are deliberate, narrow exits. There is no ambient bypass and no environment variable.
Sessions — SessionProvider interface
Sessions are pluggable behind SessionProvider (packages/auth/src/session-provider.ts). The interface accommodates Lucia, better-auth, WorkOS, Clerk, institutional SAML/OIDC, or anything else that fits the contract; teams can run Byline end-to-end without reaching for any third-party identity service, because the built-in JwtSessionProvider is a fully capable first option, not a stub.
Minimum surface:
interface SessionProvider { signInWithPassword(args: { email: string; password: string; ip: string; userAgent: string }): Promise<{ accessToken: string; refreshToken: string; actor: AdminAuth }> verifyAccessToken(token: string): Promise<{ actor: AdminAuth }> refreshSession(refreshToken: string): Promise<{ accessToken: string; refreshToken: string }> revokeSession(refreshToken: string): Promise<void> resolveActor(adminUserId: string): Promise<AdminAuth> readonly capabilities: { passwordChange: boolean magicLink: boolean sso: boolean }}The capability flags are how the admin UI decides which affordances to render — a provider without passwordChange hides the password-change form rather than failing the call.
Built-in JwtSessionProvider (packages/admin/src/modules/auth/jwt-session-provider.ts and friends):
- 15-minute access tokens. Short enough that revocation propagates without a heavy real-time check on every request.
- 30-day refresh tokens stored in
admin_refresh_tokensfor revocation. DB-backed rather than short-lived-only — short-lived-only would have no way to force-sign-out a compromised account. - Rotation on every refresh. The old refresh token is invalidated when a new pair is issued.
- Replay detection. Reusing a rotated refresh token revokes the entire session lineage, on the assumption that a rotation collision means the attacker now has a token the legitimate client also held.
- argon2id password hashing (
packages/admin/src/modules/auth/password.ts). The full PHC string is stored inadmin_users.password.
resolveActor(adminUserId) joins admin_role_admin_user → admin_permissions → flat ability strings to build the runtime AdminAuth.
Read-side scoping — the beforeRead hook
CollectionHooks.beforeRead is the query-level access-control surface.
beforeRead?: (ctx: { collectionPath: string requestContext: RequestContext readContext: ReadContext}) => QueryPredicate | void | Promise<QueryPredicate | void>The hook fires once per findDocuments call (and once per populate batch, per target collection), receives the actor and read context, and returns a QueryPredicate. The predicate is compiled into the same EXISTS / LEFT JOIN LATERAL SQL the client's existing where parser already emits, then ANDed onto whatever the caller passed in where. Callers never see the scope — it is invisible, query-level, and applies even when no where was specified. Returning void (or undefined) means "no scoping for this actor" — typically the admin / superuser path.
The predicate language is the same WhereClause shape callers already use, plus $and / $or for explicit combinators. Field names resolve through field-store-map, so any field type already filterable via client where is filterable from a hook. status and path inside a combinator — or inside a nested relation sub-clause — downshift to a direct outer-scope column comparison via DocumentColumnFilter (the adapter wires status to td${depth}.status inside a relation hop and path to a pathProjection subquery against byline_document_paths).
Wired into:
- Every
@byline/clientCollectionHandleread entry point. populateDocuments— once per target collection per request, before the batch fetch.
A per-ReadContext cache (beforeReadCache, keyed by collectionPath) ensures async hooks don't re-run across populate fanout for the same target collection.
Composition rules:
- Hook predicate AND user where. The compiler merges them with implicit AND. A user passing
where: { status: 'draft' }against Recipe 1 (owner-only drafts) sees only their own drafts — both clauses apply. - void means "no scoping". Use it for the superuser / unconditional-read branch. Do not return an empty object
{}for the same purpose; treat empty objects as always-true predicates and prefer explicit early-return for readability. - Deny via sentinel, not by throwing. When the actor cannot read anything in a collection, return a predicate that yields no rows (
{ id: '__none__' }) rather than throwing. Throwing collapses list endpoints; sentinel predicates produce the natural empty result. - Bypass is explicit. Admin tooling, migrations, and seeds pass
_bypassBeforeRead: trueon the read options to skip the hook. This is a deliberate escape hatch and should never be used inside application code.
What beforeRead is not for:
- Field-level redaction. Use
afterReadto mutatedoc.fields— see the next section.beforeReadis row-level only. - Computed-field filters. The predicate compiles against EAV store columns and reserved document keys (
status,path,id, system timestamps). Synthesise a real field if you need to filter on something derived. - Write-side checks.
assertActorCanPerformalready gates every write path. Don't try to enforce mutation rules from a read hook.
The client-before-read.integration.test.ts suite in packages/client/tests/integration/ wires the owner-only-drafts and multi-tenant recipes end-to-end and serves as the executable companion.
Field-level redaction with afterRead
afterRead is the materialised-document hook. It fires once per document on every read path that flows through @byline/client or populateDocuments. The hook receives the document and the request context; mutations to doc.fields propagate back through the response.
afterRead?: (ctx: { doc: ClientDocument collectionPath: string requestContext: RequestContext readContext: ReadContext}) => void | Promise<void>Typical patterns:
- Mask — replace a value with a placeholder (
email→j***@example.com). - Redact — delete the key entirely.
- Hash — replace with a deterministic non-reversible value.
- Tag — add a synthetic field marking the row's visibility class.
afterRead runs after populate on the source document, so hooks observe the fully populated tree. Hooks that perform their own reads must thread readContext back through (client.collection(…).find({ _readContext: readContext })) so visited-set / read-budget / afterReadFired machinery stays consistent.
See COLLECTIONS.md — Lifecycle hooks for the broader hook surface (create / update / delete / status-change / unpublish), and Quick Reference recipe 10 for a worked masking example.
Admin UI surface
Route trees under apps/webapp/src/routes/(byline)/admin/. The page-level routes are thin shells that call into route factories from @byline/host-tanstack-start/routes, so the admin UI is reusable across host installations.
Area | Capability |
| Password sign-in via |
| Self-service profile + password change. |
| List / create / edit / enable / disable admin users; assign roles; set password. |
| List / create / edit / reorder admin roles; member assignment. |
| Read-only inspector — registered abilities, role-ability matrix, who-has-what lookup. |
| Per-collection list / create / edit / history / status. Standard CMS surface. |
The role-ability editor (under roles/) is the primary control-plane UI: a checkbox tree driven by listAbilities(), grouped by ability group. Every checkbox toggle round-trips through admin-roles.setRoleAbilities (gated on admin.permissions.update).
The permissions/ inspector is read-only by design — it surfaces what is registered and who holds it, but never edits. File-based config stays primary for anything schema-shaped (collections, fields, workflows, registered abilities). Drupal's structural mistake — making every schema-shaped decision live-editable from the UI — fragmented its source of truth between database rows and config files. Byline holds the line: file-based config is primary, the UI is an inspector for registered state, and only genuinely runtime concerns (feature flags, SMTP, branding) are ever live-editable.
UI ability cues — hiding Create / Publish / Delete buttons, disabling menu items — are cosmetic. The useAbility() hook and <RequireAbility> wrapper exist for UX, not security. The real gates run in the service layer per assertActorCanPerform and assertAdminActor.
Data model
Tables below are shown unprefixed for readability. Live names carry thebyline_prefix (byline_admin_users,byline_admin_roles, …) per the Postgres adapter's namespacing convention — seepackages/db-postgres/src/database/schema/auth.ts.
admin_users id uuid pk vid uuid -- version id given_name text family_name text username text unique email text unique password text -- argon2id PHC string remember_me boolean last_login timestamptz last_login_ip inet failed_login_attempts int is_super_admin boolean is_enabled boolean is_email_verified boolean preferred_locale varchar(16) -- nullable; admin interface -- language for this editor. -- See docs/I18N.md created_at, updated_at timestamptz
admin_roles id uuid pk vid uuid name text machine_name text unique description text order int created_at, updated_at timestamptz
admin_role_admin_user admin_role_id uuid fk → admin_roles admin_user_id uuid fk → admin_users primary key (admin_role_id, admin_user_id)
admin_permissions id uuid pk admin_role_id uuid fk → admin_roles ability varchar(128) -- flat dotted string created_at, updated_at timestamptz unique (admin_role_id, ability)
admin_refresh_tokens -- JwtSessionProvider only id, admin_user_id, token_hash, issued_at, expires_at, revoked_at, replaced_by, ...admin_users.is_super_admin === true short-circuits all ability checks at runtime — a super-admin's AdminAuth carries every registered ability synthetically. The flag is not a substitute for granting abilities to roles; it is the bootstrap and break-glass mechanism.
The seed under apps/webapp/byline/seeds/admin.ts creates one super-admin user and one super-admin role on a fresh install.
UserAuth tables are reserved but not designed. The Actor union declares the type so the contract does not have to grow a discriminator later.
Architectural rules
- Service-layer enforcement, not transport-layer enforcement. Auth gates live inside
@byline/core/@byline/adminservices. Transport edges (admin server fns, future HTTP endpoints) only resolveRequestContextand pass it down. This keeps the same gate active no matter which transport invokes the service. - Flat abilities are the contract. Plugins register abilities; the role editor enumerates them;
admin_permissionsstores them as rows. Conditional rules live in hooks, not in the database. - actor: null is a first-class case. Anonymous public readers are explicitly modelled. The null actor is permitted on
readwithreadMode: 'published'and rejected everywhere else. - Super-admin is explicit in the code, not ambient. Migration scripts and seeds call
createSuperAdminContext({ id }); there is no environment variable, no test-mode bypass, no implicit "internal call" exception. - Reads go through @byline/client. Even from the admin webapp. This keeps
beforeRead/afterReadorchestration uniform with future external readers and means access-control predicates apply once, in one place. - The admin UI is an inspector, not a control panel for schema. File-based configuration is primary. Genuinely runtime settings (feature flags, SMTP) are fine to live-edit; collection schemas, field types, and workflow definitions are not.
Explicitly deferred
The following are declared in the contract but not implemented, kept that way deliberately so the surface does not have to grow a discriminator when they land:
- UserAuth sign-in surface. The type is in the
Actorunion; the DB tables, sign-in flow, and admin UI wait for a concrete end-user feature. - Magic-link / SSO / OIDC providers.
SessionProvideraccommodates them; built-in adapters wait for real demand. - UI-editable conditional rules (CASL-style). Hooks remain the expression surface. Revisit if real workloads demand role-editable conditional rules.
- Site-settings storage and editor. Orthogonal to auth. Decide whether to reuse the collection runtime when the requirement is in hand.
Code map
Concern | Location |
Actor primitives |
|
|
|
|
|
|
|
|
|
Document-collection enforcement |
|
Admin-management enforcement |
|
|
|
|
|
Predicate compiler |
|
Admin user / role / permission services |
|
Built-in JWT session provider |
|
Admin store aggregate |
|
Postgres admin repositories |
|
Admin schema + migration |
|
Admin server-fn auth context resolver |
|
Admin server fns (auth) |
|
Admin server fns (management) |
|
Admin route factories |
|
Admin UI route shells |
|
Super-admin seed |
|
Integration test for |
|