@codemation/host 0.6.0 → 0.8.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 (226) hide show
  1. package/CHANGELOG.md +483 -0
  2. package/dist/{ApiPaths-CLTHphYZ.js → ApiPaths-Dv1dcHu_.js} +4 -4
  3. package/dist/ApiPaths-Dv1dcHu_.js.map +1 -0
  4. package/dist/{AppConfigFactory-YnveXE9k.d.ts → AppConfigFactory-BT0y0LVC.d.ts} +8490 -5548
  5. package/dist/{AppConfigFactory-C6q-CSKb.js → AppConfigFactory-Cx4qQvRk.js} +112 -52
  6. package/dist/AppConfigFactory-Cx4qQvRk.js.map +1 -0
  7. package/dist/{AppContainerFactory-qaqc-R1D.js → AppContainerFactory-DRTjG7nG.js} +7298 -1732
  8. package/dist/AppContainerFactory-DRTjG7nG.js.map +1 -0
  9. package/dist/{CodemationAppContext-DRu1Dpri.d.ts → CodemationAppContext-CGFYVcSb.d.ts} +14 -4
  10. package/dist/{CodemationAuthoring.types-DZl-sJaM.js → CodemationAuthoring.types-BteaR3Dc.js} +19 -6
  11. package/dist/CodemationAuthoring.types-BteaR3Dc.js.map +1 -0
  12. package/dist/{CodemationAuthoring.types-fBRppnmi.d.ts → CodemationAuthoring.types-DiKKogum.d.ts} +30 -5
  13. package/dist/{CodemationConfigNormalizer-DVko3cVN.d.ts → CodemationConfigNormalizer-48f-T66P.d.ts} +3 -3
  14. package/dist/{CodemationConsumerConfigLoader-BeAUS144.js → CodemationConsumerConfigLoader-By-6tuGc.js} +81 -10
  15. package/dist/CodemationConsumerConfigLoader-By-6tuGc.js.map +1 -0
  16. package/dist/{CodemationConsumerConfigLoader-DJWr86f-.d.ts → CodemationConsumerConfigLoader-_PIYqwVx.d.ts} +18 -2
  17. package/dist/{CodemationPluginListMerger-B-W5Fa_X.js → CodemationPluginListMerger-D1B1IEbt.js} +1 -1
  18. package/dist/{CodemationPluginListMerger-B-W5Fa_X.js.map → CodemationPluginListMerger-D1B1IEbt.js.map} +1 -1
  19. package/dist/{CodemationPluginListMerger-DGc-jfa2.d.ts → CodemationPluginListMerger-DP7djJ9S.d.ts} +151 -19
  20. package/dist/CodemationTsyringeTypeInfoRegistrar-Bj6FJYFz.js +97 -0
  21. package/dist/CodemationTsyringeTypeInfoRegistrar-Bj6FJYFz.js.map +1 -0
  22. package/dist/{CodemationWhitelabelConfig-CWbcyQqn.d.ts → CodemationWhitelabelConfig-Ca2mCUeC.d.ts} +2 -2
  23. package/dist/{CollectionContracts.types-DdpHft0i.d.ts → CollectionContracts.types-DDyFYT_D.d.ts} +1 -1
  24. package/dist/{CredentialContractsRegistry-DrMIDSw8.d.ts → CredentialContractsRegistry-Bq2bq28t.d.ts} +2 -2
  25. package/dist/{CredentialServices-UfvHB-rN.d.ts → CredentialServices-BLloBztI.d.ts} +65 -20
  26. package/dist/{CredentialServices-CgxwguAv.js → CredentialServices-Dk8yypeL.js} +310 -51
  27. package/dist/CredentialServices-Dk8yypeL.js.map +1 -0
  28. package/dist/InternalHonoApiRouteRegistrar-c7t3KnV_.d.ts +17 -0
  29. package/dist/InternalPingRegistrar-DY3kSfxP.js +221 -0
  30. package/dist/InternalPingRegistrar-DY3kSfxP.js.map +1 -0
  31. package/dist/{ItemsInputNormalizer-C-KHg9Mo.d.ts → ItemsInputNormalizer-_RwIfRIQ.d.ts} +89 -25
  32. package/dist/{LogLevelPolicyFactory-CampWO0l.d.ts → LogLevelPolicyFactory-ewCHLDLn.d.ts} +2 -2
  33. package/dist/{PublicFrontendBootstrap-DzBgwOnG.d.ts → PublicFrontendBootstrap-Cev3qK46.d.ts} +9 -2
  34. package/dist/PublicFrontendBootstrapFactory-Dv04tJ-6.d.ts +82 -0
  35. package/dist/{PublicFrontendBootstrapJsonCodec-Cl_DLRh0.d.ts → PublicFrontendBootstrapJsonCodec-CXG9Dxft.d.ts} +3 -3
  36. package/dist/{PublicFrontendBootstrapJsonCodec-DzqvA0uo.js → PublicFrontendBootstrapJsonCodec-CegIF_ne.js} +7 -2
  37. package/dist/PublicFrontendBootstrapJsonCodec-CegIF_ne.js.map +1 -0
  38. package/dist/ServerLoggerFactory-Ckk52S3w.js +223 -0
  39. package/dist/ServerLoggerFactory-Ckk52S3w.js.map +1 -0
  40. package/dist/{TelemetryContracts-DbaNomrH.d.ts → TelemetryContracts-BtDx84Cp.d.ts} +13 -4
  41. package/dist/{WorkflowPolicyUiPresentationFactory-DQEY-h_S.d.ts → WorkflowPolicyUiPresentationFactory-6MyjCvBO.d.ts} +2 -2
  42. package/dist/{WorkflowPolicyUiPresentationFactory-DhPqQ9aB.js → WorkflowPolicyUiPresentationFactory-Bb-ae_Zh.js} +1 -1
  43. package/dist/{WorkflowPolicyUiPresentationFactory-DhPqQ9aB.js.map → WorkflowPolicyUiPresentationFactory-Bb-ae_Zh.js.map} +1 -1
  44. package/dist/{WorkflowViewContracts-CzK2KFuz.d.ts → WorkflowViewContracts-B7aFQcIw.d.ts} +10 -1
  45. package/dist/authoring.d.ts +5 -5
  46. package/dist/authoring.js +1 -1
  47. package/dist/client.d.ts +4 -4
  48. package/dist/client.js +2 -2
  49. package/dist/consumer.d.ts +6 -6
  50. package/dist/consumer.js +2 -2
  51. package/dist/credentials.d.ts +6 -6
  52. package/dist/credentials.js +1 -1
  53. package/dist/devServerSidecar.d.ts +2 -2
  54. package/dist/devServerSidecar.js +1 -94
  55. package/dist/devServerSidecar.js.map +1 -1
  56. package/dist/dto.d.ts +6 -6
  57. package/dist/{index-BbBk26m0.d.ts → index-DilAYwnH.d.ts} +49 -3
  58. package/dist/index.d.ts +141 -21
  59. package/dist/index.js +109 -14
  60. package/dist/index.js.map +1 -0
  61. package/dist/mapping.d.ts +2 -2
  62. package/dist/mapping.js +1 -1
  63. package/dist/nextServer.d.ts +42 -113
  64. package/dist/nextServer.js +9 -7
  65. package/dist/pairing.d.ts +93 -0
  66. package/dist/pairing.js +5 -0
  67. package/dist/pairing.types-snfZ_OzB.d.ts +19 -0
  68. package/dist/persistenceServer-B71RGvSj.d.ts +30 -0
  69. package/dist/{persistenceServer-CmsIKnO9.js → persistenceServer-C-hH4z6l.js} +2 -2
  70. package/dist/{persistenceServer-CmsIKnO9.js.map → persistenceServer-C-hH4z6l.js.map} +1 -1
  71. package/dist/persistenceServer.d.ts +8 -8
  72. package/dist/persistenceServer.js +3 -3
  73. package/dist/{server-MUNGsBYK.d.ts → server-09PKasWR.d.ts} +21 -6
  74. package/dist/{server-CJFfY67o.js → server-vtRCPgRJ.js} +7 -6
  75. package/dist/{server-CJFfY67o.js.map → server-vtRCPgRJ.js.map} +1 -1
  76. package/dist/server.d.ts +14 -14
  77. package/dist/server.js +13 -11
  78. package/package.json +47 -58
  79. package/prisma/migrations/20260519000000_workflow_audit_log/migration.sql +23 -0
  80. package/prisma/migrations/20260519100000_storage_growth_fixes/migration.sql +61 -0
  81. package/prisma/migrations.sqlite/20260519000000_workflow_audit_log/migration.sql +21 -0
  82. package/prisma/migrations.sqlite/20260519100000_storage_growth_fixes/migration.sql +29 -0
  83. package/prisma/schema.postgresql.prisma +55 -17
  84. package/prisma/schema.sqlite.prisma +55 -17
  85. package/prisma-generated/prisma-postgresql-client/edge.js +33 -5
  86. package/prisma-generated/prisma-postgresql-client/index-browser.js +29 -1
  87. package/prisma-generated/prisma-postgresql-client/index.d.ts +8933 -5716
  88. package/prisma-generated/prisma-postgresql-client/index.js +33 -5
  89. package/prisma-generated/prisma-postgresql-client/package.json +1 -1
  90. package/prisma-generated/prisma-postgresql-client/schema.prisma +38 -0
  91. package/prisma-generated/prisma-sqlite-client/edge.js +33 -5
  92. package/prisma-generated/prisma-sqlite-client/index-browser.js +29 -1
  93. package/prisma-generated/prisma-sqlite-client/index.d.ts +8925 -5713
  94. package/prisma-generated/prisma-sqlite-client/index.js +33 -5
  95. package/prisma-generated/prisma-sqlite-client/package.json +1 -1
  96. package/prisma-generated/prisma-sqlite-client/schema.prisma +38 -0
  97. package/scripts/check-collections.mjs +18 -0
  98. package/scripts/generate-prisma-clients.mjs +20 -11
  99. package/src/application/WorkflowAuditLogPruneScheduler.ts +96 -0
  100. package/src/application/auth/AuthenticatedPrincipal.ts +4 -0
  101. package/src/application/commands/StartWorkflowRunCommandHandler.ts +4 -0
  102. package/src/application/contracts/WorkflowViewContracts.ts +6 -0
  103. package/src/application/contracts/WorkflowWebsocketMessage.ts +3 -1
  104. package/src/application/mapping/WorkflowDefinitionMapper.ts +40 -1
  105. package/src/application/runs/WorkflowRunRetentionPruneScheduler.ts +7 -1
  106. package/src/application/telemetry/OtelExecutionTelemetry.types.ts +5 -0
  107. package/src/application/telemetry/OtelExecutionTelemetryFactory.ts +4 -0
  108. package/src/application/telemetry/StoredTelemetrySpanScope.ts +6 -2
  109. package/src/application/telemetry/TelemetryRetentionTimestampFactory.ts +27 -17
  110. package/src/application/telemetry/TelemetrySpanPublisher.ts +11 -0
  111. package/src/application/websocket/TelemetrySpanWebsocketRelay.ts +31 -0
  112. package/src/applicationTokens.ts +20 -1
  113. package/src/audit/IAuditEmitter.ts +32 -0
  114. package/src/audit/PrismaWorkflowAuditLogRepository.ts +34 -0
  115. package/src/audit/WorkflowAuditLogWriter.ts +125 -0
  116. package/src/auth/managed/ManagedAuthConfig.ts +29 -0
  117. package/src/auth/managed/ManagedAuthMiddleware.ts +52 -0
  118. package/src/auth/managed/ManagedCorsMiddleware.ts +43 -0
  119. package/src/auth/managed/ManagedModeBootGuard.ts +27 -0
  120. package/src/auth/managed/index.ts +5 -0
  121. package/src/bootstrap/AppContainerFactory.ts +295 -29
  122. package/src/bootstrap/AppContainerLifecycle.ts +31 -0
  123. package/src/bootstrap/perf/BootTimer.ts +168 -0
  124. package/src/bootstrap/runtime/AppConfigFactory.ts +21 -65
  125. package/src/bootstrap/runtime/FrontendRuntime.ts +4 -1
  126. package/src/bootstrap/runtime/HeadlessApiRuntime.ts +47 -0
  127. package/src/bootstrap/runtime/WorkerRuntime.ts +2 -1
  128. package/src/credentials/BrokerClient.ts +49 -0
  129. package/src/credentials/BrokerRefreshError.ts +12 -0
  130. package/src/credentials/BrokerRefreshInvalidGrantError.ts +13 -0
  131. package/src/credentials/ControlPlaneCatalogFetcher.ts +261 -0
  132. package/src/credentials/CredentialOAuth2MaterialReader.ts +136 -0
  133. package/src/credentials/InternalCredentialsListRegistrar.ts +48 -0
  134. package/src/credentials/InternalCredentialsPushRegistrar.ts +125 -0
  135. package/src/credentials/LocalOAuthFlowExecutor.ts +316 -0
  136. package/src/credentials/ManagedOAuthFlowExecutor.ts +94 -0
  137. package/src/credentials/ManagedOAuthRefreshInvalidGrantError.ts +13 -0
  138. package/src/credentials/catalogTypes.ts +4 -0
  139. package/src/credentials/refresh/CredentialDisconnectedError.ts +11 -0
  140. package/src/domain/credentials/CredentialBindingService.ts +54 -2
  141. package/src/domain/credentials/CredentialKeyRotatedError.ts +22 -0
  142. package/src/domain/credentials/CredentialSecretCipher.ts +68 -6
  143. package/src/domain/credentials/CredentialTypeRegistryImpl.ts +117 -10
  144. package/src/domain/credentials/OAuth2RedirectUriResolver.ts +79 -0
  145. package/src/domain/credentials/WorkflowCredentialNodeResolver.ts +14 -5
  146. package/src/domain/telemetry/TelemetryContracts.ts +7 -1
  147. package/src/domain/workflows/WorkflowActivationPreflight.ts +24 -1
  148. package/src/domain/workflows/WorkflowActivationPreflightRules.ts +40 -1
  149. package/src/index.ts +9 -0
  150. package/src/infrastructure/binary/LocalFilesystemBinaryStorageRegistry.ts +29 -1
  151. package/src/infrastructure/binary/S3BinaryStorage.ts +169 -0
  152. package/src/infrastructure/binary/S3BinaryStorageConfig.ts +17 -0
  153. package/src/infrastructure/config/CodemationPluginRegistrar.ts +3 -1
  154. package/src/infrastructure/persistence/CodemationDatabaseUrlParser.ts +41 -0
  155. package/src/infrastructure/persistence/InMemoryTelemetryArtifactStore.ts +8 -3
  156. package/src/infrastructure/persistence/PrismaMigrationDeployer.ts +21 -13
  157. package/src/infrastructure/persistence/PrismaTelemetryArtifactStore.ts +43 -8
  158. package/src/infrastructure/persistence/PrismaWorkflowRunRepository.ts +26 -3
  159. package/src/infrastructure/persistence/PrismaWorkflowSnapshotRepository.ts +48 -0
  160. package/src/mcp/AgentMcpIntegrationImpl.ts +344 -0
  161. package/src/mcp/McpClientFactory.ts +29 -0
  162. package/src/mcp/McpConnectionPool.ts +184 -0
  163. package/src/mcp/McpConnectionPool.types.ts +12 -0
  164. package/src/mcp/McpServerCatalog.ts +104 -0
  165. package/src/mcp/index.ts +5 -0
  166. package/src/pairing/HmacRequestSigner.ts +32 -0
  167. package/src/pairing/IncomingHmacVerifier.ts +82 -0
  168. package/src/pairing/InternalHmacAuthMiddleware.ts +33 -0
  169. package/src/pairing/InternalPingRegistrar.ts +25 -0
  170. package/src/pairing/PairedFetch.ts +33 -0
  171. package/src/pairing/PairingConfigFactory.ts +35 -0
  172. package/src/pairing/PairingConfigToken.ts +6 -0
  173. package/src/pairing/index.ts +14 -0
  174. package/src/pairing/pairing.types.ts +18 -0
  175. package/src/pairing.ts +17 -0
  176. package/src/persistenceServer.ts +1 -0
  177. package/src/presentation/config/AppConfig.ts +7 -1
  178. package/src/presentation/config/CodemationAuthConfig.ts +1 -1
  179. package/src/presentation/config/CodemationAuthoring.types.ts +60 -5
  180. package/src/presentation/config/CodemationConfig.ts +9 -0
  181. package/src/presentation/config/CodemationConfigNormalizer.ts +39 -1
  182. package/src/presentation/config/CodemationPlugin.ts +2 -1
  183. package/src/presentation/frontend/CodemationFrontendAuthSnapshot.ts +5 -0
  184. package/src/presentation/frontend/CodemationFrontendAuthSnapshotFactory.ts +7 -1
  185. package/src/presentation/frontend/PublicFrontendBootstrap.ts +2 -0
  186. package/src/presentation/frontend/PublicFrontendBootstrapFactory.ts +5 -1
  187. package/src/presentation/frontend/PublicFrontendBootstrapJsonCodec.ts +4 -1
  188. package/src/presentation/http/ApiPaths.ts +4 -4
  189. package/src/presentation/http/HeadlessHttpServerFactory.ts +56 -0
  190. package/src/presentation/http/ServerHttpErrorResponseFactory.ts +39 -2
  191. package/src/presentation/http/hono/CodemationHonoApiAppFactory.ts +33 -8
  192. package/src/presentation/http/hono/InternalHonoApiRouteRegistrar.ts +12 -0
  193. package/src/presentation/http/hono/registrars/ManagedMeHonoApiRouteRegistrar.ts +35 -0
  194. package/src/presentation/http/hono/registrars/OAuth2HonoApiRouteRegistrar.ts +2 -2
  195. package/src/presentation/http/routeHandlers/CredentialHttpRouteHandler.ts +28 -0
  196. package/src/presentation/http/routeHandlers/OAuth2HttpRouteHandlerFactory.ts +98 -41
  197. package/src/presentation/server/CodemationConsumerConfigLoader.ts +59 -7
  198. package/src/presentation/server/CodemationPluginDiscovery.ts +5 -0
  199. package/src/presentation/server/WorkflowDefinitionExportsResolver.ts +18 -0
  200. package/src/presentation/server/WorkflowModulePathFinder.ts +12 -1
  201. package/src/presentation/websocket/ManagedWebsocketAuthenticator.ts +50 -0
  202. package/src/presentation/websocket/WebsocketAuthenticator.types.ts +12 -0
  203. package/src/presentation/websocket/WorkflowWebsocketServer.ts +24 -3
  204. package/src/presentation/websocket/WorkflowWebsocketServerFactory.ts +16 -0
  205. package/src/process/ExecaProcessRunner.ts +41 -0
  206. package/src/process/ProcessRunner.types.ts +39 -0
  207. package/src/server.ts +2 -0
  208. package/src/workflows/InternalWorkflowActivationRegistrar.ts +42 -0
  209. package/src/workflows/InternalWorkflowDetailRegistrar.ts +33 -0
  210. package/src/workflows/InternalWorkflowTestRunRegistrar.ts +91 -0
  211. package/src/workflows/InternalWorkflowsListRegistrar.ts +28 -0
  212. package/src/workflows/discovery/WorkflowDirectoryDiscoverer.ts +79 -0
  213. package/tsconfig.json +2 -0
  214. package/vitest.shared.ts +5 -0
  215. package/dist/ApiPaths-CLTHphYZ.js.map +0 -1
  216. package/dist/AppConfigFactory-C6q-CSKb.js.map +0 -1
  217. package/dist/AppContainerFactory-qaqc-R1D.js.map +0 -1
  218. package/dist/CodemationAuthoring.types-DZl-sJaM.js.map +0 -1
  219. package/dist/CodemationConsumerConfigLoader-BeAUS144.js.map +0 -1
  220. package/dist/CredentialServices-CgxwguAv.js.map +0 -1
  221. package/dist/PublicFrontendBootstrapFactory-Cb2pLmDd.d.ts +0 -45
  222. package/dist/PublicFrontendBootstrapJsonCodec-DzqvA0uo.js.map +0 -1
  223. package/dist/ServerLoggerFactory-BKSIh9Xv.js +0 -98
  224. package/dist/ServerLoggerFactory-BKSIh9Xv.js.map +0 -1
  225. package/dist/persistenceServer-vtJAGDat.d.ts +0 -9
  226. package/src/domain/credentials/OAuth2ConnectServiceFactory.ts +0 -411
