@tumbaland/backend-core 1.37.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 (92) hide show
  1. package/dist/apiKeys/ApiKey.d.ts +23 -3
  2. package/dist/apiKeys/ApiKey.d.ts.map +1 -1
  3. package/dist/apiKeys/ApiKey.js +19 -2
  4. package/dist/apiKeys/ApiKey.js.map +1 -1
  5. package/dist/apiKeys/index.d.ts +2 -2
  6. package/dist/apiKeys/index.d.ts.map +1 -1
  7. package/dist/apiKeys/index.js +5 -1
  8. package/dist/apiKeys/index.js.map +1 -1
  9. package/dist/apiKeys/middleware.d.ts +17 -3
  10. package/dist/apiKeys/middleware.d.ts.map +1 -1
  11. package/dist/apiKeys/middleware.js +71 -34
  12. package/dist/apiKeys/middleware.js.map +1 -1
  13. package/dist/apiKeys/service.d.ts +5 -3
  14. package/dist/apiKeys/service.d.ts.map +1 -1
  15. package/dist/apiKeys/service.js +11 -3
  16. package/dist/apiKeys/service.js.map +1 -1
  17. package/dist/apiKeys/types.d.ts +43 -8
  18. package/dist/apiKeys/types.d.ts.map +1 -1
  19. package/dist/apiKeys/types.js +35 -1
  20. package/dist/apiKeys/types.js.map +1 -1
  21. package/dist/audit/AuditEvent.d.ts +51 -0
  22. package/dist/audit/AuditEvent.d.ts.map +1 -0
  23. package/dist/audit/AuditEvent.js +69 -0
  24. package/dist/audit/AuditEvent.js.map +1 -0
  25. package/dist/audit/actor.d.ts +41 -0
  26. package/dist/audit/actor.d.ts.map +1 -0
  27. package/dist/audit/actor.js +39 -0
  28. package/dist/audit/actor.js.map +1 -0
  29. package/dist/audit/context.d.ts +40 -0
  30. package/dist/audit/context.d.ts.map +1 -0
  31. package/dist/audit/context.js +60 -0
  32. package/dist/audit/context.js.map +1 -0
  33. package/dist/audit/index.d.ts +12 -0
  34. package/dist/audit/index.d.ts.map +1 -0
  35. package/dist/audit/index.js +22 -0
  36. package/dist/audit/index.js.map +1 -0
  37. package/dist/audit/plugin.d.ts +23 -0
  38. package/dist/audit/plugin.d.ts.map +1 -0
  39. package/dist/audit/plugin.js +226 -0
  40. package/dist/audit/plugin.js.map +1 -0
  41. package/dist/audit/reads.d.ts +47 -0
  42. package/dist/audit/reads.d.ts.map +1 -0
  43. package/dist/audit/reads.js +94 -0
  44. package/dist/audit/reads.js.map +1 -0
  45. package/dist/audit/service.d.ts +49 -0
  46. package/dist/audit/service.d.ts.map +1 -0
  47. package/dist/audit/service.js +65 -0
  48. package/dist/audit/service.js.map +1 -0
  49. package/dist/index.d.ts +1 -0
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +1 -0
  52. package/dist/index.js.map +1 -1
  53. package/dist/oauth/models.d.ts +13 -2
  54. package/dist/oauth/models.d.ts.map +1 -1
  55. package/dist/oauth/models.js +19 -0
  56. package/dist/oauth/models.js.map +1 -1
  57. package/dist/oauth/service.d.ts +15 -8
  58. package/dist/oauth/service.d.ts.map +1 -1
  59. package/dist/oauth/service.js +10 -3
  60. package/dist/oauth/service.js.map +1 -1
  61. package/dist/oauth/tokens.d.ts +37 -5
  62. package/dist/oauth/tokens.d.ts.map +1 -1
  63. package/dist/oauth/tokens.js +9 -2
  64. package/dist/oauth/tokens.js.map +1 -1
  65. package/package.json +1 -1
  66. package/src/apiKeys/ApiKey.test.ts +25 -1
  67. package/src/apiKeys/ApiKey.ts +31 -4
  68. package/src/apiKeys/index.ts +16 -2
  69. package/src/apiKeys/middleware.test.ts +126 -20
  70. package/src/apiKeys/middleware.ts +99 -39
  71. package/src/apiKeys/service.test.ts +58 -9
  72. package/src/apiKeys/service.ts +25 -6
  73. package/src/apiKeys/types.ts +63 -8
  74. package/src/audit/AuditEvent.ts +79 -0
  75. package/src/audit/actor.test.ts +95 -0
  76. package/src/audit/actor.ts +68 -0
  77. package/src/audit/context.test.ts +91 -0
  78. package/src/audit/context.ts +83 -0
  79. package/src/audit/index.ts +11 -0
  80. package/src/audit/plugin.test.ts +258 -0
  81. package/src/audit/plugin.ts +254 -0
  82. package/src/audit/reads.test.ts +164 -0
  83. package/src/audit/reads.ts +88 -0
  84. package/src/audit/service.test.ts +115 -0
  85. package/src/audit/service.ts +92 -0
  86. package/src/index.ts +1 -0
  87. package/src/middleware/authMiddleware.test.ts +6 -1
  88. package/src/oauth/models.ts +32 -2
  89. package/src/oauth/service.test.ts +58 -6
  90. package/src/oauth/service.ts +25 -7
  91. package/src/oauth/tokens.test.ts +22 -5
  92. package/src/oauth/tokens.ts +52 -7
