@orthacms/activity-server 0.4.2 → 0.5.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.
Files changed (44) hide show
  1. package/dist/lib/activity/activity-filter.d.ts.map +1 -1
  2. package/dist/lib/activity/activity-filter.js +2 -0
  3. package/dist/lib/activity/activity.constants.d.ts +8 -3
  4. package/dist/lib/activity/activity.constants.d.ts.map +1 -1
  5. package/dist/lib/activity/activity.constants.js +9 -3
  6. package/dist/lib/activity/controllers/dead-letters.controller.d.ts +36 -0
  7. package/dist/lib/activity/controllers/dead-letters.controller.d.ts.map +1 -0
  8. package/dist/lib/activity/controllers/dead-letters.controller.js +61 -0
  9. package/dist/lib/activity/controllers/entry-activity.controller.d.ts +32 -0
  10. package/dist/lib/activity/controllers/entry-activity.controller.d.ts.map +1 -0
  11. package/dist/lib/activity/controllers/entry-activity.controller.js +69 -0
  12. package/dist/lib/activity/dto/dead-letters-query.dto.d.ts +14 -0
  13. package/dist/lib/activity/dto/dead-letters-query.dto.d.ts.map +1 -0
  14. package/dist/lib/activity/dto/dead-letters-query.dto.js +46 -0
  15. package/dist/lib/activity/dto/entry-activity-query.dto.d.ts +14 -0
  16. package/dist/lib/activity/dto/entry-activity-query.dto.d.ts.map +1 -0
  17. package/dist/lib/activity/dto/entry-activity-query.dto.js +50 -0
  18. package/dist/lib/activity/dto/list-activity-query.dto.d.ts +16 -0
  19. package/dist/lib/activity/dto/list-activity-query.dto.d.ts.map +1 -1
  20. package/dist/lib/activity/dto/list-activity-query.dto.js +38 -0
  21. package/dist/lib/activity/infrastructure/audit-event-mapping.d.ts +46 -0
  22. package/dist/lib/activity/infrastructure/audit-event-mapping.d.ts.map +1 -1
  23. package/dist/lib/activity/infrastructure/audit-event-mapping.js +515 -34
  24. package/dist/lib/activity/services/activity.service.d.ts.map +1 -1
  25. package/dist/lib/activity/services/activity.service.js +6 -0
  26. package/dist/lib/activity/types/activity-view.d.ts +18 -1
  27. package/dist/lib/activity/types/activity-view.d.ts.map +1 -1
  28. package/dist/lib/activity.module.d.ts.map +1 -1
  29. package/dist/lib/activity.module.js +12 -1
  30. package/dist/lib/docs/activity-schemas.d.ts +19 -0
  31. package/dist/lib/docs/activity-schemas.d.ts.map +1 -0
  32. package/dist/lib/docs/activity-schemas.js +167 -0
  33. package/dist/lib/docs/describe-activity-api.d.ts +17 -0
  34. package/dist/lib/docs/describe-activity-api.d.ts.map +1 -0
  35. package/dist/lib/docs/describe-activity-api.js +85 -0
  36. package/dist/lib/schema/activity-events.d.ts +56 -2
  37. package/dist/lib/schema/activity-events.d.ts.map +1 -1
  38. package/dist/lib/schema/activity-events.js +26 -3
  39. package/dist/lib/utils/activity-plugin.d.ts.map +1 -1
  40. package/dist/lib/utils/activity-plugin.js +5 -1
  41. package/migrations/0001_actor_type_and_workspace.sql +8 -0
  42. package/migrations/meta/0001_snapshot.json +192 -0
  43. package/migrations/meta/_journal.json +7 -0
  44. package/package.json +7 -8
@@ -1,14 +1,15 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.AUDITED_EVENT_KINDS = exports.UnmappableAuditEventError = void 0;
3
+ exports.AUDIT_SUBJECT_TYPES = exports.AUDIT_KINDS = exports.AUDITED_EVENT_KINDS = exports.UnmappableAuditEventError = void 0;
4
4
  exports.toAuditRow = toAuditRow;
5
5
  const identity_server_1 = require("@orthacms/identity-server");
6
6
  /**
7
- * The **user.\*** audit kinds owned by the users context
8
- * (`USER_ACTIVITY_KINDS` in `@orthacms/users-server`). Duplicated here as
9
- * literals rather than imported, so the audit sink stays decoupled from every
10
- * producer (activity depends on identity only); the parity unit test pins these
11
- * strings against the exact rows the old in-band recorder wrote.
7
+ * The **user.\*** audit kinds the users context's `member.*` events map onto.
8
+ * Spelled as literals rather than imported, so the audit sink stays decoupled
9
+ * from every producer (activity depends on identity only); the parity unit test
10
+ * pins these strings against the exact rows the old in-band recorder wrote.
11
+ * This is the only catalogue of them — the users package deliberately keeps
12
+ * none, since nothing there reads it.
12
13
  */
