auth/audit_emitter.ts

Bound audit-emit capability.

AuditEmitter closes over the pool-level Db, its registered listeners, and the optional AuditLogConfig. Built by the consumer's audit_factory callback on CreateAppBackendOptionscreate_app_backend invokes the factory once with its constructed {db, log} and lands the result on AppDeps.audit. Consumers reach for deps.audit.emit(ctx, input) and never see the pool — handlers cannot accidentally emit an audit event against the request's transactional db (which would be rolled back with the parent on a handler throw).

Four methods cover every fan-out shape the auth domain needs:

  • emit(ctx, input) — fire-and-forget pool write. Pushes the in-flight promise onto ctx.pending_effects for post-response flushing. Errors are logged, never thrown. Returns void so callers don't pile up void keywords or accidentally await something whose handle is already in pending_effects.
  • emit_role_grant_target(ctx, auth, input) — wrapper that lifts the actor_id / account_id / ip boilerplate every role-grant-shape audit site repeated. Delegates to emit.
  • emit_pool(input) — awaitable pool write for code paths without a pending_effects queue (cleanup sweeps, ad-hoc maintenance scripts). Same write-then-notify semantics as emit, just synchronous-with-await.
  • notify(event) — fan out an already-written audit row (e.g. rows returned by query_accept_offer that were inserted in-transaction by the query layer). Runs every registered listener; per-listener throws are isolated.

Listeners are a documented registration seam — create_app_server registers additional listeners via add_listener after the backend is built (the factory-managed audit-log SSE, per-endpoint WS auth guards and logout closers, any extra_audit_handlers on a WsEndpointSpec) before the first request runs. Consumers can also register listeners directly on the emitter they return from audit_factory for setups that don't pass through create_app_server.

view source

Declarations
#

7 declarations

AuditEmitFn
#

auth/audit_emitter.ts view source

AuditEmitFn import type {AuditEmitFn} from '@fuzdev/fuz_app/auth/audit_emitter.js';

Signature of AuditEmitter.emit — captured by the inner closure so emit_role_grant_target reaches the decorated function rather than a this.emit lookup. Exposed as a type so EmitDecorator can name the inner / outer slot.

(call)

type <T extends string>(ctx: AuditEmitterContext, input: AuditLogInput<T>): void

ctx

input

type AuditLogInput<T>
returns void

AuditEmitRoleGrantContext
#

auth/audit_emitter.ts view source

AuditEmitRoleGrantContext import type {AuditEmitRoleGrantContext} from '@fuzdev/fuz_app/auth/audit_emitter.js';

Context required by AuditEmitter.emit_role_grant_target — adds client_ip so the helper can lift the ip: ctx.client_ip boilerplate every role-grant-shape emit site repeated.

inheritance

client_ip

Resolved client IP from the trusted-proxy middleware — 'unknown' if not resolved.

type string

AuditEmitter
#

auth/audit_emitter.ts view source

AuditEmitter import type {AuditEmitter} from '@fuzdev/fuz_app/auth/audit_emitter.js';

Bound audit-emit capability. Built once at backend assembly via create_audit_emitter; lives on AppDeps.audit so factories never see the pool.

emit

Fire-and-forget audit write via the captured pool.

The in-flight promise is pushed onto ctx.pending_effects so tests with await_pending_effects: true can assert side effects inline. Errors are logged, never thrown. Successful writes fan out to every listener on the chain (notify).

Returns void deliberately — the in-flight promise is already on ctx.pending_effects, and exposing it would tempt callers to await (sequencing audit writes onto the response hot path) or sprinkle void to placate no-floating-promises. For awaitable writes from code paths without pending_effects, use emit_pool.

type <T extends string>(ctx: AuditEmitterContext, input: AuditLogInput<T>): void

ctx

input

type AuditLogInput<T>
returns void

emit_role_grant_target

Emit a role-grant-shape audit event with actor_id / account_id / ip lifted from auth + ctx. Delegates to emit.

Use for any event populating one of the target_*_id columns. Reach for the lower-level emit only when the event is non-role-grant shape (e.g. app_settings_update, bootstrap, signup).

type <T extends string>(ctx: AuditEmitRoleGrantContext, auth: RequestActorContext, input: { event_type: T; target_account_id: (string & $brand<"Uuid">) | null; target_actor_id: (string & $brand<...>) | null; metadata: (T extends "invite_create" | ... 25 more ... | "actor_undelete" ? (AuditMetadataMap[T] & Record<...>) | null : Record<...> | null) | undefined; outcome?: "success" | ... 1 more ... | undefined; }): void

ctx

auth

input

