diff --git a/docs/domain-cutover.md b/docs/domain-cutover.md index 603419e..4f61605 100644 --- a/docs/domain-cutover.md +++ b/docs/domain-cutover.md @@ -21,7 +21,7 @@ The authenticated Vercel alias readback is the traffic authority. Its exact `(pr vercel api /v4/aliases/hra.sh --scope hraness --raw | jq -c '{alias,projectId,deploymentId,deployment:{id:.deployment.id,url:.deployment.url}}' ``` -`https://hra.sh/.well-known/hra.json` is independent product evidence. Generation 0 must identify repository ID `1334876494`, path `hraness/hra-v0`, and the exact accepted archive source commit. Generation 1 must identify repository ID `1343008607`, path `hraness/hra`, and the exact accepted new-HRA source commit. Both markers carry `source.commit` and version. A marker does not replace the deployment-ID readback because two deployments can share source and version. +`https://hra.sh/.well-known/hra.json` is independent product evidence. Generation 0 must identify repository ID `1334876494`, path `hraness/hra-v0`, the exact accepted archive source commit, and the archive version at `publication.version`. Generation 1 must identify repository ID `1343008607`, path `hraness/hra`, the exact accepted new-HRA source commit, and its top-level `version`. Both schema-version-2 markers carry `source.commit`; their generation-discriminated version locations are intentional. A marker does not replace the deployment-ID readback because two deployments can share source and version. 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. @@ -53,21 +53,25 @@ vercel api /v13/deployments/ --scope hraness --raw | jq -c '{id,u 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: +Stage a source upload 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`. +Do not accept a source-uploaded Q or N deployment created by a command that omitted `--skip-domain`. A deployment created from Vercel's immutable Git source may instead be rebuilt with `vercel redeploy --target production` when the original build failed before release, but only after independently proving its exact `gitSource` tuple and reading `autoAssignCustomDomains === false` immediately before and after the rebuild. `vercel redeploy` has no `--skip-domain` option. The resulting deployment remains acceptable only when it preserves the exact GitHub repository ID, `main` ref, source commit, project ID, and disabled custom-domain setting. A CLI source upload whose deployment record has `gitSource: null` is not a Q or N candidate even when its Git metadata strings look correct. Inspect Q and N before exposing them through a public custom alias with the authenticated local Vercel session: +From the fixed, mode-`0700` linked operator directory whose `.vercel/project.json` names the exact numeric project, run: + ```sh -vercel curl / --deployment --scope hraness -vercel curl /.well-known/hra.json --deployment --scope hraness +vercel curl / --deployment +vercel curl /.well-known/hra.json --deployment ``` +Vercel CLI `54.18.0` forwards `--scope` to its nested curl process even when written as a global option, so do not add it to `vercel curl`. The protected fixed link supplies project and team identity; the separate deployment and project API readbacks remain authoritative. + 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: diff --git a/scripts/domain-cutover.test.ts b/scripts/domain-cutover.test.ts index 96eb468..e018352 100644 --- a/scripts/domain-cutover.test.ts +++ b/scripts/domain-cutover.test.ts @@ -112,23 +112,40 @@ const projectFor = ( id: projectId, }); -const markerFor = (endpoint: CutoverEndpoint): unknown => ({ - generation: endpoint.generation, - product: "HRA", - repository: { - id: endpoint.repositoryId, - path: endpoint.projectId === oldProjectId ? "hraness/hra-v0" : "hraness/hra", - }, - schemaVersion: 2, - source: { commit: endpoint.sourceCommit }, - version: endpoint.version, -}); +const markerFor = (endpoint: CutoverEndpoint): unknown => { + const shared = { + generation: endpoint.generation, + product: "HRA", + repository: { + id: endpoint.repositoryId, + path: endpoint.projectId === oldProjectId ? "hraness/hra-v0" : "hraness/hra", + }, + schemaVersion: 2, + source: { commit: endpoint.sourceCommit }, + }; + return endpoint.generation === 0 + ? { + ...shared, + publication: { + build: 15, + dmgSha256: "7ff49500de3d1fc768c17454ef7642c51f6662dfa5bf0e2ba183a85bb67fcd03", + publicationCommit: "6221f79b745f154882080936b961ff431569f33e", + releaseId: 374_980_441, + sourceCommit: "7b39c459827b2acf45aa2d911c94fdb5d4f37860", + tag: "v0.1.14", + tagObject: "37ed37afb39cacfd6a51044cf7f3c1b873571aa3", + version: endpoint.version, + }, + } + : { ...shared, version: endpoint.version }; +}; type MoveBehavior = "ambiguous" | "commit" | "move-and-source-alias" | "noop"; class FakeCutoverProvider implements CutoverProvider { readonly aliasEndpoints: Record; markerBrokenForTargetAlias: ManagedAlias | undefined; + markerOverrideForTargetAlias: Readonly<{ alias: ManagedAlias; value: unknown }> | undefined; moveBehavior: MoveBehavior = "commit"; owner: "ambiguous" | "source" | "target" = "source"; sourceAliasSetFailure: ManagedAlias | undefined; @@ -185,6 +202,10 @@ class FakeCutoverProvider implements CutoverProvider { async readMarker(aliasName: ManagedAlias): Promise { const endpoint = this.aliasEndpoints[aliasName]; this.operations.push(`read-marker:${aliasName}:${endpoint.deploymentId}`); + if ( + this.markerOverrideForTargetAlias?.alias === aliasName + && endpoint === this.plan.target + ) return this.markerOverrideForTargetAlias.value; if (this.markerBrokenForTargetAlias === aliasName && endpoint === this.plan.target) { return { ...markerFor(endpoint) as object, source: { commit: "0".repeat(40) } }; } @@ -272,6 +293,9 @@ describe("domain cutover runbook", () => { expect(runbook).toContain("{id,accountId,autoAssignCustomDomains}"); expect(runbook).toContain("--prod --skip-domain"); expect(runbook).toContain("vercel curl / --deployment "); + expect(runbook).toContain("publication.version"); + expect(runbook).toContain("top-level `version`"); + expect(runbook).not.toContain("vercel curl / --deployment --scope"); 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"); @@ -540,6 +564,50 @@ describe("domain cutover operator", () => { ); }); + test("refuses generation-zero markers with a wrong schema or top-level-only version", async () => { + for (const value of [ + { ...markerFor(oldEndpoint) as object, schemaVersion: 1 }, + { + generation: 0, + product: "HRA", + repository: { + id: oldEndpoint.repositoryId, + path: "hraness/hra-v0", + }, + schemaVersion: 2, + source: { commit: oldEndpoint.sourceCommit }, + version: oldEndpoint.version, + }, + ]) { + const provider = new FakeCutoverProvider(archivePlan); + provider.markerOverrideForTargetAlias = { alias: fallbackAlias, value }; + + await expect(executeCutoverPlan(archivePlan, provider, { + clock: immediateClock(), + convergenceTimeoutMs: 2, + })).rejects.toMatchObject({ code: "cutover_reverted" }); + expect(provider.fallbackAliasEndpoint).toBe(baselineEndpoint); + expect(provider.aliasEndpoint).toBe(baselineEndpoint); + } + }); + + test("refuses a generation-one marker whose version exists only under publication", async () => { + const provider = new FakeCutoverProvider(forwardPlan); + const marker = markerFor(newEndpoint) as Record; + const { version, ...withoutVersion } = marker; + provider.markerOverrideForTargetAlias = { + alias: canonicalAlias, + value: { ...withoutVersion, publication: { version } }, + }; + + await expect(executeCutoverPlan(forwardPlan, provider, { + clock: immediateClock(), + convergenceTimeoutMs: 2, + })).rejects.toMatchObject({ code: "cutover_reverted" }); + expect(provider.aliasEndpoint).toBe(oldEndpoint); + expect(provider.owner).toBe("source"); + }); + test("restores both aliases to P if hra.sh fails after fallback Q is proven", async () => { const provider = new FakeCutoverProvider(archivePlan); provider.targetAliasSetFailure = canonicalAlias; diff --git a/scripts/domain-cutover.ts b/scripts/domain-cutover.ts index 10bb19a..6b8b711 100644 --- a/scripts/domain-cutover.ts +++ b/scripts/domain-cutover.ts @@ -114,16 +114,29 @@ const domainsReadbackSchema = z.object({ domains: z.array(z.object({ name: z.string().min(1).max(253) })).max(1_024), }); -const markerSchema = z.object({ - generation: z.union([z.literal(0), z.literal(1)]), - product: z.literal("HRA"), - repository: z.object({ - id: z.number().int().positive(), - path: z.string().min(1).max(200), - }), - source: z.object({ commit: commitSchema }), - version: versionSchema, -}); +const markerRepositorySchema = z.object({ + id: z.number().int().positive(), + path: z.string().min(1).max(200), +}).strict(); +const markerSourceSchema = z.object({ commit: commitSchema }).strict(); +const markerSchema = z.discriminatedUnion("generation", [ + z.object({ + generation: z.literal(0), + product: z.literal("HRA"), + publication: z.object({ version: versionSchema }).passthrough(), + repository: markerRepositorySchema, + schemaVersion: z.literal(2), + source: markerSourceSchema, + }).strict(), + z.object({ + generation: z.literal(1), + product: z.literal("HRA"), + repository: markerRepositorySchema, + schemaVersion: z.literal(2), + source: markerSourceSchema, + version: versionSchema, + }).strict(), +]); export type AliasReadback = z.infer; export type DeploymentReadback = z.infer; @@ -204,12 +217,17 @@ const markerMatches = (value: unknown, endpoint: CutoverEndpoint): boolean => { if (endpoint.generation === null) return true; const parsed = markerSchema.safeParse(value); const expectedPath = endpoint.projectId === oldProjectId ? "hraness/hra-v0" : "hraness/hra"; + const markerVersion = parsed.success + ? parsed.data.generation === 0 + ? parsed.data.publication.version + : parsed.data.version + : null; return parsed.success && parsed.data.generation === endpoint.generation && parsed.data.repository.id === endpoint.repositoryId && parsed.data.repository.path === expectedPath && parsed.data.source.commit === endpoint.sourceCommit - && parsed.data.version === endpoint.version; + && markerVersion === endpoint.version; }; const readOwner = async ( diff --git a/src/cli.test.ts b/src/cli.test.ts index 82e3eb4..16f50d8 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -3,12 +3,26 @@ import { lstat, mkdir, mkdtemp, realpath, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { initialize, main, renderRemoteSuccess, resolveSessionEventCursorCodec, selectDaemonCloudControl } from "./cli"; +import { + initialize, + main, + renderRemoteSuccess, + resolveDaemonCloudStartup, + resolveSessionEventCursorCodec, + selectDaemonCloudControl, +} from "./cli"; import { CloudDaemonJournalRecoveryBlocker, + CustodyCloudDaemonJournal, MemoryCloudDaemonJournal, type CloudProjectionRecoveryJournalEntry, } from "./cloud/daemon-journal"; +import { + cloudDeploymentAuthorityFromEnvironment, + DeploymentScopedCloudSecretCustody, + IdentityScopedCloudSecretCustody, +} from "./cloud/identity-custody"; +import type { CloudSecretCustodyPort } from "./cloud/local-control"; import type { CommandResponse, LocalCommand } from "./domain/contracts"; import { DAEMON_PROTOCOL, DaemonLock } from "./daemon/daemon-lock"; import { initializeStatePaths, resolveStatePaths } from "./storage/paths"; @@ -23,6 +37,31 @@ const capture = () => { }; }; +class MemoryCloudCustody implements CloudSecretCustodyPort { + readonly values = new Map>(); + + read(slot: string): Promise | null> { + return Promise.resolve(this.values.get(slot) ?? null); + } + + compareAndSwap( + slot: string, + expectedGeneration: number | null, + value: string, + ): Promise | null> { + const current = this.values.get(slot) ?? null; + if ((current?.generation ?? null) !== expectedGeneration) return Promise.resolve(null); + const committed = { generation: (current?.generation ?? -1) + 1, value }; + this.values.set(slot, committed); + return Promise.resolve(committed); + } + + clearIfGeneration(slot: string, expectedGeneration: number): Promise { + if (this.values.get(slot)?.generation !== expectedGeneration) return Promise.resolve(false); + return Promise.resolve(this.values.delete(slot)); + } +} + const runningDaemonResponse = () => ({ ok: true as const, version: 1 as const, @@ -640,15 +679,35 @@ describe("CLI entry point", () => { } }); - test("remote commands remain explicit and offline when no cloud URL is configured", async () => { + test("remote commands remain explicit and offline when cloud is explicitly disabled", async () => { + const previous = process.env.HRA_CONVEX_URL; + process.env.HRA_CONVEX_URL = ""; + try { + const captured = capture(); + expect(await main(["remote", "list", "--json"], captured.output)).toBe(5); + expect(JSON.parse(captured.read().stdout)).toMatchObject({ + ok: false, + error: { code: "UNAVAILABLE", message: expect.stringContaining("disabled") }, + }); + expect(captured.read().stderr).toBe(""); + } finally { + if (previous === undefined) delete process.env.HRA_CONVEX_URL; + else process.env.HRA_CONVEX_URL = previous; + } + }); + + test("remote deployment configuration failures stay static and actionable", async () => { const previous = process.env.HRA_CONVEX_URL; - delete process.env.HRA_CONVEX_URL; + process.env.HRA_CONVEX_URL = "not a deployment URL"; try { const captured = capture(); expect(await main(["remote", "list", "--json"], captured.output)).toBe(5); expect(JSON.parse(captured.read().stdout)).toMatchObject({ ok: false, - error: { code: "UNAVAILABLE", message: expect.stringContaining("not configured") }, + error: { + code: "UNAVAILABLE", + message: "Cloud sync is unavailable because HRA_CONVEX_URL is invalid.", + }, }); expect(captured.read().stderr).toBe(""); } finally { @@ -710,6 +769,112 @@ describe("CLI entry point", () => { expect(await control.isCompactProjectionRecoveryUnsettled(affectedSession)).toBe(true); }); + test("daemon startup reads a mismatched target's scoped journal without enabling transport", async () => { + const raw = new MemoryCloudCustody(); + const authority = await cloudDeploymentAuthorityFromEnvironment(raw, { + HRA_CONVEX_URL: "https://bound.convex.cloud", + }); + if (authority === null) throw new Error("fixture authority is disabled"); + expect(authority.custodyMode).toBe("scoped"); + const deploymentCustody = new DeploymentScopedCloudSecretCustody(raw, authority); + const unselected = await IdentityScopedCloudSecretCustody.open(deploymentCustody); + await unselected.activateIdentity("user_cli_recovery_12345678"); + const identityCustody = await IdentityScopedCloudSecretCustody.open(deploymentCustody); + const journal = new CustodyCloudDaemonJournal(identityCustody); + const affectedSession = `sess_${"4".repeat(32)}`; + expect(await journal.compareAndSwap(null, { + commands: [], + pendingUsageAccount: null, + projectionRecoveries: [{ + authority: { bootGeneration: 1, bootId: "boot_cli_scoped_12345678", fence: 1 }, + baselineCompletedTurns: [], + epochPublicId: "018bcfe5-6800-7000-8000-000000000893", + expectedCompactStreamEpoch: 0, + expectedHeadSequence: 400, + expectedTailDigest: "d".repeat(64), + idempotencyKey: "018bcfe5-6800-7000-8000-000000000894", + lineageCommitment: "e".repeat(64), + localAuthority: { + profileGeneration: 1, + profileId: "profile_cli_scoped_12345678", + providerUpdatedAt: 10, + providerThreadId: "thread_cli_scoped_12345678", + sessionRevision: 1, + }, + phase: "prepared", + replacementCacheId: "cache_cli_scoped_replacement_12345678", + requestDigest: "f".repeat(64), + requestedAt: 1_700_000_000_000, + sessionPublicId: affectedSession, + sourceDevicePublicId: "device_cli_scoped_12345678", + sourceCacheId: "cache_cli_scoped_source_12345678", + userPublicId: "user_cli_recovery_12345678", + }], + projectionRecoveryReceipts: [], + usageAccounts: [], + version: 3, + })).not.toBeNull(); + expect(await raw.read("cloud-daemon-journal")).toBeNull(); + + const startup = await resolveDaemonCloudStartup({ + environment: { HRA_CONVEX_URL: "https://requested.convex.cloud" }, + secretCustody: raw, + }); + expect(startup.deploymentAuthority).toBeNull(); + expect(startup.diagnostic) + .toBe("Cloud sync is unavailable because this state root is bound to another deployment."); + expect(startup.journal).not.toBeNull(); + expect(startup.identityNamespace).toBe(identityCustody.cacheNamespace); + expect(await startup.projectionRecoveryBlocker + .isCompactProjectionRecoveryUnsettled(affectedSession)).toBe(true); + expect(await startup.projectionRecoveryBlocker + .isCompactProjectionRecoveryUnsettled(`sess_${"5".repeat(32)}`)).toBe(false); + }); + + test("daemon startup fails projection recovery admission closed for corrupt authority", async () => { + const raw = new MemoryCloudCustody(); + expect(await raw.compareAndSwap("cloud-deployment-authority", null, "corrupt")) + .not.toBeNull(); + const startup = await resolveDaemonCloudStartup({ + environment: { HRA_CONVEX_URL: "https://requested.convex.cloud" }, + secretCustody: raw, + }); + expect(startup.deploymentAuthority).toBeNull(); + expect(startup.journal).toBeNull(); + expect(await startup.projectionRecoveryBlocker + .isCompactProjectionRecoveryUnsettled(`sess_${"6".repeat(32)}`)).toBe(true); + expect(await startup.projectionRecoveryBlocker + .isCompactProjectionRecoveryUnsettledForProfile("profile_cli_corrupt_12345678")) + .toBe(true); + expect(await startup.projectionRecoveryBlocker.supersedeTerminalCompactProjectionRecoveries()) + .toEqual({ superseded: 0 }); + await expect(startup.projectionRecoveryBlocker + .supersedeCompactProjectionRecoveryForProviderDeletion(`sess_${"6".repeat(32)}`)) + .rejects.toThrow("Cloud projection recovery custody requires recovery."); + }); + + test("daemon cloud degradation preserves recovery reads and one bounded binding diagnostic", async () => { + const blocker = { + isCompactProjectionRecoveryUnsettled: async () => true, + isCompactProjectionRecoveryUnsettledForProfile: async () => false, + supersedeCompactProjectionRecoveryForProviderDeletion: async () => ({ superseded: false }), + supersedeTerminalCompactProjectionRecoveries: async () => ({ superseded: 0 }), + }; + const diagnostic = "Cloud sync is unavailable until HRA_CONVEX_URL explicitly selects the legacy deployment."; + const control = selectDaemonCloudControl(null, blocker, diagnostic); + expect(await control.status(new AbortController().signal)).toEqual({ + configured: false, + diagnostic, + signedIn: false, + }); + expect(await control.isCompactProjectionRecoveryUnsettled("sess_33333333")) + .toBe(true); + expect(() => control.auth({ email: "reader@example.com", signal: new AbortController().signal })) + .toThrow(diagnostic); + expect(() => control.sync(new AbortController().signal)).toThrow(diagnostic); + expect(() => control.listDevices(new AbortController().signal)).toThrow(diagnostic); + }); + test("remote sessions render stable human and JSON output", () => { const human = capture(); renderRemoteSuccess({ kind: "remote.list", limit: 50 }, { diff --git a/src/cli.ts b/src/cli.ts index d007c3b..57dd71f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -34,19 +34,25 @@ import { containsAbsolutePath, createCloudDaemonLifecycle, createCloudUuidV7, + cloudDeploymentAuthorityFromEnvironment, + CloudDeploymentAuthorityError, createLocalCloudControlFromEnvironment, createLocalCloudDaemonBridgeFromEnvironment, + DeploymentScopedCloudSecretCustody, IdentityScopedCloudSecretCustody, isRecord, isSafeNonNegativeInteger, isSafePositiveInteger, isUuidV7, hasExactKeys, + readCloudDeploymentAuthority, redactAbsolutePaths, type CloudRemoteControlPort, type CloudRemoteSessionHead, type RemoteCommandPayload, type CloudDaemonLifecycle, + type CloudDeploymentAuthority, + type CloudSecretCustodyPort, } from "./cloud/index"; import { resolvePinnedCodexRuntime } from "./codex/index"; import type { CommandResponse, LocalCommand } from "./domain/contracts"; @@ -279,8 +285,214 @@ type CliMainInput = Readonly<{ export function selectDaemonCloudControl( configured: CloudControlPort | null, projectionRecoveryBlocker: CompactProjectionRecoveryBlocker, + diagnostic?: string, ): CloudControlPort { - return configured ?? new UnavailableCloudControl(projectionRecoveryBlocker); + if (configured !== null) return configured; + if (diagnostic === undefined) return new UnavailableCloudControl(projectionRecoveryBlocker); + return new DiagnosedUnavailableCloudControl(projectionRecoveryBlocker, diagnostic); +} + +class DiagnosedUnavailableCloudControl extends UnavailableCloudControl { + readonly #diagnostic: string; + + constructor(projectionRecoveryBlocker: CompactProjectionRecoveryBlocker, diagnostic: string) { + super(projectionRecoveryBlocker); + this.#diagnostic = diagnostic; + } + + #unavailable(): never { + throw new Error(this.#diagnostic); + } + + override status(): Promise { + return Promise.resolve({ configured: false, diagnostic: this.#diagnostic, signedIn: false }); + } + + override sync(): Promise { return Promise.reject(this.#unavailable()); } + override recoverCompactProjection(): Promise { return Promise.reject(this.#unavailable()); } + override auth(): Promise { return Promise.reject(this.#unavailable()); } + override logout(): Promise { return Promise.reject(this.#unavailable()); } + override deleteAccount(): Promise { return Promise.reject(this.#unavailable()); } + override listDevices(): Promise { return Promise.reject(this.#unavailable()); } + override pairDevice(): Promise { return Promise.reject(this.#unavailable()); } + override approveDevice(): Promise { return Promise.reject(this.#unavailable()); } + override revokeDevice(): Promise { return Promise.reject(this.#unavailable()); } +} + +function cloudBindingDiagnostic(error: unknown): string { + if (!(error instanceof CloudDeploymentAuthorityError)) { + return "Cloud sync is unavailable because local cloud custody requires recovery."; + } + switch (error.code) { + case "invalid_configuration": + return "Cloud sync is unavailable because HRA_CONVEX_URL is invalid."; + case "legacy_binding_required": + return "Cloud sync is unavailable until HRA_CONVEX_URL explicitly selects the legacy deployment."; + case "target_mismatch": + return "Cloud sync is unavailable because this state root is bound to another deployment."; + case "concurrent_change": + case "corrupt_custody": + case "stale_authority": + return "Cloud sync is unavailable because deployment custody requires recovery."; + } +} + +type DaemonCloudStartup = Readonly<{ + deploymentAuthority: CloudDeploymentAuthority | null; + identityNamespace: string | null; + journal: CustodyCloudDaemonJournal | null; + projectionRecoveryBlocker: CompactProjectionRecoveryBlocker; + diagnostic?: string; +}>; + +class FailClosedProjectionRecoveryBlocker implements CompactProjectionRecoveryBlocker { + readonly #delegate: CompactProjectionRecoveryBlocker | null; + + constructor(delegate: CompactProjectionRecoveryBlocker | null) { + this.#delegate = delegate; + } + + async isCompactProjectionRecoveryUnsettled( + sessionPublicId: Parameters[0], + ): Promise { + if (this.#delegate === null) return true; + try { + return await this.#delegate.isCompactProjectionRecoveryUnsettled(sessionPublicId); + } catch { + return true; + } + } + + async isCompactProjectionRecoveryUnsettledForProfile( + profileId: Parameters[0], + ): Promise { + if (this.#delegate === null) return true; + try { + return await this.#delegate.isCompactProjectionRecoveryUnsettledForProfile(profileId); + } catch { + return true; + } + } + + async supersedeCompactProjectionRecoveryForProviderDeletion( + sessionPublicId: Parameters[0], + ): Promise<{ superseded: boolean }> { + if (this.#delegate !== null) { + try { + return await this.#delegate + .supersedeCompactProjectionRecoveryForProviderDeletion(sessionPublicId); + } catch { + // Fall through to the static fail-closed diagnostic. + } + } + throw new Error("Cloud projection recovery custody requires recovery."); + } + + async supersedeTerminalCompactProjectionRecoveries(): Promise<{ superseded: number }> { + if (this.#delegate === null) return { superseded: 0 }; + try { + return await this.#delegate.supersedeTerminalCompactProjectionRecoveries(); + } catch { + return { superseded: 0 }; + } + } +} + +function daemonCloudStartupResult(input: Readonly<{ + deploymentAuthority: CloudDeploymentAuthority | null; + diagnostic?: string; + identityNamespace: string | null; + isSessionTerminal?: (sessionPublicId: string) => boolean | Promise; + journal: CustodyCloudDaemonJournal | null; +}>): DaemonCloudStartup { + const delegate = input.journal === null + ? null + : new CloudDaemonJournalRecoveryBlocker( + input.journal, + input.isSessionTerminal === undefined + ? {} + : { isSessionTerminal: input.isSessionTerminal }, + ); + const result = { + deploymentAuthority: input.deploymentAuthority, + identityNamespace: input.identityNamespace, + journal: input.journal, + projectionRecoveryBlocker: new FailClosedProjectionRecoveryBlocker(delegate), + }; + return input.diagnostic === undefined + ? result + : { ...result, diagnostic: input.diagnostic }; +} + +export async function resolveDaemonCloudStartup(input: Readonly<{ + environment: Readonly>; + isSessionTerminal?: (sessionPublicId: string) => boolean | Promise; + secretCustody: CloudSecretCustodyPort; +}>): Promise { + let deploymentAuthority: CloudDeploymentAuthority | null = null; + let diagnostic: string | undefined; + try { + deploymentAuthority = await cloudDeploymentAuthorityFromEnvironment( + input.secretCustody, + input.environment, + ); + if (deploymentAuthority === null) { + diagnostic = "Cloud sync is disabled for this daemon. Unset HRA_CONVEX_URL to use hosted sync."; + } + } catch (error: unknown) { + diagnostic = cloudBindingDiagnostic(error); + } + + let recoveryAuthority = deploymentAuthority; + if (recoveryAuthority === null) { + try { + recoveryAuthority = await readCloudDeploymentAuthority(input.secretCustody); + } catch (error: unknown) { + return daemonCloudStartupResult({ + deploymentAuthority: null, + diagnostic: diagnostic ?? cloudBindingDiagnostic(error), + identityNamespace: null, + ...(input.isSessionTerminal === undefined + ? {} + : { isSessionTerminal: input.isSessionTerminal }), + journal: null, + }); + } + } + + try { + const deploymentCustody = recoveryAuthority === null + ? input.secretCustody + : new DeploymentScopedCloudSecretCustody(input.secretCustody, recoveryAuthority); + const identityCustody = await IdentityScopedCloudSecretCustody.open(deploymentCustody); + const journal = new CustodyCloudDaemonJournal(identityCustody); + await journal.read(); + return daemonCloudStartupResult({ + deploymentAuthority, + ...(diagnostic === undefined ? {} : { diagnostic }), + identityNamespace: identityCustody.cacheNamespace, + ...(input.isSessionTerminal === undefined + ? {} + : { isSessionTerminal: input.isSessionTerminal }), + journal, + }); + } catch (error: unknown) { + return daemonCloudStartupResult({ + deploymentAuthority: null, + diagnostic: diagnostic ?? cloudBindingDiagnostic(error), + identityNamespace: null, + ...(input.isSessionTerminal === undefined + ? {} + : { isSessionTerminal: input.isSessionTerminal }), + journal: null, + }); + } } function boundedUtf8Text(value: string, maximumBytes: number): string { @@ -907,24 +1119,22 @@ async function executeRemoteInvocation( const injectedStatus = invocation.command.kind === "remote.command" ? input.getRemoteCommandStatus : undefined; - const control = injectedStatus === undefined - ? createLocalCloudControlFromEnvironment({ - lifetimeSignal: controller.signal, - secretCustody: await IdentityScopedCloudSecretCustody.open( - new GenerationalSecretCustody(paths), - ), - }) - : null; - if (control === null && injectedStatus === undefined) { - return renderFailure({ - code: "UNAVAILABLE", - message: "Cloud sync is not configured. Set HRA_CONVEX_URL and run `hra auth login`.", - }, invocation.json, output); - } const abort = () => controller.abort(new Error("Cloud remote operation was interrupted.")); process.once("SIGINT", abort); process.once("SIGTERM", abort); try { + const control = injectedStatus === undefined + ? await createLocalCloudControlFromEnvironment({ + lifetimeSignal: controller.signal, + secretCustody: new GenerationalSecretCustody(paths), + }) + : null; + if (control === null && injectedStatus === undefined) { + return renderFailure({ + code: "UNAVAILABLE", + message: "Cloud sync is disabled. Unset HRA_CONVEX_URL for hosted sync, or set a deployment URL, then run `hra auth login`.", + }, invocation.json, output); + } if (invocation.command.kind === "remote.list") { if (control === null) throw new Error("Cloud sync is not configured."); const data = await control.listRemoteSessionHeads({ @@ -973,6 +1183,12 @@ async function executeRemoteInvocation( renderRemoteSuccess(invocation.command, data, invocation.json, output); return 0; } catch (error: unknown) { + if (error instanceof CloudDeploymentAuthorityError) { + return renderFailure({ + code: "UNAVAILABLE", + message: cloudBindingDiagnostic(error), + }, invocation.json, output); + } const diagnostic = invocation.command.kind === "remote.command" && error instanceof Error ? new Error(sanitizeSyncDiagnostic(error.message)) : error; @@ -1087,10 +1303,9 @@ async function runDaemon(): Promise { }, }); const secretCustody = new GenerationalSecretCustody(paths); - const cloudSecretCustody = await IdentityScopedCloudSecretCustody.open(secretCustody); - const eventCursors = await resolveSessionEventCursorCodec(secretCustody); - const cloudJournal = new CustodyCloudDaemonJournal(cloudSecretCustody); - const projectionRecoveryBlocker = new CloudDaemonJournalRecoveryBlocker(cloudJournal, { + const cloudEnvironment = { HRA_CONVEX_URL: process.env.HRA_CONVEX_URL }; + const cloudStartup = await resolveDaemonCloudStartup({ + environment: cloudEnvironment, isSessionTerminal: (sessionPublicId) => { try { return activeStore.requireSession(sessionPublicId).state === "terminal"; @@ -1098,53 +1313,113 @@ async function runDaemon(): Promise { return false; } }, + secretCustody, }); + const cloudDeploymentAuthority = cloudStartup.deploymentAuthority; + const cloudIdentityNamespace = cloudStartup.identityNamespace; + let cloudStartupDiagnostic = cloudStartup.diagnostic; + const eventCursors = await resolveSessionEventCursorCodec(secretCustody); + const cloudJournal = cloudStartup.journal; + const projectionRecoveryBlocker = cloudStartup.projectionRecoveryBlocker; cloudRequestController = new AbortController(); - const localCloudControl = createLocalCloudControlFromEnvironment({ - lifetimeSignal: cloudRequestController.signal, - secretCustody: cloudSecretCustody, - }); - let cloud = selectDaemonCloudControl(localCloudControl, projectionRecoveryBlocker); - if (localCloudControl !== null) { - const cloudCodex = new Proxy(codex, { - get(target, property) { - const value = Reflect.get(target, property, target) as unknown; - if (typeof value !== "function") return value; - if (property === "close") { - return (...args: unknown[]): unknown => Reflect.apply(value, target, args) as unknown; + let cloud = selectDaemonCloudControl( + null, + projectionRecoveryBlocker, + cloudStartupDiagnostic, + ); + if (cloudDeploymentAuthority !== null && cloudJournal !== null) { + let candidateAdapter: StateBackedCloudDaemonAdapter | undefined; + let candidateBridge: Awaited> | undefined; + try { + const localCloudControl = await createLocalCloudControlFromEnvironment({ + deploymentAuthority: cloudDeploymentAuthority, + environment: cloudEnvironment, + lifetimeSignal: cloudRequestController.signal, + secretCustody, + }); + if (localCloudControl === null) { + throw new CloudDeploymentAuthorityError( + "stale_authority", + "Cloud deployment authority changed during daemon startup.", + ); + } + const cloudCodex = new Proxy(codex, { + get(target, property) { + const value = Reflect.get(target, property, target) as unknown; + if (typeof value !== "function") return value; + if (property === "close") { + return (...args: unknown[]): unknown => Reflect.apply(value, target, args) as unknown; + } + return async (...args: unknown[]) => { + await activeDaemonAuthority.assertCurrent(); + const result = await Reflect.apply(value, target, args) as unknown; + await activeDaemonAuthority.assertCurrent(); + return result; + }; + }, + }) as CodexRuntimePort; + candidateAdapter = new StateBackedCloudDaemonAdapter({ + codex: cloudCodex, + executeRemote: async (command, expected, options) => { + const current = serviceReference.current; + if (current === undefined) throw new Error("The local command service is not ready."); + return await current.executeRemote(command, expected, { signal: options.signal }); + }, + paths, + store: activeStore, + cloudIdentityNamespace, + }); + candidateBridge = await createLocalCloudDaemonBridgeFromEnvironment({ + daemonAuthority: { bootGeneration: generation, bootId }, + daemonAuthorityFence: activeDaemonAuthority, + deploymentAuthority: cloudDeploymentAuthority, + environment: cloudEnvironment, + executor: candidateAdapter, + lifetimeSignal: cloudRequestController.signal, + local: candidateAdapter, + journal: cloudJournal, + registration: localCloudControl, + secretCustody, + }); + if (candidateBridge === null) { + throw new CloudDeploymentAuthorityError( + "stale_authority", + "Cloud deployment authority changed during daemon startup.", + ); + } + const cloudBridge = candidateBridge; + const candidateCloud = new BridgedCloudControl( + localCloudControl, + cloudBridge, + candidateAdapter, + ); + const candidateLifecycle = createCloudDaemonLifecycle({ bridge: cloudBridge }); + cloudAdapter = candidateAdapter; + cloud = candidateCloud; + cloudLifecycle = candidateLifecycle; + candidateAdapter = undefined; + candidateBridge = undefined; + } catch (error: unknown) { + cloudRequestController.abort(new Error("Cloud initialization was fenced.")); + if (candidateBridge !== undefined && candidateBridge !== null) { + try { await candidateBridge.close(); } catch (cleanupError: unknown) { + cleanupErrors.push(cleanupError); } - return async (...args: unknown[]) => { - await activeDaemonAuthority.assertCurrent(); - const result = await Reflect.apply(value, target, args) as unknown; - await activeDaemonAuthority.assertCurrent(); - return result; - }; - }, - }) as CodexRuntimePort; - cloudAdapter = new StateBackedCloudDaemonAdapter({ - codex: cloudCodex, - executeRemote: async (command, expected, options) => { - const current = serviceReference.current; - if (current === undefined) throw new Error("The local command service is not ready."); - return await current.executeRemote(command, expected, { signal: options.signal }); - }, - paths, - store: activeStore, - cloudIdentityNamespace: cloudSecretCustody.cacheNamespace, - }); - const cloudBridge = createLocalCloudDaemonBridgeFromEnvironment({ - daemonAuthority: { bootGeneration: generation, bootId }, - daemonAuthorityFence: activeDaemonAuthority, - executor: cloudAdapter, - lifetimeSignal: cloudRequestController.signal, - local: cloudAdapter, - journal: cloudJournal, - registration: localCloudControl, - secretCustody: cloudSecretCustody, - }); - if (cloudBridge === null) throw new Error("The cloud deployment changed during daemon startup."); - cloud = new BridgedCloudControl(localCloudControl, cloudBridge, cloudAdapter); - cloudLifecycle = createCloudDaemonLifecycle({ bridge: cloudBridge }); + } + if (candidateAdapter !== undefined) { + try { candidateAdapter.close(); } catch (cleanupError: unknown) { + cleanupErrors.push(cleanupError); + } + } + cloudStartupDiagnostic = cloudBindingDiagnostic(error); + cloud = selectDaemonCloudControl( + null, + projectionRecoveryBlocker, + cloudStartupDiagnostic, + ); + } } checkpointBoot(); const desktop = process.platform === "darwin" diff --git a/src/cloud/daemon-bridge.test.ts b/src/cloud/daemon-bridge.test.ts index 2c95f8b..a4203b5 100644 --- a/src/cloud/daemon-bridge.test.ts +++ b/src/cloud/daemon-bridge.test.ts @@ -14,6 +14,7 @@ import { type CloudDaemonLocalSourcePort, type CloudLocalSessionHead, type CloudLocalUsageSnapshot, + createLocalCloudDaemonBridgeFromEnvironment, CustodyCloudDaemonIdentity, LocalCloudDaemonBridge, type RegisteredCloudIdentity, @@ -29,7 +30,14 @@ import { type CloudDaemonJournalPort, type CloudProjectionRecoveryBaselineInteraction, } from "./daemon-journal"; -import type { CloudSecretCustodyPort } from "./local-control"; +import { + cloudDeploymentAuthorityFromEnvironment, + type CloudDeploymentAuthority, +} from "./identity-custody"; +import { + createLocalCloudControlFromEnvironment, + type CloudSecretCustodyPort, +} from "./local-control"; import { PollingCloudDaemonLifecycle } from "./daemon-lifecycle"; import { hmacSha256Hex, sha256Hex } from "./crypto"; import { encryptRemoteCommand, type RemoteCommandPayload } from "./payloads"; @@ -1180,6 +1188,31 @@ class IdentityCustody implements CloudSecretCustodyPort { } } +class DeploymentCustody implements CloudSecretCustodyPort { + readonly values = new Map>(); + + async read(slot: string): Promise | null> { + return this.values.get(slot) ?? null; + } + + async compareAndSwap( + slot: string, + expectedGeneration: number | null, + value: string, + ): Promise | null> { + const current = this.values.get(slot) ?? null; + if ((current?.generation ?? null) !== expectedGeneration) return null; + const next = { generation: expectedGeneration === null ? 0 : expectedGeneration + 1, value }; + this.values.set(slot, next); + return next; + } + + async clearIfGeneration(slot: string, expectedGeneration: number): Promise { + if (this.values.get(slot)?.generation !== expectedGeneration) return false; + return this.values.delete(slot); + } +} + function pendingIdentity(input: Readonly<{ authEpoch?: number; credentialGeneration?: number; @@ -1234,6 +1267,7 @@ class RecordingExecutor implements CloudCommandExecutorPort { function bridge(input: { cloud: FakeCloud; daemonAuthorityFence?: Readonly<{ assertCurrent(): Promise }>; + deploymentAuthority?: CloudDeploymentAuthority; device: string; executor?: RecordingExecutor; identity?: CloudDaemonIdentityPort; @@ -1249,6 +1283,14 @@ function bridge(input: { daemonAuthority: { bootGeneration: 1, bootId: "boot_12345678" }, daemonAuthorityFence: input.daemonAuthorityFence ?? { assertCurrent: () => Promise.resolve() }, + deploymentAuthority: input.deploymentAuthority ?? { + assertCurrent: () => Promise.resolve(), + cacheNamespace: null, + custodyMode: "legacy", + deploymentUrl: "https://example.convex.cloud", + generation: 0, + scopeCustodySlot: (slot) => slot, + }, executor: input.executor ?? new RecordingExecutor(), identity: input.identity ?? identity(input.device), journal: input.journal ?? new MemoryCloudDaemonJournal(), @@ -1411,6 +1453,82 @@ function saturatedCommandJournal( } describe("cloud daemon bridge", () => { + test("control and daemon factories converge on one bound deployment before transport", async () => { + const custody = new DeploymentCustody(); + const environment = { HRA_CONVEX_URL: "https://shared.convex.cloud/" }; + let transportCalls = 0; + const transport: CloudTransport = { + action: async () => { transportCalls += 1; throw new Error("unexpected transport"); }, + mutation: async () => { transportCalls += 1; throw new Error("unexpected transport"); }, + query: async () => { transportCalls += 1; throw new Error("unexpected transport"); }, + }; + const control = await createLocalCloudControlFromEnvironment({ + environment, + secretCustody: custody, + transport, + }); + if (control === null) throw new Error("fixture control is disabled"); + const daemon = await createLocalCloudDaemonBridgeFromEnvironment({ + daemonAuthority: { bootGeneration: 1, bootId: "boot_shared_target_12345678" }, + daemonAuthorityFence: { assertCurrent: () => Promise.resolve() }, + environment, + executor: new RecordingExecutor(), + local: new EmptyLocal(), + registration: control, + secretCustody: custody, + transport, + }); + expect(daemon).toBeInstanceOf(LocalCloudDaemonBridge); + expect(transportCalls).toBe(0); + const binding = custody.values.get("cloud-deployment-authority"); + expect(binding?.generation).toBe(0); + expect(JSON.parse(binding?.value ?? "null")).toMatchObject({ + deploymentUrl: "https://shared.convex.cloud", + version: 1, + }); + }); + + test("refuses stale deployment authority before identity credentials or transport", async () => { + const custody = new DeploymentCustody(); + const deploymentAuthority = await cloudDeploymentAuthorityFromEnvironment(custody, { + HRA_CONVEX_URL: "https://example.convex.cloud", + }); + if (deploymentAuthority === null) throw new Error("fixture authority is disabled"); + const cloud = new FakeCloud(); + let identityCalls = 0; + let transportCalls = 0; + const guardedIdentity: CloudDaemonIdentityPort = { + async requireActive() { + identityCalls += 1; + throw new Error("unexpected identity credential read"); + }, + async requireRegistered() { + identityCalls += 1; + throw new Error("unexpected identity credential read"); + }, + }; + const guardedTransport: CloudTransport = { + action: async () => { transportCalls += 1; throw new Error("unexpected transport"); }, + mutation: async () => { transportCalls += 1; throw new Error("unexpected transport"); }, + query: async () => { transportCalls += 1; throw new Error("unexpected transport"); }, + }; + const daemon = bridge({ + cloud, + deploymentAuthority, + device: "device_11111111", + identity: guardedIdentity, + local: new EmptyLocal(), + transport: guardedTransport, + }); + custody.values.delete("cloud-deployment-authority"); + + const result = await daemon.cycle(new AbortController().signal); + expect(result.online).toBe(false); + expect(result.errors).toEqual(["Cloud deployment authority is not current."]); + expect(identityCalls).toBe(0); + expect(transportCalls).toBe(0); + }); + test("reads current credential generation for registered pending devices and migrates legacy absence to one", async () => { const custody = new IdentityCustody(); const publicKey = JSON.stringify({ diff --git a/src/cloud/daemon-bridge.ts b/src/cloud/daemon-bridge.ts index 73a2d60..527739e 100644 --- a/src/cloud/daemon-bridge.ts +++ b/src/cloud/daemon-bridge.ts @@ -78,11 +78,22 @@ import { } from "./projection"; import { parseUsageProjection, type UsageProjection } from "./usage"; import { - deploymentUrlFromEnvironment, + deploymentFencedCloudTransport, + deploymentFencedSecretCustody, LocalCloudControl, type CloudDeviceRegistrationPort, type CloudSecretCustodyPort, } from "./local-control"; +import { + acquireCloudDeploymentAuthority, + canonicalCloudDeploymentUrl, + cloudDeploymentSelectionFromEnvironment, + CloudDeploymentAuthorityError, + DeploymentScopedCloudSecretCustody, + IdentityScopedCloudSecretCustody, + type CloudDeploymentAuthority, + type CloudDeploymentSelection, +} from "./identity-custody"; const maximumLocalSessions = 25; const maximumRemoteSessions = 25; @@ -408,6 +419,7 @@ export interface CloudDaemonBridge { export type LocalCloudDaemonBridgeOptions = Readonly<{ daemonAuthority: Readonly<{ bootGeneration: number; bootId: string }>; daemonAuthorityFence: Readonly<{ assertCurrent(): Promise }>; + deploymentAuthority: CloudDeploymentAuthority; executor: CloudCommandExecutorPort; identity: CloudDaemonIdentityPort; journal: CloudDaemonJournalPort; @@ -424,6 +436,7 @@ export type LocalCloudDaemonBridgeEnvironmentOptions = Readonly<{ daemonAuthority: Readonly<{ bootGeneration: number; bootId: string }>; daemonAuthorityFence: Readonly<{ assertCurrent(): Promise }>; deploymentUrl?: string; + deploymentAuthority?: CloudDeploymentAuthority; environment?: Readonly>; executor: CloudCommandExecutorPort; journal?: CloudDaemonJournalPort; @@ -1532,6 +1545,7 @@ async function decryptPrivateLocalReference( export class LocalCloudDaemonBridge implements CloudDaemonBridge { readonly #daemonAuthority: Readonly<{ bootGeneration: number; bootId: string }>; readonly #daemonAuthorityFence: Readonly<{ assertCurrent(): Promise }>; + readonly #deploymentAuthority: CloudDeploymentAuthority; readonly #executor: CloudCommandExecutorPort; readonly #identity: CloudDaemonIdentityPort; readonly #journal: CloudDaemonJournalPort; @@ -1567,6 +1581,7 @@ export class LocalCloudDaemonBridge implements CloudDaemonBridge { ) throw new Error("Cloud daemon authority is invalid."); this.#daemonAuthority = options.daemonAuthority; this.#daemonAuthorityFence = options.daemonAuthorityFence; + this.#deploymentAuthority = options.deploymentAuthority; this.#executor = options.executor; this.#identity = options.identity; this.#journal = options.journal; @@ -1576,7 +1591,10 @@ export class LocalCloudDaemonBridge implements CloudDaemonBridge { this.#optionalSyncBudgetMs = optionalSyncBudgetMs; this.#randomConnectionUuid = options.randomConnectionUuid ?? (() => crypto.randomUUID()); this.#randomUuid = options.randomUuid ?? (() => uuidV7(this.#now())); - this.#transport = options.transport; + this.#transport = deploymentFencedCloudTransport( + options.transport, + this.#deploymentAuthority, + ); } async close(): Promise { @@ -3848,6 +3866,7 @@ export class LocalCloudDaemonBridge implements CloudDaemonBridge { async #assertDaemonCurrent(signal?: AbortSignal): Promise { if (signal !== undefined) abortBeforeEffect(signal); + await this.#deploymentAuthority.assertCurrent(); await this.#daemonAuthorityFence.assertCurrent(); if (signal !== undefined) abortBeforeEffect(signal); } @@ -3868,35 +3887,66 @@ export class LocalCloudDaemonBridge implements CloudDaemonBridge { } } -export function createLocalCloudDaemonBridgeFromEnvironment( +export async function createLocalCloudDaemonBridgeFromEnvironment( options: LocalCloudDaemonBridgeEnvironmentOptions, -): LocalCloudDaemonBridge | null { - const deploymentUrl = options.deploymentUrl - ?? deploymentUrlFromEnvironment(options.environment); - if (deploymentUrl === null) return null; +): Promise { + const selection: CloudDeploymentSelection = options.deploymentUrl === undefined + ? cloudDeploymentSelectionFromEnvironment(options.environment) + : { + deploymentUrl: canonicalCloudDeploymentUrl(options.deploymentUrl), + explicit: true, + kind: "enabled", + }; + if (selection.kind === "disabled") return null; + const deploymentAuthority = options.deploymentAuthority + ?? await acquireCloudDeploymentAuthority(options.secretCustody, selection); + if (deploymentAuthority.deploymentUrl !== selection.deploymentUrl) { + throw new CloudDeploymentAuthorityError( + "target_mismatch", + "Cloud deployment authority does not match the requested deployment.", + ); + } + await deploymentAuthority.assertCurrent(); + const deploymentUrl = selection.deploymentUrl; + const deploymentCustody = new DeploymentScopedCloudSecretCustody( + options.secretCustody, + deploymentAuthority, + ); + const identityCustody = await IdentityScopedCloudSecretCustody.open(deploymentCustody); + const fencedCustody = deploymentFencedSecretCustody( + identityCustody, + deploymentAuthority, + ); let transport: CloudTransport; if (options.transport !== undefined) { - transport = options.transport; + transport = deploymentFencedCloudTransport(options.transport, deploymentAuthority); } else { - transport = createConvexCloudTransport({ - accessToken: async () => (await readCustodyAuth(options.secretCustody))?.token ?? null, + const rawTransport = createConvexCloudTransport({ + accessToken: async () => { + await deploymentAuthority.assertCurrent(); + const token = (await readCustodyAuth(fencedCustody))?.token ?? null; + await deploymentAuthority.assertCurrent(); + return token; + }, deploymentUrl, ...(options.lifetimeSignal === undefined ? {} : { lifetimeSignal: options.lifetimeSignal }), }); + transport = deploymentFencedCloudTransport(rawTransport, deploymentAuthority); } const registration = options.registration ?? new LocalCloudControl({ + deploymentAuthority, deploymentUrl, ...(options.lifetimeSignal === undefined ? {} : { lifetimeSignal: options.lifetimeSignal }), ...(options.now === undefined ? {} : { now: options.now }), - secretCustody: options.secretCustody, + secretCustody: identityCustody, transport, }); const identity = new CustodyCloudDaemonIdentity({ - custody: options.secretCustody, + custody: fencedCustody, ...(options.now === undefined ? {} : { now: options.now }), registration, transport, @@ -3904,9 +3954,10 @@ export function createLocalCloudDaemonBridgeFromEnvironment( return new LocalCloudDaemonBridge({ daemonAuthority: options.daemonAuthority, daemonAuthorityFence: options.daemonAuthorityFence, + deploymentAuthority, executor: options.executor, identity, - journal: options.journal ?? new CustodyCloudDaemonJournal(options.secretCustody), + journal: options.journal ?? new CustodyCloudDaemonJournal(fencedCustody), ...(options.leaseDurationMs === undefined ? {} : { leaseDurationMs: options.leaseDurationMs }), diff --git a/src/cloud/identity-custody.test.ts b/src/cloud/identity-custody.test.ts index 31ac8b2..fef7e04 100644 --- a/src/cloud/identity-custody.test.ts +++ b/src/cloud/identity-custody.test.ts @@ -1,7 +1,15 @@ import { describe, expect, test } from "bun:test"; import type { CloudSecretCustodyPort } from "./local-control"; -import { IdentityScopedCloudSecretCustody } from "./identity-custody"; +import { + acquireCloudDeploymentAuthority, + cloudDeploymentAuthorityFromEnvironment, + cloudDeploymentSelectionFromEnvironment, + DEFAULT_CLOUD_DEPLOYMENT_URL, + DeploymentScopedCloudSecretCustody, + IdentityScopedCloudSecretCustody, + readCloudDeploymentAuthority, +} from "./identity-custody"; class MemoryCustody implements CloudSecretCustodyPort { readonly values = new Map>(); @@ -30,6 +38,23 @@ class MemoryCustody implements CloudSecretCustodyPort { } } +class LegacyWriteDuringAuthorityCasCustody extends MemoryCustody { + #injected = false; + + override async compareAndSwap( + slot: string, + expectedGeneration: number | null, + value: string, + ): Promise | null> { + const committed = await super.compareAndSwap(slot, expectedGeneration, value); + if (slot === "cloud-deployment-authority" && committed !== null && !this.#injected) { + this.#injected = true; + this.values.set("cloud-auth", { generation: 0, value: "late-legacy-auth" }); + } + return committed; + } +} + async function write( custody: CloudSecretCustodyPort, slot: string, @@ -103,4 +128,186 @@ describe("cloud identity-scoped custody", () => { })); await expect(IdentityScopedCloudSecretCustody.open(raw)).rejects.toThrow("corrupt"); }); + + test("selects the release deployment by default, validates overrides, and treats only explicit empty as disabled", () => { + expect(cloudDeploymentSelectionFromEnvironment({})).toEqual({ + deploymentUrl: DEFAULT_CLOUD_DEPLOYMENT_URL, + explicit: false, + kind: "enabled", + }); + expect(cloudDeploymentSelectionFromEnvironment({ HRA_CONVEX_URL: " " })) + .toEqual({ kind: "disabled" }); + expect(cloudDeploymentSelectionFromEnvironment({ + HRA_CONVEX_URL: "https://EXAMPLE.convex.cloud/", + })).toEqual({ + deploymentUrl: "https://example.convex.cloud", + explicit: true, + kind: "enabled", + }); + for (const value of [ + "not a URL", + "http://example.convex.cloud", + "https://user@example.convex.cloud", + "https://example.convex.cloud/path", + "https://example.convex.cloud?target=other", + ]) { + expect(() => cloudDeploymentSelectionFromEnvironment({ HRA_CONVEX_URL: value })) + .toThrow("HRA_CONVEX_URL is invalid."); + } + }); + + test("atomically acquires one exact deployment generation under concurrent opens", async () => { + const custody = new MemoryCustody(); + const selection = cloudDeploymentSelectionFromEnvironment({}); + if (selection.kind !== "enabled") throw new Error("fixture selection is disabled"); + const [first, second] = await Promise.all([ + acquireCloudDeploymentAuthority(custody, selection), + acquireCloudDeploymentAuthority(custody, selection), + ]); + expect(first.deploymentUrl).toBe(DEFAULT_CLOUD_DEPLOYMENT_URL); + expect(second.deploymentUrl).toBe(DEFAULT_CLOUD_DEPLOYMENT_URL); + expect(first.generation).toBe(0); + expect(second.generation).toBe(0); + expect(custody.values.get("cloud-deployment-authority")?.generation).toBe(0); + await Promise.all([first.assertCurrent(), second.assertCurrent()]); + }); + + test("allows only one deployment to win a competing first acquisition", async () => { + const custody = new MemoryCustody(); + const firstSelection = cloudDeploymentSelectionFromEnvironment({ + HRA_CONVEX_URL: "https://first.convex.cloud", + }); + const secondSelection = cloudDeploymentSelectionFromEnvironment({ + HRA_CONVEX_URL: "https://second.convex.cloud", + }); + if (firstSelection.kind !== "enabled" || secondSelection.kind !== "enabled") { + throw new Error("fixture selection is disabled"); + } + const results = await Promise.allSettled([ + acquireCloudDeploymentAuthority(custody, firstSelection), + acquireCloudDeploymentAuthority(custody, secondSelection), + ]); + const fulfilled = results.filter((result) => result.status === "fulfilled"); + const rejected = results.filter((result) => result.status === "rejected"); + expect(fulfilled).toHaveLength(1); + expect(rejected).toHaveLength(1); + expect(rejected[0]).toMatchObject({ + reason: { message: "Cloud deployment authority is bound to another deployment." }, + }); + if (fulfilled[0]?.status !== "fulfilled") throw new Error("fixture has no winner"); + await fulfilled[0].value.assertCurrent(); + expect(fulfilled[0].value.generation).toBe(0); + }); + + test("ignores a prior-version credential written concurrently after an implicit clean scan", async () => { + const custody = new LegacyWriteDuringAuthorityCasCustody(); + const authority = await cloudDeploymentAuthorityFromEnvironment(custody, {}); + if (authority === null) throw new Error("fixture authority is disabled"); + expect(authority.custodyMode).toBe("scoped"); + expect((await custody.read("cloud-auth"))?.value).toBe("late-legacy-auth"); + const deploymentCustody = new DeploymentScopedCloudSecretCustody(custody, authority); + expect(await deploymentCustody.read("cloud-auth")).toBeNull(); + expect(await deploymentCustody.compareAndSwap("cloud-auth", null, "bound-auth")) + .toMatchObject({ generation: 0, value: "bound-auth" }); + expect((await custody.read("cloud-auth"))?.value).toBe("late-legacy-auth"); + expect((await deploymentCustody.read("cloud-auth"))?.value).toBe("bound-auth"); + }); + + test("requires an explicit one-time binding for legacy cloud custody", async () => { + for (const slot of ["cloud-auth", "cloud-auth-logout", "cloud-active-identity"]) { + const custody = new MemoryCustody(); + await write(custody, slot, "legacy"); + await expect(cloudDeploymentAuthorityFromEnvironment(custody, {})) + .rejects.toThrow("requires an explicit HRA_CONVEX_URL"); + const bound = await cloudDeploymentAuthorityFromEnvironment(custody, { + HRA_CONVEX_URL: "https://legacy-target.convex.cloud", + }); + expect(bound?.deploymentUrl).toBe("https://legacy-target.convex.cloud"); + expect(bound?.custodyMode).toBe("legacy"); + if (bound === null) throw new Error("fixture authority is disabled"); + expect((await new DeploymentScopedCloudSecretCustody(custody, bound).read(slot))?.value) + .toBe("legacy"); + await bound.assertCurrent(); + } + }); + + test("preserves legacy cache custody while new bindings isolate the same cloud identity", async () => { + const activeIdentity = JSON.stringify({ userPublicId: "user_cache_12345678", version: 1 }); + const legacyRaw = new MemoryCustody(); + await write(legacyRaw, "cloud-active-identity", activeIdentity); + const unboundLegacy = await IdentityScopedCloudSecretCustody.open(legacyRaw); + const legacyAuthority = await cloudDeploymentAuthorityFromEnvironment(legacyRaw, { + HRA_CONVEX_URL: "https://legacy-cache.convex.cloud", + }); + if (legacyAuthority === null) throw new Error("fixture authority is disabled"); + const reboundLegacy = await IdentityScopedCloudSecretCustody.open( + new DeploymentScopedCloudSecretCustody(legacyRaw, legacyAuthority), + ); + expect(legacyAuthority.custodyMode).toBe("legacy"); + expect(reboundLegacy.cacheNamespace).toBe(unboundLegacy.cacheNamespace); + + const scopedRaw = new MemoryCustody(); + const scopedAuthority = await cloudDeploymentAuthorityFromEnvironment(scopedRaw, {}); + if (scopedAuthority === null) throw new Error("fixture authority is disabled"); + const deploymentCustody = new DeploymentScopedCloudSecretCustody( + scopedRaw, + scopedAuthority, + ); + await write(deploymentCustody, "cloud-active-identity", activeIdentity); + const scopedIdentity = await IdentityScopedCloudSecretCustody.open(deploymentCustody); + expect(scopedAuthority.custodyMode).toBe("scoped"); + expect(scopedIdentity.cacheNamespace).not.toBe(unboundLegacy.cacheNamespace); + expect((await scopedRaw.read("cloud-active-identity"))).toBeNull(); + }); + + test("refuses another deployment and makes absent configuration mean the exact default", async () => { + const customRaw = new MemoryCustody(); + const custom = await cloudDeploymentAuthorityFromEnvironment(customRaw, { + HRA_CONVEX_URL: "https://custom.convex.cloud", + }); + expect(custom?.deploymentUrl).toBe("https://custom.convex.cloud"); + expect(custom?.custodyMode).toBe("scoped"); + if (custom === null) throw new Error("fixture authority is disabled"); + const customCustody = new DeploymentScopedCloudSecretCustody(customRaw, custom); + await write(customCustody, "cloud-state", "recoverable-state"); + await expect(cloudDeploymentAuthorityFromEnvironment(customRaw, {})) + .rejects.toThrow("bound to another deployment"); + await expect(cloudDeploymentAuthorityFromEnvironment(customRaw, { + HRA_CONVEX_URL: "https://other.convex.cloud", + })).rejects.toThrow("bound to another deployment"); + const observedCustom = await readCloudDeploymentAuthority(customRaw); + if (observedCustom === null) throw new Error("fixture authority is absent"); + expect((await new DeploymentScopedCloudSecretCustody( + customRaw, + observedCustom, + ).read("cloud-state"))?.value).toBe("recoverable-state"); + + const releaseCustody = new MemoryCustody(); + const explicit = await cloudDeploymentAuthorityFromEnvironment(releaseCustody, { + HRA_CONVEX_URL: DEFAULT_CLOUD_DEPLOYMENT_URL, + }); + const implicit = await cloudDeploymentAuthorityFromEnvironment(releaseCustody, {}); + expect(implicit?.generation).toBe(explicit?.generation); + expect(implicit?.deploymentUrl).toBe(DEFAULT_CLOUD_DEPLOYMENT_URL); + }); + + test("fences changed, deleted, and delete-then-rebound authority observations", async () => { + const custody = new MemoryCustody(); + const authority = await cloudDeploymentAuthorityFromEnvironment(custody, {}); + if (authority === null) throw new Error("fixture authority is disabled"); + const committed = custody.values.get("cloud-deployment-authority"); + if (committed === undefined) throw new Error("fixture authority is absent"); + custody.values.set("cloud-deployment-authority", { + generation: committed.generation + 1, + value: committed.value, + }); + await expect(authority.assertCurrent()).rejects.toThrow("not current"); + + custody.values.delete("cloud-deployment-authority"); + await expect(authority.assertCurrent()).rejects.toThrow("not current"); + const rebound = await cloudDeploymentAuthorityFromEnvironment(custody, {}); + expect(rebound?.generation).toBe(0); + await expect(authority.assertCurrent()).rejects.toThrow("not current"); + await rebound?.assertCurrent(); + }); }); diff --git a/src/cloud/identity-custody.ts b/src/cloud/identity-custody.ts index c2dd267..0d8b691 100644 --- a/src/cloud/identity-custody.ts +++ b/src/cloud/identity-custody.ts @@ -4,6 +4,13 @@ import { hasExactKeys, isOpaqueIdentifier, isRecord } from "./contracts"; import type { CloudSecretCustodyPort } from "./local-control"; const activeIdentitySlot = "cloud-active-identity"; +const deploymentAuthoritySlot = "cloud-deployment-authority"; +const legacyCloudSlots = [ + "cloud-active-identity", + "cloud-auth", + "cloud-auth-logout", +] as const; +export const DEFAULT_CLOUD_DEPLOYMENT_URL = "https://quiet-bison-462.convex.cloud"; const scopedSlots = new Set([ "cloud-account-key", "cloud-account-deletion", @@ -22,6 +29,330 @@ type ActiveIdentity = Readonly<{ version: 1; }>; +type CloudDeploymentAuthorityValue = Readonly<{ + bindingId: string; + custodyMode: "legacy" | "scoped"; + deploymentUrl: string; + version: 1; +}>; + +export type CloudDeploymentSelection = + | Readonly<{ kind: "disabled" }> + | Readonly<{ + deploymentUrl: string; + explicit: boolean; + kind: "enabled"; + }>; + +export type CloudDeploymentAuthorityErrorCode = + | "concurrent_change" + | "corrupt_custody" + | "invalid_configuration" + | "legacy_binding_required" + | "stale_authority" + | "target_mismatch"; + +export class CloudDeploymentAuthorityError extends Error { + constructor(readonly code: CloudDeploymentAuthorityErrorCode, message: string) { + super(message); + this.name = "CloudDeploymentAuthorityError"; + } +} + +export interface CloudDeploymentAuthority { + readonly cacheNamespace: string | null; + readonly custodyMode: "legacy" | "scoped"; + readonly deploymentUrl: string; + readonly generation: number; + assertCurrent(): Promise; + scopeCustodySlot(slot: string): string; +} + +export function canonicalCloudDeploymentUrl(value: string): string { + let url: URL; + try { + url = new URL(value); + } catch { + throw new CloudDeploymentAuthorityError( + "invalid_configuration", + "HRA_CONVEX_URL is invalid.", + ); + } + const localHttp = url.protocol === "http:" + && (url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "[::1]"); + if ( + (url.protocol !== "https:" && !localHttp) + || url.username !== "" + || url.password !== "" + || url.pathname !== "/" + || url.search !== "" + || url.hash !== "" + ) { + throw new CloudDeploymentAuthorityError( + "invalid_configuration", + "HRA_CONVEX_URL is invalid.", + ); + } + return url.origin; +} + +export function cloudDeploymentSelectionFromEnvironment( + environment: Readonly> = process.env, +): CloudDeploymentSelection { + const value = environment.HRA_CONVEX_URL; + if (value === undefined) { + return { + deploymentUrl: DEFAULT_CLOUD_DEPLOYMENT_URL, + explicit: false, + kind: "enabled", + }; + } + if (value.trim() === "") return { kind: "disabled" }; + return { + deploymentUrl: canonicalCloudDeploymentUrl(value), + explicit: true, + kind: "enabled", + }; +} + +function parseDeploymentAuthorityValue(value: string): CloudDeploymentAuthorityValue { + let decoded: unknown; + try { + decoded = JSON.parse(value) as unknown; + } catch { + throw new CloudDeploymentAuthorityError( + "corrupt_custody", + "Cloud deployment authority custody is corrupt.", + ); + } + if ( + !isRecord(decoded) + || !hasExactKeys(decoded, ["bindingId", "custodyMode", "deploymentUrl", "version"]) + || decoded.version !== 1 + || !isOpaqueIdentifier(decoded.bindingId) + || (decoded.custodyMode !== "legacy" && decoded.custodyMode !== "scoped") + || typeof decoded.deploymentUrl !== "string" + ) { + throw new CloudDeploymentAuthorityError( + "corrupt_custody", + "Cloud deployment authority custody is corrupt.", + ); + } + let deploymentUrl: string; + try { + deploymentUrl = canonicalCloudDeploymentUrl(decoded.deploymentUrl); + } catch { + throw new CloudDeploymentAuthorityError( + "corrupt_custody", + "Cloud deployment authority custody is corrupt.", + ); + } + if (deploymentUrl !== decoded.deploymentUrl) { + throw new CloudDeploymentAuthorityError( + "corrupt_custody", + "Cloud deployment authority custody is corrupt.", + ); + } + return { + bindingId: decoded.bindingId, + custodyMode: decoded.custodyMode, + deploymentUrl, + version: 1, + }; +} + +class ExactCloudDeploymentAuthority implements CloudDeploymentAuthority { + readonly #bindingId: string; + readonly #custody: CloudSecretCustodyPort; + readonly #custodyMode: "legacy" | "scoped"; + readonly #deploymentUrl: string; + readonly #generation: number; + readonly #serialized: string; + + constructor( + custody: CloudSecretCustodyPort, + observation: Readonly<{ generation: number; value: string }>, + parsed: CloudDeploymentAuthorityValue, + ) { + this.#bindingId = parsed.bindingId; + this.#custody = custody; + this.#custodyMode = parsed.custodyMode; + this.#deploymentUrl = parsed.deploymentUrl; + this.#generation = observation.generation; + this.#serialized = observation.value; + } + + get cacheNamespace(): string | null { + return this.#custodyMode === "legacy" + ? null + : createHash("sha256") + .update(`hra-control-plane-cloud-deployment-cache:v1:${this.#bindingId}`) + .digest("hex") + .slice(0, 24); + } + + get custodyMode(): "legacy" | "scoped" { + return this.#custodyMode; + } + + get deploymentUrl(): string { + return this.#deploymentUrl; + } + + get generation(): number { + return this.#generation; + } + + async assertCurrent(): Promise { + const current = await this.#custody.read(deploymentAuthoritySlot); + if ( + current === null + || current.generation !== this.generation + || current.value !== this.#serialized + ) { + throw new CloudDeploymentAuthorityError( + "stale_authority", + "Cloud deployment authority is not current.", + ); + } + try { + parseDeploymentAuthorityValue(current.value); + } catch { + throw new CloudDeploymentAuthorityError( + "stale_authority", + "Cloud deployment authority is not current.", + ); + } + } + + scopeCustodySlot(slot: string): string { + if (!/^[a-z][a-z0-9-]{0,63}$/u.test(slot)) throw new Error("Invalid cloud custody slot."); + if (slot === deploymentAuthoritySlot || this.#custodyMode === "legacy") return slot; + const namespace = createHash("sha256") + .update(`hra-control-plane-cloud-deployment-custody:v1:${this.#bindingId}:${slot}`) + .digest("hex") + .slice(0, 48); + return `d-${namespace}`; + } +} + +export class DeploymentScopedCloudSecretCustody implements CloudSecretCustodyPort { + readonly #authority: CloudDeploymentAuthority; + readonly #custody: CloudSecretCustodyPort; + + constructor(custody: CloudSecretCustodyPort, authority: CloudDeploymentAuthority) { + this.#authority = authority; + this.#custody = custody; + } + + get cacheNamespace(): string | null { + return this.#authority.cacheNamespace; + } + + async read(slot: string): Promise | null> { + await this.#authority.assertCurrent(); + const observed = await this.#custody.read(this.#authority.scopeCustodySlot(slot)); + await this.#authority.assertCurrent(); + return observed; + } + + async compareAndSwap( + slot: string, + expectedGeneration: number | null, + value: string, + ): Promise | null> { + await this.#authority.assertCurrent(); + const committed = await this.#custody.compareAndSwap( + this.#authority.scopeCustodySlot(slot), + expectedGeneration, + value, + ); + await this.#authority.assertCurrent(); + return committed; + } + + async clearIfGeneration(slot: string, expectedGeneration: number): Promise { + await this.#authority.assertCurrent(); + const cleared = await this.#custody.clearIfGeneration( + this.#authority.scopeCustodySlot(slot), + expectedGeneration, + ); + await this.#authority.assertCurrent(); + return cleared; + } +} + +async function hasLegacyUnboundCloudCustody(custody: CloudSecretCustodyPort): Promise { + for (const slot of legacyCloudSlots) { + if (await custody.read(slot) !== null) return true; + } + return false; +} + +export async function acquireCloudDeploymentAuthority( + custody: CloudSecretCustodyPort, + selection: Extract, +): Promise { + const deploymentUrl = canonicalCloudDeploymentUrl(selection.deploymentUrl); + for (let attempt = 0; attempt < 8; attempt += 1) { + const current = await custody.read(deploymentAuthoritySlot); + if (current !== null) { + const parsed = parseDeploymentAuthorityValue(current.value); + if (parsed.deploymentUrl !== deploymentUrl) { + throw new CloudDeploymentAuthorityError( + "target_mismatch", + "Cloud deployment authority is bound to another deployment.", + ); + } + return new ExactCloudDeploymentAuthority(custody, current, parsed); + } + const hasLegacyCustody = await hasLegacyUnboundCloudCustody(custody); + if (!selection.explicit && hasLegacyCustody) { + throw new CloudDeploymentAuthorityError( + "legacy_binding_required", + "Legacy cloud custody requires an explicit HRA_CONVEX_URL before deployment binding.", + ); + } + const value = { + bindingId: `binding_${crypto.randomUUID().replaceAll("-", "")}`, + custodyMode: selection.explicit && hasLegacyCustody ? "legacy" : "scoped", + deploymentUrl, + version: 1, + } satisfies CloudDeploymentAuthorityValue; + const serialized = JSON.stringify(value); + const committed = await custody.compareAndSwap(deploymentAuthoritySlot, null, serialized); + if (committed !== null) { + return new ExactCloudDeploymentAuthority(custody, committed, value); + } + } + throw new CloudDeploymentAuthorityError( + "concurrent_change", + "Cloud deployment authority changed concurrently.", + ); +} + +export async function cloudDeploymentAuthorityFromEnvironment( + custody: CloudSecretCustodyPort, + environment: Readonly> = process.env, +): Promise { + const selection = cloudDeploymentSelectionFromEnvironment(environment); + return selection.kind === "disabled" + ? null + : await acquireCloudDeploymentAuthority(custody, selection); +} + +export async function readCloudDeploymentAuthority( + custody: CloudSecretCustodyPort, +): Promise { + const current = await custody.read(deploymentAuthoritySlot); + if (current === null) return null; + return new ExactCloudDeploymentAuthority( + custody, + current, + parseDeploymentAuthorityValue(current.value), + ); +} + function parseActiveIdentity(value: string): ActiveIdentity { let decoded: unknown; try { @@ -63,7 +394,17 @@ export class IdentityScopedCloudSecretCustody implements CloudSecretCustodyPort } get cacheNamespace(): string | null { - return this.#activeUserPublicId === null ? null : namespaceFor(this.#activeUserPublicId); + if (this.#activeUserPublicId === null) return null; + const identityNamespace = namespaceFor(this.#activeUserPublicId); + const deploymentNamespace = this.#custody instanceof DeploymentScopedCloudSecretCustody + ? this.#custody.cacheNamespace + : null; + return deploymentNamespace === null + ? identityNamespace + : createHash("sha256") + .update(`hra-control-plane-cloud-cache:v1:${deploymentNamespace}:${identityNamespace}`) + .digest("hex") + .slice(0, 24); } async activateIdentity(userPublicId: string): Promise Promise.resolve(), + cacheNamespace: null, + custodyMode: "legacy" as const, + deploymentUrl: testDeploymentUrl, + generation: 0, + scopeCustodySlot: (slot: string) => slot, +}; class MemoryCustody implements CloudSecretCustodyPort { + readonly reads: string[] = []; readonly values = new Map>(); async read(slot: string): Promise | null> { + this.reads.push(slot); return this.values.get(slot) ?? null; } @@ -537,7 +552,8 @@ function control( now: () => number = () => fixedNow, ): LocalCloudControl { return new LocalCloudControl({ - deploymentUrl: "https://example.convex.cloud", + deploymentAuthority: testDeploymentAuthority, + deploymentUrl: testDeploymentUrl, now, secretCustody: custody, transport: cloud.connect(), @@ -698,7 +714,8 @@ describe("local cloud control", () => { const unbound = await IdentityScopedCloudSecretCustody.open(raw); const cloudA = new FakeCloud("user_identity_a"); const authenticatingA = new LocalCloudControl({ - deploymentUrl: "https://example.convex.cloud", + deploymentAuthority: testDeploymentAuthority, + deploymentUrl: testDeploymentUrl, now: () => fixedNow, secretCustody: unbound, transport: cloudA.connect(), @@ -707,7 +724,8 @@ describe("local cloud control", () => { await authenticatingA.auth({ code: "12345678", email: "reader@example.com", signal }); const identityA = await IdentityScopedCloudSecretCustody.open(raw); const controlA = new LocalCloudControl({ - deploymentUrl: "https://example.convex.cloud", + deploymentAuthority: testDeploymentAuthority, + deploymentUrl: testDeploymentUrl, now: () => fixedNow, secretCustody: identityA, transport: cloudA.connect(), @@ -718,7 +736,8 @@ describe("local cloud control", () => { const identityB = await IdentityScopedCloudSecretCustody.open(raw); const cloudB = new FakeCloud("user_identity_b"); const controlB = new LocalCloudControl({ - deploymentUrl: "https://example.convex.cloud", + deploymentAuthority: testDeploymentAuthority, + deploymentUrl: testDeploymentUrl, now: () => fixedNow, secretCustody: identityB, transport: cloudB.connect(), @@ -729,7 +748,8 @@ describe("local cloud control", () => { await identityB.activateIdentity("user_identity_a"); const returnedA = await IdentityScopedCloudSecretCustody.open(raw); const reopenedA = new LocalCloudControl({ - deploymentUrl: "https://example.convex.cloud", + deploymentAuthority: testDeploymentAuthority, + deploymentUrl: testDeploymentUrl, now: () => fixedNow, secretCustody: returnedA, transport: cloudA.connect(), @@ -745,7 +765,8 @@ describe("local cloud control", () => { const raw = new MemoryCustody(); const scoped = await IdentityScopedCloudSecretCustody.open(raw); const adapter = new LocalCloudControl({ - deploymentUrl: "https://example.convex.cloud", + deploymentAuthority: testDeploymentAuthority, + deploymentUrl: testDeploymentUrl, now: () => fixedNow, secretCustody: scoped, transport: cloud.connect(), @@ -780,17 +801,79 @@ describe("local cloud control", () => { expect(JSON.stringify(cloud.authAttempts)).not.toContain(["hra", "otp"].join("-")); }); - test("is absent until the explicit Convex URL is configured", () => { + test("uses the release deployment by default and honors an explicit empty disable", async () => { const custody = new MemoryCustody(); - expect(createLocalCloudControlFromEnvironment({ + expect(await createLocalCloudControlFromEnvironment({ environment: {}, + transport: new FakeCloud().connect(), + secretCustody: custody, + })).toBeInstanceOf(LocalCloudControl); + expect(await createLocalCloudControlFromEnvironment({ + environment: { HRA_CONVEX_URL: "" }, secretCustody: custody, })).toBeNull(); - expect(createLocalCloudControlFromEnvironment({ - environment: { HRA_CONVEX_URL: "https://example.convex.cloud" }, + }); + + test("refuses implicit migration of legacy auth custody before transport", async () => { + const custody = new MemoryCustody(); + custody.values.set("cloud-auth", { generation: 0, value: "legacy-auth" }); + let transportCalls = 0; + const transport: CloudTransport = { + action: async () => { transportCalls += 1; throw new Error("unexpected transport"); }, + mutation: async () => { transportCalls += 1; throw new Error("unexpected transport"); }, + query: async () => { transportCalls += 1; throw new Error("unexpected transport"); }, + }; + await expect(createLocalCloudControlFromEnvironment({ + environment: {}, secretCustody: custody, - transport: new FakeCloud().connect(), - })).toBeInstanceOf(LocalCloudControl); + transport, + })).rejects.toThrow("requires an explicit HRA_CONVEX_URL"); + expect(transportCalls).toBe(0); + expect(custody.values.has("cloud-deployment-authority")).toBe(false); + }); + + test("refuses stale deployment authority before reading auth custody or invoking transport", async () => { + const custody = new MemoryCustody(); + const authority = await cloudDeploymentAuthorityFromEnvironment(custody, { + HRA_CONVEX_URL: testDeploymentUrl, + }); + if (authority === null) throw new Error("fixture authority is disabled"); + const cloud = new FakeCloud(); + const adapter = new LocalCloudControl({ + deploymentAuthority: authority, + deploymentUrl: testDeploymentUrl, + secretCustody: custody, + transport: cloud.connect(), + }); + custody.values.delete("cloud-deployment-authority"); + custody.reads.length = 0; + + await expect(adapter.auth({ email: "reader@example.com", signal })) + .rejects.toThrow("Cloud deployment authority is not current."); + expect(custody.reads).toEqual(["cloud-deployment-authority"]); + expect(cloud.authAttempts).toEqual([]); + }); + + test("rejects a transport response when deployment authority changes in flight", async () => { + const custody = new MemoryCustody(); + const authority = await cloudDeploymentAuthorityFromEnvironment(custody, { + HRA_CONVEX_URL: testDeploymentUrl, + }); + if (authority === null) throw new Error("fixture authority is disabled"); + let transportCalls = 0; + const transport = deploymentFencedCloudTransport({ + action: async () => { throw new Error("unexpected action"); }, + mutation: async () => { throw new Error("unexpected mutation"); }, + query: async () => { + transportCalls += 1; + custody.values.delete("cloud-deployment-authority"); + return { unsafe: "stale response" }; + }, + }, authority); + + await expect(transport.query("account:current", {})) + .rejects.toThrow("Cloud deployment authority is not current."); + expect(transportCalls).toBe(1); }); test("keeps OTP tokens in injected custody and preserves device keys on logout", async () => { @@ -849,7 +932,8 @@ describe("local cloud control", () => { let localNow = fixedNow; const transport = cloud.connect(); const adapter = new LocalCloudControl({ - deploymentUrl: "https://example.convex.cloud", + deploymentAuthority: testDeploymentAuthority, + deploymentUrl: testDeploymentUrl, now: () => localNow, secretCustody: custody, transport, @@ -893,13 +977,15 @@ describe("local cloud control", () => { let localNow = fixedNow; const transport = cloud.connect(); const refreshingControl = new LocalCloudControl({ - deploymentUrl: "https://example.convex.cloud", + deploymentAuthority: testDeploymentAuthority, + deploymentUrl: testDeploymentUrl, now: () => localNow, secretCustody: custody, transport, }); const logoutControl = new LocalCloudControl({ - deploymentUrl: "https://example.convex.cloud", + deploymentAuthority: testDeploymentAuthority, + deploymentUrl: testDeploymentUrl, now: () => localNow, secretCustody: custody, transport, @@ -931,12 +1017,14 @@ describe("local cloud control", () => { const custody = new MemoryCustody(); const transport = cloud.connect(); const authenticatingControl = new LocalCloudControl({ - deploymentUrl: "https://example.convex.cloud", + deploymentAuthority: testDeploymentAuthority, + deploymentUrl: testDeploymentUrl, secretCustody: custody, transport, }); const logoutControl = new LocalCloudControl({ - deploymentUrl: "https://example.convex.cloud", + deploymentAuthority: testDeploymentAuthority, + deploymentUrl: testDeploymentUrl, secretCustody: custody, transport, }); @@ -975,7 +1063,8 @@ describe("local cloud control", () => { let localNow = fixedNow; const transport = cloud.connect(); const adapter = new LocalCloudControl({ - deploymentUrl: "https://example.convex.cloud", + deploymentAuthority: testDeploymentAuthority, + deploymentUrl: testDeploymentUrl, now: () => localNow, secretCustody: custody, transport, diff --git a/src/cloud/local-control.ts b/src/cloud/local-control.ts index 962b0c2..2d992cd 100644 --- a/src/cloud/local-control.ts +++ b/src/cloud/local-control.ts @@ -7,6 +7,15 @@ import { createConvexCloudTransport, type CloudTransport, } from "./client"; +import { + acquireCloudDeploymentAuthority, + canonicalCloudDeploymentUrl, + cloudDeploymentSelectionFromEnvironment, + CloudDeploymentAuthorityError, + DeploymentScopedCloudSecretCustody, + IdentityScopedCloudSecretCustody, + type CloudDeploymentAuthority, +} from "./identity-custody"; import { cloudLimits, containsAbsolutePath, @@ -111,7 +120,69 @@ function canSelectCloudIdentity( && typeof custody.activateIdentity === "function"; } +export function deploymentFencedSecretCustody( + custody: CloudSecretCustodyPort, + authority: CloudDeploymentAuthority, +): CloudSecretCustodyPort { + const fenced: CloudSecretCustodyPort = { + clearIfGeneration: async (slot, expectedGeneration) => { + await authority.assertCurrent(); + const cleared = await custody.clearIfGeneration(slot, expectedGeneration); + await authority.assertCurrent(); + return cleared; + }, + compareAndSwap: async (slot, expectedGeneration, value) => { + await authority.assertCurrent(); + const committed = await custody.compareAndSwap(slot, expectedGeneration, value); + await authority.assertCurrent(); + return committed; + }, + read: async (slot) => { + await authority.assertCurrent(); + const observed = await custody.read(slot); + await authority.assertCurrent(); + return observed; + }, + }; + if (!canSelectCloudIdentity(custody)) return fenced; + return Object.assign(fenced, { + activateIdentity: async (userPublicId: string) => { + await authority.assertCurrent(); + const selected = await custody.activateIdentity(userPublicId); + await authority.assertCurrent(); + return selected; + }, + }); +} + +export function deploymentFencedCloudTransport( + transport: CloudTransport, + authority: CloudDeploymentAuthority, +): CloudTransport { + return { + action: async (name, args) => { + await authority.assertCurrent(); + const result = await transport.action(name, args); + await authority.assertCurrent(); + return result; + }, + mutation: async (name, args) => { + await authority.assertCurrent(); + const result = await transport.mutation(name, args); + await authority.assertCurrent(); + return result; + }, + query: async (name, args) => { + await authority.assertCurrent(); + const result = await transport.query(name, args); + await authority.assertCurrent(); + return result; + }, + }; +} + export type LocalCloudControlOptions = Readonly<{ + deploymentAuthority: CloudDeploymentAuthority; deploymentUrl: string; deviceLabel?: string; lifetimeSignal?: AbortSignal; @@ -121,6 +192,7 @@ export type LocalCloudControlOptions = Readonly<{ }>; export type LocalCloudControlEnvironmentOptions = Readonly<{ + deploymentAuthority?: CloudDeploymentAuthority; deviceLabel?: string; environment?: Readonly>; lifetimeSignal?: AbortSignal; @@ -1561,25 +1633,6 @@ function serializeSecret( return JSON.stringify(value); } -function validateDeploymentUrl(value: string): string { - let url: URL; - try { - url = new URL(value); - } catch { - throw new Error("HRA_CONVEX_URL is invalid."); - } - const localHttp = url.protocol === "http:" - && (url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "[::1]"); - if ( - (url.protocol !== "https:" && !localHttp) - || url.username !== "" - || url.password !== "" - || url.search !== "" - || url.hash !== "" - ) throw new Error("HRA_CONVEX_URL is invalid."); - return url.toString().replace(/\/$/u, ""); -} - function validateDeviceLabel(value: string): string { const label = value.trim(); if (label.length < 1 || label.length > 160 || containsAbsolutePath(label)) { @@ -1643,11 +1696,12 @@ function abortBeforeEffect(signal: AbortSignal): void { export function deploymentUrlFromEnvironment( environment: Readonly> = process.env, ): string | null { - const value = environment.HRA_CONVEX_URL; - return value === undefined || value.trim() === "" ? null : validateDeploymentUrl(value); + const selection = cloudDeploymentSelectionFromEnvironment(environment); + return selection.kind === "disabled" ? null : selection.deploymentUrl; } export class LocalCloudControl implements CloudControlPort { + readonly #deploymentAuthority: CloudDeploymentAuthority; readonly #deviceLabel: string; readonly #now: () => number; readonly #secrets: CloudSecretCustodyPort; @@ -1655,17 +1709,33 @@ export class LocalCloudControl implements CloudControlPort { #tail: Promise = Promise.resolve(); constructor(options: LocalCloudControlOptions) { - const deploymentUrl = validateDeploymentUrl(options.deploymentUrl); + const deploymentUrl = canonicalCloudDeploymentUrl(options.deploymentUrl); + if (deploymentUrl !== options.deploymentAuthority.deploymentUrl) { + throw new CloudDeploymentAuthorityError( + "target_mismatch", + "Cloud deployment authority does not match the requested deployment.", + ); + } + this.#deploymentAuthority = options.deploymentAuthority; this.#deviceLabel = validateDeviceLabel(options.deviceLabel ?? "This device"); this.#now = options.now ?? Date.now; - this.#secrets = options.secretCustody; - this.#transport = options.transport ?? createConvexCloudTransport({ - accessToken: async () => (await this.#readTransportAuth())?.token ?? null, + this.#secrets = deploymentFencedSecretCustody( + options.secretCustody, + this.#deploymentAuthority, + ); + const transport = options.transport ?? createConvexCloudTransport({ + accessToken: async () => { + await this.#deploymentAuthority.assertCurrent(); + const token = (await this.#readTransportAuth())?.token ?? null; + await this.#deploymentAuthority.assertCurrent(); + return token; + }, deploymentUrl, ...(options.lifetimeSignal === undefined ? {} : { lifetimeSignal: options.lifetimeSignal }), }); + this.#transport = deploymentFencedCloudTransport(transport, this.#deploymentAuthority); } async auth(input: { email: string; code?: string; invite?: string; signal: AbortSignal }): Promise { @@ -3984,6 +4054,7 @@ export class LocalCloudControl implements CloudControlPort { allowDuringAccountDeletion = false, ): Promise { const guarded = async (): Promise => { + await this.#deploymentAuthority.assertCurrent(); if (!allowDuringAccountDeletion && await this.#readPendingAccountDeletion() !== null) { throw new Error("Cloud effects are unavailable while hosted account erasure is in progress."); } @@ -4003,19 +4074,34 @@ export function createLocalCloudControl(options: LocalCloudControlOptions): Clou return new LocalCloudControl(options); } -export function createLocalCloudControlFromEnvironment( +export async function createLocalCloudControlFromEnvironment( options: LocalCloudControlEnvironmentOptions, -): LocalCloudControl | null { - const deploymentUrl = deploymentUrlFromEnvironment(options.environment); - if (deploymentUrl === null) return null; +): Promise { + const selection = cloudDeploymentSelectionFromEnvironment(options.environment); + if (selection.kind === "disabled") return null; + const deploymentAuthority = options.deploymentAuthority + ?? await acquireCloudDeploymentAuthority(options.secretCustody, selection); + if (deploymentAuthority.deploymentUrl !== selection.deploymentUrl) { + throw new CloudDeploymentAuthorityError( + "target_mismatch", + "Cloud deployment authority does not match the requested deployment.", + ); + } + await deploymentAuthority.assertCurrent(); + const deploymentCustody = new DeploymentScopedCloudSecretCustody( + options.secretCustody, + deploymentAuthority, + ); + const identityCustody = await IdentityScopedCloudSecretCustody.open(deploymentCustody); return new LocalCloudControl({ - deploymentUrl, + deploymentAuthority, + deploymentUrl: selection.deploymentUrl, ...(options.deviceLabel === undefined ? {} : { deviceLabel: options.deviceLabel }), ...(options.lifetimeSignal === undefined ? {} : { lifetimeSignal: options.lifetimeSignal }), ...(options.now === undefined ? {} : { now: options.now }), - secretCustody: options.secretCustody, + secretCustody: identityCustody, ...(options.transport === undefined ? {} : { transport: options.transport }), }); }