Skip to content

Commit ee6fbd7

Browse files
fix(plugin-auth): single written definition for the auth basePath default (#17898)
Fixes #16384 ## What changed `'/api/v1/auth'`, the shipped default for `AuthPlugin`'s `basePath` option, was written independently at **four** live sites in `packages/plugins/plugin-auth/src/` (re-derived on `origin/main`, not inherited from the card or from triage — both are stale, see below): | # | Site (this PR's before-state) | Role | |---|---|---| | 1 | `auth-plugin.ts:326` — `basePath: '/api/v1/auth',` | `AuthPlugin` constructor default | | 2 | `auth-plugin.ts:2026` — `this.options.basePath \|\| '/api/v1/auth'` | `registerAuthRoutes`'s fallback | | 3 | `auth-plugin.ts:3099` — `(this.options.basePath ?? '/api/v1/auth').replace(...)` | `registerOidcDiscoveryRoutes`'s well-known-alias fallback | | 4 | `auth-manager.ts:5695` (inside `configuredBasePath()`) — `this.config.basePath \|\| '/api/v1/auth'` | `AuthManager`'s own fallback | (Plus two `@default '/api/v1/auth'` TSDoc annotations — `auth-plugin.ts:168`, `auth-manager.ts:573` — left untouched; they're documentation, not a second definition, and out of this card's stated scope.) The card said "written twice"; triage corrected that to six and named `getAuthIssuer`/`getMcpResourceUrl` as two of them — both were already collapsed by #16399 before this card was claimed, so the live count is four, not six, not two. All four now read `DEFAULT_AUTH_BASE_PATH`, declared once in `auth-manager.ts` (beside `readMcpServerEnabledEnv`), imported into `auth-plugin.ts`. **This only moves where the literal is *written* — every site evaluates byte-identically to before.** In particular this does **not** touch `AuthManager`'s `configuredBasePath()` → `rootedBasePath()` → `getBasePath()` normalisation chain, and does not move the published OAuth `iss` (`getAuthIssuer()`) or the RFC 8707 `aud` (`getMcpResourceUrl()`) — that value is #16399's decision territory, not this card's, per the hard fence in the dispatch. Byte-identical proof: `git diff` on the four sites is a pure token substitution (`'/api/v1/auth'` → `DEFAULT_AUTH_BASE_PATH`, same operator, same position); `DEFAULT_AUTH_BASE_PATH === '/api/v1/auth'` (asserted in the new test, as a hardcoded literal independent of the constant); the full existing `auth-manager-base-path.test.ts` suite (36 cases covering every normalised spelling on a real `betterAuth()` instance) still passes unchanged. ## Where the shared definition lives — Clause-② outcome `DEFAULT_AUTH_BASE_PATH` is declared with `export const` in `auth-manager.ts`. `index.ts` re-exports `auth-manager.ts` wholesale (`export * from './auth-manager.js'`), so **the constant is reachable from the package entry** — confirmed by building the package and grepping the emitted `dist/index.d.ts`, where it appears in the final `export { ... }` list alongside `AuthManager` and `AuthPlugin`. I considered keeping it unreachable (a `export default` from `auth-manager.ts`, which `export *` never re-exports), but rejected it: this codebase has essentially zero precedent for default exports in `packages/plugins/*` (one file, for an unrelated reason), and using one here to dodge the surface diff would read as gaming the gate rather than a real design choice. A reverse import (declaring the constant in `auth-plugin.ts` and having `auth-manager.ts` import it) was the other alternative; rejected because it introduces a new import cycle where none exists today (`auth-plugin.ts` already imports from `auth-manager.ts`; the reverse edge did not exist). So: **this is a published-surface addition** (one new named export, a plain string constant, no behavior attached), which is exactly the kind of widening this seat's `Clause-②: yes` declaration anticipates. `needs:contract-review` is attached to this PR and to #16384 in this same push. ## New test — and why it can't be a value-comparison `AuthPlugin` always supplies `basePath` to `AuthManager` (via `authConfig = { ...this.options, ... }`), so `AuthManager`'s own fallback was already dead on the live path before this change — a fact this card's dispatch notes was proven by an INERT mutation ablation during #16025's round. That means a **value-equality** test (assert site A's default equals site B's default) would still pass after a regression reintroduced a second hardcoded literal with the same current value — it would only fail once someone edited the value in one place and not the other, which is exactly the failure mode this card exists to make impossible. So the regression test is structural, not behavioural: `auth-manager-base-path.test.ts` now reads both files' own source text and asserts the literal `'/api/v1/auth'` appears in **code** position (comments excluded) exactly **once** across the two files — the `DEFAULT_AUTH_BASE_PATH` declaration itself — and that `auth-plugin.ts` references the shared identifier at all four collapsed call sites. A future edit that reintroduces a hardcoded default at any of the four sites fails this test immediately, before it can drift. Alongside that, `auth-plugin.test.ts` gains a runtime test that was simply missing before: the existing suite tested a *custom* `basePath` reaching the mounted route, but never the *default* one. `should mount the default base path when none is configured` closes that gap end-to-end (constructor default → `registerAuthRoutes` → the mocked `rawApp.all` call), asserted against the literal `'/api/v1/auth'` (not the constant) so a typo in `DEFAULT_AUTH_BASE_PATH` itself would still fail it. ## Collateral fix, in-place per this repo's bounded-fix rule `scripts/check-auth-mount-ledger.mjs`'s `deriveBasePath()` read the plugin's `basePath` fallback by regex, anchored on a trailing string literal at the exact site this PR changes (`auth-plugin.ts`'s `registerAuthRoutes`). After the literal became an imported identifier, the gate refused (`NOT MEASURED — no basePath default could be derived`) instead of running its 19-mount census — confirmed before this fix (`pnpm check:auth-mount-ledger` exit 2) and after (exit 0, `OK — 19 ObjectStack auth mount(s), all accounted for`). This qualifies for the repo's bounded in-place-fix exemption: same defect class (this PR's own consolidation broke it), mechanical (one more regex alternative plus a same-shape lookup one file over), the file is held by no other claim, and no new verification surface is added — the two new self-test assertions extend an *existing* battery's *existing* coverage of the *existing* "the base path is derived" invariant, they don't open a new one. `MOUNT_SOURCE` (`auth-plugin.ts`) was already one of the two files that make `dispatch-gates` derive this family for a diff touching it; `AUTH_MANAGER_SOURCE` now joins it as a third such input, so the family keeps deriving correctly for either file going forward. ## Explicitly out of scope (per the dispatch's own clearance) Two out-of-package hits share the same string but are a different kind of thing — a per-service route-convention table row, not a declaration of `AuthPlugin`'s `basePath` default — and are outside this PR's file surface: - `packages/client/src/index.ts` — one row in the client's `routeMap` (16 rows, one per service). - `packages/metadata-protocol/src/protocol.ts` — one row in `SERVICE_CONFIG`'s route table. Neither is touched. ## Local verification - `pnpm --filter '@objectstack/plugin-auth^...' build` — 0 (dependency closure) - `pnpm --filter @objectstack/plugin-auth build` — 0 (confirmed `DEFAULT_AUTH_BASE_PATH` in emitted `dist/index.d.ts`) - `pnpm --filter @objectstack/plugin-auth typecheck` — 0, re-run on the final commit (test-layer debt ledger unchanged: 94 errors / 23 signatures, same as `origin/main`) - `pnpm --filter @objectstack/plugin-auth test` — 0 (108 files / 2287 tests) - `pnpm check:nul-bytes`, `pnpm check:auth-mount-ledger` (post-fix), `pnpm check:ratchet-remedy-authority`, `pnpm check:pnpm-filter-targets` — 0 - Full `dispatch-gates`-derived set for this diff (82 commands across both commits) — all 0 except: - `check:dual-build-cjs-loads`, `check:type-check-debt`'s `--re-measure` leg — `PREREQUISITE NOT MET` (exit 3, not a finding): both need a full-monorepo `pnpm build`, which is CI's to run per this repo's own local-scope convention (`check:type-check-coverage`'s own base measurement, which doesn't need the full closure, reports `OK`). - `check:pm-dispatch-gates` — genuinely `NOT MEASURED`: its self-test ran 1887+ clean assertions with zero failures observed over ~11 minutes before I stopped it as disproportionate to a p3, proportionate-scope card testing the PM tooling's own self-consistency rather than this diff's behaviour. CI runs it required and will give the authoritative verdict. --- _Generated by [Claude Code](https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9c44eed commit ee6fbd7

6 files changed

Lines changed: 219 additions & 16 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@objectstack/plugin-auth': minor
3+
---
4+
5+
fix(plugin-auth): give the auth `basePath` default a single written definition (#16384)
6+
7+
`'/api/v1/auth'`, the shipped default for `AuthPlugin`'s `basePath` option, was
8+
written independently at four sites: the `AuthPlugin` constructor, two later
9+
re-derivations inside `AuthPlugin` (`registerAuthRoutes`, the OIDC discovery
10+
`.well-known` alias), and `AuthManager.configuredBasePath()`'s own fallback.
11+
Nothing was broken by the duplication — `AuthPlugin` always supplies `basePath`
12+
to `AuthManager`, so the manager's copy was dead on the live path and
13+
unfalsifiable by construction: no test could have caught one copy drifting from
14+
the other three.
15+
16+
The default now lives in exactly one place, `DEFAULT_AUTH_BASE_PATH` (exported
17+
from `@objectstack/plugin-auth`, declared beside `readMcpServerEnabledEnv` in
18+
`auth-manager.ts`); all four sites import it instead of retyping the literal.
19+
Every site evaluates byte-identically to before — this is a consolidation of
20+
where the value is *written*, not a change to what any site *evaluates to*, and
21+
in particular does **not** touch `AuthManager`'s `configuredBasePath` →
22+
`rootedBasePath` → `getBasePath` normalisation chain (#16399) or the published
23+
OAuth `iss` / RFC 8707 `aud` identifiers those getters produce.
24+
25+
This is additive and non-breaking — no existing call site's behaviour changes —
26+
but it does add one new named export (`DEFAULT_AUTH_BASE_PATH`) to the
27+
package's public surface, which is what makes this `minor` rather than `patch`.

‎packages/plugins/plugin-auth/src/auth-manager-base-path.test.ts‎

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,10 @@
4848
// that can hold a live better-auth and this manager at once.
4949

5050
import { describe, it, expect } from 'vitest';
51-
import { AuthManager } from './auth-manager';
51+
import { readFileSync } from 'node:fs';
52+
import { fileURLToPath } from 'node:url';
53+
import { resolve, dirname } from 'node:path';
54+
import { AuthManager, DEFAULT_AUTH_BASE_PATH } from './auth-manager';
5255
import type { AuthManagerOptions } from './auth-manager';
5356

5457
const managerWith = (basePath?: unknown) =>
@@ -332,3 +335,68 @@ describe('#16399 one normalisation chain, and an MCP resource URL that is always
332335
}
333336
});
334337
});
338+
339+
/**
340+
* #16384 — the literal `'/api/v1/auth'` used to be written independently at
341+
* FOUR sites: this file's own `configuredBasePath()` fallback, plus three in
342+
* `auth-plugin.ts` (the constructor default, `registerAuthRoutes`'s fallback,
343+
* `registerOidcDiscoveryRoutes`'s fallback). Nothing was BROKEN by that — see
344+
* the card: `AuthPlugin` always supplies `basePath`, so this manager's own
345+
* fallback is dead on the live path, unfalsifiable by construction (measured
346+
* during #16025's round: mutating it to `/api/v7/elsewhere` was INERT). That
347+
* is exactly why a VALUE-comparison test cannot catch a future divergence:
348+
* two independently-typed copies of the same string are byte-identical today
349+
* and would stay green right up until someone edited only one of them — and
350+
* the edited one might be the manager's, which no test on the live path can
351+
* observe at all.
352+
*
353+
* The fix is structural, not behavioural: `DEFAULT_AUTH_BASE_PATH` (declared
354+
* above, next to `readMcpServerEnabledEnv`) is now the only place the literal
355+
* is WRITTEN; every one of the four readers imports it instead of retyping
356+
* it. What this describes pins is that structural fact, by reading the two
357+
* files' own source text — a future edit that reintroduces a hardcoded
358+
* default at any of the four sites (however well-intentioned: "it's just a
359+
* string, inlining it is simpler") reappears here as a second literal and
360+
* fails immediately, before it has any chance to drift from the other three.
361+
*/
362+
describe('#16384 the default base path is written in exactly ONE place', () => {
363+
const HERE = dirname(fileURLToPath(import.meta.url));
364+
const AUTH_MANAGER_SRC = readFileSync(resolve(HERE, 'auth-manager.ts'), 'utf8');
365+
const AUTH_PLUGIN_SRC = readFileSync(resolve(HERE, 'auth-plugin.ts'), 'utf8');
366+
367+
/**
368+
* Lines of source that carry the literal, MINUS comment lines (this file's
369+
* docblocks use a leading `*` per continuation line; ordinary comments use
370+
* `//`). A `@default` TSDoc annotation or a worked example inside a
371+
* docblock is documentation, not a second definition, and stays out of
372+
* this card's scope by design — the dispatch that opened it counts four
373+
* CODE sites and two doc comments separately, and only the four move here.
374+
*/
375+
const codeLinesCarryingTheLiteral = (source: string): string[] =>
376+
source
377+
.split('\n')
378+
.map((line) => line.trim())
379+
.filter((line) => line.includes(`'${DEFAULT_AUTH_BASE_PATH}'`))
380+
.filter((line) => !line.startsWith('*') && !line.startsWith('//'));
381+
382+
it('sanity: DEFAULT_AUTH_BASE_PATH is still the shipped literal this pin reasons about', () => {
383+
// Hardcoded on purpose, not a re-read of the constant — if this constant's
384+
// VALUE ever moves, this line (not the structural assertions below) is
385+
// what should turn red.
386+
expect(DEFAULT_AUTH_BASE_PATH).toBe('/api/v1/auth');
387+
});
388+
389+
it('auth-manager.ts writes the literal exactly once — the constant declaration itself', () => {
390+
expect(codeLinesCarryingTheLiteral(AUTH_MANAGER_SRC)).toEqual([
391+
`export const DEFAULT_AUTH_BASE_PATH = '${DEFAULT_AUTH_BASE_PATH}';`,
392+
]);
393+
});
394+
395+
it('auth-plugin.ts never writes the literal — every site imports the shared constant', () => {
396+
expect(codeLinesCarryingTheLiteral(AUTH_PLUGIN_SRC)).toEqual([]);
397+
// And it isn't simply missing the import either — the three collapsed
398+
// sites (constructor default, registerAuthRoutes, registerOidcDiscoveryRoutes)
399+
// must actually reference the shared binding.
400+
expect(AUTH_PLUGIN_SRC.match(/DEFAULT_AUTH_BASE_PATH/g)?.length ?? 0).toBeGreaterThanOrEqual(4);
401+
});
402+
});

