@opengeni/api-router 4.1.0 → 4.1.2-canary.36199476632001

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 (93) hide show
  1. package/dist/app.d.ts +7 -1
  2. package/dist/app.js +1 -1
  3. package/dist/auth/managed-auth-rate-limits.d.ts +167 -0
  4. package/dist/auth/managed-auth.d.ts +37 -1
  5. package/dist/auth/signup-funnel-metrics.d.ts +44 -0
  6. package/dist/{chunk-24C5LPNE.js → chunk-QDIBJSM4.js} +3277 -1509
  7. package/dist/{chunk-24C5LPNE.js.map → chunk-QDIBJSM4.js.map} +1 -1
  8. package/dist/http/cors.d.ts +12 -0
  9. package/dist/http/local-browser-boundary.d.ts +72 -0
  10. package/dist/http/metrics-listener.d.ts +22 -0
  11. package/dist/http/registered-route-label.d.ts +16 -0
  12. package/dist/http/request-source.d.ts +41 -3
  13. package/dist/http/user-content.d.ts +36 -0
  14. package/dist/http/websocket-upgrade-dispatch.d.ts +18 -0
  15. package/dist/index.js +90 -31
  16. package/dist/index.js.map +1 -1
  17. package/dist/integrations/github-lens-connect.d.ts +1 -1
  18. package/dist/integrations/oauth-client.d.ts +30 -0
  19. package/dist/integrations/slack-bot.d.ts +48 -2
  20. package/dist/integrations/slack-interactions.d.ts +1 -1
  21. package/dist/mcp/documents.d.ts +2 -0
  22. package/dist/mcp/first-party-tool-permissions.d.ts +12 -0
  23. package/dist/mcp/server.d.ts +35 -0
  24. package/dist/model-catalog.d.ts +9 -3
  25. package/dist/observability.d.ts +3 -1
  26. package/dist/routes/billing.d.ts +27 -0
  27. package/dist/routes/browser-sessions.d.ts +3 -0
  28. package/dist/routes/client-errors.d.ts +42 -0
  29. package/dist/routes/computer-sessions.d.ts +2 -0
  30. package/dist/routes/github-browser-pages.d.ts +12 -0
  31. package/dist/routes/insights.d.ts +26 -0
  32. package/dist/sandbox/auth-callout.d.ts +8 -0
  33. package/dist/sandbox-file-artifacts.d.ts +17 -1
  34. package/package.json +19 -19
  35. package/src/app.ts +124 -12
  36. package/src/auth/managed-auth-rate-limits.ts +252 -0
  37. package/src/auth/managed-auth.ts +137 -17
  38. package/src/auth/signup-funnel-metrics.ts +164 -0
  39. package/src/http/auth.ts +6 -0
  40. package/src/http/cors.ts +37 -5
  41. package/src/http/local-browser-boundary.ts +343 -0
  42. package/src/http/metrics-listener.ts +57 -0
  43. package/src/http/registered-route-label.ts +34 -0
  44. package/src/http/request-source.ts +154 -13
  45. package/src/http/sse.ts +36 -16
  46. package/src/http/user-content.ts +158 -0
  47. package/src/http/websocket-upgrade-dispatch.ts +29 -0
  48. package/src/index.ts +22 -8
  49. package/src/integrations/atlassian.ts +7 -3
  50. package/src/integrations/fiken.ts +9 -3
  51. package/src/integrations/github-app-connect.ts +12 -2
  52. package/src/integrations/google-drive.ts +7 -3
  53. package/src/integrations/oauth-client.ts +99 -12
  54. package/src/integrations/personal-github.ts +4 -2
  55. package/src/integrations/provider-oauth.ts +10 -3
  56. package/src/integrations/slack-bot.ts +43 -1
  57. package/src/integrations/social-oauth.ts +7 -2
  58. package/src/mcp/documents.ts +9 -2
  59. package/src/mcp/files.ts +5 -1
  60. package/src/mcp/first-party-tool-permissions.ts +6 -0
  61. package/src/mcp/knowledge-entries.ts +15 -2
  62. package/src/mcp/server.ts +191 -17
  63. package/src/mcp-oauth.ts +4 -6
  64. package/src/model-catalog.ts +30 -3
  65. package/src/observability.ts +15 -1
  66. package/src/routes/billing.ts +70 -9
  67. package/src/routes/browser-sessions.ts +169 -0
  68. package/src/routes/client-errors.ts +182 -0
  69. package/src/routes/company-brain.ts +4 -0
  70. package/src/routes/computer-sessions.ts +37 -9
  71. package/src/routes/connections.ts +7 -1
  72. package/src/routes/documents.ts +6 -1
  73. package/src/routes/editable-artifacts.ts +2 -1
  74. package/src/routes/enrollments.ts +2 -13
  75. package/src/routes/files.ts +17 -3
  76. package/src/routes/github-browser-pages.ts +162 -0
  77. package/src/routes/github.ts +122 -49
  78. package/src/routes/insights.ts +70 -6
  79. package/src/routes/knowledge.ts +24 -1
  80. package/src/routes/managed-auth-session-sets.ts +26 -6
  81. package/src/routes/managed-onboarding.ts +23 -19
  82. package/src/routes/organization-memberships.ts +3 -0
  83. package/src/routes/scheduled-tasks.ts +9 -2
  84. package/src/routes/sessions.ts +89 -0
  85. package/src/routes/workspace-artifacts.ts +9 -0
  86. package/src/routes/workspace-state.ts +4 -0
  87. package/src/routes/workspaces.ts +38 -15
  88. package/src/sandbox/auth-callout.ts +62 -11
  89. package/src/sandbox/channel-a.ts +8 -0
  90. package/src/sandbox/viewer.ts +16 -3
  91. package/src/sandbox-file-artifacts.ts +37 -26
  92. package/src/site-uploads.ts +8 -1
  93. package/src/workspace-tool-gateway.ts +1 -0
