@pablo2410/core-server 0.10.8 → 0.11.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 +10 -1
- package/dist/auth.js +1 -1
- package/dist/{chunk-BUMJYZRS.js → chunk-RSIH3FKC.js} +15 -4
- package/dist/chunk-RSIH3FKC.js.map +1 -0
- package/dist/hierarchy.d.ts +2 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/dist/isolation.d.ts +22 -3
- package/dist/isolation.js +33 -2
- package/dist/isolation.js.map +1 -1
- package/package.json +6 -6
- package/dist/chunk-BUMJYZRS.js.map +0 -1
package/dist/auth.d.ts
CHANGED
|
@@ -31,6 +31,12 @@ interface PortalUser {
|
|
|
31
31
|
siteId?: number;
|
|
32
32
|
/** Profile picture as a data URL, or null when the user has none (portal#518). */
|
|
33
33
|
avatarUrl?: string | null;
|
|
34
|
+
/**
|
|
35
|
+
* True for a read-only account (the live check's login, ops#246): the Portal
|
|
36
|
+
* says so in this payload and every app refuses its writes. Absent or false
|
|
37
|
+
* for everyone else.
|
|
38
|
+
*/
|
|
39
|
+
readOnly?: boolean;
|
|
34
40
|
}
|
|
35
41
|
/** Environment variables required by the auth SDK. */
|
|
36
42
|
interface AuthEnv {
|
|
@@ -57,6 +63,8 @@ interface BaseUser {
|
|
|
57
63
|
role: string;
|
|
58
64
|
enterpriseId: number | null;
|
|
59
65
|
portalUserId: number | null;
|
|
66
|
+
/** Set (true) only for a read-only account; see PortalUser.readOnly. */
|
|
67
|
+
readOnly?: boolean;
|
|
60
68
|
}
|
|
61
69
|
/**
|
|
62
70
|
* Minimal upsert payload for creating/updating users.
|
|
@@ -196,4 +204,5 @@ interface SubdomainAuth<TUser extends BaseUser = BaseUser> {
|
|
|
196
204
|
|
|
197
205
|
declare function createSubdomainAuth<TUser extends BaseUser = BaseUser>(config: SubdomainAuthConfig<TUser>): SubdomainAuth<TUser>;
|
|
198
206
|
|
|
199
|
-
export {
|
|
207
|
+
export { createSubdomainAuth };
|
|
208
|
+
export type { AuthEnv, BaseUser, DbAdapter, PortalUser, RoleMapper, SessionPayload, SubdomainAuth, SubdomainAuthConfig, UpsertUserPayload };
|
package/dist/auth.js
CHANGED
|
@@ -168,6 +168,7 @@ function createSubdomainAuth(config) {
|
|
|
168
168
|
async function verifySession(cookieValue) {
|
|
169
169
|
return (await verifyCookie(cookieValue))?.session ?? null;
|
|
170
170
|
}
|
|
171
|
+
const knownReadOnly = /* @__PURE__ */ new Set();
|
|
171
172
|
async function getPortalUser(openId) {
|
|
172
173
|
try {
|
|
173
174
|
const client = await getPortalClientAsync();
|
|
@@ -175,6 +176,10 @@ function createSubdomainAuth(config) {
|
|
|
175
176
|
const { data } = await client.get(
|
|
176
177
|
`/api/service/users/by-openid/${encodeURIComponent(openId)}`
|
|
177
178
|
);
|
|
179
|
+
if (data) {
|
|
180
|
+
if (data.readOnly === true) knownReadOnly.add(openId);
|
|
181
|
+
else knownReadOnly.delete(openId);
|
|
182
|
+
}
|
|
178
183
|
return data ?? null;
|
|
179
184
|
} catch (err) {
|
|
180
185
|
const status = err?.response?.status;
|
|
@@ -223,7 +228,9 @@ function createSubdomainAuth(config) {
|
|
|
223
228
|
email: null,
|
|
224
229
|
role: "",
|
|
225
230
|
enterpriseId: null,
|
|
226
|
-
portalUserId: null
|
|
231
|
+
portalUserId: null,
|
|
232
|
+
// A known read-only login stays read-only through a Portal outage.
|
|
233
|
+
...knownReadOnly.has(openId) ? { readOnly: true } : {}
|
|
227
234
|
};
|
|
228
235
|
}
|
|
229
236
|
const enterpriseId = portalUser.enterpriseId ?? portalUser.companyId ?? null;
|
|
@@ -235,7 +242,9 @@ function createSubdomainAuth(config) {
|
|
|
235
242
|
role: portalUser.role,
|
|
236
243
|
enterpriseId,
|
|
237
244
|
portalUserId: portalUser.id,
|
|
238
|
-
avatarUrl: portalUser.avatarUrl ?? null
|
|
245
|
+
avatarUrl: portalUser.avatarUrl ?? null,
|
|
246
|
+
// Only set for a read-only account (ops#246); the apps refuse its writes.
|
|
247
|
+
...portalUser.readOnly === true ? { readOnly: true } : {}
|
|
239
248
|
};
|
|
240
249
|
}
|
|
241
250
|
const sessionCheckCache = /* @__PURE__ */ new Map();
|
|
@@ -418,7 +427,9 @@ function createSubdomainAuth(config) {
|
|
|
418
427
|
openId: user.openId,
|
|
419
428
|
lastSignedIn: signedInAt
|
|
420
429
|
});
|
|
421
|
-
|
|
430
|
+
const portalSays = await getPortalUserCached(user.openId);
|
|
431
|
+
const readOnly = portalSays ? portalSays.readOnly === true : knownReadOnly.has(user.openId);
|
|
432
|
+
return readOnly ? { ...user, readOnly: true } : user;
|
|
422
433
|
}
|
|
423
434
|
return {
|
|
424
435
|
verifySession,
|
|
@@ -437,4 +448,4 @@ export {
|
|
|
437
448
|
resolveMarketingOrigin,
|
|
438
449
|
createSubdomainAuth
|
|
439
450
|
};
|
|
440
|
-
//# sourceMappingURL=chunk-
|
|
451
|
+
//# sourceMappingURL=chunk-RSIH3FKC.js.map
|
|
@@ -0,0 +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"]}
|
package/dist/hierarchy.d.ts
CHANGED
|
@@ -70,4 +70,5 @@ interface HierarchyService {
|
|
|
70
70
|
}
|
|
71
71
|
declare function createHierarchyService(config: HierarchyServiceConfig): HierarchyService;
|
|
72
72
|
|
|
73
|
-
export {
|
|
73
|
+
export { createHierarchyService };
|
|
74
|
+
export type { HierarchyService, HierarchyServiceConfig };
|
package/dist/index.d.ts
CHANGED
|
@@ -556,4 +556,5 @@ interface SecurityHeadersOptions {
|
|
|
556
556
|
*/
|
|
557
557
|
declare function securityHeaders(opts?: SecurityHeadersOptions): (_req: Request, res: Response, next: NextFunction) => void;
|
|
558
558
|
|
|
559
|
-
export {
|
|
559
|
+
export { AiUnavailableError, DEFAULT_COST_RATE, DEFAULT_LLM_MODEL, KNOWLEDGE, LLMBudgetError, LLMProviderError, OPLYTICS_VERIFIED_LINKS, SQDCP_PILLARS, SQDCP_PILLAR_COLORS, SQDCP_PILLAR_LABELS, buildSystemPrompt, computeCostUsd, createAiGate, createLLMClient, createStaticRetriever, createSupportEngine, estimateMessageTokens, estimateTokens, formatKnowledge, securityHeaders };
|
|
560
|
+
export type { AiAvailability, AiCaller, AiGate, AiGateConfig, AiUnavailableReason, AssistantMode, BuildSystemPromptParams, CallMeta, ChatMessage, ChatParams, ChatResult, CoreEnv, FileContent, GuardRequest, GuardResult, ImageContent, InvokeParams, InvokeResult, KnowledgeItem, LLMClientOptions, LLMProviderName, LLMProviderStatus, Message, MessageContent, OutputSchema, ResponseFormat, RetrieveOptions, Retriever, Role, SecurityHeadersOptions, SqdcpPillar, SupportContext, SupportEngine, SupportEngineOptions, TextContent, TokenCostRate, Tool, ToolChoice, UsageEvent };
|
package/dist/index.js
CHANGED
package/dist/isolation.d.ts
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import * as _trpc_server from '@trpc/server';
|
|
2
|
+
import { Request, Response, NextFunction } from 'express';
|
|
2
3
|
|
|
3
4
|
/** The user fields isolation needs. Each app's session user must have these. */
|
|
4
5
|
interface IsolationUser {
|
|
5
6
|
role: string;
|
|
6
7
|
enterpriseId?: number | null;
|
|
8
|
+
/** True for a read-only account (ops#246): every write from it is refused. */
|
|
9
|
+
readOnly?: boolean;
|
|
7
10
|
}
|
|
11
|
+
/** What a read-only account is told when it tries to change something. */
|
|
12
|
+
declare const READ_ONLY_ERR_MSG = "This account is read-only: it can look but not change anything.";
|
|
8
13
|
/** The context fields isolation needs. Each app's tRPC context must have these. */
|
|
9
14
|
interface IsolationContext<TUser extends IsolationUser = IsolationUser> {
|
|
10
15
|
user: TUser | null;
|
|
@@ -63,24 +68,38 @@ declare function assertEnterpriseOwnership<T extends EnterpriseOwned>(record: T
|
|
|
63
68
|
* to non-null. Checks run in order: user, then enterprise, then role.
|
|
64
69
|
*/
|
|
65
70
|
declare function createIsolationProcedures<TUser extends IsolationUser = IsolationUser>(): {
|
|
71
|
+
/**
|
|
72
|
+
* For public procedures and any app tier of its own: no sign-in needed, but
|
|
73
|
+
* a signed-in read-only account's writes are still refused.
|
|
74
|
+
*/
|
|
75
|
+
readOnlyGuardedProcedure: _trpc_server.TRPCProcedureBuilder<IsolationContext<TUser>, object, {}, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, false>;
|
|
66
76
|
protectedProcedure: _trpc_server.TRPCProcedureBuilder<IsolationContext<TUser>, object, {
|
|
67
77
|
user: TUser;
|
|
68
78
|
}, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, false>;
|
|
69
79
|
enterpriseProcedure: _trpc_server.TRPCProcedureBuilder<IsolationContext<TUser>, object, {
|
|
70
|
-
enterpriseId: number;
|
|
71
80
|
user: TUser;
|
|
81
|
+
enterpriseId: number;
|
|
72
82
|
}, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, false>;
|
|
73
83
|
adminProcedure: _trpc_server.TRPCProcedureBuilder<IsolationContext<TUser>, object, {
|
|
74
84
|
user: TUser;
|
|
75
85
|
}, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, false>;
|
|
76
86
|
enterpriseAdminProcedure: _trpc_server.TRPCProcedureBuilder<IsolationContext<TUser>, object, {
|
|
77
|
-
enterpriseId: number;
|
|
78
87
|
user: TUser;
|
|
88
|
+
enterpriseId: number;
|
|
79
89
|
}, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, false>;
|
|
80
90
|
platformAdminProcedure: _trpc_server.TRPCProcedureBuilder<IsolationContext<TUser>, object, {
|
|
81
91
|
user: TUser;
|
|
82
92
|
}, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, false>;
|
|
83
93
|
};
|
|
84
94
|
type IsolationProcedures<TUser extends IsolationUser = IsolationUser> = ReturnType<typeof createIsolationProcedures<TUser>>;
|
|
95
|
+
/**
|
|
96
|
+
* Express guard for write routes that are not tRPC (ops#246): a read-only
|
|
97
|
+
* account gets 403 on every POST, PUT, PATCH and DELETE. `getUser` is the
|
|
98
|
+
* app's own sign-in lookup (for example `req => auth.authenticateRequest(req)`);
|
|
99
|
+
* an unauthenticated caller (it throws or returns null) is left to the route.
|
|
100
|
+
* Mount it before the app's write routes.
|
|
101
|
+
*/
|
|
102
|
+
declare function refuseReadOnlyWritesExpress(getUser: (req: Request) => Promise<IsolationUser | null | undefined>): (req: Request, res: Response, next: NextFunction) => Promise<void | Response<any, Record<string, any>>>;
|
|
85
103
|
|
|
86
|
-
export { ADMIN_MIN_ROLE,
|
|
104
|
+
export { ADMIN_MIN_ROLE, READ_ONLY_ERR_MSG, assertEnterpriseOwnership, createIsolationProcedures, isAdminRole, refuseReadOnlyWritesExpress, requireEnterpriseId, resolveEnterpriseId, scopeToEnterprise };
|
|
105
|
+
export type { EnterpriseOwned, IsolationContext, IsolationProcedures, IsolationUser, OwnershipMessages };
|
package/dist/isolation.js
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
ROLE_HIERARCHY,
|
|
8
8
|
UNAUTHED_ERR_MSG
|
|
9
9
|
} from "@pablo2410/shared-ui/rbac";
|
|
10
|
+
var READ_ONLY_ERR_MSG = "This account is read-only: it can look but not change anything.";
|
|
10
11
|
function isEnterpriseId(value) {
|
|
11
12
|
return typeof value === "number" && Number.isInteger(value) && value > 0;
|
|
12
13
|
}
|
|
@@ -82,9 +83,21 @@ function createIsolationProcedures() {
|
|
|
82
83
|
});
|
|
83
84
|
}
|
|
84
85
|
const requireAdmin = requireRole(ADMIN_MIN_ROLE, NOT_ADMIN_ERR_MSG);
|
|
85
|
-
const
|
|
86
|
-
|
|
86
|
+
const refuseReadOnlyWrites = t.middleware(({ ctx, type, next }) => {
|
|
87
|
+
if (type === "mutation" && ctx.user?.readOnly === true) {
|
|
88
|
+
throw new TRPCError({ code: "FORBIDDEN", message: READ_ONLY_ERR_MSG });
|
|
89
|
+
}
|
|
90
|
+
return next();
|
|
91
|
+
});
|
|
92
|
+
const baseProcedure = t.procedure.use(refuseReadOnlyWrites);
|
|
93
|
+
const protectedProcedure = baseProcedure.use(requireUser);
|
|
94
|
+
const enterpriseProcedure = baseProcedure.use(requireEnterprise);
|
|
87
95
|
return {
|
|
96
|
+
/**
|
|
97
|
+
* For public procedures and any app tier of its own: no sign-in needed, but
|
|
98
|
+
* a signed-in read-only account's writes are still refused.
|
|
99
|
+
*/
|
|
100
|
+
readOnlyGuardedProcedure: baseProcedure,
|
|
88
101
|
protectedProcedure,
|
|
89
102
|
enterpriseProcedure,
|
|
90
103
|
adminProcedure: protectedProcedure.use(requireAdmin),
|
|
@@ -94,11 +107,29 @@ function createIsolationProcedures() {
|
|
|
94
107
|
)
|
|
95
108
|
};
|
|
96
109
|
}
|
|
110
|
+
function refuseReadOnlyWritesExpress(getUser) {
|
|
111
|
+
const WRITE = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
112
|
+
return async (req, res, next) => {
|
|
113
|
+
if (!WRITE.has(String(req.method).toUpperCase())) return next();
|
|
114
|
+
let user;
|
|
115
|
+
try {
|
|
116
|
+
user = await getUser(req);
|
|
117
|
+
} catch {
|
|
118
|
+
return next();
|
|
119
|
+
}
|
|
120
|
+
if (user?.readOnly === true) {
|
|
121
|
+
return res.status(403).json({ error: READ_ONLY_ERR_MSG });
|
|
122
|
+
}
|
|
123
|
+
return next();
|
|
124
|
+
};
|
|
125
|
+
}
|
|
97
126
|
export {
|
|
98
127
|
ADMIN_MIN_ROLE,
|
|
128
|
+
READ_ONLY_ERR_MSG,
|
|
99
129
|
assertEnterpriseOwnership,
|
|
100
130
|
createIsolationProcedures,
|
|
101
131
|
isAdminRole,
|
|
132
|
+
refuseReadOnlyWritesExpress,
|
|
102
133
|
requireEnterpriseId,
|
|
103
134
|
resolveEnterpriseId,
|
|
104
135
|
scopeToEnterprise
|
package/dist/isolation.js.map
CHANGED
|
@@ -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 { 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}\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 const protectedProcedure = t.procedure.use(requireUser);\n const enterpriseProcedure = t.procedure.use(requireEnterprise);\n\n return {\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"],"mappings":";AAsBA,SAAS,UAAU,iBAAiB;AACpC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OAEK;AAmBP,SAAS,eAAe,OAAiC;AACvD,SAAO,OAAO,UAAU,YAAY,OAAO,UAAU,KAAK,KAAK,QAAQ;AACzE;AAOA,SAAS,iBACP,UACA,aACS;AACT,QAAM,OAAO,eAAe,QAAQ,QAAoB;AACxD,QAAM,MAAM,eAAe,QAAQ,WAAW;AAC9C,SAAO,QAAQ,KAAK,OAAO,KAAK,QAAQ;AAC1C;AAQO,IAAM,iBAAiB;AAGvB,SAAS,YAAY,MAA0C;AACpE,SAAO,iBAAiB,MAAM,cAAc;AAC9C;AAMO,SAAS,oBACd,MACe;AACf,QAAM,KAAK,MAAM;AACjB,SAAO,eAAe,EAAE,IAAI,KAAK;AACnC;AAGO,SAAS,oBACd,MACQ;AACR,MAAI,CAAC,MAAM;AACT,UAAM,IAAI,UAAU,EAAE,MAAM,gBAAgB,SAAS,iBAAiB,CAAC;AAAA,EACzE;AACA,QAAM,KAAK,oBAAoB,IAAI;AACnC,MAAI,OAAO,MAAM;AACf,UAAM,IAAI,UAAU,EAAE,MAAM,aAAa,SAAS,sBAAsB,CAAC;AAAA,EAC3E;AACA,SAAO;AACT;AAMO,SAAS,kBACd,MACA,cACK;AACL,MAAI,CAAC,eAAe,YAAY,EAAG,QAAO,CAAC;AAC3C,SAAO,KAAK,OAAO,SAAO,IAAI,iBAAiB,YAAY;AAC7D;AAcO,SAAS,0BACd,QACA,cACA,WAA8B,CAAC,GAC5B;AACH,MAAI,WAAW,QAAQ,WAAW,QAAW;AAC3C,UAAM,IAAI,UAAU;AAAA,MAClB,MAAM;AAAA,MACN,SAAS,SAAS,YAAY;AAAA,IAChC,CAAC;AAAA,EACH;AACA,MAAI,CAAC,eAAe,YAAY,KAAK,OAAO,iBAAiB,cAAc;AACzE,UAAM,IAAI,UAAU;AAAA,MAClB,MAAM;AAAA,MACN,SACE,SAAS,aACT;AAAA,IACJ,CAAC;AAAA,EACH;AACA,SAAO;AACT;AAkBO,SAAS,4BAEZ;AACF,QAAM,IAAI,SAAS,QAAiC,EAAE,OAAO;AAE7D,QAAM,cAAc,EAAE,WAAW,CAAC,EAAE,KAAK,KAAK,MAAM;AAClD,QAAI,CAAC,IAAI,MAAM;AACb,YAAM,IAAI,UAAU,EAAE,MAAM,gBAAgB,SAAS,iBAAiB,CAAC;AAAA,IACzE;AACA,WAAO,KAAK,EAAE,KAAK,EAAE,MAAM,IAAI,KAAK,EAAE,CAAC;AAAA,EACzC,CAAC;AAED,QAAM,oBAAoB,EAAE,WAAW,CAAC,EAAE,KAAK,KAAK,MAAM;AACxD,UAAM,eAAe,oBAAoB,IAAI,IAAI;AACjD,WAAO,KAAK,EAAE,KAAK,EAAE,MAAM,IAAI,MAAe,aAAa,EAAE,CAAC;AAAA,EAChE,CAAC;AAED,WAAS,YAAY,aAAuB,SAAiB;AAC3D,QAAI,CAAC,eAAe,SAAS,WAAW,GAAG;AACzC,YAAM,IAAI,MAAM,qCAAqC,WAAW,EAAE;AAAA,IACpE;AACA,WAAO,EAAE,WAAW,CAAC,EAAE,KAAK,KAAK,MAAM;AACrC,UAAI,CAAC,IAAI,MAAM;AACb,cAAM,IAAI,UAAU;AAAA,UAClB,MAAM;AAAA,UACN,SAAS;AAAA,QACX,CAAC;AAAA,MACH;AACA,UAAI,CAAC,iBAAiB,IAAI,KAAK,MAAM,WAAW,GAAG;AACjD,cAAM,IAAI,UAAU,EAAE,MAAM,aAAa,QAAQ,CAAC;AAAA,MACpD;AACA,aAAO,KAAK;AAAA,IACd,CAAC;AAAA,EACH;AAGA,QAAM,eAAe,YAAY,gBAAgB,iBAAiB;AAElE,QAAM,qBAAqB,EAAE,UAAU,IAAI,WAAW;AACtD,QAAM,sBAAsB,EAAE,UAAU,IAAI,iBAAiB;AAE7D,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA,gBAAgB,mBAAmB,IAAI,YAAY;AAAA,IACnD,0BAA0B,oBAAoB,IAAI,YAAY;AAAA,IAC9D,wBAAwB,mBAAmB;AAAA,MACzC,YAAY,kBAAkB,0BAA0B;AAAA,IAC1D;AAAA,EACF;AACF;","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/** 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":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pablo2410/core-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Shared server utilities for Oplytics.digital subdomains",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -41,20 +41,20 @@
|
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@eslint/js": "^10.0.1",
|
|
44
|
-
"@pablo2410/shared-ui": "^0.
|
|
44
|
+
"@pablo2410/shared-ui": "^0.30.0",
|
|
45
45
|
"@types/express": "^5.0.6",
|
|
46
46
|
"@types/node": "26.6.5",
|
|
47
47
|
"axios": "^1.20.0",
|
|
48
48
|
"cookie": "^2.0.1",
|
|
49
|
-
"eslint": "^10.
|
|
49
|
+
"eslint": "^10.13.0",
|
|
50
50
|
"express": "^5.3.0",
|
|
51
51
|
"globals": "^17.13.0",
|
|
52
52
|
"jose": "^6.2.12",
|
|
53
53
|
"prettier": "^3.9.10",
|
|
54
|
-
"tsup": "^
|
|
54
|
+
"tsup": "^9.0.0",
|
|
55
55
|
"typescript": "^5.6.0",
|
|
56
|
-
"typescript-eslint": "^8.
|
|
57
|
-
"vitest": "5.0.
|
|
56
|
+
"typescript-eslint": "^8.72.0",
|
|
57
|
+
"vitest": "5.0.5"
|
|
58
58
|
},
|
|
59
59
|
"peerDependencies": {
|
|
60
60
|
"@pablo2410/shared-ui": ">=0.7.0",
|
|
@@ -1 +0,0 @@
|
|
|
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 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 // 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 } 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 } 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":["env"]}
|