@payez/next-mvp 4.4.4 → 4.4.6

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 (189) hide show
  1. package/README.md +10 -3
  2. package/dist/api/auth-handler.js +5 -3
  3. package/dist/api-handlers/account/change-password.js +6 -3
  4. package/dist/api-handlers/account/masked-info.js +2 -1
  5. package/dist/api-handlers/account/recovery/forward.d.ts +14 -0
  6. package/dist/api-handlers/account/recovery/forward.js +105 -0
  7. package/dist/api-handlers/account/recovery/initiate.js +4 -0
  8. package/dist/api-handlers/account/recovery/send-code.d.ts +12 -2
  9. package/dist/api-handlers/account/recovery/send-code.js +11 -84
  10. package/dist/api-handlers/account/recovery/verify-code.d.ts +11 -2
  11. package/dist/api-handlers/account/recovery/verify-code.js +10 -84
  12. package/dist/api-handlers/account/reset-password.js +24 -1
  13. package/dist/api-handlers/account/send-code.d.ts +1 -15
  14. package/dist/api-handlers/account/send-code.js +3 -6
  15. package/dist/api-handlers/account/update-phone.js +6 -1
  16. package/dist/api-handlers/account/validate-password.js +3 -1
  17. package/dist/api-handlers/account/verify-email.d.ts +1 -17
  18. package/dist/api-handlers/account/verify-email.js +4 -6
  19. package/dist/api-handlers/account/verify-sms.d.ts +1 -17
  20. package/dist/api-handlers/account/verify-sms.js +4 -6
  21. package/dist/api-handlers/admin/analytics.js +12 -4
  22. package/dist/api-handlers/admin/audit.js +12 -4
  23. package/dist/api-handlers/admin/redis-sessions.js +16 -8
  24. package/dist/api-handlers/admin/sessions.js +14 -6
  25. package/dist/api-handlers/admin/site-logs.js +39 -16
  26. package/dist/api-handlers/admin/stats.js +14 -4
  27. package/dist/api-handlers/admin/users.js +14 -6
  28. package/dist/api-handlers/admin/vibe-data.js +19 -11
  29. package/dist/api-handlers/auth/jwks.js +2 -1
  30. package/dist/api-handlers/auth/login.js +13 -4
  31. package/dist/api-handlers/auth/passkey-signin.js +6 -0
  32. package/dist/api-handlers/auth/refresh.js +38 -11
  33. package/dist/api-handlers/auth/signout.js +2 -1
  34. package/dist/api-handlers/auth/status.js +4 -1
  35. package/dist/api-handlers/auth/update-session.d.ts +25 -4
  36. package/dist/api-handlers/auth/update-session.js +38 -11
  37. package/dist/api-handlers/auth/validate.js +4 -2
  38. package/dist/api-handlers/auth/verify-code.js +3 -2
  39. package/dist/api-handlers/mfa/backup-code-verify.d.ts +1 -18
  40. package/dist/api-handlers/mfa/backup-code-verify.js +4 -6
  41. package/dist/api-handlers/mfa/backup-codes.js +2 -1
  42. package/dist/api-handlers/mfa/methods.js +2 -1
  43. package/dist/api-handlers/mfa/totp-confirm.js +2 -1
  44. package/dist/api-handlers/mfa/totp-enroll.js +2 -1
  45. package/dist/api-handlers/mfa/totp-verify.d.ts +1 -19
  46. package/dist/api-handlers/mfa/totp-verify.js +4 -6
  47. package/dist/api-handlers/passkeys/list.js +2 -1
  48. package/dist/api-handlers/passkeys/manage.js +2 -1
  49. package/dist/api-handlers/passkeys/register-options.js +2 -1
  50. package/dist/api-handlers/passkeys/register-verify.js +2 -1
  51. package/dist/api-handlers/session/refresh-viability.d.ts +4 -2
  52. package/dist/api-handlers/session/refresh-viability.js +10 -7
  53. package/dist/api-handlers/session/viability.js +4 -3
  54. package/dist/api-handlers/test/force-expire.js +5 -3
  55. package/dist/auth/auth-decision.js +2 -1
  56. package/dist/auth/better-auth.d.ts +14 -0
  57. package/dist/auth/better-auth.js +33 -13
  58. package/dist/auth/utils/idp-client.js +2 -1
  59. package/dist/components/admin/DataBrowserTab.js +4 -1
  60. package/dist/components/admin/SessionsTab.js +9 -3
  61. package/dist/components/recovery/RequestedStep.d.ts +11 -0
  62. package/dist/components/recovery/RequestedStep.js +14 -0
  63. package/dist/components/recovery/SetPasswordStep.js +10 -1
  64. package/dist/lib/app-slug.d.ts +21 -0
  65. package/dist/lib/app-slug.js +51 -0
  66. package/dist/lib/ensure-fresh-access-token.d.ts +26 -0
  67. package/dist/lib/ensure-fresh-access-token.js +105 -4
  68. package/dist/lib/idp-client-config.d.ts +20 -0
  69. package/dist/lib/idp-client-config.js +65 -14
  70. package/dist/lib/idp-fetch.js +1 -1
  71. package/dist/lib/password-rules.d.ts +22 -0
  72. package/dist/lib/password-rules.js +43 -0
  73. package/dist/lib/safe-error-response.d.ts +67 -0
  74. package/dist/lib/safe-error-response.js +130 -0
  75. package/dist/lib/session-cookie-shape.d.ts +22 -19
  76. package/dist/lib/session-cookie-shape.js +14 -18
  77. package/dist/lib/session-store.d.ts +8 -1
  78. package/dist/lib/session-store.js +170 -24
  79. package/dist/lib/startup-init.d.ts +6 -0
  80. package/dist/lib/startup-init.js +30 -4
  81. package/dist/lib/token-lifecycle.js +2 -1
  82. package/dist/lib/token-log-id.d.ts +18 -0
  83. package/dist/lib/token-log-id.js +84 -0
  84. package/dist/lib/two-factor-verify-failure.d.ts +45 -0
  85. package/dist/lib/two-factor-verify-failure.js +143 -0
  86. package/dist/middleware/create-middleware.d.ts +2 -1
  87. package/dist/middleware/create-middleware.js +43 -8
  88. package/dist/pages/recovery/page.js +14 -4
  89. package/dist/pages/test-env/JwtInspectPage.js +7 -149
  90. package/dist/pages/test-env/RefreshTokenPage.js +4 -1
  91. package/dist/pages/test-env/redact-credentials.d.ts +6 -0
  92. package/dist/pages/test-env/redact-credentials.js +101 -0
  93. package/dist/pages/verify-code/page.js +2 -1
  94. package/dist/routes/auth/logout.d.ts +2 -1
  95. package/dist/routes/auth/logout.js +4 -2
  96. package/dist/routes/auth/session.d.ts +0 -3
  97. package/dist/routes/auth/session.js +16 -10
  98. package/dist/routes/auth/viability.d.ts +3 -2
  99. package/dist/routes/auth/viability.js +11 -7
  100. package/dist/server/auth.js +10 -6
  101. package/dist/server/decode-session.js +15 -26
  102. package/dist/server/verified-session-token.d.ts +13 -0
  103. package/dist/server/verified-session-token.js +103 -0
  104. package/dist/stores/authStore.js +14 -3
  105. package/dist/types/recovery.d.ts +1 -1
  106. package/dist/utils/api.d.ts +18 -0
  107. package/dist/utils/api.js +51 -9
  108. package/dist/vibe/sessions.js +2 -1
  109. package/package.json +2 -1
  110. package/scripts/verify-release-provenance.mjs +410 -0
  111. package/src/api/auth-handler.ts +5 -3
  112. package/src/api-handlers/account/change-password.ts +6 -3
  113. package/src/api-handlers/account/masked-info.ts +2 -1
  114. package/src/api-handlers/account/recovery/forward.ts +117 -0
  115. package/src/api-handlers/account/recovery/initiate.ts +3 -0
  116. package/src/api-handlers/account/recovery/send-code.ts +13 -99
  117. package/src/api-handlers/account/recovery/verify-code.ts +12 -99
  118. package/src/api-handlers/account/reset-password.ts +30 -1
  119. package/src/api-handlers/account/send-code.ts +3 -9
  120. package/src/api-handlers/account/update-phone.ts +6 -1
  121. package/src/api-handlers/account/validate-password.ts +3 -1
  122. package/src/api-handlers/account/verify-email.ts +4 -9
  123. package/src/api-handlers/account/verify-sms.ts +4 -9
  124. package/src/api-handlers/admin/analytics.ts +13 -5
  125. package/src/api-handlers/admin/audit.ts +13 -5
  126. package/src/api-handlers/admin/redis-sessions.ts +16 -8
  127. package/src/api-handlers/admin/sessions.ts +15 -7
  128. package/src/api-handlers/admin/site-logs.ts +39 -16
  129. package/src/api-handlers/admin/stats.ts +15 -5
  130. package/src/api-handlers/admin/users.ts +15 -7
  131. package/src/api-handlers/admin/vibe-data.ts +20 -12
  132. package/src/api-handlers/auth/jwks.ts +2 -1
  133. package/src/api-handlers/auth/login.ts +13 -4
  134. package/src/api-handlers/auth/passkey-signin.ts +4 -0
  135. package/src/api-handlers/auth/refresh.ts +38 -11
  136. package/src/api-handlers/auth/signout.ts +2 -1
  137. package/src/api-handlers/auth/status.ts +4 -1
  138. package/src/api-handlers/auth/update-session.ts +45 -11
  139. package/src/api-handlers/auth/validate.ts +4 -2
  140. package/src/api-handlers/auth/verify-code.ts +3 -2
  141. package/src/api-handlers/mfa/backup-code-verify.ts +4 -9
  142. package/src/api-handlers/mfa/backup-codes.ts +2 -1
  143. package/src/api-handlers/mfa/methods.ts +2 -1
  144. package/src/api-handlers/mfa/totp-confirm.ts +2 -1
  145. package/src/api-handlers/mfa/totp-enroll.ts +2 -1
  146. package/src/api-handlers/mfa/totp-verify.ts +4 -9
  147. package/src/api-handlers/passkeys/list.ts +2 -1
  148. package/src/api-handlers/passkeys/manage.ts +2 -1
  149. package/src/api-handlers/passkeys/register-options.ts +2 -1
  150. package/src/api-handlers/passkeys/register-verify.ts +2 -1
  151. package/src/api-handlers/session/refresh-viability.ts +10 -7
  152. package/src/api-handlers/session/viability.ts +4 -3
  153. package/src/api-handlers/test/force-expire.ts +5 -3
  154. package/src/auth/auth-decision.ts +2 -1
  155. package/src/auth/better-auth.ts +35 -10
  156. package/src/auth/utils/idp-client.ts +2 -1
  157. package/src/components/admin/DataBrowserTab.tsx +4 -1
  158. package/src/components/admin/SessionsTab.tsx +10 -3
  159. package/src/components/recovery/RequestedStep.tsx +30 -0
  160. package/src/components/recovery/SetPasswordStep.tsx +24 -3
  161. package/src/hooks/useViabilitySession.ts +2 -1
  162. package/src/lib/app-slug.ts +50 -0
  163. package/src/lib/ensure-fresh-access-token.ts +133 -4
  164. package/src/lib/idp-client-config.ts +90 -14
  165. package/src/lib/idp-fetch.ts +1 -1
  166. package/src/lib/password-rules.ts +50 -0
  167. package/src/lib/safe-error-response.ts +126 -0
  168. package/src/lib/session-cookie-shape.ts +23 -22
  169. package/src/lib/session-store.ts +167 -24
  170. package/src/lib/startup-init.ts +29 -4
  171. package/src/lib/token-lifecycle.ts +2 -1
  172. package/src/lib/token-log-id.ts +69 -0
  173. package/src/lib/two-factor-verify-failure.ts +167 -0
  174. package/src/middleware/create-middleware.ts +55 -9
  175. package/src/pages/recovery/page.tsx +21 -4
  176. package/src/pages/test-env/JwtInspectPage.tsx +11 -233
  177. package/src/pages/test-env/RefreshTokenPage.tsx +9 -4
  178. package/src/pages/test-env/redact-credentials.ts +96 -0
  179. package/src/pages/verify-code/page.tsx +2 -1
  180. package/src/routes/auth/logout.ts +4 -2
  181. package/src/routes/auth/session.ts +16 -10
  182. package/src/routes/auth/viability.ts +11 -7
  183. package/src/server/auth.ts +10 -6
  184. package/src/server/decode-session.ts +16 -26
  185. package/src/server/verified-session-token.ts +103 -0
  186. package/src/stores/authStore.ts +16 -5
  187. package/src/types/recovery.ts +1 -1
  188. package/src/utils/api.ts +47 -8
  189. package/src/vibe/sessions.ts +2 -1
