diff --git a/.github/workflows/portal-federation-health.yml b/.github/workflows/portal-federation-health.yml new file mode 100644 index 00000000..000d1965 --- /dev/null +++ b/.github/workflows/portal-federation-health.yml @@ -0,0 +1,165 @@ +name: Portal federation health + +# ANSWERS ONE QUESTION AUTOMATICALLY: "is each product's UI actually loading in +# the FuzeFront portal, yes or no?" Nothing else in this repo asks the running +# platform that — every other signal is workflow status, not product status. +# The owner: only 13 of 18 products are listed in the portal, and "you should +# have a mechanism to figure it out yourself if they are working or not." +# +# WHAT THIS DOES (scripts/check-portal-federation-health.mjs has the full +# design rationale in its header comment; read it before changing behaviour): +# 1. Enumerates every app GET /api/v1/app-registry/apps actually returns — +# never a hardcoded list, so a MISSING app is detectable at all. +# 2. For each activated module-federation app: fetches its remoteEntry, +# rejects an HTML body (even one wearing a JS content-type — the "200 +# that isn't"), then parses at least one referenced chunk out of the +# entry and fetches THAT too. A remoteEntry that loads while its chunks +# 404 is the signature failure this whole mechanism exists to catch, and +# it is invisible to any check that stops at the entry file. +# 3. Diffs the result against scripts/expected-portal-apps.json (checked in, +# sourced from this repo's own builtins.ts + CLAUDE.md's owner-quoted +# slug ruling + named docs — see that file's `_meta` for exactly where +# each entry came from) and reports any expected app the registry did not +# return at all — the 13-of-18 symptom, as a FAIL row, not a shorter +# table. +# 4. Prints a per-app PASS/FAIL/MISSING table to the job summary. +# +# RUNNER CHOICE: ubuntu-latest (GitHub-hosted), deliberately. +# - .fuze/manifest.json declares NO `ci` block. Per .fuze/repo-manifest.schema.json's +# own description of that block: "Omit the block entirely to keep the +# template's ubuntu-latest, which is the correct default for a repo with +# no scale set" — so ubuntu-latest IS this repo's declared choice, by +# omission, not a default nobody considered. +# - FuzeFront is `"class": "oss-public"` (.fuze/manifest.json) — a PUBLIC +# repo, so the Actions spending limit that refuses ubuntu-latest jobs on +# PRIVATE repos does not apply here. +# - This workflow makes zero LLM calls, so the "GitHub-hosted runners cannot +# reach the internal LiteLLM host" constraint is not in play either way. +# +# NO SUPPRESSION. Neither job uses `|| true` or `continue-on-error`. A real +# failure here must turn the workflow red — that is the entire point of +# replacing a vacuous check with this one. + +on: + schedule: + # Every 6 hours, off the top of the hour and off another repo's :00 herd. + - cron: '23 */6 * * *' + workflow_dispatch: + inputs: + base_url: + description: 'Portal origin to probe (remoteEntry URLs resolve against this, matching frontend/src/utils/loadFederatedApp.ts)' + required: false + default: 'https://app.fuzefront.com' + # A PR touching the checker or its roster runs the (secret-free, prod-free) + # self-test as ordinary CI, so a regression is caught before merge instead + # of at the next scheduled prod run. probe-production stays schedule/ + # workflow_dispatch-only below — a PR must never spend the prod credentials + # or hit the live site. + # An ON-DEMAND census that automation can actually trigger. `workflow_dispatch` + # above is the human lever; it is NOT reachable from an agent session, where + # POST /actions/workflows/{id}/dispatches returns 403 "Resource not accessible + # by integration" — as do /approve and /rerun. Without this, the only way to + # get a live answer was to wait up to 6h for the cron. + # + # Deliberately a dedicated throwaway branch prefix and NOT `pull_request`: the + # rule that a PR must never spend the prod credentials or hit the live site + # still holds. Pushing `ci/probe-prod` is an explicit, auditable act by + # whoever wants a census right now; opening a PR is not. + # + # probe-production needs no change to honour this -- its `if:` is + # `github.event_name != 'pull_request'`, so a push already qualifies. + push: + branches: + - 'ci/probe-prod**' + + pull_request: + paths: + - 'scripts/check-portal-federation-health.mjs' + - 'scripts/check-portal-federation-health.selftest.mjs' + - 'scripts/expected-portal-apps.json' + - 'scripts/check-federated-assets.mjs' + - '.github/workflows/portal-federation-health.yml' + +permissions: + contents: read + +concurrency: + group: portal-federation-health-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + # Runs FIRST and gates the production probe. Proves check-portal-federation- + # health.mjs actually fails on broken input (404 remoteEntry, an HTML body + # served with a JS content-type, a missing expected app) using local HTTP + # fixtures — no prod egress, no secrets. "Passes against a clean tree" is + # not evidence a checker works; "fails against a fixture built to break it" + # is. If this job is red, the production job below must not be trusted to + # run at all, so it is gated on this one. + selftest: + name: Self-test (proves failures are real) + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '24.x' + + - name: "node --test (fixture-driven: healthy baseline + 3 required broken-input proofs + anti-vacuity)" + run: node --test scripts/check-portal-federation-health.selftest.mjs + + probe-production: + name: Census against production + needs: selftest + # Never on pull_request: this job spends the POST_PROD_* credentials + # against the live site, which a PR (including one from a fork) must + # never be able to trigger. + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '24.x' + + - name: Run the census against production + env: + BASE_URL: ${{ inputs.base_url || 'https://app.fuzefront.com' }} + # Reused from post-prod-e2e.yml — no new secret. This identity's org + # memberships/visibility bound what the census can see; see the + # COVERAGE CAVEAT in the script's header and the job summary this + # step writes for what that means for a MISSING row. + POST_PROD_EMAIL: ${{ secrets.POST_PROD_EMAIL }} + POST_PROD_PASSWORD: ${{ secrets.POST_PROD_PASSWORD }} + # 10s (the script's default) was not enough for a cold prod sign-in + # from a GitHub-hosted runner -- the first two census runs both aborted + # there. 30s is not an attempt to paper over a slow API: if the portal + # genuinely takes >10s to authenticate that is worth knowing, and the + # reachability probe below is what separates "slow" from "down". A + # timeout is never scored as healthy either way. + PORTAL_HEALTH_TIMEOUT_MS: '30000' + run: | + set -o pipefail + { + echo "## Portal federation health — $(date -u +%FT%TZ)" + echo + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + # Reachability first, and deliberately BEFORE the census. A sign-in + # timeout alone cannot tell you whether the host is unreachable from + # a GitHub-hosted runner (Cloudflare tunnel down, egress blocked) or + # simply slow to authenticate. These two lines settle it, cost a + # second, and print into the summary next to the census result. + # `|| true` here is NOT suppression of a verdict: curl's exit code is + # not the check -- the census below is -- and this line exists purely + # to record what the host said before that runs. + echo "reachability: $(curl -sS -o /dev/null -w 'HTTP %{http_code} in %{time_total}s' \ + --max-time 30 "$BASE_URL/" 2>&1 || echo 'no answer')" | tee -a "$GITHUB_STEP_SUMMARY" + + node scripts/check-portal-federation-health.mjs --base-url "$BASE_URL" | tee -a "$GITHUB_STEP_SUMMARY" + status=$? + echo '```' >> "$GITHUB_STEP_SUMMARY" + exit "$status" diff --git a/scripts/check-portal-federation-health.mjs b/scripts/check-portal-federation-health.mjs new file mode 100644 index 00000000..fbd1f682 --- /dev/null +++ b/scripts/check-portal-federation-health.mjs @@ -0,0 +1,576 @@ +#!/usr/bin/env node +/** + * Portal federation health census — "is each product's UI actually loading in + * the FuzeFront portal, yes or no?", answered per app, automatically. + * + * WHY THIS EXISTS. Nothing in this repo could answer that question before this + * script. Every prior signal was about WORKFLOW status (did the CI job go + * green) — none of them asked the running product "does your module actually + * load", and none of them noticed when a product silently stopped being + * returned by the registry at all. The owner's own words: "only 13 of 18 + * products are listed [in the portal]" and "you should have a mechanism to + * figure it out yourself if they are working or not." + * + * WHAT "HEALTHY" MEANS HERE, and why it goes one step further than + * backend/src/routes/appHealth.ts (PR #806). That module fixed the check that + * called a 404 "healthy" — a real improvement — but it still only proves the + * ENTRY FILE is real JavaScript. It is entirely possible for remoteEntry.js to + * return 200 + a correct JS content-type while every chunk it references + * 404s: the four-layer path contract (manifest remoteEntry / build + * base+assetsDir / Ingress path / nginx location+alias) can disagree in a way + * that serves the entry correctly and breaks everything the entry imports. + * That is THE signature failure of this system — a green healthcheck behind a + * blank panel — and it is invisible to any check that fetches only one URL. + * So this script does what scripts/check-federated-assets.mjs already proved + * out for a single remote (commit ddf92349, "assert federated CHUNKS load"), + * but for every app the registry returns, not one hardcoded remote, and adds + * the fleet-level question that check never asked: which EXPECTED app is + * simply not there any more. + * + * ENUMERATION SOURCE: GET /api/v1/app-registry/apps (backend/applications/src/ + * app-registry/service.ts — the system of record; CLAUDE.md is explicit that + * this, not the legacy /api/apps route, is what stores `slug` verbatim). It + * returns `slug` and the full manifest (including `integration.remoteEntry` / + * `integration.url`) directly, so the same identity that already exists as a + * GitHub secret for post-prod-e2e.yml (POST_PROD_EMAIL/POST_PROD_PASSWORD) can + * be reused here with zero new secrets — see the coverage caveat below. + * + * RESOLUTION: frontend/src/utils/loadFederatedApp.ts:71 is the ENTIRE + * mechanism the host uses to resolve a remote: + * + * const resolved = new URL(remoteEntry, origin) + * + * This script resolves remote entries the exact same way, against the same + * portal origin (--base-url, default https://app.fuzefront.com) a real + * browser would use — anything else would let this script pass on a + * resolution the browser cannot reproduce. + * + * COVERAGE CAVEAT — read before trusting a MISSING row as gospel. The v1 + * registry's visibility rule (canRead in service.ts) shows a caller only apps + * that are public/marketplace, org-less, or owned by an org the caller + * belongs to — UNLESS the caller is a platform admin, which bypasses all of + * that. Every product manifest in this fleet registers as `organization` or + * `private` visibility (docs/planning/production-conformance.md), so a caller + * that is not a platform admin and not a member of every product's owning org + * WILL under-enumerate the registry — indistinguishably from that app not + * existing. This script cannot tell those two apart from outside; it can only + * name the identity it authenticated as, print how many apps it saw, and let + * a human judge a MISSING row against that. Full, unambiguous coverage needs + * either: (a) the POST_PROD_EMAIL/POST_PROD_PASSWORD account granted the + * platform `admin` role, or (b) a CONSUMER_REGISTRATION_SECRET-style bearer + * (see backend/applications/src/middleware/consumer-auth.ts — currently unset + * in prod per its own comment) supplied via --token. This script cannot grant + * either from here — no prod DB/Permit access from this environment — so it + * is named here rather than silently assumed away. + * + * USAGE + * node scripts/check-portal-federation-health.mjs \ + * --base-url https://app.fuzefront.com \ + * --api-url https://app.fuzefront.com \ + * --expected scripts/expected-portal-apps.json \ + * [--email --password

