auramaxx 0.0.1

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.
Files changed (418) hide show
  1. package/LICENSE +26 -0
  2. package/README.md +77 -0
  3. package/apps/desktop-electron/main.js +428 -0
  4. package/bin/auramaxx.js +1063 -0
  5. package/docs/ADAPTERS.md +466 -0
  6. package/docs/AGENT_SETUP.md +159 -0
  7. package/docs/API.md +127 -0
  8. package/docs/APPS.md +199 -0
  9. package/docs/ARCHITECTURE.md +235 -0
  10. package/docs/AUTH.md +318 -0
  11. package/docs/BEST-PRACTICES.md +82 -0
  12. package/docs/CLI.md +141 -0
  13. package/docs/DESKTOP_ELECTRON.md +26 -0
  14. package/docs/DEVELOPING-APPS.md +453 -0
  15. package/docs/MCP.md +122 -0
  16. package/docs/PACKAGING_POLICY.md +19 -0
  17. package/docs/PERMISSION.md +137 -0
  18. package/docs/PROTOCOL.md +142 -0
  19. package/docs/README.md +50 -0
  20. package/docs/SKILLS.md +132 -0
  21. package/docs/TROUBLESHOOTING.md +376 -0
  22. package/docs/WORKSPACE.md +673 -0
  23. package/docs/agent-auth.md +14 -0
  24. package/docs/api/authentication.md +79 -0
  25. package/docs/api/secrets/api-keys.md +28 -0
  26. package/docs/api/secrets/credentials.md +80 -0
  27. package/docs/api/secrets/sharing.md +48 -0
  28. package/docs/api/system.md +41 -0
  29. package/docs/api/wallets/apps-strategies.md +66 -0
  30. package/docs/api/wallets/core.md +46 -0
  31. package/docs/api/wallets/data-portfolio.md +42 -0
  32. package/docs/aura-file.md +48 -0
  33. package/docs/core-concepts/FEATURES.md +114 -0
  34. package/docs/credentials.md +120 -0
  35. package/docs/external/HOW_TO_AURAMAXX/GETTING_SECRETS.md +33 -0
  36. package/docs/external/HOW_TO_AURAMAXX/README.md +45 -0
  37. package/docs/external/getting-started.md +10 -0
  38. package/docs/external/overview.md +19 -0
  39. package/docs/external/persona-paths.md +7 -0
  40. package/docs/external/share-secret.md +76 -0
  41. package/docs/external/why-aura.md +7 -0
  42. package/docs/security.md +227 -0
  43. package/docs/templates/RELEASE_NOTES_TEMPLATE.md +22 -0
  44. package/docs/wallet/AI.md +508 -0
  45. package/docs/wallet/DEVELOPING-STRATEGIES.md +713 -0
  46. package/docs/wallet/README.md +47 -0
  47. package/docs/wallet/STRATEGY.md +89 -0
  48. package/next.config.ts +28 -0
  49. package/package.json +167 -0
  50. package/postcss.config.mjs +8 -0
  51. package/prisma/migrations/20260214170000_baseline/migration.sql +511 -0
  52. package/prisma/migrations/20260216214537_add_passkey_model/migration.sql +18 -0
  53. package/prisma/migrations/20260217150500_add_credential_access_audit/migration.sql +31 -0
  54. package/prisma/migrations/20260222090000_update_admin_ttl_default/migration.sql +10 -0
  55. package/prisma/migrations/migration_lock.toml +3 -0
  56. package/prisma/schema.prisma +447 -0
  57. package/public/logo.webp +0 -0
  58. package/scripts/add-app.js +245 -0
  59. package/server/abi/SwapHelper.json +438 -0
  60. package/server/cli/approval.ts +447 -0
  61. package/server/cli/commands/actions.ts +474 -0
  62. package/server/cli/commands/api.ts +220 -0
  63. package/server/cli/commands/apikey.ts +277 -0
  64. package/server/cli/commands/app.ts +204 -0
  65. package/server/cli/commands/auth.ts +464 -0
  66. package/server/cli/commands/cron.ts +24 -0
  67. package/server/cli/commands/diary.ts +274 -0
  68. package/server/cli/commands/doctor.ts +1247 -0
  69. package/server/cli/commands/env.ts +476 -0
  70. package/server/cli/commands/experimental.ts +69 -0
  71. package/server/cli/commands/init.ts +798 -0
  72. package/server/cli/commands/lock.ts +157 -0
  73. package/server/cli/commands/mcp.ts +285 -0
  74. package/server/cli/commands/quickhack.ts +86 -0
  75. package/server/cli/commands/release-check.ts +231 -0
  76. package/server/cli/commands/restore.ts +314 -0
  77. package/server/cli/commands/service.ts +320 -0
  78. package/server/cli/commands/shell-hook.ts +512 -0
  79. package/server/cli/commands/skill.ts +216 -0
  80. package/server/cli/commands/start.ts +139 -0
  81. package/server/cli/commands/status.ts +59 -0
  82. package/server/cli/commands/stop.ts +36 -0
  83. package/server/cli/commands/token.ts +180 -0
  84. package/server/cli/commands/unlock.ts +50 -0
  85. package/server/cli/commands/vault.ts +1323 -0
  86. package/server/cli/commands/wallet.ts +209 -0
  87. package/server/cli/index.ts +280 -0
  88. package/server/cli/lib/approval-poll.ts +94 -0
  89. package/server/cli/lib/aura-parser.ts +64 -0
  90. package/server/cli/lib/credential-create.ts +74 -0
  91. package/server/cli/lib/credential-resolve.ts +280 -0
  92. package/server/cli/lib/dotenv-migrate.ts +116 -0
  93. package/server/cli/lib/dotenv-parser.ts +146 -0
  94. package/server/cli/lib/escalation.ts +57 -0
  95. package/server/cli/lib/http.ts +91 -0
  96. package/server/cli/lib/init-steps.ts +76 -0
  97. package/server/cli/lib/local-agent-trust.ts +45 -0
  98. package/server/cli/lib/lock-unlock-helper.ts +71 -0
  99. package/server/cli/lib/process.ts +162 -0
  100. package/server/cli/lib/prompt.ts +294 -0
  101. package/server/cli/lib/theme.ts +240 -0
  102. package/server/cli/socket.ts +579 -0
  103. package/server/cli/transport-client.ts +50 -0
  104. package/server/cron/index.ts +137 -0
  105. package/server/cron/job.ts +31 -0
  106. package/server/cron/jobs/balance-sync.ts +436 -0
  107. package/server/cron/jobs/incoming-scan.ts +506 -0
  108. package/server/cron/jobs/native-price.ts +70 -0
  109. package/server/cron/jobs/orphan-cleanup.ts +40 -0
  110. package/server/cron/jobs/strategy-runner.ts +175 -0
  111. package/server/cron/scheduler.ts +125 -0
  112. package/server/index.ts +420 -0
  113. package/server/lib/adapters/factory.ts +119 -0
  114. package/server/lib/adapters/index.ts +19 -0
  115. package/server/lib/adapters/router.ts +297 -0
  116. package/server/lib/adapters/telegram.ts +645 -0
  117. package/server/lib/adapters/types.ts +89 -0
  118. package/server/lib/adapters/webhook.ts +95 -0
  119. package/server/lib/address.ts +49 -0
  120. package/server/lib/agent-auth/contracts.ts +1194 -0
  121. package/server/lib/agent-profiles.ts +419 -0
  122. package/server/lib/ai.ts +285 -0
  123. package/server/lib/api-registry/contracts.ts +86 -0
  124. package/server/lib/api-registry/validation.ts +172 -0
  125. package/server/lib/apikey-migration.ts +258 -0
  126. package/server/lib/app-installer.ts +505 -0
  127. package/server/lib/app-tokens.ts +247 -0
  128. package/server/lib/approval-link.ts +27 -0
  129. package/server/lib/auth.ts +314 -0
  130. package/server/lib/auto-execute.ts +160 -0
  131. package/server/lib/batch.ts +242 -0
  132. package/server/lib/cold.ts +1048 -0
  133. package/server/lib/config.ts +408 -0
  134. package/server/lib/credential-access-audit.ts +85 -0
  135. package/server/lib/credential-access-policy.ts +111 -0
  136. package/server/lib/credential-health.ts +343 -0
  137. package/server/lib/credential-import.ts +608 -0
  138. package/server/lib/credential-scope.ts +102 -0
  139. package/server/lib/credential-shares.ts +190 -0
  140. package/server/lib/credential-transport.ts +533 -0
  141. package/server/lib/credential-vault.ts +77 -0
  142. package/server/lib/credentials.ts +422 -0
  143. package/server/lib/crypto.ts +8 -0
  144. package/server/lib/db.ts +58 -0
  145. package/server/lib/defaults.ts +386 -0
  146. package/server/lib/dex/index.ts +80 -0
  147. package/server/lib/dex/relay.ts +235 -0
  148. package/server/lib/dex/types.ts +59 -0
  149. package/server/lib/dex/uniswap.ts +370 -0
  150. package/server/lib/diary.ts +34 -0
  151. package/server/lib/dont-ask-again-policy.ts +41 -0
  152. package/server/lib/e2e-agent/artifacts.ts +36 -0
  153. package/server/lib/e2e-agent/contracts.ts +112 -0
  154. package/server/lib/e2e-agent/validation.ts +135 -0
  155. package/server/lib/encrypt.ts +114 -0
  156. package/server/lib/error.ts +20 -0
  157. package/server/lib/events.ts +217 -0
  158. package/server/lib/feature-flags.ts +93 -0
  159. package/server/lib/hot.ts +357 -0
  160. package/server/lib/human-action-summary.ts +80 -0
  161. package/server/lib/key-fingerprint.ts +28 -0
  162. package/server/lib/logger.ts +340 -0
  163. package/server/lib/network.ts +137 -0
  164. package/server/lib/notifications.ts +230 -0
  165. package/server/lib/oauth2-refresh.ts +241 -0
  166. package/server/lib/oursecret.ts +71 -0
  167. package/server/lib/passkey-credential.ts +360 -0
  168. package/server/lib/passkey.ts +68 -0
  169. package/server/lib/permissions.ts +299 -0
  170. package/server/lib/pino.ts +24 -0
  171. package/server/lib/policy-preview.ts +138 -0
  172. package/server/lib/price.ts +338 -0
  173. package/server/lib/prices.ts +34 -0
  174. package/server/lib/project-scope.ts +297 -0
  175. package/server/lib/resolve-action.ts +328 -0
  176. package/server/lib/resolve.ts +36 -0
  177. package/server/lib/secret-gist-share.ts +296 -0
  178. package/server/lib/sessions.ts +634 -0
  179. package/server/lib/socket-path.ts +56 -0
  180. package/server/lib/solana/connection.ts +26 -0
  181. package/server/lib/solana/jupiter.ts +128 -0
  182. package/server/lib/solana/transfer.ts +108 -0
  183. package/server/lib/solana/wallet.ts +136 -0
  184. package/server/lib/strategy/emits.ts +21 -0
  185. package/server/lib/strategy/engine.ts +1305 -0
  186. package/server/lib/strategy/executor.ts +115 -0
  187. package/server/lib/strategy/hook-context.ts +159 -0
  188. package/server/lib/strategy/hooks.ts +990 -0
  189. package/server/lib/strategy/index.ts +28 -0
  190. package/server/lib/strategy/installer.ts +305 -0
  191. package/server/lib/strategy/loader.ts +256 -0
  192. package/server/lib/strategy/message.ts +237 -0
  193. package/server/lib/strategy/repository.ts +218 -0
  194. package/server/lib/strategy/session-logger.ts +693 -0
  195. package/server/lib/strategy/sources.ts +288 -0
  196. package/server/lib/strategy/state.ts +189 -0
  197. package/server/lib/strategy/templates.ts +403 -0
  198. package/server/lib/strategy/tick.ts +404 -0
  199. package/server/lib/strategy/types.ts +230 -0
  200. package/server/lib/swap.ts +3 -0
  201. package/server/lib/temp.ts +86 -0
  202. package/server/lib/token-metadata.ts +86 -0
  203. package/server/lib/token-safety.ts +200 -0
  204. package/server/lib/token-search.ts +444 -0
  205. package/server/lib/totp.ts +194 -0
  206. package/server/lib/transactions.ts +123 -0
  207. package/server/lib/transport.ts +84 -0
  208. package/server/lib/txhistory/decoder.ts +262 -0
  209. package/server/lib/txhistory/enricher.ts +652 -0
  210. package/server/lib/txhistory/index.ts +391 -0
  211. package/server/lib/txhistory/signatures.ts +59 -0
  212. package/server/lib/update-check.ts +35 -0
  213. package/server/lib/verified-summary.ts +414 -0
  214. package/server/lib/view-registry.ts +80 -0
  215. package/server/mcp/profile-policy.ts +30 -0
  216. package/server/mcp/server.ts +1589 -0
  217. package/server/mcp/tools.ts +276 -0
  218. package/server/middleware/auth.ts +119 -0
  219. package/server/middleware/requestLogger.ts +84 -0
  220. package/server/routes/actions.ts +539 -0
  221. package/server/routes/adapters.ts +711 -0
  222. package/server/routes/addressbook.ts +113 -0
  223. package/server/routes/ai.ts +34 -0
  224. package/server/routes/apikeys.ts +343 -0
  225. package/server/routes/apps.ts +601 -0
  226. package/server/routes/auth.ts +406 -0
  227. package/server/routes/backup.ts +404 -0
  228. package/server/routes/batch.ts +270 -0
  229. package/server/routes/bookmarks.ts +162 -0
  230. package/server/routes/credential-shares.ts +380 -0
  231. package/server/routes/credential-vaults.ts +159 -0
  232. package/server/routes/credentials.ts +1782 -0
  233. package/server/routes/dashboard.ts +97 -0
  234. package/server/routes/defaults.ts +124 -0
  235. package/server/routes/flags.ts +11 -0
  236. package/server/routes/fund.ts +225 -0
  237. package/server/routes/heartbeat.ts +375 -0
  238. package/server/routes/import.ts +364 -0
  239. package/server/routes/launch.ts +665 -0
  240. package/server/routes/lock.ts +54 -0
  241. package/server/routes/logs.ts +68 -0
  242. package/server/routes/nuke.ts +111 -0
  243. package/server/routes/passkey-credentials.ts +99 -0
  244. package/server/routes/passkey.ts +366 -0
  245. package/server/routes/portfolio.ts +217 -0
  246. package/server/routes/price.ts +63 -0
  247. package/server/routes/resolve.ts +31 -0
  248. package/server/routes/security.ts +45 -0
  249. package/server/routes/send-evm.ts +241 -0
  250. package/server/routes/send-solana.ts +281 -0
  251. package/server/routes/send.ts +178 -0
  252. package/server/routes/setup.ts +210 -0
  253. package/server/routes/strategy.ts +894 -0
  254. package/server/routes/swap-evm.ts +352 -0
  255. package/server/routes/swap-solana.ts +176 -0
  256. package/server/routes/swap.ts +356 -0
  257. package/server/routes/token.ts +247 -0
  258. package/server/routes/unlock.ts +467 -0
  259. package/server/routes/views.ts +41 -0
  260. package/server/routes/wallet-assets.ts +361 -0
  261. package/server/routes/wallet-transactions.ts +515 -0
  262. package/server/routes/wallet.ts +709 -0
  263. package/server/types.ts +146 -0
  264. package/shared/credential-field-schema.ts +248 -0
  265. package/skills/auramaxx/HEARTBEAT.md +78 -0
  266. package/skills/auramaxx/SKILL.md +745 -0
  267. package/skills/auramaxx/docs/AGENT_SETUP.md +155 -0
  268. package/skills/auramaxx/docs/API.md +127 -0
  269. package/skills/auramaxx/docs/AUTH.md +318 -0
  270. package/skills/auramaxx/docs/CLI.md +130 -0
  271. package/skills/auramaxx/docs/MCP.md +122 -0
  272. package/skills/auramaxx/docs/TROUBLESHOOTING.md +357 -0
  273. package/skills/auramaxx/docs/WORKSPACE.md +673 -0
  274. package/skills/auramaxx/docs/security.md +227 -0
  275. package/skills/task-lifecycle/SKILL.md +378 -0
  276. package/src/app/api/[...doc]/page.tsx +36 -0
  277. package/src/app/api/agent-requests/route.ts +30 -0
  278. package/src/app/api/apps/install/route.ts +132 -0
  279. package/src/app/api/apps/manifests/route.ts +16 -0
  280. package/src/app/api/apps/static/[...path]/route.ts +57 -0
  281. package/src/app/api/docs/plain/route.ts +74 -0
  282. package/src/app/api/events/route.ts +92 -0
  283. package/src/app/api/page.tsx +290 -0
  284. package/src/app/api/workspace/[id]/apps/[wid]/route.ts +119 -0
  285. package/src/app/api/workspace/[id]/apps/route.ts +81 -0
  286. package/src/app/api/workspace/[id]/export/route.ts +67 -0
  287. package/src/app/api/workspace/[id]/route.ts +168 -0
  288. package/src/app/api/workspace/auth.ts +40 -0
  289. package/src/app/api/workspace/config/route.ts +121 -0
  290. package/src/app/api/workspace/import/route.ts +127 -0
  291. package/src/app/api/workspace/route.ts +116 -0
  292. package/src/app/app-legacy-do-not-use/page.tsx +2245 -0
  293. package/src/app/apple-icon.png +0 -0
  294. package/src/app/approve/[actionId]/page.tsx +409 -0
  295. package/src/app/docs/DocsPageContent.tsx +269 -0
  296. package/src/app/docs/[...doc]/page.tsx +41 -0
  297. package/src/app/docs/page.tsx +38 -0
  298. package/src/app/favicon.ico +0 -0
  299. package/src/app/globals.css +819 -0
  300. package/src/app/health/page.tsx +5 -0
  301. package/src/app/hello/page.tsx +102 -0
  302. package/src/app/icon.png +0 -0
  303. package/src/app/layout.tsx +39 -0
  304. package/src/app/page.tsx +1964 -0
  305. package/src/app/privacy/page.tsx +63 -0
  306. package/src/app/providers.tsx +87 -0
  307. package/src/app/share/[token]/page.tsx +295 -0
  308. package/src/app/terms/page.tsx +80 -0
  309. package/src/components/ChainSelector.tsx +44 -0
  310. package/src/components/HumanActionBar.tsx +697 -0
  311. package/src/components/NotificationDrawer.tsx +387 -0
  312. package/src/components/PasskeyEnrollmentPrompt.tsx +235 -0
  313. package/src/components/apps/AgentKeysApp.tsx +490 -0
  314. package/src/components/apps/App.tsx +153 -0
  315. package/src/components/apps/AppGrid.tsx +15 -0
  316. package/src/components/apps/DetailedAddressDrawer.tsx +325 -0
  317. package/src/components/apps/DraggableApp.tsx +562 -0
  318. package/src/components/apps/IFrameApp.tsx +73 -0
  319. package/src/components/apps/LogsApp.tsx +360 -0
  320. package/src/components/apps/SendApp.tsx +394 -0
  321. package/src/components/apps/SetupWizardApp.tsx +1004 -0
  322. package/src/components/apps/SystemDefaultsApp.tsx +845 -0
  323. package/src/components/apps/ThirdPartyApp.tsx +428 -0
  324. package/src/components/apps/TokenApp.tsx +319 -0
  325. package/src/components/apps/TransactionsApp.tsx +438 -0
  326. package/src/components/apps/WalletDetailApp.tsx +1505 -0
  327. package/src/components/apps/index.ts +13 -0
  328. package/src/components/design-system/Button.tsx +88 -0
  329. package/src/components/design-system/ChainIndicator.tsx +65 -0
  330. package/src/components/design-system/ChainSelector.tsx +147 -0
  331. package/src/components/design-system/ConfirmationModal.tsx +107 -0
  332. package/src/components/design-system/ConfirmationPopover.tsx +81 -0
  333. package/src/components/design-system/DownloadButton.tsx +149 -0
  334. package/src/components/design-system/Drawer.tsx +133 -0
  335. package/src/components/design-system/FilterDropdown.tsx +183 -0
  336. package/src/components/design-system/ItemPicker.tsx +157 -0
  337. package/src/components/design-system/Modal.tsx +296 -0
  338. package/src/components/design-system/Popover.tsx +142 -0
  339. package/src/components/design-system/TextInput.tsx +85 -0
  340. package/src/components/design-system/Toggle.tsx +65 -0
  341. package/src/components/design-system/TyvekCollapsibleSection.tsx +55 -0
  342. package/src/components/design-system/index.ts +14 -0
  343. package/src/components/docs/ClientSideMarkdown.tsx +51 -0
  344. package/src/components/docs/DocsSearchBar.tsx +118 -0
  345. package/src/components/docs/DocsThemeToggle.tsx +38 -0
  346. package/src/components/docs/PersistentDocGroup.tsx +91 -0
  347. package/src/components/docs/ShareUrlButton.tsx +33 -0
  348. package/src/components/docs/SidebarScrollMemory.tsx +56 -0
  349. package/src/components/health/CredentialHealthDashboard.tsx +214 -0
  350. package/src/components/icons/ChainIcons.tsx +72 -0
  351. package/src/components/layout/AppStoreDrawer.tsx +369 -0
  352. package/src/components/layout/ContentArea.tsx +21 -0
  353. package/src/components/layout/CreateViewModal.tsx +88 -0
  354. package/src/components/layout/LeftRail.tsx +114 -0
  355. package/src/components/layout/TabBar.tsx +284 -0
  356. package/src/components/layout/WalletSidebar.tsx +1030 -0
  357. package/src/components/layout/index.ts +6 -0
  358. package/src/components/marketing/AuraMaxxSpecOverlay.tsx +653 -0
  359. package/src/components/marketing/DeviceMorphExperience.tsx +216 -0
  360. package/src/components/vault/ApiKeysConsole.tsx +1272 -0
  361. package/src/components/vault/AuditConsole.tsx +600 -0
  362. package/src/components/vault/CredentialDetail.tsx +625 -0
  363. package/src/components/vault/CredentialEmpty.tsx +55 -0
  364. package/src/components/vault/CredentialField.tsx +583 -0
  365. package/src/components/vault/CredentialForm.tsx +1484 -0
  366. package/src/components/vault/CredentialList.tsx +265 -0
  367. package/src/components/vault/CredentialRow.tsx +130 -0
  368. package/src/components/vault/CredentialShareModal.tsx +273 -0
  369. package/src/components/vault/CredentialVault.tsx +1662 -0
  370. package/src/components/vault/CredentialWalletWidget.tsx +103 -0
  371. package/src/components/vault/DocsConsole.tsx +113 -0
  372. package/src/components/vault/ImportCredentialsModal.tsx +578 -0
  373. package/src/components/vault/LargeTypeModal.tsx +88 -0
  374. package/src/components/vault/PasswordGenerator.tsx +232 -0
  375. package/src/components/vault/TOTPDisplay.tsx +108 -0
  376. package/src/components/vault/TotpSetupPanel.tsx +198 -0
  377. package/src/components/vault/VaultSidebar.tsx +881 -0
  378. package/src/components/vault/credentialFormName.ts +91 -0
  379. package/src/components/vault/hooks/useVaultKeyboardShortcuts.ts +69 -0
  380. package/src/components/vault/types.ts +56 -0
  381. package/src/context/AuthContext.tsx +365 -0
  382. package/src/context/PriceContext.tsx +113 -0
  383. package/src/context/ThemeContext.tsx +164 -0
  384. package/src/context/WebSocketContext.tsx +269 -0
  385. package/src/context/WorkspaceContext.tsx +668 -0
  386. package/src/hooks/index.ts +4 -0
  387. package/src/hooks/useAgentActions.ts +552 -0
  388. package/src/hooks/useBalance.ts +103 -0
  389. package/src/hooks/useBalances.ts +129 -0
  390. package/src/hooks/useTheme.ts +156 -0
  391. package/src/instrumentation.ts +12 -0
  392. package/src/lib/api-docs.ts +154 -0
  393. package/src/lib/api.ts +474 -0
  394. package/src/lib/app-loader.ts +148 -0
  395. package/src/lib/app-registry.ts +178 -0
  396. package/src/lib/app-sdk.ts +157 -0
  397. package/src/lib/audit-console-adapter.ts +151 -0
  398. package/src/lib/auth-client.ts +75 -0
  399. package/src/lib/config.ts +74 -0
  400. package/src/lib/credential-field-schema.ts +11 -0
  401. package/src/lib/crypto.ts +112 -0
  402. package/src/lib/db.ts +21 -0
  403. package/src/lib/docs.ts +544 -0
  404. package/src/lib/events.ts +363 -0
  405. package/src/lib/pino.ts +24 -0
  406. package/src/lib/theme-handlers.ts +168 -0
  407. package/src/lib/theme.ts +351 -0
  408. package/src/lib/tokenData.ts +378 -0
  409. package/src/lib/totp-import.ts +57 -0
  410. package/src/lib/vault-crypto.ts +129 -0
  411. package/src/lib/view-registry.ts +57 -0
  412. package/src/lib/websocket-server.ts +302 -0
  413. package/src/lib/websocket-setup.ts +79 -0
  414. package/src/lib/wordlist.ts +2050 -0
  415. package/src/lib/workspace-handlers.ts +285 -0
  416. package/start.sh +170 -0
  417. package/tailwind.config.ts +99 -0
  418. package/tsconfig.json +42 -0