‎packages/plugins/plugin-auth/src/auth-manager.ts‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,21 @@ function readSsoOnlyEnv(): boolean | undefined {
305305
return readBooleanEnv('OS_AUTH_SSO_ONLY');
306306
}
307307

308+
/**
309+
* [#16384] The auth plugin's shipped `basePath` default — the ONE place this
310+
* literal is written. Before this card it existed independently at four
311+
* sites: this file's own `configuredBasePath()` fallback below, plus three in
312+
* `auth-plugin.ts` (the constructor default and two later re-derivations of
313+
* "what if the caller cleared `basePath`?"). A future edit to one could drift
314+
* from the other three silently — `configuredBasePath()`'s own fallback is
315+
* unfalsifiable by construction on the live path (`AuthPlugin` always supplies
316+
* `basePath`, per its constructor default below), so no runtime test could
317+
* have caught that drift. Every one of the four readers now evaluates BYTE
318+
* IDENTICALLY to before this constant existed — see `configuredBasePath()`'s
319+
* docblock for the normalisation chain this does NOT touch.
320+
*/
321+
export const DEFAULT_AUTH_BASE_PATH = '/api/v1/auth';
322+
308323
/**
309324
* Whether this runtime serves the HTTP MCP surface (`/api/v1/mcp`).
310325
* Delegates to the platform-wide decision point (`isMcpServerEnabled` in
@@ -5667,6 +5682,9 @@ export class AuthManager {
56675682
* configured. Unchanged from before this card — only the reading of it moved
56685683
* here, so `getBasePath()` and this cannot drift apart by accident.
56695684
*
5685+
* [#16384] The fallback is `DEFAULT_AUTH_BASE_PATH`, not a re-typed literal
5686+
* — see that constant's docblock above.
5687+
*
56705688
* ## ⛔ Never normalise here
56715689
*
56725690
* better-auth stamps the OAuth access-token `iss` from `ctx.context.baseURL`,
@@ -5692,7 +5710,7 @@ export class AuthManager {
56925710
* #16399's decision, not this card's.
56935711
*/
56945712
private configuredBasePath(): string {
5695-
return this.config.basePath || '/api/v1/auth';
5713+
return this.config.basePath || DEFAULT_AUTH_BASE_PATH;
56965714
}
56975715