@@ -0,0 +1,316 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+
3
+ import { inject, injectable } from "@codemation/core";
4
+ import type {
5
+ Clock,
6
+ OAuthFlowCallbackArgs,
7
+ OAuthFlowExecutor,
8
+ OAuthFlowStartArgs,
9
+ OAuthFlowStartResult,
10
+ OAuthMaterial,
11
+ } from "@codemation/core";
12
+
13
+ import { ApplicationTokens } from "../applicationTokens";
14
+ import { CredentialFieldEnvOverlayService } from "../domain/credentials/CredentialFieldEnvOverlayService";
15
+ import type { CredentialStore } from "../domain/credentials/CredentialServices";
16
+ import { CredentialMaterialResolver } from "../domain/credentials/CredentialMaterialResolver";
17
+ import { CredentialTypeRegistryImpl } from "../domain/credentials/CredentialTypeRegistryImpl";
18
+ import { OAuth2ProviderRegistry } from "../domain/credentials/OAuth2ProviderRegistry";
19
+
20
+ type PendingState = Readonly<{
21
+ stateToken: string;
22
+ codeVerifier: string;
23
+ instanceId: string;
24
+ typeId: string;
25
+ redirectUri: string;
26
+ expiresAt: number;
27
+ }>;
28
+
29
+ /**
30
+ * OAuthFlowExecutor for framework (OSS / standalone) mode.
31
+ *
32
+ * Reads clientId from the credential instance's publicConfig and clientSecret
33
+ * from the instance's secret material. Does NOT write tokens back — that is
34
+ * the responsibility of the callback route (a later story).
35
+ */
36
+ @injectable()
37
+ export class LocalOAuthFlowExecutor implements OAuthFlowExecutor {
38
+ private static readonly stateTtlMs = 10 * 60 * 1_000;
39
+
40
+ private readonly pendingStates = new Map<string, PendingState>();
41
+
42
+ constructor(
43
+ @inject(CredentialTypeRegistryImpl)
44
+ private readonly credentialTypeRegistry: CredentialTypeRegistryImpl,
45
+ @inject(ApplicationTokens.CredentialStore)
46
+ private readonly credentialStore: CredentialStore,
47
+ @inject(CredentialMaterialResolver)
48
+ private readonly credentialMaterialResolver: CredentialMaterialResolver,
49
+ @inject(OAuth2ProviderRegistry)
50
+ private readonly oauth2ProviderRegistry: OAuth2ProviderRegistry,
51
+ @inject(CredentialFieldEnvOverlayService)
52
+ private readonly credentialFieldEnvOverlayService: CredentialFieldEnvOverlayService,
53
+ @inject(ApplicationTokens.Clock)
54
+ private readonly clock: Clock,
55
+ ) {}
56
+
57
+ async start(args: OAuthFlowStartArgs): Promise<OAuthFlowStartResult> {
58
+ const { instanceId } = args;
59
+ if (!instanceId) {
60
+ throw new Error("LocalOAuthFlowExecutor.start requires instanceId; create the credential instance first");
61
+ }
62
+
63
+ const instance = await this.credentialStore.getInstance(instanceId);
64
+ if (!instance) {
65
+ throw new Error(`LocalOAuthFlowExecutor: credential instance not found: ${instanceId}`);
66
+ }
67
+
68
+ const credentialType = this.credentialTypeRegistry.getCredentialType(instance.typeId);
69
+ if (!credentialType) {
70
+ throw new Error(`LocalOAuthFlowExecutor: unknown credential type: ${instance.typeId}`);
71
+ }
72
+ if (credentialType.definition.auth?.kind !== "oauth2") {
73
+ throw new Error(`LocalOAuthFlowExecutor: credential type ${instance.typeId} is not an OAuth2 type`);
74
+ }
75
+
76
+ const auth = credentialType.definition.auth;
77
+ const rawMaterial = await this.credentialMaterialResolver.resolveMaterial(instance);
78
+ const { resolvedPublicConfig, resolvedMaterial: material } = this.credentialFieldEnvOverlayService.apply({
79
+ definition: credentialType.definition,
80
+ publicConfig: instance.publicConfig,
81
+ material: rawMaterial,
82
+ });
83
+ const provider = this.oauth2ProviderRegistry.resolve(credentialType.definition, resolvedPublicConfig);
84
+ const clientId = this.oauth2ProviderRegistry.resolveClientId(auth, resolvedPublicConfig);
85
+
86
+ const scopes = args.scopes.length > 0 ? [...args.scopes] : [...auth.scopes];
87
+
88
+ const stateToken = this.createOpaqueValue();
89
+ const codeVerifier = this.createOpaqueValue();
90
+ const codeChallenge = this.createPkceCodeChallenge(codeVerifier);
91
+
92
+ const nowMs = this.clock.now().getTime();
93
+
94
+ // Evict expired entries on each start call to keep the map bounded.
95
+ this.evictExpired(nowMs);
96
+
97
+ const expiresAt = nowMs + LocalOAuthFlowExecutor.stateTtlMs;
98
+ this.pendingStates.set(stateToken, {
99
+ stateToken,
100
+ codeVerifier,
101
+ instanceId,
102
+ typeId: instance.typeId,
103
+ redirectUri: args.redirectUri,
104
+ expiresAt,
105
+ });
106
+
107
+ // Suppress unused-variable lint for material — it's loaded to validate that the
108
+ // clientSecret field is present before starting the flow, but clientSecret itself is
109
+ // only needed at completeCallback / refresh time.
110
+ void material;
111
+
112
+ const url = new URL(provider.authorizeUrl);
113
+ url.searchParams.set("response_type", "code");
114
+ url.searchParams.set("client_id", clientId);
115
+ url.searchParams.set("redirect_uri", args.redirectUri);
116
+ url.searchParams.set("scope", scopes.join(" "));
117
+ url.searchParams.set("state", stateToken);
118
+ url.searchParams.set("code_challenge", codeChallenge);
119
+ url.searchParams.set("code_challenge_method", "S256");
120
+ url.searchParams.set("access_type", "offline");
121
+ url.searchParams.set("prompt", "consent");
122
+
123
+ return { consentUrl: url.toString(), stateToken };
124
+ }
125
+
126
+ lookupInstanceId(stateToken: string): string | undefined {
127
+ return this.pendingStates.get(stateToken)?.instanceId;
128
+ }
129
+
130
+ async completeCallback(args: OAuthFlowCallbackArgs): Promise<OAuthMaterial> {
131
+ const pending = this.pendingStates.get(args.stateToken);
132
+ if (!pending) {
133
+ throw new Error(`LocalOAuthFlowExecutor: state token not found or already used: ${args.stateToken}`);
134
+ }
135
+ if (this.clock.now().getTime() > pending.expiresAt) {
136
+ this.pendingStates.delete(args.stateToken);
137
+ throw new Error("LocalOAuthFlowExecutor: OAuth state token has expired");
138
+ }
139
+ this.pendingStates.delete(args.stateToken);
140
+
141
+ const instance = await this.credentialStore.getInstance(pending.instanceId);
142
+ if (!instance) {
143
+ throw new Error(`LocalOAuthFlowExecutor: credential instance not found: ${pending.instanceId}`);
144
+ }
145
+
146
+ const credentialType = this.credentialTypeRegistry.getCredentialType(instance.typeId);
147
+ if (!credentialType || credentialType.definition.auth?.kind !== "oauth2") {
148
+ throw new Error(`LocalOAuthFlowExecutor: credential type ${instance.typeId} is not an OAuth2 type`);
149
+ }
150
+
151
+ const auth = credentialType.definition.auth;
152
+ const rawMaterial = await this.credentialMaterialResolver.resolveMaterial(instance);
153
+ const { resolvedPublicConfig, resolvedMaterial: material } = this.credentialFieldEnvOverlayService.apply({
154
+ definition: credentialType.definition,
155
+ publicConfig: instance.publicConfig,
156
+ material: rawMaterial,
157
+ });
158
+ const provider = this.oauth2ProviderRegistry.resolve(credentialType.definition, resolvedPublicConfig);
159
+ const clientId = this.oauth2ProviderRegistry.resolveClientId(auth, resolvedPublicConfig);
160
+ const clientSecretFieldKey = this.oauth2ProviderRegistry.resolveClientSecretFieldKey(auth);
161
+ const clientSecret = String(material[clientSecretFieldKey] ?? "");
162
+ if (!clientSecret) {
163
+ throw new Error(`LocalOAuthFlowExecutor: clientSecret missing from secret field "${clientSecretFieldKey}"`);
164
+ }
165
+
166
+ const body = this.buildFormBody({
167
+ grant_type: "authorization_code",
168
+ code: args.code,
169
+ code_verifier: pending.codeVerifier,
170
+ client_id: clientId,
171
+ client_secret: clientSecret,
172
+ redirect_uri: pending.redirectUri,
173
+ });
174
+
175
+ const response = await fetch(provider.tokenUrl, {
176
+ method: "POST",
177
+ headers: { "content-type": "application/x-www-form-urlencoded" },
178
+ body,
179
+ });
180
+
181
+ const text = await response.text();
182
+ const json = this.parseJson(text);
183
+
184
+ if (!response.ok) {
185
+ const msg =
186
+ typeof json.error_description === "string"
187
+ ? json.error_description
188
+ : typeof json.error === "string"
189
+ ? json.error
190
+ : text || "OAuth2 token exchange failed";
191
+ throw new Error(`LocalOAuthFlowExecutor: token exchange failed: ${msg}`);
192
+ }
193
+
194
+ return this.toOAuthMaterial(json);
195
+ }
196
+
197
+ async refresh(args: { typeId: string; instanceId: string; material: OAuthMaterial }): Promise<OAuthMaterial> {
198
+ const { typeId, instanceId, material } = args;
199
+
200
+ const instance = await this.credentialStore.getInstance(instanceId);
201
+ if (!instance) {
202
+ throw new Error(`LocalOAuthFlowExecutor: credential instance not found: ${instanceId}`);
203
+ }
204
+
205
+ const credentialType = this.credentialTypeRegistry.getCredentialType(typeId);
206
+ if (!credentialType || credentialType.definition.auth?.kind !== "oauth2") {
207
+ throw new Error(`LocalOAuthFlowExecutor: credential type ${typeId} is not an OAuth2 type`);
208
+ }
209
+
210
+ const auth = credentialType.definition.auth;
211
+ const rawMaterial = await this.credentialMaterialResolver.resolveMaterial(instance);
212
+ const { resolvedPublicConfig, resolvedMaterial: secretMaterial } = this.credentialFieldEnvOverlayService.apply({
213
+ definition: credentialType.definition,
214
+ publicConfig: instance.publicConfig,
215
+ material: rawMaterial,
216
+ });
217
+ const provider = this.oauth2ProviderRegistry.resolve(credentialType.definition, resolvedPublicConfig);
218
+ const clientId = this.oauth2ProviderRegistry.resolveClientId(auth, resolvedPublicConfig);
219
+ const clientSecretFieldKey = this.oauth2ProviderRegistry.resolveClientSecretFieldKey(auth);
220
+ const clientSecret = String(secretMaterial[clientSecretFieldKey] ?? "");
221
+ if (!clientSecret) {
222
+ throw new Error(`LocalOAuthFlowExecutor: clientSecret missing from secret field "${clientSecretFieldKey}"`);
223
+ }
224
+
225
+ if (!material.refreshToken) {
226
+ throw new Error("LocalOAuthFlowExecutor: no refresh token available");
227
+ }
228
+
229
+ const body = this.buildFormBody({
230
+ grant_type: "refresh_token",
231
+ refresh_token: material.refreshToken,
232
+ client_id: clientId,
233
+ client_secret: clientSecret,
234
+ });
235
+
236
+ const response = await fetch(provider.tokenUrl, {
237
+ method: "POST",
238
+ headers: { "content-type": "application/x-www-form-urlencoded" },
239
+ body,
240
+ });
241
+
242
+ const text = await response.text();
243
+ const json = this.parseJson(text);
244
+
245
+ if (!response.ok) {
246
+ const msg =
247
+ typeof json.error_description === "string"
248
+ ? json.error_description
249
+ : typeof json.error === "string"
250
+ ? json.error
251
+ : text || "OAuth2 refresh failed";
252
+ throw new Error(`LocalOAuthFlowExecutor: token refresh failed: ${msg}`);
253
+ }
254
+
255
+ const refreshed = this.toOAuthMaterial(json);
256
+ // Preserve the existing refresh token if the provider omits it from the response.
257
+ if (!refreshed.refreshToken) {
258
+ return { ...refreshed, refreshToken: material.refreshToken };
259
+ }
260
+ return refreshed;
261
+ }
262
+
263
+ private toOAuthMaterial(json: Record<string, unknown>): OAuthMaterial {
264
+ const accessToken = String(json.access_token ?? "");
265
+ if (!accessToken) {
266
+ throw new Error("LocalOAuthFlowExecutor: token response missing access_token");
267
+ }
268
+ const refreshToken =
269
+ typeof json.refresh_token === "string" && json.refresh_token.length > 0 ? json.refresh_token : undefined;
270
+ const expiresAt = this.resolveExpiresAt(json);
271
+ const grantedScopes =
272
+ typeof json.scope === "string" && json.scope.length > 0
273
+ ? json.scope.split(/\s+/).filter((s) => s.length > 0)
274
+ : [];
275
+ return Object.freeze({ accessToken, refreshToken, expiresAt, grantedScopes });
276
+ }
277
+
278
+ private resolveExpiresAt(json: Record<string, unknown>): string | undefined {
279
+ const expiresIn = Number(json.expires_in);
280
+ if (Number.isFinite(expiresIn) && expiresIn > 0) {
281
+ return new Date(this.clock.now().getTime() + expiresIn * 1000).toISOString();
282
+ }
283
+ return undefined;
284
+ }
285
+
286
+ private parseJson(text: string): Record<string, unknown> {
287
+ try {
288
+ const parsed = JSON.parse(text) as unknown;
289
+ return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
290
+ } catch {
291
+ return {};
292
+ }
293
+ }
294
+
295
+ private buildFormBody(fields: Readonly<Record<string, string>>): string {
296
+ return Object.entries(fields)
297
+ .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
298
+ .join("&");
299
+ }
300
+
301
+ private createOpaqueValue(): string {
302
+ return randomBytes(32).toString("base64url");
303
+ }
304
+
305
+ private createPkceCodeChallenge(codeVerifier: string): string {
306
+ return createHash("sha256").update(codeVerifier).digest("base64url");
307
+ }
308
+
309
+ private evictExpired(nowMs: number): void {
310
+ for (const [key, entry] of this.pendingStates) {
311
+ if (nowMs > entry.expiresAt) {
312
+ this.pendingStates.delete(key);
313
+ }
314
+ }
315
+ }
316
+ }
@@ -0,0 +1,94 @@
1
+ import { inject, injectable } from "@codemation/core";
2
+ import type {
3
+ OAuthFlowCallbackArgs,
4
+ OAuthFlowExecutor,
5
+ OAuthFlowStartArgs,
6
+ OAuthFlowStartResult,
7
+ OAuthMaterial,
8
+ } from "@codemation/core";
9
+ import type { LoggerFactory } from "../application/logging/Logger";
10
+
11
+ import { ApplicationTokens } from "../applicationTokens";
12
+ import { PairedFetch } from "../pairing/PairedFetch";
13
+ import { PairingConfigToken } from "../pairing/PairingConfigToken";
14
+ import type { PairingConfig } from "../pairing/pairing.types";
15
+ import { ManagedOAuthRefreshInvalidGrantError } from "./ManagedOAuthRefreshInvalidGrantError";
16
+
17
+ /**
18
+ * OAuthFlowExecutor for managed mode (paired with a control plane).
19
+ *
20
+ * Delegates the entire OAuth dance to the control plane over HMAC-signed calls.
21
+ * Client secrets never leave the control plane.
22
+ */
23
+ @injectable()
24
+ export class ManagedOAuthFlowExecutor implements OAuthFlowExecutor {
25
+ constructor(
26
+ @inject(PairedFetch) private readonly pairedFetch: PairedFetch,
27
+ @inject(PairingConfigToken) private readonly pairingConfig: PairingConfig,
28
+ @inject(ApplicationTokens.LoggerFactory) private readonly loggers: LoggerFactory,
29
+ ) {}
30
+
31
+ async start(args: OAuthFlowStartArgs): Promise<OAuthFlowStartResult> {
32
+ const logger = this.loggers.create("codemation.credentials.managed-oauth");
33
+ const url = `${this.pairingConfig.controlPlaneUrl}/internal/oauth/start`;
34
+ const response = await this.pairedFetch.post(url, {
35
+ typeId: args.typeId,
36
+ scopes: args.scopes,
37
+ redirectUri: args.redirectUri,
38
+ });
39
+ if (!response.ok) {
40
+ const body = await response.text().catch(() => "");
41
+ const excerpt = body.slice(0, 200);
42
+ logger.warn(`ManagedOAuthFlowExecutor.start failed: ${response.status} ${excerpt}`);
43
+ throw new Error(`ManagedOAuthFlowExecutor.start failed: ${response.status} ${excerpt}`);
44
+ }
45
+ const json = (await response.json()) as { consentUrl: string; stateToken: string };
46
+ return { consentUrl: json.consentUrl, stateToken: json.stateToken };
47
+ }
48
+
49
+ lookupInstanceId(_stateToken: string): string | undefined {
50
+ // Managed mode — state is owned by the control plane, not the host.
51
+ return undefined;
52
+ }
53
+
54
+ async completeCallback(args: OAuthFlowCallbackArgs): Promise<OAuthMaterial> {
55
+ const logger = this.loggers.create("codemation.credentials.managed-oauth");
56
+ const url = `${this.pairingConfig.controlPlaneUrl}/internal/oauth/complete`;
57
+ const response = await this.pairedFetch.post(url, {
58
+ stateToken: args.stateToken,
59
+ code: args.code,
60
+ });
61
+ if (!response.ok) {
62
+ const body = await response.text().catch(() => "");
63
+ const excerpt = body.slice(0, 200);
64
+ logger.warn(`ManagedOAuthFlowExecutor.completeCallback failed: ${response.status} ${excerpt}`);
65
+ throw new Error(`ManagedOAuthFlowExecutor.completeCallback failed: ${response.status} ${excerpt}`);
66
+ }
67
+ return (await response.json()) as OAuthMaterial;
68
+ }
69
+
70
+ async refresh(args: { typeId: string; instanceId: string; material: OAuthMaterial }): Promise<OAuthMaterial> {
71
+ const { typeId, instanceId, material } = args;
72
+ if (!material.refreshToken) {
73
+ throw new Error("ManagedOAuthFlowExecutor.refresh: no refresh token available");
74
+ }
75
+ const url = `${this.pairingConfig.controlPlaneUrl}/internal/oauth/refresh`;
76
+ const response = await this.pairedFetch.post(url, {
77
+ typeId,
78
+ instanceId,
79
+ refreshToken: material.refreshToken,
80
+ });
81
+ if (response.status === 410) {
82
+ throw new ManagedOAuthRefreshInvalidGrantError(instanceId);
83
+ }
84
+ if (!response.ok) {
85
+ throw new Error(`ManagedOAuthFlowExecutor.refresh failed: ${response.status}`);
86
+ }
87
+ const refreshed = (await response.json()) as OAuthMaterial;
88
+ // Preserve the existing refresh token if the control plane omits it from the response.
89
+ if (!refreshed.refreshToken) {
90
+ return { ...refreshed, refreshToken: material.refreshToken };
91
+ }
92
+ return refreshed;
93
+ }
94
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Thrown when the control plane returns HTTP 410 (invalid_grant) during a refresh.
3
+ * The refresh token is dead — user revoked, token rotated away, etc.
4
+ * The credential cannot be auto-recovered; the user must reconnect via the Connect flow.
5
+ */
6
+ export class ManagedOAuthRefreshInvalidGrantError extends Error {
7
+ constructor(readonly credentialInstanceId: string) {
8
+ super(
9
+ `Credential ${credentialInstanceId}: refresh token is invalid or revoked (invalid_grant). Reconnect required.`,
10
+ );
11
+ this.name = "ManagedOAuthRefreshInvalidGrantError";
12
+ }
13
+ }
@@ -0,0 +1,4 @@
1
+ export type OAuthAppCatalogEntry = Readonly<{
2
+ appId: string;
3
+ displayName: string;
4
+ }>;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Thrown when the credential's refresh token is dead (user revoked the grant,
3
+ * or the token was rotated away). The installation cannot auto-recover; the user
4
+ * must reconnect via the broker Connect flow.
5
+ */
6
+ export class CredentialDisconnectedError extends Error {
7
+ constructor(readonly credentialInstanceId: string) {
8
+ super(`Credential ${credentialInstanceId}: refresh token is invalid or revoked. Reconnect via the Connect flow.`);
9
+ this.name = "CredentialDisconnectedError";
10
+ }
11
+ }
@@ -7,7 +7,7 @@ import type {
7
7
  WorkflowRepository,
8
8
  } from "@codemation/core";
