@pablo2410/core-server 0.17.0 → 0.19.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
@@ -100,6 +100,27 @@ interface DbAdapter<TUser extends BaseUser = BaseUser> {
100
100
  * to the subdomain's local role enum value.
101
101
  */
102
102
  type RoleMapper = (portalRole: string) => string;
103
+ /** report: log who would be refused and let them through. enforce: refuse. */
104
+ type AppAccessMode = "report" | "enforce";
105
+ /** What a refused person is told; each app shows it as a page. */
106
+ declare const APP_NO_ACCESS_MESSAGE = "You don't have access to this app. Ask your administrator.";
107
+ interface AppAccessConfig {
108
+ /** This app's slug in the Portal's app list, e.g. "sqdcp". */
109
+ serviceSlug: string;
110
+ /**
111
+ * "report" (default): log every request that would be refused, let it
112
+ * through. "enforce": refuse with a 403, code APP_NO_ACCESS, and refuse
113
+ * too when the Portal can't be reached. Anything else counts as "report".
114
+ */
115
+ mode?: AppAccessMode;
116
+ }
117
+ interface AppAccessResult {
118
+ allowed: boolean;
119
+ mode: AppAccessMode;
120
+ /** Report mode only: this person would be refused in enforce mode. */
121
+ wouldRefuse?: boolean;
122
+ reason?: "no-grant" | "portal-unavailable";
123
+ }
103
124
  /**
104
125
  * Where the authenticated user is resolved from.
105
126
  * - "db" (default, legacy): a local users table is the working copy; the SDK
@@ -162,6 +183,15 @@ interface SubdomainAuthConfig<TUser extends BaseUser = BaseUser> {
162
183
  * session (fail open), in ms. Default 2s.
163
184
  */
164
185
  sessionCheckTimeoutMs?: number;
186
+ /**
187
+ * Check the person's app grants at the door (ops#691). Off when omitted.
188
+ * authenticateRequest then throws a 403 with `code: "APP_NO_ACCESS"` in
189
+ * enforce mode; the app's context should catch that code (it is not a
190
+ * sign-out) and show a no-access page.
191
+ */
192
+ appAccess?: AppAccessConfig;
193
+ /** How long a person's app list is reused, in ms. Default 30s. 0 = never. */
194
+ appAccessCacheTtlMs?: number;
165
195
  }
166
196
  /** The auth SDK instance returned by createSubdomainAuth. */
