@ogygie/core 0.13.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -118,6 +118,8 @@ for (const route of app.gatewayRoutes) console.log(route.method, route.path);
118
118
  | `schemas` | oui | `Record<string, ZodType>` indexé par nom exporté. Doit contenir **tous** les `schemaInput`/`schemaOutput` du manifeste, sinon le démarrage est refusé. |
119
119
  | `timeoutMs` | oui | Délai au-delà duquel l'appel amont est abandonné (→ 504). |
120
120
  | `csp.nonce` | oui | Pose un nonce CSP par requête et l'estampille sur les `<script>` de l'`index.html` servi. |
121
+ | `csp.externalOrigins` | non | Liste **FERMÉE** d'origines https externes ajoutées à `connect-src` **et** `img-src`, rien d'autre (ex. un serveur de tuiles public que le front appelle directement). Chaque entrée est validée **au démarrage** : origine https nue, sans chemin ni joker — une entrée invalide refuse de démarrer (`GATEWAY_CSP_ORIGIN_INVALID`), même esprit que `passthroughPrefixes`. Absente ou vide = `connect-src`/`img-src` inchangés, octet pour octet. |
122
+ | `csp.allowWorkerBlob` | non | Ajoute la directive `worker-src 'self' blob:` — nécessaire aux libs qui créent leurs workers depuis un blob (ex. MapLibre GL). Absente ou `false` = aucune directive `worker-src`, comme aujourd'hui. |
121
123
  | `staticRoot` | non | Dossier du build front : fichiers statiques + fallback `index.html` pour le routage client. Absent = rien n'est servi. |
122
124
  | `rateLimit` | non | `{ max, timeWindow }` ; défaut `300 / 1 minute`. |
123
125
  | `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. |
@@ -134,11 +136,16 @@ les écraser) : CSP stricte sans `unsafe-inline`, HSTS, `X-Content-Type-Options`
134
136
  et les `Set-Cookie` de l'amont, eux, traversent **octet pour octet** — une
135
137
  `OgygieError` métier arrive au client sans reformatage.
136
138
 
139
+ La CSP ne s'élargit que sur demande EXPLICITE et par liste fermée
140
+ (`csp.externalOrigins`, `csp.allowWorkerBlob` ci-dessus) : sans ces options,
141
+ elle reste strictement celle d'hier.
142
+
137
143
  ### Erreurs
138
144
 
139
145
  | Code | Statut | Quand |
140
146
  |---|---|---|
141
147
  | `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. |
