Files
curriculum-project-hub/docs/adr/0029-web-surface-frontend-backend-separation.md
T
683e97ca53 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 深度被送出)。
2026-07-26 20:17:09 +08:00

7.1 KiB

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.