feat(launcher): add core assistant access controls

This commit is contained in:
DCCONSTRUCTIONS
2026-06-20 12:54:48 +03:00
parent 4584c227ff
commit eb1add4c9a
8 changed files with 742 additions and 24 deletions
+37 -2
View File
@@ -27,6 +27,7 @@ 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 coreAssistantRoles = new Set(["blocked", "member", "admin"]);
const grantTargetTypes = new Set(["client", "group", "user"]);
const appRoles = new Set(["viewer", "member", "admin", "owner"]);
const grantStatuses = new Set(["active", "disabled"]);
@@ -520,6 +521,7 @@ export function createControlPlaneStore({ projectRoot }) {
userId: user.id,
role: pickEnum(payload?.role, membershipRoles, "member"),
status: pickEnum(payload?.membershipStatus, new Set(["active", "disabled"]), "active"),
coreAssistantRole: pickEnum(payload?.coreAssistantRole, coreAssistantRoles, "member"),
invitedByUserId: actor.id,
inviteId: null,
source: "launcher",
@@ -572,9 +574,11 @@ export function createControlPlaneStore({ projectRoot }) {
membership.role = "client_owner";
membership.status = "active";
membership.coreAssistantRole = "admin";
} else {
membership.role = pickEnum(payload?.role, membershipRoles, membership.role);
membership.status = pickEnum(payload?.status, new Set(["active", "disabled"]), membership.status);
membership.coreAssistantRole = pickEnum(payload?.coreAssistantRole, coreAssistantRoles, membership.coreAssistantRole ?? "member");
}
membership.updatedAt = isoNow();
@@ -584,7 +588,7 @@ export function createControlPlaneStore({ projectRoot }) {
objectName: user.email,
clientId: membership.clientId,
result: "success",
details: `Role: ${membership.role}; status: ${membership.status}`,
details: `Role: ${membership.role}; status: ${membership.status}; coreAssistantRole: ${membership.coreAssistantRole}`,
});
markPendingSync(data, user, "user");
@@ -1595,6 +1599,7 @@ export function createControlPlaneStore({ projectRoot }) {
if (membership) {
membership.role = invite.role;
membership.status = "active";
membership.coreAssistantRole = normalizeCoreAssistantRole(membership.coreAssistantRole);
membership.invitedByUserId = invite.invitedByUserId ?? membership.invitedByUserId ?? null;
membership.inviteId = invite.id;
membership.source = invite.source ?? membership.source ?? "launcher";
@@ -1607,6 +1612,7 @@ export function createControlPlaneStore({ projectRoot }) {
userId: user.id,
role: invite.role,
status: "active",
coreAssistantRole: "member",
invitedByUserId: invite.invitedByUserId ?? null,
inviteId: invite.id,
source: invite.source ?? "launcher",
@@ -2316,6 +2322,7 @@ function normalizeData(payload) {
...client,
integrations: normalizeClientIntegrations(client.integrations),
}));
data.memberships = data.memberships.map(normalizeMembership).filter(Boolean);
data.services = data.services.map(normalizeService);
data.accessRequests = data.accessRequests.map(normalizeAccessRequest).filter(Boolean);
data.revokedAccounts = data.revokedAccounts.map(normalizeRevokedAccount).filter(Boolean);
@@ -2418,9 +2425,10 @@ function applyProtectedLauncherUserInvariants(data) {
for (const membership of data.memberships) {
if (membership.userId !== rootUser.id) continue;
if (membership.role !== "client_owner" || membership.status !== "active") {
if (membership.role !== "client_owner" || membership.status !== "active" || membership.coreAssistantRole !== "admin") {
membership.role = "client_owner";
membership.status = "active";
membership.coreAssistantRole = "admin";
membership.updatedAt = now;
}
}
@@ -2502,6 +2510,26 @@ function normalizeService(service) {
};
}
function normalizeMembership(payload) {
if (typeof payload !== "object" || payload === null) return null;
const now = isoNow();
const userId = optionalString(payload.userId, "");
const fallbackAssistantRole = isProtectedLauncherUserId(userId) ? "admin" : "member";
return {
...payload,
role: pickEnum(payload.role, membershipRoles, "member"),
status: pickEnum(payload.status, new Set(["active", "disabled"]), "active"),
coreAssistantRole: normalizeCoreAssistantRole(payload.coreAssistantRole, fallbackAssistantRole),
createdAt: optionalString(payload.createdAt, now),
updatedAt: optionalString(payload.updatedAt, now),
};
}
function normalizeCoreAssistantRole(value, fallback = "member") {
return pickEnum(value, coreAssistantRoles, fallback);
}
function normalizeServiceModuleEntitlement(payload) {
if (typeof payload !== "object" || payload === null) return null;
const clientId = nullableString(payload.clientId);
@@ -3117,6 +3145,7 @@ function ensureEngineWorkflowPublicPoolMembership(data, user, request, actor, no
membership.status = "active";
if (!wasActive) {
membership.role = "member";
membership.coreAssistantRole = normalizeCoreAssistantRole(membership.coreAssistantRole);
membership.source = "engine_workflow_access_request";
membership.sourceEngineWorkflowAccessRequestId = request.id;
} else {
@@ -3135,6 +3164,7 @@ function ensureEngineWorkflowPublicPoolMembership(data, user, request, actor, no
userId: user.id,
role: "member",
status: "active",
coreAssistantRole: "member",
invitedByUserId,
inviteId: null,
source: "engine_workflow_access_request",
@@ -3158,6 +3188,7 @@ function ensurePublicPoolMembershipActiveForServiceAccess(data, user, actor, now
membership.role = "member";
membership.status = "active";
membership.coreAssistantRole = normalizeCoreAssistantRole(membership.coreAssistantRole);
membership.invitedByUserId = membership.invitedByUserId ?? actor?.id ?? null;
membership.source = membership.source ?? "launcher";
membership.updatedAt = now;
@@ -3328,6 +3359,7 @@ function applyInviteRegistration(data, token, payload, { commit, provisioning =
if (membership) {
membership.role = invite.role;
membership.status = "active";
membership.coreAssistantRole = normalizeCoreAssistantRole(membership.coreAssistantRole);
membership.invitedByUserId = invite.invitedByUserId ?? membership.invitedByUserId ?? null;
membership.inviteId = invite.id;
membership.source = invite.source ?? membership.source ?? "launcher";
@@ -3340,6 +3372,7 @@ function applyInviteRegistration(data, token, payload, { commit, provisioning =
userId: user.id,
role: invite.role,
status: "active",
coreAssistantRole: "member",
invitedByUserId: invite.invitedByUserId ?? null,
inviteId: invite.id,
source: invite.source ?? "launcher",
@@ -3498,6 +3531,7 @@ function upsertAccessRequestMembership(data, user, requestPayload, options = {})
if (existingMembership) {
existingMembership.role = role;
existingMembership.status = options.status ?? existingMembership.status;
existingMembership.coreAssistantRole = normalizeCoreAssistantRole(existingMembership.coreAssistantRole);
existingMembership.invitedByUserId = options.invitedByUserId ?? existingMembership.invitedByUserId ?? null;
existingMembership.source = existingMembership.source ?? "access_request";
existingMembership.updatedAt = now;
@@ -3510,6 +3544,7 @@ function upsertAccessRequestMembership(data, user, requestPayload, options = {})
userId: user.id,
role,
status: options.status ?? "disabled",
coreAssistantRole: "member",
invitedByUserId: options.invitedByUserId ?? null,
inviteId: null,
source: "access_request",
+518 -20
View File
@@ -39,9 +39,17 @@ const notificationCoreClient = createNotificationCoreClient({ baseUrl: config.no
const pendingLogins = new Map();
const serviceHandoffs = new Map();
const sessions = new Map();
const adminMutationIdempotencyRecords = new Map();
const runtimeEventClients = new Set();
let discoveryCache = null;
let jwksCache = null;
const adminMutationIdempotencyTtlMs = 10 * 60 * 1000;
const assistantGatewayName = "ontology-core";
const assistantGatewayAdminRouteAllowlist = [
{ method: "GET", pattern: /^\/api\/admin\/control-plane$/ },
{ method: "PATCH", pattern: /^\/api\/admin\/users\/[^/?#]+\/profile$/ },
{ method: "PATCH", pattern: /^\/api\/admin\/memberships\/[^/?#]+$/ },
];
app.disable("x-powered-by");
app.use((req, res, next) => {
@@ -512,6 +520,23 @@ app.get("/api/profile", requireSession, (req, res) => {
});
});
app.post("/api/assistant/actions", requireSession, asyncRoute(async (req, res) => {
const { actor, data } = getLauncherProfileContext(req.nodedcSession);
const runtimeContext = getRuntimeSessionContext(req.nodedcSession);
const command = buildAssistantActionProxyCommand(req.body, {
actor,
data,
groups: runtimeContext.groups,
});
const payload = await requestAiWorkspaceAssistantJson("/api/ai-workspace/assistant/v1/actions", {
body: command,
actor,
groups: runtimeContext.groups,
role: command.input.launcherGlobalRole,
});
res.json(payload);
}));
app.get("/api/events", requireSession, (req, res) => {
const client = {
id: randomUUID(),
@@ -1501,22 +1526,24 @@ app.patch("/api/admin/users/:userId/profile", requireLauncherAdmin, asyncRoute(a
return;
}
const beforeSnapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
const beforeUser = beforeSnapshot.data.users.find((candidate) => candidate.id === req.params.userId) ?? null;
const result = await controlPlaneStore.updateUserProfile(req.params.userId, req.body, req.nodedcSession.user);
const syncResult = await syncUsersToAuthentik(result.data, [req.params.userId], req.nodedcSession.user);
const updatedUser = syncResult.data.users.find((candidate) => candidate.id === req.params.userId);
const taskManagerProfile = await syncTaskManagerUserProfile(updatedUser);
const taskManagerCleanup =
beforeUser?.globalStatus === "active" && updatedUser?.globalStatus === "blocked"
? await cleanupTaskManagerUserAccess(updatedUser, {
source: "launcher-user-blocked",
revokeIdentityLinks: false,
revokeTaskerAccess: true,
})
: null;
publishControlPlaneEvent("admin.user.updated", syncResult.userIds);
res.json({ ...scopeAdminMutationResult(req, { ...result, data: syncResult.data }), taskManagerProfile, taskManagerCleanup });
await sendAdminIdempotentJson(req, res, async () => {
const beforeSnapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
const beforeUser = beforeSnapshot.data.users.find((candidate) => candidate.id === req.params.userId) ?? null;
const result = await controlPlaneStore.updateUserProfile(req.params.userId, req.body, req.nodedcSession.user);
const syncResult = await syncUsersToAuthentik(result.data, [req.params.userId], req.nodedcSession.user);
const updatedUser = syncResult.data.users.find((candidate) => candidate.id === req.params.userId);
const taskManagerProfile = await syncTaskManagerUserProfile(updatedUser);
const taskManagerCleanup =
beforeUser?.globalStatus === "active" && updatedUser?.globalStatus === "blocked"
? await cleanupTaskManagerUserAccess(updatedUser, {
source: "launcher-user-blocked",
revokeIdentityLinks: false,
revokeTaskerAccess: true,
})
: null;
publishControlPlaneEvent("admin.user.updated", syncResult.userIds);
return { ...scopeAdminMutationResult(req, { ...result, data: syncResult.data }), taskManagerProfile, taskManagerCleanup };
});
}));
app.delete("/api/admin/users/:userId", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
@@ -1575,10 +1602,12 @@ app.patch("/api/admin/memberships/:membershipId", requireLauncherAdmin, asyncRou
return;
}
const result = await controlPlaneStore.updateMembership(req.params.membershipId, req.body, req.nodedcSession.user);
const syncResult = await syncUsersToAuthentik(result.data, [result.membership.userId], req.nodedcSession.user);
publishControlPlaneEvent("admin.membership.updated", syncResult.userIds);
res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data }));
await sendAdminIdempotentJson(req, res, async () => {
const result = await controlPlaneStore.updateMembership(req.params.membershipId, req.body, req.nodedcSession.user);
const syncResult = await syncUsersToAuthentik(result.data, [result.membership.userId], req.nodedcSession.user);
publishControlPlaneEvent("admin.membership.updated", syncResult.userIds);
return scopeAdminMutationResult(req, { ...result, data: syncResult.data });
});
}));
app.delete("/api/admin/memberships/:membershipId", requireLauncherAdmin, asyncRoute(async (req, res) => {
@@ -3114,6 +3143,199 @@ function getEngineLaunchTargetUrl(service) {
return String(service?.launchTargetUrl || getEnginePublicBaseUrl()).replace(/\/$/, "");
}
function buildAssistantActionProxyCommand(body, context) {
const source = isPlainRecord(body) ? body : {};
const sourceInput = isPlainRecord(source.input) ? source.input : source;
const input = stripClientAssistantContext(sourceInput);
const ownerContext = resolveAssistantActionOwnerContext(context, input);
const phase = normalizeAssistantActionPhase(source.phase || source.mode);
const confirmationToken = normalizeOptionalText(source.confirmationToken || sourceInput.confirmationToken || source.confirmation?.token);
return {
phase,
input: {
...input,
...ownerContext,
},
...(confirmationToken ? { confirmationToken } : {}),
};
}
function stripClientAssistantContext(input) {
const result = {};
const blockedKeys = new Set([
"actorUserId",
"actorEmail",
"actorSubject",
"groups",
"launcherGlobalRole",
"launcherUserStatus",
"globalStatus",
"membershipRole",
"membershipStatus",
"assistantRole",
"coreAssistantRole",
"isRoot",
"isSuperAdmin",
"removesOwnAccess",
"hasAlternativeAdminPath",
]);
for (const [key, value] of Object.entries(isPlainRecord(input) ? input : {})) {
if (blockedKeys.has(key)) continue;
result[key] = value;
}
return result;
}
function resolveAssistantActionOwnerContext({ actor, data, groups }, input) {
const groupSet = new Set(groups || []);
const isRoot = groupSet.has("nodedc:superadmin") || groupSet.has("nodedc:launcher:admin");
const actorMembership = isRoot ? null : resolveAssistantActionActorMembership(data, actor, input);
const assistantRole = isRoot ? "admin" : actorMembership?.coreAssistantRole || "member";
const membershipRole = isRoot ? "client_owner" : actorMembership?.role || "member";
const membershipStatus = isRoot ? "active" : actorMembership?.status || "disabled";
const selfTarget = isAssistantActionSelfTarget(data, actor, input);
const removesOwnAccess = selfTarget && assistantActionRemovesOwnAccess(input);
return {
assistantRole,
launcherGlobalRole: isRoot ? "root_admin" : membershipRole,
launcherUserStatus: actor.globalStatus || "active",
membershipRole,
membershipStatus,
removesOwnAccess,
hasAlternativeAdminPath: removesOwnAccess ? hasAlternativeAssistantAdminPath(data, actor, groups, input) : false,
};
}
function resolveAssistantActionActorMembership(data, actor, input) {
const activeMemberships = data.memberships.filter((membership) => membership.userId === actor.id && membership.status === "active");
const targetClientId = resolveAssistantActionTargetClientId(data, input);
if (targetClientId) {
const exact = activeMemberships.find((membership) => membership.clientId === targetClientId);
if (exact) return exact;
}
return activeMemberships
.slice()
.sort((left, right) => assistantMembershipWeight(right) - assistantMembershipWeight(left))[0] || null;
}
function resolveAssistantActionTargetClientId(data, input) {
const explicitClientId = normalizeOptionalText(input.clientId);
if (explicitClientId) return explicitClientId;
const targetMembershipId = normalizeOptionalText(input.membershipId);
if (targetMembershipId) {
return data.memberships.find((membership) => membership.id === targetMembershipId)?.clientId || null;
}
const targetUserId = normalizeOptionalText(input.targetUserId);
if (targetUserId) {
return data.memberships.find((membership) => membership.userId === targetUserId && membership.status === "active")?.clientId || null;
}
return null;
}
function isAssistantActionSelfTarget(data, actor, input) {
const targetUserId = normalizeOptionalText(input.targetUserId);
if (targetUserId && targetUserId === actor.id) return true;
const targetMembershipId = normalizeOptionalText(input.membershipId);
if (!targetMembershipId) return false;
return data.memberships.some((membership) => membership.id === targetMembershipId && membership.userId === actor.id);
}
function assistantActionRemovesOwnAccess(input) {
const actionId = normalizeOptionalText(input.actionId);
if (actionId === "hub.user.block" || actionId === "hub.membership.disable") return true;
if (actionId === "hub.assistant_access.change_role") {
const targetAssistantRole = normalizeOptionalText(input.targetAssistantRole);
return targetAssistantRole === "blocked" || targetAssistantRole === "member";
}
if (actionId === "hub.membership.change_role") {
return normalizeOptionalText(input.targetRole) === "member";
}
return false;
}
function hasAlternativeAssistantAdminPath(data, actor, groups, input) {
const groupSet = new Set(groups || []);
if (groupSet.has("nodedc:superadmin") || groupSet.has("nodedc:launcher:admin")) {
return normalizeOptionalText(input.actionId) !== "hub.user.block";
}
const targetMembershipId = normalizeOptionalText(input.membershipId);
return data.memberships.some(
(membership) =>
membership.userId === actor.id &&
membership.id !== targetMembershipId &&
membership.status === "active" &&
membership.coreAssistantRole === "admin" &&
clientAdminMembershipRoles.has(membership.role)
);
}
function assistantMembershipWeight(membership) {
const assistantWeight = membership.coreAssistantRole === "admin" ? 100 : membership.coreAssistantRole === "blocked" ? -100 : 0;
const roleWeight = membership.role === "client_owner" ? 30 : membership.role === "client_admin" ? 20 : 10;
return assistantWeight + roleWeight;
}
function normalizeAssistantActionPhase(value) {
const phase = String(value || "preview").trim().toLowerCase().replace(/_/g, "-");
return ["preview", "dry-run", "execute"].includes(phase) ? phase : "preview";
}
function getAiWorkspaceAssistantBaseUrl() {
return String(
process.env.NDC_AI_WORKSPACE_ASSISTANT_INTERNAL_URL ||
process.env.AI_WORKSPACE_ASSISTANT_INTERNAL_URL ||
process.env.NDC_AI_WORKSPACE_ASSISTANT_URL ||
process.env.AI_WORKSPACE_ASSISTANT_URL ||
"http://localhost:18082"
).replace(/\/$/, "");
}
async function requestAiWorkspaceAssistantJson(pathname, init = {}) {
if (!config.internalAccessToken) {
throw new Error("NODE.DC internal access token is not configured");
}
const targetUrl = new URL(pathname, `${getAiWorkspaceAssistantBaseUrl()}/`);
const response = await fetch(targetUrl, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${config.internalAccessToken}`,
"X-NODEDC-User-Id": init.actor?.id || "",
"X-NODEDC-User-Email": init.actor?.email || "",
"X-NODEDC-User-Role": init.role || "",
"X-NODEDC-User-Groups": Array.isArray(init.groups) ? init.groups.join(",") : "",
...(init.headers ?? {}),
},
body: JSON.stringify(init.body ?? {}),
});
const text = await response.text();
const payload = text ? parseJsonResponse(text, targetUrl.toString()) : {};
if (!response.ok) {
const error = typeof payload?.error === "string" ? payload.error : `AI Workspace Assistant API failed: ${response.status}`;
throw new Error(error);
}
return payload;
}
async function requestTaskManagerInternalJson(pathname, init = {}) {
if (!config.internalAccessToken) {
throw new Error("NODE.DC internal access token is not configured");
@@ -3238,6 +3460,10 @@ function normalizeOptionalText(value) {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function isPlainRecord(value) {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function normalizeTaskManagerRole(value) {
return value === "guest" || value === "admin" || value === "member" ? value : null;
}
@@ -3969,6 +4195,25 @@ function requireLauncherAdmin(req, res, next) {
const session = getCurrentSession(req);
if (!session) {
const assistantGatewayContext = resolveAssistantGatewayAdminContext(req);
if (assistantGatewayContext?.ok) {
req.nodedcSession = assistantGatewayContext.session;
req.nodedcAdminScope = assistantGatewayContext.adminScope;
req.nodedcAssistantGateway = assistantGatewayContext.gateway;
next();
return;
}
if (assistantGatewayContext) {
res.status(assistantGatewayContext.status).json({
ok: false,
error: assistantGatewayContext.error,
message: assistantGatewayContext.message,
});
return;
}
res.status(401).json({ authenticated: false, loginUrl: "/auth/login" });
return;
}
@@ -4022,6 +4267,120 @@ function requireSession(req, res, next) {
next();
}
function resolveAssistantGatewayAdminContext(req) {
const gateway = readRequestHeader(req, "x-nodedc-assistant-gateway");
const actorUserId = readRequestHeader(req, "x-nodedc-assistant-actor-user-id");
const actorEmail = readRequestHeader(req, "x-nodedc-assistant-actor-email");
const actorSubject = readRequestHeader(req, "x-nodedc-assistant-actor-subject");
const hasGatewaySignal = Boolean(gateway || actorUserId || actorEmail || actorSubject);
if (!hasGatewaySignal) {
return null;
}
if (gateway !== assistantGatewayName) {
return {
ok: false,
status: 401,
error: "assistant_gateway_invalid",
message: "Unknown assistant gateway.",
};
}
if (!isInternalRequestAuthorized(req)) {
return {
ok: false,
status: config.internalAccessToken ? 401 : 503,
error: config.internalAccessToken ? "assistant_gateway_unauthorized" : "assistant_gateway_not_configured",
message: "Assistant gateway internal token is missing or invalid.",
};
}
if (!isAssistantGatewayAdminRoute(req)) {
return {
ok: false,
status: 403,
error: "assistant_gateway_route_not_allowlisted",
message: "Assistant gateway can only call explicitly allowlisted admin routes.",
};
}
const snapshot = controlPlaneStore.getSnapshot({ name: "NDC Core Assistant Gateway", source: "assistant-gateway" });
const actor = findInternalAccessUser(snapshot.data, {
userId: actorUserId,
email: actorEmail,
subject: actorSubject,
});
if (!actor) {
return {
ok: false,
status: 404,
error: "assistant_gateway_actor_not_found",
message: "Assistant gateway actor was not found in Launcher control-plane.",
};
}
if (actor.globalStatus === "blocked") {
return {
ok: false,
status: 403,
error: "assistant_gateway_actor_blocked",
message: "Assistant gateway actor is blocked.",
};
}
const groups = resolveRequiredGroups(snapshot.data, actor);
const user = buildAssistantGatewaySessionUser(actor, groups);
const adminScope = resolveAdminScope(user, groups);
if (!adminScope.isRoot && adminScope.clientIds.size === 0) {
return {
ok: false,
status: 403,
error: "assistant_gateway_actor_not_admin",
message: "Assistant gateway actor has no Launcher admin scope.",
};
}
return {
ok: true,
session: {
id: `assistant-gateway:${actor.id}`,
expiresAt: Date.now() + 60_000,
user,
},
adminScope,
gateway: {
name: gateway,
actorUserId: actor.id,
},
};
}
function isAssistantGatewayAdminRoute(req) {
const method = String(req.method || "").toUpperCase();
const path = req.path || String(req.url || "").split("?")[0] || "";
return assistantGatewayAdminRouteAllowlist.some((route) => route.method === method && route.pattern.test(path));
}
function buildAssistantGatewaySessionUser(actor, groups) {
return {
sub: actor.authentikUserId || `launcher:${actor.id}`,
email: actor.email,
name: actor.name,
avatarUrl: actor.avatarUrl ?? null,
groups,
};
}
function readRequestHeader(req, name) {
const raw = req.headers[String(name).toLowerCase()];
const value = Array.isArray(raw) ? raw[0] : raw;
return typeof value === "string" ? value.trim() : "";
}
function isInternalRequestAuthorized(req) {
if (!config.internalAccessToken) {
return false;
@@ -4049,16 +4408,28 @@ function safeTokenEquals(actual, expected) {
function findInternalAccessUser(data, payload) {
const subject = typeof payload?.subject === "string" ? payload.subject : "";
const email = typeof payload?.email === "string" ? payload.email.toLowerCase() : "";
const canonicalEmail = canonicalInternalAccessEmail(email);
const userId = typeof payload?.userId === "string" ? payload.userId : "";
return (
data.users.find((user) => userId && user.id === userId) ??
data.users.find((user) => subject && user.authentikUserId === subject) ??
data.users.find((user) => email && user.email.toLowerCase() === email) ??
data.users.find((user) => canonicalEmail && canonicalInternalAccessEmail(user.email) === canonicalEmail) ??
null
);
}
function canonicalInternalAccessEmail(value) {
const email = String(value || "").trim().toLowerCase();
const [local, domain] = email.split("@");
if (!local || !domain) return email;
if (domain === "gmail.com" || domain === "googlemail.com") {
return `${local.replace(/\./g, "")}@gmail.com`;
}
return email;
}
function sanitizeServiceSlug(value) {
return typeof value === "string" && value ? value : "task-manager";
}
@@ -4772,6 +5143,133 @@ function ensureTrailingSlash(value) {
return value.endsWith("/") ? value : `${value}/`;
}
async function sendAdminIdempotentJson(req, res, operation) {
const idempotencyKey = normalizeAdminMutationIdempotencyKey(req);
if (idempotencyKey === false) {
res.status(400).json({
ok: false,
error: "idempotency_key_invalid",
message: "Idempotency-Key must be 8-256 visible characters.",
});
return;
}
if (!idempotencyKey) {
res.json(await operation());
return;
}
pruneAdminMutationIdempotencyRecords();
const recordKey = buildAdminMutationIdempotencyRecordKey(req, idempotencyKey);
const fingerprint = buildAdminMutationFingerprint(req);
const existingRecord = adminMutationIdempotencyRecords.get(recordKey);
if (existingRecord && existingRecord.fingerprint !== fingerprint) {
res.status(409).json({
ok: false,
error: "idempotency_key_conflict",
message: "Idempotency-Key was already used for a different admin mutation.",
});
return;
}
if (existingRecord?.status === "completed") {
res.setHeader("Idempotency-Key", idempotencyKey);
res.setHeader("Idempotency-Replayed", "true");
res.status(existingRecord.statusCode).json(existingRecord.body);
return;
}
if (existingRecord?.status === "pending") {
const response = await existingRecord.promise;
res.setHeader("Idempotency-Key", idempotencyKey);
res.setHeader("Idempotency-Replayed", "true");
res.status(response.statusCode).json(response.body);
return;
}
const promise = Promise.resolve(operation()).then((body) => ({ statusCode: 200, body }));
adminMutationIdempotencyRecords.set(recordKey, {
status: "pending",
createdAt: Date.now(),
fingerprint,
promise,
});
try {
const response = await promise;
adminMutationIdempotencyRecords.set(recordKey, {
status: "completed",
createdAt: Date.now(),
fingerprint,
statusCode: response.statusCode,
body: response.body,
});
res.setHeader("Idempotency-Key", idempotencyKey);
res.status(response.statusCode).json(response.body);
} catch (error) {
adminMutationIdempotencyRecords.delete(recordKey);
throw error;
}
}
function normalizeAdminMutationIdempotencyKey(req) {
const rawHeader = req.headers["idempotency-key"];
const rawValue = Array.isArray(rawHeader) ? rawHeader[0] : rawHeader;
if (rawValue === undefined || rawValue === null || rawValue === "") {
return null;
}
const value = String(rawValue).trim();
if (!/^[\x21-\x7E]{8,256}$/.test(value)) {
return false;
}
return value;
}
function buildAdminMutationIdempotencyRecordKey(req, idempotencyKey) {
const actorId =
req.nodedcAdminScope?.actorId ||
req.nodedcSession?.user?.sub ||
req.nodedcSession?.user?.email ||
"unknown";
return `${actorId}:${idempotencyKey}`;
}
function buildAdminMutationFingerprint(req) {
const routePath = req.route?.path || req.path;
const bodyHash = createHash("sha256").update(stableJson(req.body ?? null)).digest("hex");
return `${req.method}:${routePath}:${req.path}:${bodyHash}`;
}
function pruneAdminMutationIdempotencyRecords() {
const expiresBefore = Date.now() - adminMutationIdempotencyTtlMs;
for (const [key, record] of adminMutationIdempotencyRecords.entries()) {
if ((record.createdAt ?? 0) < expiresBefore) {
adminMutationIdempotencyRecords.delete(key);
}
}
}
function stableJson(value) {
if (Array.isArray(value)) {
return `[${value.map((item) => stableJson(item)).join(",")}]`;
}
if (value && typeof value === "object") {
return `{${Object.keys(value)
.sort()
.map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`)
.join(",")}}`;
}
return JSON.stringify(value);
}
function asyncRoute(handler) {
return (req, res, next) => {
Promise.resolve(handler(req, res, next)).catch(next);