@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,261 @@
1
+ import { inject, injectable } from "@codemation/core";
2
+ import type { CredentialTypeDefinition, McpServerDeclaration } from "@codemation/core";
3
+ import { ApplicationTokens } from "../applicationTokens";
4
+ import type { LoggerFactory } from "../application/logging/Logger";
5
+ import type { AppConfig } from "../presentation/config/AppConfig";
6
+ import { PairedFetch } from "../pairing/PairedFetch";
7
+ import { PairingConfigToken } from "../pairing/PairingConfigToken";
8
+ import type { PairingConfig } from "../pairing/pairing.types";
9
+ import type { OAuthAppCatalogEntry } from "./catalogTypes";
10
+
11
+ /**
12
+ * Configuration read from env at construction time.
13
+ *
14
+ * - `CODEMATION_CATALOG_POLL_INTERVAL_SECONDS`: seconds between polls (default 300; 0 = startup-only).
15
+ * - `CODEMATION_CATALOG_STALE_FAILURES`: consecutive failures before warn → error escalation (default 5).
16
+ * - `CODEMATION_CATALOG_STALE_HOURS`: hours stale before escalating to error level (default 24).
17
+ */
18
+ interface CatalogFetcherConfig {
19
+ readonly pollIntervalMs: number;
20
+ readonly staleFailuresThreshold: number;
21
+ readonly staleHoursThreshold: number;
22
+ }
23
+
24
+ type EndpointState = {
25
+ consecutiveFailures: number;
26
+ lastSuccessAt: Date | null;
27
+ };
28
+
29
+ /**
30
+ * Polls the three control-plane catalog endpoints on a configurable interval,
31
+ * caches the last-known-good responses, and exposes the fetched data for
32
+ * credential-type overrides, MCP server registrations, and OAuth app availability.
33
+ *
34
+ * Endpoints (HMAC-gated via PairedFetch):
35
+ * GET /internal/catalog/oauth-apps
36
+ * GET /internal/catalog/mcp-servers
37
+ * GET /internal/catalog/credential-types
38
+ *
39
+ * Failure semantics: a failure on one endpoint does NOT prevent updating the
40
+ * others. Each endpoint's consecutive-failure counter and staleness escalation
41
+ * are tracked independently.
42
+ *
43
+ * When not paired with a control plane (PairingConfigToken is null),
44
+ * start() returns immediately and all three getters remain null.
45
+ */
46
+ @injectable()
47
+ export class ControlPlaneCatalogFetcher {
48
+ private readonly config: CatalogFetcherConfig;
49
+ private timerHandle: ReturnType<typeof setTimeout> | null = null;
50
+ private stopped = false;
51
+ /** Tracks in-flight refresh so stop() can safely await it. */
52
+ private inFlight: Promise<void> | null = null;
53
+
54
+ private _oauthApps: readonly OAuthAppCatalogEntry[] | null = null;
55
+ private _mcpServers: readonly McpServerDeclaration[] | null = null;
56
+ private _credentialTypeOverrides: readonly CredentialTypeDefinition[] | null = null;
57
+
58
+ private readonly oauthAppsState: EndpointState = { consecutiveFailures: 0, lastSuccessAt: null };
59
+ private readonly mcpServersState: EndpointState = { consecutiveFailures: 0, lastSuccessAt: null };
60
+ private readonly credentialTypesState: EndpointState = { consecutiveFailures: 0, lastSuccessAt: null };
61
+
62
+ /**
63
+ * Called after each successful full fetch, before the next poll is scheduled.
64
+ * Set by AppContainerFactory to re-apply overrides on each refresh cycle.
65
+ */
66
+ onRefresh: (() => void) | null = null;
67
+
68
+ constructor(
69
+ @inject(PairedFetch) private readonly pairedFetch: PairedFetch,
70
+ @inject(PairingConfigToken, { isOptional: true }) private readonly pairingConfig: PairingConfig | null,
71
+ @inject(ApplicationTokens.LoggerFactory) private readonly loggers: LoggerFactory,
72
+ @inject(ApplicationTokens.AppConfig) appConfig: AppConfig,
73
+ ) {
74
+ const env = appConfig.env;
75
+ const pollSec = Number(env["CODEMATION_CATALOG_POLL_INTERVAL_SECONDS"] ?? 300);
76
+ const staleFailures = Number(env["CODEMATION_CATALOG_STALE_FAILURES"] ?? 5);
77
+ const staleHours = Number(env["CODEMATION_CATALOG_STALE_HOURS"] ?? 24);
78
+ this.config = {
79
+ pollIntervalMs: Number.isFinite(pollSec) ? pollSec * 1_000 : 300_000,
80
+ staleFailuresThreshold: Number.isFinite(staleFailures) ? staleFailures : 5,
81
+ staleHoursThreshold: Number.isFinite(staleHours) ? staleHours : 24,
82
+ };
83
+ }
84
+
85
+ /** Latest fetched OAuth app catalog; null until first successful fetch. */
86
+ get oauthApps(): readonly OAuthAppCatalogEntry[] | null {
87
+ return this._oauthApps;
88
+ }
89
+
90
+ /** Latest fetched MCP server declarations; null until first successful fetch. */
91
+ get mcpServers(): readonly McpServerDeclaration[] | null {
92
+ return this._mcpServers;
93
+ }
94
+
95
+ /** Latest fetched credential type overrides; null until first successful fetch. */
96
+ get credentialTypeOverrides(): readonly CredentialTypeDefinition[] | null {
97
+ return this._credentialTypeOverrides;
98
+ }
99
+
100
+ /**
101
+ * Fires the first fetch (non-blocking — failure is logged, not thrown) and
102
+ * schedules the periodic poll if `pollIntervalMs > 0`.
103
+ * No-ops immediately when pairing config is absent.
104
+ */
105
+ async start(): Promise<void> {
106
+ if (!this.pairingConfig) {
107
+ return;
108
+ }
109
+ try {
110
+ await this.refresh();
111
+ } catch {
112
+ // refresh() handles its own errors; this catch is belt-and-suspenders.
113
+ }
114
+ this.scheduleNext();
115
+ }
116
+
117
+ /**
118
+ * Cancels the poll timer. Awaits any in-flight fetch before resolving.
119
+ */
120
+ async stop(): Promise<void> {
121
+ this.stopped = true;
122
+ if (this.timerHandle !== null) {
123
+ clearTimeout(this.timerHandle);
124
+ this.timerHandle = null;
125
+ }
126
+ if (this.inFlight) {
127
+ await this.inFlight;
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Manual one-shot fetch. Same code path as the periodic tick.
133
+ * Errors are caught and logged internally; this method never rejects.
134
+ */
135
+ async refresh(): Promise<void> {
136
+ const run = this.fetchAll();
137
+ this.inFlight = run;
138
+ try {
139
+ await run;
140
+ } finally {
141
+ if (this.inFlight === run) {
142
+ this.inFlight = null;
143
+ }
144
+ }
145
+ }
146
+
147
+ private scheduleNext(): void {
148
+ if (this.stopped || this.config.pollIntervalMs <= 0) {
149
+ return;
150
+ }
151
+ this.timerHandle = setTimeout(() => {
152
+ if (this.stopped) {
153
+ return;
154
+ }
155
+ void this.refresh().finally(() => this.scheduleNext());
156
+ }, this.config.pollIntervalMs);
157
+ }
158
+
159
+ private async fetchAll(): Promise<void> {
160
+ if (!this.pairingConfig) {
161
+ return;
162
+ }
163
+ const logger = this.loggers.create("ControlPlaneCatalogFetcher");
164
+ const base = this.pairingConfig.controlPlaneUrl;
165
+
166
+ const [oauthResult, mcpResult, credTypesResult] = await Promise.allSettled([
167
+ this.pairedFetch.get(`${base}/internal/catalog/oauth-apps`),
168
+ this.pairedFetch.get(`${base}/internal/catalog/mcp-servers`),
169
+ this.pairedFetch.get(`${base}/internal/catalog/credential-types`),
170
+ ]);
171
+
172
+ await this.handleEndpointResult(
173
+ oauthResult,
174
+ this.oauthAppsState,
175
+ "oauth-apps",
176
+ (data) => {
177
+ this._oauthApps = data as OAuthAppCatalogEntry[];
178
+ },
179
+ logger,
180
+ );
181
+
182
+ await this.handleEndpointResult(
183
+ mcpResult,
184
+ this.mcpServersState,
185
+ "mcp-servers",
186
+ (data) => {
187
+ this._mcpServers = data as McpServerDeclaration[];
188
+ },
189
+ logger,
190
+ );
191
+
192
+ await this.handleEndpointResult(
193
+ credTypesResult,
194
+ this.credentialTypesState,
195
+ "credential-types",
196
+ (data) => {
197
+ this._credentialTypeOverrides = data as CredentialTypeDefinition[];
198
+ },
199
+ logger,
200
+ );
201
+
202
+ this.onRefresh?.();
203
+ }
204
+
205
+ private async handleEndpointResult(
206
+ result: PromiseSettledResult<Response>,
207
+ state: EndpointState,
208
+ endpointName: string,
209
+ onSuccess: (data: unknown[]) => void,
210
+ logger: ReturnType<LoggerFactory["create"]>,
211
+ ): Promise<void> {
212
+ if (result.status === "fulfilled") {
213
+ const res = result.value;
214
+ if (res.ok) {
215
+ try {
216
+ const data = (await res.json()) as unknown[];
217
+ onSuccess(data);
218
+ state.consecutiveFailures = 0;
219
+ state.lastSuccessAt = new Date();
220
+ logger.info(`ControlPlaneCatalogFetcher: fetched ${endpointName} (count=${data.length})`);
221
+ return;
222
+ } catch (err) {
223
+ this.logEndpointFailure(state, endpointName, err, logger);
224
+ return;
225
+ }
226
+ }
227
+ this.logEndpointFailure(state, endpointName, new Error(`HTTP ${res.status} ${res.statusText}`), logger);
228
+ } else {
229
+ this.logEndpointFailure(state, endpointName, result.reason, logger);
230
+ }
231
+ }
232
+
233
+ private logEndpointFailure(
234
+ state: EndpointState,
235
+ endpointName: string,
236
+ err: unknown,
237
+ logger: ReturnType<LoggerFactory["create"]>,
238
+ ): void {
239
+ state.consecutiveFailures++;
240
+ const staleHours = state.lastSuccessAt ? (Date.now() - state.lastSuccessAt.getTime()) / 3_600_000 : null;
241
+ const errMsg = err instanceof Error ? err.message : String(err);
242
+
243
+ const isStale =
244
+ state.consecutiveFailures >= this.config.staleFailuresThreshold ||
245
+ (staleHours !== null && staleHours >= this.config.staleHoursThreshold);
246
+
247
+ if (isStale) {
248
+ const staleLabel = staleHours !== null ? `${staleHours.toFixed(1)}h` : "never-succeeded";
249
+ logger.error(
250
+ `ControlPlaneCatalogFetcher: ${endpointName} is stale — control plane unreachable (failures=${state.consecutiveFailures}, stale=${staleLabel}): ${errMsg}`,
251
+ err instanceof Error ? err : undefined,
252
+ );
253
+ } else {
254
+ logger.warn(
255
+ `ControlPlaneCatalogFetcher: ${endpointName} fetch failed, retaining prior cached value (failures=${state.consecutiveFailures}): ${errMsg}`,
256
+ err instanceof Error ? err : undefined,
257
+ );
258
+ }
259
+ // NOTE: cached value is intentionally preserved (last-known-good).
260
+ }
261
+ }
@@ -0,0 +1,136 @@
1
+ import { inject, injectable } from "@codemation/core";
2
+ import type { Clock, OAuthFlowExecutor, OAuthMaterial } from "@codemation/core";
3
+
4
+ import { ApplicationTokens } from "../applicationTokens";
5
+ import type { LoggerFactory } from "../application/logging/Logger";
6
+ import { CredentialSecretCipher } from "../domain/credentials/CredentialSecretCipher";
7
+ import type {
8
+ CredentialOAuth2MaterialRecord,
9
+ CredentialStore,
10
+ } from "../domain/credentials/CredentialServices";
11
+
12
+ /**
13
+ * Reads OAuth2 material for a credential instance and proactively refreshes it
14
+ * when the stored access token is past (or within `REFRESH_LEAD_MS` of) expiry.
15
+ *
16
+ * Why this exists: most OAuth2 consumers in the host pipe an access token to a
17
+ * raw HTTP call (MCP transport, webhook outbound, etc.) and have no SDK-level
18
+ * 401-and-refresh behaviour. Without proactive refresh, the stored token goes
19
+ * stale ~1h after the OAuth callback and every consumer fails with 401 until
20
+ * the user manually reconnects. The Gmail trigger doesn't hit this because
21
+ * `googleapis.OAuth2Client` refreshes internally — that's the exception, not
22
+ * the rule.
23
+ *
24
+ * Concurrency: a single in-flight refresh per instanceId. Concurrent reads
25
+ * during a refresh share the same promise so we don't invalidate the refresh
26
+ * token by exchanging it twice in parallel.
27
+ */
28
+ @injectable()
29
+ export class CredentialOAuth2MaterialReader {
30
+ private static readonly REFRESH_LEAD_MS = 60_000;
31
+
32
+ private readonly inFlightRefresh = new Map<string, Promise<OAuthMaterial>>();
33
+
34
+ constructor(
35
+ @inject(ApplicationTokens.CredentialStore) private readonly credentialStore: CredentialStore,
36
+ @inject(CredentialSecretCipher) private readonly credentialSecretCipher: CredentialSecretCipher,
37
+ @inject(ApplicationTokens.OAuthFlowExecutor) private readonly oauthFlowExecutor: OAuthFlowExecutor,
38
+ @inject(ApplicationTokens.Clock) private readonly clock: Clock,
39
+ @inject(ApplicationTokens.LoggerFactory) private readonly loggers: LoggerFactory,
40
+ ) {}
41
+
42
+ async readMaterial(instanceId: string): Promise<OAuthMaterial> {
43
+ const encrypted = await this.credentialStore.getOAuth2Material(instanceId);
44
+ if (!encrypted) {
45
+ throw new Error(`CredentialOAuth2MaterialReader: instance "${instanceId}" has no OAuth2 material`);
46
+ }
47
+ const current = this.decrypt(encrypted);
48
+ if (!this.shouldRefresh(current)) {
49
+ return current;
50
+ }
51
+ return this.refreshSingleFlight(instanceId, current, encrypted);
52
+ }
53
+
54
+ private shouldRefresh(material: OAuthMaterial): boolean {
55
+ if (!material.expiresAt) return false;
56
+ if (!material.refreshToken) return false;
57
+ const expiryMs = Date.parse(material.expiresAt);
58
+ if (Number.isNaN(expiryMs)) return false;
59
+ return this.clock.now().getTime() + CredentialOAuth2MaterialReader.REFRESH_LEAD_MS >= expiryMs;
60
+ }
61
+
62
+ private refreshSingleFlight(
63
+ instanceId: string,
64
+ current: OAuthMaterial,
65
+ encrypted: CredentialOAuth2MaterialRecord,
66
+ ): Promise<OAuthMaterial> {
67
+ const inflight = this.inFlightRefresh.get(instanceId);
68
+ if (inflight) return inflight;
69
+ const next = this.doRefresh(instanceId, current, encrypted).finally(() => {
70
+ this.inFlightRefresh.delete(instanceId);
71
+ });
72
+ this.inFlightRefresh.set(instanceId, next);
73
+ return next;
74
+ }
75
+
76
+ private async doRefresh(
77
+ instanceId: string,
78
+ current: OAuthMaterial,
79
+ encrypted: CredentialOAuth2MaterialRecord,
80
+ ): Promise<OAuthMaterial> {
81
+ const logger = this.loggers.create("CredentialOAuth2MaterialReader");
82
+ const instance = await this.credentialStore.getInstance(instanceId);
83
+ if (!instance) {
84
+ throw new Error(`CredentialOAuth2MaterialReader: credential instance "${instanceId}" not found`);
85
+ }
86
+ let refreshed: OAuthMaterial;
87
+ try {
88
+ refreshed = await this.oauthFlowExecutor.refresh({ typeId: instance.typeId, instanceId, material: current });
89
+ } catch (error) {
90
+ logger.warn(
91
+ `CredentialOAuth2MaterialReader: token refresh failed for instance "${instanceId}" — returning stale material`,
92
+ error instanceof Error ? error : undefined,
93
+ );
94
+ return current;
95
+ }
96
+ const reEncrypted = this.credentialSecretCipher.encrypt({
97
+ accessToken: refreshed.accessToken,
98
+ refreshToken: refreshed.refreshToken ?? null,
99
+ expiresAt: refreshed.expiresAt ?? null,
100
+ grantedScopes: refreshed.grantedScopes.join(" "),
101
+ });
102
+ await this.credentialStore.saveOAuth2Material({
103
+ instanceId,
104
+ encryptedJson: reEncrypted.encryptedJson,
105
+ encryptionKeyId: reEncrypted.encryptionKeyId,
106
+ schemaVersion: reEncrypted.schemaVersion,
107
+ metadata: {
108
+ providerId: encrypted.providerId,
109
+ connectedEmail: encrypted.connectedEmail,
110
+ connectedAt: encrypted.connectedAt,
111
+ scopes: [...refreshed.grantedScopes],
112
+ updatedAt: this.clock.now().toISOString(),
113
+ },
114
+ });
115
+ logger.info(`CredentialOAuth2MaterialReader: refreshed token for instance "${instanceId}"`);
116
+ return refreshed;
117
+ }
118
+
119
+ private decrypt(record: CredentialOAuth2MaterialRecord): OAuthMaterial {
120
+ const json = this.credentialSecretCipher.decrypt(record) as {
121
+ accessToken?: unknown;
122
+ refreshToken?: unknown;
123
+ expiresAt?: unknown;
124
+ grantedScopes?: unknown;
125
+ };
126
+ return {
127
+ accessToken: typeof json.accessToken === "string" ? json.accessToken : "",
128
+ refreshToken: typeof json.refreshToken === "string" ? json.refreshToken : undefined,
129
+ expiresAt: typeof json.expiresAt === "string" ? json.expiresAt : undefined,
130
+ grantedScopes:
131
+ typeof json.grantedScopes === "string"
132
+ ? json.grantedScopes.split(/\s+/).filter((s) => s.length > 0)
133
+ : [],
134
+ };
135
+ }
136
+ }
@@ -0,0 +1,48 @@
1
+ import { inject, injectable } from "@codemation/core";
2
+ import type { Hono } from "hono";
3
+ import { ApplicationTokens } from "../applicationTokens";
4
+ import type { CredentialStore } from "../domain/credentials/CredentialServices";
5
+ import { InternalHmacAuthMiddleware } from "../pairing/InternalHmacAuthMiddleware";
6
+ import type { InternalHonoApiRouteRegistrar } from "../presentation/http/hono/InternalHonoApiRouteRegistrar";
7
+
8
+ /**
9
+ * Registers GET /internal/credentials — HMAC-verified endpoint that returns the status
10
+ * list of credential instances (no token material). Used by the concierge (Story 5) to
11
+ * inspect what credentials are connected.
12
+ */
13
+ @injectable()
14
+ export class InternalCredentialsListRegistrar implements InternalHonoApiRouteRegistrar {
15
+ constructor(
16
+ @inject(InternalHmacAuthMiddleware) private readonly hmacMiddleware: InternalHmacAuthMiddleware,
17
+ @inject(ApplicationTokens.CredentialStore) private readonly credentialStore: CredentialStore,
18
+ ) {}
19
+
20
+ register(app: Hono): void {
21
+ app.get("/internal/credentials", this.hmacMiddleware.handle(), async (c) => {
22
+ const instances = await this.credentialStore.listInstances();
23
+ const result = await Promise.all(
24
+ instances.map(async (instance) => {
25
+ const oauth2Material = await this.credentialStore.getOAuth2Material(instance.instanceId);
26
+ return {
27
+ instanceId: instance.instanceId,
28
+ typeId: instance.typeId,
29
+ displayName: instance.displayName,
30
+ setupStatus: instance.setupStatus,
31
+ createdAt: instance.createdAt,
32
+ updatedAt: instance.updatedAt,
33
+ oauth2: oauth2Material
34
+ ? {
35
+ providerId: oauth2Material.providerId,
36
+ connectedEmail: oauth2Material.connectedEmail,
37
+ connectedAt: oauth2Material.connectedAt,
38
+ scopes: oauth2Material.scopes,
39
+ updatedAt: oauth2Material.updatedAt,
40
+ }
41
+ : null,
42
+ };
43
+ }),
44
+ );
45
+ return c.json(result);
46
+ });
47
+ }
48
+ }
@@ -0,0 +1,125 @@
1
+ import { inject, injectable } from "@codemation/core";
2
+ import type { Hono } from "hono";
3
+ import type { Logger, LoggerFactory } from "../application/logging/Logger";
4
+ import { ApplicationTokens } from "../applicationTokens";
5
+ import type { CredentialStore } from "../domain/credentials/CredentialServices";
6
+ import { CredentialSecretCipher } from "../domain/credentials/CredentialSecretCipher";
7
+ import { InternalHmacAuthMiddleware } from "../pairing/InternalHmacAuthMiddleware";
8
+ import type { InternalHonoApiRouteRegistrar } from "../presentation/http/hono/InternalHonoApiRouteRegistrar";
9
+ import { CredentialInstanceService, CredentialTestService } from "../domain/credentials/CredentialServices";
10
+
11
+ /**
12
+ * Body shape pushed from the control-plane OAuth broker after a successful
13
+ * authorization code exchange. Defined per docs/pairing-protocol.md § Token Push.
14
+ */
15
+ type CredentialPushBody = Readonly<{
16
+ credentialInstanceId: string;
17
+ accessToken: string;
18
+ refreshToken?: string | null;
19
+ expiresAt: number;
20
+ scopesGranted: ReadonlyArray<string>;
21
+ }>;
22
+
23
+ /**
24
+ * Registers POST /internal/credentials/push — HMAC-verified endpoint that receives
25
+ * OAuth tokens from the control-plane broker and writes them to the local credential store.
26
+ *
27
+ * If `refreshToken` is null/undefined the existing one is preserved (per Story 3 open question 5).
28
+ */
29
+ @injectable()
30
+ export class InternalCredentialsPushRegistrar implements InternalHonoApiRouteRegistrar {
31
+ private readonly logger: Logger;
32
+
33
+ constructor(
34
+ @inject(InternalHmacAuthMiddleware) private readonly hmacMiddleware: InternalHmacAuthMiddleware,
35
+ @inject(ApplicationTokens.CredentialStore) private readonly credentialStore: CredentialStore,
36
+ @inject(CredentialSecretCipher) private readonly cipher: CredentialSecretCipher,
37
+ @inject(CredentialInstanceService) private readonly credentialInstanceService: CredentialInstanceService,
38
+ @inject(CredentialTestService) private readonly credentialTestService: CredentialTestService,
39
+ @inject(ApplicationTokens.LoggerFactory) loggerFactory: LoggerFactory,
40
+ ) {
41
+ this.logger = loggerFactory.create("InternalCredentialsPushRegistrar");
42
+ }
43
+
44
+ register(app: Hono): void {
45
+ app.post("/internal/credentials/push", this.hmacMiddleware.handle(), async (c) => {
46
+ try {
47
+ const rawBody = c.get("body" as never) as string | undefined;
48
+ const body: CredentialPushBody = rawBody ? JSON.parse(rawBody) : await c.req.json();
49
+
50
+ if (!body.credentialInstanceId || typeof body.credentialInstanceId !== "string") {
51
+ return c.json({ error: "credentialInstanceId is required" }, 400);
52
+ }
53
+ if (!body.accessToken || typeof body.accessToken !== "string") {
54
+ return c.json({ error: "accessToken is required" }, 400);
55
+ }
56
+
57
+ const nowIso = new Date().toISOString();
58
+ const expiryIso =
59
+ typeof body.expiresAt === "number" ? new Date(body.expiresAt * 1000).toISOString() : undefined;
60
+
61
+ // Merge: if the push omits refreshToken, preserve the existing one.
62
+ const existingMaterial = await this.credentialStore.getOAuth2Material(body.credentialInstanceId);
63
+ const existingDecrypted = existingMaterial ? this.cipher.decrypt(existingMaterial) : undefined;
64
+ const refreshToken =
65
+ body.refreshToken !== undefined && body.refreshToken !== null
66
+ ? body.refreshToken
67
+ : (existingDecrypted?.refresh_token as string | undefined);
68
+
69
+ const tokenMaterial = Object.freeze({
70
+ access_token: body.accessToken,
71
+ refresh_token: refreshToken,
72
+ expiry: expiryIso,
73
+ scope: body.scopesGranted.join(" "),
74
+ });
75
+
76
+ const encrypted = this.cipher.encrypt(tokenMaterial);
77
+
78
+ await this.credentialStore.saveOAuth2Material({
79
+ instanceId: body.credentialInstanceId,
80
+ encryptedJson: encrypted.encryptedJson,
81
+ encryptionKeyId: encrypted.encryptionKeyId,
82
+ schemaVersion: encrypted.schemaVersion,
83
+ metadata: {
84
+ providerId: body.credentialInstanceId,
85
+ connectedAt: nowIso,
86
+ scopes: [...body.scopesGranted],
87
+ updatedAt: nowIso,
88
+ },
89
+ });
90
+
91
+ // Attempt to mark the credential instance as ready if it exists.
92
+ // Not a hard requirement — broker may push before the instance is created locally.
93
+ try {
94
+ await this.credentialInstanceService.markOAuth2Connected(body.credentialInstanceId, nowIso);
95
+ } catch (markError) {
96
+ this.logger.warn(
97
+ "markOAuth2Connected failed (instance may not exist locally yet)",
98
+ markError instanceof Error ? markError : undefined,
99
+ );
100
+ }
101
+
102
+ this.logger.info(`Credential push applied for instance ${body.credentialInstanceId}`);
103
+
104
+ // Auto-test the credential so the UI shows a real health badge
105
+ // (healthy / failing) instead of "untested". For the broker type
106
+ // this just validates the token material we just persisted, so it
107
+ // never makes an outbound call to the provider. Soft-fails — a bad
108
+ // test result shouldn't fail the push that already succeeded.
109
+ try {
110
+ await this.credentialTestService.test(body.credentialInstanceId);
111
+ } catch (testError) {
112
+ this.logger.warn(
113
+ `Credential auto-test failed for instance ${body.credentialInstanceId}`,
114
+ testError instanceof Error ? testError : undefined,
115
+ );
116
+ }
117
+
118
+ return c.json({ ok: true });
119
+ } catch (error) {
120
+ this.logger.error("Credential push handler error", error instanceof Error ? error : undefined);
121
+ return c.json({ error: "Internal server error" }, 500);
122
+ }
123
+ });
124
+ }
125
+ }