@orthacms/activity-server 0.0.0-reserve.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/LICENSE +21 -0
- package/README.md +7 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/lib/activity/activity-filter.d.ts +14 -0
- package/dist/lib/activity/activity-filter.d.ts.map +1 -0
- package/dist/lib/activity/activity-filter.js +25 -0
- package/dist/lib/activity/activity.constants.d.ts +18 -0
- package/dist/lib/activity/activity.constants.d.ts.map +1 -0
- package/dist/lib/activity/activity.constants.js +16 -0
- package/dist/lib/activity/controllers/list-activity.controller.d.ts +14 -0
- package/dist/lib/activity/controllers/list-activity.controller.d.ts.map +1 -0
- package/dist/lib/activity/controllers/list-activity.controller.js +36 -0
- package/dist/lib/activity/dto/list-activity-query.dto.d.ts +46 -0
- package/dist/lib/activity/dto/list-activity-query.dto.d.ts.map +1 -0
- package/dist/lib/activity/dto/list-activity-query.dto.js +197 -0
- package/dist/lib/activity/infrastructure/audit-event-mapping.d.ts +60 -0
- package/dist/lib/activity/infrastructure/audit-event-mapping.d.ts.map +1 -0
- package/dist/lib/activity/infrastructure/audit-event-mapping.js +402 -0
- package/dist/lib/activity/infrastructure/audit-event.subscriber.d.ts +29 -0
- package/dist/lib/activity/infrastructure/audit-event.subscriber.d.ts.map +1 -0
- package/dist/lib/activity/infrastructure/audit-event.subscriber.js +54 -0
- package/dist/lib/activity/services/activity.service.d.ts +55 -0
- package/dist/lib/activity/services/activity.service.d.ts.map +1 -0
- package/dist/lib/activity/services/activity.service.js +147 -0
- package/dist/lib/activity/types/activity-view.d.ts +37 -0
- package/dist/lib/activity/types/activity-view.d.ts.map +1 -0
- package/dist/lib/activity/types/activity-view.js +2 -0
- package/dist/lib/activity.module.d.ts +20 -0
- package/dist/lib/activity.module.d.ts.map +1 -0
- package/dist/lib/activity.module.js +49 -0
- package/dist/lib/copilot/activity-tool.provider.d.ts +40 -0
- package/dist/lib/copilot/activity-tool.provider.d.ts.map +1 -0
- package/dist/lib/copilot/activity-tool.provider.js +157 -0
- package/dist/lib/schema/activity-events.d.ts +178 -0
- package/dist/lib/schema/activity-events.d.ts.map +1 -0
- package/dist/lib/schema/activity-events.js +38 -0
- package/dist/lib/schema/index.d.ts +2 -0
- package/dist/lib/schema/index.d.ts.map +1 -0
- package/dist/lib/schema/index.js +5 -0
- package/dist/lib/utils/activity-plugin.d.ts +26 -0
- package/dist/lib/utils/activity-plugin.d.ts.map +1 -0
- package/dist/lib/utils/activity-plugin.js +38 -0
- package/migrations/0000_init.sql +15 -0
- package/migrations/meta/0000_snapshot.json +159 -0
- package/migrations/meta/_journal.json +13 -0
- package/package.json +46 -0
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.AUDITED_EVENT_KINDS = exports.UnmappableAuditEventError = void 0;
|
|
4
|
+
exports.toAuditRow = toAuditRow;
|
|
5
|
+
const identity_server_1 = require("@orthacms/identity-server");
|
|
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.
|
|
12
|
+
*/
|
|
13
|
+
const USER_AUDIT_KINDS = {
|
|
14
|
+
INVITED: 'user.invited',
|
|
15
|
+
INVITE_RESENT: 'user.invite_resent',
|
|
16
|
+
/**
|
|
17
|
+
* An admin minted a one-time password-reset link for a member. Recorded at
|
|
18
|
+
* **issue** time, not at redemption: handing someone a link that can take
|
|
19
|
+
* over an account is an administrative act in its own right, and it needs
|
|
20
|
+
* to be attributed to the admin who performed it even if the link is never
|
|
21
|
+
* used. The redemption shows up separately as `user.password_changed`,
|
|
22
|
+
* actored by the account holder.
|
|
23
|
+
*/
|
|
24
|
+
PASSWORD_RESET_ISSUED: 'user.password_reset_issued',
|
|
25
|
+
INVITE_REVOKED: 'user.invite_revoked',
|
|
26
|
+
PROFILE_UPDATED: 'user.profile_updated',
|
|
27
|
+
ROLE_CHANGED: 'user.role_changed',
|
|
28
|
+
SUSPENDED: 'user.suspended',
|
|
29
|
+
REACTIVATED: 'user.reactivated',
|
|
30
|
+
/**
|
|
31
|
+
* A credential rotation. Unlike the kinds above this one has no in-band
|
|
32
|
+
* predecessor to stay bug-compatible with — `user.password_changed` was
|
|
33
|
+
* raised on the outbox by identity's aggregate but nothing mapped it, so
|
|
34
|
+
* the log recorded nothing at all when a password changed
|
|
35
|
+
* (BUG-identity-server-05).
|
|
36
|
+
*/
|
|
37
|
+
PASSWORD_CHANGED: 'user.password_changed',
|
|
38
|
+
/**
|
|
39
|
+
* An invited account accepted its invite: set a first credential and went
|
|
40
|
+
* `pending` → `active`. Identity's aggregate has raised `user.activated`
|
|
41
|
+
* since the invite flow landed and `accept-invite.use-case.ts` appends it,
|
|
42
|
+
* but nothing mapped it — so the trail showed the invite, then a sign-in,
|
|
43
|
+
* and never the moment the account became usable. Verified against a live
|
|
44
|
+
* stack under ORT-42: one `POST /auth/invite/accept` appended both
|
|
45
|
+
* `user.activated` and `auth.signed_in`, both were stamped dispatched, and
|
|
46
|
+
* only the sign-in produced a row.
|
|
47
|
+
*/
|
|
48
|
+
ACTIVATED: 'user.activated'
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* The **media.\*** audit kinds. Media's aggregates have raised these on the
|
|
52
|
+
* outbox since the asset/folder aggregates were introduced — their own comment
|
|
53
|
+
* calls the outbox "the post-commit audit + blob-GC seam" — but nothing
|
|
54
|
+
* consumed them, so **every** media write was unaudited: uploading, renaming,
|
|
55
|
+
* re-foldering, duplicating and deleting an asset, and creating, renaming and
|
|
56
|
+
* deleting a folder, all left the log completely silent. Verified live under
|
|
57
|
+
* ORT-42 (eight write paths, eight dispatched outbox rows, zero audit rows).
|
|
58
|
+
*
|
|
59
|
+
* The event kind is the audit kind: unlike `member.*` → `user.*` there is no
|
|
60
|
+
* pre-existing audit catalogue for media to stay bug-compatible with, so
|
|
61
|
+
* inventing a second set of names would only add a mapping to remember.
|
|
62
|
+
*/
|
|
63
|
+
const MEDIA_AUDIT_KINDS = {
|
|
64
|
+
ASSET_UPLOADED: 'media.asset.uploaded',
|
|
65
|
+
ASSET_UPDATED: 'media.asset.updated',
|
|
66
|
+
ASSET_MOVED: 'media.asset.moved',
|
|
67
|
+
ASSET_DELETED: 'media.asset.deleted',
|
|
68
|
+
FOLDER_CREATED: 'media.folder.created',
|
|
69
|
+
FOLDER_RENAMED: 'media.folder.renamed',
|
|
70
|
+
FOLDER_DELETED: 'media.folder.deleted'
|
|
71
|
+
};
|
|
72
|
+
/** Reads a payload field as a string (or `null` when absent/nullish). */
|
|
73
|
+
function nullableString(value) {
|
|
74
|
+
return typeof value === 'string' ? value : null;
|
|
75
|
+
}
|
|
76
|
+
/** A `'user'`-subject facet whose subject is the event's aggregate. */
|
|
77
|
+
function userSubject(event, kind, meta) {
|
|
78
|
+
return { kind, subjectType: 'user', subjectId: event.aggregateId, meta };
|
|
79
|
+
}
|
|
80
|
+
/** A `'workspace'`-subject facet whose subject is the event's aggregate. */
|
|
81
|
+
function workspaceSubject(event, meta) {
|
|
82
|
+
// Workspace audit kinds are identical strings to their event kinds
|
|
83
|
+
// (identity owns them via IDENTITY_ACTIVITY_KINDS), so the event kind is the
|
|
84
|
+
// audit kind.
|
|
85
|
+
return {
|
|
86
|
+
kind: event.kind,
|
|
87
|
+
subjectType: 'workspace',
|
|
88
|
+
subjectId: event.aggregateId,
|
|
89
|
+
meta
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* A `'content_entry'`-subject facet. The audit kind mirrors the event kind
|
|
94
|
+
* (`entry.created`, `entry.published`, `entry.deleted`, …), and the entry's
|
|
95
|
+
* content type rides in `meta` so the log can name *what* happened to without
|
|
96
|
+
* joining anything.
|
|
97
|
+
*
|
|
98
|
+
* `extra` carries the few facts one kind has and the others do not — the
|
|
99
|
+
* changed `fields` on an update, and whether a delete was a recoverable
|
|
100
|
+
* tombstone (`soft`) or the row leaving the table. Everything else about an
|
|
101
|
+
* entry event is the same shape, which is why one mapper serves all seven.
|
|
102
|
+
*/
|
|
103
|
+
function entrySubject(event, extra = {}) {
|
|
104
|
+
return {
|
|
105
|
+
kind: event.kind,
|
|
106
|
+
subjectType: 'content_entry',
|
|
107
|
+
subjectId: event.aggregateId,
|
|
108
|
+
meta: {
|
|
109
|
+
contentType: nullableString(event.payload.contentType),
|
|
110
|
+
...extra
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* An `'api_token'`-subject facet. The subject is the token, and the acting
|
|
116
|
+
* admin arrives separately as the actor.
|
|
117
|
+
*
|
|
118
|
+
* `meta` records what a reviewer needs to reason about the credential — its
|
|
119
|
+
* 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.
|
|
124
|
+
*/
|
|
125
|
+
function apiTokenSubject(event, auditKind) {
|
|
126
|
+
const payload = event.payload;
|
|
127
|
+
return {
|
|
128
|
+
kind: auditKind,
|
|
129
|
+
subjectType: 'api_token',
|
|
130
|
+
subjectId: event.aggregateId,
|
|
131
|
+
meta: {
|
|
132
|
+
name: nullableString(payload.name),
|
|
133
|
+
scope: nullableString(payload.scope),
|
|
134
|
+
workspaceIds: payload.workspaceIds ?? [],
|
|
135
|
+
lookupPrefix: nullableString(payload.lookupPrefix)
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Thrown when an event cannot be mapped to a row that identifies its subject.
|
|
141
|
+
*
|
|
142
|
+
* Refusing is the point. `subject_id` is the **only** handle an audit row keeps
|
|
143
|
+
* on the entity it is about — there is no FK, no denormalised name, and
|
|
144
|
+
* `actor_email` belongs to the actor — so a row written with an empty subject
|
|
145
|
+
* is not a degraded record, it is an unreadable one, and nothing downstream can
|
|
146
|
+
* repair it. Throwing leaves the outbox row undispatched, so the dispatcher
|
|
147
|
+
* retries it with backoff and parks it at `MAX_DELIVERY_ATTEMPTS` where the
|
|
148
|
+
* dead-letter query (`dispatched_at IS NULL AND attempts >= 15`) surfaces it.
|
|
149
|
+
* The gap becomes loud instead of becoming a row nobody can read.
|
|
150
|
+
*/
|
|
151
|
+
class UnmappableAuditEventError extends Error {
|
|
152
|
+
constructor(event, reason) {
|
|
153
|
+
super(`Cannot map ${event.kind} (event ${event.eventId}) to an audit row: ${reason}`);
|
|
154
|
+
this.name = 'UnmappableAuditEventError';
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
exports.UnmappableAuditEventError = UnmappableAuditEventError;
|
|
158
|
+
/**
|
|
159
|
+
* A workspace membership facet — subject is the affected **user**, not the
|
|
160
|
+
* workspace.
|
|
161
|
+
*
|
|
162
|
+
* A payload with no `userId` is refused rather than defaulted. It used to write
|
|
163
|
+
* `subjectId: '' `, which inserts cleanly against `subject_id text NOT NULL`
|
|
164
|
+
* and produces a row naming an action, a workspace and an actor but no subject
|
|
165
|
+
* — silent corruption of the trail, and (as ♿ A11Y-activity-server-01 records)
|
|
166
|
+
* a row whose Subject cell has no accessible name that no client can repair.
|
|
167
|
+
*/
|
|
168
|
+
function membershipSubject(event, auditKind) {
|
|
169
|
+
const payload = event.payload;
|
|
170
|
+
const userId = nullableString(payload.userId);
|
|
171
|
+
if (!userId) {
|
|
172
|
+
throw new UnmappableAuditEventError(event, 'payload.userId is missing, and the membership subject is the affected user');
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
kind: auditKind,
|
|
176
|
+
subjectType: 'user',
|
|
177
|
+
subjectId: userId,
|
|
178
|
+
meta: {
|
|
179
|
+
workspaceId: event.aggregateId,
|
|
180
|
+
email: nullableString(payload.email)
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* A `'media_asset'`-subject facet. `meta` passes the event payload's own
|
|
186
|
+
* descriptive fields through — media's payloads differ per kind (`name`/`kind`
|
|
187
|
+
* on upload, the changed field on update, `folderId` on move, the storage key
|
|
188
|
+
* on delete) and each is exactly what a reviewer asking "what happened to this
|
|
189
|
+
* asset" wants, so the facet forwards the payload minus the actor rather than
|
|
190
|
+
* flattening every kind into one shape.
|
|
191
|
+
*/
|
|
192
|
+
function mediaAssetSubject(event) {
|
|
193
|
+
return {
|
|
194
|
+
kind: event.kind,
|
|
195
|
+
subjectType: 'media_asset',
|
|
196
|
+
subjectId: event.aggregateId,
|
|
197
|
+
meta: payloadWithoutActor(event)
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
/** A `'media_folder'`-subject facet. Same payload-passthrough as the asset one. */
|
|
201
|
+
function mediaFolderSubject(event) {
|
|
202
|
+
return {
|
|
203
|
+
kind: event.kind,
|
|
204
|
+
subjectType: 'media_folder',
|
|
205
|
+
subjectId: event.aggregateId,
|
|
206
|
+
meta: payloadWithoutActor(event)
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* The event payload with `attachActor`'s `actor` key removed — the actor is
|
|
211
|
+
* lifted onto the row's own `actorId`/`actorEmail` columns by
|
|
212
|
+
* {@link toAuditRow}, so repeating it inside `meta` would only duplicate it.
|
|
213
|
+
*/
|
|
214
|
+
function payloadWithoutActor(event) {
|
|
215
|
+
const { actor: _actor, ...rest } = event.payload;
|
|
216
|
+
return rest;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* The event-kind → audit-facet table. Each entry reproduces **exactly** the row
|
|
220
|
+
* the old in-band `recorder.record(...)` wrote for that action:
|
|
221
|
+
*
|
|
222
|
+
* | domain event | audit kind | subject / meta |
|
|
223
|
+
* | -------------------------- | ------------------------- | ------------------------------------------------ |
|
|
224
|
+
* | `workspace.created` | `workspace.created` | workspace / `{ name, slug }` |
|
|
225
|
+
* | `workspace.updated` | `workspace.updated` | workspace / `{ fields }` |
|
|
226
|
+
* | `workspace.archived` | `workspace.archived` | workspace / `{}` |
|
|
227
|
+
* | `workspace.unarchived` | `workspace.unarchived` | workspace / `{}` |
|
|
228
|
+
* | `workspace.deleted` | `workspace.deleted` | workspace / `{ name, slug }` |
|
|
229
|
+
* | `workspace.member_added` | `workspace.member_added` | **user** / `{ workspaceId, email }` |
|
|
230
|
+
* | `workspace.member_removed` | `workspace.member_removed`| **user** / `{ workspaceId, email }` |
|
|
231
|
+
* | `workspace.content_granted`| `workspace.content_granted`| workspace / `{ slug, kind }` |
|
|
232
|
+
* | `workspace.content_revoked`| `workspace.content_revoked`| workspace / `{ slug }` |
|
|
233
|
+
* | `member.invited` | `user.invited` | user / `{ email }` |
|
|
234
|
+
* | `member.invite_resent` | `user.invite_resent` | user / `{ email }` |
|
|
235
|
+
* | `member.password_reset_issued` | `user.password_reset_issued` | user / `{ email }` |
|
|
236
|
+
* | `member.removed` | `user.invite_revoked` | user / `{ email }` |
|
|
237
|
+
* | `member.profile_updated` | `user.profile_updated` | user / `{ name: { from, to } }` |
|
|
238
|
+
* | `member.role_changed` | `user.role_changed` | user / `{ from, to }` |
|
|
239
|
+
* | `member.disabled` | `user.suspended` | user / `null` |
|
|
240
|
+
* | `member.reactivated` | `user.reactivated` | user / `null` |
|
|
241
|
+
* | `user.password_changed` | `user.password_changed` | user / `{ sessionsRevoked }` |
|
|
242
|
+
* | `user.activated` | `user.activated` | user / `null` |
|
|
243
|
+
* | `api_token.created` | `token.created` | api_token / `{ name, scope, workspaceIds, lookupPrefix }` |
|
|
244
|
+
* | `api_token.revoked` | `token.revoked` | api_token / same shape |
|
|
245
|
+
* | `auth.signed_in` | `user.signed_in` | user / `null` |
|
|
246
|
+
* | `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 }` |
|
|
254
|
+
* | `media.asset.uploaded` | `media.asset.uploaded` | media_asset / payload minus `actor` |
|
|
255
|
+
* | `media.asset.updated` | `media.asset.updated` | media_asset / payload minus `actor` |
|
|
256
|
+
* | `media.asset.moved` | `media.asset.moved` | media_asset / payload minus `actor` |
|
|
257
|
+
* | `media.asset.deleted` | `media.asset.deleted` | media_asset / payload minus `actor` |
|
|
258
|
+
* | `media.folder.created` | `media.folder.created` | media_folder / payload minus `actor` |
|
|
259
|
+
* | `media.folder.renamed` | `media.folder.renamed` | media_folder / payload minus `actor` |
|
|
260
|
+
* | `media.folder.deleted` | `media.folder.deleted` | media_folder / payload minus `actor` |
|
|
261
|
+
*
|
|
262
|
+
* The actor (`actorId`/`actorEmail`) is not here — it rides on the event payload
|
|
263
|
+
* (`attachActor`) and is read uniformly by {@link toAuditRow}.
|
|
264
|
+
*/
|
|
265
|
+
const FACET_MAPPERS = {
|
|
266
|
+
'workspace.created': (e) => workspaceSubject(e, {
|
|
267
|
+
name: nullableString(e.payload.name),
|
|
268
|
+
slug: nullableString(e.payload.slug)
|
|
269
|
+
}),
|
|
270
|
+
'workspace.updated': (e) => workspaceSubject(e, { fields: e.payload.fields ?? [] }),
|
|
271
|
+
'workspace.archived': (e) => workspaceSubject(e, {}),
|
|
272
|
+
'workspace.unarchived': (e) => workspaceSubject(e, {}),
|
|
273
|
+
'workspace.deleted': (e) => workspaceSubject(e, {
|
|
274
|
+
name: nullableString(e.payload.name),
|
|
275
|
+
slug: nullableString(e.payload.slug)
|
|
276
|
+
}),
|
|
277
|
+
'workspace.member_added': (e) => membershipSubject(e, identity_server_1.IDENTITY_ACTIVITY_KINDS.WORKSPACE_MEMBER_ADDED),
|
|
278
|
+
'workspace.member_removed': (e) => membershipSubject(e, identity_server_1.IDENTITY_ACTIVITY_KINDS.WORKSPACE_MEMBER_REMOVED),
|
|
279
|
+
'workspace.content_granted': (e) => workspaceSubject(e, {
|
|
280
|
+
slug: nullableString(e.payload.slug),
|
|
281
|
+
kind: nullableString(e.payload.kind)
|
|
282
|
+
}),
|
|
283
|
+
'workspace.content_revoked': (e) => workspaceSubject(e, { slug: nullableString(e.payload.slug) }),
|
|
284
|
+
'member.invited': (e) => userSubject(e, USER_AUDIT_KINDS.INVITED, {
|
|
285
|
+
email: nullableString(e.payload.email)
|
|
286
|
+
}),
|
|
287
|
+
'member.invite_resent': (e) => userSubject(e, USER_AUDIT_KINDS.INVITE_RESENT, {
|
|
288
|
+
email: nullableString(e.payload.email)
|
|
289
|
+
}),
|
|
290
|
+
'member.password_reset_issued': (e) => userSubject(e, USER_AUDIT_KINDS.PASSWORD_RESET_ISSUED, {
|
|
291
|
+
email: nullableString(e.payload.email)
|
|
292
|
+
}),
|
|
293
|
+
'member.removed': (e) => userSubject(e, USER_AUDIT_KINDS.INVITE_REVOKED, {
|
|
294
|
+
email: nullableString(e.payload.email)
|
|
295
|
+
}),
|
|
296
|
+
'member.profile_updated': (e) => userSubject(e, USER_AUDIT_KINDS.PROFILE_UPDATED, {
|
|
297
|
+
name: e.payload.name ?? null
|
|
298
|
+
}),
|
|
299
|
+
'member.role_changed': (e) => userSubject(e, USER_AUDIT_KINDS.ROLE_CHANGED, {
|
|
300
|
+
from: nullableString(e.payload.from),
|
|
301
|
+
to: nullableString(e.payload.to)
|
|
302
|
+
}),
|
|
303
|
+
'member.disabled': (e) => userSubject(e, USER_AUDIT_KINDS.SUSPENDED, null),
|
|
304
|
+
'member.reactivated': (e) => userSubject(e, USER_AUDIT_KINDS.REACTIVATED, null),
|
|
305
|
+
// Credential rotation. The kind is passed straight through (identity's
|
|
306
|
+
// event kind and the audit kind are the same string), and `meta` records
|
|
307
|
+
// how many live sessions the change evicted — the number a security review
|
|
308
|
+
// actually wants: "the password changed AND N devices were signed out".
|
|
309
|
+
'user.password_changed': (e) => userSubject(e, USER_AUDIT_KINDS.PASSWORD_CHANGED, {
|
|
310
|
+
sessionsRevoked: e.payload.sessionsRevoked ?? null
|
|
311
|
+
}),
|
|
312
|
+
// Invite acceptance. `accept-invite.use-case.ts` appends this alongside
|
|
313
|
+
// `auth.signed_in`, and only the sign-in was ever mapped — so the trail
|
|
314
|
+
// recorded that an invited person signed in, but never that the account
|
|
315
|
+
// itself went from `pending` to `active` and gained a credential. Those are
|
|
316
|
+
// different facts and a security review wants the first one.
|
|
317
|
+
'user.activated': (e) => userSubject(e, USER_AUDIT_KINDS.ACTIVATED, null),
|
|
318
|
+
'auth.signed_in': (e) => userSubject(e, identity_server_1.IDENTITY_ACTIVITY_KINDS.USER_SIGNED_IN, null),
|
|
319
|
+
'auth.signed_out': (e) => userSubject(e, identity_server_1.IDENTITY_ACTIVITY_KINDS.USER_SIGNED_OUT, null),
|
|
320
|
+
// External-API bearer tokens. Nothing mapped these before, so minting and
|
|
321
|
+
// revoking a long-lived key to workspace content left the log completely
|
|
322
|
+
// silent (BUG-identity-server-01) — the audit trail could not answer "who
|
|
323
|
+
// issued this credential, when, and scoped to what".
|
|
324
|
+
'api_token.created': (e) => apiTokenSubject(e, identity_server_1.IDENTITY_ACTIVITY_KINDS.TOKEN_CREATED),
|
|
325
|
+
'api_token.revoked': (e) => apiTokenSubject(e, identity_server_1.IDENTITY_ACTIVITY_KINDS.TOKEN_REVOKED),
|
|
326
|
+
// Content publish lifecycle. `content-server` has raised these on the outbox
|
|
327
|
+
// since the entry aggregate was introduced — its own comment anticipated
|
|
328
|
+
// this subscriber — but nothing consumed them, so the log carried no content
|
|
329
|
+
// activity at all.
|
|
330
|
+
'entry.published': entrySubject,
|
|
331
|
+
'entry.unpublished': entrySubject,
|
|
332
|
+
// …and the ordinary editing lifecycle, which raised nothing at all until
|
|
333
|
+
// the entry writes moved onto the unit of work. Publishing was the only
|
|
334
|
+
// content action the log could answer for, so an editor could create,
|
|
335
|
+
// rewrite and delete every entry in the product and it stayed silent about
|
|
336
|
+
// the single most frequent action in a CMS.
|
|
337
|
+
'entry.created': (e) => entrySubject(e),
|
|
338
|
+
// `fields` names what actually changed — the `workspace.updated` shape, and
|
|
339
|
+
// what makes the row a review rather than a bare "someone saved this".
|
|
340
|
+
'entry.updated': (e) => entrySubject(e, { fields: e.payload.fields ?? [] }),
|
|
341
|
+
// `soft` is the difference between a tombstone the trash can restore and a
|
|
342
|
+
// row that left the table when this committed.
|
|
343
|
+
'entry.deleted': (e) => entrySubject(e, { soft: e.payload.soft === true }),
|
|
344
|
+
'entry.restored': (e) => entrySubject(e),
|
|
345
|
+
// The one content action with nothing left behind to inspect afterwards,
|
|
346
|
+
// and therefore the one this row is the only remaining record of.
|
|
347
|
+
'entry.purged': (e) => entrySubject(e),
|
|
348
|
+
// The media library. Every one of these was raised on the outbox and
|
|
349
|
+
// dropped on the floor: the dispatcher found no subscriber for the kind,
|
|
350
|
+
// stamped the row dispatched, and the audit log stayed silent about every
|
|
351
|
+
// upload, rename, move and deletion in the asset store.
|
|
352
|
+
[MEDIA_AUDIT_KINDS.ASSET_UPLOADED]: mediaAssetSubject,
|
|
353
|
+
[MEDIA_AUDIT_KINDS.ASSET_UPDATED]: mediaAssetSubject,
|
|
354
|
+
[MEDIA_AUDIT_KINDS.ASSET_MOVED]: mediaAssetSubject,
|
|
355
|
+
[MEDIA_AUDIT_KINDS.ASSET_DELETED]: mediaAssetSubject,
|
|
356
|
+
[MEDIA_AUDIT_KINDS.FOLDER_CREATED]: mediaFolderSubject,
|
|
357
|
+
[MEDIA_AUDIT_KINDS.FOLDER_RENAMED]: mediaFolderSubject,
|
|
358
|
+
[MEDIA_AUDIT_KINDS.FOLDER_DELETED]: mediaFolderSubject
|
|
359
|
+
};
|
|
360
|
+
/**
|
|
361
|
+
* Every event kind the activity subscriber audits — the dispatcher delivers
|
|
362
|
+
* only these to it.
|
|
363
|
+
*/
|
|
364
|
+
exports.AUDITED_EVENT_KINDS = Object.keys(FACET_MAPPERS);
|
|
365
|
+
/** Reads the acting user off the event payload (`attachActor`'s `actor` key). */
|
|
366
|
+
function readActor(payload) {
|
|
367
|
+
const actor = payload.actor;
|
|
368
|
+
return {
|
|
369
|
+
id: nullableString(actor?.id),
|
|
370
|
+
email: nullableString(actor?.email)
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Maps a domain event to the audit row it should produce, or `null` when the
|
|
375
|
+
* kind is not audited. Pure and DB-free — the safety-net unit test asserts each
|
|
376
|
+
* kind's row equals what the old in-band recorder wrote.
|
|
377
|
+
*
|
|
378
|
+
* Throws {@link UnmappableAuditEventError} when the kind **is** audited but the
|
|
379
|
+
* payload cannot identify its subject. `null` and a throw mean different things
|
|
380
|
+
* on purpose: `null` is "not our event, skip it" and the dispatcher marks the
|
|
381
|
+
* row delivered; a throw is "this should have been audited and cannot be", so
|
|
382
|
+
* the row stays undispatched, is retried with backoff, and finally parks as a
|
|
383
|
+
* dead letter rather than becoming a row nobody can read.
|
|
384
|
+
*/
|
|
385
|
+
function toAuditRow(event) {
|
|
386
|
+
const mapper = FACET_MAPPERS[event.kind];
|
|
387
|
+
if (!mapper) {
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
const facet = mapper(event);
|
|
391
|
+
const actor = readActor(event.payload);
|
|
392
|
+
return {
|
|
393
|
+
id: event.eventId,
|
|
394
|
+
kind: facet.kind,
|
|
395
|
+
subjectType: facet.subjectType,
|
|
396
|
+
subjectId: facet.subjectId,
|
|
397
|
+
actorId: actor.id,
|
|
398
|
+
actorEmail: actor.email,
|
|
399
|
+
meta: facet.meta,
|
|
400
|
+
at: event.occurredAt
|
|
401
|
+
};
|
|
402
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type OnApplicationBootstrap } from '@nestjs/common';
|
|
2
|
+
import { OutboxDispatcher, type Database, type DomainEvent, type DomainEventSubscriber } from '@orthacms/database';
|
|
3
|
+
/**
|
|
4
|
+
* The activity log's outbox subscriber — the audit trail's single live writer.
|
|
5
|
+
* On bootstrap it registers itself with the {@link OutboxDispatcher}, which then
|
|
6
|
+
* delivers every audited domain event (see {@link AUDITED_EVENT_KINDS}) here;
|
|
7
|
+
* {@link handle} maps each to the same `activity_events` row the old in-band
|
|
8
|
+
* `ACTIVITY_RECORDER` wrote.
|
|
9
|
+
*
|
|
10
|
+
* **Idempotent** (delivery is at-least-once): the row's primary key is the
|
|
11
|
+
* source event's id and the insert is `ON CONFLICT DO NOTHING`, so a
|
|
12
|
+
* re-delivered event never double-records.
|
|
13
|
+
*/
|
|
14
|
+
export declare class AuditEventSubscriber implements DomainEventSubscriber, OnApplicationBootstrap {
|
|
15
|
+
private readonly db;
|
|
16
|
+
private readonly dispatcher;
|
|
17
|
+
/** The audited event kinds — the dispatcher's delivery allow-list. */
|
|
18
|
+
readonly kinds: readonly string[];
|
|
19
|
+
constructor(db: Database, dispatcher: OutboxDispatcher);
|
|
20
|
+
/** Registers with the dispatcher once the app is up. */
|
|
21
|
+
onApplicationBootstrap(): void;
|
|
22
|
+
/**
|
|
23
|
+
* Maps one delivered event to its audit row and appends it. A kind with no
|
|
24
|
+
* mapping is ignored (defensive — the dispatcher only delivers our
|
|
25
|
+
* {@link kinds}); the `ON CONFLICT DO NOTHING` keeps redelivery a no-op.
|
|
26
|
+
*/
|
|
27
|
+
handle(event: DomainEvent): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
//# sourceMappingURL=audit-event.subscriber.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"audit-event.subscriber.d.ts","sourceRoot":"","sources":["../../../../src/lib/activity/infrastructure/audit-event.subscriber.ts"],"names":[],"mappings":"AAAA,OAAO,EAAc,KAAK,sBAAsB,EAAE,MAAM,gBAAgB,CAAC;AACzE,OAAO,EAEH,gBAAgB,EAChB,KAAK,QAAQ,EACb,KAAK,WAAW,EAChB,KAAK,qBAAqB,EAC7B,MAAM,oBAAoB,CAAC;AAI5B;;;;;;;;;;GAUG;AACH,qBACa,oBACT,YAAW,qBAAqB,EAAE,sBAAsB;IAMlC,OAAO,CAAC,QAAQ,CAAC,EAAE;IACrC,OAAO,CAAC,QAAQ,CAAC,UAAU;IAL/B,sEAAsE;IACtE,QAAQ,CAAC,KAAK,oBAAuB;gBAGE,EAAE,EAAE,QAAQ,EAC9B,UAAU,EAAE,gBAAgB;IAGjD,wDAAwD;IACxD,sBAAsB,IAAI,IAAI;IAI9B;;;;OAIG;IACG,MAAM,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;CAUlD"}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.AuditEventSubscriber = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const common_1 = require("@nestjs/common");
|
|
6
|
+
const database_1 = require("@orthacms/database");
|
|
7
|
+
const schema_1 = require("../../schema");
|
|
8
|
+
const audit_event_mapping_1 = require("./audit-event-mapping");
|
|
9
|
+
/**
|
|
10
|
+
* The activity log's outbox subscriber — the audit trail's single live writer.
|
|
11
|
+
* On bootstrap it registers itself with the {@link OutboxDispatcher}, which then
|
|
12
|
+
* delivers every audited domain event (see {@link AUDITED_EVENT_KINDS}) here;
|
|
13
|
+
* {@link handle} maps each to the same `activity_events` row the old in-band
|
|
14
|
+
* `ACTIVITY_RECORDER` wrote.
|
|
15
|
+
*
|
|
16
|
+
* **Idempotent** (delivery is at-least-once): the row's primary key is the
|
|
17
|
+
* source event's id and the insert is `ON CONFLICT DO NOTHING`, so a
|
|
18
|
+
* re-delivered event never double-records.
|
|
19
|
+
*/
|
|
20
|
+
let AuditEventSubscriber = class AuditEventSubscriber {
|
|
21
|
+
db;
|
|
22
|
+
dispatcher;
|
|
23
|
+
/** The audited event kinds — the dispatcher's delivery allow-list. */
|
|
24
|
+
kinds = audit_event_mapping_1.AUDITED_EVENT_KINDS;
|
|
25
|
+
constructor(db, dispatcher) {
|
|
26
|
+
this.db = db;
|
|
27
|
+
this.dispatcher = dispatcher;
|
|
28
|
+
}
|
|
29
|
+
/** Registers with the dispatcher once the app is up. */
|
|
30
|
+
onApplicationBootstrap() {
|
|
31
|
+
this.dispatcher.register(this);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Maps one delivered event to its audit row and appends it. A kind with no
|
|
35
|
+
* mapping is ignored (defensive — the dispatcher only delivers our
|
|
36
|
+
* {@link kinds}); the `ON CONFLICT DO NOTHING` keeps redelivery a no-op.
|
|
37
|
+
*/
|
|
38
|
+
async handle(event) {
|
|
39
|
+
const row = (0, audit_event_mapping_1.toAuditRow)(event);
|
|
40
|
+
if (!row) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
await this.db
|
|
44
|
+
.insert(schema_1.activityEvents)
|
|
45
|
+
.values(row)
|
|
46
|
+
.onConflictDoNothing({ target: schema_1.activityEvents.id });
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
exports.AuditEventSubscriber = AuditEventSubscriber;
|
|
50
|
+
exports.AuditEventSubscriber = AuditEventSubscriber = tslib_1.__decorate([
|
|
51
|
+
(0, common_1.Injectable)(),
|
|
52
|
+
tslib_1.__param(0, (0, database_1.InjectDatabase)()),
|
|
53
|
+
tslib_1.__metadata("design:paramtypes", [Object, database_1.OutboxDispatcher])
|
|
54
|
+
], AuditEventSubscriber);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { type Database } from '@orthacms/database';
|
|
2
|
+
import type { ActivityExecutor, ActivityRecorder, ActivityRecordInput } from '@orthacms/identity-server';
|
|
3
|
+
import type { ListActivityQueryDto } from '../dto/list-activity-query.dto';
|
|
4
|
+
import type { ActivityListView } from '../types/activity-view';
|
|
5
|
+
/**
|
|
6
|
+
* Owns the audit trail: appends events (`record`) and serves the paginated
|
|
7
|
+
* read API (`list`). Implements {@link ActivityRecorder} so foundational
|
|
8
|
+
* plugins can record via the `ACTIVITY_RECORDER` token without depending on
|
|
9
|
+
* this package. Uses the shared Drizzle client directly (no repository
|
|
10
|
+
* wrapper, by repo convention).
|
|
11
|
+
*/
|
|
12
|
+
export declare class ActivityService implements ActivityRecorder {
|
|
13
|
+
private readonly db;
|
|
14
|
+
constructor(db: Database);
|
|
15
|
+
/**
|
|
16
|
+
* Appends one audit row. Pass `executor = tx` to record **in-band** with a
|
|
17
|
+
* mutation, so the audit row commits iff the mutation does; defaults to the
|
|
18
|
+
* root client. `at` defaults to now; `actorId`/`actorEmail`/`meta` are
|
|
19
|
+
* nullable.
|
|
20
|
+
*
|
|
21
|
+
* @deprecated Wave 3 moved auditing onto the outbox `AuditEventSubscriber`,
|
|
22
|
+
* the single live writer. This method (and the `ACTIVITY_RECORDER` binding)
|
|
23
|
+
* is retained only for a stable public surface — no caller writes through it
|
|
24
|
+
* anymore. Emit a domain event and let the subscriber record it instead.
|
|
25
|
+
*/
|
|
26
|
+
record(input: ActivityRecordInput, executor?: ActivityExecutor): Promise<void>;
|
|
27
|
+
/**
|
|
28
|
+
* One page of events matching the filters, with a stable order: the
|
|
29
|
+
* whitelisted sort column (default `at desc`) plus an `id` tiebreaker so
|
|
30
|
+
* paging is deterministic across equal timestamps. `created_at` is never
|
|
31
|
+
* selected — the immutable write time stays off the wire.
|
|
32
|
+
*/
|
|
33
|
+
list(query: ListActivityQueryDto): Promise<ActivityListView>;
|
|
34
|
+
/**
|
|
35
|
+
* The full `where` for the list: the structured params (`listPredicate`)
|
|
36
|
+
* AND-ed with the optional query-builder `?filter=` tree. The filter is
|
|
37
|
+
* parsed and translated against {@link ACTIVITY_FILTER_SCHEMA}; a malformed
|
|
38
|
+
* filter throws a `FilterException` (HTTP 400). `and(undefined, …)`
|
|
39
|
+
* collapses cleanly, so an unfiltered list still scans everything.
|
|
40
|
+
*/
|
|
41
|
+
private listWhere;
|
|
42
|
+
/**
|
|
43
|
+
* The structured-param `where` for the list: every supplied filter
|
|
44
|
+
* intersected (AND). `and(undefined, …)` collapses to no filter, so an
|
|
45
|
+
* unfiltered list scans everything.
|
|
46
|
+
*/
|
|
47
|
+
private listPredicate;
|
|
48
|
+
/**
|
|
49
|
+
* Case-insensitive substring match on the actor email snapshot, or
|
|
50
|
+
* `undefined` for no filter. LIKE metacharacters in the needle are escaped
|
|
51
|
+
* so a literal `%`/`_` search behaves literally.
|
|
52
|
+
*/
|
|
53
|
+
private actorEmailPredicate;
|
|
54
|
+
}
|
|
55
|
+
//# sourceMappingURL=activity.service.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"activity.service.d.ts","sourceRoot":"","sources":["../../../../src/lib/activity/services/activity.service.ts"],"names":[],"mappings":"AAYA,OAAO,EAAkB,KAAK,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAEnE,OAAO,KAAK,EACR,gBAAgB,EAChB,gBAAgB,EAChB,mBAAmB,EACtB,MAAM,2BAA2B,CAAC;AAInC,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,gCAAgC,CAAC;AAC3E,OAAO,KAAK,EAER,gBAAgB,EACnB,MAAM,wBAAwB,CAAC;AAQhC;;;;;;GAMG;AACH,qBACa,eAAgB,YAAW,gBAAgB;IACtB,OAAO,CAAC,QAAQ,CAAC,EAAE;gBAAF,EAAE,EAAE,QAAQ;IAE3D;;;;;;;;;;OAUG;IACG,MAAM,CACR,KAAK,EAAE,mBAAmB,EAC1B,QAAQ,GAAE,gBAA0B,GACrC,OAAO,CAAC,IAAI,CAAC;IAYhB;;;;;OAKG;IACG,IAAI,CAAC,KAAK,EAAE,oBAAoB,GAAG,OAAO,CAAC,gBAAgB,CAAC;IA8ClE;;;;;;OAMG;YACW,SAAS;IAWvB;;;;OAIG;IACH,OAAO,CAAC,aAAa;IAsBrB;;;;OAIG;IACH,OAAO,CAAC,mBAAmB;CAQ9B"}
|