@opengeni/api-router 0.4.1 → 0.5.1

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,179 @@
1
+ import {
2
+ ConnectionResponse,
3
+ CreateConnectionRequest,
4
+ IntegrationClientMetadata,
5
+ ListConnectionsResponse,
6
+ OAuthStartRequest,
7
+ OAuthStartResponse,
8
+ UpdateConnectionRequest,
9
+ } from "@opengeni/contracts";
10
+ import { requireAccessGrant, requireEnvironmentEncryption } from "@opengeni/core";
11
+ import {
12
+ createConnection,
13
+ encryptEnvironmentValue,
14
+ getConnectionMetadata,
15
+ listConnectionsMetadata,
16
+ revokeConnection,
17
+ updateConnection,
18
+ } from "@opengeni/db";
19
+ import type { ApiRouteDeps } from "@opengeni/core";
20
+ import type { Hono } from "hono";
21
+ import { HTTPException } from "hono/http-exception";
22
+ import {
23
+ completeMcpOAuthCallback,
24
+ integrationBaseUrl,
25
+ startMcpOAuth,
26
+ } from "../integrations/oauth-client";
27
+
28
+ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
29
+ const { db, settings } = deps;
30
+
31
+ function assertIntegrationsEnabled(): void {
32
+ if (!settings.integrationsEnabled) {
33
+ throw new HTTPException(404, { message: "integrations are not enabled for this deployment" });
34
+ }
35
+ }
36
+
37
+ app.get("/v1/workspaces/:workspaceId/connections", async (c) => {
38
+ const workspaceId = c.req.param("workspaceId");
39
+ const grant = await requireAccessGrant(c, deps, workspaceId, "connections:read");
40
+ return c.json(ListConnectionsResponse.parse({
41
+ connections: await listConnectionsMetadata(db, workspaceId, grant.subjectId),
42
+ }));
43
+ });
44
+
45
+ app.post("/v1/workspaces/:workspaceId/connections", async (c) => {
46
+ const workspaceId = c.req.param("workspaceId");
47
+ const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
48
+ const payload = CreateConnectionRequest.parse(await c.req.json());
49
+ const key = requireEnvironmentEncryption(settings);
50
+ const subjectId = writableSubjectId(payload.subjectId, grant.subjectId);
51
+ const connection = await createConnection(db, {
52
+ accountId: grant.accountId,
53
+ workspaceId,
54
+ subjectId,
55
+ providerDomain: payload.providerDomain,
56
+ kind: payload.kind,
57
+ credentialEncrypted: encryptCredentialBundle(key, payload.credential),
58
+ grantedScopes: payload.grantedScopes,
59
+ expiresAt: payload.expiresAt ? new Date(payload.expiresAt) : null,
60
+ metadata: payload.metadata,
61
+ createdBySubjectId: grant.subjectId,
62
+ });
63
+ return c.json(ConnectionResponse.parse({ connection }), 201);
64
+ });
65
+
66
+ app.get("/v1/workspaces/:workspaceId/connections/:connectionId", async (c) => {
67
+ const workspaceId = c.req.param("workspaceId");
68
+ const grant = await requireAccessGrant(c, deps, workspaceId, "connections:read");
69
+ const connection = await getConnectionMetadata(db, workspaceId, c.req.param("connectionId"), grant.subjectId);
70
+ if (!connection) {
71
+ throw new HTTPException(404, { message: "connection not found" });
72
+ }
73
+ return c.json(ConnectionResponse.parse({ connection }));
74
+ });
75
+
76
+ app.patch("/v1/workspaces/:workspaceId/connections/:connectionId", async (c) => {
77
+ const workspaceId = c.req.param("workspaceId");
78
+ const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
79
+ const payload = UpdateConnectionRequest.parse(await c.req.json());
80
+ // Status is not a free-form field: revocation goes through DELETE, and the
81
+ // broker owns needs_reauth/error. Reactivating a connection is only
82
+ // meaningful together with a fresh credential bundle — otherwise a PATCH
83
+ // could clear the broker's re-auth signal while stale tokens stay in place.
84
+ if (payload.status !== undefined) {
85
+ if (payload.status !== "active") {
86
+ throw new HTTPException(400, { message: "status can only be set to \"active\"; use DELETE to revoke" });
87
+ }
88
+ if (payload.credential === undefined) {
89
+ throw new HTTPException(400, { message: "reactivating a connection requires a new credential" });
90
+ }
91
+ }
92
+ const key = payload.credential === undefined ? null : requireEnvironmentEncryption(settings);
93
+ const subjectId = payload.subjectId === undefined ? undefined : writableSubjectId(payload.subjectId, grant.subjectId);
94
+ const connection = await updateConnection(db, {
95
+ workspaceId,
96
+ connectionId: c.req.param("connectionId"),
97
+ visibleToSubjectId: grant.subjectId,
98
+ updatedBySubjectId: grant.subjectId,
99
+ ...(payload.providerDomain !== undefined ? { providerDomain: payload.providerDomain } : {}),
100
+ ...(subjectId !== undefined ? { subjectId } : {}),
101
+ ...(payload.kind !== undefined ? { kind: payload.kind } : {}),
102
+ ...(payload.status !== undefined ? { status: payload.status } : {}),
103
+ ...(payload.credential !== undefined && key ? { credentialEncrypted: encryptCredentialBundle(key, payload.credential) } : {}),
104
+ ...(payload.grantedScopes !== undefined ? { grantedScopes: payload.grantedScopes } : {}),
105
+ ...(payload.expiresAt !== undefined ? { expiresAt: payload.expiresAt ? new Date(payload.expiresAt) : null } : {}),
106
+ ...(payload.metadata !== undefined ? { metadata: payload.metadata } : {}),
107
+ });
108
+ if (!connection) {
109
+ throw new HTTPException(404, { message: "connection not found" });
110
+ }
111
+ return c.json(ConnectionResponse.parse({ connection }));
112
+ });
113
+
114
+ app.delete("/v1/workspaces/:workspaceId/connections/:connectionId", async (c) => {
115
+ const workspaceId = c.req.param("workspaceId");
116
+ const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
117
+ const connection = await revokeConnection(db, workspaceId, c.req.param("connectionId"), grant.subjectId);
118
+ if (!connection) {
119
+ throw new HTTPException(404, { message: "connection not found" });
120
+ }
121
+ return c.json(ConnectionResponse.parse({ connection }));
122
+ });
123
+
124
+ app.post("/v1/workspaces/:workspaceId/connections/oauth/start", async (c) => {
125
+ assertIntegrationsEnabled();
126
+ const workspaceId = c.req.param("workspaceId");
127
+ const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
128
+ const parsed = OAuthStartRequest.safeParse(await c.req.json());
129
+ if (!parsed.success) {
130
+ throw new HTTPException(400, { message: parsed.error.issues[0]?.message ?? "invalid OAuth start request" });
131
+ }
132
+ const payload = parsed.data;
133
+ const result = await startMcpOAuth({ db, settings }, {
134
+ accountId: grant.accountId,
135
+ workspaceId,
136
+ subjectId: grant.subjectId,
137
+ requestUrl: c.req.url,
138
+ payload,
139
+ });
140
+ return c.json(OAuthStartResponse.parse(result));
141
+ });
142
+
143
+ app.get("/v1/integrations/oauth/callback", async (c) => {
144
+ assertIntegrationsEnabled();
145
+ const result = await completeMcpOAuthCallback({ db, settings }, {
146
+ code: c.req.query("code"),
147
+ state: c.req.query("state"),
148
+ requestUrl: c.req.url,
149
+ });
150
+ return c.redirect(result.redirectTo, 302);
151
+ });
152
+
153
+ app.get("/v1/integrations/oauth/client-metadata.json", (c) => {
154
+ const baseUrl = integrationBaseUrl(settings.publicBaseUrl, c.req.url);
155
+ const metadataUrl = `${baseUrl}/v1/integrations/oauth/client-metadata.json`;
156
+ return c.json(IntegrationClientMetadata.parse({
157
+ client_id: metadataUrl,
158
+ client_name: "OpenGeni",
159
+ redirect_uris: [`${baseUrl}/v1/integrations/oauth/callback`],
160
+ token_endpoint_auth_method: "none",
161
+ grant_types: ["authorization_code", "refresh_token"],
162
+ response_types: ["code"],
163
+ }));
164
+ });
165
+ }
166
+
167
+ function writableSubjectId(requested: string | null | undefined, grantSubjectId: string): string | null {
168
+ if (requested == null) {
169
+ return null;
170
+ }
171
+ if (requested !== grantSubjectId) {
172
+ throw new HTTPException(403, { message: "cannot write a connection for another subject" });
173
+ }
174
+ return requested;
175
+ }
176
+
177
+ function encryptCredentialBundle(key: Uint8Array, credential: Record<string, unknown>): string {
178
+ return encryptEnvironmentValue(key, JSON.stringify(credential));
179
+ }
@@ -1,10 +1,20 @@
1
1
  import {
2
2
  AddDocumentRequest,
3
+ CreateKnowledgeMemoryRequest,
3
4
  CreateDocumentBaseRequest,
4
5
  Document,
5
6
  DocumentBase,
6
7
  DocumentSearchRequest,
8
+ KnowledgeMemory,
9
+ KnowledgeMemorySearchRequest,
10
+ UpdateKnowledgeMemoryRequest,
7
11
  } from "@opengeni/contracts";
