Byline CMS
  • Startseite
  • Doku
  • Über uns
Byline CMS
Auf GitHub ansehen
  • Getting Started
    • CLI
    • Development environment and example application
    • Upgrading from 3.21 to 4.x
  • Why Byline
    • Mission & Vision
    • Content Management in the Time of AI
  • Key Architectural Decisions
    • Core Document Storage
    • Core Composition
    • Transactions
    • Path Grammar
    • Deployment Topologies
  • Collections
    • Fields
    • Blocks
    • Relationships
    • Document Trees
    • Document Paths
    • File / Media Uploads
    • Rich Text Editor
    • Collection Versioning
  • Reading & Delivery
    • Client SDK (@byline/client)
    • Routing & API
    • Transports
    • Markdown Export
    • MCP Server
    • Caching
    • Search & Retrieval
    • Search & Document Extraction
  • Auth & Security
    • Authentication & Authorization
    • Auditability
  • Internationalization (i18n)
    • The host i18n system
    • Admin interface translations
    • Content locales
    • Administering content locales
  • Admin UI
    • UI Kit (@byline/ui)
    • Client-config registration
  • Testing
  • Startseite

Authentication & Authorization

Companions:

  • Collections — 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 — server-fn transport that resolves RequestContext and passes it down.
  • Relationships — ReadContext is the seed for the actor-carrying RequestContext; populate threads beforeRead through to populated target collections.
  • Client SDK — 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:

  1. Admin identity is a built-in subsystem, not a collection. admin_users, admin_roles, admin_role_admin_user, and admin_permissions (live names carry the byline_ prefix — byline_admin_users, …) are 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.
  2. Two auth realms from day one — AdminAuth and UserAuth. Actor = AdminAuth | UserAuth | null. Today only AdminAuth and the null (anonymous public) case are used at runtime; UserAuth is reserved in the type union so the contract does not have to grow a discriminator later.
  3. Service-layer enforcement, not transport-layer enforcement. Every gate runs inside @byline/core / @byline/admin services, 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 pass RequestContext.

The subsystem is split across two packages by concern:

Package

Role

@byline/auth

Leaf package. Actor primitives (AdminAuth, UserAuth, Actor), the RequestContext shape, the AbilityRegistry, the SessionProvider interface, and the AuthError factories. No DB, no transport — types and small classes only.

@byline/admin

Concrete admin subsystem. Admin user / role / permission / account modules (each as commands.ts + repository.ts + service.ts + dto.ts + schemas.ts + errors.ts + abilities.ts), the built-in JwtSessionProvider, password hashing (argon2id), and the AdminStore aggregate.

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,reindex}) are auto-registered by initBylineCore() — only plugins outside the collection runtime need to register manually.

→ Abilities

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')

→ The enforcement boundary

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.

→ 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 same operation context is threaded through direct reads and populate.

→ 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
const profileId = requestContext.actor?.profileId
return profileId ? { id: profileId } : { id: { $in: [] } }
},
}

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 — 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.

A provider implements five methods. The load-bearing one is resolveActor — it turns an admin-user id into the runtime AdminAuth (the actor id plus its flat ability set) that every access check reads. Credential verification and token storage are yours to wire to whatever identity service you use; this example delegates credentials to an external IdP and issues short-lived JWT access tokens:

// byline-session-mycustom/src/provider.ts
import { AdminAuth, type SessionProvider } from '@byline/auth'
import type { AdminStore } from '@byline/admin'
import { SignJWT, jwtVerify } from 'jose'
const ACCESS_TTL = '15m'
const secret = new TextEncoder().encode(process.env.SESSION_SECRET!)
export class MyCustomSessionProvider implements SessionProvider {
constructor(
private deps: {
store: AdminStore // resolves an admin user's roles → permissions → abilities
verifyCredentials: (email: string, password: string) => Promise<{ adminUserId: string } | null>
refreshTokens: RefreshTokenStore // your persistence: issue / rotate / revoke
},
) {}
// Drives which affordances the admin UI renders (e.g. SSO hides the password form).
readonly capabilities = { passwordChange: false, magicLink: false, sso: true }
async signInWithPassword({ email, password }: { email: string; password: string; ip: string; userAgent: string }) {
const verified = await this.deps.verifyCredentials(email, password)
if (!verified) throw new Error('invalid credentials') // map to an AuthError in real code
const actor = await this.resolveActor(verified.adminUserId)
return {
actor,
accessToken: await this.mintAccess(actor.id),
refreshToken: await this.deps.refreshTokens.issue(actor.id),
}
}
async verifyAccessToken(token: string) {
const { payload } = await jwtVerify(token, secret)
return { actor: await this.resolveActor(payload.sub!) }
}
async refreshSession(refreshToken: string) {
// rotate() invalidates the old token and detects replay of a rotated one
const { adminUserId, nextToken } = await this.deps.refreshTokens.rotate(refreshToken)
return { accessToken: await this.mintAccess(adminUserId), refreshToken: nextToken }
}
async revokeSession(refreshToken: string) {
await this.deps.refreshTokens.revoke(refreshToken)
}
// The load-bearing method: build the actor + its abilities from the admin store.
async resolveActor(adminUserId: string): Promise<AdminAuth> {
const { id, isSuperAdmin, abilities } = await this.deps.store.resolveAdminAuth(adminUserId)
return new AdminAuth({ id, abilities: new Set(abilities), isSuperAdmin })
}
private mintAccess(sub: string) {
return new SignJWT({})
.setProtectedHeader({ alg: 'HS256' })
.setSubject(sub)
.setExpirationTime(ACCESS_TTL)
.sign(secret)
}
}

