@codemation/host 0.6.0 → 0.7.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 (223) hide show
  1. package/CHANGELOG.md +431 -0
  2. package/LICENSE +1 -37
  3. package/dist/{ApiPaths-CLTHphYZ.js → ApiPaths-Dv1dcHu_.js} +4 -4
  4. package/dist/ApiPaths-Dv1dcHu_.js.map +1 -0
  5. package/dist/{AppConfigFactory-C6q-CSKb.js → AppConfigFactory-Cx4qQvRk.js} +112 -52
  6. package/dist/AppConfigFactory-Cx4qQvRk.js.map +1 -0
  7. package/dist/{AppConfigFactory-YnveXE9k.d.ts → AppConfigFactory-DnLoQ9Li.d.ts} +8490 -5548
  8. package/dist/{AppContainerFactory-qaqc-R1D.js → AppContainerFactory-DqKYCRNP.js} +7641 -2083
  9. package/dist/AppContainerFactory-DqKYCRNP.js.map +1 -0
  10. package/dist/{CodemationAppContext-DRu1Dpri.d.ts → CodemationAppContext-CKVv9W9q.d.ts} +8 -4
  11. package/dist/{CodemationAuthoring.types-fBRppnmi.d.ts → CodemationAuthoring.types-DA3G3s6d.d.ts} +25 -5
  12. package/dist/{CodemationAuthoring.types-DZl-sJaM.js → CodemationAuthoring.types-NGkBcmmT.js} +18 -6
  13. package/dist/CodemationAuthoring.types-NGkBcmmT.js.map +1 -0
  14. package/dist/{CodemationConfigNormalizer-DVko3cVN.d.ts → CodemationConfigNormalizer-BAKjetJ6.d.ts} +3 -3
  15. package/dist/{CodemationConsumerConfigLoader-BeAUS144.js → CodemationConsumerConfigLoader-GYpBBvqE.js} +79 -10
  16. package/dist/CodemationConsumerConfigLoader-GYpBBvqE.js.map +1 -0
  17. package/dist/{CodemationConsumerConfigLoader-DJWr86f-.d.ts → CodemationConsumerConfigLoader-nxOqvv46.d.ts} +17 -2
  18. package/dist/{CodemationPluginListMerger-B-W5Fa_X.js → CodemationPluginListMerger-D1B1IEbt.js} +1 -1
  19. package/dist/{CodemationPluginListMerger-B-W5Fa_X.js.map → CodemationPluginListMerger-D1B1IEbt.js.map} +1 -1
  20. package/dist/{CodemationPluginListMerger-DGc-jfa2.d.ts → CodemationPluginListMerger-DKLAHT2b.d.ts} +123 -16
  21. package/dist/CodemationTsyringeTypeInfoRegistrar-Bj6FJYFz.js +97 -0
  22. package/dist/CodemationTsyringeTypeInfoRegistrar-Bj6FJYFz.js.map +1 -0
  23. package/dist/{CodemationWhitelabelConfig-CWbcyQqn.d.ts → CodemationWhitelabelConfig-Ca2mCUeC.d.ts} +2 -2
  24. package/dist/{CollectionContracts.types-DdpHft0i.d.ts → CollectionContracts.types-DDyFYT_D.d.ts} +1 -1
  25. package/dist/{CredentialContractsRegistry-DrMIDSw8.d.ts → CredentialContractsRegistry-Bq2bq28t.d.ts} +2 -2
  26. package/dist/{CredentialServices-UfvHB-rN.d.ts → CredentialServices-Be2I60Th.d.ts} +65 -20
  27. package/dist/{CredentialServices-CgxwguAv.js → CredentialServices-Dk8yypeL.js} +310 -51
  28. package/dist/CredentialServices-Dk8yypeL.js.map +1 -0
  29. package/dist/InternalHonoApiRouteRegistrar-Ce1yxpnO.d.ts +17 -0
  30. package/dist/InternalPingRegistrar-DY3kSfxP.js +221 -0
  31. package/dist/InternalPingRegistrar-DY3kSfxP.js.map +1 -0
  32. package/dist/{ItemsInputNormalizer-C-KHg9Mo.d.ts → ItemsInputNormalizer-_RwIfRIQ.d.ts} +89 -25
  33. package/dist/{LogLevelPolicyFactory-CampWO0l.d.ts → LogLevelPolicyFactory-ewCHLDLn.d.ts} +2 -2
  34. package/dist/{PublicFrontendBootstrap-DzBgwOnG.d.ts → PublicFrontendBootstrap-Cev3qK46.d.ts} +9 -2
  35. package/dist/PublicFrontendBootstrapFactory-CY2FS-5g.d.ts +82 -0
  36. package/dist/{PublicFrontendBootstrapJsonCodec-Cl_DLRh0.d.ts → PublicFrontendBootstrapJsonCodec-CXG9Dxft.d.ts} +3 -3
  37. package/dist/{PublicFrontendBootstrapJsonCodec-DzqvA0uo.js → PublicFrontendBootstrapJsonCodec-CegIF_ne.js} +7 -2
  38. package/dist/PublicFrontendBootstrapJsonCodec-CegIF_ne.js.map +1 -0
  39. package/dist/ServerLoggerFactory-Ckk52S3w.js +223 -0
  40. package/dist/ServerLoggerFactory-Ckk52S3w.js.map +1 -0
  41. package/dist/{TelemetryContracts-DbaNomrH.d.ts → TelemetryContracts-BtDx84Cp.d.ts} +13 -4
  42. package/dist/{WorkflowPolicyUiPresentationFactory-DQEY-h_S.d.ts → WorkflowPolicyUiPresentationFactory-6MyjCvBO.d.ts} +2 -2
  43. package/dist/{WorkflowPolicyUiPresentationFactory-DhPqQ9aB.js → WorkflowPolicyUiPresentationFactory-Bb-ae_Zh.js} +1 -1
  44. package/dist/{WorkflowPolicyUiPresentationFactory-DhPqQ9aB.js.map → WorkflowPolicyUiPresentationFactory-Bb-ae_Zh.js.map} +1 -1
  45. package/dist/{WorkflowViewContracts-CzK2KFuz.d.ts → WorkflowViewContracts-B7aFQcIw.d.ts} +10 -1
  46. package/dist/authoring.d.ts +5 -5
  47. package/dist/authoring.js +1 -1
  48. package/dist/client.d.ts +4 -4
  49. package/dist/client.js +2 -2
  50. package/dist/consumer.d.ts +6 -6
  51. package/dist/consumer.js +2 -2
  52. package/dist/credentials.d.ts +6 -6
  53. package/dist/credentials.js +1 -1
  54. package/dist/devServerSidecar.d.ts +2 -2
  55. package/dist/devServerSidecar.js +1 -94
  56. package/dist/devServerSidecar.js.map +1 -1
  57. package/dist/dto.d.ts +6 -6
  58. package/dist/{index-BbBk26m0.d.ts → index-DilAYwnH.d.ts} +49 -3
  59. package/dist/index.d.ts +110 -21
  60. package/dist/index.js +15 -13
  61. package/dist/mapping.d.ts +2 -2
  62. package/dist/mapping.js +1 -1
  63. package/dist/nextServer.d.ts +43 -88
  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-CmsIKnO9.js → persistenceServer-C-hH4z6l.js} +2 -2
  69. package/dist/{persistenceServer-CmsIKnO9.js.map → persistenceServer-C-hH4z6l.js.map} +1 -1
  70. package/dist/persistenceServer-CeTHtC6E.d.ts +30 -0
  71. package/dist/persistenceServer.d.ts +8 -8
  72. package/dist/persistenceServer.js +3 -3
  73. package/dist/{server-MUNGsBYK.d.ts → server-C4bS62rg.d.ts} +21 -6
  74. package/dist/{server-CJFfY67o.js → server-Y7kxwtCK.js} +7 -6
  75. package/dist/{server-CJFfY67o.js.map → server-Y7kxwtCK.js.map} +1 -1
  76. package/dist/server.d.ts +14 -14
  77. package/dist/server.js +13 -11
  78. package/package.json +29 -42
  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 +277 -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/WorkerRuntime.ts +2 -1
  127. package/src/credentials/BrokerClient.ts +49 -0
  128. package/src/credentials/BrokerRefreshError.ts +12 -0
  129. package/src/credentials/BrokerRefreshInvalidGrantError.ts +13 -0
  130. package/src/credentials/ControlPlaneCatalogFetcher.ts +261 -0
  131. package/src/credentials/CredentialOAuth2MaterialReader.ts +136 -0
  132. package/src/credentials/InternalCredentialsListRegistrar.ts +48 -0
  133. package/src/credentials/InternalCredentialsPushRegistrar.ts +125 -0
  134. package/src/credentials/LocalOAuthFlowExecutor.ts +316 -0
  135. package/src/credentials/ManagedOAuthFlowExecutor.ts +94 -0
  136. package/src/credentials/ManagedOAuthRefreshInvalidGrantError.ts +13 -0
  137. package/src/credentials/catalogTypes.ts +4 -0
  138. package/src/credentials/refresh/CredentialDisconnectedError.ts +11 -0
  139. package/src/domain/credentials/CredentialBindingService.ts +54 -2
  140. package/src/domain/credentials/CredentialKeyRotatedError.ts +22 -0
  141. package/src/domain/credentials/CredentialSecretCipher.ts +68 -6
  142. package/src/domain/credentials/CredentialTypeRegistryImpl.ts +117 -10
  143. package/src/domain/credentials/OAuth2RedirectUriResolver.ts +79 -0
  144. package/src/domain/credentials/WorkflowCredentialNodeResolver.ts +14 -5
  145. package/src/domain/telemetry/TelemetryContracts.ts +7 -1
  146. package/src/domain/workflows/WorkflowActivationPreflight.ts +24 -1
  147. package/src/domain/workflows/WorkflowActivationPreflightRules.ts +40 -1
  148. package/src/index.ts +6 -0
  149. package/src/infrastructure/binary/LocalFilesystemBinaryStorageRegistry.ts +29 -1
  150. package/src/infrastructure/binary/S3BinaryStorage.ts +169 -0
  151. package/src/infrastructure/binary/S3BinaryStorageConfig.ts +17 -0
  152. package/src/infrastructure/config/CodemationPluginRegistrar.ts +3 -1
  153. package/src/infrastructure/persistence/CodemationDatabaseUrlParser.ts +41 -0
  154. package/src/infrastructure/persistence/InMemoryTelemetryArtifactStore.ts +8 -3
  155. package/src/infrastructure/persistence/PrismaMigrationDeployer.ts +21 -13
  156. package/src/infrastructure/persistence/PrismaTelemetryArtifactStore.ts +43 -8
  157. package/src/infrastructure/persistence/PrismaWorkflowRunRepository.ts +26 -3
  158. package/src/infrastructure/persistence/PrismaWorkflowSnapshotRepository.ts +48 -0
  159. package/src/mcp/AgentMcpIntegrationImpl.ts +344 -0
  160. package/src/mcp/McpClientFactory.ts +29 -0
  161. package/src/mcp/McpConnectionPool.ts +184 -0
  162. package/src/mcp/McpConnectionPool.types.ts +12 -0
  163. package/src/mcp/McpServerCatalog.ts +104 -0
  164. package/src/mcp/index.ts +5 -0
  165. package/src/pairing/HmacRequestSigner.ts +32 -0
  166. package/src/pairing/IncomingHmacVerifier.ts +82 -0
  167. package/src/pairing/InternalHmacAuthMiddleware.ts +33 -0
  168. package/src/pairing/InternalPingRegistrar.ts +25 -0
  169. package/src/pairing/PairedFetch.ts +33 -0
  170. package/src/pairing/PairingConfigFactory.ts +35 -0
  171. package/src/pairing/PairingConfigToken.ts +6 -0
  172. package/src/pairing/index.ts +14 -0
  173. package/src/pairing/pairing.types.ts +18 -0
  174. package/src/pairing.ts +17 -0
  175. package/src/persistenceServer.ts +1 -0
  176. package/src/presentation/config/AppConfig.ts +7 -1
  177. package/src/presentation/config/CodemationAuthConfig.ts +1 -1
  178. package/src/presentation/config/CodemationAuthoring.types.ts +54 -5
  179. package/src/presentation/config/CodemationConfig.ts +3 -0
  180. package/src/presentation/config/CodemationConfigNormalizer.ts +39 -1
  181. package/src/presentation/config/CodemationPlugin.ts +2 -1
  182. package/src/presentation/frontend/CodemationFrontendAuthSnapshot.ts +5 -0
  183. package/src/presentation/frontend/CodemationFrontendAuthSnapshotFactory.ts +7 -1
  184. package/src/presentation/frontend/PublicFrontendBootstrap.ts +2 -0
  185. package/src/presentation/frontend/PublicFrontendBootstrapFactory.ts +5 -1
  186. package/src/presentation/frontend/PublicFrontendBootstrapJsonCodec.ts +4 -1
  187. package/src/presentation/http/ApiPaths.ts +4 -4
  188. package/src/presentation/http/ServerHttpErrorResponseFactory.ts +39 -2
  189. package/src/presentation/http/hono/CodemationHonoApiAppFactory.ts +33 -8
  190. package/src/presentation/http/hono/InternalHonoApiRouteRegistrar.ts +12 -0
  191. package/src/presentation/http/hono/registrars/ManagedMeHonoApiRouteRegistrar.ts +35 -0
  192. package/src/presentation/http/hono/registrars/OAuth2HonoApiRouteRegistrar.ts +2 -2
  193. package/src/presentation/http/routeHandlers/CredentialHttpRouteHandler.ts +28 -0
  194. package/src/presentation/http/routeHandlers/OAuth2HttpRouteHandlerFactory.ts +98 -41
  195. package/src/presentation/server/CodemationConsumerConfigLoader.ts +54 -7
  196. package/src/presentation/server/CodemationPluginDiscovery.ts +5 -0
  197. package/src/presentation/server/WorkflowDefinitionExportsResolver.ts +18 -0
  198. package/src/presentation/server/WorkflowModulePathFinder.ts +12 -1
  199. package/src/presentation/websocket/ManagedWebsocketAuthenticator.ts +50 -0
  200. package/src/presentation/websocket/WebsocketAuthenticator.types.ts +12 -0
  201. package/src/presentation/websocket/WorkflowWebsocketServer.ts +24 -3
  202. package/src/process/ExecaProcessRunner.ts +41 -0
  203. package/src/process/ProcessRunner.types.ts +39 -0
  204. package/src/server.ts +2 -0
  205. package/src/workflows/InternalWorkflowActivationRegistrar.ts +42 -0
  206. package/src/workflows/InternalWorkflowDetailRegistrar.ts +33 -0
  207. package/src/workflows/InternalWorkflowTestRunRegistrar.ts +91 -0
  208. package/src/workflows/InternalWorkflowsListRegistrar.ts +28 -0
  209. package/src/workflows/discovery/WorkflowDirectoryDiscoverer.ts +79 -0
  210. package/tsconfig.json +2 -0
  211. package/vitest.shared.ts +5 -0
  212. package/dist/ApiPaths-CLTHphYZ.js.map +0 -1
  213. package/dist/AppConfigFactory-C6q-CSKb.js.map +0 -1
  214. package/dist/AppContainerFactory-qaqc-R1D.js.map +0 -1
  215. package/dist/CodemationAuthoring.types-DZl-sJaM.js.map +0 -1
  216. package/dist/CodemationConsumerConfigLoader-BeAUS144.js.map +0 -1
  217. package/dist/CredentialServices-CgxwguAv.js.map +0 -1
  218. package/dist/PublicFrontendBootstrapFactory-Cb2pLmDd.d.ts +0 -45
  219. package/dist/PublicFrontendBootstrapJsonCodec-DzqvA0uo.js.map +0 -1
  220. package/dist/ServerLoggerFactory-BKSIh9Xv.js +0 -98
  221. package/dist/ServerLoggerFactory-BKSIh9Xv.js.map +0 -1
  222. package/dist/persistenceServer-vtJAGDat.d.ts +0 -9
  223. package/src/domain/credentials/OAuth2ConnectServiceFactory.ts +0 -411
