ObjectStackObjectStack

services.audit

Write ingress for audit events that are not CRUD — today, auth session events.

  • 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 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';
  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).
  • userId is required and must be a real sys_user id — it lands on the strict user_id lookup.
  • sessionId lands on record_id, with object_name fixed to sys_session, so the ledger row is navigable to the session it describes.
  • 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.
  • 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.
  • context is free-form and is serialized into metadata, e.g. the endpoint that produced the event (/sign-in/email, /sign-out).

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, so neither package depends on the other.

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 process — every later failure drops to debug — and the row is simply lost. Nothing retries it. The sign-in itself succeeded and returned 200, 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