Skip to content

Commit ad8414f

Browse files
elliotBraemadenarc
andauthored
fix(compose): mount guards are framework-supplied beforeLoads (#825)
* fix(compose): mount guards are framework-supplied beforeLoads — client-rendered loads resolve the session (#818) On a client-rendered full page load the root context's session is still unresolved when the mount gates ran, so they read a signed-in visitor as signed out — authed pages bounced through /login, and an admin reloading /admin landed on /dashboard. The mount registry no longer declares gate kinds. Each mount carries shape metadata (guarded, implemented), and constructTree takes guards — ordinary beforeLoads keyed by mount id — supplied by the composing framework; construction fails loudly when a guarded mount has none. The default wiring guards authenticated with requireSession and admin with requireAdmin, so guards resolve through the session query cache (SSR cache-hit, client-rendered one cached fetch) and inherit the auth policy — tri-state reads, banned handling, login redirects with the return path — from one place. The engine loses its last auth semantics: HostContext, GateUser, /login, and role checks are gone. Co-authored-by: adenarc <334235131+adenarc@users.noreply.github.com> * fix(compose): standalone-clean guard wiring — session-cache split from the auth client; CSR spec drops the anonymous logout jar CI on #825 caught two defects in the new surface: 1. The shim's static edge into auth-guards dragged the better-auth client (@better-auth/api-key) into everything-dev/ui/manifest, and the standalone tarball install could not resolve it. The session-cache primitives (sessionQueryKey, sessionQueryOptions, refreshSessionCache) move to ui/auth-session — type-only imports, no runtime deps — and auth.ts re-exports them; auth-guards consumes the dependency-free module, so the manifest surface imports clean. 2. csr-guarded-reload's signed-out case injected the "logout" cookie jar, which is an anonymous signed-in session — requireSession passes and requireAdmin redirects to /dashboard. Signed-out is no cookies at all; the test now asserts /admin → /login?redirect=%2Fadmin on a fresh context. * fix(compose): guard wiring moves to the core ui's composition entry — ui/manifest is standalone again The standalone-install gate failure was a layering signal, not a packaging problem: the shim put the app's auth policy inside the generic composition surface, which must import with only everything-dev's declared dependencies (it is what the host consumes). Both attempted workarounds — a dependency-free auth-session split, then a dynamic import — patched policy living in the wrong layer. The wiring now lives where the core ui composes itself, where the auth client is already present: a new everything-dev/ui/compose entry (the engine plus the default guards) consumed by the client boot (hydrate) and the ./compose expose the host loads for SSR (compose.gen template). ui/manifest returns to main's pure re-export byte-for-byte, the auth-session split is reverted (auth.ts is the only auth module), and the standalone gate passes by construction. * fix(compose): the compose entry exports only constructTree — template sources engine constants from ui/manifest tsdown treeshake dropped the re-exported external bindings (CORE_UI_PLUGIN_KEY, ComposePayloadSchema) from dist/ui/compose.mjs, so every consumer that builds against dist (the ui container) failed ESModulesLinkingError while typecheck passed — it resolves the development condition against src. Verified the fix against both resolution conditions: dist entries node-imported with asserted export keys, and the host suite whose setup builds the ui container. CORE_UI_PLUGIN_KEY and ComposePayloadSchema are engine vocabulary and stay on ui/manifest; everything-dev/ui/compose exports the wired constructTree, which is what it is. --------- Co-authored-by: adenarc <334235131+adenarc@users.noreply.github.com>
1 parent d323f73 commit ad8414f

19 files changed

Lines changed: 342 additions & 192 deletions

File tree

‎.changeset/mount-guards.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"every-plugin": patch
3+
"everything-dev": patch
4+
---
5+
6+
Mount guards are ordinary beforeLoads supplied by the composing framework, replacing gate kinds. The mount registry now carries shape metadata only (`guarded`, `implemented`) and `constructTree` takes `guards` keyed by mount id — construction fails loudly when a guarded mount has none. The default wiring guards `authenticated` with `requireSession` and `admin` with `requireAdmin`, so a client-rendered full page load resolves the session through the query cache instead of reading a signed-in visitor as signed out (#818): full loads of authed pages no longer bounce through `/login`, and admin pages keep the `?redirect=` return path. Banned users now bounce at the mount, and signed-in non-admins on admin mounts land on `/dashboard`. The engine's invented `HostContext`/`GateUser` types are gone — it has no auth semantics.

‎docs/adr/0008-manifest-composition.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,7 @@ Investigation (2026-09) found the framework itself converging on the same substr
5252
- `MOUNT_REGISTRY_VERSION` bumps; all compose digests invalidate once.
5353
- Plan 025 (landing carve-out) executes manifest-first as the second production consumer; plan 026 (shell into host) simplifies — the host constructs all routes, in-process or manifest-fed, one construction story; plan 028 (`app.ts`) absorbs a stable manifest-only plugin shape.
5454
- `bos types gen` gains merged route-type emission from manifests (typed cross-plugin `<Link>`) — replaces the `pluginPath` seam.
55+
56+
## Amendment (2026-11): mount guards are beforeLoads, not gate kinds
57+
58+
The registry no longer declares gate kinds (`none`/`session`/`admin`/`organization`/`team`) — each mount carries shape metadata only (`guarded`, `implemented`). Guard implementations are ordinary `beforeLoad`s supplied by the composing framework via `ConstructInput.guards`, keyed by mount id; the engine composes guard-then-declared and fails construction loudly when a `guarded` mount has no guard. The engine becomes fully auth-agnostic (no `/login`, no user/role knowledge); session semantics — tri-state query-cache reads (#527/#689), banned handling, login redirects — live in the framework glue and ride the same guards apps author in their own route files. `HostContext`/`GateUser` are deleted; the engine↔glue contract is TanStack's own `beforeLoad` shape. `MOUNT_REGISTRY_VERSION` bumps to 6; compose digests invalidate once (as the bump exists to do).

‎host/tests/integration/ui-compose-auth-tree.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ describe("constructTree over the auth route shape", () => {
7676
{ key: "ui", mfName: "ui" },
7777
{ key: "auth", mfName: "auth-ui" },
7878
],
79+
guards: { authenticated: async () => undefined, admin: async () => undefined },
7980
resolve: async (ref) => {
8081
if (ref.key === "ui")
8182
return { key: "ui", manifest: CORE_MANIFEST, routeConfig: coreRouteConfig };

‎packages/every-plugin/src/build/ui/stubs.ts‎

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -65,19 +65,14 @@ export type {
6565
ConstructedTree,
6666
ConstructInput,
6767
ConstructPluginRef,
68-
GateUser,
69-
HostContext,
7068
NavManifest,
7169
PluginManifest,
7270
ResolvedPlugin,
7371
RouteConfigModule,
7472
RouteOptionsBundle,
7573
} from "everything-dev/ui/manifest";
76-
export {
77-
CORE_UI_PLUGIN_KEY,
78-
ComposePayloadSchema,
79-
constructTree,
80-
} from "everything-dev/ui/manifest";
74+
export { CORE_UI_PLUGIN_KEY, ComposePayloadSchema } from "everything-dev/ui/manifest";
75+
export { constructTree } from "everything-dev/ui/compose";
8176
`,
8277
"src/globals.gen.ts": `// GENERATED FILE — do not edit. Regenerated from the installed framework version
8378
// by \`bos dev\` / \`bos build\` / \`bos typecheck\` (gitignored).

‎packages/every-plugin/src/ui/manifest/construct.ts‎

Lines changed: 20 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,20 @@
11
import type { AnyRoute } from "@tanstack/react-router";
2-
import { createRootRoute, createRoute, Outlet, redirect } from "@tanstack/react-router";
2+
import { createRootRoute, createRoute, Outlet } from "@tanstack/react-router";
33
import { CORE_UI_PLUGIN_KEY } from "./contract";
44
import { digestOf } from "./descriptor";
55
import type { PluginManifest, RouteRecord } from "./manifest-schema";
6-
import { MOUNT_REGISTRY, type MountDef, type MountId, resolveMountSegment } from "./mount-registry";
6+
import { MOUNT_REGISTRY, type MountId, resolveMountSegment } from "./mount-registry";
77
import { compareNavItems, type NavDeclaration, type NavItem, type NavManifest } from "./nav";
88
import { ROUTE_OPTION_KEYS, type RouteConfigModule, type RouteOptionsBundle } from "./route-config";
99

1010
/**
1111
* Host-side route-graph construction (ADR 0008 §1): manifests + route
1212
* configs in → host-built tree out. The host constructs ITS OWN Route
1313
* objects from validated manifest data via public `createRoute` APIs; no
14-
* foreign route object is ever mutated. Gates are attached HOST-side from
15-
* the mount registry — plugins cannot ship a route that escapes its
16-
* mount's gate because plugins never construct route objects.
14+
* foreign route object is ever mutated. Mount guards are ordinary
15+
* beforeLoads attached HOST-side from ConstructInput.guards — plugins
16+
* cannot ship a route that escapes its mount's guard because plugins never
17+
* construct route objects.
1718
*
1819
* Mounts are the ONLY cross-source parenting points: a root-level
1920
* pathless layout file (`_public.tsx`) declares the mount for the whole
@@ -34,57 +35,6 @@ function toAnyRoute(route: unknown): AnyRoute {
3435
return route as AnyRoute;
3536
}
3637

37-
export interface GateUser {
38-
id: string;
39-
name?: string;
40-
/** Better Auth user role — "admin" gates the admin mount. */
41-
role?: string;
42-
}
43-
44-
export interface HostContext {
45-
user?: GateUser;
46-
session?: { user?: GateUser };
47-
}
48-
49-
type GateFn = (args: {
50-
context: HostContext;
51-
location: { pathname: string; searchStr: string };
52-
}) => void;
53-
54-
/**
55-
* Gate redirects are absolute: gates attach to root-level mount routes, and
56-
* relatives would silently shift if a mount ever moved deeper in the tree.
57-
* The login surface is plugin-owned, never core-known.
58-
*/
59-
const LOGIN_TO: string = "/login";
60-
const HOME_TO: string = "/";
61-
62-
/**
63-
* Gate implementations by mount gate kind. Only `session` and `admin` are
64-
* implemented (ADR 0008 §3); unimplemented mounts are rejected during mount
65-
* declaration before any gate lookup, and gates are optional by type — a
66-
* mount without an entry (or `none`) renders ungated.
67-
*/
68-
const GATES: Partial<Record<MountDef["gate"], GateFn>> = {
69-
session: ({ context, location }) => {
70-
const user = userOf(context);
71-
if (!user) {
72-
throw redirect({
73-
to: LOGIN_TO,
74-
search: { redirect: location.pathname + location.searchStr } as never,
75-
});
76-
}
77-
},
78-
admin: ({ context }) => {
79-
const user = userOf(context);
80-
if (!user) throw redirect({ to: LOGIN_TO });
81-
if (user.role !== "admin") throw redirect({ to: HOME_TO });
82-
},
83-
};
84-
85-
const userOf = (context: HostContext | undefined): GateUser | undefined =>
86-
context?.user ?? context?.session?.user;
87-
8838
export interface ConstructPluginRef {
8939
/** composition identity — plugin key; participates in the digest */
9040
key: string;
@@ -104,6 +54,8 @@ export interface ConstructInput {
10454
resolve: (ref: ConstructPluginRef) => Promise<ResolvedPlugin>;
10555
/** core ui `__root` options — component/error/notFound + head baseline */
10656
rootOptions?: RouteOptionsBundle;
57+
/** Guard per mount id — beforeLoads supplied by the composing framework. */
58+
guards?: Partial<Record<MountId, RouteOptionsBundle["beforeLoad"]>>;
10759
}
10860

10961
export interface ConstructedTree {
@@ -139,17 +91,17 @@ const navOf = (options: RouteOptionsBundle): NavDeclaration | undefined => {
13991
};
14092

14193
/** Layout routes (mount or pathless) pass their authored options through,
142-
* like every other route kind. On mount routes the registry gate composes
94+
* like every other route kind. On mount routes the mount's guard composes
14395
* BEFORE the declaration's own beforeLoad so a source can never escape its
144-
* mount's gate (ADR 0008 §3), while the declaration's return value still
96+
* mount's guard (ADR 0008 §3), while the declaration's return value still
14597
* flows to children as route context. */
14698
function layoutBeforeLoad(
147-
gate: GateFn | undefined,
99+
guard: RouteOptionsBundle["beforeLoad"],
148100
declared: RouteOptionsBundle["beforeLoad"],
149101
): RouteOptionsBundle["beforeLoad"] {
150-
if (!gate) return declared;
151-
return (args) => {
152-
gate(args);
102+
if (!guard) return declared;
103+
return async (args) => {
104+
await guard(args);
153105
return declared?.(args);
154106
};
155107
}
@@ -270,13 +222,16 @@ export async function constructTree(input: ConstructInput): Promise<ConstructedT
270222
const declaration = declarations.get(mountId);
271223
if (!declaration) continue;
272224
const def = MOUNT_REGISTRY[mountId]!;
273-
const gate = GATES[def.gate];
225+
const guard = input.guards?.[mountId];
226+
if (def.guarded && !guard) {
227+
throw new Error(`mount "${mountId}" requires a guard — add one to ConstructInput.guards`);
228+
}
274229
const options = declaration.options;
275230
const route = createRoute({
276231
id: `__mount_${mountId}`,
277232
getParentRoute: () => rootRoute,
278-
...(gate || options.beforeLoad
279-
? { beforeLoad: layoutBeforeLoad(gate, options.beforeLoad) }
233+
...(guard || options.beforeLoad
234+
? { beforeLoad: layoutBeforeLoad(guard, options.beforeLoad) }
280235
: {}),
281236
...authoredOptions(options),
282237
component: options.component ?? Outlet,

‎packages/every-plugin/src/ui/manifest/index.ts‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,6 @@ export {
1010
type ConstructInput,
1111
type ConstructPluginRef,
1212
constructTree,
13-
type GateUser,
14-
type HostContext,
1513
type ResolvedPlugin,
1614
} from "./construct";
1715
export * from "./contract";

‎packages/every-plugin/src/ui/manifest/mount-registry.ts‎

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,40 @@
11
/**
2-
* Mount registry v2 — gates only, shape-free (ADR 0008 §3).
2+
* Mount registry v2 — shape-free (ADR 0008 §3).
33
*
4-
* A mount is a gate + a host-owned pathless layout position. It carries no
5-
* product semantics (no "dashboard", no shell, no chrome) — plugins bring
6-
* their own layouts and route freely inside their subtree. A mount is
7-
* declared by a ROOT-level pathless layout file whose `_`-prefixed segment
8-
* names the mount (`_public.tsx` → `public`); the generator derives it and
9-
* validates it against this registry; gates are attached HOST-side at
10-
* construction, so a plugin cannot ship a route that escapes its mount's
11-
* gate (plugins never construct route objects).
4+
* A mount is a host-owned pathless layout position. It carries no product
5+
* semantics (no "dashboard", no shell, no chrome) — plugins bring their own
6+
* layouts and route freely inside their subtree. A mount is declared by a
7+
* ROOT-level pathless layout file whose `_`-prefixed segment names the mount
8+
* (`_public.tsx` → `public`); the generator derives it and validates it
9+
* against this registry; guards are ordinary beforeLoads attached HOST-side
10+
* at construction from ConstructInput.guards, so a plugin cannot ship a
11+
* route that escapes its mount's guard (plugins never construct route
12+
* objects).
1213
*
13-
* `org`/`team` are declared vocabulary (parameterized membership gates) but
14+
* `org`/`team` are declared vocabulary (parameterized membership) but
1415
* unimplemented — construction rejects them until they land.
1516
*/
1617

1718
export interface MountDef {
18-
label: string;
19-
gate: "none" | "session" | "admin" | "organization" | "team";
20-
parameterized?: boolean;
19+
/** The composing framework supplies this mount's guard via ConstructInput.guards. */
20+
guarded: boolean;
2121
implemented: boolean;
2222
}
2323

2424
export const MOUNT_REGISTRY = {
25-
public: { label: "public", gate: "none", implemented: true },
26-
authenticated: { label: "authenticated", gate: "session", implemented: true },
27-
admin: { label: "admin", gate: "admin", implemented: true },
28-
org: { label: "org", gate: "organization", parameterized: true, implemented: false },
29-
team: { label: "team", gate: "team", parameterized: true, implemented: false },
30-
} satisfies Record<string, MountDef>;
25+
public: { guarded: false, implemented: true },
26+
authenticated: { guarded: true, implemented: true },
27+
admin: { guarded: true, implemented: true },
28+
org: { guarded: true, implemented: false },
29+
team: { guarded: true, implemented: false },
30+
} as const satisfies Record<string, MountDef>;
3131

3232
export type MountId = Extract<keyof typeof MOUNT_REGISTRY, string>;
3333

3434
export const MOUNTS = Object.keys(MOUNT_REGISTRY) as MountId[];
3535

3636
/** Bump when the registry shape changes — invalidates all compose digests. */
37-
export const MOUNT_REGISTRY_VERSION = 5;
37+
export const MOUNT_REGISTRY_VERSION = 6;
3838

3939
/** Migration aliases: legacy `_`-segment names → canonical mounts. */
4040
export const MOUNT_ALIASES: Record<string, MountId> = {

‎packages/every-plugin/tests/unit/construct-precedence.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,7 @@ describe("constructTree core-over-plugin precedence (ADR 0010 decision #1)", ()
284284
const tree = await constructTree({
285285
name: "server",
286286
plugins: [core.ref, auth.ref],
287+
guards: { authenticated: async () => undefined, admin: async () => undefined },
287288
resolve: async (ref) => {
288289
if (ref.key === "ui") return core.resolve(ref);
289290
return { key: "auth", manifest: authManifest, routeConfig: authConfig };

‎packages/everything-dev/package.json‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,15 @@
378378
"import": "./dist/ui/manifest/index.mjs",
379379
"require": "./dist/ui/manifest/index.cjs"
380380
},
381+
"./ui/compose": {
382+
"development": {
383+
"types": "./src/ui/compose.ts",
384+
"import": "./src/ui/compose.ts"
385+
},
386+
"types": "./dist/ui/compose.d.mts",
387+
"import": "./dist/ui/compose.mjs",
388+
"require": "./dist/ui/compose.cjs"
389+
},
381390
"./ui/manifest-generator": {
382391
"development": {
383392
"types": "./src/ui/manifest-generator.ts",

‎packages/everything-dev/src/services/scaffold/upgrade-audit/checks/compose-seam.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export type ConstructTreeFn = (input: {
6565
routeConfig: { routeConfigLoaders: Record<string, () => Promise<unknown>> };
6666
}>;
6767
rootOptions?: unknown;
68+
guards?: Record<string, unknown>;
6869
}) => Promise<{ mountCounts: Record<string, number> }>;
6970

7071
/** Pure — "unresolvable parentage" after a shadow log is the known cascade bug. */
@@ -125,6 +126,7 @@ export const runComposeProbe = async (
125126
{ key: CORE_UI_KEY, mfName: CORE_UI_KEY },
126127
{ key: AUTH_PLUGIN_KEY, mfName: AUTH_PLUGIN_KEY },
127128
],
129+
guards: { authenticated: async () => undefined, admin: async () => undefined },
128130
resolve: async (ref) => {
129131
const manifest = manifests.get(ref.key);
130132
if (!manifest) {

0 commit comments

Comments
 (0)