Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/hono-auth-mount-follows-auth-base-path.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
"@objectstack/hono": minor
"@objectstack/plugin-auth": minor
---

`createHonoApp` mounts the auth surface where the auth service actually serves, and refuses a prefix it cannot serve it under.

The documented embed did not reach better-auth at all. `createHonoApp` mounted `/auth/*` under its own `prefix` (default `/api`) while `AuthPlugin` configures better-auth with `basePath: '/api/v1/auth'`, so the two never intersected. The forwarded request could only 404, that 404 fell through to the terminal dispatcher catch-all, and the caller got a `200` with an empty body. Measured on a real kernel with `AuthPlugin`, driving `createHonoApp({ kernel })` with both defaults untouched:

```
POST /api/auth/sign-in/email (valid shape, wrong password) -> 200 {}
GET /api/auth/get-session -> 200 {}
POST /api/auth/sign-up/email -> 200 {}
```

A failed sign-in answering `200 {}` is the silent-success shape: a client that reads `res.ok` sends the user into an authenticated view with no session. The same boot now answers, through the same embed:

```
POST /api/v1/auth/sign-in/email (wrong password) -> 401 {"message":"Invalid email or password","code":"INVALID_EMAIL_OR_PASSWORD"}
GET /api/v1/auth/get-session -> 200 null
POST /api/v1/auth/delete-user -> 401 {"message":"Unauthorized","code":"UNAUTHORIZED"}
```

**Neither default moves.** `prefix` still defaults to `/api` and the auth `basePath` still defaults to `/api/v1/auth`. What changed is which of the two decides the mount:

- **`@objectstack/hono`** — the `/auth/*` mount is derived from the auth service's configured `basePath`, read at app-construction time, rather than from `prefix`. An auth service that does not expose its base path keeps the previous `${prefix}/auth` mount, so a custom or older auth service is unaffected.
- **`@objectstack/hono`** — a `prefix` the auth base path is not inside now **refuses at construction**, naming both values and every one-line fix that actually constructs: move the app up to the base path's own parent namespace, or configure better-auth down under the prefix (carrying the leading slash the prefix may itself be missing). ⛔ A direction with no working answer is not offered rather than offered wrongly — a single-segment base has no usable parent prefix, because `''` falls back to `/api` and `'/'` mounts every other route of the app under `//`. Previously that composition served auth outside the namespace the host asked for while `${prefix}/auth/*` answered `200 {}`. This is the one behaviour that can stop an app booting: a deployment passing, say, `prefix: '/custom'` alongside the default auth base path was already not serving auth, and now says so instead of failing silently.
- **`@objectstack/plugin-auth`** — `AuthManager.getBasePath()` is new and public: the configured base path in its one normalised spelling (a leading slash added when absent, trailing slashes stripped), which is the spelling an HTTP adapter can mount on. ⛔ **Purely additive — no configured `basePath` changes anything this package does.** better-auth is still handed the configured string verbatim, and the route-ownership walk still normalises its own copy; that copy now reads this accessor instead of repeating the expression. ⛔ It is **not** the string better-auth receives, and it is **not** the single definition of the value. `getAuthIssuer()` and `getMcpResourceUrl()` still derive their own copies and are deliberately unchanged: they are the OAuth `iss` this AS advertises and the RFC 8707 resource identifier a token's `aud` is matched against, both compared by exact string by relying parties, so retiring their copies moves published identifiers and is not a tidy-up that belongs on this card (filed as #16399). Normalising the string handed to better-auth is that same move seen from the other side — it shifts the access-token `iss` off `getAuthIssuer()`, and this manager's own `verifyMcpAccessToken` then rejects every MCP token the deployment mints. Measured on a real `client_credentials` token, and not done.
385 changes: 385 additions & 0 deletions packages/adapters/hono/src/hono-auth-mount-basepath.test.ts

Large diffs are not rendered by default.

