Supercommerce API Docs
Admin API

Audit Module — Admin

Unified audit log capturing every domain event (attributed to the triggering actor via request-context propagation) plus eventless HTTP mutations, enriched (geo + device) and stored in ClickHouse via a dedicated event bus and a separately-deployable worker. Filterable, paginated admin read API.

Unified audit log capturing every domain event (attributed to the triggering actor via request-context propagation) plus eventless HTTP mutations, enriched (geo + device) and stored in ClickHouse via a dedicated event bus and a separately-deployable worker. Filterable, paginated admin read API.

Source: api-modules/audit/src/query/admin-audit.controller.ts.

Optional plugin. Removing AuditModule.forRoot() from app.module.ts disables it entirely — the global capture interceptor and the onAny domain-event collector stop, the worker processor + ClickHouse migrations don't register, and /admin/audit leaves the OpenAPI surface. Cross-module consumers depend on nothing here.


Architecture

API role (capture)                         Worker role (process — separately deployable)
──────────────────                         ────────────────────────────────────────────
AuditInterceptor (HTTP mutations) ─┐
AuditEventCollector (onAny events) ─┴─► audit_outbox ──► AuditProcessor (drain loop)
                                        (Postgres,         ├ geo-IP + device enrich
                                         durable)          └ insert ─► ClickHouse audit_events
  • Capture is cheap and on the request path only for one Postgres insert. The HTTP interceptor records every POST/PUT/PATCH/DELETE (reads are skipped); the collector mirrors every domain event via EventEmitter2.onAny (no per-event listener).
  • Every event is durable. Both producers write to the Postgres audit_outbox; the worker drains it (loop-until-caught-up, every ~2s) into ClickHouse. So nothing is lost if the worker / audit Redis blips — store-side events (cart, wishlist, …) are as reliably logged as orders. ClickHouse is a rebuildable mirror, not the system of record; the in-transaction order_event / user_admin_audit ledgers remain authoritative for state changes.
  • The worker does all heavy lifting — geo-IP (MaxMind, optional) + device/browser/OS (Bowser) enrichment, redaction, and the ClickHouse insert. It is gated behind workersEnabled(), so it runs only in the worker / all role and can be deployed separately from the API by running the same image with APP_ROLE=worker (no code change).
  • Pluggable transport. The EVENT_BUS port (BullMQ on a dedicated Redis) is still mounted for the worker's drain scheduling and remains the seam for a future Redpanda/Kafka analytics stream.

Capture semantics

Actor attribution (request-context propagation) — a per-request context (AsyncLocalStorage) carries the actor + ip + ua + a requestId across the whole call stack, so a domain event emitted deep inside a service (discount.updated) is attributed to the admin who triggered it, not system. Events emitted outside a request (cron / worker) fall back to system (or an actor id found in the payload).

De-duplication — when an action produces a domain event, that event is the audit record (correctly attributed), and the generic http.* row is suppressed. So updating a discount yields a single discount.updated row by admin — not a discount.updated (system) + http.patch (admin) pair. The http.* fallback is recorded only for mutating requests that emit no domain event (e.g. an eventless settings write, or a 4xx/5xx). Every event from one request shares a requestId.

HTTP (fallback) — actorType (admin / vendor / customer / anonymous, by route prefix + session), actorId / actorEmail, method, matched route + path, statusCode (success and error), client ip (first x-forwarded-for hop), userAgent, and the redacted request body. GET/HEAD/OPTIONS and a small ignore-list (/admin/audit, health, docs, session polling) are never recorded.

Domain events — one event per emitted domain event (order.placed, cart.item.added, vendor.application.approved, …), with the redacted payload, the inherited actor + request context, the origin source channel, and resource / resourceId. search.performed is ignored (analytics signal, not an audited action).

source is the origin channel (admin-portal / store / vendor-portal / api / webhook / system) — consistent with order_event.source / user_admin_audit.source — not the capture mechanism. The actual event is always in event_type.

Redaction — keys matching password, token, secret, card, cvv, otp, authorization, cookie, pin, accountNumber, … (case/underscore-insensitive) are replaced with [REDACTED]; oversized payloads (> 8 KB) are stored as a truncation marker.

Enrichmentgeo_country/region/city come from a MaxMind GeoLite2-City DB only when GEOIP_DB_PATH is set (the DB is licence-restricted and never bundled; unset = no geo, everything else works). device_type / browser / os are parsed from the user-agent.

Retention — ClickHouse native TTL of 24 months on occurred_at.

Endpoints

All require a Better-Auth admin session and a role granting audit: view.

GET /admin/audit

Paginated, filterable list (newest first). Standard offset/limit pagination (limit, offset) plus filters:

Query paramDescription
eventTypeExact event type (e.g. http.post, order.placed)
actorIdFilter by acting principal id
actorTypeadmin | vendor | customer | system | anonymous
sourceOrigin channel: admin-portal | store | vendor-portal | api | webhook | system
resourceResource family (e.g. order, things)
vendorIdFilter by vendor scope
requestIdAll events produced during one HTTP request
from / toISO-8601 bounds on occurred_at (inclusive)

Response: { data: AuditEvent[], metadata: { total, limit, offset, hasMore } } (the canonical ApiWrappedPaginatedResponse shape).

GET /admin/audit/:id

Fetch a single audit event by id. 404 if not found.

AuditEvent shape

id, eventType, occurredAt (ISO), actorType, actorId, actorEmail, source, requestId, method, route, path, statusCode, ip, userAgent, resource, resourceId, subject, vendorId, geo { country, region, city }, device { type, browser, browserVersion, os, osVersion }, changes, payload, metadata (the last three parsed back from stored JSON).

Self-describing rows. Beyond the full redacted event payload, each row carries:

  • changes — a { from, to, reason, changedFields, delta } before→after picture. Update events across the system emit field-level from → to (e.g. a discount edit shows value: 1000 → 1500); the collector also derives it from previousX/newX, previousStatus, changedFields[], and quantityDelta.
  • subject — the entity's human identifier (name / number / code / slug / email), derived from the event payload, so a reviewer sees which entity (e.g. the discount name) without a lookup.

Configuration

Env varDefaultPurpose
CLICKHOUSE_URLhttp://localhost:8123ClickHouse HTTP endpoint
CLICKHOUSE_DBsupercommerceClickHouse database
CLICKHOUSE_USER / CLICKHOUSE_PASSWORDdefault / clickhouseClickHouse credentials
CLICKHOUSE_ENABLEDtruefalse runs the app without ClickHouse (capture still enqueues; the worker skips the insert)
AUDIT_REDIS_HOST / AUDIT_REDIS_PORT / AUDIT_REDIS_PASSWORDlocalhost / 6381Dedicated audit-bus Redis (isolated from the main queue + cache)
AUDIT_BUS_ENABLEDtruefalse wires a no-op bus (events dropped)
AUDIT_WORKER_CONCURRENCY20Worker queue concurrency
GEOIP_DB_PATH(unset)Path to a MaxMind GeoLite2-City .mmdb to enable geo enrichment

Durable in-transaction audit

A service that needs an audit record written atomically with its action injects AuditOutboxRepository and calls append(entry, tx) inside its own transaction; the worker's relay drains it into the same ClickHouse sink.

On this page