@ogygie/core
v0.14.0
Published
Ogygie framework core: manifest/spec/error schemas, generic tenancy and gate tooling. Ships TypeScript sources — Ogygie products run tsx/vitest by construction (D1).
Readme
@ogygie/core
Le framework Ogygie : l'outillage et l'infrastructure GÉNÉRIQUES du repo.
Règle de séparation (DECISIONS.md D10) — le core ne connaît aucun mot du
domaine du produit qui le consomme ; la dépendance va produit → core, jamais
l'inverse. Un module qui contient une seule décision propre au produit reste
dans apps/.
| Export | Rôle |
|---|---|
| @ogygie/core/errors | Format OgygieError (code stable, file, cause, suggestedFix) |
| @ogygie/core/manifest-owners | Quelle entrée de la carte possède une spec — feature ou package du produit |
| @ogygie/core/manifest | Schéma du manifeste ogygie.manifest.json |
| @ogygie/core/spec | Frontmatter des specs (parseSpecFrontmatter) et sections canoniques |
| @ogygie/core/db · /clock · /tenant · /test-kit | Accès DB, horloge injectable, façade multi-tenant, kit de test |
| @ogygie/core/web-lint · /bundle-budget | Parties testables des gates front |
| @ogygie/core/vocabulary | Appariement des racines de vocabulaire sur les identifiants machine — les racines appartiennent au produit |
| @ogygie/core/vocabulary-audit | Jugement du gate de vocabulaire : alias interdits du code, pureté des fichiers gouvernés |
| @ogygie/core/gateway | Passerelle publique dérivée du manifeste (ci-dessous) |
| @ogygie/core/client | Client d'API front dérivé du manifeste : transport (fetchImpl injecté), clés de cache et invalidations (D13) |
| @ogygie/core/client-react | Les hooks du même client — react et @tanstack/react-query en peers OPTIONNELS, chargés dynamiquement (D13) |
Le test du vocabulaire couvre TOUT le core
Le gate pnpm ogygie:verify-core-vocabulary du repo du framework scanne
chaque fichier de ce package — modules, tests, fixtures, JSON d'exemples — et
casse la CI à la moindre racine produit (nom du produit, id de feature, mot du
domaine, dans les deux langues). Les fixtures fuient le vocabulaire aussi
facilement que le code : rien n'est exempté. Les mots interdits, eux, vivent
dans scripts/ du repo du framework, JAMAIS ici : ce package est publié
(files: ["src"]), donc une liste de mots produit stockée dedans SERAIT la
fuite — c'est exactement ainsi que la 0.1.0 a publié le vocabulaire complet
d'un domaine produit, dans un fichier de test.
Hypothèses structurelles assumées (non-vocabulaires)
Sans domaine ne veut pas dire sans opinion. Les hypothèses ci-dessous sont GRAVÉES dans les modules du core ; on les DÉCLARE ici plutôt que de les diluer — un produit qui ne peut pas les accepter ne peut pas consommer ce core tel quel.
- SaaS multi-tenant à colonne tenant unique.
TenantContext(tenantId,userId,db,tenantColumnKey) est le contrat d'accès aux données : chaque table métier porte UNE colonne tenant et tout accès passe par la façadetenantDb(injection à l'écriture, filtre à la lecture, transactions comprises). Seule exception : les référentiels PARTAGÉS, déclarés au manifeste (shared: true) et vérifiés parshared-tables. Le produit nomme son tenant comme il veut (un alias) ; le core impose le MODÈLE. - PostgreSQL, via Drizzle.
Databaseest unPgDatabase(drizzle-orm/pg-core) ; migrations en SQL Postgres ; policy RLS attendue sur toute table tenant (second rempart) ; transactions imbriquées = savepoints Postgres ; test-kit sur PGlite (Postgres en mémoire). Ni MySQL, ni SQLite, ni un autre ORM. - Fastify comme runtime HTTP.
manifest-gatewayproduit une app Fastify (helmet, rate-limit, static) et l'API du produit est supposée être unFastifyInstancedécoré (app.db,app.clock). - Zod aux frontières. Toute entrée/sortie a un schéma Zod, source
unique de vérité (validation runtime + type inféré + doc) ; le graphe
d'un
schemas.tsn'autorise par défaut QUEzod(contract-purity, AGENTS.md §Structure — l'exception des CONTRATS). - Une devise de travail par tenant. L'argent est un entier en centimes
- code ISO 4217 ; aucune conversion de change ne traverse le core. Les futurs helpers monétaires naîtront ici sous cette hypothèse.
- Temps en UTC, horloge injectée.
Clockest la seule porte d'accès au temps ; les instants persistés sont de l'ISO 8601 UTC. - Repo cartographié par un manifeste.
ogygie.manifest.jsonà la racine décrit apps, features, routes, tables, dépendances — les gates et les dérivations (passerelle, client) le tiennent pour LA carte du repo. - Monorepo TypeScript ESM strict. Modules NodeNext (imports relatifs
en
.js), pnpm workspace, vitest, TypeScript strict ; les analyses statiques (module-purity, contract-purity) lisent la syntaxe d'import TypeScript — pas un bundler, pas un graphe runtime. - Anglais machine-lisible. Identifiants, codes d'erreur, valeurs d'enum et commentaires en anglais (D11) ; les documents humains du repo consommateur restent en français.
Passerelle dérivée du manifeste — @ogygie/core/gateway
createManifestGateway(manifest, options) dérive une passerelle Fastify d'un
manifeste Ogygie : chaque route déclarée devient un relais typé, et rien
d'autre n'est atteignable. Zéro ligne de code par feature — livrer une route
n'ajoute aucun travail ici, l'oublier au manifeste la rend inatteignable.
C'est un relais pur : il ne décide jamais qui a le droit de quoi (l'autorisation reste dans l'API — un relais qui autorise devient un confused deputy), ne transforme aucun payload, ne détient aucune session.
Usage minimal
import { readFileSync } from "node:fs";
import { createManifestGateway } from "@ogygie/core/gateway";
import * as widgetSchemas from "@my-product/api/features/widgets/schemas";
const manifest: unknown = JSON.parse(readFileSync("ogygie.manifest.json", "utf8"));
const app = await createManifestGateway(manifest, {
upstreamBaseUrl: "http://127.0.0.1:3000", // l'api, sur le réseau privé
schemas: { ...widgetSchemas }, // contrats indexés par nom exporté
staticRoot: "apps/web/dist", // build du front + fallback SPA
rateLimit: { max: 300, timeWindow: "1 minute" },
timeoutMs: 10_000,
csp: { nonce: true },
passthroughPrefixes: ["/api/auth/"], // liste FERMÉE, relais sans validation
});
await app.listen({ port: 8080, host: "0.0.0.0" });
console.log(`${app.gatewayRoutes.length} relais montés depuis le manifeste`);
for (const route of app.gatewayRoutes) console.log(route.method, route.path);Options
| Option | Requis | Effet |
|---|---|---|
| upstreamBaseUrl | oui | Adresse privée de l'api. Jamais exposée au client, même en erreur. |
| schemas | oui | Record<string, ZodType> indexé par nom exporté. Doit contenir tous les schemaInput/schemaOutput du manifeste, sinon le démarrage est refusé. |
| timeoutMs | oui | Délai au-delà duquel l'appel amont est abandonné (→ 504). |
| csp.nonce | oui | Pose un nonce CSP par requête et l'estampille sur les <script> de l'index.html servi. |
| staticRoot | non | Dossier du build front : fichiers statiques + fallback index.html pour le routage client. Absent = rien n'est servi. |
| rateLimit | non | { max, timeWindow } ; défaut 300 / 1 minute. |
| passthroughPrefixes | non | Préfixes relayés sans validation de contrat (ex. un catch-all d'auth possédé par l'api, absent du manifeste par nature). Seule brèche à la règle « rien ne passe qui ne soit au manifeste » : liste fermée, chaque entrée justifiée, jamais un motif large. |
Ce que le module N'inclut PAS, et qui reste à la charge de l'application : le
registre de schémas (produit-conscient par nature), le contrat d'environnement,
le point d'entrée, et le contenu de passthroughPrefixes.
Ce qui est posé pour tout le produit
En-têtes de sécurité possédés par la passerelle (une copie amont ne peut pas
les écraser) : CSP stricte sans unsafe-inline, HSTS, X-Content-Type-Options,
Referrer-Policy, Permissions-Policy, plus le rate limit. Le statut, le corps
et les Set-Cookie de l'amont, eux, traversent octet pour octet — une
OgygieError métier arrive au client sans reformatage.
Erreurs
| Code | Statut | Quand |
|---|---|---|
| GATEWAY_MANIFEST_INVALID | 500, au démarrage | Manifeste illisible, chemin de route malformé, route déclarée deux fois, ou schéma déclaré absent de schemas. Levée en OgygieFailure : le processus ne démarre pas — un démarrage impossible vaut mieux qu'un relais qui ment. |
| CONTRACT_VALIDATION_FAILED | 400 | L'entrée ne satisfait pas le schéma déclaré. Émise PAR la passerelle : le trafic invalide n'atteint jamais le réseau privé. Le corps JSON est validé sur POST/PUT/PATCH, { ...query, ...params } sur GET/DELETE. |
| GATEWAY_ROUTE_UNKNOWN | 404 | Aucune route déclarée ne correspond (et aucun fallback SPA applicable). |
| GATEWAY_UPSTREAM_UNAVAILABLE | 502 | L'amont est injoignable. Aucun détail interne ne fuit : ni URL privée, ni stack. |
| GATEWAY_UPSTREAM_TIMEOUT | 504 | L'amont n'a pas répondu dans timeoutMs. |
Client d'API dérivé du manifeste — @ogygie/core/client et /client-react
createManifestClient(manifest, schemas, options) est le pendant front de
la passerelle (spec manifest-client, D13) : chaque route déclarée devient une
méthode, sous le namespace de sa racine de chemin. Une route absente du
manifeste n'existe pas côté client. Les clés de cache et les
invalidations sont DÉRIVÉES — un produit n'écrit plus une seule clé à la
main, ni un seul fetch.
Usage minimal
import { createManifestClient } from "@ogygie/core/client";
import { createApiHooks } from "@ogygie/core/client-react";
import * as widgetSchemas from "@my-product/api/features/widgets/schemas";
const api = createManifestClient(manifest, { ...widgetSchemas }, {
fetchImpl: globalThis.fetch, // effet injecté, jamais importé au fond d'un helper
validateResponses: import.meta.env.DEV ? "always" : "never",
});
api.widgets.list(); // GET /api/widgets
api.widgets.get({ widgetId }); // GET /api/widgets/:widgetId
api.widgets.list.queryKey(); // ["widgets", "list"]
api.widgets.create.invalidationRoots; // ["widgets", ...invalidates du manifeste]
// Les hooks : peers OPTIONNELS, chargés dynamiquement (refus structuré si absents).
const { useApiQuery, useApiMutation } = await createApiHooks(queryClient);
useApiQuery(api.widgets.list);
useApiMutation(api.widgets.create); // invalide ["widgets"] + les racines déclaréesDérivations
| Élément | Règle |
|---|---|
| Namespace | Premier segment après /api, camelCasé (/api/work-orders/... → client.workOrders). |
| Opération | Segments statiques suivants (.../:id/archive → archive) ; sinon le VERBE du nom du schéma d'entrée (SearchReportsInputSchema → search) ; sinon la méthode (GET → list/get, POST → create, PATCH/PUT → update, DELETE → delete). |
| Clé de cache | [racine, opération, ...valeurs des params, reste de l'entrée]. L'opération EN FAIT PARTIE : deux GET voisins sur la même entité partageraient sinon une entrée de cache, et le second ne partirait jamais. |
| Invalidation | Racine propre + les racines déclarées dans invalidates de la route (vérifiées par verify-manifest : une cible fantôme casse le build). |
Options
| Option | Requis | Effet |
|---|---|---|
| fetchImpl | oui | L'effet réseau, injecté (globalThis.fetch, ou un faux en test). Le module n'appelle jamais le réseau autrement. |
| validateResponses | oui | "always" reparse chaque réponse avec son schemaOutput (dev : un contrat cassé échoue ICI) ; "never" = passthrough. Le PRODUIT choisit par environnement — le core ne lit aucune variable d'env. |
| baseUrl | non | Préfixe du chemin dérivé ; vide par défaut, le front appelle son api en relatif. |
Erreurs
| Code | Quand |
|---|---|
| MANIFEST_CLIENT_INVALID_MANIFEST | À la construction : manifeste illisible, chemin malformé, route déclarée deux fois. |
| MANIFEST_CLIENT_UNKNOWN_SCHEMA | À la construction : un schemaInput/schemaOutput du manifeste manque au registre. |
| MANIFEST_CLIENT_AMBIGUOUS_OPERATION | À la construction : deux routes dérivent le même nom d'opération — jamais un écrasement silencieux. |
| MANIFEST_CLIENT_MISSING_PATH_PARAM | Appel sans le param de chemin que la route exige, avant tout appel réseau. |
| NETWORK_ERROR | fetchImpl a rejeté, ou l'api a échoué SANS corps OgygieError (statut conservé). |
| CONTRACT_BROKEN | Réponse non-JSON, ou violant schemaOutput en validateResponses: "always". |
| CLIENT_REACT_PEER_MISSING | client-react sans @tanstack/react-query installé : refus nommant la commande, jamais une erreur de résolution de module. |
Un corps OgygieError renvoyé par l'api, lui, ressort intact (code,
details, suggestedFix, statut) : c'est la seule chose sur laquelle un écran
puisse agir.
