@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,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
+ });
@@ -0,0 +1,92 @@
1
+ import { AuditEvent, type IAuditEvent } from './AuditEvent';
2
+ import type { ActorKind } from './actor';
3
+
4
+ export interface AuditQuery {
5
+ userId: string;
6
+ /** narrow to one actor — a key's id or an OAuth client id */
7
+ credentialId?: string;
8
+ actorKind?: ActorKind;
9
+ action?: IAuditEvent['action'];
10
+ resource?: string;
11
+ /** narrow to one area — 'relationship-service', 'album-service' */
12
+ service?: string;
13
+ from?: Date;
14
+ to?: Date;
15
+ limit?: number;
16
+ page?: number;
17
+ }
18
+
19
+ export interface AuditEntry {
20
+ id: string;
21
+ at: string;
22
+ service: string;
23
+ action: IAuditEvent['action'];
24
+ resource: string;
25
+ resourceId?: string;
26
+ actorKind: ActorKind;
27
+ actorLabel: string;
28
+ actorCredentialId?: string;
29
+ tenant: string;
30
+ changes?: Record<string, { from: unknown; to: unknown }>;
31
+ snapshot?: Record<string, unknown>;
32
+ count?: number;
33
+ }
34
+
35
+ const toEntry = (event: IAuditEvent): AuditEntry => ({
36
+ id: String(event._id),
37
+ at: event.at.toISOString(),
38
+ service: event.service,
39
+ action: event.action,
40
+ resource: event.resource,
41
+ resourceId: event.resourceId,
42
+ actorKind: event.actorKind,
43
+ actorLabel: event.actorLabel,
44
+ actorCredentialId: event.actorCredentialId,
45
+ tenant: event.tenant,
46
+ changes: event.changes,
47
+ snapshot: event.snapshot,
48
+ count: event.count
49
+ });
50
+
51
+ /**
52
+ * One account's trail, newest first.
53
+ *
54
+ * Always scoped to `userId` by the query rather than filtered afterwards, so
55
+ * there is no path where a wider read is one forgotten condition away. The trail
56
+ * carries old values of journal entries, which makes it as sensitive as the
57
+ * journal itself.
58
+ */
59
+ export const listAuditEvents = async (
60
+ query: AuditQuery
61
+ ): Promise<{ events: AuditEntry[]; total: number; page: number; totalPages: number }> => {
62
+ const limit = Math.min(query.limit ?? 50, 200);
63
+ const page = Math.max(query.page ?? 1, 1);
64
+
65
+ const filter: Record<string, unknown> = { userId: query.userId };
66
+ if (query.credentialId) filter.actorCredentialId = query.credentialId;
67
+ if (query.actorKind) filter.actorKind = query.actorKind;
68
+ if (query.action) filter.action = query.action;
69
+ if (query.resource) filter.resource = query.resource;
70
+ if (query.service) filter.service = query.service;
71
+ if (query.from || query.to) {
72
+ const at: Record<string, Date> = {};
73
+ if (query.from) at.$gte = query.from;
74
+ if (query.to) at.$lte = query.to;
75
+ filter.at = at;
76
+ }
77
+
78
+ const [events, total] = await Promise.all([
79
+ AuditEvent.find(filter)
80
+ .sort({ at: -1 })
81
+ .skip((page - 1) * limit)
82
+ .limit(limit),
83
+ AuditEvent.countDocuments(filter)
84
+ ]);
85
+
86
+ return {
87
+ events: events.map(toEntry),
88
+ total,
89
+ page,
90
+ totalPages: Math.ceil(total / limit)
91
+ };
92
+ };
package/src/index.ts CHANGED
@@ -35,6 +35,7 @@ export * from './apiKeys';
35
35
 
36
36
  // OAuth 2.1 authorization server, for assistants connecting over MCP
37
37
  export * from './oauth';
38
+ export * from './audit';
38
39
 
39
40
  // Middleware
40
41
  export { authenticateToken, optionalAuth } from './middleware/authMiddleware';
@@ -195,7 +195,9 @@ describe('OAuth access tokens are not sessions', () => {
195
195
  scopes: ['relationship:read'],
196
196
  tenants: [PERSONAL_TENANT],
197
197
  defaultTenant: PERSONAL_TENANT,
198
- tenantNames: {}
198
+ tenantNames: {},
199
+ clientId: 'client-1',
200
+ clientName: 'Claude'
199
201
  }).accessToken;
