From f2b4e2c5a70dce63c5614a13bcefc3e4c950563f Mon Sep 17 00:00:00 2001 From: Florian Forster Date: Sun, 2 Aug 2026 18:08:15 +0200 Subject: [PATCH 1/5] feat: enforce framework version floors in setup and doctor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 043: the supported floors are Next.js 15+ and React 18+. The gate lives in the framework detectors, so setup refuses a below-floor app before any mutation and doctor's framework check fails on one — both with an explicit E_UNSUPPORTED_PROJECT_SHAPE naming the floor and an upgrade hint, never a silent narrowing. Unparseable version specs pass (no provable violation). @zitadel/sdk-next's peer range follows the floor (next >=15). --- .changeset/framework-version-floors.md | 6 ++ apps/cli/SKILLS.md | 5 +- apps/cli/src/lib/orca/detectors/next.ts | 16 ++++- apps/cli/src/lib/orca/detectors/react.ts | 23 +++++++- apps/cli/tests/integration/setup-next.test.ts | 18 +++++- .../tests/unit/commands/doctor/checks.test.ts | 14 +++++ .../unit/lib/orca/detectors/next.test.ts | 31 +++++++++- .../unit/lib/orca/detectors/react.test.ts | 25 ++++++++ docs/adrs/043-framework-version-floors.md | 59 +++++++++++++++++++ docs/adrs/README.md | 1 + packages/sdk-next/package.json | 2 +- 11 files changed, 190 insertions(+), 10 deletions(-) create mode 100644 .changeset/framework-version-floors.md create mode 100644 docs/adrs/043-framework-version-floors.md diff --git a/.changeset/framework-version-floors.md b/.changeset/framework-version-floors.md new file mode 100644 index 000000000..c3eae4192 --- /dev/null +++ b/.changeset/framework-version-floors.md @@ -0,0 +1,6 @@ +--- +"@zitadel/cli": minor +"@zitadel/sdk-next": minor +--- + +The CLI now enforces the supported framework floors — Next.js 15+ and React 18+ (ADR 043): `setup` refuses a below-floor app before any mutation and `doctor`'s framework check fails on one, both with an explicit `E_UNSUPPORTED_PROJECT_SHAPE` error naming the floor and an upgrade hint; unparseable version specs still pass. `@zitadel/sdk-next`'s peer range follows the floor (`next >=15`). diff --git a/apps/cli/SKILLS.md b/apps/cli/SKILLS.md index 254e9253d..97e86e07f 100644 --- a/apps/cli/SKILLS.md +++ b/apps/cli/SKILLS.md @@ -85,7 +85,10 @@ the CLI's help layer, not the envelope. `.zitadel/flows/default-login.json`, uploads them through the schema and flow APIs, then seeds `.zitadel/state.json` so `plan` is immediately empty. Agents must pass `--framework` when scaffolding into a fresh directory; interactive - humans can omit it and choose from the prompt. Flags: + humans can omit it and choose from the prompt. Supported floors: Next.js 15+ + and React 18+ — `setup` and `doctor` fail with `E_UNSUPPORTED_PROJECT_SHAPE` + below them instead of degrading silently (an unparseable version passes). + Flags: `--framework next|react|vue|angular|nuxt|solid|svelte|qwik`, `--renderer react` (selects the Next.js auth-page renderer; accepted for any framework and recorded in `zitadel.json` branding, but only Next varies its generated diff --git a/apps/cli/src/lib/orca/detectors/next.ts b/apps/cli/src/lib/orca/detectors/next.ts index 317ef31ba..af6adaffa 100644 --- a/apps/cli/src/lib/orca/detectors/next.ts +++ b/apps/cli/src/lib/orca/detectors/next.ts @@ -28,6 +28,20 @@ export class NextDetector implements Detector { return null; } + // Supported floor (ADR 043): the scaffolded templates target Next 15+ + // (React 19 property binding, current boundary conventions). The floor is + // enforced here so setup and doctor share one loud gate instead of + // degrading silently; an unparseable version spec passes (cannot prove a + // violation), matching this detector's other tolerances. + const versionMajor = dependencyVersionMajor(pkg, "next"); + if (versionMajor !== undefined && versionMajor < 15) { + throw new ZitadelError( + "E_UNSUPPORTED_PROJECT_SHAPE", + `Next.js ${versionMajor} is below the supported floor — the CLI integrates Next.js 15 and newer`, + { hint: "Upgrade the app to Next 15+ (e.g. `npx @next/codemod@latest upgrade`) and rerun." }, + ); + } + const appDir = (await dirExists(join(cwd, "app"))) ? "app" : (await dirExists(join(cwd, "src/app"))) @@ -47,7 +61,7 @@ export class NextDetector implements Detector { appDir, devPort, url: issuerFromPort(devPort), - versionMajor: dependencyVersionMajor(pkg, "next"), + ...(versionMajor === undefined ? {} : { versionMajor }), }; } } diff --git a/apps/cli/src/lib/orca/detectors/react.ts b/apps/cli/src/lib/orca/detectors/react.ts index 7806f03cd..c997f0162 100644 --- a/apps/cli/src/lib/orca/detectors/react.ts +++ b/apps/cli/src/lib/orca/detectors/react.ts @@ -1,4 +1,5 @@ -import { hasDependency, readPackageJson } from "./package-json"; +import { ZitadelError } from "../../errors"; +import { dependencyVersionMajor, hasDependency, readPackageJson } from "./package-json"; import { detectDevPort, issuerFromPort } from "./port"; import type { Detector, FrameworkFacts } from "./types"; @@ -25,7 +26,25 @@ export class ReactDetector implements Detector { return null; } + // Supported floor (ADR 043): the SDK wrappers and scaffolded templates + // target React 18+. Enforced here so setup and doctor share one loud + // gate; an unparseable version spec passes (cannot prove a violation). + const versionMajor = dependencyVersionMajor(pkg, "react"); + if (versionMajor !== undefined && versionMajor < 18) { + throw new ZitadelError( + "E_UNSUPPORTED_PROJECT_SHAPE", + `React ${versionMajor} is below the supported floor — the CLI integrates React 18 and newer`, + { hint: "Upgrade the app to React 18+ and rerun." }, + ); + } + const devPort = await detectDevPort(cwd, pkg); - return { id: "react", appDir: "src", devPort, url: issuerFromPort(devPort) }; + return { + id: "react", + appDir: "src", + devPort, + url: issuerFromPort(devPort), + ...(versionMajor === undefined ? {} : { versionMajor }), + }; } } diff --git a/apps/cli/tests/integration/setup-next.test.ts b/apps/cli/tests/integration/setup-next.test.ts index 7791d7b09..932e0198e 100644 --- a/apps/cli/tests/integration/setup-next.test.ts +++ b/apps/cli/tests/integration/setup-next.test.ts @@ -352,6 +352,20 @@ describe("Next setup integration", () => { expect((parseJson(planAfterApply.stdout) as { data: { total: number } }).data.total).toBe(0); }); + it("refuses setup below the Next 15 floor with an explicit error", async () => { + const cwd = await createNextProject("^14.2.0"); + const setup = await cli(["setup", "--cwd", cwd, "--non-interactive", "--json", "--skip-install"]); + // ADR 043: unsupported versions are a loud gate, never a silent + // narrowing — the envelope carries the machine code and the floor. + expect(setup.exitCode).toBe(3); + const envelope = parseJson(setup.stdout) as { status: string; code: string; message: string }; + expect(envelope.status).toBe("error"); + expect(envelope.code).toBe("E_UNSUPPORTED_PROJECT_SHAPE"); + expect(envelope.message).toContain("below the supported floor"); + // The gate fires at detection, before any project or file mutation. + await expect(stat(join(cwd, "zitadel.json"))).rejects.toThrow(); + }); + it("skips rerun setup without rewriting edited schema or flow config", async () => { const cwd = await createNextProject(); const setup = await cli(["setup", "--cwd", cwd, "--non-interactive", "--json", "--skip-install"]); @@ -589,7 +603,7 @@ describe("Next setup integration", () => { }); -async function createNextProject(): Promise { +async function createNextProject(nextVersion = "^16.0.0"): Promise { const cwd = await mkdtemp(join(tmpdir(), "zitadel-next-")); await mkdir(join(cwd, "app"), { recursive: true }); await writeFile( @@ -599,7 +613,7 @@ async function createNextProject(): Promise { name: "demo-next-app", private: true, dependencies: { - next: "^16.0.0", + next: nextVersion, react: "^19.0.0", "react-dom": "^19.0.0", }, diff --git a/apps/cli/tests/unit/commands/doctor/checks.test.ts b/apps/cli/tests/unit/commands/doctor/checks.test.ts index 5c0b14f64..4040b9815 100644 --- a/apps/cli/tests/unit/commands/doctor/checks.test.ts +++ b/apps/cli/tests/unit/commands/doctor/checks.test.ts @@ -307,6 +307,20 @@ describe("FrameworkCheck", () => { ); expect((await new FrameworkCheck().run(ctxFor(cwd))).status).toBe("fail"); }); + + it("fails with the version floor when the app dropped below Next 15", async () => { + // A scaffolded app later downgraded (or a pre-floor scaffold): doctor + // must say why the integration is unsupported, not crash or pass. + const cwd = await makeProject(); + const pkg = JSON.parse(await readFile(join(cwd, "package.json"), "utf8")) as { + dependencies: Record; + }; + pkg.dependencies.next = "^14.2.0"; + await writeFile(join(cwd, "package.json"), JSON.stringify(pkg)); + const outcome = await new FrameworkCheck().run(ctxFor(cwd)); + expect(outcome.status).toBe("fail"); + expect(outcome.message).toContain("below the supported floor"); + }); }); describe("SchemaCheck", () => { diff --git a/apps/cli/tests/unit/lib/orca/detectors/next.test.ts b/apps/cli/tests/unit/lib/orca/detectors/next.test.ts index 842a75a44..b974e70cc 100644 --- a/apps/cli/tests/unit/lib/orca/detectors/next.test.ts +++ b/apps/cli/tests/unit/lib/orca/detectors/next.test.ts @@ -21,7 +21,7 @@ afterEach(async () => { async function writeNextPackageJson(extra: Record = {}): Promise { await writeFile( join(dir, "package.json"), - JSON.stringify({ name: "demo", dependencies: { next: "14.0.0" }, ...extra }), + JSON.stringify({ name: "demo", dependencies: { next: "15.0.0" }, ...extra }), ); } @@ -38,7 +38,7 @@ describe("NextDetector", () => { appDir: "app", devPort: 3000, url: "http://localhost:3000", - versionMajor: 14, + versionMajor: 15, }); }); @@ -56,7 +56,7 @@ describe("NextDetector", () => { }); it("recognizes next as a devDependency", async () => { - await writeFile(join(dir, "package.json"), JSON.stringify({ devDependencies: { next: "14.0.0" } })); + await writeFile(join(dir, "package.json"), JSON.stringify({ devDependencies: { next: "15.0.0" } })); await mkdir(join(dir, "app")); expect(await detector.detect(dir)).toMatchObject({ id: "next" }); }); @@ -96,5 +96,30 @@ describe("NextDetector", () => { } expect(caught).toBeInstanceOf(ZitadelError); expect((caught as ZitadelError).code).toBe("E_UNSUPPORTED_PROJECT_SHAPE"); + expect((caught as ZitadelError).message).toContain("Pages Router"); + }); + + it("throws the version floor for Next 14, before the app-router shape check", async () => { + // No app/ dir on purpose: the floor is the more fundamental cut, so the + // user is not told to restructure to the App Router only to then hit + // the version wall (ADR 043). + await writeNextPackageJson({ dependencies: { next: "^14.2.0" } }); + let caught: unknown; + try { + await detector.detect(dir); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(ZitadelError); + expect((caught as ZitadelError).code).toBe("E_UNSUPPORTED_PROJECT_SHAPE"); + expect((caught as ZitadelError).message).toContain("below the supported floor"); + }); + + it("lets an unparseable next version through the floor", async () => { + // "latest" carries no provable major; blocking on it would be hostile. + await writeNextPackageJson({ dependencies: { next: "latest" } }); + await mkdir(join(dir, "app")); + expect(await detector.detect(dir)).toMatchObject({ id: "next" }); + expect(await detector.detect(dir)).not.toHaveProperty("versionMajor"); }); }); diff --git a/apps/cli/tests/unit/lib/orca/detectors/react.test.ts b/apps/cli/tests/unit/lib/orca/detectors/react.test.ts index 810898eda..990f706f5 100644 --- a/apps/cli/tests/unit/lib/orca/detectors/react.test.ts +++ b/apps/cli/tests/unit/lib/orca/detectors/react.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { ReactDetector } from "../../../../../src/lib/orca/detectors/react"; +import { ZitadelError } from "../../../../../src/lib/errors"; const dirs: string[] = []; @@ -41,4 +42,28 @@ describe("ReactDetector", () => { it("returns null without react", async () => { expect(await new ReactDetector().detect(await project({ vite: "^5" }))).toBeNull(); }); + + it("captures the React major version", async () => { + const facts = await new ReactDetector().detect(await project({ react: "^19.1.0", vite: "^5" })); + expect(facts?.versionMajor).toBe(19); + }); + + it("throws the version floor for React 17", async () => { + let caught: unknown; + try { + await new ReactDetector().detect(await project({ react: "^17.0.2", vite: "^5" })); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(ZitadelError); + expect((caught as ZitadelError).code).toBe("E_UNSUPPORTED_PROJECT_SHAPE"); + expect((caught as ZitadelError).message).toContain("below the supported floor"); + }); + + it("lets an unparseable react version through the floor", async () => { + // "latest" carries no provable major; blocking on it would be hostile. + const facts = await new ReactDetector().detect(await project({ react: "latest", vite: "^5" })); + expect(facts?.id).toBe("react"); + expect(facts).not.toHaveProperty("versionMajor"); + }); }); diff --git a/docs/adrs/043-framework-version-floors.md b/docs/adrs/043-framework-version-floors.md new file mode 100644 index 000000000..4a1df59de --- /dev/null +++ b/docs/adrs/043-framework-version-floors.md @@ -0,0 +1,59 @@ +# ADR 043: Framework Version Floors + +> **Status:** Accepted +> **Date:** 2026-08-02 +> **Context:** The `zitadel` CLI's framework detectors, `setup`/`doctor`, and the `@zitadel/sdk-next` peer range. +> **Relates to:** [ADR 042](042-scaffolded-file-ownership-and-drift-detection.md) + +## Context + +The CLI's scaffolded templates make version-specific assumptions that the +declared support ranges did not: `@zitadel/sdk-next` claimed `next >=14` / +`react >=18` while the Next templates target current App Router conventions +(the `middleware.ts`/`proxy.ts` boundary handling starts at 15, and React +binds non-primitive custom-element props as properties only from 19 — the +business copy overlay had to move to a ref assignment to stay correct on 18). +Nothing enforced any floor: the detectors record `versionMajor` without +judging it, so a Next 14 or React 17 app scaffolded "successfully" into a +subtly or visibly broken integration. + +Two review rounds probed this gap independently (Next 15 boundary handling on +the ADR 042 work; React 18 `locales` decay on the use-case templates), and +each time the answer was the same principle: a support floor must be an +explicit, loud error — never a silent narrowing hidden in template behavior. +This ADR records the floors and the enforcement point. + +## Decision + +- **Floors:** Next.js **15+** (App Router, as before) and React **18+** (both + 18 and 19 are supported; templates that need React-19-only behavior must + degrade safely on 18, as the ref-assigned copy overlay does). +- **Enforcement lives in the framework detectors** (`NextDetector`, + `ReactDetector`): a detected framework below its floor throws + `E_UNSUPPORTED_PROJECT_SHAPE` (exit 3) with the floor in the message and an + upgrade hint. Detection is the shared gate, so `setup` refuses before any + mutation and `doctor`'s framework check fails with the same message on an + app that was scaffolded earlier and later downgraded (or scaffolded before + the floor existed). The existing error code is reused deliberately: a + below-floor version is the same family as "Pages Router not supported", + and agents already branch on it. +- **Unparseable versions pass.** A spec like `latest` or a workspace range + carries no provable major; blocking on it would be hostile guessing. The + floor fires only on a provable violation. +- **Peer ranges follow the floor:** `@zitadel/sdk-next` declares `next >=15` + (its `react >=18` was already correct). Other frameworks currently declare + no floor; adding one later means extending its detector the same way and + amending this ADR. + +## Consequences + +- A Next 14 or React 17 (Vite) app gets one clear refusal at `setup` time + with an upgrade path, instead of a scaffold whose behavior quietly varies + by version. +- `doctor` tells the truth about downgraded or pre-floor projects: the + framework check fails with the floor message; per ADR 042's degradation + rules the managed-files check falls back to a warning rather than piling + on or crashing. +- Raising a floor (e.g. requiring React 19) is a product decision expressed + as a one-line detector change plus an ADR amendment — the mechanism makes + the silent-narrowing failure mode structurally unavailable. diff --git a/docs/adrs/README.md b/docs/adrs/README.md index eff7ad3c4..43260c376 100644 --- a/docs/adrs/README.md +++ b/docs/adrs/README.md @@ -48,3 +48,4 @@ This directory contains architecture decision records (ADRs) for nextgen. | [040](040-tenant-login-templates-editable-config.md) | Tenant Login Templates as Editable Config | Proposed | Login templates become an editable-config resource: immutable per-project branding revisions, latest-revision resolution on flow responses, CLI-authoritative LiquidJS validation with a lexical Go gate, sibling-`.liquid` local dialect, and an ejectable design catalog. | | [041](041-storage-statement-contract-tests.md) | Storage Statement Contract Tests | Accepted | Shared `stmttest` suites assert `AllStatements` behavior across dialects via build-tag registration and `forEachDialect`; dialect packages keep engine-specific tests. | | [042](042-scaffolded-file-ownership-and-drift-detection.md) | Scaffolded File Ownership and Drift Detection | Accepted | Scaffolded app files carry infrastructure/presentation classes recorded in a `.zitadel/state.json` manifest; `doctor` verifies them (missing infra fails, missing pages warn) and `--fix` restores missing files only, never overwriting edited or user-adopted ones. | +| [043](043-framework-version-floors.md) | Framework Version Floors | Accepted | Supported floors are Next.js 15+ and React 18+, enforced in the framework detectors so `setup` and `doctor` share one loud `E_UNSUPPORTED_PROJECT_SHAPE` gate; unparseable versions pass, and `@zitadel/sdk-next` peers follow the floor (`next >=15`). | diff --git a/packages/sdk-next/package.json b/packages/sdk-next/package.json index e17e12387..9b686aee9 100644 --- a/packages/sdk-next/package.json +++ b/packages/sdk-next/package.json @@ -53,7 +53,7 @@ "server-only": "^0.0.1" }, "peerDependencies": { - "next": ">=14", + "next": ">=15", "react": ">=18", "react-dom": ">=18" }, From 7d5c6ea965290077e56ba4d0caac439cf38f0968 Mon Sep 17 00:00:00 2001 From: Florian Forster Date: Sun, 2 Aug 2026 18:10:29 +0200 Subject: [PATCH 2/5] docs: propose scaffold posture defaults and copy-as-branding-revision ADRs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 044 (Proposed): scaffolded pages derive their embedding surface from the recorded fresh-vs-pre-existing hinge — fresh keeps variant="page", pre-existing apps get variant="widget" in a layout-neutral wrapper. ADR 045 (Proposed): audience copy overlays move from bundle-shipped presets wired per SDK into branding revisions resolved with the flow response; implementation stays fenced behind the templates-track milestone. --- ...044-scaffold-embedding-posture-defaults.md | 63 ++++++++++++++++++ ...045-copy-overlays-as-branding-revisions.md | 64 +++++++++++++++++++ docs/adrs/README.md | 2 + 3 files changed, 129 insertions(+) create mode 100644 docs/adrs/044-scaffold-embedding-posture-defaults.md create mode 100644 docs/adrs/045-copy-overlays-as-branding-revisions.md diff --git a/docs/adrs/044-scaffold-embedding-posture-defaults.md b/docs/adrs/044-scaffold-embedding-posture-defaults.md new file mode 100644 index 000000000..02d0db9c9 --- /dev/null +++ b/docs/adrs/044-scaffold-embedding-posture-defaults.md @@ -0,0 +1,63 @@ +# ADR 044: Scaffold Embedding Posture Defaults + +> **Status:** Proposed +> **Date:** 2026-08-02 +> **Context:** The `zitadel` CLI's scaffolded auth pages and the ``/`` `variant` surface contract. +> **Relates to:** [ADR 042](042-scaffolded-file-ownership-and-drift-detection.md) + +## Context + +The widgets carry a two-value surface contract: `variant="page"` paints the +full-page chrome (viewport height, surface background from design tokens), +`variant="widget"` renders an embeddable card that inherits the host page's +layout. Since the session card went widget-first, every scaffolded page pins +`variant="page"` explicitly, and the generated pages name the widget +alternative in a comment. + +That single default fits only half the audience. `setup` already +distinguishes the two cases and records the distinction (`scaffolded_framework` +in the scaffold manifest, ADR 042): + +- **Fresh scaffold** — the CLI created the app skeleton. There is no design to + respect; a full-page auth surface is the strongest start, and the homepage + already redirects to `/login` on the same reasoning. +- **Pre-existing app** — the app has its own shell, theme, and navigation. A + generated page that takes over the viewport with token-colored chrome + fights the host design; the agent-evaluation friction log hit exactly this + (the demo shop rebuilt the generated pages around the widget surface by + hand). + +## Decision (proposed) + +Scaffolded auth and profile pages derive their default posture from the same +hinge the homepage uses: + +- Fresh scaffold (`scaffolded_framework: true`) → pages pin + `variant="page"` — unchanged from today. +- Pre-existing app → pages pin `variant="widget"` inside a minimal, + layout-neutral wrapper (no forced color scheme, no viewport styling), so + the card drops into the host app's own layout and theme. + +In both postures the emitted comment names the other variant, and editing the +generated page remains the sanctioned way to change posture (presentation is +user-owned per ADR 042 — no config knob is introduced). `doctor --fix` +regenerates the same posture because the hinge is recorded in the manifest +and restored into the patch context. + +## Consequences + +- Templates branch on `PatchContext.scaffoldedFramework`, which already + flows from setup and is restored by doctor; no new state is needed. +- The journey matrix needs a pre-existing-app lane to cover the widget + posture end to end (today's journeys always scaffold fresh). +- Copy in the generated pages' comments and the scaffold guidance must + describe both postures rather than assuming full-page. + +## Open questions + +- Whether `--preset`-style explicitness is wanted anyway (e.g. a + `--surface page|widget` override at setup time) or whether editing the + generated page stays the only override. +- Whether the posture should be recorded per page in the scaffold manifest + so a later template revision can tell a deliberate user choice from the + scaffold default. diff --git a/docs/adrs/045-copy-overlays-as-branding-revisions.md b/docs/adrs/045-copy-overlays-as-branding-revisions.md new file mode 100644 index 000000000..c992593eb --- /dev/null +++ b/docs/adrs/045-copy-overlays-as-branding-revisions.md @@ -0,0 +1,64 @@ +# ADR 045: Copy Overlays as Branding Revisions + +> **Status:** Proposed +> **Date:** 2026-08-02 +> **Context:** The widgets' locale dictionaries and copy overlays (`businessLocales`), the branding-revision resource (ADR 040 / the templates track), and the CLI scaffold templates. +> **Relates to:** [ADR 040](040-tenant-login-templates-editable-config.md) + +## Context + +The widgets ship neutral built-in copy, and audience-flavored wording is an +overlay: `businessLocales` lives in `@zitadel/components`, is re-exported +through the framework SDKs, and is wired into generated pages at scaffold +time when the project chose the business use case. This works, but the +mechanism has a shape problem: + +- The overlay is **branding-shaped data living in code**. Changing wording + means a package release and an app redeploy, while the platform already + has a home for exactly this kind of data — immutable, per-project branding + revisions resolved at flow time (ADR 040). +- Every framework SDK must re-export the overlay and every scaffold template + must wire it, multiplying one dictionary into eight integration points + (the cross-framework parity work now in flight does precisely this + multiplication). +- The overlay applies per **app build**, not per project or environment — + two apps on one project can disagree about the project's own voice. + +## Decision (proposed) + +Copy overlays become part of the **branding revision** resource: + +- A branding revision carries optional per-language copy entries + (key → string over the built-in dictionary keys, same shape as today's + `locales` property values). The server resolves the project's current + revision and delivers the merged copy with the flow response; the widgets + apply it exactly as they apply a `locales` property today (element-level + `locales` remains as the app-level override with higher precedence). +- `setup --use-case business` seeds a branding revision carrying the + business overlay instead of wiring template props — the generated pages + stay copy-agnostic, and every framework gets the overlay through the same + server path with zero per-SDK wiring. +- Copy edits follow the branding lifecycle: eject → edit → apply publishes a + new revision (ADR 040's model), making wording changes runtime-effective + without app redeploys and giving them revision history. + +The bundle keeps only the neutral built-ins; `businessLocales` remains +exported as a convenience preset for hand-integrators, but the scaffold and +platform path no longer depend on it. + +## Consequences + +- One source of truth for copy across all eight framework scaffolds; the + per-SDK re-exports and template wiring become a transitional mechanism to + retire once this lands. +- Copy joins branding's governance story (revisions, environments per + ADR 035/040) instead of being a build-time constant. +- The flow response grows a copy payload; the widgets' locale resolution + gains one precedence layer (element property > revision copy > built-ins). + +## Fence + +Implementation is deliberately fenced behind the templates-track milestone: +this ADR seeks direction alignment only, and no code should be built ahead +of acceptance (the built-ahead-of-alignment pattern is how parallel work has +rotted before). The interim per-SDK wiring keeps delivering value until then. diff --git a/docs/adrs/README.md b/docs/adrs/README.md index 43260c376..1e5554f69 100644 --- a/docs/adrs/README.md +++ b/docs/adrs/README.md @@ -49,3 +49,5 @@ This directory contains architecture decision records (ADRs) for nextgen. | [041](041-storage-statement-contract-tests.md) | Storage Statement Contract Tests | Accepted | Shared `stmttest` suites assert `AllStatements` behavior across dialects via build-tag registration and `forEachDialect`; dialect packages keep engine-specific tests. | | [042](042-scaffolded-file-ownership-and-drift-detection.md) | Scaffolded File Ownership and Drift Detection | Accepted | Scaffolded app files carry infrastructure/presentation classes recorded in a `.zitadel/state.json` manifest; `doctor` verifies them (missing infra fails, missing pages warn) and `--fix` restores missing files only, never overwriting edited or user-adopted ones. | | [043](043-framework-version-floors.md) | Framework Version Floors | Accepted | Supported floors are Next.js 15+ and React 18+, enforced in the framework detectors so `setup` and `doctor` share one loud `E_UNSUPPORTED_PROJECT_SHAPE` gate; unparseable versions pass, and `@zitadel/sdk-next` peers follow the floor (`next >=15`). | +| [044](044-scaffold-embedding-posture-defaults.md) | Scaffold Embedding Posture Defaults | Proposed | Scaffolded auth/profile pages derive their surface from the recorded fresh-vs-pre-existing hinge: fresh scaffolds keep `variant="page"`, pre-existing apps get `variant="widget"` in a layout-neutral wrapper; posture changes stay page edits, and doctor restores the same posture from the manifest. | +| [045](045-copy-overlays-as-branding-revisions.md) | Copy Overlays as Branding Revisions | Proposed | Audience copy overlays move from bundle-shipped presets wired per SDK into the branding-revision resource: revisions carry per-language copy resolved with the flow response, `--use-case business` seeds a revision instead of template props, and wording edits follow eject→edit→apply. Implementation fenced behind the templates-track milestone. | From bb22a1c97c1d70a7f434b6a0b3574ef257022639 Mon Sep 17 00:00:00 2001 From: Florian Forster Date: Sun, 2 Aug 2026 18:56:39 +0200 Subject: [PATCH 3/5] fix: evaluate version floors with semver ranges and surface them through doctor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The floor gate now judges the declared spec with real range semantics (semver.validRange + intersects against >=floor.0.0-0): "<15" rejects, "file:../next-14-patched" and dist-tags pass — a first-digits parse inverted both. Doctor stops laundering the typed error: CheckOutcome carries the ZitadelError code and hint, the envelope prefers a coded failure over generic E_VALIDATION, and the advice ladder surfaces the check's own remedy instead of recommending a --fix that cannot repair an unsupported version. Changeset loses the internal ADR reference; the Next floor comment loses the unrelated React 19 clause. --- .changeset/framework-version-floors.md | 2 +- apps/cli/package.json | 2 + apps/cli/src/commands/doctor/checks/types.ts | 12 ++++ apps/cli/src/commands/doctor/index.ts | 16 ++++- apps/cli/src/lib/orca/detectors/next.ts | 20 +++--- .../src/lib/orca/detectors/package-json.ts | 30 +++++++++ apps/cli/src/lib/orca/detectors/react.ts | 17 +++-- apps/cli/tests/integration/setup-next.test.ts | 32 ++++++++++ .../tests/unit/commands/doctor/checks.test.ts | 4 ++ .../unit/lib/orca/detectors/next.test.ts | 10 +++ .../lib/orca/detectors/package-json.test.ts | 47 ++++++++++++++ .../unit/lib/orca/detectors/react.test.ts | 12 ++++ docs/adrs/043-framework-version-floors.md | 11 +++- pnpm-lock.yaml | 63 ++++++++++++------- pnpm-workspace.yaml | 2 + 15 files changed, 240 insertions(+), 40 deletions(-) diff --git a/.changeset/framework-version-floors.md b/.changeset/framework-version-floors.md index c3eae4192..ab18d7a81 100644 --- a/.changeset/framework-version-floors.md +++ b/.changeset/framework-version-floors.md @@ -3,4 +3,4 @@ "@zitadel/sdk-next": minor --- -The CLI now enforces the supported framework floors — Next.js 15+ and React 18+ (ADR 043): `setup` refuses a below-floor app before any mutation and `doctor`'s framework check fails on one, both with an explicit `E_UNSUPPORTED_PROJECT_SHAPE` error naming the floor and an upgrade hint; unparseable version specs still pass. `@zitadel/sdk-next`'s peer range follows the floor (`next >=15`). +The CLI now enforces its supported framework floors — Next.js 15 and newer, React 18 and newer. `setup` refuses a below-floor app before making any change, and `doctor` reports one the same way: both emit an explicit `E_UNSUPPORTED_PROJECT_SHAPE` error naming the floor together with an upgrade hint. Only version ranges that provably cannot resolve to a supported release are rejected — protocol specs (`file:`, `workspace:`), dist-tags (`latest`), and ranges that admit a supported version all pass. `@zitadel/sdk-next` now declares the matching peer range `next >=15`. diff --git a/apps/cli/package.json b/apps/cli/package.json index f28e50fbd..83008d3f0 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -69,11 +69,13 @@ "magicast": "^0.5.3", "mixpanel": "^0.18.1", "safe-stable-stringify": "catalog:", + "semver": "catalog:", "zod": "catalog:", "picocolors": "^1.1.1" }, "devDependencies": { "@types/node": "catalog:", + "@types/semver": "catalog:", "@zitadel/api-mock": "workspace:*", "@zitadel/sdk-next": "workspace:*", "msw": "catalog:", diff --git a/apps/cli/src/commands/doctor/checks/types.ts b/apps/cli/src/commands/doctor/checks/types.ts index 3e0796906..0f5bb2185 100644 --- a/apps/cli/src/commands/doctor/checks/types.ts +++ b/apps/cli/src/commands/doctor/checks/types.ts @@ -1,3 +1,4 @@ +import { ZitadelError, type ZitadelErrorCode } from "../../../lib/errors"; import type { Orca } from "../../../lib/orca"; /** Pass/fail/advisory outcome of a single {@link SanityCheck}. */ @@ -7,6 +8,14 @@ export type CheckOutcome = { status: "pass" | "warn" | "fail"; message: string; path?: string; + /** + * Set when the failure surfaced as a typed CLI error: the error's category + * and remedy travel through the outcome so the doctor envelope can carry + * the advertised code (e.g. the framework floor's + * `E_UNSUPPORTED_PROJECT_SHAPE`) and hint instead of generic advice. + */ + code?: ZitadelErrorCode; + hint?: string; }; /** Everything a check needs to inspect or repair a project. */ @@ -60,6 +69,9 @@ export abstract class AbstractSanityCheck implements SanityCheck { status: "fail", message: error instanceof Error ? error.message : String(error), path: this.path, + ...(error instanceof ZitadelError + ? { code: error.code, ...(error.hint === undefined ? {} : { hint: error.hint }) } + : {}), }; } } diff --git a/apps/cli/src/commands/doctor/index.ts b/apps/cli/src/commands/doctor/index.ts index 39e178db0..784eaa432 100644 --- a/apps/cli/src/commands/doctor/index.ts +++ b/apps/cli/src/commands/doctor/index.ts @@ -139,7 +139,13 @@ export default class Doctor extends BaseCommand { if (failed.length > 0) { const advice = failureAdvice(failed, image, port, this.meta.cliVersion); - const code = failed.some((check) => check.name === "port") ? "E_PORT_IN_USE" : "E_VALIDATION"; + // A check that failed with a typed CLI error advertises its own + // category (e.g. the framework floor's E_UNSUPPORTED_PROJECT_SHAPE) — + // surface that instead of the generic validation class so agents can + // branch on it. The port check keeps its dedicated code first. + const code = failed.some((check) => check.name === "port") + ? "E_PORT_IN_USE" + : (failed.find((check) => check.code !== undefined)?.code ?? "E_VALIDATION"); throw new ZitadelError(code, "Zitadel doctor found issues", { hint: advice.hint, nextCommands: advice.nextCommands, @@ -219,6 +225,14 @@ function failureAdvice( }; } + // A failure that surfaced as a typed CLI error carries its own remedy — + // e.g. the framework floor's upgrade hint. That beats the generic --fix + // advice below, which cannot repair an unsupported version. + const typed = failed.find((check) => check.code !== undefined && check.hint !== undefined); + if (typed?.hint !== undefined) { + return { hint: typed.hint, nextCommands: [publicCliCommand("doctor", cliVersion)] }; + } + const hasProjectFailure = failed.some((check) => !LOCAL_RUNTIME_CHECK_NAMES.has(check.name)); if (hasProjectFailure) { return { diff --git a/apps/cli/src/lib/orca/detectors/next.ts b/apps/cli/src/lib/orca/detectors/next.ts index af6adaffa..b9a2718e9 100644 --- a/apps/cli/src/lib/orca/detectors/next.ts +++ b/apps/cli/src/lib/orca/detectors/next.ts @@ -2,7 +2,12 @@ import { stat } from "node:fs/promises"; import { join } from "node:path"; import { ZitadelError } from "../../errors"; -import { dependencyVersionMajor, hasDependency, readPackageJson } from "./package-json"; +import { + dependencySpecProvablyBelowMajor, + dependencyVersionMajor, + hasDependency, + readPackageJson, +} from "./package-json"; import { detectDevPort, issuerFromPort } from "./port"; import type { Detector, FrameworkFacts } from "./types"; @@ -29,15 +34,15 @@ export class NextDetector implements Detector { } // Supported floor (ADR 043): the scaffolded templates target Next 15+ - // (React 19 property binding, current boundary conventions). The floor is + // (current App Router and request-boundary conventions). The floor is // enforced here so setup and doctor share one loud gate instead of - // degrading silently; an unparseable version spec passes (cannot prove a - // violation), matching this detector's other tolerances. - const versionMajor = dependencyVersionMajor(pkg, "next"); - if (versionMajor !== undefined && versionMajor < 15) { + // degrading silently; only a spec that provably cannot resolve to 15+ + // is rejected — protocol specs and dist-tags pass. + const belowFloor = dependencySpecProvablyBelowMajor(pkg, "next", 15); + if (belowFloor !== undefined) { throw new ZitadelError( "E_UNSUPPORTED_PROJECT_SHAPE", - `Next.js ${versionMajor} is below the supported floor — the CLI integrates Next.js 15 and newer`, + `Next.js "${belowFloor}" is below the supported floor — the CLI integrates Next.js 15 and newer`, { hint: "Upgrade the app to Next 15+ (e.g. `npx @next/codemod@latest upgrade`) and rerun." }, ); } @@ -56,6 +61,7 @@ export class NextDetector implements Detector { } const devPort = await detectDevPort(cwd, pkg); + const versionMajor = dependencyVersionMajor(pkg, "next"); return { id: "next", appDir, diff --git a/apps/cli/src/lib/orca/detectors/package-json.ts b/apps/cli/src/lib/orca/detectors/package-json.ts index 01bf24dc6..93794ac40 100644 --- a/apps/cli/src/lib/orca/detectors/package-json.ts +++ b/apps/cli/src/lib/orca/detectors/package-json.ts @@ -1,6 +1,8 @@ import { readFile } from "node:fs/promises"; import { join } from "node:path"; +import semver from "semver"; + /** * Minimal shape of the `package.json` fields detectors read. Intentionally * partial: only the keys detection logic depends on are modeled, all optional @@ -31,6 +33,12 @@ export function hasDependency(pkg: PackageJson, name: string): boolean { return Boolean(pkg.dependencies?.[name] ?? pkg.devDependencies?.[name]); } +/** + * Best-effort major version of a declared dependency: the first number in the + * spec. A heuristic for scaffold decisions (e.g. which Next request-boundary + * convention to emit), NOT for support gating — range semantics like `<16` + * are beyond it. Floors use {@link dependencySpecProvablyBelowMajor}. + */ export function dependencyVersionMajor(pkg: PackageJson, name: string): number | undefined { const spec = pkg.dependencies?.[name] ?? pkg.devDependencies?.[name]; if (!spec) { @@ -39,3 +47,25 @@ export function dependencyVersionMajor(pkg: PackageJson, name: string): number | const match = spec.match(/\d+/); return match ? Number(match[0]) : undefined; } + +/** + * Returns the declared spec for `name` when that spec provably cannot resolve + * to `floorMajor` or newer: a valid semver range with no overlap with + * `>=floorMajor.0.0-0`. Everything unprovable returns `undefined` — protocol + * specs (`file:`, `link:`, `workspace:`, git URLs), dist-tags (`latest`, + * `canary`), absent deps, and ranges that also admit a compliant version + * (`>=14`, `14 || 16`). The floor comparator includes prereleases so a + * `15.0.0-rc` user is not rejected by the 15 floor. + */ +export function dependencySpecProvablyBelowMajor( + pkg: PackageJson, + name: string, + floorMajor: number, +): string | undefined { + const spec = pkg.dependencies?.[name] ?? pkg.devDependencies?.[name]; + if (!spec || semver.validRange(spec) === null) { + return undefined; + } + const atOrAboveFloor = `>=${String(floorMajor)}.0.0-0`; + return semver.intersects(spec, atOrAboveFloor, { includePrerelease: true }) ? undefined : spec; +} diff --git a/apps/cli/src/lib/orca/detectors/react.ts b/apps/cli/src/lib/orca/detectors/react.ts index c997f0162..e6e00a559 100644 --- a/apps/cli/src/lib/orca/detectors/react.ts +++ b/apps/cli/src/lib/orca/detectors/react.ts @@ -1,5 +1,10 @@ import { ZitadelError } from "../../errors"; -import { dependencyVersionMajor, hasDependency, readPackageJson } from "./package-json"; +import { + dependencySpecProvablyBelowMajor, + dependencyVersionMajor, + hasDependency, + readPackageJson, +} from "./package-json"; import { detectDevPort, issuerFromPort } from "./port"; import type { Detector, FrameworkFacts } from "./types"; @@ -28,17 +33,19 @@ export class ReactDetector implements Detector { // Supported floor (ADR 043): the SDK wrappers and scaffolded templates // target React 18+. Enforced here so setup and doctor share one loud - // gate; an unparseable version spec passes (cannot prove a violation). - const versionMajor = dependencyVersionMajor(pkg, "react"); - if (versionMajor !== undefined && versionMajor < 18) { + // gate; only a spec that provably cannot resolve to 18+ is rejected — + // protocol specs and dist-tags pass. + const belowFloor = dependencySpecProvablyBelowMajor(pkg, "react", 18); + if (belowFloor !== undefined) { throw new ZitadelError( "E_UNSUPPORTED_PROJECT_SHAPE", - `React ${versionMajor} is below the supported floor — the CLI integrates React 18 and newer`, + `React "${belowFloor}" is below the supported floor — the CLI integrates React 18 and newer`, { hint: "Upgrade the app to React 18+ and rerun." }, ); } const devPort = await detectDevPort(cwd, pkg); + const versionMajor = dependencyVersionMajor(pkg, "react"); return { id: "react", appDir: "src", diff --git a/apps/cli/tests/integration/setup-next.test.ts b/apps/cli/tests/integration/setup-next.test.ts index 932e0198e..5b2e65c7a 100644 --- a/apps/cli/tests/integration/setup-next.test.ts +++ b/apps/cli/tests/integration/setup-next.test.ts @@ -366,6 +366,38 @@ describe("Next setup integration", () => { await expect(stat(join(cwd, "zitadel.json"))).rejects.toThrow(); }); + it("reports the floor through the doctor envelope on a downgraded app", async () => { + // Scaffold healthy, then downgrade next below the floor — the ADR 043 + // story doctor must tell truthfully: the advertised machine code and the + // upgrade hint, not a generic validation error recommending --fix (which + // cannot repair an unsupported version). + const cwd = await createNextProject(); + const setup = await cli(["setup", "--cwd", cwd, "--non-interactive", "--json", "--skip-install"]); + expect(setup.exitCode).toBe(0); + const pkgPath = join(cwd, "package.json"); + const pkg = JSON.parse(await readFile(pkgPath, "utf8")) as { + dependencies: Record; + }; + pkg.dependencies.next = "^14.2.0"; + await writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`); + + const fake = await fakeDocker(); + const port = await freePort(); + const doctor = await cli(["doctor", "--cwd", cwd, "--json", "--port", String(port)], { + PATH: `${fake.binDir}:${process.env.PATH ?? ""}`, + DOCKER_LOG: fake.logPath, + }); + expect(doctor.exitCode).toBe(3); + const envelope = parseJson(doctor.stdout) as { + code: string; + hint?: string; + next_commands?: string[]; + }; + expect(envelope.code).toBe("E_UNSUPPORTED_PROJECT_SHAPE"); + expect(envelope.hint).toContain("Upgrade the app to Next 15+"); + expect((envelope.next_commands ?? []).join(" ")).not.toContain("--fix"); + }); + it("skips rerun setup without rewriting edited schema or flow config", async () => { const cwd = await createNextProject(); const setup = await cli(["setup", "--cwd", cwd, "--non-interactive", "--json", "--skip-install"]); diff --git a/apps/cli/tests/unit/commands/doctor/checks.test.ts b/apps/cli/tests/unit/commands/doctor/checks.test.ts index 4040b9815..da6c905fa 100644 --- a/apps/cli/tests/unit/commands/doctor/checks.test.ts +++ b/apps/cli/tests/unit/commands/doctor/checks.test.ts @@ -320,6 +320,10 @@ describe("FrameworkCheck", () => { const outcome = await new FrameworkCheck().run(ctxFor(cwd)); expect(outcome.status).toBe("fail"); expect(outcome.message).toContain("below the supported floor"); + // The typed error's category and remedy travel through the outcome so + // the doctor envelope can surface them instead of generic --fix advice. + expect(outcome.code).toBe("E_UNSUPPORTED_PROJECT_SHAPE"); + expect(outcome.hint).toContain("Upgrade"); }); }); diff --git a/apps/cli/tests/unit/lib/orca/detectors/next.test.ts b/apps/cli/tests/unit/lib/orca/detectors/next.test.ts index b974e70cc..0e2cfd08f 100644 --- a/apps/cli/tests/unit/lib/orca/detectors/next.test.ts +++ b/apps/cli/tests/unit/lib/orca/detectors/next.test.ts @@ -122,4 +122,14 @@ describe("NextDetector", () => { expect(await detector.detect(dir)).toMatchObject({ id: "next" }); expect(await detector.detect(dir)).not.toHaveProperty("versionMajor"); }); + + it("judges the floor by range semantics, not by digits in the spec", async () => { + await mkdir(join(dir, "app")); + // "<15" contains the digits 15 yet admits only versions below the floor. + await writeNextPackageJson({ dependencies: { next: "<15" } }); + await expect(detector.detect(dir)).rejects.toThrow("below the supported floor"); + // A path spec carries digits but no provable version — it must pass. + await writeNextPackageJson({ dependencies: { next: "file:../next-14-patched" } }); + expect(await detector.detect(dir)).toMatchObject({ id: "next" }); + }); }); diff --git a/apps/cli/tests/unit/lib/orca/detectors/package-json.test.ts b/apps/cli/tests/unit/lib/orca/detectors/package-json.test.ts index 4c3fa237e..dfd125e66 100644 --- a/apps/cli/tests/unit/lib/orca/detectors/package-json.test.ts +++ b/apps/cli/tests/unit/lib/orca/detectors/package-json.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { + dependencySpecProvablyBelowMajor, dependencyVersionMajor, hasDependency, readPackageJson, @@ -70,3 +71,49 @@ describe("dependencyVersionMajor", () => { expect(dependencyVersionMajor({ dependencies: { next: "canary" } }, "next")).toBeUndefined(); }); }); + +describe("dependencySpecProvablyBelowMajor", () => { + function spec(value: string): PackageJson { + return { dependencies: { next: value } }; + } + + it("rejects ranges that can only resolve below the floor", () => { + // The upper-bound comparator is the case a first-digits parser inverts: + // "<15" contains 15 as text yet admits only versions below it. + for (const value of ["<15", "^14.2.0", "~14.5", "14.0.0", "14.x", ">=13 <15"]) { + expect(dependencySpecProvablyBelowMajor(spec(value), "next", 15)).toBe(value); + } + }); + + it("passes ranges that also admit a compliant version", () => { + for (const value of [">=14", "14 || 16", "*", "^15.0.0", ">=15"]) { + expect(dependencySpecProvablyBelowMajor(spec(value), "next", 15)).toBeUndefined(); + } + }); + + it("passes prereleases of the floor major", () => { + expect(dependencySpecProvablyBelowMajor(spec("15.0.0-rc.0"), "next", 15)).toBeUndefined(); + }); + + it("passes unprovable specs: protocols, dist-tags, git URLs, absence", () => { + // A path like "file:../next-14-patched" carries digits but no provable + // version — treating it as 14 would reject what cannot be judged. + for (const value of [ + "file:../next-14-patched", + "link:../next", + "workspace:*", + "latest", + "canary", + "github:vercel/next.js#canary", + ]) { + expect(dependencySpecProvablyBelowMajor(spec(value), "next", 15)).toBeUndefined(); + } + expect(dependencySpecProvablyBelowMajor({}, "next", 15)).toBeUndefined(); + }); + + it("reads devDependencies too", () => { + expect( + dependencySpecProvablyBelowMajor({ devDependencies: { next: "^14.0.0" } }, "next", 15), + ).toBe("^14.0.0"); + }); +}); diff --git a/apps/cli/tests/unit/lib/orca/detectors/react.test.ts b/apps/cli/tests/unit/lib/orca/detectors/react.test.ts index 990f706f5..48289fe76 100644 --- a/apps/cli/tests/unit/lib/orca/detectors/react.test.ts +++ b/apps/cli/tests/unit/lib/orca/detectors/react.test.ts @@ -66,4 +66,16 @@ describe("ReactDetector", () => { expect(facts?.id).toBe("react"); expect(facts).not.toHaveProperty("versionMajor"); }); + + it("judges the floor by range semantics, not by digits in the spec", async () => { + // "<18" contains the digits 18 yet admits only versions below the floor. + await expect( + new ReactDetector().detect(await project({ react: "<18", vite: "^5" })), + ).rejects.toThrow("below the supported floor"); + // A path spec carries digits but no provable version — it must pass. + const patched = await new ReactDetector().detect( + await project({ react: "file:../react-17-patched", vite: "^5" }), + ); + expect(patched?.id).toBe("react"); + }); }); diff --git a/docs/adrs/043-framework-version-floors.md b/docs/adrs/043-framework-version-floors.md index 4a1df59de..c9cbb0d04 100644 --- a/docs/adrs/043-framework-version-floors.md +++ b/docs/adrs/043-framework-version-floors.md @@ -37,9 +37,14 @@ This ADR records the floors and the enforcement point. the floor existed). The existing error code is reused deliberately: a below-floor version is the same family as "Pages Router not supported", and agents already branch on it. -- **Unparseable versions pass.** A spec like `latest` or a workspace range - carries no provable major; blocking on it would be hostile guessing. The - floor fires only on a provable violation. +- **Only provable violations reject.** The gate evaluates the declared spec + with semver range semantics: it rejects only when the range cannot resolve + to the floor major or newer (`^14.2.0` and `<15` reject; `>=14` and + `14 || 16` pass because a supported version satisfies them). Specs that + carry no provable version at all — protocol specs (`file:`, `link:`, + `workspace:`, git URLs) and dist-tags (`latest`, `canary`) — always pass; + blocking on them would be hostile guessing. Prereleases of the floor major + count as at-floor. - **Peer ranges follow the floor:** `@zitadel/sdk-next` declares `next >=15` (its `react >=18` was already correct). Other frameworks currently declare no floor; adding one later means extending its detector the same way and diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a7e0496db..88e1dceba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -123,6 +123,9 @@ catalogs: '@types/react-dom': specifier: ^19.2.3 version: 19.2.3 + '@types/semver': + specifier: ^7.7.1 + version: 7.8.0 '@typescript/native-preview': specifier: 7.0.0-dev.20260421.2 version: 7.0.0-dev.20260421.2 @@ -255,6 +258,9 @@ catalogs: safe-stable-stringify: specifier: ^2.5.0 version: 2.5.0 + semver: + specifier: ^7.7.3 + version: 7.8.5 sharp: specifier: ^0.35.3 version: 0.35.3 @@ -442,6 +448,9 @@ importers: safe-stable-stringify: specifier: 'catalog:' version: 2.5.0 + semver: + specifier: 'catalog:' + version: 7.8.5 zod: specifier: 'catalog:' version: 4.4.3 @@ -449,6 +458,9 @@ importers: '@types/node': specifier: 'catalog:' version: 25.6.0 + '@types/semver': + specifier: 'catalog:' + version: 7.8.0 '@zitadel/api-mock': specifier: workspace:* version: link:../../packages/api-mock @@ -8080,6 +8092,9 @@ packages: '@types/responselike@1.0.0': resolution: {integrity: sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA==} + '@types/semver@7.8.0': + resolution: {integrity: sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==} + '@types/send@0.17.6': resolution: {integrity: sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==} @@ -16009,7 +16024,7 @@ snapshots: chokidar: 5.0.0 convert-source-map: 1.9.0 reflect-metadata: 0.2.2 - semver: 7.8.2 + semver: 7.8.5 tslib: 2.8.1 yargs: 18.0.0 optionalDependencies: @@ -16639,7 +16654,7 @@ snapshots: outdent: 0.5.0 prettier: 2.8.8 resolve-from: 5.0.0 - semver: 7.8.2 + semver: 7.8.5 '@changesets/assemble-release-plan@6.0.10': dependencies: @@ -16648,7 +16663,7 @@ snapshots: '@changesets/should-skip-package': 0.1.2 '@changesets/types': 6.1.0 '@manypkg/get-packages': 1.1.3 - semver: 7.8.2 + semver: 7.8.5 '@changesets/changelog-git@0.2.1': dependencies: @@ -16713,7 +16728,7 @@ snapshots: '@changesets/types': 6.1.0 '@manypkg/get-packages': 1.1.3 picocolors: 1.1.1 - semver: 7.8.2 + semver: 7.8.5 '@changesets/get-github-info@0.8.0(encoding@0.1.13)': dependencies: @@ -18290,7 +18305,7 @@ snapshots: perfect-debounce: 2.1.0 pkg-types: 2.3.1 scule: 1.3.0 - semver: 7.8.2 + semver: 7.8.5 srvx: 0.11.16 std-env: 4.1.0 tinyclip: 0.1.13 @@ -18324,7 +18339,7 @@ snapshots: magicast: 0.5.3 pathe: 2.0.3 pkg-types: 2.3.1 - semver: 7.8.2 + semver: 7.8.5 '@nuxt/devtools@3.2.4(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass@1.100.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.9.0))(vue@3.5.35(typescript@5.9.3))': dependencies: @@ -18351,7 +18366,7 @@ snapshots: pathe: 2.0.3 perfect-debounce: 2.1.0 pkg-types: 2.3.1 - semver: 7.8.2 + semver: 7.8.5 simple-git: 3.36.0 sirv: 3.0.2 structured-clone-es: 2.0.0 @@ -18393,7 +18408,7 @@ snapshots: pkg-types: 2.3.1 rc9: 3.0.1 scule: 1.3.0 - semver: 7.8.2 + semver: 7.8.5 tinyglobby: 0.2.17 ufo: 1.6.4 unctx: 2.5.0 @@ -18695,7 +18710,7 @@ snapshots: is-wsl: 2.2.0 lilconfig: 3.1.3 minimatch: 10.2.5 - semver: 7.8.2 + semver: 7.8.5 string-width: 4.2.3 supports-color: 8.1.1 tinyglobby: 0.2.17 @@ -22325,6 +22340,8 @@ snapshots: dependencies: '@types/node': 22.19.19 + '@types/semver@7.8.0': {} + '@types/send@0.17.6': dependencies: '@types/mime': 1.3.5 @@ -22479,7 +22496,7 @@ snapshots: '@typescript-eslint/visitor-keys': 8.59.3 debug: 4.4.3(supports-color@8.1.1) minimatch: 10.2.5 - semver: 7.8.2 + semver: 7.8.5 tinyglobby: 0.2.17 ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 @@ -22494,7 +22511,7 @@ snapshots: '@typescript-eslint/visitor-keys': 8.59.3 debug: 4.4.3(supports-color@8.1.1) minimatch: 10.2.5 - semver: 7.8.2 + semver: 7.8.5 tinyglobby: 0.2.17 ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 @@ -25050,7 +25067,7 @@ snapshots: postcss: 8.5.25 postcss-load-config: 3.1.4(postcss@8.5.25) postcss-safe-parser: 7.0.1(postcss@8.5.25) - semver: 7.8.2 + semver: 7.8.5 svelte-eslint-parser: 1.8.0(svelte@5.56.3(@typescript-eslint/types@8.59.3)) optionalDependencies: svelte: 5.56.3(@typescript-eslint/types@8.59.3) @@ -26549,7 +26566,7 @@ snapshots: is-bun-module@2.0.0: dependencies: - semver: 7.8.2 + semver: 7.8.5 is-callable@1.2.7: {} @@ -26887,7 +26904,7 @@ snapshots: lodash.isstring: 4.0.1 lodash.once: 4.1.1 ms: 2.1.3 - semver: 7.8.2 + semver: 7.8.5 jsprim@2.0.2: dependencies: @@ -28145,7 +28162,7 @@ snapshots: rollup: 4.60.4 rollup-plugin-visualizer: 7.0.1(rolldown@1.0.3)(rollup@4.60.4) scule: 1.3.0 - semver: 7.8.2 + semver: 7.8.5 serve-placeholder: 2.0.2 serve-static: 2.2.1 source-map: 0.7.6 @@ -28252,7 +28269,7 @@ snapshots: normalize-package-data@6.0.2: dependencies: hosted-git-info: 7.0.2 - semver: 7.8.2 + semver: 7.8.5 validate-npm-package-license: 3.0.4 normalize-path@3.0.0: {} @@ -28318,7 +28335,7 @@ snapshots: pkg-types: 2.3.1 rou3: 0.8.1 scule: 1.3.0 - semver: 7.8.2 + semver: 7.8.5 std-env: 4.1.0 tinyglobby: 0.2.17 ufo: 1.6.4 @@ -28447,7 +28464,7 @@ snapshots: pkg-types: 2.3.1 rou3: 0.8.1 scule: 1.3.0 - semver: 7.8.2 + semver: 7.8.5 std-env: 4.1.0 tinyglobby: 0.2.17 ufo: 1.6.4 @@ -28613,7 +28630,7 @@ snapshots: got: 13.0.0 lodash: 4.18.1 normalize-package-data: 6.0.2 - semver: 7.8.2 + semver: 7.8.5 sort-package-json: 2.15.1 tiny-jsonc: 1.0.2 validate-npm-package-name: 5.0.1 @@ -30582,7 +30599,7 @@ snapshots: get-stdin: 9.0.0 git-hooks-list: 3.2.0 is-plain-obj: 4.1.0 - semver: 7.8.2 + semver: 7.8.5 sort-object-keys: 1.1.3 tinyglobby: 0.2.17 @@ -30677,7 +30694,7 @@ snapshots: oxc-parser: 0.127.0 oxc-resolver: 11.20.0 recast: 0.23.11 - semver: 7.8.2 + semver: 7.8.5 use-sync-external-store: 1.6.0(react@19.2.8) ws: 8.21.0 optionalDependencies: @@ -30914,7 +30931,7 @@ snapshots: postcss: 8.5.25 postcss-scss: 4.0.9(postcss@8.5.25) postcss-selector-parser: 7.1.1 - semver: 7.8.2 + semver: 7.8.5 optionalDependencies: svelte: 5.56.3(@typescript-eslint/types@8.59.3) @@ -31200,7 +31217,7 @@ snapshots: picomatch: 4.0.4 rolldown: 1.0.0-rc.17 rolldown-plugin-dts: 0.23.2(@typescript/native-preview@7.0.0-dev.20260421.2)(oxc-resolver@11.20.0)(rolldown@1.0.0-rc.17)(typescript@6.0.3)(vue-tsc@3.2.8(typescript@6.0.3)) - semver: 7.8.2 + semver: 7.8.5 tinyexec: 1.2.4 tinyglobby: 0.2.17 tree-kill: 1.2.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 127f8305e..f5501bee0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -43,6 +43,7 @@ catalog: "@types/node": ^25.6.0 "@types/react": ^19.2.14 "@types/react-dom": ^19.2.3 + "@types/semver": ^7.7.1 "@typescript/native-preview": 7.0.0-dev.20260421.2 "@vitejs/plugin-react": ^6.0.1 "@vitest/browser": ^4.1.9 @@ -87,6 +88,7 @@ catalog: react-dom: ^19.2.8 react-server-dom-webpack: 19.2.8 safe-stable-stringify: ^2.5.0 + semver: ^7.7.3 sharp: ^0.35.3 shiki: ^4.2.0 sonner: ^2.0.7 From e5f3f364f381745212d4033194e2657d55cf46c3 Mon Sep 17 00:00:00 2001 From: Florian Forster Date: Sun, 2 Aug 2026 19:05:26 +0200 Subject: [PATCH 4/5] ci: retrigger changesets pr-status on a pushed head From 48d47b0790e3667574d867e086aab5b4d774d254 Mon Sep 17 00:00:00 2001 From: Florian Forster Date: Sun, 2 Aug 2026 19:07:02 +0200 Subject: [PATCH 5/5] docs: scope ADR 044 to route-based scaffolds and align ADR 045 with the release boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round: ADR 044 now applies only to integrations that add routes without owning the app shell (Next, Nuxt) — the SPA patchers write the app root, so a pre-existing shell is conflicted-with or replaced, never inherited; non-destructive insertion moves to open questions. The posture is recorded in the scaffold manifest, and absence of a record (all legacy scaffolds) restores page, so widget restoration needs positive evidence. ADR 045 no longer implies revision creation changes runtime wording: under ADR 035 a copy-bearing revision is inert until a configuration release containing it is deployed, with ADR 040's latest-revision resolution named as the acknowledged interim. --- ...044-scaffold-embedding-posture-defaults.md | 43 +++++++++++++------ ...045-copy-overlays-as-branding-revisions.md | 31 ++++++++----- docs/adrs/README.md | 4 +- 3 files changed, 54 insertions(+), 24 deletions(-) diff --git a/docs/adrs/044-scaffold-embedding-posture-defaults.md b/docs/adrs/044-scaffold-embedding-posture-defaults.md index 02d0db9c9..5a08fd544 100644 --- a/docs/adrs/044-scaffold-embedding-posture-defaults.md +++ b/docs/adrs/044-scaffold-embedding-posture-defaults.md @@ -29,8 +29,17 @@ in the scaffold manifest, ADR 042): ## Decision (proposed) -Scaffolded auth and profile pages derive their default posture from the same -hinge the homepage uses: +**Scope: route-based integrations only** — frameworks whose patchers add +route files without owning the app shell (today: Next, Nuxt). The SPA +families (React/Vue/Solid/Svelte/Qwik/Angular) are explicitly out of scope +for the widget posture: their patchers write the app's root component, so on +a pre-existing app there is no preserved shell for a widget to inherit — +setup either conflicts with the existing root or, under `--force`, replaces +it. They keep today's page posture until a non-destructive route/layout +insertion contract exists for their routers (open question below). + +Within scope, scaffolded auth and profile pages derive their default posture +from the same hinge the homepage uses: - Fresh scaffold (`scaffolded_framework: true`) → pages pin `variant="page"` — unchanged from today. @@ -38,26 +47,36 @@ hinge the homepage uses: layout-neutral wrapper (no forced color scheme, no viewport styling), so the card drops into the host app's own layout and theme. -In both postures the emitted comment names the other variant, and editing the -generated page remains the sanctioned way to change posture (presentation is -user-owned per ADR 042 — no config knob is introduced). `doctor --fix` -regenerates the same posture because the hinge is recorded in the manifest -and restored into the patch context. +**The chosen posture is recorded in the scaffold manifest** (a +`posture: "page" | "widget"` field beside `scaffolded_framework`), and +`doctor --fix` restores from that record. Absence of a posture record — +every manifest written before this decision, and every manifest-less legacy +scaffold — restores `page`, which is what all earlier scaffolds were; the +widget posture is only ever restored on positive evidence. In both postures +the emitted comment names the other variant, and editing the generated page +remains the sanctioned way to change posture (presentation is user-owned per +ADR 042 — no config knob is introduced). ## Consequences -- Templates branch on `PatchContext.scaffoldedFramework`, which already - flows from setup and is restored by doctor; no new state is needed. -- The journey matrix needs a pre-existing-app lane to cover the widget - posture end to end (today's journeys always scaffold fresh). +- Templates branch on `PatchContext.scaffoldedFramework`; the manifest + gains the posture record, and restoration reads it rather than + re-deriving the hinge (which a manifest-less legacy scaffold could not + answer). +- The journey matrix needs a pre-existing-app lane (Next, Nuxt) to cover + the widget posture end to end (today's journeys always scaffold fresh). - Copy in the generated pages' comments and the scaffold guidance must describe both postures rather than assuming full-page. ## Open questions +- A non-destructive route/layout insertion contract for the SPA routers + (React Router, Vue Router, Angular routes, …) that would make the widget + posture reachable there without touching the app root — a prerequisite + for lifting the scope restriction. - Whether `--preset`-style explicitness is wanted anyway (e.g. a `--surface page|widget` override at setup time) or whether editing the generated page stays the only override. -- Whether the posture should be recorded per page in the scaffold manifest +- Whether the posture should be recorded per page rather than per scaffold, so a later template revision can tell a deliberate user choice from the scaffold default. diff --git a/docs/adrs/045-copy-overlays-as-branding-revisions.md b/docs/adrs/045-copy-overlays-as-branding-revisions.md index c992593eb..8d3ce84cb 100644 --- a/docs/adrs/045-copy-overlays-as-branding-revisions.md +++ b/docs/adrs/045-copy-overlays-as-branding-revisions.md @@ -30,17 +30,25 @@ Copy overlays become part of the **branding revision** resource: - A branding revision carries optional per-language copy entries (key → string over the built-in dictionary keys, same shape as today's - `locales` property values). The server resolves the project's current - revision and delivers the merged copy with the flow response; the widgets + `locales` property values). The flow response delivers the merged copy of + the revision that is **effective for the environment**, and the widgets apply it exactly as they apply a `locales` property today (element-level `locales` remains as the app-level override with higher precedence). +- **Effectiveness follows the release boundary, not revision creation.** + Under the accepted release/deployment model (ADR 035), a copy-bearing + branding revision is an inert draft until a configuration release + containing it is deployed to the environment — the flow response serves + copy from the environment's current release, never from undeployed + drafts. The latest-revision-on-flow-response resolution from ADR 040 is + the acknowledged interim until that model lands; this ADR inherits the + boundary rather than bypassing it, and copy edits reach runtime through + whichever lifecycle is in force (today's eject → edit → apply; release + construction + deployment once ADR 035 is implemented). - `setup --use-case business` seeds a branding revision carrying the - business overlay instead of wiring template props — the generated pages - stay copy-agnostic, and every framework gets the overlay through the same - server path with zero per-SDK wiring. -- Copy edits follow the branding lifecycle: eject → edit → apply publishes a - new revision (ADR 040's model), making wording changes runtime-effective - without app redeploys and giving them revision history. + business overlay instead of wiring template props — included in the + initial configuration the same way setup's other seeded resources become + active — so the generated pages stay copy-agnostic and every framework + gets the overlay through the same server path with zero per-SDK wiring. The bundle keeps only the neutral built-ins; `businessLocales` remains exported as a convenience preset for hand-integrators, but the scaffold and @@ -51,8 +59,11 @@ platform path no longer depend on it. - One source of truth for copy across all eight framework scaffolds; the per-SDK re-exports and template wiring become a transitional mechanism to retire once this lands. -- Copy joins branding's governance story (revisions, environments per - ADR 035/040) instead of being a build-time constant. +- Copy joins branding's governance story: wording changes ship as + configuration changes — no app rebuild or redeploy — and, under ADR 035, + carry release/deployment semantics, so two environments can run different + wording by running different releases, which a build-time constant cannot + express. - The flow response grows a copy payload; the widgets' locale resolution gains one precedence layer (element property > revision copy > built-ins). diff --git a/docs/adrs/README.md b/docs/adrs/README.md index 1e5554f69..f18cc36e2 100644 --- a/docs/adrs/README.md +++ b/docs/adrs/README.md @@ -49,5 +49,5 @@ This directory contains architecture decision records (ADRs) for nextgen. | [041](041-storage-statement-contract-tests.md) | Storage Statement Contract Tests | Accepted | Shared `stmttest` suites assert `AllStatements` behavior across dialects via build-tag registration and `forEachDialect`; dialect packages keep engine-specific tests. | | [042](042-scaffolded-file-ownership-and-drift-detection.md) | Scaffolded File Ownership and Drift Detection | Accepted | Scaffolded app files carry infrastructure/presentation classes recorded in a `.zitadel/state.json` manifest; `doctor` verifies them (missing infra fails, missing pages warn) and `--fix` restores missing files only, never overwriting edited or user-adopted ones. | | [043](043-framework-version-floors.md) | Framework Version Floors | Accepted | Supported floors are Next.js 15+ and React 18+, enforced in the framework detectors so `setup` and `doctor` share one loud `E_UNSUPPORTED_PROJECT_SHAPE` gate; unparseable versions pass, and `@zitadel/sdk-next` peers follow the floor (`next >=15`). | -| [044](044-scaffold-embedding-posture-defaults.md) | Scaffold Embedding Posture Defaults | Proposed | Scaffolded auth/profile pages derive their surface from the recorded fresh-vs-pre-existing hinge: fresh scaffolds keep `variant="page"`, pre-existing apps get `variant="widget"` in a layout-neutral wrapper; posture changes stay page edits, and doctor restores the same posture from the manifest. | -| [045](045-copy-overlays-as-branding-revisions.md) | Copy Overlays as Branding Revisions | Proposed | Audience copy overlays move from bundle-shipped presets wired per SDK into the branding-revision resource: revisions carry per-language copy resolved with the flow response, `--use-case business` seeds a revision instead of template props, and wording edits follow eject→edit→apply. Implementation fenced behind the templates-track milestone. | +| [044](044-scaffold-embedding-posture-defaults.md) | Scaffold Embedding Posture Defaults | Proposed | Route-based scaffolds (Next, Nuxt) derive their surface from the fresh-vs-pre-existing hinge — fresh keeps `variant="page"`, pre-existing gets `variant="widget"` in a layout-neutral wrapper — with the posture recorded in the scaffold manifest; no record (legacy) restores `page`. SPA families stay page-postured until a non-destructive route-insertion contract exists. | +| [045](045-copy-overlays-as-branding-revisions.md) | Copy Overlays as Branding Revisions | Proposed | Audience copy overlays move from bundle-shipped presets wired per SDK into the branding-revision resource: the flow response serves the copy effective for the environment, with effectiveness following ADR 035's release/deployment boundary (latest-revision resolution only as the ADR 040 interim); `--use-case business` seeds a revision instead of template props. Implementation fenced behind the templates-track milestone. |