@ogygie/studio 0.30.0 → 0.32.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
@@ -1187,7 +1187,7 @@ ${deps.originOf(path)}`
1187
1187
  }
1188
1188
 
1189
1189
  // src/server/storage/local-adapter.ts
1190
- import { mkdir, writeFile } from "node:fs/promises";
1190
+ import { mkdir, stat as stat2, writeFile } from "node:fs/promises";
1191
1191
  import { dirname, isAbsolute as isAbsolute2, resolve as resolve2, sep as sep2 } from "node:path";
1192
1192
  function resolveInsideRoot2(rootDir, relativePath) {
1193
1193
  const root = resolve2(rootDir);
@@ -1214,6 +1214,14 @@ function createLocalSpecStorage(rootDir) {
1214
1214
  const absolute = resolveInsideRoot2(rootDir, path);
1215
1215
  await mkdir(dirname(absolute), { recursive: true });
1216
1216
  await writeFile(absolute, content, "utf8");
1217
+ },
1218
+ async modifiedAt(path) {
1219
+ const absolute = resolveInsideRoot2(rootDir, path);
1220
+ try {
1221
+ return (await stat2(absolute)).mtime.toISOString();
1222
+ } catch {
1223
+ return null;
1224
+ }
1217
1225
  }
1218
1226
  };
1219
1227
  }
@@ -1345,7 +1353,8 @@ function intentOf(body) {
1345
1353
  const sentence = /^.*?[.!?](?:\s|$)/u.exec(paragraph)?.[0]?.trim() ?? paragraph;
1346
1354
  return sentence.length > 160 ? `${sentence.slice(0, 157).trimEnd()}\u2026` : sentence;
1347
1355
  }
1348
- function toReadableSummary(filePath, spec) {
1356
+ var modifiedAtOf = (deps, filePath) => deps.storage.modifiedAt?.(filePath) ?? Promise.resolve(null);
1357
+ function toReadableSummary(filePath, spec, updatedAt) {
1349
1358
  return {
1350
1359
  id: spec.frontmatter.id,
1351
1360
  status: spec.frontmatter.status,
@@ -1353,7 +1362,8 @@ function toReadableSummary(filePath, spec) {
1353
1362
  depends_on: spec.frontmatter.depends_on,
1354
1363
  filePath,
1355
1364
  presentSections: presentSectionsOf(spec.body),
1356
- intent: intentOf(spec.body)
1365
+ intent: intentOf(spec.body),
1366
+ updatedAt
1357
1367
  };
1358
1368
  }
1359
1369
  async function listSpecFileNames(deps) {
@@ -1369,7 +1379,7 @@ async function listSpecSummaries(deps) {
1369
1379
  const parsed = parseSpec(filePath, fileName, content);
1370
1380
  const id = fileName.replace(/\.spec\.md$/, "");
1371
1381
  summaries.push(
1372
- parsed.ok ? toReadableSummary(filePath, parsed.spec) : { id, filePath, error: parsed.error }
1382
+ parsed.ok ? toReadableSummary(filePath, parsed.spec, await modifiedAtOf(deps, filePath)) : { id, filePath, error: parsed.error }
1373
1383
  );
1374
1384
  }
1375
1385
  return summaries;
@@ -1484,7 +1494,7 @@ ${serialized}
1484
1494
  ---${body}`;
1485
1495
  const message = isValidation ? `spec: validate ${specId}` : `spec: save ${specId}`;
1486
1496
  await deps.storage.write(filePath, content, message);
1487
- return toReadableSummary(filePath, { frontmatter: input.frontmatter, body });
1497
+ return toReadableSummary(filePath, { frontmatter: input.frontmatter, body }, await modifiedAtOf(deps, filePath));
1488
1498
  }
1489
1499
  async function createSpec(deps, specId) {
1490
1500
  const fileName = `${specId}.spec.md`;
@@ -1526,7 +1536,7 @@ async function createSpec(deps, specId) {
1526
1536
  suggestedFix: "Fix the template's frontmatter to match SpecFrontmatterSchema."
1527
1537
  });
1528
1538
  }
1529
- return toReadableSummary(filePath, parsed.spec);
1539
+ return toReadableSummary(filePath, parsed.spec, await modifiedAtOf(deps, filePath));
1530
1540
  }
