@tumbaland/backend-core 1.38.0 → 1.40.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 (77) hide show
  1. package/dist/apiKeys/ApiKey.d.ts +11 -0
  2. package/dist/apiKeys/ApiKey.d.ts.map +1 -1
  3. package/dist/apiKeys/ApiKey.js +14 -1
  4. package/dist/apiKeys/ApiKey.js.map +1 -1
  5. package/dist/apiKeys/middleware.d.ts +12 -0
  6. package/dist/apiKeys/middleware.d.ts.map +1 -1
  7. package/dist/apiKeys/middleware.js +16 -4
  8. package/dist/apiKeys/middleware.js.map +1 -1
  9. package/dist/apiKeys/service.d.ts.map +1 -1
  10. package/dist/apiKeys/service.js +1 -0
  11. package/dist/apiKeys/service.js.map +1 -1
  12. package/dist/apiKeys/types.d.ts +2 -0
  13. package/dist/apiKeys/types.d.ts.map +1 -1
  14. package/dist/audit/AuditEvent.d.ts +82 -0
  15. package/dist/audit/AuditEvent.d.ts.map +1 -0
  16. package/dist/audit/AuditEvent.js +77 -0
  17. package/dist/audit/AuditEvent.js.map +1 -0
  18. package/dist/audit/actor.d.ts +41 -0
  19. package/dist/audit/actor.d.ts.map +1 -0
  20. package/dist/audit/actor.js +39 -0
  21. package/dist/audit/actor.js.map +1 -0
  22. package/dist/audit/context.d.ts +40 -0
  23. package/dist/audit/context.d.ts.map +1 -0
  24. package/dist/audit/context.js +60 -0
  25. package/dist/audit/context.js.map +1 -0
  26. package/dist/audit/index.d.ts +12 -0
  27. package/dist/audit/index.d.ts.map +1 -0
  28. package/dist/audit/index.js +22 -0
  29. package/dist/audit/index.js.map +1 -0
  30. package/dist/audit/plugin.d.ts +23 -0
  31. package/dist/audit/plugin.d.ts.map +1 -0
  32. package/dist/audit/plugin.js +226 -0
  33. package/dist/audit/plugin.js.map +1 -0
  34. package/dist/audit/reads.d.ts +47 -0
  35. package/dist/audit/reads.d.ts.map +1 -0
  36. package/dist/audit/reads.js +94 -0
  37. package/dist/audit/reads.js.map +1 -0
  38. package/dist/audit/service.d.ts +51 -0
  39. package/dist/audit/service.d.ts.map +1 -0
  40. package/dist/audit/service.js +66 -0
  41. package/dist/audit/service.js.map +1 -0
  42. package/dist/index.d.ts +1 -0
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +1 -0
  45. package/dist/index.js.map +1 -1
  46. package/dist/oauth/models.d.ts.map +1 -1
  47. package/dist/oauth/models.js +10 -0
  48. package/dist/oauth/models.js.map +1 -1
  49. package/dist/oauth/tokens.d.ts +15 -0
  50. package/dist/oauth/tokens.d.ts.map +1 -1
  51. package/dist/oauth/tokens.js +5 -1
  52. package/dist/oauth/tokens.js.map +1 -1
  53. package/package.json +1 -1
  54. package/src/apiKeys/ApiKey.test.ts +25 -1
  55. package/src/apiKeys/ApiKey.ts +15 -0
  56. package/src/apiKeys/middleware.test.ts +26 -3
  57. package/src/apiKeys/middleware.ts +32 -5
  58. package/src/apiKeys/service.test.ts +2 -0
  59. package/src/apiKeys/service.ts +1 -0
  60. package/src/apiKeys/types.ts +2 -0
  61. package/src/audit/AuditEvent.ts +123 -0
  62. package/src/audit/actor.test.ts +95 -0
  63. package/src/audit/actor.ts +68 -0
  64. package/src/audit/context.test.ts +91 -0
  65. package/src/audit/context.ts +83 -0
  66. package/src/audit/index.ts +11 -0
  67. package/src/audit/plugin.test.ts +258 -0
  68. package/src/audit/plugin.ts +254 -0
  69. package/src/audit/reads.test.ts +164 -0
  70. package/src/audit/reads.ts +88 -0
  71. package/src/audit/service.test.ts +115 -0
  72. package/src/audit/service.ts +95 -0
  73. package/src/index.ts +1 -0
  74. package/src/middleware/authMiddleware.test.ts +3 -1
  75. package/src/oauth/models.ts +11 -0
  76. package/src/oauth/tokens.test.ts +2 -0
  77. package/src/oauth/tokens.ts +20 -1