56985716
/**

‎packages/plugins/plugin-auth/src/auth-plugin.test.ts‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1240,6 +1240,57 @@ describe('AuthPlugin', () => {
12401240
);
12411241
});
12421242

1243+
// [#16384] Before this card, the DEFAULT base path was independently
1244+
// written at the constructor (line ~326) AND re-derived here in
1245+
// `registerAuthRoutes` (`this.options.basePath || '/api/v1/auth'`) — two
1246+
// literals that happened to agree, with nothing wiring them together. This
1247+
// is the sibling of "should use custom base path" above: that test never
1248+
// exercised the DEFAULT reaching the mount at all. Asserted against the
1249+
// external contract literal, not the `DEFAULT_AUTH_BASE_PATH` binding the
1250+
// implementation now shares — a typo in that constant must still fail
1251+
// this test.
1252+
it('should mount the default base path when none is configured', async () => {
1253+
const { hookFn, trigger } = createHookCapture();
1254+
mockContext.hook = hookFn;
1255+
1256+
authPlugin = new AuthPlugin({
1257+
secret: 'test-secret-at-least-32-chars-long',
1258+
baseUrl: 'http://localhost:3000',
1259+
});
1260+
1261+
await authPlugin.init(mockContext);
1262+
1263+
const mockRawApp = {
1264+
all: vi.fn(),
1265+
get: vi.fn(),
1266+
post: vi.fn(),
1267+
};
1268+
1269+
const mockHttpServer = {
1270+
post: vi.fn(),
1271+
get: vi.fn(),
1272+
put: vi.fn(),
1273+
delete: vi.fn(),
1274+
patch: vi.fn(),
1275+
use: vi.fn(),
1276+
getRawApp: vi.fn(() => mockRawApp),
1277+
};
1278+
1279+
// Cast, not the bare assignment the sibling test above uses: the bare
1280+
// form is TS2322 against `getService`'s generic signature — an
1281+
// ALREADY-tracked debt (`test-typecheck-debt.json`, 11 instances) this
1282+
// new test must not grow to 12.
1283+
mockContext.getService = vi.fn(() => mockHttpServer) as unknown as typeof mockContext.getService;
1284+
1285+
await authPlugin.start(mockContext);
1286+
await trigger('kernel:ready');
1287+
1288+
expect(mockRawApp.all).toHaveBeenCalledWith(
1289+
'/api/v1/auth/*',
1290+
expect.any(Function)
1291+
);
1292+
});
1293+
12431294
it('should configure session options', async () => {
12441295
authPlugin = new AuthPlugin({
12451296
secret: 'test-secret-at-least-32-chars-long',

‎packages/plugins/plugin-auth/src/auth-plugin.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ import {
3535
AuthManager,
3636
resolveOidcProviderEnabled,
3737
readMcpServerEnabledEnv,
38+
// [#16384] The one place `'/api/v1/auth'` is written — see its docblock in
39+
// auth-manager.ts. This file no longer carries an independent copy.
40+
DEFAULT_AUTH_BASE_PATH,
3841
type AuthManagerOptions,
3942
} from './auth-manager.js';
4043
import {
@@ -323,7 +326,7 @@ export class AuthPlugin implements Plugin {
323326
constructor(options: AuthPluginOptions = {}) {
324327
this.options = {
325328
registerRoutes: true,
326-
basePath: '/api/v1/auth',
329+
basePath: DEFAULT_AUTH_BASE_PATH,
327330
...options
328331
};
329332
}
@@ -2023,7 +2026,7 @@ export class AuthPlugin implements Plugin {
20232026
private registerAuthRoutes(httpServer: IHttpServer, ctx: PluginContext): void {
20242027
if (!this.authManager) return;
20252028

2026-
const basePath = this.options.basePath || '/api/v1/auth';
2029+
const basePath = this.options.basePath || DEFAULT_AUTH_BASE_PATH;
20272030

20282031
// Get raw Hono app to use native wildcard routing
20292032
// Type assertion is safe here because we explicitly require Hono server as a dependency
@@ -3096,7 +3099,7 @@ export class AuthPlugin implements Plugin {
30963099
// (including every MCP client bootstrapping from protected-resource
30973100
// metadata) request `/.well-known/oauth-authorization-server/api/v1/auth`
30983101
// — alias it to the same document.
3099-
const basePath = (this.options.basePath ?? '/api/v1/auth').replace(/\/$/, '');
3102+
const basePath = (this.options.basePath ?? DEFAULT_AUTH_BASE_PATH).replace(/\/$/, '');
31003103
rawApp.get(`/.well-known/oauth-authorization-server${basePath}`, (c: any) =>
31013104
withDiscoveryCache(authServerHandler, c.req.raw),
31023105
);

‎scripts/check-auth-mount-ledger.mjs‎

Lines changed: 47 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ import { isEntrypoint } from './invoked-as.mjs';
137137
// must not red. A battery BELOW its floor means cases stopped running; the
138138
// remedy is to find what stopped registering.
139139
const SELF_TEST_BATTERIES = Object.freeze({
140-
'The base path is DERIVED, and its absence is not an empty population.': 2,
140+
'The base path is DERIVED, and its absence is not an empty population.': 4,
141141
'LOAD-BEARING NEGATIVE: a mount with an exact row is clean.': 1,
142142
'LOAD-BEARING POSITIVE: a mount added with no row REDDENS, naming the route.': 2,
143143
'THE RIGHT BOUNDARY, both directions. This is the defect class #10534 fell into.': 4,
@@ -154,7 +154,7 @@ const SELF_TEST_BATTERIES = Object.freeze({
154154
'#8435 remedy authority. PLACEMENT is pinned here, per-gate, because the': 4,
155155
'Parse anchors: a moved anchor is a REFUSAL input, never an empty population.': 2,
156156
'The escaped-quote shape the real notes use is measured, not truncated.': 1,
157-
'And the real inputs on disk are readable, so the anchors have not moved.': 2,
157+
'And the real inputs on disk are readable, so the anchors have not moved.': 3,
158158
});
159159

160160
// DELETING an entry silences that battery's floor exactly as effectively as
@@ -168,11 +168,15 @@ const UNATTRIBUTED_BATTERY = '(no battery open)';
168168

169169
const ROOT = resolve(new URL('..', import.meta.url).pathname);
170170

171-
/** The two inputs. Module-scope literals, so `dispatch-gates` derives this
172-
* family for a diff touching either of them (#10309: a gate nothing can
173-
* derive is a gate that runs only when someone remembers it). */
171+
/** The three inputs. Module-scope literals, so `dispatch-gates` derives this
172+
* family for a diff touching any of them (#10309: a gate nothing can
173+
* derive is a gate that runs only when someone remembers it).
174+
* [#16384] `AUTH_MANAGER_SOURCE` joined the set the day the `basePath`
175+
* fallback in `MOUNT_SOURCE` stopped being a re-typed literal and became an
176+
* identifier declared over there instead — see `deriveBasePath`. */
174177
const MOUNT_SOURCE = 'packages/plugins/plugin-auth/src/auth-plugin.ts';
175178
const LEDGER_SOURCE = 'packages/plugins/plugin-auth/src/auth-route-ledger.ts';
179+
const AUTH_MANAGER_SOURCE = 'packages/plugins/plugin-auth/src/auth-manager.ts';
176180

