Extract API token manager state owner

This commit is contained in:
rcourtman 2026-03-20 23:08:43 +00:00
parent 6e09daf9be
commit dcd88e2f64
10 changed files with 1022 additions and 802 deletions

View file

@ -28,20 +28,22 @@ Own canonical runtime payload shapes between backend and frontend.
5. `frontend-modern/src/types/api.ts`
6. `frontend-modern/src/api/responseUtils.ts`
7. `frontend-modern/src/components/Settings/APITokenManager.tsx`
8. `frontend-modern/src/components/Settings/InfrastructureOperationsController.tsx`
9. `frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx`
10. `frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx`
11. `frontend-modern/src/components/Settings/UnifiedAgents.tsx`
12. `frontend-modern/src/components/Settings/NodeModal.tsx`
13. `frontend-modern/src/components/Settings/nodeModalModel.ts`
14. `frontend-modern/src/components/Settings/useNodeModalState.ts`
15. `frontend-modern/src/utils/agentInstallCommand.ts`
16. `frontend-modern/src/api/nodes.ts`
17. `frontend-modern/src/api/license.ts`
18. `frontend-modern/src/api/monitoredSystemLedger.ts`
19. `frontend-modern/src/api/resources.ts`
20. `frontend-modern/src/api/monitoring.ts`
21. `internal/api/monitored_system_ledger.go`
8. `frontend-modern/src/components/Settings/apiTokenManagerModel.ts`
9. `frontend-modern/src/components/Settings/InfrastructureOperationsController.tsx`
10. `frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx`
11. `frontend-modern/src/components/Settings/useAPITokenManagerState.ts`
12. `frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx`
13. `frontend-modern/src/components/Settings/UnifiedAgents.tsx`
14. `frontend-modern/src/components/Settings/NodeModal.tsx`
15. `frontend-modern/src/components/Settings/nodeModalModel.ts`
16. `frontend-modern/src/components/Settings/useNodeModalState.ts`
17. `frontend-modern/src/utils/agentInstallCommand.ts`
18. `frontend-modern/src/api/nodes.ts`
19. `frontend-modern/src/api/license.ts`
20. `frontend-modern/src/api/monitoredSystemLedger.ts`
21. `frontend-modern/src/api/resources.ts`
22. `frontend-modern/src/api/monitoring.ts`
23. `internal/api/monitored_system_ledger.go`
## Shared Boundaries
@ -53,31 +55,33 @@ Own canonical runtime payload shapes between backend and frontend.
6. `frontend-modern/src/api/security.ts` shared with `security-privacy`: the security frontend client is both a security/privacy control surface and a canonical API payload contract boundary.
7. `frontend-modern/src/api/updates.ts` shared with `deployment-installability`: the updates frontend client is both a deployment-installability control surface and a canonical API payload contract boundary.
8. `frontend-modern/src/components/Settings/APITokenManager.tsx` shared with `security-privacy`: the API token settings surface is both a security/privacy control surface and a canonical API payload contract boundary.
9. `frontend-modern/src/components/Settings/InfrastructureOperationsController.tsx` shared with `agent-lifecycle`: the infrastructure operations controller is both an agent fleet lifecycle control surface and an API token, lookup, assignment, and reporting/install contract boundary.
10. `frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx` shared with `agent-lifecycle`: the pure infrastructure operations inventory/install model is both an agent fleet lifecycle control surface and an API token, lookup, assignment, and reporting/install contract boundary.
11. `frontend-modern/src/components/Settings/NodeModal.tsx` shared with `agent-lifecycle`: the node setup modal render shell is both an agent lifecycle control surface and a shared API-backed install/setup contract boundary.
12. `frontend-modern/src/components/Settings/nodeModalModel.ts` shared with `agent-lifecycle`: the pure node setup modal model is both an agent lifecycle control surface and a shared API-backed install/setup contract boundary.
13. `frontend-modern/src/components/Settings/UnifiedAgents.tsx` shared with `agent-lifecycle`: the UnifiedAgents module is a compatibility shim for the canonical infrastructure operations controller and remains on the same shared agent lifecycle and API contract boundary while the old module path exists.
14. `frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx` shared with `agent-lifecycle`: the shared infrastructure operations state hook is both an agent fleet lifecycle control surface and an API token, lookup, assignment, and reporting/install contract boundary.
15. `frontend-modern/src/components/Settings/useNodeModalState.ts` shared with `agent-lifecycle`: the node setup modal state hook is both an agent lifecycle control surface and a shared API-backed install/setup contract boundary.
16. `frontend-modern/src/utils/agentInstallCommand.ts` shared with `agent-lifecycle`: the shared frontend install-command helper is both an agent lifecycle control surface and a canonical API/install transport contract boundary.
17. `internal/api/agent_install_command_shared.go` shared with `agent-lifecycle`: agent install command assembly is both an agent lifecycle control surface and a canonical API payload contract boundary.
18. `internal/api/ai_handler.go` shared with `ai-runtime`: Pulse Assistant handlers are both an AI runtime control surface and a canonical API payload contract boundary.
19. `internal/api/ai_handlers.go` shared with `ai-runtime`: AI settings and remediation handlers are both an AI runtime control surface and a canonical API payload contract boundary.
20. `internal/api/ai_intelligence_handlers.go` shared with `ai-runtime`: AI intelligence handlers are both an AI runtime control surface and a canonical API payload contract boundary.
21. `internal/api/config_setup_handlers.go` shared with `agent-lifecycle`: auto-register and setup handlers are both an agent lifecycle control surface and a canonical API payload contract boundary.
22. `internal/api/licensing_bridge.go` shared with `cloud-paid`: commercial licensing bridge handlers carry both API payload contract and cloud-paid entitlement boundary ownership.
23. `internal/api/licensing_handlers.go` shared with `cloud-paid`: commercial licensing handlers carry both API payload contract and cloud-paid entitlement boundary ownership.
24. `internal/api/notifications.go` shared with `notifications`: notification handlers are both a notification delivery control surface and a canonical API payload contract boundary.
25. `internal/api/payments_webhook_handlers.go` shared with `cloud-paid`: commercial payment webhook handlers carry both API payload contract and cloud-paid billing boundary ownership.
26. `internal/api/public_signup_handlers.go` shared with `cloud-paid`: hosted signup handlers carry both API payload contract and cloud-paid hosted provisioning boundary ownership.
27. `internal/api/resources.go` shared with `unified-resources`: the unified resource endpoint is both a backend payload contract surface and a unified-resource runtime boundary.
28. `internal/api/security.go` shared with `security-privacy`: the security handlers are both a security/privacy control surface and a canonical API payload contract boundary.
29. `internal/api/security_tokens.go` shared with `security-privacy`: the security token handlers are both a security/privacy control surface and a canonical API payload contract boundary.
30. `internal/api/slo.go` shared with `performance-and-scalability`: the SLO endpoint is both an API contract surface and a protected performance hot-path boundary.
31. `internal/api/system_settings.go` shared with `security-privacy`: the system settings telemetry and auth controls are both a security/privacy control surface and a canonical API payload contract boundary.
32. `internal/api/unified_agent.go` shared with `agent-lifecycle`: unified agent download and installer handlers are both an agent lifecycle control surface and a canonical API payload contract boundary.
33. `internal/api/updates.go` shared with `deployment-installability`: update handlers are both a deployment-installability control surface and a canonical API payload contract boundary.
9. `frontend-modern/src/components/Settings/apiTokenManagerModel.ts` shared with `security-privacy`: the pure API token settings model is both a security/privacy control surface and a canonical API payload contract boundary.
10. `frontend-modern/src/components/Settings/InfrastructureOperationsController.tsx` shared with `agent-lifecycle`: the infrastructure operations controller is both an agent fleet lifecycle control surface and an API token, lookup, assignment, and reporting/install contract boundary.
11. `frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx` shared with `agent-lifecycle`: the pure infrastructure operations inventory/install model is both an agent fleet lifecycle control surface and an API token, lookup, assignment, and reporting/install contract boundary.
12. `frontend-modern/src/components/Settings/NodeModal.tsx` shared with `agent-lifecycle`: the node setup modal render shell is both an agent lifecycle control surface and a shared API-backed install/setup contract boundary.
13. `frontend-modern/src/components/Settings/nodeModalModel.ts` shared with `agent-lifecycle`: the pure node setup modal model is both an agent lifecycle control surface and a shared API-backed install/setup contract boundary.
14. `frontend-modern/src/components/Settings/UnifiedAgents.tsx` shared with `agent-lifecycle`: the UnifiedAgents module is a compatibility shim for the canonical infrastructure operations controller and remains on the same shared agent lifecycle and API contract boundary while the old module path exists.
15. `frontend-modern/src/components/Settings/useAPITokenManagerState.ts` shared with `security-privacy`: the API token settings state hook is both a security/privacy control surface and a canonical API payload contract boundary.
16. `frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx` shared with `agent-lifecycle`: the shared infrastructure operations state hook is both an agent fleet lifecycle control surface and an API token, lookup, assignment, and reporting/install contract boundary.
17. `frontend-modern/src/components/Settings/useNodeModalState.ts` shared with `agent-lifecycle`: the node setup modal state hook is both an agent lifecycle control surface and a shared API-backed install/setup contract boundary.
18. `frontend-modern/src/utils/agentInstallCommand.ts` shared with `agent-lifecycle`: the shared frontend install-command helper is both an agent lifecycle control surface and a canonical API/install transport contract boundary.
19. `internal/api/agent_install_command_shared.go` shared with `agent-lifecycle`: agent install command assembly is both an agent lifecycle control surface and a canonical API payload contract boundary.
20. `internal/api/ai_handler.go` shared with `ai-runtime`: Pulse Assistant handlers are both an AI runtime control surface and a canonical API payload contract boundary.
21. `internal/api/ai_handlers.go` shared with `ai-runtime`: AI settings and remediation handlers are both an AI runtime control surface and a canonical API payload contract boundary.
22. `internal/api/ai_intelligence_handlers.go` shared with `ai-runtime`: AI intelligence handlers are both an AI runtime control surface and a canonical API payload contract boundary.
23. `internal/api/config_setup_handlers.go` shared with `agent-lifecycle`: auto-register and setup handlers are both an agent lifecycle control surface and a canonical API payload contract boundary.
24. `internal/api/licensing_bridge.go` shared with `cloud-paid`: commercial licensing bridge handlers carry both API payload contract and cloud-paid entitlement boundary ownership.
25. `internal/api/licensing_handlers.go` shared with `cloud-paid`: commercial licensing handlers carry both API payload contract and cloud-paid entitlement boundary ownership.
26. `internal/api/notifications.go` shared with `notifications`: notification handlers are both a notification delivery control surface and a canonical API payload contract boundary.
27. `internal/api/payments_webhook_handlers.go` shared with `cloud-paid`: commercial payment webhook handlers carry both API payload contract and cloud-paid billing boundary ownership.
28. `internal/api/public_signup_handlers.go` shared with `cloud-paid`: hosted signup handlers carry both API payload contract and cloud-paid hosted provisioning boundary ownership.
29. `internal/api/resources.go` shared with `unified-resources`: the unified resource endpoint is both a backend payload contract surface and a unified-resource runtime boundary.
30. `internal/api/security.go` shared with `security-privacy`: the security handlers are both a security/privacy control surface and a canonical API payload contract boundary.
31. `internal/api/security_tokens.go` shared with `security-privacy`: the security token handlers are both a security/privacy control surface and a canonical API payload contract boundary.
32. `internal/api/slo.go` shared with `performance-and-scalability`: the SLO endpoint is both an API contract surface and a protected performance hot-path boundary.
33. `internal/api/system_settings.go` shared with `security-privacy`: the system settings telemetry and auth controls are both a security/privacy control surface and a canonical API payload contract boundary.
34. `internal/api/unified_agent.go` shared with `agent-lifecycle`: unified agent download and installer handlers are both an agent lifecycle control surface and a canonical API payload contract boundary.
35. `internal/api/updates.go` shared with `deployment-installability`: update handlers are both a deployment-installability control surface and a canonical API payload contract boundary.
## Extension Points
1. Add or change payload fields through handler + contract tests together
@ -100,7 +104,7 @@ Own canonical runtime payload shapes between backend and frontend.
and the same shared correlation card's ordering and truncation rule, so callers pass raw correlations instead of encoding their own top-N sort behavior
and the shared `frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx` and `frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx` cards' infrastructure resource-link default, so the Patrol page, resource drawer, and problem-resource dashboard panels inherit the canonical resource-filter path construction instead of rebuilding infrastructure URLs inline
8. Route frontend API-client parsed error propagation, API-error-status fallback handling, allowed-status handling, custom status-specific error handling, command-trigger success envelope handling, shared response parsing pipelines, missing-resource lookup handling, metadata CRUD routing, stream event consumption, response status, collection normalization, scalar payload coercion, and structured error normalization through canonical shared helpers under `frontend-modern/src/api/`
9. Add or change API token scope, assignment, and revocation presentation through `frontend-modern/src/components/Settings/APITokenManager.tsx`
9. Add or change API token scope, assignment, and revocation presentation through `frontend-modern/src/components/Settings/APITokenManager.tsx`, `frontend-modern/src/components/Settings/apiTokenManagerModel.ts`, and `frontend-modern/src/components/Settings/useAPITokenManagerState.ts`
10. Add or change infrastructure operations token generation, lookup, assignment, the pure unified-agent inventory/install model, and reporting/install presentation through `frontend-modern/src/components/Settings/InfrastructureOperationsController.tsx`, `frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx`, and `frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx`
11. Keep `internal/api/session_store.go` on a fail-closed auth-persistence boundary: persisted OIDC refresh tokens may only round-trip through encrypted-at-rest session payloads, and any missing-crypto or invalid-ciphertext path must drop the token instead of preserving plaintext-at-rest session state.
12. Keep tenant AI handler wiring on canonical provider ownership: `internal/api/ai_handlers.go` may wire tenant `ReadState` and tenant-scoped unified-resource providers into AI services, but it must not revive tenant snapshot-provider bridges once Patrol can initialize and verify from those canonical providers directly.
@ -1342,14 +1346,16 @@ magic-link payload semantics, while `internal/hosted/provisioner.go` owns the
shared org bootstrap and rollback mechanics that the hosted signup handler
invokes.
The API token settings surface now also follows the same explicit ownership
rule. Changes to `frontend-modern/src/components/Settings/APITokenManager.tsx`
must carry this contract and the dedicated API-token management proof file
instead of remaining an unowned consumer of token scope labels, token
assignment visibility, and revoke-state presentation.
That shared `APITokenManager.tsx` boundary must also stay under explicit proof
routing on both sides instead of relying only on broad settings-surface
coverage on the security side: token settings changes must continue to carry
the direct `api-token-management-surface` API-contract proof together with the
rule. Changes to `frontend-modern/src/components/Settings/APITokenManager.tsx`,
`frontend-modern/src/components/Settings/apiTokenManagerModel.ts`, and
`frontend-modern/src/components/Settings/useAPITokenManagerState.ts` must
carry this contract and the dedicated API-token management proof file instead
of remaining an unowned consumer of token scope labels, token assignment
visibility, and revoke-state presentation.
That shared API-token boundary must also stay under explicit proof routing on
both sides instead of relying only on broad settings-surface coverage on the
security side: token settings changes must continue to carry the direct
`api-token-management-surface` API-contract proof together with the
security-side surface proof.
That same token surface, together with `frontend-modern/src/api/security.ts`,
`internal/api/security.go`, `internal/api/security_tokens.go`, and

