From 6f04cd321a5e5980bc2105b379b5dcba9164a7c7 Mon Sep 17 00:00:00 2001 From: 0thernet Date: Sun, 23 Aug 2026 08:38:46 -0400 Subject: [PATCH] Harden provider release boundaries --- .gitignore | 1 + docs/domain-cutover.md | 76 ++++- docs/hosted-sync.md | 14 +- scripts/bootstrap-hosted-sync.test.ts | 15 +- scripts/configure-hosted-sync.test.ts | 13 +- scripts/convex-target.test.ts | 66 ++++- scripts/convex-target.ts | 9 +- scripts/deploy-hosted-sync.test.ts | 15 +- scripts/domain-cutover.test.ts | 407 +++++++++++++++++++++++--- scripts/domain-cutover.ts | 191 +++++++++--- 10 files changed, 700 insertions(+), 107 deletions(-) diff --git a/.gitignore b/.gitignore index 68f43bc..2f4640b 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ coverage/ .convex/ .vercel/ tmp/ +.env.local diff --git a/docs/domain-cutover.md b/docs/domain-cutover.md index a18964b..603419e 100644 --- a/docs/domain-cutover.md +++ b/docs/domain-cutover.md @@ -7,7 +7,7 @@ This runbook moves `hra.sh` between HRA v0 and new HRA in the `hraness` Vercel t | Role | Vercel project ID | GitHub repository ID | Stable fallback | | --- | --- | ---: | --- | | HRA v0 | `prj_eRfUBHdHkEbvIaB8x7dyyZhBc3wr` | `1334876494` | `https://hra-weld.vercel.app` | -| New HRA | `prj_8ciIt9t9foE3utG45frRN7cxckjS` | `1343008607` | the project's automatic `.vercel.app` domain | +| New HRA | `prj_8ciIt9t9foE3utG45frRN7cxckjS` | `1343008607` | `https://try-hra.vercel.app` | The team slug is `hraness`; its numeric ID is `team_UAd1iD2XogJlbFg4h14mRaPM`. The preserved pre-migration deployment is `dpl_AmtYwx5XmgziAxGtNFMMKLGMnXUw` at the bare automatic hostname `hra-1o6bv6wbl-hraness.vercel.app`, sourced from commit `6221f79b745f154882080936b961ff431569f33e`. It is historical evidence, not the post-migration rollback target. @@ -25,6 +25,24 @@ vercel api /v4/aliases/hra.sh --scope hraness --raw | jq -c '{alias,projectId,de Record only filtered provider fields. Full deployment and alias responses can contain operator identity data. Do not use `--debug`, `--verbose`, `--token`, `--force`, remove-then-add, or a token-bearing shell variable. +## Disable automatic domain assignment + +Disable automatic custom-domain assignment on both fixed projects before staging Q or N. This setting prevents a Git-triggered or production deployment from silently reassigning `hra.sh`, `hra-weld.vercel.app`, or `try-hra.vercel.app` outside the checked cutover sequence. Address the projects by numeric ID: + +```sh +vercel api /v9/projects/prj_eRfUBHdHkEbvIaB8x7dyyZhBc3wr --scope hraness -X PATCH -F autoAssignCustomDomains=false --silent +vercel api /v9/projects/prj_8ciIt9t9foE3utG45frRN7cxckjS --scope hraness -X PATCH -F autoAssignCustomDomains=false --silent +``` + +Read both records back independently: + +```sh +vercel api /v9/projects/prj_eRfUBHdHkEbvIaB8x7dyyZhBc3wr --scope hraness --raw | jq -c '{id,accountId,autoAssignCustomDomains}' +vercel api /v9/projects/prj_8ciIt9t9foE3utG45frRN7cxckjS --scope hraness --raw | jq -c '{id,accountId,autoAssignCustomDomains}' +``` + +Require the requested project ID to equal the returned `id`, `accountId` to equal numeric team ID `team_UAd1iD2XogJlbFg4h14mRaPM`, and `autoAssignCustomDomains` to be exactly `false` for both records. A missing field, `true`, another value, a project- or team-identity mismatch, or an unreadable response is a stop condition. Keep the setting disabled through archive staging, both rehearsals, and final production cutover. + ## Prepare accepted deployments Use Vercel CLI `54.18.0`. The operator requires an absolute CLI path and refuses another reported version. Read each deployment with `/v13/deployments/` and record only: @@ -33,7 +51,24 @@ Use Vercel CLI `54.18.0`. The operator requires an absolute CLI path and refuses vercel api /v13/deployments/ --scope hraness --raw | jq -c '{id,url,projectId,readyState,gitSource:{ref:.gitSource.ref,repoId:.gitSource.repoId,sha:.gitSource.sha,type:.gitSource.type}}' ``` -Require `readyState` to be `READY`, `gitSource.type` to be `github`, `gitSource.ref` to be `main`, and the project ID, repository ID, source commit, deployment ID, and bare automatic hostname to match the accepted release. A bare automatic hostname already ends in `.vercel.app`; never append that suffix again. +Require `readyState` to be `READY`, `gitSource.type` to be `github`, `gitSource.ref` to be `main`, and the project ID, repository ID, source commit, deployment ID, and bare automatic hostname to match the accepted release. A bare automatic hostname already ends in `.vercel.app`; never append that suffix again. The deployment URL is an exact provider identity and alias destination, not proof that an unauthenticated browser can reach it. Deployment protection can cover automatic deployment URLs. + +Stage every production deployment without automatic alias promotion, even after disabling the project setting: + +```sh +vercel deploy --prod --skip-domain --project --scope hraness +``` + +Do not accept a Q or N deployment created by a command that omitted `--skip-domain`. Git-created deployments remain safe only while the exact numeric-project readbacks above stay `false`. + +Inspect Q and N before exposing them through a public custom alias with the authenticated local Vercel session: + +```sh +vercel curl / --deployment --scope hraness +vercel curl /.well-known/hra.json --deployment --scope hraness +``` + +Use `vercel curl --deployment` for every release-specific path needed by acceptance. Do not pass, print, save, or script a protection-bypass secret. These authenticated checks do not replace the later public custom-alias probes. Prepare these exact endpoints: @@ -41,7 +76,16 @@ Prepare these exact endpoints: - Q: a new `READY` HRA v0 archive deployment built from the merged, protected `hraness/hra-v0` main commit. Q becomes the rollback target. - N: a new `READY` HRA deployment built from the protected `hraness/hra` main commit accepted by all gates. -Verify Q and N through their automatic hostnames before changing `hra.sh`. Check root, privacy, `robots.txt`, `sitemap.xml`, `llms.txt`, `.well-known/security.txt`, `.well-known/hra.json`, TLS, response headers, and release-specific acceptance surfaces. Q must also serve the compatibility and immutable release-history pages. Exercise hosted health and acceptance on N. +Q must pass authenticated root, privacy, `robots.txt`, `sitemap.xml`, `llms.txt`, `.well-known/security.txt`, `.well-known/hra.json`, response-header, compatibility, immutable-release-history, and release-download checks. The archive operator subsequently gives Q a public check at `hra-weld.vercel.app` before it changes `hra.sh`. + +Assign N to the fixed new-HRA staging alias only after its authenticated checks pass: + +```sh +vercel alias set .vercel.app try-hra.vercel.app --scope hraness +vercel api /v4/aliases/try-hra.vercel.app --scope hraness --raw | jq -c '{alias,projectId,deploymentId,deployment:{id:.deployment.id,url:.deployment.url}}' +``` + +Require the tuple to identify new project ID `prj_8ciIt9t9foE3utG45frRN7cxckjS` and exact N deployment ID and URL. Publicly check the generation-1 marker, root, privacy, security, TLS, headers, hosted health, and release acceptance at `https://try-hra.vercel.app`. The checked operator refuses a forward or reverse plan unless this fixed alias still identifies exact N and its commit-bearing marker. ## Checked operator @@ -73,13 +117,14 @@ Run a checked plan with Bun 1.3.14: bun run hosted:domain-cutover --execute --vercel-cli /absolute/path/to/vercel < cutover-plan.json ``` -The operator strips inherited Vercel-token variables and uses the authenticated local Vercel session. Before any mutation it verifies both deployments, the exact current alias tuple, the current public marker when one exists, and project-domain ownership. It then: +The operator strips inherited Vercel-token variables and uses the authenticated local Vercel session. Before any mutation it verifies `autoAssignCustomDomains === false` on both fixed numeric projects, both deployments, the exact current alias tuple, the current public marker when one exists, and project-domain ownership. Domain plans additionally require `hra-weld.vercel.app` to identify exact Q and `try-hra.vercel.app` to identify exact N, including their public commit-bearing markers. It refuses before reading or changing traffic if either project setting or fixed staging alias is unsafe. It then: -1. Points `hra.sh` at the target's bare automatic hostname. -2. Probes the exact alias tuple and commit-bearing marker for at most 60 seconds. -3. Restores the exact previously proven source deployment if the command fails, readback is wrong, or public convergence times out. -4. For domain plans only, moves ownership in one API request after traffic is proven. -5. Reads both project domain lists, the alias tuple, and marker again before reporting `committed`. +1. For an archive plan, points `hra-weld.vercel.app` at Q and proves its exact alias tuple and generation-0 marker before touching `hra.sh`. +2. Points `hra.sh` at the target's bare automatic hostname. +3. Probes the exact alias tuple and commit-bearing marker for at most 60 seconds. +4. Restores the exact previously proven source deployment if the command fails, readback is wrong, or public convergence times out. Archive compensation restores both fixed old-HRA aliases to P. +5. For domain plans only, moves ownership in one API request after traffic is proven. +6. Reads both project domain lists and re-proves the canonical, old fallback, and new staging alias tuples and markers before reporting `committed`. Every failed or uncertain traffic change enters automatic restoration. If source restoration itself cannot be proven within 60 seconds, the operator reports `compensation_failed`; stop all release work and escalate. Never continue to a repository rename, tag, release, or invitation after a refused result. @@ -92,7 +137,16 @@ The only ownership mutation endpoints are the symmetric numeric-ID moves below. ## Archive deployment transition -Run the `archive` traffic-only plan P → Q after the old repository rename, protected merge, and exact-Q deployment. This replaces the pre-migration deployment with the accepted compatibility deployment without moving project ownership. Independently repeat the exact `/v4/aliases/hra.sh` readback and generation-0 marker check. Verify `https://hra-weld.vercel.app` still works. +Run the `archive` traffic-only plan P → Q after the old repository rename, protected merge, authenticated exact-Q acceptance, and exact-Q deployment readback. Before the plan, require both public old-HRA aliases to identify P: + +```sh +vercel api /v4/aliases/hra-weld.vercel.app --scope hraness --raw | jq -c '{alias,projectId,deploymentId,deployment:{id:.deployment.id,url:.deployment.url}}' +vercel api /v4/aliases/hra.sh --scope hraness --raw | jq -c '{alias,projectId,deploymentId,deployment:{id:.deployment.id,url:.deployment.url}}' +``` + +The operator first moves only `hra-weld.vercel.app` from P to Q. It requires the fallback alias tuple to identify the old numeric project and exact Q deployment ID and URL, then fetches the fallback marker without cached evidence and requires generation 0, repository ID `1334876494`, path `hraness/hra-v0`, Q's `source.commit`, and Q's version. Only after that proof does it move `hra.sh` from P to Q. Any ambiguous command, tuple mismatch, marker failure, or timeout restores both aliases to exact P and returns a refusal. + +After a committed result, independently repeat the exact readbacks for both aliases. Check the fallback's root, compatibility pages, release downloads, privacy, security policy, TLS, and headers, then repeat the generation-0 marker and canonical surface checks on `hra.sh`. This transition does not move project ownership. Q is now the only rollback source used by forward, reverse, and incident plans. P remains unchanged and readable by deployment ID as historical evidence. @@ -109,7 +163,7 @@ Require `hra.sh` exactly once under new HRA and zero times under HRA v0. Repeat ## Reverse rehearsal -Run the `reverse` plan N → Q. Require `hra.sh` exactly once under HRA v0, the exact Q alias tuple, generation-0 marker, old root, compatibility page, release downloads, privacy, security policy, and fallback. Both projects' automatic hostnames must retain their own generation markers. +Run the `reverse` plan N → Q. Require `hra.sh` exactly once under HRA v0, the exact Q alias tuple, generation-0 marker, old root, compatibility page, release downloads, privacy, security policy, and fallback. Require `hra-weld.vercel.app` to remain exact Q and `try-hra.vercel.app` to remain exact N with their own generation markers. Recheck P, Q, and N by deployment ID with authenticated `vercel curl --deployment`; do not use unauthenticated automatic-hostname responses as evidence. The rehearsal passes only after archive, forward, and reverse plans all commit and their independent readbacks pass. Repeat the forward plan and acceptance checks for production. Publish no immutable new-HRA tag until the final forward move is exact. diff --git a/docs/hosted-sync.md b/docs/hosted-sync.md index 79f3a30..4cf77c3 100644 --- a/docs/hosted-sync.md +++ b/docs/hosted-sync.md @@ -4,11 +4,11 @@ Use this sequence only for a new HRA Convex project and production deployment. T Never copy HRA v0 data, deployment URLs, deploy keys, authentication keys, HMAC material, Resend credentials, environment values, or backups into the new project. Keep the old project and deployment unchanged for rollback. -The provider identity guard is numeric. HRA v0 owns Convex project ID `2680173` and production deployment ID `4677913`; neither may be renamed into or selected by this runbook. The new source repository has GitHub repository ID `1343008607`, and the new web project has Vercel project ID `prj_8ciIt9t9foE3utG45frRN7cxckjS`. Names may change during cutover. These IDs do not. +The provider identity guard pins the intended Convex team to numeric ID `513923` and provider slug `cclrte`. HRA v0 owns Convex project ID `2680173` and production deployment ID `4677913`; neither may be renamed into or selected by this runbook. The new source repository has GitHub repository ID `1343008607`, and the new web project has Vercel project ID `prj_8ciIt9t9foE3utG45frRN7cxckjS`. Project and deployment names may change during cutover. The team identity and numeric resource IDs do not. ## Create fresh state -1. Create a new Convex project and production deployment in the HRA team. +1. Create a new Convex project and production deployment in Convex team `cclrte` with numeric team ID `513923`. 2. Record the new Convex project ID, production deployment ID, deployment name, deployment URL, and site URL in the private release record. 3. Read those values back from Convex. Attach the readback to the release record and prove that both new numeric IDs and the deployment URL differ from HRA v0 project ID `2680173`, deployment ID `4677913`, and its deployment URL. Stop on a missing, reused, or name-only identity. 4. Log in with the Convex CLI. Its global `config.json` must be a regular, single-link, mode-`0600` file. Do not supply a deploy key or deployment selector through an environment variable, `.env`, or `.env.local`. @@ -17,14 +17,14 @@ The provider identity guard is numeric. HRA v0 owns Convex project ID `2680173` ```sh bun run hosted:deploy -- \ --deployment steady-otter-321 \ - --team-id 765432 \ + --team-id 513923 \ --project-id 1234567 \ --deployment-id 7654321 \ --deployment-url https://steady-otter-321.convex.cloud \ --source-commit 0123456789abcdef0123456789abcdef01234567 ``` - The helper requires `HEAD` to equal that commit and the entire checkout, including untracked files, to be clean before and after deployment. It reads the authenticated Convex management API before and after the mutation and requires the exact team, project, deployment, production type, generated deployment name, and URL. It rejects selectors such as `prod`, `local`, and `team:project:prod`, and rejects the HRA v0 numeric IDs. + The helper requires `HEAD` to equal that commit and the entire checkout, including untracked files, to be clean before and after deployment. It refuses any caller team ID except `513923`, then reads the authenticated Convex management API before and after the mutation and requires team slug `cclrte`, team ID `513923`, and the exact project, deployment, production type, generated deployment name, and URL. It rejects selectors such as `prod`, `local`, and `team:project:prod`, and rejects the HRA v0 numeric IDs. The helper creates a private exclusive environment file containing only `CONVEX_DEPLOYMENT=prod:`. It invokes `convex deploy --env-file` with that exact file, confirmation disabled, strict typechecking, code generation disabled, sanitized inherited environment variables, bounded provider output, and a ten-minute deadline. This explicit binding takes precedence over hostile stale checkout files. Provider output is suppressed. A failure or a dirty postflight leaves the deployment quarantined for inspection; do not retry it. @@ -43,7 +43,7 @@ Pass the JSON from a protected secret source through standard input: ```sh protected-json-source | bun run hosted:configure -- \ --deployment steady-otter-321 \ - --team-id 765432 \ + --team-id 513923 \ --project-id 1234567 \ --deployment-id 7654321 \ --deployment-url https://steady-otter-321.convex.cloud @@ -54,7 +54,7 @@ An agent can use a private nonterminal descriptor: ```sh bun run hosted:configure -- \ --deployment steady-otter-321 \ - --team-id 765432 \ + --team-id 513923 \ --project-id 1234567 \ --deployment-id 7654321 \ --deployment-url https://steady-otter-321.convex.cloud \ @@ -81,7 +81,7 @@ No authentication, OTP, invitation, device, or application write may happen befo ```sh bun run hosted:bootstrap -- \ --deployment steady-otter-321 \ - --team-id 765432 \ + --team-id 513923 \ --project-id 1234567 \ --deployment-id 7654321 \ --deployment-url https://steady-otter-321.convex.cloud \ diff --git a/scripts/bootstrap-hosted-sync.test.ts b/scripts/bootstrap-hosted-sync.test.ts index f32c369..3cd6e6f 100644 --- a/scripts/bootstrap-hosted-sync.test.ts +++ b/scripts/bootstrap-hosted-sync.test.ts @@ -11,14 +11,18 @@ import { type CapabilitySink, } from "./bootstrap-hosted-sync"; import type { CommandRequest, CommandRunner } from "./configure-hosted-sync"; -import type { ConvexTarget, ConvexTargetVerifier } from "./convex-target"; +import { + HRA_CONVEX_TEAM_ID, + type ConvexTarget, + type ConvexTargetVerifier, +} from "./convex-target"; const target: ConvexTarget = { deploymentId: 7_654_321, deploymentName: "steady-otter-321", deploymentUrl: "https://steady-otter-321.convex.cloud", projectId: 1_234_567, - teamId: 765_432, + teamId: HRA_CONVEX_TEAM_ID, }; const targetArguments = [ @@ -324,6 +328,13 @@ describe("fresh hosted bootstrap", () => { "--invite-output", "relative-invite", ])).toThrow("usage_invalid"); + expect(() => parseBootstrapArguments([ + ...targetArguments.slice(0, 3), + String(HRA_CONVEX_TEAM_ID + 1), + ...targetArguments.slice(4), + "--invite-output", + "/protected/new-invite", + ])).toThrow("usage_invalid"); expect(() => parseBootstrapArguments([ ...targetArguments, "--invite-output", diff --git a/scripts/configure-hosted-sync.test.ts b/scripts/configure-hosted-sync.test.ts index cf0f5df..2702089 100644 --- a/scripts/configure-hosted-sync.test.ts +++ b/scripts/configure-hosted-sync.test.ts @@ -16,14 +16,18 @@ import { type CommandRunner, type GeneratedHostedSecrets, } from "./configure-hosted-sync"; -import type { ConvexTarget, ConvexTargetVerifier } from "./convex-target"; +import { + HRA_CONVEX_TEAM_ID, + type ConvexTarget, + type ConvexTargetVerifier, +} from "./convex-target"; const target: ConvexTarget = { deploymentId: 7_654_321, deploymentName: "steady-otter-321", deploymentUrl: "https://steady-otter-321.convex.cloud", projectId: 1_234_567, - teamId: 765_432, + teamId: HRA_CONVEX_TEAM_ID, }; const targetArguments = [ @@ -258,6 +262,11 @@ describe("fresh hosted configuration", () => { "--deployment", "other-otter-456", ])).toThrow("usage_invalid"); + expect(() => parseHostedArguments([ + ...targetArguments.slice(0, 3), + String(HRA_CONVEX_TEAM_ID + 1), + ...targetArguments.slice(4), + ])).toThrow("usage_invalid"); expect(() => parseHostedArguments([...targetArguments, "--input-fd", "1"])) .toThrow("usage_invalid"); expect(() => parseHostedInput(`${protectedDocument}\n{}`)).toThrow("input_invalid"); diff --git a/scripts/convex-target.test.ts b/scripts/convex-target.test.ts index d36c140..e56fd8e 100644 --- a/scripts/convex-target.test.ts +++ b/scripts/convex-target.test.ts @@ -4,8 +4,11 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { + HRA_CONVEX_TEAM_ID, + HRA_CONVEX_TEAM_SLUG, HRA_V0_CONVEX_DEPLOYMENT_ID, HRA_V0_CONVEX_PROJECT_ID, + parseConvexTarget, parseConvexTargetArguments, readConvexAccessToken, verifyConvexTarget, @@ -18,7 +21,7 @@ const target: ConvexTarget = { deploymentName: "steady-otter-321", deploymentUrl: "https://steady-otter-321.convex.cloud", projectId: 1_234_567, - teamId: 765_432, + teamId: HRA_CONVEX_TEAM_ID, }; const targetArguments = [ @@ -65,7 +68,7 @@ describe("numeric Convex target guard", () => { return new Response(JSON.stringify({ project: "hra", projectId: target.projectId, - team: "hraness", + team: HRA_CONVEX_TEAM_SLUG, teamId: target.teamId, }), { status: 200 }); } @@ -92,6 +95,54 @@ describe("numeric Convex target guard", () => { } }); + test("rejects every wrong or missing provider team identity before mutation", async () => { + const { configPath } = await makeConfig(); + const scenarios: readonly unknown[] = [ + { + project: "hra", + projectId: target.projectId, + team: "wrong-team", + teamId: target.teamId, + }, + { + project: "hra", + projectId: target.projectId, + teamId: target.teamId, + }, + { + project: "hra", + projectId: target.projectId, + team: HRA_CONVEX_TEAM_SLUG, + teamId: target.teamId + 1, + }, + { + project: "hra", + projectId: target.projectId, + team: HRA_CONVEX_TEAM_SLUG, + }, + ]; + + for (const teamAndProject of scenarios) { + let deploymentReadbackCalls = 0; + const fetcher: ConvexManagementFetch = async (input) => { + if (String(input).includes("/team_and_project")) { + return new Response(JSON.stringify(teamAndProject), { status: 200 }); + } + deploymentReadbackCalls += 1; + return new Response(JSON.stringify({ + deploymentType: "prod", + deploymentUrl: target.deploymentUrl, + id: target.deploymentId, + name: target.deploymentName, + projectId: target.projectId, + }), { status: 200 }); + }; + await expect(verifyConvexTarget(target, { configPath, fetch: fetcher })) + .rejects.toThrow("target_mismatch"); + expect(deploymentReadbackCalls).toBe(1); + } + }); + test("rejects a numeric mismatch, a non-prod deployment, and ambiguous provider output", async () => { const { configPath } = await makeConfig(); const scenarios = [ @@ -124,7 +175,7 @@ describe("numeric Convex target guard", () => { return new Response(JSON.stringify({ project: "hra", projectId: target.projectId, - team: "hraness", + team: HRA_CONVEX_TEAM_SLUG, teamId: target.teamId, }), { status: 200 }); } @@ -162,6 +213,15 @@ describe("numeric Convex target guard", () => { expect(() => parseConvexTargetArguments(withDeployment("local"))).toThrow("target_invalid"); expect(() => parseConvexTargetArguments(withDeployment("team:project:prod"))) .toThrow("target_invalid"); + expect(() => parseConvexTarget({ + ...target, + teamId: HRA_CONVEX_TEAM_ID + 1, + })).toThrow("target_invalid"); + expect(() => parseConvexTargetArguments([ + ...targetArguments.slice(0, 3), + String(HRA_CONVEX_TEAM_ID + 1), + ...targetArguments.slice(4), + ])).toThrow("target_invalid"); expect(() => parseConvexTargetArguments([ ...targetArguments.slice(0, 5), String(HRA_V0_CONVEX_PROJECT_ID), diff --git a/scripts/convex-target.ts b/scripts/convex-target.ts index aedfb47..16da62d 100644 --- a/scripts/convex-target.ts +++ b/scripts/convex-target.ts @@ -19,6 +19,8 @@ const hasControlCharacter = (value: string): boolean => { export const HRA_V0_CONVEX_PROJECT_ID = 2_680_173; export const HRA_V0_CONVEX_DEPLOYMENT_ID = 4_677_913; +export const HRA_CONVEX_TEAM_ID = 513_923; +export const HRA_CONVEX_TEAM_SLUG = "cclrte"; const generatedDeploymentNameSchema = z.string() .min(5) @@ -48,7 +50,7 @@ const expectedTargetSchema = z.object({ deploymentName: generatedDeploymentNameSchema, deploymentUrl: deploymentUrlSchema, projectId: numericIdentifierSchema, - teamId: numericIdentifierSchema, + teamId: z.literal(HRA_CONVEX_TEAM_ID), }).strict().superRefine((target, context) => { if (target.projectId === HRA_V0_CONVEX_PROJECT_ID) { context.addIssue({ code: "custom", message: "HRA v0 project is forbidden." }); @@ -61,8 +63,8 @@ const expectedTargetSchema = z.object({ const teamAndProjectSchema = z.object({ project: z.string().min(1).max(256), projectId: numericIdentifierSchema, - team: z.string().min(1).max(256), - teamId: numericIdentifierSchema, + team: z.literal(HRA_CONVEX_TEAM_SLUG), + teamId: z.literal(HRA_CONVEX_TEAM_ID), }).passthrough(); const deploymentReadbackSchema = z.object({ @@ -315,7 +317,6 @@ export async function verifyConvexTarget( if ( !teamAndProject.success || !deployment.success - || teamAndProject.data.teamId !== target.teamId || teamAndProject.data.projectId !== target.projectId || deployment.data.id !== target.deploymentId || deployment.data.name !== target.deploymentName diff --git a/scripts/deploy-hosted-sync.test.ts b/scripts/deploy-hosted-sync.test.ts index 5cfb18e..45a92f8 100644 --- a/scripts/deploy-hosted-sync.test.ts +++ b/scripts/deploy-hosted-sync.test.ts @@ -15,7 +15,11 @@ import { executeHostedDeploy, parseDeployArguments, } from "./deploy-hosted-sync"; -import type { ConvexTarget, ConvexTargetVerifier } from "./convex-target"; +import { + HRA_CONVEX_TEAM_ID, + type ConvexTarget, + type ConvexTargetVerifier, +} from "./convex-target"; const sourceCommit = "a".repeat(40); const target: ConvexTarget = { @@ -23,7 +27,7 @@ const target: ConvexTarget = { deploymentName: "steady-otter-321", deploymentUrl: "https://steady-otter-321.convex.cloud", projectId: 1_234_567, - teamId: 765_432, + teamId: HRA_CONVEX_TEAM_ID, }; const targetArguments = [ "--deployment", @@ -214,6 +218,13 @@ describe("verified hosted deployment", () => { .toEqual({ sourceCommit, target }); expect(() => parseDeployArguments([...targetArguments, "--source-commit", "HEAD"])) .toThrow("usage_invalid"); + expect(() => parseDeployArguments([ + ...targetArguments.slice(0, 3), + String(HRA_CONVEX_TEAM_ID + 1), + ...targetArguments.slice(4), + "--source-commit", + sourceCommit, + ])).toThrow("usage_invalid"); expect(() => parseDeployArguments([ ...targetArguments, "--source-commit", diff --git a/scripts/domain-cutover.test.ts b/scripts/domain-cutover.test.ts index b52b1a6..96eb468 100644 --- a/scripts/domain-cutover.test.ts +++ b/scripts/domain-cutover.test.ts @@ -14,6 +14,8 @@ import { type CutoverPlan, type CutoverProvider, type DeploymentReadback, + type ManagedAlias, + type ProjectReadback, type VercelCommandRequest, type VercelCommandRunner, } from "./domain-cutover"; @@ -22,6 +24,10 @@ const oldProjectId = "prj_eRfUBHdHkEbvIaB8x7dyyZhBc3wr"; const newProjectId = "prj_8ciIt9t9foE3utG45frRN7cxckjS"; const oldRepositoryId = 1_334_876_494; const newRepositoryId = 1_343_008_607; +const teamId = "team_UAd1iD2XogJlbFg4h14mRaPM"; +const canonicalAlias = "hra.sh"; +const fallbackAlias = "hra-weld.vercel.app"; +const newStagingAlias = "try-hra.vercel.app"; const oldEndpoint: CutoverEndpoint = { deploymentId: "dpl_ArchiveAccepted1234567890", @@ -51,6 +57,30 @@ const forwardPlan: CutoverPlan = { target: newEndpoint, }; +const reversePlan: CutoverPlan = { + direction: "reverse", + mode: "domain", + schemaVersion: 1, + source: newEndpoint, + target: oldEndpoint, +}; + +const baselineEndpoint: CutoverEndpoint = { + ...oldEndpoint, + deploymentId: "dpl_BaselineAccepted123456789", + deploymentUrl: "hra-baseline-hraness.vercel.app", + generation: null, + sourceCommit: "6221f79b745f154882080936b961ff431569f33e", +}; + +const archivePlan: CutoverPlan = { + direction: "archive", + mode: "traffic-only", + schemaVersion: 1, + source: baselineEndpoint, + target: oldEndpoint, +}; + const deploymentFor = (endpoint: CutoverEndpoint): DeploymentReadback => ({ gitSource: { ref: "main", @@ -64,13 +94,24 @@ const deploymentFor = (endpoint: CutoverEndpoint): DeploymentReadback => ({ url: endpoint.deploymentUrl, }); -const aliasFor = (endpoint: CutoverEndpoint): AliasReadback => ({ - alias: "hra.sh", +const aliasFor = ( + endpoint: CutoverEndpoint, + aliasName: ManagedAlias = canonicalAlias, +): AliasReadback => ({ + alias: aliasName, deployment: { id: endpoint.deploymentId, url: endpoint.deploymentUrl }, deploymentId: endpoint.deploymentId, projectId: endpoint.projectId, }); +const projectFor = ( + projectId: typeof oldProjectId | typeof newProjectId, +): ProjectReadback => ({ + accountId: teamId, + autoAssignCustomDomains: false, + id: projectId, +}); + const markerFor = (endpoint: CutoverEndpoint): unknown => ({ generation: endpoint.generation, product: "HRA", @@ -86,21 +127,42 @@ const markerFor = (endpoint: CutoverEndpoint): unknown => ({ type MoveBehavior = "ambiguous" | "commit" | "move-and-source-alias" | "noop"; class FakeCutoverProvider implements CutoverProvider { - aliasEndpoint: CutoverEndpoint; - markerBrokenForTarget = false; + readonly aliasEndpoints: Record; + markerBrokenForTargetAlias: ManagedAlias | undefined; moveBehavior: MoveBehavior = "commit"; owner: "ambiguous" | "source" | "target" = "source"; + sourceAliasSetFailure: ManagedAlias | undefined; + targetAliasSetFailure: ManagedAlias | undefined; readonly operations: string[] = []; readonly plan: CutoverPlan; + readonly projectReadbacks: Record = { + [newProjectId]: projectFor(newProjectId), + [oldProjectId]: projectFor(oldProjectId), + }; constructor(plan: CutoverPlan) { this.plan = plan; - this.aliasEndpoint = plan.source; + const acceptedArchive = plan.direction === "forward" ? plan.source : plan.target; + const acceptedNew = plan.direction === "forward" ? plan.target : plan.source; + this.aliasEndpoints = { + [canonicalAlias]: plan.source, + [fallbackAlias]: plan.direction === "archive" ? plan.source : acceptedArchive, + [newStagingAlias]: plan.direction === "archive" ? plan.target : acceptedNew, + }; + } + + get aliasEndpoint(): CutoverEndpoint { + return this.aliasEndpoints[canonicalAlias]; } - async readAlias(): Promise { - this.operations.push(`read-alias:${this.aliasEndpoint.deploymentId}`); - return aliasFor(this.aliasEndpoint); + get fallbackAliasEndpoint(): CutoverEndpoint { + return this.aliasEndpoints[fallbackAlias]; + } + + async readAlias(aliasName: ManagedAlias): Promise { + const endpoint = this.aliasEndpoints[aliasName]; + this.operations.push(`read-alias:${aliasName}:${endpoint.deploymentId}`); + return aliasFor(endpoint, aliasName); } async readDeployment(deploymentId: string): Promise { @@ -120,20 +182,36 @@ class FakeCutoverProvider implements CutoverProvider { return projectId === this.plan.target.projectId ? ["hra.sh"] : []; } - async readMarker(): Promise { - this.operations.push(`read-marker:${this.aliasEndpoint.deploymentId}`); - if (this.markerBrokenForTarget && this.aliasEndpoint === this.plan.target) { - return { ...markerFor(this.aliasEndpoint) as object, source: { commit: "0".repeat(40) } }; + async readMarker(aliasName: ManagedAlias): Promise { + const endpoint = this.aliasEndpoints[aliasName]; + this.operations.push(`read-marker:${aliasName}:${endpoint.deploymentId}`); + if (this.markerBrokenForTargetAlias === aliasName && endpoint === this.plan.target) { + return { ...markerFor(endpoint) as object, source: { commit: "0".repeat(40) } }; } - return markerFor(this.aliasEndpoint); + return markerFor(endpoint); + } + + async readProject(projectId: string): Promise { + this.operations.push(`read-project:${projectId}`); + const value = this.projectReadbacks[projectId]; + if (value === undefined) throw new Error("unknown project"); + return value as ProjectReadback; } - async setAlias(deploymentUrl: string): Promise { - this.operations.push(`set-alias:${deploymentUrl}`); + async setAlias(deploymentUrl: string, aliasName: ManagedAlias): Promise { + this.operations.push(`set-alias:${aliasName}:${deploymentUrl}`); const endpoint = [this.plan.source, this.plan.target] .find((candidate) => candidate.deploymentUrl === deploymentUrl); if (endpoint === undefined) throw new Error("unknown alias target"); - this.aliasEndpoint = endpoint; + if (this.sourceAliasSetFailure === aliasName && endpoint === this.plan.source) { + throw new Error("alias restoration failed"); + } + if (this.targetAliasSetFailure === aliasName && endpoint === this.plan.target) { + this.targetAliasSetFailure = undefined; + this.aliasEndpoints[aliasName] = endpoint; + throw new Error("ambiguous alias mutation"); + } + this.aliasEndpoints[aliasName] = endpoint; } async moveDomain(sourceProjectId: string, targetProjectId: string): Promise { @@ -149,7 +227,7 @@ class FakeCutoverProvider implements CutoverProvider { if (this.moveBehavior === "ambiguous") this.owner = "ambiguous"; if (this.moveBehavior === "move-and-source-alias") { this.owner = "target"; - this.aliasEndpoint = this.plan.source; + this.aliasEndpoints[canonicalAlias] = this.plan.source; } } } @@ -190,6 +268,14 @@ describe("domain cutover runbook", () => { ); expect(runbook).toContain("https://www.hra.sh"); expect(runbook).toContain(".well-known/hra.json"); + expect(runbook).toContain("autoAssignCustomDomains=false"); + expect(runbook).toContain("{id,accountId,autoAssignCustomDomains}"); + expect(runbook).toContain("--prod --skip-domain"); + expect(runbook).toContain("vercel curl / --deployment "); + expect(runbook).toContain("/v4/aliases/hra-weld.vercel.app"); + expect(runbook).toContain("/v4/aliases/try-hra.vercel.app"); + expect(runbook).toContain("https://try-hra.vercel.app"); + expect(runbook).not.toContain("--protection-bypass"); }); test("does not prescribe detach-first or force-based movement", async () => { @@ -210,7 +296,7 @@ describe("domain cutover runbook", () => { }); describe("domain cutover operator", () => { - test("switches traffic before ownership and accepts only exact target readback", async () => { + test("requires both fixed projects to disable automatic domains before switching traffic", async () => { const provider = new FakeCutoverProvider(forwardPlan); await executeCutoverPlan(forwardPlan, provider, { clock: immediateClock(), @@ -219,20 +305,111 @@ describe("domain cutover operator", () => { expect(provider.aliasEndpoint).toBe(newEndpoint); expect(provider.owner).toBe("target"); - const aliasMutation = provider.operations.indexOf(`set-alias:${newEndpoint.deploymentUrl}`); + const aliasMutation = provider.operations.indexOf( + `set-alias:${canonicalAlias}:${newEndpoint.deploymentUrl}`, + ); const domainMutation = provider.operations.indexOf(`move:${oldProjectId}->${newProjectId}`); expect(aliasMutation).toBeGreaterThan(-1); expect(domainMutation).toBeGreaterThan(aliasMutation); - expect(provider.operations.slice(0, 2).sort()).toEqual([ + expect(provider.operations.indexOf( + `read-marker:${newStagingAlias}:${newEndpoint.deploymentId}`, + )).toBeLessThan(aliasMutation); + expect(provider.operations.slice(0, 2)).toEqual([ + `read-project:${oldProjectId}`, + `read-project:${newProjectId}`, + ]); + expect(provider.operations.slice(2, 4).sort()).toEqual([ `read-deployment:${newEndpoint.deploymentId}`, `read-deployment:${oldEndpoint.deploymentId}`, ].sort()); - expect(provider.operations.at(-1)).toBe(`read-marker:${newEndpoint.deploymentId}`); + expect(provider.operations.filter((operation) => operation === ( + `read-marker:${newStagingAlias}:${newEndpoint.deploymentId}` + ))).toHaveLength(2); + expect(provider.operations.at(-1)).toBe( + `read-marker:${newStagingAlias}:${newEndpoint.deploymentId}`, + ); }); + const unsafeProjectReadbacks = [ + { + description: "old project reports true", + projectId: oldProjectId, + value: { accountId: teamId, autoAssignCustomDomains: true, id: oldProjectId }, + }, + { + description: "new project reports true", + projectId: newProjectId, + value: { accountId: teamId, autoAssignCustomDomains: true, id: newProjectId }, + }, + { + description: "old project omits autoAssignCustomDomains", + projectId: oldProjectId, + value: { accountId: teamId, id: oldProjectId }, + }, + { + description: "new project omits autoAssignCustomDomains", + projectId: newProjectId, + value: { accountId: teamId, id: newProjectId }, + }, + { + description: "old-project query returns the new project identity", + projectId: oldProjectId, + value: { accountId: teamId, autoAssignCustomDomains: false, id: newProjectId }, + }, + { + description: "new-project query returns the old project identity", + projectId: newProjectId, + value: { accountId: teamId, autoAssignCustomDomains: false, id: oldProjectId }, + }, + { + description: "old project omits the numeric team identity", + projectId: oldProjectId, + value: { autoAssignCustomDomains: false, id: oldProjectId }, + }, + { + description: "new project omits the numeric team identity", + projectId: newProjectId, + value: { autoAssignCustomDomains: false, id: newProjectId }, + }, + { + description: "old project reports another numeric team identity", + projectId: oldProjectId, + value: { + accountId: "team_AAAAAAAAAAAAAAAAAAAAAAAA", + autoAssignCustomDomains: false, + id: oldProjectId, + }, + }, + { + description: "new project reports another numeric team identity", + projectId: newProjectId, + value: { + accountId: "team_AAAAAAAAAAAAAAAAAAAAAAAA", + autoAssignCustomDomains: false, + id: newProjectId, + }, + }, + ] as const; + + for (const scenario of unsafeProjectReadbacks) { + test(`refuses before deployment or traffic reads when ${scenario.description}`, async () => { + const provider = new FakeCutoverProvider(forwardPlan); + provider.projectReadbacks[scenario.projectId] = scenario.value; + + await expect(executeCutoverPlan(forwardPlan, provider, { + clock: immediateClock(), + convergenceTimeoutMs: 2, + })).rejects.toMatchObject({ code: "automatic_domain_assignment_unsafe" }); + expect(provider.operations).toEqual([ + `read-project:${oldProjectId}`, + `read-project:${newProjectId}`, + ]); + }); + } + test("automatically restores the proven source when target marker does not converge", async () => { const provider = new FakeCutoverProvider(forwardPlan); - provider.markerBrokenForTarget = true; + provider.markerBrokenForTargetAlias = canonicalAlias; await expect(executeCutoverPlan(forwardPlan, provider, { clock: immediateClock(), @@ -241,7 +418,22 @@ describe("domain cutover operator", () => { expect(provider.aliasEndpoint).toBe(oldEndpoint); expect(provider.owner).toBe("source"); expect(provider.operations).not.toContain(`move:${oldProjectId}->${newProjectId}`); - expect(provider.operations).toContain(`set-alias:${oldEndpoint.deploymentUrl}`); + expect(provider.operations).toContain( + `set-alias:${canonicalAlias}:${oldEndpoint.deploymentUrl}`, + ); + }); + + test("refuses forward traffic if the fixed new staging alias is not exact N", async () => { + const provider = new FakeCutoverProvider(forwardPlan); + provider.aliasEndpoints[newStagingAlias] = oldEndpoint; + + await expect(executeCutoverPlan(forwardPlan, provider, { + clock: immediateClock(), + convergenceTimeoutMs: 2, + })).rejects.toMatchObject({ code: "source_not_authoritative" }); + expect(provider.aliasEndpoint).toBe(oldEndpoint); + expect(provider.operations.some((operation) => operation.startsWith("set-alias:"))) + .toBe(false); }); test("restores traffic when the domain move remains on its source", async () => { @@ -281,21 +473,36 @@ describe("domain cutover operator", () => { expect(provider.owner).toBe("ambiguous"); }); + test("reverses N to Q while preserving both fixed staging aliases", async () => { + const provider = new FakeCutoverProvider(reversePlan); + + await executeCutoverPlan(reversePlan, provider, { + clock: immediateClock(), + convergenceTimeoutMs: 2, + }); + expect(provider.aliasEndpoint).toBe(oldEndpoint); + expect(provider.owner).toBe("target"); + expect(provider.fallbackAliasEndpoint).toBe(oldEndpoint); + expect(provider.aliasEndpoints[newStagingAlias]).toBe(newEndpoint); + expect(provider.operations).toContain(`move:${newProjectId}->${oldProjectId}`); + }); + + test("compensates an ambiguous reverse move back to N and new-project ownership", async () => { + const provider = new FakeCutoverProvider(reversePlan); + provider.moveBehavior = "move-and-source-alias"; + + await expect(executeCutoverPlan(reversePlan, provider, { + clock: immediateClock(), + convergenceTimeoutMs: 2, + })).rejects.toMatchObject({ code: "cutover_reverted" }); + expect(provider.aliasEndpoint).toBe(newEndpoint); + expect(provider.owner).toBe("source"); + expect(provider.fallbackAliasEndpoint).toBe(oldEndpoint); + expect(provider.aliasEndpoints[newStagingAlias]).toBe(newEndpoint); + expect(provider.operations).toContain(`move:${oldProjectId}->${newProjectId}`); + }); + test("updates the archive deployment without moving project ownership", async () => { - const baseline: CutoverEndpoint = { - ...oldEndpoint, - deploymentId: "dpl_BaselineAccepted123456789", - deploymentUrl: "hra-baseline-hraness.vercel.app", - generation: null, - sourceCommit: "6221f79b745f154882080936b961ff431569f33e", - }; - const archivePlan: CutoverPlan = { - direction: "archive", - mode: "traffic-only", - schemaVersion: 1, - source: baseline, - target: oldEndpoint, - }; const provider = new FakeCutoverProvider(archivePlan); await executeCutoverPlan(archivePlan, provider, { @@ -303,9 +510,64 @@ describe("domain cutover operator", () => { convergenceTimeoutMs: 2, }); expect(provider.aliasEndpoint).toBe(oldEndpoint); + expect(provider.fallbackAliasEndpoint).toBe(oldEndpoint); + const fallbackMutation = provider.operations.indexOf( + `set-alias:${fallbackAlias}:${oldEndpoint.deploymentUrl}`, + ); + const canonicalMutation = provider.operations.indexOf( + `set-alias:${canonicalAlias}:${oldEndpoint.deploymentUrl}`, + ); + expect(fallbackMutation).toBeGreaterThan(-1); + expect(canonicalMutation).toBeGreaterThan(fallbackMutation); + expect(provider.operations.slice(fallbackMutation, canonicalMutation)).toContain( + `read-marker:${fallbackAlias}:${oldEndpoint.deploymentId}`, + ); expect(provider.operations.some((operation) => operation.startsWith("move:"))).toBe(false); }); + test("restores both aliases to P if the fallback Q marker is not exact", async () => { + const provider = new FakeCutoverProvider(archivePlan); + provider.markerBrokenForTargetAlias = fallbackAlias; + + await expect(executeCutoverPlan(archivePlan, provider, { + clock: immediateClock(), + convergenceTimeoutMs: 2, + })).rejects.toMatchObject({ code: "cutover_reverted" }); + expect(provider.fallbackAliasEndpoint).toBe(baselineEndpoint); + expect(provider.aliasEndpoint).toBe(baselineEndpoint); + expect(provider.operations).not.toContain( + `set-alias:${canonicalAlias}:${oldEndpoint.deploymentUrl}`, + ); + }); + + test("restores both aliases to P if hra.sh fails after fallback Q is proven", async () => { + const provider = new FakeCutoverProvider(archivePlan); + provider.targetAliasSetFailure = canonicalAlias; + + await expect(executeCutoverPlan(archivePlan, provider, { + clock: immediateClock(), + convergenceTimeoutMs: 2, + })).rejects.toMatchObject({ code: "cutover_reverted" }); + expect(provider.fallbackAliasEndpoint).toBe(baselineEndpoint); + expect(provider.aliasEndpoint).toBe(baselineEndpoint); + expect(provider.operations).toContain( + `read-marker:${fallbackAlias}:${oldEndpoint.deploymentId}`, + ); + }); + + test("reports compensation failure if either exact P alias cannot be restored", async () => { + const provider = new FakeCutoverProvider(archivePlan); + provider.markerBrokenForTargetAlias = fallbackAlias; + provider.sourceAliasSetFailure = fallbackAlias; + + await expect(executeCutoverPlan(archivePlan, provider, { + clock: immediateClock(), + convergenceTimeoutMs: 2, + })).rejects.toMatchObject({ code: "compensation_failed" }); + expect(provider.aliasEndpoint).toBe(baselineEndpoint); + expect(provider.fallbackAliasEndpoint).toBe(oldEndpoint); + }); + test("parses only the three fixed numeric identity transitions and bare URLs", () => { expect(parseCutoverPlan(JSON.stringify(forwardPlan))).toEqual(forwardPlan); expect(() => parseCutoverPlan(JSON.stringify({ @@ -332,6 +594,7 @@ describe("domain cutover operator", () => { test("uses exact Vercel API paths, pinned CLI version, and sanitized environment", async () => { const requests: VercelCommandRequest[] = []; + const markerRequests: string[] = []; const runner: VercelCommandRunner = async (request) => { requests.push(request); const path = request.arguments[1]; @@ -341,9 +604,26 @@ describe("domain cutover operator", () => { if (path === "/v4/aliases/hra.sh") { return { exitCode: 0, stderr: "", stdout: JSON.stringify(aliasFor(oldEndpoint)) }; } + if (path === `/v4/aliases/${fallbackAlias}`) { + return { + exitCode: 0, + stderr: "", + stdout: JSON.stringify(aliasFor(oldEndpoint, fallbackAlias)), + }; + } + if (path === `/v4/aliases/${newStagingAlias}`) { + return { + exitCode: 0, + stderr: "", + stdout: JSON.stringify(aliasFor(newEndpoint, newStagingAlias)), + }; + } if (path === `/v13/deployments/${oldEndpoint.deploymentId}`) { return { exitCode: 0, stderr: "", stdout: JSON.stringify(deploymentFor(oldEndpoint)) }; } + if (path === `/v9/projects/${oldProjectId}`) { + return { exitCode: 0, stderr: "", stdout: JSON.stringify(projectFor(oldProjectId)) }; + } if (path === `/v9/projects/${oldProjectId}/domains`) { return { exitCode: 0, stderr: "", stdout: JSON.stringify({ domains: [{ name: "hra.sh" }] }) }; } @@ -355,20 +635,38 @@ describe("domain cutover operator", () => { PATH: "/safe/bin", VERCEL_TOKEN: "must-not-propagate", }, - fetcher: async () => new Response(JSON.stringify(markerFor(oldEndpoint)), { status: 200 }), + fetcher: async (input) => { + markerRequests.push( + typeof input === "string" ? input : input instanceof URL ? input.href : input.url, + ); + return new Response(JSON.stringify(markerFor(oldEndpoint)), { status: 200 }); + }, runner, vercelCli: "/safe/vercel", }); await provider.verifyVersion(); - expect(await provider.readAlias()).toEqual(aliasFor(oldEndpoint)); + expect(await provider.readAlias(canonicalAlias)).toEqual(aliasFor(oldEndpoint)); + expect(await provider.readAlias(fallbackAlias)).toEqual(aliasFor(oldEndpoint, fallbackAlias)); + expect(await provider.readAlias(newStagingAlias)).toEqual( + aliasFor(newEndpoint, newStagingAlias), + ); expect(await provider.readDeployment(oldEndpoint.deploymentId)).toEqual( deploymentFor(oldEndpoint), ); + expect(await provider.readProject(oldProjectId)).toEqual(projectFor(oldProjectId)); expect(await provider.readDomainNames(oldProjectId)).toEqual(["hra.sh"]); - expect(await provider.readMarker()).toEqual(markerFor(oldEndpoint)); - await provider.setAlias(oldEndpoint.deploymentUrl); + expect(await provider.readMarker(canonicalAlias)).toEqual(markerFor(oldEndpoint)); + expect(await provider.readMarker(fallbackAlias)).toEqual(markerFor(oldEndpoint)); + await provider.setAlias(oldEndpoint.deploymentUrl, canonicalAlias); + await provider.setAlias(oldEndpoint.deploymentUrl, fallbackAlias); await provider.moveDomain(oldProjectId, newProjectId); + await expect(provider.readAlias("other.vercel.app" as ManagedAlias)).rejects.toMatchObject({ + code: "alias_readback_invalid", + }); + await expect( + provider.setAlias(oldEndpoint.deploymentUrl, "other.vercel.app" as ManagedAlias), + ).rejects.toMatchObject({ code: "usage_invalid" }); expect(requests.map((request) => request.arguments)).toContainEqual([ "api", @@ -377,6 +675,28 @@ describe("domain cutover operator", () => { "hraness", "--raw", ]); + expect(requests.map((request) => request.arguments)).toContainEqual([ + "api", + `/v4/aliases/${newStagingAlias}`, + "--scope", + "hraness", + "--raw", + ]); + expect(requests.map((request) => request.arguments)).toContainEqual([ + "alias", + "set", + oldEndpoint.deploymentUrl, + fallbackAlias, + "--scope", + "hraness", + ]); + expect(requests.map((request) => request.arguments)).toContainEqual([ + "api", + `/v9/projects/${oldProjectId}`, + "--scope", + "hraness", + "--raw", + ]); expect(requests.map((request) => request.arguments)).toContainEqual([ "api", `/v1/projects/${oldProjectId}/domains/hra.sh/move`, @@ -389,6 +709,9 @@ describe("domain cutover operator", () => { "--silent", ]); expect(requests.every((request) => request.environment.VERCEL_TOKEN === undefined)).toBe(true); + expect(markerRequests.some((request) => request.startsWith( + `https://${fallbackAlias}/.well-known/hra.json?cutover=`, + ))).toBe(true); expect(buildVercelEnvironment({ HOME: "/safe/home", VERCEL_TOKEN: "no" })) .toEqual({ HOME: "/safe/home", NO_COLOR: "1", TERM: "dumb" }); }); @@ -406,7 +729,7 @@ describe("domain cutover operator", () => { vercelCli: "/safe/vercel", }); - await expect(provider.readMarker()).rejects.toMatchObject({ + await expect(provider.readMarker(canonicalAlias)).rejects.toMatchObject({ code: "command_output_invalid", }); }); diff --git a/scripts/domain-cutover.ts b/scripts/domain-cutover.ts index 13b9f6f..10bb19a 100644 --- a/scripts/domain-cutover.ts +++ b/scripts/domain-cutover.ts @@ -10,7 +10,10 @@ const newProjectId = "prj_8ciIt9t9foE3utG45frRN7cxckjS"; const oldRepositoryId = 1_334_876_494; const newRepositoryId = 1_343_008_607; const team = "hraness"; -const alias = "hra.sh"; +const teamId = "team_UAd1iD2XogJlbFg4h14mRaPM"; +const canonicalAlias = "hra.sh"; +const fallbackAlias = "hra-weld.vercel.app"; +const newStagingAlias = "try-hra.vercel.app"; const supportedVercelVersion = "54.18.0"; const commandTimeoutMs = 30_000; const convergenceTimeoutMs = 60_000; @@ -76,8 +79,10 @@ const cutoverPlanSchema = z.object({ export type CutoverEndpoint = z.infer; export type CutoverPlan = z.infer; +const managedAliasSchema = z.enum([canonicalAlias, fallbackAlias, newStagingAlias]); + const aliasReadbackSchema = z.object({ - alias: z.literal(alias), + alias: managedAliasSchema, deployment: z.object({ id: deploymentIdSchema, url: deploymentUrlSchema, @@ -99,6 +104,12 @@ const deploymentReadbackSchema = z.object({ url: deploymentUrlSchema, }); +const projectReadbackSchema = z.object({ + accountId: z.literal(teamId), + autoAssignCustomDomains: z.boolean(), + id: projectIdSchema, +}); + const domainsReadbackSchema = z.object({ domains: z.array(z.object({ name: z.string().min(1).max(253) })).max(1_024), }); @@ -116,20 +127,24 @@ const markerSchema = z.object({ export type AliasReadback = z.infer; export type DeploymentReadback = z.infer; +export type ManagedAlias = z.infer; +export type ProjectReadback = z.infer; type DomainOwner = "ambiguous" | "source" | "target"; export interface CutoverProvider { moveDomain(sourceProjectId: string, targetProjectId: string): Promise; - readAlias(): Promise; + readAlias(aliasName: ManagedAlias): Promise; readDeployment(deploymentId: string): Promise; readDomainNames(projectId: string): Promise; - readMarker(): Promise; - setAlias(deploymentUrl: string): Promise; + readMarker(aliasName: ManagedAlias): Promise; + readProject(projectId: string): Promise; + setAlias(deploymentUrl: string, aliasName: ManagedAlias): Promise; } type CutoverFailureCode = | "alias_readback_invalid" + | "automatic_domain_assignment_unsafe" | "command_failed" | "command_output_invalid" | "compensation_failed" @@ -166,8 +181,12 @@ const defaultClock: CutoverClock = { }, }; -const aliasMatches = (value: AliasReadback, endpoint: CutoverEndpoint): boolean => - value.projectId === endpoint.projectId +const aliasMatches = ( + value: AliasReadback, + endpoint: CutoverEndpoint, + aliasName: ManagedAlias, +): boolean => value.alias === aliasName + && value.projectId === endpoint.projectId && value.deploymentId === endpoint.deploymentId && value.deployment.id === endpoint.deploymentId && value.deployment.url === endpoint.deploymentUrl; @@ -202,8 +221,8 @@ const readOwner = async ( provider.readDomainNames(sourceProjectId), provider.readDomainNames(targetProjectId), ]); - const sourceCount = sourceNames.filter((name) => name === alias).length; - const targetCount = targetNames.filter((name) => name === alias).length; + const sourceCount = sourceNames.filter((name) => name === canonicalAlias).length; + const targetCount = targetNames.filter((name) => name === canonicalAlias).length; if (sourceCount === 1 && targetCount === 0) return "source"; if (sourceCount === 0 && targetCount === 1) return "target"; return "ambiguous"; @@ -212,6 +231,7 @@ const readOwner = async ( const probeEndpoint = async ( provider: CutoverProvider, endpoint: CutoverEndpoint, + aliasName: ManagedAlias, clock: CutoverClock, timeoutMs: number, ): Promise => { @@ -219,10 +239,13 @@ const probeEndpoint = async ( do { try { const [aliasValue, markerValue] = await Promise.all([ - provider.readAlias(), - endpoint.generation === null ? Promise.resolve(null) : provider.readMarker(), + provider.readAlias(aliasName), + endpoint.generation === null ? Promise.resolve(null) : provider.readMarker(aliasName), ]); - if (aliasMatches(aliasValue, endpoint) && markerMatches(markerValue, endpoint)) { + if ( + aliasMatches(aliasValue, endpoint, aliasName) + && markerMatches(markerValue, endpoint) + ) { return true; } } catch { @@ -237,17 +260,34 @@ const probeEndpoint = async ( const restoreTraffic = async ( provider: CutoverProvider, source: CutoverEndpoint, + aliasName: ManagedAlias, clock: CutoverClock, timeoutMs: number, ): Promise => { try { - await provider.setAlias(source.deploymentUrl); + await provider.setAlias(source.deploymentUrl, aliasName); } catch { // Alias mutation failures are ambiguous, so the exact readback still decides. } - if (!await probeEndpoint(provider, source, clock, timeoutMs)) { + if (!await probeEndpoint(provider, source, aliasName, clock, timeoutMs)) { + throw new DomainCutoverError("compensation_failed"); + } +}; + +const restoreArchiveTraffic = async ( + provider: CutoverProvider, + source: CutoverEndpoint, + clock: CutoverClock, + timeoutMs: number, +): Promise => { + const results = await Promise.allSettled([ + restoreTraffic(provider, source, canonicalAlias, clock, timeoutMs), + restoreTraffic(provider, source, fallbackAlias, clock, timeoutMs), + ]); + if (results.some((result) => result.status === "rejected")) { throw new DomainCutoverError("compensation_failed"); } + throw new DomainCutoverError("cutover_reverted"); }; const reverseMetadataIfExact = async ( @@ -273,7 +313,7 @@ const compensateDomainMove = async ( clock: CutoverClock, timeoutMs: number, ): Promise => { - await restoreTraffic(provider, plan.source, clock, timeoutMs); + await restoreTraffic(provider, plan.source, canonicalAlias, clock, timeoutMs); const metadata = await reverseMetadataIfExact(provider, plan); if (metadata !== "restored") throw new DomainCutoverError("cutover_ambiguous"); throw new DomainCutoverError("cutover_reverted"); @@ -294,6 +334,20 @@ export async function executeCutoverPlan( throw new DomainCutoverError("usage_invalid"); } + const projects = await Promise.all([ + provider.readProject(oldProjectId), + provider.readProject(newProjectId), + ]).catch(() => { + throw new DomainCutoverError("automatic_domain_assignment_unsafe"); + }); + const expectedProjects = [oldProjectId, newProjectId] as const; + if (projects.some((project, index) => { + const parsed = projectReadbackSchema.safeParse(project); + return !parsed.success + || parsed.data.id !== expectedProjects[index] + || parsed.data.autoAssignCustomDomains; + })) throw new DomainCutoverError("automatic_domain_assignment_unsafe"); + const [sourceDeployment, targetDeployment] = await Promise.all([ provider.readDeployment(plan.source.deploymentId), provider.readDeployment(plan.target.deploymentId), @@ -305,25 +359,58 @@ export async function executeCutoverPlan( || !deploymentMatches(targetDeployment, plan.target) ) throw new DomainCutoverError("deployment_readback_invalid"); - if (!await probeEndpoint(provider, plan.source, clock, timeoutMs)) { + if (!await probeEndpoint(provider, plan.source, canonicalAlias, clock, timeoutMs)) { throw new DomainCutoverError("source_not_authoritative"); } + const acceptedArchive = plan.direction === "forward" ? plan.source : plan.target; + const fallbackSource = plan.direction === "archive" ? plan.source : acceptedArchive; + if (!await probeEndpoint(provider, fallbackSource, fallbackAlias, clock, timeoutMs)) { + throw new DomainCutoverError("source_not_authoritative"); + } + const acceptedNew = plan.direction === "forward" ? plan.target : plan.source; + if (plan.mode === "domain") { + if (!await probeEndpoint(provider, acceptedNew, newStagingAlias, clock, timeoutMs)) { + throw new DomainCutoverError("source_not_authoritative"); + } + } if ( plan.mode === "domain" && await readOwner(provider, plan.source.projectId, plan.target.projectId) !== "source" ) throw new DomainCutoverError("source_not_authoritative"); + if (plan.direction === "archive") { + try { + await provider.setAlias(plan.target.deploymentUrl, fallbackAlias); + } catch { + return await restoreArchiveTraffic(provider, plan.source, clock, timeoutMs); + } + if (!await probeEndpoint(provider, plan.target, fallbackAlias, clock, timeoutMs)) { + return await restoreArchiveTraffic(provider, plan.source, clock, timeoutMs); + } + } + try { - await provider.setAlias(plan.target.deploymentUrl); + await provider.setAlias(plan.target.deploymentUrl, canonicalAlias); } catch { - await restoreTraffic(provider, plan.source, clock, timeoutMs); + if (plan.direction === "archive") { + return await restoreArchiveTraffic(provider, plan.source, clock, timeoutMs); + } + await restoreTraffic(provider, plan.source, canonicalAlias, clock, timeoutMs); throw new DomainCutoverError("cutover_reverted"); } - if (!await probeEndpoint(provider, plan.target, clock, timeoutMs)) { - await restoreTraffic(provider, plan.source, clock, timeoutMs); + if (!await probeEndpoint(provider, plan.target, canonicalAlias, clock, timeoutMs)) { + if (plan.direction === "archive") { + return await restoreArchiveTraffic(provider, plan.source, clock, timeoutMs); + } + await restoreTraffic(provider, plan.source, canonicalAlias, clock, timeoutMs); throw new DomainCutoverError("cutover_reverted"); } - if (plan.mode === "traffic-only") return; + if (plan.mode === "traffic-only") { + if (!await probeEndpoint(provider, plan.target, fallbackAlias, clock, timeoutMs)) { + return await restoreArchiveTraffic(provider, plan.source, clock, timeoutMs); + } + return; + } try { await provider.moveDomain(plan.source.projectId, plan.target.projectId); @@ -335,16 +422,20 @@ export async function executeCutoverPlan( do { try { const [aliasValue, owner] = await Promise.all([ - provider.readAlias(), + provider.readAlias(canonicalAlias), readOwner(provider, plan.source.projectId, plan.target.projectId), ]); - if (aliasMatches(aliasValue, plan.target) && owner === "target") { - if (!await probeEndpoint(provider, plan.target, clock, timeoutMs)) { + if (aliasMatches(aliasValue, plan.target, canonicalAlias) && owner === "target") { + if ( + !await probeEndpoint(provider, plan.target, canonicalAlias, clock, timeoutMs) + || !await probeEndpoint(provider, acceptedArchive, fallbackAlias, clock, timeoutMs) + || !await probeEndpoint(provider, acceptedNew, newStagingAlias, clock, timeoutMs) + ) { return await compensateDomainMove(provider, plan, clock, timeoutMs); } return; } - if (aliasMatches(aliasValue, plan.source) && owner === "target") { + if (aliasMatches(aliasValue, plan.source, canonicalAlias) && owner === "target") { return await compensateDomainMove(provider, plan, clock, timeoutMs); } } catch { @@ -506,15 +597,20 @@ export class VercelCutoverProvider implements CutoverProvider { } } - async readAlias(): Promise { + async readAlias(aliasName: ManagedAlias): Promise { + if (!managedAliasSchema.safeParse(aliasName).success) { + throw new DomainCutoverError("alias_readback_invalid"); + } try { - return aliasReadbackSchema.parse(parseProviderJson(await this.#invoke([ + const parsed = aliasReadbackSchema.parse(parseProviderJson(await this.#invoke([ "api", - `/v4/aliases/${alias}`, + `/v4/aliases/${aliasName}`, "--scope", team, "--raw", ]))); + if (parsed.alias !== aliasName) throw new DomainCutoverError("alias_readback_invalid"); + return parsed; } catch (error: unknown) { if (error instanceof DomainCutoverError && error.code === "command_failed") throw error; throw new DomainCutoverError("alias_readback_invalid"); @@ -554,12 +650,15 @@ export class VercelCutoverProvider implements CutoverProvider { return parsed.data.domains.map((domain) => domain.name); } - async readMarker(): Promise { + async readMarker(aliasName: ManagedAlias): Promise { + if (!managedAliasSchema.safeParse(aliasName).success) { + throw new DomainCutoverError("command_output_invalid"); + } const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 5_000); try { const response = await this.#fetcher( - `https://${alias}/.well-known/hra.json?cutover=${crypto.randomUUID()}`, + `https://${aliasName}/.well-known/hra.json?cutover=${crypto.randomUUID()}`, { cache: "no-store", headers: { "cache-control": "no-cache" }, @@ -600,11 +699,35 @@ export class VercelCutoverProvider implements CutoverProvider { } } - async setAlias(deploymentUrl: string): Promise { - if (!deploymentUrlSchema.safeParse(deploymentUrl).success) { + async readProject(projectId: string): Promise { + if (!projectIdSchema.safeParse(projectId).success) { + throw new DomainCutoverError("automatic_domain_assignment_unsafe"); + } + try { + const parsed = projectReadbackSchema.parse(parseProviderJson(await this.#invoke([ + "api", + `/v9/projects/${projectId}`, + "--scope", + team, + "--raw", + ]))); + if (parsed.id !== projectId) { + throw new DomainCutoverError("automatic_domain_assignment_unsafe"); + } + return parsed; + } catch { + throw new DomainCutoverError("automatic_domain_assignment_unsafe"); + } + } + + async setAlias(deploymentUrl: string, aliasName: ManagedAlias): Promise { + if ( + !deploymentUrlSchema.safeParse(deploymentUrl).success + || !managedAliasSchema.safeParse(aliasName).success + ) { throw new DomainCutoverError("usage_invalid"); } - await this.#invoke(["alias", "set", deploymentUrl, alias, "--scope", team]); + await this.#invoke(["alias", "set", deploymentUrl, aliasName, "--scope", team]); } async moveDomain(sourceProjectId: string, targetProjectId: string): Promise { @@ -615,7 +738,7 @@ export class VercelCutoverProvider implements CutoverProvider { ) throw new DomainCutoverError("usage_invalid"); await this.#invoke([ "api", - `/v1/projects/${sourceProjectId}/domains/${alias}/move`, + `/v1/projects/${sourceProjectId}/domains/${canonicalAlias}/move`, "--scope", team, "-X",