@@ -0,0 +1,410 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * RELEASE PROVENANCE VERIFIER — 340024 / BAPert R1037 s2.
4
+ *
5
+ * WHY THIS EXISTS. Published 4.4.5 ships source that EXISTS IN NO COMMIT. Measured
6
+ * three ways; the decisive instrument was `git cat-file -e`, which resolves loose AND
7
+ * packed objects regardless of reachability — and 4.4.5's `src/auth/better-auth.ts`
8
+ * blob DOES NOT EXIST in the object store. Not a dangling object a reflog walk could
9
+ * recover: the bytes were never committed. The only shape consistent with that is a
10
+ * PUBLISH FROM A WORKING TREE WITH UNCOMMITTED EDITS.
11
+ *
12
+ * You cannot fix that after the fact — the source is gone. So R1037 s2 replaced the
13
+ * unanswerable question ("where did 4.4.5 come from?") with a pass/fail check on the
14
+ * artefact you are ABOUT to ship, which is this script:
15
+ *
16
+ * Every shipped file that is supposed to come from the repo must hash-match the
17
+ * blob recorded at the release commit FOR THAT PATH. Anything else = do not ship.
18
+ *
19
+ * ---------------------------------------------------------------------------------
20
+ * DESIGN NOTES (deliberate; do not "helpfully" soften these)
21
+ *
22
+ * 1. IT IS PATH-ANCHORED, NOT EXISTENCE-BASED. The gate was specified as "confirm the
23
+ * blob hash RESOLVES IN THE STORE". That is too weak for a pre-ship gate: a file
24
+ * from a DIFFERENT commit resolves perfectly and is still the wrong bytes. We
25
+ * compare against `<commit>:<path>` — the blob recorded at that path at that
26
+ * commit. Store existence is still computed, but only as a DIAGNOSIS on failures,
27
+ * because it separates two faults with different remediations:
28
+ *
29
+ * mismatch + hash EXISTS in store -> packed from the WRONG REVISION (re-checkout)
30
+ * mismatch + hash ABSENT from store -> UNCOMMITTED EDITS (this is the 4.4.5 disease)
31
+ *
32
+ * 2. IT CLASSIFIES, BECAUSE THE TARBALL IS NOT ALL SOURCE. Measured on real 4.4.5:
33
+ * 744 entries = 488 dist/ + 246 src/ + 7 scripts/ + package.json + 2 docs. `dist/`
34
+ * is GITIGNORED BUILD OUTPUT and can never hash-match a commit. A gate that failed
35
+ * on every unmatched file would report 488 failures on a PERFECTLY CLEAN publish,
36
+ * and a control that always fires is a control that gets switched off. So each
37
+ * shipped path is assigned to a declared class and checked accordingly.
38
+ *
39
+ * 3. UNKNOWN PATHS FAIL. If someone adds an entry to package.json "files", it lands in
40
+ * no class and this EXITS 1 rather than ignoring it. A verifier that silently skips
41
+ * what it does not recognise cannot be trusted to have checked anything. Fail closed.
42
+ *
43
+ * 4. MISSING FILES FAIL TOO. A file committed under a verified prefix but ABSENT from
44
+ * the tarball is a truncated publish — the same class of defect, opposite sign.
45
+ * Checked in both directions.
46
+ *
47
+ * 5. LINE ENDINGS ARE NORMALISED CRLF->LF BEFORE HASHING, and this is load-bearing:
48
+ * tarballs packed on Windows ship CRLF (measured: 1416 CRLF in the 4.4.5 tarball)
49
+ * while the repo blobs are LF (measured: 0). Comparing raw bytes reports every text
50
+ * file as a mismatch. Binary files (NUL byte present) are NEVER normalised.
51
+ *
52
+ * 6. `dist/` IS NOT PROVENANCE-VERIFIED AND THIS SCRIPT SAYS SO OUT LOUD. It is
53
+ * ENTAILED — built by prepack from sources this script proved clean — not verified.
54
+ * Anyone who wants dist verified needs a reproducible-build comparison, which is a
55
+ * different and more expensive control. Stating the limit is the point: a gate that
56
+ * overstates its coverage is worse than one that admits a gap, because the gap then
57
+ * goes unstaffed.
58
+ *
59
+ * 7. NO NETWORK, NO DEPENDENCIES. Reads a local .tgz and a local git object store.
60
+ * Same reasoning as check-publish-health.mjs's refusal to enter prepack: a release
61
+ * control that needs the network trades one outage for another.
62
+ *
63
+ * ---------------------------------------------------------------------------------
64
+ * Usage:
65
+ * npm run check:provenance -- --tarball <file.tgz> --commit <sha>
66
+ * node scripts/verify-release-provenance.mjs --tarball <file.tgz> --commit <sha>
67
+ * [--repo <path>] [--pkg-dir packages/next-mvp] [--json] [--quiet]
68
+ *
69
+ * Runs correctly from ANY cwd (repo root, package dir, or outside the repo):
70
+ * --repo/--pkg-dir default to this script's own location, not process.cwd().
71
+ *
72
+ * Exit: 0 = PASS every verifiable shipped file matches the commit
73
+ * 1 = FAIL mismatch / missing / unclassified -> RE-RUN, DO NOT SHIP
74
+ * 2 = INCONCLUSIVE could not read tarball or git -> NOT a pass
75
+ */
76
+
77
+ import { createHash } from 'node:crypto';
78
+ import { gunzipSync } from 'node:zlib';
79
+ import { readFileSync } from 'node:fs';
80
+ import { execFileSync } from 'node:child_process';
81
+ import { fileURLToPath } from 'node:url';
82
+ import path from 'node:path';
83
+
84
+ const EXIT_PASS = 0;
85
+ const EXIT_FAIL = 1;
86
+ const EXIT_INCONCLUSIVE = 2;
87
+
88
+ /* ------------------------------------------------------------------ classification */
89
+
90
+ /**
91
+ * Paths are relative to the tarball's "package/" root.
92
+ * Order matters: first match wins, so put specific paths before prefixes.
93
+ */
94
+ const CLASSES = [
95
+ // Rewritten by npm at pack time (_id, dist, gitHead, field normalisation).
96
+ // Never byte-matches its committed blob. Checked semantically instead.
97
+ { cls: 'MANIFEST', test: (p) => p === 'package.json' },
98
+
99
+ // Gitignored build output. See design note 6.
100
+ { cls: 'DERIVED', test: (p) => p.startsWith('dist/') },
101
+
102
+ // Committed source — the class this gate exists to protect.
103
+ { cls: 'VERIFIED', test: (p) => p.startsWith('src/') },
104
+ { cls: 'VERIFIED', test: (p) => p.startsWith('scripts/') },
105
+ { cls: 'VERIFIED', test: (p) => p === 'POST-INSTALL.md' },
106
+ // npm always ships README regardless of "files"; it is committed, so verify it.
107
+ { cls: 'VERIFIED', test: (p) => p === 'README.md' },
108
+ { cls: 'VERIFIED', test: (p) => p === 'LICENSE' || p === 'LICENCE' },
109
+ ];
110
+
111
+ function classify(path) {
112
+ for (const c of CLASSES) if (c.test(path)) return c.cls;
113
+ return 'UNCLASSIFIED'; // design note 3 — fail closed
114
+ }
115
+
116
+ /* ------------------------------------------------------------------------ tar read */
117
+
118
+ /**
119
+ * Minimal USTAR/GNU reader. We parse rather than shell out to `tar` so the check
120
+ * never writes a temp tree it might then read back differently (and so it behaves
121
+ * identically on the Windows seat and the Linux agent).
122
+ */
123
+ function readTar(buf) {
124
+ const entries = new Map();
125
+ let off = 0;
126
+ let longName = null;
127
+
128
+ const str = (b, s, n) => {
129
+ const slice = b.subarray(s, s + n);
130
+ const z = slice.indexOf(0);
131
+ return slice.subarray(0, z === -1 ? slice.length : z).toString('utf8');
132
+ };
133
+
134
+ while (off + 512 <= buf.length) {
135
+ const hdr = buf.subarray(off, off + 512);
136
+ if (hdr.every((b) => b === 0)) break; // end-of-archive
137
+
138
+ const name = str(hdr, 0, 100);
139
+ const sizeField = str(hdr, 124, 12).trim();
140
+ const size = parseInt(sizeField, 8) || 0;
141
+ const type = String.fromCharCode(hdr[156]);
142
+ const prefix = str(hdr, 345, 155);
143
+
144
+ off += 512;
145
+ const data = buf.subarray(off, off + size);
146
+ off += Math.ceil(size / 512) * 512;
147
+
148
+ if (type === 'L') {
149
+ // GNU long name: applies to the NEXT header.
150
+ longName = data.toString('utf8').replace(/\0+$/, '');
151
+ continue;
152
+ }
153
+
154
+ let full = longName ?? (prefix ? `${prefix}/${name}` : name);
155
+ longName = null;
156
+
157
+ if (type === '0' || type === '\0' || type === '') {
158
+ entries.set(full.replace(/\\/g, '/'), Buffer.from(data));
159
+ }
160
+ // directories (5), links, pax headers: not content, ignored deliberately
161
+ }
162
+ return entries;
163
+ }
164
+
165
+ /* ------------------------------------------------------------------------- hashing */
166
+
167
+ const isBinary = (buf) => buf.includes(0);
168
+
169
+ /** CRLF -> LF, text only. See design note 5. */
170
+ function normalise(buf) {
171
+ if (isBinary(buf)) return buf;
172
+ return Buffer.from(buf.toString('binary').replace(/\r\n/g, '\n'), 'binary');
173
+ }
174
+
175
+ /** git blob id = sha1("blob <bytelen>\0" + content) */
176
+ function gitBlobId(buf) {
177
+ return createHash('sha1')
178
+ .update(`blob ${buf.length}\0`, 'binary')
179
+ .update(buf)
180
+ .digest('hex');
181
+ }
182
+
183
+ /* ----------------------------------------------------------------------------- git */
184
+
185
+ function git(repo, args) {
186
+ return execFileSync('git', ['-C', repo, ...args], {
187
+ encoding: 'utf8',
188
+ maxBuffer: 64 * 1024 * 1024,
189
+ stdio: ['ignore', 'pipe', 'pipe'],
190
+ });
191
+ }
192
+
193
+ /** path (relative to pkgDir) -> blob sha, for one commit. ONE git call. */
194
+ function treeAtCommit(repo, commit, pkgDir) {
195
+ const out = git(repo, ['ls-tree', '-r', commit, '--', pkgDir]);
196
+ const map = new Map();
197
+ for (const line of out.split('\n')) {
198
+ if (!line.trim()) continue;
199
+ const m = line.match(/^(\d+) (\w+) ([0-9a-f]+)\t(.*)$/);
200
+ if (!m) continue;
201
+ const [, , objType, sha, fullPath] = m;
202
+ if (objType !== 'blob') continue;
203
+ const rel = fullPath.startsWith(pkgDir + '/') ? fullPath.slice(pkgDir.length + 1) : null;
204
+ if (rel) map.set(rel, sha);
205
+ }
206
+ return map;
207
+ }
208
+
209
+ function existsInStore(repo, sha) {
210
+ try {
211
+ git(repo, ['cat-file', '-e', `${sha}^{object}`]);
212
+ return true;
213
+ } catch {
214
+ return false;
215
+ }
216
+ }
217
+
218
+ /* ---------------------------------------------------------------------------- main */
219
+
220
+ /**
221
+ * Defaults must NOT come from process.cwd().
222
+ *
223
+ * MEASURED 2026-09-14: with repo defaulting to cwd, running this from the package
224
+ * directory -- which is exactly what an `npm run` script does -- makes git resolve
225
+ * `ls-tree -- packages/next-mvp` against packages/next-mvp itself, matching nothing.
226
+ * Result: "INCONCLUSIVE: no blobs under packages/next-mvp", exit 2, on a tarball that
227
+ * is in fact clean. A gate whose most natural invoker always answers INCONCLUSIVE is
228
+ * a gate that gets switched off.
229
+ *
230
+ * So anchor both defaults to THIS FILE's location: the repo is whatever toplevel the
231
+ * script lives in, and the package prefix is the script's parent relative to that
232
+ * toplevel. Correct from any cwd, and still correct if the package is ever moved.
233
+ * Both remain overridable by --repo / --pkg-dir.
234
+ */
235
+ function locateSelf() {
236
+ const fallback = { repo: process.cwd(), pkgDir: 'packages/next-mvp' };
237
+ try {
238
+ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
239
+ const pkgRoot = path.resolve(scriptDir, '..');
240
+ const toplevel = execFileSync('git', ['-C', pkgRoot, 'rev-parse', '--show-toplevel'], {
241
+ encoding: 'utf8',
242
+ stdio: ['ignore', 'pipe', 'pipe'],
243
+ }).trim();
244
+ if (!toplevel) return fallback;
245
+ const rel = path.relative(toplevel, pkgRoot).split(path.sep).join('/');
246
+ // rel === '' would mean the package IS the repo root; keep the explicit default.
247
+ return { repo: toplevel, pkgDir: rel || fallback.pkgDir };
248
+ } catch {
249
+ return fallback;
250
+ }
251
+ }
252
+
253
+ function parseArgs(argv) {
254
+ const self = locateSelf();
255
+ const a = { repo: self.repo, pkgDir: self.pkgDir, json: false, quiet: false };
256
+ for (let i = 0; i < argv.length; i++) {
257
+ const k = argv[i];
258
+ if (k === '--tarball') a.tarball = argv[++i];
259
+ else if (k === '--commit') a.commit = argv[++i];
260
+ else if (k === '--repo') a.repo = argv[++i];
261
+ else if (k === '--pkg-dir') a.pkgDir = argv[++i];
262
+ else if (k === '--json') a.json = true;
263
+ else if (k === '--quiet') a.quiet = true;
264
+ else if (k === '--help' || k === '-h') a.help = true;
265
+ else throw new Error(`unknown argument: ${k}`);
266
+ }
267
+ return a;
268
+ }
269
+
270
+ function main() {
271
+ let args;
272
+ try {
273
+ args = parseArgs(process.argv.slice(2));
274
+ } catch (e) {
275
+ console.error(`[provenance] ${e.message}`);
276
+ return EXIT_INCONCLUSIVE;
277
+ }
278
+
279
+ if (args.help || !args.tarball || !args.commit) {
280
+ console.error(
281
+ 'usage: node scripts/verify-release-provenance.mjs --tarball <file.tgz> --commit <sha>\n' +
282
+ ' [--repo <path>] [--pkg-dir packages/next-mvp] [--json] [--quiet]'
283
+ );
284
+ return EXIT_INCONCLUSIVE;
285
+ }
286
+
287
+ // --- read inputs. Any failure here is INCONCLUSIVE, never a pass.
288
+ let entries, committed, resolvedCommit;
289
+ try {
290
+ entries = readTar(gunzipSync(readFileSync(args.tarball)));
291
+ } catch (e) {
292
+ console.error(`[provenance] INCONCLUSIVE: cannot read tarball ${args.tarball}: ${e.message}`);
293
+ return EXIT_INCONCLUSIVE;
294
+ }
295
+ try {
296
+ resolvedCommit = git(args.repo, ['rev-parse', `${args.commit}^{commit}`]).trim();
297
+ committed = treeAtCommit(args.repo, resolvedCommit, args.pkgDir);
298
+ } catch (e) {
299
+ console.error(`[provenance] INCONCLUSIVE: cannot read git at ${args.repo}: ${e.message}`);
300
+ return EXIT_INCONCLUSIVE;
301
+ }
302
+ if (entries.size === 0) {
303
+ console.error('[provenance] INCONCLUSIVE: tarball contained zero file entries');
304
+ return EXIT_INCONCLUSIVE;
305
+ }
306
+ if (committed.size === 0) {
307
+ console.error(`[provenance] INCONCLUSIVE: no blobs under ${args.pkgDir} at ${args.commit}`);
308
+ return EXIT_INCONCLUSIVE;
309
+ }
310
+
311
+ // --- classify + check
312
+ const res = { matched: [], mismatched: [], unclassified: [], derived: [], manifest: [], missing: [] };
313
+ let normalisedCount = 0;
314
+
315
+ for (const [tarPath, raw] of entries) {
316
+ if (!tarPath.startsWith('package/')) {
317
+ res.unclassified.push({ path: tarPath, why: 'outside package/ root' });
318
+ continue;
319
+ }
320
+ const p = tarPath.slice('package/'.length);
321
+ const cls = classify(p);
322
+
323
+ if (cls === 'DERIVED') { res.derived.push(p); continue; }
324
+ if (cls === 'MANIFEST') { res.manifest.push(p); continue; }
325
+ if (cls === 'UNCLASSIFIED') {
326
+ res.unclassified.push({ path: p, why: 'shipped but in no declared class' });
327
+ continue;
328
+ }
329
+
330
+ const norm = normalise(raw);
331
+ if (norm.length !== raw.length) normalisedCount++;
332
+ const actual = gitBlobId(norm);
333
+ const expected = committed.get(p);
334
+
335
+ if (!expected) {
336
+ res.mismatched.push({ path: p, actual, expected: null, diagnosis: 'SHIPPED BUT NOT COMMITTED AT THIS COMMIT' });
337
+ } else if (actual === expected) {
338
+ res.matched.push(p);
339
+ } else {
340
+ res.mismatched.push({
341
+ path: p,
342
+ actual,
343
+ expected,
344
+ diagnosis: existsInStore(args.repo, actual)
345
+ ? 'WRONG REVISION (bytes exist in store, but not at this path/commit)'
346
+ : 'DIRTY TREE (bytes exist in NO commit — uncommitted edit)',
347
+ });
348
+ }
349
+ }
350
+
351
+ // --- reverse direction: committed under a verified prefix but not shipped
352
+ for (const [p] of committed) {
353
+ if (classify(p) !== 'VERIFIED') continue;
354
+ if (!entries.has(`package/${p}`)) res.missing.push(p);
355
+ }
356
+
357
+ const verifiable = res.matched.length + res.mismatched.length;
358
+ const failed = res.mismatched.length + res.unclassified.length + res.missing.length;
359
+ const pass = failed === 0;
360
+
361
+ if (args.json) {
362
+ console.log(JSON.stringify({ pass, commit: resolvedCommit, tarball: args.tarball, ...res }, null, 2));
363
+ return pass ? EXIT_PASS : EXIT_FAIL;
364
+ }
365
+
366
+ const log = args.quiet ? () => {} : (...a) => console.log(...a);
367
+ log('');
368
+ log(' RELEASE PROVENANCE — tarball vs commit');
369
+ log(` tarball : ${args.tarball}`);
370
+ log(` commit : ${resolvedCommit}`);
371
+ log(` pkg dir : ${args.pkgDir}`);
372
+ log(' ' + '-'.repeat(70));
373
+ log(` VERIFIED ${String(res.matched.length).padStart(4)} / ${verifiable} shipped source files match the commit`);
374
+ log(` DERIVED ${String(res.derived.length).padStart(4)} dist/ build output — NOT provenance-verified (see note 6)`);
375
+ log(` MANIFEST ${String(res.manifest.length).padStart(4)} package.json — rewritten by npm, not byte-comparable`);
376
+ log(` normalised ${String(normalisedCount).padStart(4)} text files had CRLF stripped before hashing`);
377
+
378
+ if (res.mismatched.length) {
379
+ log('');
380
+ log(` *** ${res.mismatched.length} FILE(S) DO NOT MATCH THE COMMIT ***`);
381
+ for (const m of res.mismatched.slice(0, 40)) {
382
+ log(` ${m.path}`);
383
+ log(` shipped ${m.actual}`);
384
+ log(` commit ${m.expected ?? '(no blob at this path)'}`);
385
+ log(` -> ${m.diagnosis}`);
386
+ }
387
+ if (res.mismatched.length > 40) log(` ... and ${res.mismatched.length - 40} more`);
388
+ }
389
+ if (res.missing.length) {
390
+ log('');
391
+ log(` *** ${res.missing.length} COMMITTED FILE(S) MISSING FROM TARBALL (truncated publish) ***`);
392
+ for (const p of res.missing.slice(0, 20)) log(` ${p}`);
393
+ if (res.missing.length > 20) log(` ... and ${res.missing.length - 20} more`);
394
+ }
395
+ if (res.unclassified.length) {
396
+ log('');
397
+ log(` *** ${res.unclassified.length} SHIPPED FILE(S) IN NO DECLARED CLASS ***`);
398
+ log(' (fail closed — add them to CLASSES or remove them from "files")');
399
+ for (const u of res.unclassified.slice(0, 20)) log(` ${u.path} — ${u.why}`);
400
+ if (res.unclassified.length > 20) log(` ... and ${res.unclassified.length - 20} more`);
401
+ }
402
+
403
+ log('');
404
+ log(pass ? ' PASS — every verifiable shipped file traces to this commit.'
405
+ : ' FAIL — RE-RUN THE PUBLISH FROM A CLEAN CHECKOUT. DO NOT SHIP.');
406
+ log('');
407
+ return pass ? EXIT_PASS : EXIT_FAIL;
408
+ }
409
+
410
+ process.exit(main());
@@ -24,6 +24,7 @@ import {
24
24
  checkRefreshLock,
25
25
  type SessionData
26
26
  } from '../lib/session-store';
