@pablo2410/core-server 0.15.1 → 0.16.0

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.d.ts CHANGED
@@ -174,6 +174,15 @@ interface SubdomainAuth<TUser extends BaseUser = BaseUser> {
174
174
  * Portal throws a 403 with `code: "SESSION_ENDED"`.
175
175
  */
176
176
  authenticateRequest(req: Request): Promise<TUser>;
177
+ /**
178
+ * Sign-out (#163): end the Portal session behind this request's cookie, so
179
+ * the device is signed out of the Portal and every app, not just this one.
180
+ * Call it from `auth.logout` before clearing the cookie. Never throws, and
181
+ * waits at most `sessionCheckTimeoutMs`. This app refuses the cookie from
182
+ * then on. Resolves to whether the Portal ended a live session (false when
183
+ * there was none to end), or null when the Portal gave no answer.
184
+ */
185
+ endPortalSession(req: Request): Promise<boolean | null>;
177
186
  /** Fetch a user from the portal API by openId. */
178
187
  getPortalUser(openId: string): Promise<PortalUser | null>;
179
188
  /** Get cookie options for setting session cookies. */
package/dist/auth.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  createSubdomainAuth
3
- } from "./chunk-RSIH3FKC.js";
3
+ } from "./chunk-URYEVWI6.js";
4
4
  export {
5
5
  createSubdomainAuth
6
6
  };
@@ -336,6 +336,70 @@ function createSubdomainAuth(config) {
336
336
  }
337
337
  return pending;
338
338
  }
