import { randomUUID } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; import { mkdir, rename, writeFile } from "node:fs/promises"; import { isAbsolute, join, resolve } from "node:path"; const collectionKeys = [ "clients", "users", "memberships", "groups", "services", "grants", "exceptions", "invites", "accessRequests", "revokedAccounts", "taskerInviteRequests", "syncStatuses", "auditEvents", "taskManagerMemberships", "taskManagerProjectMemberships", ]; 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 taskManagerWorkspaceManagedByValues = new Set(["launcher", "tasker"]); const accessRequestStatuses = new Set(["new", "approved", "rejected"]); const taskerInviteRequestStatuses = new Set(["new", "approved", "rejected", "cancelled"]); const taskManagerInviteRoles = new Set(["guest", "member", "admin"]); const publicPoolClientId = "client_public_pool"; const publicPoolClient = { id: publicPoolClientId, type: "person", name: "Открытый контур", legalName: "Public access pool", status: "active", contractStartsAt: null, contractEndsAt: null, paidUntil: null, demoEndsAt: null, contactName: "NODE.DC", contactEmail: null, avatarUrl: null, notes: "Системный контур для публичных заявок, публичных инвайтов и self-service пользователей.", createdAt: "2026-05-09T00:00:00.000Z", updatedAt: "2026-05-09T00:00:00.000Z", }; const defaultSettings = { brand: { logoLinkUrl: "/", }, taskManager: { workspaceCreationPolicy: "any_authorized_user", }, }; export function createControlPlaneStore({ projectRoot }) { const serverStorageRoot = resolveStorageRoot(projectRoot); const legacyPublicDataPath = join(projectRoot, "public", "storage", "launcher-data.json"); const dataPath = join(serverStorageRoot, "launcher-data.json"); function readData() { const readablePath = existsSync(dataPath) ? dataPath : legacyPublicDataPath; if (!existsSync(readablePath)) { return normalizeData({}); } return normalizeData(JSON.parse(readFileSync(readablePath, "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), avatarUrl: nullableString(payload?.avatarUrl), 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); client.avatarUrl = nullableStringWithFallback(payload?.avatarUrl, client.avatarUrl ?? 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), managedBy: payload?.managedBy, 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) ); data.taskManagerProjectMemberships = data.taskManagerProjectMemberships.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 recordTaskManagerProjectMembership(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 : {}; const project = typeof membership.project === "object" && membership.project !== null ? membership.project : {}; upsertTaskManagerProjectMembership(data, { clientId: client.id, userId: user.id, workspaceSlug: workspace.slug ?? payload?.workspaceSlug, workspaceName: workspace.name ?? payload?.workspaceName ?? null, projectId: project.id ?? payload?.projectId, projectIdentifier: project.identifier ?? payload?.projectIdentifier ?? null, projectName: project.name ?? payload?.projectName ?? null, role: normalizeTaskManagerMembershipRole(payload?.role), managedBy: payload?.managedBy, planeUserId: membership.member?.id ?? null, planeRole: typeof membership.role === "number" ? membership.role : null, }); addAuditEvent(data, actor, { action: "Назначен Tasker project", objectType: "task-manager-project-membership", objectName: user.name, clientId: client.id, result: "success", details: `Workspace: ${workspace.name ?? workspace.slug ?? payload?.workspaceSlug}; Project: ${project.name ?? project.identifier ?? payload?.projectId}; Role: ${payload?.role ?? "member"}`, }); await writeData(data); return { data }; } async function removeTaskManagerProjectMembership(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"); const projectId = requireString(payload?.projectId, "projectId"); data.taskManagerProjectMemberships = data.taskManagerProjectMemberships.filter( (membership) => !( membership.clientId === client.id && membership.userId === user.id && membership.workspaceSlug === workspaceSlug && membership.projectId === projectId ) ); addAuditEvent(data, actor, { action: "Снят Tasker project", objectType: "task-manager-project-membership", objectName: user.name, clientId: client.id, result: "success", details: `Workspace: ${workspaceSlug}; Project: ${projectId}`, }); 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 = findClientById(data, clientId); 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"), invitedByUserId: actor.id, inviteId: null, source: "launcher", sourceTaskerInviteRequestId: null, 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; } } clearRevokedAccount(data, email); 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 deleteUser(userId, identity) { const data = readData(); const actor = resolveActor(data, identity); const user = findById(data.users, userId, "user"); if (user.id === "user_root") { throw new Error("Системного root-пользователя нельзя удалить"); } const email = user.email.toLowerCase(); const removedMembershipIds = new Set(data.memberships.filter((membership) => membership.userId === user.id).map((membership) => membership.id)); const removedInviteIds = new Set( data.invites .filter((invite) => invite.email.toLowerCase() === email) .map((invite) => invite.id) ); const removedAccessRequestIds = new Set( data.accessRequests .filter((request) => request.email.toLowerCase() === email) .map((request) => request.id) ); const removedTaskerInviteRequestIds = new Set( data.taskerInviteRequests .filter( (request) => request.inviteeEmail.toLowerCase() === email || request.inviterEmail.toLowerCase() === email || request.inviterUserId === user.id ) .map((request) => request.id) ); upsertRevokedAccount(data, user, actor); data.users = data.users.filter((candidate) => candidate.id !== user.id); data.memberships = data.memberships.filter((membership) => membership.userId !== user.id); data.groups = data.groups.map((group) => ({ ...group, memberIds: group.memberIds.filter((memberId) => memberId !== user.id), updatedAt: group.memberIds.includes(user.id) ? isoNow() : group.updatedAt, })); data.grants = data.grants.filter((grant) => !(grant.targetType === "user" && grant.targetId === user.id)); data.exceptions = data.exceptions.filter((exception) => exception.userId !== user.id); data.invites = data.invites.filter( (invite) => invite.email.toLowerCase() !== email && invite.invitedByUserId !== user.id ); data.accessRequests = data.accessRequests.filter((request) => request.email.toLowerCase() !== email); data.taskerInviteRequests = data.taskerInviteRequests.filter( (request) => request.inviteeEmail.toLowerCase() !== email && request.inviterEmail.toLowerCase() !== email && request.inviterUserId !== user.id ); data.taskManagerMemberships = data.taskManagerMemberships.filter((membership) => membership.userId !== user.id); data.taskManagerProjectMemberships = data.taskManagerProjectMemberships.filter((membership) => membership.userId !== user.id); data.syncStatuses = data.syncStatuses.filter((syncStatus) => { if (syncStatus.objectId === user.id || syncStatus.objectName?.toLowerCase?.() === email) return false; if (removedInviteIds.has(syncStatus.objectId)) return false; if (removedAccessRequestIds.has(syncStatus.objectId)) return false; if (removedTaskerInviteRequestIds.has(syncStatus.objectId)) return false; if (removedMembershipIds.has(syncStatus.objectId)) return false; return true; }); addAuditEvent(data, actor, { action: "Пользователь удалён полностью", objectType: "user", objectName: email, clientId: null, result: "warning", details: `Удалены профиль, членства: ${removedMembershipIds.size}, инвайты: ${removedInviteIds.size}, заявки: ${ removedAccessRequestIds.size + removedTaskerInviteRequestIds.size }`, }); await writeData(data); return { user, affected: { memberships: removedMembershipIds.size, invites: removedInviteIds.size, accessRequests: removedAccessRequestIds.size, taskerInviteRequests: removedTaskerInviteRequestIds.size, }, 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 = findClientById(data, clientId); 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, source: "launcher", sourceTaskerInviteRequestId: null, sourceTaskerInviteId: null, sourceWorkspaceSlug: null, sourceWorkspaceName: null, 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 createAccessRequest(payload) { const data = readData(); const now = isoNow(); const requestPayload = sanitizeAccessRequestPayload(payload); const email = requestPayload.email.toLowerCase(); const existingUser = data.users.find((candidate) => candidate.email.toLowerCase() === email); if (existingUser?.globalStatus === "blocked") { throw new Error("Аккаунт с этой почтой заблокирован. Обратитесь к администратору NODE.DC."); } if (existingUser?.globalStatus === "active") { const hasOnlyPendingAccessRequest = data.accessRequests.some((candidate) => candidate.email.toLowerCase() === email && candidate.status === "new") && data.memberships.some( (membership) => membership.userId === existingUser.id && membership.clientId === publicPoolClientId && membership.source === "access_request" && membership.status === "disabled" ); if (!hasOnlyPendingAccessRequest) { throw new Error("Аккаунт с этой почтой уже существует. Войдите в NODE.DC или обратитесь к администратору."); } } const user = upsertAccessRequestUser(data, requestPayload, now); upsertAccessRequestMembership(data, user, requestPayload, { status: "disabled", now }); clearRevokedAccount(data, email); markPendingSync(data, user, "user", user.email); const existingRequest = data.accessRequests.find( (candidate) => candidate.email.toLowerCase() === email && candidate.status === "new" ); if (existingRequest) { Object.assign(existingRequest, { ...requestPayload, email, updatedAt: now, }); addAuditEvent(data, { id: "public", name: requestPayload.company, email, source: "public" }, { action: "Обновлена публичная заявка", objectType: "access_request", objectName: email, clientId: null, result: "success", details: requestPayload.company, }); await writeData(data); return { accessRequest: existingRequest, user, data }; } const accessRequest = { id: uniqueId(data.accessRequests, "access_request", email), ...requestPayload, email, status: "new", targetClientId: publicPoolClientId, role: "member", approvedInviteId: null, reviewedByUserId: null, reviewedAt: null, comment: null, createdAt: now, updatedAt: now, }; data.accessRequests.push(accessRequest); addAuditEvent(data, { id: "public", name: requestPayload.company, email, source: "public" }, { action: "Создана публичная заявка", objectType: "access_request", objectName: email, clientId: null, result: "success", details: requestPayload.company, }); await writeData(data); return { accessRequest, user, data }; } async function updateAccessRequest(accessRequestId, payload, identity) { const data = readData(); const actor = resolveActor(data, identity); const accessRequest = findAccessRequestById(data, accessRequestId); if (payload?.targetClientId !== undefined) { accessRequest.targetClientId = resolveAccessRequestTargetClientId(data, payload.targetClientId, accessRequest.targetClientId); } accessRequest.role = pickEnum(payload?.role, membershipRoles, accessRequest.role); accessRequest.comment = nullableStringWithFallback(payload?.comment, accessRequest.comment ?? null); accessRequest.updatedAt = isoNow(); addAuditEvent(data, actor, { action: "Обновлена публичная заявка", objectType: "access_request", objectName: accessRequest.email, clientId: accessRequest.targetClientId === publicPoolClientId ? null : accessRequest.targetClientId, result: "success", details: `Target: ${accessRequest.targetClientId}; role: ${accessRequest.role}`, }); await writeData(data); return { accessRequest, data }; } async function approveAccessRequest(accessRequestId, payload, identity) { const data = readData(); const actor = resolveActor(data, identity); const now = isoNow(); const accessRequest = findAccessRequestById(data, accessRequestId); if (accessRequest.status === "rejected") { throw new Error("Отклонённую заявку нельзя подтвердить без повторного запроса"); } if (payload?.targetClientId !== undefined) { accessRequest.targetClientId = resolveAccessRequestTargetClientId(data, payload.targetClientId, accessRequest.targetClientId); } else { accessRequest.targetClientId = resolveAccessRequestTargetClientId(data, accessRequest.targetClientId, publicPoolClientId); } accessRequest.role = pickEnum(payload?.role, membershipRoles, accessRequest.role); accessRequest.comment = nullableStringWithFallback(payload?.comment, accessRequest.comment ?? null); const existingUser = data.users.find((candidate) => candidate.email.toLowerCase() === accessRequest.email.toLowerCase()); if (existingUser?.globalStatus === "blocked") { throw new Error("Заблокированный аккаунт нельзя подтвердить без ручного разблокирования."); } const user = upsertAccessRequestUser(data, accessRequest, now); const client = findClientById(data, accessRequest.targetClientId); const membership = upsertAccessRequestMembership(data, user, accessRequest, { status: "active", clientId: client.id, invitedByUserId: actor.id, now, }); if (client.id !== publicPoolClientId) { data.memberships = data.memberships.filter( (candidate) => !( candidate.userId === user.id && candidate.clientId === publicPoolClientId && candidate.source === "access_request" && candidate.status === "disabled" ) ); } if (accessRequest.status === "approved") { markPendingSync(data, user, "user", user.email); await writeData(data); return { accessRequest, user, membership, invite: null, data }; } accessRequest.status = "approved"; accessRequest.approvedInviteId = null; accessRequest.reviewedByUserId = actor.id; accessRequest.reviewedAt = now; accessRequest.updatedAt = now; addAuditEvent(data, actor, { action: "Подтверждена публичная заявка", objectType: "access_request", objectName: accessRequest.email, clientId: client.id === publicPoolClientId ? null : client.id, result: "success", details: `Account activated; target: ${client.name}; role: ${membership.role}`, }); markPendingSync(data, user, "user", user.email); await writeData(data); return { accessRequest, user, membership, invite: null, data }; } async function rejectAccessRequest(accessRequestId, payload, identity) { const data = readData(); const actor = resolveActor(data, identity); const accessRequest = findAccessRequestById(data, accessRequestId); const now = isoNow(); if (accessRequest.status === "approved") { throw new Error("Подтверждённую заявку нельзя отклонить"); } accessRequest.status = "rejected"; accessRequest.comment = nullableStringWithFallback(payload?.comment, accessRequest.comment ?? null); accessRequest.reviewedByUserId = actor.id; accessRequest.reviewedAt = now; accessRequest.updatedAt = now; addAuditEvent(data, actor, { action: "Отклонена публичная заявка", objectType: "access_request", objectName: accessRequest.email, clientId: accessRequest.targetClientId === publicPoolClientId ? null : accessRequest.targetClientId, result: "warning", details: accessRequest.comment, }); await writeData(data); return { accessRequest, data }; } async function createTaskerInviteRequest(payload, identity = { name: "Operational Core", source: "tasker" }) { const data = readData(); const now = isoNow(); const actor = resolveActor(data, identity); const taskerInviteId = requireString(payload?.taskerInviteId, "taskerInviteId"); const workspaceSlug = requireString(payload?.workspaceSlug, "workspaceSlug"); const workspaceName = optionalString(payload?.workspaceName, workspaceSlug); const inviteeEmail = requireString(payload?.inviteeEmail, "inviteeEmail").toLowerCase(); const role = normalizeTaskManagerInviteRole(payload?.role); const existingRequest = data.taskerInviteRequests.find( (request) => request.taskerInviteId === taskerInviteId || (request.status === "new" && request.workspaceSlug === workspaceSlug && request.inviteeEmail.toLowerCase() === inviteeEmail) ); const request = existingRequest ?? { id: uniqueId(data.taskerInviteRequests, "tasker_invite_request", `${workspaceSlug}-${inviteeEmail}`), taskerInviteId, createdAt: now, }; Object.assign(request, { taskerInviteId, workspaceId: nullableStringWithFallback(payload?.workspaceId, request.workspaceId ?? null), workspaceSlug, workspaceName, inviteeEmail, role, inviterUserId: nullableStringWithFallback(payload?.inviterUserId, request.inviterUserId ?? null), inviterPlaneUserId: nullableStringWithFallback(payload?.inviterPlaneUserId, request.inviterPlaneUserId ?? null), inviterEmail: requireString(payload?.inviterEmail, "inviterEmail").toLowerCase(), inviterName: optionalString(payload?.inviterName, payload?.inviterEmail ?? "Operational Core user"), status: existingRequest?.status && existingRequest.status !== "rejected" ? existingRequest.status : "new", taskerInviteLink: existingRequest?.taskerInviteLink ?? null, reviewedByUserId: existingRequest?.reviewedByUserId ?? null, reviewedAt: existingRequest?.reviewedAt ?? null, comment: nullableStringWithFallback(payload?.comment, existingRequest?.comment ?? null), updatedAt: now, }); if (!existingRequest) { data.taskerInviteRequests.push(request); } addAuditEvent(data, actor, { action: existingRequest ? "Обновлена заявка workspace-инвайта" : "Создана заявка workspace-инвайта", objectType: "tasker_invite_request", objectName: `${workspaceSlug}:${inviteeEmail}`, result: "success", details: `Role: ${role}`, }); await writeData(data); return { taskerInviteRequest: request, data }; } async function approveTaskerInviteRequest(taskerInviteRequestId, payload, identity) { const data = readData(); const actor = resolveActor(data, identity); const request = findTaskerInviteRequestById(data, taskerInviteRequestId); const now = isoNow(); if (request.status === "rejected") { throw new Error("Отклонённую заявку workspace-инвайта нельзя подтвердить"); } request.status = "approved"; request.taskerInviteLink = nullableStringWithFallback(payload?.taskerInviteLink, request.taskerInviteLink ?? null); request.platformInviteId = nullableStringWithFallback(payload?.platformInviteId, request.platformInviteId ?? null); request.platformInviteToken = nullableStringWithFallback(payload?.platformInviteToken, request.platformInviteToken ?? null); request.comment = nullableStringWithFallback(payload?.comment, request.comment ?? null); request.reviewedByUserId = actor.id; request.reviewedAt = now; request.updatedAt = now; if (request.platformInviteId) { const invite = data.invites.find((candidate) => candidate.id === request.platformInviteId); if (invite) { invite.sourceTaskerInviteLink = request.taskerInviteLink ?? invite.sourceTaskerInviteLink ?? null; invite.updatedAt = now; } } addAuditEvent(data, actor, { action: "Подтверждена заявка workspace-инвайта", objectType: "tasker_invite_request", objectName: `${request.workspaceSlug}:${request.inviteeEmail}`, result: "success", details: request.taskerInviteLink ?? null, }); await writeData(data); return { taskerInviteRequest: request, data }; } async function ensureTaskerInvitePlatformInvite(taskerInviteRequestId, identity) { const data = readData(); const actor = resolveActor(data, identity); const request = findTaskerInviteRequestById(data, taskerInviteRequestId); const now = isoNow(); const client = findClientById(data, publicPoolClientId); let invite = request.platformInviteId ? data.invites.find((candidate) => candidate.id === request.platformInviteId) : null; if (!invite) { invite = data.invites.find( (candidate) => candidate.source === "tasker_workspace_invite" && candidate.sourceTaskerInviteRequestId === request.id && candidate.email.toLowerCase() === request.inviteeEmail.toLowerCase() ); } if (!invite) { invite = { id: uniqueId(data.invites, "invite", `${request.workspaceSlug}-${request.inviteeEmail}`), clientId: client.id, email: request.inviteeEmail, role: "member", invitedByUserId: request.inviterUserId || actor.id, source: "tasker_workspace_invite", sourceTaskerInviteRequestId: request.id, sourceTaskerInviteId: request.taskerInviteId, sourceWorkspaceSlug: request.workspaceSlug, sourceWorkspaceName: request.workspaceName, sourceTaskerInviteLink: request.taskerInviteLink ?? null, token: randomUUID(), expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), status: "created", createdAt: now, updatedAt: now, }; data.invites.push(invite); addAuditEvent(data, actor, { action: "Создан platform-инвайт для workspace-инвайта", objectType: "invite", objectName: invite.email, clientId: client.id, result: "success", details: `${request.workspaceSlug}; inviter: ${request.inviterEmail}`, }); } else { invite.clientId = client.id; invite.role = "member"; invite.invitedByUserId = request.inviterUserId || invite.invitedByUserId || actor.id; invite.source = "tasker_workspace_invite"; invite.sourceTaskerInviteRequestId = request.id; invite.sourceTaskerInviteId = request.taskerInviteId; invite.sourceWorkspaceSlug = request.workspaceSlug; invite.sourceWorkspaceName = request.workspaceName; invite.status = invite.status === "revoked" || invite.status === "expired" ? "created" : invite.status; invite.updatedAt = now; } request.platformInviteId = invite.id; request.platformInviteToken = invite.token; request.updatedAt = now; await writeData(data); return { taskerInviteRequest: request, invite, data }; } async function rejectTaskerInviteRequest(taskerInviteRequestId, payload, identity) { const data = readData(); const actor = resolveActor(data, identity); const request = findTaskerInviteRequestById(data, taskerInviteRequestId); const now = isoNow(); if (request.status === "approved") { throw new Error("Подтверждённую заявку workspace-инвайта нельзя отклонить"); } request.status = "rejected"; request.comment = nullableStringWithFallback(payload?.comment, request.comment ?? null); request.reviewedByUserId = actor.id; request.reviewedAt = now; request.updatedAt = now; addAuditEvent(data, actor, { action: "Отклонена заявка workspace-инвайта", objectType: "tasker_invite_request", objectName: `${request.workspaceSlug}:${request.inviteeEmail}`, result: "warning", details: request.comment ?? null, }); await writeData(data); return { taskerInviteRequest: request, data }; } async function cancelTaskerInviteRequest(payload, identity = { name: "Operational Core", source: "tasker" }) { const data = readData(); const actor = resolveActor(data, identity); const request = findTaskerInviteRequestForCancellation(data, payload); if (!request) { return { taskerInviteRequest: null, affectedUserIds: [], data }; } request.status = "cancelled"; request.taskerInviteLink = null; request.comment = nullableStringWithFallback(payload?.comment, "Отозвано в Operational Core"); request.updatedAt = isoNow(); const affectedUserIds = revokeTaskerInviteServiceAccessIfOrphaned(data, request, request.updatedAt); if (request.platformInviteId) { const invite = data.invites.find((candidate) => candidate.id === request.platformInviteId); if (invite && invite.status !== "accepted") { invite.status = "revoked"; invite.updatedAt = request.updatedAt; } } addAuditEvent(data, actor, { action: "Отозвана заявка workspace-инвайта", objectType: "tasker_invite_request", objectName: `${request.workspaceSlug}:${request.inviteeEmail}`, result: "warning", details: request.comment, }); await writeData(data); return { taskerInviteRequest: request, affectedUserIds, 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 = findClientById(data, invite.clientId); const existingUser = data.users.find((user) => user.email.toLowerCase() === invite.email.toLowerCase()) ?? null; return { invite: toPublicInvite(invite), client: toPublicClient(client), redirectUrl: resolvePublicInviteRedirectUrl(invite), account: { exists: Boolean(existingUser?.authentikUserId), email: invite.email, }, }; } 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 = findClientById(data, invite.clientId); const email = requireInviteIdentityEmail(identity); const now = isoNow(); if (invite.email.toLowerCase() !== email) { throw new Error("Этот инвайт выписан на другую почту"); } if (invite.status === "accepted") { 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("Срок действия инвайта истёк"); } validateTaskerInviteSourceCanBeAccepted(data, invite); const actor = resolveActor(data, identity); let user = data.users.find((item) => item.email.toLowerCase() === email); if (user?.globalStatus === "blocked") { throw new Error("Аккаунт заблокирован. Обратитесь к администратору NODE.DC."); } 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.invitedByUserId = invite.invitedByUserId ?? membership.invitedByUserId ?? null; membership.inviteId = invite.id; membership.source = invite.source ?? membership.source ?? "launcher"; membership.sourceTaskerInviteRequestId = invite.sourceTaskerInviteRequestId ?? membership.sourceTaskerInviteRequestId ?? null; membership.updatedAt = now; } else { membership = { id: uniqueId(data.memberships, "mem", `${invite.clientId}-${email}`), clientId: invite.clientId, userId: user.id, role: invite.role, status: "active", invitedByUserId: invite.invitedByUserId ?? null, inviteId: invite.id, source: invite.source ?? "launcher", sourceTaskerInviteRequestId: invite.sourceTaskerInviteRequestId ?? null, createdAt: now, updatedAt: now, }; data.memberships.push(membership); } ensureTaskerInviteServiceAccess(data, invite, user, now); clearRevokedAccount(data, email); 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; clearRevokedAccount(data, user.email); 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() { return [serverStorageRoot]; } function getLoginAccountStatus(email) { const data = readData(); const normalizedEmail = normalizeEmail(email); if (!normalizedEmail) { return { status: "unknown" }; } const existingUser = data.users.find((user) => normalizeEmail(user.email) === normalizedEmail); if (existingUser) { return { status: "unknown" }; } const revokedAccount = data.revokedAccounts.find((account) => normalizeEmail(account.email) === normalizedEmail); if (revokedAccount) { return { status: "revoked", revokedAt: revokedAccount.revokedAt ?? null }; } const hardDeleteAuditEvent = data.auditEvents.find( (event) => normalizeEmail(event.objectName) === normalizedEmail && event.objectType === "user" && event.action === "Пользователь удалён полностью" ); return hardDeleteAuditEvent ? { status: "revoked", revokedAt: hardDeleteAuditEvent.at ?? null } : { status: "unknown" }; } return { approveAccessRequest, approveTaskerInviteRequest, buildAuthentikSyncPlan, cancelTaskerInviteRequest, createAccessRequest, createTaskerInviteRequest, createClient, createGroup, createInvite, createService, createUser, deleteClient, deleteGroup, deleteInvite, deleteMembership, deleteService, deleteUser, rejectAccessRequest, rejectTaskerInviteRequest, acceptInvite, commitInviteRegistration, getInviteByToken, getLoginAccountStatus, getSnapshot, ensureTaskerInvitePlatformInvite, prepareInviteRegistration, readData, replaceData, reorderServices, retrySync, markUserAuthentikProvisioned, recordTaskManagerProjectMembership, recordTaskManagerWorkspaceMembership, removeTaskManagerProjectMembership, removeTaskManagerWorkspaceMembership, setUserServiceAccess, updateAccessRequest, updateClient, updateGroup, updateInvite, updateMembership, updateService, updateSettings, updateUserProfile, upsertException, upsertGrant, writeData, }; } function resolveStorageRoot(projectRoot) { const configuredRoot = process.env.NODEDC_LAUNCHER_STORAGE_DIR; if (configuredRoot && configuredRoot.trim()) { return isAbsolute(configuredRoot) ? configuredRoot : resolve(projectRoot, configuredRoot); } return join(projectRoot, "server", "storage"); } 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), })); data.accessRequests = data.accessRequests.map(normalizeAccessRequest).filter(Boolean); data.revokedAccounts = data.revokedAccounts.map(normalizeRevokedAccount).filter(Boolean); data.taskerInviteRequests = data.taskerInviteRequests.map(normalizeTaskerInviteRequest).filter(Boolean); return data; } function normalizeRevokedAccount(payload) { if (typeof payload !== "object" || payload === null) return null; const email = normalizeEmail(payload.email); if (!email) return null; const now = isoNow(); return { id: optionalString(payload.id, `revoked_account_${slugify(email)}`), email, name: nullableStringWithFallback(payload.name, null), sourceUserId: nullableStringWithFallback(payload.sourceUserId, null), authentikUserId: nullableStringWithFallback(payload.authentikUserId, null), reason: optionalString(payload.reason, "hard_deleted"), revokedByUserId: nullableStringWithFallback(payload.revokedByUserId, null), revokedByUserEmail: nullableStringWithFallback(payload.revokedByUserEmail, null), revokedByUserName: nullableStringWithFallback(payload.revokedByUserName, null), revokedAt: optionalString(payload.revokedAt, now), createdAt: optionalString(payload.createdAt, now), updatedAt: optionalString(payload.updatedAt, now), }; } function normalizeAccessRequest(payload) { if (typeof payload !== "object" || payload === null) return null; const now = isoNow(); const email = typeof payload.email === "string" ? payload.email.trim().toLowerCase() : ""; const firstName = optionalString(payload.firstName, ""); const lastName = optionalString(payload.lastName, ""); const middleName = optionalString(payload.middleName, ""); const phone = optionalString(payload.phone, ""); const company = optionalString(payload.company, ""); if (!email || !firstName || !lastName || !middleName || !phone || !company) return null; return { id: optionalString(payload.id, `access_request_${slugify(email)}`), email, firstName, lastName, middleName, phone, company, status: pickEnum(payload.status, accessRequestStatuses, "new"), targetClientId: typeof payload.targetClientId === "string" && payload.targetClientId.trim() ? payload.targetClientId.trim() : publicPoolClientId, role: pickEnum(payload.role, membershipRoles, "member"), approvedInviteId: nullableStringWithFallback(payload.approvedInviteId, null), reviewedByUserId: nullableStringWithFallback(payload.reviewedByUserId, null), reviewedAt: nullableStringWithFallback(payload.reviewedAt, null), comment: nullableStringWithFallback(payload.comment, null), createdAt: optionalString(payload.createdAt, now), updatedAt: optionalString(payload.updatedAt, now), }; } function normalizeTaskerInviteRequest(payload) { if (typeof payload !== "object" || payload === null) return null; const now = isoNow(); const taskerInviteId = typeof payload.taskerInviteId === "string" ? payload.taskerInviteId.trim() : ""; const workspaceSlug = typeof payload.workspaceSlug === "string" ? payload.workspaceSlug.trim() : ""; const inviteeEmail = typeof payload.inviteeEmail === "string" ? payload.inviteeEmail.trim().toLowerCase() : ""; const inviterEmail = typeof payload.inviterEmail === "string" ? payload.inviterEmail.trim().toLowerCase() : ""; if (!taskerInviteId || !workspaceSlug || !inviteeEmail || !inviterEmail) return null; return { id: optionalString(payload.id, `tasker_invite_request_${slugify(`${workspaceSlug}-${inviteeEmail}`)}`), taskerInviteId, workspaceId: nullableStringWithFallback(payload.workspaceId, null), workspaceSlug, workspaceName: optionalString(payload.workspaceName, workspaceSlug), inviteeEmail, role: normalizeTaskManagerInviteRole(payload.role), inviterUserId: nullableStringWithFallback(payload.inviterUserId, null), inviterPlaneUserId: nullableStringWithFallback(payload.inviterPlaneUserId, null), inviterEmail, inviterName: optionalString(payload.inviterName, inviterEmail), status: pickEnum(payload.status, taskerInviteRequestStatuses, "new"), taskerInviteLink: nullableStringWithFallback(payload.taskerInviteLink, null), platformInviteId: nullableStringWithFallback(payload.platformInviteId, null), platformInviteToken: nullableStringWithFallback(payload.platformInviteToken, null), reviewedByUserId: nullableStringWithFallback(payload.reviewedByUserId, null), reviewedAt: nullableStringWithFallback(payload.reviewedAt, null), comment: nullableStringWithFallback(payload.comment, null), createdAt: optionalString(payload.createdAt, now), updatedAt: optionalString(payload.updatedAt, now), }; } 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, managedBy: normalizeTaskManagerWorkspaceManagedBy(item.managedBy), }); } if (legacySlug && !bySlug.has(legacySlug)) { bySlug.set(legacySlug, { slug: legacySlug, name: legacyName, isPrimary: true, managedBy: "launcher" }); } 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, managedBy: normalizeTaskManagerWorkspaceManagedBy(workspace.managedBy), }; }); } function normalizeTaskManagerWorkspaceManagedBy(value) { return taskManagerWorkspaceManagedByValues.has(value) ? value : "launcher"; } 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), managedBy: normalizeTaskManagerWorkspaceManagedBy(payload.managedBy ?? existingMembership?.managedBy), 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 upsertTaskManagerProjectMembership(data, payload) { const workspaceSlug = requireString(payload.workspaceSlug, "workspaceSlug"); const projectId = requireString(payload.projectId, "projectId"); const existingMembership = data.taskManagerProjectMemberships.find( (membership) => membership.clientId === payload.clientId && membership.userId === payload.userId && membership.workspaceSlug === workspaceSlug && membership.projectId === projectId ); const nextMembership = { id: existingMembership?.id ?? uniqueId(data.taskManagerProjectMemberships, "tasker_project_mem", `${payload.clientId}-${payload.userId}-${workspaceSlug}-${projectId}`), clientId: payload.clientId, userId: payload.userId, workspaceSlug, workspaceName: nullableStringWithFallback(payload.workspaceName, existingMembership?.workspaceName ?? null), projectId, projectIdentifier: nullableStringWithFallback(payload.projectIdentifier, existingMembership?.projectIdentifier ?? null), projectName: nullableStringWithFallback(payload.projectName, existingMembership?.projectName ?? null), role: normalizeTaskManagerMembershipRole(payload.role), managedBy: normalizeTaskManagerWorkspaceManagedBy(payload.managedBy ?? existingMembership?.managedBy), 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.taskManagerProjectMemberships.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 upsertRevokedAccount(data, user, actor) { const email = normalizeEmail(user.email); if (!email) return null; const now = isoNow(); const existingAccount = data.revokedAccounts.find((account) => normalizeEmail(account.email) === email); const patch = { email, name: user.name ?? null, sourceUserId: user.id, authentikUserId: user.authentikUserId ?? null, reason: "hard_deleted", revokedByUserId: actor.id ?? null, revokedByUserEmail: actor.email ?? null, revokedByUserName: actor.name ?? null, revokedAt: now, updatedAt: now, }; if (existingAccount) { Object.assign(existingAccount, patch); return existingAccount; } const revokedAccount = { id: uniqueId(data.revokedAccounts, "revoked_account", email), ...patch, createdAt: now, }; data.revokedAccounts.push(revokedAccount); return revokedAccount; } function clearRevokedAccount(data, email) { const normalizedEmail = normalizeEmail(email); if (!normalizedEmail) return; data.revokedAccounts = data.revokedAccounts.filter((account) => normalizeEmail(account.email) !== normalizedEmail); } 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") { findClientById(data, targetId); } 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, email: invite.email, role: invite.role, expiresAt: invite.expiresAt, status: isInviteExpired(invite) && invite.status !== "accepted" && invite.status !== "revoked" ? "expired" : invite.status, source: invite.source ?? "launcher", sourceWorkspaceName: invite.sourceWorkspaceName ?? null, sourceWorkspaceSlug: invite.sourceWorkspaceSlug ?? null, }; } function resolvePublicInviteRedirectUrl(invite) { if (invite?.source === "tasker_workspace_invite" && invite.sourceTaskerInviteRequestId) { return `/tasker-workspace-invite/${encodeURIComponent(invite.sourceTaskerInviteRequestId)}`; } return "/"; } function toPublicClient(client) { return { id: client.id, name: client.name, status: client.status, }; } function ensureTaskerInviteServiceAccess(data, invite, user, now) { if (invite.source !== "tasker_workspace_invite") { return null; } const service = data.services.find((candidate) => candidate.slug === "task-manager"); if (!service) { return null; } const taskerInviteRequest = invite.sourceTaskerInviteRequestId ? data.taskerInviteRequests.find((request) => request.id === invite.sourceTaskerInviteRequestId) : null; const requestedAppRole = taskerInviteRequest?.role === "guest" ? "viewer" : "member"; const existingGrant = data.grants.find( (grant) => grant.serviceId === service.id && grant.targetType === "user" && grant.targetId === user.id ); const existingException = data.exceptions.find((exception) => exception.serviceId === service.id && exception.userId === user.id); if (existingException?.type === "deny") { return null; } if (existingGrant) { existingGrant.status = "active"; existingGrant.appRole = existingGrant.appRole === "admin" || existingGrant.appRole === "owner" ? existingGrant.appRole : requestedAppRole; existingGrant.updatedAt = now; markPendingSync(data, { id: `${service.id}:${user.id}` }, "grant", `${service.slug}:${user.email}`); return existingGrant; } const grant = { id: uniqueId(data.grants, "grant", `${service.slug}-user-${user.email}`), serviceId: service.id, targetType: "user", targetId: user.id, appRole: requestedAppRole, status: "active", createdAt: now, updatedAt: now, }; data.grants.push(grant); markPendingSync(data, { id: `${service.id}:${user.id}` }, "grant", `${service.slug}:${user.email}`); return grant; } function findTaskerInviteRequestForCancellation(data, payload) { const requestId = nullableString(payload?.requestId); const taskerInviteId = nullableString(payload?.taskerInviteId); const workspaceSlug = nullableString(payload?.workspaceSlug); const inviteeEmail = nullableString(payload?.inviteeEmail)?.toLowerCase() ?? null; if (requestId || taskerInviteId) { const request = data.taskerInviteRequests.find( (candidate) => (requestId && candidate.id === requestId) || (taskerInviteId && candidate.taskerInviteId === taskerInviteId) ); if (request) return request; } if (!workspaceSlug || !inviteeEmail) { return null; } return data.taskerInviteRequests .filter( (candidate) => candidate.workspaceSlug === workspaceSlug && candidate.inviteeEmail.toLowerCase() === inviteeEmail ) .sort((left, right) => { if (left.status !== "cancelled" && right.status === "cancelled") return -1; if (left.status === "cancelled" && right.status !== "cancelled") return 1; return Date.parse(right.updatedAt ?? right.createdAt ?? 0) - Date.parse(left.updatedAt ?? left.createdAt ?? 0); })[0] ?? null; } function revokeTaskerInviteServiceAccessIfOrphaned(data, request, now) { const user = data.users.find((candidate) => candidate.email.toLowerCase() === request.inviteeEmail.toLowerCase()); const service = data.services.find((candidate) => candidate.slug === "task-manager"); if (!user || !service) { return []; } const hasAnotherAcceptedWorkspaceInvite = data.taskerInviteRequests.some((candidate) => { if (candidate.id === request.id) return false; if (candidate.status !== "approved") return false; if (candidate.inviteeEmail.toLowerCase() !== request.inviteeEmail.toLowerCase()) return false; const platformInvite = candidate.platformInviteId ? data.invites.find((invite) => invite.id === candidate.platformInviteId) : null; return platformInvite?.status === "accepted"; }); if (hasAnotherAcceptedWorkspaceInvite) { return []; } const directGrant = data.grants.find( (grant) => grant.serviceId === service.id && grant.targetType === "user" && grant.targetId === user.id && grant.status === "active" ); if (!directGrant || directGrant.appRole === "admin" || directGrant.appRole === "owner") { return []; } data.grants = data.grants.filter((grant) => grant.id !== directGrant.id); markPendingSync(data, { id: `${service.id}:${user.id}` }, "grant", `${service.slug}:${user.email}`); markPendingSync(data, user, "user", user.email); addAuditEvent(data, { id: user.id, name: user.name, email: user.email, source: "tasker" }, { action: "Снят доступ Operational Core по workspace-инвайту", objectType: "grant", objectName: `${user.email} / ${service.slug}`, result: "warning", details: `Workspace invite: ${request.workspaceSlug}; cancelled at: ${now}`, }); return [user.id]; } function applyInviteRegistration(data, token, payload, { commit, provisioning = null } = {}) { const invite = findInviteByToken(data, token); const client = findClientById(data, invite.clientId); const now = isoNow(); const requestedEmail = normalizeInviteRegistrationEmail(payload?.email); const email = invite.email.toLowerCase(); const name = optionalString(payload?.name, requestedEmail.split("@")[0]); validateInviteCanBeRegistered(invite); validateTaskerInviteSourceCanBeAccepted(data, invite); if (!requestedEmail || requestedEmail !== email) { throw new Error("Для этой почты нет активного инвайта"); } let user = data.users.find((item) => item.email.toLowerCase() === email); if (user?.globalStatus === "blocked") { throw new Error("Аккаунт заблокирован. Обратитесь к администратору NODE.DC."); } 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.invitedByUserId = invite.invitedByUserId ?? membership.invitedByUserId ?? null; membership.inviteId = invite.id; membership.source = invite.source ?? membership.source ?? "launcher"; membership.sourceTaskerInviteRequestId = invite.sourceTaskerInviteRequestId ?? membership.sourceTaskerInviteRequestId ?? null; membership.updatedAt = now; } else { membership = { id: uniqueId(data.memberships, "mem", `${invite.clientId}-${email}`), clientId: invite.clientId, userId: user.id, role: invite.role, status: "active", invitedByUserId: invite.invitedByUserId ?? null, inviteId: invite.id, source: invite.source ?? "launcher", sourceTaskerInviteRequestId: invite.sourceTaskerInviteRequestId ?? null, createdAt: now, updatedAt: now, }; data.memberships.push(membership); } ensureTaskerInviteServiceAccess(data, invite, user, now); clearRevokedAccount(data, email); 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 validateTaskerInviteSourceCanBeAccepted(data, invite) { if (invite.source !== "tasker_workspace_invite") { return; } const request = invite.sourceTaskerInviteRequestId ? data.taskerInviteRequests.find((candidate) => candidate.id === invite.sourceTaskerInviteRequestId) : null; if (!request || request.status !== "approved") { throw new Error("Workspace-инвайт больше не активен"); } } function findById(items, id, label) { const item = items.find((candidate) => candidate.id === id); if (!item) { throw new Error(`Unknown ${label}: ${id}`); } return item; } function findClientById(data, clientId) { if (clientId === publicPoolClientId) { return publicPoolClient; } return findById(data.clients, clientId, "client"); } function findAccessRequestById(data, accessRequestId) { return findById(data.accessRequests, accessRequestId, "access_request"); } function findTaskerInviteRequestById(data, taskerInviteRequestId) { return findById(data.taskerInviteRequests, taskerInviteRequestId, "tasker_invite_request"); } function resolveAccessRequestTargetClientId(data, value, fallback = publicPoolClientId) { const clientId = optionalString(value, fallback || publicPoolClientId); findClientById(data, clientId); return clientId; } function sanitizeAccessRequestPayload(payload) { const email = requireString(payload?.email, "email").toLowerCase(); if (!isValidEmail(email)) { throw new Error("Введите корректную электронную почту"); } return { email, firstName: requireString(payload?.firstName, "firstName").slice(0, 80), lastName: requireString(payload?.lastName, "lastName").slice(0, 80), middleName: requireString(payload?.middleName, "middleName").slice(0, 80), phone: requireString(payload?.phone, "phone").slice(0, 80), company: requireString(payload?.company, "company").slice(0, 160), }; } function upsertAccessRequestUser(data, requestPayload, now) { const email = requestPayload.email.toLowerCase(); const existingUser = data.users.find((candidate) => candidate.email.toLowerCase() === email); const userName = buildAccessRequestUserName(requestPayload); if (existingUser) { existingUser.name = userName; existingUser.phone = requestPayload.phone; existingUser.notes = `Public access request: ${requestPayload.company}`; existingUser.globalStatus = existingUser.globalStatus === "blocked" ? "blocked" : "active"; existingUser.updatedAt = now; return existingUser; } const user = { id: uniqueId(data.users, "user", email), authentikUserId: null, email, name: userName, phone: requestPayload.phone, position: null, notes: `Public access request: ${requestPayload.company}`, avatarUrl: null, globalStatus: "active", createdAt: now, updatedAt: now, }; data.users.push(user); return user; } function upsertAccessRequestMembership(data, user, requestPayload, options = {}) { const now = options.now ?? isoNow(); const clientId = options.clientId ?? publicPoolClientId; const role = pickEnum(requestPayload.role, membershipRoles, "member"); const existingMembership = data.memberships.find( (membership) => membership.userId === user.id && membership.clientId === clientId ); if (existingMembership) { existingMembership.role = role; existingMembership.status = options.status ?? existingMembership.status; existingMembership.invitedByUserId = options.invitedByUserId ?? existingMembership.invitedByUserId ?? null; existingMembership.source = existingMembership.source ?? "access_request"; existingMembership.updatedAt = now; return existingMembership; } const membership = { id: uniqueId(data.memberships, "mem", `${clientId}-${user.id}`), clientId, userId: user.id, role, status: options.status ?? "disabled", invitedByUserId: options.invitedByUserId ?? null, inviteId: null, source: "access_request", sourceTaskerInviteRequestId: null, createdAt: now, updatedAt: now, }; data.memberships.push(membership); return membership; } function buildAccessRequestUserName(requestPayload) { return [requestPayload.lastName, requestPayload.firstName, requestPayload.middleName].filter(Boolean).join(" "); } function normalizeTaskManagerInviteRole(value) { const normalized = typeof value === "string" ? value.trim().toLowerCase() : value; if (normalized === "viewer") return "guest"; if (normalized === "owner") return "admin"; if (normalized === 5) return "guest"; if (normalized === 15) return "member"; if (normalized === 20) return "admin"; return taskManagerInviteRoles.has(normalized) ? normalized : "member"; } function isValidEmail(email) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); } 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 normalizeEmail(value) { return typeof value === "string" ? value.trim().toLowerCase() : ""; } 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(); }