177181
export const EXIT_CLEAN = 0;
178182
export const EXIT_FINDINGS = 1;
@@ -230,11 +234,26 @@ const LANE_VERBS = new Set(['all', 'use']);
230234
/**
231235
* The auth base path, DERIVED from the plugin rather than re-typed here, so a
232236
* rename moves this gate with it instead of silently emptying its population.
237+
*
238+
* [#16384] The fallback used to be a re-typed string literal at this exact
239+
* spot; it is now the package's single `DEFAULT_AUTH_BASE_PATH` definition,
240+
* imported from `auth-manager.ts` rather than retyped in the plugin. The
241+
* literal-string match stays first (still the shape of every OTHER fallback
242+
* this gate might meet); when the fallback is a bare identifier instead,
243+
* resolve it the same way this function was already justified — one hop
244+
* through the module that actually DECLARES it, never a second hardcoded
245+
* copy — via `importedSource`, the text of the module the plugin imports the
246+
* identifier from.
233247
*/
234-
export function deriveBasePath(mountSource) {
248+
export function deriveBasePath(mountSource, importedSource) {
235249
const masked = maskComments(mountSource);
236-
const m = /basePath\s*=\s*this\.options\.basePath\s*(?:\|\||\?\?)\s*'([^']+)'/.exec(masked);
237-
return m ? m[1] : null;
250+
const m =
251+
/basePath\s*=\s*this\.options\.basePath\s*(?:\|\||\?\?)\s*(?:'([^']+)'|([A-Za-z_$][\w$]*))/.exec(masked);
252+
if (!m) return null;
253+
if (m[1] != null) return m[1];
254+
if (importedSource == null) return null;
255+
const decl = new RegExp(`export const ${m[2]}\\s*=\\s*'([^']+)'`).exec(maskComments(importedSource));
256+
return decl ? decl[1] : null;
238257
}
239258

