Skip to content

Commit 5fcdfd8

Browse files
IAM-marcoclaude
andcommitted
feat: add a doctor check for the owning team
Reports whether the project is attached to a team, and surfaces `zitadel claim` in the advisory when it is not. Always a warning, never a failure. An unattached project works exactly like an attached one, same issuer, same users, same applications, so the only thing missing is durability and that is the developer's call rather than a defect. It matters mechanically too: `doctor` turns any `fail` into a thrown `E_VALIDATION`, so failing here would break every scripted `zitadel doctor` run against a project nobody had claimed yet. Implements `SanityCheck` directly rather than extending the abstract base, for the same reason `ManagedFilesCheck` does: the base's verify-throws contract can only express pass or fail. That also means nothing wraps a throw from `run`, so an unreadable secret or config is caught and reported as a skip. Otherwise a missing `.zitadel/secret` would crash the whole battery on a nudge and hide the `secret` check that actually reports the problem, with its repair path. `fix` stays the inherited no-op. `doctor --fix` calls it on every non-passing check, but a claim needs a human to sign in through a browser, so there is nothing safe to automate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent e9af02a commit 5fcdfd8

5 files changed

Lines changed: 243 additions & 2 deletions

File tree

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { claimState, type ClaimState } from "../../../lib/claim-state";
2+
import { readProjectServer, readZitadelConfig, readZitadelSecret } from "../../../lib/project";
3+
import type { CheckContext, CheckOutcome, SanityCheck } from "./types";
4+
5+
/**
6+
* Reports whether the project is attached to a team, reading `claimed_at` and
7+
* `team_id` from `.zitadel/secret`.
8+
*
9+
* Always advisory, never a failure. An unattached project works exactly like an
10+
* attached one — same issuer, same users, same applications — so the only thing
11+
* missing is durability, which is the developer's call and not a defect. That
12+
* matters mechanically too: `doctor` turns any `fail` into a thrown
13+
* `E_VALIDATION`, so failing here would break every scripted `zitadel doctor`
14+
* against a project nobody had claimed yet.
15+
*
16+
* Implements {@link SanityCheck} directly rather than via the abstract base,
17+
* for the same reason `ManagedFilesCheck` does: the base's `verify`-throws
18+
* contract can only express pass/fail, and this check is only ever warn or
19+
* pass.
20+
*/
21+
export class ClaimCheck implements SanityCheck {
22+
readonly name = "claim";
23+
readonly path = ".zitadel/secret";
24+
25+
async run(ctx: CheckContext): Promise<CheckOutcome> {
26+
let state;
27+
try {
28+
state = claimState({
29+
secret: await readZitadelSecret(ctx.cwd),
30+
// From `zitadel.json`, not the command's own source: `doctor` pins its
31+
// source to the local runtime URL, so asking it where the project lives
32+
// would report every project as local.
33+
server: readProjectServer(await readZitadelConfig(ctx.cwd)),
34+
});
35+
} catch {
36+
// A missing or unparseable secret/config is already reported, loudly and
37+
// with a repair path, by the `secret` and `config` checks. Implementing
38+
// `SanityCheck` directly means nothing wraps a throw here, so swallowing
39+
// it is what keeps a broken project reporting its real problem instead of
40+
// crashing the whole battery on a nudge.
41+
return {
42+
name: this.name,
43+
status: "pass",
44+
message: "Skipped: could not read the project files that record the owning team",
45+
path: this.path,
46+
};
47+
}
48+
49+
if (state.kind === "detached") {
50+
return {
51+
name: this.name,
52+
status: "warn",
53+
message:
54+
"This project is temporary until you attach it to a team. Run `zitadel claim` " +
55+
"to make it permanent; nothing about the project changes.",
56+
path: this.path,
57+
};
58+
}
59+
60+
return {
61+
name: this.name,
62+
status: "pass",
63+
message:
64+
state.kind === "attached"
65+
? `Project is attached to team ${state.team_id}`
66+
: // Local and self-hosted servers have no team to attach to, so the
67+
// check passes rather than warning about an impossible action.
68+
"Project is not on a server where teams apply",
69+
path: this.path,
70+
};
71+
}
72+
73+
/**
74+
* Deliberately a no-op. `doctor --fix` calls `fix` on every non-passing
75+
* check, but claiming requires a human to sign in through a browser, so there
76+
* is nothing safe to automate here.
77+
*/
78+
async fix(_ctx: CheckContext): Promise<void> {
79+
return;
80+
}
81+
}

