@pablo2410/core-server 0.10.2 → 0.10.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/auth.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  createSubdomainAuth
3
- } from "./chunk-SBJJKJGF.js";
3
+ } from "./chunk-WUPVFJO6.js";
4
4
  export {
5
5
  createSubdomainAuth
6
6
  };
@@ -1,6 +1,10 @@
1
1
  // src/hierarchy.ts
2
2
  import { scopeHierarchyToEnterprise } from "@pablo2410/shared-ui/hierarchy/core";
3
3
  var FIFTEEN_MINUTES = 15 * 60 * 1e3;
4
+ function enterpriseIdOf(user) {
5
+ const id = user?.enterpriseId;
6
+ return typeof id === "number" && Number.isSafeInteger(id) && id > 0 ? id : null;
7
+ }
4
8
  function createHierarchyService(config) {
5
9
  const {
6
10
  portalApiUrl,
@@ -10,9 +14,11 @@ function createHierarchyService(config) {
10
14
  now = Date.now
11
15
  } = config;
12
16
  const configured = Boolean(portalApiUrl && portalApiKey);
13
- let cache = null;
17
+ const scopedCache = /* @__PURE__ */ new Map();
18
+ let fullCache = null;
19
+ const isFresh = (entry) => cacheTtlMs > 0 && !!entry && now() - entry.at < cacheTtlMs;
14
20
  let lastError = null;
15
- async function fetchRaw() {
21
+ async function fetchRaw(enterpriseId) {
16
22
  if (!configured) {
17
23
  lastError = "not configured (PORTAL_API_URL/PORTAL_API_KEY missing)";
18
24
  return null;
@@ -23,7 +29,9 @@ function createHierarchyService(config) {
23
29
  res = await fetchImpl(url, {
24
30
  headers: {
25
31
  "X-Service-Key": portalApiKey,
26
- "Content-Type": "application/json"
32
+ "Content-Type": "application/json",
33
+ // Names the caller's company (portal#348).
34
+ ...enterpriseId !== void 0 ? { "X-Enterprise-Id": String(enterpriseId) } : {}
27
35
  }
28
36
  });
29
37
  } catch (err) {
@@ -46,25 +54,42 @@ function createHierarchyService(config) {
46
54
  return normalize(json);
47
55
  }
48
56
  async function getFullHierarchy() {
49
- if (cacheTtlMs > 0 && cache && now() - cache.at < cacheTtlMs) {
50
- return cache.data;
51
- }
57
+ if (isFresh(fullCache)) return fullCache.data;
52
58
  const data = await fetchRaw();
53
- if (data && cacheTtlMs > 0) cache = { data, at: now() };
59
+ if (data && cacheTtlMs > 0) fullCache = { data, at: now() };
54
60
  return data;
55
61
  }
56
62
  async function getScopedHierarchy(user) {
57
- const full = await getFullHierarchy();
58
- return scopeHierarchyToEnterprise(full, user?.enterpriseId ?? null);
63
+ const enterpriseId = enterpriseIdOf(user);
64
+ if (enterpriseId === null) return scopeHierarchyToEnterprise(null, null);
65
+ let entry = scopedCache.get(enterpriseId);
66
+ if (!isFresh(entry)) {
67
+ const raw = await fetchRaw(enterpriseId);
68
+ if (!raw) return scopeHierarchyToEnterprise(null, enterpriseId);
69
+ entry = {
70
+ data: scopeHierarchyToEnterprise(raw, enterpriseId),
71
+ at: now()
72
+ };
73
+ if (cacheTtlMs > 0) scopedCache.set(enterpriseId, entry);
74
+ }
75
+ return scopeHierarchyToEnterprise(entry.data, enterpriseId);
59
76
  }
60
- function invalidate() {
61
- cache = null;
77
+ function invalidate(enterpriseId) {
78
+ if (enterpriseId !== void 0) {
79
+ scopedCache.delete(enterpriseId);
80
+ return;
81
+ }
82
+ scopedCache.clear();
83
+ fullCache = null;
62
84
  }
63
85
  function getStatus() {
86
+ const ats = [...scopedCache.values()].map((e) => e.at);
87
+ if (fullCache) ats.push(fullCache.at);
64
88
  return {
65
89
  configured,
66
- cached: cache !== null,
67
- ageMs: cache ? now() - cache.at : null,
90
+ cached: ats.length > 0,
91
+ ageMs: ats.length > 0 ? now() - Math.min(...ats) : null,
92
+ cachedEnterprises: scopedCache.size,
68
93
  lastError
69
94
  };
70
95
  }
@@ -112,4 +137,4 @@ function normalize(data) {
112
137
  export {
113
138
  createHierarchyService
114
139
  };
115
- //# sourceMappingURL=chunk-CJ6GDQX2.js.map
140
+ //# sourceMappingURL=chunk-NSTH36LS.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/hierarchy.ts"],"sourcesContent":["/**\n * Hierarchy Service — the single server-side source for org-hierarchy data.\n *\n * Fetches the canonical org-hierarchy from the Portal Service API\n * (GET /api/service/hierarchy, authenticated with the service key), naming the\n * caller's company with X-Enterprise-Id (#91, as Policy Deployment does since\n * PD#313). Caches it per company with a TTL, and scopes it to that company with\n * the shared isolation rule (scopeHierarchyToEnterprise from\n * @pablo2410/shared-ui) on every read, as a second check.\n *\n * One fetch, one mapper, one cache, one scope policy. Every subdomain constructs\n * this once from its env and calls getScopedHierarchy(ctx.user) in its tRPC\n * `hierarchy` procedure — no per-app fetch/scope/null-policy reinvention.\n *\n * Quick example:\n * const hierarchy = createHierarchyService({\n * portalApiUrl: process.env.PORTAL_API_URL ?? \"\",\n * portalApiKey: process.env.PORTAL_API_KEY ?? \"\",\n * });\n * // in a tRPC router:\n * hierarchy: protectedProcedure.query(({ ctx }) => hierarchy.getScopedHierarchy(ctx.user)),\n */\nimport type { OrgHierarchyData } from \"@pablo2410/shared-ui/hierarchy/core\";\nimport { scopeHierarchyToEnterprise } from \"@pablo2410/shared-ui/hierarchy/core\";\n\nexport interface HierarchyServiceConfig {\n /** Portal Service API base URL, e.g. https://portal.oplyticsdigital.net */\n portalApiUrl: string;\n /** Platform-level X-Service-Key for the Portal Service API. */\n portalApiKey: string;\n /** Cache TTL in milliseconds. Default 15 minutes. Set 0 to disable caching. */\n cacheTtlMs?: number;\n /** Injectable fetch implementation (for tests). Defaults to global fetch. */\n fetchImpl?: typeof fetch;\n /** Injectable clock (for tests). Defaults to Date.now. */\n now?: () => number;\n}\n\nexport interface HierarchyService {\n /**\n * The org-hierarchy scoped to the caller's enterprise, fetched with\n * `X-Enterprise-Id: <enterpriseId>` and cached per enterprise. A user with no\n * resolved enterprise (null/undefined, or not a positive whole number)\n * receives an empty hierarchy without a Portal call — see\n * scopeHierarchyToEnterprise for the policy. Never returns null.\n */\n getScopedHierarchy(\n user: { enterpriseId?: number | null } | null\n ): Promise<OrgHierarchyData>;\n /**\n * The full, unscoped hierarchy (every enterprise). For Portal/admin tooling\n * only — subdomains must use getScopedHierarchy. It names no company, so the\n * Portal may refuse it with the master key once un-named company reads are\n * refused (subdomains#1037). null if unconfigured/unreachable.\n */\n getFullHierarchy(): Promise<OrgHierarchyData | null>;\n /** Clear one enterprise's cache, or every cache with no argument. */\n invalidate(enterpriseId?: number): void;\n /** Config + cache status, for health endpoints. */\n getStatus(): {\n configured: boolean;\n /** Whether anything is cached (any enterprise, or the full tree). */\n cached: boolean;\n /** Age of the oldest cached entry, in ms. */\n ageMs: number | null;\n /** How many enterprises have a cached hierarchy. */\n cachedEnterprises: number;\n /** Reason the last fetch failed (null if the last fetch succeeded). */\n lastError: string | null;\n };\n}\n\nconst FIFTEEN_MINUTES = 15 * 60 * 1000;\n\ntype CacheEntry = { data: OrgHierarchyData; at: number };\n\n/** The caller's enterprise if it is a positive whole number, else null. */\nfunction enterpriseIdOf(\n user: { enterpriseId?: number | null } | null\n): number | null {\n const id = user?.enterpriseId;\n return typeof id === \"number\" && Number.isSafeInteger(id) && id > 0\n ? id\n : null;\n}\n\nexport function createHierarchyService(\n config: HierarchyServiceConfig\n): HierarchyService {\n const {\n portalApiUrl,\n portalApiKey,\n cacheTtlMs = FIFTEEN_MINUTES,\n fetchImpl = fetch,\n now = Date.now,\n } = config;\n\n const configured = Boolean(portalApiUrl && portalApiKey);\n // One cache per company, holding only that company's slice, so one\n // company's hierarchy can never be served to another (#91).\n const scopedCache = new Map<number, CacheEntry>();\n let fullCache: CacheEntry | null = null;\n const isFresh = (entry: CacheEntry | null | undefined): entry is CacheEntry =>\n cacheTtlMs > 0 && !!entry && now() - entry.at < cacheTtlMs;\n // Last fetch failure reason, surfaced via getStatus() for diagnostics. A\n // configured service that returns an empty hierarchy is almost always a fetch\n // failure (wrong URL/key, unreachable Portal, non-200) — without this it was\n // swallowed to a silent null and looked like \"enterprise has no data\".\n let lastError: string | null = null;\n\n async function fetchRaw(\n enterpriseId?: number\n ): Promise<OrgHierarchyData | null> {\n if (!configured) {\n lastError = \"not configured (PORTAL_API_URL/PORTAL_API_KEY missing)\";\n return null;\n }\n const url = `${portalApiUrl.replace(/\\/$/, \"\")}/api/service/hierarchy`;\n let res: Response;\n try {\n res = await fetchImpl(url, {\n headers: {\n \"X-Service-Key\": portalApiKey,\n \"Content-Type\": \"application/json\",\n // Names the caller's company (portal#348).\n ...(enterpriseId !== undefined\n ? { \"X-Enterprise-Id\": String(enterpriseId) }\n : {}),\n },\n });\n } catch (err) {\n lastError = `fetch threw: ${err instanceof Error ? err.message : String(err)}`;\n console.error(`[HierarchyService] ${lastError} (url=${url})`);\n return null;\n }\n if (!res.ok) {\n lastError = `HTTP ${res.status} ${res.statusText} from ${url}`;\n console.error(`[HierarchyService] ${lastError}`);\n return null;\n }\n const json = await res.json().catch(() => null);\n if (!json) {\n lastError = `invalid/empty JSON body from ${url}`;\n console.error(`[HierarchyService] ${lastError}`);\n return null;\n }\n lastError = null;\n return normalize(json);\n }\n\n async function getFullHierarchy(): Promise<OrgHierarchyData | null> {\n if (isFresh(fullCache)) return fullCache.data;\n const data = await fetchRaw();\n if (data && cacheTtlMs > 0) fullCache = { data, at: now() };\n return data;\n }\n\n async function getScopedHierarchy(\n user: { enterpriseId?: number | null } | null\n ): Promise<OrgHierarchyData> {\n const enterpriseId = enterpriseIdOf(user);\n // No company: nothing to show, and nothing to ask the Portal for.\n if (enterpriseId === null) return scopeHierarchyToEnterprise(null, null);\n\n let entry = scopedCache.get(enterpriseId);\n if (!isFresh(entry)) {\n const raw = await fetchRaw(enterpriseId);\n if (!raw) return scopeHierarchyToEnterprise(null, enterpriseId);\n // Keep only this company's slice: today's Portal ignores the header on\n // /hierarchy and returns every company.\n entry = {\n data: scopeHierarchyToEnterprise(raw, enterpriseId),\n at: now(),\n };\n if (cacheTtlMs > 0) scopedCache.set(enterpriseId, entry);\n }\n // Scoped again on every read: a fresh copy per caller, and the isolation\n // rule stays the last word whatever the Portal returned.\n return scopeHierarchyToEnterprise(entry.data, enterpriseId);\n }\n\n function invalidate(enterpriseId?: number): void {\n if (enterpriseId !== undefined) {\n scopedCache.delete(enterpriseId);\n return;\n }\n scopedCache.clear();\n fullCache = null;\n }\n\n function getStatus() {\n const ats = [...scopedCache.values()].map(e => e.at);\n if (fullCache) ats.push(fullCache.at);\n return {\n configured,\n cached: ats.length > 0,\n ageMs: ats.length > 0 ? now() - Math.min(...ats) : null,\n cachedEnterprises: scopedCache.size,\n lastError,\n };\n }\n\n return { getScopedHierarchy, getFullHierarchy, invalidate, getStatus };\n}\n\n/**\n * Map the Portal Service API response to the canonical OrgHierarchyData shape.\n * Tolerant of missing arrays (defaults to []) so a partial payload never throws.\n */\nfunction normalize(data: unknown): OrgHierarchyData {\n const d = (data ?? {}) as Record<string, any[]>;\n return {\n enterprises: (d.enterprises ?? []).map(e => ({\n id: e.id,\n name: e.name,\n code: e.code,\n })),\n businessUnits: (d.businessUnits ?? []).map(bu => ({\n id: bu.id,\n name: bu.name,\n code: bu.code,\n enterpriseId: bu.enterpriseId,\n })),\n sites: (d.sites ?? []).map(s => ({\n id: s.id,\n name: s.name,\n code: s.code,\n enterpriseId: s.enterpriseId,\n businessUnitId: s.businessUnitId ?? 0,\n location: s.location,\n })),\n areas: (d.areas ?? []).map(a => ({\n id: a.id,\n name: a.name,\n code: a.code,\n siteId: a.siteId,\n })),\n assets: (d.assets ?? []).map(a => ({\n id: a.id,\n name: a.name,\n code: a.code,\n siteId: a.siteId,\n areaId: a.areaId ?? 0,\n assetType: a.assetType,\n })),\n };\n}\n"],"mappings":"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","names":[]}
@@ -395,4 +395,4 @@ function createSubdomainAuth(config) {
395
395
  export {
396
396
  createSubdomainAuth
397
397
  };
398
- //# sourceMappingURL=chunk-SBJJKJGF.js.map
398
+ //# sourceMappingURL=chunk-WUPVFJO6.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/auth/createSubdomainAuth.ts"],"sourcesContent":["/**\n * createSubdomainAuth — shared SSO auth factory for Oplytics.digital subdomains.\n *\n * Encapsulates:\n * - JWT session verification (PORTAL_JWT_SECRET first, fallback to JWT_SECRET)\n * - Cookie domain handling via COOKIE_DOMAIN env var\n * - Portal API user enrichment with X-Service-Key header\n * - Auto-create user from JWT payload if not in local DB\n * - Enterprise backfill from portal API\n * - Portal session check: a device signed out in the Portal is refused here\n * too, cached for 30s, failing open when the Portal can't answer (#77)\n *\n * Based on SQDCP's battle-tested sdk.ts + cookies.ts.\n */\n\nimport type { Request, CookieOptions } from \"express\";\nimport type {\n BaseUser,\n PortalUser,\n SessionPayload,\n SubdomainAuth,\n SubdomainAuthConfig,\n} from \"./types\";\n\n// ─── Constants ──────────────────────────────────────────────────────\n\nconst DEFAULT_COOKIE_NAME = \"app_session_id\";\n/** The Portal issues every session cookie for one year (portal#237). */\nconst PORTAL_SESSION_SECONDS = 31_536_000;\n/** Most session check answers kept at once; the oldest is dropped first. */\nconst SESSION_CHECK_CACHE_MAX = 10_000;\n\n// ─── Helpers ────────────────────────────────────────────────────────\n\nconst isNonEmptyString = (value: unknown): value is string =>\n typeof value === \"string\" && value.length > 0;\n\n/**\n * The cookie's issue time in seconds, as the Portal's session check needs it.\n * Cookies from before portal#237 have no iat; every Portal session cookie is\n * issued for one year, so exp minus a year is when it was issued. Never\n * guessed as 0: the Portal would read that as issued before any sign-out\n * cutoff and end a live session.\n */\nfunction issuedAtSeconds(iat: unknown, exp: unknown): number | null {\n const seconds =\n typeof iat === \"number\"\n ? iat\n : typeof exp === \"number\"\n ? exp - PORTAL_SESSION_SECONDS\n : null;\n if (seconds === null || !Number.isFinite(seconds) || seconds <= 0) {\n return null;\n }\n return Math.floor(seconds);\n}\n\n/** A verified cookie: the public payload, plus what the session check needs. */\ninterface VerifiedCookie {\n session: SessionPayload;\n /** The Portal session id. Absent on cookies issued before portal#237. */\n sid?: string;\n /** Issue time in seconds; null when the cookie has neither iat nor exp. */\n iat: number | null;\n}\n\n// ─── Factory ────────────────────────────────────────────────────────\n\nexport function createSubdomainAuth<TUser extends BaseUser = BaseUser>(\n config: SubdomainAuthConfig<TUser>\n): SubdomainAuth<TUser> {\n const {\n env,\n db,\n // Default to identity (no role translation). Subdomains should adopt the\n // Portal's canonical roles verbatim; a custom mapper is legacy.\n mapRole = (role: string) => role,\n userSource = \"db\",\n cookieName = DEFAULT_COOKIE_NAME,\n serviceName = \"Subdomain\",\n portalUserCacheTtlMs = 30_000,\n sessionCheck = true,\n sessionCheckCacheTtlMs = 30_000,\n sessionCheckTimeoutMs = 2_000,\n } = config;\n\n const tag = `[${serviceName} Auth]`;\n\n if (userSource === \"db\" && !db) {\n throw new Error(\n `${tag} userSource \"db\" requires a db adapter in createSubdomainAuth config`\n );\n }\n\n // Short-TTL cache of Portal users (portal mode), keyed by openId, so we don't\n // hit the Portal on every request.\n const portalUserCache = new Map<string, { user: PortalUser; at: number }>();\n\n // ── Lazy-loaded dependencies (jose, cookie, axios) ──────────────\n // We use dynamic imports so consumers don't need to install these\n // as peer deps if they only use other core-server features.\n\n let _jose: typeof import(\"jose\") | null = null;\n async function getJose() {\n if (!_jose) _jose = await import(\"jose\");\n return _jose;\n }\n\n let _cookie: typeof import(\"cookie\") | null = null;\n async function getCookie() {\n if (!_cookie) _cookie = await import(\"cookie\");\n return _cookie;\n }\n\n let _axios: typeof import(\"axios\").default | null = null;\n async function getAxios() {\n if (!_axios) {\n const mod = await import(\"axios\");\n _axios = mod.default;\n }\n return _axios;\n }\n\n // ── Portal API client ──────────────────────────────────────────\n\n let portalClient: import(\"axios\").AxiosInstance | null = null;\n\n async function getPortalClientAsync() {\n if (portalClient) return portalClient;\n if (!env.portalApiUrl) return null;\n const axios = await getAxios();\n portalClient = axios.create({\n baseURL: env.portalApiUrl,\n timeout: 10_000,\n headers: {\n \"Content-Type\": \"application/json\",\n ...(env.portalApiKey ? { \"X-Service-Key\": env.portalApiKey } : {}),\n },\n });\n return portalClient;\n }\n\n // ── JWT Secret ─────────────────────────────────────────────────\n\n function getPortalSecret() {\n const secret = env.portalJwtSecret;\n if (!secret) throw new Error(\"PORTAL_JWT_SECRET is not configured\");\n return new TextEncoder().encode(secret);\n }\n\n // ── Cookie Helpers ─────────────────────────────────────────────\n\n function getCookieDomain(): string | undefined {\n return env.cookieDomain || undefined;\n }\n\n function isSecureRequest(req: Request): boolean {\n if (req.protocol === \"https\") return true;\n const forwardedProto = req.headers[\"x-forwarded-proto\"];\n if (!forwardedProto) return false;\n const protoList = Array.isArray(forwardedProto)\n ? forwardedProto\n : forwardedProto.split(\",\");\n return protoList.some(p => p.trim().toLowerCase() === \"https\");\n }\n\n function getSessionCookieOptions(\n req: Request\n ): Pick<\n CookieOptions,\n \"domain\" | \"httpOnly\" | \"path\" | \"sameSite\" | \"secure\"\n > {\n const secure = isSecureRequest(req);\n const domain = getCookieDomain();\n return {\n domain,\n httpOnly: true,\n path: \"/\",\n sameSite: \"lax\",\n secure,\n };\n }\n\n function isValidReturnUrl(url: string): boolean {\n if (url.startsWith(\"/\") && !url.startsWith(\"//\")) return true;\n try {\n const parsed = new URL(url);\n if (parsed.protocol !== \"https:\") return false;\n return (\n parsed.hostname === \"oplyticsdigital.net\" ||\n parsed.hostname.endsWith(\".oplyticsdigital.net\")\n );\n } catch {\n return false;\n }\n }\n\n // ── Session Verification ───────────────────────────────────────\n\n /**\n * Verify session cookie against PORTAL_JWT_SECRET (cross-subdomain SSO).\n *\n * Portal is the only issuer of session cookies for subdomains using this\n * factory — the old Manus OAuth login path (which signed its own cookies\n * with a local JWT_SECRET) was removed. A cookie that fails portal\n * verification is invalid, full stop.\n */\n async function verifyCookie(\n cookieValue: string | undefined | null\n ): Promise<VerifiedCookie | null> {\n if (!cookieValue) {\n console.warn(`${tag} Missing session cookie`);\n return null;\n }\n\n const jose = await getJose();\n\n try {\n const portalKey = getPortalSecret();\n const { payload } = await jose.jwtVerify(cookieValue, portalKey, {\n algorithms: [\"HS256\"],\n });\n const { openId, appId, name, email, sid, iat, exp } = payload as Record<\n string,\n unknown\n >;\n // Identity is openId; the display name is cosmetic. The Portal signs\n // `name: user.name || \"\"`, so a user with no display name has an empty\n // one, and must not be locked out for it (#82).\n if (!isNonEmptyString(openId) || !isNonEmptyString(appId)) {\n console.warn(`${tag} Portal JWT payload missing required fields`);\n return null;\n }\n return {\n session: {\n openId,\n appId,\n name: isNonEmptyString(name)\n ? name\n : isNonEmptyString(email)\n ? email\n : \"\",\n },\n ...(isNonEmptyString(sid) ? { sid } : {}),\n iat: issuedAtSeconds(iat, exp),\n };\n } catch (error) {\n console.warn(\n `${tag} Session verification failed:`,\n (error as Error).message\n );\n return null;\n }\n }\n\n async function verifySession(\n cookieValue: string | undefined | null\n ): Promise<SessionPayload | null> {\n return (await verifyCookie(cookieValue))?.session ?? null;\n }\n\n // ── Portal API ─────────────────────────────────────────────────\n\n async function getPortalUser(openId: string): Promise<PortalUser | null> {\n try {\n const client = await getPortalClientAsync();\n if (!client) return null;\n // The Portal Service API is under /api/service (base URL is the origin,\n // e.g. https://portal.oplyticsdigital.net). Must include the /api/service\n // prefix — omitting it 404s and silently strands enterprise resolution.\n // Deliberately names no company (no X-Enterprise-Id): this is the\n // sign-in lookup that finds the user's company. The Portal must keep\n // allowing it with the master key (#91, subdomains#1037).\n const { data } = await client.get<PortalUser>(\n `/api/service/users/by-openid/${encodeURIComponent(openId)}`\n );\n return data ?? null;\n } catch (err: unknown) {\n const status = (err as any)?.response?.status;\n if (status === 401) {\n console.error(\n `${tag} Portal API key revoked (401) — enrichment disabled`\n );\n } else if (status === 404) {\n // User not found in portal — normal for first-time users\n } else {\n console.warn(\n `${tag} Portal user lookup failed:`,\n (err as Error).message\n );\n }\n return null;\n }\n }\n\n /** Portal user lookup with a short-TTL cache (portal mode). */\n async function getPortalUserCached(\n openId: string\n ): Promise<PortalUser | null> {\n if (portalUserCacheTtlMs > 0) {\n const hit = portalUserCache.get(openId);\n if (hit && Date.now() - hit.at < portalUserCacheTtlMs) {\n return hit.user;\n }\n }\n const user = await getPortalUser(openId);\n if (user && portalUserCacheTtlMs > 0) {\n portalUserCache.set(openId, { user, at: Date.now() });\n }\n return user;\n }\n\n /**\n * Build ctx.user entirely from the Portal record — no local persistence.\n * The single source of truth. ctx.user.id is the PORTAL user id. Roles and\n * enterprise come from the Portal verbatim (no mapping).\n *\n * On a genuine Portal outage (the by-openid lookup returns null) we keep the\n * user authenticated using their verified session claims, with role/enterprise\n * unresolved, rather than throwing. Throwing here would make createContext (in\n * every subdomain) swallow the error into `user = null`, which the AuthGate\n * renders as the Sign In screen — i.e. a transient Portal blip would log the\n * user out. Their session cookie is already valid; keep them in, degraded,\n * until the Portal recovers. (Enterprise-scoped procedures will 403 during the\n * outage; client array-guards keep pages from crashing.)\n */\n async function resolvePortalUser(\n openId: string,\n session: SessionPayload\n ): Promise<TUser> {\n const portalUser = await getPortalUserCached(openId);\n\n if (!portalUser) {\n console.warn(\n `${tag} Portal user unavailable for ${openId} — keeping session (role/enterprise unresolved until Portal recovers)`\n );\n return {\n id: 0,\n openId,\n name: session.name || null,\n email: null,\n role: \"\",\n enterpriseId: null,\n portalUserId: null,\n } as unknown as TUser;\n }\n\n const enterpriseId =\n portalUser.enterpriseId ?? portalUser.companyId ?? null;\n return {\n id: portalUser.id,\n openId: portalUser.openId ?? openId,\n name: portalUser.name || session.name || null,\n email: portalUser.email ?? null,\n role: portalUser.role,\n enterpriseId,\n portalUserId: portalUser.id,\n } as unknown as TUser;\n }\n\n // ── Portal Session Check ───────────────────────────────────────\n //\n // Fails open: only an explicit `{ active: false }` from the Portal ends a\n // session. The check runs on every request to every app, so failing closed\n // would turn a Portal outage, a slow deploy or a key problem into everyone\n // being signed out of everything at once (and nobody could sign back in\n // while the Portal is down). The cost: while the Portal can't answer, a\n // device signed out after its last answer keeps working here until the\n // Portal is back. A session the Portal has already called ended stays\n // ended through an outage.\n\n // Answers keyed by openId + sid + iat, because the Portal judges all three:\n // an old copy of a cookie keeps its sid but its earlier iat is cut off.\n const sessionCheckCache = new Map<string, { active: boolean; at: number }>();\n const sessionCheckInflight = new Map<string, Promise<boolean>>();\n const warned = new Set<string>();\n\n function warnOnce(key: string, message: string) {\n if (warned.has(key)) return;\n warned.add(key);\n console.warn(message);\n }\n\n function rememberSessionCheck(key: string, active: boolean) {\n // Re-insert so Map order stays oldest-first for eviction.\n sessionCheckCache.delete(key);\n sessionCheckCache.set(key, { active, at: Date.now() });\n if (sessionCheckCache.size > SESSION_CHECK_CACHE_MAX) {\n const oldest = sessionCheckCache.keys().next().value;\n if (oldest !== undefined) sessionCheckCache.delete(oldest);\n }\n }\n\n /**\n * One call to POST /api/service/sessions/check. Returns the Portal's\n * answer, or null when it gave none (unreachable, slow, error, odd reply).\n * Like the by-openid lookup it names no company: it runs before the user's\n * company is known, and answers only about the cookie (#91).\n */\n async function askPortalSessionActive(body: {\n openId: string;\n sid?: string;\n iat: number;\n }): Promise<boolean | null> {\n try {\n const client = await getPortalClientAsync();\n if (!client) return null;\n const { data } = await client.post<{ active?: unknown }>(\n \"/api/service/sessions/check\",\n body,\n {\n // axios's timeout only covers an idle socket; the signal caps the\n // whole call, so a Portal that answers slowly can't stall requests.\n timeout: sessionCheckTimeoutMs,\n signal: AbortSignal.timeout(sessionCheckTimeoutMs),\n }\n );\n if (typeof data?.active === \"boolean\") return data.active;\n console.warn(\n `${tag} Portal session check gave no answer for ${body.openId} — keeping session`\n );\n return null;\n } catch (err: unknown) {\n const status = (err as any)?.response?.status;\n if (status === 404) {\n warnOnce(\n \"check-404\",\n `${tag} Portal has no session check yet (404) — sessions signed out in the Portal are not refused until it ships`\n );\n } else if (status === 401) {\n console.error(\n `${tag} Portal API key refused (401) on session check — keeping session`\n );\n } else {\n console.warn(\n `${tag} Portal session check failed for ${body.openId} (${status ?? (err as Error).message}) — keeping session`\n );\n }\n return null;\n }\n }\n\n /** Is the session behind this cookie still live in the Portal? */\n async function isSessionActive(cookie: VerifiedCookie): Promise<boolean> {\n if (!sessionCheck) return true;\n if (!env.portalApiUrl) {\n warnOnce(\n \"no-portal-url\",\n `${tag} No Portal API URL — sessions signed out in the Portal are not refused`\n );\n return true;\n }\n const { openId } = cookie.session;\n if (cookie.iat === null) {\n warnOnce(\n \"no-iat\",\n `${tag} Session cookie for ${openId} has neither iat nor exp — not checked with the Portal`\n );\n return true;\n }\n\n const key = JSON.stringify([openId, cookie.sid ?? null, cookie.iat]);\n const hit = sessionCheckCache.get(key);\n if (hit && Date.now() - hit.at < sessionCheckCacheTtlMs) return hit.active;\n\n // Parallel requests from one page load share a single Portal call.\n let pending = sessionCheckInflight.get(key);\n if (!pending) {\n pending = askPortalSessionActive({\n openId,\n ...(cookie.sid ? { sid: cookie.sid } : {}),\n iat: cookie.iat,\n })\n .then(answer => {\n // No answer: keep an earlier \"ended\", otherwise let it through.\n // Remembered either way, so a down Portal isn't asked every request.\n const active = answer ?? hit?.active ?? true;\n rememberSessionCheck(key, active);\n return active;\n })\n .finally(() => sessionCheckInflight.delete(key));\n sessionCheckInflight.set(key, pending);\n }\n return pending;\n }\n\n // ── Authenticate Request ───────────────────────────────────────\n\n async function authenticateRequest(req: Request): Promise<TUser> {\n const cookie = await getCookie();\n const cookies = req.headers.cookie\n ? new Map(Object.entries(cookie.parseCookie(req.headers.cookie)))\n : new Map<string, string | undefined>();\n\n const sessionCookie = cookies.get(cookieName);\n const verified = await verifyCookie(sessionCookie);\n\n if (!verified) {\n throw Object.assign(new Error(\"Invalid session cookie\"), {\n statusCode: 403,\n });\n }\n\n // A device signed out in the Portal is refused here too (#77).\n if (!(await isSessionActive(verified))) {\n throw Object.assign(new Error(\"Session ended\"), {\n statusCode: 403,\n code: \"SESSION_ENDED\",\n });\n }\n\n const { session } = verified;\n\n const sessionUserId = session.openId;\n const signedInAt = new Date();\n\n // ── Portal mode: single source of truth, no local mirror ────\n if (userSource === \"portal\") {\n return await resolvePortalUser(sessionUserId, session);\n }\n\n let user = (await db!.getUserByOpenId(sessionUserId)) ?? null;\n\n // ── Auto-create user from verified JWT ──────────────────────\n if (!user) {\n try {\n // Try portal API first for richer user info\n const portalUser = await getPortalUser(sessionUserId);\n\n if (portalUser) {\n console.log(\n `${tag} Cross-subdomain SSO: creating user from portal (${portalUser.email || sessionUserId})`\n );\n // Portal returns `enterpriseId`; `companyId` is a legacy alias.\n const portalEnterpriseId =\n portalUser.enterpriseId ?? portalUser.companyId ?? null;\n await db!.upsertUser({\n openId: sessionUserId,\n name: portalUser.name || session.name || null,\n email: portalUser.email ?? null,\n loginMethod: \"portal-sso\",\n role: mapRole(portalUser.role),\n lastSignedIn: signedInAt,\n ...(portalEnterpriseId != null\n ? {\n enterpriseId: portalEnterpriseId,\n portalUserId: portalUser.id,\n }\n : {}),\n });\n user = (await db!.getUserByOpenId(sessionUserId)) ?? null;\n } else {\n // Portal unavailable — create from JWT payload\n console.log(\n `${tag} Cross-subdomain SSO: creating user from JWT payload (${sessionUserId})`\n );\n await db!.upsertUser({\n openId: sessionUserId,\n name: session.name || null,\n loginMethod: \"portal-sso\",\n lastSignedIn: signedInAt,\n });\n user = (await db!.getUserByOpenId(sessionUserId)) ?? null;\n }\n } catch (error) {\n console.error(`${tag} Failed to sync user info:`, error);\n throw Object.assign(new Error(\"Failed to sync user info\"), {\n statusCode: 403,\n });\n }\n }\n\n if (!user) {\n throw Object.assign(new Error(\"User not found\"), { statusCode: 403 });\n }\n\n // ── Enterprise backfill ─────────────────────────────────────\n if (user.enterpriseId === null) {\n try {\n const portalUser = await getPortalUser(user.openId);\n // Portal returns `enterpriseId`; `companyId` is a legacy alias.\n const portalEnterpriseId =\n portalUser?.enterpriseId ?? portalUser?.companyId ?? null;\n if (portalEnterpriseId != null) {\n console.log(\n `${tag} Backfilling enterpriseId=${portalEnterpriseId} for user ${user.id}`\n );\n await db!.updateUserFields(user.id, {\n enterpriseId: portalEnterpriseId,\n ...(portalUser?.id ? { portalUserId: portalUser.id } : {}),\n });\n user = (await db!.getUserByOpenId(user.openId)) ?? user;\n }\n } catch (backfillError) {\n console.warn(\n `${tag} Failed to backfill enterpriseId for user ${user.id}:`,\n backfillError\n );\n // Non-fatal — user can still proceed\n }\n }\n\n // Touch lastSignedIn\n await db!.upsertUser({\n openId: user.openId,\n lastSignedIn: signedInAt,\n });\n\n return user;\n }\n\n // ── Return public API ──────────────────────────────────────────\n\n return {\n verifySession,\n authenticateRequest,\n getPortalUser,\n getSessionCookieOptions,\n isValidReturnUrl,\n };\n}\n"],"mappings":"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","names":[]}
@@ -4,9 +4,11 @@ import { OrgHierarchyData } from '@pablo2410/shared-ui/hierarchy/core';
4
4
  * Hierarchy Service — the single server-side source for org-hierarchy data.
5
5
  *
6
6
  * Fetches the canonical org-hierarchy from the Portal Service API
7
- * (GET /api/service/hierarchy, authenticated with a platform-level service key),
8
- * caches it with a TTL, and scopes it to a caller's enterprise using the shared
9
- * isolation rule (scopeHierarchyToEnterprise from @pablo2410/shared-ui).
7
+ * (GET /api/service/hierarchy, authenticated with the service key), naming the
8
+ * caller's company with X-Enterprise-Id (#91, as Policy Deployment does since
9
+ * PD#313). Caches it per company with a TTL, and scopes it to that company with
10
+ * the shared isolation rule (scopeHierarchyToEnterprise from
11
+ * @pablo2410/shared-ui) on every read, as a second check.
10
12
  *
11
13
  * One fetch, one mapper, one cache, one scope policy. Every subdomain constructs
12
14
  * this once from its env and calls getScopedHierarchy(ctx.user) in its tRPC
@@ -35,8 +37,10 @@ interface HierarchyServiceConfig {
35
37
  }
36
38
  interface HierarchyService {
37
39
  /**
38
- * The org-hierarchy scoped to the caller's enterprise. A user with no resolved
39
- * enterprise (null/undefined enterpriseId) receives an empty hierarchy — see
40
+ * The org-hierarchy scoped to the caller's enterprise, fetched with
41
+ * `X-Enterprise-Id: <enterpriseId>` and cached per enterprise. A user with no
42
+ * resolved enterprise (null/undefined, or not a positive whole number)
43
+ * receives an empty hierarchy without a Portal call — see
40
44
  * scopeHierarchyToEnterprise for the policy. Never returns null.
41
45
  */
42
46
  getScopedHierarchy(user: {
@@ -44,16 +48,22 @@ interface HierarchyService {
44
48
  } | null): Promise<OrgHierarchyData>;
45
49
  /**
46
50
  * The full, unscoped hierarchy (every enterprise). For Portal/admin tooling
47
- * only — subdomains must use getScopedHierarchy. null if unconfigured/unreachable.
51
+ * only — subdomains must use getScopedHierarchy. It names no company, so the
52
+ * Portal may refuse it with the master key once un-named company reads are
53
+ * refused (subdomains#1037). null if unconfigured/unreachable.
48
54
  */
49
55
  getFullHierarchy(): Promise<OrgHierarchyData | null>;
50
- /** Clear the cache (e.g. after a portal sync). */
51
- invalidate(): void;
56
+ /** Clear one enterprise's cache, or every cache with no argument. */
57
+ invalidate(enterpriseId?: number): void;
52
58
  /** Config + cache status, for health endpoints. */
53
59
  getStatus(): {
54
60
  configured: boolean;
61
+ /** Whether anything is cached (any enterprise, or the full tree). */
55
62
  cached: boolean;
63
+ /** Age of the oldest cached entry, in ms. */
56
64
  ageMs: number | null;
65
+ /** How many enterprises have a cached hierarchy. */
66
+ cachedEnterprises: number;
57
67
  /** Reason the last fetch failed (null if the last fetch succeeded). */
58
68
  lastError: string | null;
59
69
  };
package/dist/hierarchy.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  createHierarchyService
3
- } from "./chunk-CJ6GDQX2.js";
3
+ } from "./chunk-NSTH36LS.js";
4
4
  export {
5
5
  createHierarchyService
6
6
  };
package/dist/index.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  createSubdomainAuth
3
- } from "./chunk-SBJJKJGF.js";
3
+ } from "./chunk-WUPVFJO6.js";
4
4
  import {
5
5
  createHierarchyService
6
- } from "./chunk-CJ6GDQX2.js";
6
+ } from "./chunk-NSTH36LS.js";
7
7
 
8
8
  // src/constants/sqdcp.ts
9
9
  var SQDCP_PILLARS = [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pablo2410/core-server",
3
- "version": "0.10.2",
3
+ "version": "0.10.3",
4
4
  "description": "Shared server utilities for Oplytics.digital subdomains",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/hierarchy.ts"],"sourcesContent":["/**\n * Hierarchy Service — the single server-side source for org-hierarchy data.\n *\n * Fetches the canonical org-hierarchy from the Portal Service API\n * (GET /api/service/hierarchy, authenticated with a platform-level service key),\n * caches it with a TTL, and scopes it to a caller's enterprise using the shared\n * isolation rule (scopeHierarchyToEnterprise from @pablo2410/shared-ui).\n *\n * One fetch, one mapper, one cache, one scope policy. Every subdomain constructs\n * this once from its env and calls getScopedHierarchy(ctx.user) in its tRPC\n * `hierarchy` procedure — no per-app fetch/scope/null-policy reinvention.\n *\n * Quick example:\n * const hierarchy = createHierarchyService({\n * portalApiUrl: process.env.PORTAL_API_URL ?? \"\",\n * portalApiKey: process.env.PORTAL_API_KEY ?? \"\",\n * });\n * // in a tRPC router:\n * hierarchy: protectedProcedure.query(({ ctx }) => hierarchy.getScopedHierarchy(ctx.user)),\n */\nimport type { OrgHierarchyData } from \"@pablo2410/shared-ui/hierarchy/core\";\nimport { scopeHierarchyToEnterprise } from \"@pablo2410/shared-ui/hierarchy/core\";\n\nexport interface HierarchyServiceConfig {\n /** Portal Service API base URL, e.g. https://portal.oplyticsdigital.net */\n portalApiUrl: string;\n /** Platform-level X-Service-Key for the Portal Service API. */\n portalApiKey: string;\n /** Cache TTL in milliseconds. Default 15 minutes. Set 0 to disable caching. */\n cacheTtlMs?: number;\n /** Injectable fetch implementation (for tests). Defaults to global fetch. */\n fetchImpl?: typeof fetch;\n /** Injectable clock (for tests). Defaults to Date.now. */\n now?: () => number;\n}\n\nexport interface HierarchyService {\n /**\n * The org-hierarchy scoped to the caller's enterprise. A user with no resolved\n * enterprise (null/undefined enterpriseId) receives an empty hierarchy — see\n * scopeHierarchyToEnterprise for the policy. Never returns null.\n */\n getScopedHierarchy(\n user: { enterpriseId?: number | null } | null\n ): Promise<OrgHierarchyData>;\n /**\n * The full, unscoped hierarchy (every enterprise). For Portal/admin tooling\n * only — subdomains must use getScopedHierarchy. null if unconfigured/unreachable.\n */\n getFullHierarchy(): Promise<OrgHierarchyData | null>;\n /** Clear the cache (e.g. after a portal sync). */\n invalidate(): void;\n /** Config + cache status, for health endpoints. */\n getStatus(): {\n configured: boolean;\n cached: boolean;\n ageMs: number | null;\n /** Reason the last fetch failed (null if the last fetch succeeded). */\n lastError: string | null;\n };\n}\n\nconst FIFTEEN_MINUTES = 15 * 60 * 1000;\n\nexport function createHierarchyService(\n config: HierarchyServiceConfig\n): HierarchyService {\n const {\n portalApiUrl,\n portalApiKey,\n cacheTtlMs = FIFTEEN_MINUTES,\n fetchImpl = fetch,\n now = Date.now,\n } = config;\n\n const configured = Boolean(portalApiUrl && portalApiKey);\n let cache: { data: OrgHierarchyData; at: number } | null = null;\n // Last fetch failure reason, surfaced via getStatus() for diagnostics. A\n // configured service that returns an empty hierarchy is almost always a fetch\n // failure (wrong URL/key, unreachable Portal, non-200) — without this it was\n // swallowed to a silent null and looked like \"enterprise has no data\".\n let lastError: string | null = null;\n\n async function fetchRaw(): Promise<OrgHierarchyData | null> {\n if (!configured) {\n lastError = \"not configured (PORTAL_API_URL/PORTAL_API_KEY missing)\";\n return null;\n }\n const url = `${portalApiUrl.replace(/\\/$/, \"\")}/api/service/hierarchy`;\n let res: Response;\n try {\n res = await fetchImpl(url, {\n headers: {\n \"X-Service-Key\": portalApiKey,\n \"Content-Type\": \"application/json\",\n },\n });\n } catch (err) {\n lastError = `fetch threw: ${err instanceof Error ? err.message : String(err)}`;\n console.error(`[HierarchyService] ${lastError} (url=${url})`);\n return null;\n }\n if (!res.ok) {\n lastError = `HTTP ${res.status} ${res.statusText} from ${url}`;\n console.error(`[HierarchyService] ${lastError}`);\n return null;\n }\n const json = await res.json().catch(() => null);\n if (!json) {\n lastError = `invalid/empty JSON body from ${url}`;\n console.error(`[HierarchyService] ${lastError}`);\n return null;\n }\n lastError = null;\n return normalize(json);\n }\n\n async function getFullHierarchy(): Promise<OrgHierarchyData | null> {\n if (cacheTtlMs > 0 && cache && now() - cache.at < cacheTtlMs) {\n return cache.data;\n }\n const data = await fetchRaw();\n if (data && cacheTtlMs > 0) cache = { data, at: now() };\n return data;\n }\n\n async function getScopedHierarchy(\n user: { enterpriseId?: number | null } | null\n ): Promise<OrgHierarchyData> {\n const full = await getFullHierarchy();\n return scopeHierarchyToEnterprise(full, user?.enterpriseId ?? null);\n }\n\n function invalidate(): void {\n cache = null;\n }\n\n function getStatus() {\n return {\n configured,\n cached: cache !== null,\n ageMs: cache ? now() - cache.at : null,\n lastError,\n };\n }\n\n return { getScopedHierarchy, getFullHierarchy, invalidate, getStatus };\n}\n\n/**\n * Map the Portal Service API response to the canonical OrgHierarchyData shape.\n * Tolerant of missing arrays (defaults to []) so a partial payload never throws.\n */\nfunction normalize(data: unknown): OrgHierarchyData {\n const d = (data ?? {}) as Record<string, any[]>;\n return {\n enterprises: (d.enterprises ?? []).map(e => ({\n id: e.id,\n name: e.name,\n code: e.code,\n })),\n businessUnits: (d.businessUnits ?? []).map(bu => ({\n id: bu.id,\n name: bu.name,\n code: bu.code,\n enterpriseId: bu.enterpriseId,\n })),\n sites: (d.sites ?? []).map(s => ({\n id: s.id,\n name: s.name,\n code: s.code,\n enterpriseId: s.enterpriseId,\n businessUnitId: s.businessUnitId ?? 0,\n location: s.location,\n })),\n areas: (d.areas ?? []).map(a => ({\n id: a.id,\n name: a.name,\n code: a.code,\n siteId: a.siteId,\n })),\n assets: (d.assets ?? []).map(a => ({\n id: a.id,\n name: a.name,\n code: a.code,\n siteId: a.siteId,\n areaId: a.areaId ?? 0,\n assetType: a.assetType,\n })),\n };\n}\n"],"mappings":"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","names":[]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/auth/createSubdomainAuth.ts"],"sourcesContent":["/**\n * createSubdomainAuth — shared SSO auth factory for Oplytics.digital subdomains.\n *\n * Encapsulates:\n * - JWT session verification (PORTAL_JWT_SECRET first, fallback to JWT_SECRET)\n * - Cookie domain handling via COOKIE_DOMAIN env var\n * - Portal API user enrichment with X-Service-Key header\n * - Auto-create user from JWT payload if not in local DB\n * - Enterprise backfill from portal API\n * - Portal session check: a device signed out in the Portal is refused here\n * too, cached for 30s, failing open when the Portal can't answer (#77)\n *\n * Based on SQDCP's battle-tested sdk.ts + cookies.ts.\n */\n\nimport type { Request, CookieOptions } from \"express\";\nimport type {\n BaseUser,\n PortalUser,\n SessionPayload,\n SubdomainAuth,\n SubdomainAuthConfig,\n} from \"./types\";\n\n// ─── Constants ──────────────────────────────────────────────────────\n\nconst DEFAULT_COOKIE_NAME = \"app_session_id\";\n/** The Portal issues every session cookie for one year (portal#237). */\nconst PORTAL_SESSION_SECONDS = 31_536_000;\n/** Most session check answers kept at once; the oldest is dropped first. */\nconst SESSION_CHECK_CACHE_MAX = 10_000;\n\n// ─── Helpers ────────────────────────────────────────────────────────\n\nconst isNonEmptyString = (value: unknown): value is string =>\n typeof value === \"string\" && value.length > 0;\n\n/**\n * The cookie's issue time in seconds, as the Portal's session check needs it.\n * Cookies from before portal#237 have no iat; every Portal session cookie is\n * issued for one year, so exp minus a year is when it was issued. Never\n * guessed as 0: the Portal would read that as issued before any sign-out\n * cutoff and end a live session.\n */\nfunction issuedAtSeconds(iat: unknown, exp: unknown): number | null {\n const seconds =\n typeof iat === \"number\"\n ? iat\n : typeof exp === \"number\"\n ? exp - PORTAL_SESSION_SECONDS\n : null;\n if (seconds === null || !Number.isFinite(seconds) || seconds <= 0) {\n return null;\n }\n return Math.floor(seconds);\n}\n\n/** A verified cookie: the public payload, plus what the session check needs. */\ninterface VerifiedCookie {\n session: SessionPayload;\n /** The Portal session id. Absent on cookies issued before portal#237. */\n sid?: string;\n /** Issue time in seconds; null when the cookie has neither iat nor exp. */\n iat: number | null;\n}\n\n// ─── Factory ────────────────────────────────────────────────────────\n\nexport function createSubdomainAuth<TUser extends BaseUser = BaseUser>(\n config: SubdomainAuthConfig<TUser>\n): SubdomainAuth<TUser> {\n const {\n env,\n db,\n // Default to identity (no role translation). Subdomains should adopt the\n // Portal's canonical roles verbatim; a custom mapper is legacy.\n mapRole = (role: string) => role,\n userSource = \"db\",\n cookieName = DEFAULT_COOKIE_NAME,\n serviceName = \"Subdomain\",\n portalUserCacheTtlMs = 30_000,\n sessionCheck = true,\n sessionCheckCacheTtlMs = 30_000,\n sessionCheckTimeoutMs = 2_000,\n } = config;\n\n const tag = `[${serviceName} Auth]`;\n\n if (userSource === \"db\" && !db) {\n throw new Error(\n `${tag} userSource \"db\" requires a db adapter in createSubdomainAuth config`\n );\n }\n\n // Short-TTL cache of Portal users (portal mode), keyed by openId, so we don't\n // hit the Portal on every request.\n const portalUserCache = new Map<string, { user: PortalUser; at: number }>();\n\n // ── Lazy-loaded dependencies (jose, cookie, axios) ──────────────\n // We use dynamic imports so consumers don't need to install these\n // as peer deps if they only use other core-server features.\n\n let _jose: typeof import(\"jose\") | null = null;\n async function getJose() {\n if (!_jose) _jose = await import(\"jose\");\n return _jose;\n }\n\n let _cookie: typeof import(\"cookie\") | null = null;\n async function getCookie() {\n if (!_cookie) _cookie = await import(\"cookie\");\n return _cookie;\n }\n\n let _axios: typeof import(\"axios\").default | null = null;\n async function getAxios() {\n if (!_axios) {\n const mod = await import(\"axios\");\n _axios = mod.default;\n }\n return _axios;\n }\n\n // ── Portal API client ──────────────────────────────────────────\n\n let portalClient: import(\"axios\").AxiosInstance | null = null;\n\n async function getPortalClientAsync() {\n if (portalClient) return portalClient;\n if (!env.portalApiUrl) return null;\n const axios = await getAxios();\n portalClient = axios.create({\n baseURL: env.portalApiUrl,\n timeout: 10_000,\n headers: {\n \"Content-Type\": \"application/json\",\n ...(env.portalApiKey ? { \"X-Service-Key\": env.portalApiKey } : {}),\n },\n });\n return portalClient;\n }\n\n // ── JWT Secret ─────────────────────────────────────────────────\n\n function getPortalSecret() {\n const secret = env.portalJwtSecret;\n if (!secret) throw new Error(\"PORTAL_JWT_SECRET is not configured\");\n return new TextEncoder().encode(secret);\n }\n\n // ── Cookie Helpers ─────────────────────────────────────────────\n\n function getCookieDomain(): string | undefined {\n return env.cookieDomain || undefined;\n }\n\n function isSecureRequest(req: Request): boolean {\n if (req.protocol === \"https\") return true;\n const forwardedProto = req.headers[\"x-forwarded-proto\"];\n if (!forwardedProto) return false;\n const protoList = Array.isArray(forwardedProto)\n ? forwardedProto\n : forwardedProto.split(\",\");\n return protoList.some(p => p.trim().toLowerCase() === \"https\");\n }\n\n function getSessionCookieOptions(\n req: Request\n ): Pick<\n CookieOptions,\n \"domain\" | \"httpOnly\" | \"path\" | \"sameSite\" | \"secure\"\n > {\n const secure = isSecureRequest(req);\n const domain = getCookieDomain();\n return {\n domain,\n httpOnly: true,\n path: \"/\",\n sameSite: \"lax\",\n secure,\n };\n }\n\n function isValidReturnUrl(url: string): boolean {\n if (url.startsWith(\"/\") && !url.startsWith(\"//\")) return true;\n try {\n const parsed = new URL(url);\n if (parsed.protocol !== \"https:\") return false;\n return (\n parsed.hostname === \"oplyticsdigital.net\" ||\n parsed.hostname.endsWith(\".oplyticsdigital.net\")\n );\n } catch {\n return false;\n }\n }\n\n // ── Session Verification ───────────────────────────────────────\n\n /**\n * Verify session cookie against PORTAL_JWT_SECRET (cross-subdomain SSO).\n *\n * Portal is the only issuer of session cookies for subdomains using this\n * factory — the old Manus OAuth login path (which signed its own cookies\n * with a local JWT_SECRET) was removed. A cookie that fails portal\n * verification is invalid, full stop.\n */\n async function verifyCookie(\n cookieValue: string | undefined | null\n ): Promise<VerifiedCookie | null> {\n if (!cookieValue) {\n console.warn(`${tag} Missing session cookie`);\n return null;\n }\n\n const jose = await getJose();\n\n try {\n const portalKey = getPortalSecret();\n const { payload } = await jose.jwtVerify(cookieValue, portalKey, {\n algorithms: [\"HS256\"],\n });\n const { openId, appId, name, email, sid, iat, exp } = payload as Record<\n string,\n unknown\n >;\n // Identity is openId; the display name is cosmetic. The Portal signs\n // `name: user.name || \"\"`, so a user with no display name has an empty\n // one, and must not be locked out for it (#82).\n if (!isNonEmptyString(openId) || !isNonEmptyString(appId)) {\n console.warn(`${tag} Portal JWT payload missing required fields`);\n return null;\n }\n return {\n session: {\n openId,\n appId,\n name: isNonEmptyString(name)\n ? name\n : isNonEmptyString(email)\n ? email\n : \"\",\n },\n ...(isNonEmptyString(sid) ? { sid } : {}),\n iat: issuedAtSeconds(iat, exp),\n };\n } catch (error) {\n console.warn(\n `${tag} Session verification failed:`,\n (error as Error).message\n );\n return null;\n }\n }\n\n async function verifySession(\n cookieValue: string | undefined | null\n ): Promise<SessionPayload | null> {\n return (await verifyCookie(cookieValue))?.session ?? null;\n }\n\n // ── Portal API ─────────────────────────────────────────────────\n\n async function getPortalUser(openId: string): Promise<PortalUser | null> {\n try {\n const client = await getPortalClientAsync();\n if (!client) return null;\n // The Portal Service API is under /api/service (base URL is the origin,\n // e.g. https://portal.oplyticsdigital.net). Must include the /api/service\n // prefix — omitting it 404s and silently strands enterprise resolution.\n const { data } = await client.get<PortalUser>(\n `/api/service/users/by-openid/${encodeURIComponent(openId)}`\n );\n return data ?? null;\n } catch (err: unknown) {\n const status = (err as any)?.response?.status;\n if (status === 401) {\n console.error(\n `${tag} Portal API key revoked (401) — enrichment disabled`\n );\n } else if (status === 404) {\n // User not found in portal — normal for first-time users\n } else {\n console.warn(\n `${tag} Portal user lookup failed:`,\n (err as Error).message\n );\n }\n return null;\n }\n }\n\n /** Portal user lookup with a short-TTL cache (portal mode). */\n async function getPortalUserCached(\n openId: string\n ): Promise<PortalUser | null> {\n if (portalUserCacheTtlMs > 0) {\n const hit = portalUserCache.get(openId);\n if (hit && Date.now() - hit.at < portalUserCacheTtlMs) {\n return hit.user;\n }\n }\n const user = await getPortalUser(openId);\n if (user && portalUserCacheTtlMs > 0) {\n portalUserCache.set(openId, { user, at: Date.now() });\n }\n return user;\n }\n\n /**\n * Build ctx.user entirely from the Portal record — no local persistence.\n * The single source of truth. ctx.user.id is the PORTAL user id. Roles and\n * enterprise come from the Portal verbatim (no mapping).\n *\n * On a genuine Portal outage (the by-openid lookup returns null) we keep the\n * user authenticated using their verified session claims, with role/enterprise\n * unresolved, rather than throwing. Throwing here would make createContext (in\n * every subdomain) swallow the error into `user = null`, which the AuthGate\n * renders as the Sign In screen — i.e. a transient Portal blip would log the\n * user out. Their session cookie is already valid; keep them in, degraded,\n * until the Portal recovers. (Enterprise-scoped procedures will 403 during the\n * outage; client array-guards keep pages from crashing.)\n */\n async function resolvePortalUser(\n openId: string,\n session: SessionPayload\n ): Promise<TUser> {\n const portalUser = await getPortalUserCached(openId);\n\n if (!portalUser) {\n console.warn(\n `${tag} Portal user unavailable for ${openId} — keeping session (role/enterprise unresolved until Portal recovers)`\n );\n return {\n id: 0,\n openId,\n name: session.name || null,\n email: null,\n role: \"\",\n enterpriseId: null,\n portalUserId: null,\n } as unknown as TUser;\n }\n\n const enterpriseId =\n portalUser.enterpriseId ?? portalUser.companyId ?? null;\n return {\n id: portalUser.id,\n openId: portalUser.openId ?? openId,\n name: portalUser.name || session.name || null,\n email: portalUser.email ?? null,\n role: portalUser.role,\n enterpriseId,\n portalUserId: portalUser.id,\n } as unknown as TUser;\n }\n\n // ── Portal Session Check ───────────────────────────────────────\n //\n // Fails open: only an explicit `{ active: false }` from the Portal ends a\n // session. The check runs on every request to every app, so failing closed\n // would turn a Portal outage, a slow deploy or a key problem into everyone\n // being signed out of everything at once (and nobody could sign back in\n // while the Portal is down). The cost: while the Portal can't answer, a\n // device signed out after its last answer keeps working here until the\n // Portal is back. A session the Portal has already called ended stays\n // ended through an outage.\n\n // Answers keyed by openId + sid + iat, because the Portal judges all three:\n // an old copy of a cookie keeps its sid but its earlier iat is cut off.\n const sessionCheckCache = new Map<string, { active: boolean; at: number }>();\n const sessionCheckInflight = new Map<string, Promise<boolean>>();\n const warned = new Set<string>();\n\n function warnOnce(key: string, message: string) {\n if (warned.has(key)) return;\n warned.add(key);\n console.warn(message);\n }\n\n function rememberSessionCheck(key: string, active: boolean) {\n // Re-insert so Map order stays oldest-first for eviction.\n sessionCheckCache.delete(key);\n sessionCheckCache.set(key, { active, at: Date.now() });\n if (sessionCheckCache.size > SESSION_CHECK_CACHE_MAX) {\n const oldest = sessionCheckCache.keys().next().value;\n if (oldest !== undefined) sessionCheckCache.delete(oldest);\n }\n }\n\n /**\n * One call to POST /api/service/sessions/check. Returns the Portal's\n * answer, or null when it gave none (unreachable, slow, error, odd reply).\n */\n async function askPortalSessionActive(body: {\n openId: string;\n sid?: string;\n iat: number;\n }): Promise<boolean | null> {\n try {\n const client = await getPortalClientAsync();\n if (!client) return null;\n const { data } = await client.post<{ active?: unknown }>(\n \"/api/service/sessions/check\",\n body,\n {\n // axios's timeout only covers an idle socket; the signal caps the\n // whole call, so a Portal that answers slowly can't stall requests.\n timeout: sessionCheckTimeoutMs,\n signal: AbortSignal.timeout(sessionCheckTimeoutMs),\n }\n );\n if (typeof data?.active === \"boolean\") return data.active;\n console.warn(\n `${tag} Portal session check gave no answer for ${body.openId} — keeping session`\n );\n return null;\n } catch (err: unknown) {\n const status = (err as any)?.response?.status;\n if (status === 404) {\n warnOnce(\n \"check-404\",\n `${tag} Portal has no session check yet (404) — sessions signed out in the Portal are not refused until it ships`\n );\n } else if (status === 401) {\n console.error(\n `${tag} Portal API key refused (401) on session check — keeping session`\n );\n } else {\n console.warn(\n `${tag} Portal session check failed for ${body.openId} (${status ?? (err as Error).message}) — keeping session`\n );\n }\n return null;\n }\n }\n\n /** Is the session behind this cookie still live in the Portal? */\n async function isSessionActive(cookie: VerifiedCookie): Promise<boolean> {\n if (!sessionCheck) return true;\n if (!env.portalApiUrl) {\n warnOnce(\n \"no-portal-url\",\n `${tag} No Portal API URL — sessions signed out in the Portal are not refused`\n );\n return true;\n }\n const { openId } = cookie.session;\n if (cookie.iat === null) {\n warnOnce(\n \"no-iat\",\n `${tag} Session cookie for ${openId} has neither iat nor exp — not checked with the Portal`\n );\n return true;\n }\n\n const key = JSON.stringify([openId, cookie.sid ?? null, cookie.iat]);\n const hit = sessionCheckCache.get(key);\n if (hit && Date.now() - hit.at < sessionCheckCacheTtlMs) return hit.active;\n\n // Parallel requests from one page load share a single Portal call.\n let pending = sessionCheckInflight.get(key);\n if (!pending) {\n pending = askPortalSessionActive({\n openId,\n ...(cookie.sid ? { sid: cookie.sid } : {}),\n iat: cookie.iat,\n })\n .then(answer => {\n // No answer: keep an earlier \"ended\", otherwise let it through.\n // Remembered either way, so a down Portal isn't asked every request.\n const active = answer ?? hit?.active ?? true;\n rememberSessionCheck(key, active);\n return active;\n })\n .finally(() => sessionCheckInflight.delete(key));\n sessionCheckInflight.set(key, pending);\n }\n return pending;\n }\n\n // ── Authenticate Request ───────────────────────────────────────\n\n async function authenticateRequest(req: Request): Promise<TUser> {\n const cookie = await getCookie();\n const cookies = req.headers.cookie\n ? new Map(Object.entries(cookie.parseCookie(req.headers.cookie)))\n : new Map<string, string | undefined>();\n\n const sessionCookie = cookies.get(cookieName);\n const verified = await verifyCookie(sessionCookie);\n\n if (!verified) {\n throw Object.assign(new Error(\"Invalid session cookie\"), {\n statusCode: 403,\n });\n }\n\n // A device signed out in the Portal is refused here too (#77).\n if (!(await isSessionActive(verified))) {\n throw Object.assign(new Error(\"Session ended\"), {\n statusCode: 403,\n code: \"SESSION_ENDED\",\n });\n }\n\n const { session } = verified;\n\n const sessionUserId = session.openId;\n const signedInAt = new Date();\n\n // ── Portal mode: single source of truth, no local mirror ────\n if (userSource === \"portal\") {\n return await resolvePortalUser(sessionUserId, session);\n }\n\n let user = (await db!.getUserByOpenId(sessionUserId)) ?? null;\n\n // ── Auto-create user from verified JWT ──────────────────────\n if (!user) {\n try {\n // Try portal API first for richer user info\n const portalUser = await getPortalUser(sessionUserId);\n\n if (portalUser) {\n console.log(\n `${tag} Cross-subdomain SSO: creating user from portal (${portalUser.email || sessionUserId})`\n );\n // Portal returns `enterpriseId`; `companyId` is a legacy alias.\n const portalEnterpriseId =\n portalUser.enterpriseId ?? portalUser.companyId ?? null;\n await db!.upsertUser({\n openId: sessionUserId,\n name: portalUser.name || session.name || null,\n email: portalUser.email ?? null,\n loginMethod: \"portal-sso\",\n role: mapRole(portalUser.role),\n lastSignedIn: signedInAt,\n ...(portalEnterpriseId != null\n ? {\n enterpriseId: portalEnterpriseId,\n portalUserId: portalUser.id,\n }\n : {}),\n });\n user = (await db!.getUserByOpenId(sessionUserId)) ?? null;\n } else {\n // Portal unavailable — create from JWT payload\n console.log(\n `${tag} Cross-subdomain SSO: creating user from JWT payload (${sessionUserId})`\n );\n await db!.upsertUser({\n openId: sessionUserId,\n name: session.name || null,\n loginMethod: \"portal-sso\",\n lastSignedIn: signedInAt,\n });\n user = (await db!.getUserByOpenId(sessionUserId)) ?? null;\n }\n } catch (error) {\n console.error(`${tag} Failed to sync user info:`, error);\n throw Object.assign(new Error(\"Failed to sync user info\"), {\n statusCode: 403,\n });\n }\n }\n\n if (!user) {\n throw Object.assign(new Error(\"User not found\"), { statusCode: 403 });\n }\n\n // ── Enterprise backfill ─────────────────────────────────────\n if (user.enterpriseId === null) {\n try {\n const portalUser = await getPortalUser(user.openId);\n // Portal returns `enterpriseId`; `companyId` is a legacy alias.\n const portalEnterpriseId =\n portalUser?.enterpriseId ?? portalUser?.companyId ?? null;\n if (portalEnterpriseId != null) {\n console.log(\n `${tag} Backfilling enterpriseId=${portalEnterpriseId} for user ${user.id}`\n );\n await db!.updateUserFields(user.id, {\n enterpriseId: portalEnterpriseId,\n ...(portalUser?.id ? { portalUserId: portalUser.id } : {}),\n });\n user = (await db!.getUserByOpenId(user.openId)) ?? user;\n }\n } catch (backfillError) {\n console.warn(\n `${tag} Failed to backfill enterpriseId for user ${user.id}:`,\n backfillError\n );\n // Non-fatal — user can still proceed\n }\n }\n\n // Touch lastSignedIn\n await db!.upsertUser({\n openId: user.openId,\n lastSignedIn: signedInAt,\n });\n\n return user;\n }\n\n // ── Return public API ──────────────────────────────────────────\n\n return {\n verifySession,\n authenticateRequest,\n getPortalUser,\n getSessionCookieOptions,\n isValidReturnUrl,\n };\n}\n"],"mappings":"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","names":[]}