@payez/next-mvp 4.1.20 → 4.4.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 (177) hide show
  1. package/POST-INSTALL.md +408 -0
  2. package/README.md +7 -0
  3. package/dist/api-handlers/account/recovery/initiate.js +35 -2
  4. package/dist/api-handlers/account/recovery/send-code.d.ts +0 -33
  5. package/dist/api-handlers/account/recovery/send-code.js +35 -2
  6. package/dist/api-handlers/account/recovery/verify-code.d.ts +0 -33
  7. package/dist/api-handlers/account/recovery/verify-code.js +35 -2
  8. package/dist/api-handlers/account/reset-password.js +35 -2
  9. package/dist/api-handlers/account/validate-password.js +21 -3
  10. package/dist/api-handlers/admin/analytics.js +2 -1
  11. package/dist/api-handlers/admin/audit.js +2 -1
  12. package/dist/api-handlers/admin/sessions.js +2 -1
  13. package/dist/api-handlers/admin/stats.js +2 -1
  14. package/dist/api-handlers/admin/users.js +2 -1
  15. package/dist/api-handlers/admin/vibe-data.js +2 -1
  16. package/dist/api-handlers/auth/jwks.js +10 -0
  17. package/dist/api-handlers/auth/login.js +5 -2
  18. package/dist/api-handlers/auth/passkey-signin.js +16 -1
  19. package/dist/api-handlers/auth/refresh.d.ts +23 -3
  20. package/dist/api-handlers/auth/refresh.js +107 -16
  21. package/dist/api-handlers/auth/signout.js +3 -2
  22. package/dist/api-handlers/auth/verify-code.js +5 -2
  23. package/dist/api-handlers/session/viability.js +27 -1
  24. package/dist/api-handlers/test/force-expire.d.ts +29 -15
  25. package/dist/api-handlers/test/force-expire.js +109 -14
  26. package/dist/auth/better-auth.d.ts +263 -6
  27. package/dist/auth/better-auth.js +437 -59
  28. package/dist/auth/utils/idp-client.js +1 -1
  29. package/dist/client/AuthContext.js +49 -17
  30. package/dist/components/account/UserAvatarMenu.js +12 -0
  31. package/dist/components/auth/ModeAwareLoginPage.js +10 -8
  32. package/dist/components/auth/ModeAwareSignupPage.js +5 -2
  33. package/dist/components/auth/TraditionalAuthSection.js +5 -1
  34. package/dist/hooks/useAuthSettings.js +14 -2
  35. package/dist/hooks/usePublicAuthSettings.d.ts +2 -2
  36. package/dist/hooks/usePublicAuthSettings.js +28 -14
  37. package/dist/lib/client-key.d.ts +61 -0
  38. package/dist/lib/client-key.js +72 -0
  39. package/dist/lib/demo-mode.js +2 -1
  40. package/dist/lib/ensure-fresh-access-token.js +12 -1
  41. package/dist/lib/idp-client-config.d.ts +25 -2
  42. package/dist/lib/idp-client-config.js +381 -36
  43. package/dist/lib/idp-fetch.d.ts +2 -0
  44. package/dist/lib/idp-fetch.js +31 -1
  45. package/dist/lib/idp-url-guard.d.ts +29 -0
  46. package/dist/lib/idp-url-guard.js +38 -0
  47. package/dist/lib/redis.js +77 -5
  48. package/dist/lib/session-cookie-shape.d.ts +69 -0
  49. package/dist/lib/session-cookie-shape.js +126 -0
  50. package/dist/lib/session-store.d.ts +35 -2
  51. package/dist/lib/session-store.js +135 -4
  52. package/dist/lib/single-flight.d.ts +35 -0
  53. package/dist/lib/single-flight.js +30 -0
  54. package/dist/lib/token-lifecycle.js +12 -0
  55. package/dist/middleware/create-middleware.js +12 -0
  56. package/dist/models/SessionModel.d.ts +41 -0
  57. package/dist/models/SessionModel.js +9 -0
  58. package/dist/pages/admin-login/page.d.ts +1 -1
  59. package/dist/pages/admin-login/page.js +5 -41
  60. package/dist/pages/security/EnhancedSecurityPage.d.ts +35 -1
  61. package/dist/pages/security/EnhancedSecurityPage.js +42 -2
  62. package/dist/pages/security/index.d.ts +1 -0
  63. package/dist/pages/security/index.js +3 -0
  64. package/dist/pages/test-env/EmergencyLogoutPage.d.ts +17 -5
  65. package/dist/pages/test-env/EmergencyLogoutPage.js +17 -5
  66. package/dist/pages/test-env/JwtInspectPage.d.ts +30 -5
  67. package/dist/pages/test-env/JwtInspectPage.js +166 -7
  68. package/dist/pages/test-env/RefreshTokenPage.d.ts +22 -6
  69. package/dist/pages/test-env/RefreshTokenPage.js +22 -6
  70. package/dist/pages/test-env/TestEnvPage.d.ts +16 -4
  71. package/dist/pages/test-env/TestEnvPage.js +16 -4
  72. package/dist/pages/test-env/guarded.d.ts +4 -0
  73. package/dist/pages/test-env/guarded.js +86 -0
  74. package/dist/pages/test-env/index.d.ts +50 -7
  75. package/dist/pages/test-env/index.js +58 -12
  76. package/dist/routes/auth/logout.js +3 -2
  77. package/dist/routes/auth/refresh.js +27 -1
  78. package/dist/routes/auth/session.d.ts +12 -3
  79. package/dist/routes/auth/session.js +92 -9
  80. package/dist/routes/auth/settings.d.ts +34 -4
  81. package/dist/routes/auth/settings.js +87 -33
  82. package/dist/routes/auth/viability.d.ts +1 -1
  83. package/dist/routes/auth/viability.js +122 -11
  84. package/dist/server/auth-guard.js +22 -15
  85. package/dist/server/auth.d.ts +170 -4
  86. package/dist/server/auth.js +42 -75
  87. package/dist/server/decode-session.js +84 -10
  88. package/dist/types/auth.d.ts +18 -0
  89. package/package.json +969 -958
  90. package/scripts/check-internal-url-usage.sh +73 -73
  91. package/scripts/check-publish-health.mjs +120 -0
  92. package/scripts/dev-broker.ps1 +71 -6
  93. package/scripts/print-client-config.mjs +162 -0
  94. package/scripts/server-only-stub.cjs +4 -0
  95. package/src/api-handlers/account/recovery/initiate.ts +35 -2
  96. package/src/api-handlers/account/recovery/send-code.ts +36 -2
  97. package/src/api-handlers/account/recovery/verify-code.ts +36 -2
  98. package/src/api-handlers/account/reset-password.ts +35 -2
  99. package/src/api-handlers/account/validate-password.ts +22 -3
  100. package/src/api-handlers/admin/analytics.ts +2 -1
  101. package/src/api-handlers/admin/audit.ts +2 -1
  102. package/src/api-handlers/admin/sessions.ts +2 -1
  103. package/src/api-handlers/admin/stats.ts +2 -1
  104. package/src/api-handlers/admin/users.ts +2 -1
  105. package/src/api-handlers/admin/vibe-data.ts +2 -1
  106. package/src/api-handlers/auth/jwks.ts +10 -0
  107. package/src/api-handlers/auth/login.ts +5 -2
  108. package/src/api-handlers/auth/passkey-signin.ts +112 -98
  109. package/src/api-handlers/auth/refresh.ts +112 -15
  110. package/src/api-handlers/auth/signout.ts +4 -3
  111. package/src/api-handlers/auth/verify-code.ts +5 -2
  112. package/src/api-handlers/mfa/backup-code-verify.ts +175 -175
  113. package/src/api-handlers/mfa/backup-codes.ts +41 -41
  114. package/src/api-handlers/mfa/methods.ts +46 -46
  115. package/src/api-handlers/mfa/totp-confirm.ts +42 -42
  116. package/src/api-handlers/mfa/totp-enroll.ts +42 -42
  117. package/src/api-handlers/passkeys/list.ts +31 -31
  118. package/src/api-handlers/passkeys/manage.ts +54 -54
  119. package/src/api-handlers/passkeys/register-options.ts +42 -42
  120. package/src/api-handlers/passkeys/register-verify.ts +41 -41
  121. package/src/api-handlers/session/viability.ts +29 -1
  122. package/src/api-handlers/test/force-expire.ts +118 -13
  123. package/src/auth/better-auth.ts +486 -51
  124. package/src/auth/utils/idp-client.ts +1 -1
  125. package/src/client/AuthContext.tsx +55 -19
  126. package/src/components/account/UserAvatarMenu.tsx +11 -0
  127. package/src/components/auth/ModeAwareLoginPage.tsx +40 -49
  128. package/src/components/auth/ModeAwareSignupPage.tsx +5 -2
  129. package/src/components/auth/TraditionalAuthSection.tsx +5 -1
  130. package/src/hooks/useAuthSettings.ts +14 -2
  131. package/src/hooks/usePublicAuthSettings.ts +38 -16
  132. package/src/lib/client-key.ts +88 -0
  133. package/src/lib/demo-mode.ts +2 -1
  134. package/src/lib/ensure-fresh-access-token.ts +12 -1
  135. package/src/lib/idp-client-config.ts +399 -36
  136. package/src/lib/idp-fetch.ts +36 -1
  137. package/src/lib/idp-url-guard.ts +37 -0
  138. package/src/lib/redis.ts +84 -6
  139. package/src/lib/session-cookie-shape.ts +184 -0
  140. package/src/lib/session-store.ts +152 -6
  141. package/src/lib/single-flight.ts +64 -0
  142. package/src/lib/token-lifecycle.ts +12 -0
  143. package/src/middleware/create-middleware.ts +11 -0
  144. package/src/models/SessionModel.ts +49 -0
  145. package/src/pages/admin-login/page.tsx +17 -127
  146. package/src/pages/security/EnhancedSecurityPage.tsx +95 -9
  147. package/src/pages/security/index.ts +3 -0
  148. package/src/pages/test-env/EmergencyLogoutPage.tsx +17 -5
  149. package/src/pages/test-env/JwtInspectPage.tsx +235 -20
  150. package/src/pages/test-env/RefreshTokenPage.tsx +22 -6
  151. package/src/pages/test-env/TestEnvPage.tsx +16 -4
  152. package/src/pages/test-env/guarded.tsx +81 -0
  153. package/src/pages/test-env/index.ts +59 -7
  154. package/src/routes/auth/logout.ts +4 -3
  155. package/src/routes/auth/passkey-signin.ts +13 -13
  156. package/src/routes/auth/refresh.ts +30 -1
  157. package/src/routes/auth/session.ts +98 -9
  158. package/src/routes/auth/settings.ts +144 -42
  159. package/src/routes/auth/viability.ts +129 -11
  160. package/src/routes/mfa/backup-code-verify.ts +1 -1
  161. package/src/routes/mfa/backup-codes.ts +10 -10
  162. package/src/routes/mfa/methods.ts +22 -22
  163. package/src/routes/mfa/totp-confirm.ts +10 -10
  164. package/src/routes/mfa/totp-enroll.ts +10 -10
  165. package/src/routes/mfa/totp-verify.ts +14 -14
  166. package/src/routes/passkeys/list.ts +10 -10
  167. package/src/routes/passkeys/manage.ts +23 -23
  168. package/src/routes/passkeys/register-options.ts +10 -10
  169. package/src/routes/passkeys/register-verify.ts +10 -10
  170. package/src/server/auth-guard.ts +24 -17
  171. package/src/server/auth.ts +54 -74
  172. package/src/server/decode-session.ts +95 -11
  173. package/src/templates/instrumentation.ts +21 -2
  174. package/src/types/auth.ts +20 -0
  175. package/dist/lib/auth-secret.d.ts +0 -17
  176. package/dist/lib/auth-secret.js +0 -116
  177. package/src/lib/auth-secret.ts +0 -137
