@ogygie/studio 0.29.2 → 0.31.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/dist/server.js CHANGED
@@ -1397,12 +1397,22 @@ async function readParsedSpec(deps, specId) {
1397
1397
  }
1398
1398
  return parsed.spec;
1399
1399
  }
1400
+ async function generatedProductOf(deps) {
1401
+ if (!await deps.storage.exists("ogygie.manifest.json")) return false;
1402
+ try {
1403
+ const raw = JSON.parse(await deps.storage.readFile("ogygie.manifest.json"));
1404
+ return typeof raw === "object" && raw !== null && "generation" in raw && raw.generation !== null;
1405
+ } catch {
1406
+ return false;
1407
+ }
1408
+ }
1400
1409
  async function getSpec(deps, specId) {
1401
1410
  const spec = await readParsedSpec(deps, specId);
1402
1411
  return {
1403
1412
  frontmatter: spec.frontmatter,
1404
1413
  body: spec.body,
1405
- presentSections: presentSectionsOf(spec.body)
1414
+ presentSections: presentSectionsOf(spec.body),
1415
+ generatedProduct: await generatedProductOf(deps)
1406
1416
  };
1407
1417
  }
1408
1418
  async function saveSpec(deps, specId, input) {
@@ -1931,7 +1941,9 @@ var GetSpecOutputSchema = z7.object({
1931
1941
  frontmatter: SpecFrontmatterSchema,
1932
1942
  /** Markdown body after the frontmatter block, byte-exact. */
1933
1943
  body: z7.string(),
1934
- presentSections: z7.array(z7.string())
1944
+ presentSections: z7.array(z7.string()),
1945
+ /** D58: the product's map declares a `generation` block — a spec without `# Génération` is then hand-written, nothing derives from it. */
1946
+ generatedProduct: z7.boolean().default(false)
1935
1947
  });
1936
1948
  var UpdateSpecInputSchema = z7.object({
1937
1949
  frontmatter: SpecFrontmatterSchema,
@@ -2191,7 +2203,7 @@ async function registerStudioRoutes(app, resolver) {
2191
2203
  }
2192
2204
 
2193
2205
  // src/server/hosted/routes.ts
2194
- import { z as z9 } from "zod";
2206
+ import { z as z10 } from "zod";
2195
2207
 
2196
2208
  // src/server/hosted/auth.ts
2197
2209
  import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
@@ -2246,19 +2258,27 @@ function consumeMagicLink(deps, token) {
2246
2258
  deps.consumed.set(nonce, exp);
2247
2259
  return email;
2248
2260
  }
2249
- function issueSession(deps, email) {
2250
- return seal({ kind: "session", email: email.toLowerCase(), exp: deps.now() + deps.ttlHours * 36e5 }, deps.secret);
2261
+ function issueSession(deps, user) {
2262
+ return seal(
2263
+ { kind: "session", email: user.email.toLowerCase(), displayName: user.displayName, role: user.role, via: user.via, exp: deps.now() + deps.ttlHours * 36e5 },
2264
+ deps.secret
2265
+ );
2251
2266
  }
2267
+ var ROLES = /* @__PURE__ */ new Set(["reader", "author", "approver", "operator"]);
2268
+ var VIAS = /* @__PURE__ */ new Set(["magic-link", "entra"]);
2252
2269
  function readSession(deps, cookieHeader) {
2253
2270
  const token = cookieValue(cookieHeader, SESSION_COOKIE);
2254
2271
  if (token === null) return null;
2255
2272
  const value = open(token, deps.secret);
2256
2273
  const email = value?.["email"];
2274
+ const displayName = value?.["displayName"];
2275
+ const role = value?.["role"];
2276
+ const via = value?.["via"];
2257
2277
  const exp = value?.["exp"];
2258
- if (value?.["kind"] !== "session" || typeof email !== "string" || typeof exp !== "number" || exp <= deps.now()) {
2278
+ if (value?.["kind"] !== "session" || typeof email !== "string" || typeof displayName !== "string" || typeof role !== "string" || !ROLES.has(role) || typeof via !== "string" || !VIAS.has(via) || typeof exp !== "number" || exp <= deps.now()) {
2259
2279
  return null;
2260
2280
  }
2261
- return email;
2281
+ return { email, displayName, role, via };
2262
2282
  }
2263
2283
  function cookieValue(cookieHeader, name) {
2264
2284
  if (cookieHeader === void 0) return null;
@@ -2307,6 +2327,21 @@ function unauthenticated(cause) {
2307
2327
  // src/server/hosted/config.ts
2308
2328
  import { z as z8 } from "zod";
2309
2329
  var HostRoleSchema = z8.enum(["reader", "author", "approver", "operator"]);
2330
+ var HOST_ROLES = HostRoleSchema.options;
2331
+ var HostIdentitySchema = z8.object({
2332
+ kind: z8.literal("entra"),
2333
+ tenantId: z8.string().min(1),
2334
+ clientId: z8.string().min(1),
2335
+ clientSecretPath: z8.string().min(1),
2336
+ /** Where the tenant writes the groups in the id token — a setting of the app registration. */
2337
+ groupsClaim: z8.string().min(1).default("groups"),
2338
+ roles: z8.object({
2339
+ reader: z8.array(z8.string().min(1)).default([]),
2340
+ author: z8.array(z8.string().min(1)).default([]),
2341
+ approver: z8.array(z8.string().min(1)).default([]),
2342
+ operator: z8.array(z8.string().min(1)).default([])
2343
+ })
2344
+ });
2310
2345
  var MergePolicySchema = z8.enum(["client", "operator"]);
2311
2346
  var HostUserSchema = z8.object({
2312
2347
  email: z8.email(),
@@ -2320,7 +2355,9 @@ var HostConfigSchema = z8.object({
2320
2355
  installationId: z8.string().min(1),
2321
2356
  privateKeyPath: z8.string().min(1)
2322
2357
  }),
2323
- users: z8.array(HostUserSchema).min(1),
2358
+ /** The addresses the magic link serves; optional once `identity` opens the door (D59). */
2359
+ users: z8.array(HostUserSchema).default([]),
2360
+ identity: HostIdentitySchema.optional(),
2324
2361
  /** "operator" at onboarding: the agency keeps the merge until it hands it over (D48). */
2325
2362
  mergePolicy: MergePolicySchema.default("operator"),
2326
2363
  writeRateLimit: z8.number().int().positive().default(20),
@@ -2353,6 +2390,9 @@ var HostConfigSchema = z8.object({
2353
2390
  /** USD per million tokens, by model id — overrides the built-in table of the estimate (B8). */
2354
2391
  costPerMillionUsd: z8.record(z8.string(), z8.object({ input: z8.number().nonnegative(), output: z8.number().nonnegative() })).optional()
2355
2392
  }).optional()
2393
+ }).refine((config) => config.users.length > 0 || config.identity !== void 0, {
2394
+ path: ["users"],
2395
+ message: "declare at least one user, or an identity block (D59): a host nobody can enter is a configuration error"
2356
2396
  });
2357
2397
  function parseHostConfig(raw, configPath) {
2358
2398
  const parsed = HostConfigSchema.safeParse(raw);
@@ -2372,20 +2412,190 @@ function findHostUser(config, email) {
2372
2412
  return config.users.find((user) => user.email.toLowerCase() === wanted) ?? null;
2373
2413
  }
2374
2414
 
2415
+ // src/server/hosted/entra.ts
2416
+ import { createHash, createPublicKey, randomBytes as randomBytes2, verify as verifySignature } from "node:crypto";
2417
+ import { z as z9 } from "zod";
2418
+ var OIDC_SCOPE = "openid profile email";
2419
+ var CLOCK_SKEW_SECONDS = 60;
2420
+ var FILE = "packages/ogygie-studio/src/server/hosted/entra.ts";
2421
+ var baseUrlOf = (config) => `https://login.microsoftonline.com/${config.tenantId}`;
2422
+ var issuerOf = (config) => `${baseUrlOf(config)}/v2.0`;
2423
+ var tokenEndpointOf = (config) => `${baseUrlOf(config)}/oauth2/v2.0/token`;
2424
+ var jwksEndpointOf = (config) => `${baseUrlOf(config)}/discovery/v2.0/keys`;
2425
+ function codeChallengeOf(codeVerifier) {
2426
+ return createHash("sha256").update(codeVerifier).digest("base64url");
2427
+ }
2428
+ function newHandshake() {
2429
+ return { state: randomBytes2(16).toString("hex"), nonce: randomBytes2(16).toString("hex"), codeVerifier: randomBytes2(48).toString("base64url") };
2430
+ }
2431
+ function authorizationUrlOf(config, handshake) {
2432
+ const url = new URL(`${baseUrlOf(config)}/oauth2/v2.0/authorize`);
2433
+ url.searchParams.set("client_id", config.clientId);
2434
+ url.searchParams.set("response_type", "code");
2435
+ url.searchParams.set("redirect_uri", config.callbackUrl);
2436
+ url.searchParams.set("response_mode", "query");
2437
+ url.searchParams.set("scope", OIDC_SCOPE);
2438
+ url.searchParams.set("state", handshake.state);
2439
+ url.searchParams.set("nonce", handshake.nonce);
2440
+ url.searchParams.set("code_challenge", codeChallengeOf(handshake.codeVerifier));
2441
+ url.searchParams.set("code_challenge_method", "S256");
2442
+ return url.toString();
2443
+ }
2444
+ function unauthenticated2(cause, suggestedFix) {
2445
+ return throwOgygieError(
2446
+ { code: "HOST_UNAUTHENTICATED", message: "The Microsoft sign-in did not establish an identity.", file: FILE, cause, suggestedFix },
2447
+ 401
2448
+ );
2449
+ }
2450
+ var IdTokenClaimsSchema = z9.looseObject({
2451
+ sub: z9.string().min(1),
2452
+ iss: z9.string().min(1),
2453
+ aud: z9.union([z9.string(), z9.array(z9.string())]),
2454
+ exp: z9.number(),
2455
+ nonce: z9.string().optional(),
2456
+ email: z9.string().optional(),
2457
+ preferred_username: z9.string().optional(),
2458
+ upn: z9.string().optional(),
2459
+ name: z9.string().optional()
2460
+ });
2461
+ var RsaSigningKeySchema = z9.object({ kid: z9.string().min(1), kty: z9.literal("RSA"), n: z9.string().min(1), e: z9.string().min(1) });
2462
+ var JwksSchema = z9.object({ keys: z9.array(z9.unknown()) });
2463
+ var TokenResponseSchema = z9.looseObject({ id_token: z9.string().min(1) });
2464
+ function decodeJwt(token) {
2465
+ const [encodedHeader, encodedPayload, encodedSignature, ...rest] = token.split(".");
2466
+ if (encodedHeader === void 0 || encodedPayload === void 0 || encodedSignature === void 0 || rest.length > 0) return null;
2467
+ try {
2468
+ const header = JSON.parse(Buffer.from(encodedHeader, "base64url").toString("utf8"));
2469
+ if (typeof header !== "object" || header === null) return null;
2470
+ const { alg, kid } = header;
2471
+ return {
2472
+ header: { ...typeof alg === "string" ? { alg } : {}, ...typeof kid === "string" ? { kid } : {} },
2473
+ payload: JSON.parse(Buffer.from(encodedPayload, "base64url").toString("utf8")),
2474
+ signingInput: `${encodedHeader}.${encodedPayload}`,
2475
+ signature: Buffer.from(encodedSignature, "base64url")
2476
+ };
2477
+ } catch {
2478
+ return null;
2479
+ }
2480
+ }
2481
+ function verifyIdToken(input) {
2482
+ const decoded = decodeJwt(input.idToken);
2483
+ if (decoded === null) return unauthenticated2("id token is not a readable JWT", "Retry the sign-in.");
2484
+ if (decoded.header.alg !== "RS256") {
2485
+ return unauthenticated2(`id token signed with alg=${decoded.header.alg ?? "none"}, only RS256 is accepted`, "Check the signing algorithm of the app registration (RS256).");
2486
+ }
2487
+ const candidates = input.keys.map((key) => RsaSigningKeySchema.safeParse(key)).flatMap((parsed) => parsed.success ? [parsed.data] : []).filter((key) => decoded.header.kid === void 0 || key.kid === decoded.header.kid);
2488
+ if (candidates.length === 0) {
2489
+ return unauthenticated2(`no published RSA key matches kid=${decoded.header.kid ?? "(absent)"}`, "Check that the id token comes from the configured tenant.");
2490
+ }
2491
+ const signed = candidates.some(
2492
+ (key) => verifySignature("sha256", Buffer.from(decoded.signingInput, "utf8"), createPublicKey({ key: { kty: "RSA", n: key.n, e: key.e }, format: "jwk" }), decoded.signature)
2493
+ );
2494
+ if (!signed) return unauthenticated2("id token signature does not match the keys published by the tenant", "Retry the sign-in; if it persists, check the tenant of this host.");
2495
+ const claims = IdTokenClaimsSchema.safeParse(decoded.payload);
2496
+ if (!claims.success) return unauthenticated2("id token claims do not have the expected shape", "Check the app registration (openid scope, id token).");
2497
+ const expectedIssuer = issuerOf(input.config);
2498
+ if (claims.data.iss !== expectedIssuer) return unauthenticated2(`id token issued by ${claims.data.iss}, expected ${expectedIssuer}`, "Point identity.tenantId at the tenant that issues the token.");
2499
+ const audiences = Array.isArray(claims.data.aud) ? claims.data.aud : [claims.data.aud];
2500
+ if (!audiences.includes(input.config.clientId)) return unauthenticated2("id token was issued for another application", "Check identity.clientId against the app registration the sign-in used.");
2501
+ if (claims.data.exp + CLOCK_SKEW_SECONDS < Math.floor(input.nowMs / 1e3)) return unauthenticated2("id token has expired", "Retry the sign-in.");
2502
+ if (claims.data.nonce !== input.nonce) return unauthenticated2("id token nonce does not match the one this handshake sent", "Retry the sign-in from the start; a token cannot be replayed.");
2503
+ return claims.data;
2504
+ }
2505
+ function groupsOfClaim(value) {
2506
+ if (typeof value === "string") return [value];
2507
+ if (!Array.isArray(value)) return [];
2508
+ return value.filter((group) => typeof group === "string" && group.length > 0);
2509
+ }
2510
+ function identityOfClaims(claims, groupsClaim) {
2511
+ const email = claims.email ?? claims.preferred_username ?? claims.upn;
2512
+ if (email === void 0 || email === "") {
2513
+ return unauthenticated2("the id token carries no email, preferred_username nor upn claim", "Add the optional email claim on the app registration.");
2514
+ }
2515
+ return { email: email.toLowerCase(), displayName: claims.name ?? email, groups: groupsOfClaim(claims[groupsClaim]) };
2516
+ }
2517
+ function roleOfGroups(groups, roles) {
2518
+ let found = null;
2519
+ for (const role of HOST_ROLES) if (roles[role].some((group) => group !== "" && groups.includes(group))) found = role;
2520
+ return found;
2521
+ }
2522
+ function hostUserOfIdentity(identity, declared, roles) {
2523
+ if (declared !== null) return { email: declared.email, displayName: declared.displayName, role: declared.role };
2524
+ const role = roleOfGroups(identity.groups, roles);
2525
+ if (role === null) {
2526
+ return throwOgygieError(
2527
+ {
2528
+ code: "HOST_ACCESS_DENIED",
2529
+ message: "This account is authenticated but not invited on this host.",
2530
+ file: null,
2531
+ cause: `${identity.email}: none of its ${identity.groups.length} group(s) is declared under identity.roles, and the address is not in users`,
2532
+ suggestedFix: "Add the account to one of the directory groups the host declares (identity.roles), or declare its address in users."
2533
+ },
2534
+ 403
2535
+ );
2536
+ }
2537
+ return { email: identity.email, displayName: identity.displayName, role };
2538
+ }
2539
+ async function readJson(response, endpoint) {
2540
+ if (!response.ok) return unauthenticated2(`${endpoint} answered ${response.status}`, "Retry the sign-in; if it persists, check the tenant, the client id and the client secret.");
2541
+ try {
2542
+ return await response.json();
2543
+ } catch {
2544
+ return unauthenticated2(`${endpoint} answered a body that is not JSON`, "Retry the sign-in.");
2545
+ }
2546
+ }
2547
+ async function exchangeAuthorizationCode(deps, config, input) {
2548
+ const endpoint = tokenEndpointOf(config);
2549
+ const response = await deps.fetchImpl(endpoint, {
2550
+ method: "POST",
2551
+ headers: { "content-type": "application/x-www-form-urlencoded" },
2552
+ body: new URLSearchParams({
2553
+ grant_type: "authorization_code",
2554
+ client_id: config.clientId,
2555
+ client_secret: config.clientSecret,
2556
+ code: input.code,
2557
+ code_verifier: input.codeVerifier,
2558
+ redirect_uri: config.callbackUrl
2559
+ }).toString()
2560
+ });
2561
+ const parsed = TokenResponseSchema.safeParse(await readJson(response, endpoint));
2562
+ if (!parsed.success) return unauthenticated2(`${endpoint} answered without an id_token`, "Check that the app registration requests the openid scope.");
2563
+ return parsed.data.id_token;
2564
+ }
2565
+ async function fetchSigningKeys(deps, config) {
2566
+ const endpoint = jwksEndpointOf(config);
2567
+ const response = await deps.fetchImpl(endpoint, { method: "GET", headers: {} });
2568
+ const parsed = JwksSchema.safeParse(await readJson(response, endpoint));
2569
+ if (!parsed.success) return unauthenticated2(`${endpoint} answered a document that is not a JWKS`, "Check identity.tenantId \u2014 the JWKS of the tenant is what verifies the id token.");
2570
+ return parsed.data.keys;
2571
+ }
2572
+
2375
2573
  // src/server/hosted/routes.ts
2574
+ var OIDC_COOKIE = "ogygie_host_oidc";
2575
+ var OIDC_TTL_MS = 10 * 60 * 1e3;
2576
+ var oidcCookie = (value) => `${OIDC_COOKIE}=${encodeURIComponent(value)}; Path=/api/auth/entra; Max-Age=${OIDC_TTL_MS / 1e3}; HttpOnly; Secure; SameSite=Lax`;
2577
+ var clearedOidcCookie = () => `${OIDC_COOKIE}=; Path=/api/auth/entra; Max-Age=0; HttpOnly; Secure; SameSite=Lax`;
2578
+ function cookieOf(cookieHeader, name) {
2579
+ if (cookieHeader === void 0) return null;
2580
+ for (const part of cookieHeader.split(";")) {
2581
+ const [key, ...rest] = part.trim().split("=");
2582
+ if (key === name) return decodeURIComponent(rest.join("="));
2583
+ }
2584
+ return null;
2585
+ }
2376
2586
  var usersByRequest = /* @__PURE__ */ new WeakMap();
2377
2587
  function hostedUserOf(request) {
2378
2588
  const user = usersByRequest.get(request);
2379
2589
  if (user === void 0) return unauthenticated("no session attached to this request");
2380
2590
  return user;
2381
2591
  }
2382
- var MagicLinkInputSchema = z9.object({ email: z9.email() });
2383
- var CallbackQuerySchema = z9.object({ token: z9.string().min(1) });
2384
- var MeOutputSchema = z9.object({
2385
- email: z9.email(),
2386
- displayName: z9.string(),
2592
+ var MagicLinkInputSchema = z10.object({ email: z10.email() });
2593
+ var CallbackQuerySchema = z10.object({ token: z10.string().min(1) });
2594
+ var MeOutputSchema = z10.object({
2595
+ email: z10.email(),
2596
+ displayName: z10.string(),
2387
2597
  role: HostRoleSchema,
2388
- host: z9.object({ environments: z9.boolean(), mergePolicy: z9.enum(["client", "operator"]) })
2598
+ host: z10.object({ environments: z10.boolean(), mergePolicy: z10.enum(["client", "operator"]) })
2389
2599
  });
2390
2600
  async function registerHostedRoutes(app, deps) {
2391
2601
  const sessions = {
@@ -2399,21 +2609,74 @@ async function registerHostedRoutes(app, deps) {
2399
2609
  consumed: deps.consumedLinks,
2400
2610
  ...deps.magicLinkTtlMs === void 0 ? {} : { ttlMs: deps.magicLinkTtlMs }
2401
2611
  };
2402
- const PUBLIC_ROUTES = /* @__PURE__ */ new Set(["/api/auth/magic-link", "/api/auth/callback", "/api/auth/logout"]);
2612
+ const PUBLIC_ROUTES = /* @__PURE__ */ new Set(["/api/auth/options", "/api/auth/magic-link", "/api/auth/callback", "/api/auth/logout", "/api/auth/entra/start", "/api/auth/entra/callback"]);
2403
2613
  app.addHook("onRequest", async (request) => {
2404
2614
  const url = request.url.split("?")[0] ?? request.url;
2405
2615
  if (!url.startsWith("/api/") || PUBLIC_ROUTES.has(url)) return;
2406
- const email = readSession(sessions, request.headers.cookie);
2407
- if (email === null) return unauthenticated("missing or expired session cookie");
2408
- const user = findHostUser(deps.config, email);
2409
- if (user === null) return unauthenticated(`session for ${email}, no longer a declared user`);
2410
- usersByRequest.set(request, user);
2616
+ const session = readSession(sessions, request.headers.cookie);
2617
+ if (session === null) return unauthenticated("missing or expired session cookie");
2618
+ if (session.via === "magic-link") {
2619
+ const declared = findHostUser(deps.config, session.email);
2620
+ if (declared === null) return unauthenticated(`session for ${session.email}, no longer a declared user`);
2621
+ usersByRequest.set(request, declared);
2622
+ return;
2623
+ }
2624
+ usersByRequest.set(request, { email: session.email, displayName: session.displayName, role: session.role });
2411
2625
  });
2412
2626
  const route = app.withTypeProvider();
2627
+ route.route({
2628
+ method: "GET",
2629
+ url: "/api/auth/options",
2630
+ schema: { response: { 200: z10.object({ magicLink: z10.boolean(), entra: z10.boolean() }) } },
2631
+ handler: async (_request, reply) => reply.code(200).send({ magicLink: deps.config.users.length > 0, entra: deps.entra !== void 0 })
2632
+ });
2633
+ if (deps.entra !== void 0) {
2634
+ const entra = deps.entra;
2635
+ route.route({
2636
+ method: "GET",
2637
+ url: "/api/auth/entra/start",
2638
+ handler: async (_request, reply) => {
2639
+ const handshake = newHandshake();
2640
+ const state = seal({ kind: "oidc", ...handshake, exp: deps.now() + OIDC_TTL_MS }, deps.sessionSecret);
2641
+ return reply.header("set-cookie", oidcCookie(state)).redirect(authorizationUrlOf(entra.config, handshake), 302);
2642
+ }
2643
+ });
2644
+ route.route({
2645
+ method: "GET",
2646
+ url: "/api/auth/entra/callback",
2647
+ schema: { querystring: z10.object({ code: z10.string().min(1).optional(), state: z10.string().min(1).optional(), error: z10.string().optional(), error_description: z10.string().optional() }) },
2648
+ handler: async (request, reply) => {
2649
+ const refused = (code, cause) => {
2650
+ deps.log(JSON.stringify({ action: "sign-in", provider: "entra", outcome: code, cause }));
2651
+ return reply.header("set-cookie", clearedOidcCookie()).redirect(`/login?error=${encodeURIComponent(code)}`, 302);
2652
+ };
2653
+ if (request.query.error !== void 0) return refused("HOST_UNAUTHENTICATED", `tenant answered ${request.query.error}: ${request.query.error_description ?? ""}`);
2654
+ const sealed = cookieOf(request.headers.cookie, OIDC_COOKIE);
2655
+ const handshake = sealed === null ? null : open(sealed, deps.sessionSecret);
2656
+ const expected = { state: handshake?.["state"], nonce: handshake?.["nonce"], codeVerifier: handshake?.["codeVerifier"], exp: handshake?.["exp"] };
2657
+ if (handshake?.["kind"] !== "oidc" || typeof expected.state !== "string" || typeof expected.nonce !== "string" || typeof expected.codeVerifier !== "string" || typeof expected.exp !== "number" || expected.exp <= deps.now()) {
2658
+ return refused("HOST_UNAUTHENTICATED", "no live handshake cookie: the sign-in took more than ten minutes, or did not start here");
2659
+ }
2660
+ if (request.query.state !== expected.state || request.query.code === void 0) return refused("HOST_UNAUTHENTICATED", "state mismatch or no code");
2661
+ try {
2662
+ const idToken = await exchangeAuthorizationCode(entra, entra.config, { code: request.query.code, codeVerifier: expected.codeVerifier });
2663
+ const keys = await fetchSigningKeys(entra, entra.config);
2664
+ const claims = verifyIdToken({ idToken, keys, config: entra.config, nonce: expected.nonce, nowMs: deps.now() });
2665
+ const identity = identityOfClaims(claims, entra.config.groupsClaim);
2666
+ const user = hostUserOfIdentity(identity, findHostUser(deps.config, identity.email), entra.config.roles);
2667
+ deps.log(JSON.stringify({ action: "sign-in", provider: "entra", actor: user.email, role: user.role, outcome: "ok" }));
2668
+ return reply.header("set-cookie", [clearedOidcCookie(), sessionCookie(issueSession(sessions, { ...user, via: "entra" }), deps.config.session.ttlHours)]).redirect("/", 302);
2669
+ } catch (thrown) {
2670
+ if (thrown instanceof OgygieFailure) return refused(thrown.error.code, thrown.error.cause);
2671
+ throw thrown;
2672
+ }
2673
+ }
2674
+ });
2675
+ }
2413
2676
  route.route({
2414
2677
  method: "POST",
2415
2678
  url: "/api/auth/magic-link",
2416
- schema: { body: MagicLinkInputSchema, response: { 202: z9.object({ accepted: z9.literal(true) }) } },
2679
+ schema: { body: MagicLinkInputSchema, response: { 202: z10.object({ accepted: z10.literal(true) }) } },
2417
2680
  handler: async (request, reply) => {
2418
2681
  const user = findHostUser(deps.config, request.body.email);
2419
2682
  if (user === null) {
@@ -2443,8 +2706,8 @@ ${link}
2443
2706
  const email = consumeMagicLink(links, request.query.token);
2444
2707
  const user = findHostUser(deps.config, email);
2445
2708
  if (user === null) return unauthenticated(`link for ${email}, no longer a declared user`);
2446
- deps.log(JSON.stringify({ action: "sign-in", actor: user.email, outcome: "ok" }));
2447
- return reply.header("set-cookie", sessionCookie(issueSession(sessions, user.email), deps.config.session.ttlHours)).redirect("/", 302);
2709
+ deps.log(JSON.stringify({ action: "sign-in", provider: "magic-link", actor: user.email, outcome: "ok" }));
2710
+ return reply.header("set-cookie", sessionCookie(issueSession(sessions, { ...user, via: "magic-link" }), deps.config.session.ttlHours)).redirect("/", 302);
2448
2711
  }
2449
2712
  });
2450
2713
  route.route({
@@ -2465,10 +2728,10 @@ ${link}
2465
2728
  }
2466
2729
 
2467
2730
  // src/server/hosted/brief-routes.ts
2468
- import { z as z11 } from "zod";
2731
+ import { z as z12 } from "zod";
2469
2732
 
2470
2733
  // src/server/hosted/attachments.ts
2471
- import { createHash } from "node:crypto";
2734
+ import { createHash as createHash2 } from "node:crypto";
2472
2735
  import { inflateRawSync } from "node:zlib";
2473
2736
  var ATTACHMENT_FORMATS = ["md", "txt", "csv", "json", "html", "xlsx", "pdf"];
2474
2737
  var MAX_ATTACHMENTS = 5;
@@ -2650,7 +2913,7 @@ function prepareBriefAttachments(raw) {
2650
2913
  if (buffer.length > MAX_ATTACHMENT_BYTES) return tooLarge(piece.name, `${buffer.length} bytes`, MAX_ATTACHMENT_BYTES, "bytes");
2651
2914
  totalBytes += buffer.length;
2652
2915
  if (totalBytes > MAX_ATTACHMENTS_BYTES) return tooLarge(piece.name, `${totalBytes} bytes in all`, MAX_ATTACHMENTS_BYTES, "bytes");
2653
- const identity = { name: piece.name, format, bytes: buffer.length, sha256: createHash("sha256").update(buffer).digest("hex") };
2916
+ const identity = { name: piece.name, format, bytes: buffer.length, sha256: createHash2("sha256").update(buffer).digest("hex") };
2654
2917
  if (format === "pdf") {
2655
2918
  if (!isPdf(buffer)) unsupported(piece.name, "the content does not start like a PDF");
2656
2919
  prepared.push({ ...identity, kind: "pdf", data: buffer.toString("base64") });
@@ -2698,7 +2961,7 @@ ${attachments.map((piece) => `- ${piece.name} (${piece.format}, ${sizeOf(piece.b
2698
2961
  var attachmentDocumentsOf = (attachments) => attachments.flatMap((piece) => piece.kind === "pdf" ? [{ name: piece.name, data: piece.data }] : []);
2699
2962
 
2700
2963
  // src/server/hosted/brief.ts
2701
- import { z as z10 } from "zod";
2964
+ import { z as z11 } from "zod";
2702
2965
  import { checkSpec, generationGrammarCard, readManifestGeneration } from "@ogygie/generate";
2703
2966
  var TEMPLATE_PATH2 = "specs/_TEMPLATE.spec.md";
2704
2967
  var PRODUCT_RULES_PATH = "AGENTS.product.md";
@@ -2715,13 +2978,18 @@ var BRIEF_IN_MESSAGE_MAX = 2e3;
2715
2978
  var MAX_QUESTION_ROUNDS = 3;
2716
2979
  var QUESTIONS_MARKER = /^\s*QUESTIONS\s*$/mu;
2717
2980
  var NO_MORE_QUESTIONS = "no more questions: write the draft now, and put what you do not know under # Hypoth\xE8ses";
2981
+ var UNGENERATED_ASKED = /sans\s+g[ée]n[ée]ration/iu;
2982
+ var BLOCK_REQUIRED = "this product is generated (D58): write the `# G\xE9n\xE9ration` block in the closed grammar of the card \u2014 a spec without it would be implemented by nobody; only a brief saying \xAB sans g\xE9n\xE9ration \xBB may omit it";
2983
+ function asksUngenerated(brief) {
2984
+ return UNGENERATED_ASKED.test(brief);
2985
+ }
2718
2986
  async function readIfPresent(storage, path) {
2719
2987
  return await storage.exists(path) ? storage.readFile(path) : null;
2720
2988
  }
2721
- var ManifestRouteSchema = z10.looseObject({ method: z10.string(), path: z10.string(), schemaInput: z10.string().optional(), schemaOutput: z10.string().optional(), invalidates: z10.array(z10.string()).optional() });
2722
- var ManifestForPromptSchema = z10.looseObject({
2723
- product: z10.looseObject({ name: z10.string(), description: z10.string().optional() }).optional(),
2724
- features: z10.array(z10.looseObject({ name: z10.string(), intent: z10.string().optional(), status: z10.string().optional(), spec: z10.string().nullable().optional(), routes: z10.array(ManifestRouteSchema).optional() })).optional()
2989
+ var ManifestRouteSchema = z11.looseObject({ method: z11.string(), path: z11.string(), schemaInput: z11.string().optional(), schemaOutput: z11.string().optional(), invalidates: z11.array(z11.string()).optional() });
2990
+ var ManifestForPromptSchema = z11.looseObject({
2991
+ product: z11.looseObject({ name: z11.string(), description: z11.string().optional() }).optional(),
2992
+ features: z11.array(z11.looseObject({ name: z11.string(), intent: z11.string().optional(), status: z11.string().optional(), spec: z11.string().nullable().optional(), routes: z11.array(ManifestRouteSchema).optional() })).optional()
2725
2993
  });
2726
2994
  async function manifestSummary(storage) {
2727
2995
  const raw = await readIfPresent(storage, MANIFEST_PATH2);
@@ -2788,8 +3056,8 @@ function extractQuestions(text) {
2788
3056
  } catch {
2789
3057
  return { problem: "QUESTIONS: the JSON after the marker does not parse" };
2790
3058
  }
2791
- const shape = z10.object({
2792
- questions: z10.array(z10.object({ id: FeatureNameSchema, text: z10.string().trim().min(1), options: z10.array(z10.string().trim().min(1)).min(2).max(4) })).min(3).max(5)
3059
+ const shape = z11.object({
3060
+ questions: z11.array(z11.object({ id: FeatureNameSchema, text: z11.string().trim().min(1), options: z11.array(z11.string().trim().min(1)).min(2).max(4) })).min(3).max(5)
2793
3061
  });
2794
3062
  const checked = shape.safeParse(parsed);
2795
3063
  if (!checked.success) return { problem: `QUESTIONS: ${checked.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join(" ; ")}` };
@@ -2814,7 +3082,9 @@ function buildBriefPrompt(context) {
2814
3082
  "- Tu ne cites une d\xE9cision `D<n>` QUE si elle figure dans la liste des d\xE9cisions connues ci-dessous ; sinon n'en cite aucune.",
2815
3083
  "- Tu n'inventes ni table, ni route, ni feature existante : tu t'appuies sur la carte du produit ci-dessous ; une rubrique nouvelle nomme ses propres contrats.",
2816
3084
  "- Tu n'\xE9cris jamais de code ni de design (couleurs, composants) : la spec dit QUOI, jamais COMMENT. Le bloc YAML de `# G\xE9n\xE9ration` n'est pas du code : c'est la d\xE9claration que le g\xE9n\xE9rateur lit.",
2817
- ...context.grammarCard === null || context.grammarCard === void 0 ? [] : ["- Quand la rubrique apporte des op\xE9rations ou un \xE9cran que le g\xE9n\xE9rateur sait d\xE9river, \xE9cris la section `# G\xE9n\xE9ration` apr\xE8s `# @intent` et ses `# Comportements` dans la grammaire ferm\xE9e (carte ci-dessous) : le brouillon est jug\xE9 par le v\xE9rificateur du g\xE9n\xE9rateur avant d'\xEAtre montr\xE9. Quand elle ne se g\xE9n\xE8re pas, n'\xE9cris pas la section."],
3085
+ ...context.grammarCard === null || context.grammarCard === void 0 ? [] : [
3086
+ context.ungenerated === true ? "- Ce produit est g\xE9n\xE9r\xE9, mais le brief demande une rubrique SANS g\xE9n\xE9ration : n'\xE9cris pas la section `# G\xE9n\xE9ration`, cette spec sera impl\xE9ment\xE9e \xE0 la main." : "- Ce produit est G\xC9N\xC9R\xC9 (D58) : \xE9cris TOUJOURS la section `# G\xE9n\xE9ration` apr\xE8s `# @intent`, et ses `# Comportements` dans la grammaire ferm\xE9e (carte ci-dessous) ; le brouillon est jug\xE9 par le v\xE9rificateur du g\xE9n\xE9rateur avant d'\xEAtre montr\xE9, et un brouillon sans cette section est refus\xE9. Routes, base, tests et \xE9crans se d\xE9rivent du bloc ; ce qui se pense devient un trou typ\xE9."
3087
+ ],
2818
3088
  "- Tu ne proposes jamais `status: validated` ni `implemented` : valider est le geste de l'humain.",
2819
3089
  "- HYPOTH\xC8SES : quand tu tranches un point que le brief ne dit pas (un nombre, un ordre, un p\xE9rim\xE8tre, un mot), ajoute apr\xE8s `# Budget` (et avant `# Design` si elle existe) une section `# Hypoth\xE8ses`, une puce par choix, en fran\xE7ais, courte ; sans hypoth\xE8se, pas de section.",
2820
3090
  '- QUESTIONS : quand la demande ci-dessous t\'y autorise et qu\'une r\xE9ponse du client CHANGERAIT la spec, tu peux, AU LIEU du fichier, r\xE9pondre par une ligne `QUESTIONS` seule puis un objet JSON `{"questions":[{"id":"kebab-case","text":"la question, en fran\xE7ais","options":["r\xE9ponse","r\xE9ponse"]}]}` \u2014 trois \xE0 cinq questions ferm\xE9es, deux \xE0 quatre options chacune, rien d\'autre avant ni apr\xE8s. Une question dont la r\xE9ponse ne changerait pas la spec ne se pose pas : elle devient une hypoth\xE8se.',
@@ -2966,6 +3236,8 @@ async function runBrief(deps, input) {
2966
3236
  const existingSpec = input.specId !== void 0 ? await readIfPresent(deps.storage, `specs/${input.specId}.spec.md`) : null;
2967
3237
  const isAmendment = existingSpec !== null;
2968
3238
  const round = Math.min(Math.max(input.round ?? 1, 1), MAX_QUESTION_ROUNDS + 1);
3239
+ const ungeneratedAsked = asksUngenerated(input.brief);
3240
+ const blockRequired = manifest.generation !== null && !ungeneratedAsked;
2969
3241
  const questionsAllowed = input.skipQuestions !== true && round <= MAX_QUESTION_ROUNDS;
2970
3242
  const prompt = buildBriefPrompt({
2971
3243
  template,
@@ -2979,7 +3251,8 @@ async function runBrief(deps, input) {
2979
3251
  brief: input.brief,
2980
3252
  attachments: input.attachments,
2981
3253
  answers: input.answers,
2982
- questions: { allowed: questionsAllowed, round }
3254
+ questions: { allowed: questionsAllowed, round },
3255
+ ungenerated: ungeneratedAsked
2983
3256
  });
2984
3257
  const usage = [];
2985
3258
  let refusedQuestions = false;
@@ -3021,7 +3294,8 @@ async function runBrief(deps, input) {
3021
3294
  });
3022
3295
  const generationJudgement = judged.problems.length === 0 && judged.specId !== null ? await judgeGeneration({ content, specPath: `specs/${judged.specId}.spec.md`, generation: manifest.generation, storage: deps.storage, features: manifest.features }) : { problems: [], tally: null };
3023
3296
  judged.problems.push(...generationJudgement.problems);
3024
- if (generationJudgement.tally !== null) maxAttempts = MAX_ATTEMPTS_GENERATED;
3297
+ if (blockRequired && judged.problems.length === 0 && generationJudgement.tally === null) judged.problems.push(BLOCK_REQUIRED);
3298
+ if (generationJudgement.tally !== null || blockRequired) maxAttempts = MAX_ATTEMPTS_GENERATED;
3025
3299
  deps.log(JSON.stringify({ action: "brief", attempt, specId: judged.specId, problems: judged.problems.length, generated: generationJudgement.tally !== null, ...answer.usage }));
3026
3300
  if (judged.problems.length === 0 && judged.specId !== null) {
3027
3301
  const specId = judged.specId;
@@ -3242,7 +3516,7 @@ async function registerBriefRoutes(app, deps) {
3242
3516
  route.route({
3243
3517
  method: "GET",
3244
3518
  url: "/api/briefs/:jobId",
3245
- schema: { params: z11.object({ jobId: z11.string().min(1) }), response: { 200: BriefJobSchema } },
3519
+ schema: { params: z12.object({ jobId: z12.string().min(1) }), response: { 200: BriefJobSchema } },
3246
3520
  handler: async (request, reply) => {
3247
3521
  hostedUserOf(request);
3248
3522
  return reply.code(200).send(jobs.read(request.params.jobId));
@@ -3862,17 +4136,17 @@ async function registerPromotionRoutes(app, deps) {
3862
4136
  }
3863
4137
 
3864
4138
  // src/server/hosted/usage.ts
3865
- import { z as z12 } from "zod";
3866
- var UsageEntrySchema = z12.object({
3867
- at: z12.string(),
4139
+ import { z as z13 } from "zod";
4140
+ var UsageEntrySchema = z13.object({
4141
+ at: z13.string(),
3868
4142
  /** brief → spec from the instance; `speculate` entries arrive with the generator's lot. */
3869
- action: z12.enum(["brief", "speculate"]),
3870
- attempt: z12.number().int().positive().optional(),
3871
- model: z12.string(),
3872
- inputTokens: z12.number().int().nonnegative(),
3873
- outputTokens: z12.number().int().nonnegative(),
3874
- actor: z12.string().optional(),
3875
- specId: z12.string().optional()
4143
+ action: z13.enum(["brief", "speculate"]),
4144
+ attempt: z13.number().int().positive().optional(),
4145
+ model: z13.string(),
4146
+ inputTokens: z13.number().int().nonnegative(),
4147
+ outputTokens: z13.number().int().nonnegative(),
4148
+ actor: z13.string().optional(),
4149
+ specId: z13.string().optional()
3876
4150
  });
3877
4151
  var USAGE_MARKER = "<!-- ogygie-usage -->";
3878
4152
  var DEFAULT_MODEL_COSTS = {
@@ -3907,7 +4181,7 @@ function parseUsageComment(body) {
3907
4181
  const block = /```json\n([\s\S]*?)\n```/.exec(body)?.[1];
3908
4182
  if (block === void 0) return [];
3909
4183
  try {
3910
- const parsed = z12.array(UsageEntrySchema).safeParse(JSON.parse(block));
4184
+ const parsed = z13.array(UsageEntrySchema).safeParse(JSON.parse(block));
3911
4185
  return parsed.success ? parsed.data : [];
3912
4186
  } catch {
3913
4187
  return [];
@@ -3982,9 +4256,9 @@ async function registerConsumptionRoutes(app, deps) {
3982
4256
  }
3983
4257
 
3984
4258
  // src/server/hosted/preview-routes.ts
3985
- import { z as z13 } from "zod";
3986
- var PreviewParamsSchema = SpecParamsSchema.extend({ state: z13.string().min(1).max(80) });
3987
- var CompositionPreviewParamsSchema = PreviewParamsSchema.extend({ composition: z13.string().min(1).max(80) });
4259
+ import { z as z14 } from "zod";
4260
+ var PreviewParamsSchema = SpecParamsSchema.extend({ state: z14.string().min(1).max(80) });
4261
+ var CompositionPreviewParamsSchema = PreviewParamsSchema.extend({ composition: z14.string().min(1).max(80) });
3988
4262
  var PREVIEW_CSP = "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:";
3989
4263
  async function registerPreviewRoutes(app, deps) {
3990
4264
  const route = app.withTypeProvider();
@@ -4026,7 +4300,7 @@ async function registerPreviewRoutes(app, deps) {
4026
4300
  }
4027
4301
 
4028
4302
  // src/server/hosted/composition-routes.ts
4029
- import { z as z14 } from "zod";
4303
+ import { z as z15 } from "zod";
4030
4304
 
4031
4305
  // src/server/hosted/compositions.ts
4032
4306
  var COMPOSITION_NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
@@ -4070,7 +4344,7 @@ La composition \xAB ${composition} \xBB est retenue parmi celles propos\xE9es ;
4070
4344
  }
4071
4345
 
4072
4346
  // src/server/hosted/composition-routes.ts
4073
- var CompositionParamsSchema = SpecParamsSchema.extend({ composition: z14.string().min(1).max(80) });
4347
+ var CompositionParamsSchema = SpecParamsSchema.extend({ composition: z15.string().min(1).max(80) });
4074
4348
  async function registerCompositionRoutes(app, deps) {
4075
4349
  const route = app.withTypeProvider();
4076
4350
  route.route({
@@ -4803,6 +5077,7 @@ async function startHostedStudio(options) {
4803
5077
  const privateKeyPem = readSecret(config.githubApp.privateKeyPath, "the GitHub App private key");
4804
5078
  const sessionSecret = readSecret(config.session.secretPath, "the session secret", MIN_SESSION_SECRET_LENGTH);
4805
5079
  const mailApiKey = readSecret(config.mail.apiKeyPath, "the mail provider key");
5080
+ const entraClientSecret = config.identity === void 0 ? void 0 : readSecret(config.identity.clientSecretPath, "the Entra client secret");
4806
5081
  const modelApiKey = config.model === void 0 ? void 0 : process.env["OGYGIE_MODEL_API_KEY"] ?? "";
4807
5082
  if (modelApiKey !== void 0 && modelApiKey.length === 0) {
4808
5083
  throwOgygieError({
@@ -4869,6 +5144,19 @@ async function startHostedStudio(options) {
4869
5144
  now,
4870
5145
  log,
4871
5146
  consumedLinks: /* @__PURE__ */ new Map(),
5147
+ ...config.identity === void 0 || entraClientSecret === void 0 ? {} : {
5148
+ entra: {
5149
+ fetchImpl: fetch,
5150
+ config: {
5151
+ tenantId: config.identity.tenantId,
5152
+ clientId: config.identity.clientId,
5153
+ clientSecret: entraClientSecret,
5154
+ callbackUrl: `${config.publicUrl.replace(/\/$/, "")}/api/auth/entra/callback`,
5155
+ groupsClaim: config.identity.groupsClaim,
5156
+ roles: config.identity.roles
5157
+ }
5158
+ }
5159
+ },
4872
5160
  pulls: createGithubPullsClient({ github: config.repo, fetchImpl: githubApp.installationFetch }),
4873
5161
  readerFor,
4874
5162
  usage: createGithubUsageClient({ github: config.repo, fetchImpl: githubApp.installationFetch }),