From 8d6875f4b0511677a61e60a040677e5644a30230 Mon Sep 17 00:00:00 2001 From: MaKarin Date: Sat, 21 Mar 2026 16:00:27 +0300 Subject: [PATCH 1/3] keycloak init --- AUTH_RUNTIME.md | 60 ++++ auth/backend-auth-rules.md | 120 ++++++++ auth/frontend-auth-rules.md | 134 +++++++++ auth/keycloak-architecture.md | 89 ++++++ auth/keycloak-realm-template-rules.md | 152 ++++++++++ backend/architecture.md | 51 +++- backend/runtime-rules.md | 76 ++++- client/.env.example | 5 + client/package-lock.json | 10 + client/package.json | 1 + client/src/App.tsx | 3 +- client/src/auth/authProvider.ts | 45 +++ client/src/auth/keycloak.ts | 96 +++++++ client/src/config/env.ts | 24 ++ client/src/dataProvider.ts | 16 +- client/src/main.tsx | 27 +- client/src/vite-env.d.ts | 11 + frontend/architecture.md | 62 +++- frontend/react-admin-rules.md | 51 +++- general-prompt.md | 157 +++++++--- generation/backend-generation.md | 139 +++++++-- generation/dev-workflow.md | 76 ++++- generation/frontend-generation.md | 128 ++++++++- generation/post-generation-validation.md | 272 +++++++++++------- generation/runtime-bootstrap.md | 90 ++++-- generation/scaffolding-rules.md | 36 ++- generation/update-strategy.md | 42 ++- server/.env.example | 6 + server/package-lock.json | 10 + server/package.json | 1 + server/src/app.module.ts | 8 +- server/src/auth/auth.constants.ts | 3 + server/src/auth/auth.module.ts | 22 ++ server/src/auth/auth.service.ts | 129 +++++++++ .../src/auth/decorators/public.decorator.ts | 5 + server/src/auth/decorators/roles.decorator.ts | 6 + server/src/auth/guards/jwt-auth.guard.ts | 54 ++++ server/src/auth/guards/roles.guard.ts | 49 ++++ .../authenticated-user.interface.ts | 20 ++ .../interfaces/express-request.interface.ts | 12 + server/src/auth/roles/realm-role.enum.ts | 6 + server/src/config/env.validation.ts | 58 ++++ server/src/health/health.controller.ts | 2 + server/src/main.ts | 27 +- .../equipment-type.controller.ts | 7 + .../modules/equipment/equipment.controller.ts | 7 + .../repair-order/repair-order.controller.ts | 7 + server/test/app.e2e-spec.ts | 71 ++++- server/test/jest-e2e.json | 3 + server/test/mocks/jose.ts | 8 + 50 files changed, 2242 insertions(+), 252 deletions(-) create mode 100644 AUTH_RUNTIME.md create mode 100644 auth/backend-auth-rules.md create mode 100644 auth/frontend-auth-rules.md create mode 100644 auth/keycloak-architecture.md create mode 100644 auth/keycloak-realm-template-rules.md create mode 100644 client/.env.example create mode 100644 client/src/auth/authProvider.ts create mode 100644 client/src/auth/keycloak.ts create mode 100644 client/src/config/env.ts create mode 100644 server/src/auth/auth.constants.ts create mode 100644 server/src/auth/auth.module.ts create mode 100644 server/src/auth/auth.service.ts create mode 100644 server/src/auth/decorators/public.decorator.ts create mode 100644 server/src/auth/decorators/roles.decorator.ts create mode 100644 server/src/auth/guards/jwt-auth.guard.ts create mode 100644 server/src/auth/guards/roles.guard.ts create mode 100644 server/src/auth/interfaces/authenticated-user.interface.ts create mode 100644 server/src/auth/interfaces/express-request.interface.ts create mode 100644 server/src/auth/roles/realm-role.enum.ts create mode 100644 server/src/config/env.validation.ts create mode 100644 server/test/mocks/jose.ts diff --git a/AUTH_RUNTIME.md b/AUTH_RUNTIME.md new file mode 100644 index 0000000..f1e3bf2 --- /dev/null +++ b/AUTH_RUNTIME.md @@ -0,0 +1,60 @@ +# Runtime Keycloak Auth + +This repository now uses Keycloak-based authentication for the runtime application only (`client/` and `server/`). + +## Required environment variables + +### Frontend (`client/.env`) + +- `VITE_API_URL` (example: `http://localhost:3000`) +- `VITE_KEYCLOAK_URL` (example: `https://sso.greact.ru`) +- `VITE_KEYCLOAK_REALM` (example: `toir`) +- `VITE_KEYCLOAK_CLIENT_ID` (example: `toir-frontend`) + +The frontend fails fast at startup if any required auth/env variable is missing. + +### Backend (`server/.env`) + +- `PORT` (example: `3000`) +- `DATABASE_URL` +- `CORS_ALLOWED_ORIGINS` (comma-separated list) +- `KEYCLOAK_ISSUER_URL` (example: `https://sso.greact.ru/realms/toir`) +- `KEYCLOAK_AUDIENCE` (example: `toir-backend`) +- `KEYCLOAK_JWKS_URL` (optional) + +Backend validates required env vars at startup and fails fast if missing. + +## Frontend auth flow + +- The SPA initializes Keycloak before rendering. +- Authentication is redirect-based Keycloak login only (no custom username/password form). +- Authorization Code + PKCE (`S256`) is used. +- Access token is attached to every API request via `Authorization: Bearer `. +- `checkError` behavior: + - `401`: force re-authentication. + - `403`: keep session and surface access denied to React Admin. +- Token refresh is concurrency-safe: parallel requests share one in-flight refresh operation. + +## Backend JWT validation and RBAC + +- JWTs are verified against: + - issuer: `KEYCLOAK_ISSUER_URL` + - audience: `KEYCLOAK_AUDIENCE` +- JWKS resolution priority: + 1. explicit `KEYCLOAK_JWKS_URL` + 2. OIDC discovery (`/.well-known/openid-configuration`) + 3. fallback `${issuer}/protocol/openid-connect/certs` +- RBAC source is only `realm_access.roles`. + +Role policy applied to CRUD endpoints: + +- `GET`: `viewer`, `editor`, `admin` +- `POST`, `PATCH` (and `PUT` if added): `editor`, `admin` +- `DELETE`: `admin` + +Public route: + +- `GET /health` + +All other existing API routes require authentication. + diff --git a/auth/backend-auth-rules.md b/auth/backend-auth-rules.md new file mode 100644 index 0000000..00d34e9 --- /dev/null +++ b/auth/backend-auth-rules.md @@ -0,0 +1,120 @@ +# Backend Auth Rules + +This document defines mandatory backend authentication and authorization behavior for generated applications. + +--- + +# Generated Backend Auth Surface + +The generator must create explicit auth infrastructure in the generated NestJS backend. + +At minimum generate: + +- `server/src/auth/auth.module.ts` +- JWT auth guard +- roles guard +- `@Public()` +- `@Roles()` +- typed authenticated principal interface +- typed environment validation in `server/src/config/` + +The generator must not describe backend auth as an external manual integration step. + +--- + +# Standards-Based JWT Verification + +The generated backend must validate JWTs against Keycloak using standards-based libraries. + +Rules: + +1. Verify **issuer** +2. Verify **audience** +3. Verify signature via **JWKS** +4. Do **not** use deprecated Keycloak-specific Node adapters such as `keycloak-connect` + +The default library rule for this repository is: + +- **`jose`** for JWT and JWKS verification + +--- + +# JWT Verification Contract + +The generated backend must verify tokens with: + +- `KEYCLOAK_ISSUER_URL` +- `KEYCLOAK_AUDIENCE` + +JWKS resolution priority must be exactly: + +1. explicit `KEYCLOAK_JWKS_URL` +2. OIDC discovery +3. fallback `${issuer}/protocol/openid-connect/certs` + +The generator must encode this priority explicitly. + +--- + +# Role Extraction + +Authorization roles must be extracted **only** from: + +- `realm_access.roles` + +The generator must not mix in: + +- resource roles +- custom frontend-only permissions +- undocumented claim fallbacks + +`realm_access.roles` is the single RBAC source for this repository. + +--- + +# Default RBAC Policy + +Apply these RBAC defaults to generated CRUD controllers: + +- `GET`: `viewer`, `editor`, `admin` +- `POST`: `editor`, `admin` +- `PATCH`: `editor`, `admin` +- `PUT`: `editor`, `admin` +- `DELETE`: `admin` + +`GET /health` must remain public and must use the generated `@Public()` mechanism. + +All other generated CRUD routes must be protected by default. + +--- + +# Typed Principal + +The generated backend must attach a typed authenticated principal to the request context. + +At minimum, the generated principal type must be able to represent: + +- `sub` +- user identity fields when present +- `roles` +- raw claims payload + +This principal type is required so guards, controllers, and tests share one consistent contract. + +--- + +# Backend Environment Contract + +The generated backend env contract must include: + +- `PORT` +- `DATABASE_URL` +- `CORS_ALLOWED_ORIGINS` +- `KEYCLOAK_ISSUER_URL` +- `KEYCLOAK_AUDIENCE` +- `KEYCLOAK_JWKS_URL` (optional) + +The generated backend config must **fail fast** if required auth variables are missing. + +The generated backend must not silently fall back to production auth settings in code. + diff --git a/auth/frontend-auth-rules.md b/auth/frontend-auth-rules.md new file mode 100644 index 0000000..394d44d --- /dev/null +++ b/auth/frontend-auth-rules.md @@ -0,0 +1,134 @@ +# Frontend Auth Rules + +This document defines mandatory frontend authentication behavior for generated applications. + +--- + +# Generated Files + +The generator must create at minimum: + +- `client/src/config/env.ts` +- `client/src/auth/keycloak.ts` +- `client/src/auth/authProvider.ts` +- `client/src/App.tsx` +- `client/src/main.tsx` +- `client/src/dataProvider.ts` +- `client/.env.example` + +`App.tsx`, `main.tsx`, and `dataProvider.ts` must be generated in an auth-aware form. Auth must not be bolted on later. + +--- + +# Login Model + +The generated frontend must use: + +- **Keycloak JS** +- **Authorization Code Flow + PKCE** +- **PKCE method `S256`** +- **redirect-based login only** + +The generator must **not** create a custom in-app username/password login form. + +The generated SPA must initialize Keycloak **before rendering** the application. The app must not operate anonymously once auth is enabled. + +--- + +# React Admin Integration + +The generated frontend must use a React Admin **`authProvider`** and connect it at the `Admin` root. + +Rules: + +1. `authProvider` is mandatory. +2. The generated `Admin` root must enforce authenticated operation. +3. Auth bootstrap must happen before rendering `Admin`. + +The generated frontend must not rely on anonymous access with later lazy auth attachment. + +--- + +# Shared Request Seam + +The generated frontend must use the shared request seam in `client/src/dataProvider.ts` as the single place where access tokens are attached. + +Rules: + +1. All backend requests must carry `Authorization: Bearer `. +2. This must cover all React Admin calls, including: + - list + - get one + - get many + - get many reference + - create + - update + - delete +3. Reference/resource lookups must flow through the same authenticated request seam. + +The generator must not scatter token attachment across resource components. + +--- + +# Error Semantics + +The generated `authProvider.checkError` must distinguish authentication failures from authorization failures: + +- `401` -> force logout / re-authentication +- `403` -> do **not** re-authenticate; surface access denied / permission error to React Admin + +The generator must not treat `401` and `403` as equivalent. + +--- + +# Token Refresh + +The generated frontend must refresh tokens before protected API calls when needed. + +Refresh behavior must be **concurrency-safe**: + +- use one shared in-flight refresh operation +- parallel requests must wait for the same refresh promise +- do not trigger multiple parallel refresh requests for the same expiry window + +The generator must explicitly describe or implement the shared in-flight refresh pattern. + +--- + +# Browser Storage Rules + +The generated frontend must **not** store access tokens or refresh tokens in: + +- `localStorage` +- `sessionStorage` + +In-memory handling via Keycloak JS behavior is the default rule for this repository. + +--- + +# Frontend Environment Contract + +The generated frontend env contract must include: + +- `VITE_API_URL` +- `VITE_KEYCLOAK_URL` +- `VITE_KEYCLOAK_REALM` +- `VITE_KEYCLOAK_CLIENT_ID` + +The generated frontend config module must **fail fast** if required auth variables are missing. + +The generated frontend must not silently fall back to production auth settings in code. + +--- + +# Default Values for Examples + +`client/.env.example` must use these repository defaults as examples: + +```env +VITE_API_URL=http://localhost:3000 +VITE_KEYCLOAK_URL=https://sso.greact.ru +VITE_KEYCLOAK_REALM=toir +VITE_KEYCLOAK_CLIENT_ID=toir-frontend +``` + diff --git a/auth/keycloak-architecture.md b/auth/keycloak-architecture.md new file mode 100644 index 0000000..b7480bb --- /dev/null +++ b/auth/keycloak-architecture.md @@ -0,0 +1,89 @@ +# Keycloak Architecture + +This repository generates a fullstack CRUD application with **default Keycloak authentication and authorization**. Authentication is not an optional add-on and must not be described as a manual post-generation task. + +--- + +# Default Auth Model + +The generated application must use this runtime topology: + +- **Frontend:** SPA served by Vite + React Admin +- **Backend:** NestJS API +- **Auth transport:** `Authorization: Bearer ` +- **Identity provider:** external Keycloak server + +The generated application must **not** use: + +- a BFF layer +- cookie/session authentication +- a custom in-app username/password login form + +The generated application must use **redirect-based Keycloak login** only. + +--- + +# Auth Is Part of Default Generation + +The generator must produce: + +- authenticated frontend bootstrap and request flow +- authenticated backend bootstrap and request guards +- auth-aware env examples +- auth-aware validation rules +- a root-level Keycloak realm import artifact that describes the Keycloak realm/client bootstrap + +Repository defaults may use names such as `toir`, `toir-frontend`, `toir-backend`, and `toir-realm.json`, but future generations must parameterize realm name, client IDs, production URLs, and artifact filename from the generated project or explicit auth configuration. + +The generated application must not require a separate prompt step such as "now add auth manually". + +--- + +# Public and Protected Routes + +The generated backend must keep: + +- `GET /health` — public + +All generated CRUD routes must be protected by default. + +Authorization must be role-based and must use **only** Keycloak realm roles from `realm_access.roles`. + +--- + +# Default Role Policy + +Apply these RBAC defaults to generated CRUD controllers: + +- `GET` list/detail: `viewer`, `editor`, `admin` +- `POST`, `PATCH`, `PUT`: `editor`, `admin` +- `DELETE`: `admin` + +The frontend may use roles for display and permission awareness, but the backend remains the enforcement point. + +--- + +# Token Contract + +Generated auth context must guarantee that access tokens used by the SPA and API reliably contain: + +- `sub` +- `aud` +- `realm_access.roles` + +The frontend client and backend audience/client must be documented explicitly. The generator must not rely on undocumented Keycloak defaults for these claims. + +--- + +# Runtime Boundaries + +The repository's local runtime topology remains unchanged: + +- `docker-compose.yml` provisions PostgreSQL only +- Keycloak remains external to this repo's Docker runtime + +The generator must therefore produce: + +- runtime documentation for importing/configuring the generated realm import artifact +- env contracts for frontend and backend +- validation rules that assume an external but reachable Keycloak server diff --git a/auth/keycloak-realm-template-rules.md b/auth/keycloak-realm-template-rules.md new file mode 100644 index 0000000..1acffb2 --- /dev/null +++ b/auth/keycloak-realm-template-rules.md @@ -0,0 +1,152 @@ +# Keycloak Realm Template Rules + +This document defines the required Keycloak realm/bootstrap artifact that the generator must produce. + +--- + +# Required Artifact + +The generator must create a root-level Keycloak import artifact: + +- project-specific realm import artifact +- repository default example filename: `toir-realm.json` + +This artifact is part of the normal generated result and must be documented in the runtime bootstrap flow. + +The generator must parameterize at minimum: + +- realm name +- frontend SPA client ID +- backend audience/resource client ID +- production frontend URLs +- realm-artifact filename + +Repository defaults may use `toir`, `toir-frontend`, `toir-backend`, `toir-realm.json`, and `https://toir-frontend.greact.ru` as examples, but those names must not be treated as universal requirements for all future generated applications. + +--- + +# Strategy Choice + +This repository uses the **robust bootstrap strategy** by default. + +That means the generated realm/client setup must be: + +- self-contained +- reproducible after import +- explicit about audience delivery +- explicit about role delivery +- explicit about required claims + +The generator must **not** rely on built-in client scopes magically existing and attaching correctly after realm import. + +--- + +# Required Realm Structure + +The generated realm import artifact must define: + +- realm name derived from generated project auth config +- realm roles: + - `admin` + - `editor` + - `viewer` +- frontend SPA client ID derived from generated project auth config +- backend audience/resource client ID derived from generated project auth config +- dedicated audience client scope for audience delivery + - repository default example: `api-audience` + +The audience client scope must explicitly add the generated backend audience/client ID to SPA access tokens. + +--- + +# Required Frontend Client Rules + +The generated SPA client must be configured as a public SPA client with: + +- `publicClient: true` +- `standardFlowEnabled: true` +- `implicitFlowEnabled: false` +- `directAccessGrantsEnabled: false` +- `serviceAccountsEnabled: false` +- `pkce.code.challenge.method: S256` + +Default rule: + +- keep `fullScopeAllowed: true` + +Reason: + +- this repository uses realm-role RBAC +- earlier failures came from fragile or implicit scope behavior +- the robust default is explicit audience delivery plus explicit claim mappers, not a partially documented scoped-role setup + +If a future prompt chooses `fullScopeAllowed: false`, it must also fully document role scope mappings and validation rules. The generator must not switch to that strategy silently. + +--- + +# Required Claim Delivery + +The generated realm/client configuration must reliably produce access tokens containing: + +- `sub` +- `aud` +- `realm_access.roles` + +The generator must explicitly address claim delivery and must not leave it implicit. + +The generated realm import artifact must therefore define explicit protocol mappers for: + +- `sub` +- user identity claims needed by the frontend +- `realm_access.roles` + +The generator must not assume these claims will appear through undeclared built-in scopes. + +Post-generation validation must fail if the generated realm contract leaves `sub`, `aud`, or `realm_access.roles` implicit instead of explicitly delivered. + +--- + +# Required Role Delivery + +The generated realm/client configuration must explicitly deliver realm roles into the access token. + +Rules: + +1. Role delivery must be configured intentionally. +2. `realm_access.roles` must be present in the access token after import. +3. The generator must validate that realm role delivery is part of the generated realm artifact. + +--- + +# Redirect URIs and Web Origins + +The generated realm template guidance must document local and production frontend URLs derived from the generated project configuration. + +Repository default production examples may include: + +- `http://localhost:5173` +- `https://toir-frontend.greact.ru` + +The generated SPA client must include matching redirect URIs and web origins for the generated local and production frontend URLs. These values must be explicit in the generated realm artifact or in a generated artifact template with explicit placeholders and validation instructions. + +--- + +# Anti-Regression Rule + +The earlier broken realm-template behavior must not be reproduced. + +The generator context must explicitly prevent: + +- missing audience in SPA access tokens +- missing `sub` +- missing `realm_access.roles` +- realm imports that depend on undeclared built-in scopes being present +- ambiguous role-delivery behavior + +The generated realm/bootstrap guidance must be self-contained enough that another engineer can import the artifact and predict the access-token shape without relying on hidden Keycloak defaults. + +The generator must guarantee through the generated realm artifact plus post-generation validation that imported access tokens reliably contain: + +- `sub` +- `aud` +- `realm_access.roles` diff --git a/backend/architecture.md b/backend/architecture.md index 36b05a9..a8a7ec5 100644 --- a/backend/architecture.md +++ b/backend/architecture.md @@ -7,6 +7,7 @@ Backend stack: - NestJS - Prisma ORM - PostgreSQL +- jose The backend is generated from the DSL specification. @@ -32,6 +33,15 @@ src/ main.ts app.module.ts + auth/ + auth.module.ts + guards/ + decorators/ + interfaces/ + + config/ + env.validation.ts + modules/ {entity}/ @@ -105,6 +115,7 @@ Every generated backend must expose a **health endpoint** so that runtime and or ## Controller example ```typescript +@Public() @Controller("health") export class HealthController { @Get() @@ -118,6 +129,42 @@ Register the health controller in the root app module (or a dedicated health mod --- +# Authentication and Authorization + +The generated backend must include explicit auth infrastructure by default. + +Generate: + +- `AuthModule` +- JWT guard +- roles guard +- `@Public()` +- `@Roles()` +- typed authenticated principal +- typed env validation for auth/runtime variables + +Rules: + +1. `/health` must remain public. +2. Generated CRUD routes must be protected by default. +3. JWT verification must use issuer + audience + JWKS. +4. Authorization roles must be extracted only from `realm_access.roles`. +5. The generated backend must not use deprecated Keycloak-specific Node adapters. + +## CRUD RBAC defaults + +Apply these defaults to generated CRUD controllers: + +- `GET` -> `viewer`, `editor`, `admin` +- `POST` -> `editor`, `admin` +- `PATCH` -> `editor`, `admin` +- `PUT` -> `editor`, `admin` +- `DELETE` -> `admin` + +These defaults must be encoded in generated guards/decorators, not left as informal guidance. + +--- + # List Endpoint List endpoint must support pagination and filters via query parameters. @@ -247,8 +294,8 @@ See **Controller Rules** above for the rule that :pk must match the entity's pri # Environment and runtime -- **Environment variables:** Backend requires at least `DATABASE_URL`. See **backend/runtime-rules.md**. -- **.env:** Generated project must include a `.env` (and `.env.example`) with `DATABASE_URL` so the app starts without runtime errors. +- **Environment variables:** Backend requires runtime and auth variables. See **backend/runtime-rules.md**. +- **.env:** Generated project must include a `.env` (and `.env.example`) with database, auth, and CORS variables so the app starts without runtime errors. - **PrismaService:** Must follow **backend/prisma-service.md** (OnModuleInit, $connect; no beforeExit). - **Prisma client:** Add `"postinstall": "prisma generate"` (or equivalent) to package.json so the client is generated after install. - **Migrations:** Document or run `npx prisma migrate dev` after schema generation. See **backend/runtime-rules.md** and **generation/backend-generation.md**. diff --git a/backend/runtime-rules.md b/backend/runtime-rules.md index 57762a0..9f939b7 100644 --- a/backend/runtime-rules.md +++ b/backend/runtime-rules.md @@ -10,14 +10,24 @@ The backend **must** have a `.env` file at the project root (e.g. `server/.env` ## Required variables -| Variable | Required | Description | -| ------------ | -------- | --------------------------------------- | -| DATABASE_URL | Yes | PostgreSQL connection string for Prisma | +| Variable | Required | Description | +| -------------------- | -------- | ----------- | +| PORT | Yes | Backend listen port | +| DATABASE_URL | Yes | PostgreSQL connection string for Prisma | +| CORS_ALLOWED_ORIGINS | Yes | Comma-separated SPA origins allowed to call the API | +| KEYCLOAK_ISSUER_URL | Yes | Keycloak issuer URL used for JWT verification | +| KEYCLOAK_AUDIENCE | Yes | Backend audience/client expected in access tokens | +| KEYCLOAK_JWKS_URL | No | Optional explicit JWKS URL | ## Example ```env +PORT=3000 DATABASE_URL="postgresql://postgres:postgres@localhost:5432/toir" +CORS_ALLOWED_ORIGINS="http://localhost:5173,https://toir-frontend.greact.ru" +KEYCLOAK_ISSUER_URL="https://sso.greact.ru/realms/toir" +KEYCLOAK_AUDIENCE="toir-backend" +# KEYCLOAK_JWKS_URL="https://sso.greact.ru/realms/toir/protocol/openid-connect/certs" ``` ## Generation requirement @@ -25,7 +35,7 @@ DATABASE_URL="postgresql://postgres:postgres@localhost:5432/toir" When generating the backend, **always** create: 1. **`.env.example`** — with placeholder DATABASE_URL and instructions. -2. **`.env`** — with the same placeholder so the app can start; user replaces with real values. +2. **`.env`** — with local development example values so the app can start. If the generator does not create `.env`, the first run will fail with: @@ -33,6 +43,34 @@ If the generator does not create `.env`, the first run will fail with: Environment variable not found: DATABASE_URL ``` +## Fail-fast requirement + +The generated backend must validate required runtime and auth variables at startup. + +Rules: + +1. Missing required auth variables must stop startup immediately. +2. The generated backend must not silently fall back to production auth settings in code. +3. Auth env validation must be implemented explicitly in generated config/bootstrap code. + +--- + +# Authentication Runtime Rules + +The generated backend must verify JWTs against Keycloak using: + +- issuer +- audience +- JWKS + +JWKS resolution priority must be exactly: + +1. explicit `KEYCLOAK_JWKS_URL` +2. OIDC discovery +3. `${issuer}/protocol/openid-connect/certs` + +The generated backend must extract authorization roles only from `realm_access.roles`. + --- # Prisma Client Generation @@ -85,10 +123,30 @@ Run after: --- +# CORS Rules + +The generated backend must support the SPA -> API bearer-token model explicitly. + +Rules: + +1. Use `CORS_ALLOWED_ORIGINS` as the allowed origin list. +2. Allow at minimum these example origins in `.env.example`: + - `http://localhost:5173` + - `https://toir-frontend.greact.ru` +3. Allow `Authorization` and JSON content headers. +4. Expose `Content-Range` because React Admin depends on it. +5. Do not enable credentials by default. + +--- + # Summary -| Requirement | Action | -| --------------- | ------------------------------------------------------------- | -| DATABASE_URL | Create `.env` and `.env.example` with DATABASE_URL | -| Prisma client | Run `npx prisma generate`; add `postinstall` script | -| Database schema | Document/run `npx prisma migrate dev` after schema generation | +| Requirement | Action | +| ----------------------- | ------ | +| Runtime env | Create `.env` and `.env.example` with DB, auth, and CORS variables | +| Fail-fast config | Validate required auth/runtime variables at startup | +| Prisma client | Run `npx prisma generate`; add `postinstall` script | +| Database schema | Document/run `npx prisma migrate dev` after schema generation | +| JWT verification | Use issuer + audience + JWKS with explicit resolution priority | +| Role extraction | Use only `realm_access.roles` | +| CORS | Support SPA -> API bearer flow and expose `Content-Range` | diff --git a/client/.env.example b/client/.env.example new file mode 100644 index 0000000..d849548 --- /dev/null +++ b/client/.env.example @@ -0,0 +1,5 @@ +VITE_API_URL=http://localhost:3000 +VITE_KEYCLOAK_URL=https://sso.greact.ru +VITE_KEYCLOAK_REALM=toir +VITE_KEYCLOAK_CLIENT_ID=toir-frontend + diff --git a/client/package-lock.json b/client/package-lock.json index 2aead9f..b045299 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -11,6 +11,7 @@ "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@mui/material": "^7.3.9", + "keycloak-js": "^26.2.3", "ra-data-simple-rest": "^5.14.4", "react": "^18.2.0", "react-admin": "^5.14.4", @@ -3572,6 +3573,15 @@ "jsonexport": "bin/jsonexport.js" } }, + "node_modules/keycloak-js": { + "version": "26.2.3", + "resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-26.2.3.tgz", + "integrity": "sha512-widjzw/9T6bHRgEp6H/Se3NCCarU7u5CwFKBcwtu7xfA1IfdZb+7Q7/KGusAnBo34Vtls8Oz9vzSqkQvQ7+b4Q==", + "license": "Apache-2.0", + "workspaces": [ + "test" + ] + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", diff --git a/client/package.json b/client/package.json index 86bcda1..fb9fac3 100644 --- a/client/package.json +++ b/client/package.json @@ -13,6 +13,7 @@ "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@mui/material": "^7.3.9", + "keycloak-js": "^26.2.3", "ra-data-simple-rest": "^5.14.4", "react": "^18.2.0", "react-admin": "^5.14.4", diff --git a/client/src/App.tsx b/client/src/App.tsx index ad3a1ea..91a825f 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,5 +1,6 @@ import { Admin, Resource } from 'react-admin'; import dataProvider from './dataProvider'; +import authProvider from './auth/authProvider'; import { EquipmentTypeList } from './resources/equipment-type/EquipmentTypeList'; import { EquipmentTypeCreate } from './resources/equipment-type/EquipmentTypeCreate'; @@ -17,7 +18,7 @@ import { RepairOrderEdit } from './resources/repair-order/RepairOrderEdit'; import { RepairOrderShow } from './resources/repair-order/RepairOrderShow'; const App = () => ( - + { + await initKeycloak(); + }, + + logout: async () => { + await logoutFromKeycloak(); + }, + + checkAuth: async () => { + await getValidAccessToken(); + }, + + checkError: async (error) => { + const status = error?.status; + + if (status === 401) { + await forceReauthentication(); + return Promise.reject(error); + } + + if (status === 403) { + return Promise.resolve(); + } + + return Promise.resolve(); + }, + + getIdentity: async () => getIdentity(), + + getPermissions: async () => getRealmRoles(), +}; + +export default authProvider; + diff --git a/client/src/auth/keycloak.ts b/client/src/auth/keycloak.ts new file mode 100644 index 0000000..2103915 --- /dev/null +++ b/client/src/auth/keycloak.ts @@ -0,0 +1,96 @@ +import Keycloak, { KeycloakTokenParsed } from 'keycloak-js'; +import { env } from '../config/env'; + +interface RealmAccessTokenParsed extends KeycloakTokenParsed { + realm_access?: { + roles: string[]; + }; +} + +const keycloak = new Keycloak({ + url: env.keycloakUrl, + realm: env.keycloakRealm, + clientId: env.keycloakClientId, +}); + +let keycloakInitPromise: Promise | null = null; +let refreshInFlight: Promise | null = null; + +export function getKeycloak() { + return keycloak; +} + +export async function initKeycloak() { + if (!keycloakInitPromise) { + keycloakInitPromise = keycloak + .init({ + onLoad: 'login-required', + pkceMethod: 'S256', + checkLoginIframe: false, + }) + .then((authenticated) => { + if (!authenticated) { + return keycloak.login({ redirectUri: window.location.href }); + } + }); + } + + await keycloakInitPromise; +} + +async function refreshAccessToken(minValiditySeconds = 30) { + if (!refreshInFlight) { + refreshInFlight = keycloak + .updateToken(minValiditySeconds) + .then(() => undefined) + .finally(() => { + refreshInFlight = null; + }); + } + + await refreshInFlight; +} + +export async function getValidAccessToken(minValiditySeconds = 30): Promise { + await initKeycloak(); + + if (!keycloak.authenticated) { + await keycloak.login({ redirectUri: window.location.href }); + throw new Error('User is not authenticated'); + } + + await refreshAccessToken(minValiditySeconds); + + if (!keycloak.token) { + throw new Error('Missing access token'); + } + + return keycloak.token; +} + +export async function forceReauthentication() { + keycloak.clearToken(); + await keycloak.login({ redirectUri: window.location.href }); +} + +export async function logoutFromKeycloak() { + await keycloak.logout({ redirectUri: window.location.origin }); +} + +export function getRealmRoles(): string[] { + const parsed = keycloak.tokenParsed as RealmAccessTokenParsed | undefined; + const roles = parsed?.realm_access?.roles; + return Array.isArray(roles) ? roles : []; +} + +export function getIdentity() { + const parsed = keycloak.tokenParsed as RealmAccessTokenParsed | undefined; + const id = parsed?.sub ?? 'unknown'; + const fullName = + parsed?.name ?? + parsed?.preferred_username ?? + parsed?.email ?? + 'Unknown User'; + + return { id, fullName }; +} diff --git a/client/src/config/env.ts b/client/src/config/env.ts new file mode 100644 index 0000000..818f12e --- /dev/null +++ b/client/src/config/env.ts @@ -0,0 +1,24 @@ +const REQUIRED_ENV_KEYS = [ + 'VITE_API_URL', + 'VITE_KEYCLOAK_URL', + 'VITE_KEYCLOAK_REALM', + 'VITE_KEYCLOAK_CLIENT_ID', +] as const; + +type RequiredEnvKey = (typeof REQUIRED_ENV_KEYS)[number]; + +function readRequiredEnv(key: RequiredEnvKey): string { + const value = import.meta.env[key]; + if (!value || !value.trim()) { + throw new Error(`Missing required environment variable: ${key}`); + } + return value; +} + +export const env = { + apiUrl: readRequiredEnv('VITE_API_URL'), + keycloakUrl: readRequiredEnv('VITE_KEYCLOAK_URL'), + keycloakRealm: readRequiredEnv('VITE_KEYCLOAK_REALM'), + keycloakClientId: readRequiredEnv('VITE_KEYCLOAK_CLIENT_ID'), +} as const; + diff --git a/client/src/dataProvider.ts b/client/src/dataProvider.ts index fd208f5..9a47d63 100644 --- a/client/src/dataProvider.ts +++ b/client/src/dataProvider.ts @@ -1,7 +1,19 @@ import { DataProvider, fetchUtils } from 'react-admin'; +import { getValidAccessToken } from './auth/keycloak'; +import { env } from './config/env'; -const apiUrl = 'http://localhost:3000'; -const httpClient = fetchUtils.fetchJson; +const apiUrl = env.apiUrl; + +const httpClient = async (url: string, options: fetchUtils.Options = {}) => { + const token = await getValidAccessToken(); + const headers = new Headers(options.headers ?? { Accept: 'application/json' }); + headers.set('Authorization', `Bearer ${token}`); + + return fetchUtils.fetchJson(url, { + ...options, + headers, + }); +}; const dataProvider: DataProvider = { getList: async (resource, params) => { diff --git a/client/src/main.tsx b/client/src/main.tsx index c018515..be3fe9e 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -1,9 +1,26 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; +import { initKeycloak } from './auth/keycloak'; -ReactDOM.createRoot(document.getElementById('root')!).render( - - - , -); +const root = ReactDOM.createRoot(document.getElementById('root')!); + +async function bootstrap() { + await initKeycloak(); + + root.render( + + + , + ); +} + +bootstrap().catch((error) => { + console.error('Failed to initialize authentication', error); + + root.render( + +
Authentication initialization failed. Check your environment variables.
+
, + ); +}); diff --git a/client/src/vite-env.d.ts b/client/src/vite-env.d.ts index 11f02fe..7de5aa4 100644 --- a/client/src/vite-env.d.ts +++ b/client/src/vite-env.d.ts @@ -1 +1,12 @@ /// + +interface ImportMetaEnv { + readonly VITE_API_URL: string; + readonly VITE_KEYCLOAK_URL: string; + readonly VITE_KEYCLOAK_REALM: string; + readonly VITE_KEYCLOAK_CLIENT_ID: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/frontend/architecture.md b/frontend/architecture.md index 42aefcb..7a43a47 100644 --- a/frontend/architecture.md +++ b/frontend/architecture.md @@ -7,11 +7,14 @@ Frontend stack: - Vite - React Admin - shadcn/ui +- Keycloak JS The frontend is generated from the DSL and API specification. Each entity becomes a React Admin resource. +The generated frontend must also include Keycloak authentication by default. + --- # Project Structure @@ -21,6 +24,17 @@ client/ App.tsx + main.tsx + + dataProvider.ts + + auth/ + keycloak.ts + authProvider.ts + + config/ + env.ts + resources/ {entity}/ @@ -35,6 +49,13 @@ client/ Each resource must be registered in App.tsx. +The generated `App.tsx` must register: + +- `dataProvider` +- `authProvider` + +The generated `Admin` root must enforce authenticated operation. The generated frontend must not operate anonymously once auth is enabled. + Example: ` in this shared seam. +3. Cover all React Admin operations, including references and bulk fetches. +4. Do not scatter auth headers across resource components. + +--- + +# Application Bootstrap + +The generated `main.tsx` must initialize Keycloak before rendering the SPA. + +Rules: + +1. Use redirect-based Keycloak login only. +2. Use Authorization Code + PKCE (`S256`). +3. Do not generate a custom in-app username/password login form. +4. Do not render the authenticated admin app before Keycloak initialization completes. + +--- + +# Config + +The generated frontend must include a dedicated config module in `src/config/`. + +Required env variables: + +- `VITE_API_URL` +- `VITE_KEYCLOAK_URL` +- `VITE_KEYCLOAK_REALM` +- `VITE_KEYCLOAK_CLIENT_ID` + +The generated frontend config must fail fast if required auth variables are missing. The generated frontend must not silently fall back to production auth settings in code. + --- # Foreign Keys @@ -120,4 +178,4 @@ React Admin resource name (used in `` and in `reference` Examples in App.tsx: - `` - `` -- `` \ No newline at end of file +- `` diff --git a/frontend/react-admin-rules.md b/frontend/react-admin-rules.md index 66ebee4..b67f185 100644 --- a/frontend/react-admin-rules.md +++ b/frontend/react-admin-rules.md @@ -4,6 +4,55 @@ Entity attributes determine UI fields. --- +# Authentication + +Generated React Admin applications in this repository must include an `authProvider`. + +Rules: + +1. `authProvider` is mandatory. +2. The generated app must use redirect-based Keycloak login only. +3. The generator must not create a custom in-app username/password form. +4. The generated app must initialize authentication before rendering the admin UI. + +--- + +# Shared Authenticated Request Layer + +The generated frontend must attach bearer tokens through the shared request seam in `client/src/dataProvider.ts`. + +Rules: + +1. All resource calls must use the same authenticated request layer. +2. Reference lookups must use the same authenticated request layer. +3. The generated frontend must not attach auth headers directly inside resource components. + +--- + +# Error Handling + +The generated `authProvider.checkError` must distinguish authentication failures from authorization failures: + +- `401` -> force logout / re-authentication +- `403` -> do not re-authenticate; surface access denied / permission error + +The generator must not treat `401` and `403` as the same outcome. + +--- + +# Token Handling + +The generated frontend must use Keycloak JS token handling with these rules: + +1. Use Authorization Code + PKCE (`S256`). +2. Refresh tokens before protected API calls when needed. +3. Token refresh must be concurrency-safe: + - one shared in-flight refresh operation + - no parallel refresh stampede +4. Do not store access tokens or refresh tokens in `localStorage` or `sessionStorage`. + +--- + # Type Mapping | DSL Type | React Admin Component | @@ -95,4 +144,4 @@ API response must include `id` so React Admin can identify the record: If the response only had `{ "code": "pump", "name": "Pump" }`, React Admin would not work correctly because it expects `id`. The backend or frontend adapter must therefore set `id: record.code` (or equivalent) when the primary key is not `id`. -This rule ensures compatibility with React Admin resource identity handling. \ No newline at end of file +This rule ensures compatibility with React Admin resource identity handling. diff --git a/general-prompt.md b/general-prompt.md index df1eaa5..2ad0bfe 100644 --- a/general-prompt.md +++ b/general-prompt.md @@ -14,6 +14,10 @@ Prisma documentation React Admin documentation +Vite documentation + +Keycloak documentation + Docker documentation The generated application must run without manual fixes. @@ -24,7 +28,7 @@ You must read the project documentation in the following strict order: domain/dsl-spec.md -examples/\*.dsl +examples/*.dsl backend/architecture.md @@ -44,6 +48,14 @@ frontend/architecture.md frontend/react-admin-rules.md +auth/keycloak-architecture.md + +auth/frontend-auth-rules.md + +auth/backend-auth-rules.md + +auth/keycloak-realm-template-rules.md + generation/scaffolding-rules.md generation/backend-generation.md @@ -58,7 +70,9 @@ Do not ignore any rules defined in these documents. GOAL -Generate a DSL-driven fullstack CRUD system. +Generate a DSL-driven fullstack CRUD system with default Keycloak authentication and authorization. + +Repository-specific defaults and examples may use names such as `toir`, `toir-frontend`, `toir-backend`, `toir-realm.json`, and `*.greact.ru`, but the generator must parameterize realm name, client IDs, production URLs, and realm-artifact filename for other generated projects. Stack: @@ -72,6 +86,8 @@ Prisma ORM PostgreSQL +jose + Frontend React @@ -84,16 +100,21 @@ MUI shadcn/ui +Keycloak JS + PROJECT STRUCTURE Root docker-compose.yml +root-level Keycloak realm import artifact (default example filename: `toir-realm.json`) server/ client/ Backend server/ src/ +auth/ +config/ modules/{entity}/ prisma/schema.prisma prisma/seed.ts @@ -101,9 +122,15 @@ prisma/seed.ts .env.example Frontend -client/src/resources/{entity}/ -client/src/App.tsx -client/src/dataProvider.ts +client/ +src/ +auth/ +config/ +resources/{entity}/ +App.tsx +main.tsx +dataProvider.ts +.env.example STEP 1 — Parse DSL @@ -134,6 +161,7 @@ Backend @prisma/client prisma @nestjs/config +jose Frontend @@ -142,6 +170,7 @@ ra-data-simple-rest @mui/material @emotion/react @emotion/styled +keycloak-js STEP 4 — Generate Prisma schema @@ -165,7 +194,7 @@ DTO mapping decimal → string date → ISO string -STEP 5 — Generate NestJS modules +STEP 5 — Generate NestJS CRUD modules Per entity generate: @@ -190,7 +219,31 @@ Examples /equipment-types/:code /repair-orders/:id -STEP 6 — Generate Service Layer +STEP 6 — Generate backend auth infrastructure + +Generate: + +AuthModule +JWT guard +roles guard +@Public() +@Roles() +typed authenticated principal +typed env validation + +Rules: + +- `/health` must remain public +- CRUD routes must be protected by default +- RBAC source must be `realm_access.roles` +- JWT verification must use issuer + audience + JWKS +- JWKS resolution priority must be: + 1. explicit `KEYCLOAK_JWKS_URL` + 2. OIDC discovery + 3. `${issuer}/protocol/openid-connect/certs` +- Do not use deprecated Keycloak-specific Node adapters + +STEP 7 — Generate Service Layer Service layer must follow backend/service-rules.md. @@ -229,7 +282,7 @@ data Example (PK = id) -const { id: \_pk, ...data } = dto +const { id: _pk, ...data } = dto return prisma.entity.update({ where: { id }, @@ -244,36 +297,58 @@ id primary key attribute readonly attributes -STEP 7 — Generate PrismaService +STEP 8 — Generate frontend auth integration -Requirements +Generate: -extends PrismaClient -implements OnModuleInit -await this.$connect() +client/src/config/env.ts +client/src/auth/keycloak.ts +client/src/auth/authProvider.ts -Do NOT use +Rules: -beforeExit +- Keycloak login must be redirect-based only +- Use Authorization Code + PKCE (`S256`) +- Initialize Keycloak before rendering the SPA +- Attach `Authorization: Bearer ` through the shared request seam in `client/src/dataProvider.ts` +- `401` must force re-authentication +- `403` must surface access denied without forcing re-authentication +- Token refresh must be concurrency-safe +- Do not store tokens in `localStorage` or `sessionStorage` +- Frontend auth config must fail fast if required auth vars are missing -STEP 8 — Generate runtime infrastructure +STEP 9 — Generate runtime infrastructure -Create +Create: server/.env server/.env.example +client/.env.example +root-level Keycloak realm import artifact (default example filename: `toir-realm.json`) -DATABASE_URL example +Backend env examples must include: -postgresql://postgres:postgres@localhost:5432/toir +PORT +DATABASE_URL +CORS_ALLOWED_ORIGINS +KEYCLOAK_ISSUER_URL +KEYCLOAK_AUDIENCE +KEYCLOAK_JWKS_URL (optional) -Add to package.json +Frontend env examples must include: + +VITE_API_URL +VITE_KEYCLOAK_URL +VITE_KEYCLOAK_REALM +VITE_KEYCLOAK_CLIENT_ID + +Add to package.json: postinstall: prisma generate -STEP 9 — Database runtime +STEP 10 — Database runtime -Generate root +Generate root: docker-compose.yml @@ -282,25 +357,25 @@ PostgreSQL container postgres:16 port 5432 -STEP 10 — Generate seed +STEP 11 — Generate seed -Create +Create: server/prisma/seed.ts -Seed minimal data for +Seed minimal data for: EquipmentType Equipment RepairOrder -Add to package.json +Add to package.json: prisma.seed -STEP 11 — Generate React Admin +STEP 12 — Generate React Admin resources -For each entity generate +For each entity generate: Field mapping @@ -310,7 +385,7 @@ date → DateInput enum → SelectInput FK → ReferenceInput -API responses MUST contain +API responses MUST contain: If PK ≠ id, map primary key to id. @@ -321,9 +396,9 @@ id: record.code, code: record.code } -STEP 12 — Validation +STEP 13 — Validation -Verify +Verify: docker-compose.yml exists database container starts @@ -332,26 +407,36 @@ prisma db seed works API responds /health React Admin receives id update services sanitize payload before Prisma +frontend auth files exist +backend auth files exist +auth env examples exist +public /health is preserved +unauthenticated protected route returns 401 +insufficient role returns 403 +generated realm import artifact is self-contained and guarantees `sub`, `aud`, and `realm_access.roles` OUTPUT -Provide +Provide: FULLSTACK GENERATION REPORT -Include +Include: 1 Parsed DSL 2 Prisma models 3 Backend modules 4 API endpoints 5 React Admin resources -6 Runtime configuration -7 Validation results +6 Authentication and authorization +7 Runtime configuration +8 Validation results RUN INSTRUCTIONS -The generated application must run successfully with +The generated application must run successfully with: + +Import the generated root-level Keycloak realm artifact (for example `toir-realm.json`) into the external Keycloak server docker compose up -d diff --git a/generation/backend-generation.md b/generation/backend-generation.md index a6b3bf6..6c69c79 100644 --- a/generation/backend-generation.md +++ b/generation/backend-generation.md @@ -1,12 +1,14 @@ # Backend Generation Process -Backend generation follows a pipeline aligned with runtime and validation docs: +Backend generation follows a pipeline aligned with runtime, auth, and validation docs: DSL ↓ CLI scaffolding ↓ -code generation +backend code generation +↓ +auth generation ↓ runtime infrastructure ↓ @@ -18,7 +20,18 @@ seed ↓ validation -Follow **backend/runtime-rules.md**, **backend/prisma-rules.md**, **backend/prisma-service.md**, **backend/database-runtime.md**, **backend/seed-rules.md**, and **backend/service-rules.md**. +Follow: + +- `backend/architecture.md` +- `backend/runtime-rules.md` +- `backend/prisma-rules.md` +- `backend/prisma-service.md` +- `backend/database-runtime.md` +- `backend/seed-rules.md` +- `backend/service-rules.md` +- `auth/keycloak-architecture.md` +- `auth/backend-auth-rules.md` +- `auth/keycloak-realm-template-rules.md` --- @@ -27,10 +40,12 @@ Follow **backend/runtime-rules.md**, **backend/prisma-rules.md**, **backend/pris Read DSL inputs and extract: - entities -- attributes (including primary key attribute name per entity) +- attributes, including the actual primary key attribute per entity - enums - foreign keys +The generator must treat auth as default runtime infrastructure, not as a DSL feature toggle. + --- # Step 2 — CLI scaffolding @@ -38,50 +53,118 @@ Read DSL inputs and extract: Use official CLIs before generating backend code: - NestJS project scaffold in `server/` (see `generation/scaffolding-rules.md`) -- Install backend dependencies (`@prisma/client`, `prisma`, `@nestjs/config`, and seed runner when needed) +- install backend dependencies: + - `@prisma/client` + - `prisma` + - `@nestjs/config` + - `jose` + - seed runner when needed by the chosen package tooling + +The generator must **not** use deprecated Keycloak-specific Node adapters such as `keycloak-connect`. --- -# Step 3 — Code generation +# Step 3 — Core backend code generation Generate backend source artifacts: -1. **Prisma schema** (`server/prisma/schema.prisma`) from domain DSL: +1. **Prisma schema** (`server/prisma/schema.prisma`) from the domain DSL: - attributes - primary keys - relations - enums 2. **NestJS modules** per entity: - module - - controller (path params use actual PK name: `:id`, `:code`, etc.) - - service (must sanitize update payload before Prisma — see **backend/service-rules.md**) + - controller + - service 3. **DTO files**: - `create-entity.dto.ts` - `update-entity.dto.ts` - `entity.response.dto.ts` (or equivalent) 4. **PrismaService**: - - `OnModuleInit` + `await this.$connect()` - - no `beforeExit` hook -5. **Service update methods**: Sanitize update payload before passing to Prisma (remove `id`, primary key, and readonly attributes from `data`). Do not pass the raw request body as `data` to `prisma.*.update()`. + - implement `OnModuleInit` + - call `await this.$connect()` in `onModuleInit()` + - do not use a `beforeExit` hook +5. **Service update methods**: + - sanitize update payload before Prisma + - remove `id`, the entity primary key, and readonly attributes from `data` + - do not pass the raw request body directly to `prisma.*.update()` Use mapping rules from `backend/prisma-rules.md`: + - DSL `decimal` -> DTO `string` - DSL `date` -> DTO `string` (ISO) --- -# Step 4 — Runtime infrastructure +# Step 4 — Backend auth generation -Generate runtime config files: +Generate auth infrastructure as part of the normal backend output. Auth must not be deferred to a manual post-step. + +Required generated auth artifacts: + +- `server/src/auth/auth.module.ts` +- JWT auth guard +- roles guard +- `@Public()` decorator +- `@Roles()` decorator +- typed authenticated principal interface +- typed auth-aware config validation in `server/src/config/` + +JWT verification rules: + +- verify JWTs with issuer, audience, and JWKS +- use a standards-based library such as `jose` +- resolve JWKS in this exact order: + 1. explicit `KEYCLOAK_JWKS_URL` + 2. OIDC discovery + 3. `${issuer}/protocol/openid-connect/certs` + +RBAC rules: + +- extract authorization roles only from `realm_access.roles` +- do not infer roles from other claims +- apply CRUD defaults by HTTP method: + - `GET` -> `viewer`, `editor`, `admin` + - `POST`, `PATCH`, `PUT` -> `editor`, `admin` + - `DELETE` -> `admin` +- mark `/health` as public with `@Public()` + +Controller generation rules: + +- generated CRUD controllers must receive auth decorators by default +- path params must still use the actual entity primary key name (`:id`, `:code`, etc.) +- public routes must be explicit rather than implicit + +--- + +# Step 5 — Runtime infrastructure + +Generate backend runtime config files: - `server/.env` - `server/.env.example` +- `server/src/config/*` typed validation helpers - `server/package.json` lifecycle: - `"postinstall": "prisma generate"` - `server/package.json` Prisma seed: - - `"prisma": { "seed": "ts-node prisma/seed.ts" }` (or `tsx` variant by project standard) + - `"prisma": { "seed": "ts-node prisma/seed.ts" }` (or `tsx` equivalent if that is the chosen project standard) -Commands that must be supported/documented: +The generated backend env contract must include: + +- `PORT` +- `DATABASE_URL` +- `CORS_ALLOWED_ORIGINS` +- `KEYCLOAK_ISSUER_URL` +- `KEYCLOAK_AUDIENCE` +- `KEYCLOAK_JWKS_URL` (optional) + +Fail-fast config rule: + +- backend startup must fail fast when required auth or database env vars are missing +- the generator must not silently fall back to production auth values in code + +Commands that must be supported and documented: - `npx prisma generate` - `npx prisma migrate dev` @@ -89,9 +172,9 @@ Commands that must be supported/documented: --- -# Step 5 — Database runtime +# Step 6 — Database runtime -Generator must create `docker-compose.yml` at the **project root** with PostgreSQL. +Create `docker-compose.yml` at the **project root** with PostgreSQL only. Minimum required compose characteristics: @@ -99,13 +182,15 @@ Minimum required compose characteristics: - `image: postgres:16` - `ports: ["5432:5432"]` -Credentials/database in compose must match `DATABASE_URL`. +Credentials and database name in compose must match `DATABASE_URL`. + +Keycloak must remain an external runtime dependency and must not be added to `docker-compose.yml` in this repository. --- -# Step 6 — Migration +# Step 7 — Migration -Apply schema to development database: +Apply schema to the development database: ```bash cd server @@ -114,7 +199,7 @@ npx prisma migrate dev --- -# Step 7 — Seed +# Step 8 — Seed Run development seed: @@ -127,12 +212,14 @@ Seed file location: `server/prisma/seed.ts`. --- -# Step 8 — Validation +# Step 9 — Validation -Run runtime and contract checks from `generation/post-generation-validation.md`, including: +Run runtime, auth, and contract checks from `generation/post-generation-validation.md`, including: - docker-compose exists and DB container starts - Prisma lifecycle commands succeed - seed runs -- `/health` responds -- React Admin receives `id` in every record \ No newline at end of file +- `/health` responds without auth +- protected routes reject unauthenticated requests with `401` +- authenticated users with insufficient role receive `403` +- React Admin-compatible API responses include `id` for every record diff --git a/generation/dev-workflow.md b/generation/dev-workflow.md index cd5559a..b553ba6 100644 --- a/generation/dev-workflow.md +++ b/generation/dev-workflow.md @@ -1,6 +1,6 @@ # Developer Workflow -This document describes the **developer workflow** for running a generated fullstack application locally. The generator must produce a project that supports this workflow so the app is **fully runnable** after generation. +This document describes the **developer workflow** for running a generated fullstack application locally. The generator must produce a project that supports this workflow so the app is **fully runnable** after generation, including authentication. --- @@ -9,12 +9,34 @@ This document describes the **developer workflow** for running a generated fulls - **Node.js** (LTS, e.g. 18+) - **npm** - **Docker** and **Docker Compose** (for the development database) +- **External Keycloak server** reachable by the generated frontend and backend + +The generated project must not require the developer to invent auth wiring manually after generation. --- # Workflow Steps -## 1. Start the database +## 1. Prepare Keycloak and env files + +From the project root, the generated project must include: + +- root-level generated Keycloak realm import artifact + - repository default example filename: `toir-realm.json` +- `server/.env.example` +- `client/.env.example` + +Required workflow: + +1. Copy the generated env examples to real env files as needed. +2. Fill all required backend and frontend auth variables. +3. Import or verify the generated Keycloak realm import artifact in the external Keycloak server before starting the app. + +The generator must document that auth config is fail-fast. Missing required auth env vars must stop startup instead of silently falling back to production values in code. + +--- + +## 2. Start the database From the **project root**: @@ -24,7 +46,7 @@ docker compose up -d This starts the PostgreSQL container defined in `docker-compose.yml`. Wait a few seconds for the database to accept connections. -Verify (optional): +Verify if needed: ```bash docker compose ps @@ -32,7 +54,7 @@ docker compose ps --- -## 2. Backend setup and start +## 3. Backend setup and start From the **server** directory: @@ -45,13 +67,15 @@ npx prisma db seed npm run start ``` -- `npm install` — installs dependencies and runs `postinstall` (for example `prisma generate`). -- `npx prisma generate` — explicitly generates Prisma client. -- `npx prisma migrate dev` — creates/applies migrations. -- `npx prisma db seed` — inserts minimal development data. -- `npm run start` — starts NestJS backend. +- `npm install` installs dependencies and runs `postinstall` when configured. +- `npx prisma generate` explicitly generates Prisma client. +- `npx prisma migrate dev` creates/applies migrations. +- `npx prisma db seed` inserts minimal development data. +- `npm run start` starts the NestJS backend. -The API should be available at the configured port (e.g. `http://localhost:3000`). Verify with: +The API should be available at the configured port (for example `http://localhost:3000`). + +Verify: ```bash curl http://localhost:3000/health @@ -59,9 +83,15 @@ curl http://localhost:3000/health Expected: `{ "status": "ok" }` (or equivalent). +Generated backend behavior must also ensure: + +- protected CRUD routes require authentication by default +- insufficient roles result in `403` +- `/health` remains public + --- -## 3. Frontend setup and start +## 4. Frontend setup and start In a **separate terminal**, from the **project root**: @@ -71,10 +101,15 @@ npm install npm run dev ``` -- `npm install` — installs frontend dependencies. -- `npm run dev` — starts the Vite dev server (e.g. `http://localhost:5173`). +- `npm install` installs frontend dependencies including `keycloak-js`. +- `npm run dev` starts the Vite dev server (for example `http://localhost:5173`). -Open the Vite URL in a browser; the React Admin app should load and use the backend API. +Open the frontend URL in a browser. The generated React Admin app must: + +- initialize Keycloak before render +- use redirect-based login only +- authenticate against the configured Keycloak realm/client +- call the backend with bearer tokens through the shared request seam --- @@ -82,8 +117,19 @@ Open the Vite URL in a browser; the React Admin app should load and use the back | Step | Command / location | |------|---------------------| +| Prepare Keycloak + env | Fill `server/.env` and `client/.env`; import or verify the generated realm import artifact | | Start database | From root: `docker compose up -d` | | Backend setup/start | `cd server && npm install && npx prisma generate && npx prisma migrate dev && npx prisma db seed && npm run start` | | Frontend setup/start | `cd client && npm install && npm run dev` | -The generator must produce all required artifacts (docker-compose, env, schema, migrations, seed, health endpoint) so that this workflow succeeds and the development environment is fully runnable. +The generator must produce all required artifacts so that this workflow succeeds: + +- docker-compose +- env examples +- schema +- migrations +- seed +- health endpoint +- frontend auth integration +- backend auth infrastructure +- root-level Keycloak realm import artifact diff --git a/generation/frontend-generation.md b/generation/frontend-generation.md index e141060..5b3641c 100644 --- a/generation/frontend-generation.md +++ b/generation/frontend-generation.md @@ -1,36 +1,138 @@ # Frontend Generation Process -Frontend generation uses the DSL and API specification. +Frontend generation must produce the React Admin CRUD application **and** the default Keycloak integration required by the runtime architecture. + +Follow: + +- `frontend/architecture.md` +- `frontend/react-admin-rules.md` +- `auth/keycloak-architecture.md` +- `auth/frontend-auth-rules.md` +- `auth/keycloak-realm-template-rules.md` --- # Step 1 — Parse DSL -Extract entities and attributes. +Extract: + +- entities +- attributes +- relation fields required for reference inputs and reference displays + +The generator must treat auth as default frontend infrastructure rather than as an optional feature. --- -# Step 2 — Generate React Admin Resources +# Step 2 — Generate frontend runtime structure + +Generate the base frontend structure required by the proven runtime: + +- `client/src/main.tsx` +- `client/src/App.tsx` +- `client/src/dataProvider.ts` +- `client/src/config/env.ts` +- `client/src/auth/keycloak.ts` +- `client/src/auth/authProvider.ts` +- `client/.env.example` + +The generated frontend must: + +- initialize Keycloak before rendering the SPA +- register React Admin with a mandatory `authProvider` +- enforce authenticated operation rather than anonymous operation +- use environment-driven runtime config and fail fast when required auth vars are missing + +--- + +# Step 3 — Generate React Admin resources For each entity create: -EntityList.tsx -EntityCreate.tsx -EntityEdit.tsx -EntityShow.tsx +- `EntityList.tsx` +- `EntityCreate.tsx` +- `EntityEdit.tsx` +- `EntityShow.tsx` + +Resource generation must remain compatible with the auth-aware shared request seam. --- -# Step 3 — Map Fields +# Step 4 — Map fields -Map DSL attributes to React Admin components. +Map DSL attributes to React Admin components according to existing field rules and relation semantics. + +Reference/resource lookups must continue to flow through the same shared authenticated request layer used by the main CRUD resources. --- -# Step 4 — Register Resources +# Step 5 — Generate auth-aware API layer -Register resources in App.tsx. +Generate a shared `dataProvider.ts` that: -Example: +- reads the API base URL from `VITE_API_URL` +- attaches `Authorization: Bearer ` to every backend request +- uses the same request seam for all React Admin operations, including: + - list + - get one + - get many + - get many reference + - create + - update + - delete +- handles token refresh before protected requests +- keeps token refresh concurrency-safe by sharing one in-flight refresh operation +- does not store access tokens or refresh tokens in `localStorage` or `sessionStorage` - \ No newline at end of file +The generator must not create multiple competing HTTP clients for authenticated and unauthenticated traffic. The shared request seam is the single bearer injection point. + +--- + +# Step 6 — Generate frontend auth flow + +Generate Keycloak frontend integration with these required rules: + +- use `keycloak-js` +- redirect-based login only +- no custom in-app username/password login form +- Authorization Code + PKCE with `S256` +- initialize Keycloak before React render +- provide a React Admin `authProvider` +- distinguish auth failures from authorization failures: + - `401` -> force logout / re-authentication + - `403` -> do not re-authenticate; surface access denied / permission error + +The generator must not silently fall back to production auth settings in code. Example values belong in `.env.example`, but runtime config must fail fast if required values are absent. + +--- + +# Step 7 — Register resources and auth + +Register resources in `App.tsx` and wire them into an authenticated `Admin` root. + +Generated `App.tsx` must: + +- register the shared `dataProvider` +- register the mandatory `authProvider` +- configure the app so it does not operate anonymously once auth is enabled + +Example shape: + +```tsx + + + +``` + +--- + +# Step 8 — Frontend runtime config + +Generate frontend env examples and config access for: + +- `VITE_API_URL` +- `VITE_KEYCLOAK_URL` +- `VITE_KEYCLOAK_REALM` +- `VITE_KEYCLOAK_CLIENT_ID` + +`client/.env.example` must use filled example values that match the documented Keycloak and local dev topology, while runtime code must fail fast if any required variable is missing. diff --git a/generation/post-generation-validation.md b/generation/post-generation-validation.md index 87cddd5..47f0b8d 100644 --- a/generation/post-generation-validation.md +++ b/generation/post-generation-validation.md @@ -1,160 +1,236 @@ # Post-Generation Validation -After generating the backend or fullstack application, run these checks to ensure the project will run without runtime errors. +After generating the backend or fullstack application, run these checks to ensure the project will start cleanly and that the default auth model has been generated correctly. --- # Validation Checklist -## 1. Environment file +## 1. Frontend and backend env files -- [ ] **`.env` exists** in the backend root (e.g. `server/.env`). -- [ ] **`.env.example` exists** with at least `DATABASE_URL` and a placeholder value. -- [ ] **`DATABASE_URL`** is present in `.env` (or documented in `.env.example` so the user can copy and set it). +- [ ] `server/.env.example` exists and documents: + - `PORT` + - `DATABASE_URL` + - `CORS_ALLOWED_ORIGINS` + - `KEYCLOAK_ISSUER_URL` + - `KEYCLOAK_AUDIENCE` + - optional `KEYCLOAK_JWKS_URL` +- [ ] `client/.env.example` exists and documents: + - `VITE_API_URL` + - `VITE_KEYCLOAK_URL` + - `VITE_KEYCLOAK_REALM` + - `VITE_KEYCLOAK_CLIENT_ID` +- [ ] Runtime code fails fast when required auth or database env vars are missing. +- [ ] Runtime code does not silently fall back to production auth settings. -**Failure symptom:** `Environment variable not found: DATABASE_URL` at startup. +**Failure symptoms:** startup succeeds with undefined auth config, or fails later with opaque auth/runtime errors. --- -## 2. PrismaService implementation +## 2. Keycloak realm artifact -- [ ] A **PrismaService** (or equivalent) class exists and extends `PrismaClient`. -- [ ] It implements **`OnModuleInit`** and calls **`await this.$connect()`** in `onModuleInit()`. -- [ ] It does **not** use **`this.$on('beforeExit', ...)`** or any `beforeExit` hook. +- [ ] A root-level generated Keycloak realm import artifact exists. +- [ ] If the repository default filename `toir-realm.json` is not used, the project-specific equivalent is documented consistently across bootstrap and workflow docs. +- [ ] The generated realm artifact is self-contained and reproducible. +- [ ] The generated realm artifact parameterizes realm name, frontend client ID, backend audience/client ID, production URLs, and artifact filename consistently with the generated project auth config. +- [ ] It defines realm roles: + - `admin` + - `editor` + - `viewer` +- [ ] It defines a frontend SPA client consistent with the generated frontend Keycloak client ID. +- [ ] It defines a backend audience/resource client consistent with the generated backend audience/client ID. +- [ ] It defines explicit audience delivery, such as an `api-audience` client scope. +- [ ] It does not rely on undeclared built-in client scopes being present after import. +- [ ] It explicitly addresses delivery of: + - `sub` + - `aud` + - `realm_access.roles` -**Failure symptom:** Deprecation/runtime errors in Prisma 5 when using `beforeExit`. +**Failure symptoms:** access tokens are missing required claims, or realm import succeeds but generated apps still cannot authenticate/authorize reliably. + +--- + +## 3. Frontend auth files and behavior + +- [ ] Generated frontend includes: + - `client/src/config/env.ts` + - `client/src/auth/keycloak.ts` + - `client/src/auth/authProvider.ts` + - `client/src/main.tsx` + - `client/src/App.tsx` + - `client/src/dataProvider.ts` +- [ ] `keycloak-js` is installed. +- [ ] Keycloak is initialized before the SPA renders. +- [ ] Login is redirect-based only. +- [ ] No custom in-app username/password login form is generated. +- [ ] `Authorization Code + PKCE (S256)` is encoded in the frontend auth flow. +- [ ] `client/src/dataProvider.ts` or the documented shared request seam injects `Authorization: Bearer ` into all API requests. +- [ ] Token refresh is concurrency-safe: + - one shared in-flight refresh operation + - no parallel refresh stampede +- [ ] Generated auth code does not persist access tokens or refresh tokens in `localStorage` or `sessionStorage`. +- [ ] React Admin auth semantics distinguish: + - `401` -> force logout / re-authentication + - `403` -> do not re-authenticate; surface access denied / permission error + +**Failure symptoms:** app renders before auth is ready, reference calls miss auth headers, refresh storms occur, or `403` incorrectly forces logout. + +--- + +## 4. Backend auth files and behavior + +- [ ] Generated backend includes: + - `server/src/auth/auth.module.ts` + - JWT guard + - roles guard + - `@Public()` decorator + - `@Roles()` decorator + - typed authenticated principal interface + - typed config validation in `server/src/config/` +- [ ] `jose` is installed. +- [ ] JWT verification uses issuer + audience + JWKS. +- [ ] JWKS resolution follows this exact priority: + 1. `KEYCLOAK_JWKS_URL` + 2. OIDC discovery + 3. `${issuer}/protocol/openid-connect/certs` +- [ ] Authorization roles are extracted only from `realm_access.roles`. +- [ ] Deprecated Keycloak-specific Node adapters are not used. + +**Failure symptoms:** invalid tokens are accepted, valid tokens are rejected due to bad JWKS resolution, or RBAC depends on unstable/non-standard claims. + +--- + +## 5. CRUD protection and RBAC defaults + +- [ ] `/health` is public. +- [ ] Each generated CRUD controller method other than explicit public routes is protected by the generated auth/RBAC infrastructure. +- [ ] CRUD RBAC defaults are present: + - `GET` -> `viewer | editor | admin` + - `POST`, `PATCH`, `PUT` -> `editor | admin` + - `DELETE` -> `admin` +- [ ] Unauthenticated request to a protected route returns `401`. +- [ ] Authenticated user with insufficient role receives `403`. + +**Failure symptoms:** anonymous CRUD access remains open, or insufficient-role users are not denied properly. + +--- + +## 6. PrismaService implementation + +- [ ] A `PrismaService` (or equivalent) class exists and extends `PrismaClient`. +- [ ] It implements `OnModuleInit` and calls `await this.$connect()` in `onModuleInit()`. +- [ ] It does not use `this.$on('beforeExit', ...)` or any `beforeExit` hook. + +**Failure symptom:** deprecation/runtime errors in Prisma 5 when using `beforeExit`. **Reference:** `backend/prisma-service.md` --- -## 3. Prisma client lifecycle +## 7. Prisma client lifecycle -- [ ] **`package.json`** includes a script that runs Prisma client generation: - - Either **`"postinstall": "prisma generate"`** (or `npx prisma generate`), - - Or clear documentation to run **`npx prisma generate`** after install. -- [ ] After schema generation or change, **`npx prisma generate`** has been run (or will run via postinstall). +- [ ] `package.json` includes a script that runs Prisma client generation: + - either `"postinstall": "prisma generate"` (or `npx prisma generate`) + - or clear documentation to run `npx prisma generate` after install +- [ ] After schema generation or change, `npx prisma generate` has been run or will run via `postinstall`. -**Failure symptom:** `Cannot find module '@prisma/client'` or missing types at build/run. +**Failure symptom:** `Cannot find module '@prisma/client'` or missing generated types. --- -## 4. Database migration +## 8. Database migration -- [ ] **Migration workflow** is documented (e.g. in README or generation docs). -- [ ] Instruction to run **`npx prisma migrate dev`** (or `prisma migrate deploy` for production) after first generation or schema change. -- [ ] Generation pipeline (see `generation/backend-generation.md`) includes or documents the migration step. +- [ ] Migration workflow is documented. +- [ ] Instruction to run `npx prisma migrate dev` exists after first generation or schema change. +- [ ] Generation pipeline includes or documents the migration step. -**Failure symptom:** Tables do not exist; Prisma errors on first query. +**Failure symptom:** tables do not exist; Prisma errors on first query. --- -## 5. REST route parameters +## 9. REST route parameters -- [ ] For each entity, path parameters use the **correct primary key name** from the DSL. -- [ ] **Entity with PK `id` (uuid):** routes use **`/:id`** (e.g. `GET /equipment/:id`, `PATCH /equipment/:id`). -- [ ] **Entity with non-`id` primary key (e.g. `code`):** routes use **`/:code`** (or the actual PK attribute name), e.g. `GET /equipment-types/:code`, **not** `GET /equipment-types/:id`. +- [ ] For each entity, path parameters use the correct primary key name from the DSL. +- [ ] Entity with PK `id` uses `/:id`. +- [ ] Entity with non-`id` primary key (for example `code`) uses the actual PK name such as `/:code`, not `/:id`. -**Example:** +**Failure symptom:** controller expects one param name while the route defines another, leading to broken CRUD behavior. -| Entity | Primary key | Correct path | Incorrect path | -| ------------- | ----------- | ---------------------- | -------------------- | -| Equipment | id | /equipment/:id | — | -| EquipmentType | code | /equipment-types/:code | /equipment-types/:id | -| RepairOrder | id | /repair-orders/:id | — | - -**Failure symptom:** Controller expects `params.id` but route is defined with `:code`; or vice versa; 404 or wrong resource updated. - -**Reference:** `backend/architecture.md` — API path rules for non-id primary keys. +**Reference:** `backend/architecture.md` --- -## 6. DTO type mapping (serialization) +## 10. DTO type mapping and React Admin ID compatibility -- [ ] **DSL `decimal`** → In DTO/API response, use **`string`** (or a type that serializes to string), not Prisma `Decimal`, to avoid JSON serialization issues. -- [ ] **DSL `date`** → In DTO/API response, use **`string`** (ISO 8601) or ensure DateTime is serialized to string, so React Admin and JSON consumers receive a string. +- [ ] DSL `decimal` maps to DTO/API `string`. +- [ ] DSL `date` maps to DTO/API `string` (ISO) or equivalent string serialization. +- [ ] Every API response object contains a field named `id`. +- [ ] If the entity primary key is not named `id`, the response maps the primary key to `id`. -**Reference:** `backend/prisma-rules.md` — DTO type mapping table. +**Failure symptoms:** serialization issues for decimals/dates, or React Admin cannot identify records. --- -## 7. React Admin ID field in API responses +## 11. Update payload sanitization -- [ ] **Every API response object** (list items and single-resource GET) contains a field named **`id`**. -- [ ] If the entity primary key is **not** named `id`, the response must **map** the primary key to `id`: e.g. `id: record.code` for EquipmentType, so the payload includes both `id` and `code` (or at least `id` with the PK value). -- [ ] The `id` field value must be the **primary key value** (string or uuid as appropriate). +- [ ] Update endpoints do not pass `id` or the primary key in Prisma `data`. +- [ ] Generated update methods remove `id`, the entity primary key, and readonly attributes before calling `prisma.*.update()`. -**Failure symptom:** React Admin fails to identify records, breaks cache/references, or throws when expecting `record.id`. - -**Reference:** `frontend/react-admin-rules.md` — React Admin ID Field Requirement. +**Failure symptom:** Prisma throws because immutable or invalid fields are passed in update `data`. --- -## 8. Update payload sanitization (service layer) +## 12. Database runtime -- [ ] **Update endpoint must not pass `id` (or primary key) in Prisma `data`.** The service must sanitize the incoming DTO before calling `prisma.*.update({ where, data })`: remove `id`, remove the entity primary key field (e.g. `code`), and remove any readonly attributes. Only updatable fields should be passed as `data`. -- [ ] Generated update methods follow the pattern from **backend/service-rules.md** (e.g. `const { id, code, ...data } = dto` then pass `data` to Prisma). +- [ ] `docker-compose.yml` exists at the project root. +- [ ] It defines a PostgreSQL service with image `postgres:16`, port `5432`, and credentials matching `DATABASE_URL`. +- [ ] `docker compose up -d` starts the database successfully. +- [ ] `docker-compose.yml` does not add Keycloak in this repository. -**Failure symptom:** Prisma throws when `data` contains `id` or another field that is not on the model or not writable (e.g. entity with PK `code` receives body with `id` from React Admin). - -**Reference:** `backend/service-rules.md` +**Failure symptom:** Prisma cannot reach the development database or repo topology drifts from the documented external-Keycloak model. --- -## 9. Database runtime (docker-compose) +## 13. Migrations, seed, and health endpoint -- [ ] **`docker-compose.yml` exists** at the project root (or documented location). -- [ ] It defines a **PostgreSQL** service with image (e.g. `postgres:16`), port `5432`, and credentials/DB name matching `DATABASE_URL` in `server/.env`. -- [ ] **Database container starts:** `docker compose up -d` runs without error and the container is reachable on the configured port. +- [ ] `npx prisma migrate dev` runs successfully from `server/`. +- [ ] Seed script exists at `server/prisma/seed.ts` (or equivalent). +- [ ] `npx prisma db seed` runs without error. +- [ ] Backend exposes `GET /health`. +- [ ] `GET /health` returns HTTP 200 with a status payload. -**Failure symptom:** `PrismaClientInitializationError P1001: Can't reach database server at localhost:5432`. - -**Reference:** `backend/database-runtime.md` - ---- - -## 10. Migrations and seed - -- [ ] **`npx prisma migrate dev`** runs successfully from `server/` when the database is up (schema is applied or created). -- [ ] **Seed script** exists at `server/prisma/seed.ts` (or equivalent) and creates minimal sample data (e.g. one EquipmentType, one Equipment, one RepairOrder). -- [ ] **`npx prisma db seed`** runs without error (package.json has `prisma.seed` configured and seed runner installed). - -**Reference:** `backend/seed-rules.md`, `generation/runtime-bootstrap.md` - ---- - -## 11. Health endpoint - -- [ ] Backend exposes **GET /health** (or equivalent health route). -- [ ] **API responds to /health:** With backend running, `GET http://localhost:/health` returns HTTP 200 and a body such as `{ "status": "ok" }`. - -**Reference:** `backend/architecture.md` — Health Endpoint +**Failure symptom:** development bootstrap cannot complete end-to-end. --- # Summary Table -| Check | Required artifact / rule | -| ------------------- | ---------------------------------------------- | -| .env | File exists with DATABASE_URL | -| DATABASE_URL | Present in .env or .env.example | -| PrismaService | OnModuleInit + $connect(); no beforeExit | -| prisma generate | postinstall script or documented step | -| Migration | Documented step: prisma migrate dev | -| REST path params | Use entity PK name (:id or :code, etc.) | -| Decimal/Date in DTO | Map to string for serialization | -| API response `id` | Every record has `id`; if PK ≠ id, map PK → id | -| Update payload | Service strips `id`, PK, readonly from data before Prisma | -| docker-compose.yml | Exists at project root; PostgreSQL service | -| Database container | Starts with `docker compose up -d` | -| prisma migrate dev | Runs successfully from server/ | -| Seed script | Exists; prisma db seed runs | -| GET /health | Backend responds with 200 and status payload | +| Check | Required artifact / rule | +| --- | --- | +| Frontend env | `client/.env.example` with required Vite auth vars | +| Backend env | `server/.env.example` with DB, CORS, and Keycloak vars | +| Fail-fast config | Startup fails when required auth env is missing | +| Realm artifact | Root generated realm import artifact with self-contained auth setup | +| Frontend auth | `keycloak.ts`, `authProvider.ts`, authenticated `dataProvider.ts` | +| Backend auth | `AuthModule`, guards, decorators, typed principal | +| JWKS strategy | explicit URL -> discovery -> certs fallback | +| Role source | `realm_access.roles` only | +| CRUD RBAC | GET viewer/editor/admin; write editor/admin; delete admin | +| `/health` | Public and returns 200 | +| Protected route unauthenticated | Returns `401` | +| Protected route insufficient role | Returns `403` | +| Token storage | No `localStorage` / `sessionStorage` persistence | +| Token refresh | Concurrency-safe single in-flight refresh | +| Prisma lifecycle | `OnModuleInit` + `$connect()`, no `beforeExit` | +| Update sanitization | Strip `id` / PK / readonly before Prisma update | +| React Admin `id` | Every record includes `id` | +| Database runtime | PostgreSQL compose exists and starts | --- # Integration with generation pipeline -1. **Backend generation** (see `generation/backend-generation.md`) should produce artifacts that satisfy the above by default. -2. After generation, run this checklist manually or via a script that parses generated code and config. -3. If any check fails, the AI context or generator should be updated so that future runs pass. +1. Backend and frontend generation must produce artifacts that satisfy the above by default. +2. Runtime bootstrap must include Keycloak realm import/verification before app startup. +3. After generation, run this checklist manually or via an automated script. +4. If any check fails, update the generator context so future runs pass without manual repair. diff --git a/generation/runtime-bootstrap.md b/generation/runtime-bootstrap.md index 34d208d..cf34c18 100644 --- a/generation/runtime-bootstrap.md +++ b/generation/runtime-bootstrap.md @@ -1,14 +1,42 @@ # Runtime Bootstrap -After project generation, the following commands must work in order so that the application runs without manual database provisioning or ad-hoc steps. +After project generation, the following commands and prerequisites must work in order so that the application runs without ad-hoc auth or database setup. -The generator must produce a **runnable development environment**: backend, frontend, and database must all be startable via a documented sequence. +The generator must produce a **runnable development environment** consisting of: + +- external Keycloak identity provider +- backend API +- frontend SPA +- PostgreSQL database + +The generator must also produce the runtime artifacts required to bootstrap auth from zero, including a root-level Keycloak realm import artifact. The repository default example filename is `toir-realm.json`, but future generations must allow a project-specific equivalent. --- # Bootstrap Sequence -## 1. Start the database +## 1. Prepare Keycloak + +Before starting the application, ensure an external Keycloak server is reachable and import or verify the generated realm artifact: + +- root-level generated Keycloak realm import artifact +- repository default example filename: `toir-realm.json` + +The runtime instructions must require importing or verifying this realm before frontend/backend startup. + +Keycloak bootstrap expectations: + +- realm import is self-contained +- frontend client exists +- backend audience client exists +- realm roles exist +- issued access tokens reliably contain `sub`, `aud`, and `realm_access.roles` + +The generator must not rely on undeclared built-in client scopes magically existing after realm import. + +--- + +## 2. Start the database From the **project root**: @@ -16,11 +44,11 @@ From the **project root**: docker compose up -d ``` -This starts the PostgreSQL container. The backend will connect to it using `DATABASE_URL` from `server/.env`. +This starts the PostgreSQL container. The backend connects to it using `DATABASE_URL` from `server/.env`. --- -## 2. Backend setup and start +## 3. Backend setup and start ```bash cd server @@ -31,15 +59,22 @@ npx prisma db seed npm run start ``` -- `npm install` — installs dependencies and runs `postinstall` (e.g. `prisma generate`) if configured. -- `npx prisma generate` — ensures Prisma client is generated explicitly after install/schema changes. -- `npx prisma migrate dev` — creates/applies migrations and ensures the database schema exists. -- `npx prisma db seed` — populates minimal development data for immediate UI/API usage. -- `npm run start` — starts the NestJS server (default port e.g. 3000). +- `npm install` installs dependencies and runs `postinstall` if configured. +- `npx prisma generate` ensures Prisma client is generated explicitly after install or schema changes. +- `npx prisma migrate dev` creates/applies migrations. +- `npx prisma db seed` inserts minimal development data. +- `npm run start` starts the NestJS server. + +Backend startup requirements: + +- required database and auth env vars must be present +- startup must fail fast if required env vars are missing +- CORS must support the SPA -> API bearer-token model +- `/health` must remain public --- -## 3. Frontend setup and start +## 4. Frontend setup and start In a separate terminal, from the **project root**: @@ -49,16 +84,37 @@ npm install npm run dev ``` -- `npm run dev` — starts the Vite dev server (e.g. http://localhost:5173). +- `npm install` installs frontend dependencies including `keycloak-js`. +- `npm run dev` starts the Vite dev server. + +Frontend startup requirements: + +- required Vite auth vars must be present +- startup must fail fast if required auth vars are missing +- Keycloak must initialize before the SPA is rendered +- login must use redirect-based Keycloak authentication only --- # Success Criteria -After running the above: +After completing the bootstrap sequence: -- Database container is running; Prisma can connect. -- Backend responds (e.g. `GET /health` returns `{ "status": "ok" }`). -- Frontend loads and can call the backend API. +- Keycloak is reachable and the generated realm has been imported or verified. +- Database container is running and Prisma can connect. +- Backend responds on `/health` without auth. +- Protected backend routes require a valid bearer token. +- Frontend loads, redirects through Keycloak login when needed, and uses bearer auth for all API calls. -The generator is responsible for producing all artifacts (docker-compose, schema, migrations, seed, env, health endpoint) so that this sequence succeeds without additional manual setup. +The generator is responsible for producing all artifacts and instructions needed for this sequence: + +- `docker-compose.yml` +- schema +- migrations +- seed +- backend/frontend env examples +- health endpoint +- auth infrastructure +- root-level Keycloak realm import artifact + +Docker scope must remain limited to PostgreSQL. Keycloak remains an external dependency in this repository. diff --git a/generation/scaffolding-rules.md b/generation/scaffolding-rules.md index 5b6b9e1..fc27f90 100644 --- a/generation/scaffolding-rules.md +++ b/generation/scaffolding-rules.md @@ -2,7 +2,9 @@ The generator must use **official CLI tools** to create base project structures. -The AI must **not** manually generate the entire project skeleton (e.g. by writing all config files and folder structure by hand). Using the CLI reduces errors and ensures compatibility with current tool versions. +The AI must **not** manually generate the entire project skeleton by hand. CLI scaffolding reduces drift and keeps the generated project aligned with current NestJS and Vite conventions. + +Auth is part of the default generated runtime. Scaffolding must therefore install the required frontend and backend auth dependencies during the normal project bootstrap path. --- @@ -19,9 +21,9 @@ npx @nestjs/cli@10.3.2 new server --package-manager npm --skip-git ## Rules - **Project directory** must be `server`. -- **TypeScript** must be used (default for Nest CLI). -- **npm** must be the package manager (`--package-manager npm`). -- **Git** initialization must be skipped (`--skip-git`). +- **TypeScript** must be used. +- **npm** must be the package manager. +- **Git** initialization must be skipped. ## After scaffolding — install required dependencies @@ -29,10 +31,16 @@ Run from the `server` directory: ```bash npm install @prisma/client -npm install prisma --save-dev npm install @nestjs/config +npm install jose +npm install prisma --save-dev ``` +## Backend auth dependency rules + +- `jose` must be installed by default because JWT verification is part of the default generated backend. +- The generator must **not** install deprecated Keycloak-specific Node adapters such as `keycloak-connect`. + --- # Frontend Scaffolding @@ -48,7 +56,7 @@ npm create vite@5.2.0 client -- --template react-ts ## Rules - **Project directory** must be `client`. -- **React + TypeScript** template must be used (`--template react-ts`). +- **React + TypeScript** template must be used. ## After scaffolding — install required dependencies @@ -58,8 +66,14 @@ Run from the `client` directory: npm install react-admin npm install ra-data-simple-rest npm install @mui/material @emotion/react @emotion/styled +npm install keycloak-js ``` +## Frontend auth dependency rules + +- `keycloak-js` must be installed by default because redirect-based Keycloak login is part of the default generated frontend. +- The generated frontend must use `keycloak-js` for Authorization Code + PKCE and must not generate a custom in-app username/password login form. + --- # Scaffolding Strategy @@ -67,12 +81,12 @@ npm install @mui/material @emotion/react @emotion/styled Generation pipeline order: 1. **Parse DSL** — Read domain, DTO, API, and UI DSL files. -2. **Run CLI scaffolding** — Create `server` with NestJS CLI and `client` with Vite CLI; install dependencies as above. -3. **Code generation** — Generate Prisma schema, NestJS modules/DTOs/PrismaService, and React Admin resources. -4. **Runtime infrastructure** — Generate `.env`, `.env.example`, package lifecycle scripts, and runtime config files. +2. **Run CLI scaffolding** — Create `server` with NestJS CLI and `client` with Vite CLI; install runtime and auth dependencies listed above. +3. **Code generation** — Generate Prisma schema, NestJS modules/DTOs/PrismaService/auth infrastructure, and React Admin resources/auth integration. +4. **Runtime infrastructure** — Generate backend/frontend `.env.example`, runtime config files, lifecycle scripts, and a root-level Keycloak realm import artifact (repository default example filename: `toir-realm.json`). 5. **Database runtime** — Generate `docker-compose.yml` in project root with PostgreSQL service (`postgres`, image `postgres:16`, port `5432:5432`). 6. **Migration** — Apply schema with `npx prisma migrate dev`. 7. **Seed** — Populate minimal development data with `npx prisma db seed`. -8. **Validation** — Run checks from `generation/post-generation-validation.md`. +8. **Validation** — Run checks from `generation/post-generation-validation.md`, including auth validation and realm-template validation. -Scaffolding (steps 1–2) must be done with the CLI; steps 3–8 are generated from the DSL and project docs. +Scaffolding (steps 1–2) must be done with the CLI. Steps 3–8 must be generated from the DSL and the project context documents, including the auth-specific context in `auth/*.md`. diff --git a/generation/update-strategy.md b/generation/update-strategy.md index 6e15242..cf5cc10 100644 --- a/generation/update-strategy.md +++ b/generation/update-strategy.md @@ -1,8 +1,40 @@ # Update Strategy -When DSL changes: +When the DSL changes, regeneration must preserve the default auth-enabled runtime rather than falling back to CRUD-only output. -1. Regenerate prisma.schema -2. Run prisma migrate dev -3. Regenerate Nest modules -4. Regenerate React Admin resources \ No newline at end of file +## Required regeneration sequence + +1. Regenerate `prisma/schema.prisma`. +2. Run `npx prisma migrate dev`. +3. Regenerate NestJS entity modules, DTOs, controllers, and services. +4. Regenerate backend auth infrastructure: + - `AuthModule` + - guards + - decorators + - typed authenticated principal + - typed config validation + - CRUD RBAC decorations +5. Regenerate React Admin resources. +6. Regenerate frontend auth infrastructure: + - `src/config/env.ts` + - `src/auth/keycloak.ts` + - `src/auth/authProvider.ts` + - authenticated `dataProvider.ts` + - `App.tsx` auth wiring + - `main.tsx` init-before-render flow +7. Regenerate backend and frontend `.env.example` files so the auth env contract stays in sync. +8. Regenerate the root-level Keycloak realm import artifact. The repository default example filename is `toir-realm.json`, but the generator must allow a project-specific equivalent. +9. Re-run post-generation validation, including: + - auth dependency checks + - fail-fast env checks + - `/health` public check + - unauthenticated protected route -> `401` + - insufficient role -> `403` + - realm-template validation + +## Guardrails + +- Regeneration must not strip auth back out of the project. +- Auth remains outside the DSL grammar, but it is part of the default generated runtime. +- If a DSL change affects entities or routes, the generator must re-apply the default CRUD RBAC rules automatically. +- If a DSL change affects runtime topology or naming, the generator must keep backend/frontend env examples, CORS rules, and the generated realm import artifact aligned with the generated app. diff --git a/server/.env.example b/server/.env.example index 43743fe..cd9c83f 100644 --- a/server/.env.example +++ b/server/.env.example @@ -1 +1,7 @@ +PORT=3000 DATABASE_URL="postgresql://postgres:postgres@localhost:5432/toir" +CORS_ALLOWED_ORIGINS="http://localhost:5173,https://toir-frontend.greact.ru" +KEYCLOAK_ISSUER_URL="https://sso.greact.ru/realms/toir" +KEYCLOAK_AUDIENCE="toir-backend" +# Optional: if omitted, backend uses OIDC discovery and then falls back to issuer + /protocol/openid-connect/certs +# KEYCLOAK_JWKS_URL="https://sso.greact.ru/realms/toir/protocol/openid-connect/certs" diff --git a/server/package-lock.json b/server/package-lock.json index acaa367..16c0ade 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -15,6 +15,7 @@ "@nestjs/core": "^10.0.0", "@nestjs/platform-express": "^10.0.0", "@prisma/client": "^5.22.0", + "jose": "^6.2.2", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1" }, @@ -6675,6 +6676,15 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/server/package.json b/server/package.json index b799dbd..6b99780 100644 --- a/server/package.json +++ b/server/package.json @@ -29,6 +29,7 @@ "@nestjs/core": "^10.0.0", "@nestjs/platform-express": "^10.0.0", "@prisma/client": "^5.22.0", + "jose": "^6.2.2", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1" }, diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 3461820..daad56b 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -1,5 +1,7 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; +import { AuthModule } from './auth/auth.module'; +import { validateEnvironment } from './config/env.validation'; import { PrismaModule } from './prisma/prisma.module'; import { HealthModule } from './health/health.module'; import { EquipmentTypeModule } from './modules/equipment-type/equipment-type.module'; @@ -8,7 +10,11 @@ import { RepairOrderModule } from './modules/repair-order/repair-order.module'; @Module({ imports: [ - ConfigModule.forRoot({ isGlobal: true }), + ConfigModule.forRoot({ + isGlobal: true, + validate: validateEnvironment, + }), + AuthModule, PrismaModule, HealthModule, EquipmentTypeModule, diff --git a/server/src/auth/auth.constants.ts b/server/src/auth/auth.constants.ts new file mode 100644 index 0000000..003c912 --- /dev/null +++ b/server/src/auth/auth.constants.ts @@ -0,0 +1,3 @@ +export const IS_PUBLIC_KEY = 'isPublic'; +export const ROLES_KEY = 'roles'; + diff --git a/server/src/auth/auth.module.ts b/server/src/auth/auth.module.ts new file mode 100644 index 0000000..9611c7a --- /dev/null +++ b/server/src/auth/auth.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { APP_GUARD } from '@nestjs/core'; +import { AuthService } from './auth.service'; +import { JwtAuthGuard } from './guards/jwt-auth.guard'; +import { RolesGuard } from './guards/roles.guard'; + +@Module({ + providers: [ + AuthService, + { + provide: APP_GUARD, + useClass: JwtAuthGuard, + }, + { + provide: APP_GUARD, + useClass: RolesGuard, + }, + ], + exports: [AuthService], +}) +export class AuthModule {} + diff --git a/server/src/auth/auth.service.ts b/server/src/auth/auth.service.ts new file mode 100644 index 0000000..b2d9b36 --- /dev/null +++ b/server/src/auth/auth.service.ts @@ -0,0 +1,129 @@ +import { Injectable, Logger, UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { createRemoteJWKSet, jwtVerify } from 'jose'; +import { RuntimeEnvironment } from '../config/env.validation'; +import { + AuthenticatedUser, + KeycloakJwtPayload, +} from './interfaces/authenticated-user.interface'; + +@Injectable() +export class AuthService { + private readonly logger = new Logger(AuthService.name); + private readonly issuerUrl: string; + private readonly audience: string; + private readonly explicitJwksUrl?: string; + + private jwksResolverPromise: Promise> | null = + null; + private jwksResolver: ReturnType | null = null; + + constructor( + private readonly configService: ConfigService, + ) { + this.issuerUrl = this.configService.getOrThrow('KEYCLOAK_ISSUER_URL'); + this.audience = this.configService.getOrThrow('KEYCLOAK_AUDIENCE'); + this.explicitJwksUrl = this.configService.get('KEYCLOAK_JWKS_URL'); + } + + async verifyAccessToken(token: string): Promise { + try { + const jwksResolver = await this.getJwksResolver(); + + const { payload } = await jwtVerify(token, jwksResolver, { + issuer: this.issuerUrl, + audience: this.audience, + }); + + return this.mapPayloadToUser(payload as KeycloakJwtPayload); + } catch (error) { + this.logger.warn( + `JWT verification failed: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + throw new UnauthorizedException('Invalid or expired access token'); + } + } + + private mapPayloadToUser(payload: KeycloakJwtPayload): AuthenticatedUser { + if (!payload.sub) { + throw new UnauthorizedException('Token subject is missing'); + } + + const roles = Array.isArray(payload.realm_access?.roles) + ? payload.realm_access.roles.filter( + (role): role is string => typeof role === 'string', + ) + : []; + + return { + sub: payload.sub, + username: payload.preferred_username, + name: payload.name, + email: payload.email, + roles, + claims: payload, + }; + } + + private async getJwksResolver() { + if (this.jwksResolver) { + return this.jwksResolver; + } + + if (!this.jwksResolverPromise) { + this.jwksResolverPromise = this.createJwksResolver() + .then((resolver) => { + this.jwksResolver = resolver; + return resolver; + }) + .finally(() => { + this.jwksResolverPromise = null; + }); + } + + return this.jwksResolverPromise; + } + + private async createJwksResolver() { + const jwksUrl = await this.resolveJwksUrl(); + this.logger.log(`Using JWKS URL: ${jwksUrl}`); + return createRemoteJWKSet(new URL(jwksUrl)); + } + + private async resolveJwksUrl(): Promise { + if (this.explicitJwksUrl) { + return this.explicitJwksUrl; + } + + const issuer = this.issuerUrl.replace(/\/+$/, ''); + const discoveryUrl = `${issuer}/.well-known/openid-configuration`; + + try { + const discoveryResponse = await fetch(discoveryUrl, { + headers: { + Accept: 'application/json', + }, + }); + + if (discoveryResponse.ok) { + const discoveryDocument = (await discoveryResponse.json()) as { + jwks_uri?: string; + }; + + if ( + typeof discoveryDocument.jwks_uri === 'string' && + discoveryDocument.jwks_uri.trim().length > 0 + ) { + return discoveryDocument.jwks_uri; + } + } + } catch (error) { + this.logger.warn( + `OIDC discovery failed at ${discoveryUrl}: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + + return `${issuer}/protocol/openid-connect/certs`; + } +} + diff --git a/server/src/auth/decorators/public.decorator.ts b/server/src/auth/decorators/public.decorator.ts new file mode 100644 index 0000000..41d2571 --- /dev/null +++ b/server/src/auth/decorators/public.decorator.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from '@nestjs/common'; +import { IS_PUBLIC_KEY } from '../auth.constants'; + +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); + diff --git a/server/src/auth/decorators/roles.decorator.ts b/server/src/auth/decorators/roles.decorator.ts new file mode 100644 index 0000000..0101ed5 --- /dev/null +++ b/server/src/auth/decorators/roles.decorator.ts @@ -0,0 +1,6 @@ +import { SetMetadata } from '@nestjs/common'; +import { ROLES_KEY } from '../auth.constants'; +import { RealmRole } from '../roles/realm-role.enum'; + +export const Roles = (...roles: RealmRole[]) => SetMetadata(ROLES_KEY, roles); + diff --git a/server/src/auth/guards/jwt-auth.guard.ts b/server/src/auth/guards/jwt-auth.guard.ts new file mode 100644 index 0000000..0e6a938 --- /dev/null +++ b/server/src/auth/guards/jwt-auth.guard.ts @@ -0,0 +1,54 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Request } from 'express'; +import { IS_PUBLIC_KEY } from '../auth.constants'; +import { AuthService } from '../auth.service'; + +@Injectable() +export class JwtAuthGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly authService: AuthService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (isPublic) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const token = this.extractBearerToken(request); + + if (!token) { + throw new UnauthorizedException('Missing bearer token'); + } + + request.user = await this.authService.verifyAccessToken(token); + return true; + } + + private extractBearerToken(request: Request): string | null { + const authorization = request.headers.authorization; + if (!authorization) { + return null; + } + + const [scheme, token] = authorization.split(' '); + if (scheme?.toLowerCase() !== 'bearer' || !token) { + return null; + } + + return token; + } +} + diff --git a/server/src/auth/guards/roles.guard.ts b/server/src/auth/guards/roles.guard.ts new file mode 100644 index 0000000..94f6393 --- /dev/null +++ b/server/src/auth/guards/roles.guard.ts @@ -0,0 +1,49 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Request } from 'express'; +import { IS_PUBLIC_KEY, ROLES_KEY } from '../auth.constants'; +import { RealmRole } from '../roles/realm-role.enum'; + +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private readonly reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (isPublic) { + return true; + } + + const requiredRoles = + this.reflector.getAllAndOverride(ROLES_KEY, [ + context.getHandler(), + context.getClass(), + ]) ?? []; + + if (requiredRoles.length === 0) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const userRoles = request.user?.roles ?? []; + const hasRequiredRole = requiredRoles.some((role) => + userRoles.includes(role), + ); + + if (!hasRequiredRole) { + throw new ForbiddenException('Access denied: insufficient role'); + } + + return true; + } +} + diff --git a/server/src/auth/interfaces/authenticated-user.interface.ts b/server/src/auth/interfaces/authenticated-user.interface.ts new file mode 100644 index 0000000..30c4599 --- /dev/null +++ b/server/src/auth/interfaces/authenticated-user.interface.ts @@ -0,0 +1,20 @@ +import { JWTPayload } from 'jose'; + +export interface KeycloakJwtPayload extends JWTPayload { + preferred_username?: string; + name?: string; + email?: string; + realm_access?: { + roles?: string[]; + }; +} + +export interface AuthenticatedUser { + sub: string; + username?: string; + name?: string; + email?: string; + roles: string[]; + claims: KeycloakJwtPayload; +} + diff --git a/server/src/auth/interfaces/express-request.interface.ts b/server/src/auth/interfaces/express-request.interface.ts new file mode 100644 index 0000000..696ab5a --- /dev/null +++ b/server/src/auth/interfaces/express-request.interface.ts @@ -0,0 +1,12 @@ +import { AuthenticatedUser } from './authenticated-user.interface'; + +declare global { + namespace Express { + interface Request { + user?: AuthenticatedUser; + } + } +} + +export {}; + diff --git a/server/src/auth/roles/realm-role.enum.ts b/server/src/auth/roles/realm-role.enum.ts new file mode 100644 index 0000000..e6856b9 --- /dev/null +++ b/server/src/auth/roles/realm-role.enum.ts @@ -0,0 +1,6 @@ +export enum RealmRole { + Admin = 'admin', + Editor = 'editor', + Viewer = 'viewer', +} + diff --git a/server/src/config/env.validation.ts b/server/src/config/env.validation.ts new file mode 100644 index 0000000..4fed5dc --- /dev/null +++ b/server/src/config/env.validation.ts @@ -0,0 +1,58 @@ +export interface RuntimeEnvironment { + PORT: number; + DATABASE_URL: string; + CORS_ALLOWED_ORIGINS: string; + KEYCLOAK_ISSUER_URL: string; + KEYCLOAK_AUDIENCE: string; + KEYCLOAK_JWKS_URL?: string; +} + +function getRequiredString( + config: Record, + key: keyof RuntimeEnvironment, +): string { + const value = config[key]; + if (typeof value !== 'string' || !value.trim()) { + throw new Error(`Missing required environment variable: ${key}`); + } + return value.trim(); +} + +function getOptionalString( + config: Record, + key: keyof RuntimeEnvironment, +): string | undefined { + const value = config[key]; + if (typeof value !== 'string') { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function parsePort(value: unknown): number { + if (value === undefined || value === null || value === '') { + return 3000; + } + + const port = Number(value); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error('Environment variable PORT must be an integer between 1 and 65535'); + } + + return port; +} + +export function validateEnvironment( + config: Record, +): RuntimeEnvironment { + return { + PORT: parsePort(config.PORT), + DATABASE_URL: getRequiredString(config, 'DATABASE_URL'), + CORS_ALLOWED_ORIGINS: getRequiredString(config, 'CORS_ALLOWED_ORIGINS'), + KEYCLOAK_ISSUER_URL: getRequiredString(config, 'KEYCLOAK_ISSUER_URL'), + KEYCLOAK_AUDIENCE: getRequiredString(config, 'KEYCLOAK_AUDIENCE'), + KEYCLOAK_JWKS_URL: getOptionalString(config, 'KEYCLOAK_JWKS_URL'), + }; +} + diff --git a/server/src/health/health.controller.ts b/server/src/health/health.controller.ts index 4b0e136..24ddbb2 100644 --- a/server/src/health/health.controller.ts +++ b/server/src/health/health.controller.ts @@ -1,5 +1,7 @@ import { Controller, Get } from '@nestjs/common'; +import { Public } from '../auth/decorators/public.decorator'; +@Public() @Controller('health') export class HealthController { @Get() diff --git a/server/src/main.ts b/server/src/main.ts index bfb39ea..2f858bb 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -1,12 +1,35 @@ import { NestFactory } from '@nestjs/core'; +import { ConfigService } from '@nestjs/config'; import { AppModule } from './app.module'; +import { RuntimeEnvironment } from './config/env.validation'; async function bootstrap() { const app = await NestFactory.create(AppModule); + const configService = app.get>( + ConfigService, + ); + + const allowedOrigins = configService + .getOrThrow('CORS_ALLOWED_ORIGINS') + .split(',') + .map((origin) => origin.trim()) + .filter((origin) => origin.length > 0); + app.enableCors({ - origin: true, + origin: (origin, callback) => { + if (!origin || allowedOrigins.includes(origin)) { + callback(null, true); + return; + } + callback(new Error(`Origin ${origin} is not allowed by CORS`), false); + }, + methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Authorization', 'Content-Type'], exposedHeaders: ['Content-Range'], + credentials: false, }); - await app.listen(process.env.PORT ?? 3000); + + const port = configService.get('PORT', 3000); + await app.listen(port); } bootstrap(); diff --git a/server/src/modules/equipment-type/equipment-type.controller.ts b/server/src/modules/equipment-type/equipment-type.controller.ts index 14d21e5..a5c9984 100644 --- a/server/src/modules/equipment-type/equipment-type.controller.ts +++ b/server/src/modules/equipment-type/equipment-type.controller.ts @@ -1,5 +1,7 @@ import { Controller, Get, Post, Patch, Delete, Param, Body, Query, Res } from '@nestjs/common'; import { Response } from 'express'; +import { Roles } from '../../auth/decorators/roles.decorator'; +import { RealmRole } from '../../auth/roles/realm-role.enum'; import { EquipmentTypeService } from './equipment-type.service'; import { CreateEquipmentTypeDto } from './dto/create-equipment-type.dto'; import { UpdateEquipmentTypeDto } from './dto/update-equipment-type.dto'; @@ -8,6 +10,7 @@ import { UpdateEquipmentTypeDto } from './dto/update-equipment-type.dto'; export class EquipmentTypeController { constructor(private readonly equipmentTypeService: EquipmentTypeService) {} + @Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin) @Get() async findAll(@Query() query: any, @Res() res: Response) { const result = await this.equipmentTypeService.findAll(query); @@ -16,21 +19,25 @@ export class EquipmentTypeController { return res.json(result.data); } + @Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin) @Get(':code') findOne(@Param('code') code: string) { return this.equipmentTypeService.findOne(code); } + @Roles(RealmRole.Editor, RealmRole.Admin) @Post() create(@Body() dto: CreateEquipmentTypeDto) { return this.equipmentTypeService.create(dto); } + @Roles(RealmRole.Editor, RealmRole.Admin) @Patch(':code') update(@Param('code') code: string, @Body() dto: UpdateEquipmentTypeDto) { return this.equipmentTypeService.update(code, dto); } + @Roles(RealmRole.Admin) @Delete(':code') remove(@Param('code') code: string) { return this.equipmentTypeService.remove(code); diff --git a/server/src/modules/equipment/equipment.controller.ts b/server/src/modules/equipment/equipment.controller.ts index 3ab81e3..fad2ff4 100644 --- a/server/src/modules/equipment/equipment.controller.ts +++ b/server/src/modules/equipment/equipment.controller.ts @@ -1,5 +1,7 @@ import { Controller, Get, Post, Patch, Delete, Param, Body, Query, Res } from '@nestjs/common'; import { Response } from 'express'; +import { Roles } from '../../auth/decorators/roles.decorator'; +import { RealmRole } from '../../auth/roles/realm-role.enum'; import { EquipmentService } from './equipment.service'; import { CreateEquipmentDto } from './dto/create-equipment.dto'; import { UpdateEquipmentDto } from './dto/update-equipment.dto'; @@ -8,6 +10,7 @@ import { UpdateEquipmentDto } from './dto/update-equipment.dto'; export class EquipmentController { constructor(private readonly equipmentService: EquipmentService) {} + @Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin) @Get() async findAll(@Query() query: any, @Res() res: Response) { const result = await this.equipmentService.findAll(query); @@ -16,21 +19,25 @@ export class EquipmentController { return res.json(result.data); } + @Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin) @Get(':id') findOne(@Param('id') id: string) { return this.equipmentService.findOne(id); } + @Roles(RealmRole.Editor, RealmRole.Admin) @Post() create(@Body() dto: CreateEquipmentDto) { return this.equipmentService.create(dto); } + @Roles(RealmRole.Editor, RealmRole.Admin) @Patch(':id') update(@Param('id') id: string, @Body() dto: UpdateEquipmentDto) { return this.equipmentService.update(id, dto); } + @Roles(RealmRole.Admin) @Delete(':id') remove(@Param('id') id: string) { return this.equipmentService.remove(id); diff --git a/server/src/modules/repair-order/repair-order.controller.ts b/server/src/modules/repair-order/repair-order.controller.ts index 8787894..af4ee24 100644 --- a/server/src/modules/repair-order/repair-order.controller.ts +++ b/server/src/modules/repair-order/repair-order.controller.ts @@ -1,5 +1,7 @@ import { Controller, Get, Post, Patch, Delete, Param, Body, Query, Res } from '@nestjs/common'; import { Response } from 'express'; +import { Roles } from '../../auth/decorators/roles.decorator'; +import { RealmRole } from '../../auth/roles/realm-role.enum'; import { RepairOrderService } from './repair-order.service'; import { CreateRepairOrderDto } from './dto/create-repair-order.dto'; import { UpdateRepairOrderDto } from './dto/update-repair-order.dto'; @@ -8,6 +10,7 @@ import { UpdateRepairOrderDto } from './dto/update-repair-order.dto'; export class RepairOrderController { constructor(private readonly repairOrderService: RepairOrderService) {} + @Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin) @Get() async findAll(@Query() query: any, @Res() res: Response) { const result = await this.repairOrderService.findAll(query); @@ -16,21 +19,25 @@ export class RepairOrderController { return res.json(result.data); } + @Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin) @Get(':id') findOne(@Param('id') id: string) { return this.repairOrderService.findOne(id); } + @Roles(RealmRole.Editor, RealmRole.Admin) @Post() create(@Body() dto: CreateRepairOrderDto) { return this.repairOrderService.create(dto); } + @Roles(RealmRole.Editor, RealmRole.Admin) @Patch(':id') update(@Param('id') id: string, @Body() dto: UpdateRepairOrderDto) { return this.repairOrderService.update(id, dto); } + @Roles(RealmRole.Admin) @Delete(':id') remove(@Param('id') id: string) { return this.repairOrderService.remove(id); diff --git a/server/test/app.e2e-spec.ts b/server/test/app.e2e-spec.ts index 50cda62..ce1c8f7 100644 --- a/server/test/app.e2e-spec.ts +++ b/server/test/app.e2e-spec.ts @@ -1,24 +1,83 @@ import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; import * as request from 'supertest'; +import { AuthService } from '../src/auth/auth.service'; +import { AuthenticatedUser } from '../src/auth/interfaces/authenticated-user.interface'; +import { PrismaService } from '../src/prisma/prisma.service'; import { AppModule } from './../src/app.module'; -describe('AppController (e2e)', () => { +describe('Auth and Health (e2e)', () => { let app: INestApplication; + let authServiceMock: { + verifyAccessToken: jest.Mock, [string]>; + }; + + beforeAll(async () => { + process.env.PORT = '3000'; + process.env.DATABASE_URL = + process.env.DATABASE_URL ?? + 'postgresql://postgres:postgres@localhost:5432/toir'; + process.env.CORS_ALLOWED_ORIGINS = + process.env.CORS_ALLOWED_ORIGINS ?? + 'http://localhost:5173,https://toir-frontend.greact.ru'; + process.env.KEYCLOAK_ISSUER_URL = + process.env.KEYCLOAK_ISSUER_URL ?? 'https://sso.greact.ru/realms/toir'; + process.env.KEYCLOAK_AUDIENCE = + process.env.KEYCLOAK_AUDIENCE ?? 'toir-backend'; + + authServiceMock = { + verifyAccessToken: jest.fn, [string]>(), + }; - beforeEach(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], - }).compile(); + }) + .overrideProvider(AuthService) + .useValue(authServiceMock) + .overrideProvider(PrismaService) + .useValue({}) + .compile(); app = moduleFixture.createNestApplication(); await app.init(); }); - it('/ (GET)', () => { + afterAll(async () => { + await app.close(); + }); + + beforeEach(() => { + authServiceMock.verifyAccessToken.mockReset(); + }); + + it('/health (GET) is public', () => { return request(app.getHttpServer()) - .get('/') + .get('/health') .expect(200) - .expect('Hello World!'); + .expect({ status: 'ok' }); + }); + + it('/equipment (GET) requires authentication', () => { + return request(app.getHttpServer()).get('/equipment').expect(401); + }); + + it('/equipment (POST) returns 403 for authenticated viewer role', async () => { + authServiceMock.verifyAccessToken.mockResolvedValue({ + sub: 'viewer-user', + username: 'viewer-user', + roles: ['viewer'], + claims: { + sub: 'viewer-user', + realm_access: { + roles: ['viewer'], + }, + }, + }); + + await request(app.getHttpServer()) + .post('/equipment') + .set('Authorization', 'Bearer viewer-token') + .send({}) + .expect(403); }); }); diff --git a/server/test/jest-e2e.json b/server/test/jest-e2e.json index e9d912f..e9166a0 100644 --- a/server/test/jest-e2e.json +++ b/server/test/jest-e2e.json @@ -5,5 +5,8 @@ "testRegex": ".e2e-spec.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" + }, + "moduleNameMapper": { + "^jose$": "/mocks/jose.ts" } } diff --git a/server/test/mocks/jose.ts b/server/test/mocks/jose.ts new file mode 100644 index 0000000..97a9d97 --- /dev/null +++ b/server/test/mocks/jose.ts @@ -0,0 +1,8 @@ +export const createRemoteJWKSet = () => { + return async () => ({}) as never; +}; + +export const jwtVerify = async () => { + return { payload: {} }; +}; + From 7e6b76cef2495d252e4fe29e13410200bfd51b8b Mon Sep 17 00:00:00 2001 From: MaKarin Date: Sat, 21 Mar 2026 17:14:37 +0300 Subject: [PATCH 2/3] use only TOiR.domain.dsl like single source of truth for generation, update context for pinned .gitignore --- .gitignore | 37 ++ AUTH_RUNTIME.md | 60 -- auth/frontend-auth-rules.md | 24 +- backend/architecture.md | 19 +- domain/dsl-spec.md | 94 +-- examples/TOiR-ui.dsl | 57 -- examples/TOiR.api.dsl | 811 ----------------------- examples/TOiR.dto.dsl | 753 --------------------- frontend/architecture.md | 13 +- frontend/react-admin-rules.md | 17 + general-prompt.md | 68 +- generation/backend-generation.md | 43 +- generation/dev-workflow.md | 2 + generation/frontend-generation.md | 49 +- generation/post-generation-validation.md | 65 +- generation/runtime-bootstrap.md | 2 + generation/scaffolding-rules.md | 28 +- generation/update-strategy.md | 11 +- 18 files changed, 394 insertions(+), 1759 deletions(-) create mode 100644 .gitignore delete mode 100644 AUTH_RUNTIME.md delete mode 100644 examples/TOiR-ui.dsl delete mode 100644 examples/TOiR.api.dsl delete mode 100644 examples/TOiR.dto.dsl diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5ad6e4e --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# Dependencies +**/node_modules/ + +# Build outputs +**/dist/ +**/dist-ssr/ +**/coverage/ +**/.cache/ +**/*.tsbuildinfo + +# Environment files +**/.env +**/.env.local +**/.env.*.local + +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# OS files +.DS_Store +Thumbs.db + +# Editor / IDE +.vscode/* +!.vscode/extensions.json +.idea/ +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/AUTH_RUNTIME.md b/AUTH_RUNTIME.md deleted file mode 100644 index f1e3bf2..0000000 --- a/AUTH_RUNTIME.md +++ /dev/null @@ -1,60 +0,0 @@ -# Runtime Keycloak Auth - -This repository now uses Keycloak-based authentication for the runtime application only (`client/` and `server/`). - -## Required environment variables - -### Frontend (`client/.env`) - -- `VITE_API_URL` (example: `http://localhost:3000`) -- `VITE_KEYCLOAK_URL` (example: `https://sso.greact.ru`) -- `VITE_KEYCLOAK_REALM` (example: `toir`) -- `VITE_KEYCLOAK_CLIENT_ID` (example: `toir-frontend`) - -The frontend fails fast at startup if any required auth/env variable is missing. - -### Backend (`server/.env`) - -- `PORT` (example: `3000`) -- `DATABASE_URL` -- `CORS_ALLOWED_ORIGINS` (comma-separated list) -- `KEYCLOAK_ISSUER_URL` (example: `https://sso.greact.ru/realms/toir`) -- `KEYCLOAK_AUDIENCE` (example: `toir-backend`) -- `KEYCLOAK_JWKS_URL` (optional) - -Backend validates required env vars at startup and fails fast if missing. - -## Frontend auth flow - -- The SPA initializes Keycloak before rendering. -- Authentication is redirect-based Keycloak login only (no custom username/password form). -- Authorization Code + PKCE (`S256`) is used. -- Access token is attached to every API request via `Authorization: Bearer `. -- `checkError` behavior: - - `401`: force re-authentication. - - `403`: keep session and surface access denied to React Admin. -- Token refresh is concurrency-safe: parallel requests share one in-flight refresh operation. - -## Backend JWT validation and RBAC - -- JWTs are verified against: - - issuer: `KEYCLOAK_ISSUER_URL` - - audience: `KEYCLOAK_AUDIENCE` -- JWKS resolution priority: - 1. explicit `KEYCLOAK_JWKS_URL` - 2. OIDC discovery (`/.well-known/openid-configuration`) - 3. fallback `${issuer}/protocol/openid-connect/certs` -- RBAC source is only `realm_access.roles`. - -Role policy applied to CRUD endpoints: - -- `GET`: `viewer`, `editor`, `admin` -- `POST`, `PATCH` (and `PUT` if added): `editor`, `admin` -- `DELETE`: `admin` - -Public route: - -- `GET /health` - -All other existing API routes require authentication. - diff --git a/auth/frontend-auth-rules.md b/auth/frontend-auth-rules.md index 394d44d..4155e29 100644 --- a/auth/frontend-auth-rules.md +++ b/auth/frontend-auth-rules.md @@ -49,6 +49,29 @@ The generated frontend must not rely on anonymous access with later lazy auth at --- +# Identity Resolution + +The generated `authProvider.getIdentity()` must derive identity from token claims already present in the parsed access token / parsed token. + +Preferred claims: + +- `sub` +- `preferred_username` +- `email` +- `name` + +Rules: + +1. `getIdentity()` must be token-claim based by default. +2. The generated frontend must **not** call `keycloak.loadUserProfile()` during normal app startup or baseline identity resolution. +3. The generated frontend must **not** depend on the Keycloak `/account` endpoint for baseline CRUD/admin generation. +4. The default generator strategy is to avoid the `/account` request entirely, not to broaden Keycloak CORS behavior. +5. Any network-based account-profile integration requires an explicit future prompt. + +The generator must not introduce startup/profile-fetch requests that are unnecessary for authorization. + +--- + # Shared Request Seam The generated frontend must use the shared request seam in `client/src/dataProvider.ts` as the single place where access tokens are attached. @@ -131,4 +154,3 @@ VITE_KEYCLOAK_URL=https://sso.greact.ru VITE_KEYCLOAK_REALM=toir VITE_KEYCLOAK_CLIENT_ID=toir-frontend ``` - diff --git a/backend/architecture.md b/backend/architecture.md index a8a7ec5..2bd9be3 100644 --- a/backend/architecture.md +++ b/backend/architecture.md @@ -9,7 +9,7 @@ Backend stack: - PostgreSQL - jose -The backend is generated from the DSL specification. +The backend is generated from `domain/*.dsl`. Each DSL entity becomes: @@ -21,6 +21,15 @@ Each DSL entity becomes: --- +# Single Source of Truth + +- `domain/*.dsl` is the only required input for backend generation. +- DTOs and REST API contracts must be derived from the domain model, primary keys, foreign keys, and enums defined in the domain DSL. +- Backend documentation, generation rules, and optional overrides must not duplicate entity, attribute, or relation structures outside the domain DSL. +- Deprecated multi-DSL inputs are compatibility-only artifacts and must never be treated as authoritative backend inputs or used to redefine entities, attributes, primary keys, foreign keys, relations, or enums. + +--- + # Project Structure server/ @@ -180,6 +189,12 @@ Response format must follow React Admin requirements: "total": number } +Sorting rules: + +1. Generated list/query logic must use actual model field names in ORM `orderBy` clauses. +2. If an entity primary key is not literally `id` but the API exposes synthetic `id` for React Admin compatibility, incoming `_sort=id` must be mapped to the real primary key field before building the query. +3. This mapping rule applies generally to all natural-key entities, not as a one-off entity hack. + --- # Service Layer @@ -198,6 +213,8 @@ remove(id) # DTO Rules +DTOs are generated automatically from the domain DSL and are never a separate required DSL input. + Create DTO: - contains required fields diff --git a/domain/dsl-spec.md b/domain/dsl-spec.md index 3ccc6c4..63b1755 100644 --- a/domain/dsl-spec.md +++ b/domain/dsl-spec.md @@ -1,6 +1,44 @@ # DSL Language Specification -This document describes the DSL (Domain Specific Language) used to specify fullstack CRUD applications. The DSL has four layers: Domain, DTO, API, and UI. +This document describes the single DSL (Domain Specific Language) used to specify fullstack CRUD applications. The only required DSL input is `domain/*.dsl`. + +--- + +# DSL Responsibility + +The domain DSL defines only: + +- domain model +- relations +- enums + +The domain DSL is the single source of truth for: + +- entities +- attributes +- primary keys +- foreign keys +- enums + +The following layers are always derived from the domain DSL and must not be authored as standalone authoritative DSL inputs: + +- DTO +- API +- UI + +Optional extension mechanism: + +```text +overrides/ + api-overrides.dsl + ui-overrides.dsl +``` + +Override rules: + +- Overrides are optional. +- The generator must work without them. +- Overrides may refine derived API or UI behavior, but they must not duplicate or redefine entities, attributes, primary keys, foreign keys, relations, or enums. --- @@ -108,7 +146,7 @@ attribute equipmentTypeCode { ## required -- **is required** — attribute is non-nullable in domain and (unless overridden) in DTOs. +- **is required** — attribute is non-nullable in the domain model and drives requiredness in derived DTO/API/UI artifacts. - Absence of `is required` means the attribute is optional (nullable). --- @@ -120,33 +158,6 @@ attribute equipmentTypeCode { --- -## map - -Used in **DTO** and **UI** layers to bind a DTO/UI field to a domain entity attribute. - -**In DTO:** - -``` -attribute name { - type string; - map Equipment.name; -} -``` - -- Ensures DTO attribute corresponds to an existing `Entity.attribute` and that types align. - -**In UI:** - -``` -attribute Наименование { - map Equipment.name; -} -``` - -- UI label (e.g. "Наименование") maps to domain field `Equipment.name` for correct data binding and generation. - ---- - # DSL → System Component Mapping ## DSL → Prisma @@ -174,9 +185,9 @@ attribute Наименование { | entity | One module (e.g. equipment.module.ts) | | entity | Controller with CRUD endpoints | | entity | Service with Prisma CRUD | -| DTO (Create) | create-{entity}.dto.ts | -| DTO (Update) | update-{entity}.dto.ts | -| DTO (Response) | Used for GET response shape | +| entity + attribute metadata | create-{entity}.dto.ts | +| entity + attribute metadata | update-{entity}.dto.ts | +| entity + attribute metadata | Response DTO / API shape | API paths are derived from entity name: PascalCase → kebab-case, pluralized (e.g. `Equipment` → `/equipment`, `RepairOrder` → `/repair-orders`). @@ -193,21 +204,12 @@ API paths are derived from entity name: PascalCase → kebab-case, pluralized (e | type date | DateInput, DateField | | enum | SelectInput with choices | | foreign key | ReferenceInput, ReferenceField | -| UI attribute with map | Field with correct source | --- -# DTO Mapping +# Derived Layer Mapping -- **map Entity.attribute** — DTO attribute corresponds to domain attribute; types must match. -- **Create DTO** — must not include generated primary keys (e.g. no `id` for uuid PK). -- **Update DTO** — all fields optional (nullable) for partial updates. -- **List response DTO** — must expose `data` (array) and `total` (integer) for React Admin compatibility. - ---- - -# UI Mapping - -- Each UI attribute should have **map Entity.attribute** so it binds to a real domain field. -- UI attribute name is the label (e.g. "Наименование"); **source** in generated components is the domain attribute name (e.g. `name`). -- Enums → SelectInput; foreign keys → ReferenceInput/ReferenceField. +- **Create DTO** — derived from domain attributes and must not include generated primary keys (for example no `id` for uuid PKs). +- **Update DTO** — derived from the same domain attributes with all fields optional for partial updates. +- **API response shape** — derived from domain attributes and must expose React Admin-compatible identifiers when needed. +- **UI field mapping** — derived from attribute types, descriptions, enums, and foreign keys without a separate UI DSL. diff --git a/examples/TOiR-ui.dsl b/examples/TOiR-ui.dsl deleted file mode 100644 index 36785de..0000000 --- a/examples/TOiR-ui.dsl +++ /dev/null @@ -1,57 +0,0 @@ -import ./TOiR; - -ui UI.Equipment { - offset 600; - - description "Единица оборудования — объект ремонта и технического обслуживания"; - - attribute Код { - map Equipment.id; - } - - attribute ИнвентарныйНомер { - map Equipment.inventoryNumber; - } - - attribute СерийныйНомер { - map Equipment.serialNumber; - } - - attribute Наименование { - map Equipment.name; - } - - // Связь с видом оборудования (справочник НСИ) - attribute Тип { - map Equipment.equipmentTypeCode; - } - - attribute Статус { - map Equipment.status; - description "Текущий статус"; - } - - attribute МестоТекущее { - map Equipment.location; - } - - attribute ДатаВвода { - map Equipment.commissionedAt; - } - - attribute НаработкаВсего { - map Equipment.totalEngineHours; - } - - attribute НаработкаТекущая { - map Equipment.engineHoursSinceLastRepair; - } - - attribute Ремонт { - map Equipment.lastRepairAt; - } - - attribute Примечания { - map Equipment.notes; - } -} \ No newline at end of file diff --git a/examples/TOiR.api.dsl b/examples/TOiR.api.dsl deleted file mode 100644 index f60fd99..0000000 --- a/examples/TOiR.api.dsl +++ /dev/null @@ -1,811 +0,0 @@ -// ───────────────────────────────────────────── -// DTO: Вид оборудования (EquipmentType) -// ───────────────────────────────────────────── - -dto DTO.EquipmentType { - description "Вид оборудования — полный объект ответа"; - - attribute code { - type string; - description "Код вида оборудования"; - map EquipmentType.code; - } - - attribute name { - type string; - description "Наименование вида"; - map EquipmentType.name; - } - - attribute manufacturer { - type string; - description "Производитель"; - is nullable; - map EquipmentType.manufacturer; - } - - attribute maintenanceIntervalHours { - type integer; - description "Периодичность ТО, моточасов"; - is nullable; - map EquipmentType.maintenanceIntervalHours; - } - - attribute overhaulIntervalHours { - type integer; - description "Периодичность КР, моточасов"; - is nullable; - map EquipmentType.overhaulIntervalHours; - } -} - -dto DTO.EquipmentTypeCreate { - description "Вид оборудования — тело запроса на создание"; - - attribute code { - type string; - description "Код вида оборудования"; - is required; - map EquipmentType.code; - } - - attribute name { - type string; - description "Наименование вида"; - is required; - map EquipmentType.name; - } - - attribute manufacturer { - type string; - description "Производитель"; - is nullable; - map EquipmentType.manufacturer; - } - - attribute maintenanceIntervalHours { - type integer; - description "Периодичность ТО, моточасов"; - is nullable; - map EquipmentType.maintenanceIntervalHours; - } - - attribute overhaulIntervalHours { - type integer; - description "Периодичность КР, моточасов"; - is nullable; - map EquipmentType.overhaulIntervalHours; - } -} - -dto DTO.EquipmentTypeUpdate { - description "Вид оборудования — тело запроса на обновление (частичное)"; - - attribute code { - type string; - description "Код вида оборудования"; - is nullable; - map EquipmentType.code; - } - - attribute name { - type string; - description "Наименование вида"; - is nullable; - map EquipmentType.name; - } - - attribute manufacturer { - type string; - description "Производитель"; - is nullable; - map EquipmentType.manufacturer; - } - - attribute maintenanceIntervalHours { - type integer; - description "Периодичность ТО, моточасов"; - is nullable; - map EquipmentType.maintenanceIntervalHours; - } - - attribute overhaulIntervalHours { - type integer; - description "Периодичность КР, моточасов"; - is nullable; - map EquipmentType.overhaulIntervalHours; - } -} - -dto DTO.EquipmentTypeListResponse { - description "Список видов оборудования (формат React Admin)"; - - attribute data { - type DTO.EquipmentType[]; - } - - attribute total { - type integer; - } -} - -// ───────────────────────────────────────────── -// DTO: Оборудование (Equipment) -// ───────────────────────────────────────────── - -dto DTO.Equipment { - description "Единица оборудования — полный объект ответа"; - - attribute id { - type uuid; - map Equipment.id; - } - - attribute inventoryNumber { - type string; - description "Инвентарный номер"; - map Equipment.inventoryNumber; - } - - attribute serialNumber { - type string; - description "Заводской (серийный) номер"; - is nullable; - map Equipment.serialNumber; - } - - attribute name { - type string; - description "Наименование единицы оборудования"; - map Equipment.name; - } - - attribute equipmentTypeCode { - type string; - description "Код вида оборудования"; - map Equipment.equipmentTypeCode; - } - - attribute status { - type EquipmentStatus; - description "Текущий статус"; - map Equipment.status; - } - - attribute location { - type string; - description "Место эксплуатации / скважина / куст"; - is nullable; - map Equipment.location; - } - - attribute commissionedAt { - type date; - description "Дата ввода в эксплуатацию"; - is nullable; - map Equipment.commissionedAt; - } - - attribute totalEngineHours { - type decimal; - description "Общая наработка, моточасов"; - is nullable; - map Equipment.totalEngineHours; - } - - attribute engineHoursSinceLastRepair { - type decimal; - description "Наработка с последнего ремонта, моточасов"; - is nullable; - map Equipment.engineHoursSinceLastRepair; - } - - attribute lastRepairAt { - type date; - description "Дата последнего ремонта"; - is nullable; - map Equipment.lastRepairAt; - } - - attribute notes { - type text; - description "Примечания"; - is nullable; - map Equipment.notes; - } -} - -dto DTO.EquipmentCreate { - description "Единица оборудования — тело запроса на создание"; - - attribute inventoryNumber { - type string; - description "Инвентарный номер"; - is required; - map Equipment.inventoryNumber; - } - - attribute serialNumber { - type string; - description "Заводской (серийный) номер"; - is nullable; - map Equipment.serialNumber; - } - - attribute name { - type string; - description "Наименование единицы оборудования"; - is required; - map Equipment.name; - } - - attribute equipmentTypeCode { - type string; - description "Код вида оборудования"; - is required; - map Equipment.equipmentTypeCode; - } - - attribute status { - type EquipmentStatus; - description "Текущий статус"; - is nullable; - map Equipment.status; - } - - attribute location { - type string; - description "Место эксплуатации / скважина / куст"; - is nullable; - map Equipment.location; - } - - attribute commissionedAt { - type date; - description "Дата ввода в эксплуатацию"; - is nullable; - map Equipment.commissionedAt; - } - - attribute totalEngineHours { - type decimal; - description "Общая наработка, моточасов"; - is nullable; - map Equipment.totalEngineHours; - } - - attribute engineHoursSinceLastRepair { - type decimal; - description "Наработка с последнего ремонта, моточасов"; - is nullable; - map Equipment.engineHoursSinceLastRepair; - } - - attribute lastRepairAt { - type date; - description "Дата последнего ремонта"; - is nullable; - map Equipment.lastRepairAt; - } - - attribute notes { - type text; - description "Примечания"; - is nullable; - map Equipment.notes; - } -} - -dto DTO.EquipmentUpdate { - description "Единица оборудования — тело запроса на обновление (частичное)"; - - attribute inventoryNumber { - type string; - description "Инвентарный номер"; - is nullable; - map Equipment.inventoryNumber; - } - - attribute serialNumber { - type string; - description "Заводской (серийный) номер"; - is nullable; - map Equipment.serialNumber; - } - - attribute name { - type string; - description "Наименование единицы оборудования"; - is nullable; - map Equipment.name; - } - - attribute equipmentTypeCode { - type string; - description "Код вида оборудования"; - is nullable; - map Equipment.equipmentTypeCode; - } - - attribute status { - type EquipmentStatus; - description "Текущий статус"; - is nullable; - map Equipment.status; - } - - attribute location { - type string; - description "Место эксплуатации / скважина / куст"; - is nullable; - map Equipment.location; - } - - attribute commissionedAt { - type date; - description "Дата ввода в эксплуатацию"; - is nullable; - map Equipment.commissionedAt; - } - - attribute totalEngineHours { - type decimal; - description "Общая наработка, моточасов"; - is nullable; - map Equipment.totalEngineHours; - } - - attribute engineHoursSinceLastRepair { - type decimal; - description "Наработка с последнего ремонта, моточасов"; - is nullable; - map Equipment.engineHoursSinceLastRepair; - } - - attribute lastRepairAt { - type date; - description "Дата последнего ремонта"; - is nullable; - map Equipment.lastRepairAt; - } - - attribute notes { - type text; - description "Примечания"; - is nullable; - map Equipment.notes; - } -} - -dto DTO.EquipmentListResponse { - description "Список оборудования (формат React Admin)"; - - attribute data { - type DTO.Equipment[]; - } - - attribute total { - type integer; - } -} - -// ───────────────────────────────────────────── -// DTO: Заявка на ремонт (RepairOrder) -// ───────────────────────────────────────────── - -dto DTO.RepairOrder { - description "Заявка на ремонт — полный объект ответа"; - - attribute id { - type uuid; - map RepairOrder.id; - } - - attribute number { - type string; - description "Номер заявки"; - map RepairOrder.number; - } - - attribute equipmentId { - type uuid; - description "Идентификатор единицы оборудования"; - map RepairOrder.equipmentId; - } - - attribute repairKind { - type RepairKind; - description "Вид ремонта"; - map RepairOrder.repairKind; - } - - attribute status { - type RepairOrderStatus; - description "Статус заявки"; - map RepairOrder.status; - } - - attribute plannedAt { - type date; - description "Плановая дата начала"; - map RepairOrder.plannedAt; - } - - attribute startedAt { - type date; - description "Фактическая дата начала"; - is nullable; - map RepairOrder.startedAt; - } - - attribute completedAt { - type date; - description "Фактическая дата завершения"; - is nullable; - map RepairOrder.completedAt; - } - - attribute contractor { - type string; - description "Подрядная организация (если внешний ремонт)"; - is nullable; - map RepairOrder.contractor; - } - - attribute engineHoursAtRepair { - type decimal; - description "Наработка на момент ремонта, моточасов"; - is nullable; - map RepairOrder.engineHoursAtRepair; - } - - attribute description { - type text; - description "Описание работ / дефекта"; - is nullable; - map RepairOrder.description; - } - - attribute notes { - type text; - description "Примечания"; - is nullable; - map RepairOrder.notes; - } -} - -dto DTO.RepairOrderCreate { - description "Заявка на ремонт — тело запроса на создание"; - - attribute number { - type string; - description "Номер заявки"; - is required; - map RepairOrder.number; - } - - attribute equipmentId { - type uuid; - description "Идентификатор единицы оборудования"; - is required; - map RepairOrder.equipmentId; - } - - attribute repairKind { - type RepairKind; - description "Вид ремонта"; - is required; - map RepairOrder.repairKind; - } - - attribute status { - type RepairOrderStatus; - description "Статус заявки"; - is nullable; - map RepairOrder.status; - } - - attribute plannedAt { - type date; - description "Плановая дата начала"; - is required; - map RepairOrder.plannedAt; - } - - attribute startedAt { - type date; - description "Фактическая дата начала"; - is nullable; - map RepairOrder.startedAt; - } - - attribute completedAt { - type date; - description "Фактическая дата завершения"; - is nullable; - map RepairOrder.completedAt; - } - - attribute contractor { - type string; - description "Подрядная организация (если внешний ремонт)"; - is nullable; - map RepairOrder.contractor; - } - - attribute engineHoursAtRepair { - type decimal; - description "Наработка на момент ремонта, моточасов"; - is nullable; - map RepairOrder.engineHoursAtRepair; - } - - attribute description { - type text; - description "Описание работ / дефекта"; - is nullable; - map RepairOrder.description; - } - - attribute notes { - type text; - description "Примечания"; - is nullable; - map RepairOrder.notes; - } -} - -dto DTO.RepairOrderUpdate { - description "Заявка на ремонт — тело запроса на обновление (частичное)"; - - attribute number { - type string; - description "Номер заявки"; - is nullable; - map RepairOrder.number; - } - - attribute equipmentId { - type uuid; - description "Идентификатор единицы оборудования"; - is nullable; - map RepairOrder.equipmentId; - } - - attribute repairKind { - type RepairKind; - description "Вид ремонта"; - is nullable; - map RepairOrder.repairKind; - } - - attribute status { - type RepairOrderStatus; - description "Статус заявки"; - is nullable; - map RepairOrder.status; - } - - attribute plannedAt { - type date; - description "Плановая дата начала"; - is nullable; - map RepairOrder.plannedAt; - } - - attribute startedAt { - type date; - description "Фактическая дата начала"; - is nullable; - map RepairOrder.startedAt; - } - - attribute completedAt { - type date; - description "Фактическая дата завершения"; - is nullable; - map RepairOrder.completedAt; - } - - attribute contractor { - type string; - description "Подрядная организация (если внешний ремонт)"; - is nullable; - map RepairOrder.contractor; - } - - attribute engineHoursAtRepair { - type decimal; - description "Наработка на момент ремонта, моточасов"; - is nullable; - map RepairOrder.engineHoursAtRepair; - } - - attribute description { - type text; - description "Описание работ / дефекта"; - is nullable; - map RepairOrder.description; - } - - attribute notes { - type text; - description "Примечания"; - is nullable; - map RepairOrder.notes; - } -} - -dto DTO.RepairOrderListResponse { - description "Список заявок на ремонт (формат React Admin)"; - - attribute data { - type DTO.RepairOrder[]; - } - - attribute total { - type integer; - } -} - -// ───────────────────────────────────────────── -// API: Виды оборудования -// ───────────────────────────────────────────── - -api API.EquipmentTypes { - description "API управления справочником видов оборудования"; - - endpoint listEquipmentTypes { - label "GET /equipment-types"; - description "Список видов оборудования (фильтры и пагинация — query-параметры)"; - attribute response { - type DTO.EquipmentTypeListResponse; - } - } - - endpoint getEquipmentType { - label "GET /equipment-types/{code}"; - description "Получить вид оборудования по коду"; - attribute code { - type string; - } - attribute response { - type DTO.EquipmentType; - } - } - - endpoint createEquipmentType { - label "POST /equipment-types"; - description "Создать вид оборудования"; - attribute request { - type DTO.EquipmentTypeCreate; - } - } - - endpoint updateEquipmentType { - label "PATCH /equipment-types/{code}"; - description "Обновить вид оборудования"; - attribute code { - type string; - } - attribute request { - type DTO.EquipmentTypeUpdate; - } - } - - endpoint deleteEquipmentType { - label "DELETE /equipment-types/{code}"; - description "Удалить вид оборудования"; - attribute code { - type string; - } - } -} - -// ───────────────────────────────────────────── -// API: Оборудование -// ───────────────────────────────────────────── - -api API.Equipment { - description "API управления оборудованием"; - - endpoint listEquipment { - label "GET /equipment"; - description "Список оборудования (фильтры и пагинация — query-параметры)"; - attribute response { - type DTO.EquipmentListResponse; - } - } - - endpoint getEquipment { - label "GET /equipment/{id}"; - description "Получить единицу оборудования по идентификатору"; - attribute id { - type uuid; - } - attribute response { - type DTO.Equipment; - } - } - - endpoint createEquipment { - label "POST /equipment"; - description "Создать единицу оборудования"; - attribute request { - type DTO.EquipmentCreate; - } - } - - endpoint updateEquipment { - label "PATCH /equipment/{id}"; - description "Обновить единицу оборудования"; - attribute id { - type uuid; - } - attribute request { - type DTO.EquipmentUpdate; - } - } - - endpoint deleteEquipment { - label "DELETE /equipment/{id}"; - description "Удалить единицу оборудования"; - attribute id { - type uuid; - } - } -} - -// ───────────────────────────────────────────── -// API: Заявки на ремонт -// ───────────────────────────────────────────── - -api API.RepairOrders { - description "API управления заявками на ремонт"; - - endpoint listRepairOrders { - label "GET /repair-orders"; - description "Список заявок на ремонт (фильтры и пагинация — query-параметры)"; - attribute response { - type DTO.RepairOrderListResponse; - } - } - - endpoint getRepairOrder { - label "GET /repair-orders/{id}"; - description "Получить заявку на ремонт по идентификатору"; - attribute id { - type uuid; - } - attribute response { - type DTO.RepairOrder; - } - } - - endpoint createRepairOrder { - label "POST /repair-orders"; - description "Создать заявку на ремонт"; - attribute request { - type DTO.RepairOrderCreate; - } - } - - endpoint updateRepairOrder { - label "PATCH /repair-orders/{id}"; - description "Обновить заявку на ремонт"; - attribute id { - type uuid; - } - attribute request { - type DTO.RepairOrderUpdate; - } - } - - endpoint deleteRepairOrder { - label "DELETE /repair-orders/{id}"; - description "Удалить заявку на ремонт"; - attribute id { - type uuid; - } - } -} \ No newline at end of file diff --git a/examples/TOiR.dto.dsl b/examples/TOiR.dto.dsl deleted file mode 100644 index 8b00f56..0000000 --- a/examples/TOiR.dto.dsl +++ /dev/null @@ -1,753 +0,0 @@ -/* - КИС ТОиР — DTO - Структуры данных для обмена через API -*/ - -//import ./TOiR; -//only external; - -// ───────────────────────────────────────────── -// Общие -// ───────────────────────────────────────────── - -dto DTO.PageRequest { - description "Параметры постраничной выдачи"; - - attribute page { - description "Номер страницы (начиная с 0)"; - type integer; - } - - attribute size { - description "Размер страницы"; - type integer; - } -} - -dto DTO.Filter { - description "Элемент фильтра для запросов списка"; - attribute field { type string; } - attribute operator { type string; } - attribute value { type string; } -} - -dto DTO.PageInfo { - description "Метаданные постраничной выдачи"; - attribute size { type integer; } - attribute number { type integer; } - attribute totalElements { type integer; } - attribute totalPages { type integer; } -} - - -// ───────────────────────────────────────────── -// Вид оборудования (EquipmentType) -// ───────────────────────────────────────────── - -dto DTO.EquipmentType { - description "Вид оборудования — response"; - - attribute code { - type string; - map EquipmentType.code; - } - - attribute name { - type string; - map EquipmentType.name; - } - - attribute manufacturer { - type string; - is nullable; - map EquipmentType.manufacturer; - } - - attribute maintenanceIntervalHours { - type integer; - is nullable; - map EquipmentType.maintenanceIntervalHours; - } - - attribute overhaulIntervalHours { - type integer; - is nullable; - map EquipmentType.overhaulIntervalHours; - } -} - -dto DTO.EquipmentTypeFilter { - description "Фильтры для списка видов оборудования"; - - attribute code { - description "Частичное совпадение по коду"; - type string; - is nullable; - } - - attribute name { - description "Частичное совпадение по наименованию"; - type string; - is nullable; - } - - attribute manufacturer { - description "Частичное совпадение по производителю"; - type string; - is nullable; - } -} - -dto DTO.EquipmentTypeCreate { - description "Тело запроса на создание вида оборудования"; - - attribute code { - description "Код вида оборудования"; - type string; - is required; - } - - attribute name { - description "Наименование вида"; - type string; - is required; - } - - attribute manufacturer { - description "Производитель"; - type string; - is nullable; - } - - attribute maintenanceIntervalHours { - description "Периодичность ТО, моточасов"; - type integer; - is nullable; - } - - attribute overhaulIntervalHours { - description "Периодичность КР, моточасов"; - type integer; - is nullable; - } -} - -dto DTO.EquipmentTypeUpdate { - description "Тело запроса на обновление вида оборудования"; - - attribute name { - description "Наименование вида"; - type string; - is nullable; - } - - attribute manufacturer { - description "Производитель"; - type string; - is nullable; - } - - attribute maintenanceIntervalHours { - description "Периодичность ТО, моточасов"; - type integer; - is nullable; - } - - attribute overhaulIntervalHours { - description "Периодичность КР, моточасов"; - type integer; - is nullable; - } -} - - -// ───────────────────────────────────────────── -// Оборудование (Equipment) -// ───────────────────────────────────────────── - -dto DTO.EquipmentListItem { - description "Строка списка оборудования"; - - attribute id { - type uuid; - map Equipment.id; - } - - attribute inventoryNumber { - type string; - map Equipment.inventoryNumber; - } - - attribute name { - type string; - map Equipment.name; - } - - attribute equipmentTypeCode { - type string; - map Equipment.equipmentTypeCode; - } - - attribute status { - type EquipmentStatus; - map Equipment.status; - } - - attribute location { - type string; - is nullable; - map Equipment.location; - } -} - -dto DTO.EquipmentDetail { - description "Полная информация об оборудовании"; - - attribute id { - type uuid; - map Equipment.id; - } - - attribute inventoryNumber { - type string; - map Equipment.inventoryNumber; - } - - attribute serialNumber { - type string; - is nullable; - map Equipment.serialNumber; - } - - attribute name { - type string; - map Equipment.name; - } - - attribute equipmentTypeCode { - type string; - map Equipment.equipmentTypeCode; - } - - attribute status { - type EquipmentStatus; - map Equipment.status; - } - - attribute location { - type string; - is nullable; - map Equipment.location; - } - - attribute commissionedAt { - type date; - is nullable; - map Equipment.commissionedAt; - } - - attribute totalEngineHours { - type decimal; - is nullable; - map Equipment.totalEngineHours; - } - - attribute engineHoursSinceLastRepair { - type decimal; - is nullable; - map Equipment.engineHoursSinceLastRepair; - } - - attribute lastRepairAt { - type date; - is nullable; - map Equipment.lastRepairAt; - } - - attribute notes { - type text; - is nullable; - map Equipment.notes; - } -} - -dto DTO.EquipmentFilter { - description "Фильтры для списка оборудования"; - - attribute inventoryNumber { - description "Частичное совпадение по инвентарному номеру"; - type string; - is nullable; - } - - attribute serialNumber { - description "Частичное совпадение по заводскому номеру"; - type string; - is nullable; - } - - attribute name { - description "Частичное совпадение по наименованию"; - type string; - is nullable; - } - - attribute equipmentTypeCode { - description "Точное совпадение по коду вида оборудования"; - type string; - is nullable; - } - - attribute status { - description "Фильтр по статусу"; - type EquipmentStatus; - is nullable; - } - - attribute location { - description "Частичное совпадение по месту эксплуатации"; - type string; - is nullable; - } -} - -dto DTO.EquipmentCreate { - description "Тело запроса на создание оборудования"; - - attribute inventoryNumber { - description "Инвентарный номер"; - type string; - is required; - } - - attribute serialNumber { - description "Заводской (серийный) номер"; - type string; - is nullable; - } - - attribute name { - description "Наименование единицы оборудования"; - type string; - is required; - } - - attribute equipmentTypeCode { - description "Код вида оборудования"; - type string; - is required; - } - - attribute status { - description "Текущий статус"; - type EquipmentStatus; - is nullable; - } - - attribute location { - description "Место эксплуатации / скважина / куст"; - type string; - is nullable; - } - - attribute commissionedAt { - description "Дата ввода в эксплуатацию"; - type date; - is nullable; - } - - attribute totalEngineHours { - description "Общая наработка, моточасов"; - type decimal; - is nullable; - } - - attribute engineHoursSinceLastRepair { - description "Наработка с последнего ремонта, моточасов"; - type decimal; - is nullable; - } - - attribute lastRepairAt { - description "Дата последнего ремонта"; - type date; - is nullable; - } - - attribute notes { - description "Примечания"; - type text; - is nullable; - } -} - -dto DTO.EquipmentUpdate { - description "Тело запроса на обновление оборудования"; - - attribute inventoryNumber { - description "Инвентарный номер"; - type string; - is nullable; - } - - attribute serialNumber { - description "Заводской (серийный) номер"; - type string; - is nullable; - } - - attribute name { - description "Наименование единицы оборудования"; - type string; - is nullable; - } - - attribute equipmentTypeCode { - description "Код вида оборудования"; - type string; - is nullable; - } - - attribute status { - description "Текущий статус"; - type EquipmentStatus; - is nullable; - } - - attribute location { - description "Место эксплуатации / скважина / куст"; - type string; - is nullable; - } - - attribute commissionedAt { - description "Дата ввода в эксплуатацию"; - type date; - is nullable; - } - - attribute totalEngineHours { - description "Общая наработка, моточасов"; - type decimal; - is nullable; - } - - attribute engineHoursSinceLastRepair { - description "Наработка с последнего ремонта, моточасов"; - type decimal; - is nullable; - } - - attribute lastRepairAt { - description "Дата последнего ремонта"; - type date; - is nullable; - } - - attribute notes { - description "Примечания"; - type text; - is nullable; - } -} - - -// ───────────────────────────────────────────── -// Заявка на ремонт (RepairOrder) -// ───────────────────────────────────────────── - -dto DTO.RepairOrderListItem { - description "Строка списка заявок на ремонт"; - - attribute id { - type uuid; - map RepairOrder.id; - } - - attribute number { - type string; - map RepairOrder.number; - } - - attribute equipmentId { - type uuid; - map RepairOrder.equipmentId; - } - - attribute repairKind { - type RepairKind; - map RepairOrder.repairKind; - } - - attribute status { - type RepairOrderStatus; - map RepairOrder.status; - } - - attribute plannedAt { - type date; - map RepairOrder.plannedAt; - } - - attribute contractor { - type string; - is nullable; - map RepairOrder.contractor; - } -} - -dto DTO.RepairOrderDetail { - description "Полная информация о заявке на ремонт"; - - attribute id { - type uuid; - map RepairOrder.id; - } - - attribute number { - type string; - map RepairOrder.number; - } - - attribute equipmentId { - type uuid; - map RepairOrder.equipmentId; - } - - attribute repairKind { - type RepairKind; - map RepairOrder.repairKind; - } - - attribute status { - type RepairOrderStatus; - map RepairOrder.status; - } - - attribute plannedAt { - type date; - map RepairOrder.plannedAt; - } - - attribute startedAt { - type date; - is nullable; - map RepairOrder.startedAt; - } - - attribute completedAt { - type date; - is nullable; - map RepairOrder.completedAt; - } - - attribute contractor { - type string; - is nullable; - map RepairOrder.contractor; - } - - attribute engineHoursAtRepair { - type decimal; - is nullable; - map RepairOrder.engineHoursAtRepair; - } - - attribute description { - type text; - is nullable; - map RepairOrder.description; - } - - attribute notes { - type text; - is nullable; - map RepairOrder.notes; - } -} - -dto DTO.RepairOrderFilter { - description "Фильтры для списка заявок на ремонт"; - - attribute number { - description "Частичное совпадение по номеру заявки"; - type string; - is nullable; - } - - attribute equipmentId { - description "Точное совпадение по идентификатору оборудования"; - type uuid; - is nullable; - } - - attribute repairKind { - description "Фильтр по виду ремонта"; - type RepairKind; - is nullable; - } - - attribute status { - description "Фильтр по статусу заявки"; - type RepairOrderStatus; - is nullable; - } - - attribute plannedAtFrom { - description "Плановая дата начала ОТ"; - type date; - is nullable; - } - - attribute plannedAtTo { - description "Плановая дата начала ДО"; - type date; - is nullable; - } - - attribute contractor { - description "Частичное совпадение по подрядчику"; - type string; - is nullable; - } -} - -dto DTO.RepairOrderCreate { - description "Тело запроса на создание заявки на ремонт"; - - attribute number { - description "Номер заявки"; - type string; - is required; - } - - attribute equipmentId { - description "Идентификатор оборудования"; - type uuid; - is required; - } - - attribute repairKind { - description "Вид ремонта"; - type RepairKind; - is required; - } - - attribute status { - description "Статус заявки"; - type RepairOrderStatus; - is nullable; - } - - attribute plannedAt { - description "Плановая дата начала"; - type date; - is required; - } - - attribute startedAt { - description "Фактическая дата начала"; - type date; - is nullable; - } - - attribute completedAt { - description "Фактическая дата завершения"; - type date; - is nullable; - } - - attribute contractor { - description "Подрядная организация"; - type string; - is nullable; - } - - attribute engineHoursAtRepair { - description "Наработка на момент ремонта, моточасов"; - type decimal; - is nullable; - } - - attribute description { - description "Описание работ / дефекта"; - type text; - is nullable; - } - - attribute notes { - description "Примечания"; - type text; - is nullable; - } -} - -dto DTO.RepairOrderUpdate { - description "Тело запроса на обновление заявки на ремонт"; - - attribute number { - description "Номер заявки"; - type string; - is nullable; - } - - attribute equipmentId { - description "Идентификатор оборудования"; - type uuid; - is nullable; - } - - attribute repairKind { - description "Вид ремонта"; - type RepairKind; - is nullable; - } - - attribute status { - description "Статус заявки"; - type RepairOrderStatus; - is nullable; - } - - attribute plannedAt { - description "Плановая дата начала"; - type date; - is nullable; - } - - attribute startedAt { - description "Фактическая дата начала"; - type date; - is nullable; - } - - attribute completedAt { - description "Фактическая дата завершения"; - type date; - is nullable; - } - - attribute contractor { - description "Подрядная организация"; - type string; - is nullable; - } - - attribute engineHoursAtRepair { - description "Наработка на момент ремонта, моточасов"; - type decimal; - is nullable; - } - - attribute description { - description "Описание работ / дефекта"; - type text; - is nullable; - } - - attribute notes { - description "Примечания"; - type text; - is nullable; - } -} diff --git a/frontend/architecture.md b/frontend/architecture.md index 7a43a47..f362061 100644 --- a/frontend/architecture.md +++ b/frontend/architecture.md @@ -9,7 +9,7 @@ Frontend stack: - shadcn/ui - Keycloak JS -The frontend is generated from the DSL and API specification. +The frontend is generated from `domain/*.dsl`. Each entity becomes a React Admin resource. @@ -17,6 +17,15 @@ The generated frontend must also include Keycloak authentication by default. --- +# Single Source of Truth + +- `domain/*.dsl` is the only required input for frontend generation. +- React Admin resources, fields, references, and routes must be derived from the domain model, primary keys, foreign keys, and enums defined in the domain DSL. +- Frontend documentation, generation rules, and optional overrides must not duplicate entity, attribute, or relation structures outside the domain DSL. +- Deprecated multi-DSL inputs are compatibility-only artifacts and must never be treated as authoritative frontend inputs or used to redefine entities, attributes, primary keys, foreign keys, relations, or enums. + +--- + # Project Structure client/ @@ -55,6 +64,7 @@ The generated `App.tsx` must register: - `authProvider` The generated `Admin` root must enforce authenticated operation. The generated frontend must not operate anonymously once auth is enabled. +The generated `authProvider.getIdentity()` must resolve identity from token claims already present in the parsed token and must not trigger a baseline Keycloak `/account` request. Example: @@ -108,6 +118,7 @@ Rules: 2. Use Authorization Code + PKCE (`S256`). 3. Do not generate a custom in-app username/password login form. 4. Do not render the authenticated admin app before Keycloak initialization completes. +5. Do not introduce `keycloak.loadUserProfile()` or `/account` profile-fetch requests as part of baseline app startup or identity resolution. --- diff --git a/frontend/react-admin-rules.md b/frontend/react-admin-rules.md index b67f185..26b196a 100644 --- a/frontend/react-admin-rules.md +++ b/frontend/react-admin-rules.md @@ -40,6 +40,20 @@ The generator must not treat `401` and `403` as the same outcome. --- +# Identity Resolution + +The generated `authProvider.getIdentity()` must use token claims already present in the parsed token. + +Rules: + +1. Prefer `sub`, `preferred_username`, `email`, and `name`. +2. Do not call `keycloak.loadUserProfile()` by default. +3. Do not rely on the Keycloak `/account` endpoint for baseline CRUD/admin generation. + +The default generator strategy is to avoid the `/account` request entirely rather than solving it through broader Keycloak CORS settings. + +--- + # Token Handling The generated frontend must use Keycloak JS token handling with these rules: @@ -117,6 +131,7 @@ React Admin requires every record in list and detail responses to contain a fiel 1. Every record returned by the API must contain an **`id`** field. 2. If the DSL primary key is not named `id`, the generator must **map** the primary key value to an `id` field in the API response (backend) or in a frontend adapter. 3. The `id` field must contain the **value of the primary key** (e.g. uuid string, or `code` value for EquipmentType). +4. If the primary key is not literally `id`, backend list/query logic must map React Admin `_sort=id` to the real primary key field before constructing ORM sorting. **Example:** @@ -144,4 +159,6 @@ API response must include `id` so React Admin can identify the record: If the response only had `{ "code": "pump", "name": "Pump" }`, React Admin would not work correctly because it expects `id`. The backend or frontend adapter must therefore set `id: record.code` (or equivalent) when the primary key is not `id`. +If React Admin later sends `_sort=id`, the generated backend must map that synthetic `id` sort back to the real primary key field (for example `code`) before building the Prisma `orderBy` clause. + This rule ensures compatibility with React Admin resource identity handling. diff --git a/general-prompt.md b/general-prompt.md index 2ad0bfe..f552aec 100644 --- a/general-prompt.md +++ b/general-prompt.md @@ -28,7 +28,12 @@ You must read the project documentation in the following strict order: domain/dsl-spec.md -examples/*.dsl +domain/*.dsl + +If present, read optional overrides after the domain DSL: + +overrides/api-overrides.dsl +overrides/ui-overrides.dsl backend/architecture.md @@ -68,9 +73,29 @@ generation/post-generation-validation.md Do not ignore any rules defined in these documents. +INPUT CONTRACT + +Required DSL input: + +domain/*.dsl + +Optional override inputs: + +overrides/api-overrides.dsl +overrides/ui-overrides.dsl + +Rules: + +- Domain DSL is the single source of truth for entities, attributes, primary keys, foreign keys, and enums. +- DTO, API, and UI must be derived from the domain DSL. +- Optional overrides must not duplicate or redefine the domain model. +- Generation must work without override files. +- Ignore deprecated multi-DSL inputs if they are present in the repository; they are not authoritative generation inputs. +- Do not require standalone DTO, API, or UI DSL inputs. + GOAL -Generate a DSL-driven fullstack CRUD system with default Keycloak authentication and authorization. +Generate a domain-DSL-driven fullstack CRUD system with default Keycloak authentication and authorization. Repository-specific defaults and examples may use names such as `toir`, `toir-frontend`, `toir-backend`, `toir-realm.json`, and `*.greact.ru`, but the generator must parameterize realm name, client IDs, production URLs, and realm-artifact filename for other generated projects. @@ -105,6 +130,7 @@ Keycloak JS PROJECT STRUCTURE Root +.gitignore docker-compose.yml root-level Keycloak realm import artifact (default example filename: `toir-realm.json`) server/ @@ -118,11 +144,13 @@ config/ modules/{entity}/ prisma/schema.prisma prisma/seed.ts +.gitignore .env .env.example Frontend client/ +.gitignore src/ auth/ config/ @@ -132,9 +160,9 @@ main.tsx dataProvider.ts .env.example -STEP 1 — Parse DSL +STEP 1 — Parse Domain DSL -Parse all DSL files and extract: +Parse domain/*.dsl and extract: Entities Attributes @@ -142,6 +170,9 @@ Primary keys Foreign keys Enums +If present, read optional override files only after the domain model has been parsed. Overrides may refine derived API or UI behavior but must never redefine entities, attributes, primary keys, foreign keys, or enums. +Do not consult any supplemental DTO/API/UI DSL source when deriving backend or frontend artifacts. + Respect the DSL specification. STEP 2 — CLI scaffolding @@ -194,7 +225,7 @@ DTO mapping decimal → string date → ISO string -STEP 5 — Generate NestJS CRUD modules +STEP 5 — Generate NestJS CRUD modules and derived DTOs Per entity generate: @@ -311,6 +342,10 @@ Rules: - Use Authorization Code + PKCE (`S256`) - Initialize Keycloak before rendering the SPA - Attach `Authorization: Bearer ` through the shared request seam in `client/src/dataProvider.ts` +- `authProvider.getIdentity()` must derive identity from parsed token claims such as `sub`, `preferred_username`, `email`, and `name` +- Do not call `keycloak.loadUserProfile()` by default +- Do not rely on the Keycloak `/account` endpoint for baseline CRUD/admin generation +- Avoid the `/account` request entirely by default rather than broadening Keycloak CORS behavior - `401` must force re-authentication - `403` must surface access denied without forcing re-authentication - Token refresh must be concurrency-safe @@ -324,6 +359,9 @@ Create: server/.env server/.env.example client/.env.example +root/.gitignore +server/.gitignore +client/.gitignore root-level Keycloak realm import artifact (default example filename: `toir-realm.json`) Backend env examples must include: @@ -346,6 +384,17 @@ Add to package.json: postinstall: prisma generate +Generated `.gitignore` files must prevent local-only artifacts from entering git, including: + +node_modules +dist +dist-ssr +coverage +*.tsbuildinfo +.env +.env.local +.env.*.local + STEP 10 — Database runtime Generate root: @@ -375,6 +424,8 @@ prisma.seed STEP 12 — Generate React Admin resources +Generate React Admin resources automatically from the domain DSL. + For each entity generate: Field mapping @@ -389,6 +440,8 @@ API responses MUST contain: If PK ≠ id, map primary key to id. +If PK ≠ id, backend list/query logic must map React Admin `_sort=id` to the real primary key field before constructing ORM sorting. + Example { @@ -410,9 +463,14 @@ update services sanitize payload before Prisma frontend auth files exist backend auth files exist auth env examples exist +root/server/client .gitignore files exist +gitignore rules exclude local dependency, build, env, coverage, and tsbuildinfo artifacts +frontend auth code does not call `keycloak.loadUserProfile()` +frontend `getIdentity()` is token-claim based and does not rely on `/account` public /health is preserved unauthenticated protected route returns 401 insufficient role returns 403 +natural-key entities map React Admin `_sort=id` to the real primary key field generated realm import artifact is self-contained and guarantees `sub`, `aud`, and `realm_access.roles` OUTPUT diff --git a/generation/backend-generation.md b/generation/backend-generation.md index 6c69c79..3eb0fde 100644 --- a/generation/backend-generation.md +++ b/generation/backend-generation.md @@ -35,15 +35,47 @@ Follow: --- -# Step 1 — Parse DSL +# Input Contract -Read DSL inputs and extract: +Required input: + +- `domain/*.dsl` + +Optional extension input: + +- `overrides/api-overrides.dsl` + +Optional extension layout: + +```text +overrides/ + api-overrides.dsl +``` + +Rules: + +- Parse `domain/*.dsl` as the only authoritative DSL input. +- Generate DTOs and REST API contracts automatically from the parsed domain model. +- The generator must work when `overrides/api-overrides.dsl` is absent. +- Optional overrides may refine derived API behavior but must not redefine entities, attributes, primary keys, foreign keys, relations, or enums. +- Supplemental DTO/API DSL inputs must not participate in backend parsing, dependency resolution, or backend generation decisions. +- Do not read standalone DTO or API DSL files. + +--- + +# Step 1 — Parse Domain DSL + +Read `domain/*.dsl` and extract: - entities - attributes, including the actual primary key attribute per entity - enums - foreign keys +All entities, attributes, primary keys, foreign keys, relations, and enums used by the backend pipeline must come from the parsed domain DSL. + +If `overrides/api-overrides.dsl` exists, process it only after the domain model has been parsed and only as optional non-authoritative metadata. + The generator must treat auth as default runtime infrastructure, not as a DSL feature toggle. --- @@ -89,6 +121,12 @@ Generate backend source artifacts: - sanitize update payload before Prisma - remove `id`, the entity primary key, and readonly attributes from `data` - do not pass the raw request body directly to `prisma.*.update()` +6. **List/query sorting methods**: + - use only actual model field names in ORM `orderBy` + - if the API exposes synthetic `id` for React Admin but the real primary key is different, map incoming `_sort=id` to the real primary key field before building `orderBy` + - apply this rule to every entity with a non-`id` primary key + +All backend DTOs and REST endpoints are derived artifacts. The generator must not require or parse separate DTO/API DSL documents. Use mapping rules from `backend/prisma-rules.md`: @@ -223,3 +261,4 @@ Run runtime, auth, and contract checks from `generation/post-generation-validati - protected routes reject unauthenticated requests with `401` - authenticated users with insufficient role receive `403` - React Admin-compatible API responses include `id` for every record +- natural-key entities translate React Admin `_sort=id` to the real primary key field diff --git a/generation/dev-workflow.md b/generation/dev-workflow.md index b553ba6..496f829 100644 --- a/generation/dev-workflow.md +++ b/generation/dev-workflow.md @@ -2,6 +2,8 @@ This document describes the **developer workflow** for running a generated fullstack application locally. The generator must produce a project that supports this workflow so the app is **fully runnable** after generation, including authentication. +This workflow assumes the project was generated from `domain/*.dsl` plus optional non-duplicating overrides only. Developers must not need to prepare separate DTO/API/UI DSL inputs before running the app. + --- # Prerequisites diff --git a/generation/frontend-generation.md b/generation/frontend-generation.md index 5b3641c..f59e1b8 100644 --- a/generation/frontend-generation.md +++ b/generation/frontend-generation.md @@ -12,7 +12,35 @@ Follow: --- -# Step 1 — Parse DSL +# Input Contract + +Required input: + +- `domain/*.dsl` + +Optional extension input: + +- `overrides/ui-overrides.dsl` + +Optional extension layout: + +```text +overrides/ + ui-overrides.dsl +``` + +Rules: + +- Parse `domain/*.dsl` as the only authoritative DSL input. +- Generate React Admin resources, views, and field mappings automatically from the parsed domain model. +- The generator must work when `overrides/ui-overrides.dsl` is absent. +- Optional overrides may refine derived UI behavior but must not redefine entities, attributes, primary keys, foreign keys, relations, or enums. +- Supplemental UI DSL inputs must not participate in frontend parsing, dependency resolution, or frontend generation decisions. +- Do not read a standalone UI DSL file. + +--- + +# Step 1 — Parse Domain DSL Extract: @@ -20,6 +48,10 @@ Extract: - attributes - relation fields required for reference inputs and reference displays +All frontend resources, fields, references, routes, and type-driven widget choices must be derived from the parsed domain DSL before optional overrides are considered. + +If `overrides/ui-overrides.dsl` exists, process it only after the domain model has been parsed and only as optional non-authoritative metadata. + The generator must treat auth as default frontend infrastructure rather than as an optional feature. --- @@ -54,7 +86,7 @@ For each entity create: - `EntityEdit.tsx` - `EntityShow.tsx` -Resource generation must remain compatible with the auth-aware shared request seam. +Resource generation must remain compatible with the auth-aware shared request seam and must be derived from domain metadata rather than a separate UI DSL. --- @@ -62,6 +94,14 @@ Resource generation must remain compatible with the auth-aware shared request se Map DSL attributes to React Admin components according to existing field rules and relation semantics. +Minimum type mapping: + +- `string`, `text` -> `TextInput`, `TextField` +- `integer`, `decimal` -> `NumberInput`, `NumberField` +- `date` -> `DateInput`, `DateField` +- `enum` -> `SelectInput` +- foreign key -> `ReferenceInput`, `ReferenceField` + Reference/resource lookups must continue to flow through the same shared authenticated request layer used by the main CRUD resources. --- @@ -98,6 +138,11 @@ Generate Keycloak frontend integration with these required rules: - Authorization Code + PKCE with `S256` - initialize Keycloak before React render - provide a React Admin `authProvider` +- derive `authProvider.getIdentity()` from token claims already present in the parsed token +- prefer `sub`, `preferred_username`, `email`, and `name` for identity resolution +- do not call `keycloak.loadUserProfile()` by default +- do not rely on the Keycloak `/account` endpoint for baseline CRUD/admin generation +- avoid the `/account` request entirely by default rather than broadening Keycloak CORS behavior - distinguish auth failures from authorization failures: - `401` -> force logout / re-authentication - `403` -> do not re-authenticate; surface access denied / permission error diff --git a/generation/post-generation-validation.md b/generation/post-generation-validation.md index 47f0b8d..252079a 100644 --- a/generation/post-generation-validation.md +++ b/generation/post-generation-validation.md @@ -6,6 +6,17 @@ After generating the backend or fullstack application, run these checks to ensur # Validation Checklist +## Source-of-truth input contract + +- [ ] Fullstack generation succeeds when `domain/*.dsl` is the only required DSL input. +- [ ] The generator can produce backend and frontend outputs from `domain/*.dsl` alone before optional overrides are considered. +- [ ] DTO, API, and UI artifacts are derived automatically from the domain model, keys, relations, and enums. +- [ ] Optional override files are not required for a successful generation run. +- [ ] Optional overrides, if present, refine only derived API/UI output and do not duplicate the domain model. +- [ ] No generator step depends on duplicated domain structures outside `domain/*.dsl`. + +**Failure symptoms:** generation requires extra DSL inputs, generated layers drift from the domain model, or the pipeline fails when override files are absent. + ## 1. Frontend and backend env files - [ ] `server/.env.example` exists and documents: @@ -27,7 +38,22 @@ After generating the backend or fullstack application, run these checks to ensur --- -## 2. Keycloak realm artifact +## 2. Git ignore hygiene + +- [ ] Root `.gitignore` exists. +- [ ] `server/.gitignore` exists. +- [ ] `client/.gitignore` exists. +- [ ] Generated gitignore rules exclude local dependency directories such as `node_modules/`. +- [ ] Generated gitignore rules exclude build artifacts such as `dist/` and `dist-ssr/`. +- [ ] Generated gitignore rules exclude local env files such as `.env`, `.env.local`, and `.env.*.local`. +- [ ] Generated gitignore rules exclude `coverage/` and `*.tsbuildinfo`. +- [ ] Generated gitignore rules do **not** exclude committed project artifacts such as source files, docs, and `.env.example`. + +**Failure symptoms:** `npm install`, local builds, or local env setup explode git status with thousands of files that should remain untracked. + +--- + +## 3. Keycloak realm artifact - [ ] A root-level generated Keycloak realm import artifact exists. - [ ] If the repository default filename `toir-realm.json` is not used, the project-specific equivalent is documented consistently across bootstrap and workflow docs. @@ -50,7 +76,7 @@ After generating the backend or fullstack application, run these checks to ensur --- -## 3. Frontend auth files and behavior +## 4. Frontend auth files and behavior - [ ] Generated frontend includes: - `client/src/config/env.ts` @@ -65,6 +91,9 @@ After generating the backend or fullstack application, run these checks to ensur - [ ] No custom in-app username/password login form is generated. - [ ] `Authorization Code + PKCE (S256)` is encoded in the frontend auth flow. - [ ] `client/src/dataProvider.ts` or the documented shared request seam injects `Authorization: Bearer ` into all API requests. +- [ ] `authProvider.getIdentity()` derives identity from parsed token claims such as `sub`, `preferred_username`, `email`, and `name`. +- [ ] Generated frontend auth code does not call `keycloak.loadUserProfile()`. +- [ ] Generated frontend auth code does not rely on the Keycloak `/account` endpoint for baseline CRUD/admin generation. - [ ] Token refresh is concurrency-safe: - one shared in-flight refresh operation - no parallel refresh stampede @@ -77,7 +106,7 @@ After generating the backend or fullstack application, run these checks to ensur --- -## 4. Backend auth files and behavior +## 5. Backend auth files and behavior - [ ] Generated backend includes: - `server/src/auth/auth.module.ts` @@ -100,7 +129,7 @@ After generating the backend or fullstack application, run these checks to ensur --- -## 5. CRUD protection and RBAC defaults +## 6. CRUD protection and RBAC defaults - [ ] `/health` is public. - [ ] Each generated CRUD controller method other than explicit public routes is protected by the generated auth/RBAC infrastructure. @@ -115,7 +144,7 @@ After generating the backend or fullstack application, run these checks to ensur --- -## 6. PrismaService implementation +## 7. PrismaService implementation - [ ] A `PrismaService` (or equivalent) class exists and extends `PrismaClient`. - [ ] It implements `OnModuleInit` and calls `await this.$connect()` in `onModuleInit()`. @@ -127,7 +156,7 @@ After generating the backend or fullstack application, run these checks to ensur --- -## 7. Prisma client lifecycle +## 8. Prisma client lifecycle - [ ] `package.json` includes a script that runs Prisma client generation: - either `"postinstall": "prisma generate"` (or `npx prisma generate`) @@ -138,7 +167,7 @@ After generating the backend or fullstack application, run these checks to ensur --- -## 8. Database migration +## 9. Database migration - [ ] Migration workflow is documented. - [ ] Instruction to run `npx prisma migrate dev` exists after first generation or schema change. @@ -148,7 +177,7 @@ After generating the backend or fullstack application, run these checks to ensur --- -## 9. REST route parameters +## 10. REST route parameters - [ ] For each entity, path parameters use the correct primary key name from the DSL. - [ ] Entity with PK `id` uses `/:id`. @@ -160,18 +189,20 @@ After generating the backend or fullstack application, run these checks to ensur --- -## 10. DTO type mapping and React Admin ID compatibility +## 11. DTO type mapping and React Admin ID compatibility - [ ] DSL `decimal` maps to DTO/API `string`. - [ ] DSL `date` maps to DTO/API `string` (ISO) or equivalent string serialization. - [ ] Every API response object contains a field named `id`. - [ ] If the entity primary key is not named `id`, the response maps the primary key to `id`. +- [ ] For entities with non-`id` primary keys, backend list/query logic translates React Admin `_sort=id` to the real primary key field. +- [ ] Generated ORM `orderBy` clauses never reference synthetic `id` when the underlying model field does not exist. **Failure symptoms:** serialization issues for decimals/dates, or React Admin cannot identify records. --- -## 11. Update payload sanitization +## 12. Update payload sanitization - [ ] Update endpoints do not pass `id` or the primary key in Prisma `data`. - [ ] Generated update methods remove `id`, the entity primary key, and readonly attributes before calling `prisma.*.update()`. @@ -180,7 +211,7 @@ After generating the backend or fullstack application, run these checks to ensur --- -## 12. Database runtime +## 13. Database runtime - [ ] `docker-compose.yml` exists at the project root. - [ ] It defines a PostgreSQL service with image `postgres:16`, port `5432`, and credentials matching `DATABASE_URL`. @@ -191,7 +222,7 @@ After generating the backend or fullstack application, run these checks to ensur --- -## 13. Migrations, seed, and health endpoint +## 14. Migrations, seed, and health endpoint - [ ] `npx prisma migrate dev` runs successfully from `server/`. - [ ] Seed script exists at `server/prisma/seed.ts` (or equivalent). @@ -209,9 +240,11 @@ After generating the backend or fullstack application, run these checks to ensur | --- | --- | | Frontend env | `client/.env.example` with required Vite auth vars | | Backend env | `server/.env.example` with DB, CORS, and Keycloak vars | +| Git ignore | Root/server/client `.gitignore` exclude local-only artifacts | | Fail-fast config | Startup fails when required auth env is missing | | Realm artifact | Root generated realm import artifact with self-contained auth setup | | Frontend auth | `keycloak.ts`, `authProvider.ts`, authenticated `dataProvider.ts` | +| Frontend identity | Token-claim based `getIdentity()`; no `loadUserProfile()` / `/account` dependency | | Backend auth | `AuthModule`, guards, decorators, typed principal | | JWKS strategy | explicit URL -> discovery -> certs fallback | | Role source | `realm_access.roles` only | @@ -224,6 +257,7 @@ After generating the backend or fullstack application, run these checks to ensur | Prisma lifecycle | `OnModuleInit` + `$connect()`, no `beforeExit` | | Update sanitization | Strip `id` / PK / readonly before Prisma update | | React Admin `id` | Every record includes `id` | +| Natural-key sorting | Map React Admin `_sort=id` to the real primary key field | | Database runtime | PostgreSQL compose exists and starts | --- @@ -231,6 +265,7 @@ After generating the backend or fullstack application, run these checks to ensur # Integration with generation pipeline 1. Backend and frontend generation must produce artifacts that satisfy the above by default. -2. Runtime bootstrap must include Keycloak realm import/verification before app startup. -3. After generation, run this checklist manually or via an automated script. -4. If any check fails, update the generator context so future runs pass without manual repair. +2. The generator must successfully build the fullstack app from `domain/*.dsl` alone; optional overrides may refine output but cannot be required. +3. Runtime bootstrap must include Keycloak realm import/verification before app startup. +4. After generation, run this checklist manually or via an automated script. +5. If any check fails, update the generator context so future runs pass without manual repair. diff --git a/generation/runtime-bootstrap.md b/generation/runtime-bootstrap.md index cf34c18..970ba8b 100644 --- a/generation/runtime-bootstrap.md +++ b/generation/runtime-bootstrap.md @@ -9,6 +9,8 @@ The generator must produce a **runnable development environment** consisting of: - frontend SPA - PostgreSQL database +Runtime bootstrap assumes the application was generated from `domain/*.dsl` plus optional non-duplicating overrides only. There is no separate DTO/API/UI DSL bootstrap step. + The generator must also produce the runtime artifacts required to bootstrap auth from zero, including a root-level Keycloak realm import artifact. The repository default example filename is `toir-realm.json`, but future generations must allow a project-specific equivalent. --- diff --git a/generation/scaffolding-rules.md b/generation/scaffolding-rules.md index fc27f90..a2fd4b7 100644 --- a/generation/scaffolding-rules.md +++ b/generation/scaffolding-rules.md @@ -80,13 +80,35 @@ npm install keycloak-js Generation pipeline order: -1. **Parse DSL** — Read domain, DTO, API, and UI DSL files. +1. **Parse DSL** — Read `domain/*.dsl` as the single required input. If present, optional override files under `overrides/` may be applied after domain parsing, but DTO/API/UI DSL files must not be required. 2. **Run CLI scaffolding** — Create `server` with NestJS CLI and `client` with Vite CLI; install runtime and auth dependencies listed above. 3. **Code generation** — Generate Prisma schema, NestJS modules/DTOs/PrismaService/auth infrastructure, and React Admin resources/auth integration. -4. **Runtime infrastructure** — Generate backend/frontend `.env.example`, runtime config files, lifecycle scripts, and a root-level Keycloak realm import artifact (repository default example filename: `toir-realm.json`). +4. **Runtime infrastructure** — Generate backend/frontend `.env.example`, root/package `.gitignore` files, runtime config files, lifecycle scripts, and a root-level Keycloak realm import artifact (repository default example filename: `toir-realm.json`). 5. **Database runtime** — Generate `docker-compose.yml` in project root with PostgreSQL service (`postgres`, image `postgres:16`, port `5432:5432`). 6. **Migration** — Apply schema with `npx prisma migrate dev`. 7. **Seed** — Populate minimal development data with `npx prisma db seed`. 8. **Validation** — Run checks from `generation/post-generation-validation.md`, including auth validation and realm-template validation. -Scaffolding (steps 1–2) must be done with the CLI. Steps 3–8 must be generated from the DSL and the project context documents, including the auth-specific context in `auth/*.md`. +Scaffolding (steps 1–2) must be done with the CLI. Steps 3–8 must be generated from `domain/*.dsl`, optional non-duplicating overrides in `overrides/`, and the project context documents, including the auth-specific context in `auth/*.md`. +There is no separate DTO/API/UI DSL preparation step in the scaffolding workflow. + +## Git ignore rules + +The generated project must include: + +- root `.gitignore` +- `server/.gitignore` +- `client/.gitignore` + +These files must keep local-only artifacts out of git, including at minimum: + +- `node_modules/` +- `dist/` +- `dist-ssr/` +- `coverage/` +- `*.tsbuildinfo` +- `.env` +- `.env.local` +- `.env.*.local` + +The generator must not ignore committed source, documentation, or `.env.example` files. diff --git a/generation/update-strategy.md b/generation/update-strategy.md index cf5cc10..fb643a4 100644 --- a/generation/update-strategy.md +++ b/generation/update-strategy.md @@ -2,6 +2,9 @@ When the DSL changes, regeneration must preserve the default auth-enabled runtime rather than falling back to CRUD-only output. +`domain/*.dsl` remains the single required source of truth for regeneration. DTOs, API contracts, and React Admin resources must be re-derived from it on every run. Optional overrides in `overrides/api-overrides.dsl` and `overrides/ui-overrides.dsl` may be applied after derivation, but they must never duplicate or redefine the domain model. +Regeneration must not resurrect or depend on supplemental DTO/API/UI DSL inputs. Every derived layer must be recalculated from `domain/*.dsl` plus optional non-duplicating overrides only. + ## Required regeneration sequence 1. Regenerate `prisma/schema.prisma`. @@ -23,13 +26,17 @@ When the DSL changes, regeneration must preserve the default auth-enabled runtim - `App.tsx` auth wiring - `main.tsx` init-before-render flow 7. Regenerate backend and frontend `.env.example` files so the auth env contract stays in sync. -8. Regenerate the root-level Keycloak realm import artifact. The repository default example filename is `toir-realm.json`, but the generator must allow a project-specific equivalent. -9. Re-run post-generation validation, including: +8. Regenerate root/package `.gitignore` files so local-only artifacts remain out of git after regeneration. +9. Regenerate the root-level Keycloak realm import artifact. The repository default example filename is `toir-realm.json`, but the generator must allow a project-specific equivalent. +10. Re-run post-generation validation, including: + - gitignore coverage for dependency, build, env, coverage, and tsbuildinfo artifacts - auth dependency checks - fail-fast env checks + - token-claim based identity with no `loadUserProfile()` / `/account` dependency - `/health` public check - unauthenticated protected route -> `401` - insufficient role -> `403` + - natural-key `_sort=id` mapping checks - realm-template validation ## Guardrails From d1ea297dfc17afea27a9ea7e269341764cb6e9a9 Mon Sep 17 00:00:00 2001 From: MaKarin Date: Sun, 22 Mar 2026 18:34:22 +0300 Subject: [PATCH 3/3] chore: harden generation context baseline --- README.md | 65 ++- auth/backend-auth-rules.md | 120 ------ auth/frontend-auth-rules.md | 156 ------- auth/keycloak-architecture.md | 89 ---- auth/keycloak-realm-template-rules.md | 152 ------- backend/architecture.md | 318 -------------- backend/database-runtime.md | 70 ---- backend/prisma-rules.md | 166 -------- backend/prisma-service.md | 80 ---- backend/runtime-rules.md | 152 ------- backend/seed-rules.md | 82 ---- backend/service-rules.md | 97 ----- docker-compose.yml | 6 +- docs/repository-structure.md | 35 ++ domain-summary.json | 324 ++++++++++++++ {examples => domain}/TOiR.domain.dsl | 34 +- domain/dsl-spec.md | 2 + frontend/architecture.md | 192 --------- frontend/react-admin-rules.md | 164 -------- general-prompt.md | 508 ---------------------- generation/backend-generation.md | 264 ------------ generation/dev-workflow.md | 137 ------ generation/frontend-generation.md | 183 -------- generation/post-generation-validation.md | 271 ------------ generation/runtime-bootstrap.md | 122 ------ generation/scaffolding-rules.md | 114 ----- generation/update-strategy.md | 47 --- overrides/api-overrides.dsl | 2 + overrides/ui-overrides.dsl | 2 + package.json | 10 + prompts/auth-rules.md | 79 ++++ prompts/backend-rules.md | 74 ++++ prompts/frontend-rules.md | 56 +++ prompts/general-prompt.md | 146 +++++++ prompts/runtime-rules.md | 96 +++++ prompts/validation-rules.md | 85 ++++ toir-realm.json | 172 ++++++++ tools/dsl-summary.mjs | 357 ++++++++++++++++ tools/generate-domain-summary.mjs | 13 + tools/validate-generation.mjs | 513 +++++++++++++++++++++++ 40 files changed, 2038 insertions(+), 3517 deletions(-) delete mode 100644 auth/backend-auth-rules.md delete mode 100644 auth/frontend-auth-rules.md delete mode 100644 auth/keycloak-architecture.md delete mode 100644 auth/keycloak-realm-template-rules.md delete mode 100644 backend/architecture.md delete mode 100644 backend/database-runtime.md delete mode 100644 backend/prisma-rules.md delete mode 100644 backend/prisma-service.md delete mode 100644 backend/runtime-rules.md delete mode 100644 backend/seed-rules.md delete mode 100644 backend/service-rules.md create mode 100644 docs/repository-structure.md create mode 100644 domain-summary.json rename {examples => domain}/TOiR.domain.dsl (74%) delete mode 100644 frontend/architecture.md delete mode 100644 frontend/react-admin-rules.md delete mode 100644 general-prompt.md delete mode 100644 generation/backend-generation.md delete mode 100644 generation/dev-workflow.md delete mode 100644 generation/frontend-generation.md delete mode 100644 generation/post-generation-validation.md delete mode 100644 generation/runtime-bootstrap.md delete mode 100644 generation/scaffolding-rules.md delete mode 100644 generation/update-strategy.md create mode 100644 overrides/api-overrides.dsl create mode 100644 overrides/ui-overrides.dsl create mode 100644 package.json create mode 100644 prompts/auth-rules.md create mode 100644 prompts/backend-rules.md create mode 100644 prompts/frontend-rules.md create mode 100644 prompts/general-prompt.md create mode 100644 prompts/runtime-rules.md create mode 100644 prompts/validation-rules.md create mode 100644 toir-realm.json create mode 100644 tools/dsl-summary.mjs create mode 100644 tools/generate-domain-summary.mjs create mode 100644 tools/validate-generation.mjs diff --git a/README.md b/README.md index b50eeaa..4a99e51 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,64 @@ -This directory defines the AI generation context. +This repository keeps the current LLM-first CRUD generation architecture as the primary working baseline. -All code generation must follow the rules described in these documents. \ No newline at end of file +It is not a new generator engine and it is not a compiler platform. The repository remains: + +- an AI generation context +- an active generated and maintained fullstack CRUD project +- `server/` as the active backend target output path +- `client/` as the active frontend target output path +- an LLM-first orchestration baseline with CLI-first framework bootstrap +- a compact rule set that strengthens the existing pipeline with: + - `domain-summary.json` + - a physical root-level realm artifact + - a lightweight automated validation gate + +## Active knowledge blocks + +The active prompt corpus is intentionally normalized to six stable blocks: + +1. [prompts/general-prompt.md](prompts/general-prompt.md) +2. [prompts/auth-rules.md](prompts/auth-rules.md) +3. [prompts/backend-rules.md](prompts/backend-rules.md) +4. [prompts/frontend-rules.md](prompts/frontend-rules.md) +5. [prompts/runtime-rules.md](prompts/runtime-rules.md) +6. [prompts/validation-rules.md](prompts/validation-rules.md) + +## Baseline contracts + +- `domain/*.dsl` is the source of truth for the domain model. +- [domain-summary.json](domain-summary.json) is a derived artifact for LLM stabilization and validation. +- [toir-realm.json](toir-realm.json) is the physical Keycloak bootstrap artifact baseline. +- `server/` and `client/` are the active target output paths for this repository. +- `server/` must remain a valid NestJS workspace baseline. +- `client/` must remain a valid Vite React TypeScript workspace baseline. + +## Scaffold baseline + +- Generation remains LLM-first for orchestration and domain-derived feature code. +- Framework bootstrap is CLI-first: + - backend baseline starts from official Nest CLI conventions + - frontend baseline starts from official Vite React TypeScript conventions +- If `server/` or `client/` drift away from a valid workspace, repair the workspace baseline before generating more feature code. +- Do not replace the framework workspace with a hand-written minimal skeleton. + +## Anti-regression contract + +- The active prompts define forbidden generation patterns, required invariants, and recovery rules for future agents. +- Buildability is part of the baseline contract, not an optional follow-up. +- Validation targets `domain/*.dsl` as reusable source inputs, while TOiR names remain project defaults/examples. + +## Repository layout + +- [docs/repository-structure.md](docs/repository-structure.md) explains the normalized folder structure. +- Active prompts live in `prompts/`. +- Helper scripts live in `tools/`. + +## Commands + +```bash +npm run generate:domain-summary +npm run validate:generation +npm run validate:generation:runtime +``` + +`npm run validate:generation` now checks both contract shape and workspace validity. When dependencies are installed, it also verifies `npm run build` in `server/` and `client/`. If dependencies are missing, it reports build verification as skipped instead of pretending the baseline is fully green. diff --git a/auth/backend-auth-rules.md b/auth/backend-auth-rules.md deleted file mode 100644 index 00d34e9..0000000 --- a/auth/backend-auth-rules.md +++ /dev/null @@ -1,120 +0,0 @@ -# Backend Auth Rules - -This document defines mandatory backend authentication and authorization behavior for generated applications. - ---- - -# Generated Backend Auth Surface - -The generator must create explicit auth infrastructure in the generated NestJS backend. - -At minimum generate: - -- `server/src/auth/auth.module.ts` -- JWT auth guard -- roles guard -- `@Public()` -- `@Roles()` -- typed authenticated principal interface -- typed environment validation in `server/src/config/` - -The generator must not describe backend auth as an external manual integration step. - ---- - -# Standards-Based JWT Verification - -The generated backend must validate JWTs against Keycloak using standards-based libraries. - -Rules: - -1. Verify **issuer** -2. Verify **audience** -3. Verify signature via **JWKS** -4. Do **not** use deprecated Keycloak-specific Node adapters such as `keycloak-connect` - -The default library rule for this repository is: - -- **`jose`** for JWT and JWKS verification - ---- - -# JWT Verification Contract - -The generated backend must verify tokens with: - -- `KEYCLOAK_ISSUER_URL` -- `KEYCLOAK_AUDIENCE` - -JWKS resolution priority must be exactly: - -1. explicit `KEYCLOAK_JWKS_URL` -2. OIDC discovery -3. fallback `${issuer}/protocol/openid-connect/certs` - -The generator must encode this priority explicitly. - ---- - -# Role Extraction - -Authorization roles must be extracted **only** from: - -- `realm_access.roles` - -The generator must not mix in: - -- resource roles -- custom frontend-only permissions -- undocumented claim fallbacks - -`realm_access.roles` is the single RBAC source for this repository. - ---- - -# Default RBAC Policy - -Apply these RBAC defaults to generated CRUD controllers: - -- `GET`: `viewer`, `editor`, `admin` -- `POST`: `editor`, `admin` -- `PATCH`: `editor`, `admin` -- `PUT`: `editor`, `admin` -- `DELETE`: `admin` - -`GET /health` must remain public and must use the generated `@Public()` mechanism. - -All other generated CRUD routes must be protected by default. - ---- - -# Typed Principal - -The generated backend must attach a typed authenticated principal to the request context. - -At minimum, the generated principal type must be able to represent: - -- `sub` -- user identity fields when present -- `roles` -- raw claims payload - -This principal type is required so guards, controllers, and tests share one consistent contract. - ---- - -# Backend Environment Contract - -The generated backend env contract must include: - -- `PORT` -- `DATABASE_URL` -- `CORS_ALLOWED_ORIGINS` -- `KEYCLOAK_ISSUER_URL` -- `KEYCLOAK_AUDIENCE` -- `KEYCLOAK_JWKS_URL` (optional) - -The generated backend config must **fail fast** if required auth variables are missing. - -The generated backend must not silently fall back to production auth settings in code. - diff --git a/auth/frontend-auth-rules.md b/auth/frontend-auth-rules.md deleted file mode 100644 index 4155e29..0000000 --- a/auth/frontend-auth-rules.md +++ /dev/null @@ -1,156 +0,0 @@ -# Frontend Auth Rules - -This document defines mandatory frontend authentication behavior for generated applications. - ---- - -# Generated Files - -The generator must create at minimum: - -- `client/src/config/env.ts` -- `client/src/auth/keycloak.ts` -- `client/src/auth/authProvider.ts` -- `client/src/App.tsx` -- `client/src/main.tsx` -- `client/src/dataProvider.ts` -- `client/.env.example` - -`App.tsx`, `main.tsx`, and `dataProvider.ts` must be generated in an auth-aware form. Auth must not be bolted on later. - ---- - -# Login Model - -The generated frontend must use: - -- **Keycloak JS** -- **Authorization Code Flow + PKCE** -- **PKCE method `S256`** -- **redirect-based login only** - -The generator must **not** create a custom in-app username/password login form. - -The generated SPA must initialize Keycloak **before rendering** the application. The app must not operate anonymously once auth is enabled. - ---- - -# React Admin Integration - -The generated frontend must use a React Admin **`authProvider`** and connect it at the `Admin` root. - -Rules: - -1. `authProvider` is mandatory. -2. The generated `Admin` root must enforce authenticated operation. -3. Auth bootstrap must happen before rendering `Admin`. - -The generated frontend must not rely on anonymous access with later lazy auth attachment. - ---- - -# Identity Resolution - -The generated `authProvider.getIdentity()` must derive identity from token claims already present in the parsed access token / parsed token. - -Preferred claims: - -- `sub` -- `preferred_username` -- `email` -- `name` - -Rules: - -1. `getIdentity()` must be token-claim based by default. -2. The generated frontend must **not** call `keycloak.loadUserProfile()` during normal app startup or baseline identity resolution. -3. The generated frontend must **not** depend on the Keycloak `/account` endpoint for baseline CRUD/admin generation. -4. The default generator strategy is to avoid the `/account` request entirely, not to broaden Keycloak CORS behavior. -5. Any network-based account-profile integration requires an explicit future prompt. - -The generator must not introduce startup/profile-fetch requests that are unnecessary for authorization. - ---- - -# Shared Request Seam - -The generated frontend must use the shared request seam in `client/src/dataProvider.ts` as the single place where access tokens are attached. - -Rules: - -1. All backend requests must carry `Authorization: Bearer `. -2. This must cover all React Admin calls, including: - - list - - get one - - get many - - get many reference - - create - - update - - delete -3. Reference/resource lookups must flow through the same authenticated request seam. - -The generator must not scatter token attachment across resource components. - ---- - -# Error Semantics - -The generated `authProvider.checkError` must distinguish authentication failures from authorization failures: - -- `401` -> force logout / re-authentication -- `403` -> do **not** re-authenticate; surface access denied / permission error to React Admin - -The generator must not treat `401` and `403` as equivalent. - ---- - -# Token Refresh - -The generated frontend must refresh tokens before protected API calls when needed. - -Refresh behavior must be **concurrency-safe**: - -- use one shared in-flight refresh operation -- parallel requests must wait for the same refresh promise -- do not trigger multiple parallel refresh requests for the same expiry window - -The generator must explicitly describe or implement the shared in-flight refresh pattern. - ---- - -# Browser Storage Rules - -The generated frontend must **not** store access tokens or refresh tokens in: - -- `localStorage` -- `sessionStorage` - -In-memory handling via Keycloak JS behavior is the default rule for this repository. - ---- - -# Frontend Environment Contract - -The generated frontend env contract must include: - -- `VITE_API_URL` -- `VITE_KEYCLOAK_URL` -- `VITE_KEYCLOAK_REALM` -- `VITE_KEYCLOAK_CLIENT_ID` - -The generated frontend config module must **fail fast** if required auth variables are missing. - -The generated frontend must not silently fall back to production auth settings in code. - ---- - -# Default Values for Examples - -`client/.env.example` must use these repository defaults as examples: - -```env -VITE_API_URL=http://localhost:3000 -VITE_KEYCLOAK_URL=https://sso.greact.ru -VITE_KEYCLOAK_REALM=toir -VITE_KEYCLOAK_CLIENT_ID=toir-frontend -``` diff --git a/auth/keycloak-architecture.md b/auth/keycloak-architecture.md deleted file mode 100644 index b7480bb..0000000 --- a/auth/keycloak-architecture.md +++ /dev/null @@ -1,89 +0,0 @@ -# Keycloak Architecture - -This repository generates a fullstack CRUD application with **default Keycloak authentication and authorization**. Authentication is not an optional add-on and must not be described as a manual post-generation task. - ---- - -# Default Auth Model - -The generated application must use this runtime topology: - -- **Frontend:** SPA served by Vite + React Admin -- **Backend:** NestJS API -- **Auth transport:** `Authorization: Bearer ` -- **Identity provider:** external Keycloak server - -The generated application must **not** use: - -- a BFF layer -- cookie/session authentication -- a custom in-app username/password login form - -The generated application must use **redirect-based Keycloak login** only. - ---- - -# Auth Is Part of Default Generation - -The generator must produce: - -- authenticated frontend bootstrap and request flow -- authenticated backend bootstrap and request guards -- auth-aware env examples -- auth-aware validation rules -- a root-level Keycloak realm import artifact that describes the Keycloak realm/client bootstrap - -Repository defaults may use names such as `toir`, `toir-frontend`, `toir-backend`, and `toir-realm.json`, but future generations must parameterize realm name, client IDs, production URLs, and artifact filename from the generated project or explicit auth configuration. - -The generated application must not require a separate prompt step such as "now add auth manually". - ---- - -# Public and Protected Routes - -The generated backend must keep: - -- `GET /health` — public - -All generated CRUD routes must be protected by default. - -Authorization must be role-based and must use **only** Keycloak realm roles from `realm_access.roles`. - ---- - -# Default Role Policy - -Apply these RBAC defaults to generated CRUD controllers: - -- `GET` list/detail: `viewer`, `editor`, `admin` -- `POST`, `PATCH`, `PUT`: `editor`, `admin` -- `DELETE`: `admin` - -The frontend may use roles for display and permission awareness, but the backend remains the enforcement point. - ---- - -# Token Contract - -Generated auth context must guarantee that access tokens used by the SPA and API reliably contain: - -- `sub` -- `aud` -- `realm_access.roles` - -The frontend client and backend audience/client must be documented explicitly. The generator must not rely on undocumented Keycloak defaults for these claims. - ---- - -# Runtime Boundaries - -The repository's local runtime topology remains unchanged: - -- `docker-compose.yml` provisions PostgreSQL only -- Keycloak remains external to this repo's Docker runtime - -The generator must therefore produce: - -- runtime documentation for importing/configuring the generated realm import artifact -- env contracts for frontend and backend -- validation rules that assume an external but reachable Keycloak server diff --git a/auth/keycloak-realm-template-rules.md b/auth/keycloak-realm-template-rules.md deleted file mode 100644 index 1acffb2..0000000 --- a/auth/keycloak-realm-template-rules.md +++ /dev/null @@ -1,152 +0,0 @@ -# Keycloak Realm Template Rules - -This document defines the required Keycloak realm/bootstrap artifact that the generator must produce. - ---- - -# Required Artifact - -The generator must create a root-level Keycloak import artifact: - -- project-specific realm import artifact -- repository default example filename: `toir-realm.json` - -This artifact is part of the normal generated result and must be documented in the runtime bootstrap flow. - -The generator must parameterize at minimum: - -- realm name -- frontend SPA client ID -- backend audience/resource client ID -- production frontend URLs -- realm-artifact filename - -Repository defaults may use `toir`, `toir-frontend`, `toir-backend`, `toir-realm.json`, and `https://toir-frontend.greact.ru` as examples, but those names must not be treated as universal requirements for all future generated applications. - ---- - -# Strategy Choice - -This repository uses the **robust bootstrap strategy** by default. - -That means the generated realm/client setup must be: - -- self-contained -- reproducible after import -- explicit about audience delivery -- explicit about role delivery -- explicit about required claims - -The generator must **not** rely on built-in client scopes magically existing and attaching correctly after realm import. - ---- - -# Required Realm Structure - -The generated realm import artifact must define: - -- realm name derived from generated project auth config -- realm roles: - - `admin` - - `editor` - - `viewer` -- frontend SPA client ID derived from generated project auth config -- backend audience/resource client ID derived from generated project auth config -- dedicated audience client scope for audience delivery - - repository default example: `api-audience` - -The audience client scope must explicitly add the generated backend audience/client ID to SPA access tokens. - ---- - -# Required Frontend Client Rules - -The generated SPA client must be configured as a public SPA client with: - -- `publicClient: true` -- `standardFlowEnabled: true` -- `implicitFlowEnabled: false` -- `directAccessGrantsEnabled: false` -- `serviceAccountsEnabled: false` -- `pkce.code.challenge.method: S256` - -Default rule: - -- keep `fullScopeAllowed: true` - -Reason: - -- this repository uses realm-role RBAC -- earlier failures came from fragile or implicit scope behavior -- the robust default is explicit audience delivery plus explicit claim mappers, not a partially documented scoped-role setup - -If a future prompt chooses `fullScopeAllowed: false`, it must also fully document role scope mappings and validation rules. The generator must not switch to that strategy silently. - ---- - -# Required Claim Delivery - -The generated realm/client configuration must reliably produce access tokens containing: - -- `sub` -- `aud` -- `realm_access.roles` - -The generator must explicitly address claim delivery and must not leave it implicit. - -The generated realm import artifact must therefore define explicit protocol mappers for: - -- `sub` -- user identity claims needed by the frontend -- `realm_access.roles` - -The generator must not assume these claims will appear through undeclared built-in scopes. - -Post-generation validation must fail if the generated realm contract leaves `sub`, `aud`, or `realm_access.roles` implicit instead of explicitly delivered. - ---- - -# Required Role Delivery - -The generated realm/client configuration must explicitly deliver realm roles into the access token. - -Rules: - -1. Role delivery must be configured intentionally. -2. `realm_access.roles` must be present in the access token after import. -3. The generator must validate that realm role delivery is part of the generated realm artifact. - ---- - -# Redirect URIs and Web Origins - -The generated realm template guidance must document local and production frontend URLs derived from the generated project configuration. - -Repository default production examples may include: - -- `http://localhost:5173` -- `https://toir-frontend.greact.ru` - -The generated SPA client must include matching redirect URIs and web origins for the generated local and production frontend URLs. These values must be explicit in the generated realm artifact or in a generated artifact template with explicit placeholders and validation instructions. - ---- - -# Anti-Regression Rule - -The earlier broken realm-template behavior must not be reproduced. - -The generator context must explicitly prevent: - -- missing audience in SPA access tokens -- missing `sub` -- missing `realm_access.roles` -- realm imports that depend on undeclared built-in scopes being present -- ambiguous role-delivery behavior - -The generated realm/bootstrap guidance must be self-contained enough that another engineer can import the artifact and predict the access-token shape without relying on hidden Keycloak defaults. - -The generator must guarantee through the generated realm artifact plus post-generation validation that imported access tokens reliably contain: - -- `sub` -- `aud` -- `realm_access.roles` diff --git a/backend/architecture.md b/backend/architecture.md deleted file mode 100644 index 2bd9be3..0000000 --- a/backend/architecture.md +++ /dev/null @@ -1,318 +0,0 @@ -# Backend Architecture - -Backend stack: - -- Node.js -- TypeScript -- NestJS -- Prisma ORM -- PostgreSQL -- jose - -The backend is generated from `domain/*.dsl`. - -Each DSL entity becomes: - -- Prisma model -- NestJS module -- CRUD controller -- Service -- DTO definitions - ---- - -# Single Source of Truth - -- `domain/*.dsl` is the only required input for backend generation. -- DTOs and REST API contracts must be derived from the domain model, primary keys, foreign keys, and enums defined in the domain DSL. -- Backend documentation, generation rules, and optional overrides must not duplicate entity, attribute, or relation structures outside the domain DSL. -- Deprecated multi-DSL inputs are compatibility-only artifacts and must never be treated as authoritative backend inputs or used to redefine entities, attributes, primary keys, foreign keys, relations, or enums. - ---- - -# Project Structure - -server/ -package.json - -prisma/ -schema.prisma - -src/ -main.ts -app.module.ts - - auth/ - auth.module.ts - guards/ - decorators/ - interfaces/ - - config/ - env.validation.ts - - modules/ - - {entity}/ - {entity}.module.ts - {entity}.controller.ts - {entity}.service.ts - - dto/ - create-{entity}.dto.ts - update-{entity}.dto.ts - {entity}.response.dto.ts - ---- - -# Module Rules - -Each entity generates exactly one NestJS module. - -Example: - -Entity: -Equipment - -Module: - -modules/ -equipment/ -equipment.module.ts -equipment.controller.ts -equipment.service.ts - ---- - -# Controller Rules - -Each entity controller must expose these endpoints: - -- GET /{resource} — list -- GET /{resource}/:pk — get one -- POST /{resource} — create -- PATCH /{resource}/:pk — update -- DELETE /{resource}/:pk — delete - -The path parameter **:pk** must use the **primary key attribute name** from the DSL, not always `:id`. - -## Path parameter by primary key - -| Entity | Primary key (DSL) | Path parameter | Example routes | -| ------------- | ----------------- | -------------- | -------------------------------------------------------- | -| Equipment | id | :id | GET /equipment/:id, PATCH /equipment/:id | -| EquipmentType | code | :code | GET /equipment-types/:code, PATCH /equipment-types/:code | -| RepairOrder | id | :id | GET /repair-orders/:id, PATCH /repair-orders/:id | - -**Rule:** Use the actual primary key name. For example, **EquipmentType** has PK `code`, so routes must be: - -- GET /equipment-types/:code -- PATCH /equipment-types/:code -- DELETE /equipment-types/:code - -**Do not** use `/equipment-types/:id` when the entity primary key is `code`. - ---- - -# Health Endpoint - -Every generated backend must expose a **health endpoint** so that runtime and orchestration can verify the API is up. - -- **Path:** `GET /health` -- **Response:** JSON with a status indicator (e.g. `{ "status": "ok" }`). - -## Controller example - -```typescript -@Public() -@Controller("health") -export class HealthController { - @Get() - getHealth() { - return { status: "ok" }; - } -} -``` - -Register the health controller in the root app module (or a dedicated health module). No authentication required for this endpoint. - ---- - -# Authentication and Authorization - -The generated backend must include explicit auth infrastructure by default. - -Generate: - -- `AuthModule` -- JWT guard -- roles guard -- `@Public()` -- `@Roles()` -- typed authenticated principal -- typed env validation for auth/runtime variables - -Rules: - -1. `/health` must remain public. -2. Generated CRUD routes must be protected by default. -3. JWT verification must use issuer + audience + JWKS. -4. Authorization roles must be extracted only from `realm_access.roles`. -5. The generated backend must not use deprecated Keycloak-specific Node adapters. - -## CRUD RBAC defaults - -Apply these defaults to generated CRUD controllers: - -- `GET` -> `viewer`, `editor`, `admin` -- `POST` -> `editor`, `admin` -- `PATCH` -> `editor`, `admin` -- `PUT` -> `editor`, `admin` -- `DELETE` -> `admin` - -These defaults must be encoded in generated guards/decorators, not left as informal guidance. - ---- - -# List Endpoint - -List endpoint must support pagination and filters via query parameters. - -Example: - -GET /equipment?page=0&size=10 - -Response format must follow React Admin requirements: - -{ -"data": [], -"total": number -} - -Sorting rules: - -1. Generated list/query logic must use actual model field names in ORM `orderBy` clauses. -2. If an entity primary key is not literally `id` but the API exposes synthetic `id` for React Admin compatibility, incoming `_sort=id` must be mapped to the real primary key field before building the query. -3. This mapping rule applies generally to all natural-key entities, not as a one-off entity hack. - ---- - -# Service Layer - -Services implement CRUD operations using Prisma. - -Example: - -findAll() -findOne(id) -create(data) -update(id, data) -remove(id) - ---- - -# DTO Rules - -DTOs are generated automatically from the domain DSL and are never a separate required DSL input. - -Create DTO: - -- contains required fields -- does NOT contain generated primary keys - -Update DTO: - -- all fields optional - -Response DTO: - -- mirrors domain entity attributes - ---- - -# Naming Conventions - -## Entity naming - -DSL entities use PascalCase. Generated backend artifacts use the same base name in lowercase for folders and file prefixes. - -- **Equipment** → equipment module -- **EquipmentType** → equipment-type module (or equipment-type as path segment) -- **RepairOrder** → repair-order module - -## Module naming - -One entity = one module folder. Folder name = entity name in kebab-case (lowercase, hyphen-separated). - -- Equipment → `modules/equipment/` -- EquipmentType → `modules/equipment-type/` -- RepairOrder → `modules/repair-order/` - -## Controller naming - -- File: `{entity-kebab}.controller.ts` -- Class: `EquipmentController`, `EquipmentTypeController`, `RepairOrderController` - -Examples: - -- `equipment.controller.ts` -- `equipment-type.controller.ts` -- `repair-order.controller.ts` - -## Service naming - -- File: `{entity-kebab}.service.ts` -- Class: `EquipmentService`, `EquipmentTypeService`, `RepairOrderService` - -Examples: - -- `equipment.service.ts` -- `equipment-type.service.ts` -- `repair-order.service.ts` - -## DTO naming - -- Create: `create-{entity-kebab}.dto.ts` (e.g. `create-equipment.dto.ts`, `create-repair-order.dto.ts`) -- Update: `update-{entity-kebab}.dto.ts` (e.g. `update-equipment.dto.ts`) -- Response: `{entity-kebab}.response.dto.ts` or use entity name for list/detail response types - ---- - -# Resource Naming Rules - -API resource paths are derived from the entity name: - -1. **PascalCase → kebab-case:** Replace camelCase with lowercase hyphenated segments. -2. **Pluralize:** Use plural form for the resource path (list endpoint represents a collection). - -| Entity (DSL) | API path (resource) | -| ------------- | ------------------- | -| Equipment | /equipment | -| EquipmentType | /equipment-types | -| RepairOrder | /repair-orders | - -Rules: - -- **Equipment** → `equipment` (already singular-looking; path is still `/equipment` for consistency with REST resource naming). -- **EquipmentType** → `equipment-types` (camelCase "EquipmentType" → "equipment-type", then plural → "equipment-types"). -- **RepairOrder** → `repair-orders` ("RepairOrder" → "repair-order" → "repair-orders"). - -Standard endpoints per resource: - -- GET /{resource} — list -- GET /{resource}/:pk — get one (pk = primary key name, e.g. :id or :code) -- POST /{resource} — create -- PATCH /{resource}/:pk — update -- DELETE /{resource}/:pk — delete - -See **Controller Rules** above for the rule that :pk must match the entity's primary key attribute name. - ---- - -# Environment and runtime - -- **Environment variables:** Backend requires runtime and auth variables. See **backend/runtime-rules.md**. -- **.env:** Generated project must include a `.env` (and `.env.example`) with database, auth, and CORS variables so the app starts without runtime errors. -- **PrismaService:** Must follow **backend/prisma-service.md** (OnModuleInit, $connect; no beforeExit). -- **Prisma client:** Add `"postinstall": "prisma generate"` (or equivalent) to package.json so the client is generated after install. -- **Migrations:** Document or run `npx prisma migrate dev` after schema generation. See **backend/runtime-rules.md** and **generation/backend-generation.md**. diff --git a/backend/database-runtime.md b/backend/database-runtime.md deleted file mode 100644 index bff59c9..0000000 --- a/backend/database-runtime.md +++ /dev/null @@ -1,70 +0,0 @@ -# Database Runtime - -The generated project must include a **PostgreSQL development database** so the application can run immediately after generation without manual database setup. - -Use **Docker** to provision the database. - ---- - -# docker-compose.yml - -The generator must create a `docker-compose.yml` file at the **project root** (same level as `server/` and `client/` directories). - -## Example - -```yaml -version: "3.9" - -services: - postgres: - image: postgres:16 - container_name: toir-postgres - restart: always - environment: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: toir - ports: - - "5432:5432" - volumes: - - postgres_data:/var/lib/postgresql/data - -volumes: - postgres_data: -``` - ---- - -# Rules - -- **Location:** Project root (e.g. `TOiR-generation/docker-compose.yml` or monorepo root). -- **Service name:** Can be `postgres` or project-specific (e.g. `toir-postgres` as container_name). -- **Credentials and DB name** must match the `DATABASE_URL` in `server/.env`: - - User: `postgres` - - Password: `postgres` - - Database: `toir` - - Host: `localhost` - - Port: `5432` -- **Volume:** Use a named volume so data persists across container restarts. - ---- - -# Usage - -Start the database before running the backend: - -```bash -docker compose up -d -``` - -Stop: - -```bash -docker compose down -``` - -Without this file and a running database, the backend will fail at runtime with errors such as: - -``` -PrismaClientInitializationError P1001: Can't reach database server at localhost:5432 -``` diff --git a/backend/prisma-rules.md b/backend/prisma-rules.md deleted file mode 100644 index 46d2fd0..0000000 --- a/backend/prisma-rules.md +++ /dev/null @@ -1,166 +0,0 @@ -# DSL → Prisma Mapping Rules - -The domain DSL defines the database schema. - -Each DSL entity becomes a Prisma model. - ---- - -# Type Mapping (Prisma schema) - -| DSL Type | Prisma Type | -|--------|-------------| -| string | String | -| uuid | String @id @default(uuid()) | -| integer | Int | -| decimal | Decimal | -| date | DateTime | -| text | String | - ---- - -# DTO Type Mapping (API / serialization) - -For DTOs and API responses, use types that serialize to JSON without errors. Prisma's `Decimal` and `DateTime` do not always serialize predictably; map them as follows in generated DTOs: - -| DSL Type | Prisma Type | DTO / API type | Notes | -|----------|-------------|----------------|-------| -| string | String | string | — | -| uuid | String | string | — | -| integer | Int | number | — | -| decimal | Decimal | **string** | Avoid Prisma Decimal in DTO; use string to prevent serialization issues. | -| date | DateTime | **string** | ISO 8601 date string (e.g. `"2025-03-08T00:00:00.000Z"`). | -| text | String | string | — | -| enum | enum | string | Enum value as string. | - -**Rules:** -- **decimal** → In create/update/response DTOs, use `string` (or a type that serializes to string). Convert to/from Prisma `Decimal` only in the service layer. -- **date** → In DTOs, use `string` (ISO date). Convert to/from Prisma `DateTime` in the service layer. - ---- - -# Enum Mapping - -DSL enum becomes Prisma enum. - -Example: - -DSL - -enum EquipmentStatus { - Active - Repair -} - -Prisma - -enum EquipmentStatus { - Active - Repair -} - ---- - -# Foreign Keys - -DSL foreign keys become Prisma relations. - -Example: - -DSL - -attribute equipmentTypeCode { - type string; - key foreign { - relates EquipmentType.code; - } -} - -Prisma - -equipmentTypeCode String -equipmentType EquipmentType @relation(fields: [equipmentTypeCode], references: [code]) - ---- - -# Primary Keys - -Primary keys defined in DSL must become Prisma model identifiers. - -Example: - -attribute id { - type uuid; - key primary; -} - -Prisma - -id String @id @default(uuid()) - ---- - -# Prisma Service Rules - -Use Prisma v5 compatible service. See **backend/prisma-service.md** for full specification. - -- PrismaService MUST implement `OnModuleInit` and call `await this.$connect()` in `onModuleInit()`. -- PrismaService MUST NOT use the deprecated `this.$on('beforeExit', ...)` hook (not supported in Prisma 5). - ---- - -# Prisma Lifecycle Rules - -These lifecycle rules must be applied in generated projects so Prisma runtime is reliable after generation and after dependency installation. - -## 1) Generate Prisma client after schema changes - -Whenever `prisma/schema.prisma` is generated or updated, run: - -```bash -npx prisma generate -``` - -## 2) postinstall lifecycle - -Generated `server/package.json` must include: - -```json -{ - "scripts": { - "postinstall": "prisma generate" - } -} -``` - -This ensures Prisma client generation after `npm install`. - -## 3) Migration lifecycle - -Use development migration workflow: - -```bash -npx prisma migrate dev -``` - -Run this after database startup and before starting the backend server. - -## 4) Seed lifecycle - -If seed is configured (see `backend/seed-rules.md`), run: - -```bash -npx prisma db seed -``` - -Generated `server/package.json` should include: - -```json -{ - "prisma": { - "seed": "ts-node prisma/seed.ts" - } -} -``` - -Use `tsx prisma/seed.ts` if the project standard uses `tsx` instead of `ts-node`. \ No newline at end of file diff --git a/backend/prisma-service.md b/backend/prisma-service.md deleted file mode 100644 index d06773b..0000000 --- a/backend/prisma-service.md +++ /dev/null @@ -1,80 +0,0 @@ -# PrismaService Implementation - -The generated backend must provide a NestJS injectable service that wraps PrismaClient. This document defines the **only** supported implementation for Prisma 5+. - ---- - -# Correct Implementation - -```typescript -import { Injectable, OnModuleInit } from "@nestjs/common"; -import { PrismaClient } from "@prisma/client"; - -@Injectable() -export class PrismaService extends PrismaClient implements OnModuleInit { - async onModuleInit() { - await this.$connect(); - } -} -``` - -## Why this pattern - -- **OnModuleInit:** NestJS lifecycle hook; `onModuleInit()` runs when the module is initialized, ensuring the database connection is established before handling requests. -- **$connect():** Explicitly connects the Prisma client. Required for reliable connection handling in serverless or long-running apps. - ---- - -# Deprecated: Do NOT Use - -The following pattern is **deprecated** in Prisma 5 and must **not** be generated: - -```typescript -// WRONG — do not generate -this.$on("beforeExit", async () => { - await this.$disconnect(); -}); -``` - -- `beforeExit` is not supported in Prisma 5. -- Using it will cause runtime errors or warnings. - -## Rule for generators - -Generated `PrismaService` (or equivalent) must: - -1. Extend `PrismaClient`. -2. Implement `OnModuleInit`. -3. Call `await this.$connect()` in `onModuleInit()`. -4. **Not** use `this.$on('beforeExit', ...)` or any `beforeExit` hook. - ---- - -# Module Registration - -Register the service in a dedicated Prisma module (or in `AppModule`) so it can be injected into other services: - -```typescript -// prisma.module.ts (or app.module.ts) -import { Global, Module } from "@nestjs/common"; -import { PrismaService } from "./prisma.service"; - -@Global() -@Module({ - providers: [PrismaService], - exports: [PrismaService], -}) -export class PrismaModule {} -``` - -Using `@Global()` allows injecting `PrismaService` in any module without re-importing. - ---- - -# File location - -Generated file: - -- `src/prisma.prisma.service.ts` or `src/prisma.service.ts` - -Class name: `PrismaService`. diff --git a/backend/runtime-rules.md b/backend/runtime-rules.md deleted file mode 100644 index 9f939b7..0000000 --- a/backend/runtime-rules.md +++ /dev/null @@ -1,152 +0,0 @@ -# Backend Runtime Rules - -This document defines runtime configuration requirements for the generated backend. Generators must produce a project that runs without errors when these rules are followed. - ---- - -# Environment Variables - -The backend **must** have a `.env` file at the project root (e.g. `server/.env` or backend package root). This file must **not** be committed with real credentials; provide an example instead (e.g. `.env.example`). - -## Required variables - -| Variable | Required | Description | -| -------------------- | -------- | ----------- | -| PORT | Yes | Backend listen port | -| DATABASE_URL | Yes | PostgreSQL connection string for Prisma | -| CORS_ALLOWED_ORIGINS | Yes | Comma-separated SPA origins allowed to call the API | -| KEYCLOAK_ISSUER_URL | Yes | Keycloak issuer URL used for JWT verification | -| KEYCLOAK_AUDIENCE | Yes | Backend audience/client expected in access tokens | -| KEYCLOAK_JWKS_URL | No | Optional explicit JWKS URL | - -## Example - -```env -PORT=3000 -DATABASE_URL="postgresql://postgres:postgres@localhost:5432/toir" -CORS_ALLOWED_ORIGINS="http://localhost:5173,https://toir-frontend.greact.ru" -KEYCLOAK_ISSUER_URL="https://sso.greact.ru/realms/toir" -KEYCLOAK_AUDIENCE="toir-backend" -# KEYCLOAK_JWKS_URL="https://sso.greact.ru/realms/toir/protocol/openid-connect/certs" -``` - -## Generation requirement - -When generating the backend, **always** create: - -1. **`.env.example`** — with placeholder DATABASE_URL and instructions. -2. **`.env`** — with local development example values so the app can start. - -If the generator does not create `.env`, the first run will fail with: - -``` -Environment variable not found: DATABASE_URL -``` - -## Fail-fast requirement - -The generated backend must validate required runtime and auth variables at startup. - -Rules: - -1. Missing required auth variables must stop startup immediately. -2. The generated backend must not silently fall back to production auth settings in code. -3. Auth env validation must be implemented explicitly in generated config/bootstrap code. - ---- - -# Authentication Runtime Rules - -The generated backend must verify JWTs against Keycloak using: - -- issuer -- audience -- JWKS - -JWKS resolution priority must be exactly: - -1. explicit `KEYCLOAK_JWKS_URL` -2. OIDC discovery -3. `${issuer}/protocol/openid-connect/certs` - -The generated backend must extract authorization roles only from `realm_access.roles`. - ---- - -# Prisma Client Generation - -After the Prisma schema is generated or modified, the Prisma client must be generated. - -## Command - -```bash -npx prisma generate -``` - -## Lifecycle rule - -Add to generated `package.json` so that `prisma generate` runs after every `npm install`: - -```json -{ - "scripts": { - "postinstall": "prisma generate" - } -} -``` - -This ensures that after cloning or installing dependencies, the Prisma client is available without a manual step. - -**Note:** Path may need to be adjusted if Prisma schema lives in a subfolder (e.g. `prisma generate` is typically run from the package root where `prisma/schema.prisma` exists). - ---- - -# Database Migration - -The generation process must document and support database migration. - -## Command - -```bash -npx prisma migrate dev -``` - -Run after: - -1. Prisma schema has been generated or updated. -2. `npx prisma generate` has been run. - -## Generation requirement - -1. Include migration in the backend generation pipeline (see `generation/backend-generation.md`). -2. Document in README or post-generation validation that the user must run `npx prisma migrate dev` before first run (or provide a setup script that runs it). - ---- - -# CORS Rules - -The generated backend must support the SPA -> API bearer-token model explicitly. - -Rules: - -1. Use `CORS_ALLOWED_ORIGINS` as the allowed origin list. -2. Allow at minimum these example origins in `.env.example`: - - `http://localhost:5173` - - `https://toir-frontend.greact.ru` -3. Allow `Authorization` and JSON content headers. -4. Expose `Content-Range` because React Admin depends on it. -5. Do not enable credentials by default. - ---- - -# Summary - -| Requirement | Action | -| ----------------------- | ------ | -| Runtime env | Create `.env` and `.env.example` with DB, auth, and CORS variables | -| Fail-fast config | Validate required auth/runtime variables at startup | -| Prisma client | Run `npx prisma generate`; add `postinstall` script | -| Database schema | Document/run `npx prisma migrate dev` after schema generation | -| JWT verification | Use issuer + audience + JWKS with explicit resolution priority | -| Role extraction | Use only `realm_access.roles` | -| CORS | Support SPA -> API bearer flow and expose `Content-Range` | diff --git a/backend/seed-rules.md b/backend/seed-rules.md deleted file mode 100644 index e3ceb4a..0000000 --- a/backend/seed-rules.md +++ /dev/null @@ -1,82 +0,0 @@ -# Seed Data Rules - -The generator must create a **Prisma seed script** so the development database contains minimal sample data. This allows the frontend and API to be used immediately after migration. - ---- - -# Seed Script Location - -**File:** `server/prisma/seed.ts` - -(Or `server/prisma/seed.js` if the project uses JavaScript; TypeScript is preferred and may require `ts-node` or `tsx` to run.) - ---- - -# Seed Data Requirements - -The seed script must create **minimal sample data** for at least one record per main entity, so that: - -- List views show data. -- Reference fields (e.g. equipment type, equipment) have valid options. -- The app is demo-ready without manual data entry. - -## Example scope (TOiR-style domain) - -- **One EquipmentType** (e.g. code `"pump"`, name `"Pump"`). -- **One Equipment** (e.g. linked to that type, with required fields filled). -- **One RepairOrder** (e.g. linked to that equipment, with required fields filled). - -Order matters: create EquipmentType first, then Equipment (references type), then RepairOrder (references equipment). Use Prisma `create` with the generated client; respect unique constraints and foreign keys. - ---- - -# Prisma Seed Configuration - -The generator must add the following to **`server/package.json`**: - -```json -"prisma": { - "seed": "ts-node prisma/seed.ts" -} -``` - -If the project uses a different runner (e.g. `tsx`), use that instead: - -```json -"prisma": { - "seed": "tsx prisma/seed.ts" -} -``` - -Ensure the seed runner is installed (e.g. `ts-node` as dev dependency) so that: - -```bash -npx prisma db seed -``` - -runs successfully. - ---- - -# Running the Seed - -After migrations: - -```bash -cd server -npx prisma migrate dev -npx prisma db seed -``` - -Or document that the user can run `npx prisma db seed` once after the first migration to populate sample data. - ---- - -# Summary - -| Requirement | Action | -|--------------------|--------| -| Seed script | Create `server/prisma/seed.ts` (or equivalent). | -| Sample data | At least one EquipmentType, one Equipment, one RepairOrder (or equivalent for the DSL). | -| package.json | Add `"prisma": { "seed": "ts-node prisma/seed.ts" }` (or tsx). | -| Seed runner | Ensure ts-node (or tsx) is available so `prisma db seed` works. | diff --git a/backend/service-rules.md b/backend/service-rules.md deleted file mode 100644 index d7e76dd..0000000 --- a/backend/service-rules.md +++ /dev/null @@ -1,97 +0,0 @@ -# Service Layer Rules - -Generated NestJS services must follow these rules so that update operations work correctly with React Admin and Prisma. - ---- - -# Update Payload Sanitization - -React Admin (and many REST clients) **always send `id`** in PATCH/PUT request bodies. - -Example payload from React Admin: - -```json -{ - "id": "003", - "name": "Pump" -} -``` - -Some entities use a **different primary key** (e.g. `code`). The API response includes `id` (mapped from the PK) for React Admin compatibility, but the Prisma model may have no `id` field—only `code`. If the service passes the incoming DTO directly to Prisma: - -```typescript -// WRONG — causes runtime error -prisma.equipmentType.update({ - where: { code }, - data: dto // dto contains "id", which is not a Prisma field -}); -``` - -Prisma throws because `id` (and possibly other non-updatable fields) are not on the model or must not be written in `data`. - ---- - -# Rules - -1. **Update payload must be sanitized** before passing to the ORM. Do not pass the raw request body as `data` to `prisma.*.update()`. - -2. **Remove `id`** from the update payload. React Admin sends `id` for identity; it must not be written to the database as a column (unless the entity actually has an `id` column and it is intended to be immutable on update). - -3. **Remove the primary key** field from the update payload. The primary key is used in `where`; it must not appear in `data`. For example, remove `code` from `data` when updating by `code`. - -4. **Remove readonly attributes** (e.g. created timestamps, server-generated fields) if they are present in the DTO, so they are not passed to Prisma `data`. - ---- - -# Example Implementation - -Destructure identity and primary key (and any other non-updatable fields) out of the DTO, then pass only the rest as `data`: - -**Entity with primary key `code` (e.g. EquipmentType):** - -```typescript -update(code: string, dto: UpdateEquipmentTypeDto) { - const { id, code: _pk, ...data } = dto as any; - return this.prisma.equipmentType.update({ - where: { code }, - data, - }); -} -``` - -Or, if the DTO type does not include `id` or `code`, explicitly omit only the fields that must not be written: - -```typescript -update(code: string, dto: UpdateEquipmentTypeDto) { - const { id, code: _pk, ...data } = dto as Record; - return this.prisma.equipmentType.update({ - where: { code }, - data, - }); -} -``` - -**Entity with primary key `id` (e.g. Equipment):** - -```typescript -update(id: string, dto: UpdateEquipmentDto) { - const { id: _pk, ...data } = dto as any; - return this.prisma.equipment.update({ - where: { id }, - data, - }); -} -``` - -This way, `id` (and the PK) are never passed into `data`, and Prisma does not receive unknown or read-only fields. - ---- - -# Summary - -| Rule | Action | -|------|--------| -| Sanitize update payload | Before `prisma.*.update()`, strip non-data fields from the DTO. | -| Remove `id` | Do not pass `id` in `data` unless the entity has an updatable `id` (rare). | -| Remove primary key | Use PK only in `where`; omit from `data`. | -| Remove readonly fields | Omit created_at, server-only fields, etc. from `data`. | diff --git a/docker-compose.yml b/docker-compose.yml index c591e3b..61fcc40 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ services: postgres: image: postgres:16 container_name: toir-postgres - restart: always + restart: unless-stopped environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres @@ -10,7 +10,7 @@ services: ports: - "5432:5432" volumes: - - postgres_data:/var/lib/postgresql/data + - postgres-data:/var/lib/postgresql/data volumes: - postgres_data: + postgres-data: diff --git a/docs/repository-structure.md b/docs/repository-structure.md new file mode 100644 index 0000000..bc2fdbd --- /dev/null +++ b/docs/repository-structure.md @@ -0,0 +1,35 @@ +# Repository Structure + +`KIS-TOiR` keeps the existing LLM-first generation philosophy and organizes the repository by meaning: + +- `domain/` + - canonical DSL inputs + - DSL specification +- `prompts/` + - active prompt corpus used to drive generation +- `docs/` + - overview and repository-level architecture notes +- `tools/` + - helper scripts for summary generation and validation +- `server/` + - active backend target output +- `client/` + - active frontend target output + +The repository keeps LLM-first generation orchestration, but framework bootstrap is CLI-first: + +- `server/` must remain a valid NestJS workspace baseline +- `client/` must remain a valid Vite React TypeScript workspace baseline +- repair a broken workspace before applying more domain-derived generation changes +- future agents must treat forbidden generation patterns in `prompts/` as contract violations, not suggestions + +Root-level files stay limited to repository-level artifacts such as: + +- `README.md` +- `package.json` +- `docker-compose.yml` +- `domain-summary.json` +- `toir-realm.json` +- `.gitignore` + +The repository does not introduce a new generator engine or compiler platform. It keeps the current LLM-first pipeline and makes it cleaner, more explicit, and easier to navigate. diff --git a/domain-summary.json b/domain-summary.json new file mode 100644 index 0000000..cf1e7e0 --- /dev/null +++ b/domain-summary.json @@ -0,0 +1,324 @@ +{ + "sourceFiles": [ + "domain/TOiR.domain.dsl" + ], + "entities": [ + { + "name": "EquipmentType", + "primaryKey": "code", + "fields": [ + { + "name": "code", + "type": "string", + "required": true, + "unique": true, + "default": null + }, + { + "name": "name", + "type": "string", + "required": true, + "unique": false, + "default": null + }, + { + "name": "manufacturer", + "type": "string", + "required": false, + "unique": false, + "default": null + }, + { + "name": "maintenanceIntervalHours", + "type": "integer", + "required": false, + "unique": false, + "default": null + }, + { + "name": "overhaulIntervalHours", + "type": "integer", + "required": false, + "unique": false, + "default": null + } + ], + "foreignKeys": [] + }, + { + "name": "Equipment", + "primaryKey": "id", + "fields": [ + { + "name": "id", + "type": "uuid", + "required": false, + "unique": false, + "default": null + }, + { + "name": "inventoryNumber", + "type": "string", + "required": true, + "unique": true, + "default": null + }, + { + "name": "serialNumber", + "type": "string", + "required": false, + "unique": false, + "default": null + }, + { + "name": "name", + "type": "string", + "required": true, + "unique": false, + "default": null + }, + { + "name": "equipmentTypeCode", + "type": "string", + "required": true, + "unique": false, + "default": null + }, + { + "name": "status", + "type": "EquipmentStatus", + "required": true, + "unique": false, + "default": "Active" + }, + { + "name": "location", + "type": "string", + "required": false, + "unique": false, + "default": null + }, + { + "name": "commissionedAt", + "type": "date", + "required": false, + "unique": false, + "default": null + }, + { + "name": "totalEngineHours", + "type": "decimal", + "required": false, + "unique": false, + "default": null + }, + { + "name": "engineHoursSinceLastRepair", + "type": "decimal", + "required": false, + "unique": false, + "default": null + }, + { + "name": "lastRepairAt", + "type": "date", + "required": false, + "unique": false, + "default": null + }, + { + "name": "notes", + "type": "text", + "required": false, + "unique": false, + "default": null + } + ], + "foreignKeys": [ + { + "field": "equipmentTypeCode", + "references": { + "entity": "EquipmentType", + "field": "code" + } + } + ] + }, + { + "name": "RepairOrder", + "primaryKey": "id", + "fields": [ + { + "name": "id", + "type": "uuid", + "required": false, + "unique": false, + "default": null + }, + { + "name": "number", + "type": "string", + "required": true, + "unique": true, + "default": null + }, + { + "name": "equipmentId", + "type": "uuid", + "required": true, + "unique": false, + "default": null + }, + { + "name": "repairKind", + "type": "RepairKind", + "required": true, + "unique": false, + "default": null + }, + { + "name": "status", + "type": "RepairOrderStatus", + "required": true, + "unique": false, + "default": "Draft" + }, + { + "name": "plannedAt", + "type": "date", + "required": true, + "unique": false, + "default": null + }, + { + "name": "startedAt", + "type": "date", + "required": false, + "unique": false, + "default": null + }, + { + "name": "completedAt", + "type": "date", + "required": false, + "unique": false, + "default": null + }, + { + "name": "contractor", + "type": "string", + "required": false, + "unique": false, + "default": null + }, + { + "name": "engineHoursAtRepair", + "type": "decimal", + "required": false, + "unique": false, + "default": null + }, + { + "name": "description", + "type": "text", + "required": false, + "unique": false, + "default": null + }, + { + "name": "notes", + "type": "text", + "required": false, + "unique": false, + "default": null + } + ], + "foreignKeys": [ + { + "field": "equipmentId", + "references": { + "entity": "Equipment", + "field": "id" + } + } + ] + } + ], + "enums": [ + { + "name": "EquipmentStatus", + "values": [ + { + "name": "Active", + "label": "В эксплуатации" + }, + { + "name": "Repair", + "label": "В ремонте" + }, + { + "name": "Reserve", + "label": "В резерве" + }, + { + "name": "WriteOff", + "label": "Списано" + } + ] + }, + { + "name": "RepairKind", + "values": [ + { + "name": "TO", + "label": "Техническое обслуживание" + }, + { + "name": "TR", + "label": "Текущий ремонт" + }, + { + "name": "TRE", + "label": "Текущий расширенный ремонт" + }, + { + "name": "KR", + "label": "Капитальный ремонт" + }, + { + "name": "AR", + "label": "Аварийный ремонт" + }, + { + "name": "MP", + "label": "Метрологическая поверка" + } + ] + }, + { + "name": "RepairOrderStatus", + "values": [ + { + "name": "Draft", + "label": "Черновик" + }, + { + "name": "Approved", + "label": "Утверждена" + }, + { + "name": "InWork", + "label": "В работе" + }, + { + "name": "Done", + "label": "Выполнена" + }, + { + "name": "Cancelled", + "label": "Отменена" + } + ] + } + ] +} diff --git a/examples/TOiR.domain.dsl b/domain/TOiR.domain.dsl similarity index 74% rename from examples/TOiR.domain.dsl rename to domain/TOiR.domain.dsl index ebef7b2..880fd1f 100644 --- a/examples/TOiR.domain.dsl +++ b/domain/TOiR.domain.dsl @@ -3,10 +3,6 @@ Сущности: Equipment (Оборудование), EquipmentType (Вид оборудования), RepairOrder (Заявка на ремонт) */ -// ───────────────────────────────────────────── -// Перечисления -// ───────────────────────────────────────────── - enum EquipmentStatus { value Active { label "В эксплуатации"; @@ -61,11 +57,6 @@ enum RepairOrderStatus { } } - -// ───────────────────────────────────────────── -// Справочник: Вид оборудования -// ───────────────────────────────────────────── - entity EquipmentType { description "Вид (марка) оборудования — нормативный справочник НСИ"; @@ -88,7 +79,6 @@ entity EquipmentType { type string; } - // Нормативный межремонтный ресурс (моточасы) attribute maintenanceIntervalHours { description "Периодичность ТО, моточасов"; type integer; @@ -100,11 +90,6 @@ entity EquipmentType { } } - -// ───────────────────────────────────────────── -// Основная сущность: Оборудование -// ───────────────────────────────────────────── - entity Equipment { description "Единица оборудования — объект ремонта и технического обслуживания"; @@ -131,14 +116,13 @@ entity Equipment { is required; } - // Связь с видом оборудования (справочник НСИ) - attribute equipmentTypeCode { - type string; - key foreign { - relates EquipmentType.code; - } - is required; + attribute equipmentTypeCode { + type string; + key foreign { + relates EquipmentType.code; } + is required; + } attribute status { description "Текущий статус"; @@ -157,7 +141,6 @@ entity Equipment { type date; } - // Наработка фиксируется вручную или из производственной программы attribute totalEngineHours { description "Общая наработка, моточасов"; type decimal; @@ -179,11 +162,6 @@ entity Equipment { } } - -// ───────────────────────────────────────────── -// Заявка на ремонт -// ───────────────────────────────────────────── - entity RepairOrder { description "Заявка на ремонт — формируется по ППР или по факту обнаруженного дефекта"; diff --git a/domain/dsl-spec.md b/domain/dsl-spec.md index 63b1755..bfd4fed 100644 --- a/domain/dsl-spec.md +++ b/domain/dsl-spec.md @@ -2,6 +2,8 @@ This document describes the single DSL (Domain Specific Language) used to specify fullstack CRUD applications. The only required DSL input is `domain/*.dsl`. +`domain-summary.json` is a derived artifact generated from this DSL to stabilize LLM-first generation and feed the lightweight validation gate. It must never replace the DSL as the source of truth. The active prompt corpus that consumes this contract lives in `prompts/`. + --- # DSL Responsibility diff --git a/frontend/architecture.md b/frontend/architecture.md deleted file mode 100644 index f362061..0000000 --- a/frontend/architecture.md +++ /dev/null @@ -1,192 +0,0 @@ -# Frontend Architecture - -Frontend stack: - -- React -- TypeScript -- Vite -- React Admin -- shadcn/ui -- Keycloak JS - -The frontend is generated from `domain/*.dsl`. - -Each entity becomes a React Admin resource. - -The generated frontend must also include Keycloak authentication by default. - ---- - -# Single Source of Truth - -- `domain/*.dsl` is the only required input for frontend generation. -- React Admin resources, fields, references, and routes must be derived from the domain model, primary keys, foreign keys, and enums defined in the domain DSL. -- Frontend documentation, generation rules, and optional overrides must not duplicate entity, attribute, or relation structures outside the domain DSL. -- Deprecated multi-DSL inputs are compatibility-only artifacts and must never be treated as authoritative frontend inputs or used to redefine entities, attributes, primary keys, foreign keys, relations, or enums. - ---- - -# Project Structure - -client/ - src/ - - App.tsx - - main.tsx - - dataProvider.ts - - auth/ - keycloak.ts - authProvider.ts - - config/ - env.ts - - resources/ - - {entity}/ - {entity}List.tsx - {entity}Create.tsx - {entity}Edit.tsx - {entity}Show.tsx - ---- - -# Resource Registration - -Each resource must be registered in App.tsx. - -The generated `App.tsx` must register: - -- `dataProvider` -- `authProvider` - -The generated `Admin` root must enforce authenticated operation. The generated frontend must not operate anonymously once auth is enabled. -The generated `authProvider.getIdentity()` must resolve identity from token claims already present in the parsed token and must not trigger a baseline Keycloak `/account` request. - -Example: - - - ---- - -# Data Provider - -React Admin uses a generated shared REST-compatible data provider. - -API format must follow: - -GET /resource -GET /resource/:id -POST /resource -PATCH /resource/:id -DELETE /resource/:id - -List response format: - -{ - data: [], - total: number -} - -The generated `dataProvider.ts` must remain the **single shared request seam** for backend API calls. - -Rules: - -1. Use an env-driven API base URL. -2. Attach `Authorization: Bearer ` in this shared seam. -3. Cover all React Admin operations, including references and bulk fetches. -4. Do not scatter auth headers across resource components. - ---- - -# Application Bootstrap - -The generated `main.tsx` must initialize Keycloak before rendering the SPA. - -Rules: - -1. Use redirect-based Keycloak login only. -2. Use Authorization Code + PKCE (`S256`). -3. Do not generate a custom in-app username/password login form. -4. Do not render the authenticated admin app before Keycloak initialization completes. -5. Do not introduce `keycloak.loadUserProfile()` or `/account` profile-fetch requests as part of baseline app startup or identity resolution. - ---- - -# Config - -The generated frontend must include a dedicated config module in `src/config/`. - -Required env variables: - -- `VITE_API_URL` -- `VITE_KEYCLOAK_URL` -- `VITE_KEYCLOAK_REALM` -- `VITE_KEYCLOAK_CLIENT_ID` - -The generated frontend config must fail fast if required auth variables are missing. The generated frontend must not silently fall back to production auth settings in code. - ---- - -# Foreign Keys - -Foreign keys must use ReferenceInput and ReferenceField. - -Example: - - - ---- - -# Naming Conventions - -## React component naming - -Components are named after the entity in PascalCase. One entity = one resource with four main views. - -- **List:** `{Entity}List.tsx` (e.g. `EquipmentList.tsx`, `RepairOrderList.tsx`) -- **Create:** `{Entity}Create.tsx` (e.g. `EquipmentCreate.tsx`) -- **Edit:** `{Entity}Edit.tsx` (e.g. `EquipmentEdit.tsx`) -- **Show:** `{Entity}Show.tsx` (e.g. `EquipmentShow.tsx`) - -Examples: -- Equipment → `EquipmentList.tsx`, `EquipmentCreate.tsx`, `EquipmentEdit.tsx`, `EquipmentShow.tsx` -- EquipmentType → `EquipmentTypeList.tsx`, `EquipmentTypeCreate.tsx`, `EquipmentTypeEdit.tsx`, `EquipmentTypeShow.tsx` -- RepairOrder → `RepairOrderList.tsx`, `RepairOrderCreate.tsx`, `RepairOrderEdit.tsx`, `RepairOrderShow.tsx` - -## Resource folder naming - -Folder under `resources/` uses kebab-case, matching the React Admin resource name: - -- `resources/equipment/` -- `resources/equipment-type/` -- `resources/repair-order/` - ---- - -# Resource Naming Rules - -React Admin resource name (used in `` and in `reference` for ReferenceInput) must match the API resource path (no leading slash, same segment string). - -1. **Entity → resource name:** PascalCase entity name is converted to kebab-case and pluralized. -2. **Consistency with API:** The resource name must be the same as the backend path segment so that the data provider calls the correct URL. - -| Entity (DSL) | Resource name (React Admin) | API path | -|----------------|-----------------------------|------------| -| Equipment | equipment | /equipment | -| EquipmentType | equipment-types | /equipment-types | -| RepairOrder | repair-orders | /repair-orders | - -Examples in App.tsx: -- `` -- `` -- `` diff --git a/frontend/react-admin-rules.md b/frontend/react-admin-rules.md deleted file mode 100644 index 26b196a..0000000 --- a/frontend/react-admin-rules.md +++ /dev/null @@ -1,164 +0,0 @@ -# DSL → React Admin Mapping - -Entity attributes determine UI fields. - ---- - -# Authentication - -Generated React Admin applications in this repository must include an `authProvider`. - -Rules: - -1. `authProvider` is mandatory. -2. The generated app must use redirect-based Keycloak login only. -3. The generator must not create a custom in-app username/password form. -4. The generated app must initialize authentication before rendering the admin UI. - ---- - -# Shared Authenticated Request Layer - -The generated frontend must attach bearer tokens through the shared request seam in `client/src/dataProvider.ts`. - -Rules: - -1. All resource calls must use the same authenticated request layer. -2. Reference lookups must use the same authenticated request layer. -3. The generated frontend must not attach auth headers directly inside resource components. - ---- - -# Error Handling - -The generated `authProvider.checkError` must distinguish authentication failures from authorization failures: - -- `401` -> force logout / re-authentication -- `403` -> do not re-authenticate; surface access denied / permission error - -The generator must not treat `401` and `403` as the same outcome. - ---- - -# Identity Resolution - -The generated `authProvider.getIdentity()` must use token claims already present in the parsed token. - -Rules: - -1. Prefer `sub`, `preferred_username`, `email`, and `name`. -2. Do not call `keycloak.loadUserProfile()` by default. -3. Do not rely on the Keycloak `/account` endpoint for baseline CRUD/admin generation. - -The default generator strategy is to avoid the `/account` request entirely rather than solving it through broader Keycloak CORS settings. - ---- - -# Token Handling - -The generated frontend must use Keycloak JS token handling with these rules: - -1. Use Authorization Code + PKCE (`S256`). -2. Refresh tokens before protected API calls when needed. -3. Token refresh must be concurrency-safe: - - one shared in-flight refresh operation - - no parallel refresh stampede -4. Do not store access tokens or refresh tokens in `localStorage` or `sessionStorage`. - ---- - -# Type Mapping - -| DSL Type | React Admin Component | -|---------|-----------------------| -| string | TextInput / TextField | -| integer | NumberInput | -| decimal | NumberInput | -| date | DateInput | -| enum | SelectInput | -| foreign key | ReferenceInput | - ---- - -# Example - -DSL - -attribute name { - type string; -} - -React Admin - - - ---- - -# Enum Example - -DSL - -attribute status { - type EquipmentStatus; -} - -React Admin - - - ---- - -# Foreign Key Example - -DSL - -attribute equipmentTypeCode { - type string; -} - -React Admin - - - ---- - -# React Admin ID Field Requirement - -React Admin requires every record in list and detail responses to contain a field named **`id`**. It uses this field for resource identity, cache keys, and references. - -**Rules:** - -1. Every record returned by the API must contain an **`id`** field. -2. If the DSL primary key is not named `id`, the generator must **map** the primary key value to an `id` field in the API response (backend) or in a frontend adapter. -3. The `id` field must contain the **value of the primary key** (e.g. uuid string, or `code` value for EquipmentType). -4. If the primary key is not literally `id`, backend list/query logic must map React Admin `_sort=id` to the real primary key field before constructing ORM sorting. - -**Example:** - -DSL entity with primary key `code`: - -``` -entity EquipmentType { - attribute code { - key primary; - type string; - } - attribute name { type string; } -} -``` - -API response must include `id` so React Admin can identify the record: - -```json -{ - "id": "pump", - "code": "pump", - "name": "Pump" -} -``` - -If the response only had `{ "code": "pump", "name": "Pump" }`, React Admin would not work correctly because it expects `id`. The backend or frontend adapter must therefore set `id: record.code` (or equivalent) when the primary key is not `id`. - -If React Admin later sends `_sort=id`, the generated backend must map that synthetic `id` sort back to the real primary key field (for example `code`) before building the Prisma `orderBy` clause. - -This rule ensures compatibility with React Admin resource identity handling. diff --git a/general-prompt.md b/general-prompt.md deleted file mode 100644 index f552aec..0000000 --- a/general-prompt.md +++ /dev/null @@ -1,508 +0,0 @@ -ROLE - -You are a Staff-level Fullstack Platform Engineer. - -Your task is to generate a fully runnable fullstack CRUD application from the DSL context of this repository. - -Use context7. - -Follow official best practices from: - -NestJS documentation - -Prisma documentation - -React Admin documentation - -Vite documentation - -Keycloak documentation - -Docker documentation - -The generated application must run without manual fixes. - -PROJECT CONTEXT - -You must read the project documentation in the following strict order: - -domain/dsl-spec.md - -domain/*.dsl - -If present, read optional overrides after the domain DSL: - -overrides/api-overrides.dsl -overrides/ui-overrides.dsl - -backend/architecture.md - -backend/prisma-rules.md - -backend/prisma-service.md - -backend/service-rules.md - -backend/runtime-rules.md - -backend/database-runtime.md - -backend/seed-rules.md - -frontend/architecture.md - -frontend/react-admin-rules.md - -auth/keycloak-architecture.md - -auth/frontend-auth-rules.md - -auth/backend-auth-rules.md - -auth/keycloak-realm-template-rules.md - -generation/scaffolding-rules.md - -generation/backend-generation.md - -generation/frontend-generation.md - -generation/runtime-bootstrap.md - -generation/post-generation-validation.md - -Do not ignore any rules defined in these documents. - -INPUT CONTRACT - -Required DSL input: - -domain/*.dsl - -Optional override inputs: - -overrides/api-overrides.dsl -overrides/ui-overrides.dsl - -Rules: - -- Domain DSL is the single source of truth for entities, attributes, primary keys, foreign keys, and enums. -- DTO, API, and UI must be derived from the domain DSL. -- Optional overrides must not duplicate or redefine the domain model. -- Generation must work without override files. -- Ignore deprecated multi-DSL inputs if they are present in the repository; they are not authoritative generation inputs. -- Do not require standalone DTO, API, or UI DSL inputs. - -GOAL - -Generate a domain-DSL-driven fullstack CRUD system with default Keycloak authentication and authorization. - -Repository-specific defaults and examples may use names such as `toir`, `toir-frontend`, `toir-backend`, `toir-realm.json`, and `*.greact.ru`, but the generator must parameterize realm name, client IDs, production URLs, and realm-artifact filename for other generated projects. - -Stack: - -Backend - -Node.js - -NestJS - -Prisma ORM - -PostgreSQL - -jose - -Frontend - -React - -Vite - -React Admin - -MUI - -shadcn/ui - -Keycloak JS - -PROJECT STRUCTURE - -Root -.gitignore -docker-compose.yml -root-level Keycloak realm import artifact (default example filename: `toir-realm.json`) -server/ -client/ - -Backend -server/ -src/ -auth/ -config/ -modules/{entity}/ -prisma/schema.prisma -prisma/seed.ts -.gitignore -.env -.env.example - -Frontend -client/ -.gitignore -src/ -auth/ -config/ -resources/{entity}/ -App.tsx -main.tsx -dataProvider.ts -.env.example - -STEP 1 — Parse Domain DSL - -Parse domain/*.dsl and extract: - -Entities -Attributes -Primary keys -Foreign keys -Enums - -If present, read optional override files only after the domain model has been parsed. Overrides may refine derived API or UI behavior but must never redefine entities, attributes, primary keys, foreign keys, or enums. -Do not consult any supplemental DTO/API/UI DSL source when deriving backend or frontend artifacts. - -Respect the DSL specification. - -STEP 2 — CLI scaffolding - -Use official CLIs. - -Backend -npx @nestjs/cli@10.3.2 new server --package-manager npm --skip-git - -Frontend -npm create vite@5.2.0 client -- --template react-ts - -STEP 3 — Install dependencies - -Backend - -@prisma/client -prisma -@nestjs/config -jose - -Frontend - -react-admin -ra-data-simple-rest -@mui/material -@emotion/react -@emotion/styled -keycloak-js - -STEP 4 — Generate Prisma schema - -From DSL domain generate: - -models - -enums - -relations - -primary keys - -Type mapping - -decimal → Decimal -date → DateTime - -DTO mapping - -decimal → string -date → ISO string - -STEP 5 — Generate NestJS CRUD modules and derived DTOs - -Per entity generate: - -module -controller -service -dto - -Controller routes - -GET /resource -GET /resource/:pk -POST /resource -PATCH /resource/:pk -DELETE /resource/:pk - -Path parameter must match the DSL primary key name. - -Examples - -/equipment/:id -/equipment-types/:code -/repair-orders/:id - -STEP 6 — Generate backend auth infrastructure - -Generate: - -AuthModule -JWT guard -roles guard -@Public() -@Roles() -typed authenticated principal -typed env validation - -Rules: - -- `/health` must remain public -- CRUD routes must be protected by default -- RBAC source must be `realm_access.roles` -- JWT verification must use issuer + audience + JWKS -- JWKS resolution priority must be: - 1. explicit `KEYCLOAK_JWKS_URL` - 2. OIDC discovery - 3. `${issuer}/protocol/openid-connect/certs` -- Do not use deprecated Keycloak-specific Node adapters - -STEP 7 — Generate Service Layer - -Service layer must follow backend/service-rules.md. - -Important rule: - -React Admin sends the id field in update payloads even when the primary key is not named id. - -Therefore update payload must be sanitized before passing data to Prisma. - -Services MUST NOT pass raw request DTO directly into Prisma. - -Incorrect: - -prisma.entity.update({ -where, -data: dto -}) - -Correct pattern: - -const { id, , ...data } = dto - -return prisma.entity.update({ -where, -data -}) - -Example (PK = code) - -const { id, code, ...data } = dto - -return prisma.equipmentType.update({ -where: { code }, -data -}) - -Example (PK = id) - -const { id: _pk, ...data } = dto - -return prisma.entity.update({ -where: { id }, -data -}) - -Rules - -Update payload passed to Prisma must not contain: - -id -primary key attribute -readonly attributes - -STEP 8 — Generate frontend auth integration - -Generate: - -client/src/config/env.ts -client/src/auth/keycloak.ts -client/src/auth/authProvider.ts - -Rules: - -- Keycloak login must be redirect-based only -- Use Authorization Code + PKCE (`S256`) -- Initialize Keycloak before rendering the SPA -- Attach `Authorization: Bearer ` through the shared request seam in `client/src/dataProvider.ts` -- `authProvider.getIdentity()` must derive identity from parsed token claims such as `sub`, `preferred_username`, `email`, and `name` -- Do not call `keycloak.loadUserProfile()` by default -- Do not rely on the Keycloak `/account` endpoint for baseline CRUD/admin generation -- Avoid the `/account` request entirely by default rather than broadening Keycloak CORS behavior -- `401` must force re-authentication -- `403` must surface access denied without forcing re-authentication -- Token refresh must be concurrency-safe -- Do not store tokens in `localStorage` or `sessionStorage` -- Frontend auth config must fail fast if required auth vars are missing - -STEP 9 — Generate runtime infrastructure - -Create: - -server/.env -server/.env.example -client/.env.example -root/.gitignore -server/.gitignore -client/.gitignore -root-level Keycloak realm import artifact (default example filename: `toir-realm.json`) - -Backend env examples must include: - -PORT -DATABASE_URL -CORS_ALLOWED_ORIGINS -KEYCLOAK_ISSUER_URL -KEYCLOAK_AUDIENCE -KEYCLOAK_JWKS_URL (optional) - -Frontend env examples must include: - -VITE_API_URL -VITE_KEYCLOAK_URL -VITE_KEYCLOAK_REALM -VITE_KEYCLOAK_CLIENT_ID - -Add to package.json: - -postinstall: prisma generate - -Generated `.gitignore` files must prevent local-only artifacts from entering git, including: - -node_modules -dist -dist-ssr -coverage -*.tsbuildinfo -.env -.env.local -.env.*.local - -STEP 10 — Database runtime - -Generate root: - -docker-compose.yml - -PostgreSQL container - -postgres:16 -port 5432 - -STEP 11 — Generate seed - -Create: - -server/prisma/seed.ts - -Seed minimal data for: - -EquipmentType -Equipment -RepairOrder - -Add to package.json: - -prisma.seed - -STEP 12 — Generate React Admin resources - -Generate React Admin resources automatically from the domain DSL. - -For each entity generate: - -Field mapping - -string → TextInput -number → NumberInput -date → DateInput -enum → SelectInput -FK → ReferenceInput - -API responses MUST contain: - -If PK ≠ id, map primary key to id. - -If PK ≠ id, backend list/query logic must map React Admin `_sort=id` to the real primary key field before constructing ORM sorting. - -Example - -{ -id: record.code, -code: record.code -} - -STEP 13 — Validation - -Verify: - -docker-compose.yml exists -database container starts -prisma migrate dev works -prisma db seed works -API responds /health -React Admin receives id -update services sanitize payload before Prisma -frontend auth files exist -backend auth files exist -auth env examples exist -root/server/client .gitignore files exist -gitignore rules exclude local dependency, build, env, coverage, and tsbuildinfo artifacts -frontend auth code does not call `keycloak.loadUserProfile()` -frontend `getIdentity()` is token-claim based and does not rely on `/account` -public /health is preserved -unauthenticated protected route returns 401 -insufficient role returns 403 -natural-key entities map React Admin `_sort=id` to the real primary key field -generated realm import artifact is self-contained and guarantees `sub`, `aud`, and `realm_access.roles` - -OUTPUT - -Provide: - -FULLSTACK GENERATION REPORT - -Include: - -1 Parsed DSL -2 Prisma models -3 Backend modules -4 API endpoints -5 React Admin resources -6 Authentication and authorization -7 Runtime configuration -8 Validation results - -RUN INSTRUCTIONS - -The generated application must run successfully with: - -Import the generated root-level Keycloak realm artifact (for example `toir-realm.json`) into the external Keycloak server - -docker compose up -d - -cd server -npm install -npx prisma migrate dev -npm run start - -cd client -npm install -npm run dev diff --git a/generation/backend-generation.md b/generation/backend-generation.md deleted file mode 100644 index 3eb0fde..0000000 --- a/generation/backend-generation.md +++ /dev/null @@ -1,264 +0,0 @@ -# Backend Generation Process - -Backend generation follows a pipeline aligned with runtime, auth, and validation docs: - -DSL -↓ -CLI scaffolding -↓ -backend code generation -↓ -auth generation -↓ -runtime infrastructure -↓ -database runtime -↓ -migration -↓ -seed -↓ -validation - -Follow: - -- `backend/architecture.md` -- `backend/runtime-rules.md` -- `backend/prisma-rules.md` -- `backend/prisma-service.md` -- `backend/database-runtime.md` -- `backend/seed-rules.md` -- `backend/service-rules.md` -- `auth/keycloak-architecture.md` -- `auth/backend-auth-rules.md` -- `auth/keycloak-realm-template-rules.md` - ---- - -# Input Contract - -Required input: - -- `domain/*.dsl` - -Optional extension input: - -- `overrides/api-overrides.dsl` - -Optional extension layout: - -```text -overrides/ - api-overrides.dsl -``` - -Rules: - -- Parse `domain/*.dsl` as the only authoritative DSL input. -- Generate DTOs and REST API contracts automatically from the parsed domain model. -- The generator must work when `overrides/api-overrides.dsl` is absent. -- Optional overrides may refine derived API behavior but must not redefine entities, attributes, primary keys, foreign keys, relations, or enums. -- Supplemental DTO/API DSL inputs must not participate in backend parsing, dependency resolution, or backend generation decisions. -- Do not read standalone DTO or API DSL files. - ---- - -# Step 1 — Parse Domain DSL - -Read `domain/*.dsl` and extract: - -- entities -- attributes, including the actual primary key attribute per entity -- enums -- foreign keys - -All entities, attributes, primary keys, foreign keys, relations, and enums used by the backend pipeline must come from the parsed domain DSL. - -If `overrides/api-overrides.dsl` exists, process it only after the domain model has been parsed and only as optional non-authoritative metadata. - -The generator must treat auth as default runtime infrastructure, not as a DSL feature toggle. - ---- - -# Step 2 — CLI scaffolding - -Use official CLIs before generating backend code: - -- NestJS project scaffold in `server/` (see `generation/scaffolding-rules.md`) -- install backend dependencies: - - `@prisma/client` - - `prisma` - - `@nestjs/config` - - `jose` - - seed runner when needed by the chosen package tooling - -The generator must **not** use deprecated Keycloak-specific Node adapters such as `keycloak-connect`. - ---- - -# Step 3 — Core backend code generation - -Generate backend source artifacts: - -1. **Prisma schema** (`server/prisma/schema.prisma`) from the domain DSL: - - attributes - - primary keys - - relations - - enums -2. **NestJS modules** per entity: - - module - - controller - - service -3. **DTO files**: - - `create-entity.dto.ts` - - `update-entity.dto.ts` - - `entity.response.dto.ts` (or equivalent) -4. **PrismaService**: - - implement `OnModuleInit` - - call `await this.$connect()` in `onModuleInit()` - - do not use a `beforeExit` hook -5. **Service update methods**: - - sanitize update payload before Prisma - - remove `id`, the entity primary key, and readonly attributes from `data` - - do not pass the raw request body directly to `prisma.*.update()` -6. **List/query sorting methods**: - - use only actual model field names in ORM `orderBy` - - if the API exposes synthetic `id` for React Admin but the real primary key is different, map incoming `_sort=id` to the real primary key field before building `orderBy` - - apply this rule to every entity with a non-`id` primary key - -All backend DTOs and REST endpoints are derived artifacts. The generator must not require or parse separate DTO/API DSL documents. - -Use mapping rules from `backend/prisma-rules.md`: - -- DSL `decimal` -> DTO `string` -- DSL `date` -> DTO `string` (ISO) - ---- - -# Step 4 — Backend auth generation - -Generate auth infrastructure as part of the normal backend output. Auth must not be deferred to a manual post-step. - -Required generated auth artifacts: - -- `server/src/auth/auth.module.ts` -- JWT auth guard -- roles guard -- `@Public()` decorator -- `@Roles()` decorator -- typed authenticated principal interface -- typed auth-aware config validation in `server/src/config/` - -JWT verification rules: - -- verify JWTs with issuer, audience, and JWKS -- use a standards-based library such as `jose` -- resolve JWKS in this exact order: - 1. explicit `KEYCLOAK_JWKS_URL` - 2. OIDC discovery - 3. `${issuer}/protocol/openid-connect/certs` - -RBAC rules: - -- extract authorization roles only from `realm_access.roles` -- do not infer roles from other claims -- apply CRUD defaults by HTTP method: - - `GET` -> `viewer`, `editor`, `admin` - - `POST`, `PATCH`, `PUT` -> `editor`, `admin` - - `DELETE` -> `admin` -- mark `/health` as public with `@Public()` - -Controller generation rules: - -- generated CRUD controllers must receive auth decorators by default -- path params must still use the actual entity primary key name (`:id`, `:code`, etc.) -- public routes must be explicit rather than implicit - ---- - -# Step 5 — Runtime infrastructure - -Generate backend runtime config files: - -- `server/.env` -- `server/.env.example` -- `server/src/config/*` typed validation helpers -- `server/package.json` lifecycle: - - `"postinstall": "prisma generate"` -- `server/package.json` Prisma seed: - - `"prisma": { "seed": "ts-node prisma/seed.ts" }` (or `tsx` equivalent if that is the chosen project standard) - -The generated backend env contract must include: - -- `PORT` -- `DATABASE_URL` -- `CORS_ALLOWED_ORIGINS` -- `KEYCLOAK_ISSUER_URL` -- `KEYCLOAK_AUDIENCE` -- `KEYCLOAK_JWKS_URL` (optional) - -Fail-fast config rule: - -- backend startup must fail fast when required auth or database env vars are missing -- the generator must not silently fall back to production auth values in code - -Commands that must be supported and documented: - -- `npx prisma generate` -- `npx prisma migrate dev` -- `npx prisma db seed` - ---- - -# Step 6 — Database runtime - -Create `docker-compose.yml` at the **project root** with PostgreSQL only. - -Minimum required compose characteristics: - -- `services.postgres` -- `image: postgres:16` -- `ports: ["5432:5432"]` - -Credentials and database name in compose must match `DATABASE_URL`. - -Keycloak must remain an external runtime dependency and must not be added to `docker-compose.yml` in this repository. - ---- - -# Step 7 — Migration - -Apply schema to the development database: - -```bash -cd server -npx prisma migrate dev -``` - ---- - -# Step 8 — Seed - -Run development seed: - -```bash -cd server -npx prisma db seed -``` - -Seed file location: `server/prisma/seed.ts`. - ---- - -# Step 9 — Validation - -Run runtime, auth, and contract checks from `generation/post-generation-validation.md`, including: - -- docker-compose exists and DB container starts -- Prisma lifecycle commands succeed -- seed runs -- `/health` responds without auth -- protected routes reject unauthenticated requests with `401` -- authenticated users with insufficient role receive `403` -- React Admin-compatible API responses include `id` for every record -- natural-key entities translate React Admin `_sort=id` to the real primary key field diff --git a/generation/dev-workflow.md b/generation/dev-workflow.md deleted file mode 100644 index 496f829..0000000 --- a/generation/dev-workflow.md +++ /dev/null @@ -1,137 +0,0 @@ -# Developer Workflow - -This document describes the **developer workflow** for running a generated fullstack application locally. The generator must produce a project that supports this workflow so the app is **fully runnable** after generation, including authentication. - -This workflow assumes the project was generated from `domain/*.dsl` plus optional non-duplicating overrides only. Developers must not need to prepare separate DTO/API/UI DSL inputs before running the app. - ---- - -# Prerequisites - -- **Node.js** (LTS, e.g. 18+) -- **npm** -- **Docker** and **Docker Compose** (for the development database) -- **External Keycloak server** reachable by the generated frontend and backend - -The generated project must not require the developer to invent auth wiring manually after generation. - ---- - -# Workflow Steps - -## 1. Prepare Keycloak and env files - -From the project root, the generated project must include: - -- root-level generated Keycloak realm import artifact - - repository default example filename: `toir-realm.json` -- `server/.env.example` -- `client/.env.example` - -Required workflow: - -1. Copy the generated env examples to real env files as needed. -2. Fill all required backend and frontend auth variables. -3. Import or verify the generated Keycloak realm import artifact in the external Keycloak server before starting the app. - -The generator must document that auth config is fail-fast. Missing required auth env vars must stop startup instead of silently falling back to production values in code. - ---- - -## 2. Start the database - -From the **project root**: - -```bash -docker compose up -d -``` - -This starts the PostgreSQL container defined in `docker-compose.yml`. Wait a few seconds for the database to accept connections. - -Verify if needed: - -```bash -docker compose ps -``` - ---- - -## 3. Backend setup and start - -From the **server** directory: - -```bash -cd server -npm install -npx prisma generate -npx prisma migrate dev -npx prisma db seed -npm run start -``` - -- `npm install` installs dependencies and runs `postinstall` when configured. -- `npx prisma generate` explicitly generates Prisma client. -- `npx prisma migrate dev` creates/applies migrations. -- `npx prisma db seed` inserts minimal development data. -- `npm run start` starts the NestJS backend. - -The API should be available at the configured port (for example `http://localhost:3000`). - -Verify: - -```bash -curl http://localhost:3000/health -``` - -Expected: `{ "status": "ok" }` (or equivalent). - -Generated backend behavior must also ensure: - -- protected CRUD routes require authentication by default -- insufficient roles result in `403` -- `/health` remains public - ---- - -## 4. Frontend setup and start - -In a **separate terminal**, from the **project root**: - -```bash -cd client -npm install -npm run dev -``` - -- `npm install` installs frontend dependencies including `keycloak-js`. -- `npm run dev` starts the Vite dev server (for example `http://localhost:5173`). - -Open the frontend URL in a browser. The generated React Admin app must: - -- initialize Keycloak before render -- use redirect-based login only -- authenticate against the configured Keycloak realm/client -- call the backend with bearer tokens through the shared request seam - ---- - -# Summary - -| Step | Command / location | -|------|---------------------| -| Prepare Keycloak + env | Fill `server/.env` and `client/.env`; import or verify the generated realm import artifact | -| Start database | From root: `docker compose up -d` | -| Backend setup/start | `cd server && npm install && npx prisma generate && npx prisma migrate dev && npx prisma db seed && npm run start` | -| Frontend setup/start | `cd client && npm install && npm run dev` | - -The generator must produce all required artifacts so that this workflow succeeds: - -- docker-compose -- env examples -- schema -- migrations -- seed -- health endpoint -- frontend auth integration -- backend auth infrastructure -- root-level Keycloak realm import artifact diff --git a/generation/frontend-generation.md b/generation/frontend-generation.md deleted file mode 100644 index f59e1b8..0000000 --- a/generation/frontend-generation.md +++ /dev/null @@ -1,183 +0,0 @@ -# Frontend Generation Process - -Frontend generation must produce the React Admin CRUD application **and** the default Keycloak integration required by the runtime architecture. - -Follow: - -- `frontend/architecture.md` -- `frontend/react-admin-rules.md` -- `auth/keycloak-architecture.md` -- `auth/frontend-auth-rules.md` -- `auth/keycloak-realm-template-rules.md` - ---- - -# Input Contract - -Required input: - -- `domain/*.dsl` - -Optional extension input: - -- `overrides/ui-overrides.dsl` - -Optional extension layout: - -```text -overrides/ - ui-overrides.dsl -``` - -Rules: - -- Parse `domain/*.dsl` as the only authoritative DSL input. -- Generate React Admin resources, views, and field mappings automatically from the parsed domain model. -- The generator must work when `overrides/ui-overrides.dsl` is absent. -- Optional overrides may refine derived UI behavior but must not redefine entities, attributes, primary keys, foreign keys, relations, or enums. -- Supplemental UI DSL inputs must not participate in frontend parsing, dependency resolution, or frontend generation decisions. -- Do not read a standalone UI DSL file. - ---- - -# Step 1 — Parse Domain DSL - -Extract: - -- entities -- attributes -- relation fields required for reference inputs and reference displays - -All frontend resources, fields, references, routes, and type-driven widget choices must be derived from the parsed domain DSL before optional overrides are considered. - -If `overrides/ui-overrides.dsl` exists, process it only after the domain model has been parsed and only as optional non-authoritative metadata. - -The generator must treat auth as default frontend infrastructure rather than as an optional feature. - ---- - -# Step 2 — Generate frontend runtime structure - -Generate the base frontend structure required by the proven runtime: - -- `client/src/main.tsx` -- `client/src/App.tsx` -- `client/src/dataProvider.ts` -- `client/src/config/env.ts` -- `client/src/auth/keycloak.ts` -- `client/src/auth/authProvider.ts` -- `client/.env.example` - -The generated frontend must: - -- initialize Keycloak before rendering the SPA -- register React Admin with a mandatory `authProvider` -- enforce authenticated operation rather than anonymous operation -- use environment-driven runtime config and fail fast when required auth vars are missing - ---- - -# Step 3 — Generate React Admin resources - -For each entity create: - -- `EntityList.tsx` -- `EntityCreate.tsx` -- `EntityEdit.tsx` -- `EntityShow.tsx` - -Resource generation must remain compatible with the auth-aware shared request seam and must be derived from domain metadata rather than a separate UI DSL. - ---- - -# Step 4 — Map fields - -Map DSL attributes to React Admin components according to existing field rules and relation semantics. - -Minimum type mapping: - -- `string`, `text` -> `TextInput`, `TextField` -- `integer`, `decimal` -> `NumberInput`, `NumberField` -- `date` -> `DateInput`, `DateField` -- `enum` -> `SelectInput` -- foreign key -> `ReferenceInput`, `ReferenceField` - -Reference/resource lookups must continue to flow through the same shared authenticated request layer used by the main CRUD resources. - ---- - -# Step 5 — Generate auth-aware API layer - -Generate a shared `dataProvider.ts` that: - -- reads the API base URL from `VITE_API_URL` -- attaches `Authorization: Bearer ` to every backend request -- uses the same request seam for all React Admin operations, including: - - list - - get one - - get many - - get many reference - - create - - update - - delete -- handles token refresh before protected requests -- keeps token refresh concurrency-safe by sharing one in-flight refresh operation -- does not store access tokens or refresh tokens in `localStorage` or `sessionStorage` - -The generator must not create multiple competing HTTP clients for authenticated and unauthenticated traffic. The shared request seam is the single bearer injection point. - ---- - -# Step 6 — Generate frontend auth flow - -Generate Keycloak frontend integration with these required rules: - -- use `keycloak-js` -- redirect-based login only -- no custom in-app username/password login form -- Authorization Code + PKCE with `S256` -- initialize Keycloak before React render -- provide a React Admin `authProvider` -- derive `authProvider.getIdentity()` from token claims already present in the parsed token -- prefer `sub`, `preferred_username`, `email`, and `name` for identity resolution -- do not call `keycloak.loadUserProfile()` by default -- do not rely on the Keycloak `/account` endpoint for baseline CRUD/admin generation -- avoid the `/account` request entirely by default rather than broadening Keycloak CORS behavior -- distinguish auth failures from authorization failures: - - `401` -> force logout / re-authentication - - `403` -> do not re-authenticate; surface access denied / permission error - -The generator must not silently fall back to production auth settings in code. Example values belong in `.env.example`, but runtime config must fail fast if required values are absent. - ---- - -# Step 7 — Register resources and auth - -Register resources in `App.tsx` and wire them into an authenticated `Admin` root. - -Generated `App.tsx` must: - -- register the shared `dataProvider` -- register the mandatory `authProvider` -- configure the app so it does not operate anonymously once auth is enabled - -Example shape: - -```tsx - - - -``` - ---- - -# Step 8 — Frontend runtime config - -Generate frontend env examples and config access for: - -- `VITE_API_URL` -- `VITE_KEYCLOAK_URL` -- `VITE_KEYCLOAK_REALM` -- `VITE_KEYCLOAK_CLIENT_ID` - -`client/.env.example` must use filled example values that match the documented Keycloak and local dev topology, while runtime code must fail fast if any required variable is missing. diff --git a/generation/post-generation-validation.md b/generation/post-generation-validation.md deleted file mode 100644 index 252079a..0000000 --- a/generation/post-generation-validation.md +++ /dev/null @@ -1,271 +0,0 @@ -# Post-Generation Validation - -After generating the backend or fullstack application, run these checks to ensure the project will start cleanly and that the default auth model has been generated correctly. - ---- - -# Validation Checklist - -## Source-of-truth input contract - -- [ ] Fullstack generation succeeds when `domain/*.dsl` is the only required DSL input. -- [ ] The generator can produce backend and frontend outputs from `domain/*.dsl` alone before optional overrides are considered. -- [ ] DTO, API, and UI artifacts are derived automatically from the domain model, keys, relations, and enums. -- [ ] Optional override files are not required for a successful generation run. -- [ ] Optional overrides, if present, refine only derived API/UI output and do not duplicate the domain model. -- [ ] No generator step depends on duplicated domain structures outside `domain/*.dsl`. - -**Failure symptoms:** generation requires extra DSL inputs, generated layers drift from the domain model, or the pipeline fails when override files are absent. - -## 1. Frontend and backend env files - -- [ ] `server/.env.example` exists and documents: - - `PORT` - - `DATABASE_URL` - - `CORS_ALLOWED_ORIGINS` - - `KEYCLOAK_ISSUER_URL` - - `KEYCLOAK_AUDIENCE` - - optional `KEYCLOAK_JWKS_URL` -- [ ] `client/.env.example` exists and documents: - - `VITE_API_URL` - - `VITE_KEYCLOAK_URL` - - `VITE_KEYCLOAK_REALM` - - `VITE_KEYCLOAK_CLIENT_ID` -- [ ] Runtime code fails fast when required auth or database env vars are missing. -- [ ] Runtime code does not silently fall back to production auth settings. - -**Failure symptoms:** startup succeeds with undefined auth config, or fails later with opaque auth/runtime errors. - ---- - -## 2. Git ignore hygiene - -- [ ] Root `.gitignore` exists. -- [ ] `server/.gitignore` exists. -- [ ] `client/.gitignore` exists. -- [ ] Generated gitignore rules exclude local dependency directories such as `node_modules/`. -- [ ] Generated gitignore rules exclude build artifacts such as `dist/` and `dist-ssr/`. -- [ ] Generated gitignore rules exclude local env files such as `.env`, `.env.local`, and `.env.*.local`. -- [ ] Generated gitignore rules exclude `coverage/` and `*.tsbuildinfo`. -- [ ] Generated gitignore rules do **not** exclude committed project artifacts such as source files, docs, and `.env.example`. - -**Failure symptoms:** `npm install`, local builds, or local env setup explode git status with thousands of files that should remain untracked. - ---- - -## 3. Keycloak realm artifact - -- [ ] A root-level generated Keycloak realm import artifact exists. -- [ ] If the repository default filename `toir-realm.json` is not used, the project-specific equivalent is documented consistently across bootstrap and workflow docs. -- [ ] The generated realm artifact is self-contained and reproducible. -- [ ] The generated realm artifact parameterizes realm name, frontend client ID, backend audience/client ID, production URLs, and artifact filename consistently with the generated project auth config. -- [ ] It defines realm roles: - - `admin` - - `editor` - - `viewer` -- [ ] It defines a frontend SPA client consistent with the generated frontend Keycloak client ID. -- [ ] It defines a backend audience/resource client consistent with the generated backend audience/client ID. -- [ ] It defines explicit audience delivery, such as an `api-audience` client scope. -- [ ] It does not rely on undeclared built-in client scopes being present after import. -- [ ] It explicitly addresses delivery of: - - `sub` - - `aud` - - `realm_access.roles` - -**Failure symptoms:** access tokens are missing required claims, or realm import succeeds but generated apps still cannot authenticate/authorize reliably. - ---- - -## 4. Frontend auth files and behavior - -- [ ] Generated frontend includes: - - `client/src/config/env.ts` - - `client/src/auth/keycloak.ts` - - `client/src/auth/authProvider.ts` - - `client/src/main.tsx` - - `client/src/App.tsx` - - `client/src/dataProvider.ts` -- [ ] `keycloak-js` is installed. -- [ ] Keycloak is initialized before the SPA renders. -- [ ] Login is redirect-based only. -- [ ] No custom in-app username/password login form is generated. -- [ ] `Authorization Code + PKCE (S256)` is encoded in the frontend auth flow. -- [ ] `client/src/dataProvider.ts` or the documented shared request seam injects `Authorization: Bearer ` into all API requests. -- [ ] `authProvider.getIdentity()` derives identity from parsed token claims such as `sub`, `preferred_username`, `email`, and `name`. -- [ ] Generated frontend auth code does not call `keycloak.loadUserProfile()`. -- [ ] Generated frontend auth code does not rely on the Keycloak `/account` endpoint for baseline CRUD/admin generation. -- [ ] Token refresh is concurrency-safe: - - one shared in-flight refresh operation - - no parallel refresh stampede -- [ ] Generated auth code does not persist access tokens or refresh tokens in `localStorage` or `sessionStorage`. -- [ ] React Admin auth semantics distinguish: - - `401` -> force logout / re-authentication - - `403` -> do not re-authenticate; surface access denied / permission error - -**Failure symptoms:** app renders before auth is ready, reference calls miss auth headers, refresh storms occur, or `403` incorrectly forces logout. - ---- - -## 5. Backend auth files and behavior - -- [ ] Generated backend includes: - - `server/src/auth/auth.module.ts` - - JWT guard - - roles guard - - `@Public()` decorator - - `@Roles()` decorator - - typed authenticated principal interface - - typed config validation in `server/src/config/` -- [ ] `jose` is installed. -- [ ] JWT verification uses issuer + audience + JWKS. -- [ ] JWKS resolution follows this exact priority: - 1. `KEYCLOAK_JWKS_URL` - 2. OIDC discovery - 3. `${issuer}/protocol/openid-connect/certs` -- [ ] Authorization roles are extracted only from `realm_access.roles`. -- [ ] Deprecated Keycloak-specific Node adapters are not used. - -**Failure symptoms:** invalid tokens are accepted, valid tokens are rejected due to bad JWKS resolution, or RBAC depends on unstable/non-standard claims. - ---- - -## 6. CRUD protection and RBAC defaults - -- [ ] `/health` is public. -- [ ] Each generated CRUD controller method other than explicit public routes is protected by the generated auth/RBAC infrastructure. -- [ ] CRUD RBAC defaults are present: - - `GET` -> `viewer | editor | admin` - - `POST`, `PATCH`, `PUT` -> `editor | admin` - - `DELETE` -> `admin` -- [ ] Unauthenticated request to a protected route returns `401`. -- [ ] Authenticated user with insufficient role receives `403`. - -**Failure symptoms:** anonymous CRUD access remains open, or insufficient-role users are not denied properly. - ---- - -## 7. PrismaService implementation - -- [ ] A `PrismaService` (or equivalent) class exists and extends `PrismaClient`. -- [ ] It implements `OnModuleInit` and calls `await this.$connect()` in `onModuleInit()`. -- [ ] It does not use `this.$on('beforeExit', ...)` or any `beforeExit` hook. - -**Failure symptom:** deprecation/runtime errors in Prisma 5 when using `beforeExit`. - -**Reference:** `backend/prisma-service.md` - ---- - -## 8. Prisma client lifecycle - -- [ ] `package.json` includes a script that runs Prisma client generation: - - either `"postinstall": "prisma generate"` (or `npx prisma generate`) - - or clear documentation to run `npx prisma generate` after install -- [ ] After schema generation or change, `npx prisma generate` has been run or will run via `postinstall`. - -**Failure symptom:** `Cannot find module '@prisma/client'` or missing generated types. - ---- - -## 9. Database migration - -- [ ] Migration workflow is documented. -- [ ] Instruction to run `npx prisma migrate dev` exists after first generation or schema change. -- [ ] Generation pipeline includes or documents the migration step. - -**Failure symptom:** tables do not exist; Prisma errors on first query. - ---- - -## 10. REST route parameters - -- [ ] For each entity, path parameters use the correct primary key name from the DSL. -- [ ] Entity with PK `id` uses `/:id`. -- [ ] Entity with non-`id` primary key (for example `code`) uses the actual PK name such as `/:code`, not `/:id`. - -**Failure symptom:** controller expects one param name while the route defines another, leading to broken CRUD behavior. - -**Reference:** `backend/architecture.md` - ---- - -## 11. DTO type mapping and React Admin ID compatibility - -- [ ] DSL `decimal` maps to DTO/API `string`. -- [ ] DSL `date` maps to DTO/API `string` (ISO) or equivalent string serialization. -- [ ] Every API response object contains a field named `id`. -- [ ] If the entity primary key is not named `id`, the response maps the primary key to `id`. -- [ ] For entities with non-`id` primary keys, backend list/query logic translates React Admin `_sort=id` to the real primary key field. -- [ ] Generated ORM `orderBy` clauses never reference synthetic `id` when the underlying model field does not exist. - -**Failure symptoms:** serialization issues for decimals/dates, or React Admin cannot identify records. - ---- - -## 12. Update payload sanitization - -- [ ] Update endpoints do not pass `id` or the primary key in Prisma `data`. -- [ ] Generated update methods remove `id`, the entity primary key, and readonly attributes before calling `prisma.*.update()`. - -**Failure symptom:** Prisma throws because immutable or invalid fields are passed in update `data`. - ---- - -## 13. Database runtime - -- [ ] `docker-compose.yml` exists at the project root. -- [ ] It defines a PostgreSQL service with image `postgres:16`, port `5432`, and credentials matching `DATABASE_URL`. -- [ ] `docker compose up -d` starts the database successfully. -- [ ] `docker-compose.yml` does not add Keycloak in this repository. - -**Failure symptom:** Prisma cannot reach the development database or repo topology drifts from the documented external-Keycloak model. - ---- - -## 14. Migrations, seed, and health endpoint - -- [ ] `npx prisma migrate dev` runs successfully from `server/`. -- [ ] Seed script exists at `server/prisma/seed.ts` (or equivalent). -- [ ] `npx prisma db seed` runs without error. -- [ ] Backend exposes `GET /health`. -- [ ] `GET /health` returns HTTP 200 with a status payload. - -**Failure symptom:** development bootstrap cannot complete end-to-end. - ---- - -# Summary Table - -| Check | Required artifact / rule | -| --- | --- | -| Frontend env | `client/.env.example` with required Vite auth vars | -| Backend env | `server/.env.example` with DB, CORS, and Keycloak vars | -| Git ignore | Root/server/client `.gitignore` exclude local-only artifacts | -| Fail-fast config | Startup fails when required auth env is missing | -| Realm artifact | Root generated realm import artifact with self-contained auth setup | -| Frontend auth | `keycloak.ts`, `authProvider.ts`, authenticated `dataProvider.ts` | -| Frontend identity | Token-claim based `getIdentity()`; no `loadUserProfile()` / `/account` dependency | -| Backend auth | `AuthModule`, guards, decorators, typed principal | -| JWKS strategy | explicit URL -> discovery -> certs fallback | -| Role source | `realm_access.roles` only | -| CRUD RBAC | GET viewer/editor/admin; write editor/admin; delete admin | -| `/health` | Public and returns 200 | -| Protected route unauthenticated | Returns `401` | -| Protected route insufficient role | Returns `403` | -| Token storage | No `localStorage` / `sessionStorage` persistence | -| Token refresh | Concurrency-safe single in-flight refresh | -| Prisma lifecycle | `OnModuleInit` + `$connect()`, no `beforeExit` | -| Update sanitization | Strip `id` / PK / readonly before Prisma update | -| React Admin `id` | Every record includes `id` | -| Natural-key sorting | Map React Admin `_sort=id` to the real primary key field | -| Database runtime | PostgreSQL compose exists and starts | - ---- - -# Integration with generation pipeline - -1. Backend and frontend generation must produce artifacts that satisfy the above by default. -2. The generator must successfully build the fullstack app from `domain/*.dsl` alone; optional overrides may refine output but cannot be required. -3. Runtime bootstrap must include Keycloak realm import/verification before app startup. -4. After generation, run this checklist manually or via an automated script. -5. If any check fails, update the generator context so future runs pass without manual repair. diff --git a/generation/runtime-bootstrap.md b/generation/runtime-bootstrap.md deleted file mode 100644 index 970ba8b..0000000 --- a/generation/runtime-bootstrap.md +++ /dev/null @@ -1,122 +0,0 @@ -# Runtime Bootstrap - -After project generation, the following commands and prerequisites must work in order so that the application runs without ad-hoc auth or database setup. - -The generator must produce a **runnable development environment** consisting of: - -- external Keycloak identity provider -- backend API -- frontend SPA -- PostgreSQL database - -Runtime bootstrap assumes the application was generated from `domain/*.dsl` plus optional non-duplicating overrides only. There is no separate DTO/API/UI DSL bootstrap step. - -The generator must also produce the runtime artifacts required to bootstrap auth from zero, including a root-level Keycloak realm import artifact. The repository default example filename is `toir-realm.json`, but future generations must allow a project-specific equivalent. - ---- - -# Bootstrap Sequence - -## 1. Prepare Keycloak - -Before starting the application, ensure an external Keycloak server is reachable and import or verify the generated realm artifact: - -- root-level generated Keycloak realm import artifact -- repository default example filename: `toir-realm.json` - -The runtime instructions must require importing or verifying this realm before frontend/backend startup. - -Keycloak bootstrap expectations: - -- realm import is self-contained -- frontend client exists -- backend audience client exists -- realm roles exist -- issued access tokens reliably contain `sub`, `aud`, and `realm_access.roles` - -The generator must not rely on undeclared built-in client scopes magically existing after realm import. - ---- - -## 2. Start the database - -From the **project root**: - -```bash -docker compose up -d -``` - -This starts the PostgreSQL container. The backend connects to it using `DATABASE_URL` from `server/.env`. - ---- - -## 3. Backend setup and start - -```bash -cd server -npm install -npx prisma generate -npx prisma migrate dev -npx prisma db seed -npm run start -``` - -- `npm install` installs dependencies and runs `postinstall` if configured. -- `npx prisma generate` ensures Prisma client is generated explicitly after install or schema changes. -- `npx prisma migrate dev` creates/applies migrations. -- `npx prisma db seed` inserts minimal development data. -- `npm run start` starts the NestJS server. - -Backend startup requirements: - -- required database and auth env vars must be present -- startup must fail fast if required env vars are missing -- CORS must support the SPA -> API bearer-token model -- `/health` must remain public - ---- - -## 4. Frontend setup and start - -In a separate terminal, from the **project root**: - -```bash -cd client -npm install -npm run dev -``` - -- `npm install` installs frontend dependencies including `keycloak-js`. -- `npm run dev` starts the Vite dev server. - -Frontend startup requirements: - -- required Vite auth vars must be present -- startup must fail fast if required auth vars are missing -- Keycloak must initialize before the SPA is rendered -- login must use redirect-based Keycloak authentication only - ---- - -# Success Criteria - -After completing the bootstrap sequence: - -- Keycloak is reachable and the generated realm has been imported or verified. -- Database container is running and Prisma can connect. -- Backend responds on `/health` without auth. -- Protected backend routes require a valid bearer token. -- Frontend loads, redirects through Keycloak login when needed, and uses bearer auth for all API calls. - -The generator is responsible for producing all artifacts and instructions needed for this sequence: - -- `docker-compose.yml` -- schema -- migrations -- seed -- backend/frontend env examples -- health endpoint -- auth infrastructure -- root-level Keycloak realm import artifact - -Docker scope must remain limited to PostgreSQL. Keycloak remains an external dependency in this repository. diff --git a/generation/scaffolding-rules.md b/generation/scaffolding-rules.md deleted file mode 100644 index a2fd4b7..0000000 --- a/generation/scaffolding-rules.md +++ /dev/null @@ -1,114 +0,0 @@ -# Project Scaffolding Rules - -The generator must use **official CLI tools** to create base project structures. - -The AI must **not** manually generate the entire project skeleton by hand. CLI scaffolding reduces drift and keeps the generated project aligned with current NestJS and Vite conventions. - -Auth is part of the default generated runtime. Scaffolding must therefore install the required frontend and backend auth dependencies during the normal project bootstrap path. - ---- - -# Backend Scaffolding - -Use **NestJS CLI**. - -## Command - -```bash -npx @nestjs/cli@10.3.2 new server --package-manager npm --skip-git -``` - -## Rules - -- **Project directory** must be `server`. -- **TypeScript** must be used. -- **npm** must be the package manager. -- **Git** initialization must be skipped. - -## After scaffolding — install required dependencies - -Run from the `server` directory: - -```bash -npm install @prisma/client -npm install @nestjs/config -npm install jose -npm install prisma --save-dev -``` - -## Backend auth dependency rules - -- `jose` must be installed by default because JWT verification is part of the default generated backend. -- The generator must **not** install deprecated Keycloak-specific Node adapters such as `keycloak-connect`. - ---- - -# Frontend Scaffolding - -Use **Vite CLI**. - -## Command - -```bash -npm create vite@5.2.0 client -- --template react-ts -``` - -## Rules - -- **Project directory** must be `client`. -- **React + TypeScript** template must be used. - -## After scaffolding — install required dependencies - -Run from the `client` directory: - -```bash -npm install react-admin -npm install ra-data-simple-rest -npm install @mui/material @emotion/react @emotion/styled -npm install keycloak-js -``` - -## Frontend auth dependency rules - -- `keycloak-js` must be installed by default because redirect-based Keycloak login is part of the default generated frontend. -- The generated frontend must use `keycloak-js` for Authorization Code + PKCE and must not generate a custom in-app username/password login form. - ---- - -# Scaffolding Strategy - -Generation pipeline order: - -1. **Parse DSL** — Read `domain/*.dsl` as the single required input. If present, optional override files under `overrides/` may be applied after domain parsing, but DTO/API/UI DSL files must not be required. -2. **Run CLI scaffolding** — Create `server` with NestJS CLI and `client` with Vite CLI; install runtime and auth dependencies listed above. -3. **Code generation** — Generate Prisma schema, NestJS modules/DTOs/PrismaService/auth infrastructure, and React Admin resources/auth integration. -4. **Runtime infrastructure** — Generate backend/frontend `.env.example`, root/package `.gitignore` files, runtime config files, lifecycle scripts, and a root-level Keycloak realm import artifact (repository default example filename: `toir-realm.json`). -5. **Database runtime** — Generate `docker-compose.yml` in project root with PostgreSQL service (`postgres`, image `postgres:16`, port `5432:5432`). -6. **Migration** — Apply schema with `npx prisma migrate dev`. -7. **Seed** — Populate minimal development data with `npx prisma db seed`. -8. **Validation** — Run checks from `generation/post-generation-validation.md`, including auth validation and realm-template validation. - -Scaffolding (steps 1–2) must be done with the CLI. Steps 3–8 must be generated from `domain/*.dsl`, optional non-duplicating overrides in `overrides/`, and the project context documents, including the auth-specific context in `auth/*.md`. -There is no separate DTO/API/UI DSL preparation step in the scaffolding workflow. - -## Git ignore rules - -The generated project must include: - -- root `.gitignore` -- `server/.gitignore` -- `client/.gitignore` - -These files must keep local-only artifacts out of git, including at minimum: - -- `node_modules/` -- `dist/` -- `dist-ssr/` -- `coverage/` -- `*.tsbuildinfo` -- `.env` -- `.env.local` -- `.env.*.local` - -The generator must not ignore committed source, documentation, or `.env.example` files. diff --git a/generation/update-strategy.md b/generation/update-strategy.md deleted file mode 100644 index fb643a4..0000000 --- a/generation/update-strategy.md +++ /dev/null @@ -1,47 +0,0 @@ -# Update Strategy - -When the DSL changes, regeneration must preserve the default auth-enabled runtime rather than falling back to CRUD-only output. - -`domain/*.dsl` remains the single required source of truth for regeneration. DTOs, API contracts, and React Admin resources must be re-derived from it on every run. Optional overrides in `overrides/api-overrides.dsl` and `overrides/ui-overrides.dsl` may be applied after derivation, but they must never duplicate or redefine the domain model. -Regeneration must not resurrect or depend on supplemental DTO/API/UI DSL inputs. Every derived layer must be recalculated from `domain/*.dsl` plus optional non-duplicating overrides only. - -## Required regeneration sequence - -1. Regenerate `prisma/schema.prisma`. -2. Run `npx prisma migrate dev`. -3. Regenerate NestJS entity modules, DTOs, controllers, and services. -4. Regenerate backend auth infrastructure: - - `AuthModule` - - guards - - decorators - - typed authenticated principal - - typed config validation - - CRUD RBAC decorations -5. Regenerate React Admin resources. -6. Regenerate frontend auth infrastructure: - - `src/config/env.ts` - - `src/auth/keycloak.ts` - - `src/auth/authProvider.ts` - - authenticated `dataProvider.ts` - - `App.tsx` auth wiring - - `main.tsx` init-before-render flow -7. Regenerate backend and frontend `.env.example` files so the auth env contract stays in sync. -8. Regenerate root/package `.gitignore` files so local-only artifacts remain out of git after regeneration. -9. Regenerate the root-level Keycloak realm import artifact. The repository default example filename is `toir-realm.json`, but the generator must allow a project-specific equivalent. -10. Re-run post-generation validation, including: - - gitignore coverage for dependency, build, env, coverage, and tsbuildinfo artifacts - - auth dependency checks - - fail-fast env checks - - token-claim based identity with no `loadUserProfile()` / `/account` dependency - - `/health` public check - - unauthenticated protected route -> `401` - - insufficient role -> `403` - - natural-key `_sort=id` mapping checks - - realm-template validation - -## Guardrails - -- Regeneration must not strip auth back out of the project. -- Auth remains outside the DSL grammar, but it is part of the default generated runtime. -- If a DSL change affects entities or routes, the generator must re-apply the default CRUD RBAC rules automatically. -- If a DSL change affects runtime topology or naming, the generator must keep backend/frontend env examples, CORS rules, and the generated realm import artifact aligned with the generated app. diff --git a/overrides/api-overrides.dsl b/overrides/api-overrides.dsl new file mode 100644 index 0000000..0a02c8a --- /dev/null +++ b/overrides/api-overrides.dsl @@ -0,0 +1,2 @@ +// Optional overrides: +// resource EquipmentType path "equipment-types"; diff --git a/overrides/ui-overrides.dsl b/overrides/ui-overrides.dsl new file mode 100644 index 0000000..048f321 --- /dev/null +++ b/overrides/ui-overrides.dsl @@ -0,0 +1,2 @@ +// Optional overrides: +// field EquipmentType.code widget "text"; diff --git a/package.json b/package.json new file mode 100644 index 0000000..56ef58c --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "name": "toir-generation-context", + "private": true, + "scripts": { + "generate:domain-summary": "node tools/generate-domain-summary.mjs", + "validate:generation": "node tools/validate-generation.mjs", + "validate:generation:runtime": "node tools/validate-generation.mjs --run-runtime", + "validate:generation:artifacts": "node tools/validate-generation.mjs --artifacts-only" + } +} diff --git a/prompts/auth-rules.md b/prompts/auth-rules.md new file mode 100644 index 0000000..2d4be3c --- /dev/null +++ b/prompts/auth-rules.md @@ -0,0 +1,79 @@ +# Auth Rules + +This repository keeps the current LLM-first CRUD generation architecture as the primary working baseline. + +- Auth is part of the default generation path, not a post-generation addon. +- `server/` is the active backend target output path. +- `client/` is the active frontend target output path. +- The generated runtime stays SPA + API + external Keycloak + PostgreSQL only. + +## Frontend auth invariants + +- Use `keycloak-js` with redirect-based login only. +- Initialize Keycloak before rendering the SPA. +- Use Authorization Code Flow + PKCE (`S256`). +- Keep `authProvider`, `dataProvider`, `getIdentity()`, `getPermissions()`, and `checkError()` as stable provider seams. +- Derive identity from token claims already present in the parsed token. +- Do not call `loadUserProfile()` and do not depend on `/account` for the baseline app. +- `401` must force re-authentication; `403` must stay an authorization error. +- Keep token handling in memory and refresh through one shared in-flight operation. + +## Working runtime defaults + +Use the already working project defaults unless a prompt explicitly overrides them. + +- Frontend Keycloak base URL example: + - `VITE_KEYCLOAK_URL=https://sso.greact.ru` +- Frontend realm and client example: + - `VITE_KEYCLOAK_REALM=toir` + - `VITE_KEYCLOAK_CLIENT_ID=toir-frontend` +- Backend issuer and audience example: + - `KEYCLOAK_ISSUER_URL=https://sso.greact.ru/realms/toir` + - `KEYCLOAK_AUDIENCE=toir-backend` +- CORS example: + - `CORS_ALLOWED_ORIGINS=http://localhost:5173,https://toir-frontend.greact.ru` + +Anti-regression rule: + +- Do not switch the baseline Keycloak example back to `http://localhost:8080` in generated `.env.example` files unless the prompt explicitly asks for a local Keycloak runtime. +- Localhost Keycloak values may exist in private `.env.local` or `.env` overrides, but they are not the default project examples. + +## Backend auth invariants + +- Verify JWTs with `jose`. +- Validate issuer + audience + signature via JWKS. +- Resolve JWKS in this order: + 1. `KEYCLOAK_JWKS_URL` + 2. OIDC discovery at `/.well-known/openid-configuration` + 3. `${issuer}/protocol/openid-connect/certs` +- Extract roles only from `realm_access.roles`. +- Keep `/health` public and all generated CRUD routes protected by default. + +## Auth anti-regression invariants + +- The accepted JWKS resolution chain above is the only baseline truth path. Do not document one order and implement another. +- If auth implementation changes, `prompts/auth-rules.md` and `prompts/validation-rules.md` must be updated in the same change. +- Do not skip OIDC discovery when no explicit `KEYCLOAK_JWKS_URL` is provided. +- Do not switch role extraction to alternative claims unless the prompt explicitly changes the baseline contract. +- Do not reintroduce localhost Keycloak defaults into shared baseline examples. + +## Realm artifact contract + +- A physical root-level `*-realm.json` artifact is mandatory output. +- The artifact must be importable, versioned, and aligned with generated backend/frontend env contracts. +- It must parameterize: + - realm name + - frontend client id + - backend client id / audience + - local and production frontend URLs + - artifact filename +- It must explicitly deliver: + - `sub` + - `aud` + - `realm_access.roles` +- It must define: + - realm roles `admin`, `editor`, `viewer` + - a public SPA client with PKCE S256 + - a bearer-only backend client + - an explicit audience client scope + - explicit protocol mappers for baseline identity and role claims diff --git a/prompts/backend-rules.md b/prompts/backend-rules.md new file mode 100644 index 0000000..cb4252f --- /dev/null +++ b/prompts/backend-rules.md @@ -0,0 +1,74 @@ +# Backend Rules + +The backend remains derived from `domain/*.dsl` inside the existing LLM-first pipeline. No compiler platform or generator engine is introduced. + +## Backend scaffold baseline + +- Start backend initialization from the official NestJS CLI workspace, not from manually created files. +- The backend must remain compatible with standard Nest workspace tooling such as `nest build` and `nest start`. +- Preserve the core Nest workspace files generated by the CLI, especially: + - `server/tsconfig.json` + - `server/tsconfig.build.json` + - `server/nest-cli.json` + - `server/src/main.ts` + - `server/src/app.module.ts` +- For domain resources, prefer official Nest CLI generation patterns for modules/controllers/services/resources and then adapt the generated code to Prisma and auth requirements. +- Do not delete required Nest workspace files just because the LLM can inline a smaller custom structure. + +## Forbidden backend generation patterns + +- Do not bootstrap `server/` by hand-writing a pseudo-Nest project from memory. +- Do not remove `tsconfig.json`, `tsconfig.build.json`, or `nest-cli.json` after generation. +- Do not replace standard Nest package scripts with ad hoc commands that break `nest build` or `nest start`. +- Do not continue CRUD generation on top of a degraded backend workspace without repairing the workspace first. + +## Domain-derived output + +- `domain/*.dsl` is the source of truth for entities, fields, primary keys, foreign keys, and enums. +- `domain-summary.json` is a derived artifact used to stabilize LLM generation and validation. It must never replace the DSL as the source of truth. +- Each entity becomes: + - a Prisma model + - a NestJS module + - a controller + - a service + - create/update DTOs + +## DTO and Prisma mapping + +- `decimal` -> Prisma `Decimal`, DTO/API `string` +- `date` -> Prisma `DateTime`, DTO/API `string` +- Enums remain string-valued in DTO/API contracts + +## CRUD and natural-key invariants + +- CRUD routes use the real primary key name in the path. +- Every API record returned to React Admin must include `id`. +- For entities whose primary key is not `id`, the backend must map the real key to `id`. +- Natural-key list/sort logic must never build ORM `orderBy` against a fake physical `id`. + +## Service invariants + +- Never pass raw update DTOs into Prisma update `data`. +- Remove `id`, the real primary key, and readonly fields from update payloads before calling Prisma. +- Keep PrismaService lightweight: + - extend `PrismaClient` + - implement `OnModuleInit` + - call `$connect()` + - do not use `beforeExit` + +## Reproducibility invariants + +- A freshly generated backend must be bootstrappable with ordinary Nest + Prisma commands from `prompts/runtime-rules.md`. +- Missing TypeScript or Nest workspace config is a generation failure, not an acceptable simplification. +- The baseline backend should fail only on missing runtime dependencies or env values, not because the Nest workspace itself is incomplete. + +## Recovery rule if backend workspace degraded + +- If required Nest scaffold files are missing or broken, restore the official workspace baseline before editing Prisma models, modules, controllers, services, or DTOs. +- Treat workspace repair as higher priority than feature generation, because generated domain code on top of a broken workspace is invalid baseline output. + +## Backend auth defaults + +- `GET` -> `viewer | editor | admin` +- `POST`, `PATCH`, `PUT` -> `editor | admin` +- `DELETE` -> `admin` diff --git a/prompts/frontend-rules.md b/prompts/frontend-rules.md new file mode 100644 index 0000000..aac402a --- /dev/null +++ b/prompts/frontend-rules.md @@ -0,0 +1,56 @@ +# Frontend Rules + +The frontend stays a React Admin SPA generated from `domain/*.dsl` and anchored to the existing auth seams. + +## Frontend scaffold baseline + +- Start frontend initialization from the official Vite React TypeScript scaffold, not from manually assembled files. +- Preserve a valid Vite workspace baseline, including: + - `client/index.html` + - `client/tsconfig.json` + - `client/vite.config.*` + - `client/src/main.tsx` +- Add React Admin and auth seams on top of that baseline instead of replacing the workspace with a hand-written minimal shell. +- Do not delete required Vite entry/config files just because the LLM can write a shorter custom setup. + +## Forbidden frontend generation patterns + +- Do not bootstrap `client/` by hand-writing a pseudo-Vite project from memory. +- Do not remove `index.html`, `tsconfig*`, or `vite.config.*` after generation. +- Do not replace standard Vite package scripts with ad hoc commands that break `vite build`, `vite dev`, or `vite preview`. +- Do not continue React Admin resource generation on top of a degraded frontend workspace without repairing the workspace first. + +## Resource generation + +- Each entity becomes a React Admin resource with list/create/edit/show views. +- Resource names must stay aligned with backend path segments. +- Foreign keys must use `ReferenceInput` / `ReferenceField`. + +## Provider seams + +- `client/src/dataProvider.ts` is the single authenticated request seam. +- `client/src/auth/authProvider.ts` is the single React Admin auth seam. +- Auth logic must not leak into resource components. + +## Identity and permissions + +- `getIdentity()` must resolve from parsed token claims. +- `getPermissions()` may expose realm roles for UI awareness. +- Backend enforcement remains authoritative. + +## React Admin compatibility + +- Every resource record must include `id`. +- Natural-key resources must preserve route, update, and sort compatibility with React Admin contracts. +- Frontend requests must continue to work when the real primary key is not named `id`. + +## Reproducibility invariants + +- A freshly generated frontend must remain compatible with standard Vite commands such as `npm run dev` and `npm run build`. +- Missing Vite workspace files or missing local Vite executable wiring is a generation failure, not an acceptable simplification. +- The generated frontend should fail only on missing installation/env/runtime backend availability, not because the Vite app structure itself is incomplete. + +## Recovery rule if frontend workspace degraded + +- If required Vite scaffold files are missing or broken, restore the official workspace baseline before editing resources, auth seams, or UI code. +- Treat workspace repair as higher priority than feature generation, because generated React Admin code on top of a broken Vite workspace is invalid baseline output. diff --git a/prompts/general-prompt.md b/prompts/general-prompt.md new file mode 100644 index 0000000..7c6be55 --- /dev/null +++ b/prompts/general-prompt.md @@ -0,0 +1,146 @@ +ROLE + +You are a Staff-level Fullstack Platform Engineer working inside the established LLM-first CRUD generation baseline. + +Use context7 when official framework guidance is needed. + +This repository is not a new generator engine. Do not redesign it into a planner/emitter/runtime platform. + +GOAL + +Strengthen and use the existing LLM-first CRUD generation pipeline. + +- Keep `domain/*.dsl` as the source of truth for the domain model. +- Keep `server/` as the active backend target output path. +- Keep `client/` as the active frontend target output path. +- Keep Keycloak auth in the default generation path. +- Keep PostgreSQL as the only Dockerized runtime dependency. +- Generate and maintain: + - `domain-summary.json` + - a root-level `*-realm.json` + - backend/frontend auth seams + - runtime/env/bootstrap artifacts + - validation-gate-compatible output + +Use the already working runtime defaults for this project unless the prompt explicitly overrides them: + +- frontend Keycloak URL example: `https://sso.greact.ru` +- frontend realm/client examples: `toir`, `toir-frontend` +- backend issuer/audience examples: `https://sso.greact.ru/realms/toir`, `toir-backend` +- production frontend origin example: `https://toir-frontend.greact.ru` + +Do not silently regress these examples to localhost Keycloak defaults. + +ACTIVE KNOWLEDGE BLOCKS + +Read in this order: + +1. `domain/dsl-spec.md` +2. `domain/*.dsl` +3. `domain-summary.json` if present +4. `prompts/auth-rules.md` +5. `prompts/backend-rules.md` +6. `prompts/frontend-rules.md` +7. `prompts/runtime-rules.md` +8. `prompts/validation-rules.md` + +Interpretation rules: + +- `domain/*.dsl` is authoritative. +- `domain-summary.json` is derived. Regenerate or validate it against the DSL; never treat it as the source of truth. +INPUT CONTRACT + +Required: + +- `domain/*.dsl` + +Optional: + +- `overrides/api-overrides.dsl` +- `overrides/ui-overrides.dsl` + +Rules: + +- Do not require DTO/API/UI DSL files. +- Do not resurrect multi-DSL source-of-truth behavior. +- Optional overrides may refine derived API/UI behavior but must not redefine the domain model. + +PIPELINE CONTRACT + +1. Parse `domain/*.dsl`. +2. Generate or refresh `domain-summary.json`. +3. Scaffold with official framework CLI conventions where scaffolding is needed. +4. Generate or maintain backend/frontend code in `server/` and `client/`. +5. Generate or maintain the root-level realm artifact. +6. Generate or maintain env/bootstrap/runtime artifacts. +7. Run the lightweight validation gate. + +REPAIR-BEFORE-GENERATE ORDER + +1. Inspect whether `server/` and `client/` are still valid framework workspaces. +2. If either workspace is degraded, repair the official scaffold baseline first. +3. Only after workspace repair, generate or update domain-derived feature code. +4. Only after generation, run validation and buildability checks. + +CLI-FIRST SCAFFOLDING CONTRACT + +- Never hand-write a fresh framework workspace from scratch when an official CLI exists for that framework. +- For `server/`, initialize the workspace from the official NestJS CLI baseline first, then generate/adapt modules/resources inside that workspace. +- For `client/`, initialize the workspace from the official Vite React TypeScript baseline first, then add React Admin, auth seams, and generated resources. +- Treat CLI scaffolding as the required baseline for compiler config, package scripts, workspace metadata, and default entry files. +- Manual creation is allowed only for domain-derived feature code after the official workspace exists. +- If a workspace already exists but is missing required CLI baseline files, repair it back to a valid official-style workspace before adding more generated code. + +ANTI-REGRESSION FAILURES + +Treat the following as baseline violations, not acceptable improvisations: + +- creating a NestJS workspace by manually writing `package.json`, `tsconfig*`, `nest-cli.json`, and `src/*` from memory instead of starting from official CLI conventions +- creating a Vite React TypeScript workspace by manually writing `package.json`, `index.html`, `tsconfig*`, `vite.config.*`, and `src/*` from memory instead of starting from official scaffold conventions +- deleting required framework scaffold files after generation because the app appears to work with a smaller custom structure +- declaring generation successful when workspace validity or buildability is broken or unverified +- letting prompts promise an auth/runtime contract that validation does not enforce +- treating one project-specific DSL filename as the only allowed source instead of supporting `domain/*.dsl` + +OUTPUT CONTRACT + +The baseline output must include: + +- `server/prisma/schema.prisma` +- `server/.env.example` +- `client/.env.example` +- `client/src/auth/keycloak.ts` +- `client/src/auth/authProvider.ts` +- `client/src/dataProvider.ts` +- `server/src/auth/*` +- `docker-compose.yml` +- `domain-summary.json` +- root-level `*-realm.json` + +The baseline output must also remain a real framework workspace, not a prompt-only file collection: + +- `server/tsconfig.json` +- `server/tsconfig.build.json` +- `server/nest-cli.json` +- `client/index.html` +- `client/tsconfig.json` +- `client/vite.config.*` + +NON-GOALS + +- No new generator engine +- No compiler/IR platform +- No heavy codegen redesign +- No replacement of the old LLM-first architecture + +COMPLETION INVARIANTS + +- Generation is incomplete if `server/` is not a valid NestJS workspace. +- Generation is incomplete if `client/` is not a valid Vite React TypeScript workspace. +- Generation is incomplete if auth rules, runtime rules, and validation rules describe different truth paths. +- Generation is incomplete if buildability is broken. +- If buildability cannot be checked because dependencies are missing, report that state explicitly; do not report a green result for buildability. + +VALIDATION + +Before considering the output complete, satisfy `prompts/validation-rules.md`. diff --git a/prompts/runtime-rules.md b/prompts/runtime-rules.md new file mode 100644 index 0000000..6ff930e --- /dev/null +++ b/prompts/runtime-rules.md @@ -0,0 +1,96 @@ +# Runtime Rules + +This repository keeps the current LLM-first CRUD generation architecture as the primary working baseline and strengthens the existing pipeline instead of replacing it. + +## Baseline runtime topology + +- `server/` is the active backend target output path. +- `client/` is the active frontend target output path. +- Docker scope remains PostgreSQL only. +- Keycloak remains external to the repository runtime. +- The project remains LLM-first: markdown knowledge blocks in `prompts/` orchestrate generation, while active generated/maintained code lives in `server/` and `client/`. + +## Required input and derived artifacts + +- Source of truth: + - `domain/*.dsl` +- Required derived artifacts: + - `domain-summary.json` + - root-level `*-realm.json` + +`domain-summary.json` exists to stabilize generation and validation; it must be regenerated from the DSL and treated as non-authoritative. + +## Output contract + +The strengthened baseline must produce and keep aligned: + +- `server/prisma/schema.prisma` +- backend/frontend env examples +- backend/frontend auth seams +- root `.gitignore`, `server/.gitignore`, `client/.gitignore` +- `docker-compose.yml` +- `domain-summary.json` +- root-level realm import artifact + +## Concrete runtime examples + +Use these as the baseline examples for this project unless the prompt explicitly overrides them: + +- Backend: + - `PORT=3000` + - `DATABASE_URL="postgresql://postgres:postgres@localhost:5432/toir"` + - `CORS_ALLOWED_ORIGINS="http://localhost:5173,https://toir-frontend.greact.ru"` + - `KEYCLOAK_ISSUER_URL="https://sso.greact.ru/realms/toir"` + - `KEYCLOAK_AUDIENCE="toir-backend"` +- Frontend: + - `VITE_API_URL=http://localhost:3000` + - `VITE_KEYCLOAK_URL=https://sso.greact.ru` + - `VITE_KEYCLOAK_REALM=toir` + - `VITE_KEYCLOAK_CLIENT_ID=toir-frontend` + +These example values come from the already working runtime shape and are preferred over local-only Keycloak placeholders. + +## Runtime bootstrap + +1. Import the root-level realm artifact into Keycloak. +2. Start PostgreSQL with `docker compose up -d`. +3. From `server/` run: + - initialize or repair the workspace with official Nest CLI scaffolding if required before generating domain code + - `npm install` + - `npx prisma generate` + - `npx prisma migrate dev` + - `npx prisma db seed` + - `npm run build` + - `npm run start` +4. From `client/` run: + - initialize or repair the workspace with official Vite React TypeScript scaffolding if required before generating app code + - `npm install` + - `npm run build` + - `npm run dev` + +## Recovery and completion rules + +- Repair degraded framework workspaces before applying any new domain-derived generation changes. +- Do not mark generation complete while `server/` or `client/` remains non-buildable. +- If dependency installation has not happened yet, buildability may be reported as skipped, but it must never be reported as green without verification. +- Runtime/bootstrap instructions are reusable project baseline rules; TOiR names remain examples, not the only supported domain project. + +## Scaffold expectations + +- NestJS workspace creation should follow the official Nest CLI path for new applications and resource scaffolding. +- Vite frontend creation should follow the official Vite `create-vite` path for React TypeScript applications. +- The LLM may customize generated code after scaffold creation, but must not replace official workspace initialization with ad hoc file creation. + +## Common generation failures to avoid + +- starting feature generation before scaffold repair +- leaving deleted framework config files unrepaired because the current diff looks smaller +- accepting a form-only validation pass while buildability is unknown +- binding runtime rules to one project-specific DSL filename instead of `domain/*.dsl` + +## Baseline intent + +- No new generator engine +- No compiler platform +- No planner/emitter/runtime redesign +- Only the current LLM-first pipeline, strengthened by summary, realm, and validation artifacts diff --git a/prompts/validation-rules.md b/prompts/validation-rules.md new file mode 100644 index 0000000..5063b8b --- /dev/null +++ b/prompts/validation-rules.md @@ -0,0 +1,85 @@ +# Validation Rules + +Validation is now a lightweight automated gate instead of a prose-only checklist. + +## Commands + +- `npm run generate:domain-summary` +- `npm run validate:generation` +- `npm run validate:generation:runtime` + +## Prompt-Gate Alignment Rule + +- Every invariant described as required in the active prompt corpus must either be enforced by this gate or be called out explicitly as a manual/runtime-only check. +- Validation must not stay silent about a violation that the prompts describe as forbidden. +- Validation must not report green buildability when build verification was skipped. + +## Gate groups + +### Build checks + +- at least one `domain/*.dsl` file exists +- required artifacts exist +- Prisma schema exists +- frontend/backend env contracts exist +- frontend/backend framework workspace files exist +- `domain-summary.json` matches the current DSL +- project `.env.example` files keep the working domain-based Keycloak examples unless explicitly overridden +- `server/` remains a valid Nest workspace +- `client/` remains a valid Vite workspace +- generation must not pass validation if framework scaffolding files were deleted and replaced by a hand-written minimal skeleton +- if dependencies are installed, build verification runs for `server/` and `client/` +- if dependencies are missing, build verification is reported as skipped with reason instead of green + +### Auth checks + +- frontend auth seam files exist +- backend auth seam files exist +- `401` and `403` semantics stay split +- auth code keeps the required Keycloak/JWT contracts +- JWKS resolution chain matches the contract: + 1. explicit `KEYCLOAK_JWKS_URL` + 2. OIDC discovery + 3. certs fallback + +### Natural-key checks + +- response records expose `id` +- route/update contracts use the real primary key +- natural-key sort/update paths do not regress to a fake physical `id` + +### Realm checks + +- a root `*-realm.json` artifact exists +- realm roles exist +- audience delivery exists +- required claims are explicit +- SPA/backend client structure is explicit + +### Runtime checks + +- compose topology stays PostgreSQL-only +- Prisma lifecycle scripts remain in place +- `/health` stays public +- backend can execute `npm run build` inside `server/` +- frontend can execute `npm run build` inside `client/` after dependencies are installed +- client/server `.env.example` stay aligned with the working runtime defaults: + - `https://sso.greact.ru` + - `toir` + - `toir-frontend` + - `toir-backend` + - `https://toir-frontend.greact.ru` +- optional runtime execution mode runs: + - `npx prisma generate` + - `npx prisma migrate dev` + - `npx prisma db seed` + +### Scaffold checks + +- backend initialization starts from official Nest CLI scaffolding +- frontend initialization starts from official Vite React TypeScript scaffolding +- feature generation happens after scaffold creation, not instead of scaffold creation +- repair happens before generation when workspace is degraded +- required framework configs and entry files must survive subsequent LLM edits + +The automated gate is intentionally small. It enforces the critical reproducibility contract without turning the repository into a test platform or a generator engine. diff --git a/toir-realm.json b/toir-realm.json new file mode 100644 index 0000000..bae772d --- /dev/null +++ b/toir-realm.json @@ -0,0 +1,172 @@ +{ + "realm": "toir", + "enabled": true, + "displayName": "TOIR", + "sslRequired": "external", + "registrationAllowed": false, + "registrationEmailAsUsername": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": true, + "rememberMe": true, + "verifyEmail": false, + "roles": { + "realm": [ + { + "name": "admin", + "description": "Full administrative access" + }, + { + "name": "editor", + "description": "Can create and modify data" + }, + { + "name": "viewer", + "description": "Read-only access" + } + ] + }, + "clientScopes": [ + { + "name": "api-audience", + "description": "Adds backend audience to SPA access token", + "protocol": "openid-connect", + "attributes": { + "display.on.consent.screen": "false", + "include.in.token.scope": "false" + }, + "protocolMappers": [ + { + "name": "aud-toir-backend", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.client.audience": "toir-backend", + "id.token.claim": "false", + "access.token.claim": "true", + "introspection.token.claim": "true" + } + } + ] + } + ], + "clients": [ + { + "clientId": "toir-frontend", + "name": "toir-frontend", + "description": "Frontend SPA client", + "enabled": true, + "protocol": "openid-connect", + "publicClient": true, + "bearerOnly": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "fullScopeAllowed": true, + "rootUrl": "https://toir-frontend.greact.ru", + "baseUrl": "https://toir-frontend.greact.ru", + "redirectUris": [ + "https://toir-frontend.greact.ru/*", + "http://localhost:5173/*" + ], + "webOrigins": [ + "https://toir-frontend.greact.ru", + "http://localhost:5173" + ], + "attributes": { + "pkce.code.challenge.method": "S256" + }, + "defaultClientScopes": [ + "api-audience" + ], + "optionalClientScopes": [ + "offline_access" + ], + "protocolMappers": [ + { + "name": "sub", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "id", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "sub", + "jsonType.label": "String" + } + }, + { + "name": "preferred_username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "preferred_username", + "jsonType.label": "String" + } + }, + { + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "email", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email", + "jsonType.label": "String" + } + }, + { + "name": "name", + "protocol": "openid-connect", + "protocolMapper": "oidc-full-name-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "name": "realm roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "multivalued": "true", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "realm_access.roles", + "jsonType.label": "String" + } + } + ] + }, + { + "clientId": "toir-backend", + "name": "toir-backend", + "description": "Backend API resource server", + "enabled": true, + "protocol": "openid-connect", + "publicClient": false, + "bearerOnly": true, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "fullScopeAllowed": false + } + ] +} diff --git a/tools/dsl-summary.mjs b/tools/dsl-summary.mjs new file mode 100644 index 0000000..823736b --- /dev/null +++ b/tools/dsl-summary.mjs @@ -0,0 +1,357 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; + +function stripInlineComment(line) { + let inString = false; + let result = ''; + + for (let index = 0; index < line.length; index += 1) { + const current = line[index]; + const next = line[index + 1]; + + if (current === '"' && line[index - 1] !== '\\') { + inString = !inString; + result += current; + continue; + } + + if (!inString && current === '/' && next === '/') { + break; + } + + result += current; + } + + return result.trim(); +} + +function parseDefaultValue(rawValue) { + const trimmed = rawValue.trim(); + if (trimmed.startsWith('"') && trimmed.endsWith('"')) { + return trimmed.slice(1, -1); + } + + if (/^-?\d+$/.test(trimmed)) { + return Number(trimmed); + } + + return trimmed; +} + +export function getDslFiles(rootDir) { + const domainDir = path.join(rootDir, 'domain'); + + return readdirSync(domainDir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.dsl')) + .map((entry) => path.join(domainDir, entry.name)) + .sort((left, right) => left.localeCompare(right)); +} + +function getOverrideFiles(rootDir) { + const overridesDir = path.join(rootDir, 'overrides'); + const files = ['api-overrides.dsl', 'ui-overrides.dsl'] + .map((name) => path.join(overridesDir, name)) + .filter((filePath) => { + try { + return readdirSync(path.dirname(filePath)).includes(path.basename(filePath)); + } catch { + return false; + } + }); + + return files; +} + +export function parseDslFiles(rootDir) { + const dslFiles = getDslFiles(rootDir); + const enums = []; + const entities = []; + const stack = []; + + for (const filePath of dslFiles) { + const contents = readFileSync(filePath, 'utf8'); + const lines = contents.split(/\r?\n/); + + for (const rawLine of lines) { + const line = stripInlineComment(rawLine); + if (!line) { + continue; + } + + const top = stack.at(-1); + + const enumMatch = line.match(/^enum\s+([A-Za-z][A-Za-z0-9_]*)\s*\{$/); + if (!top && enumMatch) { + const enumDefinition = { name: enumMatch[1], values: [] }; + enums.push(enumDefinition); + stack.push({ type: 'enum', ref: enumDefinition }); + continue; + } + + const entityMatch = line.match(/^entity\s+([A-Za-z][A-Za-z0-9_]*)\s*\{$/); + if (!top && entityMatch) { + const entityDefinition = { + name: entityMatch[1], + primaryKey: null, + fields: [], + foreignKeys: [], + }; + entities.push(entityDefinition); + stack.push({ type: 'entity', ref: entityDefinition }); + continue; + } + + const valueMatch = line.match(/^value\s+([A-Za-z][A-Za-z0-9_]*)\s*\{$/); + if (top?.type === 'enum' && valueMatch) { + const enumValue = { name: valueMatch[1] }; + top.ref.values.push(enumValue); + stack.push({ type: 'enumValue', ref: enumValue }); + continue; + } + + const attributeMatch = line.match(/^attribute\s+([A-Za-z][A-Za-z0-9_]*)\s*\{$/); + if (top?.type === 'entity' && attributeMatch) { + const field = { + name: attributeMatch[1], + type: null, + required: false, + unique: false, + default: null, + }; + top.ref.fields.push(field); + stack.push({ type: 'field', ref: field, entity: top.ref }); + continue; + } + + if (top?.type === 'field' && /^key\s+foreign\s*\{$/.test(line)) { + stack.push({ type: 'foreignKey', ref: top.ref, entity: top.entity }); + continue; + } + + if (top?.type === 'enumValue') { + const labelMatch = line.match(/^label\s+"(.*)"\s*;$/); + if (labelMatch) { + top.ref.label = labelMatch[1]; + continue; + } + } + + if (top?.type === 'field') { + const typeMatch = line.match(/^type\s+([A-Za-z][A-Za-z0-9_]*)\s*;$/); + if (typeMatch) { + top.ref.type = typeMatch[1]; + continue; + } + + if (/^key\s+primary\s*;$/.test(line)) { + top.ref.primary = true; + top.entity.primaryKey = top.ref.name; + continue; + } + + const defaultMatch = line.match(/^default\s+(.+)\s*;$/); + if (defaultMatch) { + top.ref.default = parseDefaultValue(defaultMatch[1]); + continue; + } + + if (/^is\s+required\s*;$/.test(line)) { + top.ref.required = true; + continue; + } + + if (/^is\s+unique\s*;$/.test(line)) { + top.ref.unique = true; + continue; + } + } + + if (top?.type === 'foreignKey') { + const relatesMatch = line.match( + /^relates\s+([A-Za-z][A-Za-z0-9_]*)\.([A-Za-z][A-Za-z0-9_]*)\s*;$/, + ); + if (relatesMatch) { + const foreignKey = { + field: top.ref.name, + references: { + entity: relatesMatch[1], + field: relatesMatch[2], + }, + }; + + top.ref.foreignKey = foreignKey.references; + top.entity.foreignKeys.push(foreignKey); + continue; + } + } + + if (/^}\s*;?$/.test(line)) { + stack.pop(); + } + } + } + + return { dslFiles, enums, entities }; +} + +function assertNoDomainRedefinition(line, filePath) { + const blocked = [/^\s*entity\s+/i, /^\s*enum\s+/i, /^\s*attribute\s+/i, /^\s*key\s+primary/i, /^\s*key\s+foreign/i]; + for (const pattern of blocked) { + if (pattern.test(line)) { + throw new Error( + `Override file ${path.basename(filePath)} attempts to redefine domain structure: ${line.trim()}`, + ); + } + } +} + +export function parseOverrides(rootDir, entities) { + const overrideFiles = getOverrideFiles(rootDir); + const entityNames = new Set(entities.map((entity) => entity.name)); + const fieldNames = new Set( + entities.flatMap((entity) => entity.fields.map((field) => `${entity.name}.${field.name}`)), + ); + + const api = { resources: {} }; + const ui = { fields: {} }; + + for (const filePath of overrideFiles) { + const isApi = filePath.endsWith('api-overrides.dsl'); + const isUi = filePath.endsWith('ui-overrides.dsl'); + const lines = readFileSync(filePath, 'utf8').split(/\r?\n/); + + for (const rawLine of lines) { + const line = stripInlineComment(rawLine); + if (!line) { + continue; + } + + assertNoDomainRedefinition(line, filePath); + + if (isApi) { + const match = line.match(/^resource\s+([A-Za-z][A-Za-z0-9_]*)\s+path\s+"([^"]+)"\s*;$/); + if (!match) { + throw new Error( + `Unsupported API override syntax in ${path.basename(filePath)}: ${line}`, + ); + } + const [, entityName, resourcePath] = match; + if (!entityNames.has(entityName)) { + throw new Error(`API override references unknown entity ${entityName}`); + } + api.resources[entityName] = { path: resourcePath }; + } + + if (isUi) { + const match = line.match( + /^field\s+([A-Za-z][A-Za-z0-9_]*)\.([A-Za-z][A-Za-z0-9_]*)\s+widget\s+"([^"]+)"\s*;$/, + ); + if (!match) { + throw new Error( + `Unsupported UI override syntax in ${path.basename(filePath)}: ${line}`, + ); + } + const [, entityName, fieldName, widget] = match; + const key = `${entityName}.${fieldName}`; + if (!fieldNames.has(key)) { + throw new Error(`UI override references unknown field ${key}`); + } + ui.fields[key] = { widget }; + } + } + } + + return { api, ui, sourceFiles: overrideFiles.map((filePath) => path.relative(rootDir, filePath).replaceAll('\\', '/')) }; +} + +export function buildDomainSummary(rootDir) { + const { dslFiles, enums, entities } = parseDslFiles(rootDir); + parseOverrides(rootDir, entities); + const entityByName = new Map(entities.map((entity) => [entity.name, entity])); + const enumByName = new Map(enums.map((entry) => [entry.name, entry])); + + for (const entity of entities) { + if (!entity.primaryKey) { + throw new Error(`Entity ${entity.name} is missing a primary key`); + } + + const primaryKeyField = entity.fields.find((field) => field.name === entity.primaryKey); + if (!primaryKeyField) { + throw new Error( + `Entity ${entity.name} declares primary key ${entity.primaryKey}, but the field is missing`, + ); + } + + if (entity.fields.some((field) => !field.type)) { + throw new Error(`Entity ${entity.name} has attributes with missing type`); + } + + for (const foreignKey of entity.foreignKeys) { + const targetEntity = entityByName.get(foreignKey.references.entity); + if (!targetEntity) { + throw new Error( + `Foreign key ${entity.name}.${foreignKey.field} references missing entity ${foreignKey.references.entity}`, + ); + } + + const targetField = targetEntity.fields.find( + (field) => field.name === foreignKey.references.field, + ); + if (!targetField) { + throw new Error( + `Foreign key ${entity.name}.${foreignKey.field} references missing field ${foreignKey.references.entity}.${foreignKey.references.field}`, + ); + } + + const sourceField = entity.fields.find((field) => field.name === foreignKey.field); + if (sourceField && sourceField.type !== targetField.type) { + throw new Error( + `Foreign key ${entity.name}.${foreignKey.field} type ${sourceField.type} does not match ${foreignKey.references.entity}.${foreignKey.references.field} type ${targetField.type}`, + ); + } + } + + const fieldNames = new Set(); + for (const field of entity.fields) { + if (fieldNames.has(field.name)) { + throw new Error(`Entity ${entity.name} has duplicate field ${field.name}`); + } + fieldNames.add(field.name); + } + } + + const entityNames = new Set(); + for (const entity of entities) { + if (entityNames.has(entity.name)) { + throw new Error(`Duplicate entity definition: ${entity.name}`); + } + entityNames.add(entity.name); + } + + for (const [enumName, enumDefinition] of enumByName.entries()) { + const valueNames = new Set(); + for (const value of enumDefinition.values) { + if (valueNames.has(value.name)) { + throw new Error(`Enum ${enumName} has duplicate value ${value.name}`); + } + valueNames.add(value.name); + } + } + + return { + sourceFiles: dslFiles.map((filePath) => path.relative(rootDir, filePath).replaceAll('\\', '/')), + entities: entities.map((entity) => ({ + name: entity.name, + primaryKey: entity.primaryKey, + fields: entity.fields.map((field) => ({ + name: field.name, + type: field.type, + required: field.required, + unique: field.unique, + default: field.default, + })), + foreignKeys: entity.foreignKeys, + })), + enums, + }; +} diff --git a/tools/generate-domain-summary.mjs b/tools/generate-domain-summary.mjs new file mode 100644 index 0000000..806015e --- /dev/null +++ b/tools/generate-domain-summary.mjs @@ -0,0 +1,13 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { buildDomainSummary } from './dsl-summary.mjs'; + +const rootDir = process.cwd(); +const outputPath = path.join(rootDir, 'domain-summary.json'); + +mkdirSync(path.dirname(outputPath), { recursive: true }); + +const summary = buildDomainSummary(rootDir); +writeFileSync(outputPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8'); + +console.log(`Generated ${path.relative(rootDir, outputPath)}`); diff --git a/tools/validate-generation.mjs b/tools/validate-generation.mjs new file mode 100644 index 0000000..e7209b0 --- /dev/null +++ b/tools/validate-generation.mjs @@ -0,0 +1,513 @@ +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { + buildDomainSummary, + getDslFiles, + parseDslFiles, + parseOverrides, +} from './dsl-summary.mjs'; + +const rootDir = process.cwd(); +const args = new Set(process.argv.slice(2)); +const artifactsOnly = args.has('--artifacts-only'); +const runRuntime = args.has('--run-runtime'); + +const failures = []; +const warnings = []; + +function assertCondition(condition, message) { + if (!condition) { + failures.push(message); + } +} + +function warn(message) { + warnings.push(message); +} + +function read(relativePath) { + return readFileSync(path.join(rootDir, relativePath), 'utf8'); +} + +function readIfExists(relativePath) { + const filePath = path.join(rootDir, relativePath); + if (!existsSync(filePath)) { + return null; + } + + return readFileSync(filePath, 'utf8'); +} + +function requireFile(relativePath) { + assertCondition(existsSync(path.join(rootDir, relativePath)), `Missing file: ${relativePath}`); +} + +function requireFiles(relativePaths) { + relativePaths.forEach(requireFile); +} + +function requireContent(relativePath, pattern, message) { + const contents = readIfExists(relativePath); + assertCondition(Boolean(contents), `Missing file: ${relativePath}`); + if (!contents) { + return; + } + + assertCondition(pattern.test(contents), `${message} (${relativePath})`); +} + +function parseJson(relativePath) { + const raw = readIfExists(relativePath); + if (!raw) { + failures.push(`Missing file: ${relativePath}`); + return null; + } + + try { + return JSON.parse(raw); + } catch (error) { + failures.push(`Invalid JSON in ${relativePath}: ${error.message}`); + return null; + } +} + +function kebabCase(value) { + return value + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') + .replace(/\s+/g, '-') + .toLowerCase(); +} + +function getRealmArtifactPath() { + const rootFiles = readdirSync(rootDir, { withFileTypes: true }) + .filter((entry) => entry.isFile()) + .map((entry) => entry.name); + + const realmArtifacts = rootFiles.filter((entry) => /-realm\.json$/i.test(entry)); + assertCondition(realmArtifacts.length === 1, 'Expected exactly one root-level *-realm.json artifact'); + + return realmArtifacts[0] ?? null; +} + +function getWorkspaceInfo() { + return { + server: { + dir: path.join(rootDir, 'server'), + packagePath: 'server/package.json', + scaffoldFiles: [ + 'server/package.json', + 'server/tsconfig.json', + 'server/tsconfig.build.json', + 'server/nest-cli.json', + 'server/src/main.ts', + 'server/src/app.module.ts', + ], + }, + client: { + dir: path.join(rootDir, 'client'), + packagePath: 'client/package.json', + scaffoldFiles: [ + 'client/package.json', + 'client/index.html', + 'client/tsconfig.json', + 'client/tsconfig.node.json', + 'client/vite.config.ts', + 'client/src/main.tsx', + 'client/src/vite-env.d.ts', + ], + }, + }; +} + +function validateBuildChecks() { + requireFiles([ + 'README.md', + 'package.json', + 'domain/dsl-spec.md', + 'domain-summary.json', + 'server/prisma/schema.prisma', + 'server/.env.example', + 'client/.env.example', + 'prompts/general-prompt.md', + 'prompts/auth-rules.md', + 'prompts/backend-rules.md', + 'prompts/frontend-rules.md', + 'prompts/runtime-rules.md', + 'prompts/validation-rules.md', + ]); + + const dslFiles = getDslFiles(rootDir).map((filePath) => path.relative(rootDir, filePath).replaceAll('\\', '/')); + assertCondition(dslFiles.length > 0, 'Expected at least one domain/*.dsl file'); + + const actualSummaryRaw = readIfExists('domain-summary.json'); + if (actualSummaryRaw) { + const expectedSummary = JSON.stringify(buildDomainSummary(rootDir), null, 2); + assertCondition( + actualSummaryRaw.trim() === expectedSummary, + 'domain-summary.json is out of date. Run `npm run generate:domain-summary`.', + ); + } + + try { + const { entities } = parseDslFiles(rootDir); + parseOverrides(rootDir, entities); + } catch (error) { + failures.push(`Override validation failed: ${error.message}`); + } + + const { server, client } = getWorkspaceInfo(); + requireFiles(server.scaffoldFiles); + requireFiles(client.scaffoldFiles); + + const serverPackage = parseJson(server.packagePath); + if (serverPackage) { + assertCondition(serverPackage.scripts?.build === 'nest build', 'server/package.json must keep `build = nest build`'); + assertCondition(serverPackage.scripts?.start === 'nest start', 'server/package.json must keep `start = nest start`'); + assertCondition(serverPackage.scripts?.['start:dev'] === 'nest start --watch', 'server/package.json must keep `start:dev = nest start --watch`'); + assertCondition(Boolean(serverPackage.scripts?.['start:prod']), 'server/package.json must define a start:prod script'); + assertCondition(Boolean(serverPackage.dependencies?.['@nestjs/core']), 'server/package.json must keep Nest runtime dependencies'); + } + + const clientPackage = parseJson(client.packagePath); + if (clientPackage) { + assertCondition(clientPackage.scripts?.dev === 'vite', 'client/package.json must keep `dev = vite`'); + assertCondition(clientPackage.scripts?.build === 'vite build', 'client/package.json must keep `build = vite build`'); + assertCondition(clientPackage.scripts?.preview === 'vite preview', 'client/package.json must keep `preview = vite preview`'); + assertCondition(Boolean(clientPackage.devDependencies?.vite), 'client/package.json must keep Vite as a dev dependency'); + assertCondition(Boolean(clientPackage.devDependencies?.['@vitejs/plugin-react']), 'client/package.json must keep @vitejs/plugin-react as a dev dependency'); + } +} + +function validateAuthChecks() { + requireFiles([ + 'client/src/auth/keycloak.ts', + 'client/src/auth/authProvider.ts', + 'client/src/dataProvider.ts', + 'client/src/config/env.ts', + 'client/src/main.tsx', + 'client/src/App.tsx', + 'server/src/auth/auth.module.ts', + 'server/src/auth/auth.service.ts', + 'server/src/auth/guards/jwt-auth.guard.ts', + 'server/src/auth/guards/roles.guard.ts', + 'server/src/auth/decorators/public.decorator.ts', + 'server/src/auth/decorators/roles.decorator.ts', + ]); + + requireContent( + 'client/src/auth/keycloak.ts', + /onLoad:\s*'login-required'/, + 'Frontend auth must initialize Keycloak with login-required', + ); + requireContent( + 'client/src/auth/keycloak.ts', + /pkceMethod:\s*'S256'/, + 'Frontend auth must use PKCE S256', + ); + requireContent( + 'client/src/auth/keycloak.ts', + /updateToken\(/, + 'Frontend auth must refresh access tokens through the Keycloak adapter', + ); + + const keycloakSource = readIfExists('client/src/auth/keycloak.ts') ?? ''; + assertCondition( + !/loadUserProfile\(/.test(keycloakSource), + 'Frontend auth must not call keycloak.loadUserProfile()', + ); + + requireContent( + 'client/src/dataProvider.ts', + /Authorization', `Bearer \$\{token\}`/, + 'dataProvider must attach bearer tokens in the shared request seam', + ); + + const authProvider = readIfExists('client/src/auth/authProvider.ts') ?? ''; + assertCondition( + /status === 401/.test(authProvider) && /status === 403/.test(authProvider), + 'authProvider must distinguish 401 and 403 semantics', + ); + + const authService = readIfExists('server/src/auth/auth.service.ts') ?? ''; + assertCondition( + /jwtVerify/.test(authService) && /KEYCLOAK_ISSUER_URL/.test(authService) && /KEYCLOAK_AUDIENCE/.test(authService), + 'Backend auth must verify JWTs with issuer and audience', + ); + assertCondition(/realm_access/.test(authService), 'Backend auth must extract roles from realm_access.roles'); + assertCondition(/KEYCLOAK_JWKS_URL/.test(authService), 'Backend auth must support explicit KEYCLOAK_JWKS_URL'); + assertCondition(/\.well-known\/openid-configuration/.test(authService), 'Backend auth must try OIDC discovery before fallback certs'); + assertCondition(/protocol\/openid-connect\/certs/.test(authService), 'Backend auth must keep Keycloak certs fallback resolution'); +} + +function validateNaturalKeyChecks() { + const summary = parseJson('domain-summary.json'); + if (!summary) { + return; + } + + const naturalKeyEntities = summary.entities.filter((entity) => entity.primaryKey !== 'id'); + + for (const entity of naturalKeyEntities) { + const moduleName = kebabCase(entity.name); + const controllerPath = `server/src/modules/${moduleName}/${moduleName}.controller.ts`; + const servicePath = `server/src/modules/${moduleName}/${moduleName}.service.ts`; + const controller = readIfExists(controllerPath) ?? ''; + const service = readIfExists(servicePath) ?? ''; + + assertCondition(Boolean(controller), `Missing file: ${controllerPath}`); + assertCondition(Boolean(service), `Missing file: ${servicePath}`); + if (!controller || !service) { + continue; + } + + assertCondition( + controller.includes(`@Get(':${entity.primaryKey}')`) && + controller.includes(`@Patch(':${entity.primaryKey}')`) && + controller.includes(`@Delete(':${entity.primaryKey}')`), + `${entity.name} controller must use :${entity.primaryKey} route params`, + ); + + assertCondition( + service.includes(`id: item.${entity.primaryKey}`) || service.includes(`id: record.${entity.primaryKey}`), + `${entity.name} service must map the natural key to React Admin id`, + ); + + assertCondition( + service.includes(`const { id, ${entity.primaryKey}: _pk`) || service.includes(`const { id: _pk, ${entity.primaryKey}`), + `${entity.name} update path must sanitize id and primary key from Prisma update data`, + ); + + assertCondition( + /sortField\s*===\s*'id'/.test(service) || /sortField\s*===\s*"id"/.test(service), + `${entity.name} natural-key sort must map React Admin id sorting back to the real primary key`, + ); + assertCondition( + !service.includes("query._sort || 'id'"), + `${entity.name} natural-key sort must not fall back to the physical id field`, + ); + } +} + +function validateRealmChecks() { + const realmArtifactName = getRealmArtifactPath(); + if (!realmArtifactName) { + return; + } + + const artifact = parseJson(realmArtifactName); + if (!artifact) { + return; + } + + const realmRoles = artifact.roles?.realm?.map((role) => role.name) ?? []; + const frontendClient = artifact.clients?.find((client) => client.clientId?.endsWith('-frontend')); + const backendClient = artifact.clients?.find((client) => client.clientId?.endsWith('-backend')); + const audienceScope = artifact.clientScopes?.find((scope) => scope.name === 'api-audience'); + + ['admin', 'editor', 'viewer'].forEach((role) => { + assertCondition(realmRoles.includes(role), `Realm artifact must define realm role ${role}`); + }); + + assertCondition(Boolean(frontendClient), 'Realm artifact must define the frontend SPA client'); + assertCondition(Boolean(backendClient), 'Realm artifact must define the backend resource client'); + assertCondition(Boolean(audienceScope), 'Realm artifact must define the api-audience client scope'); + + if (frontendClient) { + assertCondition(frontendClient.publicClient === true, 'Frontend realm client must be public'); + assertCondition( + frontendClient.standardFlowEnabled === true && + frontendClient.implicitFlowEnabled === false && + frontendClient.directAccessGrantsEnabled === false, + 'Frontend realm client must use standard flow only', + ); + assertCondition( + frontendClient.attributes?.['pkce.code.challenge.method'] === 'S256', + 'Frontend realm client must enforce PKCE S256', + ); + + const mapperNames = new Set((frontendClient.protocolMappers ?? []).map((mapper) => mapper.name)); + ['sub', 'preferred_username', 'email', 'name', 'realm roles'].forEach((mapperName) => { + assertCondition(mapperNames.has(mapperName), `Frontend realm client must include protocol mapper ${mapperName}`); + }); + } + + if (backendClient && audienceScope) { + const audienceMapper = (audienceScope.protocolMappers ?? []).find( + (mapper) => mapper.protocolMapper === 'oidc-audience-mapper', + ); + assertCondition(Boolean(audienceMapper), 'api-audience scope must include an audience mapper'); + assertCondition( + audienceMapper?.config?.['included.client.audience'] === backendClient.clientId, + 'api-audience scope must deliver the backend audience/client id', + ); + assertCondition(backendClient.bearerOnly === true, 'Backend realm client must be bearer-only'); + } +} + +function validateRuntimeContractChecks() { + requireFile('docker-compose.yml'); + const compose = readIfExists('docker-compose.yml') ?? ''; + assertCondition(/image:\s*postgres:16/.test(compose), 'docker-compose must provision postgres:16'); + assertCondition(!/keycloak/i.test(compose), 'docker-compose must remain PostgreSQL-only'); + + const serverEnvExample = readIfExists('server/.env.example') ?? ''; + assertCondition( + /KEYCLOAK_ISSUER_URL="https:\/\/sso\.greact\.ru\/realms\/toir"/.test(serverEnvExample), + 'server/.env.example must keep the working Keycloak issuer example', + ); + assertCondition( + /KEYCLOAK_AUDIENCE="?toir-backend"?/.test(serverEnvExample), + 'server/.env.example must keep the working backend audience example', + ); + assertCondition( + /CORS_ALLOWED_ORIGINS="http:\/\/localhost:5173,https:\/\/toir-frontend\.greact\.ru"/.test(serverEnvExample), + 'server/.env.example must keep the working CORS example with the production frontend domain', + ); + assertCondition( + !/KEYCLOAK_ISSUER_URL=http:\/\/localhost:8080\/realms\/toir/.test(serverEnvExample), + 'server/.env.example must not regress to localhost Keycloak as the baseline issuer example', + ); + + const clientEnvExample = readIfExists('client/.env.example') ?? ''; + assertCondition( + /VITE_KEYCLOAK_URL=https:\/\/sso\.greact\.ru/.test(clientEnvExample), + 'client/.env.example must keep the working domain-based Keycloak URL example', + ); + assertCondition( + /VITE_KEYCLOAK_REALM=toir/.test(clientEnvExample) && /VITE_KEYCLOAK_CLIENT_ID=toir-frontend/.test(clientEnvExample), + 'client/.env.example must keep the working realm and frontend client examples', + ); + assertCondition( + !/VITE_KEYCLOAK_URL=http:\/\/localhost:8080/.test(clientEnvExample), + 'client/.env.example must not regress to localhost Keycloak as the baseline example', + ); + + const healthController = readIfExists('server/src/health/health.controller.ts') ?? ''; + assertCondition(Boolean(healthController), 'Missing file: server/src/health/health.controller.ts'); + if (healthController) { + assertCondition( + /@Public\(\)/.test(healthController) && /@Controller\('health'\)/.test(healthController), + '/health must stay public', + ); + } +} + +function runCommand(command, commandArgs, workdir, failureLabel) { + const runtimeEnv = { ...process.env }; + const envExamplePath = path.join(workdir, '.env.example'); + if (existsSync(envExamplePath)) { + const envExample = readFileSync(envExamplePath, 'utf8'); + for (const line of envExample.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) { + continue; + } + + const separator = trimmed.indexOf('='); + if (separator <= 0) { + continue; + } + + const key = trimmed.slice(0, separator).trim(); + const value = trimmed.slice(separator + 1).trim().replace(/^"|"$/g, ''); + if (!(key in runtimeEnv)) { + runtimeEnv[key] = value; + } + } + } + + const commandLine = [command, ...commandArgs].join(' '); + const result = spawnSync(commandLine, { + cwd: workdir, + encoding: 'utf8', + stdio: 'pipe', + shell: true, + env: runtimeEnv, + }); + + if (result.error) { + failures.push(`${failureLabel}: ${commandLine}\n${result.error.message}`); + return false; + } + + if (result.status !== 0) { + const stderr = result.stderr?.trim(); + const stdout = result.stdout?.trim(); + failures.push( + `${failureLabel}: ${commandLine}${stderr ? `\n${stderr}` : stdout ? `\n${stdout}` : ''}`, + ); + return false; + } + + return true; +} + +function maybeValidateWorkspaceBuild(relativeDir) { + const workspaceDir = path.join(rootDir, relativeDir); + if (!existsSync(path.join(workspaceDir, 'package.json'))) { + failures.push(`Missing file: ${relativeDir}/package.json`); + return; + } + + if (!existsSync(path.join(workspaceDir, 'node_modules'))) { + warn(`Skipped build verification for ${relativeDir}: install dependencies in ${relativeDir}/ to validate workspace buildability.`); + return; + } + + runCommand('npm', ['run', 'build'], workspaceDir, `Build verification failed in ${relativeDir}`); +} + +function validateBuildExecutionChecks() { + maybeValidateWorkspaceBuild('server'); + maybeValidateWorkspaceBuild('client'); +} + +function validateRuntimeExecutionChecks() { + const serverDir = path.join(rootDir, 'server'); + if (!existsSync(path.join(serverDir, 'node_modules'))) { + failures.push( + 'Runtime validation requires installed backend dependencies. Run `npm install` in server/ before `npm run validate:generation:runtime`.', + ); + return; + } + + runCommand('npx', ['prisma', 'generate'], serverDir, 'Prisma generate failed'); + runCommand( + 'npx', + ['prisma', 'migrate', 'dev', '--name', 'baseline', '--skip-generate'], + serverDir, + 'Prisma migrate failed', + ); + runCommand('npx', ['prisma', 'db', 'seed'], serverDir, 'Prisma seed failed'); +} + +validateBuildChecks(); +validateAuthChecks(); +validateNaturalKeyChecks(); +validateRealmChecks(); +validateRuntimeContractChecks(); + +if (!artifactsOnly) { + validateBuildExecutionChecks(); +} + +if (!artifactsOnly && runRuntime) { + validateRuntimeExecutionChecks(); +} else if (!artifactsOnly) { + warn('Runtime command execution skipped. Use --run-runtime after installing dependencies and starting the local database.'); +} + +for (const warning of warnings) { + console.warn(`WARN: ${warning}`); +} + +if (failures.length > 0) { + console.error('Generation validation failed:'); + for (const failure of failures) { + console.error(`- ${failure}`); + } + process.exit(1); +} + +console.log('Generation validation passed.');