@hs-x/cli 0.3.6 → 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 (124) 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/completion.d.ts.map +1 -1
  24. package/dist/commands/completion.js +22 -34
  25. package/dist/commands/completion.js.map +1 -1
  26. package/dist/commands/connect.d.ts.map +1 -1
  27. package/dist/commands/connect.js +97 -18
  28. package/dist/commands/connect.js.map +1 -1
  29. package/dist/commands/control-plane-read.d.ts +3 -3
  30. package/dist/commands/control-plane-read.d.ts.map +1 -1
  31. package/dist/commands/control-plane-read.js +2 -9
  32. package/dist/commands/control-plane-read.js.map +1 -1
  33. package/dist/commands/deploy-retry.d.ts +20 -0
  34. package/dist/commands/deploy-retry.d.ts.map +1 -0
  35. package/dist/commands/deploy-retry.js +106 -0
  36. package/dist/commands/deploy-retry.js.map +1 -0
  37. package/dist/commands/deploy.d.ts +11 -8
  38. package/dist/commands/deploy.d.ts.map +1 -1
  39. package/dist/commands/deploy.js +423 -272
  40. package/dist/commands/deploy.js.map +1 -1
  41. package/dist/commands/dev.d.ts.map +1 -1
  42. package/dist/commands/dev.js +43 -5
  43. package/dist/commands/dev.js.map +1 -1
  44. package/dist/commands/flags.d.ts +9 -4
  45. package/dist/commands/flags.d.ts.map +1 -1
  46. package/dist/commands/flags.js +208 -80
  47. package/dist/commands/flags.js.map +1 -1
  48. package/dist/commands/help-command.d.ts.map +1 -1
  49. package/dist/commands/help-command.js +41 -60
  50. package/dist/commands/help-command.js.map +1 -1
  51. package/dist/commands/init.d.ts.map +1 -1
  52. package/dist/commands/init.js +5 -0
  53. package/dist/commands/init.js.map +1 -1
  54. package/dist/commands/link.d.ts.map +1 -1
  55. package/dist/commands/link.js +74 -15
  56. package/dist/commands/link.js.map +1 -1
  57. package/dist/commands/secrets.d.ts +2 -2
  58. package/dist/constants.d.ts +1 -1
  59. package/dist/constants.js +1 -1
  60. package/dist/control-plane-tenant-provisioning.d.ts +24 -0
  61. package/dist/control-plane-tenant-provisioning.d.ts.map +1 -0
  62. package/dist/control-plane-tenant-provisioning.js +85 -0
  63. package/dist/control-plane-tenant-provisioning.js.map +1 -0
  64. package/dist/deploy-targets.d.ts +24 -0
  65. package/dist/deploy-targets.d.ts.map +1 -0
  66. package/dist/deploy-targets.js +38 -0
  67. package/dist/deploy-targets.js.map +1 -0
  68. package/dist/dev/compat-shim.d.ts +3 -3
  69. package/dist/dev/compat-shim.d.ts.map +1 -1
  70. package/dist/dev/compat-shim.js +28 -4
  71. package/dist/dev/compat-shim.js.map +1 -1
  72. package/dist/dev/invoke.d.ts +3 -4
  73. package/dist/dev/invoke.d.ts.map +1 -1
  74. package/dist/dev/invoke.js +9 -4
  75. package/dist/dev/invoke.js.map +1 -1
  76. package/dist/dev/loopback-http-listener.d.ts +18 -0
  77. package/dist/dev/loopback-http-listener.d.ts.map +1 -0
  78. package/dist/dev/loopback-http-listener.js +91 -0
  79. package/dist/dev/loopback-http-listener.js.map +1 -0
  80. package/dist/dev/session-manager.d.ts.map +1 -1
  81. package/dist/dev/session-manager.js +3 -2
  82. package/dist/dev/session-manager.js.map +1 -1
  83. package/dist/errors-registry.d.ts.map +1 -1
  84. package/dist/errors-registry.js +33 -0
  85. package/dist/errors-registry.js.map +1 -1
  86. package/dist/help.d.ts.map +1 -1
  87. package/dist/help.js +20 -3
  88. package/dist/help.js.map +1 -1
  89. package/dist/machine-artifact.d.ts +16 -0
  90. package/dist/machine-artifact.d.ts.map +1 -0
  91. package/dist/machine-artifact.js +18 -0
  92. package/dist/machine-artifact.js.map +1 -0
  93. package/dist/machine-id.d.ts +15 -0
  94. package/dist/machine-id.d.ts.map +1 -1
  95. package/dist/machine-id.js +53 -0
  96. package/dist/machine-id.js.map +1 -1
  97. package/dist/mutation-consent.d.ts +20 -0
  98. package/dist/mutation-consent.d.ts.map +1 -0
  99. package/dist/mutation-consent.js +9 -0
  100. package/dist/mutation-consent.js.map +1 -0
  101. package/dist/output-context.d.ts +3 -0
  102. package/dist/output-context.d.ts.map +1 -1
  103. package/dist/output-context.js +6 -0
  104. package/dist/output-context.js.map +1 -1
  105. package/dist/prompt.d.ts.map +1 -1
  106. package/dist/prompt.js +123 -48
  107. package/dist/prompt.js.map +1 -1
  108. package/dist/reporter/index.js +1 -1
  109. package/dist/reporter/index.js.map +1 -1
  110. package/dist/reporter/style.d.ts.map +1 -1
  111. package/dist/reporter/style.js +5 -0
  112. package/dist/reporter/style.js.map +1 -1
  113. package/dist/services/telemetry.d.ts +1 -2
  114. package/dist/services/telemetry.d.ts.map +1 -1
  115. package/dist/services/telemetry.js +1 -10
  116. package/dist/services/telemetry.js.map +1 -1
  117. package/dist/telemetry.d.ts.map +1 -1
  118. package/dist/telemetry.js +1 -7
  119. package/dist/telemetry.js.map +1 -1
  120. package/dist/tenant-data-plane-provisioning.d.ts +35 -0
  121. package/dist/tenant-data-plane-provisioning.d.ts.map +1 -0
  122. package/dist/tenant-data-plane-provisioning.js +38 -0
  123. package/dist/tenant-data-plane-provisioning.js.map +1 -0
  124. package/package.json +11 -11
