diff --git a/.changeset/config.json b/.changeset/config.json index eeab345fdc..d9f44e5375 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -14,6 +14,9 @@ "updateInternalDependencies": "patch", "ignore": [ "scratchpad", + "@forgerock/devtools-extension", + "@forgerock/devtools-bridge", + "@forgerock/devtools-types", "@forgerock/pingone-scripts", "@forgerock/device-client-app", "@forgerock/davinci-app", diff --git a/.changeset/quiet-onions-study.md b/.changeset/quiet-onions-study.md new file mode 100644 index 0000000000..7c5df1f6e7 --- /dev/null +++ b/.changeset/quiet-onions-study.md @@ -0,0 +1,6 @@ +--- +'@forgerock/journey-client': minor +'@forgerock/oidc-client': minor +--- + +Adds subscribe method to public api diff --git a/.changeset/seven-hoops-win.md b/.changeset/seven-hoops-win.md new file mode 100644 index 0000000000..26c65248e1 --- /dev/null +++ b/.changeset/seven-hoops-win.md @@ -0,0 +1,5 @@ +--- +'@forgerock/davinci-client': minor +--- + +Adds a get cache method to expose the cache for consumers like devtools diff --git a/.gitignore b/.gitignore index 327a931c1a..922c4ca6c1 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ .pnpm-store/* .npm/_logs +# Elm +elm-stuff/ + # Generated code logs/* tmp/ diff --git a/.prototools b/.prototools new file mode 100644 index 0000000000..adf7bdaa9d --- /dev/null +++ b/.prototools @@ -0,0 +1,5 @@ +node = "22" +pnpm = "10" + +[settings] +auto-install=true diff --git a/.superpowers/brainstorm/694753-1778353306/content/learn-error-states.html b/.superpowers/brainstorm/694753-1778353306/content/learn-error-states.html new file mode 100644 index 0000000000..15435e557b --- /dev/null +++ b/.superpowers/brainstorm/694753-1778353306/content/learn-error-states.html @@ -0,0 +1,530 @@ +

Learn Tab — Error Visualization

+

+ When a step fails, how should the lifecycle show it? Here's a scenario: Server returns 401 + Unauthorized. +

+ +
+
+
+ + + + + + + + + + + + Step 3 — Token Exchange (FAILED) + + + + + + + + + + + + + + + + BROWSER + + + + + + + + + + + + + + + SERVER + + + + + + + + + + + + 401 + + + + + + + + + + + + + + ERROR + + + Auth failed + + + + + + + + + FORM + + + skipped + + + + + + + ✕ Authentication Failed + + + 401 Unauthorized — Invalid credentials. Token exchange rejected by server. + + +
+
+

A. Broken Flow

+

+ The arrow between server and SDK shows a visible ✕ break. The SDK card turns red with a + pulsing ring. Downstream stages (collectors/form) are ghosted out with dashed borders and + "skipped" label. Error banner below explains what happened. The flow visually stops at the + failure point. +

+
+
+ +
+
+ + + + + + + + + + + + Step 3 — Token Exchange + + + + + + + + + + + + + + + + BROWSER + + + + + + + + + + + + + + + + + + + + 401 + + + + SERVER + + + 401 Unauthorized + + + + + + + + + + + + + + + SDK + + + error + + + + + + + + + FORM + + + + + + + ✕ 401 Unauthorized + + + Invalid credentials — check username/password + + + POST /davinci/connections/…/flow • 123ms + + +
+
+

B. Source Highlight

+

+ The card where the error originated (SERVER) gets a red border, pulsing glow, and a "401" + badge. All downstream cards also turn red to show error propagation, but only the source + card pulses. Downstream form is ghosted. Error detail card below shows specifics anchored to + the source. +

+
+
+
diff --git a/.superpowers/brainstorm/694753-1778353306/content/learn-icons-v2.html b/.superpowers/brainstorm/694753-1778353306/content/learn-icons-v2.html new file mode 100644 index 0000000000..15c2405c01 --- /dev/null +++ b/.superpowers/brainstorm/694753-1778353306/content/learn-icons-v2.html @@ -0,0 +1,635 @@ +

Learn Tab — Icon Style for Step Detail

+

+ Hybrid layout confirmed. Now let's nail the icons and visual language for the step lifecycle. + Which icon style feels right? +

+ +
+
+
+ + + + + + + + + + + + + Step 2 — Password Form + + + + + + + + + + + app + + + Browser + + + POST /flow + + + + + + request + + + + + + + + + + + + + + + + DaVinci + + + PingOne + + + + + + 200 OK + + + + + + + + + + + + + + SDK + + + continue + + + + + + renders + + + + + + + + + + Submit + + + Collectors + + + 2 fields + + + + + + user submits → next request cycle + + + + + + 200 OK + + + + continue + + + + 2 fields + + +
+
+

A. Outlined Technical

+

+ Clean outlined icons — browser with traffic-light dots, rack server with status LEDs, gear + for SDK processing, form fields for collectors. Arrows show direction and label the action. + Loopback arrow shows the cycle repeating. Status pills below summarize each stage. +

+
+
+ +
+
+ + + + + + + + + + + + Step 2 — Password Form + + + + + + + + + + + + + + + + + BROWSER + + + POST /flow + + + + + + + + REQ → + + + + + + + + + + + + + + SERVER + + + PingOne + + + + + + + + ← 200 + + + + + + + + + + + + + + SDK + + + continue + + + + + + + + + + + + + FORM + + + password + + Submit → + + + FORM + + + 2 fields + + + + + + + next cycle → + + + + + + 123ms + + +
+
+

B. Filled Cards with Icons

+

+ Each stage is a card with a recognizable icon inside — browser window with globe, cloud + server with status dots, gear for SDK processing, form with input fields. Arrows are labeled + inline. Bolder, more visual weight. Cycle arrow wraps around to show the loop. +

+
+
+
diff --git a/.superpowers/brainstorm/694753-1778353306/content/learn-layout.html b/.superpowers/brainstorm/694753-1778353306/content/learn-layout.html new file mode 100644 index 0000000000..3aa9ab9836 --- /dev/null +++ b/.superpowers/brainstorm/694753-1778353306/content/learn-layout.html @@ -0,0 +1,326 @@ +

Learn Tab — Layout Direction

+

+ How should the flow diagram be oriented? Consider that auth flows can have 3-15+ steps. +

+ +
+
+
+ + + + + + + + + + + Start + + SDK Init + + + + + + Node 1 + + Username + + + + + + Node 2 + + Password + + + + + + Done + + Success + + + + ▼ Detail panel appears below + + POST /davinci/flow → 200 → collectors + + +
+
+

A. Horizontal (Left → Right)

+

+ Natural reading direction. Flow overview scrolls horizontally. Detail panel below selected + node. Works well with pan/zoom for long flows. +

+
+
+ +
+
+ + + + + + + + + + Start + + + + + + + Node 1 + + + + + + + Node 2 + + + + + + + ✓ + + + + + + Node 1 — Username + + + POST + /davinci/connections/flow → 200 + + NODE + continue — 2 collectors + + RESP + interactionId: abc-123... + +
+
+

B. Vertical (Top → Bottom)

+

+ Like the existing Graph panel but expanded. Flow scrolls vertically. Detail panel to the + right of selected node. Familiar pattern from current UI. +

+
+
+ +
+
+ + + + + + + + + + 1 + + + 2 + + + 3 + + + + + + + Step 2 — Password + + + + + REQUEST + + + + + + + + SERVER + + + + + + + 200 OK + + + + + + + NODE + + + + + + + COLLECTORS + + + + POST /flow + DaVinci + continue + Password + 2 inputs + + + + +
+
+

C. Hybrid — Rail Overview + Step Detail

+

+ Horizontal rail at the top (like existing FlowView) for the full flow overview. Below, an + expanded step-detail section shows the internal lifecycle: Request → Server → Response → + Node → Collectors. Click a node in the rail to drill in. Keeps context while showing detail. + The detail section is the draggable canvas. +

+
+
+
diff --git a/.superpowers/brainstorm/694753-1778353306/content/waiting-2.html b/.superpowers/brainstorm/694753-1778353306/content/waiting-2.html new file mode 100644 index 0000000000..c9f1f81455 --- /dev/null +++ b/.superpowers/brainstorm/694753-1778353306/content/waiting-2.html @@ -0,0 +1,3 @@ +
+

Continuing in terminal...

+
diff --git a/.superpowers/brainstorm/694753-1778353306/content/waiting.html b/.superpowers/brainstorm/694753-1778353306/content/waiting.html new file mode 100644 index 0000000000..c9f1f81455 --- /dev/null +++ b/.superpowers/brainstorm/694753-1778353306/content/waiting.html @@ -0,0 +1,3 @@ +
+

Continuing in terminal...

+
diff --git a/.superpowers/brainstorm/694753-1778353306/state/server-stopped b/.superpowers/brainstorm/694753-1778353306/state/server-stopped new file mode 100644 index 0000000000..1223f1a2da --- /dev/null +++ b/.superpowers/brainstorm/694753-1778353306/state/server-stopped @@ -0,0 +1 @@ +{"reason":"idle timeout","timestamp":1778356250698} diff --git a/.superpowers/brainstorm/694753-1778353306/state/server.log b/.superpowers/brainstorm/694753-1778353306/state/server.log new file mode 100644 index 0000000000..4695c7bc6a --- /dev/null +++ b/.superpowers/brainstorm/694753-1778353306/state/server.log @@ -0,0 +1,23 @@ +{"type":"server-started","port":53346,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:53346","screen_dir":"/home/ryan/programming/ping-javascript-sdk/.superpowers/brainstorm/694753-1778353306/content","state_dir":"/home/ryan/programming/ping-javascript-sdk/.superpowers/brainstorm/694753-1778353306/state"} +{"type":"screen-added","file":"/home/ryan/programming/ping-javascript-sdk/.superpowers/brainstorm/694753-1778353306/content/learn-layout.html"} +{"source":"user-event","type":"click","text":"1\n \n \n 2\n \n \n 3\n \n \n ✓\n\n \n \n Step 2 — Password\n\n \n \n REQUEST\n\n \n\n \n ☁\n SERVER\n\n \n\n \n 200 OK\n\n \n\n \n NODE\n\n \n\n \n COLLECTORS\n\n \n POST /flow\n DaVinci\n continue\n Password\n 2 inputs\n\n \n \n \n \n \n C. Hybrid — Rail Overview + Step Detail\n Horizontal rail at the top (like existing FlowView) for the full flow overview. Below, an expanded step-detail section shows the internal lifecycle: Request → Server → Response → Node → Collectors. Click a node in the rail to drill in. Keeps context while showing detail. The detail section is the draggable canvas.","choice":"hybrid","id":null,"timestamp":1778353448446} +{"source":"user-event","type":"click","text":"1\n \n \n 2\n \n \n 3\n \n \n ✓\n\n \n \n Step 2 — Password\n\n \n \n REQUEST\n\n \n\n \n ☁\n SERVER\n\n \n\n \n 200 OK\n\n \n\n \n NODE\n\n \n\n \n COLLECTORS\n\n \n POST /flow\n DaVinci\n continue\n Password\n 2 inputs\n\n \n \n \n \n \n C. Hybrid — Rail Overview + Step Detail\n Horizontal rail at the top (like existing FlowView) for the full flow overview. Below, an expanded step-detail section shows the internal lifecycle: Request → Server → Response → Node → Collectors. Click a node in the rail to drill in. Keeps context while showing detail. The detail section is the draggable canvas.","choice":"hybrid","id":null,"timestamp":1778353449791} +{"source":"user-event","type":"click","text":"1\n \n \n 2\n \n \n 3\n \n \n ✓\n\n \n \n Step 2 — Password\n\n \n \n REQUEST\n\n \n\n \n ☁\n SERVER\n\n \n\n \n 200 OK\n\n \n\n \n NODE\n\n \n\n \n COLLECTORS\n\n \n POST /flow\n DaVinci\n continue\n Password\n 2 inputs\n\n \n \n \n \n \n C. Hybrid — Rail Overview + Step Detail\n Horizontal rail at the top (like existing FlowView) for the full flow overview. Below, an expanded step-detail section shows the internal lifecycle: Request → Server → Response → Node → Collectors. Click a node in the rail to drill in. Keeps context while showing detail. The detail section is the draggable canvas.","choice":"hybrid","id":null,"timestamp":1778353449972} +{"type":"screen-added","file":"/home/ryan/programming/ping-javascript-sdk/.superpowers/brainstorm/694753-1778353306/content/learn-icons-v2.html"} +{"source":"user-event","type":"click","text":"Step 2 — Password Form\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n BROWSER\n POST /flow\n\n \n \n \n \n REQ →\n \n\n \n \n \n \n \n \n \n \n \n SERVER\n PingOne\n\n \n \n \n \n ← 200\n \n\n \n \n \n \n \n \n ⚙\n ✓\n \n SDK\n continue\n\n \n \n \n \n\n \n \n \n \n \n FORM\n \n \n password\n \n Submit →\n \n FORM\n 2 fields\n\n \n \n \n next cycle →\n\n \n \n 123ms\n \n \n \n B. Filled Cards with Icons\n Each stage is a card with a recognizable icon inside — browser window with globe, cloud server with status dots, gear for SDK processing, form with input fields. Arrows are labeled inline. Bolder, more visual weight. Cycle arrow wraps around to show the loop.","choice":"filled","id":null,"timestamp":1778353603070} +{"source":"user-event","type":"click","text":"Step 2 — Password Form\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n BROWSER\n POST /flow\n\n \n \n \n \n REQ →\n \n\n \n \n \n \n \n \n \n \n \n SERVER\n PingOne\n\n \n \n \n \n ← 200\n \n\n \n \n \n \n \n \n ⚙\n ✓\n \n SDK\n continue\n\n \n \n \n \n\n \n \n \n \n \n FORM\n \n \n password\n \n Submit →\n \n FORM\n 2 fields\n\n \n \n \n next cycle →\n\n \n \n 123ms\n \n \n \n B. Filled Cards with Icons\n Each stage is a card with a recognizable icon inside — browser window with globe, cloud server with status dots, gear for SDK processing, form with input fields. Arrows are labeled inline. Bolder, more visual weight. Cycle arrow wraps around to show the loop.","choice":"filled","id":null,"timestamp":1778353614416} +{"source":"user-event","type":"click","text":"Step 2 — Password Form\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n BROWSER\n POST /flow\n\n \n \n \n \n REQ →\n \n\n \n \n \n \n \n \n \n \n \n SERVER\n PingOne\n\n \n \n \n \n ← 200\n \n\n \n \n \n \n \n \n ⚙\n ✓\n \n SDK\n continue\n\n \n \n \n \n\n \n \n \n \n \n FORM\n \n \n password\n \n Submit →\n \n FORM\n 2 fields\n\n \n \n \n next cycle →\n\n \n \n 123ms\n \n \n \n B. Filled Cards with Icons\n Each stage is a card with a recognizable icon inside — browser window with globe, cloud server with status dots, gear for SDK processing, form with input fields. Arrows are labeled inline. Bolder, more visual weight. Cycle arrow wraps around to show the loop.","choice":"filled","id":null,"timestamp":1778353615062} +{"source":"user-event","type":"click","text":"Step 2 — Password Form\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n BROWSER\n POST /flow\n\n \n \n \n \n REQ →\n \n\n \n \n \n \n \n \n \n \n \n SERVER\n PingOne\n\n \n \n \n \n ← 200\n \n\n \n \n \n \n \n \n ⚙\n ✓\n \n SDK\n continue\n\n \n \n \n \n\n \n \n \n \n \n FORM\n \n \n password\n \n Submit →\n \n FORM\n 2 fields\n\n \n \n \n next cycle →\n\n \n \n 123ms\n \n \n \n B. Filled Cards with Icons\n Each stage is a card with a recognizable icon inside — browser window with globe, cloud server with status dots, gear for SDK processing, form with input fields. Arrows are labeled inline. Bolder, more visual weight. Cycle arrow wraps around to show the loop.","choice":"filled","id":null,"timestamp":1778353615233} +{"source":"user-event","type":"click","text":"Step 2 — Password Form\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n BROWSER\n POST /flow\n\n \n \n \n \n REQ →\n \n\n \n \n \n \n \n \n \n \n \n SERVER\n PingOne\n\n \n \n \n \n ← 200\n \n\n \n \n \n \n \n \n ⚙\n ✓\n \n SDK\n continue\n\n \n \n \n \n\n \n \n \n \n \n FORM\n \n \n password\n \n Submit →\n \n FORM\n 2 fields\n\n \n \n \n next cycle →\n\n \n \n 123ms\n \n \n \n B. Filled Cards with Icons\n Each stage is a card with a recognizable icon inside — browser window with globe, cloud server with status dots, gear for SDK processing, form with input fields. Arrows are labeled inline. Bolder, more visual weight. Cycle arrow wraps around to show the loop.","choice":"filled","id":null,"timestamp":1778353617313} +{"source":"user-event","type":"click","text":"Step 2 — Password Form\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n BROWSER\n POST /flow\n\n \n \n \n \n REQ →\n \n\n \n \n \n \n \n \n \n \n \n SERVER\n PingOne\n\n \n \n \n \n ← 200\n \n\n \n \n \n \n \n \n ⚙\n ✓\n \n SDK\n continue\n\n \n \n \n \n\n \n \n \n \n \n FORM\n \n \n password\n \n Submit →\n \n FORM\n 2 fields\n\n \n \n \n next cycle →\n\n \n \n 123ms\n \n \n \n B. Filled Cards with Icons\n Each stage is a card with a recognizable icon inside — browser window with globe, cloud server with status dots, gear for SDK processing, form with input fields. Arrows are labeled inline. Bolder, more visual weight. Cycle arrow wraps around to show the loop.","choice":"filled","id":null,"timestamp":1778353617492} +{"source":"user-event","type":"click","text":"Step 2 — Password Form\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n BROWSER\n POST /flow\n\n \n \n \n \n REQ →\n \n\n \n \n \n \n \n \n \n \n \n SERVER\n PingOne\n\n \n \n \n \n ← 200\n \n\n \n \n \n \n \n \n ⚙\n ✓\n \n SDK\n continue\n\n \n \n \n \n\n \n \n \n \n \n FORM\n \n \n password\n \n Submit →\n \n FORM\n 2 fields\n\n \n \n \n next cycle →\n\n \n \n 123ms\n \n \n \n B. Filled Cards with Icons\n Each stage is a card with a recognizable icon inside — browser window with globe, cloud server with status dots, gear for SDK processing, form with input fields. Arrows are labeled inline. Bolder, more visual weight. Cycle arrow wraps around to show the loop.","choice":"filled","id":null,"timestamp":1778353618039} +{"source":"user-event","type":"click","text":"Step 2 — Password Form\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n BROWSER\n POST /flow\n\n \n \n \n \n REQ →\n \n\n \n \n \n \n \n \n \n \n \n SERVER\n PingOne\n\n \n \n \n \n ← 200\n \n\n \n \n \n \n \n \n ⚙\n ✓\n \n SDK\n continue\n\n \n \n \n \n\n \n \n \n \n \n FORM\n \n \n password\n \n Submit →\n \n FORM\n 2 fields\n\n \n \n \n next cycle →\n\n \n \n 123ms\n \n \n \n B. Filled Cards with Icons\n Each stage is a card with a recognizable icon inside — browser window with globe, cloud server with status dots, gear for SDK processing, form with input fields. Arrows are labeled inline. Bolder, more visual weight. Cycle arrow wraps around to show the loop.","choice":"filled","id":null,"timestamp":1778353618773} +{"type":"screen-added","file":"/home/ryan/programming/ping-javascript-sdk/.superpowers/brainstorm/694753-1778353306/content/waiting.html"} +{"type":"screen-added","file":"/home/ryan/programming/ping-javascript-sdk/.superpowers/brainstorm/694753-1778353306/content/learn-error-states.html"} +{"type":"screen-added","file":"/home/ryan/programming/ping-javascript-sdk/.superpowers/brainstorm/694753-1778353306/content/waiting-2.html"} +{"type":"screen-updated","file":"/home/ryan/programming/ping-javascript-sdk/.superpowers/brainstorm/694753-1778353306/content/learn-error-states.html"} +{"type":"screen-updated","file":"/home/ryan/programming/ping-javascript-sdk/.superpowers/brainstorm/694753-1778353306/content/learn-icons-v2.html"} +{"type":"screen-updated","file":"/home/ryan/programming/ping-javascript-sdk/.superpowers/brainstorm/694753-1778353306/content/learn-layout.html"} +{"type":"screen-updated","file":"/home/ryan/programming/ping-javascript-sdk/.superpowers/brainstorm/694753-1778353306/content/waiting-2.html"} +{"type":"screen-updated","file":"/home/ryan/programming/ping-javascript-sdk/.superpowers/brainstorm/694753-1778353306/content/waiting.html"} +{"type":"server-stopped","reason":"idle timeout"} diff --git a/.superpowers/brainstorm/694753-1778353306/state/server.pid b/.superpowers/brainstorm/694753-1778353306/state/server.pid new file mode 100644 index 0000000000..429ccc43c5 --- /dev/null +++ b/.superpowers/brainstorm/694753-1778353306/state/server.pid @@ -0,0 +1 @@ +694761 diff --git a/e2e/davinci-app/main.ts b/e2e/davinci-app/main.ts index 119a4dec6e..ed9df0b86a 100644 --- a/e2e/davinci-app/main.ts +++ b/e2e/davinci-app/main.ts @@ -8,6 +8,7 @@ import './style.css'; import { Config, FRUser, TokenManager } from '@forgerock/javascript-sdk'; import { davinci } from '@forgerock/davinci-client'; +import { attachDevToolsBridge } from '@forgerock/devtools-bridge'; import type { CustomLogger, DaVinciConfig, @@ -334,16 +335,13 @@ const urlParams = new URLSearchParams(window.location.search); } } - /** - * Optionally subscribe to the store to listen for all store updates - * This is useful for debugging and logging - * It returns an unsubscribe function that you can call to stop listening - */ davinciClient.subscribe(() => { const node = davinciClient.getNode(); console.log('Event emitted from store:', node); }); + attachDevToolsBridge(davinciClient, config); + const qs = window.location.search; const searchParams = new URLSearchParams(qs); diff --git a/e2e/davinci-app/package.json b/e2e/davinci-app/package.json index 4dfc5cee73..61581ff106 100644 --- a/e2e/davinci-app/package.json +++ b/e2e/davinci-app/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@forgerock/davinci-client": "workspace:*", + "@forgerock/devtools-bridge": "workspace:*", "@forgerock/javascript-sdk": "4.7.0", "@forgerock/protect": "workspace:*", "@forgerock/sdk-logger": "workspace:*" diff --git a/e2e/davinci-app/tsconfig.app.json b/e2e/davinci-app/tsconfig.app.json index 9027cd9d88..80e86dfaff 100644 --- a/e2e/davinci-app/tsconfig.app.json +++ b/e2e/davinci-app/tsconfig.app.json @@ -18,6 +18,9 @@ { "path": "../../packages/protect/tsconfig.lib.json" }, + { + "path": "../../packages/devtools-bridge/tsconfig.lib.json" + }, { "path": "../../packages/davinci-client/tsconfig.lib.json" } diff --git a/e2e/journey-app/main.ts b/e2e/journey-app/main.ts index 3b61558b4d..db50c2358f 100644 --- a/e2e/journey-app/main.ts +++ b/e2e/journey-app/main.ts @@ -7,6 +7,7 @@ import './style.css'; import { journey } from '@forgerock/journey-client'; +import { attachJourneyBridge } from '@forgerock/devtools-bridge'; import type { JourneyClient, RequestMiddleware } from '@forgerock/journey-client/types'; @@ -65,6 +66,7 @@ if (searchParams.get('middleware') === 'true') { let journeyClient: JourneyClient; try { journeyClient = await journey({ config: config, requestMiddleware }); + attachJourneyBridge(journeyClient, config); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; console.error('Failed to initialize journey client:', message); diff --git a/e2e/journey-app/package.json b/e2e/journey-app/package.json index 7cc9e67485..921c91bf47 100644 --- a/e2e/journey-app/package.json +++ b/e2e/journey-app/package.json @@ -11,6 +11,7 @@ "serve": "pnpm nx nxServe" }, "dependencies": { + "@forgerock/devtools-bridge": "workspace:*", "@forgerock/journey-client": "workspace:*", "@forgerock/oidc-client": "workspace:*", "@forgerock/protect": "workspace:*", diff --git a/e2e/journey-app/tsconfig.app.json b/e2e/journey-app/tsconfig.app.json index 417f5a64c2..8bf226a28a 100644 --- a/e2e/journey-app/tsconfig.app.json +++ b/e2e/journey-app/tsconfig.app.json @@ -15,14 +15,17 @@ ], "references": [ { - "path": "../../packages/sdk-effects/logger/tsconfig.lib.json" + "path": "../../packages/device-client/tsconfig.lib.json" }, { - "path": "../../packages/device-client/tsconfig.lib.json" + "path": "../../packages/sdk-effects/logger/tsconfig.lib.json" }, { "path": "../../packages/oidc-client/tsconfig.lib.json" }, + { + "path": "../../packages/devtools-bridge/tsconfig.lib.json" + }, { "path": "../../packages/protect/tsconfig.lib.json" }, diff --git a/e2e/oidc-app/package.json b/e2e/oidc-app/package.json index 5845594e28..8b388fa100 100644 --- a/e2e/oidc-app/package.json +++ b/e2e/oidc-app/package.json @@ -9,6 +9,7 @@ "serve": "pnpm nx nxServe" }, "dependencies": { + "@forgerock/devtools-bridge": "workspace:*", "@forgerock/oidc-client": "workspace:*" }, "nx": { diff --git a/e2e/oidc-app/src/utils/oidc-app.ts b/e2e/oidc-app/src/utils/oidc-app.ts index 69289580a0..70e5902980 100644 --- a/e2e/oidc-app/src/utils/oidc-app.ts +++ b/e2e/oidc-app/src/utils/oidc-app.ts @@ -6,6 +6,7 @@ * of the MIT license. See the LICENSE file for details. * */ +import { attachOidcBridge } from '@forgerock/devtools-bridge'; import { oidc } from '@forgerock/oidc-client'; import type { AuthorizationError, @@ -54,6 +55,7 @@ export async function oidcApp({ config, urlParams }) { if ('error' in oidcClient) { displayError(oidcClient); } + attachOidcBridge(oidcClient, config); document.getElementById('login-background').addEventListener('click', async () => { const authorizeOptions: GetAuthorizationUrlOptions = diff --git a/e2e/oidc-app/tsconfig.app.json b/e2e/oidc-app/tsconfig.app.json index d15af865e3..56e92d4814 100644 --- a/e2e/oidc-app/tsconfig.app.json +++ b/e2e/oidc-app/tsconfig.app.json @@ -21,6 +21,9 @@ "references": [ { "path": "../../packages/oidc-client/tsconfig.lib.json" + }, + { + "path": "../../packages/devtools-bridge/tsconfig.lib.json" } ] } diff --git a/eslint.config.mjs b/eslint.config.mjs index b38bd77ab9..89caa94813 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -109,6 +109,14 @@ export default [ sourceTag: 'scope:sdk-types', onlyDependOnLibsWithTags: [], }, + { + sourceTag: 'scope:devtools-types', + onlyDependOnLibsWithTags: [], + }, + { + sourceTag: 'scope:devtools-bridge', + onlyDependOnLibsWithTags: ['scope:devtools-types', 'scope:package'], + }, ], }, ], diff --git a/packages/davinci-client/api-report/davinci-client.api.md b/packages/davinci-client/api-report/davinci-client.api.md index b2528bf664..e73693581b 100644 --- a/packages/davinci-client/api-report/davinci-client.api.md +++ b/packages/davinci-client/api-report/davinci-client.api.md @@ -178,7 +178,7 @@ export interface CollectorErrors { } // @public (undocumented) -export type Collectors = FlowCollector | PasswordCollector | TextCollector | SingleSelectCollector | IdpCollector | SubmitCollector | ActionCollector<'ActionCollector'> | SingleValueCollector<'SingleValueCollector'> | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | ReadOnlyCollector | ValidatedTextCollector | ProtectCollector | PollingCollector | FidoRegistrationCollector | FidoAuthenticationCollector | QrCodeCollector | AgreementCollector | UnknownCollector; +export type Collectors = FlowCollector | PasswordCollector | TextCollector | SingleSelectCollector | IdpCollector | SubmitCollector | ActionCollector<'ActionCollector'> | SingleValueCollector<'SingleValueCollector'> | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | PhoneNumberExtensionCollector | ReadOnlyCollector | ValidatedTextCollector | ProtectCollector | PollingCollector | FidoRegistrationCollector | FidoAuthenticationCollector | QrCodeCollector | AgreementCollector | UnknownCollector; // @public export type CollectorValueType = T extends { @@ -212,7 +212,7 @@ export type CollectorValueType = T extends { } ? string[] : string | string[] | PhoneNumberInputValue | FidoRegistrationInputValue | FidoAuthenticationInputValue; // @public (undocumented) -export type ComplexValueFields = DeviceAuthenticationField | DeviceRegistrationField | PhoneNumberField | FidoRegistrationField | FidoAuthenticationField | PollingField; +export type ComplexValueFields = DeviceAuthenticationField | DeviceRegistrationField | PhoneNumberField | PhoneNumberExtensionField | FidoRegistrationField | FidoAuthenticationField | PollingField; // @public (undocumented) export interface ContinueNode { @@ -267,13 +267,11 @@ export function davinci(input: { resume: (input: { continueToken: string; }) => Promise; - start: (options?: StartOptions | undefined) => Promise; + start: (options?: StartOptions | undefined) => Promise; update: (collector: T) => Updater; validate: (collector: SingleValueCollectors | ObjectValueCollectors | MultiValueCollectors | AutoCollectors) => Validator; - poll: (collector: PollingCollector) => Poller; + pollStatus: (collector: PollingCollector) => Poller; getClient: () => { - status: "start"; - } | { action: string; collectors: Collectors[]; description?: string; @@ -287,6 +285,8 @@ export function davinci(input: { status: "error"; } | { status: "failure"; + } | { + status: "start"; } | { authorization?: { code?: string; @@ -297,7 +297,7 @@ export function davinci(input: { getCollectors: () => Collectors[]; getError: () => DaVinciError | null; getErrorCollectors: () => CollectorErrors[]; - getNode: () => ContinueNode | StartNode | ErrorNode | FailureNode | SuccessNode; + getNode: () => ContinueNode | ErrorNode | FailureNode | StartNode | SuccessNode; getServer: () => { _links?: Links; id?: string; @@ -306,8 +306,6 @@ export function davinci(input: { href?: string; eventName?: string; status: "continue"; - } | { - status: "start"; } | { _links?: Links; eventName?: string; @@ -323,6 +321,8 @@ export function davinci(input: { interactionId?: string; interactionToken?: string; status: "failure"; + } | { + status: "start"; } | { _links?: Links; eventName?: string; @@ -524,6 +524,7 @@ export function davinci(input: { type: string; }; }; + getCache: (requestId: string) => unknown; }; }>; @@ -1035,7 +1036,7 @@ export type InferNoValueCollectorType = T exten export type InferSingleValueCollectorType = T extends 'TextCollector' ? TextCollector : T extends 'SingleSelectCollector' ? SingleSelectCollector : T extends 'ValidatedTextCollector' ? ValidatedTextCollector : T extends 'PasswordCollector' ? PasswordCollector : SingleValueCollectorWithValue<'SingleValueCollector'> | SingleValueCollectorNoValue<'SingleValueCollector'>; // @public (undocumented) -export type InferValueObjectCollectorType = T extends 'DeviceAuthenticationCollector' ? DeviceAuthenticationCollector : T extends 'DeviceRegistrationCollector' ? DeviceRegistrationCollector : T extends 'PhoneNumberCollector' ? PhoneNumberCollector : ObjectOptionsCollectorWithObjectValue<'ObjectValueCollector'> | ObjectOptionsCollectorWithStringValue<'ObjectValueCollector'>; +export type InferValueObjectCollectorType = T extends 'DeviceAuthenticationCollector' ? DeviceAuthenticationCollector : T extends 'DeviceRegistrationCollector' ? DeviceRegistrationCollector : T extends 'PhoneNumberCollector' ? PhoneNumberCollector : T extends 'PhoneNumberExtensionCollector' ? PhoneNumberExtensionCollector : ObjectOptionsCollectorWithObjectValue<'ObjectValueCollector'> | ObjectOptionsCollectorWithStringValue<'ObjectValueCollector'>; // @public (undocumented) export type InitFlow = () => Promise; @@ -1170,8 +1171,8 @@ value: Record; }, string>; // @public -export const nodeCollectorReducer: Reducer<(TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollectorBase | AgreementCollector | ReadOnlyCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[]> & { - getInitialState: () => (TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollectorBase | AgreementCollector | ReadOnlyCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[]; +export const nodeCollectorReducer: Reducer<(TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | MultiSelectCollector | PhoneNumberExtensionCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollectorBase | AgreementCollector | ReadOnlyCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[]> & { + getInitialState: () => (TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | MultiSelectCollector | PhoneNumberExtensionCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollectorBase | AgreementCollector | ReadOnlyCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[]; }; // @public (undocumented) @@ -1283,10 +1284,10 @@ export type ObjectValueAutoCollectorTypes = 'ObjectValueAutoCollector' | 'FidoRe export type ObjectValueCollector = ObjectOptionsCollectorWithObjectValue | ObjectOptionsCollectorWithStringValue | ObjectValueCollectorWithObjectValue; // @public (undocumented) -export type ObjectValueCollectors = DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | ObjectOptionsCollectorWithObjectValue<'ObjectSelectCollector'> | ObjectOptionsCollectorWithStringValue<'ObjectSelectCollector'>; +export type ObjectValueCollectors = DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | PhoneNumberExtensionCollector | ObjectOptionsCollectorWithObjectValue<'ObjectSelectCollector'> | ObjectOptionsCollectorWithStringValue<'ObjectSelectCollector'>; // @public -export type ObjectValueCollectorTypes = 'DeviceAuthenticationCollector' | 'DeviceRegistrationCollector' | 'PhoneNumberCollector' | 'ObjectOptionsCollector' | 'ObjectValueCollector' | 'ObjectSelectCollector'; +export type ObjectValueCollectorTypes = 'DeviceAuthenticationCollector' | 'DeviceRegistrationCollector' | 'PhoneNumberCollector' | 'PhoneNumberExtensionCollector' | 'ObjectOptionsCollector' | 'ObjectValueCollector' | 'ObjectSelectCollector'; // @public (undocumented) export interface ObjectValueCollectorWithObjectValue, OV = Record> { @@ -1328,13 +1329,68 @@ export type PasswordCollector = SingleValueCollectorNoValue<'PasswordCollector'> // @public (undocumented) export type PhoneNumberCollector = ObjectValueCollectorWithObjectValue<'PhoneNumberCollector', PhoneNumberInputValue, PhoneNumberOutputValue>; +// @public (undocumented) +export interface PhoneNumberExtensionCollector { + // (undocumented) + category: 'ObjectValueCollector'; + // (undocumented) + error: string | null; + // (undocumented) + id: string; + // (undocumented) + input: { + key: string; + value: PhoneNumberExtensionInputValue; + type: string; + validation: (ValidationRequired | ValidationPhoneNumber)[] | null; + }; + // (undocumented) + name: string; + // (undocumented) + output: { + key: string; + label: string; + type: string; + extensionLabel: string; + value: PhoneNumberExtensionOutputValue; + }; + // (undocumented) + type: 'PhoneNumberExtensionCollector'; +} + +// @public (undocumented) +export type PhoneNumberExtensionField = PhoneNumberField & { + showExtension: boolean; + extensionLabel: string; +}; + +// @public (undocumented) +export interface PhoneNumberExtensionInputValue { + // (undocumented) + countryCode: string; + // (undocumented) + extension: string; + // (undocumented) + phoneNumber: string; +} + +// @public (undocumented) +export interface PhoneNumberExtensionOutputValue { + // (undocumented) + countryCode?: string; + // (undocumented) + extension?: string; + // (undocumented) + phoneNumber?: string; +} + // @public (undocumented) export type PhoneNumberField = { type: 'PHONE_NUMBER'; key: string; label: string; - defaultCountryCode: string | null; required: boolean; + defaultCountryCode: string | null; validatePhoneNumber: boolean; }; @@ -1724,7 +1780,7 @@ export type UnknownField = Record; // @public (undocumented) export const updateCollectorValues: ActionCreatorWithPayload< { id: string; -value: string | string[] | PhoneNumberInputValue | FidoRegistrationInputValue | FidoAuthenticationInputValue; +value: string | string[] | PhoneNumberInputValue | PhoneNumberExtensionInputValue | FidoRegistrationInputValue | FidoAuthenticationInputValue; index?: number; }, string>; diff --git a/packages/davinci-client/api-report/davinci-client.types.api.md b/packages/davinci-client/api-report/davinci-client.types.api.md index 2321431a0a..07fb72dd81 100644 --- a/packages/davinci-client/api-report/davinci-client.types.api.md +++ b/packages/davinci-client/api-report/davinci-client.types.api.md @@ -178,7 +178,7 @@ export interface CollectorErrors { } // @public (undocumented) -export type Collectors = FlowCollector | PasswordCollector | TextCollector | SingleSelectCollector | IdpCollector | SubmitCollector | ActionCollector<'ActionCollector'> | SingleValueCollector<'SingleValueCollector'> | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | ReadOnlyCollector | ValidatedTextCollector | ProtectCollector | PollingCollector | FidoRegistrationCollector | FidoAuthenticationCollector | QrCodeCollector | AgreementCollector | UnknownCollector; +export type Collectors = FlowCollector | PasswordCollector | TextCollector | SingleSelectCollector | IdpCollector | SubmitCollector | ActionCollector<'ActionCollector'> | SingleValueCollector<'SingleValueCollector'> | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | PhoneNumberExtensionCollector | ReadOnlyCollector | ValidatedTextCollector | ProtectCollector | PollingCollector | FidoRegistrationCollector | FidoAuthenticationCollector | QrCodeCollector | AgreementCollector | UnknownCollector; // @public export type CollectorValueType = T extends { @@ -212,7 +212,7 @@ export type CollectorValueType = T extends { } ? string[] : string | string[] | PhoneNumberInputValue | FidoRegistrationInputValue | FidoAuthenticationInputValue; // @public (undocumented) -export type ComplexValueFields = DeviceAuthenticationField | DeviceRegistrationField | PhoneNumberField | FidoRegistrationField | FidoAuthenticationField | PollingField; +export type ComplexValueFields = DeviceAuthenticationField | DeviceRegistrationField | PhoneNumberField | PhoneNumberExtensionField | FidoRegistrationField | FidoAuthenticationField | PollingField; // @public (undocumented) export interface ContinueNode { @@ -267,13 +267,11 @@ export function davinci(input: { resume: (input: { continueToken: string; }) => Promise; - start: (options?: StartOptions | undefined) => Promise; + start: (options?: StartOptions | undefined) => Promise; update: (collector: T) => Updater; validate: (collector: SingleValueCollectors | ObjectValueCollectors | MultiValueCollectors | AutoCollectors) => Validator; - poll: (collector: PollingCollector) => Poller; + pollStatus: (collector: PollingCollector) => Poller; getClient: () => { - status: "start"; - } | { action: string; collectors: Collectors[]; description?: string; @@ -287,6 +285,8 @@ export function davinci(input: { status: "error"; } | { status: "failure"; + } | { + status: "start"; } | { authorization?: { code?: string; @@ -297,7 +297,7 @@ export function davinci(input: { getCollectors: () => Collectors[]; getError: () => DaVinciError | null; getErrorCollectors: () => CollectorErrors[]; - getNode: () => ContinueNode | StartNode | ErrorNode | FailureNode | SuccessNode; + getNode: () => ContinueNode | ErrorNode | FailureNode | StartNode | SuccessNode; getServer: () => { _links?: Links; id?: string; @@ -306,8 +306,6 @@ export function davinci(input: { href?: string; eventName?: string; status: "continue"; - } | { - status: "start"; } | { _links?: Links; eventName?: string; @@ -323,6 +321,8 @@ export function davinci(input: { interactionId?: string; interactionToken?: string; status: "failure"; + } | { + status: "start"; } | { _links?: Links; eventName?: string; @@ -524,6 +524,7 @@ export function davinci(input: { type: string; }; }; + getCache: (requestId: string) => unknown; }; }>; @@ -1032,7 +1033,7 @@ export type InferNoValueCollectorType = T exten export type InferSingleValueCollectorType = T extends 'TextCollector' ? TextCollector : T extends 'SingleSelectCollector' ? SingleSelectCollector : T extends 'ValidatedTextCollector' ? ValidatedTextCollector : T extends 'PasswordCollector' ? PasswordCollector : SingleValueCollectorWithValue<'SingleValueCollector'> | SingleValueCollectorNoValue<'SingleValueCollector'>; // @public (undocumented) -export type InferValueObjectCollectorType = T extends 'DeviceAuthenticationCollector' ? DeviceAuthenticationCollector : T extends 'DeviceRegistrationCollector' ? DeviceRegistrationCollector : T extends 'PhoneNumberCollector' ? PhoneNumberCollector : ObjectOptionsCollectorWithObjectValue<'ObjectValueCollector'> | ObjectOptionsCollectorWithStringValue<'ObjectValueCollector'>; +export type InferValueObjectCollectorType = T extends 'DeviceAuthenticationCollector' ? DeviceAuthenticationCollector : T extends 'DeviceRegistrationCollector' ? DeviceRegistrationCollector : T extends 'PhoneNumberCollector' ? PhoneNumberCollector : T extends 'PhoneNumberExtensionCollector' ? PhoneNumberExtensionCollector : ObjectOptionsCollectorWithObjectValue<'ObjectValueCollector'> | ObjectOptionsCollectorWithStringValue<'ObjectValueCollector'>; // @public (undocumented) export type InitFlow = () => Promise; @@ -1167,8 +1168,8 @@ value: Record; }, string>; // @public -export const nodeCollectorReducer: Reducer<(TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollectorBase | AgreementCollector | ReadOnlyCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[]> & { - getInitialState: () => (TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollectorBase | AgreementCollector | ReadOnlyCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[]; +export const nodeCollectorReducer: Reducer<(TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | MultiSelectCollector | PhoneNumberExtensionCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollectorBase | AgreementCollector | ReadOnlyCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[]> & { + getInitialState: () => (TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | MultiSelectCollector | PhoneNumberExtensionCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollectorBase | AgreementCollector | ReadOnlyCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[]; }; // @public (undocumented) @@ -1280,10 +1281,10 @@ export type ObjectValueAutoCollectorTypes = 'ObjectValueAutoCollector' | 'FidoRe export type ObjectValueCollector = ObjectOptionsCollectorWithObjectValue | ObjectOptionsCollectorWithStringValue | ObjectValueCollectorWithObjectValue; // @public (undocumented) -export type ObjectValueCollectors = DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | ObjectOptionsCollectorWithObjectValue<'ObjectSelectCollector'> | ObjectOptionsCollectorWithStringValue<'ObjectSelectCollector'>; +export type ObjectValueCollectors = DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | PhoneNumberExtensionCollector | ObjectOptionsCollectorWithObjectValue<'ObjectSelectCollector'> | ObjectOptionsCollectorWithStringValue<'ObjectSelectCollector'>; // @public -export type ObjectValueCollectorTypes = 'DeviceAuthenticationCollector' | 'DeviceRegistrationCollector' | 'PhoneNumberCollector' | 'ObjectOptionsCollector' | 'ObjectValueCollector' | 'ObjectSelectCollector'; +export type ObjectValueCollectorTypes = 'DeviceAuthenticationCollector' | 'DeviceRegistrationCollector' | 'PhoneNumberCollector' | 'PhoneNumberExtensionCollector' | 'ObjectOptionsCollector' | 'ObjectValueCollector' | 'ObjectSelectCollector'; // @public (undocumented) export interface ObjectValueCollectorWithObjectValue, OV = Record> { @@ -1325,13 +1326,68 @@ export type PasswordCollector = SingleValueCollectorNoValue<'PasswordCollector'> // @public (undocumented) export type PhoneNumberCollector = ObjectValueCollectorWithObjectValue<'PhoneNumberCollector', PhoneNumberInputValue, PhoneNumberOutputValue>; +// @public (undocumented) +export interface PhoneNumberExtensionCollector { + // (undocumented) + category: 'ObjectValueCollector'; + // (undocumented) + error: string | null; + // (undocumented) + id: string; + // (undocumented) + input: { + key: string; + value: PhoneNumberExtensionInputValue; + type: string; + validation: (ValidationRequired | ValidationPhoneNumber)[] | null; + }; + // (undocumented) + name: string; + // (undocumented) + output: { + key: string; + label: string; + type: string; + extensionLabel: string; + value: PhoneNumberExtensionOutputValue; + }; + // (undocumented) + type: 'PhoneNumberExtensionCollector'; +} + +// @public (undocumented) +export type PhoneNumberExtensionField = PhoneNumberField & { + showExtension: boolean; + extensionLabel: string; +}; + +// @public (undocumented) +export interface PhoneNumberExtensionInputValue { + // (undocumented) + countryCode: string; + // (undocumented) + extension: string; + // (undocumented) + phoneNumber: string; +} + +// @public (undocumented) +export interface PhoneNumberExtensionOutputValue { + // (undocumented) + countryCode?: string; + // (undocumented) + extension?: string; + // (undocumented) + phoneNumber?: string; +} + // @public (undocumented) export type PhoneNumberField = { type: 'PHONE_NUMBER'; key: string; label: string; - defaultCountryCode: string | null; required: boolean; + defaultCountryCode: string | null; validatePhoneNumber: boolean; }; @@ -1721,7 +1777,7 @@ export type UnknownField = Record; // @public (undocumented) export const updateCollectorValues: ActionCreatorWithPayload< { id: string; -value: string | string[] | PhoneNumberInputValue | FidoRegistrationInputValue | FidoAuthenticationInputValue; +value: string | string[] | PhoneNumberInputValue | PhoneNumberExtensionInputValue | FidoRegistrationInputValue | FidoAuthenticationInputValue; index?: number; }, string>; diff --git a/packages/davinci-client/src/lib/client.store.ts b/packages/davinci-client/src/lib/client.store.ts index e99dc64018..d4281c6176 100644 --- a/packages/davinci-client/src/lib/client.store.ts +++ b/packages/davinci-client/src/lib/client.store.ts @@ -535,6 +535,21 @@ export async function davinci({ return flowItem || nextItem || startItem; }, + /** + * Returns the raw cached response data for a given requestId (cache key). + * Checks all three endpoints (flow, next, start) and returns the first with data. + */ + getCache: (requestId: string): unknown => { + if (!requestId) return undefined; + const state = store.getState(); + const flow = davinciApi.endpoints.flow.select(requestId)(state); + if (flow?.data !== undefined) return flow.data; + const next = davinciApi.endpoints.next.select(requestId)(state); + if (next?.data !== undefined) return next.data; + const start = davinciApi.endpoints.start.select(requestId)(state); + if (start?.data !== undefined) return start.data; + return undefined; + }, }, }; } diff --git a/packages/devtools-bridge/README.md b/packages/devtools-bridge/README.md new file mode 100644 index 0000000000..fa7920e516 --- /dev/null +++ b/packages/devtools-bridge/README.md @@ -0,0 +1,198 @@ +# @forgerock/devtools-bridge + +Opt-in SDK adapter that connects your Ping Identity / ForgeRock application to the [Ping DevTools extension](../devtools-extension). Add it to your app in one line — it is a no-op when the extension is not installed, so it is safe to ship in production builds. + +## Contents + +- [Installation](#installation) +- [Bridges](#bridges) + - [DaVinci — `attachDevToolsBridge`](#davinci--attachdevtoolsbridge) + - [AM Journey — `attachJourneyBridge`](#am-journey--attachjourneybridge) + - [OIDC / OAuth — `attachOidcBridge`](#oidc--oauth--attachoidcbridge) +- [Low-level API](#low-level-api) +- [How it works](#how-it-works) +- [Safety](#safety) + +--- + +## Installation + +```bash +pnpm add @forgerock/devtools-bridge +``` + +`effect` is a peer dependency. `@forgerock/davinci-client` is an optional peer dependency required only if you use `attachDevToolsBridge`. + +--- + +## Bridges + +### DaVinci — `attachDevToolsBridge` + +Subscribes to a DaVinci client store and emits `sdk:node-change` on every node status transition, plus `session:cookie` / `session:storage` diffs after each transition. + +```ts +import { davinci } from '@forgerock/davinci-client'; +import { attachDevToolsBridge } from '@forgerock/devtools-bridge'; + +const client = await davinci({ config }); + +// Pass config as the second argument — emitted once as sdk:config on the first transition +const bridge = attachDevToolsBridge(client, config); + +// Unsubscribe when the component unmounts +bridge.detach(); +``` + +**What it captures per node transition:** + +| Field | Source | +| ---------------- | --------------------------------------------- | +| `nodeStatus` | DaVinci node `.status` | +| `previousStatus` | Previous status (tracked locally) | +| `interactionId` | `server.interactionId` | +| `nodeName` | `client.name` | +| `collectors` | `client.collectors` (full objects) | +| `error` | `error.code / message / type` | +| `session` | `server.session` (DaVinci session token) | +| `responseBody` | Full DaVinci server response (from RTK cache) | + +The bridge only emits when `nodeStatus` actually changes, so rapid store updates that don't advance the node do not generate noise. + +--- + +### AM Journey — `attachJourneyBridge` + +Subscribes to a Journey RTK store and emits `sdk:journey-step` for each mutation that settles (`fulfilled` or `rejected`). Each event carries the full AM step response including all callbacks with their `input`/`output` arrays. + +```ts +import { journey } from '@forgerock/journey-client'; // your RTK-based journey client +import { attachJourneyBridge } from '@forgerock/devtools-bridge'; + +const client = await journey({ config }); + +attachJourneyBridge(client, config); +``` + +**`JourneySubscribable` interface** — any object with this shape works: + +```ts +interface JourneySubscribable { + subscribe: (listener: () => void) => () => void; + getState: () => unknown; // must expose { journeyReducer: { mutations: Record } } +} +``` + +**Emitted events by step type:** + +| `stepType` | When | Notable fields | +| -------------- | --------------------------------- | ------------------------------------------ | +| `Step` | AM returns `authId` | `callbacks`, `authId`, `stage`, `header` | +| `LoginSuccess` | AM returns `tokenId` | `tokenId`, `successUrl` | +| `LoginFailure` | AM returns an error / RTK rejects | `errorCode`, `errorMessage`, `errorReason` | + +--- + +### OIDC / OAuth — `attachOidcBridge` + +Subscribes to an OIDC client RTK store and emits `sdk:oidc-state` for each settled mutation. Maps RTK endpoint names to human-readable phases. + +```ts +import { oidcClient } from '@forgerock/oidc-client'; // your RTK-based OIDC client +import { attachOidcBridge } from '@forgerock/devtools-bridge'; + +const client = oidcClient({ config }); + +attachOidcBridge(client, config); +``` + +**`OidcSubscribable` interface:** + +```ts +interface OidcSubscribable { + subscribe: (listener: () => void) => () => void; + getState: () => unknown; // must expose { oidc: { mutations: Record } } +} +``` + +**Endpoint → phase mapping:** + +| RTK endpoint name | Emitted phase | +| ----------------- | ------------- | +| `authorizeFetch` | `authorize` | +| `authorizeIframe` | `authorize` | +| `exchange` | `exchange` | +| `revoke` | `revoke` | +| `userInfo` | `userinfo` | +| `endSession` | `logout` | + +Pass `config.clientId` to surface it in the extension's node detail card: + +```ts +attachOidcBridge(client, { clientId: 'my-spa-client', ...rest }); +``` + +--- + +## Low-level API + +If you need to emit events from outside a supported client, use the primitives directly. + +```ts +import { emitAuthEvent, emitConfigEvent, DEVTOOLS_EVENT_NAME } from '@forgerock/devtools-bridge'; + +emitAuthEvent({ + id: crypto.randomUUID(), + timestamp: performance.now(), + type: 'sdk:node-change', + source: 'sdk', + flowId: null, + causedBy: null, + data: { _tag: 'sdk', nodeStatus: 'next' }, + flags: { isCors: false, isError: false, isAuthRelated: true }, +}); + +emitConfigEvent({ clientId: 'my-app', environment: 'dev' }); +``` + +Both functions dispatch a `CustomEvent` named `DEVTOOLS_EVENT_NAME` (`'pingDevtools'`) on `window`. The content script picks this up and forwards it to the extension service worker. + +--- + +## How it works + +``` +Your app + ├── attachDevToolsBridge(davinciClient) ─┐ + ├── attachJourneyBridge(journeyClient) ─┤─ emitAuthEvent() + └── attachOidcBridge(oidcClient) ─┘ + │ + │ window.dispatchEvent(new CustomEvent('pingDevtools', { detail: event })) + ▼ + content-script.js + │ + │ chrome.runtime.sendMessage({ type: 'SDK_EVENT', payload: event }) + ▼ + service-worker.ts ──(validates via AuthEventSchema)──▶ EventStore + │ + │ chrome.runtime.sendMessage({ type: 'EVENTS_UPDATED' }) + ▼ + panel (Elm) ── Timeline view + Flow view +``` + +Each bridge function: + +1. Subscribes to the client store +2. Validates the current state with an Effect Schema decoder (returns `Option.none` on mismatch — never throws) +3. Deduplicates by tracking already-emitted request IDs in a `Set` +4. Trims that `Set` to only IDs still present in the store, bounding memory use +5. Dispatches the event only when `window.__PING_DEVTOOLS_EXTENSION__` is present + +--- + +## Safety + +- **No-op without the extension** — all bridges check for `window.__PING_DEVTOOLS_EXTENSION__` before dispatching. If the marker is absent, nothing is emitted. +- **No-op in SSR / Node** — all bridges return `{ detach: () => undefined }` immediately when `typeof window === 'undefined'`. +- **Tree-shakeable** — `sideEffects: false` in `package.json`; unused bridges are eliminated by your bundler. +- **No sensitive data leakage** — the bridge never reads passwords or form values; it only observes the client's Redux/RTK state. diff --git a/packages/devtools-bridge/eslint.config.mjs b/packages/devtools-bridge/eslint.config.mjs new file mode 100644 index 0000000000..cec2c4bf81 --- /dev/null +++ b/packages/devtools-bridge/eslint.config.mjs @@ -0,0 +1,3 @@ +import baseConfig from '../../eslint.config.mjs'; + +export default [{ ignores: ['**/dist'] }, ...baseConfig, { files: ['**/*.ts'], rules: {} }]; diff --git a/packages/devtools-bridge/package.json b/packages/devtools-bridge/package.json new file mode 100644 index 0000000000..a9a4e0cca5 --- /dev/null +++ b/packages/devtools-bridge/package.json @@ -0,0 +1,48 @@ +{ + "name": "@forgerock/devtools-bridge", + "version": "2.0.0", + "private": true, + "description": "Opt-in Ping SDK adapter that emits AuthEvents to the DevTools extension", + "license": "MIT", + "author": "ForgeRock", + "repository": { + "type": "git", + "url": "git+https://github.com/ForgeRock/ping-javascript-sdk.git", + "directory": "packages/devtools-bridge" + }, + "sideEffects": false, + "type": "module", + "exports": { + ".": { + "types": "./dist/src/index.d.ts", + "import": "./dist/src/index.js", + "default": "./dist/src/index.js" + }, + "./package.json": "./package.json" + }, + "main": "./dist/src/index.js", + "module": "./dist/src/index.js", + "types": "./dist/src/index.d.ts", + "files": ["dist"], + "scripts": { + "build": "pnpm nx nxBuild", + "lint": "pnpm nx nxLint", + "test": "pnpm nx nxTest" + }, + "dependencies": { + "@forgerock/devtools-types": "workspace:*", + "effect": "catalog:effect" + }, + "devDependencies": { + "@forgerock/davinci-client": "workspace:*" + }, + "peerDependencies": { + "@forgerock/davinci-client": "workspace:*" + }, + "peerDependenciesMeta": { + "@forgerock/davinci-client": { "optional": true } + }, + "nx": { + "tags": ["scope:devtools-bridge"] + } +} diff --git a/packages/devtools-bridge/src/index.ts b/packages/devtools-bridge/src/index.ts new file mode 100644 index 0000000000..f63fb5f5de --- /dev/null +++ b/packages/devtools-bridge/src/index.ts @@ -0,0 +1,13 @@ +export { attachDevToolsBridge } from './lib/bridge.js'; +export type { BridgeHandle } from './lib/bridge.js'; +export { attachJourneyBridge } from './lib/journey-bridge.js'; +export type { JourneyBridgeHandle } from './lib/journey-bridge.js'; +export { attachOidcBridge } from './lib/oidc-bridge.js'; +export type { OidcBridgeHandle } from './lib/oidc-bridge.js'; +export { + DEVTOOLS_EVENT_NAME, + emitAuthEvent, + emitConfigEvent, + configureDevtools, +} from './lib/emit.js'; +export type { DevtoolsOptions } from './lib/emit.js'; diff --git a/packages/devtools-bridge/src/lib/bridge.test.ts b/packages/devtools-bridge/src/lib/bridge.test.ts new file mode 100644 index 0000000000..01f18a4bf0 --- /dev/null +++ b/packages/devtools-bridge/src/lib/bridge.test.ts @@ -0,0 +1,426 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { attachDevToolsBridge, nodeToSdkData } from './bridge.js'; +import { DEVTOOLS_EVENT_NAME } from './emit.js'; +import type { AuthEvent } from '@forgerock/devtools-types'; + +// --------------------------------------------------------------------------- +// nodeToSdkData unit tests (pure — no DOM, no events) +// --------------------------------------------------------------------------- + +describe('nodeToSdkData', () => { + it('maps a minimal node with only status', () => { + const result = nodeToSdkData({ status: 'start' }, undefined); + expect(result).toEqual({ + _tag: 'sdk', + nodeStatus: 'start', + previousStatus: undefined, + }); + }); + + it('uses "unknown" when status is absent', () => { + const result = nodeToSdkData({}, undefined); + expect(result.nodeStatus).toBe('unknown'); + }); + + it('carries previousStatus through', () => { + const result = nodeToSdkData({ status: 'continue' }, 'start'); + expect(result.previousStatus).toBe('start'); + }); + + it('maps nested server fields', () => { + const result = nodeToSdkData( + { + status: 'continue', + server: { + interactionId: 'iid-1', + interactionToken: 'tok-1', + id: 'node-id-1', + eventName: 'LoginNode', + session: 'sess-1', + }, + }, + undefined, + ); + expect(result.interactionId).toBe('iid-1'); + expect(result.interactionToken).toBe('tok-1'); + expect(result.nodeId).toBe('node-id-1'); + expect(result.eventName).toBe('LoginNode'); + expect(result.session).toBe('sess-1'); + }); + + it('maps nested client fields', () => { + const result = nodeToSdkData( + { + status: 'continue', + client: { + name: 'UsernameNode', + description: 'Enter username', + collectors: [{ type: 'TextCollector' }], + authorization: { code: 'auth-code', state: 'state-1' }, + }, + }, + undefined, + ); + expect(result.nodeName).toBe('UsernameNode'); + expect(result.nodeDescription).toBe('Enter username'); + expect(result.collectors).toEqual([{ type: 'TextCollector' }]); + expect(result.authorization).toEqual({ code: 'auth-code', state: 'state-1' }); + }); + + it('maps error and cache fields', () => { + const result = nodeToSdkData( + { + status: 'error', + httpStatus: 401, + error: { code: 'UNAUTHORIZED', message: 'Bad creds', type: 'auth' }, + cache: { key: 'req-key-1' }, + }, + 'continue', + ); + expect(result.httpStatus).toBe(401); + expect(result.error).toEqual({ code: 'UNAUTHORIZED', message: 'Bad creds', type: 'auth' }); + expect(result.requestId).toBe('req-key-1'); + }); + + it('ignores unrecognised fields — does not bleed unknown keys', () => { + const result = nodeToSdkData( + { status: 'start', someUnknownField: 'x', server: { unknownServerKey: 99 } } as never, + undefined, + ); + expect(result).not.toHaveProperty('someUnknownField'); + expect(result).not.toHaveProperty('unknownServerKey'); + }); + + it('coerces null error to undefined (success/continue nodes set error: null)', () => { + const result = nodeToSdkData({ status: 'continue', error: null } as never, undefined); + expect(result.error).toBeUndefined(); + }); + + it('coerces null cache to undefined requestId', () => { + const result = nodeToSdkData({ status: 'continue', cache: null } as never, undefined); + expect(result.requestId).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Mock client factory +// --------------------------------------------------------------------------- + +function makeClient(initialNode: Record) { + let listener: (() => void) | null = null; + let node = initialNode; + return { + subscribe: vi.fn((cb: () => void) => { + listener = cb; + return () => { + listener = null; + }; + }), + getNode: vi.fn(() => node), + /** Test helper: update internal node and fire the subscribed listener. */ + trigger: (newNode: Record) => { + node = newNode; + listener?.(); + }, + }; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function captureDevtoolsEvents(): { events: CustomEvent[]; stop: () => void } { + const events: CustomEvent[] = []; + const handler = (e: Event) => events.push(e as CustomEvent); + window.addEventListener(DEVTOOLS_EVENT_NAME, handler); + return { events, stop: () => window.removeEventListener(DEVTOOLS_EVENT_NAME, handler) }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('attachDevToolsBridge', () => { + beforeEach(() => { + // Simulate extension presence for all tests except the no-op test. + (window as unknown as Record)['__PING_DEVTOOLS_EXTENSION__'] = true; + }); + + afterEach(async () => { + delete (window as unknown as Record)['__PING_DEVTOOLS_EXTENSION__']; + // Flush any deferred setTimeout(0) callbacks so they don't bleed into later tests. + await new Promise((r) => setTimeout(r, 10)); + }); + + it('returns a BridgeHandle with a detach function', () => { + const client = makeClient({ status: 'start' }); + const handle = attachDevToolsBridge(client); + + expect(handle).toHaveProperty('detach'); + expect(typeof handle.detach).toBe('function'); + + handle.detach(); + }); + + it('emits sdk:node-change when node status transitions (start → continue)', () => { + const client = makeClient({ status: 'start' }); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachDevToolsBridge(client); + + // Trigger a status transition. + client.trigger({ status: 'continue' }); + + handle.detach(); + stop(); + + expect(events).toHaveLength(1); + expect(events[0].detail.type).toBe('sdk:node-change'); + expect(events[0].detail.data._tag).toBe('sdk'); + }); + + it('emits events when node has error: null and cache: null (real DaVinci ContinueNode shape)', () => { + // DaVinci reducers set error: null on ContinueNode and SuccessNode. + // Schema.optional(SdkErrorSchema) rejects null — this test guards that regression. + const continueNode = { + status: 'continue', + httpStatus: 200, + error: null, + cache: null, + server: { + interactionId: 'iid-abc', + interactionToken: 'tok-xyz', + id: 'node-1', + eventName: 'UsernameNode', + }, + client: { + name: 'Sign In', + description: 'Enter your username', + collectors: [{ type: 'TextCollector', id: 'username-0' }], + }, + }; + const client = makeClient({ status: 'start' }); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachDevToolsBridge(client); + client.trigger(continueNode); + + handle.detach(); + stop(); + + expect(events).toHaveLength(1); + expect(events[0].detail.type).toBe('sdk:node-change'); + const data = events[0].detail.data as { + _tag: string; + nodeStatus: string; + interactionId?: string; + nodeName?: string; + error?: unknown; + }; + expect(data._tag).toBe('sdk'); + expect(data.nodeStatus).toBe('continue'); + expect(data.interactionId).toBe('iid-abc'); + expect(data.nodeName).toBe('Sign In'); + expect(data.error).toBeUndefined(); + }); + + it('does NOT emit when status has not changed', () => { + const client = makeClient({ status: 'start' }); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachDevToolsBridge(client); + + // First trigger sets previousStatus = 'start'. + client.trigger({ status: 'start' }); + // Second trigger with the same status — should be suppressed. + client.trigger({ status: 'start' }); + + handle.detach(); + stop(); + + // The first trigger fires because previousStatus was undefined → 'start'. + // The second trigger must be suppressed because status did not change. + expect(events).toHaveLength(1); + expect(events[0].detail.type).toBe('sdk:node-change'); + expect(events[0].detail.data._tag).toBe('sdk'); + }); + + it('detach() unsubscribes the listener', () => { + const client = makeClient({ status: 'start' }); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachDevToolsBridge(client); + + // Verify subscribe was wired up. + expect(client.subscribe).toHaveBeenCalledTimes(1); + + handle.detach(); + + // Fire after detach — should produce no new events. + client.trigger({ status: 'continue' }); + + stop(); + + expect(events).toHaveLength(0); + }); + + it('emits sdk:config event on first transition when config is provided', () => { + const client = makeClient({ status: 'start' }); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachDevToolsBridge(client, { + clientId: 'my-app', + redirectUri: 'https://app.example.com/callback', + }); + + client.trigger({ status: 'continue' }); + + handle.detach(); + stop(); + + // First event should be sdk:config, second should be sdk:node-change + expect(events).toHaveLength(2); + expect(events[0].detail.type).toBe('sdk:config'); + expect(events[0].detail.data._tag).toBe('sdk-config'); + expect((events[0].detail.data as { _tag: string; config: unknown }).config).toEqual({ + clientId: 'my-app', + redirectUri: 'https://app.example.com/callback', + }); + expect(events[1].detail.type).toBe('sdk:node-change'); + }); + + it('emits sdk:config only once across multiple transitions', () => { + const client = makeClient({ status: 'start' }); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachDevToolsBridge(client, { clientId: 'my-app' }); + + client.trigger({ status: 'continue' }); + client.trigger({ status: 'success' }); + + handle.detach(); + stop(); + + const configEvents = events.filter((e) => e.detail.type === 'sdk:config'); + expect(configEvents).toHaveLength(1); + }); + + it('does not emit sdk:config when no config is provided', () => { + const client = makeClient({ status: 'start' }); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachDevToolsBridge(client); + + client.trigger({ status: 'continue' }); + + handle.detach(); + stop(); + + const configEvents = events.filter((e) => e.detail.type === 'sdk:config'); + expect(configEvents).toHaveLength(0); + }); + + it('is a no-op when __PING_DEVTOOLS_EXTENSION__ is absent', () => { + // Remove extension flag to exercise the guard branch. + delete (window as unknown as Record)['__PING_DEVTOOLS_EXTENSION__']; + + const client = makeClient({ status: 'start' }); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachDevToolsBridge(client); + + // subscribe is called (the bridge still subscribes), but no events should be dispatched. + expect(client.subscribe).toHaveBeenCalledTimes(1); + + client.trigger({ status: 'continue' }); + + handle.detach(); // should not throw + stop(); + + expect(events).toHaveLength(0); + }); + + it('does not emit sdk:config when extension is absent even if config is provided', () => { + delete (window as unknown as Record)['__PING_DEVTOOLS_EXTENSION__']; + + const client = makeClient({ status: 'start' }); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachDevToolsBridge(client, { clientId: 'my-app' }); + client.trigger({ status: 'continue' }); + + handle.detach(); + stop(); + + // Re-add for cleanup + (window as unknown as Record)['__PING_DEVTOOLS_EXTENSION__'] = true; + + const configEvents = events.filter((e) => e.detail.type === 'sdk:config'); + expect(configEvents).toHaveLength(0); + }); +}); + +describe('attachDevToolsBridge session tracking', () => { + beforeEach(() => { + (window as unknown as Record)['__PING_DEVTOOLS_EXTENSION__'] = true; + localStorage.clear(); + // Reset cookie (jsdom allows setting document.cookie) + Object.defineProperty(document, 'cookie', { + writable: true, + value: '', + }); + }); + + afterEach(() => { + delete (window as unknown as Record)['__PING_DEVTOOLS_EXTENSION__']; + localStorage.clear(); + }); + + it('emits session:storage event when localStorage changes after a node transition', async () => { + const client = makeClient({ status: 'start' }); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachDevToolsBridge(client); + + // Trigger a node transition, then mutate storage in the same tick + client.trigger({ status: 'continue' }); + localStorage.setItem('ping:session', 'abc123'); + + // Wait for setTimeout(0) deferred diff + await new Promise((r) => setTimeout(r, 10)); + + handle.detach(); + stop(); + + const sessionEvents = events.filter((e) => e.detail.type === 'session:storage'); + expect(sessionEvents).toHaveLength(1); + expect(sessionEvents[0].detail.data._tag).toBe('session'); + const data = sessionEvents[0].detail.data as { + _tag: string; + key: string; + before?: string; + after?: string; + }; + expect(data.key).toBe('ping:session'); + expect(data.before).toBeUndefined(); + expect(data.after).toBe('abc123'); + }); + + it('does not emit session events when nothing changes', async () => { + const client = makeClient({ status: 'start' }); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachDevToolsBridge(client); + client.trigger({ status: 'continue' }); + + await new Promise((r) => setTimeout(r, 10)); + + handle.detach(); + stop(); + + const sessionEvents = events.filter( + (e) => e.detail.type === 'session:storage' || e.detail.type === 'session:cookie', + ); + expect(sessionEvents).toHaveLength(0); + }); +}); diff --git a/packages/devtools-bridge/src/lib/bridge.ts b/packages/devtools-bridge/src/lib/bridge.ts new file mode 100644 index 0000000000..a52c9e6756 --- /dev/null +++ b/packages/devtools-bridge/src/lib/bridge.ts @@ -0,0 +1,230 @@ +import { Schema, Option, pipe } from 'effect'; +import type { SdkData } from '@forgerock/devtools-types'; +import { SdkErrorSchema, SdkAuthorizationSchema } from '@forgerock/devtools-types'; +import { emitAuthEvent, emitConfigEvent, configureDevtools } from './emit.js'; +import type { DevtoolsOptions } from './emit.js'; + +interface Subscribable { + subscribe: (listener: () => void) => () => void; + getNode: () => unknown; + cache?: { + getCache: (requestId: string) => unknown; + }; +} + +export interface BridgeHandle { + detach: () => void; +} + +export interface SdkConfig { + clientId?: string; + redirectUri?: string; + scope?: string; + serverConfig?: unknown; +} + +// --------------------------------------------------------------------------- +// DaVinci node schema — local structural contract, not a public type +// --------------------------------------------------------------------------- + +const DaVinciNodeSchema = Schema.Struct({ + status: Schema.optional(Schema.String), + httpStatus: Schema.optional(Schema.Number), + server: Schema.optional( + Schema.Struct({ + interactionId: Schema.optional(Schema.String), + interactionToken: Schema.optional(Schema.String), + id: Schema.optional(Schema.String), + eventName: Schema.optional(Schema.String), + session: Schema.optional(Schema.String), + }), + ), + client: Schema.optional( + Schema.Struct({ + name: Schema.optional(Schema.String), + description: Schema.optional(Schema.String), + collectors: Schema.optional(Schema.Array(Schema.Unknown)), + authorization: Schema.optional(SdkAuthorizationSchema), + }), + ), + error: Schema.optional(Schema.NullOr(SdkErrorSchema)), + cache: Schema.optional(Schema.NullOr(Schema.Struct({ key: Schema.optional(Schema.String) }))), +}); + +type DaVinciNode = Schema.Schema.Type; + +const decodeDaVinciNode = Schema.decodeUnknownOption(DaVinciNodeSchema); + +// --------------------------------------------------------------------------- +// Pure mapping — fully testable, no side effects +// --------------------------------------------------------------------------- + +export function nodeToSdkData( + node: DaVinciNode, + previousStatus: string | undefined, + responseBody?: unknown, +): SdkData { + return { + _tag: 'sdk', + nodeStatus: node.status ?? 'unknown', + previousStatus, + interactionId: node.server?.interactionId, + interactionToken: node.server?.interactionToken, + nodeId: node.server?.id, + requestId: node.cache?.key ?? undefined, + nodeName: node.client?.name, + nodeDescription: node.client?.description, + eventName: node.server?.eventName, + httpStatus: node.httpStatus, + collectors: node.client?.collectors as SdkData['collectors'], + error: node.error ?? undefined, + authorization: node.client?.authorization, + session: node.server?.session, + responseBody, + }; +} + +// --------------------------------------------------------------------------- +// Session snapshot helpers (imperative shell) +// --------------------------------------------------------------------------- + +interface SessionSnapshot { + cookie: string; + storage: Record; +} + +function snapshotSession(): SessionSnapshot { + const storage: Record = {}; + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + if (k) storage[k] = localStorage.getItem(k) ?? ''; + } + return { cookie: document.cookie, storage }; +} + +function emitSessionDiffs( + before: SessionSnapshot, + after: SessionSnapshot, + flowId: string | null, +): void { + if (before.cookie !== after.cookie) { + emitAuthEvent({ + id: crypto.randomUUID(), + timestamp: performance.now(), + type: 'session:cookie', + source: 'session', + flowId, + causedBy: null, + data: { + _tag: 'session', + key: 'document.cookie', + before: before.cookie || undefined, + after: after.cookie || undefined, + }, + flags: { isCors: false, isError: false, isAuthRelated: true }, + }); + } + + const allKeys = new Set([...Object.keys(before.storage), ...Object.keys(after.storage)]); + for (const key of allKeys) { + const beforeVal = before.storage[key]; + const afterVal = after.storage[key]; + if (beforeVal !== afterVal) { + emitAuthEvent({ + id: crypto.randomUUID(), + timestamp: performance.now(), + type: 'session:storage', + source: 'session', + flowId, + causedBy: null, + data: { _tag: 'session', key, before: beforeVal, after: afterVal }, + flags: { isCors: false, isError: false, isAuthRelated: true }, + }); + } + } +} + +// --------------------------------------------------------------------------- +// Event builders +// --------------------------------------------------------------------------- + +function emitNodeChange(data: SdkData): void { + emitAuthEvent({ + id: crypto.randomUUID(), + timestamp: performance.now(), + type: 'sdk:node-change', + source: 'sdk', + flowId: data.interactionId ?? null, + causedBy: null, + data, + flags: { + isCors: false, + isError: data.nodeStatus === 'error' || data.nodeStatus === 'failure', + isAuthRelated: true, + }, + }); +} + +// --------------------------------------------------------------------------- +// Bridge +// --------------------------------------------------------------------------- + +/** + * Attaches the Ping DevTools bridge to a subscribable client (e.g. DaVinci client). + * + * Pass the SDK's client config as the optional second argument — it is emitted once + * as an `sdk:config` event on the first node transition, letting the extension display + * app-level context alongside auth flow events. + * + * Returns a no-op handle when run outside a browser. Always call `detach()` on cleanup. + */ +export function attachDevToolsBridge( + client: Subscribable, + config?: object, + devtoolsOptions?: DevtoolsOptions, +): BridgeHandle { + if (typeof window === 'undefined') { + return { detach: () => undefined }; + } + + if (devtoolsOptions) { + configureDevtools(devtoolsOptions); + } + + let previousStatus: string | undefined; + let configEmitted = false; + let lastSnapshot: SessionSnapshot = snapshotSession(); + + const unsubscribe = client.subscribe(() => { + pipe( + client.getNode(), + decodeDaVinciNode, + // Advance previousStatus before the extension check so we always track + // transitions, even when the panel is closed. + Option.flatMap((node) => { + if (node.status === previousStatus) return Option.none(); + const priorStatus = previousStatus; + previousStatus = node.status; + const cachedResponse = node.cache?.key ? client.cache?.getCache(node.cache.key) : undefined; + return Option.some(nodeToSdkData(node, priorStatus, cachedResponse)); + }), + Option.filter(() => '__PING_DEVTOOLS_EXTENSION__' in window), + Option.map((data) => { + if (config && !configEmitted) { + configEmitted = true; + emitConfigEvent(config); + } + emitNodeChange(data); + // Snapshot before deferring so mutations in the same call stack are captured. + const snapshotBefore = lastSnapshot; + setTimeout(() => { + const snapshotAfter = snapshotSession(); + emitSessionDiffs(snapshotBefore, snapshotAfter, data.interactionId ?? null); + lastSnapshot = snapshotAfter; + }, 0); + }), + ); + }); + + return { detach: unsubscribe }; +} diff --git a/packages/devtools-bridge/src/lib/emit.test.ts b/packages/devtools-bridge/src/lib/emit.test.ts new file mode 100644 index 0000000000..fa6de9421c --- /dev/null +++ b/packages/devtools-bridge/src/lib/emit.test.ts @@ -0,0 +1,196 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { AuthEvent } from '@forgerock/devtools-types'; +import { DEVTOOLS_EVENT_NAME, emitAuthEvent, emitConfigEvent, configureDevtools } from './emit.js'; + +// Minimal valid AuthEvent fixture — _tag: 'sdk' satisfies the SdkDataSchema discriminant. +const makeEvent = (overrides: Partial = {}): AuthEvent => ({ + id: 'test-id-1', + timestamp: 0, + type: 'sdk:node-change', + source: 'sdk', + flowId: null, + causedBy: null, + data: { + _tag: 'sdk', + nodeStatus: 'continue', + }, + flags: { + isCors: false, + isError: false, + isAuthRelated: true, + }, + ...overrides, +}); + +describe('emitAuthEvent', () => { + beforeEach(() => { + // Reset options between tests by calling configureDevtools with defaults + configureDevtools({}); + delete window.__PING_DEVTOOLS_STATE__; + }); + + it('dispatches a CustomEvent with DEVTOOLS_EVENT_NAME and the event as detail', () => { + const captured: CustomEvent[] = []; + const handler = (e: Event) => { + captured.push(e as CustomEvent); + }; + + window.addEventListener(DEVTOOLS_EVENT_NAME, handler); + + const event = makeEvent(); + emitAuthEvent(event); + + window.removeEventListener(DEVTOOLS_EVENT_NAME, handler); + + expect(captured).toHaveLength(1); + expect(captured[0].type).toBe(DEVTOOLS_EVENT_NAME); + expect(captured[0].detail).toBe(event); + }); + + it('does not throw when window is undefined', () => { + // jsdom always defines window, so we temporarily remove it to exercise the guard branch. + const saved = globalThis.window; + // @ts-expect-error — intentionally deleting window to test the undefined guard + delete globalThis.window; + + expect(() => emitAuthEvent(makeEvent())).not.toThrow(); + + // Restore window so subsequent tests are unaffected. + globalThis.window = saved; + }); + + it('accumulates events in window.__PING_DEVTOOLS_STATE__', () => { + emitAuthEvent(makeEvent({ id: 'a' })); + emitAuthEvent(makeEvent({ id: 'b' })); + + expect(window.__PING_DEVTOOLS_STATE__).toHaveLength(2); + expect(window.__PING_DEVTOOLS_STATE__![0].id).toBe('a'); + expect(window.__PING_DEVTOOLS_STATE__![1].id).toBe('b'); + }); + + it('initialises __PING_DEVTOOLS_STATE__ array on first call', () => { + expect(window.__PING_DEVTOOLS_STATE__).toBeUndefined(); + emitAuthEvent(makeEvent()); + expect(Array.isArray(window.__PING_DEVTOOLS_STATE__)).toBe(true); + }); + + it('appends to existing __PING_DEVTOOLS_STATE__ array', () => { + window.__PING_DEVTOOLS_STATE__ = [makeEvent({ id: 'existing' })]; + emitAuthEvent(makeEvent({ id: 'new' })); + + expect(window.__PING_DEVTOOLS_STATE__).toHaveLength(2); + expect(window.__PING_DEVTOOLS_STATE__[1].id).toBe('new'); + }); +}); + +describe('configureDevtools', () => { + beforeEach(() => { + configureDevtools({}); + delete window.__PING_DEVTOOLS_STATE__; + }); + + it('enables console logging when consoleLog is true', () => { + const spy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + configureDevtools({ consoleLog: true }); + const event = makeEvent(); + emitAuthEvent(event); + + expect(spy).toHaveBeenCalledOnce(); + expect(spy).toHaveBeenCalledWith('[ping-devtools]', event.type, event); + + spy.mockRestore(); + }); + + it('does not console.log when consoleLog is false', () => { + const spy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + configureDevtools({ consoleLog: false }); + emitAuthEvent(makeEvent()); + + expect(spy).not.toHaveBeenCalled(); + + spy.mockRestore(); + }); + + it('does not console.log by default (no options)', () => { + const spy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + configureDevtools({}); + emitAuthEvent(makeEvent()); + + expect(spy).not.toHaveBeenCalled(); + + spy.mockRestore(); + }); +}); + +describe('emitConfigEvent', () => { + beforeEach(() => { + configureDevtools({}); + delete window.__PING_DEVTOOLS_STATE__; + }); + + it('emits an sdk:config event with the provided config object', () => { + const captured: CustomEvent[] = []; + const handler = (e: Event) => captured.push(e as CustomEvent); + window.addEventListener(DEVTOOLS_EVENT_NAME, handler); + + const config = { serverUrl: 'https://auth.example.com', clientId: 'my-app' }; + emitConfigEvent(config); + + window.removeEventListener(DEVTOOLS_EVENT_NAME, handler); + + expect(captured).toHaveLength(1); + const event = captured[0].detail; + expect(event.type).toBe('sdk:config'); + expect(event.source).toBe('sdk'); + expect(event.data._tag).toBe('sdk-config'); + if (event.data._tag === 'sdk-config') { + expect(event.data.config).toEqual(config); + } + }); + + it('generates a unique id and timestamp', () => { + const captured: CustomEvent[] = []; + const handler = (e: Event) => captured.push(e as CustomEvent); + window.addEventListener(DEVTOOLS_EVENT_NAME, handler); + + emitConfigEvent({}); + emitConfigEvent({}); + + window.removeEventListener(DEVTOOLS_EVENT_NAME, handler); + + expect(captured[0].detail.id).not.toBe(captured[1].detail.id); + expect(typeof captured[0].detail.timestamp).toBe('number'); + }); + + it('sets flowId and causedBy to null', () => { + const captured: CustomEvent[] = []; + const handler = (e: Event) => captured.push(e as CustomEvent); + window.addEventListener(DEVTOOLS_EVENT_NAME, handler); + + emitConfigEvent({ key: 'value' }); + + window.removeEventListener(DEVTOOLS_EVENT_NAME, handler); + + expect(captured[0].detail.flowId).toBeNull(); + expect(captured[0].detail.causedBy).toBeNull(); + }); + + it('sets flags to non-cors, non-error, auth-related', () => { + const captured: CustomEvent[] = []; + const handler = (e: Event) => captured.push(e as CustomEvent); + window.addEventListener(DEVTOOLS_EVENT_NAME, handler); + + emitConfigEvent({}); + + window.removeEventListener(DEVTOOLS_EVENT_NAME, handler); + + expect(captured[0].detail.flags).toEqual({ + isCors: false, + isError: false, + isAuthRelated: true, + }); + }); +}); diff --git a/packages/devtools-bridge/src/lib/emit.ts b/packages/devtools-bridge/src/lib/emit.ts new file mode 100644 index 0000000000..6ed02abaf8 --- /dev/null +++ b/packages/devtools-bridge/src/lib/emit.ts @@ -0,0 +1,47 @@ +import type { AuthEvent } from '@forgerock/devtools-types'; + +export const DEVTOOLS_EVENT_NAME = 'pingDevtools'; + +export interface DevtoolsOptions { + consoleLog?: boolean; +} + +declare global { + interface Window { + __PING_DEVTOOLS_STATE__?: AuthEvent[]; + } +} + +let options: DevtoolsOptions = {}; + +export function configureDevtools(opts: DevtoolsOptions): void { + options = opts; +} + +export function emitAuthEvent(event: AuthEvent): void { + if (typeof window === 'undefined') return; + + if (!window.__PING_DEVTOOLS_STATE__) { + window.__PING_DEVTOOLS_STATE__ = []; + } + window.__PING_DEVTOOLS_STATE__.push(event); + + if (options.consoleLog) { + console.log('[ping-devtools]', event.type, event); + } + + window.dispatchEvent(new CustomEvent(DEVTOOLS_EVENT_NAME, { detail: event })); +} + +export function emitConfigEvent(config: object): void { + emitAuthEvent({ + id: crypto.randomUUID(), + timestamp: performance.now(), + type: 'sdk:config', + source: 'sdk', + flowId: null, + causedBy: null, + data: { _tag: 'sdk-config', config }, + flags: { isCors: false, isError: false, isAuthRelated: true }, + }); +} diff --git a/packages/devtools-bridge/src/lib/journey-bridge.test.ts b/packages/devtools-bridge/src/lib/journey-bridge.test.ts new file mode 100644 index 0000000000..547f90d8dd --- /dev/null +++ b/packages/devtools-bridge/src/lib/journey-bridge.test.ts @@ -0,0 +1,627 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { attachJourneyBridge } from './journey-bridge.js'; +import { DEVTOOLS_EVENT_NAME } from './emit.js'; +import type { AuthEvent } from '@forgerock/devtools-types'; + +// --------------------------------------------------------------------------- +// Mock client factory +// --------------------------------------------------------------------------- + +type JourneyState = { + journeyReducer: { + mutations: Record< + string, + { status: string; endpointName?: string; data?: unknown; error?: unknown } + >; + }; +}; + +function makeClient(initialState: JourneyState) { + let listener: (() => void) | null = null; + let state = initialState; + return { + subscribe: vi.fn((cb: () => void) => { + listener = cb; + return () => { + listener = null; + }; + }), + getState: vi.fn(() => state), + trigger: (newState: JourneyState) => { + state = newState; + listener?.(); + }, + }; +} + +function emptyState(): JourneyState { + return { journeyReducer: { mutations: {} } }; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function captureDevtoolsEvents(): { events: CustomEvent[]; stop: () => void } { + const events: CustomEvent[] = []; + const handler = (e: Event) => events.push(e as CustomEvent); + window.addEventListener(DEVTOOLS_EVENT_NAME, handler); + return { events, stop: () => window.removeEventListener(DEVTOOLS_EVENT_NAME, handler) }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('attachJourneyBridge', () => { + beforeEach(() => { + (window as unknown as Record)['__PING_DEVTOOLS_EXTENSION__'] = true; + }); + + afterEach(() => { + delete (window as unknown as Record)['__PING_DEVTOOLS_EXTENSION__']; + }); + + it('returns a handle with a detach function', () => { + const client = makeClient(emptyState()); + const handle = attachJourneyBridge(client); + expect(handle).toHaveProperty('detach'); + expect(typeof handle.detach).toBe('function'); + handle.detach(); + }); + + it('emits sdk:journey-step with stepType=Step for a fulfilled step response (has authId)', () => { + const client = makeClient(emptyState()); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachJourneyBridge(client); + client.trigger({ + journeyReducer: { + mutations: { + 'req-1': { + status: 'fulfilled', + endpointName: 'next', + data: { + authId: 'abc123', + stage: 'UsernamePassword', + header: 'Sign In', + description: 'Enter your credentials', + callbacks: [{ type: 'NameCallback' }, { type: 'PasswordCallback' }], + }, + }, + }, + }, + }); + + handle.detach(); + stop(); + + expect(events).toHaveLength(1); + expect(events[0].detail.type).toBe('sdk:journey-step'); + const data = events[0].detail.data as { + _tag: string; + stepType: string; + callbacks: unknown[]; + authId?: string; + stage?: string; + header?: string; + }; + expect(data._tag).toBe('journey'); + expect(data.stepType).toBe('Step'); + expect(data.authId).toBe('abc123'); + expect(data.stage).toBe('UsernamePassword'); + expect(data.header).toBe('Sign In'); + expect(data.callbacks).toEqual([{ type: 'NameCallback' }, { type: 'PasswordCallback' }]); + expect(events[0].detail.flags.isError).toBe(false); + }); + + it('emits stepType=LoginSuccess when successUrl is present', () => { + const client = makeClient(emptyState()); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachJourneyBridge(client); + client.trigger({ + journeyReducer: { + mutations: { + 'req-1': { + status: 'fulfilled', + endpointName: 'next', + data: { successUrl: 'https://app.example.com/dashboard' }, + }, + }, + }, + }); + + handle.detach(); + stop(); + + const data = events[0].detail.data as { stepType: string }; + expect(data.stepType).toBe('LoginSuccess'); + expect(events[0].detail.flags.isError).toBe(false); + }); + + it('emits stepType=LoginFailure with error fields when no authId or successUrl', () => { + const client = makeClient(emptyState()); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachJourneyBridge(client); + client.trigger({ + journeyReducer: { + mutations: { + 'req-1': { + status: 'fulfilled', + endpointName: 'next', + data: { + code: 110, + message: 'Authentication Failed', + reason: 'LoginFailure', + }, + }, + }, + }, + }); + + handle.detach(); + stop(); + + const data = events[0].detail.data as { + stepType: string; + errorCode?: number; + errorMessage?: string; + errorReason?: string; + }; + expect(data.stepType).toBe('LoginFailure'); + expect(data.errorCode).toBe(110); + expect(data.errorMessage).toBe('Authentication Failed'); + expect(data.errorReason).toBe('LoginFailure'); + expect(events[0].detail.flags.isError).toBe(true); + }); + + it('emits stepType=LoginFailure for a rejected mutation with error message', () => { + const client = makeClient(emptyState()); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachJourneyBridge(client); + client.trigger({ + journeyReducer: { + mutations: { + 'req-1': { + status: 'rejected', + endpointName: 'next', + error: { message: 'Network error' }, + }, + }, + }, + }); + + handle.detach(); + stop(); + + expect(events).toHaveLength(1); + expect(events[0].detail.type).toBe('sdk:journey-step'); + const data = events[0].detail.data as unknown as { + stepType: string; + errorMessage?: string; + }; + expect(data.stepType).toBe('LoginFailure'); + expect(data.errorMessage).toBe('Network error'); + expect(events[0].detail.flags.isError).toBe(true); + }); + + it('extracts errorMessage from nested error.data.message for rejected mutation', () => { + const client = makeClient(emptyState()); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachJourneyBridge(client); + client.trigger({ + journeyReducer: { + mutations: { + 'req-1': { + status: 'rejected', + endpointName: 'next', + error: { data: { message: 'Session expired' } }, + }, + }, + }, + }); + + handle.detach(); + stop(); + + const data = events[0].detail.data as { errorMessage?: string }; + expect(data.errorMessage).toBe('Session expired'); + }); + + it('falls back to "Unknown error" when rejected mutation has no extractable message', () => { + const client = makeClient(emptyState()); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachJourneyBridge(client); + client.trigger({ + journeyReducer: { + mutations: { + 'req-1': { status: 'rejected', endpointName: 'next', error: { status: 500 } }, + }, + }, + }); + + handle.detach(); + stop(); + + const data = events[0].detail.data as { errorMessage?: string }; + expect(data.errorMessage).toBe('Unknown error'); + }); + + it('does NOT emit for pending mutations', () => { + const client = makeClient(emptyState()); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachJourneyBridge(client); + client.trigger({ + journeyReducer: { + mutations: { 'req-1': { status: 'pending', endpointName: 'next' } }, + }, + }); + + handle.detach(); + stop(); + + expect(events).toHaveLength(0); + }); + + it('does NOT re-emit for the same requestId on a second trigger', () => { + const client = makeClient(emptyState()); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachJourneyBridge(client); + const state: JourneyState = { + journeyReducer: { + mutations: { + 'req-1': { status: 'fulfilled', data: { authId: 'abc' } }, + }, + }, + }; + client.trigger(state); + client.trigger(state); + + handle.detach(); + stop(); + + expect(events).toHaveLength(1); + }); + + it('emits sdk:config on first mutation when config is provided', () => { + const client = makeClient(emptyState()); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachJourneyBridge(client, { realm: '/alpha' }); + client.trigger({ + journeyReducer: { + mutations: { 'req-1': { status: 'fulfilled', data: { authId: 'abc' } } }, + }, + }); + + handle.detach(); + stop(); + + expect(events).toHaveLength(2); + expect(events[0].detail.type).toBe('sdk:config'); + expect(events[1].detail.type).toBe('sdk:journey-step'); + }); + + it('emits sdk:config only once across multiple mutations', () => { + const client = makeClient(emptyState()); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachJourneyBridge(client, { realm: '/alpha' }); + + client.trigger({ + journeyReducer: { + mutations: { 'req-1': { status: 'fulfilled', data: { authId: 'abc' } } }, + }, + }); + client.trigger({ + journeyReducer: { + mutations: { + 'req-1': { status: 'fulfilled', data: { authId: 'abc' } }, + 'req-2': { status: 'fulfilled', data: { successUrl: '/home' } }, + }, + }, + }); + + handle.detach(); + stop(); + + const configEvents = events.filter((e) => e.detail.type === 'sdk:config'); + expect(configEvents).toHaveLength(1); + }); + + it('does NOT emit when __PING_DEVTOOLS_EXTENSION__ is absent', () => { + delete (window as unknown as Record)['__PING_DEVTOOLS_EXTENSION__']; + + const client = makeClient(emptyState()); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachJourneyBridge(client); + client.trigger({ + journeyReducer: { + mutations: { 'req-1': { status: 'fulfilled', data: { authId: 'abc' } } }, + }, + }); + + handle.detach(); + stop(); + + expect(events).toHaveLength(0); + }); + + it('detach() stops the listener', () => { + const client = makeClient(emptyState()); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachJourneyBridge(client); + handle.detach(); + + client.trigger({ + journeyReducer: { + mutations: { 'req-1': { status: 'fulfilled', data: { authId: 'abc' } } }, + }, + }); + + stop(); + + expect(events).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// Edge-case tests for pure function paths (stepPayloadToJourneyData, extractErrorMessage) +// --------------------------------------------------------------------------- + +describe('stepPayloadToJourneyData (via integration)', () => { + beforeEach(() => { + (window as unknown as Record)['__PING_DEVTOOLS_EXTENSION__'] = true; + }); + + afterEach(() => { + delete (window as unknown as Record)['__PING_DEVTOOLS_EXTENSION__']; + }); + + it('preserves all optional journey fields in a Step', () => { + const client = makeClient(emptyState()); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachJourneyBridge(client); + client.trigger({ + journeyReducer: { + mutations: { + 'req-1': { + status: 'fulfilled', + data: { + authId: 'abc', + tokenId: 'tok-1', + realm: '/alpha', + stage: 'UsernamePassword', + header: 'Sign In', + description: 'Enter your credentials', + callbacks: [{ type: 'NameCallback' }, { type: 'PasswordCallback' }], + }, + }, + }, + }, + }); + + handle.detach(); + stop(); + + const data = events[0].detail.data as { + tokenId?: string; + realm?: string; + description?: string; + }; + expect(data.tokenId).toBe('tok-1'); + expect(data.realm).toBe('/alpha'); + expect(data.description).toBe('Enter your credentials'); + }); + + it('does not include error fields for Step type', () => { + const client = makeClient(emptyState()); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachJourneyBridge(client); + client.trigger({ + journeyReducer: { + mutations: { + 'req-1': { + status: 'fulfilled', + data: { + authId: 'abc', + code: 110, + message: 'some message', + reason: 'some reason', + }, + }, + }, + }, + }); + + handle.detach(); + stop(); + + const data = events[0].detail.data as { + stepType: string; + errorCode?: number; + errorMessage?: string; + errorReason?: string; + }; + expect(data.stepType).toBe('Step'); + expect(data.errorCode).toBeUndefined(); + expect(data.errorMessage).toBeUndefined(); + expect(data.errorReason).toBeUndefined(); + }); + + it('does not emit for fulfilled mutation with unparseable data', () => { + const client = makeClient(emptyState()); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachJourneyBridge(client); + client.trigger({ + journeyReducer: { + mutations: { + 'req-1': { + status: 'fulfilled', + data: 'not-an-object', + }, + }, + }, + }); + + handle.detach(); + stop(); + + expect(events).toHaveLength(0); + }); + + it('trims stale requestIds from emitted set', () => { + const client = makeClient(emptyState()); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachJourneyBridge(client); + + // First trigger: add req-1 + client.trigger({ + journeyReducer: { + mutations: { + 'req-1': { status: 'fulfilled', data: { authId: 'abc' } }, + }, + }, + }); + + // Second trigger: req-1 removed from mutations, req-2 added + // req-1 should be trimmed from emittedRequests + client.trigger({ + journeyReducer: { + mutations: { + 'req-2': { status: 'fulfilled', data: { successUrl: '/home' } }, + }, + }, + }); + + handle.detach(); + stop(); + + expect(events).toHaveLength(2); + expect(events[0].detail.type).toBe('sdk:journey-step'); + expect(events[1].detail.type).toBe('sdk:journey-step'); + }); +}); + +describe('extractErrorMessage (via integration)', () => { + beforeEach(() => { + (window as unknown as Record)['__PING_DEVTOOLS_EXTENSION__'] = true; + }); + + afterEach(() => { + delete (window as unknown as Record)['__PING_DEVTOOLS_EXTENSION__']; + }); + + it('extracts string error directly', () => { + const client = makeClient(emptyState()); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachJourneyBridge(client); + client.trigger({ + journeyReducer: { + mutations: { + 'req-1': { status: 'rejected', error: 'Network timeout' }, + }, + }, + }); + + handle.detach(); + stop(); + + const data = events[0].detail.data as { errorMessage?: string }; + expect(data.errorMessage).toBe('Network timeout'); + }); + + it('extracts error.message from object', () => { + const client = makeClient(emptyState()); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachJourneyBridge(client); + client.trigger({ + journeyReducer: { + mutations: { + 'req-1': { status: 'rejected', error: { message: 'Auth failed' } }, + }, + }, + }); + + handle.detach(); + stop(); + + const data = events[0].detail.data as { errorMessage?: string }; + expect(data.errorMessage).toBe('Auth failed'); + }); + + it('falls back to "Unknown error" for null error', () => { + const client = makeClient(emptyState()); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachJourneyBridge(client); + client.trigger({ + journeyReducer: { + mutations: { + 'req-1': { status: 'rejected', error: null }, + }, + }, + }); + + handle.detach(); + stop(); + + const data = events[0].detail.data as { errorMessage?: string }; + expect(data.errorMessage).toBe('Unknown error'); + }); + + it('falls back to "Unknown error" for undefined error', () => { + const client = makeClient(emptyState()); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachJourneyBridge(client); + client.trigger({ + journeyReducer: { + mutations: { + 'req-1': { status: 'rejected' }, + }, + }, + }); + + handle.detach(); + stop(); + + const data = events[0].detail.data as { errorMessage?: string }; + expect(data.errorMessage).toBe('Unknown error'); + }); + + it('falls back to "Unknown error" for number error', () => { + const client = makeClient(emptyState()); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachJourneyBridge(client); + client.trigger({ + journeyReducer: { + mutations: { + 'req-1': { status: 'rejected', error: 42 }, + }, + }, + }); + + handle.detach(); + stop(); + + const data = events[0].detail.data as { errorMessage?: string }; + expect(data.errorMessage).toBe('Unknown error'); + }); +}); diff --git a/packages/devtools-bridge/src/lib/journey-bridge.ts b/packages/devtools-bridge/src/lib/journey-bridge.ts new file mode 100644 index 0000000000..795c2d5ec6 --- /dev/null +++ b/packages/devtools-bridge/src/lib/journey-bridge.ts @@ -0,0 +1,186 @@ +import { Schema, Option, pipe } from 'effect'; +import { emitAuthEvent, emitConfigEvent, configureDevtools } from './emit.js'; +import type { DevtoolsOptions } from './emit.js'; +import type { JourneyData } from '@forgerock/devtools-types'; + +export interface JourneyBridgeHandle { + detach: () => void; +} + +interface JourneySubscribable { + subscribe: (listener: () => void) => () => void; + getState: () => unknown; +} + +// --------------------------------------------------------------------------- +// Local schemas — structural contracts for RTK Query state, not public types +// --------------------------------------------------------------------------- + +const MutationEntrySchema = Schema.Struct({ + status: Schema.String, + endpointName: Schema.optional(Schema.String), + data: Schema.optional(Schema.Unknown), + error: Schema.optional(Schema.Unknown), +}); + +const JourneyStateSchema = Schema.Struct({ + journeyReducer: Schema.Struct({ + mutations: Schema.Record({ key: Schema.String, value: MutationEntrySchema }), + }), +}); + +const decodeMutationEntry = Schema.decodeUnknownOption(MutationEntrySchema); +const decodeJourneyState = Schema.decodeUnknownOption(JourneyStateSchema); + +// --------------------------------------------------------------------------- +// Pure mapping — Step payload → JourneyData +// --------------------------------------------------------------------------- + +const StepPayloadSchema = Schema.Struct({ + authId: Schema.optional(Schema.String), + successUrl: Schema.optional(Schema.String), + tokenId: Schema.optional(Schema.String), + code: Schema.optional(Schema.Number), + message: Schema.optional(Schema.String), + reason: Schema.optional(Schema.String), + realm: Schema.optional(Schema.String), + stage: Schema.optional(Schema.String), + header: Schema.optional(Schema.String), + description: Schema.optional(Schema.String), + callbacks: Schema.optional(Schema.Array(Schema.Unknown)), +}); + +const decodeStepPayload = Schema.decodeUnknownOption(StepPayloadSchema); + +function stepPayloadToJourneyData(data: unknown): JourneyData | null { + return pipe( + data, + decodeStepPayload, + Option.map((step) => { + const stepType: JourneyData['stepType'] = step.authId + ? 'Step' + : step.successUrl + ? 'LoginSuccess' + : 'LoginFailure'; + + return { + _tag: 'journey' as const, + stepType, + callbacks: step.callbacks, + authId: step.authId, + tokenId: step.tokenId, + successUrl: step.successUrl, + realm: step.realm, + stage: step.stage, + header: step.header, + description: step.description, + errorCode: stepType === 'LoginFailure' ? step.code : undefined, + errorMessage: stepType === 'LoginFailure' ? step.message : undefined, + errorReason: stepType === 'LoginFailure' ? step.reason : undefined, + } satisfies JourneyData; + }), + Option.getOrNull, + ); +} + +function extractErrorMessage(error: unknown): string { + if (typeof error === 'string') return error; + if (typeof error === 'object' && error !== null) { + const e = error as Record; + if (typeof e['message'] === 'string') return e['message']; + if (typeof e['data'] === 'object' && e['data'] !== null) { + const d = e['data'] as Record; + if (typeof d['message'] === 'string') return d['message']; + } + } + return 'Unknown error'; +} + +// --------------------------------------------------------------------------- +// Bridge +// --------------------------------------------------------------------------- + +export function attachJourneyBridge( + client: JourneySubscribable, + config?: object, + devtoolsOptions?: DevtoolsOptions, +): JourneyBridgeHandle { + if (typeof window === 'undefined') { + return { detach: () => undefined }; + } + + if (devtoolsOptions) { + configureDevtools(devtoolsOptions); + } + + let configEmitted = false; + let emittedRequests = new Set(); + + const unsubscribe = client.subscribe(() => { + if (!('__PING_DEVTOOLS_EXTENSION__' in window)) return; + + pipe( + client.getState(), + decodeJourneyState, + Option.map(({ journeyReducer: { mutations } }) => { + // Trim stale IDs no longer in the cache to bound memory usage + emittedRequests = new Set([...emittedRequests].filter((id) => id in mutations)); + + for (const [requestId, rawEntry] of Object.entries(mutations)) { + if (emittedRequests.has(requestId)) continue; + + pipe( + rawEntry, + decodeMutationEntry, + Option.filter((entry) => entry.status === 'fulfilled' || entry.status === 'rejected'), + Option.map((entry) => { + emittedRequests.add(requestId); + + if (config && !configEmitted) { + emitConfigEvent(config); + configEmitted = true; + } + + if (entry.status === 'fulfilled') { + const journeyData = stepPayloadToJourneyData(entry.data); + if (!journeyData) return; + emitAuthEvent({ + id: crypto.randomUUID(), + timestamp: performance.now(), + type: 'sdk:journey-step', + source: 'sdk', + flowId: null, + causedBy: null, + data: journeyData, + flags: { + isCors: false, + isError: journeyData.stepType === 'LoginFailure', + isAuthRelated: true, + }, + }); + } else { + const journeyData: JourneyData = { + _tag: 'journey', + stepType: 'LoginFailure', + errorMessage: extractErrorMessage(entry.error), + }; + emitAuthEvent({ + id: crypto.randomUUID(), + timestamp: performance.now(), + type: 'sdk:journey-step', + source: 'sdk', + flowId: null, + causedBy: null, + data: journeyData, + flags: { isCors: false, isError: true, isAuthRelated: true }, + }); + } + }), + ); + } + }), + ); + }); + + return { detach: unsubscribe }; +} diff --git a/packages/devtools-bridge/src/lib/oidc-bridge.test.ts b/packages/devtools-bridge/src/lib/oidc-bridge.test.ts new file mode 100644 index 0000000000..75f7759f60 --- /dev/null +++ b/packages/devtools-bridge/src/lib/oidc-bridge.test.ts @@ -0,0 +1,526 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { attachOidcBridge } from './oidc-bridge.js'; +import { DEVTOOLS_EVENT_NAME } from './emit.js'; +import type { AuthEvent } from '@forgerock/devtools-types'; + +// --------------------------------------------------------------------------- +// Mock client factory +// --------------------------------------------------------------------------- + +type OidcState = { + oidc: { + mutations: Record< + string, + { status: string; endpointName?: string; data?: unknown; error?: unknown } + >; + }; +}; + +function makeClient(initialState: OidcState) { + let listener: (() => void) | null = null; + let state = initialState; + return { + subscribe: vi.fn((cb: () => void) => { + listener = cb; + return () => { + listener = null; + }; + }), + getState: vi.fn(() => state), + /** Test helper: replace state and fire the subscribed listener. */ + trigger: (newState: OidcState) => { + state = newState; + listener?.(); + }, + }; +} + +function emptyState(): OidcState { + return { oidc: { mutations: {} } }; +} + +function fulfilledMutation(endpointName: string): OidcState['oidc']['mutations'][string] { + return { status: 'fulfilled', endpointName }; +} + +function rejectedMutation( + endpointName: string, + error: unknown, +): OidcState['oidc']['mutations'][string] { + return { status: 'rejected', endpointName, error }; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function captureDevtoolsEvents(): { events: CustomEvent[]; stop: () => void } { + const events: CustomEvent[] = []; + const handler = (e: Event) => events.push(e as CustomEvent); + window.addEventListener(DEVTOOLS_EVENT_NAME, handler); + return { events, stop: () => window.removeEventListener(DEVTOOLS_EVENT_NAME, handler) }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('attachOidcBridge', () => { + beforeEach(() => { + (window as unknown as Record)['__PING_DEVTOOLS_EXTENSION__'] = true; + }); + + afterEach(() => { + delete (window as unknown as Record)['__PING_DEVTOOLS_EXTENSION__']; + }); + + it('returns a handle with a detach function', () => { + const client = makeClient(emptyState()); + const handle = attachOidcBridge(client); + expect(handle).toHaveProperty('detach'); + expect(typeof handle.detach).toBe('function'); + handle.detach(); + }); + + it('emits sdk:oidc-state for a fulfilled authorizeFetch mutation', () => { + const client = makeClient(emptyState()); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachOidcBridge(client); + client.trigger({ oidc: { mutations: { 'req-1': fulfilledMutation('authorizeFetch') } } }); + + handle.detach(); + stop(); + + expect(events).toHaveLength(1); + expect(events[0].detail.type).toBe('sdk:oidc-state'); + const data = events[0].detail.data as { _tag: string; phase: string; status: string }; + expect(data._tag).toBe('oidc'); + expect(data.phase).toBe('authorize'); + expect(data.status).toBe('success'); + }); + + it('maps authorizeIframe → authorize phase', () => { + const client = makeClient(emptyState()); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachOidcBridge(client); + client.trigger({ oidc: { mutations: { 'req-1': fulfilledMutation('authorizeIframe') } } }); + + handle.detach(); + stop(); + + const data = events[0].detail.data as { phase: string }; + expect(data.phase).toBe('authorize'); + }); + + it.each([ + ['exchange', 'exchange'], + ['revoke', 'revoke'], + ['userInfo', 'userinfo'], + ['endSession', 'logout'], + ])('maps %s → %s phase', (endpointName, expectedPhase) => { + const client = makeClient(emptyState()); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachOidcBridge(client); + client.trigger({ oidc: { mutations: { 'req-1': fulfilledMutation(endpointName) } } }); + + handle.detach(); + stop(); + + const data = events[0].detail.data as { phase: string }; + expect(data.phase).toBe(expectedPhase); + }); + + it('emits status:error and extracts errorCode/errorMessage for rejected mutation', () => { + const client = makeClient(emptyState()); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachOidcBridge(client); + client.trigger({ + oidc: { + mutations: { + 'req-1': rejectedMutation('exchange', { + status: 400, + data: { error: 'invalid_grant', error_description: 'Token expired' }, + }), + }, + }, + }); + + handle.detach(); + stop(); + + expect(events).toHaveLength(1); + const data = events[0].detail.data as { + _tag: string; + phase: string; + status: string; + errorCode?: string; + errorMessage?: string; + }; + expect(data.status).toBe('error'); + expect(data.errorCode).toBe('invalid_grant'); + expect(data.errorMessage).toBe('Token expired'); + expect(events[0].detail.flags.isError).toBe(true); + }); + + it('falls back to HTTP status code when data.error is absent', () => { + const client = makeClient(emptyState()); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachOidcBridge(client); + client.trigger({ + oidc: { mutations: { 'req-1': rejectedMutation('revoke', { status: 401 }) } }, + }); + + handle.detach(); + stop(); + + const data = events[0].detail.data as { errorCode?: string }; + expect(data.errorCode).toBe('401'); + }); + + it('does NOT emit for pending mutations (status = pending)', () => { + const client = makeClient(emptyState()); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachOidcBridge(client); + client.trigger({ + oidc: { mutations: { 'req-1': { status: 'pending', endpointName: 'exchange' } } }, + }); + + handle.detach(); + stop(); + + expect(events).toHaveLength(0); + }); + + it('does NOT emit for an unknown endpointName (no phase mapping)', () => { + const client = makeClient(emptyState()); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachOidcBridge(client); + client.trigger({ + oidc: { mutations: { 'req-1': fulfilledMutation('unknownEndpoint') } }, + }); + + handle.detach(); + stop(); + + expect(events).toHaveLength(0); + }); + + it('does NOT re-emit for the same requestId on a second trigger', () => { + const client = makeClient(emptyState()); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachOidcBridge(client); + + const state: OidcState = { + oidc: { mutations: { 'req-1': fulfilledMutation('exchange') } }, + }; + client.trigger(state); + // Same requestId still present — should not emit again. + client.trigger(state); + + handle.detach(); + stop(); + + expect(events).toHaveLength(1); + }); + + it('emits sdk:config on the first mutation when config is provided', () => { + const client = makeClient(emptyState()); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachOidcBridge(client, { clientId: 'my-app' }); + client.trigger({ oidc: { mutations: { 'req-1': fulfilledMutation('exchange') } } }); + + handle.detach(); + stop(); + + expect(events).toHaveLength(2); + expect(events[0].detail.type).toBe('sdk:config'); + expect(events[1].detail.type).toBe('sdk:oidc-state'); + }); + + it('emits sdk:config only once across multiple mutations', () => { + const client = makeClient(emptyState()); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachOidcBridge(client, { clientId: 'my-app' }); + + client.trigger({ oidc: { mutations: { 'req-1': fulfilledMutation('exchange') } } }); + client.trigger({ + oidc: { + mutations: { 'req-1': fulfilledMutation('exchange'), 'req-2': fulfilledMutation('revoke') }, + }, + }); + + handle.detach(); + stop(); + + const configEvents = events.filter((e) => e.detail.type === 'sdk:config'); + expect(configEvents).toHaveLength(1); + }); + + it('includes clientId in the emitted oidc data', () => { + const client = makeClient(emptyState()); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachOidcBridge(client, { clientId: 'ping-app' }); + client.trigger({ oidc: { mutations: { 'req-1': fulfilledMutation('exchange') } } }); + + handle.detach(); + stop(); + + const oidcEvent = events.find((e) => e.detail.type === 'sdk:oidc-state'); + const data = oidcEvent?.detail.data as { clientId?: string }; + expect(data.clientId).toBe('ping-app'); + }); + + it('does NOT emit when __PING_DEVTOOLS_EXTENSION__ is absent', () => { + delete (window as unknown as Record)['__PING_DEVTOOLS_EXTENSION__']; + + const client = makeClient(emptyState()); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachOidcBridge(client); + client.trigger({ oidc: { mutations: { 'req-1': fulfilledMutation('exchange') } } }); + + handle.detach(); + stop(); + + expect(events).toHaveLength(0); + }); + + it('detach() stops the listener', () => { + const client = makeClient(emptyState()); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachOidcBridge(client); + handle.detach(); + + client.trigger({ oidc: { mutations: { 'req-1': fulfilledMutation('exchange') } } }); + + stop(); + + expect(events).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// Edge-case tests for pure function paths (extractOidcError, mutationToOidcData) +// --------------------------------------------------------------------------- + +describe('extractOidcError (via integration)', () => { + beforeEach(() => { + (window as unknown as Record)['__PING_DEVTOOLS_EXTENSION__'] = true; + }); + + afterEach(() => { + delete (window as unknown as Record)['__PING_DEVTOOLS_EXTENSION__']; + }); + + it('extracts error_description from error.data', () => { + const client = makeClient(emptyState()); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachOidcBridge(client); + client.trigger({ + oidc: { + mutations: { + 'req-1': rejectedMutation('exchange', { + data: { error: 'invalid_grant', error_description: 'Code expired' }, + }), + }, + }, + }); + + handle.detach(); + stop(); + + const data = events[0].detail.data as { errorCode?: string; errorMessage?: string }; + expect(data.errorCode).toBe('invalid_grant'); + expect(data.errorMessage).toBe('Code expired'); + }); + + it('falls back to data.message when error_description is absent', () => { + const client = makeClient(emptyState()); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachOidcBridge(client); + client.trigger({ + oidc: { + mutations: { + 'req-1': rejectedMutation('exchange', { + data: { message: 'Server error' }, + }), + }, + }, + }); + + handle.detach(); + stop(); + + const data = events[0].detail.data as { errorCode?: string; errorMessage?: string }; + expect(data.errorCode).toBeUndefined(); + expect(data.errorMessage).toBe('Server error'); + }); + + it('falls back to top-level message when no data object', () => { + const client = makeClient(emptyState()); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachOidcBridge(client); + client.trigger({ + oidc: { + mutations: { + 'req-1': rejectedMutation('exchange', { message: 'Top level error' }), + }, + }, + }); + + handle.detach(); + stop(); + + const data = events[0].detail.data as { errorMessage?: string }; + expect(data.errorMessage).toBe('Top level error'); + }); + + it('extracts string error as errorMessage', () => { + const client = makeClient(emptyState()); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachOidcBridge(client); + client.trigger({ + oidc: { + mutations: { + 'req-1': rejectedMutation('exchange', 'plain string error'), + }, + }, + }); + + handle.detach(); + stop(); + + const data = events[0].detail.data as { errorMessage?: string; errorCode?: string }; + expect(data.errorMessage).toBe('plain string error'); + expect(data.errorCode).toBeUndefined(); + }); + + it('returns empty error fields for null error', () => { + const client = makeClient(emptyState()); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachOidcBridge(client); + client.trigger({ + oidc: { + mutations: { + 'req-1': rejectedMutation('exchange', null), + }, + }, + }); + + handle.detach(); + stop(); + + const data = events[0].detail.data as { errorCode?: string; errorMessage?: string }; + expect(data.errorCode).toBeUndefined(); + expect(data.errorMessage).toBeUndefined(); + }); + + it('returns empty error fields for undefined error', () => { + const client = makeClient(emptyState()); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachOidcBridge(client); + client.trigger({ + oidc: { + mutations: { + 'req-1': { status: 'rejected', endpointName: 'exchange' }, + }, + }, + }); + + handle.detach(); + stop(); + + const data = events[0].detail.data as { errorCode?: string; errorMessage?: string }; + expect(data.errorCode).toBeUndefined(); + expect(data.errorMessage).toBeUndefined(); + }); +}); + +describe('mutationToOidcData edge cases (via integration)', () => { + beforeEach(() => { + (window as unknown as Record)['__PING_DEVTOOLS_EXTENSION__'] = true; + }); + + afterEach(() => { + delete (window as unknown as Record)['__PING_DEVTOOLS_EXTENSION__']; + }); + + it('does not emit for mutation with undefined endpointName', () => { + const client = makeClient(emptyState()); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachOidcBridge(client); + client.trigger({ + oidc: { + mutations: { + 'req-1': { status: 'fulfilled' }, + }, + }, + }); + + handle.detach(); + stop(); + + expect(events).toHaveLength(0); + }); + + it('trims stale requestIds from emitted set', () => { + const client = makeClient(emptyState()); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachOidcBridge(client); + + // First trigger: add req-1 + client.trigger({ + oidc: { mutations: { 'req-1': fulfilledMutation('exchange') } }, + }); + + // Second trigger: req-1 removed, req-2 added + client.trigger({ + oidc: { mutations: { 'req-2': fulfilledMutation('revoke') } }, + }); + + handle.detach(); + stop(); + + const oidcEvents = events.filter((e) => e.detail.type === 'sdk:oidc-state'); + expect(oidcEvents).toHaveLength(2); + expect((oidcEvents[0].detail.data as { phase: string }).phase).toBe('exchange'); + expect((oidcEvents[1].detail.data as { phase: string }).phase).toBe('revoke'); + }); + + it('passes undefined clientId when config has no clientId', () => { + const client = makeClient(emptyState()); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachOidcBridge(client, {}); + client.trigger({ + oidc: { mutations: { 'req-1': fulfilledMutation('exchange') } }, + }); + + handle.detach(); + stop(); + + const oidcEvent = events.find((e) => e.detail.type === 'sdk:oidc-state'); + const data = oidcEvent?.detail.data as { clientId?: string }; + expect(data.clientId).toBeUndefined(); + }); +}); diff --git a/packages/devtools-bridge/src/lib/oidc-bridge.ts b/packages/devtools-bridge/src/lib/oidc-bridge.ts new file mode 100644 index 0000000000..243f05fb77 --- /dev/null +++ b/packages/devtools-bridge/src/lib/oidc-bridge.ts @@ -0,0 +1,172 @@ +import { Schema, Option, pipe } from 'effect'; +import { emitAuthEvent, emitConfigEvent, configureDevtools } from './emit.js'; +import type { DevtoolsOptions } from './emit.js'; +import type { OidcData } from '@forgerock/devtools-types'; + +export interface OidcBridgeHandle { + detach: () => void; +} + +interface OidcSubscribable { + subscribe: (listener: () => void) => () => void; + getState: () => unknown; +} + +// --------------------------------------------------------------------------- +// Local schemas — structural contracts for RTK Query state, not public types +// --------------------------------------------------------------------------- + +const MutationEntrySchema = Schema.Struct({ + status: Schema.String, + endpointName: Schema.optional(Schema.String), + data: Schema.optional(Schema.Unknown), + error: Schema.optional(Schema.Unknown), +}); + +const OidcStateSchema = Schema.Struct({ + oidc: Schema.Struct({ + mutations: Schema.Record({ key: Schema.String, value: MutationEntrySchema }), + }), +}); + +const decodeMutationEntry = Schema.decodeUnknownOption(MutationEntrySchema); +const decodeOidcState = Schema.decodeUnknownOption(OidcStateSchema); + +// --------------------------------------------------------------------------- +// Endpoint name → OidcData phase +// --------------------------------------------------------------------------- + +const ENDPOINT_PHASE_MAP: Record = { + authorizeFetch: 'authorize', + authorizeIframe: 'authorize', + exchange: 'exchange', + revoke: 'revoke', + userInfo: 'userinfo', + endSession: 'logout', +}; + +// --------------------------------------------------------------------------- +// Pure mapping — RTK mutation entry → OidcData +// --------------------------------------------------------------------------- + +function extractOidcError(error: unknown): { errorCode?: string; errorMessage?: string } { + if (typeof error === 'string') return { errorMessage: error }; + if (typeof error !== 'object' || error === null) return {}; + const e = error as Record; + const errData = + typeof e['data'] === 'object' && e['data'] !== null + ? (e['data'] as Record) + : undefined; + return { + errorCode: + typeof errData?.['error'] === 'string' + ? errData['error'] + : typeof e['status'] === 'number' + ? String(e['status']) + : undefined, + errorMessage: + typeof errData?.['error_description'] === 'string' + ? errData['error_description'] + : typeof errData?.['message'] === 'string' + ? errData['message'] + : typeof e['message'] === 'string' + ? e['message'] + : undefined, + }; +} + +function mutationToOidcData( + endpointName: string | undefined, + status: 'fulfilled' | 'rejected', + error: unknown, + clientId: string | undefined, +): OidcData | null { + const phase = ENDPOINT_PHASE_MAP[endpointName ?? '']; + if (!phase) return null; + + if (status === 'fulfilled') { + return { _tag: 'oidc', phase, status: 'success', clientId }; + } + + return { _tag: 'oidc', phase, status: 'error', clientId, ...extractOidcError(error) }; +} + +// --------------------------------------------------------------------------- +// Bridge +// --------------------------------------------------------------------------- + +export function attachOidcBridge( + client: OidcSubscribable, + config?: { clientId?: string } & object, + devtoolsOptions?: DevtoolsOptions, +): OidcBridgeHandle { + if (typeof window === 'undefined') { + return { detach: () => undefined }; + } + + if (devtoolsOptions) { + configureDevtools(devtoolsOptions); + } + + let configEmitted = false; + let emittedRequests = new Set(); + + const unsubscribe = client.subscribe(() => { + if (!('__PING_DEVTOOLS_EXTENSION__' in window)) return; + + pipe( + client.getState(), + decodeOidcState, + Option.map(({ oidc: { mutations } }) => { + // Trim stale IDs no longer in the cache to bound memory usage + emittedRequests = new Set([...emittedRequests].filter((id) => id in mutations)); + + for (const [requestId, rawEntry] of Object.entries(mutations)) { + if (emittedRequests.has(requestId)) continue; + + pipe( + rawEntry, + decodeMutationEntry, + Option.filter( + (entry): entry is typeof entry & { status: 'fulfilled' | 'rejected' } => + entry.status === 'fulfilled' || entry.status === 'rejected', + ), + Option.map((entry) => { + emittedRequests.add(requestId); + + if (config && !configEmitted) { + emitConfigEvent(config); + configEmitted = true; + } + + const oidcData = mutationToOidcData( + entry.endpointName, + entry.status, + entry.error, + config?.clientId, + ); + if (!oidcData) return; + + emitAuthEvent({ + id: crypto.randomUUID(), + timestamp: performance.now(), + type: 'sdk:oidc-state', + source: 'sdk', + flowId: null, + causedBy: null, + data: oidcData, + flags: { + isCors: false, + isError: oidcData.status === 'error', + isAuthRelated: true, + }, + }); + }), + ); + } + }), + ); + }); + + return { detach: unsubscribe }; +} diff --git a/packages/devtools-bridge/tsconfig.json b/packages/devtools-bridge/tsconfig.json new file mode 100644 index 0000000000..329ef5038f --- /dev/null +++ b/packages/devtools-bridge/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { "path": "./tsconfig.lib.json" }, + { "path": "./tsconfig.spec.json" } + ], + "nx": { "addTypecheckTarget": false } +} diff --git a/packages/devtools-bridge/tsconfig.lib.json b/packages/devtools-bridge/tsconfig.lib.json new file mode 100644 index 0000000000..63236d56bb --- /dev/null +++ b/packages/devtools-bridge/tsconfig.lib.json @@ -0,0 +1,29 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "moduleResolution": "nodenext", + "module": "NodeNext", + "target": "ES2022", + "outDir": "./dist", + "resolveJsonModule": true, + "moduleDetection": "force", + "isolatedModules": true, + "strict": true, + "noImplicitOverride": true, + "declaration": true, + "declarationMap": true, + "skipLibCheck": true, + "sourceMap": true, + "lib": ["es2022", "dom", "dom.iterable"] + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"], + "references": [ + { + "path": "../devtools-types/tsconfig.lib.json" + }, + { + "path": "../davinci-client/tsconfig.lib.json" + } + ] +} diff --git a/packages/devtools-bridge/tsconfig.spec.json b/packages/devtools-bridge/tsconfig.spec.json new file mode 100644 index 0000000000..5b057b3f75 --- /dev/null +++ b/packages/devtools-bridge/tsconfig.spec.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc/vitest", + "types": [ + "vitest/globals", + "vitest/importMeta", + "vite/client", + "node", + "vitest" + ], + "module": "NodeNext", + "moduleResolution": "nodenext", + "strict": true, + "noImplicitOverride": true + }, + "include": ["vite.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts"], + "references": [{ "path": "./tsconfig.lib.json" }] +} diff --git a/packages/devtools-bridge/vite.config.ts b/packages/devtools-bridge/vite.config.ts new file mode 100644 index 0000000000..c8961e30f3 --- /dev/null +++ b/packages/devtools-bridge/vite.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite'; + +export default defineConfig(() => ({ + root: __dirname, + cacheDir: '../../node_modules/.vite/packages/devtools-bridge', + test: { + watch: false, + globals: true, + environment: 'jsdom', + include: ['src/**/*.{test,spec}.{js,ts}'], + reporters: ['default'], + }, +})); diff --git a/packages/devtools-extension/README.md b/packages/devtools-extension/README.md new file mode 100644 index 0000000000..62ecccbeab --- /dev/null +++ b/packages/devtools-extension/README.md @@ -0,0 +1,236 @@ +# Ping DevTools + +**Captures, correlates, and diagnoses** Ping Identity / ForgeRock authentication flows in real time. + +Most auth debugging starts in the Network panel and stays there — copying tokens into jwt.io, cross-referencing timestamps, guessing which 400 was the CORS preflight and which was a bad grant. Ping DevTools replaces that with a single panel that captures both network traffic and SDK-level events, correlates them into flows, and runs an automated diagnosis engine that tells you _what went wrong and how to fix it_. + +![Flow view with diagnosis banner and node rail](screenshots/Flow-Screen.png) + +--- + +## Status + +**v0.1.0 — alpha, active development.** The extension is functional and loadable as an unpacked Chrome extension. It is not published to the Chrome Web Store. The package is private (`@forgerock/devtools-extension`). + +--- + +## Diagnosis + +Every captured event is run through a rule engine that produces flow-level and per-event diagnostics with severity ratings and numbered remediation steps. + +**Flow Health** — a banner at the top of the Flow view surfaces the worst-severity issue across the entire flow. It stays hidden when everything is healthy, expands automatically when a new error arrives during recording, and each issue is clickable — jumping directly to the related event in the Timeline. + +**Per-event annotations** — the Inspector's Diagnosis tab appears only when the selected event has issues. Errors get a solid dot on the tab label; warnings get a half dot. Each annotation includes a title, description, relevant data pairs, and step-by-step remediation. + +The engine currently covers: + +| Category | Examples | +| --------------- | --------------------------------------------------------------------------------------------------------------- | +| **CORS** | Status-zero failures, missing `Access-Control-Allow-Origin`, wildcard + credentials conflict | +| **Token** | Missing `interactionToken` on non-initial nodes, expired JWTs in request headers (decoded and checked at `exp`) | +| **Flow config** | Node error/failure status, connector errors, policy-not-found | +| **OIDC** | State mismatch, missing PKCE, redirect URI mismatch | + +--- + +## Import and export + +Flows can be exported for sharing or offline analysis, and imported from JSON. + +**Export** — the toolbar dropdown offers two formats: + +- **JSON** — full flow state including all events, a summary (node count, error count, CORS flags, duration), and metadata. Supports optional redaction of sensitive data (tokens, passwords). +- **Markdown** — a human-readable report with a flow summary and event timeline grouped by type. + +**Import** — paste exported JSON into the import modal. The imported flow replaces live recording (recording pauses automatically). A metadata banner shows the flow ID, capture timestamp, and redaction status. Click **Clear** to discard the import and resume live capture. + +--- + +## Snapshots + +Click **Snapshot** to save the current flow state to local storage (up to 5 snapshots, oldest dropped when full). The dropdown arrow next to the button opens a list of saved snapshots showing flow ID, timestamp, and event count. Click an entry to load it (same as importing — recording pauses, import banner appears). Click **✕** to delete a snapshot. + +--- + +## Time-travel playback + +The Flow view includes transport controls (**Prev / Play / Pause / Reset**) that step through SDK nodes in sequence. During playback the interval between steps mirrors the real elapsed time, clamped to 300 ms – 1500 ms, so you can watch the flow unfold at roughly the pace it happened. + +--- + +## Why not just use the Network panel? + +The Network panel shows HTTP requests. Auth flows are not HTTP requests — they are multi-step state machines that span dozens of requests, involve two independent event streams (network and SDK), and fail in ways that only make sense when you see the full sequence. + +Ping DevTools gives you: + +- **Two-stream correlation** — network responses and SDK state transitions are merged into a single timeline, linked by flow ID and causal references ("Triggered by SDK Node `a1b2c3d4`"). +- **Automated diagnosis** — CORS misconfigurations, expired JWTs, missing PKCE, and connector errors are detected and explained with remediation steps, not left as a 400 status code. +- **Flow-level structure** — the Flow view shows the authentication flow as a sequence of nodes with detail cards, not a flat list of URLs. +- **Playback** — step through the flow to see exactly what the SDK saw at each point. + +![Timeline with two-stream correlation](screenshots/Timeline-Screen.png) + +--- + +## Architecture + +TypeScript with Effect-TS on the data plane, Elm on the view, Schema-validated at the boundary. Elm was chosen because of it's runtime guarantee's so the devtool should almost always, function and have no runtime errors (likely). + +``` +Host page + ├── attachDevToolsBridge(davinciClient) ─┐ + ├── attachJourneyBridge(journeyClient) ─┤─ CustomEvent('pingDevtools') + └── attachOidcBridge(oidcClient) ─┘ + │ + content-script.ts (MAIN world — postMessage only, no chrome.runtime) + │ + relay.ts (isolated world — chrome.runtime.sendMessage) + │ + service-worker.ts (Effect ManagedRuntime) + ├── AuthEventSchema validation (Effect Schema — untrusted input decoded or dropped) + ├── EventStore (Effect Ref + chrome.storage.local) + ├── diagnosis-engine.ts (flow rules + event rules) + └── broadcast to panel(s) + │ + panel/Main.elm (Elm 0.19) + ├── Timeline view — chronological event table with Inspector + ├── Flow view — node rail + detail card + health banner + └── Learn view — canvas-based request lifecycle visualization +``` + +Network events follow a parallel path: `network-observer.ts` uses `chrome.devtools.network.onRequestFinished` to capture HAR entries, filters them against auth URL patterns, and sends them to the same service worker. + +Diagnosis results include per-event annotations and numbered remediation steps that surface protocol-level context (CORS mechanics, OAuth error codes, JWT claims) inline in the Inspector, so you understand _why_ something failed without leaving the panel. + +--- + +## Captured event types + +| Type | Source | Description | +| ------------------- | ------- | ---------------------------------------------------- | +| `network:request` | network | Outgoing HTTP request to an auth endpoint | +| `network:response` | network | Response received | +| `network:cors-flag` | network | CORS failure detected (status 0, missing headers) | +| `sdk:node-change` | sdk | DaVinci node transition (start → continue → …) | +| `sdk:config` | sdk | SDK configuration snapshot (emitted once per bridge) | +| `sdk:journey-step` | sdk | AM Journey step fulfilled or rejected | +| `sdk:oidc-state` | sdk | OIDC endpoint settled (authorize, exchange, …) | +| `dom:form-submit` | dom | Form submission captured | +| `dom:redirect` | dom | Page redirect detected | +| `session:cookie` | session | Cookie value changed | +| `session:storage` | session | `localStorage` value changed | + +Events are linked by `flowId` and an optional `causedBy` reference pointing to the originating event, enabling two-stream correlation in the Timeline. + +--- + +## Security and privacy + +The extension requests only `storage, and clipboardWrite/clipboardRead` (for copying collectors from the view if wanted) — no `cookies`, `webRequest`, `tabs``, or other sensitive APIs. Content scripts use a two-world architecture: `content-script.ts`runs in the MAIN world (page access, no`chrome.runtime`), while `relay.ts`runs in the isolated world (runtime access, guarded by a sentinel flag and same-source check), preventing arbitrary page code from injecting messages into the service worker. All SDK events are decoded through`AuthEventSchema`(Effect Schema) before reaching the EventStore — malformed payloads are dropped with a console warning. Captured data is stored in`chrome.storage.local` under a namespaced key and never transmitted off-device. No remote code is loaded or executed. + +--- + +## Build + +```bash +nx run devtools-extension:build +``` + +Output is written to `packages/devtools-extension/dist/`. + +> **Prerequisite:** [Elm](https://guide.elm-lang.org/install/elm.html) must be installed and on your `PATH`. The build step compiles `src/panel/Main.elm` into a single JS bundle. + +--- + +## Load in Chrome + +1. Open `chrome://extensions` +2. Enable **Developer mode** (top-right toggle) +3. Click **Load unpacked** +4. Select `packages/devtools-extension/dist/` +5. Open DevTools on any page → **Ping DevTools** tab + +After rebuilding, click the refresh icon on the extension card at `chrome://extensions`, then close and reopen DevTools. + +--- + +## Wiring up your app + +The extension captures all network traffic automatically. To also see SDK-level events (node transitions, journey steps, OIDC phases, session diffs), add the bridge adapter to your app. + +```bash +pnpm add @forgerock/devtools-bridge +``` + +All `attach*` functions are safe to call unconditionally — they are no-ops when the extension is not installed and when running in SSR/Node. + +### DaVinci + +```ts +import { davinci } from '@forgerock/davinci-client'; +import { attachDevToolsBridge } from '@forgerock/devtools-bridge'; + +const client = await davinci({ config }); +attachDevToolsBridge(client, config); +``` + +### AM Journey + +```ts +import { attachJourneyBridge } from '@forgerock/devtools-bridge'; + +attachJourneyBridge(journeyClient, config); +``` + +### OIDC / OAuth + +```ts +import { attachOidcBridge } from '@forgerock/devtools-bridge'; + +attachOidcBridge(oidcClient, { clientId: 'my-spa-client', ...config }); +``` + +--- + +## Panel views + +### Timeline + +A chronological table of all captured events. Each row shows timestamp, event type, source, and status with colour-coded error/CORS flags. A **graph sidebar** draws a vertical SVG rail of SDK node-change events with status-coloured circles and connector lines — click a node in the rail to jump to it in the table. Click any row to open its Inspector panel. + +**Inspector tabs** — the right-hand panel shows contextual tabs depending on the selected event: + +| Tab | Shows | Appears for | +| -------------- | ----------------------------------------------------------------------------- | ---------------------- | +| **Headers** | Request and response headers with copy-to-clipboard | Network events | +| **Body** | Request/response bodies with a collapsible JSON tree viewer | Network events | +| **SDK State** | Full node data — status, tokens, errors, collectors, authorization | SDK events | +| **Collectors** | Interactive collector list with copy-all button | SDK node-change events | +| **Cookies** | Cookie values with before/after diff highlighting | Session cookie events | +| **Session** | Before/after values for localStorage changes | Session storage events | +| **Config** | SDK configuration JSON | Config events | +| **CORS** | Failure reason, preflight status, `Allow-Origin` / `Allow-Credentials` values | CORS-flagged events | +| **Diagnosis** | Severity, title, description, relevant data pairs, remediation steps | Events with issues | + +### Flow + +A visual representation of the authentication flow as a sequence of SDK nodes. The node rail draws coloured circles for each node with arrows connecting them, status and node-name labels, and a glow effect on the selected node. Selecting a node opens a detail card with contextual information — collectors for DaVinci, callbacks for Journey steps, phase and error data for OIDC — plus any causally linked network requests with expandable request/response bodies. The Flow Health banner appears above the rail when the diagnosis engine detects issues. + +### Learn + +A canvas-based visualization that maps the request lifecycle across four stages: **Browser**, **Server**, **SDK**, and **Form**. Each stage is drawn as a labelled card with animated connector arrows showing the direction and outcome of each hop — method labels on outgoing edges, status codes on responses. Error states are highlighted with red borders and status annotations (e.g. `X 400`), making it immediately clear where a request failed and how the SDK interpreted the result. + +The Learn tab correlates network events with SDK state transitions to show the full round-trip: the browser sends a request, the server responds, the SDK processes the response into a node transition, and the form renders the result. When errors occur, you can see exactly which stage failed and how that failure propagated through the rest of the pipeline. + +![Learn tab showing request lifecycle with error highlighting](screenshots/Learn-Tab-Error-Screen.png) + +--- + +## Packages + +| Package | Description | +| ------------------------------- | ----------------------------------------------------------------- | +| `@forgerock/devtools-extension` | The Chrome extension (this package — private, not published) | +| `@forgerock/devtools-bridge` | Opt-in SDK adapter — emits `AuthEvent`s from subscribable clients | +| `@forgerock/devtools-types` | Shared `AuthEvent` Effect Schema definitions and TypeScript types | diff --git a/packages/devtools-extension/elm-tooling.json b/packages/devtools-extension/elm-tooling.json new file mode 100644 index 0000000000..3ceee7dd78 --- /dev/null +++ b/packages/devtools-extension/elm-tooling.json @@ -0,0 +1,5 @@ +{ + "tools": { + "elm": "0.19.1" + } +} diff --git a/packages/devtools-extension/elm.json b/packages/devtools-extension/elm.json new file mode 100644 index 0000000000..d252750e16 --- /dev/null +++ b/packages/devtools-extension/elm.json @@ -0,0 +1,29 @@ +{ + "type": "application", + "source-directories": ["src/panel/src", "src/panel"], + "elm-version": "0.19.1", + "dependencies": { + "direct": { + "NoRedInk/elm-json-decode-pipeline": "1.0.1", + "elm/browser": "1.0.2", + "elm/core": "1.0.5", + "elm/html": "1.0.1", + "elm/json": "1.1.4", + "elm/svg": "1.0.1", + "elm/time": "1.0.0" + }, + "indirect": { + "elm/url": "1.0.0", + "elm/virtual-dom": "1.0.5" + } + }, + "test-dependencies": { + "direct": { + "elm-explorations/test": "2.2.1" + }, + "indirect": { + "elm/bytes": "1.0.8", + "elm/random": "1.0.0" + } + } +} diff --git a/packages/devtools-extension/eslint.config.mjs b/packages/devtools-extension/eslint.config.mjs new file mode 100644 index 0000000000..42816a85ca --- /dev/null +++ b/packages/devtools-extension/eslint.config.mjs @@ -0,0 +1,7 @@ +import baseConfig from '../../eslint.config.mjs'; + +export default [ + { ignores: ['**/dist', '**/elm-stuff'] }, + ...baseConfig, + { files: ['**/*.ts'], rules: {} }, +]; diff --git a/packages/devtools-extension/manifest.json b/packages/devtools-extension/manifest.json new file mode 100644 index 0000000000..6620f9698a --- /dev/null +++ b/packages/devtools-extension/manifest.json @@ -0,0 +1,29 @@ +{ + "manifest_version": 3, + "name": "Ping DevTools", + "version": "0.1.0", + "description": "Debug ForgeRock AM and PingOne auth flows", + "permissions": ["storage", "clipboardWrite", "clipboardRead"], + "host_permissions": [""], + "devtools_page": "devtools.html", + "background": { + "service_worker": "background/service-worker.js", + "type": "module" + }, + "content_scripts": [ + { + "matches": [""], + "js": ["content/content-script.js"], + "run_at": "document_idle", + "world": "MAIN" + }, + { + "matches": [""], + "js": ["content/relay.js"], + "run_at": "document_idle" + } + ], + "action": { + "default_title": "Ping DevTools" + } +} diff --git a/packages/devtools-extension/package.json b/packages/devtools-extension/package.json new file mode 100644 index 0000000000..74996d081a --- /dev/null +++ b/packages/devtools-extension/package.json @@ -0,0 +1,30 @@ +{ + "name": "@forgerock/devtools-extension", + "version": "2.0.0", + "private": true, + "description": "Ping Auth DevTools Chrome Extension", + "license": "MIT", + "author": "ForgeRock", + "repository": { + "type": "git", + "url": "git+https://github.com/ForgeRock/ping-javascript-sdk.git", + "directory": "packages/devtools-extension" + }, + "type": "module", + "scripts": { + "postinstall": "elm-tooling install" + }, + "dependencies": { + "@forgerock/devtools-types": "workspace:*", + "effect": "catalog:effect" + }, + "devDependencies": { + "@types/chrome": "^0.1.40", + "elm-tooling": "^1.15.1", + "esbuild": "^0.28.0", + "terser": "^5.47.1" + }, + "nx": { + "tags": ["scope:devtools-extension"] + } +} diff --git a/packages/devtools-extension/project.json b/packages/devtools-extension/project.json new file mode 100644 index 0000000000..639e4b587a --- /dev/null +++ b/packages/devtools-extension/project.json @@ -0,0 +1,36 @@ +{ + "name": "devtools-extension", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/devtools-extension/src", + "projectType": "application", + "targets": { + "build": { + "executor": "nx:run-commands", + "options": { + "cwd": "packages/devtools-extension", + "commands": [ + "node_modules/.bin/esbuild src/devtools/devtools.ts --bundle --outfile=dist/devtools.js --format=esm --platform=browser", + "node_modules/.bin/esbuild src/panel/panel.ts --bundle --outfile=dist/panel/panel.js --format=esm --platform=browser", + "node_modules/.bin/esbuild src/background/service-worker.ts --bundle --outfile=dist/background/service-worker.js --format=esm --platform=browser --footer:js=\"export {}\"", + "node_modules/.bin/esbuild src/content/content-script.ts --bundle --outfile=dist/content/content-script.js --format=iife --platform=browser", + "node_modules/.bin/esbuild src/content/relay.ts --bundle --outfile=dist/content/relay.js --format=iife --platform=browser", + "mkdir -p dist/panel && node_modules/.bin/elm make src/panel/Main.elm --output=dist/panel/elm.js --optimize", + "node_modules/.bin/terser dist/panel/elm.js --compress 'pure_funcs=[\"F2\",\"F3\",\"F4\",\"F5\",\"F6\",\"F7\",\"F8\",\"F9\",\"A2\",\"A3\",\"A4\",\"A5\",\"A6\",\"A7\",\"A8\",\"A9\"],pure_getters,keep_fargs=false,unsafe_comps,unsafe' --mangle --output dist/panel/elm.js", + "cp manifest.json dist/manifest.json", + "cp src/devtools/devtools.html dist/devtools.html", + "cp src/panel/panel.html dist/panel/panel.html" + ], + "parallel": false + }, + "outputs": ["{projectRoot}/dist"], + "dependsOn": ["^build"] + }, + "test": { + "executor": "@nx/vite:test", + "outputs": ["{projectRoot}/coverage"], + "options": { + "passWithNoTests": true + } + } + } +} diff --git a/packages/devtools-extension/screenshots/Flow-Screen.png b/packages/devtools-extension/screenshots/Flow-Screen.png new file mode 100644 index 0000000000..a061a3fd81 Binary files /dev/null and b/packages/devtools-extension/screenshots/Flow-Screen.png differ diff --git a/packages/devtools-extension/screenshots/Learn-Tab-Error-Screen.png b/packages/devtools-extension/screenshots/Learn-Tab-Error-Screen.png new file mode 100644 index 0000000000..f534cc527f Binary files /dev/null and b/packages/devtools-extension/screenshots/Learn-Tab-Error-Screen.png differ diff --git a/packages/devtools-extension/screenshots/Timeline-Screen.png b/packages/devtools-extension/screenshots/Timeline-Screen.png new file mode 100644 index 0000000000..900257c193 Binary files /dev/null and b/packages/devtools-extension/screenshots/Timeline-Screen.png differ diff --git a/packages/devtools-extension/src/background/diagnosis-engine.test.ts b/packages/devtools-extension/src/background/diagnosis-engine.test.ts new file mode 100644 index 0000000000..18e9e79c9f --- /dev/null +++ b/packages/devtools-extension/src/background/diagnosis-engine.test.ts @@ -0,0 +1,533 @@ +import { describe, it, expect } from 'vitest'; +import { runFlowRules, runEventRules, runDiagnosis } from './diagnosis-engine.js'; +import type { AuthEvent } from '@forgerock/devtools-types'; + +// ─── Fixtures ──────────────────────────────────────────────────────────────── + +const makeNetworkEvent = (overrides: Partial = {}): AuthEvent => ({ + id: 'net-1', + timestamp: 1000, + type: 'network:response', + source: 'network', + flowId: 'flow-1', + causedBy: null, + data: { + _tag: 'network', + url: '/davinci/flows', + method: 'POST', + status: 200, + requestHeaders: { origin: 'http://localhost:3000' }, + responseHeaders: { 'content-type': 'application/json' }, + duration: 100, + }, + flags: { isCors: false, isError: false, isAuthRelated: true }, + ...overrides, +}); + +const makeSdkEvent = (overrides: Partial = {}): AuthEvent => ({ + id: 'sdk-1', + timestamp: 2000, + type: 'sdk:node-change', + source: 'sdk', + flowId: 'flow-1', + causedBy: null, + data: { + _tag: 'sdk', + nodeStatus: 'continue', + interactionId: 'int-abc', + interactionToken: 'tok-xyz', + }, + flags: { isCors: false, isError: false, isAuthRelated: true }, + ...overrides, +}); + +// ─── CORS Rules ─────────────────────────────────────────────────────────────── + +describe('CORS rules', () => { + it('flags status 0 as CORS network failure', () => { + const events = [ + makeNetworkEvent({ + data: { + _tag: 'network', + url: '/davinci/flows', + method: 'POST', + status: 0, + requestHeaders: { origin: 'http://localhost:3000' }, + responseHeaders: {}, + duration: 0, + }, + flags: { isCors: true, isError: true, isAuthRelated: true }, + }), + ]; + const result = runFlowRules(events); + expect(result.some((i) => i.id === 'cors:status-zero')).toBe(true); + const issue = result.find((i) => i.id === 'cors:status-zero')!; + expect(issue.severity).toBe('error'); + expect(issue.category).toBe('cors'); + expect(issue.steps.length).toBeGreaterThan(0); + expect(issue.relatedEventIds).toContain('net-1'); + }); + + it('flags missing access-control-allow-origin when origin was sent', () => { + const events = [ + makeNetworkEvent({ + data: { + _tag: 'network', + url: '/davinci/flows', + method: 'POST', + status: 403, + requestHeaders: { origin: 'http://localhost:3000' }, + responseHeaders: {}, + duration: 50, + }, + flags: { isCors: true, isError: true, isAuthRelated: true }, + }), + ]; + const result = runFlowRules(events); + expect(result.some((i) => i.id === 'cors:missing-allow-origin')).toBe(true); + }); + + it('does not flag missing allow-origin when origin was NOT sent', () => { + const events = [ + makeNetworkEvent({ + data: { + _tag: 'network', + url: '/davinci/flows', + method: 'POST', + status: 403, + requestHeaders: {}, + responseHeaders: {}, + duration: 50, + }, + flags: { isCors: false, isError: true, isAuthRelated: true }, + }), + ]; + const result = runFlowRules(events); + expect(result.some((i) => i.id === 'cors:missing-allow-origin')).toBe(false); + }); + + it('flags wildcard CORS with credentials', () => { + const events = [ + makeNetworkEvent({ + data: { + _tag: 'network', + url: '/davinci/flows', + method: 'POST', + status: 200, + requestHeaders: { origin: 'http://localhost:3000' }, + responseHeaders: { + 'access-control-allow-origin': '*', + 'access-control-allow-credentials': 'true', + }, + duration: 50, + }, + flags: { isCors: true, isError: false, isAuthRelated: true }, + }), + ]; + const result = runFlowRules(events); + expect(result.some((i) => i.id === 'cors:wildcard-with-credentials')).toBe(true); + const issue = result.find((i) => i.id === 'cors:wildcard-with-credentials')!; + expect(issue.severity).toBe('error'); + }); + + it('deduplicates CORS issues — same origin produces one issue', () => { + const events = [ + makeNetworkEvent({ + id: 'net-1', + data: { + _tag: 'network', + url: '/davinci/flows', + method: 'POST', + status: 0, + requestHeaders: { origin: 'http://localhost:3000' }, + responseHeaders: {}, + duration: 0, + }, + flags: { isCors: true, isError: true, isAuthRelated: true }, + }), + makeNetworkEvent({ + id: 'net-2', + data: { + _tag: 'network', + url: '/davinci/flows/step2', + method: 'POST', + status: 0, + requestHeaders: { origin: 'http://localhost:3000' }, + responseHeaders: {}, + duration: 0, + }, + flags: { isCors: true, isError: true, isAuthRelated: true }, + }), + ]; + const result = runFlowRules(events); + const corsStatusZeroIssues = result.filter((i) => i.id === 'cors:status-zero'); + expect(corsStatusZeroIssues.length).toBe(1); + // But both event IDs should be in relatedEventIds + expect(corsStatusZeroIssues[0].relatedEventIds).toContain('net-1'); + expect(corsStatusZeroIssues[0].relatedEventIds).toContain('net-2'); + }); +}); + +// ─── Token / Session Rules ──────────────────────────────────────────────────── + +describe('Token/Session rules', () => { + it('flags interactionToken missing on non-first sdk:node-change', () => { + const events = [ + makeSdkEvent({ + id: 'sdk-1', + data: { + _tag: 'sdk', + nodeStatus: 'continue', + interactionId: 'int-abc', + interactionToken: 'tok-xyz', + }, + }), + makeSdkEvent({ + id: 'sdk-2', + timestamp: 3000, + data: { _tag: 'sdk', nodeStatus: 'continue', interactionId: 'int-abc' }, + }), + ]; + const result = runFlowRules(events); + expect(result.some((i) => i.id === 'token:missing-interaction-token')).toBe(true); + }); + + it('does not flag missing interactionToken on the first sdk:node-change', () => { + const events = [ + makeSdkEvent({ + id: 'sdk-1', + data: { _tag: 'sdk', nodeStatus: 'continue', interactionId: 'int-abc' }, + }), + ]; + const result = runFlowRules(events); + expect(result.some((i) => i.id === 'token:missing-interaction-token')).toBe(false); + }); + + it('flags SESSION_NOT_FOUND error code', () => { + const events = [ + makeSdkEvent({ + id: 'sdk-err', + flags: { isCors: false, isError: true, isAuthRelated: true }, + data: { + _tag: 'sdk', + nodeStatus: 'error', + error: { code: 'SESSION_NOT_FOUND', message: 'Session not found', type: 'SESSION_ERROR' }, + }, + }), + ]; + const result = runFlowRules(events); + expect(result.some((i) => i.id === 'token:session-not-found')).toBe(true); + const issue = result.find((i) => i.id === 'token:session-not-found')!; + expect(issue.severity).toBe('error'); + }); + + it('flags INVALID_SESSION error code', () => { + const events = [ + makeSdkEvent({ + id: 'sdk-err', + flags: { isCors: false, isError: true, isAuthRelated: true }, + data: { + _tag: 'sdk', + nodeStatus: 'error', + error: { code: 'INVALID_SESSION', message: 'Invalid session', type: 'SESSION_ERROR' }, + }, + }), + ]; + const result = runFlowRules(events); + expect(result.some((i) => i.id === 'token:session-not-found')).toBe(true); + }); +}); + +// ─── Flow Config Rules ──────────────────────────────────────────────────────── + +describe('Flow Config rules', () => { + it('flags nodeStatus error', () => { + const events = [ + makeSdkEvent({ + id: 'sdk-err', + flags: { isCors: false, isError: true, isAuthRelated: true }, + data: { _tag: 'sdk', nodeStatus: 'error', nodeName: 'Registration Form' }, + }), + ]; + const result = runFlowRules(events); + expect(result.some((i) => i.id === 'flow:node-error')).toBe(true); + const issue = result.find((i) => i.id === 'flow:node-error')!; + expect(issue.severity).toBe('error'); + expect(issue.title).toContain('Registration Form'); + expect(issue.relatedEventIds).toContain('sdk-err'); + }); + + it('flags nodeStatus failure', () => { + const events = [ + makeSdkEvent({ + id: 'sdk-fail', + flags: { isCors: false, isError: true, isAuthRelated: true }, + data: { _tag: 'sdk', nodeStatus: 'failure' }, + }), + ]; + const result = runFlowRules(events); + expect(result.some((i) => i.id === 'flow:node-error')).toBe(true); + }); + + it('flags CONNECTOR_ERROR', () => { + const events = [ + makeSdkEvent({ + id: 'sdk-conn', + flags: { isCors: false, isError: true, isAuthRelated: true }, + data: { + _tag: 'sdk', + nodeStatus: 'error', + error: { + code: 'CONNECTOR_ERROR', + message: 'Connector failed', + type: 'CONNECTOR', + internalHttpStatus: 400, + }, + }, + }), + ]; + const result = runFlowRules(events); + expect(result.some((i) => i.id === 'flow:connector-error')).toBe(true); + const issue = result.find((i) => i.id === 'flow:connector-error')!; + expect(issue.title).toContain('400'); + }); + + it('flags NOT_FOUND error code', () => { + const events = [ + makeSdkEvent({ + id: 'sdk-nf', + flags: { isCors: false, isError: true, isAuthRelated: true }, + data: { + _tag: 'sdk', + nodeStatus: 'error', + error: { code: 'NOT_FOUND', message: 'Policy not found', type: 'NOT_FOUND' }, + }, + }), + ]; + const result = runFlowRules(events); + expect(result.some((i) => i.id === 'flow:policy-not-found')).toBe(true); + }); +}); + +// ─── OIDC Rules ─────────────────────────────────────────────────────────────── + +describe('OIDC rules', () => { + it('flags state_mismatch in redirect URI', () => { + const events = [ + makeNetworkEvent({ + id: 'oidc-1', + type: 'dom:redirect', + source: 'dom', + data: { + _tag: 'dom', + url: 'https://app.example.com/callback?error=state_mismatch', + }, + flags: { isCors: false, isError: true, isAuthRelated: true }, + }), + ]; + const result = runFlowRules(events); + expect(result.some((i) => i.id === 'oidc:state-mismatch')).toBe(true); + const issue = result.find((i) => i.id === 'oidc:state-mismatch')!; + expect(issue.severity).toBe('error'); + }); + + it('flags PKCE challenge missing', () => { + const events = [ + makeNetworkEvent({ + id: 'oidc-2', + type: 'dom:redirect', + source: 'dom', + data: { + _tag: 'dom', + url: 'https://app.example.com/callback?error=invalid_request&error_description=code_challenge+missing', + }, + flags: { isCors: false, isError: true, isAuthRelated: true }, + }), + ]; + const result = runFlowRules(events); + expect(result.some((i) => i.id === 'oidc:pkce-missing')).toBe(true); + }); +}); + +// ─── runEventRules ──────────────────────────────────────────────────────────── + +describe('runEventRules', () => { + it('returns empty array for a clean network event', () => { + const event = makeNetworkEvent(); + const result = runEventRules(event, [event]); + expect(result).toEqual([]); + }); + + it('annotates status-0 network event', () => { + const event = makeNetworkEvent({ + data: { + _tag: 'network', + url: '/davinci/flows', + method: 'POST', + status: 0, + requestHeaders: { origin: 'http://localhost:3000' }, + responseHeaders: {}, + duration: 0, + }, + flags: { isCors: true, isError: true, isAuthRelated: true }, + }); + const result = runEventRules(event, [event]); + expect(result.length).toBeGreaterThan(0); + expect(result[0].severity).toBe('error'); + }); + + it('annotates sdk:node-change with error status', () => { + const event = makeSdkEvent({ + flags: { isCors: false, isError: true, isAuthRelated: true }, + data: { _tag: 'sdk', nodeStatus: 'error', nodeName: 'Login Form' }, + }); + const result = runEventRules(event, [event]); + expect(result.some((i) => i.title.includes('Node error'))).toBe(true); + }); +}); + +// ─── runDiagnosis (integration) ─────────────────────────────────────────────── + +describe('runDiagnosis', () => { + it('returns healthy when no issues', () => { + const events = [makeNetworkEvent(), makeSdkEvent()]; + const result = runDiagnosis(events); + expect(result.flowHealth).toBe('healthy'); + expect(result.issues).toHaveLength(0); + }); + + it('returns error health when error issues present', () => { + const events = [ + makeNetworkEvent({ + data: { + _tag: 'network', + url: '/davinci/flows', + method: 'POST', + status: 0, + requestHeaders: { origin: 'http://localhost:3000' }, + responseHeaders: {}, + duration: 0, + }, + flags: { isCors: true, isError: true, isAuthRelated: true }, + }), + ]; + const result = runDiagnosis(events); + expect(result.flowHealth).toBe('error'); + }); + + it('returns warning health when only warning issues present', () => { + const events = [ + makeNetworkEvent({ + id: 'net-warn', + data: { + _tag: 'network', + url: '/davinci/flows', + method: 'POST', + status: 200, + // cookie in request triggers credentials-not-allowed check + requestHeaders: { origin: 'http://localhost:3000', cookie: 'session=abc' }, + responseHeaders: { + // include allow-origin so cors:missing-allow-origin error doesn't fire + 'access-control-allow-origin': 'http://localhost:3000', + 'access-control-allow-credentials': 'false', + }, + duration: 50, + }, + flags: { isCors: true, isError: false, isAuthRelated: true }, + }), + ]; + const result = runDiagnosis(events); + // credentials not allowed warning + expect(['warning', 'healthy']).toContain(result.flowHealth); + }); + + it('populates annotatedEvents for affected events', () => { + const event = makeNetworkEvent({ + data: { + _tag: 'network', + url: '/davinci/flows', + method: 'POST', + status: 0, + requestHeaders: { origin: 'http://localhost:3000' }, + responseHeaders: {}, + duration: 0, + }, + flags: { isCors: true, isError: true, isAuthRelated: true }, + }); + const result = runDiagnosis([event]); + expect(result.annotatedEvents.has('net-1')).toBe(true); + }); + + it('issues are ordered: error before warning before info', () => { + const events = [ + makeNetworkEvent({ + id: 'net-1', + data: { + _tag: 'network', + url: '/davinci/flows', + method: 'POST', + status: 0, + requestHeaders: { origin: 'http://localhost:3000' }, + responseHeaders: {}, + duration: 0, + }, + flags: { isCors: true, isError: true, isAuthRelated: true }, + }), + makeSdkEvent({ + id: 'sdk-1', + flags: { isCors: false, isError: true, isAuthRelated: true }, + data: { _tag: 'sdk', nodeStatus: 'error' }, + }), + ]; + const result = runDiagnosis(events); + const severities = result.issues.map((i) => i.severity); + // All errors should come before warnings + const firstWarningIdx = severities.indexOf('warning'); + const lastErrorIdx = severities.lastIndexOf('error'); + if (firstWarningIdx !== -1 && lastErrorIdx !== -1) { + expect(lastErrorIdx).toBeLessThan(firstWarningIdx); + } + }); +}); + +// ─── Expired JWT via runEventRules ──────────────────────────────────────────── + +describe('expired JWT detection in runEventRules', () => { + const makeExpiredJwt = () => { + // header: {"alg":"RS256","typ":"JWT"} + const header = btoa(JSON.stringify({ alg: 'RS256', typ: 'JWT' })) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); + // payload: {"sub":"user","exp": 1 (way in the past)} + const payload = btoa(JSON.stringify({ sub: 'user', exp: 1 })) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); + return `${header}.${payload}.fakesig`; + }; + + it('flags expired JWT in authorization header', () => { + const event = makeNetworkEvent({ + data: { + _tag: 'network', + url: '/davinci/flows', + method: 'POST', + status: 401, + requestHeaders: { authorization: `Bearer ${makeExpiredJwt()}` }, + responseHeaders: {}, + duration: 50, + }, + flags: { isCors: false, isError: true, isAuthRelated: true }, + }); + const result = runEventRules(event, [event]); + expect( + result.some( + (i) => + i.title.includes('expired') || + i.title.includes('Expired') || + i.title.toLowerCase().includes('token'), + ), + ).toBe(true); + }); +}); diff --git a/packages/devtools-extension/src/background/diagnosis-engine.ts b/packages/devtools-extension/src/background/diagnosis-engine.ts new file mode 100644 index 0000000000..20f3dc35e3 --- /dev/null +++ b/packages/devtools-extension/src/background/diagnosis-engine.ts @@ -0,0 +1,507 @@ +import type { AuthEvent } from '@forgerock/devtools-types'; + +export type Severity = 'error' | 'warning' | 'info'; + +export interface FlowIssue { + id: string; + severity: Severity; + category: 'cors' | 'token' | 'flow-config' | 'oidc'; + title: string; + description: string; + steps: string[]; + relatedEventIds: string[]; + relevantData?: Record; +} + +export interface EventIssue { + severity: Severity; + title: string; + description: string; + steps: string[]; + relevantData?: Record; +} + +export interface DiagnosisResult { + issues: FlowIssue[]; + annotatedEvents: Map; + flowHealth: 'healthy' | 'warning' | 'error'; +} + +// ─── JWT helpers ────────────────────────────────────────────────────────────── + +const JWT_PATTERN = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/; + +function decodeJwtPayload(token: string): Record | null { + const parts = token.split('.'); + if (parts.length !== 3) return null; + try { + const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + return JSON.parse(atob(b64)) as Record; + } catch { + return null; + } +} + +function extractJwt(value: string): string | null { + const bearer = value.match(/^Bearer\s+([A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)$/i); + if (bearer) return bearer[1]; + if (JWT_PATTERN.test(value)) return value; + return null; +} + +function findExpiredJwtsInHeaders(headers: Record): string[] { + const expired: string[] = []; + for (const value of Object.values(headers)) { + const token = extractJwt(value); + if (!token) continue; + const payload = decodeJwtPayload(token); + if (payload && typeof payload['exp'] === 'number' && payload['exp'] * 1000 < Date.now()) { + expired.push(token); + } + } + return expired; +} + +// ─── Deduplication helper ───────────────────────────────────────────────────── + +type IssueCandidate = { + dedupKey: string; + eventId: string; + issue: Omit; +}; + +function mergeByDedupKey(candidates: IssueCandidate[]): FlowIssue[] { + const merged = new Map(); + for (const { dedupKey, eventId, issue } of candidates) { + const existing = merged.get(dedupKey); + if (existing) { + merged.set(dedupKey, { + ...existing, + relatedEventIds: [...existing.relatedEventIds, eventId], + }); + } else { + merged.set(dedupKey, { ...issue, relatedEventIds: [eventId] }); + } + } + return [...merged.values()]; +} + +// ─── CORS rules ─────────────────────────────────────────────────────────────── + +function collectCorsIssues(events: readonly AuthEvent[]): IssueCandidate[] { + const candidates: IssueCandidate[] = []; + + for (const event of events) { + if (event.data._tag !== 'network') continue; + const { data } = event; + const origin = data.requestHeaders['origin'] ?? ''; + const allowOrigin = data.responseHeaders['access-control-allow-origin'] ?? ''; + const allowCredentials = data.responseHeaders['access-control-allow-credentials'] ?? ''; + const hasOriginHeader = 'origin' in data.requestHeaders; + + if (data.status === 0 && event.flags.isCors) { + candidates.push({ + dedupKey: `cors:status-zero:${origin}`, + eventId: event.id, + issue: { + id: 'cors:status-zero', + severity: 'error', + category: 'cors', + title: 'Network failure (status 0)', + description: + 'The request never reached the server. This is almost always a CORS preflight rejection.', + steps: [ + `Your auth server must include this origin in allowed origins: ${origin || '(unknown)'}`, + 'Check the OPTIONS preflight request in the Network tab.', + 'If using credentials, wildcard (*) is not allowed as the allowed origin.', + ], + relevantData: origin ? { origin } : undefined, + }, + }); + } + + if (hasOriginHeader && !allowOrigin && data.status !== 0 && event.flags.isCors) { + candidates.push({ + dedupKey: `cors:missing-allow-origin:${origin}`, + eventId: event.id, + issue: { + id: 'cors:missing-allow-origin', + severity: 'error', + category: 'cors', + title: 'Missing CORS header', + description: 'The server response is missing Access-Control-Allow-Origin.', + steps: [ + `Add ${origin} to allowed origins on your auth server.`, + 'Verify the request origin matches what is configured in your AS CORS settings.', + ], + relevantData: { 'missing-header': 'access-control-allow-origin', origin }, + }, + }); + } + + if (allowOrigin === '*' && allowCredentials === 'true') { + candidates.push({ + dedupKey: `cors:wildcard-with-credentials:${data.url}`, + eventId: event.id, + issue: { + id: 'cors:wildcard-with-credentials', + severity: 'error', + category: 'cors', + title: 'Wildcard CORS with credentials', + description: + 'access-control-allow-origin: * cannot be used together with access-control-allow-credentials: true.', + steps: [ + `Replace wildcard with an explicit origin: ${origin || '(your app origin)'}`, + 'Configure your auth server to reflect the specific requesting origin.', + ], + relevantData: { + 'access-control-allow-origin': '*', + 'access-control-allow-credentials': 'true', + }, + }, + }); + } + + if ( + hasOriginHeader && + allowCredentials === 'false' && + data.requestHeaders['cookie'] !== undefined + ) { + candidates.push({ + dedupKey: `cors:credentials-not-allowed:${origin}`, + eventId: event.id, + issue: { + id: 'cors:credentials-not-allowed', + severity: 'warning', + category: 'cors', + title: 'Credentials not allowed by server', + description: + 'The server set access-control-allow-credentials: false but cookies were sent.', + steps: [ + 'Enable credentials on the auth server CORS config.', + 'Or remove the cookie from the request.', + ], + }, + }); + } + } + + return candidates; +} + +// ─── Token / Session rules ──────────────────────────────────────────────────── + +function collectTokenIssues(events: readonly AuthEvent[]): IssueCandidate[] { + const candidates: IssueCandidate[] = []; + + const sdkNodeEvents = events.filter((e) => e.type === 'sdk:node-change'); + + // Missing interactionToken on non-first sdk:node-change + if (sdkNodeEvents.length > 1) { + for (const event of sdkNodeEvents.slice(1)) { + if (event.data._tag !== 'sdk') continue; + if (!event.data.interactionToken) { + candidates.push({ + dedupKey: `token:missing-interaction-token:${event.id}`, + eventId: event.id, + issue: { + id: 'token:missing-interaction-token', + severity: 'warning', + category: 'token', + title: 'Missing interaction token', + description: 'interactionToken was absent on a node transition that required it.', + steps: [ + 'Check SDK initialization — do not cache or reuse stale tokens across flows.', + 'Ensure each flow starts fresh rather than resuming an expired interaction.', + ], + }, + }); + } + } + } + + // Session error codes + for (const event of events) { + if (event.data._tag !== 'sdk') continue; + const errorCode = event.data.error?.code ?? ''; + if (errorCode.includes('SESSION_NOT_FOUND') || errorCode.includes('INVALID_SESSION')) { + candidates.push({ + dedupKey: `token:session-not-found`, + eventId: event.id, + issue: { + id: 'token:session-not-found', + severity: 'error', + category: 'token', + title: 'Session not found', + description: 'The session referenced by this flow no longer exists on the server.', + steps: [ + 'Session may have expired — reinitialize the SDK.', + 'Avoid persisting flowId or interactionId across page reloads without validation.', + ], + relevantData: { 'error-code': errorCode }, + }, + }); + } + } + + return candidates; +} + +// ─── Flow Config rules ──────────────────────────────────────────────────────── + +function collectFlowConfigIssues(events: readonly AuthEvent[]): IssueCandidate[] { + const candidates: IssueCandidate[] = []; + + for (const event of events) { + if (event.data._tag !== 'sdk') continue; + const { data } = event; + const { nodeStatus } = data; + const errorCode = data.error?.code ?? ''; + + if (nodeStatus === 'error' || nodeStatus === 'failure') { + const nodeName = data.nodeName ?? ''; + candidates.push({ + dedupKey: `flow:node-error:${event.id}`, + eventId: event.id, + issue: { + id: 'flow:node-error', + severity: 'error', + category: 'flow-config', + title: nodeName ? `Node error: ${nodeName}` : 'Node error', + description: `A DaVinci node returned status "${nodeStatus}".`, + steps: [ + 'Check connector configuration in DaVinci admin.', + 'Review the error code in the SDK State tab.', + ], + relevantData: nodeName ? { node: nodeName, status: nodeStatus } : { status: nodeStatus }, + }, + }); + } + + if (errorCode === 'CONNECTOR_ERROR') { + const httpStatus = data.error?.internalHttpStatus; + candidates.push({ + dedupKey: `flow:connector-error:${event.id}`, + eventId: event.id, + issue: { + id: 'flow:connector-error', + severity: 'error', + category: 'flow-config', + title: httpStatus ? `Connector error (HTTP ${httpStatus})` : 'Connector error', + description: 'A DaVinci connector returned an HTTP error from its upstream endpoint.', + steps: [ + 'Verify connector credentials and endpoint URL in DaVinci admin.', + 'Check the upstream service is reachable from your DaVinci environment.', + ], + relevantData: httpStatus ? { 'internal-http-status': String(httpStatus) } : undefined, + }, + }); + } + + if (errorCode === 'NOT_FOUND') { + candidates.push({ + dedupKey: `flow:policy-not-found`, + eventId: event.id, + issue: { + id: 'flow:policy-not-found', + severity: 'error', + category: 'flow-config', + title: 'Flow policy not found', + description: 'The policy ID used to start this flow does not exist in the environment.', + steps: [ + 'Verify the policy ID (acr_values or flowId) matches your DaVinci environment.', + 'Check that the policy is published and assigned to the correct application.', + ], + }, + }); + } + } + + return candidates; +} + +// ─── OIDC rules ─────────────────────────────────────────────────────────────── + +function collectOidcIssues(events: readonly AuthEvent[]): IssueCandidate[] { + const candidates: IssueCandidate[] = []; + + for (const event of events) { + if (event.data._tag !== 'dom') continue; + const url = event.data.url ?? ''; + + if (url.includes('error=state_mismatch')) { + candidates.push({ + dedupKey: `oidc:state-mismatch`, + eventId: event.id, + issue: { + id: 'oidc:state-mismatch', + severity: 'error', + category: 'oidc', + title: 'State mismatch', + description: + 'The OAuth state parameter in the callback does not match the one sent in the authorization request.', + steps: [ + 'Do not share auth state across tabs.', + 'Check your PKCE/state implementation for race conditions.', + 'Ensure the state is stored and compared correctly on the callback.', + ], + }, + }); + } + + if (url.includes('error=invalid_request') && url.includes('code_challenge')) { + candidates.push({ + dedupKey: `oidc:pkce-missing`, + eventId: event.id, + issue: { + id: 'oidc:pkce-missing', + severity: 'error', + category: 'oidc', + title: 'PKCE challenge missing', + description: 'The authorization request was missing the required PKCE code_challenge.', + steps: [ + 'Ensure the SDK is configured with PKCE enabled.', + 'Verify the client application requires PKCE in your AS client configuration.', + ], + }, + }); + } + + if (url.includes('error=invalid_request') && url.includes('redirect_uri')) { + candidates.push({ + dedupKey: `oidc:redirect-uri-mismatch`, + eventId: event.id, + issue: { + id: 'oidc:redirect-uri-mismatch', + severity: 'error', + category: 'oidc', + title: 'Redirect URI mismatch', + description: + 'The redirect URI in the request does not match any URI registered in the AS client.', + steps: [ + 'Register the exact redirect URI used by your app in the AS client configuration.', + 'Ensure no trailing slashes or protocol mismatches.', + ], + }, + }); + } + } + + return candidates; +} + +// ─── Public API ─────────────────────────────────────────────────────────────── + +const SEVERITY_ORDER: Record = { error: 0, warning: 1, info: 2 }; + +export function runFlowRules(events: readonly AuthEvent[]): FlowIssue[] { + const candidates: IssueCandidate[] = [ + ...collectCorsIssues(events), + ...collectTokenIssues(events), + ...collectFlowConfigIssues(events), + ...collectOidcIssues(events), + ]; + + return mergeByDedupKey(candidates).sort( + (a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity], + ); +} + +export function runEventRules(event: AuthEvent, allEvents: readonly AuthEvent[]): EventIssue[] { + const issues: EventIssue[] = []; + + if (event.data._tag === 'network') { + const { data } = event; + + if (data.status === 0 && event.flags.isCors) { + const origin = data.requestHeaders['origin'] ?? ''; + issues.push({ + severity: 'error', + title: 'Network failure (status 0)', + description: + 'The request never reached the server. This is almost always a CORS preflight rejection.', + steps: [ + `Your AS must include this origin in allowed origins: ${origin || '(unknown)'}`, + 'If using credentials, wildcard (*) is not allowed.', + 'Check the OPTIONS preflight in the Network tab.', + ], + relevantData: { + 'access-control-allow-origin': + data.responseHeaders['access-control-allow-origin'] ?? '(not present)', + 'access-control-allow-credentials': + data.responseHeaders['access-control-allow-credentials'] ?? '(not present)', + }, + }); + } + + // Expired JWT in request headers + const expiredJwts = findExpiredJwtsInHeaders(data.requestHeaders); + for (const token of expiredJwts) { + const payload = decodeJwtPayload(token); + const exp = payload && typeof payload['exp'] === 'number' ? payload['exp'] : null; + issues.push({ + severity: 'error', + title: 'Token expired', + description: 'A JWT in the request headers has an expired exp claim.', + steps: ['Restart the flow to obtain a fresh token.', 'Check your SDK token refresh logic.'], + relevantData: exp ? { exp: new Date(exp * 1000).toISOString() } : undefined, + }); + } + + const hasOriginHeader = 'origin' in data.requestHeaders; + const allowOrigin = data.responseHeaders['access-control-allow-origin'] ?? ''; + if (hasOriginHeader && !allowOrigin && data.status !== 0 && event.flags.isCors) { + issues.push({ + severity: 'error', + title: 'Missing CORS header', + description: 'The server response is missing Access-Control-Allow-Origin.', + steps: [`Add ${data.requestHeaders['origin']} to allowed origins on your auth server.`], + relevantData: { 'missing-header': 'access-control-allow-origin' }, + }); + } + } + + if (event.data._tag === 'sdk') { + const { data } = event; + if (data.nodeStatus === 'error' || data.nodeStatus === 'failure') { + const nodeName = data.nodeName ?? ''; + issues.push({ + severity: 'error', + title: nodeName ? `Node error: ${nodeName}` : 'Node error', + description: `Node returned status "${data.nodeStatus}".`, + steps: [ + 'Check DaVinci connector configuration.', + 'Review the error code in the SDK State tab.', + ], + relevantData: data.error + ? { code: data.error.code, message: data.error.message } + : undefined, + }); + } + } + + // Suppress unused-parameter warning — allEvents available for future cross-event per-event rules + void allEvents; + + return issues; +} + +export function runDiagnosis(events: readonly AuthEvent[]): DiagnosisResult { + const issues = runFlowRules(events); + + const annotatedEvents = new Map(); + for (const event of events) { + const eventIssues = runEventRules(event, events); + if (eventIssues.length > 0) { + annotatedEvents.set(event.id, eventIssues); + } + } + + const flowHealth = issues.some((i) => i.severity === 'error') + ? 'error' + : issues.some((i) => i.severity === 'warning') + ? 'warning' + : 'healthy'; + + return { issues, annotatedEvents, flowHealth }; +} diff --git a/packages/devtools-extension/src/background/event-store.service.test.ts b/packages/devtools-extension/src/background/event-store.service.test.ts new file mode 100644 index 0000000000..38f5c20535 --- /dev/null +++ b/packages/devtools-extension/src/background/event-store.service.test.ts @@ -0,0 +1,307 @@ +import { describe, it, expect } from 'vitest'; +import { Effect } from 'effect'; +import { EventStoreService, EventStoreLive, makeEmptyFlowState } from './event-store.service.js'; +import type { AuthEvent } from '@forgerock/devtools-types'; + +const makeEvent = (overrides: Partial = {}): AuthEvent => ({ + id: 'e1', + timestamp: 100, + type: 'network:response', + source: 'network', + flowId: null, + causedBy: null, + data: { + _tag: 'network', + url: '/authorize', + method: 'POST', + status: 200, + requestHeaders: {}, + responseHeaders: {}, + duration: 50, + }, + flags: { isCors: false, isError: false, isAuthRelated: true }, + ...overrides, +}); + +describe('EventStoreService', () => { + it('appends events to state', async () => { + const program = Effect.gen(function* () { + const store = yield* EventStoreService; + yield* store.append(makeEvent()); + yield* store.append(makeEvent({ id: 'e2' })); + return yield* store.getState(); + }); + + const state = await Effect.runPromise(Effect.provide(program, EventStoreLive)); + + expect(state.events).toHaveLength(2); + expect(state.events[0].id).toBe('e1'); + }); + + it('increments errorCount for error events', async () => { + const program = Effect.gen(function* () { + const store = yield* EventStoreService; + yield* store.append( + makeEvent({ flags: { isCors: false, isError: true, isAuthRelated: true } }), + ); + return yield* store.getState(); + }); + + const state = await Effect.runPromise(Effect.provide(program, EventStoreLive)); + expect(state.summary.errorCount).toBe(1); + }); + + it('clears state', async () => { + const program = Effect.gen(function* () { + const store = yield* EventStoreService; + yield* store.append(makeEvent()); + yield* store.clear(); + return yield* store.getState(); + }); + + const state = await Effect.runPromise(Effect.provide(program, EventStoreLive)); + expect(state.events).toHaveLength(0); + }); +}); + +describe('lastSdkEventId tracking', () => { + it('sets lastSdkEventId when an sdk:node-change event is appended', async () => { + const program = Effect.gen(function* () { + const store = yield* EventStoreService; + yield* store.append( + makeEvent({ + id: 'sdk-1', + type: 'sdk:node-change', + source: 'sdk', + data: { _tag: 'sdk', nodeStatus: 'continue' }, + }), + ); + return yield* store.getState(); + }); + const state = await Effect.runPromise(Effect.provide(program, EventStoreLive)); + expect(state.lastSdkEventId).toBe('sdk-1'); + }); + + it('does not update lastSdkEventId for network events', async () => { + const program = Effect.gen(function* () { + const store = yield* EventStoreService; + yield* store.append(makeEvent({ id: 'net-1' })); + return yield* store.getState(); + }); + const state = await Effect.runPromise(Effect.provide(program, EventStoreLive)); + expect(state.lastSdkEventId).toBeNull(); + }); + + it('updates lastSdkEventId to the most recent sdk event', async () => { + const program = Effect.gen(function* () { + const store = yield* EventStoreService; + yield* store.append( + makeEvent({ + id: 'sdk-1', + type: 'sdk:node-change', + source: 'sdk', + data: { _tag: 'sdk', nodeStatus: 'continue' }, + }), + ); + yield* store.append( + makeEvent({ + id: 'sdk-2', + type: 'sdk:node-change', + source: 'sdk', + data: { _tag: 'sdk', nodeStatus: 'success' }, + }), + ); + return yield* store.getState(); + }); + const state = await Effect.runPromise(Effect.provide(program, EventStoreLive)); + expect(state.lastSdkEventId).toBe('sdk-2'); + }); +}); + +describe('updateSummary (via append)', () => { + it('increments nodeCount for sdk:node-change events', async () => { + const program = Effect.gen(function* () { + const store = yield* EventStoreService; + yield* store.append( + makeEvent({ + id: 'sdk-1', + type: 'sdk:node-change', + source: 'sdk', + data: { _tag: 'sdk', nodeStatus: 'continue' }, + }), + ); + yield* store.append( + makeEvent({ + id: 'sdk-2', + type: 'sdk:node-change', + source: 'sdk', + data: { _tag: 'sdk', nodeStatus: 'success' }, + }), + ); + return yield* store.getState(); + }); + const state = await Effect.runPromise(Effect.provide(program, EventStoreLive)); + expect(state.summary.nodeCount).toBe(2); + }); + + it('does not increment nodeCount for non-node-change events', async () => { + const program = Effect.gen(function* () { + const store = yield* EventStoreService; + yield* store.append(makeEvent({ id: 'net-1' })); + yield* store.append( + makeEvent({ + id: 'j-1', + type: 'sdk:journey-step', + source: 'sdk', + data: { _tag: 'journey', stepType: 'Step' }, + }), + ); + return yield* store.getState(); + }); + const state = await Effect.runPromise(Effect.provide(program, EventStoreLive)); + expect(state.summary.nodeCount).toBe(0); + }); + + it('sets sdkConnected to true after an sdk:node-change event', async () => { + const program = Effect.gen(function* () { + const store = yield* EventStoreService; + const before = yield* store.getState(); + yield* store.append( + makeEvent({ + id: 'sdk-1', + type: 'sdk:node-change', + source: 'sdk', + data: { _tag: 'sdk', nodeStatus: 'continue' }, + }), + ); + const after = yield* store.getState(); + return { before: before.summary.sdkConnected, after: after.summary.sdkConnected }; + }); + const result = await Effect.runPromise(Effect.provide(program, EventStoreLive)); + expect(result.before).toBe(false); + expect(result.after).toBe(true); + }); + + it('accumulates corsFlags from CORS network events', async () => { + const program = Effect.gen(function* () { + const store = yield* EventStoreService; + yield* store.append( + makeEvent({ + id: 'cors-1', + flags: { isCors: true, isError: true, isAuthRelated: true }, + data: { + _tag: 'network', + url: 'https://auth.example.com/token', + method: 'POST', + status: 0, + requestHeaders: {}, + responseHeaders: {}, + duration: 0, + corsFlag: { + url: 'https://auth.example.com/token', + reason: 'status-zero', + method: 'POST', + }, + }, + }), + ); + yield* store.append( + makeEvent({ + id: 'cors-2', + flags: { isCors: true, isError: true, isAuthRelated: true }, + data: { + _tag: 'network', + url: 'https://auth.example.com/authorize', + method: 'GET', + status: 200, + requestHeaders: { origin: 'https://app.example.com' }, + responseHeaders: {}, + duration: 50, + corsFlag: { + url: 'https://auth.example.com/authorize', + reason: 'missing-allow-origin', + method: 'GET', + }, + }, + }), + ); + return yield* store.getState(); + }); + const state = await Effect.runPromise(Effect.provide(program, EventStoreLive)); + expect(state.summary.corsFlags).toHaveLength(2); + expect(state.summary.corsFlags[0].reason).toBe('status-zero'); + expect(state.summary.corsFlags[1].reason).toBe('missing-allow-origin'); + }); + + it('does not add corsFlag for non-cors events', async () => { + const program = Effect.gen(function* () { + const store = yield* EventStoreService; + yield* store.append(makeEvent({ id: 'net-1' })); + return yield* store.getState(); + }); + const state = await Effect.runPromise(Effect.provide(program, EventStoreLive)); + expect(state.summary.corsFlags).toHaveLength(0); + }); + + it('calculates duration as max - min timestamp', async () => { + const program = Effect.gen(function* () { + const store = yield* EventStoreService; + yield* store.append(makeEvent({ id: 'e1', timestamp: 1000 })); + yield* store.append(makeEvent({ id: 'e2', timestamp: 1500 })); + yield* store.append(makeEvent({ id: 'e3', timestamp: 3000 })); + return yield* store.getState(); + }); + const state = await Effect.runPromise(Effect.provide(program, EventStoreLive)); + expect(state.summary.duration).toBe(2000); + }); + + it('duration is 0 for a single event', async () => { + const program = Effect.gen(function* () { + const store = yield* EventStoreService; + yield* store.append(makeEvent({ id: 'e1', timestamp: 1000 })); + return yield* store.getState(); + }); + const state = await Effect.runPromise(Effect.provide(program, EventStoreLive)); + expect(state.summary.duration).toBe(0); + }); + + it('sets flowId from first event with a non-null flowId', async () => { + const program = Effect.gen(function* () { + const store = yield* EventStoreService; + yield* store.append(makeEvent({ id: 'e1', flowId: null })); + yield* store.append(makeEvent({ id: 'e2', flowId: 'flow-abc' })); + return yield* store.getState(); + }); + const state = await Effect.runPromise(Effect.provide(program, EventStoreLive)); + expect(state.flowId).toBe('flow-abc'); + }); + + it('does not overwrite flowId once set', async () => { + const program = Effect.gen(function* () { + const store = yield* EventStoreService; + yield* store.append(makeEvent({ id: 'e1', flowId: 'flow-1' })); + yield* store.append(makeEvent({ id: 'e2', flowId: 'flow-2' })); + return yield* store.getState(); + }); + const state = await Effect.runPromise(Effect.provide(program, EventStoreLive)); + expect(state.flowId).toBe('flow-1'); + }); + + it('counts multiple error events', async () => { + const program = Effect.gen(function* () { + const store = yield* EventStoreService; + yield* store.append( + makeEvent({ id: 'e1', flags: { isCors: false, isError: true, isAuthRelated: true } }), + ); + yield* store.append( + makeEvent({ id: 'e2', flags: { isCors: false, isError: true, isAuthRelated: true } }), + ); + yield* store.append( + makeEvent({ id: 'e3', flags: { isCors: false, isError: false, isAuthRelated: true } }), + ); + return yield* store.getState(); + }); + const state = await Effect.runPromise(Effect.provide(program, EventStoreLive)); + expect(state.summary.errorCount).toBe(2); + }); +}); diff --git a/packages/devtools-extension/src/background/event-store.service.ts b/packages/devtools-extension/src/background/event-store.service.ts new file mode 100644 index 0000000000..a1d913da19 --- /dev/null +++ b/packages/devtools-extension/src/background/event-store.service.ts @@ -0,0 +1,78 @@ +import { Context, Effect, Layer, Ref, pipe } from 'effect'; +import type { AuthEvent, FlowState } from '@forgerock/devtools-types'; + +export function makeEmptyFlowState(): FlowState { + return { + flowId: null, + capturedAt: new Date().toISOString(), + events: [], + summary: { nodeCount: 0, errorCount: 0, corsFlags: [], duration: 0, sdkConnected: false }, + lastSdkEventId: null, + }; +} + +function updateSummary(state: FlowState, event: AuthEvent): FlowState { + const summary = { ...state.summary }; + + if (event.flags.isError) summary.errorCount += 1; + if (event.type === 'sdk:node-change') { + summary.nodeCount += 1; + summary.sdkConnected = true; + } + if (event.flags.isCors && event.data._tag === 'network' && event.data.corsFlag) { + summary.corsFlags = [...summary.corsFlags, event.data.corsFlag]; + } + + const timestamps = [...state.events, event].map((e) => e.timestamp); + summary.duration = timestamps.length > 1 ? Math.max(...timestamps) - Math.min(...timestamps) : 0; + + return { + ...state, + flowId: state.flowId ?? event.flowId, + events: [...state.events, event], + summary, + lastSdkEventId: event.type === 'sdk:node-change' ? event.id : state.lastSdkEventId, + }; +} + +export interface EventStoreServiceShape { + append: (event: AuthEvent) => Effect.Effect; + getState: () => Effect.Effect; + clear: () => Effect.Effect; + persist: () => Effect.Effect; + rehydrate: () => Effect.Effect; +} + +export class EventStoreService extends Context.Tag('EventStoreService')< + EventStoreService, + EventStoreServiceShape +>() {} + +export const EventStoreLive = Layer.effect( + EventStoreService, + pipe( + Ref.make(makeEmptyFlowState()), + Effect.map((stateRef) => ({ + append: (event: AuthEvent) => Ref.update(stateRef, (s) => updateSummary(s, event)), + getState: () => Ref.get(stateRef), + clear: () => Ref.set(stateRef, makeEmptyFlowState()), + persist: () => + pipe( + Ref.get(stateRef), + Effect.flatMap((state) => + Effect.tryPromise(() => chrome.storage.local.set({ 'ping:auth-flow': state })), + ), + Effect.orDie, + ), + rehydrate: () => + pipe( + Effect.tryPromise(() => chrome.storage.local.get('ping:auth-flow')), + Effect.orDie, + Effect.flatMap((result) => { + const stored = result['ping:auth-flow'] as FlowState | undefined; + return stored ? Ref.set(stateRef, stored) : Effect.void; + }), + ), + })), + ), +); diff --git a/packages/devtools-extension/src/background/message-handler.test.ts b/packages/devtools-extension/src/background/message-handler.test.ts new file mode 100644 index 0000000000..e9f71c871c --- /dev/null +++ b/packages/devtools-extension/src/background/message-handler.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, vi } from 'vitest'; +import { Effect, Ref, pipe } from 'effect'; +import { handleMessage } from './message-handler.js'; +import { EventStoreService, makeEmptyFlowState } from './event-store.service.js'; +import type { AuthEvent, FlowState } from '@forgerock/devtools-types'; +import { Layer } from 'effect'; + +// A test-only Layer that replaces persist/rehydrate with no-ops (no chrome.storage) +const TestStoreLive = Layer.effect( + EventStoreService, + pipe( + Ref.make(makeEmptyFlowState()), + Effect.map((stateRef) => ({ + append: (event: AuthEvent) => + Ref.update(stateRef, (s) => ({ + ...s, + events: [...s.events, event], + flowId: s.flowId ?? event.flowId, + lastSdkEventId: event.type === 'sdk:node-change' ? event.id : s.lastSdkEventId, + })), + getState: () => Ref.get(stateRef), + clear: () => Ref.set(stateRef, makeEmptyFlowState()), + persist: () => Effect.void, + rehydrate: () => Effect.void, + })), + ), +); + +function run(effect: Effect.Effect): Promise { + return Effect.runPromise(Effect.provide(effect, TestStoreLive)); +} + +const makeNetworkHarEntry = (url = 'https://auth.example.com/authorize') => ({ + request: { + url, + method: 'POST', + headers: [{ name: 'content-type', value: 'application/json' }], + }, + response: { + status: 200, + headers: [{ name: 'x-request-id', value: 'abc' }], + content: { text: '{"access_token":"tok"}' }, + }, + time: 123, +}); + +const makeSdkEvent = (overrides: Partial = {}): AuthEvent => ({ + id: 'sdk-1', + timestamp: 100, + type: 'sdk:node-change', + source: 'sdk', + flowId: 'flow-1', + causedBy: null, + data: { _tag: 'sdk', nodeStatus: 'continue' }, + flags: { isCors: false, isError: false, isAuthRelated: true }, + ...overrides, +}); + +describe('handleMessage', () => { + describe('NETWORK_EVENT', () => { + it('returns the event when URL is auth-related', async () => { + const result = await run( + handleMessage({ + type: 'NETWORK_EVENT', + payload: makeNetworkHarEntry('https://auth.example.com/authorize'), + }), + ); + + expect(result).not.toBeNull(); + const event = result as AuthEvent; + expect(event.type).toBe('network:response'); + expect(event.flags.isAuthRelated).toBe(true); + }); + + it('returns null when URL is not auth-related', async () => { + const result = await run( + handleMessage({ + type: 'NETWORK_EVENT', + payload: makeNetworkHarEntry('https://api.example.com/users'), + }), + ); + + expect(result).toBeNull(); + }); + + it('sets causedBy to the lastSdkEventId', async () => { + const program = Effect.gen(function* () { + // First, append an SDK event to set lastSdkEventId + yield* handleMessage({ type: 'SDK_EVENT', payload: makeSdkEvent({ id: 'sdk-42' }) }); + + // Then process a network event + return yield* handleMessage({ + type: 'NETWORK_EVENT', + payload: makeNetworkHarEntry('https://auth.example.com/davinci/flow'), + }); + }); + + const result = await run(program); + expect(result).not.toBeNull(); + expect((result as AuthEvent).causedBy).toBe('sdk-42'); + }); + }); + + describe('SDK_EVENT', () => { + it('accepts and stores a valid SDK event', async () => { + const event = makeSdkEvent(); + const result = await run(handleMessage({ type: 'SDK_EVENT', payload: event })); + + expect(result).not.toBeNull(); + expect((result as AuthEvent).id).toBe('sdk-1'); + }); + + it('returns null for a malformed SDK event', async () => { + const spy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + const result = await run(handleMessage({ type: 'SDK_EVENT', payload: { bad: 'data' } })); + + expect(result).toBeNull(); + expect(spy).toHaveBeenCalledOnce(); + spy.mockRestore(); + }); + + it('persists the event to the store', async () => { + const program = Effect.gen(function* () { + const store = yield* EventStoreService; + yield* handleMessage({ type: 'SDK_EVENT', payload: makeSdkEvent() }); + return yield* store.getState(); + }); + + const state = await run(program); + expect(state.events).toHaveLength(1); + }); + }); + + describe('CLEAR', () => { + it('clears the event store', async () => { + const program = Effect.gen(function* () { + const store = yield* EventStoreService; + yield* handleMessage({ type: 'SDK_EVENT', payload: makeSdkEvent() }); + yield* handleMessage({ type: 'CLEAR' }); + return yield* store.getState(); + }); + + const state = await run(program); + expect(state.events).toHaveLength(0); + }); + + it('returns null', async () => { + const result = await run(handleMessage({ type: 'CLEAR' })); + expect(result).toBeNull(); + }); + }); + + describe('GET_STATE', () => { + it('returns the current flow state', async () => { + const program = Effect.gen(function* () { + yield* handleMessage({ type: 'SDK_EVENT', payload: makeSdkEvent() }); + return yield* handleMessage({ type: 'GET_STATE' }); + }); + + const result = await run(program); + expect(result).toHaveProperty('events'); + expect((result as FlowState).events).toHaveLength(1); + }); + + it('returns empty state when nothing has been appended', async () => { + const result = await run(handleMessage({ type: 'GET_STATE' })); + expect(result).toHaveProperty('events'); + expect((result as FlowState).events).toHaveLength(0); + }); + }); +}); diff --git a/packages/devtools-extension/src/background/message-handler.ts b/packages/devtools-extension/src/background/message-handler.ts new file mode 100644 index 0000000000..e6d7a44e3c --- /dev/null +++ b/packages/devtools-extension/src/background/message-handler.ts @@ -0,0 +1,46 @@ +import { Effect, Schema, Either } from 'effect'; +import { buildNetworkEvent } from '../devtools/network-observer.js'; +import { EventStoreService } from './event-store.service.js'; +import { AuthEventSchema } from '@forgerock/devtools-types'; +import type { HarEntry } from '../devtools/network-observer.js'; + +type IncomingMessage = + | { type: 'NETWORK_EVENT'; payload: HarEntry } + | { type: 'SDK_EVENT'; payload: unknown } + | { type: 'CLEAR' } + | { type: 'GET_STATE' }; + +export function handleMessage(message: IncomingMessage) { + return Effect.gen(function* () { + const store = yield* EventStoreService; + + switch (message.type) { + case 'NETWORK_EVENT': { + const state = yield* store.getState(); + const event = buildNetworkEvent(message.payload, state.flowId); + if (!event.flags.isAuthRelated) return null; + const eventWithCause = { ...event, causedBy: state.lastSdkEventId }; + yield* store.append(eventWithCause); + yield* store.persist(); + return eventWithCause; + } + case 'SDK_EVENT': { + const result = Schema.decodeUnknownEither(AuthEventSchema)(message.payload); + if (Either.isLeft(result)) { + console.warn('[Ping DevTools] Malformed SDK event:', result.left.message); + return null; + } + yield* store.append(result.right); + yield* store.persist(); + return result.right; + } + case 'CLEAR': { + yield* store.clear(); + return null; + } + case 'GET_STATE': { + return yield* store.getState(); + } + } + }); +} diff --git a/packages/devtools-extension/src/background/serialize-diagnosis.test.ts b/packages/devtools-extension/src/background/serialize-diagnosis.test.ts new file mode 100644 index 0000000000..e83a75f1c4 --- /dev/null +++ b/packages/devtools-extension/src/background/serialize-diagnosis.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect } from 'vitest'; +import { serializeDiagnosis } from './serialize-diagnosis.js'; +import type { DiagnosisResult } from './diagnosis-engine.js'; + +describe('serializeDiagnosis', () => { + it('converts annotatedEvents Map to a plain object', () => { + const diagnosis: DiagnosisResult = { + issues: [], + annotatedEvents: new Map([ + [ + 'evt-1', + [ + { + severity: 'warning', + title: 'Expired JWT', + description: 'Token expired', + steps: ['Refresh'], + }, + ], + ], + ['evt-2', [{ severity: 'error', title: 'CORS', description: 'Blocked', steps: [] }]], + ]), + flowHealth: 'warning', + }; + + const result = serializeDiagnosis(diagnosis); + + expect(result.annotatedEvents).toEqual({ + 'evt-1': [ + { + severity: 'warning', + title: 'Expired JWT', + description: 'Token expired', + steps: ['Refresh'], + }, + ], + 'evt-2': [{ severity: 'error', title: 'CORS', description: 'Blocked', steps: [] }], + }); + }); + + it('preserves issues array as-is', () => { + const issues = [ + { + id: 'cors-1', + severity: 'error' as const, + category: 'cors' as const, + title: 'CORS Blocked', + description: 'Request blocked', + steps: ['Check headers'], + relatedEventIds: ['evt-1'], + dedupKey: 'cors:status-zero', + }, + ]; + + const diagnosis: DiagnosisResult = { + issues, + annotatedEvents: new Map(), + flowHealth: 'error', + }; + + const result = serializeDiagnosis(diagnosis); + expect(result.issues).toBe(issues); + }); + + it('preserves flowHealth value', () => { + const diagnosis: DiagnosisResult = { + issues: [], + annotatedEvents: new Map(), + flowHealth: 'healthy', + }; + + expect(serializeDiagnosis(diagnosis).flowHealth).toBe('healthy'); + }); + + it('handles empty annotatedEvents Map', () => { + const diagnosis: DiagnosisResult = { + issues: [], + annotatedEvents: new Map(), + flowHealth: 'healthy', + }; + + const result = serializeDiagnosis(diagnosis); + expect(result.annotatedEvents).toEqual({}); + }); + + it('handles multiple issues per event in annotatedEvents', () => { + const diagnosis: DiagnosisResult = { + issues: [], + annotatedEvents: new Map([ + [ + 'evt-1', + [ + { severity: 'warning', title: 'Issue 1', description: 'Desc 1', steps: [] }, + { severity: 'error', title: 'Issue 2', description: 'Desc 2', steps: ['Fix it'] }, + ], + ], + ]), + flowHealth: 'error', + }; + + const result = serializeDiagnosis(diagnosis); + expect(result.annotatedEvents['evt-1']).toHaveLength(2); + expect(result.annotatedEvents['evt-1'][0].title).toBe('Issue 1'); + expect(result.annotatedEvents['evt-1'][1].title).toBe('Issue 2'); + }); +}); diff --git a/packages/devtools-extension/src/background/serialize-diagnosis.ts b/packages/devtools-extension/src/background/serialize-diagnosis.ts new file mode 100644 index 0000000000..761d7daaa2 --- /dev/null +++ b/packages/devtools-extension/src/background/serialize-diagnosis.ts @@ -0,0 +1,15 @@ +import type { DiagnosisResult, FlowIssue, EventIssue } from './diagnosis-engine.js'; + +export interface SerializableDiagnosisResult { + issues: FlowIssue[]; + annotatedEvents: Record; + flowHealth: 'healthy' | 'warning' | 'error'; +} + +export function serializeDiagnosis(diagnosis: DiagnosisResult): SerializableDiagnosisResult { + return { + issues: diagnosis.issues, + annotatedEvents: Object.fromEntries(diagnosis.annotatedEvents), + flowHealth: diagnosis.flowHealth, + }; +} diff --git a/packages/devtools-extension/src/background/service-worker.ts b/packages/devtools-extension/src/background/service-worker.ts new file mode 100644 index 0000000000..f319f2c7f2 --- /dev/null +++ b/packages/devtools-extension/src/background/service-worker.ts @@ -0,0 +1,73 @@ +import { ManagedRuntime, Effect } from 'effect'; +import { EventStoreLive, EventStoreService } from './event-store.service.js'; +import { handleMessage } from './message-handler.js'; +import { runDiagnosis } from './diagnosis-engine.js'; +import { serializeDiagnosis } from './serialize-diagnosis.js'; +import type { SerializableDiagnosisResult } from './serialize-diagnosis.js'; + +const AppLayer = EventStoreLive; +let runtime = ManagedRuntime.make(AppLayer); + +self.addEventListener('activate', () => { + runtime = ManagedRuntime.make(AppLayer); + runtime + .runPromise( + Effect.gen(function* () { + const store = yield* EventStoreService; + yield* store.rehydrate(); + }), + ) + .catch(console.error); +}); + +function broadcastToPanel(event: unknown, diagnosis: SerializableDiagnosisResult): void { + chrome.runtime.sendMessage({ type: 'PANEL_EVENT', payload: event, diagnosis }).catch(() => { + // Panel not open — ignore + }); +} + +function runDiagnosisEffect() { + return Effect.gen(function* () { + const store = yield* EventStoreService; + const state = yield* store.getState(); + return runDiagnosis(state.events); + }); +} + +chrome.runtime.onConnect.addListener((port) => { + if (port.name !== 'devtools') return; + port.onMessage.addListener((message) => { + runtime + .runPromise( + Effect.gen(function* () { + const result = yield* handleMessage(message); + if ( + (message.type === 'NETWORK_EVENT' || message.type === 'SDK_EVENT') && + result !== null + ) { + const diagnosis = yield* runDiagnosisEffect(); + broadcastToPanel(result, serializeDiagnosis(diagnosis)); + } + return result; + }), + ) + .catch(console.error); + }); +}); + +chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + runtime + .runPromise( + Effect.gen(function* () { + const result = yield* handleMessage(message); + if ((message.type === 'NETWORK_EVENT' || message.type === 'SDK_EVENT') && result !== null) { + const diagnosis = yield* runDiagnosisEffect(); + broadcastToPanel(result, serializeDiagnosis(diagnosis)); + } + return result; + }), + ) + .then(sendResponse) + .catch(console.error); + return true; // keep channel open for async response +}); diff --git a/packages/devtools-extension/src/content/content-script.test.ts b/packages/devtools-extension/src/content/content-script.test.ts new file mode 100644 index 0000000000..9f9f0472c3 --- /dev/null +++ b/packages/devtools-extension/src/content/content-script.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +describe('content-script (MAIN world)', () => { + beforeEach(() => { + vi.stubGlobal('__PING_DEVTOOLS_EXTENSION__', undefined); + }); + + it('sets __PING_DEVTOOLS_EXTENSION__ on window', async () => { + await import('./content-script.js'); + expect(window.__PING_DEVTOOLS_EXTENSION__).toBe(true); + }); + + it('relays pingDevtools CustomEvent payload via postMessage', async () => { + const postSpy = vi.spyOn(window, 'postMessage'); + await import('./content-script.js'); + + const payload = { id: 'test', type: 'sdk:node-change' }; + window.dispatchEvent(new CustomEvent('pingDevtools', { detail: payload })); + + expect(postSpy).toHaveBeenCalledWith({ __pingDevtools: true, payload }, '*'); + }); +}); diff --git a/packages/devtools-extension/src/content/content-script.ts b/packages/devtools-extension/src/content/content-script.ts new file mode 100644 index 0000000000..05038502b6 --- /dev/null +++ b/packages/devtools-extension/src/content/content-script.ts @@ -0,0 +1,12 @@ +declare global { + interface Window { + __PING_DEVTOOLS_EXTENSION__?: boolean; + } +} + +window.__PING_DEVTOOLS_EXTENSION__ = true; + +window.addEventListener('pingDevtools', (raw: Event) => { + const event = raw as CustomEvent; + window.postMessage({ __pingDevtools: true, payload: event.detail }, '*'); +}); diff --git a/packages/devtools-extension/src/content/relay.ts b/packages/devtools-extension/src/content/relay.ts new file mode 100644 index 0000000000..d2a4e81c26 --- /dev/null +++ b/packages/devtools-extension/src/content/relay.ts @@ -0,0 +1,9 @@ +// Runs in the isolated world — relays postMessage events to the service worker +// via chrome.runtime, which is not available in the main world. +window.addEventListener('message', (e) => { + if (e.source !== window || !(e.data as { __pingDevtools?: boolean })?.__pingDevtools) return; + chrome.runtime.sendMessage({ + type: 'SDK_EVENT', + payload: (e.data as { payload: unknown }).payload, + }); +}); diff --git a/packages/devtools-extension/src/devtools/cors-detector.test.ts b/packages/devtools-extension/src/devtools/cors-detector.test.ts new file mode 100644 index 0000000000..6c5a0d9f1c --- /dev/null +++ b/packages/devtools-extension/src/devtools/cors-detector.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from 'vitest'; +import { detectCorsFlags } from './cors-detector.js'; +import type { CorsFlag } from '@forgerock/devtools-types'; + +function makeEntry(overrides: { + url?: string; + method?: string; + status?: number; + requestHeaders?: Record; + responseHeaders?: Record; +}) { + return { + request: { + url: overrides.url ?? 'https://example.com/authorize', + method: overrides.method ?? 'POST', + headers: Object.entries(overrides.requestHeaders ?? {}).map(([name, value]) => ({ + name, + value, + })), + }, + response: { + status: overrides.status ?? 200, + headers: Object.entries(overrides.responseHeaders ?? {}).map(([name, value]) => ({ + name, + value, + })), + }, + time: 0, + }; +} + +describe('detectCorsFlags', () => { + it('returns empty array for a clean request', () => { + const entry = makeEntry({ + requestHeaders: { origin: 'https://app.example.com' }, + responseHeaders: { + 'access-control-allow-origin': 'https://app.example.com', + 'access-control-allow-credentials': 'true', + }, + }); + const flags = detectCorsFlags(entry); + expect(flags).toHaveLength(0); + }); + + it('flags status 0 as status-zero', () => { + const entry = makeEntry({ status: 0 }); + const flags = detectCorsFlags(entry); + expect(flags.some((f: CorsFlag) => f.reason === 'status-zero')).toBe(true); + }); + + it('flags missing allow-origin when origin header is present', () => { + const entry = makeEntry({ + requestHeaders: { origin: 'https://app.example.com' }, + responseHeaders: {}, + }); + const flags = detectCorsFlags(entry); + expect(flags.some((f: CorsFlag) => f.reason === 'missing-allow-origin')).toBe(true); + }); + + it('flags wildcard allow-origin with credentials', () => { + const entry = makeEntry({ + requestHeaders: { origin: 'https://app.example.com' }, + responseHeaders: { + 'access-control-allow-origin': '*', + 'access-control-allow-credentials': 'true', + }, + }); + const flags = detectCorsFlags(entry); + expect(flags.some((f: CorsFlag) => f.reason === 'wildcard-with-credentials')).toBe(true); + }); + + it('flags credentials mismatch when allow-credentials is false', () => { + const entry = makeEntry({ + requestHeaders: { origin: 'https://app.example.com' }, + responseHeaders: { + 'access-control-allow-origin': 'https://app.example.com', + 'access-control-allow-credentials': 'false', + }, + }); + const flags = detectCorsFlags(entry); + expect(flags.some((f: CorsFlag) => f.reason === 'credentials-mismatch')).toBe(true); + }); + + it('flags credentials mismatch when allow-credentials header is absent', () => { + const entry = makeEntry({ + requestHeaders: { origin: 'https://app.example.com' }, + responseHeaders: { + 'access-control-allow-origin': 'https://app.example.com', + // no access-control-allow-credentials header + }, + }); + const flags = detectCorsFlags(entry); + expect(flags.some((f: CorsFlag) => f.reason === 'credentials-mismatch')).toBe(true); + }); +}); diff --git a/packages/devtools-extension/src/devtools/cors-detector.ts b/packages/devtools-extension/src/devtools/cors-detector.ts new file mode 100644 index 0000000000..a25d9569c2 --- /dev/null +++ b/packages/devtools-extension/src/devtools/cors-detector.ts @@ -0,0 +1,36 @@ +import type { CorsFlag } from '@forgerock/devtools-types'; +import type { HarHeader, HarEntry } from './network-observer.js'; + +function headerValue(headers: HarHeader[], name: string): string | undefined { + return headers.find((h) => h.name.toLowerCase() === name.toLowerCase())?.value; +} + +export function detectCorsFlags(entry: HarEntry): CorsFlag[] { + const flags: CorsFlag[] = []; + const { url, method, headers: reqHeaders } = entry.request; + const { status, headers: resHeaders } = entry.response; + + const origin = headerValue(reqHeaders, 'origin'); + const allowOrigin = headerValue(resHeaders, 'access-control-allow-origin'); + const allowCredentials = headerValue(resHeaders, 'access-control-allow-credentials'); + + if (status === 0) { + flags.push({ url, method, reason: 'status-zero' }); + } + + if (origin && !allowOrigin) { + flags.push({ url, method, reason: 'missing-allow-origin' }); + } + + if (allowOrigin === '*' && allowCredentials === 'true') { + flags.push({ url, method, reason: 'wildcard-with-credentials', allowOrigin, allowCredentials }); + } + + const credentialsDenied = allowCredentials === 'false' || allowCredentials === undefined; + + if (origin && allowOrigin && allowOrigin !== '*' && credentialsDenied) { + flags.push({ url, method, reason: 'credentials-mismatch', allowOrigin, allowCredentials }); + } + + return flags; +} diff --git a/packages/devtools-extension/src/devtools/devtools.html b/packages/devtools-extension/src/devtools/devtools.html new file mode 100644 index 0000000000..6dcc11161e --- /dev/null +++ b/packages/devtools-extension/src/devtools/devtools.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/devtools-extension/src/devtools/devtools.ts b/packages/devtools-extension/src/devtools/devtools.ts new file mode 100644 index 0000000000..147c09a5c8 --- /dev/null +++ b/packages/devtools-extension/src/devtools/devtools.ts @@ -0,0 +1,35 @@ +// Runs in the devtools page context — has access to chrome.devtools.* + +let port: chrome.runtime.Port | null = null; + +function connect() { + try { + // chrome.runtime.id throws (not just returns undefined) when the extension + // context is invalidated — so we must catch, not just optional-chain. + if (!chrome.runtime.id) return; + port = chrome.runtime.connect({ name: 'devtools' }); + port.onDisconnect.addListener(() => { + port = null; + setTimeout(connect, 1000); + }); + } catch { + // Context invalidated — stop reconnecting silently. + } +} + +connect(); + +// panels.create is safe to call once — the devtools page is not reloaded +// while DevTools is open, so no need to guard with runtime.id here. +chrome.devtools.panels.create('Ping DevTools', '', 'panel/panel.html', undefined); + +chrome.devtools.network.onRequestFinished.addListener((entry) => { + port?.postMessage({ + type: 'NETWORK_EVENT', + payload: { + request: entry.request, + response: entry.response, + time: entry.time, + }, + }); +}); diff --git a/packages/devtools-extension/src/devtools/network-observer.test.ts b/packages/devtools-extension/src/devtools/network-observer.test.ts new file mode 100644 index 0000000000..b33aaf9090 --- /dev/null +++ b/packages/devtools-extension/src/devtools/network-observer.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect } from 'vitest'; +import { buildNetworkEvent, isAuthRelated } from './network-observer.js'; + +describe('isAuthRelated', () => { + it('matches known auth endpoints', () => { + expect(isAuthRelated('https://id.example.com/authorize')).toBe(true); + expect(isAuthRelated('https://id.example.com/oauth2/token')).toBe(true); + expect(isAuthRelated('https://id.example.com/davinci/connections')).toBe(true); + expect(isAuthRelated('https://id.example.com/am/json/authenticate')).toBe(true); + }); + + it('does not match unrelated URLs', () => { + expect(isAuthRelated('https://example.com/api/users')).toBe(false); + expect(isAuthRelated('https://cdn.example.com/logo.png')).toBe(false); + }); +}); + +describe('buildNetworkEvent', () => { + it('maps a HAR entry to an AuthEvent', () => { + const entry = { + request: { + url: 'https://id.example.com/authorize', + method: 'POST', + headers: [{ name: 'origin', value: 'https://app.example.com' }], + }, + response: { + status: 200, + headers: [{ name: 'access-control-allow-origin', value: 'https://app.example.com' }], + }, + time: 123, + }; + const event = buildNetworkEvent(entry, null); + expect(event.type).toBe('network:response'); + expect(event.source).toBe('network'); + expect(event.flags.isAuthRelated).toBe(true); + expect(event.flags.isCors).toBe(false); + expect(event.data).toMatchObject({ + _tag: 'network', + url: 'https://id.example.com/authorize', + method: 'POST', + status: 200, + }); + }); + + it('sets isCors flag when cors flags detected', () => { + const entry = { + request: { + url: 'https://id.example.com/authorize', + method: 'POST', + headers: [{ name: 'origin', value: 'https://app.example.com' }], + }, + response: { status: 0, headers: [] }, + time: 50, + }; + const event = buildNetworkEvent(entry, null); + expect(event.flags.isCors).toBe(true); + expect(event.flags.isError).toBe(true); + }); +}); + +describe('buildNetworkEvent body capture', () => { + it('parses a JSON request body from postData', () => { + const entry = { + request: { + url: 'https://id.example.com/davinci/connections', + method: 'POST', + headers: [], + postData: { text: '{"action":"continueNode"}' }, + }, + response: { status: 200, headers: [] }, + time: 10, + }; + const event = buildNetworkEvent(entry, null); + if (event.data._tag !== 'network') throw new Error('not network'); + expect(event.data.requestBody).toEqual({ action: 'continueNode' }); + }); + + it('falls back to raw string for non-JSON request body', () => { + const entry = { + request: { + url: 'https://id.example.com/davinci/connections', + method: 'POST', + headers: [], + postData: { text: 'not-json' }, + }, + response: { status: 200, headers: [] }, + time: 10, + }; + const event = buildNetworkEvent(entry, null); + if (event.data._tag !== 'network') throw new Error('not network'); + expect(event.data.requestBody).toBe('not-json'); + }); + + it('parses a JSON response body from content', () => { + const entry = { + request: { + url: 'https://id.example.com/oauth2/token', + method: 'POST', + headers: [], + }, + response: { + status: 200, + headers: [], + content: { text: '{"access_token":"abc","token_type":"Bearer"}' }, + }, + time: 20, + }; + const event = buildNetworkEvent(entry, null); + if (event.data._tag !== 'network') throw new Error('not network'); + expect(event.data.responseBody).toEqual({ access_token: 'abc', token_type: 'Bearer' }); + }); + + it('omits requestBody and responseBody when absent', () => { + const entry = { + request: { + url: 'https://id.example.com/authorize', + method: 'GET', + headers: [], + }, + response: { status: 302, headers: [] }, + time: 5, + }; + const event = buildNetworkEvent(entry, null); + if (event.data._tag !== 'network') throw new Error('not network'); + expect(event.data.requestBody).toBeUndefined(); + expect(event.data.responseBody).toBeUndefined(); + }); + + it('returns undefined for empty body text', () => { + const entry = { + request: { + url: 'https://id.example.com/davinci/connections', + method: 'POST', + headers: [], + postData: { text: ' ' }, + }, + response: { status: 200, headers: [], content: { text: '' } }, + time: 10, + }; + const event = buildNetworkEvent(entry, null); + if (event.data._tag !== 'network') throw new Error('not network'); + expect(event.data.requestBody).toBeUndefined(); + expect(event.data.responseBody).toBeUndefined(); + }); +}); diff --git a/packages/devtools-extension/src/devtools/network-observer.ts b/packages/devtools-extension/src/devtools/network-observer.ts new file mode 100644 index 0000000000..ec0ebc7503 --- /dev/null +++ b/packages/devtools-extension/src/devtools/network-observer.ts @@ -0,0 +1,90 @@ +import { detectCorsFlags } from './cors-detector.js'; +import type { AuthEvent, NetworkData } from '@forgerock/devtools-types'; + +const AUTH_URL_PATTERNS = [ + /\/authorize/, + /\/oauth2\/token/, + /\/davinci\//, + /\/am\/json\//, + /\/openid-connect\//, + /\/as\/token/, +] as const; + +export interface HarHeader { + name: string; + value: string; +} + +export interface HarEntry { + request: { + url: string; + method: string; + headers: HarHeader[]; + postData?: { text: string }; + }; + response: { + status: number; + headers: HarHeader[]; + content?: { text: string }; + }; + time: number; +} + +export function isAuthRelated(url: string): boolean { + return AUTH_URL_PATTERNS.some((p) => p.test(url)); +} + +function headersToRecord(headers: HarHeader[]): Record { + return Object.fromEntries(headers.map((h) => [h.name.toLowerCase(), h.value])); +} + +const MAX_BODY_PARSE_BYTES = 512 * 1024; + +function parseBody(text: string | undefined): unknown | undefined { + if (!text || text.trim() === '') return undefined; + if (text.length > MAX_BODY_PARSE_BYTES) return text; + try { + return JSON.parse(text) as unknown; + } catch { + return text; + } +} + +export function buildNetworkEvent(entry: HarEntry, flowId: string | null): AuthEvent { + const corsFlags = detectCorsFlags(entry); + const isCors = corsFlags.some( + (f) => + f.reason === 'status-zero' || + f.reason === 'missing-allow-origin' || + f.reason === 'wildcard-with-credentials', + ); + const isError = entry.response.status === 0 || entry.response.status >= 400; + + const data: NetworkData = { + _tag: 'network', + url: entry.request.url, + method: entry.request.method, + status: entry.response.status, + requestHeaders: headersToRecord(entry.request.headers), + responseHeaders: headersToRecord(entry.response.headers), + duration: entry.time, + corsFlag: corsFlags[0], + requestBody: parseBody(entry.request.postData?.text), + responseBody: parseBody(entry.response.content?.text), + }; + + return { + id: crypto.randomUUID(), + timestamp: Date.now(), + type: 'network:response', + source: 'network', + flowId, + causedBy: null, + data, + flags: { + isCors, + isError, + isAuthRelated: isAuthRelated(entry.request.url), + }, + }; +} diff --git a/packages/devtools-extension/src/export/markdown.test.ts b/packages/devtools-extension/src/export/markdown.test.ts new file mode 100644 index 0000000000..7be1ab2cfd --- /dev/null +++ b/packages/devtools-extension/src/export/markdown.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from 'vitest'; +import { renderFlowMarkdown } from './markdown.js'; +import type { FlowState, AuthEvent } from '@forgerock/devtools-types'; +import type { DiagnosisResult } from '../background/diagnosis-engine.js'; + +const baseFlags = { isCors: false, isError: false, isAuthRelated: true }; + +const networkEvent: AuthEvent = { + id: 'e1', + timestamp: 1000, + type: 'network:response', + source: 'network', + flowId: 'flow-abcd1234', + causedBy: null, + flags: baseFlags, + data: { + _tag: 'network', + url: 'https://auth.example.com/davinci/connections', + method: 'POST', + status: 200, + requestHeaders: {}, + responseHeaders: {}, + duration: 120, + }, +}; + +const sdkEvent: AuthEvent = { + id: 'e2', + timestamp: 1120, + type: 'sdk:node-change', + source: 'sdk', + flowId: 'flow-abcd1234', + causedBy: null, + flags: baseFlags, + data: { _tag: 'sdk', nodeStatus: 'continue', nodeName: 'UsernamePassword' }, +}; + +function makeFlowState(events: AuthEvent[]): FlowState { + return { + flowId: 'flow-abcd1234', + capturedAt: '2026-05-08T14:30:00.000Z', + events, + summary: { nodeCount: 1, errorCount: 0, corsFlags: [], duration: 120, sdkConnected: true }, + lastSdkEventId: null, + }; +} + +describe('renderFlowMarkdown', () => { + it('renders header with flow ID prefix and health status', () => { + const md = renderFlowMarkdown(makeFlowState([networkEvent, sdkEvent]), null); + expect(md).toContain('## Flow: flow-abc'); + expect(md).toContain('HEALTHY'); + }); + + it('renders event table with relative timestamps', () => { + const md = renderFlowMarkdown(makeFlowState([networkEvent, sdkEvent]), null); + expect(md).toContain('+0ms'); + expect(md).toContain('+120ms'); + expect(md).toContain('network:response'); + expect(md).toContain('sdk:node-change'); + }); + + it('renders detail columns for network events', () => { + const md = renderFlowMarkdown(makeFlowState([networkEvent]), null); + expect(md).toContain('200'); + expect(md).toContain('POST /davinci/connections'); + }); + + it('renders detail columns for SDK events', () => { + const md = renderFlowMarkdown(makeFlowState([sdkEvent]), null); + expect(md).toContain('continue'); + expect(md).toContain('UsernamePassword'); + }); + + it('omits diagnosis section when healthy', () => { + const md = renderFlowMarkdown(makeFlowState([networkEvent]), null); + expect(md).not.toContain('### Diagnosis'); + }); + + it('renders diagnosis section when issues exist', () => { + const diagnosis: DiagnosisResult = { + flowHealth: 'error', + issues: [ + { + id: 'cors:status-zero', + severity: 'error', + category: 'cors', + title: 'Network failure (status 0)', + description: 'The request never reached the server.', + steps: ['Add origin to allowed origins.', 'Check preflight.'], + relatedEventIds: ['e1'], + }, + ], + annotatedEvents: new Map(), + }; + const md = renderFlowMarkdown(makeFlowState([networkEvent]), diagnosis); + expect(md).toContain('### Diagnosis'); + expect(md).toContain('ERROR'); + expect(md).toContain('Network failure (status 0)'); + expect(md).toContain('1. Add origin to allowed origins.'); + expect(md).toContain('2. Check preflight.'); + }); + + it('renders journey event detail', () => { + const journeyEvent: AuthEvent = { + id: 'e3', + timestamp: 1000, + type: 'sdk:journey-step', + source: 'sdk', + flowId: 'flow-abcd1234', + causedBy: null, + flags: baseFlags, + data: { _tag: 'journey', stepType: 'Step', stage: 'UsernamePassword' }, + }; + const md = renderFlowMarkdown(makeFlowState([journeyEvent]), null); + expect(md).toContain('Step'); + expect(md).toContain('UsernamePassword'); + }); + + it('renders OIDC event detail', () => { + const oidcEvent: AuthEvent = { + id: 'e4', + timestamp: 1000, + type: 'sdk:oidc-state', + source: 'sdk', + flowId: 'flow-abcd1234', + causedBy: null, + flags: baseFlags, + data: { _tag: 'oidc', phase: 'exchange', status: 'success' }, + }; + const md = renderFlowMarkdown(makeFlowState([oidcEvent]), null); + expect(md).toContain('success'); + expect(md).toContain('exchange'); + }); + + it('preserves redaction markers in output', () => { + const event: AuthEvent = { + id: 'e5', + timestamp: 1000, + type: 'sdk:node-change', + source: 'sdk', + flowId: 'flow-abcd1234', + causedBy: null, + flags: baseFlags, + data: { + _tag: 'sdk', + nodeStatus: 'continue', + interactionToken: '', + }, + }; + const md = renderFlowMarkdown(makeFlowState([event]), null); + expect(md).toContain('sdk:node-change'); + }); +}); diff --git a/packages/devtools-extension/src/export/markdown.ts b/packages/devtools-extension/src/export/markdown.ts new file mode 100644 index 0000000000..3d264bf54c --- /dev/null +++ b/packages/devtools-extension/src/export/markdown.ts @@ -0,0 +1,96 @@ +import type { AuthEvent, FlowState } from '@forgerock/devtools-types'; +import type { DiagnosisResult, FlowIssue } from '../background/diagnosis-engine.js'; + +function formatRelativeTime(timestamp: number, baseTimestamp: number): string { + return `+${Math.round(timestamp - baseTimestamp)}ms`; +} + +function eventStatus(event: AuthEvent): string { + switch (event.data._tag) { + case 'network': + return String(event.data.status); + case 'sdk': + return event.data.nodeStatus; + case 'journey': + return event.data.stepType; + case 'oidc': + return event.data.status; + default: + return ''; + } +} + +function eventDetail(event: AuthEvent): string { + switch (event.data._tag) { + case 'network': { + const path = extractPath(event.data.url); + return `${event.data.method} ${path}`; + } + case 'sdk': + return event.data.nodeName ?? ''; + case 'journey': + return event.data.stage ?? ''; + case 'oidc': + return event.data.phase; + case 'session': + return event.data.key; + default: + return ''; + } +} + +function extractPath(url: string): string { + try { + return new URL(url).pathname; + } catch { + return url; + } +} + +function renderIssue(issue: FlowIssue): string { + const severity = issue.severity.toUpperCase(); + const steps = issue.steps.map((s, i) => ` ${i + 1}. ${s}`).join('\n'); + return `- **[${severity}] ${issue.title}** — ${issue.description}\n${steps}`; +} + +export function renderFlowMarkdown(flow: FlowState, diagnosis: DiagnosisResult | null): string { + const lines: string[] = []; + + const flowIdPrefix = flow.flowId ? flow.flowId.slice(0, 8) : 'unknown'; + const health = diagnosis?.flowHealth?.toUpperCase() ?? 'HEALTHY'; + lines.push(`## Flow: ${flowIdPrefix} — ${health}`); + lines.push(''); + const eventCount = flow.events.length; + const errorCount = flow.summary.errorCount; + const durationSec = (flow.summary.duration / 1000).toFixed(1); + lines.push( + `Captured: ${flow.capturedAt} | ${eventCount} events | ${errorCount} errors | ${durationSec}s duration`, + ); + + if (diagnosis && diagnosis.flowHealth !== 'healthy' && diagnosis.issues.length > 0) { + lines.push(''); + lines.push('### Diagnosis'); + lines.push(''); + for (const issue of diagnosis.issues) { + lines.push(renderIssue(issue)); + lines.push(''); + } + } + + lines.push(''); + lines.push('### Events'); + lines.push(''); + lines.push('| # | Time | Type | Status | Detail |'); + lines.push('|---|------|------|--------|--------|'); + + const baseTimestamp = flow.events.length > 0 ? flow.events[0].timestamp : 0; + flow.events.forEach((event, index) => { + const time = formatRelativeTime(event.timestamp, baseTimestamp); + const status = eventStatus(event); + const detail = eventDetail(event); + lines.push(`| ${index + 1} | ${time} | ${event.type} | ${status} | ${detail} |`); + }); + + lines.push(''); + return lines.join('\n'); +} diff --git a/packages/devtools-extension/src/export/redact.test.ts b/packages/devtools-extension/src/export/redact.test.ts new file mode 100644 index 0000000000..e3e61dcfd6 --- /dev/null +++ b/packages/devtools-extension/src/export/redact.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it } from 'vitest'; +import { redactFlowState } from './redact.js'; +import type { FlowState, AuthEvent } from '@forgerock/devtools-types'; + +function makeFlowState(events: AuthEvent[]): FlowState { + return { + flowId: 'flow-1', + capturedAt: '2026-05-08T14:30:00.000Z', + events, + summary: { nodeCount: 0, errorCount: 0, corsFlags: [], duration: 0, sdkConnected: false }, + lastSdkEventId: null, + }; +} + +const baseFlags = { isCors: false, isError: false, isAuthRelated: true }; + +describe('redactFlowState', () => { + it('redacts Authorization header', () => { + const event: AuthEvent = { + id: 'e1', + timestamp: 1000, + type: 'network:response', + source: 'network', + flowId: 'flow-1', + causedBy: null, + flags: baseFlags, + data: { + _tag: 'network', + url: '/token', + method: 'POST', + status: 200, + requestHeaders: { authorization: 'Bearer eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyIn0.sig' }, + responseHeaders: {}, + duration: 100, + }, + }; + const result = redactFlowState(makeFlowState([event])); + const headers = (result.events[0].data as { requestHeaders: Record }) + .requestHeaders; + expect(headers.authorization).toBe(''); + }); + + it('redacts Cookie and Set-Cookie headers', () => { + const event: AuthEvent = { + id: 'e2', + timestamp: 1000, + type: 'network:response', + source: 'network', + flowId: 'flow-1', + causedBy: null, + flags: baseFlags, + data: { + _tag: 'network', + url: '/token', + method: 'POST', + status: 200, + requestHeaders: { cookie: 'session=abc123' }, + responseHeaders: { 'set-cookie': 'session=def456; Path=/' }, + duration: 100, + }, + }; + const result = redactFlowState(makeFlowState([event])); + const data = result.events[0].data as { + requestHeaders: Record; + responseHeaders: Record; + }; + expect(data.requestHeaders.cookie).toBe(''); + expect(data.responseHeaders['set-cookie']).toBe(''); + }); + + it('redacts interactionToken in SDK data', () => { + const event: AuthEvent = { + id: 'e3', + timestamp: 2000, + type: 'sdk:node-change', + source: 'sdk', + flowId: 'flow-1', + causedBy: null, + flags: baseFlags, + data: { _tag: 'sdk', nodeStatus: 'continue', interactionToken: 'secret-tok-xyz' }, + }; + const result = redactFlowState(makeFlowState([event])); + const data = result.events[0].data as { interactionToken?: string }; + expect(data.interactionToken).toBe(''); + }); + + it('redacts authorization.code in SDK data', () => { + const event: AuthEvent = { + id: 'e4', + timestamp: 2000, + type: 'sdk:node-change', + source: 'sdk', + flowId: 'flow-1', + causedBy: null, + flags: baseFlags, + data: { _tag: 'sdk', nodeStatus: 'success', authorization: { code: 'auth-code-secret' } }, + }; + const result = redactFlowState(makeFlowState([event])); + const data = result.events[0].data as { authorization?: { code?: string } }; + expect(data.authorization?.code).toBe(''); + }); + + it('redacts tokenId in journey data', () => { + const event: AuthEvent = { + id: 'e5', + timestamp: 3000, + type: 'sdk:journey-step', + source: 'sdk', + flowId: 'flow-1', + causedBy: null, + flags: baseFlags, + data: { _tag: 'journey', stepType: 'LoginSuccess', tokenId: 'token-secret-123' }, + }; + const result = redactFlowState(makeFlowState([event])); + const data = result.events[0].data as { tokenId?: string }; + expect(data.tokenId).toBe(''); + }); + + it('redacts token fields in response body objects', () => { + const event: AuthEvent = { + id: 'e6', + timestamp: 1000, + type: 'network:response', + source: 'network', + flowId: 'flow-1', + causedBy: null, + flags: baseFlags, + data: { + _tag: 'network', + url: '/token', + method: 'POST', + status: 200, + requestHeaders: {}, + responseHeaders: {}, + duration: 100, + responseBody: { access_token: 'secret', refresh_token: 'secret2', scope: 'openid' }, + }, + }; + const result = redactFlowState(makeFlowState([event])); + const body = (result.events[0].data as { responseBody?: Record }).responseBody; + expect(body?.access_token).toBe(''); + expect(body?.refresh_token).toBe(''); + expect(body?.scope).toBe('openid'); + }); + + it('redacts sensitive callback values in journey data', () => { + const event: AuthEvent = { + id: 'e7', + timestamp: 3000, + type: 'sdk:journey-step', + source: 'sdk', + flowId: 'flow-1', + causedBy: null, + flags: baseFlags, + data: { + _tag: 'journey', + stepType: 'Step', + callbacks: [ + { + input: [{ name: 'IDToken1', value: 'user@example.com' }], + output: [{ name: 'prompt', value: 'Username' }], + }, + { + input: [{ name: 'IDToken2_password', value: 's3cret' }], + output: [{ name: 'prompt', value: 'Password' }], + }, + ], + }, + }; + const result = redactFlowState(makeFlowState([event])); + const cbs = ( + result.events[0].data as { + callbacks?: Array<{ + input: Array<{ name: string; value: unknown }>; + output: Array<{ name: string; value: unknown }>; + }>; + } + ).callbacks!; + expect(cbs[0].input[0].value).toBe('user@example.com'); + expect(cbs[1].input[0].value).toBe(''); + }); + + it('does not mutate the original flow state', () => { + const event: AuthEvent = { + id: 'e8', + timestamp: 2000, + type: 'sdk:node-change', + source: 'sdk', + flowId: 'flow-1', + causedBy: null, + flags: baseFlags, + data: { _tag: 'sdk', nodeStatus: 'continue', interactionToken: 'original-token' }, + }; + const original = makeFlowState([event]); + redactFlowState(original); + const data = original.events[0].data as { interactionToken?: string }; + expect(data.interactionToken).toBe('original-token'); + }); + + it('passes through events with no sensitive data unchanged', () => { + const event: AuthEvent = { + id: 'e9', + timestamp: 4000, + type: 'session:cookie', + source: 'session', + flowId: 'flow-1', + causedBy: null, + flags: baseFlags, + data: { _tag: 'session', key: 'iPlanetDirectoryPro', before: 'old', after: 'new' }, + }; + const result = redactFlowState(makeFlowState([event])); + expect(result.events[0]).toEqual(event); + }); +}); diff --git a/packages/devtools-extension/src/export/redact.ts b/packages/devtools-extension/src/export/redact.ts new file mode 100644 index 0000000000..2b7ef1dcbc --- /dev/null +++ b/packages/devtools-extension/src/export/redact.ts @@ -0,0 +1,125 @@ +import type { AuthEvent, FlowState } from '@forgerock/devtools-types'; + +const SENSITIVE_HEADERS = new Set(['authorization', 'cookie', 'set-cookie']); +const SENSITIVE_BODY_FIELDS = new Set([ + 'access_token', + 'refresh_token', + 'id_token', + 'code', + 'assertion', +]); +const SENSITIVE_CALLBACK_NAME = /password|secret|credential|[_-]token$|^token[_-]/i; + +function redactHeaders(headers: Record): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(headers)) { + if (SENSITIVE_HEADERS.has(key.toLowerCase())) { + result[key] = + key.toLowerCase() === 'authorization' ? '' : ''; + } else { + result[key] = value; + } + } + return result; +} + +function redactBodyFields(body: unknown): unknown { + if (body === null || body === undefined || typeof body !== 'object' || Array.isArray(body)) { + return body; + } + const obj = body as Record; + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + if (SENSITIVE_BODY_FIELDS.has(key)) { + result[key] = ``; + } else { + result[key] = value; + } + } + return result; +} + +function redactCallbacks(callbacks: readonly unknown[]): unknown[] { + return callbacks.map((cb) => { + if (cb === null || typeof cb !== 'object') return cb; + const obj = cb as Record; + return { + ...obj, + input: Array.isArray(obj.input) ? redactCallbackEntries(obj.input) : obj.input, + output: Array.isArray(obj.output) ? redactCallbackEntries(obj.output) : obj.output, + }; + }); +} + +function redactCallbackEntries(entries: unknown[]): unknown[] { + return entries.map((entry) => { + if (entry === null || typeof entry !== 'object') return entry; + const obj = entry as Record; + if (typeof obj.name === 'string' && SENSITIVE_CALLBACK_NAME.test(obj.name)) { + return { ...obj, value: '' }; + } + return obj; + }); +} + +function redactEvent(event: AuthEvent): AuthEvent { + const { data } = event; + + switch (data._tag) { + case 'network': { + return { + ...event, + data: { + ...data, + requestHeaders: redactHeaders(data.requestHeaders), + responseHeaders: redactHeaders(data.responseHeaders), + ...(data.requestBody !== undefined + ? { requestBody: redactBodyFields(data.requestBody) } + : {}), + ...(data.responseBody !== undefined + ? { responseBody: redactBodyFields(data.responseBody) } + : {}), + }, + }; + } + + case 'sdk': { + return { + ...event, + data: { + ...data, + ...(data.interactionToken !== undefined + ? { interactionToken: '' } + : {}), + ...(data.authorization?.code !== undefined + ? { authorization: { ...data.authorization, code: '' } } + : {}), + ...(data.responseBody !== undefined + ? { responseBody: redactBodyFields(data.responseBody) } + : {}), + }, + }; + } + + case 'journey': { + return { + ...event, + data: { + ...data, + ...(data.tokenId !== undefined ? { tokenId: '' } : {}), + ...(data.callbacks !== undefined ? { callbacks: redactCallbacks(data.callbacks) } : {}), + }, + }; + } + + default: + return event; + } +} + +export function redactFlowState(flow: FlowState): FlowState { + return { + ...flow, + events: flow.events.map(redactEvent), + }; +} diff --git a/packages/devtools-extension/src/panel/Main.elm b/packages/devtools-extension/src/panel/Main.elm new file mode 100644 index 0000000000..eda61ced3e --- /dev/null +++ b/packages/devtools-extension/src/panel/Main.elm @@ -0,0 +1,185 @@ +port module Main exposing (main) + +import Browser +import Decode +import Helpers +import Json.Decode as JD +import Time +import Model exposing (init) +import Types exposing (InspectorTab(..)) +import Update exposing (Msg(..), update) +import View exposing (view) + + +main : Program () Model.Model Msg +main = + Browser.element + { init = init + , update = updateWithPorts + , view = view + , subscriptions = subscriptions + } + + +updateWithPorts : Msg -> Model.Model -> ( Model.Model, Cmd Msg ) +updateWithPorts msg model = + let + ( newModel, cmd ) = + update msg model + in + case msg of + ExportJson -> + ( newModel, Cmd.batch [ cmd, exportJson () ] ) + + ExportMarkdown -> + ( newModel, Cmd.batch [ cmd, exportMarkdown () ] ) + + SubmitImportPaste -> + ( newModel, Cmd.batch [ cmd, submitImportPaste model.importPasteText ] ) + + ClearFlow -> + ( newModel, Cmd.batch [ cmd, clearFlow () ] ) + + SaveSnapshot -> + ( newModel, Cmd.batch [ cmd, saveSnapshot () ] ) + + CopyToClipboard text -> + ( newModel, Cmd.batch [ cmd, copyToClipboard text ] ) + + ToggleSnapshotMenu -> + if not model.snapshotMenuOpen then + -- Opening: request fresh list + ( newModel, Cmd.batch [ cmd, requestSnapshots () ] ) + else + ( newModel, cmd ) + + LoadSnapshot snapshotId -> + ( newModel, Cmd.batch [ cmd, loadSnapshot snapshotId ] ) + + DeleteSnapshot snapshotId -> + ( newModel, Cmd.batch [ cmd, deleteSnapshot snapshotId ] ) + + _ -> + ( newModel, cmd ) + + +subscriptions : Model.Model -> Sub Msg +subscriptions model = + let + playbackSub = + if model.isPlaying then + let + sdkNodes = + Helpers.sdkNodes model.events + + currentNode = + model.playbackIndex + |> Maybe.andThen (\n -> List.head (List.drop n sdkNodes)) + + nextNode = + model.playbackIndex + |> Maybe.andThen (\n -> List.head (List.drop (n + 1) sdkNodes)) + + interval = + case ( currentNode, nextNode ) of + ( Just cur, Just nxt ) -> + clamp 300.0 1500.0 (nxt.timestamp - cur.timestamp) + + _ -> + 600.0 + in + Time.every interval (\_ -> PlaybackTick) + + else + Sub.none + in + Sub.batch + [ receiveEvent + (\raw -> + case JD.decodeValue Decode.decodeAuthEvent raw of + Ok event -> + EventReceived event + + Err err -> + DecodeError (JD.errorToString err) + ) + , receiveDiagnosis + (\raw -> + case JD.decodeValue Decode.decodeDiagnosisResult raw of + Ok result -> + DiagnosisReceived result + + Err err -> + DecodeError ("Diagnosis decode failed: " ++ JD.errorToString err) + ) + , receiveImportMeta + (\raw -> + case JD.decodeValue Decode.decodeImportMeta raw of + Ok meta -> + ImportMetaReceived meta + + Err err -> + ImportError ("Import meta decode failed: " ++ JD.errorToString err) + ) + , receiveImportError + (\raw -> + case JD.decodeValue (JD.field "message" JD.string) raw of + Ok errMsg -> + ImportError errMsg + + Err err -> + ImportError ("Unknown import error: " ++ JD.errorToString err) + ) + , receiveSnapshots + (\raw -> + case JD.decodeValue (JD.list Decode.decodeSnapshotMeta) raw of + Ok list -> + SnapshotsReceived list + + Err err -> + DecodeError ("Snapshots decode failed: " ++ JD.errorToString err) + ) + , playbackSub + ] + + +port receiveEvent : (JD.Value -> msg) -> Sub msg + + +port receiveDiagnosis : (JD.Value -> msg) -> Sub msg + + +port receiveImportMeta : (JD.Value -> msg) -> Sub msg + + +port receiveImportError : (JD.Value -> msg) -> Sub msg + + +port exportJson : () -> Cmd msg + + +port exportMarkdown : () -> Cmd msg + + +port submitImportPaste : String -> Cmd msg + + +port clearFlow : () -> Cmd msg + + +port saveSnapshot : () -> Cmd msg + + +port copyToClipboard : String -> Cmd msg + + +port requestSnapshots : () -> Cmd msg + + +port receiveSnapshots : (JD.Value -> msg) -> Sub msg + + +port loadSnapshot : String -> Cmd msg + + +port deleteSnapshot : String -> Cmd msg diff --git a/packages/devtools-extension/src/panel/jwt.test.ts b/packages/devtools-extension/src/panel/jwt.test.ts new file mode 100644 index 0000000000..6f4dd8309b --- /dev/null +++ b/packages/devtools-extension/src/panel/jwt.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from 'vitest'; +import { formatUnixTime, base64UrlDecode, parseJwt } from './jwt.js'; + +describe('formatUnixTime', () => { + it('formats a Unix timestamp into ISO-like UTC string', () => { + // 2023-11-14T22:13:20.000Z + const result = formatUnixTime(1700000000); + expect(result).toBe('2023-11-14 22:13:20.000 UTC'); + }); + + it('handles zero', () => { + const result = formatUnixTime(0); + expect(result).toBe('1970-01-01 00:00:00.000 UTC'); + }); + + it('returns the number as string for invalid input', () => { + const result = formatUnixTime(NaN); + expect(result).toBe('NaN'); + }); +}); + +describe('base64UrlDecode', () => { + it('decodes standard base64url string', () => { + // "hello" in base64url = "aGVsbG8" + const result = base64UrlDecode('aGVsbG8'); + expect(result).toBe('hello'); + }); + + it('handles base64url characters (- and _)', () => { + // base64url uses - for + and _ for / + // "???" in base64 = "Pz8/" → base64url = "Pz8_" + const result = base64UrlDecode('Pz8_'); + expect(result).toBe('???'); + }); + + it('handles padding correctly for 3-char input', () => { + // "ab" in base64 = "YWI=" → base64url = "YWI" + const result = base64UrlDecode('YWI'); + expect(result).toBe('ab'); + }); +}); + +describe('parseJwt', () => { + // Build a minimal JWT with base64url-encoded header and payload + function makeJwt(header: Record, payload: Record): string { + const encode = (obj: Record) => + btoa(JSON.stringify(obj)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + return `${encode(header)}.${encode(payload)}.fakesignaturedata1234`; + } + + it('parses header and payload from a valid JWT', () => { + const jwt = makeJwt({ alg: 'RS256', typ: 'JWT' }, { sub: 'user-1', exp: 1700000000 }); + const result = parseJwt(jwt); + + expect(result.header).toEqual({ alg: 'RS256', typ: 'JWT' }); + expect(result.payload.sub).toBe('user-1'); + expect(result.payload.exp).toBe(1700000000); + }); + + it('returns signature preview (first 16 chars + ellipsis)', () => { + const jwt = makeJwt({ alg: 'RS256' }, { sub: '1' }); + const result = parseJwt(jwt); + + expect(result.signaturePreview).toBe('fakesignaturedat…'); + }); + + it('throws for a string with fewer than 3 parts', () => { + expect(() => parseJwt('only.two')).toThrow('Not a 3-part JWT'); + expect(() => parseJwt('just-one')).toThrow('Not a 3-part JWT'); + }); + + it('throws for a string with more than 3 parts', () => { + expect(() => parseJwt('a.b.c.d')).toThrow('Not a 3-part JWT'); + }); + + it('throws for invalid base64 content', () => { + expect(() => parseJwt('!!!.@@@.###')).toThrow(); + }); + + it('parses JWT with URL-safe base64 characters', () => { + // Create a payload that would produce + and / in standard base64 + const jwt = makeJwt({ alg: 'RS256' }, { data: '>>>???' }); + const result = parseJwt(jwt); + expect(result.payload.data).toBe('>>>???'); + }); + + it('handles short signature (less than 16 chars)', () => { + const encode = (obj: Record) => + btoa(JSON.stringify(obj)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + const jwt = `${encode({ alg: 'RS256' })}.${encode({ sub: '1' })}.abc`; + const result = parseJwt(jwt); + expect(result.signaturePreview).toBe('abc…'); + }); +}); diff --git a/packages/devtools-extension/src/panel/jwt.ts b/packages/devtools-extension/src/panel/jwt.ts new file mode 100644 index 0000000000..4ec8b34f35 --- /dev/null +++ b/packages/devtools-extension/src/panel/jwt.ts @@ -0,0 +1,28 @@ +export function formatUnixTime(seconds: number): string { + try { + return new Date(seconds * 1000).toISOString().replace('T', ' ').replace('Z', ' UTC'); + } catch { + return String(seconds); + } +} + +export function base64UrlDecode(s: string): string { + const b64 = s.replace(/-/g, '+').replace(/_/g, '/'); + const padded = b64 + '=='.slice((b64.length + 3) & 3); + return atob(padded); +} + +export function parseJwt(jwt: string): { + header: Record; + payload: Record; + signaturePreview: string; +} { + const parts = jwt.split('.'); + if (parts.length !== 3) throw new Error('Not a 3-part JWT'); + + const header = JSON.parse(base64UrlDecode(parts[0]!)) as Record; + const payload = JSON.parse(base64UrlDecode(parts[1]!)) as Record; + const signaturePreview = parts[2]!.slice(0, 16) + '…'; + + return { header, payload, signaturePreview }; +} diff --git a/packages/devtools-extension/src/panel/panel.html b/packages/devtools-extension/src/panel/panel.html new file mode 100644 index 0000000000..86e24f561b --- /dev/null +++ b/packages/devtools-extension/src/panel/panel.html @@ -0,0 +1,1310 @@ + + + + + + + +
+ + + + diff --git a/packages/devtools-extension/src/panel/panel.ts b/packages/devtools-extension/src/panel/panel.ts new file mode 100644 index 0000000000..9f635a2d45 --- /dev/null +++ b/packages/devtools-extension/src/panel/panel.ts @@ -0,0 +1,377 @@ +import { Schema } from 'effect'; +import { FlowExportSchema } from '@forgerock/devtools-types'; +import type { FlowExport } from '@forgerock/devtools-types'; +import { redactFlowState } from '../export/redact.js'; +import { renderFlowMarkdown } from '../export/markdown.js'; +import { runDiagnosis } from '../background/diagnosis-engine.js'; +import { formatUnixTime, parseJwt } from './jwt.js'; + +declare const Elm: { + Main: { + init: (opts: { node: HTMLElement | null; flags: null }) => { + ports: { + receiveEvent: { send: (event: unknown) => void }; + receiveDiagnosis: { send: (diagnosis: unknown) => void }; + receiveImportMeta: { send: (meta: unknown) => void }; + receiveImportError: { send: (error: unknown) => void }; + exportJson: { subscribe: (cb: () => void) => void }; + exportMarkdown: { subscribe: (cb: () => void) => void }; + submitImportPaste: { subscribe: (cb: (text: string) => void) => void }; + clearFlow: { subscribe: (cb: () => void) => void }; + saveSnapshot: { subscribe: (cb: () => void) => void }; + requestSnapshots: { subscribe: (cb: () => void) => void }; + receiveSnapshots: { send: (snapshots: unknown[]) => void }; + loadSnapshot: { subscribe: (cb: (id: string) => void) => void }; + deleteSnapshot: { subscribe: (cb: (id: string) => void) => void }; + copyToClipboard: { subscribe: (cb: (text: string) => void) => void }; + }; + }; + }; +}; + +// ── Panel resize ───────────────────────────────────────────────────────────── + +const MIN_GRAPH_W = 120; +const MAX_GRAPH_W = 480; +const MIN_INSP_H = 80; +const MAX_INSP_H = 600; + +const root = document.documentElement; + +function setGraphW(px: number) { + const clamped = Math.min(MAX_GRAPH_W, Math.max(MIN_GRAPH_W, px)); + root.style.setProperty('--graph-w', `${clamped}px`); +} + +function setInspH(px: number) { + const clamped = Math.min(MAX_INSP_H, Math.max(MIN_INSP_H, px)); + root.style.setProperty('--insp-h', `${clamped}px`); +} + +function makeResizeHandle(cls: 'resize-handle-v' | 'resize-handle-h'): HTMLDivElement { + const el = document.createElement('div'); + el.className = `resize-handle ${cls}`; + document.body.appendChild(el); + return el; +} + +function initResizeHandles() { + const vHandle = makeResizeHandle('resize-handle-v'); + const hHandle = makeResizeHandle('resize-handle-h'); + + // ── vertical (graph width) ────────────────────────────────────── + vHandle.addEventListener('mousedown', (e: MouseEvent) => { + e.preventDefault(); + vHandle.classList.add('dragging'); + document.body.classList.add('resizing'); + const startX = e.clientX; + const startW = parseInt(getComputedStyle(root).getPropertyValue('--graph-w'), 10); + + function onMove(ev: MouseEvent) { + setGraphW(startW + (ev.clientX - startX)); + } + function onUp() { + vHandle.classList.remove('dragging'); + document.body.classList.remove('resizing'); + window.removeEventListener('mousemove', onMove); + window.removeEventListener('mouseup', onUp); + } + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + }); + + // ── horizontal (inspector height) ────────────────────────────── + hHandle.addEventListener('mousedown', (e: MouseEvent) => { + e.preventDefault(); + hHandle.classList.add('dragging'); + document.body.classList.add('resizing'); + const startY = e.clientY; + const startH = parseInt(getComputedStyle(root).getPropertyValue('--insp-h'), 10); + + function onMove(ev: MouseEvent) { + // dragging up = larger inspector (bottom - cursor moves up) + setInspH(startH - (ev.clientY - startY)); + } + function onUp() { + hHandle.classList.remove('dragging'); + document.body.classList.remove('resizing'); + window.removeEventListener('mousemove', onMove); + window.removeEventListener('mouseup', onUp); + } + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + }); +} + +// ── JWT Decoder ─────────────────────────────────────────────────────────────── + +function makeEl( + tag: K, + classes: string[], + textContent?: string, +): HTMLElementTagNameMap[K] { + const el = document.createElement(tag); + el.className = classes.join(' '); + if (textContent !== undefined) el.textContent = textContent; + return el; +} + +function buildJwtValueNodes(key: string, val: unknown): Node[] { + const nodes: Node[] = []; + const isTimestamp = (key === 'exp' || key === 'iat' || key === 'nbf') && typeof val === 'number'; + + if (val === null) { + nodes.push(makeEl('span', ['jwt-v', 'jwt-v-null'], 'null')); + } else if (typeof val === 'boolean') { + nodes.push(makeEl('span', ['jwt-v', 'jwt-v-bool'], String(val))); + } else if (typeof val === 'number') { + nodes.push(makeEl('span', ['jwt-v', 'jwt-v-num'], String(val))); + if (isTimestamp) { + nodes.push(makeEl('span', ['jwt-v-date'], `(${formatUnixTime(val)})`)); + } + if (key === 'exp' && val * 1000 < Date.now()) { + nodes.push(makeEl('span', ['jwt-expired'], '⚠ EXPIRED')); + } + } else if (typeof val === 'string') { + nodes.push(makeEl('span', ['jwt-v'], `"${val}"`)); + } else { + nodes.push(makeEl('span', ['jwt-v'], JSON.stringify(val))); + } + + return nodes; +} + +function buildJwtSection(title: string, obj: Record): DocumentFragment { + const frag = document.createDocumentFragment(); + + frag.appendChild(makeEl('div', ['jwt-section-hdr'], title)); + + for (const [k, v] of Object.entries(obj)) { + const row = makeEl('div', ['jwt-kv']); + row.appendChild(makeEl('span', ['jwt-k'], k)); + for (const node of buildJwtValueNodes(k, v)) { + row.appendChild(node); + } + frag.appendChild(row); + } + + return frag; +} + +function buildJwtBody(jwt: string): HTMLElement { + const body = makeEl('div', ['jwt-body']); + + try { + const { header, payload, signaturePreview } = parseJwt(jwt); + + body.appendChild(buildJwtSection('Header', header)); + body.appendChild(buildJwtSection('Claims', payload)); + body.appendChild(makeEl('div', ['jwt-section-hdr'], 'Signature')); + body.appendChild(makeEl('span', ['jwt-sig'], `${signaturePreview} (not verified)`)); + } catch (err) { + body.appendChild(makeEl('span', ['jwt-err'], `Could not decode JWT: ${String(err)}`)); + } + + return body; +} + +function initJwtObserver(appRoot: HTMLElement) { + function processAll() { + appRoot.querySelectorAll('.jwt-pending[data-jwt]').forEach((el) => { + const jwt = el.getAttribute('data-jwt')!; + el.removeAttribute('data-jwt'); + el.classList.remove('jwt-pending'); + el.appendChild(buildJwtBody(jwt)); + }); + } + + const observer = new MutationObserver(processAll); + observer.observe(appRoot, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ['data-jwt'], + }); + + // Process any JWTs already in the DOM at init time + processAll(); +} + +// ── App init ────────────────────────────────────────────────────────────────── + +const app = Elm.Main.init({ node: document.getElementById('app'), flags: null }); + +initResizeHandles(); + +function copyToClipboard(text: string): void { + if (navigator.clipboard?.writeText) { + navigator.clipboard.writeText(text).catch(() => fallbackCopy(text)); + } else { + fallbackCopy(text); + } +} + +function fallbackCopy(text: string): void { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); +} + +const appRoot = document.getElementById('app'); +if (appRoot) { + initJwtObserver(appRoot); +} + +chrome.runtime.onMessage.addListener((message) => { + if (message.type === 'PANEL_EVENT') { + app.ports.receiveEvent.send(message.payload); + if (message.diagnosis) { + app.ports.receiveDiagnosis.send(message.diagnosis); + } + } +}); + +chrome.runtime.sendMessage({ type: 'GET_STATE' }, (state) => { + if (state?.events) { + state.events.forEach((event: unknown) => app.ports.receiveEvent.send(event)); + } +}); + +app.ports.exportJson?.subscribe(() => { + chrome.runtime.sendMessage({ type: 'GET_STATE' }, (state) => { + if (!state) return; + const redacted = redactFlowState(state); + const envelope: FlowExport = { + version: 1, + exportedAt: new Date().toISOString(), + redacted: true, + flow: redacted, + }; + copyToClipboard(JSON.stringify(envelope, null, 2)); + }); +}); + +app.ports.exportMarkdown?.subscribe(() => { + chrome.runtime.sendMessage({ type: 'GET_STATE' }, (state) => { + if (!state) return; + const redacted = redactFlowState(state); + const diagnosis = runDiagnosis(redacted.events); + const md = renderFlowMarkdown(redacted, diagnosis); + copyToClipboard(md); + }); +}); + +app.ports.submitImportPaste?.subscribe((text: string) => { + try { + const parsed = JSON.parse(text); + const decoded = Schema.decodeUnknownSync(FlowExportSchema)(parsed); + + chrome.runtime.sendMessage({ type: 'CLEAR' }); + + for (const event of decoded.flow.events) { + app.ports.receiveEvent.send(event); + } + + const diagnosis = runDiagnosis(decoded.flow.events); + app.ports.receiveDiagnosis.send({ + ...diagnosis, + annotatedEvents: Object.fromEntries(diagnosis.annotatedEvents), + }); + + app.ports.receiveImportMeta.send({ + flowId: decoded.flow.flowId, + capturedAt: decoded.flow.capturedAt, + redacted: decoded.redacted, + }); + } catch (e) { + app.ports.receiveImportError.send({ + message: e instanceof Error ? e.message : 'Invalid export format', + }); + } +}); + +app.ports.copyToClipboard?.subscribe((text: string) => { + copyToClipboard(text); +}); + +app.ports.clearFlow?.subscribe(() => { + chrome.runtime.sendMessage({ type: 'CLEAR' }); +}); + +const SNAPSHOTS_KEY = 'ping:auth-flow:snapshots'; +const MAX_SNAPSHOTS = 5; + +app.ports.saveSnapshot?.subscribe(() => { + chrome.runtime.sendMessage({ type: 'GET_STATE' }, (state) => { + if (!state) return; + chrome.storage.local.get(SNAPSHOTS_KEY, (result) => { + const existing: unknown[] = Array.isArray(result[SNAPSHOTS_KEY]) ? result[SNAPSHOTS_KEY] : []; + const snapshot = { + id: crypto.randomUUID(), + savedAt: new Date().toISOString(), + flowState: state, + }; + const updated = [...existing, snapshot].slice(-MAX_SNAPSHOTS); + chrome.storage.local.set({ [SNAPSHOTS_KEY]: updated }); + }); + }); +}); + +app.ports.requestSnapshots?.subscribe(() => { + chrome.storage.local.get(SNAPSHOTS_KEY, (result) => { + const snapshots: unknown[] = Array.isArray(result[SNAPSHOTS_KEY]) ? result[SNAPSHOTS_KEY] : []; + const metas = ( + snapshots as Array<{ + id: string; + savedAt: string; + flowState: { flowId?: string | null; events?: unknown[] }; + }> + ).map((s) => ({ + id: s.id, + savedAt: s.savedAt, + flowId: s.flowState?.flowId ?? null, + eventCount: s.flowState?.events?.length ?? 0, + })); + app.ports.receiveSnapshots.send(metas); + }); +}); + +app.ports.loadSnapshot?.subscribe((snapshotId: string) => { + chrome.storage.local.get(SNAPSHOTS_KEY, (result) => { + const snapshots = Array.isArray(result[SNAPSHOTS_KEY]) ? result[SNAPSHOTS_KEY] : []; + const snapshot = snapshots.find((s: { id: string }) => s.id === snapshotId); + if (!snapshot) return; + + chrome.runtime.sendMessage({ type: 'CLEAR' }); + const state = snapshot.flowState; + + for (const event of state.events) { + app.ports.receiveEvent.send(event); + } + + const diagnosis = runDiagnosis(state.events); + app.ports.receiveDiagnosis.send({ + ...diagnosis, + annotatedEvents: Object.fromEntries(diagnosis.annotatedEvents), + }); + + app.ports.receiveImportMeta.send({ + flowId: state.flowId ?? null, + capturedAt: snapshot.savedAt, + redacted: false, + }); + }); +}); + +app.ports.deleteSnapshot?.subscribe((snapshotId: string) => { + chrome.storage.local.get(SNAPSHOTS_KEY, (result) => { + const snapshots = Array.isArray(result[SNAPSHOTS_KEY]) ? result[SNAPSHOTS_KEY] : []; + const updated = snapshots.filter((s: { id: string }) => s.id !== snapshotId); + chrome.storage.local.set({ [SNAPSHOTS_KEY]: updated }); + }); +}); diff --git a/packages/devtools-extension/src/panel/src/Decode.elm b/packages/devtools-extension/src/panel/src/Decode.elm new file mode 100644 index 0000000000..d09c46688e --- /dev/null +++ b/packages/devtools-extension/src/panel/src/Decode.elm @@ -0,0 +1,319 @@ +module Decode exposing (decodeAuthEvent, decodeDiagnosisResult, decodeImportMeta, decodeSnapshotMeta) + +import Json.Decode as JD +import Json.Decode.Pipeline exposing (hardcoded, optional, required) +import Types + exposing + ( AuthEvent + , DiagnosisResult + , EventData(..) + , EventIssue + , EventKind(..) + , EventSource(..) + , FlowHealth(..) + , FlowIssue + , ImportMeta + , JourneyData + , NetworkData + , NodeData + , NodeStatus(..) + , OidcData + , SdkAuthorization + , SdkError + , SessionData + , Severity(..) + , SnapshotMeta + ) + + +decodeSeverity : JD.Decoder Severity +decodeSeverity = + JD.string + |> JD.andThen + (\s -> + case s of + "error" -> + JD.succeed SevError + + "warning" -> + JD.succeed SevWarning + + _ -> + JD.succeed SevInfo + ) + + +decodeNodeStatus : JD.Decoder NodeStatus +decodeNodeStatus = + JD.string + |> JD.andThen + (\s -> + case s of + "continue" -> + JD.succeed Continue + + "success" -> + JD.succeed Success + + "error" -> + JD.succeed StatusError + + "failure" -> + JD.succeed Failure + + _ -> + JD.succeed UnknownStatus + ) + + +decodeEventKind : String -> String -> EventKind +decodeEventKind eventTypeStr sourceStr = + case eventTypeStr of + "sdk:node-change" -> + NodeChange + + "sdk:journey-step" -> + JourneyStep + + "sdk:oidc-state" -> + OidcState + + "sdk:config" -> + SdkConfig + + _ -> + if sourceStr == "session" then + SessionEvent + + else if sourceStr == "network" then + NetworkEvent + + else + OtherKind eventTypeStr + + +decodeEventSource : String -> EventSource +decodeEventSource sourceStr = + case sourceStr of + "network" -> + NetworkSource + + "sdk" -> + SdkSource + + "session" -> + SessionSource + + _ -> + OtherSource sourceStr + + +decodeSdkError : JD.Decoder SdkError +decodeSdkError = + JD.succeed SdkError + |> required "code" JD.string + |> required "message" JD.string + |> required "type" JD.string + |> optional "internalHttpStatus" (JD.nullable JD.int) Nothing + + +decodeSdkAuthorization : JD.Decoder SdkAuthorization +decodeSdkAuthorization = + JD.succeed SdkAuthorization + |> optional "code" (JD.nullable JD.string) Nothing + |> optional "state" (JD.nullable JD.string) Nothing + + +decodeNetworkData : JD.Decoder NetworkData +decodeNetworkData = + JD.succeed NetworkData + |> optional "status" (JD.nullable JD.int) Nothing + |> optional "url" (JD.nullable JD.string) Nothing + |> optional "method" (JD.nullable JD.string) Nothing + |> optional "duration" (JD.nullable JD.float) Nothing + |> optional "requestHeaders" (JD.nullable JD.value) Nothing + |> optional "responseHeaders" (JD.nullable JD.value) Nothing + |> optional "requestBody" (JD.nullable JD.value) Nothing + |> optional "responseBody" (JD.nullable JD.value) Nothing + + +decodeNodeData : JD.Decoder NodeData +decodeNodeData = + JD.succeed NodeData + |> optional "nodeStatus" (JD.nullable decodeNodeStatus) Nothing + |> optional "previousStatus" (JD.nullable decodeNodeStatus) Nothing + |> optional "interactionId" (JD.nullable JD.string) Nothing + |> optional "interactionToken" (JD.nullable JD.string) Nothing + |> optional "nodeId" (JD.nullable JD.string) Nothing + |> optional "requestId" (JD.nullable JD.string) Nothing + |> optional "nodeName" (JD.nullable JD.string) Nothing + |> optional "nodeDescription" (JD.nullable JD.string) Nothing + |> optional "eventName" (JD.nullable JD.string) Nothing + |> optional "httpStatus" (JD.nullable JD.int) Nothing + |> optional "error" (JD.nullable decodeSdkError) Nothing + |> optional "authorization" (JD.nullable decodeSdkAuthorization) Nothing + |> optional "session" (JD.nullable JD.string) Nothing + |> optional "collectors" (JD.nullable (JD.list JD.value)) Nothing + |> optional "responseBody" (JD.nullable JD.value) Nothing + + +decodeJourneyData : JD.Decoder JourneyData +decodeJourneyData = + JD.succeed JourneyData + |> optional "stepType" (JD.nullable JD.string) Nothing + |> optional "stage" (JD.nullable JD.string) Nothing + |> optional "header" (JD.nullable JD.string) Nothing + |> optional "description" (JD.nullable JD.string) Nothing + |> optional "callbacks" (JD.nullable (JD.list JD.value)) Nothing + |> optional "authId" (JD.nullable JD.string) Nothing + |> optional "tokenId" (JD.nullable JD.string) Nothing + |> optional "successUrl" (JD.nullable JD.string) Nothing + |> optional "errorCode" (JD.nullable JD.int) Nothing + |> optional "errorMessage" (JD.nullable JD.string) Nothing + |> optional "errorReason" (JD.nullable JD.string) Nothing + + +decodeOidcData : JD.Decoder OidcData +decodeOidcData = + JD.succeed OidcData + |> optional "phase" (JD.nullable JD.string) Nothing + |> optional "status" (JD.nullable JD.string) Nothing + |> optional "clientId" (JD.nullable JD.string) Nothing + |> optional "errorCode" (JD.nullable JD.string) Nothing + |> optional "errorMessage" (JD.nullable JD.string) Nothing + + +decodeSessionData : JD.Decoder SessionData +decodeSessionData = + JD.succeed SessionData + |> optional "key" (JD.nullable JD.string) Nothing + |> optional "before" (JD.nullable JD.string) Nothing + |> optional "after" (JD.nullable JD.string) Nothing + + +decodeEventData : EventKind -> JD.Decoder EventData +decodeEventData kind = + case kind of + NodeChange -> + JD.field "data" (JD.map DaVinciNode decodeNodeData) + + JourneyStep -> + JD.field "data" (JD.map Journey decodeJourneyData) + + OidcState -> + JD.field "data" (JD.map Oidc decodeOidcData) + + SdkConfig -> + JD.map Config (JD.maybe (JD.at [ "data", "config" ] JD.value)) + + SessionEvent -> + JD.field "data" (JD.map Session decodeSessionData) + + NetworkEvent -> + JD.field "data" (JD.map Network decodeNetworkData) + + OtherKind _ -> + JD.field "data" (JD.map Network decodeNetworkData) + + +decodeAuthEvent : JD.Decoder AuthEvent +decodeAuthEvent = + JD.field "type" JD.string + |> JD.andThen + (\eventTypeStr -> + JD.field "source" JD.string + |> JD.andThen + (\sourceStr -> + let + kind = + decodeEventKind eventTypeStr sourceStr + + source = + decodeEventSource sourceStr + in + JD.succeed AuthEvent + |> required "id" JD.string + |> required "timestamp" JD.float + |> hardcoded kind + |> hardcoded source + |> required "flowId" (JD.nullable JD.string) + |> Json.Decode.Pipeline.custom (JD.at [ "flags", "isCors" ] JD.bool) + |> Json.Decode.Pipeline.custom (JD.at [ "flags", "isError" ] JD.bool) + |> Json.Decode.Pipeline.custom (JD.at [ "flags", "isAuthRelated" ] JD.bool) + |> required "causedBy" (JD.nullable JD.string) + |> Json.Decode.Pipeline.custom (decodeEventData kind) + ) + ) + + +decodeRelevantData : JD.Decoder (Maybe (List ( String, String ))) +decodeRelevantData = + JD.maybe + (JD.field "relevantData" + (JD.keyValuePairs JD.string) + ) + + +decodeEventIssue : JD.Decoder EventIssue +decodeEventIssue = + JD.succeed EventIssue + |> required "severity" decodeSeverity + |> required "title" JD.string + |> required "description" JD.string + |> required "steps" (JD.list JD.string) + |> Json.Decode.Pipeline.custom decodeRelevantData + + +decodeFlowIssue : JD.Decoder FlowIssue +decodeFlowIssue = + JD.succeed FlowIssue + |> required "id" JD.string + |> required "severity" decodeSeverity + |> required "category" JD.string + |> required "title" JD.string + |> required "description" JD.string + |> required "steps" (JD.list JD.string) + |> required "relatedEventIds" (JD.list JD.string) + |> Json.Decode.Pipeline.custom decodeRelevantData + + +decodeFlowHealth : JD.Decoder FlowHealth +decodeFlowHealth = + JD.string + |> JD.andThen + (\s -> + case s of + "error" -> + JD.succeed Error + + "warning" -> + JD.succeed Warning + + _ -> + JD.succeed Healthy + ) + + +decodeDiagnosisResult : JD.Decoder DiagnosisResult +decodeDiagnosisResult = + JD.succeed DiagnosisResult + |> required "flowHealth" decodeFlowHealth + |> required "issues" (JD.list decodeFlowIssue) + |> required "annotatedEvents" (JD.keyValuePairs (JD.list decodeEventIssue)) + + +decodeImportMeta : JD.Decoder ImportMeta +decodeImportMeta = + JD.succeed ImportMeta + |> required "flowId" (JD.nullable JD.string) + |> required "capturedAt" JD.string + |> required "redacted" JD.bool + + +decodeSnapshotMeta : JD.Decoder SnapshotMeta +decodeSnapshotMeta = + JD.succeed SnapshotMeta + |> required "id" JD.string + |> required "savedAt" JD.string + |> required "flowId" (JD.nullable JD.string) + |> required "eventCount" JD.int diff --git a/packages/devtools-extension/src/panel/src/FlowView.elm b/packages/devtools-extension/src/panel/src/FlowView.elm new file mode 100644 index 0000000000..d398ed8244 --- /dev/null +++ b/packages/devtools-extension/src/panel/src/FlowView.elm @@ -0,0 +1,689 @@ +module FlowView exposing (view, viewPlaybackControls) + +import Helpers +import Html exposing (Html) +import Html.Attributes exposing (..) +import Html.Events +import Json.Encode as Encode +import JsonTree +import Set exposing (Set) +import Svg exposing (..) +import Svg.Attributes as SA +import Svg.Events +import Types exposing (AuthEvent, EventData(..), JourneyData, NetworkData, NodeData, NodeStatus(..), OidcData) +import Update exposing (Msg(..)) + + +nodeStatusFromEvent : AuthEvent -> NodeStatus +nodeStatusFromEvent event = + case event.data of + Oidc oidc -> + case oidc.status of + Just "success" -> + Success + + Just "error" -> + StatusError + + _ -> + UnknownStatus + + Journey journey -> + case journey.stepType of + Just "LoginSuccess" -> + Success + + Just "LoginFailure" -> + Failure + + Just "Step" -> + Continue + + _ -> + UnknownStatus + + DaVinciNode node -> + Maybe.withDefault UnknownStatus node.nodeStatus + + _ -> + UnknownStatus + + +nodeDisplayLabel : AuthEvent -> String +nodeDisplayLabel event = + case event.data of + Oidc oidc -> + Maybe.withDefault "oidc" oidc.phase + + Journey journey -> + journey.stage + |> orMaybe journey.header + |> orMaybe journey.stepType + |> Maybe.withDefault "—" + + DaVinciNode node -> + node.nodeName + |> orMaybe node.eventName + |> Maybe.withDefault "—" + + _ -> + "—" + + +orMaybe : Maybe a -> Maybe a -> Maybe a +orMaybe fallback primary = + case primary of + Just _ -> + primary + + Nothing -> + fallback + + +-- ── SVG Rail ────────────────────────────────────────────────────────────────── + + +nodeSpacing : Int +nodeSpacing = + 140 + + +nodeRadius : Int +nodeRadius = + 18 + + +railHeight : Int +railHeight = + 110 + + +viewRail : List AuthEvent -> Maybe Int -> Maybe String -> Html Msg +viewRail events playbackIndex selectedNodeId = + let + sdkNodes = + Helpers.sdkNodes events + + visibleNodes = + case playbackIndex of + Nothing -> + sdkNodes + + Just n -> + List.take (n + 1) sdkNodes + + count = + List.length visibleNodes + + svgWidth = + if count == 0 then + 200 + else + count * nodeSpacing + 60 + in + Html.div [ Html.Attributes.class "fv-rail" ] + [ if List.isEmpty sdkNodes then + Html.div [ Html.Attributes.class "fv-rail-empty" ] [ Html.text "No SDK nodes recorded yet." ] + + else + Svg.svg + [ SA.width (String.fromInt svgWidth) + , SA.height (String.fromInt railHeight) + , SA.viewBox ("0 0 " ++ String.fromInt svgWidth ++ " " ++ String.fromInt railHeight) + , SA.style "display:block" + ] + (railDefs + :: List.concat (List.indexedMap (renderRailNode selectedNodeId) visibleNodes) + ++ List.concat (List.indexedMap (\i _ -> renderArrow (List.length visibleNodes) i) visibleNodes) + ) + ] + + +railDefs : Svg Msg +railDefs = + Svg.defs [] + [ Svg.filter [ SA.id "fv-glow" ] + [ Svg.feGaussianBlur [ SA.stdDeviation "4", SA.result "blur" ] [] + , Svg.feMerge [] + [ Svg.feMergeNode [ SA.in_ "blur" ] [] + , Svg.feMergeNode [ SA.in_ "SourceGraphic" ] [] + ] + ] + , Svg.marker + [ SA.id "arrowhead" + , SA.markerWidth "8" + , SA.markerHeight "8" + , SA.refX "6" + , SA.refY "3" + , SA.orient "auto" + ] + [ Svg.polygon + [ SA.points "0 0, 8 3, 0 6" + , SA.fill "#30363D" + ] + [] + ] + ] + + +renderRailNode : Maybe String -> Int -> AuthEvent -> List (Svg Msg) +renderRailNode selectedNodeId index event = + let + cx_ = + index * nodeSpacing + 40 + + cy_ = + 44 + + status = + nodeStatusFromEvent event + + color = + Helpers.nodeColor status + + isSelected = + selectedNodeId == Just event.id + + label = + nodeDisplayLabel event + + statusLabel = + Helpers.nodeStatusLabel status + + glowRing = + if isSelected then + [ Svg.circle + [ SA.cx (String.fromInt cx_) + , SA.cy (String.fromInt cy_) + , SA.r (String.fromInt (nodeRadius + 6)) + , SA.fill "none" + , SA.stroke color + , SA.strokeWidth "2" + , SA.strokeOpacity "0.5" + , SA.filter "url(#fv-glow)" + ] + [] + ] + + else + [] + + nodeBg = + Svg.circle + [ SA.cx (String.fromInt cx_) + , SA.cy (String.fromInt cy_) + , SA.r (String.fromInt nodeRadius) + , SA.fill color + ] + [] + + nameLabel = + Svg.text_ + [ SA.x (String.fromInt cx_) + , SA.y (String.fromInt (cy_ + nodeRadius + 14)) + , SA.textAnchor "middle" + , SA.fontSize "10" + , SA.fill "#8B949E" + , SA.fontFamily "'Segoe UI', system-ui, sans-serif" + ] + [ Svg.text (truncate_ 14 label) ] + + statusText = + Svg.text_ + [ SA.x (String.fromInt cx_) + , SA.y (String.fromInt (cy_ + nodeRadius + 26)) + , SA.textAnchor "middle" + , SA.fontSize "10" + , SA.fill color + , SA.fontFamily "'Segoe UI', system-ui, sans-serif" + ] + [ Svg.text statusLabel ] + in + glowRing + ++ [ Svg.g + [ Svg.Events.onClick (SelectFlowNode event.id) + , SA.style "cursor:pointer" + ] + [ nodeBg, nameLabel, statusText ] + ] + + +renderArrow : Int -> Int -> List (Svg Msg) +renderArrow total index = + if index >= total - 1 then + [] + + else + let + x1_ = + index * nodeSpacing + 40 + nodeRadius + 16 + + x2_ = + (index + 1) * nodeSpacing + 40 - nodeRadius - 16 + + y_ = + 44 + in + [ Svg.line + [ SA.x1 (String.fromInt x1_) + , SA.y1 (String.fromInt y_) + , SA.x2 (String.fromInt x2_) + , SA.y2 (String.fromInt y_) + , SA.stroke "#30363D" + , SA.strokeWidth "1.5" + , SA.markerEnd "url(#arrowhead)" + ] + [] + ] + + +truncate_ : Int -> String -> String +truncate_ maxLen s = + if String.length s <= maxLen then + s + + else + String.left maxLen s ++ "…" + + +-- ── Detail card ─────────────────────────────────────────────────────────────── + + +viewDetail : List AuthEvent -> Maybe String -> Set String -> Html Msg +viewDetail events selectedNodeId expandedSubRows = + case selectedNodeId of + Nothing -> + Html.div [ Html.Attributes.class "fv-detail fv-detail-empty" ] + [ Html.text "Select a node to see its details." ] + + Just nodeId -> + let + maybeNode = + List.head (List.filter (\e -> e.id == nodeId) events) + + netEvents = + List.filter (\e -> e.causedBy == Just nodeId) events + |> List.sortBy .timestamp + in + Html.div [ Html.Attributes.class "fv-detail" ] + (viewNodeData maybeNode expandedSubRows + ++ viewNetworkSection nodeId netEvents expandedSubRows + ) + + +viewKvRow : String -> String -> Html Msg +viewKvRow key val = + Html.div [ Html.Attributes.class "fv-kv-row" ] + [ Html.span [ Html.Attributes.class "fv-kv-key" ] [ Html.text key ] + , Html.span [ Html.Attributes.class "fv-kv-val" ] [ Html.text val ] + , Html.button + [ Html.Attributes.class "fv-copy-btn" + , Html.Events.onClick (CopyToClipboard val) + ] + [ Html.text "⎘" ] + ] + + +viewNodeData : Maybe AuthEvent -> Set String -> List (Html Msg) +viewNodeData maybeNode expandedSubRows = + case maybeNode of + Nothing -> + [] + + Just node -> + case node.data of + Journey journey -> + viewJourneyNodeData node.id journey expandedSubRows + + Oidc oidc -> + viewOidcNodeData oidc + + DaVinciNode dvNode -> + viewDaVinciNodeData node.id dvNode expandedSubRows + + _ -> + [] + + +viewDaVinciNodeData : String -> NodeData -> Set String -> List (Html Msg) +viewDaVinciNodeData nodeId node expandedSubRows = + let + hasResponse = + node.responseBody /= Nothing + + collectorCount = + Maybe.withDefault 0 (Maybe.map List.length node.collectors) + + hasCollectors = + collectorCount > 0 + + responseKey = + nodeId ++ ":node-response" + + collectorsKey = + nodeId ++ ":node-collectors" + in + if not hasResponse && not hasCollectors then + [] + + else + [ Html.div [ Html.Attributes.class "fv-net-group" ] + [ Html.div [ Html.Attributes.class "fv-net-group-header" ] + [ Html.span [ Html.Attributes.class "fv-node-label" ] + [ Html.text (Maybe.withDefault "Node Response" node.nodeName) ] + ] + , if hasResponse then + viewSection responseKey "Response" expandedSubRows + [ case node.responseBody of + Just body -> JsonTree.view "Response" body + Nothing -> Html.text "" + ] + + else + Html.text "" + , if hasCollectors then + viewSection collectorsKey ("Collectors (" ++ String.fromInt collectorCount ++ ")") expandedSubRows + (case node.collectors of + Nothing -> [] + Just cs -> + Html.div [ Html.Attributes.class "coll-copy-all-row" ] + [ Html.button + [ Html.Attributes.class "fv-copy-btn coll-copy-all" + , Html.Events.onClick (CopyToClipboard (Encode.encode 4 (Encode.list identity cs))) + ] + [ Html.text "Copy all" ] + ] + :: List.indexedMap + (\i c -> + Html.div [ Html.Attributes.class "coll-card" ] + [ Html.div [ Html.Attributes.class "coll-card-header" ] + [ Html.span [] [ Html.text ("Collector " ++ String.fromInt (i + 1)) ] + , Html.button + [ Html.Attributes.class "fv-copy-btn" + , Html.Events.onClick (CopyToClipboard (Encode.encode 4 c)) + ] + [ Html.text "\u{2398}" ] + ] + , JsonTree.view ("Collector " ++ String.fromInt (i + 1)) c + ] + ) + cs + ) + + else + Html.text "" + ] + ] + + +viewJourneyNodeData : String -> JourneyData -> Set String -> List (Html Msg) +viewJourneyNodeData nodeId journey expandedSubRows = + let + stepType = + Maybe.withDefault "Step" journey.stepType + + callbacks = + Maybe.withDefault [] journey.callbacks + + cbCount = + List.length callbacks + + hasCallbacks = + cbCount > 0 + + isFailure = + stepType == "LoginFailure" + + isSuccess = + stepType == "LoginSuccess" + + callbacksKey = + nodeId ++ ":journey-callbacks" + + sessionKey = + nodeId ++ ":journey-session" + + title = + case journey.header of + Just h -> h + Nothing -> + case journey.stage of + Just s -> s + Nothing -> stepType + in + [ Html.div [ Html.Attributes.class "fv-net-group" ] + ([ Html.div [ Html.Attributes.class "fv-net-group-header" ] + [ Html.span [ Html.Attributes.class "fv-node-label" ] [ Html.text title ] + , Html.span + [ Html.Attributes.class + ("fv-net-status " + ++ (if isFailure then "st-err" else if isSuccess then "st-ok" else "st-nil") + ) + ] + [ Html.text stepType ] + ] + ] + ++ (if isFailure then + [ Html.div [ Html.Attributes.class "fv-journey-error" ] + [ case journey.errorMessage of + Just msg -> Html.p [] [ Html.text msg ] + Nothing -> Html.text "" + , case journey.errorReason of + Just reason -> Html.p [ Html.Attributes.class "fv-journey-reason" ] [ Html.text ("Reason: " ++ reason) ] + Nothing -> Html.text "" + ] + ] + + else if isSuccess then + [ viewSection sessionKey "Session" expandedSubRows + [ Html.div [ Html.Attributes.class "fv-kv-list" ] + (List.filterMap identity + [ Maybe.map (viewKvRow "tokenId") journey.tokenId + , Maybe.map (viewKvRow "successUrl") journey.successUrl + ] + ) + ] + ] + + else + [] + ) + ++ (if hasCallbacks then + [ viewSection callbacksKey ("Callbacks (" ++ String.fromInt cbCount ++ ")") expandedSubRows + (List.indexedMap + (\i cb -> + Html.div [ Html.Attributes.class "coll-card" ] + [ JsonTree.view ("Callback " ++ String.fromInt (i + 1)) cb ] + ) + callbacks + ) + ] + + else + [] + ) + ) + ] + + +viewOidcNodeData : OidcData -> List (Html Msg) +viewOidcNodeData oidc = + let + phase = + Maybe.withDefault "—" oidc.phase + + status = + Maybe.withDefault "—" oidc.status + + isError = + status == "error" + in + [ Html.div [ Html.Attributes.class "fv-net-group" ] + [ Html.div [ Html.Attributes.class "fv-net-group-header" ] + [ Html.span [ Html.Attributes.class "fv-node-label" ] [ Html.text phase ] + , Html.span + [ Html.Attributes.class ("fv-net-status " ++ (if isError then "st-err" else "st-ok")) ] + [ Html.text status ] + ] + , Html.div [ Html.Attributes.class "fv-kv-list" ] + (List.filterMap identity + [ Maybe.map (viewKvRow "clientId") oidc.clientId + , if isError then Maybe.map (viewKvRow "errorCode") oidc.errorCode else Nothing + , if isError then Maybe.map (viewKvRow "errorMessage") oidc.errorMessage else Nothing + ] + ) + ] + ] + + +viewNetworkSection : String -> List AuthEvent -> Set String -> List (Html Msg) +viewNetworkSection _ netEvents expandedSubRows = + if List.isEmpty netEvents then + [] + + else + List.map (\e -> viewNetGroup e expandedSubRows) netEvents + + +viewNetGroup : AuthEvent -> Set String -> Html Msg +viewNetGroup event expandedSubRows = + case event.data of + Network net -> + let + statusText = + Maybe.withDefault "—" (Maybe.map String.fromInt net.status) + + durationText = + case net.duration of + Nothing -> "" + Just ms -> + if ms < 1 then "<1ms" + else String.fromInt (round ms) ++ "ms" + + urlText = + Maybe.withDefault "—" net.url + + hasResponse = + net.responseBody /= Nothing + + hasRequest = + net.requestBody /= Nothing + + hasAny = + hasResponse || hasRequest + + responseKey = + event.id ++ ":response" + + requestKey = + event.id ++ ":request" + in + Html.div [ Html.Attributes.class "fv-net-group" ] + [ Html.div [ Html.Attributes.class "fv-net-group-header" ] + [ Html.span [ Html.Attributes.class ("tl-meth " ++ Helpers.methodClass net.method) ] + [ Html.text (Maybe.withDefault "—" net.method) ] + , Html.span [ Html.Attributes.class "fv-net-url" ] [ Html.text urlText ] + , Html.span [ Html.Attributes.class ("fv-net-status " ++ Helpers.statusClass net.status) ] + [ Html.text statusText ] + , Html.span [ Html.Attributes.class "fv-net-dur" ] [ Html.text durationText ] + ] + , if not hasAny then + Html.div [ Html.Attributes.class "fv-no-data" ] [ Html.text "No data captured for this request." ] + + else + Html.text "" + , if hasResponse then + viewSection responseKey "Response" expandedSubRows + [ case net.responseBody of + Just body -> JsonTree.view "Response" body + Nothing -> Html.text "" + ] + + else + Html.text "" + , if hasRequest then + viewSection requestKey "Request" expandedSubRows + [ case net.requestBody of + Just body -> JsonTree.view "Request" body + Nothing -> Html.text "" + ] + + else + Html.text "" + ] + + _ -> + Html.text "" + + +viewSection : String -> String -> Set String -> List (Html Msg) -> Html Msg +viewSection key label expandedSubRows content = + let + isOpen = + Set.member key expandedSubRows + + icon = + if isOpen then "▼ " else "▶ " + in + Html.div [] + [ Html.div + [ Html.Attributes.class "fv-section-row" + , Html.Events.onClick (ToggleSubRow key) + ] + [ Html.text (icon ++ label) ] + , if isOpen then + Html.div [ Html.Attributes.class "fv-section-body" ] content + + else + Html.text "" + ] + + +-- ── Main view ───────────────────────────────────────────────────────────────── + + +view : List AuthEvent -> Maybe Int -> Maybe String -> Set String -> Html Msg +view events playbackIndex selectedNodeId expandedSubRows = + Html.div [ Html.Attributes.class "fv-view" ] + [ viewRail events playbackIndex selectedNodeId + , viewDetail events selectedNodeId expandedSubRows + ] + + +-- ── Playback controls ───────────────────────────────────────────────────────── + + +viewPlaybackControls : List AuthEvent -> Maybe Int -> Bool -> Html Msg +viewPlaybackControls events playbackIndex isPlaying = + let + sdkNodes = + Helpers.sdkNodes events + + total = + List.length sdkNodes + + stepLabel = + case playbackIndex of + Just n -> "Step " ++ String.fromInt (n + 1) ++ " / " ++ String.fromInt total + Nothing -> "" + in + Html.div [ Html.Attributes.class "fv-playback-controls" ] + [ Html.button [ Html.Events.onClick ResetPlayback, Html.Attributes.class "tb-btn" ] [ Html.text "◀◀" ] + , if isPlaying then + Html.button [ Html.Events.onClick StopPlayback, Html.Attributes.class "tb-btn" ] [ Html.text "⏸ Pause" ] + + else + Html.button + [ Html.Events.onClick StartPlayback + , Html.Attributes.class "tb-btn" + , Html.Attributes.disabled (List.isEmpty sdkNodes) + ] + [ Html.text + (if playbackIndex == Nothing then "▶ Play" else "▶ Resume") + ] + , if stepLabel /= "" then + Html.span [ Html.Attributes.class "fv-step-label" ] [ Html.text stepLabel ] + + else + Html.text "" + ] diff --git a/packages/devtools-extension/src/panel/src/Graph.elm b/packages/devtools-extension/src/panel/src/Graph.elm new file mode 100644 index 0000000000..e60d15a2e4 --- /dev/null +++ b/packages/devtools-extension/src/panel/src/Graph.elm @@ -0,0 +1,234 @@ +module Graph exposing (view) + +import Helpers +import Html exposing (Html, div) +import Html.Attributes exposing (class) +import Json.Decode as Decode +import Svg exposing (..) +import Svg.Attributes as SA +import Svg.Events +import Types exposing (AuthEvent, EventData(..), EventKind(..), NodeStatus(..)) +import Update exposing (Msg(..)) + + +view : List AuthEvent -> Maybe String -> Maybe String -> Html Msg +view events selectedId hoveredId = + let + sdkNodes = + List.filter (\e -> e.kind == NodeChange) events + + nodeSpacing = + 90 + + totalHeight = + Basics.max 60 ((List.length sdkNodes * nodeSpacing) + 48) + in + if List.isEmpty sdkNodes then + div [ class "graph-empty" ] [ Svg.text "No SDK nodes" ] + + else + svg + [ SA.width "210" + , SA.height (String.fromInt totalHeight) + , SA.viewBox ("0 0 210 " ++ String.fromInt totalHeight) + , SA.class "graph-svg" + ] + (defs_ + :: List.concat (List.indexedMap (renderNode nodeSpacing selectedId hoveredId) sdkNodes) + ) + + +defs_ : Svg Msg +defs_ = + defs [] + [ Svg.filter [ SA.id "glow-node" ] + [ feGaussianBlur [ SA.stdDeviation "4", SA.result "blur" ] [] + , feMerge [] + [ feMergeNode [ SA.in_ "blur" ] [] + , feMergeNode [ SA.in_ "SourceGraphic" ] [] + ] + ] + ] + + +renderNode : Int -> Maybe String -> Maybe String -> Int -> AuthEvent -> List (Svg Msg) +renderNode spacing selectedId hoveredId index event = + let + cy_ = + index * spacing + 28 + + ( status, maybeName, maybeCollectors ) = + case event.data of + DaVinciNode node -> + ( Maybe.withDefault UnknownStatus node.nodeStatus, node.nodeName, node.collectors ) + + _ -> + ( UnknownStatus, Nothing, Nothing ) + + color = + Helpers.nodeColor status + + isSelected = + selectedId == Just event.id + + isHovered = + hoveredId == Just event.id + + isHighlighted = + isSelected || isHovered + + connectorLine = + if index > 0 then + [ line + [ SA.x1 "26" + , SA.y1 (String.fromInt (cy_ - spacing + 28)) + , SA.x2 "26" + , SA.y2 (String.fromInt (cy_ - 28)) + , SA.stroke color + , SA.strokeWidth "1" + , SA.strokeOpacity + (if isHighlighted then + "0.5" + + else + "0.2" + ) + , SA.strokeDasharray "4 4" + ] + [] + ] + + else + [] + + selectionRing = + if isSelected then + [ circle + [ SA.cx "26" + , SA.cy (String.fromInt cy_) + , SA.r "19" + , SA.fill "none" + , SA.stroke color + , SA.strokeWidth "1.5" + , SA.strokeOpacity "0.5" + , SA.class "graph-ring" + ] + [] + ] + + else + [] + + hoverRing = + if isHovered && not isSelected then + [ circle + [ SA.cx "26" + , SA.cy (String.fromInt cy_) + , SA.r "16" + , SA.fill "none" + , SA.stroke color + , SA.strokeWidth "1" + , SA.strokeOpacity "0.4" + ] + [] + ] + + else + [] + + nodeFilterAttr = + if isHighlighted then + SA.filter "url(#glow-node)" + + else + SA.class "" + + nodeBg = + circle + [ SA.cx "26" + , SA.cy (String.fromInt cy_) + , SA.r "10" + , SA.fill color + , nodeFilterAttr + ] + [] + + statusLabel = + Svg.text_ + [ SA.x "44" + , SA.y (String.fromInt (cy_ + 4)) + , SA.fontSize "12" + , SA.fill + (if isHighlighted then + "#ffffff" + + else + "#E6EDF3" + ) + , SA.fontFamily "'Segoe UI', system-ui, sans-serif" + ] + [ Svg.text (Helpers.nodeStatusLabel status) ] + + subLabel = + let + subFill = + if isHighlighted then + "#c9d1d9" + + else + "#8B949E" + in + case maybeName of + Just name -> + [ Svg.text_ + [ SA.x "44" + , SA.y (String.fromInt (cy_ + 17)) + , SA.fontSize "10" + , SA.fill subFill + , SA.fontFamily "'Segoe UI', system-ui, sans-serif" + ] + [ Svg.text name ] + ] + + Nothing -> + case maybeCollectors of + Just cs -> + if List.length cs > 0 then + [ Svg.text_ + [ SA.x "44" + , SA.y (String.fromInt (cy_ + 17)) + , SA.fontSize "10" + , SA.fill subFill + , SA.fontFamily "'Segoe UI', system-ui, sans-serif" + ] + [ Svg.text (String.fromInt (List.length cs) ++ " collectors") ] + ] + + else + [] + + Nothing -> + [] + + hitArea = + rect + [ SA.x "0" + , SA.y (String.fromInt (cy_ - 20)) + , SA.width "200" + , SA.height "40" + , SA.fill "transparent" + , SA.style "cursor:pointer" + ] + [] + in + connectorLine + ++ selectionRing + ++ hoverRing + ++ [ g + [ Svg.Events.onClick (SelectNode event.id) + , Svg.Events.on "mouseenter" (Decode.succeed (HoverNode (Just event.id))) + , Svg.Events.on "mouseleave" (Decode.succeed (HoverNode Nothing)) + , SA.style "cursor:pointer" + ] + ([ hitArea, nodeBg, statusLabel ] ++ subLabel) + ] diff --git a/packages/devtools-extension/src/panel/src/Helpers.elm b/packages/devtools-extension/src/panel/src/Helpers.elm new file mode 100644 index 0000000000..adb12d85ab --- /dev/null +++ b/packages/devtools-extension/src/panel/src/Helpers.elm @@ -0,0 +1,133 @@ +module Helpers exposing + ( findEvent + , findEventInList + , isSdkNode + , methodClass + , nodeColor + , nodeStatusLabel + , sdkNodes + , statusClass + , truncateId + ) + +import Dict exposing (Dict) +import Types exposing (AuthEvent, EventData(..), EventKind(..), NodeStatus(..)) + + +isSdkNode : AuthEvent -> Bool +isSdkNode event = + case event.data of + DaVinciNode _ -> + True + + Journey _ -> + True + + Oidc _ -> + True + + _ -> + False + + +sdkNodes : List AuthEvent -> List AuthEvent +sdkNodes events = + List.filter isSdkNode events + |> List.sortBy .timestamp + + +findEvent : String -> Dict String AuthEvent -> Maybe AuthEvent +findEvent id eventsById = + Dict.get id eventsById + + +findEventInList : String -> List AuthEvent -> Maybe AuthEvent +findEventInList id events = + List.head (List.filter (\e -> e.id == id) events) + + +statusClass : Maybe Int -> String +statusClass maybeStatus = + case maybeStatus of + Nothing -> + "st-nil" + + Just 0 -> + "st-err" + + Just s -> + if s >= 400 then + "st-warn" + + else + "st-ok" + + +methodClass : Maybe String -> String +methodClass maybeMethod = + case maybeMethod of + Nothing -> + "m-other" + + Just m -> + case String.toUpper m of + "GET" -> + "m-get" + + "POST" -> + "m-post" + + "PUT" -> + "m-put" + + "PATCH" -> + "m-patch" + + "DELETE" -> + "m-del" + + _ -> + "m-other" + + +nodeColor : NodeStatus -> String +nodeColor status = + case status of + Continue -> + "#58A6FF" + + Success -> + "#3FB950" + + StatusError -> + "#F85149" + + Failure -> + "#F85149" + + UnknownStatus -> + "#484F58" + + +nodeStatusLabel : NodeStatus -> String +nodeStatusLabel status = + case status of + Continue -> + "continue" + + Success -> + "success" + + StatusError -> + "error" + + Failure -> + "failure" + + UnknownStatus -> + "unknown" + + +truncateId : String -> String +truncateId id = + String.left 8 id diff --git a/packages/devtools-extension/src/panel/src/Inspector.elm b/packages/devtools-extension/src/panel/src/Inspector.elm new file mode 100644 index 0000000000..ccdfcc0a3d --- /dev/null +++ b/packages/devtools-extension/src/panel/src/Inspector.elm @@ -0,0 +1,586 @@ +module Inspector exposing (view) + +import Helpers +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (onClick) +import Json.Decode as Decode +import Json.Encode as Encode +import JsonTree +import Types exposing (AuthEvent, DiagnosisResult, EventData(..), EventIssue, EventKind(..), EventSource(..), InspectorTab(..), NetworkData, NodeData, NodeStatus(..), SdkAuthorization, SdkError, SessionData, Severity(..)) +import Update exposing (Msg(..)) + + +view : Maybe AuthEvent -> InspectorTab -> Maybe DiagnosisResult -> Html Msg +view selectedEvent activeTab maybeDiagnosis = + div [ style "display" "flex", style "flex-direction" "column", style "height" "100%" ] + [ viewTabs selectedEvent activeTab maybeDiagnosis + , div [ class "insp-body" ] + [ viewContent selectedEvent activeTab maybeDiagnosis ] + ] + + +eventIssues : Maybe AuthEvent -> Maybe DiagnosisResult -> List EventIssue +eventIssues maybeEvent maybeDiagnosis = + case ( maybeEvent, maybeDiagnosis ) of + ( Just event, Just diagnosis ) -> + diagnosis.annotatedEvents + |> List.filter (\( id, _ ) -> id == event.id) + |> List.concatMap (\( _, issues ) -> issues) + + _ -> + [] + + +viewTabs : Maybe AuthEvent -> InspectorTab -> Maybe DiagnosisResult -> Html Msg +viewTabs maybeEvent activeTab maybeDiagnosis = + let + isSdkEvent = + case maybeEvent of + Just event -> + event.kind == NodeChange + + Nothing -> + False + + isSessionEvent = + case maybeEvent of + Just event -> + event.source == SessionSource + + Nothing -> + False + + isConfigEvent = + case maybeEvent of + Just event -> + event.kind == SdkConfig + + Nothing -> + False + + issues = + eventIssues maybeEvent maybeDiagnosis + + hasDiagnosis = + not (List.isEmpty issues) + + diagnosisTabLabel = + if List.any (\i -> i.severity == SevError) issues then + "Diagnosis ●" + + else + "Diagnosis ◐" + in + div [ class "tab-bar" ] + ((if hasDiagnosis then + [ tabButton diagnosisTabLabel DiagnosisTab activeTab ] + + else + [] + ) + ++ [ tabButton "Headers" HeadersTab activeTab + , tabButton "Cookies" CookiesTab activeTab + , tabButton "CORS" CorsTab activeTab + , tabButton "SDK State" SdkStateTab activeTab + ] + ++ (if isSdkEvent then + [ tabButton "Collectors" CollectorsTab activeTab ] + + else + [] + ) + ++ (if isSessionEvent then + [ tabButton "Session" SessionTab activeTab ] + + else + [] + ) + ++ (if isConfigEvent then + [ tabButton "Config" ConfigTab activeTab ] + + else + [] + ) + ) + + +tabButton : String -> InspectorTab -> InspectorTab -> Html Msg +tabButton label tab activeTab = + let + cls = + if tab == activeTab then "tab-btn active" else "tab-btn" + in + button [ onClick (SwitchTab tab), class cls ] [ text label ] + + +viewContent : Maybe AuthEvent -> InspectorTab -> Maybe DiagnosisResult -> Html Msg +viewContent maybeEvent activeTab maybeDiagnosis = + case ( maybeEvent, activeTab ) of + ( Nothing, _ ) -> + div [ class "insp-empty" ] [ text "Select a request to inspect" ] + + ( Just event, DiagnosisTab ) -> + let + issues = + eventIssues (Just event) maybeDiagnosis + in + if List.isEmpty issues then + div [ class "insp-empty" ] [ text "No issues for this event." ] + + else + div [] (List.map viewEventIssue issues) + + ( Just event, HeadersTab ) -> + case event.data of + Network net -> + div [] + ([ div [ class "kv-row", style "margin-bottom" "8px" ] + [ span [ class "kv-key" ] [ text "URL" ] + , span [ class "kv-val" ] [ text (Maybe.withDefault "—" net.url) ] + ] + , div [ class "kv-row", style "margin-bottom" "8px" ] + [ span [ class "kv-key" ] [ text "Method" ] + , span [ class "kv-val" ] [ text (Maybe.withDefault "—" net.method) ] + ] + ] + ++ viewCausedBy event + ++ [ case net.requestHeaders of + Just h -> JsonTree.view "Request Headers" h + Nothing -> viewEmptySection "Request Headers" + , case net.responseHeaders of + Just h -> JsonTree.view "Response Headers" h + Nothing -> viewEmptySection "Response Headers" + ] + ++ (case net.requestBody of + Just b -> [ JsonTree.view "Request Body" b ] + Nothing -> [] + ) + ++ (case net.responseBody of + Just b -> [ JsonTree.view "Response Body" b ] + Nothing -> [] + ) + ) + + _ -> + div [ class "insp-empty" ] + [ text "Select a network request to see headers." ] + + ( Just event, CookiesTab ) -> + case event.data of + Network net -> + div [] (viewCookies net) + + _ -> + div [ class "insp-empty" ] [ text "No cookies found in headers." ] + + ( Just event, CorsTab ) -> + if event.isCors then + let + urlText = + case event.data of + Network net -> + Maybe.withDefault "—" net.url + + _ -> + "—" + in + div [ class "kv-row", style "color" "var(--red)", style "padding-top" "4px" ] + [ text ("CORS issue detected on " ++ urlText) ] + + else + div [ class "kv-row", style "color" "var(--green)", style "padding-top" "4px" ] + [ text "No CORS issues detected for this request." ] + + ( Just event, SdkStateTab ) -> + case event.data of + DaVinciNode node -> + viewSdkState node + + _ -> + div [ class "insp-empty" ] + [ text "Select an SDK node row to inspect." ] + + ( Just event, CollectorsTab ) -> + case event.data of + DaVinciNode node -> + div [] (viewCollectors node) + + _ -> + div [ class "insp-empty" ] [ text "No collectors for this event type." ] + + ( Just event, SessionTab ) -> + case event.data of + Session sess -> + div [] (viewSession sess) + + _ -> + div [ class "insp-empty" ] [ text "No session data for this event type." ] + + ( Just event, ConfigTab ) -> + case event.data of + Config maybeCfg -> + div [] (viewConfig maybeCfg) + + _ -> + div [ class "insp-empty" ] [ text "No config data for this event type." ] + + +viewSdkState : NodeData -> Html Msg +viewSdkState node = + div [] + (viewNodeSection node + ++ viewInteractionSection node + ++ viewErrorSection node + ++ viewAuthorizationSection node + ) + + +nodeStatusValClass : NodeStatus -> String +nodeStatusValClass status = + case status of + StatusError -> "kv-val kv-bold kv-err" + Failure -> "kv-val kv-bold kv-err" + Success -> "kv-val kv-bold kv-ok" + Continue -> "kv-val kv-bold kv-cont" + UnknownStatus -> "kv-val" + + +viewNodeSection : NodeData -> List (Html Msg) +viewNodeSection node = + let + statusRow = + case node.nodeStatus of + Just s -> + let + label = + Helpers.nodeStatusLabel s + + arrow = + case node.previousStatus of + Just prev -> + span [] + [ span [ class "kv-arrow" ] [ text (Helpers.nodeStatusLabel prev ++ " ") ] + , span [ class "kv-arrow" ] [ text "→ " ] + , span [ class (nodeStatusValClass s) ] [ text label ] + ] + + Nothing -> + span [ class (nodeStatusValClass s) ] [ text label ] + in + [ viewRow "Status" arrow ] + + Nothing -> + [] + + httpRow = + case node.httpStatus of + Just code -> + let + cls = + if code >= 400 then "kv-val kv-err" else "kv-val kv-ok" + in + [ viewRow "HTTP" (span [ class cls ] [ text (String.fromInt code) ]) ] + + Nothing -> + [] + in + [ div [ class "sect-hdr" ] [ text "Node" ] ] + ++ statusRow + ++ httpRow + ++ viewOptionalRow "Event" node.eventName + ++ viewOptionalRow "Form Name" node.nodeName + ++ viewOptionalRow "Description" node.nodeDescription + + +viewInteractionSection : NodeData -> List (Html Msg) +viewInteractionSection node = + let + rows = + viewOptionalRow "Interaction ID" node.interactionId + ++ viewOptionalRow "Node ID" node.nodeId + ++ viewOptionalRow "Request ID" node.requestId + ++ viewOptionalRow "Token" node.interactionToken + in + if List.isEmpty rows then + [] + + else + [ div [ class "sect-hdr" ] [ text "Interaction" ] ] ++ rows + + +viewErrorSection : NodeData -> List (Html Msg) +viewErrorSection node = + case node.sdkError of + Nothing -> + [] + + Just err -> + let + httpRow = + case err.internalHttpStatus of + Just code -> + [ viewRow "HTTP Status" + (span [ class "kv-val kv-err" ] [ text (String.fromInt code) ]) + ] + + Nothing -> + [] + in + [ div [ class "sect-hdr" ] [ text "Error" ] ] + ++ [ viewRow "Code" (span [ class "kv-val kv-err kv-bold" ] [ text err.code ]) + , viewRow "Type" (span [ class "kv-val" ] [ text err.errorType ]) + , viewRow "Message" (span [ class "kv-val" ] [ text err.message ]) + ] + ++ httpRow + + +viewAuthorizationSection : NodeData -> List (Html Msg) +viewAuthorizationSection node = + let + authRows = + case node.authorization of + Nothing -> [] + Just auth -> + viewOptionalRow "Auth Code" auth.code + ++ viewOptionalRow "State" auth.state + + sessionRows = + viewOptionalRow "Session ID" node.session + + rows = + authRows ++ sessionRows + in + if List.isEmpty rows then + [] + + else + [ div [ class "sect-hdr" ] [ text "Authorization" ] ] ++ rows + + +viewRow : String -> Html Msg -> Html Msg +viewRow label valueHtml = + div [ class "kv-row" ] + [ span [ class "kv-key" ] [ text label ] + , valueHtml + ] + + +viewOptionalRow : String -> Maybe String -> List (Html Msg) +viewOptionalRow label maybeValue = + case maybeValue of + Nothing -> + [] + + Just value -> + [ viewRow label (span [ class "kv-val" ] [ text value ]) ] + + +viewEmptySection : String -> Html Msg +viewEmptySection label = + div [ class "jt-sec" ] + [ div [ class "jt-label" ] [ text label ] + , div [ style "color" "var(--dim)", style "font-style" "italic", style "font-size" "11px" ] + [ text "None" ] + ] + + +viewCookies : NetworkData -> List (Html Msg) +viewCookies net = + let + cookieRows = + case net.requestHeaders of + Nothing -> + [] + + Just rawHeaders -> + case Decode.decodeValue (Decode.field "cookie" Decode.string) rawHeaders of + Ok v -> + [ div [ class "kv-row" ] + [ span [ class "kv-key", style "color" "var(--orange)" ] [ text "cookie" ] + , span [ class "kv-val kv-ok" ] [ text v ] + ] + ] + + Err (Decode.Field "cookie" _) -> + [ div [ class "kv-val kv-err", style "font-size" "11px" ] + [ text "cookie header present but could not be decoded" ] + ] + + Err _ -> + [] + + setCookieRows = + case net.responseHeaders of + Nothing -> + [] + + Just rawHeaders -> + case Decode.decodeValue + (Decode.field "set-cookie" + (Decode.oneOf + [ Decode.map List.singleton Decode.string + , Decode.list Decode.string + ] + ) + ) + rawHeaders of + Ok values -> + List.map + (\v -> + div [ class "kv-row" ] + [ span [ class "kv-key", style "color" "var(--orange)" ] [ text "set-cookie" ] + , span [ class "kv-val kv-ok" ] [ text v ] + ] + ) + values + + Err (Decode.Field "set-cookie" innerErr) -> + [ div [ class "kv-val kv-err", style "font-size" "11px" ] + [ text ("set-cookie format unexpected: " ++ Decode.errorToString innerErr) ] + ] + + Err _ -> + [] + + rows = + cookieRows ++ setCookieRows + in + if List.isEmpty rows then + [ div [ class "insp-empty" ] [ text "No cookies found in headers." ] ] + + else + rows + + +viewCollectors : NodeData -> List (Html Msg) +viewCollectors node = + case node.collectors of + Nothing -> + [ div [ class "insp-empty" ] [ text "No collectors on this node." ] ] + + Just [] -> + [ div [ class "insp-empty" ] [ text "No collectors on this node." ] ] + + Just cs -> + div [ class "coll-copy-all-row" ] + [ button + [ class "fv-copy-btn coll-copy-all" + , onClick (CopyToClipboard (Encode.encode 4 (Encode.list identity cs))) + ] + [ text "Copy all" ] + ] + :: List.indexedMap + (\i c -> + div [ class "coll-card" ] + [ div [ class "coll-card-header" ] + [ span [] [ text ("Collector " ++ String.fromInt (i + 1)) ] + , button + [ class "fv-copy-btn" + , onClick (CopyToClipboard (Encode.encode 4 c)) + ] + [ text "\u{2398}" ] + ] + , JsonTree.view ("Collector " ++ String.fromInt (i + 1)) c + ] + ) + cs + + +viewCausedBy : AuthEvent -> List (Html Msg) +viewCausedBy event = + case event.causedBy of + Nothing -> + [] + + Just sdkEventId -> + [ div [ class "kv-row", style "margin-bottom" "8px" ] + [ span [ class "kv-key" ] [ text "Triggered by" ] + , button + [ onClick (SelectEvent sdkEventId) + , class "cause-btn" + ] + [ text ("SDK Node " ++ String.left 8 sdkEventId) ] + ] + ] + + +viewSession : SessionData -> List (Html Msg) +viewSession sess = + let + keyLabel = Maybe.withDefault "unknown" sess.key + beforeValue = Maybe.withDefault "—" sess.before + afterValue = Maybe.withDefault "—" sess.after + in + [ div [ class "sect-hdr" ] [ text "Session Diff" ] + , viewRow "Key" (span [ class "kv-val" ] [ text keyLabel ]) + , viewRow "Before" (span [ class "kv-val kv-err" ] [ text beforeValue ]) + , viewRow "After" (span [ class "kv-val kv-ok" ] [ text afterValue ]) + ] + + +viewConfig : Maybe Decode.Value -> List (Html Msg) +viewConfig maybeCfg = + case maybeCfg of + Nothing -> + [ div [ class "insp-empty" ] [ text "No config data on this event." ] ] + + Just cfg -> + [ JsonTree.view "SDK Config" cfg ] + + +viewEventIssue : EventIssue -> Html Msg +viewEventIssue issue = + let + severityClass = + case issue.severity of + SevError -> "diag-issue diag-issue-error" + SevWarning -> "diag-issue diag-issue-warning" + SevInfo -> "diag-issue diag-issue-info" + + severityIcon = + case issue.severity of + SevError -> "✕ " + SevWarning -> "⚠ " + SevInfo -> "ℹ " + + stepItems = + List.indexedMap + (\i step -> + div [ class "diag-step" ] + [ text (String.fromInt (i + 1) ++ ". " ++ step) ] + ) + issue.steps + + dataRows = + case issue.relevantData of + Nothing -> + [] + + Just pairs -> + [ div [ class "diag-data" ] + (List.map + (\( k, v ) -> + div [ class "diag-kv" ] + [ span [ class "diag-k" ] [ text k ] + , span [ class "diag-v" ] [ text v ] + ] + ) + pairs + ) + ] + in + div [ class severityClass ] + ([ div [ class "diag-title" ] [ text (severityIcon ++ issue.title) ] + , div [ class "diag-desc" ] [ text issue.description ] + ] + ++ (if List.isEmpty issue.steps then + [] + + else + [ div [ class "diag-steps-hdr" ] [ text "What to check:" ] + , div [] stepItems + ] + ) + ++ dataRows + ) diff --git a/packages/devtools-extension/src/panel/src/JsonTree.elm b/packages/devtools-extension/src/panel/src/JsonTree.elm new file mode 100644 index 0000000000..6195dd7c99 --- /dev/null +++ b/packages/devtools-extension/src/panel/src/JsonTree.elm @@ -0,0 +1,154 @@ +module JsonTree exposing (view) + +import Html exposing (..) +import Html.Attributes exposing (..) +import Json.Decode as Decode + + +type JsonVal + = JString String + | JNumber Float + | JBool Bool + | JNull + | JArray (List JsonVal) + | JObject (List ( String, JsonVal )) + + +decodeJsonVal : Decode.Decoder JsonVal +decodeJsonVal = + Decode.oneOf + [ Decode.map JString Decode.string + , Decode.map JNumber Decode.float + , Decode.map JBool Decode.bool + , Decode.null JNull + , Decode.map JArray (Decode.list (Decode.lazy (\_ -> decodeJsonVal))) + , Decode.map JObject (Decode.keyValuePairs (Decode.lazy (\_ -> decodeJsonVal))) + ] + + +authKeys : List String +authKeys = + [ "authorization" + , "set-cookie" + , "cookie" + , "access-control-allow-origin" + , "access-control-allow-credentials" + , "www-authenticate" + ] + + +view : String -> Decode.Value -> Html msg +view label rawValue = + div [ class "jt-sec" ] + [ div [ class "jt-label" ] [ text label ] + , case Decode.decodeValue decodeJsonVal rawValue of + Ok jsonVal -> + div [ class "jt-tree" ] [ viewVal 0 Nothing jsonVal ] + + Err err -> + div [] + [ div [ class "jt-err" ] [ text "⚠ Could not decode value" ] + , div [ class "jt-errmsg" ] [ text (Decode.errorToString err) ] + ] + ] + + +isBase64Url : String -> Bool +isBase64Url s = + String.length s > 0 + && String.all (\c -> Char.isAlphaNum c || c == '-' || c == '_' || c == '=') s + + +isJwt : String -> Bool +isJwt s = + let + parts = + String.split "." s + in + List.length parts == 3 && List.all isBase64Url parts + + +viewJwt : String -> Html msg +viewJwt jwt = + Html.node "details" + [ class "jwt-details" ] + [ Html.node "summary" + [ class "jwt-summary" ] + [ text "JWT" ] + , span + [ class "jwt-pending" + , attribute "data-jwt" jwt + ] + [] + ] + + +viewVal : Int -> Maybe String -> JsonVal -> Html msg +viewVal depth maybeKey val = + case val of + JString s -> + if isJwt s then + viewJwt s + + else + span [ class "jt-str" ] [ text ("\"" ++ s ++ "\"") ] + + JNumber n -> + span [ class "jt-num" ] + [ text + (if n == toFloat (round n) then + String.fromInt (round n) + + else + String.fromFloat n + ) + ] + + JBool b -> + span [ class "jt-bool" ] + [ text (if b then "true" else "false") ] + + JNull -> + span [ class "jt-null" ] [ text "null" ] + + JArray [] -> + span [ class "jt-punct" ] [ text "[]" ] + + JArray items -> + div [ style "padding-left" (indentPx depth) ] + (List.indexedMap + (\i item -> + div [ style "margin" "1px 0" ] + [ span [ class "jt-punct" ] [ text (String.fromInt i ++ ": ") ] + , viewVal (depth + 1) Nothing item + ] + ) + items + ) + + JObject [] -> + span [ class "jt-punct" ] [ text "{}" ] + + JObject pairs -> + div [ style "padding-left" (indentPx depth) ] + (List.map + (\( k, v ) -> + let + isAuth = + List.member (String.toLower k) authKeys + + keyClass = + if isAuth then "jt-auth" else "jt-key" + in + div [ style "margin" "1px 0" ] + [ span [ class keyClass ] [ text (k ++ ": ") ] + , viewVal (depth + 1) (Just k) v + ] + ) + pairs + ) + + +indentPx : Int -> String +indentPx depth = + String.fromInt (depth * 14) ++ "px" diff --git a/packages/devtools-extension/src/panel/src/LearnView.elm b/packages/devtools-extension/src/panel/src/LearnView.elm new file mode 100644 index 0000000000..5f9aac3b3a --- /dev/null +++ b/packages/devtools-extension/src/panel/src/LearnView.elm @@ -0,0 +1,1208 @@ +module LearnView exposing (view) + +import Helpers +import Html exposing (Html) +import Html.Attributes exposing (..) +import Json.Decode as JD +import Svg exposing (..) +import Svg.Attributes as SA +import Svg.Events +import Types exposing (AuthEvent, CanvasState, CardId(..), EventData(..), NetworkData, NodeData, SdkError) +import Update exposing (Msg(..)) + + +view : List AuthEvent -> CanvasState -> Html Msg +view events canvas = + let + sdkNodes = + Helpers.sdkNodes events + in + Html.div [ class "lv-view" ] + [ viewRail sdkNodes canvas.learnSelectedNodeId + , viewCanvas events canvas + ] + + + +-- ── Rail ───────────────────────────────────────────────────────────────────── + + +nodeSpacing : Int +nodeSpacing = + 140 + + +nodeRadius : Int +nodeRadius = + 18 + + +railHeight : Int +railHeight = + 110 + + +viewRail : List AuthEvent -> Maybe String -> Html Msg +viewRail sdkNodes selectedNodeId = + let + count = + List.length sdkNodes + + svgWidth = + if count == 0 then + 200 + + else + count * nodeSpacing + 60 + in + Html.div [ class "lv-rail" ] + [ if List.isEmpty sdkNodes then + Html.div [ class "lv-rail-empty" ] [ Html.text "No SDK nodes recorded yet." ] + + else + Svg.svg + [ SA.width (String.fromInt svgWidth) + , SA.height (String.fromInt railHeight) + , SA.viewBox ("0 0 " ++ String.fromInt svgWidth ++ " " ++ String.fromInt railHeight) + , SA.style "display:block" + ] + (railDefs + :: List.concat (List.indexedMap (renderRailNode selectedNodeId) sdkNodes) + ++ List.concat (List.indexedMap (\i _ -> renderRailArrow (List.length sdkNodes) i) sdkNodes) + ) + ] + + +railDefs : Svg Msg +railDefs = + Svg.defs [] + [ Svg.filter [ SA.id "lv-glow" ] + [ Svg.feGaussianBlur [ SA.stdDeviation "4", SA.result "blur" ] [] + , Svg.feMerge [] + [ Svg.feMergeNode [ SA.in_ "blur" ] [] + , Svg.feMergeNode [ SA.in_ "SourceGraphic" ] [] + ] + ] + , Svg.marker + [ SA.id "lv-arrowhead" + , SA.markerWidth "8" + , SA.markerHeight "8" + , SA.refX "6" + , SA.refY "3" + , SA.orient "auto" + ] + [ Svg.polygon + [ SA.points "0 0, 8 3, 0 6" + , SA.fill "#30363D" + ] + [] + ] + ] + + +renderRailNode : Maybe String -> Int -> AuthEvent -> List (Svg Msg) +renderRailNode selectedNodeId index event = + let + cx_ = + index * nodeSpacing + 40 + + cy_ = + 44 + + color = + nodeColor event + + isSelected = + selectedNodeId == Just event.id + + label = + nodeLabel event + + glowRing = + if isSelected then + [ Svg.circle + [ SA.cx (String.fromInt cx_) + , SA.cy (String.fromInt cy_) + , SA.r (String.fromInt (nodeRadius + 6)) + , SA.fill "none" + , SA.stroke color + , SA.strokeWidth "2" + , SA.strokeOpacity "0.5" + , SA.filter "url(#lv-glow)" + ] + [] + ] + + else + [] + in + glowRing + ++ [ Svg.g + [ Svg.Events.onClick (LearnSelectNode event.id) + , SA.style "cursor:pointer" + ] + [ Svg.circle + [ SA.cx (String.fromInt cx_) + , SA.cy (String.fromInt cy_) + , SA.r (String.fromInt nodeRadius) + , SA.fill color + ] + [] + , Svg.text_ + [ SA.x (String.fromInt cx_) + , SA.y (String.fromInt (cy_ + nodeRadius + 14)) + , SA.textAnchor "middle" + , SA.fontSize "10" + , SA.fill "#8B949E" + , SA.fontFamily "'Segoe UI', system-ui, sans-serif" + ] + [ Svg.text (truncate_ 14 label) ] + ] + ] + + +renderRailArrow : Int -> Int -> List (Svg Msg) +renderRailArrow total index = + if index >= total - 1 then + [] + + else + let + x1_ = + index * nodeSpacing + 40 + nodeRadius + 16 + + x2_ = + (index + 1) * nodeSpacing + 40 - nodeRadius - 16 + + y_ = + 44 + in + [ Svg.line + [ SA.x1 (String.fromInt x1_) + , SA.y1 (String.fromInt y_) + , SA.x2 (String.fromInt x2_) + , SA.y2 (String.fromInt y_) + , SA.stroke "#30363D" + , SA.strokeWidth "1.5" + , SA.markerEnd "url(#lv-arrowhead)" + ] + [] + ] + + + +-- ── Canvas ─────────────────────────────────────────────────────────────────── + + +viewCanvas : List AuthEvent -> CanvasState -> Html Msg +viewCanvas events canvas = + case canvas.learnSelectedNodeId of + Nothing -> + Html.div [ class "lv-canvas lv-canvas-empty" ] + [ Html.text "Select a node above to see its request lifecycle." ] + + Just nodeId -> + let + maybeNode = + Helpers.findEventInList nodeId events + + -- Primary: direct causedBy link + directNetEvents = + List.filter (\e -> e.causedBy == Just nodeId) events + |> List.sortBy .timestamp + + -- Fallback: network events in the time window between this + -- node and the next node (when causedBy is missing) + netEvents = + if not (List.isEmpty directNetEvents) then + directNetEvents + + else + inferNetworkEvents nodeId events + + requestEvent = + List.head netEvents + + responseEvent = + List.head (List.reverse netEvents) + + -- Error attribution: where did the error originate? + sdkNodeError = + case maybeNode of + Just n -> + case n.data of + DaVinciNode nd -> + nd.sdkError + + _ -> + Nothing + + Nothing -> + Nothing + + sdkNodeStatus = + case maybeNode of + Just n -> + case n.data of + DaVinciNode nd -> + nd.nodeStatus + + _ -> + Nothing + + Nothing -> + Nothing + + serverHasError = + case responseEvent of + Just re -> + case re.data of + Network nd -> + case nd.status of + Just s -> + s >= 400 + + Nothing -> + False + + _ -> + False + + Nothing -> + False + + sdkHasError = + sdkNodeError /= Nothing + || sdkNodeStatus == Just Types.StatusError + || sdkNodeStatus == Just Types.Failure + + corsError = + case maybeNode of + Just n -> + n.isCors + + Nothing -> + False + + anyError = + serverHasError || sdkHasError || corsError + + hasCollectors = + case maybeNode of + Just n -> + case n.data of + DaVinciNode nd -> + nd.collectors /= Nothing && nd.collectors /= Just [] + + _ -> + False + + Nothing -> + False + + requestMethod = + case requestEvent of + Just re -> + case re.data of + Network nd -> + Maybe.withDefault "POST" nd.method + + _ -> + "POST" + + Nothing -> + "POST" + + responseStatusCode = + case responseEvent of + Just re -> + case re.data of + Network nd -> + nd.status + + _ -> + Nothing + + Nothing -> + Nothing + + responseStatus = + Maybe.map String.fromInt responseStatusCode + |> Maybe.withDefault "—" + + noNetEvents = + List.isEmpty netEvents + + transform = + "translate(" + ++ String.fromFloat canvas.panX + ++ "," + ++ String.fromFloat canvas.panY + ++ ") scale(" + ++ String.fromFloat canvas.zoom + ++ ")" + in + Html.div [ class "lv-canvas" ] + [ Svg.svg + [ SA.class "lv-canvas-svg" + , SA.width "100%" + , SA.height "100%" + , Svg.Events.on "mousedown" + (JD.map2 LearnStartPan + (JD.field "clientX" JD.float) + (JD.field "clientY" JD.float) + ) + , Svg.Events.on "mousemove" + (JD.map2 LearnDrag + (JD.field "clientX" JD.float) + (JD.field "clientY" JD.float) + ) + , Svg.Events.on "mouseup" (JD.succeed LearnEndDrag) + , Svg.Events.on "mouseleave" (JD.succeed LearnEndDrag) + , Svg.Events.on "wheel" + (JD.map LearnZoom (JD.field "deltaY" JD.float)) + ] + [ canvasDefs + , Svg.g [ SA.transform transform ] + (renderCards canvas serverHasError sdkHasError corsError hasCollectors noNetEvents requestMethod responseStatus maybeNode requestEvent responseEvent sdkNodeError) + ] + ] + + +canvasDefs : Svg Msg +canvasDefs = + Svg.defs [] + [ Svg.marker + [ SA.id "lv-card-arrow" + , SA.markerWidth "10" + , SA.markerHeight "7" + , SA.refX "9" + , SA.refY "3.5" + , SA.orient "auto" + ] + [ Svg.polygon + [ SA.points "0 0, 10 3.5, 0 7" + , SA.fill "#484F58" + ] + [] + ] + ] + + +renderCards : CanvasState -> Bool -> Bool -> Bool -> Bool -> Bool -> String -> String -> Maybe AuthEvent -> Maybe AuthEvent -> Maybe AuthEvent -> Maybe SdkError -> List (Svg Msg) +renderCards canvas serverHasError sdkHasError corsError hasCollectors noNetEvents requestMethod responseStatus maybeNode requestEvent responseEvent sdkNodeError = + let + anyError = + serverHasError || sdkHasError || corsError + + cardW = + 160 + + cardH = + 120 + + gap = + 80 + + startX = + 50 + + y = + 80 + + browserX = + startX + + serverX = + startX + cardW + gap + + sdkX = + startX + 2 * (cardW + gap) + + formX = + startX + 3 * (cardW + gap) + + getOffset key = + List.filter (\( k, _ ) -> k == key) canvas.cardPositions + |> List.head + |> Maybe.map Tuple.second + |> Maybe.withDefault { x = 0, y = 0 } + + browserOff = + getOffset "browser" + + serverOff = + getOffset "server" + + sdkOff = + getOffset "sdk" + + formOff = + getOffset "form" + + bx = + toFloat browserX + browserOff.x + + by = + toFloat y + browserOff.y + + sx = + toFloat serverX + serverOff.x + + sy = + toFloat y + serverOff.y + + sdx = + toFloat sdkX + sdkOff.x + + sdy = + toFloat y + sdkOff.y + + fx = + toFloat formX + formOff.x + + fy = + toFloat y + formOff.y + + fW = + toFloat cardW + + fH = + toFloat cardH + + -- Card border colors: only the error SOURCE gets red, + -- downstream cards show propagated red (dimmer) + browserBorder = + if corsError then + "#F85149" + + else + "#58A6FF" + + serverBorder = + if serverHasError then + "#F85149" + + else + "#484F58" + + sdkBorder = + if sdkHasError && not serverHasError then + -- SDK is the error source (not just receiving a server error) + "#F85149" + + else if serverHasError then + -- SDK received an error from server (propagated, not source) + "#b44a44" + + else + "#3FB950" + + formBorder = + if anyError then + "#484F58" + + else if not hasCollectors then + "#484F58" + + else + "#A371F7" + + formOpacity = + if hasCollectors && not anyError then + "1" + + else + "0.4" + + formDash = + if hasCollectors && not anyError then + "" + + else + "4,4" + + -- Arrow colors + requestArrowColor = + if corsError then + "#F85149" + + else + "#58A6FF" + + responseArrowColor = + if serverHasError then + "#F85149" + + else + "#3FB950" + + responseArrowLabel = + if serverHasError then + "✕ " ++ responseStatus + + else + "← " ++ responseStatus + + sdkContextLine = + if sdkHasError && not serverHasError then + "Error in SDK" + + else if serverHasError then + "Received error" + + else + "Processes response" + + formContextLine = + if anyError then + "Skipped" + + else if hasCollectors then + "Collects input" + + else + "No collectors" + + -- Error pulse: which card is the source? + pulseTarget = + if corsError then + Just ( bx, by ) + + else if serverHasError then + Just ( sx, sy ) + + else if sdkHasError then + Just ( sdx, sdy ) + + else + Nothing + in + [ -- Browser card + renderCard BrowserCard bx by fW fH browserBorder "1" "" canvas.expandedCard + (browserIcon (bx + 30) (by + 20)) + "BROWSER" + (if corsError then "CORS blocked" else "Sends request") + , expandedPanel BrowserCard bx (by + fH + 8) fW canvas.expandedCard + (browserDetail requestEvent corsError) + + -- Arrow: Browser -> Server + , renderArrowLine (bx + fW) (by + fH / 2) sx (sy + fH / 2) (requestMethod ++ " →") requestArrowColor False + + -- Server card + , renderCard ServerCard sx sy fW fH serverBorder "1" "" canvas.expandedCard + (serverIcon (sx + 40) (sy + 15)) + "SERVER" + (if serverHasError then "✕ " ++ responseStatus else responseStatus ++ " OK") + , expandedPanel ServerCard sx (sy + fH + 8) fW canvas.expandedCard + (serverDetail responseEvent serverHasError) + + -- Arrow: Server -> SDK + , renderArrowLine (sx + fW) (sy + fH / 2) sdx (sdy + fH / 2) responseArrowLabel responseArrowColor False + + -- SDK card + , renderCard SdkCard sdx sdy fW fH sdkBorder "1" "" canvas.expandedCard + (sdkIcon (sdx + 35) (sdy + 20)) + "SDK" + sdkContextLine + , expandedPanel SdkCard sdx (sdy + fH + 8) fW canvas.expandedCard + (sdkDetail maybeNode sdkNodeError serverHasError) + + -- Arrow: SDK -> Form + , renderArrowLine (sdx + fW) (sdy + fH / 2) fx (fy + fH / 2) "renders" "#484F58" True + + -- Form card + , renderCard FormCard fx fy fW fH formBorder formOpacity formDash canvas.expandedCard + (formIcon (fx + 35) (fy + 18)) + "FORM" + formContextLine + , expandedPanel FormCard fx (fy + fH + 8) fW canvas.expandedCard + (formDetail maybeNode) + ] + -- Error pulse ring on the SOURCE card + ++ (case pulseTarget of + Just ( px, py ) -> + [ Svg.rect + [ SA.x (String.fromFloat (px - 6)) + , SA.y (String.fromFloat (py - 6)) + , SA.width (String.fromFloat (fW + 12)) + , SA.height (String.fromFloat (fH + 12)) + , SA.rx "12" + , SA.fill "none" + , SA.stroke "#F85149" + , SA.strokeWidth "2" + , SA.strokeOpacity "0.6" + , SA.class "lv-pulse" + ] + [] + ] + + Nothing -> + [] + ) + ++ (if noNetEvents then + [ Svg.text_ + [ SA.x (String.fromFloat (bx + fW + toFloat gap / 2)) + , SA.y (String.fromFloat (by + fH + 30)) + , SA.textAnchor "middle" + , SA.fontSize "11" + , SA.fill "#484F58" + , SA.fontFamily "'Segoe UI', system-ui, sans-serif" + ] + [ Svg.text "No network events captured" ] + ] + + else + [] + ) + + +cardDragDecoder : CardId -> JD.Decoder Msg +cardDragDecoder cardId = + JD.map2 (LearnStartDrag cardId) + (JD.field "clientX" JD.float) + (JD.field "clientY" JD.float) + + +renderCard : CardId -> Float -> Float -> Float -> Float -> String -> String -> String -> Maybe CardId -> Svg Msg -> String -> String -> Svg Msg +renderCard cardId x y w h borderColor opacity dashArray expandedCard icon label contextLine = + let + isExpanded = + expandedCard == Just cardId + in + Svg.g + [ Svg.Events.on "mousedown" (cardDragDecoder cardId) + , Svg.Events.onClick (LearnExpandCard cardId) + , SA.style "cursor:grab" + , SA.opacity opacity + ] + [ Svg.rect + [ SA.x (String.fromFloat x) + , SA.y (String.fromFloat y) + , SA.width (String.fromFloat w) + , SA.height (String.fromFloat h) + , SA.rx "8" + , SA.ry "8" + , SA.fill "#161B22" + , SA.stroke borderColor + , SA.strokeWidth + (if isExpanded then + "3" + + else + "1.5" + ) + , SA.strokeDasharray dashArray + ] + [] + , icon + , Svg.text_ + [ SA.x (String.fromFloat (x + w / 2)) + , SA.y (String.fromFloat (y + h - 22)) + , SA.textAnchor "middle" + , SA.fontSize "11" + , SA.fontWeight "bold" + , SA.fill "#E6EDF3" + , SA.fontFamily "'Segoe UI', system-ui, sans-serif" + ] + [ Svg.text label ] + , Svg.text_ + [ SA.x (String.fromFloat (x + w / 2)) + , SA.y (String.fromFloat (y + h - 8)) + , SA.textAnchor "middle" + , SA.fontSize "9" + , SA.fill "#8B949E" + , SA.fontFamily "'Segoe UI', system-ui, sans-serif" + ] + [ Svg.text contextLine ] + ] + + +renderArrowLine : Float -> Float -> Float -> Float -> String -> String -> Bool -> Svg Msg +renderArrowLine x1 y1 x2 y2 label color isDashed = + let + midX = + (x1 + x2) / 2 + + midY = + (y1 + y2) / 2 - 10 + in + Svg.g [] + [ Svg.line + ([ SA.x1 (String.fromFloat x1) + , SA.y1 (String.fromFloat y1) + , SA.x2 (String.fromFloat x2) + , SA.y2 (String.fromFloat y2) + , SA.stroke color + , SA.strokeWidth "1.5" + , SA.markerEnd "url(#lv-card-arrow)" + ] + ++ (if isDashed then + [ SA.strokeDasharray "5 3" ] + + else + [] + ) + ) + [] + , Svg.text_ + [ SA.x (String.fromFloat midX) + , SA.y (String.fromFloat midY) + , SA.textAnchor "middle" + , SA.fontSize "9" + , SA.fill color + , SA.fontFamily "'Segoe UI', system-ui, sans-serif" + , SA.fontWeight "600" + ] + [ Svg.text label ] + ] + + + +-- ── Expanded Panels ───────────────────────────────────────────────────────── + + +expandedPanel : CardId -> Float -> Float -> Float -> Maybe CardId -> List (Html Msg) -> Svg Msg +expandedPanel cardId x y w expandedCard content = + if expandedCard == Just cardId then + Svg.foreignObject + [ SA.x (String.fromFloat x) + , SA.y (String.fromFloat y) + , SA.width (String.fromFloat w) + , SA.height "120" + ] + [ Html.div + [ Html.Attributes.style "font-family" "'Segoe UI', system-ui, sans-serif" + , Html.Attributes.style "font-size" "10px" + , Html.Attributes.style "color" "#8b949e" + , Html.Attributes.style "background" "#161B22" + , Html.Attributes.style "border" "1px solid #30363d" + , Html.Attributes.style "border-radius" "6px" + , Html.Attributes.style "padding" "6px 8px" + ] + content + ] + + else + Svg.g [] [] + + +detailRow : String -> String -> Html Msg +detailRow label value = + detailRowColored label value "#e6edf3" + + +detailRowColored : String -> String -> String -> Html Msg +detailRowColored label value color = + Html.div [ Html.Attributes.style "margin-bottom" "2px" ] + [ Html.span [ Html.Attributes.style "color" "#484f58" ] [ Html.text (label ++ " ") ] + , Html.span [ Html.Attributes.style "color" color, Html.Attributes.style "font-weight" "600" ] [ Html.text value ] + ] + + +browserDetail : Maybe AuthEvent -> Bool -> List (Html Msg) +browserDetail requestEvent corsError = + (if corsError then + [ detailRowColored "CORS" "Request blocked by browser" "#F85149" ] + + else + [] + ) + ++ (case requestEvent of + Just re -> + case re.data of + Network nd -> + [ detailRow "Method" (Maybe.withDefault "POST" nd.method) + , detailRow "URL" (Maybe.withDefault "—" nd.url) + ] + + _ -> + [ Html.text "No request data" ] + + Nothing -> + [ Html.text "No request captured" ] + ) + + +serverDetail : Maybe AuthEvent -> Bool -> List (Html Msg) +serverDetail responseEvent isError = + case responseEvent of + Just re -> + case re.data of + Network nd -> + let + statusStr = + Maybe.map String.fromInt nd.status + |> Maybe.withDefault "—" + + statusColor = + if isError then + "#F85149" + + else + "#3FB950" + + durationStr = + case nd.duration of + Just d -> + String.fromInt (round d) ++ "ms" + + Nothing -> + "—" + + urlStr = + Maybe.withDefault "—" nd.url + in + [ detailRowColored "Status" statusStr statusColor + , detailRow "Duration" durationStr + , detailRow "URL" (truncateUrl urlStr) + ] + + _ -> + [ Html.text "No response data" ] + + Nothing -> + [ Html.text "No response captured" ] + + +sdkDetail : Maybe AuthEvent -> Maybe Types.SdkError -> Bool -> List (Html Msg) +sdkDetail maybeNode sdkError serverErrored = + let + errorSection = + case sdkError of + Just err -> + [ detailRowColored "Error" err.code "#F85149" + , detailRow "Message" err.message + , detailRow "Type" err.errorType + ] + + Nothing -> + if serverErrored then + [ detailRowColored "Note" "Server returned an error" "#d29922" ] + + else + [] + in + case maybeNode of + Just n -> + case n.data of + DaVinciNode nd -> + let + statusLabel = + Maybe.map Helpers.nodeStatusLabel nd.nodeStatus + |> Maybe.withDefault "—" + + statusColor = + case nd.nodeStatus of + Just Types.StatusError -> + "#F85149" + + Just Types.Failure -> + "#F85149" + + Just Types.Success -> + "#3FB950" + + Just Types.Continue -> + "#58A6FF" + + _ -> + "#8b949e" + + transitionStr = + case nd.previousStatus of + Just prev -> + Helpers.nodeStatusLabel prev ++ " → " ++ statusLabel + + Nothing -> + statusLabel + in + [ detailRowColored "Status" transitionStr statusColor ] + ++ (case nd.nodeName of + Just name -> + [ detailRow "Node" name ] + + Nothing -> + [] + ) + ++ (case nd.interactionId of + Just iid -> + [ detailRow "Interaction" (truncate_ 14 iid) ] + + Nothing -> + [] + ) + ++ errorSection + + _ -> + [ Html.text "Not a DaVinci node" ] + + Nothing -> + [ Html.text "No node selected" ] + + +formDetail : Maybe AuthEvent -> List (Html Msg) +formDetail maybeNode = + case maybeNode of + Just n -> + case n.data of + DaVinciNode nd -> + case nd.collectors of + Just collectors -> + if List.isEmpty collectors then + [ Html.text "No collectors" ] + + else + [ detailRow "Collectors" (String.fromInt (List.length collectors)) ] + + Nothing -> + [ Html.text "No collectors" ] + + _ -> + [ Html.text "No form data" ] + + Nothing -> + [ Html.text "No node selected" ] + + + +-- ── Icons ──────────────────────────────────────────────────────────────────── + + +browserIcon : Float -> Float -> Svg Msg +browserIcon x y = + Svg.g [] + [ -- Window frame + Svg.rect + [ SA.x (String.fromFloat x) + , SA.y (String.fromFloat y) + , SA.width "100" + , SA.height "60" + , SA.rx "4" + , SA.fill "none" + , SA.stroke "#58A6FF" + , SA.strokeWidth "1.5" + ] + [] + + -- Title bar + , Svg.line + [ SA.x1 (String.fromFloat x) + , SA.y1 (String.fromFloat (y + 14)) + , SA.x2 (String.fromFloat (x + 100)) + , SA.y2 (String.fromFloat (y + 14)) + , SA.stroke "#58A6FF" + , SA.strokeWidth "1" + ] + [] + + -- Traffic lights + , Svg.circle [ SA.cx (String.fromFloat (x + 8)), SA.cy (String.fromFloat (y + 7)), SA.r "2.5", SA.fill "#F85149" ] [] + , Svg.circle [ SA.cx (String.fromFloat (x + 16)), SA.cy (String.fromFloat (y + 7)), SA.r "2.5", SA.fill "#D29922" ] [] + , Svg.circle [ SA.cx (String.fromFloat (x + 24)), SA.cy (String.fromFloat (y + 7)), SA.r "2.5", SA.fill "#3FB950" ] [] + + -- Globe in content area + , Svg.circle [ SA.cx (String.fromFloat (x + 50)), SA.cy (String.fromFloat (y + 38)), SA.r "12", SA.fill "none", SA.stroke "#58A6FF", SA.strokeWidth "1" ] [] + , Svg.ellipse [ SA.cx (String.fromFloat (x + 50)), SA.cy (String.fromFloat (y + 38)), SA.rx "5", SA.ry "12", SA.fill "none", SA.stroke "#58A6FF", SA.strokeWidth "0.7" ] [] + , Svg.line [ SA.x1 (String.fromFloat (x + 38)), SA.y1 (String.fromFloat (y + 38)), SA.x2 (String.fromFloat (x + 62)), SA.y2 (String.fromFloat (y + 38)), SA.stroke "#58A6FF", SA.strokeWidth "0.7" ] [] + ] + + +serverIcon : Float -> Float -> Svg Msg +serverIcon x y = + Svg.g [] + [ -- Cloud shape (simplified) + Svg.ellipse + [ SA.cx (String.fromFloat (x + 40)) + , SA.cy (String.fromFloat (y + 30)) + , SA.rx "38" + , SA.ry "22" + , SA.fill "none" + , SA.stroke "#484F58" + , SA.strokeWidth "1.5" + ] + [] + + -- LED dots + , Svg.circle [ SA.cx (String.fromFloat (x + 28)), SA.cy (String.fromFloat (y + 30)), SA.r "3", SA.fill "#3FB950" ] [] + , Svg.circle [ SA.cx (String.fromFloat (x + 40)), SA.cy (String.fromFloat (y + 30)), SA.r "3", SA.fill "#3FB950" ] [] + , Svg.circle [ SA.cx (String.fromFloat (x + 52)), SA.cy (String.fromFloat (y + 30)), SA.r "3", SA.fill "#58A6FF" ] [] + ] + + +sdkIcon : Float -> Float -> Svg Msg +sdkIcon x y = + Svg.g [] + [ -- Window frame + Svg.rect + [ SA.x (String.fromFloat x) + , SA.y (String.fromFloat y) + , SA.width "90" + , SA.height "55" + , SA.rx "4" + , SA.fill "none" + , SA.stroke "#3FB950" + , SA.strokeWidth "1.5" + ] + [] + + -- Gear icon + , Svg.circle [ SA.cx (String.fromFloat (x + 45)), SA.cy (String.fromFloat (y + 30)), SA.r "10", SA.fill "none", SA.stroke "#3FB950", SA.strokeWidth "1.5" ] [] + , Svg.circle [ SA.cx (String.fromFloat (x + 45)), SA.cy (String.fromFloat (y + 30)), SA.r "4", SA.fill "#3FB950" ] [] + ] + + +formIcon : Float -> Float -> Svg Msg +formIcon x y = + Svg.g [] + [ -- Form outline + Svg.rect + [ SA.x (String.fromFloat x) + , SA.y (String.fromFloat y) + , SA.width "90" + , SA.height "60" + , SA.rx "4" + , SA.fill "none" + , SA.stroke "#A371F7" + , SA.strokeWidth "1.5" + ] + [] + + -- Input field 1 + , Svg.rect [ SA.x (String.fromFloat (x + 10)), SA.y (String.fromFloat (y + 10)), SA.width "70", SA.height "12", SA.rx "2", SA.fill "none", SA.stroke "#484F58", SA.strokeWidth "1" ] [] + + -- Input field 2 + , Svg.rect [ SA.x (String.fromFloat (x + 10)), SA.y (String.fromFloat (y + 28)), SA.width "70", SA.height "12", SA.rx "2", SA.fill "none", SA.stroke "#484F58", SA.strokeWidth "1" ] [] + + -- Submit button + , Svg.rect [ SA.x (String.fromFloat (x + 25)), SA.y (String.fromFloat (y + 46)), SA.width "40", SA.height "10", SA.rx "2", SA.fill "#A371F7", SA.fillOpacity "0.3", SA.stroke "#A371F7", SA.strokeWidth "1" ] [] + ] + + + +-- ── Event correlation ──────────────────────────────────────────────────────── + + +{-| When no network events have a direct `causedBy` link to this node, +infer them by time window: find all network events whose timestamp falls +between this node's timestamp and the next SDK node's timestamp. +-} +inferNetworkEvents : String -> List AuthEvent -> List AuthEvent +inferNetworkEvents nodeId events = + let + sdkNodes = + Helpers.sdkNodes events + + nodeTimestamp = + sdkNodes + |> List.filter (\e -> e.id == nodeId) + |> List.head + |> Maybe.map .timestamp + + nextNodeTimestamp = + case nodeTimestamp of + Nothing -> + Nothing + + Just ts -> + sdkNodes + |> List.filter (\e -> e.timestamp > ts) + |> List.head + |> Maybe.map .timestamp + + isNetworkEvent e = + case e.data of + Network _ -> + True + + _ -> + False + in + case nodeTimestamp of + Nothing -> + [] + + Just startTs -> + events + |> List.filter isNetworkEvent + |> List.filter + (\e -> + e.timestamp + >= startTs + && (case nextNodeTimestamp of + Just endTs -> + e.timestamp < endTs + + Nothing -> + True + ) + ) + |> List.sortBy .timestamp + + +-- ── Helpers ────────────────────────────────────────────────────────────────── + + +nodeColor : AuthEvent -> String +nodeColor event = + case event.data of + DaVinciNode node -> + Helpers.nodeColor (Maybe.withDefault Types.UnknownStatus node.nodeStatus) + + _ -> + "#484F58" + + +nodeLabel : AuthEvent -> String +nodeLabel event = + case event.data of + DaVinciNode node -> + node.nodeName + |> orMaybe node.eventName + |> Maybe.withDefault "—" + + Journey journey -> + journey.stage + |> orMaybe journey.header + |> orMaybe journey.stepType + |> Maybe.withDefault "—" + + Oidc oidc -> + Maybe.withDefault "oidc" oidc.phase + + _ -> + "—" + + +orMaybe : Maybe a -> Maybe a -> Maybe a +orMaybe fallback primary = + case primary of + Just _ -> + primary + + Nothing -> + fallback + + +truncate_ : Int -> String -> String +truncate_ maxLen s = + if String.length s <= maxLen then + s + + else + String.left maxLen s ++ "…" + + +truncateUrl : String -> String +truncateUrl url = + let + stripped = + url + |> String.replace "https://" "" + |> String.replace "http://" "" + in + if String.length stripped > 28 then + String.left 28 stripped ++ "…" + + else + stripped diff --git a/packages/devtools-extension/src/panel/src/Model.elm b/packages/devtools-extension/src/panel/src/Model.elm new file mode 100644 index 0000000000..7beefe57b1 --- /dev/null +++ b/packages/devtools-extension/src/panel/src/Model.elm @@ -0,0 +1,71 @@ +module Model exposing (Model, init) + +import Dict exposing (Dict) +import Set exposing (Set) +import Types exposing (AuthEvent, CanvasState, DiagnosisResult, ImportMeta, InspectorTab(..), SnapshotMeta, ViewMode(..)) + + +type alias Model = + { events : List AuthEvent + , eventsById : Dict String AuthEvent + , selectedEventId : Maybe String + , activeTab : InspectorTab + , recording : Bool + , flowId : Maybe String + , lastDecodeError : Maybe String + , diagnosis : Maybe DiagnosisResult + , summaryCollapsed : Bool + , viewMode : ViewMode + , playbackIndex : Maybe Int + , isPlaying : Bool + , selectedNodeId : Maybe String + , expandedSubRows : Set String + , exportMenuOpen : Bool + , importedFlow : Maybe ImportMeta + , importPasteOpen : Bool + , importPasteText : String + , hoveredNodeId : Maybe String + , snapshotMenuOpen : Bool + , snapshots : List SnapshotMeta + , learnCanvas : CanvasState + } + + +init : () -> ( Model, Cmd msg ) +init _ = + ( { events = [] + , eventsById = Dict.empty + , selectedEventId = Nothing + , activeTab = HeadersTab + , recording = True + , flowId = Nothing + , lastDecodeError = Nothing + , diagnosis = Nothing + , summaryCollapsed = False + , viewMode = TimelineMode + , playbackIndex = Nothing + , isPlaying = False + , selectedNodeId = Nothing + , expandedSubRows = Set.empty + , exportMenuOpen = False + , importedFlow = Nothing + , importPasteOpen = False + , importPasteText = "" + , hoveredNodeId = Nothing + , snapshotMenuOpen = False + , snapshots = [] + , learnCanvas = + { zoom = 1.0 + , panX = 0.0 + , panY = 0.0 + , cardPositions = [] + , expandedCard = Nothing + , dragTarget = Nothing + , dragStart = Nothing + , isPanning = False + , panStart = Nothing + , learnSelectedNodeId = Nothing + } + } + , Cmd.none + ) diff --git a/packages/devtools-extension/src/panel/src/Timeline.elm b/packages/devtools-extension/src/panel/src/Timeline.elm new file mode 100644 index 0000000000..77a190c39d --- /dev/null +++ b/packages/devtools-extension/src/panel/src/Timeline.elm @@ -0,0 +1,163 @@ +module Timeline exposing (view) + +import Helpers +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (onClick) +import Types exposing (AuthEvent, EventData(..), EventKind(..), EventSource(..), NodeStatus(..)) +import Update exposing (Msg(..)) + + +nodeStatusClass : NodeStatus -> String +nodeStatusClass status = + case status of + Continue -> "kv-cont" + Success -> "kv-ok" + StatusError -> "kv-err" + Failure -> "kv-err" + UnknownStatus -> "st-nil" + + +view : List AuthEvent -> Maybe String -> Html Msg +view events selectedId = + div [] + (List.map (renderRow selectedId) events) + + +renderRow : Maybe String -> AuthEvent -> Html Msg +renderRow selectedId event = + let + isSelected = + selectedId == Just event.id + + rowClass = + if isSelected then "tl-row sel" else "tl-row" + in + case event.kind of + NodeChange -> + renderSdkRow rowClass event + + SdkConfig -> + renderConfigRow rowClass event + + SessionEvent -> + renderSessionRow rowClass event + + _ -> + renderNetworkRow rowClass event + + +renderConfigRow : String -> AuthEvent -> Html Msg +renderConfigRow rowClass event = + div [ class rowClass, onClick (SelectEvent event.id) ] + [ span [ class "tl-badge b-cfg" ] [ text "CFG" ] + , span [ class "tl-st st-nil" ] [ text "" ] + , span [ class "tl-meth" ] [ text "" ] + , span [ class "tl-desc" ] [ text "SDK Config" ] + ] + + +renderSessionRow : String -> AuthEvent -> Html Msg +renderSessionRow rowClass event = + let + label = + case event.data of + Session sess -> + Maybe.withDefault "session" sess.key + + _ -> + "session" + in + div [ class rowClass, onClick (SelectEvent event.id) ] + [ span [ class "tl-badge b-ses" ] [ text "SES" ] + , span [ class "tl-st st-nil" ] [ text "" ] + , span [ class "tl-meth" ] [ text "" ] + , span [ class "tl-desc" ] [ text label ] + ] + + +renderSdkRow : String -> AuthEvent -> Html Msg +renderSdkRow rowClass event = + case event.data of + DaVinciNode node -> + let + status = + Maybe.withDefault UnknownStatus node.nodeStatus + + statusLabel = + Helpers.nodeStatusLabel status + + transitionLabel = + case node.previousStatus of + Just prev -> + Helpers.nodeStatusLabel prev ++ " → " ++ statusLabel + + Nothing -> + statusLabel + + collectorTag = + case node.collectors of + Just cs -> + if List.length cs > 0 then + span [ class "tl-tag tag-coll" ] + [ text (String.fromInt (List.length cs) ++ " collectors") ] + + else + text "" + + Nothing -> + text "" + in + div [ class rowClass, onClick (SelectEvent event.id) ] + [ span [ class "tl-badge b-sdk" ] [ text "SDK" ] + , span [ class ("tl-st " ++ nodeStatusClass status) ] [ text "" ] + , span [ class "tl-meth" ] [ text "" ] + , span [ class "tl-desc" ] [ text transitionLabel ] + , collectorTag + ] + + _ -> + text "" + + +renderNetworkRow : String -> AuthEvent -> Html Msg +renderNetworkRow rowClass event = + case event.data of + Network net -> + let + statusText = + case net.status of + Nothing -> "—" + Just s -> String.fromInt s + + durationText = + case net.duration of + Nothing -> "" + Just ms -> + if ms < 1 then + "<1" + else + String.fromInt (round ms) + + corsTag = + if event.isCors then + span [ class "tl-tag tag-cors" ] [ text "CORS" ] + + else + text "" + + urlText = + Maybe.withDefault "—" net.url + in + div [ class rowClass, onClick (SelectEvent event.id) ] + [ span [ class "tl-badge b-net" ] [ text "NET" ] + , span [ class ("tl-st " ++ Helpers.statusClass net.status) ] [ text statusText ] + , span [ class ("tl-meth " ++ Helpers.methodClass net.method) ] + [ text (Maybe.withDefault "" net.method) ] + , span [ class "tl-desc" ] [ text urlText ] + , corsTag + , span [ class "tl-dur" ] [ text durationText ] + ] + + _ -> + text "" diff --git a/packages/devtools-extension/src/panel/src/Types.elm b/packages/devtools-extension/src/panel/src/Types.elm new file mode 100644 index 0000000000..49c2e0928a --- /dev/null +++ b/packages/devtools-extension/src/panel/src/Types.elm @@ -0,0 +1,249 @@ +module Types exposing + ( AuthEvent + , CanvasState + , CardId(..) + , DiagnosisResult + , EventData(..) + , EventIssue + , EventKind(..) + , EventSource(..) + , FlowHealth(..) + , FlowIssue + , ImportMeta + , InspectorTab(..) + , JourneyData + , NetworkData + , NodeData + , NodeStatus(..) + , OidcData + , SdkAuthorization + , SdkError + , SessionData + , Severity(..) + , SnapshotMeta + , Vec2 + , ViewMode(..) + ) + +import Json.Decode as Decode + + +type Severity + = SevError + | SevWarning + | SevInfo + + +type NodeStatus + = Continue + | Success + | StatusError + | Failure + | UnknownStatus + + +type EventKind + = NodeChange + | JourneyStep + | OidcState + | SdkConfig + | NetworkEvent + | SessionEvent + | OtherKind String + + +type EventSource + = NetworkSource + | SdkSource + | SessionSource + | OtherSource String + + +type alias SdkError = + { code : String + , message : String + , errorType : String + , internalHttpStatus : Maybe Int + } + + +type alias SdkAuthorization = + { code : Maybe String + , state : Maybe String + } + + +type alias AuthEvent = + { id : String + , timestamp : Float + , kind : EventKind + , source : EventSource + , flowId : Maybe String + , isCors : Bool + , isError : Bool + , isAuthRelated : Bool + , causedBy : Maybe String + , data : EventData + } + + +type EventData + = Network NetworkData + | DaVinciNode NodeData + | Journey JourneyData + | Oidc OidcData + | Session SessionData + | Config (Maybe Decode.Value) + + +type alias NetworkData = + { status : Maybe Int + , url : Maybe String + , method : Maybe String + , duration : Maybe Float + , requestHeaders : Maybe Decode.Value + , responseHeaders : Maybe Decode.Value + , requestBody : Maybe Decode.Value + , responseBody : Maybe Decode.Value + } + + +type alias NodeData = + { nodeStatus : Maybe NodeStatus + , previousStatus : Maybe NodeStatus + , interactionId : Maybe String + , interactionToken : Maybe String + , nodeId : Maybe String + , requestId : Maybe String + , nodeName : Maybe String + , nodeDescription : Maybe String + , eventName : Maybe String + , httpStatus : Maybe Int + , sdkError : Maybe SdkError + , authorization : Maybe SdkAuthorization + , session : Maybe String + , collectors : Maybe (List Decode.Value) + , responseBody : Maybe Decode.Value + } + + +type alias JourneyData = + { stepType : Maybe String + , stage : Maybe String + , header : Maybe String + , description : Maybe String + , callbacks : Maybe (List Decode.Value) + , authId : Maybe String + , tokenId : Maybe String + , successUrl : Maybe String + , errorCode : Maybe Int + , errorMessage : Maybe String + , errorReason : Maybe String + } + + +type alias OidcData = + { phase : Maybe String + , status : Maybe String + , clientId : Maybe String + , errorCode : Maybe String + , errorMessage : Maybe String + } + + +type alias SessionData = + { key : Maybe String + , before : Maybe String + , after : Maybe String + } + + +type InspectorTab + = DiagnosisTab + | HeadersTab + | CookiesTab + | CorsTab + | SdkStateTab + | CollectorsTab + | SessionTab + | ConfigTab + + +type FlowHealth + = Healthy + | Warning + | Error + + +type alias EventIssue = + { severity : Severity + , title : String + , description : String + , steps : List String + , relevantData : Maybe (List ( String, String )) + } + + +type alias FlowIssue = + { id : String + , severity : Severity + , category : String + , title : String + , description : String + , steps : List String + , relatedEventIds : List String + , relevantData : Maybe (List ( String, String )) + } + + +type alias DiagnosisResult = + { flowHealth : FlowHealth + , issues : List FlowIssue + , annotatedEvents : List ( String, List EventIssue ) + } + + +type CardId + = BrowserCard + | ServerCard + | SdkCard + | FormCard + + +type alias Vec2 = + { x : Float, y : Float } + + +type alias CanvasState = + { zoom : Float + , panX : Float + , panY : Float + , cardPositions : List ( String, Vec2 ) + , expandedCard : Maybe CardId + , dragTarget : Maybe CardId + , dragStart : Maybe Vec2 + , isPanning : Bool + , panStart : Maybe Vec2 + , learnSelectedNodeId : Maybe String + } + + +type ViewMode + = TimelineMode + | FlowMode + | LearnMode + + +type alias ImportMeta = + { flowId : Maybe String + , capturedAt : String + , redacted : Bool + } + + +type alias SnapshotMeta = + { id : String + , savedAt : String + , flowId : Maybe String + , eventCount : Int + } diff --git a/packages/devtools-extension/src/panel/src/Update.elm b/packages/devtools-extension/src/panel/src/Update.elm new file mode 100644 index 0000000000..5df0f0cca9 --- /dev/null +++ b/packages/devtools-extension/src/panel/src/Update.elm @@ -0,0 +1,557 @@ +module Update exposing (Msg(..), update) + +import Dict +import Helpers +import Model exposing (Model) +import Set +import Types exposing (AuthEvent, CanvasState, CardId(..), DiagnosisResult, EventData(..), EventKind(..), EventSource(..), FlowHealth(..), ImportMeta, InspectorTab(..), SnapshotMeta, Vec2, ViewMode(..)) + + +type Msg + = EventReceived AuthEvent + | SelectEvent String + | SelectNode String + | SwitchTab InspectorTab + | ToggleRecording + | ClearFlow + | ToggleExportMenu + | CloseExportMenu + | ExportJson + | ExportMarkdown + | ImportFlow + | UpdateImportPaste String + | SubmitImportPaste + | CancelImportPaste + | ImportMetaReceived ImportMeta + | ImportError String + | DecodeError String + | DiagnosisReceived DiagnosisResult + | ToggleSummary + | SaveSnapshot + | SwitchViewMode ViewMode + | StartPlayback + | StopPlayback + | PlaybackTick + | SelectFlowNode String + | ToggleSubRow String + | ResetPlayback + | HoverNode (Maybe String) + | CopyToClipboard String + | ToggleSnapshotMenu + | CloseSnapshotMenu + | SnapshotsReceived (List SnapshotMeta) + | LoadSnapshot String + | DeleteSnapshot String + | LearnSelectNode String + | LearnExpandCard CardId + | LearnCollapseCard + | LearnStartDrag CardId Float Float + | LearnDrag Float Float + | LearnEndDrag + | LearnStartPan Float Float + | LearnPan Float Float + | LearnEndPan + | LearnZoom Float + + +update : Msg -> Model -> ( Model, Cmd Msg ) +update msg model = + case msg of + EventReceived event -> + if model.importedFlow /= Nothing then + ( model, Cmd.none ) + + else + ( { model + | events = model.events ++ [ event ] + , eventsById = Dict.insert event.id event model.eventsById + , flowId = + case model.flowId of + Just _ -> + model.flowId + + Nothing -> + event.flowId + } + , Cmd.none + ) + + SelectEvent id -> + let + selectedEvent = + Helpers.findEvent id model.eventsById + + newTab = + case ( model.activeTab, selectedEvent ) of + ( CollectorsTab, Just e ) -> + if e.kind /= NodeChange then + HeadersTab + else + CollectorsTab + + ( SessionTab, Just e ) -> + if e.source /= SessionSource then + HeadersTab + else + SessionTab + + ( ConfigTab, Just e ) -> + if e.kind /= SdkConfig then + HeadersTab + else + ConfigTab + + _ -> + model.activeTab + in + ( { model | selectedEventId = Just id, activeTab = newTab }, Cmd.none ) + + SelectNode id -> + ( { model | selectedEventId = Just id, activeTab = SdkStateTab }, Cmd.none ) + + SwitchTab tab -> + ( { model | activeTab = tab }, Cmd.none ) + + ToggleRecording -> + ( { model | recording = not model.recording }, Cmd.none ) + + ClearFlow -> + ( { model + | events = [] + , eventsById = Dict.empty + , selectedEventId = Nothing + , flowId = Nothing + , selectedNodeId = Nothing + , expandedSubRows = Set.empty + , isPlaying = False + , playbackIndex = Nothing + , importedFlow = Nothing + , exportMenuOpen = False + , recording = True + , importPasteOpen = False + , importPasteText = "" + } + , Cmd.none + ) + + ToggleExportMenu -> + ( { model | exportMenuOpen = not model.exportMenuOpen }, Cmd.none ) + + CloseExportMenu -> + ( { model | exportMenuOpen = False }, Cmd.none ) + + ExportJson -> + ( { model | exportMenuOpen = False }, Cmd.none ) + + ExportMarkdown -> + ( { model | exportMenuOpen = False }, Cmd.none ) + + ImportFlow -> + ( { model | importPasteOpen = True, importPasteText = "", lastDecodeError = Nothing }, Cmd.none ) + + UpdateImportPaste text -> + ( { model | importPasteText = text }, Cmd.none ) + + SubmitImportPaste -> + ( { model | importPasteOpen = False, importPasteText = "" }, Cmd.none ) + + CancelImportPaste -> + ( { model | importPasteOpen = False, importPasteText = "" }, Cmd.none ) + + ImportMetaReceived meta -> + ( { model | importedFlow = Just meta, recording = False }, Cmd.none ) + + ImportError errMsg -> + ( { model | lastDecodeError = Just errMsg }, Cmd.none ) + + DecodeError errMsg -> + ( { model | lastDecodeError = Just errMsg }, Cmd.none ) + + DiagnosisReceived result -> + let + shouldExpand = + model.recording + && (result.flowHealth == Error) + && model.summaryCollapsed + in + ( { model + | diagnosis = Just result + , summaryCollapsed = + if shouldExpand then + False + + else + model.summaryCollapsed + } + , Cmd.none + ) + + ToggleSummary -> + ( { model | summaryCollapsed = not model.summaryCollapsed }, Cmd.none ) + + SaveSnapshot -> + ( model, Cmd.none ) + + SwitchViewMode mode -> + ( { model + | viewMode = mode + , isPlaying = False + , playbackIndex = Nothing + , selectedNodeId = Nothing + } + , Cmd.none + ) + + StartPlayback -> + let + sdkNodes = + Helpers.sdkNodes model.events + + startIndex = + case model.playbackIndex of + Just n -> + if n >= List.length sdkNodes - 1 then + 0 + else + n + + Nothing -> + 0 + + firstId = + sdkNodes + |> List.drop startIndex + |> List.head + |> Maybe.map .id + in + ( { model + | isPlaying = True + , playbackIndex = Just startIndex + , selectedNodeId = firstId + , expandedSubRows = Set.empty + } + , Cmd.none + ) + + StopPlayback -> + ( { model | isPlaying = False }, Cmd.none ) + + PlaybackTick -> + if not model.isPlaying then + ( model, Cmd.none ) + + else + let + sdkNodes = + Helpers.sdkNodes model.events + + total = + List.length sdkNodes + + nextIndex = + Maybe.map (\n -> n + 1) model.playbackIndex + |> Maybe.withDefault 0 + + isFinished = + nextIndex >= total + + nextId = + List.head (List.drop nextIndex sdkNodes) + |> Maybe.map .id + in + if isFinished then + ( { model | isPlaying = False, playbackIndex = Nothing }, Cmd.none ) + + else + ( { model + | playbackIndex = Just nextIndex + , selectedNodeId = nextId + , expandedSubRows = Set.empty + } + , Cmd.none + ) + + SelectFlowNode id -> + ( { model + | selectedNodeId = Just id + , expandedSubRows = Set.empty + } + , Cmd.none + ) + + ToggleSubRow key -> + ( { model + | expandedSubRows = + if Set.member key model.expandedSubRows then + Set.remove key model.expandedSubRows + else + Set.insert key model.expandedSubRows + } + , Cmd.none + ) + + ResetPlayback -> + ( { model + | playbackIndex = Nothing + , isPlaying = False + , selectedNodeId = Nothing + } + , Cmd.none + ) + + HoverNode maybeId -> + ( { model | hoveredNodeId = maybeId }, Cmd.none ) + + CopyToClipboard _ -> + ( model, Cmd.none ) + + ToggleSnapshotMenu -> + ( { model | snapshotMenuOpen = not model.snapshotMenuOpen }, Cmd.none ) + + CloseSnapshotMenu -> + ( { model | snapshotMenuOpen = False }, Cmd.none ) + + SnapshotsReceived list -> + ( { model | snapshots = list }, Cmd.none ) + + LoadSnapshot _ -> + ( { model | snapshotMenuOpen = False }, Cmd.none ) + + DeleteSnapshot id -> + ( { model + | snapshots = List.filter (\s -> s.id /= id) model.snapshots + } + , Cmd.none + ) + + LearnSelectNode nodeId -> + let + canvas = + model.learnCanvas + in + ( { model + | learnCanvas = + { canvas + | learnSelectedNodeId = Just nodeId + , expandedCard = Nothing + , cardPositions = [] + } + } + , Cmd.none + ) + + LearnExpandCard cardId -> + let + canvas = + model.learnCanvas + + newExpanded = + if canvas.expandedCard == Just cardId then + Nothing + + else + Just cardId + in + ( { model | learnCanvas = { canvas | expandedCard = newExpanded } } + , Cmd.none + ) + + LearnCollapseCard -> + let + canvas = + model.learnCanvas + in + ( { model | learnCanvas = { canvas | expandedCard = Nothing } } + , Cmd.none + ) + + LearnStartDrag cardId mx my -> + let + canvas = + model.learnCanvas + in + ( { model + | learnCanvas = + { canvas + | dragTarget = Just cardId + , dragStart = Just (Vec2 mx my) + } + } + , Cmd.none + ) + + LearnDrag mx my -> + let + canvas = + model.learnCanvas + in + case canvas.dragTarget of + Just cardId -> + case canvas.dragStart of + Just start -> + let + dx = + (mx - start.x) / canvas.zoom + + dy = + (my - start.y) / canvas.zoom + + key = + cardIdToString cardId + + existing = + List.filter (\( k, _ ) -> k == key) canvas.cardPositions + |> List.head + |> Maybe.map Tuple.second + |> Maybe.withDefault (Vec2 0 0) + + newPos = + Vec2 (existing.x + dx) (existing.y + dy) + + newPositions = + ( key, newPos ) + :: List.filter (\( k, _ ) -> k /= key) canvas.cardPositions + in + ( { model + | learnCanvas = + { canvas + | cardPositions = newPositions + , dragStart = Just (Vec2 mx my) + } + } + , Cmd.none + ) + + Nothing -> + ( model, Cmd.none ) + + Nothing -> + if canvas.isPanning then + case canvas.panStart of + Just start -> + let + dx = + mx - start.x + + dy = + my - start.y + in + ( { model + | learnCanvas = + { canvas + | panX = canvas.panX + dx + , panY = canvas.panY + dy + , panStart = Just (Vec2 mx my) + } + } + , Cmd.none + ) + + Nothing -> + ( model, Cmd.none ) + + else + ( model, Cmd.none ) + + LearnEndDrag -> + let + canvas = + model.learnCanvas + in + ( { model + | learnCanvas = + { canvas + | dragTarget = Nothing + , dragStart = Nothing + , isPanning = False + , panStart = Nothing + } + } + , Cmd.none + ) + + LearnStartPan mx my -> + let + canvas = + model.learnCanvas + in + ( { model + | learnCanvas = + { canvas + | isPanning = True + , panStart = Just (Vec2 mx my) + } + } + , Cmd.none + ) + + LearnPan mx my -> + let + canvas = + model.learnCanvas + in + case canvas.panStart of + Just start -> + let + dx = + mx - start.x + + dy = + my - start.y + in + ( { model + | learnCanvas = + { canvas + | panX = canvas.panX + dx + , panY = canvas.panY + dy + , panStart = Just (Vec2 mx my) + } + } + , Cmd.none + ) + + Nothing -> + ( model, Cmd.none ) + + LearnEndPan -> + let + canvas = + model.learnCanvas + in + ( { model + | learnCanvas = + { canvas + | isPanning = False + , panStart = Nothing + } + } + , Cmd.none + ) + + LearnZoom delta -> + let + canvas = + model.learnCanvas + + newZoom = + clamp 0.5 3.0 (canvas.zoom + delta * 0.001) + in + ( { model | learnCanvas = { canvas | zoom = newZoom } } + , Cmd.none + ) + + +cardIdToString : CardId -> String +cardIdToString cardId = + case cardId of + BrowserCard -> + "browser" + + ServerCard -> + "server" + + SdkCard -> + "sdk" + + FormCard -> + "form" diff --git a/packages/devtools-extension/src/panel/src/View.elm b/packages/devtools-extension/src/panel/src/View.elm new file mode 100644 index 0000000000..70ce2537d7 --- /dev/null +++ b/packages/devtools-extension/src/panel/src/View.elm @@ -0,0 +1,349 @@ +module View exposing (view) + +import FlowView +import Graph +import Helpers +import LearnView +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (onClick, onInput) +import Inspector +import Model exposing (Model) +import Timeline +import Types exposing (AuthEvent, FlowHealth(..), FlowIssue, ImportMeta, Severity(..), SnapshotMeta, ViewMode(..)) +import Update exposing (Msg(..)) + + +view : Model -> Html Msg +view model = + let + selectedEvent = + model.selectedEventId + |> Maybe.andThen (\id -> Helpers.findEvent id model.eventsById) + + eventCount = + List.length model.events + in + div [ class "layout" ] + [ viewToolbar model eventCount + , case model.lastDecodeError of + Just errMsg -> + div [ class "err-banner" ] [ text ("Bridge decode error: " ++ errMsg) ] + + Nothing -> + text "" + , viewImportBanner model + , viewImportPaste model + , viewFlowHealthPanel model + , case model.viewMode of + FlowMode -> + FlowView.view + model.events + model.playbackIndex + model.selectedNodeId + model.expandedSubRows + + TimelineMode -> + div [ class "timeline-layout" ] + [ div [ class "main-area" ] + [ div [ class "graph-panel" ] + [ div [ class "graph-panel-label" ] [ text "Flow" ] + , Graph.view model.events model.selectedEventId model.hoveredNodeId + ] + , div [ class "timeline-panel" ] + [ viewTimelineHeader + , Timeline.view model.events model.selectedEventId + ] + ] + , div [ class "inspector-panel" ] + [ Inspector.view selectedEvent model.activeTab model.diagnosis ] + ] + + LearnMode -> + LearnView.view model.events model.learnCanvas + ] + + +viewExportDropdown : Model -> Html Msg +viewExportDropdown model = + div [ class "tb-dropdown" ] + [ button [ onClick ToggleExportMenu, class "tb-btn" ] [ text "Export ▾" ] + , if model.exportMenuOpen then + div [ class "tb-dropdown-menu" ] + [ button [ onClick ExportJson, class "tb-dropdown-item" ] [ text "Export JSON" ] + , button [ onClick ExportMarkdown, class "tb-dropdown-item" ] [ text "Export Markdown" ] + ] + + else + text "" + ] + + +viewSnapshotDropdown : Model -> Html Msg +viewSnapshotDropdown model = + div [ class "tb-dropdown" ] + [ button [ onClick SaveSnapshot, class "tb-btn" ] [ text "Snapshot" ] + , button [ onClick ToggleSnapshotMenu, class "tb-btn tb-dropdown-arrow" ] [ text "▾" ] + , if model.snapshotMenuOpen then + div [ class "tb-dropdown-menu snapshot-menu" ] + (if List.isEmpty model.snapshots then + [ div [ class "snapshot-empty" ] [ text "No saved snapshots" ] ] + + else + List.map viewSnapshotItem model.snapshots + ) + + else + text "" + ] + + +viewSnapshotItem : SnapshotMeta -> Html Msg +viewSnapshotItem snap = + div [ class "snapshot-item" ] + [ div [ class "snapshot-item-info", onClick (LoadSnapshot snap.id) ] + [ span [ class "snapshot-flow" ] + [ text + (case snap.flowId of + Just fid -> + "flow " ++ Helpers.truncateId fid + + Nothing -> + "flow (none)" + ) + ] + , span [ class "snapshot-meta" ] + [ text + (" · " ++ String.left 16 snap.savedAt ++ " · " ++ String.fromInt snap.eventCount ++ " events") + ] + ] + , button + [ onClick (DeleteSnapshot snap.id) + , class "snapshot-delete" + ] + [ text "✕" ] + ] + + +viewImportBanner : Model -> Html Msg +viewImportBanner model = + case model.importedFlow of + Nothing -> + text "" + + Just meta -> + div [ class "import-banner" ] + [ span [] + [ text + ("Imported flow " + ++ (case meta.flowId of + Just id -> + Helpers.truncateId id + + Nothing -> + "(unknown)" + ) + ++ " · captured " + ++ String.left 16 meta.capturedAt + ++ (if meta.redacted then + " · redacted" + + else + "" + ) + ) + ] + , button [ onClick ClearFlow, class "import-banner-clear" ] [ text "Clear" ] + ] + + +viewImportPaste : Model -> Html Msg +viewImportPaste model = + if not model.importPasteOpen then + text "" + + else + div [ class "import-paste" ] + [ div [ class "import-paste-header" ] + [ span [] [ text "Paste exported flow JSON below" ] + , div [ class "import-paste-actions" ] + [ button + [ onClick SubmitImportPaste + , class "tb-btn" + , disabled (String.isEmpty (String.trim model.importPasteText)) + ] + [ text "Import" ] + , button [ onClick CancelImportPaste, class "tb-btn" ] [ text "Cancel" ] + ] + ] + , textarea + [ class "import-paste-textarea" + , placeholder "Paste exported JSON here (Ctrl/Cmd+V)" + , value model.importPasteText + , onInput UpdateImportPaste + ] + [] + ] + + +viewFlowHealthPanel : Model -> Html Msg +viewFlowHealthPanel model = + case model.diagnosis of + Nothing -> + text "" + + Just diagnosis -> + case diagnosis.flowHealth of + Healthy -> + text "" + + _ -> + let + ( healthClass, healthLabel ) = + case diagnosis.flowHealth of + Error -> ( "fh-panel fh-error", "● ERROR" ) + Warning -> ( "fh-panel fh-warning", "● WARNING" ) + Healthy -> ( "fh-panel", "" ) + + issueCount = + List.length diagnosis.issues + + flowLabel = + case model.flowId of + Just id -> "Flow: " ++ Helpers.truncateId id + Nothing -> "" + + summary = + healthLabel + ++ (if String.isEmpty flowLabel then "" else " " ++ flowLabel) + ++ " " + ++ String.fromInt issueCount + ++ (if issueCount == 1 then " issue found" else " issues found") + in + div [ class healthClass ] + [ div [ class "fh-header" ] + [ span [ class "fh-title" ] [ text "Flow Health" ] + , span [ class "fh-summary" ] [ text summary ] + , button [ onClick ToggleSummary, class "fh-collapse-btn" ] + [ text + (if model.summaryCollapsed then "▶" else "▼") + ] + ] + , if model.summaryCollapsed then + text "" + + else + div [ class "fh-issues" ] + (List.map viewFlowIssue diagnosis.issues) + ] + + +viewFlowIssue : FlowIssue -> Html Msg +viewFlowIssue issue = + let + ( issueClass, icon ) = + case issue.severity of + SevError -> ( "fh-issue fh-issue-error", "✕ " ) + SevWarning -> ( "fh-issue fh-issue-warning", "⚠ " ) + SevInfo -> ( "fh-issue fh-issue-info", "ℹ " ) + + firstEventId = + List.head issue.relatedEventIds + in + div + (class issueClass + :: (case firstEventId of + Just eid -> [ onClick (SelectEvent eid) ] + Nothing -> [] + ) + ) + [ span [ class "fh-issue-cat" ] [ text (String.toUpper issue.category) ] + , span [ class "fh-issue-title" ] [ text (" — " ++ icon ++ issue.title) ] + , div [ class "fh-issue-desc" ] [ text issue.description ] + ] + + +viewTimelineHeader : Html Msg +viewTimelineHeader = + div [ class "tl-header" ] + [ span [ class "tl-badge tl-hdr-label" ] [ text "Type" ] + , span [ class "tl-st tl-hdr-label" ] [ text "Status" ] + , span [ class "tl-meth tl-hdr-label" ] [ text "Method" ] + , span [ class "tl-desc tl-hdr-label" ] [ text "URL / Description" ] + , span [ class "tl-dur tl-hdr-label" ] [ text "Time" ] + ] + + +viewToolbar : Model -> Int -> Html Msg +viewToolbar model eventCount = + div [ class "toolbar" ] + [ if model.recording then + button [ onClick ToggleRecording, class "tb-btn recording" ] + [ span [ class "rec-dot" ] [] + , text "Recording" + ] + + else + button [ onClick ToggleRecording, class "tb-btn" ] + [ text "Record" ] + , div [ class "tb-sep" ] [] + , button [ onClick ClearFlow, class "tb-btn" ] [ text "Clear" ] + , viewExportDropdown model + , button [ onClick ImportFlow, class "tb-btn" ] [ text "Import" ] + , viewSnapshotDropdown model + , div [ class "tb-sep" ] [] + , button + [ onClick (SwitchViewMode TimelineMode) + , class + (if model.viewMode == TimelineMode then + "tb-btn tb-mode-btn active" + + else + "tb-btn tb-mode-btn" + ) + ] + [ text "Timeline" ] + , button + [ onClick (SwitchViewMode FlowMode) + , class + (if model.viewMode == FlowMode then + "tb-btn tb-mode-btn active" + + else + "tb-btn tb-mode-btn" + ) + ] + [ text "Flow" ] + , button + [ onClick (SwitchViewMode LearnMode) + , class + (if model.viewMode == LearnMode then + "tb-btn tb-mode-btn active" + + else + "tb-btn tb-mode-btn" + ) + ] + [ text "Learn" ] + , div [ class "tb-spacer" ] [] + , if model.viewMode == FlowMode then + FlowView.viewPlaybackControls model.events model.playbackIndex model.isPlaying + + else if model.viewMode == LearnMode then + text "" + + else if eventCount > 0 then + span [ class "event-count" ] [ text (String.fromInt eventCount ++ " events") ] + + else + text "" + , case model.flowId of + Just id -> + span [ class "flow-chip" ] + [ text "flow " + , span [ class "flow-chip-id" ] [ text (Helpers.truncateId id) ] + ] + + Nothing -> + span [ class "no-flow" ] [ text "No active flow" ] + ] diff --git a/packages/devtools-extension/tests/DecodeTests.elm b/packages/devtools-extension/tests/DecodeTests.elm new file mode 100644 index 0000000000..b2d877e378 --- /dev/null +++ b/packages/devtools-extension/tests/DecodeTests.elm @@ -0,0 +1,898 @@ +module DecodeTests exposing (suite) + +import Decode exposing (decodeAuthEvent, decodeDiagnosisResult, decodeImportMeta, decodeSnapshotMeta) +import Expect +import Json.Decode as JD +import Test exposing (Test, describe, test) +import Types exposing (EventData(..), EventKind(..), EventSource(..), FlowHealth(..), NodeStatus(..), Severity(..)) + + +baseEventJson : String -> String -> String -> String +baseEventJson eventType source dataJson = + """ + { "id": "evt-001" + , "timestamp": 1700000000000 + , "type": \"""" + ++ eventType + ++ """" + , "source": \"""" + ++ source + ++ """" + , "flowId": "flow-abc" + , "causedBy": null + , "flags": { "isCors": false, "isError": false, "isAuthRelated": true } + , "data": """ + ++ dataJson + ++ """ + }""" + + +suite : Test +suite = + describe "Decode" + [ decodeAuthEventTests + , decodeNodeDataSubObjectTests + , decodeDiagnosisTests + , decodeRelevantDataTests + , decodeImportMetaTests + , decodeSnapshotMetaTests + , decodeSeverityTests + ] + + +decodeAuthEventTests : Test +decodeAuthEventTests = + describe "decodeAuthEvent" + [ test "decodes a network event" <| + \_ -> + let + json = + baseEventJson "network:response" + "network" + """{ "_tag": "network", "url": "https://auth.example.com/token", "method": "POST", "status": 200, "duration": 123, "requestHeaders": {}, "responseHeaders": {} }""" + + result = + JD.decodeString decodeAuthEvent json + in + case result of + Ok event -> + Expect.all + [ \e -> Expect.equal "evt-001" e.id + , \e -> Expect.equal NetworkEvent e.kind + , \e -> Expect.equal NetworkSource e.source + , \e -> + case e.data of + Network nd -> + Expect.equal (Just 200) nd.status + + _ -> + Expect.fail "Expected Network data variant" + ] + event + + Err err -> + Expect.fail (JD.errorToString err) + , test "decodes a sdk:node-change event" <| + \_ -> + let + json = + baseEventJson "sdk:node-change" + "sdk" + """{ "_tag": "sdk", "nodeStatus": "continue", "interactionId": "int-1", "nodeName": "Username" }""" + + result = + JD.decodeString decodeAuthEvent json + in + case result of + Ok event -> + Expect.all + [ \e -> Expect.equal NodeChange e.kind + , \e -> Expect.equal SdkSource e.source + , \e -> + case e.data of + DaVinciNode nd -> + Expect.all + [ \n -> Expect.equal (Just Continue) n.nodeStatus + , \n -> Expect.equal (Just "int-1") n.interactionId + , \n -> Expect.equal (Just "Username") n.nodeName + ] + nd + + _ -> + Expect.fail "Expected DaVinciNode data variant" + ] + event + + Err err -> + Expect.fail (JD.errorToString err) + , test "decodes a sdk:journey-step event" <| + \_ -> + let + json = + baseEventJson "sdk:journey-step" + "sdk" + """{ "_tag": "journey", "stepType": "Step", "authId": "abc123", "stage": "UsernamePassword", "header": "Sign In" }""" + + result = + JD.decodeString decodeAuthEvent json + in + case result of + Ok event -> + Expect.all + [ \e -> Expect.equal JourneyStep e.kind + , \e -> + case e.data of + Journey jd -> + Expect.all + [ \j -> Expect.equal (Just "Step") j.stepType + , \j -> Expect.equal (Just "abc123") j.authId + , \j -> Expect.equal (Just "UsernamePassword") j.stage + ] + jd + + _ -> + Expect.fail "Expected Journey data variant" + ] + event + + Err err -> + Expect.fail (JD.errorToString err) + , test "decodes a sdk:oidc-state event" <| + \_ -> + let + json = + baseEventJson "sdk:oidc-state" + "sdk" + """{ "_tag": "oidc", "phase": "authorize", "status": "success", "clientId": "my-app" }""" + + result = + JD.decodeString decodeAuthEvent json + in + case result of + Ok event -> + Expect.all + [ \e -> Expect.equal OidcState e.kind + , \e -> + case e.data of + Oidc od -> + Expect.all + [ \o -> Expect.equal (Just "authorize") o.phase + , \o -> Expect.equal (Just "success") o.status + , \o -> Expect.equal (Just "my-app") o.clientId + ] + od + + _ -> + Expect.fail "Expected Oidc data variant" + ] + event + + Err err -> + Expect.fail (JD.errorToString err) + , test "decodes a session event" <| + \_ -> + let + json = + baseEventJson "session:storage" + "session" + """{ "_tag": "session", "key": "token", "before": "old", "after": "new" }""" + + result = + JD.decodeString decodeAuthEvent json + in + case result of + Ok event -> + Expect.all + [ \e -> Expect.equal SessionEvent e.kind + , \e -> Expect.equal SessionSource e.source + , \e -> + case e.data of + Session sd -> + Expect.all + [ \s -> Expect.equal (Just "token") s.key + , \s -> Expect.equal (Just "old") s.before + , \s -> Expect.equal (Just "new") s.after + ] + sd + + _ -> + Expect.fail "Expected Session data variant" + ] + event + + Err err -> + Expect.fail (JD.errorToString err) + , test "decodes a sdk:config event" <| + \_ -> + let + json = + baseEventJson "sdk:config" + "sdk" + """{ "_tag": "sdk-config", "config": { "serverUrl": "https://auth.example.com" } }""" + + result = + JD.decodeString decodeAuthEvent json + in + case result of + Ok event -> + Expect.all + [ \e -> Expect.equal SdkConfig e.kind + , \e -> + case e.data of + Config _ -> + Expect.pass + + _ -> + Expect.fail "Expected Config data variant" + ] + event + + Err err -> + Expect.fail (JD.errorToString err) + , test "decodes flags correctly" <| + \_ -> + let + json = + """ + { "id": "evt-cors" + , "timestamp": 100 + , "type": "network:response" + , "source": "network" + , "flowId": null + , "causedBy": null + , "flags": { "isCors": true, "isError": true, "isAuthRelated": false } + , "data": { "_tag": "network", "status": 0, "method": "POST", "url": "https://x.com", "duration": 0, "requestHeaders": {}, "responseHeaders": {} } + }""" + + result = + JD.decodeString decodeAuthEvent json + in + case result of + Ok event -> + Expect.all + [ \e -> Expect.equal True e.isCors + , \e -> Expect.equal True e.isError + , \e -> Expect.equal False e.isAuthRelated + , \e -> Expect.equal Nothing e.flowId + ] + event + + Err err -> + Expect.fail (JD.errorToString err) + , test "decodes causedBy string" <| + \_ -> + let + json = + """ + { "id": "evt-1" + , "timestamp": 100 + , "type": "network:response" + , "source": "network" + , "flowId": null + , "causedBy": "sdk-42" + , "flags": { "isCors": false, "isError": false, "isAuthRelated": true } + , "data": { "_tag": "network", "status": 200, "method": "GET", "url": "https://x.com", "duration": 10, "requestHeaders": {}, "responseHeaders": {} } + }""" + + result = + JD.decodeString decodeAuthEvent json + in + case result of + Ok event -> + Expect.equal (Just "sdk-42") event.causedBy + + Err err -> + Expect.fail (JD.errorToString err) + , test "falls back to Network for unknown event type with non-session source" <| + \_ -> + let + json = + baseEventJson "network:request" + "network" + """{ "_tag": "network", "status": 302, "method": "GET", "url": "https://x.com/redirect", "duration": 5, "requestHeaders": {}, "responseHeaders": {} }""" + + result = + JD.decodeString decodeAuthEvent json + in + case result of + Ok event -> + case event.data of + Network _ -> + Expect.pass + + _ -> + Expect.fail "Expected Network data variant for unknown type" + + Err err -> + Expect.fail (JD.errorToString err) + ] + + +decodeNodeDataSubObjectTests : Test +decodeNodeDataSubObjectTests = + describe "decodeAuthEvent (node sub-objects)" + [ test "decodes sdk:node-change with error sub-object" <| + \_ -> + let + json = + baseEventJson "sdk:node-change" + "sdk" + """{ "_tag": "sdk", "nodeStatus": "error", "error": { "code": "E001", "message": "Auth failed", "type": "authentication", "internalHttpStatus": 401 } }""" + in + case JD.decodeString decodeAuthEvent json of + Ok event -> + case event.data of + DaVinciNode nd -> + Expect.all + [ \n -> Expect.equal (Just StatusError) n.nodeStatus + , \n -> + case n.sdkError of + Just err -> + Expect.all + [ \e -> Expect.equal "E001" e.code + , \e -> Expect.equal "Auth failed" e.message + , \e -> Expect.equal "authentication" e.errorType + , \e -> Expect.equal (Just 401) e.internalHttpStatus + ] + err + + Nothing -> + Expect.fail "Expected error to be present" + ] + nd + + _ -> + Expect.fail "Expected DaVinciNode" + + Err err -> + Expect.fail (JD.errorToString err) + , test "decodes sdk:node-change with error without internalHttpStatus" <| + \_ -> + let + json = + baseEventJson "sdk:node-change" + "sdk" + """{ "_tag": "sdk", "nodeStatus": "error", "error": { "code": "E002", "message": "Timeout", "type": "network" } }""" + in + case JD.decodeString decodeAuthEvent json of + Ok event -> + case event.data of + DaVinciNode nd -> + case nd.sdkError of + Just err -> + Expect.equal Nothing err.internalHttpStatus + + Nothing -> + Expect.fail "Expected error to be present" + + _ -> + Expect.fail "Expected DaVinciNode" + + Err err -> + Expect.fail (JD.errorToString err) + , test "decodes sdk:node-change with authorization sub-object" <| + \_ -> + let + json = + baseEventJson "sdk:node-change" + "sdk" + """{ "_tag": "sdk", "nodeStatus": "success", "authorization": { "code": "auth-code-123", "state": "state-xyz" } }""" + in + case JD.decodeString decodeAuthEvent json of + Ok event -> + case event.data of + DaVinciNode nd -> + Expect.all + [ \n -> Expect.equal (Just Success) n.nodeStatus + , \n -> + case n.authorization of + Just auth -> + Expect.all + [ \a -> Expect.equal (Just "auth-code-123") a.code + , \a -> Expect.equal (Just "state-xyz") a.state + ] + auth + + Nothing -> + Expect.fail "Expected authorization to be present" + ] + nd + + _ -> + Expect.fail "Expected DaVinciNode" + + Err err -> + Expect.fail (JD.errorToString err) + , test "decodes sdk:node-change with authorization with optional fields omitted" <| + \_ -> + let + json = + baseEventJson "sdk:node-change" + "sdk" + """{ "_tag": "sdk", "nodeStatus": "success", "authorization": {} }""" + in + case JD.decodeString decodeAuthEvent json of + Ok event -> + case event.data of + DaVinciNode nd -> + case nd.authorization of + Just auth -> + Expect.all + [ \a -> Expect.equal Nothing a.code + , \a -> Expect.equal Nothing a.state + ] + auth + + Nothing -> + Expect.fail "Expected authorization to be present" + + _ -> + Expect.fail "Expected DaVinciNode" + + Err err -> + Expect.fail (JD.errorToString err) + , test "decodes sdk:node-change with all optional fields" <| + \_ -> + let + json = + baseEventJson "sdk:node-change" + "sdk" + """{ "_tag": "sdk", "nodeStatus": "continue", "previousStatus": "start", "interactionId": "int-1", "interactionToken": "tok-1", "nodeId": "node-1", "requestId": "req-1", "nodeName": "Password", "nodeDescription": "Enter password", "eventName": "click", "httpStatus": 200, "session": "sess-abc" }""" + in + case JD.decodeString decodeAuthEvent json of + Ok event -> + case event.data of + DaVinciNode nd -> + Expect.all + [ \n -> Expect.equal (Just Continue) n.nodeStatus + , \n -> Expect.equal (Just UnknownStatus) n.previousStatus + , \n -> Expect.equal (Just "int-1") n.interactionId + , \n -> Expect.equal (Just "tok-1") n.interactionToken + , \n -> Expect.equal (Just "node-1") n.nodeId + , \n -> Expect.equal (Just "req-1") n.requestId + , \n -> Expect.equal (Just "Password") n.nodeName + , \n -> Expect.equal (Just "Enter password") n.nodeDescription + , \n -> Expect.equal (Just "click") n.eventName + , \n -> Expect.equal (Just 200) n.httpStatus + , \n -> Expect.equal (Just "sess-abc") n.session + ] + nd + + _ -> + Expect.fail "Expected DaVinciNode" + + Err err -> + Expect.fail (JD.errorToString err) + , test "decodes sdk:node-change with minimal fields" <| + \_ -> + let + json = + baseEventJson "sdk:node-change" + "sdk" + """{ "_tag": "sdk" }""" + in + case JD.decodeString decodeAuthEvent json of + Ok event -> + case event.data of + DaVinciNode nd -> + Expect.all + [ \n -> Expect.equal Nothing n.nodeStatus + , \n -> Expect.equal Nothing n.previousStatus + , \n -> Expect.equal Nothing n.sdkError + , \n -> Expect.equal Nothing n.authorization + , \n -> Expect.equal Nothing n.collectors + ] + nd + + _ -> + Expect.fail "Expected DaVinciNode" + + Err err -> + Expect.fail (JD.errorToString err) + ] + + +decodeRelevantDataTests : Test +decodeRelevantDataTests = + describe "relevantData in issues" + [ test "decodes flow issue with relevantData" <| + \_ -> + let + json = + """ + { "flowHealth": "warning" + , "issues": [ + { "id": "tok-1" + , "severity": "warning" + , "category": "token" + , "title": "Missing Token" + , "description": "No interaction token" + , "steps": ["Check config"] + , "relatedEventIds": ["evt-1"] + , "relevantData": { "interactionToken": "null", "nodeId": "abc" } + } + ] + , "annotatedEvents": {} + }""" + in + case JD.decodeString decodeDiagnosisResult json of + Ok d -> + case d.issues of + [ issue ] -> + Expect.all + [ \i -> Expect.equal SevWarning i.severity + , \i -> + case i.relevantData of + Just pairs -> + Expect.equal True (List.length pairs == 2) + + Nothing -> + Expect.fail "Expected relevantData to be present" + ] + issue + + _ -> + Expect.fail "Expected exactly one issue" + + Err err -> + Expect.fail (JD.errorToString err) + , test "decodes flow issue without relevantData" <| + \_ -> + let + json = + """ + { "flowHealth": "error" + , "issues": [ + { "id": "cors-1" + , "severity": "error" + , "category": "cors" + , "title": "CORS Blocked" + , "description": "Blocked" + , "steps": [] + , "relatedEventIds": [] + } + ] + , "annotatedEvents": {} + }""" + in + case JD.decodeString decodeDiagnosisResult json of + Ok d -> + case d.issues of + [ issue ] -> + Expect.all + [ \i -> Expect.equal SevError i.severity + , \i -> Expect.equal Nothing i.relevantData + ] + issue + + _ -> + Expect.fail "Expected exactly one issue" + + Err err -> + Expect.fail (JD.errorToString err) + , test "decodes event issue with relevantData" <| + \_ -> + let + json = + """ + { "flowHealth": "warning" + , "issues": [] + , "annotatedEvents": { + "evt-1": [ + { "severity": "warning" + , "title": "Expired JWT" + , "description": "Token expired" + , "steps": ["Refresh"] + , "relevantData": { "exp": "1700000000", "now": "1700000100" } + } + ] + } + }""" + in + case JD.decodeString decodeDiagnosisResult json of + Ok d -> + case d.annotatedEvents of + [ ( _, [ issue ] ) ] -> + Expect.all + [ \i -> Expect.equal SevWarning i.severity + , \i -> + case i.relevantData of + Just pairs -> + Expect.equal 2 (List.length pairs) + + Nothing -> + Expect.fail "Expected relevantData" + ] + issue + + _ -> + Expect.fail "Expected one annotated event" + + Err err -> + Expect.fail (JD.errorToString err) + , test "decodes event issue without relevantData" <| + \_ -> + let + json = + """ + { "flowHealth": "warning" + , "issues": [] + , "annotatedEvents": { + "evt-1": [ + { "severity": "info" + , "title": "Status Zero" + , "description": "Request failed" + , "steps": [] + } + ] + } + }""" + in + case JD.decodeString decodeDiagnosisResult json of + Ok d -> + case d.annotatedEvents of + [ ( _, [ issue ] ) ] -> + Expect.all + [ \i -> Expect.equal SevInfo i.severity + , \i -> Expect.equal Nothing i.relevantData + ] + issue + + _ -> + Expect.fail "Expected one annotated event" + + Err err -> + Expect.fail (JD.errorToString err) + ] + + +decodeDiagnosisTests : Test +decodeDiagnosisTests = + describe "decodeDiagnosisResult" + [ test "decodes a healthy diagnosis" <| + \_ -> + let + json = + """ + { "flowHealth": "healthy" + , "issues": [] + , "annotatedEvents": {} + }""" + + result = + JD.decodeString decodeDiagnosisResult json + in + case result of + Ok diagnosis -> + Expect.all + [ \d -> Expect.equal Healthy d.flowHealth + , \d -> Expect.equal [] d.issues + , \d -> Expect.equal [] d.annotatedEvents + ] + diagnosis + + Err err -> + Expect.fail (JD.errorToString err) + , test "decodes error flow health" <| + \_ -> + let + json = + """{ "flowHealth": "error", "issues": [], "annotatedEvents": {} }""" + in + case JD.decodeString decodeDiagnosisResult json of + Ok d -> + Expect.equal Error d.flowHealth + + Err err -> + Expect.fail (JD.errorToString err) + , test "decodes warning flow health" <| + \_ -> + let + json = + """{ "flowHealth": "warning", "issues": [], "annotatedEvents": {} }""" + in + case JD.decodeString decodeDiagnosisResult json of + Ok d -> + Expect.equal Warning d.flowHealth + + Err err -> + Expect.fail (JD.errorToString err) + , test "decodes issues with flow issue fields" <| + \_ -> + let + json = + """ + { "flowHealth": "error" + , "issues": [ + { "id": "cors-1" + , "severity": "error" + , "category": "cors" + , "title": "CORS Blocked" + , "description": "Request blocked" + , "steps": ["Check headers", "Add allow-origin"] + , "relatedEventIds": ["evt-1", "evt-2"] + } + ] + , "annotatedEvents": {} + }""" + in + case JD.decodeString decodeDiagnosisResult json of + Ok d -> + case d.issues of + [ issue ] -> + Expect.all + [ \i -> Expect.equal "cors-1" i.id + , \i -> Expect.equal SevError i.severity + , \i -> Expect.equal "cors" i.category + , \i -> Expect.equal [ "Check headers", "Add allow-origin" ] i.steps + , \i -> Expect.equal [ "evt-1", "evt-2" ] i.relatedEventIds + ] + issue + + _ -> + Expect.fail "Expected exactly one issue" + + Err err -> + Expect.fail (JD.errorToString err) + , test "decodes annotated events with event issues" <| + \_ -> + let + json = + """ + { "flowHealth": "warning" + , "issues": [] + , "annotatedEvents": { + "evt-1": [ + { "severity": "warning" + , "title": "Expired JWT" + , "description": "Token has expired" + , "steps": ["Refresh token"] + } + ] + } + }""" + in + case JD.decodeString decodeDiagnosisResult json of + Ok d -> + case d.annotatedEvents of + [ ( eventId, [ issue ] ) ] -> + Expect.all + [ \_ -> Expect.equal "evt-1" eventId + , \_ -> Expect.equal SevWarning issue.severity + , \_ -> Expect.equal "Expired JWT" issue.title + ] + () + + _ -> + Expect.fail "Expected one annotated event entry" + + Err err -> + Expect.fail (JD.errorToString err) + ] + + +decodeImportMetaTests : Test +decodeImportMetaTests = + describe "decodeImportMeta" + [ test "decodes import meta with flowId" <| + \_ -> + let + json = + """{ "flowId": "flow-abc", "capturedAt": "2026-05-08T14:30:00.000Z", "redacted": true }""" + in + case JD.decodeString decodeImportMeta json of + Ok meta -> + Expect.all + [ \m -> Expect.equal (Just "flow-abc") m.flowId + , \m -> Expect.equal "2026-05-08T14:30:00.000Z" m.capturedAt + , \m -> Expect.equal True m.redacted + ] + meta + + Err err -> + Expect.fail (JD.errorToString err) + , test "decodes null flowId" <| + \_ -> + let + json = + """{ "flowId": null, "capturedAt": "2026-05-08T14:30:00.000Z", "redacted": false }""" + in + case JD.decodeString decodeImportMeta json of + Ok meta -> + Expect.equal Nothing meta.flowId + + Err err -> + Expect.fail (JD.errorToString err) + ] + + +decodeSnapshotMetaTests : Test +decodeSnapshotMetaTests = + describe "decodeSnapshotMeta" + [ test "decodes a snapshot meta" <| + \_ -> + let + json = + """{ "id": "snap-1", "savedAt": "2026-05-08T15:00:00.000Z", "flowId": "flow-abc", "eventCount": 5 }""" + in + case JD.decodeString decodeSnapshotMeta json of + Ok meta -> + Expect.all + [ \m -> Expect.equal "snap-1" m.id + , \m -> Expect.equal (Just "flow-abc") m.flowId + , \m -> Expect.equal 5 m.eventCount + ] + meta + + Err err -> + Expect.fail (JD.errorToString err) + , test "decodes null flowId" <| + \_ -> + let + json = + """{ "id": "snap-2", "savedAt": "2026-05-08T15:00:00.000Z", "flowId": null, "eventCount": 0 }""" + in + case JD.decodeString decodeSnapshotMeta json of + Ok meta -> + Expect.equal Nothing meta.flowId + + Err err -> + Expect.fail (JD.errorToString err) + ] + + +decodeSeverityTests : Test +decodeSeverityTests = + describe "decodeSeverity via EventIssue" + [ test "decodes error severity" <| + \_ -> + let + json = + """{ "flowHealth": "warning", "issues": [], "annotatedEvents": { "e1": [{ "severity": "error", "title": "T", "description": "D", "steps": [] }] } }""" + in + case JD.decodeString decodeDiagnosisResult json of + Ok d -> + case d.annotatedEvents of + [ ( _, [ issue ] ) ] -> + Expect.equal SevError issue.severity + + _ -> + Expect.fail "Expected one annotated event" + + Err err -> + Expect.fail (JD.errorToString err) + , test "decodes warning severity" <| + \_ -> + let + json = + """{ "flowHealth": "warning", "issues": [], "annotatedEvents": { "e1": [{ "severity": "warning", "title": "T", "description": "D", "steps": [] }] } }""" + in + case JD.decodeString decodeDiagnosisResult json of + Ok d -> + case d.annotatedEvents of + [ ( _, [ issue ] ) ] -> + Expect.equal SevWarning issue.severity + + _ -> + Expect.fail "Expected one annotated event" + + Err err -> + Expect.fail (JD.errorToString err) + , test "decodes unknown severity as info" <| + \_ -> + let + json = + """{ "flowHealth": "warning", "issues": [], "annotatedEvents": { "e1": [{ "severity": "info", "title": "T", "description": "D", "steps": [] }] } }""" + in + case JD.decodeString decodeDiagnosisResult json of + Ok d -> + case d.annotatedEvents of + [ ( _, [ issue ] ) ] -> + Expect.equal SevInfo issue.severity + + _ -> + Expect.fail "Expected one annotated event" + + Err err -> + Expect.fail (JD.errorToString err) + ] diff --git a/packages/devtools-extension/tests/HelpersTests.elm b/packages/devtools-extension/tests/HelpersTests.elm new file mode 100644 index 0000000000..70cd6a1a8b --- /dev/null +++ b/packages/devtools-extension/tests/HelpersTests.elm @@ -0,0 +1,277 @@ +module HelpersTests exposing (suite) + +import Dict +import Expect +import Helpers exposing (findEvent, findEventInList, isSdkNode, methodClass, nodeColor, nodeStatusLabel, sdkNodes, statusClass, truncateId) +import Test exposing (Test, describe, test) +import Types exposing (AuthEvent, EventData(..), EventKind(..), EventSource(..), JourneyData, NetworkData, NodeData, NodeStatus(..), OidcData, SessionData) + + +makeNetworkEvent : String -> AuthEvent +makeNetworkEvent id = + { id = id + , timestamp = 100 + , kind = NetworkEvent + , source = NetworkSource + , flowId = Nothing + , isCors = False + , isError = False + , isAuthRelated = True + , causedBy = Nothing + , data = Network (NetworkData (Just 200) (Just "https://x.com") (Just "GET") (Just 50) Nothing Nothing Nothing Nothing) + } + + +makeSdkEvent : String -> Float -> AuthEvent +makeSdkEvent id ts = + { id = id + , timestamp = ts + , kind = NodeChange + , source = SdkSource + , flowId = Nothing + , isCors = False + , isError = False + , isAuthRelated = True + , causedBy = Nothing + , data = DaVinciNode (NodeData (Just Continue) Nothing Nothing Nothing Nothing Nothing (Just "Username") Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing) + } + + +makeJourneyEvent : String -> Float -> AuthEvent +makeJourneyEvent id ts = + { id = id + , timestamp = ts + , kind = JourneyStep + , source = SdkSource + , flowId = Nothing + , isCors = False + , isError = False + , isAuthRelated = True + , causedBy = Nothing + , data = Journey (JourneyData (Just "Step") Nothing Nothing Nothing Nothing (Just "abc") Nothing Nothing Nothing Nothing Nothing) + } + + +makeOidcEvent : String -> Float -> AuthEvent +makeOidcEvent id ts = + { id = id + , timestamp = ts + , kind = OidcState + , source = SdkSource + , flowId = Nothing + , isCors = False + , isError = False + , isAuthRelated = True + , causedBy = Nothing + , data = Oidc (OidcData (Just "authorize") (Just "success") Nothing Nothing Nothing) + } + + +makeSessionEvent : String -> AuthEvent +makeSessionEvent id = + { id = id + , timestamp = 100 + , kind = SessionEvent + , source = SessionSource + , flowId = Nothing + , isCors = False + , isError = False + , isAuthRelated = True + , causedBy = Nothing + , data = Session (SessionData (Just "token") (Just "old") (Just "new")) + } + + +makeConfigEvent : String -> AuthEvent +makeConfigEvent id = + { id = id + , timestamp = 100 + , kind = SdkConfig + , source = SdkSource + , flowId = Nothing + , isCors = False + , isError = False + , isAuthRelated = True + , causedBy = Nothing + , data = Config Nothing + } + + +suite : Test +suite = + describe "Helpers" + [ isSdkNodeTests + , sdkNodesTests + , findEventTests + , statusClassTests + , methodClassTests + , nodeColorTests + , nodeStatusLabelTests + , truncateIdTests + ] + + +isSdkNodeTests : Test +isSdkNodeTests = + describe "isSdkNode" + [ test "DaVinciNode is SDK node" <| + \_ -> Expect.equal True (isSdkNode (makeSdkEvent "e1" 0)) + , test "Journey is SDK node" <| + \_ -> Expect.equal True (isSdkNode (makeJourneyEvent "e1" 0)) + , test "Oidc is SDK node" <| + \_ -> Expect.equal True (isSdkNode (makeOidcEvent "e1" 0)) + , test "Network is NOT SDK node" <| + \_ -> Expect.equal False (isSdkNode (makeNetworkEvent "e1")) + , test "Session is NOT SDK node" <| + \_ -> Expect.equal False (isSdkNode (makeSessionEvent "e1")) + , test "Config is NOT SDK node" <| + \_ -> Expect.equal False (isSdkNode (makeConfigEvent "e1")) + ] + + +sdkNodesTests : Test +sdkNodesTests = + describe "sdkNodes" + [ test "filters to only SDK events" <| + \_ -> + let + events = + [ makeNetworkEvent "n1" + , makeSdkEvent "s1" 200 + , makeSessionEvent "ss1" + , makeJourneyEvent "j1" 100 + ] + in + Expect.equal [ "j1", "s1" ] (List.map .id (sdkNodes events)) + , test "sorts by timestamp" <| + \_ -> + let + events = + [ makeSdkEvent "s2" 300 + , makeSdkEvent "s1" 100 + , makeSdkEvent "s3" 200 + ] + in + Expect.equal [ "s1", "s3", "s2" ] (List.map .id (sdkNodes events)) + , test "returns empty list for no SDK nodes" <| + \_ -> + Expect.equal [] (sdkNodes [ makeNetworkEvent "n1" ]) + ] + + +findEventTests : Test +findEventTests = + describe "findEvent / findEventInList" + [ test "findEvent finds event by id in Dict" <| + \_ -> + let + evts = + Dict.fromList [ ( "e1", makeNetworkEvent "e1" ), ( "e2", makeSdkEvent "e2" 0 ) ] + in + case findEvent "e2" evts of + Just e -> + Expect.equal "e2" e.id + + Nothing -> + Expect.fail "Expected to find event e2" + , test "findEvent returns Nothing for missing id" <| + \_ -> + Expect.equal Nothing (findEvent "missing" Dict.empty) + , test "findEventInList finds event by id" <| + \_ -> + let + events = + [ makeNetworkEvent "e1", makeSdkEvent "e2" 0 ] + in + case findEventInList "e2" events of + Just e -> + Expect.equal "e2" e.id + + Nothing -> + Expect.fail "Expected to find event e2" + , test "findEventInList returns Nothing for missing id" <| + \_ -> + Expect.equal Nothing (findEventInList "missing" []) + ] + + +statusClassTests : Test +statusClassTests = + describe "statusClass" + [ test "Nothing → st-nil" <| + \_ -> Expect.equal "st-nil" (statusClass Nothing) + , test "0 → st-err" <| + \_ -> Expect.equal "st-err" (statusClass (Just 0)) + , test "200 → st-ok" <| + \_ -> Expect.equal "st-ok" (statusClass (Just 200)) + , test "302 → st-ok" <| + \_ -> Expect.equal "st-ok" (statusClass (Just 302)) + , test "400 → st-warn" <| + \_ -> Expect.equal "st-warn" (statusClass (Just 400)) + , test "500 → st-warn" <| + \_ -> Expect.equal "st-warn" (statusClass (Just 500)) + ] + + +methodClassTests : Test +methodClassTests = + describe "methodClass" + [ test "Nothing → m-other" <| + \_ -> Expect.equal "m-other" (methodClass Nothing) + , test "GET → m-get" <| + \_ -> Expect.equal "m-get" (methodClass (Just "GET")) + , test "POST → m-post" <| + \_ -> Expect.equal "m-post" (methodClass (Just "POST")) + , test "PUT → m-put" <| + \_ -> Expect.equal "m-put" (methodClass (Just "PUT")) + , test "PATCH → m-patch" <| + \_ -> Expect.equal "m-patch" (methodClass (Just "PATCH")) + , test "DELETE → m-del" <| + \_ -> Expect.equal "m-del" (methodClass (Just "DELETE")) + , test "lowercase get → m-get" <| + \_ -> Expect.equal "m-get" (methodClass (Just "get")) + , test "OPTIONS → m-other" <| + \_ -> Expect.equal "m-other" (methodClass (Just "OPTIONS")) + ] + + +nodeColorTests : Test +nodeColorTests = + describe "nodeColor" + [ test "Continue → blue" <| + \_ -> Expect.equal "#58A6FF" (nodeColor Continue) + , test "Success → green" <| + \_ -> Expect.equal "#3FB950" (nodeColor Success) + , test "StatusError → red" <| + \_ -> Expect.equal "#F85149" (nodeColor StatusError) + , test "Failure → red" <| + \_ -> Expect.equal "#F85149" (nodeColor Failure) + , test "UnknownStatus → gray" <| + \_ -> Expect.equal "#484F58" (nodeColor UnknownStatus) + ] + + +nodeStatusLabelTests : Test +nodeStatusLabelTests = + describe "nodeStatusLabel" + [ test "Continue → continue" <| + \_ -> Expect.equal "continue" (nodeStatusLabel Continue) + , test "Success → success" <| + \_ -> Expect.equal "success" (nodeStatusLabel Success) + , test "StatusError → error" <| + \_ -> Expect.equal "error" (nodeStatusLabel StatusError) + , test "Failure → failure" <| + \_ -> Expect.equal "failure" (nodeStatusLabel Failure) + , test "UnknownStatus → unknown" <| + \_ -> Expect.equal "unknown" (nodeStatusLabel UnknownStatus) + ] + + +truncateIdTests : Test +truncateIdTests = + describe "truncateId" + [ test "truncates to 8 characters" <| + \_ -> Expect.equal "abcdefgh" (truncateId "abcdefghijklmnop") + , test "returns short strings unchanged" <| + \_ -> Expect.equal "abc" (truncateId "abc") + ] diff --git a/packages/devtools-extension/tests/UpdateTests.elm b/packages/devtools-extension/tests/UpdateTests.elm new file mode 100644 index 0000000000..044517342c --- /dev/null +++ b/packages/devtools-extension/tests/UpdateTests.elm @@ -0,0 +1,916 @@ +module UpdateTests exposing (suite) + +import Dict +import Expect +import Model exposing (Model, init) +import Set +import Test exposing (Test, describe, test) +import Types exposing (AuthEvent, CardId(..), DiagnosisResult, EventData(..), EventKind(..), EventSource(..), FlowHealth(..), ImportMeta, InspectorTab(..), NetworkData, NodeData, NodeStatus(..), SnapshotMeta, ViewMode(..)) +import Update exposing (Msg(..), update) + + +initModel : Model +initModel = + Tuple.first (init ()) + + +makeSdkEvent : String -> Float -> AuthEvent +makeSdkEvent id ts = + { id = id + , timestamp = ts + , kind = NodeChange + , source = SdkSource + , flowId = Just "flow-1" + , isCors = False + , isError = False + , isAuthRelated = True + , causedBy = Nothing + , data = DaVinciNode (NodeData (Just Continue) Nothing Nothing Nothing Nothing Nothing (Just "Username") Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing) + } + + +makeNetworkEvent : String -> AuthEvent +makeNetworkEvent id = + { id = id + , timestamp = 100 + , kind = NetworkEvent + , source = NetworkSource + , flowId = Nothing + , isCors = False + , isError = False + , isAuthRelated = True + , causedBy = Nothing + , data = Network (NetworkData (Just 200) (Just "https://x.com") (Just "GET") (Just 50) Nothing Nothing Nothing Nothing) + } + + +suite : Test +suite = + describe "Update" + [ eventReceivedTests + , selectEventTests + , selectEventTabTests + , toggleTests + , clearFlowTests + , diagnosisTests + , playbackTests + , startPlaybackEdgeTests + , snapshotTests + , viewModeTests + , importTests + , flowNodeTests + , learnSelectTests + , learnDragTests + , learnZoomTests + ] + + +eventReceivedTests : Test +eventReceivedTests = + describe "EventReceived" + [ test "appends event to events list" <| + \_ -> + let + event = + makeSdkEvent "e1" 100 + + ( model, _ ) = + update (EventReceived event) initModel + in + Expect.equal 1 (List.length model.events) + , test "inserts event into eventsById" <| + \_ -> + let + event = + makeSdkEvent "e1" 100 + + ( model, _ ) = + update (EventReceived event) initModel + in + Expect.equal (Just event) (Dict.get "e1" model.eventsById) + , test "sets flowId from first event" <| + \_ -> + let + event = + makeSdkEvent "e1" 100 + + ( model, _ ) = + update (EventReceived event) initModel + in + Expect.equal (Just "flow-1") model.flowId + , test "does not overwrite existing flowId" <| + \_ -> + let + modelWithFlow = + { initModel | flowId = Just "existing-flow" } + + event = + makeSdkEvent "e1" 100 + + ( model, _ ) = + update (EventReceived event) modelWithFlow + in + Expect.equal (Just "existing-flow") model.flowId + , test "ignores events when importedFlow is set" <| + \_ -> + let + importedModel = + { initModel | importedFlow = Just (ImportMeta (Just "flow-1") "2026-01-01" True) } + + event = + makeSdkEvent "e1" 100 + + ( model, _ ) = + update (EventReceived event) importedModel + in + Expect.equal 0 (List.length model.events) + ] + + +selectEventTests : Test +selectEventTests = + describe "SelectEvent" + [ test "sets selectedEventId" <| + \_ -> + let + ( model, _ ) = + update (SelectEvent "e1") initModel + in + Expect.equal (Just "e1") model.selectedEventId + , test "switches from CollectorsTab to HeadersTab for non-NodeChange" <| + \_ -> + let + event = + makeNetworkEvent "e1" + + modelWithCollectors = + { initModel + | activeTab = CollectorsTab + , eventsById = Dict.singleton "e1" event + } + + ( model, _ ) = + update (SelectEvent "e1") modelWithCollectors + in + Expect.equal HeadersTab model.activeTab + , test "keeps CollectorsTab for NodeChange event" <| + \_ -> + let + event = + makeSdkEvent "e1" 100 + + modelWithCollectors = + { initModel + | activeTab = CollectorsTab + , eventsById = Dict.singleton "e1" event + } + + ( model, _ ) = + update (SelectEvent "e1") modelWithCollectors + in + Expect.equal CollectorsTab model.activeTab + , test "switches from SessionTab to HeadersTab for non-session event" <| + \_ -> + let + event = + makeNetworkEvent "e1" + + modelWithSession = + { initModel + | activeTab = SessionTab + , eventsById = Dict.singleton "e1" event + } + + ( model, _ ) = + update (SelectEvent "e1") modelWithSession + in + Expect.equal HeadersTab model.activeTab + ] + + +toggleTests : Test +toggleTests = + describe "Toggle actions" + [ test "ToggleRecording flips recording flag" <| + \_ -> + let + ( model, _ ) = + update ToggleRecording initModel + in + Expect.equal False model.recording + , test "ToggleExportMenu flips export menu" <| + \_ -> + let + ( model, _ ) = + update ToggleExportMenu initModel + in + Expect.equal True model.exportMenuOpen + , test "CloseExportMenu closes export menu" <| + \_ -> + let + ( model, _ ) = + update CloseExportMenu { initModel | exportMenuOpen = True } + in + Expect.equal False model.exportMenuOpen + , test "ToggleSummary flips summaryCollapsed" <| + \_ -> + let + ( model, _ ) = + update ToggleSummary initModel + in + Expect.equal True model.summaryCollapsed + , test "SwitchTab changes active tab" <| + \_ -> + let + ( model, _ ) = + update (SwitchTab CorsTab) initModel + in + Expect.equal CorsTab model.activeTab + ] + + +clearFlowTests : Test +clearFlowTests = + describe "ClearFlow" + [ test "resets all model state" <| + \_ -> + let + dirtyModel = + { initModel + | events = [ makeSdkEvent "e1" 100 ] + , selectedEventId = Just "e1" + , flowId = Just "flow-1" + , isPlaying = True + , exportMenuOpen = True + } + + ( model, _ ) = + update ClearFlow dirtyModel + in + Expect.all + [ \m -> Expect.equal [] m.events + , \m -> Expect.equal Nothing m.selectedEventId + , \m -> Expect.equal Nothing m.flowId + , \m -> Expect.equal False m.isPlaying + , \m -> Expect.equal False m.exportMenuOpen + , \m -> Expect.equal True m.recording + , \m -> Expect.equal Dict.empty m.eventsById + ] + model + ] + + +diagnosisTests : Test +diagnosisTests = + describe "DiagnosisReceived" + [ test "stores diagnosis result" <| + \_ -> + let + diag = + DiagnosisResult Healthy [] [] + + ( model, _ ) = + update (DiagnosisReceived diag) initModel + in + Expect.equal (Just diag) model.diagnosis + , test "auto-expands summary on error when recording and collapsed" <| + \_ -> + let + diag = + DiagnosisResult Error [] [] + + collapsedModel = + { initModel | summaryCollapsed = True, recording = True } + + ( model, _ ) = + update (DiagnosisReceived diag) collapsedModel + in + Expect.equal False model.summaryCollapsed + , test "does not auto-expand when not recording" <| + \_ -> + let + diag = + DiagnosisResult Error [] [] + + notRecording = + { initModel | summaryCollapsed = True, recording = False } + + ( model, _ ) = + update (DiagnosisReceived diag) notRecording + in + Expect.equal True model.summaryCollapsed + , test "does not auto-expand for healthy diagnosis" <| + \_ -> + let + diag = + DiagnosisResult Healthy [] [] + + collapsedModel = + { initModel | summaryCollapsed = True, recording = True } + + ( model, _ ) = + update (DiagnosisReceived diag) collapsedModel + in + Expect.equal True model.summaryCollapsed + ] + + +playbackTests : Test +playbackTests = + describe "Playback" + [ test "StartPlayback sets isPlaying and playbackIndex" <| + \_ -> + let + modelWithEvents = + { initModel | events = [ makeSdkEvent "s1" 100, makeSdkEvent "s2" 200 ] } + + ( model, _ ) = + update StartPlayback modelWithEvents + in + Expect.all + [ \m -> Expect.equal True m.isPlaying + , \m -> Expect.equal (Just 0) m.playbackIndex + , \m -> Expect.equal (Just "s1") m.selectedNodeId + ] + model + , test "StopPlayback stops playing" <| + \_ -> + let + ( model, _ ) = + update StopPlayback { initModel | isPlaying = True } + in + Expect.equal False model.isPlaying + , test "PlaybackTick advances to next node" <| + \_ -> + let + modelPlaying = + { initModel + | events = [ makeSdkEvent "s1" 100, makeSdkEvent "s2" 200 ] + , isPlaying = True + , playbackIndex = Just 0 + } + + ( model, _ ) = + update PlaybackTick modelPlaying + in + Expect.all + [ \m -> Expect.equal (Just 1) m.playbackIndex + , \m -> Expect.equal (Just "s2") m.selectedNodeId + ] + model + , test "PlaybackTick stops at end of nodes" <| + \_ -> + let + modelAtEnd = + { initModel + | events = [ makeSdkEvent "s1" 100 ] + , isPlaying = True + , playbackIndex = Just 0 + } + + ( model, _ ) = + update PlaybackTick modelAtEnd + in + Expect.all + [ \m -> Expect.equal False m.isPlaying + , \m -> Expect.equal Nothing m.playbackIndex + ] + model + , test "PlaybackTick is no-op when not playing" <| + \_ -> + let + ( model, _ ) = + update PlaybackTick initModel + in + Expect.equal initModel model + , test "ResetPlayback clears playback state" <| + \_ -> + let + playing = + { initModel | isPlaying = True, playbackIndex = Just 2, selectedNodeId = Just "s3" } + + ( model, _ ) = + update ResetPlayback playing + in + Expect.all + [ \m -> Expect.equal False m.isPlaying + , \m -> Expect.equal Nothing m.playbackIndex + , \m -> Expect.equal Nothing m.selectedNodeId + ] + model + ] + + +snapshotTests : Test +snapshotTests = + describe "Snapshots" + [ test "SnapshotsReceived stores snapshots" <| + \_ -> + let + snaps = + [ SnapshotMeta "s1" "2026-01-01" (Just "f1") 5 ] + + ( model, _ ) = + update (SnapshotsReceived snaps) initModel + in + Expect.equal snaps model.snapshots + , test "DeleteSnapshot removes by id" <| + \_ -> + let + snaps = + [ SnapshotMeta "s1" "2026-01-01" Nothing 3 + , SnapshotMeta "s2" "2026-01-02" Nothing 5 + ] + + modelWithSnaps = + { initModel | snapshots = snaps } + + ( model, _ ) = + update (DeleteSnapshot "s1") modelWithSnaps + in + Expect.equal [ SnapshotMeta "s2" "2026-01-02" Nothing 5 ] model.snapshots + , test "ToggleSnapshotMenu flips snapshot menu" <| + \_ -> + let + ( model, _ ) = + update ToggleSnapshotMenu initModel + in + Expect.equal True model.snapshotMenuOpen + , test "CloseSnapshotMenu closes snapshot menu" <| + \_ -> + let + ( model, _ ) = + update CloseSnapshotMenu { initModel | snapshotMenuOpen = True } + in + Expect.equal False model.snapshotMenuOpen + ] + + +viewModeTests : Test +viewModeTests = + describe "SwitchViewMode" + [ test "switches to FlowMode and resets playback" <| + \_ -> + let + ( model, _ ) = + update (SwitchViewMode FlowMode) { initModel | isPlaying = True, playbackIndex = Just 2 } + in + Expect.all + [ \m -> Expect.equal FlowMode m.viewMode + , \m -> Expect.equal False m.isPlaying + , \m -> Expect.equal Nothing m.playbackIndex + , \m -> Expect.equal Nothing m.selectedNodeId + ] + model + ] + + +importTests : Test +importTests = + describe "Import" + [ test "ImportFlow opens paste dialog and clears error" <| + \_ -> + let + ( model, _ ) = + update ImportFlow { initModel | lastDecodeError = Just "old error" } + in + Expect.all + [ \m -> Expect.equal True m.importPasteOpen + , \m -> Expect.equal "" m.importPasteText + , \m -> Expect.equal Nothing m.lastDecodeError + ] + model + , test "UpdateImportPaste stores text" <| + \_ -> + let + ( model, _ ) = + update (UpdateImportPaste "some json") { initModel | importPasteOpen = True } + in + Expect.equal "some json" model.importPasteText + , test "SubmitImportPaste closes dialog and clears text" <| + \_ -> + let + ( model, _ ) = + update SubmitImportPaste { initModel | importPasteOpen = True, importPasteText = "data" } + in + Expect.all + [ \m -> Expect.equal False m.importPasteOpen + , \m -> Expect.equal "" m.importPasteText + ] + model + , test "CancelImportPaste closes dialog and clears text" <| + \_ -> + let + ( model, _ ) = + update CancelImportPaste { initModel | importPasteOpen = True, importPasteText = "data" } + in + Expect.all + [ \m -> Expect.equal False m.importPasteOpen + , \m -> Expect.equal "" m.importPasteText + ] + model + , test "ImportMetaReceived stores meta and stops recording" <| + \_ -> + let + meta = + ImportMeta (Just "flow-1") "2026-01-01" True + + ( model, _ ) = + update (ImportMetaReceived meta) initModel + in + Expect.all + [ \m -> Expect.equal (Just meta) m.importedFlow + , \m -> Expect.equal False m.recording + ] + model + , test "ImportError stores error message" <| + \_ -> + let + ( model, _ ) = + update (ImportError "Bad JSON") initModel + in + Expect.equal (Just "Bad JSON") model.lastDecodeError + , test "DecodeError stores error message" <| + \_ -> + let + ( model, _ ) = + update (DecodeError "Parse failed") initModel + in + Expect.equal (Just "Parse failed") model.lastDecodeError + ] + + +flowNodeTests : Test +flowNodeTests = + describe "Flow node interactions" + [ test "SelectFlowNode sets selectedNodeId and clears sub-rows" <| + \_ -> + let + ( model, _ ) = + update (SelectFlowNode "node-1") { initModel | expandedSubRows = Set.fromList [ "row-1" ] } + in + Expect.all + [ \m -> Expect.equal (Just "node-1") m.selectedNodeId + , \m -> Expect.equal Set.empty m.expandedSubRows + ] + model + , test "ToggleSubRow adds key to expanded set" <| + \_ -> + let + ( model, _ ) = + update (ToggleSubRow "row-1") initModel + in + Expect.equal True (Set.member "row-1" model.expandedSubRows) + , test "ToggleSubRow removes key if already expanded" <| + \_ -> + let + ( model, _ ) = + update (ToggleSubRow "row-1") { initModel | expandedSubRows = Set.singleton "row-1" } + in + Expect.equal False (Set.member "row-1" model.expandedSubRows) + , test "HoverNode sets hoveredNodeId" <| + \_ -> + let + ( model, _ ) = + update (HoverNode (Just "n1")) initModel + in + Expect.equal (Just "n1") model.hoveredNodeId + , test "HoverNode clears hoveredNodeId" <| + \_ -> + let + ( model, _ ) = + update (HoverNode Nothing) { initModel | hoveredNodeId = Just "n1" } + in + Expect.equal Nothing model.hoveredNodeId + , test "CopyToClipboard is a no-op on model" <| + \_ -> + let + ( model, _ ) = + update (CopyToClipboard "some text") initModel + in + Expect.equal initModel model + , test "SelectNode sets selectedEventId and switches to SdkStateTab" <| + \_ -> + let + ( model, _ ) = + update (SelectNode "e1") initModel + in + Expect.all + [ \m -> Expect.equal (Just "e1") m.selectedEventId + , \m -> Expect.equal SdkStateTab m.activeTab + ] + model + , test "ExportJson closes export menu" <| + \_ -> + let + ( model, _ ) = + update ExportJson { initModel | exportMenuOpen = True } + in + Expect.equal False model.exportMenuOpen + , test "ExportMarkdown closes export menu" <| + \_ -> + let + ( model, _ ) = + update ExportMarkdown { initModel | exportMenuOpen = True } + in + Expect.equal False model.exportMenuOpen + , test "SaveSnapshot is a no-op on model" <| + \_ -> + let + ( model, _ ) = + update SaveSnapshot initModel + in + Expect.equal initModel model + , test "LoadSnapshot closes snapshot menu" <| + \_ -> + let + ( model, _ ) = + update (LoadSnapshot "snap-1") { initModel | snapshotMenuOpen = True } + in + Expect.equal False model.snapshotMenuOpen + ] + + +selectEventTabTests : Test +selectEventTabTests = + describe "SelectEvent tab switching" + [ test "switches from ConfigTab to HeadersTab for non-config event" <| + \_ -> + let + event = + makeNetworkEvent "e1" + + modelWithConfig = + { initModel + | activeTab = ConfigTab + , eventsById = Dict.singleton "e1" event + } + + ( model, _ ) = + update (SelectEvent "e1") modelWithConfig + in + Expect.equal HeadersTab model.activeTab + , test "keeps ConfigTab for sdk:config event" <| + \_ -> + let + configEvent = + { id = "e1" + , timestamp = 100 + , kind = SdkConfig + , source = SdkSource + , flowId = Nothing + , isCors = False + , isError = False + , isAuthRelated = True + , causedBy = Nothing + , data = Config Nothing + } + + modelWithConfig = + { initModel + | activeTab = ConfigTab + , eventsById = Dict.singleton "e1" configEvent + } + + ( model, _ ) = + update (SelectEvent "e1") modelWithConfig + in + Expect.equal ConfigTab model.activeTab + , test "keeps HeadersTab for any event" <| + \_ -> + let + event = + makeNetworkEvent "e1" + + modelWithHeaders = + { initModel + | activeTab = HeadersTab + , eventsById = Dict.singleton "e1" event + } + + ( model, _ ) = + update (SelectEvent "e1") modelWithHeaders + in + Expect.equal HeadersTab model.activeTab + ] + + +startPlaybackEdgeTests : Test +startPlaybackEdgeTests = + describe "StartPlayback edge cases" + [ test "resets to 0 when at end of nodes" <| + \_ -> + let + modelAtEnd = + { initModel + | events = [ makeSdkEvent "s1" 100, makeSdkEvent "s2" 200 ] + , playbackIndex = Just 1 + } + + ( model, _ ) = + update StartPlayback modelAtEnd + in + Expect.all + [ \m -> Expect.equal (Just 0) m.playbackIndex + , \m -> Expect.equal (Just "s1") m.selectedNodeId + ] + model + , test "resumes from current index when not at end" <| + \_ -> + let + modelMidway = + { initModel + | events = [ makeSdkEvent "s1" 100, makeSdkEvent "s2" 200, makeSdkEvent "s3" 300 ] + , playbackIndex = Just 1 + } + + ( model, _ ) = + update StartPlayback modelMidway + in + Expect.all + [ \m -> Expect.equal (Just 1) m.playbackIndex + , \m -> Expect.equal (Just "s2") m.selectedNodeId + ] + model + ] + + +learnSelectTests : Test +learnSelectTests = + describe "Learn select interactions" + [ test "LearnSelectNode sets learnSelectedNodeId and clears expandedCard and cardPositions" <| + \_ -> + let + canvas = + initModel.learnCanvas + + modelWithCard = + { initModel + | learnCanvas = + { canvas + | expandedCard = Just BrowserCard + , cardPositions = [ ( "browser", { x = 10, y = 20 } ) ] + } + } + + ( model, _ ) = + update (LearnSelectNode "node-1") modelWithCard + in + Expect.all + [ \m -> Expect.equal (Just "node-1") m.learnCanvas.learnSelectedNodeId + , \m -> Expect.equal Nothing m.learnCanvas.expandedCard + , \m -> Expect.equal [] m.learnCanvas.cardPositions + ] + model + , test "LearnExpandCard sets expandedCard" <| + \_ -> + let + ( model, _ ) = + update (LearnExpandCard BrowserCard) initModel + in + Expect.equal (Just BrowserCard) model.learnCanvas.expandedCard + , test "LearnExpandCard toggles off when same card" <| + \_ -> + let + canvas = + initModel.learnCanvas + + modelWithExpanded = + { initModel | learnCanvas = { canvas | expandedCard = Just BrowserCard } } + + ( model, _ ) = + update (LearnExpandCard BrowserCard) modelWithExpanded + in + Expect.equal Nothing model.learnCanvas.expandedCard + , test "LearnExpandCard switches to different card" <| + \_ -> + let + canvas = + initModel.learnCanvas + + modelWithExpanded = + { initModel | learnCanvas = { canvas | expandedCard = Just BrowserCard } } + + ( model, _ ) = + update (LearnExpandCard ServerCard) modelWithExpanded + in + Expect.equal (Just ServerCard) model.learnCanvas.expandedCard + , test "LearnCollapseCard clears expandedCard" <| + \_ -> + let + canvas = + initModel.learnCanvas + + modelWithExpanded = + { initModel | learnCanvas = { canvas | expandedCard = Just SdkCard } } + + ( model, _ ) = + update LearnCollapseCard modelWithExpanded + in + Expect.equal Nothing model.learnCanvas.expandedCard + ] + + +learnDragTests : Test +learnDragTests = + describe "Learn drag and pan interactions" + [ test "LearnStartDrag sets dragTarget and dragStart" <| + \_ -> + let + ( model, _ ) = + update (LearnStartDrag BrowserCard 100 200) initModel + in + Expect.all + [ \m -> Expect.equal (Just BrowserCard) m.learnCanvas.dragTarget + , \m -> Expect.equal (Just { x = 100, y = 200 }) m.learnCanvas.dragStart + ] + model + , test "LearnEndDrag clears drag and pan state" <| + \_ -> + let + canvas = + initModel.learnCanvas + + draggingModel = + { initModel + | learnCanvas = + { canvas + | dragTarget = Just BrowserCard + , dragStart = Just { x = 100, y = 200 } + , isPanning = True + , panStart = Just { x = 50, y = 60 } + } + } + + ( model, _ ) = + update LearnEndDrag draggingModel + in + Expect.all + [ \m -> Expect.equal Nothing m.learnCanvas.dragTarget + , \m -> Expect.equal Nothing m.learnCanvas.dragStart + , \m -> Expect.equal False m.learnCanvas.isPanning + , \m -> Expect.equal Nothing m.learnCanvas.panStart + ] + model + , test "LearnStartPan sets isPanning and panStart" <| + \_ -> + let + ( model, _ ) = + update (LearnStartPan 300 400) initModel + in + Expect.all + [ \m -> Expect.equal True m.learnCanvas.isPanning + , \m -> Expect.equal (Just { x = 300, y = 400 }) m.learnCanvas.panStart + ] + model + , test "LearnEndPan clears isPanning and panStart" <| + \_ -> + let + canvas = + initModel.learnCanvas + + panningModel = + { initModel + | learnCanvas = + { canvas + | isPanning = True + , panStart = Just { x = 300, y = 400 } + } + } + + ( model, _ ) = + update LearnEndPan panningModel + in + Expect.all + [ \m -> Expect.equal False m.learnCanvas.isPanning + , \m -> Expect.equal Nothing m.learnCanvas.panStart + ] + model + ] + + +learnZoomTests : Test +learnZoomTests = + describe "Learn zoom interactions" + [ test "LearnZoom adjusts zoom level" <| + \_ -> + let + ( model, _ ) = + update (LearnZoom 100) initModel + in + Expect.within (Expect.Absolute 0.001) 1.1 model.learnCanvas.zoom + , test "LearnZoom clamps to minimum 0.5" <| + \_ -> + let + ( model, _ ) = + update (LearnZoom -10000) initModel + in + Expect.within (Expect.Absolute 0.001) 0.5 model.learnCanvas.zoom + , test "LearnZoom clamps to maximum 3.0" <| + \_ -> + let + ( model, _ ) = + update (LearnZoom 10000) initModel + in + Expect.within (Expect.Absolute 0.001) 3.0 model.learnCanvas.zoom + ] diff --git a/packages/devtools-extension/tsconfig.json b/packages/devtools-extension/tsconfig.json new file mode 100644 index 0000000000..329ef5038f --- /dev/null +++ b/packages/devtools-extension/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { "path": "./tsconfig.lib.json" }, + { "path": "./tsconfig.spec.json" } + ], + "nx": { "addTypecheckTarget": false } +} diff --git a/packages/devtools-extension/tsconfig.lib.json b/packages/devtools-extension/tsconfig.lib.json new file mode 100644 index 0000000000..849b5ce40b --- /dev/null +++ b/packages/devtools-extension/tsconfig.lib.json @@ -0,0 +1,21 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "moduleResolution": "nodenext", + "module": "NodeNext", + "target": "ES2022", + "outDir": "./dist", + "resolveJsonModule": true, + "moduleDetection": "force", + "isolatedModules": true, + "strict": true, + "noImplicitOverride": true, + "skipLibCheck": true, + "sourceMap": true, + "lib": ["es2022", "dom", "dom.iterable"], + "types": ["chrome"] + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"], + "references": [{ "path": "../devtools-types/tsconfig.lib.json" }] +} diff --git a/packages/devtools-extension/tsconfig.spec.json b/packages/devtools-extension/tsconfig.spec.json new file mode 100644 index 0000000000..4f89b32da3 --- /dev/null +++ b/packages/devtools-extension/tsconfig.spec.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc/vitest", + "types": [ + "vitest/globals", + "vitest/importMeta", + "vite/client", + "node", + "vitest" + ], + "module": "NodeNext", + "moduleResolution": "nodenext", + "strict": true, + "noImplicitOverride": true, + "lib": ["es2022", "dom", "dom.iterable"] + }, + "include": ["vite.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts"], + "references": [{ "path": "./tsconfig.lib.json" }] +} diff --git a/packages/devtools-extension/vite.config.ts b/packages/devtools-extension/vite.config.ts new file mode 100644 index 0000000000..c9b53d8990 --- /dev/null +++ b/packages/devtools-extension/vite.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite'; + +export default defineConfig(() => ({ + root: __dirname, + cacheDir: '../../node_modules/.vite/packages/devtools-extension', + test: { + watch: false, + globals: true, + environment: 'jsdom', + include: ['src/**/*.{test,spec}.{js,ts}'], + reporters: ['default'], + }, +})); diff --git a/packages/devtools-types/README.md b/packages/devtools-types/README.md new file mode 100644 index 0000000000..ec583f661a --- /dev/null +++ b/packages/devtools-types/README.md @@ -0,0 +1,237 @@ +# @forgerock/devtools-types + +Shared [Effect Schema](https://effect.website/docs/schema/introduction/) definitions and TypeScript types for Ping DevTools. This package is the single source of truth for the shape of every event that flows between the SDK bridges and the DevTools extension. + +## Contents + +- [Installation](#installation) +- [AuthEvent](#authevent) +- [Data variants](#data-variants) + - [NetworkData](#networkdata) + - [SdkData — DaVinci](#sdkdata--davinci) + - [JourneyData — AM trees](#journeydata--am-trees) + - [OidcData — OIDC/OAuth](#oidcdata--oidcoauth) + - [SessionData](#sessiondata) + - [SdkConfigData](#sdkconfigdata) + - [DomData](#domdata) +- [Runtime validation](#runtime-validation) +- [Exported symbols](#exported-symbols) + +--- + +## Installation + +```bash +pnpm add @forgerock/devtools-types +``` + +`effect` is a peer dependency — add it to your project if it isn't already present. + +--- + +## AuthEvent + +Every event, regardless of source, conforms to the `AuthEvent` envelope: + +```ts +import type { AuthEvent } from '@forgerock/devtools-types'; + +// Shape +{ + id: string; // crypto.randomUUID() + timestamp: number; // performance.now() + type: AuthEventType; // e.g. 'sdk:node-change', 'network:response' + source: 'network' | 'sdk' | 'dom' | 'session'; + flowId: string | null; + causedBy: string | null; + data: NetworkData | SdkData | JourneyData | OidcData | SessionData | SdkConfigData | DomData; + flags: { + isCors: boolean; + isError: boolean; + isAuthRelated: boolean; + } +} +``` + +### Event types + +| `type` | `source` | Description | +| ------------------- | --------- | ------------------------------------- | +| `network:request` | `network` | Outbound HTTP request captured by HAR | +| `network:response` | `network` | HTTP response with status + headers | +| `network:cors-flag` | `network` | Detected CORS policy violation | +| `sdk:node-change` | `sdk` | DaVinci node status transition | +| `sdk:config` | `sdk` | SDK client configuration snapshot | +| `sdk:journey-step` | `sdk` | AM authentication tree step result | +| `sdk:oidc-state` | `sdk` | OIDC/OAuth endpoint outcome | +| `dom:form-submit` | `dom` | Form submission detected in the page | +| `dom:redirect` | `dom` | Client-side redirect detected | +| `session:cookie` | `session` | `document.cookie` changed | +| `session:storage` | `session` | `localStorage` key changed | + +--- + +## Data variants + +The `data` field is a discriminated union — use `_tag` to narrow it. + +### NetworkData + +```ts +{ + _tag: 'network'; + url: string; + method: string; + status: number; + requestHeaders: Record; + responseHeaders: Record; + duration: number; + corsFlag?: CorsFlag; + requestBody?: unknown; + responseBody?: unknown; +} +``` + +`CorsFlag` carries the reason (`'status-zero' | 'missing-allow-origin' | 'credentials-mismatch' | 'wildcard-with-credentials' | 'preflight-failed'`) plus optional preflight details. + +### SdkData — DaVinci + +Emitted on every DaVinci node status transition by `attachDevToolsBridge`. + +```ts +{ + _tag: 'sdk'; + nodeStatus: string; // 'next' | 'error' | 'success' | ... + previousStatus?: string; + interactionId?: string; + interactionToken?: string; + nodeId?: string; + requestId?: string; // DaVinci cache key (maps to raw HTTP response) + nodeName?: string; + nodeDescription?: string; + eventName?: string; + httpStatus?: number; + collectors?: unknown[]; // Form fields / UI descriptors + error?: SdkError; + authorization?: SdkAuthorization; + session?: string; + responseBody?: unknown; // Full DaVinci server response (from cache) +} +``` + +### JourneyData — AM trees + +Emitted by `attachJourneyBridge` for each RTK Query mutation that settles. + +```ts +{ + _tag: 'journey'; + stepType: 'Step' | 'LoginSuccess' | 'LoginFailure'; + callbacks?: unknown[]; // Full AM callback objects (with input/output arrays) + authId?: string; // Present on Step + tokenId?: string; // Present on LoginSuccess (session token) + successUrl?: string; // Present on LoginSuccess + realm?: string; + stage?: string; + header?: string; + description?: string; + errorCode?: number; // Present on LoginFailure + errorMessage?: string; + errorReason?: string; +} +``` + +### OidcData — OIDC/OAuth + +Emitted by `attachOidcBridge` for each RTK Query mutation that settles. + +```ts +{ + _tag: 'oidc'; + phase: 'authorize' | 'exchange' | 'revoke' | 'userinfo' | 'logout'; + status: 'success' | 'error'; + clientId?: string; + errorCode?: string; // OAuth error code (e.g. 'invalid_grant') + errorMessage?: string; // Human-readable error description +} +``` + +### SessionData + +```ts +{ + _tag: 'session'; + key: string; // localStorage key or 'document.cookie' + before?: string; + after?: string; +} +``` + +### SdkConfigData + +```ts +{ + _tag: 'sdk-config'; + config: unknown; // The raw config object passed to attachDevToolsBridge +} +``` + +### DomData + +```ts +{ + _tag: 'dom'; + element?: string; // CSS selector of the form element + url?: string; // Redirect target URL +} +``` + +--- + +## Runtime validation + +All schemas are [Effect Schema](https://effect.website/docs/schema/introduction/) definitions — use them directly for decoding untrusted data at message boundaries. + +```ts +import { Schema } from 'effect'; +import { AuthEventSchema } from '@forgerock/devtools-types'; + +const decode = Schema.decodeUnknownEither(AuthEventSchema); + +// In a service worker or message handler: +const result = decode(rawMessage); +if (Either.isLeft(result)) { + // validation failed — result.left carries detailed parse errors +} else { + const event = result.right; // AuthEvent, fully typed +} +``` + +--- + +## Exported symbols + +| Export | Kind | Description | +| ------------------------ | ------ | ------------------------------------ | +| `AuthEventSchema` | Schema | Full envelope validator | +| `AuthEventTypeSchema` | Schema | Union of all event type literals | +| `AuthEventFlagsSchema` | Schema | `{ isCors, isError, isAuthRelated }` | +| `NetworkDataSchema` | Schema | Network event data | +| `SdkDataSchema` | Schema | DaVinci SDK node data | +| `SdkConfigDataSchema` | Schema | SDK config snapshot | +| `JourneyDataSchema` | Schema | AM journey step data | +| `OidcDataSchema` | Schema | OIDC/OAuth phase data | +| `SessionDataSchema` | Schema | Cookie / localStorage diff | +| `DomDataSchema` | Schema | DOM event data | +| `SdkErrorSchema` | Schema | Error object sub-schema | +| `SdkAuthorizationSchema` | Schema | Authorization code/state sub-schema | +| `AuthEvent` | Type | Inferred from `AuthEventSchema` | +| `AuthEventType` | Type | Inferred from `AuthEventTypeSchema` | +| `AuthEventFlags` | Type | Inferred from `AuthEventFlagsSchema` | +| `NetworkData` | Type | Inferred from `NetworkDataSchema` | +| `SdkData` | Type | Inferred from `SdkDataSchema` | +| `JourneyData` | Type | Inferred from `JourneyDataSchema` | +| `OidcData` | Type | Inferred from `OidcDataSchema` | +| `SessionData` | Type | Inferred from `SessionDataSchema` | +| `SdkConfigData` | Type | Inferred from `SdkConfigDataSchema` | +| `DomData` | Type | Inferred from `DomDataSchema` | diff --git a/packages/devtools-types/eslint.config.mjs b/packages/devtools-types/eslint.config.mjs new file mode 100644 index 0000000000..cec2c4bf81 --- /dev/null +++ b/packages/devtools-types/eslint.config.mjs @@ -0,0 +1,3 @@ +import baseConfig from '../../eslint.config.mjs'; + +export default [{ ignores: ['**/dist'] }, ...baseConfig, { files: ['**/*.ts'], rules: {} }]; diff --git a/packages/devtools-types/package.json b/packages/devtools-types/package.json new file mode 100644 index 0000000000..a45f6a30b7 --- /dev/null +++ b/packages/devtools-types/package.json @@ -0,0 +1,38 @@ +{ + "name": "@forgerock/devtools-types", + "version": "2.0.0", + "private": true, + "description": "Shared AuthEvent schema and types for Ping DevTools", + "repository": { + "type": "git", + "url": "git+https://github.com/ForgeRock/ping-javascript-sdk.git", + "directory": "packages/devtools-types" + }, + "license": "MIT", + "author": "ForgeRock", + "sideEffects": false, + "type": "module", + "exports": { + ".": { + "types": "./dist/src/index.d.ts", + "import": "./dist/src/index.js", + "default": "./dist/src/index.js" + }, + "./package.json": "./package.json" + }, + "main": "./dist/src/index.js", + "module": "./dist/src/index.js", + "types": "./dist/src/index.d.ts", + "files": ["dist"], + "scripts": { + "build": "pnpm nx nxBuild", + "lint": "pnpm nx nxLint", + "test": "pnpm nx nxTest" + }, + "dependencies": { + "effect": "catalog:effect" + }, + "nx": { + "tags": ["scope:devtools-types"] + } +} diff --git a/packages/devtools-types/src/index.ts b/packages/devtools-types/src/index.ts new file mode 100644 index 0000000000..7f1dd8026b --- /dev/null +++ b/packages/devtools-types/src/index.ts @@ -0,0 +1,6 @@ +export * from './lib/auth-event.schema.js'; +export * from './lib/auth-event.types.js'; +export * from './lib/flow-export.schema.js'; +export * from './lib/flow-state.schema.js'; +export * from './lib/flow-state.types.js'; +export * from './lib/cors-flag.types.js'; diff --git a/packages/devtools-types/src/lib/auth-event.schema.test.ts b/packages/devtools-types/src/lib/auth-event.schema.test.ts new file mode 100644 index 0000000000..caf725faf3 --- /dev/null +++ b/packages/devtools-types/src/lib/auth-event.schema.test.ts @@ -0,0 +1,492 @@ +import { Schema } from 'effect'; +import { describe, expect, it } from 'vitest'; +import { AuthEventSchema } from './auth-event.schema.js'; + +const baseEvent = { + id: 'evt-001', + timestamp: 1700000000000, + source: 'network' as const, + flowId: 'flow-abc', + causedBy: null, + flags: { + isCors: false, + isError: false, + isAuthRelated: true, + }, +}; + +describe('AuthEventSchema', () => { + it('decodes a valid network event', () => { + const input = { + ...baseEvent, + type: 'network:response', + data: { + _tag: 'network', + url: 'https://auth.example.com/token', + method: 'POST', + status: 200, + requestHeaders: { 'content-type': 'application/json' }, + responseHeaders: { 'x-request-id': 'abc123' }, + duration: 123, + }, + }; + + const result = Schema.decodeUnknownSync(AuthEventSchema)(input); + + expect(result.id).toBe('evt-001'); + expect(result.type).toBe('network:response'); + expect(result.data._tag).toBe('network'); + }); + + it('rejects an event with an unknown type field', () => { + const input = { + ...baseEvent, + type: 'unknown:event-type', + data: { + _tag: 'network', + url: 'https://auth.example.com/token', + method: 'GET', + status: 200, + requestHeaders: {}, + responseHeaders: {}, + duration: 50, + }, + }; + + expect(() => Schema.decodeUnknownSync(AuthEventSchema)(input)).toThrow( + /unknown:event-type|type/i, + ); + }); + + it('rejects an event with missing required id field', () => { + const input = { + timestamp: 1700000000000, + type: 'network:request', + source: 'network', + flowId: null, + flags: { isCors: false, isError: false, isAuthRelated: false }, + data: { + _tag: 'network', + url: 'https://auth.example.com/authorize', + method: 'GET', + status: 200, + requestHeaders: {}, + responseHeaders: {}, + duration: 10, + }, + }; + + expect(() => Schema.decodeUnknownSync(AuthEventSchema)(input)).toThrow(/id/i); + }); + + it('accepts null flowId', () => { + const input = { + ...baseEvent, + type: 'network:request', + flowId: null, + data: { + _tag: 'network', + url: 'https://auth.example.com/authorize', + method: 'GET', + status: 302, + requestHeaders: {}, + responseHeaders: {}, + duration: 10, + }, + }; + + const result = Schema.decodeUnknownSync(AuthEventSchema)(input); + + expect(result.flowId).toBeNull(); + }); + + it('decodes a valid sdk event', () => { + const input = { + id: 'evt-002', + timestamp: 1700000001000, + type: 'sdk:node-change', + source: 'sdk', + flowId: 'flow-xyz', + causedBy: null, + flags: { isCors: false, isError: false, isAuthRelated: true }, + data: { + _tag: 'sdk', + nodeStatus: 'next', + interactionId: 'interaction-123', + }, + }; + + const result = Schema.decodeUnknownSync(AuthEventSchema)(input); + + expect(result.id).toBe('evt-002'); + expect(result.type).toBe('sdk:node-change'); + expect(result.data._tag).toBe('sdk'); + }); + + it('decodes sdk:node-change with all optional sdk fields', () => { + const input = { + ...baseEvent, + type: 'sdk:node-change', + source: 'sdk', + data: { + _tag: 'sdk', + nodeStatus: 'continue', + previousStatus: 'start', + interactionId: 'int-1', + interactionToken: 'tok-1', + nodeId: 'node-1', + requestId: 'req-1', + nodeName: 'Username', + nodeDescription: 'Enter your name', + eventName: 'click', + httpStatus: 200, + collectors: [{ type: 'TextCollector' }], + error: { code: 'E001', message: 'Failed', type: 'auth' }, + authorization: { code: 'auth-code', state: 'xyz' }, + session: 'sess-abc', + responseBody: { key: 'value' }, + }, + }; + + const result = Schema.decodeUnknownSync(AuthEventSchema)(input); + expect(result.data._tag).toBe('sdk'); + if (result.data._tag === 'sdk') { + expect(result.data.nodeName).toBe('Username'); + expect(result.data.error?.code).toBe('E001'); + expect(result.data.authorization?.code).toBe('auth-code'); + expect(result.data.collectors).toHaveLength(1); + } + }); + + it('decodes sdk:config event', () => { + const input = { + ...baseEvent, + type: 'sdk:config', + source: 'sdk', + data: { + _tag: 'sdk-config', + config: { serverUrl: 'https://auth.example.com', clientId: 'my-app' }, + }, + }; + + const result = Schema.decodeUnknownSync(AuthEventSchema)(input); + expect(result.data._tag).toBe('sdk-config'); + }); + + it('decodes sdk:journey-step event', () => { + const input = { + ...baseEvent, + type: 'sdk:journey-step', + source: 'sdk', + data: { + _tag: 'journey', + stepType: 'Step', + authId: 'auth-123', + stage: 'UsernamePassword', + header: 'Sign In', + description: 'Enter credentials', + callbacks: [{ type: 'NameCallback' }], + realm: '/alpha', + tokenId: 'tok-1', + }, + }; + + const result = Schema.decodeUnknownSync(AuthEventSchema)(input); + expect(result.data._tag).toBe('journey'); + if (result.data._tag === 'journey') { + expect(result.data.stepType).toBe('Step'); + expect(result.data.authId).toBe('auth-123'); + expect(result.data.callbacks).toHaveLength(1); + } + }); + + it('decodes sdk:journey-step LoginFailure with error fields', () => { + const input = { + ...baseEvent, + type: 'sdk:journey-step', + source: 'sdk', + data: { + _tag: 'journey', + stepType: 'LoginFailure', + errorCode: 401, + errorMessage: 'Authentication Failed', + errorReason: 'InvalidCredentials', + }, + }; + + const result = Schema.decodeUnknownSync(AuthEventSchema)(input); + if (result.data._tag === 'journey') { + expect(result.data.stepType).toBe('LoginFailure'); + expect(result.data.errorCode).toBe(401); + expect(result.data.errorMessage).toBe('Authentication Failed'); + } + }); + + it('rejects journey event with invalid stepType', () => { + const input = { + ...baseEvent, + type: 'sdk:journey-step', + source: 'sdk', + data: { + _tag: 'journey', + stepType: 'InvalidStep', + }, + }; + + expect(() => Schema.decodeUnknownSync(AuthEventSchema)(input)).toThrow(); + }); + + it('decodes sdk:oidc-state event', () => { + const input = { + ...baseEvent, + type: 'sdk:oidc-state', + source: 'sdk', + data: { + _tag: 'oidc', + phase: 'authorize', + status: 'success', + clientId: 'my-app', + }, + }; + + const result = Schema.decodeUnknownSync(AuthEventSchema)(input); + expect(result.data._tag).toBe('oidc'); + if (result.data._tag === 'oidc') { + expect(result.data.phase).toBe('authorize'); + expect(result.data.status).toBe('success'); + expect(result.data.clientId).toBe('my-app'); + } + }); + + it('decodes oidc error event with errorCode and errorMessage', () => { + const input = { + ...baseEvent, + type: 'sdk:oidc-state', + source: 'sdk', + data: { + _tag: 'oidc', + phase: 'exchange', + status: 'error', + errorCode: 'invalid_grant', + errorMessage: 'Token expired', + }, + }; + + const result = Schema.decodeUnknownSync(AuthEventSchema)(input); + if (result.data._tag === 'oidc') { + expect(result.data.errorCode).toBe('invalid_grant'); + expect(result.data.errorMessage).toBe('Token expired'); + } + }); + + it('rejects oidc event with invalid phase', () => { + const input = { + ...baseEvent, + type: 'sdk:oidc-state', + source: 'sdk', + data: { + _tag: 'oidc', + phase: 'unknown-phase', + status: 'success', + }, + }; + + expect(() => Schema.decodeUnknownSync(AuthEventSchema)(input)).toThrow(); + }); + + it('rejects oidc event with invalid status', () => { + const input = { + ...baseEvent, + type: 'sdk:oidc-state', + source: 'sdk', + data: { + _tag: 'oidc', + phase: 'authorize', + status: 'pending', + }, + }; + + expect(() => Schema.decodeUnknownSync(AuthEventSchema)(input)).toThrow(); + }); + + it('decodes session:storage event', () => { + const input = { + ...baseEvent, + type: 'session:storage', + source: 'session', + data: { + _tag: 'session', + key: 'am-auth-session', + before: 'old-value', + after: 'new-value', + }, + }; + + const result = Schema.decodeUnknownSync(AuthEventSchema)(input); + expect(result.data._tag).toBe('session'); + if (result.data._tag === 'session') { + expect(result.data.key).toBe('am-auth-session'); + expect(result.data.before).toBe('old-value'); + expect(result.data.after).toBe('new-value'); + } + }); + + it('decodes session event with optional before/after', () => { + const input = { + ...baseEvent, + type: 'session:storage', + source: 'session', + data: { + _tag: 'session', + key: 'token', + }, + }; + + const result = Schema.decodeUnknownSync(AuthEventSchema)(input); + if (result.data._tag === 'session') { + expect(result.data.before).toBeUndefined(); + expect(result.data.after).toBeUndefined(); + } + }); + + it('decodes dom:form-submit event', () => { + const input = { + ...baseEvent, + type: 'dom:form-submit', + source: 'dom', + data: { + _tag: 'dom', + element: 'form#login', + url: 'https://app.example.com/login', + }, + }; + + const result = Schema.decodeUnknownSync(AuthEventSchema)(input); + expect(result.data._tag).toBe('dom'); + if (result.data._tag === 'dom') { + expect(result.data.element).toBe('form#login'); + expect(result.data.url).toBe('https://app.example.com/login'); + } + }); + + it('decodes dom event with all optional fields omitted', () => { + const input = { + ...baseEvent, + type: 'dom:redirect', + source: 'dom', + data: { _tag: 'dom' }, + }; + + const result = Schema.decodeUnknownSync(AuthEventSchema)(input); + expect(result.data._tag).toBe('dom'); + }); + + it('decodes network event with corsFlag', () => { + const input = { + ...baseEvent, + type: 'network:cors-flag', + data: { + _tag: 'network', + url: 'https://auth.example.com/token', + method: 'POST', + status: 0, + requestHeaders: { origin: 'https://app.example.com' }, + responseHeaders: {}, + duration: 0, + corsFlag: { + url: 'https://auth.example.com/token', + reason: 'status-zero', + method: 'POST', + }, + }, + flags: { isCors: true, isError: true, isAuthRelated: true }, + }; + + const result = Schema.decodeUnknownSync(AuthEventSchema)(input); + if (result.data._tag === 'network') { + expect(result.data.corsFlag?.reason).toBe('status-zero'); + } + }); + + it('decodes network event with request and response bodies', () => { + const input = { + ...baseEvent, + type: 'network:response', + data: { + _tag: 'network', + url: 'https://auth.example.com/token', + method: 'POST', + status: 200, + requestHeaders: {}, + responseHeaders: {}, + duration: 100, + requestBody: { grant_type: 'authorization_code' }, + responseBody: { access_token: 'abc', token_type: 'Bearer' }, + }, + }; + + const result = Schema.decodeUnknownSync(AuthEventSchema)(input); + if (result.data._tag === 'network') { + expect(result.data.requestBody).toEqual({ grant_type: 'authorization_code' }); + expect(result.data.responseBody).toEqual({ access_token: 'abc', token_type: 'Bearer' }); + } + }); + + it('rejects event with invalid source', () => { + const input = { + ...baseEvent, + source: 'invalid-source', + type: 'network:response', + data: { + _tag: 'network', + url: 'https://auth.example.com/token', + method: 'GET', + status: 200, + requestHeaders: {}, + responseHeaders: {}, + duration: 10, + }, + }; + + expect(() => Schema.decodeUnknownSync(AuthEventSchema)(input)).toThrow(); + }); + + it('validates flags field structure', () => { + const input = { + ...baseEvent, + type: 'network:response', + flags: { isCors: 'not-a-boolean', isError: false, isAuthRelated: true }, + data: { + _tag: 'network', + url: 'https://auth.example.com/token', + method: 'GET', + status: 200, + requestHeaders: {}, + responseHeaders: {}, + duration: 10, + }, + }; + + expect(() => Schema.decodeUnknownSync(AuthEventSchema)(input)).toThrow(); + }); + + it('accepts causedBy as a string', () => { + const input = { + ...baseEvent, + type: 'network:response', + causedBy: 'sdk-evt-123', + data: { + _tag: 'network', + url: 'https://auth.example.com/token', + method: 'GET', + status: 200, + requestHeaders: {}, + responseHeaders: {}, + duration: 10, + }, + }; + + const result = Schema.decodeUnknownSync(AuthEventSchema)(input); + expect(result.causedBy).toBe('sdk-evt-123'); + }); +}); diff --git a/packages/devtools-types/src/lib/auth-event.schema.ts b/packages/devtools-types/src/lib/auth-event.schema.ts new file mode 100644 index 0000000000..dbd61c43ba --- /dev/null +++ b/packages/devtools-types/src/lib/auth-event.schema.ts @@ -0,0 +1,160 @@ +import { Schema } from 'effect'; + +export const AuthEventTypeSchema = Schema.Union( + Schema.Literal('network:request'), + Schema.Literal('network:response'), + Schema.Literal('network:cors-flag'), + Schema.Literal('sdk:node-change'), + Schema.Literal('sdk:action'), + Schema.Literal('sdk:config'), + Schema.Literal('sdk:journey-step'), + Schema.Literal('sdk:oidc-state'), + Schema.Literal('dom:form-submit'), + Schema.Literal('dom:redirect'), + Schema.Literal('session:cookie'), + Schema.Literal('session:storage'), +); + +export const AuthEventFlagsSchema = Schema.Struct({ + isCors: Schema.Boolean, + isError: Schema.Boolean, + isAuthRelated: Schema.Boolean, +}); + +export const CorsFlagSchema = Schema.Struct({ + url: Schema.String, + reason: Schema.Union( + Schema.Literal('status-zero'), + Schema.Literal('missing-allow-origin'), + Schema.Literal('credentials-mismatch'), + Schema.Literal('wildcard-with-credentials'), + Schema.Literal('preflight-failed'), + ), + method: Schema.String, + preflightStatus: Schema.optional(Schema.Number), + allowOrigin: Schema.optional(Schema.String), + allowCredentials: Schema.optional(Schema.String), +}); + +export type CorsFlag = Schema.Schema.Type; + +export const NetworkDataSchema = Schema.Struct({ + _tag: Schema.Literal('network'), + url: Schema.String, + method: Schema.String, + status: Schema.Number, + requestHeaders: Schema.Record({ key: Schema.String, value: Schema.String }), + responseHeaders: Schema.Record({ key: Schema.String, value: Schema.String }), + duration: Schema.Number, + corsFlag: Schema.optional(CorsFlagSchema), + requestBody: Schema.optional(Schema.Unknown), + responseBody: Schema.optional(Schema.Unknown), +}); + +export const SdkErrorSchema = Schema.Struct({ + code: Schema.String, + message: Schema.String, + type: Schema.String, + internalHttpStatus: Schema.optional(Schema.Number), +}); + +export const SdkAuthorizationSchema = Schema.Struct({ + code: Schema.optional(Schema.String), + state: Schema.optional(Schema.String), +}); + +export const SdkDataSchema = Schema.Struct({ + _tag: Schema.Literal('sdk'), + nodeStatus: Schema.String, + previousStatus: Schema.optional(Schema.String), + interactionId: Schema.optional(Schema.String), + interactionToken: Schema.optional(Schema.String), + nodeId: Schema.optional(Schema.String), + requestId: Schema.optional(Schema.String), + nodeName: Schema.optional(Schema.String), + nodeDescription: Schema.optional(Schema.String), + eventName: Schema.optional(Schema.String), + httpStatus: Schema.optional(Schema.Number), + collectors: Schema.optional(Schema.Array(Schema.Unknown)), + error: Schema.optional(SdkErrorSchema), + authorization: Schema.optional(SdkAuthorizationSchema), + session: Schema.optional(Schema.String), + responseBody: Schema.optional(Schema.Unknown), +}); + +export const SdkConfigDataSchema = Schema.Struct({ + _tag: Schema.Literal('sdk-config'), + config: Schema.Unknown, +}); + +export const DomDataSchema = Schema.Struct({ + _tag: Schema.Literal('dom'), + element: Schema.optional(Schema.String), + url: Schema.optional(Schema.String), +}); + +export const SessionDataSchema = Schema.Struct({ + _tag: Schema.Literal('session'), + key: Schema.String, + before: Schema.optional(Schema.String), + after: Schema.optional(Schema.String), +}); + +export const JourneyDataSchema = Schema.Struct({ + _tag: Schema.Literal('journey'), + stepType: Schema.Union( + Schema.Literal('Step'), + Schema.Literal('LoginSuccess'), + Schema.Literal('LoginFailure'), + ), + callbacks: Schema.optional(Schema.Array(Schema.Unknown)), + authId: Schema.optional(Schema.String), + tokenId: Schema.optional(Schema.String), + successUrl: Schema.optional(Schema.String), + realm: Schema.optional(Schema.String), + stage: Schema.optional(Schema.String), + header: Schema.optional(Schema.String), + description: Schema.optional(Schema.String), + errorCode: Schema.optional(Schema.Number), + errorMessage: Schema.optional(Schema.String), + errorReason: Schema.optional(Schema.String), +}); + +export const OidcDataSchema = Schema.Struct({ + _tag: Schema.Literal('oidc'), + phase: Schema.Union( + Schema.Literal('authorize'), + Schema.Literal('exchange'), + Schema.Literal('revoke'), + Schema.Literal('userinfo'), + Schema.Literal('logout'), + ), + status: Schema.Union(Schema.Literal('success'), Schema.Literal('error')), + clientId: Schema.optional(Schema.String), + errorCode: Schema.optional(Schema.String), + errorMessage: Schema.optional(Schema.String), +}); + +export const AuthEventSchema = Schema.Struct({ + id: Schema.String, + timestamp: Schema.Number, + type: AuthEventTypeSchema, + source: Schema.Union( + Schema.Literal('network'), + Schema.Literal('sdk'), + Schema.Literal('dom'), + Schema.Literal('session'), + ), + flowId: Schema.NullOr(Schema.String), + causedBy: Schema.NullOr(Schema.String), + data: Schema.Union( + NetworkDataSchema, + SdkDataSchema, + SdkConfigDataSchema, + DomDataSchema, + SessionDataSchema, + JourneyDataSchema, + OidcDataSchema, + ), + flags: AuthEventFlagsSchema, +}); diff --git a/packages/devtools-types/src/lib/auth-event.types.ts b/packages/devtools-types/src/lib/auth-event.types.ts new file mode 100644 index 0000000000..1b5b37dbbe --- /dev/null +++ b/packages/devtools-types/src/lib/auth-event.types.ts @@ -0,0 +1,24 @@ +import type { Schema } from 'effect'; +import type { + AuthEventSchema, + AuthEventTypeSchema, + AuthEventFlagsSchema, + NetworkDataSchema, + SdkDataSchema, + SdkConfigDataSchema, + DomDataSchema, + SessionDataSchema, + JourneyDataSchema, + OidcDataSchema, +} from './auth-event.schema.js'; + +export type AuthEventType = Schema.Schema.Type; +export type AuthEventFlags = Schema.Schema.Type; +export type NetworkData = Schema.Schema.Type; +export type SdkData = Schema.Schema.Type; +export type SdkConfigData = Schema.Schema.Type; +export type DomData = Schema.Schema.Type; +export type SessionData = Schema.Schema.Type; +export type JourneyData = Schema.Schema.Type; +export type OidcData = Schema.Schema.Type; +export type AuthEvent = Schema.Schema.Type; diff --git a/packages/devtools-types/src/lib/cors-flag.types.ts b/packages/devtools-types/src/lib/cors-flag.types.ts new file mode 100644 index 0000000000..8355db2fb7 --- /dev/null +++ b/packages/devtools-types/src/lib/cors-flag.types.ts @@ -0,0 +1,2 @@ +// CorsFlag type is derived from CorsFlagSchema — single source of truth. +export type { CorsFlag } from './auth-event.schema.js'; diff --git a/packages/devtools-types/src/lib/flow-export.schema.test.ts b/packages/devtools-types/src/lib/flow-export.schema.test.ts new file mode 100644 index 0000000000..d9f3dfa183 --- /dev/null +++ b/packages/devtools-types/src/lib/flow-export.schema.test.ts @@ -0,0 +1,80 @@ +import { Schema } from 'effect'; +import { describe, expect, it } from 'vitest'; +import { FlowExportSchema } from './flow-export.schema.js'; + +const validExport = { + version: 1, + exportedAt: '2026-05-08T14:32:00.000Z', + redacted: true, + flow: { + flowId: 'flow-abc', + capturedAt: '2026-05-08T14:30:00.000Z', + events: [ + { + id: 'evt-001', + timestamp: 1700000000000, + type: 'network:response', + source: 'network', + flowId: 'flow-abc', + causedBy: null, + data: { + _tag: 'network', + url: 'https://auth.example.com/token', + method: 'POST', + status: 200, + requestHeaders: { 'content-type': 'application/json' }, + responseHeaders: { 'x-request-id': 'abc123' }, + duration: 123, + }, + flags: { isCors: false, isError: false, isAuthRelated: true }, + }, + ], + summary: { + nodeCount: 0, + errorCount: 0, + corsFlags: [], + duration: 0, + sdkConnected: false, + }, + }, +}; + +describe('FlowExportSchema', () => { + it('decodes a valid export envelope', () => { + const result = Schema.decodeUnknownSync(FlowExportSchema)(validExport); + expect(result.version).toBe(1); + expect(result.redacted).toBe(true); + expect(result.flow.events).toHaveLength(1); + expect(result.flow.flowId).toBe('flow-abc'); + }); + + it('rejects missing version field', () => { + const { version, ...noVersion } = validExport; + expect(() => Schema.decodeUnknownSync(FlowExportSchema)(noVersion)).toThrow(); + }); + + it('rejects wrong version number', () => { + const input = { ...validExport, version: 2 }; + expect(() => Schema.decodeUnknownSync(FlowExportSchema)(input)).toThrow(); + }); + + it('rejects invalid event inside flow.events', () => { + const input = { + ...validExport, + flow: { + ...validExport.flow, + events: [{ id: 'bad', timestamp: 0 }], + }, + }; + expect(() => Schema.decodeUnknownSync(FlowExportSchema)(input)).toThrow(); + }); + + it('accepts flow with lastSdkEventId (optional field defaults to null)', () => { + const input = { + ...validExport, + flow: { ...validExport.flow, lastSdkEventId: 'sdk-99' }, + }; + const result = Schema.decodeUnknownSync(FlowExportSchema)(input); + expect(result.flow.lastSdkEventId).toBe('sdk-99'); + }); +}); diff --git a/packages/devtools-types/src/lib/flow-export.schema.ts b/packages/devtools-types/src/lib/flow-export.schema.ts new file mode 100644 index 0000000000..3e7df35f67 --- /dev/null +++ b/packages/devtools-types/src/lib/flow-export.schema.ts @@ -0,0 +1,11 @@ +import { Schema } from 'effect'; +import { FlowStateSchema } from './flow-state.schema.js'; + +export const FlowExportSchema = Schema.Struct({ + version: Schema.Literal(1), + exportedAt: Schema.String, + redacted: Schema.Boolean, + flow: FlowStateSchema, +}); + +export type FlowExport = Schema.Schema.Type; diff --git a/packages/devtools-types/src/lib/flow-state.schema.test.ts b/packages/devtools-types/src/lib/flow-state.schema.test.ts new file mode 100644 index 0000000000..3d1870d164 --- /dev/null +++ b/packages/devtools-types/src/lib/flow-state.schema.test.ts @@ -0,0 +1,123 @@ +import { Schema } from 'effect'; +import { describe, expect, it } from 'vitest'; +import { FlowStateSchema, FlowSummarySchema } from './flow-state.schema.js'; + +const validSummary = { + nodeCount: 3, + errorCount: 1, + corsFlags: [], + duration: 1500, + sdkConnected: true, +}; + +const validNetworkEvent = { + id: 'evt-001', + timestamp: 1700000000000, + type: 'network:response', + source: 'network', + flowId: 'flow-abc', + causedBy: null, + data: { + _tag: 'network', + url: 'https://auth.example.com/token', + method: 'POST', + status: 200, + requestHeaders: { 'content-type': 'application/json' }, + responseHeaders: { 'x-request-id': 'abc123' }, + duration: 123, + }, + flags: { isCors: false, isError: false, isAuthRelated: true }, +}; + +describe('FlowSummarySchema', () => { + it('decodes a valid summary', () => { + const result = Schema.decodeUnknownSync(FlowSummarySchema)(validSummary); + expect(result.nodeCount).toBe(3); + expect(result.errorCount).toBe(1); + expect(result.sdkConnected).toBe(true); + }); + + it('decodes a summary with corsFlags', () => { + const input = { + ...validSummary, + corsFlags: [ + { + url: 'https://auth.example.com/token', + reason: 'status-zero', + method: 'POST', + }, + ], + }; + const result = Schema.decodeUnknownSync(FlowSummarySchema)(input); + expect(result.corsFlags).toHaveLength(1); + expect(result.corsFlags[0].reason).toBe('status-zero'); + }); + + it('rejects missing nodeCount', () => { + const { nodeCount, ...rest } = validSummary; + expect(() => Schema.decodeUnknownSync(FlowSummarySchema)(rest)).toThrow(); + }); + + it('rejects invalid corsFlag reason', () => { + const input = { + ...validSummary, + corsFlags: [{ url: 'https://x.com', reason: 'bad-reason', method: 'GET' }], + }; + expect(() => Schema.decodeUnknownSync(FlowSummarySchema)(input)).toThrow(); + }); +}); + +describe('FlowStateSchema', () => { + const validFlowState = { + flowId: 'flow-abc', + capturedAt: '2026-05-08T14:30:00.000Z', + events: [validNetworkEvent], + summary: validSummary, + }; + + it('decodes a valid flow state', () => { + const result = Schema.decodeUnknownSync(FlowStateSchema)(validFlowState); + expect(result.flowId).toBe('flow-abc'); + expect(result.events).toHaveLength(1); + expect(result.capturedAt).toBe('2026-05-08T14:30:00.000Z'); + }); + + it('accepts null flowId', () => { + const input = { ...validFlowState, flowId: null }; + const result = Schema.decodeUnknownSync(FlowStateSchema)(input); + expect(result.flowId).toBeNull(); + }); + + it('defaults lastSdkEventId to null when omitted', () => { + const result = Schema.decodeUnknownSync(FlowStateSchema)(validFlowState); + expect(result.lastSdkEventId).toBeNull(); + }); + + it('accepts explicit lastSdkEventId', () => { + const input = { ...validFlowState, lastSdkEventId: 'sdk-42' }; + const result = Schema.decodeUnknownSync(FlowStateSchema)(input); + expect(result.lastSdkEventId).toBe('sdk-42'); + }); + + it('accepts null lastSdkEventId', () => { + const input = { ...validFlowState, lastSdkEventId: null }; + const result = Schema.decodeUnknownSync(FlowStateSchema)(input); + expect(result.lastSdkEventId).toBeNull(); + }); + + it('accepts empty events array', () => { + const input = { ...validFlowState, events: [] }; + const result = Schema.decodeUnknownSync(FlowStateSchema)(input); + expect(result.events).toHaveLength(0); + }); + + it('rejects missing summary', () => { + const { summary, ...rest } = validFlowState; + expect(() => Schema.decodeUnknownSync(FlowStateSchema)(rest)).toThrow(); + }); + + it('rejects invalid event in events array', () => { + const input = { ...validFlowState, events: [{ id: 'bad' }] }; + expect(() => Schema.decodeUnknownSync(FlowStateSchema)(input)).toThrow(); + }); +}); diff --git a/packages/devtools-types/src/lib/flow-state.schema.ts b/packages/devtools-types/src/lib/flow-state.schema.ts new file mode 100644 index 0000000000..fc1b0dbfc9 --- /dev/null +++ b/packages/devtools-types/src/lib/flow-state.schema.ts @@ -0,0 +1,21 @@ +import { Schema } from 'effect'; +import { AuthEventSchema, CorsFlagSchema } from './auth-event.schema.js'; + +export const FlowSummarySchema = Schema.Struct({ + nodeCount: Schema.Number, + errorCount: Schema.Number, + corsFlags: Schema.Array(CorsFlagSchema), + duration: Schema.Number, + sdkConnected: Schema.Boolean, +}); + +export const FlowStateSchema = Schema.Struct({ + flowId: Schema.NullOr(Schema.String), + capturedAt: Schema.String, + events: Schema.Array(AuthEventSchema), + summary: FlowSummarySchema, + lastSdkEventId: Schema.optionalWith(Schema.NullOr(Schema.String), { default: () => null }), +}); + +export type FlowSummary = Schema.Schema.Type; +export type FlowState = Schema.Schema.Type; diff --git a/packages/devtools-types/src/lib/flow-state.types.ts b/packages/devtools-types/src/lib/flow-state.types.ts new file mode 100644 index 0000000000..02eeed7668 --- /dev/null +++ b/packages/devtools-types/src/lib/flow-state.types.ts @@ -0,0 +1,2 @@ +// FlowState and FlowSummary types are derived from their schemas — single source of truth. +export type { FlowSummary, FlowState } from './flow-state.schema.js'; diff --git a/packages/devtools-types/tsconfig.json b/packages/devtools-types/tsconfig.json new file mode 100644 index 0000000000..9fd2495969 --- /dev/null +++ b/packages/devtools-types/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { "path": "./tsconfig.lib.json" }, + { "path": "./tsconfig.spec.json" } + ], + "nx": { + "addTypecheckTarget": false + } +} diff --git a/packages/devtools-types/tsconfig.lib.json b/packages/devtools-types/tsconfig.lib.json new file mode 100644 index 0000000000..84f810411f --- /dev/null +++ b/packages/devtools-types/tsconfig.lib.json @@ -0,0 +1,21 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "moduleResolution": "nodenext", + "module": "NodeNext", + "target": "ES2022", + "outDir": "./dist", + "resolveJsonModule": true, + "moduleDetection": "force", + "isolatedModules": true, + "strict": true, + "noImplicitOverride": true, + "declaration": true, + "declarationMap": true, + "skipLibCheck": true, + "sourceMap": true, + "lib": ["es2022", "dom", "dom.iterable"] + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/packages/devtools-types/tsconfig.spec.json b/packages/devtools-types/tsconfig.spec.json new file mode 100644 index 0000000000..26485106b7 --- /dev/null +++ b/packages/devtools-types/tsconfig.spec.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc/vitest", + "types": [ + "vitest/globals", + "vitest/importMeta", + "vite/client", + "node", + "vitest" + ], + "module": "NodeNext", + "moduleResolution": "nodenext", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true + }, + "include": ["vite.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts"], + "references": [{ "path": "./tsconfig.lib.json" }] +} diff --git a/packages/devtools-types/vite.config.ts b/packages/devtools-types/vite.config.ts new file mode 100644 index 0000000000..b27e786af5 --- /dev/null +++ b/packages/devtools-types/vite.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite'; + +export default defineConfig(() => ({ + root: __dirname, + cacheDir: '../../node_modules/.vite/packages/devtools-types', + test: { + watch: false, + globals: true, + environment: 'node', + include: ['src/**/*.{test,spec}.{js,ts}'], + reporters: ['default'], + }, +})); diff --git a/packages/journey-client/api-report/journey-client.api.md b/packages/journey-client/api-report/journey-client.api.md index 9e471c8784..797d5933ae 100644 --- a/packages/journey-client/api-report/journey-client.api.md +++ b/packages/journey-client/api-report/journey-client.api.md @@ -185,6 +185,8 @@ export function journey(input: { // @public export interface JourneyClient { + // (undocumented) + getState: () => unknown; // (undocumented) next: (step: JourneyStep, options?: NextOptions) => Promise; // (undocumented) @@ -194,6 +196,8 @@ export interface JourneyClient { // (undocumented) start: (options?: StartParam) => Promise; // (undocumented) + subscribe: (listener: () => void) => () => void; + // (undocumented) terminate: (options?: { query?: Record; }) => Promise; diff --git a/packages/journey-client/api-report/journey-client.types.api.md b/packages/journey-client/api-report/journey-client.types.api.md index c9d45ac5a5..74c2ee422a 100644 --- a/packages/journey-client/api-report/journey-client.types.api.md +++ b/packages/journey-client/api-report/journey-client.types.api.md @@ -172,6 +172,8 @@ export { isValidWellknownUrl } // @public export interface JourneyClient { + // (undocumented) + getState: () => unknown; // (undocumented) next: (step: JourneyStep, options?: NextOptions) => Promise; // (undocumented) @@ -181,6 +183,8 @@ export interface JourneyClient { // (undocumented) start: (options?: StartParam) => Promise; // (undocumented) + subscribe: (listener: () => void) => () => void; + // (undocumented) terminate: (options?: { query?: Record; }) => Promise; diff --git a/packages/journey-client/src/lib/client.store.ts b/packages/journey-client/src/lib/client.store.ts index 129386a864..240374b420 100644 --- a/packages/journey-client/src/lib/client.store.ts +++ b/packages/journey-client/src/lib/client.store.ts @@ -36,6 +36,8 @@ export type JourneyResult = JourneyStep | JourneyLoginSuccess | JourneyLoginFail /** The journey client instance returned by the `journey()` function. */ export interface JourneyClient { + subscribe: (listener: () => void) => () => void; + getState: () => unknown; start: (options?: StartParam) => Promise; next: (step: JourneyStep, options?: NextOptions) => Promise; redirect: (step: JourneyStep) => Promise; @@ -154,6 +156,8 @@ export async function journey({ }); const self: JourneyClient = { + subscribe: store.subscribe, + getState: store.getState, start: async (options?: StartParam) => { const { data } = await store.dispatch(journeyApi.endpoints.start.initiate(options)); if (!data) { diff --git a/packages/oidc-client/api-report/oidc-client.api.md b/packages/oidc-client/api-report/oidc-client.api.md index 283d363dc9..0230e71cb8 100644 --- a/packages/oidc-client/api-report/oidc-client.api.md +++ b/packages/oidc-client/api-report/oidc-client.api.md @@ -11,7 +11,7 @@ import { CombinedState } from '@reduxjs/toolkit/query'; import { CustomLogger } from '@forgerock/sdk-logger'; import { EnhancedStore } from '@reduxjs/toolkit'; import { FetchArgs } from '@reduxjs/toolkit/query'; -import type { FetchBaseQueryError } from '@reduxjs/toolkit/query'; +import { FetchBaseQueryError } from '@reduxjs/toolkit/query'; import { FetchBaseQueryMeta } from '@reduxjs/toolkit/query'; import { GenericError } from '@forgerock/sdk-types'; import { GetAuthorizationUrlOptions } from '@forgerock/sdk-types'; @@ -27,6 +27,7 @@ import { StoreEnhancer } from '@reduxjs/toolkit'; import { ThunkDispatch } from '@reduxjs/toolkit'; import { Tuple } from '@reduxjs/toolkit'; import { UnknownAction } from '@reduxjs/toolkit'; +import { Unsubscribe } from '@reduxjs/toolkit'; import { WellknownResponse } from '@forgerock/sdk-types'; export { ActionTypes } @@ -250,12 +251,39 @@ export function oidc(input: { }; storage?: Partial; }): Promise<{ - error: string; - type: string; - authorize?: undefined; - token?: undefined; - user?: undefined; -} | { + subscribe: (listener: () => void) => Unsubscribe; + getState: () => { + oidc: CombinedState< { + authorizeFetch: MutationDefinition< { + url: string; + }, BaseQueryFn, never, AuthorizeSuccessResponse, "oidc", unknown>; + authorizeIframe: MutationDefinition< { + url: string; + }, BaseQueryFn, never, AuthorizationSuccess, "oidc", unknown>; + endSession: MutationDefinition< { + idToken: string; + endpoint: string; + }, BaseQueryFn, never, null, "oidc", unknown>; + exchange: MutationDefinition< { + code: string; + config: OidcConfig; + endpoint: string; + verifier?: string; + }, BaseQueryFn, never, TokenExchangeResponse, "oidc", unknown>; + revoke: MutationDefinition< { + accessToken: string; + clientId?: string; + endpoint: string; + }, BaseQueryFn, never, object, "oidc", unknown>; + userInfo: MutationDefinition< { + accessToken: string; + endpoint: string; + }, BaseQueryFn, never, UserInfoResponse, "oidc", unknown>; + }, never, "oidc">; + wellknown: CombinedState< { + configuration: QueryDefinition, never, WellknownResponse, "wellknown", unknown>; + }, never, "wellknown">; + }; authorize: { url: (options?: GetAuthorizationUrlOptions) => Promise; background: (options?: GetAuthorizationUrlOptions) => Promise; @@ -269,8 +297,6 @@ export function oidc(input: { info: () => Promise; logout: () => Promise; }; - error?: undefined; - type?: undefined; }>; // @public (undocumented) diff --git a/packages/oidc-client/api-report/oidc-client.types.api.md b/packages/oidc-client/api-report/oidc-client.types.api.md index 283d363dc9..0230e71cb8 100644 --- a/packages/oidc-client/api-report/oidc-client.types.api.md +++ b/packages/oidc-client/api-report/oidc-client.types.api.md @@ -11,7 +11,7 @@ import { CombinedState } from '@reduxjs/toolkit/query'; import { CustomLogger } from '@forgerock/sdk-logger'; import { EnhancedStore } from '@reduxjs/toolkit'; import { FetchArgs } from '@reduxjs/toolkit/query'; -import type { FetchBaseQueryError } from '@reduxjs/toolkit/query'; +import { FetchBaseQueryError } from '@reduxjs/toolkit/query'; import { FetchBaseQueryMeta } from '@reduxjs/toolkit/query'; import { GenericError } from '@forgerock/sdk-types'; import { GetAuthorizationUrlOptions } from '@forgerock/sdk-types'; @@ -27,6 +27,7 @@ import { StoreEnhancer } from '@reduxjs/toolkit'; import { ThunkDispatch } from '@reduxjs/toolkit'; import { Tuple } from '@reduxjs/toolkit'; import { UnknownAction } from '@reduxjs/toolkit'; +import { Unsubscribe } from '@reduxjs/toolkit'; import { WellknownResponse } from '@forgerock/sdk-types'; export { ActionTypes } @@ -250,12 +251,39 @@ export function oidc(input: { }; storage?: Partial; }): Promise<{ - error: string; - type: string; - authorize?: undefined; - token?: undefined; - user?: undefined; -} | { + subscribe: (listener: () => void) => Unsubscribe; + getState: () => { + oidc: CombinedState< { + authorizeFetch: MutationDefinition< { + url: string; + }, BaseQueryFn, never, AuthorizeSuccessResponse, "oidc", unknown>; + authorizeIframe: MutationDefinition< { + url: string; + }, BaseQueryFn, never, AuthorizationSuccess, "oidc", unknown>; + endSession: MutationDefinition< { + idToken: string; + endpoint: string; + }, BaseQueryFn, never, null, "oidc", unknown>; + exchange: MutationDefinition< { + code: string; + config: OidcConfig; + endpoint: string; + verifier?: string; + }, BaseQueryFn, never, TokenExchangeResponse, "oidc", unknown>; + revoke: MutationDefinition< { + accessToken: string; + clientId?: string; + endpoint: string; + }, BaseQueryFn, never, object, "oidc", unknown>; + userInfo: MutationDefinition< { + accessToken: string; + endpoint: string; + }, BaseQueryFn, never, UserInfoResponse, "oidc", unknown>; + }, never, "oidc">; + wellknown: CombinedState< { + configuration: QueryDefinition, never, WellknownResponse, "wellknown", unknown>; + }, never, "wellknown">; + }; authorize: { url: (options?: GetAuthorizationUrlOptions) => Promise; background: (options?: GetAuthorizationUrlOptions) => Promise; @@ -269,8 +297,6 @@ export function oidc(input: { info: () => Promise; logout: () => Promise; }; - error?: undefined; - type?: undefined; }>; // @public (undocumented) diff --git a/packages/oidc-client/src/lib/client.store.ts b/packages/oidc-client/src/lib/client.store.ts index da6c3de99c..29c0ef2c99 100644 --- a/packages/oidc-client/src/lib/client.store.ts +++ b/packages/oidc-client/src/lib/client.store.ts @@ -73,16 +73,10 @@ export async function oidc({ const store = createClientStore({ requestMiddleware, logger: log }); if (!config?.serverConfig?.wellknown) { - return { - error: 'Requires a wellknown url initializing this factory.', - type: 'argument_error', - }; + throw new Error('Requires a wellknown url initializing this factory.'); } if (!config?.clientId) { - return { - error: 'Requires a clientId.', - type: 'argument_error', - }; + throw new Error('Requires a clientId.'); } const wellknownUrl = config.serverConfig.wellknown; @@ -95,6 +89,8 @@ export async function oidc({ } return { + subscribe: store.subscribe, + getState: store.getState, /** * An object containing methods for the creation, and background use, of the authorization URL */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index babd5d7de7..77020caf56 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -115,10 +115,10 @@ importers: version: 22.6.5(@babel/traverse@7.28.5)(@swc-node/register@1.11.1(@emnapi/core@1.7.0)(@emnapi/runtime@1.7.0)(@swc/core@1.15.30(@swc/helpers@0.5.21))(@swc/types@0.1.26)(typescript@5.8.3))(@swc/core@1.15.30(@swc/helpers@0.5.21))(@types/node@24.9.2)(@zkochan/js-yaml@0.0.7)(babel-plugin-macros@3.1.0)(eslint@9.39.4(jiti@2.6.1))(nx@22.6.5(@swc-node/register@1.11.1(@emnapi/core@1.7.0)(@emnapi/runtime@1.7.0)(@swc/core@1.15.30(@swc/helpers@0.5.21))(@swc/types@0.1.26)(typescript@5.8.3))(@swc/core@1.15.30(@swc/helpers@0.5.21)))(ts-node@10.9.2(@swc/core@1.15.30(@swc/helpers@0.5.21))(@types/node@24.9.2)(typescript@5.8.3))(typescript@5.8.3)(verdaccio@6.5.2(typanion@3.14.0)) '@nx/vite': specifier: 22.6.5 - version: 22.6.5(@babel/traverse@7.28.5)(@swc-node/register@1.11.1(@emnapi/core@1.7.0)(@emnapi/runtime@1.7.0)(@swc/core@1.15.30(@swc/helpers@0.5.21))(@swc/types@0.1.26)(typescript@5.8.3))(@swc/core@1.15.30(@swc/helpers@0.5.21))(nx@22.6.5(@swc-node/register@1.11.1(@emnapi/core@1.7.0)(@emnapi/runtime@1.7.0)(@swc/core@1.15.30(@swc/helpers@0.5.21))(@swc/types@0.1.26)(typescript@5.8.3))(@swc/core@1.15.30(@swc/helpers@0.5.21)))(typescript@5.8.3)(verdaccio@6.5.2(typanion@3.14.0))(vite@7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.1))(vitest@3.2.4) + version: 22.6.5(@babel/traverse@7.28.5)(@swc-node/register@1.11.1(@emnapi/core@1.7.0)(@emnapi/runtime@1.7.0)(@swc/core@1.15.30(@swc/helpers@0.5.21))(@swc/types@0.1.26)(typescript@5.8.3))(@swc/core@1.15.30(@swc/helpers@0.5.21))(nx@22.6.5(@swc-node/register@1.11.1(@emnapi/core@1.7.0)(@emnapi/runtime@1.7.0)(@swc/core@1.15.30(@swc/helpers@0.5.21))(@swc/types@0.1.26)(typescript@5.8.3))(@swc/core@1.15.30(@swc/helpers@0.5.21)))(typescript@5.8.3)(verdaccio@6.5.2(typanion@3.14.0))(vite@7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.47.1)(tsx@4.20.6)(yaml@2.8.1))(vitest@3.2.4) '@nx/vitest': specifier: 22.6.5 - version: 22.6.5(@babel/traverse@7.28.5)(@swc-node/register@1.11.1(@emnapi/core@1.7.0)(@emnapi/runtime@1.7.0)(@swc/core@1.15.30(@swc/helpers@0.5.21))(@swc/types@0.1.26)(typescript@5.8.3))(@swc/core@1.15.30(@swc/helpers@0.5.21))(nx@22.6.5(@swc-node/register@1.11.1(@emnapi/core@1.7.0)(@emnapi/runtime@1.7.0)(@swc/core@1.15.30(@swc/helpers@0.5.21))(@swc/types@0.1.26)(typescript@5.8.3))(@swc/core@1.15.30(@swc/helpers@0.5.21)))(typescript@5.8.3)(verdaccio@6.5.2(typanion@3.14.0))(vite@7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.1))(vitest@3.2.4) + version: 22.6.5(@babel/traverse@7.28.5)(@swc-node/register@1.11.1(@emnapi/core@1.7.0)(@emnapi/runtime@1.7.0)(@swc/core@1.15.30(@swc/helpers@0.5.21))(@swc/types@0.1.26)(typescript@5.8.3))(@swc/core@1.15.30(@swc/helpers@0.5.21))(nx@22.6.5(@swc-node/register@1.11.1(@emnapi/core@1.7.0)(@emnapi/runtime@1.7.0)(@swc/core@1.15.30(@swc/helpers@0.5.21))(@swc/types@0.1.26)(typescript@5.8.3))(@swc/core@1.15.30(@swc/helpers@0.5.21)))(typescript@5.8.3)(verdaccio@6.5.2(typanion@3.14.0))(vite@7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.47.1)(tsx@4.20.6)(yaml@2.8.1))(vitest@3.2.4) '@nx/web': specifier: 22.6.5 version: 22.6.5(@babel/traverse@7.28.5)(@swc-node/register@1.11.1(@emnapi/core@1.7.0)(@emnapi/runtime@1.7.0)(@swc/core@1.15.30(@swc/helpers@0.5.21))(@swc/types@0.1.26)(typescript@5.8.3))(@swc/core@1.15.30(@swc/helpers@0.5.21))(nx@22.6.5(@swc-node/register@1.11.1(@emnapi/core@1.7.0)(@emnapi/runtime@1.7.0)(@swc/core@1.15.30(@swc/helpers@0.5.21))(@swc/types@0.1.26)(typescript@5.8.3))(@swc/core@1.15.30(@swc/helpers@0.5.21)))(verdaccio@6.5.2(typanion@3.14.0)) @@ -262,10 +262,10 @@ importers: version: 6.5.2(typanion@3.14.0) vite: specifier: catalog:vite - version: 7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.1) + version: 7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.47.1)(tsx@4.20.6)(yaml@2.8.1) vitest: specifier: catalog:vitest - version: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.1(@types/node@24.9.2)(typescript@5.8.3))(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.1) + version: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.1(@types/node@24.9.2)(typescript@5.8.3))(terser@5.47.1)(tsx@4.20.6)(yaml@2.8.1) vitest-canvas-mock: specifier: catalog:vitest version: 1.1.3(vitest@3.2.4) @@ -300,6 +300,9 @@ importers: '@forgerock/davinci-client': specifier: workspace:* version: link:../../packages/davinci-client + '@forgerock/devtools-bridge': + specifier: workspace:* + version: link:../../packages/devtools-bridge '@forgerock/javascript-sdk': specifier: 4.7.0 version: 4.7.0 @@ -333,6 +336,9 @@ importers: '@forgerock/device-client': specifier: workspace:* version: link:../../packages/device-client + '@forgerock/devtools-bridge': + specifier: workspace:* + version: link:../../packages/devtools-bridge '@forgerock/journey-client': specifier: workspace:* version: link:../../packages/journey-client @@ -389,10 +395,13 @@ importers: version: 0.27.0(effect@3.20.0)(vitest@3.2.4) vitest: specifier: catalog:vitest - version: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) + version: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.1) e2e/oidc-app: dependencies: + '@forgerock/devtools-bridge': + specifier: workspace:* + version: link:../../packages/devtools-bridge '@forgerock/oidc-client': specifier: workspace:* version: link:../../packages/oidc-client @@ -445,7 +454,7 @@ importers: version: 0.27.0(effect@3.20.0)(vitest@3.2.4) vitest: specifier: catalog:vitest - version: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) + version: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.1) packages/device-client: dependencies: @@ -460,6 +469,47 @@ importers: specifier: 'catalog:' version: 2.12.1(@types/node@24.9.2)(typescript@5.9.3) + packages/devtools-bridge: + dependencies: + '@forgerock/devtools-types': + specifier: workspace:* + version: link:../devtools-types + effect: + specifier: catalog:effect + version: 3.20.0 + devDependencies: + '@forgerock/davinci-client': + specifier: workspace:* + version: link:../davinci-client + + packages/devtools-extension: + dependencies: + '@forgerock/devtools-types': + specifier: workspace:* + version: link:../devtools-types + effect: + specifier: catalog:effect + version: 3.20.0 + devDependencies: + '@types/chrome': + specifier: ^0.1.40 + version: 0.1.40 + elm-tooling: + specifier: ^1.15.1 + version: 1.17.0 + esbuild: + specifier: ^0.28.0 + version: 0.28.0 + terser: + specifier: ^5.47.1 + version: 5.47.1 + + packages/devtools-types: + dependencies: + effect: + specifier: catalog:effect + version: 3.20.0 + packages/journey-client: dependencies: '@forgerock/sdk-logger': @@ -492,10 +542,10 @@ importers: version: 3.2.4(vitest@3.2.4) vite: specifier: catalog:vite - version: 7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) + version: 7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.1) vitest: specifier: catalog:vitest - version: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) + version: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.1) vitest-canvas-mock: specifier: catalog:vitest version: 1.1.3(vitest@3.2.4) @@ -601,7 +651,7 @@ importers: version: 4.20.6 vitest: specifier: catalog:vitest - version: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.1) + version: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(terser@5.47.1)(tsx@4.20.6)(yaml@2.8.1) devDependencies: '@forgerock/javascript-sdk': specifier: 4.9.0 @@ -632,7 +682,7 @@ importers: version: 3.20.0 vitest: specifier: catalog:vitest - version: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) + version: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.1) devDependencies: '@effect/language-service': specifier: catalog:effect @@ -1633,6 +1683,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.0': + resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.25.12': resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} engines: {node: '>=18'} @@ -1645,6 +1701,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.0': + resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.25.12': resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} engines: {node: '>=18'} @@ -1657,6 +1719,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.0': + resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.25.12': resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} engines: {node: '>=18'} @@ -1669,6 +1737,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.0': + resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.25.12': resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} engines: {node: '>=18'} @@ -1681,6 +1755,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.0': + resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.25.12': resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} engines: {node: '>=18'} @@ -1693,6 +1773,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.0': + resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.25.12': resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} engines: {node: '>=18'} @@ -1705,6 +1791,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.0': + resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.25.12': resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} engines: {node: '>=18'} @@ -1717,6 +1809,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.0': + resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.25.12': resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} engines: {node: '>=18'} @@ -1729,6 +1827,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.0': + resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.25.12': resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} engines: {node: '>=18'} @@ -1741,6 +1845,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.0': + resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.25.12': resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} engines: {node: '>=18'} @@ -1753,6 +1863,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.0': + resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.25.12': resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} engines: {node: '>=18'} @@ -1765,6 +1881,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.0': + resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.25.12': resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} engines: {node: '>=18'} @@ -1777,6 +1899,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.0': + resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.25.12': resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} engines: {node: '>=18'} @@ -1789,6 +1917,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.0': + resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.25.12': resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} engines: {node: '>=18'} @@ -1801,6 +1935,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.0': + resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.25.12': resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} engines: {node: '>=18'} @@ -1813,6 +1953,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.0': + resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.25.12': resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} engines: {node: '>=18'} @@ -1825,6 +1971,12 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.0': + resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.25.12': resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} engines: {node: '>=18'} @@ -1837,6 +1989,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.0': + resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.25.12': resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} engines: {node: '>=18'} @@ -1849,6 +2007,12 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.0': + resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} engines: {node: '>=18'} @@ -1861,6 +2025,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.0': + resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.25.12': resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} engines: {node: '>=18'} @@ -1873,6 +2043,12 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.0': + resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} engines: {node: '>=18'} @@ -1885,6 +2061,12 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.0': + resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.25.12': resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} engines: {node: '>=18'} @@ -1897,6 +2079,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.0': + resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.25.12': resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} engines: {node: '>=18'} @@ -1909,6 +2097,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.0': + resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.25.12': resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} engines: {node: '>=18'} @@ -1921,6 +2115,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.0': + resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.25.12': resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} engines: {node: '>=18'} @@ -1933,6 +2133,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.0': + resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.0': resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -3229,6 +3435,9 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/chrome@0.1.40': + resolution: {integrity: sha512-UnfyRAe8ORu9HSuTH0EqyOEUin3JrWW9Nl/gDXezNfTUrfIoxw+WRZgKOxGz0t5BnjbfXBnS2eCYfW2PxH1wcA==} + '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} @@ -3259,6 +3468,15 @@ packages: '@types/express@5.0.6': resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + '@types/filesystem@0.0.36': + resolution: {integrity: sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==} + + '@types/filewriter@0.0.33': + resolution: {integrity: sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==} + + '@types/har-format@1.2.16': + resolution: {integrity: sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==} + '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -3406,6 +3624,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@unrs/resolver-binding-android-arm-eabi@1.11.1': resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} @@ -4828,6 +5047,10 @@ packages: electron-to-chromium@1.5.249: resolution: {integrity: sha512-5vcfL3BBe++qZ5kuFhD/p8WOM1N9m3nwvJPULJx+4xf2usSlZFJ0qoNYO2fOX4hi3ocuDcmDobtA+5SFr4OmBg==} + elm-tooling@1.17.0: + resolution: {integrity: sha512-Y6umJYX7w/tV08pgmNF95nkvPq+xOvhirJz6LIt4YUj2I9V2W0V4Yb1E0IUZ/9X9yUgiEcFpKyObav4QMWCPrg==} + hasBin: true + emittery@0.13.1: resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} engines: {node: '>=12'} @@ -4926,6 +5149,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.28.0: + resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -7836,8 +8064,8 @@ packages: uglify-js: optional: true - terser@5.46.2: - resolution: {integrity: sha512-uxfo9fPcSgLDYob/w1FuL0c99MWiJDnv+5qXSQc5+Ki5NjVNsYi66INnMFBjf6uFz6OnX12piJQPF4IpjJTNTw==} + terser@5.47.1: + resolution: {integrity: sha512-tPbLXTI6ohPASb/1YViL428oEHu6/qv1OxqYnfaonVCFHqx4+wCd95pHrQWsL5X4pl90CTyW9piSAsS2L0VoMw==} engines: {node: '>=10'} hasBin: true @@ -8223,6 +8451,7 @@ packages: uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true v8-compile-cache-lib@3.0.1: @@ -9829,7 +10058,7 @@ snapshots: '@effect/vitest@0.27.0(effect@3.20.0)(vitest@3.2.4)': dependencies: effect: 3.20.0 - vitest: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) + vitest: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.1) '@effect/workflow@0.8.3(@effect/platform@0.90.10(effect@3.20.0))(@effect/rpc@0.68.4(@effect/platform@0.90.10(effect@3.20.0))(effect@3.20.0))(effect@3.20.0)': dependencies: @@ -9856,156 +10085,234 @@ snapshots: '@esbuild/aix-ppc64@0.27.2': optional: true + '@esbuild/aix-ppc64@0.28.0': + optional: true + '@esbuild/android-arm64@0.25.12': optional: true '@esbuild/android-arm64@0.27.2': optional: true + '@esbuild/android-arm64@0.28.0': + optional: true + '@esbuild/android-arm@0.25.12': optional: true '@esbuild/android-arm@0.27.2': optional: true + '@esbuild/android-arm@0.28.0': + optional: true + '@esbuild/android-x64@0.25.12': optional: true '@esbuild/android-x64@0.27.2': optional: true + '@esbuild/android-x64@0.28.0': + optional: true + '@esbuild/darwin-arm64@0.25.12': optional: true '@esbuild/darwin-arm64@0.27.2': optional: true + '@esbuild/darwin-arm64@0.28.0': + optional: true + '@esbuild/darwin-x64@0.25.12': optional: true '@esbuild/darwin-x64@0.27.2': optional: true + '@esbuild/darwin-x64@0.28.0': + optional: true + '@esbuild/freebsd-arm64@0.25.12': optional: true '@esbuild/freebsd-arm64@0.27.2': optional: true + '@esbuild/freebsd-arm64@0.28.0': + optional: true + '@esbuild/freebsd-x64@0.25.12': optional: true '@esbuild/freebsd-x64@0.27.2': optional: true + '@esbuild/freebsd-x64@0.28.0': + optional: true + '@esbuild/linux-arm64@0.25.12': optional: true '@esbuild/linux-arm64@0.27.2': optional: true + '@esbuild/linux-arm64@0.28.0': + optional: true + '@esbuild/linux-arm@0.25.12': optional: true '@esbuild/linux-arm@0.27.2': optional: true + '@esbuild/linux-arm@0.28.0': + optional: true + '@esbuild/linux-ia32@0.25.12': optional: true '@esbuild/linux-ia32@0.27.2': optional: true + '@esbuild/linux-ia32@0.28.0': + optional: true + '@esbuild/linux-loong64@0.25.12': optional: true '@esbuild/linux-loong64@0.27.2': optional: true + '@esbuild/linux-loong64@0.28.0': + optional: true + '@esbuild/linux-mips64el@0.25.12': optional: true '@esbuild/linux-mips64el@0.27.2': optional: true + '@esbuild/linux-mips64el@0.28.0': + optional: true + '@esbuild/linux-ppc64@0.25.12': optional: true '@esbuild/linux-ppc64@0.27.2': optional: true + '@esbuild/linux-ppc64@0.28.0': + optional: true + '@esbuild/linux-riscv64@0.25.12': optional: true '@esbuild/linux-riscv64@0.27.2': optional: true + '@esbuild/linux-riscv64@0.28.0': + optional: true + '@esbuild/linux-s390x@0.25.12': optional: true '@esbuild/linux-s390x@0.27.2': optional: true + '@esbuild/linux-s390x@0.28.0': + optional: true + '@esbuild/linux-x64@0.25.12': optional: true '@esbuild/linux-x64@0.27.2': optional: true + '@esbuild/linux-x64@0.28.0': + optional: true + '@esbuild/netbsd-arm64@0.25.12': optional: true '@esbuild/netbsd-arm64@0.27.2': optional: true + '@esbuild/netbsd-arm64@0.28.0': + optional: true + '@esbuild/netbsd-x64@0.25.12': optional: true '@esbuild/netbsd-x64@0.27.2': optional: true + '@esbuild/netbsd-x64@0.28.0': + optional: true + '@esbuild/openbsd-arm64@0.25.12': optional: true '@esbuild/openbsd-arm64@0.27.2': optional: true + '@esbuild/openbsd-arm64@0.28.0': + optional: true + '@esbuild/openbsd-x64@0.25.12': optional: true '@esbuild/openbsd-x64@0.27.2': optional: true + '@esbuild/openbsd-x64@0.28.0': + optional: true + '@esbuild/openharmony-arm64@0.25.12': optional: true '@esbuild/openharmony-arm64@0.27.2': optional: true + '@esbuild/openharmony-arm64@0.28.0': + optional: true + '@esbuild/sunos-x64@0.25.12': optional: true '@esbuild/sunos-x64@0.27.2': optional: true + '@esbuild/sunos-x64@0.28.0': + optional: true + '@esbuild/win32-arm64@0.25.12': optional: true '@esbuild/win32-arm64@0.27.2': optional: true + '@esbuild/win32-arm64@0.28.0': + optional: true + '@esbuild/win32-ia32@0.25.12': optional: true '@esbuild/win32-ia32@0.27.2': optional: true + '@esbuild/win32-ia32@0.28.0': + optional: true + '@esbuild/win32-x64@0.25.12': optional: true '@esbuild/win32-x64@0.27.2': optional: true + '@esbuild/win32-x64@0.28.0': + optional: true + '@eslint-community/eslint-utils@4.9.0(eslint@9.39.4(jiti@2.6.1))': dependencies: eslint: 9.39.4(jiti@2.6.1) @@ -10782,11 +11089,11 @@ snapshots: - typescript - verdaccio - '@nx/vite@22.6.5(@babel/traverse@7.28.5)(@swc-node/register@1.11.1(@emnapi/core@1.7.0)(@emnapi/runtime@1.7.0)(@swc/core@1.15.30(@swc/helpers@0.5.21))(@swc/types@0.1.26)(typescript@5.8.3))(@swc/core@1.15.30(@swc/helpers@0.5.21))(nx@22.6.5(@swc-node/register@1.11.1(@emnapi/core@1.7.0)(@emnapi/runtime@1.7.0)(@swc/core@1.15.30(@swc/helpers@0.5.21))(@swc/types@0.1.26)(typescript@5.8.3))(@swc/core@1.15.30(@swc/helpers@0.5.21)))(typescript@5.8.3)(verdaccio@6.5.2(typanion@3.14.0))(vite@7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.1))(vitest@3.2.4)': + '@nx/vite@22.6.5(@babel/traverse@7.28.5)(@swc-node/register@1.11.1(@emnapi/core@1.7.0)(@emnapi/runtime@1.7.0)(@swc/core@1.15.30(@swc/helpers@0.5.21))(@swc/types@0.1.26)(typescript@5.8.3))(@swc/core@1.15.30(@swc/helpers@0.5.21))(nx@22.6.5(@swc-node/register@1.11.1(@emnapi/core@1.7.0)(@emnapi/runtime@1.7.0)(@swc/core@1.15.30(@swc/helpers@0.5.21))(@swc/types@0.1.26)(typescript@5.8.3))(@swc/core@1.15.30(@swc/helpers@0.5.21)))(typescript@5.8.3)(verdaccio@6.5.2(typanion@3.14.0))(vite@7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.47.1)(tsx@4.20.6)(yaml@2.8.1))(vitest@3.2.4)': dependencies: '@nx/devkit': 22.6.5(nx@22.6.5(@swc-node/register@1.11.1(@emnapi/core@1.7.0)(@emnapi/runtime@1.7.0)(@swc/core@1.15.30(@swc/helpers@0.5.21))(@swc/types@0.1.26)(typescript@5.8.3))(@swc/core@1.15.30(@swc/helpers@0.5.21))) '@nx/js': 22.6.5(@babel/traverse@7.28.5)(@swc-node/register@1.11.1(@emnapi/core@1.7.0)(@emnapi/runtime@1.7.0)(@swc/core@1.15.30(@swc/helpers@0.5.21))(@swc/types@0.1.26)(typescript@5.8.3))(@swc/core@1.15.30(@swc/helpers@0.5.21))(nx@22.6.5(@swc-node/register@1.11.1(@emnapi/core@1.7.0)(@emnapi/runtime@1.7.0)(@swc/core@1.15.30(@swc/helpers@0.5.21))(@swc/types@0.1.26)(typescript@5.8.3))(@swc/core@1.15.30(@swc/helpers@0.5.21)))(verdaccio@6.5.2(typanion@3.14.0)) - '@nx/vitest': 22.6.5(@babel/traverse@7.28.5)(@swc-node/register@1.11.1(@emnapi/core@1.7.0)(@emnapi/runtime@1.7.0)(@swc/core@1.15.30(@swc/helpers@0.5.21))(@swc/types@0.1.26)(typescript@5.8.3))(@swc/core@1.15.30(@swc/helpers@0.5.21))(nx@22.6.5(@swc-node/register@1.11.1(@emnapi/core@1.7.0)(@emnapi/runtime@1.7.0)(@swc/core@1.15.30(@swc/helpers@0.5.21))(@swc/types@0.1.26)(typescript@5.8.3))(@swc/core@1.15.30(@swc/helpers@0.5.21)))(typescript@5.8.3)(verdaccio@6.5.2(typanion@3.14.0))(vite@7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.1))(vitest@3.2.4) + '@nx/vitest': 22.6.5(@babel/traverse@7.28.5)(@swc-node/register@1.11.1(@emnapi/core@1.7.0)(@emnapi/runtime@1.7.0)(@swc/core@1.15.30(@swc/helpers@0.5.21))(@swc/types@0.1.26)(typescript@5.8.3))(@swc/core@1.15.30(@swc/helpers@0.5.21))(nx@22.6.5(@swc-node/register@1.11.1(@emnapi/core@1.7.0)(@emnapi/runtime@1.7.0)(@swc/core@1.15.30(@swc/helpers@0.5.21))(@swc/types@0.1.26)(typescript@5.8.3))(@swc/core@1.15.30(@swc/helpers@0.5.21)))(typescript@5.8.3)(verdaccio@6.5.2(typanion@3.14.0))(vite@7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.47.1)(tsx@4.20.6)(yaml@2.8.1))(vitest@3.2.4) '@phenomnomnominal/tsquery': 6.1.4(typescript@5.8.3) ajv: 8.18.0 enquirer: 2.3.6 @@ -10794,8 +11101,8 @@ snapshots: semver: 7.7.3 tsconfig-paths: 4.2.0 tslib: 2.8.1 - vite: 7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.1) - vitest: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.1(@types/node@24.9.2)(typescript@5.8.3))(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.1) + vite: 7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.47.1)(tsx@4.20.6)(yaml@2.8.1) + vitest: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.1(@types/node@24.9.2)(typescript@5.8.3))(terser@5.47.1)(tsx@4.20.6)(yaml@2.8.1) transitivePeerDependencies: - '@babel/traverse' - '@swc-node/register' @@ -10806,7 +11113,7 @@ snapshots: - typescript - verdaccio - '@nx/vitest@22.6.5(@babel/traverse@7.28.5)(@swc-node/register@1.11.1(@emnapi/core@1.7.0)(@emnapi/runtime@1.7.0)(@swc/core@1.15.30(@swc/helpers@0.5.21))(@swc/types@0.1.26)(typescript@5.8.3))(@swc/core@1.15.30(@swc/helpers@0.5.21))(nx@22.6.5(@swc-node/register@1.11.1(@emnapi/core@1.7.0)(@emnapi/runtime@1.7.0)(@swc/core@1.15.30(@swc/helpers@0.5.21))(@swc/types@0.1.26)(typescript@5.8.3))(@swc/core@1.15.30(@swc/helpers@0.5.21)))(typescript@5.8.3)(verdaccio@6.5.2(typanion@3.14.0))(vite@7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.1))(vitest@3.2.4)': + '@nx/vitest@22.6.5(@babel/traverse@7.28.5)(@swc-node/register@1.11.1(@emnapi/core@1.7.0)(@emnapi/runtime@1.7.0)(@swc/core@1.15.30(@swc/helpers@0.5.21))(@swc/types@0.1.26)(typescript@5.8.3))(@swc/core@1.15.30(@swc/helpers@0.5.21))(nx@22.6.5(@swc-node/register@1.11.1(@emnapi/core@1.7.0)(@emnapi/runtime@1.7.0)(@swc/core@1.15.30(@swc/helpers@0.5.21))(@swc/types@0.1.26)(typescript@5.8.3))(@swc/core@1.15.30(@swc/helpers@0.5.21)))(typescript@5.8.3)(verdaccio@6.5.2(typanion@3.14.0))(vite@7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.47.1)(tsx@4.20.6)(yaml@2.8.1))(vitest@3.2.4)': dependencies: '@nx/devkit': 22.6.5(nx@22.6.5(@swc-node/register@1.11.1(@emnapi/core@1.7.0)(@emnapi/runtime@1.7.0)(@swc/core@1.15.30(@swc/helpers@0.5.21))(@swc/types@0.1.26)(typescript@5.8.3))(@swc/core@1.15.30(@swc/helpers@0.5.21))) '@nx/js': 22.6.5(@babel/traverse@7.28.5)(@swc-node/register@1.11.1(@emnapi/core@1.7.0)(@emnapi/runtime@1.7.0)(@swc/core@1.15.30(@swc/helpers@0.5.21))(@swc/types@0.1.26)(typescript@5.8.3))(@swc/core@1.15.30(@swc/helpers@0.5.21))(nx@22.6.5(@swc-node/register@1.11.1(@emnapi/core@1.7.0)(@emnapi/runtime@1.7.0)(@swc/core@1.15.30(@swc/helpers@0.5.21))(@swc/types@0.1.26)(typescript@5.8.3))(@swc/core@1.15.30(@swc/helpers@0.5.21)))(verdaccio@6.5.2(typanion@3.14.0)) @@ -10814,8 +11121,8 @@ snapshots: semver: 7.7.3 tslib: 2.8.1 optionalDependencies: - vite: 7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.1) - vitest: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.1(@types/node@24.9.2)(typescript@5.8.3))(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.1) + vite: 7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.47.1)(tsx@4.20.6)(yaml@2.8.1) + vitest: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.1(@types/node@24.9.2)(typescript@5.8.3))(terser@5.47.1)(tsx@4.20.6)(yaml@2.8.1) transitivePeerDependencies: - '@babel/traverse' - '@swc-node/register' @@ -11498,6 +11805,11 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/chrome@0.1.40': + dependencies: + '@types/filesystem': 0.0.36 + '@types/har-format': 1.2.16 + '@types/connect@3.4.38': dependencies: '@types/node': 24.9.2 @@ -11543,6 +11855,14 @@ snapshots: '@types/express-serve-static-core': 5.1.0 '@types/serve-static': 2.2.0 + '@types/filesystem@0.0.36': + dependencies: + '@types/filewriter': 0.0.33 + + '@types/filewriter@0.0.33': {} + + '@types/har-format@1.2.16': {} + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -11974,7 +12294,7 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) + vitest: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.1) transitivePeerDependencies: - supports-color @@ -11986,32 +12306,32 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(msw@2.12.1(@types/node@24.9.2)(typescript@5.8.3))(vite@7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.1))': + '@vitest/mocker@3.2.4(msw@2.12.1(@types/node@24.9.2)(typescript@5.8.3))(vite@7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.47.1)(tsx@4.20.6)(yaml@2.8.1))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.12.1(@types/node@24.9.2)(typescript@5.8.3) - vite: 7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.1) + vite: 7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.47.1)(tsx@4.20.6)(yaml@2.8.1) - '@vitest/mocker@3.2.4(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(vite@7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.1))': + '@vitest/mocker@3.2.4(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(vite@7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.47.1)(tsx@4.20.6)(yaml@2.8.1))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.12.1(@types/node@24.9.2)(typescript@5.9.3) - vite: 7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.1) + vite: 7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.47.1)(tsx@4.20.6)(yaml@2.8.1) - '@vitest/mocker@3.2.4(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(vite@7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1))': + '@vitest/mocker@3.2.4(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(vite@7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.1))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.12.1(@types/node@24.9.2)(typescript@5.9.3) - vite: 7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) + vite: 7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.1) '@vitest/pretty-format@3.2.4': dependencies: @@ -12042,7 +12362,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) + vitest: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.1) '@vitest/utils@3.2.4': dependencies: @@ -12290,17 +12610,13 @@ snapshots: dependencies: acorn: 8.16.0 - acorn-jsx@5.3.2(acorn@8.15.0): - dependencies: - acorn: 8.15.0 - acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 acorn-walk@8.3.4: dependencies: - acorn: 8.15.0 + acorn: 8.16.0 acorn@8.15.0: {} @@ -13363,6 +13679,8 @@ snapshots: electron-to-chromium@1.5.249: {} + elm-tooling@1.17.0: {} + emittery@0.13.1: {} emoji-regex@8.0.0: {} @@ -13550,6 +13868,35 @@ snapshots: '@esbuild/win32-ia32': 0.27.2 '@esbuild/win32-x64': 0.27.2 + esbuild@0.28.0: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.0 + '@esbuild/android-arm': 0.28.0 + '@esbuild/android-arm64': 0.28.0 + '@esbuild/android-x64': 0.28.0 + '@esbuild/darwin-arm64': 0.28.0 + '@esbuild/darwin-x64': 0.28.0 + '@esbuild/freebsd-arm64': 0.28.0 + '@esbuild/freebsd-x64': 0.28.0 + '@esbuild/linux-arm': 0.28.0 + '@esbuild/linux-arm64': 0.28.0 + '@esbuild/linux-ia32': 0.28.0 + '@esbuild/linux-loong64': 0.28.0 + '@esbuild/linux-mips64el': 0.28.0 + '@esbuild/linux-ppc64': 0.28.0 + '@esbuild/linux-riscv64': 0.28.0 + '@esbuild/linux-s390x': 0.28.0 + '@esbuild/linux-x64': 0.28.0 + '@esbuild/netbsd-arm64': 0.28.0 + '@esbuild/netbsd-x64': 0.28.0 + '@esbuild/openbsd-arm64': 0.28.0 + '@esbuild/openbsd-x64': 0.28.0 + '@esbuild/openharmony-arm64': 0.28.0 + '@esbuild/sunos-x64': 0.28.0 + '@esbuild/win32-arm64': 0.28.0 + '@esbuild/win32-ia32': 0.28.0 + '@esbuild/win32-x64': 0.28.0 + escalade@3.2.0: {} escape-html@1.0.3: {} @@ -13720,8 +14067,8 @@ snapshots: espree@9.6.1: dependencies: - acorn: 8.15.0 - acorn-jsx: 5.3.2(acorn@8.15.0) + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) eslint-visitor-keys: 3.4.3 esprima@4.0.1: {} @@ -16955,12 +17302,12 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 - terser: 5.46.2 + terser: 5.47.1 webpack: 5.102.1(@swc/core@1.15.30(@swc/helpers@0.5.21)) optionalDependencies: '@swc/core': 1.15.30(@swc/helpers@0.5.21) - terser@5.46.2: + terser@5.47.1: dependencies: '@jridgewell/source-map': 0.3.11 acorn: 8.16.0 @@ -17442,13 +17789,13 @@ snapshots: core-util-is: 1.0.2 extsprintf: 1.3.0 - vite-node@3.2.4(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.1): + vite-node@3.2.4(@types/node@24.9.2)(jiti@2.6.1)(terser@5.47.1)(tsx@4.20.6)(yaml@2.8.1): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.1) + vite: 7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.47.1)(tsx@4.20.6)(yaml@2.8.1) transitivePeerDependencies: - '@types/node' - jiti @@ -17463,13 +17810,13 @@ snapshots: - tsx - yaml - vite-node@3.2.4(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1): + vite-node@3.2.4(@types/node@24.9.2)(jiti@2.6.1)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.1): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) + vite: 7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.1) transitivePeerDependencies: - '@types/node' - jiti @@ -17484,7 +17831,7 @@ snapshots: - tsx - yaml - vite@7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.1): + vite@7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.47.1)(tsx@4.20.6)(yaml@2.8.1): dependencies: esbuild: 0.27.2 fdir: 6.5.0(picomatch@4.0.4) @@ -17496,11 +17843,11 @@ snapshots: '@types/node': 24.9.2 fsevents: 2.3.3 jiti: 2.6.1 - terser: 5.46.2 + terser: 5.47.1 tsx: 4.20.6 yaml: 2.8.1 - vite@7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1): + vite@7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.1): dependencies: esbuild: 0.27.2 fdir: 6.5.0(picomatch@4.0.4) @@ -17512,7 +17859,7 @@ snapshots: '@types/node': 24.9.2 fsevents: 2.3.3 jiti: 2.6.1 - terser: 5.46.2 + terser: 5.47.1 tsx: 4.21.0 yaml: 2.8.1 @@ -17520,13 +17867,13 @@ snapshots: dependencies: cssfontparser: 1.2.1 moo-color: 1.0.3 - vitest: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) + vitest: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.1) - vitest@3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.1(@types/node@24.9.2)(typescript@5.8.3))(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.1): + vitest@3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.1(@types/node@24.9.2)(typescript@5.8.3))(terser@5.47.1)(tsx@4.20.6)(yaml@2.8.1): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(msw@2.12.1(@types/node@24.9.2)(typescript@5.8.3))(vite@7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.1)) + '@vitest/mocker': 3.2.4(msw@2.12.1(@types/node@24.9.2)(typescript@5.8.3))(vite@7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.47.1)(tsx@4.20.6)(yaml@2.8.1)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -17544,8 +17891,8 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.1) - vite-node: 3.2.4(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.1) + vite: 7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.47.1)(tsx@4.20.6)(yaml@2.8.1) + vite-node: 3.2.4(@types/node@24.9.2)(jiti@2.6.1)(terser@5.47.1)(tsx@4.20.6)(yaml@2.8.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.9.2 @@ -17565,11 +17912,11 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.1): + vitest@3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(terser@5.47.1)(tsx@4.20.6)(yaml@2.8.1): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(vite@7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.1)) + '@vitest/mocker': 3.2.4(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(vite@7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.47.1)(tsx@4.20.6)(yaml@2.8.1)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -17587,8 +17934,8 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.1) - vite-node: 3.2.4(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.1) + vite: 7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.47.1)(tsx@4.20.6)(yaml@2.8.1) + vite-node: 3.2.4(@types/node@24.9.2)(jiti@2.6.1)(terser@5.47.1)(tsx@4.20.6)(yaml@2.8.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.9.2 @@ -17608,11 +17955,11 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1): + vitest@3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.1): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(vite@7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) + '@vitest/mocker': 3.2.4(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(vite@7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.1)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -17630,8 +17977,8 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) - vite-node: 3.2.4(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) + vite: 7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.1) + vite-node: 3.2.4(@types/node@24.9.2)(jiti@2.6.1)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.9.2 diff --git a/tsconfig.json b/tsconfig.json index c22692ddac..5d08a69485 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -84,6 +84,15 @@ }, { "path": "./tools/api-report" + }, + { + "path": "./packages/devtools-types" + }, + { + "path": "./packages/devtools-bridge" + }, + { + "path": "./packages/devtools-extension" } ] }