Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
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 };
8 changes: 6 additions & 2 deletions packages/server/lib/middleware/audit.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type { RequestLocals } from '../utils/express.js';
import type { AuditActor, AuditContext, AuditEvent, AuditOutcome, AuditTarget, AuditTargetType, MfaVerifiedMetadata } from '@nangohq/audit';
import type {
AcceptInvite,
AuditAction,
AuditActionOf,
AuditPolicy,
AuditResource,
AuditScope,
Expand Down Expand Up @@ -80,7 +80,11 @@ type AuditRequest<TEndpoint extends Endpoint<any>> = Request<TEndpoint['Params']
type AuditableEndpoint = Endpoint<any> & { Audit: AuditPolicy };

const Audit = {
auditable: <R extends AuditResource, A extends AuditAction, S extends AuditScope>(policy: { resource: R; action: A; scope: S }): AuditPolicy<R, A, S> => ({
auditable: <R extends AuditResource, A extends AuditActionOf<R>, S extends AuditScope>(policy: {
resource: R;
action: A;
scope: S;
}): AuditPolicy<R, A, S> => ({
kind: 'audit',
...policy
})
Expand Down
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 keeps a twin checked against `AuditEventKey`, as `apiKeyScopes` does for scopes.

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.

can you not achieve the same derivation without a const that's gonna be discarded at compile time

interface AuditEventTable {
    connection: 'created' | 'updated' | 'metadata_updated' | 'refreshed' | 'deleted';
    sync: 'enabled' | 'disabled' | ...
}

export type AuditResource = keyof AuditEventTable;
export type AuditActionOf<R extends AuditResource> = AuditEventTable[R];
export type AuditAction = AuditActionOf<AuditResource>;
export type AuditEventKey = { [R in AuditResource]: `${R}.${AuditEventTable[R]}` }[AuditResource];

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.

good point, easier.

@pfreixes pfreixes Jul 31, 2026

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.

Done — interface AuditEventTable in 4c39dc5. Better than I had it: the const was discarded at compile time and needed two lines of comment explaining why it couldn't be exported, both of which are gone now. All five compile-time guards re-verified against the new derivation (endpoint declaration, policy builder, and both assertion directions).

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 AuditActionOf<R extends AuditResource> = (typeof AUDIT_EVENTS)[R][number];
export type AuditAction = AuditActionOf<AuditResource>;

export type AuditEventKey = { [R in AuditResource]: `${R}.${AuditActionOf<R>}` }[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