@@ -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';
@@ -1,6 +1,7 @@
1
1
  import { Request, Response } from 'express';
2
2
  import jwt from 'jsonwebtoken';
3
3
  import { authenticateToken, optionalAuth } from './authMiddleware';
4
+ import { PERSONAL_TENANT } from '../apiKeys/types';
4
5
  import { mintAccessToken } from '../oauth/tokens';
5
6
  import { UserPayload } from '../types/auth';
6
7
 
@@ -192,7 +193,11 @@ describe('OAuth access tokens are not sessions', () => {
192
193
  resource: 'https://mcp.example.com',
193
194
  issuer: 'https://auth.example.com',
194
195
  scopes: ['relationship:read'],
195
- groupId: null
196
+ tenants: [PERSONAL_TENANT],
197
+ defaultTenant: PERSONAL_TENANT,
198
+ tenantNames: {},
199
+ clientId: 'client-1',
200
+ clientName: 'Claude'
196
201
  }).accessToken;
197
202
 
198
203
  it('refuses one on a session-only route', () => {
@@ -1,4 +1,6 @@
1
1
  import mongoose, { Document, Schema } from 'mongoose';
2
+ import { auditPlugin } from '../audit/plugin';
3
+ import { PERSONAL_TENANT, type Tenant } from '../apiKeys/types';
2
4
 
3
5
  /**
4
6
  * A client registered through RFC 7591 Dynamic Client Registration.
@@ -46,7 +48,13 @@ export interface IAuthorizationCode extends Document {
46
48
  userName: string;
47
49
  redirectUri: string;
48
50
  scopes: string[];
49
- groupId: string | null;
51
+ /** every tenant the user approved, and the one chosen as the default */
52
+ tenants: Tenant[];
53
+ defaultTenant: Tenant;
54
+ /** what each approved tenant is called, snapshotted at consent */
55
+ tenantNames: Record<Tenant, string>;
56
+ /** the single tenant an older grant was pinned to; read by `readTenants` */
57
+ groupId?: string | null;
50
58
  /** RFC 8707: the MCP server this will be minted for */
51
59
  resource: string;
52
60
  /** PKCE, always S256 — the spec does not allow `plain` */
@@ -66,6 +74,10 @@ const AuthorizationCodeSchema = new Schema<IAuthorizationCode>(
66
74
  userName: { type: String, default: '' },
67
75
  redirectUri: { type: String, required: true },
68
76
  scopes: { type: [String], default: [] },
77
+ tenants: { type: [String], default: () => [PERSONAL_TENANT] },
78
+ defaultTenant: { type: String, default: PERSONAL_TENANT },
79
+ tenantNames: { type: Schema.Types.Mixed, default: () => ({}) },
80
+ // Written by the single-tenant model this replaced; read-only now.
69
81
  groupId: { type: String, default: null },
70
82
  resource: { type: String, required: true },
71
83
  codeChallenge: { type: String, required: true },
@@ -96,7 +108,11 @@ export interface IRefreshToken extends Document {
96
108
  clientId: string;
97
109
  userId: string;
98
110
  scopes: string[];
99
- groupId: string | null;
111
+ tenants: Tenant[];
112
+ defaultTenant: Tenant;
113
+ tenantNames: Record<Tenant, string>;
114
+ /** the single tenant an older grant was pinned to; read by `readTenants` */
115
+ groupId?: string | null;
100
116
  resource: string;
101
117
  /**
102
118
  * When the user actually connected this assistant.
@@ -118,6 +134,10 @@ const RefreshTokenSchema = new Schema<IRefreshToken>(
118
134
  clientId: { type: String, required: true },
119
135
  userId: { type: String, required: true, index: true },
120
136
  scopes: { type: [String], default: [] },
137
+ tenants: { type: [String], default: () => [PERSONAL_TENANT] },
138
+ defaultTenant: { type: String, default: PERSONAL_TENANT },
139
+ tenantNames: { type: Schema.Types.Mixed, default: () => ({}) },
140
+ // Written by the single-tenant model this replaced; read-only now.
121
141
  groupId: { type: String, default: null },
122
142
  resource: { type: String, required: true },
123
143
  grantedAt: { type: Date, default: Date.now },
@@ -129,6 +149,16 @@ const RefreshTokenSchema = new Schema<IRefreshToken>(
129
149
 
130
150
  RefreshTokenSchema.index({ userId: 1, createdAt: -1 });
131
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
+
132
162
  export const RefreshToken = mongoose.models.RefreshToken
133
163
  ? (mongoose.models.RefreshToken as mongoose.Model<IRefreshToken>)
134
164
  : mongoose.model<IRefreshToken>('RefreshToken', RefreshTokenSchema);
@@ -1,4 +1,5 @@
1
1
  import { createHash } from 'crypto';
2
+ import { PERSONAL_TENANT } from '../apiKeys/types';
2
3
 
3
4
  jest.mock('./models', () => ({
4
5
  OAuthClient: { create: jest.fn(), findOne: jest.fn(), find: jest.fn() },
@@ -122,13 +123,21 @@ describe('issueAuthorizationCode', () => {
122
123
  userName: 'Tester',
123
124
  redirectUri: 'https://claude.ai/callback',
124
125
  scopes: ['relationship:read'],
125
- groupId: 'g1',
126
+ tenants: ['g1'],
127
+ defaultTenant: 'g1',
128
+ tenantNames: { g1: 'Irja & Tom' },
126
129
  resource: 'https://mcp.tumbaland.eu',
127
130
  codeChallenge: CHALLENGE
128
131
  });
129
132
 
130
133
  const [doc] = mockedCodeCreate.mock.calls[0];
131
- expect(doc).toMatchObject({ userId: 'u1', groupId: 'g1', scopes: ['relationship:read'] });
134
+ expect(doc).toMatchObject({
135
+ userId: 'u1',
136
+ tenants: ['g1'],
137
+ defaultTenant: 'g1',
138
+ tenantNames: { g1: 'Irja & Tom' },
139
+ scopes: ['relationship:read']
140
+ });
132
141
  expect(doc.expiresAt.getTime() - before).toBeLessThanOrEqual(60_000);
133
142
  });
134
143
  });
@@ -224,7 +233,9 @@ describe('issueRefreshToken', () => {
224
233
  clientId: 'client-1',
225
234
  userId: 'u1',
226
235
  scopes: ['relationship:read'],
227
- groupId: null,
236
+ tenants: [PERSONAL_TENANT],
237
+ defaultTenant: PERSONAL_TENANT,
238
+ tenantNames: {},
228
239
  resource: 'https://mcp.tumbaland.eu'
229
240
  });
230
241
 
@@ -311,7 +322,9 @@ describe('refresh tokens', () => {
311
322
  clientId: 'client-1',
312
323
  userId: 'u1',
313
324
  scopes: ['relationship:read'],
314
- groupId: null,
325
+ tenants: [PERSONAL_TENANT],
326
+ defaultTenant: PERSONAL_TENANT,
327
+ tenantNames: {},
315
328
  resource: 'https://mcp.tumbaland.eu'
316
329
  });
317
330
 
@@ -324,7 +337,9 @@ describe('refresh tokens', () => {
324
337
  mockedRefreshFindOne.mockResolvedValue({
325
338
  userId: 'u1',
326
339
  scopes: ['relationship:read'],
327
- groupId: 'g1',
340
+ tenants: [PERSONAL_TENANT, 'g1'],
341
+ defaultTenant: 'g1',
342
+ tenantNames: { g1: 'Irja & Tom' },
328
343
  resource: 'https://mcp.tumbaland.eu',
329
344
  grantedAt: new Date(),
330
345
  save: jest.fn()
@@ -333,7 +348,44 @@ describe('refresh tokens', () => {
333
348
  await expect(redeemRefreshToken('tok', 'client-1')).resolves.toMatchObject({
334
349
  ok: true,
335
350
  userId: 'u1',
336
- groupId: 'g1'
351
+ tenants: { allowed: [PERSONAL_TENANT, 'g1'], default: 'g1' }
352
+ });
353
+ });
354
+
355
+ it('carries the tenants through rotation rather than re-deriving them', async () => {
356
+ mockedRefreshFindOne.mockResolvedValue({
357
+ userId: 'u1',
358
+ scopes: ['relationship:read'],
359
+ tenants: [PERSONAL_TENANT, 'g1'],
360
+ defaultTenant: 'g1',
361
+ tenantNames: { g1: 'Irja & Tom' },
362
+ resource: 'https://mcp.tumbaland.eu',
363
+ grantedAt: new Date(),
364
+ save: jest.fn()
365
+ });
366
+
367
+ await redeemRefreshToken('tok', 'client-1');
368
+
369
+ // Rotation reissues the same grant; re-deriving would quietly narrow a
370
+ // connection every time it refreshed.
371
+ const [doc] = mockedRefreshCreate.mock.calls.at(-1)!;
372
+ expect(doc.tenants).toEqual([PERSONAL_TENANT, 'g1']);
373
+ expect(doc.defaultTenant).toBe('g1');
374
+ expect(doc.tenantNames).toEqual({ g1: 'Irja & Tom' });
375
+ });
376
+
377
+ it('reads a grant issued before tenants were a set', async () => {
378
+ mockedRefreshFindOne.mockResolvedValue({
379
+ userId: 'u1',
380
+ scopes: ['relationship:read'],
381
+ groupId: 'g1',
382
+ resource: 'https://mcp.tumbaland.eu',
383
+ grantedAt: new Date(),
384
+ save: jest.fn()
385
+ });
386
+
387
+ await expect(redeemRefreshToken('tok', 'client-1')).resolves.toMatchObject({
388
+ tenants: { allowed: ['g1'], default: 'g1' }
337
389
  });
338
390
  });
339
391
 
@@ -1,6 +1,11 @@
1
1
  import { createHash, randomBytes, timingSafeEqual } from 'crypto';
2
2
  import { AuthorizationCode, IAuthorizationCode, OAuthClient, RefreshToken } from './models';
3
- import type { ApiKeyScope } from '../apiKeys/types';
3
+ import {
4
+ readTenants,
5
+ type ApiKeyScope,
6
+ type ApiKeyTenant,
7
+ type Tenant
8
+ } from '../apiKeys/types';
4
9
 
5
10
  /** How long a user has between approving and the client exchanging the code. */
6
11
  const CODE_TTL_MS = 60 * 1000;
@@ -84,7 +89,9 @@ export interface IssueCodeInput {
84
89
  userName: string;
85
90
  redirectUri: string;
86
91
  scopes: ApiKeyScope[];
87
- groupId: string | null;
92
+ tenants: Tenant[];
93
+ defaultTenant: Tenant;
94
+ tenantNames: Record<Tenant, string>;
88
95
  resource: string;
89
96
  codeChallenge: string;
90
97
  }
@@ -166,7 +173,9 @@ export const issueRefreshToken = async (input: {
166
173
  clientId: string;
167
174
  userId: string;
168
175
  scopes: ApiKeyScope[];
169
- groupId: string | null;
176
+ tenants: Tenant[];
177
+ defaultTenant: Tenant;
178
+ tenantNames: Record<Tenant, string>;
170
179
  resource: string;
171
180
  }): Promise<IssuedRefreshToken> => {
172
181
  await RefreshToken.updateMany(
@@ -185,7 +194,8 @@ export interface RefreshRedemption {
185
194
  reused?: boolean;
186
195
  userId?: string;
187
196
  scopes?: ApiKeyScope[];
188
- groupId?: string | null;
197
+ tenants?: ApiKeyTenant;
198
+ tenantNames?: Record<Tenant, string>;
189
199
  resource?: string;
190
200
  /** the replacement the client must store; the presented one is now dead */
191
201
  rotatedToken?: string;
@@ -233,7 +243,11 @@ export const redeemRefreshToken = async (
233
243
  clientId,
234
244
  userId: record.userId,
235
245
  scopes: record.scopes,
236
- groupId: record.groupId ?? null,
246
+ // Carried, not reset: rotation reissues the same grant, and re-deriving the
247
+ // tenants would quietly narrow a connection every time it refreshed.
248
+ tenants: record.tenants,
249
+ defaultTenant: record.defaultTenant,
250
+ tenantNames: record.tenantNames,
237
251
  resource: record.resource,
238
252
  // Carried, not reset: this is still the grant the user approved.
239
253
  grantedAt: record.grantedAt ?? record.createdAt
@@ -243,7 +257,8 @@ export const redeemRefreshToken = async (
243
257
  ok: true,
244
258
  userId: record.userId,
245
259
  scopes: record.scopes as ApiKeyScope[],
246
- groupId: record.groupId ?? null,
260
+ tenants: readTenants(record),
261
+ tenantNames: record.tenantNames ?? {},
247
262
  resource: record.resource,
248
263
  rotatedToken: rotated
249
264
  };
@@ -271,7 +286,10 @@ export const listConnections = async (userId: string) => {
271
286
  clientId: token.clientId,
272
287
  clientName: nameById.get(token.clientId) ?? 'Unknown app',
273
288
  scopes: token.scopes,
274
- groupId: token.groupId ?? null,
289
+ ...(({ allowed, default: fallback }) => ({ tenants: allowed, defaultTenant: fallback }))(
290
+ readTenants(token)
291
+ ),
292
+ tenantNames: token.tenantNames ?? {},
275
293
  createdAt: (token.grantedAt ?? token.createdAt).toISOString(),
276
294
  // Set when the refresh token is exchanged, not when a tool runs — access
277
295
  // tokens are validated statelessly, so the server never sees ordinary use.
@@ -1,6 +1,7 @@
1
1
  const ORIGINAL_ENV = process.env;
2
2
 
3
3
  import jwt from 'jsonwebtoken';
4
+ import { PERSONAL_TENANT } from '../apiKeys/types';
4
5
  import { mintAccessToken, verifyAccessToken, parseScopes } from './tokens';
5
6
 
6
7
  const RESOURCE = 'https://mcp.tumbaland.eu';
@@ -14,7 +15,11 @@ const mint = (over: Partial<Parameters<typeof mintAccessToken>[0]> = {}) =>
14
15
  resource: RESOURCE,
15
16
  issuer: ISSUER,
16
17
  scopes: ['relationship:read', 'relationship:write'],
17
- groupId: null,
18
+ tenants: [PERSONAL_TENANT],
19
+ defaultTenant: PERSONAL_TENANT,
20
+ tenantNames: { [PERSONAL_TENANT]: 'My own data' },
21
+ clientId: 'client-1',
22
+ clientName: 'Claude',
18
23
  ...over
19
24
  });
20
25
 
@@ -38,9 +43,21 @@ describe('mintAccessToken', () => {
38
43
  expect(expiresIn).toBe(3600);
39
44
  });
40
45
 
41
- it('carries the tenant pin, so a token cannot wander between groups', () => {
42
- const claims = jwt.decode(mint({ groupId: 'g1' }).accessToken) as Record<string, unknown>;
43
- expect(claims.groupId).toBe('g1');
46
+ it('carries the granted tenants, so a token cannot wander outside them', () => {
47
+ const claims = jwt.decode(
48
+ mint({ tenants: [PERSONAL_TENANT, 'g1'], defaultTenant: 'g1' }).accessToken
49
+ ) as Record<string, unknown>;
50
+
51
+ expect(claims.tenants).toEqual([PERSONAL_TENANT, 'g1']);
52
+ expect(claims.defaultTenant).toBe('g1');
53
+ });
54
+
55
+ it('carries what each tenant is called, since nothing downstream can look it up', () => {
56
+ const claims = jwt.decode(
57
+ mint({ tenants: ['g1'], defaultTenant: 'g1', tenantNames: { g1: 'Irja & Tom' } }).accessToken
58
+ ) as Record<string, unknown>;
59
+
60
+ expect(claims.tenantNames).toEqual({ g1: 'Irja & Tom' });
44
61
  });
45
62
 
46
63
  it('gives every token a distinct id', () => {
@@ -59,7 +76,7 @@ describe('verifyAccessToken', () => {
59
76
  userId: 'u1',
60
77
  email: 'u1@example.com',
61
78
  scopes: ['relationship:read', 'relationship:write'],
62
- groupId: null
79
+ tenants: { allowed: [PERSONAL_TENANT], default: PERSONAL_TENANT }
63
80
  });
64
81
  });
65
82