@pablo2410/core-server 0.4.2 → 0.5.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
@@ -114,20 +114,46 @@ interface DbAdapter<TUser extends BaseUser = BaseUser> {
114
114
  * to the subdomain's local role enum value.
115
115
  */
116
116
  type RoleMapper = (portalRole: string) => string;
117
+ /**
118
+ * Where the authenticated user is resolved from.
119
+ * - "db" (default, legacy): a local users table is the working copy; the SDK
120
+ * upserts/enriches it from the Portal. Keeps a per-subdomain mirror.
121
+ * - "portal": the Portal is the single source of truth. ctx.user is built
122
+ * fresh from the Portal user record on each request (cached); nothing is
123
+ * persisted locally and no DbAdapter is required. ctx.user.id is the PORTAL
124
+ * user id. This is the target architecture — no per-subdomain user mirror.
125
+ */
126
+ type UserSource = "db" | "portal";
117
127
  /** Configuration object passed to createSubdomainAuth. */
118
128
  interface SubdomainAuthConfig<TUser extends BaseUser = BaseUser> {
119
129
  /** Environment variables for auth. */
120
130
  env: AuthEnv;
121
- /** Database adapter for user CRUD. */
122
- db: DbAdapter<TUser>;
123
- /** Maps portal roles to local roles. */
124
- mapRole: RoleMapper;
131
+ /**
132
+ * Where to resolve the user from. Defaults to "db" for backward compat.
133
+ * Set "portal" to drop the local users mirror (single source of truth).
134
+ */
135
+ userSource?: UserSource;
136
+ /**
137
+ * Database adapter for user CRUD. Required when userSource is "db";
138
+ * ignored (and may be omitted) when userSource is "portal".
139
+ */
140
+ db?: DbAdapter<TUser>;
141
+ /**
142
+ * Maps portal roles to local roles. Only used in "db" mode. In "portal"
143
+ * mode roles are taken from the Portal verbatim (no mapping).
144
+ */
145
+ mapRole?: RoleMapper;
125
146
  /** Cookie name (defaults to "app_session_id"). */
126
147
  cookieName?: string;
127
148
  /** Session expiry in ms (defaults to 1 year). */
128
149
  sessionExpiryMs?: number;
129
150
  /** Service name for logging (e.g. "SQDCP", "OEE Manager"). */
130
151
  serviceName?: string;
152
+ /**
153
+ * Portal-user cache TTL in ms (portal mode only). Avoids a Portal round-trip
154
+ * on every request. Default 30s. Set 0 to disable caching.
155
+ */
156
+ portalUserCacheTtlMs?: number;
131
157
  }
132
158
  /** The auth SDK instance returned by createSubdomainAuth. */
133
159
  interface SubdomainAuth<TUser extends BaseUser = BaseUser> {
package/dist/auth.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  createSubdomainAuth
3
- } from "./chunk-23I5TW53.js";
3
+ } from "./chunk-E2U7DKNZ.js";
4
4
  export {
5
5
  createSubdomainAuth
6
6
  };
