feat: integrate hub notifications with core

This commit is contained in:
DCCONSTRUCTIONS
2026-05-26 18:01:39 +03:00
parent de446c1879
commit 8f56fd2ab6
10 changed files with 1484 additions and 126 deletions
+184 -4
View File
@@ -38,7 +38,9 @@ const accessRequestStatuses = new Set(["new", "approved", "rejected"]);
const taskerInviteRequestStatuses = new Set(["new", "approved", "rejected", "cancelled"]);
const taskManagerInviteRoles = new Set(["guest", "member", "admin"]);
const engineWorkflowAccessRequestStatuses = new Set(["new", "approved", "rejected", "cancelled"]);
const engineWorkflowAccessRequestTypes = new Set(["workflow", "service_role"]);
const engineWorkflowRoles = new Set(["viewer", "editor", "admin"]);
const engineServiceRoles = new Set(["viewer", "member"]);
const publicPoolClientId = "client_public_pool";
const engineAuthentikGroups = ["nodedc_admin", "nodedc_editor", "nodedc_viewer"];
const publicPoolClient = {
@@ -1164,12 +1166,15 @@ export function createControlPlaneStore({ projectRoot }) {
};
Object.assign(request, {
requestType: "workflow",
workflowId,
workflowName,
targetUserId: targetUser.id,
targetEmail,
targetName: targetUser.name ?? null,
role,
requestedServiceRole: null,
currentServiceRole: null,
requesterUserId: requesterUser?.id ?? nullableStringWithFallback(payload?.requesterUserId, null),
requesterEmail: requesterEmail || normalizeEmail(payload?.requesterEmail) || actor.email || "engine@nodedc.ru",
requesterName,
@@ -1201,6 +1206,78 @@ export function createControlPlaneStore({ projectRoot }) {
};
}
async function createEngineRoleAccessRequest(payload, identity = { name: "NODE.DC Engine", source: "engine" }) {
const data = readData();
const now = isoNow();
const actor = resolveActor(data, identity);
const requesterEmail = normalizeEmail(payload?.requesterEmail ?? actor.email ?? payload?.email ?? "");
const targetUser =
(payload?.requesterUserId ? data.users.find((user) => user.id === payload.requesterUserId) : null) ??
(requesterEmail ? data.users.find((user) => normalizeEmail(user.email) === requesterEmail) : null) ??
null;
if (!targetUser || targetUser.globalStatus !== "active") {
throw new Error("engine_target_user_not_found");
}
const requestedServiceRole = normalizeEngineServiceRole(payload?.requestedServiceRole ?? payload?.requestedRole ?? "member");
const currentServiceRole = normalizeNullableEngineServiceRole(payload?.currentServiceRole ?? payload?.currentRole);
const role = requestedServiceRole === "viewer" ? "viewer" : "editor";
const existingRequest = data.engineWorkflowAccessRequests.find(
(request) =>
request.status === "new" &&
request.requestType === "service_role" &&
request.targetUserId === targetUser.id
);
const request =
existingRequest ??
{
id: uniqueId(data.engineWorkflowAccessRequests, "engine_role_access_request", targetUser.email),
createdAt: now,
};
Object.assign(request, {
requestType: "service_role",
workflowId: "engine:service-role",
workflowName: "NODE.DC Engine",
targetUserId: targetUser.id,
targetEmail: normalizeEmail(targetUser.email),
targetName: targetUser.name ?? null,
role,
requestedServiceRole,
currentServiceRole,
requesterUserId: targetUser.id,
requesterEmail: normalizeEmail(targetUser.email),
requesterName: optionalString(payload?.requesterName, targetUser.name ?? targetUser.email),
status: "new",
reviewedByUserId: null,
reviewedAt: null,
engineAppliedAt: null,
comment: nullableStringWithFallback(payload?.comment, existingRequest?.comment ?? null),
updatedAt: now,
});
if (!existingRequest) {
data.engineWorkflowAccessRequests.push(request);
}
addAuditEvent(data, actor, {
action: existingRequest ? "Обновлена заявка повышения роли Engine" : "Создана заявка повышения роли Engine",
objectType: "engine_role_access_request",
objectName: `${request.targetName ?? request.targetEmail}:${requestedServiceRole}`,
result: "success",
details: `Current role: ${currentServiceRole ?? "unknown"}; requested role: ${requestedServiceRole}`,
});
await writeData(data);
return {
engineWorkflowAccessRequest: request,
affectedUserIds: [targetUser.id, "user_root"].filter(Boolean),
targetUser,
data,
};
}
async function approveEngineWorkflowAccessRequest(engineWorkflowAccessRequestId, payload, identity) {
const data = readData();
const actor = resolveActor(data, identity);
@@ -1212,6 +1289,32 @@ export function createControlPlaneStore({ projectRoot }) {
}
const targetUser = findById(data.users, request.targetUserId, "user");
if (request.requestType === "service_role") {
const serviceRole = normalizeEngineServiceRole(payload?.serviceRole ?? payload?.requestedServiceRole ?? request.requestedServiceRole);
request.requestedServiceRole = serviceRole;
request.role = serviceRole === "viewer" ? "viewer" : "editor";
const membership = ensureEngineWorkflowPublicPoolMembership(data, targetUser, request, actor, now);
const grant = ensureEngineWorkflowServiceAccess(data, targetUser, request.role, now);
request.status = "approved";
request.reviewedByUserId = actor.id;
request.reviewedAt = now;
request.engineAppliedAt = nullableStringWithFallback(payload?.engineAppliedAt, now);
request.comment = nullableStringWithFallback(payload?.comment, request.comment ?? null);
request.updatedAt = now;
addAuditEvent(data, actor, {
action: "Подтверждена заявка повышения роли Engine",
objectType: "engine_role_access_request",
objectName: `${request.targetName ?? request.targetEmail}:${serviceRole}`,
result: "success",
details: `Engine role: ${serviceRole}`,
});
await writeData(data);
return { engineWorkflowAccessRequest: request, grant, membership, targetUser, data };
}
const membership = ensureEngineWorkflowPublicPoolMembership(data, targetUser, request, actor, now);
const grant = ensureEngineWorkflowServiceAccess(data, targetUser, request.role, now);
@@ -1251,9 +1354,11 @@ export function createControlPlaneStore({ projectRoot }) {
request.updatedAt = now;
addAuditEvent(data, actor, {
action: "Отклонена заявка доступа Engine workflow",
objectType: "engine_workflow_access_request",
objectName: `${request.workflowName}:${request.targetEmail}`,
action: request.requestType === "service_role" ? "Отклонена заявка повышения роли Engine" : "Отклонена заявка доступа Engine workflow",
objectType: request.requestType === "service_role" ? "engine_role_access_request" : "engine_workflow_access_request",
objectName: request.requestType === "service_role"
? `${request.targetName ?? request.targetEmail}:${request.requestedServiceRole ?? request.role}`
: `${request.workflowName}:${request.targetEmail}`,
result: "warning",
details: request.comment ?? null,
});
@@ -1649,6 +1754,7 @@ export function createControlPlaneStore({ projectRoot }) {
const user = findById(data.users, userId, "user");
const service = findById(data.services, serviceId, "service");
const engineService = isEngineService(service);
const previousEngineServiceRole = engineService ? resolveDirectEngineServiceRole(data, serviceId, userId) : null;
if (engineService && (value === "admin" || value === "owner")) {
throw new Error("Для Engine доступны только роли guest, member или блокировка");
@@ -1690,6 +1796,17 @@ export function createControlPlaneStore({ projectRoot }) {
throw new Error(`Unsupported access value: ${value}`);
}
let engineWorkflowAccessRequest = null;
if (engineService && (value === "viewer" || value === "member") && previousEngineServiceRole !== value) {
engineWorkflowAccessRequest = appendEngineRoleChangeNotification(data, {
actor,
currentServiceRole: previousEngineServiceRole,
serviceRole: value,
targetUser: user,
now,
});
}
addAuditEvent(data, actor, {
action: "Обновлён доступ пользователя к сервису",
objectType: "grant",
@@ -1700,7 +1817,7 @@ export function createControlPlaneStore({ projectRoot }) {
markPendingSync(data, { id: `${serviceId}:${userId}` }, "grant", `${service.slug}:${user.email}`);
await writeData(data);
return { data };
return { data, engineWorkflowAccessRequest, affectedUserIds: [user.id] };
}
async function setServiceModuleEntitlement(payload, identity) {
@@ -2011,6 +2128,7 @@ export function createControlPlaneStore({ projectRoot }) {
buildAuthentikSyncPlan,
cancelTaskerInviteRequest,
createAccessRequest,
createEngineRoleAccessRequest,
createEngineWorkflowAccessRequest,
createTaskerInviteRequest,
createClient,
@@ -2229,6 +2347,7 @@ function normalizeTaskerInviteRequest(payload) {
function normalizeEngineWorkflowAccessRequest(payload) {
if (typeof payload !== "object" || payload === null) return null;
const now = isoNow();
const requestType = pickEnum(payload.requestType ?? payload.type, engineWorkflowAccessRequestTypes, "workflow");
const workflowId = typeof payload.workflowId === "string" ? payload.workflowId.trim() : "";
const targetUserId = typeof payload.targetUserId === "string" ? payload.targetUserId.trim() : "";
const targetEmail = normalizeEmail(payload.targetEmail ?? payload.email);
@@ -2238,12 +2357,17 @@ function normalizeEngineWorkflowAccessRequest(payload) {
return {
id: optionalString(payload.id, `engine_workflow_access_request_${slugify(`${workflowId}-${targetEmail}`)}`),
requestType,
workflowId,
workflowName: optionalString(payload.workflowName, workflowId),
targetUserId,
targetEmail,
targetName: nullableStringWithFallback(payload.targetName, null),
role: normalizeEngineWorkflowRole(payload.role),
requestedServiceRole: requestType === "service_role"
? normalizeEngineServiceRole(payload.requestedServiceRole ?? payload.requestedRole ?? payload.serviceRole)
: nullableStringWithFallback(payload.requestedServiceRole, null),
currentServiceRole: normalizeNullableEngineServiceRole(payload.currentServiceRole ?? payload.currentRole),
requesterUserId: nullableStringWithFallback(payload.requesterUserId, null),
requesterEmail,
requesterName: optionalString(payload.requesterName, requesterEmail),
@@ -2671,6 +2795,49 @@ function ensureEngineWorkflowServiceAccess(data, user, workflowRole, now) {
return grant;
}
function resolveDirectEngineServiceRole(data, serviceId, userId) {
const denied = data.exceptions.some(
(exception) => exception.serviceId === serviceId && exception.userId === userId && exception.type === "deny"
);
if (denied) return null;
const grant = data.grants.find(
(candidate) => candidate.serviceId === serviceId && candidate.targetType === "user" && candidate.targetId === userId
);
if (!grant || grant.status !== "active") return null;
if (grant.appRole === "viewer") return "viewer";
if (grant.appRole === "member") return "member";
return null;
}
function appendEngineRoleChangeNotification(data, { actor, currentServiceRole, serviceRole, targetUser, now }) {
const requestedServiceRole = normalizeEngineServiceRole(serviceRole);
const request = {
id: uniqueId(data.engineWorkflowAccessRequests, "engine_role_change", `${targetUser.email}-${now}`),
requestType: "service_role",
workflowId: "engine:service-role",
workflowName: "NODE.DC Engine",
targetUserId: targetUser.id,
targetEmail: normalizeEmail(targetUser.email),
targetName: targetUser.name ?? null,
role: requestedServiceRole === "viewer" ? "viewer" : "editor",
requestedServiceRole,
currentServiceRole: normalizeNullableEngineServiceRole(currentServiceRole),
requesterUserId: actor.id ?? null,
requesterEmail: normalizeEmail(actor.email) || "admin@nodedc.ru",
requesterName: actor.name || actor.email || "Администратор",
status: "approved",
reviewedByUserId: actor.id ?? null,
reviewedAt: now,
engineAppliedAt: now,
comment: "admin_role_changed",
createdAt: now,
updatedAt: now,
};
data.engineWorkflowAccessRequests.push(request);
return request;
}
function ensureEngineWorkflowPublicPoolMembership(data, user, request, actor, now) {
const invitedByUserId = request.requesterUserId || actor?.id || null;
let membership = data.memberships.find(
@@ -3099,6 +3266,19 @@ function normalizeEngineWorkflowRole(value) {
return engineWorkflowRoles.has(normalized) ? normalized : "viewer";
}
function normalizeEngineServiceRole(value) {
const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
if (normalized === "guest" || normalized === "viewer" || normalized === "read_only" || normalized === "readonly") return "viewer";
if (normalized === "participant" || normalized === "editor" || normalized === "member") return "member";
return engineServiceRoles.has(normalized) ? normalized : "member";
}
function normalizeNullableEngineServiceRole(value) {
const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
if (!normalized || normalized === "null" || normalized === "undefined") return null;
return normalizeEngineServiceRole(normalized);
}
function isValidEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
+757 -3
View File
@@ -8,6 +8,7 @@ import { fileURLToPath } from "node:url";
import { createRemoteJWKSet, jwtVerify } from "jose";
import { createAuthentikSyncClient, resolveRequiredGroups } from "./authentik-sync.mjs";
import { createControlPlaneStore } from "./control-plane-store.mjs";
import { createNotificationCoreClient } from "./notification-core-client.mjs";
const serverRoot = dirname(fileURLToPath(import.meta.url));
const projectRoot = resolve(serverRoot, "..");
@@ -34,6 +35,7 @@ const controlPlaneStore = createControlPlaneStore({ projectRoot });
const runtimeStorageRoot = resolveRuntimeStorageRoot(projectRoot);
const runtimeUploadsRoot = resolveRuntimeUploadsRoot(projectRoot, runtimeStorageRoot);
const authentikSyncClient = createAuthentikSyncClient({ baseUrl: config.authentikBaseUrl, token: config.authentikApiToken });
const notificationCoreClient = createNotificationCoreClient({ baseUrl: config.notificationCoreBaseUrl, token: config.internalAccessToken });
const pendingLogins = new Map();
const serviceHandoffs = new Map();
const sessions = new Map();
@@ -58,6 +60,7 @@ app.get("/healthz", (_req, res) => {
oidcConfigured: config.oidcConfigured,
authentikApiConfigured: authentikSyncClient.isConfigured(),
internalAccessApiConfigured: Boolean(config.internalAccessToken),
notificationCoreConfigured: notificationCoreClient.isConfigured(),
});
});
@@ -99,6 +102,28 @@ app.post("/api/access-requests", asyncRoute(async (req, res) => {
});
publishControlPlaneEvent("access-request.created", [result.user.id]);
publishNotificationEvent({
type: "nodedc.access_request.created",
sourceService: "launcher",
actor: { userId: result.user.id, email: result.user.email },
subject: { type: "access_request", id: result.accessRequest.id },
payload: {
accessRequestId: result.accessRequest.id,
userId: result.user.id,
email: result.user.email,
status: result.accessRequest.status,
},
idempotencyKey: `launcher:access-request:${result.accessRequest.id}:created:${result.accessRequest.updatedAt}`,
deliveries: [
rootAdminNotificationDelivery(result.data, {
...buildAccessRequestHubNotification(result.accessRequest),
actionable: true,
actionType: "nodedc.access_request.review",
entityType: "access_request",
entityId: result.accessRequest.id,
}),
].filter(Boolean),
});
res.status(201).json({ accessRequest: result.accessRequest });
} catch (error) {
sendAccessRequestApiError(res, error);
@@ -268,6 +293,92 @@ app.get("/api/me", (req, res) => {
});
});
app.get("/api/notifications", requireSession, asyncRoute(async (req, res) => {
if (!notificationCoreClient.isConfigured()) {
res.status(503).json({ ok: false, error: "notification_core_not_configured" });
return;
}
const { actor, data } = getLauncherProfileContext(req.nodedcSession);
const user = findLauncherUser(data, actor.id);
const payload = await notificationCoreClient.listNotifications({
subject: notificationSubjectFromUser(user),
surface: sanitizeNotificationSurface(req.query.surface, "hub"),
limit: req.query.limit,
unreadOnly: req.query.unreadOnly === "1" || req.query.unreadOnly === "true",
});
res.json(payload);
}));
app.post("/api/notifications/:deliveryId/read", requireSession, asyncRoute(async (req, res) => {
if (!notificationCoreClient.isConfigured()) {
res.status(503).json({ ok: false, error: "notification_core_not_configured" });
return;
}
const { actor, data } = getLauncherProfileContext(req.nodedcSession);
const user = findLauncherUser(data, actor.id);
const payload = await notificationCoreClient.markNotificationRead({
subject: notificationSubjectFromUser(user),
deliveryId: req.params.deliveryId,
});
res.json(payload);
}));
app.post("/api/notifications/read-all", requireSession, asyncRoute(async (req, res) => {
if (!notificationCoreClient.isConfigured()) {
res.status(503).json({ ok: false, error: "notification_core_not_configured" });
return;
}
const { actor, data } = getLauncherProfileContext(req.nodedcSession);
const user = findLauncherUser(data, actor.id);
const payload = await notificationCoreClient.markAllNotificationsRead({
subject: notificationSubjectFromUser(user),
surface: sanitizeNotificationSurface(req.query.surface ?? req.body?.surface, "hub"),
});
res.json(payload);
}));
app.get("/api/notifications/stream", requireSession, asyncRoute(async (req, res) => {
if (!notificationCoreClient.isConfigured()) {
res.status(503).json({ ok: false, error: "notification_core_not_configured" });
return;
}
const { actor, data } = getLauncherProfileContext(req.nodedcSession);
const user = findLauncherUser(data, actor.id);
const abortController = new AbortController();
req.on("close", () => {
abortController.abort();
});
const upstream = await notificationCoreClient.openNotificationStream({
subject: notificationSubjectFromUser(user),
surface: sanitizeNotificationSurface(req.query.surface, "hub"),
signal: abortController.signal,
});
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache, no-transform");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
res.flushHeaders?.();
const reader = upstream.body.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
res.write(Buffer.from(value));
}
} finally {
reader.releaseLock();
res.end();
}
}));
app.get("/api/apps", (req, res) => {
const session = getCurrentSession(req);
@@ -514,6 +625,39 @@ app.post("/api/internal/tasker/invite-requests", asyncRoute(async (req, res) =>
"tasker.invite-request.created",
result.affectedUserIds?.length ? result.affectedUserIds : [inviter.id]
);
publishNotificationEvent({
type: result.taskerInviteRequest.status === "approved" ? "tasker.workspace_invite.approved" : "tasker.workspace_invite.requested",
sourceService: "tasker",
actor: { userId: inviter.id, email: inviter.email },
subject: { type: "tasker_invite_request", id: result.taskerInviteRequest.id },
payload: {
requestId: result.taskerInviteRequest.id,
workspaceSlug: result.taskerInviteRequest.workspaceSlug,
workspaceName: result.taskerInviteRequest.workspaceName,
inviteeEmail: result.taskerInviteRequest.inviteeEmail,
status: result.taskerInviteRequest.status,
},
idempotencyKey: `launcher:tasker-invite:${result.taskerInviteRequest.id}:${result.taskerInviteRequest.status}:${result.taskerInviteRequest.updatedAt}`,
deliveries: (
result.taskerInviteRequest.status === "new"
? [
rootAdminNotificationDelivery(result.data, {
...buildTaskerInviteHubNotification(result.taskerInviteRequest),
actionable: true,
actionType: "tasker.workspace_invite.review",
entityType: "tasker_invite_request",
entityId: result.taskerInviteRequest.id,
}),
]
: notificationUsersForTaskerInvite(result.data, result.taskerInviteRequest).map((user) =>
notificationDeliveryForUser(user, "hub", {
...buildTaskerInviteHubNotification(result.taskerInviteRequest),
entityType: "tasker_invite_request",
entityId: result.taskerInviteRequest.id,
})
)
).filter(Boolean),
});
res.json({ ok: true, taskerInviteRequest: result.taskerInviteRequest, autoApproved: Boolean(result.autoApproved) });
}));
@@ -607,9 +751,137 @@ app.post("/api/internal/engine/workflow-access-requests", asyncRoute(async (req,
"engine.workflow-access-request.created",
result.affectedUserIds?.length ? result.affectedUserIds : [requester.id, targetUser.id]
);
publishNotificationEvent({
type: "engine.workflow_share.access_requested",
sourceService: "engine",
actor: { userId: requester.id, email: requester.email },
subject: { type: "engine_workflow_access_request", id: result.engineWorkflowAccessRequest.id },
payload: {
requestId: result.engineWorkflowAccessRequest.id,
workflowId: result.engineWorkflowAccessRequest.workflowId,
workflowName: result.engineWorkflowAccessRequest.workflowName,
targetUserId: targetUser.id,
targetEmail: targetUser.email,
role: result.engineWorkflowAccessRequest.role,
},
idempotencyKey: `launcher:engine-workflow-request:${result.engineWorkflowAccessRequest.id}:created:${result.engineWorkflowAccessRequest.updatedAt}`,
deliveries: [
rootAdminNotificationDelivery(result.data, {
...buildEngineWorkflowHubNotification(result.engineWorkflowAccessRequest),
actionable: true,
actionType: "engine.workflow_access.review",
entityType: "engine_workflow_access_request",
entityId: result.engineWorkflowAccessRequest.id,
}),
].filter(Boolean),
});
res.json({ ok: true, engineWorkflowAccessRequest: result.engineWorkflowAccessRequest });
}));
app.post("/api/internal/engine/role-access-requests", asyncRoute(async (req, res) => {
if (!isInternalRequestAuthorized(req)) {
res.status(config.internalAccessToken ? 401 : 503).json({
ok: false,
error: config.internalAccessToken ? "internal_access_unauthorized" : "internal_access_not_configured",
});
return;
}
const snapshot = controlPlaneStore.getSnapshot({ name: "NODE.DC Engine role access request" });
const requesterPayload = typeof req.body?.requester === "object" && req.body.requester !== null ? req.body.requester : {};
const requester = findInternalAccessUser(snapshot.data, {
subject: requesterPayload.subject,
email: requesterPayload.email,
userId: requesterPayload.userId,
});
if (!requester || requester.globalStatus !== "active") {
res.status(404).json({ ok: false, error: "requester_not_found" });
return;
}
const result = await controlPlaneStore.createEngineRoleAccessRequest({
requestedRole: req.body?.requestedRole,
currentRole: req.body?.currentRole,
requesterUserId: requester.id,
requesterEmail: requester.email,
requesterName: requester.name,
}, requester);
publishControlPlaneEvent(
"engine.role-access-request.created",
result.affectedUserIds?.length ? result.affectedUserIds : [requester.id, "user_root"]
);
publishNotificationEvent({
type: "engine.role_upgrade.requested",
sourceService: "engine",
actor: { userId: requester.id, email: requester.email },
subject: { type: "engine_role_access_request", id: result.engineWorkflowAccessRequest.id },
payload: {
requestId: result.engineWorkflowAccessRequest.id,
targetUserId: requester.id,
targetEmail: requester.email,
requestedServiceRole: result.engineWorkflowAccessRequest.requestedServiceRole,
currentServiceRole: result.engineWorkflowAccessRequest.currentServiceRole,
},
idempotencyKey: `launcher:engine-role-request:${result.engineWorkflowAccessRequest.id}:created:${result.engineWorkflowAccessRequest.updatedAt}`,
deliveries: [
rootAdminNotificationDelivery(result.data, {
...buildEngineRoleHubNotification(result.engineWorkflowAccessRequest),
actionable: true,
actionType: "engine.role_upgrade.review",
entityType: "engine_role_access_request",
entityId: result.engineWorkflowAccessRequest.id,
}),
].filter(Boolean),
});
res.json({ ok: true, engineWorkflowAccessRequest: result.engineWorkflowAccessRequest });
}));
app.post("/api/internal/engine/role-access-requests/list", asyncRoute(async (req, res) => {
if (!isInternalRequestAuthorized(req)) {
res.status(config.internalAccessToken ? 401 : 503).json({
ok: false,
error: config.internalAccessToken ? "internal_access_unauthorized" : "internal_access_not_configured",
});
return;
}
const snapshot = controlPlaneStore.getSnapshot({ name: "NODE.DC Engine role access request list" });
const requesterPayload = typeof req.body?.requester === "object" && req.body.requester !== null ? req.body.requester : {};
const requester = findInternalAccessUser(snapshot.data, {
subject: requesterPayload.subject,
email: requesterPayload.email,
userId: requesterPayload.userId,
});
if (!requester || requester.globalStatus !== "active") {
res.status(404).json({ ok: false, error: "requester_not_found" });
return;
}
const requesterEmail = String(requester.email || "").toLowerCase();
const requesterGroups = Array.isArray(requesterPayload.groups)
? requesterPayload.groups.map((group) => String(group || "").trim()).filter(Boolean)
: [];
const canReviewEngineRoleRequests = requester.id === "user_root" || isLauncherAdmin(requesterGroups);
const requests = snapshot.data.engineWorkflowAccessRequests
.filter((request) => {
if (request.requestType !== "service_role" || request.status === "cancelled") return false;
const isRelated =
request.requesterUserId === requester.id ||
request.targetUserId === requester.id ||
String(request.requesterEmail || "").toLowerCase() === requesterEmail ||
String(request.targetEmail || "").toLowerCase() === requesterEmail;
if (canReviewEngineRoleRequests && request.status === "new") return true;
return isRelated && request.status !== "new";
})
.sort((left, right) => String(right.updatedAt || right.createdAt).localeCompare(String(left.updatedAt || left.createdAt)));
res.json({ ok: true, requests });
}));
app.post("/api/internal/tasker/profile-sync", asyncRoute(async (req, res) => {
if (!isInternalRequestAuthorized(req)) {
res.status(config.internalAccessToken ? 401 : 503).json({
@@ -1358,6 +1630,28 @@ app.post("/api/admin/access-requests/:accessRequestId/approve", requireLauncherA
}
publishControlPlaneEvent("admin.access-request.approved", result.user ? [result.user.id] : []);
if (result.user) {
publishNotificationEvent({
type: "nodedc.access_request.approved",
sourceService: "launcher",
actor: { userId: req.nodedcSession.user?.id, email: req.nodedcSession.user?.email },
subject: { type: "access_request", id: result.accessRequest.id },
payload: {
accessRequestId: result.accessRequest.id,
userId: result.user.id,
email: result.user.email,
status: result.accessRequest.status,
},
idempotencyKey: `launcher:access-request:${result.accessRequest.id}:approved:${result.accessRequest.updatedAt}`,
deliveries: [
notificationDeliveryForUser(result.user, "hub", {
...buildAccessRequestHubNotification(result.accessRequest),
entityType: "access_request",
entityId: result.accessRequest.id,
}),
].filter(Boolean),
});
}
res.json(scopeAdminMutationResult(req, result));
} catch (error) {
sendAccessRequestApiError(res, error);
@@ -1368,6 +1662,29 @@ app.post("/api/admin/access-requests/:accessRequestId/reject", requireLauncherAd
try {
const result = await controlPlaneStore.rejectAccessRequest(req.params.accessRequestId, req.body, req.nodedcSession.user);
publishControlPlaneEvent("admin.access-request.rejected");
const targetUser = result.data.users.find((user) => user.email.toLowerCase() === result.accessRequest.email.toLowerCase()) ?? null;
if (targetUser) {
publishNotificationEvent({
type: "nodedc.access_request.rejected",
sourceService: "launcher",
actor: { userId: req.nodedcSession.user?.id, email: req.nodedcSession.user?.email },
subject: { type: "access_request", id: result.accessRequest.id },
payload: {
accessRequestId: result.accessRequest.id,
userId: targetUser.id,
email: targetUser.email,
status: result.accessRequest.status,
},
idempotencyKey: `launcher:access-request:${result.accessRequest.id}:rejected:${result.accessRequest.updatedAt}`,
deliveries: [
notificationDeliveryForUser(targetUser, "hub", {
...buildAccessRequestHubNotification(result.accessRequest),
entityType: "access_request",
entityId: result.accessRequest.id,
}),
].filter(Boolean),
});
}
res.json(scopeAdminMutationResult(req, result));
} catch (error) {
sendAccessRequestApiError(res, error);
@@ -1407,6 +1724,27 @@ app.post("/api/admin/tasker-invite-requests/:taskerInviteRequestId/approve", req
);
publishControlPlaneEvent("admin.tasker-invite-request.approved", [result.taskerInviteRequest.inviterUserId]);
publishNotificationEvent({
type: "tasker.workspace_invite.approved",
sourceService: "tasker",
actor: { userId: req.nodedcSession.user?.id, email: req.nodedcSession.user?.email },
subject: { type: "tasker_invite_request", id: result.taskerInviteRequest.id },
payload: {
requestId: result.taskerInviteRequest.id,
workspaceSlug: result.taskerInviteRequest.workspaceSlug,
workspaceName: result.taskerInviteRequest.workspaceName,
inviteeEmail: result.taskerInviteRequest.inviteeEmail,
status: result.taskerInviteRequest.status,
},
idempotencyKey: `launcher:tasker-invite:${result.taskerInviteRequest.id}:approved:${result.taskerInviteRequest.updatedAt}`,
deliveries: notificationUsersForTaskerInvite(result.data, result.taskerInviteRequest).map((user) =>
notificationDeliveryForUser(user, "hub", {
...buildTaskerInviteHubNotification(result.taskerInviteRequest),
entityType: "tasker_invite_request",
entityId: result.taskerInviteRequest.id,
})
).filter(Boolean),
});
res.json(scopeAdminMutationResult(req, { ...result, tasker: taskerResult }));
}));
@@ -1429,6 +1767,27 @@ app.post("/api/admin/tasker-invite-requests/:taskerInviteRequestId/reject", requ
const result = await controlPlaneStore.rejectTaskerInviteRequest(req.params.taskerInviteRequestId, req.body, req.nodedcSession.user);
publishControlPlaneEvent("admin.tasker-invite-request.rejected", [result.taskerInviteRequest.inviterUserId]);
publishNotificationEvent({
type: "tasker.workspace_invite.rejected",
sourceService: "tasker",
actor: { userId: req.nodedcSession.user?.id, email: req.nodedcSession.user?.email },
subject: { type: "tasker_invite_request", id: result.taskerInviteRequest.id },
payload: {
requestId: result.taskerInviteRequest.id,
workspaceSlug: result.taskerInviteRequest.workspaceSlug,
workspaceName: result.taskerInviteRequest.workspaceName,
inviteeEmail: result.taskerInviteRequest.inviteeEmail,
status: result.taskerInviteRequest.status,
},
idempotencyKey: `launcher:tasker-invite:${result.taskerInviteRequest.id}:rejected:${result.taskerInviteRequest.updatedAt}`,
deliveries: notificationUsersForTaskerInvite(result.data, result.taskerInviteRequest).map((user) =>
notificationDeliveryForUser(user, "hub", {
...buildTaskerInviteHubNotification(result.taskerInviteRequest),
entityType: "tasker_invite_request",
entityId: result.taskerInviteRequest.id,
})
).filter(Boolean),
});
res.json(scopeAdminMutationResult(req, { ...result, tasker: taskerResult }));
}));
@@ -1443,6 +1802,50 @@ app.post("/api/admin/engine-workflow-access-requests/:engineWorkflowAccessReques
return;
}
if (request.requestType === "service_role") {
const result = await controlPlaneStore.approveEngineWorkflowAccessRequest(
req.params.engineWorkflowAccessRequestId,
{
serviceRole: req.body?.serviceRole ?? req.body?.requestedServiceRole ?? req.body?.role,
comment: req.body?.comment,
},
req.nodedcSession.user
);
const syncResult = await syncUsersToAuthentik(result.data, [result.targetUser.id], req.nodedcSession.user);
publishControlPlaneEvent("admin.engine-role-access-request.approved", [
result.engineWorkflowAccessRequest.requesterUserId,
result.targetUser.id,
]);
publishNotificationEvent({
type: "engine.role_upgrade.approved",
sourceService: "launcher",
actor: { userId: req.nodedcSession.user?.id, email: req.nodedcSession.user?.email },
subject: { type: "engine_role_access_request", id: result.engineWorkflowAccessRequest.id },
payload: {
requestId: result.engineWorkflowAccessRequest.id,
targetUserId: result.targetUser.id,
targetEmail: result.targetUser.email,
serviceRole: result.engineWorkflowAccessRequest.requestedServiceRole,
},
idempotencyKey: `launcher:engine-role-request:${result.engineWorkflowAccessRequest.id}:approved:${result.engineWorkflowAccessRequest.updatedAt}`,
deliveries: [
notificationDeliveryForUser(result.targetUser, "hub", {
...buildEngineRoleHubNotification(result.engineWorkflowAccessRequest),
entityType: "engine_role_access_request",
entityId: result.engineWorkflowAccessRequest.id,
}),
notificationDeliveryForUser(result.targetUser, "engine", {
...buildEngineSurfaceNotification(result.engineWorkflowAccessRequest),
entityType: "engine_role_access_request",
entityId: result.engineWorkflowAccessRequest.id,
}),
].filter(Boolean),
});
res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data }));
return;
}
const engineResult = await requestEngineInternalJson(`/api/internal/workflows/${encodeURIComponent(request.workflowId)}/share/users`, {
body: {
email: request.targetEmail,
@@ -1466,6 +1869,33 @@ app.post("/api/admin/engine-workflow-access-requests/:engineWorkflowAccessReques
result.engineWorkflowAccessRequest.requesterUserId,
result.targetUser.id,
]);
publishNotificationEvent({
type: "engine.workflow_share.approved",
sourceService: "launcher",
actor: { userId: req.nodedcSession.user?.id, email: req.nodedcSession.user?.email },
subject: { type: "engine_workflow_access_request", id: result.engineWorkflowAccessRequest.id },
payload: {
requestId: result.engineWorkflowAccessRequest.id,
workflowId: result.engineWorkflowAccessRequest.workflowId,
workflowName: result.engineWorkflowAccessRequest.workflowName,
targetUserId: result.targetUser.id,
targetEmail: result.targetUser.email,
role: result.engineWorkflowAccessRequest.role,
},
idempotencyKey: `launcher:engine-workflow-request:${result.engineWorkflowAccessRequest.id}:approved:${result.engineWorkflowAccessRequest.updatedAt}`,
deliveries: [
notificationDeliveryForUser(result.targetUser, "hub", {
...buildEngineWorkflowHubNotification(result.engineWorkflowAccessRequest),
entityType: "engine_workflow_access_request",
entityId: result.engineWorkflowAccessRequest.id,
}),
notificationDeliveryForUser(result.targetUser, "engine", {
...buildEngineSurfaceNotification(result.engineWorkflowAccessRequest),
entityType: "engine_workflow_access_request",
entityId: result.engineWorkflowAccessRequest.id,
}),
].filter(Boolean),
});
res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data, engine: engineResult }));
}));
@@ -1479,6 +1909,41 @@ app.post("/api/admin/engine-workflow-access-requests/:engineWorkflowAccessReques
publishControlPlaneEvent("admin.engine-workflow-access-request.rejected", [
result.engineWorkflowAccessRequest.requesterUserId,
]);
const targetUser = result.data.users.find((user) => user.id === result.engineWorkflowAccessRequest.targetUserId) ?? null;
if (targetUser) {
const isServiceRoleRequest = result.engineWorkflowAccessRequest.requestType === "service_role";
publishNotificationEvent({
type: isServiceRoleRequest ? "engine.role_upgrade.rejected" : "engine.workflow_share.rejected",
sourceService: "launcher",
actor: { userId: req.nodedcSession.user?.id, email: req.nodedcSession.user?.email },
subject: {
type: isServiceRoleRequest ? "engine_role_access_request" : "engine_workflow_access_request",
id: result.engineWorkflowAccessRequest.id,
},
payload: {
requestId: result.engineWorkflowAccessRequest.id,
targetUserId: targetUser.id,
targetEmail: targetUser.email,
workflowId: result.engineWorkflowAccessRequest.workflowId,
workflowName: result.engineWorkflowAccessRequest.workflowName,
},
idempotencyKey: `launcher:engine-request:${result.engineWorkflowAccessRequest.id}:rejected:${result.engineWorkflowAccessRequest.updatedAt}`,
deliveries: [
notificationDeliveryForUser(targetUser, "hub", {
...(isServiceRoleRequest
? buildEngineRoleHubNotification(result.engineWorkflowAccessRequest)
: buildEngineWorkflowHubNotification(result.engineWorkflowAccessRequest)),
entityType: isServiceRoleRequest ? "engine_role_access_request" : "engine_workflow_access_request",
entityId: result.engineWorkflowAccessRequest.id,
}),
notificationDeliveryForUser(targetUser, "engine", {
...buildEngineSurfaceNotification(result.engineWorkflowAccessRequest),
entityType: isServiceRoleRequest ? "engine_role_access_request" : "engine_workflow_access_request",
entityId: result.engineWorkflowAccessRequest.id,
}),
].filter(Boolean),
});
}
res.json(scopeAdminMutationResult(req, result));
}));
@@ -1618,7 +2083,41 @@ app.post("/api/admin/access/user-service", requireLauncherAdmin, asyncRoute(asyn
const result = await controlPlaneStore.setUserServiceAccess(req.body, req.nodedcSession.user);
const syncResult = await syncUsersToAuthentik(result.data, [req.body?.userId], req.nodedcSession.user);
publishControlPlaneEvent("admin.access.user-service.updated", syncResult.userIds);
publishControlPlaneEvent(
"admin.access.user-service.updated",
result.affectedUserIds?.length ? result.affectedUserIds : syncResult.userIds
);
if (result.engineWorkflowAccessRequest) {
const targetUser = syncResult.data.users.find((user) => user.id === req.body?.userId) ?? null;
if (targetUser) {
publishNotificationEvent({
type: "engine.role.changed",
sourceService: "launcher",
actor: { userId: req.nodedcSession.user?.id, email: req.nodedcSession.user?.email },
subject: { type: "engine_role_change", id: result.engineWorkflowAccessRequest.id },
payload: {
requestId: result.engineWorkflowAccessRequest.id,
targetUserId: targetUser.id,
targetEmail: targetUser.email,
serviceRole: result.engineWorkflowAccessRequest.requestedServiceRole,
currentServiceRole: result.engineWorkflowAccessRequest.currentServiceRole,
},
idempotencyKey: `launcher:engine-role-change:${result.engineWorkflowAccessRequest.id}`,
deliveries: [
notificationDeliveryForUser(targetUser, "hub", {
...buildEngineRoleHubNotification(result.engineWorkflowAccessRequest),
entityType: "engine_role_change",
entityId: result.engineWorkflowAccessRequest.id,
}),
notificationDeliveryForUser(targetUser, "engine", {
...buildEngineSurfaceNotification(result.engineWorkflowAccessRequest),
entityType: "engine_role_change",
entityId: result.engineWorkflowAccessRequest.id,
}),
].filter(Boolean),
});
}
}
res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data }));
}));
@@ -1785,6 +2284,10 @@ function readConfig() {
process.env.NODEDC_ENGINE_BASE_URL ??
process.env.ENGINE_BASE_URL ??
"https://engine.nodedc.ru",
notificationCoreBaseUrl:
process.env.NODEDC_NOTIFICATION_CORE_URL ??
process.env.NOTIFICATION_CORE_URL ??
"",
};
}
@@ -3320,6 +3823,239 @@ function findLauncherUser(data, userId) {
return user;
}
function notificationSubjectFromUser(user) {
return {
userId: user?.id ?? null,
email: typeof user?.email === "string" ? user.email.toLowerCase() : null,
};
}
function notificationDeliveryForUser(user, surface, notification) {
if (!user?.id && !user?.email) return null;
return {
recipientUserId: user?.id ?? null,
recipientEmail: typeof user?.email === "string" ? user.email.toLowerCase() : null,
surface,
title: notification.title,
body: notification.body ?? "",
meta: notification.meta ?? {},
actionable: notification.actionable === true,
actionType: notification.actionType ?? null,
actionUrl: notification.actionUrl ?? null,
entityType: notification.entityType ?? null,
entityId: notification.entityId ?? null,
};
}
function rootAdminNotificationDelivery(data, notification) {
const rootUser = data.users.find((user) => user.id === "user_root") ?? data.users[0] ?? null;
return notificationDeliveryForUser(rootUser, "hub", notification);
}
function notificationUsersForTaskerInvite(data, request) {
const users = [];
const seen = new Set();
const addUser = (user) => {
if (!user?.id || seen.has(user.id)) return;
seen.add(user.id);
users.push(user);
};
addUser(data.users.find((user) => user.id === request?.inviterUserId));
addUser(data.users.find((user) => normalizeNotificationEmail(user.email) === normalizeNotificationEmail(request?.inviterEmail)));
addUser(data.users.find((user) => normalizeNotificationEmail(user.email) === normalizeNotificationEmail(request?.inviteeEmail)));
return users;
}
function buildAccessRequestHubNotification(accessRequest) {
const applicantName = [accessRequest?.lastName, accessRequest?.firstName].filter(Boolean).join(" ") || accessRequest?.email || "NODE.DC user";
const status = accessRequest?.status || "new";
return {
title: status === "new" ? "Входящий запрос доступа" : "Запрос доступа обновлён",
body: `${applicantName} · ${accessRequest?.email || ""}`,
meta: {
displayStatus: platformNotificationStatusLabel(status),
meta: accessRequest?.company || formatNotificationDate(accessRequest?.createdAt),
},
};
}
function buildTaskerInviteHubNotification(request) {
const status = request?.status || "new";
return {
title: status === "new" ? "Запрос доступа Operational Core" : "Operational Core: заявка обновлена",
body: `${request?.workspaceName || request?.workspaceSlug || "Operational Core"} · ${request?.inviteeEmail || ""}`,
meta: {
displayStatus: platformNotificationStatusLabel(status),
meta: request?.inviterName || formatNotificationDate(request?.createdAt),
},
};
}
function buildEngineRoleHubNotification(request) {
const target = request?.targetName || request?.targetEmail || "Engine user";
const status = request?.status || "new";
return {
title: engineRoleHubNotificationTitle(request),
body: engineRoleHubNotificationBody(request, target),
meta: {
displayStatus: platformNotificationStatusLabel(status),
meta: `Запрошено: ${engineServiceRoleLabel(request?.requestedServiceRole)}`,
requestId: request?.id,
requestedServiceRole: request?.requestedServiceRole ?? null,
currentServiceRole: request?.currentServiceRole ?? null,
},
};
}
function buildEngineWorkflowHubNotification(request) {
const status = request?.status || "new";
return {
title: status === "new" ? "Запрос доступа к Engine workflow" : "Engine: заявка обновлена",
body: `${request?.workflowName || request?.workflowId || "Engine workflow"} · ${request?.targetEmail || ""}`,
meta: {
displayStatus: platformNotificationStatusLabel(status),
meta: request?.requesterName || formatNotificationDate(request?.createdAt),
requestId: request?.id,
},
};
}
function buildEngineSurfaceNotification(request) {
if (request?.requestType === "service_role") {
return {
title: request?.workflowName || "NODE.DC Engine",
body: engineRoleSurfaceNotificationBody(request),
meta: {
displayStatus: engineSurfaceStatusLabel(request),
meta: engineSurfaceMeta(request),
requestId: request?.id,
requestedServiceRole: request?.requestedServiceRole ?? null,
currentServiceRole: request?.currentServiceRole ?? null,
},
};
}
return {
title: request?.workflowName || request?.workflowId || "Engine workflow",
body: `${request?.requesterName || request?.requesterEmail || "NODE.DC Engine"} просит роль ${engineWorkflowRoleLabel(request?.role)}`,
meta: {
displayStatus: engineSurfaceStatusLabel(request),
meta: engineSurfaceMeta(request),
requestId: request?.id,
},
};
}
function engineRoleHubNotificationTitle(request) {
if (request?.status === "approved") return request?.comment === "admin_role_changed" ? "Engine: роль изменена" : "Engine: роль обновлена";
if (request?.status === "rejected") return "Engine: повышение роли отклонено";
return "Engine: запрос повышения роли";
}
function engineRoleHubNotificationBody(request, target) {
if (request?.status === "rejected") return `${target} · администратор отклонил заявку`;
if (request?.status === "approved") {
return request?.comment === "admin_role_changed" ? `${target} · роль изменена администратором` : `${target} · новая роль применена`;
}
return `${target} · текущая роль ${engineServiceRoleLabel(request?.currentServiceRole)}`;
}
function engineRoleSurfaceNotificationBody(request) {
const requestedRole = engineServiceRoleLabel(request?.requestedServiceRole);
const requestedByTarget = normalizeNotificationEmail(request?.requesterEmail) === normalizeNotificationEmail(request?.targetEmail);
if (request?.status === "approved") {
return requestedByTarget && request?.comment !== "admin_role_changed"
? `Администратор подтвердил повышение роли: ${requestedRole}`
: `Администратор изменил роль: ${requestedRole}`;
}
if (request?.status === "rejected") {
return "Администратор отклонил заявку на повышение роли";
}
return `${request?.requesterName || request?.requesterEmail || "NODE.DC Engine"} просит роль ${requestedRole}`;
}
function engineSurfaceStatusLabel(request) {
if (request?.requestType === "service_role" && request?.status === "approved" && request?.comment === "admin_role_changed") {
return "Изменено";
}
if (request?.status === "approved") return "Одобрено";
if (request?.status === "rejected") return "Отклонено";
return "Новая заявка";
}
function engineSurfaceMeta(request) {
const createdAt = formatShortNotificationDate(request?.createdAt);
return ["Ваш запрос", createdAt].filter(Boolean).join(" · ");
}
function engineServiceRoleLabel(role) {
if (role === "member") return "Участник";
if (role === "viewer") return "Гость";
return "не назначена";
}
function engineWorkflowRoleLabel(role) {
if (role === "admin") return "Соавтор";
if (role === "editor") return "Редактор";
return "Просмотр";
}
function platformNotificationStatusLabel(status) {
const normalized = String(status || "").trim().toLowerCase();
if (normalized === "approved") return "Подтверждено";
if (normalized === "rejected") return "Отклонено";
if (normalized === "cancelled") return "Отменено";
return "Входящее";
}
function formatNotificationDate(value) {
const timestamp = Date.parse(String(value || ""));
if (!Number.isFinite(timestamp)) return "";
return new Intl.DateTimeFormat("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(timestamp));
}
function formatShortNotificationDate(value) {
const timestamp = Date.parse(String(value || ""));
if (!Number.isFinite(timestamp)) return "";
return new Intl.DateTimeFormat("ru-RU", {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(timestamp));
}
function normalizeNotificationEmail(value) {
return String(value || "").trim().toLowerCase();
}
function sanitizeNotificationSurface(value, fallback = "hub") {
const surface = typeof value === "string" && value.trim() ? value.trim().toLowerCase() : fallback;
if (surface === "hub" || surface === "engine" || surface === "operational-core") return surface;
return fallback;
}
function publishNotificationEvent(command) {
if (!notificationCoreClient.isConfigured()) {
return;
}
if (!Array.isArray(command?.deliveries) || command.deliveries.length === 0) {
return;
}
notificationCoreClient.createEvent(command).catch((error) => {
console.warn("[notification-core] event publish failed:", error?.message || error);
});
}
function isLauncherAdmin(groups) {
return groups.includes("nodedc:superadmin") || groups.includes("nodedc:launcher:admin");
}
@@ -3511,6 +4247,12 @@ function scopeControlPlaneData(data, scope) {
}
const groupIds = new Set(data.groups.filter((group) => clientIds.has(group.clientId)).map((group) => group.id));
const userEmails = new Set(
data.users
.filter((user) => userIds.has(user.id))
.map((user) => String(user.email || "").toLowerCase())
.filter(Boolean)
);
return {
...data,
@@ -3521,7 +4263,12 @@ function scopeControlPlaneData(data, scope) {
invites: data.invites.filter((invite) => clientIds.has(invite.clientId)),
accessRequests: [],
taskerInviteRequests: [],
engineWorkflowAccessRequests: [],
engineWorkflowAccessRequests: data.engineWorkflowAccessRequests.filter((request) =>
userIds.has(request.requesterUserId) ||
userIds.has(request.targetUserId) ||
userEmails.has(String(request.requesterEmail || "").toLowerCase()) ||
userEmails.has(String(request.targetEmail || "").toLowerCase())
),
grants: data.grants.filter((grant) => {
if (grant.targetType === "client") return clientIds.has(grant.targetId);
if (grant.targetType === "group") return groupIds.has(grant.targetId);
@@ -3574,6 +4321,13 @@ function scopeRuntimeControlPlaneData(data, userId) {
.filter((group) => clientIds.has(group.clientId) && group.memberIds.includes(user.id))
.map((group) => ({ ...group, memberIds: group.memberIds.filter((memberId) => memberId === user.id) }));
const groupIds = new Set(groups.map((group) => group.id));
const userEmail = String(user.email || "").toLowerCase();
const relatedEngineRequests = data.engineWorkflowAccessRequests.filter((request) =>
request.requesterUserId === user.id ||
request.targetUserId === user.id ||
String(request.requesterEmail || "").toLowerCase() === userEmail ||
String(request.targetEmail || "").toLowerCase() === userEmail
);
return {
...data,
@@ -3585,7 +4339,7 @@ function scopeRuntimeControlPlaneData(data, userId) {
accessRequests: [],
revokedAccounts: [],
taskerInviteRequests: [],
engineWorkflowAccessRequests: [],
engineWorkflowAccessRequests: relatedEngineRequests,
grants: data.grants.filter((grant) => {
if (grant.targetType === "client") return clientIds.has(grant.targetId);
if (grant.targetType === "group") return groupIds.has(grant.targetId);
+125
View File
@@ -0,0 +1,125 @@
export function createNotificationCoreClient({ baseUrl, token }) {
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
const internalToken = typeof token === "string" ? token.trim() : "";
return {
isConfigured() {
return Boolean(normalizedBaseUrl && internalToken);
},
async createEvent(command) {
return requestJson({
baseUrl: normalizedBaseUrl,
token: internalToken,
method: "POST",
pathname: "/internal/notifications/events",
body: command,
});
},
async listNotifications({ subject, surface, limit, unreadOnly }) {
const searchParams = new URLSearchParams();
searchParams.set("surface", surface || "hub");
if (limit) searchParams.set("limit", String(limit));
if (unreadOnly) searchParams.set("unreadOnly", "1");
return requestJson({
baseUrl: normalizedBaseUrl,
token: internalToken,
method: "GET",
pathname: `/api/notifications?${searchParams.toString()}`,
subject,
});
},
async markNotificationRead({ subject, deliveryId }) {
return requestJson({
baseUrl: normalizedBaseUrl,
token: internalToken,
method: "POST",
pathname: `/api/notifications/${encodeURIComponent(deliveryId)}/read`,
subject,
});
},
async markAllNotificationsRead({ subject, surface }) {
const searchParams = new URLSearchParams();
searchParams.set("surface", surface || "hub");
return requestJson({
baseUrl: normalizedBaseUrl,
token: internalToken,
method: "POST",
pathname: `/api/notifications/read-all?${searchParams.toString()}`,
subject,
});
},
async openNotificationStream({ subject, surface, signal }) {
if (!normalizedBaseUrl || !internalToken) {
throw new Error("notification_core_not_configured");
}
const searchParams = new URLSearchParams();
searchParams.set("surface", surface || "hub");
const response = await fetch(`${normalizedBaseUrl}/api/notifications/stream?${searchParams.toString()}`, {
method: "GET",
headers: buildHeaders(internalToken, subject),
signal,
});
if (!response.ok || !response.body) {
throw new Error(await readError(response));
}
return response;
},
};
}
async function requestJson({ baseUrl, token, method, pathname, body, subject }) {
if (!baseUrl || !token) {
throw new Error("notification_core_not_configured");
}
const response = await fetch(`${baseUrl}${pathname}`, {
method,
headers: buildHeaders(token, subject, body !== undefined),
body: body === undefined ? undefined : JSON.stringify(body),
});
let parsed = null;
try {
parsed = await response.json();
} catch {}
if (!response.ok) {
const error = new Error(parsed?.error ? String(parsed.error) : `notification_core_http_${response.status}`);
error.status = response.status;
error.payload = parsed;
throw error;
}
return parsed;
}
function buildHeaders(token, subject, hasJsonBody = false) {
const headers = {
Accept: "application/json",
Authorization: `Bearer ${token}`,
};
if (hasJsonBody) headers["Content-Type"] = "application/json";
if (subject?.userId) headers["X-NODEDC-User-Id"] = subject.userId;
if (subject?.email) headers["X-NODEDC-User-Email"] = subject.email;
return headers;
}
async function readError(response) {
try {
const parsed = await response.json();
if (parsed?.error) return String(parsed.error);
} catch {}
return `notification_core_http_${response.status}`;
}
function normalizeBaseUrl(value) {
return typeof value === "string" && value.trim() ? value.trim().replace(/\/+$/, "") : "";
}