@@ -1,5 +1,9 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { resolveFirstPartyDelegationSecret, resolveStreamTokenSecret } from "@opengeni/config";
2
+ import {
3
+ resolveFirstPartyDelegationSecret,
4
+ resolveStreamTokenSecret,
5
+ sandboxWarmRateMicrosPerSecond,
6
+ } from "@opengeni/config";
3
7
  import {
4
8
  BROWSER_CONTROL_WEBSOCKET_BEARER_PREFIX,
5
9
  BROWSER_CONTROL_PORT,
@@ -32,6 +36,7 @@ import {
32
36
  ComputerSessionNotFoundError,
33
37
  ComputerSessionOperationConflictError,
34
38
  ComputerSessionStateError,
39
+ SandboxPaidComputeAdmissionError,
35
40
  dispatchComputerSessionOperation,
36
41
  failComputerSessionOperation,
37
42
  findComputerSessionControlRecordByOperation,
@@ -112,6 +117,7 @@ import {
112
117
  observeLifecycleResult,
113
118
  } from "../interaction-metrics";
114
119
  import { withChannelA, withChannelARead, type ChannelAOperation } from "../sandbox/channel-a";
120
+ import { USER_CONTENT_SECURITY_HEADERS } from "../http/user-content";
115
121
 
116
122
  type ComputerPlacement = {
117
123
  placement: InteractionPlacement;
@@ -173,6 +179,23 @@ export async function captureModelComputerFrame(
173
179
  return captured;
174
180
  }
175
181
 
182
+ /** One validated screen frame, served as sandboxed, non-embeddable user content. */
183
+ export function computerScreenshotResponse(
184
+ frame: Pick<ComputerControlFrame, "data" | "mediaType" | "metadataHeader">,
185
+ ): Response {
186
+ // Copy exactly the image bytes: a Node Buffer view may share a larger
187
+ // backing allocation, which `.slice().buffer` would expose whole.
188
+ return new Response(Uint8Array.from(frame.data).buffer, {
189
+ status: 200,
190
+ headers: {
191
+ "cache-control": "no-store",
192
+ "content-type": frame.mediaType,
193
+ "x-opengeni-computer-frame": frame.metadataHeader,
194
+ ...USER_CONTENT_SECURITY_HEADERS,
195
+ },
196
+ });
197
+ }
198
+
176
199
  /** Public ComputerSession resource surface. Physical app/window authority stays
177
200
  * in the same placement controller used by BrowserSession; this route owns only
178
201
  * durable authorization, placement fencing, lifecycle receipts, and routing. */
@@ -483,14 +506,7 @@ export function registerComputerSessionRoutes(app: Hono, deps: ApiRouteDeps): vo
483
506
  }
484
507
  },
485
508
  );
