@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.
- 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/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 +3 -3
- 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-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 +11 -8
- package/dist/commands/deploy.d.ts.map +1 -1
- package/dist/commands/deploy.js +423 -272
- 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 +9 -4
- 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.map +1 -1
- package/dist/commands/init.js +5 -0
- package/dist/commands/init.js.map +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/secrets.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/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/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/commands/deploy.js
CHANGED
|
@@ -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
|
|
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;
|
|
@@ -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 =
|
|
651
|
-
|
|
652
|
-
|
|
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 (
|
|
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.
|
|
747
|
-
|
|
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
|
-
|
|
759
|
+
confirm: promptConfirm,
|
|
750
760
|
});
|
|
751
|
-
if (
|
|
752
|
-
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
|
+
});
|
|
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.
|
|
1044
|
-
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).`);
|
|
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
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
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
|
-
|
|
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({
|
|
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.
|
|
1627
|
-
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).`);
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
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
|
|
2499
|
-
//
|
|
2500
|
-
|
|
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
|
|
2557
|
-
//
|
|
2558
|
-
//
|
|
2559
|
-
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');
|
|
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
|
-
...(
|
|
2571
|
-
? {
|
|
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
|
|
2633
|
-
|
|
2634
|
-
|
|
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
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
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
|
-
|
|
2664
|
-
|
|
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
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
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
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
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
|
-
|
|
2736
|
-
|
|
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`
|
|
2863
|
-
*
|
|
2864
|
-
*
|
|
2865
|
-
*
|
|
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.
|
|
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
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
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
|
-
|
|
2931
|
-
|
|
3027
|
+
if (!resourceId) {
|
|
3028
|
+
throw new Error('Could not read tenant D1 database id from wrangler output.');
|
|
2932
3029
|
}
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
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
|
|
2940
|
-
|
|
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
|
-
|
|
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:
|
|
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) {
|