@@ -8,7 +8,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;
@@ -647,9 +649,16 @@ export async function deployCommand({ argv, root, json, }) {
647
649
  }
648
650
  const needsRuntime = workers.some((worker) => worker.capabilities.some((capability) => capability.runtimeNeeds.some((need) => need !== 'hubspot-only')));
649
651
  const needsHubSpotBundle = needsRuntime || app.mcpServers.length > 0;
650
- const cloudflareDeployRequested = !buildOnly &&
651
- (explicitCloudflareDeployRequested || (!planOnly && needsRuntime && !explicitHubspotUpload));
652
- const hubspotUploadRequested = !buildOnly && (explicitHubspotUpload || (!planOnly && !cloudflareDryRun && Boolean(projectId)));
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
+ });
653
662
  if (echo) {
654
663
  echo.info(`Runtime: ${needsRuntime ? 'Cloudflare Worker required' : 'HubSpot-only compatible'}`);
655
664
  if (hubspotOnly && needsRuntime) {
@@ -724,15 +733,11 @@ export async function deployCommand({ argv, root, json, }) {
724
733
  projectId = answer;
725
734
  }
726
735
  }
727
- if (projectId && accountId && !resolvedAcct.overrodeBinding) {
728
- // Sticky binding, project half: project-scoped reads (status/logs/routes/
729
- // drift) resolve their project id from here when no flag is passed.
730
- await writeProjectBinding(root, accountId, projectId).catch(() => { });
731
- }
732
- if (!json && willChangeAnything && !flagYes && isInteractive()) {
736
+ if (willChangeAnything) {
733
737
  const summary = buildDeployPlanSummary({
734
738
  accountId,
735
739
  projectId,
740
+ deploymentMode,
736
741
  workers,
737
742
  portalSchemaPlan,
738
743
  applySchema,
@@ -743,14 +748,37 @@ export async function deployCommand({ argv, root, json, }) {
743
748
  shouldRecordControlPlane,
744
749
  promoteWhenHealthy,
745
750
  });
746
- progress?.reporter.block(summary);
747
- 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(),
748
758
  message: 'Apply this plan?',
749
- default: false,
759
+ confirm: promptConfirm,
750
760
  });
751
- if (confirm === undefined || confirm === false) {
752
- 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
+ });
753
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(() => { });
754
782
  }
755
783
  // `--apply-schema` writes property definitions to the live portal, but
756
784
  // `--plan`/`--dry-run` promise to change nothing. Honoring both would let a
