import { randomUUID } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; import { mkdir, rename, writeFile } from "node:fs/promises"; import { join } from "node:path"; const collectionKeys = [ "clients", "users", "memberships", "groups", "services", "grants", "exceptions", "invites", "syncStatuses", "auditEvents", "taskManagerMemberships", ]; const clientTypes = new Set(["company", "person"]); const clientStatuses = new Set(["active", "suspended", "demo", "expired"]); const userStatuses = new Set(["invited", "active", "blocked"]); const membershipRoles = new Set(["client_owner", "client_admin", "member"]); const grantTargetTypes = new Set(["client", "group", "user"]); const appRoles = new Set(["viewer", "member", "admin", "owner"]); const grantStatuses = new Set(["active", "disabled"]); const exceptionTypes = new Set(["deny", "allow"]); const serviceStatuses = new Set(["active", "maintenance", "hidden", "disabled"]); const defaultSettings = { brand: { logoLinkUrl: "/", }, taskManager: { workspaceCreationPolicy: "any_authorized_user", }, }; export function createControlPlaneStore({ projectRoot }) { const publicStorageRoot = join(projectRoot, "public", "storage"); const distStorageRoot = join(projectRoot, "dist", "storage"); const dataPath = join(publicStorageRoot, "launcher-data.json"); function readData() { if (!existsSync(dataPath)) { return normalizeData({}); } return normalizeData(JSON.parse(readFileSync(dataPath, "utf8"))); } async function writeData(data) { const normalizedData = normalizeData(data); const payload = `${JSON.stringify(normalizedData, null, 2)}\n`; await Promise.all( getWritableStorageRoots().map(async (storageRoot) => { await mkdir(storageRoot, { recursive: true }); await writeJsonAtomically(join(storageRoot, "launcher-data.json"), payload); }) ); return normalizedData; } function getSnapshot(identity) { const data = readData(); return { actor: resolveActor(data, identity), counts: Object.fromEntries(collectionKeys.map((key) => [key, data[key].length])), data, }; } async function replaceData(payload, identity) { const data = normalizeData(payload); const actor = resolveActor(data, identity); addAuditEvent(data, actor, { action: "Обновлено control-plane состояние", objectType: "control_plane", objectName: "Launcher data", result: "success", details: "Полная запись launcher-data.json через backend store.", }); return writeData(data); } async function createClient(payload, identity) { const data = readData(); const actor = resolveActor(data, identity); const now = isoNow(); const name = requireString(payload?.name, "name"); const client = { id: uniqueId(data.clients, "client", name), type: pickEnum(payload?.type, clientTypes, "company"), name, legalName: nullableString(payload?.legalName), inn: nullableString(payload?.inn), status: pickEnum(payload?.status, clientStatuses, "active"), contractStartsAt: nullableString(payload?.contractStartsAt), contractEndsAt: nullableString(payload?.contractEndsAt), paidUntil: nullableString(payload?.paidUntil), demoEndsAt: nullableString(payload?.demoEndsAt), contactName: nullableString(payload?.contactName), contactEmail: nullableString(payload?.contactEmail), integrations: normalizeClientIntegrations(payload?.integrations), notes: nullableString(payload?.notes), createdAt: now, updatedAt: now, }; data.clients.push(client); addAuditEvent(data, actor, { action: "Создан клиент", objectType: "client", objectName: client.name, clientId: client.id, result: "success", }); markPendingSync(data, client, "client"); await writeData(data); return { client, data }; } async function updateClient(clientId, payload, identity) { const data = readData(); const actor = resolveActor(data, identity); const client = findById(data.clients, clientId, "client"); client.type = pickEnum(payload?.type, clientTypes, client.type); client.name = optionalString(payload?.name, client.name); client.legalName = nullableStringWithFallback(payload?.legalName, client.legalName ?? null); client.inn = nullableStringWithFallback(payload?.inn, client.inn ?? null); client.status = pickEnum(payload?.status, clientStatuses, client.status); client.contractStartsAt = nullableStringWithFallback(payload?.contractStartsAt, client.contractStartsAt ?? null); client.contractEndsAt = nullableStringWithFallback(payload?.contractEndsAt, client.contractEndsAt ?? null); client.paidUntil = nullableStringWithFallback(payload?.paidUntil, client.paidUntil ?? null); client.demoEndsAt = nullableStringWithFallback(payload?.demoEndsAt, client.demoEndsAt ?? null); client.contactName = nullableStringWithFallback(payload?.contactName, client.contactName ?? null); client.contactEmail = nullableStringWithFallback(payload?.contactEmail, client.contactEmail ?? null); if ("integrations" in (payload ?? {})) { client.integrations = normalizeClientIntegrations(payload.integrations, client.integrations); } client.notes = nullableStringWithFallback(payload?.notes, client.notes ?? null); client.updatedAt = isoNow(); addAuditEvent(data, actor, { action: "Обновлён клиент", objectType: "client", objectName: client.name, clientId: client.id, result: "success", }); markPendingSync(data, client, "client"); await writeData(data); return { client, data }; } async function recordTaskManagerWorkspaceMembership(payload, identity) { const data = readData(); const actor = resolveActor(data, identity); const client = findById(data.clients, payload?.clientId, "client"); const user = findById(data.users, payload?.userId, "user"); const taskManager = typeof payload?.taskManager === "object" && payload.taskManager !== null ? payload.taskManager : {}; const membership = typeof taskManager.membership === "object" && taskManager.membership !== null ? taskManager.membership : {}; const workspace = typeof membership.workspace === "object" && membership.workspace !== null ? membership.workspace : {}; upsertTaskManagerMembership(data, { clientId: client.id, userId: user.id, workspaceSlug: workspace.slug ?? payload?.workspaceSlug, workspaceName: workspace.name ?? payload?.workspaceName ?? null, role: normalizeTaskManagerMembershipRole(payload?.role), planeUserId: membership.member?.id ?? null, planeRole: typeof membership.role === "number" ? membership.role : null, }); addAuditEvent(data, actor, { action: "Назначен Tasker workspace", objectType: "task-manager-membership", objectName: user.name, clientId: client.id, result: "success", details: `Workspace: ${workspace.name ?? workspace.slug ?? payload?.workspaceSlug}; Role: ${payload?.role ?? "member"}`, }); await writeData(data); return { data }; } async function removeTaskManagerWorkspaceMembership(payload, identity) { const data = readData(); const actor = resolveActor(data, identity); const client = findById(data.clients, payload?.clientId, "client"); const user = findById(data.users, payload?.userId, "user"); const workspaceSlug = requireString(payload?.workspaceSlug ?? client.integrations?.taskManager?.workspaceSlug, "workspaceSlug"); data.taskManagerMemberships = data.taskManagerMemberships.filter( (membership) => !(membership.clientId === client.id && membership.userId === user.id && membership.workspaceSlug === workspaceSlug) ); addAuditEvent(data, actor, { action: "Снят Tasker workspace", objectType: "task-manager-membership", objectName: user.name, clientId: client.id, result: "success", details: `Workspace: ${workspaceSlug}`, }); await writeData(data); return { data }; } async function deleteClient(clientId, identity) { const data = readData(); const actor = resolveActor(data, identity); const client = findById(data.clients, clientId, "client"); if (data.clients.length <= 1) { throw new Error("Cannot delete the last client"); } const deletedGroupIds = new Set(data.groups.filter((group) => group.clientId === clientId).map((group) => group.id)); data.clients = data.clients.filter((item) => item.id !== clientId); data.memberships = data.memberships.filter((membership) => membership.clientId !== clientId); data.groups = data.groups.filter((group) => group.clientId !== clientId); data.grants = data.grants.filter( (grant) => !(grant.targetType === "client" && grant.targetId === clientId) && !(grant.targetType === "group" && deletedGroupIds.has(grant.targetId)) ); data.invites = data.invites.filter((invite) => invite.clientId !== clientId); data.syncStatuses = data.syncStatuses.filter((syncStatus) => syncStatus.objectId !== clientId); addAuditEvent(data, actor, { action: "Удалён клиент", objectType: "client", objectName: client.name, clientId, result: "warning", }); await writeData(data); return { client, data }; } async function updateSettings(payload, identity) { const data = readData(); const actor = resolveActor(data, identity); const patch = typeof payload === "object" && payload !== null ? payload : {}; const settings = normalizeSettings({ ...data.settings, ...patch, brand: { ...(data.settings?.brand ?? {}), ...(patch.brand ?? {}), }, taskManager: { ...(data.settings?.taskManager ?? {}), ...(patch.taskManager ?? {}), }, }); data.settings = settings; addAuditEvent(data, actor, { action: "Обновлены системные настройки", objectType: "settings", objectName: "Brand settings", result: "success", details: `Logo link: ${settings.brand.logoLinkUrl}; Tasker workspace policy: ${settings.taskManager.workspaceCreationPolicy}`, }); await writeData(data); return { settings, data }; } async function updateUserProfile(userId, payload, identity) { const data = readData(); const actor = resolveActor(data, identity); const user = findById(data.users, userId, "user"); const now = isoNow(); user.name = optionalString(payload?.name, user.name); user.email = optionalString(payload?.email, user.email); user.phone = nullableStringWithFallback(payload?.phone, user.phone); user.position = nullableStringWithFallback(payload?.position, user.position); user.notes = nullableStringWithFallback(payload?.notes, user.notes); user.avatarUrl = nullableStringWithFallback(payload?.avatarUrl, user.avatarUrl ?? null); user.globalStatus = pickEnum(payload?.globalStatus, userStatuses, user.globalStatus); user.updatedAt = now; addAuditEvent(data, actor, { action: "Обновлён профиль пользователя", objectType: "user", objectName: user.email, result: "success", }); markPendingSync(data, user, "user"); await writeData(data); return { user, data }; } async function createUser(payload, identity) { const data = readData(); const actor = resolveActor(data, identity); const now = isoNow(); const clientId = requireString(payload?.clientId, "clientId"); const client = findById(data.clients, clientId, "client"); const email = requireString(payload?.email, "email").toLowerCase(); const existingUser = data.users.find((item) => item.email.toLowerCase() === email); const user = existingUser ?? { id: uniqueId(data.users, "user", email), authentikUserId: nullableString(payload?.authentikUserId), name: optionalString(payload?.name, email.split("@")[0]), email, phone: nullableString(payload?.phone), position: nullableString(payload?.position), notes: nullableString(payload?.notes), avatarUrl: nullableString(payload?.avatarUrl), globalStatus: pickEnum(payload?.globalStatus, userStatuses, "active"), createdAt: now, updatedAt: now, }; if (existingUser) { user.name = optionalString(payload?.name, user.name); user.phone = nullableStringWithFallback(payload?.phone, user.phone ?? null); user.position = nullableStringWithFallback(payload?.position, user.position ?? null); user.notes = nullableStringWithFallback(payload?.notes, user.notes ?? null); user.avatarUrl = nullableStringWithFallback(payload?.avatarUrl, user.avatarUrl ?? null); user.globalStatus = pickEnum(payload?.globalStatus, userStatuses, user.globalStatus); user.updatedAt = now; } else { data.users.push(user); } const existingMembership = data.memberships.find((membership) => membership.clientId === clientId && membership.userId === user.id); if (existingMembership) { throw new Error(`User ${email} already belongs to client ${client.name}`); } const membership = { id: uniqueId(data.memberships, "mem", `${clientId}-${email}`), clientId, userId: user.id, role: pickEnum(payload?.role, membershipRoles, "member"), status: pickEnum(payload?.membershipStatus, new Set(["active", "disabled"]), "active"), createdAt: now, updatedAt: now, }; data.memberships.push(membership); if (Array.isArray(payload?.groupIds)) { for (const group of data.groups) { if (group.clientId !== clientId || !payload.groupIds.includes(group.id) || group.memberIds.includes(user.id)) { continue; } group.memberIds.push(user.id); group.updatedAt = now; } } addAuditEvent(data, actor, { action: existingUser ? "Добавлен пользователь в клиента" : "Создан пользователь", objectType: "user", objectName: email, clientId: client.id, result: "success", details: `Role: ${membership.role}; status: ${membership.status}`, }); markPendingSync(data, user, "user", email); await writeData(data); return { user, membership, data }; } async function updateMembership(membershipId, payload, identity) { const data = readData(); const actor = resolveActor(data, identity); const membership = findById(data.memberships, membershipId, "membership"); const user = findById(data.users, membership.userId, "user"); membership.role = pickEnum(payload?.role, membershipRoles, membership.role); membership.status = pickEnum(payload?.status, new Set(["active", "disabled"]), membership.status); membership.updatedAt = isoNow(); addAuditEvent(data, actor, { action: "Обновлено членство", objectType: "user", objectName: user.email, clientId: membership.clientId, result: "success", details: `Role: ${membership.role}; status: ${membership.status}`, }); markPendingSync(data, user, "user"); await writeData(data); return { membership, data }; } async function deleteMembership(membershipId, identity) { const data = readData(); const actor = resolveActor(data, identity); const membership = findById(data.memberships, membershipId, "membership"); const user = findById(data.users, membership.userId, "user"); data.memberships = data.memberships.filter((item) => item.id !== membershipId); data.groups = data.groups.map((group) => group.clientId === membership.clientId ? { ...group, memberIds: group.memberIds.filter((userId) => userId !== membership.userId), updatedAt: isoNow(), } : group ); addAuditEvent(data, actor, { action: "Удалено членство", objectType: "user", objectName: user.email, clientId: membership.clientId, result: "warning", }); markPendingSync(data, user, "user"); await writeData(data); return { membership, data }; } async function createInvite(payload, identity) { const data = readData(); const actor = resolveActor(data, identity); const now = isoNow(); const clientId = requireString(payload?.clientId, "clientId"); const client = findById(data.clients, clientId, "client"); const email = requireString(payload?.email, "email").toLowerCase(); const role = pickEnum(payload?.role, membershipRoles, "member"); const expiresAt = optionalString(payload?.expiresAt, new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString()); const invite = { id: uniqueId(data.invites, "invite", email), clientId, email, role, invitedByUserId: actor.id, token: randomUUID(), expiresAt, status: "created", createdAt: now, updatedAt: now, }; data.invites.push(invite); addAuditEvent(data, actor, { action: "Создан инвайт", objectType: "invite", objectName: email, clientId: client.id, result: "success", details: `Роль: ${role}`, }); markPendingSync(data, invite, "invite", email); await writeData(data); return { invite, data }; } async function updateInvite(inviteId, payload, identity) { const data = readData(); const actor = resolveActor(data, identity); const invite = findById(data.invites, inviteId, "invite"); invite.email = optionalString(payload?.email, invite.email).toLowerCase(); invite.role = pickEnum(payload?.role, membershipRoles, invite.role); invite.expiresAt = optionalString(payload?.expiresAt, invite.expiresAt); invite.status = pickEnum(payload?.status, new Set(["created", "sent", "accepted", "expired", "revoked"]), invite.status); invite.updatedAt = isoNow(); addAuditEvent(data, actor, { action: "Обновлён инвайт", objectType: "invite", objectName: invite.email, clientId: invite.clientId, result: "success", }); markPendingSync(data, invite, "invite", invite.email); await writeData(data); return { invite, data }; } async function deleteInvite(inviteId, identity) { const data = readData(); const actor = resolveActor(data, identity); const invite = findById(data.invites, inviteId, "invite"); data.invites = data.invites.filter((item) => item.id !== inviteId); data.syncStatuses = data.syncStatuses.filter((syncStatus) => syncStatus.objectId !== inviteId); addAuditEvent(data, actor, { action: "Удалён инвайт", objectType: "invite", objectName: invite.email, clientId: invite.clientId, result: "warning", }); await writeData(data); return { invite, data }; } function getInviteByToken(token) { const data = readData(); const invite = findInviteByToken(data, token); const client = findById(data.clients, invite.clientId, "client"); return { invite: toPublicInvite(invite), client: toPublicClient(client), }; } function prepareInviteRegistration(token, payload = {}) { const data = readData(); const result = applyInviteRegistration(data, token, payload, { commit: false }); return result; } async function commitInviteRegistration(token, payload = {}, provisioning) { const data = readData(); const result = applyInviteRegistration(data, token, payload, { commit: true, provisioning }); await writeData(data); return result; } async function acceptInvite(token, identity) { const data = readData(); const invite = findInviteByToken(data, token); const client = findById(data.clients, invite.clientId, "client"); const email = requireInviteIdentityEmail(identity); const now = isoNow(); if (invite.email.toLowerCase() !== email) { throw new Error("Этот инвайт выписан на другую почту"); } if (invite.status === "revoked") { throw new Error("Инвайт отозван"); } if (invite.status === "expired" || isInviteExpired(invite)) { invite.status = "expired"; invite.updatedAt = now; await writeData(data); throw new Error("Срок действия инвайта истёк"); } const actor = resolveActor(data, identity); let user = data.users.find((item) => item.email.toLowerCase() === email); if (user) { user.authentikUserId = optionalString(identity?.sub, user.authentikUserId ?? null); user.name = optionalString(identity?.name, user.name); user.avatarUrl = nullableStringWithFallback(identity?.avatarUrl, user.avatarUrl ?? null); user.globalStatus = "active"; user.updatedAt = now; } else { user = { id: uniqueId(data.users, "user", email), authentikUserId: nullableString(identity?.sub), name: optionalString(identity?.name, email.split("@")[0]), email, phone: null, position: null, notes: `Создан через инвайт клиента ${client.name}.`, avatarUrl: nullableString(identity?.avatarUrl), globalStatus: "active", createdAt: now, updatedAt: now, }; data.users.push(user); } let membership = data.memberships.find((item) => item.clientId === invite.clientId && item.userId === user.id); if (membership) { membership.role = invite.role; membership.status = "active"; membership.updatedAt = now; } else { membership = { id: uniqueId(data.memberships, "mem", `${invite.clientId}-${email}`), clientId: invite.clientId, userId: user.id, role: invite.role, status: "active", createdAt: now, updatedAt: now, }; data.memberships.push(membership); } invite.status = "accepted"; invite.updatedAt = now; addAuditEvent(data, actor, { action: "Инвайт принят", objectType: "invite", objectName: invite.email, clientId: client.id, result: "success", details: `Role: ${invite.role}`, }); markPendingSync(data, user, "user", email); await writeData(data); return { invite, client, user, membership, data }; } async function createGroup(payload, identity) { const data = readData(); const actor = resolveActor(data, identity); const now = isoNow(); const clientId = requireString(payload?.clientId, "clientId"); const client = findById(data.clients, clientId, "client"); const groupName = optionalString(payload?.name, "Новая группа"); const group = { id: uniqueId(data.groups, "group", `${clientId}-${groupName}`), clientId, name: groupName, description: nullableString(payload?.description), memberIds: Array.isArray(payload?.memberIds) ? payload.memberIds.filter((userId) => data.users.some((user) => user.id === userId)) : [], createdAt: now, updatedAt: now, }; data.groups.push(group); addAuditEvent(data, actor, { action: "Создана группа", objectType: "group", objectName: group.name, clientId: client.id, result: "success", }); markPendingSync(data, group, "group"); await writeData(data); return { group, data }; } async function updateGroup(groupId, payload, identity) { const data = readData(); const actor = resolveActor(data, identity); const group = findById(data.groups, groupId, "group"); group.name = optionalString(payload?.name, group.name); group.description = nullableStringWithFallback(payload?.description, group.description ?? null); group.memberIds = Array.isArray(payload?.memberIds) ? payload.memberIds.filter((userId) => data.users.some((user) => user.id === userId)) : group.memberIds; group.updatedAt = isoNow(); addAuditEvent(data, actor, { action: "Обновлена группа", objectType: "group", objectName: group.name, clientId: group.clientId, result: "success", }); markPendingSync(data, group, "group"); await writeData(data); return { group, data }; } async function deleteGroup(groupId, identity) { const data = readData(); const actor = resolveActor(data, identity); const group = findById(data.groups, groupId, "group"); data.groups = data.groups.filter((item) => item.id !== groupId); data.grants = data.grants.filter((grant) => !(grant.targetType === "group" && grant.targetId === groupId)); data.syncStatuses = data.syncStatuses.filter((syncStatus) => syncStatus.objectId !== groupId); addAuditEvent(data, actor, { action: "Удалена группа", objectType: "group", objectName: group.name, clientId: group.clientId, result: "warning", }); await writeData(data); return { group, data }; } async function upsertGrant(payload, identity) { const data = readData(); const actor = resolveActor(data, identity); const now = isoNow(); const serviceId = requireString(payload?.serviceId, "serviceId"); const targetType = pickEnum(payload?.targetType, grantTargetTypes, "client"); const targetId = requireString(payload?.targetId, "targetId"); const service = findById(data.services, serviceId, "service"); assertGrantTargetExists(data, targetType, targetId); const existingGrant = data.grants.find( (grant) => grant.serviceId === serviceId && grant.targetType === targetType && grant.targetId === targetId ); const grant = existingGrant ?? { id: uniqueId(data.grants, "grant", `${service.slug}-${targetType}-${targetId}`), serviceId, targetType, targetId, createdAt: now, }; grant.appRole = pickEnum(payload?.appRole, appRoles, existingGrant?.appRole ?? "member"); grant.status = pickEnum(payload?.status, grantStatuses, existingGrant?.status ?? "active"); grant.updatedAt = now; if (!existingGrant) { data.grants.push(grant); } addAuditEvent(data, actor, { action: existingGrant ? "Обновлён доступ" : "Создан доступ", objectType: "grant", objectName: `${service.slug}:${targetType}:${targetId}`, result: "success", details: `App role: ${grant.appRole}; status: ${grant.status}`, }); markPendingSync(data, grant, "grant", `${service.slug}:${targetType}:${targetId}`); await writeData(data); return { grant, data }; } async function upsertException(payload, identity) { const data = readData(); const actor = resolveActor(data, identity); const now = isoNow(); const serviceId = requireString(payload?.serviceId, "serviceId"); const userId = requireString(payload?.userId, "userId"); const type = pickEnum(payload?.type, exceptionTypes, "deny"); const service = findById(data.services, serviceId, "service"); const user = findById(data.users, userId, "user"); const existingException = data.exceptions.find( (exception) => exception.serviceId === serviceId && exception.userId === userId ); const exception = existingException ?? { id: uniqueId(data.exceptions, "exception", `${service.slug}-${user.email}`), serviceId, userId, createdAt: now, }; exception.type = type; exception.reason = nullableString(payload?.reason); exception.updatedAt = now; if (!existingException) { data.exceptions.push(exception); } addAuditEvent(data, actor, { action: existingException ? "Обновлено исключение доступа" : "Создано исключение доступа", objectType: "exception", objectName: `${service.slug}:${user.email}`, result: "success", details: `Type: ${type}`, }); markPendingSync(data, exception, "grant", `${service.slug}:${user.email}`); await writeData(data); return { exception, data }; } async function setUserServiceAccess(payload, identity) { const data = readData(); const actor = resolveActor(data, identity); const now = isoNow(); const userId = requireString(payload?.userId, "userId"); const serviceId = requireString(payload?.serviceId, "serviceId"); const value = requireString(payload?.value, "value"); const user = findById(data.users, userId, "user"); const service = findById(data.services, serviceId, "service"); const directGrant = data.grants.find( (grant) => grant.serviceId === serviceId && grant.targetType === "user" && grant.targetId === userId ); data.grants = data.grants.filter( (grant) => !(grant.serviceId === serviceId && grant.targetType === "user" && grant.targetId === userId) ); data.exceptions = data.exceptions.filter((exception) => !(exception.serviceId === serviceId && exception.userId === userId)); if (value === "deny") { data.exceptions.push({ id: uniqueId(data.exceptions, "exception", `${service.slug}-${user.email}`), serviceId, userId, type: "deny", reason: "Создано из матрицы доступа.", createdAt: now, updatedAt: now, }); } else if (appRoles.has(value) && value !== "owner") { data.grants.push({ id: directGrant?.id ?? uniqueId(data.grants, "grant", `${service.slug}-user-${user.email}`), serviceId, targetType: "user", targetId: userId, appRole: value, status: "active", createdAt: directGrant?.createdAt ?? now, updatedAt: now, }); } else if (value !== "unset") { throw new Error(`Unsupported access value: ${value}`); } addAuditEvent(data, actor, { action: "Обновлён доступ пользователя к сервису", objectType: "grant", objectName: `${user.email} / ${service.slug}`, result: "success", details: `Value: ${value}`, }); markPendingSync(data, { id: `${serviceId}:${userId}` }, "grant", `${service.slug}:${user.email}`); await writeData(data); return { data }; } async function updateService(serviceId, payload, identity) { const data = readData(); const actor = resolveActor(data, identity); const service = findById(data.services, serviceId, "service"); Object.assign(service, sanitizeServicePatch(payload, service)); Object.assign(service, syncServiceLaunchLink(service)); service.updatedAt = isoNow(); addAuditEvent(data, actor, { action: "Обновлён сервис", objectType: "service", objectName: service.title, result: "success", }); markPendingSync(data, service, "service"); await writeData(data); return { service, data }; } async function reorderServices(payload, identity) { const data = readData(); const actor = resolveActor(data, identity); const orderedServiceIds = Array.isArray(payload?.orderedServiceIds) ? payload.orderedServiceIds : []; const orderById = new Map(orderedServiceIds.map((serviceId, index) => [serviceId, (index + 1) * 10])); const now = isoNow(); data.services = data.services.map((service) => ({ ...service, order: orderById.get(service.id) ?? service.order, updatedAt: orderById.has(service.id) ? now : service.updatedAt, })); addAuditEvent(data, actor, { action: "Изменён порядок сервисов", objectType: "service", objectName: "Каталог сервисов", result: "success", }); await writeData(data); return { data }; } async function createService(payload, identity) { const data = readData(); const actor = resolveActor(data, identity); const now = isoNow(); const nextIndex = data.services.length + 1; const nextOrder = Math.max(0, ...data.services.map((service) => Number(service.order) || 0)) + 10; const title = optionalString(payload?.title, "New Service"); const service = syncServiceLaunchLink({ id: uniqueId(data.services, "service", title), slug: optionalString(payload?.slug, `new-service-${nextIndex}`), title, subtitle: nullableString(payload?.subtitle) ?? "Новый сервис", description: optionalString(payload?.description, "Описание сервиса для витрины."), fullDescription: nullableString(payload?.fullDescription) ?? "Заполните описание, медиа и ссылку запуска в редакторе контента.", url: optionalString(payload?.url, "https://service.handhdc.ru/sso/launch"), launchUrl: nullableString(payload?.launchUrl) ?? optionalString(payload?.url, "https://service.handhdc.ru/sso/launch"), accentColor: nullableString(payload?.accentColor) ?? "#F7F8F4", fallbackGradient: nullableString(payload?.fallbackGradient) ?? "linear-gradient(135deg, rgba(247, 248, 244, 0.72), rgba(36, 37, 42, 0.9) 52%, #090B0F 88%)", coverMediaSource: nullableString(payload?.coverMediaSource) ?? "url", coverMediaKind: nullableString(payload?.coverMediaKind) ?? "image", ambientMediaSource: nullableString(payload?.ambientMediaSource) ?? "url", ambientMediaKind: nullableString(payload?.ambientMediaKind) ?? "gif", status: pickEnum(payload?.status, serviceStatuses, "hidden"), order: nextOrder, authentikApplicationSlug: nullableString(payload?.authentikApplicationSlug) ?? `new-service-${nextIndex}`, authentikGroupName: nullableString(payload?.authentikGroupName) ?? `service-new-${nextIndex}`, createdAt: now, updatedAt: now, }); data.services.push(service); addAuditEvent(data, actor, { action: "Создан сервис", objectType: "service", objectName: service.title, result: "success", }); markPendingSync(data, service, "service"); await writeData(data); return { service, data }; } async function deleteService(serviceId, identity) { const data = readData(); const actor = resolveActor(data, identity); const service = findById(data.services, serviceId, "service"); data.services = data.services.filter((item) => item.id !== serviceId); data.grants = data.grants.filter((grant) => grant.serviceId !== serviceId); data.exceptions = data.exceptions.filter((exception) => exception.serviceId !== serviceId); data.syncStatuses = data.syncStatuses.filter((syncStatus) => syncStatus.objectId !== serviceId); addAuditEvent(data, actor, { action: "Удалён сервис", objectType: "service", objectName: service.title, result: "warning", }); await writeData(data); return { service, data }; } async function retrySync(syncId, identity) { const data = readData(); const actor = resolveActor(data, identity); const syncStatus = findById(data.syncStatuses, syncId, "sync status"); syncStatus.state = "pending"; syncStatus.error = null; syncStatus.updatedAt = isoNow(); addAuditEvent(data, actor, { action: "Повтор sync", objectType: syncStatus.objectType, objectName: syncStatus.objectName, result: "success", details: `Target: ${syncStatus.target}`, }); await writeData(data); return { syncStatus, data }; } async function markUserAuthentikProvisioned(userId, provisioning, identity) { const data = readData(); const actor = resolveActor(data, identity); const user = findById(data.users, userId, "user"); const now = isoNow(); user.authentikUserId = provisioning.authentikUserId ?? user.authentikUserId ?? null; user.email = optionalString(provisioning.email, user.email).toLowerCase(); user.name = optionalString(provisioning.name, user.name); user.updatedAt = now; const syncStatus = data.syncStatuses.find( (status) => status.target === "authentik" && status.objectType === "user" && status.objectId === user.id ); const objectName = user.email; if (syncStatus) { syncStatus.objectName = objectName; syncStatus.state = "synced"; syncStatus.lastSyncAt = now; syncStatus.error = null; syncStatus.updatedAt = now; } else { data.syncStatuses.push({ id: uniqueId(data.syncStatuses, "sync", `user-${user.id}`), objectId: user.id, objectName, objectType: "user", target: "authentik", state: "synced", lastSyncAt: now, error: null, updatedAt: now, }); } addAuditEvent(data, actor, { action: "Пользователь синхронизирован в Authentik", objectType: "user", objectName, result: "success", details: `Groups: ${(provisioning.groups ?? []).join(", ") || "none"}`, }); await writeData(data); return { user, data }; } function buildAuthentikSyncPlan() { const data = readData(); return { mode: "dry-run", source: "launcher-control-plane", target: "authentik", users: data.users.map((user) => ({ id: user.id, authentikUserId: user.authentikUserId ?? null, email: user.email, name: user.name, avatarUrl: user.avatarUrl ?? null, active: user.globalStatus === "active", })), groups: [ "nodedc:superadmin", "nodedc:launcher:admin", "nodedc:launcher:user", ...data.services.flatMap((service) => (service.authentikGroupName ? [service.authentikGroupName] : [])), ...data.groups.map((group) => `client:${group.clientId}:group:${slugify(group.name)}`), ], accessProjection: { services: data.services.length, grants: data.grants.filter((grant) => grant.status === "active").length, exceptions: data.exceptions.length, pendingSyncObjects: data.syncStatuses.filter((syncStatus) => syncStatus.target === "authentik" && syncStatus.state === "pending").length, }, }; } function getWritableStorageRoots() { const roots = [publicStorageRoot]; if (existsSync(join(projectRoot, "dist"))) { roots.push(distStorageRoot); } return roots; } return { buildAuthentikSyncPlan, createClient, createGroup, createInvite, createService, createUser, deleteClient, deleteGroup, deleteInvite, deleteMembership, deleteService, acceptInvite, commitInviteRegistration, getInviteByToken, getSnapshot, prepareInviteRegistration, readData, replaceData, reorderServices, retrySync, markUserAuthentikProvisioned, recordTaskManagerWorkspaceMembership, removeTaskManagerWorkspaceMembership, setUserServiceAccess, updateClient, updateGroup, updateInvite, updateMembership, updateService, updateSettings, updateUserProfile, upsertException, upsertGrant, writeData, }; } function normalizeData(payload) { const data = typeof payload === "object" && payload !== null ? { ...payload } : {}; for (const key of collectionKeys) { if (!Array.isArray(data[key])) { data[key] = []; } } data.settings = normalizeSettings(data.settings); data.clients = data.clients.map((client) => ({ ...client, integrations: normalizeClientIntegrations(client.integrations), })); return data; } function normalizeSettings(payload) { const settings = typeof payload === "object" && payload !== null ? payload : {}; const brand = typeof settings.brand === "object" && settings.brand !== null ? settings.brand : {}; const taskManager = typeof settings.taskManager === "object" && settings.taskManager !== null ? settings.taskManager : {}; return { brand: { logoLinkUrl: optionalString(brand.logoLinkUrl, defaultSettings.brand.logoLinkUrl), }, taskManager: { workspaceCreationPolicy: pickEnum( taskManager.workspaceCreationPolicy, new Set(["any_authorized_user", "task_admins_only", "disabled"]), defaultSettings.taskManager.workspaceCreationPolicy ), }, }; } function normalizeClientIntegrations(payload, fallback = {}) { const integrations = typeof payload === "object" && payload !== null ? payload : {}; const fallbackIntegrations = typeof fallback === "object" && fallback !== null ? fallback : {}; const taskManager = typeof integrations.taskManager === "object" && integrations.taskManager !== null ? integrations.taskManager : {}; const fallbackTaskManager = typeof fallbackIntegrations.taskManager === "object" && fallbackIntegrations.taskManager !== null ? fallbackIntegrations.taskManager : {}; const workspaces = normalizeTaskManagerWorkspaces(taskManager, fallbackTaskManager); const primaryWorkspace = workspaces.find((workspace) => workspace.isPrimary) ?? workspaces[0] ?? null; return { taskManager: { workspaceSlug: primaryWorkspace?.slug ?? null, workspaceName: primaryWorkspace?.name ?? null, workspaces, }, }; } function normalizeTaskManagerWorkspaces(taskManager, fallbackTaskManager = {}) { const sourceWorkspaces = Array.isArray(taskManager.workspaces) ? taskManager.workspaces : Array.isArray(fallbackTaskManager.workspaces) ? fallbackTaskManager.workspaces : []; const legacySlug = nullableStringWithFallback(taskManager.workspaceSlug, fallbackTaskManager.workspaceSlug ?? null); const legacyName = nullableStringWithFallback(taskManager.workspaceName, fallbackTaskManager.workspaceName ?? null); const bySlug = new Map(); for (const item of sourceWorkspaces) { if (typeof item !== "object" || item === null) continue; const slug = nullableString(item.slug); if (!slug) continue; bySlug.set(slug, { slug, name: nullableStringWithFallback(item.name, null), isPrimary: item.isPrimary === true, }); } if (legacySlug && !bySlug.has(legacySlug)) { bySlug.set(legacySlug, { slug: legacySlug, name: legacyName, isPrimary: true }); } const workspaces = [...bySlug.values()]; if (!workspaces.length) return []; if (!workspaces.some((workspace) => workspace.isPrimary)) { workspaces[0].isPrimary = true; } let primarySeen = false; return workspaces.map((workspace) => { const isPrimary = workspace.isPrimary && !primarySeen; if (isPrimary) primarySeen = true; return { slug: workspace.slug, name: workspace.name ?? null, isPrimary, }; }); } function normalizeTaskManagerMembershipRole(value) { return value === "guest" || value === "admin" || value === "member" ? value : "member"; } function upsertTaskManagerMembership(data, payload) { const workspaceSlug = requireString(payload.workspaceSlug, "workspaceSlug"); const existingMembership = data.taskManagerMemberships.find( (membership) => membership.clientId === payload.clientId && membership.userId === payload.userId && membership.workspaceSlug === workspaceSlug ); const nextMembership = { id: existingMembership?.id ?? uniqueId(data.taskManagerMemberships, "tasker_mem", `${payload.clientId}-${payload.userId}-${workspaceSlug}`), clientId: payload.clientId, userId: payload.userId, workspaceSlug, workspaceName: nullableStringWithFallback(payload.workspaceName, existingMembership?.workspaceName ?? null), role: normalizeTaskManagerMembershipRole(payload.role), planeUserId: nullableStringWithFallback(payload.planeUserId, existingMembership?.planeUserId ?? null), planeRole: typeof payload.planeRole === "number" ? payload.planeRole : (existingMembership?.planeRole ?? null), updatedAt: isoNow(), }; if (existingMembership) { Object.assign(existingMembership, nextMembership); return existingMembership; } data.taskManagerMemberships.push(nextMembership); return nextMembership; } function resolveActor(data, identity) { const user = data.users.find( (item) => (identity?.sub && item.authentikUserId === identity.sub) || (identity?.email && item.email.toLowerCase() === identity.email.toLowerCase()) ); if (user) { return { id: user.id, name: user.name, email: user.email, source: "launcher", }; } return { id: identity?.sub ? `oidc:${identity.sub}` : "system", name: identity?.name || identity?.email || "System", email: identity?.email || null, source: "oidc", }; } function addAuditEvent(data, actor, event) { data.auditEvents.push({ id: uniqueId(data.auditEvents, "audit", event.objectName ?? event.objectType ?? "event"), at: isoNow(), actorUserId: actor.id, actorName: actor.name, action: event.action, objectType: event.objectType, objectName: event.objectName, clientId: event.clientId ?? null, result: event.result ?? "success", details: event.details ?? null, }); } function markPendingSync(data, object, objectType, objectName = object.name ?? object.email ?? object.id) { const now = isoNow(); const existingStatus = data.syncStatuses.find( (status) => status.target === "authentik" && status.objectType === objectType && status.objectId === object.id ); if (existingStatus) { existingStatus.objectName = objectName; existingStatus.state = "pending"; existingStatus.error = null; existingStatus.updatedAt = now; return; } data.syncStatuses.push({ id: uniqueId(data.syncStatuses, "sync", `${objectType}-${object.id}`), objectId: object.id, objectName, objectType, target: "authentik", state: "pending", lastSyncAt: null, error: null, updatedAt: now, }); } async function writeJsonAtomically(filePath, payload) { const tempPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`; await writeFile(tempPath, payload, "utf8"); await rename(tempPath, filePath); } function assertGrantTargetExists(data, targetType, targetId) { if (targetType === "client") { findById(data.clients, targetId, "client"); } else if (targetType === "group") { findById(data.groups, targetId, "group"); } else { findById(data.users, targetId, "user"); } } function findInviteByToken(data, token) { const normalizedToken = requireString(token, "token"); const invite = data.invites.find((candidate) => candidate.token === normalizedToken); if (!invite) { throw new Error("Инвайт не найден"); } return invite; } function requireInviteIdentityEmail(identity) { const email = typeof identity?.email === "string" ? identity.email.trim().toLowerCase() : ""; if (!email) { throw new Error("Для принятия инвайта нужна подтверждённая почта"); } return email; } function isInviteExpired(invite) { if (!invite.expiresAt) return false; return Number.isFinite(Date.parse(invite.expiresAt)) && Date.parse(invite.expiresAt) <= Date.now(); } function toPublicInvite(invite) { return { id: invite.id, role: invite.role, expiresAt: invite.expiresAt, status: isInviteExpired(invite) && invite.status !== "accepted" && invite.status !== "revoked" ? "expired" : invite.status, }; } function toPublicClient(client) { return { id: client.id, name: client.name, status: client.status, }; } function applyInviteRegistration(data, token, payload, { commit, provisioning = null } = {}) { const invite = findInviteByToken(data, token); const client = findById(data.clients, invite.clientId, "client"); const now = isoNow(); const requestedEmail = normalizeInviteRegistrationEmail(payload?.email); const email = invite.email.toLowerCase(); const name = optionalString(payload?.name, requestedEmail.split("@")[0]); validateInviteCanBeRegistered(invite); if (!requestedEmail || requestedEmail !== email) { throw new Error("Для этой почты нет активного инвайта"); } let user = data.users.find((item) => item.email.toLowerCase() === email); if (user?.authentikUserId && !provisioning) { throw new Error("Аккаунт уже существует. Войдите под почтой инвайта."); } if (user) { user.name = name; user.globalStatus = "active"; user.authentikUserId = provisioning?.authentikUserId ?? user.authentikUserId ?? null; user.updatedAt = now; } else { user = { id: uniqueId(data.users, "user", email), authentikUserId: provisioning?.authentikUserId ?? null, name, email, phone: null, position: null, notes: `Создан через публичную регистрацию по инвайту клиента ${client.name}.`, avatarUrl: null, globalStatus: "active", createdAt: now, updatedAt: now, }; data.users.push(user); } let membership = data.memberships.find((item) => item.clientId === invite.clientId && item.userId === user.id); if (membership) { membership.role = invite.role; membership.status = "active"; membership.updatedAt = now; } else { membership = { id: uniqueId(data.memberships, "mem", `${invite.clientId}-${email}`), clientId: invite.clientId, userId: user.id, role: invite.role, status: "active", createdAt: now, updatedAt: now, }; data.memberships.push(membership); } invite.status = "accepted"; invite.updatedAt = now; markPendingSync(data, user, "user", email); if (commit) { addAuditEvent(data, { id: user.id, name: user.name, email: user.email, source: "invite" }, { action: "Регистрация по инвайту", objectType: "invite", objectName: invite.email, clientId: client.id, result: "success", details: `Role: ${invite.role}`, }); } return { invite, client, user, membership, data }; } function normalizeInviteRegistrationEmail(value) { return typeof value === "string" ? value.trim().toLowerCase() : ""; } function validateInviteCanBeRegistered(invite) { if (invite.status === "accepted") { throw new Error("Инвайт уже принят"); } if (invite.status === "revoked") { throw new Error("Инвайт отозван"); } if (invite.status === "expired" || isInviteExpired(invite)) { throw new Error("Срок действия инвайта истёк"); } } function findById(items, id, label) { const item = items.find((candidate) => candidate.id === id); if (!item) { throw new Error(`Unknown ${label}: ${id}`); } return item; } function requireString(value, fieldName) { if (typeof value !== "string" || !value.trim()) { throw new Error(`Field ${fieldName} is required`); } return value.trim(); } function optionalString(value, fallback) { return typeof value === "string" && value.trim() ? value.trim() : fallback; } function nullableString(value) { return typeof value === "string" && value.trim() ? value.trim() : null; } function nullableStringWithFallback(value, fallback) { return value === undefined ? fallback : nullableString(value); } function pickEnum(value, allowedValues, fallback) { return typeof value === "string" && allowedValues.has(value) ? value : fallback; } function uniqueId(items, prefix, seed) { const base = `${prefix}_${slugify(seed)}`; let candidate = base; let counter = 1; const ids = new Set(items.map((item) => item.id)); while (ids.has(candidate)) { counter += 1; candidate = `${base}_${counter}`; } return candidate; } function sanitizeServicePatch(payload, service) { const patch = {}; const stringFields = [ "slug", "title", "subtitle", "description", "fullDescription", "url", "launchUrl", "iconUrl", "coverImageUrl", "coverMediaKind", "coverMediaSource", "coverMediaFileName", "previewVideoUrl", "ambientVideoUrl", "ambientMediaKind", "ambientMediaSource", "ambientMediaFileName", "accentColor", "fallbackGradient", "authentikApplicationSlug", "authentikGroupName", ]; for (const field of stringFields) { if (field in (payload ?? {})) { patch[field] = nullableStringWithFallback(payload[field], service[field] ?? null); } } if (typeof payload?.order === "number") { patch.order = payload.order; } if ("isAvailableForAllNewClients" in (payload ?? {})) { patch.isAvailableForAllNewClients = Boolean(payload.isAvailableForAllNewClients); } patch.status = pickEnum(payload?.status, serviceStatuses, service.status); return patch; } function syncServiceLaunchLink(service) { const launchLink = String(service.launchUrl || service.url || "").trim(); return { ...service, url: launchLink, launchUrl: launchLink || null, }; } function slugify(value) { const slug = String(value) .normalize("NFKD") .replace(/[^\w]+/g, "_") .replace(/^_+|_+$/g, "") .toLowerCase() .slice(0, 80) || randomUUID().slice(0, 8); return slug; } function isoNow() { return new Date().toISOString(); }