@tuturuuu/utils 0.29.0 → 0.30.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 (35) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/package.json +3 -3
  3. package/src/__tests__/ai-temp-auth.test.ts +32 -0
  4. package/src/__tests__/api-proxy-guard.test.ts +4 -5
  5. package/src/__tests__/app-url.test.ts +1 -0
  6. package/src/__tests__/required-mfa-api-session.test.ts +75 -0
  7. package/src/__tests__/required-mfa-browser.test.ts +73 -0
  8. package/src/__tests__/required-mfa-lineage.test.ts +40 -0
  9. package/src/__tests__/required-mfa-proxy.test.ts +105 -0
  10. package/src/__tests__/required-mfa-request.test.ts +342 -0
  11. package/src/__tests__/required-mfa-runtime.test.ts +109 -0
  12. package/src/__tests__/required-mfa-transfer.test.ts +29 -0
  13. package/src/abuse-protection/__tests__/reputation.test.ts +107 -0
  14. package/src/abuse-protection/reputation.ts +42 -3
  15. package/src/ai-temp-auth.ts +11 -0
  16. package/src/api-proxy-guard.ts +6 -6
  17. package/src/app-coordination-token.ts +247 -0
  18. package/src/commercial-comparison.ts +7 -0
  19. package/src/internal-domains.ts +3 -0
  20. package/src/launchable-apps.ts +12 -0
  21. package/src/onboarding.ts +6 -0
  22. package/src/platform-release.test.ts +1 -1
  23. package/src/platform-release.ts +1 -1
  24. package/src/portless.ts +1 -0
  25. package/src/required-mfa-api-session.ts +30 -0
  26. package/src/required-mfa-app-session.ts +34 -0
  27. package/src/required-mfa-browser.ts +42 -0
  28. package/src/required-mfa-credentials.ts +58 -0
  29. package/src/required-mfa-lineage.ts +22 -0
  30. package/src/required-mfa-policy.ts +188 -0
  31. package/src/required-mfa-proxy.ts +87 -0
  32. package/src/required-mfa-request.ts +147 -0
  33. package/src/required-mfa-runtime.ts +103 -0
  34. package/src/required-mfa-supabase-session.ts +33 -0
  35. package/src/required-mfa-transfer.ts +41 -0
