Files
curriculum-project-hub/hub/src/database/routes/databaseRoutes.ts
T
12628c9233 feat(hub): migrate database admin pages to SPA (database-admin)
- scaffold hub/database-admin as SvelteKit 2 + Svelte 5 static SPA
  with aurora/glass visual style (paths.base='/database')
- add lib/{api,session,org}.ts + Aurora.svelte component
- add routes: root redirect, /admin login page, /dashboard (OWNER/ADMIN only)
- backend: replace server-rendered HTML routes with /database/config JSON endpoint
- add hub/src/database/static.ts to serve SPA under /database/*
- wire registerDatabaseSpa into plugin.ts
- exempt /database/* from silo rate-limit (same treatment as /admin/*)
- add database:dev + database:build npm scripts; update deploy scripts
- update hub/src/database/README.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 21:51:53 +08:00

107 lines
4.6 KiB
TypeScript

/**
* `/database/*` route aggregator.
*
* Owns the `/database` HTTP surface (single place new sub-routes get wired in).
* Handlers use ABSOLUTE paths (no Fastify prefix) so every route greps as the
* literal string it serves.
*
* The login page and dashboard are now served by the `database-admin` SvelteKit
* SPA (see ../static.ts / registerDatabaseSpa). This file keeps only the
* concrete JSON/redirect routes the SPA depends on:
*
* /database/config — unauthenticated bootstrap: silo org slug + dev toggle
* /database/dev-login — DEV ONLY bypass, registered only when the flag is on
*
* The dev bypass (/database/dev-login) is self-contained here and gated by
* allowDevLoginBypass (computed in ./plugin.ts from HUB_DEV_LOGIN_BYPASS +
* NODE_ENV). Production requires real Feishu OAuth.
*
* NOTE: concrete routes here MUST be registered before the SPA fallback
* (registerDatabaseSpa serves /database and /database/*), or the wildcard would
* shadow them.
*/
import type { FastifyInstance } from "fastify";
import type { PrismaClient } from "@prisma/client";
import { SESSION_COOKIE_NAME, signSession } from "../../admin/auth/session.js";
export interface DatabaseRouteConfig {
readonly prisma: PrismaClient;
/** HMAC secret for the signed session cookie — reused from the admin plane. */
readonly sessionSecret: string;
/** Silo Organization slug — the SPA builds the org-scoped Feishu login link from it. */
readonly siloOrganizationSlug: string;
/** DEV ONLY. Enables the one-click button and the /database/dev-login route. */
readonly allowDevLoginBypass: boolean;
}
export async function registerDatabaseRoutes(
app: FastifyInstance,
config: DatabaseRouteConfig,
): Promise<void> {
// Unauthenticated bootstrap for the static SPA login page. Exposes only what
// the page needs to build the Feishu login link and toggle the dev button —
// no secrets, no user data.
app.get("/database/config", async () => {
return {
siloOrganizationSlug: config.siloOrganizationSlug,
devLoginEnabled: config.allowDevLoginBypass,
};
});
// DEV ONLY bypass — self-contained here, registered only when the flag is on
// (see ./plugin.ts). Mints a session for an existing OWNER/ADMIN, reusing the
// scoped SessionIdentity shape the Feishu OAuth callback produces so the
// session guard behaves identically. Never registered in production.
if (config.allowDevLoginBypass) {
app.get("/database/dev-login", async (_request, reply) => {
const membership = await config.prisma.organizationMembership.findFirst({
where: {
revokedAt: null,
role: { in: ["OWNER", "ADMIN"] },
organization: { status: "ACTIVE" },
},
select: { userId: true, organizationId: true },
orderBy: { createdAt: "asc" },
});
if (membership === null) {
return reply.status(404).send({ error: { code: "no_admin", message: "no active OWNER/ADMIN to impersonate" } });
}
const identity = await config.prisma.feishuUserIdentity.findFirst({
where: {
userId: membership.userId,
connection: { organizationId: membership.organizationId, status: "ACTIVE" },
},
select: { id: true, connectionId: true, connection: { select: { organizationId: true } } },
});
if (identity === null) {
return reply.status(404).send({ error: { code: "no_identity", message: "admin has no active scoped Feishu identity" } });
}
const token = signSession(
{
userId: membership.userId,
feishuIdentityId: identity.id,
feishuConnectionId: identity.connectionId,
feishuOrganizationId: identity.connection.organizationId,
},
config.sessionSecret,
);
// Local dev is http://127.0.0.1, so secure:false. This route only ever
// runs outside production (double-gated in ./plugin.ts).
reply.setCookie(SESSION_COOKIE_NAME, token, {
path: "/",
httpOnly: true,
sameSite: "lax",
secure: false,
maxAge: 7 * 24 * 60 * 60,
});
reply.log.warn({ userId: membership.userId, orgId: membership.organizationId }, "DEV database login bypass used");
return reply.redirect("/database/dashboard");
});
}
// Add more /database/* JSON routes here. Guard data routes with requireSession
// / requireOrgRole (../../admin/auth/guards.js) and scope every query to the
// caller's org (ADR-0020). Access the DB via config.prisma. Register concrete
// routes before registerDatabaseSpa's /database/* fallback (done in ./plugin.ts).
}