486
- return new Response(frame.data.slice().buffer, {
487
- status: 200,
488
- headers: {
489
- "cache-control": "no-store",
490
- "content-type": frame.mediaType,
491
- "x-opengeni-computer-frame": frame.metadataHeader,
492
- },
493
- });
509
+ return computerScreenshotResponse(frame);
494
510
  },
495
511
  );
496
512
 
@@ -1056,6 +1072,12 @@ export function registerComputerSessionRoutes(app: Hono, deps: ApiRouteDeps): vo
1056
1072
  resolved.kind !== "selfhosted" ||
1057
1073
  resolved.sandboxId !== expectedPlacement.sandboxId
1058
1074
  ) {
1075
+ if (operation === "computer.create") {
1076
+ throw new HTTPException(422, {
1077
+ message:
1078
+ "The requested Connected Machine is not this session's current placement. Move the session to that machine before creating an interaction resource.",
1079
+ });
1080
+ }
1059
1081
  return await throwComputerSourcePlacementChanged(
1060
1082
  grant,
1061
1083
  sourceSession.id,
@@ -1420,6 +1442,10 @@ export function registerComputerSessionRoutes(app: Hono, deps: ApiRouteDeps): vo
1420
1442
  holderId: interactionHolderId(computerSessionId),
1421
1443
  subjectId: sourceSession.id,
1422
1444
  backend: placement.lease.backend,
1445
+ warmBilling: {
1446
+ mode: deps.settings.sandboxWarmBillingMode,
1447
+ rateMicrosPerSecond: sandboxWarmRateMicrosPerSecond(deps.settings, placement.lease.backend),
1448
+ },
1423
1449
  os: placement.lease.os,
1424
1450
  image: sandboxRuntime.image,
1425
1451
  rigVersionId: sourceSession.rigVersionId,
@@ -1875,6 +1901,8 @@ function computerRouteError(error: unknown): HTTPException {
1875
1901
  const connectedMachineError = interactionControlApiError(error, "computer");
1876
1902
  if (connectedMachineError) return connectedMachineError;
1877
1903
  if (error instanceof HTTPException) return error;
1904
+ if (error instanceof SandboxPaidComputeAdmissionError)
1905
+ return new HTTPException(402, { message: error.message, cause: error });
1878
1906
  if (error instanceof ComputerSessionNotFoundError) {
1879
1907
  return new HTTPException(404, { message: error.message, cause: error });
1880
1908
  }
@@ -137,6 +137,7 @@ import {
137
137
  import {
138
138
  completeMcpOAuthCallback,
139
139
  integrationBaseUrl,
140
+ oauthStateFailureReturn,
140
141
  startMcpOAuth,
141
142
  } from "../integrations/oauth-client";
142
143
  import {
@@ -590,7 +591,12 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
590
591
  }
591
592
  const reason = slackInstallErrorReason(error);
592
593
  return c.redirect(
593
- slackInstallReturnUrl(baseUrl, state?.returnPath ?? "/integrations", "error", reason),
594
+ slackInstallReturnUrl(
595
+ baseUrl,
596
+ state?.returnPath ?? oauthStateFailureReturn(settings, c.req.query("state")).returnPath,
597
+ "error",
598
+ reason,
599
+ ),
594
600
  302,
595
601
  );
596
602
  }
@@ -61,6 +61,7 @@ import {
61
61
  prepareMcpOAuthWorkspaceToolGateway,
62
62
  } from "../workspace-tool-gateway";
63
63
  import { sanitizeFilename } from "./files";
64
+ import { userContentSignedGetUrlOptions } from "../http/user-content";
64
65
 
65
66
  export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
66
67
  const { db, objectStorage, documentIndexer, getDocumentServices } = deps;
@@ -435,7 +436,10 @@ export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
435
436
  if (file.status !== "ready") {
436
437
  throw new HTTPException(409, { message: `file is ${file.status}` });
437
438
  }
438
- const signed = await objectStorage.createGetUrl({ key: file.objectKey });
439
+ const signed = await objectStorage.createGetUrl({
440
+ key: file.objectKey,
441
+ ...userContentSignedGetUrlOptions(file.contentType, file.filename),
442
+ });
439
443
  await recordAuditEvent(db, {
440
444
  accountId: grant.accountId,
441
445
  workspaceId,
@@ -818,6 +822,7 @@ export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
818
822
  await requireAccessGrantAuthorization(c, deps, workspaceId, "documents:search"),
819
823
  "documents:search",
820
824
  ),
825
+ settings: deps.settings,
821
826
  },
822
827
  );
823
828
  await server.connect(transport);
@@ -30,6 +30,7 @@ import type { Context, Hono } from "hono";
30
30
  import { z } from "zod";
31
31
 
32
32
  import { ApiHttpError } from "../http/api-error";
33
+ import { USER_CONTENT_SECURITY_HEADERS } from "../http/user-content";
33
34
 
34
35
  export const EDITABLE_ARTIFACT_HTTP_REQUEST_MAX_BYTES = 4 * 1024;
35
36
  export const EDITABLE_ARTIFACT_EXPORT_REQUEST_MAX_BYTES = 260 * 1024;
@@ -616,7 +617,7 @@ export function registerEditableArtifactRoutes(
616
617
  "content-length": String(download.byteSize),
617
618
  "content-type": download.mimeType,
618
619
  etag: `"${download.contentHash}"`,
619
- "x-content-type-options": "nosniff",
620
+ ...USER_CONTENT_SECURITY_HEADERS,
620
621
  },
621
622
  });