27
+ import { tokenLogId } from '../lib/token-log-id';
27
28
 
28
29
 
29
30
  export interface AuthContext {
@@ -268,7 +269,7 @@ export function createAuthHandler(options: AuthHandlerOptions = {}) {
268
269
  }
269
270
 
270
271
  console.info('[AUTH_HANDLER] Executing refresh against IDP', {
271
- sessionToken: sessionToken.substring(0, 8) + '...',
272
+ sessionToken: tokenLogId(sessionToken),
272
273
  hasRefreshToken: true
273
274
  });
274
275
 
@@ -283,7 +284,8 @@ export function createAuthHandler(options: AuthHandlerOptions = {}) {
283
284
 
284
285
  if (!response.ok) {
285
286
  const errorText = await response.text().catch(() => 'Unknown error');
286
- console.error('[AUTH_HANDLER] Refresh failed:', response.status, errorText);
287
+ // PAY-1427: never the raw upstream body (the refresh.ts:528 twin PR 139 fixed). Status + length only.
288
+ console.error('[AUTH_HANDLER] Refresh failed:', { status: response.status, bodyLength: errorText.length });
287
289
  return { success: false, error: `Refresh failed: ${response.status}` };
288
290
  }
289
291
 
@@ -441,7 +443,7 @@ export function createAuthHandler(options: AuthHandlerOptions = {}) {
441
443
  const requestId = generateRequestId();
442
444
  console.info('[AUTH_HANDLER] Token near expiry, initiating coordinated refresh', {
443
445
  requestId,
444
- sessionToken: authContext.sessionToken.substring(0, 8) + '...'
446
+ sessionToken: tokenLogId(authContext.sessionToken)
445
447
  });
446
448
 
447
449
  const refreshResult = await performCoordinatedRefresh(
@@ -9,6 +9,7 @@ interface ChangePasswordRequest {
9
9
  }
10
10
 
11
11
  import { nanoid } from 'nanoid';
12
+ import { projectPasswordPolicyDetails } from '../../lib/safe-error-response';
12
13
 
13
14
  // ...
14
15
 
@@ -115,7 +116,8 @@ export async function POST(req: NextRequest) {
115
116
  message: errorMessage,
116
117
  error_code: responseData.error_code || 'CHANGE_PASSWORD_FAILED',
117
118
  request_id: requestId,
118
- details: responseData.details,
119
+ // PAY-1438: only the password-policy messages the security pages display, never the raw envelope.
120
+ details: projectPasswordPolicyDetails(responseData.details),
119
121
  },
120
122
  { status: idpResponse.status }
121
123
  );
@@ -127,12 +129,13 @@ export async function POST(req: NextRequest) {
127
129
  request_id: requestId,
128
130
  });
129
131
  } catch (error) {
130
- console.error('[CHANGE_PASSWORD] Error:', error);
131
132
  const requestId = req.headers.get('x-request-id') ?? crypto.randomUUID();
133
+ // PAY-1438: the exception text stays in the log, keyed by the request_id the caller receives.
134
+ console.error('[CHANGE_PASSWORD] Error:', { request_id: requestId, error });
132
135
  return NextResponse.json(
133
136
  {
134
137
  success: false,
135
- message: error instanceof Error ? error.message : 'Failed to change password',
138
+ message: 'Failed to change password',
136
139
  error_code: 'INTERNAL_ERROR',
137
140
  request_id: requestId,
138
141
  },
@@ -1,6 +1,7 @@
1
1
  import { NextRequest, NextResponse } from 'next/server';
2
2
  import { idpFetchJSON } from '../../lib/idp-fetch';
3
3
  import { ENV_CONFIG } from '../../config/env';
4
+ import { projectIdpError } from '../../lib/safe-error-response';
4
5
 
5
6
  // IDP masked-info is POST and uses capital 'A' in /api/Account
6
7
  export async function POST(req: NextRequest) {
@@ -24,7 +25,7 @@ export async function POST(req: NextRequest) {
24
25
  {
25
26
  success: false,
26
27
  message: 'Upstream error',
27
- error: { code: 'UPSTREAM_SERVICE_ERROR', status: result.status, details: result.json },
28
+ error: { code: 'UPSTREAM_SERVICE_ERROR', status: result.status, details: projectIdpError(result.json) },
28
29
  meta: { attemptedRefresh: result.attemptedRefresh },
29
30
  },
30
31
  { status: result.status }
@@ -0,0 +1,117 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { ENV_CONFIG } from '../../../config/env';
3
+ import { getIDPClientConfig } from '../../../lib/idp-client-config';
4
+ import { resolveClientKey } from '../../../lib/client-key';
5
+ import { idpErrorEnvelope } from '../../../lib/safe-error-response';
6
+
7
+ /**
8
+ * Account recovery - the ONE forwarder behind /recovery/send-code and /recovery/verify-code (PAY-1588).
9
+ *
10
+ * THE DEFECT (QAPert, W1 walk on :4797 -> dev-93 IdP 90f766bd0, 2026-09-19): portal /account-auth/recovery,
11
+ * initiate -> 200, send-code -> 401 "Upstream error". The browser sends `Authorization: Bearer <recovery session
12
+ * token>` (the token `initiate` minted); these handlers went through idpFetchJSON, which sets Authorization ONLY from
13
+ * the server-side SESSION and never forwards the inbound header, so the IdP got no Bearer at all
14
+ * (AccountController.cs:872-885 = 401 MISSING_TOKEN). Positive control direct to the IdP: without Authorization 401,
15
+ * with it 200 and the email is sent.
16
+ *
17
+ * WHY THE 2026-08-02 REWRITE GOT IT WRONG. It reasoned "browsers send a session COOKIE and no Authorization header"
18
+ * and moved both handlers onto idpFetchJSON. That is true of every OTHER authenticated route. It is false of this
19
+ * flow: the caller is LOCKED OUT, so there is no session to resolve - the recovery session token IS the credential,
20
+ * and the browser is the only party that holds it. Resolving a token from a session that does not exist is why the
21
+ * entire recovery path, both halves, has been dead: it fails exactly when it is the only thing left that could help.
22
+ *
23
+ * WHAT THIS DOES INSTEAD
24
+ * - Forwards the inbound `Authorization: Bearer <token>` to the IdP and nothing else from the caller's headers.
25
+ * - Uses a direct fetch, NOT idpFetchJSON: a stray session belonging to someone else on the same browser must not
26
+ * be able to override the recovery credential with its own bearer (idpFetchJSON's session bearer wins over any
27
+ * caller-supplied Authorization).
28
+ * - FAILS LOUD when there is no usable bearer: 401 `missing_recovery_token`, without calling upstream. The old
29
+ * behaviour returned the IdP's opaque rejection, so the user saw recovery "not working" and the log showed an
30
+ * upstream error rather than the auth fault it was.
31
+ * - Shape-checks the token (a bearer scheme, one opaque token, sane length) before forwarding, so the header cannot
32
+ * be used to smuggle anything but a credential to the IdP. It is never logged.
33
+ * - Anonymous route (PAY-1438): on failure only the whitelisted envelope, never the IdP body.
34
+ *
35
+ * SERVER VALUE WINS for client_id (cards 268012 / 268410): applied AFTER the caller's payload spread, because these are
36
+ * unauthenticated endpoints and a browser-supplied client_id would be a tenant-targeting lever on a password-reset path.
37
+ *
38
+ * @package @payez/next-mvp
39
+ */
40
+
41
+ /** One opaque token: JWT / base64url / opaque-hex alphabet, no whitespace or control characters, bounded length. */
42
+ const TOKEN_SHAPE = /^[A-Za-z0-9\-._~+/]+=*$/;
43
+
44
+ /** The recovery bearer from the inbound request, or null when absent / malformed. Never throws. */
45
+ export function inboundRecoveryBearer(req: Pick<NextRequest, 'headers'>): string | null {
46
+ const raw = req.headers.get('authorization');
47
+ if (!raw) return null;
48
+ const m = /^Bearer[ ]+(\S+)$/i.exec(raw.trim());
49
+ if (!m) return null;
50
+ const token = m[1];
51
+ if (token.length < 16 || token.length > 4096) return null;
52
+ return TOKEN_SHAPE.test(token) ? token : null;
53
+ }
54
+
55
+ async function recoveryClientKey(where: string): Promise<string | null> {
56
+ try {
57
+ const config = await getIDPClientConfig();
58
+ return resolveClientKey(config, where).key;
59
+ } catch (e) {
60
+ console.error(`[${where}] could not resolve IDP client config for client_id:`, e);
61
+ return null;
62
+ }
63
+ }
64
+
65
+ export interface RecoveryForward {
66
+ /** IdP path after the host, e.g. /api/Account/recovery/send-code */
67
+ upstreamPath: string;
68
+ /** log/route label used for the client-key resolver */
69
+ where: string;
70
+ /** stable `error` code for an upstream that could not be reached */
71
+ failureCode: string;
72
+ /** user-facing fallback when the IdP gave no message */
73
+ fallbackMessage: string;
74
+ }
75
+
76
+ export async function forwardRecoveryCall(req: NextRequest, spec: RecoveryForward): Promise<NextResponse> {
77
+ let payload: unknown = {};
78
+ try {
79
+ payload = await req.json();
80
+ } catch {
81
+ return NextResponse.json({ success: false, error: 'invalid_request', message: 'A JSON body is required' }, { status: 400 });
82
+ }
83
+
84
+ const bearer = inboundRecoveryBearer(req);
85
+ if (!bearer) {
86
+ return NextResponse.json(
87
+ {
88
+ success: false,
89
+ error: 'missing_recovery_token',
90
+ message: 'Your recovery session could not be found. Start the recovery again from the beginning.',
91
+ },
92
+ { status: 401 },
93
+ );
94
+ }
95
+
96
+ const clientKey = await recoveryClientKey(spec.where);
97
+
98
+ let upstream: Response;
99
+ try {
100
+ upstream = await fetch(`${ENV_CONFIG.IDP_URL}${spec.upstreamPath}`, {
101
+ method: 'POST',
102
+ headers: {
103
+ 'Content-Type': 'application/json',
104
+ Accept: 'application/json',
105
+ Authorization: `Bearer ${bearer}`,
106
+ ...(clientKey ? { 'X-Client-Id': clientKey } : {}),
107
+ },
108
+ body: JSON.stringify({ ...(payload as Record<string, unknown>), ...(clientKey ? { client_id: clientKey } : {}) }),
109
+ });
110
+ } catch {
111
+ return NextResponse.json({ success: false, error: spec.failureCode }, { status: 502 });
112
+ }
113
+
114
+ const data = await upstream.json().catch(() => ({}));
115
+ if (!upstream.ok) return NextResponse.json(idpErrorEnvelope(data, spec.fallbackMessage), { status: upstream.status });
116
+ return NextResponse.json(data, { status: 200 });
117
+ }
@@ -1,6 +1,7 @@
1
1
  import { NextRequest, NextResponse } from 'next/server';
2
2
  import { getIDPClientConfig } from '../../../lib/idp-client-config';
3
3
  import { resolveClientKey } from '../../../lib/client-key';
4
+ import { idpErrorEnvelope } from '../../../lib/safe-error-response';
4
5
 
5
6
  function getIdpUrl(): string {
6
7
  const url = process.env.IDP_URL;
@@ -49,6 +50,8 @@ export async function POST(req: NextRequest) {
49
50
  body: JSON.stringify({ email, ...(clientKey ? { client_id: clientKey } : {}) }),
50
51
  });
51
52
  const data = await upstream.json().catch(() => ({}));
53
+ // PAY-1438: anonymous route - on failure only the whitelisted envelope, never the IDP body.
54
+ if (!upstream.ok) return NextResponse.json(idpErrorEnvelope(data, 'Failed to initiate recovery'), { status: upstream.status });
52
55
  return NextResponse.json(data, { status: upstream.status });
53
56
  } catch (e) {
54
57
  return NextResponse.json({ success: false, error: 'initiate_failed' }, { status: 500 });