NODEDC_LAUNCHER/server/dev-server.mjs

2396 lines
76 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import express from "express";
import { createServer as createHttpServer } from "node:http";
import { randomBytes, randomUUID, createHash, timingSafeEqual } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, extname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createServer as createViteServer } from "vite";
import { createRemoteJWKSet, jwtVerify } from "jose";
import { createAuthentikSyncClient, resolveRequiredGroups } from "./authentik-sync.mjs";
import { createControlPlaneStore } from "./control-plane-store.mjs";
const serverRoot = dirname(fileURLToPath(import.meta.url));
const projectRoot = resolve(serverRoot, "..");
const maxStorageJsonBodyBytes = "260mb";
const pendingLoginTtlMs = 10 * 60 * 1000;
const serviceHandoffTtlMs = 60 * 1000;
const sessionTtlMs = 12 * 60 * 60 * 1000;
const oidcStateCookieName = "nodedc_oidc_state";
const maxOidcStateCookieEntries = 8;
const sessionCookieName = "nodedc_session";
const noStoreCacheControl = "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
loadEnvFiles([
process.env.NODEDC_PLATFORM_ENV,
resolve(projectRoot, ".env"),
resolve(projectRoot, "..", "..", "NODEDC", "platform", "infra", ".env"),
]);
const config = readConfig();
const app = express();
const httpServer = createHttpServer(app);
const controlPlaneStore = createControlPlaneStore({ projectRoot });
const authentikSyncClient = createAuthentikSyncClient({ baseUrl: config.authentikBaseUrl, token: config.authentikApiToken });
const pendingLogins = new Map();
const serviceHandoffs = new Map();
const sessions = new Map();
const runtimeEventClients = new Set();
let discoveryCache = null;
let jwksCache = null;
app.disable("x-powered-by");
app.use((req, res, next) => {
if (shouldDisableHttpCache(req)) {
lockNoStoreHeaders(res);
}
next();
});
app.use(express.json({ limit: maxStorageJsonBodyBytes }));
app.get("/healthz", (_req, res) => {
res.json({
ok: true,
service: "nodedc-launcher-bff",
oidcConfigured: config.oidcConfigured,
authentikApiConfigured: authentikSyncClient.isConfigured(),
internalAccessApiConfigured: Boolean(config.internalAccessToken),
});
});
app.get("/api/public/brand", (_req, res) => {
const snapshot = controlPlaneStore.getSnapshot({ name: "NODE.DC public brand" });
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Cache-Control", noStoreCacheControl);
res.json(buildPublicBrandResponse(snapshot.data.settings));
});
app.get("/auth/login", asyncRoute(async (req, res) => {
ensureOidcConfigured();
const discovery = await getOidcDiscovery();
const returnTo = sanitizeReturnTo(req.query.returnTo);
const prompt = sanitizePrompt(req.query.prompt);
const cookieStates = getValidOidcCookieStates(req);
const reusableState = cookieStates.find((cookieState) => {
const pendingLogin = pendingLogins.get(cookieState);
return pendingLogin?.returnTo === returnTo && pendingLogin?.prompt === prompt;
});
if (reusableState) {
const pendingLogin = pendingLogins.get(reusableState);
if (pendingLogin) {
res.redirect(buildOidcAuthorizationUrl(discovery, { state: reusableState, prompt, pendingLogin }).toString());
return;
}
}
const state = randomBase64Url(32);
const nonce = randomBase64Url(32);
const codeVerifier = randomBase64Url(64);
pruneExpiredState();
pendingLogins.set(state, {
codeVerifier,
nonce,
returnTo,
prompt,
expiresAt: Date.now() + pendingLoginTtlMs,
});
setOidcStateCookie(res, [state, ...cookieStates].slice(0, maxOidcStateCookieEntries));
const pendingLogin = pendingLogins.get(state);
const authorizationUrl = buildOidcAuthorizationUrl(discovery, { state, prompt, pendingLogin });
res.redirect(authorizationUrl.toString());
}));
app.get("/auth/callback", asyncRoute(async (req, res) => {
ensureOidcConfigured();
const error = typeof req.query.error === "string" ? req.query.error : null;
if (error) {
throw new Error(`OIDC provider returned error: ${error}`);
}
const code = typeof req.query.code === "string" ? req.query.code : null;
const state = typeof req.query.state === "string" ? req.query.state : null;
const cookieStates = getValidOidcCookieStates(req);
if (!code || !state || !cookieStates.includes(state)) {
res.clearCookie(oidcStateCookieName, clearCookieOptions());
res.redirect("/auth/login?returnTo=/");
return;
}
const pendingLogin = pendingLogins.get(state);
pendingLogins.delete(state);
setOidcStateCookie(res, cookieStates.filter((cookieState) => cookieState !== state));
if (!pendingLogin || pendingLogin.expiresAt < Date.now()) {
throw new Error("OIDC login state expired");
}
const discovery = await getOidcDiscovery();
const tokenSet = await exchangeCodeForTokens(discovery, code, pendingLogin.codeVerifier);
const claims = await verifyIdToken(discovery, tokenSet.id_token, pendingLogin.nonce);
createLauncherSession(res, normalizeUser(claims), {
idToken: tokenSet.id_token,
accessToken: tokenSet.access_token ?? null,
expiresAt: tokenSet.expires_in ? Date.now() + Number(tokenSet.expires_in) * 1000 : null,
});
res.redirect(pendingLogin.returnTo);
}));
app.get("/auth/logged-out", (req, res) => {
const returnTo = sanitizeReturnTo(req.query.returnTo);
res.clearCookie(sessionCookieName, clearCookieOptions());
res.clearCookie(oidcStateCookieName, clearCookieOptions());
setNoStore(res);
res.redirect(buildLoginRedirectUrl(returnTo, { forceLogin: true }));
});
app.get("/auth/session-sync", (req, res) => {
const allowedOrigins = getSessionSyncAllowedOrigins();
setNoStore(res);
res.setHeader(
"Content-Security-Policy",
`default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; frame-ancestors ${allowedOrigins.join(" ")}`
);
res.type("html").send(renderSessionSyncBridgePage(allowedOrigins));
});
app.get("/logout", (req, res) => {
const session = getCurrentSession(req);
if (session) {
sessions.delete(session.id);
}
res.clearCookie(sessionCookieName, clearCookieOptions());
setNoStore(res);
res.type("html").send(
"<!doctype html><html><head><meta charset='utf-8'></head><body>NODE.DC Launcher session closed.</body></html>"
);
});
app.get("/auth/logout", asyncRoute(async (req, res) => {
const session = getCurrentSession(req);
const returnTo = sanitizeReturnTo(req.query.returnTo);
const globalLogout = req.query.global === "1" || req.query.global === "true";
const taskSessionLogoutPromise = globalLogout ? notifyTaskSessionLogout(session) : Promise.resolve();
if (session) {
sessions.delete(session.id);
}
res.clearCookie(sessionCookieName, clearCookieOptions());
if (!globalLogout || !config.oidcConfigured) {
setNoStore(res);
res.redirect(returnTo);
return;
}
const discovery = await getOidcDiscovery();
const logoutUrl = buildOidcLogoutUrl(discovery, returnTo, session?.tokenSet.idToken);
await taskSessionLogoutPromise;
setNoStore(res);
res.type("html").send(renderGlobalLogoutPage(getFrontchannelLogoutUrls(), logoutUrl.toString()));
}));
app.get("/api/me", (req, res) => {
const session = getCurrentSession(req);
if (!session) {
res.status(401).json({ authenticated: false, loginUrl: "/auth/login" });
return;
}
const runtimeContext = getRuntimeSessionContext(session);
res.json({
authenticated: true,
user: runtimeContext.user,
groups: runtimeContext.groups,
isSuperAdmin: runtimeContext.groups.includes("nodedc:superadmin"),
logoutUrl: "/auth/logout?global=1&returnTo=/",
});
});
app.get("/api/apps", (req, res) => {
const session = getCurrentSession(req);
if (!session) {
res.status(401).json({ authenticated: false, loginUrl: "/auth/login" });
return;
}
res.json({ apps: getAppsForSession(session) });
});
app.get("/api/services/:serviceSlug/launch", requireSession, (req, res) => {
const serviceSlug = sanitizeServiceSlug(req.params.serviceSlug);
const runtimeContext = getRuntimeSessionContext(req.nodedcSession);
const app = getAppsForUser(runtimeContext.groups).find((candidate) => candidate.slug === serviceSlug);
if (!app || !app.hasAccess || app.status !== "active") {
res.status(403).type("text/plain").send("NODE.DC service access denied.");
return;
}
if (serviceSlug !== "task-manager") {
res.redirect(app.openUrl || app.url || "/");
return;
}
const handoffToken = createServiceHandoff(serviceSlug, runtimeContext.user);
const taskBaseUrl = getTaskBaseUrl();
const targetUrl = new URL("/auth/nodedc/handoff/", taskBaseUrl);
const nextPath = sanitizeReturnTo(req.query.next_path || req.query.returnTo || "/");
targetUrl.searchParams.set("token", handoffToken);
if (nextPath && nextPath !== "/") {
targetUrl.searchParams.set("next_path", nextPath);
}
res.redirect(targetUrl.toString());
});
app.post("/api/internal/handoff/consume", (req, res) => {
if (!isInternalRequestAuthorized(req)) {
res.status(config.internalAccessToken ? 401 : 503).json({
ok: false,
error: config.internalAccessToken ? "internal_handoff_unauthorized" : "internal_handoff_not_configured",
});
return;
}
const token = typeof req.body?.token === "string" ? req.body.token : "";
const serviceSlug = sanitizeServiceSlug(req.body?.serviceSlug);
const handoff = consumeServiceHandoff(token, serviceSlug);
if (!handoff) {
res.status(404).json({ ok: false, error: "handoff_not_found" });
return;
}
const snapshot = controlPlaneStore.getSnapshot({ name: "NODE.DC handoff validation" });
const user = findInternalAccessUser(snapshot.data, {
subject: handoff.user.sub,
email: handoff.user.email,
});
const groups = user ? resolveRequiredGroups(snapshot.data, user) : handoff.user.groups;
const app = getAppsForUser(groups).find((candidate) => candidate.slug === serviceSlug);
if (!user || !app?.hasAccess) {
res.status(403).json({ ok: false, error: "handoff_access_denied" });
return;
}
res.json({
ok: true,
serviceSlug,
user: {
id: user.id,
email: user.email,
name: user.name,
avatarUrl: resolveUserAvatarPublicUrl(user),
subject: user.authentikUserId || handoff.user.sub,
authentikUserId: user.authentikUserId ?? null,
groups,
},
});
});
app.get("/api/profile", requireSession, (req, res) => {
const { actor, data } = getLauncherProfileContext(req.nodedcSession);
const user = findLauncherUser(data, actor.id);
res.json({
user,
memberships: data.memberships.filter((membership) => membership.userId === user.id),
});
});
app.get("/api/events", requireSession, (req, res) => {
const client = {
id: randomUUID(),
res,
};
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache, no-transform");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
res.flushHeaders?.();
res.write(`event: nodedc-ready\ndata: ${JSON.stringify({ ok: true })}\n\n`);
const keepAlive = setInterval(() => {
res.write(": keep-alive\n\n");
}, 30000);
runtimeEventClients.add(client);
req.on("close", () => {
clearInterval(keepAlive);
runtimeEventClients.delete(client);
});
});
app.post("/api/internal/access/check", (req, res) => {
if (!isInternalRequestAuthorized(req)) {
res.status(config.internalAccessToken ? 401 : 503).json({
ok: false,
error: config.internalAccessToken ? "internal_access_unauthorized" : "internal_access_not_configured",
});
return;
}
const snapshot = controlPlaneStore.getSnapshot({ name: "NODE.DC internal access check" });
const user = findInternalAccessUser(snapshot.data, req.body);
const serviceSlug = sanitizeServiceSlug(req.body?.serviceSlug);
if (!user) {
res.json({
ok: true,
allowed: false,
reason: "user_not_found",
serviceSlug,
groups: [],
matchedGroups: [],
user: null,
});
return;
}
const groups = resolveRequiredGroups(snapshot.data, user);
const app = getAppsForUser(groups).find((candidate) => candidate.slug === serviceSlug);
const allowed = Boolean(app?.hasAccess);
const workspacePolicy =
serviceSlug === "task-manager" ? resolveTaskManagerWorkspacePolicy(snapshot.data, groups, allowed) : null;
res.json({
ok: true,
allowed,
reason: allowed ? "access_confirmed" : "access_denied",
serviceSlug,
groups,
matchedGroups: app?.matchedGroups ?? [],
workspacePolicy,
user: {
id: user.id,
email: user.email,
name: user.name,
avatarUrl: user.avatarUrl ?? null,
authentikUserId: user.authentikUserId ?? null,
globalStatus: user.globalStatus,
},
});
});
app.patch("/api/profile", requireSession, asyncRoute(async (req, res) => {
const { actor } = getLauncherProfileContext(req.nodedcSession);
const result = await controlPlaneStore.updateUserProfile(actor.id, sanitizeSelfProfilePatch(req.body), req.nodedcSession.user);
const provisionedUser = await authentikSyncClient.provisionUser({
data: result.data,
userId: actor.id,
});
const storeResult = await controlPlaneStore.markUserAuthentikProvisioned(actor.id, provisionedUser, req.nodedcSession.user);
publishControlPlaneEvent("profile.updated", [actor.id]);
res.json({ ...storeResult, provisioning: toProvisioningResponse(provisionedUser) });
}));
app.post("/api/profile/password", requireSession, asyncRoute(async (req, res) => {
const newPassword = sanitizeNewPassword(req.body?.newPassword);
const { actor, data } = getLauncherProfileContext(req.nodedcSession);
const provisionedUser = await authentikSyncClient.provisionUser({
data,
userId: actor.id,
password: newPassword,
});
const result = await controlPlaneStore.markUserAuthentikProvisioned(actor.id, provisionedUser, req.nodedcSession.user);
publishControlPlaneEvent("profile.password.updated", [actor.id]);
res.json({ data: result.data, ok: true });
}));
app.get("/api/invites/:token", (req, res) => {
try {
res.json(controlPlaneStore.getInviteByToken(req.params.token));
} catch (error) {
sendInviteApiError(res, error);
}
});
app.post("/api/invites/:token/register", asyncRoute(async (req, res) => {
let payload;
try {
payload = sanitizeInviteRegistrationPayload(req.body);
} catch (error) {
sendInviteApiError(res, error);
return;
}
if (!authentikSyncClient.isConfigured()) {
res.status(503).json({ error: "Регистрация временно недоступна: Authentik API не настроен" });
return;
}
let draft;
try {
draft = controlPlaneStore.prepareInviteRegistration(req.params.token, payload);
} catch (error) {
sendInviteApiError(res, error);
return;
}
const provisionedUser = await authentikSyncClient.provisionUser({
data: draft.data,
userId: draft.user.id,
password: payload.password,
});
const result = await controlPlaneStore.commitInviteRegistration(req.params.token, payload, provisionedUser);
const storeResult = await controlPlaneStore.markUserAuthentikProvisioned(result.user.id, provisionedUser, {
sub: provisionedUser.authentikUserId,
email: result.user.email,
name: result.user.name,
});
const groups = resolveRequiredGroups(storeResult.data, storeResult.user);
createLauncherSession(
res,
normalizeControlPlaneSessionUser(storeResult.user, groups),
{
idToken: null,
accessToken: null,
expiresAt: null,
}
);
publishControlPlaneEvent("invite.registered", [result.user.id]);
res.json({
...result,
user: storeResult.user,
data: storeResult.data,
provisioning: toProvisioningResponse(provisionedUser),
loginUrl: buildLoginRedirectUrl("/", { forceLogin: true, includeReturnTo: true }),
redirectUrl: "/",
authenticated: true,
});
}));
app.post("/api/invites/:token/accept", requireSession, asyncRoute(async (req, res) => {
let result;
try {
result = await controlPlaneStore.acceptInvite(req.params.token, req.nodedcSession.user);
} catch (error) {
sendInviteApiError(res, error);
return;
}
const syncResult = await syncUsersToAuthentik(result.data, [result.user.id], req.nodedcSession.user);
publishControlPlaneEvent("invite.accepted", syncResult.userIds);
res.json({ ...result, data: syncResult.data });
}));
app.get("/api/admin/control-plane", requireLauncherAdmin, (req, res) => {
res.json(scopeAdminSnapshot(req));
});
app.patch("/api/admin/settings", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
const result = await controlPlaneStore.updateSettings(req.body, req.nodedcSession.user);
publishControlPlaneEvent("admin.settings.updated");
res.json(result);
}));
app.get("/api/admin/clients", requireLauncherAdmin, (req, res) => {
const snapshot = scopeAdminSnapshot(req);
res.json({ clients: snapshot.data.clients });
});
app.get("/api/admin/task-manager/workspaces", requireLauncherAdmin, asyncRoute(async (req, res) => {
if (!req.nodedcAdminScope?.isRoot) {
res.json({ ok: true, workspaces: [] });
return;
}
const taskManager = await requestTaskManagerInternalJson("/api/internal/nodedc/workspaces/");
res.json(taskManager);
}));
app.post("/api/admin/task-manager/workspace-memberships/ensure", requireLauncherAdmin, asyncRoute(async (req, res) => {
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
const clientId = typeof req.body?.clientId === "string" ? req.body.clientId : "";
const userId = typeof req.body?.userId === "string" ? req.body.userId : "";
const client = snapshot.data.clients.find((candidate) => candidate.id === clientId);
const user = snapshot.data.users.find((candidate) => candidate.id === userId);
if (!client) {
res.status(404).json({ ok: false, error: "client_not_found" });
return;
}
if (!user) {
res.status(404).json({ ok: false, error: "user_not_found" });
return;
}
if (!assertAdminCanManageClient(req, res, client.id) || !assertAdminCanManageUser(req, res, user.id)) {
return;
}
const membership = snapshot.data.memberships.find((candidate) => candidate.clientId === client.id && candidate.userId === user.id);
const workspaceSlug = normalizeOptionalText(req.body?.workspaceSlug) ?? client.integrations?.taskManager?.workspaceSlug ?? null;
if (!workspaceSlug) {
res.status(400).json({ ok: false, error: "task_manager_workspace_not_configured" });
return;
}
const role = normalizeTaskManagerRole(req.body?.role) ?? resolveTaskManagerRoleForMembership(membership?.role);
const taskManager = await requestTaskManagerInternalJson("/api/internal/nodedc/workspace-memberships/ensure/", {
method: "POST",
body: {
workspaceSlug,
email: user.email,
subject: user.authentikUserId ?? undefined,
avatarUrl: resolveUserAvatarPublicUrl(user),
role,
companyRole: membership?.role ?? null,
setLastWorkspace: req.body?.setLastWorkspace !== false,
},
});
const result = await controlPlaneStore.recordTaskManagerWorkspaceMembership(
{
clientId: client.id,
userId: user.id,
workspaceSlug,
role,
taskManager,
},
req.nodedcSession.user
);
publishControlPlaneEvent("admin.task-manager.workspace-membership.updated", [user.id]);
res.json({ ...scopeAdminMutationResult(req, result), taskManager });
}));
app.post("/api/admin/task-manager/workspace-memberships/remove", requireLauncherAdmin, asyncRoute(async (req, res) => {
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
const clientId = typeof req.body?.clientId === "string" ? req.body.clientId : "";
const userId = typeof req.body?.userId === "string" ? req.body.userId : "";
const client = snapshot.data.clients.find((candidate) => candidate.id === clientId);
const user = snapshot.data.users.find((candidate) => candidate.id === userId);
if (!client) {
res.status(404).json({ ok: false, error: "client_not_found" });
return;
}
if (!user) {
res.status(404).json({ ok: false, error: "user_not_found" });
return;
}
if (!assertAdminCanManageClient(req, res, client.id) || !assertAdminCanManageUser(req, res, user.id)) {
return;
}
const membership = snapshot.data.memberships.find((candidate) => candidate.clientId === client.id && candidate.userId === user.id);
const workspaceSlug = normalizeOptionalText(req.body?.workspaceSlug) ?? client.integrations?.taskManager?.workspaceSlug ?? null;
if (!workspaceSlug) {
res.status(400).json({ ok: false, error: "task_manager_workspace_not_configured" });
return;
}
if (membership?.role === "client_owner") {
const taskManager = await requestTaskManagerInternalJson("/api/internal/nodedc/workspace-memberships/ensure/", {
method: "POST",
body: {
workspaceSlug,
email: user.email,
subject: user.authentikUserId ?? undefined,
avatarUrl: resolveUserAvatarPublicUrl(user),
role: "admin",
companyRole: membership.role,
setLastWorkspace: false,
},
});
const result = await controlPlaneStore.recordTaskManagerWorkspaceMembership(
{
clientId: client.id,
userId: user.id,
workspaceSlug,
role: "admin",
taskManager,
},
req.nodedcSession.user
);
publishControlPlaneEvent("admin.task-manager.workspace-membership.updated", [user.id]);
res.json({ ...scopeAdminMutationResult(req, result), taskManager, protected: true });
return;
}
const taskManager = await requestTaskManagerInternalJson("/api/internal/nodedc/workspace-memberships/remove/", {
method: "POST",
body: {
workspaceSlug,
email: user.email,
subject: user.authentikUserId ?? undefined,
},
});
const result = await controlPlaneStore.removeTaskManagerWorkspaceMembership(
{
clientId: client.id,
userId: user.id,
workspaceSlug,
},
req.nodedcSession.user
);
publishControlPlaneEvent("admin.task-manager.workspace-membership.updated", [user.id]);
res.json({ ...scopeAdminMutationResult(req, result), taskManager });
}));
app.post("/api/admin/clients", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
const result = await controlPlaneStore.createClient(req.body, req.nodedcSession.user);
res.status(201).json(result);
}));
app.patch("/api/admin/clients/:clientId", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
const result = await controlPlaneStore.updateClient(req.params.clientId, req.body, req.nodedcSession.user);
res.json(result);
}));
app.delete("/api/admin/clients/:clientId", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
const result = await controlPlaneStore.deleteClient(req.params.clientId, req.nodedcSession.user);
res.json(result);
}));
app.get("/api/admin/users", requireLauncherAdmin, (req, res) => {
const snapshot = scopeAdminSnapshot(req);
res.json({ users: snapshot.data.users, memberships: snapshot.data.memberships });
});
app.post("/api/admin/users", requireLauncherAdmin, asyncRoute(async (req, res) => {
if (!assertAdminCanManageClient(req, res, req.body?.clientId)) {
return;
}
const result = await controlPlaneStore.createUser(req.body, req.nodedcSession.user);
let provisioning = null;
if (req.body?.provisionAuth !== false) {
const provisionedUser = await authentikSyncClient.provisionUser({
data: result.data,
userId: result.user.id,
password: sanitizePassword(req.body?.password),
generatePassword: req.body?.generatePassword !== false,
});
const storeResult = await controlPlaneStore.markUserAuthentikProvisioned(result.user.id, provisionedUser, req.nodedcSession.user);
result.data = storeResult.data;
provisioning = toProvisioningResponse(provisionedUser);
}
publishControlPlaneEvent("admin.user.created", [result.user.id]);
res.status(201).json({ ...scopeAdminMutationResult(req, result), provisioning });
}));
app.patch("/api/admin/users/:userId/profile", requireLauncherAdmin, asyncRoute(async (req, res) => {
if (!assertAdminCanManageUser(req, res, req.params.userId)) {
return;
}
const result = await controlPlaneStore.updateUserProfile(req.params.userId, req.body, req.nodedcSession.user);
const syncResult = await syncUsersToAuthentik(result.data, [req.params.userId], req.nodedcSession.user);
publishControlPlaneEvent("admin.user.updated", syncResult.userIds);
res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data }));
}));
app.post("/api/admin/users/:userId/provision-authentik", requireLauncherAdmin, asyncRoute(async (req, res) => {
if (!assertAdminCanManageUser(req, res, req.params.userId)) {
return;
}
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
const provisionedUser = await authentikSyncClient.provisionUser({
data: snapshot.data,
userId: req.params.userId,
password: sanitizePassword(req.body?.password),
generatePassword: req.body?.generatePassword === true,
});
const result = await controlPlaneStore.markUserAuthentikProvisioned(req.params.userId, provisionedUser, req.nodedcSession.user);
publishControlPlaneEvent("admin.user.provisioned", [req.params.userId]);
res.json({ ...scopeAdminMutationResult(req, result), provisioning: toProvisioningResponse(provisionedUser) });
}));
app.patch("/api/admin/memberships/:membershipId", requireLauncherAdmin, asyncRoute(async (req, res) => {
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
const membership = snapshot.data.memberships.find((candidate) => candidate.id === req.params.membershipId);
if (!membership) {
res.status(404).json({ error: "membership_not_found" });
return;
}
if (!assertAdminCanManageMembership(req, res, membership)) {
return;
}
const result = await controlPlaneStore.updateMembership(req.params.membershipId, req.body, req.nodedcSession.user);
const syncResult = await syncUsersToAuthentik(result.data, [result.membership.userId], req.nodedcSession.user);
publishControlPlaneEvent("admin.membership.updated", syncResult.userIds);
res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data }));
}));
app.delete("/api/admin/memberships/:membershipId", requireLauncherAdmin, asyncRoute(async (req, res) => {
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
const membership = snapshot.data.memberships.find((candidate) => candidate.id === req.params.membershipId);
if (!membership) {
res.status(404).json({ error: "membership_not_found" });
return;
}
if (!assertAdminCanManageMembership(req, res, membership)) {
return;
}
const result = await controlPlaneStore.deleteMembership(req.params.membershipId, req.nodedcSession.user);
const syncResult = await syncUsersToAuthentik(result.data, [result.membership.userId], req.nodedcSession.user);
publishControlPlaneEvent("admin.membership.deleted", syncResult.userIds);
res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data }));
}));
app.post("/api/admin/invites", requireLauncherAdmin, asyncRoute(async (req, res) => {
if (!assertAdminCanManageClient(req, res, req.body?.clientId)) {
return;
}
const result = await controlPlaneStore.createInvite(req.body, req.nodedcSession.user);
publishControlPlaneEvent("admin.invite.created");
res.status(201).json(scopeAdminMutationResult(req, result));
}));
app.patch("/api/admin/invites/:inviteId", requireLauncherAdmin, asyncRoute(async (req, res) => {
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
const invite = snapshot.data.invites.find((candidate) => candidate.id === req.params.inviteId);
if (!invite) {
res.status(404).json({ error: "invite_not_found" });
return;
}
if (!assertAdminCanManageClient(req, res, invite.clientId)) {
return;
}
const result = await controlPlaneStore.updateInvite(req.params.inviteId, req.body, req.nodedcSession.user);
publishControlPlaneEvent("admin.invite.updated");
res.json(scopeAdminMutationResult(req, result));
}));
app.delete("/api/admin/invites/:inviteId", requireLauncherAdmin, asyncRoute(async (req, res) => {
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
const invite = snapshot.data.invites.find((candidate) => candidate.id === req.params.inviteId);
if (!invite) {
res.status(404).json({ error: "invite_not_found" });
return;
}
if (!assertAdminCanManageClient(req, res, invite.clientId)) {
return;
}
const result = await controlPlaneStore.deleteInvite(req.params.inviteId, req.nodedcSession.user);
publishControlPlaneEvent("admin.invite.deleted");
res.json(scopeAdminMutationResult(req, result));
}));
app.post("/api/admin/groups", requireLauncherAdmin, asyncRoute(async (req, res) => {
if (!assertAdminCanManageClient(req, res, req.body?.clientId)) {
return;
}
const result = await controlPlaneStore.createGroup(req.body, req.nodedcSession.user);
const syncResult = await syncUsersToAuthentik(result.data, result.group.memberIds, req.nodedcSession.user);
publishControlPlaneEvent("admin.group.created", syncResult.userIds);
res.status(201).json(scopeAdminMutationResult(req, { ...result, data: syncResult.data }));
}));
app.patch("/api/admin/groups/:groupId", requireLauncherAdmin, asyncRoute(async (req, res) => {
const beforeSnapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
const group = beforeSnapshot.data.groups.find((candidate) => candidate.id === req.params.groupId);
if (!group) {
res.status(404).json({ error: "group_not_found" });
return;
}
if (!assertAdminCanManageClient(req, res, group.clientId)) {
return;
}
const previousMemberIds = group.memberIds;
const result = await controlPlaneStore.updateGroup(req.params.groupId, req.body, req.nodedcSession.user);
const syncResult = await syncUsersToAuthentik(
result.data,
[...previousMemberIds, ...result.group.memberIds],
req.nodedcSession.user
);
publishControlPlaneEvent("admin.group.updated", syncResult.userIds);
res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data }));
}));
app.delete("/api/admin/groups/:groupId", requireLauncherAdmin, asyncRoute(async (req, res) => {
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
const group = snapshot.data.groups.find((candidate) => candidate.id === req.params.groupId);
if (!group) {
res.status(404).json({ error: "group_not_found" });
return;
}
if (!assertAdminCanManageClient(req, res, group.clientId)) {
return;
}
const result = await controlPlaneStore.deleteGroup(req.params.groupId, req.nodedcSession.user);
const syncResult = await syncUsersToAuthentik(result.data, result.group.memberIds, req.nodedcSession.user);
publishControlPlaneEvent("admin.group.deleted", syncResult.userIds);
res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data }));
}));
app.post("/api/admin/services", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
const result = await controlPlaneStore.createService(req.body, req.nodedcSession.user);
publishControlPlaneEvent("admin.service.created");
res.status(201).json(result);
}));
app.patch("/api/admin/services/reorder", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
const result = await controlPlaneStore.reorderServices(req.body, req.nodedcSession.user);
publishControlPlaneEvent("admin.service.reordered");
res.json(result);
}));
app.patch("/api/admin/services/:serviceId", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
const result = await controlPlaneStore.updateService(req.params.serviceId, req.body, req.nodedcSession.user);
publishControlPlaneEvent("admin.service.updated");
res.json(result);
}));
app.delete("/api/admin/services/:serviceId", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
const result = await controlPlaneStore.deleteService(req.params.serviceId, req.nodedcSession.user);
publishControlPlaneEvent("admin.service.deleted");
res.json(result);
}));
app.post("/api/admin/access/grants", requireLauncherAdmin, asyncRoute(async (req, res) => {
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
if (!assertAdminCanManageGrantTarget(req, res, snapshot.data, req.body?.targetType, req.body?.targetId)) {
return;
}
const result = await controlPlaneStore.upsertGrant(req.body, req.nodedcSession.user);
const syncResult = await syncUsersToAuthentik(
result.data,
resolveGrantTargetUserIds(result.data, result.grant.targetType, result.grant.targetId),
req.nodedcSession.user
);
publishControlPlaneEvent("admin.access.grant.updated", syncResult.userIds);
res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data }));
}));
app.post("/api/admin/access/exceptions", requireLauncherAdmin, asyncRoute(async (req, res) => {
if (!assertAdminCanManageUser(req, res, req.body?.userId)) {
return;
}
const result = await controlPlaneStore.upsertException(req.body, req.nodedcSession.user);
const syncResult = await syncUsersToAuthentik(result.data, [result.exception.userId], req.nodedcSession.user);
publishControlPlaneEvent("admin.access.exception.updated", syncResult.userIds);
res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data }));
}));
app.post("/api/admin/access/user-service", requireLauncherAdmin, asyncRoute(async (req, res) => {
if (!assertAdminCanManageUser(req, res, req.body?.userId)) {
return;
}
const result = await controlPlaneStore.setUserServiceAccess(req.body, req.nodedcSession.user);
const syncResult = await syncUsersToAuthentik(result.data, [req.body?.userId], req.nodedcSession.user);
publishControlPlaneEvent("admin.access.user-service.updated", syncResult.userIds);
res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data }));
}));
app.post("/api/admin/sync/:syncId/retry", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
const result = await controlPlaneStore.retrySync(req.params.syncId, req.nodedcSession.user);
publishControlPlaneEvent("admin.sync.retry");
res.json(result);
}));
app.get("/api/admin/sync/authentik/plan", requireLauncherAdmin, requireRootLauncherAdmin, (_req, res) => {
res.json(controlPlaneStore.buildAuthentikSyncPlan());
});
app.post("/api/storage/upload", asyncRoute(async (req, res) => {
const result = await saveUploadedFile(req.body);
res.json(result);
}));
app.post("/api/storage/data", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
await saveLauncherData(req.body);
publishControlPlaneEvent("storage.data.updated");
res.json({ ok: true, url: "/storage/launcher-data.json" });
}));
const vite = await createViteServer({
root: projectRoot,
appType: "spa",
server: {
middlewareMode: true,
hmr: { server: httpServer },
},
});
app.use(vite.middlewares);
app.use((error, _req, res, _next) => {
vite.ssrFixStacktrace(error);
const message = error instanceof Error ? error.message : "Unexpected server error";
res.status(500).json({ error: message });
});
httpServer.listen(config.port, "0.0.0.0", () => {
console.log(`NODE.DC launcher BFF listening on http://0.0.0.0:${config.port}`);
});
function readConfig() {
const issuer = process.env.LAUNCHER_OIDC_ISSUER ?? "";
const clientId = process.env.LAUNCHER_OIDC_CLIENT_ID ?? "";
const clientSecret = process.env.LAUNCHER_OIDC_CLIENT_SECRET ?? "";
const launcherDomain = process.env.LAUNCHER_DOMAIN ?? "localhost:5173";
const appBaseUrl = process.env.LAUNCHER_BASE_URL ?? `http://${launcherDomain}`;
return {
port: Number(process.env.PORT ?? "5173"),
issuer,
clientId,
clientSecret,
redirectUri: process.env.LAUNCHER_OIDC_REDIRECT_URI ?? `${appBaseUrl}/auth/callback`,
appBaseUrl,
scope: process.env.LAUNCHER_OIDC_SCOPE ?? "openid email profile groups offline_access",
cookieDomain: process.env.LAUNCHER_COOKIE_DOMAIN || undefined,
cookieSecure: process.env.COOKIE_SECURE === "true",
oidcConfigured: Boolean(issuer && clientId && clientSecret),
authentikBaseUrl:
process.env.NODEDC_AUTHENTIK_BASE_URL ??
process.env.AUTHENTIK_BASE_URL ??
(process.env.AUTH_DOMAIN ? `http://${process.env.AUTH_DOMAIN}` : ""),
authentikApiToken:
process.env.NODEDC_AUTHENTIK_SERVICE_TOKEN ??
process.env.AUTHENTIK_SERVICE_TOKEN ??
process.env.AUTHENTIK_BOOTSTRAP_TOKEN ??
"",
internalAccessToken:
process.env.NODEDC_INTERNAL_ACCESS_TOKEN ??
process.env.NODEDC_PLATFORM_SERVICE_TOKEN ??
process.env.PLANE_OIDC_CLIENT_SECRET ??
"",
taskLogoutUrl:
process.env.TASK_LOGOUT_URL ??
`${(process.env.TASK_BASE_URL ?? `http://${process.env.TASK_DOMAIN ?? "task.local.nodedc"}`).replace(/\/$/, "")}/logout`,
taskInternalLogoutUrl:
process.env.TASK_INTERNAL_LOGOUT_URL ??
`${(process.env.TASK_BASE_URL ?? `http://${process.env.TASK_DOMAIN ?? "task.local.nodedc"}`).replace(/\/$/, "")}/api/internal/nodedc/logout/`,
};
}
function ensureOidcConfigured() {
if (!config.oidcConfigured) {
throw new Error("Launcher OIDC is not configured. Set LAUNCHER_OIDC_ISSUER, LAUNCHER_OIDC_CLIENT_ID and LAUNCHER_OIDC_CLIENT_SECRET.");
}
}
async function getOidcDiscovery() {
if (discoveryCache && discoveryCache.expiresAt > Date.now()) {
return discoveryCache.discovery;
}
const discoveryUrl = new URL("./.well-known/openid-configuration", ensureTrailingSlash(config.issuer));
const response = await fetch(discoveryUrl, { headers: { Accept: "application/json" } });
if (!response.ok) {
throw new Error(`Unable to load OIDC discovery from ${discoveryUrl}: HTTP ${response.status}`);
}
const discovery = await response.json();
discoveryCache = { discovery, expiresAt: Date.now() + 5 * 60 * 1000 };
return discovery;
}
function buildOidcAuthorizationUrl(discovery, { state, prompt, pendingLogin }) {
const codeChallenge = createHash("sha256").update(pendingLogin.codeVerifier).digest("base64url");
const authorizationUrl = new URL(discovery.authorization_endpoint);
authorizationUrl.searchParams.set("response_type", "code");
authorizationUrl.searchParams.set("client_id", config.clientId);
authorizationUrl.searchParams.set("redirect_uri", config.redirectUri);
authorizationUrl.searchParams.set("scope", config.scope);
authorizationUrl.searchParams.set("state", state);
authorizationUrl.searchParams.set("nonce", pendingLogin.nonce);
authorizationUrl.searchParams.set("code_challenge", codeChallenge);
authorizationUrl.searchParams.set("code_challenge_method", "S256");
if (prompt) {
authorizationUrl.searchParams.set("prompt", prompt);
}
if (prompt === "login") {
authorizationUrl.searchParams.set("max_age", "0");
}
return authorizationUrl;
}
async function exchangeCodeForTokens(discovery, code, codeVerifier) {
const body = new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: config.redirectUri,
code_verifier: codeVerifier,
});
const response = await fetch(discovery.token_endpoint, {
method: "POST",
headers: {
Authorization: `Basic ${Buffer.from(`${config.clientId}:${config.clientSecret}`).toString("base64")}`,
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`OIDC token exchange failed: HTTP ${response.status} ${errorText}`);
}
const tokenSet = await response.json();
if (!tokenSet.id_token) {
throw new Error("OIDC token response does not contain id_token");
}
return tokenSet;
}
async function verifyIdToken(discovery, idToken, nonce) {
if (!jwksCache || jwksCache.uri !== discovery.jwks_uri) {
jwksCache = {
uri: discovery.jwks_uri,
jwks: createRemoteJWKSet(new URL(discovery.jwks_uri)),
};
}
const { payload } = await jwtVerify(idToken, jwksCache.jwks, {
issuer: discovery.issuer ?? config.issuer,
audience: config.clientId,
});
if (payload.nonce !== nonce) {
throw new Error("OIDC nonce validation failed");
}
return payload;
}
function normalizeUser(claims) {
const groups = normalizeGroups(claims.groups);
const email = typeof claims.email === "string" ? claims.email : "";
const avatarUrl = firstStringClaim(claims.picture, claims.avatar_url, claims.avatar);
const name =
typeof claims.name === "string" && claims.name
? claims.name
: typeof claims.preferred_username === "string" && claims.preferred_username
? claims.preferred_username
: email || String(claims.sub);
return {
sub: String(claims.sub),
email,
name,
preferredUsername: typeof claims.preferred_username === "string" ? claims.preferred_username : null,
avatarUrl,
groups,
};
}
function normalizeControlPlaneSessionUser(user, groups) {
return {
sub: String(user.authentikUserId || user.id),
email: user.email,
name: user.name,
preferredUsername: user.email,
avatarUrl: user.avatarUrl ?? null,
groups,
};
}
function createLauncherSession(res, user, tokenSet = {}) {
const sessionId = randomBase64Url(48);
const session = {
id: sessionId,
user,
tokenSet: {
idToken: tokenSet.idToken ?? null,
accessToken: tokenSet.accessToken ?? null,
expiresAt: tokenSet.expiresAt ?? null,
},
createdAt: Date.now(),
expiresAt: Date.now() + sessionTtlMs,
};
pruneExpiredSessions();
sessions.set(sessionId, session);
res.cookie(sessionCookieName, sessionId, cookieOptions(sessionTtlMs));
return session;
}
function firstStringClaim(...values) {
for (const value of values) {
if (typeof value === "string" && value) return value;
}
return null;
}
function sanitizePassword(value) {
return typeof value === "string" && value.length >= 8 ? value : null;
}
function sanitizeNewPassword(value) {
if (typeof value !== "string" || value.length < 8) {
throw new Error("Новый пароль должен быть не короче 8 символов");
}
return value;
}
function sanitizeInviteRegistrationPayload(payload) {
const email = typeof payload?.email === "string" ? payload.email.trim().toLowerCase() : "";
const name = typeof payload?.name === "string" ? payload.name.trim() : "";
if (!isValidInviteRegistrationEmail(email)) {
throw new Error("Введите почту, на которую выписан инвайт");
}
if (!name) {
throw new Error("Введите имя");
}
return {
email,
name: name.slice(0, 120),
password: sanitizeNewPassword(payload?.password),
};
}
function isValidInviteRegistrationEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
function sendInviteApiError(res, error) {
const message = error instanceof Error ? error.message : "Инвайт недоступен";
const status =
message.includes("не найден")
? 404
: message.includes("другую почту") || message.includes("нет активного инвайта")
? 403
: message.includes("истёк") || message.includes("отозван")
? 410
: 400;
res.status(status).json({ error: message });
}
function sanitizeSelfProfilePatch(payload) {
return {
name: payload?.name,
email: payload?.email,
phone: payload?.phone,
position: payload?.position,
avatarUrl: payload?.avatarUrl,
};
}
function toProvisioningResponse(provisionedUser) {
return {
authentikUserId: provisionedUser.authentikUserId,
email: provisionedUser.email,
name: provisionedUser.name,
groups: provisionedUser.groups,
created: provisionedUser.created,
temporaryPassword: provisionedUser.temporaryPassword,
};
}
async function syncUsersToAuthentik(data, userIds, identity) {
let latestData = data;
const uniqueUserIds = [...new Set(userIds.filter((userId) => typeof userId === "string" && userId))];
for (const userId of uniqueUserIds) {
if (!latestData.users.some((user) => user.id === userId)) {
continue;
}
const provisionedUser = await authentikSyncClient.provisionUser({ data: latestData, userId });
const result = await controlPlaneStore.markUserAuthentikProvisioned(userId, provisionedUser, identity);
latestData = result.data;
}
return { data: latestData, userIds: uniqueUserIds };
}
function resolveGrantTargetUserIds(data, targetType, targetId) {
if (targetType === "user") {
return [targetId];
}
if (targetType === "group") {
return data.groups.find((group) => group.id === targetId)?.memberIds ?? [];
}
if (targetType === "client") {
return data.memberships.filter((membership) => membership.clientId === targetId).map((membership) => membership.userId);
}
return [];
}
function publishControlPlaneEvent(action, affectedUserIds = []) {
publishRuntimeEvent({
type: "control-plane.updated",
action,
affectedUserIds: [...new Set(affectedUserIds.filter((userId) => typeof userId === "string" && userId))],
emittedAt: new Date().toISOString(),
});
}
function publishRuntimeEvent(payload) {
const message = `event: nodedc-runtime\ndata: ${JSON.stringify(payload)}\n\n`;
for (const client of runtimeEventClients) {
try {
client.res.write(message);
} catch {
runtimeEventClients.delete(client);
}
}
}
function normalizeGroups(groupsClaim) {
if (Array.isArray(groupsClaim)) {
return [...new Set(groupsClaim.filter((group) => typeof group === "string"))];
}
if (typeof groupsClaim === "string" && groupsClaim) {
return [groupsClaim];
}
return [];
}
function getRuntimeSessionContext(session) {
const fallback = {
user: session.user,
groups: session.user.groups,
};
try {
const snapshot = controlPlaneStore.getSnapshot(session.user);
if (snapshot.actor.source !== "launcher") {
return fallback;
}
const user = snapshot.data.users.find((candidate) => candidate.id === snapshot.actor.id);
if (!user) {
return fallback;
}
const groups = resolveRequiredGroups(snapshot.data, user);
return {
groups,
user: {
...session.user,
email: user.email,
name: user.name,
avatarUrl: user.avatarUrl ?? session.user.avatarUrl,
groups,
},
};
} catch (error) {
console.warn(error instanceof Error ? error.message : "Не удалось рассчитать runtime контекст Launcher");
return fallback;
}
}
function getAppsForSession(session) {
return getAppsForUser(getRuntimeSessionContext(session).groups);
}
function getAppsForUser(userGroups) {
const groupSet = new Set(userGroups);
const catalog = getAppCatalog();
return catalog.map((app) => {
const matchedGroups = app.requiredGroups.filter((group) => groupSet.has(group));
const isSuperAdmin = groupSet.has("nodedc:superadmin");
const isPublic = app.requiredGroups.length === 0;
const hasAccess = isSuperAdmin || isPublic || matchedGroups.length > 0;
return {
...app,
matchedGroups: isSuperAdmin ? ["nodedc:superadmin", ...matchedGroups] : matchedGroups,
hasAccess,
accessReason: hasAccess ? "Доступ подтверждён" : "Нет доступа",
};
});
}
function getAppCatalog() {
const launcherData = readLauncherData();
const services = Array.isArray(launcherData?.services) ? launcherData.services : [];
const serviceCatalog = services.map((service) => {
const specialGroups = specialRequiredGroups(service.slug);
const requiredGroups = specialGroups.length
? specialGroups
: service.authentikGroupName
? [service.authentikGroupName]
: [];
return {
id: service.id,
slug: service.slug,
title: service.title,
description: service.description,
url: getServiceUrl(service),
openUrl: getServiceUrl(service),
status: service.status ?? "disabled",
provider: "authentik",
requiredGroups,
media: {
icon: service.iconUrl ?? null,
coverImage: service.coverImageUrl ?? null,
accentColor: service.accentColor ?? null,
},
};
});
return [
{
id: "launcher",
slug: "launcher",
title: "NODE.DC Launcher",
description: "Единая точка входа в приложения NODE.DC.",
url: config.appBaseUrl,
openUrl: config.appBaseUrl,
status: "active",
provider: "authentik",
requiredGroups: ["nodedc:launcher:admin", "nodedc:launcher:user"],
},
...serviceCatalog.filter((service) => service.slug !== "launcher"),
];
}
function specialRequiredGroups(slug) {
if (slug === "launcher") return ["nodedc:launcher:admin", "nodedc:launcher:user"];
if (slug === "task-manager") return ["nodedc:taskmanager:admin", "nodedc:taskmanager:user"];
return [];
}
function getServiceUrl(service) {
if (service.slug === "task-manager") {
return `/api/services/${encodeURIComponent(service.slug)}/launch`;
}
return service.launchUrl || service.url || "#";
}
function getTaskBaseUrl() {
const taskBaseUrl = process.env.TASK_BASE_URL ?? `http://${process.env.TASK_DOMAIN ?? "task.local.nodedc"}`;
return taskBaseUrl.replace(/\/$/, "");
}
async function requestTaskManagerInternalJson(pathname, init = {}) {
if (!config.internalAccessToken) {
throw new Error("NODE.DC internal access token is not configured");
}
const targetUrl = new URL(pathname, `${getTaskBaseUrl()}/`);
const hasBody = typeof init.body === "object" && init.body !== null;
const response = await fetch(targetUrl, {
method: init.method ?? (hasBody ? "POST" : "GET"),
headers: {
Accept: "application/json",
Authorization: `Bearer ${config.internalAccessToken}`,
...(hasBody ? { "Content-Type": "application/json" } : {}),
...(init.headers ?? {}),
},
body: hasBody ? JSON.stringify(init.body) : undefined,
});
const text = await response.text();
const payload = text ? parseJsonResponse(text, targetUrl.toString()) : {};
if (!response.ok) {
const error = typeof payload?.error === "string" ? payload.error : `Task Manager internal API failed: ${response.status}`;
throw new Error(error);
}
return payload;
}
function parseJsonResponse(text, url) {
try {
return JSON.parse(text);
} catch {
throw new Error(`Task Manager internal API returned non-JSON response: ${url}`);
}
}
function normalizeOptionalText(value) {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function normalizeTaskManagerRole(value) {
return value === "guest" || value === "admin" || value === "member" ? value : null;
}
function resolveTaskManagerRoleForMembership(role) {
return role === "client_owner" || role === "client_admin" ? "admin" : "member";
}
function createServiceHandoff(serviceSlug, user) {
pruneExpiredServiceHandoffs();
const token = randomBase64Url(48);
serviceHandoffs.set(token, {
serviceSlug,
user: {
sub: user.sub,
email: user.email,
name: user.name,
preferredUsername: user.preferredUsername ?? user.email,
avatarUrl: user.avatarUrl ?? null,
groups: user.groups ?? [],
},
expiresAt: Date.now() + serviceHandoffTtlMs,
});
return token;
}
function consumeServiceHandoff(token, serviceSlug) {
pruneExpiredServiceHandoffs();
if (!token) {
return null;
}
const handoff = serviceHandoffs.get(token);
serviceHandoffs.delete(token);
if (!handoff || handoff.expiresAt < Date.now() || handoff.serviceSlug !== serviceSlug) {
return null;
}
return handoff;
}
function pruneExpiredServiceHandoffs() {
const now = Date.now();
for (const [token, handoff] of serviceHandoffs.entries()) {
if (!handoff || handoff.expiresAt < now) {
serviceHandoffs.delete(token);
}
}
}
function resolveTaskManagerWorkspacePolicy(data, groups, hasTaskManagerAccess) {
const mode = data.settings?.taskManager?.workspaceCreationPolicy ?? "any_authorized_user";
const groupSet = new Set(groups);
const isSuperAdmin = groupSet.has("nodedc:superadmin");
const isTaskManagerAdmin = groupSet.has("nodedc:taskmanager:admin");
if (!hasTaskManagerAccess) {
return {
mode,
canCreateWorkspace: false,
reason: "Нет доступа к Operational Core.",
};
}
if (mode === "disabled") {
return {
mode,
canCreateWorkspace: false,
reason: "Создание рабочих пространств отключено на уровне платформы.",
};
}
if (mode === "task_admins_only" && !isSuperAdmin && !isTaskManagerAdmin) {
return {
mode,
canCreateWorkspace: false,
reason: "Создание рабочих пространств доступно только администраторам Operational Core.",
};
}
return {
mode,
canCreateWorkspace: true,
reason: "Создание рабочих пространств разрешено платформенной policy.",
};
}
function getFrontchannelLogoutUrls() {
const urls = [config.taskLogoutUrl];
const launcherData = readLauncherData();
const services = Array.isArray(launcherData?.services) ? launcherData.services : [];
for (const service of services) {
if (typeof service.logoutUrl === "string" && service.logoutUrl.trim()) {
urls.push(service.logoutUrl.trim());
}
}
return [...new Set(urls.map(normalizeLogoutUrl).filter(Boolean))];
}
async function notifyTaskSessionLogout(session) {
if (!session?.user || !config.internalAccessToken || !config.taskInternalLogoutUrl) {
return;
}
const runtimeContext = getRuntimeSessionContext(session);
const user = runtimeContext.user ?? session.user;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 1500);
try {
const response = await fetch(config.taskInternalLogoutUrl, {
method: "POST",
headers: {
"Accept": "application/json",
"Authorization": `Bearer ${config.internalAccessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
source: "launcher-global-logout",
subject: session.user.sub,
email: user.email || session.user.email || null,
}),
signal: controller.signal,
});
if (!response.ok) {
console.warn(`Task internal logout returned ${response.status}`);
}
} catch (error) {
if (error?.name !== "AbortError") {
console.warn(error instanceof Error ? error.message : "Task internal logout failed");
}
} finally {
clearTimeout(timeout);
}
}
function normalizeLogoutUrl(value) {
try {
const url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
return url.toString();
} catch {
return null;
}
}
function renderGlobalLogoutPage(frontchannelLogoutUrls, finalRedirectUrl) {
const logoutUrlsJson = JSON.stringify(frontchannelLogoutUrls);
const redirectUrlJson = JSON.stringify(finalRedirectUrl);
return `<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>NODE.DC</title>
<style>
html,body{height:100%;margin:0;background:#0b0f0e;color:#0b0f0e}
</style>
</head>
<body aria-busy="true">
<script>
const eventPayload = {
type: "nodedc:session:logout",
id: globalThis.crypto?.randomUUID ? globalThis.crypto.randomUUID() : String(Date.now()) + "-" + Math.random().toString(36).slice(2),
source: "launcher-global-logout",
createdAt: Date.now()
};
try {
const channel = new BroadcastChannel("nodedc-platform-session");
channel.postMessage(eventPayload);
channel.close();
} catch {}
try {
localStorage.setItem("nodedc:platform-session-event", JSON.stringify(eventPayload));
} catch {}
const logoutUrls = ${logoutUrlsJson};
const finalRedirectUrl = ${redirectUrlJson};
for (const logoutUrl of logoutUrls) {
fetch(logoutUrl, { mode: "no-cors", credentials: "include", keepalive: true }).catch(() => undefined);
const image = new Image();
image.referrerPolicy = "no-referrer";
image.src = logoutUrl;
}
window.setTimeout(() => window.location.replace(finalRedirectUrl), 50);
</script>
</body>
</html>`;
}
function renderSessionSyncBridgePage(allowedOrigins) {
const allowedOriginsJson = JSON.stringify(allowedOrigins);
return `<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>NODE.DC session sync</title>
</head>
<body>
<script>
const allowedOrigins = new Set(${allowedOriginsJson});
const parentOrigin = (() => {
try {
return new URL(document.referrer).origin;
} catch {
return "";
}
})();
const channelName = "nodedc-platform-session";
const storageKey = "nodedc:platform-session-event";
let lastEventId = null;
function isAllowedOrigin(origin) {
return allowedOrigins.has(origin);
}
function isLogoutEvent(payload) {
return payload && payload.type === "nodedc:session:logout" && typeof payload.id === "string";
}
function forwardToParent(payload) {
if (!isLogoutEvent(payload) || payload.id === lastEventId || !isAllowedOrigin(parentOrigin)) return;
lastEventId = payload.id;
window.parent.postMessage(payload, parentOrigin);
}
function publish(payload) {
if (!isLogoutEvent(payload)) return;
try {
const channel = new BroadcastChannel(channelName);
channel.postMessage(payload);
channel.close();
} catch {}
try {
localStorage.setItem(storageKey, JSON.stringify(payload));
} catch {}
}
window.addEventListener("message", (event) => {
if (!isAllowedOrigin(event.origin) || !isLogoutEvent(event.data)) return;
publish(event.data);
});
try {
const channel = new BroadcastChannel(channelName);
channel.addEventListener("message", (event) => forwardToParent(event.data));
} catch {}
window.addEventListener("storage", (event) => {
if (event.key !== storageKey || !event.newValue) return;
try {
forwardToParent(JSON.parse(event.newValue));
} catch {}
});
</script>
</body>
</html>`;
}
function getSessionSyncAllowedOrigins() {
const origins = new Set([new URL(config.appBaseUrl).origin]);
for (const logoutUrl of getFrontchannelLogoutUrls()) {
try {
origins.add(new URL(logoutUrl).origin);
} catch {
void 0;
}
}
return [...origins];
}
function readLauncherData() {
const dataPath = join(projectRoot, "public", "storage", "launcher-data.json");
try {
if (!existsSync(dataPath)) return null;
return JSON.parse(readFileSync(dataPath, "utf8"));
} catch {
return null;
}
}
function buildPublicBrandResponse(settings) {
const configuredLogoLinkUrl = settings?.brand?.logoLinkUrl || "/";
return {
logoLinkUrl: resolvePublicUrl(configuredLogoLinkUrl, config.appBaseUrl),
};
}
function resolvePublicUrl(value, baseUrl) {
try {
return new URL(value || "/", baseUrl).toString();
} catch {
return new URL("/", baseUrl).toString();
}
}
function resolveUserAvatarPublicUrl(user) {
if (!user?.avatarUrl) return null;
return resolvePublicUrl(user.avatarUrl, config.appBaseUrl);
}
async function saveUploadedFile(payload) {
if (!isUploadPayload(payload)) {
throw new Error("Некорректный payload загрузки");
}
const match = /^data:([^;,]+)?(?:;[^,]*)?;base64,(.*)$/s.exec(payload.dataUrl);
if (!match) {
throw new Error("Файл должен прийти data-url с base64");
}
const mimeType = payload.mimeType || match[1] || "application/octet-stream";
const storedName = buildStoredFileName(payload.fileName, mimeType);
const fileBuffer = Buffer.from(match[2], "base64");
await Promise.all(
getWritableStorageRoots().map(async (storageRoot) => {
const uploadDir = join(storageRoot, "uploads");
await mkdir(uploadDir, { recursive: true });
await writeFile(join(uploadDir, storedName), fileBuffer);
})
);
return {
ok: true,
url: `/storage/uploads/${storedName}`,
fileName: storedName,
originalFileName: payload.fileName,
mimeType,
};
}
async function saveLauncherData(payload) {
await controlPlaneStore.writeData(payload);
}
function getWritableStorageRoots() {
const roots = [join(projectRoot, "public", "storage")];
const distRoot = join(projectRoot, "dist");
if (existsSync(distRoot)) {
roots.push(join(distRoot, "storage"));
}
return roots;
}
function buildStoredFileName(fileName, mimeType) {
const extension = extname(fileName) || extensionFromMimeType(mimeType);
const rawBase = fileName.slice(0, extension ? -extension.length : undefined);
const safeBase =
rawBase
.normalize("NFKD")
.replace(/[^\w.-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80) || "upload";
return `${Date.now()}-${randomUUID().slice(0, 8)}-${safeBase}${extension.toLowerCase()}`;
}
function extensionFromMimeType(mimeType) {
if (mimeType === "image/jpeg") return ".jpg";
if (mimeType === "image/png") return ".png";
if (mimeType === "image/gif") return ".gif";
if (mimeType === "image/webp") return ".webp";
if (mimeType === "video/mp4") return ".mp4";
if (mimeType === "video/webm") return ".webm";
if (mimeType === "video/quicktime") return ".mov";
return "";
}
function isUploadPayload(payload) {
return Boolean(
payload &&
typeof payload === "object" &&
typeof payload.fileName === "string" &&
typeof payload.mimeType === "string" &&
typeof payload.dataUrl === "string"
);
}
function getCurrentSession(req) {
const sessionId = parseCookies(req.headers.cookie)[sessionCookieName];
if (!sessionId) return null;
const session = sessions.get(sessionId);
if (!session || session.expiresAt < Date.now()) {
sessions.delete(sessionId);
return null;
}
return session;
}
function pruneExpiredSessions() {
for (const [sessionId, session] of sessions) {
if (session.expiresAt < Date.now()) {
sessions.delete(sessionId);
}
}
}
function pruneExpiredState() {
for (const [state, pendingLogin] of pendingLogins) {
if (pendingLogin.expiresAt < Date.now()) {
pendingLogins.delete(state);
}
}
}
function getValidOidcCookieStates(req) {
const rawValue = parseCookies(req.headers.cookie)[oidcStateCookieName];
if (!rawValue) return [];
const seen = new Set();
return rawValue
.split(".")
.filter((state) => /^[A-Za-z0-9_-]{32,256}$/.test(state))
.filter((state) => {
if (seen.has(state)) return false;
seen.add(state);
return true;
})
.filter((state) => {
const pendingLogin = pendingLogins.get(state);
return Boolean(pendingLogin && pendingLogin.expiresAt >= Date.now());
});
}
function setOidcStateCookie(res, states) {
if (!states.length) {
res.clearCookie(oidcStateCookieName, clearCookieOptions());
return;
}
res.cookie(oidcStateCookieName, states.join("."), cookieOptions(pendingLoginTtlMs));
}
function parseCookies(cookieHeader) {
if (!cookieHeader) return {};
return Object.fromEntries(
cookieHeader.split(";").flatMap((part) => {
const separatorIndex = part.indexOf("=");
if (separatorIndex === -1) return [];
const key = part.slice(0, separatorIndex).trim();
const value = part.slice(separatorIndex + 1).trim();
return [[key, decodeURIComponent(value)]];
})
);
}
function requireLauncherAdmin(req, res, next) {
const session = getCurrentSession(req);
if (!session) {
res.status(401).json({ authenticated: false, loginUrl: "/auth/login" });
return;
}
const runtimeContext = getRuntimeSessionContext(session);
const adminScope = resolveAdminScope(runtimeContext.user, runtimeContext.groups);
if (!adminScope.isRoot && adminScope.clientIds.size === 0) {
res.status(403).json({ error: "Недостаточно прав Launcher admin" });
return;
}
req.nodedcSession = { ...session, user: runtimeContext.user };
req.nodedcAdminScope = adminScope;
next();
}
function requireRootLauncherAdmin(req, res, next) {
if (!req.nodedcAdminScope?.isRoot) {
res.status(403).json({ error: "Действие доступно только суперпользователю NODE.DC" });
return;
}
next();
}
function requireSession(req, res, next) {
const session = getCurrentSession(req);
if (!session) {
res.status(401).json({ authenticated: false, loginUrl: "/auth/login" });
return;
}
const runtimeContext = getRuntimeSessionContext(session);
req.nodedcSession = { ...session, user: runtimeContext.user };
next();
}
function isInternalRequestAuthorized(req) {
if (!config.internalAccessToken) {
return false;
}
const authorization = typeof req.headers.authorization === "string" ? req.headers.authorization : "";
const bearerToken = authorization.match(/^Bearer\s+(.+)$/i)?.[1] ?? "";
const headerToken = typeof req.headers["x-nodedc-internal-token"] === "string" ? req.headers["x-nodedc-internal-token"] : "";
const requestToken = bearerToken || headerToken;
return safeTokenEquals(requestToken, config.internalAccessToken);
}
function safeTokenEquals(actual, expected) {
if (!actual || !expected) {
return false;
}
const actualBuffer = Buffer.from(String(actual));
const expectedBuffer = Buffer.from(String(expected));
return actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer);
}
function findInternalAccessUser(data, payload) {
const subject = typeof payload?.subject === "string" ? payload.subject : "";
const email = typeof payload?.email === "string" ? payload.email.toLowerCase() : "";
const userId = typeof payload?.userId === "string" ? payload.userId : "";
return (
data.users.find((user) => userId && user.id === userId) ??
data.users.find((user) => subject && user.authentikUserId === subject) ??
data.users.find((user) => email && user.email.toLowerCase() === email) ??
null
);
}
function sanitizeServiceSlug(value) {
return typeof value === "string" && value ? value : "task-manager";
}
function getLauncherProfileContext(session) {
const snapshot = controlPlaneStore.getSnapshot(session.user);
if (snapshot.actor.source !== "launcher") {
throw new Error("Профиль пользователя не найден в Launcher control-plane");
}
return {
actor: snapshot.actor,
data: snapshot.data,
};
}
function findLauncherUser(data, userId) {
const user = data.users.find((candidate) => candidate.id === userId);
if (!user) {
throw new Error(`Unknown Launcher user: ${userId}`);
}
return user;
}
function isLauncherAdmin(groups) {
return groups.includes("nodedc:superadmin") || groups.includes("nodedc:launcher:admin");
}
const clientAdminMembershipRoles = new Set(["client_owner", "client_admin"]);
const protectedLauncherUserIds = new Set(["user_root"]);
function resolveAdminScope(identity, groups) {
const snapshot = controlPlaneStore.getSnapshot(identity);
const actorId = snapshot.actor.source === "launcher" ? snapshot.actor.id : null;
const isRoot = isLauncherAdmin(groups);
const clientIds = new Set(
isRoot
? snapshot.data.clients.map((client) => client.id)
: snapshot.data.memberships
.filter((membership) => membership.userId === actorId && membership.status === "active" && clientAdminMembershipRoles.has(membership.role))
.map((membership) => membership.clientId)
);
return {
actorId,
clientIds,
isRoot,
snapshot,
};
}
function canAdminManageClient(req, clientId) {
return Boolean(req.nodedcAdminScope?.isRoot || req.nodedcAdminScope?.clientIds.has(clientId));
}
function canAdminManageUser(req, userId) {
if (protectedLauncherUserIds.has(userId)) {
return false;
}
if (req.nodedcAdminScope?.isRoot) {
return true;
}
return req.nodedcAdminScope?.snapshot.data.memberships.some(
(membership) => membership.userId === userId && req.nodedcAdminScope.clientIds.has(membership.clientId)
);
}
function assertAdminCanManageClient(req, res, clientId) {
if (canAdminManageClient(req, clientId)) {
return true;
}
res.status(403).json({ error: "Недостаточно прав для управления этим клиентом" });
return false;
}
function assertAdminCanManageUser(req, res, userId) {
if (canAdminManageUser(req, userId)) {
return true;
}
res.status(403).json({ error: "Недостаточно прав для управления этим пользователем" });
return false;
}
function assertAdminCanManageMembership(req, res, membership) {
if (!assertAdminCanManageClient(req, res, membership.clientId)) {
return false;
}
return assertAdminCanManageUser(req, res, membership.userId);
}
function assertAdminCanManageGrantTarget(req, res, data, targetType, targetId) {
if (req.nodedcAdminScope?.isRoot) {
return true;
}
if (targetType === "client") {
return assertAdminCanManageClient(req, res, targetId);
}
if (targetType === "group") {
const group = data.groups.find((candidate) => candidate.id === targetId);
if (!group) {
res.status(404).json({ error: "group_not_found" });
return false;
}
return assertAdminCanManageClient(req, res, group.clientId);
}
if (targetType === "user") {
return assertAdminCanManageUser(req, res, targetId);
}
res.status(403).json({ error: "Недостаточно прав для управления этим доступом" });
return false;
}
function scopeAdminSnapshot(req, snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user)) {
return {
...snapshot,
data: scopeControlPlaneData(snapshot.data, req.nodedcAdminScope),
};
}
function scopeAdminMutationResult(req, result) {
if (!result?.data) {
return result;
}
return {
...result,
data: scopeControlPlaneData(result.data, req.nodedcAdminScope),
};
}
function scopeControlPlaneData(data, scope) {
if (!scope || scope.isRoot) {
return data;
}
const clientIds = scope.clientIds;
const memberships = data.memberships.filter((membership) => clientIds.has(membership.clientId));
const userIds = new Set(memberships.map((membership) => membership.userId));
if (scope.actorId) {
userIds.add(scope.actorId);
}
const groupIds = new Set(data.groups.filter((group) => clientIds.has(group.clientId)).map((group) => group.id));
return {
...data,
clients: data.clients.filter((client) => clientIds.has(client.id)),
users: data.users.filter((user) => userIds.has(user.id)),
memberships,
groups: data.groups.filter((group) => clientIds.has(group.clientId)),
invites: data.invites.filter((invite) => clientIds.has(invite.clientId)),
grants: data.grants.filter((grant) => {
if (grant.targetType === "client") return clientIds.has(grant.targetId);
if (grant.targetType === "group") return groupIds.has(grant.targetId);
if (grant.targetType === "user") return userIds.has(grant.targetId);
return false;
}),
exceptions: data.exceptions.filter((exception) => userIds.has(exception.userId)),
taskManagerMemberships: data.taskManagerMemberships.filter(
(membership) => clientIds.has(membership.clientId) && userIds.has(membership.userId)
),
syncStatuses: data.syncStatuses.filter(
(syncStatus) => clientIds.has(syncStatus.objectId) || userIds.has(syncStatus.objectId) || groupIds.has(syncStatus.objectId)
),
auditEvents: data.auditEvents.filter((event) => !event.clientId || clientIds.has(event.clientId)),
};
}
function cookieOptions(maxAgeMs) {
const options = {
httpOnly: true,
sameSite: "lax",
secure: config.cookieSecure,
path: "/",
maxAge: maxAgeMs,
};
if (config.cookieDomain) {
options.domain = config.cookieDomain;
}
return options;
}
function clearCookieOptions() {
const options = {
httpOnly: true,
sameSite: "lax",
secure: config.cookieSecure,
path: "/",
};
if (config.cookieDomain) {
options.domain = config.cookieDomain;
}
return options;
}
function shouldDisableHttpCache(req) {
if (req.path.startsWith("/api/") || req.path.startsWith("/auth/")) {
return true;
}
if (req.method !== "GET" && req.method !== "HEAD") {
return false;
}
const accept = typeof req.headers.accept === "string" ? req.headers.accept : "";
return accept.includes("text/html");
}
function lockNoStoreHeaders(res) {
const setHeader = res.setHeader.bind(res);
setNoStore(res);
res.setHeader = (name, value) => {
const normalizedName = String(name).toLowerCase();
if (normalizedName === "cache-control") {
return setHeader(name, noStoreCacheControl);
}
if (normalizedName === "pragma") {
return setHeader(name, "no-cache");
}
if (normalizedName === "expires") {
return setHeader(name, "0");
}
return setHeader(name, value);
};
}
function setNoStore(res) {
res.setHeader("Cache-Control", noStoreCacheControl);
res.setHeader("Pragma", "no-cache");
res.setHeader("Expires", "0");
}
function buildLoginRedirectUrl(returnTo, { forceLogin = false, includeReturnTo = false } = {}) {
const loginUrl = new URL("/auth/login", config.appBaseUrl);
const cleanReturnTo = sanitizeReturnTo(returnTo);
if (forceLogin) {
loginUrl.searchParams.set("prompt", "login");
}
if (includeReturnTo || cleanReturnTo !== "/") {
loginUrl.searchParams.set("returnTo", cleanReturnTo);
}
return loginUrl.toString();
}
function buildOidcLogoutUrl(discovery, returnTo = "/", idToken = null) {
const issuerUrl = new URL(discovery.issuer || config.issuer);
const logoutUrl = new URL("/if/flow/default-invalidation-flow/", issuerUrl.origin);
logoutUrl.searchParams.set("client_id", config.clientId);
logoutUrl.searchParams.set("post_logout_redirect_uri", buildLoggedOutRedirectUrl(returnTo));
if (idToken) {
logoutUrl.searchParams.set("id_token_hint", idToken);
}
return logoutUrl;
}
function buildLoggedOutRedirectUrl(returnTo = "/") {
const loggedOutUrl = new URL("/auth/logged-out", config.appBaseUrl);
const cleanReturnTo = sanitizeReturnTo(returnTo);
if (cleanReturnTo !== "/") {
loggedOutUrl.searchParams.set("returnTo", cleanReturnTo);
}
return loggedOutUrl.toString();
}
function randomBase64Url(size) {
return randomBytes(size).toString("base64url");
}
function sanitizeReturnTo(returnTo) {
if (typeof returnTo !== "string" || !returnTo.startsWith("/") || returnTo.startsWith("//")) {
return "/";
}
return returnTo;
}
function sanitizePrompt(prompt) {
if (prompt === "login" || prompt === "none" || prompt === "consent" || prompt === "select_account") {
return prompt;
}
return null;
}
function ensureTrailingSlash(value) {
return value.endsWith("/") ? value : `${value}/`;
}
function asyncRoute(handler) {
return (req, res, next) => {
Promise.resolve(handler(req, res, next)).catch(next);
};
}
function loadEnvFiles(candidates) {
for (const candidate of candidates) {
if (!candidate) continue;
const envPath = resolve(projectRoot, candidate);
if (!existsSync(envPath)) continue;
const lines = readFileSync(envPath, "utf8").split(/\r?\n/);
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#") || !trimmed.includes("=")) continue;
const separatorIndex = trimmed.indexOf("=");
const key = trimmed.slice(0, separatorIndex).trim();
const value = stripEnvQuotes(trimmed.slice(separatorIndex + 1).trim());
if (!process.env[key]) {
process.env[key] = value;
}
}
}
}
function stripEnvQuotes(value) {
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
return value.slice(1, -1);
}
return value;
}