@tumbaland/backend-core 1.41.0 → 1.43.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 (48) hide show
  1. package/dist/apiKeys/middleware.d.ts.map +1 -1
  2. package/dist/apiKeys/middleware.js +23 -9
  3. package/dist/apiKeys/middleware.js.map +1 -1
  4. package/dist/auth/session.d.ts +22 -0
  5. package/dist/auth/session.d.ts.map +1 -0
  6. package/dist/auth/session.js +75 -0
  7. package/dist/auth/session.js.map +1 -0
  8. package/dist/groups/client.d.ts +15 -0
  9. package/dist/groups/client.d.ts.map +1 -1
  10. package/dist/groups/client.js +1 -0
  11. package/dist/groups/client.js.map +1 -1
  12. package/dist/groups/index.d.ts +1 -0
  13. package/dist/groups/index.d.ts.map +1 -1
  14. package/dist/groups/index.js +5 -1
  15. package/dist/groups/index.js.map +1 -1
  16. package/dist/groups/membership.d.ts +28 -0
  17. package/dist/groups/membership.d.ts.map +1 -0
  18. package/dist/groups/membership.js +122 -0
  19. package/dist/groups/membership.js.map +1 -0
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +6 -2
  23. package/dist/index.js.map +1 -1
  24. package/dist/middleware/authMiddleware.d.ts +2 -2
  25. package/dist/middleware/authMiddleware.d.ts.map +1 -1
  26. package/dist/middleware/authMiddleware.js +22 -10
  27. package/dist/middleware/authMiddleware.js.map +1 -1
  28. package/dist/testing/serviceTestSetup.d.ts +2 -0
  29. package/dist/testing/serviceTestSetup.d.ts.map +1 -0
  30. package/dist/testing/serviceTestSetup.js +18 -0
  31. package/dist/testing/serviceTestSetup.js.map +1 -0
  32. package/dist/types/auth.d.ts +3 -0
  33. package/dist/types/auth.d.ts.map +1 -1
  34. package/package.json +1 -1
  35. package/src/apiKeys/middleware.test.ts +84 -0
  36. package/src/apiKeys/middleware.ts +25 -10
  37. package/src/auth/session.test.ts +89 -0
  38. package/src/auth/session.ts +75 -0
  39. package/src/groups/client.ts +1 -1
  40. package/src/groups/index.ts +2 -0
  41. package/src/groups/membership.test.ts +84 -0
  42. package/src/groups/membership.ts +133 -0
  43. package/src/index.ts +1 -0
  44. package/src/middleware/authMiddleware.test.ts +78 -30
  45. package/src/middleware/authMiddleware.ts +22 -10
  46. package/src/testing/serviceTestSetup.ts +17 -0
  47. package/src/types/auth.ts +3 -0
  48. package/testing.js +10 -0