622
623
  });
@@ -50,6 +50,7 @@ import {
50
50
  } from "@opengeni/db";
51
51
  import type { Context, Hono } from "hono";
52
52
  import { HTTPException } from "hono/http-exception";
53
+ import { trustedRequestSourceRateLimitKey } from "../http/request-source";
53
54
  import {
54
55
  requireAccessGrant,
55
56
  requireAccessGrantAuthorization,
@@ -98,7 +99,7 @@ export function registerEnrollmentRoutes(app: Hono, deps: ApiRouteDeps): void {
98
99
  });
99
100
 
100
101
  function rateLimit(c: Context, limiter: TokenBucket): void {
101
- const ip = clientIp(c);
102
+ const ip = trustedRequestSourceRateLimitKey(c, settings);
102
103
  if (!limiter.take(ip)) {
103
104
  throw new HTTPException(429, { message: "too many requests; slow down" });
104
105
  }
@@ -471,18 +472,6 @@ export function registerEnrollmentRoutes(app: Hono, deps: ApiRouteDeps): void {
471
472
  });
472
473
  }
473
474
 
474
- // The remote client IP for the per-IP rate-limit bucket. Honors the proxy's
475
- // X-Forwarded-For (the first hop) when present, falling back to a constant key when
476
- // neither is available (the bucket then caps the whole edge — still a useful cap).
477
- function clientIp(c: Context): string {
478
- const xff = c.req.header("x-forwarded-for");
479
- if (xff) {
480
- const first = xff.split(",")[0]?.trim();
481
- if (first) return first;
482
- }
483
- return c.req.header("x-real-ip")?.trim() || "unknown";
484
- }
485
-
486
475
  // A minimal per-key token bucket. capacity = burst; refillPerSecond = sustained
487
476
  // rate. Buckets are created lazily and reset their tokens by elapsed time on each
