@payez/next-mvp 4.4.7 → 4.4.8
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/dist/auth/better-auth.d.ts +18 -1
- package/dist/auth/better-auth.js +96 -20
- package/dist/lib/nextauth-secret.d.ts +51 -0
- package/dist/lib/nextauth-secret.js +141 -0
- package/package.json +6 -1
- package/src/auth/better-auth.ts +100 -18
- package/src/lib/nextauth-secret.ts +168 -0
- package/src/vibe/document-unwrap.ts +103 -103
|
@@ -536,8 +536,14 @@ export declare function getBetterAuthHandler(): Promise<{
|
|
|
536
536
|
* header of the response to find the BA Redis key.
|
|
537
537
|
*
|
|
538
538
|
* This replaces the old databaseHooks approach which doesn't fire in stateless mode.
|
|
539
|
+
*
|
|
540
|
+
* RETURNS the IdP's own error code on an IDP refusal, or null otherwise
|
|
541
|
+
* (PAY-2005 scenario (b), BAPert ruling 67897). It used to return a bare
|
|
542
|
+
* boolean, so `REGISTRATION_NOT_ALLOWED` was indistinguishable from a network
|
|
543
|
+
* blip and every cause collapsed to one generic login-page code — a NEW user
|
|
544
|
+
* refused registration bounced silently, which is the failure Jon named.
|
|
539
545
|
*/
|
|
540
|
-
export declare function exchangeOAuthForIdpTokens(sessionToken: string, provider?: string): Promise<boolean>;
|
|
546
|
+
export declare function exchangeOAuthForIdpTokens(sessionToken: string, provider?: string): Promise<boolean | string>;
|
|
541
547
|
/**
|
|
542
548
|
* Write IDP tokens into an EXISTING Better Auth session. Storage only — this
|
|
543
549
|
* function never acquires tokens and never decides who the caller is.
|
|
@@ -588,6 +594,17 @@ export declare function storeIdpTokensInSession(sessionToken: string, idpTokenDa
|
|
|
588
594
|
* Try again or use another method." The cause stays in the server log, never on the page.
|
|
589
595
|
*/
|
|
590
596
|
export declare const OAUTH_SIGN_IN_FAILED_ERROR = "OAUTH_SIGN_IN_FAILED";
|
|
597
|
+
/**
|
|
598
|
+
* The ONLY IdP refusal codes the package forwards to the login page (PAY-2005 (b), BAPert 67897).
|
|
599
|
+
*
|
|
600
|
+
* Default stays PAY-1733's ONE neutral code: the cause of an ordinary failed exchange lives in the server
|
|
601
|
+
* log, never on the page. A code is forwarded ONLY when it earns a distinct user-facing message — today just
|
|
602
|
+
* the registration refusal, so a NEW Google user with registration OFF sees a clear "not open to sign-up"
|
|
603
|
+
* line instead of a silent bounce. Adding a code here REQUIRES a matching entry in the host allowlist
|
|
604
|
+
* (portal: src/lib/auth/magic-link-outcome.ts); an unlisted code renders nothing, which is a silent bounce
|
|
605
|
+
* wearing a different number. Add them together, never one alone.
|
|
606
|
+
*/
|
|
607
|
+
export declare const IDP_ERROR_CODES_TO_FORWARD: ReadonlySet<string>;
|
|
591
608
|
/**
|
|
592
609
|
* Re-emit each cookie the callback response SET as an immediate expiry, keeping the attributes that decide
|
|
593
610
|
* WHICH cookie the browser overwrites (Path, Domain, Secure, SameSite, HttpOnly - a `__Secure-` name is only
|
package/dist/auth/better-auth.js
CHANGED
|
@@ -43,7 +43,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
43
43
|
};
|
|
44
44
|
})();
|
|
45
45
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
46
|
-
exports.OAUTH_SIGN_IN_FAILED_ERROR = void 0;
|
|
46
|
+
exports.IDP_ERROR_CODES_TO_FORWARD = exports.OAUTH_SIGN_IN_FAILED_ERROR = void 0;
|
|
47
47
|
exports.secretFingerprint = secretFingerprint;
|
|
48
48
|
exports.buildIdpOAuthProviderConfig = buildIdpOAuthProviderConfig;
|
|
49
49
|
exports.buildBetterAuthProviders = buildBetterAuthProviders;
|
|
@@ -286,19 +286,42 @@ function buildBetterAuthProviders(config) {
|
|
|
286
286
|
clientSecret: oauth.clientSecret,
|
|
287
287
|
scope: wireScopes.length > 0 ? wireScopes : undefined,
|
|
288
288
|
/**
|
|
289
|
-
*
|
|
290
|
-
* the signup BFF on allow_public_registration, but the social lane never
|
|
291
|
-
* touches that code: better-auth creates the user through its own
|
|
292
|
-
* internalAdapter at the OAuth callback, and its ONLY guard is this flag.
|
|
289
|
+
* PAY-2005 — DISABLE-SIGN-UP IS REMOVED, DELIBERATELY. DO NOT RE-ADD IT.
|
|
293
290
|
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
-
*
|
|
297
|
-
*
|
|
298
|
-
*
|
|
299
|
-
*
|
|
291
|
+
* 260419 wired this from `config.authSettings?.allowPublicRegistration` to
|
|
292
|
+
* close a client-side self-serve provisioning door. It cannot do that job
|
|
293
|
+
* correctly, and it BREAKS SIGN-IN:
|
|
294
|
+
*
|
|
295
|
+
* - This app runs better-auth in STATELESS mode (no DB adapter — see the
|
|
296
|
+
* file header). better-auth therefore has NO user store, so at the OAuth
|
|
297
|
+
* callback it cannot tell an EXISTING user from a NEW one. Its
|
|
298
|
+
* handleOAuthUserInfo consults `disableSignUp` for BOTH, so
|
|
299
|
+
* `disableSignUp: true` refuses a returning user exactly like a
|
|
300
|
+
* stranger. Measured live 2026-09-30 (PAY-2005): portal.keelauth.com
|
|
301
|
+
* Google sign-in bounced to /sign-in with Better Auth `signup_disabled`
|
|
302
|
+
* once client 12's allow_public_registration reported false.
|
|
303
|
+
*
|
|
304
|
+
* - The REAL registration gate already exists and is correct, at the IDP:
|
|
305
|
+
* PayEz.External.Id.Api ExternalAuthController.oauth-callback looks the
|
|
306
|
+
* user up by verified email (:799); an existing user logs in UNTOUCHED
|
|
307
|
+
* (:811); only the isNewUser branch (:853) calls INewUserRegistrar,
|
|
308
|
+
* which refuses fail-closed when the client forbids public registration
|
|
309
|
+
* (NewUserRegistrar.cs:65, `SecurityFlags.AllowPublicRegistration != true`).
|
|
310
|
+
* Its own comment states the contract: "existing federated users ...
|
|
311
|
+
* log in untouched, so gating here never blocks login."
|
|
312
|
+
*
|
|
313
|
+
* So this flag was a DUPLICATE gate that could only ever misfire — it cannot
|
|
314
|
+
* distinguish new from existing, and the IDP already refuses new identities.
|
|
315
|
+
* Removing it restores sign-in for existing users WITHOUT reopening
|
|
316
|
+
* self-serve: a NEW identity that completes the better-auth leg still gets
|
|
317
|
+
* REFUSED at the IDP exchange and never receives IDP tokens, which is the
|
|
318
|
+
* portal's actual authorization gate.
|
|
319
|
+
*
|
|
320
|
+
* WHERE "no self-serve sign-up" IS ENFORCED NOW: the IDP's NewUserRegistrar
|
|
321
|
+
* (above) as the backstop, and each app's own front-door sign-up gate
|
|
322
|
+
* (e.g. the portal's signup-gate.ts / SignUpCard) for the UI affordance.
|
|
323
|
+
* Both are correct and neither is this flag.
|
|
300
324
|
*/
|
|
301
|
-
disableSignUp: config.authSettings?.allowPublicRegistration !== true,
|
|
302
325
|
// Google is overly eager to reuse the last account unless we
|
|
303
326
|
// explicitly ask for account selection on each social login.
|
|
304
327
|
prompt: typeof rawPrompt === 'string'
|
|
@@ -874,6 +897,12 @@ async function getBetterAuthHandler() {
|
|
|
874
897
|
* header of the response to find the BA Redis key.
|
|
875
898
|
*
|
|
876
899
|
* This replaces the old databaseHooks approach which doesn't fire in stateless mode.
|
|
900
|
+
*
|
|
901
|
+
* RETURNS the IdP's own error code on an IDP refusal, or null otherwise
|
|
902
|
+
* (PAY-2005 scenario (b), BAPert ruling 67897). It used to return a bare
|
|
903
|
+
* boolean, so `REGISTRATION_NOT_ALLOWED` was indistinguishable from a network
|
|
904
|
+
* blip and every cause collapsed to one generic login-page code — a NEW user
|
|
905
|
+
* refused registration bounced silently, which is the failure Jon named.
|
|
877
906
|
*/
|
|
878
907
|
async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
|
|
879
908
|
try {
|
|
@@ -959,6 +988,36 @@ async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
|
|
|
959
988
|
console.log('[BETTER_AUTH] IDP oauth-callback response:', { status: oauthRes.status, bodyLength: oauthResText.length });
|
|
960
989
|
if (!oauthRes.ok) {
|
|
961
990
|
console.error('[BETTER_AUTH] IDP oauth-callback failed:', oauthRes.status);
|
|
991
|
+
/**
|
|
992
|
+
* PAY-2005 scenario (b) — RETURN THE IDP'S ERROR CODE (BAPert ruling 67897).
|
|
993
|
+
*
|
|
994
|
+
* Every refusal used to return a bare `false`, so the abort collapsed ALL
|
|
995
|
+
* causes to one generic `OAUTH_SIGN_IN_FAILED`: a NEW user refused
|
|
996
|
+
* registration (`REGISTRATION_NOT_ALLOWED`, ExternalAuthController.cs:908)
|
|
997
|
+
* bounced silently, indistinguishable from a network blip. Jon named the
|
|
998
|
+
* clear message explicitly (67856): "(b) a brand-new Google user with
|
|
999
|
+
* registration OFF is refused by the IDP with a clear message, not a
|
|
1000
|
+
* silent bounce."
|
|
1001
|
+
*
|
|
1002
|
+
* The envelope is `{ error: { code, message } }` (PayEzControllerBase.Error).
|
|
1003
|
+
* We read ONLY `error.code` and return it VERBATIM — the abort puts it in
|
|
1004
|
+
* the `?error=` param, and the HOST's allowlist decides whether to render
|
|
1005
|
+
* it. The package does NOT interpret the code and does NOT add a message
|
|
1006
|
+
* (that would be a second allowlist). No code in the body -> null (the
|
|
1007
|
+
* generic host code), exactly as before.
|
|
1008
|
+
*/
|
|
1009
|
+
let idpErrorCode = null;
|
|
1010
|
+
try {
|
|
1011
|
+
const parsed = JSON.parse(oauthResText);
|
|
1012
|
+
// FORWARD ONLY AN ALLOWLISTED CODE. Every other refusal keeps PAY-1733's single neutral code — an
|
|
1013
|
+
// unlisted code would render nothing on the host page, which is the silent bounce this exists to end.
|
|
1014
|
+
if (typeof parsed?.error?.code === 'string' && exports.IDP_ERROR_CODES_TO_FORWARD.has(parsed.error.code)) {
|
|
1015
|
+
idpErrorCode = parsed.error.code;
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
catch {
|
|
1019
|
+
/* non-JSON refusal body -> no code (generic abort) */
|
|
1020
|
+
}
|
|
962
1021
|
/**
|
|
963
1022
|
* WHY THIS LOGS ONLY ON FAILURE, AND WHY IT LOGS THESE FIELDS.
|
|
964
1023
|
*
|
|
@@ -1007,7 +1066,7 @@ async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
|
|
|
1007
1066
|
sent_client_id: (0, client_key_1.resolveClientKey)(config, 'auth/better-auth:oauth-callback').key ?? null,
|
|
1008
1067
|
idp_url: idpUrl,
|
|
1009
1068
|
});
|
|
1010
|
-
return false;
|
|
1069
|
+
return idpErrorCode ?? false;
|
|
1011
1070
|
}
|
|
1012
1071
|
let idpData;
|
|
1013
1072
|
try {
|
|
@@ -1127,6 +1186,7 @@ async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
|
|
|
1127
1186
|
const stored = await storeIdpTokensInSession(sessionToken, idpTokenData, provider);
|
|
1128
1187
|
if (stored)
|
|
1129
1188
|
console.log('[BETTER_AUTH] IDP tokens stored in session for', email);
|
|
1189
|
+
// true = success (tokens stored). false = a non-IdP failure with no specific code (generic abort).
|
|
1130
1190
|
return stored;
|
|
1131
1191
|
}
|
|
1132
1192
|
catch (err) {
|
|
@@ -1222,6 +1282,17 @@ async function storeIdpTokensInSession(sessionToken, idpTokenData, authProvider)
|
|
|
1222
1282
|
* Try again or use another method." The cause stays in the server log, never on the page.
|
|
1223
1283
|
*/
|
|
1224
1284
|
exports.OAUTH_SIGN_IN_FAILED_ERROR = 'OAUTH_SIGN_IN_FAILED';
|
|
1285
|
+
/**
|
|
1286
|
+
* The ONLY IdP refusal codes the package forwards to the login page (PAY-2005 (b), BAPert 67897).
|
|
1287
|
+
*
|
|
1288
|
+
* Default stays PAY-1733's ONE neutral code: the cause of an ordinary failed exchange lives in the server
|
|
1289
|
+
* log, never on the page. A code is forwarded ONLY when it earns a distinct user-facing message — today just
|
|
1290
|
+
* the registration refusal, so a NEW Google user with registration OFF sees a clear "not open to sign-up"
|
|
1291
|
+
* line instead of a silent bounce. Adding a code here REQUIRES a matching entry in the host allowlist
|
|
1292
|
+
* (portal: src/lib/auth/magic-link-outcome.ts); an unlisted code renders nothing, which is a silent bounce
|
|
1293
|
+
* wearing a different number. Add them together, never one alone.
|
|
1294
|
+
*/
|
|
1295
|
+
exports.IDP_ERROR_CODES_TO_FORWARD = new Set(['REGISTRATION_NOT_ALLOWED']);
|
|
1225
1296
|
/**
|
|
1226
1297
|
* Re-emit each cookie the callback response SET as an immediate expiry, keeping the attributes that decide
|
|
1227
1298
|
* WHICH cookie the browser overwrites (Path, Domain, Secure, SameSite, HttpOnly - a `__Secure-` name is only
|
|
@@ -1253,7 +1324,7 @@ function expireSetCookies(setCookies) {
|
|
|
1253
1324
|
* expired, and the user goes to the login page with OAUTH_SIGN_IN_FAILED. Relative Location, as for the state-error
|
|
1254
1325
|
* branch above (328439: an absolute URL built from request.url is a dead tab in a container).
|
|
1255
1326
|
*/
|
|
1256
|
-
async function abortOAuthSignIn(callbackResponse, sessionToken, cookieHeader, loginPath) {
|
|
1327
|
+
async function abortOAuthSignIn(callbackResponse, sessionToken, cookieHeader, loginPath, errorCode = exports.OAUTH_SIGN_IN_FAILED_ERROR) {
|
|
1257
1328
|
const headers = new Headers();
|
|
1258
1329
|
if (sessionToken) {
|
|
1259
1330
|
try {
|
|
@@ -1275,8 +1346,8 @@ async function abortOAuthSignIn(callbackResponse, sessionToken, cookieHeader, lo
|
|
|
1275
1346
|
}
|
|
1276
1347
|
for (const sc of expireSetCookies(callbackResponse.headers.getSetCookie?.() ?? []))
|
|
1277
1348
|
headers.append('set-cookie', sc);
|
|
1278
|
-
headers.set('location', `${loginPath}${loginPath.includes('?') ? '&' : '?'}error=${
|
|
1279
|
-
console.warn('[BETTER_AUTH] OAuth sign-in ABORTED: the IdP exchange stored no tokens, so no session is kept (PAY-1733)', sessionToken ? (0, token_log_id_1.tokenLogId)(sessionToken) : '(no session)');
|
|
1349
|
+
headers.set('location', `${loginPath}${loginPath.includes('?') ? '&' : '?'}error=${errorCode}`);
|
|
1350
|
+
console.warn('[BETTER_AUTH] OAuth sign-in ABORTED: the IdP exchange stored no tokens, so no session is kept (PAY-1733)', sessionToken ? (0, token_log_id_1.tokenLogId)(sessionToken) : '(no session)', 'code=' + errorCode);
|
|
1280
1351
|
return new Response(null, { status: 302, headers });
|
|
1281
1352
|
}
|
|
1282
1353
|
/**
|
|
@@ -1364,7 +1435,9 @@ function createAuthGetHandler(loginPath = '/account-auth/login') {
|
|
|
1364
1435
|
.map((c) => c.split(';')[0])
|
|
1365
1436
|
.join('; ');
|
|
1366
1437
|
let sessionToken = null;
|
|
1367
|
-
|
|
1438
|
+
// true = tokens stored; false = no specific IdP code (generic abort); a string = the IdP's own refusal code
|
|
1439
|
+
// (PAY-2005 (b): REGISTRATION_NOT_ALLOWED, etc.), forwarded so the host can show a clear message.
|
|
1440
|
+
let exchange = false;
|
|
1368
1441
|
try {
|
|
1369
1442
|
const auth = await getBetterAuthInstance();
|
|
1370
1443
|
if (auth?.api?.getSession) {
|
|
@@ -1374,7 +1447,7 @@ function createAuthGetHandler(loginPath = '/account-auth/login') {
|
|
|
1374
1447
|
if (session?.session?.token) {
|
|
1375
1448
|
sessionToken = session.session.token;
|
|
1376
1449
|
console.log('[BETTER_AUTH] Got session token from callback:', (0, token_log_id_1.tokenLogId)(session.session.token), '| email:', session.user?.email);
|
|
1377
|
-
|
|
1450
|
+
exchange = await exchangeOAuthForIdpTokens(session.session.token);
|
|
1378
1451
|
}
|
|
1379
1452
|
else {
|
|
1380
1453
|
console.warn('[BETTER_AUTH] Could not get session after OAuth callback');
|
|
@@ -1384,8 +1457,11 @@ function createAuthGetHandler(loginPath = '/account-auth/login') {
|
|
|
1384
1457
|
catch (err) {
|
|
1385
1458
|
console.error('[BETTER_AUTH] IDP token exchange failed:', err.message);
|
|
1386
1459
|
}
|
|
1387
|
-
if (
|
|
1388
|
-
|
|
1460
|
+
if (exchange !== true) {
|
|
1461
|
+
// A specific IdP error code goes on the URL; otherwise the generic OAUTH_SIGN_IN_FAILED.
|
|
1462
|
+
const errorCode = typeof exchange === 'string' ? exchange : exports.OAUTH_SIGN_IN_FAILED_ERROR;
|
|
1463
|
+
return abortOAuthSignIn(response, sessionToken, cookieHeader, loginPath, errorCode);
|
|
1464
|
+
}
|
|
1389
1465
|
}
|
|
1390
1466
|
return response;
|
|
1391
1467
|
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* resolveNextAuthSecret - COMPATIBILITY SHIM (restored 2026-09-30, PAY-2005 / N7).
|
|
3
|
+
*
|
|
4
|
+
* ---------------------------------------------------------------------------
|
|
5
|
+
* WHY THIS FILE EXISTS, AND WHY IT IS A SHIM RATHER THAN A FEATURE
|
|
6
|
+
* ---------------------------------------------------------------------------
|
|
7
|
+
* The NextAuth-era broker secret resolver was DELETED in
|
|
8
|
+
* 4d3d29e6 "feat!: Better Auth 4.0.0 — NextAuth fully removed"
|
|
9
|
+
* and it is intentionally NOT coming back as first-class API: this package is
|
|
10
|
+
* Better Auth now, and new apps must not resolve a NextAuth secret at all.
|
|
11
|
+
*
|
|
12
|
+
* It is restored here ONLY because ONE consumer is still mid-migration and cannot
|
|
13
|
+
* take the 4.4.x bump without it:
|
|
14
|
+
*
|
|
15
|
+
* CONSUMER THAT NEEDS THIS: the customer portal
|
|
16
|
+
* (`payez_idp_admin_web` / VibeID-Customer-Portal). It is pinned at
|
|
17
|
+
* @payez/next-mvp 4.0.46 and still runs BOTH next-auth@4.24 and better-auth; its
|
|
18
|
+
* `src/lib/server-session.ts` and `src/lib/session-token.ts` (and 5 route files)
|
|
19
|
+
* import `resolveNextAuthSecret` from here. Without this export, bumping the
|
|
20
|
+
* portal to 4.4.x breaks its build. BAPert ruled (68003) to ship this scoped
|
|
21
|
+
* shim rather than force the portal's full NextAuth->BetterAuth migration
|
|
22
|
+
* tonight. That migration is tracked as its own card; WHEN IT LANDS, DELETE
|
|
23
|
+
* THIS FILE.
|
|
24
|
+
*
|
|
25
|
+
* DO NOT add new consumers. If you are starting an app, use Better Auth's own
|
|
26
|
+
* secret handling; you do not need this.
|
|
27
|
+
*
|
|
28
|
+
* ---------------------------------------------------------------------------
|
|
29
|
+
* BEHAVIOUR (faithful to the 4.0.46 implementation, with one deliberate change)
|
|
30
|
+
* ---------------------------------------------------------------------------
|
|
31
|
+
* Priority: (1) process.env.NEXTAUTH_SECRET if set; (2) fetch from the IDP via a
|
|
32
|
+
* signed client assertion; (3) cache in-memory 5 min and stamp NEXTAUTH_SECRET.
|
|
33
|
+
*
|
|
34
|
+
* DELIBERATE CHANGE, 4.0.46 -> here: 4.0.46 validated the fetched secret with
|
|
35
|
+
* `validateNextAuthSecret`, which this package's 4.4.x no longer exports (it was
|
|
36
|
+
* renamed to `validateAuthSecret` with an IDENTICAL body). The shim calls
|
|
37
|
+
* `validateAuthSecret` - same predicate, current name - so there is no second
|
|
38
|
+
* copy of the rule. The validation semantics are unchanged.
|
|
39
|
+
*
|
|
40
|
+
* SERVER-ONLY: the IDP handshake and NEXTAUTH_SECRET are server concerns.
|
|
41
|
+
*/
|
|
42
|
+
import 'server-only';
|
|
43
|
+
/**
|
|
44
|
+
* Resolve the NextAuth secret (server-only).
|
|
45
|
+
*
|
|
46
|
+
* Priority:
|
|
47
|
+
* 1) Use process.env.NEXTAUTH_SECRET if present (allows overrides/production)
|
|
48
|
+
* 2) Fetch from the IDP broker endpoint - the IDP handles all Key Vault/signing
|
|
49
|
+
* 3) Cache result in-memory and set process.env.NEXTAUTH_SECRET for later calls
|
|
50
|
+
*/
|
|
51
|
+
export declare function resolveNextAuthSecret(): Promise<string>;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.resolveNextAuthSecret = resolveNextAuthSecret;
|
|
4
|
+
/**
|
|
5
|
+
* resolveNextAuthSecret - COMPATIBILITY SHIM (restored 2026-09-30, PAY-2005 / N7).
|
|
6
|
+
*
|
|
7
|
+
* ---------------------------------------------------------------------------
|
|
8
|
+
* WHY THIS FILE EXISTS, AND WHY IT IS A SHIM RATHER THAN A FEATURE
|
|
9
|
+
* ---------------------------------------------------------------------------
|
|
10
|
+
* The NextAuth-era broker secret resolver was DELETED in
|
|
11
|
+
* 4d3d29e6 "feat!: Better Auth 4.0.0 — NextAuth fully removed"
|
|
12
|
+
* and it is intentionally NOT coming back as first-class API: this package is
|
|
13
|
+
* Better Auth now, and new apps must not resolve a NextAuth secret at all.
|
|
14
|
+
*
|
|
15
|
+
* It is restored here ONLY because ONE consumer is still mid-migration and cannot
|
|
16
|
+
* take the 4.4.x bump without it:
|
|
17
|
+
*
|
|
18
|
+
* CONSUMER THAT NEEDS THIS: the customer portal
|
|
19
|
+
* (`payez_idp_admin_web` / VibeID-Customer-Portal). It is pinned at
|
|
20
|
+
* @payez/next-mvp 4.0.46 and still runs BOTH next-auth@4.24 and better-auth; its
|
|
21
|
+
* `src/lib/server-session.ts` and `src/lib/session-token.ts` (and 5 route files)
|
|
22
|
+
* import `resolveNextAuthSecret` from here. Without this export, bumping the
|
|
23
|
+
* portal to 4.4.x breaks its build. BAPert ruled (68003) to ship this scoped
|
|
24
|
+
* shim rather than force the portal's full NextAuth->BetterAuth migration
|
|
25
|
+
* tonight. That migration is tracked as its own card; WHEN IT LANDS, DELETE
|
|
26
|
+
* THIS FILE.
|
|
27
|
+
*
|
|
28
|
+
* DO NOT add new consumers. If you are starting an app, use Better Auth's own
|
|
29
|
+
* secret handling; you do not need this.
|
|
30
|
+
*
|
|
31
|
+
* ---------------------------------------------------------------------------
|
|
32
|
+
* BEHAVIOUR (faithful to the 4.0.46 implementation, with one deliberate change)
|
|
33
|
+
* ---------------------------------------------------------------------------
|
|
34
|
+
* Priority: (1) process.env.NEXTAUTH_SECRET if set; (2) fetch from the IDP via a
|
|
35
|
+
* signed client assertion; (3) cache in-memory 5 min and stamp NEXTAUTH_SECRET.
|
|
36
|
+
*
|
|
37
|
+
* DELIBERATE CHANGE, 4.0.46 -> here: 4.0.46 validated the fetched secret with
|
|
38
|
+
* `validateNextAuthSecret`, which this package's 4.4.x no longer exports (it was
|
|
39
|
+
* renamed to `validateAuthSecret` with an IDENTICAL body). The shim calls
|
|
40
|
+
* `validateAuthSecret` - same predicate, current name - so there is no second
|
|
41
|
+
* copy of the rule. The validation semantics are unchanged.
|
|
42
|
+
*
|
|
43
|
+
* SERVER-ONLY: the IDP handshake and NEXTAUTH_SECRET are server concerns.
|
|
44
|
+
*/
|
|
45
|
+
require("server-only");
|
|
46
|
+
const node_crypto_1 = require("node:crypto");
|
|
47
|
+
const secret_validation_1 = require("./secret-validation");
|
|
48
|
+
let cachedSecret = null;
|
|
49
|
+
let lastFetchedAt = 0;
|
|
50
|
+
const CACHE_TTL_MS = 5 * 60 * 1000;
|
|
51
|
+
/**
|
|
52
|
+
* Resolve the NextAuth secret (server-only).
|
|
53
|
+
*
|
|
54
|
+
* Priority:
|
|
55
|
+
* 1) Use process.env.NEXTAUTH_SECRET if present (allows overrides/production)
|
|
56
|
+
* 2) Fetch from the IDP broker endpoint - the IDP handles all Key Vault/signing
|
|
57
|
+
* 3) Cache result in-memory and set process.env.NEXTAUTH_SECRET for later calls
|
|
58
|
+
*/
|
|
59
|
+
async function resolveNextAuthSecret() {
|
|
60
|
+
// Check if already in environment
|
|
61
|
+
if (process.env.NEXTAUTH_SECRET && process.env.NEXTAUTH_SECRET.trim() !== '') {
|
|
62
|
+
return process.env.NEXTAUTH_SECRET;
|
|
63
|
+
}
|
|
64
|
+
// Check if cached and fresh (within 5 minutes)
|
|
65
|
+
if (cachedSecret && Date.now() - lastFetchedAt < CACHE_TTL_MS) {
|
|
66
|
+
return cachedSecret;
|
|
67
|
+
}
|
|
68
|
+
// Broker mode: fetch from the IDP (the IDP handles all Key Vault/signing)
|
|
69
|
+
const base = process.env.IDP_URL;
|
|
70
|
+
if (!base)
|
|
71
|
+
throw new Error('IDP_URL environment variable is required');
|
|
72
|
+
const clientIdStr = process.env.CLIENT_ID;
|
|
73
|
+
if (!clientIdStr || clientIdStr.trim() === '') {
|
|
74
|
+
throw new Error('CLIENT_ID is required (e.g., "ideal_resume_website")');
|
|
75
|
+
}
|
|
76
|
+
// Step 1: ask the IDP to sign a client assertion (the IDP holds the keys, not us)
|
|
77
|
+
const signingUrl = `${base.replace(/\/$/, '')}/api/ExternalAuth/sign-client-assertion`;
|
|
78
|
+
const signingResp = await fetch(signingUrl, {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
headers: {
|
|
81
|
+
Accept: 'application/json',
|
|
82
|
+
'Content-Type': 'application/json',
|
|
83
|
+
'X-Client-Id': clientIdStr,
|
|
84
|
+
'X-Correlation-Id': (0, node_crypto_1.randomUUID)().replace(/-/g, ''),
|
|
85
|
+
},
|
|
86
|
+
body: JSON.stringify({
|
|
87
|
+
issuer: clientIdStr,
|
|
88
|
+
subject: clientIdStr,
|
|
89
|
+
audience: 'urn:payez:externalauth:nextauthsecret',
|
|
90
|
+
expires_in: 60,
|
|
91
|
+
}),
|
|
92
|
+
cache: 'no-store',
|
|
93
|
+
});
|
|
94
|
+
if (!signingResp.ok) {
|
|
95
|
+
const txt = await signingResp.text().catch(() => 'Unknown error');
|
|
96
|
+
throw new Error(`Failed to sign client assertion: ${signingResp.status} ${signingResp.statusText} - ${txt}`);
|
|
97
|
+
}
|
|
98
|
+
const signingBody = (await signingResp.json().catch(() => ({})));
|
|
99
|
+
const clientAssertion = signingBody?.data?.client_assertion ??
|
|
100
|
+
signingBody?.data?.clientAssertion ??
|
|
101
|
+
signingBody?.client_assertion ??
|
|
102
|
+
signingBody?.clientAssertion ??
|
|
103
|
+
signingBody?.data?.ClientAssertion ??
|
|
104
|
+
signingBody?.ClientAssertion;
|
|
105
|
+
if (!clientAssertion || typeof clientAssertion !== 'string') {
|
|
106
|
+
throw new Error('IDP did not return a valid signed client assertion');
|
|
107
|
+
}
|
|
108
|
+
// Step 2: use the signed assertion to fetch the NextAuth secret
|
|
109
|
+
const proxyUrl = `${base.replace(/\/$/, '')}/api/ExternalAuth/next-auth/secret`;
|
|
110
|
+
const proxyResp = await fetch(proxyUrl, {
|
|
111
|
+
method: 'POST',
|
|
112
|
+
headers: {
|
|
113
|
+
Accept: 'application/json',
|
|
114
|
+
'Content-Type': 'application/json',
|
|
115
|
+
'X-Client-Id': clientIdStr,
|
|
116
|
+
'X-Correlation-Id': (0, node_crypto_1.randomUUID)().replace(/-/g, ''),
|
|
117
|
+
},
|
|
118
|
+
body: JSON.stringify({ client_assertion: clientAssertion }),
|
|
119
|
+
cache: 'no-store',
|
|
120
|
+
});
|
|
121
|
+
if (!proxyResp.ok) {
|
|
122
|
+
const txt = await proxyResp.text().catch(() => 'Unknown error');
|
|
123
|
+
throw new Error(`Proxy error: ${proxyResp.status} ${proxyResp.statusText} - ${txt}`);
|
|
124
|
+
}
|
|
125
|
+
const proxyBody = (await proxyResp.json().catch(() => ({})));
|
|
126
|
+
const secret = proxyBody?.data?.secret ?? proxyBody?.secret;
|
|
127
|
+
// `configuration` is available from the IDP but deliberately not logged.
|
|
128
|
+
if (!secret || typeof secret !== 'string') {
|
|
129
|
+
throw new Error('Proxy did not return a valid NextAuth secret');
|
|
130
|
+
}
|
|
131
|
+
// Same predicate as 4.0.46's validateNextAuthSecret, under its current name.
|
|
132
|
+
const validation = (0, secret_validation_1.validateAuthSecret)(secret);
|
|
133
|
+
if (!validation.valid) {
|
|
134
|
+
throw new Error(`Fetched NextAuth secret failed validation: ${validation.reason}`);
|
|
135
|
+
}
|
|
136
|
+
cachedSecret = secret;
|
|
137
|
+
lastFetchedAt = Date.now();
|
|
138
|
+
process.env.NEXTAUTH_SECRET = secret;
|
|
139
|
+
console.log('[NEXTAUTH-SECRET] Resolved from IDP (length:', secret.length + ')');
|
|
140
|
+
return secret;
|
|
141
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@payez/next-mvp",
|
|
3
|
-
"version": "4.4.
|
|
3
|
+
"version": "4.4.8",
|
|
4
4
|
"sideEffects": false,
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -129,6 +129,11 @@
|
|
|
129
129
|
"require": "./dist/lib/session-store.js",
|
|
130
130
|
"default": "./dist/lib/session-store.js"
|
|
131
131
|
},
|
|
132
|
+
"./lib/nextauth-secret": {
|
|
133
|
+
"types": "./dist/lib/nextauth-secret.d.ts",
|
|
134
|
+
"require": "./dist/lib/nextauth-secret.js",
|
|
135
|
+
"default": "./dist/lib/nextauth-secret.js"
|
|
136
|
+
},
|
|
132
137
|
"./lib/ensure-fresh-access-token": {
|
|
133
138
|
"types": "./dist/lib/ensure-fresh-access-token.d.ts",
|
|
134
139
|
"require": "./dist/lib/ensure-fresh-access-token.js",
|
package/src/auth/better-auth.ts
CHANGED
|
@@ -281,19 +281,42 @@ export function buildBetterAuthProviders(
|
|
|
281
281
|
clientSecret: oauth.clientSecret,
|
|
282
282
|
scope: wireScopes.length > 0 ? wireScopes : undefined,
|
|
283
283
|
/**
|
|
284
|
-
*
|
|
285
|
-
* the signup BFF on allow_public_registration, but the social lane never
|
|
286
|
-
* touches that code: better-auth creates the user through its own
|
|
287
|
-
* internalAdapter at the OAuth callback, and its ONLY guard is this flag.
|
|
284
|
+
* PAY-2005 — DISABLE-SIGN-UP IS REMOVED, DELIBERATELY. DO NOT RE-ADD IT.
|
|
288
285
|
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
286
|
+
* 260419 wired this from `config.authSettings?.allowPublicRegistration` to
|
|
287
|
+
* close a client-side self-serve provisioning door. It cannot do that job
|
|
288
|
+
* correctly, and it BREAKS SIGN-IN:
|
|
289
|
+
*
|
|
290
|
+
* - This app runs better-auth in STATELESS mode (no DB adapter — see the
|
|
291
|
+
* file header). better-auth therefore has NO user store, so at the OAuth
|
|
292
|
+
* callback it cannot tell an EXISTING user from a NEW one. Its
|
|
293
|
+
* handleOAuthUserInfo consults `disableSignUp` for BOTH, so
|
|
294
|
+
* `disableSignUp: true` refuses a returning user exactly like a
|
|
295
|
+
* stranger. Measured live 2026-09-30 (PAY-2005): portal.keelauth.com
|
|
296
|
+
* Google sign-in bounced to /sign-in with Better Auth `signup_disabled`
|
|
297
|
+
* once client 12's allow_public_registration reported false.
|
|
298
|
+
*
|
|
299
|
+
* - The REAL registration gate already exists and is correct, at the IDP:
|
|
300
|
+
* PayEz.External.Id.Api ExternalAuthController.oauth-callback looks the
|
|
301
|
+
* user up by verified email (:799); an existing user logs in UNTOUCHED
|
|
302
|
+
* (:811); only the isNewUser branch (:853) calls INewUserRegistrar,
|
|
303
|
+
* which refuses fail-closed when the client forbids public registration
|
|
304
|
+
* (NewUserRegistrar.cs:65, `SecurityFlags.AllowPublicRegistration != true`).
|
|
305
|
+
* Its own comment states the contract: "existing federated users ...
|
|
306
|
+
* log in untouched, so gating here never blocks login."
|
|
307
|
+
*
|
|
308
|
+
* So this flag was a DUPLICATE gate that could only ever misfire — it cannot
|
|
309
|
+
* distinguish new from existing, and the IDP already refuses new identities.
|
|
310
|
+
* Removing it restores sign-in for existing users WITHOUT reopening
|
|
311
|
+
* self-serve: a NEW identity that completes the better-auth leg still gets
|
|
312
|
+
* REFUSED at the IDP exchange and never receives IDP tokens, which is the
|
|
313
|
+
* portal's actual authorization gate.
|
|
314
|
+
*
|
|
315
|
+
* WHERE "no self-serve sign-up" IS ENFORCED NOW: the IDP's NewUserRegistrar
|
|
316
|
+
* (above) as the backstop, and each app's own front-door sign-up gate
|
|
317
|
+
* (e.g. the portal's signup-gate.ts / SignUpCard) for the UI affordance.
|
|
318
|
+
* Both are correct and neither is this flag.
|
|
295
319
|
*/
|
|
296
|
-
disableSignUp: config.authSettings?.allowPublicRegistration !== true,
|
|
297
320
|
// Google is overly eager to reuse the last account unless we
|
|
298
321
|
// explicitly ask for account selection on each social login.
|
|
299
322
|
prompt: typeof rawPrompt === 'string'
|
|
@@ -984,11 +1007,17 @@ export async function getBetterAuthHandler(): Promise<{ GET: (req: Request) => P
|
|
|
984
1007
|
* header of the response to find the BA Redis key.
|
|
985
1008
|
*
|
|
986
1009
|
* This replaces the old databaseHooks approach which doesn't fire in stateless mode.
|
|
1010
|
+
*
|
|
1011
|
+
* RETURNS the IdP's own error code on an IDP refusal, or null otherwise
|
|
1012
|
+
* (PAY-2005 scenario (b), BAPert ruling 67897). It used to return a bare
|
|
1013
|
+
* boolean, so `REGISTRATION_NOT_ALLOWED` was indistinguishable from a network
|
|
1014
|
+
* blip and every cause collapsed to one generic login-page code — a NEW user
|
|
1015
|
+
* refused registration bounced silently, which is the failure Jon named.
|
|
987
1016
|
*/
|
|
988
1017
|
export async function exchangeOAuthForIdpTokens(
|
|
989
1018
|
sessionToken: string,
|
|
990
1019
|
provider: string = 'google'
|
|
991
|
-
): Promise<boolean> {
|
|
1020
|
+
): Promise<boolean | string> {
|
|
992
1021
|
try {
|
|
993
1022
|
const config = await getIDPClientConfig();
|
|
994
1023
|
const appSlug = config.clientSlug || getAppSlug();
|
|
@@ -1081,6 +1110,36 @@ export async function exchangeOAuthForIdpTokens(
|
|
|
1081
1110
|
if (!oauthRes.ok) {
|
|
1082
1111
|
console.error('[BETTER_AUTH] IDP oauth-callback failed:', oauthRes.status);
|
|
1083
1112
|
|
|
1113
|
+
/**
|
|
1114
|
+
* PAY-2005 scenario (b) — RETURN THE IDP'S ERROR CODE (BAPert ruling 67897).
|
|
1115
|
+
*
|
|
1116
|
+
* Every refusal used to return a bare `false`, so the abort collapsed ALL
|
|
1117
|
+
* causes to one generic `OAUTH_SIGN_IN_FAILED`: a NEW user refused
|
|
1118
|
+
* registration (`REGISTRATION_NOT_ALLOWED`, ExternalAuthController.cs:908)
|
|
1119
|
+
* bounced silently, indistinguishable from a network blip. Jon named the
|
|
1120
|
+
* clear message explicitly (67856): "(b) a brand-new Google user with
|
|
1121
|
+
* registration OFF is refused by the IDP with a clear message, not a
|
|
1122
|
+
* silent bounce."
|
|
1123
|
+
*
|
|
1124
|
+
* The envelope is `{ error: { code, message } }` (PayEzControllerBase.Error).
|
|
1125
|
+
* We read ONLY `error.code` and return it VERBATIM — the abort puts it in
|
|
1126
|
+
* the `?error=` param, and the HOST's allowlist decides whether to render
|
|
1127
|
+
* it. The package does NOT interpret the code and does NOT add a message
|
|
1128
|
+
* (that would be a second allowlist). No code in the body -> null (the
|
|
1129
|
+
* generic host code), exactly as before.
|
|
1130
|
+
*/
|
|
1131
|
+
let idpErrorCode: string | null = null;
|
|
1132
|
+
try {
|
|
1133
|
+
const parsed = JSON.parse(oauthResText);
|
|
1134
|
+
// FORWARD ONLY AN ALLOWLISTED CODE. Every other refusal keeps PAY-1733's single neutral code — an
|
|
1135
|
+
// unlisted code would render nothing on the host page, which is the silent bounce this exists to end.
|
|
1136
|
+
if (typeof parsed?.error?.code === 'string' && IDP_ERROR_CODES_TO_FORWARD.has(parsed.error.code)) {
|
|
1137
|
+
idpErrorCode = parsed.error.code;
|
|
1138
|
+
}
|
|
1139
|
+
} catch {
|
|
1140
|
+
/* non-JSON refusal body -> no code (generic abort) */
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1084
1143
|
/**
|
|
1085
1144
|
* WHY THIS LOGS ONLY ON FAILURE, AND WHY IT LOGS THESE FIELDS.
|
|
1086
1145
|
*
|
|
@@ -1128,7 +1187,7 @@ export async function exchangeOAuthForIdpTokens(
|
|
|
1128
1187
|
sent_client_id: resolveClientKey(config, 'auth/better-auth:oauth-callback').key ?? null,
|
|
1129
1188
|
idp_url: idpUrl,
|
|
1130
1189
|
});
|
|
1131
|
-
return false;
|
|
1190
|
+
return idpErrorCode ?? false;
|
|
1132
1191
|
}
|
|
1133
1192
|
|
|
1134
1193
|
let idpData: any;
|
|
@@ -1138,6 +1197,7 @@ export async function exchangeOAuthForIdpTokens(
|
|
|
1138
1197
|
if (!result?.access_token) {
|
|
1139
1198
|
console.warn('[BETTER_AUTH] IDP oauth-callback returned no access_token. Keys:', Object.keys(result || {}));
|
|
1140
1199
|
return false;
|
|
1200
|
+
|
|
1141
1201
|
}
|
|
1142
1202
|
|
|
1143
1203
|
// Build IDP token data
|
|
@@ -1248,6 +1308,7 @@ export async function exchangeOAuthForIdpTokens(
|
|
|
1248
1308
|
// Storage is shared with the passkey sign-in path — see storeIdpTokensInSession.
|
|
1249
1309
|
const stored = await storeIdpTokensInSession(sessionToken, idpTokenData, provider);
|
|
1250
1310
|
if (stored) console.log('[BETTER_AUTH] IDP tokens stored in session for', email);
|
|
1311
|
+
// true = success (tokens stored). false = a non-IdP failure with no specific code (generic abort).
|
|
1251
1312
|
return stored;
|
|
1252
1313
|
} catch (err) {
|
|
1253
1314
|
console.error('[BETTER_AUTH] IDP token exchange failed:', err instanceof Error ? err.message : String(err));
|
|
@@ -1255,6 +1316,7 @@ export async function exchangeOAuthForIdpTokens(
|
|
|
1255
1316
|
}
|
|
1256
1317
|
}
|
|
1257
1318
|
|
|
1319
|
+
|
|
1258
1320
|
/**
|
|
1259
1321
|
* Write IDP tokens into an EXISTING Better Auth session. Storage only — this
|
|
1260
1322
|
* function never acquires tokens and never decides who the caller is.
|
|
@@ -1351,6 +1413,19 @@ export async function storeIdpTokensInSession(
|
|
|
1351
1413
|
*/
|
|
1352
1414
|
export const OAUTH_SIGN_IN_FAILED_ERROR = 'OAUTH_SIGN_IN_FAILED';
|
|
1353
1415
|
|
|
1416
|
+
/**
|
|
1417
|
+
* The ONLY IdP refusal codes the package forwards to the login page (PAY-2005 (b), BAPert 67897).
|
|
1418
|
+
*
|
|
1419
|
+
* Default stays PAY-1733's ONE neutral code: the cause of an ordinary failed exchange lives in the server
|
|
1420
|
+
* log, never on the page. A code is forwarded ONLY when it earns a distinct user-facing message — today just
|
|
1421
|
+
* the registration refusal, so a NEW Google user with registration OFF sees a clear "not open to sign-up"
|
|
1422
|
+
* line instead of a silent bounce. Adding a code here REQUIRES a matching entry in the host allowlist
|
|
1423
|
+
* (portal: src/lib/auth/magic-link-outcome.ts); an unlisted code renders nothing, which is a silent bounce
|
|
1424
|
+
* wearing a different number. Add them together, never one alone.
|
|
1425
|
+
*/
|
|
1426
|
+
export const IDP_ERROR_CODES_TO_FORWARD: ReadonlySet<string> = new Set(['REGISTRATION_NOT_ALLOWED']);
|
|
1427
|
+
|
|
1428
|
+
|
|
1354
1429
|
/**
|
|
1355
1430
|
* Re-emit each cookie the callback response SET as an immediate expiry, keeping the attributes that decide
|
|
1356
1431
|
* WHICH cookie the browser overwrites (Path, Domain, Secure, SameSite, HttpOnly - a `__Secure-` name is only
|
|
@@ -1387,6 +1462,7 @@ async function abortOAuthSignIn(
|
|
|
1387
1462
|
sessionToken: string | null,
|
|
1388
1463
|
cookieHeader: string,
|
|
1389
1464
|
loginPath: string,
|
|
1465
|
+
errorCode: string = OAUTH_SIGN_IN_FAILED_ERROR,
|
|
1390
1466
|
): Promise<Response> {
|
|
1391
1467
|
const headers = new Headers();
|
|
1392
1468
|
if (sessionToken) {
|
|
@@ -1405,8 +1481,8 @@ async function abortOAuthSignIn(
|
|
|
1405
1481
|
}
|
|
1406
1482
|
}
|
|
1407
1483
|
for (const sc of expireSetCookies(callbackResponse.headers.getSetCookie?.() ?? [])) headers.append('set-cookie', sc);
|
|
1408
|
-
headers.set('location', `${loginPath}${loginPath.includes('?') ? '&' : '?'}error=${
|
|
1409
|
-
console.warn('[BETTER_AUTH] OAuth sign-in ABORTED: the IdP exchange stored no tokens, so no session is kept (PAY-1733)', sessionToken ? tokenLogId(sessionToken) : '(no session)');
|
|
1484
|
+
headers.set('location', `${loginPath}${loginPath.includes('?') ? '&' : '?'}error=${errorCode}`);
|
|
1485
|
+
console.warn('[BETTER_AUTH] OAuth sign-in ABORTED: the IdP exchange stored no tokens, so no session is kept (PAY-1733)', sessionToken ? tokenLogId(sessionToken) : '(no session)', 'code=' + errorCode);
|
|
1410
1486
|
return new Response(null, { status: 302, headers });
|
|
1411
1487
|
}
|
|
1412
1488
|
|
|
@@ -1498,7 +1574,9 @@ export function createAuthGetHandler(loginPath: string = '/account-auth/login')
|
|
|
1498
1574
|
.map((c: string) => c.split(';')[0])
|
|
1499
1575
|
.join('; ');
|
|
1500
1576
|
let sessionToken: string | null = null;
|
|
1501
|
-
|
|
1577
|
+
// true = tokens stored; false = no specific IdP code (generic abort); a string = the IdP's own refusal code
|
|
1578
|
+
// (PAY-2005 (b): REGISTRATION_NOT_ALLOWED, etc.), forwarded so the host can show a clear message.
|
|
1579
|
+
let exchange: boolean | string = false;
|
|
1502
1580
|
try {
|
|
1503
1581
|
const auth = await getBetterAuthInstance();
|
|
1504
1582
|
if (auth?.api?.getSession) {
|
|
@@ -1509,7 +1587,7 @@ export function createAuthGetHandler(loginPath: string = '/account-auth/login')
|
|
|
1509
1587
|
if (session?.session?.token) {
|
|
1510
1588
|
sessionToken = session.session.token;
|
|
1511
1589
|
console.log('[BETTER_AUTH] Got session token from callback:', tokenLogId(session.session.token), '| email:', session.user?.email);
|
|
1512
|
-
|
|
1590
|
+
exchange = await exchangeOAuthForIdpTokens(session.session.token);
|
|
1513
1591
|
} else {
|
|
1514
1592
|
console.warn('[BETTER_AUTH] Could not get session after OAuth callback');
|
|
1515
1593
|
}
|
|
@@ -1517,7 +1595,11 @@ export function createAuthGetHandler(loginPath: string = '/account-auth/login')
|
|
|
1517
1595
|
} catch (err: any) {
|
|
1518
1596
|
console.error('[BETTER_AUTH] IDP token exchange failed:', err.message);
|
|
1519
1597
|
}
|
|
1520
|
-
if (
|
|
1598
|
+
if (exchange !== true) {
|
|
1599
|
+
// A specific IdP error code goes on the URL; otherwise the generic OAUTH_SIGN_IN_FAILED.
|
|
1600
|
+
const errorCode = typeof exchange === 'string' ? exchange : OAUTH_SIGN_IN_FAILED_ERROR;
|
|
1601
|
+
return abortOAuthSignIn(response, sessionToken, cookieHeader, loginPath, errorCode);
|
|
1602
|
+
}
|
|
1521
1603
|
}
|
|
1522
1604
|
|
|
1523
1605
|
return response;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* resolveNextAuthSecret - COMPATIBILITY SHIM (restored 2026-09-30, PAY-2005 / N7).
|
|
3
|
+
*
|
|
4
|
+
* ---------------------------------------------------------------------------
|
|
5
|
+
* WHY THIS FILE EXISTS, AND WHY IT IS A SHIM RATHER THAN A FEATURE
|
|
6
|
+
* ---------------------------------------------------------------------------
|
|
7
|
+
* The NextAuth-era broker secret resolver was DELETED in
|
|
8
|
+
* 4d3d29e6 "feat!: Better Auth 4.0.0 — NextAuth fully removed"
|
|
9
|
+
* and it is intentionally NOT coming back as first-class API: this package is
|
|
10
|
+
* Better Auth now, and new apps must not resolve a NextAuth secret at all.
|
|
11
|
+
*
|
|
12
|
+
* It is restored here ONLY because ONE consumer is still mid-migration and cannot
|
|
13
|
+
* take the 4.4.x bump without it:
|
|
14
|
+
*
|
|
15
|
+
* CONSUMER THAT NEEDS THIS: the customer portal
|
|
16
|
+
* (`payez_idp_admin_web` / VibeID-Customer-Portal). It is pinned at
|
|
17
|
+
* @payez/next-mvp 4.0.46 and still runs BOTH next-auth@4.24 and better-auth; its
|
|
18
|
+
* `src/lib/server-session.ts` and `src/lib/session-token.ts` (and 5 route files)
|
|
19
|
+
* import `resolveNextAuthSecret` from here. Without this export, bumping the
|
|
20
|
+
* portal to 4.4.x breaks its build. BAPert ruled (68003) to ship this scoped
|
|
21
|
+
* shim rather than force the portal's full NextAuth->BetterAuth migration
|
|
22
|
+
* tonight. That migration is tracked as its own card; WHEN IT LANDS, DELETE
|
|
23
|
+
* THIS FILE.
|
|
24
|
+
*
|
|
25
|
+
* DO NOT add new consumers. If you are starting an app, use Better Auth's own
|
|
26
|
+
* secret handling; you do not need this.
|
|
27
|
+
*
|
|
28
|
+
* ---------------------------------------------------------------------------
|
|
29
|
+
* BEHAVIOUR (faithful to the 4.0.46 implementation, with one deliberate change)
|
|
30
|
+
* ---------------------------------------------------------------------------
|
|
31
|
+
* Priority: (1) process.env.NEXTAUTH_SECRET if set; (2) fetch from the IDP via a
|
|
32
|
+
* signed client assertion; (3) cache in-memory 5 min and stamp NEXTAUTH_SECRET.
|
|
33
|
+
*
|
|
34
|
+
* DELIBERATE CHANGE, 4.0.46 -> here: 4.0.46 validated the fetched secret with
|
|
35
|
+
* `validateNextAuthSecret`, which this package's 4.4.x no longer exports (it was
|
|
36
|
+
* renamed to `validateAuthSecret` with an IDENTICAL body). The shim calls
|
|
37
|
+
* `validateAuthSecret` - same predicate, current name - so there is no second
|
|
38
|
+
* copy of the rule. The validation semantics are unchanged.
|
|
39
|
+
*
|
|
40
|
+
* SERVER-ONLY: the IDP handshake and NEXTAUTH_SECRET are server concerns.
|
|
41
|
+
*/
|
|
42
|
+
import 'server-only';
|
|
43
|
+
import { randomUUID } from 'node:crypto';
|
|
44
|
+
import { validateAuthSecret } from './secret-validation';
|
|
45
|
+
|
|
46
|
+
let cachedSecret: string | null = null;
|
|
47
|
+
let lastFetchedAt = 0;
|
|
48
|
+
|
|
49
|
+
const CACHE_TTL_MS = 5 * 60 * 1000;
|
|
50
|
+
|
|
51
|
+
interface SigningResponse {
|
|
52
|
+
data?: { client_assertion?: string; clientAssertion?: string; ClientAssertion?: string };
|
|
53
|
+
client_assertion?: string;
|
|
54
|
+
clientAssertion?: string;
|
|
55
|
+
ClientAssertion?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface SecretResponse {
|
|
59
|
+
data?: { secret?: string; configuration?: unknown };
|
|
60
|
+
secret?: string;
|
|
61
|
+
configuration?: unknown;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolve the NextAuth secret (server-only).
|
|
66
|
+
*
|
|
67
|
+
* Priority:
|
|
68
|
+
* 1) Use process.env.NEXTAUTH_SECRET if present (allows overrides/production)
|
|
69
|
+
* 2) Fetch from the IDP broker endpoint - the IDP handles all Key Vault/signing
|
|
70
|
+
* 3) Cache result in-memory and set process.env.NEXTAUTH_SECRET for later calls
|
|
71
|
+
*/
|
|
72
|
+
export async function resolveNextAuthSecret(): Promise<string> {
|
|
73
|
+
// Check if already in environment
|
|
74
|
+
if (process.env.NEXTAUTH_SECRET && process.env.NEXTAUTH_SECRET.trim() !== '') {
|
|
75
|
+
return process.env.NEXTAUTH_SECRET;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Check if cached and fresh (within 5 minutes)
|
|
79
|
+
if (cachedSecret && Date.now() - lastFetchedAt < CACHE_TTL_MS) {
|
|
80
|
+
return cachedSecret;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Broker mode: fetch from the IDP (the IDP handles all Key Vault/signing)
|
|
84
|
+
const base = process.env.IDP_URL;
|
|
85
|
+
if (!base) throw new Error('IDP_URL environment variable is required');
|
|
86
|
+
|
|
87
|
+
const clientIdStr = process.env.CLIENT_ID;
|
|
88
|
+
if (!clientIdStr || clientIdStr.trim() === '') {
|
|
89
|
+
throw new Error('CLIENT_ID is required (e.g., "ideal_resume_website")');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Step 1: ask the IDP to sign a client assertion (the IDP holds the keys, not us)
|
|
93
|
+
const signingUrl = `${base.replace(/\/$/, '')}/api/ExternalAuth/sign-client-assertion`;
|
|
94
|
+
const signingResp = await fetch(signingUrl, {
|
|
95
|
+
method: 'POST',
|
|
96
|
+
headers: {
|
|
97
|
+
Accept: 'application/json',
|
|
98
|
+
'Content-Type': 'application/json',
|
|
99
|
+
'X-Client-Id': clientIdStr,
|
|
100
|
+
'X-Correlation-Id': randomUUID().replace(/-/g, ''),
|
|
101
|
+
},
|
|
102
|
+
body: JSON.stringify({
|
|
103
|
+
issuer: clientIdStr,
|
|
104
|
+
subject: clientIdStr,
|
|
105
|
+
audience: 'urn:payez:externalauth:nextauthsecret',
|
|
106
|
+
expires_in: 60,
|
|
107
|
+
}),
|
|
108
|
+
cache: 'no-store',
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
if (!signingResp.ok) {
|
|
112
|
+
const txt = await signingResp.text().catch(() => 'Unknown error');
|
|
113
|
+
throw new Error(`Failed to sign client assertion: ${signingResp.status} ${signingResp.statusText} - ${txt}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const signingBody = (await signingResp.json().catch(() => ({}))) as SigningResponse;
|
|
117
|
+
const clientAssertion =
|
|
118
|
+
signingBody?.data?.client_assertion ??
|
|
119
|
+
signingBody?.data?.clientAssertion ??
|
|
120
|
+
signingBody?.client_assertion ??
|
|
121
|
+
signingBody?.clientAssertion ??
|
|
122
|
+
signingBody?.data?.ClientAssertion ??
|
|
123
|
+
signingBody?.ClientAssertion;
|
|
124
|
+
|
|
125
|
+
if (!clientAssertion || typeof clientAssertion !== 'string') {
|
|
126
|
+
throw new Error('IDP did not return a valid signed client assertion');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Step 2: use the signed assertion to fetch the NextAuth secret
|
|
130
|
+
const proxyUrl = `${base.replace(/\/$/, '')}/api/ExternalAuth/next-auth/secret`;
|
|
131
|
+
const proxyResp = await fetch(proxyUrl, {
|
|
132
|
+
method: 'POST',
|
|
133
|
+
headers: {
|
|
134
|
+
Accept: 'application/json',
|
|
135
|
+
'Content-Type': 'application/json',
|
|
136
|
+
'X-Client-Id': clientIdStr,
|
|
137
|
+
'X-Correlation-Id': randomUUID().replace(/-/g, ''),
|
|
138
|
+
},
|
|
139
|
+
body: JSON.stringify({ client_assertion: clientAssertion }),
|
|
140
|
+
cache: 'no-store',
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
if (!proxyResp.ok) {
|
|
144
|
+
const txt = await proxyResp.text().catch(() => 'Unknown error');
|
|
145
|
+
throw new Error(`Proxy error: ${proxyResp.status} ${proxyResp.statusText} - ${txt}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const proxyBody = (await proxyResp.json().catch(() => ({}))) as SecretResponse;
|
|
149
|
+
const secret = proxyBody?.data?.secret ?? proxyBody?.secret;
|
|
150
|
+
// `configuration` is available from the IDP but deliberately not logged.
|
|
151
|
+
|
|
152
|
+
if (!secret || typeof secret !== 'string') {
|
|
153
|
+
throw new Error('Proxy did not return a valid NextAuth secret');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Same predicate as 4.0.46's validateNextAuthSecret, under its current name.
|
|
157
|
+
const validation = validateAuthSecret(secret);
|
|
158
|
+
if (!validation.valid) {
|
|
159
|
+
throw new Error(`Fetched NextAuth secret failed validation: ${validation.reason}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
cachedSecret = secret;
|
|
163
|
+
lastFetchedAt = Date.now();
|
|
164
|
+
process.env.NEXTAUTH_SECRET = secret;
|
|
165
|
+
|
|
166
|
+
console.log('[NEXTAUTH-SECRET] Resolved from IDP (length:', secret.length + ')');
|
|
167
|
+
return secret;
|
|
168
|
+
}
|
|
@@ -1,103 +1,103 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Vibe document wrapper + unwrapping.
|
|
3
|
-
*
|
|
4
|
-
* PAY-1767: extracted from generic.ts into its own module so the typed client (client.ts) can unwrap
|
|
5
|
-
* reads WITHOUT importing generic.ts - generic.ts imports client.ts, so a shared helper there would be
|
|
6
|
-
* a cycle. This module imports nothing from the vibe client.
|
|
7
|
-
*
|
|
8
|
-
* Vibe stores a row's own fields as a JSON STRING under the wrapper's `data` field, alongside storage
|
|
9
|
-
* columns (document_id, owner, timestamps). A caller that renders `response.data` directly sees the
|
|
10
|
-
* wrapper, so `row.name` is undefined. unwrapVibeDocument flattens the row out and keeps the storage
|
|
11
|
-
* document_id as `_vibe_doc_id` for update/delete paths.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
/** Vibe document wrapper structure. Vibe stores actual table data as a JSON string in the 'data' field. */
|
|
15
|
-
export interface VibeDocumentWrapper {
|
|
16
|
-
document_id: number;
|
|
17
|
-
client_id?: number;
|
|
18
|
-
user_id?: number;
|
|
19
|
-
collection?: string;
|
|
20
|
-
table_name?: string;
|
|
21
|
-
data: string | Record<string, unknown>;
|
|
22
|
-
collection_schema_id?: number;
|
|
23
|
-
created_at?: string;
|
|
24
|
-
created_by?: number;
|
|
25
|
-
updated_at?: string | null;
|
|
26
|
-
updated_by?: number | null;
|
|
27
|
-
deleted_at?: string | null;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Unwrap a Vibe document response.
|
|
32
|
-
*
|
|
33
|
-
* @param doc - Raw Vibe document (wrapper format)
|
|
34
|
-
* @returns Unwrapped document with schema fields only; storage document_id preserved as `_vibe_doc_id`
|
|
35
|
-
* for update/delete paths. See PayEz-Core/docs/vibe-primary-key-standard.md
|
|
36
|
-
*
|
|
37
|
-
* @example
|
|
38
|
-
* const raw = { document_id: 123, data: '{"user_id":15,"name":"John"}' };
|
|
39
|
-
* unwrapVibeDocument(raw); // => { user_id: 15, name: 'John', _vibe_doc_id: 123 }
|
|
40
|
-
*/
|
|
41
|
-
export function unwrapVibeDocument<T extends object = Record<string, unknown>>(
|
|
42
|
-
doc: VibeDocumentWrapper | Record<string, unknown> | null | undefined
|
|
43
|
-
): (T & { _vibe_doc_id?: number }) | null {
|
|
44
|
-
if (!doc) return null;
|
|
45
|
-
|
|
46
|
-
// Handle case where doc is already unwrapped (has schema fields directly)
|
|
47
|
-
if (!('document_id' in doc) && !('data' in doc)) {
|
|
48
|
-
return doc as T & { _vibe_doc_id?: number };
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const wrapper = doc as VibeDocumentWrapper;
|
|
52
|
-
let parsedData: Record<string, unknown> = {};
|
|
53
|
-
|
|
54
|
-
if (typeof wrapper.data === 'string') {
|
|
55
|
-
try {
|
|
56
|
-
parsedData = JSON.parse(wrapper.data);
|
|
57
|
-
} catch {
|
|
58
|
-
console.warn('[vibe-document] Failed to parse document data JSON:', wrapper.data);
|
|
59
|
-
parsedData = {};
|
|
60
|
-
}
|
|
61
|
-
} else if (typeof wrapper.data === 'object' && wrapper.data !== null) {
|
|
62
|
-
parsedData = wrapper.data;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// Preserve document_id as _vibe_doc_id for Vibe API update/delete paths only
|
|
66
|
-
const documentId = wrapper.document_id ?? (doc as { document_id?: number }).document_id;
|
|
67
|
-
if (documentId != null) {
|
|
68
|
-
parsedData._vibe_doc_id = documentId;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
return parsedData as T & { _vibe_doc_id?: number };
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Extract and unwrap an array of documents from a Vibe response.
|
|
76
|
-
*
|
|
77
|
-
* @param responseData - Raw Vibe API response ({ data: [...] }, { items: [...] }, { documents: [...] } or a bare array)
|
|
78
|
-
* @returns Array of unwrapped documents
|
|
79
|
-
*/
|
|
80
|
-
export function extractVibeDocuments<T extends Record<string, unknown> = Record<string, unknown>>(
|
|
81
|
-
responseData: unknown
|
|
82
|
-
): Array<T & { _vibe_doc_id?: number }> {
|
|
83
|
-
if (!responseData || typeof responseData !== 'object') {
|
|
84
|
-
return [];
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const data = responseData as Record<string, unknown>;
|
|
88
|
-
|
|
89
|
-
// Try common response shapes
|
|
90
|
-
const docs =
|
|
91
|
-
data.data ??
|
|
92
|
-
data.items ??
|
|
93
|
-
data.documents ??
|
|
94
|
-
(Array.isArray(responseData) ? responseData : null);
|
|
95
|
-
|
|
96
|
-
if (!Array.isArray(docs)) {
|
|
97
|
-
return [];
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
return docs
|
|
101
|
-
.map((doc) => unwrapVibeDocument<T>(doc))
|
|
102
|
-
.filter((d): d is T & { _vibe_doc_id?: number } => d !== null);
|
|
103
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Vibe document wrapper + unwrapping.
|
|
3
|
+
*
|
|
4
|
+
* PAY-1767: extracted from generic.ts into its own module so the typed client (client.ts) can unwrap
|
|
5
|
+
* reads WITHOUT importing generic.ts - generic.ts imports client.ts, so a shared helper there would be
|
|
6
|
+
* a cycle. This module imports nothing from the vibe client.
|
|
7
|
+
*
|
|
8
|
+
* Vibe stores a row's own fields as a JSON STRING under the wrapper's `data` field, alongside storage
|
|
9
|
+
* columns (document_id, owner, timestamps). A caller that renders `response.data` directly sees the
|
|
10
|
+
* wrapper, so `row.name` is undefined. unwrapVibeDocument flattens the row out and keeps the storage
|
|
11
|
+
* document_id as `_vibe_doc_id` for update/delete paths.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Vibe document wrapper structure. Vibe stores actual table data as a JSON string in the 'data' field. */
|
|
15
|
+
export interface VibeDocumentWrapper {
|
|
16
|
+
document_id: number;
|
|
17
|
+
client_id?: number;
|
|
18
|
+
user_id?: number;
|
|
19
|
+
collection?: string;
|
|
20
|
+
table_name?: string;
|
|
21
|
+
data: string | Record<string, unknown>;
|
|
22
|
+
collection_schema_id?: number;
|
|
23
|
+
created_at?: string;
|
|
24
|
+
created_by?: number;
|
|
25
|
+
updated_at?: string | null;
|
|
26
|
+
updated_by?: number | null;
|
|
27
|
+
deleted_at?: string | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Unwrap a Vibe document response.
|
|
32
|
+
*
|
|
33
|
+
* @param doc - Raw Vibe document (wrapper format)
|
|
34
|
+
* @returns Unwrapped document with schema fields only; storage document_id preserved as `_vibe_doc_id`
|
|
35
|
+
* for update/delete paths. See PayEz-Core/docs/vibe-primary-key-standard.md
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* const raw = { document_id: 123, data: '{"user_id":15,"name":"John"}' };
|
|
39
|
+
* unwrapVibeDocument(raw); // => { user_id: 15, name: 'John', _vibe_doc_id: 123 }
|
|
40
|
+
*/
|
|
41
|
+
export function unwrapVibeDocument<T extends object = Record<string, unknown>>(
|
|
42
|
+
doc: VibeDocumentWrapper | Record<string, unknown> | null | undefined
|
|
43
|
+
): (T & { _vibe_doc_id?: number }) | null {
|
|
44
|
+
if (!doc) return null;
|
|
45
|
+
|
|
46
|
+
// Handle case where doc is already unwrapped (has schema fields directly)
|
|
47
|
+
if (!('document_id' in doc) && !('data' in doc)) {
|
|
48
|
+
return doc as T & { _vibe_doc_id?: number };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const wrapper = doc as VibeDocumentWrapper;
|
|
52
|
+
let parsedData: Record<string, unknown> = {};
|
|
53
|
+
|
|
54
|
+
if (typeof wrapper.data === 'string') {
|
|
55
|
+
try {
|
|
56
|
+
parsedData = JSON.parse(wrapper.data);
|
|
57
|
+
} catch {
|
|
58
|
+
console.warn('[vibe-document] Failed to parse document data JSON:', wrapper.data);
|
|
59
|
+
parsedData = {};
|
|
60
|
+
}
|
|
61
|
+
} else if (typeof wrapper.data === 'object' && wrapper.data !== null) {
|
|
62
|
+
parsedData = wrapper.data;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Preserve document_id as _vibe_doc_id for Vibe API update/delete paths only
|
|
66
|
+
const documentId = wrapper.document_id ?? (doc as { document_id?: number }).document_id;
|
|
67
|
+
if (documentId != null) {
|
|
68
|
+
parsedData._vibe_doc_id = documentId;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return parsedData as T & { _vibe_doc_id?: number };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Extract and unwrap an array of documents from a Vibe response.
|
|
76
|
+
*
|
|
77
|
+
* @param responseData - Raw Vibe API response ({ data: [...] }, { items: [...] }, { documents: [...] } or a bare array)
|
|
78
|
+
* @returns Array of unwrapped documents
|
|
79
|
+
*/
|
|
80
|
+
export function extractVibeDocuments<T extends Record<string, unknown> = Record<string, unknown>>(
|
|
81
|
+
responseData: unknown
|
|
82
|
+
): Array<T & { _vibe_doc_id?: number }> {
|
|
83
|
+
if (!responseData || typeof responseData !== 'object') {
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const data = responseData as Record<string, unknown>;
|
|
88
|
+
|
|
89
|
+
// Try common response shapes
|
|
90
|
+
const docs =
|
|
91
|
+
data.data ??
|
|
92
|
+
data.items ??
|
|
93
|
+
data.documents ??
|
|
94
|
+
(Array.isArray(responseData) ? responseData : null);
|
|
95
|
+
|
|
96
|
+
if (!Array.isArray(docs)) {
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return docs
|
|
101
|
+
.map((doc) => unwrapVibeDocument<T>(doc))
|
|
102
|
+
.filter((d): d is T & { _vibe_doc_id?: number } => d !== null);
|
|
103
|
+
}
|