@hs-x/cli 0.3.0 → 0.3.7

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 (146) hide show
  1. package/README.md +6 -0
  2. package/dist/cli/index.d.ts.map +1 -1
  3. package/dist/cli/index.js +20 -49
  4. package/dist/cli/index.js.map +1 -1
  5. package/dist/cli/kit.d.ts.map +1 -1
  6. package/dist/cli/kit.js +1 -1
  7. package/dist/cli/kit.js.map +1 -1
  8. package/dist/cloudflare-auth.d.ts +19 -0
  9. package/dist/cloudflare-auth.d.ts.map +1 -1
  10. package/dist/cloudflare-auth.js +83 -2
  11. package/dist/cloudflare-auth.js.map +1 -1
  12. package/dist/cloudflare-oauth.d.ts.map +1 -1
  13. package/dist/cloudflare-oauth.js +18 -19
  14. package/dist/cloudflare-oauth.js.map +1 -1
  15. package/dist/cloudflare-scoped-resource.d.ts +41 -0
  16. package/dist/cloudflare-scoped-resource.d.ts.map +1 -0
  17. package/dist/cloudflare-scoped-resource.js +74 -0
  18. package/dist/cloudflare-scoped-resource.js.map +1 -0
  19. package/dist/command-catalog.d.ts +13 -0
  20. package/dist/command-catalog.d.ts.map +1 -0
  21. package/dist/command-catalog.js +49 -0
  22. package/dist/command-catalog.js.map +1 -0
  23. package/dist/commands/account.d.ts +6 -6
  24. package/dist/commands/api.js +1 -1
  25. package/dist/commands/completion.d.ts.map +1 -1
  26. package/dist/commands/completion.js +22 -34
  27. package/dist/commands/completion.js.map +1 -1
  28. package/dist/commands/connect.d.ts.map +1 -1
  29. package/dist/commands/connect.js +97 -18
  30. package/dist/commands/connect.js.map +1 -1
  31. package/dist/commands/control-plane-read.d.ts +8 -8
  32. package/dist/commands/control-plane-read.d.ts.map +1 -1
  33. package/dist/commands/control-plane-read.js +2 -9
  34. package/dist/commands/control-plane-read.js.map +1 -1
  35. package/dist/commands/deploy-promote.d.ts +1 -1
  36. package/dist/commands/deploy-retry.d.ts +20 -0
  37. package/dist/commands/deploy-retry.d.ts.map +1 -0
  38. package/dist/commands/deploy-retry.js +106 -0
  39. package/dist/commands/deploy-retry.js.map +1 -0
  40. package/dist/commands/deploy.d.ts +14 -9
  41. package/dist/commands/deploy.d.ts.map +1 -1
  42. package/dist/commands/deploy.js +603 -294
  43. package/dist/commands/deploy.js.map +1 -1
  44. package/dist/commands/dev.d.ts.map +1 -1
  45. package/dist/commands/dev.js +43 -5
  46. package/dist/commands/dev.js.map +1 -1
  47. package/dist/commands/flags.d.ts +10 -5
  48. package/dist/commands/flags.d.ts.map +1 -1
  49. package/dist/commands/flags.js +208 -80
  50. package/dist/commands/flags.js.map +1 -1
  51. package/dist/commands/help-command.d.ts.map +1 -1
  52. package/dist/commands/help-command.js +41 -60
  53. package/dist/commands/help-command.js.map +1 -1
  54. package/dist/commands/init.d.ts +2 -2
  55. package/dist/commands/init.d.ts.map +1 -1
  56. package/dist/commands/init.js +34 -5
  57. package/dist/commands/init.js.map +1 -1
  58. package/dist/commands/link.d.ts +1 -1
  59. package/dist/commands/link.d.ts.map +1 -1
  60. package/dist/commands/link.js +74 -15
  61. package/dist/commands/link.js.map +1 -1
  62. package/dist/commands/migrate.d.ts +1 -1
  63. package/dist/commands/rollback.d.ts +1 -1
  64. package/dist/commands/secrets.d.ts +2 -2
  65. package/dist/commands/status.d.ts +1 -1
  66. package/dist/commands/validate.d.ts +2 -2
  67. package/dist/constants.d.ts +1 -1
  68. package/dist/constants.js +1 -1
  69. package/dist/control-plane-tenant-provisioning.d.ts +24 -0
  70. package/dist/control-plane-tenant-provisioning.d.ts.map +1 -0
  71. package/dist/control-plane-tenant-provisioning.js +85 -0
  72. package/dist/control-plane-tenant-provisioning.js.map +1 -0
  73. package/dist/deploy-targets.d.ts +24 -0
  74. package/dist/deploy-targets.d.ts.map +1 -0
  75. package/dist/deploy-targets.js +38 -0
  76. package/dist/deploy-targets.js.map +1 -0
  77. package/dist/dev/compat-shim.d.ts +3 -3
  78. package/dist/dev/compat-shim.d.ts.map +1 -1
  79. package/dist/dev/compat-shim.js +28 -4
  80. package/dist/dev/compat-shim.js.map +1 -1
  81. package/dist/dev/invoke.d.ts +3 -4
  82. package/dist/dev/invoke.d.ts.map +1 -1
  83. package/dist/dev/invoke.js +9 -4
  84. package/dist/dev/invoke.js.map +1 -1
  85. package/dist/dev/loopback-http-listener.d.ts +18 -0
  86. package/dist/dev/loopback-http-listener.d.ts.map +1 -0
  87. package/dist/dev/loopback-http-listener.js +91 -0
  88. package/dist/dev/loopback-http-listener.js.map +1 -0
  89. package/dist/dev/session-manager.d.ts.map +1 -1
  90. package/dist/dev/session-manager.js +3 -2
  91. package/dist/dev/session-manager.js.map +1 -1
  92. package/dist/errors-registry.d.ts.map +1 -1
  93. package/dist/errors-registry.js +33 -0
  94. package/dist/errors-registry.js.map +1 -1
  95. package/dist/help.d.ts.map +1 -1
  96. package/dist/help.js +20 -3
  97. package/dist/help.js.map +1 -1
  98. package/dist/init/templates.d.ts +1 -1
  99. package/dist/init/templates.d.ts.map +1 -1
  100. package/dist/init/templates.js +71 -1
  101. package/dist/init/templates.js.map +1 -1
  102. package/dist/machine-artifact.d.ts +16 -0
  103. package/dist/machine-artifact.d.ts.map +1 -0
  104. package/dist/machine-artifact.js +18 -0
  105. package/dist/machine-artifact.js.map +1 -0
  106. package/dist/machine-id.d.ts +15 -0
  107. package/dist/machine-id.d.ts.map +1 -1
  108. package/dist/machine-id.js +53 -0
  109. package/dist/machine-id.js.map +1 -1
  110. package/dist/mutation-consent.d.ts +20 -0
  111. package/dist/mutation-consent.d.ts.map +1 -0
  112. package/dist/mutation-consent.js +9 -0
  113. package/dist/mutation-consent.js.map +1 -0
  114. package/dist/output-context.d.ts +3 -0
  115. package/dist/output-context.d.ts.map +1 -1
  116. package/dist/output-context.js +6 -0
  117. package/dist/output-context.js.map +1 -1
  118. package/dist/prompt.d.ts.map +1 -1
  119. package/dist/prompt.js +123 -48
  120. package/dist/prompt.js.map +1 -1
  121. package/dist/reporter/index.js +1 -1
  122. package/dist/reporter/index.js.map +1 -1
  123. package/dist/reporter/style.d.ts.map +1 -1
  124. package/dist/reporter/style.js +5 -0
  125. package/dist/reporter/style.js.map +1 -1
  126. package/dist/services/live.d.ts +1 -1
  127. package/dist/services/telemetry.d.ts +1 -2
  128. package/dist/services/telemetry.d.ts.map +1 -1
  129. package/dist/services/telemetry.js +1 -10
  130. package/dist/services/telemetry.js.map +1 -1
  131. package/dist/telemetry.d.ts.map +1 -1
  132. package/dist/telemetry.js +1 -7
  133. package/dist/telemetry.js.map +1 -1
  134. package/dist/tenant-data-plane-provisioning.d.ts +35 -0
  135. package/dist/tenant-data-plane-provisioning.d.ts.map +1 -0
  136. package/dist/tenant-data-plane-provisioning.js +38 -0
  137. package/dist/tenant-data-plane-provisioning.js.map +1 -0
  138. package/package.json +11 -11
  139. package/dist/cloudflare-worker-versions.d.ts +0 -184
  140. package/dist/cloudflare-worker-versions.d.ts.map +0 -1
  141. package/dist/cloudflare-worker-versions.js +0 -360
  142. package/dist/cloudflare-worker-versions.js.map +0 -1
  143. package/dist/rollback-traffic.d.ts +0 -85
  144. package/dist/rollback-traffic.d.ts.map +0 -1
  145. package/dist/rollback-traffic.js +0 -204
  146. package/dist/rollback-traffic.js.map +0 -1
@@ -1,14 +1,14 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { createHash, randomBytes } from 'node:crypto';
3
- import { cp, mkdir, mkdtemp, readFile, readdir, stat, writeFile } from 'node:fs/promises';
3
+ import { cp, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rename, rmdir, stat, unlink, writeFile, } from 'node:fs/promises';
4
4
  import { createServer } from 'node:http';
5
5
  import { tmpdir } from 'node:os';
6
- import { basename, dirname, join, relative, resolve } from 'node:path';
6
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
7
7
  import { pathToFileURL } from 'node:url';
8
8
  import * as Command from '@effect/cli/Command';
9
9
  import * as Options from '@effect/cli/Options';
10
10
  import { cloudflareResourceName, generateHubSpotRuntimeProject, generateProjectArtifacts, planPortalSchemaManagement, planWorkerCronTriggers, renderAlchemyProgram, renderCloudflareWorkerEntrypoint, stampMigratedRunServerlessShimOrigin, } from '@hs-x/codegen';
11
- import { HttpClientRequest, Schema, schemas } from '@hs-x/types';
11
+ import { HttpClientRequest, Schema, schemas, signDeploymentAttestationGrant } from '@hs-x/types';
12
12
  import { validateProject } from '@hs-x/validator';
13
13
  import { Effect, Option } from 'effect';
14
14
  import { isLinked } from '../account-store.js';
@@ -16,24 +16,30 @@ import { exitWith } from '../cli-error.js';
16
16
  import { accountIdOption, controlPlaneUrlOption, cwdOption, deployIdOption, forceOption, jsonOption, projectIdOption, runHandler, runQuarantined, userIdOption, yesOption, } from '../cli/kit.js';
17
17
  import { resolveCloudflareCredentials } from '../cloudflare-kv.js';
18
18
  import { loadCloudflarePointer } from '../cloudflare-pointer.js';
19
+ import { resolveOrProvisionCloudflareScopedResource } from '../cloudflare-scoped-resource.js';
19
20
  import { DEFAULT_CONTROL_PLANE_URL, formatConfigUrl, loadCliConfig, resolveControlPlaneUrl, } from '../config.js';
20
21
  import { CLI_VERSION } from '../constants.js';
21
22
  import { controlPlaneAuthHeaders } from '../control-plane-fetch.js';
