@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
|
@@ -31,6 +31,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
31
31
|
import { readFileSync } from 'node:fs';
|
|
32
32
|
import { fileURLToPath } from 'node:url';
|
|
33
33
|
import { dirname, join } from 'node:path';
|
|
34
|
+
import { resolvePublishTag } from './publish-health-tag.mjs';
|
|
34
35
|
|
|
35
36
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
36
37
|
const pkgPath = join(here, '..', 'package.json');
|
|
@@ -69,6 +70,14 @@ try {
|
|
|
69
70
|
const name = local.name;
|
|
70
71
|
const localVersion = local.version;
|
|
71
72
|
|
|
73
|
+
// PAY-2136: compare against the dist-tag being published (`npm publish --tag next` -> `next`), not always `latest`.
|
|
74
|
+
const tag = resolvePublishTag(process.argv.slice(2), process.env);
|
|
75
|
+
if (tag === null) {
|
|
76
|
+
console.error('[publish-health] INCONCLUSIVE: the requested dist-tag is not a plain tag name.');
|
|
77
|
+
process.exit(2);
|
|
78
|
+
}
|
|
79
|
+
const registrySpec = tag === 'latest' ? name : `${name}@${tag}`;
|
|
80
|
+
|
|
72
81
|
// Auth is reported separately: a dead credential is the ROOT of 171900, and folding
|
|
73
82
|
// it into "lagging" would hide which of the two things is actually broken.
|
|
74
83
|
let authUser = null;
|
|
@@ -105,11 +114,11 @@ const REGISTRY_RETRY_MS = 2000;
|
|
|
105
114
|
let registryVersion = null;
|
|
106
115
|
for (let attempt = 1; attempt <= REGISTRY_ATTEMPTS; attempt++) {
|
|
107
116
|
try {
|
|
108
|
-
registryVersion = run(['view',
|
|
117
|
+
registryVersion = run(['view', registrySpec, 'version', '--prefer-online']);
|
|
109
118
|
} catch (err) {
|
|
110
119
|
const msg = String(err.stderr || err.message || '');
|
|
111
120
|
if (/E404|404 Not Found/.test(msg)) {
|
|
112
|
-
console.error(`[publish-health] FAIL: ${
|
|
121
|
+
console.error(`[publish-health] FAIL: ${registrySpec} has NEVER been published to the registry.`);
|
|
113
122
|
console.error(`[publish-health] local=${localVersion} registry=<absent>`);
|
|
114
123
|
process.exit(1);
|
|
115
124
|
}
|
|
@@ -135,7 +144,7 @@ const delta = compareVersions(localVersion, registryVersion);
|
|
|
135
144
|
|
|
136
145
|
console.log(`[publish-health] package : ${name}`);
|
|
137
146
|
console.log(`[publish-health] local : ${localVersion}`);
|
|
138
|
-
console.log(`[publish-health] registry : ${registryVersion}`);
|
|
147
|
+
console.log(`[publish-health] registry : ${registryVersion} (dist-tag ${tag})`);
|
|
139
148
|
console.log(`[publish-health] npm auth : ${authUser ? `logged in as ${authUser}` : 'NOT AUTHENTICATED (npm whoami failed)'}`);
|
|
140
149
|
|
|
141
150
|
if (delta > 0) {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which dist-tag should the publish-health check compare the local version against?
|
|
3
|
+
*
|
|
4
|
+
* PAY-2136 (BAPert 71043): `npm publish --tag next` runs this check as `postpublish`, and it compared local against
|
|
5
|
+
* the `latest` tag, so a CORRECT next-tag publish printed "registry LAGS local" and exited 1 - noise nobody should
|
|
6
|
+
* learn to ignore. Resolution order: `--tag <x>` / `--tag=<x>` argument, then npm_config_tag (npm sets it inside
|
|
7
|
+
* lifecycle scripts from `--tag`), then `latest`. A tag that is not a plain word is rejected (null), never guessed.
|
|
8
|
+
*/
|
|
9
|
+
const TAG = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
10
|
+
|
|
11
|
+
export function resolvePublishTag(argv = [], env = {}) {
|
|
12
|
+
let raw;
|
|
13
|
+
for (let i = 0; i < argv.length; i++) {
|
|
14
|
+
const a = argv[i];
|
|
15
|
+
if (a === '--tag') {
|
|
16
|
+
raw = argv[i + 1];
|
|
17
|
+
break;
|
|
18
|
+
}
|
|
19
|
+
if (a.startsWith('--tag=')) {
|
|
20
|
+
raw = a.slice('--tag='.length);
|
|
21
|
+
break;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (raw === undefined) raw = env.npm_config_tag;
|
|
25
|
+
if (raw === undefined || raw === '') return 'latest';
|
|
26
|
+
return TAG.test(raw) ? raw : null;
|
|
27
|
+
}
|
|
@@ -26,6 +26,8 @@ export interface AuthContext {
|
|
|
26
26
|
twoFactorComplete: boolean;
|
|
27
27
|
};
|
|
28
28
|
circuitBreakerOpen: boolean;
|
|
29
|
+
/** PAY-2136: the request is a router prefetch. A prefetch never clears cookies (see isPrefetchRequest). */
|
|
30
|
+
isPrefetch?: boolean;
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
// Output types for the decision engine
|
|
@@ -34,10 +36,36 @@ export type AuthAction =
|
|
|
34
36
|
| { type: 'redirect'; location: string; reason: string; clearCookies?: boolean }
|
|
35
37
|
| { type: 'service_error'; reason: string };
|
|
36
38
|
|
|
39
|
+
/** PAY-2180: warn once per process, not per request. */
|
|
40
|
+
let deprecationWarned = false;
|
|
41
|
+
|
|
37
42
|
/**
|
|
38
|
-
* Main decision function - pure, testable, deterministic
|
|
43
|
+
* Main decision function - pure, testable, deterministic.
|
|
44
|
+
* PAY-2136: when `context.isPrefetch` is set, no redirect it returns asks for cookies to be cleared; the
|
|
45
|
+
* redirect itself is kept, and the next real navigation reaches the same decision and clears then.
|
|
46
|
+
*
|
|
47
|
+
* @deprecated PAY-2180: this exported engine is NOT the one the middleware runs. `createMvpMiddleware` decides with
|
|
48
|
+
* its own private engine (middleware/create-middleware.ts), so calling this answers a question nothing enforces.
|
|
49
|
+
* A sweep found no code consumer in the portal, idealvibe, idealresume or cryptaply. It will be REMOVED in the next
|
|
50
|
+
* major version, together with its re-exports from the package root and `/edge`. Use `createMvpMiddleware`.
|
|
39
51
|
*/
|
|
40
52
|
export function makeAuthDecision(context: AuthContext): AuthAction {
|
|
53
|
+
if (!deprecationWarned) {
|
|
54
|
+
deprecationWarned = true;
|
|
55
|
+
console.warn(
|
|
56
|
+
'[next-mvp] makeAuthDecision (auth/auth-decision) is deprecated (PAY-2180) and will be removed in the next major: ' +
|
|
57
|
+
'createMvpMiddleware does not use it. Use createMvpMiddleware.'
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
const action = decide(context);
|
|
61
|
+
if (context.isPrefetch && action.type === 'redirect' && action.clearCookies) {
|
|
62
|
+
const { clearCookies: _dropped, ...rest } = action;
|
|
63
|
+
return rest;
|
|
64
|
+
}
|
|
65
|
+
return action;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function decide(context: AuthContext): AuthAction {
|
|
41
69
|
const {
|
|
42
70
|
pathname,
|
|
43
71
|
isPublicRoute,
|
package/src/edge/middleware.ts
CHANGED
|
@@ -16,7 +16,7 @@ export {
|
|
|
16
16
|
get2FABypassConfig
|
|
17
17
|
} from '../auth/route-config';
|
|
18
18
|
export type { UnauthenticatedRouteConfig, ProtectedRouteConfig } from '../auth/route-config';
|
|
19
|
-
export { createMvpMiddleware } from '../middleware/create-middleware';
|
|
19
|
+
export { createMvpMiddleware, isPrefetchRequest } from '../middleware/create-middleware';
|
|
20
20
|
export type {
|
|
21
21
|
MvpMiddlewareOptions,
|
|
22
22
|
CircuitBreakerProvider,
|
package/src/index.ts
CHANGED
|
@@ -27,7 +27,7 @@ export { isUnauthenticatedRoute, configurePublicRoutes, getRouteConfig } from '.
|
|
|
27
27
|
// The ONE callback-target guard (N7 / PAY-1989): apps import this instead of
|
|
28
28
|
// copying it. Exposed from the main index AND as the subpath ./auth/safe-callback.
|
|
29
29
|
export { safeCallbackPath, isSafeCallbackPath, resolveCallbackDestination, DEFAULT_CALLBACK } from './auth/safe-callback';
|
|
30
|
-
export { createMvpMiddleware } from './middleware/create-middleware';
|
|
30
|
+
export { createMvpMiddleware, isPrefetchRequest } from './middleware/create-middleware';
|
|
31
31
|
|
|
32
32
|
// Account Components
|
|
33
33
|
export { UserAvatarMenu, MobileNavDrawer } from './components/account';
|
package/src/lib/idp-fetch.ts
CHANGED
|
@@ -3,12 +3,13 @@ import { getTokenTestAware } from './test-aware-get-token';
|
|
|
3
3
|
import { getSession } from './session-store';
|
|
4
4
|
import { internalRefresh } from './internal-api';
|
|
5
5
|
import { assertAbsoluteIdpUrl } from './idp-url-guard';
|
|
6
|
+
import { parseRetryAfterSeconds } from './retry-after';
|
|
6
7
|
|
|
7
8
|
// Re-exported so `idp-fetch` remains the single obvious import for IDP calling
|
|
8
9
|
// concerns; the implementation lives in a dependency-free leaf so the three
|
|
9
10
|
// direct-fetch callers (auth/jwks.ts, auth/passkey-signin.ts x2) do not have to
|
|
10
11
|
// pull this module's server-only chain in for one regex.
|
|
11
|
-
export { assertAbsoluteIdpUrl };
|
|
12
|
+
export { assertAbsoluteIdpUrl, parseRetryAfterSeconds };
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Centralized IDP fetch helper
|
|
@@ -21,6 +22,12 @@ export interface IdpFetchResult<T = any> {
|
|
|
21
22
|
status: number;
|
|
22
23
|
json: T | null;
|
|
23
24
|
attemptedRefresh: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* PAY-1608: the upstream Retry-After in whole seconds, when the IDP sent a usable one. Core sends it on every
|
|
27
|
+
* 2FA lock (MfaController.SecondFactorLocked: 429 + Retry-After 900; VerifyTotp: what is left of the lock).
|
|
28
|
+
* It used to be dropped here, so no BFF could state the real lock window. Absent otherwise.
|
|
29
|
+
*/
|
|
30
|
+
retryAfterSeconds?: number;
|
|
24
31
|
}
|
|
25
32
|
|
|
26
33
|
function buildHeaders(req: NextRequest, bearer?: string, extra?: Record<string, string>) {
|
|
@@ -158,5 +165,6 @@ export async function idpFetchJSON<T = any>(
|
|
|
158
165
|
} catch {}
|
|
159
166
|
}
|
|
160
167
|
|
|
161
|
-
|
|
168
|
+
const retryAfterSeconds = parseRetryAfterSeconds(res.headers?.get?.('Retry-After'));
|
|
169
|
+
return { ok: res.ok, status: res.status, json: json as T | null, attemptedRefresh, ...(retryAfterSeconds ? { retryAfterSeconds } : {}) };
|
|
162
170
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PAY-1608: parse an HTTP Retry-After header to whole seconds.
|
|
3
|
+
*
|
|
4
|
+
* Accepts delta-seconds ("900") and an HTTP date in the future. Answers undefined for absent, zero, negative,
|
|
5
|
+
* fractional, past or malformed values - a caller that gets undefined states no window rather than a guessed one.
|
|
6
|
+
*
|
|
7
|
+
* A dependency-free leaf on purpose: idp-fetch re-exports it, but a test can load it without the session/redis
|
|
8
|
+
* graph idp-fetch pulls in.
|
|
9
|
+
*/
|
|
10
|
+
export function parseRetryAfterSeconds(value: string | null | undefined, nowMs: number = Date.now()): number | undefined {
|
|
11
|
+
if (typeof value !== 'string') return undefined;
|
|
12
|
+
const v = value.trim();
|
|
13
|
+
if (/^\d+$/.test(v)) {
|
|
14
|
+
const n = parseInt(v, 10);
|
|
15
|
+
return n > 0 ? n : undefined;
|
|
16
|
+
}
|
|
17
|
+
// Only something that looks like an HTTP date (it has letters: "Sat, 10 Oct 2026 12:01:30 GMT") is parsed as
|
|
18
|
+
// one; "-5" and "1.5" must not reach Date.parse, which would read them as years.
|
|
19
|
+
if (!/[a-z]/i.test(v)) return undefined;
|
|
20
|
+
const at = Date.parse(v);
|
|
21
|
+
if (!Number.isFinite(at)) return undefined;
|
|
22
|
+
const secs = Math.ceil((at - nowMs) / 1000);
|
|
23
|
+
return secs > 0 ? secs : undefined;
|
|
24
|
+
}
|
package/src/lib/session-store.ts
CHANGED
|
@@ -1048,3 +1048,45 @@ export async function cleanupRefreshLocks(): Promise<number> {
|
|
|
1048
1048
|
console.warn('[SESSION-STORE] cleanupRefreshLocks called - scanning for expired locks');
|
|
1049
1049
|
return 0;
|
|
1050
1050
|
}
|
|
1051
|
+
|
|
1052
|
+
/**
|
|
1053
|
+
* PAY-2136 DIAGNOSTIC (BAPert 70873): which of the session keys EXIST for a token, right now.
|
|
1054
|
+
*
|
|
1055
|
+
* Names/presence only - this returns booleans and the key PREFIX (an app slug, not a secret), never a value and
|
|
1056
|
+
* never any part of the token. It exists so a NO_SESSION can be read for what it was: canonical row gone with the
|
|
1057
|
+
* ba: record alive (the :330 asymmetry), both gone (expired/deleted), or the store could not be asked ('error').
|
|
1058
|
+
*
|
|
1059
|
+
* EXISTS, not GET: it cannot parse, cannot throw on a malformed blob, and cannot return a credential. Each key is
|
|
1060
|
+
* asked separately so one timeout does not hide the other two. Never throws.
|
|
1061
|
+
*/
|
|
1062
|
+
export type KeyPresence = boolean | 'error';
|
|
1063
|
+
export async function describeSessionKeyPresence(
|
|
1064
|
+
sessionToken: string
|
|
1065
|
+
): Promise<{ prefix: string; canonical: KeyPresence; ba: KeyPresence; ver: KeyPresence }> {
|
|
1066
|
+
const ask = async (key: string): Promise<KeyPresence> => {
|
|
1067
|
+
try {
|
|
1068
|
+
return (await redis.exists(key)) > 0;
|
|
1069
|
+
} catch {
|
|
1070
|
+
return 'error';
|
|
1071
|
+
}
|
|
1072
|
+
};
|
|
1073
|
+
let prefix = '';
|
|
1074
|
+
try {
|
|
1075
|
+
prefix = getSessionPrefix();
|
|
1076
|
+
} catch {
|
|
1077
|
+
prefix = '';
|
|
1078
|
+
}
|
|
1079
|
+
if (!sessionToken) return { prefix, canonical: false, ba: false, ver: false };
|
|
1080
|
+
let canonicalKey = '';
|
|
1081
|
+
let baKey = '';
|
|
1082
|
+
let verKey = '';
|
|
1083
|
+
try {
|
|
1084
|
+
canonicalKey = getSessionKey(sessionToken);
|
|
1085
|
+
baKey = getBetterAuthSessionKey(sessionToken, getAppSlug());
|
|
1086
|
+
verKey = getSessionVersionKey(sessionToken);
|
|
1087
|
+
} catch {
|
|
1088
|
+
return { prefix, canonical: 'error', ba: 'error', ver: 'error' };
|
|
1089
|
+
}
|
|
1090
|
+
const [canonical, ba, ver] = await Promise.all([ask(canonicalKey), ask(baKey), ask(verKey)]);
|
|
1091
|
+
return { prefix, canonical, ba, ver };
|
|
1092
|
+
}
|