@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.
- package/dist/app.d.ts +7 -1
- package/dist/app.js +1 -1
- package/dist/auth/managed-auth-rate-limits.d.ts +167 -0
- package/dist/auth/managed-auth.d.ts +37 -1
- package/dist/auth/signup-funnel-metrics.d.ts +44 -0
- package/dist/{chunk-24C5LPNE.js → chunk-QDIBJSM4.js} +3277 -1509
- package/dist/{chunk-24C5LPNE.js.map → chunk-QDIBJSM4.js.map} +1 -1
- package/dist/http/cors.d.ts +12 -0
- package/dist/http/local-browser-boundary.d.ts +72 -0
- package/dist/http/metrics-listener.d.ts +22 -0
- package/dist/http/registered-route-label.d.ts +16 -0
- package/dist/http/request-source.d.ts +41 -3
- package/dist/http/user-content.d.ts +36 -0
- package/dist/http/websocket-upgrade-dispatch.d.ts +18 -0
- package/dist/index.js +90 -31
- package/dist/index.js.map +1 -1
- package/dist/integrations/github-lens-connect.d.ts +1 -1
- package/dist/integrations/oauth-client.d.ts +30 -0
- package/dist/integrations/slack-bot.d.ts +48 -2
- package/dist/integrations/slack-interactions.d.ts +1 -1
- package/dist/mcp/documents.d.ts +2 -0
- package/dist/mcp/first-party-tool-permissions.d.ts +12 -0
- package/dist/mcp/server.d.ts +35 -0
- package/dist/model-catalog.d.ts +9 -3
- package/dist/observability.d.ts +3 -1
- package/dist/routes/billing.d.ts +27 -0
- package/dist/routes/browser-sessions.d.ts +3 -0
- package/dist/routes/client-errors.d.ts +42 -0
- package/dist/routes/computer-sessions.d.ts +2 -0
- package/dist/routes/github-browser-pages.d.ts +12 -0
- package/dist/routes/insights.d.ts +26 -0
- package/dist/sandbox/auth-callout.d.ts +8 -0
- package/dist/sandbox-file-artifacts.d.ts +17 -1
- package/package.json +19 -19
- package/src/app.ts +124 -12
- package/src/auth/managed-auth-rate-limits.ts +252 -0
- package/src/auth/managed-auth.ts +137 -17
- package/src/auth/signup-funnel-metrics.ts +164 -0
- package/src/http/auth.ts +6 -0
- package/src/http/cors.ts +37 -5
- package/src/http/local-browser-boundary.ts +343 -0
- package/src/http/metrics-listener.ts +57 -0
- package/src/http/registered-route-label.ts +34 -0
- package/src/http/request-source.ts +154 -13
- package/src/http/sse.ts +36 -16
- package/src/http/user-content.ts +158 -0
- package/src/http/websocket-upgrade-dispatch.ts +29 -0
- package/src/index.ts +22 -8
- package/src/integrations/atlassian.ts +7 -3
- package/src/integrations/fiken.ts +9 -3
- package/src/integrations/github-app-connect.ts +12 -2
- package/src/integrations/google-drive.ts +7 -3
- package/src/integrations/oauth-client.ts +99 -12
- package/src/integrations/personal-github.ts +4 -2
- package/src/integrations/provider-oauth.ts +10 -3
- package/src/integrations/slack-bot.ts +43 -1
- package/src/integrations/social-oauth.ts +7 -2
- package/src/mcp/documents.ts +9 -2
- package/src/mcp/files.ts +5 -1
- package/src/mcp/first-party-tool-permissions.ts +6 -0
- package/src/mcp/knowledge-entries.ts +15 -2
- package/src/mcp/server.ts +191 -17
- package/src/mcp-oauth.ts +4 -6
- package/src/model-catalog.ts +30 -3
- package/src/observability.ts +15 -1
- package/src/routes/billing.ts +70 -9
- package/src/routes/browser-sessions.ts +169 -0
- package/src/routes/client-errors.ts +182 -0
- package/src/routes/company-brain.ts +4 -0
- package/src/routes/computer-sessions.ts +37 -9
- package/src/routes/connections.ts +7 -1
- package/src/routes/documents.ts +6 -1
- package/src/routes/editable-artifacts.ts +2 -1
- package/src/routes/enrollments.ts +2 -13
- package/src/routes/files.ts +17 -3
- package/src/routes/github-browser-pages.ts +162 -0
- package/src/routes/github.ts +122 -49
- package/src/routes/insights.ts +70 -6
- package/src/routes/knowledge.ts +24 -1
- package/src/routes/managed-auth-session-sets.ts +26 -6
- package/src/routes/managed-onboarding.ts +23 -19
- package/src/routes/organization-memberships.ts +3 -0
- package/src/routes/scheduled-tasks.ts +9 -2
- package/src/routes/sessions.ts +89 -0
- package/src/routes/workspace-artifacts.ts +9 -0
- package/src/routes/workspace-state.ts +4 -0
- package/src/routes/workspaces.ts +38 -15
- package/src/sandbox/auth-callout.ts +62 -11
- package/src/sandbox/channel-a.ts +8 -0
- package/src/sandbox/viewer.ts +16 -3
- package/src/sandbox-file-artifacts.ts +37 -26
- package/src/site-uploads.ts +8 -1
- package/src/workspace-tool-gateway.ts +1 -0
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
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
|
|
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(
|
|
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
|
}
|
package/src/routes/documents.ts
CHANGED
|
@@ -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({
|
|
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
|
-
|
|
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 =
|
|
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.
|
package/src/routes/files.ts
CHANGED
|
@@ -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({
|
|
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({
|
|
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
|
-
|
|
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
|
+
"&": "&",
|
|
156
|
+
"<": "<",
|
|
157
|
+
">": ">",
|
|
158
|
+
'"': """,
|
|
159
|
+
"'": "'",
|
|
160
|
+
})[char] ?? char,
|
|
161
|
+
);
|
|
162
|
+
}
|