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
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export declare const REQUIRED_SECRETS: readonly string[];
export declare const REQUIRED_VARS: readonly string[];
export declare const DEFAULTED_VARS: readonly { name: string; codeDefault: string }[];
export declare const ENVIRONMENTS: readonly string[];

export declare class DeploymentConfigError extends Error {}
Expand All @@ -10,7 +11,7 @@ export declare function verifyDirectoryDeploymentConfig(args: {
environments?: readonly string[];
listSecretNames: (environment: string) => Iterable<string>;
readConfig: () => unknown;
}): void;
}): { warnings: string[] };

export declare function wranglerSecretListInvocation(
environment: string,
Expand Down
81 changes: 67 additions & 14 deletions apps/account-directory/scripts/verify-deployment-config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,44 @@ import { dirname, resolve } from "node:path";
* relay hand-off has no URL to call, and an `ACTIVITY_RELAY` service binding
* cannot supply one (it is only the transport).
*
* `DIRECTORY_AUTH_SECRET` is a wrangler SECRET, so it is read from
* `wrangler secret list`; `PUSH_RELAY_URL` is a plain var, so it is read from
* the committed `wrangler.jsonc`. The deploy entry point passes the exact
* environment it is about to publish: wrangler environments do not inherit
* secrets, and a production deploy must not be blocked by an unrelated local
* development environment that is intentionally unconfigured.
* The Clerk trio is checked for exactly the same reason, one failure shape
* further in: `resolveCallerToken` throws "authentication unavailable" when any
* of `CLERK_JWKS_URL` / `CLERK_ISSUER` / `CLERK_OAUTH_CLIENT_ID` is blank, so a
* deploy missing one answers 503 on EVERY authenticated route and on the whole
* `/device/*` sign-in flow — while `/health` stays green, which is precisely the
* shape of the 2026-08-06 incident this preflight exists to prevent.
* `WEB_CLIENT_ORIGIN` earns a hard failure too: it has no code default, and
* without it no `access-control-allow-origin` is emitted, so the browser client
* at app.ade-app.dev is blocked outright.
*
* `ONLINE_WINDOW_MS` and `DIAGNOSTICS_DAILY_GLOBAL_LIMIT` are deliberately NOT
* hard requirements: both have code defaults equal to the committed values
* (`DEFAULT_ONLINE_WINDOW_MS` = 90_000, `DEFAULT_DIAGNOSTICS_DAILY_GLOBAL_LIMIT`
* = 400), so their absence changes no behavior — the diagnostics cost ceiling
* still applies at 400/day. Blocking a deploy on them would be a false gate.
* They warn instead, including when they are set to something the Worker cannot
* parse, because that silently falls back to the default rather than erroring.
*
* Secrets are read from `wrangler secret list` BY NAME ONLY — this never reads,
* prints, or logs a secret value. Vars are read from the committed
* `wrangler.jsonc`. The deploy entry point passes the exact environment it is
* about to publish: wrangler environments inherit neither vars nor secrets, and
* a production deploy must not be blocked by an unrelated local development
* environment that is intentionally unconfigured.
*/

export const REQUIRED_SECRETS = ["DIRECTORY_AUTH_SECRET"];
export const REQUIRED_VARS = ["PUSH_RELAY_URL"];
export const REQUIRED_SECRETS = [
"DIRECTORY_AUTH_SECRET",
"CLERK_JWKS_URL",
"CLERK_ISSUER",
"CLERK_OAUTH_CLIENT_ID",
];
export const REQUIRED_VARS = ["PUSH_RELAY_URL", "WEB_CLIENT_ORIGIN"];
/** Have code defaults: missing (or unparseable) is a warning, never a failure. */
export const DEFAULTED_VARS = [
{ name: "ONLINE_WINDOW_MS", codeDefault: "90000" },
{ name: "DIAGNOSTICS_DAILY_GLOBAL_LIMIT", codeDefault: "400" },
];
export const ENVIRONMENTS = ["default", "production"];

export class DeploymentConfigError extends Error {
Expand Down Expand Up @@ -83,6 +111,7 @@ function varsForEnvironment(config, environment) {
* @param {object} args
* @param {(environment: string) => Iterable<string>} args.listSecretNames
* @param {() => object} args.readConfig
* @returns {{ warnings: string[] }} Non-blocking notes about defaulted vars.
*/
export function verifyDirectoryDeploymentConfig(args) {
const environments = args.environments ?? ENVIRONMENTS;
Expand All @@ -104,17 +133,39 @@ export function verifyDirectoryDeploymentConfig(args) {
}
}
const config = args.readConfig();
const warnings = [];
for (const environment of environments) {
const vars = varsForEnvironment(config, environment);
const missingVars = REQUIRED_VARS.filter(
(name) => typeof vars[name] !== "string" || !vars[name].trim(),
);
const missingVars = REQUIRED_VARS.filter((name) => !isConfiguredVar(vars[name]));
if (missingVars.length > 0) {
throw new DeploymentConfigError(
`missing Worker vars for the ${environment} environment: ${missingVars.join(", ")}`,
);
}
for (const { name, codeDefault } of DEFAULTED_VARS) {
if (!isConfiguredVar(vars[name])) {
warnings.push(
`${name} is not set for the ${environment} environment; the Worker will use its code default of ${codeDefault}.`,
);
} else if (!isNonNegativeNumber(vars[name])) {
// The Worker parses these with Number() and silently falls back, so a
// typo here reads as "configured" while doing nothing.
warnings.push(
`${name} for the ${environment} environment is not a non-negative number; the Worker will ignore it and use ${codeDefault}.`,
);
}
}
}
return { warnings };
}

function isConfiguredVar(value) {
return typeof value === "string" && Boolean(value.trim());
}

function isNonNegativeNumber(value) {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed >= 0;
}

/**
Expand Down Expand Up @@ -192,12 +243,13 @@ function main() {
"..",
"wrangler.jsonc",
);
let warnings = [];
try {
verifyDirectoryDeploymentConfig({
({ warnings } = verifyDirectoryDeploymentConfig({
environments,
listSecretNames: wranglerSecretNames,
readConfig: () => parseJsonc(readFileSync(configPath, "utf8")),
});
}));
} catch (error) {
console.error(
`Account directory deployment preflight failed: ${
Expand All @@ -206,8 +258,9 @@ function main() {
);
process.exit(1);
}
for (const warning of warnings) console.warn(`Account directory deployment preflight: ${warning}`);
console.log(
`Account directory relay hand-off configuration is complete for the ${environments.join(" and ")} environment${environments.length === 1 ? "" : "s"}.`,
`Account directory authentication and relay hand-off configuration is complete for the ${environments.join(" and ")} environment${environments.length === 1 ? "" : "s"}.`,
);
}

Expand Down
86 changes: 74 additions & 12 deletions apps/account-directory/test/verifyDeploymentConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { dirname, resolve } from "node:path";
import { describe, expect, it, vi } from "vitest";
import {
DeploymentConfigError,
REQUIRED_SECRETS,
parseJsonc,
verifyDirectoryDeploymentConfig,
wranglerSecretListInvocation,
Expand All @@ -17,20 +18,27 @@ const wranglerConfigPath = resolve(
"wrangler.jsonc",
);

const completeVars = {
PUSH_RELAY_URL: "https://relay.test",
WEB_CLIENT_ORIGIN: "https://app.test",
ONLINE_WINDOW_MS: "90000",
DIAGNOSTICS_DAILY_GLOBAL_LIMIT: "400",
};

const completeConfig = {
vars: { PUSH_RELAY_URL: "https://relay.test" },
env: { production: { vars: { PUSH_RELAY_URL: "https://relay.test" } } },
vars: completeVars,
env: { production: { vars: completeVars } },
};

function verify(args: {
environments?: string[];
secretsByEnvironment?: Record<string, string[]>;
config?: unknown;
}): void {
verifyDirectoryDeploymentConfig({
}): { warnings: string[] } {
return verifyDirectoryDeploymentConfig({
environments: args.environments,
listSecretNames: (environment) =>
args.secretsByEnvironment?.[environment] ?? ["DIRECTORY_AUTH_SECRET"],
args.secretsByEnvironment?.[environment] ?? [...REQUIRED_SECRETS],
readConfig: () => args.config ?? completeConfig,
});
}
Expand All @@ -55,7 +63,7 @@ describe("account directory deployment preflight", () => {
expect(() =>
verify({
secretsByEnvironment: {
default: ["DIRECTORY_AUTH_SECRET"],
default: [...REQUIRED_SECRETS],
production: ["CLERK_ISSUER"],
},
})
Expand All @@ -65,10 +73,28 @@ describe("account directory deployment preflight", () => {
it("does not require the unused default environment for a production deploy", () => {
expect(() => verify({
environments: ["production"],
secretsByEnvironment: { default: [], production: ["DIRECTORY_AUTH_SECRET"] },
secretsByEnvironment: { default: [], production: [...REQUIRED_SECRETS] },
})).not.toThrow();
});

it.each(["CLERK_JWKS_URL", "CLERK_ISSUER", "CLERK_OAUTH_CLIENT_ID"])(
"fails when %s is not bound",
(missing) => {
// `resolveCallerToken` throws "authentication unavailable" when any of the
// trio is blank: every authenticated route answers 503 and the whole
// /device/* sign-in flow breaks, while /health stays green.
const secrets = REQUIRED_SECRETS.filter((name) => name !== missing);
expect(() =>
verify({ secretsByEnvironment: { default: secrets, production: secrets } })
).toThrow(new RegExp(`default environment: ${missing}`));
},
);

it("names every missing secret at once", () => {
expect(() => verify({ secretsByEnvironment: { default: ["DIRECTORY_AUTH_SECRET"] } }))
.toThrow(/CLERK_JWKS_URL, CLERK_ISSUER, CLERK_OAUTH_CLIENT_ID/);
});

it("rejects an unknown deployment environment", () => {
expect(() => verify({ environments: ["staging"] }))
.toThrow(/unknown Worker environment\(s\): staging/);
Expand All @@ -77,26 +103,62 @@ describe("account directory deployment preflight", () => {
it.each([
[
"the default environment",
{ vars: {}, env: { production: { vars: { PUSH_RELAY_URL: "https://relay.test" } } } },
{ vars: {}, env: { production: { vars: completeVars } } },
/default environment: PUSH_RELAY_URL/,
],
[
"the production environment",
{ vars: { PUSH_RELAY_URL: "https://relay.test" }, env: {} },
{ vars: completeVars, env: {} },
/production environment: PUSH_RELAY_URL/,
],
[
"an empty value",
{ vars: { PUSH_RELAY_URL: " " }, env: { production: { vars: { PUSH_RELAY_URL: "https://relay.test" } } } },
{ vars: { ...completeVars, PUSH_RELAY_URL: " " }, env: { production: { vars: completeVars } } },
/default environment: PUSH_RELAY_URL/,
],
])("fails when PUSH_RELAY_URL is missing from %s", (_label, config, expected) => {
expect(() => verify({ config })).toThrow(expected as RegExp);
});

it("accepts the committed wrangler.jsonc", () => {
it("fails when WEB_CLIENT_ORIGIN is missing", () => {
// No code default: without it the Worker emits no
// access-control-allow-origin, so the browser client is blocked outright.
const vars = { ...completeVars, WEB_CLIENT_ORIGIN: "" };
expect(() => verify({ config: { vars, env: { production: { vars } } } }))
.toThrow(/default environment: WEB_CLIENT_ORIGIN/);
});

it("warns instead of failing for vars that have code defaults", () => {
// DIAGNOSTICS_DAILY_GLOBAL_LIMIT falls back to 400 and ONLINE_WINDOW_MS to
// 90000, both equal to the committed values, so their absence changes no
// behavior — the diagnostics cost ceiling still applies. Blocking a deploy
// on them would be a false gate.
const vars = {
PUSH_RELAY_URL: "https://relay.test",
WEB_CLIENT_ORIGIN: "https://app.test",
};
const result = verify({ config: { vars, env: { production: { vars } } } });
expect(result.warnings).toEqual([
expect.stringContaining("ONLINE_WINDOW_MS is not set for the default environment"),
expect.stringContaining("DIAGNOSTICS_DAILY_GLOBAL_LIMIT is not set for the default environment"),
expect.stringContaining("ONLINE_WINDOW_MS is not set for the production environment"),
expect.stringContaining("DIAGNOSTICS_DAILY_GLOBAL_LIMIT is not set for the production environment"),
]);
});

it("warns when a defaulted var is set to something the Worker cannot parse", () => {
// Number("unlimited") is NaN, so the Worker silently uses 400 while the
// config reads as configured.
const vars = { ...completeVars, DIAGNOSTICS_DAILY_GLOBAL_LIMIT: "unlimited" };
const result = verify({ environments: ["production"], config: { vars, env: { production: { vars } } } });
expect(result.warnings).toEqual([
expect.stringContaining("DIAGNOSTICS_DAILY_GLOBAL_LIMIT for the production environment is not a non-negative number"),
]);
});

it("accepts the committed wrangler.jsonc with no warnings", () => {
const config = parseJsonc(readFileSync(wranglerConfigPath, "utf8"));
expect(() => verify({ config })).not.toThrow();
expect(verify({ config })).toEqual({ warnings: [] });
});
});

Expand Down
9 changes: 7 additions & 2 deletions apps/ade-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
buildReportIssuePayload,
describeDiagnosticUpload,
openDiagnosticIssue,
saveDiagnosticReportCopy,
sendDiagnosticReport,
} from "./commands/reportIssue";
import { redactDiagnosticText } from "./services/diagnostics/diagnosticReport";
Expand Down Expand Up @@ -22623,20 +22624,24 @@ async function runCli(
// opening it without copying first sends them to a form with nothing to
// paste. Both steps are best effort and the report is printed regardless.
const openedIssue = plan.open ? await openDiagnosticIssue(built) : null;
// Saved BEFORE the upload is attempted, so the path printed under a
// failure is a file that already exists rather than one written after we
// knew we needed it.
const savedPath = plan.send ? saveDiagnosticReportCopy(built, { surface: "cli" }) : null;
// Sending is opt-in and never blocks the printed report: a failed upload
// still leaves the user holding everything they need to file by hand.
const sent = plan.send ? await sendDiagnosticReport(built) : null;
if (parsed.options.text) {
const clipboardNote = openedIssue?.copied ? "\n(the report is on your clipboard)" : "";
const sendNote = sent ? `\n${describeDiagnosticUpload(sent)}` : "";
const sendNote = sent ? `\n${describeDiagnosticUpload(sent, savedPath)}` : "";
return {
output: `${built.report}\nFile the issue at:\n${built.issueUrl}${clipboardNote}${sendNote}\n`,
exitCode: 0,
};
}
return {
output: formatOutput(
buildReportIssuePayload(built, openedIssue, sent),
buildReportIssuePayload(built, openedIssue, sent, savedPath),
parsed.options,
undefined,
),
Expand Down
21 changes: 18 additions & 3 deletions apps/ade-cli/src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,18 @@ export type DoctorInput = {
diagnostics?: DoctorDiagnosticsSharing | null;
};

/** What the shared auto-diagnostics ledger says, verbatim. */
export type DoctorDiagnosticsSharing = ReturnType<typeof readAutoDiagnosticsState>;
/**
* What the shared auto-diagnostics ledger says about AUTOMATIC sharing.
*
* Narrowed to the three fields this row reads rather than the ledger's whole
* view: the ledger also reports the separate manual-send budget, and `ade
* doctor` is a health check for what the machine does on its own — a report a
* person deliberately asked for is not a diagnostic about the machine.
*/
export type DoctorDiagnosticsSharing = Pick<
ReturnType<typeof readAutoDiagnosticsState>,
"enabled" | "sendsInWindow" | "limit"
>;

export type DoctorCommandOptions = {
role: "cto" | "orchestrator" | "agent" | "external" | "evaluator";
Expand Down Expand Up @@ -895,7 +905,12 @@ export function readAutoDiagnosticsSharingForDoctor(
env: NodeJS.ProcessEnv = process.env,
): DoctorDiagnosticsSharing | null {
try {
return readAutoDiagnosticsState(resolveAutoDiagnosticsStateFile(adeDir, env));
// Projected down to the automatic budget rather than handed over whole:
// the ledger also tracks the separate manual-send budget, and this row is
// about what the machine does on its own.
const { enabled, sendsInWindow, limit } =
readAutoDiagnosticsState(resolveAutoDiagnosticsStateFile(adeDir, env));
return { enabled, sendsInWindow, limit };
} catch {
return null;
}
Expand Down
Loading
Loading