@payez/next-mvp 4.14.0 → 4.15.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.
Files changed (32) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/api-handlers/mfa/totp-confirm.d.ts +1 -1
  3. package/dist/api-handlers/mfa/totp-confirm.js +10 -6
  4. package/dist/api-handlers/test/force-expire.d.ts +4 -5
  5. package/dist/api-handlers/test/force-expire.js +14 -63
  6. package/dist/auth/better-auth.d.ts +16 -0
  7. package/dist/auth/better-auth.js +34 -0
  8. package/dist/lib/idp-client-config.js +1 -1
  9. package/dist/lib/log-safe.d.ts +6 -0
  10. package/dist/lib/log-safe.js +9 -0
  11. package/dist/lib/payez-client-secret.js +2 -1
  12. package/dist/lib/token-pages-switch.d.ts +23 -0
  13. package/dist/lib/token-pages-switch.js +48 -0
  14. package/dist/pages/test-env/JwtInspectPage.d.ts +16 -14
  15. package/dist/pages/test-env/JwtInspectPage.js +16 -14
  16. package/dist/pages/test-env/RefreshTokenPage.d.ts +7 -10
  17. package/dist/pages/test-env/RefreshTokenPage.js +7 -10
  18. package/dist/pages/test-env/guarded.js +21 -57
  19. package/dist/pages/test-env/index.d.ts +15 -24
  20. package/dist/pages/test-env/index.js +15 -26
  21. package/package.json +11 -1
  22. package/src/api-handlers/mfa/totp-confirm.ts +13 -5
  23. package/src/api-handlers/test/force-expire.ts +13 -70
  24. package/src/auth/better-auth.ts +44 -2
  25. package/src/lib/idp-client-config.ts +2 -2
  26. package/src/lib/log-safe.ts +9 -0
  27. package/src/lib/payez-client-secret.ts +2 -1
  28. package/src/lib/token-pages-switch.ts +44 -0
  29. package/src/pages/test-env/JwtInspectPage.tsx +16 -14
  30. package/src/pages/test-env/RefreshTokenPage.tsx +7 -10
  31. package/src/pages/test-env/guarded.tsx +21 -55
  32. package/src/pages/test-env/index.ts +15 -26
@@ -6,81 +6,45 @@ exports.RefreshTokenPage = RefreshTokenPage;
6
6
  exports.EmergencyLogoutPage = EmergencyLogoutPage;
7
7
  const jsx_runtime_1 = require("react/jsx-runtime");
8
8
  /**
9
- * SERVER-COMPONENT GUARDS for the test-env debug pages (card 249259, BAPert
10
- * ruling 35321). These are what `@payez/next-mvp/pages/test-env` exports. The
11
- * raw client components are NOT exported.
9
+ * The BARREL that `@payez/next-mvp/pages/test-env` exports: one server-component wrapper per test-env page.
12
10
  *
13
- * ── WHAT THIS IS, AND WHAT IT IS EMPHATICALLY NOT ───────────────────────────
14
- * MOUNTING IS STILL THE PRIMARY GATE. This wrapper is a backstop for a mount
15
- * that should not have happened. It is NOT a licence to mount these freely:
16
- * setting ENABLE_TEST_ROUTES=true in an environment where these pages are
17
- * mounted gives you exactly the exposure this package spent 248991 documenting
18
- * - a live session token and a copy button. If you are not actively debugging,
19
- * DO NOT MOUNT THESE AT ALL. The safest configuration remains their absence.
11
+ * PAY-3216 (Jon's order, 15 Nov): the ENABLE_TEST_ROUTES check and its notFound() are REMOVED. Each wrapper renders
12
+ * its page unconditionally, as the pages rendered before 1d40a8c. Ruling 35321 is withdrawn.
20
13
  *
21
- * ── WHY A SERVER COMPONENT, AND WHY THE PAGES THEMSELVES CANNOT DO THIS ─────
22
- * 248991 item 2 asked for a per-page env gate and it was correctly REFUSED:
23
- * the pages are `'use client'`, and non-`NEXT_PUBLIC_` variables are not
24
- * available in the browser (Next.js docs, environment-variables: "Non-
25
- * NEXT_PUBLIC_ environment variables are only available in the Node.js
26
- * environment"). A check written inside those files would read `undefined` on
27
- * the client and deny ALWAYS - killing the tool in dev too - while reading the
28
- * REAL value during SSR, producing a hydration mismatch. It would look like a
29
- * gate, satisfy a grep for one, and protect nothing.
14
+ * PAY-3220 (ruled Win 105653 s1): the ONE production switch, PAYEZ_TOKEN_PAGES_OFF (../../lib/token-pages-switch), sits
15
+ * here for the two token-showing pages ONLY. Unset, they render exactly as before. Set, they answer notFound(): a real
16
+ * 404, indistinguishable from a route that was never registered (a 403 would confirm the page exists).
17
+ * TestEnvPage (the index) and EmergencyLogoutPage are NOT covered, on purpose: Jon named the two token-showing pages.
30
18
  *
31
- * A SERVER component can read the variable for real. That is the whole reason
32
- * this file exists as a separate layer instead of a few lines in each page.
33
- *
34
- * ── FAIL CLOSED ─────────────────────────────────────────────────────────────
35
- * The test is `=== 'true'`, not truthiness. Unset, empty, "false", "0", "TRUE"
36
- * and every typo all deny. The UNSAFE state requires a deliberate, exact act;
37
- * the SAFE state is what you get by doing nothing, including by misconfiguring
38
- * it. A gate whose default is "open" is not a gate.
39
- *
40
- * `notFound()` is deliberate and is not a 403: a real 404 is indistinguishable
41
- * from a route that was never registered, whereas a 403 CONFIRMS the page
42
- * exists to anyone who probes for it. That is the same disclosure axis card
43
- * 245920 was raised about, so shipping the confirming variant here would
44
- * reintroduce next door the thing being fixed there.
45
- *
46
- * notFound() throws (NEXT_HTTP_ERROR_FALLBACK;404) and terminates rendering of
47
- * the segment, so nothing below it runs and the client component is never sent
48
- * to the browser. It does not need `return` - it is typed `never`.
19
+ * PER REQUEST: unstable_noStore() makes the segment dynamic, so a consumer's page is never prerendered with the build
20
+ * host's value. It is synchronous (the wrappers stay plain components, so PAY-3216's renderToString rows still hold) and
21
+ * a no-op outside a request. LIMIT: in Next 16 with cacheComponents (Dynamic I/O) it is a no-op too; the production
22
+ * roll's served check (both pages read back as 404 by request) is the row that would show it.
49
23
  */
