@tumbaland/backend-core 1.37.0 → 1.38.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 (46) hide show
  1. package/dist/apiKeys/ApiKey.d.ts +12 -3
  2. package/dist/apiKeys/ApiKey.d.ts.map +1 -1
  3. package/dist/apiKeys/ApiKey.js +5 -1
  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 +5 -3
  10. package/dist/apiKeys/middleware.d.ts.map +1 -1
  11. package/dist/apiKeys/middleware.js +56 -31
  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 +10 -3
  16. package/dist/apiKeys/service.js.map +1 -1
  17. package/dist/apiKeys/types.d.ts +41 -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/oauth/models.d.ts +13 -2
  22. package/dist/oauth/models.d.ts.map +1 -1
  23. package/dist/oauth/models.js +9 -0
  24. package/dist/oauth/models.js.map +1 -1
  25. package/dist/oauth/service.d.ts +15 -8
  26. package/dist/oauth/service.d.ts.map +1 -1
  27. package/dist/oauth/service.js +10 -3
  28. package/dist/oauth/service.js.map +1 -1
  29. package/dist/oauth/tokens.d.ts +22 -5
  30. package/dist/oauth/tokens.d.ts.map +1 -1
  31. package/dist/oauth/tokens.js +5 -2
  32. package/dist/oauth/tokens.js.map +1 -1
  33. package/package.json +1 -1
  34. package/src/apiKeys/ApiKey.ts +16 -4
  35. package/src/apiKeys/index.ts +16 -2
  36. package/src/apiKeys/middleware.test.ts +103 -20
  37. package/src/apiKeys/middleware.ts +68 -35
  38. package/src/apiKeys/service.test.ts +56 -9
  39. package/src/apiKeys/service.ts +24 -6
  40. package/src/apiKeys/types.ts +61 -8
  41. package/src/middleware/authMiddleware.test.ts +4 -1
  42. package/src/oauth/models.ts +21 -2
  43. package/src/oauth/service.test.ts +58 -6
  44. package/src/oauth/service.ts +25 -7
  45. package/src/oauth/tokens.test.ts +20 -5
  46. package/src/oauth/tokens.ts +33 -7
@@ -6,7 +6,15 @@ import { isAccessTokenClaims, type AccessTokenClaims } from '../oauth/tokens';
6
6
  import type { UserPayload } from '../types/auth';
7
7
  import { looksLikeApiKey } from './crypto';
8
8
  import { verifyApiKey } from './service';
9
- import { isApiKeyScope, type ApiKeyScope } from './types';
9
+ import {
10
+ PERSONAL_TENANT,
11
+ groupIdsOf,
12
+ isApiKeyScope,
13
+ readTenants,
14
+ type ApiKeyScope,
15
+ type ApiKeyTenant,
16
+ type Tenant
17
+ } from './types';
10
18
 
11
19
  /**
12
20
  * Request-scoped facts about the key a request arrived on. Absent on ordinary
@@ -16,8 +24,10 @@ import { isApiKeyScope, type ApiKeyScope } from './types';
16
24
  export interface ApiKeyContext {
17
25
  keyId: string;
18
26
  scopes: ApiKeyScope[];
19
- /** the tenant this key is pinned to; null means the owner's personal data */
20
- groupId: string | null;
27
+ /** every tenant this credential may act in, and the one it acts in by default */
28
+ tenants: ApiKeyTenant;
29
+ /** the tenant this particular request resolved to */
30
+ actingAs: Tenant;
21
31
  }
22
32
 