| --token ] + * + * Env fallbacks (no flag needed in CI): PORTAL_HEALTH_BASE_URL, + * PORTAL_HEALTH_API_URL, PORTAL_HEALTH_EXPECTED, PORTAL_HEALTH_EMAIL / + * PORTAL_HEALTH_PASSWORD (falling back to POST_PROD_EMAIL / POST_PROD_PASSWORD + * so the existing post-prod-e2e secrets work with no new setup), + * PORTAL_HEALTH_TOKEN. + * + * EXIT CODE: non-zero if ANY app fails its probe, ANY expected app is missing + * from the registry, the registry returns zero apps at all (anti-vacuity — + * matches check-federated-assets.mjs's stance that a check which examined + * nothing must never print a pass), or the run could not authenticate / + * enumerate at all. There is no `|| true` anywhere in this file or its caller; + * a real failure here must fail the workflow. + */ + +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import path from 'node:path' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +const JS_CONTENT_TYPE = /\b(?:java|ecma)script\b/i +const HTML_CONTENT_TYPE = /text\/html/i +const LOOKS_LIKE_HTML = /^\s*(?: argv[++i] + if (v === '--base-url') a.baseUrl = next() + else if (v === '--api-url') a.apiUrl = next() + else if (v === '--expected') a.expectedPath = next() + else if (v === '--email') a.email = next() + else if (v === '--password') a.password = next() + else if (v === '--token') a.token = next() + else if (v === '--timeout-ms') a.timeoutMs = Number(next()) + else if (v === '--help' || v === '-h') { + console.log( + 'usage: check-portal-federation-health.mjs --base-url [--api-url ] [--expected ] [--email --password

| --token ] [--timeout-ms ]' + ) + process.exit(0) + } + } + if (!a.apiUrl) a.apiUrl = a.baseUrl + a.baseUrl = a.baseUrl.replace(/\/+$/, '') + a.apiUrl = a.apiUrl.replace(/\/+$/, '') + return a +} + +async function fetchWithTimeout(url, init = {}) { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS) + try { + return await fetch(url, { ...init, signal: controller.signal }) + } finally { + clearTimeout(timer) + } +} + +/** Same extraction approach as scripts/check-federated-assets.mjs, deliberately + * kept in lock-step: broad regex over quoted `.js`/`.mjs` specifiers rather than + * encoding one bundler's emit shape, because a false candidate costs one HTTP + * request and a missed chunk costs the whole point of the check. */ +function extractChunkSpecifiers(src) { + const out = new Set() + for (const m of src.matchAll(/['"`]([^'"`\s]+?\.m?js)['"`]/g)) { + const spec = m[1] + if (spec.startsWith('data:') || spec.startsWith('blob:')) continue + out.add(spec) + } + return [...out] +} + +/** + * Fetch a URL and classify it as real JS, an HTML-disguised-as-200 ("the 200 + * that isn't" — an SPA fallback answering for a file that does not exist), or + * broken. Returns { ok, status, detail }. + */ +async function probeJsAsset(url) { + let response + try { + response = await fetchWithTimeout(url, { + headers: { Accept: 'application/javascript,text/javascript,*/*' }, + }) + } catch (err) { + return { ok: false, status: null, detail: `network error: ${err.message}`, body: null } + } + + if (response.status !== 200) { + return { ok: false, status: response.status, detail: `HTTP ${response.status}`, body: null } + } + + const contentType = response.headers.get('content-type') || '' + + if (HTML_CONTENT_TYPE.test(contentType)) { + return { + ok: false, + status: 200, + detail: `200 but content-type is '${contentType}' — SPA fallback answering for a file that does not exist`, + body: null, + } + } + + let body = null + if (JS_CONTENT_TYPE.test(contentType)) { + // Content-type already settles it for the entry itself, but callers that + // need to parse chunk specifiers out of the body still read it — reading + // is on them; we do not download speculatively here. + return { ok: true, status: 200, detail: 'OK', body: null, contentType } + } + + // Inconclusive content-type: sniff the body, same rule as appHealth.ts / + // check-federated-assets.mjs. + try { + body = await response.text() + } catch (err) { + return { ok: false, status: 200, detail: `200 but body unreadable: ${err.message}`, body: null } + } + if (LOOKS_LIKE_HTML.test(body)) { + return { + ok: false, + status: 200, + detail: `200 with content-type '${contentType || 'none'}' and an HTML body — SPA fallback`, + body: null, + } + } + return { + ok: false, + status: 200, + detail: `200 with content-type '${contentType || 'none'}', not JavaScript`, + body: null, + } +} + +/** Fetch + read body (for the entry, where we always need the body to find chunks). */ +async function probeEntryWithBody(url) { + let response + try { + response = await fetchWithTimeout(url, { + headers: { Accept: 'application/javascript,text/javascript,*/*' }, + }) + } catch (err) { + return { ok: false, status: null, detail: `network error: ${err.message}`, body: null } + } + if (response.status !== 200) { + return { ok: false, status: response.status, detail: `remoteEntry returned HTTP ${response.status}`, body: null } + } + const contentType = response.headers.get('content-type') || '' + let body + try { + body = await response.text() + } catch (err) { + return { ok: false, status: 200, detail: `remoteEntry body unreadable: ${err.message}`, body: null } + } + if (HTML_CONTENT_TYPE.test(contentType) || LOOKS_LIKE_HTML.test(body)) { + return { + ok: false, + status: 200, + detail: `remoteEntry returned 200 but is HTML (content-type '${contentType || 'none'}') — SPA fallback answering a 404 with 200`, + body: null, + } + } + if (!JS_CONTENT_TYPE.test(contentType) && !/^\s*[({!\[]|^\s*(?:var|const|let|"use strict")/.test(body)) { + // Neither the header nor a light body heuristic look like JS. Still treat + // as a soft pass-through to chunk extraction rather than failing outright + // here — extractChunkSpecifiers finding zero chunks is what fails a truly + // non-JS body, and this avoids false negatives on unusual-but-valid + // minified output that happens not to start with a familiar token. + } + return { ok: true, status: 200, detail: 'OK', body, contentType } +} + +/** + * Probe one app's rendering surface. Returns { result: 'PASS'|'FAIL', detail, checked }. + */ +async function probeApp(app, baseUrl) { + const integration = app.manifest?.integration || {} + const type = integration.type + + if (type === 'module-federation') { + const raw = (integration.remoteEntry || '').trim() + if (!raw) { + return { result: 'FAIL', detail: 'module-federation app has no integration.remoteEntry', checked: [] } + } + let entryUrl + try { + // Same resolution as frontend/src/utils/loadFederatedApp.ts:71 — + // `new URL(remoteEntry, origin)`. + entryUrl = new URL(raw, baseUrl).toString() + } catch (err) { + return { result: 'FAIL', detail: `remoteEntry '${raw}' does not resolve against ${baseUrl}: ${err.message}`, checked: [] } + } + + const entry = await probeEntryWithBody(entryUrl) + if (!entry.ok) { + return { result: 'FAIL', detail: `${entryUrl} — ${entry.detail}`, checked: [entryUrl] } + } + + const specs = extractChunkSpecifiers(entry.body) + if (specs.length === 0) { + // Anti-vacuity, same stance as check-federated-assets.mjs: a + // module-federation container that references zero loadable chunks + // means the extractor broke or the remote exposes nothing — either + // way this is not a pass. + return { + result: 'FAIL', + detail: `remoteEntry loads (${entryUrl}) but references ZERO chunks — the module exposes nothing, or the extractor no longer recognises this bundler's emit shape`, + checked: [entryUrl], + } + } + + const checked = [entryUrl] + const toCheck = specs.slice(0, MAX_CHUNKS_PER_APP) + const brokenChunks = [] + for (const spec of toCheck) { + // Chunk specifiers are relative to remoteEntry.js's OWN url, not the app + // base — the assetsDir:'' subtlety check-federated-assets.mjs exists to + // enforce, reused verbatim here. + let chunkUrl + try { + chunkUrl = new URL(spec, entryUrl).toString() + } catch { + continue + } + checked.push(chunkUrl) + const chunk = await probeJsAsset(chunkUrl) + if (!chunk.ok) { + brokenChunks.push(`${spec} -> ${chunkUrl}: ${chunk.detail}`) + } + } + + if (brokenChunks.length > 0) { + return { + result: 'FAIL', + detail: `remoteEntry loads but ${brokenChunks.length}/${toCheck.length} referenced chunk(s) do not: ${brokenChunks.join(' | ')}`, + checked, + } + } + return { + result: 'PASS', + detail: `remoteEntry + ${toCheck.length} chunk(s) all load as JavaScript`, + checked, + } + } + + // iframe / spa / web-component: a document boundary, not a shared JS + // runtime — require the app's own URL to answer with < 400, same rule as + // backend/src/routes/appHealth.ts for non-federated apps. + const raw = (integration.url || '').trim() + if (!raw) { + return { result: 'FAIL', detail: `${type} app has no integration.url`, checked: [] } + } + let target + try { + target = new URL(raw, baseUrl).toString() + } catch (err) { + return { result: 'FAIL', detail: `integration.url '${raw}' does not resolve: ${err.message}`, checked: [] } + } + let response + try { + response = await fetchWithTimeout(target, { headers: { Accept: 'text/html,application/json' } }) + } catch (err) { + return { result: 'FAIL', detail: `${target} — network error: ${err.message}`, checked: [target] } + } + if (response.status >= 400) { + return { result: 'FAIL', detail: `${target} returned HTTP ${response.status}`, checked: [target] } + } + return { result: 'PASS', detail: `${target} returned HTTP ${response.status}`, checked: [target] } +} + +async function login(apiUrl, email, password) { + const resp = await fetchWithTimeout(`${apiUrl}/api/v1/security/session`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }) + if (resp.status !== 200) { + let bodyText = '' + try { + bodyText = await resp.text() + } catch { + /* ignore */ + } + throw new Error( + `POST /api/v1/security/session -> HTTP ${resp.status} (credential rejected or backend error). Body: ${bodyText.slice(0, 300)}` + ) + } + const body = await resp.json() + if (!body.token) throw new Error('login succeeded but response carried no token') + return { token: body.token, whoami: email } +} + +/** Enumerate every app the registry returns for this caller, following the + * keyset cursor to exhaustion (GET /api/v1/app-registry/apps, limit=200 — + * the service's MAX_LIMIT). */ +async function listAllApps(apiUrl, token) { + const apps = [] + let cursor = null + let pages = 0 + const MAX_PAGES = 25 // guard against a pagination bug looping forever + do { + const url = new URL(`${apiUrl}/api/v1/app-registry/apps`) + url.searchParams.set('limit', '200') + if (cursor) url.searchParams.set('cursor', cursor) + const resp = await fetchWithTimeout(url.toString(), { + headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, + }) + if (resp.status !== 200) { + let bodyText = '' + try { + bodyText = await resp.text() + } catch { + /* ignore */ + } + throw new Error(`GET ${url} -> HTTP ${resp.status}. Body: ${bodyText.slice(0, 300)}`) + } + const body = await resp.json() + apps.push(...(body.apps || [])) + cursor = body.nextCursor || null + pages++ + } while (cursor && pages < MAX_PAGES) + return apps +} + +function loadExpected(expectedPath) { + const raw = readFileSync(expectedPath, 'utf8') + const parsed = JSON.parse(raw) + if (!Array.isArray(parsed.apps) || parsed.apps.length === 0) { + throw new Error(`${expectedPath} declares zero expected apps — refusing to run with an empty roster (that would silently disable the missing-app check)`) + } + return parsed.apps +} + +function printTable(rows) { + const cols = ['SLUG', 'NAME', 'STATUS', 'RESULT', 'DETAIL'] + const widths = cols.map((c, i) => + Math.max(c.length, ...rows.map(r => String(r[i] ?? '').length)) + ) + const line = arr => arr.map((v, i) => String(v).padEnd(widths[i])).join(' ') + console.log(line(cols)) + console.log(widths.map(w => '-'.repeat(w)).join(' ')) + for (const r of rows) console.log(line(r)) +} + +async function main() { + const args = parseArgs(process.argv.slice(2)) + if (Number.isFinite(args.timeoutMs) && args.timeoutMs > 0) { + FETCH_TIMEOUT_MS = args.timeoutMs + } + const expected = loadExpected(args.expectedPath) + + let token = args.token + let whoami = '(pre-supplied token)' + if (!token) { + if (!args.email || !args.password) { + fail( + 'no credentials: set --token, or --email/--password (env PORTAL_HEALTH_EMAIL/PORTAL_HEALTH_PASSWORD, falling back to POST_PROD_EMAIL/POST_PROD_PASSWORD)' + ) + process.exitCode = 1 + return + } + // Wrapped for the same reason listAllApps below is. An unwrapped failure + // here reaches the bottom-of-file catch, which can only say + // "AbortError: This operation was aborted" -- no phase, no URL, no timeout + // value. That is what the first production run of this census actually + // printed: it failed honestly, which is right, but told nobody WHAT failed, + // which defeats the point of building it. + try { + const loggedIn = await login(args.apiUrl, args.email, args.password) + token = loggedIn.token + whoami = loggedIn.whoami + } catch (err) { + const aborted = err?.name === 'AbortError' + fail( + aborted + ? `could not sign in to ${args.apiUrl}: no response within ${FETCH_TIMEOUT_MS}ms. ` + + 'The portal API did not answer in time -- it is unreachable, or slower than the timeout. ' + + 'Raise --timeout-ms to distinguish "slow" from "down"; do not assume the apps are healthy.' + : `could not sign in to ${args.apiUrl}: ${err.message}` + ) + process.exitCode = 1 + return + } + } + + let apps + try { + apps = await listAllApps(args.apiUrl, token) + } catch (err) { + fail(`could not enumerate the registry: ${err.message}`) + process.exitCode = 1 + return + } + + console.log( + `Portal federation health census — ${args.apiUrl} (resolving remotes against ${args.baseUrl}), authenticated as ${whoami}` + ) + console.log(`Registry returned ${apps.length} app(s) visible to this identity.\n`) + + // Anti-vacuity: zero apps returned is never a silent pass, even before + // consulting the expected list — a check that examined nothing must not + // print green. (Matches check-federated-assets.mjs's zero-chunks stance.) + if (apps.length === 0) { + fail( + 'the registry returned ZERO apps for this identity. Either the registry is genuinely empty (very unlikely) or this identity cannot see anything — either way this is not evidence of health.' + ) + process.exitCode = 1 + } + + const bySlug = new Map(apps.map(a => [a.slug, a])) + const rows = [] + let anyFail = apps.length === 0 + + for (const app of apps) { + const name = app.manifest?.name || '(no name)' + if (app.status !== 'activated') { + rows.push([app.slug, name, app.status, 'FAIL', `registered but status='${app.status}' — not activated, so it is NOT shown in the portal menu`]) + anyFail = true + fail(`${app.slug}: status='${app.status}', not activated — absent from the portal menu regardless of module health`) + continue + } + const probe = await probeApp(app, args.baseUrl) + rows.push([app.slug, name, app.status, probe.result, probe.detail]) + if (probe.result !== 'PASS') { + anyFail = true + fail(`${app.slug} (${name}): ${probe.detail}`) + } + } + + // Expected-but-absent: this is the '13 of 18' check. A slug in the roster + // that never appeared in `apps` at all — regardless of status — is reported + // as its own row rather than just being a shorter table. + for (const exp of expected) { + if (!bySlug.has(exp.slug)) { + rows.push([exp.slug, exp.name, '(absent)', 'MISSING', `expected app not returned by the registry at all (confidence: ${exp.confidence}, source: ${exp.source})`]) + anyFail = true + fail(`${exp.slug} (${exp.name}): expected but ABSENT from the registry response — this is the failure mode a hardcoded list cannot catch`) + } + } + + // Reverse direction: an app the registry has that the roster does not know + // about. Not a failure — just a prompt to keep the checked-in list current. + const expectedSlugs = new Set(expected.map(e => e.slug)) + for (const app of apps) { + if (!expectedSlugs.has(app.slug)) { + warn(`${app.slug} is in the registry but not in scripts/expected-portal-apps.json — if this is a real product, add it there`) + } + } + + console.log() + printTable(rows) + console.log() + + const passCount = rows.filter(r => r[3] === 'PASS').length + const failCount = rows.filter(r => r[3] === 'FAIL').length + const missingCount = rows.filter(r => r[3] === 'MISSING').length + console.log(`${passCount} PASS, ${failCount} FAIL, ${missingCount} MISSING (of ${expected.length} expected).`) + + if (anyFail) { + console.error('\nportal federation health: FAILED — see rows above and the ::error annotations.') + process.exitCode = 1 + } else { + console.log('\nportal federation health: OK — every expected app is registered, activated, and its module (+ at least one chunk) loads as JavaScript.') + } +} + +main().catch(err => { + // Last resort only. Every EXPECTED failure path above reports its own phase, + // URL and cause; reaching here means something genuinely unanticipated + // happened, so print the stack rather than a one-line message. + const aborted = err?.name === 'AbortError' + fail( + aborted + ? `timed out with no phase reported (${FETCH_TIMEOUT_MS}ms). This is a gap in this script's own ` + + 'error handling -- whichever call aborted should be wrapped with a diagnostic naming it.\n' + + (err.stack || '') + : `unhandled error: ${err.stack || err.message}` + ) + process.exitCode = 1 +}) diff --git a/scripts/check-portal-federation-health.selftest.mjs b/scripts/check-portal-federation-health.selftest.mjs new file mode 100644 index 00000000..5756dfea --- /dev/null +++ b/scripts/check-portal-federation-health.selftest.mjs @@ -0,0 +1,255 @@ +#!/usr/bin/env node +/** + * Self-test for check-portal-federation-health.mjs. + * + * WHY THIS FILE EXISTS. Production is not reachable from a dev/CI sandbox for + * this change (this session's egress proxy 403s CONNECT to app.fuzefront.com), + * so "it passes against a clean tree" is not available as evidence and would + * not be evidence anyway — a vacuous check passes against everything. What + * this proves instead: the checker actually goes RED on inputs it is + * specifically supposed to catch. Each fixture below is a tiny local HTTP + * server standing in for the registry API + the federated remotes it + * describes — no network egress, no prod credentials. + * + * Covers, per the task's hard requirement: + * - a 404 remoteEntry -> FAIL, exit 1 + * - an HTML body served WITH a JS content-type -> FAIL, exit 1 + * - an expected app missing from the registry -> FAIL, exit 1 + * plus a healthy baseline (PASS, exit 0) — proving this doesn't just always + * fail, which would be exactly as useless as never failing. + * + * Run: node scripts/check-portal-federation-health.selftest.mjs + */ + +import { test } from 'node:test' +import assert from 'node:assert/strict' +import http from 'node:http' +import { spawn } from 'node:child_process' +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const SCRIPT = path.join(__dirname, 'check-portal-federation-health.mjs') + +/** Minimal fixture server: fakes the app-registry list + login endpoints, and + * serves arbitrary fixed responses for any other path from `routes`. */ +function startFixtureServer({ apps, routes }) { + const server = http.createServer((req, res) => { + const url = new URL(req.url, 'http://localhost') + + if (req.method === 'POST' && url.pathname === '/api/v1/security/session') { + let body = '' + req.on('data', c => (body += c)) + req.on('end', () => { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ status: 'ok', token: 'selftest-token' })) + }) + return + } + + if (req.method === 'GET' && url.pathname === '/api/v1/app-registry/apps') { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ apps, nextCursor: null })) + return + } + + const route = routes[url.pathname] + if (!route) { + res.writeHead(404, { 'Content-Type': 'text/plain' }) + res.end('not found in fixture') + return + } + res.writeHead(route.status, { 'Content-Type': route.contentType ?? 'application/octet-stream' }) + res.end(route.body ?? '') + }) + return new Promise(resolve => { + server.listen(0, '127.0.0.1', () => resolve(server)) + }) +} + +function writeExpected(dir, apps) { + const p = path.join(dir, 'expected.json') + writeFileSync(p, JSON.stringify({ _meta: { purpose: 'selftest fixture' }, apps }), 'utf8') + return p +} + +/** + * MUST be async `spawn`, not `spawnSync`. The fixture server lives in this + * same process/event loop; `spawnSync` blocks that event loop synchronously + * until the child exits, which means the child's requests back to our own + * fixture server would never be serviced — a self-deadlock that resolves only + * when the child's own fetch timeout fires. (Caught by running this exact + * setup during development: every test hung for exactly FETCH_TIMEOUT_MS + * before failing, which is the tell.) + */ +function runChecker({ baseUrl, expectedPath, useToken }) { + const args = [SCRIPT, '--base-url', baseUrl, '--api-url', baseUrl, '--expected', expectedPath] + if (useToken) { + args.push('--token', 'selftest-token') + } else { + args.push('--email', 'selftest@example.test', '--password', 'irrelevant') + } + return new Promise(resolve => { + const child = spawn(process.execPath, args, { stdio: ['ignore', 'pipe', 'pipe'] }) + let stdout = '' + let stderr = '' + child.stdout.on('data', c => (stdout += c)) + child.stderr.on('data', c => (stderr += c)) + child.on('close', status => resolve({ status, stdout, stderr })) + }) +} + +let tmpDir +test.beforeEach(() => { + tmpDir = mkdtempSync(path.join(tmpdir(), 'portal-fed-health-')) +}) +test.afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) +}) + +test('HEALTHY baseline: a real MF app (entry + chunk) and a real iframe app both PASS, exit 0', async () => { + const server = await startFixtureServer({ + apps: [ + { + slug: 'demo-mf', + status: 'activated', + manifest: { name: 'Demo MF', integration: { type: 'module-federation', remoteEntry: '/apps/demo-mf/remoteEntry.js' } }, + }, + { + slug: 'demo-iframe', + status: 'activated', + manifest: { name: 'Demo Iframe', integration: { type: 'iframe', url: '/apps/demo-iframe/' } }, + }, + ], + routes: { + '/apps/demo-mf/remoteEntry.js': { + status: 200, + contentType: 'application/javascript', + body: 'import("./chunk-abc.js");', + }, + '/apps/demo-mf/chunk-abc.js': { status: 200, contentType: 'application/javascript', body: 'console.log(1)' }, + '/apps/demo-iframe/': { status: 200, contentType: 'text/html', body: 'ok' }, + }, + }) + try { + const baseUrl = `http://127.0.0.1:${server.address().port}` + const expectedPath = writeExpected(tmpDir, [ + { slug: 'demo-mf', name: 'Demo MF', confidence: 'verified', source: 'selftest' }, + { slug: 'demo-iframe', name: 'Demo Iframe', confidence: 'verified', source: 'selftest' }, + ]) + const { status, stdout } = await runChecker({ baseUrl, expectedPath }) + assert.equal(status, 0, `expected exit 0 on a healthy fixture, got ${status}. stdout:\n${stdout}`) + assert.match(stdout, /demo-mf\s+Demo MF\s+activated\s+PASS/) + assert.match(stdout, /demo-iframe\s+Demo Iframe\s+activated\s+PASS/) + assert.match(stdout, /0 FAIL, 0 MISSING/) + } finally { + server.close() + } +}) + +test('BROKEN INPUT 1/3: a 404 remoteEntry FAILS the app and exits non-zero', async () => { + const server = await startFixtureServer({ + apps: [ + { + slug: 'demo-404', + status: 'activated', + manifest: { name: 'Demo 404', integration: { type: 'module-federation', remoteEntry: '/apps/demo-404/remoteEntry.js' } }, + }, + ], + routes: {}, // nothing registered -> the fixture server 404s everything + }) + try { + const baseUrl = `http://127.0.0.1:${server.address().port}` + const expectedPath = writeExpected(tmpDir, [ + { slug: 'demo-404', name: 'Demo 404', confidence: 'verified', source: 'selftest' }, + ]) + const { status, stdout, stderr } = await runChecker({ baseUrl, expectedPath, useToken: true }) + assert.notEqual(status, 0, 'a 404 remoteEntry must fail the run, not pass it') + assert.match(stdout, /demo-404\s+Demo 404\s+activated\s+FAIL/) + assert.match(stdout + stderr, /HTTP 404/) + } finally { + server.close() + } +}) + +test('BROKEN INPUT 2/3: an HTML body served WITH a JS content-type FAILS (the header lied)', async () => { + const server = await startFixtureServer({ + apps: [ + { + slug: 'demo-spoof', + status: 'activated', + manifest: { name: 'Demo Spoof', integration: { type: 'module-federation', remoteEntry: '/apps/demo-spoof/remoteEntry.js' } }, + }, + ], + routes: { + // The header says JavaScript; the bytes are an HTML fallback page. A + // checker that trusts content-type alone would call this healthy. + '/apps/demo-spoof/remoteEntry.js': { + status: 200, + contentType: 'application/javascript', + body: 'portal shell fallback', + }, + }, + }) + try { + const baseUrl = `http://127.0.0.1:${server.address().port}` + const expectedPath = writeExpected(tmpDir, [ + { slug: 'demo-spoof', name: 'Demo Spoof', confidence: 'verified', source: 'selftest' }, + ]) + const { status, stdout } = await runChecker({ baseUrl, expectedPath, useToken: true }) + assert.notEqual(status, 0, 'an HTML body behind a JS content-type must fail, not pass') + assert.match(stdout, /demo-spoof\s+Demo Spoof\s+activated\s+FAIL/) + assert.match(stdout, /is HTML|SPA fallback/i) + } finally { + server.close() + } +}) + +test('BROKEN INPUT 3/3: an expected app absent from the registry is reported MISSING and fails the run', async () => { + const server = await startFixtureServer({ + apps: [ + { + slug: 'present-app', + status: 'activated', + manifest: { name: 'Present', integration: { type: 'module-federation', remoteEntry: '/apps/present-app/remoteEntry.js' } }, + }, + // 'ghost-app' is intentionally never returned — simulating a product + // that quietly dropped out of the registry (the 13-of-18 symptom). + ], + routes: { + '/apps/present-app/remoteEntry.js': { status: 200, contentType: 'application/javascript', body: 'import("./c.js")' }, + '/apps/present-app/c.js': { status: 200, contentType: 'application/javascript', body: '1' }, + }, + }) + try { + const baseUrl = `http://127.0.0.1:${server.address().port}` + const expectedPath = writeExpected(tmpDir, [ + { slug: 'present-app', name: 'Present', confidence: 'verified', source: 'selftest' }, + { slug: 'ghost-app', name: 'Ghost', confidence: 'verified', source: 'selftest' }, + ]) + const { status, stdout } = await runChecker({ baseUrl, expectedPath, useToken: true }) + assert.notEqual(status, 0, 'a missing expected app must fail the run, not produce a silently shorter table') + assert.match(stdout, /present-app\s+Present\s+activated\s+PASS/) + assert.match(stdout, /ghost-app\s+Ghost\s+\(absent\)\s+MISSING/) + assert.match(stdout, /1 MISSING/) + } finally { + server.close() + } +}) + +test('ANTI-VACUITY: a registry returning zero apps is a FAIL, never a silent pass', async () => { + const server = await startFixtureServer({ apps: [], routes: {} }) + try { + const baseUrl = `http://127.0.0.1:${server.address().port}` + const expectedPath = writeExpected(tmpDir, [ + { slug: 'anything', name: 'Anything', confidence: 'verified', source: 'selftest' }, + ]) + const { status, stdout, stderr } = await runChecker({ baseUrl, expectedPath, useToken: true }) + assert.notEqual(status, 0, 'zero apps returned must never exit 0') + assert.match(stdout + stderr, /ZERO apps/) + } finally { + server.close() + } +}) diff --git a/scripts/expected-portal-apps.json b/scripts/expected-portal-apps.json new file mode 100644 index 00000000..3d22e57e --- /dev/null +++ b/scripts/expected-portal-apps.json @@ -0,0 +1,43 @@ +{ + "_meta": { + "purpose": "The checked-in roster scripts/check-portal-federation-health.mjs diffs the live registry against, so a product that silently drops out of /api/v1/app-registry/apps (the '13 of 18' symptom) fails loudly instead of producing a shorter-but-still-green table.", + "notASlugMigrationWorklist": "This file is NOT authority to edit any product's registered `slug`. Per CLAUDE.md §'slug, display name, and the federated serve path are THREE INDEPENDENT questions', slug is free at registration and immutable after. If a live registry slug genuinely differs from an entry below, FIX THIS FILE to match the registry — never the other way around. This list exists only to notice disappearance, not to prescribe naming.", + "maintainedBy": "owner (izzywdev) — add an entry when a new product joins the family, remove one when a product is formally retired, correct the slug if a `confidence` below turns out wrong.", + "lastReviewed": "2026-08-25", + "countRationale": "18 entries = the 20-repo fleet in docs/planning/production-conformance.md §1, minus FuzeInfra and FuzeSDLC (platform/governance repos with no portal tile), minus FuzeFront itself (the host shell, not a listed app), plus FuzeQuality ('in prod, not in the 20' per that doc, but builtin and portal-visible). Matches the owner's own '18 products' count.", + "confidenceLevels": { + "verified": "slug read directly from code in THIS repo (backend/applications/src/app-registry/builtins.ts BUILTIN_MANIFESTS, or FuzeQuality/registration/manifest.json) — cannot be stale without this repo's own tests catching it.", + "owner-ruling": "slug is one of the exact values the owner named on 2026-08-19 (quoted verbatim in CLAUDE.md §'slug, display name, and the federated serve path') as either a prefixed exception to keep (fuzex, fuzebi) or an already-correct unprefixed slug not to be migrated.", + "documented": "slug taken from a specific named FuzeFront doc that is not itself flagged RETIRED/historical-snapshot for this purpose, but was not independently re-measured for this file.", + "inferred": "no direct citation found; slug is a best guess by family naming convention. Treat a MISSING result for one of these as 'go verify the real slug', not as proof the product is down." + }, + "sources": [ + "backend/applications/src/app-registry/builtins.ts (BUILTIN_MANIFESTS)", + "FuzeQuality/registration/manifest.json", + "CLAUDE.md §'slug, display name, and the federated serve path are THREE INDEPENDENT questions' (owner ruling, 2026-08-19, quoted verbatim)", + "docs/planning/app-suites-and-modes.md (FuzeHub suite example)", + "packages/onboarding-kit/README.md (FuzeService used as the running naming-convention example)", + "docs/runbooks/app-slug-deprefix-migration.md — RETIRED; its measured-state table was read only as a last-resort snapshot for products no other source names, and is called out per-entry below" + ] + }, + "apps": [ + { "slug": "fuzeagent", "name": "FuzeAgent", "confidence": "verified", "source": "builtins.ts BUILTIN_MANIFESTS" }, + { "slug": "fuzesocial", "name": "FuzeSocial", "confidence": "verified", "source": "builtins.ts BUILTIN_MANIFESTS" }, + { "slug": "fuzequality", "name": "FuzeQuality", "confidence": "verified", "source": "builtins.ts BUILTIN_MANIFESTS + FuzeQuality/registration/manifest.json" }, + { "slug": "fuzex", "name": "FuzeX", "confidence": "owner-ruling", "source": "CLAUDE.md 2026-08-19 — kept prefixed, one of the two named display-name exceptions (FuzeBI/FuzeX)" }, + { "slug": "fuzebi", "name": "FuzeBI", "confidence": "owner-ruling", "source": "CLAUDE.md 2026-08-19 — kept prefixed, one of the two named display-name exceptions (FuzeBI/FuzeX)" }, + { "slug": "deploy", "name": "FuzeDeploy", "confidence": "owner-ruling", "source": "CLAUDE.md 2026-08-19 — named as an already-unprefixed slug to leave alone" }, + { "slug": "call", "name": "FuzeCall", "confidence": "owner-ruling", "source": "CLAUDE.md 2026-08-19 — named as an already-unprefixed slug to leave alone" }, + { "slug": "executive", "name": "FuzeExecutive", "confidence": "owner-ruling", "source": "CLAUDE.md 2026-08-19 — named as an already-unprefixed slug to leave alone" }, + { "slug": "finance", "name": "FuzeFinance", "confidence": "owner-ruling", "source": "CLAUDE.md 2026-08-19 — named as an already-unprefixed slug to leave alone" }, + { "slug": "keys", "name": "FuzeKeys", "confidence": "owner-ruling", "source": "CLAUDE.md 2026-08-19 — named as an already-unprefixed slug to leave alone" }, + { "slug": "market", "name": "FuzeMarket", "confidence": "owner-ruling", "source": "CLAUDE.md 2026-08-19 — named as an already-unprefixed slug to leave alone" }, + { "slug": "picker", "name": "FuzePicker", "confidence": "owner-ruling", "source": "CLAUDE.md 2026-08-19 — named as an already-unprefixed slug to leave alone; corroborated by docs/runbooks/app-slug-deprefix-migration.md ('name only — already correct')" }, + { "slug": "fuzecontact", "name": "Contact", "confidence": "documented", "source": "docs/runbooks/app-slug-deprefix-migration.md snapshot (RETIRED doc — read only as a last-resort snapshot; not independently re-measured)" }, + { "slug": "fuzehub-ventures","name": "FuzeHub", "confidence": "documented", "source": "docs/planning/app-suites-and-modes.md — worked example: 'manifest.json slug: fuzehub-ventures (primary; owns policy + billing)'" }, + { "slug": "fuzeplan", "name": "FuzePlan", "confidence": "documented", "source": "docs/runbooks/app-slug-deprefix-migration.md snapshot (RETIRED doc — read only as a last-resort snapshot; not independently re-measured)" }, + { "slug": "fuzesales", "name": "Sales", "confidence": "documented", "source": "docs/runbooks/app-slug-deprefix-migration.md snapshot (RETIRED doc — read only as a last-resort snapshot; not independently re-measured)" }, + { "slug": "fuzeservice", "name": "FuzeService", "confidence": "documented", "source": "packages/onboarding-kit/README.md — used verbatim as the naming-convention's running example: \"slug\": \"fuzeservice\"" }, + { "slug": "merchandize", "name": "FuzeMerchandize", "confidence": "inferred", "source": "no direct citation found anywhere in this repo; guessed by family convention (unprefixed, matches the 'deploy/call/executive/finance/keys/market/picker' pattern). VERIFY before trusting a MISSING result for this one." } + ] +}