@opengeni/api-router 0.12.7 → 0.14.3

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 (89) hide show
  1. package/dist/app.d.ts +18 -38
  2. package/dist/app.js +3 -1
  3. package/dist/auth/managed-auth.d.ts +35 -0
  4. package/dist/{chunk-SD5GJTZ4.js → chunk-36PW33ND.js} +3744 -638
  5. package/dist/chunk-36PW33ND.js.map +1 -0
  6. package/dist/codex-redemption-security.d.ts +15 -0
  7. package/dist/github-access.d.ts +12 -0
  8. package/dist/github-browser-flow.d.ts +14 -0
  9. package/dist/http/auth.d.ts +3 -0
  10. package/dist/http/common.d.ts +3 -0
  11. package/dist/http/sse.d.ts +70 -0
  12. package/dist/index.d.ts +11 -13
  13. package/dist/index.js +1 -1
  14. package/dist/integrations/google-drive.d.ts +76 -0
  15. package/dist/integrations/oauth-client.d.ts +42 -0
  16. package/dist/integrations/provider-domain.d.ts +7 -0
  17. package/dist/integrations/slack-bot.d.ts +343 -0
  18. package/dist/mcp/documents.d.ts +8 -0
  19. package/dist/mcp/files.d.ts +5 -0
  20. package/dist/mcp/server.d.ts +107 -0
  21. package/dist/mcp/session-view.d.ts +191 -0
  22. package/dist/mcp/toolspace.d.ts +61 -0
  23. package/dist/model-catalog.d.ts +39 -0
  24. package/dist/observability.d.ts +3 -0
  25. package/dist/routes/api-keys.d.ts +3 -0
  26. package/dist/routes/billing.d.ts +16 -0
  27. package/dist/routes/capabilities.d.ts +3 -0
  28. package/dist/routes/catalog-assets.d.ts +5 -0
  29. package/dist/routes/codex.d.ts +10 -0
  30. package/dist/routes/connections.d.ts +3 -0
  31. package/dist/routes/documents.d.ts +3 -0
  32. package/dist/routes/enrollments.d.ts +3 -0
  33. package/dist/routes/environments.d.ts +5 -0
  34. package/dist/routes/files.d.ts +4 -0
  35. package/dist/routes/github.d.ts +3 -0
  36. package/dist/routes/insights.d.ts +3 -0
  37. package/dist/routes/install.d.ts +5 -0
  38. package/dist/routes/machines.d.ts +3 -0
  39. package/dist/routes/packs.d.ts +3 -0
  40. package/dist/routes/preference-registry.d.ts +5 -0
  41. package/dist/routes/rigs.d.ts +3 -0
  42. package/dist/routes/scheduled-tasks.d.ts +3 -0
  43. package/dist/routes/sessions.d.ts +15 -0
  44. package/dist/routes/social.d.ts +3 -0
  45. package/dist/routes/transcriptions.d.ts +3 -0
  46. package/dist/routes/workspace-capture.d.ts +59 -0
  47. package/dist/routes/workspace-instruction-policies.d.ts +3 -0
  48. package/dist/routes/workspace-state.d.ts +3 -0
  49. package/dist/routes/workspaces.d.ts +5 -0
  50. package/dist/sandbox/access.d.ts +21 -0
  51. package/dist/sandbox/auth-callout.d.ts +30 -0
  52. package/dist/sandbox/channel-a.d.ts +37 -0
  53. package/dist/sandbox/enrollment.d.ts +120 -0
  54. package/dist/sandbox/machines.d.ts +29 -0
  55. package/dist/sandbox/metrics-ingestion.d.ts +128 -0
  56. package/dist/sandbox/rematerialize.d.ts +24 -0
  57. package/dist/sandbox/viewer.d.ts +251 -0
  58. package/dist/transcription/providers/azure-openai.d.ts +9 -0
  59. package/dist/transcription/providers/codex-subscription.d.ts +9 -0
  60. package/dist/transcription/providers/openai.d.ts +9 -0
  61. package/dist/transcription/service.d.ts +10 -0
  62. package/dist/workspace-state-projection.d.ts +16 -0
  63. package/package.json +13 -13
  64. package/src/app.ts +75 -5
  65. package/src/github-access.ts +1 -0
  66. package/src/integrations/google-drive.ts +869 -0
  67. package/src/integrations/oauth-client.ts +41 -6
  68. package/src/integrations/slack-bot.ts +756 -26
  69. package/src/mcp/server.ts +226 -42
  70. package/src/mcp/session-view.ts +1 -0
  71. package/src/mcp/toolspace.ts +17 -9
  72. package/src/routes/connections.ts +137 -7
  73. package/src/routes/documents.ts +13 -2
  74. package/src/routes/github.ts +327 -20
  75. package/src/routes/insights.ts +30 -0
  76. package/src/routes/preference-registry.ts +482 -0
  77. package/src/routes/sessions.ts +22 -23
  78. package/src/routes/transcriptions.ts +155 -0
  79. package/src/routes/workspace-capture.ts +108 -8
  80. package/src/routes/workspace-state.ts +61 -0
  81. package/src/sandbox/channel-a.ts +2 -0
  82. package/src/sandbox/rematerialize.ts +111 -4
  83. package/src/sandbox/viewer.ts +29 -12
  84. package/src/transcription/providers/azure-openai.ts +45 -0
  85. package/src/transcription/providers/codex-subscription.ts +100 -0
  86. package/src/transcription/providers/openai.ts +93 -0
  87. package/src/transcription/service.ts +121 -0
  88. package/src/workspace-state-projection.ts +244 -0
  89. package/dist/chunk-SD5GJTZ4.js.map +0 -1
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  GitHubAppManifestCreate,
3
3
  type AccessGrant,
