Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion packages/audit/lib/event.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
// `id` and `version` are stamped at the emit boundary, not by the caller.

import type { AuditActor, AuditContext, AuditOutcome, AuditTarget, AuditTrailVersion } from '@nangohq/types';
import type { AuditActor, AuditContext, AuditEventKey, AuditOutcome, AuditTarget, AuditTrailVersion } from '@nangohq/types';

export type {
AuditActor,
AuditActorType,
AuditContext,
AuditEventKey,
AuditOutcome,
AuditTarget,
AuditTargetType,
Expand Down Expand Up @@ -152,6 +153,14 @@ export type AuditResourceAction =
| { resource: 'mfa'; action: 'enrolled' | 'enabled' | 'disabled' | 'recovery_regenerated' }
| { resource: 'mfa'; action: 'verified'; metadata?: MfaVerifiedMetadata };

type EmittedKey<T extends { resource: string; action: string }> = T extends unknown ? `${T['resource']}.${T['action']}` : never;

type EmittedButNotInVocabulary = Exclude<EmittedKey<AuditResourceAction>, AuditEventKey>;
type InVocabularyButNotEmitted = Exclude<AuditEventKey, EmittedKey<AuditResourceAction>>;

true satisfies [EmittedButNotInVocabulary] extends [never] ? true : never;
true satisfies [InVocabularyButNotEmitted] extends [never] ? true : never;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit, you can probably do it in one line

true satisfies [EmittedButNotInVocabulary, InVocabularyButNotEmitted] extends [never, never] ? true : never;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied. Verified it still catches a failure in either tuple position independently, since a single assertion covering two invariants is easy to get silently half-working — an event emitted but absent from the table, and a table entry nothing emits, each fail it on their own.


export type AuditEvent = AuditEventCommon & AuditResourceAction;

export type StoredAuditEvent = AuditEvent & { id: string; version: AuditTrailVersion };
77 changes: 22 additions & 55 deletions packages/types/lib/audit-trail/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,61 +4,28 @@ export type AuditTrailVersion = '2026-07-16';
export type AuditActorType = 'user' | 'api_key' | 'system' | 'anonymous';
export type AuditOutcome = 'success' | 'failure' | 'denied';

export type AuditResource =
| 'connection'
| 'sync'
| 'function'
| 'integration'
| 'api_key'
| 'member'
| 'team'
| 'user'
| 'environment'
| 'app_auth'
| 'mfa'
| 'billing'
| 'audit_log';
// Not exported: this package emits no JavaScript, so a const here is unusable at runtime. Code needing
// the list at runtime keeps a twin checked against `AuditEventKey`, as `apiKeyScopes` does for scopes.
const AUDIT_EVENTS = {
connection: ['created', 'updated', 'metadata_updated', 'refreshed', 'deleted'],
sync: ['enabled', 'disabled', 'paused', 'started', 'triggered', 'cancelled', 'frequency_changed', 'variant_created', 'variant_deleted'],
function: ['deployed', 'upgraded', 'deleted'],
integration: ['created', 'updated', 'deleted'],
api_key: ['created', 'updated', 'deleted'],
member: ['invited', 'invite_accepted', 'invite_declined', 'invite_revoked', 'role_changed', 'removed'],
team: ['updated'],
user: ['updated'],
environment: ['created', 'updated', 'variables_changed', 'webhook_urls_changed', 'deleted'],
app_auth: ['login', 'logout', 'signup', 'password_changed', 'password_reset'],
mfa: ['enrolled', 'enabled', 'disabled', 'verified', 'recovery_regenerated'],
billing: ['plan_changed', 'trial_extended', 'details_changed', 'payment_method_added', 'payment_method_removed']
} as const;

export type AuditAction =
| 'created'
| 'reauthorized'
| 'refreshed'
| 'updated'
| 'metadata_updated'
| 'deleted'
| 'paused'
| 'started'
| 'cancelled'
| 'enabled'
| 'disabled'
| 'frequency_changed'
| 'triggered'
| 'variant_created'
| 'variant_deleted'
| 'upgraded'
| 'deployed'
| 'invited'
| 'invite_accepted'
| 'invite_declined'
| 'invite_revoked'
| 'removed'
| 'role_changed'
| 'variables_changed'
| 'webhook_urls_changed'
| 'login'
| 'logout'
| 'signup'
| 'password_changed'
| 'password_reset'
| 'enrolled'
| 'recovery_regenerated'
| 'verified'
| 'plan_changed'
| 'trial_extended'
| 'details_changed'
| 'payment_method_added'
| 'payment_method_removed'
| 'exported';
export type AuditResource = keyof typeof AUDIT_EVENTS;
export type AuditAction = (typeof AUDIT_EVENTS)[AuditResource][number];
export type AuditActionOf<R extends AuditResource> = (typeof AUDIT_EVENTS)[R][number];

export type AuditEventKey = { [R in AuditResource]: `${R}.${(typeof AUDIT_EVENTS)[R][number]}` }[AuditResource];

export type AuditScope = 'account' | 'environment';

Expand Down Expand Up @@ -86,7 +53,7 @@ export interface AuditContext {
// decision — a customer endpoint cannot be added without consciously opting in or out. The policy's
// resource/action/scope are captured as type parameters so the endpoint's declaration and the
// middleware spec that services it are checked against each other by the compiler.
export interface AuditPolicy<R extends AuditResource = AuditResource, A extends AuditAction = AuditAction, S extends AuditScope = AuditScope> {
export interface AuditPolicy<R extends AuditResource = AuditResource, A extends AuditActionOf<R> = AuditActionOf<R>, S extends AuditScope = AuditScope> {
kind: 'audit';
resource: R;
action: A;
Expand Down
Loading