@opengeni/api-router 0.11.1 → 0.11.8

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.
@@ -1,6 +1,13 @@
1
- import type { GitHubInstallationBinding, GitHubRepository } from "@opengeni/contracts";
2
- import { listGitHubInstallationAccessForWorkspace } from "@opengeni/db";
3
- import { listGitHubAppRepositories } from "@opengeni/github";
1
+ import type {
2
+ GitHubBindingStatus,
3
+ GitHubInstallationBinding,
4
+ GitHubRepository,
5
+ } from "@opengeni/contracts";
6
+ import {
7
+ hasAuditableGitHubInstallationAuthority,
8
+ listGitHubInstallationAccessForWorkspace,
9
+ } from "@opengeni/db";
10
+ import { listGitHubAppInstallationSummaries, listGitHubAppRepositories } from "@opengeni/github";
4
11
  import type { ApiRouteDeps } from "@opengeni/core";
5
12
 
6
13
  export async function listWorkspaceGitHubInstallationBindings(
@@ -8,10 +15,53 @@ export async function listWorkspaceGitHubInstallationBindings(
8
15
  workspaceId: string,
9
16
  ): Promise<GitHubInstallationBinding[]> {
10
17
  const installations = await listGitHubInstallationAccessForWorkspace(deps.db, workspaceId);
18
+ if (installations.length === 0) {
19
+ return [];
20
+ }
21
+ let liveById = new Map<number, LiveGitHubInstallation | null>();
22
+ let lifecycleVerified = false;
23
+ try {
24
+ if (deps.githubAppApi?.getInstallation) {
25
+ liveById = new Map(
26
+ await Promise.all(
27
+ installations.map(
28
+ async (installation) =>
29
+ [
30
+ installation.installationId,
31
+ await deps.githubAppApi!.getInstallation!({
32
+ installationId: installation.installationId,
33
+ }),
34
+ ] as const,
35
+ ),
36
+ ),
37
+ );
38
+ lifecycleVerified = true;
39
+ } else if (!deps.githubAppApi) {
40
+ liveById = new Map(
41
+ (await listGitHubAppInstallationSummaries(deps.settings)).map((installation) => [
42
+ installation.installationId,
43
+ installation,
44
+ ]),
45
+ );
46
+ lifecycleVerified = true;
47
+ }
48
+ } catch {
49
+ // A provider outage cannot make a stored row healthy. Preserve the row for
50
+ // audit/unlink but project it as unverified and keep workspace status
51
+ // unbound until GitHub can be checked again.
52
+ liveById = new Map();
53
+ }
11
54
  return installations.map((installation) => ({
12
55
  installationId: installation.installationId,
56
+ githubAccountId: installation.githubAccountId,
13
57
  accountLogin: installation.accountLogin,
14
58
  accountType: installation.accountType,
59
+ lifecycle: githubInstallationBindingLifecycle(
60
+ installation,
61
+ liveById,
62
+ installation.installationId,
63
+ lifecycleVerified,
64
+ ),
15
65
  repositoryScope: installation.repositoryScope,
16
66
  repositoryCount: installation.repositoryIds.length,
17
67
  createdAt: installation.createdAt,
@@ -19,28 +69,86 @@ export async function listWorkspaceGitHubInstallationBindings(
19
69
  }));
20
70
  }
21
71
 
72
+ export function githubBindingStatus(
73
+ configured: boolean,
74
+ installations: GitHubInstallationBinding[],
75
+ ): GitHubBindingStatus {
76
+ if (!configured) {
77
+ return "disabled";
78
+ }
79
+ return installations.some((installation) => installation.lifecycle === "active")
80
+ ? "bound"
81
+ : "unbound";
82
+ }
83
+
84
+ export type LiveGitHubInstallation = {
85
+ installationId: number;
86
+ accountId: number;
87
+ suspended: boolean;
88
+ };
89
+
90
+ export function githubInstallationBindingLifecycle(
91
+ stored: Awaited<ReturnType<typeof listGitHubInstallationAccessForWorkspace>>[number],
92
+ liveById: Map<number, LiveGitHubInstallation | null>,
93
+ installationId: number,
94
+ lifecycleVerified: boolean,
95
+ ): GitHubInstallationBinding["lifecycle"] {
96
+ if (!hasAuditableGitHubInstallationAuthority(stored)) {
97
+ return "unverified";
98
+ }
99
+ if (!lifecycleVerified) {
100
+ return "unverified";
101
+ }
102
+ if (!liveById.has(installationId)) {
103
+ return "deleted";
104
+ }
105
+ const live = liveById.get(installationId);
106
+ if (!live) {
107
+ return "deleted";
108
+ }
109
+ if (live.suspended) {
110
+ return "suspended";
111
+ }
112
+ if (live.installationId !== installationId || stored.githubAccountId !== live.accountId) {
113
+ return "unverified";
114
+ }
115
+ return "active";
116
+ }
117
+
22
118
  export async function listWorkspaceGitHubRepositories(
23
119
  deps: ApiRouteDeps,
24
120
  workspaceId: string,
25
121
  ): Promise<GitHubRepository[]> {
122
+ const bindings = await listWorkspaceGitHubInstallationBindings(deps, workspaceId);
123
+ const activeInstallationIds = new Set(
124
+ bindings
125
+ .filter((installation) => installation.lifecycle === "active")
126
+ .map((installation) => installation.installationId),
127
+ );
128
+ if (activeInstallationIds.size === 0) {
129
+ return [];
130
+ }
26
131
  const access = await listGitHubInstallationAccessForWorkspace(deps.db, workspaceId);
27
- if (access.length === 0) {
132
+ const authorizedAccess = access.filter(
133
+ (installation) =>
134
+ activeInstallationIds.has(installation.installationId) &&
135
+ hasAuditableGitHubInstallationAuthority(installation),
136
+ );
137
+ if (authorizedAccess.length === 0) {
28
138
  return [];
29
139
  }
30
- const installationIds = access.map((installation) => installation.installationId);
140
+ const installationIds = authorizedAccess.map((installation) => installation.installationId);
31
141
  const repositories = deps.githubAppApi?.listRepositories
32
142
  ? await deps.githubAppApi.listRepositories({ installationIds })
33
143
  : await listGitHubAppRepositories(deps.settings, { installationIds });
34
144
  const accessByInstallation = new Map(
35
- access.map((installation) => [installation.installationId, installation]),
145
+ authorizedAccess.map((installation) => [installation.installationId, installation]),
36
146
  );
37
147
  return repositories.filter((repository) => {
38
148
  const installation = accessByInstallation.get(repository.installationId);
39
149
  if (!installation) {
40
150
  return false;
41
151
  }
42
- return (
43
- installation.repositoryScope === "all" || installation.repositoryIds.includes(repository.id)
44
- );
152
+ return installation.repositoryIds.includes(repository.id);
45
153
  });
46
154
  }
@@ -4,10 +4,10 @@ import { hasPermission } from "@opengeni/core";
4
4
  import type { GitHubSignedStatePayload } from "@opengeni/github";
5
5
 
6
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.
7
+ * Bounded configured-token browser handoff. These signed claims preserve only
8
+ * the exact OpenGeni github:manage grant across GitHub redirects; the callback
9
+ * independently proves current GitHub personal/organization ownership. This
10
+ * state must never be interpreted as GitHub installation authority.
11
11
  */
12
12
  export const githubBrowserGrantMaxAgeSeconds = 10 * 60;
13
13
 
package/src/http/auth.ts CHANGED
@@ -59,9 +59,8 @@ function isAuthExempt(c: Context, settings: Settings): boolean {
59
59
  if (path.startsWith("/v1/catalog-assets/")) {
60
60
  return true;
61
61
  }
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.
62
+ // The GitHub owner-consent entry remains public like the callbacks above; it
63
+ // verifies fresh signed workspace-bound state before redirecting to GitHub.
65
64
  if (githubConnectPathPattern.test(path)) {
66
65
  return true;
67
66
  }
package/src/index.ts CHANGED
@@ -12,7 +12,13 @@ import type {
12
12
  ScheduledTaskOverlapPolicy,
13
13
  ScheduledTaskScheduleSpec,
14
14
  } from "@opengeni/contracts";
15
- import { createDb, markSessionWorkflowWakeDelivered, type Database } from "@opengeni/db";
15
+ import {
16
+ assertRuntimeDatabasePosture,
17
+ createDb,
18
+ markSessionWorkflowWakeDelivered,
19
+ runtimeDatabaseReadyCheck,
20
+ type Database,
21
+ } from "@opengeni/db";
16
22
  import { createNatsEventBus, type ResponderConnection } from "@opengeni/events";
17
23
  import { createObservability, logStartupDependencyRetry } from "@opengeni/observability";
18
24
  import { SESSION_WORKFLOW_WAKE_DISPATCHER_SCHEDULE_ID } from "@opengeni/core";
@@ -253,11 +259,21 @@ export async function startApi() {
253
259
  const retryOptions = startupRetryOptions(settings);
254
260
  const onRetry = (event: Parameters<typeof logStartupDependencyRetry>[1]) =>
255
261
  logStartupDependencyRetry(observability, event);
262
+ const databasePosture = {
263
+ rlsStrategy: settings.rlsStrategy,
264
+ expectedRole: settings.runtimeDatabaseRole,
265
+ targetSchema: settings.dbSchema.trim() || "public",
266
+ } as const;
256
267
  // The PRIVILEGED control-plane NATS login (M-AUTH): when the server runs with
257
268
  // auth_callout, api/worker authenticate as a static account user permitted to
258
269
  // request `agent.*.rpc`. Null in local dev (anonymous connect — the bus default).
259
270
  const controlPlaneAuth = resolveNatsControlPlaneAuth(settings);
260
271
  try {
272
+ await retryStartupDependency(
273
+ "PostgreSQL runtime posture",
274
+ () => assertRuntimeDatabasePosture(dbClient.db, databasePosture),
275
+ { ...retryOptions, onRetry },
276
+ );
261
277
  bus = await retryStartupDependency(
262
278
  "NATS",
263
279
  () =>
@@ -296,6 +312,9 @@ export async function startApi() {
296
312
  workflowClient: workflowClient.client,
297
313
  documentIndexer: workflowClient.documentIndexer,
298
314
  observability,
315
+ readinessChecks: {
316
+ db: runtimeDatabaseReadyCheck(dbClient.db, databasePosture),
317
+ },
299
318
  });
300
319
  const server = Bun.serve({
301
320
  hostname: settings.apiHost,
@@ -2,6 +2,7 @@ import {
2
2
  getDocumentChunk,
3
3
  listDocumentBases,
4
4
  searchDocuments,
5
+ type DocumentAccessFilter,
5
6
  type DocumentServices,
6
7
  } from "@opengeni/documents";
7
8
  import { createKnowledgeMemory, listKnowledgeMemories, type Database } from "@opengeni/db";
@@ -44,12 +45,23 @@ export function buildDocumentsMcpServer(
44
45
  accountId: string,
45
46
  workspaceId: string,
46
47
  documentServices: DocumentServices,
47
- options: { createdBySessionId?: string | undefined } = {},
48
+ options: {
49
+ createdBySessionId?: string | undefined;
50
+ /** The human subject whose agent is making this retrieval request. */
51
+ viewerSubjectId?: string | undefined;
52
+ } = {},
48
53
  ): McpServer {
49
54
  const server = new McpServer({
50
55
  name: "opengeni-documents",
51
56
  version: "1.0.0",
52
57
  });
58
+ // This server is the agent retrieval surface. Agent-disabled documents are
59
+ // never reachable. Workspace-visible documents are shared; private
60
+ // documents are available only to the creating subject's agent.
61
+ const agentAccess: DocumentAccessFilter = {
62
+ agentOnly: true,
63
+ ...(options.viewerSubjectId ? { viewerSubjectId: options.viewerSubjectId } : {}),
64
+ };
53
65
 
54
66
  server.registerTool(
55
67
  "list_document_bases",
@@ -68,7 +80,7 @@ export function buildDocumentsMcpServer(
68
80
  description: "Search indexed documents with hybrid, vector, or keyword retrieval.",
69
81
  inputSchema: SearchInputSchema,
70
82
  },
71
- async (input) => searchContent(db, workspaceId, documentServices, input),
83
+ async (input) => searchContent(db, workspaceId, documentServices, input, agentAccess),
72
84
  );
73
85
 
74
86
  server.registerTool(
@@ -78,7 +90,7 @@ export function buildDocumentsMcpServer(
78
90
  "Search company knowledge sources with optional base, source-kind, ACL, and retrieval-mode filters.",
79
91
  inputSchema: SearchInputSchema,
80
92
  },
81
- async (input) => searchContent(db, workspaceId, documentServices, input),
93
+ async (input) => searchContent(db, workspaceId, documentServices, input, agentAccess),
82
94
  );
83
95
 
84
96
  server.registerTool(
@@ -90,7 +102,7 @@ export function buildDocumentsMcpServer(
90
102
  },
91
103
  },
92
104
  async ({ chunkId }) => {
93
- const found = await getDocumentChunk(db, workspaceId, chunkId);
105
+ const found = await getDocumentChunk(db, workspaceId, chunkId, agentAccess);
94
106
  return {
95
107
  content: [
96
108
  { type: "text", text: found ? JSON.stringify(found) : `chunk not found: ${chunkId}` },
@@ -109,7 +121,7 @@ export function buildDocumentsMcpServer(
109
121
  },
110
122
  },
111
123
  async ({ chunkId }) => {
112
- const found = await getDocumentChunk(db, workspaceId, chunkId);
124
+ const found = await getDocumentChunk(db, workspaceId, chunkId, agentAccess);
113
125
  return {
114
126
  content: [
115
127
  { type: "text", text: found ? JSON.stringify(found) : `chunk not found: ${chunkId}` },
@@ -214,6 +226,7 @@ async function searchContent(
214
226
  | undefined;
215
227
  aclTags?: string[] | undefined;
216
228
  },
229
+ access: DocumentAccessFilter,
217
230
  ) {
218
231
  return {
219
232
  content: [
@@ -230,6 +243,7 @@ async function searchContent(
230
243
  ...(input.mode ? { mode: input.mode } : {}),
231
244
  ...(input.sourceKinds ? { sourceKinds: input.sourceKinds } : {}),
232
245
  ...(input.aclTags ? { aclTags: input.aclTags } : {}),
246
+ access,
233
247
  },
234
248
  documentServices,
235
249
  ),
package/src/mcp/server.ts CHANGED
@@ -68,6 +68,7 @@ import {
68
68
  } from "@opengeni/db";
69
69
  import { appendAndPublishEvents, publishDurableSessionEvents } from "@opengeni/events";
70
70
  import {
71
+ createSignedState,
71
72
  createGitHubAppInstallationToken,
72
73
  GitHubAppConfigurationError,
73
74
  githubAppMissingSettings,
@@ -82,7 +83,12 @@ import {
82
83
  } from "@opengeni/core";
83
84
  import { recordWorkspaceUsage, requireLimit } from "@opengeni/core";
84
85
  import type { ApiRouteDeps } from "@opengeni/core";
85
- import { listWorkspaceGitHubRepositories } from "../github-access";
86
+ import {
87
+ githubBindingStatus,
88
+ listWorkspaceGitHubInstallationBindings,
89
+ listWorkspaceGitHubRepositories,
90
+ } from "../github-access";
91
+ import { githubBrowserBaseUrl, githubBrowserGrantClaims } from "../github-browser-flow";
86
92
  import {
87
93
  promoteVerifiedDefinitionEditChangeForApi,
88
94
  proposeRigChangeForApi,
@@ -140,9 +146,8 @@ import type { ToolspaceMcpSurface } from "./toolspace";
140
146
  import { ensureSessionGroupReady as ensureViewerSessionGroupReady } from "../sandbox/viewer";
141
147
 
142
148
  export type McpServerOptions = {
143
- // Origin of the HTTP request that reached the MCP route. Retained in the
144
- // options ABI for browser-oriented tools; github_connect_link does not use
145
- // it or mint state while new installation binding is disabled.
149
+ // Origin of the HTTP request that reached the MCP route. Browser-oriented
150
+ // tools use it only when no configured public base URL is available.
146
151
  requestOrigin?: string | null;
147
152
  toolspace?: ToolspaceMcpSurface | null;
148
153
  workspaceMemoryEnabled?: boolean | undefined;
@@ -229,7 +234,7 @@ export function buildOpenGeniMcpServer(
229
234
  registerWorkspaceOrchestrationTools(server, deps, grant, can, sessionId, toolspaceMode, json);
230
235
  registerVariableSetTools(server, deps, grant, can, json);
231
236
  if (can("github:use")) {
232
- registerGitHubConnectTool(server, deps, json);
237
+ registerGitHubConnectTool(server, deps, grant, options, json);
233
238
  // TOKEN-BROKER (B1): the agent-refreshable git token. Session-scoped (keys off the
234
239
  // worker-signed sessionId claim so it mints for THIS session's repos), gated on
235
240
  // the same github:use capability as github_connect_link.
@@ -2031,15 +2036,18 @@ function registerVariableSetTools(
2031
2036
  }
2032
2037
  }
2033
2038
 
2034
- // The tool remains registered for compatibility, but every new installation
2035
- // binding entry point is fail-closed until a provider-supported authority
2036
- // proof stronger than installation visibility is available.
2037
- function registerGitHubConnectTool(server: McpServer, deps: ApiRouteDeps, json: JsonResult): void {
2039
+ function registerGitHubConnectTool(
2040
+ server: McpServer,
2041
+ deps: ApiRouteDeps,
2042
+ grant: AccessGrant,
2043
+ options: McpServerOptions,
2044
+ json: JsonResult,
2045
+ ): void {
2038
2046
  server.registerTool(
2039
2047
  "github_connect_link",
2040
2048
  {
2041
2049
  description:
2042
- "Report GitHub App connection availability. New installation binding is disabled until GitHub installation authority can be proven, so installUrl and linkUrl are null.",
2050
+ "Report truthful GitHub App workspace binding status and, for a human grant with github:manage, return the fresh GitHub owner-consent link. Server App configuration alone is never reported as a usable binding.",
2043
2051
  inputSchema: {},
2044
2052
  },
2045
2053
  async () => {
@@ -2049,17 +2057,35 @@ function registerGitHubConnectTool(server: McpServer, deps: ApiRouteDeps, json:
2049
2057
  if (missing.length > 0 || !slug) {
2050
2058
  return json({
2051
2059
  configured: false,
2060
+ status: "disabled",
2052
2061
  appSlug: slug,
2053
2062
  installUrl: null,
2054
2063
  linkUrl: null,
2055
2064
  missing,
2056
2065
  });
2057
2066
  }
2067
+ const installations = await listWorkspaceGitHubInstallationBindings(deps, grant.workspaceId);
2068
+ const status = githubBindingStatus(true, installations);
2069
+ const baseUrl = githubBrowserBaseUrl(settings, options.requestOrigin);
2070
+ const state =
2071
+ baseUrl && hasPermission(grant.permissions, "github:manage")
2072
+ ? createSignedState(deps.githubStateSecret, {
2073
+ accountId: grant.accountId,
2074
+ workspaceId: grant.workspaceId,
2075
+ intent: "installation_authority",
2076
+ ...githubBrowserGrantClaims(settings, grant),
2077
+ })
2078
+ : null;
2079
+ const connectUrl = state
2080
+ ? `${baseUrl}/v1/workspaces/${grant.workspaceId}/github/connect?state=${encodeURIComponent(state)}`
2081
+ : null;
2058
2082
  return json({
2059
2083
  configured: true,
2084
+ status,
2060
2085
  appSlug: slug,
2061
- installUrl: null,
2062
- linkUrl: null,
2086
+ installUrl: connectUrl,
2087
+ linkUrl: connectUrl,
2088
+ installations,
2063
2089
  missing: [],
2064
2090
  });
2065
2091
  },