@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.
@@ -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
- // Browser entry for MCP-issued GitHub install links: opened in a browser
62
- // that holds no API credentials, like the callbacks above. The route itself
63
- // verifies the signed workspace-bound state before doing anything.
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 (dossier §23.1).
77
+ // with no credentials; the bodies carry no secrets.
70
78
  if (installExactPaths.has(path) || isInstallRedirectPath(path)) {
71
79
  return true;
72
80
  }