@terreno/admin-spa 58.0.0-beta.2 → 58.0.0-beta.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.
@@ -0,0 +1,263 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createAdminSpaGate = void 0;
4
+ const api_1 = require("@terreno/api");
5
+ const returnTo_1 = require("./returnTo");
6
+ const serverContext_1 = require("./serverContext");
7
+ const signedInOnlyRoutes_1 = require("./signedInOnlyRoutes");
8
+ /** Longest the gate waits on any of its loopback reads before failing closed. */
9
+ const UPSTREAM_TIMEOUT_MS = 5000;
10
+ /** Where Expo's client fetches route loader data, at the site root and under `basePath`. */
11
+ const LOADERS_PATH = "/_expo/loaders";
12
+ /** Paths under `basePath` that are never gated: hashed assets and the runtime config. */
13
+ const UNGATED_PREFIXES = ["/_expo/static", "/assets", "/app-config.json"];
14
+ /** Routes an anonymous or forbidden visitor may render (and load data for). */
15
+ const PUBLIC_ROUTES = new Set(["/login", "/forbidden"]);
16
+ /** Statuses the gate understands; anything else fails closed. */
17
+ const KNOWN_STATUSES = new Set([200, 401, 403]);
18
+ const ANONYMOUS = {
19
+ access: "anonymous",
20
+ adminConfig: null,
21
+ adminConfigStatus: null,
22
+ profile: null,
23
+ session: null,
24
+ };
25
+ /** Thrown when an upstream read fails in a way the gate cannot decide on. */
26
+ class GateUnavailableError extends Error {
27
+ }
28
+ /** Base for resolving a request URL on its own. */
29
+ const URL_BASE = "http://gate.invalid";
30
+ /** Characters RFC 3986 calls unreserved: an escape of one of them means the character itself. */
31
+ const UNRESERVED = /^[A-Za-z0-9\-._~]$/;
32
+ /**
33
+ * RFC 3986 percent-encoding normalization: decode escapes of unreserved characters and
34
+ * uppercase the hex of the rest. `/console/User/%36ae…` and `/console/User/6ae…` name the same
35
+ * record, and Expo keys loader data by the raw path while the router looks it up by the decoded
36
+ * params, so only the canonical form renders with its data. The URL parser already treats `%2E`
37
+ * as a dot, so this changes no dot-segment decision.
38
+ */
39
+ const canonicalizeEscapes = (url) => {
40
+ return url.replace(/%([0-9A-Fa-f]{2})/g, (match, hex) => {
41
+ const character = String.fromCharCode(Number.parseInt(hex, 16));
42
+ return UNRESERVED.test(character) ? character : match.toUpperCase();
43
+ });
44
+ };
45
+ /**
46
+ * Classify a request by its normalized path: dot segments, including encoded ones, resolved
47
+ * the way Expo will resolve them. Deciding on the raw path would let `/console/assets/../Todo`
48
+ * pass as an asset and then render as `/Todo`.
49
+ */
50
+ const classify = (method, requestPath, basePath) => {
51
+ const isRead = method === "GET" || method === "HEAD";
52
+ if (requestPath.startsWith(`${LOADERS_PATH}/`)) {
53
+ return isRead
54
+ ? { kind: "loader", route: requestPath.slice(LOADERS_PATH.length) }
55
+ : { kind: "write" };
56
+ }
57
+ if (requestPath !== basePath && !requestPath.startsWith(`${basePath}/`)) {
58
+ return { kind: "outside" };
59
+ }
60
+ if (!isRead) {
61
+ return { kind: "write" };
62
+ }
63
+ const inner = requestPath.slice(basePath.length) || "/";
64
+ if (inner.startsWith(`${LOADERS_PATH}/`)) {
65
+ return { kind: "loader", route: inner.slice(LOADERS_PATH.length) };
66
+ }
67
+ if (UNGATED_PREFIXES.some((prefix) => inner === prefix || inner.startsWith(`${prefix}/`))) {
68
+ return { kind: "static" };
69
+ }
70
+ return { kind: "page", route: inner };
71
+ };
72
+ /** One loopback read with the visitor's cookie. Only a 200 body is read. */
73
+ const readUpstream = async (url, cookie) => {
74
+ let response;
75
+ try {
76
+ response = await fetch(url, {
77
+ headers: cookie ? { Accept: "application/json", Cookie: cookie } : { Accept: "application/json" },
78
+ redirect: "manual",
79
+ signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
80
+ });
81
+ }
82
+ catch (error) {
83
+ const reason = error instanceof Error ? error.name : "unknown error";
84
+ throw new GateUnavailableError(`${new URL(url).pathname} failed: ${reason}`);
85
+ }
86
+ if (!KNOWN_STATUSES.has(response.status)) {
87
+ throw new GateUnavailableError(`${new URL(url).pathname} answered ${response.status}`);
88
+ }
89
+ if (response.status !== 200) {
90
+ return { body: null, status: response.status };
91
+ }
92
+ try {
93
+ return { body: await response.json(), status: 200 };
94
+ }
95
+ catch {
96
+ throw new GateUnavailableError(`${new URL(url).pathname} answered a body that is not JSON`);
97
+ }
98
+ };
99
+ /** The session snapshot from a Better Auth `get-session` body, or null when signed out. */
100
+ const sessionSnapshotOf = (body) => {
101
+ const value = body;
102
+ const userId = value?.session?.userId;
103
+ if (typeof userId !== "string" || !value?.user) {
104
+ return null;
105
+ }
106
+ return { user: value.user, userId };
107
+ };
108
+ /** Read the session, profile and admin config in parallel and decide the visitor's access. */
109
+ const loadShellPayload = async (req, { adminApiBasePath, appConfig, internalOrigin }) => {
110
+ const cookie = req.headers.cookie;
111
+ const authBasePath = appConfig.authBasePath ?? "/api/auth";
112
+ const [session, profile, config] = await Promise.all([
113
+ readUpstream(`${internalOrigin}${authBasePath}/get-session`, cookie),
114
+ readUpstream(`${internalOrigin}/auth/me`, cookie),
115
+ readUpstream(`${internalOrigin}${adminApiBasePath}/config`, cookie),
116
+ ]);
117
+ const snapshot = session.status === 200 ? sessionSnapshotOf(session.body) : null;
118
+ if (!snapshot || profile.status === 401 || config.status === 401) {
119
+ return ANONYMOUS;
120
+ }
121
+ const profileBody = profile.status === 200 ? profile.body : null;
122
+ if (config.status === 403) {
123
+ return {
124
+ access: "forbidden",
125
+ adminConfig: null,
126
+ adminConfigStatus: 403,
127
+ profile: profileBody,
128
+ session: snapshot,
129
+ };
130
+ }
131
+ return {
132
+ access: "admin",
133
+ adminConfig: config.body,
134
+ adminConfigStatus: 200,
135
+ profile: profileBody,
136
+ session: snapshot,
137
+ };
138
+ };
139
+ /**
140
+ * Express middleware that decides access before the admin SPA renders anything. Mounted at
141
+ * the site root, it gates `GET` page and loader requests under `basePath` and loader requests
142
+ * at the root `/_expo/loaders`, and passes every other path through.
143
+ *
144
+ * - Anonymous: pages other than `/login` redirect to `{basePath}/login?returnTo=<path>`;
145
+ * loaders answer 401 JSON (the `login` and `forbidden` loaders run).
146
+ * - Signed in but not an admin (`/admin/config` 403): signed-in-only routes (`/oauth/consent`)
147
+ * render, `/login` with a safe `returnTo` redirects there; other pages redirect to
148
+ * `{basePath}/forbidden`;
149
+ * loaders answer 403 JSON.
150
+ * - Admin: `/login` redirects to a safe `returnTo` (or `{basePath}/`); everything else renders
151
+ * inside a request scope holding the session, profile and admin config, so the page renders
152
+ * with its shell instead of a spinner.
153
+ * - An upstream answer other than 200 / 401 / 403, a network error or a 5 s timeout answers
154
+ * 503 without rendering. A 401 counts as anonymous.
155
+ */
156
+ const createAdminSpaGate = (options) => {
157
+ const { appConfig, handler } = options;
158
+ const basePath = options.basePath === "/" ? "" : options.basePath;
159
+ const render = (req, res, next, payload) => {
160
+ const scope = {
161
+ adminConfig: payload.adminConfig,
162
+ adminConfigStatus: payload.adminConfigStatus,
163
+ appConfig,
164
+ basePath: options.basePath,
165
+ profile: payload.profile,
166
+ session: payload.session,
167
+ };
168
+ (0, serverContext_1.runInAdminSpaRequest)(scope, () => handler(req, res, next));
169
+ };
170
+ // Every decision depends on the visitor's session, so no redirect may be cached or shared.
171
+ const redirect = (res, location) => {
172
+ res.set("Cache-Control", "no-store, private").redirect(302, location);
173
+ };
174
+ const deny = (res, status) => {
175
+ res
176
+ .status(status)
177
+ .set("Cache-Control", "no-store, private")
178
+ .json({ title: status === 401 ? "Unauthorized" : "Forbidden" });
179
+ };
180
+ return (req, res, next) => {
181
+ const normalized = new URL(canonicalizeEscapes(req.url), URL_BASE);
182
+ const classified = classify(req.method, normalized.pathname, basePath);
183
+ // Static paths are served by the static middleware mounted before the gate. A miss must
184
+ // never reach the page renderer, which would render it as a route (`/assets/Todo` matches
185
+ // `[model]/[id]`) without an access decision.
186
+ if (classified.kind === "outside" || classified.kind === "static") {
187
+ next();
188
+ return;
189
+ }
190
+ // Hand the renderer exactly the path the gate decided on.
191
+ req.url = `${normalized.pathname}${normalized.search}`;
192
+ // The admin SPA serves only reads: a write never reaches the renderer or a loader.
193
+ if (classified.kind === "write") {
194
+ next();
195
+ return;
196
+ }
197
+ const decide = async () => {
198
+ const payload = req.user ? await loadShellPayload(req, options) : ANONYMOUS;
199
+ const { route } = classified;
200
+ const isPublic = PUBLIC_ROUTES.has(route);
201
+ // Signed-in-only routes (MCP OAuth consent) render for any signed-in user.
202
+ const isOpenToVisitor = isPublic || (payload.access !== "anonymous" && (0, signedInOnlyRoutes_1.isSignedInOnlyRoute)(route));
203
+ if (classified.kind === "loader") {
204
+ if (payload.access === "admin" || isOpenToVisitor) {
205
+ render(req, res, next, payload);
206
+ return;
207
+ }
208
+ deny(res, payload.access === "anonymous" ? 401 : 403);
209
+ return;
210
+ }
211
+ if (payload.access === "anonymous") {
212
+ if (route === "/login") {
213
+ render(req, res, next, payload);
214
+ return;
215
+ }
216
+ const search = req.url.includes("?") ? req.url.slice(req.url.indexOf("?")) : "";
217
+ const returnTo = encodeURIComponent(`${route}${search}`);
218
+ redirect(res, `${basePath}/login?returnTo=${returnTo}`);
219
+ return;
220
+ }
221
+ if (payload.access === "forbidden") {
222
+ // Like the client gate: a signed-in user on /login goes to a safe returnTo, which
223
+ // renders when signed-in-only (the MCP consent flow) and otherwise redirects again.
224
+ const forbiddenTarget = route === "/login"
225
+ ? (0, returnTo_1.sanitizeReturnTo)(req.query.returnTo)
226
+ : null;
227
+ if (forbiddenTarget) {
228
+ redirect(res, `${basePath}${forbiddenTarget}`);
229
+ return;
230
+ }
231
+ if (isOpenToVisitor) {
232
+ render(req, res, next, payload);
233
+ return;
234
+ }
235
+ redirect(res, `${basePath}/forbidden`);
236
+ return;
237
+ }
238
+ if (route === "/login") {
239
+ const target = (0, returnTo_1.sanitizeReturnTo)(req.query.returnTo);
240
+ redirect(res, `${basePath}${target ?? "/"}`);
241
+ return;
242
+ }
243
+ if (route === "/forbidden") {
244
+ redirect(res, `${basePath}/`);
245
+ return;
246
+ }
247
+ render(req, res, next, payload);
248
+ };
249
+ decide().catch((error) => {
250
+ if (!(error instanceof GateUnavailableError)) {
251
+ next(error);
252
+ return;
253
+ }
254
+ api_1.logger.warn(`Admin SPA gate failed closed for ${req.path}`, { reason: error.message });
255
+ res
256
+ .status(503)
257
+ .set("Cache-Control", "no-store, private")
258
+ .type("text")
259
+ .send("Admin SPA unavailable");
260
+ });
261
+ };
262
+ };
263
+ exports.createAdminSpaGate = createAdminSpaGate;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Accept a post-login destination only when it is an in-app path ("/oauth/consent?request=abc"):
3
+ * one leading `/`, no second `/` right after it, no backslash, whitespace or control character,
4
+ * and not the login page itself. Anything else (an absolute URL, a protocol-relative `//host`,
5
+ * `/\\host`, a relative path) is an open redirect and yields undefined. A repeated query
6
+ * parameter uses its first value. The server gate and the client share this one rule.
7
+ */
8
+ export declare const sanitizeReturnTo: (value: unknown) => string | undefined;
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.sanitizeReturnTo = void 0;
4
+ // Any ASCII control character or whitespace; a valid in-app path never contains one.
5
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: rejecting control characters is the point
6
+ const UNSAFE_RETURN_TO_CHARS = /[\u0000-\u001f\u007f\s\\]/;
7
+ /**
8
+ * Accept a post-login destination only when it is an in-app path ("/oauth/consent?request=abc"):
9
+ * one leading `/`, no second `/` right after it, no backslash, whitespace or control character,
10
+ * and not the login page itself. Anything else (an absolute URL, a protocol-relative `//host`,
11
+ * `/\\host`, a relative path) is an open redirect and yields undefined. A repeated query
12
+ * parameter uses its first value. The server gate and the client share this one rule.
13
+ */
14
+ const sanitizeReturnTo = (value) => {
15
+ const candidate = Array.isArray(value) ? value[0] : value;
16
+ if (typeof candidate !== "string" || !candidate.startsWith("/") || candidate.startsWith("//")) {
17
+ return undefined;
18
+ }
19
+ if (UNSAFE_RETURN_TO_CHARS.test(candidate) || candidate.startsWith("/login")) {
20
+ return undefined;
21
+ }
22
+ return candidate;
23
+ };
24
+ exports.sanitizeReturnTo = sanitizeReturnTo;
@@ -0,0 +1,35 @@
1
+ interface RawRoute {
2
+ page: string;
3
+ namedRegex: string;
4
+ [key: string]: unknown;
5
+ }
6
+ /** The parts of an Expo server build's `_expo/routes.json` the serve plugin reads. */
7
+ export interface RawRoutesManifest {
8
+ assets?: {
9
+ js?: string[];
10
+ };
11
+ htmlRoutes?: RawRoute[];
12
+ apiRoutes?: RawRoute[];
13
+ notFoundRoutes?: RawRoute[];
14
+ redirects?: RawRoute[];
15
+ rewrites?: RawRoute[];
16
+ pageHeaders?: RawRoute[];
17
+ [key: string]: unknown;
18
+ }
19
+ /** Read `_expo/routes.json` from a server build, or undefined when there is no build. */
20
+ export declare const readRoutesManifest: (serverDir: string) => RawRoutesManifest | undefined;
21
+ /**
22
+ * The baseUrl baked into the build, read from its asset URLs (`/console/_expo/static/...`).
23
+ * "" for a bundle built for the site root.
24
+ */
25
+ export declare const bakedBaseUrlOf: (manifest: RawRoutesManifest) => string;
26
+ /**
27
+ * Prepare the manifest the way expo-server's environment does (regex strings become
28
+ * `RegExp`s), with HTML routes ordered by specificity. Expo 58 exports `routes.json`
29
+ * unsorted, and expo-server takes the first regex match, so `/[model]` would shadow `/login`
30
+ * and render it without its loader data. The router itself already prefers static routes.
31
+ */
32
+ export declare const prepareRoutesManifest: (raw: RawRoutesManifest, { pageCacheControl }?: {
33
+ pageCacheControl?: string;
34
+ }) => Record<string, unknown>;
35
+ export {};
@@ -0,0 +1,107 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.prepareRoutesManifest = exports.bakedBaseUrlOf = exports.readRoutesManifest = void 0;
7
+ const node_fs_1 = __importDefault(require("node:fs"));
8
+ const node_path_1 = __importDefault(require("node:path"));
9
+ const REGEX_LISTS = [
10
+ "htmlRoutes",
11
+ "apiRoutes",
12
+ "notFoundRoutes",
13
+ "redirects",
14
+ "rewrites",
15
+ "pageHeaders",
16
+ ];
17
+ /** Read `_expo/routes.json` from a server build, or undefined when there is no build. */
18
+ const readRoutesManifest = (serverDir) => {
19
+ try {
20
+ return JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(serverDir, "_expo", "routes.json"), "utf-8"));
21
+ }
22
+ catch {
23
+ return undefined;
24
+ }
25
+ };
26
+ exports.readRoutesManifest = readRoutesManifest;
27
+ /**
28
+ * The baseUrl baked into the build, read from its asset URLs (`/console/_expo/static/...`).
29
+ * "" for a bundle built for the site root.
30
+ */
31
+ const bakedBaseUrlOf = (manifest) => {
32
+ const firstAsset = manifest.assets?.js?.[0] ?? "";
33
+ const index = firstAsset.indexOf("/_expo/");
34
+ return index > 0 ? firstAsset.slice(0, index) : "";
35
+ };
36
+ exports.bakedBaseUrlOf = bakedBaseUrlOf;
37
+ /** Expo Router's match priority for one path segment: static, then dynamic, then catch-all. */
38
+ const segmentRank = (segment) => {
39
+ if (segment.startsWith("[...")) {
40
+ return 2;
41
+ }
42
+ if (segment.startsWith("[")) {
43
+ return 1;
44
+ }
45
+ return 0;
46
+ };
47
+ const routeRanks = (page) => {
48
+ return page
49
+ .split("/")
50
+ .filter((segment) => segment !== "" && !(segment.startsWith("(") && segment.endsWith(")")))
51
+ .map(segmentRank);
52
+ };
53
+ /** Compare two routes segment by segment: the more specific route sorts first. */
54
+ const compareSpecificity = (a, b) => {
55
+ const aRanks = routeRanks(a.page);
56
+ const bRanks = routeRanks(b.page);
57
+ const length = Math.min(aRanks.length, bRanks.length);
58
+ for (let index = 0; index < length; index++) {
59
+ const difference = (aRanks[index] ?? 0) - (bRanks[index] ?? 0);
60
+ if (difference !== 0) {
61
+ return difference;
62
+ }
63
+ }
64
+ return 0;
65
+ };
66
+ /**
67
+ * Replace every `Cache-Control` in the manifest's `pageHeaders` rules. Expo exports a
68
+ * `no-store` rule for every loader response, and expo-server applies it after Express
69
+ * middleware, so the plugin's own header would be overwritten.
70
+ */
71
+ const withCacheControl = (routes, cacheControl) => {
72
+ return routes.map((route) => {
73
+ const headers = route.headers;
74
+ if (!headers) {
75
+ return route;
76
+ }
77
+ const kept = Object.fromEntries(Object.entries(headers).filter(([name]) => name.toLowerCase() !== "cache-control"));
78
+ const hadCacheControl = Object.keys(kept).length !== Object.keys(headers).length;
79
+ return hadCacheControl ? { ...route, headers: { ...kept, "Cache-Control": cacheControl } } : route;
80
+ });
81
+ };
82
+ /**
83
+ * Prepare the manifest the way expo-server's environment does (regex strings become
84
+ * `RegExp`s), with HTML routes ordered by specificity. Expo 58 exports `routes.json`
85
+ * unsorted, and expo-server takes the first regex match, so `/[model]` would shadow `/login`
86
+ * and render it without its loader data. The router itself already prefers static routes.
87
+ */
88
+ const prepareRoutesManifest = (raw, { pageCacheControl } = {}) => {
89
+ const prepared = { ...raw };
90
+ for (const list of REGEX_LISTS) {
91
+ const exported = raw[list];
92
+ const routes = list === "pageHeaders" && exported && pageCacheControl
93
+ ? withCacheControl(exported, pageCacheControl)
94
+ : exported;
95
+ if (!routes) {
96
+ // expo-server defaults every list except `pageHeaders` to empty.
97
+ if (list !== "pageHeaders") {
98
+ prepared[list] = [];
99
+ }
100
+ continue;
101
+ }
102
+ const ordered = list === "htmlRoutes" ? [...routes].sort(compareSpecificity) : routes;
103
+ prepared[list] = ordered.map((route) => ({ ...route, namedRegex: new RegExp(route.namedRegex) }));
104
+ }
105
+ return prepared;
106
+ };
107
+ exports.prepareRoutesManifest = prepareRoutesManifest;
@@ -6,28 +6,49 @@ export interface AdminSpaServeOptions {
6
6
  basePath?: string;
7
7
  /** Runtime config served at `${basePath}/app-config.json`. Merged with defaults. */
8
8
  appConfig?: Partial<AdminSpaAppConfig>;
9
- /** Override the directory the pre-built bundle is served from (used by tests/custom builds). */
9
+ /**
10
+ * Override the export root the bundle is served from: it holds `client/` (static files)
11
+ * and `server/` (the Expo server build). Used by tests, custom builds, and compiled binaries.
12
+ */
10
13
  distDir?: string;
11
14
  /**
12
15
  * In dev, proxy all SPA paths to a running `expo start --web` server, e.g.
13
16
  * "http://localhost:8083". Avoids re-running `expo export` per change.
14
17
  */
15
18
  devProxyTarget?: string;
19
+ /**
20
+ * Origin the server render fetches API data from, with the visitor's cookie. Default:
21
+ * `http://127.0.0.1:${PORT ?? 4000}`, this process. Never derived from `Host` or
22
+ * `X-Forwarded-*`, so a request cannot point the render at another host.
23
+ */
24
+ internalOrigin?: string;
25
+ /**
26
+ * API path prefixes a server render may fetch. Default: the admin API base path plus
27
+ * `/auth/me`, `/orgs`, `/jobs`, `/comms`, `/configuration`, and `/rbac`.
28
+ */
29
+ ssrApiPrefixes?: string[];
16
30
  }