apps/cli/src/commands/doctor/checks/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { DependencyCheck } from "./dependency";
1515
import { ManagedFilesCheck } from "./managed-files";
1616
import { ProjectMatchCheck } from "./project-match";
1717
import { SchemaCheck } from "./schema";
18+
import { ClaimCheck } from "./claim";
1819

1920
export type { SanityCheck, CheckContext, CheckOutcome } from "./types";
2021
export { AbstractSanityCheck } from "./types";
@@ -28,6 +29,7 @@ export { SchemaCheck } from "./schema";
2829
export { DependencyCheck } from "./dependency";
2930
export { ManagedFilesCheck } from "./managed-files";
3031
export { ProjectMatchCheck } from "./project-match";
32+
export { ClaimCheck } from "./claim";
3133

3234
/** Every diagnostic the `doctor` command runs, in display order. */
3335
export const SANITY_CHECKS: ReadonlyArray<SanityCheck> = [
@@ -41,4 +43,5 @@ export const SANITY_CHECKS: ReadonlyArray<SanityCheck> = [
4143
new DependencyCheck(),
4244
new ManagedFilesCheck(),
4345
new ProjectMatchCheck(),
46+
new ClaimCheck(),
4447
];

apps/cli/src/commands/doctor/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Flags } from "@oclif/core";
22
import consola from "consola";
33

4+
import { claimAction, claimCommand } from "../../lib/claim-state";
45
import { ZitadelError } from "../../lib/errors";
56
import { assertServerPackageAvailable } from "../../lib/local-server/binary";
67
import { dockerAvailable, imageAvailable } from "../../lib/local-server/docker";
@@ -259,6 +260,11 @@ function advisoryForWarnings(
259260
nextCommands.push(...advice.nextCommands);
260261
}
261262