50
24
  const navigation_1 = require("next/navigation");
25
+ const cache_1 = require("next/cache");
26
+ const token_pages_switch_1 = require("../../lib/token-pages-switch");
51
27
  const TestEnvPage_1 = require("./TestEnvPage");
52
28
  const JwtInspectPage_1 = require("./JwtInspectPage");
53
29
  const RefreshTokenPage_1 = require("./RefreshTokenPage");
54
30
  const EmergencyLogoutPage_1 = require("./EmergencyLogoutPage");
55
- /**
56
- * The single gate. Exact-match on purpose - see FAIL CLOSED above.
57
- *
58
- * Read at REQUEST time inside each page rather than once at module scope: a
59
- * module-scope constant would be captured at build time, so a consumer who
60
- * changed the variable would keep whatever value was baked into the bundle and
61
- * would have no way to tell. Reading per render costs an object property
62
- * lookup and removes an entire class of "I turned it off and it stayed on".
63
- */
64
- function testRoutesEnabled() {
65
- return process.env.ENABLE_TEST_ROUTES === 'true';
66
- }
67
31
  function TestEnvPage() {
68
- if (!testRoutesEnabled())
69
- (0, navigation_1.notFound)();
70
32
  return (0, jsx_runtime_1.jsx)(TestEnvPage_1.TestEnvPage, {});
71
33
  }
