@payez/next-mvp 4.4.8 → 4.5.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 (75) hide show
  1. package/README.md +53 -0
  2. package/dist/api-handlers/account/reset-password.js +3 -1
  3. package/dist/api-handlers/onboarding/send-email-code.d.ts +11 -0
  4. package/dist/api-handlers/onboarding/send-email-code.js +11 -0
  5. package/dist/api-handlers/onboarding/set-password.d.ts +12 -0
  6. package/dist/api-handlers/onboarding/set-password.js +15 -0
  7. package/dist/api-handlers/onboarding/verify-code.d.ts +11 -0
  8. package/dist/api-handlers/onboarding/verify-code.js +14 -0
  9. package/dist/api-handlers/onboarding/verify-welcome-email.d.ts +10 -0
  10. package/dist/api-handlers/onboarding/verify-welcome-email.js +11 -0
  11. package/dist/api-handlers/session/viability.js +32 -0
  12. package/dist/auth/password-reset-bridge.d.ts +70 -0
  13. package/dist/auth/password-reset-bridge.js +99 -0
  14. package/dist/auth/route-config.js +17 -0
  15. package/dist/components/auth/FederatedAuthSection.js +50 -21
  16. package/dist/components/auth/PasswordResetBridge.d.ts +17 -0
  17. package/dist/components/auth/PasswordResetBridge.js +32 -0
  18. package/dist/hooks/useAvailableProviders.js +39 -17
  19. package/dist/lib/onboarding-proxy.d.ts +30 -0
  20. package/dist/lib/onboarding-proxy.js +62 -0
  21. package/dist/lib/safe-error-response.d.ts +8 -0
  22. package/dist/lib/safe-error-response.js +27 -0
  23. package/dist/middleware/create-middleware.d.ts +41 -0
  24. package/dist/middleware/create-middleware.js +74 -2
  25. package/dist/pages/login/page.js +1 -1
  26. package/dist/pages/onboarding/index.d.ts +11 -0
  27. package/dist/pages/onboarding/index.js +20 -0
  28. package/dist/pages/onboarding/page.d.ts +1 -0
  29. package/dist/pages/onboarding/page.js +118 -0
  30. package/dist/pages/recovery/page.js +20 -7
  31. package/dist/routes/auth/viability.d.ts +5 -0
  32. package/dist/routes/auth/viability.js +53 -0
  33. package/dist/routes/onboarding/index.d.ts +15 -0
  34. package/dist/routes/onboarding/index.js +22 -0
  35. package/dist/routes/onboarding/send-email-code.d.ts +2 -0
  36. package/dist/routes/onboarding/send-email-code.js +6 -0
  37. package/dist/routes/onboarding/set-password.d.ts +2 -0
  38. package/dist/routes/onboarding/set-password.js +6 -0
  39. package/dist/routes/onboarding/verify-code.d.ts +2 -0
  40. package/dist/routes/onboarding/verify-code.js +6 -0
  41. package/dist/routes/onboarding/verify-welcome-email.d.ts +2 -0
  42. package/dist/routes/onboarding/verify-welcome-email.js +6 -0
  43. package/dist/utils/api.d.ts +6 -0
  44. package/dist/utils/api.js +10 -2
  45. package/dist/vibe/sessions.d.ts +88 -1
  46. package/dist/vibe/sessions.js +260 -19
  47. package/package.json +58 -2
  48. package/scripts/check-publish-eol.mjs +135 -0
  49. package/src/api-handlers/account/reset-password.ts +4 -2
  50. package/src/api-handlers/onboarding/send-email-code.ts +18 -0
  51. package/src/api-handlers/onboarding/set-password.ts +21 -0
  52. package/src/api-handlers/onboarding/verify-code.ts +20 -0
  53. package/src/api-handlers/onboarding/verify-welcome-email.ts +17 -0
  54. package/src/api-handlers/session/viability.ts +31 -0
  55. package/src/auth/password-reset-bridge.ts +98 -0
  56. package/src/auth/route-config.ts +17 -0
  57. package/src/components/auth/FederatedAuthSection.tsx +56 -22
  58. package/src/components/auth/PasswordResetBridge.tsx +30 -0
  59. package/src/hooks/useAvailableProviders.ts +41 -18
  60. package/src/lib/onboarding-proxy.ts +57 -0
  61. package/src/lib/safe-error-response.ts +23 -0
  62. package/src/middleware/create-middleware.ts +106 -2
  63. package/src/pages/login/page.tsx +4 -7
  64. package/src/pages/onboarding/index.ts +11 -0
  65. package/src/pages/onboarding/page.tsx +212 -0
  66. package/src/pages/recovery/page.tsx +24 -8
  67. package/src/routes/auth/viability.ts +52 -0
  68. package/src/routes/onboarding/index.ts +15 -0
  69. package/src/routes/onboarding/send-email-code.ts +2 -0
  70. package/src/routes/onboarding/set-password.ts +2 -0
  71. package/src/routes/onboarding/verify-code.ts +2 -0
  72. package/src/routes/onboarding/verify-welcome-email.ts +2 -0
  73. package/src/utils/api.ts +10 -1
  74. package/src/vibe/document-unwrap.ts +103 -103
  75. package/src/vibe/sessions.ts +291 -24
@@ -42,8 +42,14 @@ exports.revokeAllUserSessions = revokeAllUserSessions;
42
42
  exports.updateSessionActivity = updateSessionActivity;
43
43
  exports.isSessionValid = isSessionValid;
44
44
  exports.checkSessionRevocation = checkSessionRevocation;
