UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

557 lines (527 loc) 15.4 kB
import { type ProviderInstrumentationTarget, resolveProviderInstrumentationPort, } from "../providers/instrumentation.js"; import { redactValue } from "./redaction.js"; /** * The normalized category of actor that caused application activity. * * Actors describe who or what performed work for request context, * authorization, audit logs, and diagnostics. They do not authenticate the * request by themselves. */ export type ActivityActorType = "anonymous" | "service" | "system" | "user"; /** * Whether an audited activity completed successfully or intentionally records * a failed attempt. */ export type AuditOutcome = "success" | "failure"; /** * JSON-like metadata values accepted by activity and audit descriptors. * * Metadata should stay intentionally small. Prefer stable IDs and short labels * over full request bodies, secrets, PHI, or PII. */ export type ActivityMetadataValue = | ActivityMetadataValue[] | boolean | null | number | string | { [key: string]: ActivityMetadataValue | undefined }; /** * Additional structured metadata attached to actors, tenants, resources, or * audit entries. */ export type ActivityMetadata = Record< string, ActivityMetadataValue | undefined >; /** * A normalized descriptor for the person, service, or system process that * caused application activity. * * Store this on application context as `ctx.actor` so routes, use cases, jobs, * policies, audit logs, and devtools share one identity shape. */ export interface ActivityActor { /** * The actor category. */ type: ActivityActorType; /** * Stable application ID for this actor, when known. */ id?: string; /** * Human-readable label for diagnostics and audit views. */ displayName?: string; /** * Small, redaction-safe metadata about the actor. */ metadata?: ActivityMetadata; } /** * A normalized tenant/account/workspace scope for activity. * * This is a context value used by audit logs, authorization, and diagnostics. * It does not create, load, or persist a tenant record. */ export interface ActivityTenant { /** * Stable tenant/account/workspace ID. */ id: string; /** * Optional human-readable tenant slug. */ slug?: string; /** * Small, redaction-safe metadata about the tenant. */ metadata?: ActivityMetadata; } /** * A normalized descriptor for the business object affected by an audit entry. */ export interface ActivityResource { /** * Resource type, usually a singular domain noun such as "post", "invoice", * or "appointment". */ type: string; /** * Stable resource ID, when known. */ id?: string; /** * Human-readable resource label for audit views. */ name?: string; /** * Small, redaction-safe metadata about the resource. */ metadata?: ActivityMetadata; } /** * A normalized audit/activity log entry. * * Application code usually records entries through an audit port wrapped with * `createAmbientAuditLog(...)` from `@beignet/core/server`, which fills * missing actor, tenant, request ID, and trace ID fields from the ambient * request context at record time. Durability depends on the `AuditLogPort` * implementation. */ export interface AuditLogEntry { /** * Stable action name, usually namespaced by feature and workflow. * * @example "posts.publish" */ action: string; /** * Actor that caused the activity. */ actor: ActivityActor; /** * Timestamp assigned when the activity occurred. */ occurredAt: Date; /** * Whether the activity succeeded or records a failed attempt. */ outcome: AuditOutcome; /** * Small, redaction-safe metadata about the activity. */ metadata?: ActivityMetadata; /** * Optional human-readable audit message. */ message?: string; /** * Request correlation ID, when the activity originated from a request or * background context. */ requestId?: string; /** * Business resource affected by the activity. */ resource?: ActivityResource; /** * Tenant/account/workspace scope for the activity. */ tenant?: ActivityTenant; /** * Trace correlation ID, when tracing is enabled. */ traceId?: string; } /** * Input accepted by `AuditLogPort.record(...)`. * * `actor`, `occurredAt`, and `outcome` are optional at call sites. Wrappers * such as `createAmbientAuditLog(...)` fill a missing actor from the ambient * request context. Adapters that store audit entries should call * `normalizeAuditLogEntry(...)` before persistence or otherwise apply * equivalent defaults; entries without an actor normalize to an anonymous * actor. */ export type AuditLogEntryInput = Omit< AuditLogEntry, "actor" | "occurredAt" | "outcome" > & { actor?: ActivityActor; occurredAt?: Date; outcome?: AuditOutcome; }; /** * App-facing port for audit/activity logging. * * Production implementations should usually write to a durable database table, * append-only log, or external audit service. Tests can use an in-memory * adapter. Application code should depend on this interface, not on a concrete * audit provider. */ export interface AuditLogPort { /** * Persist or capture an audit entry. */ record(entry: AuditLogEntryInput): Promise<void> | void; } /** * In-memory audit log port used by tests and local examples. */ export interface MemoryAuditLogPort extends AuditLogPort { /** * Captured, normalized, redacted audit entries. */ entries: AuditLogEntry[]; } /** * Options shared by audit log wrappers and in-memory audit adapters. */ export interface AuditLogOptions { /** * Optional final redaction/customization step applied after Beignet's default * metadata redaction. */ redact?: (entry: AuditLogEntry) => AuditLogEntry; } /** * Create an anonymous actor descriptor for unauthenticated activity. * * This helper only creates a normalized context value. It does not perform * authentication. * * @example * ```ts * const actor = createAnonymousActor(); * ``` * * @param options - Optional display name or metadata to include. * @returns An activity actor with `type: "anonymous"`. */ export function createAnonymousActor( options: Omit<ActivityActor, "type"> = {}, ): ActivityActor { return { type: "anonymous", ...options }; } /** * Create a service actor descriptor for work initiated by another service or * integration. * * This is useful for webhooks, internal service calls, or integration-driven * background jobs. * * @example * ```ts * const actor = createServiceActor("stripe-webhook"); * ``` * * @param id - Stable service or integration ID. * @param options - Optional display name or metadata to include. * @returns An activity actor with `type: "service"`. */ export function createServiceActor( id: string, options: Omit<ActivityActor, "type" | "id"> = {}, ): ActivityActor { return { type: "service", id, ...options }; } /** * Create a system actor descriptor for framework or app-owned background work. * * Use this for schedules, scripts, maintenance jobs, and other work that * is not directly caused by a user or external service. * * @example * ```ts * const actor = createSystemActor("nightly-maintenance"); * ``` * * @param id - Stable system actor ID. Defaults to `"system"`. * @param options - Optional display name or metadata to include. * @returns An activity actor with `type: "system"`. */ export function createSystemActor( id = "system", options: Omit<ActivityActor, "type" | "id"> = {}, ): ActivityActor { return { type: "system", id, ...options }; } /** * Create a user actor descriptor for authenticated user activity. * * This helper only normalizes a known user ID for context, authorization, * audit, and diagnostics. It does not verify a session or load a user record. * Resolve authentication first, then call this helper with the authenticated * user ID. * * @example * ```ts * const actor = createUserActor(session.user.id, { * displayName: session.user.name, * }); * ``` * * @param id - Stable application user ID. * @param options - Optional display name or metadata to include. * @returns An activity actor with `type: "user"`. */ export function createUserActor( id: string, options: Omit<ActivityActor, "type" | "id"> = {}, ): ActivityActor { return { type: "user", id, ...options }; } /** * Create a tenant/account/workspace descriptor for request or background * context. * * This helper only creates a normalized context value used by audit, * authorization, logs, and diagnostics. It does not create, load, or persist a * tenant record. * * @example * ```ts * const tenant = createTenant(session.organizationId, { * slug: session.organizationSlug, * }); * ``` * * @param id - Stable tenant/account/workspace ID. * @param options - Optional slug or metadata to include. * @returns A normalized activity tenant descriptor. */ export function createTenant( id: string, options: Omit<ActivityTenant, "id"> = {}, ): ActivityTenant { return { id, ...options }; } /** * Fill default audit fields for an input entry. * * @param entry - Partial audit entry accepted by `AuditLogPort.record(...)`. * @returns A complete audit entry with `actor`, `occurredAt`, and `outcome` * populated. Entries without an actor default to an anonymous actor. */ export function normalizeAuditLogEntry( entry: AuditLogEntryInput, ): AuditLogEntry { return { ...entry, actor: entry.actor ?? createAnonymousActor(), occurredAt: entry.occurredAt ?? new Date(), outcome: entry.outcome ?? "success", }; } /** * Redact metadata on an already-normalized audit entry. * * This redacts metadata values on the entry, actor, tenant, and resource using * the default redaction rules from `redactValue(...)`. * * @param entry - Audit entry to redact. * @returns A shallow copy with redacted metadata fields. */ export function redactAuditLogEntry(entry: AuditLogEntry): AuditLogEntry { return { ...entry, actor: { ...entry.actor, metadata: entry.actor.metadata ? redactValue(entry.actor.metadata) : entry.actor.metadata, }, tenant: entry.tenant ? { ...entry.tenant, metadata: entry.tenant.metadata ? redactValue(entry.tenant.metadata) : entry.tenant.metadata, } : entry.tenant, resource: entry.resource ? { ...entry.resource, metadata: entry.resource.metadata ? redactValue(entry.resource.metadata) : entry.resource.metadata, } : entry.resource, metadata: entry.metadata ? redactValue(entry.metadata) : entry.metadata, }; } /** * Wrap an audit log port with default audit metadata redaction. * * Use this around durable adapters so application code can record entries * without each call site remembering to redact metadata. * * @param audit - Underlying audit log port to write to after redaction. * @param options - Optional final redaction/customization hook. * @returns An audit log port that normalizes and redacts before writing. */ export function createRedactedAuditLog( audit: AuditLogPort, options: AuditLogOptions = {}, ): AuditLogPort { return { record(entry) { const normalized = normalizeAuditLogEntry(entry); const redacted = options.redact ? options.redact(redactAuditLogEntry(normalized)) : redactAuditLogEntry(normalized); return audit.record(redacted); }, }; } /** * Options for wrapping an audit log with instrumentation emission. */ export interface InstrumentedAuditLogOptions { /** * Durable audit log to write first. */ audit: AuditLogPort; /** * Instrumentation sink, port, or ports object. Pass the app ports object so * the sink (`ports.instrumentation`, then `ports.devtools`) is resolved * lazily on each write and observes provider startup order. */ instrumentation?: ProviderInstrumentationTarget; /** * Whether to emit instrumentation events. Defaults to true. */ emit?: boolean; /** * Optional app-owned redactor applied after Beignet's audit redaction. */ redact?: (entry: AuditLogEntry) => AuditLogEntry; } function prepareInstrumentedEntry( input: AuditLogEntryInput, redact?: (entry: AuditLogEntry) => AuditLogEntry, ): AuditLogEntry { const redacted = redactAuditLogEntry(normalizeAuditLogEntry(input)); return redact ? redact(redacted) : redacted; } function auditSummary(entry: AuditLogEntry): string { const resource = entry.resource?.id ? `${entry.resource.type}:${entry.resource.id}` : entry.resource?.type; const outcome = entry.outcome === "failure" ? "failed" : "succeeded"; return resource ? `${entry.action} ${outcome} for ${resource}` : `${entry.action} ${outcome}`; } /** * Wrap an audit log so durable audit writes also appear in instrumentation * sinks such as devtools. * * Instrumentation failures are ignored so audit persistence remains the * source of truth. * * @example * ```ts * const audit = createInstrumentedAuditLog({ * audit: createDrizzleSqliteAuditLogPort(db), * instrumentation: ports, * }); * ``` */ export function createInstrumentedAuditLog( options: InstrumentedAuditLogOptions, ): AuditLogPort { return { async record(input) { const entry = prepareInstrumentedEntry(input, options.redact); await options.audit.record(entry); if (options.emit === false) return; const port = resolveProviderInstrumentationPort(options.instrumentation); if (!port) return; try { port.record({ type: "custom", watcher: "audit", name: entry.action, label: "Audit", summary: auditSummary(entry), requestId: entry.requestId, traceId: entry.traceId, details: { action: entry.action, actor: entry.actor, tenant: entry.tenant, resource: entry.resource, outcome: entry.outcome, message: entry.message, metadata: entry.metadata, occurredAt: entry.occurredAt.toISOString(), }, }); } catch { // Instrumentation is an observer; durable audit writes must not depend on it. } }, }; } /** * Create an in-memory audit log for tests and local examples. * * Entries are normalized and redacted before being pushed into the shared * `entries` array. * * @example * ```ts * const audit = createMemoryAuditLog(); * await audit.record({ * action: "posts.publish", * actor: createUserActor("user_1"), * }); * expect(audit.entries).toHaveLength(1); * ``` * * @param entries - Optional backing array, useful when tests need shared state. * @param options - Optional final redaction/customization hook. * @returns An in-memory audit log port with captured `entries`. */ export function createMemoryAuditLog( entries: AuditLogEntry[] = [], options: AuditLogOptions = {}, ): MemoryAuditLogPort { return { entries, record(entry) { const normalized = normalizeAuditLogEntry(entry); entries.push( options.redact ? options.redact(redactAuditLogEntry(normalized)) : redactAuditLogEntry(normalized), ); }, }; }