@tuturuuu/utils 0.29.1 → 0.31.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 (33) hide show
  1. package/CHANGELOG.md +28 -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/ai-temp-auth.ts +11 -0
  14. package/src/api-proxy-guard.ts +6 -6
  15. package/src/app-coordination-token.ts +247 -0
  16. package/src/commercial-comparison.ts +7 -0
  17. package/src/internal-domains.ts +3 -0
  18. package/src/launchable-apps.ts +12 -0
  19. package/src/onboarding.ts +6 -0
  20. package/src/platform-release.test.ts +1 -1
  21. package/src/platform-release.ts +1 -1
  22. package/src/portless.ts +1 -0
  23. package/src/required-mfa-api-session.ts +30 -0
  24. package/src/required-mfa-app-session.ts +34 -0
  25. package/src/required-mfa-browser.ts +42 -0
  26. package/src/required-mfa-credentials.ts +58 -0
  27. package/src/required-mfa-lineage.ts +22 -0
  28. package/src/required-mfa-policy.ts +188 -0
  29. package/src/required-mfa-proxy.ts +87 -0
  30. package/src/required-mfa-request.ts +147 -0
  31. package/src/required-mfa-runtime.ts +103 -0
  32. package/src/required-mfa-supabase-session.ts +33 -0
  33. package/src/required-mfa-transfer.ts +41 -0