Then wire it in apps/webapp/byline/server.config.ts:

import { initBylineCore } from '@byline/core'
import { MyCustomSessionProvider } from '@my-org/byline-session-mycustom'
const sessionProvider = new MyCustomSessionProvider({
store: adminStore,
verifyCredentials: myIdp.verify,
refreshTokens: myRefreshTokenStore,
})
const core = await initBylineCore<AdminStore>({
// …db, collections, storage, adminStore, …
sessionProvider,
})

getAdminRequestContext() calls verifyAccessToken on every admin request, so the AdminAuth your resolveActor returns is exactly what assertAbility / assertActorCanPerform check downstream. The capabilities flags drive which affordances the admin UI renders — a provider without passwordChange hides the password-change form rather than failing the call.

→ Sessions — SessionProvider


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'
}

RequestContext and ReadContext are separate objects. The client binds one authenticated request authority to a logical read operation, then gives hooks an operation-scoped request-context clone carrying the effective readMode. Populate, richtext target reads, beforeRead, afterRead, and ability checks therefore share one actor without mutating the configured context. Reusing that logical ReadContext with another authority fails closed rather than inheriting scope compiled for the first caller.

Three classes of caller construct RequestContext:

  • Admin server functions call getAdminRequestContext() (packages/client/src/server/admin-context.ts, exported from @byline/client/server). It reads the session cookie, calls sessionProvider.verifyAccessToken, and attaches the resolved AdminAuth. No actor → throws.
  • Public readers (the in-process @byline/client) default to actor: 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 on actor.isSuperAdmin === true is 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.read
collections.pages.create
collections.pages.update
collections.pages.delete
collections.pages.publish
collections.pages.changeStatus
collections.pages.reindex
admin.users.create
admin.roles.update
admin.permissions.read
admin.activity.read

The 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 on actor.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, publish, changeStatus, reindex }

@byline/admin registers its own abilities (admin.users.*, admin.roles.*, admin.permissions.*, and the read-only admin.activity.read that gates the system activity area) 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 a QueryPredicate AND-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 if status === '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

assertActorCanPerform

Document collections

packages/core/src/auth/assert-actor-can-perform.ts

assertAdminActor

Admin user / role / permission management

packages/admin/src/lib/assert-admin-actor.ts

assertActorCanPerform — document collections. Policy:

  • No requestContext → ERR_UNAUTHENTICATED.
  • actor: null → permitted only when verb === 'read' and readMode === 'published'. Any other null-actor call throws ERR_UNAUTHENTICATED.
  • Otherwise → actor.assertAbility('collections.<path>.<verb>'). Throws AuthError on miss.
  • actor.isSuperAdmin === true short-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 on create even when shouldCreateDocument: false. See File / Media Uploads.
  • @byline/client on every read path: ordinary finds, collection and zone search, status counts, history/version reads, audit-log access gates, document-tree reads, relation populate, and richtext target hydration.
  • Every admin webapp document-collection server fn (packages/host-tanstack-start/src/server-fns/collections/). Writes thread requestContext into DocumentLifecycleContext; ordinary reads delegate to the admin BylineClient so the same collection and row gates run.