45
+ exports.hashSessionToken = hashSessionToken;
46
+ exports.resolveNumericIdpUserId = resolveNumericIdpUserId;
47
+ exports.warnNonNumericUserId = warnNonNumericUserId;
48
+ exports.readSessionRevocationHmac = readSessionRevocationHmac;
49
+ exports.getRevocationStatus = getRevocationStatus;
45
50
  exports.getSessionStats = getSessionStats;
46
51
  const crypto_1 = __importDefault(require("crypto"));
52
+ const token_utils_1 = require("../auth/utils/token-utils");
47
53
  // -----------------------------------------------------------------------------
48
54
  // CONFIGURATION
49
55
  // -----------------------------------------------------------------------------
@@ -187,7 +193,9 @@ async function createLoginSession(input) {
187
193
  async function getUserSessions(idpUserId, accessToken) {
188
194
  const endpoint = `/v1/collections/${COLLECTION}/tables/${TABLE}/query`;
189
195
  const result = await vibeProxyRequest(endpoint, 'POST', accessToken, {
190
- filter: [{ field: 'idp_user_id', operator: 'eq', value: idpUserId }],
196
+ // PAY-2037: a flat VibeFilterNode OBJECT, as idealresume sends it (vibe-sessions.ts:220). Core binds
197
+ // QueryRequest.Filter as an object (VibeQueryBuilder.cs IsFlatFilterFormat); an ARRAY does not bind.
198
+ filter: { field: 'idp_user_id', operator: 'eq', value: idpUserId },
191
199
  pageSize: 100,
192
200
  });
193
201
  return result?.data || result?.items || [];
@@ -206,11 +214,12 @@ async function getAllSessions(options = {}, accessToken) {
206
214
  const query = {
207
215
  page,
208
216
  pageSize,
209
- orderBy: 'created_at',
210
- orderDir: 'desc',
217
+ // PAY-2037: Vibe's QueryRequest.OrderBy is an OBJECT; a string is a 400 and vibeProxyRequest
218
+ // turned that into an empty list. There is no `orderDir` (QAPert 69149).
219
+ orderBy: { created_at: 'desc' },
211
220
  };
212
221
  if (options.status) {
213
- query.filter = [{ field: 'status', operator: 'eq', value: options.status }];
222
+ query.filter = { field: 'status', operator: 'eq', value: options.status };
214
223
  }
215
224
  const endpoint = `/v1/collections/${COLLECTION}/tables/${TABLE}/query`;
216
225
  const result = await vibeProxyRequest(endpoint, 'POST', accessToken, query);
@@ -243,8 +252,9 @@ async function getSessionById(sessionId, accessToken) {
243
252
  */
244
253
  async function revokeSession(sessionId, adminEmail, accessToken, reason) {
245
254
  const now = new Date().toISOString();
255
+ // PAY-2037: PATCH, not PUT - a PUT replaces the row and this body carries 4 of its fields.
246
256
  const endpoint = `/v1/collections/${COLLECTION}/tables/${TABLE}/${sessionId}`;
247
- const session = await vibeProxyRequest(endpoint, 'PUT', accessToken, {
257
+ const session = await vibeProxyRequest(endpoint, 'PATCH', accessToken, {
248
258
  status: 'revoked',
249
259
  revoked_by: adminEmail,
250
260
  revoked_at: now,
@@ -290,7 +300,7 @@ async function revokeAllUserSessions(idpUserId, adminEmail, accessToken, reason)
290
300
  */
291
301
  async function updateSessionActivity(sessionId, accessToken) {
292
302
  const endpoint = `/v1/collections/${COLLECTION}/tables/${TABLE}/${sessionId}`;
293
- const result = await vibeProxyRequest(endpoint, 'PUT', accessToken, { last_activity: new Date().toISOString() });
303
+ const result = await vibeProxyRequest(endpoint, 'PATCH', accessToken, { last_activity: new Date().toISOString() });
294
304
  return result !== null;
295
305
  }
296
306
  // -----------------------------------------------------------------------------
@@ -325,23 +335,254 @@ async function isSessionValid(sessionId, accessToken) {
325
335
  * @param accessToken - Access token for auth
326
336
  * @param sessionTokenHash - Optional token hash to check specific session
327
337
  * @returns Revoked session info if enforcement needed, null otherwise
338
+ *
339
+ * @deprecated PAY-2037 step 1 (BAPert 69153): kept so no consumer breaks. It is a thin call into the
340
+ * same hash-first decision as getRevocationStatus; prefer getRevocationStatus, which distinguishes
341
+ * a failed read ('unknown') from 'active' instead of throwing.
328
342
  */
329
- async function checkSessionRevocation(idpUserId, accessToken, sessionTokenHash) {
330
- const sessions = await getUserSessions(idpUserId, accessToken);
331
- // If we have a specific token hash, check that session
343
+ async function checkSessionRevocation(idpUserId, _accessToken, sessionTokenHash) {
344
+ // PAY-2037 step 1: same decision as getRevocationStatus, same HMAC-only read. The bearer is
345
+ // accepted for signature compatibility and never sent. A FAILED read throws - it must not read
346
+ // as "no revoked session" (that is the PAY-1880 fail-open this replaces).
347
+ const { ok, sessions } = await readSessionRevocationHmac(idpUserId);
348
+ if (!ok) {
349
+ throw new Error('[vibe-sessions] session revocation read failed (fail closed)');
350
+ }
351
+ return findRevokingSession(sessions, sessionTokenHash);
352
+ }
353
+ // BAPert 66320: log 'not_configured' ONCE, not per request - a Vibe-less
354
+ // deployment would otherwise emit a warn line on every viability check.
355
+ let warnedNotConfigured = false;
356
+ /**
357
+ * Hash a session token the same way record-login stores it.
358
+ * SHA256 hex, 64 chars. This must stay byte-identical to
359
+ * `hashSessionToken` in the consumers (idealresume.online/lib/session-token-hash.ts)
360
+ * or per-session matching silently falls back to the user-wide path.
361
+ */
362
+ function hashSessionToken(token) {
363
+ return crypto_1.default.createHash('sha256').update(token).digest('hex');
364
+ }
365
+ /**
366
+ * BAPert 66480 condition 2 (ruling (ii), 66700; corrected per QAPert 66729):
367
+ * resolve the NUMERIC IdP user id the Vibe query filters on.
368
+ *
369
+ * *** DO NOT READ `sub` FOR THE USER ID. *** In the IdP's bearer token `sub` is the
370
+ * USERNAME, not the id: AESGCM_Encryption.cs:815 writes `{ "sub", payload.Username }`
371
+ * and :822 writes `{ "user_id", payload.UserId.ToString() }`. A username is commonly
372
+ * an email, so reading `sub` yields 0 for the common case - and for a username that
373
+ * happens to be all digits it yields the WRONG id, which getRevocationStatus answers
374
+ * 'active' for a user with no rows: a fail-OPEN.
375
+ *
376
+ * Resolution order:
377
+ * 1. the token's `user_id` claim, when it is all digits;
378
+ * 2. a digits-only session.userId / session.user.id;
379
+ * 3. otherwise 0 (unresolvable), which the caller reports as 'unknown' (fail closed).
380
+ *
381
+ * session.userId is not a safe source on its own either: session-store.ts:143 falls
382
+ * back to user.email, and better-auth.ts stringifies whichever id it finds.
383
+ */
384
+ function resolveNumericIdpUserId(session, accessToken) {
385
+ const isDigits = (v) => /^[0-9]+$/.test(String(v ?? ''));
386
+ if (accessToken) {
387
+ // The raw payload (jwtDecode returns every claim, typed or not); user_id, never sub.
388
+ const payload = (0, token_utils_1.decodeIdpAccessToken)(accessToken);
389
+ const claim = payload?.user_id;
390
+ if (isDigits(claim))
391
+ return Number(claim);
392
+ }
393
+ const s = (session ?? {});
394
+ const fromSession = s.userId ?? s.user?.id;
395
+ return isDigits(fromSession) ? Number(fromSession) : 0;
396
+ }
397
+ /**
398
+ * BAPert 66480 condition 1: WARN once per session when the numeric IdP user id
399
+ * could not be resolved (userId is email-shaped, empty, or otherwise not all
400
+ * digits). A non-numeric id must show up as a log line, not as an unexplained
401
+ * sign-out. Shape only - NEVER the value.
402
+ *
403
+ * Keyed on a hash of the session token, so one WARN per session; the set is
404
+ * bounded so a long-lived process cannot grow it without limit.
405
+ */
406
+ const warnedNonNumericUserIds = new Set();
407
+ function shapeOfUserId(v) {
408
+ const str = String(v ?? '');
409
+ if (!str)
410
+ return 'empty:0';
411
+ const kind = /^[0-9]+$/.test(str) ? 'digits' : str.includes('@') ? 'email' : 'other';
412
+ return `${kind}:${str.length}`;
413
+ }
414
+ function warnNonNumericUserIdOnce(sessionToken, rawUserId) {
415
+ const key = sessionToken ? hashSessionToken(sessionToken).slice(0, 16) : `nosession:${shapeOfUserId(rawUserId)}`;
416
+ if (warnedNonNumericUserIds.has(key))
417
+ return;
418
+ if (warnedNonNumericUserIds.size > 5000)
419
+ warnedNonNumericUserIds.clear();
420
+ warnedNonNumericUserIds.add(key);
421
+ console.warn('[REVOCATION] non-numeric userId, failing closed', { shape: shapeOfUserId(rawUserId) });
422
+ }
423
+ /**
424
+ * Public entry point for the twins: call this when a session exists but its
425
+ * userId is not a usable numeric IdP id. Returns 'unknown' (fail closed) and
426
+ * emits the once-per-session WARN. Shape only, never the value.
427
+ */
428
+ function warnNonNumericUserId(sessionToken, rawUserId) {
429
+ warnNonNumericUserIdOnce(sessionToken, rawUserId);
430
+ return 'unknown';
431
+ }
432
+ /**
433
+ * HMAC-ONLY session read for the revocation check (PAY-1882 step 4).
434
+ *
435
+ * *** DO NOT "SIMPLIFY" THIS ONTO vibeProxyRequest/getUserSessions. ***
436
+ * It is deliberately separate and deliberately sends NO Authorization header.
437
+ *
438
+ * WHY: the twin this must match is idealresume.online's checkSessionRevocation
439
+ * read. That repo's vibe-client adds `Authorization: Bearer` ONLY for member
440
+ * paths (`path.startsWith('/v1/my/')`) - see idealresume.online/lib/vibe-client.ts
441
+ * :207-210 - so its /v1/collections/.../query for this check goes out HMAC-only.
442
+ * The comment at vibe-client.ts:188-206 there records why: a bearer on the
443
+ * collections path caused USER_REQUIRED and broke signups. getUserSessions()
444
+ * here ALWAYS sends the bearer, so it cannot stand in for this read without
445
+ * changing the credential the two twins are supposed to share.
446
+ *
447
+ * BAPert 66344 (option a): match the twin, HMAC-only.
448
+ *
449
+ * Also distinguishes a FAILED read from an EMPTY one - getUserSessions()
450
+ * collapses a proxy outage and "user has no sessions" to `[]`, which is
451
+ * fail-OPEN for a revocation decision. Returns `ok:false` on an outage so the
452
+ * caller fails closed, and `ok:true, sessions:[]` only for a healthy empty.
453
+ */
454
+ async function readSessionRevocationHmac(idpUserId) {
455
+ const idpUrl = getEnv('IDP_URL');
456
+ const clientId = getEnv('VIBE_CLIENT_ID');
457
+ const signingKey = getEnv('VIBE_HMAC_KEY');
458
+ if (!idpUrl || !clientId || !signingKey) {
459
+ // Caller distinguishes this from a read failure; report ok:false, not [].
460
+ return { ok: false, sessions: [] };
461
+ }
462
+ const endpoint = `/v1/collections/${COLLECTION}/tables/${TABLE}/query`;
463
+ const proxyUrl = `${idpUrl}/api/vibe/proxy`;
464
+ const timestamp = Math.floor(Date.now() / 1000);
465
+ const signature = generateHmacSignature(signingKey, timestamp, 'POST', endpoint);
466
+ // NOTE the absence of Authorization. This is the twin's credential.
467
+ const headers = {
468
+ 'Content-Type': 'application/json',
469
+ 'X-Vibe-Client-Id': clientId,
470
+ 'X-Vibe-Timestamp': String(timestamp),
471
+ 'X-Vibe-Signature': signature,
472
+ };
473
+ try {
474
+ const response = await fetch(proxyUrl, {
475
+ method: 'POST',
476
+ headers,
477
+ body: JSON.stringify({
478
+ endpoint,
479
+ method: 'POST',
480
+ data: {
481
+ // PAY-2037: a flat VibeFilterNode OBJECT, as idealresume sends it (vibe-sessions.ts:220). Core binds
482
+ // QueryRequest.Filter as an object (VibeQueryBuilder.cs IsFlatFilterFormat); an ARRAY does not bind.
483
+ filter: { field: 'idp_user_id', operator: 'eq', value: idpUserId },
484
+ pageSize: 100,
485
+ },
486
+ }),
487
+ });
488
+ if (!response.ok) {
489
+ // PAY-1427: never the raw proxy body - an error page can echo the request.
490
+ console.error(`[vibe-sessions] Revocation read proxy error ${response.status}`);
491
+ return { ok: false, sessions: [] };
492
+ }
493
+ const data = await response.json();
494
+ const unwrapped = (data?.data ?? data);
495
+ return { ok: true, sessions: unwrapped?.data || unwrapped?.items || [] };
496
+ }
497
+ catch (error) {
498
+ console.error('[vibe-sessions] Revocation read failed:', error);
499
+ return { ok: false, sessions: [] };
500
+ }
501
+ }
502
+ /**
503
+ * THE one revocation decision (PAY-2037 step 1, BAPert 69150): hash-first, per session.
504
+ * Returns the row that revokes THIS session, or null when it is not revoked.
505
+ *
506
+ * A session whose own token hash matches a row is decided by that row ALONE. A matched ACTIVE
507
+ * session is never logged out because another device was revoked (idealresume PAY-1882 F3).
508
+ * Only when no row carries the hash (a legacy row minted before hashing) does the user-wide
509
+ * rule apply: no active row -> the most recently revoked (none revoked: not revoked); else any revoked inside 2h.
510
+ */
511
+ function findRevokingSession(sessions, sessionTokenHash) {
512
+ if (sessions.length === 0)
513
+ return null;
332
514
  if (sessionTokenHash) {
333
- const matchingSession = sessions.find(s => s.session_token_hash === sessionTokenHash);
334
- if (matchingSession && matchingSession.status === 'revoked') {
335
- return matchingSession;
515
+ const matching = sessions.find(s => s.session_token_hash === sessionTokenHash);
516
+ if (matching)
517
+ return matching.status === 'revoked' ? matching : null;
518
+ }
519
+ // Hash not found (a legacy row minted before hashing) or no hash given: idealresume's user-wide
520
+ // rules, lib/vibe-sessions.ts:594-651 (BAPert 69189).
521
+ const revokedAt = (s) => (s.revoked_at ? new Date(s.revoked_at).getTime() : 0);
522
+ const newestFirst = (x, y) => revokedAt(y) - revokedAt(x);
523
+ // Check 1: no active row. The most recently revoked row (whenever it was revoked) is the verdict.
524
+ // All rows expired and none revoked is NOT revoked: expiry is not revocation.
525
+ if (!sessions.some(s => s.status === 'active')) {
526
+ const revoked = sessions.filter(s => s.status === 'revoked' && s.revoked_at).sort(newestFirst);
527
+ if (revoked.length > 0)
528
+ return revoked[0];
529
+ }
530
+ // Check 2: any row revoked in the last TWO hours (idealresume's window; 5-minute polling needs the time).
531
+ const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000;
532
+ return (sessions.filter(s => s.status === 'revoked' && revokedAt(s) > twoHoursAgo).sort(newestFirst)[0] ?? null);
533
+ }
534
+ /**
535
+ * Determine whether a session is revoked, with an explicit UNKNOWN state
536
+ * (PAY-1882 step 4 producer half).
537
+ *
538
+ * States:
539
+ * 'not_configured' - this deployment has no revocation source (IDP_URL /
540
+ * VIBE_CLIENT_ID / VIBE_HMAC_KEY absent). Viable; today's
541
+ * behaviour. Logged once, not per request.
542
+ * 'unknown' - revocation IS configured but the read FAILED (non-OK,
543
+ * timeout, throw). Enforcing middleware fails this CLOSED
544
+ * by default (revocationFailMode 'closed'); a Vibe-less
545
+ * consumer is 'not_configured', not 'unknown', so it is not
546
+ * signed out.
547
+ * 'revoked' - the session's own token hash matches a revoked row, or
548
+ * the user has no active session at all.
549
+ * 'active' - read OK and the session is not revoked.
550
+ *
551
+ * @param idpUserId - The user's IDP ID
552
+ * @param sessionToken - The live session token; hashed to match the stored row.
553
+ * When absent, the user-wide check applies (legacy path).
554
+ */
555
+ async function getRevocationStatus(idpUserId, sessionToken) {
556
+ const idpUrl = getEnv('IDP_URL');
557
+ const clientId = getEnv('VIBE_CLIENT_ID');
558
+ const signingKey = getEnv('VIBE_HMAC_KEY');
559
+ // 'not_configured' is reserved for an app with NO revocation source at all:
560
+ // no VIBE_CLIENT_ID. BAPert 66320 - "the app has no revocation source
561
+ // (VIBE_CLIENT_ID absent / revocation not enabled)". Such a consumer stays
562
+ // viable, or a Vibe-less app would sign everyone out on every request.
563
+ if (!clientId) {
564
+ if (!warnedNotConfigured) {
565
+ warnedNotConfigured = true;
566
+ console.warn('[vibe-sessions] Revocation check not configured (VIBE_CLIENT_ID absent) - reporting not_configured (logged once)');
336
567
  }
568
+ return 'not_configured';
569
+ }
570
+ // A client id IS present, so this app is a Vibe consumer that intends the
571
+ // revocation check - but the read is MISCONFIGURED (IDP_URL or VIBE_HMAC_KEY
572
+ // missing). BAPert 66344 condition 2: FAIL CLOSED. Do NOT fall back to bearer,
573
+ // do NOT report "no sessions". 'unknown' is the fail-closed state.
574
+ if (!idpUrl || !signingKey) {
575
+ console.warn('[vibe-sessions] Revocation check MISCONFIGURED (VIBE_CLIENT_ID set but IDP_URL/VIBE_HMAC_KEY missing) - reporting unknown (fail closed)');
576
+ return 'unknown';
577
+ }
578
+ const { ok, sessions } = await readSessionRevocationHmac(idpUserId);
579
+ if (!ok) {
580
+ console.warn('[vibe-sessions] Revocation read failed - reporting unknown');
581
+ return 'unknown';
337
582
  }
338
- // Otherwise, check if there are any recently revoked sessions
339
- // (revoked within the last hour - gives time for enforcement)
340
- const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
341
- const recentlyRevoked = sessions.find(s => s.status === 'revoked' &&
342
- s.revoked_at &&
343
- new Date(s.revoked_at) > oneHourAgo);
344
- return recentlyRevoked || null;
583
+ return findRevokingSession(sessions, sessionToken ? hashSessionToken(sessionToken) : undefined)
584
+ ? 'revoked'
585
+ : 'active';
345
586
  }
346
587
  // -----------------------------------------------------------------------------
347
588
  // STATISTICS
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@payez/next-mvp",
3
- "version": "4.4.8",
3
+ "version": "4.5.0",
4
4
  "sideEffects": false,
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -17,12 +17,13 @@
17
17
  "pretest:ci": "npm run build",
18
18
  "test:ci": "node --test --test-reporter=spec --test-reporter-destination=stdout --test-reporter=junit --test-reporter-destination=junit.xml \"test/*.test.cjs\"",
19
19
  "prepack": "npm run build",
20
- "prepublishOnly": "npm run build",
20
+ "prepublishOnly": "npm run check:publish-eol && npm run build",
21
21
  "postpublish": "node scripts/check-publish-health.mjs",
22
22
  "postversion": "node scripts/check-publish-health.mjs || echo \"[postversion] see above: local/registry are out of sync after this version bump (171900)\"",
23
23
  "dev": "next dev",
24
24
  "dev:local": "pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/dev-local.ps1",
25
25
  "dev:broker": "pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/dev-broker.ps1",
26
+ "check:publish-eol": "node scripts/check-publish-eol.mjs",
26
27
  "check:publish-health": "node scripts/check-publish-health.mjs",
27
28
  "check:provenance": "node scripts/verify-release-provenance.mjs",
28
29
  "census:reach": "node scripts/module-reach.mjs",
@@ -574,6 +575,61 @@
574
575
  "require": "./dist/pages/verify-code/page.js",
575
576
  "default": "./dist/pages/verify-code/page.js"
576
577
  },
578
+ "./dist/pages/onboarding": {
579
+ "types": "./dist/pages/onboarding/index.d.ts",
580
+ "require": "./dist/pages/onboarding/index.js",
581
+ "default": "./dist/pages/onboarding/index.js"
582
+ },
583
+ "./pages/onboarding": {
584
+ "types": "./dist/pages/onboarding/index.d.ts",
585
+ "require": "./dist/pages/onboarding/index.js",
586
+ "default": "./dist/pages/onboarding/index.js"
587
+ },
588
+ "./components/auth/PasswordResetBridge": {
589
+ "types": "./dist/components/auth/PasswordResetBridge.d.ts",
590
+ "require": "./dist/components/auth/PasswordResetBridge.js",
591
+ "default": "./dist/components/auth/PasswordResetBridge.js"
592
+ },
593
+ "./api-handlers/onboarding/verify-code": {
594
+ "types": "./dist/api-handlers/onboarding/verify-code.d.ts",
595
+ "require": "./dist/api-handlers/onboarding/verify-code.js",
596
+ "default": "./dist/api-handlers/onboarding/verify-code.js"
597
+ },
598
+ "./api-handlers/onboarding/send-email-code": {
599
+ "types": "./dist/api-handlers/onboarding/send-email-code.d.ts",
600
+ "require": "./dist/api-handlers/onboarding/send-email-code.js",
601
+ "default": "./dist/api-handlers/onboarding/send-email-code.js"
602
+ },
603
+ "./api-handlers/onboarding/verify-welcome-email": {
604
+ "types": "./dist/api-handlers/onboarding/verify-welcome-email.d.ts",
605
+ "require": "./dist/api-handlers/onboarding/verify-welcome-email.js",
606
+ "default": "./dist/api-handlers/onboarding/verify-welcome-email.js"
607
+ },
608
+ "./api-handlers/onboarding/set-password": {
609
+ "types": "./dist/api-handlers/onboarding/set-password.d.ts",
610
+ "require": "./dist/api-handlers/onboarding/set-password.js",
611
+ "default": "./dist/api-handlers/onboarding/set-password.js"
612
+ },
613
+ "./routes/onboarding/verify-code": {
614
+ "types": "./dist/routes/onboarding/verify-code.d.ts",
615
+ "require": "./dist/routes/onboarding/verify-code.js",
616
+ "default": "./dist/routes/onboarding/verify-code.js"
617
+ },
618
+ "./routes/onboarding/send-email-code": {
619
+ "types": "./dist/routes/onboarding/send-email-code.d.ts",
620
+ "require": "./dist/routes/onboarding/send-email-code.js",
621
+ "default": "./dist/routes/onboarding/send-email-code.js"
622
+ },
623
+ "./routes/onboarding/verify-welcome-email": {
624
+ "types": "./dist/routes/onboarding/verify-welcome-email.d.ts",
625
+ "require": "./dist/routes/onboarding/verify-welcome-email.js",
626
+ "default": "./dist/routes/onboarding/verify-welcome-email.js"
627
+ },
628
+ "./routes/onboarding/set-password": {
629
+ "types": "./dist/routes/onboarding/set-password.d.ts",
630
+ "require": "./dist/routes/onboarding/set-password.js",
631
+ "default": "./dist/routes/onboarding/set-password.js"
632
+ },
577
633
  "./pages/admin-login": {
578
634
  "types": "./dist/pages/admin-login/page.d.ts",
579
635
  "require": "./dist/pages/admin-login/page.js",
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * PAY-1998 — SHIPPED-SCRIPT LINE-ENDING GUARD (source-of-truth check, no network).
4
+ *
5
+ * WHY THIS EXISTS. @payez/next-mvp ships shell/node scripts inside the npm tarball
6
+ * and those run on LINUX build agents. A Windows checkout that stores CRLF packs
7
+ * CRLF, and bash on the agent fails with `$'\r': command not found` / `syntax error:
8
+ * unexpected end of file`. That is not hypothetical: it broke idealvibe's pipeline
9
+ * (08-09) and re-broke on 4.1.21 (published from a branch that did not carry the
10
+ * `*.sh text eol=lf` rule — 73 CRs in the tarball), and it is the same class that
11
+ * had idealresume prod live-broken.
12
+ *
13
+ * `.gitattributes` is the FIX-AT-SOURCE, and it IS on master now. The remaining gap
14
+ * is that NOTHING CHECKS IT AT RELEASE TIME: a version can still be cut from a branch
15
+ * that dropped the rule, or a new shipped script in an uncovered extension can slip
16
+ * through. This script is that release-time gate — it FAILS THE RELEASE, in the
17
+ * package's own publish path, so the defect is caught HERE instead of downstream in
18
+ * every consumer's CI.
19
+ *
20
+ * WHAT IT CHECKS:
21
+ * 1. `*.sh`, `*.mjs`, `*.cjs` that the package SHIPS (package.json "files") must
22
+ * contain NO CR bytes. These run on Linux (bash / node with a shebang).
23
+ * 2. The `*.sh text eol=lf` rule is still present in the repo's `.gitattributes` —
24
+ * so a future edit that quietly drops the rule fails the same check, not just a
25
+ * future CRLF checkout.
26
+ * 3. `*.ps1` is EXEMPT: PowerShell helpers are Windows-only and are deliberately
27
+ * kept CRLF (mirroring the .gitattributes rule).
28
+ *
29
+ * DESIGN (deliberate — do not soften):
30
+ * - EXITS NON-ZERO on any CR in an LF-required shipped script. A check that warns
31
+ * is a check that gets ignored; that is exactly how the 08-10 regression shipped.
32
+ * - NO NETWORK. Unlike check-publish-health.mjs (which reads the registry), this is
33
+ * a pure local scan, so it is safe to run in prepack/prepublishOnly.
34
+ * - Exit 2 = INCONCLUSIVE (could not enumerate/read), never a pass. A clean run that
35
+ * scanned zero files is reported and treated as inconclusive, not green — a guard
36
+ * that silently passes when it found nothing is the failure mode this file exists
37
+ * to prevent.
38
+ *
39
+ * Usage: node scripts/check-publish-eol.mjs
40
+ * Exit: 0 = every shipped LF-required script is CR-free, and the .gitattributes rule is present
41
+ * 1 = at least one shipped script has CR, or the .gitattributes rule is missing
42
+ * 2 = could not determine (nothing scanned / unreadable) — inconclusive, NOT a pass
43
+ */
44
+
45
+ import { readFileSync, existsSync, statSync, readdirSync } from 'node:fs';
46
+ import { fileURLToPath } from 'node:url';
47
+ import { dirname, join, extname } from 'node:path';
48
+
49
+ const here = dirname(fileURLToPath(import.meta.url));
50
+ const pkgDir = join(here, '..'); // packages/next-mvp
51
+ const repoRoot = join(pkgDir, '..', '..'); // repo root
52
+
53
+ // Extensions that MUST be LF (they execute on Linux build agents).
54
+ const LF_EXTS = new Set(['.sh', '.mjs', '.cjs']);
55
+ // Extensions explicitly allowed to stay CRLF (Windows-only helpers).
56
+ const CRLF_EXTS = new Set(['.ps1']);
57
+
58
+ function walk(dir, out = []) {
59
+ for (const name of readdirSync(dir)) {
60
+ const full = join(dir, name);
61
+ const st = statSync(full);
62
+ if (st.isDirectory()) walk(full, out);
63
+ else out.push(full);
64
+ }
65
+ return out;
66
+ }
67
+
68
+ function crCount(buf) {
69
+ let n = 0;
70
+ for (const b of buf) if (b === 0x0d) n++;
71
+ return n;
72
+ }
73
+
74
+ let pkg;
75
+ try {
76
+ pkg = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8'));
77
+ } catch (err) {
78
+ console.error(`[publish-eol] INCONCLUSIVE: cannot read package.json: ${err.message}`);
79
+ process.exit(2);
80
+ }
81
+
82
+ // The files the package ships. package.json itself always ships; "files" lists the rest.
83
+ const shipped = ['package.json', ...(pkg.files ?? [])];
84
+
85
+ const offenders = [];
86
+ let scanned = 0;
87
+ let exempt = 0;
88
+
89
+ for (const entry of shipped) {
90
+ const abs = join(pkgDir, entry);
91
+ if (!existsSync(abs)) continue; // a "files" glob that matches nothing here is fine
92
+ const st = statSync(abs);
93
+ const files = st.isDirectory() ? walk(abs) : [abs];
94
+ for (const f of files) {
95
+ const ext = extname(f).toLowerCase();
96
+ if (CRLF_EXTS.has(ext)) { exempt++; continue; }
97
+ if (!LF_EXTS.has(ext)) continue;
98
+ scanned++;
99
+ const crs = crCount(readFileSync(f));
100
+ if (crs > 0) offenders.push({ file: f, crs });
101
+ }
102
+ }
103
+
104
+ // 2. The .gitattributes rule must still be present at the source.
105
+ let gaRulePresent = false;
106
+ let gaPath = join(repoRoot, '.gitattributes');
107
+ try {
108
+ const ga = readFileSync(gaPath, 'utf8');
109
+ gaRulePresent = /^\s*\*\.sh\s+text\s+eol=lf\s*$/m.test(ga);
110
+ } catch {
111
+ gaRulePresent = false;
112
+ }
113
+
114
+ const rel = (p) => p.replace(repoRoot + '\\', '').replace(repoRoot + '/', '');
115
+
116
+ if (offenders.length > 0) {
117
+ console.error('[publish-eol] FAIL: shipped script(s) contain CR (CRLF) and would break Linux agents.');
118
+ for (const o of offenders) console.error(`[publish-eol] ${rel(o.file)} CR=${o.crs}`);
119
+ console.error('[publish-eol] Fix: ensure the *.sh/*.mjs/*.cjs rule is in .gitattributes and renormalise (git add --renormalize .).');
120
+ process.exit(1);
121
+ }
122
+
123
+ if (!gaRulePresent) {
124
+ console.error(`[publish-eol] FAIL: the "*.sh text eol=lf" rule is MISSING from ${rel(gaPath)}.`);
125
+ console.error('[publish-eol] Without it, a Windows checkout packs CRLF and breaks every consumer pipeline (the 4.1.21 regression).');
126
+ process.exit(1);
127
+ }
128
+
129
+ if (scanned === 0) {
130
+ console.error('[publish-eol] INCONCLUSIVE: scanned 0 LF-required shipped scripts - the scan is broken, not the package. NOT a pass.');
131
+ process.exit(2);
132
+ }
133
+
134
+ console.log(`[publish-eol] OK: ${scanned} shipped script(s) CR-free; ${exempt} Windows-only (*.ps1) exempt; .gitattributes rule present.`);
135
+ process.exit(0);
@@ -1,7 +1,7 @@
1
1
  import { NextRequest, NextResponse } from 'next/server';
2
2
  import { getIDPClientConfig } from '../../lib/idp-client-config';
3
3
  import { resolveClientKey } from '../../lib/client-key';
4
- import { idpErrorEnvelope, projectPasswordPolicyRules, projectResetTokenInvalid } from '../../lib/safe-error-response';
4
+ import { idpErrorEnvelope, projectPasswordPolicyRules, projectResetTokenInvalid, projectPasswordBreached } from '../../lib/safe-error-response';
5
5
 
6
6
  function getIdpUrl(): string {
7
7
  const url = process.env.IDP_URL;
@@ -73,8 +73,10 @@ export async function POST(req: NextRequest) {
73
73
  if (!upstream.ok) {
74
74
  // PAY-1591: a password-policy refusal also carries the KNOWN rule codes so the page can say which rules failed.
75
75
  const rules = projectPasswordPolicyRules(data);
76
+ // PAY-2103: a breached-password refusal is a distinct 400; project a BOOLEAN so the anonymous
77
+ // route carries no code text and the page picks its own sentence.
76
78
  return NextResponse.json(
77
- { ...idpErrorEnvelope(data, 'Failed to reset password'), ...(rules !== undefined ? { password_policy: { rules } } : {}), ...(projectResetTokenInvalid(data) ? { reset_token_invalid: true } : {}) },
79
+ { ...idpErrorEnvelope(data, 'Failed to reset password'), ...(rules !== undefined ? { password_policy: { rules } } : {}), ...(projectResetTokenInvalid(data) ? { reset_token_invalid: true } : {}), ...(projectPasswordBreached(data) ? { password_breached: true } : {}) },
78
80
  { status: upstream.status },
79
81
  );
80
82
  }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * POST /api/onboarding/send-email-code (PAY-2121).
3
+ *
4
+ * Body: { onboard_token } - token-driven, no session. Thin proxy to the IdP's
5
+ * POST /api/Onboarding/send-email-code. The IdP derives the recipient from the
6
+ * token's BOUND email only, never from client input, so a tampered link cannot
7
+ * redirect the code. Also the resend leg the verify card's cooldown fires
8
+ * against.
9
+ */
10
+ import { NextRequest } from 'next/server';
11
+ import { onboardingBadRequest, onboardingProxy } from '../../lib/onboarding-proxy';
12
+
13
+ export async function POST(req: NextRequest) {
14
+ const body = await req.json().catch(() => ({} as Record<string, unknown>));
15
+ const onboardToken = (body as Record<string, unknown>)?.onboard_token;
16
+ if (!onboardToken) return onboardingBadRequest('MISSING_TOKEN', 'onboard_token is required');
17
+ return onboardingProxy(req, '/api/Onboarding/send-email-code');
18
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * POST /api/onboarding/set-password (PAY-2121).
3
+ *
4
+ * Body: { onboard_token, email, reset_token, new_password } - the locked
5
+ * contract (IdP OnboardingDtos.cs SetPasswordRequest, all four snake_case).
6
+ * email + reset_token come from the verify-code response; the IdP re-checks
7
+ * email against the token's bound user before consuming the single-use token.
8
+ * On success the IdP sets the password AND completes onboarding (issues tokens).
9
+ * Thin proxy - status and body pass through verbatim.
10
+ */
11
+ import { NextRequest } from 'next/server';
12
+ import { onboardingBadRequest, onboardingProxy } from '../../lib/onboarding-proxy';
13
+
14
+ export async function POST(req: NextRequest) {
15
+ const body = await req.json().catch(() => ({} as Record<string, unknown>));
16
+ const b = body as Record<string, unknown>;
17
+ if (!b?.onboard_token) return onboardingBadRequest('MISSING_TOKEN', 'onboard_token is required');
18
+ if (!b?.email || !b?.reset_token) return onboardingBadRequest('MISSING_RESET_CONTEXT', 'email and reset_token (from verify-code) are required');
19
+ if (!b?.new_password) return onboardingBadRequest('MISSING_PASSWORD', 'new_password is required');
20
+ return onboardingProxy(req, '/api/Onboarding/set-password');
21
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * POST /api/onboarding/verify-code (PAY-2121).
3
+ *
4
+ * Body: { onboard_token, code } - token-driven, no session. Thin proxy to the
5
+ * IdP's POST /api/Onboarding/verify-code, which answers
6
+ * { success, reset_token, email, client_key } on success (OnboardingDtos.cs
7
+ * VerifyCodeResponse). reset_token + email are what the set-password step needs
8
+ * next. The onboard_token IS the auth; there is no session to attach.
9
+ */
10
+ import { NextRequest } from 'next/server';
11
+ import { onboardingBadRequest, onboardingProxy } from '../../lib/onboarding-proxy';
12
+
13
+ export async function POST(req: NextRequest) {
14
+ const body = await req.json().catch(() => ({} as Record<string, unknown>));
15
+ const onboardToken = (body as Record<string, unknown>)?.onboard_token;
16
+ const code = (body as Record<string, unknown>)?.code;
17
+ if (!onboardToken) return onboardingBadRequest('MISSING_TOKEN', 'onboard_token is required');
18
+ if (!code) return onboardingBadRequest('MISSING_CODE', 'code is required');
19
+ return onboardingProxy(req, '/api/Onboarding/verify-code');
20
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * POST /api/onboarding/verify-welcome-email (PAY-2121).
3
+ *
4
+ * Body: { onboard_token } - token-driven, no session. Thin proxy to the IdP's
5
+ * POST /api/Onboarding/verify-welcome-email, which answers the masked contact
6
+ * info + expiry the verify card displays. The onboarding page calls this to
7
+ * render its "check your email / here is what we know" state.
8
+ */
9
+ import { NextRequest } from 'next/server';
10
+ import { onboardingBadRequest, onboardingProxy } from '../../lib/onboarding-proxy';
11
+
12
+ export async function POST(req: NextRequest) {
13
+ const body = await req.json().catch(() => ({} as Record<string, unknown>));
14
+ const onboardToken = (body as Record<string, unknown>)?.onboard_token;
15
+ if (!onboardToken) return onboardingBadRequest('MISSING_TOKEN', 'onboard_token is required');
16
+ return onboardingProxy(req, '/api/Onboarding/verify-welcome-email');
17
+ }