NODEDC_LAUNCHER/server/control-plane-store.mjs

2531 lines
91 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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",
"accessRequests",
"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 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),
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;
}
}
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 = 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 === "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 });
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 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 === "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) {
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);
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 {
approveAccessRequest,
approveTaskerInviteRequest,
buildAuthentikSyncPlan,
cancelTaskerInviteRequest,
createAccessRequest,
createTaskerInviteRequest,
createClient,
createGroup,
createInvite,
createService,
createUser,
deleteClient,
deleteGroup,
deleteInvite,
deleteMembership,
deleteService,
rejectAccessRequest,
rejectTaskerInviteRequest,
acceptInvite,
commitInviteRegistration,
getInviteByToken,
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 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.taskerInviteRequests = data.taskerInviteRequests.map(normalizeTaskerInviteRequest).filter(Boolean);
return data;
}
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 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?.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);
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 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();
}