@payez/next-mvp 4.4.2 → 4.4.4

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.
@@ -147,6 +147,20 @@ function isPlainObject(value) {
147
147
  * globalThis is per-process and per-runtime, which is exactly the lifetime a request-scoped
148
148
  * carrier needs to be reachable from every copy that might run inside the request.
149
149
  */
150
+ /**
151
+ * THE KEY STAYS '__payezIdTokenScope' EVEN THOUGH THE SCOPE NOW CARRIES TWO
152
+ * THINGS. Renaming it would give a copy on the old name and a copy on the new
153
+ * name two DIFFERENT stores in the same process — which is precisely the
154
+ * double-load failure documented above, reintroduced by a cosmetic rename. The
155
+ * name is a wire contract between copies, not a description.
156
+ *
157
+ * accessToken is here for providers that do NOT issue an id_token. GitHub is
158
+ * not an OIDC provider: it has no id_token, no JWKS and no discovery document,
159
+ * so `id_token` can never be the field that proves a GitHub identity. The IDP
160
+ * re-derives that identity by calling GitHub itself with this access token
161
+ * (GitHubIdentityVerifier), which keeps the same rule google follows — the IDP
162
+ * verifies for itself and never trusts an asserted email.
163
+ */
150
164
  const ID_TOKEN_SCOPE_KEY = '__payezIdTokenScope';
151
165
  const gScope = globalThis;
152
166
  gScope[ID_TOKEN_SCOPE_KEY] ??= new node_async_hooks_1.AsyncLocalStorage();
@@ -248,9 +262,21 @@ function buildBetterAuthProviders(config) {
248
262
  * remains, so the provider emits exactly its default set.
249
263
  */
250
264
  const GOOGLE_DEFAULT_SCOPES = ['email', 'profile', 'openid'];
265
+ /**
266
+ * Same defect, same fix, different provider: github's createAuthorizationURL
267
+ * also seeds ['read:user','user:email'] unconditionally and then pushes ours
268
+ * on top (@better-auth/core social-providers/github.ts), so the stored row
269
+ * "read:user user:email" reached GitHub as
270
+ * "read:user user:email read:user user:email". Measured on :4799, not
271
+ * assumed. GitHub dedupes, so this is legibility rather than function — but
272
+ * a scope list is the first thing a security reviewer reads off that URL.
273
+ */
274
+ const GITHUB_DEFAULT_SCOPES = ['read:user', 'user:email'];
251
275
  const wireScopes = name === 'google'
252
276
  ? scopes.filter((s) => !GOOGLE_DEFAULT_SCOPES.includes(s))
253
- : scopes;
277
+ : name === 'github'
278
+ ? scopes.filter((s) => !GITHUB_DEFAULT_SCOPES.includes(s))
279
+ : scopes;
254
280
  providers[name] = {
255
281
  clientId: oauth.clientId,
256
282
  clientSecret: oauth.clientSecret,
@@ -351,6 +377,82 @@ function buildBetterAuthProviders(config) {
351
377
  };
352
378
  };
353
379
  }
380
+ /**
381
+ * GITHUB: THE SAME CAPTURE, A DIFFERENT TOKEN.
382
+ *
383
+ * GitHub is not an OIDC provider — it issues no id_token — so the google
384
+ * branch above can never fire for it and the exchange would post an
385
+ * assertion with nothing that proves identity. That is exactly the
386
+ * 401 UNVERIFIED_ASSERTION measured on :4799, whose diagnostics read
387
+ * `id_token_forwarded: false, id_token_length: 0` with `response_time_ms: 0`:
388
+ * the IDP rejected it instantly, without ever calling GitHub.
389
+ *
390
+ * getUserInfo is the ONLY provider hook that receives the token set (see the
391
+ * google note above), and @better-auth/core's github provider calls
392
+ * `options.getUserInfo(token)` BEFORE its own body — so installing this
393
+ * REPLACES the default rather than wrapping it, and the default's work has
394
+ * to be reproduced here or the sign-in loses its profile entirely.
395
+ *
396
+ * Mirrored verbatim from @better-auth/core/src/social-providers/github.ts at
397
+ * the version the container actually runs (better-auth 1.5.6 — the graft's
398
+ * node_modules says 1.6.29 and is NOT what executes). Kept deliberately
399
+ * dumb: it must not "improve" on upstream, because every divergence here is
400
+ * a behaviour change disguised as a token capture.
401
+ *
402
+ * This is NOT the identity check. The IDP re-derives the GitHub identity
403
+ * itself from this access token and enforces primary && verified server-side
404
+ * (GitHubIdentityVerifier); what we return here only shapes the local
405
+ * better-auth user record.
406
+ */
407
+ if (name === 'github') {
408
+ providers[name].getUserInfo = async (token) => {
409
+ const store = idTokenScope.getStore();
410
+ if (store) {
411
+ store.accessToken = token?.accessToken;
412
+ }
413
+ else {
414
+ console.warn('[BETTER_AUTH] github access_token captured but NO REQUEST SCOPE to put it in — ' +
415
+ 'it will NOT be forwarded and the IDP exchange will fail UNVERIFIED_ASSERTION. ' +
416
+ 'Check that GET is createAuthGetHandler(), not routes/auth/nextauth.');
417
+ }
418
+ const ghHeaders = {
419
+ 'User-Agent': 'better-auth',
420
+ Authorization: `Bearer ${token?.accessToken}`,
421
+ };
422
+ const profileRes = await fetch('https://api.github.com/user', {
423
+ headers: ghHeaders,
424
+ });
425
+ // Upstream returns null on profile error, which fails the sign-in
426
+ // closed. Preserved rather than softened.
427
+ if (!profileRes.ok) {
428
+ console.warn(`[BETTER_AUTH] github getUserInfo: /user returned ${profileRes.status}`);
429
+ return null;
430
+ }
431
+ const profile = (await profileRes.json());
432
+ const emailsRes = await fetch('https://api.github.com/user/emails', {
433
+ headers: ghHeaders,
434
+ });
435
+ const emails = emailsRes.ok
436
+ ? (await emailsRes.json())
437
+ : undefined;
438
+ // A GitHub account with a private email returns profile.email = null;
439
+ // upstream falls back to the primary address here.
440
+ if (!profile.email && emails) {
441
+ profile.email = (emails.find((e) => e.primary) ?? emails[0])?.email;
442
+ }
443
+ const emailVerified = emails?.find((e) => e.email === profile.email)?.verified ?? false;
444
+ return {
445
+ user: {
446
+ id: profile.id,
447
+ name: profile.name || profile.login || '',
448
+ email: profile.email,
449
+ image: profile.avatar_url,
450
+ emailVerified,
451
+ },
452
+ data: profile,
453
+ };
454
+ };
455
+ }
354
456
  }