17
31
  /**
32
+ * @deprecated The server-rendered build no longer serves a static index.html. Kept for
33
+ * consumers of the static-export API; it will be removed in the next major release.
34
+ *
18
35
  * Rewrite the absolute `/_expo/` and `/assets/` references emitted by Expo's static
19
36
  * web export so a single pre-built bundle works when mounted at any `basePath`.
20
37
  */
21
38
  export declare const rewriteIndexHtml: (rawIndex: string, basePath: string) => string;
22
39
  /**
40
+ * @deprecated `+html.tsx` now writes `window.__ADMIN_SPA_BASE__` into the server-rendered
41
+ * page. Kept for consumers of the static-export API; it will be removed in the next major.
42
+ *
23
43
  * Inject `window.__ADMIN_SPA_BASE__` so the SPA can build absolute URLs (e.g.
24
44
  * `${base}/app-config.json`) regardless of the current deep route. Inserted before
25
45
  * `</head>` so it runs before the bundle.
26
46
  */
27
47
  export declare const injectBaseGlobal: (html: string, basePath: string) => string;
28
48
  /**
29
- * Opt-in Express plugin that serves a pre-built admin SPA (Expo Router static export)
30
- * from the same Node process as a Terreno backend. Mounts at `/console` by default.
49
+ * Opt-in Express plugin that server-renders the pre-built admin SPA (Expo Router
50
+ * `web.output: "server"` export) from the same Node process as a Terreno backend. Mounts at
51
+ * `/console` by default; `basePath` must equal the baseUrl the bundle was built with.
31
52
  *
32
53
  * @example
33
54
  * ```typescript