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..e51b9b5da6 --- /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(), 17); + 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: 16, 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); + }); +});