13
14
  const USER_AUDIT_KINDS = {
14
15
  INVITED: 'user.invited',
@@ -69,6 +70,104 @@ const MEDIA_AUDIT_KINDS = {
69
70
  FOLDER_RENAMED: 'media.folder.renamed',
70
71
  FOLDER_DELETED: 'media.folder.deleted'
71
72
  };
73
+ /**
74
+ * The **transfer.\*** audit kinds — content leaving and entering the system in
75
+ * bulk.
76
+ *
77
+ * `transfer/server` has raised these since the feature landed, and its own
78
+ * `transfer.events.ts` explains at length why an *export* — a read — raises an
79
+ * audit event at all: it is the one operation that takes a workspace's content
80
+ * out of the system, files included, and nothing in the content tables changes
81
+ * to record it. Nothing mapped them, so that reasoning produced no rows: the
82
+ * dispatcher found no subscriber, stamped `dispatched_at`, and every export and
83
+ * import left the log silent. The fourth time this exact failure has happened,
84
+ * after API tokens, entry publishes and the whole media library.
85
+ *
86
+ * An import is *also* visible as the `entry.created` / `entry.updated` rows its
87
+ * writes raise — it goes through `EntryWriterService` like any other write — but
88
+ * those rows say a hundred entries changed, not that one person imported a file.
89
+ * Those are different facts and the second one is the one an operator asks about.
90
+ */
91
+ /**
92
+ * The **segment.\*** audit kinds — reader entitlements.
93
+ *
94
+ * Segments answer who may *read* published content, and until now nothing in
95
+ * that context raised an event at all: creating an audience, re-tagging one
96
+ * (which changes who every entry naming it is visible to, while its name and
97
+ * its entries stay put), deleting one (which strips its mention from every
98
+ * entry in one statement), and setting an entry's allow/deny lists were all
99
+ * access-control decisions the log recorded nothing about.
100
+ *
101
+ * The entry's own revisions carry its access as part of a snapshot, which
102
+ * covers exactly one of those four and only for entries saved afterwards. A
103
+ * segment's tag list is in no revision at all.
104
+ */
105
+ const SEGMENT_AUDIT_KINDS = {
106
+ CREATED: 'segment.created',
107
+ UPDATED: 'segment.updated',
108
+ DELETED: 'segment.deleted',
109
+ ENTRY_ACCESS_CHANGED: 'segment.entry_access_changed'
110
+ };
111
+ /**
112
+ * The **alarm.rule.\*** audit kinds — the *configuration* of the content
113
+ * checks, never the findings.
114
+ *
115
+ * An alarm never gates a write (ADR-0015), which is precisely why its rules are
116
+ * worth a row: disable one, re-filter one, delete one, and everything
117
+ * downstream — the entry rail's checks block, the records column, the alarms
118
+ * page — simply goes quiet. Nothing about that quiet says a person chose it,
119
+ * and after a delete the rule row that would have explained it is gone.
120
+ *
121
+ * Findings are deliberately absent: one per matching entry per sweep, derived
122
+ * from content the log already records. Auditing them would make the trail a
123
+ * metrics feed.
124
+ */
125
+ const ALARM_AUDIT_KINDS = {
126
+ RULE_CREATED: 'alarm.rule.created',
127
+ RULE_UPDATED: 'alarm.rule.updated',
128
+ RULE_DELETED: 'alarm.rule.deleted',
129
+ RULE_RESCANNED: 'alarm.rule.rescanned'
130
+ };
131
+ /**
132
+ * The **saved_view.\*** audit kinds.
133
+ *
134
+ * A view is a bookmark, not a grant — it replays through the ordinary list
135
+ * query with the reader's own permissions — so this is a low-stakes trio. It is
136
+ * here because a `workspace`-visible view is genuinely shared state: it is in
137
+ * every member's switcher, sharing it is its own permission, and deleting one
138
+ * takes it away from everybody.
139
+ *
140
+ * Setting a **personal default** raises nothing and is not listed: it is the
141
+ * reader's own landing choice, set by clicking a view, and a row per click
142
+ * would be a usage metric in an audit trail.
143
+ */
144
+ const SAVED_VIEW_AUDIT_KINDS = {
145
+ CREATED: 'saved_view.created',
146
+ UPDATED: 'saved_view.updated',
147
+ DELETED: 'saved_view.deleted'
148
+ };
149
+ /**
150
+ * The **copilot.\*** audit kinds — authority a human hands the agent.
151
+ *
152
+ * A skill is standing instruction: it changes what the copilot does in a
153
+ * workspace on every future run, and one set to `auto` runs without being asked
154
+ * for. A tool permission is the same decision at one moment. Both are recorded.
155
+ *
156
+ * What is not here: starting a run, and renaming a conversation. Everything a
157
+ * run *does* is already audited where it happens — under ADR-0009 an applied
158
+ * change is an `entry.*` row, and it now carries the run that produced it — so
159
+ * a row per run would be a usage metric with no way to filter it out.
160
+ */
161
+ const COPILOT_AUDIT_KINDS = {
162
+ SKILL_CREATED: 'copilot.skill.created',
163
+ SKILL_UPDATED: 'copilot.skill.updated',
164
+ SKILL_DELETED: 'copilot.skill.deleted',
165
+ TOOL_PERMISSION_DECIDED: 'copilot.tool_permission.decided'
166
+ };
167
+ const TRANSFER_AUDIT_KINDS = {
168
+ EXPORTED: 'transfer.content.exported',
169
+ IMPORTED: 'transfer.content.imported'
170
+ };
72
171
  /** Reads a payload field as a string (or `null` when absent/nullish). */
73
172
  function nullableString(value) {
74
173
  return typeof value === 'string' ? value : null;
@@ -95,6 +194,9 @@ function workspaceSubject(event, meta) {
95
194
  * content type rides in `meta` so the log can name *what* happened to without
96
195
  * joining anything.
97
196
  *
197
+ * The entry's frozen `title` rides alongside, so the row names something a
198
+ * reader recognises.
199
+ *
98
200
  * `extra` carries the few facts one kind has and the others do not — the
99
201
  * changed `fields` on an update, and whether a delete was a recoverable
100
202
  * tombstone (`soft`) or the row leaving the table. Everything else about an
@@ -107,6 +209,12 @@ function entrySubject(event, extra = {}) {
107
209
  subjectId: event.aggregateId,
108
210
  meta: {
109
211
  contentType: nullableString(event.payload.contentType),
212
+ // The entry's label **at the time of the event**, frozen by the
213
+ // producer. An audit row keeps no FK and no lookup, so without this
214
+ // the Subject cell is a bare uuid — survivable while the entry
215
+ // exists, and permanently unreadable after `entry.purged`, which is
216
+ // exactly the row this is the last remaining record of.
217
+ title: nullableString(event.payload.title),
110
218
  ...extra
111
219
  }
112
220
  };
@@ -117,12 +225,12 @@ function entrySubject(event, extra = {}) {
117
225
  *
118
226
  * `meta` records what a reviewer needs to reason about the credential — its
119
227
  * label, its scope, its workspace bucket, and the non-secret `lookupPrefix`
120
- * that identifies it in the admin list. It deliberately carries **neither the
121
- * plaintext nor the hash**: `api_tokens` stores only a SHA-256 precisely so a
122
- * read of another table yields nothing usable, and the audit log is another
123
- * table.
228
+ * that identifies it in the admin list, plus whatever `extra` the kind adds. It
229
+ * deliberately carries **neither the plaintext nor the hash**: `api_tokens`
230
+ * stores only a SHA-256 precisely so a read of another table yields nothing
231
+ * usable, and the audit log is another table.
124
232
  */
125
- function apiTokenSubject(event, auditKind) {
233
+ function apiTokenSubject(event, auditKind, extra = {}) {
126
234
  const payload = event.payload;
127
235
  return {
128
236
  kind: auditKind,
@@ -132,7 +240,8 @@ function apiTokenSubject(event, auditKind) {
132
240
  name: nullableString(payload.name),
133
241
  scope: nullableString(payload.scope),
134
242
  workspaceIds: payload.workspaceIds ?? [],
135
- lookupPrefix: nullableString(payload.lookupPrefix)
243
+ lookupPrefix: nullableString(payload.lookupPrefix),
244
+ ...extra
136
245
  }
137
246
  };
138
247
  }
@@ -206,6 +315,120 @@ function mediaFolderSubject(event) {
206
315
  meta: payloadWithoutActor(event)
207
316
  };
208
317
  }
318
+ /**
319
+ * A `'login_attempt'`-subject facet — the subject is the **address** that was
320
+ * tried, not a user.
321
+ *
322
+ * It has to be: the failures worth reading are exactly the ones where no
323
+ * account exists to key on, and a mapper that insisted on a user id would
324
+ * refuse them (see {@link UnmappableAuditEventError}) and park every guess
325
+ * against an unknown address as a dead letter. `subject_id` is `text` for this
326
+ * kind of subject, and `activity_events_subject_idx` then makes "what has been
327
+ * tried against this login" one indexed read.
328
+ */
329
+ function signInAttemptSubject(event) {
330
+ return {
331
+ kind: identity_server_1.IDENTITY_ACTIVITY_KINDS.USER_SIGN_IN_FAILED,
332
+ subjectType: 'login_attempt',
333
+ subjectId: event.aggregateId,
334
+ meta: payloadWithoutActor(event)
335
+ };
336
+ }
337
+ /**
338
+ * An `'alarm_rule'`-subject facet. `meta` is the payload minus the actor, which
339
+ * for an update carries `enabled` on both sides — a disabled rule is
340
+ * indistinguishable from a rule that finds nothing, so that transition is the
341
+ * one a reader most needs spelled out.
342
+ */
343
+ function alarmRuleSubject(event) {
344
+ return {
345
+ kind: event.kind,
346
+ subjectType: 'alarm_rule',
347
+ subjectId: event.aggregateId,
348
+ meta: payloadWithoutActor(event)
349
+ };
350
+ }
351
+ /** A `'copilot_skill'`-subject facet. Payload passthrough. */
352
+ function copilotSkillSubject(event) {
353
+ return {
354
+ kind: event.kind,
355
+ subjectType: 'copilot_skill',
356
+ subjectId: event.aggregateId,
357
+ meta: payloadWithoutActor(event)
358
+ };
359
+ }
360
+ /**
361
+ * A `'copilot_run'`-subject facet — the subject is the run, which is the handle
362
+ * a reviewer actually has: a transcript is read by run id, and the tool call
363
+ * this decision answered is one field inside it.
364
+ */
365
+ function copilotRunSubject(event) {
366
+ return {
367
+ kind: event.kind,
368
+ subjectType: 'copilot_run',
369
+ subjectId: event.aggregateId,
370
+ meta: payloadWithoutActor(event)
371
+ };
372
+ }
373
+ /** A `'saved_view'`-subject facet. Payload passthrough, as for alarm rules. */
374
+ function savedViewSubject(event) {
375
+ return {
376
+ kind: event.kind,
377
+ subjectType: 'saved_view',
378
+ subjectId: event.aggregateId,
379
+ meta: payloadWithoutActor(event)
380
+ };
381
+ }
382
+ /**
383
+ * A `'segment'`-subject facet — the audience itself. `meta` is the payload
384
+ * minus the actor, which for `segment.updated` includes the tag list on both
385
+ * sides and for `segment.deleted` the whole record, since after that commits
386
+ * there is nowhere left to look it up.
387
+ */
388
+ function segmentSubject(event) {
389
+ return {
390
+ kind: event.kind,
391
+ subjectType: 'segment',
392
+ subjectId: event.aggregateId,
393
+ meta: payloadWithoutActor(event)
394
+ };
395
+ }
396
+ /**
397
+ * A `'content_entry'`-subject facet for an access change.
398
+ *
399
+ * Deliberately the **entry**, not the segment: "who may read this record" is a
400
+ * fact about the record, so the row belongs in that entry's own history beside
401
+ * its edits and its publishes — which is where somebody asking "why can nobody
402
+ * read this" actually looks. It shares `subjectType` with the `entry.*` kinds
403
+ * for exactly that reason.
404
+ */
405
+ function entryAccessSubject(event) {
406
+ return {
407
+ kind: event.kind,
408
+ subjectType: 'content_entry',
409
+ subjectId: event.aggregateId,
410
+ meta: payloadWithoutActor(event)
411
+ };
412
+ }
413
+ /**
414
+ * A `'content_type'`-subject facet — the subject of a transfer is the content
415
+ * type that moved, which is what the event's `aggregateId` already carries.
416
+ *
417
+ * There is no single entry to point at: an export names a selection and an
418
+ * import names a file, and both fan out across relations, files and locales.
419
+ * The type is the one stable handle the row can keep, and `meta` carries the
420
+ * rest — the format, how many records were selected, and the per-kind counts the
421
+ * walk actually produced — so a reviewer can see that "12 selected" left as 47
422
+ * records plus 9 files without opening anything.
423
+ */
424
+ function transferSubject(event) {
425
+ return {
426
+ kind: event.kind,
427
+ subjectType: 'content_type',
428
+ subjectId: event.aggregateId,
429
+ meta: payloadWithoutActor(event)
430
+ };
431
+ }
209
432
  /**
210
433
  * The event payload with `attachActor`'s `actor` key removed — the actor is
211
434
  * lifted onto the row's own `actorId`/`actorEmail` columns by
@@ -240,17 +463,22 @@ function payloadWithoutActor(event) {
240
463
  * | `member.reactivated` | `user.reactivated` | user / `null` |
241
464
  * | `user.password_changed` | `user.password_changed` | user / `{ sessionsRevoked }` |
242
465
  * | `user.activated` | `user.activated` | user / `null` |
466
+ * | `user.disabled` | `user.suspended` | user / `null` |
467
+ * | `user.enabled` | `user.reactivated` | user / `null` |
243
468
  * | `api_token.created` | `token.created` | api_token / `{ name, scope, workspaceIds, lookupPrefix }` |
244
469
  * | `api_token.revoked` | `token.revoked` | api_token / same shape |
470
+ * | `api_token.used` | `token.used` | api_token / same shape + `previousUseAt` |
471
+ * | `user.session_revoked` | `user.session_revoked` | user / `{ sessionId }` |
245
472
  * | `auth.signed_in` | `user.signed_in` | user / `null` |
246
473
  * | `auth.signed_out` | `user.signed_out` | user / `null` |
247
- * | `entry.created` | `entry.created` | content_entry / `{ contentType }` |
248
- * | `entry.updated` | `entry.updated` | content_entry / `{ contentType, fields }` |
249
- * | `entry.published` | `entry.published` | content_entry / `{ contentType }` |
250
- * | `entry.unpublished` | `entry.unpublished` | content_entry / `{ contentType }` |
251
- * | `entry.deleted` | `entry.deleted` | content_entry / `{ contentType, soft }` |
252
- * | `entry.restored` | `entry.restored` | content_entry / `{ contentType }` |
253
- * | `entry.purged` | `entry.purged` | content_entry / `{ contentType }` |
474
+ * | `auth.sign_in_failed` | `user.sign_in_failed` | **login_attempt** / `{ reason, userId, ipAddress, userAgent }` |
475
+ * | `entry.created` | `entry.created` | content_entry / `{ contentType, title }` |
476
+ * | `entry.updated` | `entry.updated` | content_entry / `{ contentType, title, fields }` |
477
+ * | `entry.published` | `entry.published` | content_entry / `{ contentType, title }` |
478
+ * | `entry.unpublished` | `entry.unpublished` | content_entry / `{ contentType, title }` |
479
+ * | `entry.deleted` | `entry.deleted` | content_entry / `{ contentType, title, soft }` |
480
+ * | `entry.restored` | `entry.restored` | content_entry / `{ contentType, title }` |
481
+ * | `entry.purged` | `entry.purged` | content_entry / `{ contentType, title }` |
254
482
  * | `media.asset.uploaded` | `media.asset.uploaded` | media_asset / payload minus `actor` |
255
483
  * | `media.asset.updated` | `media.asset.updated` | media_asset / payload minus `actor` |
256
484
  * | `media.asset.moved` | `media.asset.moved` | media_asset / payload minus `actor` |
@@ -258,9 +486,28 @@ function payloadWithoutActor(event) {
258
486
  * | `media.folder.created` | `media.folder.created` | media_folder / payload minus `actor` |
259
487
  * | `media.folder.renamed` | `media.folder.renamed` | media_folder / payload minus `actor` |
260
488
  * | `media.folder.deleted` | `media.folder.deleted` | media_folder / payload minus `actor` |
489
+ * | `alarm.rule.created` | `alarm.rule.created` | alarm_rule / payload minus `actor` |
490
+ * | `alarm.rule.updated` | `alarm.rule.updated` | alarm_rule / payload minus `actor` (enabled from/to) |
491
+ * | `alarm.rule.deleted` | `alarm.rule.deleted` | alarm_rule / payload minus `actor` |
492
+ * | `alarm.rule.rescanned` | `alarm.rule.rescanned` | alarm_rule / payload minus `actor` (scan counts) |
493
+ * | `copilot.skill.created` | `copilot.skill.created` | copilot_skill / payload minus `actor` |
494
+ * | `copilot.skill.updated` | `copilot.skill.updated` | copilot_skill / payload minus `actor` |
495
+ * | `copilot.skill.deleted` | `copilot.skill.deleted` | copilot_skill / payload minus `actor` |
496
+ * | `copilot.tool_permission.decided` | same | **copilot_run** / `{ callId, decision, … }` |
497
+ * | `saved_view.created` | `saved_view.created` | saved_view / payload minus `actor` |
498
+ * | `saved_view.updated` | `saved_view.updated` | saved_view / payload minus `actor` |
499
+ * | `saved_view.deleted` | `saved_view.deleted` | saved_view / payload minus `actor` |
500
+ * | `segment.created` | `segment.created` | segment / payload minus `actor` |
501
+ * | `segment.updated` | `segment.updated` | segment / payload minus `actor` (tags from/to) |
502
+ * | `segment.deleted` | `segment.deleted` | segment / payload minus `actor` |
503
+ * | `segment.entry_access_changed` | same | **content_entry** / `{ allow, deny, entryIds, … }` |
504
+ * | `transfer.content.exported`| `transfer.content.exported`| content_type / payload minus `actor` |
505
+ * | `transfer.content.imported`| `transfer.content.imported`| content_type / payload minus `actor` |
261
506
  *
262
- * The actor (`actorId`/`actorEmail`) is not here — it rides on the event payload
263
- * (`attachActor`) and is read uniformly by {@link toAuditRow}.
507
+ * The actor (`actorId`/`actorType`/`actorEmail`) is not here — it rides on the
508
+ * event payload (`attachActor`) and is read uniformly by {@link toAuditRow}, as
509
+ * is `workspaceId` (from the payload's own `workspaceId`, when the producer has
510
+ * one).
264
511
  */
265
512
  const FACET_MAPPERS = {
266
513
  'workspace.created': (e) => workspaceSubject(e, {
@@ -309,24 +556,75 @@ const FACET_MAPPERS = {
309
556
  'user.password_changed': (e) => userSubject(e, USER_AUDIT_KINDS.PASSWORD_CHANGED, {
310
557
  sessionsRevoked: e.payload.sessionsRevoked ?? null
311
558
  }),
559
+ // The identity aggregate's own lifecycle pair. Today every disable/enable
560
+ // in the product runs through the users context's `Member` aggregate, whose
561
+ // `member.disabled` / `member.reactivated` are mapped above — these two are
562
+ // raised by `UserAccount.disable()` / `.enable()`, which nothing in the
563
+ // product calls yet. They are mapped anyway, and to the **same** audit kinds
564
+ // as their `member.*` counterparts: the methods are public API of a
565
+ // published package, so the first caller to appear would otherwise reproduce
566
+ // this package's signature failure — an event raised, no subscriber found,
567
+ // the row stamped dispatched, and an account silently locked out with
568
+ // nothing in the log. Which aggregate performed the change is an internal
569
+ // fact; the reader wants "this account was suspended".
570
+ 'user.disabled': (e) => userSubject(e, USER_AUDIT_KINDS.SUSPENDED, null),
571
+ 'user.enabled': (e) => userSubject(e, USER_AUDIT_KINDS.REACTIVATED, null),
312
572
  // Invite acceptance. `accept-invite.use-case.ts` appends this alongside
313
573
  // `auth.signed_in`, and only the sign-in was ever mapped — so the trail
314
574
  // recorded that an invited person signed in, but never that the account
315
575
  // itself went from `pending` to `active` and gained a credential. Those are
316
576
  // different facts and a security review wants the first one.
317
577
  'user.activated': (e) => userSubject(e, USER_AUDIT_KINDS.ACTIVATED, null),
318
- // The sign-in itself, plus **how** it happened. A password sign-in carries
319
- // no method and reads as it always has; an SSO one records the provider, so
320
- // the log can answer "which of these people came in through the directory,
321
- // and which still hold a password?" — which is the first question after a
322
- // provider is misconfigured or retired.
323
- 'auth.signed_in': (e) => userSubject(e, identity_server_1.IDENTITY_ACTIVITY_KINDS.USER_SIGNED_IN, e.payload.method === 'sso'
578
+ // The sign-in itself, plus **where from** and **how**. The IP and
579
+ // User-Agent were stored on the session row and nowhere else, so the trail
580
+ // could say somebody signed in and never from where and a session is
581
+ // eventually pruned while the audit row is not.
582
+ 'auth.signed_in': (e) => userSubject(e, identity_server_1.IDENTITY_ACTIVITY_KINDS.USER_SIGNED_IN, {
583
+ // Where it came from. Password sign-ins carry these; an SSO one
584
+ // does not (the browser reached the provider, not us), so they are
585
+ // null there rather than absent — a reader should be able to tell
586
+ // "not captured" from "not looked at".
587
+ ipAddress: nullableString(e.payload.ipAddress),
588
+ userAgent: nullableString(e.payload.userAgent),
589
+ // `method` distinguishes the two, which is the first question after
590
+ // a provider is misconfigured or retired: which of these people
591
+ // came in through the directory, and which still hold a password?
592
+ ...(e.payload.method === 'sso'
593
+ ? {
594
+ method: 'sso',
595
+ provider: nullableString(e.payload.provider)
596
+ }
597
+ : {})
598
+ }),
599
+ // The sign-out, and — when it was not the person's own doing — **how**.
600
+ // A back-channel logout is the directory ending the session: an
601
+ // offboarding, a compromised account, a licence revoked upstream. The
602
+ // person clicked nothing, and `sso-backchannel-logout.use-case.ts` puts
603
+ // the method and the provider in the payload precisely so the trail can
604
+ // say which of the two happened.
605
+ //
606
+ // Dropping them made a forced sign-out read exactly like somebody closing
607
+ // their own session — and "were they logged out, or did they leave?" is
608
+ // among the first questions asked of a trail after an incident.
609
+ // `auth.signed_in` records the same pair for the same reason; the
610
+ // asymmetry was the defect rather than a decision.
611
+ //
612
+ // An ordinary sign-out carries no method and keeps `meta: null`: inventing
613
+ // one would be worse than silence, because "not recorded" and "recorded as
614
+ // ordinary" would stop being distinguishable.
615
+ 'auth.signed_out': (e) => userSubject(e, identity_server_1.IDENTITY_ACTIVITY_KINDS.USER_SIGNED_OUT, typeof e.payload.method === 'string'
324
616
  ? {
325
- method: 'sso',
617
+ method: e.payload.method,
326
618
  provider: nullableString(e.payload.provider)
327
619
  }
328
620
  : null),
329
- 'auth.signed_out': (e) => userSubject(e, identity_server_1.IDENTITY_ACTIVITY_KINDS.USER_SIGNED_OUT, null),
621
+ // …and the refusal, which had no kind at all. `auth.signed_in` fires on
622
+ // success only, so a log full of successful sign-ins was equally consistent
623
+ // with nobody ever guessing and with a sustained attack — the one question
624
+ // an operator most wants the trail to settle. The row carries the coarse
625
+ // reason, the address, and the IP/User-Agent the attempt came from; the HTTP
626
+ // response is unchanged and still says nothing.
627
+ 'auth.sign_in_failed': signInAttemptSubject,
330
628
  // Single sign-on. Three separate facts, deliberately not folded into the
331
629
  // sign-in above: a link means a second way into the account now exists, a
332
630
  // provisioned account is the only kind this product creates without an
@@ -344,12 +642,28 @@ const FACET_MAPPERS = {
344
642
  provider: nullableString(e.payload.provider),
345
643
  role: nullableString(e.payload.role)
346
644
  }),
645
+ // An administrator ending somebody else's session. `auth.signed_out` is a
646
+ // person closing their own; this is an act performed *on* an account, and
647
+ // it was the one route under `/users/:id` that wrote nothing at all.
648
+ 'user.session_revoked': (e) => userSubject(e, identity_server_1.IDENTITY_ACTIVITY_KINDS.USER_SESSION_REVOKED, {
649
+ sessionId: nullableString(e.payload.sessionId)
650
+ }),
347
651
  // External-API bearer tokens. Nothing mapped these before, so minting and
348
652
  // revoking a long-lived key to workspace content left the log completely
349
653
  // silent (BUG-identity-server-01) — the audit trail could not answer "who
350
654
  // issued this credential, when, and scoped to what".
351
655
  'api_token.created': (e) => apiTokenSubject(e, identity_server_1.IDENTITY_ACTIVITY_KINDS.TOKEN_CREATED),
352
656
  'api_token.revoked': (e) => apiTokenSubject(e, identity_server_1.IDENTITY_ACTIVITY_KINDS.TOKEN_REVOKED),
657
+ // …and the credential actually being used, throttled at the source to one
658
+ // row per token per minute. `last_used_at` on the token row answers "when
659
+ // was this last used" and nothing else: a token dormant for six months that
660
+ // woke up on Tuesday reads exactly like one in daily use, which is the
661
+ // distinction that matters after a key leaks.
662
+ 'api_token.used': (e) => apiTokenSubject(e, identity_server_1.IDENTITY_ACTIVITY_KINDS.TOKEN_USED, {
663
+ // How long the credential had been quiet before this request. The
664
+ // touch overwrites `last_used_at`, so this is the only copy of it.
665
+ previousUseAt: e.payload.previousUseAt ?? null
666
+ }),
353
667
  // Content publish lifecycle. `content-server` has raised these on the outbox
354
668
  // since the entry aggregate was introduced — its own comment anticipated
355
669
  // this subscriber — but nothing consumed them, so the log carried no content
@@ -382,19 +696,178 @@ const FACET_MAPPERS = {
382
696
  [MEDIA_AUDIT_KINDS.ASSET_DELETED]: mediaAssetSubject,
383
697
  [MEDIA_AUDIT_KINDS.FOLDER_CREATED]: mediaFolderSubject,
384
698
  [MEDIA_AUDIT_KINDS.FOLDER_RENAMED]: mediaFolderSubject,
385
- [MEDIA_AUDIT_KINDS.FOLDER_DELETED]: mediaFolderSubject
699
+ [MEDIA_AUDIT_KINDS.FOLDER_DELETED]: mediaFolderSubject,
700
+ // Bulk export and import. Raised with an actor since the feature shipped and
701
+ // dropped on the floor ever since, so the one operation that moves a
702
+ // workspace's content out of the system wholesale was the one operation the
703
+ // audit log could not answer for.
704
+ // Reader entitlements. Four access-control decisions that produced no
705
+ // event of any kind, so the log could not answer "when did this stop being
706
+ // public, and who decided that".
707
+ [SEGMENT_AUDIT_KINDS.CREATED]: segmentSubject,
708
+ [SEGMENT_AUDIT_KINDS.UPDATED]: segmentSubject,
709
+ [SEGMENT_AUDIT_KINDS.DELETED]: segmentSubject,
710
+ [SEGMENT_AUDIT_KINDS.ENTRY_ACCESS_CHANGED]: entryAccessSubject,
711
+ // Content checks — the rules, not their findings.
712
+ [ALARM_AUDIT_KINDS.RULE_CREATED]: alarmRuleSubject,
713
+ [ALARM_AUDIT_KINDS.RULE_UPDATED]: alarmRuleSubject,
714
+ [ALARM_AUDIT_KINDS.RULE_DELETED]: alarmRuleSubject,
715
+ [ALARM_AUDIT_KINDS.RULE_RESCANNED]: alarmRuleSubject,
716
+ // Shared list views. The personal default is not here — see the kind block.
717
+ [SAVED_VIEW_AUDIT_KINDS.CREATED]: savedViewSubject,
718
+ [SAVED_VIEW_AUDIT_KINDS.UPDATED]: savedViewSubject,
719
+ [SAVED_VIEW_AUDIT_KINDS.DELETED]: savedViewSubject,
720
+ // The copilot's standing instructions, and the moment a person grants one
721
+ // of its calls the authority to proceed.
722
+ [COPILOT_AUDIT_KINDS.SKILL_CREATED]: copilotSkillSubject,
723
+ [COPILOT_AUDIT_KINDS.SKILL_UPDATED]: copilotSkillSubject,
724
+ [COPILOT_AUDIT_KINDS.SKILL_DELETED]: copilotSkillSubject,
725
+ [COPILOT_AUDIT_KINDS.TOOL_PERMISSION_DECIDED]: copilotRunSubject,
726
+ [TRANSFER_AUDIT_KINDS.EXPORTED]: transferSubject,
727
+ [TRANSFER_AUDIT_KINDS.IMPORTED]: transferSubject
386
728
  };
387
729
  /**
388
730
  * Every event kind the activity subscriber audits — the dispatcher delivers
389
731
  * only these to it.
732
+ *
733
+ * These are the mappers' **inputs**. For the `kind` values they produce — the
734
+ * strings that reach the wire and that a client renders — see
735
+ * {@link AUDIT_KINDS}, which is a different and slightly shorter list
736
+ * (`member.*` and `user.disabled` collapse onto `user.*` audit kinds).
390
737
  */
391
738
  exports.AUDITED_EVENT_KINDS = Object.keys(FACET_MAPPERS);
392
- /** Reads the acting user off the event payload (`attachActor`'s `actor` key). */
739
+ /**
740
+ * Every audit `kind` this mapper can **produce** — the catalogue a client has
741
+ * to be able to render.
742
+ *
743
+ * It is declared rather than derived because deriving it would mean running
744
+ * every mapper, and several legitimately throw on a payload that cannot name
745
+ * their subject. `audit-event-mapping.spec.ts` drives each mapper once and
746
+ * asserts the two agree in both directions, so a kind added to `FACET_MAPPERS`
747
+ * and forgotten here fails a test rather than reaching the admin as a raw
748
+ * dotted token.
749
+ *
750
+ * That failure is the whole reason this exists. The admin restates these
751
+ * strings locally (it cannot import a server plugin), the mapper casts
752
+ * `dto.kind as ActivityKind` without checking, and a kind with no descriptor
753
+ * falls back to printing the wire token. No type error, no runtime error, no
754
+ * failing test — six `workspace.*` kinds, `user.activated`, all seven `media.*`
755
+ * kinds and all three `user.sso_*` kinds shipped that way.
756
+ */
757
+ exports.AUDIT_KINDS = [
758
+ 'user.invited',
759
+ 'user.invite_resent',
760
+ 'user.invite_revoked',
761
+ 'user.password_reset_issued',
762
+ 'user.activated',
763
+ 'user.profile_updated',
764
+ 'user.role_changed',
765
+ 'user.suspended',
766
+ 'user.reactivated',
767
+ 'user.password_changed',
768
+ 'user.signed_in',
769
+ 'user.signed_out',
770
+ 'user.sign_in_failed',
771
+ 'user.session_revoked',
772
+ 'user.sso_linked',
773
+ 'user.sso_provisioned',
774
+ 'user.sso_role_mapped',
775
+ 'workspace.created',
776
+ 'workspace.updated',
777
+ 'workspace.archived',
778
+ 'workspace.unarchived',
779
+ 'workspace.deleted',
780
+ 'workspace.member_added',
781
+ 'workspace.member_removed',
782
+ 'workspace.content_granted',
783
+ 'workspace.content_revoked',
784
+ 'entry.created',
785
+ 'entry.updated',
786
+ 'entry.published',
787
+ 'entry.unpublished',
788
+ 'entry.deleted',
789
+ 'entry.restored',
790
+ 'entry.purged',
791
+ 'token.created',
792
+ 'token.revoked',
793
+ 'token.used',
794
+ 'media.asset.uploaded',
795
+ 'media.asset.updated',
796
+ 'media.asset.moved',
797
+ 'media.asset.deleted',
798
+ 'media.folder.created',
799
+ 'media.folder.renamed',
800
+ 'media.folder.deleted',
801
+ 'transfer.content.exported',
802
+ 'transfer.content.imported',
803
+ 'segment.created',
804
+ 'segment.updated',
805
+ 'segment.deleted',
806
+ 'segment.entry_access_changed',
807
+ 'alarm.rule.created',
808
+ 'alarm.rule.updated',
809
+ 'alarm.rule.deleted',
810
+ 'alarm.rule.rescanned',
811
+ 'saved_view.created',
812
+ 'saved_view.updated',
813
+ 'saved_view.deleted',
814
+ 'copilot.skill.created',
815
+ 'copilot.skill.updated',
816
+ 'copilot.skill.deleted',
817
+ 'copilot.tool_permission.decided'
818
+ ];
819
+ /**
820
+ * Every `subject_type` this mapper can stamp — the column's whole range.
821
+ *
822
+ * Restated in the admin for the same reason as {@link AUDIT_KINDS}, and pinned
823
+ * by the same spec: the raw values are snake_cased machine tokens that a client
824
+ * has to translate into something a person reads.
825
+ */
826
+ exports.AUDIT_SUBJECT_TYPES = [
827
+ 'user',
828
+ 'workspace',
829
+ 'content_entry',
830
+ 'content_type',
831
+ 'api_token',
832
+ 'media_asset',
833
+ 'media_folder',
834
+ 'login_attempt',
835
+ 'segment',
836
+ 'alarm_rule',
837
+ 'saved_view',
838
+ 'copilot_skill',
839
+ 'copilot_run'
840
+ ];
841
+ /**
842
+ * Reads the acting principal off the event payload (`attachActor`'s `actor`
843
+ * key).
844
+ *
845
+ * `type` defaults to `'user'` **only when there is an actor at all**: a row
846
+ * with no `actor_id` must not claim to have been performed by a user, so an
847
+ * absent actor yields `null` on every field. An `actor` written before
848
+ * `attachActor` carried a type is a person by construction — nothing else could
849
+ * be one — so defaulting it is a statement of fact rather than a guess.
850
+ *
851
+ * `label` is the readable name of a non-person actor (an API token's label);
852
+ * for a user the readable name is the email, which already has its own column.
853
+ *
854
+ * `via` says **how** — a copilot run, say. It is folded into `meta` rather than
855
+ * given a column: it is open per-mechanism data, it is absent on the
856
+ * overwhelming majority of rows, and nothing filters or indexes on it.
857
+ */
393
858
  function readActor(payload) {
394
859
  const actor = payload.actor;
860
+ const id = nullableString(actor?.id);
861
+ if (!id) {
862
+ return { id: null, email: null, type: null, via: null };
863
+ }
395
864
  return {
396
- id: nullableString(actor?.id),
397
- email: nullableString(actor?.email)
865
+ id,
866
+ email: nullableString(actor?.email) ?? nullableString(actor?.label),
867
+ type: nullableString(actor?.type) ?? 'user',
868
+ via: actor?.via && typeof actor.via === 'object'
869
+ ? actor.via
870
+ : null
398
871
  };
399
872
  }
400
873
  /**
@@ -416,14 +889,22 @@ function toAuditRow(event) {
416
889
  }
417
890
  const facet = mapper(event);
418
891
  const actor = readActor(event.payload);
892
+ // `via` is merged into the kind's own meta rather than replacing it: a
893
+ // copilot-applied entry update is still an entry update, and a reader wants
894
+ // the changed fields *and* the run that changed them.
895
+ const meta = actor.via
896
+ ? { ...(facet.meta ?? {}), via: actor.via }
897
+ : facet.meta;
419
898
  return {
420
899
  id: event.eventId,
421
900
  kind: facet.kind,
422
901
  subjectType: facet.subjectType,
423
902
  subjectId: facet.subjectId,
424
903
  actorId: actor.id,
904
+ actorType: actor.type,
425
905
  actorEmail: actor.email,
426
- meta: facet.meta,
906
+ workspaceId: nullableString(event.payload.workspaceId),
907
+ meta,
427
908
  at: event.occurredAt
428
909
  };
429
910
  }