auramaxx 1.0.0-alpha.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +26 -0
- package/README.md +112 -0
- package/bin/aurawallet.js +121 -0
- package/docs/ADAPTERS.md +467 -0
- package/docs/API.md +2679 -0
- package/docs/APPS.md +198 -0
- package/docs/ARCHITECTURE.md +350 -0
- package/docs/AUTH.md +698 -0
- package/docs/BEST-PRACTICES.md +121 -0
- package/docs/CLI.md +61 -0
- package/docs/DEVELOPING-APPS.md +452 -0
- package/docs/EXTENSION.md +97 -0
- package/docs/JOBS.md +33 -0
- package/docs/MCP.md +76 -0
- package/docs/PROTOCOL.md +142 -0
- package/docs/SETUP.md +219 -0
- package/docs/WORKSPACE.md +672 -0
- package/docs/agent-auth.md +63 -0
- package/docs/aura-file.md +48 -0
- package/docs/credentials.md +53 -0
- package/docs/external/getting-started.md +65 -0
- package/docs/external/overview.md +45 -0
- package/docs/external/use-cases.md +48 -0
- package/docs/external/why-aura.md +35 -0
- package/docs/jobs/connect-agent.md +77 -0
- package/docs/jobs/migrate-from-dotenv.md +79 -0
- package/docs/jobs/recover-from-lockout.md +72 -0
- package/docs/jobs/secure-ci.md +63 -0
- package/docs/oauth2.md +42 -0
- package/docs/passkeys.md +60 -0
- package/docs/security.md +540 -0
- package/docs/specs/aura-open-protocol.md +61 -0
- package/docs/specs/aura-provider-plugin.md +24 -0
- package/docs/specs/aura-registry-model.md +31 -0
- package/docs/specs/fixtures/invalid-bad-key.aura +1 -0
- package/docs/specs/fixtures/invalid-bad-unicode-escape.aura +1 -0
- package/docs/specs/fixtures/invalid-duplicate-key.aura +2 -0
- package/docs/specs/fixtures/valid-basic.aura +4 -0
- package/docs/specs/fixtures/valid-provider-ref.aura +1 -0
- package/docs/specs/fixtures/valid-quoted-escapes.aura +2 -0
- package/docs/templates/RELEASE_NOTES_TEMPLATE.md +22 -0
- package/docs/totp.md +40 -0
- package/docs/wallet/AI.md +508 -0
- package/docs/wallet/DEVELOPING-STRATEGIES.md +713 -0
- package/docs/wallet/README.md +47 -0
- package/docs/wallet/STRATEGY.md +89 -0
- package/next.config.ts +21 -0
- package/package.json +151 -0
- package/postcss.config.mjs +8 -0
- package/prisma/migrations/20260214170000_baseline/migration.sql +511 -0
- package/prisma/migrations/20260216214537_add_passkey_model/migration.sql +18 -0
- package/prisma/migrations/20260217150500_add_credential_access_audit/migration.sql +31 -0
- package/prisma/migrations/migration_lock.toml +3 -0
- package/prisma/schema.prisma +447 -0
- package/public/logo-chevron.svg +31 -0
- package/public/logo-concentric.svg +31 -0
- package/public/logo-crosshatch.svg +39 -0
- package/public/logo-dashed.svg +39 -0
- package/public/logo-horizontal.svg +31 -0
- package/public/logo-m56.svg +64 -0
- package/public/logo.webp +0 -0
- package/scripts/add-app.js +245 -0
- package/scripts/init.sh +57 -0
- package/scripts/migrate-apikeys-to-credentials.ts +35 -0
- package/scripts/sandbox-agent-flow.sh +235 -0
- package/scripts/sandbox.sh +175 -0
- package/scripts/validate-job-docs.mjs +125 -0
- package/server/abi/SwapHelper.json +438 -0
- package/server/cli/approval.ts +447 -0
- package/server/cli/commands/app.ts +204 -0
- package/server/cli/commands/cron.ts +24 -0
- package/server/cli/commands/doctor.ts +1007 -0
- package/server/cli/commands/env.ts +456 -0
- package/server/cli/commands/init.ts +752 -0
- package/server/cli/commands/mcp.ts +125 -0
- package/server/cli/commands/restore.ts +314 -0
- package/server/cli/commands/shell-hook.ts +468 -0
- package/server/cli/commands/start.ts +62 -0
- package/server/cli/commands/status.ts +59 -0
- package/server/cli/commands/stop.ts +14 -0
- package/server/cli/commands/token.ts +180 -0
- package/server/cli/commands/unlock.ts +49 -0
- package/server/cli/commands/vault.ts +417 -0
- package/server/cli/index.ts +328 -0
- package/server/cli/lib/aura-parser.ts +64 -0
- package/server/cli/lib/credential-create.ts +74 -0
- package/server/cli/lib/credential-resolve.ts +254 -0
- package/server/cli/lib/dotenv-migrate.ts +116 -0
- package/server/cli/lib/dotenv-parser.ts +146 -0
- package/server/cli/lib/http.ts +91 -0
- package/server/cli/lib/init-steps.ts +76 -0
- package/server/cli/lib/local-agent-trust.ts +45 -0
- package/server/cli/lib/process.ts +136 -0
- package/server/cli/lib/prompt.ts +85 -0
- package/server/cli/lib/theme.ts +240 -0
- package/server/cli/socket.ts +570 -0
- package/server/cli/transport-client.ts +50 -0
- package/server/cron/index.ts +137 -0
- package/server/cron/job.ts +31 -0
- package/server/cron/jobs/balance-sync.ts +436 -0
- package/server/cron/jobs/incoming-scan.ts +506 -0
- package/server/cron/jobs/native-price.ts +70 -0
- package/server/cron/jobs/orphan-cleanup.ts +40 -0
- package/server/cron/jobs/strategy-runner.ts +175 -0
- package/server/cron/scheduler.ts +125 -0
- package/server/index.ts +406 -0
- package/server/lib/adapters/factory.ts +110 -0
- package/server/lib/adapters/index.ts +19 -0
- package/server/lib/adapters/router.ts +297 -0
- package/server/lib/adapters/telegram.ts +645 -0
- package/server/lib/adapters/types.ts +89 -0
- package/server/lib/adapters/webhook.ts +95 -0
- package/server/lib/address.ts +49 -0
- package/server/lib/agent-auth/contracts.ts +1194 -0
- package/server/lib/agent-profiles.ts +328 -0
- package/server/lib/ai.ts +285 -0
- package/server/lib/api-registry/contracts.ts +86 -0
- package/server/lib/api-registry/validation.ts +172 -0
- package/server/lib/apikey-migration.ts +189 -0
- package/server/lib/app-installer.ts +505 -0
- package/server/lib/app-tokens.ts +247 -0
- package/server/lib/auth.ts +314 -0
- package/server/lib/batch.ts +242 -0
- package/server/lib/cold.ts +874 -0
- package/server/lib/config.ts +381 -0
- package/server/lib/credential-access-audit.ts +85 -0
- package/server/lib/credential-access-policy.ts +110 -0
- package/server/lib/credential-health.ts +343 -0
- package/server/lib/credential-import.ts +487 -0
- package/server/lib/credential-scope.ts +87 -0
- package/server/lib/credential-shares.ts +190 -0
- package/server/lib/credential-transport.ts +342 -0
- package/server/lib/credential-vault.ts +77 -0
- package/server/lib/credentials.ts +333 -0
- package/server/lib/crypto.ts +8 -0
- package/server/lib/db.ts +15 -0
- package/server/lib/defaults.ts +366 -0
- package/server/lib/dex/index.ts +80 -0
- package/server/lib/dex/relay.ts +235 -0
- package/server/lib/dex/types.ts +59 -0
- package/server/lib/dex/uniswap.ts +370 -0
- package/server/lib/e2e-agent/artifacts.ts +36 -0
- package/server/lib/e2e-agent/contracts.ts +112 -0
- package/server/lib/e2e-agent/validation.ts +135 -0
- package/server/lib/encrypt.ts +128 -0
- package/server/lib/error.ts +20 -0
- package/server/lib/events.ts +205 -0
- package/server/lib/hot.ts +357 -0
- package/server/lib/key-fingerprint.ts +28 -0
- package/server/lib/logger.ts +331 -0
- package/server/lib/network.ts +137 -0
- package/server/lib/notifications.ts +219 -0
- package/server/lib/oauth2-refresh.ts +241 -0
- package/server/lib/oursecret.ts +54 -0
- package/server/lib/passkey-credential.ts +360 -0
- package/server/lib/passkey.ts +68 -0
- package/server/lib/permissions.ts +248 -0
- package/server/lib/pino.ts +24 -0
- package/server/lib/policy-preview.ts +138 -0
- package/server/lib/price.ts +338 -0
- package/server/lib/prices.ts +34 -0
- package/server/lib/project-scope.ts +239 -0
- package/server/lib/resolve-action.ts +427 -0
- package/server/lib/resolve.ts +36 -0
- package/server/lib/sessions.ts +632 -0
- package/server/lib/solana/connection.ts +26 -0
- package/server/lib/solana/jupiter.ts +128 -0
- package/server/lib/solana/transfer.ts +108 -0
- package/server/lib/solana/wallet.ts +136 -0
- package/server/lib/strategy/emits.ts +21 -0
- package/server/lib/strategy/engine.ts +1305 -0
- package/server/lib/strategy/executor.ts +115 -0
- package/server/lib/strategy/hook-context.ts +158 -0
- package/server/lib/strategy/hooks.ts +990 -0
- package/server/lib/strategy/index.ts +28 -0
- package/server/lib/strategy/installer.ts +305 -0
- package/server/lib/strategy/loader.ts +256 -0
- package/server/lib/strategy/message.ts +235 -0
- package/server/lib/strategy/repository.ts +218 -0
- package/server/lib/strategy/session-logger.ts +693 -0
- package/server/lib/strategy/sources.ts +288 -0
- package/server/lib/strategy/state.ts +189 -0
- package/server/lib/strategy/templates.ts +403 -0
- package/server/lib/strategy/tick.ts +404 -0
- package/server/lib/strategy/types.ts +230 -0
- package/server/lib/swap.ts +3 -0
- package/server/lib/temp.ts +86 -0
- package/server/lib/token-metadata.ts +86 -0
- package/server/lib/token-safety.ts +200 -0
- package/server/lib/token-search.ts +444 -0
- package/server/lib/totp.ts +194 -0
- package/server/lib/transactions.ts +123 -0
- package/server/lib/transport.ts +75 -0
- package/server/lib/txhistory/decoder.ts +262 -0
- package/server/lib/txhistory/enricher.ts +652 -0
- package/server/lib/txhistory/index.ts +391 -0
- package/server/lib/txhistory/signatures.ts +59 -0
- package/server/lib/verified-summary.ts +421 -0
- package/server/mcp/profile-policy.ts +30 -0
- package/server/mcp/server.ts +619 -0
- package/server/mcp/tools.ts +523 -0
- package/server/middleware/auth.ts +119 -0
- package/server/middleware/requestLogger.ts +84 -0
- package/server/routes/actions.ts +459 -0
- package/server/routes/adapters.ts +703 -0
- package/server/routes/addressbook.ts +113 -0
- package/server/routes/ai.ts +34 -0
- package/server/routes/apikeys.ts +295 -0
- package/server/routes/apps.ts +601 -0
- package/server/routes/auth.ts +457 -0
- package/server/routes/backup.ts +340 -0
- package/server/routes/batch.ts +270 -0
- package/server/routes/bookmarks.ts +162 -0
- package/server/routes/credential-shares.ts +198 -0
- package/server/routes/credential-vaults.ts +154 -0
- package/server/routes/credentials.ts +1290 -0
- package/server/routes/dashboard.ts +71 -0
- package/server/routes/defaults.ts +124 -0
- package/server/routes/fund.ts +229 -0
- package/server/routes/import.ts +352 -0
- package/server/routes/launch.ts +665 -0
- package/server/routes/lock.ts +54 -0
- package/server/routes/logs.ts +68 -0
- package/server/routes/nuke.ts +111 -0
- package/server/routes/passkey-credentials.ts +99 -0
- package/server/routes/passkey.ts +346 -0
- package/server/routes/portfolio.ts +217 -0
- package/server/routes/price.ts +63 -0
- package/server/routes/resolve.ts +31 -0
- package/server/routes/security.ts +45 -0
- package/server/routes/send-evm.ts +241 -0
- package/server/routes/send-solana.ts +281 -0
- package/server/routes/send.ts +178 -0
- package/server/routes/setup.ts +210 -0
- package/server/routes/strategy.ts +894 -0
- package/server/routes/swap-evm.ts +353 -0
- package/server/routes/swap-solana.ts +177 -0
- package/server/routes/swap.ts +356 -0
- package/server/routes/token.ts +247 -0
- package/server/routes/unlock.ts +403 -0
- package/server/routes/wallet-assets.ts +361 -0
- package/server/routes/wallet-transactions.ts +515 -0
- package/server/routes/wallet.ts +710 -0
- package/server/types.ts +146 -0
- package/skills/aurawallet/SKILL.md +739 -0
- package/skills/aurawallet-setup/SKILL.md +74 -0
- package/skills/security-review/SKILL.md +148 -0
- package/src/app/api/agent-requests/route.ts +30 -0
- package/src/app/api/apps/install/route.ts +126 -0
- package/src/app/api/apps/manifests/route.ts +16 -0
- package/src/app/api/apps/static/[...path]/route.ts +57 -0
- package/src/app/api/events/route.ts +92 -0
- package/src/app/api/page.tsx +212 -0
- package/src/app/api/workspace/[id]/apps/[wid]/route.ts +119 -0
- package/src/app/api/workspace/[id]/apps/route.ts +81 -0
- package/src/app/api/workspace/[id]/export/route.ts +67 -0
- package/src/app/api/workspace/[id]/route.ts +168 -0
- package/src/app/api/workspace/auth.ts +34 -0
- package/src/app/api/workspace/config/route.ts +106 -0
- package/src/app/api/workspace/import/route.ts +127 -0
- package/src/app/api/workspace/route.ts +116 -0
- package/src/app/app/page.tsx +2122 -0
- package/src/app/apple-icon.png +0 -0
- package/src/app/docs/page.tsx +178 -0
- package/src/app/favicon.ico +0 -0
- package/src/app/globals.css +572 -0
- package/src/app/health/page.tsx +5 -0
- package/src/app/hello/page.tsx +15 -0
- package/src/app/icon.png +0 -0
- package/src/app/layout.tsx +34 -0
- package/src/app/page.tsx +986 -0
- package/src/app/providers.tsx +90 -0
- package/src/app/share/[token]/page.tsx +295 -0
- package/src/components/ChainSelector.tsx +144 -0
- package/src/components/HumanActionBar.tsx +695 -0
- package/src/components/NotificationDrawer.tsx +129 -0
- package/src/components/apps/AgentKeysApp.tsx +490 -0
- package/src/components/apps/App.tsx +153 -0
- package/src/components/apps/AppGrid.tsx +15 -0
- package/src/components/apps/DetailedAddressDrawer.tsx +325 -0
- package/src/components/apps/DraggableApp.tsx +562 -0
- package/src/components/apps/IFrameApp.tsx +73 -0
- package/src/components/apps/LogsApp.tsx +360 -0
- package/src/components/apps/SendApp.tsx +394 -0
- package/src/components/apps/SetupWizardApp.tsx +1004 -0
- package/src/components/apps/SystemDefaultsApp.tsx +845 -0
- package/src/components/apps/ThirdPartyApp.tsx +428 -0
- package/src/components/apps/TokenApp.tsx +319 -0
- package/src/components/apps/TransactionsApp.tsx +438 -0
- package/src/components/apps/WalletDetailApp.tsx +1505 -0
- package/src/components/apps/index.ts +13 -0
- package/src/components/design-system/Button.tsx +53 -0
- package/src/components/design-system/ChainIndicator.tsx +65 -0
- package/src/components/design-system/ChainSelector.tsx +137 -0
- package/src/components/design-system/ConfirmationModal.tsx +106 -0
- package/src/components/design-system/ConfirmationPopover.tsx +81 -0
- package/src/components/design-system/Drawer.tsx +123 -0
- package/src/components/design-system/FilterDropdown.tsx +72 -0
- package/src/components/design-system/Modal.tsx +206 -0
- package/src/components/design-system/Popover.tsx +142 -0
- package/src/components/design-system/TextInput.tsx +85 -0
- package/src/components/design-system/Toggle.tsx +58 -0
- package/src/components/design-system/index.ts +11 -0
- package/src/components/docs/DocsThemeToggle.tsx +49 -0
- package/src/components/health/CredentialHealthDashboard.tsx +214 -0
- package/src/components/icons/ChainIcons.tsx +72 -0
- package/src/components/layout/AppStoreDrawer.tsx +369 -0
- package/src/components/layout/ContentArea.tsx +21 -0
- package/src/components/layout/TabBar.tsx +278 -0
- package/src/components/layout/WalletSidebar.tsx +1033 -0
- package/src/components/layout/index.ts +4 -0
- package/src/components/marketing/AuraWalletSpecOverlay.tsx +635 -0
- package/src/components/marketing/DeviceMorphExperience.tsx +216 -0
- package/src/components/vault/ApiKeysConsole.tsx +1080 -0
- package/src/components/vault/AuditConsole.tsx +584 -0
- package/src/components/vault/CredentialDetail.tsx +455 -0
- package/src/components/vault/CredentialEmpty.tsx +55 -0
- package/src/components/vault/CredentialField.tsx +361 -0
- package/src/components/vault/CredentialForm.tsx +1212 -0
- package/src/components/vault/CredentialList.tsx +165 -0
- package/src/components/vault/CredentialRow.tsx +97 -0
- package/src/components/vault/CredentialShareModal.tsx +178 -0
- package/src/components/vault/CredentialVault.tsx +754 -0
- package/src/components/vault/CredentialWalletWidget.tsx +103 -0
- package/src/components/vault/ImportCredentialsModal.tsx +515 -0
- package/src/components/vault/LargeTypeModal.tsx +64 -0
- package/src/components/vault/PasswordGenerator.tsx +224 -0
- package/src/components/vault/TOTPDisplay.tsx +123 -0
- package/src/components/vault/VaultSidebar.tsx +413 -0
- package/src/components/vault/types.ts +54 -0
- package/src/context/AuthContext.tsx +337 -0
- package/src/context/PriceContext.tsx +113 -0
- package/src/context/ThemeContext.tsx +164 -0
- package/src/context/WebSocketContext.tsx +269 -0
- package/src/context/WorkspaceContext.tsx +668 -0
- package/src/hooks/index.ts +3 -0
- package/src/hooks/useAgentActions.ts +368 -0
- package/src/hooks/useBalance.ts +103 -0
- package/src/hooks/useBalances.ts +129 -0
- package/src/instrumentation.ts +12 -0
- package/src/lib/api.ts +449 -0
- package/src/lib/app-loader.ts +148 -0
- package/src/lib/app-registry.ts +178 -0
- package/src/lib/app-sdk.ts +157 -0
- package/src/lib/audit-console-adapter.ts +151 -0
- package/src/lib/auth-client.ts +75 -0
- package/src/lib/config.ts +74 -0
- package/src/lib/crypto.ts +112 -0
- package/src/lib/db.ts +21 -0
- package/src/lib/docs.ts +390 -0
- package/src/lib/events.ts +361 -0
- package/src/lib/pino.ts +24 -0
- package/src/lib/theme-handlers.ts +168 -0
- package/src/lib/theme.ts +351 -0
- package/src/lib/tokenData.ts +378 -0
- package/src/lib/vault-crypto.ts +129 -0
- package/src/lib/websocket-server.ts +302 -0
- package/src/lib/websocket-setup.ts +79 -0
- package/src/lib/wordlist.ts +2050 -0
- package/src/lib/workspace-handlers.ts +285 -0
- package/start.sh +80 -0
- package/tailwind.config.ts +99 -0
- package/tsconfig.json +42 -0
|
@@ -0,0 +1,1290 @@
|
|
|
1
|
+
import { Request, Response, Router } from 'express';
|
|
2
|
+
import {
|
|
3
|
+
archiveCredential,
|
|
4
|
+
createCredential,
|
|
5
|
+
deleteArchivedCredential,
|
|
6
|
+
deleteCredential,
|
|
7
|
+
findCredentialLocation,
|
|
8
|
+
getCredential,
|
|
9
|
+
listCredentials,
|
|
10
|
+
purgeDeletedCredentials,
|
|
11
|
+
readCredentialSecrets,
|
|
12
|
+
restoreArchivedCredential,
|
|
13
|
+
restoreDeletedCredential,
|
|
14
|
+
updateCredential,
|
|
15
|
+
type CredentialLocation,
|
|
16
|
+
} from '../lib/credentials';
|
|
17
|
+
import { getErrorMessage } from '../lib/error';
|
|
18
|
+
import { hasAnyPermission, isAdmin } from '../lib/permissions';
|
|
19
|
+
import { recordCredentialRead } from '../lib/sessions';
|
|
20
|
+
import { CredentialField, CredentialFile, CredentialType } from '../types';
|
|
21
|
+
import { requireWalletAuth } from '../middleware/auth';
|
|
22
|
+
import { matchesScope, normalizeScope, resolveExcludeFields } from '../lib/credential-scope';
|
|
23
|
+
import { encryptToAgentPubkey } from '../lib/credential-transport';
|
|
24
|
+
import { logEvent } from '../lib/logger';
|
|
25
|
+
import { generateTOTP, findTotpField } from '../lib/totp';
|
|
26
|
+
import { readOAuth2SecretsWithRefresh, OAUTH2_DEFAULT_EXCLUDE_FIELDS } from '../lib/oauth2-refresh';
|
|
27
|
+
import { getLinkedVaultGroup, getPrimaryVaultId } from '../lib/cold';
|
|
28
|
+
import { evaluateCredentialAccess } from '../lib/credential-access-policy';
|
|
29
|
+
import { writeCredentialAccessAudit } from '../lib/credential-access-audit';
|
|
30
|
+
import { computeGpgFingerprint, computeSshFingerprint } from '../lib/key-fingerprint';
|
|
31
|
+
import { events } from '../lib/events';
|
|
32
|
+
import {
|
|
33
|
+
buildCredentialHealthRows,
|
|
34
|
+
summarizeCredentialHealthFlags,
|
|
35
|
+
} from '../lib/credential-health';
|
|
36
|
+
|
|
37
|
+
const router = Router();
|
|
38
|
+
|
|
39
|
+
const VALID_CREDENTIAL_TYPES = new Set<CredentialType>([
|
|
40
|
+
'login',
|
|
41
|
+
'card',
|
|
42
|
+
'note',
|
|
43
|
+
'api',
|
|
44
|
+
'apikey',
|
|
45
|
+
'custom',
|
|
46
|
+
'passkey',
|
|
47
|
+
'oauth2',
|
|
48
|
+
'ssh',
|
|
49
|
+
'gpg',
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
const VALID_FIELD_TYPES = new Set(['text', 'secret', 'url', 'email', 'number']);
|
|
53
|
+
const VALID_CREDENTIAL_LOCATIONS = new Set<CredentialLocation>(['active', 'archive', 'recently_deleted']);
|
|
54
|
+
|
|
55
|
+
router.use(requireWalletAuth);
|
|
56
|
+
|
|
57
|
+
function parseCredentialLocation(value: unknown, fallback: CredentialLocation = 'active'): CredentialLocation | null {
|
|
58
|
+
if (value === undefined || value === null || value === '') return fallback;
|
|
59
|
+
if (typeof value !== 'string') return null;
|
|
60
|
+
if (!VALID_CREDENTIAL_LOCATIONS.has(value as CredentialLocation)) return null;
|
|
61
|
+
return value as CredentialLocation;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function toMetadata(credential: CredentialFile): Omit<CredentialFile, 'encrypted'> & { has_totp?: boolean } {
|
|
65
|
+
const { encrypted: _encrypted, ...metadata } = credential;
|
|
66
|
+
if (credential.meta?.has_totp === true) {
|
|
67
|
+
return { ...metadata, has_totp: true };
|
|
68
|
+
}
|
|
69
|
+
return metadata;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function normalizeName(name: string): string {
|
|
73
|
+
return name.trim().normalize('NFKC');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function normalizeMeta(meta: Record<string, unknown>): Record<string, unknown> {
|
|
77
|
+
const normalized: Record<string, unknown> = { ...meta };
|
|
78
|
+
|
|
79
|
+
if (normalized.tags !== undefined) {
|
|
80
|
+
if (!Array.isArray(normalized.tags)) {
|
|
81
|
+
throw new Error('meta.tags must be an array');
|
|
82
|
+
}
|
|
83
|
+
normalized.tags = normalized.tags
|
|
84
|
+
.filter(tag => typeof tag === 'string')
|
|
85
|
+
.map(tag => normalizeScope(tag))
|
|
86
|
+
.filter(tag => tag.length > 0);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (normalized.hosts !== undefined) {
|
|
90
|
+
if (!Array.isArray(normalized.hosts)) {
|
|
91
|
+
throw new Error('meta.hosts must be an array');
|
|
92
|
+
}
|
|
93
|
+
normalized.hosts = normalized.hosts
|
|
94
|
+
.filter(host => typeof host === 'string')
|
|
95
|
+
.map(host => host.trim())
|
|
96
|
+
.filter(host => host.length > 0);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (normalized.walletLink !== undefined) {
|
|
100
|
+
if (!normalized.walletLink || typeof normalized.walletLink !== 'object' || Array.isArray(normalized.walletLink)) {
|
|
101
|
+
throw new Error('meta.walletLink must be an object');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const rawWalletLink = normalized.walletLink as Record<string, unknown>;
|
|
105
|
+
const walletAddress = typeof rawWalletLink.walletAddress === 'string' ? rawWalletLink.walletAddress.trim() : '';
|
|
106
|
+
const chain = typeof rawWalletLink.chain === 'string' ? rawWalletLink.chain.trim() : '';
|
|
107
|
+
const tier = rawWalletLink.tier;
|
|
108
|
+
const source = rawWalletLink.source;
|
|
109
|
+
const label = typeof rawWalletLink.label === 'string' ? rawWalletLink.label.trim() : undefined;
|
|
110
|
+
const version = typeof rawWalletLink.version === 'number' ? rawWalletLink.version : 1;
|
|
111
|
+
|
|
112
|
+
if (!walletAddress) throw new Error('meta.walletLink.walletAddress is required');
|
|
113
|
+
if (!chain) throw new Error('meta.walletLink.chain is required');
|
|
114
|
+
if (tier !== 'cold' && tier !== 'hot') throw new Error('meta.walletLink.tier must be "cold" or "hot"');
|
|
115
|
+
if (source !== 'existing' && source !== 'created') throw new Error('meta.walletLink.source must be "existing" or "created"');
|
|
116
|
+
if (version !== 1) throw new Error('meta.walletLink.version must be 1');
|
|
117
|
+
|
|
118
|
+
normalized.walletLink = {
|
|
119
|
+
version: 1,
|
|
120
|
+
walletAddress,
|
|
121
|
+
chain,
|
|
122
|
+
tier,
|
|
123
|
+
source,
|
|
124
|
+
...(label ? { label } : {}),
|
|
125
|
+
linkedAt: new Date().toISOString(),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return normalized;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function isPrimaryVault(vaultId: string): boolean {
|
|
133
|
+
const primaryVaultId = getPrimaryVaultId();
|
|
134
|
+
if (primaryVaultId && vaultId === primaryVaultId) return true;
|
|
135
|
+
return vaultId === 'primary';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
function inferSshKeyType(keyText: string | undefined): string {
|
|
140
|
+
if (!keyText) return 'other';
|
|
141
|
+
const key = keyText.toLowerCase();
|
|
142
|
+
if (key.includes('ed25519')) return 'ed25519';
|
|
143
|
+
if (key.includes('rsa')) return 'rsa';
|
|
144
|
+
if (key.includes('ecdsa')) return 'ecdsa';
|
|
145
|
+
return 'other';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function enforceKeyCredentialMetadata(
|
|
149
|
+
type: CredentialType,
|
|
150
|
+
meta: Record<string, unknown>,
|
|
151
|
+
sensitiveFields: CredentialField[],
|
|
152
|
+
): Record<string, unknown> {
|
|
153
|
+
if (type !== 'ssh' && type !== 'gpg') return meta;
|
|
154
|
+
|
|
155
|
+
const nextMeta: Record<string, unknown> = { ...meta };
|
|
156
|
+
delete nextMeta.fingerprint;
|
|
157
|
+
const fieldMap = new Map(sensitiveFields.map(field => [field.key, field.value]));
|
|
158
|
+
const privateKey = fieldMap.get('private_key')?.trim() || '';
|
|
159
|
+
const publicKey = typeof nextMeta.public_key === 'string' ? nextMeta.public_key : '';
|
|
160
|
+
|
|
161
|
+
if (!privateKey) {
|
|
162
|
+
throw new Error(`${type} requires sensitive field private_key`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (type === 'ssh') {
|
|
166
|
+
const computed = computeSshFingerprint(publicKey || privateKey);
|
|
167
|
+
if (computed) nextMeta.fingerprint = computed;
|
|
168
|
+
if (!nextMeta.key_type || typeof nextMeta.key_type !== 'string') {
|
|
169
|
+
nextMeta.key_type = inferSshKeyType(publicKey || privateKey);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (type === 'gpg') {
|
|
174
|
+
const computed = computeGpgFingerprint(publicKey || privateKey);
|
|
175
|
+
if (computed) nextMeta.fingerprint = computed;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return nextMeta;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const OAUTH2_REQUIRED_SECRET_FIELDS = ['access_token', 'refresh_token', 'client_id', 'client_secret'];
|
|
182
|
+
const OAUTH2_REAUTH_RESET_FIELDS = new Set(OAUTH2_REQUIRED_SECRET_FIELDS);
|
|
183
|
+
|
|
184
|
+
function shouldClearOAuth2ReauthMarker(credentialType: string, sensitiveFields: CredentialField[] | undefined) {
|
|
185
|
+
if (credentialType !== 'oauth2' || !sensitiveFields || sensitiveFields.length === 0) {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return sensitiveFields.some(field => OAUTH2_REAUTH_RESET_FIELDS.has(field.key));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function validateOAuth2Meta(meta: Record<string, unknown>) {
|
|
193
|
+
const tokenEndpoint = meta.token_endpoint;
|
|
194
|
+
if (typeof tokenEndpoint !== 'string' || tokenEndpoint.trim().length === 0) {
|
|
195
|
+
throw new Error('oauth2 requires meta.token_endpoint');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const expiresAt = meta.expires_at;
|
|
199
|
+
if (typeof expiresAt !== 'number' || !Number.isFinite(expiresAt)) {
|
|
200
|
+
throw new Error('oauth2 requires numeric meta.expires_at (unix timestamp seconds)');
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function validateOAuth2RequiredFields(fields: CredentialField[]) {
|
|
205
|
+
const fieldMap = new Map(fields.map(field => [field.key, field.value]));
|
|
206
|
+
for (const key of OAUTH2_REQUIRED_SECRET_FIELDS) {
|
|
207
|
+
const value = fieldMap.get(key);
|
|
208
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
209
|
+
throw new Error(`oauth2 requires sensitive field ${key}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function mergeOAuth2Fields(base: CredentialField[], updates: CredentialField[] = []): CredentialField[] {
|
|
215
|
+
if (updates.length === 0) return base;
|
|
216
|
+
|
|
217
|
+
const merged = new Map<string, CredentialField>();
|
|
218
|
+
for (const field of base) merged.set(field.key, field);
|
|
219
|
+
for (const field of updates) merged.set(field.key, field);
|
|
220
|
+
return [...merged.values()];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function parseFields(value: unknown): CredentialField[] {
|
|
224
|
+
if (!Array.isArray(value)) return [];
|
|
225
|
+
|
|
226
|
+
const fields: CredentialField[] = [];
|
|
227
|
+
for (const rawField of value) {
|
|
228
|
+
if (!rawField || typeof rawField !== 'object') continue;
|
|
229
|
+
const raw = rawField as Record<string, unknown>;
|
|
230
|
+
if (typeof raw.key !== 'string' || typeof raw.value !== 'string') continue;
|
|
231
|
+
|
|
232
|
+
const key = raw.key.trim();
|
|
233
|
+
if (!key) continue;
|
|
234
|
+
|
|
235
|
+
const type = typeof raw.type === 'string' && VALID_FIELD_TYPES.has(raw.type)
|
|
236
|
+
? raw.type as CredentialField['type']
|
|
237
|
+
: 'text';
|
|
238
|
+
|
|
239
|
+
fields.push({
|
|
240
|
+
key,
|
|
241
|
+
value: raw.value,
|
|
242
|
+
type,
|
|
243
|
+
sensitive: !!raw.sensitive,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return fields;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function mergeNonSensitiveFieldsIntoMeta(
|
|
251
|
+
meta: Record<string, unknown>,
|
|
252
|
+
fields: CredentialField[],
|
|
253
|
+
): Record<string, unknown> {
|
|
254
|
+
const merged = { ...meta };
|
|
255
|
+
for (const field of fields) {
|
|
256
|
+
if (!field.sensitive && merged[field.key] === undefined) {
|
|
257
|
+
merged[field.key] = field.value;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return merged;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function canReadCredential(req: Request, credential: CredentialFile): boolean {
|
|
264
|
+
const auth = req.auth!;
|
|
265
|
+
if (isAdmin(auth)) return true;
|
|
266
|
+
if (!hasAnyPermission(auth.token.permissions, ['secret:read'])) return false;
|
|
267
|
+
const scopes = auth.token.credentialAccess?.read || [];
|
|
268
|
+
return matchesScope(credential, scopes);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function canWriteCredential(req: Request, credential: CredentialFile): boolean {
|
|
272
|
+
const auth = req.auth!;
|
|
273
|
+
if (isAdmin(auth)) return true;
|
|
274
|
+
if (!hasAnyPermission(auth.token.permissions, ['secret:write'])) return false;
|
|
275
|
+
const scopes = auth.token.credentialAccess?.write || [];
|
|
276
|
+
return matchesScope(credential, scopes);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function credentialActor(req: Request): {
|
|
280
|
+
actorType: 'admin' | 'agent';
|
|
281
|
+
agentId?: string;
|
|
282
|
+
tokenHash?: string;
|
|
283
|
+
} {
|
|
284
|
+
const auth = req.auth!;
|
|
285
|
+
return {
|
|
286
|
+
actorType: isAdmin(auth) ? 'admin' : 'agent',
|
|
287
|
+
agentId: auth.token.agentId,
|
|
288
|
+
tokenHash: auth.tokenHash,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function emitCredentialChanged(
|
|
293
|
+
req: Request,
|
|
294
|
+
credential: CredentialFile,
|
|
295
|
+
change:
|
|
296
|
+
| 'created'
|
|
297
|
+
| 'updated'
|
|
298
|
+
| 'archived'
|
|
299
|
+
| 'moved_to_recently_deleted'
|
|
300
|
+
| 'restored_to_active'
|
|
301
|
+
| 'restored_to_archive'
|
|
302
|
+
| 'purged',
|
|
303
|
+
location?: {
|
|
304
|
+
fromLocation?: CredentialLocation;
|
|
305
|
+
toLocation?: CredentialLocation;
|
|
306
|
+
},
|
|
307
|
+
): void {
|
|
308
|
+
const actor = credentialActor(req);
|
|
309
|
+
events.credentialChanged({
|
|
310
|
+
credentialId: credential.id,
|
|
311
|
+
vaultId: credential.vaultId,
|
|
312
|
+
change,
|
|
313
|
+
actorType: actor.actorType,
|
|
314
|
+
agentId: actor.agentId,
|
|
315
|
+
tokenHash: actor.tokenHash,
|
|
316
|
+
fromLocation: location?.fromLocation,
|
|
317
|
+
toLocation: location?.toLocation,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function emitCredentialAccessed(
|
|
322
|
+
req: Request,
|
|
323
|
+
credential: CredentialFile,
|
|
324
|
+
input: {
|
|
325
|
+
action: 'credentials.read' | 'credentials.totp';
|
|
326
|
+
allowed: boolean;
|
|
327
|
+
reasonCode: string;
|
|
328
|
+
httpStatus: number;
|
|
329
|
+
},
|
|
330
|
+
): void {
|
|
331
|
+
const actor = credentialActor(req);
|
|
332
|
+
events.credentialAccessed({
|
|
333
|
+
credentialId: credential.id,
|
|
334
|
+
vaultId: credential.vaultId,
|
|
335
|
+
action: input.action,
|
|
336
|
+
allowed: input.allowed,
|
|
337
|
+
reasonCode: input.reasonCode,
|
|
338
|
+
httpStatus: input.httpStatus,
|
|
339
|
+
actorType: actor.actorType,
|
|
340
|
+
agentId: actor.agentId,
|
|
341
|
+
tokenHash: actor.tokenHash,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function credentialAccessErrorMessage(reasonCode: string, action: 'credentials.read' | 'credentials.totp'): string {
|
|
346
|
+
if (reasonCode === 'TOKEN_TTL_EXPIRED') return 'Credential access TTL expired';
|
|
347
|
+
if (reasonCode === 'TOKEN_MAX_READS_EXCEEDED') return 'Credential read limit reached';
|
|
348
|
+
if (reasonCode === 'CREDENTIAL_RATE_LIMIT_EXCEEDED') {
|
|
349
|
+
return action === 'credentials.totp'
|
|
350
|
+
? 'TOTP rate limit exceeded (max 10/min)'
|
|
351
|
+
: 'Credential rate limit exceeded';
|
|
352
|
+
}
|
|
353
|
+
if (reasonCode === 'CREDENTIAL_SCOPE_DENIED') return 'Credential read scope denied';
|
|
354
|
+
if (reasonCode === 'TOKEN_PERMISSION_DENIED') return 'totp:read permission required';
|
|
355
|
+
if (reasonCode === 'TOKEN_AGENT_PUBKEY_MISSING') return 'agentPubkey is required on token for credential reads';
|
|
356
|
+
if (reasonCode === 'CREDENTIAL_TOTP_NOT_CONFIGURED') return 'Credential has no TOTP secret';
|
|
357
|
+
return reasonCode;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function writeDeniedCredentialAccess(params: {
|
|
361
|
+
req: Request;
|
|
362
|
+
credential: CredentialFile;
|
|
363
|
+
action: 'credentials.read' | 'credentials.totp';
|
|
364
|
+
reasonCode: 'TOKEN_TTL_EXPIRED' | 'TOKEN_MAX_READS_EXCEEDED' | 'CREDENTIAL_RATE_LIMIT_EXCEEDED' | 'CREDENTIAL_SCOPE_DENIED' | 'TOKEN_PERMISSION_DENIED' | 'TOKEN_AGENT_PUBKEY_MISSING' | 'CREDENTIAL_TOTP_NOT_CONFIGURED';
|
|
365
|
+
httpStatus: number;
|
|
366
|
+
metadata?: Record<string, unknown>;
|
|
367
|
+
}): Promise<void> {
|
|
368
|
+
const auth = params.req.auth!;
|
|
369
|
+
await writeCredentialAccessAudit({
|
|
370
|
+
credentialId: params.credential.id,
|
|
371
|
+
vaultId: params.credential.vaultId,
|
|
372
|
+
action: params.action,
|
|
373
|
+
allowed: false,
|
|
374
|
+
reasonCode: params.reasonCode,
|
|
375
|
+
httpStatus: params.httpStatus,
|
|
376
|
+
tokenHash: auth.tokenHash,
|
|
377
|
+
agentId: auth.token.agentId,
|
|
378
|
+
requestId: params.req.header('x-request-id') ?? undefined,
|
|
379
|
+
actorType: isAdmin(auth) ? 'admin' : 'agent',
|
|
380
|
+
metadata: params.metadata,
|
|
381
|
+
});
|
|
382
|
+
emitCredentialAccessed(params.req, params.credential, {
|
|
383
|
+
action: params.action,
|
|
384
|
+
allowed: false,
|
|
385
|
+
reasonCode: params.reasonCode,
|
|
386
|
+
httpStatus: params.httpStatus,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
type HealthScanJobStatus = 'queued' | 'running' | 'complete' | 'failed' | 'expired';
|
|
391
|
+
|
|
392
|
+
const healthScanJobs = new Map<string, {
|
|
393
|
+
status: HealthScanJobStatus;
|
|
394
|
+
createdAt: number;
|
|
395
|
+
updatedAt: number;
|
|
396
|
+
error?: string;
|
|
397
|
+
}>();
|
|
398
|
+
|
|
399
|
+
function newScanId(): string {
|
|
400
|
+
return `scan-${Math.random().toString(36).slice(2, 10)}`;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function listHealthCredentials(req: Request): CredentialFile[] {
|
|
404
|
+
const auth = req.auth!;
|
|
405
|
+
const reuseScopeMode = req.query.reuseScope === 'vault' ? 'vault' : 'group';
|
|
406
|
+
const selectedVault = typeof req.query.vault === 'string' ? req.query.vault : undefined;
|
|
407
|
+
|
|
408
|
+
const candidates = listCredentials();
|
|
409
|
+
const readable = isAdmin(auth)
|
|
410
|
+
? candidates
|
|
411
|
+
: candidates.filter(credential => matchesScope(credential, auth.token.credentialAccess?.read || []));
|
|
412
|
+
|
|
413
|
+
if (!selectedVault) return readable;
|
|
414
|
+
if (reuseScopeMode === 'vault') return readable.filter(c => c.vaultId === selectedVault);
|
|
415
|
+
|
|
416
|
+
const group = new Set(getLinkedVaultGroup(selectedVault));
|
|
417
|
+
return readable.filter(c => group.has(c.vaultId));
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function pruneExpiredHealthJobs(): void {
|
|
421
|
+
const now = Date.now();
|
|
422
|
+
for (const [id, job] of healthScanJobs.entries()) {
|
|
423
|
+
const terminal = job.status === 'complete' || job.status === 'failed';
|
|
424
|
+
if (!terminal) continue;
|
|
425
|
+
if (now - job.updatedAt > 30 * 60 * 1000) {
|
|
426
|
+
healthScanJobs.set(id, { ...job, status: 'expired', updatedAt: now });
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// POST /credentials — create
|
|
432
|
+
router.post('/', (req: Request, res: Response) => {
|
|
433
|
+
try {
|
|
434
|
+
const auth = req.auth!;
|
|
435
|
+
if (!isAdmin(auth) && !hasAnyPermission(auth.token.permissions, ['secret:write'])) {
|
|
436
|
+
res.status(403).json({ success: false, error: 'secret:write permission required' });
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const vaultId = typeof req.body?.vaultId === 'string' ? req.body.vaultId : '';
|
|
441
|
+
const type = typeof req.body?.type === 'string' ? req.body.type : '';
|
|
442
|
+
const rawName = typeof req.body?.name === 'string' ? req.body.name : '';
|
|
443
|
+
|
|
444
|
+
if (!vaultId || !type || !rawName) {
|
|
445
|
+
res.status(400).json({ success: false, error: 'vaultId, type, and name are required' });
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
if (!VALID_CREDENTIAL_TYPES.has(type as CredentialType)) {
|
|
449
|
+
res.status(400).json({ success: false, error: `Invalid credential type: ${type}` });
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const name = normalizeName(rawName);
|
|
454
|
+
const rawMeta = req.body?.meta && typeof req.body.meta === 'object' && !Array.isArray(req.body.meta)
|
|
455
|
+
? req.body.meta as Record<string, unknown>
|
|
456
|
+
: {};
|
|
457
|
+
const normalizedMeta = normalizeMeta(rawMeta);
|
|
458
|
+
const rawFields = parseFields(req.body?.fields);
|
|
459
|
+
const sensitiveFields = parseFields(req.body?.sensitiveFields);
|
|
460
|
+
const fieldsToEncrypt = sensitiveFields.length > 0
|
|
461
|
+
? sensitiveFields
|
|
462
|
+
: rawFields.filter(field => field.sensitive);
|
|
463
|
+
let finalMeta = mergeNonSensitiveFieldsIntoMeta(normalizedMeta, rawFields);
|
|
464
|
+
|
|
465
|
+
if (type === 'oauth2') {
|
|
466
|
+
if (!isPrimaryVault(vaultId)) {
|
|
467
|
+
res.status(400).json({ success: false, error: 'oauth2 credentials must be stored in the primary vault' });
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
validateOAuth2Meta(finalMeta);
|
|
471
|
+
validateOAuth2RequiredFields(fieldsToEncrypt.length > 0 ? fieldsToEncrypt : rawFields);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (type === 'ssh' || type === 'gpg') {
|
|
475
|
+
finalMeta = enforceKeyCredentialMetadata(type as CredentialType, finalMeta, fieldsToEncrypt);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (!isAdmin(auth)) {
|
|
479
|
+
const candidate: CredentialFile = {
|
|
480
|
+
id: 'pending',
|
|
481
|
+
vaultId,
|
|
482
|
+
type: type as CredentialType,
|
|
483
|
+
name,
|
|
484
|
+
meta: finalMeta,
|
|
485
|
+
encrypted: { ciphertext: '', iv: '', salt: '', mac: '' },
|
|
486
|
+
createdAt: new Date().toISOString(),
|
|
487
|
+
updatedAt: new Date().toISOString(),
|
|
488
|
+
};
|
|
489
|
+
const scopes = auth.token.credentialAccess?.write || [];
|
|
490
|
+
if (!matchesScope(candidate, scopes)) {
|
|
491
|
+
res.status(403).json({ success: false, error: 'Credential write scope denied' });
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Auto-set has_totp flag in meta if TOTP field present (check both 'totp' and 'otp' for compat)
|
|
497
|
+
if (findTotpField(fieldsToEncrypt)) {
|
|
498
|
+
finalMeta.has_totp = true;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const created = createCredential(vaultId, type as CredentialType, name, finalMeta, fieldsToEncrypt);
|
|
502
|
+
emitCredentialChanged(req, created, 'created', { toLocation: 'active' });
|
|
503
|
+
res.json({ success: true, credential: toMetadata(created) });
|
|
504
|
+
} catch (error) {
|
|
505
|
+
res.status(400).json({ success: false, error: getErrorMessage(error) });
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
// GET /credentials — list metadata
|
|
510
|
+
router.get('/', async (req: Request, res: Response) => {
|
|
511
|
+
try {
|
|
512
|
+
const auth = req.auth!;
|
|
513
|
+
if (!isAdmin(auth) && !hasAnyPermission(auth.token.permissions, ['secret:read'])) {
|
|
514
|
+
res.status(403).json({ success: false, error: 'secret:read permission required' });
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const vaultId = typeof req.query.vault === 'string' ? req.query.vault : undefined;
|
|
519
|
+
const type = typeof req.query.type === 'string' ? req.query.type : undefined;
|
|
520
|
+
const tag = typeof req.query.tag === 'string' ? req.query.tag : undefined;
|
|
521
|
+
const query = typeof req.query.q === 'string' ? req.query.q : undefined;
|
|
522
|
+
const location = parseCredentialLocation(req.query.location, 'active');
|
|
523
|
+
|
|
524
|
+
if (type && !VALID_CREDENTIAL_TYPES.has(type as CredentialType)) {
|
|
525
|
+
res.status(400).json({ success: false, error: `Invalid credential type: ${type}` });
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
if (!location) {
|
|
529
|
+
res.status(400).json({ success: false, error: 'Invalid location. Expected active, archive, or recently_deleted' });
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const credentials = listCredentials({
|
|
534
|
+
vaultId,
|
|
535
|
+
type: type as CredentialType | undefined,
|
|
536
|
+
tag,
|
|
537
|
+
query,
|
|
538
|
+
}, location);
|
|
539
|
+
|
|
540
|
+
const filtered = isAdmin(auth)
|
|
541
|
+
? credentials
|
|
542
|
+
: credentials.filter(credential => matchesScope(credential, auth.token.credentialAccess?.read || []));
|
|
543
|
+
|
|
544
|
+
const includeHealth = req.query.health === '1' || req.query.health === 'true';
|
|
545
|
+
|
|
546
|
+
if (!includeHealth) {
|
|
547
|
+
res.json({
|
|
548
|
+
success: true,
|
|
549
|
+
credentials: filtered.map(toMetadata),
|
|
550
|
+
});
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const rows = await buildCredentialHealthRows(filtered, readCredentialSecrets);
|
|
555
|
+
const rowById = new Map(rows.map(row => [row.id, row.health]));
|
|
556
|
+
|
|
557
|
+
res.json({
|
|
558
|
+
success: true,
|
|
559
|
+
credentials: filtered.map(credential => ({
|
|
560
|
+
...toMetadata(credential),
|
|
561
|
+
health: rowById.get(credential.id)
|
|
562
|
+
? { status: rowById.get(credential.id)!.status }
|
|
563
|
+
: undefined,
|
|
564
|
+
})),
|
|
565
|
+
});
|
|
566
|
+
} catch (error) {
|
|
567
|
+
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
|
568
|
+
}
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
// GET /credentials/health/summary — aggregate credential health
|
|
572
|
+
router.get('/health/summary', async (req: Request, res: Response) => {
|
|
573
|
+
try {
|
|
574
|
+
const auth = req.auth!;
|
|
575
|
+
if (!isAdmin(auth) && !hasAnyPermission(auth.token.permissions, ['secret:read'])) {
|
|
576
|
+
res.status(403).json({ success: false, error: 'secret:read permission required' });
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
pruneExpiredHealthJobs();
|
|
581
|
+
|
|
582
|
+
const credentials = listHealthCredentials(req);
|
|
583
|
+
const rows = await buildCredentialHealthRows(credentials, readCredentialSecrets);
|
|
584
|
+
const summary = summarizeCredentialHealthFlags(rows.map((row) => row.health.flags));
|
|
585
|
+
|
|
586
|
+
res.json({
|
|
587
|
+
success: true,
|
|
588
|
+
summary: {
|
|
589
|
+
totalAnalyzed: summary.total,
|
|
590
|
+
safe: summary.safe,
|
|
591
|
+
weak: summary.weak,
|
|
592
|
+
reused: summary.reused,
|
|
593
|
+
breached: summary.breached,
|
|
594
|
+
unknown: summary.unknown,
|
|
595
|
+
lastScanAt: new Date().toISOString(),
|
|
596
|
+
},
|
|
597
|
+
});
|
|
598
|
+
} catch (error) {
|
|
599
|
+
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
|
600
|
+
}
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
// GET /credentials/health — per-credential health rows
|
|
604
|
+
router.get('/health', async (req: Request, res: Response) => {
|
|
605
|
+
try {
|
|
606
|
+
const auth = req.auth!;
|
|
607
|
+
if (!isAdmin(auth) && !hasAnyPermission(auth.token.permissions, ['secret:read'])) {
|
|
608
|
+
res.status(403).json({ success: false, error: 'secret:read permission required' });
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
pruneExpiredHealthJobs();
|
|
613
|
+
|
|
614
|
+
const credentials = listHealthCredentials(req);
|
|
615
|
+
const rows = await buildCredentialHealthRows(credentials, readCredentialSecrets);
|
|
616
|
+
|
|
617
|
+
res.json({ success: true, credentials: rows });
|
|
618
|
+
} catch (error) {
|
|
619
|
+
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
|
620
|
+
}
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
// POST /credentials/health/rescan — async job kickoff
|
|
624
|
+
router.post('/health/rescan', async (req: Request, res: Response) => {
|
|
625
|
+
try {
|
|
626
|
+
const auth = req.auth!;
|
|
627
|
+
if (!isAdmin(auth) && !hasAnyPermission(auth.token.permissions, ['secret:read'])) {
|
|
628
|
+
res.status(403).json({ success: false, error: 'secret:read permission required' });
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const scanId = newScanId();
|
|
633
|
+
const now = Date.now();
|
|
634
|
+
healthScanJobs.set(scanId, { status: 'queued', createdAt: now, updatedAt: now });
|
|
635
|
+
|
|
636
|
+
void (async () => {
|
|
637
|
+
try {
|
|
638
|
+
healthScanJobs.set(scanId, { status: 'running', createdAt: now, updatedAt: Date.now() });
|
|
639
|
+
const credentials = listHealthCredentials(req);
|
|
640
|
+
await buildCredentialHealthRows(credentials, readCredentialSecrets);
|
|
641
|
+
healthScanJobs.set(scanId, { status: 'complete', createdAt: now, updatedAt: Date.now() });
|
|
642
|
+
} catch (error) {
|
|
643
|
+
healthScanJobs.set(scanId, {
|
|
644
|
+
status: 'failed',
|
|
645
|
+
createdAt: now,
|
|
646
|
+
updatedAt: Date.now(),
|
|
647
|
+
error: getErrorMessage(error),
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
})();
|
|
651
|
+
|
|
652
|
+
res.json({ accepted: true, scanId });
|
|
653
|
+
} catch (error) {
|
|
654
|
+
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
|
655
|
+
}
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
// GET /credentials/health/rescan/:scanId — read async scan status
|
|
659
|
+
router.get('/health/rescan/:scanId', (req: Request<{ scanId: string }>, res: Response) => {
|
|
660
|
+
try {
|
|
661
|
+
const auth = req.auth!;
|
|
662
|
+
if (!isAdmin(auth) && !hasAnyPermission(auth.token.permissions, ['secret:read'])) {
|
|
663
|
+
res.status(403).json({ success: false, error: 'secret:read permission required' });
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
pruneExpiredHealthJobs();
|
|
668
|
+
|
|
669
|
+
const scan = healthScanJobs.get(req.params.scanId);
|
|
670
|
+
if (!scan) {
|
|
671
|
+
res.status(404).json({ success: false, error: 'Scan job not found' });
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
res.json({
|
|
676
|
+
success: true,
|
|
677
|
+
scanId: req.params.scanId,
|
|
678
|
+
scan: {
|
|
679
|
+
status: scan.status,
|
|
680
|
+
createdAt: new Date(scan.createdAt).toISOString(),
|
|
681
|
+
updatedAt: new Date(scan.updatedAt).toISOString(),
|
|
682
|
+
error: scan.error,
|
|
683
|
+
},
|
|
684
|
+
});
|
|
685
|
+
} catch (error) {
|
|
686
|
+
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
|
687
|
+
}
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
// GET /credentials/:id — metadata
|
|
691
|
+
router.get('/:id', (req: Request<{ id: string }>, res: Response) => {
|
|
692
|
+
try {
|
|
693
|
+
const credential = getCredential(req.params.id);
|
|
694
|
+
if (!credential) {
|
|
695
|
+
res.status(404).json({ success: false, error: 'Credential not found' });
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
if (!canReadCredential(req, credential)) {
|
|
700
|
+
res.status(403).json({ success: false, error: 'Credential read scope denied' });
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
res.json({ success: true, credential: toMetadata(credential) });
|
|
705
|
+
} catch (error) {
|
|
706
|
+
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
|
707
|
+
}
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
// PUT /credentials/:id — update
|
|
711
|
+
router.put('/:id', (req: Request<{ id: string }>, res: Response) => {
|
|
712
|
+
try {
|
|
713
|
+
const credential = getCredential(req.params.id);
|
|
714
|
+
if (!credential) {
|
|
715
|
+
res.status(404).json({ success: false, error: 'Credential not found' });
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
if (!canWriteCredential(req, credential)) {
|
|
719
|
+
res.status(403).json({ success: false, error: 'Credential write scope denied' });
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
let updatedMeta: Record<string, unknown> | undefined;
|
|
724
|
+
const hasMetaInput = req.body && Object.prototype.hasOwnProperty.call(req.body, 'meta');
|
|
725
|
+
if (hasMetaInput) {
|
|
726
|
+
if (!req.body.meta || typeof req.body.meta !== 'object' || Array.isArray(req.body.meta)) {
|
|
727
|
+
res.status(400).json({ success: false, error: 'meta must be an object' });
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
updatedMeta = normalizeMeta(req.body.meta as Record<string, unknown>);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const fields = parseFields(req.body?.fields);
|
|
734
|
+
if (fields.length > 0 || updatedMeta) {
|
|
735
|
+
const baseMeta = updatedMeta || { ...credential.meta };
|
|
736
|
+
updatedMeta = mergeNonSensitiveFieldsIntoMeta(baseMeta, fields);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
let sensitiveFields: CredentialField[] | undefined;
|
|
740
|
+
const hasSensitiveInput = req.body && Object.prototype.hasOwnProperty.call(req.body, 'sensitiveFields');
|
|
741
|
+
if (hasSensitiveInput) {
|
|
742
|
+
sensitiveFields = parseFields(req.body.sensitiveFields);
|
|
743
|
+
} else {
|
|
744
|
+
const sensitiveFromFields = fields.filter(field => field.sensitive);
|
|
745
|
+
if (sensitiveFromFields.length > 0) {
|
|
746
|
+
sensitiveFields = sensitiveFromFields;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// Auto-set has_totp flag in meta if TOTP field present in update
|
|
751
|
+
if (sensitiveFields && findTotpField(sensitiveFields)) {
|
|
752
|
+
if (!updatedMeta) updatedMeta = { ...credential.meta };
|
|
753
|
+
updatedMeta.has_totp = true;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
const rawName = typeof req.body?.name === 'string' ? normalizeName(req.body.name) : undefined;
|
|
757
|
+
|
|
758
|
+
if (credential.type === 'oauth2') {
|
|
759
|
+
const baseMeta = credential.meta as Record<string, unknown>;
|
|
760
|
+
const updatedReauthMeta = shouldClearOAuth2ReauthMarker(credential.type, sensitiveFields)
|
|
761
|
+
? {
|
|
762
|
+
...baseMeta,
|
|
763
|
+
needs_reauth: false,
|
|
764
|
+
reauth_reason: null,
|
|
765
|
+
}
|
|
766
|
+
: baseMeta;
|
|
767
|
+
|
|
768
|
+
const finalMeta = { ...baseMeta, ...updatedReauthMeta, ...updatedMeta };
|
|
769
|
+
validateOAuth2Meta(finalMeta);
|
|
770
|
+
if (!isPrimaryVault(credential.vaultId)) {
|
|
771
|
+
res.status(400).json({ success: false, error: 'oauth2 credentials must be stored in the primary vault' });
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
if (sensitiveFields !== undefined) {
|
|
776
|
+
const existingSensitiveFields = readCredentialSecrets(req.params.id);
|
|
777
|
+
const effectiveSensitiveFields = mergeOAuth2Fields(existingSensitiveFields, sensitiveFields);
|
|
778
|
+
validateOAuth2RequiredFields(effectiveSensitiveFields);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
updatedMeta = finalMeta;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
if ((credential.type === 'ssh' || credential.type === 'gpg') && updatedMeta) {
|
|
786
|
+
const existingSensitiveFields = readCredentialSecrets(credential.id);
|
|
787
|
+
const effectiveSensitiveFields = sensitiveFields !== undefined
|
|
788
|
+
? mergeOAuth2Fields(existingSensitiveFields, sensitiveFields)
|
|
789
|
+
: existingSensitiveFields;
|
|
790
|
+
updatedMeta = enforceKeyCredentialMetadata(credential.type, updatedMeta, effectiveSensitiveFields);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
const updated = updateCredential(req.params.id, {
|
|
794
|
+
name: rawName,
|
|
795
|
+
meta: updatedMeta,
|
|
796
|
+
sensitiveFields,
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
emitCredentialChanged(req, updated, 'updated', { toLocation: 'active' });
|
|
800
|
+
res.json({ success: true, credential: toMetadata(updated) });
|
|
801
|
+
} catch (error) {
|
|
802
|
+
res.status(400).json({ success: false, error: getErrorMessage(error) });
|
|
803
|
+
}
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
// DELETE /credentials/:id — lifecycle delete:
|
|
807
|
+
// active -> archive, archive -> recently_deleted, recently_deleted -> permanent delete
|
|
808
|
+
router.delete('/:id', (req: Request<{ id: string }>, res: Response) => {
|
|
809
|
+
try {
|
|
810
|
+
const location = parseCredentialLocation(req.query.location, 'active');
|
|
811
|
+
if (!location) {
|
|
812
|
+
res.status(400).json({ success: false, error: 'Invalid location. Expected active, archive, or recently_deleted' });
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
const credential = getCredential(req.params.id, location);
|
|
817
|
+
if (!credential) {
|
|
818
|
+
res.status(404).json({ success: false, error: 'Credential not found' });
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
if (!canWriteCredential(req, credential)) {
|
|
822
|
+
res.status(403).json({ success: false, error: 'Credential write scope denied' });
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
if (location === 'active') {
|
|
827
|
+
const archived = archiveCredential(req.params.id);
|
|
828
|
+
if (archived) {
|
|
829
|
+
emitCredentialChanged(req, archived, 'archived', {
|
|
830
|
+
fromLocation: 'active',
|
|
831
|
+
toLocation: 'archive',
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
res.json({ success: true, action: 'archived', credential: archived ? toMetadata(archived) : null });
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
if (location === 'archive') {
|
|
839
|
+
const deleted = deleteArchivedCredential(req.params.id);
|
|
840
|
+
if (deleted) {
|
|
841
|
+
emitCredentialChanged(req, deleted, 'moved_to_recently_deleted', {
|
|
842
|
+
fromLocation: 'archive',
|
|
843
|
+
toLocation: 'recently_deleted',
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
res.json({ success: true, action: 'moved_to_recently_deleted', credential: deleted ? toMetadata(deleted) : null });
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
const deleted = deleteCredential(req.params.id, 'recently_deleted');
|
|
851
|
+
if (deleted) {
|
|
852
|
+
emitCredentialChanged(req, credential, 'purged', {
|
|
853
|
+
fromLocation: 'recently_deleted',
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
res.json({ success: true, action: 'purged', deleted });
|
|
857
|
+
} catch (error) {
|
|
858
|
+
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
|
859
|
+
}
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
// POST /credentials/:id/restore — restore from archive/recently_deleted
|
|
863
|
+
router.post('/:id/restore', (req: Request<{ id: string }>, res: Response) => {
|
|
864
|
+
try {
|
|
865
|
+
const from = parseCredentialLocation(req.body?.from ?? req.query.from, 'archive');
|
|
866
|
+
if (!from || from === 'active') {
|
|
867
|
+
res.status(400).json({ success: false, error: 'Invalid from location. Expected archive or recently_deleted' });
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
const credential = getCredential(req.params.id, from);
|
|
872
|
+
if (!credential) {
|
|
873
|
+
res.status(404).json({ success: false, error: 'Credential not found' });
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
if (!canWriteCredential(req, credential)) {
|
|
877
|
+
res.status(403).json({ success: false, error: 'Credential write scope denied' });
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
if (from === 'archive') {
|
|
882
|
+
const restored = restoreArchivedCredential(req.params.id);
|
|
883
|
+
if (restored) {
|
|
884
|
+
emitCredentialChanged(req, restored, 'restored_to_active', {
|
|
885
|
+
fromLocation: 'archive',
|
|
886
|
+
toLocation: 'active',
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
res.json({ success: true, action: 'restored_to_active', credential: restored ? toMetadata(restored) : null });
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
const restored = restoreDeletedCredential(req.params.id);
|
|
894
|
+
if (restored) {
|
|
895
|
+
emitCredentialChanged(req, restored, 'restored_to_archive', {
|
|
896
|
+
fromLocation: 'recently_deleted',
|
|
897
|
+
toLocation: 'archive',
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
res.json({ success: true, action: 'restored_to_archive', credential: restored ? toMetadata(restored) : null });
|
|
901
|
+
} catch (error) {
|
|
902
|
+
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
|
903
|
+
}
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
// POST /credentials/purge — manual retention sweep for recently deleted credentials
|
|
907
|
+
router.post('/purge', (req: Request, res: Response) => {
|
|
908
|
+
try {
|
|
909
|
+
const daysRaw = req.body?.retentionDays;
|
|
910
|
+
const retentionDays = typeof daysRaw === 'number' && Number.isFinite(daysRaw) && daysRaw > 0
|
|
911
|
+
? Math.floor(daysRaw)
|
|
912
|
+
: 30;
|
|
913
|
+
const summary = purgeDeletedCredentials(retentionDays);
|
|
914
|
+
res.json({ success: true, retentionDays, ...summary });
|
|
915
|
+
} catch (error) {
|
|
916
|
+
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
|
917
|
+
}
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
// POST /credentials/:id/read — decrypt, filter fields, re-encrypt to agent pubkey
|
|
921
|
+
router.post('/:id/read', async (req: Request<{ id: string }>, res: Response) => {
|
|
922
|
+
try {
|
|
923
|
+
const auth = req.auth!;
|
|
924
|
+
const requestedLocation = parseCredentialLocation(req.query.location, 'active');
|
|
925
|
+
if (!requestedLocation) {
|
|
926
|
+
res.status(400).json({ success: false, error: 'Invalid location. Expected active, archive, or recently_deleted' });
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
let credentialLocation: CredentialLocation = requestedLocation;
|
|
931
|
+
let credential = getCredential(req.params.id, credentialLocation);
|
|
932
|
+
if (!credential && requestedLocation === 'active') {
|
|
933
|
+
const detectedLocation = findCredentialLocation(req.params.id);
|
|
934
|
+
if (detectedLocation) {
|
|
935
|
+
credentialLocation = detectedLocation;
|
|
936
|
+
credential = getCredential(req.params.id, credentialLocation);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
if (!credential) {
|
|
941
|
+
res.status(404).json({ success: false, error: 'Credential not found' });
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
if (!canReadCredential(req, credential)) {
|
|
945
|
+
await writeDeniedCredentialAccess({
|
|
946
|
+
req,
|
|
947
|
+
credential,
|
|
948
|
+
action: 'credentials.read',
|
|
949
|
+
reasonCode: 'CREDENTIAL_SCOPE_DENIED',
|
|
950
|
+
httpStatus: 403,
|
|
951
|
+
});
|
|
952
|
+
res.status(403).json({
|
|
953
|
+
success: false,
|
|
954
|
+
error: credentialAccessErrorMessage('CREDENTIAL_SCOPE_DENIED', 'credentials.read'),
|
|
955
|
+
reasonCode: 'CREDENTIAL_SCOPE_DENIED',
|
|
956
|
+
});
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
const decision = evaluateCredentialAccess({
|
|
961
|
+
tokenHash: auth.tokenHash,
|
|
962
|
+
token: auth.token,
|
|
963
|
+
credentialId: credential.id,
|
|
964
|
+
action: 'credentials.read',
|
|
965
|
+
});
|
|
966
|
+
if (!decision.allowed) {
|
|
967
|
+
await writeDeniedCredentialAccess({
|
|
968
|
+
req,
|
|
969
|
+
credential,
|
|
970
|
+
action: 'credentials.read',
|
|
971
|
+
reasonCode: decision.reasonCode,
|
|
972
|
+
httpStatus: decision.httpStatus,
|
|
973
|
+
metadata: {
|
|
974
|
+
limiterWindowMs: decision.limiterWindowMs,
|
|
975
|
+
limiterLimit: decision.limiterLimit,
|
|
976
|
+
limiterCount: decision.limiterCount,
|
|
977
|
+
},
|
|
978
|
+
});
|
|
979
|
+
res.status(decision.httpStatus).json({
|
|
980
|
+
success: false,
|
|
981
|
+
error: credentialAccessErrorMessage(decision.reasonCode, 'credentials.read'),
|
|
982
|
+
reasonCode: decision.reasonCode,
|
|
983
|
+
});
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
if (!auth.token.agentPubkey) {
|
|
988
|
+
await writeDeniedCredentialAccess({
|
|
989
|
+
req,
|
|
990
|
+
credential,
|
|
991
|
+
action: 'credentials.read',
|
|
992
|
+
reasonCode: 'TOKEN_AGENT_PUBKEY_MISSING',
|
|
993
|
+
httpStatus: 400,
|
|
994
|
+
});
|
|
995
|
+
res.status(400).json({
|
|
996
|
+
success: false,
|
|
997
|
+
error: credentialAccessErrorMessage('TOKEN_AGENT_PUBKEY_MISSING', 'credentials.read'),
|
|
998
|
+
reasonCode: 'TOKEN_AGENT_PUBKEY_MISSING',
|
|
999
|
+
});
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
// For oauth2 credentials, auto-refresh if expired
|
|
1004
|
+
const secrets = credential.type === 'oauth2' && credentialLocation === 'active'
|
|
1005
|
+
? await readOAuth2SecretsWithRefresh(credential.id)
|
|
1006
|
+
: readCredentialSecrets(credential.id, credentialLocation);
|
|
1007
|
+
|
|
1008
|
+
// Admin tokens should always read full credential payloads in the dashboard.
|
|
1009
|
+
// Exclusion defaults are intended for delegated agent tokens.
|
|
1010
|
+
const baseExclude = isAdmin(auth)
|
|
1011
|
+
? []
|
|
1012
|
+
: resolveExcludeFields(auth.token.credentialAccess?.excludeFields, credential.type);
|
|
1013
|
+
// For oauth2, always exclude refresh machinery from agent reads
|
|
1014
|
+
const oauth2Exclude = credential.type === 'oauth2' ? OAUTH2_DEFAULT_EXCLUDE_FIELDS : [];
|
|
1015
|
+
const excluded = new Set(
|
|
1016
|
+
[...baseExclude, ...oauth2Exclude].map(field => normalizeScope(field)),
|
|
1017
|
+
);
|
|
1018
|
+
const filteredFields = secrets.filter(field => !excluded.has(normalizeScope(field.key)));
|
|
1019
|
+
|
|
1020
|
+
const [healthRow] = await buildCredentialHealthRows([credential], () => secrets);
|
|
1021
|
+
|
|
1022
|
+
const encrypted = encryptToAgentPubkey(
|
|
1023
|
+
JSON.stringify({
|
|
1024
|
+
id: credential.id,
|
|
1025
|
+
vaultId: credential.vaultId,
|
|
1026
|
+
type: credential.type,
|
|
1027
|
+
fields: filteredFields,
|
|
1028
|
+
health: healthRow?.health,
|
|
1029
|
+
}),
|
|
1030
|
+
auth.token.agentPubkey,
|
|
1031
|
+
);
|
|
1032
|
+
|
|
1033
|
+
// Increment read usage only after successful encryption.
|
|
1034
|
+
recordCredentialRead(auth.tokenHash);
|
|
1035
|
+
|
|
1036
|
+
const auditId = await writeCredentialAccessAudit({
|
|
1037
|
+
credentialId: credential.id,
|
|
1038
|
+
vaultId: credential.vaultId,
|
|
1039
|
+
action: 'credentials.read',
|
|
1040
|
+
allowed: true,
|
|
1041
|
+
reasonCode: 'ALLOW',
|
|
1042
|
+
httpStatus: 200,
|
|
1043
|
+
tokenHash: auth.tokenHash,
|
|
1044
|
+
agentId: auth.token.agentId,
|
|
1045
|
+
requestId: req.header('x-request-id') ?? undefined,
|
|
1046
|
+
actorType: isAdmin(auth) ? 'admin' : 'agent',
|
|
1047
|
+
metadata: {
|
|
1048
|
+
returnedFieldKeys: filteredFields.map((field) => field.key),
|
|
1049
|
+
},
|
|
1050
|
+
});
|
|
1051
|
+
emitCredentialAccessed(req, credential, {
|
|
1052
|
+
action: 'credentials.read',
|
|
1053
|
+
allowed: true,
|
|
1054
|
+
reasonCode: 'ALLOW',
|
|
1055
|
+
httpStatus: 200,
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
logEvent({
|
|
1059
|
+
category: 'agent',
|
|
1060
|
+
action: 'credential_access_decision',
|
|
1061
|
+
description: `Credential access allow: ${credential.id}`,
|
|
1062
|
+
agentId: auth.token.agentId,
|
|
1063
|
+
metadata: {
|
|
1064
|
+
auditId,
|
|
1065
|
+
credentialId: credential.id,
|
|
1066
|
+
route: 'credentials.read',
|
|
1067
|
+
allowed: true,
|
|
1068
|
+
reasonCode: 'ALLOW',
|
|
1069
|
+
},
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
res.json({
|
|
1073
|
+
success: true,
|
|
1074
|
+
credentialId: credential.id,
|
|
1075
|
+
encrypted,
|
|
1076
|
+
});
|
|
1077
|
+
} catch (error) {
|
|
1078
|
+
res.status(400).json({ success: false, error: getErrorMessage(error) });
|
|
1079
|
+
}
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
// POST /credentials/:id/totp — generate current TOTP code
|
|
1083
|
+
router.post('/:id/totp', async (req: Request<{ id: string }>, res: Response) => {
|
|
1084
|
+
try {
|
|
1085
|
+
const auth = req.auth!;
|
|
1086
|
+
|
|
1087
|
+
const credential = getCredential(req.params.id);
|
|
1088
|
+
if (!credential) {
|
|
1089
|
+
res.status(404).json({ success: false, error: 'Credential not found' });
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
if (!canReadCredential(req, credential)) {
|
|
1093
|
+
await writeDeniedCredentialAccess({
|
|
1094
|
+
req,
|
|
1095
|
+
credential,
|
|
1096
|
+
action: 'credentials.totp',
|
|
1097
|
+
reasonCode: 'CREDENTIAL_SCOPE_DENIED',
|
|
1098
|
+
httpStatus: 403,
|
|
1099
|
+
});
|
|
1100
|
+
res.status(403).json({
|
|
1101
|
+
success: false,
|
|
1102
|
+
error: credentialAccessErrorMessage('CREDENTIAL_SCOPE_DENIED', 'credentials.totp'),
|
|
1103
|
+
reasonCode: 'CREDENTIAL_SCOPE_DENIED',
|
|
1104
|
+
});
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// totp:read permission required (admin bypasses)
|
|
1109
|
+
if (!isAdmin(auth) && !hasAnyPermission(auth.token.permissions, ['totp:read'])) {
|
|
1110
|
+
await writeDeniedCredentialAccess({
|
|
1111
|
+
req,
|
|
1112
|
+
credential,
|
|
1113
|
+
action: 'credentials.totp',
|
|
1114
|
+
reasonCode: 'TOKEN_PERMISSION_DENIED',
|
|
1115
|
+
httpStatus: 403,
|
|
1116
|
+
});
|
|
1117
|
+
res.status(403).json({
|
|
1118
|
+
success: false,
|
|
1119
|
+
error: credentialAccessErrorMessage('TOKEN_PERMISSION_DENIED', 'credentials.totp'),
|
|
1120
|
+
reasonCode: 'TOKEN_PERMISSION_DENIED',
|
|
1121
|
+
});
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
const decision = evaluateCredentialAccess({
|
|
1126
|
+
tokenHash: auth.tokenHash,
|
|
1127
|
+
token: auth.token,
|
|
1128
|
+
credentialId: credential.id,
|
|
1129
|
+
action: 'credentials.totp',
|
|
1130
|
+
});
|
|
1131
|
+
if (!decision.allowed) {
|
|
1132
|
+
await writeDeniedCredentialAccess({
|
|
1133
|
+
req,
|
|
1134
|
+
credential,
|
|
1135
|
+
action: 'credentials.totp',
|
|
1136
|
+
reasonCode: decision.reasonCode,
|
|
1137
|
+
httpStatus: decision.httpStatus,
|
|
1138
|
+
metadata: {
|
|
1139
|
+
limiterWindowMs: decision.limiterWindowMs,
|
|
1140
|
+
limiterLimit: decision.limiterLimit,
|
|
1141
|
+
limiterCount: decision.limiterCount,
|
|
1142
|
+
},
|
|
1143
|
+
});
|
|
1144
|
+
res.status(decision.httpStatus).json({
|
|
1145
|
+
success: false,
|
|
1146
|
+
error: credentialAccessErrorMessage(decision.reasonCode, 'credentials.totp'),
|
|
1147
|
+
reasonCode: decision.reasonCode,
|
|
1148
|
+
});
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
const secrets = readCredentialSecrets(credential.id);
|
|
1153
|
+
const totpField = findTotpField(secrets);
|
|
1154
|
+
if (!totpField) {
|
|
1155
|
+
await writeDeniedCredentialAccess({
|
|
1156
|
+
req,
|
|
1157
|
+
credential,
|
|
1158
|
+
action: 'credentials.totp',
|
|
1159
|
+
reasonCode: 'CREDENTIAL_TOTP_NOT_CONFIGURED',
|
|
1160
|
+
httpStatus: 400,
|
|
1161
|
+
});
|
|
1162
|
+
res.status(400).json({
|
|
1163
|
+
success: false,
|
|
1164
|
+
error: credentialAccessErrorMessage('CREDENTIAL_TOTP_NOT_CONFIGURED', 'credentials.totp'),
|
|
1165
|
+
reasonCode: 'CREDENTIAL_TOTP_NOT_CONFIGURED',
|
|
1166
|
+
});
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
const result = generateTOTP(totpField.value, credential.name);
|
|
1171
|
+
|
|
1172
|
+
const auditId = await writeCredentialAccessAudit({
|
|
1173
|
+
credentialId: credential.id,
|
|
1174
|
+
vaultId: credential.vaultId,
|
|
1175
|
+
action: 'credentials.totp',
|
|
1176
|
+
allowed: true,
|
|
1177
|
+
reasonCode: 'ALLOW',
|
|
1178
|
+
httpStatus: 200,
|
|
1179
|
+
tokenHash: auth.tokenHash,
|
|
1180
|
+
agentId: auth.token.agentId,
|
|
1181
|
+
requestId: req.header('x-request-id') ?? undefined,
|
|
1182
|
+
actorType: isAdmin(auth) ? 'admin' : 'agent',
|
|
1183
|
+
});
|
|
1184
|
+
emitCredentialAccessed(req, credential, {
|
|
1185
|
+
action: 'credentials.totp',
|
|
1186
|
+
allowed: true,
|
|
1187
|
+
reasonCode: 'ALLOW',
|
|
1188
|
+
httpStatus: 200,
|
|
1189
|
+
});
|
|
1190
|
+
|
|
1191
|
+
logEvent({
|
|
1192
|
+
category: 'agent',
|
|
1193
|
+
action: 'credential_access_decision',
|
|
1194
|
+
description: `Credential TOTP access allow: ${credential.id}`,
|
|
1195
|
+
agentId: auth.token.agentId,
|
|
1196
|
+
metadata: {
|
|
1197
|
+
auditId,
|
|
1198
|
+
credentialId: credential.id,
|
|
1199
|
+
route: 'credentials.totp',
|
|
1200
|
+
allowed: true,
|
|
1201
|
+
reasonCode: 'ALLOW',
|
|
1202
|
+
},
|
|
1203
|
+
});
|
|
1204
|
+
|
|
1205
|
+
res.json({ success: true, code: result.code, remaining: result.remaining });
|
|
1206
|
+
} catch (error) {
|
|
1207
|
+
res.status(400).json({ success: false, error: getErrorMessage(error) });
|
|
1208
|
+
}
|
|
1209
|
+
});
|
|
1210
|
+
|
|
1211
|
+
// GET /credentials/:id/secrets — admin-only plaintext field read (for web UI)
|
|
1212
|
+
router.get('/:id/secrets', (req: Request<{ id: string }>, res: Response) => {
|
|
1213
|
+
try {
|
|
1214
|
+
const auth = req.auth!;
|
|
1215
|
+
if (!isAdmin(auth)) {
|
|
1216
|
+
res.status(403).json({ success: false, error: 'Admin access required' });
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
const credential = getCredential(req.params.id);
|
|
1221
|
+
if (!credential) {
|
|
1222
|
+
res.status(404).json({ success: false, error: 'Credential not found' });
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
const fields = readCredentialSecrets(credential.id);
|
|
1227
|
+
|
|
1228
|
+
// If TOTP field exists, also generate current code
|
|
1229
|
+
const totpField = findTotpField(fields);
|
|
1230
|
+
let totp: { code: string; remaining: number } | undefined;
|
|
1231
|
+
if (totpField) {
|
|
1232
|
+
try {
|
|
1233
|
+
totp = generateTOTP(totpField.value, credential.name);
|
|
1234
|
+
} catch {
|
|
1235
|
+
// Invalid TOTP secret — skip
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
res.json({ success: true, fields, totp });
|
|
1240
|
+
} catch (error) {
|
|
1241
|
+
res.status(400).json({ success: false, error: getErrorMessage(error) });
|
|
1242
|
+
}
|
|
1243
|
+
});
|
|
1244
|
+
|
|
1245
|
+
// POST /credentials/:id/reauth — stub for re-authentication flow
|
|
1246
|
+
router.post('/:id/reauth', (req: Request<{ id: string }>, res: Response) => {
|
|
1247
|
+
try {
|
|
1248
|
+
const auth = req.auth!;
|
|
1249
|
+
if (!isAdmin(auth)) {
|
|
1250
|
+
res.status(403).json({ success: false, error: 'Admin access required' });
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
const credential = getCredential(req.params.id);
|
|
1255
|
+
if (!credential) {
|
|
1256
|
+
res.status(404).json({ success: false, error: 'Credential not found' });
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
if (credential.type !== 'oauth2') {
|
|
1261
|
+
res.status(400).json({ success: false, error: 'Re-auth only applies to oauth2 credentials' });
|
|
1262
|
+
return;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
const authorizationEndpoint = credential.meta.authorization_endpoint as string | undefined;
|
|
1266
|
+
const tokenEndpoint = credential.meta.token_endpoint as string | undefined;
|
|
1267
|
+
|
|
1268
|
+
if (!authorizationEndpoint || !tokenEndpoint) {
|
|
1269
|
+
res.status(400).json({
|
|
1270
|
+
success: false,
|
|
1271
|
+
error: 'OAuth2 credentials require both `authorization_endpoint` and `token_endpoint` for re-auth.',
|
|
1272
|
+
});
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
res.status(501).json({
|
|
1277
|
+
success: false,
|
|
1278
|
+
credentialId: credential.id,
|
|
1279
|
+
needs_reauth: credential.meta.needs_reauth || false,
|
|
1280
|
+
reauth_reason: credential.meta.reauth_reason || null,
|
|
1281
|
+
authorization_endpoint: authorizationEndpoint,
|
|
1282
|
+
token_endpoint: tokenEndpoint,
|
|
1283
|
+
error: 'OAuth2 re-authentication flow is not implemented yet. Update credential fields manually with fresh tokens.',
|
|
1284
|
+
});
|
|
1285
|
+
} catch (error) {
|
|
1286
|
+
res.status(400).json({ success: false, error: getErrorMessage(error) });
|
|
1287
|
+
}
|
|
1288
|
+
});
|
|
1289
|
+
|
|
1290
|
+
export default router;
|