From 74c35227006b68913ebe792dc422b6b4aa58b90f Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 06:11:53 +0100 Subject: [PATCH 1/2] Align API-token help with least-privilege presets Correct the existing configuration help against the live preset model and token client: reporting does not include lifecycle authority, exposed credentials need prompt revocation, and kiosk display preferences do not restrict credentials or guarantee persistence. Keep the shipped mirror and add runtime-model-bound documentation controls and browser help validation. Contract-Neutral: Documentation and test fixtures only; no runtime token, authorization, navigation or delivery contract changes. Change-source: pulse-maintainer --- docs/CONFIGURATION.md | 107 +++++++++++----- .../docs-fragment-navigation.tsx | 1 + .../browser-tests/token-guidance.cjs | 119 ++++++++++++++++++ frontend-modern/public/docs/CONFIGURATION.md | 107 +++++++++++----- .../utils/__tests__/docsTokenGuidance.test.ts | 100 +++++++++++++++ 5 files changed, 372 insertions(+), 62 deletions(-) create mode 100644 frontend-modern/browser-tests/token-guidance.cjs create mode 100644 frontend-modern/src/utils/__tests__/docsTokenGuidance.test.ts diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 190e1d6b93..409cbc8bf6 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -664,19 +664,36 @@ docker run --init -e HTTPS_ENABLED=true \ ## 🔑 API Tokens -API tokens provide scoped, revocable access to Pulse. Manage tokens in **Settings → Security → API Tokens**. +API tokens provide scoped, revocable access to Pulse. Manage them in +**Settings → API Access**, in the **Security** group. An account without token +management permission can review the inventory but cannot create or revoke tokens. The token shown during first-run setup is the primary automation API token for -that Pulse instance. It is separate from your web login password and is meant -for agents, scripts, integrations, kiosks, and temporary setup handoffs. Tokens -are shown once; later token rows show only identifying hints such as prefix, -suffix, label, scopes, and last-used metadata. +that Pulse instance, not your web login password. Do not reuse a full-access +setup token for every consumer. Give each agent, script, integration or display +only the scopes it needs, with a name that identifies its use. Tokens are shown +once; later rows show hints and metadata, not a recoverable secret. Store the +secret privately before dismissing it. -Revoking a token is safe for Pulse itself, but it immediately breaks any agent, -script, kiosk, or integration still using that token. When a consumer needs to -stay online, create and install a replacement token first, then revoke the old -one. An agent whose token has been revoked stops authenticating until it is -reinstalled or reconfigured with a valid token. +### Replace or revoke a token + +Revoking a token stops consumers still using it from authenticating. For a +planned rotation, create a least-privilege replacement, install it privately +in each affected consumer and verify a fresh authenticated result before +revoking the old token. Last-used metadata alone does not account for every +consumer. Do not reinstall an agent or delete its saved identity just to +replace a credential; use its private token-file or managed configuration. + +**If a token or kiosk link has been exposed, revoke it promptly**, even if that +interrupts monitoring or a display. Replace it through the same private setup +path. Do not leave a leaked token active while arranging a gradual rotation. +For a server-address change without credential exposure, follow +[agent retargeting](UNIFIED_AGENT.md#moving-pulse-to-a-new-address) instead. + +Never put a token or session cookie in a diagnostic command, screenshot or +issue thread. For scripted API access, use the +[private header-file procedure](API.md#api-token-recommended), keep TLS +verification enabled and do not share verbose or trace output. ### Token Scopes @@ -684,53 +701,81 @@ reinstalled or reconfigured with a valid token. | ------- | ------------- | | `*` (Full access) | All permissions (legacy, not recommended) | | `monitoring:read` | View dashboards, metrics, alerts | -| `monitoring:write` | Acknowledge/silence alerts | +| `monitoring:write` | Acknowledge, silence and clear alerts | | `docker:report` | Docker / Podman agent telemetry submission | | `docker:manage` | Docker / Podman container lifecycle actions (restart, stop) | | `kubernetes:report` | Kubernetes agent telemetry submission | | `kubernetes:manage` | Kubernetes cluster management | | `agent:report` | Agent host telemetry submission | | `agent:config:read` | Read agent config payloads | -| `agent:manage` | Manage registered agents (unlink/delete/config) | -| `agent:exec` | Establish agent command WebSocket connections | +| `agent:manage` | Agent lifecycle/configuration changes and unregistering; not needed by the reporting preset | +| `agent:exec` | Establish agent command WebSocket connections; not a reporting permission | | `ai:chat` | Use Pulse Assistant chat and read knowledge | | `ai:execute` | Use governed Patrol plans, approvals, actions, and history | | `settings:read` | Read configuration | -| `settings:write` | Modify configuration | +| `settings:write` | Modify configuration, manage tokens and trigger updates | | `audit:read` | Read audit events, verification results, summaries, and exports | ### Presets -The UI offers quick presets for common use cases: +Use **New token**, enter a name under **Create token**, select a **Quick preset** +and choose **Generate**. The current presets are: | Preset | Scopes | Use Case | | -------- | -------- | ---------- | -| **Kiosk / Dashboard** | `monitoring:read` | Read-only dashboard displays | -| **Agent host** | `agent:report`, `agent:config:read`, `agent:manage` | Agent telemetry, configuration fetch, and uninstall cleanup | +| **Kiosk / Monitoring** | `monitoring:read` | Read-only dashboard displays | +| **Agent** | `agent:report`, `agent:config:read` | Host telemetry and the agent's bound configuration | | **Docker / Podman report** | `docker:report` | Docker / Podman agent (read-only) | | **Docker / Podman manage** | `docker:report`, `docker:manage` | Docker / Podman agent with actions | | **Settings read** | `settings:read` | Read-only config access | | **Settings admin** | `settings:read`, `settings:write` | Full config access | +| **Audit read** | `audit:read` | Audit events, verification history, summaries and exports | + +**Reporting is not remote execution.** The Agent preset does not grant +`agent:manage` or `agent:exec`; Docker / Podman report does not grant +`docker:manage`. Add lifecycle or action authority only for the operation you +intend, not to fix a missing reading. A settings-read token can read sensitive +configuration and diagnostics; it is not a public-display credential. See +[agent security](AGENT_SECURITY.md) for the separate collector and action-runner +requirements. + +If **Patrol external agent** is offered, its scopes come from the current Patrol +requirements. Use the displayed set rather than guessing or adding **Full access**. +The separate **Full access** choice grants the legacy `*` wildcard, not a +least-privilege preset. Hiding controls does not reduce a token's permissions. ### Kiosk Mode -For unattended displays (wall monitors, dashboards), use a kiosk token to avoid cookie persistence issues: +For a wall monitor, create a dedicated **Kiosk / Monitoring** token with only +`monitoring:read`. Never use **Full access**, a settings token or your +administrator's logged-in browser profile on an unattended display. -1. Go to **Settings → Security → API Tokens** -2. Click **New token** and select the **Kiosk / Dashboard** preset -3. Copy the generated token -4. Access Pulse via URL with token: - ```text - https://your-pulse-url/?token=YOUR_TOKEN_HERE - ``` +1. Open Pulse at its trusted **HTTPS** address and go to **Settings → API Access**. +2. Use **New token**, name the display, select **Kiosk / Monitoring** and + choose **Generate**. Store the secret privately. +3. After closing the token-reveal dialog, the **Magic Kiosk Link** offers + **Copy Link** for this monitoring-only token. Transfer that link privately + to the display; do not put it in shared bookmarks, screenshots or reports. +4. Check the display using the link in a separate browser profile with no + administrator session. Confirm it shows the intended monitoring data and + does not offer settings or write actions. A hidden control alone is not + proof of restricted access. -**Kiosk tokens:** -- Grant read-only dashboard access (`monitoring:read` scope) -- Hide the Settings tab automatically -- Work without cookies (token in URL) -- Can be revoked anytime from the UI +The link is a **bearer credential**: anyone with it can read the permitted +infrastructure data until the token expires or is revoked. The `kiosk=1` +option only hides navigation and filters; it does not authenticate a browser +or change the token's scopes. -> **Security note**: URL tokens appear in browser history and server logs. Use only for read-only dashboard access on trusted networks. +Pulse removes the token from the page URL after loading and keeps it in that +tab's session storage. **The initial request already carried the token**; +HTTPS and the cleaned address bar do not remove copies from proxy/server logs, +browser history or the clipboard. Do not rely on this session surviving a +closed tab, browser restart or cleared site data. Keep the original link private +if the display needs it again, and clear clipboard copies after setup. + +If the display or link is lost or exposed, revoke its dedicated token in +**API Access** and create a replacement. Revoking a display token should not +require rotating unrelated agents' credentials. --- diff --git a/frontend-modern/browser-tests/docs-fragment-navigation.tsx b/frontend-modern/browser-tests/docs-fragment-navigation.tsx index b7e8976db6..373a4dd878 100644 --- a/frontend-modern/browser-tests/docs-fragment-navigation.tsx +++ b/frontend-modern/browser-tests/docs-fragment-navigation.tsx @@ -24,6 +24,7 @@ const targets: Record = { troubleshooting: '/docs/TROUBLESHOOTING#recovery-mode', truenas: '/docs/TRUENAS', installation: '/docs/INSTALL', + configuration: '/docs/CONFIGURATION#-api-tokens', }; window.history.replaceState({}, '', targets[scenario] ?? targets.plain); diff --git a/frontend-modern/browser-tests/token-guidance.cjs b/frontend-modern/browser-tests/token-guidance.cjs new file mode 100644 index 0000000000..111eac5ed3 --- /dev/null +++ b/frontend-modern/browser-tests/token-guidance.cjs @@ -0,0 +1,119 @@ +// Inspect the existing shipped help using the production Docs renderer. +// This creates no token, authenticates no client and performs no rotation. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { chromium, webkit } = require('playwright'); + +(async () => { + const root = '/workspace/frontend-modern'; + const artifacts = path.join(root, 'node_modules', 'token-guidance-browser'); + fs.mkdirSync(artifacts, { recursive: true }); + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({ + root, + configFile: path.join(root, 'vite.config.ts'), + cacheDir: path.join(root, 'node_modules', '.vite-token-guidance'), + server: { host: '127.0.0.1', port: 5253, strictPort: true }, + optimizeDeps: { noDiscovery: true, include: [] }, + }); + const results = []; + let browser; + try { + await server.listen(); + for (const [engine, width] of [['chromium', 1280], ['webkit', 390]]) { + browser = await { chromium, webkit }[engine].launch(engine === 'chromium' + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }); + const page = await browser.newPage({ viewport: { width, height: 1000 }, + ...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}) }); + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + page.on('console', (message) => { + if (message.type() === 'error') errors.push(message.text()); + }); + await page.goto('http://127.0.0.1:5253/browser-tests/docs-fragment-navigation.html?scenario=configuration', + { waitUntil: 'domcontentloaded', timeout: 60_000 }); + const tokens = page.getByRole('heading', { name: '🔑 API Tokens', exact: true }); + await tokens.waitFor(); + await page.waitForFunction(() => document.activeElement?.id === '-api-tokens'); + if (engine === 'webkit') { + await page.evaluate(async () => { + document.documentElement.classList.add('dark'); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + await Promise.all(document.getAnimations().map((animation) => animation.finished.catch(() => {}))); + }); + } + const presetTable = page.getByRole('table').filter({ has: page.getByRole('columnheader', + { name: 'Preset', exact: true }) }); + const agent = presetTable.getByRole('row').filter({ has: page.getByRole('cell', + { name: 'Agent', exact: true }) }); + assert.equal(await agent.getByRole('cell').nth(1).innerText(), 'agent:report, agent:config:read'); + assert.equal(await presetTable.getByRole('row').count(), 8); + const scopesTable = page.getByRole('table').filter({ has: page.getByRole('columnheader', + { name: 'Scope', exact: true }) }); + assert.equal(await scopesTable.getByRole('row').count(), 16); + for (const table of [presetTable, scopesTable]) { + assert.ok(await table.evaluate((element) => { + const parent = element.parentElement; + return parent.dataset.docTableScroll !== undefined && + getComputedStyle(parent).overflowX === 'auto' && + [...element.querySelectorAll('thead th')].every((header) => header.scope === 'col'); + })); + } + const body = (await page.locator('article').innerText()).replace(/\s+/g, ' '); + for (const text of ['Settings → API Access', 'Reporting is not remote execution', + 'not to fix a missing reading', 'revoke it promptly', 'even if that interrupts monitoring', + 'Magic Kiosk Link', 'Copy Link', 'no administrator session', 'bearer credential', + 'initial request already carried the token', 'proxy/server logs', + 'Do not rely on this session surviving']) assert.ok(body.includes(text), text); + assert.ok(!body.includes('?token=YOUR_TOKEN_HERE')); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + for (const [name, heading] of [ + ['rotation', tokens], + ['presets', page.getByRole('heading', { name: 'Presets', exact: true })], + ['kiosk', page.getByRole('heading', { name: 'Kiosk Mode', exact: true })], + ]) { + await heading.evaluate((element) => element.scrollIntoView({ block: 'start' })); + await page.screenshot({ path: path.join(artifacts, `${engine}-${name}.png`) }); + } + for (const [name, href, heading, id] of [ + ['private header-file procedure', '/docs/API#api-token-recommended', + 'API Token (Recommended)', 'api-token-recommended'], + ['agent retargeting', '/docs/UNIFIED_AGENT#moving-pulse-to-a-new-address', + 'Moving Pulse to a new address', 'moving-pulse-to-a-new-address'], + ]) { + const link = page.getByRole('link', { name, exact: true }); + assert.equal(await link.getAttribute('href'), href); + await link.focus(); + await page.keyboard.press('Enter'); + await page.waitForURL(`**${href}`); + await page.getByRole('heading', { name: heading, exact: true }).waitFor(); + assert.equal(await page.evaluate(() => document.activeElement?.id), id); + await page.goBack(); + await tokens.waitFor(); + } + assert.deepEqual(errors, []); + results.push({ engine, version: browser.version(), width, + tokenAnchorFocus: true, leastPrivilegeAgentRow: true, actualPresetCount: 7, + scopeCount: 15, semanticContainedTables: true, noDocumentOverflow: true, + kioskCredentialWarning: true, keyboardApiHelp: true, keyboardAgentRetargeting: true, errors }); + await browser.close(); + browser = undefined; + } + const files = ['public/docs/CONFIGURATION.md', 'browser-tests/token-guidance.cjs', + 'browser-tests/docs-fragment-navigation.tsx', 'src/features/docs/docMarkdown.ts', + 'src/pages/Docs.tsx'].map((file) => ({ file, + sha256: crypto.createHash('sha256').update(fs.readFileSync(path.join(root, file))).digest('hex') })); + const result = { result: 'passed', playwright: require('playwright/package.json').version, + scope: 'shipped token guidance rendering and keyboard help links; no token/native operation performed', + dompurify: JSON.parse(fs.readFileSync(path.join(root, 'node_modules/dompurify/package.json'))).version, + files, results }; + fs.writeFileSync(path.join(artifacts, 'result.json'), JSON.stringify(result, null, 2) + '\n'); + console.log(JSON.stringify(result)); + } finally { + if (browser) await browser.close(); + await server.close(); + } +})().catch((error) => { console.error(error); process.exitCode = 1; }); diff --git a/frontend-modern/public/docs/CONFIGURATION.md b/frontend-modern/public/docs/CONFIGURATION.md index 190e1d6b93..409cbc8bf6 100644 --- a/frontend-modern/public/docs/CONFIGURATION.md +++ b/frontend-modern/public/docs/CONFIGURATION.md @@ -664,19 +664,36 @@ docker run --init -e HTTPS_ENABLED=true \ ## 🔑 API Tokens -API tokens provide scoped, revocable access to Pulse. Manage tokens in **Settings → Security → API Tokens**. +API tokens provide scoped, revocable access to Pulse. Manage them in +**Settings → API Access**, in the **Security** group. An account without token +management permission can review the inventory but cannot create or revoke tokens. The token shown during first-run setup is the primary automation API token for -that Pulse instance. It is separate from your web login password and is meant -for agents, scripts, integrations, kiosks, and temporary setup handoffs. Tokens -are shown once; later token rows show only identifying hints such as prefix, -suffix, label, scopes, and last-used metadata. +that Pulse instance, not your web login password. Do not reuse a full-access +setup token for every consumer. Give each agent, script, integration or display +only the scopes it needs, with a name that identifies its use. Tokens are shown +once; later rows show hints and metadata, not a recoverable secret. Store the +secret privately before dismissing it. -Revoking a token is safe for Pulse itself, but it immediately breaks any agent, -script, kiosk, or integration still using that token. When a consumer needs to -stay online, create and install a replacement token first, then revoke the old -one. An agent whose token has been revoked stops authenticating until it is -reinstalled or reconfigured with a valid token. +### Replace or revoke a token + +Revoking a token stops consumers still using it from authenticating. For a +planned rotation, create a least-privilege replacement, install it privately +in each affected consumer and verify a fresh authenticated result before +revoking the old token. Last-used metadata alone does not account for every +consumer. Do not reinstall an agent or delete its saved identity just to +replace a credential; use its private token-file or managed configuration. + +**If a token or kiosk link has been exposed, revoke it promptly**, even if that +interrupts monitoring or a display. Replace it through the same private setup +path. Do not leave a leaked token active while arranging a gradual rotation. +For a server-address change without credential exposure, follow +[agent retargeting](UNIFIED_AGENT.md#moving-pulse-to-a-new-address) instead. + +Never put a token or session cookie in a diagnostic command, screenshot or +issue thread. For scripted API access, use the +[private header-file procedure](API.md#api-token-recommended), keep TLS +verification enabled and do not share verbose or trace output. ### Token Scopes @@ -684,53 +701,81 @@ reinstalled or reconfigured with a valid token. | ------- | ------------- | | `*` (Full access) | All permissions (legacy, not recommended) | | `monitoring:read` | View dashboards, metrics, alerts | -| `monitoring:write` | Acknowledge/silence alerts | +| `monitoring:write` | Acknowledge, silence and clear alerts | | `docker:report` | Docker / Podman agent telemetry submission | | `docker:manage` | Docker / Podman container lifecycle actions (restart, stop) | | `kubernetes:report` | Kubernetes agent telemetry submission | | `kubernetes:manage` | Kubernetes cluster management | | `agent:report` | Agent host telemetry submission | | `agent:config:read` | Read agent config payloads | -| `agent:manage` | Manage registered agents (unlink/delete/config) | -| `agent:exec` | Establish agent command WebSocket connections | +| `agent:manage` | Agent lifecycle/configuration changes and unregistering; not needed by the reporting preset | +| `agent:exec` | Establish agent command WebSocket connections; not a reporting permission | | `ai:chat` | Use Pulse Assistant chat and read knowledge | | `ai:execute` | Use governed Patrol plans, approvals, actions, and history | | `settings:read` | Read configuration | -| `settings:write` | Modify configuration | +| `settings:write` | Modify configuration, manage tokens and trigger updates | | `audit:read` | Read audit events, verification results, summaries, and exports | ### Presets -The UI offers quick presets for common use cases: +Use **New token**, enter a name under **Create token**, select a **Quick preset** +and choose **Generate**. The current presets are: | Preset | Scopes | Use Case | | -------- | -------- | ---------- | -| **Kiosk / Dashboard** | `monitoring:read` | Read-only dashboard displays | -| **Agent host** | `agent:report`, `agent:config:read`, `agent:manage` | Agent telemetry, configuration fetch, and uninstall cleanup | +| **Kiosk / Monitoring** | `monitoring:read` | Read-only dashboard displays | +| **Agent** | `agent:report`, `agent:config:read` | Host telemetry and the agent's bound configuration | | **Docker / Podman report** | `docker:report` | Docker / Podman agent (read-only) | | **Docker / Podman manage** | `docker:report`, `docker:manage` | Docker / Podman agent with actions | | **Settings read** | `settings:read` | Read-only config access | | **Settings admin** | `settings:read`, `settings:write` | Full config access | +| **Audit read** | `audit:read` | Audit events, verification history, summaries and exports | + +**Reporting is not remote execution.** The Agent preset does not grant +`agent:manage` or `agent:exec`; Docker / Podman report does not grant +`docker:manage`. Add lifecycle or action authority only for the operation you +intend, not to fix a missing reading. A settings-read token can read sensitive +configuration and diagnostics; it is not a public-display credential. See +[agent security](AGENT_SECURITY.md) for the separate collector and action-runner +requirements. + +If **Patrol external agent** is offered, its scopes come from the current Patrol +requirements. Use the displayed set rather than guessing or adding **Full access**. +The separate **Full access** choice grants the legacy `*` wildcard, not a +least-privilege preset. Hiding controls does not reduce a token's permissions. ### Kiosk Mode -For unattended displays (wall monitors, dashboards), use a kiosk token to avoid cookie persistence issues: +For a wall monitor, create a dedicated **Kiosk / Monitoring** token with only +`monitoring:read`. Never use **Full access**, a settings token or your +administrator's logged-in browser profile on an unattended display. -1. Go to **Settings → Security → API Tokens** -2. Click **New token** and select the **Kiosk / Dashboard** preset -3. Copy the generated token -4. Access Pulse via URL with token: - ```text - https://your-pulse-url/?token=YOUR_TOKEN_HERE - ``` +1. Open Pulse at its trusted **HTTPS** address and go to **Settings → API Access**. +2. Use **New token**, name the display, select **Kiosk / Monitoring** and + choose **Generate**. Store the secret privately. +3. After closing the token-reveal dialog, the **Magic Kiosk Link** offers + **Copy Link** for this monitoring-only token. Transfer that link privately + to the display; do not put it in shared bookmarks, screenshots or reports. +4. Check the display using the link in a separate browser profile with no + administrator session. Confirm it shows the intended monitoring data and + does not offer settings or write actions. A hidden control alone is not + proof of restricted access. -**Kiosk tokens:** -- Grant read-only dashboard access (`monitoring:read` scope) -- Hide the Settings tab automatically -- Work without cookies (token in URL) -- Can be revoked anytime from the UI +The link is a **bearer credential**: anyone with it can read the permitted +infrastructure data until the token expires or is revoked. The `kiosk=1` +option only hides navigation and filters; it does not authenticate a browser +or change the token's scopes. -> **Security note**: URL tokens appear in browser history and server logs. Use only for read-only dashboard access on trusted networks. +Pulse removes the token from the page URL after loading and keeps it in that +tab's session storage. **The initial request already carried the token**; +HTTPS and the cleaned address bar do not remove copies from proxy/server logs, +browser history or the clipboard. Do not rely on this session surviving a +closed tab, browser restart or cleared site data. Keep the original link private +if the display needs it again, and clear clipboard copies after setup. + +If the display or link is lost or exposed, revoke its dedicated token in +**API Access** and create a replacement. Revoking a display token should not +require rotating unrelated agents' credentials. --- diff --git a/frontend-modern/src/utils/__tests__/docsTokenGuidance.test.ts b/frontend-modern/src/utils/__tests__/docsTokenGuidance.test.ts new file mode 100644 index 0000000000..c55c5ccf69 --- /dev/null +++ b/frontend-modern/src/utils/__tests__/docsTokenGuidance.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { API_SCOPE_OPTIONS } from '@/constants/apiScopes'; +import { getAPITokenScopePresets } from '@/components/Settings/apiTokenManagerModel'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../..'); +const configuration = readFileSync(path.join(repoRoot, 'docs/CONFIGURATION.md'), 'utf8'); +const tokens = configuration.split('## 🔑 API Tokens')[1]?.split('\n## TrueNAS')[0] ?? ''; +const normalized = tokens.replace(/\s+/g, ' '); + +function tableRows(section: string) { + return section + .split('\n') + .filter((line) => line.startsWith('|') && !/^\|\s*-/.test(line)) + .slice(1) + .map((line) => + line + .split('|') + .slice(1, -1) + .map((cell) => cell.trim()), + ); +} + +describe('shipped API Access guidance', () => { + it('names the existing token controls rather than the retired navigation', () => { + expect(normalized).toContain('Settings → API Access'); + expect(normalized).not.toContain('Security → API Tokens'); + for (const control of ['New token', 'Create token', 'Quick preset', 'Generate']) { + expect(normalized).toContain(control); + } + expect(normalized).toContain('cannot create or revoke tokens'); + }); + + it('documents every UI scope without inventing capabilities', () => { + const scopeSection = tokens.split('### Token Scopes')[1]?.split('### Presets')[0] ?? ''; + const scopes = tableRows(scopeSection).map(([scope]) => scope.match(/`([^`]+)`/)?.[1]); + expect(scopes.sort()).toEqual(['*', ...API_SCOPE_OPTIONS.map((scope) => scope.value)].sort()); + }); + + it('matches names and exact least-privilege scope sets to the actual preset model', () => { + const presetSection = tokens.split('### Presets')[1]?.split('### Kiosk Mode')[0] ?? ''; + const documented = tableRows(presetSection).map(([label, scopes]) => ({ + label: label.replace(/\*\*/g, ''), + scopes: [...scopes.matchAll(/`([^`]+)`/g)].map((match) => match[1]), + })); + expect(documented).toEqual( + getAPITokenScopePresets().map(({ label, scopes }) => ({ label, scopes })), + ); + expect(normalized).toContain('Reporting is not remote execution'); + expect(normalized).toContain('not to fix a missing reading'); + expect(normalized).toContain('separate collector and action-runner'); + }); + + it('distinguishes backend-required Patrol scopes and wildcard authority from presets', () => { + expect(normalized).toContain('its scopes come from the current Patrol requirements'); + expect(normalized).toContain('legacy `*` wildcard, not a least-privilege preset'); + expect(normalized).toContain("Hiding controls does not reduce a token's permissions"); + }); + + it('keeps credential rotation separate from identity deletion and exposure response', () => { + const rotation = + tokens.split('### Replace or revoke a token')[1]?.split('### Token Scopes')[0] ?? ''; + const text = rotation.replace(/\s+/g, ' '); + expect(text).toContain('fresh authenticated result before revoking the old token'); + expect(text).toContain('Last-used metadata alone does not account for every consumer'); + expect(text).toContain('Do not reinstall an agent or delete its saved identity'); + expect(text).toContain('revoke it promptly'); + expect(text).toContain('even if that interrupts monitoring or a display'); + expect(text).toContain('Do not leave a leaked token active'); + expect(text).toContain('[agent retargeting](UNIFIED_AGENT.md#moving-pulse-to-a-new-address)'); + expect(text).toContain('[private header-file procedure](API.md#api-token-recommended)'); + }); + + it('does not present a hidden dashboard or cleaned URL as a credential boundary', () => { + const kiosk = tokens.split('### Kiosk Mode')[1] ?? ''; + const text = kiosk.replace(/\s+/g, ' '); + expect(text).toContain('Kiosk / Monitoring'); + expect(text).toContain('only `monitoring:read`'); + expect(text).toContain('Magic Kiosk Link'); + expect(text).toContain('Copy Link'); + expect(text).toContain('HTTPS'); + expect(text).toContain('no administrator session'); + expect(text).toContain('bearer credential'); + expect(text).toContain('it does not authenticate a browser or change'); + expect(text).toContain('initial request already carried the token'); + expect(text).toContain('proxy/server logs'); + expect(text).toContain('session storage'); + expect(text).toContain('Do not rely on this session surviving'); + expect(text).not.toContain('?token=YOUR_TOKEN_HERE'); + expect(text).not.toContain('avoid cookie persistence issues'); + }); + + it('ships the same scope guidance the UI links to', () => { + expect( + readFileSync(path.join(repoRoot, 'frontend-modern/public/docs/CONFIGURATION.md'), 'utf8'), + ).toBe(configuration); + }); +}); From 383be3053eb91f6d26ac11bee49d898b78ae18ea Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 06:18:40 +0100 Subject: [PATCH 2/2] Correct the token-help browser scope count The real scope table contains the 15 selectable UI scopes plus the wildcard row. Count all 16 entries rather than rejecting the correctly rendered reference. The corrected Chromium and phone-emulated WebKit run passes with all six screenshots inspected; shipped documentation and suite-covered source are unchanged. Contract-Neutral: Browser-test assertion only; no runtime or user-facing contract delta. Change-source: pulse-maintainer --- frontend-modern/browser-tests/token-guidance.cjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend-modern/browser-tests/token-guidance.cjs b/frontend-modern/browser-tests/token-guidance.cjs index 111eac5ed3..e51b9b5da6 100644 --- a/frontend-modern/browser-tests/token-guidance.cjs +++ b/frontend-modern/browser-tests/token-guidance.cjs @@ -53,7 +53,7 @@ const { chromium, webkit } = require('playwright'); assert.equal(await presetTable.getByRole('row').count(), 8); const scopesTable = page.getByRole('table').filter({ has: page.getByRole('columnheader', { name: 'Scope', exact: true }) }); - assert.equal(await scopesTable.getByRole('row').count(), 16); + assert.equal(await scopesTable.getByRole('row').count(), 17); for (const table of [presetTable, scopesTable]) { assert.ok(await table.evaluate((element) => { const parent = element.parentElement; @@ -97,7 +97,7 @@ const { chromium, webkit } = require('playwright'); assert.deepEqual(errors, []); results.push({ engine, version: browser.version(), width, tokenAnchorFocus: true, leastPrivilegeAgentRow: true, actualPresetCount: 7, - scopeCount: 15, semanticContainedTables: true, noDocumentOverflow: true, + scopeCount: 16, semanticContainedTables: true, noDocumentOverflow: true, kioskCredentialWarning: true, keyboardApiHelp: true, keyboardAgentRetargeting: true, errors }); await browser.close(); browser = undefined;