@terreno/admin-spa 58.0.0-beta.3 → 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.
package/CHANGELOG.md CHANGED
@@ -20,6 +20,74 @@ Upgrade notes for consumer action live in [`mcp-server/src/docs/upgrades/`](mcp-
20
20
 
21
21
  Unreleased changes live in [`changelog/unreleased/`](changelog/unreleased/). Add one Markdown file per feature (see that directory's README) instead of editing this section.
22
22
 
23
+ ## [58.0.0-beta.4] - 2026-11-04
24
+
25
+ Upgrade note: [`mcp-server/src/docs/upgrades/58.0.0-beta.4.md`](mcp-server/src/docs/upgrades/58.0.0-beta.4.md).
26
+
27
+ ### Breaking
28
+
29
+ - `@terreno/admin-spa` now server-renders every admin page in the backend's Express process, behind an access gate that runs before anything renders. The first HTML holds the login form, the shell and the screen's data. Upgrade steps: [Upgrade to the server-rendered admin SPA](docs/how-to/upgrade-admin-spa-ssr.md).
30
+ - `AdminSpaServeApp`'s `distDir` (and example-backend's `ADMIN_SPA_DIST_DIR`) now names the export root, which holds `client/` and `server/`. `build:web` no longer writes `dist/index.html`. Rebuild, and copy the whole `dist` into images. Without `dist/server` the plugin answers 503 on `/console/*`.
31
+ - `basePath` must equal the `experiments.baseUrl` baked into the bundle (`/console` by default), or `register()` throws. Any other mount needs `experiments.baseUrl` set in `admin-spa/app.json` and a rebuild from source; the published package ships only the `/console` build. Mounting at the site root (`basePath: "/"`) is no longer supported.
32
+ - Terreno 58 targets Expo SDK 58 (`~58.0.14`), React Native `0.88.0`, and React `19.3.0`. `@terreno/ui` peers `react-native` `0.88.0`. Native catalog moves with the SDK: `react-native-gesture-handler` `~3.3.0`, `react-native-reanimated` `4.7.1` + `react-native-worklets` `0.13.0`, `react-native-screens` `~4.30.0`, `react-native-webview` `14.0.1`, `@sentry/react-native` `~8.28.0`, `@shopify/flash-list` `2.3.2`, `@shopify/react-native-skia` `2.14.0`. Rebuild dev clients and store binaries.
33
+ - Expo Router 58: set `export const unstable_settings = {initialRouteName}` on the layout instead of passing `initialRouteName` to `<Slot>` / navigators. `SidebarNavigation` ignores the prop.
34
+ - Expo Router 58 static rendering: a custom `app/+html.tsx` must render `headNodes` and `bodyNodes` from `useServerDocumentContext()` (`expo-router/html`), or exported pages ship without their JavaScript. `example-frontend` is updated.
35
+ - React Native 0.88 generated types: type refs as `ViewInstance`, `TextInstance`, `ScrollViewInstance`, and `TextInputInstance` (`Box.scrollRef`, `TextField.inputRef`, `Text` `ref`).
36
+ - `create-terreno-app` scaffolds Expo `~58.0.14`, React Native `0.88.0`, and React `19.3.0`.
37
+ - The Terreno 58 native module baseline lands in `example-frontend` and `demo`: `@stripe/stripe-react-native` 0.81.0, `react-native-purchases` and `react-native-purchases-ui` 10.12.2, `expo-device`, `expo-crypto`, `expo-local-authentication`, `expo-system-ui`, and `expo-build-properties` (Android Kotlin 2.2.21 and `compileSdkVersion` 37 pins). Rebuild every dev client and store binary. `react-native-otp-verify` is left out because it does not support the New Architecture. The demo has a hidden **Native Modules** page that shows each module's version and whether the running binary links it. See the 58.0.0 upgrade note.
38
+ - `TerrenoApp` turns HTTP rate limiting on by default. Omitting `rateLimit` now mounts the limiter with the memory store (auth 20 / 15 min, api 600 / 15 min). Pass `rateLimit: false` to turn it off. On Cloud Run pass `rateLimit: {trustProxy: 1}` so unauthenticated traffic keys by client IP. See the 58.0.0 upgrade note.
39
+ - `@terreno/admin-frontend` no longer takes an RTK `api`. The `api` prop is removed from every admin component (`AdminProvider`, `AdminShellLayout`, `AdminHome`, `AdminModelList`, `AdminModelTable`, `AdminModelForm`, `AdminScreenRouter`, `ConfigurationScreen`, `DocumentStorageBrowser`, `ConsentHistory`, comms, jobs, org, consent, and announcement screens, and widget props), and admin hooks drop their `api` argument (`useAdminConfig(apiBase)`, `useAdminScripts(apiBase)`, `useAdminRoles(apiBase)`, `useConfigurationApi({basePath})`, `useDocumentStorageApi(basePath)`, `useConsentHistory(baseUrl)`, `useOrganizationsApi(basePath)`, `useCommsDashboardApi()`, `useJobsDashboardApi()`, and the `useAiObservability*Api()` hooks).
40
+ - `useAdminApi(api, routePath, modelName)` is removed. Use `useAdminModelApi(routePath, modelName)`, which keeps the same hook names and calls the same `/admin/{routePath}` URLs through the admin fetch client. `AdminApi`, `EndpointBuilder`, and admin `injectEndpoints` are gone; `@reduxjs/toolkit` and `react-redux` are no longer peers of admin-frontend.
41
+ - `AdminProvider` is now the required auth binding for every admin request, including ObjectId and API-only model CRUD. Cookie SPAs pass `credentials="same-origin"`; embedded apps pass `apiOrigin` and `getAuthHeaders`. Product screens that reuse admin components wrap them in `AdminProvider`. See the 58.0.0 upgrade guide.
42
+ - Removed the legacy RTK realtime path. Use `sync` on `modelRouter` with `@terreno/syncdb` for live collection updates. `RealtimeApp`, `SyncApp`, socket auth, `sync:delta`, and admin `admin:model.changed` events are unchanged. See the 58.0.0 upgrade note.
43
+ - `@terreno/api`: `modelRouter` no longer accepts `realtime` (now a TypeScript error), and the change stream no longer emits the `sync` websocket event. Removed `RealtimeConfig`, `RealtimeEvent`, `DocumentSubscription`, `QuerySubscription`, `registerRealtime`, `getRealtimeRegistry`, `findRegistryEntryByCollection`, `findRegistryEntryByRoutePath`, `updateRealtimeRegistryOptions`, `clearRealtimeRegistry`, `RealtimeRegistryEntry`, the query-subscription store (`addQuerySubscription`, `removeQuerySubscription`, `removeAllSocketQueries`, `getQuerySubscriptionsForCollection`, `computeQueryId`, `clearQueryStore`), and `MAX_MODEL_SUBSCRIPTIONS` / `MAX_DOCUMENT_SUBSCRIPTIONS` / `MAX_QUERY_SUBSCRIPTIONS`. Sockets no longer handle `subscribe:model`, `subscribe:document`, or `subscribe:query`.
44
+ - `@terreno/rtk`: removed `realtimeList`, `realtimeDocument`, `setRealtimeSocket`, `getRealtimeSocket`, `useSyncConnection`, and the `RealtimeEvent` type. The rest of `@terreno/rtk` (including `useSocketConnection` and `useRealtimeDebug`) is removed in the same release; see `remove-rtk.md`.
45
+ - Removed `@terreno/rtk` and every Redux dependency (`@reduxjs/toolkit`, `react-redux`, `redux-persist`, `@rtk-query/codegen-openapi`). `@terreno/rtk` is not published for 58. `@terreno/syncdb` is the only frontend data, request, and session layer. Follow `docs/how-to/migrate-rtk-to-syncdb.md`, which maps every removed export to its replacement, and the 58.0.0 upgrade note.
46
+ - `@terreno/syncdb` adds `@terreno/syncdb/api` (`createApiClient`, `ApiError`), request hooks in `@terreno/syncdb/react` (`ApiClientProvider`, `useApiQuery`, `useLazyApiQuery`, `useApiMutation`, `useSocketEvent`), `@terreno/syncdb/config` (`baseUrl`, `baseWebsocketsUrl`, `appHeaders`), `@terreno/syncdb/auth` (`createBetterAuthClient`, `SessionProvider`, `useSession`, `jwtAuthAdapter`, `useJwtAuth`, token-expiry helpers), `@terreno/syncdb/flags` (`useFeatureFlags`, `useTerrenoFeatureFlags` on the sync socket), `@terreno/syncdb/app` (`useUpgradeCheck`, `hasPermission`, `canOpenAdminPage`, `installDevConsoleLogger`), `@terreno/syncdb/mcp` (`useMCPTools`), and `@terreno/syncdb/buildNumber`.
47
+ - `terreno-syncdb-codegen --api-out` generates typed hooks for every non-synced OpenAPI operation (same names as the RTK codegen), replacing `@rtk-query/codegen-openapi`. `bun run sdk` writes `store/apiSdk.ts`.
48
+ - `@terreno/ui` `ConsentNavigator`, `AnnouncementNavigator`, `useConsentForms`, `useSubmitConsent`, `useAnnouncements`, and `useAcknowledgeAnnouncement` take a request `client` instead of an RTK `api`.
49
+ - `GptHistory` (`gpt/histories`) is a synced collection and keeps its ObjectId `_id` (no migration). `McpServiceToken` (`mcp/service-tokens`, read-only) is a synced collection with String ids; run `mcpServiceTokenStringIdsMigration` (`@terreno/api`) before deploying 58 frontends.
50
+ - `create-terreno-app`, the `terreno_bootstrap` prompt, and the `terreno_generate_screen` tool scaffold apps without Redux or `@terreno/rtk`. The terreno-mcp-local `get_rtk_state` tool, the MCP resource `terreno://docs/rtk`, and `terreno state --slice rtk` are removed; use `get_syncdb_state` and `terreno state`.
51
+ - `@terreno/syncdb`: `useMCPTools` (and `MCPToolInfo`, `UseMCPToolsOptions`, `UseMCPToolsResult`) moved from `@terreno/syncdb/app` to the new `@terreno/syncdb/mcp` entry point. Change `import {useMCPTools} from "@terreno/syncdb/app"` to `import {useMCPTools} from "@terreno/syncdb/mcp"`. The hook imports the optional `@modelcontextprotocol/client` peer at the top level, so exporting it from `/app` broke every app that imported `/app` without that package installed, including apps scaffolded by `create-terreno-app`, which import `installDevConsoleLogger` from `/app`.
52
+
53
+ ### Added
54
+
55
+ - `AdminSpaServeApp` options `internalOrigin` (where server renders fetch API data, default `http://127.0.0.1:${PORT ?? 4000}`) and `ssrApiPrefixes` (API paths a render may fetch). The plugin also serves Expo's root `/_expo/loaders/*`, with `Cache-Control: no-store, private` on pages and loader data.
56
+ - `@terreno/syncdb`: `SessionProvider` / session store `initialSession`, and the request client's `primeQuery(args, data)`, to start from data a server render already read.
57
+ - `@terreno/admin-frontend`: `AdminRpcSeed` and `adminRpcSeedKey` seed `useAdminRpcQuery` with server-fetched bodies; `buildAdminReadUrl`, `buildAdminListQueryParams`, `buildAdminApiListQueryRequest` and `homeWidgetUrls` build the exact URLs the screens request.
58
+ - `DataTable` columns size themselves when `width` is omitted. Each auto column starts at the wider of its header and its content (sampled from at most 50 rows, measured with a canvas on web and estimated on native), clamped to 80–360px, and auto columns grow to fill the table's container. Columns that set `width` keep it. `DataTableColumn.width` is now optional.
59
+ - `@terreno/syncdb` `createSyncDb({idFormats})` mints 24-hex ObjectIds for a collection set to `"objectId"`, so a model that keeps Mongoose's default ObjectId `_id` can sync without migrating its ids. `mutate()` throws on an explicit non-hex id for such a collection. `generateObjectId` and the `SyncIdFormat` type are exported.
60
+ - Sync `validation` nacks carry an optional `reason`: `invalid_id` when an ObjectId-keyed collection gets a non-hex id (rejected before the mutation is claimed), and `id_collision` when a create names an `_id` another document holds. A collision never touches the existing document and its message never names the holder. The ledger records the reason (`SyncMutation.nackReason`) so a redelivery replays it.
61
+ - `GET /sync/entities` leaves out non-hex ids for an ObjectId-keyed model instead of answering 400, so a rejected create queued for repair cannot fail the rest of the repair batch.
62
+
63
+ ### Changed
64
+
65
+ - The admin comms dashboard's default 7-day range now ends at the next whole minute, so a server render and the hydrating browser request the same URL.
66
+
67
+ `useAdminRpcQuery` treats the request's `X-Organization-Id` as part of its key. When the selected organization changes on the same URL, it drops the previous organization's data and loads again, even with `keepPreviousData`, so one organization's rows never show under another.
68
+
69
+ `useAdminModelApi`'s `useReadQuery` builds its URL with `buildAdminReadUrl`, which encodes the id.
70
+ - The repository's agent rules now generate with rulesync 17 (up from 9). The root rule `.rulesync/rules/00-root.md` now generates `AGENTS.md`, which Devin and other AGENTS.md readers load, in place of `.devin/rules/00-root.md`. Never edit `AGENTS.md` by hand: edit the root rule and run `bun run rules`. The examples that existed only in the old hand-edited `AGENTS.md` (syncdb and RTK SDK usage) moved into the root rule, so `CLAUDE.md`, the Cursor root rule, and Copilot instructions get them too. Links in the root rule now resolve from the repository root. Generated Copilot hooks use the portable `command` field. Generated Copilot subagents no longer list `agent/runSubagent` as their only tool, so they get every default tool. Generated skills carry their `license`, `disable-model-invocation`, and Devin `triggers` frontmatter.
71
+ - `@terreno/syncdb/auth` `SessionProvider`: `logout()` no longer clears the session before the server answers. The session stays in the state until `authClient.signOut()` finishes (`signOutTimeoutMs`, default 10 seconds), and then it is cleared. Meanwhile the new `isSigningOut` state (`useSession().isSigningOut`) is true; `isLoading` is left alone, so a navigator gated on it keeps its screen. Before, a login screen that opened at once could find the still-live session and sign the user straight back in. A sign-out that fails or times out checks the session first. A session that is still live, or a cookie or native session the server cannot be asked about, stays signed in, and `error` is set to `SIGN_OUT_FAILED_MESSAGE` ("Couldn't sign out. Check your connection and try again."). A web bearer client, which drops its token before the request leaves, ends signed out. The global `auth/logout` action (a refused session) now ends signed out even when the sign-out request fails. With `apisToReset` (57.17.0), `logout()` purges the cache when the session clears, not when it is dispatched: a purge at dispatch refetched every mounted screen mid-sign-out, and a refetch that could not reach the server answered 401 and signed the user out even though the sign-out failed. A failed sign-out keeps both the session and the cache. The global `auth/logout` action still purges at once (only the first of several in flight) and again when the session clears. A sign-in for another user that lands while a sign-out is in flight is kept; a sign-out that started signed out asks `getSession` first, so a restore of the session it revoked is cleared. Show `selectBetterAuthIsSigningOut` on the sign-out control and `selectBetterAuthError` after a failed sign-out.
72
+
73
+ ### Deprecated
74
+
75
+ - `@terreno/admin-spa` `rewriteIndexHtml` and `injectBaseGlobal` are deprecated: the server-rendered build has no `index.html`, and `app/+html.tsx` writes `window.__ADMIN_SPA_BASE__` itself. They will be removed in the next major release.
76
+
77
+ ### Removed
78
+
79
+ - `@terreno/ui` no longer exports the `Filter` and `FilterProps` aliases deprecated in 57.7.0. Import `DropdownPanel` and `DropdownPanelProps` instead. `FilterSelectMenu`, `FilterBoolean`, `FilterAccordion`, and `FilterChangesBadge` keep their names.
80
+
81
+ ### Fixed
82
+
83
+ - The admin configuration screen shows stored values again: `useConfigurationApi`'s values query unwraps the API's `{data}` envelope.
84
+ - syncdb window hydration no longer lets a REST page overwrite a newer synced entity. REST rows only fill ids the store lacks, and a stored entity at least as new as the fetched one is kept, so a live delta is never skipped.
85
+ - `@terreno/api`: `modelRouter("/path", Model, options)` no longer builds a router at registration. `TerrenoApp` mounts the router from `_buildWithContext`, which has OpenAPI wired in, so the eager one was built for nothing and logged "No options.openApi provided" for every model at import. `registration.router` now builds on first read, for code that mounts a registration by hand.
86
+ - `@terreno/api`: admin responses keep ObjectId, Date and other non-plain values intact. `scrubAdminFields` used to copy the own keys of every object it reached, so an ObjectId in an array field (or a bare ObjectId ref) came back as a plain `{i0, i1, i2, i3}` object and a Date became `{}`. It now scrubs only plain objects and Mongoose documents, and never treats a bare ObjectId as a populated document.
87
+ - `@terreno/syncdb`: logging out while `syncDb.start()` is still running no longer loses local writes or logs errors. `start()` now resolves the web encryption key from `GET /sync/key` before it loads the store, so the flush in `stop()` never needs the network after the session has ended. A `stop()` or `signOut()` call now cancels a `start()` that is running or queued ahead of it, so the transport never connects without a session. A failed key fetch is now retried on the next save instead of failing every save for the rest of the session.
88
+ - `@terreno/syncdb`: a record you create now gets the server's copy, including `_id` and other server-added fields. Before, the mutation ACK stamped the row's seq before the server's delta for that seq arrived, so the delta was dropped as a duplicate and the row kept only its optimistic data. Filters on real ids and anything else that reads server fields failed for that row. The ACK sets a marker on that row; the equal-seq delta applies only while the marker is set, and a later local edit clears it. Deltas older than the row's seq, and repeats of a delta the row already took, are still skipped.
89
+ - `@terreno/ui`: the `DateTimeActionSheet` time wheels show their numbers on physical iPhones. iOS now uses UIPickerView's natural 216pt height. The forced 104pt height looked fine in the simulator but showed empty rows on devices. Wheel items also take the theme's text color on iOS, not just Android, so they're no longer white on white on a dark-mode phone.
90
+
23
91
  ## [58.0.0-beta.3] - 2026-11-03
24
92
 
25
93
  Upgrade note: [`mcp-server/src/docs/upgrades/58.0.0-beta.3.md`](mcp-server/src/docs/upgrades/58.0.0-beta.3.md).
@@ -156,6 +224,37 @@ Upgrade note: [`mcp-server/src/docs/upgrades/58.0.0-beta.1.md`](mcp-server/src/d
156
224
 
157
225
  - `@terreno/syncdb`: logging out while `syncDb.start()` is still running no longer loses local writes or logs errors. `start()` now resolves the web encryption key from `GET /sync/key` before it loads the store, so the flush in `stop()` never needs the network after the session has ended. A `stop()` or `signOut()` call now cancels a `start()` that is running or queued ahead of it, so the transport never connects without a session. A failed key fetch is now retried on the next save instead of failing every save for the rest of the session.
158
226
 
227
+ ## [57.22.0] - 2026-11-03
228
+
229
+ Upgrade note: [`mcp-server/src/docs/upgrades/57.22.0.md`](mcp-server/src/docs/upgrades/57.22.0.md).
230
+
231
+ ### Breaking
232
+
233
+ - `@terreno/ai` no longer integrates with Langfuse. The built-in observability prompt registry, traces, and review queue (`ObservabilityApp` with `createLocalObservabilityPlugin()`) replace it. Removed exports: `LangfuseApp`, `LangfuseCache`, `getCached`, `setCached`, `invalidateCache`, `initLangfuseClient`, `getLangfuseClient`, `isLangfuseInitialized`, `shutdownLangfuseClient`, `compilePrompt`, `createPrompt`, `getPrompt`, `invalidatePromptCache`, `initTracing`, `shutdownTracing`, `createTelemetryConfig`, `preparePromptForAI`, and the types `ChatMessage`, `GetPromptOptions`, `LangfuseAppOptions`, `LangfuseCachedPrompt`, `PaginatedResult`, `PreparePromptResult`, `PromptListItem`, `ScoreSubmission`, `ScoringFunction`, `TelemetrySettings`, `TraceListItem`. The `@terreno/ai/langfuseApp` and `@terreno/ai/langfuseClient` subpath exports are gone, along with the `@langfuse/client`, `@langfuse/otel`, and `@opentelemetry/*` dependencies of `@terreno/ai`.
234
+ - `addGptRoutes` drops `langfuseSystemPromptName`; use `systemPromptName` (a registry prompt) instead. Chat turns no longer set `experimental_telemetry`.
235
+ - `ControlPrimary` is now only `"local"`.
236
+ - The example backend no longer reads `LANGFUSE_SECRET_KEY`, `LANGFUSE_PUBLIC_KEY`, `LANGFUSE_BASE_URL`, `LANGFUSE_ORGANIZATION`, `LANGFUSE_PROJECT`, or `LANGFUSE_PROJECT_ID`. Deploys (`.github/workflows/cd.yml`, `scripts/ci/gcp-deploy.sh`) no longer mount the Langfuse secrets, and `infra/flourish/main.tf` drops the `backend_secret_langfuse_secret_key` and `backend_secret_langfuse_public_key` modules, so the next `terraform apply` deletes those two Secret Manager secrets. Deploy a revision without the mounts before applying.
237
+
238
+ ### Added
239
+
240
+ - `addGptRoutes` accepts `systemPromptName` and `systemPromptLabel`. A chat turn prepends that observability registry prompt to the system prompt and records its version on the trace.
241
+ - The example chat reads `chat-assistant` and the `generate_joke` tool reads `joke-generator` from the observability prompt registry.
242
+ - `bun run backend:seed` fills every AI Observability screen: labelled prompt versions for `example-summarize`, `joke-generator`, and `chat-assistant`, golden datasets, finished experiments, traces with scores, and review items in every status. Run it on an existing database with the `seedAiObservabilityDemo` admin script.
243
+ - Review batches: pick a human question set and press **Grab 10 recent traces** to give reviewers a fixed set of inputs and outputs (`POST /ai/observability/review/batches`).
244
+ - The review item screen shows one input, one output, and every question as Yes / No rows. On web, `Y` / `N` answer the highlighted question, `Enter` submits and moves to the next batch item, and `S` skips. Evaluator dimensions accept a reviewer `question`.
245
+ - Run any LLM judge over a batch and see overall and per-question agreement with the human answers, plus a list of disagreements (`POST /ai/observability/review/batches/:id/judgeRuns`).
246
+ - The AI observability seed adds the `summary-review-questions` question set, two judges for it, and a 10-item batch with 8 answers where one judge agrees 97% and the other 78%.
247
+ - Observability registry prompts can reference other prompts with `{{> base-rules}}`, `{{> base-rules label=staging}}`, or `{{> base-rules version=3}}` (default label `production`). Shared instructions live in one prompt; `PromptRegistry.get`, the playground, experiments, and judges use the resolved text.
248
+ - Every reference must resolve when it is saved. Creating a prompt, saving a version, and moving a label return 400 `Invalid prompt reference` for a malformed tag, an unknown prompt or version, or a label the referenced prompt does not have yet, 400 `Prompt reference cycle` when the change would make prompts include each other, and 400 `Prompt references nested too deeply` when it would nest the prompt, or any prompt that includes it, more than 5 levels.
249
+ - Traces stay named after the called prompt. `ObsTrace.prompts` lists it first, then each shared prompt version it pulled in, marked with `includedBy`. `PromptVersionRef.includes` and harness prompt refs carry the same list.
250
+
251
+ ### Fixed
252
+
253
+ - Review queue: `Y` and `N` answer the highlighted question when a Yes/No segment is focused. **What the AI was given** and **What the AI wrote** are headings above the field cards.
254
+ - Review batches: **Save batch** sits on the New batch card. Search and **More filters** share a baseline.
255
+ - Prompt hub: description, folder, and tags sit on the Versions screen with the editor. System and template fields explain what to write.
256
+ - Evaluator detail loads the judge prompt `outputSchema` (so helpfulness no longer reports a missing dimension) and experiment rows open that experiment.
257
+
159
258
  ## [57.21.0] - 2026-11-01
160
259
 
161
260
  Upgrade note: [`mcp-server/src/docs/upgrades/57.21.0.md`](mcp-server/src/docs/upgrades/57.21.0.md).
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Opt-in admin SPA (Expo Router web app) plus an Express plugin that serves it from a Terreno backend.
4
4
 
5
- Opt-in package that lets any Terreno backend serve a pre-built admin SPA from the
5
+ Opt-in package that lets any Terreno backend server-render a pre-built admin SPA from the
6
6
  same Node process — no separate static-site deploy to GCS/Netlify/Cloudflare Pages.
7
7
 
8
8
  The default backend image stays small: React/Expo assets only ship in the npm
@@ -12,14 +12,18 @@ does) remains supported and unchanged.
12
12
 
13
13
  ## What's here
14
14
 
15
- - **Backend serving plugin** (`src/`, `AdminSpaServeApp`): serves the pre-built Expo
16
- Router static export (`dist/`) and a runtime `app-config.json` from a Terreno backend.
15
+ - **Backend serving plugin** (`src/`, `AdminSpaServeApp`): server-renders the pre-built
16
+ Expo Router export (`dist/client` + `dist/server`) behind an access gate, and serves a
17
+ runtime `app-config.json`, from a Terreno backend.
18
+ - **Route loaders** (`server/`): fetch each screen's data with the visitor's cookie, so the
19
+ first HTML holds the page and its data.
17
20
  - **The SPA frontend** (`app/`, `store/`, `components/`): an Expo Router web app that
18
21
  wires `@terreno/admin-frontend`'s screens with a Better-Auth session gate. It uses the
19
22
  `@terreno/admin-frontend` `apiBase`/`routeBase` prop split so API calls go to `/admin`
20
23
  while in-app navigation stays inside the SPA.
21
24
 
22
- Boot flow: `AppConfigGate` fetches `app-config.json` → `StoreProvider` builds the
25
+ Boot flow: the Express gate decides access and the server renders the page →
26
+ `AppConfigGate` hydrates from the config written into the page → `StoreProvider` builds the
23
27
  Better-Auth client and a same-origin request client and mounts `SessionProvider` from
24
28
  `@terreno/syncdb/auth` → `AdminGate` reads `useSession()` and redirects
25
29
  anonymous users to `/login` and non-admins (admin API returns 403) to `/forbidden`,
@@ -64,6 +68,9 @@ bun add @terreno/admin-spa
64
68
 
65
69
  ## Register with a backend
66
70
 
71
+ Register `BetterAuthApp` before `AdminSpaServeApp`: the gate reads `req.user`, which the
72
+ Better Auth session middleware sets.
73
+
67
74
  ```typescript
68
75
  import {AdminSpaServeApp} from "@terreno/admin-spa";
69
76
  import {AdminApp} from "@terreno/admin-backend";
@@ -95,8 +102,10 @@ cookies attach automatically, no CORS.
95
102
  |---|---|---|
96
103
  | `basePath` | `/console` | Path the SPA mounts at. The `/admin` API is untouched. |
97
104
  | `appConfig` | see below | Runtime config served at `${basePath}/app-config.json`. Merged over defaults. |
98
- | `distDir` | `<pkg>/dist` | Override the pre-built bundle directory (tests / custom builds). |
99
- | `devProxyTarget` | — | In dev, proxy all SPA paths to a running `expo start --web`, e.g. `http://localhost:8083`. |
105
+ | `distDir` | `<pkg>/dist` | The export root holding `client/` and `server/` (compiled binaries, custom builds, tests). |
106
+ | `devProxyTarget` | — | In dev, proxy all SPA paths to a running `expo start --web`, e.g. `http://localhost:8083`. No gate or server render is mounted. |
107
+ | `internalOrigin` | `http://127.0.0.1:${PORT ?? 4000}` | Where server renders fetch API data, with the visitor's cookie. Never taken from `Host` or `X-Forwarded-*`. |
108
+ | `ssrApiPrefixes` | admin API base, `/auth/me`, `/orgs`, `/jobs`, `/comms`, `/configuration`, `/rbac` | API prefixes a render may fetch. Replaces the defaults; keep the admin API base. |
100
109
 
101
110
  ### App config
102
111
 
@@ -115,34 +124,42 @@ auth/admin API paths per consumer without rebuilding. Defaults:
115
124
 
116
125
  ## How serving works
117
126
 
118
- - `${basePath}/_expo` and `${basePath}/assets` are served with
119
- `Cache-Control: public, max-age=31536000, immutable`.
120
- - `${basePath}/app-config.json` and `index.html` are served with
121
- `Cache-Control: no-store`.
122
- - `index.html`'s absolute `/_expo/` and `/assets/` references are rewritten once at
123
- boot to `${basePath}/...` (a no-op when the bundle was already built for that base).
124
- - The serve plugin injects `window.__ADMIN_SPA_BASE__ = "${basePath}"` into `index.html`
125
- so the SPA can resolve `app-config.json` on deep refreshes.
126
- - SPA fallback: both the bare `${basePath}` and `${basePath}/*splat` return
127
- `index.html` (Express 5 named-splat convention).
127
+ | Path | `Cache-Control` |
128
+ |---|---|
129
+ | `${basePath}/_expo/*`, `${basePath}/assets/*` (from `dist/client`) | `public, max-age=31536000, immutable` |
130
+ | `${basePath}/app-config.json` | `no-store` |
131
+ | Pages under `${basePath}` and loader data at `${basePath}/_expo/loaders/*` and root `/_expo/loaders/*` | `no-store, private` |
132
+
133
+ - Pages and loader data can hold PHI. Never cache them in a proxy or CDN.
134
+ - Expo SDK 58's client fetches loader data from the site root, `/_expo/loaders/<route>`,
135
+ ignoring the baked base URL. The plugin claims that root path too; route it to the backend.
136
+ - The gate runs before any render. Anonymous visitors get `/login` (other pages redirect to
137
+ it; loaders answer 401). Signed-in non-admins get `/forbidden` (loaders answer 403). If a
138
+ loopback read fails or takes over 5 s, the gate answers 503 and renders nothing.
139
+ - Each signed-in page makes 3 gate calls (`get-session`, `/auth/me`, `/admin/config`) plus its
140
+ loader's calls to `internalOrigin` (1 to 3 on most screens; on the home page one per model plus
141
+ up to 3), counted in the visitor's own rate-limit bucket.
142
+ - Without `dist/server`, the plugin logs a warning and answers 503 on `${basePath}/*`.
143
+ - `rewriteIndexHtml` and `injectBaseGlobal` are deprecated and will be removed in the next
144
+ major release.
128
145
 
