@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
@@ -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,9 @@ 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' },
18
21
  ...over
19
22
  });
20
23
 
@@ -38,9 +41,21 @@ describe('mintAccessToken', () => {
38
41
  expect(expiresIn).toBe(3600);
39
42
  });
40
43
 
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');
44
+ it('carries the granted tenants, so a token cannot wander outside them', () => {
45
+ const claims = jwt.decode(
46
+ mint({ tenants: [PERSONAL_TENANT, 'g1'], defaultTenant: 'g1' }).accessToken
47
+ ) as Record<string, unknown>;
48
+
49
+ expect(claims.tenants).toEqual([PERSONAL_TENANT, 'g1']);
50
+ expect(claims.defaultTenant).toBe('g1');
51
+ });
52
+
53
+ it('carries what each tenant is called, since nothing downstream can look it up', () => {
54
+ const claims = jwt.decode(
55
+ mint({ tenants: ['g1'], defaultTenant: 'g1', tenantNames: { g1: 'Irja & Tom' } }).accessToken
56
+ ) as Record<string, unknown>;
57
+
58
+ expect(claims.tenantNames).toEqual({ g1: 'Irja & Tom' });
44
59
  });
45
60
 
46
61
  it('gives every token a distinct id', () => {
@@ -59,7 +74,7 @@ describe('verifyAccessToken', () => {
59
74
  userId: 'u1',
60
75
  email: 'u1@example.com',
61
76
  scopes: ['relationship:read', 'relationship:write'],
62
- groupId: null
77
+ tenants: { allowed: [PERSONAL_TENANT], default: PERSONAL_TENANT }
63
78
  });
64
79
  });
65
80
 
@@ -1,7 +1,13 @@
1
1
  import { randomUUID } from 'crypto';
2
2
  import jwt from 'jsonwebtoken';
3
3
  import { requireEnv } from '../config/env';
4
- import { isApiKeyScope, type ApiKeyScope } from '../apiKeys/types';
4
+ import {
5
+ isApiKeyScope,
6
+ readTenants,
7
+ type ApiKeyScope,
8
+ type ApiKeyTenant,
9
+ type Tenant
10
+ } from '../apiKeys/types';
5
11
 
6
12
  /**
7
13
  * Access tokens for the OAuth flow that lets an assistant connect to Tumbaland.
@@ -27,8 +33,22 @@ export interface AccessTokenClaims {
27
33
  aud: string;
28
34
  iss: string;
29
35
  scope: string;
30
- /** the tenant the token is pinned to; null means the user's personal data */
31
- groupId: string | null;
36
+ /** every tenant this token may act in */
37
+ tenants: Tenant[];
38
+ /** the one it acts in when a call names none */
39
+ defaultTenant: Tenant;
40
+ /**
41
+ * What each tenant is called, for a client that has to offer the choice.
42
+ *
43
+ * Carried on the token because the only place these names are known is the
44
+ * consent screen that rendered them, and the MCP server — which has to name
45
+ * the choice to a model — cannot reach group-service. A snapshot: a group
46
+ * renamed after the connection was made shows its old name until reconnect,
47
+ * which is the same trade the owner's name on an API key already makes.
48
+ */
49
+ tenantNames: Record<Tenant, string>;
50
+ /** the single tenant an older token was pinned to; read by `readTenants` */
51
+ groupId?: string | null;
32
52
  email: string;
33
53
  name: string;
34
54
  typ: typeof ACCESS_TOKEN_TYPE;
@@ -45,7 +65,9 @@ export interface MintAccessTokenInput {
45
65
  resource: string;
46
66
  issuer: string;
47
67
  scopes: ApiKeyScope[];
48
- groupId: string | null;
68
+ tenants: Tenant[];
69
+ defaultTenant: Tenant;
70
+ tenantNames: Record<Tenant, string>;
49
71
  }
50
72
 
51
73
  export interface MintedAccessToken {
@@ -63,7 +85,9 @@ export const mintAccessToken = (input: MintAccessTokenInput): MintedAccessToken
63
85
  aud: input.resource,
64
86
  iss: input.issuer,
65
87
  scope,
66
- groupId: input.groupId,
88
+ tenants: input.tenants,
89
+ defaultTenant: input.defaultTenant,
90
+ tenantNames: input.tenantNames,
67
91
  email: input.email,
68
92
  name: input.name,
69
93
  typ: ACCESS_TOKEN_TYPE,
@@ -83,7 +107,8 @@ export interface AccessTokenVerification {
83
107
  email?: string;
84
108
  name?: string;
85
109
  scopes?: ApiKeyScope[];
86
- groupId?: string | null;
110
+ tenants?: ApiKeyTenant;
111
+ tenantNames?: Record<Tenant, string>;
87
112
  }
88
113
 
89
114
  /**
@@ -118,7 +143,8 @@ export const verifyAccessToken = (
118
143
  email: claims.email ?? '',
119
144
  name: claims.name ?? '',
120
145
  scopes: (claims.scope ?? '').split(' ').filter(isApiKeyScope),
121
- groupId: claims.groupId ?? null
146
+ tenants: readTenants(claims),
147
+ tenantNames: claims.tenantNames ?? {}
122
148
  };
123
149
  };
124
150