@tumbaland/backend-core 1.38.0 → 1.39.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 +51 -0
  15. package/dist/audit/AuditEvent.d.ts.map +1 -0
  16. package/dist/audit/AuditEvent.js +69 -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 +49 -0
  39. package/dist/audit/service.d.ts.map +1 -0
  40. package/dist/audit/service.js +65 -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 +79 -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 +92 -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,83 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import type { NextFunction, Request, RequestHandler, Response } from 'express';
3
+ import { actorOf, type Actor } from './actor';
4
+ import { recordRead } from './reads';
5
+
6
+ /**
7
+ * The actor, reachable from wherever the write actually happens.
8
+ *
9
+ * Attribution has to come from the request and be recorded at the data layer,
10
+ * and those are far apart: a Mongoose hook knows exactly which fields changed
11
+ * and nothing about who asked. Threading an actor through every service and
12
+ * controller signature to bridge that would touch every function between the
13
+ * two and be silently wrong the first time someone forgot.
14
+ *
15
+ * `AsyncLocalStorage` carries it instead, so the hook reads the actor without
16
+ * anything in between knowing it exists. A write outside a request — a script,
17
+ * a migration — simply finds no actor, which is the truth about it.
18
+ */
19
+ export interface AuditContext {
20
+ actor: Actor;
21
+ service: string;
22
+ }
23
+
24
+ const storage = new AsyncLocalStorage<AuditContext>();
25
+
26
+ export const currentAuditContext = (): AuditContext | undefined => storage.getStore();
27
+
28
+ /**
29
+ * Run the rest of a request with its actor in scope.
30
+ *
31
+ * Mounted after authentication, since there is no actor before it.
32
+ */
33
+ export const auditContext =
34
+ (service: string): RequestHandler =>
35
+ (req: Request, _res: Response, next: NextFunction): void => {
36
+ const actor = actorOf(req);
37
+ if (!actor) {
38
+ next();
39
+ return;
40
+ }
41
+
42
+ storage.run({ actor, service }, next);
43
+ };
44
+
45
+ /** Run something with an explicit actor — for jobs and tests. */
46
+ export const withAuditContext = <T>(context: AuditContext, fn: () => T): T =>
47
+ storage.run(context, fn);
48
+
49
+ /**
50
+ * The service this process is, for a trail that spans several of them.
51
+ *
52
+ * `SERVICE_NAME` is already how metrics, health and the internal-service clients
53
+ * identify a process, so auditing uses the same one rather than introducing a
54
+ * second name for the same thing.
55
+ *
56
+ * The fallback to the package name matters more here than elsewhere: the trail
57
+ * is filtered by service in the UI, and in local development nothing sets
58
+ * `SERVICE_NAME`, so every row would land under one meaningless label and the
59
+ * filter would look broken. Every service's package is named after it.
60
+ */
61
+ const serviceName = (): string =>
62
+ process.env.SERVICE_NAME || process.env.npm_package_name || 'unknown-service';
63
+
64
+ /**
65
+ * Put a request's actor in scope, and arm the read trail, for everything after.
66
+ *
67
+ * Called from `authenticateAgent` rather than mounted as its own middleware. A
68
+ * separate mount only works on a router that authenticates with
69
+ * `router.use(...)`; routers that authenticate per route — album's photos,
70
+ * finance's tickers — have no actor yet when a router-level middleware runs, so
71
+ * the context would be silently empty for exactly the endpoints most worth
72
+ * auditing. Doing it where authentication happens makes the two inseparable.
73
+ */
74
+ export function beginAudit(req: Request, res: Response, next: NextFunction): void {
75
+ const actor = actorOf(req);
76
+ if (!actor) {
77
+ next();
78
+ return;
79
+ }
80
+
81
+ recordRead(req, res, actor, serviceName());
82
+ storage.run({ actor, service: serviceName() }, next);
83
+ }
@@ -0,0 +1,11 @@
1
+ export { actorOf, describeActor } from './actor';
2
+ export type { Actor, ActorKind } from './actor';
3
+ export { AuditEvent } from './AuditEvent';
4
+ export type { IAuditEvent } from './AuditEvent';
5
+ export { auditContext, beginAudit, currentAuditContext, withAuditContext } from './context';
6
+ export type { AuditContext } from './context';
7
+ export { auditPlugin } from './plugin';
8
+ export type { AuditPluginOptions } from './plugin';
9
+ export { auditReads, countRead, resourceOf } from './reads';
10
+ export { listAuditEvents } from './service';
11
+ export type { AuditQuery, AuditEntry } from './service';
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Integration tests against a real MongoDB, not mocks.
3
+ *
4
+ * The whole value of this plugin is that it sees what actually changed, and a
5
+ * mocked Mongoose cannot tell you that: hooks that never fire, a `findOneAndUpdate`
6
+ * that returns whatever the mock was told to, a diff computed against a fixture
7
+ * rather than a document. Every bug worth catching here — a hook on the wrong
8
+ * event, an update whose before-image was read after the write, a delete whose
9
+ * snapshot came back empty — passes a mocked test and fails a real one.
10
+ */
11
+ import mongoose, { Schema } from 'mongoose';
12
+ import { MongoMemoryServer } from 'mongodb-memory-server';
13
+
14
+ jest.mock('../logging/logger', () => ({
15
+ __esModule: true,
16
+ default: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn(), http: jest.fn() }
17
+ }));
18
+
19
+ import { AuditEvent } from './AuditEvent';
20
+ import { auditPlugin } from './plugin';
21
+ import { withAuditContext } from './context';
22
+ import type { Actor } from './actor';
23
+
24
+ let mongo: MongoMemoryServer;
25
+
26
+ interface INote {
27
+ title: string;
28
+ hours?: number;
29
+ secret?: string;
30
+ createdBy?: string;
31
+ updatedBy?: string;
32
+ }
33
+
34
+ const NoteSchema = new Schema<INote>(
35
+ { title: String, hours: Number, secret: String },
36
+ { timestamps: true }
37
+ );
38
+ NoteSchema.plugin(auditPlugin, { resource: 'note', redact: ['secret'] });
39
+ const Note = mongoose.model<INote>('AuditTestNote', NoteSchema);
40
+
41
+ const CLAUDE: Actor = {
42
+ kind: 'assistant',
43
+ userId: 'u1',
44
+ label: 'Claude',
45
+ credentialId: 'client-1',
46
+ tenant: 'g1'
47
+ };
48
+
49
+ /**
50
+ * A Mongoose query builds lazily and only runs when it is awaited, so the await
51
+ * has to happen *inside* the context. Awaiting outside it — which reads
52
+ * identically at the call site — starts the query with no actor in scope and
53
+ * silently records nothing.
54
+ */
55
+ const asClaude = <T>(fn: () => Promise<T>): Promise<T> =>
56
+ withAuditContext({ actor: CLAUDE, service: 'test-service' }, async () => await fn());
57
+
58
+ const asPerson = <T>(fn: () => Promise<T>): Promise<T> =>
59
+ withAuditContext(
60
+ { actor: { kind: 'person', userId: 'u1', label: 'Tom', tenant: 'g1' }, service: 'test-service' },
61
+ async () => await fn()
62
+ );
63
+
64
+ /** Recording is fire-and-forget, so a test has to let the write land. */
65
+ const settle = () => new Promise((resolve) => setTimeout(resolve, 60));
66
+
67
+ const events = () => AuditEvent.find().sort({ _id: 1 }).lean();
68
+
69
+ beforeAll(async () => {
70
+ mongo = await MongoMemoryServer.create();
71
+ await mongoose.connect(mongo.getUri());
72
+ });
73
+
74
+ afterAll(async () => {
75
+ await mongoose.disconnect();
76
+ await mongo.stop();
77
+ });
78
+
79
+ beforeEach(async () => {
80
+ await Promise.all([Note.deleteMany({}), AuditEvent.deleteMany({})]);
81
+ });
82
+
83
+ describe('attribution on the record', () => {
84
+ it('stamps who created it and who last touched it', async () => {
85
+ const note = await asClaude(() => new Note({ title: 'Dinner' }).save());
86
+
87
+ expect(note.createdBy).toBe('Claude');
88
+ expect(note.updatedBy).toBe('Claude');
89
+ });
90
+
91
+ it('leaves createdBy alone on a later edit, and moves updatedBy', async () => {
92
+ const note = await asClaude(() => new Note({ title: 'Dinner' }).save());
93
+
94
+ await asPerson(() => Note.findOneAndUpdate({ _id: note._id }, { title: 'Late dinner' }, { new: true }));
95
+
96
+ const after = await Note.findById(note._id);
97
+ expect(after?.createdBy).toBe('Claude');
98
+ expect(after?.updatedBy).toBe('Tom');
99
+ });
100
+
101
+ it('writes nothing at all outside a request', async () => {
102
+ // A backfill script is not a person, and a row claiming it was would be
103
+ // worse than no row.
104
+ const note = await new Note({ title: 'From a migration' }).save();
105
+ await settle();
106
+
107
+ expect(note.createdBy).toBeUndefined();
108
+ expect(await events()).toHaveLength(0);
109
+ });
110
+ });
111
+
112
+ describe('the trail', () => {
113
+ it('records a save of an existing document as an update, not a second create', async () => {
114
+ // Anything that loads a document, changes a field and saves it — revoking
115
+ // an API key, counting a reveal — would otherwise appear as the thing being
116
+ // created again, with a fresh snapshot and no sign of what changed.
117
+ const note = await asClaude(() => new Note({ title: 'Dinner', hours: 3 }).save());
118
+
119
+ await asClaude(async () => {
120
+ const loaded = await Note.findById(note._id);
121
+ loaded!.hours = 2;
122
+ return loaded!.save();
123
+ });
124
+ await settle();
125
+
126
+ const actions = (await events()).map((e) => e.action);
127
+ expect(actions).toEqual(['create', 'update']);
128
+
129
+ const update = (await events()).find((e) => e.action === 'update');
130
+ expect(update?.changes).toEqual({ hours: { from: 3, to: 2 } });
131
+ expect(update?.snapshot).toBeUndefined();
132
+ });
133
+
134
+ it('records nothing when a save changed nothing', async () => {
135
+ const note = await asClaude(() => new Note({ title: 'Dinner' }).save());
136
+
137
+ await asClaude(async () => {
138
+ const loaded = await Note.findById(note._id);
139
+ return loaded!.save();
140
+ });
141
+ await settle();
142
+
143
+ expect((await events()).filter((e) => e.action === 'update')).toHaveLength(0);
144
+ });
145
+
146
+ it('records a create with the document that was written', async () => {
147
+ await asClaude(() => new Note({ title: 'Dinner', hours: 2 }).save());
148
+ await settle();
149
+
150
+ const [event] = await events();
151
+ expect(event).toMatchObject({
152
+ action: 'create',
153
+ resource: 'note',
154
+ actorKind: 'assistant',
155
+ actorLabel: 'Claude',
156
+ actorCredentialId: 'client-1',
157
+ tenant: 'g1',
158
+ service: 'test-service'
159
+ });
160
+ expect(event.snapshot).toMatchObject({ title: 'Dinner', hours: 2 });
161
+ });
162
+
163
+ it('records an update as before and after, field by field', async () => {
164
+ const note = await asClaude(() => new Note({ title: 'Dinner', hours: 3 }).save());
165
+ await asClaude(() => Note.findOneAndUpdate({ _id: note._id }, { hours: 2 }, { new: true }));
166
+ await settle();
167
+
168
+ const update = (await events()).find((e) => e.action === 'update');
169
+ // The point of the whole exercise: "Claude edited an entry" is barely worth
170
+ // storing; "Claude changed hours from 3 to 2" is what someone came to see.
171
+ expect(update?.changes).toEqual({ hours: { from: 3, to: 2 } });
172
+ });
173
+
174
+ it('never persists its own before-image into the document', async () => {
175
+ // The obvious way to stash state on a query is `query.set()`, and that
176
+ // writes into the update payload — so the before-image would be saved onto
177
+ // the very document it was taken of, growing a copy of each record inside
178
+ // itself on every edit.
179
+ const note = await asClaude(() => new Note({ title: 'Dinner', hours: 3 }).save());
180
+ await asClaude(() => Note.findOneAndUpdate({ _id: note._id }, { hours: 2 }, { new: true }));
181
+
182
+ const raw = await mongoose.connection.db!.collection('audittestnotes').findOne({ _id: note._id });
183
+ expect(Object.keys(raw!).filter((key) => key.startsWith('__audit'))).toEqual([]);
184
+ });
185
+
186
+ it('records nothing for an update that changed nothing', async () => {
187
+ const note = await asClaude(() => new Note({ title: 'Dinner', hours: 3 }).save());
188
+ await asClaude(() => Note.findOneAndUpdate({ _id: note._id }, { hours: 3 }, { new: true }));
189
+ await settle();
190
+
191
+ expect((await events()).filter((e) => e.action === 'update')).toHaveLength(0);
192
+ });
193
+
194
+ it('reports only the fields the update touched', async () => {
195
+ const note = await asClaude(() => new Note({ title: 'Dinner', hours: 3 }).save());
196
+ await asClaude(() => Note.findOneAndUpdate({ _id: note._id }, { hours: 2 }, { new: true }));
197
+ await settle();
198
+
199
+ const update = (await events()).find((e) => e.action === 'update');
200
+ expect(Object.keys(update!.changes!)).toEqual(['hours']);
201
+ });
202
+
203
+ it('keeps what a delete removed, since nothing else does', async () => {
204
+ const note = await asClaude(() => new Note({ title: 'Dinner', hours: 2 }).save());
205
+ await asClaude(() => Note.findOneAndDelete({ _id: note._id }));
206
+ await settle();
207
+
208
+ const deleted = (await events()).find((e) => e.action === 'delete');
209
+ expect(deleted?.snapshot).toMatchObject({ title: 'Dinner', hours: 2 });
210
+ expect(deleted?.resourceId).toBe(String(note._id));
211
+ });
212
+
213
+ it('records a bulk delete one row per document', async () => {
214
+ // A day's save deletes the entries it replaces, and "the editor removed
215
+ // three things" is exactly what someone would come here to find.
216
+ await asClaude(async () => {
217
+ await new Note({ title: 'One' }).save();
218
+ await new Note({ title: 'Two' }).save();
219
+ await Note.deleteMany({});
220
+ });
221
+ await settle();
222
+
223
+ const deletes = (await events()).filter((e) => e.action === 'delete');
224
+ expect(deletes).toHaveLength(2);
225
+ expect(deletes.map((d) => (d.snapshot as { title: string }).title).sort()).toEqual(['One', 'Two']);
226
+ });
227
+
228
+ it('leaves redacted fields out of what it keeps', async () => {
229
+ await asClaude(() => new Note({ title: 'Dinner', secret: 'do not store' }).save());
230
+ await settle();
231
+
232
+ const [event] = await events();
233
+ expect(JSON.stringify(event)).not.toContain('do not store');
234
+ });
235
+
236
+ it('omits bookkeeping fields nobody would want to read', async () => {
237
+ await asClaude(() => new Note({ title: 'Dinner' }).save());
238
+ await settle();
239
+
240
+ const [event] = await events();
241
+ expect(Object.keys(event.snapshot!)).not.toContain('updatedAt');
242
+ expect(Object.keys(event.snapshot!)).not.toContain('__v');
243
+ });
244
+ });
245
+
246
+ describe('recording never breaks the thing it records', () => {
247
+ it('still saves the document when the trail cannot be written', async () => {
248
+ const create = jest.spyOn(AuditEvent, 'create').mockRejectedValue(new Error('audit db down'));
249
+
250
+ const note = await asClaude(() => new Note({ title: 'Dinner' }).save());
251
+ await settle();
252
+
253
+ // Losing a row of history is a cost worth paying; losing the entry someone
254
+ // was logging is not.
255
+ expect(await Note.findById(note._id)).not.toBeNull();
256
+ create.mockRestore();
257
+ });
258
+ });
@@ -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
+ }