1531
1541
  async function getManifest(deps) {
1532
1542
  const content = await deps.storage.readFile(MANIFEST_PATH);
@@ -1920,7 +1930,9 @@ var ReadableSpecSummarySchema = z7.object({
1920
1930
  filePath: z7.string(),
1921
1931
  presentSections: z7.array(z7.string()),
1922
1932
  /** The first sentence of `# @intent`, for a list that reads (v1.20: the target-spec suggestions of the brief). */
1923
- intent: z7.string().default("")
1933
+ intent: z7.string().default(""),
1934
+ /** When the spec last changed (ISO 8601) — the board's order, newest first (v3.4); null when the storage does not know. */
1935
+ updatedAt: z7.string().nullable().default(null)
1924
1936
  });
1925
1937
  var UnreadableSpecSummarySchema = z7.object({
1926
1938
  id: z7.string(),
@@ -2203,7 +2215,7 @@ async function registerStudioRoutes(app, resolver) {
2203
2215
  }
2204
2216
 
2205
2217
  // src/server/hosted/routes.ts
2206
- import { z as z9 } from "zod";
2218
+ import { z as z10 } from "zod";
2207
2219
 
2208
2220
  // src/server/hosted/auth.ts
2209
2221
  import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
@@ -2258,19 +2270,27 @@ function consumeMagicLink(deps, token) {
2258
2270
  deps.consumed.set(nonce, exp);
2259
2271
  return email;
2260
2272
  }
2261
- function issueSession(deps, email) {
2262
- return seal({ kind: "session", email: email.toLowerCase(), exp: deps.now() + deps.ttlHours * 36e5 }, deps.secret);
2273
+ function issueSession(deps, user) {
2274
+ return seal(
2275
+ { kind: "session", email: user.email.toLowerCase(), displayName: user.displayName, role: user.role, via: user.via, exp: deps.now() + deps.ttlHours * 36e5 },
2276
+ deps.secret
2277
+ );
2263
2278
  }
2279
+ var ROLES = /* @__PURE__ */ new Set(["reader", "author", "approver", "operator"]);
2280
+ var VIAS = /* @__PURE__ */ new Set(["magic-link", "entra"]);
2264
2281
  function readSession(deps, cookieHeader) {
2265
2282
  const token = cookieValue(cookieHeader, SESSION_COOKIE);
2266
2283
  if (token === null) return null;
2267
2284
  const value = open(token, deps.secret);
2268
2285
  const email = value?.["email"];
2286
+ const displayName = value?.["displayName"];
2287
+ const role = value?.["role"];
2288
+ const via = value?.["via"];
2269
2289
  const exp = value?.["exp"];
2270
- if (value?.["kind"] !== "session" || typeof email !== "string" || typeof exp !== "number" || exp <= deps.now()) {
2290
+ 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()) {
2271
2291
  return null;
2272
2292
  }
2273
- return email;
2293
+ return { email, displayName, role, via };
2274
2294
  }
2275
2295
  function cookieValue(cookieHeader, name) {
2276
2296
  if (cookieHeader === void 0) return null;
@@ -2319,6 +2339,21 @@ function unauthenticated(cause) {
2319
2339
  // src/server/hosted/config.ts
2320
2340
  import { z as z8 } from "zod";
2321
2341
  var HostRoleSchema = z8.enum(["reader", "author", "approver", "operator"]);
2342
+ var HOST_ROLES = HostRoleSchema.options;
2343
+ var HostIdentitySchema = z8.object({
2344
+ kind: z8.literal("entra"),
2345
+ tenantId: z8.string().min(1),
2346
+ clientId: z8.string().min(1),
2347
+ clientSecretPath: z8.string().min(1),
2348
+ /** Where the tenant writes the groups in the id token — a setting of the app registration. */
2349
+ groupsClaim: z8.string().min(1).default("groups"),
2350
+ roles: z8.object({
2351
+ reader: z8.array(z8.string().min(1)).default([]),
2352
+ author: z8.array(z8.string().min(1)).default([]),
2353
+ approver: z8.array(z8.string().min(1)).default([]),
2354
+ operator: z8.array(z8.string().min(1)).default([])
2355
+ })
2356
+ });
2322
2357
  var MergePolicySchema = z8.enum(["client", "operator"]);
2323
2358
  var HostUserSchema = z8.object({
2324
2359
  email: z8.email(),
@@ -2332,7 +2367,9 @@ var HostConfigSchema = z8.object({
2332
2367
  installationId: z8.string().min(1),
2333
2368
  privateKeyPath: z8.string().min(1)
2334
2369
  }),
2335
- users: z8.array(HostUserSchema).min(1),
2370
+ /** The addresses the magic link serves; optional once `identity` opens the door (D59). */
2371
+ users: z8.array(HostUserSchema).default([]),
2372
+ identity: HostIdentitySchema.optional(),
2336
2373
  /** "operator" at onboarding: the agency keeps the merge until it hands it over (D48). */
2337
2374
  mergePolicy: MergePolicySchema.default("operator"),
2338
2375
  writeRateLimit: z8.number().int().positive().default(20),
@@ -2365,6 +2402,9 @@ var HostConfigSchema = z8.object({
2365
2402
  /** USD per million tokens, by model id — overrides the built-in table of the estimate (B8). */
2366
2403
  costPerMillionUsd: z8.record(z8.string(), z8.object({ input: z8.number().nonnegative(), output: z8.number().nonnegative() })).optional()
2367
2404
  }).optional()
2405
+ }).refine((config) => config.users.length > 0 || config.identity !== void 0, {
2406
+ path: ["users"],
2407
+ message: "declare at least one user, or an identity block (D59): a host nobody can enter is a configuration error"
2368
2408
  });
2369
2409
  function parseHostConfig(raw, configPath) {
2370
2410
  const parsed = HostConfigSchema.safeParse(raw);
@@ -2384,20 +2424,190 @@ function findHostUser(config, email) {
2384
2424
  return config.users.find((user) => user.email.toLowerCase() === wanted) ?? null;
2385
2425
  }
2386
2426
 
2427
+ // src/server/hosted/entra.ts
2428
+ import { createHash, createPublicKey, randomBytes as randomBytes2, verify as verifySignature } from "node:crypto";
2429
+ import { z as z9 } from "zod";
2430
+ var OIDC_SCOPE = "openid profile email";
2431
+ var CLOCK_SKEW_SECONDS = 60;
2432
+ var FILE = "packages/ogygie-studio/src/server/hosted/entra.ts";
2433
+ var baseUrlOf = (config) => `https://login.microsoftonline.com/${config.tenantId}`;
2434
+ var issuerOf = (config) => `${baseUrlOf(config)}/v2.0`;
2435
+ var tokenEndpointOf = (config) => `${baseUrlOf(config)}/oauth2/v2.0/token`;
2436
+ var jwksEndpointOf = (config) => `${baseUrlOf(config)}/discovery/v2.0/keys`;
2437
+ function codeChallengeOf(codeVerifier) {
2438
+ return createHash("sha256").update(codeVerifier).digest("base64url");
2439
+ }
2440
+ function newHandshake() {
2441
+ return { state: randomBytes2(16).toString("hex"), nonce: randomBytes2(16).toString("hex"), codeVerifier: randomBytes2(48).toString("base64url") };
2442
+ }
2443
+ function authorizationUrlOf(config, handshake) {
2444
+ const url = new URL(`${baseUrlOf(config)}/oauth2/v2.0/authorize`);
2445
+ url.searchParams.set("client_id", config.clientId);
2446
+ url.searchParams.set("response_type", "code");
2447
+ url.searchParams.set("redirect_uri", config.callbackUrl);
2448
+ url.searchParams.set("response_mode", "query");
2449
+ url.searchParams.set("scope", OIDC_SCOPE);
2450
+ url.searchParams.set("state", handshake.state);
2451
+ url.searchParams.set("nonce", handshake.nonce);
2452
+ url.searchParams.set("code_challenge", codeChallengeOf(handshake.codeVerifier));
2453
+ url.searchParams.set("code_challenge_method", "S256");
2454
+ return url.toString();
2455
+ }
2456
+ function unauthenticated2(cause, suggestedFix) {
2457
+ return throwOgygieError(
2458
+ { code: "HOST_UNAUTHENTICATED", message: "The Microsoft sign-in did not establish an identity.", file: FILE, cause, suggestedFix },
2459
+ 401
2460
+ );
2461
+ }
2462
+ var IdTokenClaimsSchema = z9.looseObject({
2463
+ sub: z9.string().min(1),
2464
+ iss: z9.string().min(1),
2465
+ aud: z9.union([z9.string(), z9.array(z9.string())]),
2466
+ exp: z9.number(),
2467
+ nonce: z9.string().optional(),
2468
+ email: z9.string().optional(),
2469
+ preferred_username: z9.string().optional(),
2470
+ upn: z9.string().optional(),
2471
+ name: z9.string().optional()
2472
+ });
2473
+ var RsaSigningKeySchema = z9.object({ kid: z9.string().min(1), kty: z9.literal("RSA"), n: z9.string().min(1), e: z9.string().min(1) });
2474
+ var JwksSchema = z9.object({ keys: z9.array(z9.unknown()) });
2475
+ var TokenResponseSchema = z9.looseObject({ id_token: z9.string().min(1) });
2476
+ function decodeJwt(token) {
2477
+ const [encodedHeader, encodedPayload, encodedSignature, ...rest] = token.split(".");
2478
+ if (encodedHeader === void 0 || encodedPayload === void 0 || encodedSignature === void 0 || rest.length > 0) return null;
2479
+ try {
2480
+ const header = JSON.parse(Buffer.from(encodedHeader, "base64url").toString("utf8"));
2481
+ if (typeof header !== "object" || header === null) return null;
2482
+ const { alg, kid } = header;
2483
+ return {
2484
+ header: { ...typeof alg === "string" ? { alg } : {}, ...typeof kid === "string" ? { kid } : {} },
2485
+ payload: JSON.parse(Buffer.from(encodedPayload, "base64url").toString("utf8")),
2486
+ signingInput: `${encodedHeader}.${encodedPayload}`,
2487
+ signature: Buffer.from(encodedSignature, "base64url")
2488
+ };
2489
+ } catch {
2490
+ return null;
2491
+ }
2492
+ }
2493
+ function verifyIdToken(input) {
2494
+ const decoded = decodeJwt(input.idToken);
2495
+ if (decoded === null) return unauthenticated2("id token is not a readable JWT", "Retry the sign-in.");
2496
+ if (decoded.header.alg !== "RS256") {
2497
+ return unauthenticated2(`id token signed with alg=${decoded.header.alg ?? "none"}, only RS256 is accepted`, "Check the signing algorithm of the app registration (RS256).");
2498
+ }
2499
+ 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);
2500
+ if (candidates.length === 0) {
2501
+ return unauthenticated2(`no published RSA key matches kid=${decoded.header.kid ?? "(absent)"}`, "Check that the id token comes from the configured tenant.");
2502
+ }
2503
+ const signed = candidates.some(
2504
+ (key) => verifySignature("sha256", Buffer.from(decoded.signingInput, "utf8"), createPublicKey({ key: { kty: "RSA", n: key.n, e: key.e }, format: "jwk" }), decoded.signature)
2505
+ );
2506
+ 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.");
2507
+ const claims = IdTokenClaimsSchema.safeParse(decoded.payload);
2508
+ if (!claims.success) return unauthenticated2("id token claims do not have the expected shape", "Check the app registration (openid scope, id token).");
2509
+ const expectedIssuer = issuerOf(input.config);
2510
+ 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.");
2511
+ const audiences = Array.isArray(claims.data.aud) ? claims.data.aud : [claims.data.aud];
2512
+ 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.");
2513
+ if (claims.data.exp + CLOCK_SKEW_SECONDS < Math.floor(input.nowMs / 1e3)) return unauthenticated2("id token has expired", "Retry the sign-in.");
2514
+ 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.");
2515
+ return claims.data;
2516
+ }
2517
+ function groupsOfClaim(value) {
2518
+ if (typeof value === "string") return [value];
2519
+ if (!Array.isArray(value)) return [];
2520
+ return value.filter((group) => typeof group === "string" && group.length > 0);
2521
+ }
2522
+ function identityOfClaims(claims, groupsClaim) {
2523
+ const email = claims.email ?? claims.preferred_username ?? claims.upn;
2524
+ if (email === void 0 || email === "") {
2525
+ return unauthenticated2("the id token carries no email, preferred_username nor upn claim", "Add the optional email claim on the app registration.");
2526
+ }
2527
+ return { email: email.toLowerCase(), displayName: claims.name ?? email, groups: groupsOfClaim(claims[groupsClaim]) };
2528
+ }
2529
+ function roleOfGroups(groups, roles) {
2530
+ let found = null;
2531
+ for (const role of HOST_ROLES) if (roles[role].some((group) => group !== "" && groups.includes(group))) found = role;
2532
+ return found;
2533
+ }
2534
+ function hostUserOfIdentity(identity, declared, roles) {
2535
+ if (declared !== null) return { email: declared.email, displayName: declared.displayName, role: declared.role };
2536
+ const role = roleOfGroups(identity.groups, roles);
2537
+ if (role === null) {
2538
+ return throwOgygieError(
2539
+ {
2540
+ code: "HOST_ACCESS_DENIED",
2541
+ message: "This account is authenticated but not invited on this host.",
2542
+ file: null,
2543
+ cause: `${identity.email}: none of its ${identity.groups.length} group(s) is declared under identity.roles, and the address is not in users`,
2544
+ suggestedFix: "Add the account to one of the directory groups the host declares (identity.roles), or declare its address in users."
2545
+ },
2546
+ 403
2547
+ );
2548
+ }
2549
+ return { email: identity.email, displayName: identity.displayName, role };
2550
+ }
2551
+ async function readJson(response, endpoint) {
2552
+ 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.");
2553
+ try {
2554
+ return await response.json();
2555
+ } catch {
2556
+ return unauthenticated2(`${endpoint} answered a body that is not JSON`, "Retry the sign-in.");
2557
+ }
2558
+ }
2559
+ async function exchangeAuthorizationCode(deps, config, input) {
2560
+ const endpoint = tokenEndpointOf(config);
2561
+ const response = await deps.fetchImpl(endpoint, {
2562
+ method: "POST",
2563
+ headers: { "content-type": "application/x-www-form-urlencoded" },
2564
+ body: new URLSearchParams({
2565
+ grant_type: "authorization_code",
2566
+ client_id: config.clientId,
2567
+ client_secret: config.clientSecret,
2568
+ code: input.code,
2569
+ code_verifier: input.codeVerifier,
2570
+ redirect_uri: config.callbackUrl
2571
+ }).toString()
2572
+ });
2573
+ const parsed = TokenResponseSchema.safeParse(await readJson(response, endpoint));
2574
+ if (!parsed.success) return unauthenticated2(`${endpoint} answered without an id_token`, "Check that the app registration requests the openid scope.");
2575
+ return parsed.data.id_token;
2576
+ }
2577
+ async function fetchSigningKeys(deps, config) {
2578
+ const endpoint = jwksEndpointOf(config);
2579
+ const response = await deps.fetchImpl(endpoint, { method: "GET", headers: {} });
2580
+ const parsed = JwksSchema.safeParse(await readJson(response, endpoint));
2581
+ 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.");
2582
+ return parsed.data.keys;
2583
+ }
2584
+
2387
2585
  // src/server/hosted/routes.ts
2586
+ var OIDC_COOKIE = "ogygie_host_oidc";
2587
+ var OIDC_TTL_MS = 10 * 60 * 1e3;
2588
+ var oidcCookie = (value) => `${OIDC_COOKIE}=${encodeURIComponent(value)}; Path=/api/auth/entra; Max-Age=${OIDC_TTL_MS / 1e3}; HttpOnly; Secure; SameSite=Lax`;
2589
+ var clearedOidcCookie = () => `${OIDC_COOKIE}=; Path=/api/auth/entra; Max-Age=0; HttpOnly; Secure; SameSite=Lax`;
2590
+ function cookieOf(cookieHeader, name) {
2591
+ if (cookieHeader === void 0) return null;
2592
+ for (const part of cookieHeader.split(";")) {
2593
+ const [key, ...rest] = part.trim().split("=");
2594
+ if (key === name) return decodeURIComponent(rest.join("="));
2595
+ }
2596
+ return null;
2597
+ }
2388
2598
  var usersByRequest = /* @__PURE__ */ new WeakMap();
2389
2599
  function hostedUserOf(request) {
2390
2600
  const user = usersByRequest.get(request);
2391
2601
  if (user === void 0) return unauthenticated("no session attached to this request");
2392
2602
  return user;
2393
2603
  }
2394
- var MagicLinkInputSchema = z9.object({ email: z9.email() });
2395
- var CallbackQuerySchema = z9.object({ token: z9.string().min(1) });
2396
- var MeOutputSchema = z9.object({
2397
- email: z9.email(),
2398
- displayName: z9.string(),
2604
+ var MagicLinkInputSchema = z10.object({ email: z10.email() });
2605
+ var CallbackQuerySchema = z10.object({ token: z10.string().min(1) });
2606
+ var MeOutputSchema = z10.object({
2607
+ email: z10.email(),
2608
+ displayName: z10.string(),
2399
2609
  role: HostRoleSchema,
2400
- host: z9.object({ environments: z9.boolean(), mergePolicy: z9.enum(["client", "operator"]) })
2610
+ host: z10.object({ environments: z10.boolean(), mergePolicy: z10.enum(["client", "operator"]) })
2401
2611
  });
2402
2612
  async function registerHostedRoutes(app, deps) {
2403
2613
  const sessions = {
@@ -2411,21 +2621,74 @@ async function registerHostedRoutes(app, deps) {
2411
2621
  consumed: deps.consumedLinks,
2412
2622
  ...deps.magicLinkTtlMs === void 0 ? {} : { ttlMs: deps.magicLinkTtlMs }
2413
2623
  };
2414
- const PUBLIC_ROUTES = /* @__PURE__ */ new Set(["/api/auth/magic-link", "/api/auth/callback", "/api/auth/logout"]);
2624
+ 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"]);
2415
2625
  app.addHook("onRequest", async (request) => {
2416
2626
  const url = request.url.split("?")[0] ?? request.url;
2417
2627
  if (!url.startsWith("/api/") || PUBLIC_ROUTES.has(url)) return;
2418
- const email = readSession(sessions, request.headers.cookie);
2419
- if (email === null) return unauthenticated("missing or expired session cookie");
2420
- const user = findHostUser(deps.config, email);
2421
- if (user === null) return unauthenticated(`session for ${email}, no longer a declared user`);
2422
- usersByRequest.set(request, user);
2628
+ const session = readSession(sessions, request.headers.cookie);
2629
+ if (session === null) return unauthenticated("missing or expired session cookie");
2630
+ if (session.via === "magic-link") {
2631
+ const declared = findHostUser(deps.config, session.email);
2632
+ if (declared === null) return unauthenticated(`session for ${session.email}, no longer a declared user`);
2633
+ usersByRequest.set(request, declared);
2634
+ return;
2635
+ }
2636
+ usersByRequest.set(request, { email: session.email, displayName: session.displayName, role: session.role });
2423
2637
  });
2424
2638
  const route = app.withTypeProvider();
2639
+ route.route({
2640
+ method: "GET",
2641
+ url: "/api/auth/options",
2642
+ schema: { response: { 200: z10.object({ magicLink: z10.boolean(), entra: z10.boolean() }) } },
2643
+ handler: async (_request, reply) => reply.code(200).send({ magicLink: deps.config.users.length > 0, entra: deps.entra !== void 0 })
2644
+ });
2645
+ if (deps.entra !== void 0) {
2646
+ const entra = deps.entra;
2647
+ route.route({
2648
+ method: "GET",
2649
+ url: "/api/auth/entra/start",
2650
+ handler: async (_request, reply) => {
2651
+ const handshake = newHandshake();
2652
+ const state = seal({ kind: "oidc", ...handshake, exp: deps.now() + OIDC_TTL_MS }, deps.sessionSecret);
2653
+ return reply.header("set-cookie", oidcCookie(state)).redirect(authorizationUrlOf(entra.config, handshake), 302);
2654
+ }
2655
+ });
2656
+ route.route({
2657
+ method: "GET",
2658
+ url: "/api/auth/entra/callback",
2659
+ 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() }) },
2660
+ handler: async (request, reply) => {
2661
+ const refused = (code, cause) => {
2662
+ deps.log(JSON.stringify({ action: "sign-in", provider: "entra", outcome: code, cause }));
2663
+ return reply.header("set-cookie", clearedOidcCookie()).redirect(`/login?error=${encodeURIComponent(code)}`, 302);
2664
+ };
2665
+ if (request.query.error !== void 0) return refused("HOST_UNAUTHENTICATED", `tenant answered ${request.query.error}: ${request.query.error_description ?? ""}`);
2666
+ const sealed = cookieOf(request.headers.cookie, OIDC_COOKIE);
2667
+ const handshake = sealed === null ? null : open(sealed, deps.sessionSecret);
2668
+ const expected = { state: handshake?.["state"], nonce: handshake?.["nonce"], codeVerifier: handshake?.["codeVerifier"], exp: handshake?.["exp"] };
2669
+ if (handshake?.["kind"] !== "oidc" || typeof expected.state !== "string" || typeof expected.nonce !== "string" || typeof expected.codeVerifier !== "string" || typeof expected.exp !== "number" || expected.exp <= deps.now()) {
2670
+ return refused("HOST_UNAUTHENTICATED", "no live handshake cookie: the sign-in took more than ten minutes, or did not start here");
2671
+ }
2672
+ if (request.query.state !== expected.state || request.query.code === void 0) return refused("HOST_UNAUTHENTICATED", "state mismatch or no code");
2673
+ try {
2674
+ const idToken = await exchangeAuthorizationCode(entra, entra.config, { code: request.query.code, codeVerifier: expected.codeVerifier });
2675
+ const keys = await fetchSigningKeys(entra, entra.config);
2676
+ const claims = verifyIdToken({ idToken, keys, config: entra.config, nonce: expected.nonce, nowMs: deps.now() });
2677
+ const identity = identityOfClaims(claims, entra.config.groupsClaim);
2678
+ const user = hostUserOfIdentity(identity, findHostUser(deps.config, identity.email), entra.config.roles);
2679
+ deps.log(JSON.stringify({ action: "sign-in", provider: "entra", actor: user.email, role: user.role, outcome: "ok" }));
2680
+ return reply.header("set-cookie", [clearedOidcCookie(), sessionCookie(issueSession(sessions, { ...user, via: "entra" }), deps.config.session.ttlHours)]).redirect("/", 302);
2681
+ } catch (thrown) {
2682
+ if (thrown instanceof OgygieFailure) return refused(thrown.error.code, thrown.error.cause);
2683
+ throw thrown;
2684
+ }
2685
+ }
2686
+ });
2687
+ }
2425
2688
  route.route({
2426
2689
  method: "POST",
2427
2690
  url: "/api/auth/magic-link",
2428
- schema: { body: MagicLinkInputSchema, response: { 202: z9.object({ accepted: z9.literal(true) }) } },
2691
+ schema: { body: MagicLinkInputSchema, response: { 202: z10.object({ accepted: z10.literal(true) }) } },
2429
2692
  handler: async (request, reply) => {
2430
2693
  const user = findHostUser(deps.config, request.body.email);
2431
2694
  if (user === null) {
@@ -2455,8 +2718,8 @@ ${link}
2455
2718
  const email = consumeMagicLink(links, request.query.token);
2456
2719
  const user = findHostUser(deps.config, email);
2457
2720
  if (user === null) return unauthenticated(`link for ${email}, no longer a declared user`);
2458
- deps.log(JSON.stringify({ action: "sign-in", actor: user.email, outcome: "ok" }));
2459
- return reply.header("set-cookie", sessionCookie(issueSession(sessions, user.email), deps.config.session.ttlHours)).redirect("/", 302);
2721
+ deps.log(JSON.stringify({ action: "sign-in", provider: "magic-link", actor: user.email, outcome: "ok" }));
2722
+ return reply.header("set-cookie", sessionCookie(issueSession(sessions, { ...user, via: "magic-link" }), deps.config.session.ttlHours)).redirect("/", 302);
2460
2723
  }
2461
2724
  });
2462
2725
  route.route({
@@ -2477,10 +2740,10 @@ ${link}
2477
2740
  }
2478
2741
 
2479
2742
  // src/server/hosted/brief-routes.ts
2480
- import { z as z11 } from "zod";
2743
+ import { z as z12 } from "zod";
2481
2744
 
2482
2745
  // src/server/hosted/attachments.ts
2483
- import { createHash } from "node:crypto";
2746
+ import { createHash as createHash2 } from "node:crypto";
2484
2747
  import { inflateRawSync } from "node:zlib";
2485
2748
  var ATTACHMENT_FORMATS = ["md", "txt", "csv", "json", "html", "xlsx", "pdf"];
2486
2749
  var MAX_ATTACHMENTS = 5;
@@ -2662,7 +2925,7 @@ function prepareBriefAttachments(raw) {
2662
2925
  if (buffer.length > MAX_ATTACHMENT_BYTES) return tooLarge(piece.name, `${buffer.length} bytes`, MAX_ATTACHMENT_BYTES, "bytes");
2663
2926
  totalBytes += buffer.length;
2664
2927
  if (totalBytes > MAX_ATTACHMENTS_BYTES) return tooLarge(piece.name, `${totalBytes} bytes in all`, MAX_ATTACHMENTS_BYTES, "bytes");
2665
- const identity = { name: piece.name, format, bytes: buffer.length, sha256: createHash("sha256").update(buffer).digest("hex") };
2928
+ const identity = { name: piece.name, format, bytes: buffer.length, sha256: createHash2("sha256").update(buffer).digest("hex") };
2666
2929
  if (format === "pdf") {
2667
2930
  if (!isPdf(buffer)) unsupported(piece.name, "the content does not start like a PDF");
2668
2931
  prepared.push({ ...identity, kind: "pdf", data: buffer.toString("base64") });
@@ -2710,7 +2973,7 @@ ${attachments.map((piece) => `- ${piece.name} (${piece.format}, ${sizeOf(piece.b
2710
2973
  var attachmentDocumentsOf = (attachments) => attachments.flatMap((piece) => piece.kind === "pdf" ? [{ name: piece.name, data: piece.data }] : []);
2711
2974
 
2712
2975
  // src/server/hosted/brief.ts
2713
- import { z as z10 } from "zod";
2976
+ import { z as z11 } from "zod";
2714
2977
  import { checkSpec, generationGrammarCard, readManifestGeneration } from "@ogygie/generate";
2715
2978
  var TEMPLATE_PATH2 = "specs/_TEMPLATE.spec.md";
2716
2979
  var PRODUCT_RULES_PATH = "AGENTS.product.md";
@@ -2735,10 +2998,10 @@ function asksUngenerated(brief) {
2735
2998
  async function readIfPresent(storage, path) {
2736
2999
  return await storage.exists(path) ? storage.readFile(path) : null;
2737
3000
  }
2738
- var ManifestRouteSchema = z10.looseObject({ method: z10.string(), path: z10.string(), schemaInput: z10.string().optional(), schemaOutput: z10.string().optional(), invalidates: z10.array(z10.string()).optional() });
2739
- var ManifestForPromptSchema = z10.looseObject({
2740
- product: z10.looseObject({ name: z10.string(), description: z10.string().optional() }).optional(),
2741
- 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()
3001
+ var ManifestRouteSchema = z11.looseObject({ method: z11.string(), path: z11.string(), schemaInput: z11.string().optional(), schemaOutput: z11.string().optional(), invalidates: z11.array(z11.string()).optional() });
3002
+ var ManifestForPromptSchema = z11.looseObject({
3003
+ product: z11.looseObject({ name: z11.string(), description: z11.string().optional() }).optional(),
3004
+ 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()
2742
3005
  });
2743
3006
  async function manifestSummary(storage) {
2744
3007
  const raw = await readIfPresent(storage, MANIFEST_PATH2);
@@ -2805,8 +3068,8 @@ function extractQuestions(text) {
2805
3068
  } catch {
2806
3069
  return { problem: "QUESTIONS: the JSON after the marker does not parse" };
2807
3070
  }
2808
- const shape = z10.object({
2809
- 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)
3071
+ const shape = z11.object({
3072
+ 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)
2810
3073
  });
2811
3074
  const checked = shape.safeParse(parsed);
2812
3075
  if (!checked.success) return { problem: `QUESTIONS: ${checked.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join(" ; ")}` };
@@ -3265,7 +3528,7 @@ async function registerBriefRoutes(app, deps) {
3265
3528
  route.route({
3266
3529
  method: "GET",
3267
3530
  url: "/api/briefs/:jobId",
3268
- schema: { params: z11.object({ jobId: z11.string().min(1) }), response: { 200: BriefJobSchema } },
3531
+ schema: { params: z12.object({ jobId: z12.string().min(1) }), response: { 200: BriefJobSchema } },
3269
3532
  handler: async (request, reply) => {
3270
3533
  hostedUserOf(request);
3271
3534
  return reply.code(200).send(jobs.read(request.params.jobId));
@@ -3436,6 +3699,12 @@ function createHostedSpecStorage(deps) {
3436
3699
  async readFile(path) {
3437
3700
  return (await readerOf(path)).readFile(path);
3438
3701
  },
3702
+ async modifiedAt(path) {
3703
+ if (deps.lastCommitDateOf === void 0) return null;
3704
+ const specId = specIdOfPath2(path);
3705
+ const ref = specId !== null && (await branches()).has(specBranchOf(specId)) ? specBranchOf(specId) : deps.github.defaultBranch;
3706
+ return deps.lastCommitDateOf(ref, path);
3707
+ },
3439
3708
  async exists(path) {
3440
3709
  return (await readerOf(path)).exists(path);
3441
3710
  },
@@ -3885,17 +4154,17 @@ async function registerPromotionRoutes(app, deps) {
3885
4154
  }
3886
4155
 
3887
4156
  // src/server/hosted/usage.ts
3888
- import { z as z12 } from "zod";
3889
- var UsageEntrySchema = z12.object({
3890
- at: z12.string(),
4157
+ import { z as z13 } from "zod";
4158
+ var UsageEntrySchema = z13.object({
4159
+ at: z13.string(),
3891
4160
  /** brief → spec from the instance; `speculate` entries arrive with the generator's lot. */
3892
- action: z12.enum(["brief", "speculate"]),
3893
- attempt: z12.number().int().positive().optional(),
3894
- model: z12.string(),
3895
- inputTokens: z12.number().int().nonnegative(),
3896
- outputTokens: z12.number().int().nonnegative(),
3897
- actor: z12.string().optional(),
3898
- specId: z12.string().optional()
4161
+ action: z13.enum(["brief", "speculate"]),
4162
+ attempt: z13.number().int().positive().optional(),
4163
+ model: z13.string(),
4164
+ inputTokens: z13.number().int().nonnegative(),
4165
+ outputTokens: z13.number().int().nonnegative(),
4166
+ actor: z13.string().optional(),
4167
+ specId: z13.string().optional()
3899
4168
  });
3900
4169
  var USAGE_MARKER = "<!-- ogygie-usage -->";
3901
4170
  var DEFAULT_MODEL_COSTS = {
@@ -3930,7 +4199,7 @@ function parseUsageComment(body) {
3930
4199
  const block = /```json\n([\s\S]*?)\n```/.exec(body)?.[1];
3931
4200
  if (block === void 0) return [];
3932
4201
  try {
3933
- const parsed = z12.array(UsageEntrySchema).safeParse(JSON.parse(block));
4202
+ const parsed = z13.array(UsageEntrySchema).safeParse(JSON.parse(block));
3934
4203
  return parsed.success ? parsed.data : [];
3935
4204
  } catch {
3936
4205
  return [];
@@ -4005,9 +4274,9 @@ async function registerConsumptionRoutes(app, deps) {
4005
4274
  }
4006
4275
 
4007
4276
  // src/server/hosted/preview-routes.ts
4008
- import { z as z13 } from "zod";
4009
- var PreviewParamsSchema = SpecParamsSchema.extend({ state: z13.string().min(1).max(80) });
4010
- var CompositionPreviewParamsSchema = PreviewParamsSchema.extend({ composition: z13.string().min(1).max(80) });
4277
+ import { z as z14 } from "zod";
4278
+ var PreviewParamsSchema = SpecParamsSchema.extend({ state: z14.string().min(1).max(80) });
4279
+ var CompositionPreviewParamsSchema = PreviewParamsSchema.extend({ composition: z14.string().min(1).max(80) });
4011
4280
  var PREVIEW_CSP = "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:";
4012
4281
  async function registerPreviewRoutes(app, deps) {
4013
4282
  const route = app.withTypeProvider();
@@ -4049,7 +4318,7 @@ async function registerPreviewRoutes(app, deps) {
4049
4318
  }
4050
4319
 
4051
4320
  // src/server/hosted/composition-routes.ts
4052
- import { z as z14 } from "zod";
4321
+ import { z as z15 } from "zod";
4053
4322
 
4054
4323
  // src/server/hosted/compositions.ts
4055
4324
  var COMPOSITION_NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
@@ -4093,7 +4362,7 @@ La composition \xAB ${composition} \xBB est retenue parmi celles propos\xE9es ;
4093
4362
  }
4094
4363
 
4095
4364
  // src/server/hosted/composition-routes.ts
4096
- var CompositionParamsSchema = SpecParamsSchema.extend({ composition: z14.string().min(1).max(80) });
4365
+ var CompositionParamsSchema = SpecParamsSchema.extend({ composition: z15.string().min(1).max(80) });
4097
4366
  async function registerCompositionRoutes(app, deps) {
4098
4367
  const route = app.withTypeProvider();
4099
4368
  route.route({
@@ -4478,6 +4747,15 @@ function createGithubPullsClient(deps) {
4478
4747
  );
4479
4748
  return (json ?? []).map((entry) => ({ sha: entry.sha, message: entry.commit.message }));
4480
4749
  },
4750
+ async lastCommitDateOf(branch, path) {
4751
+ const { json } = await request(
4752
+ "GET",
4753
+ `${repoUrl}/commits?sha=${encodeURIComponent(branch)}&path=${encodeURIComponent(path)}&per_page=1`,
4754
+ void 0
4755
+ );
4756
+ const last = json?.[0];
4757
+ return last?.commit.committer?.date ?? last?.commit.author?.date ?? null;
4758
+ },
4481
4759
  async getPullRequest(number) {
4482
4760
  const { json } = await request(
4483
4761
  "GET",
@@ -4826,6 +5104,7 @@ async function startHostedStudio(options) {
4826
5104
  const privateKeyPem = readSecret(config.githubApp.privateKeyPath, "the GitHub App private key");
4827
5105
  const sessionSecret = readSecret(config.session.secretPath, "the session secret", MIN_SESSION_SECRET_LENGTH);
4828
5106
  const mailApiKey = readSecret(config.mail.apiKeyPath, "the mail provider key");
5107
+ const entraClientSecret = config.identity === void 0 ? void 0 : readSecret(config.identity.clientSecretPath, "the Entra client secret");
4829
5108
  const modelApiKey = config.model === void 0 ? void 0 : process.env["OGYGIE_MODEL_API_KEY"] ?? "";
4830
5109
  if (modelApiKey !== void 0 && modelApiKey.length === 0) {
4831
5110
  throwOgygieError({
@@ -4878,6 +5157,7 @@ async function startHostedStudio(options) {
4878
5157
  }
4879
5158
  }
4880
5159
  }
5160
+ const pulls = createGithubPullsClient({ github: config.repo, fetchImpl: githubApp.installationFetch });
4881
5161
  const app = await createStudioApp({
4882
5162
  uiDistDir,
4883
5163
  hosted: {
@@ -4892,7 +5172,20 @@ async function startHostedStudio(options) {
4892
5172
  now,
4893
5173
  log,
4894
5174
  consumedLinks: /* @__PURE__ */ new Map(),
4895
- pulls: createGithubPullsClient({ github: config.repo, fetchImpl: githubApp.installationFetch }),
5175
+ ...config.identity === void 0 || entraClientSecret === void 0 ? {} : {
5176
+ entra: {
5177
+ fetchImpl: fetch,
5178
+ config: {
5179
+ tenantId: config.identity.tenantId,
5180
+ clientId: config.identity.clientId,
5181
+ clientSecret: entraClientSecret,
5182
+ callbackUrl: `${config.publicUrl.replace(/\/$/, "")}/api/auth/entra/callback`,
5183
+ groupsClaim: config.identity.groupsClaim,
5184
+ roles: config.identity.roles
5185
+ }
5186
+ }
5187
+ },
5188
+ pulls,
4896
5189
  readerFor,
4897
5190
  usage: createGithubUsageClient({ github: config.repo, fetchImpl: githubApp.installationFetch }),
4898
5191
  ...deploy === void 0 ? {} : { deploy },
@@ -4902,6 +5195,7 @@ async function startHostedStudio(options) {
4902
5195
  github: config.repo,
4903
5196
  readerFor,
4904
5197
  listSpecBranches,
5198
+ lastCommitDateOf: pulls.lastCommitDateOf,
4905
5199
  client,
4906
5200
  user,
4907
5201
  now,