4
+ type GitHubInstallationBindingCandidate,
4
5
  type GitHubInstallationBindingProof,
5
6
  } from "@opengeni/contracts";
6
7
  import {
@@ -13,6 +14,7 @@ import {
13
14
  buildGitHubAppManifest,
14
15
  convertGitHubAppManifest,
15
16
  createSignedState,
17
+ discoverGitHubInstallationBindingCandidates,
16
18
  envLinesFromGitHubManifestConversion,
17
19
  GitHubAppApiError,
18
20
  GitHubAppConfigurationError,
@@ -47,7 +49,6 @@ const githubStateCookie = "opengeni_github_state";
47
49
  const githubBindingStateMaxAgeSeconds = 10 * 60;
48
50
  const legacyInstallationChooserDisabledMessage =
49
51
  "The legacy repository-admin GitHub installation chooser is disabled; use the GitHub owner-consent connect flow";
50
-
51
52
  export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
52
53
  const { db, settings, githubStateSecret } = deps;
53
54
 
@@ -61,6 +62,7 @@ export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
61
62
  ? await listWorkspaceGitHubInstallationBindings(deps, grant.workspaceId)
62
63
  : [];
63
64
  const status = githubBindingStatus(missing.length === 0, installations);
65
+ const setupMode = settings.productAccessMode === "managed" ? "platform" : "operator";
64
66
  const canManage = hasPermission(grant.permissions, "github:manage");
65
67
  const connectState =
66
68
  missing.length === 0 && slug && canManage
@@ -74,23 +76,29 @@ export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
74
76
  const connectUrl = connectState
75
77
  ? `${openGeniBaseUrl(settings, c)}/v1/workspaces/${grant.workspaceId}/github/connect?state=${encodeURIComponent(connectState)}`
76
78
  : null;
79
+ const installationViews = installations.map((installation) => ({
80
+ ...installation,
81
+ configureUrl: connectState
82
+ ? `${openGeniBaseUrl(settings, c)}/v1/workspaces/${grant.workspaceId}/github/installations/${installation.installationId}/configure?state=${encodeURIComponent(connectState)}`
83
+ : null,
84
+ }));
77
85
  return c.json({
78
86
  configured: missing.length === 0,
79
87
  status,
80
- appId: settings.githubAppId ?? null,
81
- clientId: settings.githubClientId ?? null,
82
- appSlug: slug,
88
+ setupMode,
89
+ appId: setupMode === "operator" ? (settings.githubAppId ?? null) : null,
90
+ clientId: setupMode === "operator" ? (settings.githubClientId ?? null) : null,
91
+ appSlug: setupMode === "operator" ? slug : null,
83
92
  installUrl: connectUrl,
84
93
  linkUrl: connectUrl,
85
- installations,
86
- missing,
94
+ installations: installationViews,
95
+ missing: setupMode === "operator" ? missing : [],
87
96
  });
88
97
  });