355
457
  return providers;
356
458
  }
@@ -802,6 +904,21 @@ async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
802
904
  * distinguishable from "the provider issued none".
803
905
  */
804
906
  ...(idTokenScope.getStore()?.idToken ? { id_token: idTokenScope.getStore().idToken } : {}),
907
+ /**
908
+ * The same field for providers that have no id_token to give.
909
+ *
910
+ * Identical doctrine to the id_token above: RAW, forwarded verbatim,
911
+ * NOTHING DERIVED FROM IT, and omitted entirely rather than sent null so
912
+ * "never captured" stays distinguishable from "provider issued none".
913
+ * The IDP verifies for itself by calling GitHub with this token and
914
+ * enforcing primary && verified; we assert nothing about the identity.
915
+ *
916
+ * Scoped to the provider access token captured during THIS callback, so
917
+ * a google login continues to send id_token only and is unaffected.
918
+ */
919
+ ...(idTokenScope.getStore()?.accessToken
920
+ ? { access_token: idTokenScope.getStore().accessToken }
921
+ : {}),
805
922
  }),
806
923
  });
807
924
  const oauthResText = await oauthRes.text();
@@ -833,9 +950,21 @@ async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
833
950
  claims = { decode: 'FAILED — not a well-formed JWT' };
834
951
  }
835
952
  }
