@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.
- package/README.md +6 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +20 -49
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/kit.d.ts.map +1 -1
- package/dist/cli/kit.js +1 -1
- package/dist/cli/kit.js.map +1 -1
- package/dist/cloudflare-auth.d.ts +19 -0
- package/dist/cloudflare-auth.d.ts.map +1 -1
- package/dist/cloudflare-auth.js +83 -2
- package/dist/cloudflare-auth.js.map +1 -1
- package/dist/cloudflare-oauth.d.ts.map +1 -1
- package/dist/cloudflare-oauth.js +18 -19
- package/dist/cloudflare-oauth.js.map +1 -1
- package/dist/cloudflare-scoped-resource.d.ts +41 -0
- package/dist/cloudflare-scoped-resource.d.ts.map +1 -0
- package/dist/cloudflare-scoped-resource.js +74 -0
- package/dist/cloudflare-scoped-resource.js.map +1 -0
- package/dist/command-catalog.d.ts +13 -0
- package/dist/command-catalog.d.ts.map +1 -0
- package/dist/command-catalog.js +49 -0
- package/dist/command-catalog.js.map +1 -0
- package/dist/commands/account.d.ts +6 -6
- package/dist/commands/api.js +1 -1
- package/dist/commands/completion.d.ts.map +1 -1
- package/dist/commands/completion.js +22 -34
- package/dist/commands/completion.js.map +1 -1
- package/dist/commands/connect.d.ts.map +1 -1
- package/dist/commands/connect.js +97 -18
- package/dist/commands/connect.js.map +1 -1
- package/dist/commands/control-plane-read.d.ts +8 -8
- package/dist/commands/control-plane-read.d.ts.map +1 -1
- package/dist/commands/control-plane-read.js +2 -9
- package/dist/commands/control-plane-read.js.map +1 -1
- package/dist/commands/deploy-promote.d.ts +1 -1
- package/dist/commands/deploy-retry.d.ts +20 -0
- package/dist/commands/deploy-retry.d.ts.map +1 -0
- package/dist/commands/deploy-retry.js +106 -0
- package/dist/commands/deploy-retry.js.map +1 -0
- package/dist/commands/deploy.d.ts +14 -9
- package/dist/commands/deploy.d.ts.map +1 -1
- package/dist/commands/deploy.js +603 -294
- package/dist/commands/deploy.js.map +1 -1
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +43 -5
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/flags.d.ts +10 -5
- package/dist/commands/flags.d.ts.map +1 -1
- package/dist/commands/flags.js +208 -80
- package/dist/commands/flags.js.map +1 -1
- package/dist/commands/help-command.d.ts.map +1 -1
- package/dist/commands/help-command.js +41 -60
- package/dist/commands/help-command.js.map +1 -1
- package/dist/commands/init.d.ts +2 -2
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +34 -5
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/link.d.ts +1 -1
- package/dist/commands/link.d.ts.map +1 -1
- package/dist/commands/link.js +74 -15
- package/dist/commands/link.js.map +1 -1
- package/dist/commands/migrate.d.ts +1 -1
- package/dist/commands/rollback.d.ts +1 -1
- package/dist/commands/secrets.d.ts +2 -2
- package/dist/commands/status.d.ts +1 -1
- package/dist/commands/validate.d.ts +2 -2
- package/dist/constants.d.ts +1 -1
- package/dist/constants.js +1 -1
- package/dist/control-plane-tenant-provisioning.d.ts +24 -0
- package/dist/control-plane-tenant-provisioning.d.ts.map +1 -0
- package/dist/control-plane-tenant-provisioning.js +85 -0
- package/dist/control-plane-tenant-provisioning.js.map +1 -0
- package/dist/deploy-targets.d.ts +24 -0
- package/dist/deploy-targets.d.ts.map +1 -0
- package/dist/deploy-targets.js +38 -0
- package/dist/deploy-targets.js.map +1 -0
- package/dist/dev/compat-shim.d.ts +3 -3
- package/dist/dev/compat-shim.d.ts.map +1 -1
- package/dist/dev/compat-shim.js +28 -4
- package/dist/dev/compat-shim.js.map +1 -1
- package/dist/dev/invoke.d.ts +3 -4
- package/dist/dev/invoke.d.ts.map +1 -1
- package/dist/dev/invoke.js +9 -4
- package/dist/dev/invoke.js.map +1 -1
- package/dist/dev/loopback-http-listener.d.ts +18 -0
- package/dist/dev/loopback-http-listener.d.ts.map +1 -0
- package/dist/dev/loopback-http-listener.js +91 -0
- package/dist/dev/loopback-http-listener.js.map +1 -0
- package/dist/dev/session-manager.d.ts.map +1 -1
- package/dist/dev/session-manager.js +3 -2
- package/dist/dev/session-manager.js.map +1 -1
- package/dist/errors-registry.d.ts.map +1 -1
- package/dist/errors-registry.js +33 -0
- package/dist/errors-registry.js.map +1 -1
- package/dist/help.d.ts.map +1 -1
- package/dist/help.js +20 -3
- package/dist/help.js.map +1 -1
- package/dist/init/templates.d.ts +1 -1
- package/dist/init/templates.d.ts.map +1 -1
- package/dist/init/templates.js +71 -1
- package/dist/init/templates.js.map +1 -1
- package/dist/machine-artifact.d.ts +16 -0
- package/dist/machine-artifact.d.ts.map +1 -0
- package/dist/machine-artifact.js +18 -0
- package/dist/machine-artifact.js.map +1 -0
- package/dist/machine-id.d.ts +15 -0
- package/dist/machine-id.d.ts.map +1 -1
- package/dist/machine-id.js +53 -0
- package/dist/machine-id.js.map +1 -1
- package/dist/mutation-consent.d.ts +20 -0
- package/dist/mutation-consent.d.ts.map +1 -0
- package/dist/mutation-consent.js +9 -0
- package/dist/mutation-consent.js.map +1 -0
- package/dist/output-context.d.ts +3 -0
- package/dist/output-context.d.ts.map +1 -1
- package/dist/output-context.js +6 -0
- package/dist/output-context.js.map +1 -1
- package/dist/prompt.d.ts.map +1 -1
- package/dist/prompt.js +123 -48
- package/dist/prompt.js.map +1 -1
- package/dist/reporter/index.js +1 -1
- package/dist/reporter/index.js.map +1 -1
- package/dist/reporter/style.d.ts.map +1 -1
- package/dist/reporter/style.js +5 -0
- package/dist/reporter/style.js.map +1 -1
- package/dist/services/live.d.ts +1 -1
- package/dist/services/telemetry.d.ts +1 -2
- package/dist/services/telemetry.d.ts.map +1 -1
- package/dist/services/telemetry.js +1 -10
- package/dist/services/telemetry.js.map +1 -1
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +1 -7
- package/dist/telemetry.js.map +1 -1
- package/dist/tenant-data-plane-provisioning.d.ts +35 -0
- package/dist/tenant-data-plane-provisioning.d.ts.map +1 -0
- package/dist/tenant-data-plane-provisioning.js +38 -0
- package/dist/tenant-data-plane-provisioning.js.map +1 -0
- package/package.json +11 -11
- package/dist/cloudflare-worker-versions.d.ts +0 -184
- package/dist/cloudflare-worker-versions.d.ts.map +0 -1
- package/dist/cloudflare-worker-versions.js +0 -360
- package/dist/cloudflare-worker-versions.js.map +0 -1
- package/dist/rollback-traffic.d.ts +0 -85
- package/dist/rollback-traffic.d.ts.map +0 -1
- package/dist/rollback-traffic.js +0 -204
- package/dist/rollback-traffic.js.map +0 -1
package/dist/commands/deploy.js
CHANGED
|
@@ -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
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
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
|
|
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
|
|
650
|
-
|
|
651
|
-
|
|
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 (
|
|
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.
|
|
746
|
-
|
|
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
|
-
|
|
759
|
+
confirm: promptConfirm,
|
|
749
760
|
});
|
|
750
|
-
if (
|
|
751
|
-
return
|
|
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.
|
|
1043
|
-
echo.warn('
|
|
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 =
|
|
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
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
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
|
-
|
|
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({
|
|
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.
|
|
1626
|
-
reporter.warn('
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
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
|
|
2498
|
-
//
|
|
2499
|
-
|
|
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
|
|
2556
|
-
//
|
|
2557
|
-
//
|
|
2558
|
-
const
|
|
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
|
-
...(
|
|
2570
|
-
? {
|
|
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
|
|
2632
|
-
|
|
2633
|
-
|
|
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
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
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
|
-
|
|
2663
|
-
|
|
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
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
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
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
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
|
-
|
|
2735
|
-
|
|
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`
|
|
2862
|
-
*
|
|
2863
|
-
*
|
|
2864
|
-
*
|
|
2985
|
+
* Returns the canonical `syncGrantSecretValue` on every call — including 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
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
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
|
-
|
|
2930
|
-
|
|
3027
|
+
if (!resourceId) {
|
|
3028
|
+
throw new Error('Could not read tenant D1 database id from wrangler output.');
|
|
2931
3029
|
}
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
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
|
|
2939
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
3197
|
-
//
|
|
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:
|
|
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) {
|