263+
if (warnings.some((check) => check.name === "claim")) {
264+
nextActions.push(claimAction(cliVersion));
265+
nextCommands.push(claimCommand(cliVersion));
266+
}
267+
262268
const managedRuntimeWarning = warnings.find((check) => check.name === "managed-runtime-processes");
263269
if (hasManagedRuntimeProcesses(managedRuntimeWarning)) {
264270
nextActions.push(

apps/cli/tests/unit/commands/doctor.test.ts

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { join } from "node:path";
66
import { afterEach, describe, expect, it } from "vitest";
77

88
import { MANAGED_MARKER } from "../../../src/lib/paths";
9-
import { parseJson, runCliForTest } from "../../helpers/run-cli";
9+
import { expectedPublicCliCommand, parseJson, runCliForTest } from "../../helpers/run-cli";
1010

1111
type Check = { name: string; status: "pass" | "warn" | "fail"; message: string; path?: string };
1212

@@ -46,7 +46,9 @@ async function doctor(cwd: string, extra: string[] = []) {
4646
* Builds a well-formed managed project that should pass every doctor check
4747
* runnable without the platform: config/secret parse + match, 0600 secret,
4848
* gitignore + env.example coverage, a Next.js framework signature, a valid
49-
* user schema, and a Zitadel SDK dependency.
49+
* user schema, a Zitadel SDK dependency, and an owning team recorded by
50+
* `zitadel claim` (without it the claim check warns, which is its own test
51+
* below).
5052
*/
5153
async function makeHealthyProject(): Promise<string> {
5254
const cwd = await mkdtemp(join(tmpdir(), "zitadel-doctor-"));
@@ -80,6 +82,8 @@ async function makeHealthyProject(): Promise<string> {
8082
preview_secret: "sk_proj_preview",
8183
preview_origins: [],
8284
created_at: "2026-01-01T00:00:00.000Z",
85+
claimed_at: "2026-01-02T00:00:00.000Z",
86+
team_id: "team-001",
8387
}),
8488
);
8589
await chmod(join(cwd, ".zitadel/secret"), 0o600);
@@ -112,6 +116,24 @@ async function makeHealthyProject(): Promise<string> {
112116
return cwd;
113117
}
114118

119+
/**
120+
* Rewrites the secret without `claimed_at`/`team_id`, i.e. a project that has
121+
* been set up but never claimed — the state every project starts in.
122+
*/
123+
async function writeDetachedSecret(cwd: string): Promise<void> {
124+
await writeFile(
125+
join(cwd, ".zitadel/secret"),
126+
JSON.stringify({
127+
project_id: "proj-001",
128+
project_secret: "sk_proj_test",
129+
preview_secret: "sk_proj_preview",
130+
preview_origins: [],
131+
created_at: "2026-01-01T00:00:00.000Z",
132+
}),
133+
);
134+
await chmod(join(cwd, ".zitadel/secret"), 0o600);
135+
}
136+
115137
afterEach(async () => {
116138
for (const server of servers.splice(0)) {
117139
await new Promise<void>((resolve) => server.close(() => resolve()));
@@ -143,11 +165,74 @@ describe("doctor command", () => {
143165
expect(names).toContain("secret");
144166
expect(names).toContain("dependency");
145167
expect(names).toContain("project-match");
168+
expect(names).toContain("claim");
146169
expect(names).not.toContain("managed-login");
147170
expect(names).not.toContain("managed-register");
148171
expect(names).not.toContain("managed-middleware");
149172
});
150173

174+
// The nudge has to stay advisory: doctor throws E_VALIDATION on any `fail`,
175+
// so failing here would break every scripted `zitadel doctor` run against a
176+
// project nobody has attached to a team yet.
177+
it("warns (but passes) when the project is not attached to a team, and points at claim", async () => {
178+
const cwd = await makeHealthyProject();
179+
await writeDetachedSecret(cwd);
180+
181+
const res = await doctor(cwd);
182+
183+
expect(res.exitCode).toBe(0);
184+
const json = parseJson(res.stdout) as {
185+
status: string;
186+
data: { ok: boolean; checks: Check[]; next_commands?: string[] };
187+
};
188+
expect(json.status).toBe("ok");
189+
expect(json.data.ok).toBe(true);
190+
const claim = json.data.checks.find((check) => check.name === "claim");
191+
expect(claim?.status).toBe("warn");
192+
expect(claim?.message).toContain("temporary until you attach it to a team");
193+
expect(json.data.next_commands).toContain(expectedPublicCliCommand("claim"));
194+
});
195+
196+
// Claiming needs a human in a browser, so --fix has nothing safe to do. The
197+
// warning has to survive it rather than being silently "repaired".
198+
it("leaves the claim warning (and the secret) alone under --fix", async () => {
199+
const cwd = await makeHealthyProject();
200+
await writeDetachedSecret(cwd);
201+
const before = await readFile(join(cwd, ".zitadel/secret"), "utf8");
202+
203+
const res = await doctor(cwd, ["--fix"]);
204+
205+
expect(res.exitCode).toBe(0);
206+
const json = parseJson(res.stdout) as { data: { ok: boolean; checks: Check[] } };
207+
expect(json.data.checks.find((check) => check.name === "claim")?.status).toBe("warn");
208+
expect(await readFile(join(cwd, ".zitadel/secret"), "utf8")).toBe(before);
209+
});
210+
211+
// A local project has no platform team to attach to, so the nudge must not
212+
// follow `zitadel setup --server local` around forever.
213+
it("passes the claim check without a nudge for a local project", async () => {
214+
const cwd = await makeHealthyProject();
215+
await writeDetachedSecret(cwd);
216+
await writeFile(
217+
join(cwd, "zitadel.json"),
218+
JSON.stringify({
219+
project: "proj-001",
220+
server: "http://localhost:8080",
221+
framework: { id: "next" },
222+
environments: { development: { issuer: "http://localhost:3000" } },
223+
}),
224+
);
225+
226+
const res = await doctor(cwd);
227+
228+
expect(res.exitCode).toBe(0);
229+
const json = parseJson(res.stdout) as {
230+
data: { checks: Check[]; next_commands?: string[] };
231+
};
232+
expect(json.data.checks.find((check) => check.name === "claim")?.status).toBe("pass");
233+
expect(json.data.next_commands ?? []).not.toContain(expectedPublicCliCommand("claim"));
234+
});
235+
151236
it("warns (but passes) when .zitadel/schemas is empty — legacy or interrupted projects", async () => {
152237
const cwd = await makeHealthyProject();
153238
await rm(join(cwd, ".zitadel/schemas/user.json"));

apps/cli/tests/unit/commands/doctor/checks.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { join } from "node:path";
55
import { afterEach, describe, expect, it } from "vitest";
66

77
import {
8+
ClaimCheck,
89
ConfigCheck,
910
DependencyCheck,
1011
EnvExampleCheck,
@@ -941,6 +942,71 @@ describe("ProjectMatchCheck", () => {
941942
});
942943
});
943944

945+
describe("ClaimCheck", () => {
946+
/** Rewrites `.zitadel/secret`, preserving the 0600 mode doctor asserts elsewhere. */
947+
async function writeSecret(cwd: string, extra: Record<string, unknown>): Promise<void> {
948+
await writeFile(join(cwd, ".zitadel/secret"), JSON.stringify({ ...SECRET, ...extra }));
949+
await chmod(join(cwd, ".zitadel/secret"), 0o600);
950+
}
951+
952+
/** Rewrites `zitadel.json` with a different `server`, leaving everything else intact. */
953+
async function writeServer(cwd: string, server: string): Promise<void> {
954+
const config = JSON.parse(await readFile(join(cwd, "zitadel.json"), "utf8")) as Record<
955+
string,
956+
unknown
957+
>;
958+
await writeFile(join(cwd, "zitadel.json"), JSON.stringify({ ...config, server }));
959+
}
960+
961+
it("warns for a cloud project with no owning team", async () => {
962+
const cwd = await makeProject();
963+
const outcome = await new ClaimCheck().run(ctxFor(cwd));
964+
expect(outcome.status).toBe("warn");
965+
expect(outcome.message).toContain("temporary until you attach it to a team");
966+
});
967+
968+
it("passes and names the team once attached", async () => {
969+
const cwd = await makeProject();
970+
await writeSecret(cwd, { claimed_at: "2026-01-02T00:00:00.000Z", team_id: "team-001" });
971+
const outcome = await new ClaimCheck().run(ctxFor(cwd));
972+
expect(outcome.status).toBe("pass");
973+
expect(outcome.message).toContain("team-001");
974+
});
975+
976+
// Local and self-hosted servers have no platform team, so there is nothing to
977+
// nudge toward and a warning would be permanent noise.
978+
it("passes without a nudge off the cloud", async () => {
979+
for (const server of ["http://localhost:8080", "https://zitadel.example.com"]) {
980+
const cwd = await makeProject();
981+
await writeServer(cwd, server);
982+
const outcome = await new ClaimCheck().run(ctxFor(cwd));
983+
expect(outcome.status).toBe("pass");
984+
expect(outcome.message).not.toContain("temporary");
985+
}
986+
});
987+
988+
// Nothing wraps a throw from this check (it implements SanityCheck directly),
989+
// so an unreadable secret must not take the whole battery down with it — the
990+
// `secret` check is the one that reports that problem.
991+
it("skips instead of throwing when the secret cannot be read", async () => {
992+
const cwd = await makeProject();
993+
await rm(join(cwd, ".zitadel/secret"));
994+
const outcome = await new ClaimCheck().run(ctxFor(cwd));
995+
expect(outcome.status).toBe("pass");
996+
expect(outcome.message).toContain("Skipped");
997+
});
998+
999+
// `doctor --fix` calls fix() on every non-passing check. Claiming needs a
1000+
// browser, so this one must be inert rather than half-writing the record.
1001+
it("has no automatic repair", async () => {
1002+
const cwd = await makeProject();
1003+
const before = await readFile(join(cwd, ".zitadel/secret"), "utf8");
1004+
await new ClaimCheck().fix(ctxFor(cwd));
1005+
expect(await readFile(join(cwd, ".zitadel/secret"), "utf8")).toBe(before);
1006+
expect((await new ClaimCheck().run(ctxFor(cwd))).status).toBe("warn");
1007+
});
1008+
});
1009+
9441010
describe("loadPatchContext", () => {
9451011
it("reconstructs the patch context from on-disk project files", async () => {
9461012
const cwd = await makeProject();

0 commit comments

Comments
 (0)