@payez/next-mvp 4.11.0 → 4.12.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.
- package/CHANGELOG.md +13 -0
- package/dist/api-handlers/account/recovery/forward.js +2 -1
- package/dist/api-handlers/account/recovery/initiate.js +2 -1
- package/dist/api-handlers/account/reset-password.js +2 -1
- package/dist/api-handlers/account/send-code.js +2 -1
- package/dist/api-handlers/account/update-phone.js +2 -1
- package/dist/api-handlers/account/validate-password.js +2 -1
- package/dist/api-handlers/account/verify-email.js +2 -1
- package/dist/api-handlers/account/verify-sms.js +2 -1
- package/dist/api-handlers/anon/preferences.js +3 -2
- package/dist/api-handlers/auth/credential-login-email-code.js +3 -2
- package/dist/api-handlers/auth/credential-login.js +6 -5
- package/dist/api-handlers/auth/refresh.js +2 -1
- package/dist/api-handlers/auth/signout.js +43 -6
- package/dist/api-handlers/mfa/backup-code-verify.js +2 -1
- package/dist/api-handlers/mfa/totp-verify.js +2 -1
- package/dist/api-handlers/session/refresh-viability.js +3 -2
- package/dist/api-handlers/session/viability.js +2 -1
- package/dist/auth/better-auth.js +7 -6
- package/dist/auth/idp-oauth.js +4 -2
- package/dist/auth/utils/idp-client.js +5 -4
- package/dist/auth/utils/token-utils.js +4 -3
- package/dist/config/logger.js +2 -1
- package/dist/config/logging-config.js +4 -3
- package/dist/config/vibe-log-transport.js +2 -1
- package/dist/lib/ensure-fresh-access-token.js +2 -1
- package/dist/lib/geolocation.js +5 -22
- package/dist/lib/idp-client-config.js +4 -3
- package/dist/lib/idp-fetch.d.ts +11 -0
- package/dist/lib/idp-fetch.js +3 -1
- package/dist/lib/idp-logout.d.ts +10 -0
- package/dist/lib/idp-logout.js +73 -1
- package/dist/lib/jwt-decode-client.js +2 -1
- package/dist/lib/jwt-decode.js +2 -1
- package/dist/lib/log-safe.d.ts +19 -0
- package/dist/lib/log-safe.js +32 -0
- package/dist/lib/redis.js +2 -1
- package/dist/lib/safe-error-response.d.ts +5 -1
- package/dist/lib/safe-error-response.js +7 -2
- package/dist/lib/session-store.js +2 -1
- package/dist/lib/site-logger.d.ts +2 -8
- package/dist/lib/site-logger.js +11 -5
- package/dist/lib/standardized-client-api.js +3 -2
- package/dist/lib/token-lifecycle.js +3 -2
- package/dist/middleware/create-middleware.js +2 -1
- package/dist/middleware/rbac-check.d.ts +0 -13
- package/dist/middleware/rbac-check.js +6 -5
- package/dist/routes/auth/logout.js +6 -2
- package/dist/routes/auth/settings.js +2 -1
- package/dist/server/auth-guard.js +4 -3
- package/dist/server/auth.js +5 -9
- package/dist/server/decode-session.js +3 -2
- package/dist/server/with-auth.js +2 -1
- package/package.json +1 -1
- package/src/api-handlers/account/recovery/forward.ts +2 -1
- package/src/api-handlers/account/recovery/initiate.ts +2 -1
- package/src/api-handlers/account/reset-password.ts +2 -1
- package/src/api-handlers/account/send-code.ts +2 -1
- package/src/api-handlers/account/update-phone.ts +2 -1
- package/src/api-handlers/account/validate-password.ts +2 -1
- package/src/api-handlers/account/verify-email.ts +2 -1
- package/src/api-handlers/account/verify-sms.ts +2 -1
- package/src/api-handlers/anon/preferences.ts +3 -2
- package/src/api-handlers/auth/credential-login-email-code.ts +3 -2
- package/src/api-handlers/auth/credential-login.ts +6 -5
- package/src/api-handlers/auth/refresh.ts +2 -1
- package/src/api-handlers/auth/signout.ts +54 -7
- package/src/api-handlers/mfa/backup-code-verify.ts +2 -1
- package/src/api-handlers/mfa/totp-verify.ts +2 -1
- package/src/api-handlers/session/refresh-viability.ts +3 -2
- package/src/api-handlers/session/viability.ts +2 -1
- package/src/auth/better-auth.ts +7 -6
- package/src/auth/idp-oauth.ts +7 -2
- package/src/auth/utils/idp-client.ts +5 -4
- package/src/auth/utils/token-utils.ts +4 -3
- package/src/config/logger.ts +2 -1
- package/src/config/logging-config.ts +4 -3
- package/src/config/vibe-log-transport.ts +2 -1
- package/src/lib/ensure-fresh-access-token.ts +2 -1
- package/src/lib/geolocation.ts +5 -4
- package/src/lib/idp-client-config.ts +4 -3
- package/src/lib/idp-fetch.ts +11 -1
- package/src/lib/idp-logout.ts +36 -1
- package/src/lib/jwt-decode-client.ts +3 -1
- package/src/lib/jwt-decode.ts +2 -1
- package/src/lib/log-safe.ts +30 -0
- package/src/lib/redis.ts +2 -1
- package/src/lib/safe-error-response.ts +7 -2
- package/src/lib/session-store.ts +2 -1
- package/src/lib/site-logger.ts +13 -7
- package/src/lib/standardized-client-api.ts +3 -2
- package/src/lib/token-lifecycle.ts +3 -2
- package/src/middleware/create-middleware.ts +2 -1
- package/src/middleware/rbac-check.ts +2 -1
- package/src/routes/auth/logout.ts +9 -2
- package/src/routes/auth/settings.ts +2 -1
- package/src/server/auth-guard.ts +4 -3
- package/src/server/auth.ts +5 -10
- package/src/server/decode-session.ts +3 -2
- package/src/server/with-auth.ts +2 -1
|
@@ -4,6 +4,7 @@ import { checkRefreshViability } from '../../lib/refresh-token-validator';
|
|
|
4
4
|
import { getTokenTestAware } from '../../lib/test-aware-get-token';
|
|
5
5
|
import { logger } from '../../config/logger';
|
|
6
6
|
import { tokenLogId } from '../../lib/token-log-id';
|
|
7
|
+
import { errForLog } from '../../lib/log-safe';
|
|
7
8
|
import { resolveSessionState } from '../../server/auth';
|
|
8
9
|
|
|
9
10
|
export async function GET(req: NextRequest) {
|
|
@@ -49,8 +50,8 @@ export async function GET(req: NextRequest) {
|
|
|
49
50
|
return NextResponse.json({ canRefresh: viabilityCheck.canRefresh, reason: viabilityCheck.reason, timeRemaining: viabilityCheck.timeRemaining, expiresAt: viabilityCheck.expiresAt, accessTokenExpired: viabilityCheck.accessTokenExpired, accessTokenTimeRemaining: viabilityCheck.accessTokenTimeRemaining, sessionTokenId: tokenLogId(finalSessionToken), twoFactorComplete: sessionData.mfaVerified ?? false, userId: sessionData.userId, refreshInProgress: false });
|
|
50
51
|
} catch (error) {
|
|
51
52
|
const correlationId = globalThis.crypto.randomUUID();
|
|
52
|
-
logger.error('[REFRESH-VIABILITY] Error checking refresh viability', { correlationId, error:
|
|
53
|
-
// PAY-1438: the
|
|
53
|
+
logger.error('[REFRESH-VIABILITY] Error checking refresh viability', { correlationId, error: errForLog(error) });
|
|
54
|
+
// PAY-1438: the caller gets the id; the log line names the error only (errForLog), never its message (PII-in-logs group 1, BAPert 76553).
|
|
54
55
|
return NextResponse.json({ canRefresh: false, reason: 'check_error', error: 'Failed to check refresh viability', code: 'INTERNAL_ERROR', correlationId }, { status: 500 });
|
|
55
56
|
}
|
|
56
57
|
}
|
|
@@ -12,6 +12,7 @@ import { isInitializationFailed, ensureInitialized } from '../../lib/startup-ini
|
|
|
12
12
|
import { getIDPClientConfig } from '../../lib/idp-client-config';
|
|
13
13
|
import { tokenLogId } from '../../lib/token-log-id';
|
|
14
14
|
import { getRevocationStatus, warnNonNumericUserId, resolveNumericIdpUserId, type RevocationStatus } from '../../vibe/sessions';
|
|
15
|
+
import { errForLog } from '../../lib/log-safe';
|
|
15
16
|
|
|
16
17
|
export async function GET(req: NextRequest) {
|
|
17
18
|
try {
|
|
@@ -233,7 +234,7 @@ export async function GET(req: NextRequest) {
|
|
|
233
234
|
return NextResponse.json({ authenticated: false });
|
|
234
235
|
|
|
235
236
|
} catch (error) {
|
|
236
|
-
console.error('[API Viability] Error checking session viability:', error);
|
|
237
|
+
console.error('[API Viability] Error checking session viability:', errForLog(error));
|
|
237
238
|
return NextResponse.json({ authenticated: false }, { status: 500 });
|
|
238
239
|
}
|
|
239
240
|
}
|
package/src/auth/better-auth.ts
CHANGED
|
@@ -33,6 +33,7 @@ import { getRedis } from '../lib/redis';
|
|
|
33
33
|
import { createSingleFlight, type SingleFlight } from '../lib/single-flight';
|
|
34
34
|
import { SESSION_TTL } from '../lib/session-store';
|
|
35
35
|
import { tokenLogId } from '../lib/token-log-id';
|
|
36
|
+
import { errForLog } from '../lib/log-safe';
|
|
36
37
|
|
|
37
38
|
/**
|
|
38
39
|
* 185121: short sha256 fingerprint for secrets/tokens in log lines — enough
|
|
@@ -855,7 +856,7 @@ export function createBetterAuthInstance(
|
|
|
855
856
|
const { deleteSession } = await import('../lib/session-store');
|
|
856
857
|
await deleteSession(key);
|
|
857
858
|
} catch (err) {
|
|
858
|
-
console.warn('[BETTER_AUTH] secondary delete: canonical store cleanup failed:', err);
|
|
859
|
+
console.warn('[BETTER_AUTH] secondary delete: canonical store cleanup failed:', errForLog(err));
|
|
859
860
|
}
|
|
860
861
|
},
|
|
861
862
|
},
|
|
@@ -1356,7 +1357,7 @@ export async function exchangeOAuthForIdpTokens(
|
|
|
1356
1357
|
// true = success (tokens stored). false = a non-IdP failure with no specific code (generic abort).
|
|
1357
1358
|
return stored;
|
|
1358
1359
|
} catch (err) {
|
|
1359
|
-
console.error('[BETTER_AUTH] IDP token exchange failed:',
|
|
1360
|
+
console.error('[BETTER_AUTH] IDP token exchange failed:', errForLog(err));
|
|
1360
1361
|
return false;
|
|
1361
1362
|
}
|
|
1362
1363
|
}
|
|
@@ -1446,7 +1447,7 @@ export async function storeIdpTokensInSession(
|
|
|
1446
1447
|
|
|
1447
1448
|
return true;
|
|
1448
1449
|
} catch (err) {
|
|
1449
|
-
console.error('[BETTER_AUTH] storeIdpTokensInSession failed:',
|
|
1450
|
+
console.error('[BETTER_AUTH] storeIdpTokensInSession failed:', errForLog(err));
|
|
1450
1451
|
return false;
|
|
1451
1452
|
}
|
|
1452
1453
|
}
|
|
@@ -1547,13 +1548,13 @@ async function abortOAuthSignIn(
|
|
|
1547
1548
|
const out = await auth?.api?.signOut?.({ headers: new Headers({ cookie: cookieHeader }), asResponse: true });
|
|
1548
1549
|
for (const sc of (out as Response | undefined)?.headers?.getSetCookie?.() ?? []) headers.append('set-cookie', sc);
|
|
1549
1550
|
} catch (err) {
|
|
1550
|
-
console.warn('[BETTER_AUTH] abort: Better Auth signOut failed, deleting the record directly:',
|
|
1551
|
+
console.warn('[BETTER_AUTH] abort: Better Auth signOut failed, deleting the record directly:', errForLog(err));
|
|
1551
1552
|
}
|
|
1552
1553
|
try {
|
|
1553
1554
|
const config = await getIDPClientConfig();
|
|
1554
1555
|
await getRedis().del(`ba:${config.clientSlug || getAppSlug()}:${sessionToken}`);
|
|
1555
1556
|
} catch (err) {
|
|
1556
|
-
console.error('[BETTER_AUTH] abort: could not delete the Better Auth session record', tokenLogId(sessionToken),
|
|
1557
|
+
console.error('[BETTER_AUTH] abort: could not delete the Better Auth session record', tokenLogId(sessionToken), errForLog(err));
|
|
1557
1558
|
}
|
|
1558
1559
|
}
|
|
1559
1560
|
for (const sc of expireSetCookies(callbackResponse.headers.getSetCookie?.() ?? [])) headers.append('set-cookie', sc);
|
|
@@ -1673,7 +1674,7 @@ export function createAuthGetHandler(loginPath: string = '/account-auth/login')
|
|
|
1673
1674
|
}
|
|
1674
1675
|
}
|
|
1675
1676
|
} catch (err: any) {
|
|
1676
|
-
console.error('[BETTER_AUTH] IDP token exchange failed:', err
|
|
1677
|
+
console.error('[BETTER_AUTH] IDP token exchange failed:', errForLog(err));
|
|
1677
1678
|
}
|
|
1678
1679
|
if (exchange !== true) {
|
|
1679
1680
|
// A specific IdP error code goes on the URL; otherwise the generic OAUTH_SIGN_IN_FAILED.
|
package/src/auth/idp-oauth.ts
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
* case-insensitively, at the authorize step). Both come from ONE value: the `redirectURI` of the provider config.
|
|
20
20
|
*/
|
|
21
21
|
import { createRemoteJWKSet, decodeProtectedHeader, jwtVerify, type JWTVerifyGetKey } from 'jose';
|
|
22
|
+
import { errForLog } from '../lib/log-safe';
|
|
22
23
|
|
|
23
24
|
export interface IdpTokenGrant {
|
|
24
25
|
accessToken: string;
|
|
@@ -281,7 +282,11 @@ export function createIdpOAuthHooks(opts: IdpOAuthHookOptions) {
|
|
|
281
282
|
if (responseMasked) console.warn('[BETTER_AUTH] payez-idp token response address is masked or unusable; ignored');
|
|
282
283
|
const email = resolveEmail(payload as Record<string, unknown>, responseEmailUsable ? responseEmailRaw : undefined);
|
|
283
284
|
if (!email) {
|
|
284
|
-
|
|
285
|
+
// A closed `code` rides on the error so the refusal log tells an operator WHY (errForLog prints a closed UPPER_SNAKE code) with no value (BAPert 76998 E11).
|
|
286
|
+
throw Object.assign(
|
|
287
|
+
new Error(responseMasked ? 'response address is masked' : 'no usable email (needs data.user.email or an email claim, address-shaped, equal when both are present)'),
|
|
288
|
+
{ code: responseMasked ? 'RESPONSE_ADDRESS_MASKED' : 'NO_USABLE_EMAIL' },
|
|
289
|
+
);
|
|
285
290
|
}
|
|
286
291
|
const name =
|
|
287
292
|
(typeof payload.name === 'string' && payload.name.trim()) ||
|
|
@@ -301,7 +306,7 @@ export function createIdpOAuthHooks(opts: IdpOAuthHookOptions) {
|
|
|
301
306
|
return { id: claims.sub, email: claims.email, name: claims.name, emailVerified: claims.emailVerified };
|
|
302
307
|
} catch (err) {
|
|
303
308
|
// Reason only, never the token.
|
|
304
|
-
console.warn('[BETTER_AUTH] payez-idp access token refused:',
|
|
309
|
+
console.warn('[BETTER_AUTH] payez-idp access token refused:', errForLog(err));
|
|
305
310
|
return null;
|
|
306
311
|
}
|
|
307
312
|
}
|
|
@@ -18,6 +18,7 @@ import type {
|
|
|
18
18
|
IdpRefreshResponse,
|
|
19
19
|
LoginCredentials,
|
|
20
20
|
} from '../types/auth-types';
|
|
21
|
+
import { errForLog } from '../../lib/log-safe';
|
|
21
22
|
|
|
22
23
|
// ============================================================================
|
|
23
24
|
// CONFIGURATION
|
|
@@ -109,7 +110,7 @@ export async function idpLogin(
|
|
|
109
110
|
result: responseData.result,
|
|
110
111
|
};
|
|
111
112
|
} catch (error) {
|
|
112
|
-
console.error('[IDP_CLIENT] Login request failed:', error);
|
|
113
|
+
console.error('[IDP_CLIENT] Login request failed:', errForLog(error));
|
|
113
114
|
return {
|
|
114
115
|
success: false,
|
|
115
116
|
error: {
|
|
@@ -246,7 +247,7 @@ export async function idpRefreshToken(
|
|
|
246
247
|
},
|
|
247
248
|
};
|
|
248
249
|
} catch (error) {
|
|
249
|
-
console.error('[IDP_CLIENT] Token refresh request failed:', error);
|
|
250
|
+
console.error('[IDP_CLIENT] Token refresh request failed:', errForLog(error));
|
|
250
251
|
return {
|
|
251
252
|
success: false,
|
|
252
253
|
error: {
|
|
@@ -304,7 +305,7 @@ export async function idpVerify2FA(
|
|
|
304
305
|
|
|
305
306
|
return { success: true };
|
|
306
307
|
} catch (error) {
|
|
307
|
-
console.error('[IDP_CLIENT] 2FA verification failed:', error);
|
|
308
|
+
console.error('[IDP_CLIENT] 2FA verification failed:', errForLog(error));
|
|
308
309
|
return {
|
|
309
310
|
success: false,
|
|
310
311
|
error: {
|
|
@@ -352,7 +353,7 @@ export async function idpSend2FACode(
|
|
|
352
353
|
|
|
353
354
|
return { success: true };
|
|
354
355
|
} catch (error) {
|
|
355
|
-
console.error('[IDP_CLIENT] Send 2FA code failed:', error);
|
|
356
|
+
console.error('[IDP_CLIENT] Send 2FA code failed:', errForLog(error));
|
|
356
357
|
return {
|
|
357
358
|
success: false,
|
|
358
359
|
error: {
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { jwtDecode, decodeJwtHeader, extractKidFromToken, JwtHeader } from '../../lib/jwt-decode';
|
|
11
11
|
import type { DecodedIdpAccessToken } from '../types/auth-types';
|
|
12
12
|
import { REFRESH_WINDOW_MS } from '../../lib/refresh-window';
|
|
13
|
+
import { errForLog } from '../../lib/log-safe';
|
|
13
14
|
|
|
14
15
|
// Re-export header utilities for consumers
|
|
15
16
|
export { decodeJwtHeader, extractKidFromToken, type JwtHeader } from '../../lib/jwt-decode';
|
|
@@ -28,7 +29,7 @@ export function decodeIdpAccessToken(token: string): DecodedIdpAccessToken | nul
|
|
|
28
29
|
try {
|
|
29
30
|
return jwtDecode<DecodedIdpAccessToken>(token);
|
|
30
31
|
} catch (error) {
|
|
31
|
-
console.error('[TOKEN_UTILS] Failed to decode access token:', error);
|
|
32
|
+
console.error('[TOKEN_UTILS] Failed to decode access token:', errForLog(error));
|
|
32
33
|
return null;
|
|
33
34
|
}
|
|
34
35
|
}
|
|
@@ -59,7 +60,7 @@ export function decodeIdpAccessTokenFull(token: string): {
|
|
|
59
60
|
bearerKeyId: header.kid,
|
|
60
61
|
};
|
|
61
62
|
} catch (error) {
|
|
62
|
-
console.error('[TOKEN_UTILS] Failed to decode access token (full):', error);
|
|
63
|
+
console.error('[TOKEN_UTILS] Failed to decode access token (full):', errForLog(error));
|
|
63
64
|
return null;
|
|
64
65
|
}
|
|
65
66
|
}
|
|
@@ -238,7 +239,7 @@ export function validateTokenExpiry(
|
|
|
238
239
|
mismatch,
|
|
239
240
|
};
|
|
240
241
|
} catch (error) {
|
|
241
|
-
console.error('[TOKEN_UTILS] Failed to validate token expiry:', error);
|
|
242
|
+
console.error('[TOKEN_UTILS] Failed to validate token expiry:', errForLog(error));
|
|
242
243
|
return { valid: false, actualExpiresAt: null, mismatch: false };
|
|
243
244
|
}
|
|
244
245
|
}
|
package/src/config/logger.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getLoggingConfigSync, startConfigWatching, addConfigWatcher } from './logging-config';
|
|
2
2
|
import { LoggingConfig } from '../types/logging';
|
|
3
3
|
import { createVibeLogTransport } from './vibe-log-transport';
|
|
4
|
+
import { errForLog } from '../lib/log-safe';
|
|
4
5
|
|
|
5
6
|
let loggingConfig: LoggingConfig = getLoggingConfigSync();
|
|
6
7
|
startConfigWatching();
|
|
@@ -41,7 +42,7 @@ const createLogger = (): any => {
|
|
|
41
42
|
console.log('[LOGGER] Vibe Redis transport DISABLED - no REDIS_URL configured');
|
|
42
43
|
}
|
|
43
44
|
return winston.createLogger({ level: loggingConfig.logLevel, levels: loggingConfig.levels, format: winston.format.combine(winston.format.timestamp(), winston.format.errors({ stack: true }), winston.format.json()), transports, exitOnError: false });
|
|
44
|
-
} catch (error) { console.warn('Failed to initialize Winston logger, falling back to EdgeLogger:', error); return new EdgeLogger(); }
|
|
45
|
+
} catch (error) { console.warn('Failed to initialize Winston logger, falling back to EdgeLogger:', errForLog(error)); return new EdgeLogger(); }
|
|
45
46
|
};
|
|
46
47
|
|
|
47
48
|
let logger: any;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { promises as fs } from 'fs';
|
|
2
2
|
import { watch, existsSync } from 'fs';
|
|
3
3
|
import { LoggingConfig, LogLevel } from '../types/logging';
|
|
4
|
+
import { errForLog } from '../lib/log-safe';
|
|
4
5
|
|
|
5
6
|
const isEdgeRuntime = typeof (globalThis as any).EdgeRuntime !== 'undefined' || process.env.NEXT_RUNTIME === 'edge';
|
|
6
7
|
|
|
@@ -35,7 +36,7 @@ class LoggingConfigManager {
|
|
|
35
36
|
const config = this.validateAndMergeConfig(rawConfig);
|
|
36
37
|
this.config = config; this.notifyWatchers(config); return config;
|
|
37
38
|
} catch (error) {
|
|
38
|
-
console.warn(`Failed to load logging config from ${this.configPath}:`, error);
|
|
39
|
+
console.warn(`Failed to load logging config from ${this.configPath}:`, errForLog(error));
|
|
39
40
|
return this.getDefaultConfig();
|
|
40
41
|
}
|
|
41
42
|
}
|
|
@@ -43,11 +44,11 @@ class LoggingConfigManager {
|
|
|
43
44
|
async getConfig(): Promise<LoggingConfig> { if (!this.config) { return await this.loadConfig(); } return this.config; }
|
|
44
45
|
getConfigSync(): LoggingConfig { return this.config || this.getDefaultConfig(); }
|
|
45
46
|
|
|
46
|
-
startWatching(): void { if (this.isEdgeRuntime || this.isBrowser) { return; } if (this.fileWatcher) { return; } try { if (!existsSync(this.configPath)) { console.warn(`Logging config file not found at ${this.configPath} — watch disabled`); return; } this.fileWatcher = watch(this.configPath, { persistent: false }, (eventType) => { if (eventType === 'change') { this.loadConfig().catch(console.error); } }); } catch (error) { console.warn('Failed to start watching logging config file:', error); } }
|
|
47
|
+
startWatching(): void { if (this.isEdgeRuntime || this.isBrowser) { return; } if (this.fileWatcher) { return; } try { if (!existsSync(this.configPath)) { console.warn(`Logging config file not found at ${this.configPath} — watch disabled`); return; } this.fileWatcher = watch(this.configPath, { persistent: false }, (eventType) => { if (eventType === 'change') { this.loadConfig().catch(console.error); } }); } catch (error) { console.warn('Failed to start watching logging config file:', errForLog(error)); } }
|
|
47
48
|
stopWatching(): void { if (this.isEdgeRuntime || this.isBrowser) { return; } if (this.fileWatcher) { this.fileWatcher.close(); this.fileWatcher = null; } }
|
|
48
49
|
addWatcher(callback: (config: LoggingConfig) => void): void { this.watchers.add(callback); }
|
|
49
50
|
removeWatcher(callback: (config: LoggingConfig) => void): void { this.watchers.delete(callback); }
|
|
50
|
-
private notifyWatchers(config: LoggingConfig): void { this.watchers.forEach(cb => { try { cb(config); } catch (error) { console.error('Error in logging config watcher:', error); } }); }
|
|
51
|
+
private notifyWatchers(config: LoggingConfig): void { this.watchers.forEach(cb => { try { cb(config); } catch (error) { console.error('Error in logging config watcher:', errForLog(error)); } }); }
|
|
51
52
|
|
|
52
53
|
private validateAndMergeConfig(rawConfig: any): LoggingConfig {
|
|
53
54
|
const defaults = this.getDefaultConfig();
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { getRedis } from '../lib/redis';
|
|
15
|
+
import { errForLog } from '../lib/log-safe';
|
|
15
16
|
|
|
16
17
|
// Dynamic import — winston is a peerDependency and may not be installed.
|
|
17
18
|
// We resolve the base class lazily so builds don't break without it.
|
|
@@ -188,7 +189,7 @@ export class VibeLogTransport extends TransportBase {
|
|
|
188
189
|
this.batch = [...entries, ...this.batch].slice(0, 100);
|
|
189
190
|
}
|
|
190
191
|
// Log to console as fallback
|
|
191
|
-
console.error('[VibeLogTransport] Failed to flush logs:', err);
|
|
192
|
+
console.error('[VibeLogTransport] Failed to flush logs:', errForLog(err));
|
|
192
193
|
} finally {
|
|
193
194
|
this.isFlushing = false;
|
|
194
195
|
}
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
isRefreshInDoubt,
|
|
36
36
|
requestNeverLeft,
|
|
37
37
|
} from './refresh-in-doubt';
|
|
38
|
+
import { errForLog } from './log-safe';
|
|
38
39
|
|
|
39
40
|
export interface EnsureFreshConfig {
|
|
40
41
|
idpBaseUrl: string;
|
|
@@ -412,7 +413,7 @@ export async function ensureFreshAccessToken(
|
|
|
412
413
|
try {
|
|
413
414
|
await updateSession(sessionToken, inDoubtPatch(sentRefreshToken as string));
|
|
414
415
|
} catch (markError) {
|
|
415
|
-
console.error('[ENSURE-FRESH] could not record the in-doubt marker', { error:
|
|
416
|
+
console.error('[ENSURE-FRESH] could not record the in-doubt marker', { error: errForLog(markError) });
|
|
416
417
|
}
|
|
417
418
|
return {
|
|
418
419
|
ok: false,
|
package/src/lib/geolocation.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { errForLog } from './log-safe';
|
|
1
2
|
/**
|
|
2
3
|
* =============================================================================
|
|
3
4
|
* GEOLOCATION UTILITY
|
|
@@ -103,7 +104,7 @@ export async function getPublicIP(): Promise<string | null> {
|
|
|
103
104
|
// Validate it looks like an IP
|
|
104
105
|
if (ip && /^[\d.]+$/.test(ip) || /^[a-f0-9:]+$/i.test(ip)) {
|
|
105
106
|
cachedPublicIP = { ip, timestamp: Date.now() };
|
|
106
|
-
console.log(
|
|
107
|
+
console.log("[geolocation] Public IP resolved");
|
|
107
108
|
return ip;
|
|
108
109
|
}
|
|
109
110
|
}
|
|
@@ -163,7 +164,7 @@ export async function getLocationFromIP(ip: string, resolvePublicIP: boolean = t
|
|
|
163
164
|
if (resolvePublicIP) {
|
|
164
165
|
const publicIP = await getPublicIP();
|
|
165
166
|
if (publicIP && !isPrivateIP(publicIP)) {
|
|
166
|
-
console.log(
|
|
167
|
+
console.log("[geolocation] Private IP -> using the resolved public IP");
|
|
167
168
|
// Recursively call with public IP (but don't resolve again to prevent loops)
|
|
168
169
|
return getLocationFromIP(publicIP, false);
|
|
169
170
|
}
|
|
@@ -201,7 +202,7 @@ export async function getLocationFromIP(ip: string, resolvePublicIP: boolean = t
|
|
|
201
202
|
const data = await response.json();
|
|
202
203
|
|
|
203
204
|
if (data.status !== 'success') {
|
|
204
|
-
console.warn(
|
|
205
|
+
console.warn("[geolocation] Lookup failed (upstream refused)");
|
|
205
206
|
// Cache the failure to avoid repeated lookups
|
|
206
207
|
geoCache.set(ip, { location: null, timestamp: Date.now() });
|
|
207
208
|
return null;
|
|
@@ -224,7 +225,7 @@ export async function getLocationFromIP(ip: string, resolvePublicIP: boolean = t
|
|
|
224
225
|
return location;
|
|
225
226
|
|
|
226
227
|
} catch (error) {
|
|
227
|
-
console.error(
|
|
228
|
+
console.error("[geolocation] lookup failed:", errForLog(error)); // no IP on a routine line (BAPert 76553 Q1)
|
|
228
229
|
return null;
|
|
229
230
|
}
|
|
230
231
|
}
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
import 'server-only';
|
|
44
44
|
import { createHash, randomUUID } from 'crypto';
|
|
45
45
|
import { markNeverInResponse } from './never-in-response';
|
|
46
|
+
import { errForLog } from './log-safe';
|
|
46
47
|
// REDIS IS IMPORTED LAZILY, AND THAT IS LOAD-BEARING.
|
|
47
48
|
// A module-scope `import redis from './redis'` drags ioredis into EVERY bundle that
|
|
48
49
|
// touches this file - including instrumentation.ts. Next compiles the instrumentation
|
|
@@ -360,7 +361,7 @@ async function getConfigFromRedis(): Promise<IDPClientConfig | null> {
|
|
|
360
361
|
|
|
361
362
|
return parsed.config;
|
|
362
363
|
} catch (error) {
|
|
363
|
-
console.warn('[IDP_CONFIG] Failed to read from Redis cache:', error);
|
|
364
|
+
console.warn('[IDP_CONFIG] Failed to read from Redis cache:', errForLog(error));
|
|
364
365
|
return null;
|
|
365
366
|
}
|
|
366
367
|
}
|
|
@@ -377,7 +378,7 @@ async function setConfigInRedis(config: IDPClientConfig): Promise<void> {
|
|
|
377
378
|
// Store with TTL slightly longer than the logical expiry to allow for clock skew
|
|
378
379
|
await (await getRedis()).set(key, JSON.stringify(data), 'EX', ttlSeconds + 10);
|
|
379
380
|
} catch (error) {
|
|
380
|
-
console.warn('[IDP_CONFIG] Failed to write to Redis cache:', error);
|
|
381
|
+
console.warn('[IDP_CONFIG] Failed to write to Redis cache:', errForLog(error));
|
|
381
382
|
}
|
|
382
383
|
}
|
|
383
384
|
|
|
@@ -633,7 +634,7 @@ export async function clearConfigRedisCache(): Promise<void> {
|
|
|
633
634
|
await (await getRedis()).del(key);
|
|
634
635
|
console.log('[IDP_CONFIG] Redis cache cleared:', key);
|
|
635
636
|
} catch (error) {
|
|
636
|
-
console.warn('[IDP_CONFIG] Failed to clear Redis cache:', error);
|
|
637
|
+
console.warn('[IDP_CONFIG] Failed to clear Redis cache:', errForLog(error));
|
|
637
638
|
}
|
|
638
639
|
}
|
|
639
640
|
|
package/src/lib/idp-fetch.ts
CHANGED
|
@@ -46,6 +46,14 @@ export interface IdpFetchOptions {
|
|
|
46
46
|
* `skipped: true` without a request; null on the retry leaves the first answer standing. Absent: `init` is used as before.
|
|
47
47
|
*/
|
|
48
48
|
initFor?: (sessionToken: string) => Promise<RequestInit | null>;
|
|
49
|
+
/**
|
|
50
|
+
* 4.12: the refresh that follows a 401/403 is done by THIS function instead of the HTTP self-call to /api/auth/refresh. Sign-out supplies one that
|
|
51
|
+
* calls `ensureFreshAccessToken` in-process with a window that cannot be "fresh": a logout 401 with a locally-fresh token usually means Core
|
|
52
|
+
* invalidated the token (password change), and only the IdP's answer to a real refresh (401 + the discard marker = the grant is dead; success =
|
|
53
|
+
* the grant is live) can say which. No header on the public refresh route, so nothing a browser sends can force a refresh (BAPert 77022).
|
|
54
|
+
* Returns the status the refresh route would have answered. Absent: `internalRefresh` as before.
|
|
55
|
+
*/
|
|
56
|
+
refreshOnAuthFailure?: (sessionToken: string) => Promise<{ ok: boolean; status: number }>;
|
|
49
57
|
}
|
|
50
58
|
|
|
51
59
|
function buildHeaders(req: NextRequest, bearer?: string, extra?: Record<string, string>) {
|
|
@@ -184,7 +192,9 @@ export async function idpFetchJSON<T = any>(
|
|
|
184
192
|
attemptedRefresh = true;
|
|
185
193
|
try {
|
|
186
194
|
// Use centralized internal API helper for server-to-server calls
|
|
187
|
-
const rf =
|
|
195
|
+
const rf = opts.refreshOnAuthFailure
|
|
196
|
+
? await opts.refreshOnAuthFailure(sessionToken)
|
|
197
|
+
: await internalRefresh(req.headers.get('cookie') || '', sessionToken);
|
|
188
198
|
refreshStatus = rf.status;
|
|
189
199
|
if (rf.ok) {
|
|
190
200
|
const fresh = await getSession(sessionToken);
|
package/src/lib/idp-logout.ts
CHANGED
|
@@ -26,6 +26,19 @@ import { ENV_CONFIG } from '../config/env';
|
|
|
26
26
|
*/
|
|
27
27
|
export type IdpLogoutOutcome = 'revoked' | 'nothing-to-revoke' | 'grant-already-dead';
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* The revoke could not be confirmed. `grantLive` is true ONLY when the IdP itself showed the grant is alive: the forced refresh SUCCEEDED (it issued
|
|
31
|
+
* new tokens) and the logout still did not end it. Every other unknown (a transport error, a 5xx, a refresh that threw or answered something
|
|
32
|
+
* else) leaves it false: the grant may be live or dead, and the log must not claim either (BAPert 77013). The closed `code` is what errForLog prints.
|
|
33
|
+
*/
|
|
34
|
+
export class IdpLogoutUnconfirmedError extends Error {
|
|
35
|
+
readonly code = 'IDP_LOGOUT_UNCONFIRMED';
|
|
36
|
+
constructor(status: number, readonly grantLive: boolean) {
|
|
37
|
+
super(`IdP logout answered ${status}`);
|
|
38
|
+
this.name = 'IdpLogoutUnconfirmedError';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
29
42
|
export async function revokeSessionAtIdp(req: NextRequest): Promise<IdpLogoutOutcome> {
|
|
30
43
|
let resolvedSession: string | undefined;
|
|
31
44
|
const result = await idpFetchJSON(
|
|
@@ -33,6 +46,9 @@ export async function revokeSessionAtIdp(req: NextRequest): Promise<IdpLogoutOut
|
|
|
33
46
|
`${ENV_CONFIG.IDP_URL}/api/Account/logout`,
|
|
34
47
|
{},
|
|
35
48
|
{
|
|
49
|
+
// A logout 401 with a locally-fresh token must reach a REAL refresh, so the IdP's answer can prove the grant dead (BAPert 77013). Done
|
|
50
|
+
// in-process, not through the public refresh route, so no request header can force it (NightHawk 77021, BAPert 77022).
|
|
51
|
+
refreshOnAuthFailure: forceRefreshAtIdp,
|
|
36
52
|
initFor: async (token) => {
|
|
37
53
|
resolvedSession = token;
|
|
38
54
|
let session = await getSession(token);
|
|
@@ -50,7 +66,26 @@ export async function revokeSessionAtIdp(req: NextRequest): Promise<IdpLogoutOut
|
|
|
50
66
|
if (result.skipped) return 'nothing-to-revoke';
|
|
51
67
|
if (result.ok) return 'revoked';
|
|
52
68
|
if (result.attemptedRefresh && result.refreshStatus === 401 && resolvedSession && (await grantWasDiscarded(resolvedSession))) return 'grant-already-dead';
|
|
53
|
-
|
|
69
|
+
const refreshSucceeded = result.attemptedRefresh && typeof result.refreshStatus === 'number' && result.refreshStatus >= 200 && result.refreshStatus < 300;
|
|
70
|
+
throw new IdpLogoutUnconfirmedError(result.status, refreshSucceeded);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Refresh the session NOW, ignoring "the stored access token looks fresh", by calling `ensureFreshAccessToken` in this process (the one
|
|
75
|
+
* implementation that talks to the IdP, holds the lock and writes the discard marker). Config is read the way `oauth/resume` reads it
|
|
76
|
+
* (IDP_URL, CLIENT_ID, the default refresh endpoint). It answers with the status the refresh route would have: 200 on success, 401 for
|
|
77
|
+
* NO_SESSION / NO_REFRESH_TOKEN, 409 for a lost lock, otherwise E's own status (a discarded IdP 401 is 401 with the marker written).
|
|
78
|
+
* Missing config or a throw is an UNKNOWN outcome: the caller treats a thrown refresh as `refreshStatus: null`.
|
|
79
|
+
*/
|
|
80
|
+
async function forceRefreshAtIdp(sessionToken: string): Promise<{ ok: boolean; status: number }> {
|
|
81
|
+
const idpBaseUrl = process.env.IDP_URL;
|
|
82
|
+
const clientId = process.env.CLIENT_ID;
|
|
83
|
+
if (!idpBaseUrl || !clientId) throw new Error('IDP_URL and CLIENT_ID are required to prove a grant dead');
|
|
84
|
+
const { ensureFreshAccessToken } = await import('./ensure-fresh-access-token');
|
|
85
|
+
const r = await ensureFreshAccessToken(sessionToken, { idpBaseUrl, clientId }, { requireRefreshToken: true, safetyWindowMs: Number.MAX_SAFE_INTEGER });
|
|
86
|
+
if (r.ok) return { ok: true, status: 200 };
|
|
87
|
+
const status = r.code === 'NO_SESSION' || r.code === 'NO_REFRESH_TOKEN' ? 401 : r.code === 'CONFLICT' ? 409 : r.status;
|
|
88
|
+
return { ok: false, status };
|
|
54
89
|
}
|
|
55
90
|
|
|
56
91
|
/**
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* This is a lightweight version for browser usage
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { errForLog } from './log-safe';
|
|
7
|
+
|
|
6
8
|
// Decode base64url
|
|
7
9
|
function base64urlDecode(base64url: string): string {
|
|
8
10
|
try {
|
|
@@ -39,7 +41,7 @@ export function jwtDecode<T = any>(token: string): T | null {
|
|
|
39
41
|
|
|
40
42
|
return parsedPayload;
|
|
41
43
|
} catch (e) {
|
|
42
|
-
console.error('[JWT] Decode failed:', e
|
|
44
|
+
console.error('[JWT] Decode failed:', errForLog(e));
|
|
43
45
|
return null;
|
|
44
46
|
}
|
|
45
47
|
}
|
package/src/lib/jwt-decode.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { jwtDecode as originalJwtDecode } from 'jwt-decode';
|
|
2
|
+
import { errForLog } from './log-safe';
|
|
2
3
|
|
|
3
4
|
// Define JwtPayload interface since jwt-decode v4 doesn't export it
|
|
4
5
|
export interface JwtPayload {
|
|
@@ -65,7 +66,7 @@ export function decodeJwtHeader(token: string): JwtHeader | null {
|
|
|
65
66
|
|
|
66
67
|
return JSON.parse(headerJson) as JwtHeader;
|
|
67
68
|
} catch (error) {
|
|
68
|
-
console.error('[JWT_DECODE] Failed to decode JWT header:', error);
|
|
69
|
+
console.error('[JWT_DECODE] Failed to decode JWT header:', errForLog(error));
|
|
69
70
|
return null;
|
|
70
71
|
}
|
|
71
72
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* log-safe: the ONLY way a caught error or an upstream `code` goes into a server log (PII-in-logs census, BAPert 76553 group 1; ported from the
|
|
3
|
+
* portal's api-error-response.ts, BAPert 76470).
|
|
4
|
+
*
|
|
5
|
+
* Server logs persist and are read beyond the person who made the request. An error's `message`, `stack`, `cause` and `details` can carry an
|
|
6
|
+
* email, a phone number, a code, a token, a SQL fragment or a host: an IdP error's message is the upstream's own free text, and V8's JSON parse
|
|
7
|
+
* error quotes its input. So a log line gets the error's NAME and a closed STATUS / CODE, nothing else. Every field is matched against a closed
|
|
8
|
+
* shape; anything that does not match is dropped, not truncated.
|
|
9
|
+
*
|
|
10
|
+
* No imports on purpose: this is a leaf, loaded by modules that must not pull in anything else.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** An upstream `code` is loggable only if it is a closed token (UPPER_SNAKE). Anything else, including free text, becomes null. */
|
|
14
|
+
export function closedCodeForLog(code: unknown): string | null {
|
|
15
|
+
return typeof code === 'string' && /^[A-Z0-9_]{1,64}$/.test(code) ? code : null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Describe a caught error for a log line: `error=<Name> [status=<n>] [code=<CLOSED_CODE>]`.
|
|
20
|
+
* It NEVER carries `message`, `stack`, `cause` or `details`. A name that is not a short identifier (a hostile or free-text `name`) becomes
|
|
21
|
+
* `NonError`; a status outside 100-599 is dropped; a code outside the closed token shape is dropped.
|
|
22
|
+
*/
|
|
23
|
+
export function errForLog(err: unknown): string {
|
|
24
|
+
const e = (err !== null && typeof err === 'object' ? err : {}) as { name?: unknown; status?: unknown; code?: unknown };
|
|
25
|
+
const name = typeof e.name === 'string' && /^[A-Za-z0-9_]{1,48}$/.test(e.name) ? e.name : 'NonError';
|
|
26
|
+
const status = typeof e.status === 'number' && Number.isInteger(e.status) && e.status >= 100 && e.status <= 599 ? ` status=${e.status}` : '';
|
|
27
|
+
const closed = closedCodeForLog(e.code);
|
|
28
|
+
const code = closed ? ` code=${closed}` : '';
|
|
29
|
+
return `error=${name}${status}${code}`;
|
|
30
|
+
}
|
package/src/lib/redis.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// E:\Repos\PayEz-Next-MVP\packages\next-mvp\src\lib\redis.ts
|
|
2
2
|
import Redis, { RedisOptions } from 'ioredis';
|
|
3
|
+
import { errForLog } from './log-safe';
|
|
3
4
|
|
|
4
5
|
let client: Redis | null = null;
|
|
5
6
|
|
|
@@ -76,7 +77,7 @@ function createClient(): Redis {
|
|
|
76
77
|
// unhandled EventEmitter error, which can take the whole PROCESS down -
|
|
77
78
|
// a Redis blip becoming a dead server is worse than the spinner was.
|
|
78
79
|
instance.on('error', (err) => {
|
|
79
|
-
console.error(`[redis] PATH FAILURE ${new Date().toISOString()}: ${
|
|
80
|
+
console.error(`[redis] PATH FAILURE ${new Date().toISOString()}: ${errForLog(err)}`);
|
|
80
81
|
});
|
|
81
82
|
|
|
82
83
|
// 185121 line 3: the 16:05 event left no named trigger because ONLY 'error'
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { knownPasswordRules } from './password-rules';
|
|
18
|
+
import { errForLog } from './log-safe';
|
|
18
19
|
|
|
19
20
|
export interface IdpErrorProjection {
|
|
20
21
|
code?: string;
|
|
@@ -25,10 +26,14 @@ export interface IdpErrorProjection {
|
|
|
25
26
|
|
|
26
27
|
const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined);
|
|
27
28
|
|
|
28
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* Log an exception with a fresh correlationId and return the id for the response body.
|
|
31
|
+
* The log line carries the error's NAME and closed code/status only (errForLog), never its message, stack or cause: those can hold an
|
|
32
|
+
* email, a phone number, a token or a quoted request body (PII-in-logs, BAPert 76553 group 1). The correlationId still ties the response to the line.
|
|
33
|
+
*/
|
|
29
34
|
export function logExceptionWithCorrelation(tag: string, err: unknown): string {
|
|
30
35
|
const correlationId = globalThis.crypto.randomUUID();
|
|
31
|
-
console.error(tag, { correlationId, error: err });
|
|
36
|
+
console.error(tag, { correlationId, error: errForLog(err) });
|
|
32
37
|
return correlationId;
|
|
33
38
|
}
|
|
34
39
|
|
package/src/lib/session-store.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { getSessionPrefix, getRefreshLockPrefix, getAppSlug } from './app-slug';
|
|
|
15
15
|
import { extractKidFromToken, decodeIdpAccessToken } from '../auth/utils/token-utils';
|
|
16
16
|
import { tokenLogId } from './token-log-id';
|
|
17
17
|
import { refreshTokenIdentity } from './refresh-in-doubt';
|
|
18
|
+
import { errForLog } from './log-safe';
|
|
18
19
|
|
|
19
20
|
// Re-export SessionData for consumers
|
|
20
21
|
export type { SessionData } from '../models/SessionModel';
|
|
@@ -65,7 +66,7 @@ export async function createSession(data: SessionData): Promise<string> {
|
|
|
65
66
|
.setex(versionKey, SESSION_TTL, '1')
|
|
66
67
|
.exec();
|
|
67
68
|
} catch (error) {
|
|
68
|
-
console.error('[SESSION-STORE] Failed to create session:', error);
|
|
69
|
+
console.error('[SESSION-STORE] Failed to create session:', errForLog(error));
|
|
69
70
|
throw error;
|
|
70
71
|
}
|
|
71
72
|
|
package/src/lib/site-logger.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { getRedis } from './redis';
|
|
14
|
+
import { closedCodeForLog, errForLog } from './log-safe';
|
|
14
15
|
|
|
15
16
|
// Redis key for site logs (separate from data_logs)
|
|
16
17
|
const REDIS_SITE_LOG_KEY = 'vibe:site-logs:pending';
|
|
@@ -74,11 +75,11 @@ export async function logSiteEvent(entry: SiteLogEntry): Promise<void> {
|
|
|
74
75
|
console.log('[site-logger] Redis push success, queue length:', result);
|
|
75
76
|
redis.expire(REDIS_SITE_LOG_KEY, REDIS_LOG_TTL).catch(() => {});
|
|
76
77
|
}).catch(err => {
|
|
77
|
-
console.error('[site-logger] Redis push failed:', err
|
|
78
|
+
console.error('[site-logger] Redis push failed:', errForLog(err));
|
|
78
79
|
});
|
|
79
80
|
} catch (error) {
|
|
80
81
|
// Fail silently - logging should never break the app
|
|
81
|
-
console.error('[site-logger] Failed to log event:', error);
|
|
82
|
+
console.error('[site-logger] Failed to log event:', errForLog(error));
|
|
82
83
|
}
|
|
83
84
|
}
|
|
84
85
|
|
|
@@ -120,12 +121,14 @@ export const siteEvents = {
|
|
|
120
121
|
});
|
|
121
122
|
},
|
|
122
123
|
|
|
123
|
-
|
|
124
|
+
// BREAKING (PII-in-logs group 3b, BAPert 76553 Q2): no `email` parameter. A failed login is told apart by a CLOSED reason code (UPPER_SNAKE),
|
|
125
|
+
// never by the address that was tried; anything that is not a closed code is stored as UNSPECIFIED. A caller that still passes `email` fails to compile.
|
|
126
|
+
loginFailed: (opts: { reason: string; url?: string; user_agent?: string; ip_address?: string }) => {
|
|
124
127
|
logSiteEvent({
|
|
125
128
|
level: 'warn',
|
|
126
129
|
category: 'auth',
|
|
127
130
|
message: 'Login failed',
|
|
128
|
-
context: { reason: opts.reason
|
|
131
|
+
context: { reason: closedCodeForLog(opts.reason) ?? 'UNSPECIFIED' },
|
|
129
132
|
url: opts.url || '/auth/login',
|
|
130
133
|
user_agent: opts.user_agent,
|
|
131
134
|
ip_address: opts.ip_address,
|
|
@@ -214,12 +217,15 @@ export const siteEvents = {
|
|
|
214
217
|
});
|
|
215
218
|
},
|
|
216
219
|
|
|
217
|
-
|
|
220
|
+
// BREAKING (PII-in-logs group 3b, BAPert 76553 Q2): takes a CLOSED code, not caller text, an error string or a stack. The stored message is the fixed
|
|
221
|
+
// sentence 'Application error'; the code (UPPER_SNAKE, else UNSPECIFIED) goes in context. A caller that still passes `message`, `error` or `stack`
|
|
222
|
+
// fails to compile.
|
|
223
|
+
error: (opts: { code: string; user_id?: string | number; url?: string; user_agent?: string; ip_address?: string }) => {
|
|
218
224
|
logSiteEvent({
|
|
219
225
|
level: 'error',
|
|
220
226
|
category: 'error',
|
|
221
|
-
message:
|
|
222
|
-
context: {
|
|
227
|
+
message: 'Application error',
|
|
228
|
+
context: { code: closedCodeForLog(opts.code) ?? 'UNSPECIFIED' },
|
|
223
229
|
user_id: opts.user_id,
|
|
224
230
|
url: opts.url,
|
|
225
231
|
user_agent: opts.user_agent,
|