22
23
  import { loadCreateControlPlane } from '../control-plane-loader.js';
24
+ import { createControlPlaneTenantProvisioningAuthority, } from '../control-plane-tenant-provisioning.js';
23
25
  import { captureDeployRevision } from '../deploy-revision-capture.js';
26
+ import { resolveDeployTargets } from '../deploy-targets.js';
24
27
  import { executeCliHttp } from '../effect-http.js';
25
28
  import { createDeployHubSpotDeveloperClient } from '../hubspot-developer-client.js';
26
29
  import { hydrateAncestorEnv } from '../load-env.js';
27
30
  import { LocalProjectPickerCancelled, resolveLocalProject } from '../local-project-picker.js';
28
- import { getMachineId } from '../machine-id.js';
31
+ import { getMachineId, getMachineSecret, previewMachineId } from '../machine-id.js';
32
+ import { requestMutationConsent } from '../mutation-consent.js';
29
33
  import { cachePath } from '../paths.js';
30
34
  import { readProjectBinding, writeProjectBinding } from '../project-binding.js';
31
35
  import { isInteractive, promptConfirm, promptMultiSelect, promptPassword, promptSelect, promptText, } from '../prompt.js';
32
36
  import { createReporter } from '../reporter/index.js';
33
37
  import { Cwd } from '../services/cwd.js';
38
+ import { createTenantDataPlaneProvisioningCoordinator, } from '../tenant-data-plane-provisioning.js';
34
39
  import { ensureTenantStateStore, preflightTenantStateCredentials, } from '../tenant-state.js';
35
40
  import { defaultTokenKeyCustodyAdapter, escrowTokenKey, resolveEscrowedTokenKey, tokenKeyCustodyIdFor, } from '../token-key-custody.js';
36
41
  import { deployPromoteCommand, requestControlPlaneDeployPromotion } from './deploy-promote.js';