@@ -0,0 +1,133 @@
1
+ import logger from '../logging/logger';
2
+ import { internalGroupUrl } from './client';
3
+
4
+ /**
5
+ * Which groups a person belongs to, asked of group-service.
6
+ *
7
+ * This used to travel in the session token's `groups` claim, which made it only
8
+ * as fresh as the token: a new member could not see the group until their next
9
+ * renewal, and a removed one kept access until theirs, which is why membership
10
+ * changes used to sign people out. Asked here instead, with a short cache, a
11
+ * change reaches every service within CACHE_TTL_MS.
12
+ *
13
+ * Every request pays for this only once per person per service per cache
14
+ * window; concurrent callers share one request.
15
+ */
16
+ const CACHE_TTL_MS = 30_000;
17
+
18
+ /** Bounded so a service under enumeration can't grow the cache without limit. */
19
+ const MAX_CACHE_ENTRIES = 5_000;
20
+
21
+ const REQUEST_TIMEOUT_MS = 3_000;
22
+
23
+ interface CacheEntry {
24
+ groups: string[];
25
+ expiresAt: number;
26
+ }
27
+
28
+ const cache = new Map<string, CacheEntry>();
29
+ const inFlight = new Map<string, Promise<string[] | null>>();
30
+
31
+ function putInCache(email: string, groups: string[]): void {
32
+ if (!cache.has(email) && cache.size >= MAX_CACHE_ENTRIES) {
33
+ // Map iterates in insertion order, so the first key is the oldest write.
34
+ const oldest = cache.keys().next();
35
+ if (!oldest.done) cache.delete(oldest.value);
36
+ }
37
+ cache.set(email, { groups, expiresAt: Date.now() + CACHE_TTL_MS });
38
+ }
39
+
40
+ async function fetchUserGroupIds(email: string): Promise<string[] | null> {
41
+ const baseUrl = internalGroupUrl();
42
+ const token = process.env.INTERNAL_SERVICE_TOKEN;
43
+
44
+ if (!baseUrl || !token) {
45
+ logger.error('Group membership lookup not configured', {
46
+ hasGroupApiUrl: Boolean(baseUrl),
47
+ hasInternalToken: Boolean(token)
48
+ });
49
+ return null;
50
+ }
51
+
52
+ try {
53
+ const response = await fetch(`${baseUrl}/api/groups/mine/ids?userEmail=${encodeURIComponent(email)}`, {
54
+ method: 'GET',
55
+ headers: {
56
+ 'Content-Type': 'application/json',
57
+ 'x-internal-token': token,
58
+ 'x-service-id': process.env.SERVICE_NAME || 'unknown-service'
59
+ },
60
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
61
+ });
62
+
63
+ if (!response.ok) {
64
+ logger.error('Group membership lookup failed', { email, status: response.status });
65
+ return null;
66
+ }
67
+
68
+ const body = (await response.json()) as { success?: boolean; data?: unknown };
69
+ if (!body.success || !Array.isArray(body.data)) {
70
+ logger.error('Group membership lookup returned no list', { email });
71
+ return null;
72
+ }
73
+ return body.data.filter((id): id is string => typeof id === 'string');
74
+ } catch (error) {
75
+ logger.error('Group membership lookup unreachable', { email, error: (error as Error)?.message });
76
+ return null;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * The ids of the groups `email` belongs to right now.
82
+ *
83
+ * When group-service cannot be asked, the last answer is used even if it has
84
+ * expired, and failing that no groups at all: a person keeps their own data
85
+ * through an outage and loses group data until it ends, rather than being
86
+ * granted a group on a guess.
87
+ */
88
+ export async function getUserGroupIds(email: string): Promise<string[]> {
89
+ const key = email.toLowerCase();
90
+ const entry = cache.get(key);
91
+ if (entry && entry.expiresAt > Date.now()) return entry.groups;
92
+
93
+ let pending = inFlight.get(key);
94
+ if (!pending) {
95
+ pending = fetchUserGroupIds(key).finally(() => inFlight.delete(key));
96
+ inFlight.set(key, pending);
97
+ }
98
+
99
+ const groups = await pending;
100
+ if (groups) {
101
+ putInCache(key, groups);
102
+ return groups;
103
+ }
104
+ if (entry) {
105
+ logger.warn('Using the last known group membership while group-service is unreachable', { email: key });
106
+ return entry.groups;
107
+ }
108
+ return [];
109
+ }
110
+
111
+ /** For tests. */
112
+ export function clearMembershipCache(): void {
113
+ cache.clear();
114
+ inFlight.clear();
115
+ }
116
+
117
+ type MembershipLookup = (user: { email: string; groups?: string[] }) => Promise<string[]>;
118
+
119
+ const lookUpMembership: MembershipLookup = (user) => getUserGroupIds(user.email);
120
+ let membershipLookup: MembershipLookup = lookUpMembership;
121
+
122
+ /** The groups the auth middlewares put on `req.userGroups`. */
123
+ export const currentGroupsOf = (user: { email: string; groups?: string[] }): Promise<string[]> =>
124
+ membershipLookup(user);
125
+
126
+ /**
127
+ * Replaces the lookup, for tests only; pass nothing to restore the real one.
128
+ * Service suites reach it through `@tumbaland/backend-core/testing`, which makes
129
+ * a test token's own `groups` claim the membership, so they need no group-service.
130
+ */
131
+ export function overrideMembershipLookupForTests(lookup?: MembershipLookup): void {
132
+ membershipLookup = lookup ?? lookUpMembership;
133
+ }
package/src/index.ts CHANGED
@@ -39,6 +39,7 @@ export * from './audit';
39
39
 
40
40
  // Middleware
41
41
  export { authenticateToken, optionalAuth } from './middleware/authMiddleware';
42
+ export { USER_SESSIONS_COLLECTION, isCurrentSession, isLegacySessionlessToken } from './auth/session';
42
43
  export {
43
44
  requireInternalServiceToken,
44
45
  internalServiceTokenBypass,
@@ -4,6 +4,12 @@ import { authenticateToken, optionalAuth } from './authMiddleware';
4
4
  import { PERSONAL_TENANT } from '../apiKeys/types';
5
5
  import { mintAccessToken } from '../oauth/tokens';
6
6
  import { UserPayload } from '../types/auth';
7
+ import { overrideSessionCheckForTests } from '../auth/session';
8
+ import { overrideMembershipLookupForTests } from '../groups/membership';
9
+
10
+ // These tokens carry no session; the session check has its own tests below and in session.test.ts.
11
+ beforeEach(() => overrideSessionCheckForTests(async () => true));
12
+ afterAll(() => overrideSessionCheckForTests());
7
13
 
8
14
  function mockReq(overrides: Partial<Request> = {}): Request {
9
15
  return { cookies: {}, headers: {}, ...overrides } as unknown as Request;
@@ -31,75 +37,80 @@ describe('authenticateToken', () => {
31
37
 
32
38
  const payload: UserPayload = { id: '1', email: 'user@example.com', name: 'Test User' };
33
39
 
34
- it('attaches the decoded user and calls next for a valid cookie token', () => {
40
+ it('attaches the decoded user and calls next for a valid cookie token', async () => {
35
41
  const token = jwt.sign(payload, 'test-secret');
36
42
  const req = mockReq({ cookies: { access_token: token } });
37
43
  const res = mockRes();
38
44
 
39
- authenticateToken(req, res, next);
45
+ await authenticateToken(req, res, next);
40
46
 
41
47
  expect(next).toHaveBeenCalledTimes(1);
42
48
  expect(req.user).toMatchObject({ id: '1', email: 'user@example.com' });
43
49
  });
44
50
 
45
- it('attaches an empty userGroups array when the token has no groups claim', () => {
51
+ it('attaches an empty userGroups array when the token has no groups claim', async () => {
46
52
  const token = jwt.sign(payload, 'test-secret');
47
53
  const req = mockReq({ cookies: { access_token: token } });
48
54
  const res = mockRes();
49
55
 
50
- authenticateToken(req, res, next);
56
+ await authenticateToken(req, res, next);
51
57
 
52
58
  expect(req.userGroups).toEqual([]);
53
59
  });
54
60
 
55
- it('attaches userGroups from the token when present', () => {
56
- const token = jwt.sign({ ...payload, groups: ['group-1', 'group-2'] }, 'test-secret');
61
+ it("takes the person's groups from group-service, not from the token", async () => {
62
+ const lookup = jest.fn(async () => ['live-group']);
63
+ overrideMembershipLookupForTests(lookup);
64
+ const token = jwt.sign({ ...payload, groups: ['stale-group'] }, 'test-secret');
57
65
  const req = mockReq({ cookies: { access_token: token } });
58
66
  const res = mockRes();
59
67
 
60
- authenticateToken(req, res, next);
68
+ await authenticateToken(req, res, next);
61
69
 
62
- expect(req.userGroups).toEqual(['group-1', 'group-2']);
70
+ expect(lookup).toHaveBeenCalledWith(expect.objectContaining({ email: 'user@example.com' }));
71
+ expect(req.userGroups).toEqual(['live-group']);
72
+ expect(req.user?.groups).toEqual(['live-group']);
73
+ overrideMembershipLookupForTests();
63
74
  });
64
75
 
65
- it('accepts a Bearer token from the Authorization header', () => {
76
+ it('accepts a Bearer token from the Authorization header', async () => {
66
77
  const token = jwt.sign(payload, 'test-secret');
67
78
  const req = mockReq({ headers: { authorization: `Bearer ${token}` } });
68
79
  const res = mockRes();
69
80
 
70
- authenticateToken(req, res, next);
81
+ await authenticateToken(req, res, next);
71
82
 
72
83
  expect(next).toHaveBeenCalledTimes(1);
73
84
  });
74
85
 
75
- it('returns 401 when no token is present', () => {
86
+ it('returns 401 when no token is present', async () => {
76
87
  const req = mockReq();
77
88
  const res = mockRes();
78
89
 
79
- authenticateToken(req, res, next);
90
+ await authenticateToken(req, res, next);
80
91
 
81
92
  expect(next).not.toHaveBeenCalled();
82
93
  expect(res.status).toHaveBeenCalledWith(401);
83
94
  expect(res.json).toHaveBeenCalledWith({ success: false, message: 'Access token required' });
84
95
  });
85
96
 
86
- it('returns 401 for an invalid token', () => {
97
+ it('returns 401 for an invalid token', async () => {
87
98
  const req = mockReq({ cookies: { access_token: 'not-a-real-token' } });
88
99
  const res = mockRes();
89
100
 
90
- authenticateToken(req, res, next);
101
+ await authenticateToken(req, res, next);
91
102
 
92
103
  expect(next).not.toHaveBeenCalled();
93
104
  expect(res.status).toHaveBeenCalledWith(401);
94
105
  expect(res.json).toHaveBeenCalledWith({ success: false, message: 'Invalid or expired token' });
95
106
  });
96
107
 
97
- it('returns 401 for a token signed with a different secret', () => {
108
+ it('returns 401 for a token signed with a different secret', async () => {
98
109
  const token = jwt.sign(payload, 'wrong-secret');
99
110
  const req = mockReq({ cookies: { access_token: token } });
100
111
  const res = mockRes();
101
112
 
102
- authenticateToken(req, res, next);
113
+ await authenticateToken(req, res, next);
103
114
 
104
115
  expect(res.status).toHaveBeenCalledWith(401);
105
116
  });
@@ -120,54 +131,54 @@ describe('optionalAuth', () => {
120
131
 
121
132
  const payload: UserPayload = { id: '1', email: 'user@example.com', name: 'Test User' };
122
133
 
123
- it('attaches the user when the token is valid', () => {
134
+ it('attaches the user when the token is valid', async () => {
124
135
  const req = mockReq({ cookies: { access_token: jwt.sign(payload, 'test-secret') } });
125
136
 
126
- optionalAuth(req, mockRes(), next);
137
+ await optionalAuth(req, mockRes(), next);
127
138
 
128
139
  expect(next).toHaveBeenCalledTimes(1);
129
140
  expect(req.user).toMatchObject({ email: 'user@example.com' });
130
141
  });
131
142
 
132
- it('reads a bearer token too', () => {
143
+ it('reads a bearer token too', async () => {
133
144
  const req = mockReq({ headers: { authorization: `Bearer ${jwt.sign(payload, 'test-secret')}` } });
134
145
 
135
- optionalAuth(req, mockRes(), next);
146
+ await optionalAuth(req, mockRes(), next);
136
147
 
137
148
  expect(req.user).toMatchObject({ email: 'user@example.com' });
138
149
  });
139
150
 
140
- it('continues anonymously when there is no token', () => {
151
+ it('continues anonymously when there is no token', async () => {
141
152
  const req = mockReq();
142
153
  const res = mockRes();
143
154
 
144
- optionalAuth(req, res, next);
155
+ await optionalAuth(req, res, next);
145
156
 
146
157
  expect(next).toHaveBeenCalledTimes(1);
147
158
  expect(req.user).toBeUndefined();
148
159
  expect(res.status).not.toHaveBeenCalled();
149
160
  });
150
161
 
151
- it('treats an expired or forged token as no token rather than as a 401', () => {
162
+ it('treats an expired or forged token as no token rather than as a 401', async () => {
152
163
  // The caller asked for public data. Refusing it because a stale cookie is
153
164
  // lying around would break the landing page for exactly the people most
154
165
  // likely to have one.
155
166
  const req = mockReq({ cookies: { access_token: jwt.sign(payload, 'wrong-secret') } });
156
167
  const res = mockRes();
157
168
 
158
- optionalAuth(req, res, next);
169
+ await optionalAuth(req, res, next);
159
170
 
160
171
  expect(next).toHaveBeenCalledTimes(1);
161
172
  expect(req.user).toBeUndefined();
162
173
  expect(res.status).not.toHaveBeenCalled();
163
174
  });
164
175
 
165
- it('continues anonymously when JWT_SECRET is not configured', () => {
176
+ it('continues anonymously when JWT_SECRET is not configured', async () => {
166
177
  delete process.env.JWT_SECRET;
167
178
  const req = mockReq({ cookies: { access_token: 'anything' } });
168
179
  const res = mockRes();
169
180
 
170
- optionalAuth(req, res, next);
181
+ await optionalAuth(req, res, next);
171
182
 
172
183
  expect(next).toHaveBeenCalledTimes(1);
173
184
  expect(res.status).not.toHaveBeenCalled();
@@ -200,28 +211,65 @@ describe('OAuth access tokens are not sessions', () => {
200
211
  clientName: 'Claude'
201
212
  }).accessToken;
202
213
 
203
- it('refuses one on a session-only route', () => {
214
+ it('refuses one on a session-only route', async () => {
204
215
  const res = mockRes();
205
216
  const next = jest.fn();
206
217
  const req = mockReq({ headers: { authorization: `Bearer ${accessToken()}` } });
207
218
 
208
219
  // It verifies — same secret — so nothing but the type check stands between
209
220
  // an assistant and every route that has no scopes to be held to.
210
- authenticateToken(req, res, next);
221
+ await authenticateToken(req, res, next);
211
222
 
212
223
  expect(res.status).toHaveBeenCalledWith(403);
213
224
  expect(next).not.toHaveBeenCalled();
214
225
  expect(req.user).toBeUndefined();
215
226
  });
216
227
 
217
- it('treats one as no session at all on an optional-auth route', () => {
228
+ it('treats one as no session at all on an optional-auth route', async () => {
218
229
  const next = jest.fn();
219
230
  const req = mockReq({ headers: { authorization: `Bearer ${accessToken()}` } });
220
231
 
221
- optionalAuth(req, mockRes(), next);
232
+ await optionalAuth(req, mockRes(), next);
222
233
 
223
234
  // The route still answers, in its public form — the same as a stale cookie.
224
235
  expect(next).toHaveBeenCalled();
225
236
  expect(req.user).toBeUndefined();
226
237
  });
227
238
  });
239
+
240
+ describe('ended sessions', () => {
241
+ const ORIGINAL_ENV = process.env;
242
+ const payload: UserPayload = { id: '1', email: 'user@example.com', name: 'Test User', sid: 'a'.repeat(24) };
243
+
244
+ beforeEach(() => {
245
+ process.env = { ...ORIGINAL_ENV, JWT_SECRET: 'test-secret' };
246
+ overrideSessionCheckForTests(async () => false);
247
+ });
248
+
249
+ afterAll(() => {
250
+ process.env = ORIGINAL_ENV;
251
+ });
252
+
253
+ it('authenticateToken refuses a validly signed token whose session has ended', async () => {
254
+ const req = mockReq({ cookies: { access_token: jwt.sign(payload, 'test-secret') } });
255
+ const res = mockRes();
256
+ const next = jest.fn();
257
+
258
+ await authenticateToken(req, res, next);
259
+
260
+ expect(res.status).toHaveBeenCalledWith(401);
261
+ expect(res.json).toHaveBeenCalledWith({ success: false, message: 'Session has ended' });
262
+ expect(next).not.toHaveBeenCalled();
263
+ expect(req.user).toBeUndefined();
264
+ });
265
+
266
+ it('optionalAuth treats an ended session as no session', async () => {
267
+ const req = mockReq({ cookies: { access_token: jwt.sign(payload, 'test-secret') } });
268
+ const next = jest.fn();
269
+
270
+ await optionalAuth(req, mockRes(), next);
271
+
272
+ expect(next).toHaveBeenCalledTimes(1);
273
+ expect(req.user).toBeUndefined();
274
+ });
275
+ });
@@ -3,6 +3,8 @@ import jwt from 'jsonwebtoken';
3
3
  import { requireEnv } from '../config/env';
4
4
  import { isAccessTokenClaims } from '../oauth/tokens';
5
5
  import { UserPayload } from '../types/auth';
6
+ import { isCurrentSession } from '../auth/session';
7
+ import { currentGroupsOf } from '../groups/membership';
6
8
 
7
9
  /**
8
10
  * JWT authentication middleware
@@ -17,7 +19,7 @@ import { UserPayload } from '../types/auth';
17
19
  * enforces both — so anything still guarded by this one is a person's to do,
18
20
  * and letting a token through would be handing an assistant the account.
19
21
  */
20
- export function authenticateToken(req: Request, res: Response, next: NextFunction): void {
22
+ export async function authenticateToken(req: Request, res: Response, next: NextFunction): Promise<void> {
21
23
  try {
22
24
  const JWT_SECRET = requireEnv('JWT_SECRET');
23
25
  const token = req.cookies?.access_token || req.headers.authorization?.replace('Bearer ', '');
@@ -36,13 +38,22 @@ export function authenticateToken(req: Request, res: Response, next: NextFunctio
36
38
  return;
37
39
  }
38
40
 
39
- const user = claims as UserPayload;
40
- req.user = user;
41
- req.userGroups = user.groups ?? [];
42
- next();
41
+ // A signed-out or revoked device keeps a validly signed token; only its
42
+ // session record says it has ended.
43
+ if (!(await isCurrentSession(claims))) {
44
+ res.status(401).json({ success: false, message: 'Session has ended' });
45
+ return;
46
+ }
47
+ // Membership is asked of group-service, not read from the token, so a
48
+ // change applies within seconds rather than at the next renewal.
49
+ const groups = await currentGroupsOf(claims as UserPayload);
50
+ req.user = { ...(claims as UserPayload), groups };
51
+ req.userGroups = groups;
43
52
  } catch (error) {
44
53
  res.status(401).json({ success: false, message: 'Invalid or expired token' });
54
+ return;
45
55
  }
56
+ next();
46
57
  }
47
58
  /**
48
59
  * Attaches the user when a valid token is present, and never rejects.
@@ -57,7 +68,7 @@ export function authenticateToken(req: Request, res: Response, next: NextFunctio
57
68
  * around would break the landing page for exactly the people most likely to
58
69
  * have one.
59
70
  */
60
- export function optionalAuth(req: Request, _res: Response, next: NextFunction): void {
71
+ export async function optionalAuth(req: Request, _res: Response, next: NextFunction): Promise<void> {
61
72
  try {
62
73
  const JWT_SECRET = requireEnv('JWT_SECRET');
63
74
  const token = req.cookies?.access_token || req.headers.authorization?.replace('Bearer ', '');
@@ -66,10 +77,11 @@ export function optionalAuth(req: Request, _res: Response, next: NextFunction):
66
77
  const claims = jwt.verify(token, JWT_SECRET);
67
78
  // An access token is not a session; treated as none, exactly like an
68
79
  // expired cookie, so the route still answers with its public form.
69
- if (!isAccessTokenClaims(claims)) {
70
- const user = claims as UserPayload;
71
- req.user = user;
72
- req.userGroups = user.groups ?? [];
80
+ // An ended session is treated as none, like an expired cookie.
81
+ if (!isAccessTokenClaims(claims) && (await isCurrentSession(claims))) {
82
+ const groups = await currentGroupsOf(claims as UserPayload);
83
+ req.user = { ...(claims as UserPayload), groups };
84
+ req.userGroups = groups;
73
85
  }
74
86
  }
75
87
  } catch {
@@ -0,0 +1,17 @@
1
+ import { overrideSessionCheckForTests } from '../auth/session';
2
+ import { overrideMembershipLookupForTests } from '../groups/membership';
3
+
4
+ /**
5
+ * Jest setup for service test suites: they sign session tokens without a
6
+ * session and run with no database or group-service, so every token's session
7
+ * counts as live and its `groups` claim stands in for group membership.
8
+ * The session check itself is tested in backend-core (auth/session.test.ts).
9
+ *
10
+ * Loaded through the package's test-only entry point:
11
+ * setupFiles: ['@tumbaland/backend-core/testing']
12
+ */
13
+ overrideSessionCheckForTests(async () => true);
14
+
15
+ // No group-service either: a test token's own `groups` claim is taken as the
16
+ // person's membership. The live lookup is tested in groups/membership.test.ts.
17
+ overrideMembershipLookupForTests(async (user) => user.groups ?? []);
package/src/types/auth.ts CHANGED
@@ -7,6 +7,9 @@ export interface UserPayload {
7
7
  picture?: string;
8
8
  groups?: string[];
9
9
  roles?: string[];
10
+ tokenVersion?: number;
11
+ /** The auth-service session (user_sessions) this token belongs to; absent on pre-sessions tokens. */
12
+ sid?: string;
10
13
  }
11
14
 
12
15
  /**
package/testing.js ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Test-only entry point: `@tumbaland/backend-core/testing`.
3
+ *
4
+ * Loading it makes every session token's session count as live, and a test
5
+ * token's `groups` claim its group membership, for service test suites that
6
+ * run with no database and no group-service.
7
+ * Kept out of the package's main exports so production code never reaches it.
8
+ * The session check itself is tested in backend-core (src/auth/session.test.ts).
9
+ */
10
+ require('./dist/testing/serviceTestSetup');