@payez/next-mvp 4.4.8 → 4.4.9

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.
@@ -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
+ }
@@ -33,6 +33,7 @@ import crypto from 'crypto';
33
33
  import type { IVibeLoginSession, SessionStatus } from './types';
34
34
  import type { LocationInfo } from '../lib/geolocation';
35
35
  import type { DeviceInfo } from '../lib/user-agent-parser';
36
+ import { decodeIdpAccessToken } from '../auth/utils/token-utils';
36
37
 
37
38
  // -----------------------------------------------------------------------------
38
39
  // CONFIGURATION
@@ -74,7 +75,7 @@ function generateHmacSignature(
74
75
  */
75
76
  async function vibeProxyRequest<T>(
76
77
  endpoint: string,
77
- method: 'GET' | 'POST' | 'PUT' | 'DELETE',
78
+ method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
78
79
  accessToken: string,
79
80
  body?: unknown
80
81
  ): Promise<T | null> {
@@ -262,7 +263,9 @@ export async function getUserSessions(
262
263
  'POST',
263
264
  accessToken,
264
265
  {
265
- filter: [{ field: 'idp_user_id', operator: 'eq', value: idpUserId }],
266
+ // PAY-2037: a flat VibeFilterNode OBJECT, as idealresume sends it (vibe-sessions.ts:220). Core binds
267
+ // QueryRequest.Filter as an object (VibeQueryBuilder.cs IsFlatFilterFormat); an ARRAY does not bind.
268
+ filter: { field: 'idp_user_id', operator: 'eq', value: idpUserId },
266
269
  pageSize: 100,
267
270
  }
268
271
  );
@@ -288,12 +291,13 @@ export async function getAllSessions(
288
291
  const query: Record<string, unknown> = {
289
292
  page,
290
293
  pageSize,
291
- orderBy: 'created_at',
292
- orderDir: 'desc',
294
+ // PAY-2037: Vibe's QueryRequest.OrderBy is an OBJECT; a string is a 400 and vibeProxyRequest
295
+ // turned that into an empty list. There is no `orderDir` (QAPert 69149).
296
+ orderBy: { created_at: 'desc' },
293
297
  };
294
298
 
295
299
  if (options.status) {
296
- query.filter = [{ field: 'status', operator: 'eq', value: options.status }];
300
+ query.filter = { field: 'status', operator: 'eq', value: options.status };
297
301
  }
298
302
 
299
303
  const endpoint = `/v1/collections/${COLLECTION}/tables/${TABLE}/query`;
@@ -345,11 +349,11 @@ export async function revokeSession(
345
349
  reason?: string
346
350
  ): Promise<IVibeLoginSession | null> {
347
351
  const now = new Date().toISOString();
348
-
352
+ // PAY-2037: PATCH, not PUT - a PUT replaces the row and this body carries 4 of its fields.
349
353
  const endpoint = `/v1/collections/${COLLECTION}/tables/${TABLE}/${sessionId}`;
350
354
  const session = await vibeProxyRequest<IVibeLoginSession>(
351
355
  endpoint,
352
- 'PUT',
356
+ 'PATCH',
353
357
  accessToken,
354
358
  {
355
359
  status: 'revoked',
@@ -414,7 +418,7 @@ export async function updateSessionActivity(
414
418
  const endpoint = `/v1/collections/${COLLECTION}/tables/${TABLE}/${sessionId}`;
415
419
  const result = await vibeProxyRequest<IVibeLoginSession>(
416
420
  endpoint,
417
- 'PUT',
421
+ 'PATCH',
418
422
  accessToken,
419
423
  { last_activity: new Date().toISOString() }
420
424
  );
@@ -460,33 +464,296 @@ export async function isSessionValid(
460
464
  * @param accessToken - Access token for auth
461
465
  * @param sessionTokenHash - Optional token hash to check specific session
462
466
  * @returns Revoked session info if enforcement needed, null otherwise
467
+ *
468
+ * @deprecated PAY-2037 step 1 (BAPert 69153): kept so no consumer breaks. It is a thin call into the
469
+ * same hash-first decision as getRevocationStatus; prefer getRevocationStatus, which distinguishes
470
+ * a failed read ('unknown') from 'active' instead of throwing.
463
471
  */
464
472
  export async function checkSessionRevocation(
465
473
  idpUserId: number,
466
- accessToken: string,
474
+ _accessToken: string,
467
475
  sessionTokenHash?: string
468
476
  ): Promise<IVibeLoginSession | null> {
469
- const sessions = await getUserSessions(idpUserId, accessToken);
477
+ // PAY-2037 step 1: same decision as getRevocationStatus, same HMAC-only read. The bearer is
478
+ // accepted for signature compatibility and never sent. A FAILED read throws - it must not read
479
+ // as "no revoked session" (that is the PAY-1880 fail-open this replaces).
480
+ const { ok, sessions } = await readSessionRevocationHmac(idpUserId);
481
+ if (!ok) {
482
+ throw new Error('[vibe-sessions] session revocation read failed (fail closed)');
483
+ }
484
+ return findRevokingSession(sessions, sessionTokenHash);
485
+ }
470
486
 
471
- // If we have a specific token hash, check that session
472
- if (sessionTokenHash) {
473
- const matchingSession = sessions.find(s => s.session_token_hash === sessionTokenHash);
474
- if (matchingSession && matchingSession.status === 'revoked') {
475
- return matchingSession;
487
+ // -----------------------------------------------------------------------------
488
+ // REVOCATION STATUS (PAY-1882 step 4 producer half)
489
+ // -----------------------------------------------------------------------------
490
+
491
+ /** The four states a viability producer can report (BAPert 66320). */
492
+ export type RevocationStatus = 'active' | 'revoked' | 'unknown' | 'not_configured';
493
+
494
+ // BAPert 66320: log 'not_configured' ONCE, not per request - a Vibe-less
495
+ // deployment would otherwise emit a warn line on every viability check.
496
+ let warnedNotConfigured = false;
497
+
498
+ /**
499
+ * Hash a session token the same way record-login stores it.
500
+ * SHA256 hex, 64 chars. This must stay byte-identical to
501
+ * `hashSessionToken` in the consumers (idealresume.online/lib/session-token-hash.ts)
502
+ * or per-session matching silently falls back to the user-wide path.
503
+ */
504
+ export function hashSessionToken(token: string): string {
505
+ return crypto.createHash('sha256').update(token).digest('hex');
506
+ }
507
+
508
+ /**
509
+ * BAPert 66480 condition 2 (ruling (ii), 66700; corrected per QAPert 66729):
510
+ * resolve the NUMERIC IdP user id the Vibe query filters on.
511
+ *
512
+ * *** DO NOT READ `sub` FOR THE USER ID. *** In the IdP's bearer token `sub` is the
513
+ * USERNAME, not the id: AESGCM_Encryption.cs:815 writes `{ "sub", payload.Username }`
514
+ * and :822 writes `{ "user_id", payload.UserId.ToString() }`. A username is commonly
515
+ * an email, so reading `sub` yields 0 for the common case - and for a username that
516
+ * happens to be all digits it yields the WRONG id, which getRevocationStatus answers
517
+ * 'active' for a user with no rows: a fail-OPEN.
518
+ *
519
+ * Resolution order:
520
+ * 1. the token's `user_id` claim, when it is all digits;
521
+ * 2. a digits-only session.userId / session.user.id;
522
+ * 3. otherwise 0 (unresolvable), which the caller reports as 'unknown' (fail closed).
523
+ *
524
+ * session.userId is not a safe source on its own either: session-store.ts:143 falls
525
+ * back to user.email, and better-auth.ts stringifies whichever id it finds.
526
+ */
527
+ export function resolveNumericIdpUserId(session: unknown, accessToken?: string): number {
528
+ const isDigits = (v: unknown) => /^[0-9]+$/.test(String(v ?? ''));
529
+ if (accessToken) {
530
+ // The raw payload (jwtDecode returns every claim, typed or not); user_id, never sub.
531
+ const payload = decodeIdpAccessToken(accessToken) as Record<string, unknown> | null;
532
+ const claim = payload?.user_id;
533
+ if (isDigits(claim)) return Number(claim);
534
+ }
535
+ const s = (session ?? {}) as Record<string, any>;
536
+ const fromSession = s.userId ?? s.user?.id;
537
+ return isDigits(fromSession) ? Number(fromSession) : 0;
538
+ }
539
+
540
+ /**
541
+ * BAPert 66480 condition 1: WARN once per session when the numeric IdP user id
542
+ * could not be resolved (userId is email-shaped, empty, or otherwise not all
543
+ * digits). A non-numeric id must show up as a log line, not as an unexplained
544
+ * sign-out. Shape only - NEVER the value.
545
+ *
546
+ * Keyed on a hash of the session token, so one WARN per session; the set is
547
+ * bounded so a long-lived process cannot grow it without limit.
548
+ */
549
+ const warnedNonNumericUserIds = new Set<string>();
550
+
551
+ function shapeOfUserId(v: unknown): string {
552
+ const str = String(v ?? '');
553
+ if (!str) return 'empty:0';
554
+ const kind = /^[0-9]+$/.test(str) ? 'digits' : str.includes('@') ? 'email' : 'other';
555
+ return `${kind}:${str.length}`;
556
+ }
557
+
558
+ function warnNonNumericUserIdOnce(sessionToken: string | undefined, rawUserId: unknown): void {
559
+ const key = sessionToken ? hashSessionToken(sessionToken).slice(0, 16) : `nosession:${shapeOfUserId(rawUserId)}`;
560
+ if (warnedNonNumericUserIds.has(key)) return;
561
+ if (warnedNonNumericUserIds.size > 5000) warnedNonNumericUserIds.clear();
562
+ warnedNonNumericUserIds.add(key);
563
+ console.warn('[REVOCATION] non-numeric userId, failing closed', { shape: shapeOfUserId(rawUserId) });
564
+ }
565
+
566
+ /**
567
+ * Public entry point for the twins: call this when a session exists but its
568
+ * userId is not a usable numeric IdP id. Returns 'unknown' (fail closed) and
569
+ * emits the once-per-session WARN. Shape only, never the value.
570
+ */
571
+ export function warnNonNumericUserId(sessionToken: string | undefined, rawUserId: unknown): 'unknown' {
572
+ warnNonNumericUserIdOnce(sessionToken, rawUserId);
573
+ return 'unknown';
574
+ }
575
+
576
+ /**
577
+ * HMAC-ONLY session read for the revocation check (PAY-1882 step 4).
578
+ *
579
+ * *** DO NOT "SIMPLIFY" THIS ONTO vibeProxyRequest/getUserSessions. ***
580
+ * It is deliberately separate and deliberately sends NO Authorization header.
581
+ *
582
+ * WHY: the twin this must match is idealresume.online's checkSessionRevocation
583
+ * read. That repo's vibe-client adds `Authorization: Bearer` ONLY for member
584
+ * paths (`path.startsWith('/v1/my/')`) - see idealresume.online/lib/vibe-client.ts
585
+ * :207-210 - so its /v1/collections/.../query for this check goes out HMAC-only.
586
+ * The comment at vibe-client.ts:188-206 there records why: a bearer on the
587
+ * collections path caused USER_REQUIRED and broke signups. getUserSessions()
588
+ * here ALWAYS sends the bearer, so it cannot stand in for this read without
589
+ * changing the credential the two twins are supposed to share.
590
+ *
591
+ * BAPert 66344 (option a): match the twin, HMAC-only.
592
+ *
593
+ * Also distinguishes a FAILED read from an EMPTY one - getUserSessions()
594
+ * collapses a proxy outage and "user has no sessions" to `[]`, which is
595
+ * fail-OPEN for a revocation decision. Returns `ok:false` on an outage so the
596
+ * caller fails closed, and `ok:true, sessions:[]` only for a healthy empty.
597
+ */
598
+ export async function readSessionRevocationHmac(
599
+ idpUserId: number
600
+ ): Promise<{ ok: boolean; sessions: IVibeLoginSession[] }> {
601
+ const idpUrl = getEnv('IDP_URL');
602
+ const clientId = getEnv('VIBE_CLIENT_ID');
603
+ const signingKey = getEnv('VIBE_HMAC_KEY');
604
+
605
+ if (!idpUrl || !clientId || !signingKey) {
606
+ // Caller distinguishes this from a read failure; report ok:false, not [].
607
+ return { ok: false, sessions: [] };
608
+ }
609
+
610
+ const endpoint = `/v1/collections/${COLLECTION}/tables/${TABLE}/query`;
611
+ const proxyUrl = `${idpUrl}/api/vibe/proxy`;
612
+ const timestamp = Math.floor(Date.now() / 1000);
613
+ const signature = generateHmacSignature(signingKey, timestamp, 'POST', endpoint);
614
+
615
+ // NOTE the absence of Authorization. This is the twin's credential.
616
+ const headers: Record<string, string> = {
617
+ 'Content-Type': 'application/json',
618
+ 'X-Vibe-Client-Id': clientId,
619
+ 'X-Vibe-Timestamp': String(timestamp),
620
+ 'X-Vibe-Signature': signature,
621
+ };
622
+
623
+ try {
624
+ const response = await fetch(proxyUrl, {
625
+ method: 'POST',
626
+ headers,
627
+ body: JSON.stringify({
628
+ endpoint,
629
+ method: 'POST',
630
+ data: {
631
+ // PAY-2037: a flat VibeFilterNode OBJECT, as idealresume sends it (vibe-sessions.ts:220). Core binds
632
+ // QueryRequest.Filter as an object (VibeQueryBuilder.cs IsFlatFilterFormat); an ARRAY does not bind.
633
+ filter: { field: 'idp_user_id', operator: 'eq', value: idpUserId },
634
+ pageSize: 100,
635
+ },
636
+ }),
637
+ });
638
+
639
+ if (!response.ok) {
640
+ // PAY-1427: never the raw proxy body - an error page can echo the request.
641
+ console.error(`[vibe-sessions] Revocation read proxy error ${response.status}`);
642
+ return { ok: false, sessions: [] };
476
643
  }
644
+
645
+ const data = await response.json();
646
+ const unwrapped = (data?.data ?? data) as { data?: IVibeLoginSession[]; items?: IVibeLoginSession[] };
647
+ return { ok: true, sessions: unwrapped?.data || unwrapped?.items || [] };
648
+ } catch (error) {
649
+ console.error('[vibe-sessions] Revocation read failed:', error);
650
+ return { ok: false, sessions: [] };
651
+ }
652
+ }
653
+
654
+ /**
655
+ * THE one revocation decision (PAY-2037 step 1, BAPert 69150): hash-first, per session.
656
+ * Returns the row that revokes THIS session, or null when it is not revoked.
657
+ *
658
+ * A session whose own token hash matches a row is decided by that row ALONE. A matched ACTIVE
659
+ * session is never logged out because another device was revoked (idealresume PAY-1882 F3).
660
+ * Only when no row carries the hash (a legacy row minted before hashing) does the user-wide
661
+ * rule apply: no active row -> the most recently revoked (none revoked: not revoked); else any revoked inside 2h.
662
+ */
663
+ function findRevokingSession(
664
+ sessions: IVibeLoginSession[],
665
+ sessionTokenHash?: string
666
+ ): IVibeLoginSession | null {
667
+ if (sessions.length === 0) return null;
668
+
669
+ if (sessionTokenHash) {
670
+ const matching = sessions.find(s => s.session_token_hash === sessionTokenHash);
671
+ if (matching) return matching.status === 'revoked' ? matching : null;
672
+ }
673
+
674
+ // Hash not found (a legacy row minted before hashing) or no hash given: idealresume's user-wide
675
+ // rules, lib/vibe-sessions.ts:594-651 (BAPert 69189).
676
+ const revokedAt = (s: IVibeLoginSession) => (s.revoked_at ? new Date(s.revoked_at).getTime() : 0);
677
+ const newestFirst = (x: IVibeLoginSession, y: IVibeLoginSession) => revokedAt(y) - revokedAt(x);
678
+
679
+ // Check 1: no active row. The most recently revoked row (whenever it was revoked) is the verdict.
680
+ // All rows expired and none revoked is NOT revoked: expiry is not revocation.
681
+ if (!sessions.some(s => s.status === 'active')) {
682
+ const revoked = sessions.filter(s => s.status === 'revoked' && s.revoked_at).sort(newestFirst);
683
+ if (revoked.length > 0) return revoked[0];
477
684
  }
478
685
 
479
- // Otherwise, check if there are any recently revoked sessions
480
- // (revoked within the last hour - gives time for enforcement)
481
- const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
482
- const recentlyRevoked = sessions.find(
483
- s =>
484
- s.status === 'revoked' &&
485
- s.revoked_at &&
486
- new Date(s.revoked_at) > oneHourAgo
686
+ // Check 2: any row revoked in the last TWO hours (idealresume's window; 5-minute polling needs the time).
687
+ const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000;
688
+ return (
689
+ sessions.filter(s => s.status === 'revoked' && revokedAt(s) > twoHoursAgo).sort(newestFirst)[0] ?? null
487
690
  );
691
+ }
692
+
693
+ /**
694
+ * Determine whether a session is revoked, with an explicit UNKNOWN state
695
+ * (PAY-1882 step 4 producer half).
696
+ *
697
+ * States:
698
+ * 'not_configured' - this deployment has no revocation source (IDP_URL /
699
+ * VIBE_CLIENT_ID / VIBE_HMAC_KEY absent). Viable; today's
700
+ * behaviour. Logged once, not per request.
701
+ * 'unknown' - revocation IS configured but the read FAILED (non-OK,
702
+ * timeout, throw). Enforcing middleware fails this CLOSED
703
+ * by default (revocationFailMode 'closed'); a Vibe-less
704
+ * consumer is 'not_configured', not 'unknown', so it is not
705
+ * signed out.
706
+ * 'revoked' - the session's own token hash matches a revoked row, or
707
+ * the user has no active session at all.
708
+ * 'active' - read OK and the session is not revoked.
709
+ *
710
+ * @param idpUserId - The user's IDP ID
711
+ * @param sessionToken - The live session token; hashed to match the stored row.
712
+ * When absent, the user-wide check applies (legacy path).
713
+ */
714
+ export async function getRevocationStatus(
715
+ idpUserId: number,
716
+ sessionToken?: string
717
+ ): Promise<RevocationStatus> {
718
+ const idpUrl = getEnv('IDP_URL');
719
+ const clientId = getEnv('VIBE_CLIENT_ID');
720
+ const signingKey = getEnv('VIBE_HMAC_KEY');
721
+
722
+ // 'not_configured' is reserved for an app with NO revocation source at all:
723
+ // no VIBE_CLIENT_ID. BAPert 66320 - "the app has no revocation source
724
+ // (VIBE_CLIENT_ID absent / revocation not enabled)". Such a consumer stays
725
+ // viable, or a Vibe-less app would sign everyone out on every request.
726
+ if (!clientId) {
727
+ if (!warnedNotConfigured) {
728
+ warnedNotConfigured = true;
729
+ console.warn(
730
+ '[vibe-sessions] Revocation check not configured (VIBE_CLIENT_ID absent) - reporting not_configured (logged once)'
731
+ );
732
+ }
733
+ return 'not_configured';
734
+ }
735
+
736
+ // A client id IS present, so this app is a Vibe consumer that intends the
737
+ // revocation check - but the read is MISCONFIGURED (IDP_URL or VIBE_HMAC_KEY
738
+ // missing). BAPert 66344 condition 2: FAIL CLOSED. Do NOT fall back to bearer,
739
+ // do NOT report "no sessions". 'unknown' is the fail-closed state.
740
+ if (!idpUrl || !signingKey) {
741
+ console.warn(
742
+ '[vibe-sessions] Revocation check MISCONFIGURED (VIBE_CLIENT_ID set but IDP_URL/VIBE_HMAC_KEY missing) - reporting unknown (fail closed)'
743
+ );
744
+ return 'unknown';
745
+ }
746
+
747
+ const { ok, sessions } = await readSessionRevocationHmac(idpUserId);
748
+
749
+ if (!ok) {
750
+ console.warn('[vibe-sessions] Revocation read failed - reporting unknown');
751
+ return 'unknown';
752
+ }
488
753
 
489
- return recentlyRevoked || null;
754
+ return findRevokingSession(sessions, sessionToken ? hashSessionToken(sessionToken) : undefined)
755
+ ? 'revoked'
756
+ : 'active';
490
757
  }
491
758
 
492
759
  // -----------------------------------------------------------------------------