Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/hosted-sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ The provider identity guard pins the intended Convex team to numeric ID `513923`
--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 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 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`, the exact project, deployment, production type, generated deployment name, URL, and two matching default-production facts: the deployment reports `isDefault: true` and the project names that deployment as `prodDeploymentName`. 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:<generated-name>`. 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.
The helper creates a private exclusive environment file containing only `CONVEX_DEPLOYMENT=prod:<generated-name>`. Convex uses that value as project context and deploys to the project's current default production deployment, so the matching default-production readbacks are part of the target guard rather than an informational check. After Convex resolves the actual deployment credentials and before it pushes, its mandatory `--cmd` exposes the resolved canonical cloud URL only to a silent local assertion. That assertion must match the exact expected deployment URL or the deploy stops before `runPush`; a later default change cannot redirect the already-resolved credentials. The helper disables Convex's optional pre-command WorkOS provisioning because HRA does not use Convex AuthKit and no provider mutation may precede this assertion. It otherwise invokes `convex deploy --env-file` with confirmation disabled, strict typechecking, code generation disabled, sanitized inherited environment variables, bounded provider output, and a ten-minute deadline. Provider output is suppressed. A failure, changed default, resolved-target mismatch, or dirty postflight leaves the deployment quarantined for inspection; do not retry it.

## Configure secrets

Expand Down
4 changes: 2 additions & 2 deletions kb/plans/hra-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@ Rename the new GitHub repository ID `1343008607` from `hot-codex` to `hra` and V

The domain procedure is traffic-first and numeric-ID-bound. The exact authenticated `/v4/aliases/hra.sh` tuple `(projectId, deploymentId, deployment.id, deployment.url)` is traffic authority. The public `/.well-known/hra.json` marker independently binds product generation, numeric repository ID, version, and exact source commit; it cannot substitute for deployment identity. The checked operator verifies both source and target deployments, aliases `hra.sh` to the target's bare automatic hostname, proves the alias tuple and marker for at most 60 seconds, and automatically restores the last proven source on every failed or ambiguous traffic change. It calls Vercel's single project-domain move endpoint only after traffic is proven, resolves alias and both domain lists with an explicit ambiguity table, and reverses exact target metadata only after restoring source traffic. Rehearse archive P→Q, forward Q→N, reverse N→Q, and final forward movement. Never use `vercel domains add --force`, detach-then-attach, an unreviewed dashboard move, or a name as provider authority. The checked operator sequence lives in `docs/domain-cutover.md`.

Fresh hosted setup accepts exactly six protected values or locally generated secrets through nonterminal input: `SITE_URL`, `JWT_PRIVATE_KEY`, `JWKS`, `HRA_AUTH_HMAC_SECRET`, `HRA_RESEND_API_KEY`, and `HRA_AUTH_EMAIL_FROM`. Every configure, deploy, and bootstrap mutation performs authenticated Convex management readback before and after the command and requires the exact expected numeric team, project, deployment, deployment type, generated deployment name, and deployment URL. Deploy uses one exclusive temporary env file containing only the exact `prod:<deployment-name>` selector, so inherited or stale Convex linkage cannot redirect it. Configuration refuses overwrite and provider ambiguity. On that exact fresh deployment, one-shot hard-quota genesis and an exact empty-ledger readback precede every auth, OTP, invitation, device, or application write. The first invite capability goes directly to a new no-follow mode-0600 file; both file content and its parent directory entry are synced before success. It never enters terminal output, argv, an environment variable, or provider logs. The checked operator sequence lives in `docs/hosted-sync.md`.
Fresh hosted setup accepts exactly six protected values or locally generated secrets through nonterminal input: `SITE_URL`, `JWT_PRIVATE_KEY`, `JWKS`, `HRA_AUTH_HMAC_SECRET`, `HRA_RESEND_API_KEY`, and `HRA_AUTH_EMAIL_FROM`. Every configure, deploy, and bootstrap mutation performs authenticated Convex management readback before and after the command and requires the exact expected numeric team, project, deployment, deployment type, generated deployment name, and deployment URL. Convex interprets the exclusive temporary `CONVEX_DEPLOYMENT=prod:<deployment-name>` file used by `convex deploy` as project context, not as an exact nondefault target. The deploy guard therefore additionally requires the exact deployment to report `isDefault: true` and its project to name it as `prodDeploymentName` immediately before and after the push. After Convex resolves deployment credentials, a mandatory pre-push command compares that resolved canonical cloud URL to the exact expected URL and aborts before `runPush` on mismatch. Configuration and bootstrap explicitly address the generated deployment and retain the broader identity guard. Configuration refuses overwrite and provider ambiguity. On that exact fresh deployment, one-shot hard-quota genesis and an exact empty-ledger readback precede every auth, OTP, invitation, device, or application write. The first invite capability goes directly to a new no-follow mode-0600 file; both file content and its parent directory entry are synced before success. It never enters terminal output, argv, an environment variable, or provider logs. The checked operator sequence lives in `docs/hosted-sync.md`.

