@opengeni/api-router 2.5.0 → 2.6.4-canary.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.
Files changed (70) hide show
  1. package/dist/app.d.ts +2 -1
  2. package/dist/app.js +3 -1
  3. package/dist/auth/managed-auth-attempt-context.d.ts +5 -1
  4. package/dist/auth/managed-auth.d.ts +24 -1
  5. package/dist/{chunk-QESX7HDK.js → chunk-UOI7KAP3.js} +3240 -791
  6. package/dist/chunk-UOI7KAP3.js.map +1 -0
  7. package/dist/http/sse.d.ts +9 -0
  8. package/dist/index.js +117 -5
  9. package/dist/index.js.map +1 -1
  10. package/dist/interaction-metrics.d.ts +2 -0
  11. package/dist/mcp/company-brain-governed-writes.d.ts +1 -1
  12. package/dist/mcp/company-profile-agent-admin.d.ts +6 -6
  13. package/dist/mcp/remember.d.ts +2 -2
  14. package/dist/mcp/scheduled-task-view.d.ts +3 -3
  15. package/dist/mcp/server.d.ts +1 -0
  16. package/dist/mcp/session-view.d.ts +1 -0
  17. package/dist/mcp/session-wait.d.ts +19 -0
  18. package/dist/model-catalog.d.ts +5 -31
  19. package/dist/routes/api-keys.d.ts +2 -0
  20. package/dist/routes/browser-sessions.d.ts +1 -0
  21. package/dist/routes/computer-sessions.d.ts +12 -0
  22. package/dist/routes/managed-auth-session-sets.d.ts +7 -0
  23. package/dist/routes/workspaces.d.ts +1 -1
  24. package/dist/sandbox/metrics-ingestion.d.ts +16 -0
  25. package/dist/workspace-delete-observability.d.ts +10 -0
  26. package/package.json +19 -19
  27. package/src/app.ts +206 -29
  28. package/src/auth/managed-auth-attempt-context.ts +40 -3
  29. package/src/auth/managed-auth-session-adapter.ts +1 -0
  30. package/src/auth/managed-auth.ts +164 -4
  31. package/src/http/sse.ts +279 -45
  32. package/src/index.ts +13 -1
  33. package/src/integrations/oauth-client.ts +8 -1
  34. package/src/integrations/provider-oauth.ts +12 -2
  35. package/src/integrations/slack-interactions.ts +77 -37
  36. package/src/interaction-metrics.ts +30 -0
  37. package/src/mcp/company-brain-governed-writes.ts +38 -23
  38. package/src/mcp/company-profile-agent-admin.ts +7 -7
  39. package/src/mcp/remember.ts +19 -8
  40. package/src/mcp/server.ts +328 -27
  41. package/src/mcp/session-wait.ts +56 -8
  42. package/src/model-catalog.ts +45 -337
  43. package/src/routes/api-integrations.ts +2 -2
  44. package/src/routes/api-keys.ts +149 -7
  45. package/src/routes/automations.ts +186 -19
  46. package/src/routes/browser-sessions.ts +10 -2
  47. package/src/routes/capabilities.ts +3 -3
  48. package/src/routes/codex.ts +483 -74
  49. package/src/routes/company-profile.ts +64 -0
  50. package/src/routes/computer-sessions.ts +103 -1
  51. package/src/routes/connections.ts +271 -16
  52. package/src/routes/github.ts +0 -8
  53. package/src/routes/integration-facets.ts +8 -5
  54. package/src/routes/interaction-resources.ts +7 -1
  55. package/src/routes/managed-auth-session-sets.ts +199 -2
  56. package/src/routes/organization-memberships.ts +28 -8
  57. package/src/routes/packs.ts +5 -5
  58. package/src/routes/plugins.ts +2 -2
  59. package/src/routes/pr-review.ts +80 -4
  60. package/src/routes/scheduled-tasks.ts +47 -11
  61. package/src/routes/sessions.ts +75 -53
  62. package/src/routes/skills.ts +3 -3
  63. package/src/routes/supergrok.ts +3 -2
  64. package/src/routes/workspaces.ts +665 -56
  65. package/src/sandbox/channel-a.ts +10 -4
  66. package/src/sandbox/machines.ts +13 -6
  67. package/src/sandbox/metrics-ingestion.ts +157 -3
  68. package/src/sandbox/viewer.ts +20 -1
  69. package/src/workspace-delete-observability.ts +75 -0
  70. package/dist/chunk-QESX7HDK.js.map +0 -1
