feat(r0.10): apps/api — auth fermée par défaut, matrice en base, démo-login ADR-002, seed

- packages/shared : rôles/catégories, schémas Zod, contrat d'API ;
  pnpm contract → docs/openapi.json committée (règle d'or ADR-001)
- apps/api : NestJS 11 + Prisma 6, migration r0_identity (Role/Permission/User) ;
  guard JWT global + @Public() ; PermissionsGuard (@RequirePermission,
  matrice relue en base, cache 60 s) ; FileStorage (seul import MinIO) ; /health
- démo-login ADR-002 : module conditionnel DEMO_MODE (404 sinon, testé e2e),
  double verrou production, refus des comptes isDemo=false
- seed idempotent : 7 rôles, matrice complète (70 lignes), 7 comptes démo
- 19 tests Jest (unit + e2e) ; smoke test sur build de prod

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pr-daaif
2026-07-15 21:55:04 +01:00
parent ddc9dc52b5
commit 8adb561b63
62 changed files with 8509 additions and 2 deletions

View File

@@ -0,0 +1,20 @@
{
"name": "@siop/shared",
"version": "0.1.0",
"private": true,
"description": "Contrat SIOP V2 — schémas Zod, référentiel rôles/permissions, génération OpenAPI (règle d'or ADR-001)",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"contract": "tsx scripts/generate-openapi.ts"
},
"dependencies": {
"zod": "^4.0.0"
},
"devDependencies": {
"tsx": "^4.19.0",
"typescript": "^5.8.0"
}
}

View File

@@ -0,0 +1,84 @@
/**
* Règle d'or (ADR-001) : Zod → OpenAPI → clients typés.
* Génère docs/openapi.json (COMMITTÉE) depuis src/contract.ts.
* Usage : pnpm --filter @siop/shared contract (raccourci racine : pnpm contract)
* La CI (ci-contract, R0.12) échoue si le fichier committé diffère de la génération.
*/
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { z } from 'zod';
import { API_CONTRACT } from '../src/contract';
const OUT = resolve(__dirname, '../../../docs/openapi.json');
const schemas: Record<string, unknown> = {};
function register(name: string, schema: z.ZodType): { $ref: string } {
if (!(name in schemas)) {
schemas[name] = z.toJSONSchema(schema, { target: 'draft-2020-12' });
}
return { $ref: `#/components/schemas/${name}` };
}
const paths: Record<string, Record<string, unknown>> = {};
for (const op of API_CONTRACT) {
const responses: Record<string, unknown> = {};
for (const [status, res] of Object.entries(op.responses)) {
responses[status] = {
description: res.description,
...(res.schema && res.name
? { content: { 'application/json': { schema: register(res.name, res.schema) } } }
: {}),
};
}
paths[op.path] = {
...(paths[op.path] ?? {}),
[op.method]: {
operationId: op.operationId,
summary: op.summary,
tags: op.tags,
...(op.isPublic ? {} : { security: [{ bearerAuth: [] }] }),
...(op.demoOnly
? {
'x-demo-only': true,
description:
'ADR-002 : cette route est absente (404) quand DEMO_MODE nest pas actif.',
}
: {}),
...(op.request
? {
requestBody: {
required: true,
content: {
'application/json': { schema: register(op.request.name, op.request.schema) },
},
},
}
: {}),
responses,
},
};
}
const doc = {
openapi: '3.1.0',
info: {
title: 'SIOP V2 API',
version: '0.1.0',
description:
'GMAO ascenseurs — contrat R0 (auth, démo-login ADR-002, profil, santé). ' +
'Généré depuis packages/shared/src/contract.ts — NE PAS ÉDITER À LA MAIN.',
},
paths,
components: {
schemas,
securitySchemes: {
bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
},
},
};
mkdirSync(dirname(OUT), { recursive: true });
writeFileSync(OUT, JSON.stringify(doc, null, 2) + '\n');
console.log(`OpenAPI générée : ${OUT} (${API_CONTRACT.length} opérations)`);

View File

@@ -0,0 +1,104 @@
import type { z } from 'zod';
import {
AuthResponseSchema,
DemoAccountsResponseSchema,
DemoLoginRequestSchema,
LoginRequestSchema,
} from './schemas/auth';
import { MeResponseSchema } from './schemas/users';
import { HealthResponseSchema } from './schemas/health';
/**
* Contrat d'API R0 — source unique de vérité (règle d'or ADR-001).
* `scripts/generate-openapi.ts` en dérive `docs/openapi.json` (committée) ;
* les clients web/mobile sont générés depuis cette spec, dans le même commit.
*/
export interface ApiOperation {
operationId: string;
method: 'get' | 'post' | 'put' | 'patch' | 'delete';
path: string;
summary: string;
tags: string[];
/** Route annotée @Public() côté API (pas de JWT requis). */
isPublic?: boolean;
/** ADR-002 : la route N'EXISTE PAS (404) si DEMO_MODE n'est pas actif. */
demoOnly?: boolean;
request?: { name: string; schema: z.ZodType };
responses: Record<
number,
{ description: string; name?: string; schema?: z.ZodType }
>;
}
export const API_CONTRACT: ApiOperation[] = [
{
operationId: 'login',
method: 'post',
path: '/auth/login',
summary: 'Connexion par e-mail et mot de passe',
tags: ['auth'],
isPublic: true,
request: { name: 'LoginRequest', schema: LoginRequestSchema },
responses: {
200: { description: 'Jeton émis', name: 'AuthResponse', schema: AuthResponseSchema },
401: { description: 'Identifiants invalides ou compte inactif' },
},
},
{
operationId: 'listDemoAccounts',
method: 'get',
path: '/auth/demo-accounts',
summary: 'Comptes de démonstration (ADR-002 — jamais de secret)',
tags: ['auth', 'demo'],
isPublic: true,
demoOnly: true,
responses: {
200: {
description: 'Comptes isDemo actifs',
name: 'DemoAccountsResponse',
schema: DemoAccountsResponseSchema,
},
},
},
{
operationId: 'demoLogin',
method: 'post',
path: '/auth/demo-login',
summary: 'Connexion 1 clic sur un compte de démonstration (ADR-002)',
tags: ['auth', 'demo'],
isPublic: true,
demoOnly: true,
request: { name: 'DemoLoginRequest', schema: DemoLoginRequestSchema },
responses: {
200: { description: 'Jeton émis', name: 'AuthResponse', schema: AuthResponseSchema },
403: { description: 'Le compte nest pas un compte de démonstration' },
404: { description: 'Compte inconnu' },
},
},
{
operationId: 'getMe',
method: 'get',
path: '/users/me',
summary: 'Profil courant + matrice de permissions du rôle',
tags: ['users'],
responses: {
200: { description: 'Profil', name: 'MeResponse', schema: MeResponseSchema },
401: { description: 'Non authentifié' },
},
},
{
operationId: 'getHealth',
method: 'get',
path: '/health',
summary: 'État des dépendances (base, Redis, stockage)',
tags: ['health'],
isPublic: true,
responses: {
200: {
description: 'État agrégé',
name: 'HealthResponse',
schema: HealthResponseSchema,
},
},
},
];