129
146
  ### Mount path / `baseUrl`
130
147
 
131
- Client-side routing under a sub-path requires the bundle to be built with a matching
132
- router base. `app.json` sets `experiments.baseUrl: "/console"`, so the default build is
133
- served at `/console` and `basePath` must match. To mount elsewhere, rebuild with the
134
- matching base (e.g. `EXPO_BASE_URL=/admin-ui bun run build:web`) and set `basePath`
135
- accordingly. Mounting at the origin root (`basePath: "/"`) needs no base.
148
+ `app.json` sets `experiments.baseUrl: "/console"`, and that value is baked into the build.
149
+ `basePath` must equal it, or `register()` throws. To mount elsewhere, change
150
+ `experiments.baseUrl` in `app.json`, run `bun run build:web`, and set `basePath` to match.
151
+ `EXPO_BASE_URL` in the shell has no effect: Metro reads the app config. The published
152
+ package ships the `/console` build only. Mounting at the origin root is not supported.
136
153
 
137
154
  ## Develop, build, and test
138
155
 
139
156
  ```bash
140
157
  bun run compile # compile the server plugin (src/ -> src/dist, CommonJS)
141
- bun run build:web # produce the static export in dist/
158
+ bun run build:web # produce the server export in dist/ (client/ + server/)
142
159
  bun run dev # expo start --web for local SPA development
143
160
  bun run test:ci # serve-plugin unit tests (supertest)
144
- bun run smoke # backend-free smoke: compiled serve plugin + built dist/
145
- bun run test:e2e # Playwright e2e (anonymous -> login) over the built dist/
161
+ bun run smoke # backend-free smoke: compiled serve plugin over the built dist/ (SSR login, root loader, redirect)
162
+ bun run test:e2e # Playwright e2e (SSR, login, shell hydration) over the built dist/
146
163
  ```