@@ -58,7 +58,11 @@ import {
58
58
  import { publishDurableSessionEvents } from "@opengeni/events";
59
59
  import type { Context, Hono } from "hono";
60
60
  import { HTTPException } from "hono/http-exception";
61
- import { sseWorkspaceInteractionRevisionStream, sseWorkspaceLiveStream } from "../http/sse";
61
+ import {
62
+ browserSseDeliveryOptions,
63
+ sseWorkspaceInteractionRevisionStream,
64
+ sseWorkspaceLiveStream,
65
+ } from "../http/sse";
62
66
  import { observeInterventionMutation } from "../interaction-metrics";
63
67
 
64
68
  export function registerInteractionResourceRoutes(app: Hono, deps: ApiRouteDeps): void {
@@ -74,6 +78,7 @@ export function registerInteractionResourceRoutes(app: Hono, deps: ApiRouteDeps)
74
78
  nonnegativeSafeIntegerQuery(context, "interactionAfter", 0),
75
79
  context.req.raw.signal,
76
80
  {
81
+ ...browserSseDeliveryOptions(context.req.query("transport")),
77
82
  observability: deps.observability,
78
83
  actorEpoch: getManagedAuthRequestActorEpoch(context.req.raw) ?? undefined,
79
84
  reauthorize: async () => {
@@ -99,6 +104,7 @@ export function registerInteractionResourceRoutes(app: Hono, deps: ApiRouteDeps)
99
104
  after,
100
105
  context.req.raw.signal,
101
106
  {
107
+ ...browserSseDeliveryOptions(context.req.query("transport")),
102
108
  observability: deps.observability,
103
109
  actorEpoch: getManagedAuthRequestActorEpoch(context.req.raw) ?? undefined,
104
110
  reauthorize: async () => {
@@ -7,6 +7,7 @@ import {
7
7
  CompleteManagedAuthLoginTransactionResponse,
8
8
  LogoutManagedAuthLoginSlotRequest,
9
9
  LogoutManagedAuthSessionSetRequest,
10
+ MANAGED_AUTH_TRANSACTION_TTL_SECONDS,
10
11
  MANAGED_AUTH_SESSION_SET_API_CONTRACT_HEADER,
11
12
  MANAGED_AUTH_SESSION_SET_API_CONTRACT_REVISION,
12
13
  ManagedAuthDeepLinkResolution,
@@ -15,6 +16,8 @@ import {
15
16
  ManagedAuthSessionSetErrorCode,
16
17
  ResolveManagedAuthDeepLinkRequest,
17
18
  SelectManagedAuthLoginSlotRequest,
19
+ StartManagedAuthSocialTransactionRequest,
20
+ StartManagedAuthSocialTransactionResponse,
18
21
  type ManagedAuthSessionSetProjection as ManagedAuthSessionSetProjectionType,
19
22
  type ManagedAuthSessionSetErrorCode as ManagedAuthSessionSetErrorCodeType,
20
23
  } from "@opengeni/contracts/managed-auth-session-sets";
@@ -57,11 +60,21 @@ import {
57
60
  mutateManagedAuthSessionSet,
58
61
  } from "@opengeni/db/managed-auth-session-sets";
59
62
  import { ensureManagedAccessForUser, getSession } from "@opengeni/db";
63
+ import { sql } from "drizzle-orm";
60
64
  import type { Context, Hono } from "hono";
61
65
  import { deleteCookie, getCookie, setCookie } from "hono/cookie";
62
66
  import { HTTPException } from "hono/http-exception";
63
67
  import { ApiHttpError } from "../http/api-error";
64
- import type { z } from "zod";
68
+ import { z } from "zod";
69
+
70
+ const ManagedAuthSocialStartReceipt = z
71
+ .object({
72
+ version: z.literal(1),
73
+ requestDigest: z.string().regex(/^[0-9a-f]{64}$/u),
74
+ url: z.string().url().max(4_096),
75
+ stateCookie: z.string().min(1).max(4_096),
76
+ })
77
+ .strict();
65
78
 
66
79
  export function registerManagedAuthSessionSetRoutes(app: Hono, deps: ApiRouteDeps): void {
67
80
  const noStore = async (context: Context, next: () => Promise<void>) => {
@@ -304,6 +317,129 @@ export function registerManagedAuthSessionSetRoutes(app: Hono, deps: ApiRouteDep
304
317
  }
305
318
  });
306
319
 
320
+ app.post("/v1/auth/session-set/transactions/social", async (context) => {
321
+ const body = await bodyAs(context, StartManagedAuthSocialTransactionRequest);
322
+ const { authority, actorEpoch } = await requireMutation(context, deps, body.expectedGeneration);
323
+ const available = requireAvailable(deps);
324
+ if (!managedAuthSocialProviderConfigured(deps, body.provider)) {
325
+ throw managedAuthApiError(404, "managed_authentication_unavailable");
326
+ }
327
+ const transactionSecret = requireTransactionSecret(context, body.transactionId);
328
+ const publicBaseUrl = deps.settings.publicBaseUrl;
329
+ if (!publicBaseUrl) {
330
+ throw managedAuthApiError(503, "managed_authentication_unavailable", {
331
+ retryable: true,
332
+ });
333
+ }
334
+ const callbackURL = new URL("/account-auth", publicBaseUrl);
335
+ callbackURL.searchParams.set("transaction", body.transactionId);
336
+ callbackURL.searchParams.set("social", "complete");
337
+ const errorCallbackURL = new URL(callbackURL);
338
+ errorCallbackURL.searchParams.set("social", "error");
339
+ try {
340
+ const authorityHash = managedAuthSha256(authority);
341
+ const transactionSecretHash = managedAuthSha256(transactionSecret);
342
+ const requestDigest = digest(deps, {
343
+ operation: "social_start",
344
+ authorityHash,
345
+ actorEpoch,
346
+ transactionSecretHash,
347
+ request: body,
348
+ });
349
+ const receiptIdentifier = `opengeni-managed-social-start:${authorityHash}:${body.operationId}`;
350
+ const receipt = await deps.db.transaction(async (transaction) => {
351
+ await transaction.execute(
352
+ sql`select pg_advisory_xact_lock(hashtextextended(${receiptIdentifier}, 0))`,
353
+ );
354
+ const authContext = await available.managedAuth.$context;
355
+ const stored = await authContext.internalAdapter.findVerificationValue(receiptIdentifier);
356
+ if (stored && new Date(stored.expiresAt).getTime() > Date.now()) {
357
+ let parsed: ReturnType<typeof ManagedAuthSocialStartReceipt.safeParse>;
358
+ try {
359
+ parsed = ManagedAuthSocialStartReceipt.safeParse(JSON.parse(stored.value));
360
+ } catch (error) {
361
+ throw new ManagedAuthCompletionOutcomeUnknownError({
362
+ cause: error,
363
+ });
364
+ }
365
+ if (!parsed.success) {
366
+ throw new ManagedAuthCompletionOutcomeUnknownError({
367
+ cause: parsed.error,
368
+ });
369
+ }
370
+ if (parsed.data.requestDigest !== requestDigest) {
371
+ throw new ManagedAuthSessionSetOperationReuseError();
372
+ }
373
+ return parsed.data;
374
+ }
375
+ if (stored) {
376
+ await authContext.internalAdapter.deleteVerificationByIdentifier(receiptIdentifier);
377
+ }
378
+ const result = await available.managedAuth.api.signInSocial({
379
+ body: {
380
+ provider: body.provider,
381
+ disableRedirect: true,
382
+ callbackURL: callbackURL.toString(),
383
+ errorCallbackURL: errorCallbackURL.toString(),
384
+ additionalData: {
385
+ opengeniManagedAuth: {
386
+ version: 1,
387
+ operationId: body.operationId,
388
+ provider: body.provider,
389
+ transactionId: body.transactionId,
390
+ authorityHash,
391
+ transactionSecretHash,
392
+ expectedGeneration: body.expectedGeneration,
393
+ expectedActorEpoch: actorEpoch,
394
+ },
395
+ },
396
+ },
397
+ headers: isolatedManagedAuthHeaders(context.req.raw),
398
+ returnHeaders: true,
399
+ });
400
+ const url = validatedManagedAuthSocialAuthorizationUrl({
401
+ provider: body.provider,
402
+ rawUrl: result.response?.url,
403
+ expectedClientId:
404
+ body.provider === "google"
405
+ ? deps.settings.managedAuthGoogleClientId!
406
+ : deps.settings.managedAuthGithubClientId!,
407
+ expectedCallbackUrl: new URL(
408
+ `/v1/auth/callback/${body.provider}`,
409
+ publicBaseUrl,
410
+ ).toString(),
411
+ });
412
+ const stateCookieName = authContext.createAuthCookie("state").name;
413
+ const stateCookie = setCookieHeaders(result.headers).find((cookie) =>
414
+ cookie.startsWith(`${stateCookieName}=`),
415
+ );
416
+ const parsed = ManagedAuthSocialStartReceipt.parse({
417
+ version: 1,
418
+ requestDigest,
419
+ url,
420
+ stateCookie,
421
+ });
422
+ const created = await authContext.internalAdapter.createVerificationValue({
423
+ identifier: receiptIdentifier,
424
+ value: JSON.stringify(parsed),
425
+ expiresAt: new Date(Date.now() + MANAGED_AUTH_TRANSACTION_TTL_SECONDS * 1_000),
426
+ });
427
+ if (!created) {
428
+ throw new ManagedAuthCompletionOutcomeUnknownError();
429
+ }
430
+ return parsed;
431
+ });
432
+ context.header("set-cookie", receipt.stateCookie, { append: true });
433
+ return jsonWithActorEpoch(
434
+ context,
435
+ actorEpoch,
436
+ StartManagedAuthSocialTransactionResponse.parse({ url: receipt.url }),
437
+ );
438
+ } catch (error) {
439
+ throwHttp(error);
440
+ }
441
+ });
442
+
307
443
  app.delete("/v1/auth/session-set/transactions/:transactionId", async (context) => {
308
444
  const body = await bodyAs(context, CancelManagedAuthLoginTransactionRequest);
309
445
  if (body.transactionId !== context.req.param("transactionId")) invalid();
@@ -449,6 +585,46 @@ export function registerManagedAuthSessionSetRoutes(app: Hono, deps: ApiRouteDep
449
585
  });
450
586
  }
451
587
 
588
+ export function validatedManagedAuthSocialAuthorizationUrl(input: {
589
+ provider: "google" | "github";
590
+ rawUrl: unknown;
591
+ expectedClientId: string;
592
+ expectedCallbackUrl: string;
593
+ }): string {
594
+ if (typeof input.rawUrl !== "string") {
595
+ throw new ManagedAuthCompletionOutcomeUnknownError();
596
+ }
597
+ let url: URL;
598
+ try {
599
+ url = new URL(input.rawUrl);
600
+ } catch (error) {
601
+ throw new ManagedAuthCompletionOutcomeUnknownError({ cause: error });
602
+ }
603
+ const expectedEndpoint =
604
+ input.provider === "google"
605
+ ? { origin: "https://accounts.google.com", pathname: "/o/oauth2/v2/auth" }
606
+ : { origin: "https://github.com", pathname: "/login/oauth/authorize" };
607
+ const exactlyOne = (name: string, expected?: string): boolean => {
608
+ const values = url.searchParams.getAll(name);
609
+ return (
610
+ values.length === 1 && values[0] !== "" && (expected === undefined || values[0] === expected)
611
+ );
612
+ };
613
+ if (
614
+ url.origin !== expectedEndpoint.origin ||
615
+ url.pathname !== expectedEndpoint.pathname ||
616
+ url.username ||
617
+ url.password ||
618
+ url.hash ||
619
+ !exactlyOne("client_id", input.expectedClientId) ||
620
+ !exactlyOne("redirect_uri", input.expectedCallbackUrl) ||
621
+ !exactlyOne("state")
622
+ ) {
623
+ throw new ManagedAuthCompletionOutcomeUnknownError();
624
+ }
625
+ return input.rawUrl;
626
+ }
627
+
452
628
  /**
453
629
  * Better Auth 1.6.26 provider lifecycle routes retained while session-set mode
454
630
  * owns all selected-session reads, enumeration, revocation, and user mutation.
@@ -470,21 +646,42 @@ export function requireManagedAuthProviderRouteAllowed(method: string, pathname:
470
646
  (pathname === "/v1/auth/verify-email" ||
471
647
  pathname === "/v1/auth/error" ||
472
648
  pathname === "/v1/auth/ok" ||
649
+ /^\/v1\/auth\/callback\/(?:google|github)$/.test(pathname) ||
473
650
  /^\/v1\/auth\/reset-password\/[^/]+$/.test(pathname)));
474
651
  if (!allowed) throw managedAuthApiError(409, "provider_route_blocked");
475
652
  }
476
653
 
654
+ function managedAuthSocialProviderConfigured(
655
+ deps: ApiRouteDeps,
656
+ provider: "google" | "github",
657
+ ): boolean {
658
+ return provider === "google"
659
+ ? Boolean(
660
+ deps.settings.managedAuthGoogleClientId && deps.settings.managedAuthGoogleClientSecret,
661
+ )
662
+ : Boolean(
663
+ deps.settings.managedAuthGithubClientId && deps.settings.managedAuthGithubClientSecret,
664
+ );
665
+ }
666
+
477
667
  /** Remove provider bearer/session material and enforce browser-auth cache/cookie policy. */
478
668
  export async function scrubManagedAuthProviderResponse(
479
669
  response: Response,
480
- options: { replacementCookies?: readonly string[] | undefined } = {},
670
+ options: {
671
+ replacementCookies?: readonly string[] | undefined;
672
+ preserveCookieNames?: readonly string[] | undefined;
673
+ } = {},
481
674
  ): Promise<Response> {
482
675
  const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
483
676
  const headers = new Headers(response.headers);
484
677
  headers.set("cache-control", "no-store");
485
678
  headers.set("pragma", "no-cache");
486
679
  if (options.replacementCookies !== undefined) {
680
+ const preserved = setCookieHeaders(response.headers).filter((cookie) =>
681
+ options.preserveCookieNames?.some((name) => cookie.startsWith(`${name}=`)),
682
+ );
487
683
  headers.delete("set-cookie");
684
+ for (const cookie of preserved) headers.append("set-cookie", cookie);
488
685
  for (const cookie of options.replacementCookies) headers.append("set-cookie", cookie);
489
686
  }
490
687
  let body: BodyInit | null = response.body;
@@ -33,6 +33,7 @@ import {
33
33
  import {
34
34
  getManagedSession,
35
35
  organizationMembershipHttpStatus,
36
+ requireCanonicalLocalAccountAdministrator,
36
37
  type ApiRouteDeps,
37
38
  } from "@opengeni/core";
38
39
  import {
@@ -100,6 +101,25 @@ async function requireManagedHuman(context: Context, deps: ApiRouteDeps) {
100
101
  return { session, subjectId: `user:${session.user.id}` };
101
102
  }
102
103
 
104
+ async function requireOrganizationAdministrator(
105
+ context: Context,
106
+ deps: ApiRouteDeps,
107
+ organizationId: string,
108
+ ): Promise<{ subjectId: string }> {
109
+ if (deps.settings.productAccessMode === "managed") {
110
+ return await requireManagedHuman(context, deps);
111
+ }
112
+ if (deps.settings.productAccessMode === "local") {
113
+ const { subjectId } = await requireCanonicalLocalAccountAdministrator(
114
+ context,
115
+ deps,
116
+ organizationId,
117
+ );
118
+ return { subjectId };
119
+ }
120
+ throw new HTTPException(401, { message: "organization administrator session required" });
121
+ }
122
+
103
123
  async function parseBody<S extends z.ZodType>(context: Context, schema: S): Promise<z.infer<S>> {
104
124
  const parsed = schema.safeParse(await context.req.json().catch(() => null));
105
125
  if (!parsed.success) {
@@ -213,12 +233,12 @@ export function registerOrganizationMembershipRoutes(app: Hono, deps: ApiRouteDe
213
233
  });
214
234
 
215
235
  app.get("/v1/organizations/:organizationId/overview", async (context) => {
216
- const { subjectId } = await requireManagedHuman(context, deps);
217
236
  const organizationId = parseId(
218
237
  OrganizationId,
219
238
  context.req.param("organizationId"),
220
239
  "organization id",
221
240
  );
241
+ const { subjectId } = await requireOrganizationAdministrator(context, deps, organizationId);
222
242
  try {
223
243
  return context.json(
224
244
  OrganizationAdministrationOverview.parse(
@@ -234,12 +254,12 @@ export function registerOrganizationMembershipRoutes(app: Hono, deps: ApiRouteDe
234
254
  });
235
255
 
236
256
  app.patch("/v1/organizations/:organizationId", async (context) => {
237
- const { subjectId } = await requireManagedHuman(context, deps);
238
257
  const organizationId = parseId(
239
258
  OrganizationId,
240
259
  context.req.param("organizationId"),
241
260
  "organization id",
242
261
  );
262
+ const { subjectId } = await requireOrganizationAdministrator(context, deps, organizationId);
243
263
  const payload = await parseBody(context, UpdateOrganizationNameRequest);
244
264
  try {
245
265
  return context.json(
@@ -257,12 +277,12 @@ export function registerOrganizationMembershipRoutes(app: Hono, deps: ApiRouteDe
257
277
  });
258
278
 
259
279
  app.patch("/v1/organizations/:organizationId/workspaces/:workspaceId", async (context) => {
260
- const { subjectId } = await requireManagedHuman(context, deps);
261
280
  const organizationId = parseId(
262
281
  OrganizationId,
263
282
  context.req.param("organizationId"),
264
283
  "organization id",
265
284
  );
285
+ const { subjectId } = await requireOrganizationAdministrator(context, deps, organizationId);
266
286
  const workspaceId = parseId(WorkspaceId, context.req.param("workspaceId"), "workspace id");
267
287
  const payload = await parseBody(context, UpdateOrganizationWorkspaceRequest);
268
288
  try {
@@ -284,12 +304,12 @@ export function registerOrganizationMembershipRoutes(app: Hono, deps: ApiRouteDe
284
304
  });
285
305
 
286
306
  app.post("/v1/organizations/:organizationId/workspaces", async (context) => {
287
- const { subjectId } = await requireManagedHuman(context, deps);
288
307
  const organizationId = parseId(
289
308
  OrganizationId,
290
309
  context.req.param("organizationId"),
291
310
  "organization id",
292
311
  );
312
+ const { subjectId } = await requireOrganizationAdministrator(context, deps, organizationId);
293
313
  const payload = await parseBody(context, CreateOrganizationWorkspaceRequest);
294
314
  try {
295
315
  return context.json(
@@ -311,12 +331,12 @@ export function registerOrganizationMembershipRoutes(app: Hono, deps: ApiRouteDe
311
331
  app.patch(
312
332
  "/v1/organizations/:organizationId/workspaces/:workspaceId/settings",
313
333
  async (context) => {
314
- const { subjectId } = await requireManagedHuman(context, deps);
315
334
  const organizationId = parseId(
316
335
  OrganizationId,
317
336
  context.req.param("organizationId"),
318
337
  "organization id",
319
338
  );
339
+ const { subjectId } = await requireOrganizationAdministrator(context, deps, organizationId);
320
340
  const workspaceId = parseId(WorkspaceId, context.req.param("workspaceId"), "workspace id");
321
341
  const payload = await parseBody(context, UpdateWorkspaceSettingsRequest);
322
342
  try {
@@ -642,12 +662,12 @@ export function registerOrganizationMembershipRoutes(app: Hono, deps: ApiRouteDe
642
662
  );
643
663
 
644
664
  app.get("/v1/organizations/:organizationId/members", async (context) => {
645
- const { subjectId } = await requireManagedHuman(context, deps);
646
665
  const organizationId = parseId(
647
666
  OrganizationId,
648
667
  context.req.param("organizationId"),
649
668
  "organization id",
650
669
  );
670
+ const { subjectId } = await requireOrganizationAdministrator(context, deps, organizationId);
651
671
  try {
652
672
  return context.json(
653
673
  ListOrganizationAdministrationMembersResponse.parse({
@@ -689,12 +709,12 @@ export function registerOrganizationMembershipRoutes(app: Hono, deps: ApiRouteDe
689
709
  });
690
710
 
691
711
  app.get("/v1/organizations/:organizationId/retention-policy", async (context) => {
692
- const { subjectId } = await requireManagedHuman(context, deps);
693
712
  const organizationId = parseId(
694
713
  OrganizationId,
695
714
  context.req.param("organizationId"),
696
715
  "organization id",
697
716
  );
717
+ const { subjectId } = await requireOrganizationAdministrator(context, deps, organizationId);
698
718
  try {
699
719
  return context.json(
700
720
  OrganizationRetentionPolicy.parse(
@@ -710,12 +730,12 @@ export function registerOrganizationMembershipRoutes(app: Hono, deps: ApiRouteDe
710
730
  });
711
731
 
712
732
  app.patch("/v1/organizations/:organizationId/retention-policy", async (context) => {
713
- const { subjectId } = await requireManagedHuman(context, deps);
714
733
  const organizationId = parseId(
715
734
  OrganizationId,
716
735
  context.req.param("organizationId"),
717
736
  "organization id",
718
737
  );
738
+ const { subjectId } = await requireOrganizationAdministrator(context, deps, organizationId);
719
739
  const payload = await parseBody(context, UpdateOrganizationRetentionPolicyRequest);
720
740
  try {
721
741
  return context.json(
@@ -83,7 +83,7 @@ export function registerPackRoutes(app: Hono, deps: ApiRouteDeps): void {
83
83
  // Built-in pack ids stay reserved so a registration can never shadow them.
84
84
  app.post("/v1/workspaces/:workspaceId/packs", async (c) => {
85
85
  const workspaceId = c.req.param("workspaceId");
86
- const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
86
+ const grant = await requireAccessGrant(c, deps, workspaceId, "capabilities:manage");
87
87
  const manifest = RegisterCapabilityPackRequest.parse(await c.req.json());
88
88
  if (isBuiltInCapabilityPack(manifest.id)) {
89
89
  throw new HTTPException(409, {
@@ -100,7 +100,7 @@ export function registerPackRoutes(app: Hono, deps: ApiRouteDeps): void {
100
100
 
101
101
  app.delete("/v1/workspaces/:workspaceId/packs/:packId", async (c) => {
102
102
  const workspaceId = c.req.param("workspaceId");
103
- await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
103
+ await requireAccessGrant(c, deps, workspaceId, "capabilities:manage");
104
104
  const packId = c.req.param("packId");
105
105
  if (isBuiltInCapabilityPack(packId)) {
106
106
  throw new HTTPException(409, {
@@ -172,7 +172,7 @@ export function registerPackRoutes(app: Hono, deps: ApiRouteDeps): void {
172
172
 
173
173
  app.post("/v1/workspaces/:workspaceId/packs/:packId/install", async (c) => {
174
174
  const workspaceId = c.req.param("workspaceId");
175
- const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
175
+ const grant = await requireAccessGrant(c, deps, workspaceId, "capabilities:manage");
176
176
  const pack = await requirePack(db, workspaceId, c.req.param("packId"));
177
177
  const payload = InstallPackRequest.parse(await c.req.json());
178
178
  const preview = PackInstallationPreview.parse(
@@ -393,7 +393,7 @@ export function registerPackRoutes(app: Hono, deps: ApiRouteDeps): void {
393
393
 
394
394
  app.delete("/v1/workspaces/:workspaceId/packs/:packId/installation", async (c) => {
395
395
  const workspaceId = c.req.param("workspaceId");
396
- const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
396
+ const grant = await requireAccessGrant(c, deps, workspaceId, "capabilities:manage");
397
397
  const pack = await requirePack(db, workspaceId, c.req.param("packId"));
398
398
  const payload = UninstallPackRequest.parse(await c.req.json());
399
399
  const requestDigest = sha256(
@@ -483,7 +483,7 @@ export function registerPackRoutes(app: Hono, deps: ApiRouteDeps): void {
483
483
 
484
484
  app.post("/v1/workspaces/:workspaceId/packs/:packId/enable", async (c) => {
485
485
  const workspaceId = c.req.param("workspaceId");
486
- const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
486
+ const grant = await requireAccessGrant(c, deps, workspaceId, "capabilities:manage");
487
487
  const pack = await requirePack(db, workspaceId, c.req.param("packId"));
488
488
  if (capabilityPackRequiresInstallationPlan(pack)) {
489
489
  throw new HTTPException(409, {
@@ -120,7 +120,7 @@ export function registerPluginRoutes(
120
120
 
121
121
  app.post("/v1/workspaces/:workspaceId/plugins/install", async (c) => {
122
122
  const workspaceId = c.req.param("workspaceId");
123
- const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
123
+ const grant = await requireAccessGrant(c, deps, workspaceId, "capabilities:manage");
124
124
  const payload = InstallPluginRequest.parse(await c.req.json());
125
125
  const resolved = await resolvePluginPackage({
126
126
  deps,
@@ -253,7 +253,7 @@ export function registerPluginRoutes(
253
253
 
254
254
  app.delete("/v1/workspaces/:workspaceId/plugins/:pluginKey", async (c) => {
255
255
  const workspaceId = c.req.param("workspaceId");
256
- const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
256
+ const grant = await requireAccessGrant(c, deps, workspaceId, "capabilities:manage");
257
257
  const pluginKey = decodeURIComponent(c.req.param("pluginKey"));
258
258
  const payload = UninstallPluginRequest.parse(await c.req.json());
259
259
  try {
@@ -13,12 +13,14 @@ import {
13
13
  canonicalConfiguredModel,
14
14
  defaultPrReviewProviderBaseUrl,
15
15
  getCapabilityPack,
16
+ workspaceCustomModelReference,
16
17
  prReviewWebhookAuthKind,
17
18
  normalizePrReviewProviderBaseUrl,
18
19
  prReviewPackConnectorId,
19
20
  PR_REVIEW_AUTOMATION_TEMPLATE_ID,
20
21
  requireAccessGrant,
21
22
  requirePermission,
23
+ resolveWorkspaceCatalogSettings,
22
24
  type ApiRouteDeps,
23
25
  } from "@opengeni/core";
24
26
  import {
@@ -32,6 +34,8 @@ import {
32
34
  getPackInstallation,
33
35
  listPrReviewAppRegistrations,
34
36
  listPrReviewRepositoryBindings,
37
+ lockActiveWorkspaceGatewayCustomModelForAdmission,
38
+ lockActiveWorkspaceOpenRouterCustomModelForAdmission,
35
39
  nestedPostgresSqlState,
36
40
  recordAuditEvent,
37
41
  updatePrReviewAppRegistration,
@@ -385,10 +389,24 @@ export function registerPrReviewRoutes(app: Hono, deps: ApiRouteDeps): void {
385
389
  throw error;
386
390
  }
387
391
  }
392
+ const catalogSettings = (
393
+ await resolveWorkspaceCatalogSettings(db, deps.settings, {
394
+ accountId: grant.accountId,
395
+ workspaceId,
396
+ })
397
+ ).settings;
388
398
  const model = payload.model
389
- ? (canonicalConfiguredModel(settings, payload.model) ?? null)
399
+ ? (canonicalConfiguredModel(catalogSettings, payload.model) ?? null)
390
400
  : null;
391
- if (model) await assertWorkspaceModelPolicyAllows(db, settings, workspaceId, model);
401
+ if (model) await assertWorkspaceModelPolicyAllows(db, catalogSettings, workspaceId, model);
402
+ const beforeCreateCommit = model
403
+ ? prReviewCustomModelCommitGuard({
404
+ settings: catalogSettings,
405
+ accountId: grant.accountId,
406
+ workspaceId,
407
+ modelId: model,
408
+ })
409
+ : undefined;
392
410
  const template = getCapabilityPack(OPENGENI_PR_REVIEW_PACK_ID)?.automationTemplates?.find(
393
411
  (candidate) => candidate.id === PR_REVIEW_AUTOMATION_TEMPLATE_ID,
394
412
  );
@@ -417,6 +435,7 @@ export function registerPrReviewRoutes(app: Hono, deps: ApiRouteDeps): void {
417
435
  eventTypes: template.eventTypes,
418
436
  configuration: template.configuration,
419
437
  sessionTemplate: template.sessionTemplate,
438
+ ...(beforeCreateCommit ? { beforeCreateCommit } : {}),
420
439
  }),
421
440
  "This repository is already bound to the selected PR Review registration",
422
441
  );
@@ -441,13 +460,40 @@ export function registerPrReviewRoutes(app: Hono, deps: ApiRouteDeps): void {
441
460
  const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
442
461
  await requirePrReviewPackActive(db, workspaceId);
443
462
  const payload = UpdatePrReviewRepositoryBindingRequest.parse(await c.req.json());
463
+ const catalogSettings = (
464
+ await resolveWorkspaceCatalogSettings(db, deps.settings, {
465
+ accountId: grant.accountId,
466
+ workspaceId,
467
+ })
468
+ ).settings;
444
469
  const model =
445
470
  payload.model === undefined
446
471
  ? undefined
447
472
  : payload.model === null
448
473
  ? null
449
- : (canonicalConfiguredModel(settings, payload.model) ?? null);
450
- if (model) await assertWorkspaceModelPolicyAllows(db, settings, workspaceId, model);
474
+ : (canonicalConfiguredModel(catalogSettings, payload.model) ?? null);
475
+ if (model) await assertWorkspaceModelPolicyAllows(db, catalogSettings, workspaceId, model);
476
+ const beforeUpdateCommit = async (
477
+ tx: ApiRouteDeps["db"],
478
+ context: {
479
+ currentModel: string | null;
480
+ currentStatus: "active" | "disabled";
481
+ nextModel: string | null;
482
+ nextStatus: "active" | "disabled";
483
+ },
484
+ ): Promise<void> => {
485
+ const materialExecutionChange =
486
+ payload.model !== undefined ||
487
+ payload.additionalInstructions !== undefined ||
488
+ (context.currentStatus === "disabled" && context.nextStatus === "active");
489
+ if (!materialExecutionChange || !context.nextModel) return;
490
+ await prReviewCustomModelCommitGuard({
491
+ settings: catalogSettings,
492
+ accountId: grant.accountId,
493
+ workspaceId,
494
+ modelId: context.nextModel,
495
+ })?.(tx);
496
+ };
451
497
  const binding = await updatePrReviewRepositoryBinding(db, {
452
498
  accountId: grant.accountId,
453
499
  workspaceId,
@@ -458,6 +504,7 @@ export function registerPrReviewRoutes(app: Hono, deps: ApiRouteDeps): void {
458
504
  ? { additionalInstructions: payload.additionalInstructions }
459
505
  : {}),
460
506
  ...(payload.status !== undefined ? { status: payload.status } : {}),
507
+ beforeUpdateCommit,
461
508
  });
462
509
  if (!binding)
463
510
  throw new HTTPException(404, {
@@ -501,6 +548,35 @@ export function registerPrReviewRoutes(app: Hono, deps: ApiRouteDeps): void {
501
548
  });
502
549
  }
503
550
 
551
+ function prReviewCustomModelCommitGuard(input: {
552
+ settings: ApiRouteDeps["settings"];
553
+ accountId: string;
554
+ workspaceId: string;
555
+ modelId: string;
556
+ }): ((tx: ApiRouteDeps["db"]) => Promise<void>) | undefined {
557
+ const reference = workspaceCustomModelReference(input.settings, input.modelId);
558
+ if (!reference) return undefined;
559
+ return async (tx): Promise<void> => {
560
+ const active =
561
+ reference.providerKind === "openrouter"
562
+ ? await lockActiveWorkspaceOpenRouterCustomModelForAdmission(tx, {
563
+ accountId: input.accountId,
564
+ workspaceId: input.workspaceId,
565
+ upstreamModelId: reference.upstreamModelId,
566
+ })
567
+ : await lockActiveWorkspaceGatewayCustomModelForAdmission(tx, {
568
+ accountId: input.accountId,
569
+ workspaceId: input.workspaceId,
570
+ upstreamModelId: reference.upstreamModelId,
571
+ });
572
+ if (!active) {
573
+ throw new HTTPException(422, {
574
+ message: `model is not available: ${input.modelId}`,
575
+ });
576
+ }
577
+ };
578
+ }
579
+
504
580
  function assertPrReviewSandboxBackend(deps: ApiRouteDeps): void {
505
581
  if (deps.settings.sandboxBackend === "selfhosted") {
506
582
  throw new HTTPException(409, {