89
98
 
90
- // The signed state is a short browser handoff minted only for an OpenGeni
91
- // github:manage grant. GitHub remains responsible for installation/config
92
- // consent; the later OAuth callback independently proves current owner
93
- // authority before any workspace binding write.
99
+ // Start with user authorization, not GitHub's install/configure selector.
100
+ // This lets an owner link an already-installed App without relying on
101
+ // GitHub's Configure page to preserve or return OpenGeni state.
94
102
  app.get("/v1/workspaces/:workspaceId/github/connect", async (c) => {
95
103
  const workspaceId = c.req.param("workspaceId");
96
104
  const state = c.req.query("state");
@@ -107,8 +115,8 @@ export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
107
115
  ) {
108
116
  throw new HTTPException(400, { message: "invalid or expired GitHub installation state" });
109
117
  }
110
- const slug = settings.githubAppSlug?.trim();
111
- if (!slug || githubAppMissingSettings(settings).length > 0) {
118
+ const clientId = settings.githubClientId?.trim();
119
+ if (!clientId || githubAppMissingSettings(settings).length > 0) {
112
120
  throw new HTTPException(409, {
113
121
  message: JSON.stringify({
114
122
  message: "GitHub App is not configured",
@@ -116,9 +124,19 @@ export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
116
124
  }),
117
125
  });
118
126
  }
119
- setGitHubStateCookie(c, deps, state);
127
+ const discoveryState = createSignedState(githubStateSecret, {
128
+ accountId: statePayload.accountId,
129
+ workspaceId,
130
+ intent: "installation_authority_discovery",
131
+ ...continuedGitHubBrowserGrantClaims(statePayload),
132
+ });
133
+ setGitHubStateCookie(c, deps, discoveryState);
120
134
  return c.redirect(
121
- `https://github.com/apps/${slug}/installations/new?state=${encodeURIComponent(state)}`,
135
+ githubOAuthAuthorizeUrl({
136
+ clientId,
137
+ state: discoveryState,
138
+ redirectUri: `${openGeniBaseUrl(settings, c)}/v1/github/oauth/callback`,
139
+ }),
122
140
  );
123
141
  });
124
142
 
@@ -174,7 +192,55 @@ export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
174
192
  return c.body(null, 204);
175
193
  });
176
194
 