View file

@ -97,6 +97,14 @@
"security-privacy"
]
},
{
"path": "frontend-modern/src/components/Settings/apiTokenManagerModel.ts",
"rationale": "the pure API token settings model is both a security/privacy control surface and a canonical API payload contract boundary",
"subsystems": [
"api-contracts",
"security-privacy"
]
},
{
"path": "frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx",
"rationale": "the general settings privacy panel is both a security/privacy control surface and a canonical settings-shell presentation boundary",
@ -161,6 +169,14 @@
"api-contracts"
]
},
{
"path": "frontend-modern/src/components/Settings/useAPITokenManagerState.ts",
"rationale": "the API token settings state hook is both a security/privacy control surface and a canonical API payload contract boundary",
"subsystems": [
"api-contracts",
"security-privacy"
]
},
{
"path": "frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx",
"rationale": "the shared infrastructure operations state hook is both an agent fleet lifecycle control surface and an API token, lookup, assignment, and reporting/install contract boundary",
@ -441,32 +457,32 @@
"internal/hostagent/send_report_test.go"
]
},
{
"id": "unified-agent-installer-runtime",
"label": "unified agent installer runtime proof",
"match_prefixes": [],
"match_files": [
"scripts/install.sh"
],
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
"scripts/installtests/install_sh_test.go"
]
},
{
"id": "windows-agent-installer-runtime",
"label": "windows agent installer lifecycle proof",
"match_prefixes": [],
"match_files": [
"scripts/install.ps1"
],
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
"scripts/installtests/install_ps1_test.go"
]
},
{
"id": "unified-agent-installer-runtime",
"label": "unified agent installer runtime proof",
"match_prefixes": [],
"match_files": [
"scripts/install.sh"
],
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
"scripts/installtests/install_sh_test.go"
]
},
{
"id": "windows-agent-installer-runtime",
"label": "windows agent installer lifecycle proof",
"match_prefixes": [],
"match_files": [
"scripts/install.ps1"
],
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
"scripts/installtests/install_ps1_test.go"
]
},
{
"id": "agent-profiles-surface",
"label": "agent profile management proof",
@ -513,47 +529,47 @@
]
},
{
"id": "node-setup-settings-surface",
"label": "node setup/install command proof",
"match_prefixes": [],
"match_files": [
"frontend-modern/src/components/Settings/NodeModal.tsx",
"frontend-modern/src/components/Settings/nodeModalModel.ts",
"frontend-modern/src/components/Settings/useNodeModalState.ts"
],
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
"frontend-modern/src/components/Settings/__tests__/NodeModal.guardrails.test.ts"
]
},
{
"id": "setup-completion-install-surface",
"label": "setup completion install handoff proof",
"match_prefixes": [],
"match_files": [
"frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx"
],
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
"frontend-modern/src/components/SetupWizard/__tests__/SetupCompletionPanel.guardrails.test.ts"
]
},
{
"id": "deploy-fallback-install-surface",
"label": "deploy fallback install command proof",
"match_prefixes": [],
"match_files": [
"frontend-modern/src/components/Infrastructure/deploy/ResultsStep.tsx"
],
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
"frontend-modern/src/components/Infrastructure/__tests__/DeployStepComponents.test.tsx"
]
},
{
"id": "node-setup-settings-surface",
"label": "node setup/install command proof",
"match_prefixes": [],
"match_files": [
"frontend-modern/src/components/Settings/NodeModal.tsx",
"frontend-modern/src/components/Settings/nodeModalModel.ts",
"frontend-modern/src/components/Settings/useNodeModalState.ts"
],
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
"frontend-modern/src/components/Settings/__tests__/NodeModal.guardrails.test.ts"
]
},
{
"id": "setup-completion-install-surface",
"label": "setup completion install handoff proof",
"match_prefixes": [],
"match_files": [
"frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx"
],
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
"frontend-modern/src/components/SetupWizard/__tests__/SetupCompletionPanel.guardrails.test.ts"
]
},
{
"id": "deploy-fallback-install-surface",
"label": "deploy fallback install command proof",
"match_prefixes": [],
"match_files": [
"frontend-modern/src/components/Infrastructure/deploy/ResultsStep.tsx"
],
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
"frontend-modern/src/components/Infrastructure/__tests__/DeployStepComponents.test.tsx"
]
},
{
"id": "unified-agent-settings-surface",
"label": "unified agent settings lifecycle proof",
"match_prefixes": [],
@ -753,11 +769,13 @@
],
"owned_files": [
"frontend-modern/src/components/Settings/APITokenManager.tsx",
"frontend-modern/src/components/Settings/apiTokenManagerModel.ts",
"frontend-modern/src/components/Settings/InfrastructureOperationsController.tsx",
"frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx",
"frontend-modern/src/components/Settings/NodeModal.tsx",
"frontend-modern/src/components/Settings/nodeModalModel.ts",
"frontend-modern/src/components/Settings/UnifiedAgents.tsx",
"frontend-modern/src/components/Settings/useAPITokenManagerState.ts",
"frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx",
"frontend-modern/src/components/Settings/useNodeModalState.ts",
"frontend-modern/src/types/api.ts",
@ -883,12 +901,15 @@
"label": "API token management surface proof",
"match_prefixes": [],
"match_files": [
"frontend-modern/src/components/Settings/APITokenManager.tsx"
"frontend-modern/src/components/Settings/APITokenManager.tsx",
"frontend-modern/src/components/Settings/apiTokenManagerModel.ts",
"frontend-modern/src/components/Settings/useAPITokenManagerState.ts"
],
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
"frontend-modern/src/components/Settings/__tests__/APITokenManager.test.tsx"
"frontend-modern/src/components/Settings/__tests__/APITokenManager.test.tsx",
"frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts"
]
},
{
@ -1873,89 +1894,89 @@
"internal/unifiedresources/code_standards_test.go"
],
"require_explicit_path_policy_coverage": true,
"path_policies": [
{
"id": "discovery-provider-runtime",
"label": "monitoring discovery provider proof",
"match_prefixes": [],
"match_files": [
"internal/monitoring/kubernetes_agents.go",
"internal/monitoring/monitor_discovery_helpers.go",
"internal/monitoring/monitor_pbs_pmg.go",
"internal/monitoring/poll_providers.go",
"internal/monitoring/truenas_poller.go"
],
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
"internal/monitoring/kubernetes_agents_test.go",
"internal/monitoring/monitor_host_agents_test.go",
"internal/monitoring/monitor_pbs_coverage_test.go",
"internal/monitoring/monitor_pmg_test.go",
"internal/monitoring/monitor_polling_test.go",
"internal/monitoring/truenas_poller_test.go"
]
},
{
"id": "metrics-hot-path",
"label": "monitoring metrics hot-path proof",
"match_prefixes": [],
"match_files": [
"internal/monitoring/monitor_metrics.go"
],
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
"internal/monitoring/monitor_metrics_chart_batch_bench_test.go",
"internal/monitoring/monitor_metrics_slo_test.go"
]
},
{
"id": "metrics-history-runtime",
"label": "monitoring metrics history proof",
"match_prefixes": [],
"match_files": [
"internal/monitoring/metrics.go",
"internal/monitoring/metrics_history.go",
"internal/monitoring/mock_metrics_history.go"
],
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
"internal/monitoring/metrics_history_concurrency_test.go",
"internal/monitoring/metrics_history_memory_regression_test.go",
"internal/monitoring/metrics_history_test.go",
"internal/monitoring/metrics_test.go",
"internal/monitoring/mock_metrics_history_test.go"
]
},
{
"id": "memory-source-runtime",
"label": "monitoring memory-source proof",
"match_prefixes": [],
"match_files": [
"internal/api/diagnostics.go",
"internal/monitoring/guest_memory_sources.go",
"internal/monitoring/memory_source_catalog.go",
"internal/monitoring/monitor_polling_node.go",
"internal/monitoring/monitor_polling_vm.go",
"internal/monitoring/node_memory_sources.go"
],
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
"internal/api/diagnostics_memory_test.go",
"internal/monitoring/diagnostic_snapshots_test.go",
"internal/monitoring/memory_source_catalog_test.go",
"internal/monitoring/memory_trust_characterization_test.go",
"internal/monitoring/node_memory_sources_test.go"
]
},
{
"id": "container-entrypoint-runtime",
"label": "container entrypoint runtime proof",
"match_prefixes": [],
"match_files": [
"path_policies": [
{
"id": "discovery-provider-runtime",
"label": "monitoring discovery provider proof",
"match_prefixes": [],
"match_files": [
"internal/monitoring/kubernetes_agents.go",
"internal/monitoring/monitor_discovery_helpers.go",
"internal/monitoring/monitor_pbs_pmg.go",
"internal/monitoring/poll_providers.go",
"internal/monitoring/truenas_poller.go"
],
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
"internal/monitoring/kubernetes_agents_test.go",
"internal/monitoring/monitor_host_agents_test.go",
"internal/monitoring/monitor_pbs_coverage_test.go",
"internal/monitoring/monitor_pmg_test.go",
"internal/monitoring/monitor_polling_test.go",
"internal/monitoring/truenas_poller_test.go"
]
},
{
"id": "metrics-hot-path",
"label": "monitoring metrics hot-path proof",
"match_prefixes": [],
"match_files": [
"internal/monitoring/monitor_metrics.go"
],
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
"internal/monitoring/monitor_metrics_chart_batch_bench_test.go",
"internal/monitoring/monitor_metrics_slo_test.go"
]
},
{
"id": "metrics-history-runtime",
"label": "monitoring metrics history proof",
"match_prefixes": [],
"match_files": [
"internal/monitoring/metrics.go",
"internal/monitoring/metrics_history.go",
"internal/monitoring/mock_metrics_history.go"
],
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
"internal/monitoring/metrics_history_concurrency_test.go",
"internal/monitoring/metrics_history_memory_regression_test.go",
"internal/monitoring/metrics_history_test.go",
"internal/monitoring/metrics_test.go",
"internal/monitoring/mock_metrics_history_test.go"
]
},
{
"id": "memory-source-runtime",
"label": "monitoring memory-source proof",
"match_prefixes": [],
"match_files": [
"internal/api/diagnostics.go",
"internal/monitoring/guest_memory_sources.go",
"internal/monitoring/memory_source_catalog.go",
"internal/monitoring/monitor_polling_node.go",
"internal/monitoring/monitor_polling_vm.go",
"internal/monitoring/node_memory_sources.go"
],
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
"internal/api/diagnostics_memory_test.go",
"internal/monitoring/diagnostic_snapshots_test.go",
"internal/monitoring/memory_source_catalog_test.go",
"internal/monitoring/memory_trust_characterization_test.go",
"internal/monitoring/node_memory_sources_test.go"
]
},
{
"id": "container-entrypoint-runtime",
"label": "container entrypoint runtime proof",
"match_prefixes": [],
"match_files": [
"docker-entrypoint.sh"
],
"allow_same_subsystem_tests": false,
@ -2363,9 +2384,11 @@
"docs/PRIVACY.md",
"frontend-modern/src/api/security.ts",
"frontend-modern/src/components/Settings/APITokenManager.tsx",
"frontend-modern/src/components/Settings/apiTokenManagerModel.ts",
"frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx",
"frontend-modern/src/components/Settings/SecurityAuthPanel.tsx",
"frontend-modern/src/components/Settings/SecurityOverviewPanel.tsx",
"frontend-modern/src/components/Settings/useAPITokenManagerState.ts",
"frontend-modern/src/components/Settings/useSystemSettingsState.ts",
"internal/api/security.go",
"internal/api/security_tokens.go",
@ -2416,9 +2439,11 @@
"match_prefixes": [],
"match_files": [
"frontend-modern/src/components/Settings/APITokenManager.tsx",
"frontend-modern/src/components/Settings/apiTokenManagerModel.ts",
"frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx",
"frontend-modern/src/components/Settings/SecurityAuthPanel.tsx",
"frontend-modern/src/components/Settings/SecurityOverviewPanel.tsx"
"frontend-modern/src/components/Settings/SecurityOverviewPanel.tsx",
"frontend-modern/src/components/Settings/useAPITokenManagerState.ts"
],
"allow_same_subsystem_tests": false,
"test_prefixes": [],
@ -2644,10 +2669,10 @@
"internal/unifiedresources/code_standards_test.go"
],
"require_explicit_path_policy_coverage": true,
"path_policies": [
{
"id": "identity-canonicalization",
"label": "canonical identity proof",
"path_policies": [
{
"id": "identity-canonicalization",
"label": "canonical identity proof",
"match_prefixes": [],
"match_files": [
"internal/unifiedresources/canonical_identity.go",
@ -2687,9 +2712,9 @@
"internal/unifiedresources/views_test.go"
]
},
{
"id": "resource-consumers",
"label": "unified resource consumer proof",
{
"id": "resource-consumers",
"label": "unified resource consumer proof",
"match_prefixes": [],
"match_files": [
"frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts",
@ -2724,52 +2749,52 @@
"frontend-modern/src/stores/__tests__/websocket-unified.test.ts",
"frontend-modern/src/types/__tests__/resource.test.ts",
"internal/unifiedresources/code_standards_test.go"
]
},
{
"id": "metrics-target-runtime",
"label": "unified resource metrics target proof",
"match_prefixes": [],
"match_files": [
"internal/unifiedresources/metrics.go",
"internal/unifiedresources/metrics_targets.go"
],
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
"internal/unifiedresources/metrics_targets_test.go",
"internal/unifiedresources/metrics_test.go"
]
},
{
"id": "platform-registry-runtime",
"label": "unified resource platform registry proof",
"match_prefixes": [],
"match_files": [
"internal/unifiedresources/kubernetes_capabilities.go",
"internal/unifiedresources/pbs_rollups.go",
"internal/unifiedresources/registry.go",
"internal/unifiedresources/resolve.go",
"internal/unifiedresources/resolved_host_set.go",
"internal/unifiedresources/snapshot_source_filter.go",
"internal/unifiedresources/store.go"
],
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
"internal/unifiedresources/kubernetes_registry_test.go",
"internal/unifiedresources/pbs_pmg_registry_test.go",
"internal/unifiedresources/registry_merge_policy_test.go",
"internal/unifiedresources/registry_test.go",
"internal/unifiedresources/resolve_test.go",
"internal/unifiedresources/resolved_host_set_test.go",
"internal/unifiedresources/snapshot_source_filter_test.go",
"internal/unifiedresources/store_test.go"
]
},
{
"id": "unified-resource-runtime-support",
"label": "unified resource runtime support proof",
]
},
{
"id": "metrics-target-runtime",
"label": "unified resource metrics target proof",
"match_prefixes": [],
"match_files": [
"internal/unifiedresources/metrics.go",
"internal/unifiedresources/metrics_targets.go"
],
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
"internal/unifiedresources/metrics_targets_test.go",
"internal/unifiedresources/metrics_test.go"
]
},
{
"id": "platform-registry-runtime",
"label": "unified resource platform registry proof",
"match_prefixes": [],
"match_files": [
"internal/unifiedresources/kubernetes_capabilities.go",
"internal/unifiedresources/pbs_rollups.go",
"internal/unifiedresources/registry.go",
"internal/unifiedresources/resolve.go",
"internal/unifiedresources/resolved_host_set.go",
"internal/unifiedresources/snapshot_source_filter.go",
"internal/unifiedresources/store.go"
],
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
"internal/unifiedresources/kubernetes_registry_test.go",
"internal/unifiedresources/pbs_pmg_registry_test.go",
"internal/unifiedresources/registry_merge_policy_test.go",
"internal/unifiedresources/registry_test.go",
"internal/unifiedresources/resolve_test.go",
"internal/unifiedresources/resolved_host_set_test.go",
"internal/unifiedresources/snapshot_source_filter_test.go",
"internal/unifiedresources/store_test.go"
]
},
{
"id": "unified-resource-runtime-support",
"label": "unified resource runtime support proof",
"match_prefixes": [
"internal/unifiedresources/"
],

View file

@ -25,32 +25,36 @@ token-management visibility, and privacy controls to operators.
2. `docs/PRIVACY.md`
3. `frontend-modern/src/api/security.ts`
4. `frontend-modern/src/components/Settings/APITokenManager.tsx`
5. `frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx`
6. `frontend-modern/src/components/Settings/SecurityAuthPanel.tsx`
7. `frontend-modern/src/components/Settings/SecurityOverviewPanel.tsx`
8. `frontend-modern/src/components/Settings/useSystemSettingsState.ts`
9. `internal/api/security.go`
10. `internal/api/security_tokens.go`
11. `internal/api/system_settings.go`
12. `internal/telemetry/telemetry.go`
5. `frontend-modern/src/components/Settings/apiTokenManagerModel.ts`
6. `frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx`
7. `frontend-modern/src/components/Settings/SecurityAuthPanel.tsx`
8. `frontend-modern/src/components/Settings/SecurityOverviewPanel.tsx`
9. `frontend-modern/src/components/Settings/useAPITokenManagerState.ts`
10. `frontend-modern/src/components/Settings/useSystemSettingsState.ts`
11. `internal/api/security.go`
12. `internal/api/security_tokens.go`
13. `internal/api/system_settings.go`
14. `internal/telemetry/telemetry.go`
## Shared Boundaries
1. `frontend-modern/src/api/security.ts` shared with `api-contracts`: the security frontend client is both a security/privacy control surface and a canonical API payload contract boundary.
2. `frontend-modern/src/components/Settings/APITokenManager.tsx` shared with `api-contracts`: the API token settings surface is both a security/privacy control surface and a canonical API payload contract boundary.
3. `frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx` shared with `frontend-primitives`: the general settings privacy panel is both a security/privacy control surface and a canonical settings-shell presentation boundary.
4. `frontend-modern/src/components/Settings/SecurityAuthPanel.tsx` shared with `frontend-primitives`: the authentication settings surface is both a security/privacy control surface and a canonical settings-shell presentation boundary.
5. `frontend-modern/src/components/Settings/SecurityOverviewPanel.tsx` shared with `frontend-primitives`: the security overview settings surface is both a security/privacy control surface and a canonical settings-shell presentation boundary.
6. `internal/api/security.go` shared with `api-contracts`: the security handlers are both a security/privacy control surface and a canonical API payload contract boundary.
7. `internal/api/security_tokens.go` shared with `api-contracts`: the security token handlers are both a security/privacy control surface and a canonical API payload contract boundary.
8. `internal/api/system_settings.go` shared with `api-contracts`: the system settings telemetry and auth controls are both a security/privacy control surface and a canonical API payload contract boundary.
3. `frontend-modern/src/components/Settings/apiTokenManagerModel.ts` shared with `api-contracts`: the pure API token settings model is both a security/privacy control surface and a canonical API payload contract boundary.
4. `frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx` shared with `frontend-primitives`: the general settings privacy panel is both a security/privacy control surface and a canonical settings-shell presentation boundary.
5. `frontend-modern/src/components/Settings/SecurityAuthPanel.tsx` shared with `frontend-primitives`: the authentication settings surface is both a security/privacy control surface and a canonical settings-shell presentation boundary.
6. `frontend-modern/src/components/Settings/SecurityOverviewPanel.tsx` shared with `frontend-primitives`: the security overview settings surface is both a security/privacy control surface and a canonical settings-shell presentation boundary.
7. `frontend-modern/src/components/Settings/useAPITokenManagerState.ts` shared with `api-contracts`: the API token settings state hook is both a security/privacy control surface and a canonical API payload contract boundary.
8. `internal/api/security.go` shared with `api-contracts`: the security handlers are both a security/privacy control surface and a canonical API payload contract boundary.
9. `internal/api/security_tokens.go` shared with `api-contracts`: the security token handlers are both a security/privacy control surface and a canonical API payload contract boundary.
10. `internal/api/system_settings.go` shared with `api-contracts`: the system settings telemetry and auth controls are both a security/privacy control surface and a canonical API payload contract boundary.
## Extension Points
1. Change privacy disclosures or outbound-data guarantees through `docs/PRIVACY.md` and `internal/telemetry/telemetry.go` together.
2. Change security policy, hardening guidance, or supported auth boundaries through `SECURITY.md`.
3. Change telemetry/privacy settings state handling through `frontend-modern/src/components/Settings/useSystemSettingsState.ts`.
4. Change security/auth/token transport behavior through the shared `frontend-modern/src/api/security.ts`, `frontend-modern/src/components/Settings/APITokenManager.tsx`, `internal/api/security.go`, `internal/api/security_tokens.go`, and `internal/api/system_settings.go` boundary.
4. Change security/auth/token transport behavior through the shared `frontend-modern/src/api/security.ts`, `frontend-modern/src/components/Settings/APITokenManager.tsx`, `frontend-modern/src/components/Settings/apiTokenManagerModel.ts`, `frontend-modern/src/components/Settings/useAPITokenManagerState.ts`, `internal/api/security.go`, `internal/api/security_tokens.go`, and `internal/api/system_settings.go` boundary.
5. Change security/privacy settings presentation through the shared `frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx`, `frontend-modern/src/components/Settings/SecurityAuthPanel.tsx`, and `frontend-modern/src/components/Settings/SecurityOverviewPanel.tsx` boundary.
## Forbidden Paths
@ -212,7 +216,9 @@ migration input. Once a legacy plaintext license file can be read, canonical
persistence must rewrite encrypted-at-rest storage immediately on load instead
of allowing plaintext licensing state to remain on the runtime primary path.
That same shared token-settings boundary must stay under explicit proof routing
on both sides: `frontend-modern/src/components/Settings/APITokenManager.tsx`
must continue to carry the direct `security-settings-surfaces` proof path
together with the API-contract token-management proof instead of borrowing
coverage only from broader settings-shell or API ownership.
on both sides: `frontend-modern/src/components/Settings/APITokenManager.tsx`,
`frontend-modern/src/components/Settings/apiTokenManagerModel.ts`, and
`frontend-modern/src/components/Settings/useAPITokenManagerState.ts` must
continue to carry the direct `security-settings-surfaces` proof path together
with the API-contract token-management proof instead of borrowing coverage only
from broader settings-shell or API ownership.

View file

@ -1,48 +1,10 @@
import {
Component,
For,
Show,
createEffect,
createMemo,
createSignal,
onCleanup,
onMount,
} from 'solid-js';
import { unwrap } from 'solid-js/store';
import { SecurityAPI, type APITokenRecord } from '@/api/security';
import { notificationStore } from '@/stores/notifications';
import { formatRelativeTime } from '@/utils/format';
import { useWebSocket } from '@/App';
import { getPulseBaseUrl } from '@/utils/url';
import { showTokenReveal, useTokenRevealState } from '@/stores/tokenReveal';
import { logger } from '@/utils/logger';
import {
getAPITokenGenerateErrorMessage,
getAPITokensLoadErrorMessage,
getAPITokenRevokeErrorMessage,
} from '@/utils/apiTokenPresentation';
import { Component, For, Show } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { SectionHeader } from '@/components/shared/SectionHeader';
import { PulseDataGrid } from '@/components/shared/PulseDataGrid';
import { useResources } from '@/hooks/useResources';
import type { Resource } from '@/types/resource';
import {
getActionableAgentIdFromResource,
getActionableDockerRuntimeIdFromResource,
hasAgentFacet as resourceHasAgentFacet,
} from '@/utils/agentResources';
import { getPreferredResourceDisplayName } from '@/utils/resourceIdentity';
import BadgeCheck from 'lucide-solid/icons/badge-check';
import {
API_SCOPE_LABELS,
API_SCOPE_OPTIONS,
DOCKER_MANAGE_SCOPE,
DOCKER_REPORT_SCOPE,
AGENT_REPORT_SCOPE,
MONITORING_READ_SCOPE,
SETTINGS_READ_SCOPE,
SETTINGS_WRITE_SCOPE,
} from '@/constants/apiScopes';
import { MONITORING_READ_SCOPE } from '@/constants/apiScopes';
import { useAPITokenManagerState } from './useAPITokenManagerState';
interface APITokenManagerProps {
currentTokenHint?: string;
@ -51,439 +13,45 @@ interface APITokenManagerProps {
canManage?: boolean;
}
const SCOPES_DOC_URL =
'https://github.com/rcourtman/Pulse/blob/main/docs/CONFIGURATION.md#token-scopes';
const WILDCARD_SCOPE = '*';
export const APITokenManager: Component<APITokenManagerProps> = (props) => {
const { markDockerRuntimesTokenRevoked, markAgentsTokenRevoked } = useWebSocket();
const { byType, resources } = useResources();
const dockerRuntimeResources = createMemo(() => byType('docker-host'));
const hasAgentScopeResource = (resource: Resource): boolean => {
if (resource.type === 'docker-host') return false;
return (
resource.type === 'agent' ||
resource.type === 'pbs' ||
resource.type === 'pmg' ||
resource.type === 'truenas' ||
resourceHasAgentFacet(resource)
);
};
const agentCapableResources = createMemo(() =>
resources().filter((resource) => hasAgentScopeResource(resource)),
);
const readPlatformData = (resource: Resource): Record<string, unknown> | undefined => {
return resource.platformData
? (unwrap(resource.platformData) as Record<string, unknown>)
: undefined;
};
const readPlatformString = (value: unknown): string | undefined => {
return typeof value === 'string' && value.length > 0 ? value : undefined;
};
const readPlatformNumber = (value: unknown): number | undefined => {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
};
const readNestedPlatformField = (
platformData: Record<string, unknown> | undefined,
field: string,
): unknown => {
if (!platformData) return undefined;
if (field in platformData) return platformData[field];
const agent = platformData.agent;
if (agent && typeof agent === 'object' && field in (agent as Record<string, unknown>)) {
return (agent as Record<string, unknown>)[field];
}
const docker = platformData.docker;
if (docker && typeof docker === 'object' && field in (docker as Record<string, unknown>)) {
return (docker as Record<string, unknown>)[field];
}
return undefined;
};
const tokenIdForResource = (resource: Resource): string | undefined => {
const platformData = readPlatformData(resource);
return readPlatformString(readNestedPlatformField(platformData, 'tokenId'));
};
const agentActionIdForResource = (resource: Resource): string => {
return getActionableAgentIdFromResource(resource) || resource.id;
};
const dockerActionIdForResource = (resource: Resource): string => {
return getActionableDockerRuntimeIdFromResource(resource) || resource.id;
};
const revokedTokenIdForResource = (resource: Resource): string | undefined => {
const platformData = readPlatformData(resource);
return readPlatformString(readNestedPlatformField(platformData, 'revokedTokenId'));
};
const tokenRevokedAtForResource = (resource: Resource): number | undefined => {
const platformData = readPlatformData(resource);
return readPlatformNumber(readNestedPlatformField(platformData, 'tokenRevokedAt'));
};
const dockerTokenUsage = createMemo(() => {
type UsageRuntime = { id: string; label: string };
const usage = new Map<string, { count: number; runtimes: UsageRuntime[] }>();
for (const resource of dockerRuntimeResources()) {
const tokenId = tokenIdForResource(resource);
if (!tokenId) continue;
const runtimeId = dockerActionIdForResource(resource);
const label = getPreferredResourceDisplayName(resource);
const previous = usage.get(tokenId);
if (previous) {
if (previous.runtimes.some((runtime) => runtime.id === runtimeId)) continue;
usage.set(tokenId, {
count: previous.count + 1,
runtimes: [...previous.runtimes, { id: runtimeId, label }],
});
} else {
usage.set(tokenId, {
count: 1,
runtimes: [{ id: runtimeId, label }],
});
}
}
return usage;
});
const agentTokenUsage = createMemo(() => {
type UsageAgent = { id: string; label: string };
const usage = new Map<string, { count: number; agents: UsageAgent[] }>();
for (const resource of agentCapableResources()) {
const tokenId = tokenIdForResource(resource);
if (!tokenId) continue;
const agentId = agentActionIdForResource(resource);
const label = getPreferredResourceDisplayName(resource);
const previous = usage.get(tokenId);
if (previous) {
if (previous.agents.some((agent) => agent.id === agentId)) continue;
usage.set(tokenId, {
count: previous.count + 1,
agents: [...previous.agents, { id: agentId, label }],
});
} else {
usage.set(tokenId, { count: 1, agents: [{ id: agentId, label }] });
}
}
return usage;
});
const [tokens, setTokens] = createSignal<APITokenRecord[]>([]);
const [tokensLoaded, setTokensLoaded] = createSignal(false);
const sortedTokens = createMemo(() =>
[...tokens()].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()),
);
const totalTokens = createMemo(() => sortedTokens().length);
const wildcardCount = createMemo(
() =>
sortedTokens().filter((token) => {
const scopes = token.scopes;
return !scopes || scopes.length === 0 || scopes.includes('*');
}).length,
);
const scopedTokenCount = createMemo(() => totalTokens() - wildcardCount());
const hasWildcardTokens = createMemo(() => wildcardCount() > 0);
const [loading, setLoading] = createSignal(true);
const [isGenerating, setIsGenerating] = createSignal(false);
const [newTokenValue, setNewTokenValue] = createSignal<string | null>(null);
const [newTokenRecord, setNewTokenRecord] = createSignal<APITokenRecord | null>(null);
const [nameInput, setNameInput] = createSignal('');
const tokenRevealState = useTokenRevealState();
const [selectedScopes, setSelectedScopes] = createSignal<string[]>([]);
const canManage = () => props.canManage !== false;
type ScopeGroup = (typeof API_SCOPE_OPTIONS)[number]['group'];
type ScopeOption = (typeof API_SCOPE_OPTIONS)[number];
const scopeGroupOrder: ScopeGroup[] = ['Monitoring', 'Agents', 'Settings'];
const scopeGroups = createMemo<[ScopeGroup, ScopeOption[]][]>(() => {
const grouped: Record<ScopeGroup, ScopeOption[]> = {
Monitoring: [],
Agents: [],
Settings: [],
};
for (const option of API_SCOPE_OPTIONS) {
grouped[option.group].push(option);
}
return scopeGroupOrder
.map((group) => [group, grouped[group]] as [ScopeGroup, ScopeOption[]])
.filter(([, options]) => options.length > 0);
});
const isFullAccessSelected = () =>
selectedScopes().length === 0 || selectedScopes().includes(WILDCARD_SCOPE);
const scopePresets: { label: string; scopes: string[]; description: string }[] = [
{
label: 'Kiosk / Dashboard',
scopes: [MONITORING_READ_SCOPE],
description:
'Read-only access for wall displays. Use ?token=xxx&kiosk=1 in the URL to hide navigation and filters.',
},
{
label: 'Agent',
scopes: [AGENT_REPORT_SCOPE],
description: 'Allow the Pulse agent to submit OS, CPU, and disk metrics.',
},
{
label: 'Container report',
scopes: [DOCKER_REPORT_SCOPE],
description:
'Permits container agents (Docker or Podman) to stream runtime and container telemetry only.',
},
{
label: 'Container manage',
scopes: [DOCKER_REPORT_SCOPE, DOCKER_MANAGE_SCOPE],
description: 'Extends container reporting with lifecycle actions (restart, stop, etc.).',
},
{
label: 'Settings read',
scopes: [SETTINGS_READ_SCOPE],
description: 'Read configuration snapshots and diagnostics without modifying anything.',
},
{
label: 'Settings admin',
scopes: [SETTINGS_READ_SCOPE, SETTINGS_WRITE_SCOPE],
description: 'Full settings read/write – equivalent to automation with admin privileges.',
},
];
const presetMatchesSelection = (presetScopes: string[]) => {
const selection = [...selectedScopes()].filter((scope) => scope !== WILDCARD_SCOPE).sort();
const target = [...presetScopes].sort();
if (target.length === 0) {
return isFullAccessSelected();
}
if (selection.length !== target.length) {
return false;
}
return target.every((scope) => selection.includes(scope));
};
const applyScopePreset = (scopes: string[]) => {
const unique = Array.from(new Set(scopes)).filter(Boolean);
setSelectedScopes(unique);
};
const clearScopes = () => setSelectedScopes([]);
let createSectionRef: HTMLDivElement | undefined;
const [createHighlight, setCreateHighlight] = createSignal(false);
let highlightTimer: number | undefined;
const focusCreateSection = () => {
if (!createSectionRef) return;
createSectionRef.scrollIntoView({ behavior: 'smooth', block: 'start' });
setCreateHighlight(true);
window.clearTimeout(highlightTimer);
highlightTimer = window.setTimeout(() => setCreateHighlight(false), 1600);
};
onCleanup(() => {
if (highlightTimer) window.clearTimeout(highlightTimer);
});
const loadTokens = async () => {
setLoading(true);
setTokensLoaded(false);
try {
const list = await SecurityAPI.listTokens();
setTokens(list);
setTokensLoaded(true);
} catch (err) {
logger.error('Failed to load API tokens', err);
notificationStore.error(getAPITokensLoadErrorMessage());
} finally {
setLoading(false);
}
};
onMount(() => {
void loadTokens();
});
createEffect(() => {
if (!tokensLoaded()) return;
const activeTokenIds = new Set(tokens().map((token) => token.id));
const pendingRuntimesByToken = new Map<string, string[]>();
for (const resource of dockerRuntimeResources()) {
const tokenId = tokenIdForResource(resource);
if (!tokenId) continue;
if (activeTokenIds.has(tokenId)) continue;
if (revokedTokenIdForResource(resource) === tokenId) continue;
if (!pendingRuntimesByToken.has(tokenId)) {
pendingRuntimesByToken.set(tokenId, []);
}
const runtimeIds = pendingRuntimesByToken.get(tokenId)!;
const runtimeId = dockerActionIdForResource(resource);
if (!runtimeIds.includes(runtimeId)) {
runtimeIds.push(runtimeId);
}
}
pendingRuntimesByToken.forEach((runtimeIds, tokenId) => {
if (runtimeIds.length === 0) return;
markDockerRuntimesTokenRevoked(tokenId, runtimeIds);
});
const pendingAgentsByToken = new Map<string, string[]>();
for (const resource of agentCapableResources()) {
const tokenId = tokenIdForResource(resource);
if (!tokenId) continue;
if (activeTokenIds.has(tokenId)) continue;
if (revokedTokenIdForResource(resource) === tokenId && tokenRevokedAtForResource(resource))
continue;
if (!pendingAgentsByToken.has(tokenId)) {
pendingAgentsByToken.set(tokenId, []);
}
const agentIds = pendingAgentsByToken.get(tokenId)!;
const agentId = agentActionIdForResource(resource);
if (!agentIds.includes(agentId)) {
agentIds.push(agentId);
}
}
pendingAgentsByToken.forEach((agentIds, tokenId) => {
if (agentIds.length === 0) return;
markAgentsTokenRevoked(tokenId, agentIds);
});
});
const handleGenerate = async () => {
if (!canManage()) return;
setIsGenerating(true);
try {
const trimmedName = nameInput().trim() || undefined;
const scopeSelection = [...selectedScopes()].sort();
const scopePayload = scopeSelection.length > 0 ? scopeSelection : undefined;
const { token, record } = await SecurityAPI.createToken(trimmedName, scopePayload);
setTokens((prev) => [record, ...prev]);
setNewTokenRecord(record);
setNewTokenValue(token);
setNameInput('');
showTokenReveal({
token,
record,
source: 'security',
note: 'Copy this token now. You can reopen this dialog from Security → API tokens while this page stays open.',
});
notificationStore.success(
'New API token generated. Copy it below while it is still visible.',
);
props.onTokensChanged?.();
} catch (err) {
logger.error('Failed to generate API token', err);
notificationStore.error(getAPITokenGenerateErrorMessage(err));
} finally {
setIsGenerating(false);
}
};
const tokenHint = (record: APITokenRecord | null | undefined) => {
if (!record) return '—';
if (record.prefix && record.suffix) {
return `${record.prefix}…${record.suffix}`;
}
if (record.prefix) {
return `${record.prefix}…`;
}
return '—';
};
const tokenNameForDialog = (record: APITokenRecord) => {
if (record.name?.trim()) return record.name.trim();
if (record.prefix && record.suffix) return `${record.prefix}…${record.suffix}`;
if (record.prefix) return `${record.prefix}…`;
return 'untitled token';
};
const handleDelete = async (record: APITokenRecord) => {
if (!canManage()) return;
const dockerUsage = dockerTokenUsage().get(record.id);
const agentUsage = agentTokenUsage().get(record.id);
const displayName = tokenNameForDialog(record);
const affectedRuntimeIds = dockerUsage ? dockerUsage.runtimes.map((runtime) => runtime.id) : [];
const affectedAgentIds = agentUsage ? agentUsage.agents.map((agent) => agent.id) : [];
let revokeMessage: string | undefined;
const messageChunks: string[] = [];
if (dockerUsage) {
const runtimeListPreview = dockerUsage.runtimes
.slice(0, 5)
.map((runtime) => runtime.label)
.join(', ');
const extraCount = dockerUsage.runtimes.length - 5;
const usageSummary =
extraCount > 0 ? `${runtimeListPreview}, +${extraCount} more` : runtimeListPreview;
const runtimeCountLabel =
dockerUsage.count === 1 ? 'container runtime' : `${dockerUsage.count} container runtimes`;
messageChunks.push(`${runtimeCountLabel}: ${usageSummary}`);
}
if (agentUsage) {
const agentListPreview = agentUsage.agents
.slice(0, 5)
.map((agent) => agent.label)
.join(', ');
const agentExtra = agentUsage.agents.length - 5;
const agentSummary =
agentExtra > 0 ? `${agentListPreview}, +${agentExtra} more` : agentListPreview;
const agentCountLabel = agentUsage.count === 1 ? 'agent' : `${agentUsage.count} agents`;
messageChunks.push(`${agentCountLabel}: ${agentSummary}`);
}
if (messageChunks.length > 0) {
revokeMessage = `Token "${displayName}" was previously used by ${messageChunks.join(' • ')}. Update those agents with a new token.`;
}
try {
await SecurityAPI.deleteToken(record.id);
setTokens((prev) => prev.filter((token) => token.id !== record.id));
notificationStore.success(
revokeMessage ? `Token revoked: ${revokeMessage}` : 'Token revoked',
);
props.onTokensChanged?.();
if (affectedRuntimeIds.length > 0) {
markDockerRuntimesTokenRevoked(record.id, affectedRuntimeIds);
}
if (affectedAgentIds.length > 0) {
markAgentsTokenRevoked(record.id, affectedAgentIds);
}
const current = newTokenRecord();
if (current && current.id === record.id) {
setNewTokenValue(null);
setNewTokenRecord(null);
}
} catch (err) {
logger.error('Failed to revoke API token', err);
notificationStore.error(getAPITokenRevokeErrorMessage());
}
};
const isRevealActiveForCurrentToken = () => {
const active = tokenRevealState();
if (!active) return false;
return newTokenValue() !== null && active.token === newTokenValue();
};
const reopenTokenDialog = () => {
const token = newTokenValue();
const record = newTokenRecord();
if (!token || !record) return;
showTokenReveal({
token,
record,
source: 'security',
note: 'Copy this token now. Close the dialog once you have stored it safely.',
});
};
const {
API_SCOPE_LABELS,
API_TOKEN_SCOPES_DOC_URL,
agentTokenUsage,
applyScopePreset,
canManage,
clearScopes,
copyNewMonitoringKioskLink,
createHighlight,
dismissNewToken,
dockerTokenUsage,
focusCreateSection,
formatRelativeTime,
handleDelete,
handleGenerate,
hasWildcardTokens,
isFullAccessSelected,
isGenerating,
isRevealActiveForCurrentToken,
loading,
nameInput,
newMonitoringKioskLink,
newTokenRecord,
newTokenValue,
reopenTokenDialog,
scopedTokenCount,
scopeGroups,
scopePresets,
selectedScopes,
setCreateSectionRef,
setNameInput,
sortedTokens,
tokenHint,
toggleScope,
totalTokens,
wildcardCount,
presetMatchesSelection,
} = useAPITokenManagerState(props);
return (
<div class="space-y-5">
@ -626,8 +194,7 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
</button>
<button
onClick={() => {
setNewTokenValue(null);
setNewTokenRecord(null);
dismissNewToken();
}}
class="font-medium underline decoration-green-500 underline-offset-2 hover:text-green-900 dark:hover:text-green-100"
>
@ -650,15 +217,11 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
</p>
<div class="flex items-center gap-2">
<code class="flex-1 rounded border border-blue-200 bg-surface px-3 py-2 font-mono text-xs text-blue-800 dark:border-blue-800 dark:bg-black dark:text-blue-200 break-all">
{getPulseBaseUrl()}/?token={newTokenValue()}&kiosk=1
{newMonitoringKioskLink()}
</code>
<button
type="button"
onClick={() => {
const url = `${getPulseBaseUrl()}/?token=${newTokenValue()}&kiosk=1`;
navigator.clipboard.writeText(url);
notificationStore.success('Link copied to clipboard');
}}
onClick={() => void copyNewMonitoringKioskLink()}
class="flex-shrink-0 rounded-md bg-blue-600 px-3 py-2 text-xs font-semibold text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 dark:bg-blue-600 dark:hover:bg-blue-500"
>
Copy Link
@ -771,21 +334,21 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
if (dockerUsageEntry) {
usageSegments.push(
dockerUsageEntry.count === 1
? (dockerUsageEntry.runtimes[0]?.label ?? 'Container runtime')
? (dockerUsageEntry.items[0]?.label ?? 'Container runtime')
: `${dockerUsageEntry.count} container runtimes`,
);
usageTitleSegments.push(
`Container runtimes: ${dockerUsageEntry.runtimes.map((runtime) => runtime.label).join(', ')}`,
`Container runtimes: ${dockerUsageEntry.items.map((runtime) => runtime.label).join(', ')}`,
);
}
if (agentUsageEntry) {
usageSegments.push(
agentUsageEntry.count === 1
? `${agentUsageEntry.agents[0]?.label ?? 'Agent'}`
? `${agentUsageEntry.items[0]?.label ?? 'Agent'}`
: `${agentUsageEntry.count} agents`,
);
usageTitleSegments.push(
`Agents: ${agentUsageEntry.agents.map((agent) => agent.label).join(', ')}`,
`Agents: ${agentUsageEntry.items.map((agent) => agent.label).join(', ')}`,
);
}
const usageSummary = usageSegments.length > 0 ? usageSegments.join(' • ') : '—';
@ -861,9 +424,7 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
class={`border border-border transition-shadow ${
createHighlight() ? 'ring-2 ring-blue-500 shadow-sm' : ''
}`}
ref={(el: HTMLDivElement) => {
createSectionRef = el;
}}
ref={setCreateSectionRef}
>
<div class="flex flex-col gap-6 p-4 sm:p-6 lg:p-8">
<div class="flex flex-wrap items-start justify-between gap-4">
@ -959,14 +520,7 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
<button
type="button"
class={`min-h-10 sm:min-h-10 rounded-full border px-3 py-2 text-sm font-semibold transition ${isActive() ? 'border-blue-500 bg-blue-600 text-white shadow-sm' : 'border-border bg-surface text-base-content hover:border-blue-400 hover:text-blue-600 dark:hover:border-blue-400 dark:hover:text-blue-200'}`}
onClick={() => {
setSelectedScopes((prev) => {
if (prev.includes(option.value)) {
return prev.filter((v) => v !== option.value);
}
return [...prev, option.value];
});
}}
onClick={() => toggleScope(option.value)}
disabled={!canManage()}
title={option.description}
>
@ -1000,7 +554,7 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
Separate tokens per integration • Rotate regularly •{' '}
<a
class="inline-flex min-h-10 sm:min-h-10 items-center rounded-md px-2 py-1.5 text-sm font-medium text-blue-600 underline-offset-2 hover:underline dark:text-blue-400"
href={SCOPES_DOC_URL}
href={API_TOKEN_SCOPES_DOC_URL}
target="_blank"
rel="noreferrer"
>

View file

@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest';
import agentProfilesPanelSource from '../AgentProfilesPanel.tsx?raw';
import agentProfilesPanelStateSource from '../useAgentProfilesPanelState.ts?raw';
import apiTokenManagerSource from '../APITokenManager.tsx?raw';
import apiTokenManagerModelSource from '../apiTokenManagerModel.ts?raw';
import apiTokenManagerStateSource from '../useAPITokenManagerState.ts?raw';
import infrastructureOperationsStateSource from '../useInfrastructureOperationsState.tsx?raw';
import agentLedgerPanelSource from '../MonitoredSystemLedgerPanel.tsx?raw';
import alertsPageSource from '@/pages/Alerts.tsx?raw';
@ -167,33 +169,39 @@ describe('monitored-system model guardrails', () => {
});
it('keeps APITokenManager runtime usage mapped from unified resources', () => {
expect(apiTokenManagerSource).toContain('const agentCapableResources = createMemo(() =>');
expect(apiTokenManagerSource).toContain('@/utils/apiTokenPresentation');
expect(apiTokenManagerSource).toContain(
expect(apiTokenManagerSource).toContain("import { useAPITokenManagerState }");
expect(apiTokenManagerSource).toContain('useAPITokenManagerState(props)');
expect(apiTokenManagerSource).not.toContain('const agentCapableResources = createMemo(() =>');
expect(apiTokenManagerSource).not.toContain('@/utils/apiTokenPresentation');
expect(apiTokenManagerStateSource).toContain('const agentCapableResources = createMemo(() =>');
expect(apiTokenManagerStateSource).toContain('@/utils/apiTokenPresentation');
expect(apiTokenManagerStateSource).toContain(
"const dockerRuntimeResources = createMemo(() => byType('docker-host'))",
);
expect(apiTokenManagerSource).toContain('const hasAgentScopeResource = (resource: Resource)');
expect(apiTokenManagerSource).toContain("resource.type === 'agent'");
expect(apiTokenManagerSource).toContain("resource.type === 'pbs'");
expect(apiTokenManagerSource).toContain("resource.type === 'pmg'");
expect(apiTokenManagerSource).toContain("resource.type === 'truenas'");
expect(apiTokenManagerSource).toContain('resourceHasAgentFacet(resource)');
expect(apiTokenManagerSource).toContain('getActionableDockerRuntimeIdFromResource(resource)');
expect(apiTokenManagerSource).toContain('markDockerRuntimesTokenRevoked');
expect(apiTokenManagerSource).not.toContain('markDockerHostsTokenRevoked');
expect(apiTokenManagerSource).not.toContain("resource.type === 'host'");
expect(apiTokenManagerSource).not.toContain('markHostsTokenRevoked');
expect(apiTokenManagerSource).not.toContain(
expect(apiTokenManagerModelSource).toContain('export const hasAgentScopeResource =');
expect(apiTokenManagerModelSource).toContain("resource.type === 'agent'");
expect(apiTokenManagerModelSource).toContain("resource.type === 'pbs'");
expect(apiTokenManagerModelSource).toContain("resource.type === 'pmg'");
expect(apiTokenManagerModelSource).toContain("resource.type === 'truenas'");
expect(apiTokenManagerModelSource).toContain('resourceHasAgentFacet(resource)');
expect(apiTokenManagerModelSource).toContain(
'getActionableDockerRuntimeIdFromResource(resource)',
);
expect(apiTokenManagerStateSource).toContain('markDockerRuntimesTokenRevoked');
expect(apiTokenManagerStateSource).not.toContain('markDockerHostsTokenRevoked');
expect(apiTokenManagerModelSource).not.toContain("resource.type === 'host'");
expect(apiTokenManagerStateSource).not.toContain('markHostsTokenRevoked');
expect(apiTokenManagerStateSource).not.toContain(
"const hostResources = createMemo(() => byType('host'))",
);
expect(apiTokenManagerSource).not.toContain('isAppContainerDiscoveryResourceType');
expect(apiTokenManagerSource).not.toContain(
expect(apiTokenManagerModelSource).not.toContain('isAppContainerDiscoveryResourceType');
expect(apiTokenManagerStateSource).not.toContain(
"notificationStore.error('Failed to load API tokens')",
);
expect(apiTokenManagerSource).not.toContain(
expect(apiTokenManagerStateSource).not.toContain(
"notificationStore.error('Failed to generate API token')",
);
expect(apiTokenManagerSource).not.toContain(
expect(apiTokenManagerStateSource).not.toContain(
"notificationStore.error('Failed to revoke API token')",
);
expect(apiTokenPresentationSource).toContain('export function getAPITokensLoadErrorMessage');

View file

@ -11,6 +11,9 @@ import nodeModalSource from '../NodeModal.tsx?raw';
import infrastructureOperationsStateSource from '../useInfrastructureOperationsState.tsx?raw';
import nodeModalStateSource from '../useNodeModalState.ts?raw';
import apiAccessPanelSource from '../APIAccessPanel.tsx?raw';
import apiTokenManagerSource from '../APITokenManager.tsx?raw';
import apiTokenManagerModelSource from '../apiTokenManagerModel.ts?raw';
import apiTokenManagerStateSource from '../useAPITokenManagerState.ts?raw';
import auditLogPanelSource from '../AuditLogPanel.tsx?raw';
import auditLogStateSource from '../useAuditLogPanelState.ts?raw';
import auditWebhookPanelSource from '../AuditWebhookPanel.tsx?raw';
@ -63,6 +66,8 @@ const extractedModules = [
'../InfrastructureOperationsController.tsx',
'../infrastructureOperationsModel.tsx',
'../useInfrastructureOperationsState.tsx',
'../apiTokenManagerModel.ts',
'../useAPITokenManagerState.ts',
'../useAuditLogPanelState.ts',
'../useAuditWebhookPanelState.ts',
'../NodeModal.tsx',
@ -347,6 +352,20 @@ describe('Settings architecture guardrails', () => {
expect(infrastructureOperationsModelSource).toContain('export const buildCommandsByPlatform');
});
it('keeps the API token manager shell behind extracted state and model owners', () => {
expect(apiTokenManagerSource).toContain('./useAPITokenManagerState');
expect(apiTokenManagerSource).not.toContain('const [tokens, setTokens] = createSignal<APITokenRecord[]>([])');
expect(apiTokenManagerSource).not.toContain('const loadTokens = async () =>');
expect(apiTokenManagerSource).not.toContain('const hasAgentScopeResource = (resource: Resource)');
expect(apiTokenManagerStateSource).toContain('export const useAPITokenManagerState =');
expect(apiTokenManagerStateSource).toContain('const [tokens, setTokens] = createSignal<APITokenRecord[]>([])');
expect(apiTokenManagerStateSource).toContain('const loadTokens = async () =>');
expect(apiTokenManagerStateSource).toContain('SecurityAPI.listTokens()');
expect(apiTokenManagerModelSource).toContain('export const hasAgentScopeResource =');
expect(apiTokenManagerModelSource).toContain('export const buildDockerTokenUsage =');
expect(apiTokenManagerModelSource).toContain('export const buildAgentTokenUsage =');
});
it('keeps the node setup modal shell behind extracted state and model owners', () => {
expect(nodeModalSource).toContain('@/components/Settings/nodeModalModel');
expect(nodeModalSource).toContain('@/components/Settings/useNodeModalState');

View file

@ -0,0 +1,232 @@
import { unwrap } from 'solid-js/store';
import type { APITokenRecord } from '@/api/security';
import {
AGENT_REPORT_SCOPE,
API_SCOPE_OPTIONS,
DOCKER_MANAGE_SCOPE,
DOCKER_REPORT_SCOPE,
MONITORING_READ_SCOPE,
SETTINGS_READ_SCOPE,
SETTINGS_WRITE_SCOPE,
} from '@/constants/apiScopes';
import {
getActionableAgentIdFromResource,
getActionableDockerRuntimeIdFromResource,
hasAgentFacet as resourceHasAgentFacet,
} from '@/utils/agentResources';
import { getPreferredResourceDisplayName } from '@/utils/resourceIdentity';
import type { Resource } from '@/types/resource';
export const API_TOKEN_SCOPES_DOC_URL =
'https://github.com/rcourtman/Pulse/blob/main/docs/CONFIGURATION.md#token-scopes';
export const API_TOKEN_WILDCARD_SCOPE = '*';
export interface APITokenPreset {
label: string;
scopes: string[];
description: string;
}
export interface APITokenUsageEntry {
count: number;
items: { id: string; label: string }[];
}
type ScopeGroup = (typeof API_SCOPE_OPTIONS)[number]['group'];
type ScopeOption = (typeof API_SCOPE_OPTIONS)[number];
const API_TOKEN_SCOPE_GROUP_ORDER: ScopeGroup[] = ['Monitoring', 'Agents', 'Settings'];
export const API_TOKEN_SCOPE_PRESETS: APITokenPreset[] = [
{
label: 'Kiosk / Dashboard',
scopes: [MONITORING_READ_SCOPE],
description:
'Read-only access for wall displays. Use ?token=xxx&kiosk=1 in the URL to hide navigation and filters.',
},
{
label: 'Agent',
scopes: [AGENT_REPORT_SCOPE],
description: 'Allow the Pulse agent to submit OS, CPU, and disk metrics.',
},
{
label: 'Container report',
scopes: [DOCKER_REPORT_SCOPE],
description:
'Permits container agents (Docker or Podman) to stream runtime and container telemetry only.',
},
{
label: 'Container manage',
scopes: [DOCKER_REPORT_SCOPE, DOCKER_MANAGE_SCOPE],
description: 'Extends container reporting with lifecycle actions (restart, stop, etc.).',
},
{
label: 'Settings read',
scopes: [SETTINGS_READ_SCOPE],
description: 'Read configuration snapshots and diagnostics without modifying anything.',
},
{
label: 'Settings admin',
scopes: [SETTINGS_READ_SCOPE, SETTINGS_WRITE_SCOPE],
description: 'Full settings read/write – equivalent to automation with admin privileges.',
},
];
export const hasAgentScopeResource = (resource: Resource): boolean => {
if (resource.type === 'docker-host') return false;
return (
resource.type === 'agent' ||
resource.type === 'pbs' ||
resource.type === 'pmg' ||
resource.type === 'truenas' ||
resourceHasAgentFacet(resource)
);
};
const readPlatformData = (resource: Resource): Record<string, unknown> | undefined => {
return resource.platformData
? (unwrap(resource.platformData) as Record<string, unknown>)
: undefined;
};
const readPlatformString = (value: unknown): string | undefined => {
return typeof value === 'string' && value.length > 0 ? value : undefined;
};
const readPlatformNumber = (value: unknown): number | undefined => {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
};
const readNestedPlatformField = (
platformData: Record<string, unknown> | undefined,
field: string,
): unknown => {
if (!platformData) return undefined;
if (field in platformData) return platformData[field];
const agent = platformData.agent;
if (agent && typeof agent === 'object' && field in (agent as Record<string, unknown>)) {
return (agent as Record<string, unknown>)[field];
}
const docker = platformData.docker;
if (docker && typeof docker === 'object' && field in (docker as Record<string, unknown>)) {
return (docker as Record<string, unknown>)[field];
}
return undefined;
};
export const tokenIdForResource = (resource: Resource): string | undefined => {
return readPlatformString(readNestedPlatformField(readPlatformData(resource), 'tokenId'));
};
export const agentActionIdForResource = (resource: Resource): string => {
return getActionableAgentIdFromResource(resource) || resource.id;
};
export const dockerActionIdForResource = (resource: Resource): string => {
return getActionableDockerRuntimeIdFromResource(resource) || resource.id;
};
export const revokedTokenIdForResource = (resource: Resource): string | undefined => {
return readPlatformString(readNestedPlatformField(readPlatformData(resource), 'revokedTokenId'));
};
export const tokenRevokedAtForResource = (resource: Resource): number | undefined => {
return readPlatformNumber(readNestedPlatformField(readPlatformData(resource), 'tokenRevokedAt'));
};
const appendUsageEntry = (
usage: Map<string, APITokenUsageEntry>,
tokenId: string,
item: { id: string; label: string },
) => {
const previous = usage.get(tokenId);
if (!previous) {
usage.set(tokenId, { count: 1, items: [item] });
return;
}
if (previous.items.some((existing) => existing.id === item.id)) return;
usage.set(tokenId, {
count: previous.count + 1,
items: [...previous.items, item],
});
};
export const buildDockerTokenUsage = (resources: Resource[]): Map<string, APITokenUsageEntry> => {
const usage = new Map<string, APITokenUsageEntry>();
for (const resource of resources) {
const tokenId = tokenIdForResource(resource);
if (!tokenId) continue;
appendUsageEntry(usage, tokenId, {
id: dockerActionIdForResource(resource),
label: getPreferredResourceDisplayName(resource),
});
}
return usage;
};
export const buildAgentTokenUsage = (resources: Resource[]): Map<string, APITokenUsageEntry> => {
const usage = new Map<string, APITokenUsageEntry>();
for (const resource of resources) {
const tokenId = tokenIdForResource(resource);
if (!tokenId) continue;
appendUsageEntry(usage, tokenId, {
id: agentActionIdForResource(resource),
label: getPreferredResourceDisplayName(resource),
});
}
return usage;
};
export const sortAPITokensByCreatedAt = (tokens: APITokenRecord[]): APITokenRecord[] => {
return [...tokens].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
};
export const countWildcardTokens = (tokens: APITokenRecord[]): number => {
return tokens.filter((token) => {
const scopes = token.scopes;
return !scopes || scopes.length === 0 || scopes.includes(API_TOKEN_WILDCARD_SCOPE);
}).length;
};
export const groupAPITokenScopes = (): [ScopeGroup, ScopeOption[]][] => {
const grouped: Record<ScopeGroup, ScopeOption[]> = {
Monitoring: [],
Agents: [],
Settings: [],
};
for (const option of API_SCOPE_OPTIONS) {
grouped[option.group].push(option);
}
return API_TOKEN_SCOPE_GROUP_ORDER.map((group) => [group, grouped[group]] as [ScopeGroup, ScopeOption[]]).filter(
([, options]) => options.length > 0,
);
};
export const matchesScopePreset = (selectedScopes: string[], presetScopes: string[]): boolean => {
const selection = [...selectedScopes]
.filter((scope) => scope !== API_TOKEN_WILDCARD_SCOPE)
.sort();
const target = [...presetScopes].sort();
if (target.length === 0) {
return (
selectedScopes.length === 0 || selectedScopes.includes(API_TOKEN_WILDCARD_SCOPE)
);
}
if (selection.length !== target.length) return false;
return target.every((scope) => selection.includes(scope));
};
export const getAPITokenHint = (record: APITokenRecord | null | undefined): string => {
if (!record) return '—';
if (record.prefix && record.suffix) return `${record.prefix}…${record.suffix}`;
if (record.prefix) return `${record.prefix}…`;
return '—';
};
export const getAPITokenDialogName = (record: APITokenRecord): string => {
if (record.name?.trim()) return record.name.trim();
if (record.prefix && record.suffix) return `${record.prefix}…${record.suffix}`;
if (record.prefix) return `${record.prefix}…`;
return 'untitled token';
};

View file

@ -0,0 +1,355 @@
import {
createEffect,
createMemo,
createSignal,
onCleanup,
onMount,
} from 'solid-js';
import type { APITokenRecord } from '@/api/security';
import { SecurityAPI } from '@/api/security';
import { useWebSocket } from '@/App';
import { MONITORING_READ_SCOPE, API_SCOPE_LABELS } from '@/constants/apiScopes';
import { useResources } from '@/hooks/useResources';
import { notificationStore } from '@/stores/notifications';
import { showTokenReveal, useTokenRevealState } from '@/stores/tokenReveal';
import type { Resource } from '@/types/resource';
import { formatRelativeTime } from '@/utils/format';
import {
getAPITokenGenerateErrorMessage,
getAPITokensLoadErrorMessage,
getAPITokenRevokeErrorMessage,
} from '@/utils/apiTokenPresentation';
import { logger } from '@/utils/logger';
import { getPulseBaseUrl } from '@/utils/url';
import {
API_TOKEN_SCOPES_DOC_URL,
API_TOKEN_SCOPE_PRESETS,
API_TOKEN_WILDCARD_SCOPE,
agentActionIdForResource,
buildAgentTokenUsage,
buildDockerTokenUsage,
countWildcardTokens,
dockerActionIdForResource,
getAPITokenDialogName,
getAPITokenHint,
groupAPITokenScopes,
hasAgentScopeResource,
matchesScopePreset,
revokedTokenIdForResource,
sortAPITokensByCreatedAt,
tokenIdForResource,
tokenRevokedAtForResource,
} from './apiTokenManagerModel';
interface APITokenManagerProps {
currentTokenHint?: string;
onTokensChanged?: () => void;
refreshing?: boolean;
canManage?: boolean;
}
export const useAPITokenManagerState = (props: APITokenManagerProps) => {
const { markDockerRuntimesTokenRevoked, markAgentsTokenRevoked } = useWebSocket();
const { byType, resources } = useResources();
const dockerRuntimeResources = createMemo(() => byType('docker-host'));
const agentCapableResources = createMemo(() =>
resources().filter((resource: Resource) => hasAgentScopeResource(resource)),
);
const dockerTokenUsage = createMemo(() => buildDockerTokenUsage(dockerRuntimeResources()));
const agentTokenUsage = createMemo(() => buildAgentTokenUsage(agentCapableResources()));
const [tokens, setTokens] = createSignal<APITokenRecord[]>([]);
const [tokensLoaded, setTokensLoaded] = createSignal(false);
const [loading, setLoading] = createSignal(true);
const [isGenerating, setIsGenerating] = createSignal(false);
const [newTokenValue, setNewTokenValue] = createSignal<string | null>(null);
const [newTokenRecord, setNewTokenRecord] = createSignal<APITokenRecord | null>(null);
const [nameInput, setNameInput] = createSignal('');
const [selectedScopes, setSelectedScopes] = createSignal<string[]>([]);
const tokenRevealState = useTokenRevealState();
const canManage = () => props.canManage !== false;
const sortedTokens = createMemo(() => sortAPITokensByCreatedAt(tokens()));
const totalTokens = createMemo(() => sortedTokens().length);
const wildcardCount = createMemo(() => countWildcardTokens(sortedTokens()));
const scopedTokenCount = createMemo(() => totalTokens() - wildcardCount());
const hasWildcardTokens = createMemo(() => wildcardCount() > 0);
const scopeGroups = createMemo(() => groupAPITokenScopes());
const isFullAccessSelected = () =>
selectedScopes().length === 0 || selectedScopes().includes(API_TOKEN_WILDCARD_SCOPE);
let createSectionRef: HTMLDivElement | undefined;
const [createHighlight, setCreateHighlight] = createSignal(false);
let highlightTimer: number | undefined;
const setCreateSectionRef = (element: HTMLDivElement) => {
createSectionRef = element;
};
const focusCreateSection = () => {
if (!createSectionRef) return;
createSectionRef.scrollIntoView({ behavior: 'smooth', block: 'start' });
setCreateHighlight(true);
window.clearTimeout(highlightTimer);
highlightTimer = window.setTimeout(() => setCreateHighlight(false), 1600);
};
onCleanup(() => {
if (highlightTimer) window.clearTimeout(highlightTimer);
});
const loadTokens = async () => {
setLoading(true);
setTokensLoaded(false);
try {
const list = await SecurityAPI.listTokens();
setTokens(list);
setTokensLoaded(true);
} catch (err) {
logger.error('Failed to load API tokens', err);
notificationStore.error(getAPITokensLoadErrorMessage());
} finally {
setLoading(false);
}
};
onMount(() => {
void loadTokens();
});
createEffect(() => {
if (!tokensLoaded()) return;
const activeTokenIds = new Set(tokens().map((token) => token.id));
const pendingRuntimesByToken = new Map<string, string[]>();
for (const resource of dockerRuntimeResources()) {
const tokenId = tokenIdForResource(resource);
if (!tokenId || activeTokenIds.has(tokenId)) continue;
if (revokedTokenIdForResource(resource) === tokenId) continue;
if (!pendingRuntimesByToken.has(tokenId)) {
pendingRuntimesByToken.set(tokenId, []);
}
const runtimeIds = pendingRuntimesByToken.get(tokenId)!;
const runtimeId = dockerActionIdForResource(resource);
if (!runtimeIds.includes(runtimeId)) runtimeIds.push(runtimeId);
}
pendingRuntimesByToken.forEach((runtimeIds, tokenId) => {
if (runtimeIds.length > 0) markDockerRuntimesTokenRevoked(tokenId, runtimeIds);
});
const pendingAgentsByToken = new Map<string, string[]>();
for (const resource of agentCapableResources()) {
const tokenId = tokenIdForResource(resource);
if (!tokenId || activeTokenIds.has(tokenId)) continue;
if (revokedTokenIdForResource(resource) === tokenId && tokenRevokedAtForResource(resource)) {
continue;
}
if (!pendingAgentsByToken.has(tokenId)) {
pendingAgentsByToken.set(tokenId, []);
}
const agentIds = pendingAgentsByToken.get(tokenId)!;
const agentId = agentActionIdForResource(resource);
if (!agentIds.includes(agentId)) agentIds.push(agentId);
}
pendingAgentsByToken.forEach((agentIds, tokenId) => {
if (agentIds.length > 0) markAgentsTokenRevoked(tokenId, agentIds);
});
});
const applyScopePreset = (scopes: string[]) => {
setSelectedScopes(Array.from(new Set(scopes)).filter(Boolean));
};
const clearScopes = () => setSelectedScopes([]);
const toggleScope = (scope: string) => {
setSelectedScopes((previous) => {
if (previous.includes(scope)) {
return previous.filter((value) => value !== scope);
}
return [...previous, scope];
});
};
const handleGenerate = async () => {
if (!canManage()) return;
setIsGenerating(true);
try {
const trimmedName = nameInput().trim() || undefined;
const scopeSelection = [...selectedScopes()].sort();
const scopePayload = scopeSelection.length > 0 ? scopeSelection : undefined;
const { token, record } = await SecurityAPI.createToken(trimmedName, scopePayload);
setTokens((previous) => [record, ...previous]);
setNewTokenRecord(record);
setNewTokenValue(token);
setNameInput('');
showTokenReveal({
token,
record,
source: 'security',
note: 'Copy this token now. You can reopen this dialog from Security → API tokens while this page stays open.',
});
notificationStore.success(
'New API token generated. Copy it below while it is still visible.',
);
props.onTokensChanged?.();
} catch (err) {
logger.error('Failed to generate API token', err);
notificationStore.error(getAPITokenGenerateErrorMessage(err));
} finally {
setIsGenerating(false);
}
};
const handleDelete = async (record: APITokenRecord) => {
if (!canManage()) return;
const dockerUsage = dockerTokenUsage().get(record.id);
const agentUsage = agentTokenUsage().get(record.id);
const displayName = getAPITokenDialogName(record);
const affectedRuntimeIds = dockerUsage ? dockerUsage.items.map((item) => item.id) : [];
const affectedAgentIds = agentUsage ? agentUsage.items.map((item) => item.id) : [];
let revokeMessage: string | undefined;
const messageChunks: string[] = [];
if (dockerUsage) {
const preview = dockerUsage.items
.slice(0, 5)
.map((item) => item.label)
.join(', ');
const extraCount = dockerUsage.items.length - 5;
const summary = extraCount > 0 ? `${preview}, +${extraCount} more` : preview;
const label =
dockerUsage.count === 1 ? 'container runtime' : `${dockerUsage.count} container runtimes`;
messageChunks.push(`${label}: ${summary}`);
}
if (agentUsage) {
const preview = agentUsage.items
.slice(0, 5)
.map((item) => item.label)
.join(', ');
const extraCount = agentUsage.items.length - 5;
const summary = extraCount > 0 ? `${preview}, +${extraCount} more` : preview;
const label = agentUsage.count === 1 ? 'agent' : `${agentUsage.count} agents`;
messageChunks.push(`${label}: ${summary}`);
}
if (messageChunks.length > 0) {
revokeMessage = `Token "${displayName}" was previously used by ${messageChunks.join(' • ')}. Update those agents with a new token.`;
}
try {
await SecurityAPI.deleteToken(record.id);
setTokens((previous) => previous.filter((token) => token.id !== record.id));
notificationStore.success(
revokeMessage ? `Token revoked: ${revokeMessage}` : 'Token revoked',
);
props.onTokensChanged?.();
if (affectedRuntimeIds.length > 0) {
markDockerRuntimesTokenRevoked(record.id, affectedRuntimeIds);
}
if (affectedAgentIds.length > 0) {
markAgentsTokenRevoked(record.id, affectedAgentIds);
}
const current = newTokenRecord();
if (current && current.id === record.id) {
setNewTokenValue(null);
setNewTokenRecord(null);
}
} catch (err) {
logger.error('Failed to revoke API token', err);
notificationStore.error(getAPITokenRevokeErrorMessage());
}
};
const isRevealActiveForCurrentToken = () => {
const active = tokenRevealState();
return newTokenValue() !== null && Boolean(active && active.token === newTokenValue());
};
const reopenTokenDialog = () => {
const token = newTokenValue();
const record = newTokenRecord();
if (!token || !record) return;
showTokenReveal({
token,
record,
source: 'security',
note: 'Copy this token now. Close the dialog once you have stored it safely.',
});
};
const dismissNewToken = () => {
setNewTokenValue(null);
setNewTokenRecord(null);
};
const newMonitoringKioskLink = createMemo(() => {
if (
!newTokenValue() ||
newTokenRecord()?.scopes?.length !== 1 ||
newTokenRecord()?.scopes?.[0] !== MONITORING_READ_SCOPE
) {
return null;
}
return `${getPulseBaseUrl()}/?token=${newTokenValue()}&kiosk=1`;
});
const copyNewMonitoringKioskLink = async () => {
const link = newMonitoringKioskLink();
if (!link) return;
await navigator.clipboard.writeText(link);
notificationStore.success('Link copied to clipboard');
};
return {
API_SCOPE_LABELS,
API_TOKEN_SCOPES_DOC_URL,
agentTokenUsage,
applyScopePreset,
canManage,
clearScopes,
copyNewMonitoringKioskLink,
createHighlight,
dismissNewToken,
dockerTokenUsage,
focusCreateSection,
formatRelativeTime,
handleDelete,
handleGenerate,
hasWildcardTokens,
isFullAccessSelected,
isGenerating,
isRevealActiveForCurrentToken,
loading,
nameInput,
newMonitoringKioskLink,
newTokenRecord,
newTokenValue,
reopenTokenDialog,
scopedTokenCount,
scopeGroups,
scopePresets: API_TOKEN_SCOPE_PRESETS,
selectedScopes,
setCreateSectionRef,
setNameInput,
sortedTokens,
tokenHint: getAPITokenHint,
toggleScope,
totalTokens,
wildcardCount,
presetMatchesSelection: (presetScopes: string[]) =>
matchesScopePreset(selectedScopes(), presetScopes),
};
};

View file

@ -2092,10 +2092,10 @@ class CanonicalCompletionGuardTest(unittest.TestCase):
],
)
def test_api_token_manager_change_requires_security_and_api_contracts(self):
required = infer_impacted_subsystems(
["frontend-modern/src/components/Settings/APITokenManager.tsx"]
)
def _assert_api_token_manager_change_requires_security_and_api_contracts(
self, touched_path: str
) -> None:
required = infer_impacted_subsystems([touched_path])
self.assertEqual(set(required), {"security-privacy", "api-contracts"})
security = required["security-privacy"]
@ -2105,7 +2105,7 @@ class CanonicalCompletionGuardTest(unittest.TestCase):
)
self.assertEqual(
security["touched_runtime_files"],
["frontend-modern/src/components/Settings/APITokenManager.tsx"],
[touched_path],
)
self.assertEqual(
security["verification_requirements"],
@ -2113,9 +2113,7 @@ class CanonicalCompletionGuardTest(unittest.TestCase):
{
"id": "security-settings-surfaces",
"label": "security settings surface proof",
"touched_runtime_files": [
"frontend-modern/src/components/Settings/APITokenManager.tsx"
],
"touched_runtime_files": [touched_path],
"allow_same_subsystem_tests": False,
"test_prefixes": [],
"exact_files": [
@ -2135,7 +2133,7 @@ class CanonicalCompletionGuardTest(unittest.TestCase):
)
self.assertEqual(
api_contracts["touched_runtime_files"],
["frontend-modern/src/components/Settings/APITokenManager.tsx"],
[touched_path],
)
self.assertEqual(
api_contracts["verification_requirements"],
@ -2143,18 +2141,32 @@ class CanonicalCompletionGuardTest(unittest.TestCase):
{
"id": "api-token-management-surface",
"label": "API token management surface proof",
"touched_runtime_files": [
"frontend-modern/src/components/Settings/APITokenManager.tsx"
],
"touched_runtime_files": [touched_path],
"allow_same_subsystem_tests": False,
"test_prefixes": [],
"exact_files": [
"frontend-modern/src/components/Settings/__tests__/APITokenManager.test.tsx",
"frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts",
],
}
],
)
def test_api_token_manager_change_requires_security_and_api_contracts(self):
self._assert_api_token_manager_change_requires_security_and_api_contracts(
"frontend-modern/src/components/Settings/APITokenManager.tsx"
)
def test_api_token_manager_model_change_requires_security_and_api_contracts(self):
self._assert_api_token_manager_change_requires_security_and_api_contracts(
"frontend-modern/src/components/Settings/apiTokenManagerModel.ts"
)
def test_api_token_manager_state_change_requires_security_and_api_contracts(self):
self._assert_api_token_manager_change_requires_security_and_api_contracts(
"frontend-modern/src/components/Settings/useAPITokenManagerState.ts"
)
def test_security_client_change_requires_security_and_api_contracts(self):
required = infer_impacted_subsystems(["frontend-modern/src/api/security.ts"])
self.assertEqual(set(required), {"security-privacy", "api-contracts"})

View file

@ -773,7 +773,10 @@ class SubsystemLookupTest(unittest.TestCase):
)
self.assertEqual(
api_match["verification_requirement"]["exact_files"],
["frontend-modern/src/components/Settings/__tests__/APITokenManager.test.tsx"],
[
"frontend-modern/src/components/Settings/__tests__/APITokenManager.test.tsx",
"frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts",
],
)
security_match = next(
match for match in file_entry["matches"] if match["subsystem"] == "security-privacy"