@ogygie/core 0.1.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/LICENSE +21 -0
- package/README.md +139 -0
- package/package.json +77 -0
- package/src/bundle-budget.test.ts +64 -0
- package/src/bundle-budget.ts +51 -0
- package/src/clock.ts +24 -0
- package/src/contract-purity.test.ts +185 -0
- package/src/contract-purity.ts +213 -0
- package/src/db.ts +9 -0
- package/src/decision-refs.test.ts +114 -0
- package/src/decision-refs.ts +103 -0
- package/src/design-brief.test.ts +221 -0
- package/src/design-brief.ts +581 -0
- package/src/error.schema.ts +62 -0
- package/src/examples/erreur-convention-ci.json +10 -0
- package/src/examples/erreur-validation-runtime.json +10 -0
- package/src/manifest-client.test.ts +287 -0
- package/src/manifest-client.ts +313 -0
- package/src/manifest-gateway.test.ts +541 -0
- package/src/manifest-gateway.ts +392 -0
- package/src/manifest.schema.test.ts +255 -0
- package/src/manifest.schema.ts +187 -0
- package/src/module-purity.test.ts +117 -0
- package/src/module-purity.ts +127 -0
- package/src/product-vocabulary.scan.test.ts +142 -0
- package/src/shared-tables.test.ts +136 -0
- package/src/shared-tables.ts +113 -0
- package/src/spec.schema.test.ts +143 -0
- package/src/spec.schema.ts +182 -0
- package/src/tenant.ts +207 -0
- package/src/test-kit.ts +30 -0
- package/src/vocabulary.test.ts +46 -0
- package/src/vocabulary.ts +36 -0
- package/src/web-lint.test.ts +100 -0
- package/src/web-lint.ts +176 -0
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @intent GENERIC manifest-driven gateway factory (D27), framework side: this
|
|
3
|
+
* module knows ZERO product vocabulary (D10 amended — generic tooling lives in
|
|
4
|
+
* the core), and the vocabulary test next to it proves it. It derives a pure
|
|
5
|
+
* relay from an Ogygie manifest: every declared route becomes a typed relay to
|
|
6
|
+
* the upstream api, nothing else is reachable. Boot REFUSES an incoherent
|
|
7
|
+
* manifest (GATEWAY_MANIFEST_INVALID) — a boot refusal beats a lying relay.
|
|
8
|
+
*
|
|
9
|
+
* The consuming app owns everything product-aware: the schema registry, the
|
|
10
|
+
* env contract, the server entry point, and the closed passthrough list.
|
|
11
|
+
* Usage: see packages/ogygie-core/README.md.
|
|
12
|
+
*
|
|
13
|
+
* Input validation (2nd barrier, no transitive trust — the api revalidates):
|
|
14
|
+
* the declared input schema validates the JSON body on POST/PUT/PATCH, and
|
|
15
|
+
* the merged { ...query, ...params } object on GET/DELETE — the exact slots
|
|
16
|
+
* the api binds those schemas to (body / querystring / params).
|
|
17
|
+
*
|
|
18
|
+
* Relay purity: upstream status and body are forwarded byte-for-byte
|
|
19
|
+
* (OgygieError included, Set-Cookie retransmitted integrally). The gateway
|
|
20
|
+
* only OWNS the transverse security headers (CSP, HSTS…): those are stripped
|
|
21
|
+
* from upstream responses so the strict policy set HERE is authoritative.
|
|
22
|
+
*/
|
|
23
|
+
import { readFileSync } from "node:fs";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import { randomUUID } from "node:crypto";
|
|
26
|
+
import Fastify, { type FastifyReply, type FastifyRequest } from "fastify";
|
|
27
|
+
import helmet from "@fastify/helmet";
|
|
28
|
+
import rateLimit from "@fastify/rate-limit";
|
|
29
|
+
import fastifyStatic from "@fastify/static";
|
|
30
|
+
import type { z } from "zod";
|
|
31
|
+
import { OgygieManifestSchema } from "./manifest.schema.js";
|
|
32
|
+
import { ogygieError, throwOgygieError } from "./error.schema.js";
|
|
33
|
+
|
|
34
|
+
export interface ManifestGatewayOptions {
|
|
35
|
+
upstreamBaseUrl: string;
|
|
36
|
+
/** Declared input/output schemas, keyed by their exported name. */
|
|
37
|
+
schemas: Record<string, z.ZodType>;
|
|
38
|
+
/** Front build to serve (SPA fallback on index.html); nothing served when absent. */
|
|
39
|
+
staticRoot?: string;
|
|
40
|
+
rateLimit?: { max: number; timeWindow: string };
|
|
41
|
+
timeoutMs: number;
|
|
42
|
+
csp: { nonce: boolean };
|
|
43
|
+
/** Path prefixes relayed WITHOUT contract validation (e.g. an auth
|
|
44
|
+
* catch-all owned by the upstream, absent from the manifest by nature). */
|
|
45
|
+
passthroughPrefixes?: string[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface GatewayRouteInventoryEntry {
|
|
49
|
+
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
50
|
+
path: string;
|
|
51
|
+
schemaInput: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
declare module "fastify" {
|
|
55
|
+
interface FastifyInstance {
|
|
56
|
+
/** Mounted relays, in manifest order — the startup log and the tests read it. */
|
|
57
|
+
gatewayRoutes: ReadonlyArray<GatewayRouteInventoryEntry>;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const METHODS_WITH_BODY = new Set(["POST", "PUT", "PATCH"]);
|
|
62
|
+
|
|
63
|
+
/** Hop-by-hop or recomputed headers, never forwarded upstream. */
|
|
64
|
+
const SKIPPED_REQUEST_HEADERS = new Set([
|
|
65
|
+
"host",
|
|
66
|
+
"connection",
|
|
67
|
+
"content-length",
|
|
68
|
+
"transfer-encoding",
|
|
69
|
+
"accept-encoding",
|
|
70
|
+
"keep-alive",
|
|
71
|
+
"expect",
|
|
72
|
+
"upgrade",
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
/** Not relayed back: hop-by-hop headers, recomputed lengths, and the
|
|
76
|
+
* security headers the gateway is the single owner of (spec: posed HERE,
|
|
77
|
+
* once for the whole product — an upstream copy must not override them). */
|
|
78
|
+
const SKIPPED_RESPONSE_HEADERS = new Set([
|
|
79
|
+
"connection",
|
|
80
|
+
"keep-alive",
|
|
81
|
+
"transfer-encoding",
|
|
82
|
+
"content-length",
|
|
83
|
+
"content-encoding",
|
|
84
|
+
"set-cookie",
|
|
85
|
+
"content-security-policy",
|
|
86
|
+
"cross-origin-embedder-policy",
|
|
87
|
+
"cross-origin-opener-policy",
|
|
88
|
+
"cross-origin-resource-policy",
|
|
89
|
+
"origin-agent-cluster",
|
|
90
|
+
"permissions-policy",
|
|
91
|
+
"referrer-policy",
|
|
92
|
+
"strict-transport-security",
|
|
93
|
+
"x-content-type-options",
|
|
94
|
+
"x-dns-prefetch-control",
|
|
95
|
+
"x-download-options",
|
|
96
|
+
"x-frame-options",
|
|
97
|
+
"x-permitted-cross-domain-policies",
|
|
98
|
+
"x-xss-protection",
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
/** Manifest route paths are plain /api segments with :params — anything else
|
|
102
|
+
* (regex, wildcard, spaces…) is a malformed declaration, refused at boot. */
|
|
103
|
+
const WELL_FORMED_ROUTE_PATH = /^\/api(\/(:?[a-zA-Z0-9_-]+))+$/;
|
|
104
|
+
|
|
105
|
+
function refuseBoot(cause: string): never {
|
|
106
|
+
throwOgygieError(
|
|
107
|
+
{
|
|
108
|
+
code: "GATEWAY_MANIFEST_INVALID",
|
|
109
|
+
message: "Gateway boot refused: the manifest cannot be derived into relays.",
|
|
110
|
+
file: "ogygie.manifest.json",
|
|
111
|
+
cause,
|
|
112
|
+
suggestedFix:
|
|
113
|
+
"Fix the manifest (or register the missing schema in the gateway's schema registry), then restart — a boot refusal beats a lying relay.",
|
|
114
|
+
},
|
|
115
|
+
500,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function createManifestGateway(
|
|
120
|
+
manifest: unknown,
|
|
121
|
+
options: ManifestGatewayOptions,
|
|
122
|
+
) {
|
|
123
|
+
const parsedManifest = OgygieManifestSchema.safeParse(manifest);
|
|
124
|
+
if (!parsedManifest.success) {
|
|
125
|
+
refuseBoot(
|
|
126
|
+
parsedManifest.error.issues
|
|
127
|
+
.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
|
|
128
|
+
.join(" ; "),
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const inventory: GatewayRouteInventoryEntry[] = [];
|
|
133
|
+
const mounted = new Set<string>();
|
|
134
|
+
for (const feature of parsedManifest.data.features) {
|
|
135
|
+
for (const route of feature.routes) {
|
|
136
|
+
if (!WELL_FORMED_ROUTE_PATH.test(route.path)) {
|
|
137
|
+
refuseBoot(`feature "${feature.name}": route path "${route.path}" is malformed`);
|
|
138
|
+
}
|
|
139
|
+
for (const schemaName of [route.schemaInput, route.schemaOutput]) {
|
|
140
|
+
if (options.schemas[schemaName] === undefined) {
|
|
141
|
+
refuseBoot(
|
|
142
|
+
`feature "${feature.name}": route ${route.method} ${route.path} declares schema "${schemaName}" which is not in the schema registry`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const key = `${route.method} ${route.path}`;
|
|
147
|
+
if (mounted.has(key)) {
|
|
148
|
+
refuseBoot(`route ${key} is declared twice`);
|
|
149
|
+
}
|
|
150
|
+
mounted.add(key);
|
|
151
|
+
inventory.push({
|
|
152
|
+
method: route.method,
|
|
153
|
+
path: route.path,
|
|
154
|
+
schemaInput: route.schemaInput,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const app = Fastify({ logger: false });
|
|
160
|
+
app.decorate("gatewayRoutes", inventory);
|
|
161
|
+
|
|
162
|
+
// Bodies are kept RAW (string/buffer): the gateway validates a parsed COPY
|
|
163
|
+
// but relays the original bytes — it never re-serializes a payload.
|
|
164
|
+
app.removeAllContentTypeParsers();
|
|
165
|
+
app.addContentTypeParser(
|
|
166
|
+
"application/json",
|
|
167
|
+
{ parseAs: "string" },
|
|
168
|
+
(_request, body, done) => done(null, body),
|
|
169
|
+
);
|
|
170
|
+
app.addContentTypeParser("*", { parseAs: "buffer" }, (_request, body, done) =>
|
|
171
|
+
done(null, body),
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
await app.register(helmet, {
|
|
175
|
+
// Strict CSP, no 'unsafe-inline' anywhere; per-request nonces cover the
|
|
176
|
+
// scripts the front build may inline (spec §Comportements).
|
|
177
|
+
enableCSPNonces: options.csp.nonce,
|
|
178
|
+
contentSecurityPolicy: {
|
|
179
|
+
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
|
+
},
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
// Helmet does not cover Permissions-Policy; posed here once (spec).
|
|
195
|
+
app.addHook("onSend", async (_request, reply) => {
|
|
196
|
+
reply.header("permissions-policy", "camera=(), microphone=(), geolocation=()");
|
|
197
|
+
});
|
|
198
|
+
await app.register(rateLimit, options.rateLimit ?? { max: 300, timeWindow: "1 minute" });
|
|
199
|
+
|
|
200
|
+
async function relay(request: FastifyRequest, reply: FastifyReply) {
|
|
201
|
+
const incomingId = request.headers["x-request-id"];
|
|
202
|
+
const requestId = typeof incomingId === "string" ? incomingId : randomUUID();
|
|
203
|
+
const headers: Record<string, string> = {};
|
|
204
|
+
for (const [name, value] of Object.entries(request.headers)) {
|
|
205
|
+
if (SKIPPED_REQUEST_HEADERS.has(name) || value === undefined) continue;
|
|
206
|
+
headers[name] = Array.isArray(value) ? value.join(", ") : value;
|
|
207
|
+
}
|
|
208
|
+
headers["x-request-id"] = requestId;
|
|
209
|
+
reply.header("x-request-id", requestId);
|
|
210
|
+
|
|
211
|
+
const body =
|
|
212
|
+
METHODS_WITH_BODY.has(request.method) && request.body !== undefined
|
|
213
|
+
? (request.body as string | Buffer)
|
|
214
|
+
: undefined;
|
|
215
|
+
|
|
216
|
+
let upstreamResponse: Response;
|
|
217
|
+
try {
|
|
218
|
+
upstreamResponse = await fetch(
|
|
219
|
+
new URL(request.raw.url ?? request.url, options.upstreamBaseUrl),
|
|
220
|
+
{
|
|
221
|
+
method: request.method,
|
|
222
|
+
headers,
|
|
223
|
+
...(body === undefined ? {} : { body }),
|
|
224
|
+
redirect: "manual",
|
|
225
|
+
signal: AbortSignal.timeout(options.timeoutMs),
|
|
226
|
+
},
|
|
227
|
+
);
|
|
228
|
+
} catch (error) {
|
|
229
|
+
// No internal detail leaks: neither the private upstream URL nor a stack.
|
|
230
|
+
const timedOut = error instanceof Error && error.name === "TimeoutError";
|
|
231
|
+
return reply.status(timedOut ? 504 : 502).send(
|
|
232
|
+
ogygieError({
|
|
233
|
+
code: timedOut ? "GATEWAY_UPSTREAM_TIMEOUT" : "GATEWAY_UPSTREAM_UNAVAILABLE",
|
|
234
|
+
message: timedOut
|
|
235
|
+
? "The upstream api did not answer within the gateway timeout."
|
|
236
|
+
: "The upstream api is unreachable from the gateway.",
|
|
237
|
+
file: null,
|
|
238
|
+
cause: timedOut
|
|
239
|
+
? `no upstream response within ${options.timeoutMs} ms`
|
|
240
|
+
: "upstream connection failed",
|
|
241
|
+
suggestedFix:
|
|
242
|
+
"Check that the api service is up and reachable on the private network, then retry.",
|
|
243
|
+
}),
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
reply.status(upstreamResponse.status);
|
|
248
|
+
upstreamResponse.headers.forEach((value, name) => {
|
|
249
|
+
if (!SKIPPED_RESPONSE_HEADERS.has(name)) reply.header(name, value);
|
|
250
|
+
});
|
|
251
|
+
const setCookies = upstreamResponse.headers.getSetCookie();
|
|
252
|
+
if (setCookies.length > 0) reply.header("set-cookie", setCookies);
|
|
253
|
+
return reply.send(Buffer.from(await upstreamResponse.arrayBuffer()));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function contractViolation(reply: FastifyReply, cause: string) {
|
|
257
|
+
// Same code and semantics as the api's own handler — emitted BY the
|
|
258
|
+
// gateway, before any upstream call (invalid traffic never reaches the
|
|
259
|
+
// private network).
|
|
260
|
+
return reply.status(400).send(
|
|
261
|
+
ogygieError({
|
|
262
|
+
code: "CONTRACT_VALIDATION_FAILED",
|
|
263
|
+
message: "The request does not satisfy the route's declared input contract.",
|
|
264
|
+
file: null,
|
|
265
|
+
cause,
|
|
266
|
+
suggestedFix: "Fix the listed fields to match the declared Zod input schema.",
|
|
267
|
+
}),
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
for (const route of inventory) {
|
|
272
|
+
const schema = options.schemas[route.schemaInput];
|
|
273
|
+
if (schema === undefined) continue; // unreachable — boot already refused
|
|
274
|
+
app.route({
|
|
275
|
+
method: route.method,
|
|
276
|
+
url: route.path,
|
|
277
|
+
handler: async (request, reply) => {
|
|
278
|
+
let input: unknown;
|
|
279
|
+
if (METHODS_WITH_BODY.has(route.method)) {
|
|
280
|
+
const raw = request.body;
|
|
281
|
+
if (raw === undefined || raw === null || raw === "") {
|
|
282
|
+
input = {};
|
|
283
|
+
} else if (typeof raw === "string") {
|
|
284
|
+
try {
|
|
285
|
+
input = JSON.parse(raw);
|
|
286
|
+
} catch {
|
|
287
|
+
return contractViolation(reply, "request body is not valid JSON");
|
|
288
|
+
}
|
|
289
|
+
} else {
|
|
290
|
+
return contractViolation(reply, "request body is not JSON");
|
|
291
|
+
}
|
|
292
|
+
} else {
|
|
293
|
+
input = { ...(request.query as object), ...(request.params as object) };
|
|
294
|
+
}
|
|
295
|
+
const parsed = schema.safeParse(input);
|
|
296
|
+
if (!parsed.success) {
|
|
297
|
+
return contractViolation(
|
|
298
|
+
reply,
|
|
299
|
+
parsed.error.issues
|
|
300
|
+
.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
|
|
301
|
+
.join(" ; "),
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
return relay(request, reply);
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
for (const prefix of options.passthroughPrefixes ?? []) {
|
|
310
|
+
app.route({
|
|
311
|
+
method: ["GET", "POST", "PUT", "PATCH", "DELETE"],
|
|
312
|
+
url: `${prefix.replace(/\/$/, "")}/*`,
|
|
313
|
+
handler: relay,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (options.staticRoot !== undefined) {
|
|
318
|
+
await app.register(fastifyStatic, {
|
|
319
|
+
root: options.staticRoot,
|
|
320
|
+
wildcard: true,
|
|
321
|
+
index: false,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// index.html is read once and cached: the build is immutable while running.
|
|
326
|
+
let indexHtmlCache: string | null = null;
|
|
327
|
+
function readIndexHtml(staticRoot: string): string | null {
|
|
328
|
+
if (indexHtmlCache === null) {
|
|
329
|
+
try {
|
|
330
|
+
indexHtmlCache = readFileSync(join(staticRoot, "index.html"), "utf8");
|
|
331
|
+
} catch {
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return indexHtmlCache;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* The SPA shell, with the per-request CSP nonce stamped on every script tag.
|
|
340
|
+
* Returns false when there is no front to serve, so the caller falls through
|
|
341
|
+
* to the structured 404 instead of an empty page.
|
|
342
|
+
*/
|
|
343
|
+
function sendSpaShell(reply: FastifyReply): boolean {
|
|
344
|
+
const staticRoot = options.staticRoot;
|
|
345
|
+
if (staticRoot === undefined) return false;
|
|
346
|
+
const indexHtml = readIndexHtml(staticRoot);
|
|
347
|
+
if (indexHtml === null) return false;
|
|
348
|
+
const html = options.csp.nonce
|
|
349
|
+
? indexHtml.replaceAll("<script", `<script nonce="${reply.cspNonce.script}"`)
|
|
350
|
+
: indexHtml;
|
|
351
|
+
reply.type("text/html; charset=utf-8").send(html);
|
|
352
|
+
return true;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// The ROOT is the product's entry URL, and it is NOT a not-found: the static
|
|
356
|
+
// plugin owns "/" (wildcard, index disabled) and would answer 403 on the
|
|
357
|
+
// directory, so the shell is served explicitly here — same nonce path as the
|
|
358
|
+
// client-side routes below.
|
|
359
|
+
if (options.staticRoot !== undefined) {
|
|
360
|
+
app.route({
|
|
361
|
+
method: ["GET", "HEAD"],
|
|
362
|
+
url: "/",
|
|
363
|
+
handler: async (_request, reply) => {
|
|
364
|
+
if (!sendSpaShell(reply)) {
|
|
365
|
+
return reply.callNotFound();
|
|
366
|
+
}
|
|
367
|
+
return reply;
|
|
368
|
+
},
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
app.setNotFoundHandler((request, reply) => {
|
|
373
|
+
const isReadForHtml = request.method === "GET" || request.method === "HEAD";
|
|
374
|
+
if (!request.url.startsWith("/api/") && isReadForHtml && sendSpaShell(reply)) {
|
|
375
|
+
// SPA fallback: every client-side route serves the shell.
|
|
376
|
+
return reply;
|
|
377
|
+
}
|
|
378
|
+
return reply.status(404).send(
|
|
379
|
+
ogygieError({
|
|
380
|
+
code: "GATEWAY_ROUTE_UNKNOWN",
|
|
381
|
+
message: "No declared route matches this request.",
|
|
382
|
+
file: null,
|
|
383
|
+
cause: `${request.method} ${request.url} is not declared in the manifest`,
|
|
384
|
+
suggestedFix:
|
|
385
|
+
"Declare the route in ogygie.manifest.json — an undeclared route is unreachable by design (D27).",
|
|
386
|
+
}),
|
|
387
|
+
);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
await app.ready();
|
|
391
|
+
return app;
|
|
392
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @intent Mutation tests of the manifest front-feature extension (D22: the
|
|
3
|
+
* gate evolution is born with its negative tests). The api-feature path is
|
|
4
|
+
* already covered end-to-end by verify-manifest in CI.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
import { OgygieManifestSchema } from "./manifest.schema.js";
|
|
8
|
+
|
|
9
|
+
function manifestWithFeature(feature: Record<string, unknown>) {
|
|
10
|
+
return {
|
|
11
|
+
version: 1,
|
|
12
|
+
product: { name: "acme", description: "SaaS générique servant de fixture aux tests du manifeste." },
|
|
13
|
+
apps: [
|
|
14
|
+
{ name: "api", path: "apps/api", type: "api", entryPoint: "apps/api/src/server.ts" },
|
|
15
|
+
{ name: "web", path: "apps/web", type: "front", entryPoint: "apps/web/src/main.tsx" },
|
|
16
|
+
],
|
|
17
|
+
features: [
|
|
18
|
+
{
|
|
19
|
+
name: "web-shell",
|
|
20
|
+
intent: "Bootstrap du front : patterns canoniques, auth, écran de preuve.",
|
|
21
|
+
folder: "src",
|
|
22
|
+
spec: "specs/web-shell.spec.md",
|
|
23
|
+
routes: [],
|
|
24
|
+
dbTables: [],
|
|
25
|
+
dependsOn: [],
|
|
26
|
+
status: "delivered",
|
|
27
|
+
...feature,
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
conventions: { agentsMd: "AGENTS.md", failuresMd: "FAILURES.md", specsFolder: "specs/" },
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("manifest — features front", () => {
|
|
35
|
+
it("une feature front valide (app: web, routes/dbTables vides) est acceptée", () => {
|
|
36
|
+
const parsed = OgygieManifestSchema.safeParse(manifestWithFeature({ app: "web" }));
|
|
37
|
+
expect(parsed.success).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("MUTATION : app inconnu (typo) est refusé", () => {
|
|
41
|
+
const parsed = OgygieManifestSchema.safeParse(manifestWithFeature({ app: "wbe" }));
|
|
42
|
+
expect(parsed.success).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("MUTATION : une feature front qui déclare une route HTTP est refusée (les routes appartiennent à l'api)", () => {
|
|
46
|
+
const parsed = OgygieManifestSchema.safeParse(
|
|
47
|
+
manifestWithFeature({
|
|
48
|
+
app: "web",
|
|
49
|
+
routes: [
|
|
50
|
+
{
|
|
51
|
+
method: "GET",
|
|
52
|
+
path: "/api/orders",
|
|
53
|
+
description: "duplication interdite de la route api",
|
|
54
|
+
schemaInput: "ListOrdersInputSchema",
|
|
55
|
+
schemaOutput: "ListOrdersOutputSchema",
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
}),
|
|
59
|
+
);
|
|
60
|
+
expect(parsed.success).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("MUTATION : une feature front qui déclare une table DB est refusée", () => {
|
|
64
|
+
const parsed = OgygieManifestSchema.safeParse(
|
|
65
|
+
manifestWithFeature({ app: "web", dbTables: ["orders"] }),
|
|
66
|
+
);
|
|
67
|
+
expect(parsed.success).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("sans champ app, une feature reste une feature api (compatibilité intégrale)", () => {
|
|
71
|
+
const parsed = OgygieManifestSchema.safeParse(manifestWithFeature({}));
|
|
72
|
+
expect(parsed.success).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Front-derivation extension (spec front-derivation): a mutation route may
|
|
77
|
+
// declare the cache roots it stales beyond its own — read by the derived
|
|
78
|
+
// client, verified here: a target matching nothing is a typo that would
|
|
79
|
+
// silently never invalidate anything.
|
|
80
|
+
function manifestWithApiRoutes(routes: ReadonlyArray<Record<string, unknown>>) {
|
|
81
|
+
return manifestWithFeature({
|
|
82
|
+
name: "order-log",
|
|
83
|
+
intent: "Relevés append-only, valeur courante calculée jamais stockée.",
|
|
84
|
+
folder: "src/features/order-log",
|
|
85
|
+
spec: "specs/order-log.spec.md",
|
|
86
|
+
routes,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const createRecordRoute = {
|
|
91
|
+
method: "POST",
|
|
92
|
+
path: "/api/records",
|
|
93
|
+
description: "Enregistre un relevé constaté (append-only).",
|
|
94
|
+
schemaInput: "CreateRecordInputSchema",
|
|
95
|
+
schemaOutput: "CreateRecordOutputSchema",
|
|
96
|
+
};
|
|
97
|
+
const listArchiveRoute = {
|
|
98
|
+
method: "GET",
|
|
99
|
+
path: "/api/record-archive",
|
|
100
|
+
description: "Vue archive avec valeur courante calculée ou null.",
|
|
101
|
+
schemaInput: "ListRecordArchiveInputSchema",
|
|
102
|
+
schemaOutput: "ListRecordArchiveOutputSchema",
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
describe("manifest — invalidations croisées des routes (spec front-derivation)", () => {
|
|
106
|
+
it("une route sans invalidates reste acceptée (champ optionnel, élargissement libre)", () => {
|
|
107
|
+
const parsed = OgygieManifestSchema.safeParse(
|
|
108
|
+
manifestWithApiRoutes([createRecordRoute]),
|
|
109
|
+
);
|
|
110
|
+
expect(parsed.success).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("invalidates visant la racine d'une route existante est accepté", () => {
|
|
114
|
+
const parsed = OgygieManifestSchema.safeParse(
|
|
115
|
+
manifestWithApiRoutes([
|
|
116
|
+
{ ...createRecordRoute, invalidates: ["record-archive"] },
|
|
117
|
+
listArchiveRoute,
|
|
118
|
+
]),
|
|
119
|
+
);
|
|
120
|
+
expect(parsed.success).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("invalidates visant un nom de feature existant est accepté", () => {
|
|
124
|
+
const parsed = OgygieManifestSchema.safeParse(
|
|
125
|
+
manifestWithApiRoutes([
|
|
126
|
+
{ ...createRecordRoute, invalidates: ["order-log"] },
|
|
127
|
+
]),
|
|
128
|
+
);
|
|
129
|
+
expect(parsed.success).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("MUTATION : invalidates visant une cible inconnue (typo) est refusé", () => {
|
|
133
|
+
const parsed = OgygieManifestSchema.safeParse(
|
|
134
|
+
manifestWithApiRoutes([
|
|
135
|
+
{ ...createRecordRoute, invalidates: ["record-archivee"] },
|
|
136
|
+
listArchiveRoute,
|
|
137
|
+
]),
|
|
138
|
+
);
|
|
139
|
+
expect(parsed.success).toBe(false);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// Gateway extension (D27): the gateway app type exists, and
|
|
144
|
+
// a gateway feature — like a front one — OWNS no routes and no tables (it
|
|
145
|
+
// RELAYS the api's declared routes, it never declares its own).
|
|
146
|
+
function manifestWithGatewayFeature(feature: Record<string, unknown>) {
|
|
147
|
+
const base = manifestWithFeature({
|
|
148
|
+
name: "edge-gateway",
|
|
149
|
+
intent: "Passerelle publique dérivée du manifeste : relais typé des routes déclarées.",
|
|
150
|
+
app: "gateway",
|
|
151
|
+
spec: "specs/edge-gateway.spec.md",
|
|
152
|
+
...feature,
|
|
153
|
+
});
|
|
154
|
+
base.apps.push({
|
|
155
|
+
name: "gateway",
|
|
156
|
+
path: "apps/gateway",
|
|
157
|
+
type: "gateway",
|
|
158
|
+
entryPoint: "apps/gateway/src/server.ts",
|
|
159
|
+
});
|
|
160
|
+
return base;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
describe("manifest — app gateway (D27)", () => {
|
|
164
|
+
it("une feature gateway valide (app: gateway, routes/dbTables vides) est acceptée", () => {
|
|
165
|
+
const parsed = OgygieManifestSchema.safeParse(manifestWithGatewayFeature({}));
|
|
166
|
+
expect(parsed.success).toBe(true);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("MUTATION : un type d'app inconnu (typo « gatway ») est refusé", () => {
|
|
170
|
+
const manifest = manifestWithGatewayFeature({});
|
|
171
|
+
const gatewayApp = manifest.apps.find((app) => app.name === "gateway");
|
|
172
|
+
if (gatewayApp !== undefined) gatewayApp.type = "gatway";
|
|
173
|
+
expect(OgygieManifestSchema.safeParse(manifest).success).toBe(false);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("MUTATION : une feature gateway qui déclare une route HTTP est refusée (elle relaie, elle ne possède pas)", () => {
|
|
177
|
+
const parsed = OgygieManifestSchema.safeParse(
|
|
178
|
+
manifestWithGatewayFeature({
|
|
179
|
+
routes: [
|
|
180
|
+
{
|
|
181
|
+
method: "GET",
|
|
182
|
+
path: "/api/orders",
|
|
183
|
+
description: "duplication interdite de la route api",
|
|
184
|
+
schemaInput: "ListOrdersInputSchema",
|
|
185
|
+
schemaOutput: "ListOrdersOutputSchema",
|
|
186
|
+
},
|
|
187
|
+
],
|
|
188
|
+
}),
|
|
189
|
+
);
|
|
190
|
+
expect(parsed.success).toBe(false);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// Standalone-repo extension (spec ogygie-standalone-repo): a consumer manifest
|
|
195
|
+
// pins its framework version (`ogygieVersion`), and the framework repo's own
|
|
196
|
+
// manifest describes its published packages, templates and governed-files
|
|
197
|
+
// module (`framework`) so B1's discoverability holds without exploration.
|
|
198
|
+
describe("manifest — ogygieVersion et bloc framework (spec ogygie-standalone-repo)", () => {
|
|
199
|
+
it("un manifeste consommateur avec ogygieVersion x.y.z est accepté", () => {
|
|
200
|
+
const manifest = { ...manifestWithFeature({}), ogygieVersion: "0.1.0" };
|
|
201
|
+
expect(OgygieManifestSchema.safeParse(manifest).success).toBe(true);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("MUTATION : un ogygieVersion non x.y.z (« 0.1 ») est refusé", () => {
|
|
205
|
+
const manifest = { ...manifestWithFeature({}), ogygieVersion: "0.1" };
|
|
206
|
+
expect(OgygieManifestSchema.safeParse(manifest).success).toBe(false);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("MUTATION : un ogygieVersion avec préfixe de plage (« ^0.1.0 ») est refusé", () => {
|
|
210
|
+
const manifest = { ...manifestWithFeature({}), ogygieVersion: "^0.1.0" };
|
|
211
|
+
expect(OgygieManifestSchema.safeParse(manifest).success).toBe(false);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("le bloc framework complet (packages, templates, governedFiles) est accepté", () => {
|
|
215
|
+
const manifest = {
|
|
216
|
+
...manifestWithFeature({}),
|
|
217
|
+
framework: {
|
|
218
|
+
packages: [
|
|
219
|
+
{
|
|
220
|
+
name: "@ogygie/core",
|
|
221
|
+
path: "packages/ogygie-core",
|
|
222
|
+
intent: "Schémas, erreurs et outillage génériques du framework.",
|
|
223
|
+
},
|
|
224
|
+
],
|
|
225
|
+
templates: { project: "templates/project", feature: "templates/feature" },
|
|
226
|
+
governedFiles: "packages/ogygie-cli/src/governed-files.ts",
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
expect(OgygieManifestSchema.safeParse(manifest).success).toBe(true);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("MUTATION : un bloc framework sans governedFiles est refusé (la liste gouvernée doit être localisable)", () => {
|
|
233
|
+
const manifest = {
|
|
234
|
+
...manifestWithFeature({}),
|
|
235
|
+
framework: {
|
|
236
|
+
packages: [],
|
|
237
|
+
templates: { project: "templates/project", feature: "templates/feature" },
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
expect(OgygieManifestSchema.safeParse(manifest).success).toBe(false);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("les deux champs restent optionnels (compatibilité intégrale des manifestes existants)", () => {
|
|
244
|
+
expect(OgygieManifestSchema.safeParse(manifestWithFeature({})).success).toBe(true);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe("manifest — app gateway, suite (D27)", () => {
|
|
249
|
+
it("MUTATION (bis) : une feature gateway qui déclare une table DB est refusée", () => {
|
|
250
|
+
const parsed = OgygieManifestSchema.safeParse(
|
|
251
|
+
manifestWithGatewayFeature({ dbTables: ["orders"] }),
|
|
252
|
+
);
|
|
253
|
+
expect(parsed.success).toBe(false);
|
|
254
|
+
});
|
|
255
|
+
});
|