@penclipai/server 2026.508.2 → 2026.511.0-canary.0

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 (192) hide show
  1. package/dist/adapters/builtin-adapter-types.d.ts.map +1 -1
  2. package/dist/adapters/builtin-adapter-types.js +1 -0
  3. package/dist/adapters/builtin-adapter-types.js.map +1 -1
  4. package/dist/adapters/registry.d.ts.map +1 -1
  5. package/dist/adapters/registry.js +17 -0
  6. package/dist/adapters/registry.js.map +1 -1
  7. package/dist/config.js +4 -4
  8. package/dist/config.js.map +1 -1
  9. package/dist/home-paths.d.ts +8 -11
  10. package/dist/home-paths.d.ts.map +1 -1
  11. package/dist/home-paths.js +14 -67
  12. package/dist/home-paths.js.map +1 -1
  13. package/dist/routes/agents.d.ts.map +1 -1
  14. package/dist/routes/agents.js +8 -0
  15. package/dist/routes/agents.js.map +1 -1
  16. package/dist/routes/environments.d.ts.map +1 -1
  17. package/dist/routes/environments.js +8 -1
  18. package/dist/routes/environments.js.map +1 -1
  19. package/dist/routes/plugins.d.ts.map +1 -1
  20. package/dist/routes/plugins.js +6 -0
  21. package/dist/routes/plugins.js.map +1 -1
  22. package/dist/routes/projects.d.ts.map +1 -1
  23. package/dist/routes/projects.js +6 -0
  24. package/dist/routes/projects.js.map +1 -1
  25. package/dist/routes/secrets.d.ts.map +1 -1
  26. package/dist/routes/secrets.js +269 -5
  27. package/dist/routes/secrets.js.map +1 -1
  28. package/dist/secrets/aws-secrets-manager-provider.d.ts +87 -0
  29. package/dist/secrets/aws-secrets-manager-provider.d.ts.map +1 -0
  30. package/dist/secrets/aws-secrets-manager-provider.js +748 -0
  31. package/dist/secrets/aws-secrets-manager-provider.js.map +1 -0
  32. package/dist/secrets/configured-provider.d.ts +3 -0
  33. package/dist/secrets/configured-provider.d.ts.map +1 -0
  34. package/dist/secrets/configured-provider.js +8 -0
  35. package/dist/secrets/configured-provider.js.map +1 -0
  36. package/dist/secrets/external-stub-providers.d.ts.map +1 -1
  37. package/dist/secrets/external-stub-providers.js +55 -5
  38. package/dist/secrets/external-stub-providers.js.map +1 -1
  39. package/dist/secrets/local-encrypted-provider.d.ts.map +1 -1
  40. package/dist/secrets/local-encrypted-provider.js +140 -12
  41. package/dist/secrets/local-encrypted-provider.js.map +1 -1
  42. package/dist/secrets/provider-registry.d.ts +2 -1
  43. package/dist/secrets/provider-registry.d.ts.map +1 -1
  44. package/dist/secrets/provider-registry.js +6 -2
  45. package/dist/secrets/provider-registry.js.map +1 -1
  46. package/dist/secrets/types.d.ts +117 -8
  47. package/dist/secrets/types.d.ts.map +1 -1
  48. package/dist/secrets/types.js +35 -1
  49. package/dist/secrets/types.js.map +1 -1
  50. package/dist/services/access.d.ts +27 -27
  51. package/dist/services/activity.d.ts +8 -8
  52. package/dist/services/agents.d.ts +9 -9
  53. package/dist/services/approvals.d.ts +10 -10
  54. package/dist/services/assets.d.ts +3 -3
  55. package/dist/services/board-auth.d.ts +24 -24
  56. package/dist/services/environment-config.d.ts +14 -2
  57. package/dist/services/environment-config.d.ts.map +1 -1
  58. package/dist/services/environment-config.js +57 -4
  59. package/dist/services/environment-config.js.map +1 -1
  60. package/dist/services/environment-execution-target.d.ts.map +1 -1
  61. package/dist/services/environment-execution-target.js +2 -0
  62. package/dist/services/environment-execution-target.js.map +1 -1
  63. package/dist/services/environment-runtime.d.ts.map +1 -1
  64. package/dist/services/environment-runtime.js +10 -2
  65. package/dist/services/environment-runtime.js.map +1 -1
  66. package/dist/services/feedback.d.ts +4 -4
  67. package/dist/services/finance.d.ts +8 -8
  68. package/dist/services/goals.d.ts +20 -20
  69. package/dist/services/heartbeat.d.ts +24 -19
  70. package/dist/services/heartbeat.d.ts.map +1 -1
  71. package/dist/services/heartbeat.js +82 -8
  72. package/dist/services/heartbeat.js.map +1 -1
  73. package/dist/services/inbox-dismissals.d.ts +2 -2
  74. package/dist/services/issue-approvals.d.ts +2 -2
  75. package/dist/services/issue-continuation-summary.d.ts +2 -0
  76. package/dist/services/issue-continuation-summary.d.ts.map +1 -1
  77. package/dist/services/issue-continuation-summary.js +10 -0
  78. package/dist/services/issue-continuation-summary.js.map +1 -1
  79. package/dist/services/issues.d.ts +18 -18
  80. package/dist/services/plugin-environment-driver.d.ts +6 -6
  81. package/dist/services/plugin-host-services.d.ts.map +1 -1
  82. package/dist/services/plugin-host-services.js +31 -4
  83. package/dist/services/plugin-host-services.js.map +1 -1
  84. package/dist/services/plugin-local-folders.d.ts +1 -0
  85. package/dist/services/plugin-local-folders.d.ts.map +1 -1
  86. package/dist/services/plugin-local-folders.js +45 -0
  87. package/dist/services/plugin-local-folders.js.map +1 -1
  88. package/dist/services/plugin-managed-agents.d.ts.map +1 -1
  89. package/dist/services/plugin-managed-agents.js +52 -9
  90. package/dist/services/plugin-managed-agents.js.map +1 -1
  91. package/dist/services/plugin-managed-skills.d.ts +14 -0
  92. package/dist/services/plugin-managed-skills.d.ts.map +1 -0
  93. package/dist/services/plugin-managed-skills.js +264 -0
  94. package/dist/services/plugin-managed-skills.js.map +1 -0
  95. package/dist/services/plugin-registry.d.ts +79 -79
  96. package/dist/services/plugin-secrets-handler.d.ts +2 -0
  97. package/dist/services/plugin-secrets-handler.d.ts.map +1 -1
  98. package/dist/services/plugin-secrets-handler.js +17 -80
  99. package/dist/services/plugin-secrets-handler.js.map +1 -1
  100. package/dist/services/recovery/service.d.ts +61 -0
  101. package/dist/services/recovery/service.d.ts.map +1 -1
  102. package/dist/services/recovery/service.js +63 -17
  103. package/dist/services/recovery/service.js.map +1 -1
  104. package/dist/services/routines.d.ts +18 -18
  105. package/dist/services/routines.d.ts.map +1 -1
  106. package/dist/services/routines.js +36 -4
  107. package/dist/services/routines.js.map +1 -1
  108. package/dist/services/secrets.d.ts +1566 -119
  109. package/dist/services/secrets.d.ts.map +1 -1
  110. package/dist/services/secrets.js +1465 -69
  111. package/dist/services/secrets.js.map +1 -1
  112. package/package.json +17 -16
  113. package/ui-dist/assets/{_basePickBy-5H35nb2M.js → _basePickBy-B0S0WJuO.js} +1 -1
  114. package/ui-dist/assets/{_baseUniq-BqsDtPj4.js → _baseUniq-DPfwHtaX.js} +1 -1
  115. package/ui-dist/assets/{arc-F3f4deZD.js → arc-dayO-_vQ.js} +1 -1
  116. package/ui-dist/assets/{architectureDiagram-VXUJARFQ-pBm1fRMv.js → architectureDiagram-VXUJARFQ-B7gjpAgc.js} +1 -1
  117. package/ui-dist/assets/{blockDiagram-VD42YOAC-CGtc9RP7.js → blockDiagram-VD42YOAC-e0_AbQkY.js} +1 -1
  118. package/ui-dist/assets/{browser-ponyfill-CkdJVj1i.js → browser-ponyfill-D5A0IJkC.js} +1 -1
  119. package/ui-dist/assets/{c4Diagram-YG6GDRKO-BXgVj8GH.js → c4Diagram-YG6GDRKO-D5OdXvo8.js} +1 -1
  120. package/ui-dist/assets/channel-BLi0LFH0.js +1 -0
  121. package/ui-dist/assets/{chunk-4BX2VUAB-Ds1Y5IYA.js → chunk-4BX2VUAB-uWBbpQP3.js} +1 -1
  122. package/ui-dist/assets/{chunk-55IACEB6-D8GwRmXK.js → chunk-55IACEB6-CFVDvwbd.js} +1 -1
  123. package/ui-dist/assets/{chunk-B4BG7PRW-R_v5U6_B.js → chunk-B4BG7PRW-Niy3z_vf.js} +1 -1
  124. package/ui-dist/assets/{chunk-DI55MBZ5-DO4X9EFt.js → chunk-DI55MBZ5-BSFdxpNJ.js} +1 -1
  125. package/ui-dist/assets/{chunk-FMBD7UC4-DUu61Scs.js → chunk-FMBD7UC4-Npjz_-5M.js} +1 -1
  126. package/ui-dist/assets/{chunk-QN33PNHL-Bw2VuHcK.js → chunk-QN33PNHL-C2BptvDe.js} +1 -1
  127. package/ui-dist/assets/{chunk-QZHKN3VN-BMGdo5T7.js → chunk-QZHKN3VN-B_lJernK.js} +1 -1
  128. package/ui-dist/assets/{chunk-TZMSLE5B-Cj_cdifl.js → chunk-TZMSLE5B-CNK1gBxt.js} +1 -1
  129. package/ui-dist/assets/classDiagram-2ON5EDUG-DGtCZCaT.js +1 -0
  130. package/ui-dist/assets/classDiagram-v2-WZHVMYZB-DGtCZCaT.js +1 -0
  131. package/ui-dist/assets/clone-_FFZ0Kkl.js +1 -0
  132. package/ui-dist/assets/{cose-bilkent-S5V4N54A-3YDaxa6o.js → cose-bilkent-S5V4N54A-P5AbZ8TB.js} +1 -1
  133. package/ui-dist/assets/{dagre-6UL2VRFP-CmLvQKQL.js → dagre-6UL2VRFP-Byky8_ia.js} +1 -1
  134. package/ui-dist/assets/{diagram-PSM6KHXK-vZK9KPBu.js → diagram-PSM6KHXK-fWgBJ_Sk.js} +1 -1
  135. package/ui-dist/assets/{diagram-QEK2KX5R-Bu9JBN1e.js → diagram-QEK2KX5R-Di6U-VgU.js} +1 -1
  136. package/ui-dist/assets/{diagram-S2PKOQOG-gPR1ps4Q.js → diagram-S2PKOQOG--ALJobJP.js} +1 -1
  137. package/ui-dist/assets/{erDiagram-Q2GNP2WA-BpqETGGN.js → erDiagram-Q2GNP2WA-BPLxxd-8.js} +1 -1
  138. package/ui-dist/assets/{flowDiagram-NV44I4VS-B0iV5Bcy.js → flowDiagram-NV44I4VS-DsKtnwgY.js} +1 -1
  139. package/ui-dist/assets/{ganttDiagram-JELNMOA3-BxQL_kP7.js → ganttDiagram-JELNMOA3-D-RgqRnI.js} +1 -1
  140. package/ui-dist/assets/{gitGraphDiagram-V2S2FVAM-CSsc-XDT.js → gitGraphDiagram-V2S2FVAM-B9hWxZno.js} +1 -1
  141. package/ui-dist/assets/{graph-DWnTQd3e.js → graph-BR5K_DHH.js} +1 -1
  142. package/ui-dist/assets/{index-BEfD-NrI.js → index--jhL8HjG.js} +1 -1
  143. package/ui-dist/assets/{index-C24lwrRh.js → index-3FxOnh1Q.js} +1 -1
  144. package/ui-dist/assets/{index-CqFa1_Kx.js → index-B5iG7mfQ.js} +1 -1
  145. package/ui-dist/assets/{index-DRqmQ4ym.js → index-BC-VyJRT.js} +1 -1
  146. package/ui-dist/assets/{index-mfdF2CPG.js → index-BKJKSfwf.js} +1 -1
  147. package/ui-dist/assets/{index-DNyOAFyR.js → index-BP2zDr7N.js} +1 -1
  148. package/ui-dist/assets/{index-CmZ6XLj-.js → index-BVZZDphv.js} +1 -1
  149. package/ui-dist/assets/{index-DbsPCpm_.js → index-Bi7Lez4i.js} +1 -1
  150. package/ui-dist/assets/{index-CpOa4tDv.js → index-C5zaTUz3.js} +1 -1
  151. package/ui-dist/assets/index-CJyKYByK.js +538 -0
  152. package/ui-dist/assets/index-COTa6xTk.css +1 -0
  153. package/ui-dist/assets/{index-Dt3jXp2D.js → index-CP1her1A.js} +1 -1
  154. package/ui-dist/assets/{index-h9lpZQKL.js → index-CPBqNjLU.js} +1 -1
  155. package/ui-dist/assets/{index-CxnOSxK9.js → index-CY4Sacam.js} +1 -1
  156. package/ui-dist/assets/{index-DgU-HHpm.js → index-CrCHtJt9.js} +1 -1
  157. package/ui-dist/assets/{index-C3xGHm3G.js → index-D-kP_TCS.js} +1 -1
  158. package/ui-dist/assets/{index-Bw88aPGu.js → index-D0b0bdp9.js} +1 -1
  159. package/ui-dist/assets/{index-CaFk2kgt.js → index-DDo7a1ad.js} +1 -1
  160. package/ui-dist/assets/{index-Ckh-TE03.js → index-F8dNHc7L.js} +1 -1
  161. package/ui-dist/assets/{index-DUerGXI8.js → index-GIT3T29G.js} +1 -1
  162. package/ui-dist/assets/{index-DiPyhXNL.js → index-Ii2Zu1VC.js} +1 -1
  163. package/ui-dist/assets/{index-DvOzQrgz.js → index-cAXY17Km.js} +1 -1
  164. package/ui-dist/assets/{index-CzxxXvnl.js → index-vL5R__U-.js} +1 -1
  165. package/ui-dist/assets/{index-DNgm4Hx7.js → index-xy3NppqB.js} +1 -1
  166. package/ui-dist/assets/{infoDiagram-HS3SLOUP-C9a-DRdF.js → infoDiagram-HS3SLOUP-Cw5FMQPy.js} +1 -1
  167. package/ui-dist/assets/{journeyDiagram-XKPGCS4Q-Cu752PhZ.js → journeyDiagram-XKPGCS4Q-qqubogM5.js} +1 -1
  168. package/ui-dist/assets/{kanban-definition-3W4ZIXB7-PXy7IffE.js → kanban-definition-3W4ZIXB7-BHKa2WJW.js} +1 -1
  169. package/ui-dist/assets/{layout-Ci-UJJNd.js → layout-n4Wr5yzd.js} +1 -1
  170. package/ui-dist/assets/{linear-D8Qy6J5I.js → linear-BeRJXNne.js} +1 -1
  171. package/ui-dist/assets/{mermaid.core-RTrr9erR.js → mermaid.core-DelVCbRr.js} +4 -4
  172. package/ui-dist/assets/{mindmap-definition-VGOIOE7T-Ad_ky9GS.js → mindmap-definition-VGOIOE7T-DR-YsecJ.js} +1 -1
  173. package/ui-dist/assets/{pieDiagram-ADFJNKIX-BPWjI8Sm.js → pieDiagram-ADFJNKIX-CZXfqZBG.js} +1 -1
  174. package/ui-dist/assets/{quadrantDiagram-AYHSOK5B-DxY-Cew9.js → quadrantDiagram-AYHSOK5B-BBl26L66.js} +1 -1
  175. package/ui-dist/assets/{requirementDiagram-UZGBJVZJ-BiUlKNuF.js → requirementDiagram-UZGBJVZJ-CvVYsxxA.js} +1 -1
  176. package/ui-dist/assets/{sankeyDiagram-TZEHDZUN-t3mfJhUj.js → sankeyDiagram-TZEHDZUN-BVw2uYFt.js} +1 -1
  177. package/ui-dist/assets/{sequenceDiagram-WL72ISMW-D90qw9a6.js → sequenceDiagram-WL72ISMW--kspLpct.js} +1 -1
  178. package/ui-dist/assets/{stateDiagram-FKZM4ZOC-DB70vjUm.js → stateDiagram-FKZM4ZOC-DPx02LV8.js} +1 -1
  179. package/ui-dist/assets/stateDiagram-v2-4FDKWEC3-S-NvEffl.js +1 -0
  180. package/ui-dist/assets/{timeline-definition-IT6M3QCI-CYSOqO3s.js → timeline-definition-IT6M3QCI-CyMnn5_z.js} +1 -1
  181. package/ui-dist/assets/{treemap-GDKQZRPO-QqfwsQzN.js → treemap-GDKQZRPO-DwLh53A5.js} +1 -1
  182. package/ui-dist/assets/{xychartDiagram-PRI3JC2R-DZuSZ892.js → xychartDiagram-PRI3JC2R-xX5JJ2_4.js} +1 -1
  183. package/ui-dist/index.html +2 -2
  184. package/ui-dist/locales/en/common.json +629 -0
  185. package/ui-dist/locales/zh-CN/common.json +551 -0
  186. package/ui-dist/assets/channel-BklDDvhc.js +0 -1
  187. package/ui-dist/assets/classDiagram-2ON5EDUG-gSWbQXsC.js +0 -1
  188. package/ui-dist/assets/classDiagram-v2-WZHVMYZB-gSWbQXsC.js +0 -1
  189. package/ui-dist/assets/clone-BhrAR33H.js +0 -1
  190. package/ui-dist/assets/index-GwA57FCP.js +0 -537
  191. package/ui-dist/assets/index-RH-ttKJp.css +0 -1
  192. package/ui-dist/assets/stateDiagram-v2-4FDKWEC3-WlT0xb9j.js +0 -1