339
+ function headerValue(req, name, max) {
340
+ const raw = req.headers[name];
341
+ const value = (Array.isArray(raw) ? raw[0] : raw)?.trim();
342
+ return value ? value.slice(0, max) : void 0;
343
+ }
344
+ async function endPortalSession(req) {
345
+ let openId;
346
+ try {
347
+ const cookie = await getCookie();
348
+ const value = req.headers.cookie ? cookie.parseCookie(req.headers.cookie)[cookieName] : void 0;
349
+ if (!value) return false;
350
+ const verified = await verifyCookie(value);
351
+ if (!verified?.sid) return false;
352
+ openId = verified.session.openId;
353
+ if (verified.iat !== null) {
354
+ rememberSessionCheck(
355
+ JSON.stringify([openId, verified.sid, verified.iat]),
356
+ false
357
+ );
358
+ }
359
+ if (!env2.portalApiUrl) {
360
+ warnOnce(
361
+ "no-portal-url-end",
362
+ `${tag} No Portal API URL \u2014 signing out here leaves the Portal session live`
363
+ );
364
+ return null;
365
+ }
366
+ const ip = headerValue(req, "cf-connecting-ip", 45) ?? (req.ip ? String(req.ip).slice(0, 45) : void 0);
367
+ const userAgent = headerValue(req, "user-agent", 255);
368
+ const client = await getPortalClientAsync();
369
+ if (!client) return null;
370
+ const { data } = await client.post(
371
+ "/api/service/sessions/end",
372
+ {
373
+ cookie: value,
374
+ app: serviceName.slice(0, 64),
375
+ ...ip ? { ip } : {},
376
+ ...userAgent ? { userAgent } : {}
377
+ },
378
+ {
379
+ timeout: sessionCheckTimeoutMs,
380
+ signal: AbortSignal.timeout(sessionCheckTimeoutMs)
381
+ }
382
+ );
383
+ if (typeof data?.ended === "boolean") return data.ended;
384
+ console.warn(
385
+ `${tag} Portal gave no answer to ending the session for ${openId}`
386
+ );
387
+ return null;
388
+ } catch (err) {
389
+ const status = err?.response?.status;
390
+ if (status === 404) {
391
+ warnOnce(
392
+ "end-404",
393
+ `${tag} Portal can't end sessions yet (404) \u2014 signing out here leaves the Portal session live until it ships`
394
+ );
395
+ } else {
396
+ console.warn(
397
+ `${tag} Portal could not end the session for ${openId ?? "a user"} (${status ?? err.message}) \u2014 signed out here only`
398
+ );
399
+ }
400
+ return null;
401
+ }
402
+ }
339
403
  async function authenticateRequest(req) {
340
404
  const cookie = await getCookie();
341
405
  const cookies = req.headers.cookie ? new Map(Object.entries(cookie.parseCookie(req.headers.cookie))) : /* @__PURE__ */ new Map();
@@ -434,6 +498,7 @@ function createSubdomainAuth(config) {
434
498
  return {
435
499
  verifySession,
436
500
  authenticateRequest,
501
+ endPortalSession,
437
502
  getPortalUser,
438
503
  getSessionCookieOptions,
439
504
  isValidReturnUrl
@@ -448,4 +513,4 @@ export {
448
513
  resolveMarketingOrigin,
449
514
  createSubdomainAuth
450
515
  };
451
- //# sourceMappingURL=chunk-RSIH3FKC.js.map
516
+ //# sourceMappingURL=chunk-URYEVWI6.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/sandbox.ts","../src/auth/createSubdomainAuth.ts"],"sourcesContent":["/**\n * Production and local-sandbox addresses, and how to tell the two apart\n * (#86, part of oplytics-portal#261: no production links inside the sandbox).\n *\n * Same rule as Portal (oplytics-portal#373) and Policy Deployment (PD#329):\n * the sandbox is INFISICAL_ENV=staging; production is INFISICAL_ENV=prod.\n * NODE_ENV is \"production\" in both, so it can't tell them apart. Production\n * addresses are the defaults and never change.\n *\n * The sandbox ports mirror the sandbox compose file (portal 3000, apps\n * 3001-3006, marketing-site 3007) and shared-ui's SANDBOX_LOCALHOST_PORTS.\n */\n\nexport const PRODUCTION_PORTAL_ORIGIN = \"https://portal.oplyticsdigital.net\";\nexport const PRODUCTION_MARKETING_ORIGIN = \"https://oplytics.digital\";\nexport const SANDBOX_PORTAL_ORIGIN = \"http://localhost:3000\";\nexport const SANDBOX_MARKETING_ORIGIN = \"http://localhost:3007\";\n\nconst env = (name: string): string | undefined =>\n typeof process === \"undefined\" ? undefined : process.env[name];\n\n/** True when this process is the local sandbox (INFISICAL_ENV=staging). */\nexport function isSandboxEnv(\n infisicalEnv: string | undefined = env(\"INFISICAL_ENV\")\n): boolean {\n return infisicalEnv === \"staging\";\n}\n\n/**\n * A sandbox app's own address: plain http on localhost or 127.0.0.1, ports\n * 3000-3009 (the rule Portal's cors.ts and returnUrl.ts use).\n */\nexport function isLocalSandboxUrl(url: URL): boolean {\n return (\n url.protocol === \"http:\" &&\n (url.hostname === \"localhost\" || url.hostname === \"127.0.0.1\") &&\n /^300\\d$/.test(url.port)\n );\n}\n\n/** The origin of a configured URL, or null if it is unset or not a URL. */\nfunction originOf(value: string | undefined): string | null {\n const trimmed = value?.trim();\n if (!trimmed) return null;\n try {\n return new URL(trimmed).origin;\n } catch {\n return null;\n }\n}\n\n/**\n * The Portal origin the browser talks to in the sandbox: the app's own\n * PORTAL_LOGIN_URL (set by the tunnel variant, e.g.\n * https://sandbox-portal.oplyticsdigital.net), else the local sandbox Portal.\n */\nexport function sandboxPortalOrigin(\n portalLoginUrl: string | undefined = env(\"PORTAL_LOGIN_URL\")\n): string {\n return originOf(portalLoginUrl) ?? SANDBOX_PORTAL_ORIGIN;\n}\n\n/**\n * The marketing site's origin, for links Opi cites. As Portal's\n * resolveMarketingOrigin: an explicit MARKETING_ORIGIN wins; otherwise the\n * sandbox's marketing port when running as the sandbox, else production.\n */\nexport function resolveMarketingOrigin(\n configured: string | undefined = env(\"MARKETING_ORIGIN\"),\n infisicalEnv: string | undefined = env(\"INFISICAL_ENV\")\n): string {\n return (\n originOf(configured) ??\n (isSandboxEnv(infisicalEnv)\n ? SANDBOX_MARKETING_ORIGIN\n : PRODUCTION_MARKETING_ORIGIN)\n );\n}\n","/**\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\";\nimport { isLocalSandboxUrl, isSandboxEnv } from \"../sandbox\";\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/** Most Portal users cached at once (each can hold a picture); the oldest is dropped first. */\nconst PORTAL_USER_CACHE_MAX = 1_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 // The local sandbox (INFISICAL_ENV=staging) also accepts its own\n // http://localhost:300x addresses as a returnTo (#86).\n const sandbox = isSandboxEnv();\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 (sandbox && isLocalSandboxUrl(parsed)) return true;\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 // ops#246: who the Portal last said is read-only. Used only when the Portal\n // cannot be reached, so a Portal blip never lifts read-only from a login we\n // already know (the live check). Someone never seen stays fail-open.\n const knownReadOnly = new Set<string>();\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 if (data) {\n if (data.readOnly === true) knownReadOnly.add(openId);\n else knownReadOnly.delete(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 // Re-insert so Map order stays oldest-first for eviction.\n portalUserCache.delete(openId);\n portalUserCache.set(openId, { user, at: Date.now() });\n if (portalUserCache.size > PORTAL_USER_CACHE_MAX) {\n const oldest = portalUserCache.keys().next().value;\n if (oldest !== undefined) portalUserCache.delete(oldest);\n }\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 // A known read-only login stays read-only through a Portal outage.\n ...(knownReadOnly.has(openId) ? { readOnly: true } : {}),\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 avatarUrl: portalUser.avatarUrl ?? null,\n // Only set for a read-only account (ops#246); the apps refuse its writes.\n ...(portalUser.readOnly === true ? { readOnly: true } : {}),\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 // A read-only account (ops#246) is marked on the user the apps see. The\n // Portal answer is cached like portal mode's; an unreachable Portal leaves\n // the user as it is (the same fail-open as the session check).\n const portalSays = await getPortalUserCached(user.openId);\n const readOnly = portalSays\n ? portalSays.readOnly === true\n : knownReadOnly.has(user.openId);\n return readOnly ? { ...user, readOnly: true } : 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":["env"]}
1
+ {"version":3,"sources":["../src/sandbox.ts","../src/auth/createSubdomainAuth.ts"],"sourcesContent":["/**\n * Production and local-sandbox addresses, and how to tell the two apart\n * (#86, part of oplytics-portal#261: no production links inside the sandbox).\n *\n * Same rule as Portal (oplytics-portal#373) and Policy Deployment (PD#329):\n * the sandbox is INFISICAL_ENV=staging; production is INFISICAL_ENV=prod.\n * NODE_ENV is \"production\" in both, so it can't tell them apart. Production\n * addresses are the defaults and never change.\n *\n * The sandbox ports mirror the sandbox compose file (portal 3000, apps\n * 3001-3006, marketing-site 3007) and shared-ui's SANDBOX_LOCALHOST_PORTS.\n */\n\nexport const PRODUCTION_PORTAL_ORIGIN = \"https://portal.oplyticsdigital.net\";\nexport const PRODUCTION_MARKETING_ORIGIN = \"https://oplytics.digital\";\nexport const SANDBOX_PORTAL_ORIGIN = \"http://localhost:3000\";\nexport const SANDBOX_MARKETING_ORIGIN = \"http://localhost:3007\";\n\nconst env = (name: string): string | undefined =>\n typeof process === \"undefined\" ? undefined : process.env[name];\n\n/** True when this process is the local sandbox (INFISICAL_ENV=staging). */\nexport function isSandboxEnv(\n infisicalEnv: string | undefined = env(\"INFISICAL_ENV\")\n): boolean {\n return infisicalEnv === \"staging\";\n}\n\n/**\n * A sandbox app's own address: plain http on localhost or 127.0.0.1, ports\n * 3000-3009 (the rule Portal's cors.ts and returnUrl.ts use).\n */\nexport function isLocalSandboxUrl(url: URL): boolean {\n return (\n url.protocol === \"http:\" &&\n (url.hostname === \"localhost\" || url.hostname === \"127.0.0.1\") &&\n /^300\\d$/.test(url.port)\n );\n}\n\n/** The origin of a configured URL, or null if it is unset or not a URL. */\nfunction originOf(value: string | undefined): string | null {\n const trimmed = value?.trim();\n if (!trimmed) return null;\n try {\n return new URL(trimmed).origin;\n } catch {\n return null;\n }\n}\n\n/**\n * The Portal origin the browser talks to in the sandbox: the app's own\n * PORTAL_LOGIN_URL (set by the tunnel variant, e.g.\n * https://sandbox-portal.oplyticsdigital.net), else the local sandbox Portal.\n */\nexport function sandboxPortalOrigin(\n portalLoginUrl: string | undefined = env(\"PORTAL_LOGIN_URL\")\n): string {\n return originOf(portalLoginUrl) ?? SANDBOX_PORTAL_ORIGIN;\n}\n\n/**\n * The marketing site's origin, for links Opi cites. As Portal's\n * resolveMarketingOrigin: an explicit MARKETING_ORIGIN wins; otherwise the\n * sandbox's marketing port when running as the sandbox, else production.\n */\nexport function resolveMarketingOrigin(\n configured: string | undefined = env(\"MARKETING_ORIGIN\"),\n infisicalEnv: string | undefined = env(\"INFISICAL_ENV\")\n): string {\n return (\n originOf(configured) ??\n (isSandboxEnv(infisicalEnv)\n ? SANDBOX_MARKETING_ORIGIN\n : PRODUCTION_MARKETING_ORIGIN)\n );\n}\n","/**\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\";\nimport { isLocalSandboxUrl, isSandboxEnv } from \"../sandbox\";\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/** Most Portal users cached at once (each can hold a picture); the oldest is dropped first. */\nconst PORTAL_USER_CACHE_MAX = 1_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 // The local sandbox (INFISICAL_ENV=staging) also accepts its own\n // http://localhost:300x addresses as a returnTo (#86).\n const sandbox = isSandboxEnv();\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 (sandbox && isLocalSandboxUrl(parsed)) return true;\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 // ops#246: who the Portal last said is read-only. Used only when the Portal\n // cannot be reached, so a Portal blip never lifts read-only from a login we\n // already know (the live check). Someone never seen stays fail-open.\n const knownReadOnly = new Set<string>();\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 if (data) {\n if (data.readOnly === true) knownReadOnly.add(openId);\n else knownReadOnly.delete(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 // Re-insert so Map order stays oldest-first for eviction.\n portalUserCache.delete(openId);\n portalUserCache.set(openId, { user, at: Date.now() });\n if (portalUserCache.size > PORTAL_USER_CACHE_MAX) {\n const oldest = portalUserCache.keys().next().value;\n if (oldest !== undefined) portalUserCache.delete(oldest);\n }\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 // A known read-only login stays read-only through a Portal outage.\n ...(knownReadOnly.has(openId) ? { readOnly: true } : {}),\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 avatarUrl: portalUser.avatarUrl ?? null,\n // Only set for a read-only account (ops#246); the apps refuse its writes.\n ...(portalUser.readOnly === true ? { readOnly: true } : {}),\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 // ── Sign-out ───────────────────────────────────────────────────\n\n /** The first non-empty value of a header, cut to the Portal's limit. */\n function headerValue(req: Request, name: string, max: number) {\n const raw = req.headers[name];\n const value = (Array.isArray(raw) ? raw[0] : raw)?.trim();\n return value ? value.slice(0, max) : undefined;\n }\n\n /**\n * Ends the Portal session behind this request's cookie (#163), through the\n * Portal's own endSession: POST /api/service/sessions/end with the cookie\n * itself, which the Portal verifies. Signing out of this app then signs the\n * device out of the Portal and every other app, not just this one. Call it\n * from auth.logout before clearing the cookie.\n *\n * Best-effort: it never throws, and gives up after sessionCheckTimeoutMs,\n * so a slow or missing Portal never holds up a sign-out. Whatever the\n * Portal answers, this app refuses the cookie from now on. Returns whether\n * the Portal ended a live session, or null when it gave no answer.\n */\n async function endPortalSession(req: Request): Promise<boolean | null> {\n let openId: string | undefined;\n try {\n const cookie = await getCookie();\n const value = req.headers.cookie\n ? cookie.parseCookie(req.headers.cookie)[cookieName]\n : undefined;\n if (!value) return false;\n const verified = await verifyCookie(value);\n // A cookie from before portal#237 has no session id: nothing to end.\n if (!verified?.sid) return false;\n openId = verified.session.openId;\n if (verified.iat !== null) {\n rememberSessionCheck(\n JSON.stringify([openId, verified.sid, verified.iat]),\n false\n );\n }\n if (!env.portalApiUrl) {\n warnOnce(\n \"no-portal-url-end\",\n `${tag} No Portal API URL — signing out here leaves the Portal session live`\n );\n return null;\n }\n const ip =\n headerValue(req, \"cf-connecting-ip\", 45) ??\n (req.ip ? String(req.ip).slice(0, 45) : undefined);\n const userAgent = headerValue(req, \"user-agent\", 255);\n const client = await getPortalClientAsync();\n if (!client) return null;\n const { data } = await client.post<{ ended?: unknown }>(\n \"/api/service/sessions/end\",\n {\n cookie: value,\n app: serviceName.slice(0, 64),\n ...(ip ? { ip } : {}),\n ...(userAgent ? { userAgent } : {}),\n },\n {\n timeout: sessionCheckTimeoutMs,\n signal: AbortSignal.timeout(sessionCheckTimeoutMs),\n }\n );\n if (typeof data?.ended === \"boolean\") return data.ended;\n console.warn(\n `${tag} Portal gave no answer to ending the session for ${openId}`\n );\n return null;\n } catch (err: unknown) {\n const status = (err as { response?: { status?: number } } | null)\n ?.response?.status;\n if (status === 404) {\n warnOnce(\n \"end-404\",\n `${tag} Portal can't end sessions yet (404) — signing out here leaves the Portal session live until it ships`\n );\n } else {\n console.warn(\n `${tag} Portal could not end the session for ${openId ?? \"a user\"} (${status ?? (err as Error).message}) — signed out here only`\n );\n }\n return null;\n }\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 // A read-only account (ops#246) is marked on the user the apps see. The\n // Portal answer is cached like portal mode's; an unreachable Portal leaves\n // the user as it is (the same fail-open as the session check).\n const portalSays = await getPortalUserCached(user.openId);\n const readOnly = portalSays\n ? portalSays.readOnly === true\n : knownReadOnly.has(user.openId);\n return readOnly ? { ...user, readOnly: true } : user;\n }\n\n // ── Return public API ──────────────────────────────────────────\n\n return {\n verifySession,\n authenticateRequest,\n endPortalSession,\n getPortalUser,\n getSessionCookieOptions,\n isValidReturnUrl,\n };\n}\n"],"mappings":"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","names":["env"]}
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  isSandboxEnv,
6
6
  resolveMarketingOrigin,
7
7
  sandboxPortalOrigin
8
- } from "./chunk-RSIH3FKC.js";
8
+ } from "./chunk-URYEVWI6.js";
9
9
  import {
10
10
  HierarchyRefusedError,
11
11
  createHierarchyService
@@ -10,6 +10,15 @@ interface IsolationUser {
10
10
  }
11
11
  /** What a read-only account is told when it tries to change something. */
12
12
  declare const READ_ONLY_ERR_MSG = "This account is read-only: it can look but not change anything.";
13
+ /**
14
+ * The only mutation a read-only account may call (core-server#129): signing
15
+ * itself out, `auth.logout` in every app. It ends the caller's own session and
16
+ * changes nothing else, so the live check's login can sign out after each run
17
+ * instead of leaving its session open. Matched on the full procedure path, so
18
+ * any other mutation, even one named "logout" elsewhere (`admin.logout`,
19
+ * `auth.logoutAll`), is still refused.
20
+ */
21
+ declare const READ_ONLY_ALLOWED_MUTATIONS: readonly string[];
13
22
  /** The context fields isolation needs. Each app's tRPC context must have these. */
14
23
  interface IsolationContext<TUser extends IsolationUser = IsolationUser> {
15
24
  user: TUser | null;
@@ -101,5 +110,5 @@ type IsolationProcedures<TUser extends IsolationUser = IsolationUser> = ReturnTy
101
110
  */
102
111
  declare function refuseReadOnlyWritesExpress(getUser: (req: Request) => Promise<IsolationUser | null | undefined>): (req: Request, res: Response, next: NextFunction) => Promise<void | Response<any, Record<string, any>>>;
103
112
 
104
- export { ADMIN_MIN_ROLE, READ_ONLY_ERR_MSG, assertEnterpriseOwnership, createIsolationProcedures, isAdminRole, refuseReadOnlyWritesExpress, requireEnterpriseId, resolveEnterpriseId, scopeToEnterprise };
113
+ export { ADMIN_MIN_ROLE, READ_ONLY_ALLOWED_MUTATIONS, READ_ONLY_ERR_MSG, assertEnterpriseOwnership, createIsolationProcedures, isAdminRole, refuseReadOnlyWritesExpress, requireEnterpriseId, resolveEnterpriseId, scopeToEnterprise };
105
114
  export type { EnterpriseOwned, IsolationContext, IsolationProcedures, IsolationUser, OwnershipMessages };
package/dist/isolation.js CHANGED
@@ -8,6 +8,9 @@ import {
8
8
  UNAUTHED_ERR_MSG
9
9
  } from "@pablo2410/shared-ui/rbac";
10
10
  var READ_ONLY_ERR_MSG = "This account is read-only: it can look but not change anything.";
11
+ var READ_ONLY_ALLOWED_MUTATIONS = Object.freeze([
12
+ "auth.logout"
13
+ ]);
11
14
  function isEnterpriseId(value) {
12
15
  return typeof value === "number" && Number.isInteger(value) && value > 0;
13
16
  }
@@ -83,8 +86,8 @@ function createIsolationProcedures() {
83
86
  });
84
87
  }
85
88
  const requireAdmin = requireRole(ADMIN_MIN_ROLE, NOT_ADMIN_ERR_MSG);
86
- const refuseReadOnlyWrites = t.middleware(({ ctx, type, next }) => {
87
- if (type === "mutation" && ctx.user?.readOnly === true) {
89
+ const refuseReadOnlyWrites = t.middleware(({ ctx, type, path, next }) => {
90
+ if (type === "mutation" && ctx.user?.readOnly === true && !READ_ONLY_ALLOWED_MUTATIONS.includes(path)) {
88
91
  throw new TRPCError({ code: "FORBIDDEN", message: READ_ONLY_ERR_MSG });
89
92
  }
90
93
  return next();
@@ -125,6 +128,7 @@ function refuseReadOnlyWritesExpress(getUser) {
125
128
  }
126
129
  export {
127
130
  ADMIN_MIN_ROLE,
131
+ READ_ONLY_ALLOWED_MUTATIONS,
128
132
  READ_ONLY_ERR_MSG,
129
133
  assertEnterpriseOwnership,
130
134
  createIsolationProcedures,
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/isolation.ts"],"sourcesContent":["/**\n * Enterprise Isolation — the one enforced tenant-scoping path for every service.\n *\n * Replaces the per-app copies of `requireEnterprise` / `enterpriseProcedure` and\n * the hand-written `rows.filter(r => r.enterpriseId === id)` and \"does this\n * record belong to the caller?\" checks. Roles and error messages come from\n * `@pablo2410/shared-ui/rbac`, the platform's single role model.\n *\n * Every check fails closed: no user → UNAUTHORIZED; no (valid) enterprise →\n * FORBIDDEN / no rows; an unknown role never passes a role check; a record with\n * no enterpriseId never matches, even against a caller with none.\n *\n * Quick example (each app keeps its own `t`; the procedures are concatenated\n * onto it, so the app's transformer, context and error formatter still apply):\n *\n * import { createIsolationProcedures } from \"@pablo2410/core-server/isolation\";\n * const iso = createIsolationProcedures<SessionUser>();\n * export const enterpriseProcedure = t.procedure.concat(iso.enterpriseProcedure);\n * // in a resolver: ctx.enterpriseId is a number, ctx.user is non-null\n * listPlans: enterpriseProcedure.query(async ({ ctx }) =>\n * scopeToEnterprise(await getPlans(), ctx.enterpriseId)),\n */\nimport type { NextFunction, Request, Response } from \"express\";\nimport { initTRPC, TRPCError } from \"@trpc/server\";\nimport {\n NO_ENTERPRISE_ERR_MSG,\n NOT_ADMIN_ERR_MSG,\n NOT_PLATFORM_ADMIN_ERR_MSG,\n ROLE_HIERARCHY,\n UNAUTHED_ERR_MSG,\n type RoleType,\n} from \"@pablo2410/shared-ui/rbac\";\n\n/** The user fields isolation needs. Each app's session user must have these. */\nexport interface IsolationUser {\n role: string;\n enterpriseId?: number | null;\n /** True for a read-only account (ops#246): every write from it is refused. */\n readOnly?: boolean;\n}\n\n/** What a read-only account is told when it tries to change something. */\nexport const READ_ONLY_ERR_MSG =\n \"This account is read-only: it can look but not change anything.\";\n\n/** The context fields isolation needs. Each app's tRPC context must have these. */\nexport interface IsolationContext<TUser extends IsolationUser = IsolationUser> {\n user: TUser | null;\n}\n\n/** Anything owned by one enterprise (a row from the DB or the Portal API). */\nexport interface EnterpriseOwned {\n enterpriseId?: number | null;\n}\n\n/** Only positive integers are real enterprise IDs. */\nfunction isEnterpriseId(value: unknown): value is number {\n return typeof value === \"number\" && Number.isInteger(value) && value > 0;\n}\n\n/**\n * True when `userRole` is a known role at or above `minimumRole`.\n * Unlike a bare indexOf comparison, an unknown role on either side is never\n * treated as meeting the bar.\n */\nfunction meetsMinimumRole(\n userRole: string | null | undefined,\n minimumRole: RoleType\n): boolean {\n const user = ROLE_HIERARCHY.indexOf(userRole as RoleType);\n const min = ROLE_HIERARCHY.indexOf(minimumRole);\n return user >= 0 && min >= 0 && user >= min;\n}\n\n/**\n * The fleet's one admin rule: enterprise_admin and above (enterprise_admin,\n * platform_admin). Paul, 5 Oct 2026 (core-server#74). Every admin check in\n * every app uses this; no app sets its own admin threshold, and superuser is\n * not an admin level.\n */\nexport const ADMIN_MIN_ROLE = \"enterprise_admin\" as const satisfies RoleType;\n\n/** True when `role` passes the fleet admin rule (ADMIN_MIN_ROLE and above). */\nexport function isAdminRole(role: string | null | undefined): boolean {\n return meetsMinimumRole(role, ADMIN_MIN_ROLE);\n}\n\n/**\n * The caller's enterprise, or null. Never a default tenant: a missing,\n * zero, negative or non-integer enterpriseId is null.\n */\nexport function resolveEnterpriseId(\n user: IsolationUser | null | undefined\n): number | null {\n const id = user?.enterpriseId;\n return isEnterpriseId(id) ? id : null;\n}\n\n/** Like resolveEnterpriseId, but throws UNAUTHORIZED / FORBIDDEN instead of null. */\nexport function requireEnterpriseId(\n user: IsolationUser | null | undefined\n): number {\n if (!user) {\n throw new TRPCError({ code: \"UNAUTHORIZED\", message: UNAUTHED_ERR_MSG });\n }\n const id = resolveEnterpriseId(user);\n if (id === null) {\n throw new TRPCError({ code: \"FORBIDDEN\", message: NO_ENTERPRISE_ERR_MSG });\n }\n return id;\n}\n\n/**\n * Keep only the rows owned by `enterpriseId`. A null/invalid enterprise gets no\n * rows; a row with no enterpriseId is never included.\n */\nexport function scopeToEnterprise<T extends EnterpriseOwned>(\n rows: readonly T[],\n enterpriseId: number | null | undefined\n): T[] {\n if (!isEnterpriseId(enterpriseId)) return [];\n return rows.filter(row => row.enterpriseId === enterpriseId);\n}\n\nexport interface OwnershipMessages {\n /** Message when the record does not exist. Default \"Not found\". */\n notFound?: string;\n /** Message when the record belongs to another enterprise. */\n forbidden?: string;\n}\n\n/**\n * Return `record` if it belongs to `enterpriseId`; otherwise throw.\n * Missing record → NOT_FOUND. Other enterprise, a record with no enterpriseId,\n * or a caller with no enterprise → FORBIDDEN.\n */\nexport function assertEnterpriseOwnership<T extends EnterpriseOwned>(\n record: T | null | undefined,\n enterpriseId: number | null | undefined,\n messages: OwnershipMessages = {}\n): T {\n if (record === null || record === undefined) {\n throw new TRPCError({\n code: \"NOT_FOUND\",\n message: messages.notFound ?? \"Not found\",\n });\n }\n if (!isEnterpriseId(enterpriseId) || record.enterpriseId !== enterpriseId) {\n throw new TRPCError({\n code: \"FORBIDDEN\",\n message:\n messages.forbidden ??\n \"Access denied: this record belongs to another enterprise\",\n });\n }\n return record;\n}\n\n/**\n * Build the standard procedures for an app whose session user is `TUser`.\n * Attach each one to the app's own tRPC instance with `t.procedure.concat(...)`.\n *\n * | Procedure | Auth | Enterprise | Min role |\n * |--------------------------|:----:|:----------:|-----------------------------|\n * | protectedProcedure | Yes | No | any |\n * | enterpriseProcedure | Yes | Yes | any |\n * | adminProcedure | Yes | No | ADMIN_MIN_ROLE (admin gate) |\n * | enterpriseAdminProcedure | Yes | Yes | ADMIN_MIN_ROLE (admin gate) |\n * | platformAdminProcedure | Yes | No | platform_admin |\n *\n * Both admin procedures share one admin gate built from ADMIN_MIN_ROLE.\n * Enterprise procedures add `ctx.enterpriseId: number` and narrow `ctx.user`\n * to non-null. Checks run in order: user, then enterprise, then role.\n */\nexport function createIsolationProcedures<\n TUser extends IsolationUser = IsolationUser,\n>() {\n const t = initTRPC.context<IsolationContext<TUser>>().create();\n\n const requireUser = t.middleware(({ ctx, next }) => {\n if (!ctx.user) {\n throw new TRPCError({ code: \"UNAUTHORIZED\", message: UNAUTHED_ERR_MSG });\n }\n return next({ ctx: { user: ctx.user } });\n });\n\n const requireEnterprise = t.middleware(({ ctx, next }) => {\n const enterpriseId = requireEnterpriseId(ctx.user);\n return next({ ctx: { user: ctx.user as TUser, enterpriseId } });\n });\n\n function requireRole(minimumRole: RoleType, message: string) {\n if (!ROLE_HIERARCHY.includes(minimumRole)) {\n throw new Error(`Unknown role for isolation check: ${minimumRole}`);\n }\n return t.middleware(({ ctx, next }) => {\n if (!ctx.user) {\n throw new TRPCError({\n code: \"UNAUTHORIZED\",\n message: UNAUTHED_ERR_MSG,\n });\n }\n if (!meetsMinimumRole(ctx.user.role, minimumRole)) {\n throw new TRPCError({ code: \"FORBIDDEN\", message });\n }\n return next();\n });\n }\n\n /** The one admin gate (ADMIN_MIN_ROLE). */\n const requireAdmin = requireRole(ADMIN_MIN_ROLE, NOT_ADMIN_ERR_MSG);\n\n // ops#246: the live check's login can look but not change anything. Every\n // procedure below starts here, so no mutation from a read-only account\n // reaches a resolver, whatever its role says. Runs before any input is read.\n const refuseReadOnlyWrites = t.middleware(({ ctx, type, next }) => {\n if (type === \"mutation\" && ctx.user?.readOnly === true) {\n throw new TRPCError({ code: \"FORBIDDEN\", message: READ_ONLY_ERR_MSG });\n }\n return next();\n });\n const baseProcedure = t.procedure.use(refuseReadOnlyWrites);\n\n const protectedProcedure = baseProcedure.use(requireUser);\n const enterpriseProcedure = baseProcedure.use(requireEnterprise);\n\n return {\n /**\n * For public procedures and any app tier of its own: no sign-in needed, but\n * a signed-in read-only account's writes are still refused.\n */\n readOnlyGuardedProcedure: baseProcedure,\n protectedProcedure,\n enterpriseProcedure,\n adminProcedure: protectedProcedure.use(requireAdmin),\n enterpriseAdminProcedure: enterpriseProcedure.use(requireAdmin),\n platformAdminProcedure: protectedProcedure.use(\n requireRole(\"platform_admin\", NOT_PLATFORM_ADMIN_ERR_MSG)\n ),\n };\n}\n\nexport type IsolationProcedures<TUser extends IsolationUser = IsolationUser> =\n ReturnType<typeof createIsolationProcedures<TUser>>;\n\n/**\n * Express guard for write routes that are not tRPC (ops#246): a read-only\n * account gets 403 on every POST, PUT, PATCH and DELETE. `getUser` is the\n * app's own sign-in lookup (for example `req => auth.authenticateRequest(req)`);\n * an unauthenticated caller (it throws or returns null) is left to the route.\n * Mount it before the app's write routes.\n */\nexport function refuseReadOnlyWritesExpress(\n getUser: (req: Request) => Promise<IsolationUser | null | undefined>\n) {\n const WRITE = new Set([\"POST\", \"PUT\", \"PATCH\", \"DELETE\"]);\n return async (req: Request, res: Response, next: NextFunction) => {\n if (!WRITE.has(String(req.method).toUpperCase())) return next();\n let user: IsolationUser | null | undefined;\n try {\n user = await getUser(req);\n } catch {\n return next();\n }\n if (user?.readOnly === true) {\n return res.status(403).json({ error: READ_ONLY_ERR_MSG });\n }\n return next();\n };\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/isolation.ts"],"sourcesContent":["/**\n * Enterprise Isolation — the one enforced tenant-scoping path for every service.\n *\n * Replaces the per-app copies of `requireEnterprise` / `enterpriseProcedure` and\n * the hand-written `rows.filter(r => r.enterpriseId === id)` and \"does this\n * record belong to the caller?\" checks. Roles and error messages come from\n * `@pablo2410/shared-ui/rbac`, the platform's single role model.\n *\n * Every check fails closed: no user → UNAUTHORIZED; no (valid) enterprise →\n * FORBIDDEN / no rows; an unknown role never passes a role check; a record with\n * no enterpriseId never matches, even against a caller with none.\n *\n * Quick example (each app keeps its own `t`; the procedures are concatenated\n * onto it, so the app's transformer, context and error formatter still apply):\n *\n * import { createIsolationProcedures } from \"@pablo2410/core-server/isolation\";\n * const iso = createIsolationProcedures<SessionUser>();\n * export const enterpriseProcedure = t.procedure.concat(iso.enterpriseProcedure);\n * // in a resolver: ctx.enterpriseId is a number, ctx.user is non-null\n * listPlans: enterpriseProcedure.query(async ({ ctx }) =>\n * scopeToEnterprise(await getPlans(), ctx.enterpriseId)),\n */\nimport type { NextFunction, Request, Response } from \"express\";\nimport { initTRPC, TRPCError } from \"@trpc/server\";\nimport {\n NO_ENTERPRISE_ERR_MSG,\n NOT_ADMIN_ERR_MSG,\n NOT_PLATFORM_ADMIN_ERR_MSG,\n ROLE_HIERARCHY,\n UNAUTHED_ERR_MSG,\n type RoleType,\n} from \"@pablo2410/shared-ui/rbac\";\n\n/** The user fields isolation needs. Each app's session user must have these. */\nexport interface IsolationUser {\n role: string;\n enterpriseId?: number | null;\n /** True for a read-only account (ops#246): every write from it is refused. */\n readOnly?: boolean;\n}\n\n/** What a read-only account is told when it tries to change something. */\nexport const READ_ONLY_ERR_MSG =\n \"This account is read-only: it can look but not change anything.\";\n\n/**\n * The only mutation a read-only account may call (core-server#129): signing\n * itself out, `auth.logout` in every app. It ends the caller's own session and\n * changes nothing else, so the live check's login can sign out after each run\n * instead of leaving its session open. Matched on the full procedure path, so\n * any other mutation, even one named \"logout\" elsewhere (`admin.logout`,\n * `auth.logoutAll`), is still refused.\n */\nexport const READ_ONLY_ALLOWED_MUTATIONS: readonly string[] = Object.freeze([\n \"auth.logout\",\n]);\n\n/** The context fields isolation needs. Each app's tRPC context must have these. */\nexport interface IsolationContext<TUser extends IsolationUser = IsolationUser> {\n user: TUser | null;\n}\n\n/** Anything owned by one enterprise (a row from the DB or the Portal API). */\nexport interface EnterpriseOwned {\n enterpriseId?: number | null;\n}\n\n/** Only positive integers are real enterprise IDs. */\nfunction isEnterpriseId(value: unknown): value is number {\n return typeof value === \"number\" && Number.isInteger(value) && value > 0;\n}\n\n/**\n * True when `userRole` is a known role at or above `minimumRole`.\n * Unlike a bare indexOf comparison, an unknown role on either side is never\n * treated as meeting the bar.\n */\nfunction meetsMinimumRole(\n userRole: string | null | undefined,\n minimumRole: RoleType\n): boolean {\n const user = ROLE_HIERARCHY.indexOf(userRole as RoleType);\n const min = ROLE_HIERARCHY.indexOf(minimumRole);\n return user >= 0 && min >= 0 && user >= min;\n}\n\n/**\n * The fleet's one admin rule: enterprise_admin and above (enterprise_admin,\n * platform_admin). Paul, 5 Oct 2026 (core-server#74). Every admin check in\n * every app uses this; no app sets its own admin threshold, and superuser is\n * not an admin level.\n */\nexport const ADMIN_MIN_ROLE = \"enterprise_admin\" as const satisfies RoleType;\n\n/** True when `role` passes the fleet admin rule (ADMIN_MIN_ROLE and above). */\nexport function isAdminRole(role: string | null | undefined): boolean {\n return meetsMinimumRole(role, ADMIN_MIN_ROLE);\n}\n\n/**\n * The caller's enterprise, or null. Never a default tenant: a missing,\n * zero, negative or non-integer enterpriseId is null.\n */\nexport function resolveEnterpriseId(\n user: IsolationUser | null | undefined\n): number | null {\n const id = user?.enterpriseId;\n return isEnterpriseId(id) ? id : null;\n}\n\n/** Like resolveEnterpriseId, but throws UNAUTHORIZED / FORBIDDEN instead of null. */\nexport function requireEnterpriseId(\n user: IsolationUser | null | undefined\n): number {\n if (!user) {\n throw new TRPCError({ code: \"UNAUTHORIZED\", message: UNAUTHED_ERR_MSG });\n }\n const id = resolveEnterpriseId(user);\n if (id === null) {\n throw new TRPCError({ code: \"FORBIDDEN\", message: NO_ENTERPRISE_ERR_MSG });\n }\n return id;\n}\n\n/**\n * Keep only the rows owned by `enterpriseId`. A null/invalid enterprise gets no\n * rows; a row with no enterpriseId is never included.\n */\nexport function scopeToEnterprise<T extends EnterpriseOwned>(\n rows: readonly T[],\n enterpriseId: number | null | undefined\n): T[] {\n if (!isEnterpriseId(enterpriseId)) return [];\n return rows.filter(row => row.enterpriseId === enterpriseId);\n}\n\nexport interface OwnershipMessages {\n /** Message when the record does not exist. Default \"Not found\". */\n notFound?: string;\n /** Message when the record belongs to another enterprise. */\n forbidden?: string;\n}\n\n/**\n * Return `record` if it belongs to `enterpriseId`; otherwise throw.\n * Missing record → NOT_FOUND. Other enterprise, a record with no enterpriseId,\n * or a caller with no enterprise → FORBIDDEN.\n */\nexport function assertEnterpriseOwnership<T extends EnterpriseOwned>(\n record: T | null | undefined,\n enterpriseId: number | null | undefined,\n messages: OwnershipMessages = {}\n): T {\n if (record === null || record === undefined) {\n throw new TRPCError({\n code: \"NOT_FOUND\",\n message: messages.notFound ?? \"Not found\",\n });\n }\n if (!isEnterpriseId(enterpriseId) || record.enterpriseId !== enterpriseId) {\n throw new TRPCError({\n code: \"FORBIDDEN\",\n message:\n messages.forbidden ??\n \"Access denied: this record belongs to another enterprise\",\n });\n }\n return record;\n}\n\n/**\n * Build the standard procedures for an app whose session user is `TUser`.\n * Attach each one to the app's own tRPC instance with `t.procedure.concat(...)`.\n *\n * | Procedure | Auth | Enterprise | Min role |\n * |--------------------------|:----:|:----------:|-----------------------------|\n * | protectedProcedure | Yes | No | any |\n * | enterpriseProcedure | Yes | Yes | any |\n * | adminProcedure | Yes | No | ADMIN_MIN_ROLE (admin gate) |\n * | enterpriseAdminProcedure | Yes | Yes | ADMIN_MIN_ROLE (admin gate) |\n * | platformAdminProcedure | Yes | No | platform_admin |\n *\n * Both admin procedures share one admin gate built from ADMIN_MIN_ROLE.\n * Enterprise procedures add `ctx.enterpriseId: number` and narrow `ctx.user`\n * to non-null. Checks run in order: user, then enterprise, then role.\n */\nexport function createIsolationProcedures<\n TUser extends IsolationUser = IsolationUser,\n>() {\n const t = initTRPC.context<IsolationContext<TUser>>().create();\n\n const requireUser = t.middleware(({ ctx, next }) => {\n if (!ctx.user) {\n throw new TRPCError({ code: \"UNAUTHORIZED\", message: UNAUTHED_ERR_MSG });\n }\n return next({ ctx: { user: ctx.user } });\n });\n\n const requireEnterprise = t.middleware(({ ctx, next }) => {\n const enterpriseId = requireEnterpriseId(ctx.user);\n return next({ ctx: { user: ctx.user as TUser, enterpriseId } });\n });\n\n function requireRole(minimumRole: RoleType, message: string) {\n if (!ROLE_HIERARCHY.includes(minimumRole)) {\n throw new Error(`Unknown role for isolation check: ${minimumRole}`);\n }\n return t.middleware(({ ctx, next }) => {\n if (!ctx.user) {\n throw new TRPCError({\n code: \"UNAUTHORIZED\",\n message: UNAUTHED_ERR_MSG,\n });\n }\n if (!meetsMinimumRole(ctx.user.role, minimumRole)) {\n throw new TRPCError({ code: \"FORBIDDEN\", message });\n }\n return next();\n });\n }\n\n /** The one admin gate (ADMIN_MIN_ROLE). */\n const requireAdmin = requireRole(ADMIN_MIN_ROLE, NOT_ADMIN_ERR_MSG);\n\n // ops#246: the live check's login can look but not change anything. Every\n // procedure below starts here, so no mutation from a read-only account\n // reaches a resolver, whatever its role says. Runs before any input is read.\n // The one exception is signing itself out (READ_ONLY_ALLOWED_MUTATIONS).\n const refuseReadOnlyWrites = t.middleware(({ ctx, type, path, next }) => {\n if (\n type === \"mutation\" &&\n ctx.user?.readOnly === true &&\n !READ_ONLY_ALLOWED_MUTATIONS.includes(path)\n ) {\n throw new TRPCError({ code: \"FORBIDDEN\", message: READ_ONLY_ERR_MSG });\n }\n return next();\n });\n const baseProcedure = t.procedure.use(refuseReadOnlyWrites);\n\n const protectedProcedure = baseProcedure.use(requireUser);\n const enterpriseProcedure = baseProcedure.use(requireEnterprise);\n\n return {\n /**\n * For public procedures and any app tier of its own: no sign-in needed, but\n * a signed-in read-only account's writes are still refused.\n */\n readOnlyGuardedProcedure: baseProcedure,\n protectedProcedure,\n enterpriseProcedure,\n adminProcedure: protectedProcedure.use(requireAdmin),\n enterpriseAdminProcedure: enterpriseProcedure.use(requireAdmin),\n platformAdminProcedure: protectedProcedure.use(\n requireRole(\"platform_admin\", NOT_PLATFORM_ADMIN_ERR_MSG)\n ),\n };\n}\n\nexport type IsolationProcedures<TUser extends IsolationUser = IsolationUser> =\n ReturnType<typeof createIsolationProcedures<TUser>>;\n\n/**\n * Express guard for write routes that are not tRPC (ops#246): a read-only\n * account gets 403 on every POST, PUT, PATCH and DELETE. `getUser` is the\n * app's own sign-in lookup (for example `req => auth.authenticateRequest(req)`);\n * an unauthenticated caller (it throws or returns null) is left to the route.\n * Mount it before the app's write routes.\n */\nexport function refuseReadOnlyWritesExpress(\n getUser: (req: Request) => Promise<IsolationUser | null | undefined>\n) {\n const WRITE = new Set([\"POST\", \"PUT\", \"PATCH\", \"DELETE\"]);\n return async (req: Request, res: Response, next: NextFunction) => {\n if (!WRITE.has(String(req.method).toUpperCase())) return next();\n let user: IsolationUser | null | undefined;\n try {\n user = await getUser(req);\n } catch {\n return next();\n }\n if (user?.readOnly === true) {\n return res.status(403).json({ error: READ_ONLY_ERR_MSG });\n }\n return next();\n };\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pablo2410/core-server",
3
- "version": "0.15.1",
3
+ "version": "0.16.0",
4
4
  "description": "Shared server utilities for Oplytics.digital subdomains",
5
5
  "type": "module",
6
6
  "exports": {