type { event_type: T; target_account_id: (string & $brand<"Uuid">) | null; target_actor_id: (string & $brand<"Uuid">) | null; metadata: (T extends "invite_create" | ... 25 more ... | "actor_undelete" ? (AuditMetadataMap[T] & Record<...>) | null : Record<...> | null) | undefined; outcome?: "success" | ... 1 more ... | und...
returns void

emit_pool

Awaitable pool write for code paths without a pending_effects queue.

Same write-then-notify semantics as emit. Errors are logged and swallowed (resolved void), so callers can sequence sweeps with await audit.emit_pool(...) without try/catch boilerplate. The primary user is auth/cleanup.ts — sweeps have no per-request pending_effects to attach to.

type <T extends string>(input: AuditLogInput<T>): Promise<void>

input

type AuditLogInput<T>
returns Promise<void>

notify

Fan out an already-written audit row to the registered listeners.

Use only when the row was inserted in-transaction by a query helper that returned the AuditLogEvent (e.g. query_accept_offer.audit_events). Per-listener exceptions are caught and logged; one failing listener does not starve siblings.

type (event: AuditLogEvent): void

event

returns void

add_listener

Register an audit-event listener. Append-only — listeners fire in registration order on every successful emit / emit_pool and on every notify.

create_app_server registers the factory-managed audit-log SSE listener and per-endpoint WS auth guards / logout closers here so SSE + WS fan-out compose on top of the consumer's on_audit_event callback without shallow-copying AppDeps. Consumers can also register listeners directly for setups that don't run through create_app_server.

Twin of the Rust fuz_auth AuditEmitter::add_listener.

type (listener: (event: AuditLogEvent) => void): void

listener

type (event: AuditLogEvent) => void
returns void

listener_count

Count of registered listeners — introspection for tests and diagnostics.

type (): number

returns number

AuditEmitterContext
#

auth/audit_emitter.ts view source

AuditEmitterContext import type {AuditEmitterContext} from '@fuzdev/fuz_app/auth/audit_emitter.js';

Per-request context required by AuditEmitter.emit — just the eager pending_effects queue. The bound emitter carries its own log reference inside the closure, so per-call contexts don't need one.

Audit emits are eager-only by design: the bound emitter fires the pool write immediately and pushes the in-flight Promise<void> here. They never go through emit_after_commit — pool-routed audit writes are already rollback-resilient because they run outside the request transaction, so the post-commit timing the deferred queue provides would only delay forensic visibility without any safety benefit.

Both RouteContext and ActionContext structurally satisfy this shape (they each carry pending_effects), so handlers pass route / ctx directly.

pending_effects

type Array<Promise<void>>

create_audit_emitter
#

auth/audit_emitter.ts view source

(options: CreateAuditEmitterOptions): AuditEmitter import {create_audit_emitter} from '@fuzdev/fuz_app/auth/audit_emitter.js';

Build a bound AuditEmitter. Typical caller is the consumer's audit_factory callback on CreateAppBackendOptionscreate_app_backend invokes that callback with its constructed {db, log} and lands the result on AppDeps.audit.

options

pool, logger, optional initial subscriber, optional config

returns

AuditEmitter

the bound emitter; closes over the pool + config + listener chain

CreateAuditEmitterOptions
#

auth/audit_emitter.ts view source

CreateAuditEmitterOptions import type {CreateAuditEmitterOptions} from '@fuzdev/fuz_app/auth/audit_emitter.js';

db

Pool-level Db. Captured by every emit call.

type Db

log

Logger for write + listener-callback failures.

type Logger

on_audit_event?

Initial listener — registered as the first listener when set. Omit for backends that compose listeners post-assembly (e.g. via audit_log_sse).

type ((event: AuditLogEvent) => void) | null

audit_log_config?

Audit-log config. Defaults to builtin_audit_log_config. Consumer- extended configs from create_audit_log_config({extra_events}) get registered here once at backend assembly.

type AuditLogConfig

emit_decorator?

Test-only hook to wrap emit at construction time. The decorated function is captured by emit_role_grant_target's closure and is the function exposed on the returned AuditEmitter, so both call shapes route through it — see EmitDecorator for the rationale.

Leave unset in production. The intended caller is create_emit_ordering_audit_factory in testing/audit_drift_guard.ts.

type EmitDecorator

EmitDecorator
#

auth/audit_emitter.ts view source

EmitDecorator import type {EmitDecorator} from '@fuzdev/fuz_app/auth/audit_emitter.js';

Wrap the bound emit before it gets captured by emit_role_grant_target's closure and exposed on the returned AuditEmitter. Test instrumentation uses this to record emit invocation ordering against external markers (e.g. eager ConnectionCloser calls in connection_closer.db.test.ts) without paying the freeze-breaking footgun the pre-decorator patch_audit_emit_capture hot-patcher had.

Because the inner closure captures the decorated function (not the outer slot reference), emit_role_grant_target also routes through the wrap — the close-vs-emit ordering helper sees role-grant-shape emissions, not just bare emit calls. Production never sets this.

(call)

type (inner: AuditEmitFn): AuditEmitFn

inner

returns AuditEmitFn

Depends on
#

Imported by
#