@@ -1,11 +1,87 @@
1
- import { and, desc, eq } from "drizzle-orm";
2
- import { companySecrets, companySecretVersions } from "@penclipai/db";
3
- import { envBindingSchema } from "@penclipai/shared";
4
- import { conflict, notFound, unprocessable } from "../errors.js";
5
- import { getSecretProvider, listSecretProviders } from "../secrets/provider-registry.js";
1
+ import { and, desc, eq, inArray, like, ne, notInArray, sql } from "drizzle-orm";
2
+ import { agents, companySecretBindings, companySecretProviderConfigs, companySecrets, companySecretVersions, environments, heartbeatRuns, issues, projects, routines, secretAccessEvents, } from "@penclipai/db";
3
+ import { createSecretProviderConfigSchema, deriveProjectUrlKey, envBindingSchema, isUuidLike, normalizeAgentUrlKey, secretProviderConfigPayloadSchema, updateSecretProviderConfigSchema, } from "@penclipai/shared";
4
+ import { conflict, HttpError, notFound, unprocessable } from "../errors.js";
5
+ import { logger } from "../middleware/logger.js";
6
+ import { checkSecretProviders, getSecretProvider, listSecretProviders, } from "../secrets/provider-registry.js";
7
+ import { isSecretProviderClientError } from "../secrets/types.js";
6
8
  const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