240259
/**
@@ -627,6 +646,20 @@ function selfTest() {
627646
battery('The base path is DERIVED, and its absence is not an empty population.');
628647
ok(deriveBasePath(FIXTURE_PREAMBLE) === FIXTURE_BASE, 'basePath was not derived from the plugin');
629648
ok(deriveBasePath('const basePath = 42;') === null, 'a plugin with no derivable basePath did not refuse');
649+
// [#16384] The fallback is now a named identifier import in the real plugin,
650+
// not a re-typed literal — resolved one hop through the module it is
651+
// DECLARED in, never guessed at.
652+
ok(
653+
deriveBasePath(
654+
"const basePath = this.options.basePath || DEFAULT_AUTH_BASE_PATH;\n",
655+
"export const DEFAULT_AUTH_BASE_PATH = '/api/v1/auth';\n",
656+
) === FIXTURE_BASE,
657+
'an identifier fallback was not resolved through the imported module',
658+
);
659+
ok(
660+
deriveBasePath("const basePath = this.options.basePath || DEFAULT_AUTH_BASE_PATH;\n") === null,
661+
'an identifier fallback resolved to something without an imported module to read',
662+
);
630663

631664
// -- LOAD-BEARING NEGATIVE: a mount with an exact row is clean.
632665
battery('LOAD-BEARING NEGATIVE: a mount with an exact row is clean.');
@@ -895,7 +928,7 @@ function selfTest() {
895928

896929
// -- And the real inputs on disk are readable, so the anchors have not moved.
897930
battery('And the real inputs on disk are readable, so the anchors have not moved.');
898-
for (const rel of [MOUNT_SOURCE, LEDGER_SOURCE]) {
931+
for (const rel of [MOUNT_SOURCE, LEDGER_SOURCE, AUTH_MANAGER_SOURCE]) {
899932
ok(existsSync(join(ROOT, rel)), `${rel} does not exist -- this gate's anchor moved`);
900933
}
901934

@@ -981,14 +1014,17 @@ function main() {
9811014

9821015
const mountAbs = join(ROOT, MOUNT_SOURCE);
9831016
const ledgerAbs = join(ROOT, LEDGER_SOURCE);
1017+
const authManagerAbs = join(ROOT, AUTH_MANAGER_SOURCE);
9841018
if (!existsSync(mountAbs)) refuse(`${MOUNT_SOURCE} does not exist`);
9851019
if (!existsSync(ledgerAbs)) refuse(`${LEDGER_SOURCE} does not exist`);
1020+
if (!existsSync(authManagerAbs)) refuse(`${AUTH_MANAGER_SOURCE} does not exist`);
9861021

9871022
const mountText = readFileSync(mountAbs, 'utf8');
9881023
const ledgerText = readFileSync(ledgerAbs, 'utf8');
1024+
const authManagerText = readFileSync(authManagerAbs, 'utf8');
9891025

990-
const basePath = deriveBasePath(mountText);
991-
if (!basePath) refuse(`no \`basePath\` default could be derived from ${MOUNT_SOURCE}`);
1026+
const basePath = deriveBasePath(mountText, authManagerText);
1027+
if (!basePath) refuse(`no \`basePath\` default could be derived from ${MOUNT_SOURCE} (or its import from ${AUTH_MANAGER_SOURCE})`);
9921028

9931029
const rows = parseLedgerRows(ledgerText);
9941030
if (rows === null) refuse(`the AUTH_ROUTE_LEDGER anchor was not found in ${LEDGER_SOURCE}`);

0 commit comments

Comments
 (0)