Skip to content

Commit f1a3d91

Browse files
committed
feat(plugin-auth): expose the configured better-auth basePath as one definition
`AuthManager.config` was private and nothing else exposed the base path better-auth is configured with, so an HTTP adapter mounting this service had no way to ask where its routes live. `getBasePath()` answers that, and is now the single definition of the value: `createAuthInstance` hands better-auth exactly this string and `betterAuthEndpointPath` reads the same call. The two sites previously normalised independently and disagreed on a configured value written without a leading slash — `api/v1/auth` reached better-auth verbatim while the route-ownership walk tested `/api/v1/auth`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
1 parent a87163c commit f1a3d91

2 files changed

Lines changed: 106 additions & 3 deletions

File tree

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// #16025 — `AuthManager.getBasePath()`, the one definition of "where does
4+
// better-auth serve".
5+
//
6+
// ## Why this member is public, and why a rename is a breaking change
7+
//
8+
// An HTTP adapter that mounts this service has to know where its routes live.
9+
// Until this accessor existed it could not ask — `config` is private and
10+
// nothing exposed the value — so `@objectstack/hono`'s `createHonoApp` mounted
11+
// the auth surface under its OWN `prefix` option, whose default (`/api`) does
12+
// not compose with this one (`/api/v1/auth`). Measured on a real boot with the
13+
// documented embed `createHonoApp({ kernel })`, before the fix:
14+
//
15+
// POST /api/auth/sign-in/email (valid shape, wrong password) -> 200 {}
16+
//
17+
// `createHonoApp` now derives the mount from this method, by name, through a
18+
// structural interface (the adapter does not depend on this package). ⇒ A
19+
// rename or removal here silently returns that adapter to the mount above.
20+
//
21+
// ⛔ The behaviour that matters most is NOT assertable from this package: that
22+
// better-auth is really configured with the string this returns. It is one
23+
// expression — `createAuthInstance` passes `this.getBasePath()` and
24+
// `betterAuthEndpointPath` reads the same call — so the two cannot disagree by
25+
// construction rather than by two sites happening to agree. The observable
26+
// proof runs on a real boot in `@objectstack/verify`
27+
// (`auth-base-path-contract.test.ts`), which is the nearest package that can
28+
// hold a live better-auth and this manager at once.
29+
30+
import { describe, it, expect } from 'vitest';
31+
import { AuthManager } from './auth-manager';
32+
import type { AuthManagerOptions } from './auth-manager';
33+
34+
const managerWith = (basePath?: unknown) =>
35+
new AuthManager({ ...(basePath === undefined ? {} : { basePath }) } as unknown as AuthManagerOptions);
36+
37+
describe('#16025 AuthManager.getBasePath', () => {
38+
it('is a public member — the surface @objectstack/hono reads by name', () => {
39+
expect(typeof managerWith().getBasePath).toBe('function');
40+
});
41+
42+
it('defaults to the shipped base path when nothing is configured', () => {
43+
expect(managerWith().getBasePath()).toBe('/api/v1/auth');
44+
});
45+
46+
it('answers the CONFIGURED base path, which is the point of asking', () => {
47+
expect(managerWith('/api/v9/identity').getBasePath()).toBe('/api/v9/identity');
48+
});
49+
50+
it('normalises the spellings that used to reach better-auth and the ownership walk differently', () => {
51+
// Before this method there were two normalising sites and they disagreed:
52+
// a configured `api/v1/auth` reached better-auth WITHOUT its leading slash
53+
// while `betterAuthEndpointPath` tested against `/api/v1/auth`.
54+
expect(managerWith('api/v1/auth').getBasePath()).toBe('/api/v1/auth');
55+
expect(managerWith('/api/v1/auth/').getBasePath()).toBe('/api/v1/auth');
56+
expect(managerWith('/api/v1/auth///').getBasePath()).toBe('/api/v1/auth');
57+
expect(managerWith('api/v1/auth/').getBasePath()).toBe('/api/v1/auth');
58+
});
59+
60+
it('treats an empty configured value as unset, exactly as the pre-#16025 readers did', () => {
61+
expect(managerWith('').getBasePath()).toBe('/api/v1/auth');
62+
});
63+
64+
it('leaves a configured root as the empty base — unchanged behaviour, pinned so it is a decision', () => {
65+
// `'/'` normalises to `''`, which is what `betterAuthEndpointPath` has
66+
// always computed for it. The hono adapter rejects that answer as unusable
67+
// and keeps its previous mount rather than mounting at the app root.
68+
expect(managerWith('/').getBasePath()).toBe('');
69+
});
70+
});

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

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1249,7 +1249,7 @@ export class AuthManager {
12491249
// bare host) so the reset-password / verify-email / magic-link URLs
12501250
// better-auth derives from baseURL are always clickable links.
12511251
baseURL: this.getCanonicalOrigin(),
1252-
basePath: this.config.basePath || '/api/v1/auth',
1252+
basePath: this.getBasePath(),
12531253

12541254
// Database adapter configuration
12551255
database: this.createDatabaseConfig(),
@@ -5445,6 +5445,40 @@ export class AuthManager {
54455445
return response;
54465446
}
54475447

5448+
/**
5449+
* [#16025] The path prefix better-auth matches its routes under — the SAME
5450+
* string this manager hands better-auth as its `basePath`, normalised once.
5451+
*
5452+
* ## Why this is public, and why it is the ONLY definition
5453+
*
5454+
* An HTTP adapter that mounts this service has to know where its routes
5455+
* live, and until this method existed it could not ask: `config` is private
5456+
* and nothing else exposed the value. `@objectstack/hono`'s `createHonoApp`
5457+
* therefore mounted the auth surface under its OWN `prefix` option, whose
5458+
* default (`/api`) does not compose with this one (`/api/v1/auth`), so on
5459+
* the documented embed better-auth was never reached at all — measured, on a
5460+
* real boot:
5461+
*
5462+
* POST /api/auth/sign-in/email (valid shape, wrong password) -> 200 {}
5463+
* POST /api/v1/auth/delete-user -> 401 {"message":"Unauthorized","code":"UNAUTHORIZED"}
5464+
*
5465+
* ⛔ The value must not be re-derived by any caller, here or in an adapter.
5466+
* Two readers already existed inside this file — the `basePath` handed to
5467+
* better-auth and `betterAuthEndpointPath`'s own normalising copy — and they
5468+
* normalised DIFFERENTLY: a configured `'api/v1/auth'` reached better-auth
5469+
* without its leading slash while the ownership walk tested against
5470+
* `'/api/v1/auth'`. Both now read this method, so "where better-auth serves"
5471+
* has one answer by construction rather than by three sites agreeing.
5472+
*
5473+
* Normalisation is exactly what `betterAuthEndpointPath` always applied: a
5474+
* leading slash is added when absent, trailing slashes are stripped. A
5475+
* configured `'/'` still normalises to `''`, unchanged from before.
5476+
*/
5477+
getBasePath(): string {
5478+
const configured = this.config.basePath || '/api/v1/auth';
5479+
return (configured.startsWith('/') ? configured : `/${configured}`).replace(/\/+$/, '');
5480+
}
5481+
54485482
/**
54495483
* [#15417] Does better-auth ROUTE this request — i.e. is the path one its own
54505484
* router owns, whatever it then answers?
@@ -5499,8 +5533,7 @@ export class AuthManager {
54995533
} catch {
55005534
return undefined;
55015535
}
5502-
const configured = this.config.basePath || '/api/v1/auth';
5503-
const base = (configured.startsWith('/') ? configured : `/${configured}`).replace(/\/+$/, '');
5536+
const base = this.getBasePath();
55045537
if (!pathname.startsWith(base)) return undefined;
55055538
const endpoint = pathname.slice(base.length).replace(/\/+$/, '');
55065539
return endpoint.startsWith('/') ? endpoint : undefined;

0 commit comments

Comments
 (0)