Event-driven foundation
July 3, 2026 Β· View on GitHub
Clean-stack ships a transactional outbox + dispatcher + audit/webhook subscribers. You never touch the rail. You declare events and handlers; the rest is automatic.
Deployment requirements
The dispatcher is an in-process Bun worker holding a persistent pg.Client connection on LISTEN outbox_event. The webhook delivery worker uses a setInterval poll. Both die when the api process dies. This shapes where the api can run.
| Platform shape | Status | Notes |
|---|---|---|
| Railway, Fly.io, Render, Coolify, dedicated VM, K8s | β Just works | Process stays alive; LISTEN persists across requests. |
| Cloud Run, App Runner, Azure Container Apps | π‘ Set min_instances β₯ 1 | Default scale-to-zero kills the worker. With one always-warm replica, behaves like the row above. |
| Vercel Functions, Netlify Functions, AWS Lambda | β Re-wire required | Functions terminate after the response. The outbox table will fill up; no one drains it. |
| Cloudflare Workers, edge runtimes | β Not viable | No Node.js process model + no pg client. Even with rewiring, no in-process worker lives long enough. |
Symptom of mis-deployment: the api answers requests, outbox_event rows accumulate (visible in any Postgres client), audit_log and webhook_delivery stay empty. Cause: dispatcher never ran or died between requests.
Going serverless β three paths
If you must ship on serverless functions, the rail still works β you swap the dispatcher only:
- Cron-triggered drain (lowest effort). Expose a protected
POST /internal/drain-outbox(gated by the same HMAC layer as/internal/rgpd-sweep) that runs one batch offindPendingBatch+ subscribers. Trigger every 1-5 min via Vercel Cron / GitHub Actions / Inngest scheduled function. Trade-off: latency floor = cron interval (1 min on Vercel free, 30 s on Inngest). - External queue (lowest latency). Replace
outbox.enqueue()with a push to SQS / Inngest / QStash inside the same TX (XA-style two-phase, or accept the well-known race window). The queue invokes a serverless function per message. - Hybrid (most pragmatic). Keep the api serverless, deploy a tiny worker container alongside (Railway/Fly, ~5 β¬/mo) running the existing
OutboxDispatcher+WebhookDeliveryWorker. Pointing it at the sameDATABASE_URLis enough; no other code changes.
The audit/webhook subscribers, the catalogue, the uow.run flush β all unchanged in any path. Only OutboxDispatcher swaps.
Mental model
your code ββΊ uow.run(async tx => repo.save(aggregate, tx))
β
βΌ ALS collector flushes pre-COMMIT
outbox_event INSERT (same TX)
β
βΌ pg_notify post-COMMIT
OutboxDispatcher (in-process Bun worker)
β
drain via SELECT ... FOR UPDATE SKIP LOCKED
β
ββββ built-in (TX-bound) ββββββββββΌβββββββ post-commit (best-effort) βββ
βΌ βΌ βΌ
AuditEventSubscriber WebhookFanoutSubscriber user-defined handlers
β audit_log row β webhook_delivery rows (auto-discovered via
(idempotent via (HMAC POST β consumers via EVENT_HANDLER_SYMBOL)
audit-${eventId}) WebhookDeliveryWorker)
Key invariant: built-in subscribers run inside the same DB transaction as markDispatched β atomic. User handlers run post-commit, best-effort, isolated from each other (one handler throwing doesn't fail the outbox dispatch).
How to emit a new event
Step 1 β declare the type in packages/events/src/event-types.ts:
export const EventTypes = {
// ...
ORDER_PLACED: "order.placed",
} as const;
Step 2 β declare the Zod payload in packages/events/src/payloads.ts:
export const OrderPlacedPayload = z.object({
orderId: z.string(),
userId: z.string(), // subject (whose order)
actorUserId: z.string(), // who placed it β REQUIRED when actor β subject (admin places on behalf, system bot, etc.)
total: z.number().positive(),
});
export type OrderPlacedPayload = z.infer<typeof OrderPlacedPayload>;
// don't forget to add it to PayloadByEventType at the bottom
[EventTypes.ORDER_PLACED]: OrderPlacedPayload,
Actor identification (rule #7 of root CLAUDE.md).
AuditEventSubscriber.extractActorscans the payload for the actor in priority order:actorUserIdβinviterUserIdβownerUserIdβuserId.userIdis the subject, not the actor by default. When the actor differs from the subject (admin kicks member, system cron processes deletion, owner changes someone else's role),actorUserIdmust be a separate, NOT NULL field. Self-actor flows (sign-in, MFA toggle, self-deletion) can rely onuserIdalone. A row landing inaudit_logwithactor_type="system"should be the exception, not a default β the runtime guard atenqueuecatches a missing/wrong-shape payload, but it can't catch a semantically missing actor: that's on the schema author.
Step 3 β set retention in packages/events/src/retention-map.ts:
[EventTypes.ORDER_PLACED]: "compliance", // or "operational" / "none"
Step 4 β define the event class + emit from your aggregate:
class OrderPlaced extends BaseDomainEvent<OrderPlacedPayload> {
readonly eventType = EventTypes.ORDER_PLACED;
readonly aggregateId: string;
readonly payload: OrderPlacedPayload;
// ...
}
class Order extends Aggregate<IOrderProps> {
static place(props: PlaceOrderProps): Order {
const order = new Order(props, new UUID());
order.addEvent(new OrderPlaced({
orderId: order.id.value,
userId: props.userId,
total: props.total,
}));
return order;
}
}
Step 5 β persist in a use-case via uow.run():
class PlaceOrderUseCase {
constructor(
private readonly uow: IUnitOfWork<ITransaction>,
private readonly repo: IOrderRepository,
) {}
async execute(input: PlaceOrderInput): Promise<Result<Order, OrderError>> {
return this.uow.run(async (tx) => {
const order = Order.place(input);
return this.repo.save(order, tx);
});
}
}
That's it. The event is in outbox_event (same TX as the order row), audit_log row written automatically, every matching webhook_endpoint receives a webhook_delivery.
Repos must opt into auto-tracking. In your repo
save()/create()impl, returntrackEventsOnSuccess(result, aggregate)(helper in@packages/drizzle) β that pushes pulled events into the ALS collector. Without it, events stay buffered on the aggregate and are silently lost.
How to add an in-process handler
Oneline factory + inwire binding:
// modules/orders/module.ts
import { type EventHandler, onEvent } from "@packages/ddd-kit";
import { EventTypes } from "@packages/events";
declare module "inwire" {
interface AppDeps {
NotifyCustomerOnOrderPlaced: EventHandler<OrderPlacedEvent>;
}
}
export const ordersModule = defineModule()((b) =>
b.add(
"NotifyCustomerOnOrderPlaced",
onEvent(EventTypes.ORDER_PLACED, (c) => async (event) => {
await c.IEmailService.sendTemplate("order_confirmation", ...);
}),
),
);
OutboxDispatcher.start() discovers it automatically at boot via Object.entries(di) + EVENT_HANDLER_SYMBOL marker. No registration array, no manifest.
Built-in audit & webhook coverage
If your event is in RETENTION_MAP with operational or compliance:
audit_logrow written byAuditEventSubscriberinside the dispatch TX (idempotent viaaudit-${eventId}deterministic ID +ON CONFLICT DO NOTHING).- Every enabled
webhook_endpointmatchingeventTypes ? <type>ANDorganizationId = event.organizationIdreceives awebhook_deliveryrow, dispatched independently byWebhookDeliveryWorker(HMAC POST + retry + dead-letter).
Multi-tenant safety: events with
organizationId = null(platform-level:user.created,user.signed_in, etc.) skip webhook fanout entirely β never broadcast across tenants.
Retention
Three sweep endpoints purge derived tables. All gated by internalLayers (HMAC), called by an external cron via signedInternalFetch. Defaults reflect SOTA 2026 research, configurable via env knobs.
| Table | Knob | Default | Filter | Conformance source |
|---|---|---|---|---|
outbox_event | OUTBOX_RETENTION_DAYS | 7d | dispatched_at IS NOT NULL AND dispatched_at < cutoff | NServiceBus / industry consensus 2025 β debug window |
audit_log (operational) | AUDIT_LOG_OPERATIONAL_RETENTION_DAYS | 90d | retention = 'operational' AND occurred_at < cutoff | SOC2 minimum-safe (operational logs); RGPD minimisation |
audit_log (compliance) | AUDIT_LOG_COMPLIANCE_RETENTION_DAYS | 365d | retention = 'compliance' AND occurred_at < cutoff | SOC2 Type II β₯ 12 months; PCI-DSS 10.7; NIS2 baseline |
webhook_delivery | WEBHOOK_DELIVERY_RETENTION_DAYS | 30d | status IN ('success','dead_letter') AND created_at < cutoff | Stripe / GitHub / Hookdeck convergence |
Notes:
pendingandfailedwebhook deliveries are NEVER purged automatically β they signal worker death or active retry. Apendingrow stale > 24h must page on-call, not be cleaned up.retention = 'none'rows never reach the DB.AuditEventSubscriberreturns early whenretentionFor(eventType) === "none"β uncatalogued events are never written.
Endpoints
POST /internal/sweep-outboxβ body{ batchSize?: 1β50000 (default 5000), dryRun?: boolean }β{ deleted, durationMs, dryRun, batchCount }POST /internal/sweep-audit-logβ same body β{ deletedPerBucket: { operational, compliance }, durationMs, dryRun }POST /internal/sweep-webhook-deliveryβ same body β{ deleted, durationMs, dryRun, batchCount }
Each endpoint runs a batched DELETE ... WHERE id IN (SELECT id ... ORDER BY <ts> LIMIT N FOR UPDATE SKIP LOCKED) inside a transaction with SET LOCAL statement_timeout = '5s', lock_timeout = '500ms', idle_in_transaction_session_timeout = '10s'. Loops until 0 rows; hard-capped at 1000 batches per call.
Order matters (FK constraint)
webhook_delivery.outbox_event_id is ON DELETE RESTRICT. The cron must run sweeps in this order:
POST /internal/sweep-webhook-deliveryβ frees terminal deliveriesPOST /internal/sweep-audit-logβ independentPOST /internal/sweep-outboxβ last, otherwiseoutbox_eventrows still referenced by undeleted deliveries trigger an FK violation
Cron chain entrypoint
The chained sweep runner lives at apps/api/src/cron/sweep.ts β single source of truth, bundled by bun build into dist/cron/sweep.js. Reads API_URL and INTERNAL_SIGNING_KEY from env, hits the three endpoints in FK order, exits non-zero on first failure.
Runtime invocation (the same binary works in any orchestrator):
API_URL=https://api.example.com \
INTERNAL_SIGNING_KEY=<hex32+> \
bun dist/cron/sweep.js
Railway Cron (reference deploy β see DEPLOY-RAILWAY.md) configures this via infra/railway/cron.toml: startCommand = "bun dist/cron/sweep.js", cronSchedule = "17 3 * * *", restartPolicyType = "NEVER". The cron service reuses the api Docker image β no extra Dockerfile.
For other orchestrators (Fly Machines --schedule, Render Cron Job, Cloud Scheduler β Cloud Run job, K8s CronJob), point the entrypoint at the same bun dist/cron/sweep.js command and pass the two env vars. The signature primitives (signedInternalFetch with object input β see apps/api/src/shared/internal-routes/internal-fetch.ts) are platform-agnostic.
Setup checklist post-clone
- Tables (
outbox_event,audit_log,webhook_endpoint,webhook_delivery) are created automatically β the api runsdrizzle migrateat boot beforeOutboxDispatcher.start()(apps/api/src/migrate.ts). In native dev,pnpm db:migrateonce beforepnpm devif you skipped Docker. - Set env vars in
apps/api/.env:WEBHOOK_MASTER_KEY=<64 hex chars>β generate viaopenssl rand -hex 32(required in production)AUDIT_TAMPER_EVIDENCE=falseβ leave off; flip totrueonly when SOC2 audit demands hash chain
Operational endpoints
GET /admin/audit-logβ list audit events for active org. Permission:auditLog: ["read"].organizationIdalways derived from session, never query string.POST /internal/sweep-{outbox,audit-log,webhook-delivery}β retention sweeps (see Β§ Retention above). Internal-gated (HMAC signature + optional private network).GET/POST/PATCH/DELETE /settings/webhooksβ manage endpoints. Permission:webhooks: ["read"|"write"]. Plaintext secret returned once at creation (Stripe-style), never re-exposed.GET /settings/webhooks/:id/deliveriesβ list deliveries with status filter (?status=pending|success|failed|dead_letter).POST /settings/webhooks/:id/deliveries/:deliveryId/replayβ re-enqueue a past delivery with fresh idempotency key.
HMAC signature format (for receivers)
Header: x-webhook-signature: t=<unix>,v1=<hex-sha256>. Signed payload: ${timestamp}.${rawBody}. Body shape: { id, type, data, time } (CloudEvents-aligned).
Reject if timestamp drift > 5 min (replay protection). Use the x-webhook-idempotency header (<eventId>:<endpointId>) to dedupe on your side.
// Receiver verification
const sigHeader = req.headers["x-webhook-signature"];
const [tsPart, sigPart] = sigHeader.split(",");
const ts = Number(tsPart.split("=")[1]);
const sig = sigPart.split("=")[1];
const expected = hmacSha256(`${ts}.${rawBody}`, secret);
if (!timingSafeEqual(sig, expected)) return reject(401);
if (Math.abs(Date.now() / 1000 - ts) > 300) return reject(401);
Architecture choices (SOTA 2026)
- UUID v7 for
outbox_event.id,audit_log.id,webhook_*.idβ time-ordered, B-tree locality preserved on inserts. - Postgres LISTEN/NOTIFY via dedicated
pg.Client(hors pool Drizzle) + 30s poll fallback for connection drops. Trigger ensured at boot via idempotentCREATE OR REPLACE TRIGGER(Postgres 14+ atomic). SELECT ... FOR UPDATE SKIP LOCKEDdrain β multi-instance safe out of the box.SET LOCAL idle_in_transaction_session_timeout = '30s'at the start of every drain TX β zombie workers can't lock rows indefinitely.- AEAD secret encryption (
@noble/ciphersXChaCha20-Poly1305 + HKDF-SHA256 per-org sub-key) for webhook secrets at rest. - Decorrelated jitter retry (
apps/api/src/shared/jitter.ts) βBASE * MULTIPLIER^attemptsthenrandom(BASE, upper)clamped to 12h cap. Dead-letter after 5 attempts. - Claim window pattern in delivery worker β claim a batch with
next_attempt_at = now() + (BATCH_SIZE Γ FETCH_TIMEOUT + buffer), fetch HTTP outside the TX, update status in a fresh TX. Prevents lock starvation. - CloudEvents 1.0 envelope stored in
outbox_event.metadata(specversion, source, subject, traceparent, requestId) for cross-system interop.requestIdcarries the request'sX-Request-Idβ captured via anAsyncLocalStoragerequest context (shared/request-context.ts) at enqueue time, then copied intoaudit_log.request_idby the audit subscriber, so every audit row joins back to its originating HTTP request, pino logs, and Sentry event. (traceparentstays reserved for W3C trace context β Phase D.1 OTel.)
BetterAuth bridge β what fires what
The boilerplate emits 38 events automatically (23 from apps/api/src/auth.ts covering BetterAuth lifecycles, 5 from modules/rgpd/, 3 from modules/uploads/, 3 from modules/webhooks/, 1 from modules/policies/, 3 from security middleware/endpoint). Source of truth: packages/events/src/event-types.ts.
Via databaseHooks (TX-bound, captures all flows)
USER_CREATEDβdatabaseHooks.user.create.afterUSER_SIGNED_INβdatabaseHooks.session.create.afterUSER_SIGNED_OUTβdatabaseHooks.session.delete.afterUSER_ACCOUNT_UNLINKEDβdatabaseHooks.account.delete.after(skip credential)
Via hooks.after + createAuthMiddleware (path-based, plugin events)
Filter: if (ctx.context.returned instanceof APIError) return (skip on 4xx/5xx).
USER_MFA_ENABLEDβpath === "/two-factor/enable"USER_MFA_DISABLEDβpath === "/two-factor/disable"USER_PASSKEY_ADDEDβpath === "/passkey/verify-registration"+ lookup latest passkeyUSER_PASSKEY_REMOVEDβpath === "/passkey/delete-passkey"+ body.idUSER_EMAIL_VERIFIEDβpath === "/verify-email"(skipped if session not yet active β limitation)USER_PASSWORD_CHANGEDβpath === "/change-password"USER_PROFILE_UPDATEDβpath === "/update-user". Payload:{ userId, changes }(field-level diff).USER_ACCOUNT_LINKEDβpath === "/link-social"+ lookup latest non-credential account created < 5s ago
Via BetterAuth callbacks (native)
USER_PASSWORD_RESET_REQUESTEDβemailAndPassword.sendResetPasswordUSER_PASSWORD_CHANGEDβemailAndPassword.onPasswordResetUSER_MAGIC_LINK_REQUESTEDβmagicLink.sendMagicLinkUSER_EMAIL_CHANGE_REQUESTEDβuser.changeEmail.sendChangeEmailConfirmation. Payload:{ userId, newEmail }. Confirmation sent to the current address.
Via organizationHooks (org plugin)
ORG_CREATED(afterCreateOrganization) Β·ORG_UPDATEDΒ·ORG_DELETEDΒ·ORG_MEMBER_INVITED(afterCreateInvitation) Β·ORG_INVITATION_CANCELLEDΒ·ORG_MEMBER_REMOVED(afterRemoveMember) Β·ORG_MEMBER_ROLE_CHANGED(afterUpdateMemberRole)ORG_MEMBER_JOINEDfires from two hooks:afterAddMember(direct add β org-create creator + signup auto-personal-org) andafterAcceptInvitation(member joins via invite). The two lifecycles are independent in BetterAuth β wiring only one would silently drop the other path.
Via RGPD service
USER_DELETION_{REQUESTED,CANCELLED}Β·USER_DELETEDΒ·USER_EXPORT_{REQUESTED,COMPLETED}(payload containsstorageKey, not the presigned URL β security)
Via UploadService
UPLOAD_REQUESTEDΒ·UPLOAD_CONFIRMEDΒ·UPLOAD_DELETED(payload useshashKey(key)β sha256 truncated, never the raw filename β PII protection)
Via WebhooksService
WEBHOOK_ENDPOINT_CREATEDΒ·WEBHOOK_ENDPOINT_UPDATEDΒ·WEBHOOK_ENDPOINT_DELETED(payload carriesactorUserIdpropagated from the HTTP boundary βc.get("user").id)
Via PolicyAcceptanceService (Phase A.2)
USER_POLICY_ACCEPTED(user.policy.accepted) β payload{ userId, policyType, policyVersion, ipAddress? }, retentioncompliance. Self-actor:userIdresolves as the actor viaAuditEventSubscriber.extractActor. Emitted fromPolicyAcceptanceService.accept, which is called from two sites: (1) the BetterAuth/verify-emailafter-hook inauth.ts(sign-up path, idempotent viagetStaleTypes) and (2) thePOST /me/policies/acceptroute (explicit re-acceptance by already-authenticated users).
Via security middleware / endpoint (Phase C.1)
Ces 3 events ne sont pas des state-changes mΓ©tier β ils signalent des rejets de sΓ©curitΓ© au niveau infra. Γmis via emitEvent(outbox, ...) hors agrΓ©gat (mΓͺme chemin que les events RGPD/uploads/webhooks), avec actorUserId nullable (pas de session authentifiΓ©e fiable sur ces rejets).
SECURITY_RATE_LIMIT_EXCEEDED(security.rate_limit.exceeded) β Γ©mis par le rate-limit middleware sur rejet d'une requΓͺte auth. Payload :{ actorUserId: string | null, ip: string (max 45), policyName: string (max 64), path: string (max 512), method: string (max 16) }, retentionoperational.SECURITY_CSP_VIOLATION(security.csp.violation) β Γ©mis par l'endpoint publicPOST /csp-report. Payload :{ documentUri, violatedDirective, blockedUri, actorUserId? }, retentionoperational.SECURITY_CSRF_REJECTED(security.csrf.rejected) β Γ©mis par le CSRF middleware sur Origin invalide. Payload :{ ipAddress, path, origin?, actorUserId? }, retentionoperational.
Payload validation guarantee
Every outbox.enqueue(...) call validates each event against PayloadByEventType[eventType] via Zod safeParse before the INSERT. A failure throws, which rolls back the surrounding TX (UoW or BetterAuth hook). Why: the audit trail is only as good as the payloads it stores β a missing actorUserId, an extra field, a wrong type silently corrupts compliance. Failing the mutation forces the bug to surface at the call site, atomically (the business write and the bad event are rejected together, never half-applied).
Symptoms when this guard fires:
outbox: unknown event type "X"β emitter passed an event type not registered inPayloadByEventType(forgot step 2 of "How to emit").outbox: payload validation failed for "X": ...β payload shape drifted from the Zod schema (typo, missing required field, wrong type). The Zod error message points to the offending key.
The guard lives in DrizzleOutboxRepository.enqueue (the single porte d'entrΓ©e β covers emitEvent(...) helper and aggregate-driven flushes uniformly).
Hard rules
uow.run()cannot be nested. Drizzle nesteddb.transaction()opens independent TXs (not savepoints). TheTransactionService.run()throws ifEventCollector.hasContext()is already true. Refactor your code to a single outeruow.run().addEvent()outsideuow.run()= events lost. TheEventCollectorALS context is created byuow.run(). If you emit events in code that doesn't go throughuow.run(), they stay on the aggregate buffer and never reach the outbox. A dev-mode warning is logged viaEventCollector.setOutOfContextLogger()(wired inapps/api/src/index.ts).- Built-in subscriber failures roll back the dispatch. Audit writer or webhook fanout throwing β the entire batch's TX rolls back, events retried at next drain with backoff. Make sure built-in subscribers stay deterministic.
- Payloads validated at enqueue. A payload that doesn't match its Zod schema in
PayloadByEventTypethrows inside the outboxINSERT, rolling back the surrounding TX. The business write fails with the bad payload β never apply one without the other.
Known limitations
- BetterAuth race window:
databaseHooksemit events post-COMMIT BetterAuth, hors outbox TX. A process crash between BetterAuth COMMIT andoutbox.enqueueloses the event. No 2PC primitive available. For SOC2-strict reconciliation: cron querySELECT u.id FROM "user" u LEFT JOIN outbox_event o ON o.aggregate_id = u.id AND o.event_type = 'user.created' WHERE o.id IS NULL. USER_EMAIL_VERIFIEDskipped when session not yet propagated β the BetterAuth/verify-emailhandler can run before auto-sign-in commits, leavingctx.context.sessionnull. Workaround: poll a periodic reconciliation, or wait for BetterAuth to exposeuserIdfrom the verification token./passkey/*register*fuzzy match was wrong β current code uses exact path/passkey/verify-registration(the only path that writes to DB). Path matching against BetterAuth internals is fragile; if BetterAuth renames a route in a minor version, the bridge silently no-ops. Mitigation: integration test that exercises the real HTTP endpoint and asserts the event lands.- Tamper-evidence:
prev_hash/hashcolumns posed inaudit_logbut calculation off (AUDIT_TAMPER_EVIDENCE=false). Implementation (Merkle batch or hash chain with row-lock) deferred until SOC2 audit demands it. - UUID v7 ordering: monotonic across milliseconds, not strict within the same ms. Sufficient for B-tree locality, not for global causal ordering.
- In-process workers:
OutboxDispatcherandWebhookDeliveryWorkerrun inside the API process. Above ~500 events/s sustained, extract to a separatebunprocess pointing at the same DB β theIOutboxWorker { start, stop }interface stays stable. - SIGTERM grace:
stopWithTimeout(25s per worker) β if a worker has in-flight work that exceeds the grace, the process exits anyway. Receivers must honorx-webhook-idempotencyto dedupe potential double-POSTs.
Files of reference
| Path | Role |
|---|---|
packages/events/src/{event-types,payloads,retention-map}.ts | Central catalog (38 events) |
packages/ddd-kit/src/events/{event-collector,on-event,outbox-mapping}.ts | ALS collector + handler factory + CloudEvents mapping |
packages/drizzle/src/schema/{outbox,audit-log,webhooks}.ts | The 4 tables |
packages/drizzle/src/services/transaction-manager.service.ts | TransactionService.run() β ALS flush + nested-run guard |
packages/drizzle/src/repositories/track-events.ts | trackEventsOnSuccess() repo helper |
apps/api/src/shared/services/outbox-dispatcher.service.ts | LISTEN/NOTIFY worker, drain, fan-out |
apps/api/src/shared/services/audit-event-subscriber.ts | Built-in audit writer |
apps/api/src/shared/services/webhook-fanout-subscriber.ts | Built-in webhook fanout (org-scoped) |
apps/api/src/modules/webhooks/infrastructure/services/webhook-delivery-worker.service.ts | HMAC POST + claim window + retry |
apps/api/src/shared/aead.ts | AEAD encrypt/decrypt for webhook secrets |
apps/api/src/shared/jitter.ts | Decorrelated jitter math |
apps/api/src/shared/event-emitter.ts | emitEvent() shared helper (used by RGPD, uploads, BetterAuth bridge) |
apps/api/src/auth.ts | BetterAuth bridge (23 events: 15 user + 8 org) |
apps/api/src/modules/{audit-log,webhooks}/ | Built-in modules (admin routes + worker) |