@tuturuuu/utils 0.29.0 → 0.29.1
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.
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.29.1](https://github.com/tutur3u/platform/compare/utils-v0.29.0...utils-v0.29.1) (2026-09-23)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **auth:** reuse completed session-bound challenges ([b63d510](https://github.com/tutur3u/platform/commit/b63d51046b427350d959137cf37058223e544788))
|
|
9
|
+
* **mobile:** repair Live persistence and reuse session verification ([#5436](https://github.com/tutur3u/platform/issues/5436)) ([cb452e1](https://github.com/tutur3u/platform/commit/cb452e1908eab3db2dce9dfd1d57d721284a2e49))
|
|
10
|
+
|
|
3
11
|
## [0.29.0](https://github.com/tutur3u/platform/compare/utils-v0.28.0...utils-v0.29.0) (2026-09-20)
|
|
4
12
|
|
|
5
13
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tuturuuu/utils",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "0.29.
|
|
4
|
+
"version": "0.29.1",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "https://github.com/tutur3u/platform",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"@tiptap/react": "3.31.3",
|
|
25
25
|
"@tuturuuu/google": "0.1.1",
|
|
26
26
|
"@tuturuuu/icons": "0.1.1",
|
|
27
|
-
"@tuturuuu/internal-api": "0.
|
|
27
|
+
"@tuturuuu/internal-api": "0.40.0",
|
|
28
28
|
"@tuturuuu/supabase": "0.5.2",
|
|
29
29
|
"@upstash/ratelimit": "^2.0.8",
|
|
30
30
|
"@upstash/redis": "^1.38.4",
|
|
@@ -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
|
-
|
|
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(
|
|
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'
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
|
|
8
8
|
describe('platform release metadata', () => {
|
|
9
9
|
it('uses the centralized shared browser app version', () => {
|
|
10
|
-
expect(TUTURUUU_PLATFORM_VERSION).toBe('0.
|
|
10
|
+
expect(TUTURUUU_PLATFORM_VERSION).toBe('0.53.0'); // x-release-please-version
|
|
11
11
|
});
|
|
12
12
|
|
|
13
13
|
it('preserves the source timestamp in builtAt and derives a short hash', () => {
|
package/src/platform-release.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { PLATFORM_BUILD_METADATA } from './generated/platform-build-metadata';
|
|
2
2
|
|
|
3
|
-
export const TUTURUUU_PLATFORM_VERSION = '0.
|
|
3
|
+
export const TUTURUUU_PLATFORM_VERSION = '0.53.0'; // x-release-please-version
|
|
4
4
|
|
|
5
5
|
export type PlatformBuildMetadataInput = {
|
|
6
6
|
/** ISO timestamp of the source commit used for the build, not deployment time. */
|