12
+ import {
13
+ createKnowledgeMemory,
14
+ getKnowledgeMemory,
15
+ listKnowledgeMemories,
16
+ updateKnowledgeMemory,
17
+ } from "@opengeni/db";
8
18
  import {
9
19
  addDocumentToBase,
10
20
  createDocumentBase,
@@ -59,7 +69,7 @@ export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
59
69
  await requireLimit(deps, { accountId: grant.accountId, workspaceId, action: "document:index", quantity: 0 });
60
70
  const payload = AddDocumentRequest.parse(await c.req.json());
61
71
  try {
62
- const document = await addDocumentToBase(db, { accountId: grant.accountId, workspaceId, baseId: c.req.param("baseId"), fileId: payload.fileId });
72
+ const document = await addDocumentToBase(db, { ...payload, accountId: grant.accountId, workspaceId, baseId: c.req.param("baseId") });
63
73
  const wasCreated = document.status === "queued" && document.chunkCount === 0 && document.error === null;
64
74
  const indexed = document.status === "ready" ? document : (await documentIndexer.indexDocument({ accountId: grant.accountId, workspaceId, documentId: document.id }) ?? document);
65
75
  if (indexed.status === "ready") {
@@ -159,15 +169,89 @@ export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
159
169
  baseIds: [base.id],
160
170
  query: payload.query,
161
171
  limit: payload.limit,
172
+ mode: payload.mode,
173
+ sourceKinds: payload.sourceKinds,
174
+ aclTags: payload.aclTags,
162
175
  }, getDocumentServices()),
163
176
  });
