NODEDC_LAUNCHER/server/control-plane-store.mjs

2437 lines
88 KiB
JavaScript

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 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, 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, 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);
if (accessRequest.status === "approved" && accessRequest.approvedInviteId) {
const existingInvite = findById(data.invites, accessRequest.approvedInviteId, "invite");
return { accessRequest, invite: existingInvite, data };
}
const client = findClientById(data, accessRequest.targetClientId);
const invite = {
id: uniqueId(data.invites, "invite", accessRequest.email),
clientId: client.id,
email: accessRequest.email,
role: accessRequest.role,
invitedByUserId: actor.id,
source: "access_request",
sourceTaskerInviteRequestId: null,
sourceTaskerInviteId: null,
sourceWorkspaceSlug: null,
sourceWorkspaceName: null,
token: randomUUID(),
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
status: "created",
createdAt: now,
updatedAt: now,
};
data.invites.push(invite);
accessRequest.status = "approved";
accessRequest.approvedInviteId = invite.id;
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: `Invite: ${invite.id}; target: ${client.name}; role: ${invite.role}`,
});
markPendingSync(data, invite, "invite", invite.email);
await writeData(data);
return { accessRequest, invite, 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 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();
}