@payez/next-mvp 4.5.0 → 4.5.2
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/auth-decision.d.ts +10 -1
- package/dist/auth/auth-decision.js +23 -1
- package/dist/edge/middleware.d.ts +1 -1
- package/dist/edge/middleware.js +2 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -1
- package/dist/lib/idp-fetch.d.ts +8 -1
- package/dist/lib/idp-fetch.js +5 -2
- package/dist/lib/retry-after.d.ts +10 -0
- package/dist/lib/retry-after.js +30 -0
- package/dist/lib/session-store.d.ts +17 -0
- package/dist/lib/session-store.js +33 -0
- package/dist/lib/two-factor-verify-failure.d.ts +8 -0
- package/dist/lib/two-factor-verify-failure.js +35 -3
- package/dist/middleware/create-middleware.d.ts +10 -0
- package/dist/middleware/create-middleware.js +30 -1
- package/dist/server/auth.js +87 -3
- package/dist/vibe/sessions.d.ts +10 -22
- package/dist/vibe/sessions.js +159 -24
- package/package.json +1042 -1042
- package/scripts/check-publish-health.mjs +12 -3
- package/scripts/publish-health-tag.mjs +27 -0
- package/src/auth/auth-decision.ts +29 -1
- package/src/edge/middleware.ts +1 -1
- package/src/index.ts +1 -1
- package/src/lib/idp-fetch.ts +10 -2
- package/src/lib/retry-after.ts +24 -0
- package/src/lib/session-store.ts +42 -0
- package/src/lib/two-factor-verify-failure.ts +277 -248
- package/src/middleware/create-middleware.ts +26 -1
- package/src/server/auth.ts +90 -3
- package/src/vibe/document-unwrap.ts +103 -103
- package/src/vibe/sessions.ts +172 -27
package/src/server/auth.ts
CHANGED
|
@@ -10,8 +10,10 @@ import { getBetterAuthInstance } from '../auth/better-auth';
|
|
|
10
10
|
import {
|
|
11
11
|
getSession as getRedisSession,
|
|
12
12
|
getBetterAuthSession as getBetterAuthRedisSession,
|
|
13
|
+
describeSessionKeyPresence,
|
|
13
14
|
type SessionData,
|
|
14
15
|
} from '../lib/session-store';
|
|
16
|
+
import { tokenLogId } from '../lib/token-log-id';
|
|
15
17
|
import { resolveVerifiedSessionTokenFromRequest } from './verified-session-token';
|
|
16
18
|
|
|
17
19
|
export type IdpTokenResult =
|
|
@@ -125,6 +127,82 @@ export async function getAuthInstance() {
|
|
|
125
127
|
}
|
|
126
128
|
|
|
127
129
|
|
|
130
|
+
/* ----------------------------------------------------------------------------------------------------------------
|
|
131
|
+
* PAY-2136 DIAGNOSTICS (BAPert 70873, Nextpert-Scout 70872/70875) - LOG ONLY, NO BEHAVIOUR CHANGE.
|
|
132
|
+
*
|
|
133
|
+
* A getFreshIdpToken NO_SESSION used to carry no origin: three different returns produced the same
|
|
134
|
+
* `{error:'NO_SESSION'}`, and getSession() swallowed every Redis error into a bare `catch {}`. These helpers name
|
|
135
|
+
* the origin and make a swallowed read audible. Hard rules: every helper is wrapped so it can NEVER throw or change
|
|
136
|
+
* a return value; nothing here ever logs a token, a cookie VALUE or a Redis value - only a tokenLogId() fingerprint,
|
|
137
|
+
* cookie NAMES, booleans, and an error name/message with any long opaque run redacted.
|
|
138
|
+
* -------------------------------------------------------------------------------------------------------------- */
|
|
139
|
+
|
|
140
|
+
/** An error name/message, first 80 chars, with any 24+ char opaque run (a token, a key) replaced. */
|
|
141
|
+
function scrubForLog(v: unknown): string {
|
|
142
|
+
const raw = v instanceof Error ? v.message : String(v ?? '');
|
|
143
|
+
return raw.replace(/[A-Za-z0-9_\-.+/=]{24,}/g, '[redacted]').slice(0, 80);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** `catch {}` sites on the session read path: still swallowed, now audible. */
|
|
147
|
+
function logSessionReadSwallowed(path: 'primary' | 'A' | 'B', err: unknown): void {
|
|
148
|
+
try {
|
|
149
|
+
const name = scrubForLog(err instanceof Error ? err.name : typeof err);
|
|
150
|
+
console.warn(`[server-auth] SESSION_READ_SWALLOWED path=${path} err=${name} msg=${scrubForLog(err)}`);
|
|
151
|
+
} catch {
|
|
152
|
+
/* a diagnostic must never change the outcome */
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Origin a: getSession(request) produced no token. Cookie NAMES only, never a value. */
|
|
157
|
+
function logNoSessionOriginA(request: Request | undefined): void {
|
|
158
|
+
try {
|
|
159
|
+
const header = request?.headers?.get('cookie') ?? '';
|
|
160
|
+
// Only segments of the form name=value contribute a NAME. A segment with no '=' is not a cookie pair, and
|
|
161
|
+
// logging it whole could emit a bare token (Nextpert-Scout 70901 finding 1).
|
|
162
|
+
const cookieNames = header
|
|
163
|
+
.split(';')
|
|
164
|
+
// Structural, alphabet-independent (Nextpert-Scout 70945): base64 padding can only sit at the END of a bare
|
|
165
|
+
// value, so a bare padded token splits as name=<token>, value='' or '='. A real pair has a non-empty value that
|
|
166
|
+
// is not only '=' characters. (The old includes('=') let 74% of base64url tokens through as a "name".)
|
|
167
|
+
.filter((c) => {
|
|
168
|
+
const i = c.indexOf('=');
|
|
169
|
+
if (i < 0) return false;
|
|
170
|
+
const v = c.slice(i + 1);
|
|
171
|
+
return v !== '' && !/^=+$/.test(v);
|
|
172
|
+
})
|
|
173
|
+
.map((c) => c.split('=')[0].trim())
|
|
174
|
+
.filter((n) => n !== '')
|
|
175
|
+
// A bare token that merely ENDS in base64 padding ('=') still passes the filter above and its "name" would be
|
|
176
|
+
// the token itself (Nextpert-Scout 70913). Real cookie names carry a separator ('.', '-', '_'); a long name with
|
|
177
|
+
// none is opaque, so it is logged as a placeholder, never as itself.
|
|
178
|
+
.map((n) => (n.length >= 24 && !/[._-]/.test(n) ? '[opaque-name]' : n))
|
|
179
|
+
.slice(0, 20);
|
|
180
|
+
console.warn('[server-auth] NO_SESSION origin=a', { hasCookieHeader: header !== '', cookieNames });
|
|
181
|
+
} catch {
|
|
182
|
+
/* a diagnostic must never change the outcome */
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Origins b and c: a token WAS resolved. Log which of its three keys exist (presence only), fire-and-forget. */
|
|
187
|
+
function logNoSessionWithKeys(origin: 'b' | 'c', sessionToken: string): void {
|
|
188
|
+
try {
|
|
189
|
+
void describeSessionKeyPresence(sessionToken).then(
|
|
190
|
+
(keys) => {
|
|
191
|
+
try {
|
|
192
|
+
console.warn(`[server-auth] NO_SESSION origin=${origin}`, { token: tokenLogId(sessionToken), ...keys });
|
|
193
|
+
} catch {
|
|
194
|
+
/* never throws */
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
() => {
|
|
198
|
+
/* never throws */
|
|
199
|
+
}
|
|
200
|
+
);
|
|
201
|
+
} catch {
|
|
202
|
+
/* a diagnostic must never change the outcome */
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
128
206
|
/**
|
|
129
207
|
* Get the current session from a request.
|
|
130
208
|
*
|
|
@@ -153,7 +231,9 @@ export async function getSession(request?: Request): Promise<any> {
|
|
|
153
231
|
let betterAuthSession: any = null;
|
|
154
232
|
try {
|
|
155
233
|
betterAuthSession = await auth.api.getSession({ headers: request.headers });
|
|
156
|
-
} catch {
|
|
234
|
+
} catch (err) {
|
|
235
|
+
logSessionReadSwallowed('primary', err); // fall through to canonical Redis path
|
|
236
|
+
}
|
|
157
237
|
|
|
158
238
|
if (betterAuthSession?.session?.token && betterAuthSession?.user) {
|
|
159
239
|
const sessionToken = betterAuthSession.session.token as string;
|
|
@@ -161,7 +241,9 @@ export async function getSession(request?: Request): Promise<any> {
|
|
|
161
241
|
try {
|
|
162
242
|
sessionData = await getRedisSession(sessionToken);
|
|
163
243
|
if (!sessionData) sessionData = await getBetterAuthRedisSession(sessionToken);
|
|
164
|
-
} catch {
|
|
244
|
+
} catch (err) {
|
|
245
|
+
logSessionReadSwallowed('A', err); // Redis unavailable
|
|
246
|
+
}
|
|
165
247
|
if (!sessionData) sessionData = buildSessionDataFromAuthSession(betterAuthSession);
|
|
166
248
|
return attachSessionData(betterAuthSession, sessionData, sessionToken);
|
|
167
249
|
}
|
|
@@ -184,7 +266,9 @@ export async function getSession(request?: Request): Promise<any> {
|
|
|
184
266
|
try {
|
|
185
267
|
sessionData = await getRedisSession(sessionToken);
|
|
186
268
|
if (!sessionData) sessionData = await getBetterAuthRedisSession(sessionToken);
|
|
187
|
-
} catch {
|
|
269
|
+
} catch (err) {
|
|
270
|
+
logSessionReadSwallowed('B', err); // Redis unavailable
|
|
271
|
+
}
|
|
188
272
|
if (!sessionData) return null;
|
|
189
273
|
|
|
190
274
|
// Synthesize a minimal session shape compatible with downstream callers.
|
|
@@ -296,6 +380,7 @@ export async function getFreshIdpToken(
|
|
|
296
380
|
const session = await getSession(request);
|
|
297
381
|
const sessionToken = session?.session?.token as string | undefined;
|
|
298
382
|
if (!sessionToken) {
|
|
383
|
+
logNoSessionOriginA(request);
|
|
299
384
|
return { success: false, error: 'NO_SESSION', status: 401, terminal: true };
|
|
300
385
|
}
|
|
301
386
|
|
|
@@ -314,6 +399,7 @@ export async function getFreshIdpToken(
|
|
|
314
399
|
);
|
|
315
400
|
|
|
316
401
|
if (!result.ok) {
|
|
402
|
+
if (result.code === 'NO_SESSION') logNoSessionWithKeys('b', sessionToken);
|
|
317
403
|
return {
|
|
318
404
|
success: false,
|
|
319
405
|
error: result.code,
|
|
@@ -327,6 +413,7 @@ export async function getFreshIdpToken(
|
|
|
327
413
|
|
|
328
414
|
const sessionData = await getRedisSession(sessionToken);
|
|
329
415
|
if (!sessionData) {
|
|
416
|
+
logNoSessionWithKeys('c', sessionToken);
|
|
330
417
|
return { success: false, error: 'NO_SESSION', status: 401, terminal: true };
|
|
331
418
|
}
|
|
332
419
|
|
|
@@ -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
|
+
}
|
package/src/vibe/sessions.ts
CHANGED
|
@@ -34,6 +34,7 @@ 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
36
|
import { decodeIdpAccessToken } from '../auth/utils/token-utils';
|
|
37
|
+
import { tokenLogId } from '../lib/token-log-id';
|
|
37
38
|
|
|
38
39
|
// -----------------------------------------------------------------------------
|
|
39
40
|
// CONFIGURATION
|
|
@@ -597,31 +598,31 @@ export function warnNonNumericUserId(sessionToken: string | undefined, rawUserId
|
|
|
597
598
|
*/
|
|
598
599
|
export async function readSessionRevocationHmac(
|
|
599
600
|
idpUserId: number
|
|
600
|
-
): Promise<{ ok: boolean; sessions: IVibeLoginSession[] }> {
|
|
601
|
+
): Promise<{ ok: boolean; sessions: IVibeLoginSession[]; failure?: string }> {
|
|
601
602
|
const idpUrl = getEnv('IDP_URL');
|
|
602
603
|
const clientId = getEnv('VIBE_CLIENT_ID');
|
|
603
604
|
const signingKey = getEnv('VIBE_HMAC_KEY');
|
|
604
605
|
|
|
605
606
|
if (!idpUrl || !clientId || !signingKey) {
|
|
606
607
|
// Caller distinguishes this from a read failure; report ok:false, not [].
|
|
607
|
-
return { ok: false, sessions: [] };
|
|
608
|
+
return { ok: false, sessions: [], failure: 'misconfigured' };
|
|
608
609
|
}
|
|
609
610
|
|
|
610
611
|
const endpoint = `/v1/collections/${COLLECTION}/tables/${TABLE}/query`;
|
|
611
612
|
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
613
|
|
|
623
|
-
|
|
624
|
-
|
|
614
|
+
// Signed per ATTEMPT: the retry below must not reuse a timestamp that has aged.
|
|
615
|
+
const send = (): Promise<Response> => {
|
|
616
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
617
|
+
const signature = generateHmacSignature(signingKey, timestamp, 'POST', endpoint);
|
|
618
|
+
// NOTE the absence of Authorization. This is the twin's credential.
|
|
619
|
+
const headers: Record<string, string> = {
|
|
620
|
+
'Content-Type': 'application/json',
|
|
621
|
+
'X-Vibe-Client-Id': clientId,
|
|
622
|
+
'X-Vibe-Timestamp': String(timestamp),
|
|
623
|
+
'X-Vibe-Signature': signature,
|
|
624
|
+
};
|
|
625
|
+
return fetch(proxyUrl, {
|
|
625
626
|
method: 'POST',
|
|
626
627
|
headers,
|
|
627
628
|
body: JSON.stringify({
|
|
@@ -635,11 +636,24 @@ export async function readSessionRevocationHmac(
|
|
|
635
636
|
},
|
|
636
637
|
}),
|
|
637
638
|
});
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
try {
|
|
642
|
+
let response = await send();
|
|
643
|
+
|
|
644
|
+
// PAY-2136: ONE retry, and only for the transient class. The portal's own prefetch burst exceeds the free
|
|
645
|
+
// tier's concurrency (CONCURRENT_LIMIT, HTTP 429). Keyed on the BODY code, never on Retry-After: the IdP proxy
|
|
646
|
+
// relays body and status but drops that header (PAY-2143). AUTH_BLOCKED / other 429s and every 5xx are NOT
|
|
647
|
+
// retried (a block is not transient; a retry only adds a counted failure).
|
|
648
|
+
if (response.status === 429 && isConcurrentLimitBody(await safeBodyText(response))) {
|
|
649
|
+
await new Promise((resolve) => setTimeout(resolve, revocationRetryDelayMs(response)));
|
|
650
|
+
response = await send();
|
|
651
|
+
}
|
|
638
652
|
|
|
639
653
|
if (!response.ok) {
|
|
640
654
|
// PAY-1427: never the raw proxy body - an error page can echo the request.
|
|
641
655
|
console.error(`[vibe-sessions] Revocation read proxy error ${response.status}`);
|
|
642
|
-
return { ok: false, sessions: [] };
|
|
656
|
+
return { ok: false, sessions: [], failure: String(response.status) }; // PAY-2136: status only, for the reason log
|
|
643
657
|
}
|
|
644
658
|
|
|
645
659
|
const data = await response.json();
|
|
@@ -647,10 +661,39 @@ export async function readSessionRevocationHmac(
|
|
|
647
661
|
return { ok: true, sessions: unwrapped?.data || unwrapped?.items || [] };
|
|
648
662
|
} catch (error) {
|
|
649
663
|
console.error('[vibe-sessions] Revocation read failed:', error);
|
|
650
|
-
return { ok: false, sessions: [] };
|
|
664
|
+
return { ok: false, sessions: [], failure: 'exception' };
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** Body text of a response WITHOUT consuming the caller's copy; '' on any failure. */
|
|
669
|
+
async function safeBodyText(response: Response): Promise<string> {
|
|
670
|
+
try {
|
|
671
|
+
return await response.clone().text();
|
|
672
|
+
} catch {
|
|
673
|
+
return '';
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/** True only for the exact code CONCURRENT_LIMIT (nested error.code, top-level code/errorCode); a non-JSON body falls back to the literal. */
|
|
678
|
+
export function isConcurrentLimitBody(bodyText: string): boolean {
|
|
679
|
+
try {
|
|
680
|
+
const b = JSON.parse(bodyText) as Record<string, unknown>;
|
|
681
|
+
const nested = (b?.error as Record<string, unknown> | undefined)?.code;
|
|
682
|
+
const code = [nested, b?.code, b?.errorCode].find((c) => typeof c === 'string');
|
|
683
|
+
if (typeof code === 'string') return code === 'CONCURRENT_LIMIT';
|
|
684
|
+
return false;
|
|
685
|
+
} catch {
|
|
686
|
+
return /CONCURRENT_LIMIT/.test(bodyText);
|
|
651
687
|
}
|
|
652
688
|
}
|
|
653
689
|
|
|
690
|
+
/** Retry-After seconds when the proxy kept it (capped at 2 s: this runs in the request path), else 200-400 ms jitter. */
|
|
691
|
+
export function revocationRetryDelayMs(response: Response): number {
|
|
692
|
+
const ra = Number(response.headers?.get?.('retry-after'));
|
|
693
|
+
if (Number.isFinite(ra) && ra > 0) return Math.min(ra * 1000, 2000);
|
|
694
|
+
return 200 + Math.floor(Math.random() * 200);
|
|
695
|
+
}
|
|
696
|
+
|
|
654
697
|
/**
|
|
655
698
|
* THE one revocation decision (PAY-2037 step 1, BAPert 69150): hash-first, per session.
|
|
656
699
|
* Returns the row that revokes THIS session, or null when it is not revoked.
|
|
@@ -664,11 +707,21 @@ function findRevokingSession(
|
|
|
664
707
|
sessions: IVibeLoginSession[],
|
|
665
708
|
sessionTokenHash?: string
|
|
666
709
|
): IVibeLoginSession | null {
|
|
667
|
-
|
|
710
|
+
return findRevokingSessionWithRule(sessions, sessionTokenHash).row;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/** Which branch of the revocation decision produced the verdict (PAY-2136 reason log; never a value). */
|
|
714
|
+
export type RevocationRule = 'own_row_revoked' | 'legacy_no_active' | 'legacy_recent_2h' | 'none';
|
|
715
|
+
|
|
716
|
+
function findRevokingSessionWithRule(
|
|
717
|
+
sessions: IVibeLoginSession[],
|
|
718
|
+
sessionTokenHash?: string
|
|
719
|
+
): { row: IVibeLoginSession | null; rule: RevocationRule } {
|
|
720
|
+
if (sessions.length === 0) return { row: null, rule: 'none' };
|
|
668
721
|
|
|
669
722
|
if (sessionTokenHash) {
|
|
670
723
|
const matching = sessions.find(s => s.session_token_hash === sessionTokenHash);
|
|
671
|
-
if (matching) return matching.status === 'revoked' ? matching : null;
|
|
724
|
+
if (matching) return matching.status === 'revoked' ? { row: matching, rule: 'own_row_revoked' } : { row: null, rule: 'none' };
|
|
672
725
|
}
|
|
673
726
|
|
|
674
727
|
// Hash not found (a legacy row minted before hashing) or no hash given: idealresume's user-wide
|
|
@@ -680,14 +733,36 @@ function findRevokingSession(
|
|
|
680
733
|
// All rows expired and none revoked is NOT revoked: expiry is not revocation.
|
|
681
734
|
if (!sessions.some(s => s.status === 'active')) {
|
|
682
735
|
const revoked = sessions.filter(s => s.status === 'revoked' && s.revoked_at).sort(newestFirst);
|
|
683
|
-
if (revoked.length > 0) return revoked[0];
|
|
736
|
+
if (revoked.length > 0) return { row: revoked[0], rule: 'legacy_no_active' };
|
|
684
737
|
}
|
|
685
738
|
|
|
686
739
|
// Check 2: any row revoked in the last TWO hours (idealresume's window; 5-minute polling needs the time).
|
|
687
740
|
const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000;
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
741
|
+
const recent = sessions.filter(s => s.status === 'revoked' && revokedAt(s) > twoHoursAgo).sort(newestFirst)[0] ?? null;
|
|
742
|
+
return recent ? { row: recent, rule: 'legacy_recent_2h' } : { row: null, rule: 'none' };
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* PAY-2136: one shape-only line per NON-viable revocation verdict, naming WHICH rule fired. Never a token, userId,
|
|
747
|
+
* hash, or row content: the rule label, a read-failure status code, a row COUNT and the session's tokenLogId.
|
|
748
|
+
* Must never throw into the viability path.
|
|
749
|
+
*/
|
|
750
|
+
function logRevocationReason(
|
|
751
|
+
verdict: 'revoked' | 'unknown',
|
|
752
|
+
reason: string,
|
|
753
|
+
sessionToken: string | undefined,
|
|
754
|
+
extra: Record<string, number> = {}
|
|
755
|
+
): void {
|
|
756
|
+
try {
|
|
757
|
+
console.warn('[REVOCATION] verdict', {
|
|
758
|
+
verdict,
|
|
759
|
+
reason,
|
|
760
|
+
sessionTokenId: sessionToken ? tokenLogId(sessionToken) : null,
|
|
761
|
+
...extra,
|
|
762
|
+
});
|
|
763
|
+
} catch {
|
|
764
|
+
/* diagnostic only */
|
|
765
|
+
}
|
|
691
766
|
}
|
|
692
767
|
|
|
693
768
|
/**
|
|
@@ -711,7 +786,7 @@ function findRevokingSession(
|
|
|
711
786
|
* @param sessionToken - The live session token; hashed to match the stored row.
|
|
712
787
|
* When absent, the user-wide check applies (legacy path).
|
|
713
788
|
*/
|
|
714
|
-
|
|
789
|
+
async function readRevocationStatus(
|
|
715
790
|
idpUserId: number,
|
|
716
791
|
sessionToken?: string
|
|
717
792
|
): Promise<RevocationStatus> {
|
|
@@ -741,19 +816,24 @@ export async function getRevocationStatus(
|
|
|
741
816
|
console.warn(
|
|
742
817
|
'[vibe-sessions] Revocation check MISCONFIGURED (VIBE_CLIENT_ID set but IDP_URL/VIBE_HMAC_KEY missing) - reporting unknown (fail closed)'
|
|
743
818
|
);
|
|
819
|
+
logRevocationReason('unknown', 'misconfigured', sessionToken);
|
|
744
820
|
return 'unknown';
|
|
745
821
|
}
|
|
746
822
|
|
|
747
|
-
const { ok, sessions } = await readSessionRevocationHmac(idpUserId);
|
|
823
|
+
const { ok, sessions, failure } = await readSessionRevocationHmac(idpUserId);
|
|
748
824
|
|
|
749
825
|
if (!ok) {
|
|
750
826
|
console.warn('[vibe-sessions] Revocation read failed - reporting unknown');
|
|
827
|
+
logRevocationReason('unknown', `read_failed:${failure ?? 'unknown'}`, sessionToken);
|
|
751
828
|
return 'unknown';
|
|
752
829
|
}
|
|
753
830
|
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
:
|
|
831
|
+
const { row, rule } = findRevokingSessionWithRule(sessions, sessionToken ? hashSessionToken(sessionToken) : undefined);
|
|
832
|
+
if (row) {
|
|
833
|
+
logRevocationReason('revoked', rule, sessionToken, { rows: sessions.length });
|
|
834
|
+
return 'revoked';
|
|
835
|
+
}
|
|
836
|
+
return 'active';
|
|
757
837
|
}
|
|
758
838
|
|
|
759
839
|
// -----------------------------------------------------------------------------
|
|
@@ -817,3 +897,68 @@ export async function getSessionStats(accessToken: string): Promise<{
|
|
|
817
897
|
recentLogins: stats.recentLogins,
|
|
818
898
|
};
|
|
819
899
|
}
|
|
900
|
+
|
|
901
|
+
// -----------------------------------------------------------------------------
|
|
902
|
+
// PAY-2136: SINGLE-FLIGHT + SHORT RESULT CACHE for the revocation read
|
|
903
|
+
// -----------------------------------------------------------------------------
|
|
904
|
+
// Every middleware viability check does one revocation read through the IdP proxy for the SAME Vibe client. A page
|
|
905
|
+
// whose chrome prefetches ~4 links makes ~8 checks inside a second; the free tier allows 2 concurrent queries, so
|
|
906
|
+
// the 3rd+ read was a 429 CONCURRENT_LIMIT and (before this change) signed the user out. Now concurrent callers for
|
|
907
|
+
// ONE session share ONE in-flight read, and a CONFIRMED verdict is reused for a few seconds.
|
|
908
|
+
// - keyed by userId + the token HASH: never shared across sessions or users;
|
|
909
|
+
// - only 'active' / 'revoked' are cached: a failure ('unknown') is shared while in flight but never remembered, so
|
|
910
|
+
// the next request reads again; a revoked session is honoured at most REVOCATION_CACHE_MS late;
|
|
911
|
+
// - REVOCATION_CACHE_MS=0 disables the cache (single-flight still applies).
|
|
912
|
+
const DEFAULT_REVOCATION_CACHE_MS = 5000;
|
|
913
|
+
const MAX_REVOCATION_CACHE_ENTRIES = 2000;
|
|
914
|
+
const revocationInflight = new Map<string, Promise<RevocationStatus>>();
|
|
915
|
+
const revocationVerdicts = new Map<string, { status: RevocationStatus; at: number }>();
|
|
916
|
+
|
|
917
|
+
function revocationCacheMs(): number {
|
|
918
|
+
const raw = process.env.REVOCATION_CACHE_MS;
|
|
919
|
+
if (raw === undefined || raw === '') return DEFAULT_REVOCATION_CACHE_MS;
|
|
920
|
+
const n = Number(raw);
|
|
921
|
+
return Number.isFinite(n) && n >= 0 ? Math.min(n, 30_000) : DEFAULT_REVOCATION_CACHE_MS;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/** Test/ops hook: forget every cached verdict and in-flight read. */
|
|
925
|
+
export function resetRevocationCache(): void {
|
|
926
|
+
revocationInflight.clear();
|
|
927
|
+
revocationVerdicts.clear();
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
export async function getRevocationStatus(
|
|
931
|
+
idpUserId: number,
|
|
932
|
+
sessionToken?: string
|
|
933
|
+
): Promise<RevocationStatus> {
|
|
934
|
+
// No token = the user-wide legacy path: nothing session-specific to key on, so no sharing.
|
|
935
|
+
if (!sessionToken) return readRevocationStatus(idpUserId, sessionToken);
|
|
936
|
+
|
|
937
|
+
const key = `${idpUserId}:${hashSessionToken(sessionToken)}`;
|
|
938
|
+
const ttl = revocationCacheMs();
|
|
939
|
+
const now = Date.now();
|
|
940
|
+
|
|
941
|
+
const hit = revocationVerdicts.get(key);
|
|
942
|
+
if (hit && ttl > 0 && now - hit.at < ttl) return hit.status;
|
|
943
|
+
if (hit) revocationVerdicts.delete(key);
|
|
944
|
+
|
|
945
|
+
const existing = revocationInflight.get(key);
|
|
946
|
+
if (existing) return existing;
|
|
947
|
+
|
|
948
|
+
const read = readRevocationStatus(idpUserId, sessionToken)
|
|
949
|
+
.then((status) => {
|
|
950
|
+
if (ttl > 0 && (status === 'active' || status === 'revoked')) {
|
|
951
|
+
if (revocationVerdicts.size >= MAX_REVOCATION_CACHE_ENTRIES) {
|
|
952
|
+
for (const [k, v] of revocationVerdicts) if (now - v.at >= ttl) revocationVerdicts.delete(k);
|
|
953
|
+
if (revocationVerdicts.size >= MAX_REVOCATION_CACHE_ENTRIES) revocationVerdicts.clear();
|
|
954
|
+
}
|
|
955
|
+
revocationVerdicts.set(key, { status, at: Date.now() });
|
|
956
|
+
}
|
|
957
|
+
return status;
|
|
958
|
+
})
|
|
959
|
+
.finally(() => {
|
|
960
|
+
revocationInflight.delete(key);
|
|
961
|
+
});
|
|
962
|
+
revocationInflight.set(key, read);
|
|
963
|
+
return read;
|
|
964
|
+
}
|