mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-20 11:46:11 +00:00
Extract API token manager state owner
This commit is contained in:
parent
6e09daf9be
commit
dcd88e2f64
10 changed files with 1022 additions and 802 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
232
frontend-modern/src/components/Settings/apiTokenManagerModel.ts
Normal file
232
frontend-modern/src/components/Settings/apiTokenManagerModel.ts
Normal 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';
|
||||
};
|
||||
|
|
@ -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),
|
||||
};
|
||||
};
|
||||
|
|
@ -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"})
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue