mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
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:
20
packages/shared/package.json
Normal file
20
packages/shared/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
84
packages/shared/scripts/generate-openapi.ts
Normal file
84
packages/shared/scripts/generate-openapi.ts
Normal 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 n’est 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)`);
|
||||
104
packages/shared/src/contract.ts
Normal file
104
packages/shared/src/contract.ts
Normal 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 n’est 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
5
packages/shared/src/index.ts
Normal file
5
packages/shared/src/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './permissions';
|
||||
export * from './schemas/auth';
|
||||
export * from './schemas/users';
|
||||
export * from './schemas/health';
|
||||
export * from './contract';
|
||||
53
packages/shared/src/permissions.ts
Normal file
53
packages/shared/src/permissions.ts
Normal 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>;
|
||||
42
packages/shared/src/schemas/auth.ts
Normal file
42
packages/shared/src/schemas/auth.ts
Normal 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>;
|
||||
13
packages/shared/src/schemas/health.ts
Normal file
13
packages/shared/src/schemas/health.ts
Normal 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>;
|
||||
11
packages/shared/src/schemas/users.ts
Normal file
11
packages/shared/src/schemas/users.ts
Normal 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>;
|
||||
16
packages/shared/tsconfig.json
Normal file
16
packages/shared/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user