@@ -0,0 +1,408 @@
1
+ # Post-Install Debt
2
+
3
+ **A running tab of everything a consuming site must do BY HAND after `npm install @payez/next-mvp`.**
4
+
5
+ This file exists because "works out of the box" is the product claim and it is currently
6
+ false. Every row below is work the package pushed onto the consumer. The tab is the backlog:
7
+ the goal is to empty it, not to document it more thoroughly.
8
+
9
+ Started 2026-08-26 from the UMI Browser (`umi_browser_website`, client 16) integration, where
10
+ every single row was hit in one sitting. UMI is the honest measure of the out-of-box
11
+ experience because nobody had pre-wired it — unlike idealresume.online and idealvibe.online,
12
+ which were built alongside the package and quietly absorbed each of these as they appeared.
13
+
14
+ **Add a row the moment you hand-write something the package could have shipped.** A step you
15
+ only discover by hitting it is the same defect class this codebase keeps paying for: present,
16
+ plausible and inert.
17
+
18
+ ---
19
+
20
+ ## Scoring
21
+
22
+ - **P1** — site is broken or insecure until done, and nothing tells you
23
+ - **P2** — site is degraded or wrong until done
24
+ - **P3** — friction; you get there, but slower than you should
25
+
26
+ **Fix class:**
27
+ - `auto` — the package could just do this, no consumer action at all
28
+ - `codemod` — a generator / `npx @payez/next-mvp init` could write it
29
+ - `doc` — genuinely a consumer choice; it only needs to be findable
30
+
31
+ ---
32
+
33
+ ## The tab
34
+
35
+ | # | What you must hand-write | P | Fix class | Status |
36
+ |---|---|---|---|---|
37
+ | 1 | 16 CSS variables in `globals.css` | P1 | auto | open |
38
+ | 2 | `/api/auth/[...nextauth]` route file | P1 | codemod | open |
39
+ | 3 | `/api/auth/settings` route file | P1 | codemod | open |
40
+ | 4 | `/login` page mounting `ModeAwareLoginPage` | P1 | codemod | open |
41
+ | 5 | `instrumentation.ts` + the node split | P1 | codemod | open |
42
+ | 6 | `next.config.js` transpile + tracing keys | P1 | doc | open |
43
+ | 7 | `--primary` vs `--color-primary` collision | P2 | auto | open |
44
+ | 8 | `/test-env/*` mounts + `ENABLE_TEST_ROUTES` | P2 | doc | open |
45
+ | 9 | Local user record (route + lib + hook) | P2 | codemod | open |
46
+ | 10 | `server-auth.ts` wrapper | P3 | auto | open |
47
+ | 11 | `REDIS_URL` and the required env set | P3 | doc | open |
48
+ | 12 | `providers.tsx` provider nesting | P3 | codemod | open |
49
+ | 13 | `/api/internal/session-data` — page ships, endpoint does not | P1 | auto | open |
50
+ | 14 | **`routes/auth/nextauth` is a trap — do not mount it** | P1 | auto | open |
51
+ | 15 | `id_token` dropped by a module-scoped ALS | P1 | auto | **CLOSED 4.3.4** |
52
+ | 16 | Tenant-side: `VIBE_COLLECTION` is doing double duty | P2 | doc | open |
53
+ | 17 | Tenant-side: vibe-client 0.2.3 sends the wrong filter shape | P2 | — | open (not this package) |
54
+
55
+ **Nothing is closed yet. There is no `postinstall` script and no `init` command.**
56
+
57
+ ---
58
+
59
+ ## 1. Sixteen CSS variables, and `ThemeProvider` emits none of them — P1
60
+
61
+ The worst row on the tab, because it is invisible: the site renders, it just looks wrong, and
62
+ the fallbacks make it look *deliberately* wrong rather than unstyled.
63
+
64
+ Measured 2026-08-26 against installed `dist`:
65
+
66
+ ```
67
+ components/ and pages/ READ 16 CSS variables
68
+ theme/ThemeProvider WRITES 0
69
+ ```
70
+
71
+ The sixteen:
72
+
73
+ ```
74
+ --background --bg-card --bg-default --border --border-default --card
75
+ --color-error --color-primary --foreground --input --muted --muted-foreground
76
+ --primary --text-muted --text-primary --text-secondary
77
+ ```
78
+
79
+ `ThemeProvider` takes a theme object and emits **nothing to CSS**. So all three sites
80
+ independently hand-authored the same variable block in their own `globals.css` — three
81
+ copies, already drifting.
82
+
83
+ **Fix:** `ThemeProvider` maps the theme object onto these sixteen and sets them. That is the
84
+ entire job it appears to exist to do. Until then a site that installs the package and uses a
85
+ component gets silent fallbacks.
86
+
87
+ ## 2–4. The package exports routes and pages; you mount every one — P1
88
+
89
+ `@payez/next-mvp` exports working handlers and pages. It cannot create files in your `app/`
90
+ directory, so **every one of them is a 404 until you write a re-export by hand** — and
91
+ nothing tells you which exist.
92
+
93
+ UMI had **zero of them mounted** and looked like a working site. The login page rendered a
94
+ demo gate against hardcoded strings; `/api/auth/*` did not exist at all, so the sign-in
95
+ screen's request for what the client offers 404'd and it drew nothing.
96
+
97
+ ```
98
+ app/api/auth/[...nextauth]/route.ts export { GET, POST } from '@payez/next-mvp/routes/auth/nextauth';
99
+ app/api/auth/settings/route.ts export { GET } from '@payez/next-mvp/routes/auth/settings';
100
+ app/login/page.tsx ModeAwareLoginPage + your branding
101
+ ```
102
+
103
+ **Fix:** `npx @payez/next-mvp init` writes these. Failing that, one page in the README that
104
+ lists every mountable export — there is currently no such list anywhere.
105
+
106
+ ## 5. `instrumentation.ts`, and the part the template gets wrong — P1
107
+
108
+ A template ships at `src/templates/instrumentation.ts` — the only template in the package —
109
+ but you still copy it in by hand, and it is **not** the shape that works.
110
+
111
+ Two things it does not tell you:
112
+
113
+ - **`instrumentation.ts` is compiled for BOTH the node and edge runtimes.** `NEXT_RUNTIME` is
114
+ a *runtime* guard; imports resolve at *build* time, so node-only dependencies still get
115
+ traced into the edge bundle and the build fails. The working shape is a thin
116
+ `instrumentation.ts` that dynamic-imports `instrumentation-node.ts`.
117
+ - **`await import()` does not stay dynamic under `module: CommonJS`.** TypeScript emits
118
+ `Promise.resolve().then(() => require(...))`, which is statically traced exactly like a
119
+ top-level import. Cost us three failed fixes.
120
+
121
+ Also: the template's catch block used to log *"Will retry initialization on first request"*
122
+ and continue. It does not retry. A site whose IDP handshake failed would serve requests
123
+ with no auth config and say so nowhere. Now `process.exit(1)`.
124
+
125
+ **Fix:** ship the two-file split as the template, and put both facts above in it as comments.
126
+
127
+ ## 6. `next.config.js` — two keys, and one that must never come back — P1
128
+
129
+ ```js
130
+ transpilePackages: ['@payez/next-mvp'],
131
+ outputFileTracingRoot: path.join(__dirname),
132
+ ```
133
+
134
+ Mentioned in two docs, in passing, not as a required step.
135
+
136
+ **Do not add a `webpack:` key to work around a bundler warning.** It disables Turbopack and
137
+ turns a cosmetic warning into a hard `UnhandledSchemeError`. A note now sits in UMI's
138
+ `next.config.js` saying so.
139
+
140
+ **Related, and the reason a day was lost:** `next dev` uses **webpack** by default in Next
141
+ 15.5. Turbopack is opt-in via `--turbopack`. Assuming otherwise sent three bundler fixes at
142
+ the wrong bundler.
143
+
144
+ ## 7. `--primary` and `--color-primary` are two names for one value — P2
145
+
146
+ Components read both. Nothing defines the relationship. `TraditionalAuthSection`'s submit
147
+ button hardcodes `var(--color-primary, #10b981)` — so a site that defines `--primary` and not
148
+ `--color-primary` gets a **green** button no matter what its brand colour is. UMI is
149
+ `#FF6B5B`; it defines both, as a workaround.
150
+
151
+ This is the CLAUDE.md rule the package itself breaks: **one value gets one name.** Pick
152
+ `--color-primary`, and drop the hardcoded emerald — a fallback that silently substitutes
153
+ another product's brand colour is worse than no fallback.
154
+
155
+ ## 8. `/test-env/*` — four pages, a flag, and a trap — P2
156
+
157
+ Four diagnostics ship (`TestEnvPage`, `JwtInspectPage`, `RefreshTokenPage`,
158
+ `EmergencyLogoutPage`). Documented in **zero** docs. idealvibe mounts five routes; UMI mounted
159
+ none and every URL 404'd.
160
+
161
+ They are exported **only** as guarded server components — the raw client pages are not
162
+ exported. The guard reads `ENABLE_TEST_ROUTES`, exact-match `'true'`, fail-closed, and returns
163
+ a real 404 rather than a 403 so probing cannot confirm the route exists. All of that is
164
+ correct and should not be softened.
165
+
166
+ **The trap:** putting `'use client'` on the page file that re-exports the guard. Non-`NEXT_PUBLIC_`
167
+ variables do not exist in the browser, so the guard reads `undefined` and denies **forever** —
168
+ a gate that greps as present and protects nothing. Hit on 2026-08-26.
169
+
170
+ These pages show a **live session token with a copy button**. `ENABLE_TEST_ROUTES=true` belongs
171
+ in `.env.development` and nowhere else. Verified both ways on UMI: flag off, all four 404 while
172
+ `/login` stays 200.
173
+
174
+ **Fix:** document the four exports, the server-component requirement, and the flag — together,
175
+ in one place, with the `'use client'` trap called out.
176
+
177
+ ## 9. The local user record — three files, written three times — P2
178
+
179
+ The IDP owns identity; the tenant app needs its own row so application data can join to a user.
180
+ The package ships **none** of it, so all three sites wrote the same three files:
181
+
182
+ ```
183
+ app/api/auth/record-login/route.ts upsert on sign-in
184
+ app/lib/vibe-user.ts find-or-create by idp_user_id
185
+ app/hooks/useLoginRecorder.ts fire once per session
186
+ ```
187
+
188
+ Two things every consumer must get right and has no way to know:
189
+
190
+ - **Identity is `idp_user_id`, never email.** Email changes and gets reused; matching on it
191
+ silently merges two people.
192
+ - **The effect needs a ref guard.** Without one, a re-render or Fast Refresh posts again —
193
+ harmless for the row, but it inflates `login_count`, which is then wrong in a way nobody
194
+ would think to question.
195
+
196
+ The app-specific parts (tiers, credits, milestones — idealresume's runs to 1,567 lines) are
197
+ genuinely the site's. The find-or-create core is not.
198
+
199
+ ## 10. `server-auth.ts` — a wrapper over one export — P3
200
+
201
+ Every site writes the same ~60-line wrapper over `getFreshIdpToken` to assemble the refresh
202
+ config from env and normalise the result. Package-side, this is a default config reader.
203
+
204
+ ## 11. Env vars — no single list, no startup validation of the set — P3
205
+
206
+ `IDP_URL`, `CLIENT_ID`, `REFRESH_ENDPOINT`, `REDIS_URL`, `ENABLE_TEST_ROUTES`, plus the broker
207
+ secret. `REDIS_URL` was missing from UMI and surfaced as an unrelated-looking failure.
208
+
209
+ **`CLIENT_ID` is the only spelling.** Until 4.2.5 the package accepted `CLIENT_ID`,
210
+ `NEXT_PUBLIC_CLIENT_ID` and `NEXT_CLIENT_ID` for one value; that cost a live defect. All six
211
+ read sites are collapsed and `idp-client-config.ts` carries the banner. Do not reintroduce a
212
+ second name here.
213
+
214
+ **Fix:** ship `.env.example` with every variable, what reads it, and whether it is required.
215
+
216
+ ## 12. `providers.tsx` — nesting order matters and is unstated — P3
217
+
218
+ ```
219
+ AuthProvider → LoginRecorderProvider → ThemeProvider
220
+ ```
221
+
222
+ `ModeAwareLoginPage` reads `useAuthConfig()`, so without `AuthProvider` the sign-in screen
223
+ renders **no controls at all** — no Google button, no email form — even while
224
+ `/api/auth/settings` correctly reports `enabledProviders: ["google"]`. It fails as a blank
225
+ card, which reads as a styling problem.
226
+
227
+ **Do not set `mode` in a site's auth config.** What sign-in offers comes from the IDP via
228
+ `/api/auth/settings` (4.3.0+). A site hardcoding its own auth mode is how a login screen and
229
+ its own IDP end up disagreeing.
230
+
231
+ ## 13. The Session Inspector fetches an endpoint the package does not ship — P1
232
+
233
+ `JwtInspectPage` fetches `/api/internal/session-data`. **No such handler exists in the
234
+ package and none is exported.** Line 39 of the page says the quiet part — "reads them from
235
+ the *consuming app's* `/api/internal/session-data` route" — which is a defensible design
236
+ call (a session record's shape is the app's business) documented in the one place no
237
+ consumer will ever read: the source of a page they did not write.
238
+
239
+ **It fails invisibly**, which is the real defect. The page does:
240
+
241
+ ```ts
242
+ fetch('/api/internal/session-data').then(r => r.ok ? r.json() : null).catch(() => {})
243
+ ```
244
+
245
+ A 404 and a session with no data render **identically**: every field `N/A`, and
246
+ `Error: N/A` underneath, which reads as "no error". The inspector looked like it was
247
+ reporting a broken session. It was reporting a missing route. Found only by watching the
248
+ network tab.
249
+
250
+ UMI now owns `app/api/internal/session-data/route.ts`. Two things any implementation must
251
+ get right:
252
+
253
+ - **The 16 field names are a contract** (`JwtInspectPage` reads `ss.userId`, `ss.roles`,
254
+ `ss.idpAccessToken`, …). Rename one and that row silently goes `N/A` forever.
255
+ - **It returns the live IDP access AND refresh token.** The package's own `better-auth.ts`
256
+ names this exact endpoint as the leak path it refuses to put `id_token` behind. UMI gates
257
+ it on `ENABLE_TEST_ROUTES` — same fail-closed exact-match, read at request time, 404 not
258
+ 403. Verified both ways.
259
+
260
+ **Fix:** either ship the route (it is mechanical — `getSession` + `getSession` from
261
+ `lib/session-store`, both already exported), or make the page say `endpoint missing` instead
262
+ of swallowing the 404. Preferably both.
263
+
264
+ ## 14. `routes/auth/nextauth` is a trap. Do not mount it. — P1
265
+
266
+ **This one cost the most and looked the most correct.** The package ships
267
+ `routes/auth/nextauth`, its own docblock calls it "Ready-to-Use", and its usage example is
268
+ the obvious one-liner:
269
+
270
+ ```ts
271
+ export { GET, POST } from '@payez/next-mvp/routes/auth/nextauth'; // WRONG
272
+ ```
273
+
274
+ It signs users in. It never provisions them.
275
+
276
+ ```
277
+ routes/auth/nextauth GET -> toNextJsHandler(auth) — signs in. Stops.
278
+ createAuthGetHandler GET -> ba.GET + IDP token exchange — signs in AND provisions.
279
+ ```
280
+
281
+ Only `createAuthGetHandler` runs `exchangeOAuthForIdpTokens` after the OAuth callback. With
282
+ the re-export, Better Auth creates a **perfectly convincing session** — cookie, user id,
283
+ email, Google avatar — and the Redis record holding the IDP access token, refresh token and
284
+ roles is **never written**.
285
+
286
+ The damage surfaces nowhere near the cause:
287
+
288
+ - `/test-env/jwt-inspect` → `NO_REDIS_SESSION`, no tokens, no roles
289
+ - anything calling `getFreshIdpToken` → `NO_SESSION` **for a user who is visibly signed in**
290
+ - `record-login` → 401, so the app never creates its local user row
291
+
292
+ Correct wiring, and what idealvibe.online and idealresume.online **both** hand-write:
293
+
294
+ ```ts
295
+ export const GET = createAuthGetHandler('/login'); // your login path
296
+ export async function POST(request: Request) {
297
+ const ba = await getBetterAuthHandler();
298
+ return ba!.POST(request);
299
+ }
300
+ ```
301
+
302
+ Neither working site uses the re-export. That is not boilerplate they failed to factor out —
303
+ it is the only wiring that works.
304
+
305
+ **This row is also a correction to row 2 of this tab.** Row 2's advice — prefer the package
306
+ re-export over copying another site's file — is right for `settings` and wrong here, and I
307
+ followed it into this exact bug on UMI. A codemod that generates the "obvious" re-export
308
+ would ship this defect to every new consumer.
309
+
310
+ **Fix:** `routes/auth/nextauth` should either export the exchanging handler or refuse to
311
+ exist. A "Ready-to-Use" export that yields a half-provisioned session is worse than no
312
+ export, because it looks like the sanctioned path.
313
+
314
+ ## 15. `id_token` was dropped because the ALS was module-scoped — P1, CLOSED in 4.3.4
315
+
316
+ The most expensive one, and the package's own fault end to end.
317
+
318
+ `state` (the Better Auth instance) was moved to `globalThis` by 181814 **because this module
319
+ loads more than once per process** — instrumentation and the auth route are separate chunks
320
+ with separate registries. `idTokenScope`, the AsyncLocalStorage that carries the provider's
321
+ raw `id_token` from `getUserInfo` to the IDP exchange, was left in module scope.
322
+
323
+ So the two copies shared the instance and **not** the scope:
324
+
325
+ ```
326
+ copy A builds the instance -> the getUserInfo closure captures A's idTokenScope
327
+ copy B serves the callback -> createAuthGetHandler runs .run({}) on B's
328
+ getUserInfo fires, reads A -> getStore() undefined, `if (scope)` skips SILENTLY
329
+ the exchange reads B -> empty, so id_token is omitted from the body
330
+ ```
331
+
332
+ Sign-in succeeds at every visible step — cookie, user id, email, avatar. The IDP then
333
+ answers `401 UNVERIFIED_ASSERTION`, and its own log names the reason:
334
+ `reason="id_token missing"` (idp.payez.net, correlationId 0HNO2755RESR1, 2026-08-26).
335
+
336
+ **The consequence is a security control that shipped as a no-op.** 472fb75c added the raw
337
+ `id_token` because `/api/ExternalAuth/oauth-callback` would mint an IDP access+refresh pair
338
+ for any *asserted* email, on a public LoadBalancer — the only verifiable field in that body
339
+ is the provider's signed token. Wherever this module double-loads, that fix never arrived.
340
+
341
+ Fixed in 4.3.4: `idTokenScope` now lives on `globalThis` under the same pattern as `state`,
342
+ and the silent `if (scope)` skip warns and names its two causes.
343
+
344
+ **The lesson generalises past this bug: shared instance + per-copy request scope is the worst
345
+ combination.** Everything looks wired, and the one field that proves identity is dropped. If
346
+ any other module-scoped singleton in this package participates in a request, audit it the
347
+ same way.
348
+
349
+ ## 16. Tenant-side: one env var, two unrelated jobs — P2
350
+
351
+ Not this package's bug, but every consumer that has a marketing form will hit it.
352
+
353
+ `createVibeClient` resolves the collection as
354
+ `config.defaultCollection || process.env.VIBE_COLLECTION || 'vibe_app'`. The library default
355
+ is already correct. UMI set `VIBE_COLLECTION=payez_contact_list` for its **waitlist form**,
356
+ and that variable is global to the client — so it silently retargeted every collection call
357
+ in the app, including user identity. Sign-in queried the waitlist's collection for a `users`
358
+ table that does not live there.
359
+
360
+ Same rule as `CLIENT_ID`, from the other direction: there, one value had three names; here,
361
+ one name carries two values. Pin the collection at the call site.
362
+
363
+ ## 17. Tenant-side: vibe-client 0.2.3 sends a filter shape the API rejects — P2
364
+
365
+ `list({ filter })` serialises through `convertFiltersToVibeFormat`, which returns an **array**
366
+ `[{ field, operator, value }]`. `PayEz.Vibe.Public.Api` binds `filter` to a single
367
+ `VibeFilterNode` **object** (`VibeQueryModels.cs:63`), so the request fails to deserialise
368
+ before the query builder ever runs:
369
+
370
+ ```
371
+ 400 VALIDATION_ERROR $.filter
372
+ "The JSON value could not be converted to ...QueryBuilder.VibeFilterNode"
373
+ ```
374
+
375
+ The API is not being arbitrary — `VibeQueryBuilder.cs:355` documents this exact query as
376
+ supported (`{ "idp_user_id": { "eq": 22 } }`), and :94 accepts the flat
377
+ `{ field, operator, value }` form too. The array wrapper is the whole defect, and the API
378
+ change is deliberate.
379
+
380
+ **This affects every filtered query from 0.2.3, on all three sites.** UMI was only the first
381
+ to make one — measured in the IDP proxy logs, UMI's client was the sole source of `$.filter`
382
+ 400s in 24h while idealresume queried cleanly, because idealresume's calls in that window did
383
+ not filter.
384
+
385
+ UMI hand-rolls the single lookup against the proxy with the correct node, reusing the
386
+ library's `resolveConfig()`. That is a workaround with a cost: it duplicates the HMAC recipe
387
+ (`timestamp|method|endpoint`, key base64-decoded) because `generateHmacSignature` is not
388
+ exported, so a signing change in the library breaks that call with a 401. **The real fix is a
389
+ vibe-client release that emits a `VibeFilterNode`**; its source is not in this workspace.
390
+
391
+ ---
392
+
393
+ ## What the tab says about the package
394
+
395
+ Fourteen rows; **eleven are `auto` or `codemod`** — the package could do them, or generate
396
+ them, with no judgement call from the consumer. Only three are real consumer choices.
397
+
398
+ The three P1 route/page rows share one root cause: **the package can export a thing but cannot
399
+ mount it**, and nothing enumerates what is mountable. That is one fix, not three — a list, or
400
+ an `init` command that writes the re-exports.
401
+
402
+ Eight of the fourteen appear in **no doc at all**. `QUICK_START.md` is a stub with a single
403
+ heading. Row 14 is worse than undocumented — the wrong path is the one with the usage example.
404
+
405
+ The recurring shape, which is the same one this codebase keeps paying for: **things present,
406
+ plausible and inert.** A `ThemeProvider` that provides no theme. A template whose comment
407
+ promises a retry that never happens. A guard that greps as a gate and denies everything. An
408
+ exported page nothing mounts. None of them announce themselves; all of them look done.
package/README.md CHANGED
@@ -6,6 +6,13 @@ Drop-in authentication for Next.js. One package, zero secret management in produ
6
6
  npm install @payez/next-mvp next-auth
7
7
  ```
8
8
 
9
+ > **Installing is not finishing.** There is no `postinstall` step and no generator, so the
10
+ > package cannot mount its own routes or pages into your `app/` directory, and it does not
11
+ > emit the CSS variables its components read. **[POST-INSTALL.md](./POST-INSTALL.md) is the
12
+ > complete list of what you must still do by hand** — including three that leave the site
13
+ > broken with nothing to tell you why: the auth routes, the login page, and the sixteen theme
14
+ > variables. Read it before you conclude something is wrong with the package.
15
+
9
16
  ## How Secrets Work
10
17
 
11
18
  You never manage signing keys or OAuth credentials directly. The package resolves them automatically at startup based on your environment.
@@ -2,20 +2,53 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.POST = POST;
4
4
  const server_1 = require("next/server");
5
+ const idp_client_config_1 = require("../../../lib/idp-client-config");
6
+ const client_key_1 = require("../../../lib/client-key");
5
7
  function getIdpUrl() {
6
8
  const url = process.env.IDP_URL;
7
9
  if (!url)
8
10
  throw new Error('[IDP_URL] FATAL: IDP_URL environment variable is REQUIRED.');
9
11
  return url;
10
12
  }
13
+ /**
14
+ * RECOVERY CLIENT KEY — cards 268012 (S12) and 268410.
15
+ *
16
+ * These four proxies populated NONE of PayEzClientIdResolver's six slots, so
17
+ * RequirePayEzUnAuthorized 404'd the request BEFORE the action ran and no
18
+ * recovery token was ever minted. That is why S12 failed at step 1.
19
+ *
20
+ * NOT `ENV_CONFIG.CLIENT_ID`, which is what card 268012 originally prescribed.
21
+ * That variable is the ENVIRONMENT-SPECIFIC NUMERIC id, and the IDP accepts a
22
+ * numeric with NO existence check (RequirePayEzUnAuthorizedAttribute.cs:31-33)
23
+ * while it VALIDATES a slug (:41-47). Hardcoding the numeric here would have
24
+ * added four more sites to the 268410 family and made a stale id answer for
25
+ * the WRONG TENANT on a password-reset path, silently. Slug-preferring, with
26
+ * the numeric fallback logged, is the same resolver the other nine sites use.
27
+ *
28
+ * SERVER VALUE WINS: client_id is applied AFTER the caller's payload spread.
29
+ * These are unauthenticated recovery endpoints, so a browser-supplied
30
+ * client_id would be a tenant-targeting lever. The caller does not get to
31
+ * choose which tenant their password reset is addressed to.
32
+ */
33
+ async function recoveryClientKey() {
34
+ try {
35
+ const config = await (0, idp_client_config_1.getIDPClientConfig)();
36
+ return (0, client_key_1.resolveClientKey)(config, 'account/recovery/initiate').key;
37
+ }
38
+ catch (e) {
39
+ console.error('[account/recovery/initiate] could not resolve IDP client config for client_id:', e);
40
+ return null;
41
+ }
42
+ }
11
43
  async function POST(req) {
12
44
  const IDP_URL = getIdpUrl();
13
45
  try {
14
46
  const { email } = await req.json();
47
+ const clientKey = await recoveryClientKey();
15
48
  const upstream = await fetch(`${IDP_URL}/api/Account/recovery/initiate`, {
16
49
  method: 'POST',
17
- headers: { 'Content-Type': 'application/json' },
18
- body: JSON.stringify({ email }),
50
+ headers: { 'Content-Type': 'application/json', ...(clientKey ? { 'X-Client-Id': clientKey } : {}) },
51
+ body: JSON.stringify({ email, ...(clientKey ? { client_id: clientKey } : {}) }),
19
52
  });
20
53
  const data = await upstream.json().catch(() => ({}));
21
54
  return server_1.NextResponse.json(data, { status: upstream.status });
@@ -1,35 +1,2 @@
1
1
  import { NextRequest, NextResponse } from 'next/server';
2
- /**
3
- * Account recovery — send-code
4
- *
5
- * WHY THIS WAS REWRITTEN (2026-08-02, swept out of QAPert defect #7)
6
- * This handler read the caller's bearer straight off the inbound request:
7
- *
8
- * const auth = req.headers.get('authorization') || '';
9
- * const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';
10
- * ... headers: { 'Authorization': `Bearer ${token}` }
11
- *
12
- * Browsers send a session COOKIE and no Authorization header, so `token` was
13
- * ALWAYS the empty string and this always called the IDP with a literal
14
- * "Bearer " and nothing after it.
15
- *
16
- * THAT IS WORSE THAN THE profile.ts VERSION OF THIS BUG, in two ways:
17
- *
18
- * 1. profile.ts at least 401'd when the header was missing. This one has no
19
- * check at all — `|| ''` swallows the absence — so it proceeds to call
20
- * upstream with an empty credential and returns the IDP's rejection as an
21
- * opaque failure. The user sees recovery "not working", not "not
22
- * authenticated", and the log shows an upstream error rather than an auth
23
- * one. The defect hides itself.
24
- * 2. It is BOTH HALVES of account recovery — send-code and verify-code — so
25
- * the entire recovery path is unreachable, not merely degraded. This is
26
- * the flow a locked-out user reaches for, i.e. it fails exactly when it is
27
- * the only thing left that could help them.
28
- *
29
- * Fixed the same way as profile.ts and masked-info.ts: idpFetchJSON resolves
30
- * the caller's token from the server-side session, so the cookie works and the
31
- * token is never handed to browser JavaScript.
32
- *
33
- * @package @payez/next-mvp
34
- */
35
2
  export declare function POST(req: NextRequest): Promise<NextResponse<any>>;
@@ -4,6 +4,8 @@ exports.POST = POST;
4
4
  const server_1 = require("next/server");
5
5
  const idp_fetch_1 = require("../../../lib/idp-fetch");
6
6
  const env_1 = require("../../../config/env");
7
+ const idp_client_config_1 = require("../../../lib/idp-client-config");
8
+ const client_key_1 = require("../../../lib/client-key");
7
9
  /**
8
10
  * Account recovery — send-code
9
11
  *
@@ -37,6 +39,36 @@ const env_1 = require("../../../config/env");
37
39
  *
38
40
  * @package @payez/next-mvp
39
41
  */
42
+ /**
43
+ * RECOVERY CLIENT KEY — cards 268012 (S12) and 268410.
44
+ *
45
+ * These four proxies populated NONE of PayEzClientIdResolver's six slots, so
46
+ * RequirePayEzUnAuthorized 404'd the request BEFORE the action ran and no
47
+ * recovery token was ever minted. That is why S12 failed at step 1.
48
+ *
49
+ * NOT `ENV_CONFIG.CLIENT_ID`, which is what card 268012 originally prescribed.
50
+ * That variable is the ENVIRONMENT-SPECIFIC NUMERIC id, and the IDP accepts a
51
+ * numeric with NO existence check (RequirePayEzUnAuthorizedAttribute.cs:31-33)
52
+ * while it VALIDATES a slug (:41-47). Hardcoding the numeric here would have
53
+ * added four more sites to the 268410 family and made a stale id answer for
54
+ * the WRONG TENANT on a password-reset path, silently. Slug-preferring, with
55
+ * the numeric fallback logged, is the same resolver the other nine sites use.
56
+ *
57
+ * SERVER VALUE WINS: client_id is applied AFTER the caller's payload spread.
58
+ * These are unauthenticated recovery endpoints, so a browser-supplied
59
+ * client_id would be a tenant-targeting lever. The caller does not get to
60
+ * choose which tenant their password reset is addressed to.
61
+ */
62
+ async function recoveryClientKey() {
63
+ try {
64
+ const config = await (0, idp_client_config_1.getIDPClientConfig)();
65
+ return (0, client_key_1.resolveClientKey)(config, 'account/recovery/send-code').key;
66
+ }
67
+ catch (e) {
68
+ console.error('[account/recovery/send-code] could not resolve IDP client config for client_id:', e);
69
+ return null;
70
+ }
71
+ }
40
72
  async function POST(req) {
41
73
  let payload = {};
42
74
  try {
@@ -45,10 +77,11 @@ async function POST(req) {
45
77
  catch {
46
78
  return server_1.NextResponse.json({ success: false, error: 'invalid_request', message: 'A JSON body is required' }, { status: 400 });
47
79
  }
80
+ const clientKey = await recoveryClientKey();
48
81
  const result = await (0, idp_fetch_1.idpFetchJSON)(req, `${env_1.ENV_CONFIG.IDP_URL}/api/Account/recovery/send-code`, {
49
82
  method: 'POST',
50
- headers: { 'Content-Type': 'application/json' },
51
- body: JSON.stringify(payload),
83
+ headers: { 'Content-Type': 'application/json', ...(clientKey ? { 'X-Client-Id': clientKey } : {}) },
84
+ body: JSON.stringify({ ...payload, ...(clientKey ? { client_id: clientKey } : {}) }),
52
85
  });
53
86
  if (!result.ok) {
54
87
  return server_1.NextResponse.json({
@@ -1,35 +1,2 @@
1
1
  import { NextRequest, NextResponse } from 'next/server';
2
- /**
3
- * Account recovery — verify-code
4
- *
5
- * WHY THIS WAS REWRITTEN (2026-08-02, swept out of QAPert defect #7)
6
- * This handler read the caller's bearer straight off the inbound request:
7
- *
8
- * const auth = req.headers.get('authorization') || '';
9
- * const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';
10
- * ... headers: { 'Authorization': `Bearer ${token}` }
11
- *
12
- * Browsers send a session COOKIE and no Authorization header, so `token` was
13
- * ALWAYS the empty string and this always called the IDP with a literal
14
- * "Bearer " and nothing after it.
15
- *
16
- * THAT IS WORSE THAN THE profile.ts VERSION OF THIS BUG, in two ways:
17
- *
18
- * 1. profile.ts at least 401'd when the header was missing. This one has no
19
- * check at all — `|| ''` swallows the absence — so it proceeds to call
20
- * upstream with an empty credential and returns the IDP's rejection as an
21
- * opaque failure. The user sees recovery "not working", not "not
22
- * authenticated", and the log shows an upstream error rather than an auth
23
- * one. The defect hides itself.
24
- * 2. It is BOTH HALVES of account recovery — send-code and verify-code — so
25
- * the entire recovery path is unreachable, not merely degraded. This is
26
- * the flow a locked-out user reaches for, i.e. it fails exactly when it is
27
- * the only thing left that could help them.
28
- *
29
- * Fixed the same way as profile.ts and masked-info.ts: idpFetchJSON resolves
30
- * the caller's token from the server-side session, so the cookie works and the
31
- * token is never handed to browser JavaScript.
32
- *
33
- * @package @payez/next-mvp
34
- */
35
2
  export declare function POST(req: NextRequest): Promise<NextResponse<any>>;
@@ -4,6 +4,8 @@ exports.POST = POST;
4
4
  const server_1 = require("next/server");
5
5
  const idp_fetch_1 = require("../../../lib/idp-fetch");
6
6
  const env_1 = require("../../../config/env");
7
+ const idp_client_config_1 = require("../../../lib/idp-client-config");
8
+ const client_key_1 = require("../../../lib/client-key");
7
9
  /**
8
10
  * Account recovery — verify-code
9
11
  *
@@ -37,6 +39,36 @@ const env_1 = require("../../../config/env");
37
39
  *
38
40
  * @package @payez/next-mvp
39
41
  */
42
+ /**
43
+ * RECOVERY CLIENT KEY — cards 268012 (S12) and 268410.
44
+ *
45
+ * These four proxies populated NONE of PayEzClientIdResolver's six slots, so
46
+ * RequirePayEzUnAuthorized 404'd the request BEFORE the action ran and no
47
+ * recovery token was ever minted. That is why S12 failed at step 1.
48
+ *
49
+ * NOT `ENV_CONFIG.CLIENT_ID`, which is what card 268012 originally prescribed.
50
+ * That variable is the ENVIRONMENT-SPECIFIC NUMERIC id, and the IDP accepts a
51
+ * numeric with NO existence check (RequirePayEzUnAuthorizedAttribute.cs:31-33)
52
+ * while it VALIDATES a slug (:41-47). Hardcoding the numeric here would have
53
+ * added four more sites to the 268410 family and made a stale id answer for
54
+ * the WRONG TENANT on a password-reset path, silently. Slug-preferring, with
55
+ * the numeric fallback logged, is the same resolver the other nine sites use.
56
+ *
57
+ * SERVER VALUE WINS: client_id is applied AFTER the caller's payload spread.
58
+ * These are unauthenticated recovery endpoints, so a browser-supplied
59
+ * client_id would be a tenant-targeting lever. The caller does not get to
60
+ * choose which tenant their password reset is addressed to.
61
+ */
62
+ async function recoveryClientKey() {
63
+ try {
64
+ const config = await (0, idp_client_config_1.getIDPClientConfig)();
65
+ return (0, client_key_1.resolveClientKey)(config, 'account/recovery/verify-code').key;
66
+ }
67
+ catch (e) {
68
+ console.error('[account/recovery/verify-code] could not resolve IDP client config for client_id:', e);
69
+ return null;
70
+ }
71
+ }
40
72
  async function POST(req) {
41
73
  let payload = {};
42
74
  try {
@@ -45,10 +77,11 @@ async function POST(req) {
45
77
  catch {
46
78
  return server_1.NextResponse.json({ success: false, error: 'invalid_request', message: 'A JSON body is required' }, { status: 400 });
47
79
  }
80
+ const clientKey = await recoveryClientKey();
48
81
  const result = await (0, idp_fetch_1.idpFetchJSON)(req, `${env_1.ENV_CONFIG.IDP_URL}/api/Account/recovery/verify-code`, {
49
82
  method: 'POST',
50
- headers: { 'Content-Type': 'application/json' },
51
- body: JSON.stringify(payload),
83
+ headers: { 'Content-Type': 'application/json', ...(clientKey ? { 'X-Client-Id': clientKey } : {}) },
84
+ body: JSON.stringify({ ...payload, ...(clientKey ? { client_id: clientKey } : {}) }),
52
85
  });
53
86
  if (!result.ok) {
54
87
  return server_1.NextResponse.json({