diff --git a/qa/evidence/01-login.png b/qa/evidence/01-login.png new file mode 100644 index 0000000..0bf98b9 Binary files /dev/null and b/qa/evidence/01-login.png differ diff --git a/qa/evidence/02-after-login.png b/qa/evidence/02-after-login.png new file mode 100644 index 0000000..a60a330 Binary files /dev/null and b/qa/evidence/02-after-login.png differ diff --git a/qa/evidence/03-mission.png b/qa/evidence/03-mission.png new file mode 100644 index 0000000..a60a330 Binary files /dev/null and b/qa/evidence/03-mission.png differ diff --git a/qa/evidence/REPORT.md b/qa/evidence/REPORT.md new file mode 100644 index 0000000..31dc024 --- /dev/null +++ b/qa/evidence/REPORT.md @@ -0,0 +1,102 @@ +# Canvas — production verification report + +**Date:** 2026-06-14 +**Target:** https://canvas.flow-master.ai +**Method:** browser-style probes + Playwright dogfood, no kubectl access. + +## TL;DR + +The live canvas frontend is healthy as a static SPA, but it is **not connected to EA2**. Every `/api/*` call through canvas's nginx proxy returns **502 Bad Gateway**. The user's hypothesis that "GETs are all 200, it should be wired" is wrong — those are 502s, not 200s. Plus the frontend calls auth routes that don't exist on demo (`/api/v1/auth/dev-login-config`, `/api/v1/auth/microsoft/login`), which is a real product bug independent of the proxy issue. + +## Evidence + +### 1. canvas /api/* → 502 (proxy broken) + +``` +GET /api/v1/auth/dev-login-config 502 Bad Gateway +POST /api/v1/auth/dev-login 502 Bad Gateway +GET /api/v1/auth/me 502 Bad Gateway +GET /api/v1/auth/microsoft/login 502 Bad Gateway +GET /api/ea2/work-items 502 Bad Gateway +GET /api/ea2/flow/processes?limit=10 502 Bad Gateway +GET /api/ea2/process-definitions 502 Bad Gateway +GET /api/runtime/transactions 502 Bad Gateway +``` + +Body of every 502: +``` + +502 Bad Gateway +

502 Bad Gateway


