@pablo2410/core-server 0.9.4 → 0.10.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 +25 -2
- package/dist/auth.js +1 -1
- package/dist/{chunk-C2JAV4QB.js → chunk-PIWC3MQG.js} +122 -7
- package/dist/chunk-PIWC3MQG.js.map +1 -0
- package/dist/index.js +1 -1
- package/dist/isolation.d.ts +17 -24
- package/dist/isolation.js +12 -22
- package/dist/isolation.js.map +1 -1
- package/package.json +1 -1
- package/dist/chunk-C2JAV4QB.js.map +0 -1
package/dist/auth.d.ts
CHANGED
|
@@ -130,14 +130,35 @@ interface SubdomainAuthConfig<TUser extends BaseUser = BaseUser> {
|
|
|
130
130
|
* on every request. Default 30s. Set 0 to disable caching.
|
|
131
131
|
*/
|
|
132
132
|
portalUserCacheTtlMs?: number;
|
|
133
|
+
/**
|
|
134
|
+
* Ask the Portal whether the session behind the cookie is still live
|
|
135
|
+
* (POST /api/service/sessions/check), so a device signed out in the Portal
|
|
136
|
+
* is refused here too (core-server#77). Default true. Only an explicit
|
|
137
|
+
* `{ active: false }` from the Portal ends a session: if the Portal is down,
|
|
138
|
+
* slow or answers with an error, the session is kept (fail open).
|
|
139
|
+
*/
|
|
140
|
+
sessionCheck?: boolean;
|
|
141
|
+
/**
|
|
142
|
+
* How long a session check answer is reused, in ms. Default 30s, which is
|
|
143
|
+
* how long a signed-out device can keep working here. Set 0 to check on
|
|
144
|
+
* every request.
|
|
145
|
+
*/
|
|
146
|
+
sessionCheckCacheTtlMs?: number;
|
|
147
|
+
/**
|
|
148
|
+
* How long to wait for the Portal's session check before keeping the
|
|
149
|
+
* session (fail open), in ms. Default 2s.
|
|
150
|
+
*/
|
|
151
|
+
sessionCheckTimeoutMs?: number;
|
|
133
152
|
}
|
|
134
153
|
/** The auth SDK instance returned by createSubdomainAuth. */
|
|
135
154
|
interface SubdomainAuth<TUser extends BaseUser = BaseUser> {
|
|
136
155
|
/** Verify a session cookie value. Returns payload or null. */
|
|
137
156
|
verifySession(cookieValue: string | undefined | null): Promise<SessionPayload | null>;
|
|
138
157
|
/**
|
|
139
|
-
* Full authentication flow: verify cookie →
|
|
140
|
-
*
|
|
158
|
+
* Full authentication flow: verify cookie → check the session is still
|
|
159
|
+
* live in the Portal → find/create user → enrich from portal → backfill
|
|
160
|
+
* enterprise. Returns the local user record. A session signed out in the
|
|
161
|
+
* Portal throws a 403 with `code: "SESSION_ENDED"`.
|
|
141
162
|
*/
|
|
142
163
|
authenticateRequest(req: Request): Promise<TUser>;
|
|
143
164
|
/** Fetch a user from the portal API by openId. */
|
|
@@ -157,6 +178,8 @@ interface SubdomainAuth<TUser extends BaseUser = BaseUser> {
|
|
|
157
178
|
* - Portal API user enrichment with X-Service-Key header
|
|
158
179
|
* - Auto-create user from JWT payload if not in local DB
|
|
159
180
|
* - Enterprise backfill from portal API
|
|
181
|
+
* - Portal session check: a device signed out in the Portal is refused here
|
|
182
|
+
* too, cached for 30s, failing open when the Portal can't answer (#77)
|
|
160
183
|
*
|
|
161
184
|
* Based on SQDCP's battle-tested sdk.ts + cookies.ts.
|
|
162
185
|
*/
|
package/dist/auth.js
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
// src/auth/createSubdomainAuth.ts
|
|
2
2
|
var DEFAULT_COOKIE_NAME = "app_session_id";
|
|
3
3
|
var ONE_YEAR_MS = 1e3 * 60 * 60 * 24 * 365;
|
|
4
|
+
var PORTAL_SESSION_SECONDS = 31536e3;
|
|
5
|
+
var SESSION_CHECK_CACHE_MAX = 1e4;
|
|
4
6
|
var isNonEmptyString = (value) => typeof value === "string" && value.length > 0;
|
|
7
|
+
function issuedAtSeconds(iat, exp) {
|
|
8
|
+
const seconds = typeof iat === "number" ? iat : typeof exp === "number" ? exp - PORTAL_SESSION_SECONDS : null;
|
|
9
|
+
if (seconds === null || !Number.isFinite(seconds) || seconds <= 0) {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
return Math.floor(seconds);
|
|
13
|
+
}
|
|
5
14
|
function createSubdomainAuth(config) {
|
|
6
15
|
const {
|
|
7
16
|
env,
|
|
@@ -13,7 +22,10 @@ function createSubdomainAuth(config) {
|
|
|
13
22
|
cookieName = DEFAULT_COOKIE_NAME,
|
|
14
23
|
sessionExpiryMs = ONE_YEAR_MS,
|
|
15
24
|
serviceName = "Subdomain",
|
|
16
|
-
portalUserCacheTtlMs = 3e4
|
|
25
|
+
portalUserCacheTtlMs = 3e4,
|
|
26
|
+
sessionCheck = true,
|
|
27
|
+
sessionCheckCacheTtlMs = 3e4,
|
|
28
|
+
sessionCheckTimeoutMs = 2e3
|
|
17
29
|
} = config;
|
|
18
30
|
const tag = `[${serviceName} Auth]`;
|
|
19
31
|
if (userSource === "db" && !db) {
|
|
@@ -91,7 +103,7 @@ function createSubdomainAuth(config) {
|
|
|
91
103
|
return false;
|
|
92
104
|
}
|
|
93
105
|
}
|
|
94
|
-
async function
|
|
106
|
+
async function verifyCookie(cookieValue) {
|
|
95
107
|
if (!cookieValue) {
|
|
96
108
|
console.warn(`${tag} Missing session cookie`);
|
|
97
109
|
return null;
|
|
@@ -102,12 +114,16 @@ function createSubdomainAuth(config) {
|
|
|
102
114
|
const { payload } = await jose.jwtVerify(cookieValue, portalKey, {
|
|
103
115
|
algorithms: ["HS256"]
|
|
104
116
|
});
|
|
105
|
-
const { openId, appId, name } = payload;
|
|
117
|
+
const { openId, appId, name, sid, iat, exp } = payload;
|
|
106
118
|
if (!isNonEmptyString(openId) || !isNonEmptyString(appId) || !isNonEmptyString(name)) {
|
|
107
119
|
console.warn(`${tag} Portal JWT payload missing required fields`);
|
|
108
120
|
return null;
|
|
109
121
|
}
|
|
110
|
-
return {
|
|
122
|
+
return {
|
|
123
|
+
session: { openId, appId, name },
|
|
124
|
+
...isNonEmptyString(sid) ? { sid } : {},
|
|
125
|
+
iat: issuedAtSeconds(iat, exp)
|
|
126
|
+
};
|
|
111
127
|
} catch (error) {
|
|
112
128
|
console.warn(
|
|
113
129
|
`${tag} Session verification failed:`,
|
|
@@ -116,6 +132,9 @@ function createSubdomainAuth(config) {
|
|
|
116
132
|
return null;
|
|
117
133
|
}
|
|
118
134
|
}
|
|
135
|
+
async function verifySession(cookieValue) {
|
|
136
|
+
return (await verifyCookie(cookieValue))?.session ?? null;
|
|
137
|
+
}
|
|
119
138
|
async function getPortalUser(openId) {
|
|
120
139
|
try {
|
|
121
140
|
const client = await getPortalClientAsync();
|
|
@@ -180,16 +199,112 @@ function createSubdomainAuth(config) {
|
|
|
180
199
|
portalUserId: portalUser.id
|
|
181
200
|
};
|
|
182
201
|
}
|
|
202
|
+
const sessionCheckCache = /* @__PURE__ */ new Map();
|
|
203
|
+
const sessionCheckInflight = /* @__PURE__ */ new Map();
|
|
204
|
+
const warned = /* @__PURE__ */ new Set();
|
|
205
|
+
function warnOnce(key, message) {
|
|
206
|
+
if (warned.has(key)) return;
|
|
207
|
+
warned.add(key);
|
|
208
|
+
console.warn(message);
|
|
209
|
+
}
|
|
210
|
+
function rememberSessionCheck(key, active) {
|
|
211
|
+
sessionCheckCache.delete(key);
|
|
212
|
+
sessionCheckCache.set(key, { active, at: Date.now() });
|
|
213
|
+
if (sessionCheckCache.size > SESSION_CHECK_CACHE_MAX) {
|
|
214
|
+
const oldest = sessionCheckCache.keys().next().value;
|
|
215
|
+
if (oldest !== void 0) sessionCheckCache.delete(oldest);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
async function askPortalSessionActive(body) {
|
|
219
|
+
try {
|
|
220
|
+
const client = await getPortalClientAsync();
|
|
221
|
+
if (!client) return null;
|
|
222
|
+
const { data } = await client.post(
|
|
223
|
+
"/api/service/sessions/check",
|
|
224
|
+
body,
|
|
225
|
+
{
|
|
226
|
+
// axios's timeout only covers an idle socket; the signal caps the
|
|
227
|
+
// whole call, so a Portal that answers slowly can't stall requests.
|
|
228
|
+
timeout: sessionCheckTimeoutMs,
|
|
229
|
+
signal: AbortSignal.timeout(sessionCheckTimeoutMs)
|
|
230
|
+
}
|
|
231
|
+
);
|
|
232
|
+
if (typeof data?.active === "boolean") return data.active;
|
|
233
|
+
console.warn(
|
|
234
|
+
`${tag} Portal session check gave no answer for ${body.openId} \u2014 keeping session`
|
|
235
|
+
);
|
|
236
|
+
return null;
|
|
237
|
+
} catch (err) {
|
|
238
|
+
const status = err?.response?.status;
|
|
239
|
+
if (status === 404) {
|
|
240
|
+
warnOnce(
|
|
241
|
+
"check-404",
|
|
242
|
+
`${tag} Portal has no session check yet (404) \u2014 sessions signed out in the Portal are not refused until it ships`
|
|
243
|
+
);
|
|
244
|
+
} else if (status === 401) {
|
|
245
|
+
console.error(
|
|
246
|
+
`${tag} Portal API key refused (401) on session check \u2014 keeping session`
|
|
247
|
+
);
|
|
248
|
+
} else {
|
|
249
|
+
console.warn(
|
|
250
|
+
`${tag} Portal session check failed for ${body.openId} (${status ?? err.message}) \u2014 keeping session`
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
async function isSessionActive(cookie) {
|
|
257
|
+
if (!sessionCheck) return true;
|
|
258
|
+
if (!env.portalApiUrl) {
|
|
259
|
+
warnOnce(
|
|
260
|
+
"no-portal-url",
|
|
261
|
+
`${tag} No Portal API URL \u2014 sessions signed out in the Portal are not refused`
|
|
262
|
+
);
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
const { openId } = cookie.session;
|
|
266
|
+
if (cookie.iat === null) {
|
|
267
|
+
warnOnce(
|
|
268
|
+
"no-iat",
|
|
269
|
+
`${tag} Session cookie for ${openId} has neither iat nor exp \u2014 not checked with the Portal`
|
|
270
|
+
);
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
const key = JSON.stringify([openId, cookie.sid ?? null, cookie.iat]);
|
|
274
|
+
const hit = sessionCheckCache.get(key);
|
|
275
|
+
if (hit && Date.now() - hit.at < sessionCheckCacheTtlMs) return hit.active;
|
|
276
|
+
let pending = sessionCheckInflight.get(key);
|
|
277
|
+
if (!pending) {
|
|
278
|
+
pending = askPortalSessionActive({
|
|
279
|
+
openId,
|
|
280
|
+
...cookie.sid ? { sid: cookie.sid } : {},
|
|
281
|
+
iat: cookie.iat
|
|
282
|
+
}).then((answer) => {
|
|
283
|
+
const active = answer ?? hit?.active ?? true;
|
|
284
|
+
rememberSessionCheck(key, active);
|
|
285
|
+
return active;
|
|
286
|
+
}).finally(() => sessionCheckInflight.delete(key));
|
|
287
|
+
sessionCheckInflight.set(key, pending);
|
|
288
|
+
}
|
|
289
|
+
return pending;
|
|
290
|
+
}
|
|
183
291
|
async function authenticateRequest(req) {
|
|
184
292
|
const cookie = await getCookie();
|
|
185
293
|
const cookies = req.headers.cookie ? new Map(Object.entries(cookie.parseCookie(req.headers.cookie))) : /* @__PURE__ */ new Map();
|
|
186
294
|
const sessionCookie = cookies.get(cookieName);
|
|
187
|
-
const
|
|
188
|
-
if (!
|
|
295
|
+
const verified = await verifyCookie(sessionCookie);
|
|
296
|
+
if (!verified) {
|
|
189
297
|
throw Object.assign(new Error("Invalid session cookie"), {
|
|
190
298
|
statusCode: 403
|
|
191
299
|
});
|
|
192
300
|
}
|
|
301
|
+
if (!await isSessionActive(verified)) {
|
|
302
|
+
throw Object.assign(new Error("Session ended"), {
|
|
303
|
+
statusCode: 403,
|
|
304
|
+
code: "SESSION_ENDED"
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
const { session } = verified;
|
|
193
308
|
const sessionUserId = session.openId;
|
|
194
309
|
const signedInAt = /* @__PURE__ */ new Date();
|
|
195
310
|
if (userSource === "portal") {
|
|
@@ -278,4 +393,4 @@ function createSubdomainAuth(config) {
|
|
|
278
393
|
export {
|
|
279
394
|
createSubdomainAuth
|
|
280
395
|
};
|
|
281
|
-
//# sourceMappingURL=chunk-
|
|
396
|
+
//# sourceMappingURL=chunk-PIWC3MQG.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/auth/createSubdomainAuth.ts"],"sourcesContent":["/**\n * createSubdomainAuth — shared SSO auth factory for Oplytics.digital subdomains.\n *\n * Encapsulates:\n * - JWT session verification (PORTAL_JWT_SECRET first, fallback to JWT_SECRET)\n * - Cookie domain handling via COOKIE_DOMAIN env var\n * - Portal API user enrichment with X-Service-Key header\n * - Auto-create user from JWT payload if not in local DB\n * - Enterprise backfill from portal API\n * - Portal session check: a device signed out in the Portal is refused here\n * too, cached for 30s, failing open when the Portal can't answer (#77)\n *\n * Based on SQDCP's battle-tested sdk.ts + cookies.ts.\n */\n\nimport type { Request, CookieOptions } from \"express\";\nimport type {\n AuthEnv,\n BaseUser,\n DbAdapter,\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;\n/** The Portal issues every session cookie for one year (portal#237). */\nconst PORTAL_SESSION_SECONDS = 31_536_000;\n/** Most session check answers kept at once; the oldest is dropped first. */\nconst SESSION_CHECK_CACHE_MAX = 10_000;\n\n// ─── Helpers ────────────────────────────────────────────────────────\n\nconst isNonEmptyString = (value: unknown): value is string =>\n typeof value === \"string\" && value.length > 0;\n\n/**\n * The cookie's issue time in seconds, as the Portal's session check needs it.\n * Cookies from before portal#237 have no iat; every Portal session cookie is\n * issued for one year, so exp minus a year is when it was issued. Never\n * guessed as 0: the Portal would read that as issued before any sign-out\n * cutoff and end a live session.\n */\nfunction issuedAtSeconds(iat: unknown, exp: unknown): number | null {\n const seconds =\n typeof iat === \"number\"\n ? iat\n : typeof exp === \"number\"\n ? exp - PORTAL_SESSION_SECONDS\n : null;\n if (seconds === null || !Number.isFinite(seconds) || seconds <= 0) {\n return null;\n }\n return Math.floor(seconds);\n}\n\n/** A verified cookie: the public payload, plus what the session check needs. */\ninterface VerifiedCookie {\n session: SessionPayload;\n /** The Portal session id. Absent on cookies issued before portal#237. */\n sid?: string;\n /** Issue time in seconds; null when the cookie has neither iat nor exp. */\n iat: number | null;\n}\n\n// ─── Factory ────────────────────────────────────────────────────────\n\nexport function createSubdomainAuth<TUser extends BaseUser = BaseUser>(\n config: SubdomainAuthConfig<TUser>\n): SubdomainAuth<TUser> {\n const {\n env,\n db,\n // Default to identity (no role translation). Subdomains should adopt the\n // Portal's canonical roles verbatim; a custom mapper is legacy.\n mapRole = (role: string) => role,\n userSource = \"db\",\n cookieName = DEFAULT_COOKIE_NAME,\n sessionExpiryMs = ONE_YEAR_MS,\n serviceName = \"Subdomain\",\n portalUserCacheTtlMs = 30_000,\n sessionCheck = true,\n sessionCheckCacheTtlMs = 30_000,\n sessionCheckTimeoutMs = 2_000,\n } = config;\n\n const tag = `[${serviceName} Auth]`;\n\n if (userSource === \"db\" && !db) {\n throw new Error(\n `${tag} userSource \"db\" requires a db adapter in createSubdomainAuth config`\n );\n }\n\n // Short-TTL cache of Portal users (portal mode), keyed by openId, so we don't\n // hit the Portal on every request.\n const portalUserCache = new Map<string, { user: PortalUser; at: number }>();\n\n // ── Lazy-loaded dependencies (jose, cookie, axios) ──────────────\n // We use dynamic imports so consumers don't need to install these\n // as peer deps if they only use other core-server features.\n\n let _jose: typeof import(\"jose\") | null = null;\n async function getJose() {\n if (!_jose) _jose = await import(\"jose\");\n return _jose;\n }\n\n let _cookie: typeof import(\"cookie\") | null = null;\n async function getCookie() {\n if (!_cookie) _cookie = await import(\"cookie\");\n return _cookie;\n }\n\n let _axios: typeof import(\"axios\").default | null = null;\n async function getAxios() {\n if (!_axios) {\n const mod = await import(\"axios\");\n _axios = mod.default;\n }\n return _axios;\n }\n\n // ── Portal API client ──────────────────────────────────────────\n\n let portalClient: import(\"axios\").AxiosInstance | null = null;\n\n async function getPortalClientAsync() {\n if (portalClient) return portalClient;\n if (!env.portalApiUrl) return null;\n const axios = await getAxios();\n portalClient = axios.create({\n baseURL: env.portalApiUrl,\n timeout: 10_000,\n headers: {\n \"Content-Type\": \"application/json\",\n ...(env.portalApiKey ? { \"X-Service-Key\": env.portalApiKey } : {}),\n },\n });\n return portalClient;\n }\n\n // ── JWT Secret ─────────────────────────────────────────────────\n\n function getPortalSecret() {\n const secret = env.portalJwtSecret;\n if (!secret) throw new Error(\"PORTAL_JWT_SECRET is not configured\");\n return new TextEncoder().encode(secret);\n }\n\n // ── Cookie Helpers ─────────────────────────────────────────────\n\n function getCookieDomain(): string | undefined {\n return env.cookieDomain || undefined;\n }\n\n function isSecureRequest(req: Request): boolean {\n if (req.protocol === \"https\") return true;\n const forwardedProto = req.headers[\"x-forwarded-proto\"];\n if (!forwardedProto) return false;\n const protoList = Array.isArray(forwardedProto)\n ? forwardedProto\n : forwardedProto.split(\",\");\n return protoList.some(p => p.trim().toLowerCase() === \"https\");\n }\n\n function getSessionCookieOptions(\n req: Request\n ): Pick<\n CookieOptions,\n \"domain\" | \"httpOnly\" | \"path\" | \"sameSite\" | \"secure\"\n > {\n const secure = isSecureRequest(req);\n const domain = getCookieDomain();\n return {\n domain,\n httpOnly: true,\n path: \"/\",\n sameSite: \"lax\",\n secure,\n };\n }\n\n function isValidReturnUrl(url: string): boolean {\n if (url.startsWith(\"/\") && !url.startsWith(\"//\")) return true;\n try {\n const parsed = new URL(url);\n if (parsed.protocol !== \"https:\") return false;\n return (\n parsed.hostname === \"oplyticsdigital.net\" ||\n parsed.hostname.endsWith(\".oplyticsdigital.net\")\n );\n } catch {\n return false;\n }\n }\n\n // ── Session Verification ───────────────────────────────────────\n\n /**\n * Verify session cookie against PORTAL_JWT_SECRET (cross-subdomain SSO).\n *\n * Portal is the only issuer of session cookies for subdomains using this\n * factory — the old Manus OAuth login path (which signed its own cookies\n * with a local JWT_SECRET) was removed. A cookie that fails portal\n * verification is invalid, full stop.\n */\n async function verifyCookie(\n cookieValue: string | undefined | null\n ): Promise<VerifiedCookie | null> {\n if (!cookieValue) {\n console.warn(`${tag} Missing session cookie`);\n return null;\n }\n\n const jose = await getJose();\n\n try {\n const portalKey = getPortalSecret();\n const { payload } = await jose.jwtVerify(cookieValue, portalKey, {\n algorithms: [\"HS256\"],\n });\n const { openId, appId, name, sid, iat, exp } = payload as Record<\n string,\n unknown\n >;\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 {\n session: { openId, appId, name },\n ...(isNonEmptyString(sid) ? { sid } : {}),\n iat: issuedAtSeconds(iat, exp),\n };\n } catch (error) {\n console.warn(\n `${tag} Session verification failed:`,\n (error as Error).message\n );\n return null;\n }\n }\n\n async function verifySession(\n cookieValue: string | undefined | null\n ): Promise<SessionPayload | null> {\n return (await verifyCookie(cookieValue))?.session ?? null;\n }\n\n // ── Portal API ─────────────────────────────────────────────────\n\n async function getPortalUser(openId: string): Promise<PortalUser | null> {\n try {\n const client = await getPortalClientAsync();\n if (!client) return null;\n // The Portal Service API is under /api/service (base URL is the origin,\n // e.g. https://portal.oplyticsdigital.net). Must include the /api/service\n // prefix — omitting it 404s and silently strands enterprise resolution.\n const { data } = await client.get<PortalUser>(\n `/api/service/users/by-openid/${encodeURIComponent(openId)}`\n );\n return data ?? null;\n } catch (err: unknown) {\n const status = (err as any)?.response?.status;\n if (status === 401) {\n console.error(\n `${tag} Portal API key revoked (401) — enrichment disabled`\n );\n } else if (status === 404) {\n // User not found in portal — normal for first-time users\n } else {\n console.warn(\n `${tag} Portal user lookup failed:`,\n (err as Error).message\n );\n }\n return null;\n }\n }\n\n /** Portal user lookup with a short-TTL cache (portal mode). */\n async function getPortalUserCached(\n openId: string\n ): Promise<PortalUser | null> {\n if (portalUserCacheTtlMs > 0) {\n const hit = portalUserCache.get(openId);\n if (hit && Date.now() - hit.at < portalUserCacheTtlMs) {\n return hit.user;\n }\n }\n const user = await getPortalUser(openId);\n if (user && portalUserCacheTtlMs > 0) {\n portalUserCache.set(openId, { user, at: Date.now() });\n }\n return user;\n }\n\n /**\n * Build ctx.user entirely from the Portal record — no local persistence.\n * The single source of truth. ctx.user.id is the PORTAL user id. Roles and\n * enterprise come from the Portal verbatim (no mapping).\n *\n * On a genuine Portal outage (the by-openid lookup returns null) we keep the\n * user authenticated using their verified session claims, with role/enterprise\n * unresolved, rather than throwing. Throwing here would make createContext (in\n * every subdomain) swallow the error into `user = null`, which the AuthGate\n * renders as the Sign In screen — i.e. a transient Portal blip would log the\n * user out. Their session cookie is already valid; keep them in, degraded,\n * until the Portal recovers. (Enterprise-scoped procedures will 403 during the\n * outage; client array-guards keep pages from crashing.)\n */\n async function resolvePortalUser(\n openId: string,\n session: SessionPayload\n ): Promise<TUser> {\n const portalUser = await getPortalUserCached(openId);\n\n if (!portalUser) {\n console.warn(\n `${tag} Portal user unavailable for ${openId} — keeping session (role/enterprise unresolved until Portal recovers)`\n );\n return {\n id: 0,\n openId,\n name: session.name ?? null,\n email: null,\n role: \"\",\n enterpriseId: null,\n portalUserId: null,\n } as unknown as TUser;\n }\n\n const enterpriseId =\n portalUser.enterpriseId ?? portalUser.companyId ?? null;\n return {\n id: portalUser.id,\n openId: portalUser.openId ?? openId,\n name: portalUser.name ?? session.name ?? null,\n email: portalUser.email ?? null,\n role: portalUser.role,\n enterpriseId,\n portalUserId: portalUser.id,\n } as unknown as TUser;\n }\n\n // ── Portal Session Check ───────────────────────────────────────\n //\n // Fails open: only an explicit `{ active: false }` from the Portal ends a\n // session. The check runs on every request to every app, so failing closed\n // would turn a Portal outage, a slow deploy or a key problem into everyone\n // being signed out of everything at once (and nobody could sign back in\n // while the Portal is down). The cost: while the Portal can't answer, a\n // device signed out after its last answer keeps working here until the\n // Portal is back. A session the Portal has already called ended stays\n // ended through an outage.\n\n // Answers keyed by openId + sid + iat, because the Portal judges all three:\n // an old copy of a cookie keeps its sid but its earlier iat is cut off.\n const sessionCheckCache = new Map<string, { active: boolean; at: number }>();\n const sessionCheckInflight = new Map<string, Promise<boolean>>();\n const warned = new Set<string>();\n\n function warnOnce(key: string, message: string) {\n if (warned.has(key)) return;\n warned.add(key);\n console.warn(message);\n }\n\n function rememberSessionCheck(key: string, active: boolean) {\n // Re-insert so Map order stays oldest-first for eviction.\n sessionCheckCache.delete(key);\n sessionCheckCache.set(key, { active, at: Date.now() });\n if (sessionCheckCache.size > SESSION_CHECK_CACHE_MAX) {\n const oldest = sessionCheckCache.keys().next().value;\n if (oldest !== undefined) sessionCheckCache.delete(oldest);\n }\n }\n\n /**\n * One call to POST /api/service/sessions/check. Returns the Portal's\n * answer, or null when it gave none (unreachable, slow, error, odd reply).\n */\n async function askPortalSessionActive(body: {\n openId: string;\n sid?: string;\n iat: number;\n }): Promise<boolean | null> {\n try {\n const client = await getPortalClientAsync();\n if (!client) return null;\n const { data } = await client.post<{ active?: unknown }>(\n \"/api/service/sessions/check\",\n body,\n {\n // axios's timeout only covers an idle socket; the signal caps the\n // whole call, so a Portal that answers slowly can't stall requests.\n timeout: sessionCheckTimeoutMs,\n signal: AbortSignal.timeout(sessionCheckTimeoutMs),\n }\n );\n if (typeof data?.active === \"boolean\") return data.active;\n console.warn(\n `${tag} Portal session check gave no answer for ${body.openId} — keeping session`\n );\n return null;\n } catch (err: unknown) {\n const status = (err as any)?.response?.status;\n if (status === 404) {\n warnOnce(\n \"check-404\",\n `${tag} Portal has no session check yet (404) — sessions signed out in the Portal are not refused until it ships`\n );\n } else if (status === 401) {\n console.error(\n `${tag} Portal API key refused (401) on session check — keeping session`\n );\n } else {\n console.warn(\n `${tag} Portal session check failed for ${body.openId} (${status ?? (err as Error).message}) — keeping session`\n );\n }\n return null;\n }\n }\n\n /** Is the session behind this cookie still live in the Portal? */\n async function isSessionActive(cookie: VerifiedCookie): Promise<boolean> {\n if (!sessionCheck) return true;\n if (!env.portalApiUrl) {\n warnOnce(\n \"no-portal-url\",\n `${tag} No Portal API URL — sessions signed out in the Portal are not refused`\n );\n return true;\n }\n const { openId } = cookie.session;\n if (cookie.iat === null) {\n warnOnce(\n \"no-iat\",\n `${tag} Session cookie for ${openId} has neither iat nor exp — not checked with the Portal`\n );\n return true;\n }\n\n const key = JSON.stringify([openId, cookie.sid ?? null, cookie.iat]);\n const hit = sessionCheckCache.get(key);\n if (hit && Date.now() - hit.at < sessionCheckCacheTtlMs) return hit.active;\n\n // Parallel requests from one page load share a single Portal call.\n let pending = sessionCheckInflight.get(key);\n if (!pending) {\n pending = askPortalSessionActive({\n openId,\n ...(cookie.sid ? { sid: cookie.sid } : {}),\n iat: cookie.iat,\n })\n .then(answer => {\n // No answer: keep an earlier \"ended\", otherwise let it through.\n // Remembered either way, so a down Portal isn't asked every request.\n const active = answer ?? hit?.active ?? true;\n rememberSessionCheck(key, active);\n return active;\n })\n .finally(() => sessionCheckInflight.delete(key));\n sessionCheckInflight.set(key, pending);\n }\n return pending;\n }\n\n // ── Authenticate Request ───────────────────────────────────────\n\n async function authenticateRequest(req: Request): Promise<TUser> {\n const cookie = await getCookie();\n const cookies = req.headers.cookie\n ? new Map(Object.entries(cookie.parseCookie(req.headers.cookie)))\n : new Map<string, string | undefined>();\n\n const sessionCookie = cookies.get(cookieName);\n const verified = await verifyCookie(sessionCookie);\n\n if (!verified) {\n throw Object.assign(new Error(\"Invalid session cookie\"), {\n statusCode: 403,\n });\n }\n\n // A device signed out in the Portal is refused here too (#77).\n if (!(await isSessionActive(verified))) {\n throw Object.assign(new Error(\"Session ended\"), {\n statusCode: 403,\n code: \"SESSION_ENDED\",\n });\n }\n\n const { session } = verified;\n\n const sessionUserId = session.openId;\n const signedInAt = new Date();\n\n // ── Portal mode: single source of truth, no local mirror ────\n if (userSource === \"portal\") {\n return await resolvePortalUser(sessionUserId, session);\n }\n\n let user = (await db!.getUserByOpenId(sessionUserId)) ?? null;\n\n // ── Auto-create user from verified JWT ──────────────────────\n if (!user) {\n try {\n // Try portal API first for richer user info\n const portalUser = await getPortalUser(sessionUserId);\n\n if (portalUser) {\n console.log(\n `${tag} Cross-subdomain SSO: creating user from portal (${portalUser.email || sessionUserId})`\n );\n // Portal returns `enterpriseId`; `companyId` is a legacy alias.\n const portalEnterpriseId =\n portalUser.enterpriseId ?? portalUser.companyId ?? null;\n await db!.upsertUser({\n openId: sessionUserId,\n name: portalUser.name || session.name || null,\n email: portalUser.email ?? null,\n loginMethod: \"portal-sso\",\n role: mapRole(portalUser.role),\n lastSignedIn: signedInAt,\n ...(portalEnterpriseId != null\n ? {\n enterpriseId: portalEnterpriseId,\n portalUserId: portalUser.id,\n }\n : {}),\n });\n user = (await db!.getUserByOpenId(sessionUserId)) ?? null;\n } else {\n // Portal unavailable — create from JWT payload\n console.log(\n `${tag} Cross-subdomain SSO: creating user from JWT payload (${sessionUserId})`\n );\n await db!.upsertUser({\n openId: sessionUserId,\n name: session.name || null,\n loginMethod: \"portal-sso\",\n lastSignedIn: signedInAt,\n });\n user = (await db!.getUserByOpenId(sessionUserId)) ?? null;\n }\n } catch (error) {\n console.error(`${tag} Failed to sync user info:`, error);\n throw Object.assign(new Error(\"Failed to sync user info\"), {\n statusCode: 403,\n });\n }\n }\n\n if (!user) {\n throw Object.assign(new Error(\"User not found\"), { statusCode: 403 });\n }\n\n // ── Enterprise backfill ─────────────────────────────────────\n if (user.enterpriseId === null) {\n try {\n const portalUser = await getPortalUser(user.openId);\n // Portal returns `enterpriseId`; `companyId` is a legacy alias.\n const portalEnterpriseId =\n portalUser?.enterpriseId ?? portalUser?.companyId ?? null;\n if (portalEnterpriseId != null) {\n console.log(\n `${tag} Backfilling enterpriseId=${portalEnterpriseId} for user ${user.id}`\n );\n await db!.updateUserFields(user.id, {\n enterpriseId: portalEnterpriseId,\n ...(portalUser?.id ? { portalUserId: portalUser.id } : {}),\n });\n user = (await db!.getUserByOpenId(user.openId)) ?? user;\n }\n } catch (backfillError) {\n console.warn(\n `${tag} Failed to backfill enterpriseId for user ${user.id}:`,\n backfillError\n );\n // Non-fatal — user can still proceed\n }\n }\n\n // Touch lastSignedIn\n await db!.upsertUser({\n openId: user.openId,\n lastSignedIn: signedInAt,\n });\n\n return user;\n }\n\n // ── Return public API ──────────────────────────────────────────\n\n return {\n verifySession,\n authenticateRequest,\n getPortalUser,\n getSessionCookieOptions,\n isValidReturnUrl,\n };\n}\n"],"mappings":"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","names":[]}
|
package/dist/index.js
CHANGED
package/dist/isolation.d.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import * as _trpc_server from '@trpc/server';
|
|
2
|
-
import { RoleType } from '@pablo2410/shared-ui/rbac';
|
|
3
2
|
|
|
4
3
|
/** The user fields isolation needs. Each app's session user must have these. */
|
|
5
4
|
interface IsolationUser {
|
|
@@ -15,11 +14,14 @@ interface EnterpriseOwned {
|
|
|
15
14
|
enterpriseId?: number | null;
|
|
16
15
|
}
|
|
17
16
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
17
|
+
* The fleet's one admin rule: enterprise_admin and above (enterprise_admin,
|
|
18
|
+
* platform_admin). Paul, 5 Oct 2026 (core-server#74). Every admin check in
|
|
19
|
+
* every app uses this; no app sets its own admin threshold, and superuser is
|
|
20
|
+
* not an admin level.
|
|
21
21
|
*/
|
|
22
|
-
declare
|
|
22
|
+
declare const ADMIN_MIN_ROLE: "enterprise_admin";
|
|
23
|
+
/** True when `role` passes the fleet admin rule (ADMIN_MIN_ROLE and above). */
|
|
24
|
+
declare function isAdminRole(role: string | null | undefined): boolean;
|
|
23
25
|
/**
|
|
24
26
|
* The caller's enterprise, or null. Never a default tenant: a missing,
|
|
25
27
|
* zero, negative or non-integer enterpriseId is null.
|
|
@@ -48,16 +50,15 @@ declare function assertEnterpriseOwnership<T extends EnterpriseOwned>(record: T
|
|
|
48
50
|
* Build the standard procedures for an app whose session user is `TUser`.
|
|
49
51
|
* Attach each one to the app's own tRPC instance with `t.procedure.concat(...)`.
|
|
50
52
|
*
|
|
51
|
-
* | Procedure
|
|
52
|
-
*
|
|
53
|
-
* | protectedProcedure
|
|
54
|
-
* | enterpriseProcedure
|
|
55
|
-
* |
|
|
56
|
-
* | enterpriseAdminProcedure
|
|
57
|
-
* | platformAdminProcedure
|
|
58
|
-
* | enterpriseRoleProcedure(r) | Yes | Yes | r |
|
|
59
|
-
* | roleProcedure(r) | Yes | No | r |
|
|
53
|
+
* | Procedure | Auth | Enterprise | Min role |
|
|
54
|
+
* |--------------------------|:----:|:----------:|-----------------------------|
|
|
55
|
+
* | protectedProcedure | Yes | No | any |
|
|
56
|
+
* | enterpriseProcedure | Yes | Yes | any |
|
|
57
|
+
* | adminProcedure | Yes | No | ADMIN_MIN_ROLE (admin gate) |
|
|
58
|
+
* | enterpriseAdminProcedure | Yes | Yes | ADMIN_MIN_ROLE (admin gate) |
|
|
59
|
+
* | platformAdminProcedure | Yes | No | platform_admin |
|
|
60
60
|
*
|
|
61
|
+
* Both admin procedures share one admin gate built from ADMIN_MIN_ROLE.
|
|
61
62
|
* Enterprise procedures add `ctx.enterpriseId: number` and narrow `ctx.user`
|
|
62
63
|
* to non-null. Checks run in order: user, then enterprise, then role.
|
|
63
64
|
*/
|
|
@@ -69,8 +70,7 @@ declare function createIsolationProcedures<TUser extends IsolationUser = Isolati
|
|
|
69
70
|
enterpriseId: number;
|
|
70
71
|
user: TUser;
|
|
71
72
|
}, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, false>;
|
|
72
|
-
|
|
73
|
-
enterpriseId: number;
|
|
73
|
+
adminProcedure: _trpc_server.TRPCProcedureBuilder<IsolationContext<TUser>, object, {
|
|
74
74
|
user: TUser;
|
|
75
75
|
}, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, false>;
|
|
76
76
|
enterpriseAdminProcedure: _trpc_server.TRPCProcedureBuilder<IsolationContext<TUser>, object, {
|
|
@@ -80,14 +80,7 @@ declare function createIsolationProcedures<TUser extends IsolationUser = Isolati
|
|
|
80
80
|
platformAdminProcedure: _trpc_server.TRPCProcedureBuilder<IsolationContext<TUser>, object, {
|
|
81
81
|
user: TUser;
|
|
82
82
|
}, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, false>;
|
|
83
|
-
enterpriseRoleProcedure: (minimumRole: RoleType, message?: string) => _trpc_server.TRPCProcedureBuilder<IsolationContext<TUser>, object, {
|
|
84
|
-
enterpriseId: number;
|
|
85
|
-
user: TUser;
|
|
86
|
-
}, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, false>;
|
|
87
|
-
roleProcedure: (minimumRole: RoleType, message?: string) => _trpc_server.TRPCProcedureBuilder<IsolationContext<TUser>, object, {
|
|
88
|
-
user: TUser;
|
|
89
|
-
}, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, _trpc_server.TRPCUnsetMarker, false>;
|
|
90
83
|
};
|
|
91
84
|
type IsolationProcedures<TUser extends IsolationUser = IsolationUser> = ReturnType<typeof createIsolationProcedures<TUser>>;
|
|
92
85
|
|
|
93
|
-
export { type EnterpriseOwned, type IsolationContext, type IsolationProcedures, type IsolationUser, type OwnershipMessages, assertEnterpriseOwnership, createIsolationProcedures,
|
|
86
|
+
export { ADMIN_MIN_ROLE, type EnterpriseOwned, type IsolationContext, type IsolationProcedures, type IsolationUser, type OwnershipMessages, assertEnterpriseOwnership, createIsolationProcedures, isAdminRole, requireEnterpriseId, resolveEnterpriseId, scopeToEnterprise };
|
package/dist/isolation.js
CHANGED
|
@@ -4,7 +4,6 @@ import {
|
|
|
4
4
|
NO_ENTERPRISE_ERR_MSG,
|
|
5
5
|
NOT_ADMIN_ERR_MSG,
|
|
6
6
|
NOT_PLATFORM_ADMIN_ERR_MSG,
|
|
7
|
-
NOT_SUPERUSER_ERR_MSG,
|
|
8
7
|
ROLE_HIERARCHY,
|
|
9
8
|
UNAUTHED_ERR_MSG
|
|
10
9
|
} from "@pablo2410/shared-ui/rbac";
|
|
@@ -16,6 +15,10 @@ function meetsMinimumRole(userRole, minimumRole) {
|
|
|
16
15
|
const min = ROLE_HIERARCHY.indexOf(minimumRole);
|
|
17
16
|
return user >= 0 && min >= 0 && user >= min;
|
|
18
17
|
}
|
|
18
|
+
var ADMIN_MIN_ROLE = "enterprise_admin";
|
|
19
|
+
function isAdminRole(role) {
|
|
20
|
+
return meetsMinimumRole(role, ADMIN_MIN_ROLE);
|
|
21
|
+
}
|
|
19
22
|
function resolveEnterpriseId(user) {
|
|
20
23
|
const id = user?.enterpriseId;
|
|
21
24
|
return isEnterpriseId(id) ? id : null;
|
|
@@ -78,37 +81,24 @@ function createIsolationProcedures() {
|
|
|
78
81
|
return next();
|
|
79
82
|
});
|
|
80
83
|
}
|
|
84
|
+
const requireAdmin = requireRole(ADMIN_MIN_ROLE, NOT_ADMIN_ERR_MSG);
|
|
81
85
|
const protectedProcedure = t.procedure.use(requireUser);
|
|
82
86
|
const enterpriseProcedure = t.procedure.use(requireEnterprise);
|
|
83
|
-
function enterpriseRoleProcedure(minimumRole, message = NOT_ADMIN_ERR_MSG) {
|
|
84
|
-
return enterpriseProcedure.use(requireRole(minimumRole, message));
|
|
85
|
-
}
|
|
86
|
-
function roleProcedure(minimumRole, message = NOT_ADMIN_ERR_MSG) {
|
|
87
|
-
return protectedProcedure.use(requireRole(minimumRole, message));
|
|
88
|
-
}
|
|
89
87
|
return {
|
|
90
88
|
protectedProcedure,
|
|
91
89
|
enterpriseProcedure,
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
"enterprise_admin",
|
|
98
|
-
NOT_ADMIN_ERR_MSG
|
|
99
|
-
),
|
|
100
|
-
platformAdminProcedure: roleProcedure(
|
|
101
|
-
"platform_admin",
|
|
102
|
-
NOT_PLATFORM_ADMIN_ERR_MSG
|
|
103
|
-
),
|
|
104
|
-
enterpriseRoleProcedure,
|
|
105
|
-
roleProcedure
|
|
90
|
+
adminProcedure: protectedProcedure.use(requireAdmin),
|
|
91
|
+
enterpriseAdminProcedure: enterpriseProcedure.use(requireAdmin),
|
|
92
|
+
platformAdminProcedure: protectedProcedure.use(
|
|
93
|
+
requireRole("platform_admin", NOT_PLATFORM_ADMIN_ERR_MSG)
|
|
94
|
+
)
|
|
106
95
|
};
|
|
107
96
|
}
|
|
108
97
|
export {
|
|
98
|
+
ADMIN_MIN_ROLE,
|
|
109
99
|
assertEnterpriseOwnership,
|
|
110
100
|
createIsolationProcedures,
|
|
111
|
-
|
|
101
|
+
isAdminRole,
|
|
112
102
|
requireEnterpriseId,
|
|
113
103
|
resolveEnterpriseId,
|
|
114
104
|
scopeToEnterprise
|
package/dist/isolation.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/isolation.ts"],"sourcesContent":["/**\n * Enterprise Isolation — the one enforced tenant-scoping path for every service.\n *\n * Replaces the per-app copies of `requireEnterprise` / `enterpriseProcedure` and\n * the hand-written `rows.filter(r => r.enterpriseId === id)` and \"does this\n * record belong to the caller?\" checks. Roles and error messages come from\n * `@pablo2410/shared-ui/rbac`, the platform's single role model.\n *\n * Every check fails closed: no user → UNAUTHORIZED; no (valid) enterprise →\n * FORBIDDEN / no rows; an unknown role never passes a role check; a record with\n * no enterpriseId never matches, even against a caller with none.\n *\n * Quick example (each app keeps its own `t`; the procedures are concatenated\n * onto it, so the app's transformer, context and error formatter still apply):\n *\n * import { createIsolationProcedures } from \"@pablo2410/core-server/isolation\";\n * const iso = createIsolationProcedures<SessionUser>();\n * export const enterpriseProcedure = t.procedure.concat(iso.enterpriseProcedure);\n * // in a resolver: ctx.enterpriseId is a number, ctx.user is non-null\n * listPlans: enterpriseProcedure.query(async ({ ctx }) =>\n * scopeToEnterprise(await getPlans(), ctx.enterpriseId)),\n */\nimport { initTRPC, TRPCError } from \"@trpc/server\";\nimport {\n NO_ENTERPRISE_ERR_MSG,\n NOT_ADMIN_ERR_MSG,\n NOT_PLATFORM_ADMIN_ERR_MSG,\n NOT_SUPERUSER_ERR_MSG,\n ROLE_HIERARCHY,\n UNAUTHED_ERR_MSG,\n type RoleType,\n} from \"@pablo2410/shared-ui/rbac\";\n\n/** The user fields isolation needs. Each app's session user must have these. */\nexport interface IsolationUser {\n role: string;\n enterpriseId?: number | null;\n}\n\n/** The context fields isolation needs. Each app's tRPC context must have these. */\nexport interface IsolationContext<TUser extends IsolationUser = IsolationUser> {\n user: TUser | null;\n}\n\n/** Anything owned by one enterprise (a row from the DB or the Portal API). */\nexport interface EnterpriseOwned {\n enterpriseId?: number | null;\n}\n\n/** Only positive integers are real enterprise IDs. */\nfunction isEnterpriseId(value: unknown): value is number {\n return typeof value === \"number\" && Number.isInteger(value) && value > 0;\n}\n\n/**\n * True when `userRole` is a known role at or above `minimumRole`.\n * Unlike a bare indexOf comparison, an unknown role on either side is never\n * treated as meeting the bar.\n */\nexport function meetsMinimumRole(\n userRole: string | null | undefined,\n minimumRole: RoleType\n): boolean {\n const user = ROLE_HIERARCHY.indexOf(userRole as RoleType);\n const min = ROLE_HIERARCHY.indexOf(minimumRole);\n return user >= 0 && min >= 0 && user >= min;\n}\n\n/**\n * The caller's enterprise, or null. Never a default tenant: a missing,\n * zero, negative or non-integer enterpriseId is null.\n */\nexport function resolveEnterpriseId(\n user: IsolationUser | null | undefined\n): number | null {\n const id = user?.enterpriseId;\n return isEnterpriseId(id) ? id : null;\n}\n\n/** Like resolveEnterpriseId, but throws UNAUTHORIZED / FORBIDDEN instead of null. */\nexport function requireEnterpriseId(\n user: IsolationUser | null | undefined\n): number {\n if (!user) {\n throw new TRPCError({ code: \"UNAUTHORIZED\", message: UNAUTHED_ERR_MSG });\n }\n const id = resolveEnterpriseId(user);\n if (id === null) {\n throw new TRPCError({ code: \"FORBIDDEN\", message: NO_ENTERPRISE_ERR_MSG });\n }\n return id;\n}\n\n/**\n * Keep only the rows owned by `enterpriseId`. A null/invalid enterprise gets no\n * rows; a row with no enterpriseId is never included.\n */\nexport function scopeToEnterprise<T extends EnterpriseOwned>(\n rows: readonly T[],\n enterpriseId: number | null | undefined\n): T[] {\n if (!isEnterpriseId(enterpriseId)) return [];\n return rows.filter(row => row.enterpriseId === enterpriseId);\n}\n\nexport interface OwnershipMessages {\n /** Message when the record does not exist. Default \"Not found\". */\n notFound?: string;\n /** Message when the record belongs to another enterprise. */\n forbidden?: string;\n}\n\n/**\n * Return `record` if it belongs to `enterpriseId`; otherwise throw.\n * Missing record → NOT_FOUND. Other enterprise, a record with no enterpriseId,\n * or a caller with no enterprise → FORBIDDEN.\n */\nexport function assertEnterpriseOwnership<T extends EnterpriseOwned>(\n record: T | null | undefined,\n enterpriseId: number | null | undefined,\n messages: OwnershipMessages = {}\n): T {\n if (record === null || record === undefined) {\n throw new TRPCError({\n code: \"NOT_FOUND\",\n message: messages.notFound ?? \"Not found\",\n });\n }\n if (!isEnterpriseId(enterpriseId) || record.enterpriseId !== enterpriseId) {\n throw new TRPCError({\n code: \"FORBIDDEN\",\n message:\n messages.forbidden ??\n \"Access denied: this record belongs to another enterprise\",\n });\n }\n return record;\n}\n\n/**\n * Build the standard procedures for an app whose session user is `TUser`.\n * Attach each one to the app's own tRPC instance with `t.procedure.concat(...)`.\n *\n * | Procedure | Auth | Enterprise | Min role |\n * |------------------------------|:----:|:----------:|-------------------|\n * | protectedProcedure | Yes | No | any |\n * | enterpriseProcedure | Yes | Yes | any |\n * | enterpriseSuperuserProcedure | Yes | Yes | superuser+ |\n * | enterpriseAdminProcedure | Yes | Yes | enterprise_admin+ |\n * | platformAdminProcedure | Yes | No | platform_admin |\n * | enterpriseRoleProcedure(r) | Yes | Yes | r |\n * | roleProcedure(r) | Yes | No | r |\n *\n * Enterprise procedures add `ctx.enterpriseId: number` and narrow `ctx.user`\n * to non-null. Checks run in order: user, then enterprise, then role.\n */\nexport function createIsolationProcedures<\n TUser extends IsolationUser = IsolationUser,\n>() {\n const t = initTRPC.context<IsolationContext<TUser>>().create();\n\n const requireUser = t.middleware(({ ctx, next }) => {\n if (!ctx.user) {\n throw new TRPCError({ code: \"UNAUTHORIZED\", message: UNAUTHED_ERR_MSG });\n }\n return next({ ctx: { user: ctx.user } });\n });\n\n const requireEnterprise = t.middleware(({ ctx, next }) => {\n const enterpriseId = requireEnterpriseId(ctx.user);\n return next({ ctx: { user: ctx.user as TUser, enterpriseId } });\n });\n\n function requireRole(minimumRole: RoleType, message: string) {\n if (!ROLE_HIERARCHY.includes(minimumRole)) {\n throw new Error(`Unknown role for isolation check: ${minimumRole}`);\n }\n return t.middleware(({ ctx, next }) => {\n if (!ctx.user) {\n throw new TRPCError({\n code: \"UNAUTHORIZED\",\n message: UNAUTHED_ERR_MSG,\n });\n }\n if (!meetsMinimumRole(ctx.user.role, minimumRole)) {\n throw new TRPCError({ code: \"FORBIDDEN\", message });\n }\n return next();\n });\n }\n\n const protectedProcedure = t.procedure.use(requireUser);\n const enterpriseProcedure = t.procedure.use(requireEnterprise);\n\n /** Enterprise procedure with a custom minimum role (keeps an app's existing threshold). */\n function enterpriseRoleProcedure(\n minimumRole: RoleType,\n message: string = NOT_ADMIN_ERR_MSG\n ) {\n return enterpriseProcedure.use(requireRole(minimumRole, message));\n }\n\n /** Signed-in procedure with a minimum role and no enterprise (cross-enterprise admin). */\n function roleProcedure(\n minimumRole: RoleType,\n message: string = NOT_ADMIN_ERR_MSG\n ) {\n return protectedProcedure.use(requireRole(minimumRole, message));\n }\n\n return {\n protectedProcedure,\n enterpriseProcedure,\n enterpriseSuperuserProcedure: enterpriseRoleProcedure(\n \"superuser\",\n NOT_SUPERUSER_ERR_MSG\n ),\n enterpriseAdminProcedure: enterpriseRoleProcedure(\n \"enterprise_admin\",\n NOT_ADMIN_ERR_MSG\n ),\n platformAdminProcedure: roleProcedure(\n \"platform_admin\",\n NOT_PLATFORM_ADMIN_ERR_MSG\n ),\n enterpriseRoleProcedure,\n roleProcedure,\n };\n}\n\nexport type IsolationProcedures<TUser extends IsolationUser = IsolationUser> =\n ReturnType<typeof createIsolationProcedures<TUser>>;\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/isolation.ts"],"sourcesContent":["/**\n * Enterprise Isolation — the one enforced tenant-scoping path for every service.\n *\n * Replaces the per-app copies of `requireEnterprise` / `enterpriseProcedure` and\n * the hand-written `rows.filter(r => r.enterpriseId === id)` and \"does this\n * record belong to the caller?\" checks. Roles and error messages come from\n * `@pablo2410/shared-ui/rbac`, the platform's single role model.\n *\n * Every check fails closed: no user → UNAUTHORIZED; no (valid) enterprise →\n * FORBIDDEN / no rows; an unknown role never passes a role check; a record with\n * no enterpriseId never matches, even against a caller with none.\n *\n * Quick example (each app keeps its own `t`; the procedures are concatenated\n * onto it, so the app's transformer, context and error formatter still apply):\n *\n * import { createIsolationProcedures } from \"@pablo2410/core-server/isolation\";\n * const iso = createIsolationProcedures<SessionUser>();\n * export const enterpriseProcedure = t.procedure.concat(iso.enterpriseProcedure);\n * // in a resolver: ctx.enterpriseId is a number, ctx.user is non-null\n * listPlans: enterpriseProcedure.query(async ({ ctx }) =>\n * scopeToEnterprise(await getPlans(), ctx.enterpriseId)),\n */\nimport { initTRPC, TRPCError } from \"@trpc/server\";\nimport {\n NO_ENTERPRISE_ERR_MSG,\n NOT_ADMIN_ERR_MSG,\n NOT_PLATFORM_ADMIN_ERR_MSG,\n ROLE_HIERARCHY,\n UNAUTHED_ERR_MSG,\n type RoleType,\n} from \"@pablo2410/shared-ui/rbac\";\n\n/** The user fields isolation needs. Each app's session user must have these. */\nexport interface IsolationUser {\n role: string;\n enterpriseId?: number | null;\n}\n\n/** The context fields isolation needs. Each app's tRPC context must have these. */\nexport interface IsolationContext<TUser extends IsolationUser = IsolationUser> {\n user: TUser | null;\n}\n\n/** Anything owned by one enterprise (a row from the DB or the Portal API). */\nexport interface EnterpriseOwned {\n enterpriseId?: number | null;\n}\n\n/** Only positive integers are real enterprise IDs. */\nfunction isEnterpriseId(value: unknown): value is number {\n return typeof value === \"number\" && Number.isInteger(value) && value > 0;\n}\n\n/**\n * True when `userRole` is a known role at or above `minimumRole`.\n * Unlike a bare indexOf comparison, an unknown role on either side is never\n * treated as meeting the bar.\n */\nfunction meetsMinimumRole(\n userRole: string | null | undefined,\n minimumRole: RoleType\n): boolean {\n const user = ROLE_HIERARCHY.indexOf(userRole as RoleType);\n const min = ROLE_HIERARCHY.indexOf(minimumRole);\n return user >= 0 && min >= 0 && user >= min;\n}\n\n/**\n * The fleet's one admin rule: enterprise_admin and above (enterprise_admin,\n * platform_admin). Paul, 5 Oct 2026 (core-server#74). Every admin check in\n * every app uses this; no app sets its own admin threshold, and superuser is\n * not an admin level.\n */\nexport const ADMIN_MIN_ROLE = \"enterprise_admin\" as const satisfies RoleType;\n\n/** True when `role` passes the fleet admin rule (ADMIN_MIN_ROLE and above). */\nexport function isAdminRole(role: string | null | undefined): boolean {\n return meetsMinimumRole(role, ADMIN_MIN_ROLE);\n}\n\n/**\n * The caller's enterprise, or null. Never a default tenant: a missing,\n * zero, negative or non-integer enterpriseId is null.\n */\nexport function resolveEnterpriseId(\n user: IsolationUser | null | undefined\n): number | null {\n const id = user?.enterpriseId;\n return isEnterpriseId(id) ? id : null;\n}\n\n/** Like resolveEnterpriseId, but throws UNAUTHORIZED / FORBIDDEN instead of null. */\nexport function requireEnterpriseId(\n user: IsolationUser | null | undefined\n): number {\n if (!user) {\n throw new TRPCError({ code: \"UNAUTHORIZED\", message: UNAUTHED_ERR_MSG });\n }\n const id = resolveEnterpriseId(user);\n if (id === null) {\n throw new TRPCError({ code: \"FORBIDDEN\", message: NO_ENTERPRISE_ERR_MSG });\n }\n return id;\n}\n\n/**\n * Keep only the rows owned by `enterpriseId`. A null/invalid enterprise gets no\n * rows; a row with no enterpriseId is never included.\n */\nexport function scopeToEnterprise<T extends EnterpriseOwned>(\n rows: readonly T[],\n enterpriseId: number | null | undefined\n): T[] {\n if (!isEnterpriseId(enterpriseId)) return [];\n return rows.filter(row => row.enterpriseId === enterpriseId);\n}\n\nexport interface OwnershipMessages {\n /** Message when the record does not exist. Default \"Not found\". */\n notFound?: string;\n /** Message when the record belongs to another enterprise. */\n forbidden?: string;\n}\n\n/**\n * Return `record` if it belongs to `enterpriseId`; otherwise throw.\n * Missing record → NOT_FOUND. Other enterprise, a record with no enterpriseId,\n * or a caller with no enterprise → FORBIDDEN.\n */\nexport function assertEnterpriseOwnership<T extends EnterpriseOwned>(\n record: T | null | undefined,\n enterpriseId: number | null | undefined,\n messages: OwnershipMessages = {}\n): T {\n if (record === null || record === undefined) {\n throw new TRPCError({\n code: \"NOT_FOUND\",\n message: messages.notFound ?? \"Not found\",\n });\n }\n if (!isEnterpriseId(enterpriseId) || record.enterpriseId !== enterpriseId) {\n throw new TRPCError({\n code: \"FORBIDDEN\",\n message:\n messages.forbidden ??\n \"Access denied: this record belongs to another enterprise\",\n });\n }\n return record;\n}\n\n/**\n * Build the standard procedures for an app whose session user is `TUser`.\n * Attach each one to the app's own tRPC instance with `t.procedure.concat(...)`.\n *\n * | Procedure | Auth | Enterprise | Min role |\n * |--------------------------|:----:|:----------:|-----------------------------|\n * | protectedProcedure | Yes | No | any |\n * | enterpriseProcedure | Yes | Yes | any |\n * | adminProcedure | Yes | No | ADMIN_MIN_ROLE (admin gate) |\n * | enterpriseAdminProcedure | Yes | Yes | ADMIN_MIN_ROLE (admin gate) |\n * | platformAdminProcedure | Yes | No | platform_admin |\n *\n * Both admin procedures share one admin gate built from ADMIN_MIN_ROLE.\n * Enterprise procedures add `ctx.enterpriseId: number` and narrow `ctx.user`\n * to non-null. Checks run in order: user, then enterprise, then role.\n */\nexport function createIsolationProcedures<\n TUser extends IsolationUser = IsolationUser,\n>() {\n const t = initTRPC.context<IsolationContext<TUser>>().create();\n\n const requireUser = t.middleware(({ ctx, next }) => {\n if (!ctx.user) {\n throw new TRPCError({ code: \"UNAUTHORIZED\", message: UNAUTHED_ERR_MSG });\n }\n return next({ ctx: { user: ctx.user } });\n });\n\n const requireEnterprise = t.middleware(({ ctx, next }) => {\n const enterpriseId = requireEnterpriseId(ctx.user);\n return next({ ctx: { user: ctx.user as TUser, enterpriseId } });\n });\n\n function requireRole(minimumRole: RoleType, message: string) {\n if (!ROLE_HIERARCHY.includes(minimumRole)) {\n throw new Error(`Unknown role for isolation check: ${minimumRole}`);\n }\n return t.middleware(({ ctx, next }) => {\n if (!ctx.user) {\n throw new TRPCError({\n code: \"UNAUTHORIZED\",\n message: UNAUTHED_ERR_MSG,\n });\n }\n if (!meetsMinimumRole(ctx.user.role, minimumRole)) {\n throw new TRPCError({ code: \"FORBIDDEN\", message });\n }\n return next();\n });\n }\n\n /** The one admin gate (ADMIN_MIN_ROLE). */\n const requireAdmin = requireRole(ADMIN_MIN_ROLE, NOT_ADMIN_ERR_MSG);\n\n const protectedProcedure = t.procedure.use(requireUser);\n const enterpriseProcedure = t.procedure.use(requireEnterprise);\n\n return {\n protectedProcedure,\n enterpriseProcedure,\n adminProcedure: protectedProcedure.use(requireAdmin),\n enterpriseAdminProcedure: enterpriseProcedure.use(requireAdmin),\n platformAdminProcedure: protectedProcedure.use(\n requireRole(\"platform_admin\", NOT_PLATFORM_ADMIN_ERR_MSG)\n ),\n };\n}\n\nexport type IsolationProcedures<TUser extends IsolationUser = IsolationUser> =\n ReturnType<typeof createIsolationProcedures<TUser>>;\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -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 *\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 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;\n\n// ─── Helpers ────────────────────────────────────────────────────────\n\nconst isNonEmptyString = (value: unknown): value is string =>\n typeof value === \"string\" && value.length > 0;\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 // ── Portal API client ──────────────────────────────────────────\n\n let portalClient: import(\"axios\").AxiosInstance | null = null;\n\n async function getPortalClientAsync() {\n if (portalClient) return portalClient;\n if (!env.portalApiUrl) return null;\n const axios = await getAxios();\n portalClient = axios.create({\n baseURL: env.portalApiUrl,\n timeout: 10_000,\n headers: {\n \"Content-Type\": \"application/json\",\n ...(env.portalApiKey ? { \"X-Service-Key\": env.portalApiKey } : {}),\n },\n });\n return portalClient;\n }\n\n // ── JWT Secret ─────────────────────────────────────────────────\n\n function getPortalSecret() {\n const secret = env.portalJwtSecret;\n if (!secret) throw new Error(\"PORTAL_JWT_SECRET is not configured\");\n return new TextEncoder().encode(secret);\n }\n\n // ── Cookie Helpers ─────────────────────────────────────────────\n\n function getCookieDomain(): string | undefined {\n return env.cookieDomain || undefined;\n }\n\n function isSecureRequest(req: Request): boolean {\n if (req.protocol === \"https\") return true;\n const forwardedProto = req.headers[\"x-forwarded-proto\"];\n if (!forwardedProto) return false;\n const protoList = Array.isArray(forwardedProto)\n ? forwardedProto\n : forwardedProto.split(\",\");\n return protoList.some(p => p.trim().toLowerCase() === \"https\");\n }\n\n function getSessionCookieOptions(\n req: Request\n ): Pick<\n CookieOptions,\n \"domain\" | \"httpOnly\" | \"path\" | \"sameSite\" | \"secure\"\n > {\n const secure = isSecureRequest(req);\n const domain = getCookieDomain();\n return {\n domain,\n httpOnly: true,\n path: \"/\",\n sameSite: \"lax\",\n secure,\n };\n }\n\n function isValidReturnUrl(url: string): boolean {\n if (url.startsWith(\"/\") && !url.startsWith(\"//\")) return true;\n try {\n const parsed = new URL(url);\n if (parsed.protocol !== \"https:\") return false;\n return (\n parsed.hostname === \"oplyticsdigital.net\" ||\n parsed.hostname.endsWith(\".oplyticsdigital.net\")\n );\n } catch {\n return false;\n }\n }\n\n // ── Session Verification ───────────────────────────────────────\n\n /**\n * Verify session cookie against PORTAL_JWT_SECRET (cross-subdomain SSO).\n *\n * Portal is the only issuer of session cookies for subdomains using this\n * factory — the old Manus OAuth login path (which signed its own cookies\n * with a local JWT_SECRET) was removed. A cookie that fails portal\n * verification is invalid, full stop.\n */\n async function 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 {\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 (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 // The Portal Service API is under /api/service (base URL is the origin,\n // e.g. https://portal.oplyticsdigital.net). Must include the /api/service\n // prefix — omitting it 404s and silently strands enterprise resolution.\n const { data } = await client.get<PortalUser>(\n `/api/service/users/by-openid/${encodeURIComponent(openId)}`\n );\n return data ?? null;\n } catch (err: unknown) {\n const status = (err as any)?.response?.status;\n if (status === 401) {\n console.error(\n `${tag} Portal API key revoked (401) — enrichment disabled`\n );\n } else if (status === 404) {\n // User not found in portal — normal for first-time users\n } else {\n console.warn(\n `${tag} Portal user lookup failed:`,\n (err as Error).message\n );\n }\n return null;\n }\n }\n\n /** Portal user lookup with a short-TTL cache (portal mode). */\n async function getPortalUserCached(\n openId: string\n ): Promise<PortalUser | null> {\n if (portalUserCacheTtlMs > 0) {\n const hit = portalUserCache.get(openId);\n if (hit && Date.now() - hit.at < portalUserCacheTtlMs) {\n return hit.user;\n }\n }\n const user = await getPortalUser(openId);\n if (user && portalUserCacheTtlMs > 0) {\n portalUserCache.set(openId, { user, at: Date.now() });\n }\n return user;\n }\n\n /**\n * Build ctx.user entirely from the Portal record — no local persistence.\n * The single source of truth. ctx.user.id is the PORTAL user id. Roles and\n * enterprise come from the Portal verbatim (no mapping).\n *\n * On a genuine Portal outage (the by-openid lookup returns null) we keep the\n * user authenticated using their verified session claims, with role/enterprise\n * unresolved, rather than throwing. Throwing here would make createContext (in\n * every subdomain) swallow the error into `user = null`, which the AuthGate\n * renders as the Sign In screen — i.e. a transient Portal blip would log the\n * user out. Their session cookie is already valid; keep them in, degraded,\n * until the Portal recovers. (Enterprise-scoped procedures will 403 during the\n * outage; client array-guards keep pages from crashing.)\n */\n async function resolvePortalUser(\n openId: string,\n session: SessionPayload\n ): Promise<TUser> {\n const portalUser = await getPortalUserCached(openId);\n\n if (!portalUser) {\n console.warn(\n `${tag} Portal user unavailable for ${openId} — keeping session (role/enterprise unresolved until Portal recovers)`\n );\n return {\n id: 0,\n openId,\n name: session.name ?? null,\n email: null,\n role: \"\",\n enterpriseId: null,\n portalUserId: null,\n } as unknown as TUser;\n }\n\n const enterpriseId =\n portalUser.enterpriseId ?? portalUser.companyId ?? null;\n return {\n id: portalUser.id,\n openId: portalUser.openId ?? openId,\n name: portalUser.name ?? session.name ?? null,\n email: portalUser.email ?? null,\n role: portalUser.role,\n enterpriseId,\n portalUserId: portalUser.id,\n } as unknown as TUser;\n }\n\n // ── 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 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 console.error(`${tag} Failed to sync user info:`, error);\n throw Object.assign(new Error(\"Failed to sync user info\"), {\n statusCode: 403,\n });\n }\n }\n\n if (!user) {\n throw Object.assign(new Error(\"User not found\"), { statusCode: 403 });\n }\n\n // ── Enterprise backfill ─────────────────────────────────────\n if (user.enterpriseId === null) {\n try {\n const portalUser = await getPortalUser(user.openId);\n // Portal returns `enterpriseId`; `companyId` is a legacy alias.\n const portalEnterpriseId =\n portalUser?.enterpriseId ?? portalUser?.companyId ?? null;\n if (portalEnterpriseId != null) {\n console.log(\n `${tag} Backfilling enterpriseId=${portalEnterpriseId} for user ${user.id}`\n );\n await db!.updateUserFields(user.id, {\n enterpriseId: portalEnterpriseId,\n ...(portalUser?.id ? { portalUserId: portalUser.id } : {}),\n });\n user = (await db!.getUserByOpenId(user.openId)) ?? user;\n }\n } catch (backfillError) {\n console.warn(\n `${tag} Failed to backfill enterpriseId for user ${user.id}:`,\n backfillError\n );\n // Non-fatal — user can still proceed\n }\n }\n\n // Touch lastSignedIn\n await db!.upsertUser({\n openId: user.openId,\n lastSignedIn: signedInAt,\n });\n\n return user;\n }\n\n // ── Return public API ──────────────────────────────────────────\n\n return {\n verifySession,\n authenticateRequest,\n getPortalUser,\n getSessionCookieOptions,\n isValidReturnUrl,\n };\n}\n"],"mappings":"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","names":[]}
|