@@ -0,0 +1,247 @@
1
+ import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
2
+
3
+ import { isMfaSessionProof, type MfaSessionProof } from './required-mfa-policy';
4
+
5
+ const TOKEN_PREFIX = 'ttr_app_';
6
+ const TOKEN_ISSUER = 'tuturuuu';
7
+ const TOKEN_AUDIENCE = 'tuturuuu-api';
8
+ const DEFAULT_EXPIRES_IN_SECONDS = 8 * 60 * 60;
9
+ const LOCAL_DEVELOPMENT_SECRET =
10
+ 'tuturuuu-local-development-app-coordination-secret';
11
+
12
+ export type AppCoordinationTokenClaims = {
13
+ aud: typeof TOKEN_AUDIENCE;
14
+ email: string | null;
15
+ exp: number;
16
+ iat: number;
17
+ iss: typeof TOKEN_ISSUER;
18
+ jti: string;
19
+ mfa?: MfaSessionProof;
20
+ origin_app: string;
21
+ scopes: string[];
22
+ sub: string;
23
+ target_app: string;
24
+ typ: 'app_coordination';
25
+ };
26
+
27
+ export type AppCoordinationTokenPayload = {
28
+ email?: string | null;
29
+ expiresInSeconds?: number;
30
+ mfa?: MfaSessionProof;
31
+ originApp?: string;
32
+ scopes?: string[];
33
+ targetApp: string;
34
+ userId: string;
35
+ };
36
+
37
+ export type AppCoordinationTokenVerification =
38
+ | {
39
+ claims: AppCoordinationTokenClaims;
40
+ ok: true;
41
+ }
42
+ | {
43
+ error: string;
44
+ ok: false;
45
+ };
46
+
47
+ function getSecretCandidates(explicitSecret?: string) {
48
+ const candidates = explicitSecret
49
+ ? [explicitSecret]
50
+ : [
51
+ process.env.TUTURUUU_APP_COORDINATION_SECRET,
52
+ process.env.APP_COORDINATION_TOKEN_SECRET,
53
+ process.env.SUPABASE_SECRET_KEY,
54
+ process.env.SUPABASE_SERVICE_ROLE_KEY,
55
+ process.env.SUPABASE_SERVICE_KEY,
56
+ ];
57
+
58
+ const secrets = candidates
59
+ .map((value) => value?.trim())
60
+ .filter((value): value is string => Boolean(value));
61
+
62
+ if (secrets.length === 0 && process.env.NODE_ENV !== 'production') {
63
+ return [LOCAL_DEVELOPMENT_SECRET];
64
+ }
65
+
66
+ if (secrets.length === 0) {
67
+ throw new Error(
68
+ 'Missing TUTURUUU_APP_COORDINATION_SECRET or SUPABASE_SECRET_KEY'
69
+ );
70
+ }
71
+
72
+ return [...new Set(secrets)];
73
+ }
74
+
75
+ function getSigningSecret(explicitSecret?: string) {
76
+ return getSecretCandidates(explicitSecret)[0]!;
77
+ }
78
+
79
+ function getVerificationSecrets(explicitSecret?: string) {
80
+ return getSecretCandidates(explicitSecret);
81
+ }
82
+
83
+ function encodeBase64Url(value: string | Buffer) {
84
+ return Buffer.from(value).toString('base64url');
85
+ }
86
+
87
+ function decodeBase64Url(value: string) {
88
+ return Buffer.from(value, 'base64url').toString('utf8');
89
+ }
90
+
91
+ function signContent(content: string, secret: string) {
92
+ return createHmac('sha256', secret).update(content).digest('base64url');
93
+ }
94
+
95
+ function safeJsonParse(value: string): unknown {
96
+ try {
97
+ return JSON.parse(value) as unknown;
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ function isClaims(value: unknown): value is AppCoordinationTokenClaims {
104
+ if (!value || typeof value !== 'object') {
105
+ return false;
106
+ }
107
+
108
+ const claims = value as Partial<AppCoordinationTokenClaims>;
109
+
110
+ return (
111
+ (claims.mfa === undefined || isMfaSessionProof(claims.mfa)) &&
112
+ claims.typ === 'app_coordination' &&
113
+ claims.iss === TOKEN_ISSUER &&
114
+ claims.aud === TOKEN_AUDIENCE &&
115
+ typeof claims.sub === 'string' &&
116
+ typeof claims.target_app === 'string' &&
117
+ typeof claims.origin_app === 'string' &&
118
+ typeof claims.iat === 'number' &&
119
+ typeof claims.exp === 'number' &&
120
+ typeof claims.jti === 'string' &&
121
+ Array.isArray(claims.scopes)
122
+ );
123
+ }
124
+
125
+ export function isAppCoordinationToken(token: string | null | undefined) {
126
+ return Boolean(token?.startsWith(TOKEN_PREFIX));
127
+ }
128
+
129
+ export function getBearerAppCoordinationToken(
130
+ request: Pick<Request, 'headers'>
131
+ ) {
132
+ const authorization =
133
+ request.headers.get('authorization') ??
134
+ request.headers.get('Authorization');
135
+
136
+ if (!authorization?.toLowerCase().startsWith('bearer ')) {
137
+ return null;
138
+ }
139
+
140
+ const token = authorization.slice(7).trim();
141
+
142
+ return isAppCoordinationToken(token) ? token : null;
143
+ }
144
+
145
+ export function createAppCoordinationToken(
146
+ payload: AppCoordinationTokenPayload,
147
+ options: {
148
+ now?: Date;
149
+ secret?: string;
150
+ } = {}
151
+ ) {
152
+ if (payload.mfa !== undefined && !isMfaSessionProof(payload.mfa)) {
153
+ throw new Error('Invalid MFA session proof');
154
+ }
155
+ const secret = getSigningSecret(options.secret);
156
+ const nowSeconds = Math.floor((options.now ?? new Date()).getTime() / 1000);
157
+ const expiresInSeconds =
158
+ payload.expiresInSeconds ?? DEFAULT_EXPIRES_IN_SECONDS;
159
+ const header = {
160
+ alg: 'HS256',
161
+ typ: 'JWT',
162
+ };
163
+ const claims: AppCoordinationTokenClaims = {
164
+ aud: TOKEN_AUDIENCE,
165
+ email: payload.email ?? null,
166
+ exp: nowSeconds + expiresInSeconds,
167
+ iat: nowSeconds,
168
+ iss: TOKEN_ISSUER,
169
+ jti: randomUUID(),
170
+ ...(payload.mfa ? { mfa: payload.mfa } : {}),
171
+ origin_app: payload.originApp ?? 'web',
172
+ scopes: payload.scopes ?? [],
173
+ sub: payload.userId,
174
+ target_app: payload.targetApp,
175
+ typ: 'app_coordination',
176
+ };
177
+ const unsigned = `${encodeBase64Url(JSON.stringify(header))}.${encodeBase64Url(
178
+ JSON.stringify(claims)
179
+ )}`;
180
+ const signature = signContent(unsigned, secret);
181
+
182
+ return {
183
+ claims,
184
+ expiresAt: new Date(claims.exp * 1000).toISOString(),
185
+ token: `${TOKEN_PREFIX}${unsigned}.${signature}`,
186
+ };
187
+ }
188
+
189
+ export function verifyAppCoordinationToken(
190
+ token: string,
191
+ options: {
192
+ now?: Date;
193
+ secret?: string;
194
+ } = {}
195
+ ): AppCoordinationTokenVerification {
196
+ if (!isAppCoordinationToken(token)) {
197
+ return { error: 'Invalid token type', ok: false };
198
+ }
199
+
200
+ const withoutPrefix = token.slice(TOKEN_PREFIX.length);
201
+ const parts = withoutPrefix.split('.');
202
+
203
+ if (parts.length !== 3) {
204
+ return { error: 'Invalid token format', ok: false };
205
+ }
206
+
207
+ const [encodedHeader, encodedPayload, signature] = parts;
208
+
209
+ if (!encodedHeader || !encodedPayload || !signature) {
210
+ return { error: 'Invalid token format', ok: false };
211
+ }
212
+
213
+ const unsigned = `${encodedHeader}.${encodedPayload}`;
214
+ const signatureBuffer = Buffer.from(signature);
215
+ const signatureMatches = getVerificationSecrets(options.secret).some(
216
+ (secret) => {
217
+ const expectedSignature = signContent(unsigned, secret);
218
+ const expectedSignatureBuffer = Buffer.from(expectedSignature);
219
+
220
+ return (
221
+ signatureBuffer.length === expectedSignatureBuffer.length &&
222
+ timingSafeEqual(signatureBuffer, expectedSignatureBuffer)
223
+ );
224
+ }
225
+ );
226
+
227
+ if (!signatureMatches) {
228
+ return { error: 'Invalid token signature', ok: false };
229
+ }
230
+
231
+ const claims = safeJsonParse(decodeBase64Url(encodedPayload));
232
+
233
+ if (!isClaims(claims)) {
234
+ return { error: 'Invalid token claims', ok: false };
235
+ }
236
+
237
+ const nowSeconds = Math.floor((options.now ?? new Date()).getTime() / 1000);
238
+
239
+ if (claims.exp <= nowSeconds) {
240
+ return { error: 'Token expired', ok: false };
241
+ }
242
+
243
+ return {
244
+ claims,
245
+ ok: true,
246
+ };
247
+ }
@@ -9,6 +9,13 @@ import { features2 } from './commercial-comparison-2';
9
9
 
10
10
  const gateOrder = ['FREE', 'PLUS', 'PRO', 'ENTERPRISE'];
11
11
  const advanced: ComparisonFeature[] = [
12
+ {
13
+ id: 'parley_0',
14
+ app: 'parley',
15
+ category: 'internal',
16
+ detail: 'standard',
17
+ values: ['internal', 'internal', 'internal', 'internal'],
18
+ },
12
19
  {
13
20
  id: 'prioritySupport',
14
21
  app: 'platform',
@@ -4,6 +4,7 @@ import {
4
4
  } from './portless';
5
5
 
6
6
  export const PRODUCTION_INTERNAL_APP_DOMAINS = [
7
+ { name: 'parley', url: 'https://parley.tuturuuu.com' },
7
8
  { name: 'lettin', url: 'https://lettin.tuturuuu.com' },
8
9
  { name: 'colab', url: 'https://colab.tuturuuu.com' },
9
10
  {
@@ -133,6 +134,7 @@ export const PRODUCTION_INTERNAL_APP_DOMAINS = [
133
134
  ] as const;
134
135
 
135
136
  export const PORTLESS_INTERNAL_APP_DOMAINS = [
137
+ { name: 'parley', url: getTuturuuuPortlessAppOrigin('parley') },
136
138
  { name: 'lettin', url: getTuturuuuPortlessAppOrigin('lettin') },
137
139
  { name: 'colab', url: getTuturuuuPortlessAppOrigin('colab') },
138
140
  {
@@ -254,6 +256,7 @@ export const PORTLESS_INTERNAL_APP_DOMAINS = [
254
256
  ] as const;
255
257
 
256
258
  export const LOCALHOST_INTERNAL_APP_DOMAINS = [
259
+ { name: 'parley', url: 'http://localhost:7834' },
257
260
  { name: 'lettin', url: 'http://localhost:7833' },
258
261
  { name: 'colab', url: 'http://localhost:8795' },
259
262
  {
@@ -412,6 +412,18 @@ export const LAUNCHABLE_APPS = [
412
412
  title: '(Tu)lettin',
413
413
  workspacePathResolver: workspaceRootPath,
414
414
  },
415
+ {
416
+ aliases: ['Scenario training', 'Roleplay'],
417
+ appRoot: 'apps/parley',
418
+ category: 'ai',
419
+ defaultPath: '/',
420
+ localhostOrigin: 'http://localhost:7834',
421
+ packageName: '@tuturuuu/parley',
422
+ portlessApp: 'parley',
423
+ productionUrl: 'https://parley.tuturuuu.com',
424
+ slug: 'parley',
425
+ title: 'Parley',
426
+ },
415
427
  ] as const satisfies readonly LaunchableApp[];
416
428
 
417
429
  export type LaunchableAppSlug = (typeof LAUNCHABLE_APPS)[number]['slug'];
package/src/onboarding.ts CHANGED
@@ -71,6 +71,12 @@ export const GOAL_RECOMMENDATIONS = {
71
71
  } as const satisfies Record<OnboardingGoal, readonly string[]>;
72
72
 
73
73
  const MISSIONS = [
74
+ [
75
+ 'parley',
76
+ 'learn',
77
+ 'confirmation_required',
78
+ ['scenario', 'practice', 'reflect'],
79
+ ],
74
80
  ['colab', 'learn', 'reversible', ['prompt', 'simulate', 'review']],
75
81
  ['platform', 'focus', 'reversible', ['overview', 'apps', 'next_move']],
76
82
  ['calendar', 'focus', 'reversible', ['schedule', 'timebox', 'review']],
@@ -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.53.0'); // x-release-please-version
10
+ expect(TUTURUUU_PLATFORM_VERSION).toBe('0.55.0'); // x-release-please-version
11
11
  });
12
12
 
13
13
  it('preserves the source timestamp in builtAt and derives a short hash', () => {
@@ -1,6 +1,6 @@
1
1
  import { PLATFORM_BUILD_METADATA } from './generated/platform-build-metadata';
2
2
 
3
- export const TUTURUUU_PLATFORM_VERSION = '0.53.0'; // x-release-please-version
3
+ export const TUTURUUU_PLATFORM_VERSION = '0.55.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. */
package/src/portless.ts CHANGED
@@ -6,6 +6,7 @@ export const TUTURUUU_PORTLESS_ALLOWED_DEV_ORIGINS = [
6
6
  ] as const;
7
7
 
8
8
  export const TUTURUUU_PORTLESS_APP_ORIGINS = {
9
+ parley: `https://parley.${TUTURUUU_PORTLESS_ROOT_HOST}`,
9
10
  ai: `https://ai.${TUTURUUU_PORTLESS_ROOT_HOST}`,
10
11
  apps: `https://apps.${TUTURUUU_PORTLESS_ROOT_HOST}`,
11
12
  calendar: `https://calendar.${TUTURUUU_PORTLESS_ROOT_HOST}`,
@@ -0,0 +1,30 @@
1
+ import { updateSession } from '@tuturuuu/supabase/next/proxy';
2
+ import { type NextRequest, NextResponse } from 'next/server';
3
+ import { discardAppCookiesForVerifiedProvider } from './required-mfa-credentials';
4
+ import { isAccountAssuranceRecoveryPath } from './required-mfa-proxy';
5
+
6
+ /** Refresh browser provider cookies before checking assurance, and propagate
7
+ * rotated cookies even when policy redirects/rejects the subsequent request.
8
+ * Explicit credentials and refresh/handoff handlers retain their own lifecycle.
9
+ */
10
+ export async function guardBrowserApiSession(
11
+ request: NextRequest,
12
+ guard: () => Promise<NextResponse | null>
13
+ ) {
14
+ const hasProviderCookie = request.cookies
15
+ .getAll()
16
+ .some(({ name }) => /^sb-[a-z0-9-]+-auth-token(?:\.\d+)?$/i.test(name));
17
+ const session =
18
+ !request.headers.has('authorization') &&
19
+ hasProviderCookie &&
20
+ !isAccountAssuranceRecoveryPath(request.nextUrl.pathname)
21
+ ? await updateSession(request)
22
+ : null;
23
+ if (session?.claims) discardAppCookiesForVerifiedProvider(request);
24
+ const response =
25
+ (await guard()) ??
26
+ NextResponse.next({ request: { headers: request.headers } });
27
+ for (const cookie of session?.res.headers.getSetCookie() ?? [])
28
+ response.headers.append('set-cookie', cookie);
29
+ return response;
30
+ }
@@ -0,0 +1,34 @@
1
+ import { createAdminClient } from '@tuturuuu/supabase/next/server';
2
+ import type { TypedSupabaseClient } from '@tuturuuu/supabase/types';
3
+ import type { AppCoordinationTokenClaims } from './app-coordination-token';
4
+ import { bindCurrentMfaFactor } from './required-mfa-lineage';
5
+ import {
6
+ type MfaSessionProof,
7
+ readRequiredMfaPolicy,
8
+ satisfiesRequiredMfaPolicy,
9
+ } from './required-mfa-policy';
10
+
11
+ /** Claims must already be signature/scope/target verified by the caller. */
12
+ export async function isRequiredMfaAppSessionAllowed(
13
+ claims: AppCoordinationTokenClaims
14
+ ) {
15
+ return isRequiredMfaProofAllowed(claims.sub, claims.mfa ?? null);
16
+ }
17
+
18
+ export async function isRequiredMfaProofAllowed(
19
+ userId: string,
20
+ proof: MfaSessionProof | null
21
+ ) {
22
+ const admin = (await createAdminClient({
23
+ noCookie: true,
24
+ })) as TypedSupabaseClient;
25
+ const { data, error } = await admin.auth.admin.getUserById(userId);
26
+ if (error) throw new Error('Account security is unavailable');
27
+ if (!data.user || data.user.id !== userId) return false;
28
+ if (data.user.banned_until && Date.parse(data.user.banned_until) > Date.now())
29
+ return false;
30
+ const policy = readRequiredMfaPolicy(data.user.app_metadata);
31
+ if (!policy.required) return true;
32
+ if (!proof || !satisfiesRequiredMfaPolicy(policy, proof)) return false;
33
+ return Boolean(await bindCurrentMfaFactor(admin, userId, proof));
34
+ }
@@ -0,0 +1,42 @@
1
+ import type { TypedSupabaseClient } from '@tuturuuu/supabase/types';
2
+ import {
3
+ mfaProofFromVerifiedClaims,
4
+ readRequiredMfaPolicy,
5
+ satisfiesRequiredMfaPolicy,
6
+ } from './required-mfa-policy';
7
+
8
+ /** UX routing only; server/database gates remain authoritative. */
9
+ export async function requiresAccountMfa(
10
+ client: TypedSupabaseClient,
11
+ options: { acceptMobileApproval?: boolean } = {}
12
+ ) {
13
+ const user = await client.auth.getUser();
14
+ if (user.error || !user.data.user)
15
+ throw new Error('Unable to verify account security');
16
+ const policy = readRequiredMfaPolicy(user.data.user.app_metadata);
17
+ if (policy.required) {
18
+ const claims = await client.auth.getClaims();
19
+ if (claims.error || claims.data?.claims.sub !== user.data.user.id)
20
+ return true;
21
+ if (
22
+ !satisfiesRequiredMfaPolicy(
23
+ policy,
24
+ mfaProofFromVerifiedClaims(claims.data.claims)
25
+ )
26
+ ) {
27
+ if (options.acceptMobileApproval) {
28
+ // The database validates the consumed approval against this exact
29
+ // provider session, current policy boundary, and proof expiry.
30
+ const result = await client.rpc('account_required_mfa_satisfied');
31
+ if (!result.error && result.data === true) return false;
32
+ }
33
+ return true;
34
+ }
35
+ const verified = await client.rpc('account_required_mfa_satisfied');
36
+ return Boolean(verified.error || verified.data !== true);
37
+ }
38
+ const { data, error } =
39
+ await client.auth.mfa.getAuthenticatorAssuranceLevel();
40
+ if (error) throw error;
41
+ return data.currentLevel === 'aal1' && data.nextLevel === 'aal2';
42
+ }
@@ -0,0 +1,58 @@
1
+ import {
2
+ getSupabaseAuthCookieUrls,
3
+ getSupabaseAuthStorageKey,
4
+ } from '@tuturuuu/supabase/next/common';
5
+ import type { NextRequest } from 'next/server';
6
+
7
+ /** Read opaque credentials only. No decoded claim is trusted here; each token
8
+ * is verified by the provider or our app-token verifier before policy lookup. */
9
+ export function accountAssuranceCredentials(request: NextRequest): string[] {
10
+ const bearer = request.headers
11
+ .get('authorization')
12
+ ?.match(/^Bearer\s+(\S+)$/i)?.[1];
13
+ const tokens = new Set<string>();
14
+ if (bearer && /^(?:eyJ|ttr_app_)/.test(bearer)) tokens.add(bearer);
15
+ for (const name of ['tuturuuu_web_app_session', 'tuturuuu_app_session']) {
16
+ const token = request.cookies.get(name)?.value;
17
+ if (token?.startsWith('ttr_app_')) tokens.add(token);
18
+ }
19
+ // Browser auth uses this project key even when the server URL is internal.
20
+ const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
21
+ if (!url) return [...tokens];
22
+ for (const cookieUrl of getSupabaseAuthCookieUrls(url)) {
23
+ const key = getSupabaseAuthStorageKey(cookieUrl);
24
+ let raw = request.cookies.get(key)?.value;
25
+ if (!raw) {
26
+ const chunks: string[] = [];
27
+ for (let index = 0; request.cookies.has(`${key}.${index}`); index += 1) {
28
+ chunks.push(request.cookies.get(`${key}.${index}`)!.value);
29
+ }
30
+ raw = chunks.join('');
31
+ }
32
+ if (raw) {
33
+ try {
34
+ const decoded = raw.startsWith('base64-')
35
+ ? Buffer.from(raw.slice(7), 'base64url').toString('utf8')
36
+ : raw;
37
+ const session = JSON.parse(decoded) as { access_token?: unknown };
38
+ if (typeof session.access_token === 'string')
39
+ tokens.add(session.access_token);
40
+ } catch {
41
+ // Existing authentication/malformed-cookie guards reject these cookies.
42
+ }
43
+ }
44
+ }
45
+ return [...tokens];
46
+ }
47
+
48
+ /** Provider-first proxies must remove superseded app credentials from the
49
+ * forwarded request, not merely ignore them during policy enforcement. */
50
+ export function discardAppCookiesForVerifiedProvider(request: NextRequest) {
51
+ for (const name of [
52
+ 'tuturuuu_app_session',
53
+ 'tuturuuu_web_app_session',
54
+ 'tuturuuu_app_session_refresh',
55
+ 'tuturuuu_web_app_session_refresh',
56
+ ])
57
+ request.cookies.delete(name);
58
+ }
@@ -0,0 +1,22 @@
1
+ import type { TypedSupabaseClient } from '@tuturuuu/supabase/types';
2
+ import type { MfaSessionProof } from './required-mfa-policy';
3
+
4
+ /** The provider JWT does not contain factor_id. Resolve its verified session
5
+ * against current GoTrue factor state; signed handoffs retain that exact id. */
6
+ export async function bindCurrentMfaFactor(
7
+ admin: TypedSupabaseClient,
8
+ userId: string,
9
+ proof: MfaSessionProof,
10
+ allowInitialBinding = false
11
+ ): Promise<MfaSessionProof | null> {
12
+ if (!proof.factorId && !allowInitialBinding) return null;
13
+ const { data, error } = await admin.rpc('account_mfa_verified_factor', {
14
+ p_user_id: userId,
15
+ p_session_id: proof.verificationSessionId ?? proof.sessionId,
16
+ p_verified_at: proof.verifiedAt,
17
+ p_primary_verified_at: proof.primaryVerifiedAt,
18
+ });
19
+ if (error) throw new Error('Account factor verification unavailable');
20
+ if (!data || (proof.factorId && data !== proof.factorId)) return null;
21
+ return { ...proof, factorId: data };
22
+ }