@@ -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")
@@ -1,29 +1,136 @@
1
1
  import type { CredentialTypeDefinition, CredentialTypeId, CredentialTypeRegistry } from "@codemation/core";
2
2
 
3
- import { injectable } from "@codemation/core";
3
+ import { inject, injectable } from "@codemation/core";
4
4
 
5
- import type { AnyCredentialType, CredentialType } from "./CredentialServices";
5
+ import { ApplicationTokens } from "../../applicationTokens";
6
+ import type { LoggerFactory } from "../../application/logging/Logger";
7
+ import type { AnyCredentialType } from "./CredentialServices";
8
+
9
+ export type CredentialTypeSource = "plugin" | "config" | "controlPlane";
10
+
11
+ const SOURCE_PRIORITY: Record<CredentialTypeSource, number> = {
12
+ plugin: 0,
13
+ config: 1,
14
+ controlPlane: 2,
15
+ };
16
+
17
+ type RegistryEntry = Readonly<{
18
+ type: AnyCredentialType;
19
+ source: CredentialTypeSource;
20
+ }>;
6
21
 
7
22
  @injectable()
8
23
  export class CredentialTypeRegistryImpl implements CredentialTypeRegistry {
9
- private readonly credentialTypesById = new Map<CredentialTypeId, AnyCredentialType>();
24
+ private readonly entries = new Map<CredentialTypeId, RegistryEntry>();
25
+ private readonly bySource = new Map<CredentialTypeSource, Set<CredentialTypeId>>();
10
26
 
11
- register(type: CredentialType<any, any, unknown>): void {
12
- if (this.credentialTypesById.has(type.definition.typeId)) {
13
- throw new Error(`Credential type already registered: ${type.definition.typeId}`);
27
+ constructor(@inject(ApplicationTokens.LoggerFactory) private readonly loggers: LoggerFactory) {}
28
+
29
+ merge(source: CredentialTypeSource, types: ReadonlyArray<AnyCredentialType>): void {
30
+ const logger = this.loggers.create("CredentialTypeRegistryImpl");
31
+ for (const type of types) {
32
+ this.insert(source, type, logger);
33
+ }
34
+ }
35
+
36
+ mergeDefinitions(source: CredentialTypeSource, definitions: ReadonlyArray<CredentialTypeDefinition>): void {
37
+ const logger = this.loggers.create("CredentialTypeRegistryImpl");
38
+ for (const definition of definitions) {
39
+ const existing = this.entries.get(definition.typeId);
40
+ const sourcePriority = SOURCE_PRIORITY[source];
41
+ if (existing) {
42
+ if (sourcePriority < SOURCE_PRIORITY[existing.source]) {
43
+ logger.warn(
44
+ `CredentialTypeRegistryImpl: id collision — lower-priority source "${source}" ignored for typeId "${definition.typeId}" (current source: "${existing.source}")`,
45
+ );
46
+ continue;
47
+ }
48
+ if (sourcePriority > SOURCE_PRIORITY[existing.source]) {
49
+ logger.warn(
50
+ `CredentialTypeRegistryImpl: typeId "${definition.typeId}" shadowed — "${existing.source}" overridden by higher-priority source "${source}"`,
51
+ );
52
+ this.bySource.get(existing.source)?.delete(definition.typeId);
53
+ }
54
+ const nextType: AnyCredentialType =
55
+ sourcePriority === SOURCE_PRIORITY[existing.source]
56
+ ? { ...existing.type, definition }
57
+ : { definition, createSession: this.createUnsupportedSessionFactory(definition.typeId, source), test: this.createUnsupportedHealthTester(definition.typeId, source) };
58
+ this.recordEntry(definition.typeId, { type: nextType, source });
59
+ continue;
60
+ }
61
+ const stubType: AnyCredentialType = {
62
+ definition,
63
+ createSession: this.createUnsupportedSessionFactory(definition.typeId, source),
64
+ test: this.createUnsupportedHealthTester(definition.typeId, source),
65
+ };
66
+ this.recordEntry(definition.typeId, { type: stubType, source });
67
+ }
68
+ }
69
+
70
+ clear(source: CredentialTypeSource): void {
71
+ const ids = this.bySource.get(source);
72
+ if (!ids) {
73
+ return;
74
+ }
75
+ for (const id of ids) {
76
+ this.entries.delete(id);
14
77
  }
15
- this.credentialTypesById.set(type.definition.typeId, type);
78
+ this.bySource.delete(source);
16
79
  }
17
80
 
18
81
  listTypes(): ReadonlyArray<CredentialTypeDefinition> {
19
- return [...this.credentialTypesById.values()].map((entry) => entry.definition);
82
+ return [...this.entries.values()].map((entry) => entry.type.definition);
20
83
  }
21
84
 
22
85
  getType(typeId: CredentialTypeId): CredentialTypeDefinition | undefined {
23
- return this.credentialTypesById.get(typeId)?.definition;
86
+ return this.entries.get(typeId)?.type.definition;
24
87
  }
25
88
 
26
89
  getCredentialType(typeId: CredentialTypeId): AnyCredentialType | undefined {
27
- return this.credentialTypesById.get(typeId);
90
+ return this.entries.get(typeId)?.type;
91
+ }
92
+
93
+ private insert(source: CredentialTypeSource, type: AnyCredentialType, logger: ReturnType<LoggerFactory["create"]>): void {
94
+ const typeId = type.definition.typeId;
95
+ const existing = this.entries.get(typeId);
96
+ const sourcePriority = SOURCE_PRIORITY[source];
97
+ if (existing) {
98
+ if (sourcePriority < SOURCE_PRIORITY[existing.source]) {
99
+ logger.warn(
100
+ `CredentialTypeRegistryImpl: id collision — lower-priority source "${source}" ignored for typeId "${typeId}" (current source: "${existing.source}")`,
101
+ );
102
+ return;
103
+ }
104
+ if (sourcePriority > SOURCE_PRIORITY[existing.source]) {
105
+ logger.warn(
106
+ `CredentialTypeRegistryImpl: typeId "${typeId}" shadowed — "${existing.source}" overridden by higher-priority source "${source}"`,
107
+ );
108
+ this.bySource.get(existing.source)?.delete(typeId);
109
+ }
110
+ }
111
+ this.recordEntry(typeId, { type, source });
112
+ }
113
+
114
+ private recordEntry(typeId: CredentialTypeId, entry: RegistryEntry): void {
115
+ this.entries.set(typeId, entry);
116
+ if (!this.bySource.has(entry.source)) {
117
+ this.bySource.set(entry.source, new Set());
118
+ }
119
+ this.bySource.get(entry.source)!.add(typeId);
120
+ }
121
+
122
+ private createUnsupportedSessionFactory(typeId: CredentialTypeId, source: CredentialTypeSource): AnyCredentialType["createSession"] {
123
+ return async () => {
124
+ throw new Error(
125
+ `Credential type "${typeId}" (source "${source}") was registered with definition only — no createSession implementation is available in this runtime.`,
126
+ );
127
+ };
128
+ }
129
+
130
+ private createUnsupportedHealthTester(typeId: CredentialTypeId, source: CredentialTypeSource): AnyCredentialType["test"] {
131
+ return async () => ({
132
+ status: "unknown" as const,
133
+ message: `Credential type "${typeId}" (source "${source}") has no local test implementation.`,
134
+ });
28
135
  }
29
136
  }
@@ -0,0 +1,79 @@
1
+ import { inject, injectable } from "@codemation/core";
2
+ import { ApplicationRequestError } from "../../application/ApplicationRequestError";
3
+ import { ApplicationTokens } from "../../applicationTokens";
4
+ import type { AppConfig } from "../../presentation/config/AppConfig";
5
+
6
+ /**
7
+ * Resolves the canonical OAuth2 redirect URI from the public base URL or request origin.
8
+ * The redirect URI always points to `/api/oauth2/callback`, which is the URL operators
9
+ * register with their OAuth provider.
10
+ */
11
+ @injectable()
12
+ export class OAuth2RedirectUriResolver {
13
+ constructor(
14
+ @inject(ApplicationTokens.AppConfig)
15
+ private readonly appConfig: AppConfig,
16
+ ) {}
17
+
18
+ resolve(requestOrigin: string): string {
19
+ const rawBase = this.appConfig.env.CODEMATION_PUBLIC_BASE_URL?.trim() || requestOrigin.trim();
20
+ if (!rawBase) {
21
+ throw new Error("Unable to resolve the public base URL for OAuth2 redirect URI generation.");
22
+ }
23
+ const baseUrl = this.ensureAbsoluteUrl(rawBase);
24
+ try {
25
+ const callback = new URL("/api/oauth2/callback", this.normalizeBaseUrl(baseUrl));
26
+ // Several OAuth2 providers (notably Azure AD / Microsoft) reject raw loopback IPs in
27
+ // redirect URIs and only allow the `localhost` hostname. 127.0.0.1 / [::1] are equivalent
28
+ // to localhost by definition, so rewriting is lossless.
29
+ const loopbackHostnames = new Set(["127.0.0.1", "[::1]"]);
30
+ if (loopbackHostnames.has(callback.hostname)) {
31
+ callback.hostname = "localhost";
32
+ }
33
+ return callback.toString();
34
+ } catch {
35
+ throw new ApplicationRequestError(
36
+ 500,
37
+ `Invalid public base URL for OAuth2 redirect URI generation: "${rawBase}". Use a full URL (e.g. http://localhost:3000) for CODEMATION_PUBLIC_BASE_URL or ensure the request has a valid Host / forwarded headers.`,
38
+ );
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Ensures the base URL has an http/https scheme. Comma-separated values (proxy chains) use
44
+ * the first segment only.
45
+ */
46
+ private ensureAbsoluteUrl(raw: string): string {
47
+ const segments = raw
48
+ .split(",")
49
+ .map((s) => s.trim())
50
+ .filter((s) => s.length > 0);
51
+ let candidate = segments[0] ?? raw.trim();
52
+ if (!candidate) {
53
+ throw new Error("Unable to resolve the public base URL for OAuth2 redirect URI generation.");
54
+ }
55
+ if (!/^https?:\/\//i.test(candidate)) {
56
+ candidate = `http://${candidate}`;
57
+ }
58
+ let parsed: URL;
59
+ try {
60
+ parsed = new URL(candidate);
61
+ } catch {
62
+ throw new ApplicationRequestError(
63
+ 500,
64
+ `Invalid public base URL for OAuth2 redirect URI generation: "${raw}". Use a single full URL (e.g. http://localhost:3000) for CODEMATION_PUBLIC_BASE_URL.`,
65
+ );
66
+ }
67
+ if (parsed.hostname === "http" || parsed.hostname === "https") {
68
+ throw new ApplicationRequestError(
69
+ 500,
70
+ `Invalid OAuth2 public base URL (hostname "${parsed.hostname}"). Set CODEMATION_PUBLIC_BASE_URL to one full URL with a real host, e.g. http://localhost:3000 — not "http,http" or other typos.`,
71
+ );
72
+ }
73
+ return candidate;
74
+ }
75
+
76
+ private normalizeBaseUrl(baseUrl: string): string {
77
+ return baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
78
+ }
79
+ }
@@ -4,9 +4,10 @@ import {
4
4
  AgentConnectionNodeCollector,
5
5
  type AgentConnectionNodeDescriptor,
6
6
  ConnectionNodeIdFactory,
7
+ inject,
8
+ injectable,
7
9
  } from "@codemation/core";
8
-
9
- import { injectable } from "@codemation/core";
10
+ import { McpServerCatalog } from "../../mcp/McpServerCatalog";
10
11
 
11
12
  export type WorkflowCredentialSlotRef = Readonly<{
12
13
  workflowId: string;
@@ -20,6 +21,10 @@ export type WorkflowCredentialSlotRef = Readonly<{
20
21
  */
21
22
  @injectable()
22
23
  export class WorkflowCredentialNodeResolver {
24
+ constructor(
25
+ @inject(McpServerCatalog)
26
+ private readonly mcpCatalog?: McpServerCatalog,
27
+ ) {}
23
28
  /**
24
29
  * Human-readable label for credential errors (workflow node name or agent › attachment).
25
30
  */
@@ -102,7 +107,9 @@ export class WorkflowCredentialNodeResolver {
102
107
  agentConfig: Parameters<typeof AgentConnectionNodeCollector.collect>[1],
103
108
  slotsByKey: Map<string, WorkflowCredentialSlotRef>,
104
109
  ): void {
105
- for (const entry of AgentConnectionNodeCollector.collect(rootAgentNodeId, agentConfig)) {
110
+ const mcpResolver = this.mcpCatalog ? (id: string) => this.mcpCatalog!.get(id) : undefined;
111
+ const descriptors = AgentConnectionNodeCollector.collect(rootAgentNodeId, agentConfig, mcpResolver);
112
+ for (const entry of descriptors) {
106
113
  this.addSlotsForRequirements(
107
114
  workflowId,
108
115
  entry.nodeId,
@@ -147,15 +154,17 @@ export class WorkflowCredentialNodeResolver {
147
154
  | undefined {
148
155
  if (
149
156
  !ConnectionNodeIdFactory.isLanguageModelConnectionNodeId(nodeId) &&
150
- !ConnectionNodeIdFactory.isToolConnectionNodeId(nodeId)
157
+ !ConnectionNodeIdFactory.isToolConnectionNodeId(nodeId) &&
158
+ !ConnectionNodeIdFactory.isMcpConnectionNodeId(nodeId)
151
159
  ) {
152
160
  return undefined;
153
161
  }
162
+ const mcpResolver = this.mcpCatalog ? (id: string) => this.mcpCatalog!.get(id) : undefined;
154
163
  for (const node of workflow.nodes) {
155
164
  if (!AgentConfigInspector.isAgentNodeConfig(node.config)) {
156
165
  continue;
157
166
  }
158
- const entries = AgentConnectionNodeCollector.collect(node.id, node.config);
167
+ const entries = AgentConnectionNodeCollector.collect(node.id, node.config, mcpResolver);
159
168
  const entriesById = new Map(entries.map((entry) => [entry.nodeId, entry]));
160
169
  const entry = entriesById.get(nodeId);
161
170
  if (!entry) {
@@ -86,6 +86,8 @@ export interface TelemetryArtifactRecord {
86
86
  readonly previewJson?: unknown;
87
87
  readonly payloadText?: string;
88
88
  readonly payloadJson?: unknown;
89
+ /** Set when the payload was offloaded to BinaryStorage (byteLength > 64 KB). */
90
+ readonly payloadStorageKey?: string;
89
91
  readonly bytes?: number;
90
92
  readonly truncated?: boolean;
91
93
  readonly createdAt: string;
@@ -195,7 +197,11 @@ export interface TelemetrySpanStore {
195
197
  export interface TelemetryArtifactStore {
196
198
  save(record: TelemetryArtifactWrite): Promise<TelemetryArtifactRecord>;
197
199
  listByTraceId(traceId: string): Promise<ReadonlyArray<TelemetryArtifactRecord>>;
198
- pruneExpired(args: TelemetryPruneArgs): Promise<number>;
200
+ /**
201
+ * Deletes expired artifacts. Returns the count of deleted rows and any
202
+ * `payloadStorageKey` references that callers must clean up from BinaryStorage.
203
+ */
204
+ pruneExpired(args: TelemetryPruneArgs): Promise<{ count: number; storageKeys: ReadonlyArray<string> }>;
199
205
  }
200
206
 
201
207
  export interface TelemetryMetricPointStore {