@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,91 @@
1
+ import type { Request, Response } from 'express';
2
+
3
+ jest.mock('./reads', () => ({ recordRead: jest.fn() }));
4
+
5
+ import { recordRead } from './reads';
6
+ import { beginAudit, currentAuditContext, withAuditContext } from './context';
7
+ import { PERSONAL_TENANT } from '../apiKeys/types';
8
+
9
+ const ORIGINAL_ENV = process.env;
10
+
11
+ const req = (over: Partial<Request> = {}): Request =>
12
+ ({ method: 'GET', query: {}, body: {}, baseUrl: '', path: '/', ...over }) as unknown as Request;
13
+
14
+ const res = () => ({ on: jest.fn(), locals: {} }) as unknown as Response;
15
+ const user = { id: 'u1', email: 'tom@example.com', name: 'Tom' };
16
+
17
+ beforeEach(() => {
18
+ jest.clearAllMocks();
19
+ process.env = { ...ORIGINAL_ENV };
20
+ });
21
+
22
+ afterEach(() => {
23
+ process.env = ORIGINAL_ENV;
24
+ });
25
+
26
+ describe('beginAudit', () => {
27
+ it('puts the actor in scope for everything after it', () => {
28
+ let seen: string | undefined;
29
+ beginAudit(req({ user }), res(), () => {
30
+ seen = currentAuditContext()?.actor.label;
31
+ });
32
+
33
+ expect(seen).toBe('Tom');
34
+ });
35
+
36
+ it('leaves no context behind once the request is done', () => {
37
+ beginAudit(req({ user }), res(), () => undefined);
38
+ // A leaked context would attribute the next request — or a background job —
39
+ // to whoever happened to come before it.
40
+ expect(currentAuditContext()).toBeUndefined();
41
+ });
42
+
43
+ it('continues without a context when nobody is authenticated', () => {
44
+ const next = jest.fn();
45
+ beginAudit(req(), res(), next);
46
+
47
+ expect(next).toHaveBeenCalled();
48
+ expect(recordRead).not.toHaveBeenCalled();
49
+ });
50
+
51
+ it('arms the read trail for the same request', () => {
52
+ beginAudit(req({ user }), res(), () => undefined);
53
+ expect(recordRead).toHaveBeenCalled();
54
+ });
55
+ });
56
+
57
+ describe('naming the service', () => {
58
+ it('uses SERVICE_NAME, as metrics and health already do', () => {
59
+ process.env.SERVICE_NAME = 'album-service';
60
+ let seen: string | undefined;
61
+
62
+ beginAudit(req({ user }), res(), () => {
63
+ seen = currentAuditContext()?.service;
64
+ });
65
+
66
+ expect(seen).toBe('album-service');
67
+ });
68
+
69
+ it('falls back to the package name, so local development is still labelled', () => {
70
+ // Nothing sets SERVICE_NAME in dev, and every row landing under one
71
+ // meaningless label makes the UI's area filter look broken.
72
+ delete process.env.SERVICE_NAME;
73
+ process.env.npm_package_name = 'finance-service';
74
+ let seen: string | undefined;
75
+
76
+ beginAudit(req({ user }), res(), () => {
77
+ seen = currentAuditContext()?.service;
78
+ });
79
+
80
+ expect(seen).toBe('finance-service');
81
+ });
82
+ });
83
+
84
+ describe('withAuditContext', () => {
85
+ it('runs work as an explicit actor, for jobs and tests', () => {
86
+ const actor = { kind: 'person' as const, userId: 'u1', label: 'Tom', tenant: PERSONAL_TENANT };
87
+ const seen = withAuditContext({ actor, service: 'test' }, () => currentAuditContext());
88
+
89
+ expect(seen?.actor.label).toBe('Tom');
90
+ });
91
+ });
@@ -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, ToolCall } 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
+ });