@edynamix/mf-isolation 0.0.34 → 0.0.36

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.
@@ -1 +1 @@
1
- {"version":3,"file":"vite-development.d.ts","sourceRoot":"","sources":["../src/vite-development.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAGjE,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAC;AAUnC,eAAO,MAAM,mCAAmC,sCAAsC,CAAC;AAsFvF,MAAM,WAAW,8BAA8B;IAC7C,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,iBAAiB,EAAE,MAAM,CAAC;IAC1B,eAAe,EAAE,MAAM,CAAC;IACxB,aAAa,EAAE,MAAM,CAAC;CACvB;AAi4BD,eAAO,MAAM,sBAAsB,GAAI,SAAS,8BAA8B,KAAG,MAmBhF,CAAC;AAEF,MAAM,MAAM,2BAA2B,GAAG,CAAC,OAAO,EAAE,eAAe,EAAE,QAAQ,EAAE,cAAc,EAAE,IAAI,EAAE,CAAC,KAAK,CAAC,EAAE,OAAO,KAAK,IAAI,KAAK,IAAI,CAAC;AAExI,eAAO,MAAM,gCAAgC,GAAI,SAAS,8BAA8B,KAAG,2BAK1F,CAAC","sourcesContent":["import { createHash, randomBytes } from 'node:crypto';\nimport type { IncomingMessage, ServerResponse } from 'node:http';\nimport { Readable } from 'node:stream';\nimport { createRemoteJWKSet, jwtVerify } from 'jose';\nimport type { Plugin } from 'vite';\n\nconst SESSION_COOKIE = '__Host-edyn_dev_session';\nconst REFRESH_COOKIE = '__Host-edyn_dev_refresh';\nconst FLOW_COOKIE = '__Host-edyn_oidc_flow';\nconst LOGOUT_COOKIE = '__Host-edyn_oidc_logout';\nconst CALLBACK_PATH = '/callback';\nconst LOGGED_OUT_PATH = '/logged-out';\nconst LOGIN_PATH = '/api/dev-auth/login';\nconst LEGACY_LOGIN_PATH = '/login';\nexport const MF_DEVELOPMENT_HOST_NAVIGATION_PATH = '/api/dev-host-navigation/index.js';\nconst HOST_ICON_PATH = '/exsto-icon.svg';\nconst HOST_NAVIGATION_ASSET_PATHS = new Set([HOST_ICON_PATH, '/exsto-text.svg']);\nconst ICON_LINK_PATTERN = /<link\\b[^>]*\\brel=[\"'](?:shortcut\\s+)?icon[\"'][^>]*>\\s*/gi;\nconst MAX_HOST_NAVIGATION_BYTES = 5 * 1024 * 1024;\nconst MAX_HOST_NAVIGATION_ASSET_BYTES = 512 * 1024;\nconst SESSION_MAX_AGE_SECONDS = 8 * 60 * 60;\nconst REFRESH_COOKIE_MAX_AGE_SECONDS = 7 * 24 * 60 * 60;\nconst FLOW_MAX_AGE_SECONDS = 10 * 60;\nconst TOKEN_REFRESH_SKEW_MS = 30_000;\nconst SPENT_REFRESH_TOKEN_REUSE_MS = 30_000;\nconst NAVIGATION_GROUP_HEADER = 'X-Edyn-Navigation-Group-Id';\nconst NAVIGATION_DEALER_HEADER = 'X-Edyn-Navigation-Dealer-Id';\nconst NAVIGATION_DEALERS_HEADER = 'X-Edyn-Navigation-Dealer-Ids';\nconst NAVIGATION_HEADERS = [NAVIGATION_GROUP_HEADER, NAVIGATION_DEALER_HEADER, NAVIGATION_DEALERS_HEADER].map((name) => name.toLowerCase());\nconst MAX_NAVIGATION_EVENT_BYTES = 5 * 1024 * 1024;\nconst MAX_REQUEST_BODY_BYTES = 100 * 1024 * 1024;\nconst EVENT_SEPARATOR = /\\r?\\n\\r?\\n/;\nconst COOKIE_NAME_PATTERN = /^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$/;\nconst COOKIE_VALUE_PATTERN = /^[\\x20-\\x7e]*$/;\nconst ALLOWED_HOST_API_PATHS = [\n '/api/auth/me',\n '/api/navigation',\n '/api/navigation/access-check',\n '/api/navigation/access-request',\n '/api/navigation/counter-stream',\n] as const;\n\ninterface IOidcClientConfiguration {\n clientId: string;\n issuer: string;\n scope: string;\n}\n\ninterface IOidcDiscovery {\n authorizationEndpoint: string;\n endSessionEndpoint: string;\n issuer: string;\n jwksUri: string;\n revocationEndpoint: string | null;\n tokenEndpoint: string;\n}\n\ninterface IOidcFlow {\n codeVerifier: string;\n createdAt: number;\n nonce: string;\n returnTo: string;\n state: string;\n}\n\ninterface IModuleNavigationContext {\n dealerIds: readonly number[];\n groupId: number;\n}\n\ninterface IModuleProxyContext {\n cookies: Map<string, string>;\n navigationContext: IModuleNavigationContext | null;\n}\n\ninterface ITokenSession {\n accessToken: string;\n expiresAt: number;\n idToken: string | null;\n moduleBootstrapTail: Promise<void>;\n moduleCookies: Map<string, string>;\n moduleNavigationContext: IModuleNavigationContext | null;\n navigationContextPromise?: Promise<void>;\n refreshPromise?: Promise<void>;\n refreshToken: string | null;\n}\n\ninterface ILogoutFlow {\n createdAt: number;\n idToken: string | null;\n}\n\ninterface ITokenResponse {\n accessToken: string;\n expiresIn: number;\n idToken: string | null;\n refreshToken: string | null;\n tokenType: string;\n}\n\nexport interface IMfDevelopmentBffPluginOptions {\n developmentOrigin?: string;\n hostNavigationUrl: string;\n moduleApiOrigin: string;\n moduleApiPath: string;\n}\n\nconst isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value);\n\nconst requiredString = (value: Record<string, unknown>, key: string): string => {\n const candidate = value[key];\n if (typeof candidate !== 'string' || candidate.trim() === '') {\n throw new Error(`OIDC response is missing ${key}.`);\n }\n return candidate;\n};\n\nconst optionalString = (value: Record<string, unknown>, key: string): string | null => {\n const candidate = value[key];\n if (candidate === undefined || candidate === null) {\n return null;\n }\n if (typeof candidate !== 'string' || candidate.trim() === '') {\n throw new Error(`OIDC response contains an invalid ${key}.`);\n }\n return candidate;\n};\n\nconst absoluteHttpsUrl = (candidate: string, label: string, allowLocalHttp = false): URL => {\n let url: URL;\n try {\n url = new URL(candidate);\n } catch {\n throw new Error(`${label} must be an absolute URL.`);\n }\n const isLocalHttp = allowLocalHttp && url.protocol === 'http:' && (url.hostname === 'localhost' || url.hostname === '127.0.0.1');\n if (url.protocol !== 'https:' && !isLocalHttp) {\n throw new Error(`${label} must use HTTPS.`);\n }\n if (url.username || url.password || url.hash) {\n throw new Error(`${label} must not contain credentials or a fragment.`);\n }\n return url;\n};\n\nconst moduleApiPrefix = (candidate: string): string => {\n if (!/^\\/[a-z][a-z0-9-]*\\/api\\/$/.test(candidate)) {\n throw new Error('moduleApiPath must use the canonical /module/api/ boundary.');\n }\n return candidate;\n};\n\nconst base64Url = (size = 32): string => randomBytes(size).toString('base64url');\nconst codeChallengeOf = (verifier: string): string => createHash('sha256').update(verifier).digest('base64url');\n\nconst cookiesOf = (request: IncomingMessage): ReadonlyMap<string, string> => {\n const cookies = new Map<string, string>();\n for (const part of (request.headers.cookie ?? '').split(';')) {\n const separator = part.indexOf('=');\n if (separator <= 0) {\n continue;\n }\n const name = part.slice(0, separator).trim();\n const value = part.slice(separator + 1).trim();\n if (name && value) {\n cookies.set(name, value);\n }\n }\n return cookies;\n};\n\nconst cookie = (name: string, value: string, maxAge: number): string => `${name}=${value}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}`;\n\nconst clearCookie = (name: string): string => cookie(name, '', 0);\n\n// The refresh token stays in an HttpOnly cookie for this origin, which every module's development server shares, so\n// one sign-in survives restarts and module switches until the token expires; page scripts can never read it.\nconst refreshCookieOf = (session: ITokenSession): string =>\n session.refreshToken\n ? cookie(REFRESH_COOKIE, encodeURIComponent(session.refreshToken), REFRESH_COOKIE_MAX_AGE_SECONDS)\n : clearCookie(REFRESH_COOKIE);\n\nconst decodedCookieValue = (value: string | undefined): string | null => {\n if (!value) {\n return null;\n }\n try {\n return decodeURIComponent(value);\n } catch {\n return null;\n }\n};\n\nconst writeJson = (response: ServerResponse, status: number, body: unknown): void => {\n response.statusCode = status;\n response.setHeader('Cache-Control', 'no-store');\n response.setHeader('Content-Type', 'application/json; charset=utf-8');\n response.end(JSON.stringify(body));\n};\n\nconst redirect = (response: ServerResponse, location: string, cookies: string[] = []): void => {\n response.statusCode = 302;\n response.setHeader('Cache-Control', 'no-store');\n response.setHeader('Location', location);\n if (cookies.length > 0) {\n response.setHeader('Set-Cookie', cookies);\n }\n response.end();\n};\n\nconst safeReturnTo = (candidate: string | null): string => {\n if (!candidate?.startsWith('/') || candidate.startsWith('//')) {\n return '/';\n }\n try {\n const parsed = new URL(candidate, 'https://localhost');\n return `${parsed.pathname}${parsed.search}${parsed.hash}`;\n } catch {\n return '/';\n }\n};\n\nconst parseTokenResponse = (value: unknown, requireIdToken: boolean): ITokenResponse => {\n if (!isRecord(value)) {\n throw new Error('OIDC token endpoint returned an invalid response.');\n }\n const expiresIn = value.expires_in;\n if (typeof expiresIn !== 'number' || !Number.isFinite(expiresIn) || expiresIn <= 0) {\n throw new Error('OIDC token endpoint returned an invalid expires_in value.');\n }\n const idToken = optionalString(value, 'id_token');\n if (requireIdToken && !idToken) {\n throw new Error('OIDC token endpoint did not return an ID token.');\n }\n const tokenType = requiredString(value, 'token_type');\n if (tokenType.toLowerCase() !== 'bearer') {\n throw new Error('OIDC token endpoint returned an unsupported token type.');\n }\n return {\n accessToken: requiredString(value, 'access_token'),\n expiresIn,\n idToken,\n refreshToken: optionalString(value, 'refresh_token'),\n tokenType,\n };\n};\n\nconst pathIsHostApi = (path: string): boolean => {\n return ALLOWED_HOST_API_PATHS.some((candidate) => path === candidate || path.startsWith(`${candidate}/`));\n};\n\n// The host validates a selection the browser sends it, so host requests keep the navigation headers. A module trusts\n// them, so its requests carry only the selection the host last confirmed.\nconst proxyHeaders = (request: IncomingMessage, accessToken: string, module?: IModuleProxyContext): Headers => {\n const headers = new Headers();\n for (const [name, value] of Object.entries(request.headers)) {\n const lowerName = name.toLowerCase();\n if (\n !value ||\n name.startsWith(':') ||\n ['authorization', 'connection', 'content-length', 'cookie', 'host', 'origin', 'transfer-encoding'].includes(lowerName) ||\n (module && NAVIGATION_HEADERS.includes(lowerName))\n ) {\n continue;\n }\n if (Array.isArray(value)) {\n for (const item of value) {\n headers.append(name, item);\n }\n } else {\n headers.set(name, value);\n }\n }\n headers.set('Authorization', `Bearer ${accessToken}`);\n if (module?.cookies.size) {\n headers.set('Cookie', [...module.cookies.values()].join('; '));\n }\n const navigationContext = module?.navigationContext;\n if (navigationContext) {\n headers.set(NAVIGATION_GROUP_HEADER, String(navigationContext.groupId));\n headers.set(NAVIGATION_DEALERS_HEADER, navigationContext.dealerIds.join(','));\n if (navigationContext.dealerIds.length === 1) {\n headers.set(NAVIGATION_DEALER_HEADER, String(navigationContext.dealerIds[0]));\n }\n }\n return headers;\n};\n\nconst navigationContextIn = (value: unknown): IModuleNavigationContext | null => {\n if (!isRecord(value) || !isRecord(value.context)) {\n return null;\n }\n const { selectedDealerId, selectedDealerIds, selectedGroupId } = value.context;\n const dealerIds: unknown[] = Array.isArray(selectedDealerIds) ? selectedDealerIds : [selectedDealerId];\n if (\n !Number.isSafeInteger(selectedGroupId) ||\n (selectedGroupId as number) <= 0 ||\n dealerIds.length === 0 ||\n !dealerIds.every((dealerId) => Number.isSafeInteger(dealerId) && (dealerId as number) >= 0)\n ) {\n return null;\n }\n return { dealerIds: dealerIds as number[], groupId: selectedGroupId as number };\n};\n\n// Reads the selection each `navigation` event of the host's navigation stream confirms, chunk by chunk as it passes.\nconst navigationEventReader = (onContext: (context: IModuleNavigationContext) => void): ((chunk: Buffer) => void) => {\n const decoder = new TextDecoder();\n let pending = '';\n return (chunk) => {\n pending += decoder.decode(chunk, { stream: true });\n for (let separator = EVENT_SEPARATOR.exec(pending); separator; separator = EVENT_SEPARATOR.exec(pending)) {\n const block = pending.slice(0, separator.index);\n pending = pending.slice(separator.index + separator[0].length);\n const lines = block.split(/\\r?\\n/);\n if (!lines.includes('event: navigation') && !lines.includes('event:navigation')) {\n continue;\n }\n const data = lines\n .filter((line) => line.startsWith('data:'))\n .map((line) => line.slice(5).replace(/^ /, ''))\n .join('\\n');\n let payload: unknown = null;\n try {\n payload = JSON.parse(data);\n } catch {\n continue;\n }\n const context = navigationContextIn(payload);\n if (context) {\n onContext(context);\n }\n }\n if (pending.length > MAX_NAVIGATION_EVENT_BYTES) {\n pending = '';\n }\n };\n};\n\nconst updateModuleCookies = (cookies: Map<string, string>, headers: Headers): void => {\n const values = headers.getSetCookie();\n for (const value of values) {\n if (value.length > 4096) {\n continue;\n }\n const [pair, ...attributes] = value.split(';');\n const separator = pair?.indexOf('=') ?? -1;\n if (!pair || separator <= 0) {\n continue;\n }\n const name = pair.slice(0, separator).trim();\n const cookieValue = pair.slice(separator + 1).trim();\n if (!COOKIE_NAME_PATTERN.test(name) || !COOKIE_VALUE_PATTERN.test(cookieValue) || cookieValue.includes(';')) {\n continue;\n }\n const deleted = cookieValue === '' || attributes.some((attribute) => /^\\s*max-age\\s*=\\s*0\\s*$/i.test(attribute));\n if (deleted) {\n cookies.delete(name);\n continue;\n }\n if (!cookies.has(name) && cookies.size >= 32) {\n continue;\n }\n cookies.set(name, `${name}=${cookieValue}`);\n }\n};\n\nconst requestBodyOf = async (request: IncomingMessage): Promise<Buffer | null> => {\n const chunks: Buffer[] = [];\n let size = 0;\n for await (const chunk of request) {\n const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as string);\n size += buffer.length;\n if (size > MAX_REQUEST_BODY_BYTES) {\n return null;\n }\n chunks.push(buffer);\n }\n return Buffer.concat(chunks);\n};\n\nconst copyProxyResponse = async (upstream: Response, response: ServerResponse, onChunk?: (chunk: Buffer) => void): Promise<void> => {\n response.statusCode = upstream.status;\n for (const [name, value] of upstream.headers) {\n if (!['connection', 'content-encoding', 'content-length', 'set-cookie', 'transfer-encoding'].includes(name.toLowerCase())) {\n response.setHeader(name, value);\n }\n }\n response.setHeader('Cache-Control', upstream.headers.get('cache-control') ?? 'no-store');\n if (!upstream.body) {\n response.end();\n return;\n }\n await new Promise<void>((resolve, reject) => {\n const readable = Readable.fromWeb(upstream.body as never);\n if (onChunk) {\n readable.on('data', onChunk);\n }\n readable.once('error', reject);\n response.once('error', reject);\n response.once('finish', resolve);\n readable.pipe(response);\n });\n};\n\nconst readResponseBody = async (response: Response, maximumBytes: number, label: string): Promise<Buffer> => {\n const declaredLength = Number(response.headers.get('content-length'));\n if (Number.isFinite(declaredLength) && declaredLength > maximumBytes) {\n await response.body?.cancel();\n throw new Error(`${label} exceeded the configured size limit.`);\n }\n if (!response.body) {\n return Buffer.alloc(0);\n }\n\n const reader = response.body.getReader();\n const chunks: Uint8Array[] = [];\n let totalBytes = 0;\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) {\n break;\n }\n totalBytes += value.byteLength;\n if (totalBytes > maximumBytes) {\n await reader.cancel();\n throw new Error(`${label} exceeded the configured size limit.`);\n }\n chunks.push(value);\n }\n } finally {\n reader.releaseLock();\n }\n return Buffer.concat(chunks, totalBytes);\n};\n\nclass MfDevelopmentBff {\n readonly #configurationUrl: URL;\n readonly #developmentOrigin: URL;\n readonly #flows = new Map<string, IOidcFlow>();\n readonly #hostOrigin: URL;\n readonly #hostNavigationUrl: URL;\n readonly #jwks = new Map<string, ReturnType<typeof createRemoteJWKSet>>();\n readonly #logoutFlows = new Map<string, ILogoutFlow>();\n readonly #moduleApiOrigin: URL;\n readonly #moduleApiPath: string;\n readonly #sessions = new Map<string, ITokenSession>();\n readonly #restores = new Map<string, Promise<{ session: ITokenSession; sessionId: string } | null>>();\n #configurationPromise: Promise<IOidcClientConfiguration> | null = null;\n #discoveryPromise: Promise<IOidcDiscovery> | null = null;\n\n constructor(options: IMfDevelopmentBffPluginOptions) {\n const hostNavigation = absoluteHttpsUrl(options.hostNavigationUrl, 'hostNavigationUrl', true);\n this.#hostNavigationUrl = hostNavigation;\n this.#hostOrigin = new URL(hostNavigation.origin);\n this.#configurationUrl = new URL('/api/auth/oidc-configuration', this.#hostOrigin);\n this.#developmentOrigin = absoluteHttpsUrl(options.developmentOrigin ?? 'https://localhost:4200', 'developmentOrigin');\n if (this.#developmentOrigin.pathname !== '/' || this.#developmentOrigin.search || this.#developmentOrigin.hash) {\n throw new Error('developmentOrigin must be an origin without a path, query, or fragment.');\n }\n this.#moduleApiOrigin = absoluteHttpsUrl(options.moduleApiOrigin, 'moduleApiOrigin', true);\n if (this.#moduleApiOrigin.pathname !== '/' || this.#moduleApiOrigin.search || this.#moduleApiOrigin.hash) {\n throw new Error('moduleApiOrigin must be an origin without a path, query, or fragment.');\n }\n this.#moduleApiPath = moduleApiPrefix(options.moduleApiPath);\n }\n\n async handle(request: IncomingMessage, response: ServerResponse, next: (error?: unknown) => void): Promise<void> {\n if (!request.url || !request.method) {\n next();\n return;\n }\n const url = new URL(request.url, this.#developmentOrigin);\n this.#prune();\n\n try {\n if (request.method === 'GET' && url.pathname === MF_DEVELOPMENT_HOST_NAVIGATION_PATH) {\n await this.#hostNavigationResource(response, this.#hostNavigationUrl, 'script');\n return;\n }\n if (request.method === 'GET' && HOST_NAVIGATION_ASSET_PATHS.has(url.pathname)) {\n await this.#hostNavigationResource(response, new URL(url.pathname, this.#hostOrigin), 'asset');\n return;\n }\n if (request.method === 'GET' && url.pathname.toLowerCase() === LEGACY_LOGIN_PATH) {\n await this.#legacyLogin(request, response);\n return;\n }\n if (request.method === 'GET' && url.pathname === LOGIN_PATH) {\n await this.#login(request, response, safeReturnTo(url.searchParams.get('returnTo')));\n return;\n }\n if (request.method === 'GET' && url.pathname === CALLBACK_PATH) {\n await this.#callback(request, response, url);\n return;\n }\n if (request.method === 'GET' && url.pathname === LOGGED_OUT_PATH) {\n const returnTo = safeReturnTo(url.searchParams.get('state'));\n redirect(response, `${LOGIN_PATH}?returnTo=${encodeURIComponent(returnTo)}`, [clearCookie(LOGOUT_COOKIE)]);\n return;\n }\n if (request.method === 'GET' && url.pathname === '/frontchannel-logout') {\n const sessionId = cookiesOf(request).get(SESSION_COOKIE);\n if (sessionId) {\n this.#sessions.delete(sessionId);\n }\n response.statusCode = 204;\n response.setHeader('Set-Cookie', [clearCookie(SESSION_COOKIE), clearCookie(REFRESH_COOKIE)]);\n response.end();\n return;\n }\n if (request.method === 'POST' && url.pathname === '/api/auth/logout') {\n await this.#logout(request, response);\n return;\n }\n if (pathIsHostApi(url.pathname) || url.pathname.startsWith(this.#moduleApiPath)) {\n await this.#proxy(request, response, url);\n return;\n }\n next();\n } catch (error) {\n const message = error instanceof Error ? error.message : 'Development authentication failed.';\n writeJson(response, 502, { code: 'development_authentication_failed', error: message });\n }\n }\n\n async #hostNavigationResource(response: ServerResponse, upstreamUrl: URL, kind: 'asset' | 'script'): Promise<void> {\n const upstream = await fetch(upstreamUrl, {\n headers: { Accept: kind === 'script' ? 'text/javascript, application/javascript' : 'image/svg+xml' },\n redirect: 'error',\n });\n if (!upstream.ok) {\n throw new Error(`Host navigation ${kind} failed (${upstream.status}).`);\n }\n const contentType = upstream.headers.get('content-type') ?? '';\n const validContentType =\n kind === 'script' ? /^(?:text|application)\\/javascript(?:;|$)/i.test(contentType) : /^image\\/svg\\+xml(?:;|$)/i.test(contentType);\n if (!validContentType) {\n throw new Error(`Host navigation ${kind} returned an invalid content type.`);\n }\n const maximumBytes = kind === 'script' ? MAX_HOST_NAVIGATION_BYTES : MAX_HOST_NAVIGATION_ASSET_BYTES;\n const body = await readResponseBody(upstream, maximumBytes, `Host navigation ${kind}`);\n response.statusCode = 200;\n response.setHeader('Cache-Control', 'no-store');\n response.setHeader('Content-Type', contentType);\n response.setHeader('X-Content-Type-Options', 'nosniff');\n response.end(body);\n }\n\n async #configuration(): Promise<IOidcClientConfiguration> {\n this.#configurationPromise ??= fetch(this.#configurationUrl, { headers: { Accept: 'application/json' }, redirect: 'error' })\n .then(async (response) => {\n if (!response.ok) {\n throw new Error(`Host OIDC configuration failed (${response.status}).`);\n }\n const value: unknown = await response.json();\n if (!isRecord(value)) {\n throw new Error('Host OIDC configuration returned an invalid response.');\n }\n const issuer = absoluteHttpsUrl(requiredString(value, 'issuer'), 'OIDC issuer', true).href.replace(/\\/$/, '');\n return { clientId: requiredString(value, 'clientId'), issuer, scope: requiredString(value, 'scope') };\n })\n .catch((error) => {\n this.#configurationPromise = null;\n throw error;\n });\n return this.#configurationPromise;\n }\n\n async #discovery(): Promise<IOidcDiscovery> {\n this.#discoveryPromise ??= this.#configuration()\n .then(async (configuration) => {\n const response = await fetch(`${configuration.issuer}/.well-known/openid-configuration`, {\n headers: { Accept: 'application/json' },\n redirect: 'error',\n });\n if (!response.ok) {\n throw new Error(`OIDC discovery failed (${response.status}).`);\n }\n const value: unknown = await response.json();\n if (!isRecord(value)) {\n throw new Error('OIDC discovery returned an invalid response.');\n }\n const issuer = absoluteHttpsUrl(requiredString(value, 'issuer'), 'OIDC discovery issuer', true).href.replace(/\\/$/, '');\n if (issuer !== configuration.issuer) {\n throw new Error('OIDC discovery issuer does not match the host configuration.');\n }\n const endpoint = (key: string): string => {\n const url = absoluteHttpsUrl(requiredString(value, key), `OIDC ${key}`, true);\n if (url.origin !== new URL(issuer).origin) {\n throw new Error(`OIDC ${key} must use the configured issuer origin.`);\n }\n return url.href;\n };\n const revocation = optionalString(value, 'revocation_endpoint');\n return {\n authorizationEndpoint: endpoint('authorization_endpoint'),\n endSessionEndpoint: endpoint('end_session_endpoint'),\n issuer,\n jwksUri: endpoint('jwks_uri'),\n revocationEndpoint: revocation ? endpoint('revocation_endpoint') : null,\n tokenEndpoint: endpoint('token_endpoint'),\n };\n })\n .catch((error) => {\n this.#discoveryPromise = null;\n throw error;\n });\n return this.#discoveryPromise;\n }\n\n async #login(request: IncomingMessage, response: ServerResponse, returnTo: string): Promise<void> {\n const logoutId = cookiesOf(request).get(LOGOUT_COOKIE);\n if (logoutId) {\n const logout = this.#logoutFlows.get(logoutId);\n this.#logoutFlows.delete(logoutId);\n if (logout) {\n const [configuration, discovery] = await Promise.all([this.#configuration(), this.#discovery()]);\n const endpoint = new URL(discovery.endSessionEndpoint);\n endpoint.searchParams.set('client_id', configuration.clientId);\n endpoint.searchParams.set('post_logout_redirect_uri', new URL(LOGGED_OUT_PATH, this.#developmentOrigin).href);\n endpoint.searchParams.set('state', returnTo);\n if (logout.idToken) {\n endpoint.searchParams.set('id_token_hint', logout.idToken);\n }\n redirect(response, endpoint.href, [clearCookie(LOGOUT_COOKIE)]);\n return;\n }\n }\n\n const [configuration, discovery] = await Promise.all([this.#configuration(), this.#discovery()]);\n const flowId = base64Url();\n const codeVerifier = base64Url(48);\n const flow: IOidcFlow = { codeVerifier, createdAt: Date.now(), nonce: base64Url(), returnTo, state: base64Url() };\n this.#flows.set(flowId, flow);\n\n const endpoint = new URL(discovery.authorizationEndpoint);\n endpoint.searchParams.set('response_type', 'code');\n endpoint.searchParams.set('client_id', configuration.clientId);\n endpoint.searchParams.set('redirect_uri', new URL(CALLBACK_PATH, this.#developmentOrigin).href);\n endpoint.searchParams.set('scope', configuration.scope);\n endpoint.searchParams.set('state', flow.state);\n endpoint.searchParams.set('nonce', flow.nonce);\n endpoint.searchParams.set('code_challenge', codeChallengeOf(codeVerifier));\n endpoint.searchParams.set('code_challenge_method', 'S256');\n redirect(response, endpoint.href, [cookie(FLOW_COOKIE, flowId, FLOW_MAX_AGE_SECONDS)]);\n }\n\n async #callback(request: IncomingMessage, response: ServerResponse, url: URL): Promise<void> {\n const flowId = cookiesOf(request).get(FLOW_COOKIE);\n const flow = flowId ? this.#flows.get(flowId) : undefined;\n if (flowId) {\n this.#flows.delete(flowId);\n }\n if (!flow || url.searchParams.get('state') !== flow.state) {\n writeJson(response, 400, { code: 'invalid_oidc_state', error: 'The OIDC callback state is invalid or expired.' });\n return;\n }\n const providerError = url.searchParams.get('error');\n if (providerError) {\n writeJson(response, 401, { code: providerError, error: url.searchParams.get('error_description') ?? 'Sign-in was rejected.' });\n return;\n }\n const code = url.searchParams.get('code');\n if (!code) {\n writeJson(response, 400, { code: 'missing_authorization_code', error: 'The OIDC callback did not include an authorization code.' });\n return;\n }\n\n const [configuration, discovery] = await Promise.all([this.#configuration(), this.#discovery()]);\n const fields = new URLSearchParams({\n client_id: configuration.clientId,\n code,\n code_verifier: flow.codeVerifier,\n grant_type: 'authorization_code',\n redirect_uri: new URL(CALLBACK_PATH, this.#developmentOrigin).href,\n });\n const tokenResponse = await fetch(discovery.tokenEndpoint, {\n body: fields,\n headers: { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' },\n method: 'POST',\n redirect: 'error',\n });\n if (!tokenResponse.ok) {\n throw new Error(`OIDC token exchange failed (${tokenResponse.status}).`);\n }\n const tokens = parseTokenResponse(await tokenResponse.json(), true);\n const idToken = tokens.idToken;\n if (!idToken) {\n throw new Error('OIDC token endpoint did not return an ID token.');\n }\n const jwks = this.#jwks.get(discovery.jwksUri) ?? createRemoteJWKSet(new URL(discovery.jwksUri));\n this.#jwks.set(discovery.jwksUri, jwks);\n const verified = await jwtVerify(idToken, jwks, {\n audience: configuration.clientId,\n issuer: discovery.issuer,\n requiredClaims: ['sub', 'nonce'],\n });\n if (verified.payload.nonce !== flow.nonce) {\n throw new Error('OIDC ID token nonce does not match the authorization request.');\n }\n\n const sessionId = base64Url();\n const session: ITokenSession = {\n accessToken: tokens.accessToken,\n expiresAt: Date.now() + tokens.expiresIn * 1000,\n idToken,\n moduleBootstrapTail: Promise.resolve(),\n moduleCookies: new Map(),\n moduleNavigationContext: null,\n refreshToken: tokens.refreshToken,\n };\n this.#sessions.set(sessionId, session);\n redirect(response, flow.returnTo, [\n cookie(SESSION_COOKIE, sessionId, SESSION_MAX_AGE_SECONDS),\n refreshCookieOf(session),\n clearCookie(FLOW_COOKIE),\n ]);\n }\n\n async #logout(request: IncomingMessage, response: ServerResponse): Promise<void> {\n const sessionId = cookiesOf(request).get(SESSION_COOKIE);\n const session = sessionId ? this.#sessions.get(sessionId) : undefined;\n if (sessionId) {\n this.#sessions.delete(sessionId);\n }\n if (session) {\n void this.#revoke(session).catch(() => undefined);\n }\n const logoutId = base64Url();\n this.#logoutFlows.set(logoutId, { createdAt: Date.now(), idToken: session?.idToken ?? null });\n response.setHeader('Set-Cookie', [\n clearCookie(SESSION_COOKIE),\n clearCookie(REFRESH_COOKIE),\n cookie(LOGOUT_COOKIE, logoutId, FLOW_MAX_AGE_SECONDS),\n ]);\n writeJson(response, 200, { loggedOut: true });\n }\n\n async #revoke(session: ITokenSession): Promise<void> {\n const [configuration, discovery] = await Promise.all([this.#configuration(), this.#discovery()]);\n const revocationEndpoint = discovery.revocationEndpoint;\n if (!revocationEndpoint) {\n return;\n }\n const revoke = async (token: string, hint: string): Promise<void> => {\n await fetch(revocationEndpoint, {\n body: new URLSearchParams({ client_id: configuration.clientId, token, token_type_hint: hint }),\n headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n method: 'POST',\n redirect: 'error',\n });\n };\n await Promise.all([\n revoke(session.accessToken, 'access_token'),\n session.refreshToken ? revoke(session.refreshToken, 'refresh_token') : Promise.resolve(),\n ]);\n }\n\n // Standalone modules send the browser to their legacy /login page when an API call answers 401, and locally no such\n // page exists: serving the module again would repeat the 401 in a reload loop. Signed out, this is the development\n // sign-in; signed in, the module API itself refused the session, so the page says so and redirects nowhere.\n async #legacyLogin(request: IncomingMessage, response: ServerResponse): Promise<void> {\n if (!(await this.#authenticatedSession(request, response))) {\n redirect(response, `${LOGIN_PATH}?returnTo=%2F`);\n return;\n }\n response.statusCode = 401;\n response.setHeader('Cache-Control', 'no-store');\n response.setHeader('Content-Type', 'text/html; charset=utf-8');\n response.end(\n '<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><title>Module sign-in refused</title></head>' +\n '<body style=\"font-family:system-ui,sans-serif;margin:3rem;line-height:1.5\">' +\n '<h1 style=\"font-size:1.25rem\">The module API refused your session</h1>' +\n '<p>You are signed in, but a module API request answered 401 and the module asked for its legacy sign-in page. ' +\n 'Check the module backend (MODULE_API_ORIGIN) and its dev server output.</p>' +\n `<p><a href=\"${LOGIN_PATH}?returnTo=%2F\">Sign in again</a> &middot; <a href=\"/\">Back to the module</a></p>` +\n '</body></html>',\n );\n }\n\n async #authenticatedSession(request: IncomingMessage, response: ServerResponse): Promise<ITokenSession | null> {\n const cookies = cookiesOf(request);\n const sessionId = cookies.get(SESSION_COOKIE);\n const session = sessionId ? this.#sessions.get(sessionId) : undefined;\n if (!session) {\n return this.#restoredSession(decodedCookieValue(cookies.get(REFRESH_COOKIE)), response);\n }\n if (session.expiresAt > Date.now() + TOKEN_REFRESH_SKEW_MS) {\n return session;\n }\n if (!session.refreshToken) {\n if (sessionId) {\n this.#sessions.delete(sessionId);\n }\n return null;\n }\n session.refreshPromise ??= this.#refresh(session).finally(() => {\n session.refreshPromise = undefined;\n });\n try {\n await session.refreshPromise;\n response.setHeader('Set-Cookie', refreshCookieOf(session));\n return session;\n } catch {\n if (sessionId) {\n this.#sessions.delete(sessionId);\n }\n response.setHeader('Set-Cookie', clearCookie(REFRESH_COOKIE));\n return null;\n }\n }\n\n // Starts a session from the browser's refresh cookie after this server restarted or another module's server signed\n // the developer in. Parallel requests carrying the same token share one refresh, and so, for a short while, do\n // requests the browser sent before the rotated cookie reached it: the sign-in service accepts a refresh token once,\n // so spending it again would fail and clear the cookie that replaced it.\n async #restoredSession(refreshToken: string | null, response: ServerResponse): Promise<ITokenSession | null> {\n if (!refreshToken) {\n return null;\n }\n let restore = this.#restores.get(refreshToken);\n if (!restore) {\n restore = this.#restore(refreshToken);\n this.#restores.set(refreshToken, restore);\n void restore.then((restoredSession) => {\n if (!restoredSession) {\n this.#restores.delete(refreshToken);\n return;\n }\n setTimeout(() => this.#restores.delete(refreshToken), SPENT_REFRESH_TOKEN_REUSE_MS).unref();\n });\n }\n const restored = await restore;\n if (!restored || this.#sessions.get(restored.sessionId) !== restored.session) {\n response.setHeader('Set-Cookie', clearCookie(REFRESH_COOKIE));\n return null;\n }\n response.setHeader('Set-Cookie', [cookie(SESSION_COOKIE, restored.sessionId, SESSION_MAX_AGE_SECONDS), refreshCookieOf(restored.session)]);\n return restored.session;\n }\n\n async #restore(refreshToken: string): Promise<{ session: ITokenSession; sessionId: string } | null> {\n const session: ITokenSession = {\n accessToken: '',\n expiresAt: 0,\n idToken: null,\n moduleBootstrapTail: Promise.resolve(),\n moduleCookies: new Map(),\n moduleNavigationContext: null,\n refreshToken,\n };\n try {\n await this.#refresh(session);\n } catch {\n return null;\n }\n const sessionId = base64Url();\n this.#sessions.set(sessionId, session);\n return { session, sessionId };\n }\n\n async #refresh(session: ITokenSession): Promise<void> {\n const refreshToken = session.refreshToken;\n if (!refreshToken) {\n throw new Error('OIDC refresh token is unavailable.');\n }\n const [configuration, discovery] = await Promise.all([this.#configuration(), this.#discovery()]);\n const response = await fetch(discovery.tokenEndpoint, {\n body: new URLSearchParams({\n client_id: configuration.clientId,\n grant_type: 'refresh_token',\n refresh_token: refreshToken,\n }),\n headers: { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' },\n method: 'POST',\n redirect: 'error',\n });\n if (!response.ok) {\n throw new Error(`OIDC refresh failed (${response.status}).`);\n }\n const tokens = parseTokenResponse(await response.json(), false);\n session.accessToken = tokens.accessToken;\n session.expiresAt = Date.now() + tokens.expiresIn * 1000;\n session.refreshToken = tokens.refreshToken ?? session.refreshToken;\n session.idToken = tokens.idToken ?? session.idToken;\n }\n\n async #proxy(request: IncomingMessage, response: ServerResponse, localUrl: URL): Promise<void> {\n const session = await this.#authenticatedSession(request, response);\n if (!session) {\n // This server refreshes the session's tokens itself, so the browser can only sign in again. A refreshable code\n // would send the host client to a refresh endpoint that only the host has.\n writeJson(response, 401, { code: 'sign_in_required', error: 'Sign in is required.' });\n return;\n }\n const isModuleApi = localUrl.pathname.startsWith(this.#moduleApiPath);\n if (isModuleApi && !session.moduleNavigationContext) {\n // The host mounts a module only once the selection is known, so its first API calls already carry it. Locally\n // the module starts beside the navigation, so its first calls can arrive first: ask the host before forwarding.\n session.navigationContextPromise ??= this.#loadNavigationContext(session).finally(() => {\n session.navigationContextPromise = undefined;\n });\n await session.navigationContextPromise;\n }\n if (isModuleApi && session.moduleCookies.size === 0) {\n const previous = session.moduleBootstrapTail;\n let release = (): void => {};\n const current = new Promise<void>((resolve) => {\n release = resolve;\n });\n session.moduleBootstrapTail = previous.catch(() => undefined).then(() => current);\n await previous.catch(() => undefined);\n try {\n await this.#proxyAuthenticated(request, response, localUrl, session, true);\n } finally {\n release();\n }\n return;\n }\n await this.#proxyAuthenticated(request, response, localUrl, session, isModuleApi);\n }\n\n async #loadNavigationContext(session: ITokenSession): Promise<void> {\n const upstream = await fetch(new URL('/api/navigation', this.#hostOrigin), {\n headers: { Accept: 'application/json', Authorization: `Bearer ${session.accessToken}` },\n redirect: 'manual',\n }).catch(() => null);\n if (!upstream?.ok || session.moduleNavigationContext) {\n return;\n }\n session.moduleNavigationContext = navigationContextIn(await upstream.json().catch(() => null));\n }\n\n async #proxyAuthenticated(\n request: IncomingMessage,\n response: ServerResponse,\n localUrl: URL,\n session: ITokenSession,\n isModuleApi: boolean,\n ): Promise<void> {\n const upstreamOrigin = isModuleApi ? this.#moduleApiOrigin : this.#hostOrigin;\n const upstreamUrl = new URL(`${localUrl.pathname}${localUrl.search}`, upstreamOrigin);\n const method = request.method ?? 'GET';\n const init: RequestInit = {\n headers: proxyHeaders(\n request,\n session.accessToken,\n isModuleApi ? { cookies: session.moduleCookies, navigationContext: session.moduleNavigationContext } : undefined,\n ),\n method,\n redirect: 'manual',\n };\n if (method !== 'GET' && method !== 'HEAD') {\n // A fixed-length body: the web front ends of the deployed module servers answer chunked request bodies with 404.\n const body = await requestBodyOf(request);\n if (!body) {\n writeJson(response, 413, { code: 'request_body_too_large', error: 'The request body exceeds the development proxy limit.' });\n return;\n }\n init.body = body;\n }\n const upstream = await fetch(upstreamUrl, init);\n let onChunk: ((chunk: Buffer) => void) | undefined;\n if (isModuleApi) {\n updateModuleCookies(session.moduleCookies, upstream.headers);\n } else if (localUrl.pathname === '/api/navigation' && upstream.ok) {\n session.moduleNavigationContext = navigationContextIn(\n await upstream\n .clone()\n .json()\n .catch(() => null),\n );\n } else if (localUrl.pathname === '/api/navigation/stream' && upstream.ok) {\n onChunk = navigationEventReader((context) => {\n session.moduleNavigationContext = context;\n });\n }\n await copyProxyResponse(upstream, response, onChunk);\n }\n\n #prune(): void {\n const now = Date.now();\n for (const [id, flow] of this.#flows) {\n if (flow.createdAt + FLOW_MAX_AGE_SECONDS * 1000 < now) {\n this.#flows.delete(id);\n }\n }\n for (const [id, flow] of this.#logoutFlows) {\n if (flow.createdAt + FLOW_MAX_AGE_SECONDS * 1000 < now) {\n this.#logoutFlows.delete(id);\n }\n }\n for (const [id, session] of this.#sessions) {\n if (!session.refreshToken && session.expiresAt + TOKEN_REFRESH_SKEW_MS < now) {\n this.#sessions.delete(id);\n }\n }\n }\n}\n\nexport const mfDevelopmentBffPlugin = (options: IMfDevelopmentBffPluginOptions): Plugin => {\n const middleware = createMfDevelopmentBffMiddleware(options);\n return {\n apply: 'serve',\n name: 'edynamix-mf-development-bff',\n configureServer(server) {\n server.middlewares.use((request, response, next) => {\n middleware(request, response, next);\n });\n },\n // Locally the module runs inside the host shell, so its page shows the host's icon. The module's own favicon is\n // only copied into the build output, so this server has none to answer the browser's favicon request with.\n transformIndexHtml(html) {\n return {\n html: html.replace(ICON_LINK_PATTERN, ''),\n tags: [{ tag: 'link', attrs: { rel: 'icon', type: 'image/svg+xml', href: HOST_ICON_PATH }, injectTo: 'head' }],\n };\n },\n };\n};\n\nexport type TMfDevelopmentBffMiddleware = (request: IncomingMessage, response: ServerResponse, next: (error?: unknown) => void) => void;\n\nexport const createMfDevelopmentBffMiddleware = (options: IMfDevelopmentBffPluginOptions): TMfDevelopmentBffMiddleware => {\n const bff = new MfDevelopmentBff(options);\n return (request, response, next) => {\n void bff.handle(request, response, next);\n };\n};\n"]}
1
+ {"version":3,"file":"vite-development.d.ts","sourceRoot":"","sources":["../src/vite-development.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAGjE,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAC;AAUnC,eAAO,MAAM,mCAAmC,sCAAsC,CAAC;AA2FvF,MAAM,WAAW,8BAA8B;IAC7C,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,iBAAiB,EAAE,MAAM,CAAC;IAC1B,eAAe,EAAE,MAAM,CAAC;IACxB,aAAa,EAAE,MAAM,CAAC;CACvB;AA66BD,eAAO,MAAM,sBAAsB,GAAI,SAAS,8BAA8B,KAAG,MAmBhF,CAAC;AAEF,MAAM,MAAM,2BAA2B,GAAG,CAAC,OAAO,EAAE,eAAe,EAAE,QAAQ,EAAE,cAAc,EAAE,IAAI,EAAE,CAAC,KAAK,CAAC,EAAE,OAAO,KAAK,IAAI,KAAK,IAAI,CAAC;AAExI,eAAO,MAAM,gCAAgC,GAAI,SAAS,8BAA8B,KAAG,2BAK1F,CAAC","sourcesContent":["import { createHash, randomBytes } from 'node:crypto';\nimport type { IncomingMessage, ServerResponse } from 'node:http';\nimport { Readable } from 'node:stream';\nimport { createRemoteJWKSet, jwtVerify } from 'jose';\nimport type { Plugin } from 'vite';\n\nconst SESSION_COOKIE = '__Host-edyn_dev_session';\nconst REFRESH_COOKIE = '__Host-edyn_dev_refresh';\nconst FLOW_COOKIE = '__Host-edyn_oidc_flow';\nconst LOGOUT_COOKIE = '__Host-edyn_oidc_logout';\nconst CALLBACK_PATH = '/callback';\nconst LOGGED_OUT_PATH = '/logged-out';\nconst LOGIN_PATH = '/api/dev-auth/login';\nconst LEGACY_LOGIN_PATH = '/login';\nexport const MF_DEVELOPMENT_HOST_NAVIGATION_PATH = '/api/dev-host-navigation/index.js';\nconst HOST_ICON_PATH = '/exsto-icon.svg';\nconst HOST_NAVIGATION_ASSET_PATHS = new Set([HOST_ICON_PATH, '/exsto-text.svg']);\nconst ICON_LINK_PATTERN = /<link\\b[^>]*\\brel=[\"'](?:shortcut\\s+)?icon[\"'][^>]*>\\s*/gi;\nconst MAX_HOST_NAVIGATION_BYTES = 5 * 1024 * 1024;\nconst MAX_HOST_NAVIGATION_ASSET_BYTES = 512 * 1024;\nconst SESSION_MAX_AGE_SECONDS = 8 * 60 * 60;\nconst REFRESH_COOKIE_MAX_AGE_SECONDS = 7 * 24 * 60 * 60;\nconst FLOW_MAX_AGE_SECONDS = 10 * 60;\nconst TOKEN_REFRESH_SKEW_MS = 30_000;\nconst SPENT_REFRESH_TOKEN_REUSE_MS = 30_000;\nconst NAVIGATION_GROUP_HEADER = 'X-Edyn-Navigation-Group-Id';\nconst NAVIGATION_DEALER_HEADER = 'X-Edyn-Navigation-Dealer-Id';\nconst NAVIGATION_DEALERS_HEADER = 'X-Edyn-Navigation-Dealer-Ids';\nconst NAVIGATION_HEADERS = [NAVIGATION_GROUP_HEADER, NAVIGATION_DEALER_HEADER, NAVIGATION_DEALERS_HEADER].map((name) => name.toLowerCase());\nconst MAX_NAVIGATION_EVENT_BYTES = 5 * 1024 * 1024;\nconst MAX_REQUEST_BODY_BYTES = 100 * 1024 * 1024;\nconst EVENT_SEPARATOR = /\\r?\\n\\r?\\n/;\nconst COOKIE_NAME_PATTERN = /^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$/;\nconst COOKIE_VALUE_PATTERN = /^[\\x20-\\x7e]*$/;\nconst ALLOWED_HOST_API_PATHS = [\n '/api/auth/me',\n '/api/navigation',\n '/api/navigation/access-check',\n '/api/navigation/access-request',\n '/api/navigation/counter-stream',\n] as const;\n// The standalone navigation reads the portal menus under the host's /api/navigation/portal, which only a host running\n// in development relays to the portal. Every host also serves the portal API itself at /portal/api, a deployed one too,\n// so those calls go there.\nconst PORTAL_MENU_PATH = '/api/navigation/portal';\nconst PORTAL_API_PATH = '/portal/api';\n\ninterface IOidcClientConfiguration {\n clientId: string;\n issuer: string;\n scope: string;\n}\n\ninterface IOidcDiscovery {\n authorizationEndpoint: string;\n endSessionEndpoint: string;\n issuer: string;\n jwksUri: string;\n revocationEndpoint: string | null;\n tokenEndpoint: string;\n}\n\ninterface IOidcFlow {\n codeVerifier: string;\n createdAt: number;\n nonce: string;\n returnTo: string;\n state: string;\n}\n\ninterface IModuleNavigationContext {\n dealerIds: readonly number[];\n groupId: number;\n}\n\ninterface IModuleProxyContext {\n cookies: Map<string, string>;\n navigationContext: IModuleNavigationContext | null;\n}\n\ninterface ITokenSession {\n accessToken: string;\n expiresAt: number;\n idToken: string | null;\n moduleBootstrapTail: Promise<void>;\n moduleCookies: Map<string, string>;\n moduleNavigationContext: IModuleNavigationContext | null;\n navigationContextPromise?: Promise<void>;\n refreshPromise?: Promise<void>;\n refreshToken: string | null;\n}\n\ninterface ILogoutFlow {\n createdAt: number;\n idToken: string | null;\n}\n\ninterface ITokenResponse {\n accessToken: string;\n expiresIn: number;\n idToken: string | null;\n refreshToken: string | null;\n tokenType: string;\n}\n\nexport interface IMfDevelopmentBffPluginOptions {\n developmentOrigin?: string;\n hostNavigationUrl: string;\n moduleApiOrigin: string;\n moduleApiPath: string;\n}\n\nconst isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value);\n\nconst requiredString = (value: Record<string, unknown>, key: string): string => {\n const candidate = value[key];\n if (typeof candidate !== 'string' || candidate.trim() === '') {\n throw new Error(`OIDC response is missing ${key}.`);\n }\n return candidate;\n};\n\nconst optionalString = (value: Record<string, unknown>, key: string): string | null => {\n const candidate = value[key];\n if (candidate === undefined || candidate === null) {\n return null;\n }\n if (typeof candidate !== 'string' || candidate.trim() === '') {\n throw new Error(`OIDC response contains an invalid ${key}.`);\n }\n return candidate;\n};\n\nconst absoluteHttpsUrl = (candidate: string, label: string, allowLocalHttp = false): URL => {\n let url: URL;\n try {\n url = new URL(candidate);\n } catch {\n throw new Error(`${label} must be an absolute URL.`);\n }\n const isLocalHttp = allowLocalHttp && url.protocol === 'http:' && (url.hostname === 'localhost' || url.hostname === '127.0.0.1');\n if (url.protocol !== 'https:' && !isLocalHttp) {\n throw new Error(`${label} must use HTTPS.`);\n }\n if (url.username || url.password || url.hash) {\n throw new Error(`${label} must not contain credentials or a fragment.`);\n }\n return url;\n};\n\nconst moduleApiPrefix = (candidate: string): string => {\n if (!/^\\/[a-z][a-z0-9-]*\\/api\\/$/.test(candidate)) {\n throw new Error('moduleApiPath must use the canonical /module/api/ boundary.');\n }\n return candidate;\n};\n\nconst base64Url = (size = 32): string => randomBytes(size).toString('base64url');\nconst codeChallengeOf = (verifier: string): string => createHash('sha256').update(verifier).digest('base64url');\n\nconst cookiesOf = (request: IncomingMessage): ReadonlyMap<string, string> => {\n const cookies = new Map<string, string>();\n for (const part of (request.headers.cookie ?? '').split(';')) {\n const separator = part.indexOf('=');\n if (separator <= 0) {\n continue;\n }\n const name = part.slice(0, separator).trim();\n const value = part.slice(separator + 1).trim();\n if (name && value) {\n cookies.set(name, value);\n }\n }\n return cookies;\n};\n\nconst cookie = (name: string, value: string, maxAge: number): string => `${name}=${value}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}`;\n\nconst clearCookie = (name: string): string => cookie(name, '', 0);\n\n// The refresh token stays in an HttpOnly cookie for this origin, which every module's development server shares, so\n// one sign-in survives restarts and module switches until the token expires; page scripts can never read it.\nconst refreshCookieOf = (session: ITokenSession): string =>\n session.refreshToken\n ? cookie(REFRESH_COOKIE, encodeURIComponent(session.refreshToken), REFRESH_COOKIE_MAX_AGE_SECONDS)\n : clearCookie(REFRESH_COOKIE);\n\nconst decodedCookieValue = (value: string | undefined): string | null => {\n if (!value) {\n return null;\n }\n try {\n return decodeURIComponent(value);\n } catch {\n return null;\n }\n};\n\nconst writeJson = (response: ServerResponse, status: number, body: unknown): void => {\n response.statusCode = status;\n response.setHeader('Cache-Control', 'no-store');\n response.setHeader('Content-Type', 'application/json; charset=utf-8');\n response.end(JSON.stringify(body));\n};\n\nconst redirect = (response: ServerResponse, location: string, cookies: string[] = []): void => {\n response.statusCode = 302;\n response.setHeader('Cache-Control', 'no-store');\n response.setHeader('Location', location);\n if (cookies.length > 0) {\n response.setHeader('Set-Cookie', cookies);\n }\n response.end();\n};\n\nconst safeReturnTo = (candidate: string | null): string => {\n if (!candidate?.startsWith('/') || candidate.startsWith('//')) {\n return '/';\n }\n try {\n const parsed = new URL(candidate, 'https://localhost');\n return `${parsed.pathname}${parsed.search}${parsed.hash}`;\n } catch {\n return '/';\n }\n};\n\nconst parseTokenResponse = (value: unknown, requireIdToken: boolean): ITokenResponse => {\n if (!isRecord(value)) {\n throw new Error('OIDC token endpoint returned an invalid response.');\n }\n const expiresIn = value.expires_in;\n if (typeof expiresIn !== 'number' || !Number.isFinite(expiresIn) || expiresIn <= 0) {\n throw new Error('OIDC token endpoint returned an invalid expires_in value.');\n }\n const idToken = optionalString(value, 'id_token');\n if (requireIdToken && !idToken) {\n throw new Error('OIDC token endpoint did not return an ID token.');\n }\n const tokenType = requiredString(value, 'token_type');\n if (tokenType.toLowerCase() !== 'bearer') {\n throw new Error('OIDC token endpoint returned an unsupported token type.');\n }\n return {\n accessToken: requiredString(value, 'access_token'),\n expiresIn,\n idToken,\n refreshToken: optionalString(value, 'refresh_token'),\n tokenType,\n };\n};\n\nconst pathIsHostApi = (path: string): boolean => {\n return ALLOWED_HOST_API_PATHS.some((candidate) => path === candidate || path.startsWith(`${candidate}/`));\n};\n\n// The host validates a selection the browser sends it, so host requests keep the navigation headers. A module trusts\n// them, so its requests carry only the selection the host last confirmed.\nconst portalApiPathOf = (path: string): string | null =>\n path === PORTAL_MENU_PATH || path.startsWith(`${PORTAL_MENU_PATH}/`) ? `${PORTAL_API_PATH}${path.slice(PORTAL_MENU_PATH.length)}` : null;\n\n// Node reports the reason on the error's cause (ECONNREFUSED), Bun on the error itself.\nconst errorCodeOf = (error: unknown): string | null => {\n for (const candidate of [isRecord(error) ? error.cause : undefined, error]) {\n if (isRecord(candidate) && typeof candidate.code === 'string') {\n return candidate.code;\n }\n }\n return null;\n};\n\nconst proxyHeaders = (request: IncomingMessage, accessToken: string, module?: IModuleProxyContext): Headers => {\n const headers = new Headers();\n for (const [name, value] of Object.entries(request.headers)) {\n const lowerName = name.toLowerCase();\n if (\n !value ||\n name.startsWith(':') ||\n ['authorization', 'connection', 'content-length', 'cookie', 'host', 'origin', 'transfer-encoding'].includes(lowerName) ||\n (module && NAVIGATION_HEADERS.includes(lowerName))\n ) {\n continue;\n }\n if (Array.isArray(value)) {\n for (const item of value) {\n headers.append(name, item);\n }\n } else {\n headers.set(name, value);\n }\n }\n headers.set('Authorization', `Bearer ${accessToken}`);\n if (module?.cookies.size) {\n headers.set('Cookie', [...module.cookies.values()].join('; '));\n }\n const navigationContext = module?.navigationContext;\n if (navigationContext) {\n headers.set(NAVIGATION_GROUP_HEADER, String(navigationContext.groupId));\n headers.set(NAVIGATION_DEALERS_HEADER, navigationContext.dealerIds.join(','));\n if (navigationContext.dealerIds.length === 1) {\n headers.set(NAVIGATION_DEALER_HEADER, String(navigationContext.dealerIds[0]));\n }\n }\n return headers;\n};\n\nconst navigationContextIn = (value: unknown): IModuleNavigationContext | null => {\n if (!isRecord(value) || !isRecord(value.context)) {\n return null;\n }\n const { selectedDealerId, selectedDealerIds, selectedGroupId } = value.context;\n const dealerIds: unknown[] = Array.isArray(selectedDealerIds) ? selectedDealerIds : [selectedDealerId];\n if (\n !Number.isSafeInteger(selectedGroupId) ||\n (selectedGroupId as number) <= 0 ||\n dealerIds.length === 0 ||\n !dealerIds.every((dealerId) => Number.isSafeInteger(dealerId) && (dealerId as number) >= 0)\n ) {\n return null;\n }\n return { dealerIds: dealerIds as number[], groupId: selectedGroupId as number };\n};\n\n// Reads the selection each `navigation` event of the host's navigation stream confirms, chunk by chunk as it passes.\nconst navigationEventReader = (onContext: (context: IModuleNavigationContext) => void): ((chunk: Buffer) => void) => {\n const decoder = new TextDecoder();\n let pending = '';\n return (chunk) => {\n pending += decoder.decode(chunk, { stream: true });\n for (let separator = EVENT_SEPARATOR.exec(pending); separator; separator = EVENT_SEPARATOR.exec(pending)) {\n const block = pending.slice(0, separator.index);\n pending = pending.slice(separator.index + separator[0].length);\n const lines = block.split(/\\r?\\n/);\n if (!lines.includes('event: navigation') && !lines.includes('event:navigation')) {\n continue;\n }\n const data = lines\n .filter((line) => line.startsWith('data:'))\n .map((line) => line.slice(5).replace(/^ /, ''))\n .join('\\n');\n let payload: unknown = null;\n try {\n payload = JSON.parse(data);\n } catch {\n continue;\n }\n const context = navigationContextIn(payload);\n if (context) {\n onContext(context);\n }\n }\n if (pending.length > MAX_NAVIGATION_EVENT_BYTES) {\n pending = '';\n }\n };\n};\n\nconst updateModuleCookies = (cookies: Map<string, string>, headers: Headers): void => {\n const values = headers.getSetCookie();\n for (const value of values) {\n if (value.length > 4096) {\n continue;\n }\n const [pair, ...attributes] = value.split(';');\n const separator = pair?.indexOf('=') ?? -1;\n if (!pair || separator <= 0) {\n continue;\n }\n const name = pair.slice(0, separator).trim();\n const cookieValue = pair.slice(separator + 1).trim();\n if (!COOKIE_NAME_PATTERN.test(name) || !COOKIE_VALUE_PATTERN.test(cookieValue) || cookieValue.includes(';')) {\n continue;\n }\n const deleted = cookieValue === '' || attributes.some((attribute) => /^\\s*max-age\\s*=\\s*0\\s*$/i.test(attribute));\n if (deleted) {\n cookies.delete(name);\n continue;\n }\n if (!cookies.has(name) && cookies.size >= 32) {\n continue;\n }\n cookies.set(name, `${name}=${cookieValue}`);\n }\n};\n\nconst requestBodyOf = async (request: IncomingMessage): Promise<Buffer | null> => {\n const chunks: Buffer[] = [];\n let size = 0;\n for await (const chunk of request) {\n const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as string);\n size += buffer.length;\n if (size > MAX_REQUEST_BODY_BYTES) {\n return null;\n }\n chunks.push(buffer);\n }\n return Buffer.concat(chunks);\n};\n\nconst copyProxyResponse = async (upstream: Response, response: ServerResponse, onChunk?: (chunk: Buffer) => void): Promise<void> => {\n response.statusCode = upstream.status;\n for (const [name, value] of upstream.headers) {\n if (!['connection', 'content-encoding', 'content-length', 'set-cookie', 'transfer-encoding'].includes(name.toLowerCase())) {\n response.setHeader(name, value);\n }\n }\n response.setHeader('Cache-Control', upstream.headers.get('cache-control') ?? 'no-store');\n if (!upstream.body) {\n response.end();\n return;\n }\n await new Promise<void>((resolve, reject) => {\n const readable = Readable.fromWeb(upstream.body as never);\n if (onChunk) {\n readable.on('data', onChunk);\n }\n readable.once('error', reject);\n response.once('error', reject);\n response.once('finish', resolve);\n readable.pipe(response);\n });\n};\n\nconst readResponseBody = async (response: Response, maximumBytes: number, label: string): Promise<Buffer> => {\n const declaredLength = Number(response.headers.get('content-length'));\n if (Number.isFinite(declaredLength) && declaredLength > maximumBytes) {\n await response.body?.cancel();\n throw new Error(`${label} exceeded the configured size limit.`);\n }\n if (!response.body) {\n return Buffer.alloc(0);\n }\n\n const reader = response.body.getReader();\n const chunks: Uint8Array[] = [];\n let totalBytes = 0;\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) {\n break;\n }\n totalBytes += value.byteLength;\n if (totalBytes > maximumBytes) {\n await reader.cancel();\n throw new Error(`${label} exceeded the configured size limit.`);\n }\n chunks.push(value);\n }\n } finally {\n reader.releaseLock();\n }\n return Buffer.concat(chunks, totalBytes);\n};\n\nclass MfDevelopmentBff {\n readonly #configurationUrl: URL;\n readonly #developmentOrigin: URL;\n readonly #flows = new Map<string, IOidcFlow>();\n readonly #hostOrigin: URL;\n readonly #hostNavigationUrl: URL;\n readonly #jwks = new Map<string, ReturnType<typeof createRemoteJWKSet>>();\n readonly #logoutFlows = new Map<string, ILogoutFlow>();\n readonly #moduleApiOrigin: URL;\n readonly #moduleApiPath: string;\n #moduleApiUnreachable = false;\n readonly #sessions = new Map<string, ITokenSession>();\n readonly #restores = new Map<string, Promise<{ session: ITokenSession; sessionId: string } | null>>();\n #configurationPromise: Promise<IOidcClientConfiguration> | null = null;\n #discoveryPromise: Promise<IOidcDiscovery> | null = null;\n\n constructor(options: IMfDevelopmentBffPluginOptions) {\n const hostNavigation = absoluteHttpsUrl(options.hostNavigationUrl, 'hostNavigationUrl', true);\n this.#hostNavigationUrl = hostNavigation;\n this.#hostOrigin = new URL(hostNavigation.origin);\n this.#configurationUrl = new URL('/api/auth/oidc-configuration', this.#hostOrigin);\n this.#developmentOrigin = absoluteHttpsUrl(options.developmentOrigin ?? 'https://localhost:4200', 'developmentOrigin');\n if (this.#developmentOrigin.pathname !== '/' || this.#developmentOrigin.search || this.#developmentOrigin.hash) {\n throw new Error('developmentOrigin must be an origin without a path, query, or fragment.');\n }\n this.#moduleApiOrigin = absoluteHttpsUrl(options.moduleApiOrigin, 'moduleApiOrigin', true);\n if (this.#moduleApiOrigin.pathname !== '/' || this.#moduleApiOrigin.search || this.#moduleApiOrigin.hash) {\n throw new Error('moduleApiOrigin must be an origin without a path, query, or fragment.');\n }\n this.#moduleApiPath = moduleApiPrefix(options.moduleApiPath);\n }\n\n async handle(request: IncomingMessage, response: ServerResponse, next: (error?: unknown) => void): Promise<void> {\n if (!request.url || !request.method) {\n next();\n return;\n }\n const url = new URL(request.url, this.#developmentOrigin);\n this.#prune();\n\n try {\n if (request.method === 'GET' && url.pathname === MF_DEVELOPMENT_HOST_NAVIGATION_PATH) {\n await this.#hostNavigationResource(response, this.#hostNavigationUrl, 'script');\n return;\n }\n if (request.method === 'GET' && HOST_NAVIGATION_ASSET_PATHS.has(url.pathname)) {\n await this.#hostNavigationResource(response, new URL(url.pathname, this.#hostOrigin), 'asset');\n return;\n }\n if (request.method === 'GET' && url.pathname.toLowerCase() === LEGACY_LOGIN_PATH) {\n await this.#legacyLogin(request, response);\n return;\n }\n if (request.method === 'GET' && url.pathname === LOGIN_PATH) {\n await this.#login(request, response, safeReturnTo(url.searchParams.get('returnTo')));\n return;\n }\n if (request.method === 'GET' && url.pathname === CALLBACK_PATH) {\n await this.#callback(request, response, url);\n return;\n }\n if (request.method === 'GET' && url.pathname === LOGGED_OUT_PATH) {\n const returnTo = safeReturnTo(url.searchParams.get('state'));\n redirect(response, `${LOGIN_PATH}?returnTo=${encodeURIComponent(returnTo)}`, [clearCookie(LOGOUT_COOKIE)]);\n return;\n }\n if (request.method === 'GET' && url.pathname === '/frontchannel-logout') {\n const sessionId = cookiesOf(request).get(SESSION_COOKIE);\n if (sessionId) {\n this.#sessions.delete(sessionId);\n }\n response.statusCode = 204;\n response.setHeader('Set-Cookie', [clearCookie(SESSION_COOKIE), clearCookie(REFRESH_COOKIE)]);\n response.end();\n return;\n }\n if (request.method === 'POST' && url.pathname === '/api/auth/logout') {\n await this.#logout(request, response);\n return;\n }\n if (pathIsHostApi(url.pathname) || url.pathname.startsWith(this.#moduleApiPath)) {\n await this.#proxy(request, response, url);\n return;\n }\n next();\n } catch (error) {\n const message = error instanceof Error ? error.message : 'Development authentication failed.';\n writeJson(response, 502, { code: 'development_authentication_failed', error: message });\n }\n }\n\n async #hostNavigationResource(response: ServerResponse, upstreamUrl: URL, kind: 'asset' | 'script'): Promise<void> {\n const upstream = await fetch(upstreamUrl, {\n headers: { Accept: kind === 'script' ? 'text/javascript, application/javascript' : 'image/svg+xml' },\n redirect: 'error',\n });\n if (!upstream.ok) {\n throw new Error(`Host navigation ${kind} failed (${upstream.status}).`);\n }\n const contentType = upstream.headers.get('content-type') ?? '';\n const validContentType =\n kind === 'script' ? /^(?:text|application)\\/javascript(?:;|$)/i.test(contentType) : /^image\\/svg\\+xml(?:;|$)/i.test(contentType);\n if (!validContentType) {\n throw new Error(`Host navigation ${kind} returned an invalid content type.`);\n }\n const maximumBytes = kind === 'script' ? MAX_HOST_NAVIGATION_BYTES : MAX_HOST_NAVIGATION_ASSET_BYTES;\n const body = await readResponseBody(upstream, maximumBytes, `Host navigation ${kind}`);\n response.statusCode = 200;\n response.setHeader('Cache-Control', 'no-store');\n response.setHeader('Content-Type', contentType);\n response.setHeader('X-Content-Type-Options', 'nosniff');\n response.end(body);\n }\n\n async #configuration(): Promise<IOidcClientConfiguration> {\n this.#configurationPromise ??= fetch(this.#configurationUrl, { headers: { Accept: 'application/json' }, redirect: 'error' })\n .then(async (response) => {\n if (!response.ok) {\n throw new Error(`Host OIDC configuration failed (${response.status}).`);\n }\n const value: unknown = await response.json();\n if (!isRecord(value)) {\n throw new Error('Host OIDC configuration returned an invalid response.');\n }\n const issuer = absoluteHttpsUrl(requiredString(value, 'issuer'), 'OIDC issuer', true).href.replace(/\\/$/, '');\n return { clientId: requiredString(value, 'clientId'), issuer, scope: requiredString(value, 'scope') };\n })\n .catch((error) => {\n this.#configurationPromise = null;\n throw error;\n });\n return this.#configurationPromise;\n }\n\n async #discovery(): Promise<IOidcDiscovery> {\n this.#discoveryPromise ??= this.#configuration()\n .then(async (configuration) => {\n const response = await fetch(`${configuration.issuer}/.well-known/openid-configuration`, {\n headers: { Accept: 'application/json' },\n redirect: 'error',\n });\n if (!response.ok) {\n throw new Error(`OIDC discovery failed (${response.status}).`);\n }\n const value: unknown = await response.json();\n if (!isRecord(value)) {\n throw new Error('OIDC discovery returned an invalid response.');\n }\n const issuer = absoluteHttpsUrl(requiredString(value, 'issuer'), 'OIDC discovery issuer', true).href.replace(/\\/$/, '');\n if (issuer !== configuration.issuer) {\n throw new Error('OIDC discovery issuer does not match the host configuration.');\n }\n const endpoint = (key: string): string => {\n const url = absoluteHttpsUrl(requiredString(value, key), `OIDC ${key}`, true);\n if (url.origin !== new URL(issuer).origin) {\n throw new Error(`OIDC ${key} must use the configured issuer origin.`);\n }\n return url.href;\n };\n const revocation = optionalString(value, 'revocation_endpoint');\n return {\n authorizationEndpoint: endpoint('authorization_endpoint'),\n endSessionEndpoint: endpoint('end_session_endpoint'),\n issuer,\n jwksUri: endpoint('jwks_uri'),\n revocationEndpoint: revocation ? endpoint('revocation_endpoint') : null,\n tokenEndpoint: endpoint('token_endpoint'),\n };\n })\n .catch((error) => {\n this.#discoveryPromise = null;\n throw error;\n });\n return this.#discoveryPromise;\n }\n\n async #login(request: IncomingMessage, response: ServerResponse, returnTo: string): Promise<void> {\n const logoutId = cookiesOf(request).get(LOGOUT_COOKIE);\n if (logoutId) {\n const logout = this.#logoutFlows.get(logoutId);\n this.#logoutFlows.delete(logoutId);\n if (logout) {\n const [configuration, discovery] = await Promise.all([this.#configuration(), this.#discovery()]);\n const endpoint = new URL(discovery.endSessionEndpoint);\n endpoint.searchParams.set('client_id', configuration.clientId);\n endpoint.searchParams.set('post_logout_redirect_uri', new URL(LOGGED_OUT_PATH, this.#developmentOrigin).href);\n endpoint.searchParams.set('state', returnTo);\n if (logout.idToken) {\n endpoint.searchParams.set('id_token_hint', logout.idToken);\n }\n redirect(response, endpoint.href, [clearCookie(LOGOUT_COOKIE)]);\n return;\n }\n }\n\n const [configuration, discovery] = await Promise.all([this.#configuration(), this.#discovery()]);\n const flowId = base64Url();\n const codeVerifier = base64Url(48);\n const flow: IOidcFlow = { codeVerifier, createdAt: Date.now(), nonce: base64Url(), returnTo, state: base64Url() };\n this.#flows.set(flowId, flow);\n\n const endpoint = new URL(discovery.authorizationEndpoint);\n endpoint.searchParams.set('response_type', 'code');\n endpoint.searchParams.set('client_id', configuration.clientId);\n endpoint.searchParams.set('redirect_uri', new URL(CALLBACK_PATH, this.#developmentOrigin).href);\n endpoint.searchParams.set('scope', configuration.scope);\n endpoint.searchParams.set('state', flow.state);\n endpoint.searchParams.set('nonce', flow.nonce);\n endpoint.searchParams.set('code_challenge', codeChallengeOf(codeVerifier));\n endpoint.searchParams.set('code_challenge_method', 'S256');\n redirect(response, endpoint.href, [cookie(FLOW_COOKIE, flowId, FLOW_MAX_AGE_SECONDS)]);\n }\n\n async #callback(request: IncomingMessage, response: ServerResponse, url: URL): Promise<void> {\n const flowId = cookiesOf(request).get(FLOW_COOKIE);\n const flow = flowId ? this.#flows.get(flowId) : undefined;\n if (flowId) {\n this.#flows.delete(flowId);\n }\n if (!flow || url.searchParams.get('state') !== flow.state) {\n writeJson(response, 400, { code: 'invalid_oidc_state', error: 'The OIDC callback state is invalid or expired.' });\n return;\n }\n const providerError = url.searchParams.get('error');\n if (providerError) {\n writeJson(response, 401, { code: providerError, error: url.searchParams.get('error_description') ?? 'Sign-in was rejected.' });\n return;\n }\n const code = url.searchParams.get('code');\n if (!code) {\n writeJson(response, 400, { code: 'missing_authorization_code', error: 'The OIDC callback did not include an authorization code.' });\n return;\n }\n\n const [configuration, discovery] = await Promise.all([this.#configuration(), this.#discovery()]);\n const fields = new URLSearchParams({\n client_id: configuration.clientId,\n code,\n code_verifier: flow.codeVerifier,\n grant_type: 'authorization_code',\n redirect_uri: new URL(CALLBACK_PATH, this.#developmentOrigin).href,\n });\n const tokenResponse = await fetch(discovery.tokenEndpoint, {\n body: fields,\n headers: { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' },\n method: 'POST',\n redirect: 'error',\n });\n if (!tokenResponse.ok) {\n throw new Error(`OIDC token exchange failed (${tokenResponse.status}).`);\n }\n const tokens = parseTokenResponse(await tokenResponse.json(), true);\n const idToken = tokens.idToken;\n if (!idToken) {\n throw new Error('OIDC token endpoint did not return an ID token.');\n }\n const jwks = this.#jwks.get(discovery.jwksUri) ?? createRemoteJWKSet(new URL(discovery.jwksUri));\n this.#jwks.set(discovery.jwksUri, jwks);\n const verified = await jwtVerify(idToken, jwks, {\n audience: configuration.clientId,\n issuer: discovery.issuer,\n requiredClaims: ['sub', 'nonce'],\n });\n if (verified.payload.nonce !== flow.nonce) {\n throw new Error('OIDC ID token nonce does not match the authorization request.');\n }\n\n const sessionId = base64Url();\n const session: ITokenSession = {\n accessToken: tokens.accessToken,\n expiresAt: Date.now() + tokens.expiresIn * 1000,\n idToken,\n moduleBootstrapTail: Promise.resolve(),\n moduleCookies: new Map(),\n moduleNavigationContext: null,\n refreshToken: tokens.refreshToken,\n };\n this.#sessions.set(sessionId, session);\n redirect(response, flow.returnTo, [\n cookie(SESSION_COOKIE, sessionId, SESSION_MAX_AGE_SECONDS),\n refreshCookieOf(session),\n clearCookie(FLOW_COOKIE),\n ]);\n }\n\n async #logout(request: IncomingMessage, response: ServerResponse): Promise<void> {\n const sessionId = cookiesOf(request).get(SESSION_COOKIE);\n const session = sessionId ? this.#sessions.get(sessionId) : undefined;\n if (sessionId) {\n this.#sessions.delete(sessionId);\n }\n if (session) {\n void this.#revoke(session).catch(() => undefined);\n }\n const logoutId = base64Url();\n this.#logoutFlows.set(logoutId, { createdAt: Date.now(), idToken: session?.idToken ?? null });\n response.setHeader('Set-Cookie', [\n clearCookie(SESSION_COOKIE),\n clearCookie(REFRESH_COOKIE),\n cookie(LOGOUT_COOKIE, logoutId, FLOW_MAX_AGE_SECONDS),\n ]);\n writeJson(response, 200, { loggedOut: true });\n }\n\n async #revoke(session: ITokenSession): Promise<void> {\n const [configuration, discovery] = await Promise.all([this.#configuration(), this.#discovery()]);\n const revocationEndpoint = discovery.revocationEndpoint;\n if (!revocationEndpoint) {\n return;\n }\n const revoke = async (token: string, hint: string): Promise<void> => {\n await fetch(revocationEndpoint, {\n body: new URLSearchParams({ client_id: configuration.clientId, token, token_type_hint: hint }),\n headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n method: 'POST',\n redirect: 'error',\n });\n };\n await Promise.all([\n revoke(session.accessToken, 'access_token'),\n session.refreshToken ? revoke(session.refreshToken, 'refresh_token') : Promise.resolve(),\n ]);\n }\n\n // Standalone modules send the browser to their legacy /login page when an API call answers 401, and locally no such\n // page exists: serving the module again would repeat the 401 in a reload loop. Signed out, this is the development\n // sign-in; signed in, the module API itself refused the session, so the page says so and redirects nowhere.\n async #legacyLogin(request: IncomingMessage, response: ServerResponse): Promise<void> {\n if (!(await this.#authenticatedSession(request, response))) {\n redirect(response, `${LOGIN_PATH}?returnTo=%2F`);\n return;\n }\n response.statusCode = 401;\n response.setHeader('Cache-Control', 'no-store');\n response.setHeader('Content-Type', 'text/html; charset=utf-8');\n response.end(\n '<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><title>Module sign-in refused</title></head>' +\n '<body style=\"font-family:system-ui,sans-serif;margin:3rem;line-height:1.5\">' +\n '<h1 style=\"font-size:1.25rem\">The module API refused your session</h1>' +\n '<p>You are signed in, but a module API request answered 401 and the module asked for its legacy sign-in page. ' +\n 'Check the module backend (MODULE_API_ORIGIN) and its dev server output.</p>' +\n `<p><a href=\"${LOGIN_PATH}?returnTo=%2F\">Sign in again</a> &middot; <a href=\"/\">Back to the module</a></p>` +\n '</body></html>',\n );\n }\n\n async #authenticatedSession(request: IncomingMessage, response: ServerResponse): Promise<ITokenSession | null> {\n const cookies = cookiesOf(request);\n const sessionId = cookies.get(SESSION_COOKIE);\n const session = sessionId ? this.#sessions.get(sessionId) : undefined;\n if (!session) {\n return this.#restoredSession(decodedCookieValue(cookies.get(REFRESH_COOKIE)), response);\n }\n if (session.expiresAt > Date.now() + TOKEN_REFRESH_SKEW_MS) {\n return session;\n }\n if (!session.refreshToken) {\n if (sessionId) {\n this.#sessions.delete(sessionId);\n }\n return null;\n }\n session.refreshPromise ??= this.#refresh(session).finally(() => {\n session.refreshPromise = undefined;\n });\n try {\n await session.refreshPromise;\n response.setHeader('Set-Cookie', refreshCookieOf(session));\n return session;\n } catch {\n if (sessionId) {\n this.#sessions.delete(sessionId);\n }\n response.setHeader('Set-Cookie', clearCookie(REFRESH_COOKIE));\n return null;\n }\n }\n\n // Starts a session from the browser's refresh cookie after this server restarted or another module's server signed\n // the developer in. Parallel requests carrying the same token share one refresh, and so, for a short while, do\n // requests the browser sent before the rotated cookie reached it: the sign-in service accepts a refresh token once,\n // so spending it again would fail and clear the cookie that replaced it.\n async #restoredSession(refreshToken: string | null, response: ServerResponse): Promise<ITokenSession | null> {\n if (!refreshToken) {\n return null;\n }\n let restore = this.#restores.get(refreshToken);\n if (!restore) {\n restore = this.#restore(refreshToken);\n this.#restores.set(refreshToken, restore);\n void restore.then((restoredSession) => {\n if (!restoredSession) {\n this.#restores.delete(refreshToken);\n return;\n }\n setTimeout(() => this.#restores.delete(refreshToken), SPENT_REFRESH_TOKEN_REUSE_MS).unref();\n });\n }\n const restored = await restore;\n if (!restored || this.#sessions.get(restored.sessionId) !== restored.session) {\n response.setHeader('Set-Cookie', clearCookie(REFRESH_COOKIE));\n return null;\n }\n response.setHeader('Set-Cookie', [cookie(SESSION_COOKIE, restored.sessionId, SESSION_MAX_AGE_SECONDS), refreshCookieOf(restored.session)]);\n return restored.session;\n }\n\n async #restore(refreshToken: string): Promise<{ session: ITokenSession; sessionId: string } | null> {\n const session: ITokenSession = {\n accessToken: '',\n expiresAt: 0,\n idToken: null,\n moduleBootstrapTail: Promise.resolve(),\n moduleCookies: new Map(),\n moduleNavigationContext: null,\n refreshToken,\n };\n try {\n await this.#refresh(session);\n } catch {\n return null;\n }\n const sessionId = base64Url();\n this.#sessions.set(sessionId, session);\n return { session, sessionId };\n }\n\n async #refresh(session: ITokenSession): Promise<void> {\n const refreshToken = session.refreshToken;\n if (!refreshToken) {\n throw new Error('OIDC refresh token is unavailable.');\n }\n const [configuration, discovery] = await Promise.all([this.#configuration(), this.#discovery()]);\n const response = await fetch(discovery.tokenEndpoint, {\n body: new URLSearchParams({\n client_id: configuration.clientId,\n grant_type: 'refresh_token',\n refresh_token: refreshToken,\n }),\n headers: { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' },\n method: 'POST',\n redirect: 'error',\n });\n if (!response.ok) {\n throw new Error(`OIDC refresh failed (${response.status}).`);\n }\n const tokens = parseTokenResponse(await response.json(), false);\n session.accessToken = tokens.accessToken;\n session.expiresAt = Date.now() + tokens.expiresIn * 1000;\n session.refreshToken = tokens.refreshToken ?? session.refreshToken;\n session.idToken = tokens.idToken ?? session.idToken;\n }\n\n async #proxy(request: IncomingMessage, response: ServerResponse, localUrl: URL): Promise<void> {\n const session = await this.#authenticatedSession(request, response);\n if (!session) {\n // This server refreshes the session's tokens itself, so the browser can only sign in again. A refreshable code\n // would send the host client to a refresh endpoint that only the host has.\n writeJson(response, 401, { code: 'sign_in_required', error: 'Sign in is required.' });\n return;\n }\n const isModuleApi = localUrl.pathname.startsWith(this.#moduleApiPath);\n const isPortalApi = !isModuleApi && portalApiPathOf(localUrl.pathname) !== null;\n if ((isModuleApi || isPortalApi) && !session.moduleNavigationContext) {\n // The host mounts a module only once the selection is known, so its first API calls already carry it. Locally\n // the module starts beside the navigation, so its first calls can arrive first: ask the host before forwarding.\n session.navigationContextPromise ??= this.#loadNavigationContext(session).finally(() => {\n session.navigationContextPromise = undefined;\n });\n await session.navigationContextPromise;\n }\n if (isModuleApi && session.moduleCookies.size === 0) {\n const previous = session.moduleBootstrapTail;\n let release = (): void => {};\n const current = new Promise<void>((resolve) => {\n release = resolve;\n });\n session.moduleBootstrapTail = previous.catch(() => undefined).then(() => current);\n await previous.catch(() => undefined);\n try {\n await this.#proxyAuthenticated(request, response, localUrl, session, true);\n } finally {\n release();\n }\n return;\n }\n await this.#proxyAuthenticated(request, response, localUrl, session, isModuleApi);\n }\n\n async #loadNavigationContext(session: ITokenSession): Promise<void> {\n const upstream = await fetch(new URL('/api/navigation', this.#hostOrigin), {\n headers: { Accept: 'application/json', Authorization: `Bearer ${session.accessToken}` },\n redirect: 'manual',\n }).catch(() => null);\n if (!upstream?.ok || session.moduleNavigationContext) {\n return;\n }\n session.moduleNavigationContext = navigationContextIn(await upstream.json().catch(() => null));\n }\n\n async #proxyAuthenticated(\n request: IncomingMessage,\n response: ServerResponse,\n localUrl: URL,\n session: ITokenSession,\n isModuleApi: boolean,\n ): Promise<void> {\n const portalApiPath = isModuleApi ? null : portalApiPathOf(localUrl.pathname);\n const upstreamOrigin = isModuleApi ? this.#moduleApiOrigin : this.#hostOrigin;\n const upstreamUrl = new URL(`${portalApiPath ?? localUrl.pathname}${localUrl.search}`, upstreamOrigin);\n const method = request.method ?? 'GET';\n // The portal is a module too: it gets the selection the host confirmed, and no module session of this one.\n const moduleContext = isModuleApi\n ? { cookies: session.moduleCookies, navigationContext: session.moduleNavigationContext }\n : portalApiPath\n ? { cookies: new Map<string, string>(), navigationContext: session.moduleNavigationContext }\n : undefined;\n const init: RequestInit = {\n headers: proxyHeaders(request, session.accessToken, moduleContext),\n method,\n redirect: 'manual',\n };\n if (method !== 'GET' && method !== 'HEAD') {\n // A fixed-length body: the web front ends of the deployed module servers answer chunked request bodies with 404.\n const body = await requestBodyOf(request);\n if (!body) {\n writeJson(response, 413, { code: 'request_body_too_large', error: 'The request body exceeds the development proxy limit.' });\n return;\n }\n init.body = body;\n }\n let upstream: Response;\n try {\n upstream = await fetch(upstreamUrl, init);\n } catch (error) {\n if (!isModuleApi) {\n throw error;\n }\n this.#answerModuleApiUnreachable(response, error);\n return;\n }\n if (isModuleApi) {\n this.#moduleApiUnreachable = false;\n }\n let onChunk: ((chunk: Buffer) => void) | undefined;\n if (isModuleApi) {\n updateModuleCookies(session.moduleCookies, upstream.headers);\n } else if (localUrl.pathname === '/api/navigation' && upstream.ok) {\n session.moduleNavigationContext = navigationContextIn(\n await upstream\n .clone()\n .json()\n .catch(() => null),\n );\n } else if (localUrl.pathname === '/api/navigation/stream' && upstream.ok) {\n onChunk = navigationEventReader((context) => {\n session.moduleNavigationContext = context;\n });\n }\n await copyProxyResponse(upstream, response, onChunk);\n }\n\n // Without this, a module server that is not running shows only as the catch-all's development_authentication_failed.\n // The terminal says it once per outage; every refused call answers with the same explanation.\n #answerModuleApiUnreachable(response: ServerResponse, error: unknown): void {\n const reason = errorCodeOf(error) ?? (error instanceof Error ? error.message : 'no answer');\n const message =\n `Cannot reach the module server at ${this.#moduleApiOrigin.origin} (MODULE_API_ORIGIN, ${reason}). ` +\n \"Start the module's server, or point MODULE_API_ORIGIN at one that runs.\";\n if (!this.#moduleApiUnreachable) {\n this.#moduleApiUnreachable = true;\n console.warn(`[mf-isolation] ${message}`);\n }\n writeJson(response, 502, { code: 'module_api_unreachable', error: message });\n }\n\n #prune(): void {\n const now = Date.now();\n for (const [id, flow] of this.#flows) {\n if (flow.createdAt + FLOW_MAX_AGE_SECONDS * 1000 < now) {\n this.#flows.delete(id);\n }\n }\n for (const [id, flow] of this.#logoutFlows) {\n if (flow.createdAt + FLOW_MAX_AGE_SECONDS * 1000 < now) {\n this.#logoutFlows.delete(id);\n }\n }\n for (const [id, session] of this.#sessions) {\n if (!session.refreshToken && session.expiresAt + TOKEN_REFRESH_SKEW_MS < now) {\n this.#sessions.delete(id);\n }\n }\n }\n}\n\nexport const mfDevelopmentBffPlugin = (options: IMfDevelopmentBffPluginOptions): Plugin => {\n const middleware = createMfDevelopmentBffMiddleware(options);\n return {\n apply: 'serve',\n name: 'edynamix-mf-development-bff',\n configureServer(server) {\n server.middlewares.use((request, response, next) => {\n middleware(request, response, next);\n });\n },\n // Locally the module runs inside the host shell, so its page shows the host's icon. The module's own favicon is\n // only copied into the build output, so this server has none to answer the browser's favicon request with.\n transformIndexHtml(html) {\n return {\n html: html.replace(ICON_LINK_PATTERN, ''),\n tags: [{ tag: 'link', attrs: { rel: 'icon', type: 'image/svg+xml', href: HOST_ICON_PATH }, injectTo: 'head' }],\n };\n },\n };\n};\n\nexport type TMfDevelopmentBffMiddleware = (request: IncomingMessage, response: ServerResponse, next: (error?: unknown) => void) => void;\n\nexport const createMfDevelopmentBffMiddleware = (options: IMfDevelopmentBffPluginOptions): TMfDevelopmentBffMiddleware => {\n const bff = new MfDevelopmentBff(options);\n return (request, response, next) => {\n void bff.handle(request, response, next);\n };\n};\n"]}
@@ -36,6 +36,11 @@ const ALLOWED_HOST_API_PATHS = [
36
36
  '/api/navigation/access-request',
37
37
  '/api/navigation/counter-stream',
38
38
  ];
39
+ // The standalone navigation reads the portal menus under the host's /api/navigation/portal, which only a host running
40
+ // in development relays to the portal. Every host also serves the portal API itself at /portal/api, a deployed one too,
41
+ // so those calls go there.
42
+ const PORTAL_MENU_PATH = '/api/navigation/portal';
43
+ const PORTAL_API_PATH = '/portal/api';
39
44
  const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
40
45
  const requiredString = (value, key) => {
41
46
  const candidate = value[key];
@@ -168,6 +173,16 @@ const pathIsHostApi = (path) => {
168
173
  };
169
174
  // The host validates a selection the browser sends it, so host requests keep the navigation headers. A module trusts
170
175
  // them, so its requests carry only the selection the host last confirmed.
176
+ const portalApiPathOf = (path) => path === PORTAL_MENU_PATH || path.startsWith(`${PORTAL_MENU_PATH}/`) ? `${PORTAL_API_PATH}${path.slice(PORTAL_MENU_PATH.length)}` : null;
177
+ // Node reports the reason on the error's cause (ECONNREFUSED), Bun on the error itself.
178
+ const errorCodeOf = (error) => {
179
+ for (const candidate of [isRecord(error) ? error.cause : undefined, error]) {
180
+ if (isRecord(candidate) && typeof candidate.code === 'string') {
181
+ return candidate.code;
182
+ }
183
+ }
184
+ return null;
185
+ };
171
186
  const proxyHeaders = (request, accessToken, module) => {
172
187
  const headers = new Headers();
173
188
  for (const [name, value] of Object.entries(request.headers)) {
@@ -353,6 +368,7 @@ class MfDevelopmentBff {
353
368
  #logoutFlows = new Map();
354
369
  #moduleApiOrigin;
355
370
  #moduleApiPath;
371
+ #moduleApiUnreachable = false;
356
372
  #sessions = new Map();
357
373
  #restores = new Map();
358
374
  #configurationPromise = null;
@@ -784,7 +800,8 @@ class MfDevelopmentBff {
784
800
  return;
785
801
  }
786
802
  const isModuleApi = localUrl.pathname.startsWith(this.#moduleApiPath);
787
- if (isModuleApi && !session.moduleNavigationContext) {
803
+ const isPortalApi = !isModuleApi && portalApiPathOf(localUrl.pathname) !== null;
804
+ if ((isModuleApi || isPortalApi) && !session.moduleNavigationContext) {
788
805
  // The host mounts a module only once the selection is known, so its first API calls already carry it. Locally
789
806
  // the module starts beside the navigation, so its first calls can arrive first: ask the host before forwarding.
790
807
  session.navigationContextPromise ??= this.#loadNavigationContext(session).finally(() => {
@@ -821,11 +838,18 @@ class MfDevelopmentBff {
821
838
  session.moduleNavigationContext = navigationContextIn(await upstream.json().catch(() => null));
822
839
  }
823
840
  async #proxyAuthenticated(request, response, localUrl, session, isModuleApi) {
841
+ const portalApiPath = isModuleApi ? null : portalApiPathOf(localUrl.pathname);
824
842
  const upstreamOrigin = isModuleApi ? this.#moduleApiOrigin : this.#hostOrigin;
825
- const upstreamUrl = new URL(`${localUrl.pathname}${localUrl.search}`, upstreamOrigin);
843
+ const upstreamUrl = new URL(`${portalApiPath ?? localUrl.pathname}${localUrl.search}`, upstreamOrigin);
826
844
  const method = request.method ?? 'GET';
845
+ // The portal is a module too: it gets the selection the host confirmed, and no module session of this one.
846
+ const moduleContext = isModuleApi
847
+ ? { cookies: session.moduleCookies, navigationContext: session.moduleNavigationContext }
848
+ : portalApiPath
849
+ ? { cookies: new Map(), navigationContext: session.moduleNavigationContext }
850
+ : undefined;
827
851
  const init = {
828
- headers: proxyHeaders(request, session.accessToken, isModuleApi ? { cookies: session.moduleCookies, navigationContext: session.moduleNavigationContext } : undefined),
852
+ headers: proxyHeaders(request, session.accessToken, moduleContext),
829
853
  method,
830
854
  redirect: 'manual',
831
855
  };
@@ -838,7 +862,20 @@ class MfDevelopmentBff {
838
862
  }
839
863
  init.body = body;
840
864
  }
841
- const upstream = await fetch(upstreamUrl, init);
865
+ let upstream;
866
+ try {
867
+ upstream = await fetch(upstreamUrl, init);
868
+ }
869
+ catch (error) {
870
+ if (!isModuleApi) {
871
+ throw error;
872
+ }
873
+ this.#answerModuleApiUnreachable(response, error);
874
+ return;
875
+ }
876
+ if (isModuleApi) {
877
+ this.#moduleApiUnreachable = false;
878
+ }
842
879
  let onChunk;
843
880
  if (isModuleApi) {
844
881
  updateModuleCookies(session.moduleCookies, upstream.headers);
@@ -856,6 +893,18 @@ class MfDevelopmentBff {
856
893
  }
857
894
  await copyProxyResponse(upstream, response, onChunk);
858
895
  }
896
+ // Without this, a module server that is not running shows only as the catch-all's development_authentication_failed.
897
+ // The terminal says it once per outage; every refused call answers with the same explanation.
898
+ #answerModuleApiUnreachable(response, error) {
899
+ const reason = errorCodeOf(error) ?? (error instanceof Error ? error.message : 'no answer');
900
+ const message = `Cannot reach the module server at ${this.#moduleApiOrigin.origin} (MODULE_API_ORIGIN, ${reason}). ` +
901
+ "Start the module's server, or point MODULE_API_ORIGIN at one that runs.";
902
+ if (!this.#moduleApiUnreachable) {
903
+ this.#moduleApiUnreachable = true;
904
+ console.warn(`[mf-isolation] ${message}`);
905
+ }
906
+ writeJson(response, 502, { code: 'module_api_unreachable', error: message });
907
+ }
859
908
  #prune() {
860
909
  const now = Date.now();
861
910
  for (const [id, flow] of this.#flows) {