GitHub redirects from the old `hraness/hra` repository cease when the new repository reuses that name. Before collision, audit and update every mutable old surface: default-branch docs, site links, repository metadata, package metadata, release-download helpers, security/contact paths, and the old Vercel site. Immutable artifacts and prior release bodies cannot all be rewritten, so the v0 fallback hosts a durable compatibility page mapping legacy tags and assets to `hraness/hra-v0` by exact version and commit.

Expand Down Expand Up @@ -379,7 +379,7 @@ After the public commit point, repository names and immutable tags do not roll b

- Rename the old GitHub repository by numeric ID while `hraness/hra` is otherwise vacant, then publish the protected archive PR and record its actual green main commit. Rename the old Vercel and Convex projects in place, deploy that exact archive commit, and read back their original numeric identities, immutable releases, preserved deployment, data, and fallback.
- Rename the new repository and Vercel project to HRA. Create and deploy fresh Convex state and credentials. Link Git by numeric repository identity, install a strict current-head `Required` rule, and require the release workflow to prove the peeled tag commit equals current `origin/main`.
- Configure only the six protected hosted values through exact numeric Convex preflight and postflight, deploy through an exclusive exact-target env file, run one-shot hard-quota genesis, prove the singleton authority is hard and empty, then issue the first identity invite to an exclusive capability file with file and parent-directory durability. Complete live two-account and two-device acceptance before traffic movement.
- Configure only the six protected hosted values through exact numeric Convex preflight and postflight. Deploy through exclusive project-context selection, matching default-production readbacks, and the in-process resolved-target assertion. Run one-shot hard-quota genesis, prove the singleton authority is hard and empty, then issue the first identity invite to an exclusive capability file with file and parent-directory durability. Complete live two-account and two-device acceptance before traffic movement.
- Verify the new release behind a noncanonical URL, move `hra.sh` with the checked exact-deployment operator and bounded automatic compensation, atomically move its project-domain ownership by numeric ID, verify the exact alias API tuple plus apex and `www`, then publish an unambiguous immutable beta tag, checksums, SBOM, release notes, install flow, and friend-beta instructions.
- Rehearse full staging rollback before the irreversible public commit point and domain-only incident rollback after it. Do not attach the prerelease domain to HRA.

Expand Down
42 changes: 42 additions & 0 deletions scripts/assert-convex-deploy-target.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, expect, test } from "bun:test";

import {
HRA_EXPECTED_CONVEX_DEPLOY_URL,
HRA_RESOLVED_CONVEX_DEPLOY_URL,
resolvedConvexDeployTargetMatches,
} from "./assert-convex-deploy-target";

const expected = "https://steady-otter-321.convex.cloud";

describe("resolved Convex deploy target assertion", () => {
test("accepts only the exact generated deployment URL Convex resolved", () => {
expect(resolvedConvexDeployTargetMatches({
[HRA_EXPECTED_CONVEX_DEPLOY_URL]: expected,
[HRA_RESOLVED_CONVEX_DEPLOY_URL]: expected,
})).toBeTrue();
});

test("refuses a default switch, missing value, custom origin, or URL decoration", () => {
for (const resolved of [
undefined,
"https://other-otter-999.convex.cloud",
"https://convex.example.com",
`${expected}/path`,
`${expected}?query=1`,
]) {
expect(resolvedConvexDeployTargetMatches({
[HRA_EXPECTED_CONVEX_DEPLOY_URL]: expected,
...(resolved === undefined
? {}
: { [HRA_RESOLVED_CONVEX_DEPLOY_URL]: resolved }),
})).toBeFalse();
}
});

test("refuses a malformed expected target even when both values match", () => {
expect(resolvedConvexDeployTargetMatches({
[HRA_EXPECTED_CONVEX_DEPLOY_URL]: "https://convex.example.com",
[HRA_RESOLVED_CONVEX_DEPLOY_URL]: "https://convex.example.com",
})).toBeFalse();
});
});
39 changes: 39 additions & 0 deletions scripts/assert-convex-deploy-target.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
const generatedDeploymentUrlPattern =
/^https:\/\/[a-z][a-z0-9]*-[a-z][a-z0-9]*-[0-9]+\.convex\.cloud$/u;

