forked from EduCraft/curriculum-project-hub
docs(adr): 0029 web 界面一律静态 SPA,hub 只出 JSON
记录本次迁移的语义决策:没有 HTTP handler 渲染 HTML;/app 与 /database 是同一个前端工程 filelib-web,构建一次挂两个前缀; 客户端导航用真 URL 路由而非 hash 片段。 两条承重配置约束一并写明:appDir 必须改名(默认 _app 与 admin-web 在根上的 /_app/* 撞重复路由,Fastify 启动即失败),以及 paths.relative=false(同一份 index.html 在不同 URL 深度被送出)。
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
# 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-0028, 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.
|
||||
Reference in New Issue
Block a user