488
477
  // take, so an idle key fully refills without a background timer.
@@ -15,6 +15,7 @@ import {
15
15
  retainedGeneratedImageReferenceFromFile,
16
16
  retainedGeneratedVideoReferenceFromFile,
17
17
  retainedScreenshotReferenceFromFile,
18
+ retainedSessionScreenshotKindFromObjectKey,
18
19
  VideoArtifactPlaybackSource,
19
20
  resolveRetainedOutputRange,
20
21
  type RetainedArtifactMetadata,
@@ -57,6 +58,7 @@ import type { ApiRouteDeps } from "@opengeni/core";
57
58
  import { retryWhileMissing } from "@opengeni/storage";
58
59
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
59
60
  import { buildFilesMcpServer } from "../mcp/files";
61
+ import { userContentResponseHeaders, userContentSignedGetUrlOptions } from "../http/user-content";
60
62
  import { mcpOAuthAuthenticateHeader, resolveMcpOAuthRouteAccess } from "../mcp-oauth";
61
63
  import { withAccessGrantSessionRlsContext } from "../access-grant-rls";
62
64
  import {
@@ -494,7 +496,10 @@ export function registerFileRoutes(app: Hono, deps: ApiRouteDeps): void {
494
496
  if (!videoPresent) {
495
497
  throw new HTTPException(410, { message: "generated video bytes are unavailable" });
496
498
  }
497
- const signed = await objectStorage.createGetUrl({ key: file.objectKey });
499
+ const signed = await objectStorage.createGetUrl({
500
+ key: file.objectKey,
501
+ ...userContentSignedGetUrlOptions(file.contentType, file.filename),
502
+ });
498
503
  await recordAuditEvent(db, {
499
504
  accountId: grant.accountId,
500
505
  workspaceId,
@@ -616,7 +621,10 @@ export function registerFileRoutes(app: Hono, deps: ApiRouteDeps): void {
616
621
  if (!present) {
617
622
  throw new HTTPException(410, { message: "file bytes are unavailable" });
618
623
  }
619
- const signed = await objectStorage.createGetUrl({ key: file.objectKey });
624
+ const signed = await objectStorage.createGetUrl({
625
+ key: file.objectKey,
626
+ ...userContentSignedGetUrlOptions(file.contentType, file.filename),
627
+ });
620
628
  await recordAuditEvent(db, {
621
629
  accountId: grant.accountId,
622
630
  workspaceId,
@@ -684,12 +692,15 @@ async function serveRetainedArtifactContent(
684
692
  );
685
693
  }
686
694
 
695
+ // Stored bytes and their content type are user- or agent-chosen; this origin
696
+ // also serves the console, so the response is a sandboxed, non-embeddable
697
+ // document and active markup downloads instead of rendering.
687
698
  const headers = {
688
699
  "Accept-Ranges": range.acceptRanges,
689
700
  "Cache-Control": "private, no-store",
690
701
  "Content-Length": String(range.length),
691
702
  "Content-Type": metadata.contentType,
692
- "X-Content-Type-Options": "nosniff",
703
+ ...userContentResponseHeaders(metadata.contentType, file.filename),
693
704
  ...(range.contentRange ? { "Content-Range": range.contentRange } : {}),
694
705
  };
695
706
  if (range.kind === "empty") {
@@ -887,12 +898,15 @@ function retainedScreenshotMetadata(
887
898
  artifact.sessionId &&
888
899
  artifact.retentionExpiresAt.getTime() > Date.now()
889
900
  ) {
901
+ const kind = retainedSessionScreenshotKindFromObjectKey(artifact.file.objectKey);
902
+ if (!kind) return retainedArtifactUnavailable(artifact.artifactId, "invalid_content");
890
903
  const reference = retainedScreenshotReferenceFromFile({
891
904
  ...artifact.file,
892
905
  sessionId: artifact.sessionId,
893
906
  width: artifact.width,
894
907
  height: artifact.height,
895
908
  expiresAt: artifact.retentionExpiresAt.toISOString(),
909
+ kind,
896
910
  });
897
911
  if (reference) return reference;
898
912
  }
@@ -0,0 +1,162 @@
1
+ import type { GitHubInstallationBindingCandidate } from "@opengeni/contracts";
2
+
3
+ // GitHub redirects can land on an API-only host. Keep these pages self-contained
4
+ // while using the same dark palette and typography as @opengeni/react.
5
+ const pageStyle = `
6
+ :root{color-scheme:dark;--bg:oklch(.155 .012 260);--surface:oklch(.19 .014 260);--raised:oklch(.225 .015 260);--border:oklch(.3 .014 260);--border-strong:oklch(.42 .016 260);--fg:oklch(.955 .005 260);--muted:oklch(.73 .012 260);--blue:oklch(.54 .18 255);--blue-light:oklch(.72 .15 255)}
7
+ *{box-sizing:border-box}
8
+ html{min-height:100%;background:var(--bg)}
9
+ body{margin:0;min-height:100vh;min-height:100dvh;color:var(--fg);background:var(--bg);font-family:"Inter Variable",Inter,ui-sans-serif,system-ui,sans-serif;font-feature-settings:"cv11","ss01","ss03";-webkit-font-smoothing:antialiased}
10
+ .shell{width:min(100% - 48px,700px);margin:0 auto}
11
+ header{height:84px;display:flex;align-items:center;justify-content:space-between;gap:16px;border-bottom:1px solid var(--border)}
12
+ .brand{font-size:16px;font-weight:670;letter-spacing:-.045em;color:var(--fg);text-decoration:none}
13
+ .context{font-size:12px;color:var(--muted)}
14
+ main{padding:clamp(48px,10vh,100px) 0 96px}
15
+ h1{margin:0 0 14px;font-size:clamp(27px,4vw,36px);font-weight:640;letter-spacing:-.05em;line-height:1.17}
16
+ p{margin:0;color:var(--muted);font-size:14px;line-height:1.65;max-width:59ch}
17
+ .intro{margin-bottom:32px}
18
+ .options{display:grid;gap:10px;margin:0 0 24px;padding:0;border:0;max-height:min(390px,52vh);overflow:auto}
19
+ .option{display:flex;gap:14px;align-items:center;min-height:70px;padding:14px 16px;border:1px solid var(--border);border-radius:10px;background:var(--surface);cursor:pointer;transition:background .15s,border-color .15s}
20
+ .option:hover{background:var(--raised)}
21
+ .option:has(input:checked){border-color:var(--blue-light);background:var(--raised)}
22
+ .option:has(input:focus-visible){outline:2px solid var(--blue-light);outline-offset:3px}
23
+ .option input{margin:0;flex:none;width:17px;height:17px;accent-color:var(--blue-light)}
24
+ .option-text{min-width:0;display:grid;gap:3px}
25
+ .option strong{overflow-wrap:anywhere;font-size:14px;font-weight:600}
26
+ .option small{font-size:12px;color:var(--muted)}
27
+ .actions{display:flex;align-items:center;flex-wrap:wrap;gap:12px;margin-top:24px}
28
+ .button{display:inline-flex;min-height:42px;align-items:center;justify-content:center;padding:0 17px;border:1px solid var(--blue);border-radius:8px;background:var(--blue);color:#fff;font-family:inherit;font-size:13px;font-weight:600;text-decoration:none;cursor:pointer}
29
+ .button:hover{background:oklch(.6 .18 255)}
30
+ .button:disabled{cursor:wait;opacity:.7}
31
+ .secondary{border-color:var(--border);background:transparent;color:var(--fg)}
32
+ .secondary:hover{background:var(--raised)}
33
+ :is(button,a):focus-visible{outline:2px solid var(--blue-light);outline-offset:3px}
34
+ .note{margin-top:30px;padding-top:20px;border-top:1px solid var(--border);font-size:12px}
35
+ .env-header{display:flex;justify-content:space-between;align-items:center;gap:14px;margin-top:36px}
36
+ h2{margin:0;font-size:14px;font-weight:600}
37
+ pre{white-space:pre-wrap;overflow-wrap:anywhere;max-height:380px;overflow:auto;padding:18px;border:1px solid var(--border);border-radius:10px;background:var(--surface);font:12px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace}
38
+ @media(max-width:480px){.shell{width:calc(100% - 32px)}header{height:70px}main{padding-top:56px}.actions{align-items:stretch;flex-direction:column}.actions form,.actions .button{width:100%}}
39
+ @media(prefers-reduced-motion:reduce){.option{transition:none}}
40
+ `;
41
+
42
+ function page(title: string, content: string): string {
43
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(title)} · OpenGeni</title><style>${pageStyle}</style></head><body><div class="shell"><header><span class="brand">OpenGeni</span><span class="context">GitHub connection</span></header><main>${content}</main></div></body></html>`;
44
+ }
45
+
46
+ export function githubInstallationChooserHtml(
47
+ candidates: GitHubInstallationBindingCandidate[],
48
+ state: string,
49
+ workspaceId: string,
50
+ baseUrl: string,
51
+ ): string {
52
+ const action = `${baseUrl}/v1/workspaces/${encodeURIComponent(workspaceId)}/github/installations/select`;
53
+ const options = candidates
54
+ .map(({ installation, authorityKind }, index) => {
55
+ const account = escapeHtml(
56
+ installation.accountLogin ?? `installation ${installation.installationId}`,
57
+ );
58
+ const label = authorityKind === "personal_owner" ? "Personal account" : "Organization owner";
59
+ return `<label class="option"><input type="radio" name="installation_id" value="${installation.installationId}" required${candidates.length === 1 && index === 0 ? " checked" : ""}><span class="option-text"><strong>${account}</strong><small>${label}</small></span></label>`;
60
+ })
61
+ .join("");
62
+ return page(
63
+ "Choose a GitHub account",
64
+ `<h1>Choose a GitHub account</h1><p class="intro">Connect an account where the OpenGeni GitHub App is already installed, or install it on another account.</p><form id="existing-account" method="get" action="${escapeHtml(action)}"><input type="hidden" name="state" value="${escapeHtml(state)}"><fieldset class="options" aria-label="Available GitHub accounts">${options}</fieldset></form><div class="actions"><button class="button" type="submit" form="existing-account">Connect selected account</button><form method="get" action="${escapeHtml(action)}"><input type="hidden" name="state" value="${escapeHtml(state)}"><input type="hidden" name="installation_id" value="new"><button class="button secondary" type="submit">Install on another account</button></form></div><p class="note">Only accounts GitHub has confirmed you own or manage appear here.</p>`,
65
+ );
66
+ }
67
+
68
+ export function githubSetupSuccessHtml(account: string, returnUrl: string): string {
69
+ return page(
70
+ "GitHub connected",
71
+ `<h1>GitHub connected</h1><p class="intro"><strong>${escapeHtml(account)}</strong> is now available in this OpenGeni workspace. OpenGeni can access only the repositories allowed for this installation.</p><a class="button" href="${escapeHtml(returnUrl)}">Back to OpenGeni</a>`,
72
+ );
73
+ }
74
+
75
+ export function githubSetupPendingHtml(): string {
76
+ return page(
77
+ "GitHub approval pending",
78
+ `<h1>Waiting for an organization owner</h1><p>An owner needs to approve your GitHub App request before you can connect. OpenGeni has not created a workspace binding.</p>`,
79
+ );
80
+ }
81
+
82
+ /**
83
+ * Why a GitHub browser step could not continue. Browser navigation (a stale
84
+ * page-load link, GitHub's Cancel button, a non-owner) must land on a readable
85
+ * page with a way back, never on a raw JSON error body.
86
+ */
87
+ export type GitHubConnectFailure =
88
+ | "expired"
89
+ | "cancelled"
90
+ | "not_owner"
91
+ | "forbidden"
92
+ | "policy_denied"
93
+ | "signed_out"
94
+ | "failed";
95
+
96
+ const GITHUB_CONNECT_FAILURE_COPY: Record<GitHubConnectFailure, { title: string; body: string }> = {
97
+ expired: {
98
+ title: "This GitHub link expired",
99
+ body: "GitHub connection links stay valid for 10 minutes, and each one works once. Go back to OpenGeni and select Connect again to get a fresh link.",
100
+ },
101
+ cancelled: {
102
+ title: "GitHub connection cancelled",
103
+ body: "You cancelled on GitHub, so nothing was connected. Go back to OpenGeni and select Connect when you are ready.",
104
+ },
105
+ not_owner: {
106
+ title: "An owner needs to connect this account",
107
+ body: "Only the owner of the GitHub account, or an owner of the GitHub organization, can connect it to OpenGeni. Ask an owner to connect it, or install the app on an account you own.",
108
+ },
109
+ forbidden: {
110
+ title: "You can't manage GitHub here",
111
+ body: "Your OpenGeni access doesn't allow connecting GitHub for this workspace. Ask a workspace admin to connect it.",
112
+ },
113
+ policy_denied: {
114
+ title: "GitHub is turned off for your organization",
115
+ body: "Your organization's integration policy doesn't allow connecting GitHub. Ask an organization admin to allow it, then select Connect again.",
116
+ },
117
+ signed_out: {
118
+ title: "Sign in to continue",
119
+ body: "Your OpenGeni sign-in wasn't available when GitHub sent you back. Sign in to OpenGeni and select Connect again.",
120
+ },
121
+ failed: {
122
+ title: "GitHub couldn't finish connecting",
123
+ body: "Nothing was connected. Go back to OpenGeni and select Connect to try again.",
124
+ },
125
+ };
126
+
127
+ export function githubConnectFailureHtml(
128
+ failure: GitHubConnectFailure,
129
+ returnUrl: string,
130
+ detail?: string | null,
131
+ ): string {
132
+ const copy = GITHUB_CONNECT_FAILURE_COPY[failure];
133
+ const note = detail?.trim()
134
+ ? `<p class="note">Details: ${escapeHtml(detail.trim().slice(0, 300))}</p>`
135
+ : "";
136
+ return page(
137
+ copy.title,
138
+ `<h1>${escapeHtml(copy.title)}</h1><p class="intro">${escapeHtml(copy.body)}</p><a class="button" href="${escapeHtml(returnUrl)}">Back to OpenGeni</a>${note}`,
139
+ );
140
+ }
141
+
142
+ export function githubSuccessHtml(envLines: string[]): string {
143
+ const escaped = escapeHtml(envLines.join("\n"));
144
+ return page(
145
+ "GitHub App created",
146
+ `<h1>GitHub App created</h1><p>Add these values to .env, then restart API and worker.</p><div class="env-header"><h2>Environment variables</h2><button class="button secondary" id="copy-env" type="button">Copy env</button></div><pre id="env-lines">${escaped}</pre><script>(()=>{const button=document.getElementById("copy-env");const env=document.getElementById("env-lines");async function copyText(text){if(navigator.clipboard&&window.isSecureContext){await navigator.clipboard.writeText(text);return;}const area=document.createElement("textarea");area.value=text;area.setAttribute("readonly","");area.style.position="fixed";area.style.inset="-9999px";document.body.append(area);area.select();document.execCommand("copy");area.remove();}button?.addEventListener("click",async()=>{try{await copyText(env?.textContent||"");button.textContent="Copied";setTimeout(()=>button.textContent="Copy env",1600);}catch{button.textContent="Copy failed";setTimeout(()=>button.textContent="Copy env",2200);}});})();</script>`,
147
+ );
148
+ }
149
+
150
+ function escapeHtml(value: string): string {
151
+ return value.replace(
152
+ /[&<>"']/g,
153
+ (char) =>
154
+ ({
155
+ "&": "&amp;",
156
+ "<": "&lt;",
157
+ ">": "&gt;",
158
+ '"': "&quot;",
159
+ "'": "&#39;",
160
+ })[char] ?? char,
161
+ );
162
+ }