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, isAbsolute, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { createRemoteJWKSet, jwtVerify } from "jose"; import { createAuthentikSyncClient, resolveRequiredGroups } from "./authentik-sync.mjs"; import { createControlPlaneStore } from "./control-plane-store.mjs"; import { createNotificationCoreClient } from "./notification-core-client.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 runtimeStorageRoot = resolveRuntimeStorageRoot(projectRoot); const runtimeUploadsRoot = resolveRuntimeUploadsRoot(projectRoot, runtimeStorageRoot); const authentikSyncClient = createAuthentikSyncClient({ baseUrl: config.authentikBaseUrl, token: config.authentikApiToken }); const notificationCoreClient = createNotificationCoreClient({ baseUrl: config.notificationCoreBaseUrl, token: config.internalAccessToken }); const pendingLogins = new Map(); const serviceHandoffs = new Map(); const sessions = new Map(); const adminMutationIdempotencyRecords = new Map(); const runtimeEventClients = new Set(); let discoveryCache = null; let jwksCache = null; const adminMutationIdempotencyTtlMs = 10 * 60 * 1000; const assistantGatewayName = "ontology-core"; const assistantGatewayAdminRouteAllowlist = [ { method: "GET", pattern: /^\/api\/admin\/control-plane$/ }, { method: "PATCH", pattern: /^\/api\/admin\/users\/[^/?#]+\/profile$/ }, { method: "PATCH", pattern: /^\/api\/admin\/memberships\/[^/?#]+$/ }, ]; 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), notificationCoreConfigured: notificationCoreClient.isConfigured(), }); }); 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]); publishNotificationEvent({ type: "nodedc.access_request.created", sourceService: "launcher", actor: { userId: result.user.id, email: result.user.email }, subject: { type: "access_request", id: result.accessRequest.id }, payload: { accessRequestId: result.accessRequest.id, userId: result.user.id, email: result.user.email, status: result.accessRequest.status, }, idempotencyKey: `launcher:access-request:${result.accessRequest.id}:created:${result.accessRequest.updatedAt}`, deliveries: [ rootAdminNotificationDelivery(result.data, { ...buildAccessRequestHubNotification(result.accessRequest), actionable: true, actionType: "nodedc.access_request.review", entityType: "access_request", entityId: result.accessRequest.id, }), ].filter(Boolean), }); 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( "NODE.DC Launcher session closed." ); }); 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/notifications", requireSession, asyncRoute(async (req, res) => { if (!notificationCoreClient.isConfigured()) { res.status(503).json({ ok: false, error: "notification_core_not_configured" }); return; } const { actor, data } = getLauncherProfileContext(req.nodedcSession); const user = findLauncherUser(data, actor.id); const payload = await notificationCoreClient.listNotifications({ subject: notificationSubjectFromUser(user), surface: sanitizeNotificationSurface(req.query.surface, "hub"), limit: req.query.limit, unreadOnly: req.query.unreadOnly === "1" || req.query.unreadOnly === "true", }); res.json(payload); })); app.post("/api/notifications/:deliveryId/read", requireSession, asyncRoute(async (req, res) => { if (!notificationCoreClient.isConfigured()) { res.status(503).json({ ok: false, error: "notification_core_not_configured" }); return; } const { actor, data } = getLauncherProfileContext(req.nodedcSession); const user = findLauncherUser(data, actor.id); const payload = await notificationCoreClient.markNotificationRead({ subject: notificationSubjectFromUser(user), deliveryId: req.params.deliveryId, }); res.json(payload); })); app.post("/api/notifications/read-all", requireSession, asyncRoute(async (req, res) => { if (!notificationCoreClient.isConfigured()) { res.status(503).json({ ok: false, error: "notification_core_not_configured" }); return; } const { actor, data } = getLauncherProfileContext(req.nodedcSession); const user = findLauncherUser(data, actor.id); const payload = await notificationCoreClient.markAllNotificationsRead({ subject: notificationSubjectFromUser(user), surface: sanitizeNotificationSurface(req.query.surface ?? req.body?.surface, "hub"), }); res.json(payload); })); app.get("/api/notifications/stream", requireSession, asyncRoute(async (req, res) => { if (!notificationCoreClient.isConfigured()) { res.status(503).json({ ok: false, error: "notification_core_not_configured" }); return; } const { actor, data } = getLauncherProfileContext(req.nodedcSession); const user = findLauncherUser(data, actor.id); const abortController = new AbortController(); req.on("close", () => { abortController.abort(); }); const upstream = await notificationCoreClient.openNotificationStream({ subject: notificationSubjectFromUser(user), surface: sanitizeNotificationSurface(req.query.surface, "hub"), signal: abortController.signal, }); 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?.(); const reader = upstream.body.getReader(); try { while (true) { const { done, value } = await reader.read(); if (done) break; res.write(Buffer.from(value)); } } finally { reader.releaseLock(); res.end(); } })); 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/optional-launch", (req, res) => { const serviceSlug = sanitizeServiceSlug(req.params.serviceSlug); const fallbackPath = req.query.fallbackTo || req.query.returnTo || "/"; const fallbackUrl = buildOptionalServiceFallbackUrl(serviceSlug, null, fallbackPath); const session = getCurrentSession(req); if (!session) { setNoStore(res); res.redirect(fallbackUrl); return; } const sessionAccess = getSessionAccessState(session); if (!sessionAccess.ok) { expireSession(res, session); setNoStore(res); res.redirect(fallbackUrl); return; } const runtimeContext = sessionAccess.runtimeContext; const app = getAppsForUser(runtimeContext.groups).find((candidate) => candidate.slug === serviceSlug); const appFallbackUrl = buildOptionalServiceFallbackUrl(serviceSlug, app, fallbackPath); if (!app || !app.hasAccess || app.status !== "active") { setNoStore(res); res.redirect(appFallbackUrl); return; } redirectToServiceLaunchTarget(res, serviceSlug, runtimeContext, app, req.query.next_path || req.query.returnTo || "/"); }); 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; } redirectToServiceLaunchTarget(res, serviceSlug, runtimeContext, app, req.query.next_path || req.query.returnTo || "/"); }); 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.post("/api/assistant/actions", requireSession, asyncRoute(async (req, res) => { const { actor, data } = getLauncherProfileContext(req.nodedcSession); const runtimeContext = getRuntimeSessionContext(req.nodedcSession); const command = buildAssistantActionProxyCommand(req.body, { actor, data, groups: runtimeContext.groups, }); const payload = await requestAiWorkspaceAssistantJson("/api/ai-workspace/assistant/v1/actions", { body: command, actor, groups: runtimeContext.groups, role: command.input.launcherGlobalRole, }); res.json(payload); })); 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); const workspaceSlug = normalizeOptionalText(req.body?.workspaceSlug ?? req.body?.workspace?.slug); 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 serviceModules = serviceSlug === "task-manager" ? resolveTaskManagerWorkspaceServiceModules(snapshot.data, user, serviceSlug, workspaceSlug) : resolveUserServiceModules(snapshot.data, user, serviceSlug, null); const workspacePolicy = serviceSlug === "task-manager" ? resolveTaskManagerWorkspacePolicy(snapshot.data, groups, allowed, user, workspaceSlug, serviceModules) : null; res.json({ ok: true, allowed, reason: allowed ? "access_confirmed" : "access_denied", serviceSlug, groups, matchedGroups: app?.matchedGroups ?? [], serviceModules, 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", result.affectedUserIds?.length ? result.affectedUserIds : [inviter.id] ); publishNotificationEvent({ type: result.taskerInviteRequest.status === "approved" ? "tasker.workspace_invite.approved" : "tasker.workspace_invite.requested", sourceService: "tasker", actor: { userId: inviter.id, email: inviter.email }, subject: { type: "tasker_invite_request", id: result.taskerInviteRequest.id }, payload: { requestId: result.taskerInviteRequest.id, workspaceSlug: result.taskerInviteRequest.workspaceSlug, workspaceName: result.taskerInviteRequest.workspaceName, inviteeEmail: result.taskerInviteRequest.inviteeEmail, status: result.taskerInviteRequest.status, }, idempotencyKey: `launcher:tasker-invite:${result.taskerInviteRequest.id}:${result.taskerInviteRequest.status}:${result.taskerInviteRequest.updatedAt}`, deliveries: ( result.taskerInviteRequest.status === "new" ? [ rootAdminNotificationDelivery(result.data, { ...buildTaskerInviteHubNotification(result.taskerInviteRequest), actionable: true, actionType: "tasker.workspace_invite.review", entityType: "tasker_invite_request", entityId: result.taskerInviteRequest.id, }), ] : notificationUsersForTaskerInvite(result.data, result.taskerInviteRequest).map((user) => notificationDeliveryForUser(user, "hub", { ...buildTaskerInviteHubNotification(result.taskerInviteRequest), entityType: "tasker_invite_request", entityId: result.taskerInviteRequest.id, }) ) ).filter(Boolean), }); res.json({ ok: true, taskerInviteRequest: result.taskerInviteRequest, autoApproved: Boolean(result.autoApproved) }); })); 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.post("/api/internal/engine/workflow-access-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 Engine workflow access request" }); const requesterPayload = typeof req.body?.requester === "object" && req.body.requester !== null ? req.body.requester : {}; const requester = findInternalAccessUser(snapshot.data, { subject: requesterPayload.subject, email: requesterPayload.email, userId: requesterPayload.userId, }); const targetEmail = typeof req.body?.targetEmail === "string" ? req.body.targetEmail.trim().toLowerCase() : ""; const targetUser = findInternalAccessUser(snapshot.data, { email: targetEmail }); if (!requester) { res.status(404).json({ ok: false, error: "requester_not_found" }); return; } if (!targetUser || targetUser.globalStatus !== "active") { res.status(404).json({ ok: false, error: "user_not_found" }); return; } const groups = resolveRequiredGroups(snapshot.data, targetUser); const app = getAppsForUser(groups).find((candidate) => candidate.slug === "nodedc"); if (app?.hasAccess) { res.json({ ok: true, alreadyAllowed: true, targetUser: { id: targetUser.id, email: targetUser.email, name: targetUser.name, avatarUrl: targetUser.avatarUrl ?? null, }, }); return; } const result = await controlPlaneStore.createEngineWorkflowAccessRequest({ workflowId: req.body?.workflow?.id ?? req.body?.workflowId, workflowName: req.body?.workflow?.name ?? req.body?.workflowName, targetEmail, role: req.body?.role, requesterUserId: requester.id, requesterEmail: requester.email, requesterName: requester.name, }, requester); publishControlPlaneEvent( "engine.workflow-access-request.created", result.affectedUserIds?.length ? result.affectedUserIds : [requester.id, targetUser.id] ); publishNotificationEvent({ type: "engine.workflow_share.access_requested", sourceService: "engine", actor: { userId: requester.id, email: requester.email }, subject: { type: "engine_workflow_access_request", id: result.engineWorkflowAccessRequest.id }, payload: { requestId: result.engineWorkflowAccessRequest.id, workflowId: result.engineWorkflowAccessRequest.workflowId, workflowName: result.engineWorkflowAccessRequest.workflowName, targetUserId: targetUser.id, targetEmail: targetUser.email, role: result.engineWorkflowAccessRequest.role, }, idempotencyKey: `launcher:engine-workflow-request:${result.engineWorkflowAccessRequest.id}:created:${result.engineWorkflowAccessRequest.updatedAt}`, deliveries: [ rootAdminNotificationDelivery(result.data, { ...buildEngineWorkflowHubNotification(result.engineWorkflowAccessRequest), actionable: true, actionType: "engine.workflow_access.review", entityType: "engine_workflow_access_request", entityId: result.engineWorkflowAccessRequest.id, }), ].filter(Boolean), }); res.json({ ok: true, engineWorkflowAccessRequest: result.engineWorkflowAccessRequest }); })); app.post("/api/internal/engine/role-access-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 Engine role access request" }); const requesterPayload = typeof req.body?.requester === "object" && req.body.requester !== null ? req.body.requester : {}; const requester = findInternalAccessUser(snapshot.data, { subject: requesterPayload.subject, email: requesterPayload.email, userId: requesterPayload.userId, }); if (!requester || requester.globalStatus !== "active") { res.status(404).json({ ok: false, error: "requester_not_found" }); return; } const result = await controlPlaneStore.createEngineRoleAccessRequest({ requestedRole: req.body?.requestedRole, currentRole: req.body?.currentRole, requesterUserId: requester.id, requesterEmail: requester.email, requesterName: requester.name, }, requester); publishControlPlaneEvent( "engine.role-access-request.created", result.affectedUserIds?.length ? result.affectedUserIds : [requester.id, "user_root"] ); publishNotificationEvent({ type: "engine.role_upgrade.requested", sourceService: "engine", actor: { userId: requester.id, email: requester.email }, subject: { type: "engine_role_access_request", id: result.engineWorkflowAccessRequest.id }, payload: { requestId: result.engineWorkflowAccessRequest.id, targetUserId: requester.id, targetEmail: requester.email, requestedServiceRole: result.engineWorkflowAccessRequest.requestedServiceRole, currentServiceRole: result.engineWorkflowAccessRequest.currentServiceRole, }, idempotencyKey: `launcher:engine-role-request:${result.engineWorkflowAccessRequest.id}:created:${result.engineWorkflowAccessRequest.updatedAt}`, deliveries: [ rootAdminNotificationDelivery(result.data, { ...buildEngineRoleHubNotification(result.engineWorkflowAccessRequest), actionable: true, actionType: "engine.role_upgrade.review", entityType: "engine_role_access_request", entityId: result.engineWorkflowAccessRequest.id, }), ].filter(Boolean), }); res.json({ ok: true, engineWorkflowAccessRequest: result.engineWorkflowAccessRequest }); })); app.post("/api/internal/engine/role-access-requests/list", 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 Engine role access request list" }); const requesterPayload = typeof req.body?.requester === "object" && req.body.requester !== null ? req.body.requester : {}; const requester = findInternalAccessUser(snapshot.data, { subject: requesterPayload.subject, email: requesterPayload.email, userId: requesterPayload.userId, }); if (!requester || requester.globalStatus !== "active") { res.status(404).json({ ok: false, error: "requester_not_found" }); return; } const requesterEmail = String(requester.email || "").toLowerCase(); const requesterGroups = Array.isArray(requesterPayload.groups) ? requesterPayload.groups.map((group) => String(group || "").trim()).filter(Boolean) : []; const canReviewEngineRoleRequests = requester.id === "user_root" || isLauncherAdmin(requesterGroups); const requests = snapshot.data.engineWorkflowAccessRequests .filter((request) => { if (request.requestType !== "service_role" || request.status === "cancelled") return false; const isRelated = request.requesterUserId === requester.id || request.targetUserId === requester.id || String(request.requesterEmail || "").toLowerCase() === requesterEmail || String(request.targetEmail || "").toLowerCase() === requesterEmail; if (canReviewEngineRoleRequests && request.status === "new") return true; return isRelated && request.status !== "new"; }) .sort((left, right) => String(right.updatedAt || right.createdAt).localeCompare(String(left.updatedAt || left.createdAt))); res.json({ ok: true, requests }); })); app.post("/api/internal/tasker/profile-sync", 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 profile sync" }); const user = findInternalAccessUser(snapshot.data, req.body); if (!user) { res.status(404).json({ ok: false, error: "user_not_found" }); return; } const patch = sanitizeTaskerProfilePatch(req.body); if (Object.keys(patch).length === 0) { res.json({ ok: true, user, data: snapshot.data, skipped: true }); return; } const actor = { sub: "tasker-profile-sync", name: req.body?.source === "tasker" ? "Operational Core" : "NODE.DC profile sync", email: typeof req.body?.email === "string" ? req.body.email : user.email, source: "tasker", }; const result = await controlPlaneStore.updateUserProfile(user.id, patch, actor); const syncResult = await syncUsersToAuthentik(result.data, [user.id], actor); const updatedUser = syncResult.data.users.find((candidate) => candidate.id === user.id) ?? result.user; publishControlPlaneEvent("tasker.profile.updated", [user.id]); res.json({ ok: true, user: updatedUser, data: syncResult.data }); })); 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, data: scopeSessionControlPlaneData(req, storeResult.data, actor.id), 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: scopeSessionControlPlaneData(req, result.data, actor.id), 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; } res.json(scopeSessionControlPlaneData(req, 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) || !assertAdminCanManageAccessForUser(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 requestedRole = normalizeTaskManagerRole(req.body?.role); if (requestedRole === "admin") { res.status(403).json({ ok: false, error: "task_manager_admin_role_locked", message: "Admin-роль Operational Core назначается только системно.", }); return; } const role = requestedRole ?? 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) || !assertAdminCanManageAccessForUser(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) || !assertAdminCanManageAccessForUser(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; } if (role === "admin") { res.status(403).json({ ok: false, error: "task_manager_admin_role_locked", message: "Admin-роль Operational Core назначается только системно.", }); 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) || !assertAdminCanManageAccessForUser(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; } await sendAdminIdempotentJson(req, res, async () => { const beforeSnapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user); const beforeUser = beforeSnapshot.data.users.find((candidate) => candidate.id === req.params.userId) ?? null; 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); const taskManagerCleanup = beforeUser?.globalStatus === "active" && updatedUser?.globalStatus === "blocked" ? await cleanupTaskManagerUserAccess(updatedUser, { source: "launcher-user-blocked", revokeIdentityLinks: false, revokeTaskerAccess: true, }) : null; publishControlPlaneEvent("admin.user.updated", syncResult.userIds); return { ...scopeAdminMutationResult(req, { ...result, data: syncResult.data }), taskManagerProfile, taskManagerCleanup }; }); })); 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, { source: "launcher-user-hard-delete", revokeIdentityLinks: true, revokeTaskerAccess: true, }); 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; } await sendAdminIdempotentJson(req, res, async () => { 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); return 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] : []); if (result.user) { publishNotificationEvent({ type: "nodedc.access_request.approved", sourceService: "launcher", actor: { userId: req.nodedcSession.user?.id, email: req.nodedcSession.user?.email }, subject: { type: "access_request", id: result.accessRequest.id }, payload: { accessRequestId: result.accessRequest.id, userId: result.user.id, email: result.user.email, status: result.accessRequest.status, }, idempotencyKey: `launcher:access-request:${result.accessRequest.id}:approved:${result.accessRequest.updatedAt}`, deliveries: [ notificationDeliveryForUser(result.user, "hub", { ...buildAccessRequestHubNotification(result.accessRequest), entityType: "access_request", entityId: result.accessRequest.id, }), ].filter(Boolean), }); } 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"); const targetUser = result.data.users.find((user) => user.email.toLowerCase() === result.accessRequest.email.toLowerCase()) ?? null; if (targetUser) { publishNotificationEvent({ type: "nodedc.access_request.rejected", sourceService: "launcher", actor: { userId: req.nodedcSession.user?.id, email: req.nodedcSession.user?.email }, subject: { type: "access_request", id: result.accessRequest.id }, payload: { accessRequestId: result.accessRequest.id, userId: targetUser.id, email: targetUser.email, status: result.accessRequest.status, }, idempotencyKey: `launcher:access-request:${result.accessRequest.id}:rejected:${result.accessRequest.updatedAt}`, deliveries: [ notificationDeliveryForUser(targetUser, "hub", { ...buildAccessRequestHubNotification(result.accessRequest), entityType: "access_request", entityId: result.accessRequest.id, }), ].filter(Boolean), }); } 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]); publishNotificationEvent({ type: "tasker.workspace_invite.approved", sourceService: "tasker", actor: { userId: req.nodedcSession.user?.id, email: req.nodedcSession.user?.email }, subject: { type: "tasker_invite_request", id: result.taskerInviteRequest.id }, payload: { requestId: result.taskerInviteRequest.id, workspaceSlug: result.taskerInviteRequest.workspaceSlug, workspaceName: result.taskerInviteRequest.workspaceName, inviteeEmail: result.taskerInviteRequest.inviteeEmail, status: result.taskerInviteRequest.status, }, idempotencyKey: `launcher:tasker-invite:${result.taskerInviteRequest.id}:approved:${result.taskerInviteRequest.updatedAt}`, deliveries: notificationUsersForTaskerInvite(result.data, result.taskerInviteRequest).map((user) => notificationDeliveryForUser(user, "hub", { ...buildTaskerInviteHubNotification(result.taskerInviteRequest), entityType: "tasker_invite_request", entityId: result.taskerInviteRequest.id, }) ).filter(Boolean), }); 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]); publishNotificationEvent({ type: "tasker.workspace_invite.rejected", sourceService: "tasker", actor: { userId: req.nodedcSession.user?.id, email: req.nodedcSession.user?.email }, subject: { type: "tasker_invite_request", id: result.taskerInviteRequest.id }, payload: { requestId: result.taskerInviteRequest.id, workspaceSlug: result.taskerInviteRequest.workspaceSlug, workspaceName: result.taskerInviteRequest.workspaceName, inviteeEmail: result.taskerInviteRequest.inviteeEmail, status: result.taskerInviteRequest.status, }, idempotencyKey: `launcher:tasker-invite:${result.taskerInviteRequest.id}:rejected:${result.taskerInviteRequest.updatedAt}`, deliveries: notificationUsersForTaskerInvite(result.data, result.taskerInviteRequest).map((user) => notificationDeliveryForUser(user, "hub", { ...buildTaskerInviteHubNotification(result.taskerInviteRequest), entityType: "tasker_invite_request", entityId: result.taskerInviteRequest.id, }) ).filter(Boolean), }); res.json(scopeAdminMutationResult(req, { ...result, tasker: taskerResult })); })); app.post("/api/admin/engine-workflow-access-requests/:engineWorkflowAccessRequestId/approve", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => { const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user); const request = snapshot.data.engineWorkflowAccessRequests.find( (candidate) => candidate.id === req.params.engineWorkflowAccessRequestId ); if (!request) { res.status(404).json({ error: "engine_workflow_access_request_not_found" }); return; } if (request.requestType === "service_role") { const result = await controlPlaneStore.approveEngineWorkflowAccessRequest( req.params.engineWorkflowAccessRequestId, { serviceRole: req.body?.serviceRole ?? req.body?.requestedServiceRole ?? req.body?.role, comment: req.body?.comment, }, req.nodedcSession.user ); const syncResult = await syncUsersToAuthentik(result.data, [result.targetUser.id], req.nodedcSession.user); publishControlPlaneEvent("admin.engine-role-access-request.approved", [ result.engineWorkflowAccessRequest.requesterUserId, result.targetUser.id, ]); publishNotificationEvent({ type: "engine.role_upgrade.approved", sourceService: "launcher", actor: { userId: req.nodedcSession.user?.id, email: req.nodedcSession.user?.email }, subject: { type: "engine_role_access_request", id: result.engineWorkflowAccessRequest.id }, payload: { requestId: result.engineWorkflowAccessRequest.id, targetUserId: result.targetUser.id, targetEmail: result.targetUser.email, serviceRole: result.engineWorkflowAccessRequest.requestedServiceRole, }, idempotencyKey: `launcher:engine-role-request:${result.engineWorkflowAccessRequest.id}:approved:${result.engineWorkflowAccessRequest.updatedAt}`, deliveries: [ notificationDeliveryForUser(result.targetUser, "hub", { ...buildEngineRoleHubNotification(result.engineWorkflowAccessRequest), entityType: "engine_role_access_request", entityId: result.engineWorkflowAccessRequest.id, }), notificationDeliveryForUser(result.targetUser, "engine", { ...buildEngineSurfaceNotification(result.engineWorkflowAccessRequest), entityType: "engine_role_access_request", entityId: result.engineWorkflowAccessRequest.id, }), ].filter(Boolean), }); res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data })); return; } const engineResult = await requestEngineInternalJson(`/api/internal/workflows/${encodeURIComponent(request.workflowId)}/share/users`, { body: { email: request.targetEmail, role: request.role, requestId: request.id, approvedBy: { userId: req.nodedcSession.user?.id, email: req.nodedcSession.user?.email, name: req.nodedcSession.user?.name, }, }, }); const result = await controlPlaneStore.approveEngineWorkflowAccessRequest( req.params.engineWorkflowAccessRequestId, { engineAppliedAt: engineResult.updatedAt ?? new Date().toISOString(), comment: req.body?.comment }, req.nodedcSession.user ); const syncResult = await syncUsersToAuthentik(result.data, [result.targetUser.id], req.nodedcSession.user); publishControlPlaneEvent("admin.engine-workflow-access-request.approved", [ result.engineWorkflowAccessRequest.requesterUserId, result.targetUser.id, ]); publishNotificationEvent({ type: "engine.workflow_share.approved", sourceService: "launcher", actor: { userId: req.nodedcSession.user?.id, email: req.nodedcSession.user?.email }, subject: { type: "engine_workflow_access_request", id: result.engineWorkflowAccessRequest.id }, payload: { requestId: result.engineWorkflowAccessRequest.id, workflowId: result.engineWorkflowAccessRequest.workflowId, workflowName: result.engineWorkflowAccessRequest.workflowName, targetUserId: result.targetUser.id, targetEmail: result.targetUser.email, role: result.engineWorkflowAccessRequest.role, }, idempotencyKey: `launcher:engine-workflow-request:${result.engineWorkflowAccessRequest.id}:approved:${result.engineWorkflowAccessRequest.updatedAt}`, deliveries: [ notificationDeliveryForUser(result.targetUser, "hub", { ...buildEngineWorkflowHubNotification(result.engineWorkflowAccessRequest), entityType: "engine_workflow_access_request", entityId: result.engineWorkflowAccessRequest.id, }), notificationDeliveryForUser(result.targetUser, "engine", { ...buildEngineSurfaceNotification(result.engineWorkflowAccessRequest), entityType: "engine_workflow_access_request", entityId: result.engineWorkflowAccessRequest.id, }), ].filter(Boolean), }); res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data, engine: engineResult })); })); app.post("/api/admin/engine-workflow-access-requests/:engineWorkflowAccessRequestId/reject", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => { const result = await controlPlaneStore.rejectEngineWorkflowAccessRequest( req.params.engineWorkflowAccessRequestId, req.body, req.nodedcSession.user ); publishControlPlaneEvent("admin.engine-workflow-access-request.rejected", [ result.engineWorkflowAccessRequest.requesterUserId, ]); const targetUser = result.data.users.find((user) => user.id === result.engineWorkflowAccessRequest.targetUserId) ?? null; if (targetUser) { const isServiceRoleRequest = result.engineWorkflowAccessRequest.requestType === "service_role"; publishNotificationEvent({ type: isServiceRoleRequest ? "engine.role_upgrade.rejected" : "engine.workflow_share.rejected", sourceService: "launcher", actor: { userId: req.nodedcSession.user?.id, email: req.nodedcSession.user?.email }, subject: { type: isServiceRoleRequest ? "engine_role_access_request" : "engine_workflow_access_request", id: result.engineWorkflowAccessRequest.id, }, payload: { requestId: result.engineWorkflowAccessRequest.id, targetUserId: targetUser.id, targetEmail: targetUser.email, workflowId: result.engineWorkflowAccessRequest.workflowId, workflowName: result.engineWorkflowAccessRequest.workflowName, }, idempotencyKey: `launcher:engine-request:${result.engineWorkflowAccessRequest.id}:rejected:${result.engineWorkflowAccessRequest.updatedAt}`, deliveries: [ notificationDeliveryForUser(targetUser, "hub", { ...(isServiceRoleRequest ? buildEngineRoleHubNotification(result.engineWorkflowAccessRequest) : buildEngineWorkflowHubNotification(result.engineWorkflowAccessRequest)), entityType: isServiceRoleRequest ? "engine_role_access_request" : "engine_workflow_access_request", entityId: result.engineWorkflowAccessRequest.id, }), notificationDeliveryForUser(targetUser, "engine", { ...buildEngineSurfaceNotification(result.engineWorkflowAccessRequest), entityType: isServiceRoleRequest ? "engine_role_access_request" : "engine_workflow_access_request", entityId: result.engineWorkflowAccessRequest.id, }), ].filter(Boolean), }); } res.json(scopeAdminMutationResult(req, result)); })); app.post("/api/admin/groups", requireLauncherAdmin, asyncRoute(async (req, res) => { if (!assertAdminCanManageClient(req, res, req.body?.clientId)) { return; } if (!assertAdminCanManageProtectedGroupMembers(req, res, [], req.body?.memberIds)) { 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; if (!assertAdminCanManageProtectedGroupMembers(req, res, previousMemberIds, req.body?.memberIds)) { return; } 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; } if (!assertAdminCanManageProtectedGroupMembers(req, res, group.memberIds, [])) { 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 (req.body?.targetType === "user" && isProtectedLauncherUser(req.body?.targetId)) { res.status(403).json({ ok: false, error: "protected_user_access_locked", message: "Доступ системного root-пользователя зафиксирован платформой.", }); return; } if (!assertAdminCanManageGrantTarget(req, res, snapshot.data, req.body?.targetType, req.body?.targetId, { allowProtectedSelf: true })) { return; } const service = snapshot.data.services.find((candidate) => candidate.id === req.body?.serviceId); if (isTaskManagerService(service) && (req.body?.appRole === "admin" || req.body?.appRole === "owner")) { res.status(403).json({ ok: false, error: "task_manager_admin_role_locked", message: "Admin-роль Operational Core назначается только системно.", }); 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 (!assertAdminCanManageAccessForUser(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 (!assertAdminCanManageAccessForUser(req, res, req.body?.userId)) { return; } const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user); const service = snapshot.data.services.find((candidate) => candidate.id === req.body?.serviceId); if (isTaskManagerService(service) && (req.body?.value === "admin" || req.body?.value === "owner")) { res.status(403).json({ ok: false, error: "task_manager_admin_role_locked", message: "Admin-роль Operational Core назначается только системно.", }); return; } if (isPublicTaskManagerGuestServiceAssignment(snapshot.data, req.body)) { res.status(400).json({ ok: false, error: "task_manager_public_guest_not_assignable", message: "Workspace Guest выдаётся только через настройки Operational Core.", }); 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", result.affectedUserIds?.length ? result.affectedUserIds : syncResult.userIds ); if (result.engineWorkflowAccessRequest) { const targetUser = syncResult.data.users.find((user) => user.id === req.body?.userId) ?? null; if (targetUser) { publishNotificationEvent({ type: "engine.role.changed", sourceService: "launcher", actor: { userId: req.nodedcSession.user?.id, email: req.nodedcSession.user?.email }, subject: { type: "engine_role_change", id: result.engineWorkflowAccessRequest.id }, payload: { requestId: result.engineWorkflowAccessRequest.id, targetUserId: targetUser.id, targetEmail: targetUser.email, serviceRole: result.engineWorkflowAccessRequest.requestedServiceRole, currentServiceRole: result.engineWorkflowAccessRequest.currentServiceRole, }, idempotencyKey: `launcher:engine-role-change:${result.engineWorkflowAccessRequest.id}`, deliveries: [ notificationDeliveryForUser(targetUser, "hub", { ...buildEngineRoleHubNotification(result.engineWorkflowAccessRequest), entityType: "engine_role_change", entityId: result.engineWorkflowAccessRequest.id, }), notificationDeliveryForUser(targetUser, "engine", { ...buildEngineSurfaceNotification(result.engineWorkflowAccessRequest), entityType: "engine_role_change", entityId: result.engineWorkflowAccessRequest.id, }), ].filter(Boolean), }); } } res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data })); })); app.post("/api/admin/access/service-modules", requireLauncherAdmin, asyncRoute(async (req, res) => { if (!assertAdminCanManageClient(req, res, req.body?.clientId) || !assertAdminCanManageAccessForUser(req, res, req.body?.userId)) { return; } const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user); const service = snapshot.data.services.find((candidate) => candidate.id === req.body?.serviceId); if (!service) { res.status(404).json({ ok: false, error: "service_not_found" }); return; } const result = await controlPlaneStore.setServiceModuleEntitlement(req.body, req.nodedcSession.user); publishControlPlaneEvent("admin.access.service-module.updated", [req.body?.userId]); res.json(scopeAdminMutationResult(req, result)); })); app.post("/api/admin/sync/:syncId/retry", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => { const retryResult = await controlPlaneStore.retrySync(req.params.syncId, req.nodedcSession.user); const affectedUserIds = resolveSyncStatusAffectedUserIds(retryResult.data, retryResult.syncStatus); if (!authentikSyncClient.isConfigured()) { const result = await controlPlaneStore.updateSyncStatus( req.params.syncId, { state: "error", error: "Authentik API не настроен на сервере launcher." }, req.nodedcSession.user ); publishControlPlaneEvent("admin.sync.retry.failed"); res.json(scopeAdminMutationResult(req, { ...result, ok: false, message: result.syncStatus.error })); return; } try { const syncResult = affectedUserIds.length ? await syncUsersToAuthentik(retryResult.data, affectedUserIds, req.nodedcSession.user) : { data: retryResult.data, userIds: [] }; const result = await controlPlaneStore.updateSyncStatus( req.params.syncId, { state: "synced", error: null }, req.nodedcSession.user ); publishControlPlaneEvent("admin.sync.retry", syncResult.userIds); res.json(scopeAdminMutationResult(req, { ...result, ok: true, syncedUserIds: syncResult.userIds })); } catch (error) { const message = error instanceof Error ? error.message : "Не удалось выполнить синхронизацию Authentik"; const result = await controlPlaneStore.updateSyncStatus( req.params.syncId, { state: "error", error: message }, req.nodedcSession.user ); publishControlPlaneEvent("admin.sync.retry.failed"); res.json(scopeAdminMutationResult(req, { ...result, ok: false, message })); } })); app.get("/api/admin/sync/authentik/health", requireLauncherAdmin, requireRootLauncherAdmin, (req, res) => { const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user); res.json(buildAuthentikSyncHealth(snapshot.data)); }); 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" }); }); for (const uploadRoot of getReadableUploadRoots()) { app.use( "/storage/uploads", express.static(uploadRoot, { fallthrough: true, immutable: true, maxAge: "1y", setHeaders(res) { res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); }, }) ); } let fixFrontendStacktrace = () => {}; if (process.env.NODE_ENV === "production") { const distRoot = join(projectRoot, "dist"); const indexHtmlPath = join(distRoot, "index.html"); if (!existsSync(indexHtmlPath)) { throw new Error("Launcher production build is missing. Run npm run build before starting the server."); } app.use(express.static(distRoot, { index: false, immutable: true, maxAge: "1y", setHeaders(res, assetPath) { if (assetPath === indexHtmlPath) { res.setHeader("Cache-Control", noStoreCacheControl); return; } res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); }, })); app.use((req, res, next) => { if (req.method !== "GET" && req.method !== "HEAD") { next(); return; } const accept = typeof req.headers.accept === "string" ? req.headers.accept : ""; if (!accept.includes("text/html")) { next(); return; } setNoStore(res); res.sendFile(indexHtmlPath); }); } else { const { createServer: createViteServer } = await import("vite"); const vite = await createViteServer({ root: projectRoot, appType: "spa", server: { middlewareMode: true, hmr: { server: httpServer }, }, }); fixFrontendStacktrace = (error) => vite.ssrFixStacktrace(error); app.use(vite.middlewares); } app.use((error, _req, res, _next) => { fixFrontendStacktrace(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/`, engineBaseUrl: process.env.NODEDC_ENGINE_INTERNAL_URL ?? process.env.NODEDC_ENGINE_BASE_URL ?? process.env.ENGINE_BASE_URL ?? "https://engine.nodedc.ru", notificationCoreBaseUrl: process.env.NODEDC_NOTIFICATION_CORE_URL ?? process.env.NOTIFICATION_CORE_URL ?? "", }; } 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 sanitizeTaskerProfilePatch(payload) { const patch = {}; const changedFields = Array.isArray(payload?.changedFields) ? payload.changedFields : []; const taskerFullName = joinTaskerProfileName( payload?.firstName ?? payload?.first_name, payload?.lastName ?? payload?.last_name ); const taskerDisplayName = firstNonEmptyString(payload?.displayName, payload?.display_name, payload?.name); const name = hasChangedField(changedFields, ["display_name"]) || !hasChangedField(changedFields, ["first_name", "last_name"]) ? firstNonEmptyString(taskerDisplayName, taskerFullName) : firstNonEmptyString(taskerFullName, taskerDisplayName); const hasAvatar = Object.hasOwn(payload ?? {}, "avatarUrl") || Object.hasOwn(payload ?? {}, "avatar_url") || Object.hasOwn(payload ?? {}, "avatar"); if (name) { patch.name = name; } if (hasAvatar) { patch.avatarUrl = nullableProfileUrl(payload?.avatarUrl ?? payload?.avatar_url ?? payload?.avatar); } return patch; } function firstNonEmptyString(...values) { for (const value of values) { if (typeof value === "string" && value.trim()) return value.trim(); } return null; } function joinTaskerProfileName(firstName, lastName) { return [firstName, lastName].filter((value) => typeof value === "string" && value.trim()).join(" ").trim(); } function splitTaskerProfileName(name) { const trimmedName = typeof name === "string" ? name.trim() : ""; const parts = trimmedName.split(/\s+/, 2); return { firstName: parts[0] ?? "", lastName: parts.length > 1 ? trimmedName.slice(parts[0].length).trim() : "", }; } function hasChangedField(changedFields, fields) { return fields.some((field) => changedFields.includes(field)); } function nullableProfileUrl(value) { return typeof value === "string" && value.trim() ? value.trim() : null; } 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 buildAuthentikSyncHealth(data) { const rows = data.syncStatuses.filter((syncStatus) => syncStatus.target === "authentik"); const errors = rows.filter((syncStatus) => syncStatus.state === "error"); const pending = rows.filter((syncStatus) => syncStatus.state === "pending"); const freshPending = pending.filter(isFreshPendingSyncStatus); const stalePending = pending.filter((syncStatus) => !isFreshPendingSyncStatus(syncStatus)); const state = !authentikSyncClient.isConfigured() ? "not_configured" : errors.length > 0 ? "error" : freshPending.length > 0 ? "pending" : "ok"; return { configured: authentikSyncClient.isConfigured(), state, checkedAt: new Date().toISOString(), counts: { total: rows.length, synced: rows.filter((syncStatus) => syncStatus.state === "synced").length, pending: pending.length, freshPending: freshPending.length, stalePending: stalePending.length, errors: errors.length, disabled: rows.filter((syncStatus) => syncStatus.state === "disabled").length, }, }; } function isFreshPendingSyncStatus(syncStatus) { if (syncStatus.state !== "pending") return false; const timestamp = Date.parse(syncStatus.updatedAt ?? syncStatus.lastSyncAt ?? ""); if (!Number.isFinite(timestamp)) return false; return Date.now() - timestamp <= 5 * 60 * 1000; } function resolveSyncStatusAffectedUserIds(data, syncStatus) { if (!syncStatus || syncStatus.target !== "authentik") { return []; } if (syncStatus.objectType === "user") { return data.users.some((user) => user.id === syncStatus.objectId) ? [syncStatus.objectId] : []; } if (syncStatus.objectType === "group") { return data.groups.find((group) => group.id === syncStatus.objectId)?.memberIds ?? []; } if (syncStatus.objectType === "client") { return data.memberships.filter((membership) => membership.clientId === syncStatus.objectId).map((membership) => membership.userId); } if (syncStatus.objectType === "service") { return resolveServiceSyncUserIds(data, syncStatus.objectId); } if (syncStatus.objectType === "grant") { return resolveGrantSyncUserIds(data, syncStatus); } if (syncStatus.objectType === "invite") { const invite = data.invites.find((candidate) => candidate.id === syncStatus.objectId); return invite ? resolveUserIdsByEmail(data, invite.email) : resolveUserIdsByEmail(data, syncStatus.objectName); } return []; } function resolveServiceSyncUserIds(data, serviceId) { const userIds = new Set(); for (const grant of data.grants.filter((candidate) => candidate.serviceId === serviceId && candidate.status === "active")) { for (const userId of resolveGrantTargetUserIds(data, grant.targetType, grant.targetId)) { userIds.add(userId); } } for (const exception of data.exceptions.filter((candidate) => candidate.serviceId === serviceId)) { userIds.add(exception.userId); } return [...userIds]; } function resolveGrantSyncUserIds(data, syncStatus) { const existingGrant = data.grants.find((grant) => grant.id === syncStatus.objectId); if (existingGrant) { return resolveGrantTargetUserIds(data, existingGrant.targetType, existingGrant.targetId); } const [serviceId, userId] = String(syncStatus.objectId).split(":"); if (serviceId && userId && data.users.some((user) => user.id === userId)) { return [userId]; } return resolveUserIdsByEmail(data, syncStatus.objectName); } function resolveUserIdsByEmail(data, value) { const normalizedValue = String(value ?? "").toLowerCase(); if (!normalizedValue) return []; return data.users .filter((user) => normalizedValue.includes(String(user.email ?? "").toLowerCase())) .map((user) => user.id); } 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 ? "Доступ подтверждён" : "Нет доступа", }; }); } function getAppCatalog() { const launcherData = readLauncherData(); const services = Array.isArray(launcherData?.services) ? launcherData.services : []; const serviceCatalog = services.map((service) => { const specialGroups = specialRequiredGroups(service); 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), launchTargetUrl: getServiceLaunchTargetUrl(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"), ]; } const engineRequiredGroups = [ "nodedc_admin", "nodedc_editor", "nodedc_viewer", ]; function isEngineServiceSlug(serviceSlug) { const slug = sanitizeServiceSlug(serviceSlug); return slug === "nodedc" || slug === "engine" || slug === "nodedc-engine"; } function isBimViewerServiceSlug(serviceSlug) { const slug = sanitizeServiceSlug(serviceSlug); return slug === "bim-viewer" || slug === "beam-viewer" || slug === "bim"; } function specialRequiredGroups(serviceOrSlug) { const slug = typeof serviceOrSlug === "string" ? serviceOrSlug : serviceOrSlug?.slug; const applicationSlug = typeof serviceOrSlug === "string" ? null : serviceOrSlug?.authentikApplicationSlug; const serviceId = typeof serviceOrSlug === "string" ? null : serviceOrSlug?.id; if (slug === "launcher") return ["nodedc:launcher:admin", "nodedc:launcher:user"]; if (slug === "task-manager") return ["nodedc:taskmanager:admin", "nodedc:taskmanager:user"]; if (slug === "nodedc" || slug === "engine" || slug === "nodedc-engine" || applicationSlug === "nodedc-engine" || serviceId === "service_nodedc") { return engineRequiredGroups; } return []; } function getServiceUrl(service) { if (service.slug === "task-manager" || isEngineServiceSlug(service.slug) || isBimViewerServiceSlug(service.slug)) { return `/api/services/${encodeURIComponent(service.slug)}/launch`; } return service.launchUrl || service.url || "#"; } function getServiceLaunchTargetUrl(service) { if (isEngineServiceSlug(service?.slug)) { return service.launchUrl || service.url || getEnginePublicBaseUrl(); } 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(/\/$/, ""); } function getBimViewerBaseUrl(app = null) { return String( process.env.NODEDC_BIM_VIEWER_PUBLIC_URL || process.env.BIM_VIEWER_BASE_URL || app?.launchTargetUrl || app?.openUrl || app?.url || "http://localhost:8080" ).replace(/\/$/, ""); } function findServiceCatalogAppBySlug(serviceSlug) { const slug = sanitizeServiceSlug(serviceSlug); try { const snapshot = controlPlaneStore.getSnapshot({ name: "NODE.DC optional service fallback" }); return snapshot.data.services.find((candidate) => sanitizeServiceSlug(candidate.slug) === slug) || null; } catch { return null; } } function buildOptionalServiceFallbackUrl(serviceSlug, app, fallbackPath) { const path = sanitizeReturnTo(fallbackPath || "/"); const serviceApp = app || findServiceCatalogAppBySlug(serviceSlug); let baseUrl = config.appBaseUrl; if (isBimViewerServiceSlug(serviceSlug)) { baseUrl = getBimViewerBaseUrl(serviceApp); } else if (isEngineServiceSlug(serviceSlug)) { baseUrl = getEngineLaunchTargetUrl(serviceApp); } else if (serviceSlug === "task-manager") { baseUrl = getTaskBaseUrl(); } else if (serviceApp?.openUrl || serviceApp?.url) { baseUrl = serviceApp.openUrl || serviceApp.url; } try { return new URL(path, String(baseUrl || config.appBaseUrl).replace(/\/$/, "")).toString(); } catch { return new URL("/", config.appBaseUrl).toString(); } } function redirectToServiceLaunchTarget(res, serviceSlug, runtimeContext, app, returnTo = "/") { if (isEngineServiceSlug(serviceSlug)) { const handoffToken = createServiceHandoff("nodedc", runtimeContext.user); const targetUrl = new URL("/auth/nodedc/handoff/", getEngineLaunchTargetUrl(app)); const nextPath = sanitizeReturnTo(returnTo); targetUrl.searchParams.set("token", handoffToken); if (nextPath && nextPath !== "/") { targetUrl.searchParams.set("next_path", nextPath); } res.redirect(targetUrl.toString()); return; } if (isBimViewerServiceSlug(serviceSlug)) { const handoffToken = createServiceHandoff(serviceSlug, runtimeContext.user); const targetUrl = new URL("/auth/nodedc/handoff", getBimViewerBaseUrl(app)); const nextPath = sanitizeReturnTo(returnTo); targetUrl.searchParams.set("token", handoffToken); if (nextPath && nextPath !== "/") { targetUrl.searchParams.set("next_path", nextPath); } res.redirect(targetUrl.toString()); return; } if (serviceSlug !== "task-manager") { res.redirect(app.openUrl || app.url || "/"); return; } const handoffToken = createServiceHandoff(serviceSlug, runtimeContext.user); const targetUrl = new URL("/auth/nodedc/handoff/", getTaskBaseUrl()); const nextPath = sanitizeReturnTo(returnTo); targetUrl.searchParams.set("token", handoffToken); if (nextPath && nextPath !== "/") { targetUrl.searchParams.set("next_path", nextPath); } res.redirect(targetUrl.toString()); } function getEnginePublicBaseUrl() { return String( process.env.NODEDC_ENGINE_PUBLIC_URL || process.env.NODEDC_ENGINE_BASE_URL || process.env.ENGINE_BASE_URL || "https://engine.nodedc.ru" ).replace(/\/$/, ""); } function getEngineBaseUrl() { return String(config.engineBaseUrl || "https://engine.nodedc.ru").replace(/\/$/, ""); } function getEngineLaunchTargetUrl(service) { return String(service?.launchTargetUrl || getEnginePublicBaseUrl()).replace(/\/$/, ""); } function buildAssistantActionProxyCommand(body, context) { const source = isPlainRecord(body) ? body : {}; const sourceInput = isPlainRecord(source.input) ? source.input : source; const input = stripClientAssistantContext(sourceInput); const ownerContext = resolveAssistantActionOwnerContext(context, input); const phase = normalizeAssistantActionPhase(source.phase || source.mode); const confirmationToken = normalizeOptionalText(source.confirmationToken || sourceInput.confirmationToken || source.confirmation?.token); return { phase, input: { ...input, ...ownerContext, }, ...(confirmationToken ? { confirmationToken } : {}), }; } function stripClientAssistantContext(input) { const result = {}; const blockedKeys = new Set([ "actorUserId", "actorEmail", "actorSubject", "groups", "launcherGlobalRole", "launcherUserStatus", "globalStatus", "membershipRole", "membershipStatus", "assistantRole", "coreAssistantRole", "isRoot", "isSuperAdmin", "removesOwnAccess", "hasAlternativeAdminPath", ]); for (const [key, value] of Object.entries(isPlainRecord(input) ? input : {})) { if (blockedKeys.has(key)) continue; result[key] = value; } return result; } function resolveAssistantActionOwnerContext({ actor, data, groups }, input) { const groupSet = new Set(groups || []); const isRoot = groupSet.has("nodedc:superadmin") || groupSet.has("nodedc:launcher:admin"); const actorMembership = isRoot ? null : resolveAssistantActionActorMembership(data, actor, input); const assistantRole = isRoot ? "admin" : actorMembership?.coreAssistantRole || "member"; const membershipRole = isRoot ? "client_owner" : actorMembership?.role || "member"; const membershipStatus = isRoot ? "active" : actorMembership?.status || "disabled"; const selfTarget = isAssistantActionSelfTarget(data, actor, input); const removesOwnAccess = selfTarget && assistantActionRemovesOwnAccess(input); return { assistantRole, launcherGlobalRole: isRoot ? "root_admin" : membershipRole, launcherUserStatus: actor.globalStatus || "active", membershipRole, membershipStatus, removesOwnAccess, hasAlternativeAdminPath: removesOwnAccess ? hasAlternativeAssistantAdminPath(data, actor, groups, input) : false, }; } function resolveAssistantActionActorMembership(data, actor, input) { const activeMemberships = data.memberships.filter((membership) => membership.userId === actor.id && membership.status === "active"); const targetClientId = resolveAssistantActionTargetClientId(data, input); if (targetClientId) { const exact = activeMemberships.find((membership) => membership.clientId === targetClientId); if (exact) return exact; } return activeMemberships .slice() .sort((left, right) => assistantMembershipWeight(right) - assistantMembershipWeight(left))[0] || null; } function resolveAssistantActionTargetClientId(data, input) { const explicitClientId = normalizeOptionalText(input.clientId); if (explicitClientId) return explicitClientId; const targetMembershipId = normalizeOptionalText(input.membershipId); if (targetMembershipId) { return data.memberships.find((membership) => membership.id === targetMembershipId)?.clientId || null; } const targetUserId = normalizeOptionalText(input.targetUserId); if (targetUserId) { return data.memberships.find((membership) => membership.userId === targetUserId && membership.status === "active")?.clientId || null; } return null; } function isAssistantActionSelfTarget(data, actor, input) { const targetUserId = normalizeOptionalText(input.targetUserId); if (targetUserId && targetUserId === actor.id) return true; const targetMembershipId = normalizeOptionalText(input.membershipId); if (!targetMembershipId) return false; return data.memberships.some((membership) => membership.id === targetMembershipId && membership.userId === actor.id); } function assistantActionRemovesOwnAccess(input) { const actionId = normalizeOptionalText(input.actionId); if (actionId === "hub.user.block" || actionId === "hub.membership.disable") return true; if (actionId === "hub.assistant_access.change_role") { const targetAssistantRole = normalizeOptionalText(input.targetAssistantRole); return targetAssistantRole === "blocked" || targetAssistantRole === "member"; } if (actionId === "hub.membership.change_role") { return normalizeOptionalText(input.targetRole) === "member"; } return false; } function hasAlternativeAssistantAdminPath(data, actor, groups, input) { const groupSet = new Set(groups || []); if (groupSet.has("nodedc:superadmin") || groupSet.has("nodedc:launcher:admin")) { return normalizeOptionalText(input.actionId) !== "hub.user.block"; } const targetMembershipId = normalizeOptionalText(input.membershipId); return data.memberships.some( (membership) => membership.userId === actor.id && membership.id !== targetMembershipId && membership.status === "active" && membership.coreAssistantRole === "admin" && clientAdminMembershipRoles.has(membership.role) ); } function assistantMembershipWeight(membership) { const assistantWeight = membership.coreAssistantRole === "admin" ? 100 : membership.coreAssistantRole === "blocked" ? -100 : 0; const roleWeight = membership.role === "client_owner" ? 30 : membership.role === "client_admin" ? 20 : 10; return assistantWeight + roleWeight; } function normalizeAssistantActionPhase(value) { const phase = String(value || "preview").trim().toLowerCase().replace(/_/g, "-"); return ["preview", "dry-run", "execute"].includes(phase) ? phase : "preview"; } function getAiWorkspaceAssistantBaseUrl() { return String( process.env.NDC_AI_WORKSPACE_ASSISTANT_INTERNAL_URL || process.env.AI_WORKSPACE_ASSISTANT_INTERNAL_URL || process.env.NDC_AI_WORKSPACE_ASSISTANT_URL || process.env.AI_WORKSPACE_ASSISTANT_URL || "http://localhost:18082" ).replace(/\/$/, ""); } async function requestAiWorkspaceAssistantJson(pathname, init = {}) { if (!config.internalAccessToken) { throw new Error("NODE.DC internal access token is not configured"); } const targetUrl = new URL(pathname, `${getAiWorkspaceAssistantBaseUrl()}/`); const response = await fetch(targetUrl, { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json", Authorization: `Bearer ${config.internalAccessToken}`, "X-NODEDC-User-Id": init.actor?.id || "", "X-NODEDC-User-Email": init.actor?.email || "", "X-NODEDC-User-Role": init.role || "", "X-NODEDC-User-Groups": Array.isArray(init.groups) ? init.groups.join(",") : "", ...(init.headers ?? {}), }, body: JSON.stringify(init.body ?? {}), }); const text = await response.text(); const payload = text ? parseJsonResponse(text, targetUrl.toString()) : {}; if (!response.ok) { const error = typeof payload?.error === "string" ? payload.error : `AI Workspace Assistant API failed: ${response.status}`; throw new Error(error); } return payload; } 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 requestEngineInternalJson(pathname, init = {}) { if (!config.internalAccessToken) { throw new Error("NODE.DC internal access token is not configured"); } const targetUrl = new URL(pathname, `${getEngineBaseUrl()}/`); 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}`, "X-Authentik-Groups": "nodedc_admin", "X-Authentik-Email": "launcher-internal@nodedc.ru", "X-Authentik-Username": "launcher-internal@nodedc.ru", ...(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 : `Engine internal API failed: ${response.status}`; throw new Error(error); } return payload; } async function syncTaskManagerUserProfile(user) { if (!user?.email || !config.internalAccessToken) { return null; } const taskerNameParts = splitTaskerProfileName(user.name); try { return await requestTaskManagerInternalJson("/api/internal/nodedc/users/profile-sync/", { method: "POST", body: { email: user.email, subject: user.authentikUserId ?? undefined, name: user.name, displayName: user.name, firstName: taskerNameParts.firstName, lastName: taskerNameParts.lastName, 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, options = {}) { if (!user?.email || !config.internalAccessToken) { return null; } try { return await requestTaskManagerInternalJson("/api/internal/nodedc/logout/", { method: "POST", body: { source: normalizeOptionalText(options.source) ?? "launcher-user-access-revoked", subject: user.authentikUserId ?? undefined, email: user.email, revokeIdentityLinks: options.revokeIdentityLinks === true, revokeTaskerAccess: options.revokeTaskerAccess !== false, }, }); } 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 isPlainRecord(value) { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } function normalizeTaskManagerRole(value) { return value === "guest" || value === "admin" || value === "member" ? value : null; } function isPublicTaskManagerGuestServiceAssignment(data, payload) { if (payload?.value !== "viewer") return false; const service = data.services.find((candidate) => candidate.id === payload?.serviceId); if (!service || !isTaskManagerService(service)) return false; return data.memberships.some( (membership) => membership.userId === payload?.userId && membership.clientId === publicPoolClientId && membership.status === "active" ); } function isTaskManagerService(service) { return service?.slug === "task-manager" || service?.authentikApplicationSlug === "task-manager"; } 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, serviceModules = {}) { 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, serviceModules, canCreateWorkspace: false, reason: "Нет доступа к Operational Core.", }; } if (mode === "disabled") { return { mode, managedBy: defaultManagedBy, defaultManagedBy, inviteApproval: "disabled", defaultInviteApproval, workspaces, serviceModules, canCreateWorkspace: false, reason: "Создание рабочих пространств отключено на уровне платформы.", }; } if (hasLauncherManagedWorkspace && !isSuperAdmin && !isTaskManagerAdmin) { if (workspaceAssignment?.managedBy === "launcher") { return { mode, managedBy: "launcher", defaultManagedBy: "launcher", inviteApproval: "launcher", defaultInviteApproval: "launcher", workspaces, serviceModules, canCreateWorkspace: false, reason: "Рабочие пространства этого пользователя управляются через Launcher.", }; } if (workspaceSlug) { return { mode, managedBy: "tasker", defaultManagedBy: "launcher", inviteApproval: defaultInviteApproval, defaultInviteApproval, workspaces, serviceModules, canCreateWorkspace: false, reason: "Self-service workspace работает через Tasker, approve инвайтов выполняет Launcher.", }; } return { mode, managedBy: "launcher", defaultManagedBy: "launcher", inviteApproval: "launcher", defaultInviteApproval: "launcher", workspaces, serviceModules, canCreateWorkspace: false, reason: "Рабочие пространства этого пользователя управляются через Launcher.", }; } if (mode === "task_admins_only" && !isSuperAdmin && !isTaskManagerAdmin) { return { mode, managedBy: defaultManagedBy, defaultManagedBy, inviteApproval: defaultInviteApproval, defaultInviteApproval, workspaces, serviceModules, canCreateWorkspace: false, reason: "Создание рабочих пространств доступно только администраторам Operational Core.", }; } return { mode, managedBy: "tasker", defaultManagedBy: "tasker", inviteApproval: defaultInviteApproval, defaultInviteApproval, workspaces, serviceModules, canCreateWorkspace: true, reason: "Создание рабочих пространств разрешено платформенной policy.", }; } function resolveTaskManagerWorkspaceServiceModules(data, user, serviceSlug, workspaceSlug) { const normalizedWorkspaceSlug = normalizeOptionalText(workspaceSlug); if (!normalizedWorkspaceSlug) { return {}; } const workspaces = resolveTaskManagerWorkspaceAssignments(data, user); const workspaceAssignment = workspaces.find((workspace) => workspace.slug === normalizedWorkspaceSlug); const boundClientId = resolveTaskManagerWorkspaceClientId(data, normalizedWorkspaceSlug); const clientId = workspaceAssignment?.clientId ?? (boundClientId ? null : isPublicPoolUser(data, user) ? publicPoolClientId : null); return resolveUserServiceModules(data, user, serviceSlug, clientId); } function resolveTaskManagerWorkspaceClientId(data, workspaceSlug) { const normalizedWorkspaceSlug = normalizeOptionalText(workspaceSlug); if (!normalizedWorkspaceSlug) return null; const client = data.clients.find((candidate) => resolveTaskManagerWorkspaceBinding(candidate, normalizedWorkspaceSlug)); return client?.id ?? null; } function isPublicPoolUser(data, user) { return data.memberships.some( (membership) => membership.userId === user?.id && membership.clientId === publicPoolClientId && membership.status === "active" ); } function resolveUserServiceModules(data, user, serviceSlug, clientId) { if (!user?.id) return {}; const service = data.services.find( (candidate) => candidate.slug === serviceSlug || candidate.authentikApplicationSlug === serviceSlug ); if (!service?.id) return {}; if (hasSystemServiceModuleEntitlement(user, service, "codex_agents")) { return { codex_agents: true }; } return Object.fromEntries( (data.serviceModuleEntitlements ?? []) .filter( (entitlement) => entitlement.userId === user.id && entitlement.serviceId === service.id && entitlement.enabled ) .map((entitlement) => [entitlement.moduleId, true]) ); } function hasSystemServiceModuleEntitlement(user, service, moduleId) { return user?.id === "user_root" && moduleId === "codex_agents" && service?.slug === "task-manager"; } 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 ` NODE.DC `; } function renderSessionSyncBridgePage(allowedOrigins) { const allowedOriginsJson = JSON.stringify(allowedOrigins); return ` NODE.DC session sync `; } 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( getWritableUploadRoots().map(async (uploadRoot) => { await mkdir(uploadRoot, { recursive: true }); await writeFile(join(uploadRoot, storedName), fileBuffer); }) ); return { ok: true, url: `/storage/uploads/${storedName}`, fileName: storedName, originalFileName: payload.fileName, mimeType, }; } async function saveLauncherData(payload) { await controlPlaneStore.writeData(payload); } function getWritableUploadRoots() { return [runtimeUploadsRoot]; } function getReadableUploadRoots() { const roots = [ runtimeUploadsRoot, join(projectRoot, "public", "storage", "uploads"), ]; const distRoot = join(projectRoot, "dist"); if (existsSync(distRoot)) { roots.push(join(distRoot, "storage", "uploads")); } return [...new Set(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 resolveRuntimeStorageRoot(root) { const configuredRoot = process.env.NODEDC_LAUNCHER_STORAGE_DIR; if (configuredRoot && configuredRoot.trim()) { return isAbsolute(configuredRoot) ? configuredRoot : resolve(root, configuredRoot); } return join(root, "server", "storage"); } function resolveRuntimeUploadsRoot(root, storageRoot) { const configuredRoot = process.env.NODEDC_LAUNCHER_UPLOADS_DIR; if (configuredRoot && configuredRoot.trim()) { return isAbsolute(configuredRoot) ? configuredRoot : resolve(root, configuredRoot); } return join(storageRoot, "uploads"); } 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) { const assistantGatewayContext = resolveAssistantGatewayAdminContext(req); if (assistantGatewayContext?.ok) { req.nodedcSession = assistantGatewayContext.session; req.nodedcAdminScope = assistantGatewayContext.adminScope; req.nodedcAssistantGateway = assistantGatewayContext.gateway; next(); return; } if (assistantGatewayContext) { res.status(assistantGatewayContext.status).json({ ok: false, error: assistantGatewayContext.error, message: assistantGatewayContext.message, }); return; } 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 resolveAssistantGatewayAdminContext(req) { const gateway = readRequestHeader(req, "x-nodedc-assistant-gateway"); const actorUserId = readRequestHeader(req, "x-nodedc-assistant-actor-user-id"); const actorEmail = readRequestHeader(req, "x-nodedc-assistant-actor-email"); const actorSubject = readRequestHeader(req, "x-nodedc-assistant-actor-subject"); const hasGatewaySignal = Boolean(gateway || actorUserId || actorEmail || actorSubject); if (!hasGatewaySignal) { return null; } if (gateway !== assistantGatewayName) { return { ok: false, status: 401, error: "assistant_gateway_invalid", message: "Unknown assistant gateway.", }; } if (!isInternalRequestAuthorized(req)) { return { ok: false, status: config.internalAccessToken ? 401 : 503, error: config.internalAccessToken ? "assistant_gateway_unauthorized" : "assistant_gateway_not_configured", message: "Assistant gateway internal token is missing or invalid.", }; } if (!isAssistantGatewayAdminRoute(req)) { return { ok: false, status: 403, error: "assistant_gateway_route_not_allowlisted", message: "Assistant gateway can only call explicitly allowlisted admin routes.", }; } const snapshot = controlPlaneStore.getSnapshot({ name: "NDC Core Assistant Gateway", source: "assistant-gateway" }); const actor = findInternalAccessUser(snapshot.data, { userId: actorUserId, email: actorEmail, subject: actorSubject, }); if (!actor) { return { ok: false, status: 404, error: "assistant_gateway_actor_not_found", message: "Assistant gateway actor was not found in Launcher control-plane.", }; } if (actor.globalStatus === "blocked") { return { ok: false, status: 403, error: "assistant_gateway_actor_blocked", message: "Assistant gateway actor is blocked.", }; } const groups = resolveRequiredGroups(snapshot.data, actor); const user = buildAssistantGatewaySessionUser(actor, groups); const adminScope = resolveAdminScope(user, groups); if (!adminScope.isRoot && adminScope.clientIds.size === 0) { return { ok: false, status: 403, error: "assistant_gateway_actor_not_admin", message: "Assistant gateway actor has no Launcher admin scope.", }; } return { ok: true, session: { id: `assistant-gateway:${actor.id}`, expiresAt: Date.now() + 60_000, user, }, adminScope, gateway: { name: gateway, actorUserId: actor.id, }, }; } function isAssistantGatewayAdminRoute(req) { const method = String(req.method || "").toUpperCase(); const path = req.path || String(req.url || "").split("?")[0] || ""; return assistantGatewayAdminRouteAllowlist.some((route) => route.method === method && route.pattern.test(path)); } function buildAssistantGatewaySessionUser(actor, groups) { return { sub: actor.authentikUserId || `launcher:${actor.id}`, email: actor.email, name: actor.name, avatarUrl: actor.avatarUrl ?? null, groups, }; } function readRequestHeader(req, name) { const raw = req.headers[String(name).toLowerCase()]; const value = Array.isArray(raw) ? raw[0] : raw; return typeof value === "string" ? value.trim() : ""; } 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 canonicalEmail = canonicalInternalAccessEmail(email); 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) ?? data.users.find((user) => canonicalEmail && canonicalInternalAccessEmail(user.email) === canonicalEmail) ?? null ); } function canonicalInternalAccessEmail(value) { const email = String(value || "").trim().toLowerCase(); const [local, domain] = email.split("@"); if (!local || !domain) return email; if (domain === "gmail.com" || domain === "googlemail.com") { return `${local.replace(/\./g, "")}@gmail.com`; } return email; } 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 notificationSubjectFromUser(user) { return { userId: user?.id ?? null, email: typeof user?.email === "string" ? user.email.toLowerCase() : null, }; } function notificationDeliveryForUser(user, surface, notification) { if (!user?.id && !user?.email) return null; return { recipientUserId: user?.id ?? null, recipientEmail: typeof user?.email === "string" ? user.email.toLowerCase() : null, surface, title: notification.title, body: notification.body ?? "", meta: notification.meta ?? {}, actionable: notification.actionable === true, actionType: notification.actionType ?? null, actionUrl: notification.actionUrl ?? null, entityType: notification.entityType ?? null, entityId: notification.entityId ?? null, }; } function rootAdminNotificationDelivery(data, notification) { const rootUser = data.users.find((user) => user.id === "user_root") ?? data.users[0] ?? null; return notificationDeliveryForUser(rootUser, "hub", notification); } function notificationUsersForTaskerInvite(data, request) { const users = []; const seen = new Set(); const addUser = (user) => { if (!user?.id || seen.has(user.id)) return; seen.add(user.id); users.push(user); }; addUser(data.users.find((user) => user.id === request?.inviterUserId)); addUser(data.users.find((user) => normalizeNotificationEmail(user.email) === normalizeNotificationEmail(request?.inviterEmail))); addUser(data.users.find((user) => normalizeNotificationEmail(user.email) === normalizeNotificationEmail(request?.inviteeEmail))); return users; } function buildAccessRequestHubNotification(accessRequest) { const applicantName = [accessRequest?.lastName, accessRequest?.firstName].filter(Boolean).join(" ") || accessRequest?.email || "NODE.DC user"; const status = accessRequest?.status || "new"; return { title: status === "new" ? "Входящий запрос доступа" : "Запрос доступа обновлён", body: `${applicantName} · ${accessRequest?.email || ""}`, meta: { displayStatus: platformNotificationStatusLabel(status), meta: accessRequest?.company || formatNotificationDate(accessRequest?.createdAt), }, }; } function buildTaskerInviteHubNotification(request) { const status = request?.status || "new"; return { title: status === "new" ? "Запрос доступа Operational Core" : "Operational Core: заявка обновлена", body: `${request?.workspaceName || request?.workspaceSlug || "Operational Core"} · ${request?.inviteeEmail || ""}`, meta: { displayStatus: platformNotificationStatusLabel(status), meta: request?.inviterName || formatNotificationDate(request?.createdAt), }, }; } function buildEngineRoleHubNotification(request) { const target = request?.targetName || request?.targetEmail || "Engine user"; const status = request?.status || "new"; return { title: engineRoleHubNotificationTitle(request), body: engineRoleHubNotificationBody(request, target), meta: { displayStatus: platformNotificationStatusLabel(status), meta: `Запрошено: ${engineServiceRoleLabel(request?.requestedServiceRole)}`, requestId: request?.id, requestedServiceRole: request?.requestedServiceRole ?? null, currentServiceRole: request?.currentServiceRole ?? null, }, }; } function buildEngineWorkflowHubNotification(request) { const status = request?.status || "new"; return { title: status === "new" ? "Запрос доступа к Engine workflow" : "Engine: заявка обновлена", body: `${request?.workflowName || request?.workflowId || "Engine workflow"} · ${request?.targetEmail || ""}`, meta: { displayStatus: platformNotificationStatusLabel(status), meta: request?.requesterName || formatNotificationDate(request?.createdAt), requestId: request?.id, }, }; } function buildEngineSurfaceNotification(request) { if (request?.requestType === "service_role") { return { title: request?.workflowName || "NODE.DC Engine", body: engineRoleSurfaceNotificationBody(request), meta: { displayStatus: engineSurfaceStatusLabel(request), meta: engineSurfaceMeta(request), requestId: request?.id, requestedServiceRole: request?.requestedServiceRole ?? null, currentServiceRole: request?.currentServiceRole ?? null, }, }; } return { title: request?.workflowName || request?.workflowId || "Engine workflow", body: `${request?.requesterName || request?.requesterEmail || "NODE.DC Engine"} просит роль ${engineWorkflowRoleLabel(request?.role)}`, meta: { displayStatus: engineSurfaceStatusLabel(request), meta: engineSurfaceMeta(request), requestId: request?.id, }, }; } function engineRoleHubNotificationTitle(request) { if (request?.status === "approved") return request?.comment === "admin_role_changed" ? "Engine: роль изменена" : "Engine: роль обновлена"; if (request?.status === "rejected") return "Engine: повышение роли отклонено"; return "Engine: запрос повышения роли"; } function engineRoleHubNotificationBody(request, target) { if (request?.status === "rejected") return `${target} · администратор отклонил заявку`; if (request?.status === "approved") { return request?.comment === "admin_role_changed" ? `${target} · роль изменена администратором` : `${target} · новая роль применена`; } return `${target} · текущая роль ${engineServiceRoleLabel(request?.currentServiceRole)}`; } function engineRoleSurfaceNotificationBody(request) { const requestedRole = engineServiceRoleLabel(request?.requestedServiceRole); const requestedByTarget = normalizeNotificationEmail(request?.requesterEmail) === normalizeNotificationEmail(request?.targetEmail); if (request?.status === "approved") { return requestedByTarget && request?.comment !== "admin_role_changed" ? `Администратор подтвердил повышение роли: ${requestedRole}` : `Администратор изменил роль: ${requestedRole}`; } if (request?.status === "rejected") { return "Администратор отклонил заявку на повышение роли"; } return `${request?.requesterName || request?.requesterEmail || "NODE.DC Engine"} просит роль ${requestedRole}`; } function engineSurfaceStatusLabel(request) { if (request?.requestType === "service_role" && request?.status === "approved" && request?.comment === "admin_role_changed") { return "Изменено"; } if (request?.status === "approved") return "Одобрено"; if (request?.status === "rejected") return "Отклонено"; return "Новая заявка"; } function engineSurfaceMeta(request) { const createdAt = formatShortNotificationDate(request?.createdAt); return ["Ваш запрос", createdAt].filter(Boolean).join(" · "); } function engineServiceRoleLabel(role) { if (role === "member") return "Участник"; if (role === "viewer") return "Гость"; return "не назначена"; } function engineWorkflowRoleLabel(role) { if (role === "admin") return "Соавтор"; if (role === "editor") return "Редактор"; return "Просмотр"; } function platformNotificationStatusLabel(status) { const normalized = String(status || "").trim().toLowerCase(); if (normalized === "approved") return "Подтверждено"; if (normalized === "rejected") return "Отклонено"; if (normalized === "cancelled") return "Отменено"; return "Входящее"; } function formatNotificationDate(value) { const timestamp = Date.parse(String(value || "")); if (!Number.isFinite(timestamp)) return ""; return new Intl.DateTimeFormat("ru-RU", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit", }).format(new Date(timestamp)); } function formatShortNotificationDate(value) { const timestamp = Date.parse(String(value || "")); if (!Number.isFinite(timestamp)) return ""; return new Intl.DateTimeFormat("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit", }).format(new Date(timestamp)); } function normalizeNotificationEmail(value) { return String(value || "").trim().toLowerCase(); } function sanitizeNotificationSurface(value, fallback = "hub") { const surface = typeof value === "string" && value.trim() ? value.trim().toLowerCase() : fallback; if (surface === "hub" || surface === "engine" || surface === "operational-core") return surface; return fallback; } function publishNotificationEvent(command) { if (!notificationCoreClient.isConfigured()) { return; } if (!Array.isArray(command?.deliveries) || command.deliveries.length === 0) { return; } notificationCoreClient.createEvent(command).catch((error) => { console.warn("[notification-core] event publish failed:", error?.message || error); }); } 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 isProtectedLauncherUser(userId) { return protectedLauncherUserIds.has(userId); } function isProtectedSelfManageRequest(req, userId) { return isProtectedLauncherUser(userId) && req.nodedcAdminScope?.actorId === userId; } function canAdminManageUser(req, userId, options = {}) { if (isProtectedLauncherUser(userId) && !(options.allowProtectedSelf && isProtectedSelfManageRequest(req, 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, options = {}) { if (canAdminManageUser(req, userId, options)) { return true; } res.status(403).json({ error: "Недостаточно прав для управления этим пользователем" }); return false; } function assertAdminCanManageAccessForUser(req, res, userId) { return assertAdminCanManageUser(req, res, userId); } function assertAdminCanManageProtectedGroupMembers(req, res, previousMemberIds = [], nextMemberIds = []) { if (!Array.isArray(nextMemberIds)) { return true; } const previous = new Set(previousMemberIds); const next = new Set(nextMemberIds); const changedProtectedUserIds = [...protectedLauncherUserIds].filter((userId) => previous.has(userId) !== next.has(userId)); const blockedUserId = changedProtectedUserIds.find((userId) => !isProtectedSelfManageRequest(req, userId)); if (!blockedUserId) { return true; } res.status(403).json({ error: "protected_user_group_membership_locked", message: "Защищённого пользователя может добавлять в группы или удалять из групп только он сам.", }); return false; } function assertAdminCanManageMembership(req, res, membership, options = {}) { if (!assertAdminCanManageClient(req, res, membership.clientId)) { return false; } return assertAdminCanManageUser(req, res, membership.userId, options); } function assertAdminCanManageGrantTarget(req, res, data, targetType, targetId, options = {}) { if (req.nodedcAdminScope?.isRoot) { if (targetType === "client") { return true; } if (targetType === "user" && (!isProtectedLauncherUser(targetId) || isProtectedSelfManageRequest(req, targetId) || !options.allowProtectedSelf)) { 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; } const blockedProtectedUserId = group.memberIds.find((userId) => isProtectedLauncherUser(userId) && !isProtectedSelfManageRequest(req, userId)); if (blockedProtectedUserId) { res.status(403).json({ error: "protected_group_access_locked", message: "Гранты группы с защищённым пользователем может менять только сам защищённый пользователь.", }); return false; } return assertAdminCanManageClient(req, res, group.clientId); } if (targetType === "user") { return assertAdminCanManageUser(req, res, targetId, options); } 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 scopeSessionControlPlaneData(req, data, actorId) { const runtimeContext = getRuntimeSessionContext(req.nodedcSession); const adminScope = resolveAdminScope(runtimeContext.user, runtimeContext.groups); if (adminScope.isRoot || adminScope.clientIds.size > 0) { return scopeControlPlaneData(data, adminScope); } return scopeRuntimeControlPlaneData(data, actorId); } 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)); const userEmails = new Set( data.users .filter((user) => userIds.has(user.id)) .map((user) => String(user.email || "").toLowerCase()) .filter(Boolean) ); 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: [], engineWorkflowAccessRequests: data.engineWorkflowAccessRequests.filter((request) => userIds.has(request.requesterUserId) || userIds.has(request.targetUserId) || userEmails.has(String(request.requesterEmail || "").toLowerCase()) || userEmails.has(String(request.targetEmail || "").toLowerCase()) ), 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)), serviceModuleEntitlements: data.serviceModuleEntitlements.filter((entitlement) => userIds.has(entitlement.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: [], engineWorkflowAccessRequests: [], grants: [], exceptions: [], serviceModuleEntitlements: [], 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)); const userEmail = String(user.email || "").toLowerCase(); const relatedEngineRequests = data.engineWorkflowAccessRequests.filter((request) => request.requesterUserId === user.id || request.targetUserId === user.id || String(request.requesterEmail || "").toLowerCase() === userEmail || String(request.targetEmail || "").toLowerCase() === userEmail ); return { ...data, clients: data.clients.filter((client) => clientIds.has(client.id)), users: [user], memberships, groups, invites: [], accessRequests: [], revokedAccounts: [], taskerInviteRequests: [], engineWorkflowAccessRequests: relatedEngineRequests, 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), serviceModuleEntitlements: data.serviceModuleEntitlements.filter((entitlement) => entitlement.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}/`; } async function sendAdminIdempotentJson(req, res, operation) { const idempotencyKey = normalizeAdminMutationIdempotencyKey(req); if (idempotencyKey === false) { res.status(400).json({ ok: false, error: "idempotency_key_invalid", message: "Idempotency-Key must be 8-256 visible characters.", }); return; } if (!idempotencyKey) { res.json(await operation()); return; } pruneAdminMutationIdempotencyRecords(); const recordKey = buildAdminMutationIdempotencyRecordKey(req, idempotencyKey); const fingerprint = buildAdminMutationFingerprint(req); const existingRecord = adminMutationIdempotencyRecords.get(recordKey); if (existingRecord && existingRecord.fingerprint !== fingerprint) { res.status(409).json({ ok: false, error: "idempotency_key_conflict", message: "Idempotency-Key was already used for a different admin mutation.", }); return; } if (existingRecord?.status === "completed") { res.setHeader("Idempotency-Key", idempotencyKey); res.setHeader("Idempotency-Replayed", "true"); res.status(existingRecord.statusCode).json(existingRecord.body); return; } if (existingRecord?.status === "pending") { const response = await existingRecord.promise; res.setHeader("Idempotency-Key", idempotencyKey); res.setHeader("Idempotency-Replayed", "true"); res.status(response.statusCode).json(response.body); return; } const promise = Promise.resolve(operation()).then((body) => ({ statusCode: 200, body })); adminMutationIdempotencyRecords.set(recordKey, { status: "pending", createdAt: Date.now(), fingerprint, promise, }); try { const response = await promise; adminMutationIdempotencyRecords.set(recordKey, { status: "completed", createdAt: Date.now(), fingerprint, statusCode: response.statusCode, body: response.body, }); res.setHeader("Idempotency-Key", idempotencyKey); res.status(response.statusCode).json(response.body); } catch (error) { adminMutationIdempotencyRecords.delete(recordKey); throw error; } } function normalizeAdminMutationIdempotencyKey(req) { const rawHeader = req.headers["idempotency-key"]; const rawValue = Array.isArray(rawHeader) ? rawHeader[0] : rawHeader; if (rawValue === undefined || rawValue === null || rawValue === "") { return null; } const value = String(rawValue).trim(); if (!/^[\x21-\x7E]{8,256}$/.test(value)) { return false; } return value; } function buildAdminMutationIdempotencyRecordKey(req, idempotencyKey) { const actorId = req.nodedcAdminScope?.actorId || req.nodedcSession?.user?.sub || req.nodedcSession?.user?.email || "unknown"; return `${actorId}:${idempotencyKey}`; } function buildAdminMutationFingerprint(req) { const routePath = req.route?.path || req.path; const bodyHash = createHash("sha256").update(stableJson(req.body ?? null)).digest("hex"); return `${req.method}:${routePath}:${req.path}:${bodyHash}`; } function pruneAdminMutationIdempotencyRecords() { const expiresBefore = Date.now() - adminMutationIdempotencyTtlMs; for (const [key, record] of adminMutationIdempotencyRecords.entries()) { if ((record.createdAt ?? 0) < expiresBefore) { adminMutationIdempotencyRecords.delete(key); } } } function stableJson(value) { if (Array.isArray(value)) { return `[${value.map((item) => stableJson(item)).join(",")}]`; } if (value && typeof value === "object") { return `{${Object.keys(value) .sort() .map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`) .join(",")}}`; } return JSON.stringify(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; }