148
+ | `GATEWAY_CSP_ORIGIN_INVALID` | 500, **au démarrage** | Une entrée de `csp.externalOrigins` n'est pas une origine https nue (joker, chemin, schéma non-https…). Même logique que `GATEWAY_MANIFEST_INVALID` : un démarrage refusé vaut mieux qu'une CSP silencieusement élargie. |
142
149
  | `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. |
143
150
  | `GATEWAY_ROUTE_UNKNOWN` | 404 | Aucune route déclarée ne correspond (et aucun fallback SPA applicable). |
144
151
  | `GATEWAY_UPSTREAM_UNAVAILABLE` | 502 | L'amont est injoignable. Aucun détail interne ne fuit : ni URL privée, ni stack. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ogygie/core",
3
- "version": "0.13.1",
3
+ "version": "0.15.0",
4
4
  "description": "Ogygie framework core: manifest/spec/error schemas, generic tenancy and gate tooling. Ships TypeScript sources — Ogygie products run tsx/vitest by construction (D1).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -13,28 +13,29 @@
13
13
  "access": "public"
14
14
  },
15
15
  "exports": {
16
- "./manifest": "./src/manifest.schema.ts",
17
- "./manifest-owners": "./src/manifest-owners.ts",
18
- "./vocabulary": "./src/vocabulary.ts",
19
- "./vocabulary-audit": "./src/vocabulary-audit.ts",
20
- "./web-lint": "./src/web-lint.ts",
21
16
  "./bundle-budget": "./src/bundle-budget.ts",
22
- "./spec": "./src/spec.schema.ts",
23
- "./impact": "./src/impact.ts",
24
- "./repo-adapter": "./src/repo-adapter.ts",
17
+ "./client": "./src/manifest-client.ts",
18
+ "./client-react": "./src/manifest-client-react.ts",
19
+ "./clock": "./src/clock.ts",
20
+ "./contract-purity": "./src/contract-purity.ts",
21
+ "./db": "./src/db.ts",
25
22
  "./decision-refs": "./src/decision-refs.ts",
26
23
  "./design-brief": "./src/design-brief.ts",
27
- "./module-purity": "./src/module-purity.ts",
28
- "./contract-purity": "./src/contract-purity.ts",
29
24
  "./errors": "./src/error.schema.ts",
30
- "./db": "./src/db.ts",
31
- "./clock": "./src/clock.ts",
32
- "./tenant": "./src/tenant.ts",
25
+ "./gateway": "./src/manifest-gateway.ts",
26
+ "./impact": "./src/impact.ts",
27
+ "./manifest": "./src/manifest.schema.ts",
28
+ "./manifest-owners": "./src/manifest-owners.ts",
29
+ "./module-purity": "./src/module-purity.ts",
30
+ "./repo-adapter": "./src/repo-adapter.ts",
31
+ "./scan": "./src/scan.ts",
33
32
  "./shared-tables": "./src/shared-tables.ts",
33
+ "./spec": "./src/spec.schema.ts",
34
+ "./tenant": "./src/tenant.ts",
34
35
  "./test-kit": "./src/test-kit.ts",
35
- "./gateway": "./src/manifest-gateway.ts",
36
- "./client": "./src/manifest-client.ts",
37
- "./client-react": "./src/manifest-client-react.ts"
36
+ "./vocabulary": "./src/vocabulary.ts",
37
+ "./vocabulary-audit": "./src/vocabulary-audit.ts",
38
+ "./web-lint": "./src/web-lint.ts"
38
39
  },
39
40
  "peerDependencies": {
40
41
  "@electric-sql/pglite": "^0.3.0",
@@ -3,7 +3,10 @@
3
3
  * factory: mounted inventory = manifest routes (mutation test included),
4
4
  * input validated WITHOUT any upstream call, OgygieError relayed intact,
5
5
  * 502/504 on upstream failure, security headers on every response, static
6
- * SPA fallback with per-request nonce, boot refusal on incoherent manifest.
6
+ * SPA fallback with per-request nonce, boot refusal on incoherent manifest,
7
+ * and the two opt-in CSP widenings (external origins, worker-src blob:) —
8
+ * default policy unchanged, closed-list validation, boot refusal on an
9
+ * invalid origin.
7
10
  * The upstream is a real local Fastify recording every hit — no mock of our
8
11
  * own relay code.
9
12
  *
@@ -462,6 +465,95 @@ describe("manifest-gateway — en-têtes de sécurité et front statique", () =>
462
465
  });
463
466
  });
464
467
 
468
+ describe("manifest-gateway — CSP, origines externes et worker-src (opt-in)", () => {
469
+ it("par défaut (aucune option csp au-delà de nonce) la CSP est STRICTEMENT identique à avant : connect-src/img-src sans ajout, aucun worker-src", async () => {
470
+ const app = await createManifestGateway(buildTestManifest(), gatewayOptions());
471
+ const response = await app.inject({ method: "GET", url: "/api/widgets" });
472
+ const csp = response.headers["content-security-policy"];
473
+ expect(csp).toContain("connect-src 'self';");
474
+ expect(csp).toContain("img-src 'self' data:;");
475
+ expect(csp).not.toContain("worker-src");
476
+ await app.close();
477
+ });
478
+
479
+ it("csp.externalOrigins ajoute chaque origine à connect-src ET img-src, et rien d'autre", async () => {
480
+ const app = await createManifestGateway(
481
+ buildTestManifest(),
482
+ gatewayOptions({
483
+ csp: { nonce: true, externalOrigins: ["https://data.geopf.fr"] },
484
+ }),
485
+ );
486
+ const response = await app.inject({ method: "GET", url: "/api/widgets" });
487
+ const csp = response.headers["content-security-policy"];
488
+ expect(csp).toContain("connect-src 'self' https://data.geopf.fr;");
489
+ expect(csp).toContain("img-src 'self' data: https://data.geopf.fr;");
490
+ // Le reste de la politique ne bouge pas.
491
+ expect(csp).toContain("script-src 'self'");
492
+ expect(csp).toContain("style-src 'self'");
493
+ expect(csp).not.toContain("worker-src");
494
+ await app.close();
495
+ });
496
+
497
+ it("csp.allowWorkerBlob ajoute worker-src 'self' blob: — absent, aucun worker-src", async () => {
498
+ const withBlob = await createManifestGateway(
499
+ buildTestManifest(),
500
+ gatewayOptions({ csp: { nonce: true, allowWorkerBlob: true } }),
501
+ );
502
+ const responseWithBlob = await withBlob.inject({ method: "GET", url: "/api/widgets" });
503
+ expect(responseWithBlob.headers["content-security-policy"]).toContain(
504
+ "worker-src 'self' blob:",
505
+ );
506
+ await withBlob.close();
507
+
508
+ const withoutBlob = await createManifestGateway(
509
+ buildTestManifest(),
510
+ gatewayOptions({ csp: { nonce: true, allowWorkerBlob: false } }),
511
+ );
512
+ const responseWithoutBlob = await withoutBlob.inject({ method: "GET", url: "/api/widgets" });
513
+ expect(responseWithoutBlob.headers["content-security-policy"]).not.toContain("worker-src");
514
+ await withoutBlob.close();
515
+ });
516
+
517
+ it.each([
518
+ ["joker de sous-domaine", "https://*.geopf.fr"],
519
+ ["schéma http, pas https", "http://data.geopf.fr"],
520
+ ["chemin après l'origine", "https://data.geopf.fr/wmts"],
521
+ ["barre oblique finale", "https://data.geopf.fr/"],
522
+ ["chaîne vide", ""],
523
+ ["pas une URL", "geopf"],
524
+ ])(
525
+ "MUTATION : une origine externe invalide (%s) refuse le démarrage — GATEWAY_CSP_ORIGIN_INVALID",
526
+ async (_label, invalidOrigin) => {
527
+ try {
528
+ await createManifestGateway(
529
+ buildTestManifest(),
530
+ gatewayOptions({ csp: { nonce: true, externalOrigins: [invalidOrigin] } }),
531
+ );
532
+ expect.unreachable("boot should have been refused");
533
+ } catch (error) {
534
+ expect(error).toBeInstanceOf(OgygieFailure);
535
+ if (error instanceof OgygieFailure) {
536
+ expect(error.error.code).toBe("GATEWAY_CSP_ORIGIN_INVALID");
537
+ }
538
+ }
539
+ },
540
+ );
541
+
542
+ it("une origine externe valide, hôte multi-niveaux avec port, est acceptée", async () => {
543
+ const app = await createManifestGateway(
544
+ buildTestManifest(),
545
+ gatewayOptions({
546
+ csp: { nonce: true, externalOrigins: ["https://tiles.example.co.uk:8443"] },
547
+ }),
548
+ );
549
+ const response = await app.inject({ method: "GET", url: "/api/widgets" });
550
+ expect(response.headers["content-security-policy"]).toContain(
551
+ "connect-src 'self' https://tiles.example.co.uk:8443;",
552
+ );
553
+ await app.close();
554
+ });
555
+ });
556
+
465
557
  describe("manifest-gateway — refus de démarrer (GATEWAY_MANIFEST_INVALID)", () => {
466
558
  async function expectBootRefusal(manifest: unknown, options: Partial<ManifestGatewayOptions>) {
467
559
  try {
@@ -39,7 +39,25 @@ export interface ManifestGatewayOptions {
39
39
  staticRoot?: string;
40
40
  rateLimit?: { max: number; timeWindow: string };
41
41
  timeoutMs: number;
42
- csp: { nonce: boolean };
42
+ csp: {
43
+ nonce: boolean;
44
+ /**
45
+ * Closed list of external https origins additionally allowed in
46
+ * connect-src and img-src — nothing else is widened (e.g. a public tile
47
+ * server the front fetches directly). Each entry is validated at boot: a
48
+ * bare https origin, no path, no wildcard. An invalid entry refuses to
49
+ * start (GATEWAY_CSP_ORIGIN_INVALID) — a boot refusal beats a silently
50
+ * widened policy. Absent or empty = today's connect-src/img-src, byte
51
+ * for byte unchanged.
52
+ */
53
+ externalOrigins?: string[];
54
+ /**
55
+ * Adds `worker-src 'self' blob:` — required by libraries that spawn
56
+ * their workers from a Blob URL (e.g. MapLibre GL). Absent or false =
57
+ * no worker-src directive at all, identical to today.
58
+ */
59
+ allowWorkerBlob?: boolean;
60
+ };
43
61
  /** Path prefixes relayed WITHOUT contract validation (e.g. an auth
44
62
  * catch-all owned by the upstream, absent from the manifest by nature). */
45
63
  passthroughPrefixes?: string[];
@@ -102,6 +120,11 @@ const SKIPPED_RESPONSE_HEADERS = new Set([
102
120
  * (regex, wildcard, spaces…) is a malformed declaration, refused at boot. */
103
121
  const WELL_FORMED_ROUTE_PATH = /^\/api(\/(:?[a-zA-Z0-9_-]+))+$/;
104
122
 
123
+ /** A bare https origin — scheme, host, optional port, nothing after it. No
124
+ * path, no query, no wildcard: the character classes below admit neither. */
125
+ const HTTPS_BARE_ORIGIN_PATTERN =
126
+ /^https:\/\/[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?::[0-9]{1,5})?$/;
127
+
105
128
  function refuseBoot(cause: string): never {
106
129
  throwOgygieError(
107
130
  {
@@ -116,6 +139,20 @@ function refuseBoot(cause: string): never {
116
139
  );
117
140
  }
118
141
 
142
+ function refuseCspExternalOrigin(origin: string): never {
143
+ throwOgygieError(
144
+ {
145
+ code: "GATEWAY_CSP_ORIGIN_INVALID",
146
+ message: "Gateway boot refused: an entry of csp.externalOrigins is not a valid origin.",
147
+ file: null,
148
+ cause: `"${origin}" must be a bare https origin — no path, no query, no wildcard (e.g. "https://data.geopf.fr")`,
149
+ suggestedFix:
150
+ "Fix the entry in csp.externalOrigins, then restart — a boot refusal beats a silently widened CSP.",
151
+ },
152
+ 500,
153
+ );
154
+ }
155
+
119
156
  export async function createManifestGateway(
120
157
  manifest: unknown,
121
158
  options: ManifestGatewayOptions,
@@ -156,6 +193,15 @@ export async function createManifestGateway(
156
193
  }
157
194
  }
158
195
 
196
+ // Closed list, validated at boot — same spirit as passthroughPrefixes:
197
+ // every entry is a deliberate, justified widening, never a broad pattern.
198
+ const externalOrigins = options.csp.externalOrigins ?? [];
199
+ for (const origin of externalOrigins) {
200
+ if (!HTTPS_BARE_ORIGIN_PATTERN.test(origin)) {
201
+ refuseCspExternalOrigin(origin);
202
+ }
203
+ }
204
+
159
205
  const app = Fastify({ logger: false });
160
206
  app.decorate("gatewayRoutes", inventory);
161
207
 
@@ -171,24 +217,32 @@ export async function createManifestGateway(
171
217
  done(null, body),
172
218
  );
173
219
 
220
+ // Strict by default: no external origin, no worker-src directive at all —
221
+ // both widenings below are opt-in, and the default is byte-for-byte what
222
+ // it was before either option existed.
223
+ const cspDirectives: Record<string, string[]> = {
224
+ "default-src": ["'self'"],
225
+ "script-src": ["'self'"],
226
+ "style-src": ["'self'"],
227
+ "img-src": ["'self'", "data:", ...externalOrigins],
228
+ "font-src": ["'self'"],
229
+ "connect-src": ["'self'", ...externalOrigins],
230
+ "object-src": ["'none'"],
231
+ "base-uri": ["'self'"],
232
+ "form-action": ["'self'"],
233
+ "frame-ancestors": ["'none'"],
234
+ };
235
+ if (options.csp.allowWorkerBlob === true) {
236
+ cspDirectives["worker-src"] = ["'self'", "blob:"];
237
+ }
238
+
174
239
  await app.register(helmet, {
175
240
  // Strict CSP, no 'unsafe-inline' anywhere; per-request nonces cover the
176
241
  // scripts the front build may inline (spec §Comportements).
177
242
  enableCSPNonces: options.csp.nonce,
178
243
  contentSecurityPolicy: {
179
244
  useDefaults: false,
180
- directives: {
181
- "default-src": ["'self'"],
182
- "script-src": ["'self'"],
183
- "style-src": ["'self'"],
184
- "img-src": ["'self'", "data:"],
185
- "font-src": ["'self'"],
186
- "connect-src": ["'self'"],
187
- "object-src": ["'none'"],
188
- "base-uri": ["'self'"],
189
- "form-action": ["'self'"],
190
- "frame-ancestors": ["'none'"],
191
- },
245
+ directives: cspDirectives,
192
246
  },
193
247
  });
194
248
  // Helmet does not cover Permissions-Policy; posed here once (spec).
@@ -0,0 +1,31 @@
1
+ /**
2
+ * @intent Prouve la règle de parcours des gates, avec le cas qui l'a imposée :
3
+ * un projet Nuxt dont `typecheck` génère `.nuxt/` que `lint` refusait ensuite.
4
+ */
5
+ import { describe, expect, it } from "vitest";
6
+ import { isScannableDirectory, NEVER_SCANNED_DIRECTORIES } from "./scan.js";
7
+
8
+ describe("isScannableDirectory", () => {
9
+ it("parcourt le code du projet", () => {
10
+ for (const name of ["apps", "packages", "src", "scripts", "features", "specs"]) {
11
+ expect(isScannableDirectory(name)).toBe(true);
12
+ }
13
+ });
14
+
15
+ it("ne parcourt AUCUN dossier en point — c'est la règle qui ne vieillit pas", () => {
16
+ for (const name of [".git", ".nuxt", ".output", ".next", ".svelte-kit", ".turbo", ".venv"]) {
17
+ expect(isScannableDirectory(name)).toBe(false);
18
+ }
19
+ });
20
+
21
+ it("ne parcourt pas les artefacts au nom ordinaire", () => {
22
+ for (const name of NEVER_SCANNED_DIRECTORIES) expect(isScannableDirectory(name)).toBe(false);
23
+ });
24
+
25
+ it("MUTATION : un dossier dont le nom COMMENCE par celui d'un artefact reste parcouru", () => {
26
+ // `distribution/` n'est pas `dist/`, et `buildings/` n'est pas `build/`.
27
+ expect(isScannableDirectory("distribution")).toBe(true);
28
+ expect(isScannableDirectory("buildings")).toBe(true);
29
+ expect(isScannableDirectory("node_modules_backup")).toBe(true);
30
+ });
31
+ });
package/src/scan.ts ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * @intent Which directories a gate WALKS — the one rule, for every gate.
3
+ *
4
+ * It existed five times, slightly differently, in as many scripts. That is how
5
+ * it fell behind: the lists were written when a build produced `dist/`, and a
6
+ * modern framework generates `.nuxt/`, `.output/`, `.next/`, `.svelte-kit/`,
7
+ * while a Python package carries `.venv/`. A gate that walks into them reports
8
+ * bans and forbidden words found in code NOBODY WROTE — and worse, it does so
9
+ * only after a build, so the loop passes in CI and fails on the machine of
10
+ * whoever just ran it. A consumer project hit exactly that: `typecheck`
11
+ * generated `.nuxt/`, and `lint` then refused a type-suppression directive the
12
+ * framework had written into its own generated types.
13
+ *
14
+ * The rule is deliberately blunt, because a subtle one would fall behind again:
15
+ * a DOT-directory is never source. Everything a project actually writes lives
16
+ * in a plainly-named folder; tooling, caches, virtual environments and build
17
+ * output all hide behind a dot, by a convention older than any of these tools.
18
+ */
19
+
20
+ /** Plainly-named directories that are still artefacts, not source. */
21
+ export const NEVER_SCANNED_DIRECTORIES = [
22
+ "node_modules",
23
+ "dist",
24
+ "dist-ui",
25
+ "build",
26
+ "coverage",
27
+ ] as const;
28
+
29
+ /** Does a gate walk into this directory? Its NAME is the whole question. */
30
+ export function isScannableDirectory(name: string): boolean {
31
+ // `.git`, `.nuxt`, `.output`, `.next`, `.svelte-kit`, `.turbo`, `.venv`,
32
+ // `.pytest_cache`… — one rule instead of a list that ages.
33
+ if (name.startsWith(".")) return false;
34
+ return !(NEVER_SCANNED_DIRECTORIES as readonly string[]).includes(name);
35
+ }