@payez/next-mvp 4.8.0 → 4.8.1

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.
@@ -198,16 +198,17 @@ function serviceUnavailableResponse(request, check = 'viability', unavailablePag
198
198
  // callback, and no Set-Cookie (nothing here touches the session). No redirect, so no 404 is possible.
199
199
  // `unavailablePage` is accepted only as a PLAIN path ([A-Za-z0-9_-/]; no query, fragment, %-encoding, dot segment or
200
200
  // scheme), so the self-check below compares like with like and a mis-set value falls back to the inline page (Scout 74668).
201
- // The named page applies to the revocation hold ONLY (BAPert 74648: the viability-failure arm is untouched). If the
201
+ // PAY-2345: the viability-FAILURE arm (500 / timeout / breaker) takes the same path, so an app with no page of its own
202
+ // gets the inline 503 instead of a 404 during an outage. The two arms differ only in `check` (JSON body for /api/*). If the
202
203
  // request is already FOR the named page (an app that did not register it as public), answer inline: redirecting to
203
204
  // it again would loop with a longer callbackUrl each time (QAPert 74651).
204
- const named = check === 'revocation' && unavailablePage && /^\/[A-Za-z0-9_\-\/]*$/.test(unavailablePage) && !unavailablePage.includes('//') ? unavailablePage : null;
205
- if (check === 'revocation' && (!named || pathname.replace(/\/+$/, '') === named.replace(/\/+$/, ''))) {
205
+ const named = unavailablePage && /^\/[A-Za-z0-9_\-\/]*$/.test(unavailablePage) && !unavailablePage.includes('//') ? unavailablePage : null;
206
+ if (!named || pathname.replace(/\/+$/, '') === named.replace(/\/+$/, '')) {
206
207
  const href = callback.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
207
208
  return new server_1.NextResponse('<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Temporarily unavailable</title>' +
208
209
  '<main role="status" style="font-family:system-ui,sans-serif;max-width:28rem;margin:20vh auto;padding:0 1rem;text-align:center">' +
209
210
  '<h1 style="font-size:1.25rem">Temporarily unavailable</h1>' +
210
- '<p>We could not check your session just now. You are still signed in.</p>' +
211
+ '<p>' + (check === 'revocation' ? 'We could not check your session just now. You are still signed in.' : 'We could not reach the sign-in service just now. Please try again.') + '</p>' +
211
212
  '<p><a href="' + href + '">Try again</a></p></main>', { status: 503, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'retry-after': '30' } });
212
213
  }
213
214
  const target = new URL(named || '/service-unavailable', request.url);
@@ -766,7 +767,7 @@ async function handleRefresh(request, safeCallback, opts) {
766
767
  log.warn('Circuit breaker preventing refresh attempt');
767
768
  // PAY-2147: a breaker that merely declines to TRY a refresh has learned nothing about the session; wiping the
768
769
  // cookies here logged out every user on the first request after a blip (HALF_OPEN refuses all but one probe).
769
- return serviceUnavailableResponse(request);
770
+ return serviceUnavailableResponse(request, 'viability', opts.unavailablePage);
770
771
  }
771
772
  try {
772
773
  const baseUrl = (0, internal_api_url_1.getInternalApiUrl)(request);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@payez/next-mvp",
3
- "version": "4.8.0",
3
+ "version": "4.8.1",
4
4
  "sideEffects": false,
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -1,16 +1,16 @@
1
- /**
2
- * PAY-2339 (BAPert 74633 gap 1): the two revocation fields every viability producer emits, in ONE place so the
3
- * producers (api-handlers/session/viability.ts and routes/auth/viability.ts, four return sites) cannot drift.
4
- *
5
- * forceInvalid - true ONLY for 'revoked'. An 'unknown' answer is no longer a sign-out verdict.
6
- * unavailable - true when the revocation backend could not answer: 'unknown' and ANY string that is not one of the
7
- * three recognised states. A consumer that reads this JSON (useSessionCheck, makeAuthDecision) holds
8
- * instead of signing the member out. `revocationStatus` itself is emitted unchanged.
9
- *
10
- * 'not_configured' is a configuration fault, not an outage: neither field is set for it (unchanged).
11
- */
12
- export function revocationFields(status: unknown): { forceInvalid: boolean; unavailable: boolean; revoked: boolean } {
13
- const revoked = status === 'revoked';
14
- const unavailable = !(status === 'revoked' || status === 'active' || status === 'not_configured');
15
- return { forceInvalid: revoked, unavailable, revoked };
16
- }
1
+ /**
2
+ * PAY-2339 (BAPert 74633 gap 1): the two revocation fields every viability producer emits, in ONE place so the
3
+ * producers (api-handlers/session/viability.ts and routes/auth/viability.ts, four return sites) cannot drift.
4
+ *
5
+ * forceInvalid - true ONLY for 'revoked'. An 'unknown' answer is no longer a sign-out verdict.
6
+ * unavailable - true when the revocation backend could not answer: 'unknown' and ANY string that is not one of the
7
+ * three recognised states. A consumer that reads this JSON (useSessionCheck, makeAuthDecision) holds
8
+ * instead of signing the member out. `revocationStatus` itself is emitted unchanged.
9
+ *
10
+ * 'not_configured' is a configuration fault, not an outage: neither field is set for it (unchanged).
11
+ */
12
+ export function revocationFields(status: unknown): { forceInvalid: boolean; unavailable: boolean; revoked: boolean } {
13
+ const revoked = status === 'revoked';
14
+ const unavailable = !(status === 'revoked' || status === 'active' || status === 'not_configured');
15
+ return { forceInvalid: revoked, unavailable, revoked };
16
+ }
@@ -381,19 +381,20 @@ function serviceUnavailableResponse(request: NextRequest, check: UnavailableChec
381
381
  // PAY-2339 (BAPert 74640, P13): a HELD revocation answer needs no page in the consumer. Unless the app names its own
382
382
  // page (`unavailablePage`), the middleware answers inline: a minimal 503 HTML, a "try again" link to the safe
383
383
  // callback, and no Set-Cookie (nothing here touches the session). No redirect, so no 404 is possible.
384
- // `unavailablePage` is accepted only as a PLAIN path ([A-Za-z0-9_-/]; no query, fragment, %-encoding, dot segment or
385
- // scheme), so the self-check below compares like with like and a mis-set value falls back to the inline page (Scout 74668).
386
- // The named page applies to the revocation hold ONLY (BAPert 74648: the viability-failure arm is untouched). If the
384
+ // `unavailablePage` is accepted only as a PLAIN path ([A-Za-z0-9_-/]; no query, fragment, %-encoding, dot segment or
385
+ // scheme), so the self-check below compares like with like and a mis-set value falls back to the inline page (Scout 74668).
386
+ // PAY-2345: the viability-FAILURE arm (500 / timeout / breaker) takes the same path, so an app with no page of its own
387
+ // gets the inline 503 instead of a 404 during an outage. The two arms differ only in `check` (JSON body for /api/*). If the
387
388
  // request is already FOR the named page (an app that did not register it as public), answer inline: redirecting to
388
389
  // it again would loop with a longer callbackUrl each time (QAPert 74651).
389
- const named = check === 'revocation' && unavailablePage && /^\/[A-Za-z0-9_\-\/]*$/.test(unavailablePage) && !unavailablePage.includes('//') ? unavailablePage : null;
390
- if (check === 'revocation' && (!named || pathname.replace(/\/+$/, '') === named.replace(/\/+$/, ''))) {
390
+ const named = unavailablePage && /^\/[A-Za-z0-9_\-\/]*$/.test(unavailablePage) && !unavailablePage.includes('//') ? unavailablePage : null;
391
+ if (!named || pathname.replace(/\/+$/, '') === named.replace(/\/+$/, '')) {
391
392
  const href = callback.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
392
393
  return new NextResponse(
393
394
  '<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Temporarily unavailable</title>' +
394
395
  '<main role="status" style="font-family:system-ui,sans-serif;max-width:28rem;margin:20vh auto;padding:0 1rem;text-align:center">' +
395
396
  '<h1 style="font-size:1.25rem">Temporarily unavailable</h1>' +
396
- '<p>We could not check your session just now. You are still signed in.</p>' +
397
+ '<p>' + (check === 'revocation' ? 'We could not check your session just now. You are still signed in.' : 'We could not reach the sign-in service just now. Please try again.') + '</p>' +
397
398
  '<p><a href="' + href + '">Try again</a></p></main>',
398
399
  { status: 503, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'retry-after': '30' } },
399
400
  );
@@ -1064,7 +1065,7 @@ async function handleRefresh(
1064
1065
  log.warn('Circuit breaker preventing refresh attempt');
1065
1066
  // PAY-2147: a breaker that merely declines to TRY a refresh has learned nothing about the session; wiping the
1066
1067
  // cookies here logged out every user on the first request after a blip (HALF_OPEN refuses all but one probe).
1067
- return serviceUnavailableResponse(request);
1068
+ return serviceUnavailableResponse(request, 'viability', opts.unavailablePage);
1068
1069
  }
1069
1070
 
1070
1071
  try {
@@ -1,63 +1,63 @@
1
- 'use client';
2
-
3
- import React, { Suspense } from 'react';
4
- import { useSearchParams } from 'next/navigation';
5
-
6
- /**
7
- * PAY-2339 (option C): the default "temporarily unavailable" page the middleware sends a member to when the revocation
8
- * check (or the viability read) could not answer. Mount it in one line in the consuming app:
9
- *
10
- * // app/service-unavailable/page.tsx
11
- * export { default } from '@payez/next-mvp/pages/service-unavailable';
12
- *
13
- * It signs nobody out and clears nothing: "Try again" goes back to `callbackUrl`, and the member is still signed in. A
14
- * consumer may replace the page (and the wording) with its own at the same route.
15
- *
16
- * `callbackUrl` is untrusted input. Only a same-origin PATH is followed ("/dashboard"); anything else (an absolute URL,
17
- * a protocol-relative "//host", a "javascript:" value, a backslash trick, nothing at all) falls back to "/".
18
- */
19
- export function safeReturnPath(raw: string | null | undefined): string {
20
- if (!raw || typeof raw !== 'string') return '/';
21
- if (!raw.startsWith('/') || raw.startsWith('//') || raw.includes('\\')) return '/';
22
- if (/[\u0000-\u001f]/.test(raw)) return '/';
23
- return raw;
24
- }
25
-
26
- function ServiceUnavailableContent() {
27
- const params = useSearchParams();
28
- const back = safeReturnPath(params?.get('callbackUrl'));
29
- return (
30
- <div className="min-h-screen flex items-center justify-center p-4">
31
- <div
32
- role="status"
33
- className="max-w-md w-full text-center rounded-xl p-8 shadow-lg border"
34
- style={{
35
- backgroundColor: 'var(--bg-card, #ffffff)',
36
- borderColor: 'var(--border-default, #e5e7eb)',
37
- }}
38
- >
39
- <h1 className="text-xl font-semibold mb-2" style={{ color: 'var(--text-primary, #111827)' }}>
40
- Temporarily unavailable
41
- </h1>
42
- <p className="mb-6" style={{ color: 'var(--text-secondary, #6b7280)' }}>
43
- We could not check your session just now. You are still signed in. Please try again in a moment.
44
- </p>
45
- <a
46
- href={back}
47
- className="inline-block rounded-lg px-4 py-2 font-medium"
48
- style={{ background: 'var(--color-primary, #10b981)', color: 'white' }}
49
- >
50
- Try again
51
- </a>
52
- </div>
53
- </div>
54
- );
55
- }
56
-
57
- export default function ServiceUnavailablePage() {
58
- return (
59
- <Suspense fallback={null}>
60
- <ServiceUnavailableContent />
61
- </Suspense>
62
- );
63
- }
1
+ 'use client';
2
+
3
+ import React, { Suspense } from 'react';
4
+ import { useSearchParams } from 'next/navigation';
5
+
6
+ /**
7
+ * PAY-2339 (option C): the default "temporarily unavailable" page the middleware sends a member to when the revocation
8
+ * check (or the viability read) could not answer. Mount it in one line in the consuming app:
9
+ *
10
+ * // app/service-unavailable/page.tsx
11
+ * export { default } from '@payez/next-mvp/pages/service-unavailable';
12
+ *
13
+ * It signs nobody out and clears nothing: "Try again" goes back to `callbackUrl`, and the member is still signed in. A
14
+ * consumer may replace the page (and the wording) with its own at the same route.
15
+ *
16
+ * `callbackUrl` is untrusted input. Only a same-origin PATH is followed ("/dashboard"); anything else (an absolute URL,
17
+ * a protocol-relative "//host", a "javascript:" value, a backslash trick, nothing at all) falls back to "/".
18
+ */
19
+ export function safeReturnPath(raw: string | null | undefined): string {
20
+ if (!raw || typeof raw !== 'string') return '/';
21
+ if (!raw.startsWith('/') || raw.startsWith('//') || raw.includes('\\')) return '/';
22
+ if (/[\u0000-\u001f]/.test(raw)) return '/';
23
+ return raw;
24
+ }
25
+
26
+ function ServiceUnavailableContent() {
27
+ const params = useSearchParams();
28
+ const back = safeReturnPath(params?.get('callbackUrl'));
29
+ return (
30
+ <div className="min-h-screen flex items-center justify-center p-4">
31
+ <div
32
+ role="status"
33
+ className="max-w-md w-full text-center rounded-xl p-8 shadow-lg border"
34
+ style={{
35
+ backgroundColor: 'var(--bg-card, #ffffff)',
36
+ borderColor: 'var(--border-default, #e5e7eb)',
37
+ }}
38
+ >
39
+ <h1 className="text-xl font-semibold mb-2" style={{ color: 'var(--text-primary, #111827)' }}>
40
+ Temporarily unavailable
41
+ </h1>
42
+ <p className="mb-6" style={{ color: 'var(--text-secondary, #6b7280)' }}>
43
+ We could not check your session just now. You are still signed in. Please try again in a moment.
44
+ </p>
45
+ <a
46
+ href={back}
47
+ className="inline-block rounded-lg px-4 py-2 font-medium"
48
+ style={{ background: 'var(--color-primary, #10b981)', color: 'white' }}
49
+ >
50
+ Try again
51
+ </a>
52
+ </div>
53
+ </div>
54
+ );
55
+ }
56
+
57
+ export default function ServiceUnavailablePage() {
58
+ return (
59
+ <Suspense fallback={null}>
60
+ <ServiceUnavailableContent />
61
+ </Suspense>
62
+ );
63
+ }