42
+ import { withTransientDeployRetry } from './deploy-retry.js';
37
43
  async function hostedHttp(input) {
38
44
  const headers = { ...(input.headers ?? {}) };
39
45
  let request = HttpClientRequest.make(input.method ?? 'GET')(input.url).pipe(HttpClientRequest.setHeaders(headers));
@@ -149,10 +155,11 @@ async function promptMissingText(message, current, validate) {
149
155
  * across concurrent worker deploys), then persist answers to the process env so
150
156
  * sibling workers reuse them.
151
157
  *
152
- * OAuth-only bootstrap deploys retain the historical warning-only behavior
153
- * because the Auth page may not exist until after upload. Card backends fail
154
- * closed before the Worker deploy: shipping one without the HMAC key guarantees
155
- * that every live `hubspot.fetch` returns 401.
158
+ * OAuth-only bootstrap deploys may proceed with a warning because the Auth page
159
+ * may not exist until after upload, but every HubSpot-authored runtime route
160
+ * returns 503 until the secret is configured and the Worker is redeployed.
161
+ * Card backends fail earlier, at deploy time, because they cannot be useful in
162
+ * that bootstrap state.
156
163
  */
157
164
  /** @internal Exported for the deploy credential-gate regression suite. */
158
165
  export async function resolveHubSpotRuntimeCredentials(input) {
@@ -333,7 +340,7 @@ async function promptMissingCloudflareCredentials(input) {
333
340
  reporter.info('Easiest: `hs-x connect cloudflare` signs in via your browser and stores it for every future deploy.');
334
341
  reporter.info(`Or paste an API token (HS-X template): ${CLOUDFLARE_TOKEN_TEMPLATE_URL}`);
335
342
  for (;;) {
336
- const token = await promptText({
343
+ const token = await promptPassword({
337
344
  message: 'Paste your Cloudflare API token',
338
345
  validate: (v) => (v.length >= 30 ? undefined : 'Token looks too short.'),
339
346
  });
@@ -591,7 +598,6 @@ export async function deployCommand({ argv, root, json, }) {
591
598
  const explicitControlPlaneUrl = resolveFlag(argv, '--control-plane-url');
592
599
  const controlPlaneUrl = explicitControlPlaneUrl ?? (planOnly ? undefined : process.env.HSX_CONTROL_PLANE_URL);
593
600
  const linked = await isLinked();
594
- const machineId = await getMachineId();
595
601
  const userId = resolveFlag(argv, '--user-id') ?? process.env.HSX_USER_ID ?? 'local-cli-user';
596
602
  const resolvedAcct = await resolveAccountIdOrPrompt({
597
603
  argv,
@@ -610,10 +616,6 @@ export async function deployCommand({ argv, root, json, }) {
610
616
  // One-off --account-id override: warn but leave the recorded binding intact.
611
617
  progress?.reporter.warn('HSX_W_DEPLOY_ACCOUNT_OVERRIDE', `This project is bound to ${resolvedAcct.overrodeBinding}; deploying under ${accountId} for this run only (binding unchanged).`);
612
618
  }
613
- else {
614
- // Sticky binding: remember which HS-X account this project deploys under.
615
- await writeProjectBinding(root, accountId).catch(() => { });
616
- }
617
619
  }
618
620
  const explicitProjectId = resolveFlag(argv, '--project-id') ?? process.env.HSX_PROJECT_ID;
619
621
  let projectId = explicitProjectId;
@@ -646,9 +648,17 @@ export async function deployCommand({ argv, root, json, }) {
646
648
  projectId = await readHsxConfigProjectId(root);
647
649
  }
648
650
  const needsRuntime = workers.some((worker) => worker.capabilities.some((capability) => capability.runtimeNeeds.some((need) => need !== 'hubspot-only')));
649
- const cloudflareDeployRequested = !buildOnly &&
650
- (explicitCloudflareDeployRequested || (!planOnly && needsRuntime && !explicitHubspotUpload));
651
- const hubspotUploadRequested = !buildOnly && (explicitHubspotUpload || (!planOnly && !cloudflareDryRun && Boolean(projectId)));
651
+ const needsHubSpotBundle = needsRuntime || app.mcpServers.length > 0;
652
+ const { deploymentMode, cloudflareDeployRequested, hubspotUploadRequested } = resolveDeployTargets({
653
+ buildOnly,
654
+ planOnly,
655
+ needsRuntime,
656
+ hasProjectId: Boolean(projectId),
657
+ hubspotOnly,
658
+ explicitHubspotUpload,
659
+ explicitCloudflareDeploy: explicitCloudflareDeployRequested,
660
+ cloudflareDryRun,
661
+ });
652
662
  if (echo) {
653
663
  echo.info(`Runtime: ${needsRuntime ? 'Cloudflare Worker required' : 'HubSpot-only compatible'}`);
654
664
  if (hubspotOnly && needsRuntime) {
@@ -723,15 +733,11 @@ export async function deployCommand({ argv, root, json, }) {
723
733
  projectId = answer;
724
734
  }
725
735
  }
726
- if (projectId && accountId && !resolvedAcct.overrodeBinding) {
727
- // Sticky binding, project half: project-scoped reads (status/logs/routes/
728
- // drift) resolve their project id from here when no flag is passed.
729
- await writeProjectBinding(root, accountId, projectId).catch(() => { });
730
- }
731
- if (!json && willChangeAnything && !flagYes && isInteractive()) {
736
+ if (willChangeAnything) {
732
737
  const summary = buildDeployPlanSummary({
733
738
  accountId,
734
739
  projectId,
740
+ deploymentMode,
735
741
  workers,
736
742
  portalSchemaPlan,
737
743
  applySchema,
@@ -742,14 +748,37 @@ export async function deployCommand({ argv, root, json, }) {
742
748
  shouldRecordControlPlane,
743
749
  promoteWhenHealthy,
744
750
  });
745
- progress?.reporter.block(summary);
746
- const confirm = await promptConfirm({
751
+ const machineOutput = json || progress?.reporter.mode === 'ndjson';
752
+ if (!machineOutput && !flagYes)
753
+ progress?.reporter.block(summary);
754
+ const consent = await requestMutationConsent({
755
+ yes: flagYes,
756
+ machineOutput,
757
+ interactive: isInteractive(),
747
758
  message: 'Apply this plan?',
748
- default: false,
759
+ confirm: promptConfirm,
749
760
  });
750
- if (confirm === undefined || confirm === false) {
751
- return cancelledResult(argv, 'deploy');
761
+ if (consent === 'require-yes') {
762
+ return inputValidationError({
763
+ argv,
764
+ command: 'deploy',
765
+ json,
766
+ code: 'HSX_E_INPUT_INVALID',
767
+ message: 'Deploy requires explicit consent in machine or non-interactive mode.',
768
+ hint: 'Review with `hs-x deploy --plan`, then re-run the mutating command with --yes.',
769
+ detail: summary,
770
+ });
752
771
  }
772
+ if (consent === 'cancelled')
773
+ return cancelledResult(argv, 'deploy');
774
+ }
775
+ // Consent is the boundary for durable command state too. A declined deploy,
776
+ // JSON/non-TTY invocation without --yes, or --plan must not create a machine
777
+ // id or rewrite the project's sticky account/project binding.
778
+ const machineId = willChangeAnything ? await getMachineId() : await previewMachineId();
779
+ const machineSecret = willChangeAnything && !linked ? await getMachineSecret() : undefined;
780
+ if (willChangeAnything && accountId && !resolvedAcct.overrodeBinding) {
781
+ await writeProjectBinding(root, accountId, projectId).catch(() => { });
753
782
  }
754
783
  // `--apply-schema` writes property definitions to the live portal, but
755
784
  // `--plan`/`--dry-run` promise to change nothing. Honoring both would let a
@@ -1017,6 +1046,7 @@ export async function deployCommand({ argv, root, json, }) {
1017
1046
  controlPlanePlan,
1018
1047
  userId,
1019
1048
  machineId,
1049
+ ...(machineSecret ? { machineSecret } : {}),
1020
1050
  heartbeatEnabled: noHeartbeat
1021
1051
  ? false
1022
1052
  : controlPlaneBackedPlan
@@ -1039,8 +1069,8 @@ export async function deployCommand({ argv, root, json, }) {
1039
1069
  }
1040
1070
  if (echo && cloudflareDeployResult) {
1041
1071
  for (const worker of cloudflareDeployResult.workers) {
1042
- if (worker.unauthenticatedInvoke) {
1043
- echo.warn('HSX_W_DEPLOY_UNAUTHENTICATED_INVOKE', `${worker.workerName}: workflow-action invoke endpoints accept unsigned requests until the app's OAuth client secret is configured (redeploy with HSX_HUBSPOT_CLIENT_SECRET / --hubspot-client-secret after the OAuth install).`);
1072
+ if (worker.signatureVerificationUnavailable) {
1073
+ echo.warn('HSX_W_DEPLOY_SIGNATURE_UNAVAILABLE', `${worker.workerName}: HubSpot workflow-action and trigger requests will fail closed until the app's OAuth client secret is configured (redeploy with HSX_HUBSPOT_CLIENT_SECRET / --hubspot-client-secret after the OAuth install).`);
1044
1074
  }
1045
1075
  }
1046
1076
  }
@@ -1135,7 +1165,7 @@ export async function deployCommand({ argv, root, json, }) {
1135
1165
  // Default order (skipped when we already uploaded first above): generate the
1136
1166
  // HubSpot bundle from the deployed Worker URL, then upload.
1137
1167
  if (!uploadFirstOrigin && hubspotUploadRequested && validation.ok) {
1138
- const bundleStep = needsRuntime ? progress?.step('Generating HubSpot bundle') : undefined;
1168
+ const bundleStep = needsHubSpotBundle ? progress?.step('Generating HubSpot bundle') : undefined;
1139
1169
  try {
1140
1170
  await ensureHubSpotRuntimeProjectArtifacts({
1141
1171
  argv,
@@ -1208,6 +1238,16 @@ export async function deployCommand({ argv, root, json, }) {
1208
1238
  : planOnly
1209
1239
  ? 'plan'
1210
1240
  : 'local-artifacts',
1241
+ deploymentMode,
1242
+ targets: {
1243
+ cloudflare: {
1244
+ requested: cloudflareDeployRequested,
1245
+ dryRun: cloudflareDryRun,
1246
+ },
1247
+ hubspot: {
1248
+ uploadRequested: hubspotUploadRequested,
1249
+ },
1250
+ },
1211
1251
  root,
1212
1252
  hubspotOnly,
1213
1253
  needsRuntime,
@@ -1253,6 +1293,7 @@ export async function deployCommand({ argv, root, json, }) {
1253
1293
  hubspotUploadResult,
1254
1294
  linked,
1255
1295
  machineId,
1296
+ machineSecret,
1256
1297
  });
1257
1298
  }
1258
1299
  // Record tenant state + emit the deploy event whenever the worker is actually
@@ -1267,14 +1308,17 @@ export async function deployCommand({ argv, root, json, }) {
1267
1308
  plan: controlPlanePlan,
1268
1309
  environment: resolveFlag(argv, '--env') ?? resolveFlag(argv, '--environment') ?? 'production',
1269
1310
  });
1270
- await emitAnonymousDeployEvent({
1271
- argv,
1272
- machineId,
1273
- projectId: controlPlanePlan?.projectId ?? projectId,
1274
- deployId: cloudflareDeployResult.deployId,
1275
- workerUrl: cloudflareDeployResult.workers.find((worker) => worker.url)?.url,
1276
- environment: resolveFlag(argv, '--env') ?? resolveFlag(argv, '--environment') ?? 'production',
1277
- });
1311
+ if (machineSecret) {
1312
+ await emitAnonymousDeployEvent({
1313
+ argv,
1314
+ machineId,
1315
+ machineSecret,
1316
+ projectId: controlPlanePlan?.projectId ?? projectId,
1317
+ deployId: cloudflareDeployResult.deployId,
1318
+ workerUrl: cloudflareDeployResult.workers.find((worker) => worker.url)?.url,
1319
+ environment: resolveFlag(argv, '--env') ?? resolveFlag(argv, '--environment') ?? 'production',
1320
+ });
1321
+ }
1278
1322
  }
1279
1323
  // 0 = clean, 20 = partial (worker live, HubSpot upload failed — distinct so
1280
1324
  // automation doesn't treat a serving worker as a total failure), 1 = failure.
@@ -1287,6 +1331,7 @@ function buildDeployPlanSummary(input) {
1287
1331
  lines.push(` account: ${input.accountId}`);
1288
1332
  if (input.projectId)
1289
1333
  lines.push(` project: ${input.projectId}`);
1334
+ lines.push(` deploy mode: ${input.deploymentMode}`);
1290
1335
  lines.push(` workers: ${input.workers.length} (${capCount} capabilities)`);
1291
1336
  if (input.applySchema && input.portalSchemaPlan) {
1292
1337
  const adds = input.portalSchemaPlan.actions.filter((item) => item.kind === 'will-create-object' || item.kind === 'will-create-property').length;
@@ -1305,8 +1350,7 @@ function buildDeployPlanSummary(input) {
1305
1350
  if (input.cloudflareDeployRequested) {
1306
1351
  lines.push(` cloudflare: ${input.cloudflareDryRun ? 'dry-run' : 'deploy'}`);
1307
1352
  }
1308
- if (input.hubspotUpload)
1309
- lines.push(' hubspot upload: yes');
1353
+ lines.push(` hubspot upload: ${input.hubspotUpload ? 'yes' : 'no'}`);
1310
1354
  return lines.join('\n');
1311
1355
  }
1312
1356
  function createUnlinkedDeployPlan(input) {
@@ -1350,6 +1394,7 @@ async function emitAnonymousDeployEvent(input) {
1350
1394
  headers: {
1351
1395
  'content-type': 'application/json',
1352
1396
  accept: 'application/json',
1397
+ authorization: `Bearer ${input.machineSecret}`,
1353
1398
  },
1354
1399
  body: JSON.stringify({
1355
1400
  schema_version: 1,
@@ -1445,7 +1490,12 @@ function deployDoneMessage(input, firstWorkerUrl) {
1445
1490
  }
1446
1491
  /** The railed key/value facts of what this deploy produced (human/plain). */
1447
1492
  function buildDeploySummaryRows(input) {
1448
- const rows = [];
1493
+ const rows = [
1494
+ {
1495
+ key: 'deploy mode',
1496
+ value: input.plan.deploymentMode,
1497
+ },
1498
+ ];
1449
1499
  if (input.controlPlanePlan) {
1450
1500
  rows.push({
1451
1501
  key: 'deploy',
@@ -1523,7 +1573,10 @@ function buildDeploySummaryRows(input) {
1523
1573
  }
1524
1574
  }
1525
1575
  if (!input.linked && input.cloudflareDeployResult) {
1526
- rows.push({ key: 'history', value: machineDashboardUrl(input.machineId) });
1576
+ rows.push({
1577
+ key: 'history',
1578
+ value: machineDashboardUrl(input.machineId, input.machineSecret),
1579
+ });
1527
1580
  }
1528
1581
  return rows;
1529
1582
  }
@@ -1622,13 +1675,13 @@ function renderDeployNdjsonSummary(input) {
1622
1675
  .join(', ');
1623
1676
  reporter.info(`Cloudflare deploy: ${deployed}`);
1624
1677
  for (const worker of input.cloudflareDeployResult.workers) {
1625
- if (worker.unauthenticatedInvoke) {
1626
- reporter.warn('HSX_W_DEPLOY_UNAUTHENTICATED_INVOKE', `${worker.workerName}: workflow-action invoke endpoints accept unsigned requests until the app's OAuth client secret is configured (redeploy with HSX_HUBSPOT_CLIENT_SECRET / --hubspot-client-secret after the OAuth install).`);
1678
+ if (worker.signatureVerificationUnavailable) {
1679
+ reporter.warn('HSX_W_DEPLOY_SIGNATURE_UNAVAILABLE', `${worker.workerName}: HubSpot workflow-action and trigger requests will fail closed until the app's OAuth client secret is configured (redeploy with HSX_HUBSPOT_CLIENT_SECRET / --hubspot-client-secret after the OAuth install).`);
1627
1680
  }
1628
1681
  }
1629
1682
  }
1630
1683
  if (!input.linked && input.cloudflareDeployResult) {
1631
- reporter.info(`Deploy history for this machine: ${machineDashboardUrl(input.machineId)}`);
1684
+ reporter.info(`Deploy history for this machine: ${machineDashboardUrl(input.machineId, input.machineSecret)}`);
1632
1685
  }
1633
1686
  if (input.hubspotUploadResult) {
1634
1687
  const upload = input.hubspotUploadResult;
@@ -1678,9 +1731,12 @@ function renderDeployNdjsonSummary(input) {
1678
1731
  }
1679
1732
  reporter.done(deployDoneMessage(input, firstWorkerUrl), input.plan.ok ? 0 : input.plan.partial ? 20 : 1);
1680
1733
  }
1681
- function machineDashboardUrl(machineId) {
1734
+ function machineDashboardUrl(machineId, machineSecret) {
1682
1735
  const base = process.env.HSX_DASHBOARD_URL ?? 'https://app.hs-x.dev';
1683
- return `${base.replace(/\/$/, '')}/m/${encodeURIComponent(machineId)}`;
1736
+ const url = new URL(`/m/${encodeURIComponent(machineId)}`, `${base.replace(/\/$/, '')}/`);
1737
+ if (machineSecret)
1738
+ url.hash = new URLSearchParams({ access: machineSecret }).toString();
1739
+ return url.toString();
1684
1740
  }
1685
1741
  /**
1686
1742
  * The HubSpot developer-console URL for an uploaded app's auth tab, where a
@@ -2128,7 +2184,7 @@ function defaultHubSpotFieldType(type) {
2128
2184
  return 'text';
2129
2185
  }
2130
2186
  }
2131
- async function executeCloudflareDeploy({ argv, root, workers, controlPlanePlan, controlPlaneUrl, userId, machineId, heartbeatEnabled, dryRun, note, }) {
2187
+ async function executeCloudflareDeploy({ argv, root, workers, controlPlanePlan, controlPlaneUrl, userId, machineId, machineSecret, heartbeatEnabled, dryRun, note, }) {
2132
2188
  // Fail fast on missing Cloudflare credentials BEFORE deploying any Worker, so
2133
2189
  // an unlinked deploy can't leave a Worker live and then error on the
2134
2190
  // tenant-state write for a missing account id.
@@ -2171,6 +2227,17 @@ async function executeCloudflareDeploy({ argv, root, workers, controlPlanePlan,
2171
2227
  });
2172
2228
  }
2173
2229
  const sourcePaths = await discoverWorkerSourcePaths(root);
2230
+ // Provision once per tenant trust domain, then let every Worker continue its
2231
+ // deploy in parallel with the exact same resources and verifier secret.
2232
+ const tenantDataPlaneProvisioning = createTenantDataPlaneProvisioningCoordinator(controlPlaneUrl && !dryRun
2233
+ ? {
2234
+ authority: createControlPlaneTenantProvisioningAuthority({
2235
+ controlPlaneUrl,
2236
+ authorizationHeaders: () => controlPlaneAuthHeaders(userId),
2237
+ http: hostedHttp,
2238
+ }),
2239
+ }
2240
+ : {});
2174
2241
  const concurrency = Math.max(1, Math.min(4, workers.length));
2175
2242
  const results = await Effect.runPromise(Effect.forEach(workers.map((worker, index) => ({ worker, index })), ({ worker, index }) => Effect.promise(() => executeCloudflareWorkerDeploy({
2176
2243
  argv,
@@ -2182,8 +2249,10 @@ async function executeCloudflareDeploy({ argv, root, workers, controlPlanePlan,
2182
2249
  controlPlanePlan,
2183
2250
  userId,
2184
2251
  machineId,
2252
+ ...(machineSecret ? { machineSecret } : {}),
2185
2253
  heartbeatEnabled,
2186
2254
  dryRun,
2255
+ tenantDataPlaneProvisioning,
2187
2256
  ...(controlPlaneUrl ? { controlPlaneUrl } : {}),
2188
2257
  ...(note ? { note } : {}),
2189
2258
  })), { concurrency }));
@@ -2192,7 +2261,7 @@ async function executeCloudflareDeploy({ argv, root, workers, controlPlanePlan,
2192
2261
  workers: results,
2193
2262
  };
2194
2263
  }
2195
- async function executeCloudflareWorkerDeploy({ argv, root, worker, index, allowCredentialPrompt, sourcePaths, controlPlanePlan, controlPlaneUrl, userId, machineId, heartbeatEnabled, dryRun, note, }) {
2264
+ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, allowCredentialPrompt, sourcePaths, controlPlanePlan, controlPlaneUrl, userId, machineId, machineSecret, heartbeatEnabled, dryRun, tenantDataPlaneProvisioning, note, }) {
2196
2265
  const emitNote = note ??
2197
2266
  ((line) => {
2198
2267
  process.stderr.write(line);
@@ -2216,10 +2285,24 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, allowC
2216
2285
  const environment = resolveFlag(argv, '--env') ?? resolveFlag(argv, '--environment') ?? 'production';
2217
2286
  const deployCliConfig = loadCliConfig(argv);
2218
2287
  const runtimeControlPlaneUrl = formatConfigUrl(deployCliConfig.runtimeControlPlaneUrl ?? new URL(DEFAULT_CONTROL_PLANE_URL));
2288
+ const exactTenantScope = {
2289
+ accountId: controlPlanePlan.accountId,
2290
+ projectId: controlPlanePlan.projectId,
2291
+ environment,
2292
+ };
2219
2293
  await writeFile(entrypointPath, renderCloudflareWorkerEntrypoint({
2220
2294
  workerImportPath: relativeImportPath(dirname(entrypointPath), workerSourcePath),
2221
2295
  requireActiveCardInstall: appConfig.auth === 'oauth',
2222
- ...(cronPlan.dispatch.length > 0 ? { syncCron: cronPlan } : {}),
2296
+ // Tenant D1/KV and the grant secret are shared across the linked app's
2297
+ // Workers. Wire the matching routes on every linked Worker even though
2298
+ // revision-bound heartbeat/attestation remains primary-only below.
2299
+ ...(controlPlaneUrl ? { tenantDataPlaneScope: exactTenantScope } : {}),
2300
+ ...(cronPlan.dispatch.length > 0
2301
+ ? {
2302
+ syncCron: cronPlan,
2303
+ syncRunScope: exactTenantScope,
2304
+ }
2305
+ : {}),
2223
2306
  ...(appConfig.appEventBatching !== undefined
2224
2307
  ? { appEventBatching: appConfig.appEventBatching }
2225
2308
  : {}),
@@ -2235,7 +2318,10 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, allowC
2235
2318
  },
2236
2319
  }
2237
2320
  : {}),
2238
- ...(heartbeatEnabled
2321
+ // A linked deploy records the primary Worker's Cloudflare revision on
2322
+ // the deploy record, so only that Worker may attest against it. Anonymous
2323
+ // telemetry remains per Worker because it has no revision trust binding.
2324
+ ...(heartbeatEnabled && (!controlPlaneUrl || index === 0)
2239
2325
  ? {
2240
2326
  heartbeat: controlPlaneUrl
2241
2327
  ? {
@@ -2253,6 +2339,7 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, allowC
2253
2339
  controlPlaneUrl: formatConfigUrl(loadCliConfig(argv).runtimeControlPlaneUrl ??
2254
2340
  new URL(DEFAULT_CONTROL_PLANE_URL)),
2255
2341
  machineId,
2342
+ machineSecretBinding: 'HSX_MACHINE_SECRET',
2256
2343
  projectId: controlPlanePlan.projectId,
2257
2344
  deployId: controlPlanePlan.deployId,
2258
2345
  environment,
@@ -2294,7 +2381,12 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, allowC
2294
2381
  // flags KV + grant secret, persisting the scope + secret to local state so the
2295
2382
  // leaveable `hs-x flags` CLI can sign without the control plane.
2296
2383
  const tenantDataPlane = installRuntimeBinding && controlPlaneUrl && hubspotAppId && !dryRun
2297
- ? await ensureTenantDataPlane({
2384
+ ? await tenantDataPlaneProvisioning.resolve({
2385
+ accountId: controlPlanePlan.accountId,
2386
+ projectId: controlPlanePlan.projectId,
2387
+ environment,
2388
+ hubSpotAppId: hubspotAppId,
2389
+ }, (claim) => ensureTenantDataPlane({
2298
2390
  argv,
2299
2391
  root,
2300
2392
  accountId: controlPlanePlan.accountId,
@@ -2302,9 +2394,14 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, allowC
2302
2394
  environment,
2303
2395
  hubSpotAppId: hubspotAppId,
2304
2396
  install: installRuntimeBinding,
2305
- controlPlaneUrl,
2306
- userId,
2307
- ...(note ? { note } : {}),
2397
+ ...(claim ? { canonicalSyncGrantSecretValue: claim.syncGrantSecretValue } : {}),
2398
+ }), async () => {
2399
+ const { stateStore } = await ensureTenantStateStore({ argv, root });
2400
+ return (await stateStore.getBinding({
2401
+ projectId: controlPlanePlan.projectId,
2402
+ environment,
2403
+ hubSpotAppId: hubspotAppId,
2404
+ }))?.syncGrantSecretValue;
2308
2405
  })
2309
2406
  : undefined;
2310
2407
  const hubSpotOAuthSecret = hubspotAppId && controlPlaneUrl
@@ -2376,6 +2473,7 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, allowC
2376
2473
  ? { installKvNamespaceId: installRuntimeBinding.installKvNamespaceId }
2377
2474
  : {}),
2378
2475
  ...(controlPlaneUrl ? { checkpointDataset: 'hsx_checkpoint', observability: true } : {}),
2476
+ ...(controlPlaneUrl ? { versionMetadataBinding: 'CF_VERSION_METADATA' } : {}),
2379
2477
  ...(tenantDataPlane
2380
2478
  ? {
2381
2479
  flagsKvNamespaceId: tenantDataPlane.flagsKvNamespaceId,
@@ -2451,10 +2549,10 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, allowC
2451
2549
  ...(dryRun ? ['--dry-run'] : []),
2452
2550
  ];
2453
2551
  const deployEnv = cloudflareDeployEnv(argv);
2454
- const output = await runCloudflareCommand(command, {
2552
+ const output = await withTransientDeployRetry(() => runCloudflareCommand(command, {
2455
2553
  cwd: root,
2456
2554
  env: deployEnv,
2457
- });
2555
+ }));
2458
2556
  if (installRuntimeBinding?.tokenKeySecretValue && !dryRun) {
2459
2557
  await runCloudflareCommand([
2460
2558
  'bun',
@@ -2471,6 +2569,13 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, allowC
2471
2569
  stdin: installRuntimeBinding.tokenKeySecretValue,
2472
2570
  });
2473
2571
  }
2572
+ if (!controlPlaneUrl && heartbeatEnabled && machineSecret && !dryRun) {
2573
+ await runCloudflareCommand(['bun', 'x', 'wrangler', 'secret', 'put', 'HSX_MACHINE_SECRET', '--name', workerName], {
2574
+ cwd: root,
2575
+ env: deployEnv,
2576
+ stdin: machineSecret,
2577
+ });
2578
+ }
2474
2579
  if (resolvedHubSpotClientSecret && !dryRun) {
2475
2580
  await runCloudflareCommand([
2476
2581
  'bun',
@@ -2494,9 +2599,10 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, allowC
2494
2599
  stdin: billingRuntimeToken.runtimeToken,
2495
2600
  });
2496
2601
  }
2497
- // ADR-015 grant-verifier secret only set when freshly generated (a re-deploy
2498
- // reuses the value already on the Worker + in local state).
2499
- if (tenantDataPlane?.syncGrantSecretValue && !dryRun) {
2602
+ // ADR-015 grant-verifier secret. Cloudflare Worker secrets are write-only and
2603
+ // per Worker, so install the canonical stored value on EVERY deployed Worker
2604
+ // (including one added after the trust domain was first provisioned).
2605
+ if (tenantDataPlane && !dryRun) {
2500
2606
  await runCloudflareCommand([
2501
2607
  'bun',
2502
2608
  'x',
@@ -2552,10 +2658,10 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, allowC
2552
2658
  emitNote(`warning: could not register the tenant Worker URL with the control plane: ${error instanceof Error ? error.message : String(error)}\n`);
2553
2659
  }
2554
2660
  }
2555
- // Without the client secret on the Worker, the runtime accepts UNSIGNED
2556
- // workflow-action invokes (HubSpot must still be able to call them pre-OAuth)
2557
- // surface that as a one-line warning instead of leaving it silent.
2558
- const hasWorkflowActions = worker.capabilities.some((capability) => capability.kind === 'tool');
2661
+ // Without the client secret, all HubSpot-authored request surfaces fail
2662
+ // closed. Surface the unavailable state instead of leaving bootstrap deploys
2663
+ // live but mysteriously non-functional.
2664
+ const hasHubSpotAuthoredIngress = worker.capabilities.some((capability) => capability.kind === 'tool' || capability.kind === 'trigger');
2559
2665
  return {
2560
2666
  workerName,
2561
2667
  workerSourcePath: relative(root, workerSourcePath),
@@ -2566,8 +2672,8 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, allowC
2566
2672
  dryRun,
2567
2673
  stdout: output.stdout,
2568
2674
  stderr: output.stderr,
2569
- ...(hasWorkflowActions && !resolvedHubSpotClientSecret && !dryRun
2570
- ? { unauthenticatedInvoke: true }
2675
+ ...(hasHubSpotAuthoredIngress && !resolvedHubSpotClientSecret && !dryRun
2676
+ ? { signatureVerificationUnavailable: true }
2571
2677
  : {}),
2572
2678
  };
2573
2679
  }
@@ -2620,49 +2726,68 @@ async function ensureTenantDeployInstallRuntimeBinding(input) {
2620
2726
  installKvNamespaceName: existing.installKvNamespaceName,
2621
2727
  tokenKeySecretName: existing.tokenKeySecretName,
2622
2728
  tokenKeyCustody: existing.tokenKeyCustody,
2729
+ ...(existing.tenantD1DatabaseId ? { tenantD1DatabaseId: existing.tenantD1DatabaseId } : {}),
2730
+ ...(existing.tenantD1DatabaseName
2731
+ ? { tenantD1DatabaseName: existing.tenantD1DatabaseName }
2732
+ : {}),
2733
+ ...(existing.flagsKvNamespaceId ? { flagsKvNamespaceId: existing.flagsKvNamespaceId } : {}),
2734
+ ...(existing.flagsKvNamespaceName
2735
+ ? { flagsKvNamespaceName: existing.flagsKvNamespaceName }
2736
+ : {}),
2623
2737
  };
2624
2738
  }
2625
2739
  if (input.dryRun)
2626
2740
  return undefined;
2627
- // ADR-022: generate the token key and escrow it BEFORE creating any
2628
- // Cloudflare resource — if custody is unavailable, the deploy stops with
2629
- // zero mutations instead of minting a key that exists nowhere durable.
2630
2741
  const tokenKeySecretName = 'HSX_TOKEN_KEY';
2631
- const tokenKeySecretValue = randomBytes(32).toString('base64url');
2632
- const tokenKeyCustody = await escrowTokenKey({
2633
- adapter: defaultTokenKeyCustodyAdapter(),
2634
- custodyId: tokenKeyCustodyIdFor({
2635
- ownerId: pointer.ownerId,
2742
+ const installResource = await resolveOrProvisionCloudflareScopedResource({
2743
+ identity: {
2744
+ accountId: input.ownerId,
2636
2745
  projectId: input.projectId,
2637
2746
  environment: input.environment,
2638
2747
  hubSpotAppId: input.hubSpotAppId,
2639
- }),
2640
- tokenKeySecretValue,
2641
- });
2642
- const installKvNamespaceName = installKvNamespaceNameFor({
2643
- accountId: input.ownerId,
2644
- projectId: input.projectId,
2645
- environment: input.environment,
2646
- hubSpotAppId: input.hubSpotAppId,
2647
- });
2648
- const namespaceOutput = await runCloudflareCommand([
2649
- 'bun',
2650
- 'x',
2651
- 'wrangler',
2652
- 'kv',
2653
- 'namespace',
2654
- 'create',
2655
- installKvNamespaceName,
2656
- '--binding',
2657
- 'INSTALL_KV',
2658
- ], {
2659
- cwd: input.root,
2660
- env: cloudflareDeployEnv(input.argv),
2748
+ },
2749
+ kind: 'install-kv',
2750
+ async provisionByName(installKvNamespaceName) {
2751
+ // ADR-022: generate and escrow the token key before creating Cloudflare
2752
+ // state, but only after long ambiguous names have already failed closed.
2753
+ const tokenKeySecretValue = randomBytes(32).toString('base64url');
2754
+ const tokenKeyCustody = await escrowTokenKey({
2755
+ adapter: defaultTokenKeyCustodyAdapter(),
2756
+ custodyId: tokenKeyCustodyIdFor({
2757
+ ownerId: pointer.ownerId,
2758
+ projectId: input.projectId,
2759
+ environment: input.environment,
2760
+ hubSpotAppId: input.hubSpotAppId,
2761
+ }),
2762
+ tokenKeySecretValue,
2763
+ });
2764
+ const namespaceOutput = await runCloudflareCommand([
2765
+ 'bun',
2766
+ 'x',
2767
+ 'wrangler',
2768
+ 'kv',
2769
+ 'namespace',
2770
+ 'create',
2771
+ installKvNamespaceName,
2772
+ '--binding',
2773
+ 'INSTALL_KV',
2774
+ ], {
2775
+ cwd: input.root,
2776
+ env: cloudflareDeployEnv(input.argv),
2777
+ });
2778
+ const resourceId = extractKvNamespaceId(`${namespaceOutput.stdout}\n${namespaceOutput.stderr}`);
2779
+ if (!resourceId) {
2780
+ throw new Error('Could not read Cloudflare KV namespace id from wrangler output.');
2781
+ }
2782
+ return { resourceId, value: { tokenKeySecretValue, tokenKeyCustody } };
2783
+ },
2661
2784
  });
2662
- const installKvNamespaceId = extractKvNamespaceId(`${namespaceOutput.stdout}\n${namespaceOutput.stderr}`);
2663
- if (!installKvNamespaceId) {
2664
- throw new Error('Could not read Cloudflare KV namespace id from wrangler output.');
2785
+ if (!installResource.value) {
2786
+ throw new Error('Install KV provisioning did not return token-key custody.');
2665
2787
  }
2788
+ const installKvNamespaceId = installResource.resourceId;
2789
+ const installKvNamespaceName = installResource.resourceName;
2790
+ const { tokenKeySecretValue, tokenKeyCustody } = installResource.value;
2666
2791
  await stateStore.putBinding(Schema.decodeUnknownSync(schemas.LocalAppRuntimeBinding)({
2667
2792
  schemaVersion: 2,
2668
2793
  ownerId: pointer.ownerId,
@@ -2692,49 +2817,68 @@ async function ensureDeployInstallRuntimeBinding(input) {
2692
2817
  installKvNamespaceId: existing.installKvNamespaceId,
2693
2818
  installKvNamespaceName: existing.installKvNamespaceName,
2694
2819
  tokenKeySecretName: existing.tokenKeySecretName,
2820
+ ...(existing.tenantD1DatabaseId ? { tenantD1DatabaseId: existing.tenantD1DatabaseId } : {}),
2821
+ ...(existing.tenantD1DatabaseName
2822
+ ? { tenantD1DatabaseName: existing.tenantD1DatabaseName }
2823
+ : {}),
2824
+ ...(existing.flagsKvNamespaceId ? { flagsKvNamespaceId: existing.flagsKvNamespaceId } : {}),
2825
+ ...(existing.flagsKvNamespaceName
2826
+ ? { flagsKvNamespaceName: existing.flagsKvNamespaceName }
2827
+ : {}),
2695
2828
  };
2696
2829
  }
2697
2830
  if (input.dryRun) {
2698
2831
  return undefined;
2699
2832
  }
2700
- // ADR-022: the linked path generates the token key at the same seam, so it
2701
- // gets the same escrow — before any Cloudflare mutation. Only the custody
2702
- // REFERENCE lands in local state; the control plane never sees key bytes.
2703
- const tokenKeySecretValue = randomBytes(32).toString('base64url');
2704
- const tokenKeyCustody = await escrowTokenKey({
2705
- adapter: defaultTokenKeyCustodyAdapter(),
2706
- custodyId: tokenKeyCustodyIdFor({
2707
- ownerId: input.accountId,
2833
+ const installResource = await resolveOrProvisionCloudflareScopedResource({
2834
+ identity: {
2835
+ accountId: input.accountId,
2708
2836
  projectId: input.projectId,
2709
2837
  environment: input.environment,
2710
2838
  hubSpotAppId: input.hubSpotAppId,
2711
- }),
2712
- tokenKeySecretValue,
2713
- });
2714
- const installKvNamespaceName = installKvNamespaceNameFor({
2715
- accountId: input.accountId,
2716
- projectId: input.projectId,
2717
- environment: input.environment,
2718
- hubSpotAppId: input.hubSpotAppId,
2719
- });
2720
- const namespaceOutput = await runCloudflareCommand([
2721
- 'bun',
2722
- 'x',
2723
- 'wrangler',
2724
- 'kv',
2725
- 'namespace',
2726
- 'create',
2727
- installKvNamespaceName,
2728
- '--binding',
2729
- 'INSTALL_KV',
2730
- ], {
2731
- cwd: input.root,
2732
- env: cloudflareDeployEnv(input.argv),
2839
+ },
2840
+ kind: 'install-kv',
2841
+ async provisionByName(installKvNamespaceName) {
2842
+ // ADR-022: escrow precedes Cloudflare mutation, while the ambiguity
2843
+ // guard precedes both. The control plane never receives key bytes.
2844
+ const tokenKeySecretValue = randomBytes(32).toString('base64url');
2845
+ const tokenKeyCustody = await escrowTokenKey({
2846
+ adapter: defaultTokenKeyCustodyAdapter(),
2847
+ custodyId: tokenKeyCustodyIdFor({
2848
+ ownerId: input.accountId,
2849
+ projectId: input.projectId,
2850
+ environment: input.environment,
2851
+ hubSpotAppId: input.hubSpotAppId,
2852
+ }),
2853
+ tokenKeySecretValue,
2854
+ });
2855
+ const namespaceOutput = await runCloudflareCommand([
2856
+ 'bun',
2857
+ 'x',
2858
+ 'wrangler',
2859
+ 'kv',
2860
+ 'namespace',
2861
+ 'create',
2862
+ installKvNamespaceName,
2863
+ '--binding',
2864
+ 'INSTALL_KV',
2865
+ ], {
2866
+ cwd: input.root,
2867
+ env: cloudflareDeployEnv(input.argv),
2868
+ });
2869
+ const resourceId = extractKvNamespaceId(`${namespaceOutput.stdout}\n${namespaceOutput.stderr}`);
2870
+ if (!resourceId) {
2871
+ throw new Error('Could not read Cloudflare KV namespace id from wrangler output.');
2872
+ }
2873
+ return { resourceId, value: { tokenKeySecretValue, tokenKeyCustody } };
2874
+ },
2733
2875
  });
2734
- const installKvNamespaceId = extractKvNamespaceId(`${namespaceOutput.stdout}\n${namespaceOutput.stderr}`);
2735
- if (!installKvNamespaceId) {
2736
- throw new Error('Could not read Cloudflare KV namespace id from wrangler output.');
2876
+ if (!installResource.value) {
2877
+ throw new Error('Install KV provisioning did not return token-key custody.');
2737
2878
  }
2879
+ const installKvNamespaceId = installResource.resourceId;
2880
+ const installKvNamespaceName = installResource.resourceName;
2881
+ const { tokenKeySecretValue, tokenKeyCustody } = installResource.value;
2738
2882
  const tokenKeySecretName = 'HSX_TOKEN_KEY';
2739
2883
  await putDeployInstallRuntimeBinding({
2740
2884
  ...input,
@@ -2819,31 +2963,11 @@ async function requestDeployBillingRuntimeToken(input) {
2819
2963
  binding: body.binding,
2820
2964
  };
2821
2965
  }
2822
- function installKvNamespaceNameFor(input) {
2823
- return truncateCloudflareResourceName(`hsx-${cloudflareNameSlug(input.accountId)}-${cloudflareNameSlug(input.projectId)}-${cloudflareNameSlug(input.environment)}-${cloudflareNameSlug(String(input.hubSpotAppId))}-install`);
2824
- }
2825
- function cloudflareNameSlug(value) {
2826
- return (value
2827
- .toLowerCase()
2828
- .replace(/[^a-z0-9]+/g, '-')
2829
- .replace(/^-+|-+$/g, '') || 'x');
2830
- }
2831
- function truncateCloudflareResourceName(value) {
2832
- return value.length <= 63 ? value : value.slice(0, 63).replace(/-+$/g, '');
2833
- }
2834
2966
  function extractKvNamespaceId(output) {
2835
2967
  return (/"id"\s*:\s*"([^"]+)"/.exec(output)?.[1] ??
2836
2968
  /id\s*=\s*"([^"]+)"/.exec(output)?.[1] ??
2837
2969
  /id[:\s]+([a-f0-9]{16,})/i.exec(output)?.[1]);
2838
2970
  }
2839
- // ADR-015/ADR-014 tenant data plane: one project-scoped D1 (flag config +
2840
- // installer PII + platform events) and one flags-snapshot KV per install.
2841
- function tenantD1DatabaseNameFor(input) {
2842
- return truncateCloudflareResourceName(`hsx-${cloudflareNameSlug(input.accountId)}-${cloudflareNameSlug(input.projectId)}-${cloudflareNameSlug(input.environment)}-${cloudflareNameSlug(String(input.hubSpotAppId))}-tenant`);
2843
- }
2844
- function flagsKvNamespaceNameFor(input) {
2845
- return truncateCloudflareResourceName(`hsx-${cloudflareNameSlug(input.accountId)}-${cloudflareNameSlug(input.projectId)}-${cloudflareNameSlug(input.environment)}-${cloudflareNameSlug(String(input.hubSpotAppId))}-flags`);
2846
- }
2847
2971
  function extractD1DatabaseId(output) {
2848
2972
  return (/"database_id"\s*:\s*"([^"]+)"/.exec(output)?.[1] ??
2849
2973
  /database_id\s*=\s*"([^"]+)"/.exec(output)?.[1] ??
@@ -2858,10 +2982,12 @@ function extractD1DatabaseId(output) {
2858
2982
  *
2859
2983
  * Idempotent: re-uses already-provisioned ids + an already-generated secret from
2860
2984
  * local state (so a re-deploy keeps the same secret the Worker already holds).
2861
- * Returns `syncGrantSecretValue` only when freshly generated the caller does
2862
- * a `wrangler secret put` only then. Tenant-data-plane activation is
2863
- * linked-only (codegen emits the options solely for heartbeat.mode==='linked'),
2864
- * so this is never called for unlinked direct-to-HubSpot deploys.
2985
+ * Returns the canonical `syncGrantSecretValue` on every callincluding when
2986
+ * it came from the durable binding — because a newly-added Worker does not yet
2987
+ * have the write-only Cloudflare secret even though sibling Workers do.
2988
+ * Tenant-data-plane activation is linked-only. Codegen receives the exact
2989
+ * tenant scope for every linked Worker independently of primary-only heartbeat
2990
+ * ownership, so this is never called for unlinked direct-to-HubSpot deploys.
2865
2991
  */
2866
2992
  async function ensureTenantDataPlane(input) {
2867
2993
  const { pointer, stateStore } = await ensureTenantStateStore({
@@ -2874,69 +3000,86 @@ async function ensureTenantDataPlane(input) {
2874
3000
  hubSpotAppId: input.hubSpotAppId,
2875
3001
  });
2876
3002
  const env = cloudflareDeployEnv(input.argv);
2877
- const tenantD1DatabaseName = existing?.tenantD1DatabaseName ?? tenantD1DatabaseNameFor(input);
2878
- let tenantD1DatabaseId = existing?.tenantD1DatabaseId;
2879
- if (!tenantD1DatabaseId) {
2880
- // Create, or ADOPT on a name conflict: resource names are deterministic,
2881
- // so a database that already exists is this project's own tenant D1 from
2882
- // an earlier deploy whose local state was lost — failing here would brick
2883
- // every redeploy while the data-holding database sits one lookup away.
2884
- const created = await runCloudflareCommand(['bun', 'x', 'wrangler', 'd1', 'create', tenantD1DatabaseName], { cwd: input.root, env }).catch((error) => {
2885
- if (!/already exists/i.test(String(error)))
2886
- throw error;
2887
- return undefined;
2888
- });
2889
- if (created) {
2890
- tenantD1DatabaseId = extractD1DatabaseId(`${created.stdout}\n${created.stderr}`);
2891
- }
2892
- else {
2893
- const info = await runCloudflareCommand(['bun', 'x', 'wrangler', 'd1', 'info', tenantD1DatabaseName, '--json'], { cwd: input.root, env });
2894
- tenantD1DatabaseId =
2895
- extractD1DatabaseId(info.stdout) ?? /"uuid"\s*:\s*"([^"]+)"/.exec(info.stdout)?.[1];
2896
- }
2897
- if (!tenantD1DatabaseId) {
2898
- throw new Error('Could not read tenant D1 database id from wrangler output.');
2899
- }
2900
- }
2901
- const flagsKvNamespaceName = existing?.flagsKvNamespaceName ?? flagsKvNamespaceNameFor(input);
2902
- let flagsKvNamespaceId = existing?.flagsKvNamespaceId;
2903
- if (!flagsKvNamespaceId) {
2904
- // Same create-or-adopt posture as the tenant D1 above.
2905
- const created = await runCloudflareCommand([
2906
- 'bun',
2907
- 'x',
2908
- 'wrangler',
2909
- 'kv',
2910
- 'namespace',
2911
- 'create',
2912
- flagsKvNamespaceName,
2913
- '--binding',
2914
- 'FLAGS_KV',
2915
- ], { cwd: input.root, env }).catch((error) => {
2916
- if (!/already exists/i.test(String(error)))
2917
- throw error;
2918
- return undefined;
2919
- });
2920
- if (created) {
2921
- flagsKvNamespaceId = extractKvNamespaceId(`${created.stdout}\n${created.stderr}`);
2922
- }
2923
- else {
2924
- const listed = await runCloudflareCommand(['bun', 'x', 'wrangler', 'kv', 'namespace', 'list'], { cwd: input.root, env });
2925
- try {
2926
- const namespaces = JSON.parse(listed.stdout);
2927
- flagsKvNamespaceId = namespaces.find((namespace) => namespace.title === flagsKvNamespaceName)?.id;
3003
+ // Exact local state wins; exact control-plane refs recover a linked deploy
3004
+ // when its tenant-state binding was lost. Name-only create/adopt remains
3005
+ // available for short legacy-compatible names only.
3006
+ const exactTenantD1DatabaseId = existing?.tenantD1DatabaseId ?? input.install.tenantD1DatabaseId;
3007
+ const exactTenantD1DatabaseName = existing?.tenantD1DatabaseName ?? input.install.tenantD1DatabaseName;
3008
+ const tenantD1 = await resolveOrProvisionCloudflareScopedResource({
3009
+ identity: input,
3010
+ kind: 'tenant-d1',
3011
+ ...(exactTenantD1DatabaseId ? { exactResourceId: exactTenantD1DatabaseId } : {}),
3012
+ ...(exactTenantD1DatabaseName ? { exactResourceName: exactTenantD1DatabaseName } : {}),
3013
+ async provisionByName(tenantD1DatabaseName) {
3014
+ const created = await runCloudflareCommand(['bun', 'x', 'wrangler', 'd1', 'create', tenantD1DatabaseName], { cwd: input.root, env }).catch((error) => {
3015
+ if (!/already exists/i.test(String(error)))
3016
+ throw error;
3017
+ return undefined;
3018
+ });
3019
+ let resourceId = created
3020
+ ? extractD1DatabaseId(`${created.stdout}\n${created.stderr}`)
3021
+ : undefined;
3022
+ if (!created) {
3023
+ const info = await runCloudflareCommand(['bun', 'x', 'wrangler', 'd1', 'info', tenantD1DatabaseName, '--json'], { cwd: input.root, env });
3024
+ resourceId =
3025
+ extractD1DatabaseId(info.stdout) ?? /"uuid"\s*:\s*"([^"]+)"/.exec(info.stdout)?.[1];
2928
3026
  }
2929
- catch {
2930
- flagsKvNamespaceId = undefined;
3027
+ if (!resourceId) {
3028
+ throw new Error('Could not read tenant D1 database id from wrangler output.');
2931
3029
  }
2932
- }
2933
- if (!flagsKvNamespaceId) {
2934
- throw new Error('Could not read flags KV namespace id from wrangler output.');
2935
- }
2936
- }
3030
+ return { resourceId };
3031
+ },
3032
+ });
3033
+ const tenantD1DatabaseId = tenantD1.resourceId;
3034
+ const tenantD1DatabaseName = tenantD1.resourceName;
3035
+ const exactFlagsKvNamespaceId = existing?.flagsKvNamespaceId ?? input.install.flagsKvNamespaceId;
3036
+ const exactFlagsKvNamespaceName = existing?.flagsKvNamespaceName ?? input.install.flagsKvNamespaceName;
3037
+ const flagsKv = await resolveOrProvisionCloudflareScopedResource({
3038
+ identity: input,
3039
+ kind: 'flags-kv',
3040
+ ...(exactFlagsKvNamespaceId ? { exactResourceId: exactFlagsKvNamespaceId } : {}),
3041
+ ...(exactFlagsKvNamespaceName ? { exactResourceName: exactFlagsKvNamespaceName } : {}),
3042
+ async provisionByName(flagsKvNamespaceName) {
3043
+ const created = await runCloudflareCommand([
3044
+ 'bun',
3045
+ 'x',
3046
+ 'wrangler',
3047
+ 'kv',
3048
+ 'namespace',
3049
+ 'create',
3050
+ flagsKvNamespaceName,
3051
+ '--binding',
3052
+ 'FLAGS_KV',
3053
+ ], { cwd: input.root, env }).catch((error) => {
3054
+ if (!/already exists/i.test(String(error)))
3055
+ throw error;
3056
+ return undefined;
3057
+ });
3058
+ let resourceId = created
3059
+ ? extractKvNamespaceId(`${created.stdout}\n${created.stderr}`)
3060
+ : undefined;
3061
+ if (!created) {
3062
+ const listed = await runCloudflareCommand(['bun', 'x', 'wrangler', 'kv', 'namespace', 'list'], { cwd: input.root, env });
3063
+ try {
3064
+ const namespaces = JSON.parse(listed.stdout);
3065
+ resourceId = namespaces.find((namespace) => namespace.title === flagsKvNamespaceName)?.id;
3066
+ }
3067
+ catch {
3068
+ resourceId = undefined;
3069
+ }
3070
+ }
3071
+ if (!resourceId) {
3072
+ throw new Error('Could not read flags KV namespace id from wrangler output.');
3073
+ }
3074
+ return { resourceId };
3075
+ },
3076
+ });
3077
+ const flagsKvNamespaceId = flagsKv.resourceId;
3078
+ const flagsKvNamespaceName = flagsKv.resourceName;
2937
3079
  const syncGrantSecretName = 'HSX_SYNC_GRANT_KEY';
2938
- const reusedSecret = existing?.syncGrantSecretValue;
2939
- const syncGrantSecretValue = reusedSecret ?? randomBytes(32).toString('base64url');
3080
+ const syncGrantSecretValue = input.canonicalSyncGrantSecretValue ??
3081
+ existing?.syncGrantSecretValue ??
3082
+ randomBytes(32).toString('base64url');
2940
3083
  await stateStore.putBinding(Schema.decodeUnknownSync(schemas.LocalAppRuntimeBinding)({
2941
3084
  schemaVersion: 2,
2942
3085
  ownerId: pointer.ownerId,
@@ -2964,58 +3107,15 @@ async function ensureTenantDataPlane(input) {
2964
3107
  syncGrantSecretValue,
2965
3108
  updatedAt: new Date().toISOString(),
2966
3109
  }));
2967
- // ADR-015 §5 "Both": register the secret control-plane-side for the (gated)
2968
- // dashboard/agent/sync consumers. Best-effort — the leaveable CLI-direct path
2969
- // reads the value from local state and needs no control plane.
2970
- if (input.controlPlaneUrl && input.userId) {
2971
- try {
2972
- await registerDeploySyncGrantSecret({
2973
- controlPlaneUrl: input.controlPlaneUrl,
2974
- userId: input.userId,
2975
- accountId: input.accountId,
2976
- projectId: input.projectId,
2977
- environment: input.environment,
2978
- hubSpotAppId: input.hubSpotAppId,
2979
- syncGrantSecret: syncGrantSecretValue,
2980
- });
2981
- }
2982
- catch (error) {
2983
- const warning = `warning: could not register the sync grant secret with the control plane: ${error instanceof Error ? error.message : String(error)}\n`;
2984
- if (input.note) {
2985
- input.note(warning);
2986
- }
2987
- else {
2988
- process.stderr.write(warning);
2989
- }
2990
- }
2991
- }
2992
3110
  return {
2993
3111
  tenantD1DatabaseId,
2994
3112
  tenantD1DatabaseName,
2995
3113
  flagsKvNamespaceId,
2996
3114
  flagsKvNamespaceName,
2997
3115
  syncGrantSecretName,
2998
- ...(reusedSecret ? {} : { syncGrantSecretValue }),
3116
+ syncGrantSecretValue,
2999
3117
  };
3000
3118
  }
3001
- async function registerDeploySyncGrantSecret(input) {
3002
- const response = await hostedHttp({
3003
- url: new URL(`/v1/accounts/${encodeURIComponent(input.accountId)}/projects/${encodeURIComponent(input.projectId)}/hubspot-apps/${encodeURIComponent(input.hubSpotAppId)}/sync-grant-secret`, input.controlPlaneUrl),
3004
- method: 'PUT',
3005
- headers: await controlPlaneAuthHeaders(input.userId),
3006
- body: {
3007
- environment: input.environment,
3008
- syncGrantSecret: input.syncGrantSecret,
3009
- },
3010
- });
3011
- const body = await response.json();
3012
- if (!response.ok) {
3013
- const message = isRecord(body) && typeof body.message === 'string'
3014
- ? body.message
3015
- : `Control plane returned ${response.status}.`;
3016
- throw new Error(`Could not register the sync grant secret: ${message}`);
3017
- }
3018
- }
3019
3119
  export function renderWranglerConfig(input) {
3020
3120
  const lines = [
3021
3121
  '# Generated by hs-x. Do not edit by hand.',
@@ -3031,12 +3131,19 @@ export function renderWranglerConfig(input) {
3031
3131
  // when --compatibility-date is set to an older date.
3032
3132
  'compatibility_flags = [ "nodejs_compat", "nodejs_compat_populate_process_env" ]',
3033
3133
  ];
3134
+ if (input.versionMetadataBinding) {
3135
+ lines.push('', '[version_metadata]', `binding = "${tomlString(input.versionMetadataBinding)}"`);
3136
+ }
3034
3137
  if (input.installKvNamespaceId) {
3035
3138
  lines.push('', '[[kv_namespaces]]', 'binding = "INSTALL_KV"', `id = "${tomlString(input.installKvNamespaceId)}"`);
3036
3139
  }
3037
3140
  // ADR-015 flag-snapshot KV (the eval-path hot replica).
3038
3141
  if (input.flagsKvNamespaceId) {
3039
3142
  lines.push('', '[[kv_namespaces]]', 'binding = "FLAGS_KV"', `id = "${tomlString(input.flagsKvNamespaceId)}"`);
3143
+ // Route-level abuse protection for the public flag evaluate + authoring
3144
+ // surfaces. The request key includes deployment identity + exact route, so
3145
+ // a namespace collision cannot merge counters between generated Workers.
3146
+ lines.push('', '[[ratelimits]]', 'name = "HSX_FLAGS_RATE_LIMITER"', `namespace_id = "${flagsRateLimitNamespaceId(input.workerName)}"`, '', ' [ratelimits.simple]', ' limit = 120', ' period = 60');
3040
3147
  }
3041
3148
  // ADR-015/ADR-014 project-scoped tenant D1 (flag config + installer PII +
3042
3149
  // platform events). migrations_dir lets wrangler auto-apply the tenant
@@ -3068,6 +3175,14 @@ export function renderWranglerConfig(input) {
3068
3175
  }
3069
3176
  return `${lines.join('\n')}\n`;
3070
3177
  }
3178
+ function flagsRateLimitNamespaceId(workerName) {
3179
+ let hash = 2_166_136_261;
3180
+ for (const character of `${workerName}:HSX_FLAGS_RATE_LIMITER`) {
3181
+ hash ^= character.codePointAt(0) ?? 0;
3182
+ hash = Math.imul(hash, 16_777_619) >>> 0;
3183
+ }
3184
+ return String(hash || 1);
3185
+ }
3071
3186
  function tomlString(value) {
3072
3187
  return value.replaceAll('\\', '\\\\').replaceAll('"', '\\"');
3073
3188
  }
@@ -3121,8 +3236,161 @@ async function resolveUploadFirstRuntimeOrigin(input) {
3121
3236
  return undefined;
3122
3237
  }
3123
3238
  }
3239
+ const HUBSPOT_GENERATED_LEDGER = '.hs-x/hubspot-generated-files.json';
3240
+ const USER_OWNED_GENERATED_PATHS = new Set(['src/app/cards/package.json']);
3241
+ function generatedFileHash(contents) {
3242
+ return createHash('sha256').update(contents).digest('hex');
3243
+ }
3244
+ function isKnownGeneratedHubSpotPath(file) {
3245
+ return (file === 'hsproject.json' ||
3246
+ file === 'src/app/app-hsmeta.json' ||
3247
+ file === 'src/app/cards/package.json' ||
3248
+ file === 'src/app/cards/_hsx-backend.ts' ||
3249
+ /^src\/app\/(?:workflow-actions|app-objects|app-object-associations|app-events|mcp-server|cards)\/[^/]+-hsmeta\.json$/.test(file));
3250
+ }
3251
+ /** Resolve a ledger path only when it is a known, normalized root-relative artifact. */
3252
+ function resolveGeneratedFilePath(root, file) {
3253
+ if (!isKnownGeneratedHubSpotPath(file) || isAbsolute(file) || file.includes('\\')) {
3254
+ return undefined;
3255
+ }
3256
+ const segments = file.split('/');
3257
+ if (segments.some((segment) => segment === '' || segment === '.' || segment === '..')) {
3258
+ return undefined;
3259
+ }
3260
+ const rootPath = resolve(root);
3261
+ const resolved = resolve(rootPath, file);
3262
+ return resolved.startsWith(`${rootPath}/`) ? resolved : undefined;
3263
+ }
3264
+ async function assertGeneratedPathIsContained(root, path) {
3265
+ const rootRealPath = await realpath(root);
3266
+ let existingAncestor = dirname(path);
3267
+ while (true) {
3268
+ try {
3269
+ const ancestorRealPath = await realpath(existingAncestor);
3270
+ const fromRoot = relative(rootRealPath, ancestorRealPath);
3271
+ if (fromRoot.startsWith('..') || isAbsolute(fromRoot)) {
3272
+ throw new Error(`Generated HubSpot artifact parent escapes the project root: ${path}`);
3273
+ }
3274
+ break;
3275
+ }
3276
+ catch (error) {
3277
+ if (error.code !== 'ENOENT')
3278
+ throw error;
3279
+ const parent = dirname(existingAncestor);
3280
+ if (parent === existingAncestor)
3281
+ throw error;
3282
+ existingAncestor = parent;
3283
+ }
3284
+ }
3285
+ const target = await lstat(path).catch((error) => {
3286
+ if (error.code === 'ENOENT')
3287
+ return undefined;
3288
+ throw error;
3289
+ });
3290
+ if (target?.isSymbolicLink()) {
3291
+ throw new Error(`Generated HubSpot artifact path must not be a symbolic link: ${path}`);
3292
+ }
3293
+ }
3294
+ async function readGeneratedFileLedger(root) {
3295
+ try {
3296
+ const parsed = JSON.parse(await readFile(join(root, HUBSPOT_GENERATED_LEDGER), 'utf8'));
3297
+ if (typeof parsed !== 'object' ||
3298
+ parsed === null ||
3299
+ parsed.version !== 1 ||
3300
+ typeof parsed.files !== 'object' ||
3301
+ parsed.files === null) {
3302
+ return { version: 1, files: {} };
3303
+ }
3304
+ const files = {};
3305
+ for (const [file, hash] of Object.entries(parsed.files)) {
3306
+ if (resolveGeneratedFilePath(root, file) && /^[a-f0-9]{64}$/.test(String(hash))) {
3307
+ files[file] = String(hash);
3308
+ }
3309
+ }
3310
+ return { version: 1, files };
3311
+ }
3312
+ catch {
3313
+ // No heuristic cleanup for projects that predate the ledger, or for a
3314
+ // malformed ledger. Generation can safely establish a fresh ledger.
3315
+ return { version: 1, files: {} };
3316
+ }
3317
+ }
3318
+ async function pruneEmptyGeneratedDirectories(root, from) {
3319
+ const rootPath = resolve(root);
3320
+ let directory = resolve(from);
3321
+ while (directory !== rootPath && directory.startsWith(`${rootPath}/`)) {
3322
+ try {
3323
+ if ((await readdir(directory)).length > 0)
3324
+ return;
3325
+ await rmdir(directory);
3326
+ }
3327
+ catch {
3328
+ return;
3329
+ }
3330
+ directory = dirname(directory);
3331
+ }
3332
+ }
3333
+ async function reconcileHubSpotRuntimeProjectFiles(root, generatedFiles, options = {}) {
3334
+ const previous = await readGeneratedFileLedger(root);
3335
+ const generatedPaths = new Set(Object.keys(generatedFiles));
3336
+ const nextFiles = {};
3337
+ for (const [file, expectedHash] of Object.entries(previous.files)) {
3338
+ if (generatedPaths.has(file) || USER_OWNED_GENERATED_PATHS.has(file))
3339
+ continue;
3340
+ if (options.preservePrevious?.(file)) {
3341
+ nextFiles[file] = expectedHash;
3342
+ continue;
3343
+ }
3344
+ const path = resolveGeneratedFilePath(root, file);
3345
+ if (!path)
3346
+ continue;
3347
+ try {
3348
+ await assertGeneratedPathIsContained(root, path);
3349
+ const current = await readFile(path);
3350
+ if (generatedFileHash(current) !== expectedHash)
3351
+ continue;
3352
+ await unlink(path);
3353
+ await pruneEmptyGeneratedDirectories(root, dirname(path));
3354
+ }
3355
+ catch {
3356
+ // Missing and non-file paths are preserved; only a byte-for-byte owned
3357
+ // regular file is eligible for removal.
3358
+ }
3359
+ }
3360
+ for (const [file, contents] of Object.entries(generatedFiles)) {
3361
+ const path = resolveGeneratedFilePath(root, file);
3362
+ if (!path)
3363
+ throw new Error(`Generated HubSpot artifact has an unsafe path: ${file}`);
3364
+ await assertGeneratedPathIsContained(root, path);
3365
+ // HubSpot tells card authors to add dependencies here. Once created, this
3366
+ // file is user-owned and is deliberately never entered in the ledger.
3367
+ if (USER_OWNED_GENERATED_PATHS.has(file)) {
3368
+ if (!(await stat(path).catch(() => undefined))) {
3369
+ await mkdir(dirname(path), { recursive: true });
3370
+ await writeFile(path, contents);
3371
+ }
3372
+ continue;
3373
+ }
3374
+ await mkdir(dirname(path), { recursive: true });
3375
+ await writeFile(path, contents);
3376
+ nextFiles[file] = generatedFileHash(contents);
3377
+ }
3378
+ const ledgerPath = join(root, HUBSPOT_GENERATED_LEDGER);
3379
+ await assertGeneratedPathIsContained(root, ledgerPath);
3380
+ await mkdir(dirname(ledgerPath), { recursive: true });
3381
+ const temporaryLedgerPath = `${ledgerPath}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
3382
+ await writeFile(temporaryLedgerPath, `${JSON.stringify({ version: 1, files: nextFiles }, null, 2)}\n`);
3383
+ await rename(temporaryLedgerPath, ledgerPath);
3384
+ }
3124
3385
  export async function ensureHubSpotRuntimeProjectArtifacts({ argv, root, app, workers, cloudflareDeployResult, needsRuntime, controlPlanePlan, runtimeOrigin, }) {
3125
- if (!needsRuntime) {
3386
+ const hasMcpServers = app.mcpServers.length > 0;
3387
+ if (!needsRuntime && !hasMcpServers) {
3388
+ // A prior MCP-only generation may have left ledger-owned MCP files. Remove
3389
+ // those declarations, but retain other generated metadata: a display-only
3390
+ // card or schema declaration remains valid when its runtime worker is gone.
3391
+ await reconcileHubSpotRuntimeProjectFiles(root, {}, {
3392
+ preservePrevious: (file) => !file.startsWith('src/app/mcp-server/'),
3393
+ });
3126
3394
  return;
3127
3395
  }
3128
3396
  const runtimeBaseUrl = runtimeOrigin ??
@@ -3135,26 +3403,17 @@ export async function ensureHubSpotRuntimeProjectArtifacts({ argv, root, app, wo
3135
3403
  const redirectUrls = app.auth === 'oauth' && oauthCallbackPath
3136
3404
  ? [`${runtimeBaseUrl.replace(/\/$/, '')}${oauthCallbackPath}`]
3137
3405
  : [];
3406
+ const platformVersion = resolveFlag(argv, '--platform-version') ?? app.platformVersion;
3138
3407
  const generated = generateHubSpotRuntimeProject({
3139
3408
  ...app,
3409
+ platformVersion,
3140
3410
  workers,
3141
3411
  runtimeBaseUrl,
3142
3412
  redirectUrls,
3143
3413
  });
3144
- for (const [file, contents] of Object.entries(generated.files)) {
3145
- const path = join(root, file);
3146
- // src/app/cards/package.json is user-owned once it exists: HubSpot's own
3147
- // build errors instruct users to add card dependencies (e.g. hs-uix) to
3148
- // it, and regenerating the fixed-dep version here silently dropped those
3149
- // deps so the card never built (cold-stranger run #2 blocker B). Keep the
3150
- // user's file verbatim; only generate one when absent.
3151
- if (file === 'src/app/cards/package.json' && (await stat(path).catch(() => undefined))) {
3152
- continue;
3153
- }
3154
- await mkdir(dirname(path), { recursive: true });
3155
- await writeFile(path, contents);
3156
- }
3414
+ await reconcileHubSpotRuntimeProjectFiles(root, generated.files);
3157
3415
  const migratedShimPath = join(root, 'src', 'app', 'cards', '_hsx-migration-run-serverless.ts');
3416
+ await assertGeneratedPathIsContained(root, migratedShimPath);
3158
3417
  if (await stat(migratedShimPath).catch(() => undefined)) {
3159
3418
  const migratedShim = await readFile(migratedShimPath, 'utf8');
3160
3419
  await writeFile(migratedShimPath, stampMigratedRunServerlessShimOrigin(migratedShim, runtimeBaseUrl));
@@ -3169,9 +3428,19 @@ async function readHsxAppScopesForDeploy(root) {
3169
3428
  return [];
3170
3429
  }
3171
3430
  }
3172
- async function readHsxAppConfig(root) {
3431
+ export async function readHsxAppConfig(root) {
3173
3432
  const raw = await readFile(join(root, 'hsx.config.ts'), 'utf8');
3174
- const loaded = parseHsxAppConfigLiteral(raw) ?? (await loadHsxAppConfigModule(root).catch(() => undefined));
3433
+ let loaded = parseHsxAppConfigLiteral(raw);
3434
+ if (!loaded) {
3435
+ try {
3436
+ loaded = await loadHsxAppConfigModule(root);
3437
+ }
3438
+ catch (error) {
3439
+ if (/\bmcpServer\s*\(|\bmcpServers\b/.test(raw)) {
3440
+ throw new Error('Could not evaluate helper-based MCP declarations in hsx.config.ts; refusing to treat the declaration set as empty because that could remove generated MCP metadata.', { cause: error });
3441
+ }
3442
+ }
3443
+ }
3175
3444
  return {
3176
3445
  appName: loaded?.name ?? /name\s*:\s*["'`]([^"'`]+)["'`]/.exec(raw)?.[1] ?? basename(root),
3177
3446
  // No regex fallback for description: a raw-text match could pick up a
@@ -3193,9 +3462,10 @@ async function readHsxAppConfig(root) {
3193
3462
  ...(loaded?.rateLimits !== undefined ? { rateLimits: loaded.rateLimits } : {}),
3194
3463
  ...(loaded?.alerts !== undefined ? { alerts: loaded.alerts } : {}),
3195
3464
  ...(loaded?.redaction !== undefined ? { redaction: loaded.redaction } : {}),
3196
- // Cards are only recoverable from the loaded module (the literal parser
3197
- // can't evaluate `card(...)` calls); an unloadable config yields none.
3465
+ // Helper-based declarations are only recoverable from the loaded module
3466
+ // (the literal parser cannot evaluate card(...) or mcpServer(...) calls).
3198
3467
  cards: loaded?.cards ?? [],
3468
+ mcpServers: loaded?.mcpServers ?? [],
3199
3469
  ...(loaded?.billing ? { billing: loaded.billing } : {}),
3200
3470
  };
3201
3471
  }
@@ -3527,11 +3797,13 @@ async function executeHubSpotOnlyUpload({ argv, root, local, uploadOnly, }) {
3527
3797
  cwd: root,
3528
3798
  });
3529
3799
  await client.projects.ensureProject({ projectName });
3800
+ const hsproject = await readHubSpotProjectConfig(root);
3801
+ const configuredPlatformVersion = typeof hsproject.platformVersion === 'string' ? hsproject.platformVersion : undefined;
3530
3802
  const upload = await client.projects.upload({
3531
3803
  projectName,
3532
3804
  archivePath,
3533
3805
  message: resolveFlag(argv, '--message') ?? 'HS-X HubSpot-only deploy',
3534
- platformVersion: resolveFlag(argv, '--platform-version') ?? '2026.03',
3806
+ platformVersion: resolveFlag(argv, '--platform-version') ?? configuredPlatformVersion ?? '2026.03',
3535
3807
  intermediateRepresentation,
3536
3808
  });
3537
3809
  const buildStatus = await waitForHubSpotBuildStatus({
@@ -3574,11 +3846,11 @@ async function executeHubSpotOnlyUpload({ argv, root, local, uploadOnly, }) {
3574
3846
  local,
3575
3847
  };
3576
3848
  }
3577
- const deploy = await client.projects.deployBuild({
3849
+ const deploy = await withTransientDeployRetry(() => client.projects.deployBuild({
3578
3850
  projectName,
3579
3851
  buildId: upload.buildId,
3580
3852
  force: argv.includes('--force'),
3581
- });
3853
+ }));
3582
3854
  const deployId = readNumericProperty(deploy, 'deployId');
3583
3855
  const deployStatus = deployId === undefined
3584
3856
  ? deploy
@@ -4004,8 +4276,28 @@ async function requestHostedControlPlaneDeployPlan({ request, controlPlaneUrl, u
4004
4276
  return Schema.decodeUnknownSync(schemas.DeployPlanResponse)(body);
4005
4277
  }
4006
4278
  async function requestLocalControlPlaneDeployRecordAndMaybePromote({ request, plan, promoteWhenHealthy, revision, }) {
4279
+ const localAttestationAuth = revision
4280
+ ? { grantSecret: randomBytes(32).toString('base64url'), hubSpotAppId: 1 }
4281
+ : undefined;
4007
4282
  const controlPlane = (await loadCreateControlPlane())({
4008
4283
  now: () => new Date('2026-05-18T14:00:00.000Z'),
4284
+ ...(localAttestationAuth
4285
+ ? {
4286
+ secretsKv: {
4287
+ async get() {
4288
+ return JSON.stringify({
4289
+ accountId: request.accountId,
4290
+ projectId: request.projectId,
4291
+ environment: 'dev',
4292
+ hubSpotAppId: localAttestationAuth.hubSpotAppId,
4293
+ syncGrantSecret: localAttestationAuth.grantSecret,
4294
+ updatedAt: '2026-05-18T14:00:00.000Z',
4295
+ });
4296
+ },
4297
+ async put() { },
4298
+ },
4299
+ }
4300
+ : {}),
4009
4301
  });
4010
4302
  const headers = {
4011
4303
  'content-type': 'application/json',
@@ -4042,20 +4334,37 @@ async function requestLocalControlPlaneDeployRecordAndMaybePromote({ request, pl
4042
4334
  if (!promoteWhenHealthy) {
4043
4335
  return { record };
4044
4336
  }
4337
+ if (!localAttestationAuth || !revision) {
4338
+ throw new Error('Local promotion requires a scoped runtime grant secret, HubSpot app id, and captured Cloudflare revision.');
4339
+ }
4340
+ const attestationBody = JSON.stringify({
4341
+ manifestHash: plan.manifestHash,
4342
+ bindingFingerprint: `bindings:${plan.deployId}`,
4343
+ sdkVersion: CLI_VERSION,
4344
+ taggedResourceCount: Math.max(1, plan.resources.workerNames.length),
4345
+ hsXAccountId: request.accountId,
4346
+ projectId: request.projectId,
4347
+ environment: 'dev',
4348
+ hubSpotAppId: localAttestationAuth.hubSpotAppId,
4349
+ deployId: plan.deployId,
4350
+ revisionId: revision.versionId,
4351
+ timestamp: '2026-05-18T14:00:00.000Z',
4352
+ });
4353
+ const attestationGrant = await signDeploymentAttestationGrant({
4354
+ secret: localAttestationAuth.grantSecret,
4355
+ body: attestationBody,
4356
+ accountId: request.accountId,
4357
+ projectId: request.projectId,
4358
+ environment: 'dev',
4359
+ hubSpotAppId: localAttestationAuth.hubSpotAppId,
4360
+ deployId: plan.deployId,
4361
+ revisionId: revision.versionId,
4362
+ now: new Date('2026-05-18T14:00:00.000Z'),
4363
+ });
4045
4364
  const driftResponse = await controlPlane.fetch(new Request(`https://api.hs-x.dev/v1/projects/${request.projectId}/attestation`, {
4046
4365
  method: 'POST',
4047
- headers,
4048
- body: JSON.stringify({
4049
- manifestHash: plan.manifestHash,
4050
- bindingFingerprint: `bindings:${plan.deployId}`,
4051
- sdkVersion: CLI_VERSION,
4052
- taggedResourceCount: Math.max(1, plan.resources.workerNames.length),
4053
- hsXAccountId: request.accountId,
4054
- projectId: request.projectId,
4055
- environment: 'dev',
4056
- deployId: plan.deployId,
4057
- timestamp: '2026-05-18T14:00:00.000Z',
4058
- }),
4366
+ headers: { ...headers, authorization: `Bearer ${attestationGrant}` },
4367
+ body: attestationBody,
4059
4368
  }));
4060
4369
  const driftBody = await driftResponse.json();
4061
4370
  if (!driftResponse.ok) {