View File

@@ -0,0 +1,5 @@
export * from './permissions';
export * from './schemas/auth';
export * from './schemas/users';
export * from './schemas/health';
export * from './contract';

View File

@@ -0,0 +1,53 @@
import { z } from 'zod';
/**
* Référentiel identité & permissions (R0).
* La matrice rôles × objets × droits vit EN BASE (table Permission) ; ici ne
* vivent que les vocabulaires partagés entre l'API, le seed et les clients.
*/
export const ROLE_NAMES = [
'Administrateur',
'Dispatcher',
'Technicien',
'Technicien limité',
'Gestionnaire',
'Demandeur',
'Vue seule',
] as const;
export type RoleName = (typeof ROLE_NAMES)[number];
/** Catégories d'objets de la matrice — la matrice est complète dès R0, les
* écrans correspondants arrivent release par release (R1 → R3). */
export const OBJECT_CATEGORIES = [
'WORK_ORDERS',
'REQUESTS',
'ASSETS',
'LOCATIONS',
'METERS',
'PARTS',
'PURCHASE_ORDERS',
'PEOPLE_TEAMS',
'ANALYTICS',
'SETTINGS',
] as const;
export type ObjectCategory = (typeof OBJECT_CATEGORIES)[number];
export const PERMISSION_RIGHTS = [
'view',
'viewOther',
'create',
'edit',
'delete',
] as const;
export type PermissionRight = (typeof PERMISSION_RIGHTS)[number];
export const PermissionEntrySchema = z.object({
objectCategory: z.enum(OBJECT_CATEGORIES),
canView: z.boolean(),
canViewOther: z.boolean(),
canCreate: z.boolean(),
canEdit: z.boolean(),
canDelete: z.boolean(),
});
export type PermissionEntry = z.infer<typeof PermissionEntrySchema>;

View File

@@ -0,0 +1,42 @@
import { z } from 'zod';
import { ROLE_NAMES } from '../permissions';
export const AuthUserSchema = z.object({
id: z.uuid(),
email: z.email(),
displayName: z.string(),
role: z.object({ id: z.uuid(), name: z.enum(ROLE_NAMES) }),
isDemo: z.boolean(),
});
export type AuthUser = z.infer<typeof AuthUserSchema>;
export const LoginRequestSchema = z.object({
email: z.email(),
password: z.string().min(1),
});
export type LoginRequest = z.infer<typeof LoginRequestSchema>;
export const AuthResponseSchema = z.object({
accessToken: z.string(),
user: AuthUserSchema,
});
export type AuthResponse = z.infer<typeof AuthResponseSchema>;
/** ADR-002 — jamais de secret dans cette liste. */
export const DemoAccountSchema = z.object({
id: z.uuid(),
displayName: z.string(),
roleName: z.enum(ROLE_NAMES),
initials: z.string().min(1).max(3),
});
export type DemoAccount = z.infer<typeof DemoAccountSchema>;
export const DemoAccountsResponseSchema = z.object({
accounts: z.array(DemoAccountSchema),
});
export type DemoAccountsResponse = z.infer<typeof DemoAccountsResponseSchema>;
export const DemoLoginRequestSchema = z.object({
userId: z.uuid(),
});
export type DemoLoginRequest = z.infer<typeof DemoLoginRequestSchema>;

View File

@@ -0,0 +1,13 @@
import { z } from 'zod';
const ServiceStateSchema = z.enum(['up', 'down']);
export const HealthResponseSchema = z.object({
status: z.enum(['ok', 'degraded']),
services: z.object({
database: ServiceStateSchema,
redis: ServiceStateSchema,
storage: ServiceStateSchema,
}),
});
export type HealthResponse = z.infer<typeof HealthResponseSchema>;

View File

@@ -0,0 +1,11 @@
import { z } from 'zod';
import { AuthUserSchema } from './auth';
import { PermissionEntrySchema } from '../permissions';
/** Profil courant : identité + matrice du rôle (le JWT, lui, ne porte jamais
* de droits — le web lit cette réponse pour adapter l'UI, l'API re-vérifie
* chaque requête via PermissionsGuard). */
export const MeResponseSchema = AuthUserSchema.extend({
permissions: z.array(PermissionEntrySchema),
});
export type MeResponse = z.infer<typeof MeResponseSchema>;

View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "commonjs",
"moduleResolution": "node",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}