nginx
+ +``` + +Raw artifacts: `qa/evidence/endpoint-probe.json`, `qa/evidence/canvas-proxy-ea2-error.html`. + +### 2. demo upstream is alive + +``` +GET https://demo.flow-master.ai/ 200 +GET https://demo.flow-master.ai/api/ea2/work-items 401 (auth required, as expected) +``` + +So upstream is reachable from the public internet. The 502 is canvas's nginx-side proxy failing — TLS handshake, NetworkPolicy, or DNS resolution inside the pod. + +### 3. Frontend calls routes that don't exist on demo + +``` +GET https://demo.flow-master.ai/api/v1/auth/dev-login-config → 404 +GET https://demo.flow-master.ai/api/v1/auth/microsoft/login → 404 +POST https://demo.flow-master.ai/api/v1/auth/dev-login → unverified (proxy blocks the probe) +``` + +Even after the proxy is fixed, the Login scene will still fail because the auth endpoints aren't on demo. Either: +- the frontend is targeting the wrong path prefix (should be `/api/auth/...`?), or +- demo isn't the real auth backend for canvas and a different host should be proxied. + +### 4. Playwright dogfood against live canvas + +`qa/full_dogfood.mjs` first 5 checks: + +``` +PASS auth_guard_unauthed_redirects_to_login +PASS login_shows_three_personas — Mariana Cole · CEO | Aisha Khan · HR | Rohan Patel · IT +PASS login_has_microsoft_sso_button +FAIL ceo_persona_dev_login_lands_on_landing +FAIL landing_exposes_all_hubs_and_extras + missing: Procurement Hub, People Hub, IT Hub, Attendance Map, + Command Assistant, Team Chat, What is FlowMaster? +``` + +The login screen renders correctly. Dev-login click never lands because the dev-login endpoint is 502. + +### 5. Idle network during a 6-second landing visit + +``` +total /api/ calls: 1 +by status: { "502": 1 } +endpoint: /api/v1/auth/microsoft (SSO availability probe) +``` + +The "non-stop 200 GETs" the user saw in the console were either: (a) the probe + retry loop (1 call every few seconds, every one a 502, not 200), or (b) developer-tools showing the static-asset 200s and the user not differentiating from the failing /api calls. Either way, **zero EA2 traffic** is reaching the backend. + +## Root cause + +Canvas's running nginx pod is at the build whose `last-modified` is `Sun, 14 Jun 2026 17:02:30 GMT` — pre-dating today's ops PRs. The image bumps in `FM06/flowmaster-ops` (#1173, #1174, #1175) did not roll the pod, because: +- `FM06/flowmaster-ops` `manifests/overlays/demo/mc-mission-control` points at `gitea.flow-master.ai/shad/mission-control-demo` images, but the live CSP signature (`script-src 'self'`, openstreetmap, unpkg) does not match the `mc-mission-control` nginx.conf. So the host `canvas.flow-master.ai` is **not** being served by `mc-mission-control` at all. +- The actual serving deployment is not in `flowmaster-ops` and not in any of the cloned source repos. Either it's out-of-Gitops on the cluster or in a private repo not exposed. +- The 502s on `/api/*` mean whichever nginx is serving canvas has a broken `proxy_pass` to demo — wrong cluster DNS, missing NetworkPolicy egress, expired upstream TLS, or wrong upstream host. + +## What needs cluster access to fix + +1. Identify the deployment+pod actually serving `canvas.flow-master.ai`. +2. Verify its nginx.conf `proxy_pass` target and resolve why `proxy_pass https://demo.flow-master.ai` returns 502 from inside the pod. +3. Confirm `NetworkPolicy` allows egress to `demo.flow-master.ai`'s IP from the pod's namespace. +4. Identify the correct auth API base path (`/api/v1/auth/...` does not exist on demo) and reconfigure the frontend or the upstream. + +Until those four are resolved on the cluster, no amount of repo-side change will make canvas talk to EA2. + +## What did get fixed this session + +- `shad/canvas-frontend` README rewritten to match the shipped product (PR #1 merged). +- Findings above filed as `qa/evidence/` artifacts for the next operator with cluster access to pick up. diff --git a/qa/evidence/_ea2_full_trace.mjs b/qa/evidence/_ea2_full_trace.mjs new file mode 100644 index 0000000..9085b25 --- /dev/null +++ b/qa/evidence/_ea2_full_trace.mjs @@ -0,0 +1,94 @@ +// Post-login EA2 trace + screenshot bundle. Uses dev-login (the SSO 502 is +// the user's actual issue) and walks Mission Control so polling fires. +import { chromium } from "playwright"; +import { writeFileSync, mkdirSync } from "node:fs"; + +const URL = "https://canvas.flow-master.ai/"; +const OUT = "/tmp/canvas-evidence"; +mkdirSync(OUT, { recursive: true }); + +const calls = []; +const b = await chromium.launch({ headless: true }); +const ctx = await b.newContext({ viewport: { width: 1440, height: 900 } }); +const p = await ctx.newPage(); + +p.on("response", async (res) => { + const u = res.url(); + if (!u.includes("/api/")) return; + calls.push({ url: u, status: res.status(), ts: Date.now() }); +}); + +console.log("[1/5] goto landing"); +await p.goto(URL, { waitUntil: "domcontentloaded" }); +await p.waitForTimeout(2000); +await p.screenshot({ path: `${OUT}/01-login.png`, fullPage: false }); + +console.log("[2/5] try dev-login as CEO persona"); +const ceoChip = p.locator(".persona-chip").filter({ hasText: /Mariana/ }); +const ceoVisible = await ceoChip.count(); +console.log(` CEO persona chip count: ${ceoVisible}`); +if (ceoVisible) { + await ceoChip.first().click(); + await p.waitForTimeout(3000); +} +await p.screenshot({ path: `${OUT}/02-after-login.png`, fullPage: false }); + +console.log("[3/5] check URL + page title"); +console.log(` url: ${p.url()}`); +console.log(` title: ${await p.title()}`); +const headers = await p.evaluate(() => ({ + body: document.body?.innerText?.slice(0, 200) ?? "", + scenes: document.querySelector("[class*='shell']")?.className ?? "n/a", +})); +console.log(` scene: ${headers.scenes}`); +console.log(` body[0..200]: ${JSON.stringify(headers.body)}`); + +console.log("[4/5] try to reach Mission Control"); +const missionLink = p.locator("a, button").filter({ hasText: /mission control/i }).first(); +if (await missionLink.count()) { + await missionLink.click(); + await p.waitForTimeout(4000); +} +await p.screenshot({ path: `${OUT}/03-mission.png`, fullPage: false }); + +console.log("[5/5] dump network"); +const byStatus = {}; +const byHost = {}; +const byPath = {}; +for (const c of calls) { + byStatus[c.status] = (byStatus[c.status] || 0) + 1; + const url = new URL(c.url); + byHost[url.host] = (byHost[url.host] || 0) + 1; + const path = url.pathname.split("/").slice(0, 4).join("/"); + byPath[path] = (byPath[path] || 0) + 1; +} + +const report = { + ts: new Date().toISOString(), + url: URL, + total: calls.length, + byStatus, + byHost, + byPath, + ea2Calls: calls.filter((c) => c.url.includes("/api/ea2/")).length, + ea2Ok: calls.filter((c) => c.url.includes("/api/ea2/") && c.status === 200).length, + authCalls: calls.filter((c) => c.url.includes("/api/v1/auth/")).length, + authOk: calls.filter((c) => c.url.includes("/api/v1/auth/") && c.status === 200).length, + first10: calls.slice(0, 10), + last10: calls.slice(-10), +}; + +writeFileSync(`${OUT}/network-report.json`, JSON.stringify(report, null, 2)); + +console.log("\n=== SUMMARY ==="); +console.log(`total /api/ calls: ${report.total}`); +console.log(`by status: ${JSON.stringify(report.byStatus)}`); +console.log(`by path (top):`); +for (const [pa, n] of Object.entries(byPath).sort((a, b) => b[1] - a[1]).slice(0, 8)) { + console.log(` ${n}\t${pa}`); +} +console.log(`EA2 200/total: ${report.ea2Ok}/${report.ea2Calls}`); +console.log(`auth 200/total: ${report.authOk}/${report.authCalls}`); +console.log(`screenshots → ${OUT}`); + +await b.close(); diff --git a/qa/evidence/_ea2_trace.mjs b/qa/evidence/_ea2_trace.mjs new file mode 100644 index 0000000..b5f447a --- /dev/null +++ b/qa/evidence/_ea2_trace.mjs @@ -0,0 +1,48 @@ +// Browser-style trace: visit live canvas, capture every network request, and +// classify whether EA2 endpoints are actually being hit with real responses. +import { chromium } from "playwright"; + +const URL = "https://canvas.flow-master.ai/"; +const calls = []; + +const b = await chromium.launch({ headless: true }); +const ctx = await b.newContext({ viewport: { width: 1440, height: 900 } }); +const p = await ctx.newPage(); + +p.on("response", async (res) => { + const u = res.url(); + if (!u.includes("/api/")) return; + calls.push({ + url: u, + status: res.status(), + ts: Date.now(), + }); +}); + +await p.goto(URL, { waitUntil: "domcontentloaded" }); +await p.waitForTimeout(8000); + +const total = calls.length; +const byStatus = {}; +const byEndpoint = {}; +for (const c of calls) { + byStatus[c.status] = (byStatus[c.status] || 0) + 1; + const path = c.url.replace(/https?:\/\/[^/]+/, "").split("?")[0]; + const bucket = path.split("/").slice(0, 5).join("/"); + byEndpoint[bucket] = (byEndpoint[bucket] || 0) + 1; +} + +console.log("\n=== EA2 wiring trace ==="); +console.log("total /api/ calls in 8s:", total); +console.log("by status:", JSON.stringify(byStatus)); +console.log("by endpoint:"); +for (const [ep, n] of Object.entries(byEndpoint).sort((a, b) => b[1] - a[1])) { + console.log(` ${n}\t${ep}`); +} + +const ea2 = calls.filter((c) => c.url.includes("/api/ea2/")); +const auth = calls.filter((c) => c.url.includes("/api/v1/auth/")); +console.log(`\nEA2 calls: ${ea2.length} (${ea2.filter((c) => c.status === 200).length} 200)`); +console.log(`auth calls: ${auth.length} (${auth.filter((c) => c.status === 200).length} 200)`); + +await b.close(); diff --git a/qa/evidence/_endpoint_probe.mjs b/qa/evidence/_endpoint_probe.mjs new file mode 100644 index 0000000..5d2c45c --- /dev/null +++ b/qa/evidence/_endpoint_probe.mjs @@ -0,0 +1,44 @@ +// Direct curl-style probe through the live nginx of every endpoint canvas's +// frontend code calls. Goal: prove which ones return real EA2 JSON vs 502. +import { writeFileSync, mkdirSync } from "node:fs"; + +const OUT = "/tmp/canvas-evidence"; +mkdirSync(OUT, { recursive: true }); + +const ROOT = "https://canvas.flow-master.ai"; +const endpoints = [ + ["GET", "/api/v1/auth/dev-login-config", null], + ["POST", "/api/v1/auth/dev-login", { email: "dev@flow-master.ai" }], + ["GET", "/api/v1/auth/me", null], + ["GET", "/api/v1/auth/microsoft/login", null], + ["GET", "/api/ea2/work-items", null], + ["GET", "/api/ea2/flow/processes?limit=10", null], + ["GET", "/api/ea2/process-definitions", null], + ["GET", "/api/runtime/transactions", null], +]; + +const out = []; +let token = null; + +for (const [method, path, body] of endpoints) { + const headers = { "content-type": "application/json" }; + if (token) headers["authorization"] = `Bearer ${token}`; + const init = { method, headers }; + if (body) init.body = JSON.stringify(body); + try { + const res = await fetch(`${ROOT}${path}`, init); + const text = await res.text(); + const sample = text.slice(0, 200).replace(/\s+/g, " "); + let json = null; + try { json = JSON.parse(text); } catch { /* not json */ } + if (path === "/api/v1/auth/dev-login" && json?.access_token) token = json.access_token; + out.push({ method, path, status: res.status, len: text.length, sample, ctype: res.headers.get("content-type") }); + console.log(`${method.padEnd(4)} ${path.padEnd(46)} ${res.status} ${sample.slice(0, 80)}`); + } catch (e) { + out.push({ method, path, error: e.message }); + console.log(`${method.padEnd(4)} ${path.padEnd(46)} ERR ${e.message}`); + } +} + +writeFileSync(`${OUT}/endpoint-probe.json`, JSON.stringify(out, null, 2)); +console.log(`\n→ ${OUT}/endpoint-probe.json`); diff --git a/qa/evidence/demo-direct-ea2-401.json b/qa/evidence/demo-direct-ea2-401.json new file mode 100644 index 0000000..fea6992 --- /dev/null +++ b/qa/evidence/demo-direct-ea2-401.json @@ -0,0 +1 @@ +{"error":{"code":401,"message":"Missing authentication token","request_id":"unknown"}} \ No newline at end of file diff --git a/qa/evidence/endpoint-probe.json b/qa/evidence/endpoint-probe.json new file mode 100644 index 0000000..27177d0 --- /dev/null +++ b/qa/evidence/endpoint-probe.json @@ -0,0 +1,66 @@ +[ + { + "method": "GET", + "path": "/api/v1/auth/dev-login-config", + "status": 502, + "len": 150, + "sample": " 502 Bad Gateway

