AGENTS.md@apps/console · git:20260924.1a05521 · 2026-09-24 · sha256 af80ae79e7ad8974

AGENTS.md@apps/console git:20260924.1a05521A

Immutable. This exact content never changes and is served at /api/v1/blob/af80ae79e7ad8974.

# Agent Instructions — `apps/console`

Scoped instructions for the console SPA. These add to, and where they conflict
take precedence over, the root [`AGENTS.md`](../../AGENTS.md) for files under
`apps/console`.

## Decision source: read the console ADRs first

Before changing console routing, navigation, the app shell, or how the console
talks to the API, read the console-scoped ADRs in
[`docs/adrs/`](docs/adrs/README.md) (statuses in that index; 0001/0002 are
Accepted, 0003/0004 Proposed and largely implemented). They are the agreed
direction for the console build-out (issue
[#440](https://github.com/zitadel/nextgen/issues/440)):

- [ADR 0001: Routing](docs/adrs/0001-console-routing.md) — file-based routing,
  one router factory, `basepath` derived from the Vite `base`, route loaders +
  pending/error/not-found boundaries, `staticData`-driven sidebar.
- [ADR 0002: API access and auth interceptors](docs/adrs/0002-console-api-access.md)
  — the console holds no script-readable credential and calls the API
  same-origin; the embedded browser carries its HttpOnly first-party session
  cookie. Reuse `configureZitadel()` / `getApi()` rather than a bespoke client.
  The base is `/api` **only** under the dev server (whose proxy temporarily
  injects the project secret); the embedded build talks to the origin root,
  where the Go binary serves the API. The `/api` shim §1 once deferred to the
  server was withdrawn in the 2026-08-12 revision — do not reintroduce it.
- [ADR 0003: Console authentication](docs/adrs/0003-console-authentication.md)
  — `/login` embeds the login widget (`@zitadel/sdk-react`); the pathless
  `_authed` layout owns the session guard (`GET /sessions/me`) and the app
  shell; the first-party session cookie is the embedded Console's human
  operator credential. Management calls stay on the dev-only proxy secret
  until ADR 053's session-derived target authorization exists; embedded calls
  fail closed in the meantime.
- [ADR 0004: Deployment modes](docs/adrs/0004-console-deployment-modes.md)
  — one build and one authorization model serve cloud and self-host. **Target:**
  every deployment uses a reserved platform project for Console identities; an
  explicit testkit or future server-file seed fully provisions it, its initial
  user, membership, separate owner assignment, and optional customer project.
  **Today:** the Console signs into the pinned or first-created project — an
  ordinary customer project — under §2's cutover rule, unless
  `platform.bootstrap_project` is set, which provisions the reserved platform
  project itself (keys, default schema, default login flow — but not the
  initial user/membership/owner assignment the target seed transport adds)
  and is what makes claiming and self-registration work. `zitadel start` is
  the exception to "today": it sets that flag and hands the server an initial
  operator through `--user-file`, so on a CLI-started server with the default
  bootstrap the Console signs into `proj_platform` (setting the flag to
  `false` or pinning another project keeps the fallback). Do not remove that
  fallback (or the `platform.project_id` pin) until the seed transport ships;
  doing so strands self-hosters. Standalone optimizes for one project but does
  not forbid more. The runtime document carries only public sign-in metadata;
  portal surfaces render from target-scoped **effective permissions**, never
  membership, build-time flags, or a parallel console-facing feature array.
  A runtime document the Console cannot read is an **error, not a mode** (§3):
  keep "unreachable/erroring" and "no project yet" separate states, and do not
  reintroduce a silent fallback to `standalone` — backend-less dev and preview
  runs opt in with `VITE_CONSOLE_RUNTIME_FALLBACK` instead.

If an implementation needs to diverge from an ADR, update the ADR in the same
change rather than letting code and decision drift.

## Styling: classify before building

Before building any console UI, read
[`docs/styling.md`](docs/styling.md) — where a component lives decides how it
is styled (unprefixed shadcn utilities for console chrome vs a Lit+React pair),
and the retired `*-zl-*` console utility names must not come back. The 3-way
classification and token authority live there; the pair recipe lives in
[`apps/storybook/AGENTS.md`](../storybook/AGENTS.md).

## Screen conventions

List/detail screens follow the shipped patterns under `src/routes/_authed/`
(users, schemas, flow-definitions): loader-fetched data, status columns where
the resource has lifecycle state, `$param` detail routes. The sessions screen
speaks `POST /sessions/query` (structured filters + cursor pagination); there
is no `GET /sessions` list, and sessions have no `revoked` state — revocation
deletes the session.

## Generated files

`src/routeTree.gen.ts` is generated by the TanStack Router Vite plugin. Do not
hand-edit it — add or change files under `src/routes/` and let the plugin
regenerate the tree (consistent with the root `AGENTS.md` "Generated Files"
rule).

## Local tasks

```sh
moon run console:dev-real   # seeded real backend + dev server (default loop)
moon run console:dev-claim  # same, but for the claim page (see below)
moon run console:dev        # dev server only on http://localhost:5174
moon run console:typecheck
moon run console:test
moon run console:build
```

## The claim page needs `dev-claim`, not `dev-real`

`dev-real` boots one ordinary project and signs the console into it. The claim
page is the console acting as the *platform's* claim surface, and
`claim/complete` only accepts a session belonging to the platform project — so
on `dev-real` the page renders but the claim always fails with "This account
can't claim the project". That is the harness, not a defect.

`moon run console:dev-claim` boots the platform project, pins the console to it,
and prints a ready claim link. The trade-off is why it is opt-in: pinning the
console to `proj_platform` changes the standalone semantics the demo and
embedded suites rely on, and the seeded users live in the project being claimed
rather than the platform one, so list screens read empty and you register on the
claim page instead of signing in with the seeded credentials.

Three things that break either loop before it starts, none of which say so
clearly:

- **Build the CLI first** (`moon run cli:build`). Both loops shell out to it to
  start the server; without a built bundle the server starts unmigrated and dies
  with `no such table: projects`.
- **Reinstall after a rebase** (`pnpm install`). A stale `node_modules` fails the
  built CLI on a missing transitive dependency, not on anything you changed.
- **A raw binary needs `--migrate`.** Migrations are opt-in since #1152; the CLI
  passes the flag for you, so this only bites when launching `dist/server/nextgen`
  by hand.

`CONSOLE_DEV_ORIGIN` sets the console's port for both loops, so a second worktree
can run beside the first.

## Develop against real data, not the mock

The console manages an instance, so **use `console:dev-real`** — it boots a real
ephemeral instance and seeds users, so list screens show real API responses.
`@zitadel/api-mock` has no user store; a users list read from it is a fiction.

Why the mock cannot substitute (authorization, the `user.read` scope, and the
publishable-key refusal) is documented canonically in the Local development
section of [`README.md`](README.md) — read it for all three backends and when
each applies. The mock's flow-shape authority rule lives in
[`packages/api-mock/AGENTS.md`](../../packages/api-mock/AGENTS.md).