NODEDC_LAUNCHER/server/control-plane-store.mjs

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