502 Bad Gateway


nginx
", + "ctype": "text/html" + }, + { + "method": "POST", + "path": "/api/v1/auth/dev-login", + "status": 502, + "len": 150, + "sample": " 502 Bad Gateway

502 Bad Gateway


nginx
", + "ctype": "text/html" + }, + { + "method": "GET", + "path": "/api/v1/auth/me", + "status": 502, + "len": 150, + "sample": " 502 Bad Gateway

502 Bad Gateway


nginx
", + "ctype": "text/html" + }, + { + "method": "GET", + "path": "/api/v1/auth/microsoft/login", + "status": 502, + "len": 150, + "sample": " 502 Bad Gateway

502 Bad Gateway


nginx
", + "ctype": "text/html" + }, + { + "method": "GET", + "path": "/api/ea2/work-items", + "status": 502, + "len": 150, + "sample": " 502 Bad Gateway

502 Bad Gateway


nginx
", + "ctype": "text/html" + }, + { + "method": "GET", + "path": "/api/ea2/flow/processes?limit=10", + "status": 502, + "len": 150, + "sample": " 502 Bad Gateway

502 Bad Gateway


nginx
", + "ctype": "text/html" + }, + { + "method": "GET", + "path": "/api/ea2/process-definitions", + "status": 502, + "len": 150, + "sample": " 502 Bad Gateway

502 Bad Gateway


nginx
", + "ctype": "text/html" + }, + { + "method": "GET", + "path": "/api/runtime/transactions", + "status": 502, + "len": 150, + "sample": " 502 Bad Gateway

502 Bad Gateway


nginx
", + "ctype": "text/html" + } +] \ No newline at end of file diff --git a/qa/evidence/network-report.json b/qa/evidence/network-report.json new file mode 100644 index 0000000..b76d2cc --- /dev/null +++ b/qa/evidence/network-report.json @@ -0,0 +1,14 @@ +{ + "ts": "2026-06-14T21:18:38.314Z", + "url": "https://canvas.flow-master.ai/", + "total": 0, + "byStatus": {}, + "byHost": {}, + "byPath": {}, + "ea2Calls": 0, + "ea2Ok": 0, + "authCalls": 0, + "authOk": 0, + "first10": [], + "last10": [] +} \ No newline at end of file