forked from EduCraft/curriculum-project-hub
133 lines
7.1 KiB
Markdown
133 lines
7.1 KiB
Markdown
# ADR 0029: Web Surfaces Are Static SPAs; the Hub Serves JSON Only
|
|
|
|
## Status
|
|
|
|
Accepted.
|
|
|
|
## Context
|
|
|
|
The Hub exposes three browser surfaces: the org-admin console (`/admin`), the
|
|
teacher-facing file library (`/app`), and the database admin back office
|
|
(`/database`). They arrived at different times and diverged in how HTML reached
|
|
the browser.
|
|
|
|
`/admin` and `/app` were already separated: the backend serves a prebuilt static
|
|
`index.html` and never inspects the request; all data flows through JSON
|
|
endpoints. `/database` was not. Roughly 1770 lines across four modules
|
|
(`renderDashboard`/`renderLoginPage` in `routes/databaseRoutes.ts`,
|
|
`routes/adminPanels.ts`, `routes/libraryBrowser.ts`, `routes/libraryPage.ts`)
|
|
assembled HTML template strings server-side, reading the session cookie and
|
|
querying Prisma inside the page handler, with layout expressed as inline
|
|
`style="…"` attributes and behavior as `<script>` text.
|
|
|
|
A prior migration (`12628c9`) introduced a fourth frontend project,
|
|
`hub/database-admin/`, intended to replace those pages. It was never wired up:
|
|
the concrete route `/database/dashboard` is more specific than the SPA wildcard
|
|
`/database/*`, so the server-rendered handler always won and the SPA's dashboard
|
|
was unreachable. That project's file header claimed the SPA served the dashboard
|
|
and that `/database/config` existed; neither was true. The `npm run build` script
|
|
also never built it, so the `existsSync` guard in `database/static.ts` failed on
|
|
every deploy and the shell was permanently disabled.
|
|
|
|
Duplicated visual rules were the practical cost: card padding and type sizes were
|
|
restated in each render module, and only the CSS variables in `routes/uiTheme.ts`
|
|
were genuinely shared.
|
|
|
|
## Decision
|
|
|
|
**No Hub HTTP handler renders HTML.** Every browser surface is a prebuilt static
|
|
SPA. Page handlers send a byte-identical `index.html` that does not depend on the
|
|
request; all per-user and per-request data is fetched by the client from JSON
|
|
endpoints under `/api/*` or `/database/api/*`.
|
|
|
|
**`/app` and `/database` are one frontend project, `hub/filelib-web`, built once
|
|
and mounted at two prefixes.** They share the file library browser, the session
|
|
layer, the toast host, and the design tokens; splitting them would duplicate all
|
|
of it. `hub/database-admin` is deleted — superseded before it ever served a
|
|
request.
|
|
|
|
Two configuration constraints follow from co-hosting two SvelteKit SPAs on one
|
|
Fastify instance, and are load-bearing:
|
|
|
|
- `filelib-web` sets `appDir: '_filelib'`. The SvelteKit default `_app` collides
|
|
with the root `/_app/*` asset route that `admin-web` owns
|
|
(`src/admin/static.ts`); Fastify rejects duplicate routes at startup, so the
|
|
collision is a boot failure, not a silent misroute.
|
|
- `filelib-web` sets `paths.relative: false`. The same `index.html` is served at
|
|
different URL depths (`/app`, `/database/dashboard/users`), so relative asset
|
|
paths would resolve against the wrong base.
|
|
|
|
**Client-side navigation uses real URL routes, not hash fragments or hidden
|
|
sections.** The six back-office tabs are `/database/dashboard`,
|
|
`/database/dashboard/library`, `/users`, `/groups`, `/search`, `/settings`.
|
|
Refresh preserves position and links are shareable — the previous
|
|
`location.hash` + `display:none` scheme lost both.
|
|
|
|
Concrete routes must be registered before the SPA wildcards. This is an ordering
|
|
obligation on `database/plugin.ts`, not an incidental detail: the earlier
|
|
`/database/dashboard` shadowing bug is exactly what happens when a concrete page
|
|
route outranks the fallback.
|
|
|
|
## Consequences
|
|
|
|
- Authorization is enforced only by the JSON endpoints. A client-side guard (the
|
|
`isWebsiteAdmin` check in the dashboard layout) is a navigation convenience and
|
|
carries no security weight; every endpoint keeps its own `fail closed` guard.
|
|
- `/database/api/stats` is a new endpoint carrying what `loadDashboardStats` used
|
|
to compute inline. It requires silo org `OWNER`/`ADMIN` because it aggregates
|
|
org-wide counts and the audit stream rather than a per-node permission view.
|
|
- `/database/api/me` grew `displayName` and `avatarUrl`. Anything the old page
|
|
handler read from Prisma to render chrome has to become part of a JSON payload
|
|
or it is simply unavailable: the sidebar identity strip showed a raw `userId`
|
|
until these were added. When migrating a server-rendered surface, the data the
|
|
template closed over is part of the contract being ported, not an incidental
|
|
detail of the old implementation.
|
|
- Editing a page no longer requires a Hub restart in development; `vite dev`
|
|
serves the frontend and proxies data requests to the Hub. In production the
|
|
`index.html` is cached in memory at startup, so a frontend rebuild does require
|
|
a restart.
|
|
- Deploy scripts and the silo rate-limit exemption list name `filelib-web` and
|
|
`/_filelib/*`. Adding a fourth surface means picking another `appDir` and
|
|
extending that list.
|
|
- The design system is one file, `filelib-web/src/app.css`: an `@theme` block for
|
|
tokens plus an `@layer components` block for the shared component classes
|
|
(`.btn`, `.panel`, `.input`, `.select`, `.list`, `.tag`, `.quiet`, …).
|
|
`routes/uiTheme.ts` is deleted; both halves live there now.
|
|
|
|
The first cut of this migration kept only the tokens and restated button,
|
|
input, and panel styling inline in every component. That reproduced the
|
|
duplication the old code had — the admin panels visibly regressed — so the
|
|
component layer was ported too. Components carry layout utilities; they do not
|
|
restate component styling. The one admitted exception is a data-derived value
|
|
(tree indent computed from `depth`), which cannot be a static class.
|
|
|
|
The icon set (`lib/Icon.svelte`, 13 paths) is likewise shared rather than
|
|
restated. It came from `adminPanels.ts`; Group nodes deliberately use a
|
|
two-person silhouette, not a folder glyph, because `MemberGroup` and the file
|
|
library's `FOLDER`/`PROJECT` are unrelated hierarchies (ADR-0038, ADR-0021).
|
|
|
|
- **A migrated surface is only done when its endpoint coverage matches.** Two
|
|
panels were rebuilt from a superficially similar component that predated the
|
|
migration rather than from the server module they replaced, and the mismatch
|
|
was invisible in the rendered page:
|
|
|
|
- Group management called 5 of 8 endpoints. Rename (`PATCH`),
|
|
`?includeArchived=1`, `/restore`, and `/users/search` had no entry point, so
|
|
a soft-deleted group could not be restored through the UI at all even though
|
|
the backend fully supported it.
|
|
- The library browser dropped the `授权` tab entirely — `GET/PUT/DELETE
|
|
.../grants` and `PUT .../independent-permission` had no caller. Permission
|
|
editing is the point of the back office, and it was unreachable.
|
|
|
|
Diffing the route table against the frontend's `api()` call sites catches this;
|
|
reading the new page does not.
|
|
|
|
## Deferred
|
|
|
|
- `/admin` (admin-web) stays a separate project. It has its own design language
|
|
(`saas-*` classes, `surface-*`/`primary-*` scales) and a different audience;
|
|
merging it is not motivated by shared code.
|
|
- The `search` and `settings` tabs remain placeholders, as they were server-side.
|
|
- Serving `/admin` and `/database` from a single SPA, which would remove the
|
|
`appDir` collision constraint entirely.
|