@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,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
+ }
@@ -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
+ }