@@ -1018,6 +1046,7 @@ export async function deployCommand({ argv, root, json, }) {
1018
1046
  controlPlanePlan,
1019
1047
  userId,
1020
1048
  machineId,
1049
+ ...(machineSecret ? { machineSecret } : {}),
1021
1050
  heartbeatEnabled: noHeartbeat
1022
1051
  ? false
1023
1052
  : controlPlaneBackedPlan
@@ -1040,8 +1069,8 @@ export async function deployCommand({ argv, root, json, }) {
1040
1069
  }
1041
1070
  if (echo && cloudflareDeployResult) {
1042
1071
  for (const worker of cloudflareDeployResult.workers) {
1043
- if (worker.unauthenticatedInvoke) {
1044
- 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).`);
1045
1074
  }
1046
1075
  }
1047
1076
  }
@@ -1209,6 +1238,16 @@ export async function deployCommand({ argv, root, json, }) {
1209
1238
  : planOnly
1210
1239
  ? 'plan'
1211
1240
  : 'local-artifacts',
1241
+ deploymentMode,
1242
+ targets: {
1243
+ cloudflare: {
1244
+ requested: cloudflareDeployRequested,
1245
+ dryRun: cloudflareDryRun,
1246
+ },
1247
+ hubspot: {
1248
+ uploadRequested: hubspotUploadRequested,
1249
+ },
1250
+ },
1212
1251
  root,
1213
1252
  hubspotOnly,
1214
1253
  needsRuntime,
@@ -1254,6 +1293,7 @@ export async function deployCommand({ argv, root, json, }) {
1254
1293
  hubspotUploadResult,
1255
1294
  linked,
1256
1295
  machineId,
1296
+ machineSecret,
1257
1297
  });
1258
1298
  }
1259
1299
  // Record tenant state + emit the deploy event whenever the worker is actually
@@ -1268,14 +1308,17 @@ export async function deployCommand({ argv, root, json, }) {
1268
1308
  plan: controlPlanePlan,
1269
1309
  environment: resolveFlag(argv, '--env') ?? resolveFlag(argv, '--environment') ?? 'production',
1270
1310
  });
1271
- await emitAnonymousDeployEvent({
1272
- argv,
1273
- machineId,
1274
- projectId: controlPlanePlan?.projectId ?? projectId,
1275
- deployId: cloudflareDeployResult.deployId,
1276
- workerUrl: cloudflareDeployResult.workers.find((worker) => worker.url)?.url,
1277
- environment: resolveFlag(argv, '--env') ?? resolveFlag(argv, '--environment') ?? 'production',
1278
- });
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
+ }
1279
1322
  }
1280
1323
  // 0 = clean, 20 = partial (worker live, HubSpot upload failed — distinct so
1281
1324
  // automation doesn't treat a serving worker as a total failure), 1 = failure.
@@ -1288,6 +1331,7 @@ function buildDeployPlanSummary(input) {
1288
1331
  lines.push(` account: ${input.accountId}`);
1289
1332
  if (input.projectId)
1290
1333
  lines.push(` project: ${input.projectId}`);
1334
+ lines.push(` deploy mode: ${input.deploymentMode}`);
1291
1335
  lines.push(` workers: ${input.workers.length} (${capCount} capabilities)`);
1292
1336
  if (input.applySchema && input.portalSchemaPlan) {
1293
1337
  const adds = input.portalSchemaPlan.actions.filter((item) => item.kind === 'will-create-object' || item.kind === 'will-create-property').length;
@@ -1306,8 +1350,7 @@ function buildDeployPlanSummary(input) {
1306
1350
  if (input.cloudflareDeployRequested) {
1307
1351
  lines.push(` cloudflare: ${input.cloudflareDryRun ? 'dry-run' : 'deploy'}`);
1308
1352
  }
1309
- if (input.hubspotUpload)
1310
- lines.push(' hubspot upload: yes');
1353
+ lines.push(` hubspot upload: ${input.hubspotUpload ? 'yes' : 'no'}`);
1311
1354
  return lines.join('\n');
1312
1355
  }
1313
1356
  function createUnlinkedDeployPlan(input) {
@@ -1351,6 +1394,7 @@ async function emitAnonymousDeployEvent(input) {
1351
1394
  headers: {
1352
1395
  'content-type': 'application/json',
1353
1396
  accept: 'application/json',
1397
+ authorization: `Bearer ${input.machineSecret}`,
1354
1398
  },
1355
1399
  body: JSON.stringify({
1356
1400
  schema_version: 1,
@@ -1446,7 +1490,12 @@ function deployDoneMessage(input, firstWorkerUrl) {
1446
1490
  }
1447
1491
  /** The railed key/value facts of what this deploy produced (human/plain). */
1448
1492
  function buildDeploySummaryRows(input) {
1449
- const rows = [];
1493
+ const rows = [
1494
+ {
1495
+ key: 'deploy mode',
1496
+ value: input.plan.deploymentMode,
1497
+ },
1498
+ ];
1450
1499
  if (input.controlPlanePlan) {
1451
1500
  rows.push({
1452
1501
  key: 'deploy',
@@ -1524,7 +1573,10 @@ function buildDeploySummaryRows(input) {
1524
1573
  }
1525
1574
  }
1526
1575
  if (!input.linked && input.cloudflareDeployResult) {
1527
- rows.push({ key: 'history', value: machineDashboardUrl(input.machineId) });
1576
+ rows.push({
1577
+ key: 'history',
1578
+ value: machineDashboardUrl(input.machineId, input.machineSecret),
1579
+ });
1528
1580
  }
1529
1581
  return rows;
1530
1582
  }
@@ -1623,13 +1675,13 @@ function renderDeployNdjsonSummary(input) {
1623
1675
  .join(', ');
1624
1676
  reporter.info(`Cloudflare deploy: ${deployed}`);
1625
1677
  for (const worker of input.cloudflareDeployResult.workers) {
1626
- if (worker.unauthenticatedInvoke) {
1627
- 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).`);
1628
1680
  }
1629
1681
  }
1630
1682
  }
1631
1683
  if (!input.linked && input.cloudflareDeployResult) {
1632
- reporter.info(`Deploy history for this machine: ${machineDashboardUrl(input.machineId)}`);
1684
+ reporter.info(`Deploy history for this machine: ${machineDashboardUrl(input.machineId, input.machineSecret)}`);
1633
1685
  }
1634
1686
  if (input.hubspotUploadResult) {
1635
1687
  const upload = input.hubspotUploadResult;
@@ -1679,9 +1731,12 @@ function renderDeployNdjsonSummary(input) {
1679
1731
  }
1680
1732
  reporter.done(deployDoneMessage(input, firstWorkerUrl), input.plan.ok ? 0 : input.plan.partial ? 20 : 1);
1681
1733
  }