195
+ app.get(
196
+ "/v1/workspaces/:workspaceId/github/installations/:installationId/configure",
197
+ async (c) => {
198
+ const workspaceId = c.req.param("workspaceId");
199
+ const installationId = parsePositiveInteger(c.req.param("installationId"));
200
+ const state = c.req.query("state");
201
+ if (installationId === null || !state) {
202
+ throw new HTTPException(400, { message: "invalid GitHub installation configuration" });
203
+ }
204
+ const statePayload = readSignedState(state, githubStateSecret);
205
+ if (
206
+ !statePayload ||
207
+ statePayload.intent !== "installation_authority" ||
208
+ statePayload.workspaceId !== workspaceId ||
209
+ typeof statePayload.accountId !== "string" ||
210
+ !isFreshGitHubBindingState(statePayload)
211
+ ) {
212
+ throw new HTTPException(400, {
213
+ message: "invalid or expired GitHub installation configuration state",
214
+ });
215
+ }
216
+ const grant = await requireGitHubManageGrant(c, deps, workspaceId, statePayload);
217
+ if (grant.accountId !== statePayload.accountId) {
218
+ throw new HTTPException(403, {
219
+ message: "GitHub installation state does not match this workspace",
220
+ });
221
+ }
222
+ const installation = (
223
+ await listWorkspaceGitHubInstallationBindings(deps, grant.workspaceId)
224
+ ).find((candidate) => candidate.installationId === installationId);
225
+ if (!installation) {
226
+ throw new HTTPException(404, { message: "GitHub installation binding not found" });
227
+ }
228
+ const configureState = createSignedState(githubStateSecret, {
229
+ accountId: grant.accountId,
230
+ workspaceId: grant.workspaceId,
231
+ expectedInstallationId: installationId,
232
+ intent: "installation_authority_install",
233
+ ...continuedGitHubBrowserGrantClaims(statePayload),
234
+ });
235
+ setGitHubStateCookie(c, deps, configureState);
236
+ const configureUrl = githubInstallationSettingsUrl(installation);
237
+ configureUrl.searchParams.set("state", configureState);
238
+ return c.redirect(configureUrl.toString());
239
+ },
240
+ );
241
+
177
242
  app.post("/v1/workspaces/:workspaceId/github/app-manifest", async (c) => {
243
+ assertOperatorGitHubAppSetup(settings);
178
244
  const workspaceId = c.req.param("workspaceId");
179
245
  const grant = await requireAccessGrant(c, deps, workspaceId, "github:manage");
180
246
  const payload = GitHubAppManifestCreate.parse(await c.req.json());
@@ -206,6 +272,7 @@ export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
206
272
  });
207
273
 