@@ -0,0 +1,342 @@
1
+ // @vitest-environment node
2
+ import type { TypedSupabaseClient } from '@tuturuuu/supabase/types';
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { createAppCoordinationToken } from '../app-coordination-token';
5
+ import { checkRequiredAccountMfa } from '../required-mfa-request';
6
+
7
+ const now = Math.floor(Date.now() / 1000);
8
+ const policy = {
9
+ tuturuuu_required_mfa: { required: true, verifiedAfter: now - 30 },
10
+ };
11
+ const user = { id: 'actor', app_metadata: policy };
12
+ function fixture() {
13
+ const query: Record<string, ReturnType<typeof vi.fn>> = {};
14
+ for (const method of ['select', 'eq', 'not', 'order', 'limit'])
15
+ query[method] = vi.fn(() => query);
16
+ query.maybeSingle = vi.fn().mockResolvedValue({ data: null, error: null });
17
+ const getUser = vi.fn().mockResolvedValue({ data: { user }, error: null });
18
+ const getClaims = vi.fn().mockResolvedValue({
19
+ data: {
20
+ claims: {
21
+ sub: 'actor',
22
+ aal: 'aal2',
23
+ session_id: 'session',
24
+ amr: [{ method: 'totp', timestamp: now - 10 }],
25
+ },
26
+ },
27
+ error: null,
28
+ });
29
+ const getUserById = vi
30
+ .fn()
31
+ .mockResolvedValue({ data: { user }, error: null });
32
+ const from = vi.fn(() => query);
33
+ const dependencies = {
34
+ createUserClient: vi.fn().mockResolvedValue({
35
+ auth: { getUser, getClaims },
36
+ } as unknown as TypedSupabaseClient),
37
+ createAdminClient: vi.fn().mockResolvedValue({
38
+ auth: { admin: { getUserById } },
39
+ from,
40
+ rpc: vi.fn().mockResolvedValue({ data: 'factor', error: null }),
41
+ } as unknown as TypedSupabaseClient),
42
+ nowSeconds: () => now,
43
+ };
44
+ const request = new Request('https://tuturuuu.com/api/v1/workspaces', {
45
+ headers: { authorization: 'Bearer supabase-session' },
46
+ });
47
+ return {
48
+ dependencies,
49
+ request,
50
+ getUser,
51
+ getClaims,
52
+ getUserById,
53
+ query,
54
+ from,
55
+ };
56
+ }
57
+
58
+ beforeEach(() =>
59
+ vi.stubEnv('TUTURUUU_APP_COORDINATION_SECRET', 'required-mfa-test-secret')
60
+ );
61
+
62
+ describe('fresh account assurance', () => {
63
+ it('allows current provider-verified MFA', async () => {
64
+ const f = fixture();
65
+ expect(
66
+ await checkRequiredAccountMfa(f.request, f.dependencies, {
67
+ userId: 'actor',
68
+ })
69
+ ).toEqual({
70
+ status: 'allowed',
71
+ userId: 'actor',
72
+ proof: { sessionId: 'session', verifiedAt: now - 10, factorId: 'factor' },
73
+ });
74
+ expect(f.dependencies.createAdminClient).toHaveBeenCalled();
75
+ });
76
+
77
+ it('rejects assurance for a different resolved route principal', async () => {
78
+ const f = fixture();
79
+ expect(
80
+ await checkRequiredAccountMfa(f.request, f.dependencies, {
81
+ userId: 'another-actor',
82
+ })
83
+ ).toEqual({ status: 'invalid' });
84
+ });
85
+
86
+ it('does not fetch claims for an account without the policy', async () => {
87
+ const f = fixture();
88
+ f.getUser.mockResolvedValue({
89
+ data: { user: { ...user, app_metadata: {} } },
90
+ error: null,
91
+ });
92
+ expect(
93
+ (
94
+ await checkRequiredAccountMfa(f.request, f.dependencies, {
95
+ userId: 'actor',
96
+ })
97
+ ).status
98
+ ).toBe('allowed');
99
+ expect(f.getClaims).not.toHaveBeenCalled();
100
+ });
101
+
102
+ it('ignores user-controlled metadata', async () => {
103
+ const f = fixture();
104
+ f.getUser.mockResolvedValue({
105
+ data: {
106
+ user: {
107
+ ...user,
108
+ user_metadata: { tuturuuu_required_mfa: { required: false } },
109
+ },
110
+ },
111
+ error: null,
112
+ });
113
+ f.getClaims.mockResolvedValue({
114
+ data: { claims: { sub: 'actor', aal: 'aal1', session_id: 'session' } },
115
+ error: null,
116
+ });
117
+ expect(
118
+ (
119
+ await checkRequiredAccountMfa(f.request, f.dependencies, {
120
+ userId: 'actor',
121
+ })
122
+ ).status
123
+ ).toBe('required');
124
+ });
125
+
126
+ it.each([
127
+ { aal: 'aal1', amr: [{ method: 'password', timestamp: now }] },
128
+ { aal: 'aal2', amr: [{ method: 'totp', timestamp: now - 30 }] },
129
+ { aal: 'aal2', amr: [{ method: 'totp', timestamp: now + 10 }] },
130
+ { aal: 'aal2', amr: [{ method: 'totp', timestamp: now - 100 }], iat: now },
131
+ ])('rejects stale, absent and future proof %j', async (claims) => {
132
+ const f = fixture();
133
+ f.getClaims.mockResolvedValue({
134
+ data: { claims: { sub: 'actor', session_id: 'session', ...claims } },
135
+ error: null,
136
+ });
137
+ expect(
138
+ (
139
+ await checkRequiredAccountMfa(f.request, f.dependencies, {
140
+ userId: 'actor',
141
+ })
142
+ ).status
143
+ ).toBe('required');
144
+ });
145
+
146
+ it('rejects claims for a different actor', async () => {
147
+ const f = fixture();
148
+ f.getClaims.mockResolvedValue({
149
+ data: { claims: { sub: 'another-user' } },
150
+ error: null,
151
+ });
152
+ expect(
153
+ (
154
+ await checkRequiredAccountMfa(f.request, f.dependencies, {
155
+ userId: 'actor',
156
+ })
157
+ ).status
158
+ ).toBe('invalid');
159
+ });
160
+
161
+ it('fails closed when fresh identity lookup throws', async () => {
162
+ const f = fixture();
163
+ f.getUser.mockRejectedValue(new Error('offline'));
164
+ expect(
165
+ (
166
+ await checkRequiredAccountMfa(f.request, f.dependencies, {
167
+ userId: 'actor',
168
+ })
169
+ ).status
170
+ ).toBe('unavailable');
171
+ });
172
+
173
+ it('rechecks changed policy for an already signed app token', async () => {
174
+ const f = fixture();
175
+ const { token } = createAppCoordinationToken({
176
+ userId: 'actor',
177
+ targetApp: 'mail',
178
+ mfa: { factorId: 'factor', sessionId: 'session', verifiedAt: now - 10 },
179
+ });
180
+ const request = new Request(f.request.url, {
181
+ headers: { authorization: `Bearer ${token}` },
182
+ });
183
+ expect(
184
+ (
185
+ await checkRequiredAccountMfa(request, f.dependencies, {
186
+ userId: 'actor',
187
+ appToken: token,
188
+ })
189
+ ).status
190
+ ).toBe('allowed');
191
+ f.getUserById.mockResolvedValue({
192
+ data: {
193
+ user: {
194
+ ...user,
195
+ app_metadata: {
196
+ tuturuuu_required_mfa: { required: true, verifiedAfter: now },
197
+ },
198
+ },
199
+ },
200
+ error: null,
201
+ });
202
+ expect(
203
+ (
204
+ await checkRequiredAccountMfa(request, f.dependencies, {
205
+ userId: 'actor',
206
+ appToken: token,
207
+ })
208
+ ).status
209
+ ).toBe('required');
210
+ expect(f.getUserById).toHaveBeenCalledTimes(2);
211
+ expect(f.from).not.toHaveBeenCalled();
212
+ expect(f.dependencies.createUserClient).not.toHaveBeenCalled();
213
+ });
214
+
215
+ it('does not accept an app token with no MFA evidence', async () => {
216
+ const f = fixture();
217
+ const { token } = createAppCoordinationToken({
218
+ userId: 'actor',
219
+ targetApp: 'mail',
220
+ });
221
+ const request = new Request(f.request.url, {
222
+ headers: { cookie: `tuturuuu_app_session=${token}` },
223
+ });
224
+ expect(
225
+ (
226
+ await checkRequiredAccountMfa(request, f.dependencies, {
227
+ userId: 'actor',
228
+ appToken: token,
229
+ })
230
+ ).status
231
+ ).toBe('required');
232
+ });
233
+
234
+ it('does not trust an invalid app token', async () => {
235
+ const f = fixture();
236
+ const token = 'ttr_app_invalid';
237
+ const request = new Request(f.request.url, {
238
+ headers: { authorization: `Bearer ${token}` },
239
+ });
240
+ expect(
241
+ (
242
+ await checkRequiredAccountMfa(request, f.dependencies, {
243
+ userId: 'actor',
244
+ appToken: token,
245
+ })
246
+ ).status
247
+ ).toBe('invalid');
248
+ expect(f.dependencies.createAdminClient).not.toHaveBeenCalled();
249
+ });
250
+
251
+ it('scopes mobile approval lookup to the exact actor and session', async () => {
252
+ const f = fixture();
253
+ f.getClaims.mockResolvedValue({
254
+ data: { claims: { sub: 'actor', aal: 'aal1', session_id: 'session' } },
255
+ error: null,
256
+ });
257
+ f.query.maybeSingle!.mockResolvedValue({
258
+ data: {
259
+ approved_at: new Date((now - 5) * 1000).toISOString(),
260
+ approval_metadata: {
261
+ requiredMfaProof: {
262
+ sessionId: 'source-session',
263
+ factorId: 'factor',
264
+ verifiedAt: now - 5,
265
+ },
266
+ mobileMfaValidUntil: new Date((now + 60) * 1000).toISOString(),
267
+ },
268
+ },
269
+ error: null,
270
+ });
271
+ expect(
272
+ (
273
+ await checkRequiredAccountMfa(f.request, f.dependencies, {
274
+ userId: 'actor',
275
+ })
276
+ ).status
277
+ ).toBe('allowed');
278
+ expect(f.query.eq).toHaveBeenCalledWith('approver_user_id', 'actor');
279
+ expect(f.query.eq).toHaveBeenCalledWith(
280
+ 'request_metadata->>requesterSessionId',
281
+ 'session'
282
+ );
283
+ expect(f.query.eq).toHaveBeenCalledWith(
284
+ 'approval_metadata->>approverSessionId',
285
+ 'session'
286
+ );
287
+ expect(f.query.not).toHaveBeenCalledWith('consumed_at', 'is', null);
288
+ });
289
+
290
+ it.each(['invalid', new Date((now - 1) * 1000).toISOString()])(
291
+ 'rejects unusable mobile approval expiry %s',
292
+ async (expiry) => {
293
+ const f = fixture();
294
+ f.getClaims.mockResolvedValue({
295
+ data: { claims: { sub: 'actor', aal: 'aal1', session_id: 'session' } },
296
+ error: null,
297
+ });
298
+ f.query.maybeSingle!.mockResolvedValue({
299
+ data: {
300
+ approved_at: new Date((now - 5) * 1000).toISOString(),
301
+ approval_metadata: { mobileMfaValidUntil: expiry },
302
+ },
303
+ error: null,
304
+ });
305
+ expect(
306
+ (
307
+ await checkRequiredAccountMfa(f.request, f.dependencies, {
308
+ userId: 'actor',
309
+ })
310
+ ).status
311
+ ).toBe('required');
312
+ }
313
+ );
314
+ it('keeps mobile proof precision consistent with signed integer timestamps', async () => {
315
+ const f = fixture();
316
+ f.getClaims.mockResolvedValue({
317
+ data: { claims: { sub: 'actor', aal: 'aal1', session_id: 'session' } },
318
+ error: null,
319
+ });
320
+ f.query.maybeSingle!.mockResolvedValue({
321
+ data: {
322
+ approved_at: new Date((now - 30) * 1000 + 500).toISOString(),
323
+ approval_metadata: {
324
+ requiredMfaProof: {
325
+ sessionId: 'source-session',
326
+ factorId: 'factor',
327
+ verifiedAt: now - 5,
328
+ },
329
+ mobileMfaValidUntil: new Date((now + 60) * 1000).toISOString(),
330
+ },
331
+ },
332
+ error: null,
333
+ });
334
+ expect(
335
+ (
336
+ await checkRequiredAccountMfa(f.request, f.dependencies, {
337
+ userId: 'actor',
338
+ })
339
+ ).status
340
+ ).toBe('required');
341
+ });
342
+ });
@@ -0,0 +1,109 @@
1
+ // @vitest-environment node
2
+ import { NextRequest } from 'next/server';
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { createAppCoordinationToken } from '../app-coordination-token';
5
+ import { enforceRequiredMfaRequest } from '../required-mfa-runtime';
6
+
7
+ const mocks = vi.hoisted(() => ({ admin: vi.fn() }));
8
+ vi.mock('@tuturuuu/supabase/next/server', () => ({
9
+ createAdminClient: mocks.admin,
10
+ }));
11
+ const now = Math.floor(Date.now() / 1000);
12
+ function setup() {
13
+ const user = {
14
+ id: 'actor',
15
+ app_metadata: {
16
+ tuturuuu_required_mfa: { required: true, verifiedAfter: now - 30 },
17
+ },
18
+ };
19
+ const getUserById = vi.fn(async (id: string) => ({
20
+ data: { user: id === 'actor' ? user : { id, app_metadata: {} } },
21
+ error: null,
22
+ }));
23
+ const getUser = vi.fn(async () => ({ data: { user }, error: null }));
24
+ const getClaims = vi.fn(async () => ({
25
+ data: { claims: { sub: 'actor', aal: 'aal1' } },
26
+ error: null,
27
+ }));
28
+ mocks.admin.mockResolvedValue({
29
+ rpc: async () => ({ data: 'factor', error: null }),
30
+ auth: { getUser, getClaims, admin: { getUserById } },
31
+ });
32
+ return { getUser, getClaims, getUserById };
33
+ }
34
+ function request(headers: HeadersInit, path = '/api/v1/workspaces') {
35
+ return new NextRequest(`https://tuturuuu.com${path}`, { headers });
36
+ }
37
+ beforeEach(() => {
38
+ vi.clearAllMocks();
39
+ vi.stubEnv('TUTURUUU_APP_COORDINATION_SECRET', 'test-required-mfa-secret');
40
+ });
41
+ describe('connected required MFA request enforcement', () => {
42
+ it('denies provider-authenticated AAL1 sessions with fresh required policy', async () => {
43
+ setup();
44
+ const response = await enforceRequiredMfaRequest(
45
+ request({ authorization: 'Bearer eyJprovider' })
46
+ );
47
+ expect(response?.status).toBe(403);
48
+ expect(await response?.json()).toMatchObject({ code: 'MFA_REQUIRED' });
49
+ });
50
+ it('does not borrow assurance from another cookie identity', async () => {
51
+ setup();
52
+ const optional = createAppCoordinationToken({
53
+ userId: 'optional',
54
+ targetApp: 'web',
55
+ }).token;
56
+ const required = createAppCoordinationToken({
57
+ userId: 'actor',
58
+ targetApp: 'infra',
59
+ }).token;
60
+ const response = await enforceRequiredMfaRequest(
61
+ request({
62
+ cookie: `tuturuuu_web_app_session=${optional}; tuturuuu_app_session=${required}`,
63
+ })
64
+ );
65
+ expect(response?.status).toBe(403);
66
+ });
67
+ it('accepts a signed proof after the recovery boundary', async () => {
68
+ setup();
69
+ const token = createAppCoordinationToken({
70
+ userId: 'actor',
71
+ targetApp: 'infra',
72
+ mfa: { factorId: 'factor', sessionId: 'session', verifiedAt: now - 5 },
73
+ }).token;
74
+ expect(
75
+ await enforceRequiredMfaRequest(
76
+ request({ authorization: `Bearer ${token}` })
77
+ )
78
+ ).toBeNull();
79
+ });
80
+ it('fails closed when current policy is unavailable', async () => {
81
+ mocks.admin.mockRejectedValue(new Error('offline'));
82
+ expect(
83
+ (
84
+ await enforceRequiredMfaRequest(
85
+ request({ authorization: 'Bearer eyJprovider' })
86
+ )
87
+ )?.status
88
+ ).toBe(503);
89
+ });
90
+ it('leaves independently authenticated machine requests to their own authentication', async () => {
91
+ expect(
92
+ await enforceRequiredMfaRequest(
93
+ request({ authorization: 'Bearer machine-key' })
94
+ )
95
+ ).toBeNull();
96
+ expect(mocks.admin).not.toHaveBeenCalled();
97
+ });
98
+ it('leaves recovery requests reachable for their own challenge validation', async () => {
99
+ expect(
100
+ await enforceRequiredMfaRequest(
101
+ request(
102
+ { authorization: 'Bearer eyJprovider' },
103
+ '/api/v1/auth/mfa/devices'
104
+ )
105
+ )
106
+ ).toBeNull();
107
+ expect(mocks.admin).not.toHaveBeenCalled();
108
+ });
109
+ });
@@ -0,0 +1,29 @@
1
+ // @vitest-environment node
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { createAppCoordinationToken } from '../app-coordination-token';
4
+ import { createMfaTransfer, readMfaTransfer } from '../required-mfa-transfer';
5
+
6
+ const proof = {
7
+ sessionId: 'session',
8
+ verifiedAt: Math.floor(Date.now() / 1000) - 5,
9
+ };
10
+ beforeEach(() =>
11
+ vi.stubEnv('TUTURUUU_APP_COORDINATION_SECRET', 'test-transfer-secret')
12
+ );
13
+ describe('MFA proof transfer through caller-controlled metadata', () => {
14
+ it('accepts only a signed proof for the exact user and target', () => {
15
+ const token = createMfaTransfer('actor', 'mail', proof);
16
+ expect(readMfaTransfer(token, 'actor', 'mail')).toEqual(proof);
17
+ expect(readMfaTransfer(token, 'victim', 'mail')).toBeNull();
18
+ expect(readMfaTransfer(token, 'actor', 'infra')).toBeNull();
19
+ });
20
+ it('rejects raw metadata and tokens without the transfer scope', () => {
21
+ expect(readMfaTransfer(proof, 'actor', 'mail')).toBeNull();
22
+ const token = createAppCoordinationToken({
23
+ userId: 'actor',
24
+ targetApp: 'mail',
25
+ mfa: proof,
26
+ }).token;
27
+ expect(readMfaTransfer(token, 'actor', 'mail')).toBeNull();
28
+ });
29
+ });
@@ -1,8 +1,10 @@
1
1
  import { beforeEach, describe, expect, it, vi } from 'vitest';