147
164
 
148
165
  `dist/` is produced by `bun run build:web` and shipped via
@@ -162,6 +179,8 @@ populated.
162
179
 
163
180
  Full API reference: [docs/reference/admin-spa.md](https://github.com/TerrenoLabs/terreno/blob/master/docs/reference/admin-spa.md)
164
181
 
182
+ Upgrading from the static export: [docs/how-to/upgrade-admin-spa-ssr.md](https://github.com/TerrenoLabs/terreno/blob/master/docs/how-to/upgrade-admin-spa-ssr.md)
183
+
165
184
  ## License and Contributing
166
185
 
167
186
  Licensed under the [MIT License](https://github.com/TerrenoLabs/terreno/blob/master/LICENSE). See [CONTRIBUTING.md](https://github.com/TerrenoLabs/terreno/blob/master/CONTRIBUTING.md) for contribution guidelines.
package/package.json CHANGED
@@ -7,9 +7,9 @@
7
7
  "@better-auth/expo": "^1.6.25",
8
8
  "@expo/vector-icons": "^15.1.1",
9
9
  "@react-native-async-storage/async-storage": "2.2.0",
10
- "@terreno/admin-frontend": "58.0.0-beta.3",
11
- "@terreno/syncdb": "58.0.0-beta.3",
12
- "@terreno/ui": "58.0.0-beta.3",
10
+ "@terreno/admin-frontend": "58.0.0-beta.4",
11
+ "@terreno/syncdb": "58.0.0-beta.4",
12
+ "@terreno/ui": "58.0.0-beta.4",
13
13
  "better-auth": "^1.6.25",
14
14
  "expo": "~58.0.14",
15
15
  "expo-constants": "~58.0.12",
@@ -18,6 +18,7 @@
18
18
  "expo-network": "~58.0.3",
19
19
  "expo-router": "~58.0.23",
20
20
  "expo-secure-store": "~58.0.2",
21
+ "expo-server": "~58.0.5",
21
22
  "expo-splash-screen": "~58.0.10",
22
23
  "expo-status-bar": "~58.0.3",
23
24
  "express": "^5.2.1",
@@ -36,8 +37,8 @@
36
37
  "devDependencies": {
37
38
  "@biomejs/biome": "^2.3.6",
38
39
  "@playwright/test": "^1.58.2",
39
- "@terreno/api": "58.0.0-beta.3",
40
- "@terreno/test": "58.0.0-beta.3",
40
+ "@terreno/api": "58.0.0-beta.4",
41
+ "@terreno/test": "58.0.0-beta.4",
41
42
  "@types/bun": "^1.4.0",
42
43
  "@types/express": "^5.0.6",
43
44
  "@types/luxon": "^3.7.1",
@@ -78,7 +79,7 @@
78
79
  "main": "expo-router/entry",
79
80
  "name": "@terreno/admin-spa",
80
81
  "peerDependencies": {
81
- "@terreno/api": "58.0.0-beta.3"
82
+ "@terreno/api": "58.0.0-beta.4"
82
83
  },
83
84
  "publishConfig": {
84
85
  "access": "public"
@@ -92,13 +93,13 @@
92
93
  "compile": "tsc -p tsconfig.server.json",
93
94
  "compile:watch": "tsc -p tsconfig.server.json -w",
94
95
  "dev": "expo start --web --port 8083",
95
- "lint": "biome check ./src ./app ./store ./components ./e2e playwright.config.ts playwright.integration.config.ts",
96
- "lint:fix": "biome check --write ./src ./app ./store ./components ./e2e playwright.config.ts playwright.integration.config.ts",
96
+ "lint": "biome check ./src ./app ./store ./components ./server ./e2e playwright.config.ts playwright.integration.config.ts",
97
+ "lint:fix": "biome check --write ./src ./app ./store ./components ./server ./e2e playwright.config.ts playwright.integration.config.ts",
97
98
  "smoke": "bun e2e/smoke.ts",
98
99
  "start": "expo start",
99
- "test": "bun test ./src ./store ./components",
100
- "test:agent": "AGENT=1 bun test --only-failures ./src ./store ./components",
101
- "test:ci": "bun test ./src ./store ./components",
100
+ "test": "bun test ./src ./store ./components ./server",
101
+ "test:agent": "AGENT=1 bun test --only-failures ./src ./store ./components ./server",
102
+ "test:ci": "bun test ./src ./store ./components ./server",
102
103
  "test:coverage": "bun run ../scripts/check-coverage.ts",
103
104
  "test:e2e": "playwright test",
104
105
  "test:integration": "playwright test --config playwright.integration.config.ts",
@@ -106,5 +107,5 @@
106
107
  "web": "expo start --web --port 8083"
107
108
  },
108
109
  "types": "./src/dist/index.d.ts",
109
- "version": "58.0.0-beta.3"
110
+ "version": "58.0.0-beta.4"
110
111
  }
@@ -0,0 +1,31 @@
1
+ import type express from "express";
2
+ import type { AdminSpaAppConfig } from "./appConfig";
3
+ export interface AdminSpaGateOptions {
4
+ /** Base path of the admin API on this origin (`/admin`). */
5
+ adminApiBasePath: string;
6
+ appConfig: AdminSpaAppConfig;
7
+ /** Where the SPA is mounted (`/console`). */
8
+ basePath: string;
9
+ /** Renders the page or loader; called inside the request scope. */
10
+ handler: express.RequestHandler;
11
+ /** Origin of this backend for loopback reads. Never derived from the request. */
12
+ internalOrigin: string;
13
+ }
14
+ /**
15
+ * Express middleware that decides access before the admin SPA renders anything. Mounted at
16
+ * the site root, it gates `GET` page and loader requests under `basePath` and loader requests
17
+ * at the root `/_expo/loaders`, and passes every other path through.
18
+ *
19
+ * - Anonymous: pages other than `/login` redirect to `{basePath}/login?returnTo=<path>`;
20
+ * loaders answer 401 JSON (the `login` and `forbidden` loaders run).
21
+ * - Signed in but not an admin (`/admin/config` 403): signed-in-only routes (`/oauth/consent`)
22
+ * render, `/login` with a safe `returnTo` redirects there; other pages redirect to
23
+ * `{basePath}/forbidden`;
24
+ * loaders answer 403 JSON.
25
+ * - Admin: `/login` redirects to a safe `returnTo` (or `{basePath}/`); everything else renders
26
+ * inside a request scope holding the session, profile and admin config, so the page renders
27
+ * with its shell instead of a spinner.
28
+ * - An upstream answer other than 200 / 401 / 403, a network error or a 5 s timeout answers
29
+ * 503 without rendering. A 401 counts as anonymous.
30
+ */
31
+ export declare const createAdminSpaGate: (options: AdminSpaGateOptions) => express.RequestHandler;
@@ -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
package/src/dist/serve.js CHANGED
@@ -4,16 +4,42 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.AdminSpaServeApp = exports.injectBaseGlobal = exports.rewriteIndexHtml = void 0;
7
- const node_fs_1 = __importDefault(require("node:fs"));
8
7
  const node_path_1 = __importDefault(require("node:path"));
9
8
  const api_1 = require("@terreno/api");
10
- const express_1 = __importDefault(require("express"));
9
+ const express_1 = require("expo-server/adapter/express");
10
+ const express_2 = __importDefault(require("express"));
11
11
  const appConfig_1 = require("./appConfig");
12
- // Compiled (CommonJS) file lives at src/dist/serve.js; the pre-built bundle lives at
13
- // admin-spa/dist. `__dirname` is the CommonJS module-directory global.
12
+ const gate_1 = require("./gate");
13
+ const routesManifest_1 = require("./routesManifest");
14
+ const serverContext_1 = require("./serverContext");
15
+ // Compiled (CommonJS) file lives at src/dist/serve.js; the export root (`client/` +
16
+ // `server/`) lives at admin-spa/dist. `__dirname` is the CommonJS module-directory global.
14
17
  const DIST_DIR = node_path_1.default.resolve(__dirname, "../../dist");
15
18
  const NO_STORE = "no-store";
19
+ /** Server-rendered pages and loader data can hold PHI: never cached, never shared. */
20
+ const NO_STORE_PRIVATE = "no-store, private";
21
+ /** API prefixes a server render may fetch, besides the admin API base path. */
22
+ const DEFAULT_SSR_API_PREFIXES = [
23
+ "/auth/me",
24
+ "/orgs",
25
+ "/jobs",
26
+ "/comms",
27
+ "/configuration",
28
+ "/rbac",
29
+ ];
16
30
  /**
31
+ * The default SSR allowlist: the admin API base, the fixed API roots, and the RBAC root, which
32
+ * mounts at the API root (`/internal/admin` puts it at `/internal/rbac`, as `useAdminRoles` does).
33
+ */
34
+ const defaultSsrApiPrefixes = (adminApiBasePath = "/admin") => {
35
+ const rbacPrefix = `${adminApiBasePath.replace(/\/admin\/?$/, "")}/rbac`;
36
+ const prefixes = [adminApiBasePath, ...DEFAULT_SSR_API_PREFIXES];
37
+ return prefixes.includes(rbacPrefix) ? prefixes : [...prefixes, rbacPrefix];
38
+ };
39
+ /**
40
+ * @deprecated The server-rendered build no longer serves a static index.html. Kept for
41
+ * consumers of the static-export API; it will be removed in the next major release.
42
+ *
17
43
  * Rewrite the absolute `/_expo/` and `/assets/` references emitted by Expo's static
18
44
  * web export so a single pre-built bundle works when mounted at any `basePath`.
19
45
  */
@@ -26,6 +52,9 @@ const rewriteIndexHtml = (rawIndex, basePath) => {
26
52
  };
27
53
  exports.rewriteIndexHtml = rewriteIndexHtml;
28
54
  /**
55
+ * @deprecated `+html.tsx` now writes `window.__ADMIN_SPA_BASE__` into the server-rendered
56
+ * page. Kept for consumers of the static-export API; it will be removed in the next major.
57
+ *
29
58
  * Inject `window.__ADMIN_SPA_BASE__` so the SPA can build absolute URLs (e.g.
30
59
  * `${base}/app-config.json`) regardless of the current deep route. Inserted before
31
60
  * `</head>` so it runs before the bundle.
@@ -39,8 +68,9 @@ const injectBaseGlobal = (html, basePath) => {
39
68
  };
40
69
  exports.injectBaseGlobal = injectBaseGlobal;
41
70
  /**
42
- * Opt-in Express plugin that serves a pre-built admin SPA (Expo Router static export)
43
- * from the same Node process as a Terreno backend. Mounts at `/console` by default.
71
+ * Opt-in Express plugin that server-renders the pre-built admin SPA (Expo Router
72
+ * `web.output: "server"` export) from the same Node process as a Terreno backend. Mounts at
73
+ * `/console` by default; `basePath` must equal the baseUrl the bundle was built with.
44
74
  *
45
75
  * @example
46
76
  * ```typescript
@@ -78,31 +108,96 @@ class AdminSpaServeApp {
78
108
  api_1.logger.info(`Admin SPA dev-proxied to ${this.opts.devProxyTarget} at ${basePath}/`);
79
109
  return;
80
110
  }
81
- // Read index.html once and rewrite absolute asset refs to the mounted basePath.
82
- const indexHtmlPath = node_path_1.default.join(distDir, "index.html");
83
- let indexHtml = "";
84
- try {
85
- indexHtml = (0, exports.injectBaseGlobal)((0, exports.rewriteIndexHtml)(node_fs_1.default.readFileSync(indexHtmlPath, "utf-8"), basePath), basePath);
86
- }
87
- catch {
88
- api_1.logger.warn(`Admin SPA: no pre-built bundle found at ${indexHtmlPath}. ` +
89
- "Run `bun run --filter '@terreno/admin-spa' build:web` or set devProxyTarget.");
90
- }
91
- // 1. Hashed asset directories — long cache, immutable.
111
+ const context = {
112
+ appConfig,
113
+ basePath,
114
+ internalOrigin: this.opts.internalOrigin ?? `http://127.0.0.1:${process.env.PORT ?? 4000}`,
115
+ ssrApiPrefixes: this.opts.ssrApiPrefixes ?? defaultSsrApiPrefixes(appConfig.adminApiBasePath),
116
+ warn: (message, meta) => {
117
+ api_1.logger.warn(message, meta);
118
+ },
119
+ };
120
+ (0, serverContext_1.publishAdminSpaServerContext)(context);
121
+ // 1. Hashed client asset directories: long cache, immutable.
122
+ const clientDir = node_path_1.default.join(distDir, "client");
92
123
  const staticOpts = { immutable: true, maxAge: "365d" };
93
- app.use(`${basePath}/_expo`, express_1.default.static(node_path_1.default.join(distDir, "_expo"), staticOpts));
94
- app.use(`${basePath}/assets`, express_1.default.static(node_path_1.default.join(distDir, "assets"), staticOpts));
95
- // 2. Runtime config — never cached.
124
+ app.use(`${basePath}/_expo`, express_2.default.static(node_path_1.default.join(clientDir, "_expo"), staticOpts));
125
+ app.use(`${basePath}/assets`, express_2.default.static(node_path_1.default.join(clientDir, "assets"), staticOpts));
126
+ // 2. Runtime config, never cached.
96
127
  app.get(`${basePath}/app-config.json`, (_req, res) => {
97
128
  res.set("Cache-Control", NO_STORE).json(appConfig);
98
129
  });
99
- // 3. SPA fallback. Two registrations: bare basePath (no trailing slash) AND splat,
100
- // matching the `/*name` Express 5 convention used elsewhere in the repo.
101
- const serveSpa = (_req, res) => {
102
- res.set("Cache-Control", NO_STORE).type("html").send(indexHtml);
130
+ // 3. Server render. Without a server build, answer 503 instead of crashing the backend.
131
+ const serverDir = node_path_1.default.join(distDir, "server");
132
+ const rawManifest = (0, routesManifest_1.readRoutesManifest)(serverDir);
133
+ if (!rawManifest) {
134
+ api_1.logger.warn(`Admin SPA: no server build found at ${serverDir}. ` +
135
+ "Run `bun run --filter '@terreno/admin-spa' build:web` or set devProxyTarget.");
136
+ const unavailable = (_req, res) => {
137
+ res.status(503).set("Cache-Control", NO_STORE).type("text").send("Admin SPA unavailable");
138
+ };
139
+ app.get(basePath, unavailable);
140
+ app.get(`${basePath}/*splat`, unavailable);
141
+ return;
142
+ }
143
+ const bakedBaseUrl = (0, routesManifest_1.bakedBaseUrlOf)(rawManifest);
144
+ if (bakedBaseUrl !== basePath) {
145
+ throw new Error(`Admin SPA basePath "${basePath}" does not match the bundle's baseUrl "${bakedBaseUrl}"`);
146
+ }
147
+ const manifest = (0, routesManifest_1.prepareRoutesManifest)(rawManifest, {
148
+ pageCacheControl: NO_STORE_PRIVATE,
149
+ });
150
+ const handler = (0, express_1.createRequestHandler)({ build: serverDir }, { getRoutesManifest: async () => manifest });
151
+ // Every server render runs in a request scope, so a render reads only its own request's
152
+ // data. Middleware that set a richer scope earlier (the auth gate) keeps it.
153
+ // A failed render or loader logs the route path and the error only: never the query string
154
+ // or response data, which can hold PHI.
155
+ const renderError = (error, req, res) => {
156
+ api_1.logger.error(`Admin SPA render failed for ${req.path}`, {
157
+ error: error instanceof Error ? error.message : String(error),
158
+ path: req.path,
159
+ });
160
+ if (res.headersSent) {
161
+ res.end();
162
+ return;
163
+ }
164
+ res.status(500).set("Cache-Control", NO_STORE_PRIVATE).type("text").send("Admin SPA error");
165
+ };
166
+ const mountPrefix = basePath === "/" ? "" : basePath;
167
+ // Runs Expo's handler in a request scope (the gate's, or a base one) and catches its errors
168
+ // here rather than in a root error middleware, which would also see the API's errors.
169
+ const renderPage = (req, res, next) => {
170
+ res.set("Cache-Control", NO_STORE_PRIVATE);
171
+ // Expo matches routes without the mount path; root loader requests have none to strip.
172
+ if (mountPrefix && (req.path === mountPrefix || req.path.startsWith(`${mountPrefix}/`))) {
173
+ const rest = req.url.slice(mountPrefix.length);
174
+ req.url = rest.startsWith("/") ? rest : `/${rest}`;
175
+ }
176
+ const run = () => {
177
+ handler(req, res, (error) => {
178
+ if (error) {
179
+ renderError(error, req, res);
180
+ return;
181
+ }
182
+ next();
183
+ });
184
+ };
185
+ if ((0, serverContext_1.getAdminSpaRequest)()) {
186
+ run();
187
+ return;
188
+ }
189
+ (0, serverContext_1.runInAdminSpaRequest)({ appConfig, basePath }, run);
103
190
  };
104
- app.get(basePath, serveSpa);
105
- app.get(`${basePath}/*splat`, serveSpa);
191
+ // The gate decides access before anything renders, for pages under basePath and for loader
192
+ // data, which Expo 58's client fetches from the site root regardless of the baked baseUrl
193
+ // (D11 in the SSR plan).
194
+ app.use((0, gate_1.createAdminSpaGate)({
195
+ adminApiBasePath: appConfig.adminApiBasePath ?? "/admin",
196
+ appConfig,
197
+ basePath,
198
+ handler: renderPage,
199
+ internalOrigin: context.internalOrigin,
200
+ }));
106
201
  api_1.logger.info(`Admin SPA mounted at ${basePath}/`);
107
202
  }
108
203
  }
@@ -0,0 +1,61 @@
1
+ import type { AdminSpaAppConfig } from "./appConfig";
2
+ /** What the serve plugin publishes for `render.js`, `+html.tsx`, and route loaders. */
3
+ export interface AdminSpaServerContext {
4
+ appConfig: AdminSpaAppConfig;
5
+ basePath: string;
6
+ /** Origin loopback SSR fetches go to. Never built from `Host` or `X-Forwarded-*`. */
7
+ internalOrigin: string;
8
+ /** API path prefixes a server render may fetch; anything else is rejected. */
9
+ ssrApiPrefixes: string[];
10
+ /**
11
+ * The host's warning logger (`logger.warn` from `@terreno/api`). The render bundle stubs
12
+ * `@terreno/api`, so server code reaches the host's log pipeline through this.
13
+ */
14
+ warn?: (message: string, meta?: Record<string, unknown>) => void;
15
+ }
16
+ /**
17
+ * What one request's server render may read: set by Express before the Expo handler runs,
18
+ * visible to that request's render and loaders only.
19
+ */
20
+ export interface AdminSpaRequestScope {
21
+ appConfig: AdminSpaAppConfig;
22
+ basePath: string;
23
+ /** The Better Auth session snapshot, or null for an anonymous visitor. Set by the gate. */
24
+ session?: AdminSpaSessionSnapshot | null;
25
+ /** The visitor's `GET /auth/me` body, or null when anonymous. Set by the gate. */
26
+ profile?: unknown;
27
+ /** The `GET {adminApiBasePath}/config` body for an admin, else null. Set by the gate. */
28
+ adminConfig?: unknown;
29
+ /** Status of the admin config read (200 admin, 403 forbidden), or null when not read. */
30
+ adminConfigStatus?: number | null;
31
+ }
32
+ /** The Better Auth session as the client's session source reads it. */
33
+ export interface AdminSpaSessionSnapshot {
34
+ userId: string;
35
+ user: unknown;
36
+ }
37
+ /** Publish the server context. One admin SPA per process: a later call replaces it. */
38
+ export declare const publishAdminSpaServerContext: (context: AdminSpaServerContext) => void;
39
+ /** Read the published server context, or undefined before the plugin registers. */
40
+ export declare const getAdminSpaServerContext: () => AdminSpaServerContext | undefined;
41
+ /**
42
+ * Warn from server render code through the host's logger, or `console.warn` before the plugin
43
+ * has published one. Callers pass paths and reasons only: never a query string, a cookie or a
44
+ * response body.
45
+ */
46
+ export declare const warnAdminSpaServer: (message: string, meta?: Record<string, unknown>) => void;
47
+ /**
48
+ * True while rendering on the server: there is no browser `window`, and the serve plugin has
49
+ * published its context in this process. A server render reads what it was given and never
50
+ * starts watchers, sockets or requests of its own.
51
+ */
52
+ export declare const isAdminSpaServerRender: () => boolean;
53
+ /** Run `fn` with `scope` as the current request scope (server only). */
54
+ export declare const runInAdminSpaRequest: <T>(scope: AdminSpaRequestScope, fn: () => T) => T;
55
+ /** The current request's scope, or undefined outside a request (and always in the browser). */
56
+ export declare const getAdminSpaRequest: () => AdminSpaRequestScope | undefined;
57
+ /**
58
+ * Serialize a value for embedding in HTML (an inline `<script>` or a JSON script blob): JSON
59
+ * with every character that could close the script or break a JS string literal escaped.
60
+ */
61
+ export declare const serializeForHtml: (value: unknown) => string;
@@ -0,0 +1,88 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.serializeForHtml = exports.getAdminSpaRequest = exports.runInAdminSpaRequest = exports.isAdminSpaServerRender = exports.warnAdminSpaServer = exports.getAdminSpaServerContext = exports.publishAdminSpaServerContext = void 0;
4
+ /**
5
+ * Key of the process global the serve plugin publishes for the server render. The Expo
6
+ * `render.js` bundle and its loaders run in the same process as Express but are bundled
7
+ * separately, so a process global is the one place both can read. `Symbol.for` keeps the
8
+ * key identical across the two bundles.
9
+ */
10
+ const ADMIN_SPA_SERVER_CONTEXT_KEY = Symbol.for("terreno.adminSpa.server");
11
+ /** Key of the one per-request `AsyncLocalStorage`, shared by every bundle in the process. */
12
+ const ADMIN_SPA_REQUEST_STORE_KEY = Symbol.for("terreno.adminSpa.requestStore");
13
+ /** Publish the server context. One admin SPA per process: a later call replaces it. */
14
+ const publishAdminSpaServerContext = (context) => {
15
+ globalThis[ADMIN_SPA_SERVER_CONTEXT_KEY] = context;
16
+ };
17
+ exports.publishAdminSpaServerContext = publishAdminSpaServerContext;
18
+ /** Read the published server context, or undefined before the plugin registers. */
19
+ const getAdminSpaServerContext = () => {
20
+ return globalThis[ADMIN_SPA_SERVER_CONTEXT_KEY];
21
+ };
22
+ exports.getAdminSpaServerContext = getAdminSpaServerContext;
23
+ /**
24
+ * Warn from server render code through the host's logger, or `console.warn` before the plugin
25
+ * has published one. Callers pass paths and reasons only: never a query string, a cookie or a
26
+ * response body.
27
+ */
28
+ const warnAdminSpaServer = (message, meta) => {
29
+ const warn = (0, exports.getAdminSpaServerContext)()?.warn;
30
+ if (warn) {
31
+ warn(message, meta);
32
+ return;
33
+ }
34
+ if (meta) {
35
+ console.warn(message, meta);
36
+ return;
37
+ }
38
+ console.warn(message);
39
+ };
40
+ exports.warnAdminSpaServer = warnAdminSpaServer;
41
+ /**
42
+ * True while rendering on the server: there is no browser `window`, and the serve plugin has
43
+ * published its context in this process. A server render reads what it was given and never
44
+ * starts watchers, sockets or requests of its own.
45
+ */
46
+ const isAdminSpaServerRender = () => {
47
+ return !("window" in globalThis) && (0, exports.getAdminSpaServerContext)() !== undefined;
48
+ };
49
+ exports.isAdminSpaServerRender = isAdminSpaServerRender;
50
+ /**
51
+ * The process-wide request store, created on first use. `process.getBuiltinModule` keeps
52
+ * `node:async_hooks` out of the static import graph, because this module is also bundled for
53
+ * the browser, where there is no request store.
54
+ */
55
+ const requestStore = () => {
56
+ const global = globalThis;
57
+ const existing = global[ADMIN_SPA_REQUEST_STORE_KEY];
58
+ if (existing) {
59
+ return existing;
60
+ }
61
+ const asyncHooks = process.getBuiltinModule("node:async_hooks");
62
+ const store = new asyncHooks.AsyncLocalStorage();
63
+ global[ADMIN_SPA_REQUEST_STORE_KEY] = store;
64
+ return store;
65
+ };
66
+ /** Run `fn` with `scope` as the current request scope (server only). */
67
+ const runInAdminSpaRequest = (scope, fn) => {
68
+ return requestStore().run(scope, fn);
69
+ };
70
+ exports.runInAdminSpaRequest = runInAdminSpaRequest;
71
+ /** The current request's scope, or undefined outside a request (and always in the browser). */
72
+ const getAdminSpaRequest = () => {
73
+ return globalThis[ADMIN_SPA_REQUEST_STORE_KEY]?.getStore();
74
+ };
75
+ exports.getAdminSpaRequest = getAdminSpaRequest;
76
+ /**
77
+ * Serialize a value for embedding in HTML (an inline `<script>` or a JSON script blob): JSON
78
+ * with every character that could close the script or break a JS string literal escaped.
79
+ */
80
+ const serializeForHtml = (value) => {
81
+ return JSON.stringify(value)
82
+ .replaceAll("<", "\\u003c")
83
+ .replaceAll(">", "\\u003e")
84
+ .replaceAll("&", "\\u0026")
85
+ .replaceAll("\u2028", "\\u2028")
86
+ .replaceAll("\u2029", "\\u2029");
87
+ };
88
+ exports.serializeForHtml = serializeForHtml;
@@ -0,0 +1,2 @@
1
+ /** Whether a route path (`/oauth/consent`, trailing slashes ignored) is signed-in-only. */
2
+ export declare const isSignedInOnlyRoute: (pathname: string) => boolean;
@@ -0,0 +1,10 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isSignedInOnlyRoute = void 0;
4
+ /** Routes any signed-in user may open; they skip the admin check and admin bootstrapping. */
5
+ const SIGNED_IN_ONLY_ROUTES = ["/oauth/consent"];
6
+ /** Whether a route path (`/oauth/consent`, trailing slashes ignored) is signed-in-only. */
7
+ const isSignedInOnlyRoute = (pathname) => {
8
+ return SIGNED_IN_ONLY_ROUTES.includes(pathname.replace(/\/+$/, "") || "/");
9
+ };
10
+ exports.isSignedInOnlyRoute = isSignedInOnlyRoute;