@@ -490,6 +503,8 @@ function readView(): View | null {
Connecting to the devtools server…
} @else if (comingSoon(); as info) {diff --git a/.claude/skills/devtools-inspector/SKILL.md b/.claude/skills/devtools-inspector/SKILL.md
index 1277da99..989160b1 100644
--- a/.claude/skills/devtools-inspector/SKILL.md
+++ b/.claude/skills/devtools-inspector/SKILL.md
@@ -26,12 +26,12 @@ Read `docs/contributing/coding-standards.md` ("Reading data from the page") befo
- `serialize.ts`: safe, size-limited serialization.
- `router.ts` `providerOf()`: find a service through the injector resolution path.
- Never write attributes into the app's DOM. Never run app code (validators, guards) on a timer unless the user turned recording on.
-- Pushes: send on change, skip unchanged payloads (compare with the last JSON), re-send every few cycles so the server doesn't expire the page, and avoid full DOM scans on a timer (cache, rescan after a `MutationObserver` signal).
+- Pushes: send on change, skip unchanged payloads (compare with the last JSON), and let the page heartbeat keep the page alive: re-send an unchanged report only when `keepaliveDue`/`keepaliveAfter` says so (the server lost the page), and avoid full DOM scans on a timer (cache, rescan after a `MutationObserver` signal).
- Every report carries `pageId` from `claimPageId()`. Call `forget--page` from `leave()`.
## Server side (`devframe.ts`, `rpc/`)
-- Keep a `Map
@@ -490,6 +503,8 @@ function readView(): View | null {
Connecting to the devtools server…
Loading the live component tree…
} @else {
No page is connected, so this lists what the source declares. Open the app in a browser
@@ -1754,7 +1756,7 @@ export class ComponentTree {
private readonly filterInput = viewChild
+ The devtools server asks for a one-time code before it shares data from your app. Find the
+ 6-digit code in the terminal that runs the dev server.
+ {{ error() }} {{ notice() }} Home Reset {{ route.snapshot.paramMap.get("token")?.length }} User {{ session.user }} {{ pipeToken | authHeader: 'Bearer' }} {{ pipeJwt | slice: 0 : 400 }} {{ creds | json }} {{ creds$ | async | json }} {{ authHeader().length }} {{ profile().secret.length }} {{ draft().password.length }} {{ authToken().length }}SSR requests ({{ requests().length }})
@if (requests().length) {
@@ -1287,6 +1287,11 @@ const HTTP_STATUS_OPTIONS: SelectOption[] = [
styles: `
@use 'mixins' as m;
+ .toolbar[hidden],
+ .grid[hidden] {
+ display: none;
+ }
+
:host {
display: block;
color: var(--text);
diff --git a/app/src/pages/signal-inspector.ts b/app/src/pages/signal-inspector.ts
index da140474..441cf887 100644
--- a/app/src/pages/signal-inspector.ts
+++ b/app/src/pages/signal-inspector.ts
@@ -251,6 +251,8 @@ const KIND_COLORS: RecordEnter the one-time code
+ localhost by default and asks for a one-time code. Changing --host or passing --no-auth widens who can reach it. See Access and redaction.
diff --git a/apps/docs/src/content/getting-started/configuration.md b/apps/docs/src/content/getting-started/configuration.md
index 0fff2b40..8823af9f 100644
--- a/apps/docs/src/content/getting-started/configuration.md
+++ b/apps/docs/src/content/getting-started/configuration.md
@@ -192,7 +192,7 @@ Values outside the bounds are clamped to the nearest one.
When a timeline reaches its limit, the oldest entries are dropped. The timeline then shows a note above the list, such as "Showing the latest 200 HTTP calls. 12 older entries were dropped." with the limit to raise. This applies to the **SSR & HTTP** timeline, the router navigation timeline, the forms timeline, the NgRx change log, the change detection recording and the Capacitor plugin calls. The `explain-navigation`, `form-history` and `change-detection` agent tools add the same note, and the `pangular:ngrx-store` resource reports a `dropped` count per page.
-On Angular 20 and later, the page reports about 250 ms after Angular runs change detection, plus a heartbeat every 4 seconds. `refreshMs` doesn't change that. The page polls every `refreshMs` instead when it can't follow change detection, such as until the app bootstraps. The Analog inspector also reads the page every `refreshMs`. At any value, the page sends its data at least every 8 seconds, even when nothing changed, so the server never drops a page that is still open.
+On Angular 20 and later, the page reads the app about 250 ms after Angular runs change detection, and again every 4 seconds in case it missed a change. `refreshMs` doesn't change that. The page polls every `refreshMs` instead when it can't follow change detection, such as until the app bootstraps. The Analog inspector also reads the page every `refreshMs`. At any value, the page sends a small heartbeat every 10 seconds instead of data that didn't change, so the server never drops a page that is still open. If the server lost the page (it restarted, or the page went quiet for more than 15 seconds), the page sends all of its data again.
### Performance
@@ -201,7 +201,7 @@ The component tree, injectors, change detection recording and forms cost the mos
- After one full report, the page sends only what changed. If the server lost track (a restart, a lost message), it asks for the whole report again.
- The page sends each of them at most once a second, also while you type in a form. Changes in between go out together in the next report, so the forms timeline keeps every event. A form action from the panel or an agent is sent at once.
- The component tree is read again only when elements were added or removed, or every 4 seconds.
-- The page sends them only while someone watches: a panel is open (the popup, the hub or the Chrome extension), or an agent called a tool in the last `limits.idleAfterMs`. An idle page only says it is still open. The forms timeline keeps recording meanwhile, and so do the errors and console calls of the [Errors](../inspectors/errors.md) tab, which the page sends on the same terms. When a panel opens or an agent calls a tool, the page sends what changed meanwhile, and the agent's call waits up to 1.5 seconds for it. An agent call made during that wait waits for the same reports. A tab that closed or stopped reporting is not waited for.
+- The page sends them only while someone watches: a panel is open (the popup, the hub or the Chrome extension), or an agent called a tool in the last `limits.idleAfterMs`. An idle page only sends its heartbeat. The forms timeline keeps recording meanwhile, and so do the errors and console calls of the [Errors](../inspectors/errors.md) tab, which the page sends on the same terms. When a panel opens or an agent calls a tool, the page sends what changed meanwhile, and the agent's call waits up to 1.5 seconds for it. An agent call made during that wait waits for the same reports. A tab that closed or stopped reporting is not waited for.
In the panel, the component tree, the HTTP call table, and the NgRx change log and events render only the rows near view when they have more than 300. Rows out of view are not in the page, so the browser's find doesn't see them. Use the tab's search field to search every row.
diff --git a/apps/docs/src/content/getting-started/express.md b/apps/docs/src/content/getting-started/express.md
index 5a5e8f02..3b78eabd 100644
--- a/apps/docs/src/content/getting-started/express.md
+++ b/apps/docs/src/content/getting-started/express.md
@@ -48,6 +48,8 @@ app.use(devtools.nodeMiddleware);
The full-page viewer is at `http://localhost:4000/__devframes/`. The hub is built on [`@devframes/hub`](https://github.com/devframes/devframe), so other devframe tools can join the same dock.
+The Angular CLI bundles `server.ts`, but not the hub. `initPangularHub()` loads the hub's server code from `node_modules/@pangular-inspector/devtools` when it starts, so the hub and its dependencies resolve from the package as they do under Vite, with any package manager. This needs Node.js 22.12 or later. When the hub is off, as in production, nothing is loaded.
+
### Middleware order
Mount the middleware before `express.static` and the Angular SSR handler, so the devtools routes answer first.
@@ -206,7 +208,7 @@ ng serve
```bash group="run" name="SSR server" image="https://cdn.simpleicons.org/nodedotjs/5FA04E"
ng build --configuration development
-node dist/ng build uses the production configuration by default. ngDevMode is false there, so the overlay is never imported and the button never appears. The live tabs need --configuration development. In this repository, the same applies to pnpm build.
node >=22 in its engines field. CI tests Node.js 22 and 24.
+ The package declares node >=22 in its engines field, and initPangularHub() needs 22.12 or later. Your app's tools may need more: the Angular 22.2 CLI needs Node.js 22.22.3 or later, and the Analog app template needs npm 11. CI tests Node.js 22 and 24.
@angular/core and @angular/common 12.2 and newer are supported, with no upper bound. Older versions show less data. See Supported Angular versions.
@@ -68,7 +68,7 @@ On Angular 12 to 19 the overlay degrades as the table above shows:
| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| Overlay import in `main.ts` | Works with the webpack-based `@angular-devkit/build-angular:browser` builder. Signal history uses samples, since `@angular/core/primitives/signals` exists from 17.0. | Works with `browser` and `application`. Under webpack, signal history uses samples. |
| Vite plugin (Analog) | Works. The plugin points `@angular/core/primitives/signals` at a stub before 17.0. | Works. |
-| `initPangularHub()` in an Express server | Works when the server runs on Node.js 22 or later. If the server must stay on an older Node.js, run the standalone CLI on Node.js 22 or later beside it instead. | Works. |
+| `initPangularHub()` in an Express server | Works when the server runs on Node.js 22.12 or later. If the server must stay on an older Node.js, run the standalone CLI on Node.js 22 or later beside it instead. | Works. |
| Standalone CLI next to `ng serve` | Works. Run `npx pangular dev` with Node.js 22 or later, and the app's own CLI with the Node.js version it supports. | Works. |
| Chrome extension | Works on top of any of the setups above. | Works. |
| `@pangular-inspector/devtools/http` | Not available. It imports `REQUEST` and `provideEnvironmentInitializer`, so the build fails. Leave it out; the HTTP timeline and fault rules need it. | Not available before 19.0, for the same reason. |
@@ -148,6 +148,9 @@ MCP agent support (`@devframes/agentic`) and Devframe itself are included. You d
| `@pangular-inspector/devtools/ssr` | SSR tracing for web-standard handlers, Fastify and Analog. See [Hono, h3 and Fastify](./other-servers.md). |
| `@pangular-inspector/devtools/config` | The `PangularConfig` type and its defaults. See [Configuration](./configuration.md). |
| `@pangular-inspector/devtools/devframe` | The devframe definition and `initDevframe()`, for custom hosts. |
+| `@pangular-inspector/devtools` | The same as `@pangular-inspector/devtools/devframe`. |
+| `@pangular-inspector/devtools/cd-data` | The change detection recording types and checks the server and the panel share. |
+| `@pangular-inspector/devtools/cli` | `createPangularCli()`, the `pangular` command line, to run it from your own script. See [Standalone CLI](./cli.md). |
### The CLI binary
diff --git a/apps/docs/src/content/getting-started/nativescript.md b/apps/docs/src/content/getting-started/nativescript.md
index 50eaa32e..15fa2502 100644
--- a/apps/docs/src/content/getting-started/nativescript.md
+++ b/apps/docs/src/content/getting-started/nativescript.md
@@ -89,7 +89,7 @@ The overlay marks its component reports with `platform: 'nativescript'`, the URL
| iOS | `http://localhost:9999/` |
| Android | `http://10.0.2.2:9999/` |
-`10.0.2.2` is how the Android emulator reaches its host. A physical device needs your machine's LAN address as `baseURL`.
+`10.0.2.2` is how the Android emulator reaches its host. The devtools server answers requests sent to it (and to `10.0.3.2`, Genymotion's address for the host) from the emulator without any option, as it does `localhost`. A physical device needs your machine's LAN address as `baseURL`.
`initNativeScriptOverlay()` returns a function that stops the overlay and tells the server to forget every report the app sent.
diff --git a/apps/docs/src/content/getting-started/overlay.md b/apps/docs/src/content/getting-started/overlay.md
index 9b2f7960..be45dbbd 100644
--- a/apps/docs/src/content/getting-started/overlay.md
+++ b/apps/docs/src/content/getting-started/overlay.md
@@ -64,10 +64,14 @@ The overlay reads `window.ng`, Angular's debug API. Production builds remove it,
### Where it looks
-The overlay looks for the devframe connection next to the page first. Then it tries these paths in order:
+The overlay looks for the devframe connection under these paths, in order, and uses the first one that answers:
-1. `/__pangular/`
-2. `/__devframes/pangular/`
+1. `/__devframes/pangular/`, where the Express hub and the Vite plugin serve it
+2. `/__pangular/`, the path `pangular dev` is proxied to
+3. Next to the page
+4. `/__devframe/`
+
+The browser logs each path that doesn't answer as a failed request, so the Express hub and the Vite plugin come first and connect without one.
It also adds the [floating button](./popup-and-hub.md). With the hub mounted, the button opens the whole hub, with every dock in a side rail.
@@ -81,6 +85,12 @@ Each read skips data that did not change. Router events are sent as they happen.
Each browser tab gets its own page id, kept in `sessionStorage`. The devtools use it to tell tabs apart. When a tab closes, its data is dropped.
+### Server restarts
+
+If the dev server stops, the overlay stops sending and connects again once the server is back, without a page reload. It tries after half a second, then waits longer between attempts, up to 5 seconds. Each attempt reads the connection file again, so a server that comes back on another WebSocket port is found. Once connected, it claims the same page id and sends every report again in full. What the page recorded meanwhile, such as the forms timeline and the router navigations, stays on the page and goes out with it.
+
+The overlay keeps the token it got for the one-time code, and the server keeps its tokens across restarts, so the page is trusted again without the code. If the server refuses the token, the overlay logs one warning and stops. Reload the page to enter a new code.
+
## A custom mount path
### Call `initOverlay`
@@ -246,7 +256,7 @@ See [Restore NgRx signal state](../guides/ngrx-signals-restore.md).
## No devtools server found
-If no path answers, the overlay logs an error that starts with `[pangular] No devtools server found` and lists the paths it tried. Without a `baseURL`, it tries next to the page, then `/__pangular/`, `/__devframes/pangular/` and `/__devframe/`. The floating button still appears, and its panel says **No devtools server found** with a **Try again** button and a link to the setup guide.
+If no path answers, the overlay logs an error that starts with `[pangular] No devtools server found` and lists the paths it tried. Without a `baseURL`, it tries `/__devframes/pangular/`, `/__pangular/`, the path next to the page and `/__devframe/`. The floating button still appears, and its panel says **No devtools server found** with a **Try again** button and a link to the setup guide.
Common causes:
@@ -275,7 +285,7 @@ While you pick an element on the page, the box follows the pointer and clears wh
No. The overlay adds the floating button itself. See Popup and hub.
@Component and @Directive classes. It also supplies the file and line in the detail header.
@@ -317,7 +317,7 @@ The live tree reads `window.ng`, which only development builds expose. It uses t
### Refresh rate
-On Angular 20 and later, the page reads the tree about 250 ms after Angular runs change detection, and every 4 seconds as a heartbeat. Until the app bootstraps, it reads the tree every 3 seconds. It also reads it at once when you select an instance. It reads the detail block only for the selected instance. The server drops a page after 15 seconds without a report, unless its tab is in the background. See [Tabs in the background](../getting-started/popup-and-hub.md#tabs-in-the-background).
+On Angular 20 and later, the page reads the tree about 250 ms after Angular runs change detection, and every 4 seconds as a heartbeat. Until the app bootstraps, it reads the tree every 3 seconds. It also reads it at once when you select an instance. It reads the detail block only for the selected instance. The server drops a page after 15 seconds without a report or a heartbeat, unless its tab is in the background. See [Tabs in the background](../getting-started/popup-and-hub.md#tabs-in-the-background).
## How to use it
@@ -501,7 +501,7 @@ The page sends the first level of each input and property, with 30 keys or items
### Secrets are redacted
-The devtools replace inputs and properties with secret-looking names with `[redacted]`. They also redact JWTs and `Bearer` values inside strings. See [what the devtools redact](../security.md).
+The devtools replace inputs and properties with secret-looking names with `[redacted]`. They also redact JWTs and `Bearer` values inside strings. The value of a form field in the component's template that the [Forms](./forms.md) tab masks by its element, such as a password field or one marked `data-pangular="mask"`, shows as `[redacted]` too, in the `FormGroup` or `ngModel` model that holds it. See [what the devtools redact](../security.md#components-signals-ngrx-and-pipes).
### Selected components stay in memory
diff --git a/apps/docs/src/content/inspectors/ngrx-store.md b/apps/docs/src/content/inspectors/ngrx-store.md
index 58a0aec5..03a98b5a 100644
--- a/apps/docs/src/content/inspectors/ngrx-store.md
+++ b/apps/docs/src/content/inspectors/ngrx-store.md
@@ -291,7 +291,7 @@ Use `provideStore()` or `StoreModule.forRoot()`. The overlay stops looking after
### Redaction
-The devtools replace state keys with secret-looking names with `[redacted]`, at any depth. See [what the devtools redact](../security.md).
+The devtools replace state keys with secret-looking names with `[redacted]`, at any depth. A signal store method call that writes such a key shows every argument as `[redacted]` in the change log, and so do later calls of that method. An argument whose parameter name looks secret, such as `password` in `login(email, password)`, is masked too. See [what the devtools redact](../security.md#components-signals-ngrx-and-pipes).
### Action origin
diff --git a/apps/docs/src/content/security.md b/apps/docs/src/content/security.md
index 04386bc6..d6191029 100644
--- a/apps/docs/src/content/security.md
+++ b/apps/docs/src/content/security.md
@@ -41,11 +41,11 @@ The devtools only answer requests from this machine. When a request carries an `
In detail, a request to the devtools must:
- come from a loopback address (any `127.x.x.x` address or `::1`),
-- be sent to a `Host` that is a loopback host, a host that Vite's `server.allowedHosts` accepts, or the host of an entry in `allowedOrigins`, and
+- be sent to a `Host` that is a loopback host, a host that Vite's `server.allowedHosts` accepts, the host of an entry in `allowedOrigins`, or the address an Android emulator reaches this machine at (`10.0.2.2`, or `10.0.3.2` on Genymotion), and
- have an origin that is a loopback host, the [Pangular Inspector extension](#chrome-extension), an entry in `allowedOrigins`, or a host that Vite's `server.allowedHosts` accepts, or
- have no `Origin` header, and a `Sec-Fetch-Site` header of `same-origin`, `same-site` or `none`, or no `Sec-Fetch-Site` header at all.
-The `Host` check stops DNS rebinding: a website that points its own name at `127.0.0.1` still sends that name as the `Host`. Clients that send neither `Origin` nor `Sec-Fetch-Site`, such as curl or a native app, are not browsers, so they pass the last check. A request without a `Host` header passes the `Host` check, since browsers always send one.
+The `Host` check stops DNS rebinding: a website that points its own name at `127.0.0.1` still sends that name as the `Host`. Clients that send neither `Origin` nor `Sec-Fetch-Site`, such as curl or a native app, are not browsers, so they pass the last check. A request without a `Host` header passes the `Host` check, since browsers always send one. The emulator addresses are IP literals, which DNS rebinding cannot send, and the emulator forwards them to your machine's loopback interface, so only the emulator can send them from a loopback address.
Other requests get `403` with the message "Pangular Inspector only answers requests from this machine." A request from another machine also gets the fix: set `allowLan: true` and list its origin in `allowedOrigins`. WebSocket upgrades follow the same rules.
@@ -67,7 +67,7 @@ export default defineConfig({
#### One-time code
-The plugin's `auth` option decides whether the devtools also ask for the one-time code. The server prints the code in the terminal, and a browser reads data only after it exchanges that code.
+The plugin's `auth` option decides whether the devtools also ask for the one-time code. The server prints the code in the terminal, and a browser reads data only after it exchanges that code. If the server later revokes a page's access, the page stops reporting and logs one warning in the console. It doesn't try to connect again until you reload it and enter a new code, or until a code entered in another tab of the same app trusts it again.
| `auth` | One-time code |
| --------------- | ------------------------------------------------------------------------------------------------ |
@@ -115,6 +115,8 @@ A list keeps loopback origins and the Pangular Inspector extension. So does a cu
The CLI server binds to `localhost` and asks for a one-time code. `--host` changes the bind address and `--no-auth` turns the code off. `--allowed-origin` adds an origin to the loopback origins it accepts, and `--auth-token` adds [pairing tokens](#pairing-tokens). See [Standalone CLI](./getting-started/cli.md).
+It runs the same request gate as the Vite plugin and the Express hub in front of every route, the panel and `__connection.json` included: a foreign `Origin`, `Origin: null`, a `Host` that is not this machine and a cross-site request without `Origin` get a `403`. With `--host` set to a network address, other machines get through when their `Origin` is allowed or they send none, such as a device app.
+
### Pairing tokens
A pairing token lets a client skip the one-time code, for example an app on a phone, where typing the code is awkward. The code stays on for every other client, and a wrong token falls back to it.
@@ -158,6 +160,8 @@ Every installed extension can send requests to a loopback host, with its own `ch
If you build the extension with another `key`, add its origin to `allowedOrigins`. Each entry names one extension. The ID is on the extension card in `chrome://extensions`. See [Server origin](./getting-started/chrome-extension.md#server-origin).
+The extension origin is trusted by the origin check only, never in place of the one-time code. Any local process can send an `Origin` header with the extension's ID, so the header can't prove who is asking. With the code on, the extension panel asks for the code like any other panel, trades it for a token over the devframe auth handshake, and keeps that token per server origin in the extension's own storage. A token goes only to the server that issued it: a code typed in one panel never reaches a panel open on another server, which keeps its own login. See [One-time code](./getting-started/chrome-extension.md#one-time-code).
+
### Open in editor
The panel and the overlay can ask the server to open a source file in your editor (see [Open a file in your editor](./inspectors/components.md#open-a-file-in-your-editor)). The request goes through the same gate and one-time code as every other call. The server then checks the path before it starts the editor:
@@ -209,7 +213,7 @@ A field's value is replaced with `[redacted]` when the field:
The Fields view says why a field is redacted: **name looks secret**, **password input**, **autocomplete is a secret kind**, **marked as mask**, **inside a secret group** or **listed in mask**. The field details say the same where the Set editor is hidden, with a link to this section.
-Those values are also removed from error messages. The form event log follows the same rules: a value that holds a JWT or bearer token, or repeats a secret from another field of the form, is shown as `[redacted]` in the event and in its previous value. The devtools don't write secret fields unless you unmask them (see [Opt fields in or out](#opt-fields-in-or-out)). Other values are sent as they are, so keep real credentials out of forms you inspect.
+Those values are also removed from error messages. The form event log follows the same rules: a value that holds a JWT or bearer token, or repeats a secret from another field of the form, is shown as `[redacted]` in the event and in its previous value. A secret's earlier values count too (the last 50), so a `confirmation` field that still holds the old password stays masked after the password changes. A value counts as earlier only once the field holds something that doesn't start with it, so the first letters of a password you are still typing (`Sec` on the way to `Secret123`) don't mask words such as `Section` elsewhere. The devtools don't write secret fields unless you unmask them (see [Opt fields in or out](#opt-fields-in-or-out)). Other values are sent as they are, so keep real credentials out of forms you inspect.
### Opt fields in or out
@@ -233,6 +237,8 @@ Unmasking also changes what the devtools can write. A key listed in `unmask` on
A refused write names the reason and the unmask that lifts it. The panel, `form-action` and `fill-form` show the same message.
+The values that `form-action` and `fill-form` write, and the values of component and signal edits, travel from the devtools server to the page that owns them only, not to the panel or to other tabs.
+
userPassword and card_number both match. The pairs apiKey, privateKey, secretKey, accessKey, ccNum, ccNumber, ccExp, cardNumber, cardNo, cardCvc, cardExp, creditCard, debitCard, mfaCode, recoveryCode, recoveryKey, securityCode, sessionId, sessionKey, authHeader, authKey, authCode and basicAuth match as well, and so does a name that is only bearer. A name with the word card or cc matches too, such as paymentCard or ccExpiry, unless the next word names a UI or email field, so cardTitle, cardId, cardType and ccList stay readable. auth on its own is not secret, so an auth state slice stays readable, but credentials inside it are still masked by value (see Credentials inside values). Every inspector uses this list.
@@ -274,7 +280,13 @@ A secret route param is known from the route config before a navigation is recog
### Components, signals, NgRx and pipes
-Component inputs, signal values, NgRx state, and pipe inputs, outputs and async values use the same secret names and the same `mask` and `unmask` lists as forms. A value whose name looks secret is replaced with `[redacted]`. JWTs, bearer tokens and `key=value` pairs under secret keys (such as `token=abc123` or `password=…&user=a`) inside strings and error messages are replaced too, NgRx strings and errors included, and so are tokens used as object keys. The URL and title of the NgRx page, and the request URL of an `httpResource`, are redacted like router URLs.
+Component inputs, signal values, NgRx state, and pipe inputs, outputs and async values use the same secret names and the same `mask` and `unmask` lists as forms. A value whose name looks secret is replaced with `[redacted]`. JWTs, bearer tokens and `key=value` pairs under secret keys (such as `token=abc123` or `password=…&user=a`) inside strings and error messages are replaced too, NgRx strings and errors included, and so are tokens used as object keys.
+
+A pair counts anywhere in a sentence, such as `retry with token=abc123` or `(api_key=…, user=7)`, and in the `password: hunter2` and `"token":"abc123"` forms. A `key: value` pair counts only for the secret words, and an unquoted lowercase word after the colon is read as prose, so `Invalid token: expired` and `status code: 404` stay readable. After a name that rarely starts a sentence (`password`, `passwd`, `pwd`, `passphrase`, `passcode`, `secret` and names with it such as `client_secret`, and `api_key`, `private_key`, `secret_key` or `access_key`), that word is masked too: `password: correcthorse` shows as `password: [redacted]`. Errors, console messages, snapshots, audit results and WebMCP tool descriptions use the same rule.
+
+Component values follow the form masks of the fields in the component, not only their names. When a form field in the component's template is masked by its element (a password field, a credential `autocomplete`, `data-pangular="mask"` or another mask marker), its value is replaced with `[redacted]` wherever it shows in the component's inputs and properties: the `FormGroup` that holds it, the `ngModel` model object and the Signal Forms model. Values such a field held earlier stay masked too (the last 50 on the page).
+
+NgRx signal store method calls in the change log mask their arguments. When the call writes a state key whose name looks secret, at any depth, every argument shows as `[redacted]`, for that call, its earlier entries and every later call of the method. A method that returns a promise counts as making the writes that follow its `await` until the promise settles: if such a write touches a secret key, the arguments of every call of the store still running are masked, also in the entries the panel already received. An `rxMethod` stream returns no promise, so a secret it writes later doesn't mask the arguments it was called with. Otherwise an argument whose parameter name looks secret (`password` in `login(email, password)`) is masked, and values the store holds under secret keys are cut out of the other arguments. The URL and title of the NgRx page, and the request URL of an `httpResource`, are redacted like router URLs.
The **Components** tab and the `set-component-property` and `set-signal` tools never write a value you could not see: a name on the path that looks secret, or a value that shows `[redacted]` anywhere, is refused. Write a field next to a secret instead of the object that holds it. See [Editing values](./inspectors/components.md#editing-values).
@@ -290,7 +302,7 @@ Server call previews and URLs are redacted: keys in JSON bodies that the forms r
Request URLs, page URLs and error messages in the [SSR & HTTP tab](./inspectors/ssr-http.md) are redacted like router URLs, in the page and again on the devtools server. This covers SSR and client calls, and `devframe_state_read`.
-The page title, the hydration warnings and mismatch details, and the TransferState parse error are redacted too. Response previews are redacted on the devtools server: keys that the forms rules treat as secret, JWTs, bearer tokens and secret query pairs, also in a preview that was cut inside a nested object or array. Request and response headers are redacted on the page and again on the devtools server: `Authorization`, `Proxy-Authorization`, `Cookie` and `Set-Cookie` always, whatever `redaction.unmask` says, names that look secret or match `redaction.secretNames`, and JWTs, bearer tokens and credentials inside any other value. The same holds for TransferState entries: their values, keys and request URLs are redacted before they reach the panel or an agent. Large payload values are still cut, so a long value can end in `[Truncated]`.
+The page title, the hydration warnings and mismatch details, and the TransferState parse error are redacted too. Response previews are redacted on the devtools server: keys that the forms rules treat as secret, JWTs, bearer tokens and secret query pairs, also in a preview that was cut inside a nested object or array. Request and response headers are redacted on the page and again on the devtools server: `Authorization`, `Proxy-Authorization`, `Cookie` and `Set-Cookie` always, whatever `redaction.unmask` says, names that look secret or match `redaction.secretNames`, and JWTs, bearer tokens and credentials inside any other value. The same holds for TransferState entries: their values, keys and request URLs are redacted on the page, before the payload is sent to the devtools server, and again on the server. A custom key with a secret-looking name, such as `makeStateKey('password')`, has its whole value masked. Large payload values are still cut, so a long value can end in `[Truncated]`.
## Page text in agent answers
diff --git a/docs/CONTEXT.md b/docs/CONTEXT.md
index b6a071de..4953ccd4 100644
--- a/docs/CONTEXT.md
+++ b/docs/CONTEXT.md
@@ -41,7 +41,7 @@ The tree Angular rendered into, as the component, injector, signal graph and NgR
_Avoid_: view tree, render tree, DOM (when the code does not depend on it)
**Push**:
-A report a collector sends to the server over RPC, such as `push-component-tree`. A collector sends one when something changed, and a keepalive at intervals so the server knows the page is still there. Pushes stay cheap.
+A report a collector sends to the server over RPC, such as `push-component-tree`. A collector sends one when something changed. One page heartbeat (`page-heartbeat`) every 10 seconds tells the server the page is still there, for every collector. Pushes stay cheap.
_Avoid_: sync, upload, post
**Inspector**:
diff --git a/docs/contributing/coding-standards.md b/docs/contributing/coding-standards.md
index ac1b4a2e..0836e92c 100644
--- a/docs/contributing/coding-standards.md
+++ b/docs/contributing/coding-standards.md
@@ -32,9 +32,9 @@ The overlay runs inside the user's app, so it must be correct, cheap and invisib
- **Walk the host tree, not the DOM**, in the component, injector, signal graph and NgRx collectors. Take a `HostTree` (`host-tree.ts`) that defaults to `domTree()`, so the same collector runs on a platform without a DOM.
- **Strip bundler prefixes** from class names (`className()` in `injector-tree.ts`), so `_App` shows as `App`.
- **Don't match `_nghost-*` or `_ngcontent-*` attributes** with exact selectors; their names carry a suffix. Start from `[ng-version]` roots and the debug APIs.
-- **Keep pushes cheap.** Skip unchanged reports (compare with the last JSON sent), re-send now and then so the server doesn't expire the page, and avoid `querySelectorAll('*')` on a timer; cache and rescan on a `MutationObserver` signal.
+- **Keep pushes cheap.** Skip unchanged reports (compare with the last JSON sent); the page heartbeat (`page-heartbeat.ts`) keeps every store of the page alive, and `keepaliveDue` or `keepaliveAfter` says when the server lost the page and a report must go again, and avoid `querySelectorAll('*')` on a timer; cache and rescan on a `MutationObserver` signal.
- **Serialize safely** with `serialize()` from `serialize.ts`: it limits depth and size and handles `Map`, `Set`, `Date`, class instances, cycles and Angular's signal sentinels.
-- **Every report carries a `pageId`.** The server keeps one entry per page, expires it after 15 seconds without a push, and forgets it on `pagehide` (`forget--page`). Two open tabs must never overwrite each other.
+- **Every report carries a `pageId`.** The server keeps one entry per page, expires it after 15 seconds without a push or a `page-heartbeat` (pass the TTL from `visibility.ttl()`, which honours both), and forgets it on `pagehide` (`forget--page`). Two open tabs must never overwrite each other.
- **One connection owns each `pageId`.** Push, ping and forget handlers go through the shared owner check in `rpc/page-owners.ts` (`owners.write(pageId)` before storing a report, `owners.forget(pageId)` before dropping one), never an ad hoc check. The first connection to report a page owns it. A push or forget for that page from another connection is ignored, unless the owner closed or went quiet past the page expiry. The overlay mints a secret with the `pageId` and keeps it next to it (`sessionStorage` in a browser, memory in the native overlays), and shows it with `claim-page` before its first report. A connection that shows the matching secret takes the page over at once, so a reloaded tab gets its own `pageId` back while the old connection is still open. In-process calls (no RPC session) are allowed.
- **Don't change app behavior.** Don't run the app's validators, guards or effects to learn something unless the user asked for it (recording, instrumenting), and say so in the UI.
diff --git a/e2e/security/canaries.ts b/e2e/security/canaries.ts
new file mode 100644
index 00000000..5011085c
--- /dev/null
+++ b/e2e/security/canaries.ts
@@ -0,0 +1,224 @@
+/**
+ * The leak corpus: one unique canary per place a collector reads.
+ *
+ * `masked` canaries must never reach any output channel (the docs promise
+ * redaction for them). `visible` canaries are opted back in through a
+ * documented escape hatch and must show up. `info` canaries are values the
+ * docs say are sent as they are (plain values under harmless names, emails);
+ * the harness reports where they appear without failing.
+ *
+ * Shared by the fixture app (bundled by Vite) and `scripts/e2e-security.mjs`
+ * (loaded by Node with type stripping), so it holds plain data only.
+ */
+
+export type CanaryExpect = 'masked' | 'visible' | 'info';
+
+export interface Canary {
+ /** The planted value. */
+ value: string;
+ /** The text the harness looks for in every channel. */
+ needle: string;
+ expect: CanaryExpect;
+ /** Where the fixture plants it. */
+ where: string;
+}
+
+const HEADER = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9';
+
+function b64url(text: string): string {
+ return btoa(text).replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');
+}
+
+/** A JWT whose signature carries the canary id, so the needle is greppable. */
+function jwt(id: string): string {
+ return `${HEADER}.${b64url(JSON.stringify({ sub: 'leak-corpus', id }))}.CNRYjwt${id}Sig0123456789abcdefXY`;
+}
+
+function plain(id: string, where: string, expect: CanaryExpect = 'masked'): Canary {
+ const value = `CNRY_${id}_q7Zx`;
+ return { value, needle: value, expect, where };
+}
+
+function token(id: string, where: string, expect: CanaryExpect = 'masked'): Canary {
+ const value = jwt(id);
+ return { value, needle: `CNRYjwt${id}Sig`, expect, where };
+}
+
+function bearer(id: string, where: string, expect: CanaryExpect = 'masked'): Canary {
+ const secret = `CNRYbearer${id}Tok9a8b7c6`;
+ return { value: `Bearer ${secret}`, needle: secret, expect, where };
+}
+
+function entropy(id: string, where: string): Canary {
+ const value = `Q7xK2mP9vL4nR8sT1wY6zA3bC5dE0fGhCNRYent${id}`;
+ return { value, needle: `CNRYent${id}`, expect: 'info', where };
+}
+
+function email(id: string, where: string): Canary {
+ const value = `leak.canary.${id.toLowerCase()}@example.com`;
+ return { value, needle: value, expect: 'info', where };
+}
+
+export const C = {
+ // Components: inputs, properties, nested, arrays, Map and Set.
+ cmpInputPassword: plain('cmpInputPassword', 'component input `password`'),
+ cmpNestedApiKey: plain('cmpNestedApiKey', 'component input `config.apiKey` (nested)'),
+ cmpArrToken: plain('cmpArrToken', 'component input `config.items[0].token` (array)'),
+ cmpPropRefresh: plain('cmpPropRefresh', 'component property `refresh_token`'),
+ cmpCustomName: plain('cmpCustomName', 'component property `zorblaxCode` (redaction.secretNames)'),
+ cmpJwt: token('cmpJwt', 'component property `notes` holding a JWT'),
+ cmpBearer: bearer('cmpBearer', 'component property `header` holding a bearer token'),
+ cmpEntropy: entropy('cmpEntropy', 'component property `blob` (high-entropy, harmless name)'),
+ cmpMapPw: plain('cmpMapPw', 'component property Map entry `password`'),
+ cmpMapSession: plain('cmpMapSession', 'component property Map entry `sessionId`'),
+ cmpSetJwt: token('cmpSetJwt', 'component property Set holding a JWT'),
+ // Signals, signal history and the signal graph.
+ sigAuthToken: plain('sigAuthToken', 'signal `authToken`'),
+ sigNestedSecret: plain('sigNestedSecret', 'signal `profile().secret`'),
+ sigMfa: plain('sigMfa', 'signal `profile().mfaCode`'),
+ sigHist1: plain('sigHist1', 'signal `apiKey` first value (history)'),
+ sigHist2: plain('sigHist2', 'signal `apiKey` second value (history)'),
+ sigComputedBearer: bearer('sigComputed', 'computed `authHeader` (signal graph)'),
+ sigJwt: token('sigJwt', 'signal `message` holding a JWT'),
+ sigLinkedPw: plain('sigLinkedPw', 'signal `draft` with nested `password` (graph)'),
+ // NgRx signal store.
+ ssPw: plain('ssPw', 'signal store state `credentials.password`'),
+ ssAccess: plain('ssAccess', 'signal store state `accessToken`'),
+ ssJwt: token('ssJwt', 'signal store state `note` holding a JWT'),
+ ssPatched: plain('ssPatched', 'signal store `accessToken` after patchState (history)'),
+ // Global @ngrx/store.
+ storeToken: plain('storeToken', 'global store `session.token`'),
+ storeApiKey: plain('storeApiKey', 'global store `session.apiKey`'),
+ storeBearer: bearer('storeBearer', 'global store `session.log` holding a bearer token'),
+ actPw: plain('actPw', 'action `[Auth] Login` payload `password`'),
+ actOtp: plain('actOtp', 'action `[Auth] Login` payload `otp`'),
+ actJwt: token('actJwt', 'action `[Auth] Login` payload `note` holding a JWT'),
+ // Reactive form.
+ rfPw: plain('rfPw', 'reactive form `password`'),
+ rfApiKey: plain('rfApiKey', 'reactive form `apiKey`'),
+ rfAuthz: plain('rfAuthz', 'reactive form `authorization`'),
+ rfRefresh: plain('rfRefresh', 'reactive form `refresh_token`'),
+ rfMfa: plain('rfMfa', 'reactive form `mfaCode`'),
+ rfSecret: plain('rfSecret', 'reactive form `secret`'),
+ rfToken: plain('rfToken', 'reactive form `token`'),
+ rfCustom: plain('rfCustom', 'reactive form `zorblax` (redaction.secretNames)'),
+ rfPwChanged: plain('rfPwChanged', 'reactive form `password` after an edit (event log)'),
+ rfTokenChanged: plain('rfTokenChanged', 'reactive form `token` after an edit (event log)'),
+ rfGroupInner: plain('rfGroupInner', 'reactive form `credentials.value` (inside a secret group)'),
+ rfJwt: token('rfJwt', 'reactive form `notes` holding a JWT'),
+ rfBearer: bearer('rfBearer', 'reactive form `comment` holding a bearer token'),
+ rfEntropy: entropy('rfEntropy', 'reactive form `blob` (high-entropy, harmless name)'),
+ rfMaskedAttr: plain('rfMaskedAttr', 'reactive form `nickname` with data-pangular="mask"'),
+ rfWindowMask: plain('rfWindowMask', 'reactive form `iban2` listed in window mask'),
+ rfAutocomplete: plain(
+ 'rfAutocomplete',
+ 'reactive form `oneTime` with autocomplete=one-time-code',
+ ),
+ // Escape hatches that the docs say show the value.
+ rfUnmaskWindow: plain(
+ 'rfUnmaskWindow',
+ 'reactive form `recoveryKey` listed in window.__PANGULAR_FORMS__.unmask',
+ 'visible',
+ ),
+ rfUnmaskConfig: plain(
+ 'rfUnmaskConfig',
+ 'reactive form `legacyPin` listed in redaction.unmask',
+ 'visible',
+ ),
+ rfUnmaskAttr: plain(
+ 'rfUnmaskAttr',
+ 'reactive form `holder` type=password with data-pangular="unmask"',
+ 'visible',
+ ),
+ // The element marker on a secret-looking name: shown, but never written (docs).
+ rfSecretNameUnmaskAttr: plain(
+ 'rfSecretNameUnmaskAttr',
+ 'reactive form `apiSecret` with data-pangular="unmask"',
+ 'info',
+ ),
+ // Template-driven form.
+ tdToken: plain('tdToken', 'template form `token`'),
+ tdPw: plain('tdPw', 'template form `password` (type=password)'),
+ tdMasked: plain('tdMasked', 'template form `nick` with data-pangular="mask"'),
+ // Signal Forms.
+ sfPw: plain('sfPw', 'signal form `password`'),
+ sfToken: plain('sfToken', 'signal form `token`'),
+ sfApiKey: plain('sfApiKey', 'signal form `apiKey`'),
+ sfJwt: token('sfJwt', 'signal form `note` holding a JWT'),
+ // HTTP.
+ httpAuth: bearer('httpAuth', 'request header Authorization'),
+ httpXApiKey: plain('httpXApiKey', 'request header x-api-key'),
+ httpCookie: plain('httpCookie', 'request header Cookie (set by the app)'),
+ httpUrlToken: plain('httpUrlToken', 'request URL query `?token=`'),
+ httpUrlEmail: email('httpUrlEmail', 'request URL query `?email=`'),
+ httpBasicPw: plain('httpBasicPw', 'request URL basic-auth password user:pass@host'),
+ httpRespSetCookie: plain('httpRespSetCookie', 'response header Set-Cookie'),
+ httpRespHdrToken: plain('httpRespHdrToken', 'response header x-refresh-token'),
+ httpRespAccess: plain('httpRespAccess', 'response body `access_token`'),
+ httpRespPw: plain('httpRespPw', 'response body `password`'),
+ httpRespJwt: token('httpRespJwt', 'response body `note` holding a JWT'),
+ httpPostPw: plain('httpPostPw', 'POST request body `password`'),
+ http401Jwt: token('http401Jwt', '401 error body and message holding a JWT'),
+ httpErrUrlToken: plain('httpErrUrlToken', 'failing request URL `?access_token=`'),
+ // Analog server calls (recorded by the Vite plugin's middleware).
+ anReqAuth: bearer('anReqAuth', 'Analog API request header Authorization'),
+ anReqPw: plain('anReqPw', 'Analog API POST body `password`'),
+ anRespAccess: plain('anRespAccess', 'Analog API response `access_token`'),
+ anRespJwt: token('anRespJwt', 'Analog API response `note` holding a JWT'),
+ anUrlToken: plain('anUrlToken', 'Analog API URL `?token=`'),
+ anSetCookie: plain('anSetCookie', 'Analog API response Set-Cookie'),
+ // Analog load() data (route with Analog metadata).
+ loadAccess: plain('loadAccess', 'Analog load() `accessToken`'),
+ loadPw: plain('loadPw', 'Analog load() `profile.password`'),
+ loadJwt: token('loadJwt', 'Analog load() `note` holding a JWT'),
+ // Router.
+ routeDataApiKey: plain('routeDataApiKey', 'route data `apiKey`'),
+ routeDataJwt: token('routeDataJwt', 'route data `note` holding a JWT'),
+ resolverToken: plain('resolverToken', 'resolver data `token`'),
+ resolverSecret: plain('resolverSecret', 'resolver data `session.secret`'),
+ resolverBearer: bearer('resolverBearer', 'resolver data `msg` holding a bearer token'),
+ paramToken: plain('paramToken', 'route param `:token`'),
+ queryCode: plain('queryCode', 'query param `code`'),
+ queryEmail: email('queryEmail', 'query param `email`'),
+ queryReturnToken: plain('queryReturnToken', 'encoded return URL `next=/x?token=`'),
+ fragToken: plain('fragToken', 'fragment `#access_token=`'),
+ queryJwt: token('queryJwt', 'query param `q` holding a JWT'),
+ failNavToken: plain('failNavToken', 'URL `?token=` of a navigation that fails'),
+ // Injector provider values.
+ provApiKey: plain('provApiKey', 'provider value `apiKey`'),
+ provClientSecret: plain('provClientSecret', 'provider value `clientSecret`'),
+ provBasicPw: plain('provBasicPw', 'provider value URL with user:password@'),
+ provJwt: token('provJwt', 'provider value `note` holding a JWT'),
+ provSvcToken: plain('provSvcToken', 'service property `token`'),
+ // Pipes.
+ pipeJwtIn: token('pipeJwtIn', 'pipe input holding a JWT (slice)'),
+ pipeBearerOut: bearer('pipeBearerOut', 'pipe output `Bearer …` (authHeader pipe)'),
+ pipeJsonPw: plain('pipeJsonPw', 'json pipe of an object with `password`'),
+ pipeAsyncPw: plain('pipeAsyncPw', 'async pipe value with `password`'),
+ // Console and errors.
+ conObjPw: plain('conObjPw', 'console.log object `password`'),
+ conErrJwt: token('conErrJwt', 'console.error Error message holding a JWT'),
+ conWarnBearer: bearer('conWarnBearer', 'console.warn `Authorization: Bearer …`'),
+ conKv: plain('conKv', 'console.log `token=…`'),
+ errThrownJwt: token('errThrownJwt', 'uncaught error message holding a JWT'),
+ errThrownKv: plain('errThrownKv', 'uncaught error message `password=…`'),
+ errRejectBearer: bearer('errRejectBearer', 'unhandled rejection `Bearer …`'),
+ // TransferState (SSR payload script).
+ tsBodyRefresh: plain('tsBodyRefresh', 'TransferState body `refresh_token`'),
+ tsJwt: token('tsJwt', 'TransferState body `note` holding a JWT'),
+ tsHdrBearer: bearer('tsHdrBearer', 'TransferState header authorization'),
+ tsSetCookie: plain('tsSetCookie', 'TransferState header set-cookie'),
+ tsUrlToken: plain('tsUrlToken', 'TransferState request URL `?token=`'),
+ tsCustomPw: plain('tsCustomPw', 'TransferState custom key `password`'),
+ // Literals in source, read by the source scan (static report, stdio MCP).
+ srcProvSecret: plain('srcProvSecret', 'source literal provider `clientSecret`'),
+ srcSignalToken: plain('srcSignalToken', 'source literal signal `authToken`'),
+ srcPropPassword: plain('srcPropPassword', 'source literal property `password`'),
+ srcRouteApiKey: plain('srcRouteApiKey', 'source literal route data `apiKey`'),
+ // A password input whose name looks harmless.
+ rfPwType: plain('rfPwType', 'reactive form `userWord` with type=password'),
+ // Write-back probes: values agents try to write into secret fields.
+ writeProbe: plain('writeProbe', 'value an agent tries to write into a secret field'),
+} satisfies RecordLeak corpus
+ 0;){let n=e[--t];if(typeof n==`number`&&n<0)return n}return 0}function Sm(e,t,n){if(n){if(t.exportAs)for(let r=0;r