7
9
  const SENSITIVE_ENV_KEY_RE = /(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i;
8
10
  const REDACTED_SENTINEL = "***REDACTED***";
11
+ const COMING_SOON_SECRET_PROVIDERS = new Set([
12
+ "gcp_secret_manager",
13
+ "vault",
14
+ ]);
15
+ function remoteProviderHttpError(error, context) {
16
+ if (isSecretProviderClientError(error)) {
17
+ logger.warn({
18
+ err: error,
19
+ companyId: context.companyId,
20
+ provider: context.provider,
21
+ providerConfigId: context.providerConfigId,
22
+ operation: context.operation,
23
+ providerErrorCode: error.code,
24
+ }, "remote secret provider request failed");
25
+ return new HttpError(error.status, error.message, { code: error.code });
26
+ }
27
+ if (error instanceof HttpError)
28
+ return error;
29
+ logger.warn({
30
+ err: error,
31
+ companyId: context.companyId,
32
+ provider: context.provider,
33
+ providerConfigId: context.providerConfigId,
34
+ operation: context.operation,
35
+ providerErrorCode: "provider_error",
36
+ }, "remote secret provider request failed");
37
+ return new HttpError(502, "Remote secret provider request failed.", { code: "provider_error" });
38
+ }
39
+ function remoteImportRowFailureReason(error, fallback, context) {
40
+ if (isSecretProviderClientError(error)) {
41
+ logger.warn({
42
+ err: error,
43
+ companyId: context.companyId,
44
+ provider: context.provider,
45
+ providerConfigId: context.providerConfigId,
46
+ operation: context.operation,
47
+ providerErrorCode: error.code,
48
+ }, "remote secret import row provider failure");
49
+ return error.message;
50
+ }
51
+ if (error instanceof HttpError && error.status < 500)
52
+ return error.message;
53
+ logger.warn({
54
+ err: error,
55
+ companyId: context.companyId,
56
+ provider: context.provider,
57
+ providerConfigId: context.providerConfigId,
58
+ operation: context.operation,
59
+ providerErrorCode: "provider_error",
60
+ }, "remote secret import row failed");
61
+ return fallback;
62
+ }
63
+ async function cleanupPreparedProviderWrite(input) {
64
+ try {
65
+ await input.provider.deleteOrArchive({
66
+ material: input.prepared.material,
67
+ externalRef: input.prepared.externalRef,
68
+ providerConfig: input.providerConfig,
69
+ context: input.context,
70
+ mode: input.mode,
71
+ });
72
+ return true;
73
+ }
74
+ catch (cleanupError) {
75
+ logger.warn({
76
+ err: cleanupError,
77
+ companyId: input.context.companyId,
78
+ provider: input.provider.id,
79
+ providerConfigId: input.providerConfig?.id ?? null,
80
+ operation: input.operation,
81
+ }, "remote secret provider cleanup failed after db write failure");
82
+ return false;
83
+ }
84
+ }
9
85
  function asRecord(value) {
10
86
  if (typeof value !== "object" || value === null || Array.isArray(value))
11
87
  return null;
@@ -14,6 +90,20 @@ function asRecord(value) {
14
90
  function isSensitiveEnvKey(key) {
15
91
  return SENSITIVE_ENV_KEY_RE.test(key);
16
92
  }
93
+ function normalizeSecretKey(input) {
94
+ return input
95
+ .trim()
96
+ .toLowerCase()
97
+ .replace(/[^a-z0-9_.-]+/g, "-")
98
+ .replace(/^-+|-+$/g, "")
99
+ .slice(0, 120);
100
+ }
101
+ function deriveSecretNameFromExternalRef(externalRef) {
102
+ const trimmed = externalRef.trim();
103
+ const arnMatch = /^arn:[^:]+:secretsmanager:[^:]*:[^:]*:secret:(.+)$/i.exec(trimmed);
104
+ const name = arnMatch?.[1] ?? trimmed;
105
+ return name.split("/").filter(Boolean).at(-1) ?? name;
106
+ }
17
107
  function canonicalizeBinding(binding) {
18
108
  if (typeof binding === "string") {
19
109
  return { type: "plain", value: binding };
@@ -27,6 +117,21 @@ function canonicalizeBinding(binding) {
27
117
  version: binding.version ?? "latest",
28
118
  };
29
119
  }
120
+ function defaultProviderConfigStatus(provider) {
121
+ return COMING_SOON_SECRET_PROVIDERS.has(provider) ? "coming_soon" : "ready";
122
+ }
123
+ function assertSelectableProviderConfig(config, companyId, provider) {
124
+ if (config.companyId !== companyId)
125
+ throw unprocessable("Provider vault must belong to same company");
126
+ if (config.provider !== provider)
127
+ throw unprocessable("Provider vault must match the secret provider");
128
+ if (config.status === "coming_soon") {
129
+ throw unprocessable("Provider vault is locked while coming soon");
130
+ }
131
+ if (config.status === "disabled") {
132
+ throw unprocessable("Provider vault is disabled");
133
+ }
134
+ }
30
135
  export function secretService(db) {
31
136
  async function getById(id) {
32
137
  return db
@@ -39,7 +144,7 @@ export function secretService(db) {
39
144
  return db
40
145
  .select()
41
146
  .from(companySecrets)
42
- .where(and(eq(companySecrets.companyId, companyId), eq(companySecrets.name, name)))
147
+ .where(and(eq(companySecrets.companyId, companyId), eq(companySecrets.name, name), ne(companySecrets.status, "deleted")))
43
148
  .then((rows) => rows[0] ?? null);
44
149
  }
45
150
  async function getSecretVersion(secretId, version) {
@@ -49,25 +154,222 @@ export function secretService(db) {
49
154
  .where(and(eq(companySecretVersions.secretId, secretId), eq(companySecretVersions.version, version)))
50
155
  .then((rows) => rows[0] ?? null);
51
156
  }
157
+ async function getBinding(input) {
158
+ return db
159
+ .select()
160
+ .from(companySecretBindings)
161
+ .where(and(eq(companySecretBindings.companyId, input.companyId), eq(companySecretBindings.secretId, input.secretId), eq(companySecretBindings.targetType, input.consumerType), eq(companySecretBindings.targetId, input.consumerId), eq(companySecretBindings.configPath, input.configPath)))
162
+ .then((rows) => rows[0] ?? null);
163
+ }
164
+ async function assertBindingContext(companyId, secretId, context) {
165
+ if (!context)
166
+ return;
167
+ if (!context.configPath) {
168
+ throw unprocessable("Secret resolution requires a binding config path");
169
+ }
170
+ const binding = await getBinding({
171
+ companyId,
172
+ secretId,
173
+ consumerType: context.consumerType,
174
+ consumerId: context.consumerId,
175
+ configPath: context.configPath,
176
+ });
177
+ if (!binding) {
178
+ throw unprocessable(`Secret is not bound to ${context.consumerType}:${context.consumerId} at ${context.configPath}`);
179
+ }
180
+ }
181
+ async function recordAccessEvent(input) {
182
+ if (!input.context)
183
+ return;
184
+ await db.insert(secretAccessEvents).values({
185
+ companyId: input.companyId,
186
+ secretId: input.secretId,
187
+ version: input.version,
188
+ provider: input.provider,
189
+ actorType: input.context.actorType ?? "system",
190
+ actorId: input.context.actorId ?? null,
191
+ consumerType: input.context.consumerType,
192
+ consumerId: input.context.consumerId,
193
+ configPath: input.context.configPath ?? null,
194
+ issueId: input.context.issueId ?? null,
195
+ heartbeatRunId: input.context.heartbeatRunId ?? null,
196
+ pluginId: input.context.pluginId ?? null,
197
+ outcome: input.outcome,
198
+ errorCode: input.errorCode ?? null,
199
+ });
200
+ }
52
201
  async function assertSecretInCompany(companyId, secretId) {
53
202
  const secret = await getById(secretId);
54
203
  if (!secret)
55
204
  throw notFound("Secret not found");
205
+ if (secret.status === "deleted")
206
+ throw notFound("Secret not found");
56
207
  if (secret.companyId !== companyId)
57
208
  throw unprocessable("Secret must belong to same company");
58
209
  return secret;
59
210
  }
60
- async function resolveSecretValue(companyId, secretId, version) {
211
+ async function getProviderConfigById(id) {
212
+ return db
213
+ .select()
214
+ .from(companySecretProviderConfigs)
215
+ .where(eq(companySecretProviderConfigs.id, id))
216
+ .then((rows) => rows[0] ?? null);
217
+ }
218
+ async function assertProviderConfigForSecret(companyId, provider, providerConfigId) {
219
+ if (!providerConfigId)
220
+ return null;
221
+ const providerConfig = await getProviderConfigById(providerConfigId);
222
+ if (!providerConfig)
223
+ throw notFound("Provider vault not found");
224
+ assertSelectableProviderConfig(providerConfig, companyId, provider);
225
+ return providerConfig;
226
+ }
227
+ function toProviderVaultRuntimeConfig(providerConfig) {
228
+ if (!providerConfig)
229
+ return null;
230
+ return {
231
+ id: providerConfig.id,
232
+ provider: providerConfig.provider,
233
+ status: providerConfig.status,
234
+ config: providerConfig.config ?? {},
235
+ };
236
+ }
237
+ async function getSelectableRuntimeProviderConfig(input) {
238
+ const providerConfig = await assertProviderConfigForSecret(input.companyId, input.provider, input.providerConfigId);
239
+ return toProviderVaultRuntimeConfig(providerConfig);
240
+ }
241
+ function validateProviderConfigPayload(provider, config) {
242
+ const parsed = secretProviderConfigPayloadSchema.safeParse({ provider, config });
243
+ if (!parsed.success) {
244
+ throw unprocessable("Invalid provider vault config", parsed.error.flatten());
245
+ }
246
+ return parsed.data.config;
247
+ }
248
+ function providerConfigHealth(input) {
249
+ if (input.status === "disabled") {
250
+ return {
251
+ configId: input.id,
252
+ provider: input.provider,
253
+ status: "disabled",
254
+ message: "Provider vault is disabled.",
255
+ details: { code: "disabled", message: "Provider vault is disabled." },
256
+ };
257
+ }
258
+ if (input.status === "coming_soon" || COMING_SOON_SECRET_PROVIDERS.has(input.provider)) {
259
+ return {
260
+ configId: input.id,
261
+ provider: input.provider,
262
+ status: "coming_soon",
263
+ message: "Provider vault runtime is locked while coming soon.",
264
+ details: {
265
+ code: "runtime_locked",
266
+ message: "Provider vault runtime is locked while coming soon.",
267
+ guidance: ["Draft metadata may be saved, but create, rotate, and resolve stay unavailable."],
268
+ },
269
+ };
270
+ }
271
+ return null;
272
+ }
273
+ function mapProviderModuleHealth(input) {
274
+ const status = input.health.status === "ok"
275
+ ? input.providerStatus === "warning" ? "warning" : "ready"
276
+ : input.health.status === "error"
277
+ ? "error"
278
+ : "warning";
279
+ const guidance = [
280
+ ...(input.health.warnings ?? []),
281
+ ...(input.health.backupGuidance ?? []),
282
+ ];
283
+ return {
284
+ configId: input.configId,
285
+ provider: input.provider,
286
+ status,
287
+ message: input.health.message,
288
+ details: {
289
+ code: input.health.status === "ok" ? "provider_ready" : "provider_needs_attention",
290
+ message: input.health.message,
291
+ guidance: guidance.length > 0 ? guidance : undefined,
292
+ },
293
+ };
294
+ }
295
+ async function resolveSecretValueInternal(companyId, secretId, version, context) {
61
296
  const secret = await assertSecretInCompany(companyId, secretId);
62
297
  const resolvedVersion = version === "latest" ? secret.latestVersion : version;
63
- const versionRow = await getSecretVersion(secret.id, resolvedVersion);
64
- if (!versionRow)
65
- throw notFound("Secret version not found");
66
- const provider = getSecretProvider(secret.provider);
67
- return provider.resolveVersion({
68
- material: versionRow.material,
69
- externalRef: secret.externalRef,
70
- });
298
+ const providerId = secret.provider;
299
+ const configPath = context?.configPath ?? null;
300
+ try {
301
+ if (secret.status !== "active") {
302
+ throw unprocessable("Secret is not active");
303
+ }
304
+ await assertBindingContext(companyId, secret.id, context);
305
+ const versionRow = await getSecretVersion(secret.id, resolvedVersion);
306
+ if (!versionRow)
307
+ throw notFound("Secret version not found");
308
+ if (versionRow.status === "disabled" || versionRow.status === "destroyed" || versionRow.revokedAt) {
309
+ throw unprocessable("Secret version is not active");
310
+ }
311
+ const provider = getSecretProvider(providerId);
312
+ const providerConfig = await getSelectableRuntimeProviderConfig({
313
+ companyId,
314
+ provider: providerId,
315
+ providerConfigId: secret.providerConfigId,
316
+ });
317
+ const value = await provider.resolveVersion({
318
+ material: versionRow.material,
319
+ externalRef: secret.externalRef,
320
+ providerVersionRef: versionRow.providerVersionRef,
321
+ providerConfig,
322
+ context: {
323
+ companyId,
324
+ secretId: secret.id,
325
+ secretKey: secret.key,
326
+ version: resolvedVersion,
327
+ },
328
+ });
329
+ await Promise.all([
330
+ db
331
+ .update(companySecrets)
332
+ .set({ lastResolvedAt: new Date(), updatedAt: new Date() })
333
+ .where(eq(companySecrets.id, secret.id))
334
+ .catch(() => undefined),
335
+ recordAccessEvent({
336
+ companyId,
337
+ secretId: secret.id,
338
+ version: resolvedVersion,
339
+ provider: providerId,
340
+ context,
341
+ outcome: "success",
342
+ }).catch(() => undefined),
343
+ ]);
344
+ return {
345
+ value,
346
+ manifestEntry: {
347
+ configPath: configPath ?? "",
348
+ envKey: configPath?.startsWith("env.") ? configPath.slice("env.".length) : null,
349
+ secretId: secret.id,
350
+ secretKey: secret.key,
351
+ version: resolvedVersion,
352
+ provider: providerId,
353
+ outcome: "success",
354
+ },
355
+ };
356
+ }
357
+ catch (err) {
358
+ const errorCode = err instanceof Error ? err.message.slice(0, 120) : "resolution_failed";
359
+ await recordAccessEvent({
360
+ companyId,
361
+ secretId: secret.id,
362
+ version: resolvedVersion,
363
+ provider: providerId,
364
+ context,
365
+ outcome: "failure",
366
+ errorCode,
367
+ }).catch(() => undefined);
368
+ throw err;
369
+ }
370
+ }
371
+ async function resolveSecretValue(companyId, secretId, version, context) {
372
+ return (await resolveSecretValueInternal(companyId, secretId, version, context)).value;
71
373
  }
72
374
  async function normalizeEnvConfig(companyId, envValue, opts) {
73
375
  const record = asRecord(envValue);
@@ -110,13 +412,707 @@ export function secretService(db) {
110
412
  normalized.env = await normalizeEnvConfig(companyId, adapterConfig.env, opts);
111
413
  return normalized;
112
414
  }
415
+ function collectTargetIds(bindings, targetType, opts) {
416
+ return [
417
+ ...new Set(bindings
418
+ .filter((binding) => binding.targetType === targetType)
419
+ .map((binding) => binding.targetId)
420
+ .filter((id) => !opts?.uuidOnly || isUuidLike(id))),
421
+ ];
422
+ }
423
+ function fallbackBindingTarget(binding) {
424
+ return {
425
+ type: binding.targetType,
426
+ id: binding.targetId,
427
+ label: binding.targetId,
428
+ href: null,
429
+ status: null,
430
+ };
431
+ }
432
+ async function buildBindingTargetMap(companyId, bindings) {
433
+ const targetMap = new Map();
434
+ const setTarget = (target) => {
435
+ targetMap.set(`${target.type}:${target.id}`, target);
436
+ };
437
+ const agentIds = collectTargetIds(bindings, "agent", { uuidOnly: true });
438
+ if (agentIds.length > 0) {
439
+ const rows = await db
440
+ .select({
441
+ id: agents.id,
442
+ name: agents.name,
443
+ title: agents.title,
444
+ status: agents.status,
445
+ })
446
+ .from(agents)
447
+ .where(and(eq(agents.companyId, companyId), inArray(agents.id, agentIds)));
448
+ for (const row of rows) {
449
+ setTarget({
450
+ type: "agent",
451
+ id: row.id,
452
+ label: row.title ? `${row.name} (${row.title})` : row.name,
453
+ href: `/agents/${normalizeAgentUrlKey(row.name) ?? row.id}`,
454
+ status: row.status,
455
+ });
456
+ }
457
+ }
458
+ const projectIds = collectTargetIds(bindings, "project", { uuidOnly: true });
459
+ if (projectIds.length > 0) {
460
+ const rows = await db
461
+ .select({
462
+ id: projects.id,
463
+ name: projects.name,
464
+ status: projects.status,
465
+ })
466
+ .from(projects)
467
+ .where(and(eq(projects.companyId, companyId), inArray(projects.id, projectIds)));
468
+ for (const row of rows) {
469
+ setTarget({
470
+ type: "project",
471
+ id: row.id,
472
+ label: row.name,
473
+ href: `/projects/${deriveProjectUrlKey(row.name, row.id)}`,
474
+ status: row.status,
475
+ });
476
+ }
477
+ }
478
+ const environmentIds = collectTargetIds(bindings, "environment", { uuidOnly: true });
479
+ if (environmentIds.length > 0) {
480
+ const rows = await db
481
+ .select({
482
+ id: environments.id,
483
+ name: environments.name,
484
+ status: environments.status,
485
+ })
486
+ .from(environments)
487
+ .where(and(eq(environments.companyId, companyId), inArray(environments.id, environmentIds)));
488
+ for (const row of rows) {
489
+ setTarget({
490
+ type: "environment",
491
+ id: row.id,
492
+ label: row.name,
493
+ href: "/company/settings/environments",
494
+ status: row.status,
495
+ });
496
+ }
497
+ }
498
+ const routineIds = collectTargetIds(bindings, "routine", { uuidOnly: true });
499
+ if (routineIds.length > 0) {
500
+ const rows = await db
501
+ .select({
502
+ id: routines.id,
503
+ title: routines.title,
504
+ status: routines.status,
505
+ })
506
+ .from(routines)
507
+ .where(and(eq(routines.companyId, companyId), inArray(routines.id, routineIds)));
508
+ for (const row of rows) {
509
+ setTarget({
510
+ type: "routine",
511
+ id: row.id,
512
+ label: row.title,
513
+ href: `/routines/${row.id}`,
514
+ status: row.status,
515
+ });
516
+ }
517
+ }
518
+ const issueIds = collectTargetIds(bindings, "issue", { uuidOnly: true });
519
+ if (issueIds.length > 0) {
520
+ const rows = await db
521
+ .select({
522
+ id: issues.id,
523
+ identifier: issues.identifier,
524
+ title: issues.title,
525
+ status: issues.status,
526
+ })
527
+ .from(issues)
528
+ .where(and(eq(issues.companyId, companyId), inArray(issues.id, issueIds)));
529
+ for (const row of rows) {
530
+ setTarget({
531
+ type: "issue",
532
+ id: row.id,
533
+ label: row.identifier ? `${row.identifier} ${row.title}` : row.title,
534
+ href: `/issues/${row.identifier ?? row.id}`,
535
+ status: row.status,
536
+ });
537
+ }
538
+ }
539
+ const runIds = collectTargetIds(bindings, "run", { uuidOnly: true });
540
+ if (runIds.length > 0) {
541
+ const rows = await db
542
+ .select({
543
+ id: heartbeatRuns.id,
544
+ agentId: heartbeatRuns.agentId,
545
+ status: heartbeatRuns.status,
546
+ })
547
+ .from(heartbeatRuns)
548
+ .where(and(eq(heartbeatRuns.companyId, companyId), inArray(heartbeatRuns.id, runIds)));
549
+ for (const row of rows) {
550
+ setTarget({
551
+ type: "run",
552
+ id: row.id,
553
+ label: `Run ${row.id.slice(0, 8)}`,
554
+ href: `/agents/${row.agentId}/runs/${row.id}`,
555
+ status: row.status,
556
+ });
557
+ }
558
+ }
559
+ return targetMap;
560
+ }
561
+ async function buildRemoteImportConflictMaps(companyId, provider) {
562
+ const activeSecrets = await db
563
+ .select({
564
+ id: companySecrets.id,
565
+ name: companySecrets.name,
566
+ key: companySecrets.key,
567
+ provider: companySecrets.provider,
568
+ providerConfigId: companySecrets.providerConfigId,
569
+ externalRef: companySecrets.externalRef,
570
+ status: companySecrets.status,
571
+ })
572
+ .from(companySecrets)
573
+ .where(and(eq(companySecrets.companyId, companyId), ne(companySecrets.status, "deleted")));
574
+ return {
575
+ byProviderConfigExternalRef: new Map(activeSecrets
576
+ .filter((secret) => secret.provider === provider &&
577
+ typeof secret.externalRef === "string" &&
578
+ secret.externalRef.trim())
579
+ .map((secret) => [
580
+ remoteImportExternalRefKey(secret.providerConfigId, secret.externalRef),
581
+ secret,
582
+ ])),
583
+ byName: new Map(activeSecrets.map((secret) => [secret.name, secret])),
584
+ byKey: new Map(activeSecrets.map((secret) => [secret.key, secret])),
585
+ };
586
+ }
587
+ function remoteImportExternalRefKey(providerConfigId, externalRef) {
588
+ return `${providerConfigId ?? "default"}\0${externalRef.trim()}`;
589
+ }
590
+ function sanitizeRemoteProviderMetadata(provider, metadata) {
591
+ if (!metadata || provider !== "aws_secrets_manager")
592
+ return null;
593
+ const safe = {};
594
+ for (const key of ["createdDate", "lastAccessedDate", "lastChangedDate", "deletedDate"]) {
595
+ const value = metadata[key];
596
+ if (typeof value === "string" || value === null)
597
+ safe[key] = value;
598
+ }
599
+ for (const key of ["hasDescription", "hasKmsKey", "tagCount"]) {
600
+ const value = metadata[key];
601
+ if (typeof value === "boolean" || typeof value === "number")
602
+ safe[key] = value;
603
+ }
604
+ return Object.keys(safe).length > 0 ? safe : null;
605
+ }
606
+ function remoteImportConflictsFor(input) {
607
+ const conflicts = [];
608
+ const duplicate = input.maps.byProviderConfigExternalRef.get(remoteImportExternalRefKey(input.providerConfigId, input.externalRef));
609
+ if (duplicate) {
610
+ conflicts.push({
611
+ type: "exact_reference",
612
+ existingSecretId: duplicate.id,
613
+ message: "An existing secret already links this exact provider reference.",
614
+ });
615
+ return conflicts;
616
+ }
617
+ const nameConflict = input.maps.byName.get(input.name);
618
+ if (nameConflict) {
619
+ conflicts.push({
620
+ type: "name",
621
+ existingSecretId: nameConflict.id,
622
+ message: `Secret name already exists: ${input.name}`,
623
+ });
624
+ }
625
+ const keyConflict = input.maps.byKey.get(input.key);
626
+ if (keyConflict) {
627
+ conflicts.push({
628
+ type: "key",
629
+ existingSecretId: keyConflict.id,
630
+ message: `Secret key already exists: ${input.key}`,
631
+ });
632
+ }
633
+ return conflicts;
634
+ }
635
+ async function getRemoteImportProviderConfig(companyId, providerConfigId) {
636
+ const providerConfig = await getProviderConfigById(providerConfigId);
637
+ if (!providerConfig)
638
+ throw notFound("Provider vault not found");
639
+ const provider = providerConfig.provider;
640
+ assertSelectableProviderConfig(providerConfig, companyId, provider);
641
+ return { providerConfig, provider, runtimeConfig: toProviderVaultRuntimeConfig(providerConfig) };
642
+ }
113
643
  return {
114
644
  listProviders: () => listSecretProviders(),
115
- list: (companyId) => db
645
+ checkProviders: () => checkSecretProviders(),
646
+ listProviderConfigs: (companyId) => db
116
647
  .select()
117
- .from(companySecrets)
118
- .where(eq(companySecrets.companyId, companyId))
119
- .orderBy(desc(companySecrets.createdAt)),
648
+ .from(companySecretProviderConfigs)
649
+ .where(eq(companySecretProviderConfigs.companyId, companyId))
650
+ .orderBy(desc(companySecretProviderConfigs.createdAt)),
651
+ getProviderConfigById,
652
+ createProviderConfig: async (companyId, input, actor) => {
653
+ const parsed = createSecretProviderConfigSchema.safeParse(input);
654
+ if (!parsed.success)
655
+ throw unprocessable("Invalid provider vault config", parsed.error.flatten());
656
+ const status = input.status ?? defaultProviderConfigStatus(input.provider);
657
+ if ((status === "coming_soon" || status === "disabled") && input.isDefault) {
658
+ throw unprocessable("Only ready or warning provider vaults can be default");
659
+ }
660
+ const normalizedConfig = validateProviderConfigPayload(input.provider, input.config ?? {});
661
+ return db.transaction(async (tx) => {
662
+ if (input.isDefault) {
663
+ await tx
664
+ .update(companySecretProviderConfigs)
665
+ .set({ isDefault: false, updatedAt: new Date() })
666
+ .where(and(eq(companySecretProviderConfigs.companyId, companyId), eq(companySecretProviderConfigs.provider, input.provider)));
667
+ }
668
+ return tx
669
+ .insert(companySecretProviderConfigs)
670
+ .values({
671
+ companyId,
672
+ provider: input.provider,
673
+ displayName: input.displayName.trim(),
674
+ status,
675
+ isDefault: input.isDefault ?? false,
676
+ config: normalizedConfig,
677
+ disabledAt: status === "disabled" ? new Date() : null,
678
+ createdByAgentId: actor?.agentId ?? null,
679
+ createdByUserId: actor?.userId ?? null,
680
+ })
681
+ .returning()
682
+ .then((rows) => rows[0]);
683
+ });
684
+ },
685
+ updateProviderConfig: async (id, patch) => {
686
+ const existing = await getProviderConfigById(id);
687
+ if (!existing)
688
+ return null;
689
+ const parsed = updateSecretProviderConfigSchema.safeParse(patch);
690
+ if (!parsed.success)
691
+ throw unprocessable("Invalid provider vault config", parsed.error.flatten());
692
+ const provider = existing.provider;
693
+ const status = patch.status ?? existing.status;
694
+ if (COMING_SOON_SECRET_PROVIDERS.has(provider) && status !== "coming_soon" && status !== "disabled") {
695
+ throw unprocessable(`${provider} provider vaults are locked while coming soon`);
696
+ }
697
+ if ((status === "coming_soon" || status === "disabled") && patch.isDefault) {
698
+ throw unprocessable("Only ready or warning provider vaults can be default");
699
+ }
700
+ const normalizedConfig = patch.config === undefined
701
+ ? existing.config
702
+ : validateProviderConfigPayload(provider, patch.config);
703
+ return db.transaction(async (tx) => {
704
+ if (patch.isDefault) {
705
+ await tx
706
+ .update(companySecretProviderConfigs)
707
+ .set({ isDefault: false, updatedAt: new Date() })
708
+ .where(and(eq(companySecretProviderConfigs.companyId, existing.companyId), eq(companySecretProviderConfigs.provider, existing.provider)));
709
+ }
710
+ return tx
711
+ .update(companySecretProviderConfigs)
712
+ .set({
713
+ displayName: patch.displayName?.trim() ?? existing.displayName,
714
+ status,
715
+ isDefault: status === "disabled" || status === "coming_soon" ? false : patch.isDefault ?? existing.isDefault,
716
+ config: normalizedConfig,
717
+ disabledAt: status === "disabled" ? existing.disabledAt ?? new Date() : null,
718
+ updatedAt: new Date(),
719
+ })
720
+ .where(eq(companySecretProviderConfigs.id, id))
721
+ .returning()
722
+ .then((rows) => rows[0] ?? null);
723
+ });
724
+ },
725
+ disableProviderConfig: async (id) => {
726
+ const existing = await getProviderConfigById(id);
727
+ if (!existing)
728
+ return null;
729
+ return db
730
+ .update(companySecretProviderConfigs)
731
+ .set({
732
+ status: "disabled",
733
+ isDefault: false,
734
+ disabledAt: existing.disabledAt ?? new Date(),
735
+ updatedAt: new Date(),
736
+ })
737
+ .where(eq(companySecretProviderConfigs.id, id))
738
+ .returning()
739
+ .then((rows) => rows[0] ?? null);
740
+ },
741
+ setDefaultProviderConfig: async (id) => {
742
+ const existing = await getProviderConfigById(id);
743
+ if (!existing)
744
+ return null;
745
+ if (existing.status === "coming_soon" || existing.status === "disabled") {
746
+ throw unprocessable("Only ready or warning provider vaults can be default");
747
+ }
748
+ return db.transaction(async (tx) => {
749
+ const current = await tx
750
+ .select()
751
+ .from(companySecretProviderConfigs)
752
+ .where(eq(companySecretProviderConfigs.id, id))
753
+ .then((rows) => rows[0] ?? null);
754
+ if (!current)
755
+ return null;
756
+ if (current.status === "coming_soon" || current.status === "disabled") {
757
+ throw unprocessable("Only ready or warning provider vaults can be default");
758
+ }
759
+ await tx
760
+ .update(companySecretProviderConfigs)
761
+ .set({ isDefault: false, updatedAt: new Date() })
762
+ .where(and(eq(companySecretProviderConfigs.companyId, current.companyId), eq(companySecretProviderConfigs.provider, current.provider)));
763
+ const updated = await tx
764
+ .update(companySecretProviderConfigs)
765
+ .set({ isDefault: true, updatedAt: new Date() })
766
+ .where(and(eq(companySecretProviderConfigs.id, id), notInArray(companySecretProviderConfigs.status, ["coming_soon", "disabled"])))
767
+ .returning()
768
+ .then((rows) => rows[0] ?? null);
769
+ if (!updated)
770
+ throw unprocessable("Only ready or warning provider vaults can be default");
771
+ return updated;
772
+ });
773
+ },
774
+ checkProviderConfigHealth: async (id) => {
775
+ const existing = await getProviderConfigById(id);
776
+ if (!existing)
777
+ return null;
778
+ const checkedAt = new Date();
779
+ const staticHealth = providerConfigHealth({
780
+ id: existing.id,
781
+ provider: existing.provider,
782
+ status: existing.status,
783
+ config: existing.config ?? {},
784
+ });
785
+ const provider = getSecretProvider(existing.provider);
786
+ const health = staticHealth ?? mapProviderModuleHealth({
787
+ configId: existing.id,
788
+ provider: existing.provider,
789
+ providerStatus: existing.status,
790
+ health: await provider.healthCheck({
791
+ providerConfig: toProviderVaultRuntimeConfig(existing),
792
+ }),
793
+ });
794
+ await db
795
+ .update(companySecretProviderConfigs)
796
+ .set({
797
+ healthStatus: health.status,
798
+ healthCheckedAt: checkedAt,
799
+ healthMessage: health.message,
800
+ healthDetails: health.details,
801
+ updatedAt: new Date(),
802
+ })
803
+ .where(eq(companySecretProviderConfigs.id, id));
804
+ return { ...health, checkedAt };
805
+ },
806
+ list: async (companyId) => {
807
+ const [secrets, referenceCounts] = await Promise.all([
808
+ db
809
+ .select()
810
+ .from(companySecrets)
811
+ .where(and(eq(companySecrets.companyId, companyId), ne(companySecrets.status, "deleted")))
812
+ .orderBy(desc(companySecrets.createdAt)),
813
+ db
814
+ .select({
815
+ secretId: companySecretBindings.secretId,
816
+ count: sql `count(*)::int`,
817
+ })
818
+ .from(companySecretBindings)
819
+ .where(eq(companySecretBindings.companyId, companyId))
820
+ .groupBy(companySecretBindings.secretId),
821
+ ]);
822
+ const countsBySecretId = new Map(referenceCounts.map((row) => [row.secretId, row.count]));
823
+ return secrets.map((secret) => ({
824
+ ...secret,
825
+ referenceCount: countsBySecretId.get(secret.id) ?? 0,
826
+ }));
827
+ },
828
+ listBindings: (companyId, secretId) => db
829
+ .select()
830
+ .from(companySecretBindings)
831
+ .where(secretId
832
+ ? and(eq(companySecretBindings.companyId, companyId), eq(companySecretBindings.secretId, secretId))
833
+ : eq(companySecretBindings.companyId, companyId))
834
+ .orderBy(desc(companySecretBindings.createdAt)),
835
+ listBindingReferences: async (companyId, secretId) => {
836
+ const bindings = await db
837
+ .select()
838
+ .from(companySecretBindings)
839
+ .where(and(eq(companySecretBindings.companyId, companyId), eq(companySecretBindings.secretId, secretId)))
840
+ .orderBy(desc(companySecretBindings.createdAt));
841
+ const targetMap = await buildBindingTargetMap(companyId, bindings);
842
+ return bindings.map((binding) => ({
843
+ ...binding,
844
+ target: targetMap.get(`${binding.targetType}:${binding.targetId}`) ??
845
+ fallbackBindingTarget(binding),
846
+ }));
847
+ },
848
+ listAccessEvents: (companyId, secretId) => db
849
+ .select()
850
+ .from(secretAccessEvents)
851
+ .where(and(eq(secretAccessEvents.companyId, companyId), eq(secretAccessEvents.secretId, secretId)))
852
+ .orderBy(desc(secretAccessEvents.createdAt)),
853
+ previewRemoteImport: async (companyId, input) => {
854
+ const { providerConfig, provider: providerId, runtimeConfig } = await getRemoteImportProviderConfig(companyId, input.providerConfigId);
855
+ const provider = getSecretProvider(providerId);
856
+ if (!provider.listRemoteSecrets) {
857
+ throw unprocessable(`${providerId} provider does not support remote import listing`);
858
+ }
859
+ let listed;
860
+ try {
861
+ listed = await provider.listRemoteSecrets({
862
+ providerConfig: runtimeConfig,
863
+ query: input.query,
864
+ nextToken: input.nextToken,
865
+ pageSize: input.pageSize,
866
+ });
867
+ }
868
+ catch (error) {
869
+ throw remoteProviderHttpError(error, {
870
+ companyId,
871
+ provider: providerId,
872
+ providerConfigId: providerConfig.id,
873
+ operation: "remote_import.preview",
874
+ });
875
+ }
876
+ const maps = await buildRemoteImportConflictMaps(companyId, providerId);
877
+ const candidates = [];
878
+ for (const remote of listed.secrets) {
879
+ const externalRef = remote.externalRef.trim();
880
+ const remoteName = remote.name.trim() || deriveSecretNameFromExternalRef(externalRef);
881
+ const name = remoteName || deriveSecretNameFromExternalRef(externalRef);
882
+ const key = normalizeSecretKey(name);
883
+ let canonicalExternalRef = externalRef;
884
+ const conflicts = [];
885
+ try {
886
+ const prepared = await provider.linkExternalSecret({
887
+ externalRef,
888
+ providerVersionRef: remote.providerVersionRef ?? null,
889
+ providerConfig: runtimeConfig,
890
+ context: {
891
+ companyId,
892
+ secretKey: key || "remote-import-preview",
893
+ secretName: name,
894
+ version: 1,
895
+ },
896
+ });
897
+ canonicalExternalRef = prepared.externalRef ?? externalRef;
898
+ }
899
+ catch (error) {
900
+ conflicts.push({
901
+ type: "provider_guardrail",
902
+ message: remoteImportRowFailureReason(error, "Provider rejected this external reference", {
903
+ companyId,
904
+ provider: providerId,
905
+ providerConfigId: providerConfig.id,
906
+ operation: "remote_import.preview.link_external_reference",
907
+ }),
908
+ });
909
+ }
910
+ conflicts.push(...remoteImportConflictsFor({
911
+ providerConfigId: providerConfig.id,
912
+ externalRef: canonicalExternalRef,
913
+ name,
914
+ key,
915
+ maps,
916
+ }));
917
+ const hasDuplicate = conflicts.some((conflict) => conflict.type === "exact_reference");
918
+ const hasConflict = conflicts.length > 0;
919
+ candidates.push({
920
+ externalRef,
921
+ remoteName,
922
+ name,
923
+ key,
924
+ providerVersionRef: remote.providerVersionRef ?? null,
925
+ providerMetadata: sanitizeRemoteProviderMetadata(providerId, remote.metadata),
926
+ status: hasDuplicate ? "duplicate" : hasConflict ? "conflict" : "ready",
927
+ importable: !hasConflict,
928
+ conflicts,
929
+ });
930
+ }
931
+ return {
932
+ providerConfigId: providerConfig.id,
933
+ provider: providerId,
934
+ nextToken: listed.nextToken ?? null,
935
+ candidates,
936
+ };
937
+ },
938
+ importRemoteSecrets: async (companyId, input, actor) => {
939
+ const { providerConfig, provider: providerId, runtimeConfig } = await getRemoteImportProviderConfig(companyId, input.providerConfigId);
940
+ const provider = getSecretProvider(providerId);
941
+ if (provider.descriptor().supportsExternalReferences === false) {
942
+ throw unprocessable(`${providerId} provider does not support linked external references`);
943
+ }
944
+ const maps = await buildRemoteImportConflictMaps(companyId, providerId);
945
+ const results = [];
946
+ for (const selection of input.secrets) {
947
+ const externalRef = selection.externalRef.trim();
948
+ const name = selection.name?.trim() || deriveSecretNameFromExternalRef(externalRef);
949
+ const key = normalizeSecretKey(selection.key?.trim() || name);
950
+ const description = selection.description?.trim() || null;
951
+ let prepared;
952
+ const conflicts = remoteImportConflictsFor({
953
+ providerConfigId: providerConfig.id,
954
+ externalRef,
955
+ name,
956
+ key,
957
+ maps,
958
+ });
959
+ if (!key) {
960
+ results.push({
961
+ externalRef,
962
+ name,
963
+ key,
964
+ status: "error",
965
+ reason: "Secret key is required",
966
+ secretId: null,
967
+ conflicts,
968
+ });
969
+ continue;
970
+ }
971
+ if (conflicts.length === 0) {
972
+ try {
973
+ prepared = await provider.linkExternalSecret({
974
+ externalRef,
975
+ providerVersionRef: selection.providerVersionRef ?? null,
976
+ providerConfig: runtimeConfig,
977
+ context: {
978
+ companyId,
979
+ secretKey: key,
980
+ secretName: name,
981
+ version: 1,
982
+ },
983
+ });
984
+ const canonicalDuplicate = maps.byProviderConfigExternalRef.get(remoteImportExternalRefKey(providerConfig.id, prepared.externalRef ?? externalRef));
985
+ if (canonicalDuplicate) {
986
+ conflicts.push({
987
+ type: "exact_reference",
988
+ existingSecretId: canonicalDuplicate.id,
989
+ message: "An existing secret already links this exact provider reference.",
990
+ });
991
+ }
992
+ }
993
+ catch (error) {
994
+ results.push({
995
+ externalRef,
996
+ name,
997
+ key,
998
+ status: "error",
999
+ reason: remoteImportRowFailureReason(error, "Provider rejected this external reference", {
1000
+ companyId,
1001
+ provider: providerId,
1002
+ providerConfigId: providerConfig.id,
1003
+ operation: "remote_import.prepare_external_reference",
1004
+ }),
1005
+ secretId: null,
1006
+ conflicts: [],
1007
+ });
1008
+ continue;
1009
+ }
1010
+ }
1011
+ if (conflicts.length > 0) {
1012
+ results.push({
1013
+ externalRef,
1014
+ name,
1015
+ key,
1016
+ status: "skipped",
1017
+ reason: conflicts.some((conflict) => conflict.type === "exact_reference")
1018
+ ? "exact_reference_duplicate"
1019
+ : "name_or_key_conflict",
1020
+ secretId: null,
1021
+ conflicts,
1022
+ });
1023
+ continue;
1024
+ }
1025
+ try {
1026
+ if (!prepared) {
1027
+ prepared = await provider.linkExternalSecret({
1028
+ externalRef,
1029
+ providerVersionRef: selection.providerVersionRef ?? null,
1030
+ providerConfig: runtimeConfig,
1031
+ context: {
1032
+ companyId,
1033
+ secretKey: key,
1034
+ secretName: name,
1035
+ version: 1,
1036
+ },
1037
+ });
1038
+ }
1039
+ if (!prepared) {
1040
+ throw unprocessable("Provider rejected this external reference");
1041
+ }
1042
+ const preparedSecret = prepared;
1043
+ const secret = await db.transaction(async (tx) => {
1044
+ const inserted = await tx
1045
+ .insert(companySecrets)
1046
+ .values({
1047
+ companyId,
1048
+ key,
1049
+ name,
1050
+ provider: providerId,
1051
+ providerConfigId: providerConfig.id,
1052
+ status: "active",
1053
+ managedMode: "external_reference",
1054
+ externalRef: preparedSecret.externalRef,
1055
+ providerMetadata: null,
1056
+ latestVersion: 1,
1057
+ description,
1058
+ lastRotatedAt: new Date(),
1059
+ createdByAgentId: actor?.agentId ?? null,
1060
+ createdByUserId: actor?.userId ?? null,
1061
+ })
1062
+ .returning()
1063
+ .then((rows) => rows[0]);
1064
+ await tx.insert(companySecretVersions).values({
1065
+ secretId: inserted.id,
1066
+ version: 1,
1067
+ material: preparedSecret.material,
1068
+ valueSha256: preparedSecret.valueSha256,
1069
+ fingerprintSha256: preparedSecret.fingerprintSha256 ?? preparedSecret.valueSha256,
1070
+ providerVersionRef: preparedSecret.providerVersionRef ?? null,
1071
+ status: "current",
1072
+ createdByAgentId: actor?.agentId ?? null,
1073
+ createdByUserId: actor?.userId ?? null,
1074
+ });
1075
+ return inserted;
1076
+ });
1077
+ maps.byProviderConfigExternalRef.set(remoteImportExternalRefKey(providerConfig.id, preparedSecret.externalRef ?? externalRef), secret);
1078
+ maps.byName.set(name, secret);
1079
+ maps.byKey.set(key, secret);
1080
+ results.push({
1081
+ externalRef,
1082
+ name,
1083
+ key,
1084
+ status: "imported",
1085
+ reason: null,
1086
+ secretId: secret.id,
1087
+ conflicts: [],
1088
+ });
1089
+ }
1090
+ catch (error) {
1091
+ results.push({
1092
+ externalRef,
1093
+ name,
1094
+ key,
1095
+ status: "error",
1096
+ reason: remoteImportRowFailureReason(error, "Import failed", {
1097
+ companyId,
1098
+ provider: providerId,
1099
+ providerConfigId: providerConfig.id,
1100
+ operation: "remote_import.commit",
1101
+ }),
1102
+ secretId: null,
1103
+ conflicts: [],
1104
+ });
1105
+ }
1106
+ }
1107
+ return {
1108
+ providerConfigId: providerConfig.id,
1109
+ provider: providerId,
1110
+ importedCount: results.filter((result) => result.status === "imported").length,
1111
+ skippedCount: results.filter((result) => result.status === "skipped").length,
1112
+ errorCount: results.filter((result) => result.status === "error").length,
1113
+ results,
1114
+ };
1115
+ },
120
1116
  getById,
121
1117
  getByName,
122
1118
  resolveSecretValue,
@@ -124,97 +1120,491 @@ export function secretService(db) {
124
1120
  const existing = await getByName(companyId, input.name);
125
1121
  if (existing)
126
1122
  throw conflict(`Secret already exists: ${input.name}`);
1123
+ const key = normalizeSecretKey(input.key ?? input.name);
1124
+ if (!key)
1125
+ throw unprocessable("Secret key is required");
1126
+ const duplicateKey = await db
1127
+ .select()
1128
+ .from(companySecrets)
1129
+ .where(and(eq(companySecrets.companyId, companyId), eq(companySecrets.key, key), ne(companySecrets.status, "deleted")))
1130
+ .then((rows) => rows[0] ?? null);
1131
+ if (duplicateKey)
1132
+ throw conflict(`Secret key already exists: ${key}`);
1133
+ const managedMode = input.managedMode ?? "paperclip_managed";
127
1134
  const provider = getSecretProvider(input.provider);
128
- const prepared = await provider.createVersion({
129
- value: input.value,
130
- externalRef: input.externalRef ?? null,
1135
+ const providerConfig = await getSelectableRuntimeProviderConfig({
1136
+ companyId,
1137
+ provider: input.provider,
1138
+ providerConfigId: input.providerConfigId,
131
1139
  });
132
- return db.transaction(async (tx) => {
133
- const secret = await tx
134
- .insert(companySecrets)
135
- .values({
136
- companyId,
137
- name: input.name,
138
- provider: input.provider,
1140
+ if (managedMode === "external_reference" && !input.externalRef?.trim()) {
1141
+ throw unprocessable("External reference secrets require externalRef");
1142
+ }
1143
+ if (managedMode === "paperclip_managed" && input.externalRef?.trim()) {
1144
+ throw unprocessable("Managed secrets cannot override externalRef");
1145
+ }
1146
+ if (managedMode === "paperclip_managed" && !input.value?.trim()) {
1147
+ throw unprocessable("Managed secrets require value");
1148
+ }
1149
+ const providerWriteContext = {
1150
+ companyId,
1151
+ secretKey: key,
1152
+ secretName: input.name,
1153
+ version: 1,
1154
+ };
1155
+ const reservedSecret = await db
1156
+ .insert(companySecrets)
1157
+ .values({
1158
+ companyId,
1159
+ key,
1160
+ name: input.name,
1161
+ provider: input.provider,
1162
+ providerConfigId: input.providerConfigId ?? null,
1163
+ status: "archived",
1164
+ managedMode,
1165
+ externalRef: null,
1166
+ providerMetadata: input.providerMetadata ?? null,
1167
+ latestVersion: 0,
1168
+ description: input.description ?? null,
1169
+ createdByAgentId: actor?.agentId ?? null,
1170
+ createdByUserId: actor?.userId ?? null,
1171
+ })
1172
+ .returning()
1173
+ .then((rows) => rows[0]);
1174
+ let prepared;
1175
+ try {
1176
+ prepared =
1177
+ managedMode === "external_reference"
1178
+ ? await provider.linkExternalSecret({
1179
+ externalRef: input.externalRef ?? "",
1180
+ providerVersionRef: input.providerVersionRef ?? null,
1181
+ providerConfig,
1182
+ context: providerWriteContext,
1183
+ })
1184
+ : await provider.createSecret({
1185
+ value: input.value ?? "",
1186
+ externalRef: null,
1187
+ providerConfig,
1188
+ context: providerWriteContext,
1189
+ });
1190
+ }
1191
+ catch (error) {
1192
+ await db.delete(companySecrets).where(eq(companySecrets.id, reservedSecret.id)).catch(() => undefined);
1193
+ throw error;
1194
+ }
1195
+ try {
1196
+ await db
1197
+ .update(companySecrets)
1198
+ .set({
139
1199
  externalRef: prepared.externalRef,
140
1200
  latestVersion: 1,
141
- description: input.description ?? null,
142
- createdByAgentId: actor?.agentId ?? null,
143
- createdByUserId: actor?.userId ?? null,
1201
+ updatedAt: new Date(),
144
1202
  })
145
- .returning()
146
- .then((rows) => rows[0]);
147
- await tx.insert(companySecretVersions).values({
148
- secretId: secret.id,
1203
+ .where(eq(companySecrets.id, reservedSecret.id));
1204
+ await db.insert(companySecretVersions).values({
1205
+ secretId: reservedSecret.id,
149
1206
  version: 1,
150
1207
  material: prepared.material,
151
1208
  valueSha256: prepared.valueSha256,
1209
+ fingerprintSha256: prepared.fingerprintSha256 ?? prepared.valueSha256,
1210
+ providerVersionRef: prepared.providerVersionRef ?? null,
1211
+ status: "disabled",
152
1212
  createdByAgentId: actor?.agentId ?? null,
153
1213
  createdByUserId: actor?.userId ?? null,
154
1214
  });
155
- return secret;
156
- });
1215
+ }
1216
+ catch (error) {
1217
+ if (managedMode === "paperclip_managed") {
1218
+ const cleaned = await cleanupPreparedProviderWrite({
1219
+ provider,
1220
+ prepared,
1221
+ providerConfig,
1222
+ context: providerWriteContext,
1223
+ mode: "delete",
1224
+ operation: "create.prepare_rollback",
1225
+ });
1226
+ if (cleaned) {
1227
+ await db.delete(companySecrets).where(eq(companySecrets.id, reservedSecret.id)).catch(() => undefined);
1228
+ }
1229
+ }
1230
+ else {
1231
+ await db.delete(companySecrets).where(eq(companySecrets.id, reservedSecret.id)).catch(() => undefined);
1232
+ }
1233
+ throw error;
1234
+ }
1235
+ try {
1236
+ return await db.transaction(async (tx) => {
1237
+ await tx
1238
+ .update(companySecretVersions)
1239
+ .set({ status: "current" })
1240
+ .where(and(eq(companySecretVersions.secretId, reservedSecret.id), eq(companySecretVersions.version, 1)));
1241
+ const secret = await tx
1242
+ .update(companySecrets)
1243
+ .set({
1244
+ status: "active",
1245
+ externalRef: prepared.externalRef,
1246
+ latestVersion: 1,
1247
+ lastRotatedAt: new Date(),
1248
+ updatedAt: new Date(),
1249
+ })
1250
+ .where(eq(companySecrets.id, reservedSecret.id))
1251
+ .returning()
1252
+ .then((rows) => rows[0]);
1253
+ if (!secret)
1254
+ throw notFound("Secret not found");
1255
+ return secret;
1256
+ });
1257
+ }
1258
+ catch (error) {
1259
+ if (managedMode === "paperclip_managed") {
1260
+ const cleaned = await cleanupPreparedProviderWrite({
1261
+ provider,
1262
+ prepared,
1263
+ providerConfig,
1264
+ context: providerWriteContext,
1265
+ mode: "delete",
1266
+ operation: "create.rollback",
1267
+ });
1268
+ if (cleaned) {
1269
+ await db.delete(companySecrets).where(eq(companySecrets.id, reservedSecret.id)).catch(() => undefined);
1270
+ }
1271
+ }
1272
+ else {
1273
+ await db.delete(companySecrets).where(eq(companySecrets.id, reservedSecret.id)).catch(() => undefined);
1274
+ }
1275
+ throw error;
1276
+ }
157
1277
  },
158
1278
  rotate: async (secretId, input, actor) => {
159
1279
  const secret = await getById(secretId);
160
1280
  if (!secret)
161
1281
  throw notFound("Secret not found");
162
- const provider = getSecretProvider(secret.provider);
163
- const nextVersion = secret.latestVersion + 1;
164
- const prepared = await provider.createVersion({
165
- value: input.value,
166
- externalRef: input.externalRef ?? secret.externalRef ?? null,
1282
+ if (secret.status !== "active")
1283
+ throw unprocessable("Cannot rotate a non-active secret");
1284
+ const providerId = secret.provider;
1285
+ const provider = getSecretProvider(providerId);
1286
+ const providerConfigId = input.providerConfigId === undefined ? secret.providerConfigId : input.providerConfigId;
1287
+ const providerConfig = await getSelectableRuntimeProviderConfig({
1288
+ companyId: secret.companyId,
1289
+ provider: providerId,
1290
+ providerConfigId,
167
1291
  });
168
- return db.transaction(async (tx) => {
169
- await tx.insert(companySecretVersions).values({
1292
+ const nextVersion = secret.latestVersion + 1;
1293
+ if (secret.managedMode === "external_reference" && !(input.externalRef ?? secret.externalRef)?.trim()) {
1294
+ throw unprocessable("External reference secrets require externalRef");
1295
+ }
1296
+ if (secret.managedMode !== "external_reference" && input.externalRef?.trim()) {
1297
+ throw unprocessable("Managed secrets cannot override externalRef");
1298
+ }
1299
+ if (secret.managedMode !== "external_reference" && !input.value?.trim()) {
1300
+ throw unprocessable("Managed secrets require value");
1301
+ }
1302
+ const providerWriteContext = {
1303
+ companyId: secret.companyId,
1304
+ secretKey: secret.key,
1305
+ secretName: secret.name,
1306
+ version: nextVersion,
1307
+ };
1308
+ const prepared = secret.managedMode === "external_reference"
1309
+ ? await provider.linkExternalSecret({
1310
+ externalRef: input.externalRef ?? secret.externalRef ?? "",
1311
+ providerVersionRef: input.providerVersionRef ?? null,
1312
+ providerConfig,
1313
+ context: providerWriteContext,
1314
+ })
1315
+ : await provider.createVersion({
1316
+ value: input.value ?? "",
1317
+ externalRef: secret.externalRef ?? null,
1318
+ providerConfig,
1319
+ context: providerWriteContext,
1320
+ });
1321
+ try {
1322
+ await db.insert(companySecretVersions).values({
170
1323
  secretId: secret.id,
171
1324
  version: nextVersion,
172
1325
  material: prepared.material,
173
1326
  valueSha256: prepared.valueSha256,
1327
+ fingerprintSha256: prepared.fingerprintSha256 ?? prepared.valueSha256,
1328
+ providerVersionRef: prepared.providerVersionRef ?? null,
1329
+ status: "disabled",
174
1330
  createdByAgentId: actor?.agentId ?? null,
175
1331
  createdByUserId: actor?.userId ?? null,
176
1332
  });
177
- const updated = await tx
178
- .update(companySecrets)
179
- .set({
180
- latestVersion: nextVersion,
181
- externalRef: prepared.externalRef,
182
- updatedAt: new Date(),
183
- })
184
- .where(eq(companySecrets.id, secret.id))
185
- .returning()
186
- .then((rows) => rows[0] ?? null);
187
- if (!updated)
188
- throw notFound("Secret not found");
189
- return updated;
190
- });
1333
+ }
1334
+ catch (error) {
1335
+ if (secret.managedMode !== "external_reference") {
1336
+ await cleanupPreparedProviderWrite({
1337
+ provider,
1338
+ prepared,
1339
+ providerConfig,
1340
+ context: providerWriteContext,
1341
+ mode: "archive",
1342
+ operation: "rotate.prepare_rollback",
1343
+ });
1344
+ }
1345
+ throw error;
1346
+ }
1347
+ try {
1348
+ return await db.transaction(async (tx) => {
1349
+ await tx
1350
+ .update(companySecretVersions)
1351
+ .set({ status: "previous" })
1352
+ .where(and(eq(companySecretVersions.secretId, secret.id), ne(companySecretVersions.version, nextVersion)));
1353
+ await tx
1354
+ .update(companySecretVersions)
1355
+ .set({ status: "current" })
1356
+ .where(and(eq(companySecretVersions.secretId, secret.id), eq(companySecretVersions.version, nextVersion)));
1357
+ const updated = await tx
1358
+ .update(companySecrets)
1359
+ .set({
1360
+ latestVersion: nextVersion,
1361
+ externalRef: prepared.externalRef,
1362
+ providerConfigId,
1363
+ lastRotatedAt: new Date(),
1364
+ updatedAt: new Date(),
1365
+ })
1366
+ .where(eq(companySecrets.id, secret.id))
1367
+ .returning()
1368
+ .then((rows) => rows[0] ?? null);
1369
+ if (!updated)
1370
+ throw notFound("Secret not found");
1371
+ return updated;
1372
+ });
1373
+ }
1374
+ catch (error) {
1375
+ if (secret.managedMode !== "external_reference") {
1376
+ const cleaned = await cleanupPreparedProviderWrite({
1377
+ provider,
1378
+ prepared,
1379
+ providerConfig,
1380
+ context: providerWriteContext,
1381
+ mode: "archive",
1382
+ operation: "rotate.rollback",
1383
+ });
1384
+ if (cleaned) {
1385
+ await db
1386
+ .delete(companySecretVersions)
1387
+ .where(and(eq(companySecretVersions.secretId, secret.id), eq(companySecretVersions.version, nextVersion)))
1388
+ .catch(() => undefined);
1389
+ }
1390
+ }
1391
+ throw error;
1392
+ }
191
1393
  },
192
1394
  update: async (secretId, patch) => {
193
1395
  const secret = await getById(secretId);
194
1396
  if (!secret)
195
1397
  throw notFound("Secret not found");
1398
+ if (secret.status === "deleted")
1399
+ throw notFound("Secret not found");
196
1400
  if (patch.name && patch.name !== secret.name) {
197
1401
  const duplicate = await getByName(secret.companyId, patch.name);
198
1402
  if (duplicate && duplicate.id !== secret.id) {
199
1403
  throw conflict(`Secret already exists: ${patch.name}`);
200
1404
  }
201
1405
  }
1406
+ const nextKey = patch.key ? normalizeSecretKey(patch.key) : secret.key;
1407
+ if (!nextKey)
1408
+ throw unprocessable("Secret key is required");
1409
+ if (nextKey !== secret.key) {
1410
+ const duplicateKey = await db
1411
+ .select()
1412
+ .from(companySecrets)
1413
+ .where(and(eq(companySecrets.companyId, secret.companyId), eq(companySecrets.key, nextKey), ne(companySecrets.status, "deleted")))
1414
+ .then((rows) => rows[0] ?? null);
1415
+ if (duplicateKey && duplicateKey.id !== secret.id) {
1416
+ throw conflict(`Secret key already exists: ${nextKey}`);
1417
+ }
1418
+ }
1419
+ const deleting = patch.status === "deleted";
1420
+ if (deleting && secret.managedMode === "paperclip_managed") {
1421
+ throw unprocessable("Managed secrets must be deleted through DELETE /secrets/:id");
1422
+ }
1423
+ if (secret.managedMode !== "external_reference" && patch.externalRef !== undefined) {
1424
+ throw unprocessable("Managed secrets cannot override externalRef");
1425
+ }
1426
+ if (secret.managedMode === "external_reference" &&
1427
+ patch.externalRef !== undefined &&
1428
+ patch.externalRef !== secret.externalRef) {
1429
+ throw unprocessable("External reference secrets cannot be retargeted through generic update");
1430
+ }
1431
+ if (secret.managedMode === "external_reference" &&
1432
+ patch.providerConfigId !== undefined &&
1433
+ patch.providerConfigId !== secret.providerConfigId) {
1434
+ throw unprocessable("External reference secrets cannot change provider vault through generic update");
1435
+ }
1436
+ if (secret.managedMode === "paperclip_managed" &&
1437
+ patch.providerConfigId !== undefined &&
1438
+ patch.providerConfigId !== secret.providerConfigId) {
1439
+ throw unprocessable("Managed secrets cannot change provider vault through PATCH; use rotate() to migrate to a new vault");
1440
+ }
1441
+ if (patch.providerConfigId !== undefined) {
1442
+ await assertProviderConfigForSecret(secret.companyId, secret.provider, patch.providerConfigId);
1443
+ }
202
1444
  return db
203
1445
  .update(companySecrets)
204
1446
  .set({
205
- name: patch.name ?? secret.name,
1447
+ key: deleting ? `${secret.key}__deleted__${secret.id}` : nextKey,
1448
+ name: deleting ? `${secret.name}__deleted__${secret.id}` : patch.name ?? secret.name,
1449
+ status: patch.status ?? secret.status,
1450
+ providerConfigId: patch.providerConfigId === undefined ? secret.providerConfigId : patch.providerConfigId,
206
1451
  description: patch.description === undefined ? secret.description : patch.description,
207
1452
  externalRef: patch.externalRef === undefined ? secret.externalRef : patch.externalRef,
1453
+ providerMetadata: patch.providerMetadata === undefined ? secret.providerMetadata : patch.providerMetadata,
1454
+ deletedAt: deleting ? new Date() : secret.deletedAt,
208
1455
  updatedAt: new Date(),
209
1456
  })
210
1457
  .where(eq(companySecrets.id, secret.id))
211
1458
  .returning()
212
1459
  .then((rows) => rows[0] ?? null);
213
1460
  },
1461
+ createBinding: async (input) => {
1462
+ await assertSecretInCompany(input.companyId, input.secretId);
1463
+ const existing = await db
1464
+ .select()
1465
+ .from(companySecretBindings)
1466
+ .where(and(eq(companySecretBindings.companyId, input.companyId), eq(companySecretBindings.targetType, input.targetType), eq(companySecretBindings.targetId, input.targetId), eq(companySecretBindings.configPath, input.configPath)))
1467
+ .then((rows) => rows[0] ?? null);
1468
+ if (existing)
1469
+ throw conflict(`Secret binding already exists at ${input.configPath}`);
1470
+ return db
1471
+ .insert(companySecretBindings)
1472
+ .values({
1473
+ companyId: input.companyId,
1474
+ secretId: input.secretId,
1475
+ targetType: input.targetType,
1476
+ targetId: input.targetId,
1477
+ configPath: input.configPath,
1478
+ versionSelector: String(input.versionSelector ?? "latest"),
1479
+ required: input.required ?? true,
1480
+ label: input.label ?? null,
1481
+ })
1482
+ .returning()
1483
+ .then((rows) => rows[0]);
1484
+ },
1485
+ syncSecretRefsForTarget: async (companyId, target, refs) => {
1486
+ const normalizedRefs = [];
1487
+ for (const ref of refs) {
1488
+ await assertSecretInCompany(companyId, ref.secretId);
1489
+ normalizedRefs.push({
1490
+ secretId: ref.secretId,
1491
+ configPath: ref.configPath,
1492
+ versionSelector: ref.versionSelector ?? "latest",
1493
+ required: ref.required ?? true,
1494
+ label: ref.label ?? null,
1495
+ });
1496
+ }
1497
+ const pathPrefixes = [...new Set(normalizedRefs.map((ref) => ref.configPath.split(".")[0]))];
1498
+ await db.transaction(async (tx) => {
1499
+ if (pathPrefixes.length > 0) {
1500
+ for (const pathPrefix of pathPrefixes) {
1501
+ await tx
1502
+ .delete(companySecretBindings)
1503
+ .where(and(eq(companySecretBindings.companyId, companyId), eq(companySecretBindings.targetType, target.targetType), eq(companySecretBindings.targetId, target.targetId), like(companySecretBindings.configPath, `${pathPrefix}.%`)));
1504
+ }
1505
+ }
1506
+ else {
1507
+ await tx
1508
+ .delete(companySecretBindings)
1509
+ .where(and(eq(companySecretBindings.companyId, companyId), eq(companySecretBindings.targetType, target.targetType), eq(companySecretBindings.targetId, target.targetId)));
1510
+ }
1511
+ if (normalizedRefs.length === 0)
1512
+ return;
1513
+ await tx.insert(companySecretBindings).values(normalizedRefs.map((ref) => ({
1514
+ companyId,
1515
+ secretId: ref.secretId,
1516
+ targetType: target.targetType,
1517
+ targetId: target.targetId,
1518
+ configPath: ref.configPath,
1519
+ versionSelector: String(ref.versionSelector),
1520
+ required: ref.required,
1521
+ label: ref.label,
1522
+ })));
1523
+ });
1524
+ return normalizedRefs;
1525
+ },
1526
+ syncEnvBindingsForTarget: async (companyId, target, envValue) => {
1527
+ const record = asRecord(envValue) ?? {};
1528
+ const refs = [];
1529
+ const pathPrefix = target.pathPrefix ?? "env";
1530
+ for (const [key, rawBinding] of Object.entries(record)) {
1531
+ const parsed = envBindingSchema.safeParse(rawBinding);
1532
+ if (!parsed.success)
1533
+ continue;
1534
+ const binding = canonicalizeBinding(parsed.data);
1535
+ if (binding.type !== "secret_ref")
1536
+ continue;
1537
+ await assertSecretInCompany(companyId, binding.secretId);
1538
+ refs.push({
1539
+ secretId: binding.secretId,
1540
+ configPath: `${pathPrefix}.${key}`,
1541
+ versionSelector: binding.version,
1542
+ });
1543
+ }
1544
+ await db.transaction(async (tx) => {
1545
+ await tx
1546
+ .delete(companySecretBindings)
1547
+ .where(and(eq(companySecretBindings.companyId, companyId), eq(companySecretBindings.targetType, target.targetType), eq(companySecretBindings.targetId, target.targetId), like(companySecretBindings.configPath, `${pathPrefix}.%`)));
1548
+ if (refs.length === 0)
1549
+ return;
1550
+ await tx.insert(companySecretBindings).values(refs.map((ref) => ({
1551
+ companyId,
1552
+ secretId: ref.secretId,
1553
+ targetType: target.targetType,
1554
+ targetId: target.targetId,
1555
+ configPath: ref.configPath,
1556
+ versionSelector: String(ref.versionSelector),
1557
+ required: true,
1558
+ })));
1559
+ });
1560
+ return refs;
1561
+ },
214
1562
  remove: async (secretId) => {
215
1563
  const secret = await getById(secretId);
216
1564
  if (!secret)
217
1565
  return null;
1566
+ const versionRow = await getSecretVersion(secret.id, secret.latestVersion);
1567
+ const providerId = secret.provider;
1568
+ const provider = getSecretProvider(providerId);
1569
+ if (secret.status !== "deleted") {
1570
+ await db
1571
+ .update(companySecrets)
1572
+ .set({
1573
+ key: `${secret.key}__deleted__${secret.id}`,
1574
+ name: `${secret.name}__deleted__${secret.id}`,
1575
+ status: "deleted",
1576
+ deletedAt: secret.deletedAt ?? new Date(),
1577
+ updatedAt: new Date(),
1578
+ })
1579
+ .where(eq(companySecrets.id, secretId));
1580
+ }
1581
+ const providerConfig = secret.providerConfigId
1582
+ ? await getProviderConfigById(secret.providerConfigId)
1583
+ : null;
1584
+ const providerRuntimeConfig = providerConfig && providerConfig.status !== "disabled" && providerConfig.status !== "coming_soon"
1585
+ ? toProviderVaultRuntimeConfig(providerConfig)
1586
+ : null;
1587
+ if (!secret.providerConfigId || providerRuntimeConfig) {
1588
+ try {
1589
+ await provider.deleteOrArchive({
1590
+ material: versionRow?.material,
1591
+ externalRef: secret.externalRef,
1592
+ providerConfig: providerRuntimeConfig,
1593
+ context: {
1594
+ companyId: secret.companyId,
1595
+ secretKey: secret.key,
1596
+ secretName: secret.name,
1597
+ version: secret.latestVersion,
1598
+ },
1599
+ mode: "delete",
1600
+ });
1601
+ }
1602
+ catch (error) {
1603
+ if (!isSecretProviderClientError(error) || error.code !== "not_found") {
1604
+ throw error;
1605
+ }
1606
+ }
1607
+ }
218
1608
  await db.delete(companySecrets).where(eq(companySecrets.id, secretId));
219
1609
  return secret;
220
1610
  },
@@ -228,12 +1618,13 @@ export function secretService(db) {
228
1618
  }
229
1619
  return normalized;
230
1620
  },
231
- resolveEnvBindings: async (companyId, envValue) => {
1621
+ resolveEnvBindings: async (companyId, envValue, context) => {
232
1622
  const record = asRecord(envValue);
233
1623
  if (!record)
234
- return { env: {}, secretKeys: new Set() };
1624
+ return { env: {}, secretKeys: new Set(), manifest: [] };
235
1625
  const resolved = {};
236
1626
  const secretKeys = new Set();
1627
+ const manifest = [];
237
1628
  for (const [key, rawBinding] of Object.entries(record)) {
238
1629
  if (!ENV_KEY_RE.test(key)) {
239
1630
  throw unprocessable(`Invalid environment variable name: ${key}`);
@@ -247,22 +1638,25 @@ export function secretService(db) {
247
1638
  resolved[key] = binding.value;
248
1639
  }
249
1640
  else {
250
- resolved[key] = await resolveSecretValue(companyId, binding.secretId, binding.version);
1641
+ const secretResolution = await resolveSecretValueInternal(companyId, binding.secretId, binding.version, context ? { ...context, configPath: `env.${key}` } : undefined);
1642
+ resolved[key] = secretResolution.value;
1643
+ manifest.push(secretResolution.manifestEntry);
251
1644
  secretKeys.add(key);
252
1645
  }
253
1646
  }
254
- return { env: resolved, secretKeys };
1647
+ return { env: resolved, secretKeys, manifest };
255
1648
  },
256
- resolveAdapterConfigForRuntime: async (companyId, adapterConfig) => {
1649
+ resolveAdapterConfigForRuntime: async (companyId, adapterConfig, context) => {
257
1650
  const resolved = { ...adapterConfig };
258
1651
  const secretKeys = new Set();
1652
+ const manifest = [];
259
1653
  if (!Object.prototype.hasOwnProperty.call(adapterConfig, "env")) {
260
- return { config: resolved, secretKeys };
1654
+ return { config: resolved, secretKeys, manifest };
261
1655
  }
262
1656
  const record = asRecord(adapterConfig.env);
263
1657
  if (!record) {
264
1658
  resolved.env = {};
265
- return { config: resolved, secretKeys };
1659
+ return { config: resolved, secretKeys, manifest };
266
1660
  }
267
1661
  const env = {};
268
1662
  for (const [key, rawBinding] of Object.entries(record)) {
@@ -278,12 +1672,14 @@ export function secretService(db) {
278
1672
  env[key] = binding.value;
279
1673
  }
280
1674
  else {
281
- env[key] = await resolveSecretValue(companyId, binding.secretId, binding.version);
1675
+ const secretResolution = await resolveSecretValueInternal(companyId, binding.secretId, binding.version, context ? { ...context, configPath: `env.${key}` } : undefined);
1676
+ env[key] = secretResolution.value;
1677
+ manifest.push(secretResolution.manifestEntry);
282
1678
  secretKeys.add(key);
283
1679
  }
284
1680
  }
285
1681
  resolved.env = env;
286
- return { config: resolved, secretKeys };
1682
+ return { config: resolved, secretKeys, manifest };
287
1683
  },
288
1684
  };
289
1685
  }