167
197
  interface SubdomainAuth<TUser extends BaseUser = BaseUser> {
@@ -174,6 +204,11 @@ interface SubdomainAuth<TUser extends BaseUser = BaseUser> {
174
204
  * Portal throws a 403 with `code: "SESSION_ENDED"`.
175
205
  */
176
206
  authenticateRequest(req: Request): Promise<TUser>;
207
+ /**
208
+ * The app check on its own (for apps that build their own context). Always
209
+ * allowed when `appAccess` isn't configured or in report mode.
210
+ */
211
+ checkAppAccess(user: TUser): Promise<AppAccessResult>;
177
212
  /**
178
213
  * Sign-out (#163): end the Portal session behind this request's cookie, so
179
214
  * the device is signed out of the Portal and every app, not just this one.
@@ -213,5 +248,5 @@ interface SubdomainAuth<TUser extends BaseUser = BaseUser> {
213
248
 
214
249
  declare function createSubdomainAuth<TUser extends BaseUser = BaseUser>(config: SubdomainAuthConfig<TUser>): SubdomainAuth<TUser>;
215
250
 
216
- export { createSubdomainAuth };
217
- export type { AuthEnv, BaseUser, DbAdapter, PortalUser, RoleMapper, SessionPayload, SubdomainAuth, SubdomainAuthConfig, UpsertUserPayload };
251
+ export { APP_NO_ACCESS_MESSAGE, createSubdomainAuth };
252
+ export type { AppAccessConfig, AppAccessMode, AppAccessResult, AuthEnv, BaseUser, DbAdapter, PortalUser, RoleMapper, SessionPayload, SubdomainAuth, SubdomainAuthConfig, UpsertUserPayload };
package/dist/auth.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import {
2
+ APP_NO_ACCESS_MESSAGE,
2
3
  createSubdomainAuth
3
- } from "./chunk-URYEVWI6.js";
4
+ } from "./chunk-4XNPHXO7.js";
4
5
  export {
6
+ APP_NO_ACCESS_MESSAGE,
5
7
  createSubdomainAuth
6
8
  };
7
9
  //# sourceMappingURL=auth.js.map
@@ -1,3 +1,10 @@
1
+ // src/auth/types.ts
2
+ var APP_ACCESS_ADMIN_ROLES = /* @__PURE__ */ new Set([
3
+ "enterprise_admin",
4
+ "platform_admin"
5
+ ]);
6
+ var APP_NO_ACCESS_MESSAGE = "You don't have access to this app. Ask your administrator.";
7
+
1
8
  // src/sandbox.ts
2
9
  var PRODUCTION_PORTAL_ORIGIN = "https://portal.oplyticsdigital.net";
3
10
  var PRODUCTION_MARKETING_ORIGIN = "https://oplytics.digital";
@@ -52,7 +59,9 @@ function createSubdomainAuth(config) {
52
59
  portalUserCacheTtlMs = 3e4,
53
60
  sessionCheck = true,
54
61
  sessionCheckCacheTtlMs = 3e4,
55
- sessionCheckTimeoutMs = 2e3
62
+ sessionCheckTimeoutMs = 2e3,
63
+ appAccess,
64
+ appAccessCacheTtlMs = 3e4
56
65
  } = config;
57
66
  const tag = `[${serviceName} Auth]`;
58
67
  const sandbox = isSandboxEnv();
@@ -400,7 +409,83 @@ function createSubdomainAuth(config) {
400
409
  return null;
401
410
  }
402
411
  }
412
+ const appAccessMode = appAccess?.mode === "enforce" ? "enforce" : "report";
413
+ if (appAccess?.mode && appAccess.mode !== appAccessMode) {
414
+ console.warn(
415
+ `${tag} Unknown appAccess mode "${String(appAccess.mode)}" \u2014 using report`
416
+ );
417
+ }
418
+ const appAccessCache = /* @__PURE__ */ new Map();
419
+ async function getEffectiveApps(openId) {
420
+ const hit = appAccessCache.get(openId);
421
+ if (appAccessCacheTtlMs > 0 && hit && Date.now() - hit.at < appAccessCacheTtlMs) {
422
+ return hit.slugs;
423
+ }
424
+ try {
425
+ const client = await getPortalClientAsync();
426
+ if (!client) return null;
427
+ const { data } = await client.get(
428
+ `/api/service/users/by-openid/${encodeURIComponent(openId)}/services`,
429
+ {
430
+ timeout: sessionCheckTimeoutMs,
431
+ signal: AbortSignal.timeout(sessionCheckTimeoutMs)
432
+ }
433
+ );
434
+ if (!Array.isArray(data?.services)) return null;
435
+ const slugs = data.services.filter(
436
+ (s) => typeof s === "string"
437
+ );
438
+ if (appAccessCacheTtlMs > 0) {
439
+ appAccessCache.delete(openId);
440
+ appAccessCache.set(openId, { slugs, at: Date.now() });
441
+ if (appAccessCache.size > PORTAL_USER_CACHE_MAX) {
442
+ const oldest = appAccessCache.keys().next().value;
443
+ if (oldest !== void 0) appAccessCache.delete(oldest);
444
+ }
445
+ }
446
+ return slugs;
447
+ } catch (err) {
448
+ const status = err?.response?.status;
449
+ console.warn(
450
+ `${tag} Portal app list failed for a user (${status ?? err.message})`
451
+ );
452
+ return null;
453
+ }
454
+ }
455
+ async function checkAppAccess(user) {
456
+ if (!appAccess) return { allowed: true, mode: appAccessMode };
457
+ const slug = appAccess.serviceSlug;
458
+ const mode = appAccessMode;
459
+ const who = `user=${user.portalUserId ?? user.id} company=${user.enterpriseId ?? "none"}`;
460
+ if (APP_ACCESS_ADMIN_ROLES.has(user.role)) {
461
+ return { allowed: true, mode };
462
+ }
463
+ const slugs = await getEffectiveApps(user.openId);
464
+ if (slugs?.includes(slug)) return { allowed: true, mode };
465
+ const reason = slugs ? "no-grant" : "portal-unavailable";
466
+ if (mode === "report") {
467
+ console.warn(
468
+ `${tag} APP_ACCESS_REPORT would refuse app=${slug} ${who} reason=${reason}`
469
+ );
470
+ return { allowed: true, mode, wouldRefuse: true, reason };
471
+ }
472
+ console.warn(
473
+ `${tag} APP_ACCESS_ENFORCE refused app=${slug} ${who} reason=${reason}`
474
+ );
475
+ return { allowed: false, mode, reason };
476
+ }
403
477
  async function authenticateRequest(req) {
478
+ const user = await authenticateUser(req);
479
+ const access = await checkAppAccess(user);
480
+ if (!access.allowed) {
481
+ throw Object.assign(new Error(APP_NO_ACCESS_MESSAGE), {
482
+ statusCode: 403,
483
+ code: "APP_NO_ACCESS"
484
+ });
485
+ }
486
+ return user;
487
+ }
488
+ async function authenticateUser(req) {
404
489
  const cookie = await getCookie();
405
490
  const cookies = req.headers.cookie ? new Map(Object.entries(cookie.parseCookie(req.headers.cookie))) : /* @__PURE__ */ new Map();
406
491
  const sessionCookie = cookies.get(cookieName);
@@ -498,6 +583,7 @@ function createSubdomainAuth(config) {
498
583
  return {
499
584
  verifySession,
500
585
  authenticateRequest,
586
+ checkAppAccess,
501
587
  endPortalSession,
502
588
  getPortalUser,
503
589
  getSessionCookieOptions,
@@ -511,6 +597,7 @@ export {
511
597
  isSandboxEnv,
512
598
  sandboxPortalOrigin,
513
599
  resolveMarketingOrigin,
600
+ APP_NO_ACCESS_MESSAGE,
514
601
  createSubdomainAuth
515
602
  };
516
- //# sourceMappingURL=chunk-URYEVWI6.js.map
603
+ //# sourceMappingURL=chunk-4XNPHXO7.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/auth/types.ts","../src/sandbox.ts","../src/auth/createSubdomainAuth.ts"],"sourcesContent":["/**\n * Types for the createSubdomainAuth factory.\n *\n * These types define the contract between the shared auth SDK and each\n * subdomain's local database, role mapping, and portal enrichment logic.\n */\n\nimport type { Request, CookieOptions } from \"express\";\n\n// ─── Session & JWT ──────────────────────────────────────────────────\n\n/** Payload stored inside the session JWT. */\nexport interface SessionPayload {\n openId: string;\n appId: string;\n name: string;\n}\n\n// ─── Portal User (from portal API enrichment) ──────────────────────\n\n/**\n * Minimal portal user shape returned by the portal API\n * (GET /users/by-openid/:openId). The portal exposes the enterprise as\n * `enterpriseId`; the legacy `companyId` alias is kept optional only for\n * backwards-compatibility with older portal builds.\n */\nexport interface PortalUser {\n id: number;\n openId: string;\n name: string | null;\n email: string | null;\n role: string;\n enterpriseId?: number | null;\n /** @deprecated legacy alias for enterpriseId — older portal builds only */\n companyId?: number;\n siteId?: number;\n /** Profile picture as a data URL, or null when the user has none (portal#518). */\n avatarUrl?: string | null;\n /**\n * True for a read-only account (the live check's login, ops#246): the Portal\n * says so in this payload and every app refuses its writes. Absent or false\n * for everyone else.\n */\n readOnly?: boolean;\n}\n\n// ─── Auth Environment ──────────────────────────────────────────────\n\n/** Environment variables required by the auth SDK. */\nexport interface AuthEnv {\n /** Shared portal JWT secret for cross-subdomain SSO (PORTAL_JWT_SECRET) */\n portalJwtSecret: string;\n /** Cookie domain for cross-subdomain sharing, e.g. \".oplyticsdigital.net\" */\n cookieDomain: string;\n /** Owner's openId for auto-admin assignment */\n ownerOpenId: string;\n /** Portal API base URL for user enrichment */\n portalApiUrl: string;\n /** Portal API key sent as X-Service-Key header */\n portalApiKey: string;\n}\n\n// ─── DB Adapter (subdomain provides) ───────────────────────────────\n\n/**\n * Minimal user shape that the auth SDK works with.\n * Subdomains extend this with their own fields.\n */\nexport interface BaseUser {\n id: number;\n openId: string;\n name: string | null;\n email: string | null;\n role: string;\n enterpriseId: number | null;\n portalUserId: number | null;\n /** Set (true) only for a read-only account; see PortalUser.readOnly. */\n readOnly?: boolean;\n}\n\n/**\n * Minimal upsert payload for creating/updating users.\n * Subdomains can extend with additional fields.\n */\nexport interface UpsertUserPayload {\n openId: string;\n name?: string | null;\n email?: string | null;\n loginMethod?: string | null;\n role?: string;\n lastSignedIn?: Date;\n enterpriseId?: number;\n portalUserId?: number;\n}\n\n/**\n * Database adapter that each subdomain must provide.\n * This decouples the auth SDK from any specific ORM or schema.\n */\nexport interface DbAdapter<TUser extends BaseUser = BaseUser> {\n /** Find a user by their openId. Returns null/undefined if not found. */\n getUserByOpenId(openId: string): Promise<TUser | null | undefined>;\n\n /** Create or update a user record. */\n upsertUser(payload: UpsertUserPayload): Promise<void>;\n\n /**\n * Update specific fields on an existing user by their local DB id.\n * Used for enterprise backfill after portal enrichment.\n */\n updateUserFields(\n userId: number,\n fields: Partial<Pick<BaseUser, \"enterpriseId\" | \"portalUserId\">>\n ): Promise<void>;\n}\n\n// ─── Role Mapper (subdomain provides) ──────────────────────────────\n\n/**\n * Maps a portal role string (e.g. \"platform_admin\", \"enterprise_admin\")\n * to the subdomain's local role enum value.\n */\nexport type RoleMapper = (portalRole: string) => string;\n\n// ─── App access (ops#691) ──────────────────────────────────────────\n\n/** report: log who would be refused and let them through. enforce: refuse. */\nexport type AppAccessMode = \"report\" | \"enforce\";\n\n/** Roles that pass the app check without a grant (the fleet admin rule). */\nexport const APP_ACCESS_ADMIN_ROLES: ReadonlySet<string> = new Set([\n \"enterprise_admin\",\n \"platform_admin\",\n]);\n\n/** What a refused person is told; each app shows it as a page. */\nexport const APP_NO_ACCESS_MESSAGE =\n \"You don't have access to this app. Ask your administrator.\";\n\nexport interface AppAccessConfig {\n /** This app's slug in the Portal's app list, e.g. \"sqdcp\". */\n serviceSlug: string;\n /**\n * \"report\" (default): log every request that would be refused, let it\n * through. \"enforce\": refuse with a 403, code APP_NO_ACCESS, and refuse\n * too when the Portal can't be reached. Anything else counts as \"report\".\n */\n mode?: AppAccessMode;\n}\n\nexport interface AppAccessResult {\n allowed: boolean;\n mode: AppAccessMode;\n /** Report mode only: this person would be refused in enforce mode. */\n wouldRefuse?: boolean;\n reason?: \"no-grant\" | \"portal-unavailable\";\n}\n\n// ─── Factory Config ────────────────────────────────────────────────\n\n/**\n * Where the authenticated user is resolved from.\n * - \"db\" (default, legacy): a local users table is the working copy; the SDK\n * upserts/enriches it from the Portal. Keeps a per-subdomain mirror.\n * - \"portal\": the Portal is the single source of truth. ctx.user is built\n * fresh from the Portal user record on each request (cached); nothing is\n * persisted locally and no DbAdapter is required. ctx.user.id is the PORTAL\n * user id. This is the target architecture — no per-subdomain user mirror.\n */\nexport type UserSource = \"db\" | \"portal\";\n\n/** Configuration object passed to createSubdomainAuth. */\nexport interface SubdomainAuthConfig<TUser extends BaseUser = BaseUser> {\n /** Environment variables for auth. */\n env: AuthEnv;\n\n /**\n * Where to resolve the user from. Defaults to \"db\" for backward compat.\n * Set \"portal\" to drop the local users mirror (single source of truth).\n */\n userSource?: UserSource;\n\n /**\n * Database adapter for user CRUD. Required when userSource is \"db\";\n * ignored (and may be omitted) when userSource is \"portal\".\n */\n db?: DbAdapter<TUser>;\n\n /**\n * Maps portal roles to local roles. Only used in \"db\" mode. In \"portal\"\n * mode roles are taken from the Portal verbatim (no mapping).\n */\n mapRole?: RoleMapper;\n\n /** Cookie name (defaults to \"app_session_id\"). */\n cookieName?: string;\n\n /**\n * @deprecated Has no effect. The Portal issues every session cookie (for\n * one year); this factory never signs one.\n */\n sessionExpiryMs?: number;\n\n /** Service name for logging (e.g. \"SQDCP\", \"OEE Manager\"). */\n serviceName?: string;\n\n /**\n * Portal-user cache TTL in ms (portal mode only). Avoids a Portal round-trip\n * on every request. Default 30s. Set 0 to disable caching.\n */\n portalUserCacheTtlMs?: number;\n\n /**\n * Ask the Portal whether the session behind the cookie is still live\n * (POST /api/service/sessions/check), so a device signed out in the Portal\n * is refused here too (core-server#77). Default true. Only an explicit\n * `{ active: false }` from the Portal ends a session: if the Portal is down,\n * slow or answers with an error, the session is kept (fail open).\n */\n sessionCheck?: boolean;\n\n /**\n * How long a session check answer is reused, in ms. Default 30s, which is\n * how long a signed-out device can keep working here. Set 0 to check on\n * every request.\n */\n sessionCheckCacheTtlMs?: number;\n\n /**\n * How long to wait for the Portal's session check before keeping the\n * session (fail open), in ms. Default 2s.\n */\n sessionCheckTimeoutMs?: number;\n\n /**\n * Check the person's app grants at the door (ops#691). Off when omitted.\n * authenticateRequest then throws a 403 with `code: \"APP_NO_ACCESS\"` in\n * enforce mode; the app's context should catch that code (it is not a\n * sign-out) and show a no-access page.\n */\n appAccess?: AppAccessConfig;\n\n /** How long a person's app list is reused, in ms. Default 30s. 0 = never. */\n appAccessCacheTtlMs?: number;\n}\n\n// ─── Return Type ───────────────────────────────────────────────────\n\n/** The auth SDK instance returned by createSubdomainAuth. */\nexport interface SubdomainAuth<TUser extends BaseUser = BaseUser> {\n /** Verify a session cookie value. Returns payload or null. */\n verifySession(\n cookieValue: string | undefined | null\n ): Promise<SessionPayload | null>;\n\n /**\n * Full authentication flow: verify cookie → check the session is still\n * live in the Portal → find/create user → enrich from portal → backfill\n * enterprise. Returns the local user record. A session signed out in the\n * Portal throws a 403 with `code: \"SESSION_ENDED\"`.\n */\n authenticateRequest(req: Request): Promise<TUser>;\n\n /**\n * The app check on its own (for apps that build their own context). Always\n * allowed when `appAccess` isn't configured or in report mode.\n */\n checkAppAccess(user: TUser): Promise<AppAccessResult>;\n\n /**\n * Sign-out (#163): end the Portal session behind this request's cookie, so\n * the device is signed out of the Portal and every app, not just this one.\n * Call it from `auth.logout` before clearing the cookie. Never throws, and\n * waits at most `sessionCheckTimeoutMs`. This app refuses the cookie from\n * then on. Resolves to whether the Portal ended a live session (false when\n * there was none to end), or null when the Portal gave no answer.\n */\n endPortalSession(req: Request): Promise<boolean | null>;\n\n /** Fetch a user from the portal API by openId. */\n getPortalUser(openId: string): Promise<PortalUser | null>;\n\n /** Get cookie options for setting session cookies. */\n getSessionCookieOptions(\n req: Request\n ): Pick<\n CookieOptions,\n \"domain\" | \"httpOnly\" | \"path\" | \"sameSite\" | \"secure\"\n >;\n\n /**\n * Validate a returnTo URL to prevent open-redirect attacks: a relative path,\n * or https on oplyticsdigital.net / *.oplyticsdigital.net. In the local\n * sandbox (INFISICAL_ENV=staging) also http://localhost or 127.0.0.1 on\n * ports 3000-3009.\n */\n isValidReturnUrl(url: string): boolean;\n}\n","/**\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 {\n APP_ACCESS_ADMIN_ROLES,\n APP_NO_ACCESS_MESSAGE,\n type AppAccessMode,\n type AppAccessResult,\n} from \"./types\";\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 appAccess,\n appAccessCacheTtlMs = 30_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 // ── App Access (ops#691) ───────────────────────────────────────\n //\n // Does this person's effective app list (Portal: role's apps plus their own\n // grants) include this app? Platform and enterprise admins always pass.\n // report mode (default) logs who would be refused and lets them through;\n // enforce mode refuses, and fails closed when the Portal can't answer.\n\n const appAccessMode: AppAccessMode =\n appAccess?.mode === \"enforce\" ? \"enforce\" : \"report\";\n if (appAccess?.mode && appAccess.mode !== appAccessMode) {\n console.warn(\n `${tag} Unknown appAccess mode \"${String(appAccess.mode)}\" — using report`\n );\n }\n const appAccessCache = new Map<string, { slugs: string[]; at: number }>();\n\n /** The person's effective app slugs from the Portal, or null if it gave none. */\n async function getEffectiveApps(openId: string): Promise<string[] | null> {\n const hit = appAccessCache.get(openId);\n if (\n appAccessCacheTtlMs > 0 &&\n hit &&\n Date.now() - hit.at < appAccessCacheTtlMs\n ) {\n return hit.slugs;\n }\n try {\n const client = await getPortalClientAsync();\n if (!client) return null;\n const { data } = await client.get<{ services?: unknown }>(\n `/api/service/users/by-openid/${encodeURIComponent(openId)}/services`,\n {\n timeout: sessionCheckTimeoutMs,\n signal: AbortSignal.timeout(sessionCheckTimeoutMs),\n }\n );\n if (!Array.isArray(data?.services)) return null;\n const slugs = data.services.filter(\n (s): s is string => typeof s === \"string\"\n );\n if (appAccessCacheTtlMs > 0) {\n appAccessCache.delete(openId);\n appAccessCache.set(openId, { slugs, at: Date.now() });\n if (appAccessCache.size > PORTAL_USER_CACHE_MAX) {\n const oldest = appAccessCache.keys().next().value;\n if (oldest !== undefined) appAccessCache.delete(oldest);\n }\n }\n return slugs;\n } catch (err: unknown) {\n const status = (err as { response?: { status?: number } } | null)\n ?.response?.status;\n console.warn(\n `${tag} Portal app list failed for a user (${status ?? (err as Error).message})`\n );\n return null;\n }\n }\n\n async function checkAppAccess(user: TUser): Promise<AppAccessResult> {\n if (!appAccess) return { allowed: true, mode: appAccessMode };\n const slug = appAccess.serviceSlug;\n const mode = appAccessMode;\n const who = `user=${user.portalUserId ?? user.id} company=${user.enterpriseId ?? \"none\"}`;\n if (APP_ACCESS_ADMIN_ROLES.has(user.role)) {\n return { allowed: true, mode };\n }\n const slugs = await getEffectiveApps(user.openId);\n if (slugs?.includes(slug)) return { allowed: true, mode };\n\n const reason = slugs ? \"no-grant\" : \"portal-unavailable\";\n if (mode === \"report\") {\n console.warn(\n `${tag} APP_ACCESS_REPORT would refuse app=${slug} ${who} reason=${reason}`\n );\n return { allowed: true, mode, wouldRefuse: true, reason };\n }\n console.warn(\n `${tag} APP_ACCESS_ENFORCE refused app=${slug} ${who} reason=${reason}`\n );\n return { allowed: false, mode, reason };\n }\n\n // ── Authenticate Request ───────────────────────────────────────\n\n async function authenticateRequest(req: Request): Promise<TUser> {\n const user = await authenticateUser(req);\n const access = await checkAppAccess(user);\n if (!access.allowed) {\n throw Object.assign(new Error(APP_NO_ACCESS_MESSAGE), {\n statusCode: 403,\n code: \"APP_NO_ACCESS\",\n });\n }\n return user;\n }\n\n async function authenticateUser(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 checkAppAccess,\n endPortalSession,\n getPortalUser,\n getSessionCookieOptions,\n isValidReturnUrl,\n };\n}\n"],"mappings":"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","names":["env"]}
package/dist/db.d.ts ADDED
@@ -0,0 +1,97 @@
1
+ import mysql, { Pool } from 'mysql2/promise';
2
+ import * as drizzle_orm_mysql2 from 'drizzle-orm/mysql2';
3
+
4
+ /**
5
+ * What keeps a database connection that TiDB has closed, or that has stalled,
6
+ * from failing or hanging this app (oplytics-subdomains#1285). On 21 Oct the
7
+ * test copy's Action Manager and SQDCP logged "Failed query" for creates and
8
+ * lists at random: every one was PROTOCOL_CONNECTION_LOST or ETIMEDOUT, a
9
+ * pooled connection the database had already closed. The pool had no
10
+ * keep-alive, no idle clean-up and no timeouts. The same fix has run in the
11
+ * Portal since portal#499. This is the one shared copy (core-server#137),
12
+ * without the Portal's request-level 503 flag.
13
+ *
14
+ * resilientPool() wraps a mysql2 promise pool so that:
15
+ * - waiting for a free connection ends after ACQUIRE_TIMEOUT_MS;
16
+ * - a query that gets no answer ends after READ_TIMEOUT_MS (reads) or
17
+ * WRITE_TIMEOUT_MS (everything else), and its connection is DESTROYED, not
18
+ * given back: mysql2's own `timeout` option leaves a dead connection in the
19
+ * pool, where the next query would wait on it again;
20
+ * - a read that fails on a dropped connection is tried once more on a fresh
21
+ * one. Writes are never retried: the first one may have been applied.
22
+ *
23
+ * Every limit can be set with an environment variable (DB_ACQUIRE_TIMEOUT_MS,
24
+ * DB_QUERY_TIMEOUT_MS, DB_WRITE_TIMEOUT_MS). What the user is told when the
25
+ * database can't be reached is DB_UNAVAILABLE_MESSAGE (see withDbUnavailableMessage).
26
+ */
27
+
28
+ interface Limits {
29
+ /** Longest wait for a free pooled connection. */
30
+ acquireTimeoutMs: number;
31
+ /** Longest wait for the answer to a read (SELECT, SHOW, ...). */
32
+ readTimeoutMs: number;
33
+ /** Longest wait for the answer to anything else. */
34
+ writeTimeoutMs: number;
35
+ }
36
+ declare const defaultLimits: () => Limits;
37
+ /**
38
+ * Pool settings (merged into mysql2.createPool in dbClient.ts).
39
+ * - connectTimeout: a new connection that does not come up fails in 10 s.
40
+ * - enableKeepAlive: the OS notices a peer that has vanished.
41
+ * - maxIdle < connectionLimit is what switches mysql2's idle clean-up on; it
42
+ * then closes any connection idle for over idleTimeout, so connections are
43
+ * replaced before TiDB closes them from its side.
44
+ * - queueLimit: past this many requests waiting for a connection, new ones
45
+ * fail at once instead of piling up behind stuck ones.
46
+ */
47
+ declare const POOL_TUNING: {
48
+ readonly connectionLimit: 10;
49
+ readonly maxIdle: 5;
50
+ readonly idleTimeout: 60000;
51
+ readonly connectTimeout: 10000;
52
+ readonly enableKeepAlive: true;
53
+ readonly keepAliveInitialDelay: 10000;
54
+ readonly queueLimit: 200;
55
+ };
56
+ /** What a user sees when a request failed because the database was out of reach. */
57
+ declare const DB_UNAVAILABLE_MESSAGE = "The database could not be reached just now. Please try again in a moment. If you were saving a change, check whether it was saved before you try again.";
58
+ declare class DbUnavailableError extends Error {
59
+ code: "DB_ACQUIRE_TIMEOUT" | "DB_QUEUE_FULL" | "DB_QUERY_TIMEOUT";
60
+ constructor(code: "DB_ACQUIRE_TIMEOUT" | "DB_QUEUE_FULL" | "DB_QUERY_TIMEOUT", message: string);
61
+ }
62
+ declare const isDbUnavailable: (err: unknown) => boolean;
63
+ /**
64
+ * For tRPC's errorFormatter: a call that failed because the database was out
65
+ * of reach tells the user so in plain words, never with the SQL that failed.
66
+ * Every other error keeps its message.
67
+ */
68
+ declare function withDbUnavailableMessage<S extends {
69
+ message: string;
70
+ }>(shape: S, error: unknown): S;
71
+ declare function resilientPool<T extends Pool>(pool: T, overrides?: Partial<Limits>): T;
72
+
73
+ /**
74
+ * Create a Drizzle client from a DATABASE_URL, enabling TLS when the host
75
+ * requires it. TiDB Cloud Serverless enforces TLS + SNI, so a bare `mysql://`
76
+ * URL is rejected ("Connections using insecure transport are prohibited") and
77
+ * the failure surfaces at query time as a generic "Failed query" error. For
78
+ * those hosts we connect with TLS; hosts that don't need TLS (e.g. plain local
79
+ * MySQL) use the connection string as it is.
80
+ *
81
+ * Set DATABASE_TLS=true to force TLS for a non-tidbcloud.com host.
82
+ */
83
+ declare function connectionOptions(url: string): string | mysql.PoolOptions;
84
+ /**
85
+ * Always a pool we configure ourselves (never drizzle's bare `drizzle(url)`
86
+ * one, which has no keep-alive or timeouts): connect, keep-alive, idle and
87
+ * queue limits from POOL_TUNING, and a wrapper that retries a read once on a
88
+ * fresh connection when TiDB has closed the old one, and ends a wait for a dead
89
+ * connection (resilience.ts, oplytics-subdomains#1285). `overrides`
90
+ * and `limits` are for tests.
91
+ */
92
+ declare function createDbClient(url: string, overrides?: Partial<mysql.PoolOptions>, limits?: Partial<Limits>): drizzle_orm_mysql2.MySql2Database<Record<string, never>> & {
93
+ $client: mysql.Pool;
94
+ };
95
+
96
+ export { DB_UNAVAILABLE_MESSAGE, DbUnavailableError, POOL_TUNING, connectionOptions, createDbClient, defaultLimits, isDbUnavailable, resilientPool, withDbUnavailableMessage };
97
+ export type { Limits };
package/dist/db.js ADDED
@@ -0,0 +1,208 @@
1
+ // src/db/resilience.ts
2
+ var envMs = (name, fallback) => {
3
+ const n = Number(process.env[name]);
4
+ return Number.isFinite(n) && n > 0 ? n : fallback;
5
+ };
6
+ var defaultLimits = () => ({
7
+ acquireTimeoutMs: envMs("DB_ACQUIRE_TIMEOUT_MS", 1e4),
8
+ readTimeoutMs: envMs("DB_QUERY_TIMEOUT_MS", 15e3),
9
+ writeTimeoutMs: envMs("DB_WRITE_TIMEOUT_MS", 6e4)
10
+ });
11
+ var POOL_TUNING = {
12
+ connectionLimit: 10,
13
+ maxIdle: 5,
14
+ idleTimeout: 6e4,
15
+ connectTimeout: 1e4,
16
+ enableKeepAlive: true,
17
+ keepAliveInitialDelay: 1e4,
18
+ queueLimit: 200
19
+ };
20
+ var DB_UNAVAILABLE_MESSAGE = "The database could not be reached just now. Please try again in a moment. If you were saving a change, check whether it was saved before you try again.";
21
+ var RETRY_CODES = /* @__PURE__ */ new Set([
22
+ "PROTOCOL_CONNECTION_LOST",
23
+ "PROTOCOL_ENQUEUE_AFTER_FATAL_ERROR",
24
+ "PROTOCOL_ENQUEUE_AFTER_QUIT",
25
+ "ECONNRESET",
26
+ "EPIPE",
27
+ "ETIMEDOUT",
28
+ "ECONNREFUSED",
29
+ "EAI_AGAIN",
30
+ "DB_QUERY_TIMEOUT"
31
+ ]);
32
+ var UNAVAILABLE_CODES = /* @__PURE__ */ new Set([
33
+ ...Array.from(RETRY_CODES),
34
+ "ENOTFOUND",
35
+ "DB_ACQUIRE_TIMEOUT",
36
+ "DB_QUEUE_FULL"
37
+ ]);
38
+ var DbUnavailableError = class extends Error {
39
+ constructor(code, message) {
40
+ super(message);
41
+ this.code = code;
42
+ this.name = "DbUnavailableError";
43
+ }
44
+ code;
45
+ };
46
+ function codes(err) {
47
+ const found = [];
48
+ let cur = err;
49
+ for (let depth = 0; cur && depth < 5; depth++) {
50
+ if (typeof cur.code === "string") found.push(cur.code);
51
+ cur = cur.cause;
52
+ }
53
+ return found;
54
+ }
55
+ var isDbUnavailable = (err) => codes(err).some((c) => UNAVAILABLE_CODES.has(c));
56
+ function withDbUnavailableMessage(shape, error) {
57
+ return isDbUnavailable(error) ? { ...shape, message: DB_UNAVAILABLE_MESSAGE } : shape;
58
+ }
59
+ var isRetryable = (err) => codes(err).some((c) => RETRY_CODES.has(c));
60
+ var READ_ONLY = /^\s*(select|show|describe|desc|explain)\b/i;
61
+ var sqlOf = (first) => typeof first === "string" ? first : String(first?.sql ?? "");
62
+ function dropIdleConnections(pool) {
63
+ try {
64
+ const free = pool.pool?._freeConnections;
65
+ if (!free || typeof free.get !== "function") return;
66
+ for (let guard = 0; free.length > 0 && guard < 100; guard++) {
67
+ free.get(0).destroy();
68
+ }
69
+ } catch {
70
+ }
71
+ }
72
+ function resilientPool(pool, overrides = {}) {
73
+ const limits = { ...defaultLimits(), ...overrides };
74
+ const acquire = async () => {
75
+ let timer;
76
+ let gaveUp = false;
77
+ const pending = pool.getConnection();
78
+ pending.then(
79
+ (conn) => {
80
+ if (gaveUp) conn.release();
81
+ },
82
+ () => void 0
83
+ );
84
+ const deadline = new Promise((_, reject) => {
85
+ timer = setTimeout(() => {
86
+ gaveUp = true;
87
+ reject(
88
+ new DbUnavailableError(
89
+ "DB_ACQUIRE_TIMEOUT",
90
+ `No database connection came free within ${limits.acquireTimeoutMs} ms`
91
+ )
92
+ );
93
+ }, limits.acquireTimeoutMs);
94
+ });
95
+ try {
96
+ return await Promise.race([pending, deadline]);
97
+ } catch (err) {
98
+ if (err instanceof Error && /queue limit reached/i.test(err.message)) {
99
+ throw new DbUnavailableError(
100
+ "DB_QUEUE_FULL",
101
+ "Too many requests are waiting for a database connection"
102
+ );
103
+ }
104
+ throw err;
105
+ } finally {
106
+ clearTimeout(timer);
107
+ }
108
+ };
109
+ const attempt = async (method, first, values, ms) => {
110
+ const conn = await acquire();
111
+ let timer;
112
+ let abandoned = false;
113
+ const deadline = new Promise((_, reject) => {
114
+ timer = setTimeout(() => {
115
+ abandoned = true;
116
+ conn.destroy();
117
+ dropIdleConnections(pool);
118
+ reject(
119
+ new DbUnavailableError(
120
+ "DB_QUERY_TIMEOUT",
121
+ `The database did not answer within ${ms} ms`
122
+ )
123
+ );
124
+ }, ms);
125
+ });
126
+ try {
127
+ const work = conn[method](first, values);
128
+ work.catch(() => void 0);
129
+ const out = await Promise.race([work, deadline]);
130
+ conn.release();
131
+ return out;
132
+ } catch (err) {
133
+ if (!abandoned) conn.release();
134
+ throw err;
135
+ } finally {
136
+ clearTimeout(timer);
137
+ }
138
+ };
139
+ const run = async (method, first, values) => {
140
+ const read = READ_ONLY.test(sqlOf(first));
141
+ const ms = read ? limits.readTimeoutMs : limits.writeTimeoutMs;
142
+ try {
143
+ try {
144
+ return await attempt(method, first, values, ms);
145
+ } catch (err) {
146
+ if (!isRetryable(err)) throw err;
147
+ dropIdleConnections(pool);
148
+ if (!read) throw err;
149
+ console.warn(
150
+ `[Database] read failed (${codes(err)[0]}); trying once on a fresh connection`
151
+ );
152
+ return await attempt(method, first, values, ms);
153
+ }
154
+ } catch (err) {
155
+ if (isDbUnavailable(err)) {
156
+ console.error(
157
+ `[Database] unavailable (${codes(err)[0]}): ${err.message}`
158
+ );
159
+ }
160
+ throw err;
161
+ }
162
+ };
163
+ return new Proxy(pool, {
164
+ get(target, prop) {
165
+ if (prop === "query" || prop === "execute") {
166
+ return (first, values) => run(prop, first, values);
167
+ }
168
+ if (prop === "getConnection") return acquire;
169
+ const value = Reflect.get(target, prop, target);
170
+ return typeof value === "function" ? value.bind(target) : value;
171
+ }
172
+ });
173
+ }
174
+
175
+ // src/db/client.ts
176
+ import { drizzle } from "drizzle-orm/mysql2";
177
+ import mysql from "mysql2/promise";
178
+ function connectionOptions(url) {
179
+ const requiresTls = /tidbcloud\.com/i.test(url) || process.env.DATABASE_TLS === "true";
180
+ if (!requiresTls) return url;
181
+ const parsed = new URL(url);
182
+ return {
183
+ host: parsed.hostname,
184
+ port: parsed.port ? Number(parsed.port) : 4e3,
185
+ user: decodeURIComponent(parsed.username),
186
+ password: decodeURIComponent(parsed.password),
187
+ database: parsed.pathname.replace(/^\//, ""),
188
+ ssl: { minVersion: "TLSv1.2" }
189
+ };
190
+ }
191
+ function createDbClient(url, overrides = {}, limits = {}) {
192
+ const options = connectionOptions(url);
193
+ const base = typeof options === "string" ? { uri: options } : options;
194
+ const pool = mysql.createPool({ ...base, ...POOL_TUNING, ...overrides });
195
+ return drizzle(resilientPool(pool, limits));
196
+ }
197
+ export {
198
+ DB_UNAVAILABLE_MESSAGE,
199
+ DbUnavailableError,
200
+ POOL_TUNING,
201
+ connectionOptions,
202
+ createDbClient,
203
+ defaultLimits,
204
+ isDbUnavailable,
205
+ resilientPool,
206
+ withDbUnavailableMessage
207
+ };
208
+ //# sourceMappingURL=db.js.map
package/dist/db.js.map ADDED
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/db/resilience.ts","../src/db/client.ts"],"sourcesContent":["/**\n * What keeps a database connection that TiDB has closed, or that has stalled,\n * from failing or hanging this app (oplytics-subdomains#1285). On 21 Oct the\n * test copy's Action Manager and SQDCP logged \"Failed query\" for creates and\n * lists at random: every one was PROTOCOL_CONNECTION_LOST or ETIMEDOUT, a\n * pooled connection the database had already closed. The pool had no\n * keep-alive, no idle clean-up and no timeouts. The same fix has run in the\n * Portal since portal#499. This is the one shared copy (core-server#137),\n * without the Portal's request-level 503 flag.\n *\n * resilientPool() wraps a mysql2 promise pool so that:\n * - waiting for a free connection ends after ACQUIRE_TIMEOUT_MS;\n * - a query that gets no answer ends after READ_TIMEOUT_MS (reads) or\n * WRITE_TIMEOUT_MS (everything else), and its connection is DESTROYED, not\n * given back: mysql2's own `timeout` option leaves a dead connection in the\n * pool, where the next query would wait on it again;\n * - a read that fails on a dropped connection is tried once more on a fresh\n * one. Writes are never retried: the first one may have been applied.\n *\n * Every limit can be set with an environment variable (DB_ACQUIRE_TIMEOUT_MS,\n * DB_QUERY_TIMEOUT_MS, DB_WRITE_TIMEOUT_MS). What the user is told when the\n * database can't be reached is DB_UNAVAILABLE_MESSAGE (see withDbUnavailableMessage).\n */\nimport type { Pool, PoolConnection } from \"mysql2/promise\";\n\nconst envMs = (name: string, fallback: number) => {\n const n = Number(process.env[name]);\n return Number.isFinite(n) && n > 0 ? n : fallback;\n};\n\nexport interface Limits {\n /** Longest wait for a free pooled connection. */\n acquireTimeoutMs: number;\n /** Longest wait for the answer to a read (SELECT, SHOW, ...). */\n readTimeoutMs: number;\n /** Longest wait for the answer to anything else. */\n writeTimeoutMs: number;\n}\n\nexport const defaultLimits = (): Limits => ({\n acquireTimeoutMs: envMs(\"DB_ACQUIRE_TIMEOUT_MS\", 10_000),\n readTimeoutMs: envMs(\"DB_QUERY_TIMEOUT_MS\", 15_000),\n writeTimeoutMs: envMs(\"DB_WRITE_TIMEOUT_MS\", 60_000),\n});\n\n/**\n * Pool settings (merged into mysql2.createPool in dbClient.ts).\n * - connectTimeout: a new connection that does not come up fails in 10 s.\n * - enableKeepAlive: the OS notices a peer that has vanished.\n * - maxIdle < connectionLimit is what switches mysql2's idle clean-up on; it\n * then closes any connection idle for over idleTimeout, so connections are\n * replaced before TiDB closes them from its side.\n * - queueLimit: past this many requests waiting for a connection, new ones\n * fail at once instead of piling up behind stuck ones.\n */\nexport const POOL_TUNING = {\n connectionLimit: 10,\n maxIdle: 5,\n idleTimeout: 60_000,\n connectTimeout: 10_000,\n enableKeepAlive: true,\n keepAliveInitialDelay: 10_000,\n queueLimit: 200,\n} as const;\n\n/** What a user sees when a request failed because the database was out of reach. */\nexport const DB_UNAVAILABLE_MESSAGE =\n \"The database could not be reached just now. Please try again in a moment. If you were saving a change, check whether it was saved before you try again.\";\n\n// ─── telling a dropped database from a bad query ────────────────────────────\n\n/** Errors that mean \"that connection is gone\"; a read is worth one retry. */\nconst RETRY_CODES = new Set([\n \"PROTOCOL_CONNECTION_LOST\",\n \"PROTOCOL_ENQUEUE_AFTER_FATAL_ERROR\",\n \"PROTOCOL_ENQUEUE_AFTER_QUIT\",\n \"ECONNRESET\",\n \"EPIPE\",\n \"ETIMEDOUT\",\n \"ECONNREFUSED\",\n \"EAI_AGAIN\",\n \"DB_QUERY_TIMEOUT\",\n]);\n/** Everything that means \"the database cannot be reached right now\". */\nconst UNAVAILABLE_CODES = new Set([\n ...Array.from(RETRY_CODES),\n \"ENOTFOUND\",\n \"DB_ACQUIRE_TIMEOUT\",\n \"DB_QUEUE_FULL\",\n]);\n\nexport class DbUnavailableError extends Error {\n constructor(\n public code: \"DB_ACQUIRE_TIMEOUT\" | \"DB_QUEUE_FULL\" | \"DB_QUERY_TIMEOUT\",\n message: string\n ) {\n super(message);\n this.name = \"DbUnavailableError\";\n }\n}\n\n/** drizzle wraps a driver error and keeps the original as `cause`. */\nfunction codes(err: unknown): string[] {\n const found: string[] = [];\n let cur = err as { code?: unknown; cause?: unknown } | undefined;\n for (let depth = 0; cur && depth < 5; depth++) {\n if (typeof cur.code === \"string\") found.push(cur.code);\n cur = cur.cause as typeof cur | undefined;\n }\n return found;\n}\n\nexport const isDbUnavailable = (err: unknown) =>\n codes(err).some(c => UNAVAILABLE_CODES.has(c));\n\n/**\n * For tRPC's errorFormatter: a call that failed because the database was out\n * of reach tells the user so in plain words, never with the SQL that failed.\n * Every other error keeps its message.\n */\nexport function withDbUnavailableMessage<S extends { message: string }>(\n shape: S,\n error: unknown\n): S {\n return isDbUnavailable(error)\n ? { ...shape, message: DB_UNAVAILABLE_MESSAGE }\n : shape;\n}\nconst isRetryable = (err: unknown) => codes(err).some(c => RETRY_CODES.has(c));\n\nconst READ_ONLY = /^\\s*(select|show|describe|desc|explain)\\b/i;\nconst sqlOf = (first: unknown): string =>\n typeof first === \"string\"\n ? first\n : String((first as { sql?: unknown } | null)?.sql ?? \"\");\n\n// ─── the pool wrapper ───────────────────────────────────────────────────────\n\n/**\n * Close every connection that is sitting idle in the pool. When one connection\n * has stopped answering, the idle ones behind it have usually stopped too (the\n * network stalled, not one socket), and each would cost a request a timeout\n * before it was found out. mysql2 keeps them in a private queue and closes\n * idle ones the same way itself; if that shape ever changes this does nothing.\n */\nfunction dropIdleConnections(pool: Pool) {\n try {\n const free = (\n pool as unknown as {\n pool?: {\n _freeConnections?: { length: number; get(i: number): PoolConnection };\n };\n }\n ).pool?._freeConnections;\n if (!free || typeof free.get !== \"function\") return;\n for (let guard = 0; free.length > 0 && guard < 100; guard++) {\n free.get(0).destroy();\n }\n } catch {\n /* best effort */\n }\n}\n\nexport function resilientPool<T extends Pool>(\n pool: T,\n overrides: Partial<Limits> = {}\n): T {\n const limits = { ...defaultLimits(), ...overrides };\n\n /** A free connection, or an error after acquireTimeoutMs. */\n const acquire = async (): Promise<PoolConnection> => {\n let timer: NodeJS.Timeout | undefined;\n let gaveUp = false;\n const pending = pool.getConnection();\n // A connection that turns up after we gave up must go back, not leak.\n pending.then(\n conn => {\n if (gaveUp) conn.release();\n },\n () => undefined\n );\n const deadline = new Promise<never>((_, reject) => {\n timer = setTimeout(() => {\n gaveUp = true;\n reject(\n new DbUnavailableError(\n \"DB_ACQUIRE_TIMEOUT\",\n `No database connection came free within ${limits.acquireTimeoutMs} ms`\n )\n );\n }, limits.acquireTimeoutMs);\n });\n try {\n return await Promise.race([pending, deadline]);\n } catch (err) {\n if (err instanceof Error && /queue limit reached/i.test(err.message)) {\n throw new DbUnavailableError(\n \"DB_QUEUE_FULL\",\n \"Too many requests are waiting for a database connection\"\n );\n }\n throw err;\n } finally {\n clearTimeout(timer);\n }\n };\n\n /** One try on one connection. A connection that does not answer is destroyed. */\n const attempt = async (\n method: \"query\" | \"execute\",\n first: unknown,\n values: unknown,\n ms: number\n ) => {\n const conn = await acquire();\n let timer: NodeJS.Timeout | undefined;\n let abandoned = false;\n const deadline = new Promise<never>((_, reject) => {\n timer = setTimeout(() => {\n abandoned = true;\n conn.destroy();\n dropIdleConnections(pool);\n reject(\n new DbUnavailableError(\n \"DB_QUERY_TIMEOUT\",\n `The database did not answer within ${ms} ms`\n )\n );\n }, ms);\n });\n try {\n const work = (\n conn[method] as (a: unknown, b?: unknown) => Promise<unknown>\n )(first, values);\n // The destroyed connection fails this later; nobody is waiting for it.\n work.catch(() => undefined);\n const out = await Promise.race([work, deadline]);\n conn.release();\n return out;\n } catch (err) {\n if (!abandoned) conn.release();\n throw err;\n } finally {\n clearTimeout(timer);\n }\n };\n\n const run = async (\n method: \"query\" | \"execute\",\n first: unknown,\n values?: unknown\n ) => {\n const read = READ_ONLY.test(sqlOf(first));\n const ms = read ? limits.readTimeoutMs : limits.writeTimeoutMs;\n try {\n try {\n return await attempt(method, first, values, ms);\n } catch (err) {\n if (!isRetryable(err)) throw err;\n // A blip that drops one connection drops the ones idle beside it too,\n // and the pool hands out the next idle one unchecked: close them, so\n // the retry (or, for a write, the user's own retry) gets a fresh one.\n dropIdleConnections(pool);\n if (!read) throw err;\n console.warn(\n `[Database] read failed (${codes(err)[0]}); trying once on a fresh connection`\n );\n return await attempt(method, first, values, ms);\n }\n } catch (err) {\n if (isDbUnavailable(err)) {\n console.error(\n `[Database] unavailable (${codes(err)[0]}): ${(err as Error).message}`\n );\n }\n throw err;\n }\n };\n\n return new Proxy(pool, {\n get(target, prop) {\n if (prop === \"query\" || prop === \"execute\") {\n return (first: unknown, values?: unknown) => run(prop, first, values);\n }\n if (prop === \"getConnection\") return acquire;\n const value = Reflect.get(target, prop, target);\n return typeof value === \"function\" ? value.bind(target) : value;\n },\n });\n}\n","/* Shared Drizzle client factory. */\nimport { drizzle } from \"drizzle-orm/mysql2\";\nimport mysql from \"mysql2/promise\";\nimport { POOL_TUNING, resilientPool, type Limits } from \"./resilience\";\n\n/**\n * Create a Drizzle client from a DATABASE_URL, enabling TLS when the host\n * requires it. TiDB Cloud Serverless enforces TLS + SNI, so a bare `mysql://`\n * URL is rejected (\"Connections using insecure transport are prohibited\") and\n * the failure surfaces at query time as a generic \"Failed query\" error. For\n * those hosts we connect with TLS; hosts that don't need TLS (e.g. plain local\n * MySQL) use the connection string as it is.\n *\n * Set DATABASE_TLS=true to force TLS for a non-tidbcloud.com host.\n */\nexport function connectionOptions(url: string): string | mysql.PoolOptions {\n const requiresTls =\n /tidbcloud\\.com/i.test(url) || process.env.DATABASE_TLS === \"true\";\n if (!requiresTls) return url;\n const parsed = new URL(url);\n return {\n host: parsed.hostname,\n port: parsed.port ? Number(parsed.port) : 4000,\n user: decodeURIComponent(parsed.username),\n password: decodeURIComponent(parsed.password),\n database: parsed.pathname.replace(/^\\//, \"\"),\n ssl: { minVersion: \"TLSv1.2\" },\n };\n}\n\n/**\n * Always a pool we configure ourselves (never drizzle's bare `drizzle(url)`\n * one, which has no keep-alive or timeouts): connect, keep-alive, idle and\n * queue limits from POOL_TUNING, and a wrapper that retries a read once on a\n * fresh connection when TiDB has closed the old one, and ends a wait for a dead\n * connection (resilience.ts, oplytics-subdomains#1285). `overrides`\n * and `limits` are for tests.\n */\nexport function createDbClient(\n url: string,\n overrides: Partial<mysql.PoolOptions> = {},\n limits: Partial<Limits> = {}\n) {\n const options = connectionOptions(url);\n const base = typeof options === \"string\" ? { uri: options } : options;\n const pool = mysql.createPool({ ...base, ...POOL_TUNING, ...overrides });\n return drizzle(resilientPool(pool, limits));\n}\n"],"mappings":";AAyBA,IAAM,QAAQ,CAAC,MAAc,aAAqB;AAChD,QAAM,IAAI,OAAO,QAAQ,IAAI,IAAI,CAAC;AAClC,SAAO,OAAO,SAAS,CAAC,KAAK,IAAI,IAAI,IAAI;AAC3C;AAWO,IAAM,gBAAgB,OAAe;AAAA,EAC1C,kBAAkB,MAAM,yBAAyB,GAAM;AAAA,EACvD,eAAe,MAAM,uBAAuB,IAAM;AAAA,EAClD,gBAAgB,MAAM,uBAAuB,GAAM;AACrD;AAYO,IAAM,cAAc;AAAA,EACzB,iBAAiB;AAAA,EACjB,SAAS;AAAA,EACT,aAAa;AAAA,EACb,gBAAgB;AAAA,EAChB,iBAAiB;AAAA,EACjB,uBAAuB;AAAA,EACvB,YAAY;AACd;AAGO,IAAM,yBACX;AAKF,IAAM,cAAc,oBAAI,IAAI;AAAA,EAC1B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAED,IAAM,oBAAoB,oBAAI,IAAI;AAAA,EAChC,GAAG,MAAM,KAAK,WAAW;AAAA,EACzB;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAEM,IAAM,qBAAN,cAAiC,MAAM;AAAA,EAC5C,YACS,MACP,SACA;AACA,UAAM,OAAO;AAHN;AAIP,SAAK,OAAO;AAAA,EACd;AAAA,EALS;AAMX;AAGA,SAAS,MAAM,KAAwB;AACrC,QAAM,QAAkB,CAAC;AACzB,MAAI,MAAM;AACV,WAAS,QAAQ,GAAG,OAAO,QAAQ,GAAG,SAAS;AAC7C,QAAI,OAAO,IAAI,SAAS,SAAU,OAAM,KAAK,IAAI,IAAI;AACrD,UAAM,IAAI;AAAA,EACZ;AACA,SAAO;AACT;AAEO,IAAM,kBAAkB,CAAC,QAC9B,MAAM,GAAG,EAAE,KAAK,OAAK,kBAAkB,IAAI,CAAC,CAAC;AAOxC,SAAS,yBACd,OACA,OACG;AACH,SAAO,gBAAgB,KAAK,IACxB,EAAE,GAAG,OAAO,SAAS,uBAAuB,IAC5C;AACN;AACA,IAAM,cAAc,CAAC,QAAiB,MAAM,GAAG,EAAE,KAAK,OAAK,YAAY,IAAI,CAAC,CAAC;AAE7E,IAAM,YAAY;AAClB,IAAM,QAAQ,CAAC,UACb,OAAO,UAAU,WACb,QACA,OAAQ,OAAoC,OAAO,EAAE;AAW3D,SAAS,oBAAoB,MAAY;AACvC,MAAI;AACF,UAAM,OACJ,KAKA,MAAM;AACR,QAAI,CAAC,QAAQ,OAAO,KAAK,QAAQ,WAAY;AAC7C,aAAS,QAAQ,GAAG,KAAK,SAAS,KAAK,QAAQ,KAAK,SAAS;AAC3D,WAAK,IAAI,CAAC,EAAE,QAAQ;AAAA,IACtB;AAAA,EACF,QAAQ;AAAA,EAER;AACF;AAEO,SAAS,cACd,MACA,YAA6B,CAAC,GAC3B;AACH,QAAM,SAAS,EAAE,GAAG,cAAc,GAAG,GAAG,UAAU;AAGlD,QAAM,UAAU,YAAqC;AACnD,QAAI;AACJ,QAAI,SAAS;AACb,UAAM,UAAU,KAAK,cAAc;AAEnC,YAAQ;AAAA,MACN,UAAQ;AACN,YAAI,OAAQ,MAAK,QAAQ;AAAA,MAC3B;AAAA,MACA,MAAM;AAAA,IACR;AACA,UAAM,WAAW,IAAI,QAAe,CAAC,GAAG,WAAW;AACjD,cAAQ,WAAW,MAAM;AACvB,iBAAS;AACT;AAAA,UACE,IAAI;AAAA,YACF;AAAA,YACA,2CAA2C,OAAO,gBAAgB;AAAA,UACpE;AAAA,QACF;AAAA,MACF,GAAG,OAAO,gBAAgB;AAAA,IAC5B,CAAC;AACD,QAAI;AACF,aAAO,MAAM,QAAQ,KAAK,CAAC,SAAS,QAAQ,CAAC;AAAA,IAC/C,SAAS,KAAK;AACZ,UAAI,eAAe,SAAS,uBAAuB,KAAK,IAAI,OAAO,GAAG;AACpE,cAAM,IAAI;AAAA,UACR;AAAA,UACA;AAAA,QACF;AAAA,MACF;AACA,YAAM;AAAA,IACR,UAAE;AACA,mBAAa,KAAK;AAAA,IACpB;AAAA,EACF;AAGA,QAAM,UAAU,OACd,QACA,OACA,QACA,OACG;AACH,UAAM,OAAO,MAAM,QAAQ;AAC3B,QAAI;AACJ,QAAI,YAAY;AAChB,UAAM,WAAW,IAAI,QAAe,CAAC,GAAG,WAAW;AACjD,cAAQ,WAAW,MAAM;AACvB,oBAAY;AACZ,aAAK,QAAQ;AACb,4BAAoB,IAAI;AACxB;AAAA,UACE,IAAI;AAAA,YACF;AAAA,YACA,sCAAsC,EAAE;AAAA,UAC1C;AAAA,QACF;AAAA,MACF,GAAG,EAAE;AAAA,IACP,CAAC;AACD,QAAI;AACF,YAAM,OACJ,KAAK,MAAM,EACX,OAAO,MAAM;AAEf,WAAK,MAAM,MAAM,MAAS;AAC1B,YAAM,MAAM,MAAM,QAAQ,KAAK,CAAC,MAAM,QAAQ,CAAC;AAC/C,WAAK,QAAQ;AACb,aAAO;AAAA,IACT,SAAS,KAAK;AACZ,UAAI,CAAC,UAAW,MAAK,QAAQ;AAC7B,YAAM;AAAA,IACR,UAAE;AACA,mBAAa,KAAK;AAAA,IACpB;AAAA,EACF;AAEA,QAAM,MAAM,OACV,QACA,OACA,WACG;AACH,UAAM,OAAO,UAAU,KAAK,MAAM,KAAK,CAAC;AACxC,UAAM,KAAK,OAAO,OAAO,gBAAgB,OAAO;AAChD,QAAI;AACF,UAAI;AACF,eAAO,MAAM,QAAQ,QAAQ,OAAO,QAAQ,EAAE;AAAA,MAChD,SAAS,KAAK;AACZ,YAAI,CAAC,YAAY,GAAG,EAAG,OAAM;AAI7B,4BAAoB,IAAI;AACxB,YAAI,CAAC,KAAM,OAAM;AACjB,gBAAQ;AAAA,UACN,2BAA2B,MAAM,GAAG,EAAE,CAAC,CAAC;AAAA,QAC1C;AACA,eAAO,MAAM,QAAQ,QAAQ,OAAO,QAAQ,EAAE;AAAA,MAChD;AAAA,IACF,SAAS,KAAK;AACZ,UAAI,gBAAgB,GAAG,GAAG;AACxB,gBAAQ;AAAA,UACN,2BAA2B,MAAM,GAAG,EAAE,CAAC,CAAC,MAAO,IAAc,OAAO;AAAA,QACtE;AAAA,MACF;AACA,YAAM;AAAA,IACR;AAAA,EACF;AAEA,SAAO,IAAI,MAAM,MAAM;AAAA,IACrB,IAAI,QAAQ,MAAM;AAChB,UAAI,SAAS,WAAW,SAAS,WAAW;AAC1C,eAAO,CAAC,OAAgB,WAAqB,IAAI,MAAM,OAAO,MAAM;AAAA,MACtE;AACA,UAAI,SAAS,gBAAiB,QAAO;AACrC,YAAM,QAAQ,QAAQ,IAAI,QAAQ,MAAM,MAAM;AAC9C,aAAO,OAAO,UAAU,aAAa,MAAM,KAAK,MAAM,IAAI;AAAA,IAC5D;AAAA,EACF,CAAC;AACH;;;AChSA,SAAS,eAAe;AACxB,OAAO,WAAW;AAaX,SAAS,kBAAkB,KAAyC;AACzE,QAAM,cACJ,kBAAkB,KAAK,GAAG,KAAK,QAAQ,IAAI,iBAAiB;AAC9D,MAAI,CAAC,YAAa,QAAO;AACzB,QAAM,SAAS,IAAI,IAAI,GAAG;AAC1B,SAAO;AAAA,IACL,MAAM,OAAO;AAAA,IACb,MAAM,OAAO,OAAO,OAAO,OAAO,IAAI,IAAI;AAAA,IAC1C,MAAM,mBAAmB,OAAO,QAAQ;AAAA,IACxC,UAAU,mBAAmB,OAAO,QAAQ;AAAA,IAC5C,UAAU,OAAO,SAAS,QAAQ,OAAO,EAAE;AAAA,IAC3C,KAAK,EAAE,YAAY,UAAU;AAAA,EAC/B;AACF;AAUO,SAAS,eACd,KACA,YAAwC,CAAC,GACzC,SAA0B,CAAC,GAC3B;AACA,QAAM,UAAU,kBAAkB,GAAG;AACrC,QAAM,OAAO,OAAO,YAAY,WAAW,EAAE,KAAK,QAAQ,IAAI;AAC9D,QAAM,OAAO,MAAM,WAAW,EAAE,GAAG,MAAM,GAAG,aAAa,GAAG,UAAU,CAAC;AACvE,SAAO,QAAQ,cAAc,MAAM,MAAM,CAAC;AAC5C;","names":[]}
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  isSandboxEnv,
6
6
  resolveMarketingOrigin,
7
7
  sandboxPortalOrigin
8
- } from "./chunk-URYEVWI6.js";
8
+ } from "./chunk-4XNPHXO7.js";
9
9
  import {
10
10
  HierarchyRefusedError,
11
11
  createHierarchyService
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pablo2410/core-server",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "description": "Shared server utilities for Oplytics.digital subdomains",
5
5
  "type": "module",
6
6
  "exports": {
@@ -19,6 +19,10 @@
19
19
  "./isolation": {
20
20
  "types": "./dist/isolation.d.ts",
21
21
  "import": "./dist/isolation.js"
22
+ },
23
+ "./db": {
24
+ "types": "./dist/db.d.ts",
25
+ "import": "./dist/db.js"
22
26
  }
23
27
  },
24
28
  "files": [
@@ -46,10 +50,12 @@
46
50
  "@types/node": "^26.6.6",
47
51
  "axios": "^1.21.0",
48
52
  "cookie": "^2.0.1",
49
- "eslint": "^10.13.0",
53
+ "drizzle-orm": "^0.45.4",
54
+ "eslint": "^10.14.0",
50
55
  "express": "^5.3.0",
51
56
  "globals": "^17.13.0",
52
57
  "jose": "^6.2.14",
58
+ "mysql2": "^3.24.6",
53
59
  "prettier": "^3.9.10",
54
60
  "tsup": "^9.0.0",
55
61
  "typescript": "^5.6.0",
@@ -62,7 +68,9 @@
62
68
  "axios": ">=1.0.0",
63
69
  "cookie": ">=0.6.0",
64
70
  "express": ">=4.0.0",
65
- "jose": ">=5.0.0"
71
+ "jose": ">=5.0.0",
72
+ "drizzle-orm": ">=0.30.0",
73
+ "mysql2": ">=3.0.0"
66
74
  },
67
75
  "peerDependenciesMeta": {
68
76
  "@pablo2410/shared-ui": {
@@ -79,6 +87,12 @@
79
87
  },
80
88
  "axios": {
81
89
  "optional": true
90
+ },
91
+ "drizzle-orm": {
92
+ "optional": true
93
+ },
94
+ "mysql2": {
95
+ "optional": true
82
96
  }
83
97
  },
84
98
  "pnpm": {
@@ -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 // 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":";AAaO,IAAM,2BAA2B;AACjC,IAAM,8BAA8B;AACpC,IAAM,wBAAwB;AAC9B,IAAM,2BAA2B;AAExC,IAAM,MAAM,CAAC,SACX,OAAO,YAAY,cAAc,SAAY,QAAQ,IAAI,IAAI;AAGxD,SAAS,aACd,eAAmC,IAAI,eAAe,GAC7C;AACT,SAAO,iBAAiB;AAC1B;AAMO,SAAS,kBAAkB,KAAmB;AACnD,SACE,IAAI,aAAa,YAChB,IAAI,aAAa,eAAe,IAAI,aAAa,gBAClD,UAAU,KAAK,IAAI,IAAI;AAE3B;AAGA,SAAS,SAAS,OAA0C;AAC1D,QAAM,UAAU,OAAO,KAAK;AAC5B,MAAI,CAAC,QAAS,QAAO;AACrB,MAAI;AACF,WAAO,IAAI,IAAI,OAAO,EAAE;AAAA,EAC1B,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAOO,SAAS,oBACd,iBAAqC,IAAI,kBAAkB,GACnD;AACR,SAAO,SAAS,cAAc,KAAK;AACrC;AAOO,SAAS,uBACd,aAAiC,IAAI,kBAAkB,GACvD,eAAmC,IAAI,eAAe,GAC9C;AACR,SACE,SAAS,UAAU,MAClB,aAAa,YAAY,IACtB,2BACA;AAER;;;AClDA,IAAM,sBAAsB;AAE5B,IAAM,yBAAyB;AAE/B,IAAM,0BAA0B;AAEhC,IAAM,wBAAwB;AAI9B,IAAM,mBAAmB,CAAC,UACxB,OAAO,UAAU,YAAY,MAAM,SAAS;AAS9C,SAAS,gBAAgB,KAAc,KAA6B;AAClE,QAAM,UACJ,OAAO,QAAQ,WACX,MACA,OAAO,QAAQ,WACb,MAAM,yBACN;AACR,MAAI,YAAY,QAAQ,CAAC,OAAO,SAAS,OAAO,KAAK,WAAW,GAAG;AACjE,WAAO;AAAA,EACT;AACA,SAAO,KAAK,MAAM,OAAO;AAC3B;AAaO,SAAS,oBACd,QACsB;AACtB,QAAM;AAAA,IACJ,KAAAA;AAAA,IACA;AAAA;AAAA;AAAA,IAGA,UAAU,CAAC,SAAiB;AAAA,IAC5B,aAAa;AAAA,IACb,aAAa;AAAA,IACb,cAAc;AAAA,IACd,uBAAuB;AAAA,IACvB,eAAe;AAAA,IACf,yBAAyB;AAAA,IACzB,wBAAwB;AAAA,EAC1B,IAAI;AAEJ,QAAM,MAAM,IAAI,WAAW;AAG3B,QAAM,UAAU,aAAa;AAE7B,MAAI,eAAe,QAAQ,CAAC,IAAI;AAC9B,UAAM,IAAI;AAAA,MACR,GAAG,GAAG;AAAA,IACR;AAAA,EACF;AAIA,QAAM,kBAAkB,oBAAI,IAA8C;AAM1E,MAAI,QAAsC;AAC1C,iBAAe,UAAU;AACvB,QAAI,CAAC,MAAO,SAAQ,MAAM,OAAO,MAAM;AACvC,WAAO;AAAA,EACT;AAEA,MAAI,UAA0C;AAC9C,iBAAe,YAAY;AACzB,QAAI,CAAC,QAAS,WAAU,MAAM,OAAO,QAAQ;AAC7C,WAAO;AAAA,EACT;AAEA,MAAI,SAAgD;AACpD,iBAAe,WAAW;AACxB,QAAI,CAAC,QAAQ;AACX,YAAM,MAAM,MAAM,OAAO,OAAO;AAChC,eAAS,IAAI;AAAA,IACf;AACA,WAAO;AAAA,EACT;AAIA,MAAI,eAAqD;AAEzD,iBAAe,uBAAuB;AACpC,QAAI,aAAc,QAAO;AACzB,QAAI,CAACA,KAAI,aAAc,QAAO;AAC9B,UAAM,QAAQ,MAAM,SAAS;AAC7B,mBAAe,MAAM,OAAO;AAAA,MAC1B,SAASA,KAAI;AAAA,MACb,SAAS;AAAA,MACT,SAAS;AAAA,QACP,gBAAgB;AAAA,QAChB,GAAIA,KAAI,eAAe,EAAE,iBAAiBA,KAAI,aAAa,IAAI,CAAC;AAAA,MAClE;AAAA,IACF,CAAC;AACD,WAAO;AAAA,EACT;AAIA,WAAS,kBAAkB;AACzB,UAAM,SAASA,KAAI;AACnB,QAAI,CAAC,OAAQ,OAAM,IAAI,MAAM,qCAAqC;AAClE,WAAO,IAAI,YAAY,EAAE,OAAO,MAAM;AAAA,EACxC;AAIA,WAAS,kBAAsC;AAC7C,WAAOA,KAAI,gBAAgB;AAAA,EAC7B;AAEA,WAAS,gBAAgB,KAAuB;AAC9C,QAAI,IAAI,aAAa,QAAS,QAAO;AACrC,UAAM,iBAAiB,IAAI,QAAQ,mBAAmB;AACtD,QAAI,CAAC,eAAgB,QAAO;AAC5B,UAAM,YAAY,MAAM,QAAQ,cAAc,IAC1C,iBACA,eAAe,MAAM,GAAG;AAC5B,WAAO,UAAU,KAAK,OAAK,EAAE,KAAK,EAAE,YAAY,MAAM,OAAO;AAAA,EAC/D;AAEA,WAAS,wBACP,KAIA;AACA,UAAM,SAAS,gBAAgB,GAAG;AAClC,UAAM,SAAS,gBAAgB;AAC/B,WAAO;AAAA,MACL;AAAA,MACA,UAAU;AAAA,MACV,MAAM;AAAA,MACN,UAAU;AAAA,MACV;AAAA,IACF;AAAA,EACF;AAEA,WAAS,iBAAiB,KAAsB;AAC9C,QAAI,IAAI,WAAW,GAAG,KAAK,CAAC,IAAI,WAAW,IAAI,EAAG,QAAO;AACzD,QAAI;AACF,YAAM,SAAS,IAAI,IAAI,GAAG;AAC1B,UAAI,WAAW,kBAAkB,MAAM,EAAG,QAAO;AACjD,UAAI,OAAO,aAAa,SAAU,QAAO;AACzC,aACE,OAAO,aAAa,yBACpB,OAAO,SAAS,SAAS,sBAAsB;AAAA,IAEnD,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AAYA,iBAAe,aACb,aACgC;AAChC,QAAI,CAAC,aAAa;AAChB,cAAQ,KAAK,GAAG,GAAG,yBAAyB;AAC5C,aAAO;AAAA,IACT;AAEA,UAAM,OAAO,MAAM,QAAQ;AAE3B,QAAI;AACF,YAAM,YAAY,gBAAgB;AAClC,YAAM,EAAE,QAAQ,IAAI,MAAM,KAAK,UAAU,aAAa,WAAW;AAAA,QAC/D,YAAY,CAAC,OAAO;AAAA,MACtB,CAAC;AACD,YAAM,EAAE,QAAQ,OAAO,MAAM,OAAO,KAAK,KAAK,IAAI,IAAI;AAOtD,UAAI,CAAC,iBAAiB,MAAM,KAAK,CAAC,iBAAiB,KAAK,GAAG;AACzD,gBAAQ,KAAK,GAAG,GAAG,6CAA6C;AAChE,eAAO;AAAA,MACT;AACA,aAAO;AAAA,QACL,SAAS;AAAA,UACP;AAAA,UACA;AAAA,UACA,MAAM,iBAAiB,IAAI,IACvB,OACA,iBAAiB,KAAK,IACpB,QACA;AAAA,QACR;AAAA,QACA,GAAI,iBAAiB,GAAG,IAAI,EAAE,IAAI,IAAI,CAAC;AAAA,QACvC,KAAK,gBAAgB,KAAK,GAAG;AAAA,MAC/B;AAAA,IACF,SAAS,OAAO;AACd,cAAQ;AAAA,QACN,GAAG,GAAG;AAAA,QACL,MAAgB;AAAA,MACnB;AACA,aAAO;AAAA,IACT;AAAA,EACF;AAEA,iBAAe,cACb,aACgC;AAChC,YAAQ,MAAM,aAAa,WAAW,IAAI,WAAW;AAAA,EACvD;AAOA,QAAM,gBAAgB,oBAAI,IAAY;AAEtC,iBAAe,cAAc,QAA4C;AACvE,QAAI;AACF,YAAM,SAAS,MAAM,qBAAqB;AAC1C,UAAI,CAAC,OAAQ,QAAO;AAOpB,YAAM,EAAE,KAAK,IAAI,MAAM,OAAO;AAAA,QAC5B,gCAAgC,mBAAmB,MAAM,CAAC;AAAA,MAC5D;AACA,UAAI,MAAM;AACR,YAAI,KAAK,aAAa,KAAM,eAAc,IAAI,MAAM;AAAA,YAC/C,eAAc,OAAO,MAAM;AAAA,MAClC;AACA,aAAO,QAAQ;AAAA,IACjB,SAAS,KAAc;AACrB,YAAM,SAAU,KAAa,UAAU;AACvC,UAAI,WAAW,KAAK;AAClB,gBAAQ;AAAA,UACN,GAAG,GAAG;AAAA,QACR;AAAA,MACF,WAAW,WAAW,KAAK;AAAA,MAE3B,OAAO;AACL,gBAAQ;AAAA,UACN,GAAG,GAAG;AAAA,UACL,IAAc;AAAA,QACjB;AAAA,MACF;AACA,aAAO;AAAA,IACT;AAAA,EACF;AAGA,iBAAe,oBACb,QAC4B;AAC5B,QAAI,uBAAuB,GAAG;AAC5B,YAAM,MAAM,gBAAgB,IAAI,MAAM;AACtC,UAAI,OAAO,KAAK,IAAI,IAAI,IAAI,KAAK,sBAAsB;AACrD,eAAO,IAAI;AAAA,MACb;AAAA,IACF;AACA,UAAM,OAAO,MAAM,cAAc,MAAM;AACvC,QAAI,QAAQ,uBAAuB,GAAG;AAEpC,sBAAgB,OAAO,MAAM;AAC7B,sBAAgB,IAAI,QAAQ,EAAE,MAAM,IAAI,KAAK,IAAI,EAAE,CAAC;AACpD,UAAI,gBAAgB,OAAO,uBAAuB;AAChD,cAAM,SAAS,gBAAgB,KAAK,EAAE,KAAK,EAAE;AAC7C,YAAI,WAAW,OAAW,iBAAgB,OAAO,MAAM;AAAA,MACzD;AAAA,IACF;AACA,WAAO;AAAA,EACT;AAgBA,iBAAe,kBACb,QACA,SACgB;AAChB,UAAM,aAAa,MAAM,oBAAoB,MAAM;AAEnD,QAAI,CAAC,YAAY;AACf,cAAQ;AAAA,QACN,GAAG,GAAG,gCAAgC,MAAM;AAAA,MAC9C;AACA,aAAO;AAAA,QACL,IAAI;AAAA,QACJ;AAAA,QACA,MAAM,QAAQ,QAAQ;AAAA,QACtB,OAAO;AAAA,QACP,MAAM;AAAA,QACN,cAAc;AAAA,QACd,cAAc;AAAA;AAAA,QAEd,GAAI,cAAc,IAAI,MAAM,IAAI,EAAE,UAAU,KAAK,IAAI,CAAC;AAAA,MACxD;AAAA,IACF;AAEA,UAAM,eACJ,WAAW,gBAAgB,WAAW,aAAa;AACrD,WAAO;AAAA,MACL,IAAI,WAAW;AAAA,MACf,QAAQ,WAAW,UAAU;AAAA,MAC7B,MAAM,WAAW,QAAQ,QAAQ,QAAQ;AAAA,MACzC,OAAO,WAAW,SAAS;AAAA,MAC3B,MAAM,WAAW;AAAA,MACjB;AAAA,MACA,cAAc,WAAW;AAAA,MACzB,WAAW,WAAW,aAAa;AAAA;AAAA,MAEnC,GAAI,WAAW,aAAa,OAAO,EAAE,UAAU,KAAK,IAAI,CAAC;AAAA,IAC3D;AAAA,EACF;AAeA,QAAM,oBAAoB,oBAAI,IAA6C;AAC3E,QAAM,uBAAuB,oBAAI,IAA8B;AAC/D,QAAM,SAAS,oBAAI,IAAY;AAE/B,WAAS,SAAS,KAAa,SAAiB;AAC9C,QAAI,OAAO,IAAI,GAAG,EAAG;AACrB,WAAO,IAAI,GAAG;AACd,YAAQ,KAAK,OAAO;AAAA,EACtB;AAEA,WAAS,qBAAqB,KAAa,QAAiB;AAE1D,sBAAkB,OAAO,GAAG;AAC5B,sBAAkB,IAAI,KAAK,EAAE,QAAQ,IAAI,KAAK,IAAI,EAAE,CAAC;AACrD,QAAI,kBAAkB,OAAO,yBAAyB;AACpD,YAAM,SAAS,kBAAkB,KAAK,EAAE,KAAK,EAAE;AAC/C,UAAI,WAAW,OAAW,mBAAkB,OAAO,MAAM;AAAA,IAC3D;AAAA,EACF;AAQA,iBAAe,uBAAuB,MAIV;AAC1B,QAAI;AACF,YAAM,SAAS,MAAM,qBAAqB;AAC1C,UAAI,CAAC,OAAQ,QAAO;AACpB,YAAM,EAAE,KAAK,IAAI,MAAM,OAAO;AAAA,QAC5B;AAAA,QACA;AAAA,QACA;AAAA;AAAA;AAAA,UAGE,SAAS;AAAA,UACT,QAAQ,YAAY,QAAQ,qBAAqB;AAAA,QACnD;AAAA,MACF;AACA,UAAI,OAAO,MAAM,WAAW,UAAW,QAAO,KAAK;AACnD,cAAQ;AAAA,QACN,GAAG,GAAG,4CAA4C,KAAK,MAAM;AAAA,MAC/D;AACA,aAAO;AAAA,IACT,SAAS,KAAc;AACrB,YAAM,SAAU,KAAa,UAAU;AACvC,UAAI,WAAW,KAAK;AAClB;AAAA,UACE;AAAA,UACA,GAAG,GAAG;AAAA,QACR;AAAA,MACF,WAAW,WAAW,KAAK;AACzB,gBAAQ;AAAA,UACN,GAAG,GAAG;AAAA,QACR;AAAA,MACF,OAAO;AACL,gBAAQ;AAAA,UACN,GAAG,GAAG,oCAAoC,KAAK,MAAM,KAAK,UAAW,IAAc,OAAO;AAAA,QAC5F;AAAA,MACF;AACA,aAAO;AAAA,IACT;AAAA,EACF;AAGA,iBAAe,gBAAgB,QAA0C;AACvE,QAAI,CAAC,aAAc,QAAO;AAC1B,QAAI,CAACA,KAAI,cAAc;AACrB;AAAA,QACE;AAAA,QACA,GAAG,GAAG;AAAA,MACR;AACA,aAAO;AAAA,IACT;AACA,UAAM,EAAE,OAAO,IAAI,OAAO;AAC1B,QAAI,OAAO,QAAQ,MAAM;AACvB;AAAA,QACE;AAAA,QACA,GAAG,GAAG,uBAAuB,MAAM;AAAA,MACrC;AACA,aAAO;AAAA,IACT;AAEA,UAAM,MAAM,KAAK,UAAU,CAAC,QAAQ,OAAO,OAAO,MAAM,OAAO,GAAG,CAAC;AACnE,UAAM,MAAM,kBAAkB,IAAI,GAAG;AACrC,QAAI,OAAO,KAAK,IAAI,IAAI,IAAI,KAAK,uBAAwB,QAAO,IAAI;AAGpE,QAAI,UAAU,qBAAqB,IAAI,GAAG;AAC1C,QAAI,CAAC,SAAS;AACZ,gBAAU,uBAAuB;AAAA,QAC/B;AAAA,QACA,GAAI,OAAO,MAAM,EAAE,KAAK,OAAO,IAAI,IAAI,CAAC;AAAA,QACxC,KAAK,OAAO;AAAA,MACd,CAAC,EACE,KAAK,YAAU;AAGd,cAAM,SAAS,UAAU,KAAK,UAAU;AACxC,6BAAqB,KAAK,MAAM;AAChC,eAAO;AAAA,MACT,CAAC,EACA,QAAQ,MAAM,qBAAqB,OAAO,GAAG,CAAC;AACjD,2BAAqB,IAAI,KAAK,OAAO;AAAA,IACvC;AACA,WAAO;AAAA,EACT;AAKA,WAAS,YAAY,KAAc,MAAc,KAAa;AAC5D,UAAM,MAAM,IAAI,QAAQ,IAAI;AAC5B,UAAM,SAAS,MAAM,QAAQ,GAAG,IAAI,IAAI,CAAC,IAAI,MAAM,KAAK;AACxD,WAAO,QAAQ,MAAM,MAAM,GAAG,GAAG,IAAI;AAAA,EACvC;AAcA,iBAAe,iBAAiB,KAAuC;AACrE,QAAI;AACJ,QAAI;AACF,YAAM,SAAS,MAAM,UAAU;AAC/B,YAAM,QAAQ,IAAI,QAAQ,SACtB,OAAO,YAAY,IAAI,QAAQ,MAAM,EAAE,UAAU,IACjD;AACJ,UAAI,CAAC,MAAO,QAAO;AACnB,YAAM,WAAW,MAAM,aAAa,KAAK;AAEzC,UAAI,CAAC,UAAU,IAAK,QAAO;AAC3B,eAAS,SAAS,QAAQ;AAC1B,UAAI,SAAS,QAAQ,MAAM;AACzB;AAAA,UACE,KAAK,UAAU,CAAC,QAAQ,SAAS,KAAK,SAAS,GAAG,CAAC;AAAA,UACnD;AAAA,QACF;AAAA,MACF;AACA,UAAI,CAACA,KAAI,cAAc;AACrB;AAAA,UACE;AAAA,UACA,GAAG,GAAG;AAAA,QACR;AACA,eAAO;AAAA,MACT;AACA,YAAM,KACJ,YAAY,KAAK,oBAAoB,EAAE,MACtC,IAAI,KAAK,OAAO,IAAI,EAAE,EAAE,MAAM,GAAG,EAAE,IAAI;AAC1C,YAAM,YAAY,YAAY,KAAK,cAAc,GAAG;AACpD,YAAM,SAAS,MAAM,qBAAqB;AAC1C,UAAI,CAAC,OAAQ,QAAO;AACpB,YAAM,EAAE,KAAK,IAAI,MAAM,OAAO;AAAA,QAC5B;AAAA,QACA;AAAA,UACE,QAAQ;AAAA,UACR,KAAK,YAAY,MAAM,GAAG,EAAE;AAAA,UAC5B,GAAI,KAAK,EAAE,GAAG,IAAI,CAAC;AAAA,UACnB,GAAI,YAAY,EAAE,UAAU,IAAI,CAAC;AAAA,QACnC;AAAA,QACA;AAAA,UACE,SAAS;AAAA,UACT,QAAQ,YAAY,QAAQ,qBAAqB;AAAA,QACnD;AAAA,MACF;AACA,UAAI,OAAO,MAAM,UAAU,UAAW,QAAO,KAAK;AAClD,cAAQ;AAAA,QACN,GAAG,GAAG,oDAAoD,MAAM;AAAA,MAClE;AACA,aAAO;AAAA,IACT,SAAS,KAAc;AACrB,YAAM,SAAU,KACZ,UAAU;AACd,UAAI,WAAW,KAAK;AAClB;AAAA,UACE;AAAA,UACA,GAAG,GAAG;AAAA,QACR;AAAA,MACF,OAAO;AACL,gBAAQ;AAAA,UACN,GAAG,GAAG,yCAAyC,UAAU,QAAQ,KAAK,UAAW,IAAc,OAAO;AAAA,QACxG;AAAA,MACF;AACA,aAAO;AAAA,IACT;AAAA,EACF;AAIA,iBAAe,oBAAoB,KAA8B;AAC/D,UAAM,SAAS,MAAM,UAAU;AAC/B,UAAM,UAAU,IAAI,QAAQ,SACxB,IAAI,IAAI,OAAO,QAAQ,OAAO,YAAY,IAAI,QAAQ,MAAM,CAAC,CAAC,IAC9D,oBAAI,IAAgC;AAExC,UAAM,gBAAgB,QAAQ,IAAI,UAAU;AAC5C,UAAM,WAAW,MAAM,aAAa,aAAa;AAEjD,QAAI,CAAC,UAAU;AACb,YAAM,OAAO,OAAO,IAAI,MAAM,wBAAwB,GAAG;AAAA,QACvD,YAAY;AAAA,MACd,CAAC;AAAA,IACH;AAGA,QAAI,CAAE,MAAM,gBAAgB,QAAQ,GAAI;AACtC,YAAM,OAAO,OAAO,IAAI,MAAM,eAAe,GAAG;AAAA,QAC9C,YAAY;AAAA,QACZ,MAAM;AAAA,MACR,CAAC;AAAA,IACH;AAEA,UAAM,EAAE,QAAQ,IAAI;AAEpB,UAAM,gBAAgB,QAAQ;AAC9B,UAAM,aAAa,oBAAI,KAAK;AAG5B,QAAI,eAAe,UAAU;AAC3B,aAAO,MAAM,kBAAkB,eAAe,OAAO;AAAA,IACvD;AAEA,QAAI,OAAQ,MAAM,GAAI,gBAAgB,aAAa,KAAM;AAGzD,QAAI,CAAC,MAAM;AACT,UAAI;AAEF,cAAM,aAAa,MAAM,cAAc,aAAa;AAEpD,YAAI,YAAY;AACd,kBAAQ;AAAA,YACN,GAAG,GAAG,oDAAoD,WAAW,SAAS,aAAa;AAAA,UAC7F;AAEA,gBAAM,qBACJ,WAAW,gBAAgB,WAAW,aAAa;AACrD,gBAAM,GAAI,WAAW;AAAA,YACnB,QAAQ;AAAA,YACR,MAAM,WAAW,QAAQ,QAAQ,QAAQ;AAAA,YACzC,OAAO,WAAW,SAAS;AAAA,YAC3B,aAAa;AAAA,YACb,MAAM,QAAQ,WAAW,IAAI;AAAA,YAC7B,cAAc;AAAA,YACd,GAAI,sBAAsB,OACtB;AAAA,cACE,cAAc;AAAA,cACd,cAAc,WAAW;AAAA,YAC3B,IACA,CAAC;AAAA,UACP,CAAC;AACD,iBAAQ,MAAM,GAAI,gBAAgB,aAAa,KAAM;AAAA,QACvD,OAAO;AAEL,kBAAQ;AAAA,YACN,GAAG,GAAG,yDAAyD,aAAa;AAAA,UAC9E;AACA,gBAAM,GAAI,WAAW;AAAA,YACnB,QAAQ;AAAA,YACR,MAAM,QAAQ,QAAQ;AAAA,YACtB,aAAa;AAAA,YACb,cAAc;AAAA,UAChB,CAAC;AACD,iBAAQ,MAAM,GAAI,gBAAgB,aAAa,KAAM;AAAA,QACvD;AAAA,MACF,SAAS,OAAO;AACd,gBAAQ,MAAM,GAAG,GAAG,8BAA8B,KAAK;AACvD,cAAM,OAAO,OAAO,IAAI,MAAM,0BAA0B,GAAG;AAAA,UACzD,YAAY;AAAA,QACd,CAAC;AAAA,MACH;AAAA,IACF;AAEA,QAAI,CAAC,MAAM;AACT,YAAM,OAAO,OAAO,IAAI,MAAM,gBAAgB,GAAG,EAAE,YAAY,IAAI,CAAC;AAAA,IACtE;AAGA,QAAI,KAAK,iBAAiB,MAAM;AAC9B,UAAI;AACF,cAAM,aAAa,MAAM,cAAc,KAAK,MAAM;AAElD,cAAM,qBACJ,YAAY,gBAAgB,YAAY,aAAa;AACvD,YAAI,sBAAsB,MAAM;AAC9B,kBAAQ;AAAA,YACN,GAAG,GAAG,6BAA6B,kBAAkB,aAAa,KAAK,EAAE;AAAA,UAC3E;AACA,gBAAM,GAAI,iBAAiB,KAAK,IAAI;AAAA,YAClC,cAAc;AAAA,YACd,GAAI,YAAY,KAAK,EAAE,cAAc,WAAW,GAAG,IAAI,CAAC;AAAA,UAC1D,CAAC;AACD,iBAAQ,MAAM,GAAI,gBAAgB,KAAK,MAAM,KAAM;AAAA,QACrD;AAAA,MACF,SAAS,eAAe;AACtB,gBAAQ;AAAA,UACN,GAAG,GAAG,6CAA6C,KAAK,EAAE;AAAA,UAC1D;AAAA,QACF;AAAA,MAEF;AAAA,IACF;AAGA,UAAM,GAAI,WAAW;AAAA,MACnB,QAAQ,KAAK;AAAA,MACb,cAAc;AAAA,IAChB,CAAC;AAKD,UAAM,aAAa,MAAM,oBAAoB,KAAK,MAAM;AACxD,UAAM,WAAW,aACb,WAAW,aAAa,OACxB,cAAc,IAAI,KAAK,MAAM;AACjC,WAAO,WAAW,EAAE,GAAG,MAAM,UAAU,KAAK,IAAI;AAAA,EAClD;AAIA,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF;AACF;","names":["env"]}