@plakboek/auth
v0.6.0
Published
better-auth wiring, invite, audit and impersonation primitives for Plakboek CMS installations
Readme
@plakboek/auth
better-auth wiring, invite, audit and impersonation primitives for Plakboek CMS installations: session issuance/validation, email/password with a length-only strength rule, two-factor authentication (TOTP and email OTP), magic links, audited impersonation, and the audit-log hook every permission-gated mutation writes through.
Install
pnpm add @plakboek/authStatus
The Phase 2 (Authentication, Sessions & Audit Trail) surface is complete. Everything a host needs is exported from the package entry point and listed below. Routes, screens and the bootstrap wizard that call these functions belong to the host and to later phases.
Public API
Every export of @plakboek/auth, grouped the way src/index.ts groups
them. tests/unit/public-api.test.ts compares these tables with the
entry point, so an export cannot be added or removed without updating
them.
Nothing else is reachable from the entry point. In particular the token
value generator, the stored-identifier builder, the advisory lock keys and
the schema tables stay internal: with them a consumer could write a
verification, session or audit_log row around the issue, consume and
audited paths described here.
Configuration and policy
| Export | Kind | Purpose |
| ----------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| createAuth | function | Builds the better-auth instance for one installation from injected options: sliding sessions, audited two-factor, magic links, closed routes and the password floor |
| AuthConfigError | class | Thrown by createAuth for an invalid option; names the option, never the value |
| SESSION_EXPIRES_IN_SECONDS | constant | 2592000 (30 days): how long a session lives after its last refresh |
| SESSION_UPDATE_AGE_SECONDS | constant | 86400 (one day): how old a session must be before a request slides its expiry |
| IMPERSONATION_SESSION_TTL_SECONDS | constant | 28800 (8 hours): the fixed lifetime of an impersonation session, which never slides |
| SET_PASSWORD_TOKEN_TTL_SECONDS | constant | 172800 (48 hours): lifetime of set-password and reset-password links |
| MAGIC_LINK_TTL_SECONDS | constant | 900 (15 minutes): lifetime of a magic link |
| TWO_FACTOR_CODE_TTL_SECONDS | constant | 300 (5 minutes): lifetime of an emailed second-factor code |
| TWO_FACTOR_LOCKOUT | constant | Frozen { maxFailedAttempts: 5, durationSeconds: 900 }, one counter shared by authenticator and emailed codes |
| DISABLED_AUTH_PATHS | constant | Frozen list of better-auth HTTP routes that answer 404; the full list and the reason are under the security notes |
| Auth | type | The better-auth instance createAuth returns |
| CreateAuthOptions | type | db, baseURL, secret, mail, roles, and optionally appName, minPasswordLength, renderEmail, onMailDeliveryError, onAuditWriteFailed, rateLimitEnabled, trustedProxies, ipAddressHeaders |
| RenderAuthEmail | type | A template renderer a host can pass as renderEmail to theme the emails |
Passwords
| Export | Kind | Purpose |
| ---------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| PASSWORD_MIN_LENGTH | constant | 12: the floor, counted in code points, with no composition rules; createAuth's minPasswordLength may raise it but never lower it |
| assertPasswordPolicy | function | Throws PasswordPolicyError for a password below PASSWORD_MIN_LENGTH, or below a higher minLength passed as the second argument; a non-integer minLength throws TypeError |
| PasswordPolicyError | class | Carries minLength, the minimum that was enforced; never the rejected password |
Users
| Export | Kind | Purpose |
| ---------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| createUserWithRole | function | The one user-creation primitive: the first user on an empty table becomes superadmin, and the address is stored trimmed and lower-cased |
| SUPERADMIN_ROLE_KEY | constant | 'superadmin' |
| CreateUserInput | type | id, email, name, roleKey, and an optional better-auth passwordHash that also creates a credential account |
| CreateUserOptions | type | An optional clock |
| CreateUserResult | type | userId, the stored roleKey, and wasFirstUser |
| UserCreationExecutor | type | The database or an enclosing transaction |
Audit log
| Export | Kind | Purpose |
| ------------------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| createAuditRecorder | function | Binds database, permission resolver, failure hook and clock, and returns an AuditRecorder: the single call site for gated mutations |
| runAuditedMutation | function | The unbound form of run: permission check, mutation and audit row in one transaction |
| PermissionDeniedError | class | Thrown once a refusal has been recorded; carries the permission and role key only |
| AfterCommitTimeoutError | class | Reported through onAfterCommitFailed when a callback has not settled within afterCommitTimeoutMs |
| AfterCommitRegistrationError | class | Thrown by afterCommit when the recorder is bound to a transaction or the mutation already returned |
| AuditWriteError | class | The audit row could not be written, so the mutation was rolled back; a self-service change was already made and is not undone |
| AfterCommitCallback | type | Work registered through afterCommit; runs once the transaction has committed |
| AfterCommitFailure | type | What onAfterCommitFailed receives: identifiers and the callback's error, never the payload |
| AfterCommitFailureHook | type | Called once per after-commit callback that threw or rejected |
| AuditActor | type | The acting user's id and role key, and impersonatedBy while impersonating |
| AuditDatabase | type | Any Drizzle Postgres handle that can open a transaction |
| AuditDeps | type | db, resolver, and optionally onAuditWriteFailed, onAfterCommitFailed, afterCommitTimeoutMs and now |
| AuditEntryInput | type | Permission, action, entity and the before and after states of one audit row |
| AuditFailureHook | type | Called once per failed audit write, after the rollback |
| AuditMutationContext | type | The second argument a mutation receives: afterCommit(callback) |
| AuditRecorder | type | run(actor, entry, mutation) for gated mutations, recordDenied(actor, entry) for a refusal made for another reason, and recordSelfService(actor, entry) for a user's change to their own account, stored with permission 'self' |
| AuditTransaction | type | The transaction a mutation runs in |
| AuditWriteFailure | type | What the failure hook receives: identifiers and the error, never the payload |
| AuditedMutation | type | A mutation that runs inside the audit transaction, receives (tx, context) and returns { result, after } |
After-commit work
A mutation can register work that must only happen once its change is durable, such as purging a served cache, through the second argument it receives:
await recorder.run(actor, entry, async (tx, context) => {
// ...write through tx...
context.afterCommit(async () => {
await purge();
});
return { result };
});The rules: callbacks run only after the transaction has committed, in
registration order, one at a time, and are awaited before run resolves.
They never run when the permission is denied, the mutation throws, the audit
row cannot be written, or the transaction rolls back. A callback that throws
or rejects never fails or alters the committed mutation: the failure is
reported once through onAfterCommitFailed (default: one console.error
line with the action, entity type and error names only; for a composed cache
purge that includes how many layers failed and which). Each callback is also
bounded by afterCommitTimeoutMs (default 10 seconds, Infinity for no
limit): one that has not settled by then is reported as an
AfterCommitTimeoutError and run stops waiting for it, so a hung downstream
cannot hang the request. The abandoned callback keeps running and nothing
retries it: a layer that must not stay stale after a failed or timed-out purge
needs a host-level retry or outbox. afterCommit throws
AfterCommitRegistrationError when the recorder is bound to a transaction
(the commit would not be its own) or when called after the mutation returned.
Audit payload redaction
| Export | Kind | Purpose |
| --------------------------- | -------- | --------------------------------------------------------------- |
| REDACTED_KEYS | constant | Frozen, normalised keys whose values never reach an audit row |
| REDACTION_MARKER | constant | '[redacted]', written in place of such a value |
| redactAuditPayload | function | Returns the redacted copy of a payload that the recorder stores |
| RedactAuditPayloadOptions | type | An optional maxDepth |
Retention prune
| Export | Kind | Purpose |
| ---------------------- | -------- | ---------------------------------------------------------------------------------------------------------- |
| pruneAuditLog | function | Deletes audit rows older than the retention window and returns how many; the host schedules it (see below) |
| AUDIT_RETENTION_DAYS | constant | 365 |
| PruneAuditLogOptions | type | Optional retentionDays and now |
Single-use tokens
| Export | Kind | Purpose |
| ---------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| issueSingleUseToken | function | Issues a token for a subject and purpose, deleting every earlier token of that purpose for that subject; stores only a SHA-256 digest |
| consumeSingleUseToken | function | Deletes the token and runs the action it authorises in one transaction; if the action throws, the token stays valid |
| InvalidOrExpiredTokenError | class | One error for a never-issued, consumed or expired token; carries the purpose only |
| TOKEN_PURPOSES | constant | Frozen ['set-password', 'reset-password', 'magic-link'] |
| TokenPurpose | type | One of TOKEN_PURPOSES |
| TokenDatabase | type | The database or an enclosing transaction |
| TokenTransaction | type | The transaction an authorised action runs in |
| IssueSingleUseTokenInput | type | subjectId, purpose, ttlSeconds |
| ConsumeSingleUseTokenInput | type | purpose, token |
| TokenClockOptions | type | An optional clock |
| AuthorizeWithToken | type | The action a consumed token authorises, given the transaction and the subject id |
Redirect targets
| Export | Kind | Purpose |
| ------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| sanitizeNextPath | function | The one open-redirect guard for a next value: returns a same-origin relative path (query kept, fragment and every _edit parameter dropped), or / for anything else |
sanitizeNextPath answers / for a non-string, an empty or over-long
(more than 2048 characters) value, anything not starting with a single /
(absolute, protocol-relative and scheme URLs), backslashes and control
characters (raw or percent-encoded), and any target under /cms or /api
in any case or encoding. Runs of slashes in the path collapse to one.
Password links
| Export | Kind | Purpose |
| ---------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| requestPasswordLink | function | Emails a set-password or reset-password link; the same work and the same result for any address, and the send is not awaited |
| completeSetPassword | function | Checks the password against the minimum createAuth configured, then in one transaction consumes the link, writes the credential, revokes sessions and other links, and writes a credential.set row |
| CredentialWriteError | class | The credential write failed; carries error types and SQLSTATE and deliberately has no cause, which would quote the password hash |
| PASSWORD_LINK_PATHS | constant | Frozen landing paths /cms/set-password and /cms/reset-password; the token travels in ?token= |
| CREDENTIAL_SET_ACTION | constant | 'credential.set' |
| PasswordLinkPurpose | type | 'set-password' or 'reset-password' |
| PasswordLinkRequestOutcome | type | { delivered: true }, the only outcome |
| PasswordLinkProbe | type | Test observation points fired on both the known-address and unknown-address branch |
| RequestPasswordLinkDeps | type | db, mail, baseURL, and optionally renderEmail, onDeliveryError, probe |
| RequestPasswordLinkInput | type | email, purpose, and optionally next (the page to return to, sanitised by sanitizeNextPath) |
| CompleteSetPasswordDeps | type | db, auth (better-auth's hasher and the configured minPasswordLength), and optionally onAuditWriteFailed, now |
| CompleteSetPasswordInput | type | purpose, token, newPassword |
Invitations
| Export | Kind | Purpose |
| --------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| inviteUser | function | Needs users:create. Creates the user with no credential through createUserWithRole and emails a set-password link after the commit |
| resendSetPasswordLink | function | Needs users:create. Issues a fresh link, a reset-password link once a password exists, which ends the previous one |
| InviteDeliveryError | class | The user and link are committed but the message was not delivered; carries userId and recipientDomain |
| InvalidInviteError | class | Invalid invite input; carries the field only |
| InviteWriteError | class | A database failure inside an invite; carries error types and SQLSTATE, no cause |
| USER_INVITE_ACTION | constant | 'user.invite' |
| USER_RESEND_SET_PASSWORD_ACTION | constant | 'user.resend-set-password' |
| InviteDeps | type | recorder (bound to the database, not a transaction), mail, baseURL, and optionally renderEmail |
| InviteUserInput | type | email, name, roleKey, actor, and an optional id for a bootstrap where the invitee is also the actor |
| InviteUserResult | type | userId, wasFirstUser |
| ResendSetPasswordLinkInput | type | email, actor |
| InviteField | type | 'email', 'name', 'roleKey' or 'id' |
Impersonation
| Export | Kind | Purpose |
| ----------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| startImpersonation | function | Audited start from the request headers: needs users:impersonate, refuses superadmin and self targets, returns sessionToken, expiresAt and responseHeaders |
| stopImpersonation | function | Audited stop from the request headers; restores the impersonating user's own session and returns the same shape |
| assertImpersonationTargetAllowed | function | Throws ImpersonationTargetForbiddenError for a superadmin or self target |
| auditActorFromSession | function | The audit actor for a session: the impersonated user acts and the impersonating user is recorded as impersonatedBy |
| ImpersonationTargetForbiddenError | class | Carries targetUserId and reason |
| ImpersonationSessionError | class | The presented session cannot start or stop: no-session, not-impersonating or already-impersonating |
| ImpersonatableSession | type | userId, roleKey, and impersonatedBy while impersonating |
| ImpersonationParty | type | A user id and role key |
| ImpersonationRefusalReason | type | 'target-is-superadmin' or 'target-is-self' |
| ImpersonationDeps | type | auth, recorder, db |
| ImpersonationSessionResult | type | sessionToken, expiresAt, responseHeaders |
| ImpersonationSessionRefusalReason | type | 'no-session', 'not-impersonating' or 'already-impersonating' |
Two-factor gate
| Export | Kind | Purpose |
| --------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------- |
| assertTwoFactorSatisfied | function | Throws TwoFactorEnrolmentRequiredError when the subject's role requires a second factor that is not enrolled |
| needsTwoFactorEnrolment | function | The same decision as a boolean |
| isTwoFactorRequiredForRole | function | Whether a role key requires a second factor; only superadmin does |
| TWO_FACTOR_REQUIRED_ROLE_KEYS | constant | Frozen ['superadmin'] |
| TWO_FACTOR_ENROLMENT_PATH | constant | /cms/two-factor/enrol, where a gated user is sent; the enrolment step lives in the login modal served there |
| TwoFactorEnrolmentRequiredError | class | Carries redirectTo and roleKey only |
| TwoFactorSubject | type | The role key and twoFactorEnabled flag, read from the session's user row |
Mail senders
| Export | Kind | Purpose |
| ---------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------ |
| createMailSender | function | Returns { sender, kind }: the SMTP sender for a non-blank host, otherwise the console sender, which production refuses |
| createSmtpSender | function | A pooled SMTP sender with mandatory TLS and bounded timeouts |
| createConsoleSender | function | A development sender that prints each message; throws when NODE_ENV is production |
| MailSenderConfigError | class | Invalid sender options; names the field, never the value |
| SmtpOptions | type | host, and optionally port, secure, user, pass |
| SmtpTransport | type | The transport a sender sends through |
| SmtpTransportConfig | type | The exact configuration handed to the transport factory |
| SmtpTransportMessage | type | One message as handed to the transport |
| CreateSmtpTransport | type | A transport factory; tests inject a fake so no socket opens |
| CreateSmtpSenderOptions | type | smtp, from, and optionally createTransport |
| CreateConsoleSenderOptions | type | from, and optionally nodeEnv, write |
| CreateMailSenderOptions | type | Optional smtp, from, and optionally nodeEnv, write, createTransport |
| MailSenderKind | type | 'smtp' or 'console' |
Email templates
| Export | Kind | Purpose |
| ------------------ | -------- | ------------------------------------------------------------------------------------------------------------ |
| renderAuthEmail | function | Renders a set-password, reset-password, magic-link or two-factor-code message with html and plain-text parts |
| AUTH_EMAIL_KINDS | constant | Frozen list of those four kinds |
| AuthEmailKind | type | One of AUTH_EMAIL_KINDS |
Mail contract
| Export | Kind | Purpose |
| --------------- | ----- | ---------------------------------------------------------- |
| MailSendError | class | A failed send; names only the recipient's domain |
| MailMessage | type | to, subject, html, text |
| MailSender | type | send(message), rejecting with MailSendError on failure |
Configuration
The host supplies these environment variables. The package reads none of
them from the ambient environment: the host reads its own environment and
passes plain values to createAuth and createMailSender. The only
ambient read in the package is NODE_ENV, and only when
createMailSender or createConsoleSender is called without nodeEnv.
| Variable | Passed as | Notes |
| ---------------------- | ---------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| BETTER_AUTH_SECRET | createAuth({ secret }) | At least 32 characters; signs session cookies |
| BETTER_AUTH_URL | createAuth({ baseURL }), and baseURL of the link flows | Absolute URL of the installation; links are built against it |
| PLAKBOEK_SMTP_HOST | createMailSender({ smtp: { host } }) | Empty selects the console sender outside production and fails at boot in production |
| PLAKBOEK_SMTP_PORT | smtp.port, as a number | Defaults to 587 (STARTTLS required), or 465 when secure is true |
| PLAKBOEK_SMTP_SECURE | smtp.secure, as a boolean | true only for implicit TLS on 465; the string 'false' is rejected, so parse it |
| PLAKBOEK_SMTP_USER | smtp.user | Supplied together with the password, or both omitted for an unauthenticated relay |
| PLAKBOEK_SMTP_PASS | smtp.pass | See above |
| PLAKBOEK_MAIL_FROM | createMailSender({ from }) | One address the SMTP account may send as |
import { createAuth, createMailSender, type SmtpOptions } from '@plakboek/auth';
import { createDb } from '@plakboek/db';
import { defaultRoles, defineRoles } from '@plakboek/permissions';
const env = process.env;
const smtp: SmtpOptions = {
host: env.PLAKBOEK_SMTP_HOST ?? '',
port: Number(env.PLAKBOEK_SMTP_PORT ?? '587'),
secure: env.PLAKBOEK_SMTP_SECURE === 'true',
...(env.PLAKBOEK_SMTP_USER
? { user: env.PLAKBOEK_SMTP_USER, pass: env.PLAKBOEK_SMTP_PASS ?? '' }
: {}),
};
const { sender, kind } = createMailSender({
smtp,
from: env.PLAKBOEK_MAIL_FROM ?? '',
});
console.info(`mail sender: ${kind}`); // 'smtp' or, outside production, 'console'
const database = createDb({ connectionString: env.DATABASE_URL ?? '' });
const auth = createAuth({
db: database.db,
baseURL: env.BETTER_AUTH_URL ?? '',
secret: env.BETTER_AUTH_SECRET ?? '',
mail: sender,
roles: defineRoles(defaultRoles),
// reportToMonitoring stands for the host's own error reporting.
onMailDeliveryError: (error) => reportToMonitoring(error),
});Password-link endpoints
createAuth also serves the two HTTP endpoints the login modal needs, under
the same base path as every other auth route (/api/auth when mounted
there). Both are POST, both take JSON, and both refuse a foreign Origin
the way better-auth's own sign-in does.
| Endpoint | Body | Answers | Limit |
| ---------------------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- |
| /plakboek/password-link/request | { email, next? } | 200 { status: true } for every well-formed body, registered address or not; 400 INVALID_REQUEST for a malformed one. Mails a reset-password link carrying the sanitised next | 3 per 60 s per client |
| /plakboek/password-link/complete | { purpose, token, newPassword } | 200 { status: true }; 400 INVALID_LINK for an expired, used, unknown or malformed token (one answer); 400 PASSWORD_TOO_SHORT, leaving the link usable; 400 INVALID_REQUEST | 10 per 60 s per client |
Set-password links are issued by invitations only, never by the request
endpoint. A link carries ?token= and, when one was given and survives
sanitizeNextPath, &next=; without a usable next it lands home.
The limits are better-auth's limiter, which is on in production and off
elsewhere. Pass createAuth({ rateLimitEnabled: true }) to switch it on in
development or test, or false to switch it off. The default in-memory
store is per process and keyed by the client address better-auth can
resolve. By default that address is read from X-Forwarded-For, and only when
the header holds one address: a header with several (a proxy that appends to
what the client sent) has no client address, and every such client then shares
one allowance per path, so one caller can lock everyone out of "forgot
password" and sign-in. A host directly exposed has the opposite problem: a
client can pick its own allowance by sending its own header. A host behind a
proxy should therefore pass createAuth({ trustedProxies: ['172.18.0.0/16'] })
with the address or range of its proxies, so the client is the first hop that is
not one of them, or ipAddressHeaders: ['CF-Connecting-IP'] when the proxy sets
a header of its own and always overwrites it. List only proxies clients cannot
bypass. The limit is per client and path, not per email address: an attacker
with many addresses can still send many reset emails to one mailbox, one
request per allowance, so edge limiting is worth adding for a shared deployment.
Magic links and two-factor
A magic link is a one-step sign-in, and better-auth's two-factor plugin challenges only the password sign-in endpoints. So a magic link is never sent to a user who has two-factor enabled (the request answers exactly as it does for an unknown address), and a link issued before the user enrolled creates no session when followed: the verify call answers its normal error redirect and sets no cookie. The role gate in the two-factor section is unchanged.
Mail delivery
Mail goes out over SMTP only, so any SMTP server works:
- a dedicated mailbox on the customer's own mail server, or
- the SMTP endpoint of an EU transactional relay, for example Lettermint (NL) or Scaleway Transactional Email (FR).
TLS is mandatory. Port 587 requires STARTTLS and port 465 uses implicit TLS; there is no plaintext option, so a server that cannot upgrade fails the send. The sending domain needs SPF, DKIM and DMARC records for the chosen server, or messages will land in spam or be refused.
An empty PLAKBOEK_SMTP_HOST selects the console sender, which prints
each message, link included, for local development. In production that
same configuration throws MailSenderConfigError when the sender is
created, so a missing mail setup fails at boot rather than silently.
Mail on unauthenticated paths is fire-and-forget. A magic-link request and a password-reset request dispatch the send without awaiting it, so the response time cannot reveal whether an address is registered, and the requester never learns of a delivery failure. The host does, through a hook:
createAuth'sonMailDeliveryErrorreceives failed magic-link and emailed second-factor sends;requestPasswordLink'sonDeliveryErrorreceives failed set-password and reset-password link sends.
Both default to one console.error line naming the error type and the
recipient's domain. Invitations and resends are privileged, so they await
the send and throw InviteDeliveryError instead.
Security notes
Properties a host must not undo
- The audit row shares the mutation's transaction. Route every
permission-gated mutation through
AuditRecorder.run. If the audit row cannot be written, the mutation rolls back. Never write toaudit_logdirectly and never catchAuditWriteErrorto carry on. - Only the most recently issued link of a purpose is valid. Issuing a set-password or reset-password link deletes every earlier one of that purpose for that user, so a resend invalidates the previous email. Tokens are stored as SHA-256 digests; magic-link tokens and emailed codes are stored hashed too.
- Consuming a link and performing the action it authorises share one
transaction.
completeSetPasswordconsumes the link, writes the credential, revokes every session of the user and every impersonation they started, ends their other outstanding links and writes the audit row together. Never call better-auth'sauth.api.resetPasswordorauth.api.requestPasswordReset, even server-side: they spend a token and write a password in separate steps. - Superadmin accounts require a second factor before reaching any
protected route. Every protected loader calls
assertTwoFactorSatisfiedwith the role key andtwo_factor_enabledfrom the session's user row, never from client input, and redirects to the error'sredirectTo.
Closed HTTP routes
DISABLED_AUTH_PATHS lists better-auth routes that answer 404 to any HTTP
request, whatever its method, trailing slashes or query string. Each one
would let a caller reach an action around a guarantee this package builds:
/sign-up/email/admin/ban-user/admin/create-user/admin/get-user/admin/has-permission/admin/impersonate-user/admin/list-user-sessions/admin/list-users/admin/remove-user/admin/revoke-user-session/admin/revoke-user-sessions/admin/set-role/admin/set-user-password/admin/stop-impersonating/admin/unban-user/admin/update-user/request-password-reset/reset-password/reset-password/:token/change-password/update-user/change-email/delete-user/delete-user/callback
Why they are closed:
- Sign-up. Users are created only by the first-user rule and by invitation, never by self-registration.
- Every admin-plugin route. Creating, updating, banning or removing a
user, setting a role or password, and listing or revoking sessions over
these routes would skip the first-user rule, the audit log and the
set-password flow. The plugin's role map grants its impersonate statement
only to roles holding
users:impersonate, and no user-management statement to any role; closing the routes means a later change to that map cannot reopen them. Impersonation runs only through the auditedstartImpersonationandstopImpersonation, which call the plugin throughauth.api. - The built-in reset routes. Set and reset run only through the transactional single-use-token flow above.
- Self-service account routes. Changing your own password, name or
address, or deleting your own account, would write no audit row; self-service
account changes arrive audited in Phase 11. Do not call their
auth.apicounterparts either.
The :token segment is enforced by this package's own request guard, since
better-auth's disabledPaths compares paths literally. A host that mounts
auth.handler needs to do nothing extra. Server-side auth.api calls are
not affected.
Host responsibilities
- Resending needs
users:create.resendSetPasswordLinkchecks the same permission asinviteUser;users:reset-passwordalone is not enough. A role that should resend links needsusers:create. - Create every user through
createUserWithRoleorinviteUser. They apply the first-user rule and store the address trimmed and lower-cased, which is the form sign-in, link requests and invitations look up. A seed that inserts user rows any other way must storeemail.trim().toLowerCase()itself, or those users cannot be found and may be created twice. - Bootstrap the first user with an explicit id. On an empty
installation no other user can be the audit actor, so call
inviteUserwithidset andactornaming that same id assuperadmin. - Bind the invite recorder to the database.
inviteUserandresendSetPasswordLinksend after their transaction commits; a recorder bound to an enclosing transaction would send before that commit. - Impersonation lapses after 8 hours. An impersonation session expires
IMPERSONATION_SESSION_TTL_SECONDSafter it starts unless it is stopped sooner, whatever cookies later requests carry; ordinary sessions keep sliding.startImpersonationandstopImpersonationtake the request headers, and the route must forward theirresponseHeadersto the browser, or the stop cannot find the impersonating user's session. Never extend a session'sexpiresAtthrough better-auth's adapter directly. users:impersonateis what lets a role impersonate. The shipped roles grant it only tosuperadmin. A host role given it can start and stop impersonations throughstartImpersonationandstopImpersonation, every row names both people (D-11), and a superadmin is never a target (D-13). A role that should never act as another user must not hold it.- Report mail delivery failures. Unauthenticated sends are
fire-and-forget (see Mail delivery); pass
onMailDeliveryErrorandonDeliveryErrorso failures reach monitoring. - Report two-factor audit failures. Enabling and disabling two-factor
stay open over HTTP. Each successful call writes a
two-factor.enabledortwo-factor.disabledrow withpermission'self', after the plugin has saved the change. A failed row cannot undo that change: the call answersAUDIT_WRITE_FAILEDandcreateAuth'sonAuditWriteFailedreceives the failure, so pass it. - Schedule the audit log prune. The package never runs
pruneAuditLogitself; see the next section. - Rate-limit the unauthenticated endpoints. The password-link
endpoints and magic-link requests are throttled by better-auth's limiter
in production (see Password-link endpoints); sign-in is throttled by its
default rule. Add edge limiting in front of both for a shared deployment,
and set
trustedProxies(oripAddressHeaders) so the limiter can tell clients apart behind your proxy.
Audit log retention
Every permission-gated mutation writes one row to audit_log. Rows are
kept for one year (AUDIT_RETENTION_DAYS, 365 days). pruneAuditLog
enforces that window. It runs one DELETE that removes every row created
strictly before now minus the window, and returns how many rows it
deleted. A row exactly at the cutoff is kept. The window can be passed as
retentionDays; anything other than a positive whole number of days
throws before a statement runs, so a bad value can never empty the table.
const deleted = await pruneAuditLog(db); // rows older than 365 daysThe host calls it on a schedule. The package never does. Once a day is enough: each run then deletes about one day's worth of rows. Two ways to schedule it, once the hosting choice is made:
A scheduled CI workflow that runs a small script from the host repo. This fits when the database accepts connections from the CI runner.
// scripts/prune-audit-log.ts import { pruneAuditLog } from '@plakboek/auth'; import { createDb } from '@plakboek/db'; const database = createDb({ connectionString: process.env.DATABASE_URL ?? '', }); try { const deleted = await pruneAuditLog(database.db); console.log(`audit log: pruned ${deleted} rows`); } finally { await database.close(); }# .github/workflows/prune-audit-log.yml name: Prune audit log on: schedule: - cron: '17 3 * * *' workflow_dispatch: jobs: prune: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v5 with: node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - run: node scripts/prune-audit-log.ts env: DATABASE_URL: ${{ secrets.DATABASE_URL }}A systemd timer on the server that runs the same script. This fits when the database is only reachable from that machine.
# /etc/systemd/system/plakboek-audit-prune.service [Unit] Description=Prune Plakboek audit log rows past the retention window [Service] Type=oneshot WorkingDirectory=/srv/site EnvironmentFile=/srv/site/.env ExecStart=/usr/bin/node scripts/prune-audit-log.ts# /etc/systemd/system/plakboek-audit-prune.timer [Unit] Description=Run the Plakboek audit log prune daily [Timer] OnCalendar=daily RandomizedDelaySec=1h Persistent=true [Install] WantedBy=timers.targetEnable it with
systemctl enable --now plakboek-audit-prune.timer.
This package deliberately ships no scheduler, no queue and no job registry:
pruneAuditLog is its only retention entry point. Choosing what calls it
is a hosting decision for Phase 6, not a gap in Phase 2.
Requirement coverage
Each Phase 2 requirement and the automated cases that prove it. Paths are
relative to packages/auth. Two commands run them, from the repository
root:
- unit:
pnpm --filter @plakboek/auth run test - integration:
docker compose up -d --wait postgres, thenTEST_DATABASE_URL=postgres://postgres:[email protected]:54329/postgres pnpm --filter @plakboek/auth run test:integration
| Requirement | Test file | Case | Command |
| ----------- | --------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ----------- |
| AUTH-01 | tests/integration/tracer-first-user-login-audit.test.ts | runs the whole path against a real Postgres (the first user resolves to every catalogue permission) | integration |
| AUTH-01 | tests/integration/tracer-first-user-login-audit.test.ts | grants superadmin to exactly one of two concurrent first-user creations | integration |
| AUTH-01 | tests/integration/invite.test.ts | forces superadmin on the first user of an empty installation, whatever role was asked for | integration |
| AUTH-02 | tests/integration/invite.test.ts | creates the user with the requested role and mails one link that sets a password | integration |
| AUTH-02 | tests/integration/invite.test.ts | keeps the user and the link when delivery fails, and a resend recovers | integration |
| AUTH-02 | tests/unit/invite-guards.test.ts | refuses a caller without users:create, records the denial and creates nothing | unit |
| AUTH-03 | tests/integration/invite.test.ts | stops completing once the link is 48 hours old, and completes at 47 hours | integration |
| AUTH-03 | tests/integration/set-password.test.ts | spends the link on success, so presenting it again is rejected | integration |
| AUTH-03 | tests/unit/token-expiry.test.ts | rejects a token at the exact instant of expiry | unit |
| AUTH-04 | tests/integration/invite.test.ts | invalidates the previously issued link, leaving exactly one valid link | integration |
| AUTH-04 | tests/integration/invite.test.ts | issues a reset-password link, recorded in the audit log, for a user who already set a password | integration |
| AUTH-04 | tests/integration/invite.test.ts | lets a role holding only users:create resend, with an allowed row naming that permission | integration |
| AUTH-05 | tests/integration/set-password.test.ts | resets a password through the emailed link, and the new password signs in | integration |
| AUTH-05 | tests/integration/reset-timing.test.ts | does the same counted work for a known and an unknown address | integration |
| AUTH-05 | tests/unit/credentials-timing.test.ts | resolves for a known address even when the send never settles | unit |
| AUTH-06 | tests/unit/password-policy.test.ts | rejects one character below the minimum and accepts exactly the minimum | unit |
| AUTH-06 | tests/integration/set-password.test.ts | rejects a password below the minimum before spending the link | integration |
| AUTH-06 | tests/integration/session-persistence.test.ts | rejects a short new password on change and on reset, without spending the reset link | integration |
| AUTH-06 | tests/integration/set-password.test.ts | enforces the raised minimum when a link completes, and spends the link only at that minimum | integration |
| AUTH-07 | tests/integration/two-factor.test.ts | enables an emailed code: exactly one message, and its code verifies | integration |
| AUTH-07 | tests/integration/two-factor.test.ts | locks after five failures mixed across both factors and unlocks on its own | integration |
| AUTH-08 | tests/integration/two-factor.test.ts | enrols an authenticator app: its code verifies, a code from another secret does not | integration |
| AUTH-08 | tests/unit/two-factor-gate.test.ts | throws TwoFactorEnrolmentRequiredError for an unenrolled superadmin | unit |
| AUTH-09 | tests/integration/magic-link.test.ts | delivers one link to an existing user that signs them in | integration |
| AUTH-09 | tests/integration/magic-link.test.ts | accepts a link just inside fifteen minutes and rejects one just past it | integration |
| AUTH-09 | tests/integration/magic-link.test.ts | answers an unknown address in the same shape, sends nothing and creates nobody | integration |
| AUTH-10 | tests/integration/session-persistence.test.ts | survives a refresh: an independent instance resolves the same user from the cookie | integration |
| AUTH-10 | tests/integration/session-persistence.test.ts | slides the expiry forward only once the update age has passed | integration |
| AUTH-11 | tests/integration/single-use.test.ts | lets exactly one of two concurrent consumptions authorise | integration |
| AUTH-11 | tests/integration/single-use.test.ts | keeps the token valid when the authorised action fails, and propagates its error | integration |
| AUTH-11 | tests/integration/set-password.test.ts | keeps the link and writes no credential when the credential write fails | integration |
| USER-08 | tests/integration/audit-log.test.ts | records a granted mutation with its actor, permission, entity and after state | integration |
| USER-08 | tests/integration/audit-log.test.ts | leaves the entity untouched when permission is denied and records the refusal | integration |
| USER-08 | tests/integration/impersonation.test.ts | records an action taken while impersonating with both identities on one row | integration |
| USER-08 | tests/integration/impersonation.test.ts | lets a host role holding users:impersonate start and stop, recording both rows | integration |
| USER-08 | tests/integration/invite.test.ts | writes one allowed row per invite and resend, naming the acting superadmin and no address or token | integration |
| USER-08 | tests/integration/set-password.test.ts | writes one audit row naming the user whose credential changed | integration |
| USER-08 | tests/integration/two-factor.test.ts | writes one two-factor.enabled row when an authenticator is enabled, with no secret in it | integration |
The five Phase 2 success criteria map onto those rows: the first user holds every permission (AUTH-01); an invited user's link works once and stops after use or 48 hours, with a superadmin able to resend (AUTH-02, AUTH-03, AUTH-04, AUTH-11); a user signs in with password plus second factor or with a magic link and stays signed in across a refresh (AUTH-07, AUTH-08, AUTH-09, AUTH-10); a reset completes through an emailed link and a password failing the strength rule is rejected where it is set (AUTH-05, AUTH-06); every data-changing action writes an audit record naming the acting user (USER-08).