export const HRA_EXPECTED_CONVEX_DEPLOY_URL =
"HRA_EXPECTED_CONVEX_DEPLOY_URL" as const;
export const HRA_RESOLVED_CONVEX_DEPLOY_URL =
"HRA_RESOLVED_CONVEX_DEPLOY_URL" as const;

const exactGeneratedDeploymentUrl = (value: unknown): string | null => {
if (typeof value !== "string" || !generatedDeploymentUrlPattern.test(value)) {
return null;
}
try {
const parsed = new URL(value);
return parsed.origin === value
&& parsed.username === ""
&& parsed.password === ""
? value
: null;
} catch {
return null;
}
};

export function resolvedConvexDeployTargetMatches(
environment: Readonly<NodeJS.ProcessEnv>,
): boolean {
const expected = exactGeneratedDeploymentUrl(
environment[HRA_EXPECTED_CONVEX_DEPLOY_URL],
);
const resolved = exactGeneratedDeploymentUrl(
environment[HRA_RESOLVED_CONVEX_DEPLOY_URL],
);
return expected !== null && resolved === expected;
}

if (import.meta.main) {
process.exitCode = resolvedConvexDeployTargetMatches(process.env) ? 0 : 1;
}
63 changes: 63 additions & 0 deletions scripts/convex-target.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
parseConvexTarget,
parseConvexTargetArguments,
readConvexAccessToken,
verifyConvexDefaultTarget,
verifyConvexTarget,
type ConvexManagementFetch,
type ConvexTarget,
Expand Down Expand Up @@ -76,6 +77,7 @@ describe("numeric Convex target guard", () => {
deploymentType: "prod",
deploymentUrl: target.deploymentUrl,
id: target.deploymentId,
isDefault: false,
name: target.deploymentName,
projectId: target.projectId,
}), { status: 200 });
Expand Down Expand Up @@ -133,6 +135,7 @@ describe("numeric Convex target guard", () => {
deploymentType: "prod",
deploymentUrl: target.deploymentUrl,
id: target.deploymentId,
isDefault: false,
name: target.deploymentName,
projectId: target.projectId,
}), { status: 200 });
Expand All @@ -151,6 +154,7 @@ describe("numeric Convex target guard", () => {
deploymentType: "prod",
deploymentUrl: target.deploymentUrl,
id: target.deploymentId + 1,
isDefault: false,
name: target.deploymentName,
projectId: target.projectId,
},
Expand All @@ -161,6 +165,7 @@ describe("numeric Convex target guard", () => {
deploymentType: "dev",
deploymentUrl: target.deploymentUrl,
id: target.deploymentId,
isDefault: false,
name: target.deploymentName,
projectId: target.projectId,
},
Expand Down Expand Up @@ -189,6 +194,64 @@ describe("numeric Convex target guard", () => {
}
});

test("requires both deployment and project default authority for a production push", async () => {
const { configPath } = await makeConfig();
const requests: string[] = [];
const makeFetcher = (
isDefault: boolean,
prodDeploymentName: string | null,
projectTeamId: number = target.teamId,
): ConvexManagementFetch => async (input) => {
const url = String(input);
requests.push(url);
if (url.includes("/team_and_project")) {
return new Response(JSON.stringify({
project: "hra",
projectId: target.projectId,
team: HRA_CONVEX_TEAM_SLUG,
teamId: target.teamId,
}), { status: 200 });
}
if (url.includes(`/v1/projects/${target.projectId}`)) {
return new Response(JSON.stringify({
id: target.projectId,
prodDeploymentName,
teamId: projectTeamId,
}), { status: 200 });
}
return new Response(JSON.stringify({
deploymentType: "prod",
deploymentUrl: target.deploymentUrl,
id: target.deploymentId,
isDefault,
name: target.deploymentName,
projectId: target.projectId,
}), { status: 200 });
};

await verifyConvexDefaultTarget(target, {
configPath,
fetch: makeFetcher(true, target.deploymentName),
});
expect(requests.sort()).toEqual([
`https://api.convex.dev/api/deployment/${target.deploymentName}/team_and_project`,
`https://api.convex.dev/v1/deployments/${target.deploymentName}`,
`https://api.convex.dev/v1/projects/${target.projectId}`,
].sort());

for (const [isDefault, prodDeploymentName, projectTeamId] of [
[false, target.deploymentName, target.teamId],
[true, null, target.teamId],
[true, "other-otter-999", target.teamId],
[true, target.deploymentName, target.teamId + 1],
] as const) {
await expect(verifyConvexDefaultTarget(target, {
configPath,
fetch: makeFetcher(isDefault, prodDeploymentName, projectTeamId),
})).rejects.toThrow("target_mismatch");
}
});

test("requires a no-follow single-link 0600 bounded config file", async () => {
const permissive = await makeConfig(0o644);
await expect(readConvexAccessToken(permissive.configPath))
Expand Down
48 changes: 43 additions & 5 deletions scripts/convex-target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,17 @@ const deploymentReadbackSchema = z.object({
deploymentType: z.literal("prod"),
deploymentUrl: deploymentUrlSchema,
id: numericIdentifierSchema,
isDefault: z.boolean(),
name: generatedDeploymentNameSchema,
projectId: numericIdentifierSchema,
}).passthrough();

const projectReadbackSchema = z.object({
id: numericIdentifierSchema,
prodDeploymentName: generatedDeploymentNameSchema.nullable(),
teamId: z.literal(HRA_CONVEX_TEAM_ID),
}).passthrough();

const configSchema = z.object({
accessToken: z.string()
.min(1)
Expand Down Expand Up @@ -279,10 +286,11 @@ export type ConvexTargetVerificationOptions = Readonly<{

export type ConvexTargetVerifier = (target: ConvexTarget) => Promise<void>;

export async function verifyConvexTarget(
const verifyConvexTargetWithRequirement = async (
targetValue: ConvexTarget,
options: ConvexTargetVerificationOptions = {},
): Promise<void> {
options: ConvexTargetVerificationOptions,
requireDefault: boolean,
): Promise<void> => {
const target = parseConvexTarget(targetValue);
const accessToken = await readConvexAccessToken(options.configPath);
const fetcher = options.fetch ?? fetch;
Expand All @@ -305,13 +313,20 @@ export async function verifyConvexTarget(
return await readBoundedJson(response);
};

const [teamAndProjectValue, deploymentValue] = await Promise.all([
const requests = [
request(new URL(
`/api/deployment/${encodedName}/team_and_project`,
"https://api.convex.dev",
)),
request(new URL(`/v1/deployments/${encodedName}`, "https://api.convex.dev")),
]);
];
if (requireDefault) {
requests.push(request(new URL(
`/v1/projects/${target.projectId}`,
"https://api.convex.dev",
)));
}
const [teamAndProjectValue, deploymentValue, projectValue] = await Promise.all(requests);
const teamAndProject = teamAndProjectSchema.safeParse(teamAndProjectValue);
const deployment = deploymentReadbackSchema.safeParse(deploymentValue);
if (
Expand All @@ -323,4 +338,27 @@ export async function verifyConvexTarget(
|| deployment.data.projectId !== target.projectId
|| deployment.data.deploymentUrl !== target.deploymentUrl
) throw new ConvexTargetError("target_mismatch");
if (requireDefault) {
const project = projectReadbackSchema.safeParse(projectValue);
if (
!project.success
|| !deployment.data.isDefault
|| project.data.id !== target.projectId
|| project.data.prodDeploymentName !== target.deploymentName
) throw new ConvexTargetError("target_mismatch");
}
};

export async function verifyConvexTarget(
targetValue: ConvexTarget,
options: ConvexTargetVerificationOptions = {},
): Promise<void> {
await verifyConvexTargetWithRequirement(targetValue, options, false);
}

export async function verifyConvexDefaultTarget(
targetValue: ConvexTarget,
options: ConvexTargetVerificationOptions = {},
): Promise<void> {
await verifyConvexTargetWithRequirement(targetValue, options, true);
}
Loading
Loading