953
+ const forwardedAccess = idTokenScope.getStore()?.accessToken;
836
954
  console.error('[BETTER_AUTH] exchange diagnostics:', {
837
955
  id_token_forwarded: !!forwarded,
838
956
  id_token_length: forwarded ? forwarded.length : 0,
957
+ /**
958
+ * Read these two for github, and IGNORE the id_token pair above: github
959
+ * issues no id_token, so `id_token_forwarded: false` is its NORMAL state
960
+ * and not the fault. Presence and length only — an access token is
961
+ * credential material and this log is persistent. A github failure with
962
+ * access_token_forwarded:true means the IDP called GitHub and refused
963
+ * the answer (unverified or non-primary email, or app not installed);
964
+ * false means the capture hook never ran and the IDP never called out.
965
+ */
966
+ access_token_forwarded: !!forwardedAccess,
967
+ access_token_length: forwardedAccess ? forwardedAccess.length : 0,
839
968
  // Absent id_token => the IDP had nothing to verify. Present => compare these
840
969
  // against the client's registered Google credentials on the IDP.
841
970
  aud: claims.aud ?? null,
@@ -21,6 +21,27 @@
21
21
  * v1 `startsWith('/') && !startsWith('//')` -> bypassed by /BACKSLASH host
22
22
  * v2 + `!startsWith('/' + BACKSLASH)` -> bypassed by /TAB/host
23
23
  * ... and /LF/host, /CR/host
24
+ * v3 resolve once + compare ORIGIN -> bypassed by /..//host
25
+ * and /.//host, /%2e%2e//host
26
+ * v4 + require the RESULT to be a FIXED POINT <- current
27
+ *
28
+ * *** DRIFT, 2026-09-11 (325543). THIS FILE WAS v3 WHILE THE PORTAL'S COPY WAS
29
+ * *** v4, AND THE PACKAGE COPY IS THE ONE THAT SHIPPED. The portal fixed its own
30
+ * *** src/lib/safe-callback.ts (e1f08fea) and deployed; the served bundle STILL
31
+ * *** escaped 5/5 in a real browser, because `src/contexts/ThemeContext.tsx`
32
+ * *** imports the BARE BARREL `@payez/next-mvp`, which pulls dist/index.js ->
33
+ * *** auth/auth-decision -> THIS FILE into the client graph, and because the
34
+ * *** portal's `src/middleware.ts` imports `@payez/next-mvp/edge`, which requires
35
+ * *** THIS FILE server-side on every request. It was read as a stale build
36
+ * *** artifact of the portal's file for most of a day. It was not. It was a
37
+ * *** SECOND DEFINITION, and no rebuild of the portal could have removed it.
38
+ * *** The header below says "ONE definition ... for the whole package" and that
39
+ * *** was true INSIDE this package boundary the entire time. The defect lived in
40
+ * *** the gap BETWEEN two boundaries that each enforced one-definition correctly.
41
+ * *** A guard that exists in N copies is only as strong as its weakest copy, and
42
+ * *** a per-repo grep gate cannot see N. IF YOU CHANGE THE PREDICATE HERE, CHANGE
43
+ * *** IT IN THE PORTAL'S src/lib/safe-callback.ts IN THE SAME HOUR, OR DELETE ONE
44
+ * *** OF THEM.
24
45
  *
25
46
  * Both bypasses work the same way: the browser NORMALISES the string AFTER the
26
47
  * guard has finished looking at it. The WHATWG URL parser converts backslashes
@@ -45,7 +66,15 @@ export declare const DEFAULT_CALLBACK = "/";
45
66
  * the auth surface. Never throws.
46
67
  */
47
68
  export declare function safeCallbackPath(raw: unknown, fallback?: string): string;
48
- /** True when `raw` is a callback target we are willing to honour. */
69
+ /**
70
+ * True when `raw` is a callback target we are willing to honour.
71
+ *
72
+ * DEFINED IN TERMS OF `safeCallbackPath` ON PURPOSE - it used to carry its own
73
+ * copy of the predicate, and that copy was missing the second resolution above
74
+ * while the two were maintained separately. This file's whole history is
75
+ * duplicated predicates drifting apart (see the header). One definition, one
76
+ * place to fix.
77
+ */
49
78
  export declare function isSafeCallbackPath(raw: unknown): raw is string;
50
79
  /**
51
80
  * Pick the post-login destination: an explicit `?callbackUrl=` when it is one we
@@ -22,6 +22,27 @@
22
22
  * v1 `startsWith('/') && !startsWith('//')` -> bypassed by /BACKSLASH host
23
23
  * v2 + `!startsWith('/' + BACKSLASH)` -> bypassed by /TAB/host
24
24
  * ... and /LF/host, /CR/host
25
+ * v3 resolve once + compare ORIGIN -> bypassed by /..//host
26
+ * and /.//host, /%2e%2e//host
27
+ * v4 + require the RESULT to be a FIXED POINT <- current
28
+ *
29
+ * *** DRIFT, 2026-09-11 (325543). THIS FILE WAS v3 WHILE THE PORTAL'S COPY WAS
30
+ * *** v4, AND THE PACKAGE COPY IS THE ONE THAT SHIPPED. The portal fixed its own
31
+ * *** src/lib/safe-callback.ts (e1f08fea) and deployed; the served bundle STILL
32
+ * *** escaped 5/5 in a real browser, because `src/contexts/ThemeContext.tsx`
33
+ * *** imports the BARE BARREL `@payez/next-mvp`, which pulls dist/index.js ->
34
+ * *** auth/auth-decision -> THIS FILE into the client graph, and because the
35
+ * *** portal's `src/middleware.ts` imports `@payez/next-mvp/edge`, which requires
36
+ * *** THIS FILE server-side on every request. It was read as a stale build
37
+ * *** artifact of the portal's file for most of a day. It was not. It was a
38
+ * *** SECOND DEFINITION, and no rebuild of the portal could have removed it.
39
+ * *** The header below says "ONE definition ... for the whole package" and that
40
+ * *** was true INSIDE this package boundary the entire time. The defect lived in
41
+ * *** the gap BETWEEN two boundaries that each enforced one-definition correctly.
42
+ * *** A guard that exists in N copies is only as strong as its weakest copy, and
43
+ * *** a per-repo grep gate cannot see N. IF YOU CHANGE THE PREDICATE HERE, CHANGE
44
+ * *** IT IN THE PORTAL'S src/lib/safe-callback.ts IN THE SAME HOUR, OR DELETE ONE
45
+ * *** OF THEM.
25
46
  *
26
47
  * Both bypasses work the same way: the browser NORMALISES the string AFTER the
27
48
  * guard has finished looking at it. The WHATWG URL parser converts backslashes
@@ -77,19 +98,69 @@ function safeCallbackPath(raw, fallback = exports.DEFAULT_CALLBACK) {
77
98
  if (url.pathname.startsWith(AUTH_PREFIX))
78
99
  return fallback;
79
100
  // The parser's normalised output - control characters already stripped.
80
- return url.pathname + url.search + url.hash;
81
- }
82
- /** True when `raw` is a callback target we are willing to honour. */
83
- function isSafeCallbackPath(raw) {
84
- if (typeof raw !== 'string' || raw.length === 0)
85
- return false;
101
+ const candidate = url.pathname + url.search + url.hash;
102
+ // *** SECOND RESOLUTION. DO NOT DELETE THIS AS REDUNDANT - IT IS NOT. ***
103
+ //
104
+ // v3 (the origin comparison above) was bypassed too, and differently from v1/v2.
105
+ // The origin check constrains where `raw` RESOLVED TO. It does not constrain how
106
+ // the RESULT IS SPELLED - and the result is resolved a SECOND time, by whoever
107
+ // navigates to it. A returned path that begins with `//` is a PROTOCOL-RELATIVE
108
+ // URL at that second resolution, so it lands off-origin having passed every
109
+ // check here:
110
+ //
111
+ // '/..//evil.example.com' -> pathname '//evil.example.com' -> evil.example.com
112
+ // '/.//evil.example.com' -> pathname '//evil.example.com' -> evil.example.com
113
+ // '/%2e%2e//evil.example.com' -> pathname '//evil.example.com' -> evil.example.com
114
+ //
115
+ // The parser resolves `..` against the synthetic base and, at the root, leaves
116
+ // behind the EMPTY SEGMENT rather than collapsing it. Origin stayed BASE the
117
+ // whole time, so nothing upstream fired.
118
+ //
119
+ // MEASURED, not reasoned: in a real headless Chromium on the live origin
120
+ // https://portal.keelauth.com, 5 spellings resolved to https://evil.example.com
121
+ // (QAPert-NightHawk, 2026-09-11, card 325543), and on 2026-09-11 the SERVED
122
+ // BUNDLE was measured escaping 5/5 through THIS PACKAGE'S copy while the
123
+ // portal's own src/lib/safe-callback.ts was already fixed - the two copies had
124
+ // drifted. See the DRIFT note in this file's header.
125
+ //
126
+ // THE FIX FOLLOWS THIS FILE'S OWN DOCTRINE: do not pattern-match the spellings
127
+ // (that is the startsWith() trap that failed four times, and this would be the
128
+ // fifth). Ask the parser AGAIN about the exact value we are about to hand out,
129
+ // and require it to be a FIXED POINT. Anything whose meaning changes when it is
130
+ // resolved a second time is by definition not a stable same-origin path, however
131
+ // it is spelled - including spellings nobody has found yet.
132
+ let echo;
86
133
  try {
87
- const url = new URL(raw, BASE);
88
- return url.origin === BASE && !url.pathname.startsWith(AUTH_PREFIX);
134
+ echo = new URL(candidate, BASE);
89
135
  }
90
136
  catch {
91
- return false;
137
+ return fallback;
92
138
  }
139
+ if (echo.origin !== BASE)
140
+ return fallback;
141
+ if (echo.pathname.startsWith(AUTH_PREFIX))
142
+ return fallback;
143
+ if (echo.pathname + echo.search + echo.hash !== candidate)
144
+ return fallback;
145
+ return candidate;
146
+ }
147
+ /**
148
+ * Sentinel that `safeCallbackPath` can never legitimately return: it is not a
149
+ * fixed point of the URL parser (the parser would percent-encode the space), so
150
+ * it cannot collide with a genuine accepted value.
151
+ */
152
+ const REJECTED = '\u0000rejected';
153
+ /**
154
+ * True when `raw` is a callback target we are willing to honour.
155
+ *
156
+ * DEFINED IN TERMS OF `safeCallbackPath` ON PURPOSE - it used to carry its own
157
+ * copy of the predicate, and that copy was missing the second resolution above
158
+ * while the two were maintained separately. This file's whole history is
159
+ * duplicated predicates drifting apart (see the header). One definition, one
160
+ * place to fix.
161
+ */
162
+ function isSafeCallbackPath(raw) {
163
+ return typeof raw === 'string' && raw.length > 0 && safeCallbackPath(raw, REJECTED) !== REJECTED;
93
164
  }
94
165
  /**
95
166
  * Pick the post-login destination: an explicit `?callbackUrl=` when it is one we
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@payez/next-mvp",
3
- "version": "4.4.2",
3
+ "version": "4.4.4",
4
4
  "sideEffects": false,
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -24,6 +24,7 @@
24
24
  "dev:local": "pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/dev-local.ps1",
25
25
  "dev:broker": "pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/dev-broker.ps1",
26
26
  "check:publish-health": "node scripts/check-publish-health.mjs",
27
+ "census:reach": "node scripts/module-reach.mjs",
27
28
  "build:incremental": "npx tsc --project tsconfig.json"
28
29
  },
29
30
  "exports": {
@@ -966,4 +967,4 @@
966
967
  "typescript": "^5.4.5",
967
968
  "zustand": "^4.5.5"
968
969
  }
969
- }
970
+ }
@@ -0,0 +1,286 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * module-reach.mjs - module-graph reachability census over the BUILT dist/.
4
+ *
5
+ * WHY THIS EXISTS
6
+ * ---------------
7
+ * Cards 372358 / 373665 rest on numbers like "40 consumer-mountable entry points can
8
+ * reach server/auth". Those numbers were originally produced by a script that was run
9
+ * inline and never committed, so nobody could reproduce them. A number nobody can
10
+ * re-derive is a rumour. This file is that script, made durable and self-testing.
11
+ *
12
+ * WHAT IT MEASURES
13
+ * ----------------
14
+ * Over dist/**\/*.js (the artifact a consumer actually installs, NOT src/):
15
+ * - the module graph (nodes + edges)
16
+ * - which modules transitively REACH a target module (reverse reachability)
17
+ * - which of those are MOUNTABLE - i.e. named in package.json "exports", so a
18
+ * consumer can import them directly and thereby pull the target in
19
+ *
20
+ * HOW EDGES ARE FOUND - and why this shape was chosen
21
+ * ---------------------------------------------------
22
+ * We do NOT pattern-match `require(...)`. Three real defects came from doing that:
23
+ * 1. QUOTE STYLE - a pattern written with " misses ' (tsc emits either).
24
+ * 2. SIBLING PATHS - a pattern anchored on `./lib/` misses a plain `./auth`.
25
+ * 3. DYNAMIC IMPORT - `await import('x')`, and tsc's downlevel
26
+ * `Promise.resolve().then(() => __importStar(require('x')))`, are not `require(` calls
27
+ * in the shape a naive pattern expects.
28
+ * Instead: extract EVERY string literal (', ", and substitution-free `), and keep the ones
29
+ * that RESOLVE to a real file on disk. Keyword-agnostic and quote-agnostic by construction,
30
+ * so all three modes above are caught without enumerating them.
31
+ *
32
+ * THE HONESTY SIGNAL
33
+ * ------------------
34
+ * A relative specifier that does NOT resolve is counted as UNRESOLVED. Unresolved > 0 means
35
+ * the graph is incomplete and every count derived from it is a lower bound. The tool says so
36
+ * loudly rather than quietly under-reporting. Counts are reported over CALL SITES where
37
+ * relevant, never deduplicated to "files that mention it" - that conflation is its own defect.
38
+ *
39
+ * BOUND: this is a STATIC graph over emitted JS. It proves a module is REACHABLE, not that it
40
+ * is EXECUTED at runtime, and not that reaching it has any security consequence. Reachability
41
+ * is necessary for consequence, never sufficient. Do not quote this tool as proof of impact.
42
+ *
43
+ * USAGE
44
+ * node scripts/module-reach.mjs --to server/auth
45
+ * node scripts/module-reach.mjs --to server/auth --json
46
+ * node scripts/module-reach.mjs --graph-only
47
+ * node scripts/module-reach.mjs --to server/auth --pkg <dir> (default: package root)
48
+ */
49
+
50
+ import fs from 'node:fs';
51
+ import path from 'node:path';
52
+
53
+ const CANDIDATE_EXTS = ['', '.js', '.mjs', '.cjs', '.json'];
54
+ const INDEX_FILES = ['index.js', 'index.mjs', 'index.cjs'];
55
+
56
+ /** Extract every string literal. Handles ' " and substitution-free `. Escape-aware. */
57
+ export function extractStringLiterals(src) {
58
+ const out = [];
59
+ const n = src.length;
60
+ let i = 0;
61
+ while (i < n) {
62
+ const ch = src[i];
63
+ // skip line comments
64
+ if (ch === '/' && src[i + 1] === '/') {
65
+ while (i < n && src[i] !== '\n') i++;
66
+ continue;
67
+ }
68
+ // skip block comments
69
+ if (ch === '/' && src[i + 1] === '*') {
70
+ i += 2;
71
+ while (i < n && !(src[i] === '*' && src[i + 1] === '/')) i++;
72
+ i += 2;
73
+ continue;
74
+ }
75
+ if (ch === '"' || ch === "'" || ch === '`') {
76
+ const quote = ch;
77
+ let j = i + 1;
78
+ let buf = '';
79
+ let sawSubstitution = false;
80
+ while (j < n) {
81
+ const c = src[j];
82
+ if (c === '\\') { buf += src[j + 1] ?? ''; j += 2; continue; }
83
+ if (c === quote) break;
84
+ if (quote === '`' && c === '$' && src[j + 1] === '{') sawSubstitution = true;
85
+ if (quote !== '`' && (c === '\n' || c === '\r')) break; // unterminated
86
+ buf += c;
87
+ j++;
88
+ }
89
+ // A template literal WITH ${...} is not a static specifier - we cannot resolve it,
90
+ // and pretending otherwise would invent an edge. Dropped, and counted by the caller.
91
+ if (!sawSubstitution) out.push(buf);
92
+ i = j + 1;
93
+ continue;
94
+ }
95
+ i++;
96
+ }
97
+ return out;
98
+ }
99
+
100
+ /** Resolve a relative specifier from `fromFile` to a real file, or null. */
101
+ export function resolveSpecifier(fromFile, spec, exists) {
102
+ const base = path.resolve(path.dirname(fromFile), spec);
103
+ for (const ext of CANDIDATE_EXTS) {
104
+ const cand = base + ext;
105
+ if (exists(cand)) return cand;
106
+ }
107
+ for (const idx of INDEX_FILES) {
108
+ const cand = path.join(base, idx);
109
+ if (exists(cand)) return cand;
110
+ }
111
+ return null;
112
+ }
113
+
114
+ const isRelative = (s) => s === '.' || s === '..' || s.startsWith('./') || s.startsWith('../');
115
+
116
+ function walkJs(dir, acc = []) {
117
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
118
+ const p = path.join(dir, e.name);
119
+ if (e.isDirectory()) walkJs(p, acc);
120
+ // `__`-prefixed files in dist are TEST MUTANTS, never tsc output (measured:
121
+ // 0 of 245 in a clean dist). Several suites write one next to the real
122
+ // artifact — so its relative requires resolve — and delete it straight
123
+ // after, while `node --test` runs other files CONCURRENTLY. Counting one
124
+ // would make this census non-reproducible (the property the LIVE test
125
+ // asserts) and reading one after its delete lands is an ENOENT crash.
126
+ else if (e.isFile() && !e.name.startsWith('__') && /\.(js|mjs|cjs)$/.test(e.name)) acc.push(p);
127
+ }
128
+ return acc;
129
+ }
130
+
131
+ export function buildGraph(distDir, opts = {}) {
132
+ const readFile = opts.readFile || ((f) => fs.readFileSync(f, 'utf8'));
133
+ const exists = opts.exists || ((f) => { try { return fs.statSync(f).isFile(); } catch { return false; } });
134
+ const files = (opts.files || walkJs(distDir)).map((f) => path.resolve(f));
135
+
136
+ const rel = (f) => path.relative(distDir, f).split(path.sep).join('/');
137
+ const forward = new Map(); // module -> Set(module)
138
+ const reverse = new Map(); // module -> Set(module)
139
+ const unresolved = []; // {from, spec} - CALL SITES, not deduped
140
+ let edgeSites = 0;
141
+ let externalSites = 0;
142
+ let dotValueSites = 0;
143
+
144
+ for (const f of files) { forward.set(rel(f), new Set()); reverse.set(rel(f), new Set()); }
145
+
146
+ for (const f of files) {
147
+ const from = rel(f);
148
+ for (const spec of extractStringLiterals(readFile(f))) {
149
+ if (!isRelative(spec)) {
150
+ // bare specifier: 'next/server', 'jose'. External to this package, not a defect.
151
+ if (/^[@a-zA-Z]/.test(spec) && !spec.includes(' ') && spec.length < 100) externalSites++;
152
+ continue;
153
+ }
154
+ const target = resolveSpecifier(f, spec, exists);
155
+ if (!target) {
156
+ // A LONE "." or ".." that does not resolve is the CHARACTER, not a specifier:
157
+ // verified in this dist - every occurrence is `token.split('.')`. `require('.')`
158
+ // is still legal, so we only discount it AFTER resolution has actually failed.
159
+ if (spec === '.' || spec === '..') { dotValueSites++; continue; }
160
+ unresolved.push({ from, spec });
161
+ continue;
162
+ }
163
+ const to = rel(target);
164
+ if (!forward.has(to)) continue; // resolved outside the scanned set
165
+ edgeSites++;
166
+ forward.get(from).add(to);
167
+ reverse.get(to).add(from);
168
+ }
169
+ }
170
+
171
+ let edgeCount = 0;
172
+ for (const s of forward.values()) edgeCount += s.size;
173
+
174
+ return { distDir, modules: [...forward.keys()].sort(), forward, reverse, unresolved, edgeCount, edgeSites, externalSites, dotValueSites };
175
+ }
176
+
177
+ /** Reverse-transitive closure: every module that can reach `target`. */
178
+ export function reachers(graph, target) {
179
+ if (!graph.reverse.has(target)) return null;
180
+ const depth = new Map([[target, 0]]);
181
+ const queue = [target];
182
+ while (queue.length) {
183
+ const cur = queue.shift();
184
+ for (const prev of graph.reverse.get(cur) || []) {
185
+ if (!depth.has(prev)) { depth.set(prev, depth.get(cur) + 1); queue.push(prev); }
186
+ }
187
+ }
188
+ depth.delete(target);
189
+ return depth;
190
+ }
191
+
192
+ /** Every dist/*.js named as a target in package.json "exports". */
193
+ export function exportTargets(pkgJson) {
194
+ const out = new Map(); // distRelPath -> Set(subpath)
195
+ const visit = (subpath, node) => {
196
+ if (typeof node === 'string') {
197
+ if (!node.endsWith('.js')) return;
198
+ // normalise './dist/lib/x.js' and './lib/x.js' (both appear) to 'lib/x.js'
199
+ let p = node.replace(/^\.\//, '');
200
+ p = p.replace(/^dist\//, '');
201
+ if (!out.has(p)) out.set(p, new Set());
202
+ out.get(p).add(subpath);
203
+ return;
204
+ }
205
+ if (node && typeof node === 'object') for (const v of Object.values(node)) visit(subpath, v);
206
+ };
207
+ for (const [subpath, node] of Object.entries(pkgJson.exports || {})) visit(subpath, node);
208
+ if (typeof pkgJson.main === 'string') visit('. (main)', './' + pkgJson.main);
209
+ return out;
210
+ }
211
+
212
+ function main(argv) {
213
+ const arg = (k) => { const i = argv.indexOf(k); return i >= 0 ? argv[i + 1] : undefined; };
214
+ const pkgDir = path.resolve(arg('--pkg') || path.join(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1')), '..'));
215
+ const distDir = path.join(pkgDir, 'dist');
216
+ const json = argv.includes('--json');
217
+ const to = arg('--to');
218
+
219
+ if (!fs.existsSync(distDir)) {
220
+ console.error(`[module-reach] FATAL: no dist at ${distDir}. Run \`npm run build\` first.`);
221
+ console.error('[module-reach] Refusing to report a census over a tree that does not exist.');
222
+ process.exit(2);
223
+ }
224
+
225
+ const pkgJson = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8'));
226
+ const graph = buildGraph(distDir);
227
+ const exports_ = exportTargets(pkgJson);
228
+
229
+ const report = {
230
+ package: pkgJson.name, version: pkgJson.version, distDir,
231
+ modules: graph.modules.length, edges: graph.edgeCount, edgeSites: graph.edgeSites,
232
+ externalSites: graph.externalSites,
233
+ unresolvedSites: graph.unresolved.length,
234
+ exportSubpaths: Object.keys(pkgJson.exports || {}).length,
235
+ exportTargetFiles: exports_.size,
236
+ };
237
+
238
+ if (to) {
239
+ const candidates = [to, `${to}.js`, `${to}/index.js`].map((t) => t.replace(/^\.\//, ''));
240
+ const target = candidates.find((c) => graph.reverse.has(c));
241
+ if (!target) {
242
+ console.error(`[module-reach] FATAL: target not in dist graph: ${to}`);
243
+ console.error(`[module-reach] tried: ${candidates.join(', ')}`);
244
+ process.exit(2);
245
+ }
246
+ const depth = reachers(graph, target);
247
+ const all = [...depth.keys()].sort();
248
+ const direct = all.filter((m) => depth.get(m) === 1);
249
+ const mountable = all.filter((m) => exports_.has(m));
250
+ report.target = target;
251
+ report.directRequirers = direct.length;
252
+ report.reaching = all.length;
253
+ report.mountable = mountable.length;
254
+ report.targetIsItselfMountable = exports_.has(target);
255
+ report.directRequirerList = direct;
256
+ report.mountableList = mountable.map((m) => ({ module: m, subpaths: [...exports_.get(m)] }));
257
+ }
258
+
259
+ if (json) { console.log(JSON.stringify(report, null, 2)); return; }
260
+
261
+ console.log(`[module-reach] ${report.package}@${report.version}`);
262
+ console.log(` dist modules ......... ${report.modules}`);
263
+ console.log(` edges (unique) ....... ${report.edges}`);
264
+ console.log(` edge call sites ...... ${report.edgeSites}`);
265
+ console.log(` external specifiers .. ${report.externalSites} (bare, outside this package)`);
266
+ console.log(` export subpaths ...... ${report.exportSubpaths} -> ${report.exportTargetFiles} distinct dist files`);
267
+ if (report.unresolvedSites === 0) {
268
+ console.log(` unresolved ........... 0 <- graph is complete; counts below are exact`);
269
+ } else {
270
+ console.log(` unresolved ........... ${report.unresolvedSites} *** GRAPH INCOMPLETE - ALL COUNTS BELOW ARE LOWER BOUNDS ***`);
271
+ for (const u of graph.unresolved.slice(0, 20)) console.log(` ${u.from} -> ${u.spec}`);
272
+ }
273
+ if (report.target) {
274
+ console.log('');
275
+ console.log(` TARGET: ${report.target}${report.targetIsItselfMountable ? ' (itself a public export)' : ''}`);
276
+ console.log(` direct requirers ..... ${report.directRequirers}`);
277
+ console.log(` transitively reaching ${report.reaching}`);
278
+ console.log(` ...of which MOUNTABLE ${report.mountable} <- consumer-reachable entry points`);
279
+ console.log('');
280
+ console.log(' BOUND: static reachability over emitted JS. Proves reachable, NOT executed,');
281
+ console.log(' and NOT that reaching it has any consequence. Do not quote as proof of impact.');
282
+ }
283
+ }
284
+
285
+ const invokedDirectly = process.argv[1] && import.meta.url.endsWith(path.basename(process.argv[1]));
286
+ if (invokedDirectly) main(process.argv.slice(2));
@@ -125,12 +125,27 @@ export interface BetterAuthSocialProvider {
125
125
  * globalThis is per-process and per-runtime, which is exactly the lifetime a request-scoped
126
126
  * carrier needs to be reachable from every copy that might run inside the request.
127
127
  */
128
+ /**
129
+ * THE KEY STAYS '__payezIdTokenScope' EVEN THOUGH THE SCOPE NOW CARRIES TWO
130
+ * THINGS. Renaming it would give a copy on the old name and a copy on the new
131
+ * name two DIFFERENT stores in the same process — which is precisely the
132
+ * double-load failure documented above, reintroduced by a cosmetic rename. The
133
+ * name is a wire contract between copies, not a description.
134
+ *
135
+ * accessToken is here for providers that do NOT issue an id_token. GitHub is
136
+ * not an OIDC provider: it has no id_token, no JWKS and no discovery document,
137
+ * so `id_token` can never be the field that proves a GitHub identity. The IDP
138
+ * re-derives that identity by calling GitHub itself with this access token
139
+ * (GitHubIdentityVerifier), which keeps the same rule google follows — the IDP
140
+ * verifies for itself and never trusts an asserted email.
141
+ */
128
142
  const ID_TOKEN_SCOPE_KEY = '__payezIdTokenScope';
143
+ type ProviderTokenScope = { idToken?: string; accessToken?: string };
129
144
  const gScope = globalThis as unknown as Record<
130
145
  string,
131
- AsyncLocalStorage<{ idToken?: string }> | undefined
146
+ AsyncLocalStorage<ProviderTokenScope> | undefined
132
147
  >;
133
- gScope[ID_TOKEN_SCOPE_KEY] ??= new AsyncLocalStorage<{ idToken?: string }>();
148
+ gScope[ID_TOKEN_SCOPE_KEY] ??= new AsyncLocalStorage<ProviderTokenScope>();
134
149
  const idTokenScope = gScope[ID_TOKEN_SCOPE_KEY]!;
135
150
 
136
151
  /**
@@ -242,10 +257,22 @@ export function buildBetterAuthProviders(
242
257
  * remains, so the provider emits exactly its default set.
243
258
  */
244
259
  const GOOGLE_DEFAULT_SCOPES = ['email', 'profile', 'openid'];
260
+ /**
261
+ * Same defect, same fix, different provider: github's createAuthorizationURL
262
+ * also seeds ['read:user','user:email'] unconditionally and then pushes ours
263
+ * on top (@better-auth/core social-providers/github.ts), so the stored row
264
+ * "read:user user:email" reached GitHub as
265
+ * "read:user user:email read:user user:email". Measured on :4799, not
266
+ * assumed. GitHub dedupes, so this is legibility rather than function — but
267
+ * a scope list is the first thing a security reviewer reads off that URL.
268
+ */
269
+ const GITHUB_DEFAULT_SCOPES = ['read:user', 'user:email'];
245
270
  const wireScopes =
246
271
  name === 'google'
247
272
  ? scopes.filter((s) => !GOOGLE_DEFAULT_SCOPES.includes(s))
248
- : scopes;
273
+ : name === 'github'
274
+ ? scopes.filter((s) => !GITHUB_DEFAULT_SCOPES.includes(s))
275
+ : scopes;
249
276
 
250
277
  providers[name] = {
251
278
  clientId: oauth.clientId,
@@ -350,6 +377,98 @@ export function buildBetterAuthProviders(
350
377
  };
351
378
  };
352
379
  }
380
+
381
+ /**
382
+ * GITHUB: THE SAME CAPTURE, A DIFFERENT TOKEN.
383
+ *
384
+ * GitHub is not an OIDC provider — it issues no id_token — so the google
385
+ * branch above can never fire for it and the exchange would post an
386
+ * assertion with nothing that proves identity. That is exactly the
387
+ * 401 UNVERIFIED_ASSERTION measured on :4799, whose diagnostics read
388
+ * `id_token_forwarded: false, id_token_length: 0` with `response_time_ms: 0`:
389
+ * the IDP rejected it instantly, without ever calling GitHub.
390
+ *
391
+ * getUserInfo is the ONLY provider hook that receives the token set (see the
392
+ * google note above), and @better-auth/core's github provider calls
393
+ * `options.getUserInfo(token)` BEFORE its own body — so installing this
394
+ * REPLACES the default rather than wrapping it, and the default's work has
395
+ * to be reproduced here or the sign-in loses its profile entirely.
396
+ *
397
+ * Mirrored verbatim from @better-auth/core/src/social-providers/github.ts at
398
+ * the version the container actually runs (better-auth 1.5.6 — the graft's
399
+ * node_modules says 1.6.29 and is NOT what executes). Kept deliberately
400
+ * dumb: it must not "improve" on upstream, because every divergence here is
401
+ * a behaviour change disguised as a token capture.
402
+ *
403
+ * This is NOT the identity check. The IDP re-derives the GitHub identity
404
+ * itself from this access token and enforces primary && verified server-side
405
+ * (GitHubIdentityVerifier); what we return here only shapes the local
406
+ * better-auth user record.
407
+ */
408
+ if (name === 'github') {
409
+ (providers[name] as unknown as Record<string, unknown>).getUserInfo = async (token: {
410
+ accessToken?: string;
411
+ }) => {
412
+ const store = idTokenScope.getStore();
413
+ if (store) {
414
+ store.accessToken = token?.accessToken;
415
+ } else {
416
+ console.warn(
417
+ '[BETTER_AUTH] github access_token captured but NO REQUEST SCOPE to put it in — ' +
418
+ 'it will NOT be forwarded and the IDP exchange will fail UNVERIFIED_ASSERTION. ' +
419
+ 'Check that GET is createAuthGetHandler(), not routes/auth/nextauth.',
420
+ );
421
+ }
422
+
423
+ const ghHeaders = {
424
+ 'User-Agent': 'better-auth',
425
+ Authorization: `Bearer ${token?.accessToken}`,
426
+ };
427
+
428
+ const profileRes = await fetch('https://api.github.com/user', {
429
+ headers: ghHeaders,
430
+ });
431
+ // Upstream returns null on profile error, which fails the sign-in
432
+ // closed. Preserved rather than softened.
433
+ if (!profileRes.ok) {
434
+ console.warn(
435
+ `[BETTER_AUTH] github getUserInfo: /user returned ${profileRes.status}`,
436
+ );
437
+ return null;
438
+ }
439
+ const profile = (await profileRes.json()) as Record<string, any>;
440
+
441
+ const emailsRes = await fetch('https://api.github.com/user/emails', {
442
+ headers: ghHeaders,
443
+ });
444
+ const emails = emailsRes.ok
445
+ ? ((await emailsRes.json()) as Array<{
446
+ email: string;
447
+ primary: boolean;
448
+ verified: boolean;
449
+ }>)
450
+ : undefined;
451
+
452
+ // A GitHub account with a private email returns profile.email = null;
453
+ // upstream falls back to the primary address here.
454
+ if (!profile.email && emails) {
455
+ profile.email = (emails.find((e) => e.primary) ?? emails[0])?.email;
456
+ }
457
+ const emailVerified =
458
+ emails?.find((e) => e.email === profile.email)?.verified ?? false;
459
+
460
+ return {
461
+ user: {
462
+ id: profile.id,
463
+ name: profile.name || profile.login || '',
464
+ email: profile.email,
465
+ image: profile.avatar_url,
466
+ emailVerified,
467
+ },
468
+ data: profile,
469
+ };
470
+ };
471
+ }
353
472
  }
354
473
 
355
474
  return providers;
@@ -900,6 +1019,21 @@ export async function exchangeOAuthForIdpTokens(
900
1019
  * distinguishable from "the provider issued none".
901
1020
  */
902
1021
  ...(idTokenScope.getStore()?.idToken ? { id_token: idTokenScope.getStore()!.idToken } : {}),
1022
+ /**
1023
+ * The same field for providers that have no id_token to give.
1024
+ *
1025
+ * Identical doctrine to the id_token above: RAW, forwarded verbatim,
1026
+ * NOTHING DERIVED FROM IT, and omitted entirely rather than sent null so
1027
+ * "never captured" stays distinguishable from "provider issued none".
1028
+ * The IDP verifies for itself by calling GitHub with this token and
1029
+ * enforcing primary && verified; we assert nothing about the identity.
1030
+ *
1031
+ * Scoped to the provider access token captured during THIS callback, so
1032
+ * a google login continues to send id_token only and is unaffected.
1033
+ */
1034
+ ...(idTokenScope.getStore()?.accessToken
1035
+ ? { access_token: idTokenScope.getStore()!.accessToken }
1036
+ : {}),
903
1037
  }),
904
1038
  });
905
1039
 
@@ -933,9 +1067,21 @@ export async function exchangeOAuthForIdpTokens(
933
1067
  claims = { decode: 'FAILED — not a well-formed JWT' };
934
1068
  }
935
1069
  }
1070
+ const forwardedAccess = idTokenScope.getStore()?.accessToken;
936
1071
  console.error('[BETTER_AUTH] exchange diagnostics:', {
937
1072
  id_token_forwarded: !!forwarded,
938
1073
  id_token_length: forwarded ? forwarded.length : 0,
1074
+ /**
1075
+ * Read these two for github, and IGNORE the id_token pair above: github
1076
+ * issues no id_token, so `id_token_forwarded: false` is its NORMAL state
1077
+ * and not the fault. Presence and length only — an access token is
1078
+ * credential material and this log is persistent. A github failure with
1079
+ * access_token_forwarded:true means the IDP called GitHub and refused
1080
+ * the answer (unverified or non-primary email, or app not installed);
1081
+ * false means the capture hook never ran and the IDP never called out.
1082
+ */
1083
+ access_token_forwarded: !!forwardedAccess,
1084
+ access_token_length: forwardedAccess ? forwardedAccess.length : 0,
939
1085
  // Absent id_token => the IDP had nothing to verify. Present => compare these
940
1086
  // against the client's registered Google credentials on the IDP.
941
1087
  aud: claims.aud ?? null,
@@ -21,6 +21,27 @@
21
21
  * v1 `startsWith('/') && !startsWith('//')` -> bypassed by /BACKSLASH host
22
22
  * v2 + `!startsWith('/' + BACKSLASH)` -> bypassed by /TAB/host
23
23
  * ... and /LF/host, /CR/host
24
+ * v3 resolve once + compare ORIGIN -> bypassed by /..//host
25
+ * and /.//host, /%2e%2e//host
26
+ * v4 + require the RESULT to be a FIXED POINT <- current
27
+ *
28
+ * *** DRIFT, 2026-09-11 (325543). THIS FILE WAS v3 WHILE THE PORTAL'S COPY WAS
29
+ * *** v4, AND THE PACKAGE COPY IS THE ONE THAT SHIPPED. The portal fixed its own
30
+ * *** src/lib/safe-callback.ts (e1f08fea) and deployed; the served bundle STILL
31
+ * *** escaped 5/5 in a real browser, because `src/contexts/ThemeContext.tsx`
32
+ * *** imports the BARE BARREL `@payez/next-mvp`, which pulls dist/index.js ->
33
+ * *** auth/auth-decision -> THIS FILE into the client graph, and because the
34
+ * *** portal's `src/middleware.ts` imports `@payez/next-mvp/edge`, which requires
35
+ * *** THIS FILE server-side on every request. It was read as a stale build
36
+ * *** artifact of the portal's file for most of a day. It was not. It was a
37
+ * *** SECOND DEFINITION, and no rebuild of the portal could have removed it.
38
+ * *** The header below says "ONE definition ... for the whole package" and that
39
+ * *** was true INSIDE this package boundary the entire time. The defect lived in
40
+ * *** the gap BETWEEN two boundaries that each enforced one-definition correctly.
41
+ * *** A guard that exists in N copies is only as strong as its weakest copy, and
42
+ * *** a per-repo grep gate cannot see N. IF YOU CHANGE THE PREDICATE HERE, CHANGE
43
+ * *** IT IN THE PORTAL'S src/lib/safe-callback.ts IN THE SAME HOUR, OR DELETE ONE
44
+ * *** OF THEM.
24
45
  *
25
46
  * Both bypasses work the same way: the browser NORMALISES the string AFTER the
26
47
  * guard has finished looking at it. The WHATWG URL parser converts backslashes
@@ -75,18 +96,69 @@ export function safeCallbackPath(raw: unknown, fallback: string = DEFAULT_CALLBA
75
96
  if (url.pathname.startsWith(AUTH_PREFIX)) return fallback;
76
97
 
77
98
  // The parser's normalised output - control characters already stripped.
78
- return url.pathname + url.search + url.hash;
79
- }
99
+ const candidate = url.pathname + url.search + url.hash;
80
100
 
81
- /** True when `raw` is a callback target we are willing to honour. */
82
- export function isSafeCallbackPath(raw: unknown): raw is string {
83
- if (typeof raw !== 'string' || raw.length === 0) return false;
101
+ // *** SECOND RESOLUTION. DO NOT DELETE THIS AS REDUNDANT - IT IS NOT. ***
102
+ //
103
+ // v3 (the origin comparison above) was bypassed too, and differently from v1/v2.
104
+ // The origin check constrains where `raw` RESOLVED TO. It does not constrain how
105
+ // the RESULT IS SPELLED - and the result is resolved a SECOND time, by whoever
106
+ // navigates to it. A returned path that begins with `//` is a PROTOCOL-RELATIVE
107
+ // URL at that second resolution, so it lands off-origin having passed every
108
+ // check here:
109
+ //
110
+ // '/..//evil.example.com' -> pathname '//evil.example.com' -> evil.example.com
111
+ // '/.//evil.example.com' -> pathname '//evil.example.com' -> evil.example.com
112
+ // '/%2e%2e//evil.example.com' -> pathname '//evil.example.com' -> evil.example.com
113
+ //
114
+ // The parser resolves `..` against the synthetic base and, at the root, leaves
115
+ // behind the EMPTY SEGMENT rather than collapsing it. Origin stayed BASE the
116
+ // whole time, so nothing upstream fired.
117
+ //
118
+ // MEASURED, not reasoned: in a real headless Chromium on the live origin
119
+ // https://portal.keelauth.com, 5 spellings resolved to https://evil.example.com
120
+ // (QAPert-NightHawk, 2026-09-11, card 325543), and on 2026-09-11 the SERVED
121
+ // BUNDLE was measured escaping 5/5 through THIS PACKAGE'S copy while the
122
+ // portal's own src/lib/safe-callback.ts was already fixed - the two copies had
123
+ // drifted. See the DRIFT note in this file's header.
124
+ //
125
+ // THE FIX FOLLOWS THIS FILE'S OWN DOCTRINE: do not pattern-match the spellings
126
+ // (that is the startsWith() trap that failed four times, and this would be the
127
+ // fifth). Ask the parser AGAIN about the exact value we are about to hand out,
128
+ // and require it to be a FIXED POINT. Anything whose meaning changes when it is
129
+ // resolved a second time is by definition not a stable same-origin path, however
130
+ // it is spelled - including spellings nobody has found yet.
131
+ let echo: URL;
84
132
  try {
85
- const url = new URL(raw, BASE);
86
- return url.origin === BASE && !url.pathname.startsWith(AUTH_PREFIX);
133
+ echo = new URL(candidate, BASE);
87
134
  } catch {
88
- return false;
135
+ return fallback;
89
136
  }
137
+ if (echo.origin !== BASE) return fallback;
138
+ if (echo.pathname.startsWith(AUTH_PREFIX)) return fallback;
139
+ if (echo.pathname + echo.search + echo.hash !== candidate) return fallback;
140
+
141
+ return candidate;
142
+ }
143
+
144
+ /**
145
+ * Sentinel that `safeCallbackPath` can never legitimately return: it is not a
146
+ * fixed point of the URL parser (the parser would percent-encode the space), so
147
+ * it cannot collide with a genuine accepted value.
148
+ */
149
+ const REJECTED = '\u0000rejected';
150
+
151
+ /**
152
+ * True when `raw` is a callback target we are willing to honour.
153
+ *
154
+ * DEFINED IN TERMS OF `safeCallbackPath` ON PURPOSE - it used to carry its own
155
+ * copy of the predicate, and that copy was missing the second resolution above
156
+ * while the two were maintained separately. This file's whole history is
157
+ * duplicated predicates drifting apart (see the header). One definition, one
158
+ * place to fix.
159
+ */
160
+ export function isSafeCallbackPath(raw: unknown): raw is string {
161
+ return typeof raw === 'string' && raw.length > 0 && safeCallbackPath(raw, REJECTED) !== REJECTED;
90
162
  }
91
163
 
92
164
  /**