2
2
 
3
3
  const mockRpc = vi.fn();
4
+ const mockFrom = vi.fn();
4
5
  const mockCreateAdminClient = vi.fn(() => ({
5
6
  rpc: mockRpc,
7
+ from: mockFrom,
6
8
  }));
7
9
 
8
10
  vi.mock('@tuturuuu/supabase/next/server', () => ({
@@ -28,6 +30,111 @@ describe('adaptive abuse reputation', () => {
28
30
  mockRpc.mockResolvedValue({ data: [], error: null });
29
31
  });
30
32
 
33
+ it('binds native subjects to authenticated bearer credentials, never a raw token or an anonymous caller', () => {
34
+ const subjects = (token: string, userId?: string) =>
35
+ buildAbuseRiskSubjects({
36
+ headers: { authorization: `Bearer ${token}` },
37
+ userId,
38
+ });
39
+ expect(subjects('secret', 'caller')).toContainEqual(
40
+ expect.objectContaining({ subject_type: 'session' })
41
+ );
42
+ expect(JSON.stringify(subjects('secret', 'caller'))).not.toContain(
43
+ 'secret'
44
+ );
45
+ expect(subjects('secret', 'caller')).not.toEqual(
46
+ subjects('rotated', 'caller')
47
+ );
48
+ expect(subjects('secret')).toEqual([]);
49
+ });
50
+
51
+ it.each([true, false])(
52
+ 'reuses only a recent passed challenge for this authenticated session: %s',
53
+ async (passed) => {
54
+ const lookup = {
55
+ select: vi.fn().mockReturnThis(),
56
+ eq: vi.fn().mockReturnThis(),
57
+ gt: vi.fn().mockReturnThis(),
58
+ gte: vi.fn().mockReturnThis(),
59
+ order: vi.fn().mockReturnThis(),
60
+ limit: vi.fn().mockResolvedValue({
61
+ data: passed ? [{ id: 'proof' }] : [],
62
+ error: null,
63
+ }),
64
+ };
65
+ mockFrom.mockReturnValue(lookup);
66
+ const decision = await resolveAbuseRiskDecision({
67
+ authKind: 'session',
68
+ headers: {
69
+ authorization: 'Bearer session-token',
70
+ 'user-agent': 'Dart/3.9 (dart:io)',
71
+ },
72
+ isRead: false,
73
+ method: 'POST',
74
+ route: '/tasks',
75
+ userId: 'caller',
76
+ });
77
+ expect(decision.tier).toBe(passed ? 'standard' : 'challenge_required');
78
+ expect(decision.trustMultiplier).toBe(1);
79
+ expect(lookup.eq).toHaveBeenCalledWith('user_id', 'caller');
80
+ expect(lookup.eq).toHaveBeenCalledWith(
81
+ 'subject_key',
82
+ decision.subjects.find((subject) => subject.subject_type === 'session')
83
+ ?.subject_key
84
+ );
85
+ expect(lookup.eq).toHaveBeenCalledWith('status', 'passed');
86
+ expect(lookup.gt).toHaveBeenCalledWith(
87
+ 'expires_at',
88
+ '2026-05-17T00:00:00.000Z'
89
+ );
90
+ expect(lookup.gte).toHaveBeenCalledWith(
91
+ 'completed_at',
92
+ '2026-05-16T23:45:00.000Z'
93
+ );
94
+ }
95
+ );
96
+
97
+ it.each(['restricted', 'challenge_required'])(
98
+ 'never overrides explicit server policy: %s',
99
+ async (tier) => {
100
+ mockRpc.mockResolvedValue({
101
+ data: [{ tier, trust_multiplier: 1, decision_source: 'override' }],
102
+ error: null,
103
+ });
104
+ const decision = await resolveAbuseRiskDecision({
105
+ authKind: 'session',
106
+ headers: {
107
+ authorization: 'Bearer session-token',
108
+ 'user-agent': 'Dart/3.9 (dart:io)',
109
+ },
110
+ isRead: false,
111
+ method: 'POST',
112
+ route: '/tasks',
113
+ userId: 'caller',
114
+ });
115
+ expect(decision.tier).toBe(tier);
116
+ expect(mockFrom).not.toHaveBeenCalled();
117
+ }
118
+ );
119
+
120
+ it('fails closed when challenge storage is unavailable', async () => {
121
+ mockFrom.mockImplementation(() => {
122
+ throw new Error('offline');
123
+ });
124
+ const decision = await resolveAbuseRiskDecision({
125
+ authKind: 'session',
126
+ headers: {
127
+ authorization: 'Bearer session-token',
128
+ 'user-agent': 'Dart/3.9 (dart:io)',
129
+ },
130
+ isRead: false,
131
+ method: 'POST',
132
+ route: '/tasks',
133
+ userId: 'caller',
134
+ });
135
+ expect(decision.tier).toBe('challenge_required');
136
+ });
137
+
31
138
  it('does not earn trust from old account and browser shape alone', async () => {
32
139
  const decision = await resolveAbuseRiskDecision({
33
140
  authKind: 'session',
@@ -192,6 +192,9 @@ function readHeader(
192
192
  function getSessionSubjectKey(
193
193
  headers: Headers | Map<string, string> | Record<string, string | null>
194
194
  ) {
195
+ const authorization = readHeader(headers, 'authorization');
196
+ const bearer = authorization?.match(/^Bearer ([^\s]+)$/i)?.[1];
197
+ if (bearer) return `session:${hashStableSubject(`bearer:${bearer}`)}`;
195
198
  const cookies = parseCookieHeader(readHeader(headers, 'cookie'));
196
199
  const authCookie = cookies.find(
197
200
  (cookie) =>
@@ -304,7 +307,7 @@ export function buildAbuseRiskSubjects({
304
307
  subjects.push({ subject_type: 'user', subject_key: `user:${userId}` });
305
308
  }
306
309
 
307
- if (sessionKey) {
310
+ if (sessionKey && userId && !apiKeyId) {
308
311
  subjects.push({ subject_type: 'session', subject_key: sessionKey });
309
312
  }
310
313
 
@@ -365,6 +368,34 @@ async function loadServerTrustDecision({
365
368
  }
366
369
  }
367
370
 
371
+ async function hasRecentSessionChallenge(
372
+ userId: string | null | undefined,
373
+ subjects: AbuseRiskSubject[]
374
+ ) {
375
+ const session = subjects.find(
376
+ (subject) => subject.subject_type === 'session'
377
+ );
378
+ if (!userId || !session) return false;
379
+ const supabase = await getSupabaseAdmin();
380
+ if (!supabase) return false;
381
+ try {
382
+ const now = new Date();
383
+ const { data, error } = await supabase
384
+ .from('abuse_step_up_challenges')
385
+ .select('id')
386
+ .eq('user_id', userId)
387
+ .eq('subject_key', session.subject_key)
388
+ .eq('status', 'passed')
389
+ .gt('expires_at', now.toISOString())
390
+ .gte('completed_at', new Date(now.getTime() - 15 * 60_000).toISOString())
391
+ .order('completed_at', { ascending: false })
392
+ .limit(1);
393
+ return !error && Boolean(data?.length);
394
+ } catch {
395
+ return false;
396
+ }
397
+ }
398
+
368
399
  export async function resolveAbuseRiskDecision(
369
400
  input: ResolveAbuseRiskDecisionInput
370
401
  ): Promise<AbuseRiskDecision> {
@@ -432,11 +463,19 @@ export async function resolveAbuseRiskDecision(
432
463
  (!likelyBrowser || userAgentClassification.riskLevel === 'block');
433
464
 
434
465
  if (suspiciousBrowserMutation && tier !== 'restricted') {
435
- tier = 'challenge_required';
466
+ // A completed session-bound challenge satisfies the browser-shape
467
+ // heuristic only. Server restrictions and explicit challenges still win.
468
+ const cleared =
469
+ serverDecision?.tier !== 'challenge_required' &&
470
+ (input.authKind === 'session' || input.authKind === 'app-session') &&
471
+ (await hasRecentSessionChallenge(input.userId, subjects));
472
+ tier = cleared ? 'standard' : 'challenge_required';
436
473
  trustMultiplier = 1;
437
474
  decisionSource = 'heuristic';
438
475
  subjectKey ??= subjects[0]?.subject_key ?? null;
439
- reasons.push('suspicious_browser_mutation');
476
+ reasons.push(
477
+ cleared ? 'session_challenge_passed' : 'suspicious_browser_mutation'
478
+ );
440
479
  } else if (
441
480
  tier === 'trusted' &&
442
481
  userAgentClassification.riskLevel === 'block'