9
9
 
10
- import { CoreTokens, inject, injectable } from "@codemation/core";
10
+ import { CoreTokens, CredentialUnboundError, inject, injectable } from "@codemation/core";
11
11
 
12
12
  import { ApplicationRequestError } from "../../application/ApplicationRequestError";
13
13
 
@@ -17,6 +17,7 @@ import type {
17
17
  } from "../../application/contracts/CredentialContractsRegistry";
18
18
 
19
19
  import { ApplicationTokens } from "../../applicationTokens";
20
+ import type { Logger, LoggerFactory } from "../../application/logging/Logger";
20
21
 
21
22
  import { WorkflowCredentialNodeResolver } from "./WorkflowCredentialNodeResolver";
22
23
  import { CredentialInstanceService } from "./CredentialInstanceService";
@@ -24,6 +25,8 @@ import type { CredentialStore, MutableCredentialSessionService } from "./Credent
24
25
 
25
26
  @injectable()
26
27
  export class CredentialBindingService {
28
+ private readonly logger: Logger;
29
+
27
30
  constructor(
28
31
  @inject(ApplicationTokens.CredentialStore)
29
32
  private readonly credentialStore: CredentialStore,
@@ -35,7 +38,11 @@ export class CredentialBindingService {
35
38
  private readonly credentialSessionService: MutableCredentialSessionService,
36
39
  @inject(WorkflowCredentialNodeResolver)
37
40
  private readonly workflowCredentialNodeResolver: WorkflowCredentialNodeResolver,
38
- ) {}
41
+ @inject(ApplicationTokens.LoggerFactory)
42
+ loggerFactory: LoggerFactory,
43
+ ) {
44
+ this.logger = loggerFactory.create("CredentialBindingService");
45
+ }
39
46
 
40
47
  async upsertBinding(
41
48
  args: Readonly<{ workflowId: string; nodeId: string; slotKey: string; instanceId: CredentialInstanceId }>,
@@ -63,6 +70,51 @@ export class CredentialBindingService {
63
70
  return binding;
64
71
  }
65
72
 
73
+ async assertRequiredCredentialsBound(workflowId: string): Promise<void> {
74
+ const workflow = this.requireWorkflow(workflowId);
75
+ const bindings = await this.credentialStore.listBindingsByWorkflowId(workflowId);
76
+ const boundKeys = new Set(bindings.map((b) => this.toBindingKeyString(b.key)));
77
+ const unboundByDb = this.workflowCredentialNodeResolver
78
+ .listSlots(workflow)
79
+ .filter((slot) => !slot.requirement.optional)
80
+ .filter(
81
+ (slot) =>
82
+ !boundKeys.has(
83
+ this.toBindingKeyString({ workflowId, nodeId: slot.nodeId, slotKey: slot.requirement.slotKey }),
84
+ ),
85
+ );
86
+ if (unboundByDb.length === 0) return;
87
+ // Confirm each apparently-unbound slot by attempting session resolution. A custom
88
+ // CredentialSessionService (e.g. a test harness) can satisfy slots that have no DB
89
+ // binding row; only slots that still fail are truly unresolvable.
90
+ const confirmed = [];
91
+ for (const slot of unboundByDb) {
92
+ try {
93
+ await this.credentialSessionService.getSession({
94
+ workflowId,
95
+ nodeId: slot.nodeId,
96
+ slotKey: slot.requirement.slotKey,
97
+ });
98
+ } catch (error) {
99
+ if (!(error instanceof CredentialUnboundError)) {
100
+ this.logger.debug(
101
+ `CredentialBindingService: unexpected error resolving session for slot ${slot.requirement.slotKey} on ${slot.nodeId}`,
102
+ error instanceof Error ? error : undefined,
103
+ );
104
+ }
105
+ confirmed.push(slot);
106
+ }
107
+ }
108
+ if (confirmed.length === 0) return;
109
+ const descriptions = confirmed
110
+ .map((slot) => `"${slot.requirement.label}" on ${slot.nodeName ?? slot.nodeId}`)
111
+ .join(", ");
112
+ throw new ApplicationRequestError(
113
+ 400,
114
+ `Cannot run workflow: required credential slot${confirmed.length > 1 ? "s" : ""} not bound: ${descriptions}`,
115
+ );
116
+ }
117
+
66
118
  async listWorkflowHealth(workflowId: string): Promise<WorkflowCredentialHealthDto> {
67
119
  const workflow = this.requireWorkflow(workflowId);
68
120
  const bindings = await this.credentialStore.listBindingsByWorkflowId(workflowId);
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Thrown by {@link CredentialSecretCipher.decrypt} when the credential's stored
3
+ * `encryptionKeyId` does not match the current master key's id.
4
+ *
5
+ * This indicates the `CODEMATION_CREDENTIALS_MASTER_KEY` environment variable has
6
+ * been rotated since the credential was encrypted. The operator must re-bind the
7
+ * affected credential (which re-encrypts it with the new key).
8
+ *
9
+ * See {@link docs/security-boundary.md} for the key rotation contract.
10
+ */
11
+ export class CredentialKeyRotatedError extends Error {
12
+ readonly storedKeyId: string;
13
+
14
+ constructor(storedKeyId: string) {
15
+ super(
16
+ `Credential was encrypted with key "${storedKeyId}" but the current master key produces a different id. ` +
17
+ `Re-bind the credential to re-encrypt it with the active key.`,
18
+ );
19
+ this.name = "CredentialKeyRotatedError";
20
+ this.storedKeyId = storedKeyId;
21
+ }
22
+ }
@@ -1,4 +1,4 @@
1
- import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
1
+ import { createCipheriv, createDecipheriv, createHash, hkdfSync, randomBytes } from "node:crypto";
2
2
 
3
3
  import { inject, injectable } from "@codemation/core";
4
4
 
@@ -6,13 +6,26 @@ import { ApplicationTokens } from "../../applicationTokens";
6
6
  import type { AppConfig } from "../../presentation/config/AppConfig";
7
7
 
8
8
  import type { JsonRecord } from "./CredentialServices";
9
+ import { CredentialKeyRotatedError } from "./CredentialKeyRotatedError";
9
10
 
11
+ /**
12
+ * Schema versions:
13
+ * 1 — key = SHA-256(rawValue) (legacy, read-only support retained for migration)
14
+ * 2 — key = HKDF-SHA-256(rawKey32Bytes, ...) (current)
15
+ *
16
+ * All new encryptions are written as v2. Existing v1 records can still be
17
+ * decrypted so operators can re-encrypt at their own pace (re-bind the
18
+ * credential in the UI, or run the one-shot re-encrypt script).
19
+ */
10
20
  @injectable()
11
21
  export class CredentialSecretCipher {
12
22
  private static readonly algorithm = "aes-256-gcm";
13
- private static readonly schemaVersion = 1;
23
+ private static readonly currentSchemaVersion = 2;
14
24
  private static readonly ivLength = 12;
15
25
 
26
+ private static readonly HKDF_SALT = "codemation/credential-cipher/v1";
27
+ private static readonly HKDF_INFO = "aes-256-gcm-key";
28
+
16
29
  constructor(
17
30
  @inject(ApplicationTokens.AppConfig)
18
31
  private readonly appConfig: AppConfig,
@@ -24,7 +37,7 @@ export class CredentialSecretCipher {
24
37
  schemaVersion: number;
25
38
  }> {
26
39
  const iv = randomBytes(CredentialSecretCipher.ivLength);
27
- const cipher = createCipheriv(CredentialSecretCipher.algorithm, this.resolveKeyMaterial(), iv);
40
+ const cipher = createCipheriv(CredentialSecretCipher.algorithm, this.resolveKeyMaterialV2(), iv);
28
41
  const plaintext = Buffer.from(JSON.stringify(value), "utf8");
29
42
  // eslint-disable-next-line codemation/no-buffer-everything -- AES-GCM credential cipher operates on bounded KB-sized JSON payloads; streaming crypto is not applicable here.
30
43
  const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
@@ -33,7 +46,7 @@ export class CredentialSecretCipher {
33
46
  // eslint-disable-next-line codemation/no-buffer-everything -- AES-GCM credential cipher operates on bounded KB-sized JSON payloads; streaming crypto is not applicable here.
34
47
  encryptedJson: Buffer.concat([iv, authTag, encrypted]).toString("base64"),
35
48
  encryptionKeyId: this.resolveKeyId(),
36
- schemaVersion: CredentialSecretCipher.schemaVersion,
49
+ schemaVersion: CredentialSecretCipher.currentSchemaVersion,
37
50
  };
38
51
  }
39
52
 
@@ -44,19 +57,48 @@ export class CredentialSecretCipher {
44
57
  schemaVersion: number;
45
58
  }>,
46
59
  ): JsonRecord {
60
+ // resolveKeyMaterialV2 / resolveKeyMaterialV1 both throw if env is missing
61
+ // — that check must come before the key-id comparison.
62
+ const keyMaterial = (record.schemaVersion ?? 1) >= 2 ? this.resolveKeyMaterialV2() : this.resolveKeyMaterialV1();
63
+
64
+ const currentKeyId = this.resolveKeyId();
65
+ if (record.encryptionKeyId !== currentKeyId) {
66
+ throw new CredentialKeyRotatedError(record.encryptionKeyId);
67
+ }
47
68
  // eslint-disable-next-line codemation/no-buffer-everything -- AES-GCM credential cipher operates on bounded KB-sized JSON payloads; streaming crypto is not applicable here.
48
69
  const packed = Buffer.from(record.encryptedJson, "base64");
49
70
  const iv = packed.subarray(0, CredentialSecretCipher.ivLength);
50
71
  const authTag = packed.subarray(CredentialSecretCipher.ivLength, CredentialSecretCipher.ivLength + 16);
51
72
  const encrypted = packed.subarray(CredentialSecretCipher.ivLength + 16);
52
- const decipher = createDecipheriv(CredentialSecretCipher.algorithm, this.resolveKeyMaterial(), iv);
73
+ const decipher = createDecipheriv(CredentialSecretCipher.algorithm, keyMaterial, iv);
53
74
  decipher.setAuthTag(authTag);
54
75
  // eslint-disable-next-line codemation/no-buffer-everything -- AES-GCM credential cipher operates on bounded KB-sized JSON payloads; streaming crypto is not applicable here.
55
76
  const plaintext = Buffer.concat([decipher.update(encrypted), decipher.final()]).toString("utf8");
56
77
  return JSON.parse(plaintext) as JsonRecord;
57
78
  }
58
79
 
59
- private resolveKeyMaterial(): Buffer {
80
+ /**
81
+ * Current (v2) key derivation: HKDF-SHA-256 with a fixed application salt and info label.
82
+ * Input must be a base64-encoded 32-byte value (`CODEMATION_CREDENTIALS_MASTER_KEY`).
83
+ */
84
+ private resolveKeyMaterialV2(): Buffer {
85
+ const ikm = this.resolveBase64Key32Bytes();
86
+ return Buffer.from(
87
+ hkdfSync(
88
+ "sha256",
89
+ ikm,
90
+ Buffer.from(CredentialSecretCipher.HKDF_SALT, "utf8"),
91
+ Buffer.from(CredentialSecretCipher.HKDF_INFO, "utf8"),
92
+ 32,
93
+ ),
94
+ );
95
+ }
96
+
97
+ /**
98
+ * Legacy (v1) key derivation: SHA-256 of the raw env string.
99
+ * Retained for decrypt-side backward compatibility only.
100
+ */
101
+ private resolveKeyMaterialV1(): Buffer {
60
102
  const rawValue = this.appConfig.env.CODEMATION_CREDENTIALS_MASTER_KEY;
61
103
  if (!rawValue || rawValue.trim().length === 0) {
62
104
  throw new Error("CODEMATION_CREDENTIALS_MASTER_KEY is required to encrypt database-managed credentials.");
@@ -64,6 +106,26 @@ export class CredentialSecretCipher {
64
106
  return createHash("sha256").update(rawValue).digest();
65
107
  }
66
108
 
109
+ /**
110
+ * Validates and returns the raw 32-byte key material from the env var.
111
+ * Throws if the env var is absent or does not decode to exactly 32 bytes.
112
+ */
113
+ private resolveBase64Key32Bytes(): Buffer {
114
+ const rawValue = this.appConfig.env.CODEMATION_CREDENTIALS_MASTER_KEY;
115
+ if (!rawValue || rawValue.trim().length === 0) {
116
+ throw new Error("CODEMATION_CREDENTIALS_MASTER_KEY is required to encrypt database-managed credentials.");
117
+ }
118
+ // eslint-disable-next-line codemation/no-buffer-everything -- key material is always 32 bytes; bounded by validation below.
119
+ const decoded = Buffer.from(rawValue.trim(), "base64");
120
+ if (decoded.length !== 32) {
121
+ throw new Error(
122
+ `CODEMATION_CREDENTIALS_MASTER_KEY must be a base64-encoded 32-byte value (got ${decoded.length} bytes). ` +
123
+ `Generate a valid key with: openssl rand -base64 32`,
124
+ );
125
+ }
126
+ return decoded;
127
+ }
128
+
67
129
  private resolveKeyId(): string {
68
130
  const rawValue = this.appConfig.env.CODEMATION_CREDENTIALS_MASTER_KEY;
69
131
  return createHash("sha256")