164
177
  });
165
178
 
166
- app.all("/v1/workspaces/:workspaceId/mcp/docs", async (c) => {
179
+ app.post("/v1/workspaces/:workspaceId/knowledge/search", async (c) => {
167
180
  const workspaceId = c.req.param("workspaceId");
168
181
  await requireAccessGrant(c, deps, workspaceId, "documents:search");
182
+ const payload = DocumentSearchRequest.parse(await c.req.json());
183
+ return c.json({
184
+ results: await searchDocuments(db, {
185
+ workspaceId,
186
+ query: payload.query,
187
+ baseIds: payload.baseIds,
188
+ limit: payload.limit,
189
+ mode: payload.mode,
190
+ sourceKinds: payload.sourceKinds,
191
+ aclTags: payload.aclTags,
192
+ }, getDocumentServices()),
193
+ });
194
+ });
195
+
196
+ app.get("/v1/workspaces/:workspaceId/knowledge/memories", async (c) => {
197
+ const workspaceId = c.req.param("workspaceId");
198
+ await requireAccessGrant(c, deps, workspaceId, "documents:search");
199
+ const parsed = KnowledgeMemorySearchRequest.safeParse({
200
+ query: c.req.query("query") || undefined,
201
+ status: c.req.query("status") || undefined,
202
+ kind: c.req.query("kind") || undefined,
203
+ scope: c.req.query("scope") || undefined,
204
+ limit: c.req.query("limit") ? Number(c.req.query("limit")) : undefined,
205
+ });
206
+ if (!parsed.success) {
207
+ throw new HTTPException(400, { message: "invalid knowledge memory query parameters" });
208
+ }
209
+ return c.json((await listKnowledgeMemories(db, workspaceId, parsed.data)).map((memory) => KnowledgeMemory.parse(memory)));
210
+ });
211
+
212
+ app.get("/v1/workspaces/:workspaceId/knowledge/memories/:memoryId", async (c) => {
213
+ const workspaceId = c.req.param("workspaceId");
214
+ await requireAccessGrant(c, deps, workspaceId, "documents:search");
215
+ const memory = await getKnowledgeMemory(db, workspaceId, c.req.param("memoryId"));
216
+ if (!memory) {
217
+ throw new HTTPException(404, { message: "knowledge memory not found" });
218
+ }
219
+ return c.json(KnowledgeMemory.parse(memory));
220
+ });
221
+
222
+ app.post("/v1/workspaces/:workspaceId/knowledge/memories", async (c) => {
223
+ const workspaceId = c.req.param("workspaceId");
224
+ const grant = await requireAccessGrant(c, deps, workspaceId, "documents:manage");
225
+ const payload = CreateKnowledgeMemoryRequest.parse(await c.req.json());
226
+ return c.json(KnowledgeMemory.parse(await createKnowledgeMemory(db, {
227
+ ...payload,
228
+ accountId: grant.accountId,
229
+ workspaceId,
230
+ })), 201);
231
+ });
232
+
233
+ app.patch("/v1/workspaces/:workspaceId/knowledge/memories/:memoryId", async (c) => {
234
+ const workspaceId = c.req.param("workspaceId");
235
+ const grant = await requireAccessGrant(c, deps, workspaceId, "documents:manage");
236
+ const payload = UpdateKnowledgeMemoryRequest.parse(await c.req.json());
237
+ const reviewedBy = payload.reviewedBy
238
+ ?? (payload.status === "approved" || payload.status === "rejected" ? grant.subjectLabel ?? grant.subjectId : undefined);
239
+ try {
240
+ return c.json(KnowledgeMemory.parse(await updateKnowledgeMemory(db, workspaceId, c.req.param("memoryId"), {
241
+ ...payload,
242
+ ...(reviewedBy ? { reviewedBy } : {}),
243
+ })));
244
+ } catch (error) {
245
+ throw documentHttpException(error);
246
+ }
247
+ });
248
+
249
+ app.all("/v1/workspaces/:workspaceId/mcp/docs", async (c) => {
250
+ const workspaceId = c.req.param("workspaceId");
251
+ const grant = await requireAccessGrant(c, deps, workspaceId, "documents:search");
252
+ const sessionId = typeof grant.metadata?.sessionId === "string" ? grant.metadata.sessionId : undefined;
169
253
  const transport = new WebStandardStreamableHTTPServerTransport({ enableJsonResponse: true });
170
- const server = buildDocumentsMcpServer(db, workspaceId, getDocumentServices());
254
+ const server = buildDocumentsMcpServer(db, grant.accountId, workspaceId, getDocumentServices(), { createdBySessionId: sessionId });
171
255
  await server.connect(transport);
172
256
  return await transport.handleRequest(c.req.raw);
173
257
  });
@@ -286,6 +286,7 @@ export function registerEnrollmentRoutes(app: Hono, deps: ApiRouteDeps): void {
286
286
  pubkey: row.pubkey,
287
287
  exposure: row.exposure,
288
288
  hasDisplay: row.hasDisplay,
289
+ desktopUnavailableReason: row.desktopUnavailableReason,
289
290
  allowScreenControl: row.allowScreenControl,
290
291
  status: row.status,
291
292
  os: row.os,
@@ -140,7 +140,7 @@ export async function withChannelA<T>(
140
140
  const settingsForSession = session.sandboxBackend !== settings.sandboxBackend
141
141
  ? { ...settings, sandboxBackend: session.sandboxBackend }
142
142
  : settings;
143
- const environment = stableSandboxEnvironmentForRun(settingsForSession, workspaceEnvironment?.values ?? {});
143
+ const environment = stableSandboxEnvironmentForRun(settingsForSession, workspaceEnvironment?.values ?? {}, { workspaceId });
144
144
  if (hasGitHubRepositorySelection(session.resources)) {
145
145
  applyGitAuthPointerEnvironment(environment, githubAppBotIdentity(settings));
146
146
  }
@@ -199,6 +199,7 @@ export async function listMachines(
199
199
  os: "linux",
200
200
  arch: "x86_64",
201
201
  hasDisplay: false,
202
+ desktopUnavailableReason: null,
202
203
  allowScreenControl: false,
203
204
  sharedSessionCount: 1,
204
205
  lastSeenAt: null,
@@ -244,6 +245,7 @@ export async function listMachines(
244
245
  os: enrollment.os,
245
246
  arch: enrollment.arch,
246
247
  hasDisplay: enrollment.hasDisplay,
248
+ desktopUnavailableReason: enrollment.desktopUnavailableReason,
247
249
  allowScreenControl: enrollment.allowScreenControl,
248
250
  sharedSessionCount,
249
251
  lastSeenAt: enrollment.lastSeenAt,
@@ -30,7 +30,7 @@
30
30
  import {
31
31
  getEnrollment,
32
32
  ingestMachineMetricsSample,
33
- setEnrollmentHasDisplay,
33
+ setEnrollmentDisplayState,
34
34
  touchEnrollmentLastSeen,
35
35
  type Database,
36
36
  type MachineMetricsSample,
@@ -203,33 +203,63 @@ export function helloReportsDisplay(hello: Hello): boolean {
203
203
  if (!caps) {
204
204
  return false;
205
205
  }
206
+ // A CAPTURE-BLOCKED display is NOT a usable display: a Mac reports a display but
207
+ // withholds `desktop` and sets `desktopUnavailableReason` when Screen Recording
208
+ // (TCC) is not granted. Treating it as "has display" is exactly how the 0.1.3
209
+ // incident hid — the machine claimed a desktop it could not capture, so it was
210
+ // offered for computer-use and the model saw a blank. Gate it out here (the single
211
+ // source of truth for `has_display`, consumed by both the machine state and the
212
+ // capability negotiation). The `display`-present fallback is preserved for every
213
+ // other case (e.g. a relay-less agent that reports a display but not `desktop`).
214
+ if (caps.desktopUnavailableReason) {
215
+ return false;
216
+ }
206
217
  return caps.desktop === true || caps.display != null;
207
218
  }
208
219
 
209
220
  /**
210
- * Reconcile `enrollments.has_display` to the display presence a Hello reports.
211
- * Resolves the enrollment (the accountId is the RLS principal + the existence
212
- * check + the current value). A no-change Hello short-circuits BEFORE issuing any
213
- * write (and the DB writer is itself change-guarded as a backstop), so a steady
214
- * state never churns. An unknown/cross-workspace agentId is a no-op.
221
+ * The human, actionable reason a display is present but UNUSABLE (macOS Screen
222
+ * Recording / TCC not granted), or null when capture is permitted / the machine is
223
+ * headless. Normalizes the proto's non-optional "" empty string to null so the DB
224
+ * carries a clean tri-state (a real reason vs. no reason) the Machines dashboard
225
+ * shows "display: capture not granted" only when this is non-null.
226
+ */
227
+ export function helloDesktopUnavailableReason(hello: Hello): string | null {
228
+ const reason = hello.capabilities?.desktopUnavailableReason;
229
+ return reason ? reason : null;
230
+ }
231
+
232
+ /**
233
+ * Reconcile `enrollments.has_display` (+ the capture-blocked reason) to what a Hello
234
+ * reports. Resolves the enrollment (the accountId is the RLS principal + the
235
+ * existence check + the current values). A no-change Hello short-circuits BEFORE
236
+ * issuing any write (and the DB writer is itself change-guarded on BOTH fields as a
237
+ * backstop), so a steady state never churns. An unknown/cross-workspace agentId is a
238
+ * no-op.
215
239
  */
216
240
  export async function refreshEnrollmentDisplay(
217
241
  db: Database,
218
- input: { workspaceId: string; agentId: string; hasDisplay: boolean },
242
+ input: { workspaceId: string; agentId: string; hasDisplay: boolean; desktopUnavailableReason?: string | null },
219
243
  ): Promise<{ updated: boolean }> {
244
+ const desktopUnavailableReason = input.desktopUnavailableReason ?? null;
220
245
  const enrollment = await getEnrollment(db, input.workspaceId, input.agentId);
221
246
  if (!enrollment) {
222
247
  return { updated: false };
223
248
  }
224
- if (enrollment.hasDisplay === input.hasDisplay) {
225
- // Unchanged do not even issue the UPDATE (no churn on a steady-state Hello).
249
+ if (
250
+ enrollment.hasDisplay === input.hasDisplay &&
251
+ (enrollment.desktopUnavailableReason ?? null) === desktopUnavailableReason
252
+ ) {
253
+ // Both fields unchanged — do not even issue the UPDATE (no churn on a
254
+ // steady-state Hello).
226
255
  return { updated: false };
227
256
  }
228
- return await setEnrollmentHasDisplay(db, {
257
+ return await setEnrollmentDisplayState(db, {
229
258
  accountId: enrollment.accountId,
230
259
  workspaceId: input.workspaceId,
231
260
  enrollmentId: input.agentId,
232
261
  hasDisplay: input.hasDisplay,
262
+ desktopUnavailableReason,
233
263
  });
234
264
  }
235
265
 
@@ -263,6 +293,7 @@ export async function handleHelloPayload(
263
293
  workspaceId: ids.workspaceId,
264
294
  agentId: ids.agentId,
265
295
  hasDisplay: helloReportsDisplay(hello),
296
+ desktopUnavailableReason: helloDesktopUnavailableReason(hello),
266
297
  });
267
298
  } catch (error) {
268
299
  observability?.warn?.("Failed to refresh an enrollment's display from a Hello", {
@@ -136,7 +136,7 @@ export async function sessionAttachEnvironment(
136
136
  const settingsForSession = session.sandboxBackend !== services.settings.sandboxBackend
137
137
  ? { ...services.settings, sandboxBackend: session.sandboxBackend }
138
138
  : services.settings;
139
- const environment = stableSandboxEnvironmentForRun(settingsForSession, workspaceEnvironment?.values ?? {});
139
+ const environment = stableSandboxEnvironmentForRun(settingsForSession, workspaceEnvironment?.values ?? {}, { workspaceId });
140
140
  if (hasGitHubRepositorySelection(session.resources)) {
141
141
  applyGitAuthPointerEnvironment(environment, githubAppBotIdentity(services.settings));
142
142
  }