ObjectStackObjectStack

services.audit — the audit log service API

Write ingress for audit events that are not CRUD — today, auth session events and sign-in outcome events on an account.

  • Stability: experimental
  • Canonical source: packages/plugins/plugin-audit/src/auth-event-audit.ts
  • Registry slot: audit — resolve with ctx.getService('audit').

services.audit is the kernel service slot that @objectstack/plugin-audit registers during init(). It is the write ingress for audit events that are not CRUD — today, auth session events and sign-in outcome events on an account — and every call appends one row to sys_audit_log.

Record-level create / update / delete rows do not travel through this slot. The same plugin writes those from ObjectQL lifecycle hooks, with no service call involved, so resolving this service is only ever necessary for events the CRUD lifecycle cannot see.

Record views do not travel through it either. @objectstack/plugin-audit records those as read rows from an afterFind hook, under a per-object opt-in given at construction — there is no service slot to resolve for them. See Record-View Auditing for that surface.

Method

The slot's entire surface is one method:

services.audit.recordAuthEvent(event: {
  action: 'login' | 'logout' | 'login_failed' | 'account_locked';
  userId: string;
  sessionId?: string;
  organizationId?: string;
  ipAddress?: string;
  userAgent?: string;
  actor?: string;
  context?: Record<string, unknown>;
}): Promise<void>
  • action is a closed literal union. Every sys_audit_log field is readonly and validation skips readonly/system fields, so the object's declared action vocabulary validates nothing in either direction — a misspelled action would be accepted silently. This union is the only structural protection an author gets, which is why the caller hands over an event (what happened) rather than a row (what to store).
    • login / logout are session events: a sign-in that minted a session, and a sign-out.
    • login_failed / account_locked are account events: a refused password sign-in that named an existing account, and the refused sign-in that stamped the account lockout (lockoutThreshold, ADR-0069 D2). They exist without any session.
  • userId is required and must be a real sys_user id — it lands on the strict user_id lookup. On an account event it is the account the sign-in named.
  • sessionId lands on record_id, with object_name set to sys_session, so the ledger row is navigable to the session it describes. An account event names its account instead — object_name sys_user and record_id the account's id — and does not read sessionId.
  • organizationId is the session's active organization. It stamps the row's tenant columns, and an unstamped row is one that non-administrator members can never see through the security layer's predicate.
  • ipAddress / userAgent are the client fingerprint by better-auth's own rule: the session row's values for a session event, and the same resolution applied to the sign-in request for an account event.
  • actor is the principal that caused the event when that is not the subject — an administrator's user id on an impersonation session. Omitted, the subject is recorded as having acted for themselves on a session event; an account event records no principal, because a refused attempt authenticated nobody.
  • context is free-form and is serialized into metadata, e.g. the endpoint that produced the event (/sign-in/email, /sign-out). On an account event it is the endpoint, the refusal's machine code (reason, e.g. INVALID_EMAIL_OR_PASSWORD or ACCOUNT_LOCKED) and, for account_locked, the lock's locked_until and threshold. It never carries the attempted credential.

Notes

  • This API is intentionally write-only. It is not a query API.
  • For querying audit history, use services.data against sys_audit_log/sys_activity objects.
  • The slot is registered in init(), while the data engine only resolves at kernel:ready. The sink therefore resolves its engine per call, so it is safe to hold a reference from the moment the plugin initializes.
  • @objectstack/plugin-auth is the only in-repo consumer. It resolves the slot lazily through a locally-declared structural surface and calls it from the session lifecycle and from the password sign-in outcome, so neither package depends on the other. A sign-in identifier that resolves to no account produces no call.

Typical Errors

recordAuthEvent never throws. An audit write must not turn a valid sign-in into an error, so every failure is caught, and callers do not need a surrounding try/catch. The consequences are worth knowing precisely, because none of them are visible from the caller's side:

  • A failed ledger insert is reported at error level once per cause — a cause is the error's code plus the object the row is about, so every uncoded failure collapses into one bucket per object, and the object is sys_session for a session event and sys_user for an account event — and the row is simply lost. Nothing retries it. A repeat of an already-reported cause drops to debug; a new cause gets its own error line, once. The sign-in was answered exactly as it would have been without the ledger, so the shipped auth_events list view and the system-overview widgets keep showing an empty, healthy-looking screen.
  • The call silently no-ops when no data engine resolves, or when userId is absent. A caller that passes an event without a subject records nothing and is told nothing.
  • The most common cause of a failing insert is a datasource split rather than a broken write: sys_audit_log carries an ADR-0057 audit lifecycle class, which routes it to a dedicated telemetry datasource whenever one is registered — os dev provisions one by default as a sibling SQLite file. A "no such table" here usually means the write ran against a different datasource than the one the table was created in.

Not this: the settings audit sink

A second, unrelated audit shape shares the word audit and is easy to reach for by mistake. SettingsAuditSink — canonical source packages/services/service-settings/src/settings-service.types.ts — is a constructor-injected sink that service-settings calls after a settings write lands:

record(entry: {
  namespace: string;
  key: string;
  scope: SpecifierScope;
  userId?: string;
  tenantId?: string;
  actor?: string;
  action: 'set' | 'reset';
  valueDigest: string;
  encrypted: boolean;
  requestId?: string;
}): Promise<void> | void

It is never registered as, or resolved from, this service slot, and the settings service wraps the call in its own try/catch so a throwing sink cannot fail the write it describes. Calling getService('audit').record({ ... }) therefore fails with a TypeError: the resolved object has no record, and it accepts neither 'set' nor 'reset'.

On this page