@@ -0,0 +1,1782 @@
1
+ import { randomBytes } from 'crypto';
2
+ import { NextFunction, Request, Response, Router } from 'express';
3
+ import {
4
+ archiveCredential,
5
+ createCredential,
6
+ deleteArchivedCredential,
7
+ deleteCredential,
8
+ duplicateCredential,
9
+ findCredentialLocation,
10
+ getCredential,
11
+ isValidCredentialId,
12
+ listCredentials,
13
+ purgeDeletedCredentials,
14
+ readCredentialSecrets,
15
+ restoreArchivedCredential,
16
+ restoreDeletedCredential,
17
+ updateCredential,
18
+ type CredentialLocation,
19
+ } from '../lib/credentials';
20
+ import { getErrorMessage } from '../lib/error';
21
+ import { buildPermissionDenied, hasAnyPermission, isAdmin } from '../lib/permissions';
22
+ import { recordCredentialRead } from '../lib/sessions';
23
+ import { CredentialField, CredentialFile, CredentialType } from '../types';
24
+ import { requireWalletAuth } from '../middleware/auth';
25
+ import { matchesScope, normalizeScope, resolveExcludeFields } from '../lib/credential-scope';
26
+ import { resolveDontAskAgainDefault } from '../lib/dont-ask-again-policy';
27
+ import { encryptToAgentPubkey } from '../lib/credential-transport';
28
+ import { logEvent } from '../lib/logger';
29
+ import { generateTOTP, findTotpField } from '../lib/totp';
30
+ import { readOAuth2SecretsWithRefresh, OAUTH2_DEFAULT_EXCLUDE_FIELDS } from '../lib/oauth2-refresh';
31
+ import { getLinkedVaultGroup, getPrimaryVaultId } from '../lib/cold';
32
+ import { createHotWallet, deleteHotWallet, exportHotWallet } from '../lib/hot';
33
+ import { evaluateCredentialAccess } from '../lib/credential-access-policy';
34
+ import { writeCredentialAccessAudit } from '../lib/credential-access-audit';
35
+ import { prisma } from '../lib/db';
36
+ import { getDefault } from '../lib/defaults';
37
+ import { computeGpgFingerprint, computeSshFingerprint } from '../lib/key-fingerprint';
38
+ import { events } from '../lib/events';
39
+ import { createHumanActionNotification } from '../lib/notifications';
40
+ import { hashSecret } from '../lib/crypto';
41
+ import { buildApproveUrl } from '../lib/approval-link';
42
+ import {
43
+ buildCredentialHealthRows,
44
+ summarizeCredentialHealthFlags,
45
+ } from '../lib/credential-health';
46
+ import {
47
+ NOTE_CONTENT_KEY,
48
+ getCredentialFieldValue,
49
+ normalizeCredentialFieldsForType,
50
+ } from '../../shared/credential-field-schema';
51
+
52
+ const router = Router();
53
+
54
+ const VALID_CREDENTIAL_TYPES = new Set<CredentialType>([
55
+ 'login',
56
+ 'card',
57
+ 'note',
58
+ 'plain_note',
59
+ 'hot_wallet',
60
+ 'api',
61
+ 'apikey',
62
+ 'custom',
63
+ 'passkey',
64
+ 'oauth2',
65
+ 'ssh',
66
+ 'gpg',
67
+ ]);
68
+
69
+ const VALID_FIELD_TYPES = new Set(['text', 'secret', 'url', 'email', 'number']);
70
+ const VALID_CREDENTIAL_LOCATIONS = new Set<CredentialLocation>(['active', 'archive', 'recently_deleted']);
71
+
72
+ const OAUTH2_REAUTH_STATE_TTL_MS = 10 * 60 * 1000;
73
+ const oauth2ReauthState = new Map<string, { credentialId: string; redirectUri: string; expiresAt: number }>();
74
+
75
+ router.use(requireWalletAuth);
76
+
77
+ router.param('id', (req: Request, res: Response, next: NextFunction, id: string) => {
78
+ if (!isValidCredentialId(id)) {
79
+ res.status(400).json({ success: false, error: 'Invalid credential id format' });
80
+ return;
81
+ }
82
+ next();
83
+ });
84
+
85
+ function parseCredentialLocation(value: unknown, fallback: CredentialLocation = 'active'): CredentialLocation | null {
86
+ if (value === undefined || value === null || value === '') return fallback;
87
+ if (typeof value !== 'string') return null;
88
+ if (!VALID_CREDENTIAL_LOCATIONS.has(value as CredentialLocation)) return null;
89
+ return value as CredentialLocation;
90
+ }
91
+
92
+ function toMetadata(credential: CredentialFile): Omit<CredentialFile, 'encrypted'> & { has_totp?: boolean } {
93
+ const { encrypted: _encrypted, ...metadata } = credential;
94
+ if (credential.meta?.has_totp === true) {
95
+ return { ...metadata, has_totp: true };
96
+ }
97
+ return metadata;
98
+ }
99
+
100
+ function normalizeName(name: string): string {
101
+ return name.trim().normalize('NFKC');
102
+ }
103
+
104
+ function findSensitiveFieldValue(fields: CredentialField[], key: string): string {
105
+ const field = fields.find((entry) => entry.key === key);
106
+ return typeof field?.value === 'string' ? field.value : '';
107
+ }
108
+
109
+ function upsertSensitiveField(fields: CredentialField[], key: string, value: string): CredentialField[] {
110
+ const next = [...fields];
111
+ const index = next.findIndex((entry) => entry.key === key);
112
+ const normalized: CredentialField = { key, value, type: 'secret', sensitive: true };
113
+ if (index >= 0) next[index] = normalized;
114
+ else next.push(normalized);
115
+ return next;
116
+ }
117
+
118
+ function normalizeMeta(meta: Record<string, unknown>): Record<string, unknown> {
119
+ const normalized: Record<string, unknown> = { ...meta };
120
+
121
+ if (normalized.tags !== undefined) {
122
+ if (!Array.isArray(normalized.tags)) {
123
+ throw new Error('meta.tags must be an array');
124
+ }
125
+ normalized.tags = normalized.tags
126
+ .filter(tag => typeof tag === 'string')
127
+ .map(tag => normalizeScope(tag))
128
+ .filter(tag => tag.length > 0);
129
+ }
130
+
131
+ if (normalized.hosts !== undefined) {
132
+ if (!Array.isArray(normalized.hosts)) {
133
+ throw new Error('meta.hosts must be an array');
134
+ }
135
+ normalized.hosts = normalized.hosts
136
+ .filter(host => typeof host === 'string')
137
+ .map(host => host.trim())
138
+ .filter(host => host.length > 0);
139
+ }
140
+
141
+ if (normalized.walletLink !== undefined) {
142
+ if (!normalized.walletLink || typeof normalized.walletLink !== 'object' || Array.isArray(normalized.walletLink)) {
143
+ throw new Error('meta.walletLink must be an object');
144
+ }
145
+
146
+ const rawWalletLink = normalized.walletLink as Record<string, unknown>;
147
+ const walletAddress = typeof rawWalletLink.walletAddress === 'string' ? rawWalletLink.walletAddress.trim() : '';
148
+ const chain = typeof rawWalletLink.chain === 'string' ? rawWalletLink.chain.trim() : '';
149
+ const tier = rawWalletLink.tier;
150
+ const source = rawWalletLink.source;
151
+ const label = typeof rawWalletLink.label === 'string' ? rawWalletLink.label.trim() : undefined;
152
+ const version = typeof rawWalletLink.version === 'number' ? rawWalletLink.version : 1;
153
+
154
+ if (!walletAddress) throw new Error('meta.walletLink.walletAddress is required');
155
+ if (!chain) throw new Error('meta.walletLink.chain is required');
156
+ if (tier !== 'cold' && tier !== 'hot') throw new Error('meta.walletLink.tier must be "cold" or "hot"');
157
+ if (source !== 'existing' && source !== 'created') throw new Error('meta.walletLink.source must be "existing" or "created"');
158
+ if (version !== 1) throw new Error('meta.walletLink.version must be 1');
159
+
160
+ normalized.walletLink = {
161
+ version: 1,
162
+ walletAddress,
163
+ chain,
164
+ tier,
165
+ source,
166
+ ...(label ? { label } : {}),
167
+ linkedAt: new Date().toISOString(),
168
+ };
169
+ }
170
+
171
+ return normalized;
172
+ }
173
+
174
+ function isPrimaryVault(vaultId: string): boolean {
175
+ const primaryVaultId = getPrimaryVaultId();
176
+ if (primaryVaultId && vaultId === primaryVaultId) return true;
177
+ return vaultId === 'primary';
178
+ }
179
+
180
+
181
+ function inferSshKeyType(keyText: string | undefined): string {
182
+ if (!keyText) return 'other';
183
+ const key = keyText.toLowerCase();
184
+ if (key.includes('ed25519')) return 'ed25519';
185
+ if (key.includes('rsa')) return 'rsa';
186
+ if (key.includes('ecdsa')) return 'ecdsa';
187
+ return 'other';
188
+ }
189
+
190
+ function enforceKeyCredentialMetadata(
191
+ type: CredentialType,
192
+ meta: Record<string, unknown>,
193
+ sensitiveFields: CredentialField[],
194
+ ): Record<string, unknown> {
195
+ if (type !== 'ssh' && type !== 'gpg') return meta;
196
+
197
+ const nextMeta: Record<string, unknown> = { ...meta };
198
+ delete nextMeta.fingerprint;
199
+ const fieldMap = new Map(sensitiveFields.map(field => [field.key, field.value]));
200
+ const privateKey = fieldMap.get('private_key')?.trim() || '';
201
+ const publicKey = typeof nextMeta.public_key === 'string' ? nextMeta.public_key : '';
202
+
203
+ if (!privateKey) {
204
+ throw new Error(`${type} requires sensitive field private_key`);
205
+ }
206
+
207
+ if (type === 'ssh') {
208
+ const computed = computeSshFingerprint(publicKey || privateKey);
209
+ if (computed) nextMeta.fingerprint = computed;
210
+ if (!nextMeta.key_type || typeof nextMeta.key_type !== 'string') {
211
+ nextMeta.key_type = inferSshKeyType(publicKey || privateKey);
212
+ }
213
+ }
214
+
215
+ if (type === 'gpg') {
216
+ const computed = computeGpgFingerprint(publicKey || privateKey);
217
+ if (computed) nextMeta.fingerprint = computed;
218
+ }
219
+
220
+ return nextMeta;
221
+ }
222
+
223
+ const OAUTH2_REQUIRED_SECRET_FIELDS = ['access_token', 'refresh_token', 'client_id', 'client_secret'];
224
+ const OAUTH2_REAUTH_RESET_FIELDS = new Set(OAUTH2_REQUIRED_SECRET_FIELDS);
225
+
226
+ function shouldClearOAuth2ReauthMarker(credentialType: string, sensitiveFields: CredentialField[] | undefined) {
227
+ if (credentialType !== 'oauth2' || !sensitiveFields || sensitiveFields.length === 0) {
228
+ return false;
229
+ }
230
+
231
+ return sensitiveFields.some(field => OAUTH2_REAUTH_RESET_FIELDS.has(field.key));
232
+ }
233
+
234
+ function validateOAuth2Meta(meta: Record<string, unknown>) {
235
+ const tokenEndpoint = meta.token_endpoint;
236
+ if (typeof tokenEndpoint !== 'string' || tokenEndpoint.trim().length === 0) {
237
+ throw new Error('oauth2 requires meta.token_endpoint');
238
+ }
239
+
240
+ const expiresAt = meta.expires_at;
241
+ if (typeof expiresAt !== 'number' || !Number.isFinite(expiresAt)) {
242
+ throw new Error('oauth2 requires numeric meta.expires_at (unix timestamp seconds)');
243
+ }
244
+ }
245
+
246
+ function validateOAuth2RequiredFields(fields: CredentialField[]) {
247
+ const fieldMap = new Map(fields.map(field => [field.key, field.value]));
248
+ for (const key of OAUTH2_REQUIRED_SECRET_FIELDS) {
249
+ const value = fieldMap.get(key);
250
+ if (typeof value !== 'string' || value.trim().length === 0) {
251
+ throw new Error(`oauth2 requires sensitive field ${key}`);
252
+ }
253
+ }
254
+ }
255
+
256
+ function mergeOAuth2Fields(base: CredentialField[], updates: CredentialField[] = []): CredentialField[] {
257
+ if (updates.length === 0) return base;
258
+
259
+ const merged = new Map<string, CredentialField>();
260
+ for (const field of base) merged.set(field.key, field);
261
+ for (const field of updates) merged.set(field.key, field);
262
+ return [...merged.values()];
263
+ }
264
+
265
+ function parseFields(value: unknown): CredentialField[] {
266
+ if (!Array.isArray(value)) return [];
267
+
268
+ const fields: CredentialField[] = [];
269
+ for (const rawField of value) {
270
+ if (!rawField || typeof rawField !== 'object') continue;
271
+ const raw = rawField as Record<string, unknown>;
272
+ if (typeof raw.key !== 'string' || typeof raw.value !== 'string') continue;
273
+
274
+ const key = raw.key.trim();
275
+ if (!key) continue;
276
+
277
+ const type = typeof raw.type === 'string' && VALID_FIELD_TYPES.has(raw.type)
278
+ ? raw.type as CredentialField['type']
279
+ : 'text';
280
+
281
+ fields.push({
282
+ key,
283
+ value: raw.value,
284
+ type,
285
+ sensitive: !!raw.sensitive,
286
+ });
287
+ }
288
+
289
+ return fields;
290
+ }
291
+
292
+ function mergeNonSensitiveFieldsIntoMeta(
293
+ meta: Record<string, unknown>,
294
+ fields: CredentialField[],
295
+ ): Record<string, unknown> {
296
+ const merged = { ...meta };
297
+ for (const field of fields) {
298
+ if (!field.sensitive && merged[field.key] === undefined) {
299
+ merged[field.key] = field.value;
300
+ }
301
+ }
302
+ return merged;
303
+ }
304
+
305
+ function normalizePlainNoteFields(fields: CredentialField[]): CredentialField[] {
306
+ return fields.map((field) => ({ ...field, sensitive: false, type: 'text' }));
307
+ }
308
+
309
+ function resolvePlainNoteContent(
310
+ meta: Record<string, unknown>,
311
+ fallbackFields: CredentialField[] = [],
312
+ ): string {
313
+ const contentFromMeta = typeof meta[NOTE_CONTENT_KEY] === 'string' ? meta[NOTE_CONTENT_KEY] : '';
314
+ if (contentFromMeta.trim()) return contentFromMeta;
315
+
316
+ // Legacy plain-note support: older payloads may still send/store `value`.
317
+ const legacyMetaValue = typeof meta.value === 'string' ? meta.value : '';
318
+ if (legacyMetaValue.trim()) return legacyMetaValue;
319
+
320
+ const normalizedFieldValue = getCredentialFieldValue('plain_note', fallbackFields, NOTE_CONTENT_KEY) || '';
321
+ return normalizedFieldValue.trim() ? normalizedFieldValue : '';
322
+ }
323
+
324
+ function normalizePlainNoteMeta(
325
+ meta: Record<string, unknown>,
326
+ fallbackFields: CredentialField[] = [],
327
+ ): Record<string, unknown> {
328
+ const normalizedMeta = { ...meta };
329
+ const content = resolvePlainNoteContent(normalizedMeta, fallbackFields);
330
+ normalizedMeta[NOTE_CONTENT_KEY] = content;
331
+ delete normalizedMeta.value;
332
+ delete normalizedMeta.key;
333
+ return normalizedMeta;
334
+ }
335
+
336
+ function plainNoteFieldsFromMeta(
337
+ meta: Record<string, unknown>,
338
+ fallbackFields: CredentialField[] = [],
339
+ ): CredentialField[] {
340
+ const content = resolvePlainNoteContent(meta, fallbackFields);
341
+ return [
342
+ { key: NOTE_CONTENT_KEY, value: content, type: 'text', sensitive: false },
343
+ ];
344
+ }
345
+
346
+ function canReadCredential(req: Request, credential: CredentialFile): boolean {
347
+ const auth = req.auth!;
348
+ if (isAdmin(auth)) return true;
349
+ if (!hasAnyPermission(auth.token.permissions, ['secret:read'])) return false;
350
+ const scopes = auth.token.credentialAccess?.read || [];
351
+ return matchesScope(credential, scopes);
352
+ }
353
+
354
+ function canWriteCredential(req: Request, credential: CredentialFile): boolean {
355
+ const auth = req.auth!;
356
+ if (isAdmin(auth)) return true;
357
+ if (!hasAnyPermission(auth.token.permissions, ['secret:write'])) return false;
358
+ const scopes = auth.token.credentialAccess?.write || [];
359
+ return matchesScope(credential, scopes);
360
+ }
361
+
362
+ function credentialActor(req: Request): {
363
+ actorType: 'admin' | 'agent';
364
+ agentId?: string;
365
+ tokenHash?: string;
366
+ } {
367
+ const auth = req.auth!;
368
+ return {
369
+ actorType: isAdmin(auth) ? 'admin' : 'agent',
370
+ agentId: auth.token.agentId,
371
+ tokenHash: auth.tokenHash,
372
+ };
373
+ }
374
+
375
+ function emitCredentialChanged(
376
+ req: Request,
377
+ credential: CredentialFile,
378
+ change:
379
+ | 'created'
380
+ | 'updated'
381
+ | 'archived'
382
+ | 'moved_to_recently_deleted'
383
+ | 'restored_to_active'
384
+ | 'restored_to_archive'
385
+ | 'purged'
386
+ | 'duplicated',
387
+ location?: {
388
+ fromLocation?: CredentialLocation;
389
+ toLocation?: CredentialLocation;
390
+ },
391
+ ): void {
392
+ const actor = credentialActor(req);
393
+ events.credentialChanged({
394
+ credentialId: credential.id,
395
+ vaultId: credential.vaultId,
396
+ change,
397
+ actorType: actor.actorType,
398
+ agentId: actor.agentId,
399
+ tokenHash: actor.tokenHash,
400
+ fromLocation: location?.fromLocation,
401
+ toLocation: location?.toLocation,
402
+ });
403
+ }
404
+
405
+ function emitCredentialAccessed(
406
+ req: Request,
407
+ credential: CredentialFile,
408
+ input: {
409
+ action: 'credentials.read' | 'credentials.totp';
410
+ allowed: boolean;
411
+ reasonCode: string;
412
+ httpStatus: number;
413
+ },
414
+ ): void {
415
+ const actor = credentialActor(req);
416
+ events.credentialAccessed({
417
+ credentialId: credential.id,
418
+ vaultId: credential.vaultId,
419
+ action: input.action,
420
+ allowed: input.allowed,
421
+ reasonCode: input.reasonCode,
422
+ httpStatus: input.httpStatus,
423
+ actorType: actor.actorType,
424
+ agentId: actor.agentId,
425
+ tokenHash: actor.tokenHash,
426
+ });
427
+ }
428
+
429
+ function credentialAccessErrorMessage(reasonCode: string, action: 'credentials.read' | 'credentials.totp'): string {
430
+ if (reasonCode === 'TOKEN_TTL_EXPIRED') return 'Credential access TTL expired';
431
+ if (reasonCode === 'TOKEN_MAX_READS_EXCEEDED') return 'Credential read limit reached';
432
+ if (reasonCode === 'CREDENTIAL_RATE_LIMIT_EXCEEDED') {
433
+ return action === 'credentials.totp'
434
+ ? 'TOTP rate limit exceeded (max 10/min)'
435
+ : 'Credential rate limit exceeded';
436
+ }
437
+ if (reasonCode === 'DENY_EXCLUDED_FIELD') return 'Excluded credential fields require human approval';
438
+ if (reasonCode === 'CREDENTIAL_SCOPE_DENIED') return 'Credential read scope denied';
439
+ if (reasonCode === 'TOKEN_PERMISSION_DENIED') return 'totp:read permission required';
440
+ if (reasonCode === 'TOKEN_AGENT_PUBKEY_MISSING') return 'agentPubkey is required on token for credential reads';
441
+ if (reasonCode === 'CREDENTIAL_TOTP_NOT_CONFIGURED') return 'Credential has no TOTP secret';
442
+ return reasonCode;
443
+ }
444
+
445
+ async function writeDeniedCredentialAccess(params: {
446
+ req: Request;
447
+ credential: CredentialFile;
448
+ action: 'credentials.read' | 'credentials.totp';
449
+ 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' | 'DENY_EXCLUDED_FIELD';
450
+ httpStatus: number;
451
+ metadata?: Record<string, unknown>;
452
+ }): Promise<void> {
453
+ const auth = params.req.auth!;
454
+ await writeCredentialAccessAudit({
455
+ credentialId: params.credential.id,
456
+ vaultId: params.credential.vaultId,
457
+ action: params.action,
458
+ allowed: false,
459
+ reasonCode: params.reasonCode,
460
+ httpStatus: params.httpStatus,
461
+ tokenHash: auth.tokenHash,
462
+ agentId: auth.token.agentId,
463
+ requestId: params.req.header('x-request-id') ?? undefined,
464
+ actorType: isAdmin(auth) ? 'admin' : 'agent',
465
+ metadata: params.metadata,
466
+ });
467
+ emitCredentialAccessed(params.req, params.credential, {
468
+ action: params.action,
469
+ allowed: false,
470
+ reasonCode: params.reasonCode,
471
+ httpStatus: params.httpStatus,
472
+ });
473
+ }
474
+
475
+ type HealthScanJobStatus = 'queued' | 'running' | 'complete' | 'failed' | 'expired';
476
+
477
+ const healthScanJobs = new Map<string, {
478
+ status: HealthScanJobStatus;
479
+ createdAt: number;
480
+ updatedAt: number;
481
+ error?: string;
482
+ }>();
483
+
484
+ function newScanId(): string {
485
+ return `scan-${Math.random().toString(36).slice(2, 10)}`;
486
+ }
487
+
488
+ function listHealthCredentials(req: Request): CredentialFile[] {
489
+ const auth = req.auth!;
490
+ const reuseScopeMode = req.query.reuseScope === 'vault' ? 'vault' : 'group';
491
+ const selectedVault = typeof req.query.vault === 'string' ? req.query.vault : undefined;
492
+
493
+ const candidates = listCredentials();
494
+ const readable = isAdmin(auth)
495
+ ? candidates
496
+ : candidates.filter(credential => matchesScope(credential, auth.token.credentialAccess?.read || []));
497
+
498
+ if (!selectedVault) return readable;
499
+ if (reuseScopeMode === 'vault') return readable.filter(c => c.vaultId === selectedVault);
500
+
501
+ const group = new Set(getLinkedVaultGroup(selectedVault));
502
+ return readable.filter(c => group.has(c.vaultId));
503
+ }
504
+
505
+ function pruneExpiredHealthJobs(): void {
506
+ const now = Date.now();
507
+ for (const [id, job] of healthScanJobs.entries()) {
508
+ const terminal = job.status === 'complete' || job.status === 'failed';
509
+ if (!terminal) continue;
510
+ if (now - job.updatedAt > 30 * 60 * 1000) {
511
+ healthScanJobs.set(id, { ...job, status: 'expired', updatedAt: now });
512
+ }
513
+ }
514
+ }
515
+
516
+ // POST /credentials — create
517
+ router.post('/', async (req: Request, res: Response) => {
518
+ try {
519
+ const auth = req.auth!;
520
+ if (!isAdmin(auth) && !hasAnyPermission(auth.token.permissions, ['secret:write'])) {
521
+ res.status(403).json({ success: false, ...buildPermissionDenied('secret:write permission required', ['secret:write'], auth.token.permissions) });
522
+ return;
523
+ }
524
+
525
+ const vaultId = typeof req.body?.vaultId === 'string' ? req.body.vaultId : '';
526
+ const type = typeof req.body?.type === 'string' ? req.body.type : '';
527
+ const rawName = typeof req.body?.name === 'string' ? req.body.name : '';
528
+
529
+ if (!vaultId || !type || !rawName) {
530
+ res.status(400).json({ success: false, error: 'vaultId, type, and name are required' });
531
+ return;
532
+ }
533
+ if (!VALID_CREDENTIAL_TYPES.has(type as CredentialType)) {
534
+ res.status(400).json({ success: false, error: `Invalid credential type: ${type}` });
535
+ return;
536
+ }
537
+
538
+ const name = normalizeName(rawName);
539
+ const rawMeta = req.body?.meta && typeof req.body.meta === 'object' && !Array.isArray(req.body.meta)
540
+ ? req.body.meta as Record<string, unknown>
541
+ : {};
542
+ const normalizedMeta = normalizeMeta(rawMeta);
543
+ const rawFieldsInitial = normalizeCredentialFieldsForType(type, parseFields(req.body?.fields));
544
+ const rawFields = type === 'plain_note' ? normalizePlainNoteFields(rawFieldsInitial) : rawFieldsInitial;
545
+ const sensitiveFieldsInitial = normalizeCredentialFieldsForType(type, parseFields(req.body?.sensitiveFields));
546
+ const sensitiveFields = type === 'plain_note' ? [] : sensitiveFieldsInitial;
547
+ let fieldsToEncrypt = sensitiveFields.length > 0
548
+ ? sensitiveFields
549
+ : rawFields.filter(field => field.sensitive);
550
+ let finalMeta = mergeNonSensitiveFieldsIntoMeta(normalizedMeta, rawFields);
551
+ let provisionedHotWalletAddress: string | null = null;
552
+
553
+ if (type === 'plain_note') {
554
+ const normalizedPlainNoteMeta = normalizePlainNoteMeta(finalMeta, rawFields);
555
+ if (!resolvePlainNoteContent(normalizedPlainNoteMeta, rawFields)) {
556
+ res.status(400).json({ success: false, error: 'plain_note requires non-empty content field' });
557
+ return;
558
+ }
559
+ finalMeta = normalizedPlainNoteMeta;
560
+ }
561
+
562
+ if (!isAdmin(auth) && type === 'hot_wallet') {
563
+ const candidate: CredentialFile = {
564
+ id: 'pending',
565
+ vaultId,
566
+ type: type as CredentialType,
567
+ name,
568
+ meta: finalMeta,
569
+ encrypted: { ciphertext: '', iv: '', salt: '', mac: '' },
570
+ createdAt: new Date().toISOString(),
571
+ updatedAt: new Date().toISOString(),
572
+ };
573
+ const scopes = auth.token.credentialAccess?.write || [];
574
+ if (!matchesScope(candidate, scopes)) {
575
+ res.status(403).json({ success: false, ...buildPermissionDenied('Credential write scope denied', ['secret:write'], auth.token.permissions) });
576
+ return;
577
+ }
578
+ }
579
+
580
+ if (type === 'hot_wallet') {
581
+ if (!isAdmin(auth) && !hasAnyPermission(auth.token.permissions, ['wallet:create:hot'])) {
582
+ res.status(403).json({ success: false, ...buildPermissionDenied('wallet:create:hot permission required', ['wallet:create:hot'], auth.token.permissions) });
583
+ return;
584
+ }
585
+
586
+ const chainFromMeta = typeof finalMeta.chain === 'string' ? finalMeta.chain.trim().toLowerCase() : '';
587
+ const chain = chainFromMeta || 'base';
588
+ try {
589
+ const hotWallet = await createHotWallet({
590
+ tokenHash: auth.tokenHash,
591
+ chain,
592
+ name,
593
+ coldWalletId: vaultId === 'primary' ? undefined : vaultId,
594
+ });
595
+ provisionedHotWalletAddress = hotWallet.address;
596
+ const exported = await exportHotWallet(hotWallet.address);
597
+
598
+ fieldsToEncrypt = [{
599
+ key: 'private_key',
600
+ value: exported.privateKey,
601
+ type: 'secret',
602
+ sensitive: true,
603
+ }];
604
+
605
+ finalMeta = normalizeMeta({
606
+ ...finalMeta,
607
+ address: hotWallet.address,
608
+ chain: hotWallet.chain || chain,
609
+ walletLink: {
610
+ version: 1,
611
+ walletAddress: hotWallet.address,
612
+ chain: hotWallet.chain || chain,
613
+ tier: 'hot',
614
+ source: 'created',
615
+ ...(hotWallet.name ? { label: hotWallet.name } : {}),
616
+ },
617
+ });
618
+ } catch (error) {
619
+ if (provisionedHotWalletAddress) {
620
+ await deleteHotWallet(provisionedHotWalletAddress).catch(() => {});
621
+ }
622
+ throw error;
623
+ }
624
+ }
625
+
626
+ if (type === 'oauth2') {
627
+ if (!isPrimaryVault(vaultId)) {
628
+ res.status(400).json({ success: false, error: 'oauth2 credentials must be stored in the primary vault' });
629
+ return;
630
+ }
631
+ validateOAuth2Meta(finalMeta);
632
+ validateOAuth2RequiredFields(fieldsToEncrypt.length > 0 ? fieldsToEncrypt : rawFields);
633
+ }
634
+
635
+ if (type === 'ssh' || type === 'gpg') {
636
+ finalMeta = enforceKeyCredentialMetadata(type as CredentialType, finalMeta, fieldsToEncrypt);
637
+ }
638
+
639
+ if (!isAdmin(auth) && type !== 'hot_wallet') {
640
+ const candidate: CredentialFile = {
641
+ id: 'pending',
642
+ vaultId,
643
+ type: type as CredentialType,
644
+ name,
645
+ meta: finalMeta,
646
+ encrypted: { ciphertext: '', iv: '', salt: '', mac: '' },
647
+ createdAt: new Date().toISOString(),
648
+ updatedAt: new Date().toISOString(),
649
+ };
650
+ const scopes = auth.token.credentialAccess?.write || [];
651
+ if (!matchesScope(candidate, scopes)) {
652
+ res.status(403).json({ success: false, ...buildPermissionDenied('Credential write scope denied', ['secret:write'], auth.token.permissions) });
653
+ return;
654
+ }
655
+ }
656
+
657
+ // Auto-set has_totp flag in meta if TOTP field present (check both 'totp' and 'otp' for compat)
658
+ if (findTotpField(fieldsToEncrypt)) {
659
+ finalMeta.has_totp = true;
660
+ }
661
+
662
+ let created: CredentialFile;
663
+ try {
664
+ created = createCredential(vaultId, type as CredentialType, name, finalMeta, fieldsToEncrypt);
665
+ } catch (error) {
666
+ if (provisionedHotWalletAddress) {
667
+ await deleteHotWallet(provisionedHotWalletAddress).catch(() => {});
668
+ }
669
+ throw error;
670
+ }
671
+
672
+ emitCredentialChanged(req, created, 'created', { toLocation: 'active' });
673
+ res.json({ success: true, credential: toMetadata(created) });
674
+ } catch (error) {
675
+ res.status(400).json({ success: false, error: getErrorMessage(error) });
676
+ }
677
+ });
678
+
679
+ // GET /credentials — list metadata
680
+ router.get('/', async (req: Request, res: Response) => {
681
+ try {
682
+ const auth = req.auth!;
683
+ if (!isAdmin(auth) && !hasAnyPermission(auth.token.permissions, ['secret:read'])) {
684
+ res.status(403).json({ success: false, ...buildPermissionDenied('secret:read permission required', ['secret:read'], auth.token.permissions) });
685
+ return;
686
+ }
687
+
688
+ const vaultId = typeof req.query.vault === 'string' ? req.query.vault : undefined;
689
+ const type = typeof req.query.type === 'string' ? req.query.type : undefined;
690
+ const tag = typeof req.query.tag === 'string' ? req.query.tag : undefined;
691
+ const query = typeof req.query.q === 'string' ? req.query.q : undefined;
692
+ const location = parseCredentialLocation(req.query.location, 'active');
693
+
694
+ if (type && !VALID_CREDENTIAL_TYPES.has(type as CredentialType)) {
695
+ res.status(400).json({ success: false, error: `Invalid credential type: ${type}` });
696
+ return;
697
+ }
698
+ if (!location) {
699
+ res.status(400).json({ success: false, error: 'Invalid location. Expected active, archive, or recently_deleted' });
700
+ return;
701
+ }
702
+
703
+ const credentials = listCredentials({
704
+ vaultId,
705
+ type: type as CredentialType | undefined,
706
+ tag,
707
+ query,
708
+ }, location);
709
+
710
+ const filtered = isAdmin(auth)
711
+ ? credentials
712
+ : credentials.filter(credential => matchesScope(credential, auth.token.credentialAccess?.read || []));
713
+
714
+ const includeHealth = req.query.health === '1' || req.query.health === 'true';
715
+
716
+ if (!includeHealth) {
717
+ res.json({
718
+ success: true,
719
+ credentials: filtered.map(toMetadata),
720
+ });
721
+ return;
722
+ }
723
+
724
+ const rows = await buildCredentialHealthRows(filtered, readCredentialSecrets);
725
+ const rowById = new Map(rows.map(row => [row.id, row.health]));
726
+
727
+ res.json({
728
+ success: true,
729
+ credentials: filtered.map(credential => ({
730
+ ...toMetadata(credential),
731
+ health: rowById.get(credential.id)
732
+ ? { status: rowById.get(credential.id)!.status }
733
+ : undefined,
734
+ })),
735
+ });
736
+ } catch (error) {
737
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
738
+ }
739
+ });
740
+
741
+ // GET /credentials/health/summary — aggregate credential health
742
+ router.get('/health/summary', async (req: Request, res: Response) => {
743
+ try {
744
+ const auth = req.auth!;
745
+ if (!isAdmin(auth) && !hasAnyPermission(auth.token.permissions, ['secret:read'])) {
746
+ res.status(403).json({ success: false, ...buildPermissionDenied('secret:read permission required', ['secret:read'], auth.token.permissions) });
747
+ return;
748
+ }
749
+
750
+ pruneExpiredHealthJobs();
751
+
752
+ const credentials = listHealthCredentials(req);
753
+ const rows = await buildCredentialHealthRows(credentials, readCredentialSecrets);
754
+ const summary = summarizeCredentialHealthFlags(rows.map((row) => row.health.flags));
755
+
756
+ res.json({
757
+ success: true,
758
+ summary: {
759
+ totalAnalyzed: summary.total,
760
+ safe: summary.safe,
761
+ weak: summary.weak,
762
+ reused: summary.reused,
763
+ breached: summary.breached,
764
+ unknown: summary.unknown,
765
+ lastScanAt: new Date().toISOString(),
766
+ },
767
+ });
768
+ } catch (error) {
769
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
770
+ }
771
+ });
772
+
773
+ // GET /credentials/health — per-credential health rows
774
+ router.get('/health', async (req: Request, res: Response) => {
775
+ try {
776
+ const auth = req.auth!;
777
+ if (!isAdmin(auth) && !hasAnyPermission(auth.token.permissions, ['secret:read'])) {
778
+ res.status(403).json({ success: false, ...buildPermissionDenied('secret:read permission required', ['secret:read'], auth.token.permissions) });
779
+ return;
780
+ }
781
+
782
+ pruneExpiredHealthJobs();
783
+
784
+ const credentials = listHealthCredentials(req);
785
+ const rows = await buildCredentialHealthRows(credentials, readCredentialSecrets);
786
+
787
+ res.json({ success: true, credentials: rows });
788
+ } catch (error) {
789
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
790
+ }
791
+ });
792
+
793
+ // POST /credentials/health/rescan — async job kickoff
794
+ router.post('/health/rescan', async (req: Request, res: Response) => {
795
+ try {
796
+ const auth = req.auth!;
797
+ if (!isAdmin(auth) && !hasAnyPermission(auth.token.permissions, ['secret:read'])) {
798
+ res.status(403).json({ success: false, ...buildPermissionDenied('secret:read permission required', ['secret:read'], auth.token.permissions) });
799
+ return;
800
+ }
801
+
802
+ const scanId = newScanId();
803
+ const now = Date.now();
804
+ healthScanJobs.set(scanId, { status: 'queued', createdAt: now, updatedAt: now });
805
+
806
+ void (async () => {
807
+ try {
808
+ healthScanJobs.set(scanId, { status: 'running', createdAt: now, updatedAt: Date.now() });
809
+ const credentials = listHealthCredentials(req);
810
+ await buildCredentialHealthRows(credentials, readCredentialSecrets);
811
+ healthScanJobs.set(scanId, { status: 'complete', createdAt: now, updatedAt: Date.now() });
812
+ } catch (error) {
813
+ healthScanJobs.set(scanId, {
814
+ status: 'failed',
815
+ createdAt: now,
816
+ updatedAt: Date.now(),
817
+ error: getErrorMessage(error),
818
+ });
819
+ }
820
+ })();
821
+
822
+ res.json({ accepted: true, scanId });
823
+ } catch (error) {
824
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
825
+ }
826
+ });
827
+
828
+ // GET /credentials/health/rescan/:scanId — read async scan status
829
+ router.get('/health/rescan/:scanId', (req: Request<{ scanId: string }>, res: Response) => {
830
+ try {
831
+ const auth = req.auth!;
832
+ if (!isAdmin(auth) && !hasAnyPermission(auth.token.permissions, ['secret:read'])) {
833
+ res.status(403).json({ success: false, ...buildPermissionDenied('secret:read permission required', ['secret:read'], auth.token.permissions) });
834
+ return;
835
+ }
836
+
837
+ pruneExpiredHealthJobs();
838
+
839
+ const scan = healthScanJobs.get(req.params.scanId);
840
+ if (!scan) {
841
+ res.status(404).json({ success: false, error: 'Scan job not found' });
842
+ return;
843
+ }
844
+
845
+ res.json({
846
+ success: true,
847
+ scanId: req.params.scanId,
848
+ scan: {
849
+ status: scan.status,
850
+ createdAt: new Date(scan.createdAt).toISOString(),
851
+ updatedAt: new Date(scan.updatedAt).toISOString(),
852
+ error: scan.error,
853
+ },
854
+ });
855
+ } catch (error) {
856
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
857
+ }
858
+ });
859
+
860
+ // GET /credentials/:id — metadata
861
+ router.get('/:id', (req: Request<{ id: string }>, res: Response) => {
862
+ try {
863
+ const credential = getCredential(req.params.id);
864
+ if (!credential) {
865
+ res.status(404).json({ success: false, error: 'Credential not found' });
866
+ return;
867
+ }
868
+
869
+ if (!canReadCredential(req, credential)) {
870
+ res.status(403).json({ success: false, ...buildPermissionDenied('Credential read scope denied', ['secret:read'], req.auth!.token.permissions) });
871
+ return;
872
+ }
873
+
874
+ res.json({ success: true, credential: toMetadata(credential) });
875
+ } catch (error) {
876
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
877
+ }
878
+ });
879
+
880
+ // PUT /credentials/:id — update
881
+ router.put('/:id', (req: Request<{ id: string }>, res: Response) => {
882
+ try {
883
+ const credential = getCredential(req.params.id);
884
+ if (!credential) {
885
+ res.status(404).json({ success: false, error: 'Credential not found' });
886
+ return;
887
+ }
888
+ if (!canWriteCredential(req, credential)) {
889
+ res.status(403).json({ success: false, ...buildPermissionDenied('Credential write scope denied', ['secret:write'], req.auth!.token.permissions) });
890
+ return;
891
+ }
892
+
893
+ let updatedMeta: Record<string, unknown> | undefined;
894
+ const hasMetaInput = req.body && Object.prototype.hasOwnProperty.call(req.body, 'meta');
895
+ if (hasMetaInput) {
896
+ if (!req.body.meta || typeof req.body.meta !== 'object' || Array.isArray(req.body.meta)) {
897
+ res.status(400).json({ success: false, error: 'meta must be an object' });
898
+ return;
899
+ }
900
+ updatedMeta = normalizeMeta(req.body.meta as Record<string, unknown>);
901
+ }
902
+
903
+ const parsedFields = normalizeCredentialFieldsForType(credential.type, parseFields(req.body?.fields));
904
+ const fields = credential.type === 'plain_note' ? normalizePlainNoteFields(parsedFields) : parsedFields;
905
+ if (fields.length > 0 || updatedMeta) {
906
+ const baseMeta = updatedMeta || { ...credential.meta };
907
+ updatedMeta = mergeNonSensitiveFieldsIntoMeta(baseMeta, fields);
908
+ }
909
+
910
+ if (credential.type === 'plain_note' && updatedMeta) {
911
+ const existingPlainNoteSecrets = readCredentialSecrets(credential.id);
912
+ const plainNoteFallbackFields = [...existingPlainNoteSecrets, ...fields];
913
+ updatedMeta = normalizePlainNoteMeta(updatedMeta, plainNoteFallbackFields);
914
+ if (!resolvePlainNoteContent(updatedMeta, plainNoteFallbackFields)) {
915
+ res.status(400).json({ success: false, error: 'plain_note requires non-empty content field' });
916
+ return;
917
+ }
918
+ }
919
+
920
+ let sensitiveFields: CredentialField[] | undefined;
921
+ const hasSensitiveInput = req.body && Object.prototype.hasOwnProperty.call(req.body, 'sensitiveFields');
922
+ if (credential.type === 'plain_note') {
923
+ sensitiveFields = [];
924
+ } else if (hasSensitiveInput) {
925
+ sensitiveFields = normalizeCredentialFieldsForType(credential.type, parseFields(req.body.sensitiveFields));
926
+ } else {
927
+ const sensitiveFromFields = fields.filter(field => field.sensitive);
928
+ if (sensitiveFromFields.length > 0) {
929
+ sensitiveFields = sensitiveFromFields;
930
+ }
931
+ }
932
+
933
+ // Auto-set has_totp flag in meta if TOTP field present in update
934
+ if (sensitiveFields && findTotpField(sensitiveFields)) {
935
+ if (!updatedMeta) updatedMeta = { ...credential.meta };
936
+ updatedMeta.has_totp = true;
937
+ }
938
+
939
+ const rawName = typeof req.body?.name === 'string' ? normalizeName(req.body.name) : undefined;
940
+
941
+ if (credential.type === 'oauth2') {
942
+ const baseMeta = credential.meta as Record<string, unknown>;
943
+ const updatedReauthMeta = shouldClearOAuth2ReauthMarker(credential.type, sensitiveFields)
944
+ ? {
945
+ ...baseMeta,
946
+ needs_reauth: false,
947
+ reauth_reason: null,
948
+ }
949
+ : baseMeta;
950
+
951
+ const finalMeta = { ...baseMeta, ...updatedReauthMeta, ...updatedMeta };
952
+ validateOAuth2Meta(finalMeta);
953
+ if (!isPrimaryVault(credential.vaultId)) {
954
+ res.status(400).json({ success: false, error: 'oauth2 credentials must be stored in the primary vault' });
955
+ return;
956
+ }
957
+
958
+ if (sensitiveFields !== undefined) {
959
+ const existingSensitiveFields = readCredentialSecrets(req.params.id);
960
+ const effectiveSensitiveFields = mergeOAuth2Fields(existingSensitiveFields, sensitiveFields);
961
+ validateOAuth2RequiredFields(effectiveSensitiveFields);
962
+ }
963
+
964
+ updatedMeta = finalMeta;
965
+ }
966
+
967
+
968
+ if ((credential.type === 'ssh' || credential.type === 'gpg') && updatedMeta) {
969
+ const existingSensitiveFields = readCredentialSecrets(credential.id);
970
+ const effectiveSensitiveFields = sensitiveFields !== undefined
971
+ ? mergeOAuth2Fields(existingSensitiveFields, sensitiveFields)
972
+ : existingSensitiveFields;
973
+ updatedMeta = enforceKeyCredentialMetadata(credential.type, updatedMeta, effectiveSensitiveFields);
974
+ }
975
+
976
+ const updated = updateCredential(req.params.id, {
977
+ name: rawName,
978
+ meta: updatedMeta,
979
+ sensitiveFields,
980
+ });
981
+
982
+ emitCredentialChanged(req, updated, 'updated', { toLocation: 'active' });
983
+ res.json({ success: true, credential: toMetadata(updated) });
984
+ } catch (error) {
985
+ res.status(400).json({ success: false, error: getErrorMessage(error) });
986
+ }
987
+ });
988
+
989
+ // DELETE /credentials/:id — lifecycle delete:
990
+ // active -> archive, archive -> recently_deleted, recently_deleted -> permanent delete
991
+ router.delete('/:id', (req: Request<{ id: string }>, res: Response) => {
992
+ try {
993
+ const location = parseCredentialLocation(req.query.location, 'active');
994
+ if (!location) {
995
+ res.status(400).json({ success: false, error: 'Invalid location. Expected active, archive, or recently_deleted' });
996
+ return;
997
+ }
998
+
999
+ const credential = getCredential(req.params.id, location);
1000
+ if (!credential) {
1001
+ res.status(404).json({ success: false, error: 'Credential not found' });
1002
+ return;
1003
+ }
1004
+ if (!canWriteCredential(req, credential)) {
1005
+ res.status(403).json({ success: false, ...buildPermissionDenied('Credential write scope denied', ['secret:write'], req.auth!.token.permissions) });
1006
+ return;
1007
+ }
1008
+
1009
+ if (location === 'active') {
1010
+ const archived = archiveCredential(req.params.id);
1011
+ if (archived) {
1012
+ emitCredentialChanged(req, archived, 'archived', {
1013
+ fromLocation: 'active',
1014
+ toLocation: 'archive',
1015
+ });
1016
+ }
1017
+ res.json({ success: true, action: 'archived', credential: archived ? toMetadata(archived) : null });
1018
+ return;
1019
+ }
1020
+
1021
+ if (location === 'archive') {
1022
+ const deleted = deleteArchivedCredential(req.params.id);
1023
+ if (deleted) {
1024
+ emitCredentialChanged(req, deleted, 'moved_to_recently_deleted', {
1025
+ fromLocation: 'archive',
1026
+ toLocation: 'recently_deleted',
1027
+ });
1028
+ }
1029
+ res.json({ success: true, action: 'moved_to_recently_deleted', credential: deleted ? toMetadata(deleted) : null });
1030
+ return;
1031
+ }
1032
+
1033
+ const deleted = deleteCredential(req.params.id, 'recently_deleted');
1034
+ if (deleted) {
1035
+ emitCredentialChanged(req, credential, 'purged', {
1036
+ fromLocation: 'recently_deleted',
1037
+ });
1038
+ }
1039
+ res.json({ success: true, action: 'purged', deleted });
1040
+ } catch (error) {
1041
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
1042
+ }
1043
+ });
1044
+
1045
+ // POST /credentials/:id/restore — restore from archive/recently_deleted
1046
+ router.post('/:id/restore', (req: Request<{ id: string }>, res: Response) => {
1047
+ try {
1048
+ const from = parseCredentialLocation(req.body?.from ?? req.query.from, 'archive');
1049
+ if (!from || from === 'active') {
1050
+ res.status(400).json({ success: false, error: 'Invalid from location. Expected archive or recently_deleted' });
1051
+ return;
1052
+ }
1053
+
1054
+ const credential = getCredential(req.params.id, from);
1055
+ if (!credential) {
1056
+ res.status(404).json({ success: false, error: 'Credential not found' });
1057
+ return;
1058
+ }
1059
+ if (!canWriteCredential(req, credential)) {
1060
+ res.status(403).json({ success: false, ...buildPermissionDenied('Credential write scope denied', ['secret:write'], req.auth!.token.permissions) });
1061
+ return;
1062
+ }
1063
+
1064
+ if (from === 'archive') {
1065
+ const restored = restoreArchivedCredential(req.params.id);
1066
+ if (restored) {
1067
+ emitCredentialChanged(req, restored, 'restored_to_active', {
1068
+ fromLocation: 'archive',
1069
+ toLocation: 'active',
1070
+ });
1071
+ }
1072
+ res.json({ success: true, action: 'restored_to_active', credential: restored ? toMetadata(restored) : null });
1073
+ return;
1074
+ }
1075
+
1076
+ const restored = restoreDeletedCredential(req.params.id);
1077
+ if (restored) {
1078
+ emitCredentialChanged(req, restored, 'restored_to_archive', {
1079
+ fromLocation: 'recently_deleted',
1080
+ toLocation: 'archive',
1081
+ });
1082
+ }
1083
+ res.json({ success: true, action: 'restored_to_archive', credential: restored ? toMetadata(restored) : null });
1084
+ } catch (error) {
1085
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
1086
+ }
1087
+ });
1088
+
1089
+ // POST /credentials/:id/duplicate — duplicate an active credential
1090
+ router.post('/:id/duplicate', (req: Request<{ id: string }>, res: Response) => {
1091
+ try {
1092
+ const auth = req.auth!;
1093
+ if (!isAdmin(auth) && !hasAnyPermission(auth.token.permissions, ['secret:write'])) {
1094
+ res.status(403).json({ success: false, ...buildPermissionDenied('secret:write permission required', ['secret:write'], auth.token.permissions) });
1095
+ return;
1096
+ }
1097
+
1098
+ const source = getCredential(req.params.id, 'active');
1099
+ if (!source) {
1100
+ res.status(404).json({ success: false, error: 'Credential not found in active location' });
1101
+ return;
1102
+ }
1103
+
1104
+ if (!canWriteCredential(req, source)) {
1105
+ res.status(403).json({ success: false, ...buildPermissionDenied('Credential write scope denied', ['secret:write'], auth.token.permissions) });
1106
+ return;
1107
+ }
1108
+
1109
+ const { name, vaultId } = req.body || {};
1110
+ const newCred = duplicateCredential(req.params.id, {
1111
+ name: typeof name === 'string' ? name : undefined,
1112
+ vaultId: typeof vaultId === 'string' ? vaultId : undefined,
1113
+ });
1114
+
1115
+ emitCredentialChanged(req, newCred, 'duplicated');
1116
+ res.json({ success: true, credential: toMetadata(newCred) });
1117
+ } catch (error) {
1118
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
1119
+ }
1120
+ });
1121
+
1122
+ // POST /credentials/purge — manual retention sweep for recently deleted credentials
1123
+ router.post('/purge', (req: Request, res: Response) => {
1124
+ try {
1125
+ const daysRaw = req.body?.retentionDays;
1126
+ const retentionDays = typeof daysRaw === 'number' && Number.isFinite(daysRaw) && daysRaw > 0
1127
+ ? Math.floor(daysRaw)
1128
+ : 30;
1129
+ const summary = purgeDeletedCredentials(retentionDays);
1130
+ res.json({ success: true, retentionDays, ...summary });
1131
+ } catch (error) {
1132
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
1133
+ }
1134
+ });
1135
+
1136
+ // POST /credentials/:id/read — decrypt, filter fields, re-encrypt to agent pubkey
1137
+ router.post('/:id/read', async (req: Request<{ id: string }>, res: Response) => {
1138
+ try {
1139
+ const auth = req.auth!;
1140
+ const requestedLocation = parseCredentialLocation(req.query.location, 'active');
1141
+ if (!requestedLocation) {
1142
+ res.status(400).json({ success: false, error: 'Invalid location. Expected active, archive, or recently_deleted' });
1143
+ return;
1144
+ }
1145
+
1146
+ let credentialLocation: CredentialLocation = requestedLocation;
1147
+ let credential = getCredential(req.params.id, credentialLocation);
1148
+ if (!credential && requestedLocation === 'active') {
1149
+ const detectedLocation = findCredentialLocation(req.params.id);
1150
+ if (detectedLocation) {
1151
+ credentialLocation = detectedLocation;
1152
+ credential = getCredential(req.params.id, credentialLocation);
1153
+ }
1154
+ }
1155
+
1156
+ if (!credential) {
1157
+ res.status(404).json({ success: false, error: 'Credential not found' });
1158
+ return;
1159
+ }
1160
+ if (!canReadCredential(req, credential)) {
1161
+ await writeDeniedCredentialAccess({
1162
+ req,
1163
+ credential,
1164
+ action: 'credentials.read',
1165
+ reasonCode: 'CREDENTIAL_SCOPE_DENIED',
1166
+ httpStatus: 403,
1167
+ });
1168
+ res.status(403).json({
1169
+ success: false,
1170
+ error: credentialAccessErrorMessage('CREDENTIAL_SCOPE_DENIED', 'credentials.read'),
1171
+ reasonCode: 'CREDENTIAL_SCOPE_DENIED',
1172
+ });
1173
+ return;
1174
+ }
1175
+
1176
+ const decision = evaluateCredentialAccess({
1177
+ tokenHash: auth.tokenHash,
1178
+ token: auth.token,
1179
+ credentialId: credential.id,
1180
+ action: 'credentials.read',
1181
+ });
1182
+ if (!decision.allowed) {
1183
+ await writeDeniedCredentialAccess({
1184
+ req,
1185
+ credential,
1186
+ action: 'credentials.read',
1187
+ reasonCode: decision.reasonCode,
1188
+ httpStatus: decision.httpStatus,
1189
+ metadata: {
1190
+ limiterWindowMs: decision.limiterWindowMs,
1191
+ limiterLimit: decision.limiterLimit,
1192
+ limiterCount: decision.limiterCount,
1193
+ },
1194
+ });
1195
+ res.status(decision.httpStatus).json({
1196
+ success: false,
1197
+ error: credentialAccessErrorMessage(decision.reasonCode, 'credentials.read'),
1198
+ reasonCode: decision.reasonCode,
1199
+ });
1200
+ return;
1201
+ }
1202
+
1203
+ if (!auth.token.agentPubkey) {
1204
+ await writeDeniedCredentialAccess({
1205
+ req,
1206
+ credential,
1207
+ action: 'credentials.read',
1208
+ reasonCode: 'TOKEN_AGENT_PUBKEY_MISSING',
1209
+ httpStatus: 400,
1210
+ });
1211
+ res.status(400).json({
1212
+ success: false,
1213
+ error: credentialAccessErrorMessage('TOKEN_AGENT_PUBKEY_MISSING', 'credentials.read'),
1214
+ reasonCode: 'TOKEN_AGENT_PUBKEY_MISSING',
1215
+ });
1216
+ return;
1217
+ }
1218
+
1219
+ // For oauth2 credentials, auto-refresh if expired.
1220
+ // For plain notes, we still read encrypted fields as a fallback for older data.
1221
+ const rawSecrets = credential.type === 'oauth2' && credentialLocation === 'active'
1222
+ ? await readOAuth2SecretsWithRefresh(credential.id)
1223
+ : readCredentialSecrets(credential.id, credentialLocation);
1224
+ const secrets = credential.type === 'plain_note'
1225
+ ? plainNoteFieldsFromMeta(credential.meta, rawSecrets)
1226
+ : normalizeCredentialFieldsForType(credential.type, rawSecrets);
1227
+
1228
+ // Admin tokens should always read full credential payloads in the dashboard.
1229
+ // Exclusion defaults are intended for delegated agent tokens.
1230
+ const baseExclude = isAdmin(auth)
1231
+ ? []
1232
+ : resolveExcludeFields(auth.token.credentialAccess?.excludeFields, credential.type);
1233
+ const baseExcludedNormalized = new Set(baseExclude.map(field => normalizeScope(field)));
1234
+ const requestedExcludedFields = isAdmin(auth)
1235
+ ? []
1236
+ : Array.from(new Set(
1237
+ secrets
1238
+ .map((field) => field.key)
1239
+ .filter((fieldKey) => baseExcludedNormalized.has(normalizeScope(fieldKey))),
1240
+ ));
1241
+ if (requestedExcludedFields.length > 0) {
1242
+ const approvalTtl = await getDefault<number>('ttl.action', 60);
1243
+ const approvalSummaryBase = `${auth.token.agentId || 'agent'} requests excluded fields (${requestedExcludedFields.join(', ')}) from credential "${credential.name}"`;
1244
+ const approvalSummary = approvalSummaryBase.length > 500
1245
+ ? `${approvalSummaryBase.slice(0, 497)}...`
1246
+ : approvalSummaryBase;
1247
+ const secret = randomBytes(32).toString('hex');
1248
+ const secretHash = hashSecret(secret);
1249
+ const requestedExcludedNormalized = new Set(requestedExcludedFields.map((field) => normalizeScope(field)));
1250
+ const escalationExcludeFields = Array.from(
1251
+ new Set(
1252
+ baseExclude
1253
+ .map((field) => normalizeScope(field))
1254
+ .filter((field) => !requestedExcludedNormalized.has(field)),
1255
+ ),
1256
+ );
1257
+ const dontAskAgainDecision = resolveDontAskAgainDefault(requestedExcludedFields);
1258
+ const request = await prisma.humanAction.create({
1259
+ data: {
1260
+ type: 'auth',
1261
+ fromTier: 'system',
1262
+ toAddress: null,
1263
+ amount: null,
1264
+ chain: 'base',
1265
+ status: 'pending',
1266
+ metadata: JSON.stringify({
1267
+ agentId: auth.token.agentId,
1268
+ limit: 0,
1269
+ limits: { fund: 0, send: 0, swap: 0 },
1270
+ permissions: ['secret:read'],
1271
+ ttl: approvalTtl,
1272
+ credentialAccess: {
1273
+ read: [credential.id],
1274
+ write: [],
1275
+ excludeFields: escalationExcludeFields,
1276
+ maxReads: 1,
1277
+ ttl: approvalTtl,
1278
+ },
1279
+ pubkey: auth.token.agentPubkey,
1280
+ secretHash,
1281
+ summary: approvalSummary,
1282
+ escalationReason: 'DENY_EXCLUDED_FIELD',
1283
+ credentialId: credential.id,
1284
+ credentialName: credential.name,
1285
+ vaultId: credential.vaultId,
1286
+ requestedFields: requestedExcludedFields,
1287
+ policyContext: {
1288
+ readScopes: auth.token.credentialAccess?.read || [],
1289
+ tokenExcludeFields: auth.token.credentialAccess?.excludeFields ?? null,
1290
+ effectiveExcludeFields: baseExclude,
1291
+ credentialType: credential.type,
1292
+ dontAskAgainDefaultOn: dontAskAgainDecision.defaultOn,
1293
+ dontAskAgainReason: dontAskAgainDecision.reason,
1294
+ },
1295
+ }),
1296
+ },
1297
+ });
1298
+ await createHumanActionNotification(request);
1299
+ events.actionCreated({
1300
+ id: request.id,
1301
+ type: 'agent_access',
1302
+ source: `agent:${auth.token.agentId || 'agent'}`,
1303
+ summary: approvalSummary,
1304
+ expiresAt: null,
1305
+ metadata: {
1306
+ agentId: auth.token.agentId,
1307
+ credentialId: credential.id,
1308
+ credentialName: credential.name,
1309
+ requestedFields: requestedExcludedFields,
1310
+ effectiveExcludeFields: baseExclude,
1311
+ reasonCode: 'DENY_EXCLUDED_FIELD',
1312
+ },
1313
+ });
1314
+ await writeDeniedCredentialAccess({
1315
+ req,
1316
+ credential,
1317
+ action: 'credentials.read',
1318
+ reasonCode: 'DENY_EXCLUDED_FIELD',
1319
+ httpStatus: 403,
1320
+ metadata: {
1321
+ humanActionId: request.id,
1322
+ requestedFields: requestedExcludedFields,
1323
+ effectiveExcludeFields: baseExclude,
1324
+ dontAskAgainDefaultOn: dontAskAgainDecision.defaultOn,
1325
+ dontAskAgainReason: dontAskAgainDecision.reason,
1326
+ },
1327
+ });
1328
+ logEvent({
1329
+ category: 'agent',
1330
+ action: 'credential_access_decision',
1331
+ description: `Credential access denied (excluded fields): ${credential.id}`,
1332
+ agentId: auth.token.agentId,
1333
+ metadata: {
1334
+ credentialId: credential.id,
1335
+ route: 'credentials.read',
1336
+ allowed: false,
1337
+ reasonCode: 'DENY_EXCLUDED_FIELD',
1338
+ requestedFields: requestedExcludedFields,
1339
+ humanActionId: request.id,
1340
+ },
1341
+ });
1342
+ const dashboardBase = `http://localhost:${process.env.DASHBOARD_PORT || '4747'}`;
1343
+ res.status(403).json({
1344
+ success: false,
1345
+ error: credentialAccessErrorMessage('DENY_EXCLUDED_FIELD', 'credentials.read'),
1346
+ reasonCode: 'DENY_EXCLUDED_FIELD',
1347
+ escalated: true,
1348
+ requiresHumanApproval: true,
1349
+ requestId: request.id,
1350
+ secret,
1351
+ approveUrl: buildApproveUrl(dashboardBase, request.id),
1352
+ message: 'Action escalated — waiting for human approval',
1353
+ credential: {
1354
+ id: credential.id,
1355
+ name: credential.name,
1356
+ vaultId: credential.vaultId,
1357
+ },
1358
+ requestedFields: requestedExcludedFields,
1359
+ effectiveExcludeFields: baseExclude,
1360
+ dontAskAgainDefaultOn: dontAskAgainDecision.defaultOn,
1361
+ dontAskAgainReason: dontAskAgainDecision.reason,
1362
+ });
1363
+ return;
1364
+ }
1365
+ // For oauth2, always exclude refresh machinery from agent reads
1366
+ const oauth2Exclude = credential.type === 'oauth2' ? OAUTH2_DEFAULT_EXCLUDE_FIELDS : [];
1367
+ const excluded = new Set(
1368
+ [...baseExclude, ...oauth2Exclude].map(field => normalizeScope(field)),
1369
+ );
1370
+ const filteredFields = secrets.filter(field => !excluded.has(normalizeScope(field.key)));
1371
+
1372
+ const [healthRow] = await buildCredentialHealthRows([credential], () => secrets);
1373
+
1374
+ const encrypted = encryptToAgentPubkey(
1375
+ JSON.stringify({
1376
+ id: credential.id,
1377
+ vaultId: credential.vaultId,
1378
+ type: credential.type,
1379
+ fields: filteredFields,
1380
+ health: healthRow?.health,
1381
+ }),
1382
+ auth.token.agentPubkey,
1383
+ );
1384
+
1385
+ // Increment read usage only after successful encryption.
1386
+ recordCredentialRead(auth.tokenHash);
1387
+
1388
+ const auditId = await writeCredentialAccessAudit({
1389
+ credentialId: credential.id,
1390
+ vaultId: credential.vaultId,
1391
+ action: 'credentials.read',
1392
+ allowed: true,
1393
+ reasonCode: 'ALLOW',
1394
+ httpStatus: 200,
1395
+ tokenHash: auth.tokenHash,
1396
+ agentId: auth.token.agentId,
1397
+ requestId: req.header('x-request-id') ?? undefined,
1398
+ actorType: isAdmin(auth) ? 'admin' : 'agent',
1399
+ metadata: {
1400
+ returnedFieldKeys: filteredFields.map((field) => field.key),
1401
+ },
1402
+ });
1403
+ emitCredentialAccessed(req, credential, {
1404
+ action: 'credentials.read',
1405
+ allowed: true,
1406
+ reasonCode: 'ALLOW',
1407
+ httpStatus: 200,
1408
+ });
1409
+
1410
+ const secretSurface = req.header('x-secret-surface') as 'inject_secret' | 'get_secret' | undefined;
1411
+ if (secretSurface) {
1412
+ events.secretAccessed({
1413
+ credentialId: credential.id,
1414
+ credentialName: req.header('x-credential-name') || credential.name,
1415
+ vaultId: credential.vaultId,
1416
+ surface: secretSurface,
1417
+ envVar: req.header('x-secret-envvar') || undefined,
1418
+ agentId: auth.token.agentId,
1419
+ tokenHash: auth.tokenHash,
1420
+ });
1421
+ }
1422
+
1423
+ logEvent({
1424
+ category: 'agent',
1425
+ action: 'credential_access_decision',
1426
+ description: `Credential access allow: ${credential.id}`,
1427
+ agentId: auth.token.agentId,
1428
+ metadata: {
1429
+ auditId,
1430
+ credentialId: credential.id,
1431
+ route: 'credentials.read',
1432
+ allowed: true,
1433
+ reasonCode: 'ALLOW',
1434
+ },
1435
+ });
1436
+
1437
+ res.json({
1438
+ success: true,
1439
+ credentialId: credential.id,
1440
+ encrypted,
1441
+ });
1442
+ } catch (error) {
1443
+ res.status(400).json({ success: false, error: getErrorMessage(error) });
1444
+ }
1445
+ });
1446
+
1447
+ // POST /credentials/:id/totp — generate current TOTP code
1448
+ router.post('/:id/totp', async (req: Request<{ id: string }>, res: Response) => {
1449
+ try {
1450
+ const auth = req.auth!;
1451
+
1452
+ const credential = getCredential(req.params.id);
1453
+ if (!credential) {
1454
+ res.status(404).json({ success: false, error: 'Credential not found' });
1455
+ return;
1456
+ }
1457
+ if (!canReadCredential(req, credential)) {
1458
+ await writeDeniedCredentialAccess({
1459
+ req,
1460
+ credential,
1461
+ action: 'credentials.totp',
1462
+ reasonCode: 'CREDENTIAL_SCOPE_DENIED',
1463
+ httpStatus: 403,
1464
+ });
1465
+ res.status(403).json({
1466
+ success: false,
1467
+ error: credentialAccessErrorMessage('CREDENTIAL_SCOPE_DENIED', 'credentials.totp'),
1468
+ reasonCode: 'CREDENTIAL_SCOPE_DENIED',
1469
+ });
1470
+ return;
1471
+ }
1472
+
1473
+ // totp:read permission required (admin bypasses)
1474
+ if (!isAdmin(auth) && !hasAnyPermission(auth.token.permissions, ['totp:read'])) {
1475
+ await writeDeniedCredentialAccess({
1476
+ req,
1477
+ credential,
1478
+ action: 'credentials.totp',
1479
+ reasonCode: 'TOKEN_PERMISSION_DENIED',
1480
+ httpStatus: 403,
1481
+ });
1482
+ res.status(403).json({
1483
+ success: false,
1484
+ error: credentialAccessErrorMessage('TOKEN_PERMISSION_DENIED', 'credentials.totp'),
1485
+ reasonCode: 'TOKEN_PERMISSION_DENIED',
1486
+ });
1487
+ return;
1488
+ }
1489
+
1490
+ const decision = evaluateCredentialAccess({
1491
+ tokenHash: auth.tokenHash,
1492
+ token: auth.token,
1493
+ credentialId: credential.id,
1494
+ action: 'credentials.totp',
1495
+ });
1496
+ if (!decision.allowed) {
1497
+ await writeDeniedCredentialAccess({
1498
+ req,
1499
+ credential,
1500
+ action: 'credentials.totp',
1501
+ reasonCode: decision.reasonCode,
1502
+ httpStatus: decision.httpStatus,
1503
+ metadata: {
1504
+ limiterWindowMs: decision.limiterWindowMs,
1505
+ limiterLimit: decision.limiterLimit,
1506
+ limiterCount: decision.limiterCount,
1507
+ },
1508
+ });
1509
+ res.status(decision.httpStatus).json({
1510
+ success: false,
1511
+ error: credentialAccessErrorMessage(decision.reasonCode, 'credentials.totp'),
1512
+ reasonCode: decision.reasonCode,
1513
+ });
1514
+ return;
1515
+ }
1516
+
1517
+ const secrets = readCredentialSecrets(credential.id);
1518
+ const totpField = findTotpField(secrets);
1519
+ if (!totpField) {
1520
+ await writeDeniedCredentialAccess({
1521
+ req,
1522
+ credential,
1523
+ action: 'credentials.totp',
1524
+ reasonCode: 'CREDENTIAL_TOTP_NOT_CONFIGURED',
1525
+ httpStatus: 400,
1526
+ });
1527
+ res.status(400).json({
1528
+ success: false,
1529
+ error: credentialAccessErrorMessage('CREDENTIAL_TOTP_NOT_CONFIGURED', 'credentials.totp'),
1530
+ reasonCode: 'CREDENTIAL_TOTP_NOT_CONFIGURED',
1531
+ });
1532
+ return;
1533
+ }
1534
+
1535
+ const result = generateTOTP(totpField.value, credential.name);
1536
+
1537
+ const auditId = await writeCredentialAccessAudit({
1538
+ credentialId: credential.id,
1539
+ vaultId: credential.vaultId,
1540
+ action: 'credentials.totp',
1541
+ allowed: true,
1542
+ reasonCode: 'ALLOW',
1543
+ httpStatus: 200,
1544
+ tokenHash: auth.tokenHash,
1545
+ agentId: auth.token.agentId,
1546
+ requestId: req.header('x-request-id') ?? undefined,
1547
+ actorType: isAdmin(auth) ? 'admin' : 'agent',
1548
+ });
1549
+ emitCredentialAccessed(req, credential, {
1550
+ action: 'credentials.totp',
1551
+ allowed: true,
1552
+ reasonCode: 'ALLOW',
1553
+ httpStatus: 200,
1554
+ });
1555
+
1556
+ logEvent({
1557
+ category: 'agent',
1558
+ action: 'credential_access_decision',
1559
+ description: `Credential TOTP access allow: ${credential.id}`,
1560
+ agentId: auth.token.agentId,
1561
+ metadata: {
1562
+ auditId,
1563
+ credentialId: credential.id,
1564
+ route: 'credentials.totp',
1565
+ allowed: true,
1566
+ reasonCode: 'ALLOW',
1567
+ },
1568
+ });
1569
+
1570
+ res.json({ success: true, code: result.code, remaining: result.remaining });
1571
+ } catch (error) {
1572
+ res.status(400).json({ success: false, error: getErrorMessage(error) });
1573
+ }
1574
+ });
1575
+
1576
+ // GET /credentials/:id/secrets — admin-only plaintext field read (for web UI)
1577
+ router.get('/:id/secrets', (req: Request<{ id: string }>, res: Response) => {
1578
+ try {
1579
+ const auth = req.auth!;
1580
+ if (!isAdmin(auth)) {
1581
+ res.status(403).json({ success: false, ...buildPermissionDenied('Admin access required', ['admin:*'], auth.token.permissions) });
1582
+ return;
1583
+ }
1584
+
1585
+ const credential = getCredential(req.params.id);
1586
+ if (!credential) {
1587
+ res.status(404).json({ success: false, error: 'Credential not found' });
1588
+ return;
1589
+ }
1590
+
1591
+ const fields = credential.type === 'plain_note'
1592
+ ? plainNoteFieldsFromMeta(credential.meta, readCredentialSecrets(credential.id))
1593
+ : readCredentialSecrets(credential.id);
1594
+
1595
+ // If TOTP field exists, also generate current code
1596
+ const totpField = findTotpField(fields);
1597
+ let totp: { code: string; remaining: number } | undefined;
1598
+ if (totpField) {
1599
+ try {
1600
+ totp = generateTOTP(totpField.value, credential.name);
1601
+ } catch {
1602
+ // Invalid TOTP secret — skip
1603
+ }
1604
+ }
1605
+
1606
+ res.json({ success: true, fields, totp });
1607
+ } catch (error) {
1608
+ res.status(400).json({ success: false, error: getErrorMessage(error) });
1609
+ }
1610
+ });
1611
+
1612
+ // POST /credentials/:id/reauth — oauth2 re-auth start/complete flow
1613
+ router.post('/:id/reauth', async (req: Request<{ id: string }>, res: Response) => {
1614
+ try {
1615
+ const auth = req.auth!;
1616
+ if (!isAdmin(auth)) {
1617
+ res.status(403).json({ success: false, ...buildPermissionDenied('Admin access required', ['admin:*'], auth.token.permissions) });
1618
+ return;
1619
+ }
1620
+
1621
+ const credential = getCredential(req.params.id);
1622
+ if (!credential) {
1623
+ res.status(404).json({ success: false, error: 'Credential not found' });
1624
+ return;
1625
+ }
1626
+
1627
+ if (credential.type !== 'oauth2') {
1628
+ res.status(400).json({ success: false, error: 'Re-auth only applies to oauth2 credentials' });
1629
+ return;
1630
+ }
1631
+
1632
+ const authorizationEndpoint = credential.meta.authorization_endpoint as string | undefined;
1633
+ const tokenEndpoint = credential.meta.token_endpoint as string | undefined;
1634
+ if (!authorizationEndpoint || !tokenEndpoint) {
1635
+ res.status(400).json({
1636
+ success: false,
1637
+ error: 'OAuth2 credentials require both `authorization_endpoint` and `token_endpoint` for re-auth.',
1638
+ });
1639
+ return;
1640
+ }
1641
+
1642
+ const secrets = readCredentialSecrets(credential.id);
1643
+ const clientId = findSensitiveFieldValue(secrets, 'client_id');
1644
+ const clientSecret = findSensitiveFieldValue(secrets, 'client_secret');
1645
+ const existingRefreshToken = findSensitiveFieldValue(secrets, 'refresh_token');
1646
+
1647
+ if (!clientId) {
1648
+ res.status(400).json({ success: false, error: 'oauth2 reauth requires sensitive field client_id' });
1649
+ return;
1650
+ }
1651
+
1652
+ const body = (req.body || {}) as { code?: string; state?: string; redirect_uri?: string };
1653
+ const code = typeof body.code === 'string' ? body.code.trim() : '';
1654
+
1655
+ if (!code) {
1656
+ const redirectUri = (typeof body.redirect_uri === 'string' && body.redirect_uri.trim())
1657
+ || (credential.meta.redirect_uri as string | undefined)
1658
+ || 'urn:ietf:wg:oauth:2.0:oob';
1659
+ const state = randomBytes(16).toString('hex');
1660
+ oauth2ReauthState.set(state, {
1661
+ credentialId: credential.id,
1662
+ redirectUri,
1663
+ expiresAt: Date.now() + OAUTH2_REAUTH_STATE_TTL_MS,
1664
+ });
1665
+
1666
+ const params = new URLSearchParams({
1667
+ response_type: 'code',
1668
+ client_id: clientId,
1669
+ redirect_uri: redirectUri,
1670
+ state,
1671
+ });
1672
+ const scopes = typeof credential.meta.scopes === 'string' ? credential.meta.scopes.trim() : '';
1673
+ if (scopes) params.set('scope', scopes);
1674
+
1675
+ res.json({
1676
+ success: true,
1677
+ phase: 'authorization_required',
1678
+ message: 'Open provider consent URL, then submit returned authorization code to complete re-auth.',
1679
+ credentialId: credential.id,
1680
+ authorization_url: `${authorizationEndpoint}${authorizationEndpoint.includes('?') ? '&' : '?'}${params.toString()}`,
1681
+ state,
1682
+ });
1683
+ return;
1684
+ }
1685
+
1686
+ const providedState = typeof body.state === 'string' ? body.state.trim() : '';
1687
+ if (!providedState) {
1688
+ res.status(400).json({ success: false, error: 'state is required when completing oauth2 reauth' });
1689
+ return;
1690
+ }
1691
+
1692
+ const stateRecord = oauth2ReauthState.get(providedState);
1693
+ if (!stateRecord || stateRecord.credentialId !== credential.id || stateRecord.expiresAt < Date.now()) {
1694
+ res.status(400).json({ success: false, error: 'Invalid or expired reauth state. Start re-auth again.' });
1695
+ return;
1696
+ }
1697
+
1698
+ const redirectUri = stateRecord.redirectUri;
1699
+ oauth2ReauthState.delete(providedState);
1700
+
1701
+ const authMethod = String(credential.meta.auth_method || 'client_secret_post');
1702
+ const tokenBody = new URLSearchParams({
1703
+ grant_type: 'authorization_code',
1704
+ code,
1705
+ redirect_uri: redirectUri,
1706
+ });
1707
+
1708
+ const headers: Record<string, string> = { 'content-type': 'application/x-www-form-urlencoded' };
1709
+ if (authMethod === 'client_secret_post') {
1710
+ tokenBody.set('client_id', clientId);
1711
+ tokenBody.set('client_secret', clientSecret);
1712
+ } else {
1713
+ const basic = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
1714
+ headers.authorization = `Basic ${basic}`;
1715
+ tokenBody.set('client_id', clientId);
1716
+ }
1717
+
1718
+ const tokenRes = await fetch(tokenEndpoint, {
1719
+ method: 'POST',
1720
+ headers,
1721
+ body: tokenBody.toString(),
1722
+ });
1723
+
1724
+ let tokenPayload: Record<string, unknown> = {};
1725
+ try {
1726
+ tokenPayload = await tokenRes.json() as Record<string, unknown>;
1727
+ } catch {
1728
+ tokenPayload = {};
1729
+ }
1730
+
1731
+ if (!tokenRes.ok) {
1732
+ const reason = String(tokenPayload.error_description || tokenPayload.error || `OAuth2 re-auth failed (${tokenRes.status})`);
1733
+ updateCredential(credential.id, {
1734
+ meta: {
1735
+ ...credential.meta,
1736
+ needs_reauth: true,
1737
+ reauth_reason: reason,
1738
+ },
1739
+ });
1740
+ res.status(400).json({ success: false, error: reason, phase: 'failed' });
1741
+ return;
1742
+ }
1743
+
1744
+ const accessToken = String(tokenPayload.access_token || '').trim();
1745
+ if (!accessToken) {
1746
+ res.status(400).json({ success: false, error: 'Provider response missing access_token' });
1747
+ return;
1748
+ }
1749
+
1750
+ const refreshToken = String(tokenPayload.refresh_token || existingRefreshToken || '').trim();
1751
+ const expiresIn = Number(tokenPayload.expires_in);
1752
+ const nextExpiresAt = Number.isFinite(expiresIn) && expiresIn > 0
1753
+ ? Math.floor(Date.now() / 1000) + expiresIn
1754
+ : Number(credential.meta.expires_at || Math.floor(Date.now() / 1000) + 3600);
1755
+
1756
+ let nextSecrets = upsertSensitiveField(secrets, 'access_token', accessToken);
1757
+ nextSecrets = upsertSensitiveField(nextSecrets, 'refresh_token', refreshToken);
1758
+
1759
+ updateCredential(credential.id, {
1760
+ meta: {
1761
+ ...credential.meta,
1762
+ expires_at: nextExpiresAt,
1763
+ needs_reauth: false,
1764
+ reauth_reason: null,
1765
+ last_refreshed: new Date().toISOString(),
1766
+ },
1767
+ sensitiveFields: nextSecrets,
1768
+ });
1769
+
1770
+ res.json({
1771
+ success: true,
1772
+ phase: 'completed',
1773
+ credentialId: credential.id,
1774
+ message: 'OAuth2 credential re-authenticated successfully.',
1775
+ expires_at: nextExpiresAt,
1776
+ });
1777
+ } catch (error) {
1778
+ res.status(400).json({ success: false, error: getErrorMessage(error) });
1779
+ }
1780
+ });
1781
+
1782
+ export default router;