@opengeni/api-router 0.5.7 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.d.ts +21 -3
- package/dist/app.js +3 -1
- package/dist/{chunk-HBEJMWD3.js → chunk-QOYQBYHM.js} +4302 -926
- package/dist/chunk-QOYQBYHM.js.map +1 -0
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/package.json +12 -11
- package/src/app.ts +83 -18
- package/src/codex-redemption-security.ts +96 -0
- package/src/github-access.ts +46 -0
- package/src/github-browser-flow.ts +83 -0
- package/src/http/auth.ts +12 -4
- package/src/http/sse.ts +526 -92
- package/src/index.ts +2 -1
- package/src/integrations/oauth-client.ts +147 -61
- package/src/mcp/server.ts +828 -146
- package/src/mcp/session-view.ts +721 -201
- package/src/mcp/toolspace.ts +482 -132
- package/src/model-catalog.ts +337 -0
- package/src/routes/codex.ts +883 -28
- package/src/routes/enrollments.ts +2 -2
- package/src/routes/files.ts +153 -0
- package/src/routes/github.ts +63 -202
- package/src/routes/install.ts +1 -1
- package/src/routes/machines.ts +2 -2
- package/src/routes/sessions.ts +789 -81
- package/src/routes/workspace-capture.ts +56 -38
- package/src/routes/workspaces.ts +66 -13
- package/src/sandbox/access.ts +1 -1
- package/src/sandbox/auth-callout.ts +1 -1
- package/src/sandbox/channel-a.ts +14 -1
- package/src/sandbox/enrollment.ts +4 -4
- package/src/sandbox/machines.ts +1 -1
- package/src/sandbox/metrics-ingestion.ts +1 -1
- package/src/sandbox/viewer.ts +8 -2
- package/dist/chunk-HBEJMWD3.js.map +0 -1
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
type CodexRedemptionConfirmationClaims = {
|
|
2
|
+
version: 1;
|
|
3
|
+
attemptId: string;
|
|
4
|
+
workspaceId: string;
|
|
5
|
+
credentialId: string;
|
|
6
|
+
creditId: string;
|
|
7
|
+
subjectId: string;
|
|
8
|
+
browserSessionHash: string;
|
|
9
|
+
expiresAt: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const encoder = new TextEncoder();
|
|
13
|
+
|
|
14
|
+
function base64UrlEncode(bytes: Uint8Array): string {
|
|
15
|
+
return Buffer.from(bytes).toString("base64url");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function base64UrlDecode(value: string): Uint8Array | null {
|
|
19
|
+
try {
|
|
20
|
+
return new Uint8Array(Buffer.from(value, "base64url"));
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function hmac(secret: string, payload: string): Promise<Uint8Array> {
|
|
27
|
+
const key = await crypto.subtle.importKey(
|
|
28
|
+
"raw",
|
|
29
|
+
encoder.encode(secret),
|
|
30
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
31
|
+
false,
|
|
32
|
+
["sign"],
|
|
33
|
+
);
|
|
34
|
+
return new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(payload)));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function constantTimeEqual(left: Uint8Array, right: Uint8Array): boolean {
|
|
38
|
+
if (left.length !== right.length) return false;
|
|
39
|
+
let diff = 0;
|
|
40
|
+
for (let index = 0; index < left.length; index += 1) {
|
|
41
|
+
diff |= left[index]! ^ right[index]!;
|
|
42
|
+
}
|
|
43
|
+
return diff === 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function hashCodexBrowserSession(sessionId: string): Promise<string> {
|
|
47
|
+
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(sessionId));
|
|
48
|
+
return base64UrlEncode(new Uint8Array(digest));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Five-minute, session-bound, HMAC-confirmed browser mutation token. */
|
|
52
|
+
export async function signCodexRedemptionConfirmation(
|
|
53
|
+
secret: string,
|
|
54
|
+
claims: CodexRedemptionConfirmationClaims,
|
|
55
|
+
): Promise<string> {
|
|
56
|
+
const payload = base64UrlEncode(encoder.encode(JSON.stringify(claims)));
|
|
57
|
+
return `${payload}.${base64UrlEncode(await hmac(secret, payload))}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function verifyCodexRedemptionConfirmation(
|
|
61
|
+
secret: string,
|
|
62
|
+
token: string,
|
|
63
|
+
now = Date.now(),
|
|
64
|
+
): Promise<CodexRedemptionConfirmationClaims | null> {
|
|
65
|
+
const [payload, signature, extra] = token.split(".");
|
|
66
|
+
if (!payload || !signature || extra !== undefined) return null;
|
|
67
|
+
const supplied = base64UrlDecode(signature);
|
|
68
|
+
const encodedClaims = base64UrlDecode(payload);
|
|
69
|
+
if (!supplied || !encodedClaims) return null;
|
|
70
|
+
if (!constantTimeEqual(supplied, await hmac(secret, payload))) return null;
|
|
71
|
+
let claims: unknown;
|
|
72
|
+
try {
|
|
73
|
+
claims = JSON.parse(new TextDecoder().decode(encodedClaims));
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
if (!claims || typeof claims !== "object") return null;
|
|
78
|
+
const value = claims as Record<string, unknown>;
|
|
79
|
+
if (
|
|
80
|
+
value.version !== 1 ||
|
|
81
|
+
typeof value.attemptId !== "string" ||
|
|
82
|
+
typeof value.workspaceId !== "string" ||
|
|
83
|
+
typeof value.credentialId !== "string" ||
|
|
84
|
+
typeof value.creditId !== "string" ||
|
|
85
|
+
typeof value.subjectId !== "string" ||
|
|
86
|
+
typeof value.browserSessionHash !== "string" ||
|
|
87
|
+
typeof value.expiresAt !== "number" ||
|
|
88
|
+
!Number.isFinite(value.expiresAt) ||
|
|
89
|
+
value.expiresAt * 1000 <= now
|
|
90
|
+
) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
return value as CodexRedemptionConfirmationClaims;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export type { CodexRedemptionConfirmationClaims };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { GitHubInstallationBinding, GitHubRepository } from "@opengeni/contracts";
|
|
2
|
+
import { listGitHubInstallationAccessForWorkspace } from "@opengeni/db";
|
|
3
|
+
import { listGitHubAppRepositories } from "@opengeni/github";
|
|
4
|
+
import type { ApiRouteDeps } from "@opengeni/core";
|
|
5
|
+
|
|
6
|
+
export async function listWorkspaceGitHubInstallationBindings(
|
|
7
|
+
deps: ApiRouteDeps,
|
|
8
|
+
workspaceId: string,
|
|
9
|
+
): Promise<GitHubInstallationBinding[]> {
|
|
10
|
+
const installations = await listGitHubInstallationAccessForWorkspace(deps.db, workspaceId);
|
|
11
|
+
return installations.map((installation) => ({
|
|
12
|
+
installationId: installation.installationId,
|
|
13
|
+
accountLogin: installation.accountLogin,
|
|
14
|
+
accountType: installation.accountType,
|
|
15
|
+
repositoryScope: installation.repositoryScope,
|
|
16
|
+
repositoryCount: installation.repositoryIds.length,
|
|
17
|
+
createdAt: installation.createdAt,
|
|
18
|
+
updatedAt: installation.updatedAt,
|
|
19
|
+
}));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function listWorkspaceGitHubRepositories(
|
|
23
|
+
deps: ApiRouteDeps,
|
|
24
|
+
workspaceId: string,
|
|
25
|
+
): Promise<GitHubRepository[]> {
|
|
26
|
+
const access = await listGitHubInstallationAccessForWorkspace(deps.db, workspaceId);
|
|
27
|
+
if (access.length === 0) {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
const installationIds = access.map((installation) => installation.installationId);
|
|
31
|
+
const repositories = deps.githubAppApi?.listRepositories
|
|
32
|
+
? await deps.githubAppApi.listRepositories({ installationIds })
|
|
33
|
+
: await listGitHubAppRepositories(deps.settings, { installationIds });
|
|
34
|
+
const accessByInstallation = new Map(
|
|
35
|
+
access.map((installation) => [installation.installationId, installation]),
|
|
36
|
+
);
|
|
37
|
+
return repositories.filter((repository) => {
|
|
38
|
+
const installation = accessByInstallation.get(repository.installationId);
|
|
39
|
+
if (!installation) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
return (
|
|
43
|
+
installation.repositoryScope === "all" || installation.repositoryIds.includes(repository.id)
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { Settings } from "@opengeni/config";
|
|
2
|
+
import type { AccessGrant } from "@opengeni/contracts";
|
|
3
|
+
import { hasPermission } from "@opengeni/core";
|
|
4
|
+
import type { GitHubSignedStatePayload } from "@opengeni/github";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Dormant compatibility helpers for tests and decoding already-issued browser
|
|
8
|
+
* handoffs. No production route imports this module. Its signed claims preserve
|
|
9
|
+
* a prior OpenGeni grant across a redirect; they do not prove that GitHub
|
|
10
|
+
* authorizes the human to install, configure, or bind an App installation.
|
|
11
|
+
*/
|
|
12
|
+
export const githubBrowserGrantMaxAgeSeconds = 10 * 60;
|
|
13
|
+
|
|
14
|
+
export function githubBrowserGrantClaims(
|
|
15
|
+
settings: Pick<Settings, "productAccessMode">,
|
|
16
|
+
grant: AccessGrant,
|
|
17
|
+
nowSeconds = Math.floor(Date.now() / 1000),
|
|
18
|
+
): Record<string, unknown> {
|
|
19
|
+
if (
|
|
20
|
+
settings.productAccessMode !== "configured" ||
|
|
21
|
+
!hasPermission(grant.permissions, "github:manage")
|
|
22
|
+
) {
|
|
23
|
+
return {};
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
browserGrantSubjectId: grant.subjectId,
|
|
27
|
+
browserGrantExpiresAt: nowSeconds + githubBrowserGrantMaxAgeSeconds,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function continuedGitHubBrowserGrantClaims(
|
|
32
|
+
payload: GitHubSignedStatePayload,
|
|
33
|
+
): Record<string, unknown> {
|
|
34
|
+
return typeof payload.browserGrantSubjectId === "string" &&
|
|
35
|
+
typeof payload.browserGrantExpiresAt === "number"
|
|
36
|
+
? {
|
|
37
|
+
browserGrantSubjectId: payload.browserGrantSubjectId,
|
|
38
|
+
browserGrantExpiresAt: payload.browserGrantExpiresAt,
|
|
39
|
+
}
|
|
40
|
+
: {};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function githubBrowserGrantFromState(
|
|
44
|
+
settings: Pick<Settings, "productAccessMode">,
|
|
45
|
+
payload: GitHubSignedStatePayload,
|
|
46
|
+
workspaceId: string,
|
|
47
|
+
nowSeconds = Math.floor(Date.now() / 1000),
|
|
48
|
+
): AccessGrant | null {
|
|
49
|
+
const subjectId = payload.browserGrantSubjectId;
|
|
50
|
+
const expiresAt = payload.browserGrantExpiresAt;
|
|
51
|
+
if (
|
|
52
|
+
settings.productAccessMode !== "configured" ||
|
|
53
|
+
typeof payload.accountId !== "string" ||
|
|
54
|
+
payload.workspaceId !== workspaceId ||
|
|
55
|
+
typeof subjectId !== "string" ||
|
|
56
|
+
subjectId.length === 0 ||
|
|
57
|
+
typeof expiresAt !== "number" ||
|
|
58
|
+
!Number.isInteger(expiresAt) ||
|
|
59
|
+
expiresAt < nowSeconds ||
|
|
60
|
+
expiresAt > payload.iat + githubBrowserGrantMaxAgeSeconds
|
|
61
|
+
) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
accountId: payload.accountId,
|
|
66
|
+
workspaceId,
|
|
67
|
+
subjectId,
|
|
68
|
+
permissions: ["github:manage"],
|
|
69
|
+
metadata: { githubBrowserHandoff: true, expiresAt },
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function githubBrowserBaseUrl(
|
|
74
|
+
settings: Pick<Settings, "githubAppManifestBaseUrl" | "publicBaseUrl">,
|
|
75
|
+
requestOrigin?: string | null,
|
|
76
|
+
): string {
|
|
77
|
+
return (
|
|
78
|
+
settings.githubAppManifestBaseUrl ??
|
|
79
|
+
settings.publicBaseUrl ??
|
|
80
|
+
requestOrigin ??
|
|
81
|
+
""
|
|
82
|
+
).replace(/\/+$/, "");
|
|
83
|
+
}
|
package/src/http/auth.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { Context, MiddlewareHandler } from "hono";
|
|
|
3
3
|
import { installExactPaths, isInstallRedirectPath } from "../routes/install";
|
|
4
4
|
|
|
5
5
|
const githubConnectPathPattern = /^\/v1\/workspaces\/[^/]+\/github\/connect$/;
|
|
6
|
+
const githubInstallationLinkPathPattern = /^\/v1\/workspaces\/[^/]+\/github\/installations$/;
|
|
6
7
|
|
|
7
8
|
export function requireAccessKey(settings: Settings): MiddlewareHandler {
|
|
8
9
|
return async (c, next) => {
|
|
@@ -58,15 +59,22 @@ function isAuthExempt(c: Context, settings: Settings): boolean {
|
|
|
58
59
|
if (path.startsWith("/v1/catalog-assets/")) {
|
|
59
60
|
return true;
|
|
60
61
|
}
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
62
|
+
// Compatibility entry for already-issued GitHub install/link URLs. It stays
|
|
63
|
+
// public like the callbacks above, verifies signed workspace-bound state,
|
|
64
|
+
// and then terminates with 410 while new installation binding is disabled.
|
|
64
65
|
if (githubConnectPathPattern.test(path)) {
|
|
65
66
|
return true;
|
|
66
67
|
}
|
|
68
|
+
// Compatibility endpoint for stale chooser submissions. It remains public
|
|
69
|
+
// only so already-rendered forms can authenticate their signed account and
|
|
70
|
+
// workspace state locally before terminating with 410; it does not parse a
|
|
71
|
+
// ticket, resolve browser authority, or write an installation binding.
|
|
72
|
+
if (c.req.method === "POST" && githubInstallationLinkPathPattern.test(path)) {
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
67
75
|
// The get.<domain> install-serving routes (install.sh/.ps1/uninstall.sh/
|
|
68
76
|
// minisign pub + the release-binary redirects). Reached by a fresh machine
|
|
69
|
-
// with no credentials; the bodies carry no secrets
|
|
77
|
+
// with no credentials; the bodies carry no secrets.
|
|
70
78
|
if (installExactPaths.has(path) || isInstallRedirectPath(path)) {
|
|
71
79
|
return true;
|
|
72
80
|
}
|