forked from bai/curriculum-project-hub
12628c9233
- 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>
107 lines
4.6 KiB
TypeScript
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).
|
|
}
|