3227 lines
105 KiB
JavaScript
3227 lines
105 KiB
JavaScript
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";
|
||
const publicPoolClientId = "client_public_pool";
|
||
|
||
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("/api/public/login-account-status", (req, res) => {
|
||
const email = typeof req.query.email === "string" ? req.query.email : "";
|
||
|
||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||
setNoStore(res);
|
||
res.json(controlPlaneStore.getLoginAccountStatus(email));
|
||
});
|
||
|
||
app.post("/api/access-requests", asyncRoute(async (req, res) => {
|
||
try {
|
||
const password = sanitizeNewPassword(req.body?.password);
|
||
|
||
if (!authentikSyncClient.isConfigured()) {
|
||
res.status(503).json({ error: "Authentik API не настроен. Заявку с паролем сейчас создать нельзя." });
|
||
return;
|
||
}
|
||
|
||
const result = await controlPlaneStore.createAccessRequest(req.body);
|
||
const provisioning = await authentikSyncClient.provisionUser({
|
||
data: result.data,
|
||
userId: result.user.id,
|
||
password,
|
||
});
|
||
await controlPlaneStore.markUserAuthentikProvisioned(result.user.id, provisioning, {
|
||
sub: "public-access-request",
|
||
name: "NODE.DC public request",
|
||
email: result.user.email,
|
||
});
|
||
|
||
publishControlPlaneEvent("access-request.created", [result.user.id]);
|
||
res.status(201).json({ accessRequest: result.accessRequest });
|
||
} catch (error) {
|
||
sendAccessRequestApiError(res, error);
|
||
}
|
||
}));
|
||
|
||
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 sessionAccess = getSessionAccessState(session);
|
||
|
||
if (!sessionAccess.ok) {
|
||
rejectInactiveSession(res, session, sessionAccess);
|
||
return;
|
||
}
|
||
|
||
const runtimeContext = sessionAccess.runtimeContext;
|
||
|
||
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;
|
||
}
|
||
|
||
const sessionAccess = getSessionAccessState(session);
|
||
|
||
if (!sessionAccess.ok) {
|
||
rejectInactiveSession(res, session, sessionAccess);
|
||
return;
|
||
}
|
||
|
||
res.json({ apps: getAppsForUser(sessionAccess.runtimeContext.groups) });
|
||
});
|
||
|
||
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, user) : 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.post("/api/internal/tasker/invite-requests", asyncRoute(async (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 tasker invite request" });
|
||
const inviterPayload = typeof req.body?.inviter === "object" && req.body.inviter !== null ? req.body.inviter : req.body;
|
||
const inviter = findInternalAccessUser(snapshot.data, {
|
||
subject: inviterPayload.subject,
|
||
email: inviterPayload.email,
|
||
userId: inviterPayload.userId,
|
||
});
|
||
|
||
if (!inviter) {
|
||
res.status(404).json({ ok: false, error: "inviter_not_found" });
|
||
return;
|
||
}
|
||
|
||
const groups = resolveRequiredGroups(snapshot.data, inviter);
|
||
const app = getAppsForUser(groups).find((candidate) => candidate.slug === "task-manager");
|
||
const workspaceSlug = req.body?.workspace?.slug ?? req.body?.workspaceSlug;
|
||
const workspacePolicy = resolveTaskManagerWorkspacePolicy(
|
||
snapshot.data,
|
||
groups,
|
||
Boolean(app?.hasAccess),
|
||
inviter,
|
||
workspaceSlug
|
||
);
|
||
|
||
if (
|
||
!app?.hasAccess ||
|
||
workspacePolicy.managedBy !== "tasker" ||
|
||
!["nodedc", "launcher"].includes(workspacePolicy.inviteApproval)
|
||
) {
|
||
res.status(403).json({ ok: false, error: "nodedc_tasker_invite_approval_not_allowed", workspacePolicy });
|
||
return;
|
||
}
|
||
|
||
const result = await controlPlaneStore.createTaskerInviteRequest({
|
||
taskerInviteId: req.body?.taskerInviteId,
|
||
workspaceId: req.body?.workspace?.id ?? req.body?.workspaceId,
|
||
workspaceSlug: req.body?.workspace?.slug ?? req.body?.workspaceSlug,
|
||
workspaceName: req.body?.workspace?.name ?? req.body?.workspaceName,
|
||
inviteeEmail: req.body?.invitee?.email ?? req.body?.inviteeEmail,
|
||
role: req.body?.invitee?.role ?? req.body?.role,
|
||
inviterUserId: inviter.id,
|
||
inviterPlaneUserId: inviterPayload.planeUserId,
|
||
inviterEmail: inviter.email,
|
||
inviterName: inviter.name,
|
||
}, inviter);
|
||
|
||
publishControlPlaneEvent("tasker.invite-request.created", [inviter.id]);
|
||
res.json({ ok: true, taskerInviteRequest: result.taskerInviteRequest });
|
||
}));
|
||
|
||
app.post("/api/internal/tasker/invite-requests/cancel", asyncRoute(async (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 result = await controlPlaneStore.cancelTaskerInviteRequest(req.body, {
|
||
name: req.body?.cancelledBy?.name ?? req.body?.cancelledBy?.email ?? "Operational Core",
|
||
email: req.body?.cancelledBy?.email,
|
||
source: "tasker",
|
||
});
|
||
const syncResult = await syncUsersToAuthentik(result.data, result.affectedUserIds ?? [], {
|
||
name: req.body?.cancelledBy?.name ?? req.body?.cancelledBy?.email ?? "Operational Core",
|
||
email: req.body?.cancelledBy?.email,
|
||
source: "tasker",
|
||
});
|
||
|
||
if (result.taskerInviteRequest) {
|
||
publishControlPlaneEvent("tasker.invite-request.cancelled", [
|
||
result.taskerInviteRequest.inviterUserId,
|
||
...syncResult.userIds,
|
||
]);
|
||
}
|
||
|
||
res.json({ ok: true, taskerInviteRequest: result.taskerInviteRequest });
|
||
}));
|
||
|
||
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);
|
||
const taskManagerProfile = await syncTaskManagerUserProfile(storeResult.user);
|
||
|
||
publishControlPlaneEvent("profile.updated", [actor.id]);
|
||
res.json({ ...storeResult, provisioning: toProvisioningResponse(provisionedUser), taskManagerProfile });
|
||
}));
|
||
|
||
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]);
|
||
const redirectUrl = resolveInviteRedirectUrl(result.invite);
|
||
res.json({
|
||
...result,
|
||
user: storeResult.user,
|
||
data: storeResult.data,
|
||
provisioning: toProvisioningResponse(provisionedUser),
|
||
loginUrl: buildLoginRedirectUrl(redirectUrl, { 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, redirectUrl: resolveInviteRedirectUrl(result.invite) });
|
||
}));
|
||
|
||
app.get("/tasker-workspace-invite/:taskerInviteRequestId", (req, res) => {
|
||
const session = getCurrentSession(req);
|
||
|
||
if (!session) {
|
||
res.redirect(buildLoginRedirectUrl(req.originalUrl, { forceLogin: true }));
|
||
return;
|
||
}
|
||
|
||
const sessionAccess = getSessionAccessState(session);
|
||
|
||
if (!sessionAccess.ok) {
|
||
expireSession(res, session);
|
||
res.redirect(buildLoginRedirectUrl(req.originalUrl, { forceLogin: true }));
|
||
return;
|
||
}
|
||
|
||
const runtimeContext = sessionAccess.runtimeContext;
|
||
const request = controlPlaneStore
|
||
.getSnapshot({ name: "NODE.DC tasker invite redirect" })
|
||
.data.taskerInviteRequests.find((candidate) => candidate.id === req.params.taskerInviteRequestId);
|
||
|
||
if (!request || request.status !== "approved") {
|
||
res.status(404).send("Workspace-инвайт не найден или ещё не подтверждён NODE.DC.");
|
||
return;
|
||
}
|
||
|
||
if (session.user.email?.toLowerCase() !== request.inviteeEmail.toLowerCase()) {
|
||
res.status(403).send("Этот workspace-инвайт выписан на другую почту.");
|
||
return;
|
||
}
|
||
|
||
const handoffToken = createServiceHandoff("task-manager", runtimeContext.user);
|
||
const taskBaseUrl = getTaskBaseUrl();
|
||
const targetUrl = new URL("/auth/nodedc/handoff/", taskBaseUrl);
|
||
targetUrl.searchParams.set("token", handoffToken);
|
||
targetUrl.searchParams.set(
|
||
"next_path",
|
||
`/auth/nodedc/workspace-invite/accept/${encodeURIComponent(request.id)}/`
|
||
);
|
||
|
||
res.redirect(targetUrl.toString());
|
||
});
|
||
|
||
app.get("/api/admin/control-plane", requireLauncherAdmin, (req, res) => {
|
||
res.json(scopeAdminSnapshot(req));
|
||
});
|
||
|
||
app.get("/api/storage/data", requireSession, (req, res) => {
|
||
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
|
||
|
||
if (snapshot.actor.source !== "launcher") {
|
||
res.status(403).json({ error: "Профиль пользователя не найден в Launcher control-plane" });
|
||
return;
|
||
}
|
||
|
||
const runtimeContext = getRuntimeSessionContext(req.nodedcSession);
|
||
const adminScope = resolveAdminScope(runtimeContext.user, runtimeContext.groups);
|
||
|
||
if (adminScope.isRoot || adminScope.clientIds.size > 0) {
|
||
res.json(scopeControlPlaneData(snapshot.data, adminScope));
|
||
return;
|
||
}
|
||
|
||
res.json(scopeRuntimeControlPlaneData(snapshot.data, snapshot.actor.id));
|
||
});
|
||
|
||
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) => {
|
||
const taskManager = await requestTaskManagerInternalJson("/api/internal/nodedc/workspaces/");
|
||
|
||
if (req.nodedcAdminScope?.isRoot) {
|
||
res.json(taskManager);
|
||
return;
|
||
}
|
||
|
||
const allowedWorkspaceSlugs = new Set(
|
||
req.nodedcAdminScope.snapshot.data.clients
|
||
.filter((client) => req.nodedcAdminScope.clientIds.has(client.id))
|
||
.flatMap((client) => {
|
||
const workspaces = Array.isArray(client.integrations?.taskManager?.workspaces)
|
||
? client.integrations.taskManager.workspaces
|
||
: [];
|
||
const slugs = workspaces.map((workspace) => workspace?.slug).filter((slug) => typeof slug === "string" && slug.trim());
|
||
const legacySlug = client.integrations?.taskManager?.workspaceSlug;
|
||
return legacySlug ? [...slugs, legacySlug] : slugs;
|
||
})
|
||
);
|
||
res.json({
|
||
...taskManager,
|
||
workspaces: (taskManager.workspaces ?? []).filter((workspace) => allowedWorkspaceSlugs.has(workspace.slug)),
|
||
});
|
||
}));
|
||
|
||
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;
|
||
const workspaceManagedBy = resolveTaskManagerWorkspaceManagedByForClient(client, workspaceSlug);
|
||
|
||
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,
|
||
managedBy: workspaceManagedBy,
|
||
setLastWorkspace: req.body?.setLastWorkspace !== false,
|
||
},
|
||
});
|
||
|
||
const result = await controlPlaneStore.recordTaskManagerWorkspaceMembership(
|
||
{
|
||
clientId: client.id,
|
||
userId: user.id,
|
||
workspaceSlug,
|
||
role,
|
||
managedBy: workspaceManagedBy,
|
||
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,
|
||
managedBy: resolveTaskManagerWorkspaceManagedByForClient(client, workspaceSlug),
|
||
setLastWorkspace: false,
|
||
},
|
||
});
|
||
const result = await controlPlaneStore.recordTaskManagerWorkspaceMembership(
|
||
{
|
||
clientId: client.id,
|
||
userId: user.id,
|
||
workspaceSlug,
|
||
role: "admin",
|
||
managedBy: resolveTaskManagerWorkspaceManagedByForClient(client, workspaceSlug),
|
||
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/task-manager/project-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 workspaceSlug = normalizeOptionalText(req.body?.workspaceSlug);
|
||
const projectId = normalizeOptionalText(req.body?.projectId);
|
||
const role = normalizeTaskManagerRole(req.body?.role);
|
||
const workspaceManagedBy = resolveTaskManagerWorkspaceManagedByForClient(client, workspaceSlug);
|
||
|
||
if (!workspaceSlug) {
|
||
res.status(400).json({ ok: false, error: "task_manager_workspace_not_configured" });
|
||
return;
|
||
}
|
||
|
||
if (!projectId) {
|
||
res.status(400).json({ ok: false, error: "task_manager_project_not_configured" });
|
||
return;
|
||
}
|
||
|
||
if (!role) {
|
||
res.status(400).json({ ok: false, error: "task_manager_role_invalid" });
|
||
return;
|
||
}
|
||
|
||
const taskManager = await requestTaskManagerInternalJson("/api/internal/nodedc/project-memberships/ensure/", {
|
||
method: "POST",
|
||
body: {
|
||
workspaceSlug,
|
||
projectId,
|
||
email: user.email,
|
||
subject: user.authentikUserId ?? undefined,
|
||
avatarUrl: resolveUserAvatarPublicUrl(user),
|
||
role,
|
||
managedBy: workspaceManagedBy,
|
||
setLastWorkspace: false,
|
||
},
|
||
});
|
||
|
||
const result = await controlPlaneStore.recordTaskManagerProjectMembership(
|
||
{
|
||
clientId: client.id,
|
||
userId: user.id,
|
||
workspaceSlug,
|
||
projectId,
|
||
role,
|
||
managedBy: workspaceManagedBy,
|
||
taskManager,
|
||
},
|
||
req.nodedcSession.user
|
||
);
|
||
|
||
publishControlPlaneEvent("admin.task-manager.project-membership.updated", [user.id]);
|
||
res.json({ ...scopeAdminMutationResult(req, result), taskManager });
|
||
}));
|
||
|
||
app.post("/api/admin/task-manager/project-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 workspaceSlug = normalizeOptionalText(req.body?.workspaceSlug);
|
||
const projectId = normalizeOptionalText(req.body?.projectId);
|
||
|
||
if (!workspaceSlug) {
|
||
res.status(400).json({ ok: false, error: "task_manager_workspace_not_configured" });
|
||
return;
|
||
}
|
||
|
||
if (!projectId) {
|
||
res.status(400).json({ ok: false, error: "task_manager_project_not_configured" });
|
||
return;
|
||
}
|
||
|
||
const taskManager = await requestTaskManagerInternalJson("/api/internal/nodedc/project-memberships/remove/", {
|
||
method: "POST",
|
||
body: {
|
||
workspaceSlug,
|
||
projectId,
|
||
email: user.email,
|
||
subject: user.authentikUserId ?? undefined,
|
||
},
|
||
});
|
||
const result = await controlPlaneStore.removeTaskManagerProjectMembership(
|
||
{
|
||
clientId: client.id,
|
||
userId: user.id,
|
||
workspaceSlug,
|
||
projectId,
|
||
},
|
||
req.nodedcSession.user
|
||
);
|
||
|
||
publishControlPlaneEvent("admin.task-manager.project-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);
|
||
const updatedUser = syncResult.data.users.find((candidate) => candidate.id === req.params.userId);
|
||
const taskManagerProfile = await syncTaskManagerUserProfile(updatedUser);
|
||
publishControlPlaneEvent("admin.user.updated", syncResult.userIds);
|
||
res.json({ ...scopeAdminMutationResult(req, { ...result, data: syncResult.data }), taskManagerProfile });
|
||
}));
|
||
|
||
app.delete("/api/admin/users/:userId", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
|
||
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
|
||
const user = snapshot.data.users.find((candidate) => candidate.id === req.params.userId);
|
||
|
||
if (!user) {
|
||
res.status(404).json({ error: "user_not_found" });
|
||
return;
|
||
}
|
||
|
||
let authentik = null;
|
||
|
||
if (authentikSyncClient.isConfigured()) {
|
||
authentik = await authentikSyncClient.deleteUser({ data: snapshot.data, userId: req.params.userId });
|
||
}
|
||
|
||
const taskManagerCleanup = await cleanupTaskManagerUserAccess(user);
|
||
const result = await controlPlaneStore.deleteUser(req.params.userId, req.nodedcSession.user);
|
||
publishControlPlaneEvent("admin.user.deleted", [req.params.userId]);
|
||
res.json({ ...scopeAdminMutationResult(req, result), authentik, taskManagerCleanup });
|
||
}));
|
||
|
||
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.patch("/api/admin/access-requests/:accessRequestId", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
|
||
try {
|
||
const result = await controlPlaneStore.updateAccessRequest(req.params.accessRequestId, req.body, req.nodedcSession.user);
|
||
publishControlPlaneEvent("admin.access-request.updated");
|
||
res.json(scopeAdminMutationResult(req, result));
|
||
} catch (error) {
|
||
sendAccessRequestApiError(res, error);
|
||
}
|
||
}));
|
||
|
||
app.post("/api/admin/access-requests/:accessRequestId/approve", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
|
||
try {
|
||
let result = await controlPlaneStore.approveAccessRequest(req.params.accessRequestId, req.body, req.nodedcSession.user);
|
||
let provisioning = null;
|
||
|
||
if (result.user && authentikSyncClient.isConfigured()) {
|
||
provisioning = await authentikSyncClient.provisionUser({
|
||
data: result.data,
|
||
userId: result.user.id,
|
||
});
|
||
const syncResult = await controlPlaneStore.markUserAuthentikProvisioned(result.user.id, provisioning, req.nodedcSession.user);
|
||
result = { ...result, data: syncResult.data, user: syncResult.user, provisioning };
|
||
}
|
||
|
||
publishControlPlaneEvent("admin.access-request.approved", result.user ? [result.user.id] : []);
|
||
res.json(scopeAdminMutationResult(req, result));
|
||
} catch (error) {
|
||
sendAccessRequestApiError(res, error);
|
||
}
|
||
}));
|
||
|
||
app.post("/api/admin/access-requests/:accessRequestId/reject", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
|
||
try {
|
||
const result = await controlPlaneStore.rejectAccessRequest(req.params.accessRequestId, req.body, req.nodedcSession.user);
|
||
publishControlPlaneEvent("admin.access-request.rejected");
|
||
res.json(scopeAdminMutationResult(req, result));
|
||
} catch (error) {
|
||
sendAccessRequestApiError(res, error);
|
||
}
|
||
}));
|
||
|
||
app.post("/api/admin/tasker-invite-requests/:taskerInviteRequestId/approve", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
|
||
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
|
||
const taskerInviteRequest = snapshot.data.taskerInviteRequests.find((request) => request.id === req.params.taskerInviteRequestId);
|
||
|
||
if (!taskerInviteRequest) {
|
||
res.status(404).json({ error: "tasker_invite_request_not_found" });
|
||
return;
|
||
}
|
||
|
||
const platformInviteResult = await controlPlaneStore.ensureTaskerInvitePlatformInvite(
|
||
req.params.taskerInviteRequestId,
|
||
req.nodedcSession.user
|
||
);
|
||
const platformInviteLink = buildPlatformInviteUrl(platformInviteResult.invite);
|
||
const taskerResult = await requestTaskManagerInternalJson("/api/internal/nodedc/workspace-invite-requests/approve/", {
|
||
body: {
|
||
taskerInviteId: taskerInviteRequest.taskerInviteId,
|
||
requestId: taskerInviteRequest.id,
|
||
platformInviteLink,
|
||
},
|
||
});
|
||
const result = await controlPlaneStore.approveTaskerInviteRequest(
|
||
req.params.taskerInviteRequestId,
|
||
{
|
||
taskerInviteLink: taskerResult.invite?.taskerInviteLink ?? taskerResult.invite?.tasker_invite_link ?? taskerResult.invite?.inviteLink ?? null,
|
||
platformInviteId: platformInviteResult.invite.id,
|
||
platformInviteToken: platformInviteResult.invite.token,
|
||
comment: req.body?.comment,
|
||
},
|
||
req.nodedcSession.user
|
||
);
|
||
|
||
publishControlPlaneEvent("admin.tasker-invite-request.approved", [result.taskerInviteRequest.inviterUserId]);
|
||
res.json(scopeAdminMutationResult(req, { ...result, tasker: taskerResult }));
|
||
}));
|
||
|
||
app.post("/api/admin/tasker-invite-requests/:taskerInviteRequestId/reject", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
|
||
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
|
||
const taskerInviteRequest = snapshot.data.taskerInviteRequests.find((request) => request.id === req.params.taskerInviteRequestId);
|
||
|
||
if (!taskerInviteRequest) {
|
||
res.status(404).json({ error: "tasker_invite_request_not_found" });
|
||
return;
|
||
}
|
||
|
||
const taskerResult = await requestTaskManagerInternalJson("/api/internal/nodedc/workspace-invite-requests/reject/", {
|
||
body: {
|
||
taskerInviteId: taskerInviteRequest.taskerInviteId,
|
||
requestId: taskerInviteRequest.id,
|
||
comment: req.body?.comment,
|
||
},
|
||
});
|
||
const result = await controlPlaneStore.rejectTaskerInviteRequest(req.params.taskerInviteRequestId, req.body, req.nodedcSession.user);
|
||
|
||
publishControlPlaneEvent("admin.tasker-invite-request.rejected", [result.taskerInviteRequest.inviterUserId]);
|
||
res.json(scopeAdminMutationResult(req, { ...result, tasker: taskerResult }));
|
||
}));
|
||
|
||
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", requireSession, 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 });
|
||
}));
|
||
|
||
app.get("/storage/launcher-data.json", (_req, res) => {
|
||
setNoStore(res);
|
||
res.status(404).json({ error: "not_found" });
|
||
});
|
||
|
||
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 ??
|
||
"",
|
||
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 sendAccessRequestApiError(res, error) {
|
||
const message = error instanceof Error ? error.message : "Заявка недоступна";
|
||
const status =
|
||
message.includes("Unknown access_request") || message.includes("не найден")
|
||
? 404
|
||
: message.includes("нельзя")
|
||
? 409
|
||
: message.includes("required") || message.includes("Введите")
|
||
? 400
|
||
: 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;
|
||
}
|
||
|
||
return buildRuntimeSessionContext(session, snapshot.data, user);
|
||
} catch (error) {
|
||
console.warn(error instanceof Error ? error.message : "Не удалось рассчитать runtime контекст Launcher");
|
||
return fallback;
|
||
}
|
||
}
|
||
|
||
function buildRuntimeSessionContext(session, data, user) {
|
||
const groups = resolveRequiredGroups(data, user);
|
||
|
||
return {
|
||
groups,
|
||
user: {
|
||
...session.user,
|
||
email: user.email,
|
||
name: user.name,
|
||
avatarUrl: user.avatarUrl ?? session.user.avatarUrl,
|
||
groups,
|
||
},
|
||
};
|
||
}
|
||
|
||
function getSessionAccessState(session) {
|
||
try {
|
||
const snapshot = controlPlaneStore.getSnapshot(session.user);
|
||
const sessionEmail = typeof session.user?.email === "string" ? session.user.email.toLowerCase() : "";
|
||
|
||
if (snapshot.actor.source !== "launcher") {
|
||
const revokedAccount = snapshot.data.revokedAccounts.find((account) => account.email.toLowerCase() === sessionEmail);
|
||
return {
|
||
ok: false,
|
||
status: 401,
|
||
error: revokedAccount ? "account_revoked" : "account_not_found",
|
||
message: revokedAccount
|
||
? "Аккаунт больше не активен. Запросите доступ, если хотите подключиться снова."
|
||
: "Профиль пользователя не найден в Launcher control-plane.",
|
||
};
|
||
}
|
||
|
||
const user = snapshot.data.users.find((candidate) => candidate.id === snapshot.actor.id);
|
||
|
||
if (!user) {
|
||
return {
|
||
ok: false,
|
||
status: 401,
|
||
error: "account_not_found",
|
||
message: "Профиль пользователя не найден в Launcher control-plane.",
|
||
};
|
||
}
|
||
|
||
if (user.globalStatus === "blocked") {
|
||
return {
|
||
ok: false,
|
||
status: 401,
|
||
error: "account_blocked",
|
||
message: "Аккаунт заблокирован. Обратитесь к администратору NODE.DC.",
|
||
};
|
||
}
|
||
|
||
return {
|
||
ok: true,
|
||
runtimeContext: buildRuntimeSessionContext(session, snapshot.data, user),
|
||
};
|
||
} catch (error) {
|
||
console.warn(error instanceof Error ? error.message : "Не удалось проверить Launcher-сессию");
|
||
return {
|
||
ok: false,
|
||
status: 401,
|
||
error: "session_invalid",
|
||
message: "Не удалось проверить Launcher-сессию.",
|
||
};
|
||
}
|
||
}
|
||
|
||
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 ? "Доступ подтверждён" : "Нет доступа",
|
||
};
|
||
}).filter((app) => app.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;
|
||
}
|
||
|
||
async function syncTaskManagerUserProfile(user) {
|
||
if (!user?.email || !config.internalAccessToken) {
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
return await requestTaskManagerInternalJson("/api/internal/nodedc/users/profile-sync/", {
|
||
method: "POST",
|
||
body: {
|
||
email: user.email,
|
||
subject: user.authentikUserId ?? undefined,
|
||
name: user.name,
|
||
avatarUrl: resolveUserAvatarPublicUrl(user),
|
||
},
|
||
});
|
||
} catch (error) {
|
||
console.warn(
|
||
error instanceof Error
|
||
? `Task Manager profile sync failed: ${error.message}`
|
||
: "Task Manager profile sync failed"
|
||
);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function cleanupTaskManagerUserAccess(user) {
|
||
if (!user?.email || !config.internalAccessToken) {
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
return await requestTaskManagerInternalJson("/api/internal/nodedc/logout/", {
|
||
method: "POST",
|
||
body: {
|
||
source: "launcher-user-hard-delete",
|
||
subject: user.authentikUserId ?? undefined,
|
||
email: user.email,
|
||
revokeIdentityLinks: true,
|
||
revokeTaskerAccess: true,
|
||
},
|
||
});
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : "Task Manager user cleanup failed";
|
||
console.warn(`Task Manager user cleanup failed: ${message}`);
|
||
return { ok: false, error: message };
|
||
}
|
||
}
|
||
|
||
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 normalizeTaskManagerWorkspaceManagedBy(value) {
|
||
return value === "tasker" ? "tasker" : "launcher";
|
||
}
|
||
|
||
function getClientTaskManagerWorkspaces(client) {
|
||
const taskManager = client?.integrations?.taskManager;
|
||
const workspaces = Array.isArray(taskManager?.workspaces) ? taskManager.workspaces : [];
|
||
const legacySlug = normalizeOptionalText(taskManager?.workspaceSlug);
|
||
|
||
if (!legacySlug || workspaces.some((workspace) => normalizeOptionalText(workspace?.slug) === legacySlug)) {
|
||
return workspaces;
|
||
}
|
||
|
||
return [
|
||
...workspaces,
|
||
{
|
||
slug: legacySlug,
|
||
name: normalizeOptionalText(taskManager?.workspaceName),
|
||
isPrimary: true,
|
||
managedBy: "launcher",
|
||
},
|
||
];
|
||
}
|
||
|
||
function resolveTaskManagerWorkspaceBinding(client, workspaceSlug) {
|
||
const normalizedWorkspaceSlug = normalizeOptionalText(workspaceSlug);
|
||
if (!normalizedWorkspaceSlug) return null;
|
||
|
||
return (
|
||
getClientTaskManagerWorkspaces(client).find((workspace) => normalizeOptionalText(workspace?.slug) === normalizedWorkspaceSlug) ?? null
|
||
);
|
||
}
|
||
|
||
function resolveTaskManagerWorkspaceManagedByForClient(client, workspaceSlug) {
|
||
return normalizeTaskManagerWorkspaceManagedBy(resolveTaskManagerWorkspaceBinding(client, workspaceSlug)?.managedBy);
|
||
}
|
||
|
||
function resolveTaskManagerWorkspaceAssignments(data, user) {
|
||
if (!user?.id) return [];
|
||
|
||
const bySlug = new Map();
|
||
for (const membership of data.taskManagerMemberships ?? []) {
|
||
if (membership.userId !== user.id) continue;
|
||
const workspaceSlug = normalizeOptionalText(membership.workspaceSlug);
|
||
if (!workspaceSlug) continue;
|
||
|
||
const client = data.clients.find((candidate) => candidate.id === membership.clientId);
|
||
const managedBy = normalizeTaskManagerWorkspaceManagedBy(
|
||
membership.managedBy ?? resolveTaskManagerWorkspaceBinding(client, workspaceSlug)?.managedBy
|
||
);
|
||
const current = bySlug.get(workspaceSlug);
|
||
if (current && current.managedBy === "launcher") continue;
|
||
|
||
bySlug.set(workspaceSlug, {
|
||
slug: workspaceSlug,
|
||
name: normalizeOptionalText(membership.workspaceName ?? resolveTaskManagerWorkspaceBinding(client, workspaceSlug)?.name),
|
||
managedBy,
|
||
clientId: client?.id ?? membership.clientId ?? null,
|
||
clientName: client?.name ?? null,
|
||
role: normalizeTaskManagerRole(membership.role) ?? "member",
|
||
});
|
||
}
|
||
|
||
return [...bySlug.values()];
|
||
}
|
||
|
||
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, user, workspaceSlug = null) {
|
||
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");
|
||
const workspaces = resolveTaskManagerWorkspaceAssignments(data, user);
|
||
const hasLauncherManagedWorkspace = workspaces.some((workspace) => workspace.managedBy === "launcher");
|
||
const isPublicPoolUser = data.memberships.some(
|
||
(membership) => membership.userId === user?.id && membership.clientId === publicPoolClientId && membership.status === "active"
|
||
);
|
||
const defaultManagedBy = hasLauncherManagedWorkspace && !isSuperAdmin ? "launcher" : "tasker";
|
||
const defaultInviteApproval = hasLauncherManagedWorkspace && !isSuperAdmin ? "launcher" : isPublicPoolUser ? "nodedc" : "tasker";
|
||
const workspaceAssignment =
|
||
typeof workspaceSlug === "string" && workspaceSlug.trim()
|
||
? workspaces.find((workspace) => workspace.slug === workspaceSlug.trim())
|
||
: null;
|
||
|
||
if (!hasTaskManagerAccess) {
|
||
return {
|
||
mode,
|
||
managedBy: defaultManagedBy,
|
||
defaultManagedBy,
|
||
inviteApproval: "disabled",
|
||
defaultInviteApproval,
|
||
workspaces,
|
||
canCreateWorkspace: false,
|
||
reason: "Нет доступа к Operational Core.",
|
||
};
|
||
}
|
||
|
||
if (mode === "disabled") {
|
||
return {
|
||
mode,
|
||
managedBy: defaultManagedBy,
|
||
defaultManagedBy,
|
||
inviteApproval: "disabled",
|
||
defaultInviteApproval,
|
||
workspaces,
|
||
canCreateWorkspace: false,
|
||
reason: "Создание рабочих пространств отключено на уровне платформы.",
|
||
};
|
||
}
|
||
|
||
if (hasLauncherManagedWorkspace && !isSuperAdmin) {
|
||
if (workspaceAssignment?.managedBy === "launcher") {
|
||
return {
|
||
mode,
|
||
managedBy: "launcher",
|
||
defaultManagedBy: "launcher",
|
||
inviteApproval: "launcher",
|
||
defaultInviteApproval: "launcher",
|
||
workspaces,
|
||
canCreateWorkspace: false,
|
||
reason: "Рабочие пространства этого пользователя управляются через Launcher.",
|
||
};
|
||
}
|
||
|
||
if (workspaceSlug) {
|
||
return {
|
||
mode,
|
||
managedBy: "tasker",
|
||
defaultManagedBy: "launcher",
|
||
inviteApproval: defaultInviteApproval,
|
||
defaultInviteApproval,
|
||
workspaces,
|
||
canCreateWorkspace: false,
|
||
reason: "Self-service workspace работает через Tasker, approve инвайтов выполняет Launcher.",
|
||
};
|
||
}
|
||
|
||
return {
|
||
mode,
|
||
managedBy: "launcher",
|
||
defaultManagedBy: "launcher",
|
||
inviteApproval: "launcher",
|
||
defaultInviteApproval: "launcher",
|
||
workspaces,
|
||
canCreateWorkspace: false,
|
||
reason: "Рабочие пространства этого пользователя управляются через Launcher.",
|
||
};
|
||
}
|
||
|
||
if (mode === "task_admins_only" && !isSuperAdmin && !isTaskManagerAdmin) {
|
||
return {
|
||
mode,
|
||
managedBy: defaultManagedBy,
|
||
defaultManagedBy,
|
||
inviteApproval: defaultInviteApproval,
|
||
defaultInviteApproval,
|
||
workspaces,
|
||
canCreateWorkspace: false,
|
||
reason: "Создание рабочих пространств доступно только администраторам Operational Core.",
|
||
};
|
||
}
|
||
|
||
return {
|
||
mode,
|
||
managedBy: "tasker",
|
||
defaultManagedBy: "tasker",
|
||
inviteApproval: defaultInviteApproval,
|
||
defaultInviteApproval,
|
||
workspaces,
|
||
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() {
|
||
try {
|
||
return controlPlaneStore.readData();
|
||
} 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 expireSession(res, session) {
|
||
sessions.delete(session.id);
|
||
res.clearCookie(sessionCookieName, clearCookieOptions());
|
||
}
|
||
|
||
function rejectInactiveSession(res, session, sessionAccess) {
|
||
expireSession(res, session);
|
||
res.status(sessionAccess.status ?? 401).json({
|
||
authenticated: false,
|
||
loginUrl: "/auth/login",
|
||
error: sessionAccess.error,
|
||
message: sessionAccess.message,
|
||
});
|
||
}
|
||
|
||
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 sessionAccess = getSessionAccessState(session);
|
||
|
||
if (!sessionAccess.ok) {
|
||
rejectInactiveSession(res, session, sessionAccess);
|
||
return;
|
||
}
|
||
|
||
const runtimeContext = sessionAccess.runtimeContext;
|
||
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 sessionAccess = getSessionAccessState(session);
|
||
|
||
if (!sessionAccess.ok) {
|
||
rejectInactiveSession(res, session, sessionAccess);
|
||
return;
|
||
}
|
||
|
||
const runtimeContext = sessionAccess.runtimeContext;
|
||
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)),
|
||
accessRequests: [],
|
||
taskerInviteRequests: [],
|
||
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)
|
||
),
|
||
taskManagerProjectMemberships: data.taskManagerProjectMemberships.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 scopeRuntimeControlPlaneData(data, userId) {
|
||
const user = data.users.find((candidate) => candidate.id === userId);
|
||
|
||
if (!user) {
|
||
return {
|
||
...data,
|
||
clients: [],
|
||
users: [],
|
||
memberships: [],
|
||
groups: [],
|
||
invites: [],
|
||
accessRequests: [],
|
||
revokedAccounts: [],
|
||
taskerInviteRequests: [],
|
||
grants: [],
|
||
exceptions: [],
|
||
syncStatuses: [],
|
||
auditEvents: [],
|
||
taskManagerMemberships: [],
|
||
taskManagerProjectMemberships: [],
|
||
};
|
||
}
|
||
|
||
const memberships = data.memberships.filter((membership) => membership.userId === user.id);
|
||
const clientIds = new Set(memberships.map((membership) => membership.clientId));
|
||
const groups = data.groups
|
||
.filter((group) => clientIds.has(group.clientId) && group.memberIds.includes(user.id))
|
||
.map((group) => ({ ...group, memberIds: group.memberIds.filter((memberId) => memberId === user.id) }));
|
||
const groupIds = new Set(groups.map((group) => group.id));
|
||
|
||
return {
|
||
...data,
|
||
clients: data.clients.filter((client) => clientIds.has(client.id)),
|
||
users: [user],
|
||
memberships,
|
||
groups,
|
||
invites: [],
|
||
accessRequests: [],
|
||
revokedAccounts: [],
|
||
taskerInviteRequests: [],
|
||
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 grant.targetId === user.id;
|
||
return false;
|
||
}),
|
||
exceptions: data.exceptions.filter((exception) => exception.userId === user.id),
|
||
syncStatuses: [],
|
||
auditEvents: [],
|
||
taskManagerMemberships: data.taskManagerMemberships.filter(
|
||
(membership) => membership.userId === user.id && clientIds.has(membership.clientId)
|
||
),
|
||
taskManagerProjectMemberships: data.taskManagerProjectMemberships.filter(
|
||
(membership) => membership.userId === user.id && clientIds.has(membership.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 buildPlatformInviteUrl(invite) {
|
||
return new URL(`/invite/${encodeURIComponent(invite.token)}`, config.appBaseUrl).toString();
|
||
}
|
||
|
||
function resolveInviteRedirectUrl(invite) {
|
||
if (invite?.source === "tasker_workspace_invite" && invite.sourceTaskerInviteRequestId) {
|
||
return `/tasker-workspace-invite/${encodeURIComponent(invite.sourceTaskerInviteRequestId)}`;
|
||
}
|
||
|
||
return "/";
|
||
}
|
||
|
||
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;
|
||
}
|