@@ -25,12 +25,22 @@ function createSubdomainAuth(config) {
25
25
  const {
26
26
  env,
27
27
  db,
28
- mapRole,
28
+ // Default to identity (no role translation). Subdomains should adopt the
29
+ // Portal's canonical roles verbatim; a custom mapper is legacy.
30
+ mapRole = (role) => role,
31
+ userSource = "db",
29
32
  cookieName = DEFAULT_COOKIE_NAME,
30
33
  sessionExpiryMs = ONE_YEAR_MS,
31
- serviceName = "Subdomain"
34
+ serviceName = "Subdomain",
35
+ portalUserCacheTtlMs = 3e4
32
36
  } = config;
33
37
  const tag = `[${serviceName} Auth]`;
38
+ if (userSource === "db" && !db) {
39
+ throw new Error(
40
+ `${tag} userSource "db" requires a db adapter in createSubdomainAuth config`
41
+ );
42
+ }
43
+ const portalUserCache = /* @__PURE__ */ new Map();
34
44
  let _jose = null;
35
45
  async function getJose() {
36
46
  if (!_jose) _jose = await import("jose");
@@ -201,6 +211,46 @@ function createSubdomainAuth(config) {
201
211
  return null;
202
212
  }
203
213
  }
214
+ async function getPortalUserCached(openId) {
215
+ if (portalUserCacheTtlMs > 0) {
216
+ const hit = portalUserCache.get(openId);
217
+ if (hit && Date.now() - hit.at < portalUserCacheTtlMs) {
218
+ return hit.user;
219
+ }
220
+ }
221
+ const user = await getPortalUser(openId);
222
+ if (user && portalUserCacheTtlMs > 0) {
223
+ portalUserCache.set(openId, { user, at: Date.now() });
224
+ }
225
+ return user;
226
+ }
227
+ async function resolvePortalUser(openId, session) {
228
+ const portalUser = await getPortalUserCached(openId);
229
+ if (portalUser) {
230
+ const enterpriseId = portalUser.enterpriseId ?? portalUser.companyId ?? null;
231
+ return {
232
+ id: portalUser.id,
233
+ openId: portalUser.openId ?? openId,
234
+ name: portalUser.name ?? session.name ?? null,
235
+ email: portalUser.email ?? null,
236
+ role: portalUser.role,
237
+ enterpriseId,
238
+ portalUserId: portalUser.id
239
+ };
240
+ }
241
+ console.warn(
242
+ `${tag} Portal user unavailable for ${openId}; using session claims (no role/enterprise)`
243
+ );
244
+ return {
245
+ id: 0,
246
+ openId,
247
+ name: session.name ?? null,
248
+ email: null,
249
+ role: "",
250
+ enterpriseId: null,
251
+ portalUserId: null
252
+ };
253
+ }
204
254
  async function exchangeCodeForToken(code, state) {
205
255
  const client = await getOAuthClient();
206
256
  const redirectUri = atob(state);
@@ -251,6 +301,9 @@ function createSubdomainAuth(config) {
251
301
  }
252
302
  const sessionUserId = session.openId;
253
303
  const signedInAt = /* @__PURE__ */ new Date();
304
+ if (userSource === "portal") {
305
+ return await resolvePortalUser(sessionUserId, session);
306
+ }
254
307
  let user = await db.getUserByOpenId(sessionUserId) ?? null;
255
308
  if (!user) {
256
309
  try {
@@ -396,4 +449,4 @@ function createSubdomainAuth(config) {
396
449
  export {
397
450
  createSubdomainAuth
398
451
  };
399
- //# sourceMappingURL=chunk-23I5TW53.js.map
452
+ //# sourceMappingURL=chunk-E2U7DKNZ.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/auth/createSubdomainAuth.ts"],"sourcesContent":["/**\n * createSubdomainAuth — shared SSO auth factory for Oplytics.digital subdomains.\n *\n * Encapsulates:\n * - JWT session verification (PORTAL_JWT_SECRET first, fallback to JWT_SECRET)\n * - Cookie domain handling via COOKIE_DOMAIN env var\n * - Portal API user enrichment with X-Service-Key header\n * - Auto-create user from JWT payload if not in local DB\n * - Enterprise backfill from portal API\n * - Manus OAuth code exchange and user info\n * - OAuth callback route registration\n *\n * Based on SQDCP's battle-tested sdk.ts + cookies.ts.\n */\n\nimport type { Request, CookieOptions } from \"express\";\nimport type {\n AuthEnv,\n BaseUser,\n DbAdapter,\n ExchangeTokenResponse,\n GetUserInfoResponse,\n GetUserInfoWithJwtResponse,\n PortalUser,\n RoleMapper,\n SessionPayload,\n SubdomainAuth,\n SubdomainAuthConfig,\n} from \"./types\";\n\n// ─── Constants ──────────────────────────────────────────────────────\n\nconst DEFAULT_COOKIE_NAME = \"app_session_id\";\nconst ONE_YEAR_MS = 1000 * 60 * 60 * 24 * 365;\nconst AXIOS_TIMEOUT_MS = 30_000;\n\nconst EXCHANGE_TOKEN_PATH =\n \"/webdev.v1.WebDevAuthPublicService/ExchangeToken\";\nconst GET_USER_INFO_PATH =\n \"/webdev.v1.WebDevAuthPublicService/GetUserInfo\";\nconst GET_USER_INFO_WITH_JWT_PATH =\n \"/webdev.v1.WebDevAuthPublicService/GetUserInfoWithJwt\";\n\n// ─── Helpers ────────────────────────────────────────────────────────\n\nconst isNonEmptyString = (value: unknown): value is string =>\n typeof value === \"string\" && value.length > 0;\n\n/**\n * Derive the login method from the Manus platforms array.\n */\nfunction deriveLoginMethod(\n platforms: unknown,\n fallback: string | null | undefined\n): string | null {\n if (fallback && fallback.length > 0) return fallback;\n if (!Array.isArray(platforms) || platforms.length === 0) return null;\n const set = new Set<string>(\n platforms.filter((p): p is string => typeof p === \"string\")\n );\n if (set.has(\"REGISTERED_PLATFORM_EMAIL\")) return \"email\";\n if (set.has(\"REGISTERED_PLATFORM_GOOGLE\")) return \"google\";\n if (set.has(\"REGISTERED_PLATFORM_APPLE\")) return \"apple\";\n if (\n set.has(\"REGISTERED_PLATFORM_MICROSOFT\") ||\n set.has(\"REGISTERED_PLATFORM_AZURE\")\n )\n return \"microsoft\";\n if (set.has(\"REGISTERED_PLATFORM_GITHUB\")) return \"github\";\n const first = Array.from(set)[0];\n return first ? first.toLowerCase() : 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 sessionExpiryMs = ONE_YEAR_MS,\n serviceName = \"Subdomain\",\n portalUserCacheTtlMs = 30_000,\n } = config;\n\n const tag = `[${serviceName} Auth]`;\n\n if (userSource === \"db\" && !db) {\n throw new Error(\n `${tag} userSource \"db\" requires a db adapter in createSubdomainAuth config`\n );\n }\n\n // Short-TTL cache of Portal users (portal mode), keyed by openId, so we don't\n // hit the Portal on every request.\n const portalUserCache = new Map<string, { user: PortalUser; at: number }>();\n\n // ── Lazy-loaded dependencies (jose, cookie, axios) ──────────────\n // We use dynamic imports so consumers don't need to install these\n // as peer deps if they only use other core-server features.\n\n let _jose: typeof import(\"jose\") | null = null;\n async function getJose() {\n if (!_jose) _jose = await import(\"jose\");\n return _jose;\n }\n\n let _cookie: typeof import(\"cookie\") | null = null;\n async function getCookie() {\n if (!_cookie) _cookie = await import(\"cookie\");\n return _cookie;\n }\n\n let _axios: typeof import(\"axios\").default | null = null;\n async function getAxios() {\n if (!_axios) {\n const mod = await import(\"axios\");\n _axios = mod.default;\n }\n return _axios;\n }\n\n // ── OAuth HTTP client ───────────────────────────────────────────\n\n let oauthClient: import(\"axios\").AxiosInstance | null = null;\n async function getOAuthClient() {\n if (oauthClient) return oauthClient;\n const axios = await getAxios();\n oauthClient = axios.create({\n baseURL: env.oAuthServerUrl,\n timeout: AXIOS_TIMEOUT_MS,\n });\n return oauthClient;\n }\n\n // ── Portal API client ──────────────────────────────────────────\n\n let portalClient: import(\"axios\").AxiosInstance | null = null;\n function getPortalClient() {\n if (portalClient) return portalClient;\n // Synchronous check — we only create if we have the URL\n if (!env.portalApiUrl) return null;\n // We need axios synchronously here, but it should already be loaded\n // by the time portal client is needed (after authenticateRequest calls getAxios)\n return null; // Will be created lazily in async context\n }\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 Secrets ────────────────────────────────────────────────\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 function getSessionSecret() {\n // Prefer portalJwtSecret for signing so cookies work cross-subdomain\n const secret = env.portalJwtSecret || env.jwtSecret;\n return new TextEncoder().encode(secret);\n }\n\n // ── Cookie Helpers ─────────────────────────────────────────────\n\n function getCookieDomain(): string | undefined {\n return env.cookieDomain || undefined;\n }\n\n function isSecureRequest(req: Request): boolean {\n if (req.protocol === \"https\") return true;\n const forwardedProto = req.headers[\"x-forwarded-proto\"];\n if (!forwardedProto) return false;\n const protoList = Array.isArray(forwardedProto)\n ? forwardedProto\n : forwardedProto.split(\",\");\n return protoList.some((p) => p.trim().toLowerCase() === \"https\");\n }\n\n function getSessionCookieOptions(\n req: Request\n ): Pick<\n CookieOptions,\n \"domain\" | \"httpOnly\" | \"path\" | \"sameSite\" | \"secure\"\n > {\n const secure = isSecureRequest(req);\n const domain = getCookieDomain();\n return {\n domain,\n httpOnly: true,\n path: \"/\",\n sameSite: \"lax\",\n secure,\n };\n }\n\n function isValidReturnUrl(url: string): boolean {\n if (url.startsWith(\"/\") && !url.startsWith(\"//\")) return true;\n try {\n const parsed = new URL(url);\n if (parsed.protocol !== \"https:\") return false;\n return (\n parsed.hostname === \"oplytics.digital\" ||\n parsed.hostname.endsWith(\".oplytics.digital\")\n );\n } catch {\n return false;\n }\n }\n\n // ── Session Token ──────────────────────────────────────────────\n\n async function signSession(\n payload: SessionPayload,\n options: { expiresInMs?: number } = {}\n ): Promise<string> {\n const jose = await getJose();\n const issuedAt = Date.now();\n const expiresInMs = options.expiresInMs ?? sessionExpiryMs;\n const expirationSeconds = Math.floor((issuedAt + expiresInMs) / 1000);\n const secretKey = getSessionSecret();\n\n return new jose.SignJWT({\n openId: payload.openId,\n appId: payload.appId,\n name: payload.name,\n })\n .setProtectedHeader({ alg: \"HS256\", typ: \"JWT\" })\n .setExpirationTime(expirationSeconds)\n .sign(secretKey);\n }\n\n async function createSessionToken(\n openId: string,\n options: { expiresInMs?: number; name?: string } = {}\n ): Promise<string> {\n return signSession(\n { openId, appId: env.appId, name: options.name || \"\" },\n options\n );\n }\n\n // ── Session Verification ───────────────────────────────────────\n\n async function verifySession(\n cookieValue: string | undefined | null\n ): Promise<SessionPayload | null> {\n if (!cookieValue) {\n console.warn(`${tag} Missing session cookie`);\n return null;\n }\n\n const jose = await getJose();\n\n // Try PORTAL_JWT_SECRET first (cross-subdomain SSO)\n if (env.portalJwtSecret) {\n try {\n const portalKey = getPortalSecret();\n const { payload } = await jose.jwtVerify(cookieValue, portalKey, {\n algorithms: [\"HS256\"],\n });\n const { openId, appId, name } = payload as Record<string, unknown>;\n if (\n !isNonEmptyString(openId) ||\n !isNonEmptyString(appId) ||\n !isNonEmptyString(name)\n ) {\n console.warn(`${tag} Portal JWT payload missing required fields`);\n return null;\n }\n return { openId, appId, name };\n } catch {\n // Portal secret didn't work — fall through to local secret\n }\n }\n\n // Fallback: try local JWT_SECRET\n try {\n const secretKey = getSessionSecret();\n const { payload } = await jose.jwtVerify(cookieValue, secretKey, {\n algorithms: [\"HS256\"],\n });\n const { openId, appId, name } = payload as Record<string, unknown>;\n if (\n !isNonEmptyString(openId) ||\n !isNonEmptyString(appId) ||\n !isNonEmptyString(name)\n ) {\n console.warn(`${tag} Session payload missing required fields`);\n return null;\n }\n return { openId, appId, name };\n } catch (error) {\n console.warn(\n `${tag} Session verification failed:`,\n (error as Error).message\n );\n return null;\n }\n }\n\n // ── Portal API ─────────────────────────────────────────────────\n\n async function getPortalUser(openId: string): Promise<PortalUser | null> {\n try {\n const client = await getPortalClientAsync();\n if (!client) return null;\n const { data } = await client.get<PortalUser>(\n `/users/by-openid/${openId}`\n );\n return data ?? null;\n } catch (err: unknown) {\n const status = (err as any)?.response?.status;\n if (status === 401) {\n console.error(\n `${tag} Portal API key revoked (401) — enrichment disabled`\n );\n } else if (status === 404) {\n // User not found in portal — normal for first-time users\n } else {\n console.warn(\n `${tag} Portal user lookup failed:`,\n (err as Error).message\n );\n }\n return null;\n }\n }\n\n /** Portal user lookup with a short-TTL cache (portal mode). */\n async function getPortalUserCached(\n openId: string\n ): Promise<PortalUser | null> {\n if (portalUserCacheTtlMs > 0) {\n const hit = portalUserCache.get(openId);\n if (hit && Date.now() - hit.at < portalUserCacheTtlMs) {\n return hit.user;\n }\n }\n const user = await getPortalUser(openId);\n if (user && portalUserCacheTtlMs > 0) {\n portalUserCache.set(openId, { user, at: Date.now() });\n }\n return user;\n }\n\n /**\n * Build ctx.user entirely from the Portal record — no local persistence.\n * The single source of truth. ctx.user.id is the PORTAL user id. Roles and\n * enterprise come from the Portal verbatim (no mapping). Falls back to the\n * verified session's claims if the Portal is unreachable, so an authenticated\n * user is never locked out by a transient Portal outage (degraded: no role/\n * enterprise until the Portal recovers).\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 const enterpriseId =\n portalUser.enterpriseId ?? portalUser.companyId ?? null;\n return {\n id: portalUser.id,\n openId: portalUser.openId ?? openId,\n name: portalUser.name ?? session.name ?? null,\n email: portalUser.email ?? null,\n role: portalUser.role,\n enterpriseId,\n portalUserId: portalUser.id,\n } as unknown as TUser;\n }\n\n // Portal unreachable / user not yet in Portal — degraded identity from JWT.\n console.warn(\n `${tag} Portal user unavailable for ${openId}; using session claims (no role/enterprise)`\n );\n return {\n id: 0,\n openId,\n name: session.name ?? null,\n email: null,\n role: \"\",\n enterpriseId: null,\n portalUserId: null,\n } as unknown as TUser;\n }\n\n // ── OAuth ──────────────────────────────────────────────────────\n\n async function exchangeCodeForToken(\n code: string,\n state: string\n ): Promise<ExchangeTokenResponse> {\n const client = await getOAuthClient();\n const redirectUri = atob(state);\n const { data } = await client.post<ExchangeTokenResponse>(\n EXCHANGE_TOKEN_PATH,\n {\n clientId: env.appId,\n grantType: \"authorization_code\",\n code,\n redirectUri,\n }\n );\n return data;\n }\n\n async function getUserInfo(\n accessToken: string\n ): Promise<GetUserInfoResponse> {\n const client = await getOAuthClient();\n const { data } = await client.post<GetUserInfoResponse>(\n GET_USER_INFO_PATH,\n { accessToken }\n );\n const loginMethod = deriveLoginMethod(\n (data as any)?.platforms,\n (data as any)?.platform ?? data.platform ?? null\n );\n return { ...(data as any), platform: loginMethod, loginMethod };\n }\n\n async function getUserInfoWithJwt(\n jwtToken: string\n ): Promise<GetUserInfoWithJwtResponse> {\n const client = await getOAuthClient();\n const { data } = await client.post<GetUserInfoWithJwtResponse>(\n GET_USER_INFO_WITH_JWT_PATH,\n { jwtToken, projectId: env.appId }\n );\n const loginMethod = deriveLoginMethod(\n (data as any)?.platforms,\n (data as any)?.platform ?? data.platform ?? null\n );\n return { ...(data as any), platform: loginMethod, loginMethod };\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.parse(req.headers.cookie)))\n : new Map<string, string>();\n\n const sessionCookie = cookies.get(cookieName);\n const session = await verifySession(sessionCookie);\n\n if (!session) {\n throw Object.assign(new Error(\"Invalid session cookie\"), {\n statusCode: 403,\n });\n }\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 // Last resort: try Manus OAuth JWT\n try {\n const userInfo = await getUserInfoWithJwt(sessionCookie ?? \"\");\n await db!.upsertUser({\n openId: userInfo.openId,\n name: userInfo.name || null,\n email: userInfo.email ?? null,\n loginMethod: userInfo.loginMethod ?? userInfo.platform ?? null,\n lastSignedIn: signedInAt,\n });\n user = (await db!.getUserByOpenId(userInfo.openId)) ?? null;\n } catch (oauthError) {\n console.error(\n `${tag} All user sync methods failed:`,\n error,\n oauthError\n );\n throw Object.assign(new Error(\"Failed to sync user info\"), {\n statusCode: 403,\n });\n }\n }\n }\n\n if (!user) {\n throw Object.assign(new Error(\"User not found\"), { statusCode: 403 });\n }\n\n // ── Enterprise backfill ─────────────────────────────────────\n if (user.enterpriseId === null) {\n try {\n const portalUser = await getPortalUser(user.openId);\n // Portal returns `enterpriseId`; `companyId` is a legacy alias.\n const portalEnterpriseId =\n portalUser?.enterpriseId ?? portalUser?.companyId ?? null;\n if (portalEnterpriseId != null) {\n console.log(\n `${tag} Backfilling enterpriseId=${portalEnterpriseId} for user ${user.id}`\n );\n await db!.updateUserFields(user.id, {\n enterpriseId: portalEnterpriseId,\n ...(portalUser?.id ? { portalUserId: portalUser.id } : {}),\n });\n user = (await db!.getUserByOpenId(user.openId)) ?? user;\n }\n } catch (backfillError) {\n console.warn(\n `${tag} Failed to backfill enterpriseId for user ${user.id}:`,\n backfillError\n );\n // Non-fatal — user can still proceed\n }\n }\n\n // Touch lastSignedIn\n await db!.upsertUser({\n openId: user.openId,\n lastSignedIn: signedInAt,\n });\n\n return user;\n }\n\n // ── OAuth Route Registration ───────────────────────────────────\n\n function registerOAuthRoutes(app: import(\"express\").Express) {\n app.get(\n \"/api/oauth/callback\",\n async (req: Request, res: import(\"express\").Response) => {\n const code =\n typeof req.query.code === \"string\" ? req.query.code : undefined;\n const state =\n typeof req.query.state === \"string\" ? req.query.state : undefined;\n\n if (!code || !state) {\n res.status(400).json({ error: \"code and state are required\" });\n return;\n }\n\n try {\n const tokenResponse = await exchangeCodeForToken(code, state);\n const userInfo = await getUserInfo(tokenResponse.accessToken);\n\n if (!userInfo.openId) {\n res.status(400).json({ error: \"openId missing from user info\" });\n return;\n }\n\n await db!.upsertUser({\n openId: userInfo.openId,\n name: userInfo.name || null,\n email: userInfo.email ?? null,\n loginMethod: userInfo.loginMethod ?? userInfo.platform ?? null,\n lastSignedIn: new Date(),\n });\n\n const sessionToken = await createSessionToken(userInfo.openId, {\n name: userInfo.name || \"\",\n expiresInMs: sessionExpiryMs,\n });\n\n const cookieOptions = getSessionCookieOptions(req);\n res.cookie(cookieName, sessionToken, {\n ...cookieOptions,\n maxAge: sessionExpiryMs,\n });\n\n res.redirect(302, \"/\");\n } catch (error) {\n console.error(`${tag} OAuth callback failed`, error);\n res.status(500).json({ error: \"OAuth callback failed\" });\n }\n }\n );\n }\n\n // ── Return public API ──────────────────────────────────────────\n\n return {\n verifySession,\n createSessionToken,\n authenticateRequest,\n exchangeCodeForToken,\n getUserInfo,\n getUserInfoWithJwt,\n getPortalUser,\n getSessionCookieOptions,\n isValidReturnUrl,\n registerOAuthRoutes,\n };\n}\n"],"mappings":"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","names":[]}
@@ -11,8 +11,12 @@ function createHierarchyService(config) {
11
11
  } = config;
12
12
  const configured = Boolean(portalApiUrl && portalApiKey);
13
13
  let cache = null;
14
+ let lastError = null;
14
15
  async function fetchRaw() {
15
- if (!configured) return null;
16
+ if (!configured) {
17
+ lastError = "not configured (PORTAL_API_URL/PORTAL_API_KEY missing)";
18
+ return null;
19
+ }
16
20
  const url = `${portalApiUrl.replace(/\/$/, "")}/api/service/hierarchy`;
17
21
  let res;
18
22
  try {
@@ -22,12 +26,24 @@ function createHierarchyService(config) {
22
26
  "Content-Type": "application/json"
23
27
  }
24
28
  });
25
- } catch {
29
+ } catch (err) {
30
+ lastError = `fetch threw: ${err instanceof Error ? err.message : String(err)}`;
31
+ console.error(`[HierarchyService] ${lastError} (url=${url})`);
32
+ return null;
33
+ }
34
+ if (!res.ok) {
35
+ lastError = `HTTP ${res.status} ${res.statusText} from ${url}`;
36
+ console.error(`[HierarchyService] ${lastError}`);
26
37
  return null;
27
38
  }
28
- if (!res.ok) return null;
29
39
  const json = await res.json().catch(() => null);
30
- return json ? normalize(json) : null;
40
+ if (!json) {
41
+ lastError = `invalid/empty JSON body from ${url}`;
42
+ console.error(`[HierarchyService] ${lastError}`);
43
+ return null;
44
+ }
45
+ lastError = null;
46
+ return normalize(json);
31
47
  }
32
48
  async function getFullHierarchy() {
33
49
  if (cacheTtlMs > 0 && cache && now() - cache.at < cacheTtlMs) {
@@ -48,7 +64,8 @@ function createHierarchyService(config) {
48
64
  return {
49
65
  configured,
50
66
  cached: cache !== null,
51
- ageMs: cache ? now() - cache.at : null
67
+ ageMs: cache ? now() - cache.at : null,
68
+ lastError
52
69
  };
53
70
  }
54
71
  return { getScopedHierarchy, getFullHierarchy, invalidate, getStatus };
@@ -95,4 +112,4 @@ function normalize(data) {
95
112
  export {
96
113
  createHierarchyService
97
114
  };
98
- //# sourceMappingURL=chunk-5Y5LLC4Z.js.map
115
+ //# sourceMappingURL=chunk-OAF3JSO7.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/hierarchy.ts"],"sourcesContent":["/**\n * Hierarchy Service — the single server-side source for org-hierarchy data.\n *\n * Fetches the canonical org-hierarchy from the Portal Service API\n * (GET /api/service/hierarchy, authenticated with a platform-level service key),\n * caches it with a TTL, and scopes it to a caller's enterprise using the shared\n * isolation rule (scopeHierarchyToEnterprise from @pablo2410/shared-ui).\n *\n * One fetch, one mapper, one cache, one scope policy. Every subdomain constructs\n * this once from its env and calls getScopedHierarchy(ctx.user) in its tRPC\n * `hierarchy` procedure — no per-app fetch/scope/null-policy reinvention.\n *\n * Quick example:\n * const hierarchy = createHierarchyService({\n * portalApiUrl: process.env.PORTAL_API_URL ?? \"\",\n * portalApiKey: process.env.PORTAL_API_KEY ?? \"\",\n * });\n * // in a tRPC router:\n * hierarchy: protectedProcedure.query(({ ctx }) => hierarchy.getScopedHierarchy(ctx.user)),\n */\nimport type { OrgHierarchyData } from \"@pablo2410/shared-ui/hierarchy/core\";\nimport { scopeHierarchyToEnterprise } from \"@pablo2410/shared-ui/hierarchy/core\";\n\nexport interface HierarchyServiceConfig {\n /** Portal Service API base URL, e.g. https://portal.oplytics.digital */\n portalApiUrl: string;\n /** Platform-level X-Service-Key for the Portal Service API. */\n portalApiKey: string;\n /** Cache TTL in milliseconds. Default 15 minutes. Set 0 to disable caching. */\n cacheTtlMs?: number;\n /** Injectable fetch implementation (for tests). Defaults to global fetch. */\n fetchImpl?: typeof fetch;\n /** Injectable clock (for tests). Defaults to Date.now. */\n now?: () => number;\n}\n\nexport interface HierarchyService {\n /**\n * The org-hierarchy scoped to the caller's enterprise. A user with no resolved\n * enterprise (null/undefined enterpriseId) receives an empty hierarchy — see\n * scopeHierarchyToEnterprise for the policy. Never returns null.\n */\n getScopedHierarchy(\n user: { enterpriseId?: number | null } | null,\n ): Promise<OrgHierarchyData>;\n /**\n * The full, unscoped hierarchy (every enterprise). For Portal/admin tooling\n * only — subdomains must use getScopedHierarchy. null if unconfigured/unreachable.\n */\n getFullHierarchy(): Promise<OrgHierarchyData | null>;\n /** Clear the cache (e.g. after a portal sync). */\n invalidate(): void;\n /** Config + cache status, for health endpoints. */\n getStatus(): {\n configured: boolean;\n cached: boolean;\n ageMs: number | null;\n /** Reason the last fetch failed (null if the last fetch succeeded). */\n lastError: string | null;\n };\n}\n\nconst FIFTEEN_MINUTES = 15 * 60 * 1000;\n\nexport function createHierarchyService(\n config: HierarchyServiceConfig,\n): HierarchyService {\n const {\n portalApiUrl,\n portalApiKey,\n cacheTtlMs = FIFTEEN_MINUTES,\n fetchImpl = fetch,\n now = Date.now,\n } = config;\n\n const configured = Boolean(portalApiUrl && portalApiKey);\n let cache: { data: OrgHierarchyData; at: number } | null = null;\n // Last fetch failure reason, surfaced via getStatus() for diagnostics. A\n // configured service that returns an empty hierarchy is almost always a fetch\n // failure (wrong URL/key, unreachable Portal, non-200) — without this it was\n // swallowed to a silent null and looked like \"enterprise has no data\".\n let lastError: string | null = null;\n\n async function fetchRaw(): Promise<OrgHierarchyData | null> {\n if (!configured) {\n lastError = \"not configured (PORTAL_API_URL/PORTAL_API_KEY missing)\";\n return null;\n }\n const url = `${portalApiUrl.replace(/\\/$/, \"\")}/api/service/hierarchy`;\n let res: Response;\n try {\n res = await fetchImpl(url, {\n headers: {\n \"X-Service-Key\": portalApiKey,\n \"Content-Type\": \"application/json\",\n },\n });\n } catch (err) {\n lastError = `fetch threw: ${err instanceof Error ? err.message : String(err)}`;\n console.error(`[HierarchyService] ${lastError} (url=${url})`);\n return null;\n }\n if (!res.ok) {\n lastError = `HTTP ${res.status} ${res.statusText} from ${url}`;\n console.error(`[HierarchyService] ${lastError}`);\n return null;\n }\n const json = await res.json().catch(() => null);\n if (!json) {\n lastError = `invalid/empty JSON body from ${url}`;\n console.error(`[HierarchyService] ${lastError}`);\n return null;\n }\n lastError = null;\n return normalize(json);\n }\n\n async function getFullHierarchy(): Promise<OrgHierarchyData | null> {\n if (cacheTtlMs > 0 && cache && now() - cache.at < cacheTtlMs) {\n return cache.data;\n }\n const data = await fetchRaw();\n if (data && cacheTtlMs > 0) cache = { data, at: now() };\n return data;\n }\n\n async function getScopedHierarchy(\n user: { enterpriseId?: number | null } | null,\n ): Promise<OrgHierarchyData> {\n const full = await getFullHierarchy();\n return scopeHierarchyToEnterprise(full, user?.enterpriseId ?? null);\n }\n\n function invalidate(): void {\n cache = null;\n }\n\n function getStatus() {\n return {\n configured,\n cached: cache !== null,\n ageMs: cache ? now() - cache.at : null,\n lastError,\n };\n }\n\n return { getScopedHierarchy, getFullHierarchy, invalidate, getStatus };\n}\n\n/**\n * Map the Portal Service API response to the canonical OrgHierarchyData shape.\n * Tolerant of missing arrays (defaults to []) so a partial payload never throws.\n */\nfunction normalize(data: unknown): OrgHierarchyData {\n const d = (data ?? {}) as Record<string, any[]>;\n return {\n enterprises: (d.enterprises ?? []).map((e) => ({\n id: e.id,\n name: e.name,\n code: e.code,\n })),\n businessUnits: (d.businessUnits ?? []).map((bu) => ({\n id: bu.id,\n name: bu.name,\n code: bu.code,\n enterpriseId: bu.enterpriseId,\n })),\n sites: (d.sites ?? []).map((s) => ({\n id: s.id,\n name: s.name,\n code: s.code,\n enterpriseId: s.enterpriseId,\n businessUnitId: s.businessUnitId ?? 0,\n location: s.location,\n })),\n areas: (d.areas ?? []).map((a) => ({\n id: a.id,\n name: a.name,\n code: a.code,\n siteId: a.siteId,\n })),\n assets: (d.assets ?? []).map((a) => ({\n id: a.id,\n name: a.name,\n code: a.code,\n siteId: a.siteId,\n areaId: a.areaId ?? 0,\n assetType: a.assetType,\n })),\n };\n}\n"],"mappings":"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","names":[]}
@@ -54,6 +54,8 @@ interface HierarchyService {
54
54
  configured: boolean;
55
55
  cached: boolean;
56
56
  ageMs: number | null;
57
+ /** Reason the last fetch failed (null if the last fetch succeeded). */
58
+ lastError: string | null;
57
59
  };
58
60
  }
59
61
  declare function createHierarchyService(config: HierarchyServiceConfig): HierarchyService;
package/dist/hierarchy.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  createHierarchyService
3
- } from "./chunk-5Y5LLC4Z.js";
3
+ } from "./chunk-OAF3JSO7.js";
4
4
  export {
5
5
  createHierarchyService
6
6
  };
package/dist/index.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  createSubdomainAuth
3
- } from "./chunk-23I5TW53.js";
3
+ } from "./chunk-E2U7DKNZ.js";
4
4
  import {
5
5
  createHierarchyService
6
- } from "./chunk-5Y5LLC4Z.js";
6
+ } from "./chunk-OAF3JSO7.js";
7
7
 
