diff --git a/server/authentik-sync.mjs b/server/authentik-sync.mjs index 595b254..5d6b5b4 100644 --- a/server/authentik-sync.mjs +++ b/server/authentik-sync.mjs @@ -205,6 +205,14 @@ export function resolveRequiredGroups(data, user) { groupNames.add(platformGroups.taskManagerAdmin); groupNames.add(platformGroups.taskManagerUser); addGroups(groupNames, resolveEngineRoleGroups("member")); + // The platform super-admin is the default owner of every internally + // registered service. This also keeps the Authentik view consistent with + // the Launcher control-plane grants (including Module Foundry). + for (const service of data.services) { + if (service.authentikGroupName) { + groupNames.add(service.authentikGroupName); + } + } return [...groupNames]; } diff --git a/server/control-plane-store.mjs b/server/control-plane-store.mjs index 34bef53..d7ce42b 100644 --- a/server/control-plane-store.mjs +++ b/server/control-plane-store.mjs @@ -107,6 +107,40 @@ const bimViewerDefaultGrants = [ updatedAt: "2026-06-18T00:00:00.000Z", }, ]; +const moduleFoundryService = { + id: "service_module_foundry", + slug: "module-foundry", + title: "MODULE FOUNDRY", + subtitle: "Конфигурация платформенных модулей", + description: "Сборка приложений NODE.DC из утверждённых страниц, визуальных профилей и онтологических сущностей.", + fullDescription: + "Module Foundry — внутренний контур для конфигурации модулей NODE.DC. В первой версии доступ открывается только через Launcher; самостоятельной регистрации, публичного входа и многопользовательского владения проектами нет.", + url: "https://foundry.nodedc.ru", + launchUrl: "https://foundry.nodedc.ru", + logoutUrl: "https://foundry.nodedc.ru/auth/logout", + accentColor: "#F12F92", + fallbackGradient: "linear-gradient(132deg, rgba(241, 47, 146, 0.8), rgba(67, 19, 58, 0.92) 48%, #080B0F 86%)", + status: "active", + order: 26, + authentikApplicationSlug: "module-foundry", + authentikGroupName: "nodedc:module-foundry:access", + authHandoffPath: "/auth/nodedc/handoff", + isAvailableForAllNewClients: false, + createdAt: "2026-07-14T00:00:00.000Z", + updatedAt: "2026-07-14T00:00:00.000Z", +}; +const moduleFoundryDefaultGrants = [ + { + id: "grant_module_foundry_dctouch_admins", + serviceId: "service_module_foundry", + targetType: "group", + targetId: "group_dctouch_admins", + appRole: "member", + status: "active", + createdAt: "2026-07-14T00:00:00.000Z", + updatedAt: "2026-07-14T00:00:00.000Z", + }, +]; const defaultSettings = { brand: { logoLinkUrl: "/", @@ -2333,6 +2367,7 @@ function normalizeData(payload) { data.engineWorkflowAccessRequests = data.engineWorkflowAccessRequests.map(normalizeEngineWorkflowAccessRequest).filter(Boolean); applyProtectedLauncherUserInvariants(data); applyBimViewerServiceInvariants(data); + applyModuleFoundryServiceInvariants(data); return data; } @@ -2396,6 +2431,65 @@ function applyBimViewerServiceInvariants(data) { } } +function applyModuleFoundryServiceInvariants(data) { + let service = data.services.find( + (candidate) => candidate.id === moduleFoundryService.id || candidate.slug === moduleFoundryService.slug + ); + + if (!service) { + service = normalizeService({ ...moduleFoundryService }); + data.services.push(service); + } else { + service.id = service.id || moduleFoundryService.id; + service.slug = service.slug || moduleFoundryService.slug; + service.title = service.title || moduleFoundryService.title; + service.subtitle = service.subtitle || moduleFoundryService.subtitle; + service.description = service.description || moduleFoundryService.description; + service.fullDescription = service.fullDescription || moduleFoundryService.fullDescription; + service.url = service.url || moduleFoundryService.url; + service.launchUrl = service.launchUrl || service.url || moduleFoundryService.launchUrl; + service.logoutUrl = service.logoutUrl || moduleFoundryService.logoutUrl; + service.accentColor = service.accentColor || moduleFoundryService.accentColor; + service.fallbackGradient = service.fallbackGradient || moduleFoundryService.fallbackGradient; + service.status = service.status || moduleFoundryService.status; + service.order = service.order ?? moduleFoundryService.order; + service.authentikApplicationSlug = service.authentikApplicationSlug || moduleFoundryService.authentikApplicationSlug; + service.authentikGroupName = service.authentikGroupName || moduleFoundryService.authentikGroupName; + service.authHandoffPath = service.authHandoffPath || moduleFoundryService.authHandoffPath; + service.isAvailableForAllNewClients = service.isAvailableForAllNewClients ?? false; + service.createdAt = service.createdAt || moduleFoundryService.createdAt; + service.updatedAt = service.updatedAt || moduleFoundryService.updatedAt; + } + + const serviceId = service.id || moduleFoundryService.id; + const defaultGrant = { ...moduleFoundryDefaultGrants[0], serviceId }; + const groupExists = data.groups.some((group) => group.id === defaultGrant.targetId); + const grantExists = data.grants.some( + (candidate) => + candidate.serviceId === defaultGrant.serviceId && + candidate.targetType === defaultGrant.targetType && + candidate.targetId === defaultGrant.targetId + ); + if (groupExists && !grantExists) { + data.grants.push(defaultGrant); + } + + ensureStaticSyncStatus(data, { + id: "sync_module_foundry_authentik", + objectId: serviceId, + objectName: moduleFoundryService.title, + objectType: "service", + }); + if (groupExists) { + ensureStaticSyncStatus(data, { + id: `sync_${defaultGrant.id}`, + objectId: defaultGrant.id, + objectName: `${moduleFoundryService.slug}:${defaultGrant.targetType}:${defaultGrant.targetId}`, + objectType: "grant", + }); + } +} + function ensureStaticSyncStatus(data, status) { const exists = data.syncStatuses.some( (candidate) => candidate.target === "authentik" && candidate.objectType === status.objectType && candidate.objectId === status.objectId @@ -2491,6 +2585,22 @@ function upsertSystemProtectedUserGrant(data, user, service, appRole, now) { function normalizeService(service) { if (typeof service !== "object" || service === null) return service; + if ( + service.id === "service_module_foundry" || + service.slug === "module-foundry" || + service.authentikApplicationSlug === "module-foundry" + ) { + return { + ...service, + url: service.url || moduleFoundryService.url, + launchUrl: service.launchUrl || service.url || moduleFoundryService.launchUrl, + logoutUrl: service.logoutUrl || moduleFoundryService.logoutUrl, + authentikApplicationSlug: service.authentikApplicationSlug || moduleFoundryService.authentikApplicationSlug, + authentikGroupName: service.authentikGroupName || moduleFoundryService.authentikGroupName, + authHandoffPath: service.authHandoffPath || moduleFoundryService.authHandoffPath, + isAvailableForAllNewClients: service.isAvailableForAllNewClients ?? false, + }; + } if (service.id === "service_bim_viewer" || service.slug === "bim-viewer" || service.authentikApplicationSlug === "bim-viewer") { const legacyLocalUrl = (value) => value === "http://localhost:8080" || value === "http://127.0.0.1:8080"; return { @@ -3680,6 +3790,7 @@ function sanitizeServicePatch(payload, service) { "fallbackGradient", "authentikApplicationSlug", "authentikGroupName", + "authHandoffPath", ]; for (const field of stringFields) { diff --git a/server/dev-server.mjs b/server/dev-server.mjs index 222979e..d463f8d 100644 --- a/server/dev-server.mjs +++ b/server/dev-server.mjs @@ -3347,6 +3347,7 @@ function getAppCatalog() { url: getServiceUrl(service), openUrl: getServiceUrl(service), launchTargetUrl: getServiceLaunchTargetUrl(service), + authHandoffPath: service.authHandoffPath || null, status: service.status ?? "disabled", provider: "authentik", requiredGroups, @@ -3405,7 +3406,12 @@ function specialRequiredGroups(serviceOrSlug) { } function getServiceUrl(service) { - if (service.slug === "task-manager" || isEngineServiceSlug(service.slug) || isBimViewerServiceSlug(service.slug)) { + if ( + service.slug === "task-manager" || + isEngineServiceSlug(service.slug) || + isBimViewerServiceSlug(service.slug) || + service.authHandoffPath + ) { return `/api/services/${encodeURIComponent(service.slug)}/launch`; } @@ -3413,6 +3419,14 @@ function getServiceUrl(service) { } function getServiceLaunchTargetUrl(service) { + // Catalog entries replace `url` with the Launcher launch route for services + // that need a handoff. Keep their external origin separately so a relative + // handoff path is always resolved against the target application, never the + // internal `/api/services/:slug/launch` route. + if (service?.launchTargetUrl) { + return service.launchTargetUrl; + } + if (isEngineServiceSlug(service?.slug)) { return service.launchUrl || service.url || getEnginePublicBaseUrl(); } @@ -3500,6 +3514,25 @@ function redirectToServiceLaunchTarget(res, serviceSlug, runtimeContext, app, re return; } + // Module Foundry and future internal services use the same Launcher-owned + // session contract as BIM. The path is service metadata, so the launcher + // does not grow a separate authentication mechanism per module. + const handoffPath = String(app?.authHandoffPath || "").trim(); + if (handoffPath) { + const handoffToken = createServiceHandoff(serviceSlug, runtimeContext.user, { launcherSessionId: session?.id }); + const targetUrl = new URL(handoffPath, getServiceLaunchTargetUrl(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; diff --git a/src/entities/service/types.ts b/src/entities/service/types.ts index 7b0155e..7915f55 100644 --- a/src/entities/service/types.ts +++ b/src/entities/service/types.ts @@ -31,6 +31,11 @@ export interface Service { order: number; authentikApplicationSlug?: string | null; authentikGroupName?: string | null; + /** + * Relative handoff endpoint on the target app. Services using the shared + * Launcher/Auth­entik session path never expose a standalone browser token. + */ + authHandoffPath?: string | null; isAvailableForAllNewClients?: boolean; createdAt: string; updatedAt: string;