1682
- function machineDashboardUrl(machineId) {
1734
+ function machineDashboardUrl(machineId, machineSecret) {
1683
1735
  const base = process.env.HSX_DASHBOARD_URL ?? 'https://app.hs-x.dev';
1684
- 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();
1685
1740
  }
1686
1741
  /**
1687
1742
  * The HubSpot developer-console URL for an uploaded app's auth tab, where a
@@ -2129,7 +2184,7 @@ function defaultHubSpotFieldType(type) {
2129
2184
  return 'text';
2130
2185
  }
2131
2186
  }
2132
- 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, }) {
2133
2188
  // Fail fast on missing Cloudflare credentials BEFORE deploying any Worker, so
2134
2189
  // an unlinked deploy can't leave a Worker live and then error on the
2135
2190
  // tenant-state write for a missing account id.
@@ -2172,6 +2227,17 @@ async function executeCloudflareDeploy({ argv, root, workers, controlPlanePlan,
2172
2227
  });
2173
2228
  }
2174
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
+ : {});
2175
2241
  const concurrency = Math.max(1, Math.min(4, workers.length));
2176
2242
  const results = await Effect.runPromise(Effect.forEach(workers.map((worker, index) => ({ worker, index })), ({ worker, index }) => Effect.promise(() => executeCloudflareWorkerDeploy({
2177
2243
  argv,
@@ -2183,8 +2249,10 @@ async function executeCloudflareDeploy({ argv, root, workers, controlPlanePlan,
2183
2249
  controlPlanePlan,
2184
2250
  userId,
2185
2251
  machineId,
2252
+ ...(machineSecret ? { machineSecret } : {}),
2186
2253
  heartbeatEnabled,
2187
2254
  dryRun,
2255
+ tenantDataPlaneProvisioning,
2188
2256
  ...(controlPlaneUrl ? { controlPlaneUrl } : {}),
2189
2257
  ...(note ? { note } : {}),
2190
2258
  })), { concurrency }));
@@ -2193,7 +2261,7 @@ async function executeCloudflareDeploy({ argv, root, workers, controlPlanePlan,
2193
2261
  workers: results,
2194
2262
  };
2195
2263
  }
2196
- 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, }) {
2197
2265
  const emitNote = note ??
2198
2266
  ((line) => {
2199
2267
  process.stderr.write(line);
@@ -2217,10 +2285,24 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, allowC
2217
2285
  const environment = resolveFlag(argv, '--env') ?? resolveFlag(argv, '--environment') ?? 'production';
2218
2286
  const deployCliConfig = loadCliConfig(argv);
2219
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
+ };
2220
2293
  await writeFile(entrypointPath, renderCloudflareWorkerEntrypoint({
2221
2294
  workerImportPath: relativeImportPath(dirname(entrypointPath), workerSourcePath),
2222
2295
  requireActiveCardInstall: appConfig.auth === 'oauth',
2223
- ...(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
+ : {}),
2224
2306
  ...(appConfig.appEventBatching !== undefined
2225
2307
  ? { appEventBatching: appConfig.appEventBatching }
2226
2308
  : {}),
@@ -2236,7 +2318,10 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, allowC
2236
2318
  },
2237
2319
  }
2238
2320
  : {}),
2239
- ...(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)
2240
2325
  ? {
2241
2326
  heartbeat: controlPlaneUrl
2242
2327
  ? {
@@ -2254,6 +2339,7 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, allowC
2254
2339
  controlPlaneUrl: formatConfigUrl(loadCliConfig(argv).runtimeControlPlaneUrl ??
2255
2340
  new URL(DEFAULT_CONTROL_PLANE_URL)),
2256
2341
  machineId,
2342
+ machineSecretBinding: 'HSX_MACHINE_SECRET',
2257
2343
  projectId: controlPlanePlan.projectId,
2258
2344
  deployId: controlPlanePlan.deployId,
2259
2345
  environment,
@@ -2295,7 +2381,12 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, allowC
2295
2381
  // flags KV + grant secret, persisting the scope + secret to local state so the
2296
2382
  // leaveable `hs-x flags` CLI can sign without the control plane.
2297
2383
  const tenantDataPlane = installRuntimeBinding && controlPlaneUrl && hubspotAppId && !dryRun
2298
- ? await ensureTenantDataPlane({
2384
+ ? await tenantDataPlaneProvisioning.resolve({
2385
+ accountId: controlPlanePlan.accountId,
2386
+ projectId: controlPlanePlan.projectId,
2387
+ environment,
2388
+ hubSpotAppId: hubspotAppId,
2389
+ }, (claim) => ensureTenantDataPlane({
2299
2390
  argv,
2300
2391
  root,
2301
2392
  accountId: controlPlanePlan.accountId,
@@ -2303,9 +2394,14 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, allowC
2303
2394
  environment,
2304
2395
  hubSpotAppId: hubspotAppId,
2305
2396
  install: installRuntimeBinding,
2306
- controlPlaneUrl,
2307
- userId,
2308
- ...(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;
2309
2405
  })
2310
2406
  : undefined;
2311
2407
  const hubSpotOAuthSecret = hubspotAppId && controlPlaneUrl
@@ -2377,6 +2473,7 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, allowC
2377
2473
  ? { installKvNamespaceId: installRuntimeBinding.installKvNamespaceId }
2378
2474
  : {}),
2379
2475
  ...(controlPlaneUrl ? { checkpointDataset: 'hsx_checkpoint', observability: true } : {}),
2476
+ ...(controlPlaneUrl ? { versionMetadataBinding: 'CF_VERSION_METADATA' } : {}),
2380
2477
  ...(tenantDataPlane
2381
2478
  ? {
2382
2479
  flagsKvNamespaceId: tenantDataPlane.flagsKvNamespaceId,
@@ -2452,10 +2549,10 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, allowC
2452
2549
  ...(dryRun ? ['--dry-run'] : []),
2453
2550
  ];
2454
2551
  const deployEnv = cloudflareDeployEnv(argv);
2455
- const output = await runCloudflareCommand(command, {
2552
+ const output = await withTransientDeployRetry(() => runCloudflareCommand(command, {
2456
2553
  cwd: root,
2457
2554
  env: deployEnv,
2458
- });
2555
+ }));
2459
2556
  if (installRuntimeBinding?.tokenKeySecretValue && !dryRun) {
2460
2557
  await runCloudflareCommand([
2461
2558
  'bun',
@@ -2472,6 +2569,13 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, allowC
2472
2569
  stdin: installRuntimeBinding.tokenKeySecretValue,
2473
2570
  });
2474
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
+ }
2475
2579
  if (resolvedHubSpotClientSecret && !dryRun) {
2476
2580
  await runCloudflareCommand([
2477
2581
  'bun',
@@ -2495,9 +2599,10 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, allowC
2495
2599
  stdin: billingRuntimeToken.runtimeToken,
2496
2600
  });
2497
2601
  }
2498
- // ADR-015 grant-verifier secret only set when freshly generated (a re-deploy
2499
- // reuses the value already on the Worker + in local state).
2500
- 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) {
2501
2606
  await runCloudflareCommand([
2502
2607
  'bun',
2503
2608
  'x',
@@ -2553,10 +2658,10 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, allowC
2553
2658
  emitNote(`warning: could not register the tenant Worker URL with the control plane: ${error instanceof Error ? error.message : String(error)}\n`);
2554
2659
  }
2555
2660
  }
2556
- // Without the client secret on the Worker, the runtime accepts UNSIGNED
2557
- // workflow-action invokes (HubSpot must still be able to call them pre-OAuth)
2558
- // surface that as a one-line warning instead of leaving it silent.
2559
- 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');
2560
2665
  return {
2561
2666
  workerName,
2562
2667
  workerSourcePath: relative(root, workerSourcePath),
@@ -2567,8 +2672,8 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, allowC
2567
2672
  dryRun,
2568
2673
  stdout: output.stdout,
2569
2674
  stderr: output.stderr,
2570
- ...(hasWorkflowActions && !resolvedHubSpotClientSecret && !dryRun
2571
- ? { unauthenticatedInvoke: true }
2675
+ ...(hasHubSpotAuthoredIngress && !resolvedHubSpotClientSecret && !dryRun
2676
+ ? { signatureVerificationUnavailable: true }
2572
2677
  : {}),
2573
2678
  };
2574
2679
  }
@@ -2621,49 +2726,68 @@ async function ensureTenantDeployInstallRuntimeBinding(input) {
2621
2726
  installKvNamespaceName: existing.installKvNamespaceName,
2622
2727
  tokenKeySecretName: existing.tokenKeySecretName,
2623
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
+ : {}),
2624
2737
  };
2625
2738
  }
2626
2739
  if (input.dryRun)
2627
2740
  return undefined;
2628
- // ADR-022: generate the token key and escrow it BEFORE creating any
2629
- // Cloudflare resource — if custody is unavailable, the deploy stops with
2630
- // zero mutations instead of minting a key that exists nowhere durable.
2631
2741
  const tokenKeySecretName = 'HSX_TOKEN_KEY';
2632
- const tokenKeySecretValue = randomBytes(32).toString('base64url');
2633
- const tokenKeyCustody = await escrowTokenKey({
2634
- adapter: defaultTokenKeyCustodyAdapter(),
2635
- custodyId: tokenKeyCustodyIdFor({
2636
- ownerId: pointer.ownerId,
2742
+ const installResource = await resolveOrProvisionCloudflareScopedResource({
2743
+ identity: {
2744
+ accountId: input.ownerId,
2637
2745
  projectId: input.projectId,
2638
2746
  environment: input.environment,
2639
2747
  hubSpotAppId: input.hubSpotAppId,
2640
- }),
2641
- tokenKeySecretValue,
2642
- });
2643
- const installKvNamespaceName = installKvNamespaceNameFor({
2644
- accountId: input.ownerId,
2645
- projectId: input.projectId,
2646
- environment: input.environment,
2647
- hubSpotAppId: input.hubSpotAppId,
2648
- });
2649
- const namespaceOutput = await runCloudflareCommand([
2650
- 'bun',
2651
- 'x',
2652
- 'wrangler',
2653
- 'kv',
2654
- 'namespace',
2655
- 'create',
2656
- installKvNamespaceName,
2657
- '--binding',
2658
- 'INSTALL_KV',
2659
- ], {
2660
- cwd: input.root,
2661
- 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
+ },
2662
2784
  });
2663
- const installKvNamespaceId = extractKvNamespaceId(`${namespaceOutput.stdout}\n${namespaceOutput.stderr}`);
2664
- if (!installKvNamespaceId) {
2665
- 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.');
2666
2787
  }
2788
+ const installKvNamespaceId = installResource.resourceId;
2789
+ const installKvNamespaceName = installResource.resourceName;
2790
+ const { tokenKeySecretValue, tokenKeyCustody } = installResource.value;
2667
2791
  await stateStore.putBinding(Schema.decodeUnknownSync(schemas.LocalAppRuntimeBinding)({
2668
2792
  schemaVersion: 2,
2669
2793
  ownerId: pointer.ownerId,
@@ -2693,49 +2817,68 @@ async function ensureDeployInstallRuntimeBinding(input) {
2693
2817
  installKvNamespaceId: existing.installKvNamespaceId,
2694
2818
  installKvNamespaceName: existing.installKvNamespaceName,
2695
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
+ : {}),
2696
2828
  };
2697
2829
  }
2698
2830
  if (input.dryRun) {
2699
2831
  return undefined;
2700
2832
  }
2701
- // ADR-022: the linked path generates the token key at the same seam, so it
2702
- // gets the same escrow — before any Cloudflare mutation. Only the custody
2703
- // REFERENCE lands in local state; the control plane never sees key bytes.
2704
- const tokenKeySecretValue = randomBytes(32).toString('base64url');
2705
- const tokenKeyCustody = await escrowTokenKey({
2706
- adapter: defaultTokenKeyCustodyAdapter(),
2707
- custodyId: tokenKeyCustodyIdFor({
2708
- ownerId: input.accountId,
2833
+ const installResource = await resolveOrProvisionCloudflareScopedResource({
2834
+ identity: {
2835
+ accountId: input.accountId,
2709
2836
  projectId: input.projectId,
2710
2837
  environment: input.environment,
2711
2838
  hubSpotAppId: input.hubSpotAppId,
2712
- }),
2713
- tokenKeySecretValue,
2714
- });
2715
- const installKvNamespaceName = installKvNamespaceNameFor({
2716
- accountId: input.accountId,
2717
- projectId: input.projectId,
2718
- environment: input.environment,
2719
- hubSpotAppId: input.hubSpotAppId,
2720
- });
2721
- const namespaceOutput = await runCloudflareCommand([
2722
- 'bun',
2723
- 'x',
2724
- 'wrangler',
2725
- 'kv',
2726
- 'namespace',
2727
- 'create',
2728
- installKvNamespaceName,
2729
- '--binding',
2730
- 'INSTALL_KV',
2731
- ], {
2732
- cwd: input.root,
2733
- 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
+ },
2734
2875
  });
2735
- const installKvNamespaceId = extractKvNamespaceId(`${namespaceOutput.stdout}\n${namespaceOutput.stderr}`);
2736
- if (!installKvNamespaceId) {
2737
- 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.');
2738
2878
  }
2879
+ const installKvNamespaceId = installResource.resourceId;
2880
+ const installKvNamespaceName = installResource.resourceName;
2881
+ const { tokenKeySecretValue, tokenKeyCustody } = installResource.value;
2739
2882
  const tokenKeySecretName = 'HSX_TOKEN_KEY';
2740
2883
  await putDeployInstallRuntimeBinding({
2741
2884
  ...input,
@@ -2820,31 +2963,11 @@ async function requestDeployBillingRuntimeToken(input) {
2820
2963
  binding: body.binding,
2821
2964
  };
2822
2965
  }
2823
- function installKvNamespaceNameFor(input) {
2824
- return truncateCloudflareResourceName(`hsx-${cloudflareNameSlug(input.accountId)}-${cloudflareNameSlug(input.projectId)}-${cloudflareNameSlug(input.environment)}-${cloudflareNameSlug(String(input.hubSpotAppId))}-install`);
2825
- }
2826
- function cloudflareNameSlug(value) {
2827
- return (value
2828
- .toLowerCase()
2829
- .replace(/[^a-z0-9]+/g, '-')
2830
- .replace(/^-+|-+$/g, '') || 'x');
2831
- }
2832
- function truncateCloudflareResourceName(value) {
2833
- return value.length <= 63 ? value : value.slice(0, 63).replace(/-+$/g, '');
2834
- }
2835
2966
  function extractKvNamespaceId(output) {
2836
2967
  return (/"id"\s*:\s*"([^"]+)"/.exec(output)?.[1] ??
2837
2968
  /id\s*=\s*"([^"]+)"/.exec(output)?.[1] ??
2838
2969
  /id[:\s]+([a-f0-9]{16,})/i.exec(output)?.[1]);
2839
2970
  }
2840
- // ADR-015/ADR-014 tenant data plane: one project-scoped D1 (flag config +
2841
- // installer PII + platform events) and one flags-snapshot KV per install.
2842
- function tenantD1DatabaseNameFor(input) {
2843
- return truncateCloudflareResourceName(`hsx-${cloudflareNameSlug(input.accountId)}-${cloudflareNameSlug(input.projectId)}-${cloudflareNameSlug(input.environment)}-${cloudflareNameSlug(String(input.hubSpotAppId))}-tenant`);
2844
- }
2845
- function flagsKvNamespaceNameFor(input) {
2846
- return truncateCloudflareResourceName(`hsx-${cloudflareNameSlug(input.accountId)}-${cloudflareNameSlug(input.projectId)}-${cloudflareNameSlug(input.environment)}-${cloudflareNameSlug(String(input.hubSpotAppId))}-flags`);
2847
- }
2848
2971
  function extractD1DatabaseId(output) {
2849
2972
  return (/"database_id"\s*:\s*"([^"]+)"/.exec(output)?.[1] ??
2850
2973
  /database_id\s*=\s*"([^"]+)"/.exec(output)?.[1] ??
@@ -2859,10 +2982,12 @@ function extractD1DatabaseId(output) {
2859
2982
  *
2860
2983
  * Idempotent: re-uses already-provisioned ids + an already-generated secret from
2861
2984
  * local state (so a re-deploy keeps the same secret the Worker already holds).
2862
- * Returns `syncGrantSecretValue` only when freshly generated the caller does
2863
- * a `wrangler secret put` only then. Tenant-data-plane activation is
2864
- * linked-only (codegen emits the options solely for heartbeat.mode==='linked'),
2865
- * 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.
2866
2991
  */
2867
2992
  async function ensureTenantDataPlane(input) {
2868
2993
  const { pointer, stateStore } = await ensureTenantStateStore({
@@ -2875,69 +3000,86 @@ async function ensureTenantDataPlane(input) {
2875
3000
  hubSpotAppId: input.hubSpotAppId,
2876
3001
  });
2877
3002
  const env = cloudflareDeployEnv(input.argv);
2878
- const tenantD1DatabaseName = existing?.tenantD1DatabaseName ?? tenantD1DatabaseNameFor(input);
2879
- let tenantD1DatabaseId = existing?.tenantD1DatabaseId;
2880
- if (!tenantD1DatabaseId) {
2881
- // Create, or ADOPT on a name conflict: resource names are deterministic,
2882
- // so a database that already exists is this project's own tenant D1 from
2883
- // an earlier deploy whose local state was lost — failing here would brick
2884
- // every redeploy while the data-holding database sits one lookup away.
2885
- const created = await runCloudflareCommand(['bun', 'x', 'wrangler', 'd1', 'create', tenantD1DatabaseName], { cwd: input.root, env }).catch((error) => {
2886
- if (!/already exists/i.test(String(error)))
2887
- throw error;
2888
- return undefined;
2889
- });
2890
- if (created) {
2891
- tenantD1DatabaseId = extractD1DatabaseId(`${created.stdout}\n${created.stderr}`);
2892
- }
2893
- else {
2894
- const info = await runCloudflareCommand(['bun', 'x', 'wrangler', 'd1', 'info', tenantD1DatabaseName, '--json'], { cwd: input.root, env });
2895
- tenantD1DatabaseId =
2896
- extractD1DatabaseId(info.stdout) ?? /"uuid"\s*:\s*"([^"]+)"/.exec(info.stdout)?.[1];
2897
- }
2898
- if (!tenantD1DatabaseId) {
2899
- throw new Error('Could not read tenant D1 database id from wrangler output.');
2900
- }
2901
- }
2902
- const flagsKvNamespaceName = existing?.flagsKvNamespaceName ?? flagsKvNamespaceNameFor(input);
2903
- let flagsKvNamespaceId = existing?.flagsKvNamespaceId;
2904
- if (!flagsKvNamespaceId) {
2905
- // Same create-or-adopt posture as the tenant D1 above.
2906
- const created = await runCloudflareCommand([
2907
- 'bun',
2908
- 'x',
2909
- 'wrangler',
2910
- 'kv',
2911
- 'namespace',
2912
- 'create',
2913
- flagsKvNamespaceName,
2914
- '--binding',
2915
- 'FLAGS_KV',
2916
- ], { cwd: input.root, env }).catch((error) => {
2917
- if (!/already exists/i.test(String(error)))
2918
- throw error;
2919
- return undefined;
2920
- });
2921
- if (created) {
2922
- flagsKvNamespaceId = extractKvNamespaceId(`${created.stdout}\n${created.stderr}`);
2923
- }
2924
- else {
2925
- const listed = await runCloudflareCommand(['bun', 'x', 'wrangler', 'kv', 'namespace', 'list'], { cwd: input.root, env });
2926
- try {
2927
- const namespaces = JSON.parse(listed.stdout);
2928
- 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];
2929
3026
  }
2930
- catch {
2931
- flagsKvNamespaceId = undefined;
3027
+ if (!resourceId) {
3028
+ throw new Error('Could not read tenant D1 database id from wrangler output.');
2932
3029
  }
2933
- }
2934
- if (!flagsKvNamespaceId) {
2935
- throw new Error('Could not read flags KV namespace id from wrangler output.');
2936
- }
2937
- }
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;
2938
3079
  const syncGrantSecretName = 'HSX_SYNC_GRANT_KEY';
2939
- const reusedSecret = existing?.syncGrantSecretValue;
2940
- const syncGrantSecretValue = reusedSecret ?? randomBytes(32).toString('base64url');
3080
+ const syncGrantSecretValue = input.canonicalSyncGrantSecretValue ??
3081
+ existing?.syncGrantSecretValue ??
3082
+ randomBytes(32).toString('base64url');
2941
3083
  await stateStore.putBinding(Schema.decodeUnknownSync(schemas.LocalAppRuntimeBinding)({
2942
3084
  schemaVersion: 2,
2943
3085
  ownerId: pointer.ownerId,
@@ -2965,58 +3107,15 @@ async function ensureTenantDataPlane(input) {
2965
3107
  syncGrantSecretValue,
2966
3108
  updatedAt: new Date().toISOString(),
2967
3109
  }));
2968
- // ADR-015 §5 "Both": register the secret control-plane-side for the (gated)
2969
- // dashboard/agent/sync consumers. Best-effort — the leaveable CLI-direct path
2970
- // reads the value from local state and needs no control plane.
2971
- if (input.controlPlaneUrl && input.userId) {
2972
- try {
2973
- await registerDeploySyncGrantSecret({
2974
- controlPlaneUrl: input.controlPlaneUrl,
2975
- userId: input.userId,
2976
- accountId: input.accountId,
2977
- projectId: input.projectId,
2978
- environment: input.environment,
2979
- hubSpotAppId: input.hubSpotAppId,
2980
- syncGrantSecret: syncGrantSecretValue,
2981
- });
2982
- }
2983
- catch (error) {
2984
- const warning = `warning: could not register the sync grant secret with the control plane: ${error instanceof Error ? error.message : String(error)}\n`;
2985
- if (input.note) {
2986
- input.note(warning);
2987
- }
2988
- else {
2989
- process.stderr.write(warning);
2990
- }
2991
- }
2992
- }
2993
3110
  return {
2994
3111
  tenantD1DatabaseId,
2995
3112
  tenantD1DatabaseName,
2996
3113
  flagsKvNamespaceId,
2997
3114
  flagsKvNamespaceName,
2998
3115
  syncGrantSecretName,
2999
- ...(reusedSecret ? {} : { syncGrantSecretValue }),
3116
+ syncGrantSecretValue,
3000
3117
  };
3001
3118
  }
3002
- async function registerDeploySyncGrantSecret(input) {
3003
- const response = await hostedHttp({
3004
- url: new URL(`/v1/accounts/${encodeURIComponent(input.accountId)}/projects/${encodeURIComponent(input.projectId)}/hubspot-apps/${encodeURIComponent(input.hubSpotAppId)}/sync-grant-secret`, input.controlPlaneUrl),
3005
- method: 'PUT',
3006
- headers: await controlPlaneAuthHeaders(input.userId),
3007
- body: {
3008
- environment: input.environment,
3009
- syncGrantSecret: input.syncGrantSecret,
3010
- },
3011
- });
3012
- const body = await response.json();
3013
- if (!response.ok) {
3014
- const message = isRecord(body) && typeof body.message === 'string'
3015
- ? body.message
3016
- : `Control plane returned ${response.status}.`;
3017
- throw new Error(`Could not register the sync grant secret: ${message}`);
3018
- }
3019
- }
3020
3119
  export function renderWranglerConfig(input) {
3021
3120
  const lines = [
3022
3121
  '# Generated by hs-x. Do not edit by hand.',
@@ -3032,12 +3131,19 @@ export function renderWranglerConfig(input) {
3032
3131
  // when --compatibility-date is set to an older date.
3033
3132
  'compatibility_flags = [ "nodejs_compat", "nodejs_compat_populate_process_env" ]',
3034
3133
  ];
3134
+ if (input.versionMetadataBinding) {
3135
+ lines.push('', '[version_metadata]', `binding = "${tomlString(input.versionMetadataBinding)}"`);
3136
+ }
3035
3137
  if (input.installKvNamespaceId) {
3036
3138
  lines.push('', '[[kv_namespaces]]', 'binding = "INSTALL_KV"', `id = "${tomlString(input.installKvNamespaceId)}"`);
3037
3139
  }
3038
3140
  // ADR-015 flag-snapshot KV (the eval-path hot replica).
3039
3141
  if (input.flagsKvNamespaceId) {
3040
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');
3041
3147
  }
3042
3148
  // ADR-015/ADR-014 project-scoped tenant D1 (flag config + installer PII +
3043
3149
  // platform events). migrations_dir lets wrangler auto-apply the tenant
@@ -3069,6 +3175,14 @@ export function renderWranglerConfig(input) {
3069
3175
  }
3070
3176
  return `${lines.join('\n')}\n`;
3071
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
+ }
3072
3186
  function tomlString(value) {
3073
3187
  return value.replaceAll('\\', '\\\\').replaceAll('"', '\\"');
3074
3188
  }
@@ -3732,11 +3846,11 @@ async function executeHubSpotOnlyUpload({ argv, root, local, uploadOnly, }) {
3732
3846
  local,
3733
3847
  };
3734
3848
  }
3735
- const deploy = await client.projects.deployBuild({
3849
+ const deploy = await withTransientDeployRetry(() => client.projects.deployBuild({
3736
3850
  projectName,
3737
3851
  buildId: upload.buildId,
3738
3852
  force: argv.includes('--force'),
3739
- });
3853
+ }));
3740
3854
  const deployId = readNumericProperty(deploy, 'deployId');
3741
3855
  const deployStatus = deployId === undefined
3742
3856
  ? deploy
@@ -4162,8 +4276,28 @@ async function requestHostedControlPlaneDeployPlan({ request, controlPlaneUrl, u
4162
4276
  return Schema.decodeUnknownSync(schemas.DeployPlanResponse)(body);
4163
4277
  }
4164
4278
  async function requestLocalControlPlaneDeployRecordAndMaybePromote({ request, plan, promoteWhenHealthy, revision, }) {
4279
+ const localAttestationAuth = revision
4280
+ ? { grantSecret: randomBytes(32).toString('base64url'), hubSpotAppId: 1 }
4281
+ : undefined;
4165
4282
  const controlPlane = (await loadCreateControlPlane())({
4166
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
+ : {}),
4167
4301
  });
4168
4302
  const headers = {
4169
4303
  'content-type': 'application/json',
@@ -4200,20 +4334,37 @@ async function requestLocalControlPlaneDeployRecordAndMaybePromote({ request, pl
4200
4334
  if (!promoteWhenHealthy) {
4201
4335
  return { record };
4202
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
+ });
4203
4364
  const driftResponse = await controlPlane.fetch(new Request(`https://api.hs-x.dev/v1/projects/${request.projectId}/attestation`, {
4204
4365
  method: 'POST',
4205
- headers,
4206
- body: JSON.stringify({
4207
- manifestHash: plan.manifestHash,
4208
- bindingFingerprint: `bindings:${plan.deployId}`,
4209
- sdkVersion: CLI_VERSION,
4210
- taggedResourceCount: Math.max(1, plan.resources.workerNames.length),
4211
- hsXAccountId: request.accountId,
4212
- projectId: request.projectId,
4213
- environment: 'dev',
4214
- deployId: plan.deployId,
4215
- timestamp: '2026-05-18T14:00:00.000Z',
4216
- }),
4366
+ headers: { ...headers, authorization: `Bearer ${attestationGrant}` },
4367
+ body: attestationBody,
4217
4368
  }));
4218
4369
  const driftBody = await driftResponse.json();
4219
4370
  if (!driftResponse.ok) {