8
8
  // src/constants/sqdcp.ts
9
9
  var SQDCP_PILLARS = ["safety", "quality", "delivery", "cost", "people"];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pablo2410/core-server",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "Shared server utilities for Oplytics.digital subdomains",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/auth/createSubdomainAuth.ts"],"sourcesContent":["/**\n * createSubdomainAuth — shared SSO auth factory for Oplytics.digital subdomains.\n *\n * Encapsulates:\n * - JWT session verification (PORTAL_JWT_SECRET first, fallback to JWT_SECRET)\n * - Cookie domain handling via COOKIE_DOMAIN env var\n * - Portal API user enrichment with X-Service-Key header\n * - Auto-create user from JWT payload if not in local DB\n * - Enterprise backfill from portal API\n * - Manus OAuth code exchange and user info\n * - OAuth callback route registration\n *\n * Based on SQDCP's battle-tested sdk.ts + cookies.ts.\n */\n\nimport type { Request, CookieOptions } from \"express\";\nimport type {\n AuthEnv,\n BaseUser,\n DbAdapter,\n ExchangeTokenResponse,\n GetUserInfoResponse,\n GetUserInfoWithJwtResponse,\n PortalUser,\n RoleMapper,\n SessionPayload,\n SubdomainAuth,\n SubdomainAuthConfig,\n} from \"./types\";\n\n// ─── Constants ──────────────────────────────────────────────────────\n\nconst DEFAULT_COOKIE_NAME = \"app_session_id\";\nconst ONE_YEAR_MS = 1000 * 60 * 60 * 24 * 365;\nconst AXIOS_TIMEOUT_MS = 30_000;\n\nconst EXCHANGE_TOKEN_PATH =\n \"/webdev.v1.WebDevAuthPublicService/ExchangeToken\";\nconst GET_USER_INFO_PATH =\n \"/webdev.v1.WebDevAuthPublicService/GetUserInfo\";\nconst GET_USER_INFO_WITH_JWT_PATH =\n \"/webdev.v1.WebDevAuthPublicService/GetUserInfoWithJwt\";\n\n// ─── Helpers ────────────────────────────────────────────────────────\n\nconst isNonEmptyString = (value: unknown): value is string =>\n typeof value === \"string\" && value.length > 0;\n\n/**\n * Derive the login method from the Manus platforms array.\n */\nfunction deriveLoginMethod(\n platforms: unknown,\n fallback: string | null | undefined\n): string | null {\n if (fallback && fallback.length > 0) return fallback;\n if (!Array.isArray(platforms) || platforms.length === 0) return null;\n const set = new Set<string>(\n platforms.filter((p): p is string => typeof p === \"string\")\n );\n if (set.has(\"REGISTERED_PLATFORM_EMAIL\")) return \"email\";\n if (set.has(\"REGISTERED_PLATFORM_GOOGLE\")) return \"google\";\n if (set.has(\"REGISTERED_PLATFORM_APPLE\")) return \"apple\";\n if (\n set.has(\"REGISTERED_PLATFORM_MICROSOFT\") ||\n set.has(\"REGISTERED_PLATFORM_AZURE\")\n )\n return \"microsoft\";\n if (set.has(\"REGISTERED_PLATFORM_GITHUB\")) return \"github\";\n const first = Array.from(set)[0];\n return first ? first.toLowerCase() : 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 mapRole,\n cookieName = DEFAULT_COOKIE_NAME,\n sessionExpiryMs = ONE_YEAR_MS,\n serviceName = \"Subdomain\",\n } = config;\n\n const tag = `[${serviceName} Auth]`;\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 // ── OAuth HTTP client ───────────────────────────────────────────\n\n let oauthClient: import(\"axios\").AxiosInstance | null = null;\n async function getOAuthClient() {\n if (oauthClient) return oauthClient;\n const axios = await getAxios();\n oauthClient = axios.create({\n baseURL: env.oAuthServerUrl,\n timeout: AXIOS_TIMEOUT_MS,\n });\n return oauthClient;\n }\n\n // ── Portal API client ──────────────────────────────────────────\n\n let portalClient: import(\"axios\").AxiosInstance | null = null;\n function getPortalClient() {\n if (portalClient) return portalClient;\n // Synchronous check — we only create if we have the URL\n if (!env.portalApiUrl) return null;\n // We need axios synchronously here, but it should already be loaded\n // by the time portal client is needed (after authenticateRequest calls getAxios)\n return null; // Will be created lazily in async context\n }\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 Secrets ────────────────────────────────────────────────\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 function getSessionSecret() {\n // Prefer portalJwtSecret for signing so cookies work cross-subdomain\n const secret = env.portalJwtSecret || env.jwtSecret;\n return new TextEncoder().encode(secret);\n }\n\n // ── Cookie Helpers ─────────────────────────────────────────────\n\n function getCookieDomain(): string | undefined {\n return env.cookieDomain || undefined;\n }\n\n function isSecureRequest(req: Request): boolean {\n if (req.protocol === \"https\") return true;\n const forwardedProto = req.headers[\"x-forwarded-proto\"];\n if (!forwardedProto) return false;\n const protoList = Array.isArray(forwardedProto)\n ? forwardedProto\n : forwardedProto.split(\",\");\n return protoList.some((p) => p.trim().toLowerCase() === \"https\");\n }\n\n function getSessionCookieOptions(\n req: Request\n ): Pick<\n CookieOptions,\n \"domain\" | \"httpOnly\" | \"path\" | \"sameSite\" | \"secure\"\n > {\n const secure = isSecureRequest(req);\n const domain = getCookieDomain();\n return {\n domain,\n httpOnly: true,\n path: \"/\",\n sameSite: \"lax\",\n secure,\n };\n }\n\n function isValidReturnUrl(url: string): boolean {\n if (url.startsWith(\"/\") && !url.startsWith(\"//\")) return true;\n try {\n const parsed = new URL(url);\n if (parsed.protocol !== \"https:\") return false;\n return (\n parsed.hostname === \"oplytics.digital\" ||\n parsed.hostname.endsWith(\".oplytics.digital\")\n );\n } catch {\n return false;\n }\n }\n\n // ── Session Token ──────────────────────────────────────────────\n\n async function signSession(\n payload: SessionPayload,\n options: { expiresInMs?: number } = {}\n ): Promise<string> {\n const jose = await getJose();\n const issuedAt = Date.now();\n const expiresInMs = options.expiresInMs ?? sessionExpiryMs;\n const expirationSeconds = Math.floor((issuedAt + expiresInMs) / 1000);\n const secretKey = getSessionSecret();\n\n return new jose.SignJWT({\n openId: payload.openId,\n appId: payload.appId,\n name: payload.name,\n })\n .setProtectedHeader({ alg: \"HS256\", typ: \"JWT\" })\n .setExpirationTime(expirationSeconds)\n .sign(secretKey);\n }\n\n async function createSessionToken(\n openId: string,\n options: { expiresInMs?: number; name?: string } = {}\n ): Promise<string> {\n return signSession(\n { openId, appId: env.appId, name: options.name || \"\" },\n options\n );\n }\n\n // ── Session Verification ───────────────────────────────────────\n\n async function verifySession(\n cookieValue: string | undefined | null\n ): Promise<SessionPayload | null> {\n if (!cookieValue) {\n console.warn(`${tag} Missing session cookie`);\n return null;\n }\n\n const jose = await getJose();\n\n // Try PORTAL_JWT_SECRET first (cross-subdomain SSO)\n if (env.portalJwtSecret) {\n try {\n const portalKey = getPortalSecret();\n const { payload } = await jose.jwtVerify(cookieValue, portalKey, {\n algorithms: [\"HS256\"],\n });\n const { openId, appId, name } = payload as Record<string, unknown>;\n if (\n !isNonEmptyString(openId) ||\n !isNonEmptyString(appId) ||\n !isNonEmptyString(name)\n ) {\n console.warn(`${tag} Portal JWT payload missing required fields`);\n return null;\n }\n return { openId, appId, name };\n } catch {\n // Portal secret didn't work — fall through to local secret\n }\n }\n\n // Fallback: try local JWT_SECRET\n try {\n const secretKey = getSessionSecret();\n const { payload } = await jose.jwtVerify(cookieValue, secretKey, {\n algorithms: [\"HS256\"],\n });\n const { openId, appId, name } = payload as Record<string, unknown>;\n if (\n !isNonEmptyString(openId) ||\n !isNonEmptyString(appId) ||\n !isNonEmptyString(name)\n ) {\n console.warn(`${tag} Session payload missing required fields`);\n return null;\n }\n return { openId, appId, name };\n } catch (error) {\n console.warn(\n `${tag} Session verification failed:`,\n (error as Error).message\n );\n return null;\n }\n }\n\n // ── Portal API ─────────────────────────────────────────────────\n\n async function getPortalUser(openId: string): Promise<PortalUser | null> {\n try {\n const client = await getPortalClientAsync();\n if (!client) return null;\n const { data } = await client.get<PortalUser>(\n `/users/by-openid/${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 // ── OAuth ──────────────────────────────────────────────────────\n\n async function exchangeCodeForToken(\n code: string,\n state: string\n ): Promise<ExchangeTokenResponse> {\n const client = await getOAuthClient();\n const redirectUri = atob(state);\n const { data } = await client.post<ExchangeTokenResponse>(\n EXCHANGE_TOKEN_PATH,\n {\n clientId: env.appId,\n grantType: \"authorization_code\",\n code,\n redirectUri,\n }\n );\n return data;\n }\n\n async function getUserInfo(\n accessToken: string\n ): Promise<GetUserInfoResponse> {\n const client = await getOAuthClient();\n const { data } = await client.post<GetUserInfoResponse>(\n GET_USER_INFO_PATH,\n { accessToken }\n );\n const loginMethod = deriveLoginMethod(\n (data as any)?.platforms,\n (data as any)?.platform ?? data.platform ?? null\n );\n return { ...(data as any), platform: loginMethod, loginMethod };\n }\n\n async function getUserInfoWithJwt(\n jwtToken: string\n ): Promise<GetUserInfoWithJwtResponse> {\n const client = await getOAuthClient();\n const { data } = await client.post<GetUserInfoWithJwtResponse>(\n GET_USER_INFO_WITH_JWT_PATH,\n { jwtToken, projectId: env.appId }\n );\n const loginMethod = deriveLoginMethod(\n (data as any)?.platforms,\n (data as any)?.platform ?? data.platform ?? null\n );\n return { ...(data as any), platform: loginMethod, loginMethod };\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.parse(req.headers.cookie)))\n : new Map<string, string>();\n\n const sessionCookie = cookies.get(cookieName);\n const session = await verifySession(sessionCookie);\n\n if (!session) {\n throw Object.assign(new Error(\"Invalid session cookie\"), {\n statusCode: 403,\n });\n }\n\n const sessionUserId = session.openId;\n const signedInAt = new Date();\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 // Last resort: try Manus OAuth JWT\n try {\n const userInfo = await getUserInfoWithJwt(sessionCookie ?? \"\");\n await db.upsertUser({\n openId: userInfo.openId,\n name: userInfo.name || null,\n email: userInfo.email ?? null,\n loginMethod: userInfo.loginMethod ?? userInfo.platform ?? null,\n lastSignedIn: signedInAt,\n });\n user = (await db.getUserByOpenId(userInfo.openId)) ?? null;\n } catch (oauthError) {\n console.error(\n `${tag} All user sync methods failed:`,\n error,\n oauthError\n );\n throw Object.assign(new Error(\"Failed to sync user info\"), {\n statusCode: 403,\n });\n }\n }\n }\n\n if (!user) {\n throw Object.assign(new Error(\"User not found\"), { statusCode: 403 });\n }\n\n // ── Enterprise backfill ─────────────────────────────────────\n if (user.enterpriseId === null) {\n try {\n const portalUser = await getPortalUser(user.openId);\n // Portal returns `enterpriseId`; `companyId` is a legacy alias.\n const portalEnterpriseId =\n portalUser?.enterpriseId ?? portalUser?.companyId ?? null;\n if (portalEnterpriseId != null) {\n console.log(\n `${tag} Backfilling enterpriseId=${portalEnterpriseId} for user ${user.id}`\n );\n await db.updateUserFields(user.id, {\n enterpriseId: portalEnterpriseId,\n ...(portalUser?.id ? { portalUserId: portalUser.id } : {}),\n });\n user = (await db.getUserByOpenId(user.openId)) ?? user;\n }\n } catch (backfillError) {\n console.warn(\n `${tag} Failed to backfill enterpriseId for user ${user.id}:`,\n backfillError\n );\n // Non-fatal — user can still proceed\n }\n }\n\n // Touch lastSignedIn\n await db.upsertUser({\n openId: user.openId,\n lastSignedIn: signedInAt,\n });\n\n return user;\n }\n\n // ── OAuth Route Registration ───────────────────────────────────\n\n function registerOAuthRoutes(app: import(\"express\").Express) {\n app.get(\n \"/api/oauth/callback\",\n async (req: Request, res: import(\"express\").Response) => {\n const code =\n typeof req.query.code === \"string\" ? req.query.code : undefined;\n const state =\n typeof req.query.state === \"string\" ? req.query.state : undefined;\n\n if (!code || !state) {\n res.status(400).json({ error: \"code and state are required\" });\n return;\n }\n\n try {\n const tokenResponse = await exchangeCodeForToken(code, state);\n const userInfo = await getUserInfo(tokenResponse.accessToken);\n\n if (!userInfo.openId) {\n res.status(400).json({ error: \"openId missing from user info\" });\n return;\n }\n\n await db.upsertUser({\n openId: userInfo.openId,\n name: userInfo.name || null,\n email: userInfo.email ?? null,\n loginMethod: userInfo.loginMethod ?? userInfo.platform ?? null,\n lastSignedIn: new Date(),\n });\n\n const sessionToken = await createSessionToken(userInfo.openId, {\n name: userInfo.name || \"\",\n expiresInMs: sessionExpiryMs,\n });\n\n const cookieOptions = getSessionCookieOptions(req);\n res.cookie(cookieName, sessionToken, {\n ...cookieOptions,\n maxAge: sessionExpiryMs,\n });\n\n res.redirect(302, \"/\");\n } catch (error) {\n console.error(`${tag} OAuth callback failed`, error);\n res.status(500).json({ error: \"OAuth callback failed\" });\n }\n }\n );\n }\n\n // ── Return public API ──────────────────────────────────────────\n\n return {\n verifySession,\n createSessionToken,\n authenticateRequest,\n exchangeCodeForToken,\n getUserInfo,\n getUserInfoWithJwt,\n getPortalUser,\n getSessionCookieOptions,\n isValidReturnUrl,\n registerOAuthRoutes,\n };\n}\n"],"mappings":"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","names":[]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/hierarchy.ts"],"sourcesContent":["/**\n * Hierarchy Service — the single server-side source for org-hierarchy data.\n *\n * Fetches the canonical org-hierarchy from the Portal Service API\n * (GET /api/service/hierarchy, authenticated with a platform-level service key),\n * caches it with a TTL, and scopes it to a caller's enterprise using the shared\n * isolation rule (scopeHierarchyToEnterprise from @pablo2410/shared-ui).\n *\n * One fetch, one mapper, one cache, one scope policy. Every subdomain constructs\n * this once from its env and calls getScopedHierarchy(ctx.user) in its tRPC\n * `hierarchy` procedure — no per-app fetch/scope/null-policy reinvention.\n *\n * Quick example:\n * const hierarchy = createHierarchyService({\n * portalApiUrl: process.env.PORTAL_API_URL ?? \"\",\n * portalApiKey: process.env.PORTAL_API_KEY ?? \"\",\n * });\n * // in a tRPC router:\n * hierarchy: protectedProcedure.query(({ ctx }) => hierarchy.getScopedHierarchy(ctx.user)),\n */\nimport type { OrgHierarchyData } from \"@pablo2410/shared-ui/hierarchy/core\";\nimport { scopeHierarchyToEnterprise } from \"@pablo2410/shared-ui/hierarchy/core\";\n\nexport interface HierarchyServiceConfig {\n /** Portal Service API base URL, e.g. https://portal.oplytics.digital */\n portalApiUrl: string;\n /** Platform-level X-Service-Key for the Portal Service API. */\n portalApiKey: string;\n /** Cache TTL in milliseconds. Default 15 minutes. Set 0 to disable caching. */\n cacheTtlMs?: number;\n /** Injectable fetch implementation (for tests). Defaults to global fetch. */\n fetchImpl?: typeof fetch;\n /** Injectable clock (for tests). Defaults to Date.now. */\n now?: () => number;\n}\n\nexport interface HierarchyService {\n /**\n * The org-hierarchy scoped to the caller's enterprise. A user with no resolved\n * enterprise (null/undefined enterpriseId) receives an empty hierarchy — see\n * scopeHierarchyToEnterprise for the policy. Never returns null.\n */\n getScopedHierarchy(\n user: { enterpriseId?: number | null } | null,\n ): Promise<OrgHierarchyData>;\n /**\n * The full, unscoped hierarchy (every enterprise). For Portal/admin tooling\n * only — subdomains must use getScopedHierarchy. null if unconfigured/unreachable.\n */\n getFullHierarchy(): Promise<OrgHierarchyData | null>;\n /** Clear the cache (e.g. after a portal sync). */\n invalidate(): void;\n /** Config + cache status, for health endpoints. */\n getStatus(): { configured: boolean; cached: boolean; ageMs: number | null };\n}\n\nconst FIFTEEN_MINUTES = 15 * 60 * 1000;\n\nexport function createHierarchyService(\n config: HierarchyServiceConfig,\n): HierarchyService {\n const {\n portalApiUrl,\n portalApiKey,\n cacheTtlMs = FIFTEEN_MINUTES,\n fetchImpl = fetch,\n now = Date.now,\n } = config;\n\n const configured = Boolean(portalApiUrl && portalApiKey);\n let cache: { data: OrgHierarchyData; at: number } | null = null;\n\n async function fetchRaw(): Promise<OrgHierarchyData | null> {\n if (!configured) return null;\n const url = `${portalApiUrl.replace(/\\/$/, \"\")}/api/service/hierarchy`;\n let res: Response;\n try {\n res = await fetchImpl(url, {\n headers: {\n \"X-Service-Key\": portalApiKey,\n \"Content-Type\": \"application/json\",\n },\n });\n } catch {\n return null;\n }\n if (!res.ok) return null;\n const json = await res.json().catch(() => null);\n return json ? normalize(json) : null;\n }\n\n async function getFullHierarchy(): Promise<OrgHierarchyData | null> {\n if (cacheTtlMs > 0 && cache && now() - cache.at < cacheTtlMs) {\n return cache.data;\n }\n const data = await fetchRaw();\n if (data && cacheTtlMs > 0) cache = { data, at: now() };\n return data;\n }\n\n async function getScopedHierarchy(\n user: { enterpriseId?: number | null } | null,\n ): Promise<OrgHierarchyData> {\n const full = await getFullHierarchy();\n return scopeHierarchyToEnterprise(full, user?.enterpriseId ?? null);\n }\n\n function invalidate(): void {\n cache = null;\n }\n\n function getStatus() {\n return {\n configured,\n cached: cache !== null,\n ageMs: cache ? now() - cache.at : null,\n };\n }\n\n return { getScopedHierarchy, getFullHierarchy, invalidate, getStatus };\n}\n\n/**\n * Map the Portal Service API response to the canonical OrgHierarchyData shape.\n * Tolerant of missing arrays (defaults to []) so a partial payload never throws.\n */\nfunction normalize(data: unknown): OrgHierarchyData {\n const d = (data ?? {}) as Record<string, any[]>;\n return {\n enterprises: (d.enterprises ?? []).map((e) => ({\n id: e.id,\n name: e.name,\n code: e.code,\n })),\n businessUnits: (d.businessUnits ?? []).map((bu) => ({\n id: bu.id,\n name: bu.name,\n code: bu.code,\n enterpriseId: bu.enterpriseId,\n })),\n sites: (d.sites ?? []).map((s) => ({\n id: s.id,\n name: s.name,\n code: s.code,\n enterpriseId: s.enterpriseId,\n businessUnitId: s.businessUnitId ?? 0,\n location: s.location,\n })),\n areas: (d.areas ?? []).map((a) => ({\n id: a.id,\n name: a.name,\n code: a.code,\n siteId: a.siteId,\n })),\n assets: (d.assets ?? []).map((a) => ({\n id: a.id,\n name: a.name,\n code: a.code,\n siteId: a.siteId,\n areaId: a.areaId ?? 0,\n assetType: a.assetType,\n })),\n };\n}\n"],"mappings":";AAqBA,SAAS,kCAAkC;AAmC3C,IAAM,kBAAkB,KAAK,KAAK;AAE3B,SAAS,uBACd,QACkB;AAClB,QAAM;AAAA,IACJ;AAAA,IACA;AAAA,IACA,aAAa;AAAA,IACb,YAAY;AAAA,IACZ,MAAM,KAAK;AAAA,EACb,IAAI;AAEJ,QAAM,aAAa,QAAQ,gBAAgB,YAAY;AACvD,MAAI,QAAuD;AAE3D,iBAAe,WAA6C;AAC1D,QAAI,CAAC,WAAY,QAAO;AACxB,UAAM,MAAM,GAAG,aAAa,QAAQ,OAAO,EAAE,CAAC;AAC9C,QAAI;AACJ,QAAI;AACF,YAAM,MAAM,UAAU,KAAK;AAAA,QACzB,SAAS;AAAA,UACP,iBAAiB;AAAA,UACjB,gBAAgB;AAAA,QAClB;AAAA,MACF,CAAC;AAAA,IACH,QAAQ;AACN,aAAO;AAAA,IACT;AACA,QAAI,CAAC,IAAI,GAAI,QAAO;AACpB,UAAM,OAAO,MAAM,IAAI,KAAK,EAAE,MAAM,MAAM,IAAI;AAC9C,WAAO,OAAO,UAAU,IAAI,IAAI;AAAA,EAClC;AAEA,iBAAe,mBAAqD;AAClE,QAAI,aAAa,KAAK,SAAS,IAAI,IAAI,MAAM,KAAK,YAAY;AAC5D,aAAO,MAAM;AAAA,IACf;AACA,UAAM,OAAO,MAAM,SAAS;AAC5B,QAAI,QAAQ,aAAa,EAAG,SAAQ,EAAE,MAAM,IAAI,IAAI,EAAE;AACtD,WAAO;AAAA,EACT;AAEA,iBAAe,mBACb,MAC2B;AAC3B,UAAM,OAAO,MAAM,iBAAiB;AACpC,WAAO,2BAA2B,MAAM,MAAM,gBAAgB,IAAI;AAAA,EACpE;AAEA,WAAS,aAAmB;AAC1B,YAAQ;AAAA,EACV;AAEA,WAAS,YAAY;AACnB,WAAO;AAAA,MACL;AAAA,MACA,QAAQ,UAAU;AAAA,MAClB,OAAO,QAAQ,IAAI,IAAI,MAAM,KAAK;AAAA,IACpC;AAAA,EACF;AAEA,SAAO,EAAE,oBAAoB,kBAAkB,YAAY,UAAU;AACvE;AAMA,SAAS,UAAU,MAAiC;AAClD,QAAM,IAAK,QAAQ,CAAC;AACpB,SAAO;AAAA,IACL,cAAc,EAAE,eAAe,CAAC,GAAG,IAAI,CAAC,OAAO;AAAA,MAC7C,IAAI,EAAE;AAAA,MACN,MAAM,EAAE;AAAA,MACR,MAAM,EAAE;AAAA,IACV,EAAE;AAAA,IACF,gBAAgB,EAAE,iBAAiB,CAAC,GAAG,IAAI,CAAC,QAAQ;AAAA,MAClD,IAAI,GAAG;AAAA,MACP,MAAM,GAAG;AAAA,MACT,MAAM,GAAG;AAAA,MACT,cAAc,GAAG;AAAA,IACnB,EAAE;AAAA,IACF,QAAQ,EAAE,SAAS,CAAC,GAAG,IAAI,CAAC,OAAO;AAAA,MACjC,IAAI,EAAE;AAAA,MACN,MAAM,EAAE;AAAA,MACR,MAAM,EAAE;AAAA,MACR,cAAc,EAAE;AAAA,MAChB,gBAAgB,EAAE,kBAAkB;AAAA,MACpC,UAAU,EAAE;AAAA,IACd,EAAE;AAAA,IACF,QAAQ,EAAE,SAAS,CAAC,GAAG,IAAI,CAAC,OAAO;AAAA,MACjC,IAAI,EAAE;AAAA,MACN,MAAM,EAAE;AAAA,MACR,MAAM,EAAE;AAAA,MACR,QAAQ,EAAE;AAAA,IACZ,EAAE;AAAA,IACF,SAAS,EAAE,UAAU,CAAC,GAAG,IAAI,CAAC,OAAO;AAAA,MACnC,IAAI,EAAE;AAAA,MACN,MAAM,EAAE;AAAA,MACR,MAAM,EAAE;AAAA,MACR,QAAQ,EAAE;AAAA,MACV,QAAQ,EAAE,UAAU;AAAA,MACpB,WAAW,EAAE;AAAA,IACf,EAAE;AAAA,EACJ;AACF;","names":[]}