72
- function JwtInspectPage() {
73
- if (!testRoutesEnabled())
34
+ /** PAY-3220: per request, then the switch. notFound() throws, so nothing below it renders and no client code is sent. */
35
+ function refuseWhenTokenPagesOff() {
36
+ (0, cache_1.unstable_noStore)();
37
+ if ((0, token_pages_switch_1.tokenPagesOff)())
74
38
  (0, navigation_1.notFound)();
39
+ }
40
+ function JwtInspectPage() {
41
+ refuseWhenTokenPagesOff();
75
42
  return (0, jsx_runtime_1.jsx)(JwtInspectPage_1.JwtInspectPage, {});
76
43
  }
77
44
  function RefreshTokenPage() {
78
- if (!testRoutesEnabled())
79
- (0, navigation_1.notFound)();
45
+ refuseWhenTokenPagesOff();
80
46
  return (0, jsx_runtime_1.jsx)(RefreshTokenPage_1.RefreshTokenPage, {});
81
47
  }
82
48
  function EmergencyLogoutPage() {
83
- if (!testRoutesEnabled())
84
- (0, navigation_1.notFound)();
85
49
  return (0, jsx_runtime_1.jsx)(EmergencyLogoutPage_1.EmergencyLogoutPage, {});
86
50
  }
@@ -7,8 +7,8 @@
7
7
  * and the block below is a four-line copy-paste that mounts every debug page
8
8
  * this package has. It previously carried no warning at all.
9
9
  *
10
- * NONE of these pages has an env gate of its own. Unlike the force-expire
11
- * handler (ENABLE_TEST_ROUTES) or the idp-token route
10
+ * NONE of these pages has an env gate of its own. Like the force-expire
11
+ * handler (no flag since PAY-3216) and unlike the idp-token route
12
12
  * (ENABLE_TOKEN_INSPECTOR), a mounted page is reachable exactly as far as your
13
13
  * middleware allows. MOUNTING IS THE GATE — so gate the WHOLE /test-env
14
14
  * segment, per environment, not page by page.
@@ -22,28 +22,19 @@
22
22
  * RefreshTokenPage Drives the destructive /api/test/force-expire.
23
23
  * EmergencyLogoutPage Mildest. Self-inflicted, recoverable.
24
24
  *
25
- * ── THESE PAGES ARE NOW ENV-GATED, FAIL-CLOSED (249259, BAPert ruling 35321) ──
26
- * SUPERSEDES the previous note here, which said a gate was impossible and that
27
- * mounting was the only control. That was correct about the PAGES and wrong as
28
- * a conclusion: it CANNOT be done inside these `'use client'` files, but it CAN
29
- * be done in a server component wrapping them. That wrapper now ships, and it
30
- * is what the four names below resolve to — see ./guarded.tsx.
31
- *
32
- * ENABLE_TEST_ROUTES === 'true' -> the page renders
33
- * anything else, including unset -> notFound()
34
- *
35
- * It is the DEFAULT, not an opt-in sibling: BAPert's ruling was explicit that
36
- * "an opt-in safety gate is one nobody opts into". The raw ungated components
37
- * are NOT exported. Same import path, same component names, same signatures —
38
- * consumers add one env var to their dev environments and change no code.
39
- *
40
- * ── MOUNTING IS STILL THE PRIMARY GATE ──────────────────────────────────────
41
- * Do not read the env var as making mounting safe. The wrapper is a backstop
42
- * for a mount that should not have happened. With ENABLE_TEST_ROUTES=true in an
43
- * environment where these are mounted you have exactly the exposure 248991
44
- * documented: a live session token, one click from the clipboard. If you are
45
- * not actively debugging, DO NOT MOUNT THESE AT ALL — the safest configuration
46
- * is still their absence, and the env var does not change that.
25
+ * ── NO ENV GATE (PAY-3216, Jon's order, 15 Nov) ─────────────────────────────
26
+ * The ENABLE_TEST_ROUTES gate that 1d40a8c (4.2.0, ruling 35321) put on these
27
+ * four pages is REMOVED: they render with no flag set, as before that commit.
28
+ * The ruling is withdrawn. MOUNTING IS THE GATE: a mounted page is reachable
29
+ * exactly as far as your middleware allows. Which of them a production site
30
+ * mounts is the site owner's choice.
31
+ *
32
+ * ── ONE PRODUCTION SWITCH (PAY-3220) ────────────────────────────────────────
33
+ * PAYEZ_TOKEN_PAGES_OFF, UNSET by default (every page works as above). Set on
34
+ * a production host, on Jon's word only, it makes JwtInspectPage,
35
+ * RefreshTokenPage and /api/test/force-expire answer 404. TestEnvPage and
36
+ * EmergencyLogoutPage are not covered. Read server-side per request; see
37
+ * lib/token-pages-switch for the values it accepts.
47
38
  *
48
39
  * Each page's own docstring carries the detail. If you are not actively
49
40
  * debugging, DO NOT MOUNT THESE AT ALL — the safest configuration is their
@@ -8,8 +8,8 @@
8
8
  * and the block below is a four-line copy-paste that mounts every debug page
9
9
  * this package has. It previously carried no warning at all.
10
10
  *
11
- * NONE of these pages has an env gate of its own. Unlike the force-expire
12
- * handler (ENABLE_TEST_ROUTES) or the idp-token route
11
+ * NONE of these pages has an env gate of its own. Like the force-expire
12
+ * handler (no flag since PAY-3216) and unlike the idp-token route
13
13
  * (ENABLE_TOKEN_INSPECTOR), a mounted page is reachable exactly as far as your
14
14
  * middleware allows. MOUNTING IS THE GATE — so gate the WHOLE /test-env
15
15
  * segment, per environment, not page by page.
@@ -23,28 +23,19 @@
23
23
  * RefreshTokenPage Drives the destructive /api/test/force-expire.
24
24
  * EmergencyLogoutPage Mildest. Self-inflicted, recoverable.
25
25
  *
26
- * ── THESE PAGES ARE NOW ENV-GATED, FAIL-CLOSED (249259, BAPert ruling 35321) ──
27
- * SUPERSEDES the previous note here, which said a gate was impossible and that
28
- * mounting was the only control. That was correct about the PAGES and wrong as
29
- * a conclusion: it CANNOT be done inside these `'use client'` files, but it CAN
30
- * be done in a server component wrapping them. That wrapper now ships, and it
31
- * is what the four names below resolve to — see ./guarded.tsx.
26
+ * ── NO ENV GATE (PAY-3216, Jon's order, 15 Nov) ─────────────────────────────
27
+ * The ENABLE_TEST_ROUTES gate that 1d40a8c (4.2.0, ruling 35321) put on these
28
+ * four pages is REMOVED: they render with no flag set, as before that commit.
29
+ * The ruling is withdrawn. MOUNTING IS THE GATE: a mounted page is reachable
30
+ * exactly as far as your middleware allows. Which of them a production site
31
+ * mounts is the site owner's choice.
32
32
  *
33
- * ENABLE_TEST_ROUTES === 'true' -> the page renders
34
- * anything else, including unset -> notFound()
35
- *
36
- * It is the DEFAULT, not an opt-in sibling: BAPert's ruling was explicit that
37
- * "an opt-in safety gate is one nobody opts into". The raw ungated components
38
- * are NOT exported. Same import path, same component names, same signatures —
39
- * consumers add one env var to their dev environments and change no code.
40
- *
41
- * ── MOUNTING IS STILL THE PRIMARY GATE ──────────────────────────────────────
42
- * Do not read the env var as making mounting safe. The wrapper is a backstop
43
- * for a mount that should not have happened. With ENABLE_TEST_ROUTES=true in an
44
- * environment where these are mounted you have exactly the exposure 248991
45
- * documented: a live session token, one click from the clipboard. If you are
46
- * not actively debugging, DO NOT MOUNT THESE AT ALL — the safest configuration
47
- * is still their absence, and the env var does not change that.
33
+ * ── ONE PRODUCTION SWITCH (PAY-3220) ────────────────────────────────────────
34
+ * PAYEZ_TOKEN_PAGES_OFF, UNSET by default (every page works as above). Set on
35
+ * a production host, on Jon's word only, it makes JwtInspectPage,
36
+ * RefreshTokenPage and /api/test/force-expire answer 404. TestEnvPage and
37
+ * EmergencyLogoutPage are not covered. Read server-side per request; see
38
+ * lib/token-pages-switch for the values it accepts.
48
39
  *
49
40
  * Each page's own docstring carries the detail. If you are not actively
50
41
  * debugging, DO NOT MOUNT THESE AT ALL — the safest configuration is their
@@ -67,9 +58,7 @@
67
58
  */
68
59
  Object.defineProperty(exports, "__esModule", { value: true });
69
60
  exports.EmergencyLogoutPage = exports.RefreshTokenPage = exports.JwtInspectPage = exports.default = exports.TestEnvPage = void 0;
70
- // GUARDED wrappers only. Exporting the raw components from './TestEnvPage'
71
- // etc. would reintroduce an ungated path to the same pages under a different
72
- // import, which is the "opt-in gate nobody opts into" the ruling rejected.
61
+ // Through the barrel (./guarded), as on main: it is PAY-3220's seam. Since PAY-3216 it renders each page with no flag.
73
62
  var guarded_1 = require("./guarded");
74
63
  Object.defineProperty(exports, "TestEnvPage", { enumerable: true, get: function () { return guarded_1.TestEnvPage; } });
75
64
  Object.defineProperty(exports, "default", { enumerable: true, get: function () { return guarded_1.TestEnvPage; } });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@payez/next-mvp",
3
- "version": "4.14.0",
3
+ "version": "4.15.0",
4
4
  "sideEffects": false,
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -130,6 +130,11 @@
130
130
  "require": "./dist/lib/session-store.js",
131
131
  "default": "./dist/lib/session-store.js"
132
132
  },
133
+ "./lib/idp-logout": {
134
+ "types": "./dist/lib/idp-logout.d.ts",
135
+ "require": "./dist/lib/idp-logout.js",
136
+ "default": "./dist/lib/idp-logout.js"
137
+ },
133
138
  "./lib/nextauth-secret": {
134
139
  "types": "./dist/lib/nextauth-secret.d.ts",
135
140
  "require": "./dist/lib/nextauth-secret.js",
@@ -155,6 +160,11 @@
155
160
  "require": "./dist/lib/app-slug.js",
156
161
  "default": "./dist/lib/app-slug.js"
157
162
  },
163
+ "./lib/first-enrol": {
164
+ "types": "./dist/lib/first-enrol.d.ts",
165
+ "require": "./dist/lib/first-enrol.js",
166
+ "default": "./dist/lib/first-enrol.js"
167
+ },
158
168
  "./api-handlers/anon/preferences": {
159
169
  "types": "./dist/api-handlers/anon/preferences.d.ts",
160
170
  "require": "./dist/api-handlers/anon/preferences.js",
@@ -37,10 +37,18 @@ export async function POST(req: NextRequest) {
37
37
  );
38
38
  }
39
39
 
40
+ // PAY-3214: the browser gets ONLY `confirmed` and `stepped_up`. When the IdP steps the session up it
41
+ // re-mints and answers { confirmed, stepped_up: true, access_token, refresh_token, token_type }; relaying
42
+ // `data` whole put the person's bearer and refresh token in a browser-readable body. The pair is DROPPED
43
+ // here, not stored: storing it would change the session's MFA state from an ENROLMENT, which
44
+ // totp-verify.ts's header forbids, and that is a ruling not yet given (BAPert-MB 105226 s3-s4). With the
45
+ // pair dropped the session keeps its own token, as on the IdP's own `stepped_up: false` path.
46
+ // `stepped_up` is passed back as the IdP said it (its meaning, with the pair dropped, is s3 Q2 of that ruling).
40
47
  const bodyJson: any = result.json;
41
- if (bodyJson && typeof bodyJson === 'object' && 'success' in bodyJson && 'data' in bodyJson) {
42
- if (bodyJson.success === true) return NextResponse.json(bodyJson.data, { status: 200, headers: NO_STORE });
43
- return NextResponse.json(bodyJson, { status: 200, headers: NO_STORE });
44
- }
45
- return NextResponse.json(bodyJson ?? {}, { status: 200, headers: NO_STORE });
48
+ const enveloped = bodyJson && typeof bodyJson === 'object' && 'success' in bodyJson && 'data' in bodyJson;
49
+ const data: any = (enveloped ? (bodyJson.success === true ? bodyJson.data : null) : bodyJson) ?? {};
50
+ return NextResponse.json(
51
+ { confirmed: data.confirmed === true, stepped_up: data.stepped_up === true },
52
+ { status: 200, headers: NO_STORE },
53
+ );
46
54
  }
@@ -3,6 +3,7 @@ import { getSession as getBetterAuthSession } from '../../server/auth';
3
3
  import { getSession, updateSession } from '../../lib/session-store';
4
4
  import { logExceptionWithCorrelation } from '../../lib/safe-error-response';
5
5
  import { tokenLogId } from '../../lib/token-log-id';
6
+ import { tokenPagesOff } from '../../lib/token-pages-switch';
6
7
 
7
8
  /**
8
9
  * ⚠️ DESTRUCTIVE TEST-ONLY HANDLER — DO NOT MOUNT IN PRODUCTION. ⚠️
@@ -20,19 +21,18 @@ import { tokenLogId } from '../../lib/token-log-id';
20
21
  * mounts a destructive endpoint. Every one of the three surfaces this route
21
22
  * ended up on followed exactly these instructions.
22
23
  *
23
- * The handler itself was fixed (244877: default-deny behind ENABLE_TEST_ROUTES,
24
- * plus the caller-supplied-token IDOR removed). But a fixed handler shipped
24
+ * The handler itself was fixed (244877: the caller-supplied-token IDOR removed;
25
+ * its ENABLE_TEST_ROUTES gate was later removed again by PAY-3216). But a fixed handler shipped
25
26
  * with unqualified mounting instructions still spreads: the guidance was never
26
27
  * corrected alongside the code, and a package that documents a dangerous route
27
28
  * as a plain convenience is instructing the weaker configuration.
28
29
  *
29
30
  * ── IF YOU MOUNT IT, MOUNT IT LIKE THIS ───────────────────────────────────
30
- * Required: set ENABLE_TEST_ROUTES=true IN THAT ENVIRONMENT ONLY. Absent, empty
31
- * or any other value default-DENIES (404). Never set it in production.
31
+ * There is no env flag (PAY-3216): a mounted handler answers. Mount it only
32
+ * where you mean it to answer.
32
33
  *
33
34
  * ```typescript
34
35
  * // app/api/test/force-expire/route.ts — TEST/DEV ENVIRONMENTS ONLY
35
- * // Requires ENABLE_TEST_ROUTES=true in that environment; 404s otherwise.
36
36
  * export { POST } from '@payez/next-mvp/api-handlers/test/force-expire';
37
37
  * ```
38
38
  *
@@ -40,67 +40,12 @@ import { tokenLogId } from '../../lib/token-log-id';
40
40
  * The safest configuration is the absence of the route.
41
41
  */
42
42
  export const POST = async (req: NextRequest) => {
43
- // 244877 (package-level): DEFAULT DENY behind ENABLE_TEST_ROUTES.
44
- //
45
- // This handler MUTATES session state — it rewrites idpAccessTokenExpires two
46
- // minutes into the past — and was gated on nothing but "any signed-in
47
- // session": no role check, no env kill-switch. Because consuming apps mount
48
- // it with a one-line re-export, EVERY consumer inherited that, including
49
- // deployments on production hosts.
50
- //
51
- // The card was raised against one app's local copy. Sweeping the pattern
52
- // rather than the instance found the same route in three places — a local
53
- // copy in the graft, a local copy in idealvibe.online, and idealresume.online
54
- // re-exporting THIS handler — so the fix belongs here, at the source all
55
- // three ultimately derive from, not only in whichever copy was noticed first.
56
- //
57
- // The gate is the FIRST statement, before any session decode, so a disabled
58
- // build does not reveal that the route is live. Absent, empty, or any value
59
- // other than 'true' disables it. Distinguishable body on purpose: a bare 404
60
- // reads as "route missing / build broken" and sends someone debugging
61
- // deployment instead of reading a flag.
62
- //
63
- // A consumer that genuinely needs this in a test environment sets
64
- // ENABLE_TEST_ROUTES=true there, which is the same posture the token
65
- // inspector already uses for a strictly LESS dangerous, read-only route.
66
- if (process.env.ENABLE_TEST_ROUTES !== 'true') {
67
- // DISCLOSURE IS CONDITIONAL ON ENVIRONMENT (QAPert-NightHawk 34539, after
68
- // QAPert caught that my first version shipped the very leak we had just
69
- // criticised elsewhere).
70
- //
71
- // My original body named the flag: "Set ENABLE_TEST_ROUTES=true...". That
72
- // is the same class of disclosure as TOKEN_INSPECTOR_DISABLED — it confirms
73
- // the route exists, names the capability, and hands a prober the exact
74
- // switch that turns it on. Having argued against that pattern one directory
75
- // over, shipping it here would have been incoherent.
76
- //
77
- // Deleting the message outright is also wrong: it is what stops a developer
78
- // losing an hour deciding whether their build is broken. The tension
79
- // resolves by AUDIENCE, not by picking a side:
80
- // production -> 404, EMPTY body. Nothing about the capability, the
81
- // flag, or even that a handler is here.
82
- // non-production -> 404 + the helpful message naming the flag.
83
- //
84
- // The operator affordance survives in production too, via the log line
85
- // below: logs are visible to operators and not to probers, which is exactly
86
- // the split the response body cannot make.
87
- console.warn(
88
- '[FORCE-EXPIRE] blocked: ENABLE_TEST_ROUTES is not "true" (test routes default-deny).',
89
- );
43
+ // PAY-3216 (Jon's order, 15 Nov): the ENABLE_TEST_ROUTES default-deny gate that 072d20e (244877) put here is
44
+ // REMOVED. The handler works with no flag set, as before that commit. Mounting is the gate.
90
45
 
91
- if (process.env.NODE_ENV === 'production') {
92
- return new NextResponse(null, { status: 404 });
93
- }
94
-
95
- return NextResponse.json(
96
- {
97
- error: 'TEST_ROUTES_DISABLED',
98
- message:
99
- 'Test routes are disabled in this build. Set ENABLE_TEST_ROUTES=true to enable them (development only).',
100
- },
101
- { status: 404 },
102
- );
103
- }
46
+ // PAY-3220 (ruled Win 105653 s1): the production switch for the token pages covers this route too, since the refresh
47
+ // page is its one caller. Unset, nothing changes. Set, an empty 404 before any session is read.
48
+ if (tokenPagesOff()) return new NextResponse(null, { status: 404 });
104
49
 
105
50
  try {
106
51
  const betterAuthSession = await getBetterAuthSession(req);
@@ -120,11 +65,9 @@ export const POST = async (req: NextRequest) => {
120
65
  // now ALWAYS the caller's own, which is also what the graft's local copy
121
66
  // has always done.
122
67
  //
123
- // This is defence in depth, not the only control: the ENABLE_TEST_ROUTES
124
- // gate above already 404s this handler by default. But the gate is
125
- // switched ON in test environments, and "unauthenticated users can expire
126
- // each other's sessions" is not acceptable there either — a control that
127
- // only holds while a feature is switched off is not an access control.
68
+ // A control that only held while a feature was switched off would not be
69
+ // an access control: "unauthenticated users can expire each other's
70
+ // sessions" is not acceptable in any environment that mounts this.
128
71
  //
129
72
  // Corrects NextPert's own scope claim on card 244877 ("a caller could only
130
73
  // ever expire their OWN session"): that was read off the graft's LOCAL
@@ -27,13 +27,13 @@ import {
27
27
  type IdpVerifiedClaims,
28
28
  } from './idp-oauth';
29
29
  import type { IDPClientConfig } from '../lib/idp-client-config';
30
- import { getIDPClientConfig } from '../lib/idp-client-config';
30
+ import { getIDPClientConfig, providerSecretEnvVar } from '../lib/idp-client-config';
31
31
  import { getAppSlug, assertAppSlugMatchesIdp } from '../lib/app-slug';
32
32
  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
+ import { errForLog, logSafeName } from '../lib/log-safe';
37
37
  import { warnIfMagicLinkBelowFix } from './better-auth-exposure';
38
38
  import { noStore } from '../lib/no-store';
39
39
 
@@ -277,6 +277,28 @@ export function buildIdpOAuthProviderConfig(appBaseUrl?: string): IdpOAuthProvid
277
277
  };
278
278
  }
279
279
 
280
+ /**
281
+ * PAY-3139: the credentials better-auth ITSELF requires before it can build a provider's authorize URL.
282
+ *
283
+ * Read from @better-auth/core 1.5.6 (social-providers/*.mjs, createAuthorizationURL): google throws
284
+ * `BetterAuthError("CLIENT_ID_AND_SECRET_REQUIRED")` when clientId OR clientSecret is empty. That is a plain
285
+ * throw, not an APIError, so the sign-in door answered a 500 with an EMPTY body (dev-93, 14 Nov: the IdP served
286
+ * the public, secret-redacted client config, so google's secret arrived empty). apple, facebook and github have
287
+ * no such guard (facebook signs in by id_token; apple's secret is the IdP's), so only the id is required there:
288
+ * an empty client id cannot produce a working authorize URL for any provider.
289
+ *
290
+ * Returns the missing FIELD NAMES, never a value.
291
+ */
292
+ export function missingProviderCredentials(
293
+ name: string,
294
+ oauth: { clientId?: string | null; clientSecret?: string | null }
295
+ ): Array<'clientId' | 'clientSecret'> {
296
+ const required: Array<'clientId' | 'clientSecret'> =
297
+ name === 'google' ? ['clientId', 'clientSecret'] : ['clientId'];
298
+ return required.filter((field) => String(oauth[field] ?? '').trim() === '');
299
+ }
300
+
301
+
280
302
  export function buildBetterAuthProviders(
281
303
  config: IDPClientConfig
282
304
  ): Record<string, BetterAuthSocialProvider> {
@@ -285,6 +307,26 @@ export function buildBetterAuthProviders(
285
307
  for (const oauth of config.oauthProviders || []) {
286
308
  if (!oauth.enabled) continue;
287
309
  const name = oauth.provider.toLowerCase();
310
+
311
+ /**
312
+ * PAY-3139: an ENABLED row that lacks a credential better-auth requires is LEFT OUT, loudly. Left in, its
313
+ * sign-in throws inside better-auth and the door answers an empty 500 that names nothing. Left out, the
314
+ * same door answers better-auth's typed 404 PROVIDER_NOT_FOUND, and this line names the provider and the
315
+ * missing field (never a value), once per instance build.
316
+ */
317
+ const missing = missingProviderCredentials(name, oauth);
318
+ if (missing.length > 0) {
319
+ const secretHint = missing.includes('clientSecret')
320
+ ? ` (the secret is read env-first from ${providerSecretEnvVar(oauth.provider)}, else from the IdP's ` +
321
+ `secret-bearing client config, which a client that sends no valid client_secret does not receive)`
322
+ : '';
323
+ console.error(
324
+ `[BETTER_AUTH] PROVIDER_CREDENTIALS_MISSING provider=${logSafeName(name)} missing=${missing.join(',')}: ` +
325
+ `left out of the sign-in providers, so its sign-in answers PROVIDER_NOT_FOUND until the ` +
326
+ `${missing.join(' and ')} reach this app${secretHint}`
327
+ );
328
+ continue;
329
+ }
288
330
  const additionalParams = oauth.additionalParams ?? {};
289
331
  const rawPrompt = additionalParams.prompt;
290
332
  const rawAccessType = additionalParams.accessType ?? additionalParams.access_type;
@@ -44,7 +44,7 @@ import 'server-only';
44
44
  import { createHash, randomUUID } from 'crypto';
45
45
  import { markNeverInResponse } from './never-in-response';
46
46
  import { clientSecretBody, warnIfProviderSecretRedacted } from './payez-client-secret';
47
- import { errForLog } from './log-safe';
47
+ import { errForLog, logSafeName } from './log-safe';
48
48
  // REDIS IS IMPORTED LAZILY, AND THAT IS LOAD-BEARING.
49
49
  // A module-scope `import redis from './redis'` drags ioredis into EVERY bundle that
50
50
  // touches this file - including instrumentation.ts. Next compiles the instrumentation
@@ -225,7 +225,7 @@ function applyEnvSecrets(config: IDPClientConfig): IDPClientConfig {
225
225
  const envVar = providerSecretEnvVar(p.provider);
226
226
  const fromEnv = process.env[envVar];
227
227
  const source: SecretSource = fromEnv ? `env:${envVar}` : p.clientSecret ? 'idp-config' : 'none';
228
- logSecretSource(`${p.provider} clientSecret`, source);
228
+ logSecretSource(`${logSafeName(p.provider)} clientSecret`, source);
229
229
  const clientSecret = fromEnv || p.clientSecret;
230
230
  markNeverInResponse(clientSecret);
231
231
  return { ...p, clientSecret };
@@ -28,3 +28,12 @@ export function errForLog(err: unknown): string {
28
28
  const code = closed ? ` code=${closed}` : '';
29
29
  return `error=${name}${status}${code}`;
30
30
  }
31
+
32
+ /**
33
+ * A provider name (or any other IdP-supplied identifier) for a log line: lower-cased, then anything outside [a-z0-9_-] becomes '?',
34
+ * so a name carrying a newline cannot forge a second log line (Nextpert-Scout 102543 LOW 1; the shared helper per 104626, used at
35
+ * every site that logs an IdP provider name: better-auth.ts, payez-client-secret.ts, idp-client-config.ts).
36
+ */
37
+ export function logSafeName(name: unknown): string {
38
+ return String(name ?? '').toLowerCase().replace(/[^a-z0-9_-]/g, '?');
39
+ }
@@ -5,6 +5,7 @@
5
5
  * what it sent before. The value is never logged and never leaves in a response (markNeverInResponse).
6
6
  */
7
7
  import { markNeverInResponse } from './never-in-response';
8
+ import { logSafeName } from './log-safe';
8
9
 
9
10
  export const CLIENT_SECRET_ENV = 'PAYEZ_CLIENT_SECRET';
10
11
 
@@ -34,6 +35,6 @@ export function warnIfProviderSecretRedacted(
34
35
  if (process.env[envVarFor(p.provider)]) continue;
35
36
  if (warnedProviders.has(p.provider)) continue;
36
37
  warnedProviders.add(p.provider);
37
- console.warn(`[IDP_CONFIG] PAYEZ_PROVIDER_SECRET_MISSING_REDACTED provider=${p.provider}`);
38
+ console.warn(`[IDP_CONFIG] PAYEZ_PROVIDER_SECRET_MISSING_REDACTED provider=${logSafeName(p.provider)}`);
38
39
  }
39
40
  }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * PAY-3220 (ruled Win 105653 s1, on NextPert-MB's Mac 105619): the ONE production switch for the two token-showing
3
+ * test-env pages (JwtInspectPage, RefreshTokenPage) and the route the refresh page drives (POST /api/test/force-expire).
4
+ *
5
+ * PAYEZ_TOKEN_PAGES_OFF unset or empty -> ON (every page works as today; the default everywhere)
6
+ * true / 1 / yes / on -> OFF (any letter case)
7
+ * false / 0 / no / off -> ON (any letter case), no warning
8
+ * anything else -> OFF, and ONE warning per process naming the variable
9
+ *
10
+ * Why an unrecognised value is OFF (the ruling's one change to the recommendation): nobody sets this variable except to
11
+ * turn the pages off, on Jon's word, on a production host. A mistyped attempt to turn them off must not leave
12
+ * token-showing pages on in production with only a log line to say so.
13
+ *
14
+ * Read from the server's process environment on every call, never captured at module scope (a module constant would be
15
+ * whatever the build host had). It is not NEXT_PUBLIC_, so it never reaches a client bundle, and no request input
16
+ * (cookie, header, query string, body, session) is consulted: only whoever can change the host's environment can turn it.
17
+ *
18
+ * Set ONLY on a production host, ONLY on Jon's word, on the day production is real. It is never in a tracked .env file.
19
+ * The switch is not counted as set until the production roll's served check reads both pages and force-expire back as
20
+ * 404 by request (BAPert-MB 105629 s3).
21
+ */
22
+ export const TOKEN_PAGES_OFF_ENV = 'PAYEZ_TOKEN_PAGES_OFF';
23
+
24
+ const OFF_WORDS = new Set(['true', '1', 'yes', 'on']);
25
+ const ON_WORDS = new Set(['false', '0', 'no', 'off']);
26
+ let warnedUnrecognised = false;
27
+
28
+ export function tokenPagesOff(): boolean {
29
+ const raw = process.env[TOKEN_PAGES_OFF_ENV];
30
+ if (raw === undefined) return false;
31
+ const value = raw.trim().toLowerCase();
32
+ if (value === '') return false;
33
+ if (OFF_WORDS.has(value)) return true;
34
+ if (ON_WORDS.has(value)) return false;
35
+ if (!warnedUnrecognised) {
36
+ warnedUnrecognised = true;
37
+ // The variable's NAME only. The value is not a secret, but nothing about it helps the reader of this line.
38
+ console.warn(
39
+ `[test-env] ${TOKEN_PAGES_OFF_ENV} is set to a value that is not recognised (true/1/yes/on or false/0/no/off); ` +
40
+ 'the token pages are OFF: jwt-inspect, refresh-token and POST /api/test/force-expire answer 404.',
41
+ );
42
+ }
43
+ return true;
44
+ }
@@ -35,23 +35,25 @@ function decodeJwtPayload(token: string): any {
35
35
  * IN PRODUCTION. ⚠️
36
36
  *
37
37
  * Session Inspector Page. Debug page for inspecting session data from Redis:
38
- * shows user info, roles, 2FA status, and TOKENS. It offers
39
- * COPY-TO-CLIPBOARD buttons for the live SESSION TOKEN and ACCESS TOKEN, and
40
- * reads them from the consuming app's `/api/internal/session-data` route.
38
+ * shows user info, roles, 2FA status, and the DECODED CLAIMS of the IDP access
39
+ * token, which it reads from the consuming app's `/api/internal/session-data`
40
+ * route. Credentials themselves are shown as tokenLogId + length ONLY: the
41
+ * Copy-to-clipboard and Reveal controls this docstring used to describe were
42
+ * removed by PAY-1470 (BAPert 59022, 59040), and test-env-no-token-material
43
+ * holds the page to that. (PAY-3220: this sentence was stale until now.)
41
44
  *
42
45
  * ────────────────────────── WHY THIS WARNING EXISTS (QAPert 35000 / 248991) ──────────────────────────
43
- * THIS PAGE HAS NO ENV GATE OF ITS OWN. Unlike the force-expire handler
44
- * (default-denied behind ENABLE_TEST_ROUTES) or the idp-token route (behind
45
- * ENABLE_TOKEN_INSPECTOR), a mounted page is reachable exactly as far as the
46
- * consuming app's own middleware allows. MOUNTING IT IS THE GATE. There is no
47
- * second line of defence behind it.
46
+ * Like the force-expire handler (no flag since PAY-3216) and unlike the
47
+ * idp-token route (behind ENABLE_TOKEN_INSPECTOR), a mounted page is reachable
48
+ * exactly as far as the consuming app's own middleware allows. MOUNTING IT IS
49
+ * THE GATE. The one switch on it is PAYEZ_TOKEN_PAGES_OFF (PAY-3220), unset by
50
+ * default and set only on a production host on Jon's word.
48
51
  *
49
- * AND THE CREDENTIAL IT HANDS OVER IS THE PRECONDITION FOR A REAL ATTACK ON
50
- * THIS SAME PACKAGE. The 246007 finding reduces to "a caller who KNOWS A
51
- * SESSION TOKEN can act on a session it does not own". The purpose of this
52
- * page is to put a live session token on the clipboard of whoever loads it. A
53
- * screen-share, a shoulder-surfed screenshot or a support call is enough.
54
- * Treat the rendered value as a live credential, because that is what it is.
52
+ * WHAT IT STILL HANDS OVER IS SENSITIVE. The decoded claims say who the
53
+ * signed-in person is, which roles they hold and how they authenticated, and
54
+ * the 246007 finding reduces to "a caller who KNOWS A SESSION TOKEN can act on
55
+ * a session it does not own": a page that once showed that token is the reason
56
+ * this one is treated as a production hazard.
55
57
  *
56
58
  * NOTE FOR MAINTAINERS: this warning must stay on THIS docstring, the one
57
59
  * attached to the exported declaration. An identical block placed higher in