@payez/next-mvp 4.8.6 → 4.8.8
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/README.md +1 -1
- package/dist/api-handlers/onboarding/send-email-code.js +1 -1
- package/dist/api-handlers/onboarding/set-password.js +1 -1
- package/dist/api-handlers/onboarding/verify-code.js +1 -1
- package/dist/api-handlers/onboarding/verify-welcome-email.js +1 -1
- package/dist/lib/onboarding-code-client.d.ts +7 -0
- package/dist/lib/onboarding-code-client.js +13 -0
- package/dist/lib/onboarding-proxy.d.ts +2 -2
- package/dist/lib/onboarding-proxy.js +10 -8
- package/dist/lib/require-idp-url.d.ts +12 -0
- package/dist/lib/require-idp-url.js +26 -0
- package/dist/pages/onboarding/page.js +5 -7
- package/dist/routes/account/masked-info.d.ts +1 -1
- package/dist/routes/account/masked-info.js +1 -1
- package/dist/routes/account/send-code.d.ts +1 -1
- package/dist/routes/account/send-code.js +1 -1
- package/dist/routes/account/verify-email.d.ts +1 -1
- package/dist/routes/account/verify-email.js +1 -1
- package/dist/routes/account/verify-sms.d.ts +1 -1
- package/dist/routes/account/verify-sms.js +1 -1
- package/package.json +6 -2
- package/scripts/check-publish-health.mjs +65 -60
- package/scripts/publish-health-poll.mjs +83 -0
- package/src/api-handlers/onboarding/send-email-code.ts +1 -1
- package/src/api-handlers/onboarding/set-password.ts +1 -1
- package/src/api-handlers/onboarding/verify-code.ts +1 -1
- package/src/api-handlers/onboarding/verify-welcome-email.ts +1 -1
- package/src/lib/onboarding-code-client.ts +15 -0
- package/src/lib/onboarding-proxy.ts +14 -7
- package/src/lib/require-idp-url.ts +27 -0
- package/src/pages/onboarding/page.tsx +6 -9
- package/src/routes/account/masked-info.ts +1 -1
- package/src/routes/account/send-code.ts +1 -1
- package/src/routes/account/verify-email.ts +1 -1
- package/src/routes/account/verify-sms.ts +1 -1
package/README.md
CHANGED
|
@@ -548,7 +548,7 @@ import { POST as logout } from '@payez/next-mvp/routes/auth/logout';
|
|
|
548
548
|
| Variable | Required | Description |
|
|
549
549
|
|----------|----------|-------------|
|
|
550
550
|
| `CLIENT_ID` | ✅ Yes | Your PayEz IDP client ID (string slug, e.g., `payez_idp_admin_web`) |
|
|
551
|
-
| `IDP_URL` | ✅ Yes | PayEz IDP base URL (e.g., `https://idp.payez.net` or `http://localhost:32785`) |
|
|
551
|
+
| `IDP_URL` | ✅ Yes | PayEz IDP base URL (e.g., `https://idp.payez.net` or `http://localhost:32785`). **No default in code.** Call `requireIdpUrl()` from `@payez/next-mvp/lib/require-idp-url` in your `instrumentation.ts` (4.8.8) and the server refuses to start, with one fixed sentence, when it is missing or not a full http(s) address. The quickstart generator writes the production address into `.env.local` and adds that file for you. |
|
|
552
552
|
| `AUTH_TRUST_HOST` | ✅ Yes | Must be `true` - NextAuth derives OAuth URLs from request headers |
|
|
553
553
|
| `NEXTAUTH_SECRET` | 🔄 Broker | **Do NOT set** - fetched automatically from IDP at startup (see below) |
|
|
554
554
|
| `CLIENT_SECRET` | ⚠️ Legacy | Not needed with broker mode - IDP handles signing |
|
|
@@ -7,5 +7,5 @@ async function POST(req) {
|
|
|
7
7
|
const onboardToken = body?.onboard_token;
|
|
8
8
|
if (!onboardToken)
|
|
9
9
|
return (0, onboarding_proxy_1.onboardingBadRequest)('MISSING_TOKEN', 'onboard_token is required');
|
|
10
|
-
return (0, onboarding_proxy_1.onboardingProxy)(req, '/api/Onboarding/send-email-code');
|
|
10
|
+
return (0, onboarding_proxy_1.onboardingProxy)(req, '/api/Onboarding/send-email-code', body);
|
|
11
11
|
}
|
|
@@ -11,5 +11,5 @@ async function POST(req) {
|
|
|
11
11
|
return (0, onboarding_proxy_1.onboardingBadRequest)('MISSING_RESET_CONTEXT', 'email and reset_token (from verify-code) are required');
|
|
12
12
|
if (!b?.new_password)
|
|
13
13
|
return (0, onboarding_proxy_1.onboardingBadRequest)('MISSING_PASSWORD', 'new_password is required');
|
|
14
|
-
return (0, onboarding_proxy_1.onboardingProxy)(req, '/api/Onboarding/set-password');
|
|
14
|
+
return (0, onboarding_proxy_1.onboardingProxy)(req, '/api/Onboarding/set-password', body);
|
|
15
15
|
}
|
|
@@ -10,5 +10,5 @@ async function POST(req) {
|
|
|
10
10
|
return (0, onboarding_proxy_1.onboardingBadRequest)('MISSING_TOKEN', 'onboard_token is required');
|
|
11
11
|
if (!code)
|
|
12
12
|
return (0, onboarding_proxy_1.onboardingBadRequest)('MISSING_CODE', 'code is required');
|
|
13
|
-
return (0, onboarding_proxy_1.onboardingProxy)(req, '/api/Onboarding/verify-code');
|
|
13
|
+
return (0, onboarding_proxy_1.onboardingProxy)(req, '/api/Onboarding/verify-code', body);
|
|
14
14
|
}
|
|
@@ -7,5 +7,5 @@ async function POST(req) {
|
|
|
7
7
|
const onboardToken = body?.onboard_token;
|
|
8
8
|
if (!onboardToken)
|
|
9
9
|
return (0, onboarding_proxy_1.onboardingBadRequest)('MISSING_TOKEN', 'onboard_token is required');
|
|
10
|
-
return (0, onboarding_proxy_1.onboardingProxy)(req, '/api/Onboarding/verify-welcome-email');
|
|
10
|
+
return (0, onboarding_proxy_1.onboardingProxy)(req, '/api/Onboarding/verify-welcome-email', body);
|
|
11
11
|
}
|
|
@@ -8,3 +8,10 @@ export type OnboardingCodeResult = {
|
|
|
8
8
|
devCode: string | null;
|
|
9
9
|
};
|
|
10
10
|
export declare function requestOnboardingCode(onboardToken: string, fetchImpl?: typeof fetch): Promise<OnboardingCodeResult>;
|
|
11
|
+
/** Where the code step stands: the page has asked the IdP to email a code ('sending'), the IdP said yes ('sent') or no ('failed'). */
|
|
12
|
+
export type CodeState = 'sending' | 'sent' | 'failed';
|
|
13
|
+
/**
|
|
14
|
+
* 4.8.7 (QAPert 75864): the code step used to say "We emailed you a 6-digit code" before anything was sent, and kept saying it when
|
|
15
|
+
* the send failed. It now says a code was emailed ONLY when the IdP answered ok.
|
|
16
|
+
*/
|
|
17
|
+
export declare function codeStepSentence(state: CodeState, maskedEmail: string | null): string;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.requestOnboardingCode = requestOnboardingCode;
|
|
4
|
+
exports.codeStepSentence = codeStepSentence;
|
|
4
5
|
function pick(j) {
|
|
5
6
|
const o = j;
|
|
6
7
|
return o?.dev_verification_code ?? o?.data?.dev_verification_code ?? o?.data?.result?.dev_verification_code ?? o?.result?.dev_verification_code;
|
|
@@ -20,3 +21,15 @@ async function requestOnboardingCode(onboardToken, fetchImpl = fetch) {
|
|
|
20
21
|
return { ok: false, devCode: null };
|
|
21
22
|
}
|
|
22
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* 4.8.7 (QAPert 75864): the code step used to say "We emailed you a 6-digit code" before anything was sent, and kept saying it when
|
|
26
|
+
* the send failed. It now says a code was emailed ONLY when the IdP answered ok.
|
|
27
|
+
*/
|
|
28
|
+
function codeStepSentence(state, maskedEmail) {
|
|
29
|
+
if (state === 'sent') {
|
|
30
|
+
return maskedEmail ? `We emailed a 6-digit code to ${maskedEmail}. Enter it below.` : 'We emailed you a 6-digit code. Enter it below.';
|
|
31
|
+
}
|
|
32
|
+
if (state === 'sending')
|
|
33
|
+
return 'Sending you a 6-digit code…';
|
|
34
|
+
return 'We could not send a code. Choose "Send a new code" to try again.';
|
|
35
|
+
}
|
|
@@ -21,10 +21,10 @@ export declare function onboardingBadRequest(code: string, message: string): Nex
|
|
|
21
21
|
};
|
|
22
22
|
}>;
|
|
23
23
|
/**
|
|
24
|
-
* POST
|
|
24
|
+
* POST `body` (the handler's ALREADY-PARSED JSON) to `{IDP_URL}{idpPath}` with no credentials, and relay the
|
|
25
25
|
* IdP's status and body verbatim (the page acts on what the IdP actually
|
|
26
26
|
* returns, not on a shape this layer guesses). Never relays the IdP error TEXT
|
|
27
27
|
* for a non-2xx is NOT done here: the onboarding endpoints answer structured
|
|
28
28
|
* envelopes the page reads; the page owns the copy.
|
|
29
29
|
*/
|
|
30
|
-
export declare function onboardingProxy(req: NextRequest, idpPath: string): Promise<NextResponse>;
|
|
30
|
+
export declare function onboardingProxy(req: NextRequest, idpPath: string, body: unknown): Promise<NextResponse>;
|
|
@@ -23,26 +23,28 @@ function onboardingBadRequest(code, message) {
|
|
|
23
23
|
return server_1.NextResponse.json({ success: false, message, error: { code } }, { status: 400 });
|
|
24
24
|
}
|
|
25
25
|
/**
|
|
26
|
-
* POST
|
|
26
|
+
* POST `body` (the handler's ALREADY-PARSED JSON) to `{IDP_URL}{idpPath}` with no credentials, and relay the
|
|
27
27
|
* IdP's status and body verbatim (the page acts on what the IdP actually
|
|
28
28
|
* returns, not on a shape this layer guesses). Never relays the IdP error TEXT
|
|
29
29
|
* for a non-2xx is NOT done here: the onboarding endpoints answer structured
|
|
30
30
|
* envelopes the page reads; the page owns the copy.
|
|
31
31
|
*/
|
|
32
|
-
async function onboardingProxy(req, idpPath) {
|
|
32
|
+
async function onboardingProxy(req, idpPath, body) {
|
|
33
33
|
const idpBase = env_1.ENV_CONFIG.IDP_URL;
|
|
34
34
|
if (!idpBase) {
|
|
35
35
|
return server_1.NextResponse.json({ success: false, message: 'IDP_URL is not configured', error: { code: 'NO_IDP_URL' } }, { status: 500 });
|
|
36
36
|
}
|
|
37
37
|
const url = `${idpBase}${idpPath}`;
|
|
38
38
|
(0, idp_url_guard_1.assertAbsoluteIdpUrl)(url);
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
39
|
+
// 4.8.7: the handler has ALREADY read the request body to check its fields, and a Request body is single-read. The proxy used to
|
|
40
|
+
// call req.text() again, which threw "Body has already been read", swallowed it, and POSTed '{}' to the IdP: every onboarding
|
|
41
|
+
// step from a generated app (4.5.0 - 4.8.6) reached Core with no token. The parsed body is passed in; the request is only read
|
|
42
|
+
// for its forwarding headers.
|
|
43
|
+
// A caller that did not pass the parsed body is a bug in this package, and it must be LOUD: it used to become '{}' at the IdP.
|
|
44
|
+
if (body === null || typeof body !== 'object' || Array.isArray(body)) {
|
|
45
|
+
return server_1.NextResponse.json({ success: false, message: 'The onboarding request body was not forwarded.', error: { code: 'BODY_NOT_FORWARDED' } }, { status: 500 });
|
|
44
46
|
}
|
|
45
|
-
|
|
47
|
+
const bodyText = JSON.stringify(body);
|
|
46
48
|
const headers = { 'Content-Type': 'application/json', Accept: 'application/json' };
|
|
47
49
|
const xfwd = req.headers.get('x-forwarded-for') || req.headers.get('x-real-ip');
|
|
48
50
|
if (xfwd)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 4.8.8 (item 24, Jon: "fast and loud are how i like my config errors"): the app REQUIRES IDP_URL and says so at START.
|
|
3
|
+
*
|
|
4
|
+
* There is no default in code. The quickstart generator writes the production sign-in service address into the app's .env.local
|
|
5
|
+
* at generation time; rigs override that file. When the value is missing, empty or not an absolute http(s) address, the server
|
|
6
|
+
* refuses to come up - printed once, in one fixed sentence, exit code 1 - instead of booting and failing on the first sign-in.
|
|
7
|
+
*
|
|
8
|
+
* A leaf module: no imports, so a generated `instrumentation.ts` can load it without pulling the session store or the logger.
|
|
9
|
+
*/
|
|
10
|
+
export declare const IDP_URL_REQUIRED_SENTENCE = "IDP_URL is not set to a full http(s) address. This app has no default sign-in service: set IDP_URL in .env.local and start again.";
|
|
11
|
+
/** True when IDP_URL is present and absolute. Otherwise logs the fixed sentence and calls `exit(1)`; returns false if `exit` returns. */
|
|
12
|
+
export declare function requireIdpUrl(env?: Record<string, string | undefined>, exit?: (code: number) => unknown, log?: (line: string) => void): boolean;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.IDP_URL_REQUIRED_SENTENCE = void 0;
|
|
4
|
+
exports.requireIdpUrl = requireIdpUrl;
|
|
5
|
+
/**
|
|
6
|
+
* 4.8.8 (item 24, Jon: "fast and loud are how i like my config errors"): the app REQUIRES IDP_URL and says so at START.
|
|
7
|
+
*
|
|
8
|
+
* There is no default in code. The quickstart generator writes the production sign-in service address into the app's .env.local
|
|
9
|
+
* at generation time; rigs override that file. When the value is missing, empty or not an absolute http(s) address, the server
|
|
10
|
+
* refuses to come up - printed once, in one fixed sentence, exit code 1 - instead of booting and failing on the first sign-in.
|
|
11
|
+
*
|
|
12
|
+
* A leaf module: no imports, so a generated `instrumentation.ts` can load it without pulling the session store or the logger.
|
|
13
|
+
*/
|
|
14
|
+
exports.IDP_URL_REQUIRED_SENTENCE = 'IDP_URL is not set to a full http(s) address. This app has no default sign-in service: set IDP_URL in .env.local and start again.';
|
|
15
|
+
/** True when IDP_URL is present and absolute. Otherwise logs the fixed sentence and calls `exit(1)`; returns false if `exit` returns. */
|
|
16
|
+
function requireIdpUrl(env = process.env, exit = (code) => process.exit(code), log = (line) => console.error(line)) {
|
|
17
|
+
const value = (env.IDP_URL ?? '').trim();
|
|
18
|
+
if (/^https?:\/\/[^/\s]+/i.test(value))
|
|
19
|
+
return true;
|
|
20
|
+
log('');
|
|
21
|
+
log('[STARTUP FAILED - REFUSING TO SERVE]');
|
|
22
|
+
log(` ${exports.IDP_URL_REQUIRED_SENTENCE}`);
|
|
23
|
+
log('');
|
|
24
|
+
exit(1);
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
@@ -21,21 +21,21 @@ function OnboardingContent() {
|
|
|
21
21
|
const [busy, setBusy] = (0, react_1.useState)(false);
|
|
22
22
|
const [error, setError] = (0, react_1.useState)(null);
|
|
23
23
|
const [devCode, setDevCode] = (0, react_1.useState)(null);
|
|
24
|
-
const [
|
|
24
|
+
const [codeState, setCodeState] = (0, react_1.useState)('sending');
|
|
25
25
|
const sentFor = (0, react_1.useRef)(null);
|
|
26
26
|
// 4.8.6: the page asks for the code itself, once per token (nothing else ever sends it). The dev echo, if the IdP gave one in
|
|
27
27
|
// this answer, is shown under the code box; nothing else can make it appear.
|
|
28
28
|
async function sendCode() {
|
|
29
|
-
|
|
29
|
+
setCodeState('sending');
|
|
30
30
|
const r = await (0, onboarding_code_client_1.requestOnboardingCode)(onboardToken);
|
|
31
31
|
setDevCode(r.devCode);
|
|
32
|
-
|
|
32
|
+
setCodeState(r.ok ? 'sent' : 'failed');
|
|
33
33
|
}
|
|
34
34
|
(0, react_1.useEffect)(() => {
|
|
35
35
|
if (!onboardToken || sentFor.current === onboardToken)
|
|
36
36
|
return;
|
|
37
37
|
sentFor.current = onboardToken;
|
|
38
|
-
void (0, onboarding_code_client_1.requestOnboardingCode)(onboardToken).then((r) => setDevCode(r.devCode));
|
|
38
|
+
void (0, onboarding_code_client_1.requestOnboardingCode)(onboardToken).then((r) => { setDevCode(r.devCode); setCodeState(r.ok ? 'sent' : 'failed'); });
|
|
39
39
|
}, [onboardToken]);
|
|
40
40
|
// Step 1: tell the invitee which address the code went to (masked). Best
|
|
41
41
|
// effort - a failure here does not block entering a code they were emailed.
|
|
@@ -114,9 +114,7 @@ function OnboardingContent() {
|
|
|
114
114
|
setBusy(false);
|
|
115
115
|
}
|
|
116
116
|
}
|
|
117
|
-
return ((0, jsx_runtime_1.jsxs)(Shell, { children: [step === 'code' && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("h1", { style: titleStyle, children: "Finish setting up your account" }), (0, jsx_runtime_1.jsx)("p", { style: { color: H.secondary, fontSize: 14, marginTop: 8 }, children: maskedEmail
|
|
118
|
-
? `We emailed a 6-digit code to ${maskedEmail}. Enter it below.`
|
|
119
|
-
: 'We emailed you a 6-digit code. Enter it below.' }), (0, jsx_runtime_1.jsxs)("form", { onSubmit: submitCode, noValidate: true, style: { marginTop: 16 }, children: [(0, jsx_runtime_1.jsx)("input", { value: digits, onChange: (e) => setDigits(e.target.value.replace(/\D/g, '').slice(0, 6)), inputMode: "numeric", autoComplete: "one-time-code", "aria-label": "6-digit code", placeholder: "000000", style: { ...inputStyle, letterSpacing: 6, textAlign: 'center', fontFamily: 'monospace', fontSize: 20 } }), (0, jsx_runtime_1.jsx)("button", { type: "submit", disabled: busy || digits.length < 6, style: ctaStyle(busy || digits.length < 6), children: busy ? 'Verifying…' : 'Verify' })] }), (0, jsx_runtime_1.jsx)(DevCodeNotice_1.DevCodeNotice, { code: devCode }), (0, jsx_runtime_1.jsxs)("p", { style: { marginTop: 12, fontSize: 13 }, children: [(0, jsx_runtime_1.jsx)("button", { type: "button", onClick: () => void sendCode(), "data-testid": "resend-code", style: { background: 'none', border: 'none', padding: 0, color: H.primary, fontWeight: 600, textDecoration: 'underline', cursor: 'pointer' }, children: "Send a new code" }), codeNote && (0, jsx_runtime_1.jsx)("span", { role: "status", style: { marginLeft: 8, color: H.secondary }, children: codeNote })] })] })), step === 'password' && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("h1", { style: titleStyle, children: "Choose a password" }), (0, jsx_runtime_1.jsx)("p", { style: { color: H.secondary, fontSize: 14, marginTop: 8 }, children: "Set the password you will sign in with." }), (0, jsx_runtime_1.jsxs)("form", { onSubmit: submitPassword, noValidate: true, style: { marginTop: 16 }, children: [(0, jsx_runtime_1.jsx)("input", { type: "password", value: password, onChange: (e) => setPassword(e.target.value), "aria-label": "New password", placeholder: "New password", style: inputStyle }), (0, jsx_runtime_1.jsx)("input", { type: "password", value: confirm, onChange: (e) => setConfirm(e.target.value), "aria-label": "Confirm password", placeholder: "Confirm password", style: { ...inputStyle, marginTop: 10 } }), (0, jsx_runtime_1.jsx)("button", { type: "submit", disabled: busy || !password || !confirm, style: ctaStyle(busy || !password || !confirm), children: busy ? 'Saving…' : 'Set password' })] })] })), step === 'done' && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("h1", { style: titleStyle, children: "You're all set." }), (0, jsx_runtime_1.jsx)("p", { style: { color: H.secondary, fontSize: 14, marginTop: 8 }, children: "Your account is ready. You can sign in now." }), (0, jsx_runtime_1.jsx)("p", { style: { marginTop: 16 }, children: (0, jsx_runtime_1.jsx)("a", { href: "/account-auth/login", style: linkStyle, children: "Go to sign in" }) })] })), error && (0, jsx_runtime_1.jsx)("p", { style: { color: '#b00020', fontSize: 13, marginTop: 12 }, children: error })] }));
|
|
117
|
+
return ((0, jsx_runtime_1.jsxs)(Shell, { children: [step === 'code' && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("h1", { style: titleStyle, children: "Finish setting up your account" }), (0, jsx_runtime_1.jsx)("p", { style: { color: H.secondary, fontSize: 14, marginTop: 8 }, children: (0, onboarding_code_client_1.codeStepSentence)(codeState, maskedEmail) }), (0, jsx_runtime_1.jsxs)("form", { onSubmit: submitCode, noValidate: true, style: { marginTop: 16 }, children: [(0, jsx_runtime_1.jsx)("input", { value: digits, onChange: (e) => setDigits(e.target.value.replace(/\D/g, '').slice(0, 6)), inputMode: "numeric", autoComplete: "one-time-code", "aria-label": "6-digit code", placeholder: "000000", style: { ...inputStyle, letterSpacing: 6, textAlign: 'center', fontFamily: 'monospace', fontSize: 20 } }), (0, jsx_runtime_1.jsx)("button", { type: "submit", disabled: busy || digits.length < 6, style: ctaStyle(busy || digits.length < 6), children: busy ? 'Verifying…' : 'Verify' })] }), (0, jsx_runtime_1.jsx)(DevCodeNotice_1.DevCodeNotice, { code: devCode }), (0, jsx_runtime_1.jsx)("p", { style: { marginTop: 12, fontSize: 13 }, children: (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: () => void sendCode(), "data-testid": "resend-code", style: { background: 'none', border: 'none', padding: 0, color: H.primary, fontWeight: 600, textDecoration: 'underline', cursor: 'pointer' }, children: "Send a new code" }) })] })), step === 'password' && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("h1", { style: titleStyle, children: "Choose a password" }), (0, jsx_runtime_1.jsx)("p", { style: { color: H.secondary, fontSize: 14, marginTop: 8 }, children: "Set the password you will sign in with." }), (0, jsx_runtime_1.jsxs)("form", { onSubmit: submitPassword, noValidate: true, style: { marginTop: 16 }, children: [(0, jsx_runtime_1.jsx)("input", { type: "password", value: password, onChange: (e) => setPassword(e.target.value), "aria-label": "New password", placeholder: "New password", style: inputStyle }), (0, jsx_runtime_1.jsx)("input", { type: "password", value: confirm, onChange: (e) => setConfirm(e.target.value), "aria-label": "Confirm password", placeholder: "Confirm password", style: { ...inputStyle, marginTop: 10 } }), (0, jsx_runtime_1.jsx)("button", { type: "submit", disabled: busy || !password || !confirm, style: ctaStyle(busy || !password || !confirm), children: busy ? 'Saving…' : 'Set password' })] })] })), step === 'done' && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("h1", { style: titleStyle, children: "You're all set." }), (0, jsx_runtime_1.jsx)("p", { style: { color: H.secondary, fontSize: 14, marginTop: 8 }, children: "Your account is ready. You can sign in now." }), (0, jsx_runtime_1.jsx)("p", { style: { marginTop: 16 }, children: (0, jsx_runtime_1.jsx)("a", { href: "/account-auth/login", style: linkStyle, children: "Go to sign in" }) })] })), error && (0, jsx_runtime_1.jsx)("p", { style: { color: '#b00020', fontSize: 13, marginTop: 12 }, children: error })] }));
|
|
120
118
|
}
|
|
121
119
|
const titleStyle = { color: H.primary, fontSize: 20, fontWeight: 600, margin: 0 };
|
|
122
120
|
const linkStyle = { color: H.primary, fontWeight: 600, textDecoration: 'underline' };
|
|
@@ -22,7 +22,7 @@ export { POST } from '../../api-handlers/account/masked-info';
|
|
|
22
22
|
* email/phone options to the user.
|
|
23
23
|
*
|
|
24
24
|
* Environment variables used:
|
|
25
|
-
* - IDP_URL or NEXT_PUBLIC_IDP_URL (
|
|
25
|
+
* - IDP_URL or NEXT_PUBLIC_IDP_URL (REQUIRED: there is no default in code)
|
|
26
26
|
* - CLIENT_ID or NEXT_PUBLIC_IDP_CLIENT_ID (required)
|
|
27
27
|
* - BETTER_AUTH_SECRET (required — fetched from IDP at startup)
|
|
28
28
|
*
|
|
@@ -28,7 +28,7 @@ Object.defineProperty(exports, "POST", { enumerable: true, get: function () { re
|
|
|
28
28
|
* email/phone options to the user.
|
|
29
29
|
*
|
|
30
30
|
* Environment variables used:
|
|
31
|
-
* - IDP_URL or NEXT_PUBLIC_IDP_URL (
|
|
31
|
+
* - IDP_URL or NEXT_PUBLIC_IDP_URL (REQUIRED: there is no default in code)
|
|
32
32
|
* - CLIENT_ID or NEXT_PUBLIC_IDP_CLIENT_ID (required)
|
|
33
33
|
* - BETTER_AUTH_SECRET (required — fetched from IDP at startup)
|
|
34
34
|
*
|
|
@@ -26,7 +26,7 @@ export { POST } from '../../api-handlers/account/send-code';
|
|
|
26
26
|
* - contactId: string - ID of the masked contact to send to
|
|
27
27
|
*
|
|
28
28
|
* Environment variables used:
|
|
29
|
-
* - IDP_URL or NEXT_PUBLIC_IDP_URL (
|
|
29
|
+
* - IDP_URL or NEXT_PUBLIC_IDP_URL (REQUIRED: there is no default in code)
|
|
30
30
|
* - CLIENT_ID or NEXT_PUBLIC_IDP_CLIENT_ID (required)
|
|
31
31
|
* - BETTER_AUTH_SECRET (required — fetched from IDP at startup)
|
|
32
32
|
*
|
|
@@ -31,7 +31,7 @@ Object.defineProperty(exports, "POST", { enumerable: true, get: function () { re
|
|
|
31
31
|
* - contactId: string - ID of the masked contact to send to
|
|
32
32
|
*
|
|
33
33
|
* Environment variables used:
|
|
34
|
-
* - IDP_URL or NEXT_PUBLIC_IDP_URL (
|
|
34
|
+
* - IDP_URL or NEXT_PUBLIC_IDP_URL (REQUIRED: there is no default in code)
|
|
35
35
|
* - CLIENT_ID or NEXT_PUBLIC_IDP_CLIENT_ID (required)
|
|
36
36
|
* - BETTER_AUTH_SECRET (required — fetched from IDP at startup)
|
|
37
37
|
*
|
|
@@ -25,7 +25,7 @@ export { POST } from '../../api-handlers/account/verify-email';
|
|
|
25
25
|
* - emailId: string - ID of the email address used
|
|
26
26
|
*
|
|
27
27
|
* Environment variables used:
|
|
28
|
-
* - IDP_URL or NEXT_PUBLIC_IDP_URL (
|
|
28
|
+
* - IDP_URL or NEXT_PUBLIC_IDP_URL (REQUIRED: there is no default in code)
|
|
29
29
|
* - CLIENT_ID or NEXT_PUBLIC_IDP_CLIENT_ID (required)
|
|
30
30
|
* - BETTER_AUTH_SECRET (required — fetched from IDP at startup)
|
|
31
31
|
*
|
|
@@ -30,7 +30,7 @@ Object.defineProperty(exports, "POST", { enumerable: true, get: function () { re
|
|
|
30
30
|
* - emailId: string - ID of the email address used
|
|
31
31
|
*
|
|
32
32
|
* Environment variables used:
|
|
33
|
-
* - IDP_URL or NEXT_PUBLIC_IDP_URL (
|
|
33
|
+
* - IDP_URL or NEXT_PUBLIC_IDP_URL (REQUIRED: there is no default in code)
|
|
34
34
|
* - CLIENT_ID or NEXT_PUBLIC_IDP_CLIENT_ID (required)
|
|
35
35
|
* - BETTER_AUTH_SECRET (required — fetched from IDP at startup)
|
|
36
36
|
*
|
|
@@ -25,7 +25,7 @@ export { POST } from '../../api-handlers/account/verify-sms';
|
|
|
25
25
|
* - phoneId: string - ID of the phone number used
|
|
26
26
|
*
|
|
27
27
|
* Environment variables used:
|
|
28
|
-
* - IDP_URL or NEXT_PUBLIC_IDP_URL (
|
|
28
|
+
* - IDP_URL or NEXT_PUBLIC_IDP_URL (REQUIRED: there is no default in code)
|
|
29
29
|
* - CLIENT_ID or NEXT_PUBLIC_IDP_CLIENT_ID (required)
|
|
30
30
|
* - BETTER_AUTH_SECRET (required — fetched from IDP at startup)
|
|
31
31
|
*
|
|
@@ -30,7 +30,7 @@ Object.defineProperty(exports, "POST", { enumerable: true, get: function () { re
|
|
|
30
30
|
* - phoneId: string - ID of the phone number used
|
|
31
31
|
*
|
|
32
32
|
* Environment variables used:
|
|
33
|
-
* - IDP_URL or NEXT_PUBLIC_IDP_URL (
|
|
33
|
+
* - IDP_URL or NEXT_PUBLIC_IDP_URL (REQUIRED: there is no default in code)
|
|
34
34
|
* - CLIENT_ID or NEXT_PUBLIC_IDP_CLIENT_ID (required)
|
|
35
35
|
* - BETTER_AUTH_SECRET (required — fetched from IDP at startup)
|
|
36
36
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@payez/next-mvp",
|
|
3
|
-
"version": "4.8.
|
|
3
|
+
"version": "4.8.8",
|
|
4
4
|
"sideEffects": false,
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -19,7 +19,6 @@
|
|
|
19
19
|
"prepack": "npm run build && npm run check:publish-eol",
|
|
20
20
|
"prepublishOnly": "npm run check:publish-eol && npm run build",
|
|
21
21
|
"postpublish": "node scripts/check-publish-health.mjs",
|
|
22
|
-
"postversion": "node scripts/check-publish-health.mjs || echo \"[postversion] see above: local/registry are out of sync after this version bump (171900)\"",
|
|
23
22
|
"dev": "next dev",
|
|
24
23
|
"dev:local": "pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/dev-local.ps1",
|
|
25
24
|
"dev:broker": "pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/dev-broker.ps1",
|
|
@@ -180,6 +179,11 @@
|
|
|
180
179
|
"require": "./dist/lib/api-handler.js",
|
|
181
180
|
"default": "./dist/lib/api-handler.js"
|
|
182
181
|
},
|
|
182
|
+
"./lib/require-idp-url": {
|
|
183
|
+
"types": "./dist/lib/require-idp-url.d.ts",
|
|
184
|
+
"require": "./dist/lib/require-idp-url.js",
|
|
185
|
+
"default": "./dist/lib/require-idp-url.js"
|
|
186
|
+
},
|
|
183
187
|
"./lib/startup-init": {
|
|
184
188
|
"types": "./dist/lib/startup-init.d.ts",
|
|
185
189
|
"require": "./dist/lib/startup-init.js",
|
|
@@ -21,10 +21,14 @@
|
|
|
21
21
|
* - Missing auth is reported as its OWN outcome, not folded into "lagging".
|
|
22
22
|
* Those are different failures with different fixes (credential vs publish).
|
|
23
23
|
*
|
|
24
|
+
* MEANINGFUL ONLY IN THE TREE THAT PUBLISHED: another checkout or dist packs a different integrity (CRLF vs LF, a different build).
|
|
24
25
|
* Usage: node scripts/check-publish-health.mjs
|
|
25
|
-
* Exit: 0 =
|
|
26
|
-
* 1 =
|
|
26
|
+
* Exit: 0 = the dist-tag points at the local version AND the registry's integrity for it equals the packed tarball's
|
|
27
|
+
* 1 = it does not (timeout with the two values it last saw, an integrity mismatch, never published, no auth)
|
|
27
28
|
* 2 = could not determine (network/registry/parse) — inconclusive, NOT a pass
|
|
29
|
+
* Flag: --timeout-ms <n> (0 = one read, no waiting; `postversion` uses it because nothing is published yet at that point).
|
|
30
|
+
* Env: PUBLISH_HEALTH_TIMEOUT_MS (default 600000): how long to wait for the registry to reflect the publish. Measured stale
|
|
31
|
+
* for about 5-7 minutes after every publish 4.8.3 - 4.8.6, so a short bound fails a good publish. (BAPert 75831)
|
|
28
32
|
*/
|
|
29
33
|
|
|
30
34
|
import { execFileSync } from 'node:child_process';
|
|
@@ -32,6 +36,7 @@ import { readFileSync } from 'node:fs';
|
|
|
32
36
|
import { fileURLToPath } from 'node:url';
|
|
33
37
|
import { dirname, join } from 'node:path';
|
|
34
38
|
import { resolvePublishTag } from './publish-health-tag.mjs';
|
|
39
|
+
import { pollRegistry, resolveTimeoutMs } from './publish-health-poll.mjs';
|
|
35
40
|
|
|
36
41
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
37
42
|
const pkgPath = join(here, '..', 'package.json');
|
|
@@ -44,21 +49,6 @@ function run(args) {
|
|
|
44
49
|
}).trim();
|
|
45
50
|
}
|
|
46
51
|
|
|
47
|
-
function sleepSync(ms) {
|
|
48
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** Numeric-aware semver compare. Returns >0 if a > b, 0 if equal, <0 if a < b. */
|
|
52
|
-
function compareVersions(a, b) {
|
|
53
|
-
const norm = (v) => String(v).split('-')[0].split('.').map((n) => parseInt(n, 10) || 0);
|
|
54
|
-
const [x, y] = [norm(a), norm(b)];
|
|
55
|
-
for (let i = 0; i < Math.max(x.length, y.length); i++) {
|
|
56
|
-
const d = (x[i] ?? 0) - (y[i] ?? 0);
|
|
57
|
-
if (d !== 0) return d;
|
|
58
|
-
}
|
|
59
|
-
return 0;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
52
|
let local;
|
|
63
53
|
try {
|
|
64
54
|
local = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
@@ -101,62 +91,77 @@ try {
|
|
|
101
91
|
// which is the dangerous action — and against a registry that refuses republication
|
|
102
92
|
// it burns a version number, which is how a version string ends up naming two trees.
|
|
103
93
|
//
|
|
104
|
-
// `--prefer-online` forces revalidation
|
|
105
|
-
//
|
|
94
|
+
// `--prefer-online` forces revalidation, and the bounded poll (publish-health-poll.mjs) covers the propagation delay: measured
|
|
95
|
+
// 5-7 MINUTES of stale reads after each publish 4.8.3 - 4.8.6, which is why the default bound is 10 minutes, not seconds.
|
|
106
96
|
//
|
|
107
|
-
// THIS DOES NOT SOFTEN THE CHECK, and it must not be "helpfully" turned into a warning:
|
|
108
|
-
//
|
|
109
|
-
// unpublished for WEEKS)
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
97
|
+
// THIS DOES NOT SOFTEN THE CHECK, and it must not be "helpfully" turned into a warning: it passes only when the tag points at
|
|
98
|
+
// the version we published AND the registry's integrity for it equals the tarball we packed. A genuine lag (the 171900 failure
|
|
99
|
+
// mode — a dead credential leaving versions unpublished for WEEKS) exhausts the poll and still exits 1, printing what it saw.
|
|
100
|
+
const TIMEOUT_MS = resolveTimeoutMs(process.argv.slice(2), process.env);
|
|
101
|
+
if (TIMEOUT_MS === null) {
|
|
102
|
+
console.error('[publish-health] INCONCLUSIVE: --timeout-ms / PUBLISH_HEALTH_TIMEOUT_MS must be a non-negative integer (milliseconds).');
|
|
103
|
+
process.exit(2);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The integrity of the tarball WE packed: `npm pack --dry-run --json` reports it without writing a file, and --ignore-scripts keeps
|
|
107
|
+
// it from re-running prepack/build. Measured 2026-10-20: from a clean LF tree at the published commit it reproduced 4.8.6's
|
|
108
|
+
// registry integrity exactly. If it cannot be computed the check is INCONCLUSIVE, never a pass.
|
|
109
|
+
let packIntegrity;
|
|
110
|
+
try {
|
|
111
|
+
const out = JSON.parse(run(['pack', '--dry-run', '--json', '--ignore-scripts']));
|
|
112
|
+
packIntegrity = Array.isArray(out) ? out[0]?.integrity : out?.integrity;
|
|
113
|
+
if (typeof packIntegrity !== 'string' || !packIntegrity) throw new Error('npm pack reported no integrity');
|
|
114
|
+
} catch (err) {
|
|
115
|
+
console.error(`[publish-health] INCONCLUSIVE: cannot compute the packed tarball's integrity: ${String(err.message).split('\n')[0]}`);
|
|
116
|
+
process.exit(2);
|
|
117
|
+
}
|
|
113
118
|
|
|
114
|
-
|
|
115
|
-
|
|
119
|
+
const is404 = (err) => /E404|404 Not Found/.test(String(err.stderr || err.message || ''));
|
|
120
|
+
const view = (spec, field) => {
|
|
116
121
|
try {
|
|
117
|
-
|
|
122
|
+
return run(['view', spec, field, '--prefer-online']) || null;
|
|
118
123
|
} catch (err) {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
console.error(`[publish-health] FAIL: ${registrySpec} has NEVER been published to the registry.`);
|
|
122
|
-
console.error(`[publish-health] local=${localVersion} registry=<absent>`);
|
|
123
|
-
process.exit(1);
|
|
124
|
-
}
|
|
125
|
-
console.error(`[publish-health] INCONCLUSIVE: could not query the registry: ${msg.split('\n')[0]}`);
|
|
126
|
-
console.error('[publish-health] Treat as UNKNOWN, not as a pass.');
|
|
127
|
-
process.exit(2);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// Stop as soon as the registry is at or ahead of local. Only an apparent lag is
|
|
131
|
-
// worth re-reading, and only because THIS script's own hook makes a stale read likely.
|
|
132
|
-
if (compareVersions(localVersion, registryVersion) <= 0) break;
|
|
133
|
-
|
|
134
|
-
if (attempt < REGISTRY_ATTEMPTS) {
|
|
135
|
-
console.log(
|
|
136
|
-
`[publish-health] registry says ${registryVersion}, local is ${localVersion} — ` +
|
|
137
|
-
`re-reading (${attempt}/${REGISTRY_ATTEMPTS - 1}) in case this is post-publish propagation lag.`,
|
|
138
|
-
);
|
|
139
|
-
sleepSync(REGISTRY_RETRY_MS);
|
|
124
|
+
if (is404(err)) return null;
|
|
125
|
+
throw new Error(`could not query the registry for ${spec}: ${String(err.stderr || err.message || '').split('\n')[0]}`);
|
|
140
126
|
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
console.log(`[publish-health] waiting up to ${Math.round(TIMEOUT_MS / 1000)}s for ${registrySpec} to show ${localVersion} with the packed integrity`);
|
|
130
|
+
const r = await pollRegistry({
|
|
131
|
+
readTag: () => view(registrySpec, 'version'),
|
|
132
|
+
readIntegrity: () => view(`${name}@${localVersion}`, 'dist.integrity'),
|
|
133
|
+
localVersion,
|
|
134
|
+
packIntegrity,
|
|
135
|
+
timeoutMs: TIMEOUT_MS,
|
|
136
|
+
sleep: (ms) => new Promise((res) => setTimeout(res, ms)),
|
|
137
|
+
onWait: (w) => console.log(`[publish-health] not yet (attempt ${w.attempts}): tag ${tag} -> ${w.sawTag ?? '<absent>'}, integrity ${w.sawIntegrity ?? '<not visible>'}; re-reading in ${Math.round(w.nextDelayMs / 1000)}s`),
|
|
138
|
+
});
|
|
144
139
|
|
|
145
140
|
console.log(`[publish-health] package : ${name}`);
|
|
146
|
-
console.log(`[publish-health] local : ${localVersion}`);
|
|
147
|
-
console.log(`[publish-health] registry :
|
|
141
|
+
console.log(`[publish-health] local : ${localVersion} packed integrity ${packIntegrity}`);
|
|
142
|
+
console.log(`[publish-health] registry : dist-tag ${tag} -> ${r.sawTag ?? '<absent>'}, integrity ${r.sawIntegrity ?? '<not visible>'}`);
|
|
148
143
|
console.log(`[publish-health] npm auth : ${authUser ? `logged in as ${authUser}` : 'NOT AUTHENTICATED (npm whoami failed)'}`);
|
|
149
144
|
|
|
150
|
-
if (
|
|
145
|
+
if (r.reason === 'unreadable') {
|
|
146
|
+
console.error(`[publish-health] INCONCLUSIVE: the registry could not be read: ${r.lastError}`);
|
|
147
|
+
console.error('[publish-health] Treat as UNKNOWN, not as a pass.');
|
|
148
|
+
process.exit(2);
|
|
149
|
+
}
|
|
150
|
+
if (!r.ok) {
|
|
151
151
|
console.error('');
|
|
152
|
-
|
|
153
|
-
|
|
152
|
+
if (r.reason === 'integrity-mismatch') {
|
|
153
|
+
console.error(`[publish-health] FAIL: the registry's ${localVersion} is NOT the tarball packed here.`);
|
|
154
|
+
console.error('[publish-health] If this is not the tree that published, the mismatch is expected: re-run there before concluding anything.');
|
|
155
|
+
} else {
|
|
156
|
+
console.error(`[publish-health] FAIL: after ${Math.round(TIMEOUT_MS / 1000)}s the registry does not show ${localVersion} under ${tag} with the packed integrity.`);
|
|
157
|
+
}
|
|
158
|
+
console.error(`[publish-health] saw: dist-tag ${tag} -> ${r.sawTag ?? '<absent>'}; integrity of ${localVersion} -> ${r.sawIntegrity ?? '<not visible>'}`);
|
|
159
|
+
console.error(`[publish-health] want: dist-tag ${tag} -> ${localVersion}; integrity ${packIntegrity}`);
|
|
154
160
|
if (!authUser) {
|
|
155
161
|
console.error('[publish-health] ROOT CAUSE LIKELY: npm is not authenticated on this machine.');
|
|
156
162
|
console.error('[publish-health] This is the 171900 failure mode — a silent credential expiry.');
|
|
157
163
|
}
|
|
158
|
-
console.error('[publish-health]
|
|
159
|
-
console.error('[publish-health] installing from a local tarball. See cards 171900 / 185177.');
|
|
164
|
+
console.error('[publish-health] Do NOT publish again to "fix" this: check the registry by hand first (a second publish of the same version is refused).');
|
|
160
165
|
process.exit(1);
|
|
161
166
|
}
|
|
162
167
|
|
|
@@ -167,5 +172,5 @@ if (!authUser) {
|
|
|
167
172
|
process.exit(1);
|
|
168
173
|
}
|
|
169
174
|
|
|
170
|
-
console.log('[publish-health] OK:
|
|
175
|
+
console.log('[publish-health] OK: the dist-tag points at the published version, the integrity matches the packed tarball, and auth is live.');
|
|
171
176
|
process.exit(0);
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The decision half of the postpublish health check (BAPert 75831): after `npm publish`, poll the registry until BOTH
|
|
3
|
+
* (1) the dist-tag we published under points at exactly the version we published, AND
|
|
4
|
+
* (2) that version's dist.integrity equals the integrity of the tarball we packed,
|
|
5
|
+
* and pass only on that equality. Pure and injectable (readers, sleep, clock) so the rows can run it against a stubbed registry.
|
|
6
|
+
*
|
|
7
|
+
* WHY POLL, AND FOR HOW LONG. Right after a successful publish the registry's own reads are stale for minutes (measured on every
|
|
8
|
+
* publish 4.8.3 - 4.8.6: "version not found" for about 5-7 minutes). A single read, or a handful of 2-second retries, fails a
|
|
9
|
+
* publish that is fine, and a check that always fails is a check that gets overridden by habit. The poll is bounded and it never
|
|
10
|
+
* softens: on timeout or mismatch the result is NOT ok and carries the two values it last saw.
|
|
11
|
+
*
|
|
12
|
+
* A read of the exact version's integrity that comes back DIFFERENT from the pack's is final (a published version is immutable),
|
|
13
|
+
* so it fails immediately instead of waiting out the clock.
|
|
14
|
+
*
|
|
15
|
+
* Readers: readTag() -> the version the tag points at, or null if the tag/package is absent. readIntegrity() -> the published
|
|
16
|
+
* integrity of the local version, or null if that version is not visible yet. A reader that throws is an unreadable registry.
|
|
17
|
+
*/
|
|
18
|
+
export const DEFAULTS = { timeoutMs: 10 * 60 * 1000, firstDelayMs: 5000, maxDelayMs: 30000, factor: 1.5 };
|
|
19
|
+
|
|
20
|
+
export async function pollRegistry({
|
|
21
|
+
readTag,
|
|
22
|
+
readIntegrity,
|
|
23
|
+
localVersion,
|
|
24
|
+
packIntegrity,
|
|
25
|
+
timeoutMs = DEFAULTS.timeoutMs,
|
|
26
|
+
firstDelayMs = DEFAULTS.firstDelayMs,
|
|
27
|
+
maxDelayMs = DEFAULTS.maxDelayMs,
|
|
28
|
+
factor = DEFAULTS.factor,
|
|
29
|
+
sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
30
|
+
now = () => Date.now(),
|
|
31
|
+
onWait = () => {},
|
|
32
|
+
}) {
|
|
33
|
+
const started = now();
|
|
34
|
+
let delay = firstDelayMs;
|
|
35
|
+
let attempts = 0;
|
|
36
|
+
let sawTag = null;
|
|
37
|
+
let sawIntegrity = null;
|
|
38
|
+
let lastError = null;
|
|
39
|
+
let reads = 0;
|
|
40
|
+
for (;;) {
|
|
41
|
+
attempts += 1;
|
|
42
|
+
try {
|
|
43
|
+
sawTag = await readTag();
|
|
44
|
+
sawIntegrity = await readIntegrity();
|
|
45
|
+
lastError = null;
|
|
46
|
+
reads += 1;
|
|
47
|
+
} catch (err) {
|
|
48
|
+
lastError = String((err && err.message) || err);
|
|
49
|
+
}
|
|
50
|
+
if (!lastError) {
|
|
51
|
+
if (sawIntegrity !== null && sawIntegrity !== packIntegrity) {
|
|
52
|
+
return { ok: false, reason: 'integrity-mismatch', attempts, sawTag, sawIntegrity };
|
|
53
|
+
}
|
|
54
|
+
if (sawTag === localVersion && sawIntegrity === packIntegrity) {
|
|
55
|
+
return { ok: true, reason: 'match', attempts, sawTag, sawIntegrity };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (now() - started + delay > timeoutMs) {
|
|
59
|
+
return { ok: false, reason: reads === 0 ? 'unreadable' : 'timeout', attempts, sawTag, sawIntegrity, lastError };
|
|
60
|
+
}
|
|
61
|
+
onWait({ attempts, sawTag, sawIntegrity, lastError, nextDelayMs: delay });
|
|
62
|
+
await sleep(delay);
|
|
63
|
+
delay = Math.min(Math.round(delay * factor), maxDelayMs);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* How long to wait: `--timeout-ms <n>` / `--timeout-ms=<n>` (a flag, because an env prefix does not work in Windows cmd), then
|
|
69
|
+
* PUBLISH_HEALTH_TIMEOUT_MS, then the default. 0 is a real value (one read, no waiting): `postversion` runs BEFORE anything is
|
|
70
|
+
* published, so the new version cannot be on the registry yet and waiting would only delay `npm version` (NightHawk, PR 1386).
|
|
71
|
+
* A value that is not a non-negative integer returns null - the caller treats it as inconclusive, never as the default.
|
|
72
|
+
*/
|
|
73
|
+
export function resolveTimeoutMs(argv = [], env = {}) {
|
|
74
|
+
let raw;
|
|
75
|
+
for (let i = 0; i < argv.length; i++) {
|
|
76
|
+
const a = argv[i];
|
|
77
|
+
if (a === '--timeout-ms') { raw = argv[i + 1]; break; }
|
|
78
|
+
if (a.startsWith('--timeout-ms=')) { raw = a.slice('--timeout-ms='.length); break; }
|
|
79
|
+
}
|
|
80
|
+
if (raw === undefined) raw = env.PUBLISH_HEALTH_TIMEOUT_MS;
|
|
81
|
+
if (raw === undefined || raw === '') return DEFAULTS.timeoutMs;
|
|
82
|
+
return /^\d+$/.test(String(raw)) ? Number.parseInt(raw, 10) : null;
|
|
83
|
+
}
|
|
@@ -14,5 +14,5 @@ export async function POST(req: NextRequest) {
|
|
|
14
14
|
const body = await req.json().catch(() => ({} as Record<string, unknown>));
|
|
15
15
|
const onboardToken = (body as Record<string, unknown>)?.onboard_token;
|
|
16
16
|
if (!onboardToken) return onboardingBadRequest('MISSING_TOKEN', 'onboard_token is required');
|
|
17
|
-
return onboardingProxy(req, '/api/Onboarding/send-email-code');
|
|
17
|
+
return onboardingProxy(req, '/api/Onboarding/send-email-code', body);
|
|
18
18
|
}
|
|
@@ -17,5 +17,5 @@ export async function POST(req: NextRequest) {
|
|
|
17
17
|
if (!b?.onboard_token) return onboardingBadRequest('MISSING_TOKEN', 'onboard_token is required');
|
|
18
18
|
if (!b?.email || !b?.reset_token) return onboardingBadRequest('MISSING_RESET_CONTEXT', 'email and reset_token (from verify-code) are required');
|
|
19
19
|
if (!b?.new_password) return onboardingBadRequest('MISSING_PASSWORD', 'new_password is required');
|
|
20
|
-
return onboardingProxy(req, '/api/Onboarding/set-password');
|
|
20
|
+
return onboardingProxy(req, '/api/Onboarding/set-password', body);
|
|
21
21
|
}
|
|
@@ -16,5 +16,5 @@ export async function POST(req: NextRequest) {
|
|
|
16
16
|
const code = (body as Record<string, unknown>)?.code;
|
|
17
17
|
if (!onboardToken) return onboardingBadRequest('MISSING_TOKEN', 'onboard_token is required');
|
|
18
18
|
if (!code) return onboardingBadRequest('MISSING_CODE', 'code is required');
|
|
19
|
-
return onboardingProxy(req, '/api/Onboarding/verify-code');
|
|
19
|
+
return onboardingProxy(req, '/api/Onboarding/verify-code', body);
|
|
20
20
|
}
|
|
@@ -13,5 +13,5 @@ export async function POST(req: NextRequest) {
|
|
|
13
13
|
const body = await req.json().catch(() => ({} as Record<string, unknown>));
|
|
14
14
|
const onboardToken = (body as Record<string, unknown>)?.onboard_token;
|
|
15
15
|
if (!onboardToken) return onboardingBadRequest('MISSING_TOKEN', 'onboard_token is required');
|
|
16
|
-
return onboardingProxy(req, '/api/Onboarding/verify-welcome-email');
|
|
16
|
+
return onboardingProxy(req, '/api/Onboarding/verify-welcome-email', body);
|
|
17
17
|
}
|
|
@@ -29,3 +29,18 @@ export async function requestOnboardingCode(
|
|
|
29
29
|
return { ok: false, devCode: null };
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
|
+
|
|
33
|
+
/** Where the code step stands: the page has asked the IdP to email a code ('sending'), the IdP said yes ('sent') or no ('failed'). */
|
|
34
|
+
export type CodeState = 'sending' | 'sent' | 'failed';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 4.8.7 (QAPert 75864): the code step used to say "We emailed you a 6-digit code" before anything was sent, and kept saying it when
|
|
38
|
+
* the send failed. It now says a code was emailed ONLY when the IdP answered ok.
|
|
39
|
+
*/
|
|
40
|
+
export function codeStepSentence(state: CodeState, maskedEmail: string | null): string {
|
|
41
|
+
if (state === 'sent') {
|
|
42
|
+
return maskedEmail ? `We emailed a 6-digit code to ${maskedEmail}. Enter it below.` : 'We emailed you a 6-digit code. Enter it below.';
|
|
43
|
+
}
|
|
44
|
+
if (state === 'sending') return 'Sending you a 6-digit code…';
|
|
45
|
+
return 'We could not send a code. Choose "Send a new code" to try again.';
|
|
46
|
+
}
|
|
@@ -21,24 +21,31 @@ export function onboardingBadRequest(code: string, message: string) {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
|
-
* POST
|
|
24
|
+
* POST `body` (the handler's ALREADY-PARSED JSON) to `{IDP_URL}{idpPath}` with no credentials, and relay the
|
|
25
25
|
* IdP's status and body verbatim (the page acts on what the IdP actually
|
|
26
26
|
* returns, not on a shape this layer guesses). Never relays the IdP error TEXT
|
|
27
27
|
* for a non-2xx is NOT done here: the onboarding endpoints answer structured
|
|
28
28
|
* envelopes the page reads; the page owns the copy.
|
|
29
29
|
*/
|
|
30
|
-
export async function onboardingProxy(req: NextRequest, idpPath: string): Promise<NextResponse> {
|
|
30
|
+
export async function onboardingProxy(req: NextRequest, idpPath: string, body: unknown): Promise<NextResponse> {
|
|
31
31
|
const idpBase = ENV_CONFIG.IDP_URL;
|
|
32
32
|
if (!idpBase) {
|
|
33
33
|
return NextResponse.json({ success: false, message: 'IDP_URL is not configured', error: { code: 'NO_IDP_URL' } }, { status: 500 });
|
|
34
34
|
}
|
|
35
35
|
const url = `${idpBase}${idpPath}`;
|
|
36
36
|
assertAbsoluteIdpUrl(url);
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
37
|
+
// 4.8.7: the handler has ALREADY read the request body to check its fields, and a Request body is single-read. The proxy used to
|
|
38
|
+
// call req.text() again, which threw "Body has already been read", swallowed it, and POSTed '{}' to the IdP: every onboarding
|
|
39
|
+
// step from a generated app (4.5.0 - 4.8.6) reached Core with no token. The parsed body is passed in; the request is only read
|
|
40
|
+
// for its forwarding headers.
|
|
41
|
+
// A caller that did not pass the parsed body is a bug in this package, and it must be LOUD: it used to become '{}' at the IdP.
|
|
42
|
+
if (body === null || typeof body !== 'object' || Array.isArray(body)) {
|
|
43
|
+
return NextResponse.json(
|
|
44
|
+
{ success: false, message: 'The onboarding request body was not forwarded.', error: { code: 'BODY_NOT_FORWARDED' } },
|
|
45
|
+
{ status: 500 },
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
const bodyText = JSON.stringify(body);
|
|
42
49
|
|
|
43
50
|
const headers: Record<string, string> = { 'Content-Type': 'application/json', Accept: 'application/json' };
|
|
44
51
|
const xfwd = req.headers.get('x-forwarded-for') || req.headers.get('x-real-ip');
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 4.8.8 (item 24, Jon: "fast and loud are how i like my config errors"): the app REQUIRES IDP_URL and says so at START.
|
|
3
|
+
*
|
|
4
|
+
* There is no default in code. The quickstart generator writes the production sign-in service address into the app's .env.local
|
|
5
|
+
* at generation time; rigs override that file. When the value is missing, empty or not an absolute http(s) address, the server
|
|
6
|
+
* refuses to come up - printed once, in one fixed sentence, exit code 1 - instead of booting and failing on the first sign-in.
|
|
7
|
+
*
|
|
8
|
+
* A leaf module: no imports, so a generated `instrumentation.ts` can load it without pulling the session store or the logger.
|
|
9
|
+
*/
|
|
10
|
+
export const IDP_URL_REQUIRED_SENTENCE =
|
|
11
|
+
'IDP_URL is not set to a full http(s) address. This app has no default sign-in service: set IDP_URL in .env.local and start again.';
|
|
12
|
+
|
|
13
|
+
/** True when IDP_URL is present and absolute. Otherwise logs the fixed sentence and calls `exit(1)`; returns false if `exit` returns. */
|
|
14
|
+
export function requireIdpUrl(
|
|
15
|
+
env: Record<string, string | undefined> = process.env,
|
|
16
|
+
exit: (code: number) => unknown = (code) => process.exit(code),
|
|
17
|
+
log: (line: string) => void = (line) => console.error(line),
|
|
18
|
+
): boolean {
|
|
19
|
+
const value = (env.IDP_URL ?? '').trim();
|
|
20
|
+
if (/^https?:\/\/[^/\s]+/i.test(value)) return true;
|
|
21
|
+
log('');
|
|
22
|
+
log('[STARTUP FAILED - REFUSING TO SERVE]');
|
|
23
|
+
log(` ${IDP_URL_REQUIRED_SENTENCE}`);
|
|
24
|
+
log('');
|
|
25
|
+
exit(1);
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import React, { Suspense, useEffect, useRef, useState } from 'react';
|
|
4
4
|
import { useSearchParams } from 'next/navigation';
|
|
5
5
|
import { DevCodeNotice } from '../../components/auth/DevCodeNotice';
|
|
6
|
-
import { requestOnboardingCode } from '../../lib/onboarding-code-client';
|
|
6
|
+
import { requestOnboardingCode, codeStepSentence, type CodeState } from '../../lib/onboarding-code-client';
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* PAY-2121: the /account-auth/onboarding landing page.
|
|
@@ -47,21 +47,21 @@ function OnboardingContent() {
|
|
|
47
47
|
const [busy, setBusy] = useState(false);
|
|
48
48
|
const [error, setError] = useState<string | null>(null);
|
|
49
49
|
const [devCode, setDevCode] = useState<string | null>(null);
|
|
50
|
-
const [
|
|
50
|
+
const [codeState, setCodeState] = useState<CodeState>('sending');
|
|
51
51
|
const sentFor = useRef<string | null>(null);
|
|
52
52
|
|
|
53
53
|
// 4.8.6: the page asks for the code itself, once per token (nothing else ever sends it). The dev echo, if the IdP gave one in
|
|
54
54
|
// this answer, is shown under the code box; nothing else can make it appear.
|
|
55
55
|
async function sendCode() {
|
|
56
|
-
|
|
56
|
+
setCodeState('sending');
|
|
57
57
|
const r = await requestOnboardingCode(onboardToken);
|
|
58
58
|
setDevCode(r.devCode);
|
|
59
|
-
|
|
59
|
+
setCodeState(r.ok ? 'sent' : 'failed');
|
|
60
60
|
}
|
|
61
61
|
useEffect(() => {
|
|
62
62
|
if (!onboardToken || sentFor.current === onboardToken) return;
|
|
63
63
|
sentFor.current = onboardToken;
|
|
64
|
-
void requestOnboardingCode(onboardToken).then((r) => setDevCode(r.devCode));
|
|
64
|
+
void requestOnboardingCode(onboardToken).then((r) => { setDevCode(r.devCode); setCodeState(r.ok ? 'sent' : 'failed'); });
|
|
65
65
|
}, [onboardToken]);
|
|
66
66
|
|
|
67
67
|
// Step 1: tell the invitee which address the code went to (masked). Best
|
|
@@ -150,9 +150,7 @@ function OnboardingContent() {
|
|
|
150
150
|
<>
|
|
151
151
|
<h1 style={titleStyle}>Finish setting up your account</h1>
|
|
152
152
|
<p style={{ color: H.secondary, fontSize: 14, marginTop: 8 }}>
|
|
153
|
-
{maskedEmail
|
|
154
|
-
? `We emailed a 6-digit code to ${maskedEmail}. Enter it below.`
|
|
155
|
-
: 'We emailed you a 6-digit code. Enter it below.'}
|
|
153
|
+
{codeStepSentence(codeState, maskedEmail)}
|
|
156
154
|
</p>
|
|
157
155
|
<form onSubmit={submitCode} noValidate style={{ marginTop: 16 }}>
|
|
158
156
|
<input
|
|
@@ -173,7 +171,6 @@ function OnboardingContent() {
|
|
|
173
171
|
<button type="button" onClick={() => void sendCode()} data-testid="resend-code" style={{ background: 'none', border: 'none', padding: 0, color: H.primary, fontWeight: 600, textDecoration: 'underline', cursor: 'pointer' }}>
|
|
174
172
|
Send a new code
|
|
175
173
|
</button>
|
|
176
|
-
{codeNote && <span role="status" style={{ marginLeft: 8, color: H.secondary }}>{codeNote}</span>}
|
|
177
174
|
</p>
|
|
178
175
|
</>
|
|
179
176
|
)}
|
|
@@ -26,7 +26,7 @@ export { POST } from '../../api-handlers/account/masked-info';
|
|
|
26
26
|
* email/phone options to the user.
|
|
27
27
|
*
|
|
28
28
|
* Environment variables used:
|
|
29
|
-
* - IDP_URL or NEXT_PUBLIC_IDP_URL (
|
|
29
|
+
* - IDP_URL or NEXT_PUBLIC_IDP_URL (REQUIRED: there is no default in code)
|
|
30
30
|
* - CLIENT_ID or NEXT_PUBLIC_IDP_CLIENT_ID (required)
|
|
31
31
|
* - BETTER_AUTH_SECRET (required — fetched from IDP at startup)
|
|
32
32
|
*
|
|
@@ -29,7 +29,7 @@ export { POST } from '../../api-handlers/account/send-code';
|
|
|
29
29
|
* - contactId: string - ID of the masked contact to send to
|
|
30
30
|
*
|
|
31
31
|
* Environment variables used:
|
|
32
|
-
* - IDP_URL or NEXT_PUBLIC_IDP_URL (
|
|
32
|
+
* - IDP_URL or NEXT_PUBLIC_IDP_URL (REQUIRED: there is no default in code)
|
|
33
33
|
* - CLIENT_ID or NEXT_PUBLIC_IDP_CLIENT_ID (required)
|
|
34
34
|
* - BETTER_AUTH_SECRET (required — fetched from IDP at startup)
|
|
35
35
|
*
|
|
@@ -28,7 +28,7 @@ export { POST } from '../../api-handlers/account/verify-email';
|
|
|
28
28
|
* - emailId: string - ID of the email address used
|
|
29
29
|
*
|
|
30
30
|
* Environment variables used:
|
|
31
|
-
* - IDP_URL or NEXT_PUBLIC_IDP_URL (
|
|
31
|
+
* - IDP_URL or NEXT_PUBLIC_IDP_URL (REQUIRED: there is no default in code)
|
|
32
32
|
* - CLIENT_ID or NEXT_PUBLIC_IDP_CLIENT_ID (required)
|
|
33
33
|
* - BETTER_AUTH_SECRET (required — fetched from IDP at startup)
|
|
34
34
|
*
|
|
@@ -28,7 +28,7 @@ export { POST } from '../../api-handlers/account/verify-sms';
|
|
|
28
28
|
* - phoneId: string - ID of the phone number used
|
|
29
29
|
*
|
|
30
30
|
* Environment variables used:
|
|
31
|
-
* - IDP_URL or NEXT_PUBLIC_IDP_URL (
|
|
31
|
+
* - IDP_URL or NEXT_PUBLIC_IDP_URL (REQUIRED: there is no default in code)
|
|
32
32
|
* - CLIENT_ID or NEXT_PUBLIC_IDP_CLIENT_ID (required)
|
|
33
33
|
* - BETTER_AUTH_SECRET (required — fetched from IDP at startup)
|
|
34
34
|
*
|