feat(foundry): productionize Cesium Map Page and platform runtime
This commit is contained in:
@@ -0,0 +1,556 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { extname } from "node:path";
|
||||
|
||||
const DEFAULT_SESSION_TTL_MS = 12 * 60 * 60 * 1000;
|
||||
const DEFAULT_SESSION_VALIDATION_TTL_MS = 20_000;
|
||||
const MIN_SESSION_VALIDATION_TTL_MS = 15_000;
|
||||
const MAX_SESSION_VALIDATION_TTL_MS = 30_000;
|
||||
const DEFAULT_SESSION_VALIDATION_GRACE_MS = 30_000;
|
||||
const MAX_SESSION_VALIDATION_GRACE_MS = 60_000;
|
||||
const DEFAULT_SESSION_VALIDATION_RETRY_MS = 2_000;
|
||||
|
||||
function parseBoolean(value, fallback) {
|
||||
if (value === undefined || value === null || value === "") return fallback;
|
||||
return ["1", "true", "yes", "on"].includes(String(value).trim().toLowerCase());
|
||||
}
|
||||
|
||||
function boundedInteger(value, fallback, minimum, maximum) {
|
||||
const parsed = Number.parseInt(String(value ?? ""), 10);
|
||||
if (!Number.isFinite(parsed)) return fallback;
|
||||
return Math.min(maximum, Math.max(minimum, parsed));
|
||||
}
|
||||
|
||||
function normalizedBaseUrl(value, fallback) {
|
||||
return String(value || fallback).trim().replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function sanitizeReturnTo(value, fallback = "/") {
|
||||
return typeof value === "string" && value.startsWith("/") && !value.startsWith("//") ? value : fallback;
|
||||
}
|
||||
|
||||
function parseCookies(cookieHeader) {
|
||||
if (!cookieHeader) return {};
|
||||
|
||||
return Object.fromEntries(
|
||||
String(cookieHeader)
|
||||
.split(";")
|
||||
.flatMap((part) => {
|
||||
const separator = part.indexOf("=");
|
||||
if (separator === -1) return [];
|
||||
const key = part.slice(0, separator).trim();
|
||||
const rawValue = part.slice(separator + 1).trim();
|
||||
try {
|
||||
return [[key, decodeURIComponent(rawValue)]];
|
||||
} catch {
|
||||
return [[key, rawValue]];
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function appendSetCookie(response, cookie) {
|
||||
const current = response.getHeader("Set-Cookie");
|
||||
if (!current) {
|
||||
response.setHeader("Set-Cookie", cookie);
|
||||
return;
|
||||
}
|
||||
response.setHeader("Set-Cookie", Array.isArray(current) ? [...current, cookie] : [current, cookie]);
|
||||
}
|
||||
|
||||
function sendJson(response, statusCode, body) {
|
||||
response.statusCode = statusCode;
|
||||
response.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
response.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function sendText(response, statusCode, body) {
|
||||
response.statusCode = statusCode;
|
||||
response.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
response.end(body);
|
||||
}
|
||||
|
||||
function isHtmlNavigation(request, url) {
|
||||
if (request.method !== "GET" && request.method !== "HEAD") return false;
|
||||
if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/auth/")) return false;
|
||||
const accept = String(request.headers.accept || "");
|
||||
return url.pathname === "/" || (!extname(url.pathname) && accept.includes("text/html"));
|
||||
}
|
||||
|
||||
function identityGroupNames(user) {
|
||||
if (!user || typeof user !== "object") return new Set();
|
||||
const values = [user.groups, user.roles, user.roleKeys, user.permissions];
|
||||
const names = new Set();
|
||||
for (const value of values) {
|
||||
const items = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
|
||||
for (const item of items) {
|
||||
const name = typeof item === "string"
|
||||
? item
|
||||
: item && typeof item === "object"
|
||||
? item.name || item.key || item.slug || item.id
|
||||
: "";
|
||||
const normalized = String(name || "").trim().toLowerCase();
|
||||
if (normalized) names.add(normalized);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function resolveFoundryAccess(user, authRequired) {
|
||||
// Local development deliberately remains operable without a Launcher. In a
|
||||
// deployed instance every decision below comes only from the revalidated
|
||||
// Launcher/Authentik session identity, never from a browser header.
|
||||
if (!authRequired && !user) return { role: "admin", allowed: true, admin: true };
|
||||
if (!user || typeof user !== "object") return { role: "none", allowed: false, admin: false };
|
||||
|
||||
const id = String(user.id || user.subject || user.sub || "").trim().toLowerCase();
|
||||
const groups = identityGroupNames(user);
|
||||
// A block is deny-first: it wins even over an accidentally retained admin
|
||||
// group, so Launcher can revoke a service session deterministically.
|
||||
if (groups.has("nodedc:module-foundry:blocked")) return { role: "blocked", allowed: false, admin: false };
|
||||
if (id === "user_root" || groups.has("nodedc:superadmin") || groups.has("nodedc:module-foundry:admin")) {
|
||||
return { role: "admin", allowed: true, admin: true };
|
||||
}
|
||||
if (groups.has("nodedc:module-foundry:user") || groups.has("nodedc:module-foundry:access")) {
|
||||
return { role: "user", allowed: true, admin: false };
|
||||
}
|
||||
return { role: "none", allowed: false, admin: false };
|
||||
}
|
||||
|
||||
export function createFoundryAuth(options = {}) {
|
||||
const now = typeof options.now === "function" ? options.now : Date.now;
|
||||
const fetchImpl = typeof options.fetch === "function" ? options.fetch : fetch;
|
||||
const authRequired = parseBoolean(process.env.NODEDC_FOUNDRY_AUTH_REQUIRED, process.env.NODE_ENV === "production");
|
||||
const serviceSlug = String(process.env.NODEDC_FOUNDRY_SERVICE_SLUG || "module-foundry").trim() || "module-foundry";
|
||||
const launcherBaseUrl = normalizedBaseUrl(process.env.NODEDC_LAUNCHER_BASE_URL, "http://127.0.0.1:5173");
|
||||
const launcherInternalUrl = normalizedBaseUrl(process.env.NODEDC_LAUNCHER_INTERNAL_URL, launcherBaseUrl);
|
||||
const internalAccessToken = String(
|
||||
process.env.NODEDC_INTERNAL_ACCESS_TOKEN || process.env.NODEDC_PLATFORM_SERVICE_TOKEN || ""
|
||||
).trim();
|
||||
const sessionCookie = String(process.env.NODEDC_FOUNDRY_SESSION_COOKIE || "nodedc_foundry_session").trim();
|
||||
const sessionTtlMs = Math.max(
|
||||
60 * 1000,
|
||||
Number.parseInt(process.env.NODEDC_FOUNDRY_SESSION_TTL_MS || String(DEFAULT_SESSION_TTL_MS), 10) || DEFAULT_SESSION_TTL_MS
|
||||
);
|
||||
// Launcher revocation remains bounded while a tile burst no longer performs
|
||||
// one control-plane validation per object. Production configuration is
|
||||
// deliberately clamped to a narrow range; tests may inject deterministic
|
||||
// values without changing runtime policy.
|
||||
const sessionValidationTtlMs = options.validationTtlMs ?? boundedInteger(
|
||||
process.env.NODEDC_FOUNDRY_SESSION_VALIDATION_TTL_MS,
|
||||
DEFAULT_SESSION_VALIDATION_TTL_MS,
|
||||
MIN_SESSION_VALIDATION_TTL_MS,
|
||||
MAX_SESSION_VALIDATION_TTL_MS,
|
||||
);
|
||||
const sessionValidationGraceMs = options.validationGraceMs ?? boundedInteger(
|
||||
process.env.NODEDC_FOUNDRY_SESSION_VALIDATION_GRACE_MS,
|
||||
DEFAULT_SESSION_VALIDATION_GRACE_MS,
|
||||
0,
|
||||
MAX_SESSION_VALIDATION_GRACE_MS,
|
||||
);
|
||||
const sessionValidationRetryMs = options.validationRetryMs ?? boundedInteger(
|
||||
process.env.NODEDC_FOUNDRY_SESSION_VALIDATION_RETRY_MS,
|
||||
DEFAULT_SESSION_VALIDATION_RETRY_MS,
|
||||
250,
|
||||
5_000,
|
||||
);
|
||||
const cookieSecure = parseBoolean(process.env.NODEDC_FOUNDRY_COOKIE_SECURE, authRequired);
|
||||
const cookieSameSite = String(process.env.NODEDC_FOUNDRY_COOKIE_SAMESITE || "Lax").trim() || "Lax";
|
||||
const sessions = new Map();
|
||||
|
||||
function emitAuthDiagnostic({ outcome, reason, launcherStatus, durationMs }) {
|
||||
// The allowlisted record intentionally excludes the local cookie id,
|
||||
// Launcher session id, internal bearer token and user identity.
|
||||
const record = Object.freeze({
|
||||
event: "foundry_session_validation",
|
||||
outcome,
|
||||
reason,
|
||||
launcherStatus: Number.isInteger(launcherStatus) ? launcherStatus : null,
|
||||
durationMs: Math.max(0, Math.round(Number(durationMs) || 0)),
|
||||
serviceSlug,
|
||||
});
|
||||
if (typeof options.onDiagnostic === "function") {
|
||||
try {
|
||||
options.onDiagnostic(record);
|
||||
} catch {
|
||||
// Diagnostics are best-effort and must never change an authorization
|
||||
// decision or turn a successful validation into a transient failure.
|
||||
}
|
||||
return;
|
||||
}
|
||||
const method = outcome === "active" ? "info" : "warn";
|
||||
try {
|
||||
console[method](JSON.stringify(record));
|
||||
} catch {
|
||||
// A broken log sink must not affect the request path.
|
||||
}
|
||||
}
|
||||
|
||||
function buildCookie(value, maxAgeSeconds) {
|
||||
const parts = [
|
||||
`${sessionCookie}=${encodeURIComponent(value)}`,
|
||||
"Path=/",
|
||||
"HttpOnly",
|
||||
`SameSite=${cookieSameSite}`,
|
||||
`Max-Age=${Math.max(0, Math.floor(maxAgeSeconds))}`,
|
||||
];
|
||||
if (cookieSecure) parts.push("Secure");
|
||||
return parts.join("; ");
|
||||
}
|
||||
|
||||
function clearSession(request, response) {
|
||||
const sessionId = parseCookies(request.headers.cookie || "")[sessionCookie];
|
||||
if (sessionId) sessions.delete(sessionId);
|
||||
appendSetCookie(response, buildCookie("", 0));
|
||||
}
|
||||
|
||||
function pruneExpiredSessions() {
|
||||
const currentTime = now();
|
||||
for (const [id, session] of sessions.entries()) {
|
||||
if (!session || session.expiresAt <= currentTime) sessions.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
function createSession(response, handoff) {
|
||||
pruneExpiredSessions();
|
||||
const createdAtMs = now();
|
||||
const id = randomBytes(32).toString("base64url");
|
||||
const session = {
|
||||
id,
|
||||
user: handoff.user || null,
|
||||
launcherSessionId: typeof handoff.launcherSessionId === "string" ? handoff.launcherSessionId : null,
|
||||
createdAt: new Date(createdAtMs).toISOString(),
|
||||
expiresAt: createdAtMs + sessionTtlMs,
|
||||
lastValidatedAt: createdAtMs,
|
||||
validationInFlight: null,
|
||||
validationRetryAt: 0,
|
||||
lastValidationOutcome: "active",
|
||||
};
|
||||
sessions.set(id, session);
|
||||
appendSetCookie(response, buildCookie(id, sessionTtlMs / 1000));
|
||||
return session;
|
||||
}
|
||||
|
||||
function currentSession(request) {
|
||||
const sessionId = parseCookies(request.headers.cookie || "")[sessionCookie];
|
||||
if (!sessionId) return null;
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session || session.expiresAt <= now()) {
|
||||
sessions.delete(sessionId);
|
||||
return null;
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
function buildLauncherLaunchUrl(nextPath) {
|
||||
const launchUrl = new URL(`/api/services/${encodeURIComponent(serviceSlug)}/launch`, launcherBaseUrl);
|
||||
launchUrl.searchParams.set("returnTo", sanitizeReturnTo(nextPath));
|
||||
return launchUrl.toString();
|
||||
}
|
||||
|
||||
function buildLauncherLoginUrl(nextPath) {
|
||||
const loginUrl = new URL("/auth/login", launcherBaseUrl);
|
||||
const launchUrl = new URL(buildLauncherLaunchUrl(nextPath));
|
||||
loginUrl.searchParams.set("returnTo", `${launchUrl.pathname}${launchUrl.search}`);
|
||||
return loginUrl.toString();
|
||||
}
|
||||
|
||||
async function launcherRequest(pathname, payload) {
|
||||
if (!internalAccessToken) {
|
||||
throw new Error("NODE.DC internal access configuration is not available.");
|
||||
}
|
||||
|
||||
const response = await fetchImpl(new URL(pathname, launcherInternalUrl), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${internalAccessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal: AbortSignal.timeout(8_000),
|
||||
});
|
||||
const body = await response.json().catch(() => null);
|
||||
return { response, body };
|
||||
}
|
||||
|
||||
async function consumeHandoff(token) {
|
||||
const { response, body } = await launcherRequest("/api/internal/handoff/consume", { token, serviceSlug });
|
||||
if (!response.ok || !body?.ok) {
|
||||
throw new Error(body?.error || `Launcher handoff failed: HTTP ${response.status}`);
|
||||
}
|
||||
return {
|
||||
user: body.user || null,
|
||||
launcherSessionId: typeof body.launcherSessionId === "string" ? body.launcherSessionId : null,
|
||||
};
|
||||
}
|
||||
|
||||
async function validateSession(session) {
|
||||
if (!authRequired) return { outcome: "active", user: session?.user || null, reason: "auth_not_required" };
|
||||
if (!session?.launcherSessionId) {
|
||||
return { outcome: "inactive", reason: "launcher_session_missing", launcherStatus: null };
|
||||
}
|
||||
const { response, body } = await launcherRequest("/api/internal/session/validate", {
|
||||
serviceSlug,
|
||||
launcherSessionId: session.launcherSessionId,
|
||||
});
|
||||
if (response.ok && body?.ok === true && body.active === true) {
|
||||
return {
|
||||
outcome: "active",
|
||||
user: body.user || session.user || null,
|
||||
reason: "launcher_session_active",
|
||||
launcherStatus: response.status,
|
||||
};
|
||||
}
|
||||
// Launcher deliberately represents a revoked/expired user session as a
|
||||
// successful validation response with active=false. HTTP failures here
|
||||
// describe the internal validation service (including its own bearer-token
|
||||
// configuration), so they must not destroy an otherwise valid user cookie.
|
||||
if (response.ok && body?.ok === true && body.active === false) {
|
||||
return {
|
||||
outcome: "inactive",
|
||||
reason: "launcher_session_inactive",
|
||||
launcherStatus: response.status,
|
||||
};
|
||||
}
|
||||
return {
|
||||
outcome: "transient",
|
||||
reason: response.status >= 500
|
||||
? "launcher_unavailable"
|
||||
: response.status === 401 || response.status === 403
|
||||
? "launcher_internal_auth_rejected"
|
||||
: "launcher_validation_invalid_response",
|
||||
launcherStatus: response.status,
|
||||
};
|
||||
}
|
||||
|
||||
function isReadOnlyRequest(request) {
|
||||
return request.method === "GET" || request.method === "HEAD";
|
||||
}
|
||||
|
||||
function transientFailureReason(error) {
|
||||
return error?.name === "TimeoutError" || error?.name === "AbortError"
|
||||
? "launcher_timeout"
|
||||
: "launcher_network_error";
|
||||
}
|
||||
|
||||
function startSessionValidation(session) {
|
||||
if (session.validationInFlight) return session.validationInFlight;
|
||||
const startedAt = now();
|
||||
const validation = (async () => {
|
||||
try {
|
||||
const result = await validateSession(session);
|
||||
if (result.outcome === "active") {
|
||||
session.user = result.user;
|
||||
session.lastValidatedAt = now();
|
||||
session.validationRetryAt = 0;
|
||||
} else if (result.outcome === "transient") {
|
||||
session.validationRetryAt = now() + sessionValidationRetryMs;
|
||||
}
|
||||
session.lastValidationOutcome = result.outcome;
|
||||
emitAuthDiagnostic({
|
||||
outcome: result.outcome,
|
||||
reason: result.reason,
|
||||
launcherStatus: result.launcherStatus,
|
||||
durationMs: now() - startedAt,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
const result = {
|
||||
outcome: "transient",
|
||||
reason: transientFailureReason(error),
|
||||
launcherStatus: null,
|
||||
};
|
||||
session.validationRetryAt = now() + sessionValidationRetryMs;
|
||||
session.lastValidationOutcome = result.outcome;
|
||||
emitAuthDiagnostic({
|
||||
...result,
|
||||
durationMs: now() - startedAt,
|
||||
});
|
||||
return result;
|
||||
} finally {
|
||||
session.validationInFlight = null;
|
||||
}
|
||||
})();
|
||||
session.validationInFlight = validation;
|
||||
return validation;
|
||||
}
|
||||
|
||||
function useSession(request, session) {
|
||||
request.nodedcFoundrySession = session;
|
||||
return session;
|
||||
}
|
||||
|
||||
function canUseValidationGrace(request, session) {
|
||||
if (!isReadOnlyRequest(request)) return false;
|
||||
const validationAge = Math.max(0, now() - session.lastValidatedAt);
|
||||
return validationAge <= sessionValidationTtlMs + sessionValidationGraceMs;
|
||||
}
|
||||
|
||||
async function getValidatedSession(request, response) {
|
||||
const session = currentSession(request);
|
||||
if (!session) {
|
||||
// Sessions intentionally remain process-local: after a deploy/restart the
|
||||
// opaque cookie cannot be reconstructed safely. Expire a stale cookie and
|
||||
// require a fresh Launcher handoff instead of treating it as recoverable.
|
||||
const staleSessionId = parseCookies(request.headers.cookie || "")[sessionCookie];
|
||||
if (staleSessionId) {
|
||||
clearSession(request, response);
|
||||
emitAuthDiagnostic({
|
||||
outcome: "inactive",
|
||||
reason: "local_session_missing_or_expired",
|
||||
launcherStatus: null,
|
||||
durationMs: 0,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (now() - session.lastValidatedAt <= sessionValidationTtlMs) {
|
||||
return useSession(request, session);
|
||||
}
|
||||
|
||||
let validation;
|
||||
if (session.lastValidationOutcome === "transient" && now() < session.validationRetryAt) {
|
||||
validation = { outcome: "transient", reason: "launcher_retry_backoff", launcherStatus: null };
|
||||
} else {
|
||||
validation = await startSessionValidation(session);
|
||||
}
|
||||
|
||||
// Logout or another explicit invalidation may have removed the process-local
|
||||
// session while a shared validation was in flight. Never resurrect it.
|
||||
if (sessions.get(session.id) !== session) return null;
|
||||
|
||||
if (validation.outcome === "active") return useSession(request, session);
|
||||
if (validation.outcome === "inactive") {
|
||||
clearSession(request, response);
|
||||
return null;
|
||||
}
|
||||
if (canUseValidationGrace(request, session)) return useSession(request, session);
|
||||
|
||||
request.nodedcFoundryAuthUnavailable = true;
|
||||
return null;
|
||||
}
|
||||
|
||||
function sendAuthRequired(request, response, url) {
|
||||
const nextPath = sanitizeReturnTo(`${url.pathname}${url.search || ""}`);
|
||||
if (!internalAccessToken) {
|
||||
sendText(response, 503, "Module Foundry access is not configured on this server.");
|
||||
return;
|
||||
}
|
||||
const loginUrl = buildLauncherLoginUrl(nextPath);
|
||||
if (isHtmlNavigation(request, url)) {
|
||||
response.statusCode = 302;
|
||||
response.setHeader("Location", loginUrl);
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
sendJson(response, 401, {
|
||||
ok: false,
|
||||
authenticated: false,
|
||||
error: "module_foundry_auth_required",
|
||||
loginUrl,
|
||||
});
|
||||
}
|
||||
|
||||
function sendAuthUnavailable(request, response) {
|
||||
if (String(request.url || "").startsWith("/api/")) {
|
||||
sendJson(response, 503, {
|
||||
ok: false,
|
||||
authenticated: true,
|
||||
error: "module_foundry_auth_unavailable",
|
||||
});
|
||||
return;
|
||||
}
|
||||
sendText(response, 503, "Module Foundry session validation is temporarily unavailable.");
|
||||
}
|
||||
|
||||
function sendAccessDenied(response, access) {
|
||||
sendJson(response, 403, {
|
||||
ok: false,
|
||||
authenticated: true,
|
||||
error: access?.role === "blocked" ? "module_foundry_access_blocked" : "module_foundry_access_denied",
|
||||
});
|
||||
}
|
||||
|
||||
async function handleLauncherHandoff(request, response, url) {
|
||||
if (!authRequired) {
|
||||
response.statusCode = 302;
|
||||
response.setHeader("Location", sanitizeReturnTo(url.searchParams.get("next_path") || url.searchParams.get("returnTo") || "/"));
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const token = String(url.searchParams.get("token") || "");
|
||||
const nextPath = sanitizeReturnTo(url.searchParams.get("next_path") || url.searchParams.get("returnTo") || "/");
|
||||
if (!token) {
|
||||
sendText(response, 400, "Missing Launcher handoff token.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const handoff = await consumeHandoff(token);
|
||||
createSession(response, handoff);
|
||||
response.statusCode = 302;
|
||||
response.setHeader("Location", nextPath);
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
response.end();
|
||||
} catch (error) {
|
||||
sendText(response, 401, `Launcher handoff failed: ${error.message || "unknown error"}`);
|
||||
}
|
||||
}
|
||||
|
||||
function handleLogout(request, response) {
|
||||
clearSession(request, response);
|
||||
response.statusCode = 302;
|
||||
response.setHeader("Location", "/");
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
response.end();
|
||||
}
|
||||
|
||||
async function ensureRequestSession(request, response, url) {
|
||||
if (!authRequired) return false;
|
||||
if (url.pathname === "/auth/nodedc/handoff" || url.pathname === "/auth/logout") return false;
|
||||
if (url.pathname === "/healthz") return false;
|
||||
if (url.pathname === "/api/mcp" || url.pathname === "/api/ai-workspace/entitlements") return false;
|
||||
if (await getValidatedSession(request, response)) {
|
||||
const access = currentUserAccess(request);
|
||||
if (access.allowed) return false;
|
||||
sendAccessDenied(response, access);
|
||||
return true;
|
||||
}
|
||||
if (request.nodedcFoundryAuthUnavailable) {
|
||||
sendAuthUnavailable(request, response);
|
||||
return true;
|
||||
}
|
||||
sendAuthRequired(request, response, url);
|
||||
return true;
|
||||
}
|
||||
|
||||
function currentUserAccess(request) {
|
||||
return resolveFoundryAccess(request.nodedcFoundrySession?.user, authRequired);
|
||||
}
|
||||
|
||||
function currentUserProfile(request) {
|
||||
const user = request.nodedcFoundrySession?.user;
|
||||
if (!user || typeof user !== "object") return null;
|
||||
const id = String(user.id || user.subject || user.sub || "").trim();
|
||||
const email = String(user.email || "").trim();
|
||||
const displayName = String(user.name || user.displayName || email || "NODE.DC").trim().slice(0, 240);
|
||||
const avatarCandidate = String(user.avatarUrl || user.avatar_url || user.picture || "").trim();
|
||||
const avatarUrl = /^https:\/\//i.test(avatarCandidate) || avatarCandidate.startsWith("/") ? avatarCandidate : null;
|
||||
const initials = displayName.split(/\s+/).filter(Boolean).slice(0, 2).map((part) => part[0]).join("").toUpperCase() || "DC";
|
||||
return { id, email, displayName, avatarUrl, initials };
|
||||
}
|
||||
|
||||
return {
|
||||
authRequired,
|
||||
serviceSlug,
|
||||
handleLauncherHandoff,
|
||||
handleLogout,
|
||||
ensureRequestSession,
|
||||
currentUserAccess,
|
||||
currentUserProfile,
|
||||
profileUrl: new URL("/profile", launcherBaseUrl).toString(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user