assertAdminActor — admin management. Policy:

  • Always requires a present AdminAuth actor — no anonymous path.
  • Asserts the specific module ability: admin.users.*, admin.roles.*, admin.permissions.*, admin.activity.read.

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 exception is admin.activity.read: the activity area owns no AdminStore command (it reads the document db adapter's findAuditLog directly), so its assertAdminActor call lives in the host server fn getSystemActivityLog rather than in an @byline/admin command.

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/client read options skips beforeRead predicate 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_tokens for 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 in admin_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 runs for collection reads and for target collections reached through relation or richtext population. It receives the actor and read context and returns a QueryPredicate. Caller where and hook security predicates compile separately: caller input uses the ordinary parser, while the hook uses strict validation and compiles wholly to adapter DocumentFilters. Those two filter lists are then ANDed. 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 security-checked subset of WhereClause: scalar equality, $eq / $ne / $gt / $gte / $lt / $lte / $contains / $in / $nin, relation sub-clauses and quantifiers, and $and / $or combinators. Field names resolve through field-store-map. Reserved id, status, and path keys compile as document-column filters at the correct relation depth. For top-level security clauses, status accepts $eq / $ne / $in / $nin; path accepts those plus $contains. Because strict security compilation no longer passes through caller-only top-level scalar handling, these operators are enforced consistently on list, detail, populate, count, history, version, and tree reads.

The strict result is compiled once per logical ReadContext + client security domain + collection definition + effective read mode and held in private state bound to that request authority. The authority includes request id, locale, actor realm/id, super-admin state, and abilities; changing any of them fails closed. Promise sharing prevents concurrent populate/richtext branches from rerunning hooks or relation-id resolution, while same-collection and cyclic hook reads fail with ERR_READ_RECURSION instead of awaiting their own compilation. ReadContext.beforeReadCache remains only as a deprecated source-compatibility property: caller values are ignored and cannot seed authorization.

Wired into:

  • Every @byline/client read entry point, including search finishing, historical reads, counts, and document-tree reads.
  • Authenticated populateDocuments calls and richtext target hydration, before target adapter access.

Composition rules:

  • Hook predicate AND user where. The adapter applies the separately compiled filter lists with implicit AND. A user passing where: { status: 'draft' } against Recipe 1 (owner-only drafts) sees only their own drafts — both clauses apply.
  • Compilation stays separate. Permissive caller parsing cannot weaken, drop, or pre-seed strict hook filters; only the final adapter filter lists are combined.
  • void means "no scoping". Use it for the superuser / unconditional-read branch. Do not return an empty object {}; strict security-predicate parsing rejects it.
  • Deny with an empty set, not an invalid UUID or an exception. When the actor cannot read anything in a collection, return { id: { $in: [] } }. It compiles to an always-false predicate without asking Postgres to cast a sentinel string to UUID; throwing would collapse list endpoints instead of producing the natural empty result.
  • Security predicates are strict and fail closed. Unknown fields or operators, empty predicates/combinators, unsupported query, unresolved relation targets, and malformed reserved-column operands raise a validation error instead of being silently discarded. The deliberate exception is a relation quantifier such as $none: {}, whose empty nested clause means "no resolving targets."
  • Bypass is explicit. Admin tooling, migrations, and seeds pass _bypassBeforeRead: true on 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 afterRead to mutate doc.fields — see the next section. beforeRead is row-level only.
  • Computed-field filters. The predicate compiles against EAV store columns and the reserved document keys status, path, and id. Synthesise a real field if you need to filter on something derived.
  • Write-side checks. assertActorCanPerform already 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 runs on documents returned by @byline/client, including historical versions, tree hydration, relation targets, and richtext targets. The hook receives the document and the authenticated request context; mutations to doc.fields propagate back through the response. Recursive re-entry into an actively processing version fails closed rather than returning a potentially half-redacted object.

afterRead?: (ctx: {
doc: Record<string, any>
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 recursion limits and the authenticated operation stay coherent.

See Collections — 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

Repository route files live under apps/webapp/src/routes/_byline/<configured-admin-segment>/; for the default routes.admin: '/admin', that is 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. When the configured segment changes, the filesystem route subtree and every route-factory ID must change with it.

Admin/sign-in route safety contract. routes.signIn must be outside the configured admin and API trees. An unauthenticated admin layout redirects to configured routes.signIn with the original location in callbackUrl. The sign-in host accepts that untrusted value only when it is an unencoded root-relative path inside the configured admin subtree. It preserves a valid query string and hash, but rejects external or protocol-relative URLs, backslashes, control characters, encoded-path tricks, and paths outside the admin subtree. Rejected or missing callbacks fall back to the configured admin dashboard. Successful sign-in uses full-page navigation so the admin layout re-runs against the new session cookies.

Host integrations should pass the already-validated destination to SignInForm as redirectTo. Its callbackUrl prop remains deprecated compatibility only. Likewise, createAdminLayoutRoute(..., { signInPath }) and configureSignInRoutePath() are deprecated: configure routes.signIn instead, and any legacy override is accepted only when it resolves to exactly the configured path.

Area

Capability

sign-in

Password sign-in via JwtSessionProvider.signInWithPassword.

account/

Self-service profile + password change.

users/

List / create / edit / enable / disable admin users; assign roles; set password.

roles/

List / create / edit / reorder admin roles; member assignment.

permissions/

Read-only inspector — registered abilities, role-ability matrix, who-has-what lookup.

collections/

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-permissions.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

Table names

Tables below are shown unprefixed for readability. Live names carry the byline_ prefix (byline_admin_users, byline_admin_roles, …) per the Postgres adapter's namespacing convention — see packages/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/Internationalization
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

  1. Service-layer enforcement, not transport-layer enforcement. Auth gates live inside @byline/core / @byline/admin services. Transport edges (admin server fns, future HTTP endpoints) only resolve RequestContext and pass it down. This keeps the same gate active no matter which transport invokes the service.
  2. Flat abilities are the contract. Plugins register abilities; the role editor enumerates them; admin_permissions stores them as rows. Conditional rules live in hooks, not in the database.
  3. actor: null is a first-class case. Anonymous public readers are explicitly modelled. The null actor is permitted on read with readMode: 'published' and rejected everywhere else.
  4. 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.
  5. Reads go through @byline/client. Even from the admin webapp. This keeps beforeRead / afterRead orchestration uniform with future external readers and means access-control predicates apply once, in one place.
  6. 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 Actor union; the DB tables, sign-in flow, and admin UI wait for a concrete end-user feature.
  • Magic-link / SSO / OIDC providers. SessionProvider accommodates 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

packages/auth/src/actor.ts

RequestContext shape

packages/auth/src/context.ts

AbilityRegistry

packages/auth/src/abilities.ts

SessionProvider interface

packages/auth/src/session-provider.ts

AuthError factories

packages/auth/src/errors.ts

Document-collection enforcement

packages/core/src/auth/assert-actor-can-perform.ts

Admin-management enforcement

packages/admin/src/lib/assert-admin-actor.ts

beforeRead orchestration

packages/core/src/auth/apply-before-read.ts

QueryPredicate + combinators

packages/core/src/@types/query-predicate.ts

Predicate compiler

packages/core/src/query/parse-where.ts

Admin user / role / permission services

packages/admin/src/modules/admin-{users,roles,permissions,account}/

Built-in JWT session provider

packages/admin/src/modules/auth/jwt-session-provider.ts

Admin store aggregate

packages/admin/src/store.ts

Postgres admin repositories

packages/db-postgres/src/modules/admin/ (subpath: @byline/db-postgres/admin)

Admin schema + migration

packages/db-postgres/src/database/schema/auth.ts

Admin server-fn auth context resolver

packages/client/src/server/admin-context.ts (getAdminRequestContext, exported from @byline/client/server)

Admin server fns (auth)

packages/host-tanstack-start/src/server-fns/auth/

Admin server fns (management)

packages/host-tanstack-start/src/server-fns/admin-{users,roles,permissions,account}/

Admin route factories

packages/host-tanstack-start/src/routes/create-admin-*-route.tsx

Route config normalization

packages/core/src/config/routes.ts

Admin paths + callback validation

packages/host-tanstack-start/src/routes/admin-path.ts

Canonical sign-in path

packages/host-tanstack-start/src/routes/sign-in-path.ts

Sign-in form redirect validation

packages/admin/src/modules/auth/safe-redirect.ts

Admin UI route shells

apps/webapp/src/routes/_byline/<configured-admin-segment>/

Super-admin seed

apps/webapp/byline/seeds/admin.ts

Integration test for beforeRead

packages/client/tests/integration/client-before-read.integration.test.ts

ZurückAuth & Security
WeiterAuditability

Auf dieser Seite

  • Overview
  • Quick reference
  • 1. Register a new ability
  • 2. Assert an ability at a service-layer call site
  • 3. Resolve RequestContext in a script or seed
  • 4. Recipe — owner-only drafts
  • 5. Recipe — multi-tenant scoping
  • 6. Recipe — embargo / scheduled publish
  • 7. Recipe — soft-delete hide
  • 8. Recipe — department / workspace visibility
  • 9. Recipe — self-only on user-like collections
  • 10. Mask or redact a field on read (afterRead)
  • 11. Bypass beforeRead (escape hatch)
  • 12. Plug in a different SessionProvider
  • Architecture
  • Actors and RequestContext
  • Abilities
  • Two-layer access control
  • The enforcement boundary
  • The documented escape hatches
  • Sessions — SessionProvider interface
  • Read-side scoping — the beforeRead hook
  • Field-level redaction with afterRead
  • Admin UI surface
  • Data model
  • Architectural rules
  • Explicitly deferred
  • Code map
Byline CMS

Wir bauen die Zukunft des Content-Managements — Commit für Commit.

Projekt

  • Dokumentation
  • Roadmap
  • Mitwirken
  • Releases

Community

  • GitHub Discussions
  • Blog
  • Newsletter

Rechtliches

  • Datenschutzerklärung
  • Nutzungsbedingungen
  • Cookies

© 2026 Infonomic Company Limited und Mitwirkende. Open Source und mit ❤️ von der Community entwickelt.