@@ -0,0 +1,254 @@
1
+ import type { Model, Schema } from 'mongoose';
2
+ import logger from '../logging/logger';
3
+ import { AuditEvent } from './AuditEvent';
4
+ import { currentAuditContext } from './context';
5
+
6
+ /** Bookkeeping fields nobody wants to read in a change list. */
7
+ const NOISE = new Set(['updatedAt', 'createdAt', '__v', 'createdBy', 'updatedBy']);
8
+
9
+ /**
10
+ * Where a hook stashes what it read before the write.
11
+ *
12
+ * A symbol on the query object, not `query.set()` — that method writes into the
13
+ * *update payload*, so stashing there would persist the before-image into the
14
+ * document it was taken of. Per-query, so concurrent writes cannot read each
15
+ * other's.
16
+ */
17
+ const BEFORE = Symbol('auditBefore');
18
+
19
+ /** Comparable form, so a Date and an ObjectId do not read as changed every time. */
20
+ const plain = (value: unknown): unknown => {
21
+ if (value === null || value === undefined) return value;
22
+ if (value instanceof Date) return value.toISOString();
23
+ if (typeof value === 'object' && 'toHexString' in (value as object)) return String(value);
24
+ return value;
25
+ };
26
+
27
+ const changesBetween = (
28
+ before: Record<string, unknown>,
29
+ after: Record<string, unknown>,
30
+ fields: string[]
31
+ ): Record<string, { from: unknown; to: unknown }> | undefined => {
32
+ const changes: Record<string, { from: unknown; to: unknown }> = {};
33
+
34
+ for (const field of fields) {
35
+ if (NOISE.has(field)) continue;
36
+ const from = plain(before?.[field]);
37
+ const to = plain(after?.[field]);
38
+ if (JSON.stringify(from) !== JSON.stringify(to)) changes[field] = { from, to };
39
+ }
40
+
41
+ return Object.keys(changes).length > 0 ? changes : undefined;
42
+ };
43
+
44
+ /**
45
+ * Never let recording a thing fail the thing.
46
+ *
47
+ * An audit trail is worth having and is not worth refusing a write over: a
48
+ * failure here means someone loses a row of history, and throwing would mean
49
+ * they lose the entry they were logging. So it is fire-and-forget with the
50
+ * failure written to the ordinary log, where an operator sees it.
51
+ */
52
+ const record = (event: Record<string, unknown>): void => {
53
+ void AuditEvent.create(event).catch((error) => {
54
+ logger.warn('Could not record an audit event', {
55
+ error: (error as Error)?.message,
56
+ action: event.action,
57
+ resource: event.resource
58
+ });
59
+ });
60
+ };
61
+
62
+ export interface AuditPluginOptions {
63
+ /** what these documents are called in a trail — 'activity', 'photo' */
64
+ resource: string;
65
+ /** fields never worth recording the content of */
66
+ redact?: string[];
67
+ }
68
+
69
+ /**
70
+ * Attribute and record every write to a collection.
71
+ *
72
+ * Applied to a model rather than called from its controllers, for two reasons.
73
+ * The data layer is the only place that knows which fields actually changed —
74
+ * a controller sees the patch that was requested, not the difference it made,
75
+ * and those differ whenever a field was already the value being set. And a
76
+ * plugin cannot be forgotten: a new endpoint that writes through the model is
77
+ * audited without anyone remembering to add a line.
78
+ *
79
+ * Writes made outside a request — a migration, a backfill script — find no
80
+ * actor and are recorded as nothing, which is honest. A row claiming a script
81
+ * was a person would be worse than no row.
82
+ */
83
+ export function auditPlugin(schema: Schema, options: AuditPluginOptions): void {
84
+ const { resource, redact = [] } = options;
85
+ const hide = new Set(redact);
86
+
87
+ const visible = (doc: Record<string, unknown>): Record<string, unknown> =>
88
+ Object.fromEntries(
89
+ Object.entries(doc)
90
+ .filter(([key]) => !NOISE.has(key) && !hide.has(key))
91
+ .map(([key, value]) => [key, plain(value)])
92
+ );
93
+
94
+ // Who last touched this, on the record itself. Denormalized on purpose: a day
95
+ // view showing "logged by Claude" should not have to query a second
96
+ // collection for every row it renders.
97
+ schema.add({
98
+ createdBy: { type: String },
99
+ updatedBy: { type: String }
100
+ });
101
+
102
+ schema.pre('save', async function () {
103
+ const context = currentAuditContext();
104
+ if (!context) return;
105
+
106
+ const label = context.actor.label;
107
+ if (this.isNew) this.set('createdBy', label);
108
+ this.set('updatedBy', label);
109
+
110
+ // `isNew` is false by the time the post hook runs, so it is carried here.
111
+ // Recording every save as a create was the original mistake: any code that
112
+ // loads a document, changes a field and saves it — revoking an API key,
113
+ // counting a reveal — would appear in the trail as the thing being created
114
+ // again, with a fresh snapshot and no sign of what actually changed.
115
+ this.$locals.auditWasNew = this.isNew;
116
+ if (this.isNew) return;
117
+
118
+ this.$locals.auditPaths = this.modifiedPaths().filter((path) => !NOISE.has(path));
119
+ this.$locals.auditBefore = await (this.constructor as Model<unknown>)
120
+ .findById(this._id)
121
+ .lean();
122
+ });
123
+
124
+ schema.post('save', function (doc) {
125
+ const context = currentAuditContext();
126
+ if (!context) return;
127
+
128
+ const common = {
129
+ at: new Date(),
130
+ service: context.service,
131
+ resource,
132
+ resourceId: String(doc._id),
133
+ actorKind: context.actor.kind,
134
+ userId: context.actor.userId,
135
+ actorLabel: context.actor.label,
136
+ actorCredentialId: context.actor.credentialId,
137
+ tenant: context.actor.tenant
138
+ };
139
+
140
+ if (doc.$locals.auditWasNew) {
141
+ record({ ...common, action: 'create', snapshot: visible(doc.toObject()) });
142
+ return;
143
+ }
144
+
145
+ const before = (doc.$locals.auditBefore ?? {}) as Record<string, unknown>;
146
+ const paths = (doc.$locals.auditPaths ?? []) as string[];
147
+ const changes = changesBetween(before, doc.toObject(), paths);
148
+ if (!changes) return;
149
+
150
+ record({ ...common, action: 'update', changes });
151
+ });
152
+
153
+ // The document as it stood, fetched before the write so there is something to
154
+ // compare against. Held on the query, which is per-operation, so concurrent
155
+ // updates cannot read each other's.
156
+ schema.pre('findOneAndUpdate', async function () {
157
+ const context = currentAuditContext();
158
+ if (!context) return;
159
+
160
+ (this as unknown as Record<symbol, unknown>)[BEFORE] = await this.model
161
+ .findOne(this.getQuery())
162
+ .lean();
163
+
164
+ // This one genuinely belongs in the update: it is a field on the document.
165
+ this.set('updatedBy', context.actor.label);
166
+ });
167
+
168
+ schema.post('findOneAndUpdate', function (doc) {
169
+ const context = currentAuditContext();
170
+ if (!context || !doc) return;
171
+
172
+ const before = ((this as unknown as Record<symbol, unknown>)[BEFORE] ?? {}) as Record<
173
+ string,
174
+ unknown
175
+ >;
176
+ const after = doc.toObject ? doc.toObject() : (doc as Record<string, unknown>);
177
+ // Only the fields the update touched: comparing everything would report a
178
+ // change on any field a concurrent write happened to move.
179
+ const update = (this.getUpdate() ?? {}) as Record<string, unknown>;
180
+ const touched = Object.keys((update.$set as object) ?? update);
181
+ const changes = changesBetween(before, after, touched);
182
+ if (!changes) return;
183
+
184
+ record({
185
+ at: new Date(),
186
+ service: context.service,
187
+ action: 'update',
188
+ resource,
189
+ resourceId: String((doc as { _id: unknown })._id),
190
+ actorKind: context.actor.kind,
191
+ userId: context.actor.userId,
192
+ actorLabel: context.actor.label,
193
+ actorCredentialId: context.actor.credentialId,
194
+ tenant: context.actor.tenant,
195
+ changes
196
+ });
197
+ });
198
+
199
+ schema.post('findOneAndDelete', function (doc) {
200
+ const context = currentAuditContext();
201
+ if (!context || !doc) return;
202
+
203
+ record({
204
+ at: new Date(),
205
+ service: context.service,
206
+ action: 'delete',
207
+ resource,
208
+ resourceId: String((doc as { _id: unknown })._id),
209
+ actorKind: context.actor.kind,
210
+ userId: context.actor.userId,
211
+ actorLabel: context.actor.label,
212
+ actorCredentialId: context.actor.credentialId,
213
+ tenant: context.actor.tenant,
214
+ // The whole document, because a delete is the one action whose record is
215
+ // the only remaining copy of what was there.
216
+ snapshot: visible(doc.toObject ? doc.toObject() : (doc as Record<string, unknown>))
217
+ });
218
+ });
219
+
220
+ // Bulk deletes are read back first, one row each. A day's save deletes the
221
+ // entries it is replacing, and "the day editor removed three things" is
222
+ // exactly the change someone would come here to find.
223
+ schema.pre('deleteMany', async function () {
224
+ if (!currentAuditContext()) return;
225
+ (this as unknown as Record<symbol, unknown>)[BEFORE] = await this.model
226
+ .find(this.getQuery())
227
+ .lean();
228
+ });
229
+
230
+ schema.post('deleteMany', function () {
231
+ const context = currentAuditContext();
232
+ if (!context) return;
233
+
234
+ const doomed = ((this as unknown as Record<symbol, unknown>)[BEFORE] ?? []) as Record<
235
+ string,
236
+ unknown
237
+ >[];
238
+ for (const doc of doomed) {
239
+ record({
240
+ at: new Date(),
241
+ service: context.service,
242
+ action: 'delete',
243
+ resource,
244
+ resourceId: String(doc._id),
245
+ actorKind: context.actor.kind,
246
+ userId: context.actor.userId,
247
+ actorLabel: context.actor.label,
248
+ actorCredentialId: context.actor.credentialId,
249
+ tenant: context.actor.tenant,
250
+ snapshot: visible(doc)
251
+ });
252
+ }
253
+ });
254
+ }
@@ -0,0 +1,164 @@
1
+ import type { Request, Response } from 'express';
2
+
3
+ jest.mock('./AuditEvent', () => ({ AuditEvent: { create: jest.fn().mockResolvedValue({}) } }));
4
+ jest.mock('../logging/logger', () => ({
5
+ __esModule: true,
6
+ default: { warn: jest.fn(), info: jest.fn(), error: jest.fn(), debug: jest.fn() }
7
+ }));
8
+
9
+ import { AuditEvent } from './AuditEvent';
10
+ import { auditReads, countRead, resourceOf } from './reads';
11
+ import { PERSONAL_TENANT } from '../apiKeys/types';
12
+
13
+ const created = AuditEvent.create as jest.Mock;
14
+
15
+ /** A response that runs its finish listeners when told to. */
16
+ function mockRes(statusCode = 200) {
17
+ const listeners: (() => void)[] = [];
18
+ const res = {
19
+ statusCode,
20
+ locals: {} as Record<string, unknown>,
21
+ on: (event: string, fn: () => void) => {
22
+ if (event === 'finish') listeners.push(fn);
23
+ return res;
24
+ },
25
+ finish: () => listeners.forEach((fn) => fn())
26
+ };
27
+ return res as unknown as Response & { finish: () => void };
28
+ }
29
+
30
+ const req = (over: Partial<Request> = {}): Request =>
31
+ ({ method: 'GET', baseUrl: '/api/activities', path: '/', query: {}, ...over }) as unknown as Request;
32
+
33
+ const assistant = {
34
+ keyId: 'client-1',
35
+ scopes: [],
36
+ tenants: { allowed: ['g1'], default: 'g1' },
37
+ actingAs: 'g1',
38
+ kind: 'oauth' as const,
39
+ label: 'Claude',
40
+ credentialId: 'client-1'
41
+ };
42
+
43
+ const user = { id: 'u1', email: 'tom@example.com', name: 'Tom' };
44
+
45
+ beforeEach(() => jest.clearAllMocks());
46
+
47
+ describe('auditReads', () => {
48
+ it('records what an assistant read', async () => {
49
+ const res = mockRes();
50
+ const request = req({ user, apiKey: assistant });
51
+
52
+ auditReads('relationship-service')(request, res, jest.fn());
53
+ countRead(res, 47);
54
+ res.finish();
55
+
56
+ expect(created).toHaveBeenCalledWith(
57
+ expect.objectContaining({
58
+ action: 'read',
59
+ resource: 'activities',
60
+ actorKind: 'assistant',
61
+ actorLabel: 'Claude',
62
+ tenant: 'g1',
63
+ // The size of what left the server is most of what makes a read worth
64
+ // keeping: "read the journal" and "read 47 entries" are different events.
65
+ count: 47
66
+ })
67
+ );
68
+ });
69
+
70
+ it('records nothing for a person browsing their own journal', async () => {
71
+ // One dashboard load hits half a dozen endpoints. Logging those would bury
72
+ // the interesting rows and put a database write behind every page view.
73
+ const res = mockRes();
74
+
75
+ auditReads('relationship-service')(req({ user }), res, jest.fn());
76
+ res.finish();
77
+
78
+ expect(created).not.toHaveBeenCalled();
79
+ });
80
+
81
+ it('records nothing for a write, which the model plugin already covers', () => {
82
+ const res = mockRes();
83
+ auditReads('relationship-service')(req({ user, apiKey: assistant, method: 'POST' }), res, jest.fn());
84
+ res.finish();
85
+
86
+ expect(created).not.toHaveBeenCalled();
87
+ });
88
+
89
+ it('records nothing for a refused request', () => {
90
+ // A 403 is not a read: nothing left the server.
91
+ const res = mockRes(403);
92
+ auditReads('relationship-service')(req({ user, apiKey: assistant }), res, jest.fn());
93
+ res.finish();
94
+
95
+ expect(created).not.toHaveBeenCalled();
96
+ });
97
+
98
+ it('always continues the request, whether or not it recorded anything', () => {
99
+ const next = jest.fn();
100
+ auditReads('relationship-service')(req(), mockRes(), next);
101
+ expect(next).toHaveBeenCalled();
102
+ });
103
+
104
+ it('does not fail the request when the trail cannot be written', async () => {
105
+ created.mockRejectedValueOnce(new Error('audit db down'));
106
+ const res = mockRes();
107
+
108
+ auditReads('relationship-service')(req({ user, apiKey: assistant }), res, jest.fn());
109
+ expect(() => res.finish()).not.toThrow();
110
+ });
111
+
112
+ it('survives a handler that never counted anything', () => {
113
+ const res = mockRes();
114
+ auditReads('relationship-service')(req({ user, apiKey: assistant }), res, jest.fn());
115
+ res.finish();
116
+
117
+ expect(created).toHaveBeenCalledWith(expect.objectContaining({ count: undefined }));
118
+ });
119
+ });
120
+
121
+ describe('resourceOf', () => {
122
+ it.each([
123
+ ['/api/activities/', 'activities'],
124
+ ['/api/activities/68d6d7edfb26c947ecaa057a', 'activities'],
125
+ ['/api/activity-types/', 'activity_types'],
126
+ ['/api/statistics/trends', 'statistics']
127
+ ])('reads %s as %s', (path, expected) => {
128
+ // Deliberately crude: a trail naming the resource is readable, and one
129
+ // naming the full path with ids in it is a request log in disguise.
130
+ expect(resourceOf(path)).toBe(expected);
131
+ });
132
+
133
+ it('names something rather than nothing for an unrecognisable path', () => {
134
+ expect(resourceOf('/')).toBe('unknown');
135
+ });
136
+ });
137
+
138
+ describe('countRead', () => {
139
+ it('cannot break the request it is instrumenting', () => {
140
+ // Express always provides `locals`; a handler called directly does not.
141
+ expect(() => countRead({} as Response, 3)).not.toThrow();
142
+ expect(() => countRead(undefined as unknown as Response, 3)).not.toThrow();
143
+ });
144
+
145
+ it('leaves the count where the middleware will find it', () => {
146
+ const res = { locals: {} } as Response;
147
+ countRead(res, 12);
148
+ expect(res.locals.auditCount).toBe(12);
149
+ });
150
+ });
151
+
152
+ describe('PERSONAL_TENANT', () => {
153
+ it('is what a personal-tenant read is recorded against', () => {
154
+ const res = mockRes();
155
+ auditReads('relationship-service')(
156
+ req({ user, apiKey: { ...assistant, actingAs: PERSONAL_TENANT } }),
157
+ res,
158
+ jest.fn()
159
+ );
160
+ res.finish();
161
+
162
+ expect(created).toHaveBeenCalledWith(expect.objectContaining({ tenant: PERSONAL_TENANT }));
163
+ });
164
+ });
@@ -0,0 +1,88 @@
1
+ import type { NextFunction, Request, RequestHandler, Response } from 'express';
2
+ import { AuditEvent } from './AuditEvent';
3
+ import { actorOf, type Actor } from './actor';
4
+ import logger from '../logging/logger';
5
+
6
+ /**
7
+ * Record what software read, and only what software read.
8
+ *
9
+ * A person browsing their own journal generates nothing here. That is not a
10
+ * shortcut: one dashboard load hits half a dozen endpoints, so logging it would
11
+ * bury the interesting rows under thousands of uninteresting ones and put a
12
+ * database write behind every page view.
13
+ *
14
+ * What an assistant read is the part worth keeping, because it is the part that
15
+ * left the server. A created entry leaves a record behind either way; a read
16
+ * leaves nothing at all, and it is exactly what the privacy page warns about —
17
+ * ask an assistant what you did last month and those entries go to whoever runs
18
+ * it. This is the only place that becomes visible.
19
+ */
20
+ /**
21
+ * Arm the read trail for one request, if it is one worth recording.
22
+ *
23
+ * Nothing is written here — the listener fires when the response finishes, so a
24
+ * refused request is not recorded as a read and the count reflects what was
25
+ * actually sent.
26
+ */
27
+ export function recordRead(req: Request, res: Response, actor: Actor, service: string): void {
28
+ if (req.method !== 'GET' || actor.kind === 'person') return;
29
+
30
+ res.on('finish', () => {
31
+ if (res.statusCode >= 400) return;
32
+
33
+ void AuditEvent.create({
34
+ at: new Date(),
35
+ service,
36
+ action: 'read',
37
+ resource: resourceOf(req.baseUrl + req.path),
38
+ actorKind: actor.kind,
39
+ userId: actor.userId,
40
+ actorLabel: actor.label,
41
+ actorCredentialId: actor.credentialId,
42
+ tenant: actor.tenant,
43
+ count: res.locals.auditCount as number | undefined
44
+ }).catch((error) => {
45
+ logger.warn('Could not record a read', { error: (error as Error)?.message });
46
+ });
47
+ });
48
+ }
49
+
50
+ /**
51
+ * The same thing as standalone middleware, for anything that authenticates its
52
+ * own way rather than through `authenticateAgent`.
53
+ */
54
+ export const auditReads =
55
+ (service: string): RequestHandler =>
56
+ (req: Request, res: Response, next: NextFunction): void => {
57
+ const actor = actorOf(req);
58
+ if (actor) recordRead(req, res, actor, service);
59
+ next();
60
+ };
61
+
62
+ /**
63
+ * What a path was asking for, in one word.
64
+ *
65
+ * The first path segment, which is the collection on every route here —
66
+ * `/api/activities/68d6…` is about activities. Deliberately crude: a trail
67
+ * naming the resource is readable, and one naming the full path with ids in it
68
+ * is a request log wearing an audit log's clothes.
69
+ */
70
+ export function resourceOf(path: string): string {
71
+ const segments = path.split('/').filter((segment) => segment && segment !== 'api');
72
+ return segments[0]?.replace(/-/g, '_') ?? 'unknown';
73
+ }
74
+
75
+ /**
76
+ * How many records a response carried.
77
+ *
78
+ * Set by a handler that knows; absent otherwise. The size of what left the
79
+ * server is most of what makes a read worth recording — "read the journal" and
80
+ * "read four hundred entries of the journal" are different events.
81
+ */
82
+ export const countRead = (res: Response, count: number): void => {
83
+ // Guarded for the same reason recording is fire-and-forget: instrumentation
84
+ // must not be able to fail the thing it instruments. Express always provides
85
+ // `locals`, but a handler called directly does not have to.
86
+ if (!res?.locals) return;
87
+ res.locals.auditCount = count;
88
+ };
@@ -0,0 +1,115 @@
1
+ import mongoose from 'mongoose';
2
+ import { MongoMemoryServer } from 'mongodb-memory-server';
3
+
4
+ import { AuditEvent } from './AuditEvent';
5
+ import { listAuditEvents } from './service';
6
+
7
+ let mongo: MongoMemoryServer;
8
+
9
+ const event = (over: Record<string, unknown> = {}) => ({
10
+ at: new Date('2026-09-05T10:00:00Z'),
11
+ service: 'relationship-service',
12
+ action: 'create' as const,
13
+ resource: 'activity',
14
+ actorKind: 'assistant' as const,
15
+ userId: 'u1',
16
+ actorLabel: 'Claude',
17
+ actorCredentialId: 'client-1',
18
+ tenant: 'g1',
19
+ ...over
20
+ });
21
+
22
+ beforeAll(async () => {
23
+ mongo = await MongoMemoryServer.create();
24
+ await mongoose.connect(mongo.getUri());
25
+ });
26
+
27
+ afterAll(async () => {
28
+ await mongoose.disconnect();
29
+ await mongo.stop();
30
+ });
31
+
32
+ beforeEach(() => AuditEvent.deleteMany({}));
33
+
34
+ describe('listAuditEvents', () => {
35
+ it('returns one account’s trail and never anyone else’s', async () => {
36
+ await AuditEvent.insertMany([event(), event({ userId: 'u2', actorLabel: 'Someone else' })]);
37
+
38
+ const { events, total } = await listAuditEvents({ userId: 'u1' });
39
+
40
+ // Scoped by the query rather than filtered afterwards: the trail carries old
41
+ // values of journal entries, so a wider read must not be one forgotten
42
+ // condition away.
43
+ expect(total).toBe(1);
44
+ expect(events[0].actorLabel).toBe('Claude');
45
+ });
46
+
47
+ it('returns newest first, which is the only order anyone reads a trail in', async () => {
48
+ await AuditEvent.insertMany([
49
+ event({ at: new Date('2026-09-01T10:00:00Z'), resource: 'older' }),
50
+ event({ at: new Date('2026-09-05T10:00:00Z'), resource: 'newer' })
51
+ ]);
52
+
53
+ const { events } = await listAuditEvents({ userId: 'u1' });
54
+ expect(events.map((e) => e.resource)).toEqual(['newer', 'older']);
55
+ });
56
+
57
+ it('narrows to one assistant, which is the question people actually ask', async () => {
58
+ await AuditEvent.insertMany([
59
+ event({ actorCredentialId: 'client-1', actorLabel: 'Claude' }),
60
+ event({ actorCredentialId: 'client-2', actorLabel: 'ChatGPT' })
61
+ ]);
62
+
63
+ const { events } = await listAuditEvents({ userId: 'u1', credentialId: 'client-1' });
64
+
65
+ expect(events).toHaveLength(1);
66
+ expect(events[0].actorLabel).toBe('Claude');
67
+ });
68
+
69
+ it('separates what software did from what a person did', async () => {
70
+ await AuditEvent.insertMany([
71
+ event({ actorKind: 'assistant' }),
72
+ event({ actorKind: 'person', actorLabel: 'Tom' })
73
+ ]);
74
+
75
+ const { events } = await listAuditEvents({ userId: 'u1', actorKind: 'person' });
76
+ expect(events.map((e) => e.actorLabel)).toEqual(['Tom']);
77
+ });
78
+
79
+ it('narrows by action and by date range', async () => {
80
+ await AuditEvent.insertMany([
81
+ event({ action: 'delete', at: new Date('2026-09-01T10:00:00Z') }),
82
+ event({ action: 'delete', at: new Date('2026-09-05T10:00:00Z') }),
83
+ event({ action: 'read', at: new Date('2026-09-05T11:00:00Z') })
84
+ ]);
85
+
86
+ const { events } = await listAuditEvents({
87
+ userId: 'u1',
88
+ action: 'delete',
89
+ from: new Date('2026-09-03T00:00:00Z')
90
+ });
91
+
92
+ expect(events).toHaveLength(1);
93
+ expect(events[0].at).toBe('2026-09-05T10:00:00.000Z');
94
+ });
95
+
96
+ it('hands back the change detail, which is the reason to look', async () => {
97
+ await AuditEvent.insertMany([event({ action: 'update', changes: { hours: { from: 3, to: 2 } } })]);
98
+
99
+ const { events } = await listAuditEvents({ userId: 'u1' });
100
+ expect(events[0].changes).toEqual({ hours: { from: 3, to: 2 } });
101
+ });
102
+
103
+ it('pages, and caps a caller asking for everything at once', async () => {
104
+ await AuditEvent.insertMany(Array.from({ length: 5 }, (_, i) => event({ resource: `r${i}` })));
105
+
106
+ const { events, total, totalPages } = await listAuditEvents({ userId: 'u1', limit: 2, page: 2 });
107
+
108
+ expect(events).toHaveLength(2);
109
+ expect(total).toBe(5);
110
+ expect(totalPages).toBe(3);
111
+
112
+ const capped = await listAuditEvents({ userId: 'u1', limit: 5000 });
113
+ expect(capped.events.length).toBeLessThanOrEqual(200);
114
+ });
115
+ });