23
33
  declare global {
@@ -40,10 +50,12 @@ const unauthorized = (res: Response): void => {
40
50
  * The tenant a request is asking to act in, wherever it named one.
41
51
  *
42
52
  * Handlers read `groupId` from either the query string or the body depending on
43
- * the verb, so both are checked — a pin that only covered one of them would be
44
- * no pin at all.
53
+ * the verb, so both are checked — a rule that only covered one of them would be
54
+ * no rule at all. A caller names the personal tenant with the literal
55
+ * `personal`, since "no group" is what the resolved default gets written into
56
+ * and would otherwise be indistinguishable from not having asked.
45
57
  */
46
- const requestedGroupId = (req: Request): string | undefined => {
58
+ const requestedTenant = (req: Request): Tenant | undefined => {
47
59
  const fromQuery = req.query?.groupId;
48
60
  if (typeof fromQuery === 'string' && fromQuery.length > 0) return fromQuery;
49
61
 
@@ -53,35 +65,50 @@ const requestedGroupId = (req: Request): string | undefined => {
53
65
  return undefined;
54
66
  };
55
67
 
68
+ /** Express 5 makes `req.query` a getter, so it is redefined rather than assigned. */
69
+ const setQueryGroupId = (req: Request, groupId: string | undefined): void => {
70
+ const query = { ...req.query } as Record<string, unknown>;
71
+ if (groupId === undefined) delete query.groupId;
72
+ else query.groupId = groupId;
73
+
74
+ Object.defineProperty(req, 'query', {
75
+ value: query,
76
+ writable: true,
77
+ configurable: true,
78
+ enumerable: true
79
+ });
80
+ };
81
+
56
82
  /**
57
- * Hold a key to the tenant it was issued for.
83
+ * Hold a credential to the tenants it was granted, and settle which one this
84
+ * request is acting in.
85
+ *
86
+ * A request naming a tenant outside the grant is refused outright — that
87
+ * boundary is the whole reason a credential is safe to hand to an agent, and it
88
+ * is unchanged by the grant being a set rather than one. What the set adds is a
89
+ * choice *inside* it, which is what lets one connection reach a shared journal
90
+ * and a private photo library without reconnecting.
58
91
  *
59
- * Two things happen here, and the second is the one that makes keys pleasant to
60
- * use. A request that names a *different* tenant is refused outright the pin
61
- * is the whole reason a key is safe to hand to an agent. A request that names
62
- * none has the pinned tenant written in for it, so the agent never has to know a
63
- * group id exists, and a handler's `if (groupId) ... else personal` branch lands
64
- * where the key's owner intended.
92
+ * A request that names nothing gets the default written in for it, so an agent
93
+ * granted a single tenant never has to know a group id exists and a handler's
94
+ * `if (groupId) else personal` branch lands where the owner intended.
65
95
  */
66
- const applyTenantPin = (req: Request, groupId: string | null): boolean => {
67
- const requested = requestedGroupId(req);
96
+ const applyTenantGrant = (req: Request, tenants: ApiKeyTenant): Tenant | null => {
97
+ const requested = requestedTenant(req) ?? tenants.default;
68
98
 
69
- if (requested !== undefined && requested !== groupId) return false;
99
+ if (!tenants.allowed.includes(requested)) return null;
70
100
 
71
- if (groupId !== null && requested === undefined) {
72
- // Express 5 makes req.query a getter, so it is redefined rather than assigned.
73
- Object.defineProperty(req, 'query', {
74
- value: { ...req.query, groupId },
75
- writable: true,
76
- configurable: true,
77
- enumerable: true
78
- });
79
- if (req.body && typeof req.body === 'object') {
80
- (req.body as Record<string, unknown>).groupId = groupId;
81
- }
101
+ // Resolved either way, including when the caller asked for exactly what the
102
+ // default already was: handlers read the personal tenant as an absent
103
+ // `groupId`, so `personal` has to be erased rather than passed through.
104
+ const groupId = requested === PERSONAL_TENANT ? undefined : requested;
105
+ setQueryGroupId(req, groupId);
106
+ if (req.body && typeof req.body === 'object') {
107
+ if (groupId === undefined) delete (req.body as Record<string, unknown>).groupId;
108
+ else (req.body as Record<string, unknown>).groupId = groupId;
82
109
  }
83
110
 
84
- return true;
111
+ return requested;
85
112
  };
86
113
 
87
114
  /** A verified non-session credential, whichever kind it arrived as. */
@@ -92,7 +119,7 @@ interface AgentCredential {
92
119
  email: string;
93
120
  name: string;
94
121
  scopes: ApiKeyScope[];
95
- groupId: string | null;
122
+ tenants: ApiKeyTenant;
96
123
  }
97
124
 
98
125
  /**
@@ -120,7 +147,8 @@ function admitAgent(
120
147
  return;
121
148
  }
122
149
 
123
- if (!applyTenantPin(req, credential.groupId)) {
150
+ const actingAs = applyTenantGrant(req, credential.tenants);
151
+ if (actingAs === null) {
124
152
  res.status(403).json({
125
153
  success: false,
126
154
  message: 'Credential is not permitted to act in the requested group'
@@ -129,11 +157,16 @@ function admitAgent(
129
157
  }
130
158
 
131
159
  req.user = { id: credential.userId, email: credential.email, name: credential.name };
132
- // Only the pinned group, never the owner's full membership: this is what
160
+ // Only the granted groups, never the owner's full membership: this is what
133
161
  // stops a credential reaching a group it was not issued for through any
134
162
  // handler that consults `userGroups` instead of the `groupId` parameter.
135
- req.userGroups = credential.groupId ? [credential.groupId] : [];
136
- req.apiKey = { keyId: credential.credentialId, scopes: credential.scopes, groupId: credential.groupId };
163
+ req.userGroups = groupIdsOf(credential.tenants.allowed);
164
+ req.apiKey = {
165
+ keyId: credential.credentialId,
166
+ scopes: credential.scopes,
167
+ tenants: credential.tenants,
168
+ actingAs
169
+ };
137
170
 
138
171
  next();
139
172
  }
@@ -191,7 +224,7 @@ export const authenticateAgent = (...requiredScopes: ApiKeyScope[]): RequestHand
191
224
  email: claims.email ?? '',
192
225
  name: claims.name ?? '',
193
226
  scopes: (claims.scope ?? '').split(' ').filter(isApiKeyScope),
194
- groupId: claims.groupId ?? null
227
+ tenants: readTenants(claims)
195
228
  });
196
229
  return;
197
230
  }
@@ -211,7 +244,7 @@ export const authenticateAgent = (...requiredScopes: ApiKeyScope[]): RequestHand
211
244
  email: result.userEmail ?? '',
212
245
  name: result.userName ?? '',
213
246
  scopes: result.scopes ?? [],
214
- groupId: result.groupId ?? null
247
+ tenants: result.tenants ?? readTenants({})
215
248
  });
216
249
  };
217
250
 
@@ -11,6 +11,7 @@ jest.mock('./ApiKey', () => ({
11
11
  }));
12
12
 
13
13
  import { ApiKey } from './ApiKey';
14
+ import { PERSONAL_TENANT } from './types';
14
15
  import {
15
16
  createApiKey,
16
17
  listApiKeys,
@@ -43,7 +44,8 @@ function storedKey(over: Record<string, unknown> = {}) {
43
44
  sealedIv: 'iv',
44
45
  sealedTag: 'tag',
45
46
  scopes: ['relationship:read'],
46
- groupId: null,
47
+ tenants: [PERSONAL_TENANT],
48
+ defaultTenant: PERSONAL_TENANT,
47
49
  revealCount: 0,
48
50
  lastRevealedAt: undefined as Date | undefined,
49
51
  createdAt: CREATED_AT,
@@ -98,14 +100,30 @@ describe('createApiKey', () => {
98
100
  expect(stored.sealedTag).toBeTruthy();
99
101
  });
100
102
 
101
- it('defaults an unpinned key to the owner’s personal scope', async () => {
103
+ it('defaults a key with no stated tenants to the owner’s personal data', async () => {
102
104
  await createApiKey(input);
103
- expect(mockedCreate.mock.calls[0][0].groupId).toBeNull();
105
+ expect(mockedCreate.mock.calls[0][0].tenants).toEqual([PERSONAL_TENANT]);
106
+ expect(mockedCreate.mock.calls[0][0].defaultTenant).toBe(PERSONAL_TENANT);
104
107
  });
105
108
 
106
- it('pins a key to the group it was issued for', async () => {
107
- await createApiKey({ ...input, groupId: 'g1' });
108
- expect(mockedCreate.mock.calls[0][0].groupId).toBe('g1');
109
+ it('stores every tenant a key was granted', async () => {
110
+ await createApiKey({ ...input, tenants: [PERSONAL_TENANT, 'g1'], defaultTenant: 'g1' });
111
+
112
+ expect(mockedCreate.mock.calls[0][0].tenants).toEqual([PERSONAL_TENANT, 'g1']);
113
+ expect(mockedCreate.mock.calls[0][0].defaultTenant).toBe('g1');
114
+ });
115
+
116
+ it('refuses to store a default outside the grant', async () => {
117
+ // A key acting by default in something it cannot reach would authenticate
118
+ // and then fail every call that did not name a tenant explicitly.
119
+ await createApiKey({ ...input, tenants: ['g1'], defaultTenant: 'g2' });
120
+
121
+ expect(mockedCreate.mock.calls[0][0].defaultTenant).toBe('g1');
122
+ });
123
+
124
+ it('reads an empty grant as the safest non-empty one', async () => {
125
+ await createApiKey({ ...input, tenants: [] });
126
+ expect(mockedCreate.mock.calls[0][0].tenants).toEqual([PERSONAL_TENANT]);
109
127
  });
110
128
 
111
129
  it('snapshots the owner so verification needs no second query', async () => {
@@ -311,10 +329,14 @@ describe('verifyApiKey', () => {
311
329
  expect(mockedFindOne).toHaveBeenCalledWith({ keyId: parseKey(token)!.id });
312
330
  });
313
331
 
314
- it('returns the identity, scopes and tenant a valid key carries', async () => {
332
+ it('returns the identity, scopes and tenants a valid key carries', async () => {
315
333
  const { token } = generateKey();
316
334
  mockedFindOne.mockResolvedValue(
317
- acceptingKey(token, { scopes: ['relationship:read', 'album:write'], groupId: 'g1' })
335
+ acceptingKey(token, {
336
+ scopes: ['relationship:read', 'album:write'],
337
+ tenants: [PERSONAL_TENANT, 'g1'],
338
+ defaultTenant: 'g1'
339
+ })
318
340
  );
319
341
 
320
342
  await expect(verifyApiKey(token)).resolves.toEqual({
@@ -324,7 +346,32 @@ describe('verifyApiKey', () => {
324
346
  userName: 'Tester',
325
347
  keyId: 'k1',
326
348
  scopes: ['relationship:read', 'album:write'],
327
- groupId: 'g1'
349
+ tenants: { allowed: [PERSONAL_TENANT, 'g1'], default: 'g1' }
350
+ });
351
+ });
352
+
353
+ it('reads a key issued before tenants were a set', async () => {
354
+ // Keys stored under the single-tenant model carry `groupId` and no array.
355
+ // Without this fallback every connected assistant would break at once on
356
+ // the deploy that introduced the set.
357
+ const { token } = generateKey();
358
+ mockedFindOne.mockResolvedValue(
359
+ acceptingKey(token, { tenants: undefined, defaultTenant: undefined, groupId: 'g1' })
360
+ );
361
+
362
+ await expect(verifyApiKey(token)).resolves.toMatchObject({
363
+ tenants: { allowed: ['g1'], default: 'g1' }
364
+ });
365
+ });
366
+
367
+ it('reads an old personal key as the personal tenant, not as no tenant', async () => {
368
+ const { token } = generateKey();
369
+ mockedFindOne.mockResolvedValue(
370
+ acceptingKey(token, { tenants: undefined, defaultTenant: undefined, groupId: null })
371
+ );
372
+
373
+ await expect(verifyApiKey(token)).resolves.toMatchObject({
374
+ tenants: { allowed: [PERSONAL_TENANT], default: PERSONAL_TENANT }
328
375
  });
329
376
  });
330
377
 
@@ -7,7 +7,14 @@ import {
7
7
  parseKey,
8
8
  secretMatches
9
9
  } from './crypto';
10
- import type { ApiKeyScope, ApiKeySummary, ApiKeyVerification } from './types';
10
+ import {
11
+ PERSONAL_TENANT,
12
+ readTenants,
13
+ type ApiKeyScope,
14
+ type ApiKeySummary,
15
+ type ApiKeyVerification,
16
+ type Tenant
17
+ } from './types';
11
18
 
12
19
  export interface CreateApiKeyInput {
13
20
  userId: string;
@@ -15,8 +22,10 @@ export interface CreateApiKeyInput {
15
22
  userName: string;
16
23
  name: string;
17
24
  scopes: ApiKeyScope[];
18
- /** null pins the key to the user's own non-group data */
19
- groupId?: string | null;
25
+ /** every tenant the key may act in; defaults to the user's own data alone */
26
+ tenants?: Tenant[];
27
+ /** which of them it acts in when a request names none */
28
+ defaultTenant?: Tenant;
20
29
  expiresAt?: Date | null;
21
30
  }
22
31
 
@@ -31,7 +40,9 @@ const toSummary = (key: IApiKey): ApiKeySummary => ({
31
40
  name: key.name,
32
41
  prefix: displayPrefix(key.keyId),
33
42
  scopes: key.scopes,
34
- groupId: key.groupId ?? null,
43
+ ...(({ allowed, default: fallback }) => ({ tenants: allowed, defaultTenant: fallback }))(
44
+ readTenants(key)
45
+ ),
35
46
  createdAt: key.createdAt.toISOString(),
36
47
  lastUsedAt: key.lastUsedAt?.toISOString() ?? null,
37
48
  expiresAt: key.expiresAt?.toISOString() ?? null,
@@ -39,6 +50,10 @@ const toSummary = (key: IApiKey): ApiKeySummary => ({
39
50
  });
40
51
 
41
52
  export const createApiKey = async (input: CreateApiKeyInput): Promise<CreatedApiKey> => {
53
+ // A key that reaches nowhere would authenticate and then fail every call, so
54
+ // an empty grant is read as the safest non-empty one rather than stored.
55
+ const tenants = input.tenants?.length ? input.tenants : [PERSONAL_TENANT];
56
+
42
57
  const { token, id, hash } = generateKey();
43
58
  const sealed = encryptSecret(token);
44
59
 
@@ -53,7 +68,10 @@ export const createApiKey = async (input: CreateApiKeyInput): Promise<CreatedApi
53
68
  sealedIv: sealed.iv,
54
69
  sealedTag: sealed.tag,
55
70
  scopes: input.scopes,
56
- groupId: input.groupId ?? null,
71
+ tenants,
72
+ defaultTenant: tenants.includes(input.defaultTenant ?? '')
73
+ ? input.defaultTenant
74
+ : (tenants[0] as Tenant),
57
75
  expiresAt: input.expiresAt ?? undefined
58
76
  });
59
77
 
@@ -144,6 +162,6 @@ export const verifyApiKey = async (token: unknown): Promise<ApiKeyVerification>
144
162
  userName: key.userName,
145
163
  keyId: String(key._id),
146
164
  scopes: key.scopes,
147
- groupId: key.groupId ?? null
165
+ tenants: readTenants(key)
148
166
  };
149
167
  };
@@ -24,17 +24,69 @@ export const isApiKeyScope = (value: unknown): value is ApiKeyScope =>
24
24
  typeof value === 'string' && (API_KEY_SCOPES as readonly string[]).includes(value);
25
25
 
26
26
  /**
27
- * The tenant a key acts in, fixed when the key is created.
27
+ * A tenant a credential may act in: a group id, or the user's own data.
28
28
  *
29
- * The web app picks this per request from a tenant selector, but an agent has no
30
- * such UI and no way to know it guessed wrong so the choice is made once, by a
31
- * person, and the key cannot escape it. `groupId: null` means the user's own
32
- * non-group data.
29
+ * Personal is a named sentinel rather than `null` so that a tenant is always a
30
+ * plain string storable in an array, sendable in a JWT claim, comparable
31
+ * without a special case at every layer. It cannot collide with a real tenant:
32
+ * group ids are 24-character hex.
33
+ */
34
+ export const PERSONAL_TENANT = 'personal';
35
+
36
+ export type Tenant = string;
37
+
38
+ /** The `groupId` a tenant corresponds to, as handlers have always read it. */
39
+ export const groupIdOf = (tenant: Tenant): string | null =>
40
+ tenant === PERSONAL_TENANT ? null : tenant;
41
+
42
+ /**
43
+ * Which tenants a credential may act in, and which one it acts in by default.
44
+ *
45
+ * The web app picks a tenant per request from a selector; an agent has no such
46
+ * UI. So the *set* is chosen once, by a person, and the credential cannot escape
47
+ * it — but within that set the caller may name one per request, which is what
48
+ * lets a single connection reach both a shared journal and a private photo
49
+ * library without reconnecting.
50
+ *
51
+ * `allowed` is never empty and always contains `default`. A credential granted
52
+ * exactly one tenant behaves precisely as a pinned one did: nothing to name,
53
+ * nothing to get wrong.
33
54
  */
34
55
  export interface ApiKeyTenant {
35
- groupId: string | null;
56
+ allowed: Tenant[];
57
+ default: Tenant;
36
58
  }
37
59
 
60
+ /** Just the groups, for the `req.userGroups` every handler already reads. */
61
+ export const groupIdsOf = (tenants: Tenant[]): string[] =>
62
+ tenants.filter((tenant) => tenant !== PERSONAL_TENANT);
63
+
64
+ /**
65
+ * Read a stored tenant grant, tolerating one written before tenants were a set.
66
+ *
67
+ * Keys and grants issued under the old single-tenant model carry `groupId`
68
+ * alone. Falling back to it here means they keep working across the deploy
69
+ * rather than every connected assistant breaking at once.
70
+ */
71
+ export const readTenants = (stored: {
72
+ tenants?: Tenant[] | null;
73
+ defaultTenant?: Tenant | null;
74
+ groupId?: string | null;
75
+ }): ApiKeyTenant => {
76
+ const allowed =
77
+ stored.tenants && stored.tenants.length > 0
78
+ ? stored.tenants
79
+ : [stored.groupId ?? PERSONAL_TENANT];
80
+
81
+ const fallbackDefault = allowed[0] as Tenant;
82
+ const preferred = stored.defaultTenant ?? fallbackDefault;
83
+
84
+ return {
85
+ allowed,
86
+ default: allowed.includes(preferred) ? preferred : fallbackDefault
87
+ };
88
+ };
89
+
38
90
  /** A key as the API hands it back — never including the secret. */
39
91
  export interface ApiKeySummary {
40
92
  id: string;
@@ -42,7 +94,8 @@ export interface ApiKeySummary {
42
94
  /** the public half, shown in listings so a key is identifiable at a glance */
43
95
  prefix: string;
44
96
  scopes: ApiKeyScope[];
45
- groupId: string | null;
97
+ tenants: Tenant[];
98
+ defaultTenant: Tenant;
46
99
  createdAt: string;
47
100
  lastUsedAt: string | null;
48
101
  expiresAt: string | null;
@@ -65,5 +118,5 @@ export interface ApiKeyVerification {
65
118
  userName?: string;
66
119
  keyId?: string;
67
120
  scopes?: ApiKeyScope[];
68
- groupId?: string | null;
121
+ tenants?: ApiKeyTenant;
69
122
  }
@@ -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,9 @@ 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: {}
196
199
  }).accessToken;
197
200
 
198
201
  it('refuses one on a session-only route', () => {
@@ -1,4 +1,5 @@
1
1
  import mongoose, { Document, Schema } from 'mongoose';
2
+ import { PERSONAL_TENANT, type Tenant } from '../apiKeys/types';
2
3
 
3
4
  /**
4
5
  * A client registered through RFC 7591 Dynamic Client Registration.
@@ -46,7 +47,13 @@ export interface IAuthorizationCode extends Document {
46
47
  userName: string;
47
48
  redirectUri: string;
48
49
  scopes: string[];
49
- groupId: string | null;
50
+ /** every tenant the user approved, and the one chosen as the default */
51
+ tenants: Tenant[];
52
+ defaultTenant: Tenant;
53
+ /** what each approved tenant is called, snapshotted at consent */
54
+ tenantNames: Record<Tenant, string>;
55
+ /** the single tenant an older grant was pinned to; read by `readTenants` */
56
+ groupId?: string | null;
50
57
  /** RFC 8707: the MCP server this will be minted for */
51
58
  resource: string;
52
59
  /** PKCE, always S256 — the spec does not allow `plain` */
@@ -66,6 +73,10 @@ const AuthorizationCodeSchema = new Schema<IAuthorizationCode>(
66
73
  userName: { type: String, default: '' },
67
74
  redirectUri: { type: String, required: true },
68
75
  scopes: { type: [String], default: [] },
76
+ tenants: { type: [String], default: () => [PERSONAL_TENANT] },
77
+ defaultTenant: { type: String, default: PERSONAL_TENANT },
78
+ tenantNames: { type: Schema.Types.Mixed, default: () => ({}) },
79
+ // Written by the single-tenant model this replaced; read-only now.
69
80
  groupId: { type: String, default: null },
70
81
  resource: { type: String, required: true },
71
82
  codeChallenge: { type: String, required: true },
@@ -96,7 +107,11 @@ export interface IRefreshToken extends Document {
96
107
  clientId: string;
97
108
  userId: string;
98
109
  scopes: string[];
99
- groupId: string | null;
110
+ tenants: Tenant[];
111
+ defaultTenant: Tenant;
112
+ tenantNames: Record<Tenant, string>;
113
+ /** the single tenant an older grant was pinned to; read by `readTenants` */
114
+ groupId?: string | null;
100
115
  resource: string;
101
116
  /**
102
117
  * When the user actually connected this assistant.
@@ -118,6 +133,10 @@ const RefreshTokenSchema = new Schema<IRefreshToken>(
118
133
  clientId: { type: String, required: true },
119
134
  userId: { type: String, required: true, index: true },
120
135
  scopes: { type: [String], default: [] },
136
+ tenants: { type: [String], default: () => [PERSONAL_TENANT] },
137
+ defaultTenant: { type: String, default: PERSONAL_TENANT },
138
+ tenantNames: { type: Schema.Types.Mixed, default: () => ({}) },
139
+ // Written by the single-tenant model this replaced; read-only now.
121
140
  groupId: { type: String, default: null },
122
141
  resource: { type: String, required: true },
123
142
  grantedAt: { type: Date, default: Date.now },
@@ -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