185 changes: 180 additions & 5 deletions packages/adapters/hono/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,176 @@ interface AuthService {
* AUTH SERVICE's configured `basePath`, not from this adapter's `prefix`,
* so a deployment whose two disagree gets `false` for everything — the
* yielding, pre-#15928 answer, which is the safe direction.
*
* [#16025] That disagreement is what the mount itself now avoids: it is
* derived from the same `basePath` (see `resolveAuthMount`), so on every
* service that answers `getBasePath` the request this predicate is asked
* about is already under the base it answers on.
*/
ownsRoute?(request: Request): Promise<boolean>;
/**
* Where does this service's OWN router serve, i.e. what did it configure as
* its `basePath`? (#16025)
*
* Optional for the same reason `ownsRoute` is: this is a structural
* interface over whatever the kernel registered as `auth`, and an
* implementation predating the accessor must keep working. `AuthPlugin`'s
* `AuthManager` implements it, returning the very string it hands
* better-auth. A service that does not answer leaves the mount where it was
* before this card — see `resolveAuthMount`.
*/
getBasePath?(): string;
}

/**
* The auth service's configured `basePath`, read at app-construction time, or
* `undefined` when there is nothing to read. (#16025)
*
* ## Why the SYNC accessor
*
* `createHonoApp` is synchronous and returns a mounted `Hono`, so the mount
* path has to be decided before any request exists. `kernel.getService` is the
* synchronous registry lookup; measured on a real boot it returns the very
* same `AuthManager` instance `getServiceAsync` resolves. It throws for a
* FACTORY-registered service that has not been instantiated ("is async - use
* await") exactly as it throws for a service nobody registered — both are
* "cannot read it here", and both land on the pre-#16025 mount rather than on
* a guess.
*
* ⛔ Every non-string, every throw and every empty answer is `undefined`. This
* function can only ever MOVE the mount onto an answer the auth service gave;
* it can never invent one.
*/
function readAuthBasePath(kernel: ObjectKernel): string | undefined {
let service: AuthService | null | undefined;
try {
const getService = (kernel as any)?.getService;
if (typeof getService !== 'function') return undefined;
service = getService.call(kernel, 'auth') as AuthService | null | undefined;
} catch {
return undefined;
}
if (!service || typeof service.getBasePath !== 'function') return undefined;
let raw: unknown;
try {
raw = service.getBasePath();
} catch {
return undefined;
}
if (typeof raw !== 'string') return undefined;
const trimmed = raw.trim();
if (trimmed === '' || trimmed === '/') return undefined;
const withSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
const normalised = withSlash.replace(/\/+$/, '');
return normalised === '' ? undefined : normalised;
}

/** Is `path` the namespace `prefix` names, or something inside it? */
function isUnderPrefix(path: string, prefix: string): boolean {
const base = prefix.replace(/\/+$/, '');
if (base === '') return true;
return path === base || path.startsWith(`${base}/`);
}

/**
* The fixes the refusal offers, each one CHECKED against the same predicate the
* refusal itself uses — because a `Fix:` line that does not fix is a false
* sentence in shipped code, and the first spelling of this message carried two.
*
* Two directions, and the caller picks:
*
* A — move the app UP to the namespace the base already sits in. Offered only
* when that parent is a USABLE prefix. The parent of a single-segment base
* such as `/auth` is `''`, which `createHonoApp` coerces straight back to
* `/api` (`options.prefix || '/api'`), and the `'/'` that reads as its
* equivalent mounts every OTHER route of the app under `//` — measured
* 404 for everything. Suggesting either is advice that does not work, and
* `'/'` is exactly what the first spelling suggested.
* B — move better-auth DOWN under the prefix the caller asked for. Always
* available, but only with a prefix carrying a LEADING SLASH: a base path
* is normalised to start with one and `isUnderPrefix` compares the two as
* written, so NO base path can sit inside a prefix spelled `api/v1`. The
* first spelling suggested `new AuthPlugin({ basePath: 'api/v1/auth' })`
* for exactly that prefix, and it refuses again.
*
* ⛔ This changes what the refusal SAYS, never which compositions it refuses.
*/
function authMountFixes(basePath: string, prefix: string): string[] {
const fixes: string[] = [];

const parent = basePath.split('/').slice(0, -1).join('/');
if (parent !== '' && isUnderPrefix(basePath, parent)) {
fixes.push(`pass a prefix the base path sits under (createHonoApp({ kernel, prefix: '${parent}' }))`);
}

const rooted = (prefix.startsWith('/') ? prefix : `/${prefix}`).replace(/\/+$/, '');
const candidate = `${rooted}/auth`;
if (isUnderPrefix(candidate, rooted)) {
fixes.push(
prefix.startsWith('/')
? `configure the auth service to serve under this prefix (new AuthPlugin({ basePath: '${candidate}' }))`
: `spell the prefix with a leading slash and configure the auth service under it ` +
`(createHonoApp({ kernel, prefix: '${rooted}' }) with new AuthPlugin({ basePath: '${candidate}' })) — ` +
`a base path always starts with '/', so it can never sit inside a prefix that does not`,
);
}

return fixes;
}

/**
* Where the `/auth/*` mount goes, and the boot refusal that guards it (#16025).
*
* ## B — the mount FOLLOWS THE AUTH SERVICE
*
* Maintainer ruling of 2026-09-06 (director batch #54), options A + B. The
* mount is derived from the auth service's own `basePath`, not from this
* adapter's `prefix`, because the two defaults do not compose and the failure
* was invisible. Measured on a real boot through this adapter, before the fix,
* with the documented embed `createHonoApp({ kernel })`:
*
* POST /api/auth/sign-in/email (valid shape, wrong password) -> 200 {}
* GET /api/auth/get-session -> 200 {}
* POST /api/auth/sign-up/email -> 200 {}
*
* — while the same boot answered the auth service directly at its own base:
*
* POST /api/v1/auth/delete-user -> 401 {"message":"Unauthorized","code":"UNAUTHORIZED"}
*
* A failed sign-in answering `200 {}` is the silent-success shape: a client
* reading `res.ok` sends the user into an authenticated view with no session.
* ⛔ Neither default moves — options C and D were rejected in the same ruling.
*
* ## A — and a MISALIGNED prefix refuses out loud
*
* Following the auth service makes the two line up by construction whenever
* the base sits inside the namespace the host asked for, which is true of both
* defaults (`/api/v1/auth` is under `/api`). It does NOT when a caller passes
* a `prefix` the base is outside of: the auth surface would then be served
* outside the namespace the host mounted, and `${prefix}/auth/*` would be
* answered by the terminal dispatcher catch-all — the `200 {}` above. That is
* the one combination this function refuses, naming both values, because the
* ruling's floor is that no combination may fail silently.
*
* ⚠️ Residual, recorded rather than implied: an auth service that does not
* answer `getBasePath` keeps the pre-#16025 mount and buys no refusal, because
* nothing here can tell an aligned custom service from a misaligned one. That
* is the behaviour before this change, not a new one.
*/
function resolveAuthMount(kernel: ObjectKernel, prefix: string): string {
const basePath = readAuthBasePath(kernel);
if (basePath === undefined) return `${prefix}/auth`;
if (!isUnderPrefix(basePath, prefix)) {
throw new Error(
`[@objectstack/hono] createHonoApp cannot mount the auth surface: the auth service serves ` +
`better-auth under basePath "${basePath}", which is not inside this app's prefix "${prefix}". ` +
`Mounting it anyway would put auth outside the namespace this app was given, and every request to ` +
`"${prefix}/auth/*" would be answered by the dispatcher catch-all instead — a 200 with an empty body, ` +
`which reads as success on a failed sign-in. Fix — ` +
`${authMountFixes(basePath, prefix).join('; or ')}.`,
);
}
return basePath;
}

/**
Expand Down Expand Up @@ -133,6 +301,11 @@ export function objectStackMiddleware(kernel: ObjectKernel) {
export function createHonoApp(options: ObjectStackHonoOptions): Hono {
const app = new Hono();
const prefix = options.prefix || '/api';
// [#16025] Where `/auth/*` is mounted, and the boot refusal that guards it.
// Computed BEFORE any route is registered so a misaligned composition never
// gets a half-built app: see `resolveAuthMount` for the ruling and the
// measurement.
const authMount = resolveAuthMount(options.kernel, prefix);
// ADR-0006 Phase 5: env resolution + multi-kernel routing belong to the
// host's KernelResolver (the dispatcher resolves the `kernel-resolver`
// service itself). The legacy envRegistry/kernelManager options are
Expand Down Expand Up @@ -325,7 +498,7 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono {
/**
* Hand a path THIS mount does not own to whatever else matched (#4117).
*
* The `${prefix}/auth/*` mount below claims a whole namespace and used to be
* The `${authMount}/*` mount below claims a whole namespace and used to be
* TERMINAL — it answered 404 for a path its auth service does not implement.
* That is #4088's shape, which cost four fixes before #4116's scan started
* enumerating it, and it is what #4087/#4112 had already concluded about the
Expand Down Expand Up @@ -371,9 +544,9 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono {
};

// --- Auth (needs auth service integration) ---
app.all(`${prefix}/auth/*`, async (c, next) => {
app.all(`${authMount}/*`, async (c, next) => {
try {
const path = c.req.path.substring(`${prefix}/auth/`.length);
const path = c.req.path.substring(authMount.length + 1);
const method = c.req.method;

// Try AuthPlugin service first (prefer async to support factory-based services)
Expand Down Expand Up @@ -456,8 +629,10 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono {
// `@objectstack/plugin-auth`, and should not), so it asks the auth
// SERVICE, which is the very `AuthManager` instance that owns the walk.
//
// ⛔ The mount is untouched and still claims `${prefix}/auth/*`; what
// narrowed is which 404 may be handed on. `/auth/me/permissions` and
// ⛔ #15928 left the mount untouched; what it narrowed is which 404
// may be handed on. (#16025 later moved WHERE the mount sits — see
// `resolveAuthMount` — without touching this decision.)
// `/auth/me/permissions` and
// `/auth/me/localization` are not better-auth endpoints, so they are
// disclaimed and still yield — #4088's ordering-independent surface,
// which objectui's permission layer reads, is unchanged.
Expand Down
Loading
Loading