@awsmbiz/inbis-audit 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,147 @@
1
- # Temporary Holding Version
1
+ # @awsmbiz/inbis-audit
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The audit trail for Inbis applications: one `audit_event` table for platform and app
4
+ events, a D1 `AuditSink` (the interface from `@awsmbiz/inbis-core`) that redacts metadata before
5
+ storing it, an extensible action registry, and package-owned D1 migrations (ADR-4).
6
+
7
+ Installing it replaces `@awsmbiz/inbis-auth`'s interim `auth_audit_event` table (plan Q39): pass the
8
+ sink to `createInbisAuth`, and copy the interim history over with `importAuthAuditHistory(db)`.
9
+
10
+ ## Public API
11
+
12
+ | Entry | Contents |
13
+ |---|---|
14
+ | `@awsmbiz/inbis-audit` | `createD1AuditLog`, `createAuditActions`, `PLATFORM_AUDIT_NAMESPACES`, `AUDIT_MIGRATIONS_TABLE`, `importAuthAuditHistory`, `listAuthAuditEvents`; types `AuthAuditShapedRecord`, `AuditLog`, `AuditEventRecord`, `AuditTimelineFilter`, `AuditTimelineQuery`, `D1AuditLogOptions`, `AuditActions`, `AuditActionsInput`, `AuditActionCatalogue` |
15
+ | `@awsmbiz/inbis-audit/hono` | `mountAuditTimeline`, `mountOwnActivity`; types `AuditTimelineItem`, `AuditTimelineResponse`, `MountAuditTimelineOptions`, `MountOwnActivityOptions` |
16
+ | `migrations/` | Numbered, frozen SQL plus `migrations.lock.json`, shipped in the tarball |
17
+
18
+ Peer dependencies: `drizzle-orm` ^0.45.4; `hono` ^4.13.14 (optional, for `/hono`).
19
+
20
+ ## Setting it up
21
+
22
+ 1. Declare an audit binding to the app's database, after auth's (and access-control's) and
23
+ before jobs' and the app's own (ADR-4 platform order), with the same `database_id`:
24
+
25
+ ```jsonc
26
+ "d1_databases": [
27
+ { "binding": "AUTH_DB", "database_name": "myapp-dev", "database_id": "<id>",
28
+ "migrations_dir": "node_modules/@awsmbiz/inbis-auth/migrations", "migrations_table": "_inbis_auth_migrations" },
29
+ { "binding": "AUDIT_DB", "database_name": "myapp-dev", "database_id": "<id>",
30
+ "migrations_dir": "node_modules/@awsmbiz/inbis-audit/migrations", "migrations_table": "_inbis_audit_migrations" },
31
+ { "binding": "DB", "database_name": "myapp-dev", "database_id": "<id>",
32
+ "migrations_dir": "migrations", "migrations_table": "d1_migrations" }
33
+ ]
34
+ ```
35
+
36
+ `pnpm exec inbis-db migrate --local` (or `--remote --env <env>`) applies them in that order.
37
+
38
+ 2. Register the actions the app records, and pass the sink to auth:
39
+
40
+ ```ts
41
+ import { createAuditActions, createD1AuditLog } from "@awsmbiz/inbis-audit";
42
+ import { AUTH_AUDIT_ACTIONS, createInbisAuth } from "@awsmbiz/inbis-auth";
43
+
44
+ // Once per isolate: the platform catalogues the app uses, plus its own.
45
+ const actions = createAuditActions({
46
+ platform: [AUTH_AUDIT_ACTIONS],
47
+ app: { invoiceSent: "billing.invoice.sent" },
48
+ });
49
+
50
+ // Per request:
51
+ const audit = createD1AuditLog(requireBinding(c.env, "AUDIT_DB"), { actions, strict: isLocal, logger: c.var.logger });
52
+ createInbisAuth({ ..., audit });
53
+ await audit.record({ action: "billing.invoice.sent", occurredAt: new Date(), actorId: user.id, subjectId: `invoice:${id}` });
54
+ ```
55
+
56
+ ## The action registry
57
+
58
+ `createAuditActions({ platform, app })` lists every action name the trail accepts. Each
59
+ platform package that records events exports its catalogue (`AUTH_AUDIT_ACTIONS`); the app
60
+ adds its own, as an `as const` object or a list.
61
+
62
+ - Names are dotted lowercase: `namespace.thing[.more]` (`[a-z][a-z0-9_]*` segments).
63
+ - App actions may not use a platform namespace (`PLATFORM_AUDIT_NAMESPACES`: `access`,
64
+ `audit`, `auth`, `cache`, `jobs`, `notifications`, `platform`, `storage`).
65
+ - Both are programming errors: `createAuditActions` throws at startup.
66
+
67
+ `createD1AuditLog(db, { actions, strict, logger })` checks each event's action:
68
+
69
+ - `strict: true` (local development and tests): an unregistered action throws and nothing is
70
+ stored. `@awsmbiz/inbis-auth` logs a failing sink as `auth.audit.record_failed`; tests should assert
71
+ there is none.
72
+ - `strict: false` (default; deployed): the event is stored anyway and
73
+ `audit.action.unregistered` is logged, because losing an audit event is worse than an
74
+ unexpected name.
75
+
76
+ ## What is stored
77
+
78
+ `audit_event` holds `id` (UUIDv7), `action`, `actor_id` (`null` for the system or an unknown
79
+ actor), `subject_id` (e.g. `user:<id>`), `occurred_at` (epoch ms), `request_id` and
80
+ `metadata`. Metadata passes through `redact()` from `@awsmbiz/inbis-core` before it is stored, so
81
+ credential-looking fields (passwords, tokens, OTPs, codes, cookies, secrets) and
82
+ `Bearer`/`Basic` values become `[REDACTED]` whatever the caller passed. Events have no
83
+ foreign keys and outlive the users and records they name.
84
+
85
+ Indexes serve timeline queries newest first: overall or by time range
86
+ (`occurred_at, id`), and by actor, subject or action (each with `occurred_at`).
87
+
88
+ ## The activity timeline
89
+
90
+ `AuditLog.list(query)` returns events newest first (`occurred_at`, then `id`), as
91
+ `{ items, nextCursor }` (keyset; `limit` 1–200, default 50). Every filter given narrows the
92
+ result: `action` (exact name), `actorId`, `subjectId` (e.g. `user:<id>`), `from` (inclusive) and
93
+ `to` (exclusive) as `Date`s. Keep the same filters when passing `nextCursor` back as `cursor`.
94
+ `AuditLog.count(filter)` counts the events matching the same filters, without paging (e.g. failed
95
+ sign-ins in the last day for the admin dashboard, `mountAuthStats` in `@awsmbiz/inbis-auth/hono`).
96
+
97
+ `@awsmbiz/inbis-audit/hono` serves it over HTTP. The package depends on no auth package, so the app
98
+ passes the guard that admits the caller:
99
+
100
+ ```ts
101
+ import { mountAuditTimeline, mountOwnActivity } from "@awsmbiz/inbis-audit/hono";
102
+ import { requireAdmin, requireAuth } from "@awsmbiz/inbis-auth/hono";
103
+
104
+ const log = (c) => createD1AuditLog(requireBinding(c.env, "AUDIT_DB"), { actions });
105
+ mountAuditTimeline(app, { log, guard: requireAdmin() });
106
+ mountOwnActivity(app, { log, guard: requireAuth(), actorIdOf: (c) => c.var.user?.id });
107
+ ```
108
+
109
+ | Route | Who | Query |
110
+ |---|---|---|
111
+ | `GET /api/admin/audit-events` (`path` to change) | The guard's: pass the app's admin policy | `action`, `actor`, `subject`, `from`, `to`, `limit`, `cursor` |
112
+ | `GET /api/me/activity` (`path` to change) | Anyone the guard admits; only events whose actor is the caller | `action`, `from`, `to`, `limit`, `cursor` (`actor`/`subject` are ignored) |
113
+
114
+ `from`/`to` are ISO 8601 dates (`2026-11-01`, UTC midnight) or date-times; anything else is
115
+ `422 VALIDATION_FAILED` with `details.issues[].path` `["from"]`/`["to"]`. Both answer
116
+ `{ items: [{ id, action, actorId, subjectId, occurredAt (ISO), requestId, metadata }], nextCursor }`
117
+ with `Cache-Control: no-store`. My activity returns the caller's own metadata (sign-in method,
118
+ IP address, browser), so users can spot sign-ins they did not make; events *about* the user by
119
+ someone else (an admin's ban, say) are not included, since their metadata is the admin's.
120
+
121
+ `AuditLog.forgetUser({ id, email, phoneNumber })` strips `email`, `phoneNumber`, `ipAddress`
122
+ and `userAgent` from the metadata of every event by or about the user, or naming its email
123
+ (any case) or number, so only the user ID remains (plan §5.7). `@awsmbiz/inbis-auth` calls it on
124
+ account deletion (it is the optional `forgetUser` of `AuditSink`).
125
+
126
+ ## Interim auth history
127
+
128
+ `@awsmbiz/inbis-audit`'s migrations never touch `@awsmbiz/inbis-auth`'s tables, so the two can be added in
129
+ either order. Taking over from auth's interim `auth_audit_event` table is an explicit step:
130
+
131
+ - `importAuthAuditHistory(db)` copies every interim row into `audit_event` (same `id`;
132
+ `resource` becomes `subjectId`) and resolves with the number copied. It is idempotent (rows
133
+ already copied are skipped) and a no-op (`0`) on a database without the interim table, so run
134
+ it once after adopting audit, on each deploy, or from the cron trigger (the playground does
135
+ the latter: `createPlaygroundScheduled`). `db` is the audit binding.
136
+ - The `inbis-auth bootstrap-admin` CLI writes SQL directly; it records its event in
137
+ `audit_event` whenever that table exists in the database, and in the interim table otherwise.
138
+ - `listAuthAuditEvents(log, input)` reads the trail in auth's interim record shape, for
139
+ `mountAuthAudit(app, { list: (c, input) => listAuthAuditEvents(auditLog(c), input) })`, so
140
+ `GET /api/admin/auth-audit-events` keeps its response for existing callers.
141
+ - The interim table stays: it is auth's fallback for apps without `@awsmbiz/inbis-audit` (Q39).
142
+
143
+ ## Changing the schema (maintainers)
144
+
145
+ Migrations are append-only: add `migrations/000N_*.sql`, update `src/schema.ts` to match,
146
+ then `pnpm exec inbis-db lock migrations`. `pnpm check:migrations` fails if a released file
147
+ changed.
@@ -0,0 +1,102 @@
1
+ import { AuditSink, ForgottenUser, Logger } from "@awsmbiz/inbis-core";
2
+ import { Page, PageInput } from "@awsmbiz/inbis-db";
3
+ /**
4
+ * The action registry: every action name the app's audit trail accepts.
5
+ * Platform packages export their catalogues (e.g. `AUTH_AUDIT_ACTIONS` from
6
+ * `@awsmbiz/inbis-auth`); the app adds its own.
7
+ */
8
+ /** A catalogue of action names: an `as const` object of names, or a list. */
9
+ type AuditActionCatalogue = Readonly<Record<string, string>> | readonly string[];
10
+ interface AuditActionsInput {
11
+ /** Catalogues exported by platform packages, e.g. `AUTH_AUDIT_ACTIONS`. */
12
+ platform?: readonly AuditActionCatalogue[];
13
+ /** The app's own actions. They may not use a platform namespace ({@link PLATFORM_AUDIT_NAMESPACES}). */
14
+ app?: AuditActionCatalogue;
15
+ }
16
+ /** The registered action names. */
17
+ interface AuditActions {
18
+ has(action: string): boolean;
19
+ /** Every registered name, sorted. */
20
+ readonly all: readonly string[];
21
+ }
22
+ /**
23
+ * First segments reserved for platform packages. An app action such as
24
+ * `auth.something` is rejected, so app events never pass for platform ones.
25
+ */
26
+ declare const PLATFORM_AUDIT_NAMESPACES: readonly ["access", "audit", "auth", "cache", "jobs", "notifications", "platform", "storage"];
27
+ /**
28
+ * Builds the registry. Throws on a malformed name, or on an app action in a
29
+ * platform namespace: both are programming errors, found at startup.
30
+ */
31
+ declare function createAuditActions({ platform, app }: AuditActionsInput): AuditActions;
32
+ /** One stored audit event. */
33
+ interface AuditEventRecord {
34
+ id: string;
35
+ /** Dotted action name, e.g. `auth.sign_in.succeeded`. */
36
+ action: string;
37
+ actorId: string | null;
38
+ /** `AuditEvent.subjectId`, e.g. `user:<id>`. */
39
+ subjectId: string | null;
40
+ occurredAt: Date;
41
+ requestId: string | null;
42
+ /** Redacted, secret-free detail. */
43
+ metadata: Record<string, unknown> | null;
44
+ }
45
+ /** Timeline filters; each one given narrows the result. */
46
+ interface AuditTimelineFilter {
47
+ /** Exact action name, e.g. `auth.sign_in.failed`. */
48
+ action?: string | undefined;
49
+ /** Events performed by this user ID. */
50
+ actorId?: string | undefined;
51
+ /** Events about this subject, e.g. `user:<id>`. */
52
+ subjectId?: string | undefined;
53
+ /** Events at or after this instant. */
54
+ from?: Date | undefined;
55
+ /** Events before this instant (exclusive). */
56
+ to?: Date | undefined;
57
+ }
58
+ /** A timeline request: filters plus keyset paging. */
59
+ type AuditTimelineQuery = AuditTimelineFilter & PageInput;
60
+ /** The audit trail over `audit_event`: an `AuditSink`, plus reading. */
61
+ interface AuditLog extends AuditSink {
62
+ /**
63
+ * After an account deletion: strips `email`, `phoneNumber`, `ipAddress` and
64
+ * `userAgent` from every event by or about the user, or naming its email
65
+ * (any case) or number, so only the user ID remains (plan §5.7).
66
+ */
67
+ forgetUser(user: ForgottenUser): Promise<void>;
68
+ /**
69
+ * The timeline: events matching every filter given, newest first (keyset
70
+ * pagination; `limit` defaults to 50, at most 200). Keep the same filters
71
+ * when following `nextCursor`.
72
+ */
73
+ list(query?: AuditTimelineQuery): Promise<Page<AuditEventRecord>>;
74
+ /**
75
+ * How many events match every filter given (the same filters as
76
+ * {@link list}, without paging), e.g. failed sign-ins in the last day.
77
+ */
78
+ count(filter?: AuditTimelineFilter): Promise<number>;
79
+ /**
80
+ * How many distinct actors performed the matching events (the same filters
81
+ * as {@link count}; events without an actor are not counted), e.g. users who
82
+ * signed in during the last 7 days.
83
+ */
84
+ countActors(filter?: AuditTimelineFilter): Promise<number>;
85
+ }
86
+ interface D1AuditLogOptions {
87
+ /** The registered action names (see `createAuditActions`). */
88
+ actions: AuditActions;
89
+ /**
90
+ * `true` (development and tests): an unregistered action throws and nothing
91
+ * is stored, so a typo fails the test that triggers it. `false` (default,
92
+ * production): it is stored anyway and logged as `audit.action.unregistered`,
93
+ * because losing an audit event is worse than an unexpected name.
94
+ */
95
+ strict?: boolean;
96
+ /** Receives the `audit.action.unregistered` warning. Defaults to a console JSON logger. */
97
+ logger?: Logger;
98
+ }
99
+ /** Creates the D1-backed audit log over the database holding `audit_event` (the app's audit binding). */
100
+ declare function createD1AuditLog(d1: D1Database, { actions, strict, logger }: D1AuditLogOptions): AuditLog;
101
+ export { D1AuditLogOptions as a, AuditActions as c, createAuditActions as d, AuditTimelineQuery as i, AuditActionsInput as l, AuditLog as n, createD1AuditLog as o, AuditTimelineFilter as r, AuditActionCatalogue as s, AuditEventRecord as t, PLATFORM_AUDIT_NAMESPACES as u };
102
+ //# sourceMappingURL=audit-log-xkH9ZIGE.d.ts.map
package/dist/hono.d.ts ADDED
@@ -0,0 +1,46 @@
1
+ import { n as AuditLog, t as AuditEventRecord } from "./audit-log-xkH9ZIGE.js";
2
+ import { Context, Env, Hono, MiddlewareHandler } from "hono";
3
+ /** An event as the timeline routes return it: {@link AuditEventRecord} with `occurredAt` as ISO 8601. */
4
+ export interface AuditTimelineItem extends Omit<AuditEventRecord, "occurredAt"> {
5
+ occurredAt: string;
6
+ }
7
+ /** The timeline routes' response body. */
8
+ export interface AuditTimelineResponse {
9
+ items: AuditTimelineItem[];
10
+ /** Pass as `cursor` (with the same filters) for the next page; `null` on the last. */
11
+ nextCursor: string | null;
12
+ }
13
+ interface TimelineRouteOptions<E extends Env> {
14
+ /** The app's audit log for the request. */
15
+ log: (c: Context<E>) => AuditLog;
16
+ /**
17
+ * Admits the caller, or throws (e.g. `requireAdmin()` / `requireAuth()` from
18
+ * `@awsmbiz/inbis-auth/hono`). Any env: guards are typed for their own variables.
19
+ */
20
+ guard: MiddlewareHandler;
21
+ /** Route path. */
22
+ path?: string;
23
+ }
24
+ export type MountAuditTimelineOptions<E extends Env = Env> = TimelineRouteOptions<E>;
25
+ export interface MountOwnActivityOptions<E extends Env = Env> extends TimelineRouteOptions<E> {
26
+ /** The signed-in caller's user ID; `null`/`undefined` is refused with `401 UNAUTHENTICATED`. */
27
+ actorIdOf: (c: Context<E>) => string | null | undefined;
28
+ }
29
+ /**
30
+ * Mounts `GET /api/admin/audit-events` (default path): every audit event,
31
+ * newest first, as {@link AuditTimelineResponse}. Query: `action`, `actor`
32
+ * (user ID), `subject` (e.g. `user:<id>`), `from` (inclusive) and `to`
33
+ * (exclusive) as ISO 8601 dates or date-times, `limit` (1–200, default 50) and
34
+ * `cursor`. A malformed date is `422 VALIDATION_FAILED` (`details.issues[].path`).
35
+ * Admission is the `guard`'s: pass the app's admin policy.
36
+ */
37
+ export declare function mountAuditTimeline<E extends Env>(app: Hono<E>, options: MountAuditTimelineOptions<E>): void;
38
+ /**
39
+ * Mounts `GET /api/me/activity` (default path): the caller's own events (those
40
+ * it performed), newest first, as {@link AuditTimelineResponse}. Accepts
41
+ * `action`, `from`, `to`, `limit` and `cursor` like {@link mountAuditTimeline};
42
+ * the actor is always the caller, and any `actor` or `subject` parameter is ignored.
43
+ */
44
+ export declare function mountOwnActivity<E extends Env>(app: Hono<E>, options: MountOwnActivityOptions<E>): void;
45
+ export {};
46
+ //# sourceMappingURL=hono.d.ts.map
package/dist/hono.js ADDED
@@ -0,0 +1,94 @@
1
+ import { AppError, validationFailed } from "@awsmbiz/inbis-core";
2
+ // src/hono.ts
3
+ /**
4
+ * Hono routes for the activity timeline: `@awsmbiz/inbis-audit/hono`.
5
+ *
6
+ * The package knows nothing about sessions or roles (it depends on no auth
7
+ * package): the app passes the guard middleware that admits the caller, and
8
+ * for the own-activity route, how to read the caller's user ID.
9
+ *
10
+ * ```ts
11
+ * import { requireAdmin, requireAuth } from "@awsmbiz/inbis-auth/hono";
12
+ * const log = (c) => createD1AuditLog(c.env.AUDIT_DB, { actions });
13
+ * mountAuditTimeline(app, { log, guard: requireAdmin() });
14
+ * mountOwnActivity(app, { log, guard: requireAuth(), actorIdOf: (c) => c.var.user?.id });
15
+ * ```
16
+ */
17
+ /** `YYYY-MM-DD` or a full ISO 8601 date-time. */
18
+ const ISO_DATE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2}))?$/;
19
+ function dateParam(c, name) {
20
+ const raw = c.req.query(name);
21
+ if (raw === void 0 || raw === "") return void 0;
22
+ const date = new Date(raw);
23
+ if (!ISO_DATE.test(raw) || Number.isNaN(date.getTime())) throw validationFailed([{
24
+ path: [name],
25
+ message: "Must be an ISO 8601 date or date-time."
26
+ }]);
27
+ return date;
28
+ }
29
+ function textParam(c, name) {
30
+ const raw = c.req.query(name);
31
+ return raw === void 0 || raw === "" ? void 0 : raw;
32
+ }
33
+ /** Paging, action and date range: the query parameters both routes accept. */
34
+ function commonQuery(c) {
35
+ return {
36
+ limit: c.req.query("limit"),
37
+ cursor: c.req.query("cursor"),
38
+ action: textParam(c, "action"),
39
+ from: dateParam(c, "from"),
40
+ to: dateParam(c, "to")
41
+ };
42
+ }
43
+ async function respond(c, log, query) {
44
+ const page = await log.list(query);
45
+ const body = {
46
+ items: page.items.map((item) => ({
47
+ ...item,
48
+ occurredAt: item.occurredAt.toISOString()
49
+ })),
50
+ nextCursor: page.nextCursor
51
+ };
52
+ c.header("cache-control", "no-store");
53
+ return c.json(body);
54
+ }
55
+ /**
56
+ * Mounts `GET /api/admin/audit-events` (default path): every audit event,
57
+ * newest first, as {@link AuditTimelineResponse}. Query: `action`, `actor`
58
+ * (user ID), `subject` (e.g. `user:<id>`), `from` (inclusive) and `to`
59
+ * (exclusive) as ISO 8601 dates or date-times, `limit` (1–200, default 50) and
60
+ * `cursor`. A malformed date is `422 VALIDATION_FAILED` (`details.issues[].path`).
61
+ * Admission is the `guard`'s: pass the app's admin policy.
62
+ */
63
+ function mountAuditTimeline(app, options) {
64
+ const { log, guard } = options;
65
+ app.get(options.path ?? "/api/admin/audit-events", guard, (c) => respond(c, log(c), {
66
+ ...commonQuery(c),
67
+ actorId: textParam(c, "actor"),
68
+ subjectId: textParam(c, "subject")
69
+ }));
70
+ }
71
+ /**
72
+ * Mounts `GET /api/me/activity` (default path): the caller's own events (those
73
+ * it performed), newest first, as {@link AuditTimelineResponse}. Accepts
74
+ * `action`, `from`, `to`, `limit` and `cursor` like {@link mountAuditTimeline};
75
+ * the actor is always the caller, and any `actor` or `subject` parameter is ignored.
76
+ */
77
+ function mountOwnActivity(app, options) {
78
+ const { log, guard, actorIdOf } = options;
79
+ app.get(options.path ?? "/api/me/activity", guard, (c) => {
80
+ const actorId = actorIdOf(c);
81
+ if (actorId === null || actorId === void 0 || actorId === "") throw new AppError({
82
+ code: "UNAUTHENTICATED",
83
+ status: 401,
84
+ message: "Sign in to continue."
85
+ });
86
+ return respond(c, log(c), {
87
+ ...commonQuery(c),
88
+ actorId
89
+ });
90
+ });
91
+ }
92
+ export { mountAuditTimeline, mountOwnActivity };
93
+
94
+ //# sourceMappingURL=hono.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hono.js","names":[],"sources":["../src/hono.ts"],"sourcesContent":["/**\n * Hono routes for the activity timeline: `@awsmbiz/inbis-audit/hono`.\n *\n * The package knows nothing about sessions or roles (it depends on no auth\n * package): the app passes the guard middleware that admits the caller, and\n * for the own-activity route, how to read the caller's user ID.\n *\n * ```ts\n * import { requireAdmin, requireAuth } from \"@awsmbiz/inbis-auth/hono\";\n * const log = (c) => createD1AuditLog(c.env.AUDIT_DB, { actions });\n * mountAuditTimeline(app, { log, guard: requireAdmin() });\n * mountOwnActivity(app, { log, guard: requireAuth(), actorIdOf: (c) => c.var.user?.id });\n * ```\n */\nimport { AppError, validationFailed } from \"@awsmbiz/inbis-core\";\nimport type { Context, Env, Hono, MiddlewareHandler } from \"hono\";\nimport type { AuditEventRecord, AuditLog, AuditTimelineQuery } from \"./audit-log.js\";\n\n/** An event as the timeline routes return it: {@link AuditEventRecord} with `occurredAt` as ISO 8601. */\nexport interface AuditTimelineItem extends Omit<AuditEventRecord, \"occurredAt\"> {\n occurredAt: string;\n}\n\n/** The timeline routes' response body. */\nexport interface AuditTimelineResponse {\n items: AuditTimelineItem[];\n /** Pass as `cursor` (with the same filters) for the next page; `null` on the last. */\n nextCursor: string | null;\n}\n\ninterface TimelineRouteOptions<E extends Env> {\n /** The app's audit log for the request. */\n log: (c: Context<E>) => AuditLog;\n /**\n * Admits the caller, or throws (e.g. `requireAdmin()` / `requireAuth()` from\n * `@awsmbiz/inbis-auth/hono`). Any env: guards are typed for their own variables.\n */\n guard: MiddlewareHandler;\n /** Route path. */\n path?: string;\n}\n\nexport type MountAuditTimelineOptions<E extends Env = Env> = TimelineRouteOptions<E>;\n\nexport interface MountOwnActivityOptions<E extends Env = Env> extends TimelineRouteOptions<E> {\n /** The signed-in caller's user ID; `null`/`undefined` is refused with `401 UNAUTHENTICATED`. */\n actorIdOf: (c: Context<E>) => string | null | undefined;\n}\n\n/** `YYYY-MM-DD` or a full ISO 8601 date-time. */\nconst ISO_DATE = /^\\d{4}-\\d{2}-\\d{2}(T\\d{2}:\\d{2}(:\\d{2}(\\.\\d+)?)?(Z|[+-]\\d{2}:\\d{2}))?$/;\n\nfunction dateParam(c: Context, name: \"from\" | \"to\"): Date | undefined {\n const raw = c.req.query(name);\n if (raw === undefined || raw === \"\") return undefined;\n const date = new Date(raw);\n if (!ISO_DATE.test(raw) || Number.isNaN(date.getTime())) {\n throw validationFailed([{ path: [name], message: \"Must be an ISO 8601 date or date-time.\" }]);\n }\n return date;\n}\n\nfunction textParam(c: Context, name: string): string | undefined {\n const raw = c.req.query(name);\n return raw === undefined || raw === \"\" ? undefined : raw;\n}\n\n/** Paging, action and date range: the query parameters both routes accept. */\nfunction commonQuery(c: Context): AuditTimelineQuery {\n return {\n limit: c.req.query(\"limit\"),\n cursor: c.req.query(\"cursor\"),\n action: textParam(c, \"action\"),\n from: dateParam(c, \"from\"),\n to: dateParam(c, \"to\"),\n };\n}\n\nasync function respond(c: Context, log: AuditLog, query: AuditTimelineQuery): Promise<Response> {\n const page = await log.list(query);\n const body: AuditTimelineResponse = {\n items: page.items.map((item) => ({ ...item, occurredAt: item.occurredAt.toISOString() })),\n nextCursor: page.nextCursor,\n };\n c.header(\"cache-control\", \"no-store\");\n return c.json(body);\n}\n\n/**\n * Mounts `GET /api/admin/audit-events` (default path): every audit event,\n * newest first, as {@link AuditTimelineResponse}. Query: `action`, `actor`\n * (user ID), `subject` (e.g. `user:<id>`), `from` (inclusive) and `to`\n * (exclusive) as ISO 8601 dates or date-times, `limit` (1–200, default 50) and\n * `cursor`. A malformed date is `422 VALIDATION_FAILED` (`details.issues[].path`).\n * Admission is the `guard`'s: pass the app's admin policy.\n */\nexport function mountAuditTimeline<E extends Env>(app: Hono<E>, options: MountAuditTimelineOptions<E>): void {\n const { log, guard } = options;\n // Hono's handler types do not compose over a generic env: register on the plain app.\n (app as unknown as Hono).get(options.path ?? \"/api/admin/audit-events\", guard, (c) =>\n respond(c, log(c as unknown as Context<E>), { ...commonQuery(c), actorId: textParam(c, \"actor\"), subjectId: textParam(c, \"subject\") }),\n );\n}\n\n/**\n * Mounts `GET /api/me/activity` (default path): the caller's own events (those\n * it performed), newest first, as {@link AuditTimelineResponse}. Accepts\n * `action`, `from`, `to`, `limit` and `cursor` like {@link mountAuditTimeline};\n * the actor is always the caller, and any `actor` or `subject` parameter is ignored.\n */\nexport function mountOwnActivity<E extends Env>(app: Hono<E>, options: MountOwnActivityOptions<E>): void {\n const { log, guard, actorIdOf } = options;\n (app as unknown as Hono).get(options.path ?? \"/api/me/activity\", guard, (c) => {\n const actorId = actorIdOf(c as unknown as Context<E>);\n if (actorId === null || actorId === undefined || actorId === \"\") {\n throw new AppError({ code: \"UNAUTHENTICATED\", status: 401, message: \"Sign in to continue.\" });\n }\n return respond(c, log(c as unknown as Context<E>), { ...commonQuery(c), actorId });\n });\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AAkDA,MAAM,WAAW;AAEjB,SAAS,UAAU,GAAY,MAAuC;CACpE,MAAM,MAAM,EAAE,IAAI,MAAM,IAAI;CAC5B,IAAI,QAAQ,KAAA,KAAa,QAAQ,IAAI,OAAO,KAAA;CAC5C,MAAM,OAAO,IAAI,KAAK,GAAG;CACzB,IAAI,CAAC,SAAS,KAAK,GAAG,KAAK,OAAO,MAAM,KAAK,QAAQ,CAAC,GACpD,MAAM,iBAAiB,CAAC;EAAE,MAAM,CAAC,IAAI;EAAG,SAAS;CAAyC,CAAC,CAAC;CAE9F,OAAO;AACT;AAEA,SAAS,UAAU,GAAY,MAAkC;CAC/D,MAAM,MAAM,EAAE,IAAI,MAAM,IAAI;CAC5B,OAAO,QAAQ,KAAA,KAAa,QAAQ,KAAK,KAAA,IAAY;AACvD;;AAGA,SAAS,YAAY,GAAgC;CACnD,OAAO;EACL,OAAO,EAAE,IAAI,MAAM,OAAO;EAC1B,QAAQ,EAAE,IAAI,MAAM,QAAQ;EAC5B,QAAQ,UAAU,GAAG,QAAQ;EAC7B,MAAM,UAAU,GAAG,MAAM;EACzB,IAAI,UAAU,GAAG,IAAI;CACvB;AACF;AAEA,eAAe,QAAQ,GAAY,KAAe,OAA8C;CAC9F,MAAM,OAAO,MAAM,IAAI,KAAK,KAAK;CACjC,MAAM,OAA8B;EAClC,OAAO,KAAK,MAAM,KAAK,UAAU;GAAE,GAAG;GAAM,YAAY,KAAK,WAAW,YAAY;EAAE,EAAE;EACxF,YAAY,KAAK;CACnB;CACA,EAAE,OAAO,iBAAiB,UAAU;CACpC,OAAO,EAAE,KAAK,IAAI;AACpB;;;;;;;;;AAUA,SAAgB,mBAAkC,KAAc,SAA6C;CAC3G,MAAM,EAAE,KAAK,UAAU;CAEvB,IAAyB,IAAI,QAAQ,QAAQ,2BAA2B,QAAQ,MAC9E,QAAQ,GAAG,IAAI,CAA0B,GAAG;EAAE,GAAG,YAAY,CAAC;EAAG,SAAS,UAAU,GAAG,OAAO;EAAG,WAAW,UAAU,GAAG,SAAS;CAAE,CAAC,CACvI;AACF;;;;;;;AAQA,SAAgB,iBAAgC,KAAc,SAA2C;CACvG,MAAM,EAAE,KAAK,OAAO,cAAc;CAClC,IAAyB,IAAI,QAAQ,QAAQ,oBAAoB,QAAQ,MAAM;EAC7E,MAAM,UAAU,UAAU,CAA0B;EACpD,IAAI,YAAY,QAAQ,YAAY,KAAA,KAAa,YAAY,IAC3D,MAAM,IAAI,SAAS;GAAE,MAAM;GAAmB,QAAQ;GAAK,SAAS;EAAuB,CAAC;EAE9F,OAAO,QAAQ,GAAG,IAAI,CAA0B,GAAG;GAAE,GAAG,YAAY,CAAC;GAAG;EAAQ,CAAC;CACnF,CAAC;AACH"}
@@ -0,0 +1,29 @@
1
+ import { a as D1AuditLogOptions, c as AuditActions, d as createAuditActions, i as AuditTimelineQuery, l as AuditActionsInput, n as AuditLog, o as createD1AuditLog, r as AuditTimelineFilter, s as AuditActionCatalogue, t as AuditEventRecord, u as PLATFORM_AUDIT_NAMESPACES } from "./audit-log-xkH9ZIGE.js";
2
+ import { Page, PageInput } from "@awsmbiz/inbis-db";
3
+ /** An event in `@awsmbiz/inbis-auth`'s interim record shape (`AuthAuditEventRecord`): `resource` for `subjectId`. */
4
+ type AuthAuditShapedRecord = Omit<AuditEventRecord, "subjectId"> & {
5
+ resource: string | null;
6
+ };
7
+ /**
8
+ * The trail, newest first, in `@awsmbiz/inbis-auth`'s interim record shape: pass it as
9
+ * `mountAuthAudit`'s `list` so `GET /api/admin/auth-audit-events` reads
10
+ * `audit_event` without changing its response for existing callers:
11
+ * `mountAuthAudit(app, { list: (c, input) => listAuthAuditEvents(auditOf(c), input) })`.
12
+ */
13
+ export declare function listAuthAuditEvents(log: AuditLog, input?: PageInput): Promise<Page<AuthAuditShapedRecord>>;
14
+ /**
15
+ * Copies every row of `@awsmbiz/inbis-auth`'s interim `auth_audit_event` table into
16
+ * `audit_event` (same `id`; `resource` becomes `subjectId`) and resolves with
17
+ * the number of rows copied. Idempotent: rows already copied are skipped, so
18
+ * an app can run it on every deploy or from its cron trigger. A no-op (`0`)
19
+ * on a database without the interim table. `db` is the app's audit binding
20
+ * (the same database as auth's, ADR-4).
21
+ */
22
+ export declare function importAuthAuditHistory(db: D1Database): Promise<number>;
23
+ /**
24
+ * The `migrations_table` of the app's audit D1 binding (ADR-4). Declare that
25
+ * binding after auth's (and access-control's) and before jobs'.
26
+ */
27
+ export declare const AUDIT_MIGRATIONS_TABLE = "_inbis_audit_migrations";
28
+ export { type AuditActionCatalogue, type AuditActions, type AuditActionsInput, type AuditEventRecord, type AuditLog, type AuditTimelineFilter, type AuditTimelineQuery, type AuthAuditShapedRecord, type D1AuditLogOptions, PLATFORM_AUDIT_NAMESPACES, createAuditActions, createD1AuditLog };
29
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,174 @@
1
+ import { createId, createLogger, redact } from "@awsmbiz/inbis-core";
2
+ import { createDatabase, parsePageRequest, toPage } from "@awsmbiz/inbis-db";
3
+ import { and, countDistinct, desc, eq, gte, isNotNull, lt, or, sql } from "drizzle-orm";
4
+ import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
5
+ // src/actions.ts
6
+ /**
7
+ * First segments reserved for platform packages. An app action such as
8
+ * `auth.something` is rejected, so app events never pass for platform ones.
9
+ */
10
+ const PLATFORM_AUDIT_NAMESPACES = [
11
+ "access",
12
+ "audit",
13
+ "auth",
14
+ "cache",
15
+ "jobs",
16
+ "notifications",
17
+ "platform",
18
+ "storage"
19
+ ];
20
+ /** `namespace.thing[.more]`: lowercase segments of letters, digits and `_`, at least two. */
21
+ const ACTION_NAME = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/;
22
+ const namesOf = (catalogue) => Array.isArray(catalogue) ? catalogue : Object.values(catalogue);
23
+ /**
24
+ * Builds the registry. Throws on a malformed name, or on an app action in a
25
+ * platform namespace: both are programming errors, found at startup.
26
+ */
27
+ function createAuditActions({ platform = [], app = [] }) {
28
+ const names = /* @__PURE__ */ new Set();
29
+ const add = (name) => {
30
+ if (!ACTION_NAME.test(name)) throw new Error(`Audit action "${name}" is not a dotted lowercase name such as "billing.invoice.sent".`);
31
+ names.add(name);
32
+ };
33
+ for (const catalogue of platform) namesOf(catalogue).forEach(add);
34
+ for (const name of namesOf(app)) {
35
+ const namespace = name.split(".")[0] ?? "";
36
+ if (PLATFORM_AUDIT_NAMESPACES.includes(namespace)) throw new Error(`App audit action "${name}" uses the platform namespace "${namespace}". Use the app's own namespace.`);
37
+ add(name);
38
+ }
39
+ return {
40
+ has: (action) => names.has(action),
41
+ all: [...names].sort()
42
+ };
43
+ }
44
+ // src/schema.ts
45
+ /**
46
+ * The `audit_event` table, as `migrations/0001_audit_event.sql` creates it.
47
+ * Internal: apps read the trail through `AuditLog`, never the table.
48
+ */
49
+ const auditEvent = sqliteTable("audit_event", {
50
+ /** UUIDv7 (copied events keep their interim ID). */
51
+ id: text("id").primaryKey(),
52
+ /** Dotted action name from the action registry, e.g. `auth.sign_in.succeeded`. */
53
+ action: text("action").notNull(),
54
+ /** User who performed the action; `null` for the system or an unknown actor. */
55
+ actorId: text("actor_id"),
56
+ /** What the action applied to, e.g. `user:<id>`. */
57
+ subjectId: text("subject_id"),
58
+ occurredAt: integer("occurred_at", { mode: "timestamp_ms" }).notNull(),
59
+ requestId: text("request_id"),
60
+ /** Redacted JSON object. */
61
+ metadata: text("metadata")
62
+ }, (table) => [
63
+ index("audit_event_occurred_at_idx").on(table.occurredAt, table.id),
64
+ index("audit_event_actor_id_idx").on(table.actorId, table.occurredAt),
65
+ index("audit_event_subject_id_idx").on(table.subjectId, table.occurredAt),
66
+ index("audit_event_action_idx").on(table.action, table.occurredAt)
67
+ ]);
68
+ // src/audit-log.ts
69
+ /** Cursor key: occurred-at milliseconds and ID, newest first. */
70
+ const keyOf = (row) => `${String(row.occurredAt.getTime())}:${row.id}`;
71
+ function parseKey(key) {
72
+ const separator = key.indexOf(":");
73
+ return {
74
+ at: new Date(Number(key.slice(0, separator))),
75
+ id: key.slice(separator + 1)
76
+ };
77
+ }
78
+ /** The SQL conditions of a timeline filter. */
79
+ function filterConditions(filter) {
80
+ return [
81
+ filter.action === void 0 ? void 0 : eq(auditEvent.action, filter.action),
82
+ filter.actorId === void 0 ? void 0 : eq(auditEvent.actorId, filter.actorId),
83
+ filter.subjectId === void 0 ? void 0 : eq(auditEvent.subjectId, filter.subjectId),
84
+ filter.from === void 0 ? void 0 : gte(auditEvent.occurredAt, filter.from),
85
+ filter.to === void 0 ? void 0 : lt(auditEvent.occurredAt, filter.to)
86
+ ];
87
+ }
88
+ /** Creates the D1-backed audit log over the database holding `audit_event` (the app's audit binding). */
89
+ function createD1AuditLog(d1, { actions, strict = false, logger }) {
90
+ const db = createDatabase(d1);
91
+ return {
92
+ async record(event) {
93
+ if (!actions.has(event.action)) {
94
+ if (strict) throw new Error(`Audit action "${event.action}" is not registered (createAuditActions).`);
95
+ (logger ?? createLogger()).warn("audit.action.unregistered", { action: event.action });
96
+ }
97
+ await db.insert(auditEvent).values({
98
+ id: createId(),
99
+ action: event.action,
100
+ actorId: event.actorId,
101
+ subjectId: event.subjectId ?? null,
102
+ occurredAt: event.occurredAt,
103
+ requestId: event.requestId ?? null,
104
+ metadata: event.metadata === void 0 ? null : JSON.stringify(redact(event.metadata))
105
+ });
106
+ },
107
+ async forgetUser({ id, email, phoneNumber }) {
108
+ await db.update(auditEvent).set({ metadata: sql`json_remove(${auditEvent.metadata}, '$.email', '$.phoneNumber', '$.ipAddress', '$.userAgent')` }).where(and(isNotNull(auditEvent.metadata), or(eq(auditEvent.actorId, id), eq(auditEvent.subjectId, `user:${id}`), sql`lower(json_extract(${auditEvent.metadata}, '$.email')) = lower(${email})`, ...typeof phoneNumber === "string" ? [sql`json_extract(${auditEvent.metadata}, '$.phoneNumber') = ${phoneNumber}`] : [])));
109
+ },
110
+ async list(query = {}) {
111
+ const page = parsePageRequest(query, {
112
+ defaultLimit: 50,
113
+ maxLimit: 200
114
+ });
115
+ const after = page.after === void 0 ? void 0 : parseKey(page.after);
116
+ const conditions = [...filterConditions(query), after === void 0 ? void 0 : or(lt(auditEvent.occurredAt, after.at), and(eq(auditEvent.occurredAt, after.at), lt(auditEvent.id, after.id)))];
117
+ const rows = await db.select().from(auditEvent).where(and(...conditions)).orderBy(desc(auditEvent.occurredAt), desc(auditEvent.id)).limit(page.fetchLimit);
118
+ return toPage(rows.map((row) => ({
119
+ ...row,
120
+ metadata: row.metadata === null ? null : JSON.parse(row.metadata)
121
+ })), page, keyOf);
122
+ },
123
+ async count(filter = {}) {
124
+ const [row] = await db.select({ total: sql`count(*)` }).from(auditEvent).where(and(...filterConditions(filter)));
125
+ return row?.total ?? 0;
126
+ },
127
+ async countActors(filter = {}) {
128
+ const [row] = await db.select({ total: countDistinct(auditEvent.actorId) }).from(auditEvent).where(and(...filterConditions(filter)));
129
+ return row?.total ?? 0;
130
+ }
131
+ };
132
+ }
133
+ // src/auth-history.ts
134
+ /**
135
+ * The trail, newest first, in `@awsmbiz/inbis-auth`'s interim record shape: pass it as
136
+ * `mountAuthAudit`'s `list` so `GET /api/admin/auth-audit-events` reads
137
+ * `audit_event` without changing its response for existing callers:
138
+ * `mountAuthAudit(app, { list: (c, input) => listAuthAuditEvents(auditOf(c), input) })`.
139
+ */
140
+ async function listAuthAuditEvents(log, input = {}) {
141
+ const page = await log.list(input);
142
+ return {
143
+ ...page,
144
+ items: page.items.map(({ subjectId, ...item }) => ({
145
+ ...item,
146
+ resource: subjectId
147
+ }))
148
+ };
149
+ }
150
+ /** auth's interim table (its `0002_auth_audit_event` migration). */
151
+ const INTERIM_TABLE = "auth_audit_event";
152
+ /**
153
+ * Copies every row of `@awsmbiz/inbis-auth`'s interim `auth_audit_event` table into
154
+ * `audit_event` (same `id`; `resource` becomes `subjectId`) and resolves with
155
+ * the number of rows copied. Idempotent: rows already copied are skipped, so
156
+ * an app can run it on every deploy or from its cron trigger. A no-op (`0`)
157
+ * on a database without the interim table. `db` is the app's audit binding
158
+ * (the same database as auth's, ADR-4).
159
+ */
160
+ async function importAuthAuditHistory(db) {
161
+ if (await db.prepare("SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = ?").bind(INTERIM_TABLE).first("found") === null) return 0;
162
+ const { meta } = await db.prepare(`INSERT OR IGNORE INTO audit_event (id, action, actor_id, subject_id, occurred_at, request_id, metadata)
163
+ SELECT id, action, actor_id, resource, occurred_at, request_id, metadata FROM ${INTERIM_TABLE}`).run();
164
+ return meta.changes;
165
+ }
166
+ // src/index.ts
167
+ /**
168
+ * The `migrations_table` of the app's audit D1 binding (ADR-4). Declare that
169
+ * binding after auth's (and access-control's) and before jobs'.
170
+ */
171
+ const AUDIT_MIGRATIONS_TABLE = "_inbis_audit_migrations";
172
+ export { AUDIT_MIGRATIONS_TABLE, PLATFORM_AUDIT_NAMESPACES, createAuditActions, createD1AuditLog, importAuthAuditHistory, listAuthAuditEvents };
173
+
174
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":[],"sources":["../src/actions.ts","../src/schema.ts","../src/audit-log.ts","../src/auth-history.ts","../src/index.ts"],"sourcesContent":["/**\n * The action registry: every action name the app's audit trail accepts.\n * Platform packages export their catalogues (e.g. `AUTH_AUDIT_ACTIONS` from\n * `@awsmbiz/inbis-auth`); the app adds its own.\n */\n\n/** A catalogue of action names: an `as const` object of names, or a list. */\nexport type AuditActionCatalogue = Readonly<Record<string, string>> | readonly string[];\n\nexport interface AuditActionsInput {\n /** Catalogues exported by platform packages, e.g. `AUTH_AUDIT_ACTIONS`. */\n platform?: readonly AuditActionCatalogue[];\n /** The app's own actions. They may not use a platform namespace ({@link PLATFORM_AUDIT_NAMESPACES}). */\n app?: AuditActionCatalogue;\n}\n\n/** The registered action names. */\nexport interface AuditActions {\n has(action: string): boolean;\n /** Every registered name, sorted. */\n readonly all: readonly string[];\n}\n\n/**\n * First segments reserved for platform packages. An app action such as\n * `auth.something` is rejected, so app events never pass for platform ones.\n */\nexport const PLATFORM_AUDIT_NAMESPACES = [\"access\", \"audit\", \"auth\", \"cache\", \"jobs\", \"notifications\", \"platform\", \"storage\"] as const;\n\n/** `namespace.thing[.more]`: lowercase segments of letters, digits and `_`, at least two. */\nconst ACTION_NAME = /^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)+$/;\n\nconst namesOf = (catalogue: AuditActionCatalogue): readonly string[] =>\n Array.isArray(catalogue) ? (catalogue as readonly string[]) : Object.values(catalogue);\n\n/**\n * Builds the registry. Throws on a malformed name, or on an app action in a\n * platform namespace: both are programming errors, found at startup.\n */\nexport function createAuditActions({ platform = [], app = [] }: AuditActionsInput): AuditActions {\n const names = new Set<string>();\n const add = (name: string) => {\n if (!ACTION_NAME.test(name)) {\n throw new Error(`Audit action \"${name}\" is not a dotted lowercase name such as \"billing.invoice.sent\".`);\n }\n names.add(name);\n };\n for (const catalogue of platform) namesOf(catalogue).forEach(add);\n for (const name of namesOf(app)) {\n const namespace = name.split(\".\")[0] ?? \"\";\n if ((PLATFORM_AUDIT_NAMESPACES as readonly string[]).includes(namespace)) {\n throw new Error(`App audit action \"${name}\" uses the platform namespace \"${namespace}\". Use the app's own namespace.`);\n }\n add(name);\n }\n const all = [...names].sort();\n return { has: (action) => names.has(action), all };\n}\n","/**\n * The `audit_event` table, as `migrations/0001_audit_event.sql` creates it.\n * Internal: apps read the trail through `AuditLog`, never the table.\n */\nimport { index, integer, sqliteTable, text } from \"drizzle-orm/sqlite-core\";\n\nexport const auditEvent = sqliteTable(\n \"audit_event\",\n {\n /** UUIDv7 (copied events keep their interim ID). */\n id: text(\"id\").primaryKey(),\n /** Dotted action name from the action registry, e.g. `auth.sign_in.succeeded`. */\n action: text(\"action\").notNull(),\n /** User who performed the action; `null` for the system or an unknown actor. */\n actorId: text(\"actor_id\"),\n /** What the action applied to, e.g. `user:<id>`. */\n subjectId: text(\"subject_id\"),\n occurredAt: integer(\"occurred_at\", { mode: \"timestamp_ms\" }).notNull(),\n requestId: text(\"request_id\"),\n /** Redacted JSON object. */\n metadata: text(\"metadata\"),\n },\n (table) => [\n index(\"audit_event_occurred_at_idx\").on(table.occurredAt, table.id),\n index(\"audit_event_actor_id_idx\").on(table.actorId, table.occurredAt),\n index(\"audit_event_subject_id_idx\").on(table.subjectId, table.occurredAt),\n index(\"audit_event_action_idx\").on(table.action, table.occurredAt),\n ],\n);\n","import { createId, createLogger, redact, type AuditEvent, type AuditSink, type ForgottenUser, type Logger } from \"@awsmbiz/inbis-core\";\nimport { createDatabase, parsePageRequest, toPage, type Page, type PageInput } from \"@awsmbiz/inbis-db\";\nimport { and, countDistinct, desc, eq, gte, isNotNull, lt, or, sql, type SQL } from \"drizzle-orm\";\nimport type { AuditActions } from \"./actions.js\";\nimport { auditEvent } from \"./schema.js\";\n\n/** One stored audit event. */\nexport interface AuditEventRecord {\n id: string;\n /** Dotted action name, e.g. `auth.sign_in.succeeded`. */\n action: string;\n actorId: string | null;\n /** `AuditEvent.subjectId`, e.g. `user:<id>`. */\n subjectId: string | null;\n occurredAt: Date;\n requestId: string | null;\n /** Redacted, secret-free detail. */\n metadata: Record<string, unknown> | null;\n}\n\n/** Timeline filters; each one given narrows the result. */\nexport interface AuditTimelineFilter {\n /** Exact action name, e.g. `auth.sign_in.failed`. */\n action?: string | undefined;\n /** Events performed by this user ID. */\n actorId?: string | undefined;\n /** Events about this subject, e.g. `user:<id>`. */\n subjectId?: string | undefined;\n /** Events at or after this instant. */\n from?: Date | undefined;\n /** Events before this instant (exclusive). */\n to?: Date | undefined;\n}\n\n/** A timeline request: filters plus keyset paging. */\nexport type AuditTimelineQuery = AuditTimelineFilter & PageInput;\n\n/** The audit trail over `audit_event`: an `AuditSink`, plus reading. */\nexport interface AuditLog extends AuditSink {\n /**\n * After an account deletion: strips `email`, `phoneNumber`, `ipAddress` and\n * `userAgent` from every event by or about the user, or naming its email\n * (any case) or number, so only the user ID remains (plan §5.7).\n */\n forgetUser(user: ForgottenUser): Promise<void>;\n /**\n * The timeline: events matching every filter given, newest first (keyset\n * pagination; `limit` defaults to 50, at most 200). Keep the same filters\n * when following `nextCursor`.\n */\n list(query?: AuditTimelineQuery): Promise<Page<AuditEventRecord>>;\n /**\n * How many events match every filter given (the same filters as\n * {@link list}, without paging), e.g. failed sign-ins in the last day.\n */\n count(filter?: AuditTimelineFilter): Promise<number>;\n /**\n * How many distinct actors performed the matching events (the same filters\n * as {@link count}; events without an actor are not counted), e.g. users who\n * signed in during the last 7 days.\n */\n countActors(filter?: AuditTimelineFilter): Promise<number>;\n}\n\nexport interface D1AuditLogOptions {\n /** The registered action names (see `createAuditActions`). */\n actions: AuditActions;\n /**\n * `true` (development and tests): an unregistered action throws and nothing\n * is stored, so a typo fails the test that triggers it. `false` (default,\n * production): it is stored anyway and logged as `audit.action.unregistered`,\n * because losing an audit event is worse than an unexpected name.\n */\n strict?: boolean;\n /** Receives the `audit.action.unregistered` warning. Defaults to a console JSON logger. */\n logger?: Logger;\n}\n\n/** Cursor key: occurred-at milliseconds and ID, newest first. */\nconst keyOf = (row: { occurredAt: Date; id: string }) => `${String(row.occurredAt.getTime())}:${row.id}`;\n\nfunction parseKey(key: string): { at: Date; id: string } {\n const separator = key.indexOf(\":\");\n return { at: new Date(Number(key.slice(0, separator))), id: key.slice(separator + 1) };\n}\n\n/** The SQL conditions of a timeline filter. */\nfunction filterConditions(filter: AuditTimelineFilter): (SQL | undefined)[] {\n return [\n filter.action === undefined ? undefined : eq(auditEvent.action, filter.action),\n filter.actorId === undefined ? undefined : eq(auditEvent.actorId, filter.actorId),\n filter.subjectId === undefined ? undefined : eq(auditEvent.subjectId, filter.subjectId),\n filter.from === undefined ? undefined : gte(auditEvent.occurredAt, filter.from),\n filter.to === undefined ? undefined : lt(auditEvent.occurredAt, filter.to),\n ];\n}\n\n/** Creates the D1-backed audit log over the database holding `audit_event` (the app's audit binding). */\nexport function createD1AuditLog(d1: D1Database, { actions, strict = false, logger }: D1AuditLogOptions): AuditLog {\n const db = createDatabase(d1);\n return {\n async record(event: AuditEvent) {\n if (!actions.has(event.action)) {\n if (strict) throw new Error(`Audit action \"${event.action}\" is not registered (createAuditActions).`);\n (logger ?? createLogger()).warn(\"audit.action.unregistered\", { action: event.action });\n }\n await db.insert(auditEvent).values({\n id: createId(),\n action: event.action,\n actorId: event.actorId,\n subjectId: event.subjectId ?? null,\n occurredAt: event.occurredAt,\n requestId: event.requestId ?? null,\n // Redacted at rest, whatever the caller passed.\n metadata: event.metadata === undefined ? null : JSON.stringify(redact(event.metadata)),\n });\n },\n async forgetUser({ id, email, phoneNumber }) {\n await db\n .update(auditEvent)\n .set({\n metadata: sql`json_remove(${auditEvent.metadata}, '$.email', '$.phoneNumber', '$.ipAddress', '$.userAgent')`,\n })\n .where(\n and(\n isNotNull(auditEvent.metadata),\n or(\n eq(auditEvent.actorId, id),\n eq(auditEvent.subjectId, `user:${id}`),\n sql`lower(json_extract(${auditEvent.metadata}, '$.email')) = lower(${email})`,\n ...(typeof phoneNumber === \"string\"\n ? [sql`json_extract(${auditEvent.metadata}, '$.phoneNumber') = ${phoneNumber}`]\n : []),\n ),\n ),\n );\n },\n async list(query = {}) {\n const page = parsePageRequest(query, { defaultLimit: 50, maxLimit: 200 });\n const after = page.after === undefined ? undefined : parseKey(page.after);\n const conditions: (SQL | undefined)[] = [\n ...filterConditions(query),\n after === undefined\n ? undefined\n : or(lt(auditEvent.occurredAt, after.at), and(eq(auditEvent.occurredAt, after.at), lt(auditEvent.id, after.id))),\n ];\n const rows = await db\n .select()\n .from(auditEvent)\n .where(and(...conditions))\n .orderBy(desc(auditEvent.occurredAt), desc(auditEvent.id))\n .limit(page.fetchLimit);\n return toPage(\n rows.map((row) => ({\n ...row,\n metadata: row.metadata === null ? null : (JSON.parse(row.metadata) as Record<string, unknown>),\n })),\n page,\n keyOf,\n );\n },\n async count(filter = {}) {\n const [row] = await db\n .select({ total: sql<number>`count(*)` })\n .from(auditEvent)\n .where(and(...filterConditions(filter)));\n return row?.total ?? 0;\n },\n async countActors(filter = {}) {\n const [row] = await db\n .select({ total: countDistinct(auditEvent.actorId) })\n .from(auditEvent)\n .where(and(...filterConditions(filter)));\n return row?.total ?? 0;\n },\n };\n}\n","/**\n * Taking over from `@awsmbiz/inbis-auth`'s interim audit table (plan Q39). The\n * migrations never touch auth's tables (auth may be added before or after\n * audit), so the copy is an explicit step the app runs; and the trail can be\n * read in auth's interim record shape.\n */\nimport type { Page, PageInput } from \"@awsmbiz/inbis-db\";\nimport type { AuditEventRecord, AuditLog } from \"./audit-log.js\";\n\n/** An event in `@awsmbiz/inbis-auth`'s interim record shape (`AuthAuditEventRecord`): `resource` for `subjectId`. */\nexport type AuthAuditShapedRecord = Omit<AuditEventRecord, \"subjectId\"> & { resource: string | null };\n\n/**\n * The trail, newest first, in `@awsmbiz/inbis-auth`'s interim record shape: pass it as\n * `mountAuthAudit`'s `list` so `GET /api/admin/auth-audit-events` reads\n * `audit_event` without changing its response for existing callers:\n * `mountAuthAudit(app, { list: (c, input) => listAuthAuditEvents(auditOf(c), input) })`.\n */\nexport async function listAuthAuditEvents(log: AuditLog, input: PageInput = {}): Promise<Page<AuthAuditShapedRecord>> {\n const page = await log.list(input);\n return { ...page, items: page.items.map(({ subjectId, ...item }) => ({ ...item, resource: subjectId })) };\n}\n\n/** auth's interim table (its `0002_auth_audit_event` migration). */\nconst INTERIM_TABLE = \"auth_audit_event\";\n\n/**\n * Copies every row of `@awsmbiz/inbis-auth`'s interim `auth_audit_event` table into\n * `audit_event` (same `id`; `resource` becomes `subjectId`) and resolves with\n * the number of rows copied. Idempotent: rows already copied are skipped, so\n * an app can run it on every deploy or from its cron trigger. A no-op (`0`)\n * on a database without the interim table. `db` is the app's audit binding\n * (the same database as auth's, ADR-4).\n */\nexport async function importAuthAuditHistory(db: D1Database): Promise<number> {\n const interim = await db\n .prepare(\"SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = ?\")\n .bind(INTERIM_TABLE)\n .first<number>(\"found\");\n if (interim === null) return 0;\n const { meta } = await db\n .prepare(\n `INSERT OR IGNORE INTO audit_event (id, action, actor_id, subject_id, occurred_at, request_id, metadata)\n SELECT id, action, actor_id, resource, occurred_at, request_id, metadata FROM ${INTERIM_TABLE}`,\n )\n .run();\n return meta.changes;\n}\n","export {\n createAuditActions,\n PLATFORM_AUDIT_NAMESPACES,\n type AuditActionCatalogue,\n type AuditActions,\n type AuditActionsInput,\n} from \"./actions.js\";\nexport {\n createD1AuditLog,\n type AuditEventRecord,\n type AuditLog,\n type AuditTimelineFilter,\n type AuditTimelineQuery,\n type D1AuditLogOptions,\n} from \"./audit-log.js\";\nexport { importAuthAuditHistory, listAuthAuditEvents, type AuthAuditShapedRecord } from \"./auth-history.js\";\n\n/**\n * The `migrations_table` of the app's audit D1 binding (ADR-4). Declare that\n * binding after auth's (and access-control's) and before jobs'.\n */\nexport const AUDIT_MIGRATIONS_TABLE = \"_inbis_audit_migrations\";\n"],"mappings":"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"}
@@ -0,0 +1,18 @@
1
+ -- @inbis/audit 0001_audit_event
2
+ -- The audit trail (plan §5.8): one table for platform and app events.
3
+ -- Source: src/schema.ts. Timestamps are epoch milliseconds. No foreign keys:
4
+ -- events outlive the users and records they name.
5
+ CREATE TABLE `audit_event` (
6
+ `id` text PRIMARY KEY NOT NULL,
7
+ `action` text NOT NULL,
8
+ `actor_id` text,
9
+ `subject_id` text,
10
+ `occurred_at` integer NOT NULL,
11
+ `request_id` text,
12
+ `metadata` text
13
+ );
14
+ -- Timeline queries: newest first overall, or for one actor, subject or action.
15
+ CREATE INDEX `audit_event_occurred_at_idx` ON `audit_event` (`occurred_at`, `id`);
16
+ CREATE INDEX `audit_event_actor_id_idx` ON `audit_event` (`actor_id`, `occurred_at`);
17
+ CREATE INDEX `audit_event_subject_id_idx` ON `audit_event` (`subject_id`, `occurred_at`);
18
+ CREATE INDEX `audit_event_action_idx` ON `audit_event` (`action`, `occurred_at`);
@@ -0,0 +1,3 @@
1
+ {
2
+ "0001_audit_event.sql": "sha256:a182ad4f1c9a96557f5b7dcd108b7ca51e7546737b94d0182c502cd899542c94"
3
+ }
package/package.json CHANGED
@@ -1,6 +1,69 @@
1
1
  {
2
2
  "name": "@awsmbiz/inbis-audit",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0",
4
+ "description": "Audit trail for Inbis applications: the audit_event table, a D1 AuditSink with redacted metadata, an extensible action registry and package-owned D1 migrations, plus a filterable activity timeline with Hono routes.",
5
+ "license": "UNLICENSED",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/awsmbiz/inbis-platform.git",
9
+ "directory": "packages/audit"
10
+ },
11
+ "publishConfig": {
12
+ "registry": "https://registry.npmjs.org/",
13
+ "access": "public"
14
+ },
15
+ "type": "module",
16
+ "sideEffects": false,
17
+ "files": [
18
+ "dist",
19
+ "migrations"
20
+ ],
21
+ "exports": {
22
+ ".": {
23
+ "types": "./dist/index.d.ts",
24
+ "default": "./dist/index.js"
25
+ },
26
+ "./hono": {
27
+ "types": "./dist/hono.d.ts",
28
+ "default": "./dist/hono.js"
29
+ },
30
+ "./package.json": "./package.json"
31
+ },
32
+ "dependencies": {
33
+ "@awsmbiz/inbis-core": "0.2.0",
34
+ "@awsmbiz/inbis-db": "0.2.0"
35
+ },
36
+ "peerDependencies": {
37
+ "drizzle-orm": "^0.45.4",
38
+ "hono": "^4.13.14"
39
+ },
40
+ "devDependencies": {
41
+ "@arethetypeswrong/cli": "0.18.5",
42
+ "@cloudflare/vitest-plugin": "1.4.11",
43
+ "@cloudflare/workers-types": "5.20261030.1",
44
+ "@awsmbiz/inbis-auth": "0.2.0",
45
+ "@awsmbiz/inbis-eslint-config": "0.2.0",
46
+ "@awsmbiz/inbis-tsconfig": "0.2.0",
47
+ "drizzle-orm": "0.45.4",
48
+ "eslint": "10.14.0",
49
+ "hono": "4.13.14",
50
+ "publint": "0.3.25",
51
+ "tsdown": "0.23.4",
52
+ "typescript": "6.0.3",
53
+ "vitest": "5.0.5",
54
+ "wrangler": "4.156.0"
55
+ },
56
+ "peerDependenciesMeta": {
57
+ "hono": {
58
+ "optional": true
59
+ }
60
+ },
61
+ "scripts": {
62
+ "build": "tsdown",
63
+ "typecheck": "tsc",
64
+ "lint": "eslint . --max-warnings 0",
65
+ "test": "vitest run",
66
+ "check:exports": "publint --strict && attw --pack . --profile esm-only",
67
+ "check:migrations": "inbis-db check-lock migrations --base \"${MIGRATIONS_BASE_REF:-origin/main}\""
68
+ }
6
69
  }