208
274
  app.get("/v1/github/app-manifest/callback", async (c) => {
275
+ assertOperatorGitHubAppSetup(settings);
209
276
  const code = c.req.query("code");
210
277
  const state = c.req.query("state");
211
278
  if (!code) {
@@ -226,14 +293,21 @@ export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
226
293
  });
227
294
 
228
295
  const handleGitHubInstallCallback = async (c: Context) => {
229
- const state = c.req.query("state");
296
+ const state =
297
+ c.req.query("state") ??
298
+ allCookieValues(c, githubStateCookie).find((candidate) => {
299
+ const payload = readSignedState(candidate, githubStateSecret);
300
+ return (
301
+ payload?.intent === "installation_authority_install" && isFreshGitHubBindingState(payload)
302
+ );
303
+ });
230
304
  if (!state) {
231
305
  throw new HTTPException(400, { message: "missing GitHub installation state" });
232
306
  }
233
307
  const statePayload = readSignedState(state, githubStateSecret);
234
308
  if (
235
309
  !statePayload ||
236
- statePayload.intent !== "installation_authority" ||
310
+ statePayload.intent !== "installation_authority_install" ||
237
311
  typeof statePayload.accountId !== "string" ||
238
312
  typeof statePayload.workspaceId !== "string" ||
239
313
  !isFreshGitHubBindingState(statePayload)
@@ -258,6 +332,14 @@ export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
258
332
  if (installationId === null) {
259
333
  throw new HTTPException(400, { message: "missing or invalid GitHub installation_id" });
260
334
  }
335
+ if (
336
+ statePayload.expectedInstallationId !== undefined &&
337
+ statePayload.expectedInstallationId !== installationId
338
+ ) {
339
+ throw new HTTPException(409, {
340
+ message: "GitHub returned a different installation than the one being configured",
341
+ });
342
+ }
261
343
  const clientId = settings.githubClientId?.trim();
262
344
  if (!clientId) {
263
345
  throw new HTTPException(409, {
@@ -299,13 +381,71 @@ export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
299
381
  const statePayload = readSignedState(state, githubStateSecret);
300
382
  if (
301
383
  !statePayload ||
302
- statePayload.intent !== "installation_authority_oauth" ||
303
384
  typeof statePayload.accountId !== "string" ||
304
385
  typeof statePayload.workspaceId !== "string" ||
305
386
  !isFreshGitHubBindingState(statePayload)
306
387
  ) {
307
388
  throw new HTTPException(400, { message: "invalid or expired GitHub OAuth state" });
308
389
  }
390
+ if (statePayload.intent === "installation_authority_discovery") {
391
+ requireGitHubStateCookie(c, state);
392
+ const grant = await requireGitHubManageGrant(c, deps, statePayload.workspaceId, statePayload);
393
+ if (grant.accountId !== statePayload.accountId) {
394
+ throw new HTTPException(403, {
395
+ message: "GitHub OAuth state does not match this workspace",
396
+ });
397
+ }
398
+ let candidates: GitHubInstallationBindingCandidate[] | null;
399
+ try {
400
+ candidates = deps.githubAppApi?.discoverInstallationBindingCandidates
401
+ ? await deps.githubAppApi.discoverInstallationBindingCandidates({ code })
402
+ : deps.githubAppApi
403
+ ? null
404
+ : await discoverGitHubInstallationBindingCandidates(settings, { code });
405
+ } catch (error) {
406
+ throw githubAuthorityHttpError(error);
407
+ }
408
+ if (!candidates) {
409
+ throw new HTTPException(409, {
410
+ message: "The configured GitHub provider cannot discover owner-authorized installations",
411
+ });
412
+ }
413
+ if (!isConsistentGitHubBindingCandidates(candidates)) {
414
+ throw new HTTPException(409, {
415
+ message: "GitHub installation discovery proof is stale or invalid",
416
+ });
417
+ }
418
+ const selectionState = createSignedState(githubStateSecret, {
419
+ accountId: grant.accountId,
420
+ workspaceId: grant.workspaceId,
421
+ intent: "installation_authority_selection",
422
+ allowedInstallationIds: candidates.map(({ installation }) => installation.installationId),
423
+ ...continuedGitHubBrowserGrantClaims(statePayload),
424
+ });
425
+ if (candidates.length === 0) {
426
+ return redirectToGitHubInstallation(c, deps, selectionState);
427
+ }
428
+ if (candidates.length === 1) {
429
+ return redirectToExactGitHubAuthorization(
430
+ c,
431
+ deps,
432
+ selectionState,
433
+ candidates[0]!.installation.installationId,
434
+ );
435
+ }
436
+ setGitHubStateCookie(c, deps, selectionState);
437
+ return c.html(
438
+ githubInstallationChooserHtml(
439
+ candidates,
440
+ selectionState,
441
+ grant.workspaceId,
442
+ openGeniBaseUrl(settings, c),
443
+ ),
444
+ );
445
+ }
446
+ if (statePayload.intent !== "installation_authority_oauth") {
447
+ throw new HTTPException(400, { message: "invalid or expired GitHub OAuth state" });
448
+ }
309
449
  const installationId = parsePositiveInteger(String(statePayload.installationId ?? ""));
310
450
  if (installationId === null) {
311
451
  throw new HTTPException(400, { message: "invalid GitHub installation id" });
@@ -382,6 +522,54 @@ export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
382
522
  );
383
523
  });
384
524
 
525
+ // This is browser navigation, like /github/connect and the OAuth callbacks.
526
+ // Keep it GET: a native HTML form cannot attach the API contract header that
527
+ // protects product mutations, and no durable binding is written here.
528
+ app.get("/v1/workspaces/:workspaceId/github/installations/select", async (c) => {
529
+ const workspaceId = c.req.param("workspaceId");
530
+ const state = c.req.query("state");
531
+ if (!state) {
532
+ throw new HTTPException(400, { message: "missing GitHub installation selection state" });
533
+ }
534
+ const statePayload = readSignedState(state, githubStateSecret);
535
+ if (
536
+ !statePayload ||
537
+ statePayload.intent !== "installation_authority_selection" ||
538
+ typeof statePayload.accountId !== "string" ||
539
+ statePayload.accountId.length === 0 ||
540
+ statePayload.workspaceId !== workspaceId ||
541
+ !isFreshGitHubBindingState(statePayload)
542
+ ) {
543
+ throw new HTTPException(400, {
544
+ message: "invalid or expired GitHub installation selection state",
545
+ });
546
+ }
547
+ requireGitHubStateCookie(c, state);
548
+ const grant = await requireGitHubManageGrant(c, deps, workspaceId, statePayload);
549
+ if (grant.accountId !== statePayload.accountId) {
550
+ throw new HTTPException(403, {
551
+ message: "GitHub installation state does not match this workspace",
552
+ });
553
+ }
554
+ const selected = c.req.query("installation_id");
555
+ if (selected === "new") {
556
+ return redirectToGitHubInstallation(c, deps, state);
557
+ }
558
+ const installationId = parsePositiveInteger(selected);
559
+ if (installationId === null) {
560
+ throw new HTTPException(400, { message: "invalid GitHub installation selection" });
561
+ }
562
+ if (
563
+ !Array.isArray(statePayload.allowedInstallationIds) ||
564
+ !statePayload.allowedInstallationIds.includes(installationId)
565
+ ) {
566
+ throw new HTTPException(403, {
567
+ message: "GitHub installation was not in the owner-authorized selection",
568
+ });
569
+ }
570
+ return redirectToExactGitHubAuthorization(c, deps, state, installationId);
571
+ });
572
+
385
573
  app.post("/v1/workspaces/:workspaceId/github/installations", async (c) => {
386
574
  const workspaceId = c.req.param("workspaceId");
387
575
  const form = new URLSearchParams(await c.req.text());
@@ -398,12 +586,89 @@ export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
398
586
  ) {
399
587
  throw new HTTPException(400, { message: "invalid or expired GitHub OAuth state" });
400
588
  }
401
- throw legacyInstallationChooserDisabled();
589
+ throw new HTTPException(410, { message: legacyInstallationChooserDisabledMessage });
402
590
  });
403
591
  }
404
592
 
405
- function legacyInstallationChooserDisabled(): HTTPException {
406
- return new HTTPException(410, { message: legacyInstallationChooserDisabledMessage });
593
+ function assertOperatorGitHubAppSetup(settings: ApiRouteDeps["settings"]): void {
594
+ if (settings.productAccessMode === "managed") {
595
+ throw new HTTPException(404, {
596
+ message: "GitHub App creation is unavailable in platform-managed deployments",
597
+ });
598
+ }
599
+ }
600
+
601
+ function redirectToGitHubInstallation(
602
+ c: Context,
603
+ deps: ApiRouteDeps,
604
+ sourceState: string,
605
+ ): Response {
606
+ const payload = readSignedState(sourceState, deps.githubStateSecret);
607
+ const slug = deps.settings.githubAppSlug?.trim();
608
+ if (
609
+ !payload ||
610
+ typeof payload.accountId !== "string" ||
611
+ typeof payload.workspaceId !== "string" ||
612
+ !slug
613
+ ) {
614
+ throw new HTTPException(409, { message: "GitHub App installation is unavailable" });
615
+ }
616
+ const installState = createSignedState(deps.githubStateSecret, {
617
+ accountId: payload.accountId,
618
+ workspaceId: payload.workspaceId,
619
+ intent: "installation_authority_install",
620
+ ...continuedGitHubBrowserGrantClaims(payload),
621
+ });
622
+ setGitHubStateCookie(c, deps, installState);
623
+ return c.redirect(
624
+ `https://github.com/apps/${encodeURIComponent(slug)}/installations/new?state=${encodeURIComponent(installState)}`,
625
+ );
626
+ }
627
+
628
+ function githubInstallationSettingsUrl(installation: {
629
+ installationId: number;
630
+ accountLogin: string | null;
631
+ accountType: string | null;
632
+ }): URL {
633
+ if (installation.accountType === "Organization" && installation.accountLogin) {
634
+ return new URL(
635
+ `https://github.com/organizations/${encodeURIComponent(installation.accountLogin)}/settings/installations/${installation.installationId}`,
636
+ );
637
+ }
638
+ return new URL(`https://github.com/settings/installations/${installation.installationId}`);
639
+ }
640
+
641
+ function redirectToExactGitHubAuthorization(
642
+ c: Context,
643
+ deps: ApiRouteDeps,
644
+ sourceState: string,
645
+ installationId: number,
646
+ ): Response {
647
+ const payload = readSignedState(sourceState, deps.githubStateSecret);
648
+ const clientId = deps.settings.githubClientId?.trim();
649
+ if (
650
+ !payload ||
651
+ typeof payload.accountId !== "string" ||
652
+ typeof payload.workspaceId !== "string" ||
653
+ !clientId
654
+ ) {
655
+ throw new HTTPException(409, { message: "GitHub user authorization is unavailable" });
656
+ }
657
+ const oauthState = createSignedState(deps.githubStateSecret, {
658
+ accountId: payload.accountId,
659
+ workspaceId: payload.workspaceId,
660
+ installationId,
661
+ intent: "installation_authority_oauth",
662
+ ...continuedGitHubBrowserGrantClaims(payload),
663
+ });
664
+ setGitHubStateCookie(c, deps, oauthState);
665
+ return c.redirect(
666
+ githubOAuthAuthorizeUrl({
667
+ clientId,
668
+ state: oauthState,
669
+ redirectUri: `${openGeniBaseUrl(deps.settings, c)}/v1/github/oauth/callback`,
670
+ }),
671
+ );
407
672
  }
408
673
 
409
674
  function setGitHubStateCookie(c: Context, deps: ApiRouteDeps, state: string): void {
@@ -502,10 +767,52 @@ function githubSetupSuccessHtml(account: string, returnUrl: string): string {
502
767
  return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>GitHub App Connected</title><style>body{font-family:system-ui,sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;background:#0b0b0d;color:#f4f4f5}main{width:min(640px,calc(100vw - 32px));border:1px solid #27272a;border-radius:8px;padding:28px;background:#111114}h1{margin:0 0 10px;font-size:24px;line-height:1.2}p{margin:0 0 18px;color:#d4d4d8}.button{display:inline-flex;align-items:center;justify-content:center;min-height:36px;border-radius:6px;border:1px solid #3f3f46;padding:0 12px;background:#f4f4f5;color:#09090b;font:600 14px system-ui,sans-serif;text-decoration:none}</style></head><body><main><h1>GitHub App connected</h1><p>${escapeHtml(account)} is now available to this OpenGeni workspace through an explicit repository allowlist.</p><a class="button" href="${escapeHtml(returnUrl)}">Back to OpenGeni</a></main></body></html>`;
503
768
  }
504
769
 
770
+ function githubInstallationChooserHtml(
771
+ candidates: GitHubInstallationBindingCandidate[],
772
+ state: string,
773
+ workspaceId: string,
774
+ baseUrl: string,
775
+ ): string {
776
+ const action = `${baseUrl}/v1/workspaces/${encodeURIComponent(workspaceId)}/github/installations/select`;
777
+ const options = candidates
778
+ .map(({ installation, authorityKind }) => {
779
+ const account = escapeHtml(
780
+ installation.accountLogin ?? `installation ${installation.installationId}`,
781
+ );
782
+ const label = authorityKind === "personal_owner" ? "Personal account" : "Organization owner";
783
+ return `<label class="option"><input type="radio" name="installation_id" value="${installation.installationId}" required><span><strong>${account}</strong><small>${label}</small></span></label>`;
784
+ })
785
+ .join("");
786
+ return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Choose GitHub installation</title><style>body{font-family:system-ui,sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;background:#0b0b0d;color:#f4f4f5}main{width:min(640px,calc(100vw - 32px));border:1px solid #27272a;border-radius:12px;padding:28px;background:#111114}h1{margin:0 0 10px;font-size:24px}p{margin:0 0 18px;color:#d4d4d8}.options{display:grid;gap:8px;margin-bottom:18px}.option{display:flex;align-items:center;gap:12px;border:1px solid #3f3f46;border-radius:8px;padding:12px;cursor:pointer}.option span{display:grid;gap:2px}.option small{color:#a1a1aa}button{min-height:38px;border-radius:7px;border:1px solid #3f3f46;padding:0 14px;background:#f4f4f5;color:#09090b;font:600 14px system-ui,sans-serif;cursor:pointer}.secondary{margin-left:8px;background:transparent;color:#f4f4f5}</style></head><body><main><h1>Choose a GitHub account</h1><p>Only installations where GitHub proved you are the personal owner or an active organization owner are shown.</p><form method="get" action="${escapeHtml(action)}"><input type="hidden" name="state" value="${escapeHtml(state)}"><div class="options">${options}</div><button type="submit">Connect selected</button><button class="secondary" type="submit" name="installation_id" value="new" formnovalidate>Install on another account</button></form></main></body></html>`;
787
+ }
788
+
505
789
  function githubSetupPendingHtml(): string {
506
790
  return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>GitHub App Requested</title><style>body{font-family:system-ui,sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;background:#0b0b0d;color:#f4f4f5}main{width:min(640px,calc(100vw - 32px));border:1px solid #27272a;border-radius:8px;padding:28px;background:#111114}h1{margin:0 0 10px;font-size:24px;line-height:1.2}p{margin:0;color:#d4d4d8}</style></head><body><main><h1>GitHub App request sent</h1><p>A GitHub organization owner must approve the installation. OpenGeni has not created a workspace binding.</p></main></body></html>`;
507
791
  }
508
792
 
793
+ function isConsistentGitHubBindingCandidates(
794
+ candidates: GitHubInstallationBindingCandidate[],
795
+ ): boolean {
796
+ const ids = new Set<number>();
797
+ return candidates.every(({ installation, authorityKind }) => {
798
+ if (
799
+ !Number.isSafeInteger(installation.installationId) ||
800
+ installation.installationId <= 0 ||
801
+ !Number.isSafeInteger(installation.accountId) ||
802
+ installation.accountId <= 0 ||
803
+ !installation.accountLogin?.trim() ||
804
+ installation.suspended ||
805
+ ids.has(installation.installationId)
806
+ ) {
807
+ return false;
808
+ }
809
+ ids.add(installation.installationId);
810
+ return authorityKind === "personal_owner"
811
+ ? installation.accountType === "User"
812
+ : installation.accountType === "Organization";
813
+ });
814
+ }
815
+
509
816
  function parsePositiveInteger(value: string | undefined | null): number | null {
510
817
  if (!value || !/^\d+$/.test(value)) {
511
818
  return null;
@@ -0,0 +1,30 @@
1
+ import { InsightsRange, WorkspaceInsightsResponse } from "@opengeni/contracts";
2
+ import { getWorkspaceInsights, requireAccessGrant, type ApiRouteDeps } from "@opengeni/core";
3
+ import type { Hono } from "hono";
4
+ import { HTTPException } from "hono/http-exception";
5
+
6
+ export function registerInsightsRoutes(app: Hono, deps: ApiRouteDeps): void {
7
+ app.get("/v1/workspaces/:workspaceId/insights", async (c) => {
8
+ const workspaceId = c.req.param("workspaceId");
9
+ await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
10
+
11
+ const rangeRaw = c.req.query("range") ?? "week";
12
+ const rangeParsed = InsightsRange.safeParse(rangeRaw);
13
+ if (!rangeParsed.success) {
14
+ throw new HTTPException(400, {
15
+ message: "range must be one of today|week|month|ytd",
16
+ });
17
+ }
18
+ const provider = c.req.query("provider");
19
+ const model = c.req.query("model");
20
+
21
+ const response = await getWorkspaceInsights(deps.db, deps.settings, {
22
+ workspaceId,
23
+ range: rangeParsed.data,
24
+ provider: provider && provider !== "all" ? provider : null,
25
+ model: model && model !== "all" ? model : null,
26
+ });
27
+ c.header("cache-control", "private, no-store");
28
+ return c.json(WorkspaceInsightsResponse.parse(response));
29
+ });
30
+ }