200
202
 
201
203
  it('refuses one on a session-only route', () => {
@@ -1,4 +1,5 @@
1
1
  import mongoose, { Document, Schema } from 'mongoose';
2
+ import { auditPlugin } from '../audit/plugin';
2
3
  import { PERSONAL_TENANT, type Tenant } from '../apiKeys/types';
3
4
 
4
5
  /**
@@ -148,6 +149,16 @@ const RefreshTokenSchema = new Schema<IRefreshToken>(
148
149
 
149
150
  RefreshTokenSchema.index({ userId: 1, createdAt: -1 });
150
151
 
152
+ /**
153
+ * A grant appearing and being revoked is the shape of "who connected Claude,
154
+ * and when did it stop" — the question the connections page answers for the
155
+ * present and this answers for the past.
156
+ *
157
+ * `tokenHash` is redacted: it is the only thing standing between a copy of this
158
+ * collection and a working refresh token.
159
+ */
160
+ RefreshTokenSchema.plugin(auditPlugin, { resource: 'connection', redact: ['tokenHash'] });
161
+
151
162
  export const RefreshToken = mongoose.models.RefreshToken
152
163
  ? (mongoose.models.RefreshToken as mongoose.Model<IRefreshToken>)
153
164
  : mongoose.model<IRefreshToken>('RefreshToken', RefreshTokenSchema);
@@ -18,6 +18,8 @@ const mint = (over: Partial<Parameters<typeof mintAccessToken>[0]> = {}) =>
18
18
  tenants: [PERSONAL_TENANT],
19
19
  defaultTenant: PERSONAL_TENANT,
20
20
  tenantNames: { [PERSONAL_TENANT]: 'My own data' },
21
+ clientId: 'client-1',
22
+ clientName: 'Claude',
21
23
  ...over
22
24
  });
23
25
 
@@ -49,6 +49,17 @@ export interface AccessTokenClaims {
49
49
  tenantNames: Record<Tenant, string>;
50
50
  /** the single tenant an older token was pinned to; read by `readTenants` */
51
51
  groupId?: string | null;
52
+ /**
53
+ * Which app this token was issued to.
54
+ *
55
+ * `jti` identifies the token and rotates every hour, so it cannot stand in for
56
+ * the connection — an audit trail built on it would show a different actor
57
+ * each time the assistant refreshed. The client id is stable for the life of
58
+ * the grant, and the name is snapshotted alongside it for the same reason the
59
+ * tenant names are: nothing downstream can look it up.
60
+ */
61
+ clientId: string;
62
+ clientName: string;
52
63
  email: string;
53
64
  name: string;
54
65
  typ: typeof ACCESS_TOKEN_TYPE;
@@ -68,6 +79,8 @@ export interface MintAccessTokenInput {
68
79
  tenants: Tenant[];
69
80
  defaultTenant: Tenant;
70
81
  tenantNames: Record<Tenant, string>;
82
+ clientId: string;
83
+ clientName: string;
71
84
  }
72
85
 
73
86
  export interface MintedAccessToken {
@@ -88,6 +101,8 @@ export const mintAccessToken = (input: MintAccessTokenInput): MintedAccessToken
88
101
  tenants: input.tenants,
89
102
  defaultTenant: input.defaultTenant,
90
103
  tenantNames: input.tenantNames,
104
+ clientId: input.clientId,
105
+ clientName: input.clientName,
91
106
  email: input.email,
92
107
  name: input.name,
93
108
  typ: ACCESS_TOKEN_TYPE,
@@ -109,6 +124,8 @@ export interface AccessTokenVerification {
109
124
  scopes?: ApiKeyScope[];
110
125
  tenants?: ApiKeyTenant;
111
126
  tenantNames?: Record<Tenant, string>;
127
+ clientId?: string;
128
+ clientName?: string;
112
129
  }
113
130
 
114
131
  /**
@@ -144,7 +161,9 @@ export const verifyAccessToken = (
144
161
  name: claims.name ?? '',
145
162
  scopes: (claims.scope ?? '').split(' ').filter(isApiKeyScope),
146
163
  tenants: readTenants(claims),
147
- tenantNames: claims.tenantNames ?? {}
164
+ tenantNames: claims.tenantNames ?? {},
165
+ clientId: claims.clientId,
166
+ clientName: claims.clientName
148
167
  };
149
168
  };
150
169