@opengeni/db 0.4.1 → 0.6.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.
package/src/index.ts CHANGED
@@ -9,9 +9,17 @@ import type {
9
9
  CapabilityKind,
10
10
  CapabilityPack,
11
11
  CapabilitySource,
12
+ ConnectionKind,
13
+ ConnectionMetadata,
14
+ ConnectionStatus,
15
+ McpServerConnectionRef,
12
16
  FileAsset,
13
17
  FileStatus,
14
18
  FileUploadStatus,
19
+ KnowledgeMemory,
20
+ KnowledgeMemoryKind,
21
+ KnowledgeMemoryStatus,
22
+ KnowledgeSourceRef,
15
23
  ManagedAccount,
16
24
  Permission,
17
25
  PackInstallation,
@@ -58,7 +66,7 @@ import { isCodexBilledModel } from "@opengeni/codex";
58
66
  // Re-exported so consumers get the whole codex-billed detection surface (the pure
59
67
  // prefix test + the credential-aware predicates below) from a single import.
60
68
  export { isCodexBilledModel } from "@opengeni/codex";
61
- import { and, asc, desc, eq, gt, gte, inArray, lt, ne, sql, type SQL } from "drizzle-orm";
69
+ import { and, asc, desc, eq, gt, gte, inArray, isNull, lt, ne, or, sql, type SQL } from "drizzle-orm";
62
70
  import type { PgDatabase } from "drizzle-orm/pg-core";
63
71
  import { drizzle } from "drizzle-orm/postgres-js";
64
72
  import postgres from "postgres";
@@ -347,6 +355,8 @@ export const allWorkspacePermissions: Permission[] = [
347
355
  "github:manage",
348
356
  "github:use",
349
357
  "api_keys:manage",
358
+ "connections:read",
359
+ "connections:write",
350
360
  "environments:manage",
351
361
  "environments:use",
352
362
  "mcp_servers:attach",
@@ -1132,6 +1142,38 @@ export type RegisterWorkspacePackInput = {
1132
1142
  pack: CapabilityPack;
1133
1143
  };
1134
1144
 
1145
+ export type CreateKnowledgeMemoryInput = {
1146
+ accountId: string;
1147
+ workspaceId: string;
1148
+ status?: KnowledgeMemoryStatus | undefined;
1149
+ kind?: KnowledgeMemoryKind | undefined;
1150
+ scope?: string | undefined;
1151
+ text: string;
1152
+ sourceRefs?: KnowledgeSourceRef[] | undefined;
1153
+ confidence?: number | undefined;
1154
+ metadata?: Record<string, unknown> | undefined;
1155
+ createdBySessionId?: string | null | undefined;
1156
+ };
1157
+
1158
+ export type UpdateKnowledgeMemoryInput = {
1159
+ status?: KnowledgeMemoryStatus | undefined;
1160
+ kind?: KnowledgeMemoryKind | undefined;
1161
+ scope?: string | undefined;
1162
+ text?: string | undefined;
1163
+ sourceRefs?: KnowledgeSourceRef[] | undefined;
1164
+ confidence?: number | undefined;
1165
+ metadata?: Record<string, unknown> | undefined;
1166
+ reviewedBy?: string | null | undefined;
1167
+ };
1168
+
1169
+ export type ListKnowledgeMemoryOptions = {
1170
+ query?: string | undefined;
1171
+ status?: KnowledgeMemoryStatus | undefined;
1172
+ kind?: KnowledgeMemoryKind | undefined;
1173
+ scope?: string | undefined;
1174
+ limit?: number | undefined;
1175
+ };
1176
+
1135
1177
  export type CreateSocialConnectionInput = {
1136
1178
  accountId: string;
1137
1179
  workspaceId: string;
@@ -1159,6 +1201,95 @@ export type CreateSocialPostInput = {
1159
1201
  raw?: Record<string, unknown>;
1160
1202
  };
1161
1203
 
1204
+ export type CreateConnectionInput = {
1205
+ accountId: string;
1206
+ workspaceId: string;
1207
+ subjectId?: string | null;
1208
+ providerDomain: string;
1209
+ kind: ConnectionKind;
1210
+ status?: ConnectionStatus;
1211
+ credentialEncrypted: string;
1212
+ grantedScopes?: string[];
1213
+ expiresAt?: Date | null;
1214
+ metadata?: Record<string, unknown>;
1215
+ createdBySubjectId?: string | null;
1216
+ updatedBySubjectId?: string | null;
1217
+ };
1218
+
1219
+ export type UpdateConnectionInput = {
1220
+ workspaceId: string;
1221
+ connectionId: string;
1222
+ visibleToSubjectId?: string | null;
1223
+ expectedVersion?: number | undefined;
1224
+ subjectId?: string | null;
1225
+ providerDomain?: string;
1226
+ kind?: ConnectionKind;
1227
+ status?: ConnectionStatus;
1228
+ credentialEncrypted?: string;
1229
+ grantedScopes?: string[];
1230
+ expiresAt?: Date | null;
1231
+ metadata?: Record<string, unknown>;
1232
+ updatedBySubjectId?: string | null;
1233
+ };
1234
+
1235
+ export type ConnectionCredentialForBroker = {
1236
+ id: string;
1237
+ accountId: string;
1238
+ workspaceId: string;
1239
+ subjectId: string | null;
1240
+ providerDomain: string;
1241
+ kind: ConnectionKind;
1242
+ status: ConnectionStatus;
1243
+ credential: Record<string, unknown>;
1244
+ grantedScopes: string[];
1245
+ expiresAt: Date | null;
1246
+ lastRefreshAt: Date | null;
1247
+ version: number;
1248
+ metadata: Record<string, unknown>;
1249
+ };
1250
+
1251
+ export type IntegrationOAuthClientForUse = {
1252
+ id: string;
1253
+ issuer: string;
1254
+ authorizationServer: string;
1255
+ clientId: string;
1256
+ clientSecret: string | null;
1257
+ tokenEndpointAuthMethod: string;
1258
+ metadata: Record<string, unknown>;
1259
+ createdAt: Date;
1260
+ updatedAt: Date;
1261
+ };
1262
+
1263
+ export type StoredIntegrationOAuthClient = {
1264
+ id: string;
1265
+ issuer: string;
1266
+ authorizationServer: string;
1267
+ clientId: string;
1268
+ clientSecretEncrypted: string | null;
1269
+ tokenEndpointAuthMethod: string;
1270
+ metadata: Record<string, unknown>;
1271
+ createdAt: Date;
1272
+ updatedAt: Date;
1273
+ };
1274
+
1275
+ export type StoreIntegrationOAuthClientInput = {
1276
+ issuer: string;
1277
+ authorizationServer: string;
1278
+ clientId: string;
1279
+ clientSecretEncrypted?: string | null;
1280
+ tokenEndpointAuthMethod?: string;
1281
+ metadata?: Record<string, unknown>;
1282
+ };
1283
+
1284
+ export type ConsumeOAuthStateNonceInput = {
1285
+ accountId: string;
1286
+ workspaceId: string;
1287
+ subjectId: string;
1288
+ nonce: string;
1289
+ expiresAt: Date;
1290
+ now: Date;
1291
+ };
1292
+
1162
1293
  export type CreateCapabilityCatalogItemInput = {
1163
1294
  accountId: string;
1164
1295
  workspaceId: string;
@@ -1176,6 +1307,69 @@ export type CreateCapabilityCatalogItemInput = {
1176
1307
  metadata?: Record<string, unknown>;
1177
1308
  };
1178
1309
 
1310
+ export type ImportBatch = {
1311
+ id: string;
1312
+ source: string;
1313
+ snapshotDate: string;
1314
+ snapshotRef: string | null;
1315
+ attributionNote: string;
1316
+ importedCount: number;
1317
+ skippedCount: number;
1318
+ quarantinedCount: number;
1319
+ logoFailureCount: number;
1320
+ staleCount: number;
1321
+ details: Record<string, unknown>;
1322
+ createdAt: string;
1323
+ updatedAt: string;
1324
+ };
1325
+
1326
+ export type CreateImportBatchInput = {
1327
+ source: string;
1328
+ snapshotDate: Date;
1329
+ snapshotRef?: string | null;
1330
+ attributionNote: string;
1331
+ importedCount?: number;
1332
+ skippedCount?: number;
1333
+ quarantinedCount?: number;
1334
+ logoFailureCount?: number;
1335
+ staleCount?: number;
1336
+ details?: Record<string, unknown>;
1337
+ };
1338
+
1339
+ export type UpdateImportBatchCountsInput = {
1340
+ importedCount: number;
1341
+ skippedCount: number;
1342
+ quarantinedCount: number;
1343
+ logoFailureCount: number;
1344
+ staleCount: number;
1345
+ details?: Record<string, unknown>;
1346
+ };
1347
+
1348
+ export type RegistryCapabilityCatalogItemInput = {
1349
+ id: string;
1350
+ providerDomain: string;
1351
+ name: string;
1352
+ description?: string | null;
1353
+ mcpUrl: string;
1354
+ transport: string;
1355
+ authKind: "oauth2" | "api_key" | "none" | "unknown";
1356
+ credentialFacts: Array<Record<string, unknown>>;
1357
+ tier: "verified" | "community";
1358
+ provenance: string;
1359
+ logoAssetPath?: string | null;
1360
+ importBatchId: string;
1361
+ scopesHint?: string[];
1362
+ homepageUrl?: string | null;
1363
+ tags?: string[];
1364
+ metadata?: Record<string, unknown>;
1365
+ };
1366
+
1367
+ export type RegistryCatalogSurfaceKey = {
1368
+ id: string;
1369
+ providerDomain: string;
1370
+ mcpUrl: string;
1371
+ };
1372
+
1179
1373
  export type EnableCapabilityInstallationInput = {
1180
1374
  accountId: string;
1181
1375
  workspaceId: string;
@@ -1200,6 +1394,7 @@ export type EnabledMcpCapabilityServer = {
1200
1394
  * capability API surface.
1201
1395
  */
1202
1396
  headersEncrypted?: Record<string, string>;
1397
+ connectionRef?: McpServerConnectionRef;
1203
1398
  };
1204
1399
 
1205
1400
  export type CreateSessionMcpServerInput = {
@@ -1209,6 +1404,7 @@ export type CreateSessionMcpServerInput = {
1209
1404
  allowedTools?: string[] | null;
1210
1405
  timeoutMs?: number | null;
1211
1406
  cacheToolsList?: boolean | null;
1407
+ requireApproval?: boolean | string[] | null;
1212
1408
  headersEncrypted?: Record<string, string>;
1213
1409
  };
1214
1410
 
@@ -1226,6 +1422,7 @@ export type SessionMcpServerForRun = SessionMcpServerMetadata & {
1226
1422
  allowedTools?: string[];
1227
1423
  timeoutMs?: number;
1228
1424
  cacheToolsList?: boolean;
1425
+ requireApproval?: boolean | string[];
1229
1426
  headers: Record<string, string>;
1230
1427
  };
1231
1428
 
@@ -1485,6 +1682,152 @@ export async function deleteWorkspacePack(db: Database, workspaceId: string, pac
1485
1682
  });
1486
1683
  }
1487
1684
 
1685
+ const registryCapabilitySource = "registry" as CapabilitySource;
1686
+
1687
+ export async function createImportBatch(db: Database, input: CreateImportBatchInput): Promise<ImportBatch> {
1688
+ const [row] = await db.insert(schema.importBatches).values({
1689
+ source: input.source,
1690
+ snapshotDate: input.snapshotDate,
1691
+ snapshotRef: input.snapshotRef ?? null,
1692
+ attributionNote: input.attributionNote,
1693
+ importedCount: input.importedCount ?? 0,
1694
+ skippedCount: input.skippedCount ?? 0,
1695
+ quarantinedCount: input.quarantinedCount ?? 0,
1696
+ logoFailureCount: input.logoFailureCount ?? 0,
1697
+ staleCount: input.staleCount ?? 0,
1698
+ details: input.details ?? {},
1699
+ }).returning();
1700
+ if (!row) {
1701
+ throw new Error("Failed to create import batch");
1702
+ }
1703
+ return mapImportBatch(row);
1704
+ }
1705
+
1706
+ export async function updateImportBatchCounts(db: Database, id: string, input: UpdateImportBatchCountsInput): Promise<ImportBatch> {
1707
+ const [row] = await db.update(schema.importBatches).set({
1708
+ importedCount: input.importedCount,
1709
+ skippedCount: input.skippedCount,
1710
+ quarantinedCount: input.quarantinedCount,
1711
+ logoFailureCount: input.logoFailureCount,
1712
+ staleCount: input.staleCount,
1713
+ ...(input.details ? { details: input.details } : {}),
1714
+ updatedAt: new Date(),
1715
+ }).where(eq(schema.importBatches.id, id)).returning();
1716
+ if (!row) {
1717
+ throw new Error(`Import batch not found: ${id}`);
1718
+ }
1719
+ return mapImportBatch(row);
1720
+ }
1721
+
1722
+ export async function upsertRegistryCapabilityCatalogItem(db: Database, input: RegistryCapabilityCatalogItemInput): Promise<CapabilityCatalogItem> {
1723
+ const now = new Date();
1724
+ const metadata = {
1725
+ registry: "integrations.sh",
1726
+ providerDomain: input.providerDomain,
1727
+ scopesHint: input.scopesHint ?? [],
1728
+ ...input.metadata,
1729
+ };
1730
+ const values = {
1731
+ id: input.id,
1732
+ accountId: null,
1733
+ workspaceId: null,
1734
+ kind: "mcp" as Exclude<CapabilityKind, "pack">,
1735
+ source: registryCapabilitySource,
1736
+ name: input.name,
1737
+ description: input.description ?? null,
1738
+ category: "integrations",
1739
+ tags: input.tags ?? ["mcp", "integration", input.tier],
1740
+ homepageUrl: input.homepageUrl ?? `https://${input.providerDomain}`,
1741
+ endpointUrl: input.mcpUrl,
1742
+ installUrl: input.homepageUrl ?? `https://${input.providerDomain}`,
1743
+ authModel: input.authKind === "none" ? null : "credential_ref",
1744
+ providerDomain: input.providerDomain,
1745
+ surfaceType: "mcp",
1746
+ transport: input.transport,
1747
+ mcpUrl: input.mcpUrl,
1748
+ authKind: input.authKind,
1749
+ credentialFacts: input.credentialFacts,
1750
+ tier: input.tier,
1751
+ provenance: input.provenance,
1752
+ logoAssetPath: input.logoAssetPath ?? null,
1753
+ importBatchId: input.importBatchId,
1754
+ stale: false,
1755
+ staleAt: null,
1756
+ metadata,
1757
+ updatedAt: now,
1758
+ };
1759
+ const updateValues = {
1760
+ id: values.id,
1761
+ kind: values.kind,
1762
+ name: values.name,
1763
+ description: values.description,
1764
+ category: values.category,
1765
+ tags: values.tags,
1766
+ homepageUrl: values.homepageUrl,
1767
+ endpointUrl: values.endpointUrl,
1768
+ installUrl: values.installUrl,
1769
+ authModel: values.authModel,
1770
+ surfaceType: values.surfaceType,
1771
+ transport: values.transport,
1772
+ authKind: values.authKind,
1773
+ credentialFacts: values.credentialFacts,
1774
+ tier: values.tier,
1775
+ provenance: values.provenance,
1776
+ logoAssetPath: sql`coalesce(excluded.logo_asset_path, ${schema.capabilityCatalogItems.logoAssetPath})`,
1777
+ importBatchId: values.importBatchId,
1778
+ stale: false,
1779
+ staleAt: null,
1780
+ metadata: values.metadata,
1781
+ updatedAt: values.updatedAt,
1782
+ };
1783
+ const [row] = await db.insert(schema.capabilityCatalogItems).values(values)
1784
+ .onConflictDoUpdate({
1785
+ target: [
1786
+ schema.capabilityCatalogItems.source,
1787
+ schema.capabilityCatalogItems.providerDomain,
1788
+ schema.capabilityCatalogItems.mcpUrl,
1789
+ ],
1790
+ set: updateValues,
1791
+ })
1792
+ .returning();
1793
+ if (!row) {
1794
+ throw new Error("Failed to upsert registry capability catalog item");
1795
+ }
1796
+ return mapCapabilityCatalogItem(row);
1797
+ }
1798
+
1799
+ export async function listRegistryCatalogSurfaceKeys(db: Database): Promise<RegistryCatalogSurfaceKey[]> {
1800
+ const rows = await db.select({
1801
+ id: schema.capabilityCatalogItems.id,
1802
+ providerDomain: schema.capabilityCatalogItems.providerDomain,
1803
+ mcpUrl: schema.capabilityCatalogItems.mcpUrl,
1804
+ }).from(schema.capabilityCatalogItems)
1805
+ .where(eq(schema.capabilityCatalogItems.source, registryCapabilitySource));
1806
+ return rows.flatMap((row) => row.providerDomain && row.mcpUrl
1807
+ ? [{ id: row.id, providerDomain: row.providerDomain, mcpUrl: row.mcpUrl }]
1808
+ : []);
1809
+ }
1810
+
1811
+ export async function markStaleRegistryCatalogItems(db: Database, activeKeys: Iterable<{ providerDomain: string; mcpUrl: string }>, importBatchId: string): Promise<number> {
1812
+ const active = new Set([...activeKeys].map((key) => `${key.providerDomain}\n${key.mcpUrl}`));
1813
+ const existing = await listRegistryCatalogSurfaceKeys(db);
1814
+ const stale = existing.filter((row) => !active.has(`${row.providerDomain}\n${row.mcpUrl}`));
1815
+ if (stale.length === 0) {
1816
+ return 0;
1817
+ }
1818
+ const now = new Date();
1819
+ const updated = await db.update(schema.capabilityCatalogItems).set({
1820
+ stale: true,
1821
+ staleAt: now,
1822
+ importBatchId,
1823
+ updatedAt: now,
1824
+ }).where(and(
1825
+ eq(schema.capabilityCatalogItems.source, registryCapabilitySource),
1826
+ inArray(schema.capabilityCatalogItems.id, stale.map((row) => row.id)),
1827
+ )).returning({ id: schema.capabilityCatalogItems.id });
1828
+ return updated.length;
1829
+ }
1830
+
1488
1831
  export async function upsertCapabilityCatalogItem(db: Database, input: CreateCapabilityCatalogItemInput): Promise<CapabilityCatalogItem> {
1489
1832
  return await withRlsContext(db, { accountId: input.accountId, workspaceId: input.workspaceId }, async (scopedDb) => {
1490
1833
  const now = new Date();
@@ -1502,6 +1845,18 @@ export async function upsertCapabilityCatalogItem(db: Database, input: CreateCap
1502
1845
  endpointUrl: input.endpointUrl ?? null,
1503
1846
  installUrl: input.installUrl ?? null,
1504
1847
  authModel: input.authModel ?? null,
1848
+ providerDomain: null,
1849
+ surfaceType: null,
1850
+ transport: null,
1851
+ mcpUrl: null,
1852
+ authKind: null,
1853
+ credentialFacts: [],
1854
+ tier: null,
1855
+ provenance: null,
1856
+ logoAssetPath: null,
1857
+ importBatchId: null,
1858
+ stale: false,
1859
+ staleAt: null,
1505
1860
  metadata: input.metadata ?? {},
1506
1861
  updatedAt: now,
1507
1862
  };
@@ -1516,6 +1871,18 @@ export async function upsertCapabilityCatalogItem(db: Database, input: CreateCap
1516
1871
  endpointUrl: values.endpointUrl,
1517
1872
  installUrl: values.installUrl,
1518
1873
  authModel: values.authModel,
1874
+ providerDomain: values.providerDomain,
1875
+ surfaceType: values.surfaceType,
1876
+ transport: values.transport,
1877
+ mcpUrl: values.mcpUrl,
1878
+ authKind: values.authKind,
1879
+ credentialFacts: values.credentialFacts,
1880
+ tier: values.tier,
1881
+ provenance: values.provenance,
1882
+ logoAssetPath: values.logoAssetPath,
1883
+ importBatchId: values.importBatchId,
1884
+ stale: values.stale,
1885
+ staleAt: values.staleAt,
1519
1886
  metadata: values.metadata,
1520
1887
  updatedAt: values.updatedAt,
1521
1888
  };
@@ -1535,7 +1902,16 @@ export async function upsertCapabilityCatalogItem(db: Database, input: CreateCap
1535
1902
  export async function listCapabilityCatalogItems(db: Database, workspaceId: string): Promise<CapabilityCatalogItem[]> {
1536
1903
  return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
1537
1904
  const rows = await scopedDb.select().from(schema.capabilityCatalogItems)
1538
- .where(eq(schema.capabilityCatalogItems.workspaceId, workspaceId))
1905
+ .where(or(
1906
+ eq(schema.capabilityCatalogItems.workspaceId, workspaceId),
1907
+ and(
1908
+ isNull(schema.capabilityCatalogItems.workspaceId),
1909
+ or(
1910
+ ne(schema.capabilityCatalogItems.source, registryCapabilitySource),
1911
+ eq(schema.capabilityCatalogItems.stale, false),
1912
+ ),
1913
+ ),
1914
+ ))
1539
1915
  .orderBy(asc(schema.capabilityCatalogItems.kind), asc(schema.capabilityCatalogItems.name));
1540
1916
  return rows.map(mapCapabilityCatalogItem);
1541
1917
  });
@@ -1544,7 +1920,11 @@ export async function listCapabilityCatalogItems(db: Database, workspaceId: stri
1544
1920
  export async function getCapabilityCatalogItem(db: Database, workspaceId: string, capabilityId: string): Promise<CapabilityCatalogItem | null> {
1545
1921
  return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
1546
1922
  const [row] = await scopedDb.select().from(schema.capabilityCatalogItems)
1547
- .where(and(eq(schema.capabilityCatalogItems.workspaceId, workspaceId), eq(schema.capabilityCatalogItems.id, capabilityId)))
1923
+ .where(and(
1924
+ eq(schema.capabilityCatalogItems.id, capabilityId),
1925
+ or(eq(schema.capabilityCatalogItems.workspaceId, workspaceId), isNull(schema.capabilityCatalogItems.workspaceId)),
1926
+ ))
1927
+ .orderBy(asc(sql`(${schema.capabilityCatalogItems.workspaceId} is null)`))
1548
1928
  .limit(1);
1549
1929
  return row ? mapCapabilityCatalogItem(row) : null;
1550
1930
  });
@@ -1625,7 +2005,10 @@ export async function listEnabledMcpCapabilityServers(db: Database, workspaceId:
1625
2005
  installation: schema.capabilityInstallations,
1626
2006
  }).from(schema.capabilityInstallations)
1627
2007
  .innerJoin(schema.capabilityCatalogItems, and(
1628
- eq(schema.capabilityInstallations.workspaceId, schema.capabilityCatalogItems.workspaceId),
2008
+ or(
2009
+ eq(schema.capabilityInstallations.workspaceId, schema.capabilityCatalogItems.workspaceId),
2010
+ isNull(schema.capabilityCatalogItems.workspaceId),
2011
+ ),
1629
2012
  eq(schema.capabilityInstallations.capabilityId, schema.capabilityCatalogItems.id),
1630
2013
  ))
1631
2014
  .where(and(
@@ -1635,14 +2018,28 @@ export async function listEnabledMcpCapabilityServers(db: Database, workspaceId:
1635
2018
  ))
1636
2019
  .orderBy(asc(schema.capabilityCatalogItems.name)));
1637
2020
 
1638
- return rows.flatMap(({ item, installation }) => {
2021
+ // A workspace-scoped catalog row and a global registry row can share the
2022
+ // same capability id; the join then matches one installation twice. Keep
2023
+ // one row per installation, preferring the workspace-scoped catalog row
2024
+ // (same precedence as getCapabilityCatalogItem).
2025
+ const preferredByInstallation = new Map<string, (typeof rows)[number]>();
2026
+ for (const row of rows) {
2027
+ const existing = preferredByInstallation.get(row.installation.id);
2028
+ if (!existing || (existing.item.workspaceId === null && row.item.workspaceId !== null)) {
2029
+ preferredByInstallation.set(row.installation.id, row);
2030
+ }
2031
+ }
2032
+
2033
+ return [...preferredByInstallation.values()].flatMap(({ item, installation }) => {
1639
2034
  if (!item.endpointUrl || !mcpConnectivityOk(installation.metadata)) {
1640
2035
  return [];
1641
2036
  }
1642
2037
  const headersEncrypted = encryptedHeadersConfig(installation.config.headersEncrypted);
1643
- if (item.authModel && !headersEncrypted) {
1644
- // Credential-gated MCPs are runnable only when credential headers were
1645
- // stored at enable time.
2038
+ const connectionRef = connectionRefConfig(installation.config.connectionRef);
2039
+ if (item.authModel && !headersEncrypted && !connectionRef) {
2040
+ // Credential-gated MCPs are runnable only when either legacy static
2041
+ // credential headers or the connections broker ref were stored at enable
2042
+ // time.
1646
2043
  return [];
1647
2044
  }
1648
2045
  const metadata = item.metadata;
@@ -1659,6 +2056,7 @@ export async function listEnabledMcpCapabilityServers(db: Database, workspaceId:
1659
2056
  ...(timeoutMs ? { timeoutMs } : {}),
1660
2057
  ...(cacheToolsList !== undefined ? { cacheToolsList } : {}),
1661
2058
  ...(headersEncrypted ? { headersEncrypted } : {}),
2059
+ ...(connectionRef ? { connectionRef } : {}),
1662
2060
  }];
1663
2061
  });
1664
2062
  }
@@ -1715,6 +2113,441 @@ export function mcpServerIdForCapability(capabilityId: string, metadata: Record<
1715
2113
  return `cap-${body}-${shortHash(capabilityId)}`;
1716
2114
  }
1717
2115
 
2116
+ const connectionMetadataColumns = {
2117
+ id: schema.connections.id,
2118
+ accountId: schema.connections.accountId,
2119
+ workspaceId: schema.connections.workspaceId,
2120
+ subjectId: schema.connections.subjectId,
2121
+ providerDomain: schema.connections.providerDomain,
2122
+ kind: schema.connections.kind,
2123
+ status: schema.connections.status,
2124
+ grantedScopes: schema.connections.grantedScopes,
2125
+ expiresAt: schema.connections.expiresAt,
2126
+ lastRefreshAt: schema.connections.lastRefreshAt,
2127
+ lastUsedAt: schema.connections.lastUsedAt,
2128
+ lastError: schema.connections.lastError,
2129
+ version: schema.connections.version,
2130
+ metadata: schema.connections.metadata,
2131
+ createdBySubjectId: schema.connections.createdBySubjectId,
2132
+ updatedBySubjectId: schema.connections.updatedBySubjectId,
2133
+ createdAt: schema.connections.createdAt,
2134
+ updatedAt: schema.connections.updatedAt,
2135
+ };
2136
+
2137
+ function connectionSubjectVisibility(subjectId?: string | null): SQL {
2138
+ return subjectId
2139
+ ? or(isNull(schema.connections.subjectId), eq(schema.connections.subjectId, subjectId))!
2140
+ : isNull(schema.connections.subjectId);
2141
+ }
2142
+
2143
+ export async function createConnection(db: Database, input: CreateConnectionInput): Promise<ConnectionMetadata> {
2144
+ return await withRlsContext(db, { accountId: input.accountId, workspaceId: input.workspaceId }, async (scopedDb) => {
2145
+ const [row] = await scopedDb.insert(schema.connections).values({
2146
+ accountId: input.accountId,
2147
+ workspaceId: input.workspaceId,
2148
+ subjectId: input.subjectId ?? null,
2149
+ providerDomain: input.providerDomain,
2150
+ kind: input.kind,
2151
+ status: input.status ?? "active",
2152
+ credentialEncrypted: input.credentialEncrypted,
2153
+ grantedScopes: input.grantedScopes ?? [],
2154
+ expiresAt: input.expiresAt ?? null,
2155
+ metadata: input.metadata ?? {},
2156
+ createdBySubjectId: input.createdBySubjectId ?? null,
2157
+ updatedBySubjectId: input.updatedBySubjectId ?? input.createdBySubjectId ?? null,
2158
+ }).returning(connectionMetadataColumns);
2159
+ if (!row) {
2160
+ throw new Error("Failed to create connection");
2161
+ }
2162
+ return mapConnectionMetadata(row);
2163
+ });
2164
+ }
2165
+
2166
+ export async function listConnectionsMetadata(db: Database, workspaceId: string, subjectId?: string | null): Promise<ConnectionMetadata[]> {
2167
+ return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
2168
+ const rows = await scopedDb.select(connectionMetadataColumns).from(schema.connections)
2169
+ .where(and(eq(schema.connections.workspaceId, workspaceId), connectionSubjectVisibility(subjectId)))
2170
+ .orderBy(desc(schema.connections.createdAt));
2171
+ return rows.map(mapConnectionMetadata);
2172
+ });
2173
+ }
2174
+
2175
+ export async function getConnectionMetadata(db: Database, workspaceId: string, connectionId: string, subjectId?: string | null): Promise<ConnectionMetadata | null> {
2176
+ return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
2177
+ const [row] = await scopedDb.select(connectionMetadataColumns).from(schema.connections)
2178
+ .where(and(
2179
+ eq(schema.connections.workspaceId, workspaceId),
2180
+ eq(schema.connections.id, connectionId),
2181
+ connectionSubjectVisibility(subjectId),
2182
+ ))
2183
+ .limit(1);
2184
+ return row ? mapConnectionMetadata(row) : null;
2185
+ });
2186
+ }
2187
+
2188
+ export async function updateConnection(db: Database, input: UpdateConnectionInput): Promise<ConnectionMetadata | null> {
2189
+ return await withWorkspaceRls(db, input.workspaceId, async (scopedDb) => {
2190
+ const set = {
2191
+ updatedAt: new Date(),
2192
+ ...(input.providerDomain !== undefined ? { providerDomain: input.providerDomain } : {}),
2193
+ ...(input.subjectId !== undefined ? { subjectId: input.subjectId } : {}),
2194
+ ...(input.kind !== undefined ? { kind: input.kind } : {}),
2195
+ ...(input.status !== undefined ? { status: input.status } : {}),
2196
+ ...(input.credentialEncrypted !== undefined
2197
+ ? {
2198
+ credentialEncrypted: input.credentialEncrypted,
2199
+ version: sql`${schema.connections.version} + 1`,
2200
+ lastError: null,
2201
+ }
2202
+ : {}),
2203
+ ...(input.grantedScopes !== undefined ? { grantedScopes: input.grantedScopes } : {}),
2204
+ ...(input.expiresAt !== undefined ? { expiresAt: input.expiresAt } : {}),
2205
+ ...(input.metadata !== undefined ? { metadata: input.metadata } : {}),
2206
+ ...(input.updatedBySubjectId !== undefined ? { updatedBySubjectId: input.updatedBySubjectId } : {}),
2207
+ };
2208
+ const [row] = await scopedDb.update(schema.connections).set(set)
2209
+ .where(and(
2210
+ eq(schema.connections.workspaceId, input.workspaceId),
2211
+ eq(schema.connections.id, input.connectionId),
2212
+ connectionSubjectVisibility(input.visibleToSubjectId),
2213
+ ...(input.expectedVersion !== undefined ? [eq(schema.connections.version, input.expectedVersion)] : []),
2214
+ ))
2215
+ .returning(connectionMetadataColumns);
2216
+ return row ? mapConnectionMetadata(row) : null;
2217
+ });
2218
+ }
2219
+
2220
+ export async function revokeConnection(db: Database, workspaceId: string, connectionId: string, updatedBySubjectId?: string | null): Promise<ConnectionMetadata | null> {
2221
+ return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
2222
+ const [row] = await scopedDb.update(schema.connections).set({
2223
+ status: "revoked",
2224
+ // The version bump invalidates any in-flight refresh's (id, version) CAS,
2225
+ // so a racing refresh cannot commit and flip the row back to active.
2226
+ version: sql`${schema.connections.version} + 1`,
2227
+ updatedBySubjectId: updatedBySubjectId ?? null,
2228
+ updatedAt: new Date(),
2229
+ })
2230
+ .where(and(
2231
+ eq(schema.connections.workspaceId, workspaceId),
2232
+ eq(schema.connections.id, connectionId),
2233
+ // Same visibility rule as get/update: shared rows plus the caller's own
2234
+ // subject rows. Cross-subject revocation (admin janitorial) arrives with
2235
+ // the subject-connections UX in I5, deliberately not before.
2236
+ connectionSubjectVisibility(updatedBySubjectId),
2237
+ ))
2238
+ .returning(connectionMetadataColumns);
2239
+ return row ? mapConnectionMetadata(row) : null;
2240
+ });
2241
+ }
2242
+
2243
+ export async function loadConnectionCredentialForBroker(db: Database, settings: Settings, input: {
2244
+ workspaceId: string;
2245
+ connectionId?: string;
2246
+ providerDomain: string;
2247
+ kind?: ConnectionKind;
2248
+ subjectId?: string | null;
2249
+ allowSubjectOwned?: boolean;
2250
+ }): Promise<ConnectionCredentialForBroker | null> {
2251
+ const key = environmentsEncryptionKeyBytes(settings);
2252
+ if (!key) {
2253
+ throw new Error("connection credential present but OPENGENI_ENVIRONMENTS_ENCRYPTION_KEY is not configured");
2254
+ }
2255
+ const subjectPredicate = input.allowSubjectOwned
2256
+ ? connectionSubjectVisibility(input.subjectId)
2257
+ : isNull(schema.connections.subjectId);
2258
+ const conditions: SQL[] = [
2259
+ eq(schema.connections.workspaceId, input.workspaceId),
2260
+ subjectPredicate,
2261
+ ];
2262
+ if (input.connectionId) {
2263
+ conditions.push(eq(schema.connections.id, input.connectionId));
2264
+ } else {
2265
+ conditions.push(eq(schema.connections.providerDomain, input.providerDomain));
2266
+ if (input.kind) {
2267
+ conditions.push(eq(schema.connections.kind, input.kind));
2268
+ }
2269
+ }
2270
+ return await withWorkspaceRls(db, input.workspaceId, async (scopedDb) => {
2271
+ // Prefer active rows: a revoke bumps updatedAt, so recency alone would let a
2272
+ // freshly revoked connection shadow an active replacement for the provider.
2273
+ const [row] = await scopedDb.select().from(schema.connections)
2274
+ .where(and(...conditions))
2275
+ .orderBy(desc(sql`(${schema.connections.status} = 'active')`), desc(schema.connections.updatedAt))
2276
+ .limit(1);
2277
+ if (!row) {
2278
+ return null;
2279
+ }
2280
+ let credential: unknown;
2281
+ try {
2282
+ credential = JSON.parse(decryptEnvironmentValue(key, row.credentialEncrypted));
2283
+ } catch (error) {
2284
+ throw new Error(`connection credential could not be decrypted for ${row.id}: ${error instanceof Error ? error.message : String(error)}`);
2285
+ }
2286
+ if (!credential || typeof credential !== "object" || Array.isArray(credential)) {
2287
+ throw new Error(`connection credential bundle for ${row.id} is not a JSON object`);
2288
+ }
2289
+ return {
2290
+ id: row.id,
2291
+ accountId: row.accountId,
2292
+ workspaceId: row.workspaceId,
2293
+ subjectId: row.subjectId,
2294
+ providerDomain: row.providerDomain,
2295
+ kind: row.kind as ConnectionKind,
2296
+ status: row.status as ConnectionStatus,
2297
+ credential: credential as Record<string, unknown>,
2298
+ grantedScopes: row.grantedScopes,
2299
+ expiresAt: row.expiresAt,
2300
+ lastRefreshAt: row.lastRefreshAt,
2301
+ version: row.version,
2302
+ metadata: row.metadata,
2303
+ };
2304
+ });
2305
+ }
2306
+
2307
+ export async function recordConnectionTokenRefresh(db: Database, input: {
2308
+ id: string;
2309
+ version: number;
2310
+ workspaceId: string;
2311
+ credentialEncrypted: string;
2312
+ expiresAt: Date | null;
2313
+ grantedScopes?: string[];
2314
+ lastRefreshAt: Date;
2315
+ }): Promise<boolean> {
2316
+ return await withWorkspaceRls(db, input.workspaceId, async (scopedDb) => {
2317
+ const set = {
2318
+ credentialEncrypted: input.credentialEncrypted,
2319
+ expiresAt: input.expiresAt,
2320
+ lastRefreshAt: input.lastRefreshAt,
2321
+ status: "active",
2322
+ lastError: null,
2323
+ version: sql`${schema.connections.version} + 1`,
2324
+ updatedAt: new Date(),
2325
+ ...(input.grantedScopes !== undefined ? { grantedScopes: input.grantedScopes } : {}),
2326
+ };
2327
+ const updated = await scopedDb.update(schema.connections).set(set)
2328
+ .where(and(
2329
+ eq(schema.connections.id, input.id),
2330
+ eq(schema.connections.workspaceId, input.workspaceId),
2331
+ eq(schema.connections.version, input.version),
2332
+ // A refresh may only ever renew a live credential; revoked/errored rows
2333
+ // stay dead even if a status change somewhere forgot to bump version.
2334
+ eq(schema.connections.status, "active"),
2335
+ ))
2336
+ .returning({ id: schema.connections.id });
2337
+ return updated.length > 0;
2338
+ });
2339
+ }
2340
+
2341
+ export async function setConnectionStatus(db: Database, workspaceId: string, status: ConnectionStatus, lastError: string | null, guard: { id: string; version: number }): Promise<boolean> {
2342
+ return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
2343
+ const updated = await scopedDb.update(schema.connections).set({
2344
+ status,
2345
+ lastError,
2346
+ version: sql`${schema.connections.version} + 1`,
2347
+ updatedAt: new Date(),
2348
+ }).where(and(
2349
+ eq(schema.connections.workspaceId, workspaceId),
2350
+ eq(schema.connections.id, guard.id),
2351
+ eq(schema.connections.version, guard.version),
2352
+ )).returning({ id: schema.connections.id });
2353
+ return updated.length > 0;
2354
+ });
2355
+ }
2356
+
2357
+ export async function recordConnectionUsed(db: Database, workspaceId: string, connectionId: string): Promise<void> {
2358
+ await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
2359
+ await scopedDb.update(schema.connections).set({
2360
+ lastUsedAt: new Date(),
2361
+ updatedAt: new Date(),
2362
+ }).where(and(eq(schema.connections.workspaceId, workspaceId), eq(schema.connections.id, connectionId)));
2363
+ });
2364
+ }
2365
+
2366
+ export async function loadIntegrationOAuthClient(
2367
+ db: Database,
2368
+ settings: Settings,
2369
+ issuer: string,
2370
+ ): Promise<IntegrationOAuthClientForUse | null> {
2371
+ const [row] = await db.select().from(schema.integrationOauthClients)
2372
+ .where(eq(schema.integrationOauthClients.issuer, issuer))
2373
+ .limit(1);
2374
+ if (!row) {
2375
+ return null;
2376
+ }
2377
+ let clientSecret: string | null = null;
2378
+ if (row.clientSecretEncrypted) {
2379
+ const key = environmentsEncryptionKeyBytes(settings);
2380
+ if (!key) {
2381
+ throw new Error("OAuth client secret present but OPENGENI_ENVIRONMENTS_ENCRYPTION_KEY is not configured");
2382
+ }
2383
+ clientSecret = decryptEnvironmentValue(key, row.clientSecretEncrypted);
2384
+ }
2385
+ return {
2386
+ id: row.id,
2387
+ issuer: row.issuer,
2388
+ authorizationServer: row.authorizationServer,
2389
+ clientId: row.clientId,
2390
+ clientSecret,
2391
+ tokenEndpointAuthMethod: row.tokenEndpointAuthMethod,
2392
+ metadata: row.metadata,
2393
+ createdAt: row.createdAt,
2394
+ updatedAt: row.updatedAt,
2395
+ };
2396
+ }
2397
+
2398
+ export async function storeIntegrationOAuthClient(
2399
+ db: Database,
2400
+ input: StoreIntegrationOAuthClientInput,
2401
+ ): Promise<StoredIntegrationOAuthClient> {
2402
+ const [inserted] = await db.insert(schema.integrationOauthClients).values({
2403
+ issuer: input.issuer,
2404
+ authorizationServer: input.authorizationServer,
2405
+ clientId: input.clientId,
2406
+ clientSecretEncrypted: input.clientSecretEncrypted ?? null,
2407
+ tokenEndpointAuthMethod: input.tokenEndpointAuthMethod ?? "none",
2408
+ metadata: input.metadata ?? {},
2409
+ }).onConflictDoNothing({
2410
+ target: schema.integrationOauthClients.issuer,
2411
+ }).returning();
2412
+ if (inserted) {
2413
+ return mapStoredIntegrationOAuthClient(inserted);
2414
+ }
2415
+ const [winner] = await db.select().from(schema.integrationOauthClients)
2416
+ .where(eq(schema.integrationOauthClients.issuer, input.issuer))
2417
+ .limit(1);
2418
+ if (!winner) {
2419
+ throw new Error(`OAuth client registration conflict winner not found for issuer ${input.issuer}`);
2420
+ }
2421
+ return mapStoredIntegrationOAuthClient(winner);
2422
+ }
2423
+
2424
+ function mapStoredIntegrationOAuthClient(row: typeof schema.integrationOauthClients.$inferSelect): StoredIntegrationOAuthClient {
2425
+ return {
2426
+ id: row.id,
2427
+ issuer: row.issuer,
2428
+ authorizationServer: row.authorizationServer,
2429
+ clientId: row.clientId,
2430
+ clientSecretEncrypted: row.clientSecretEncrypted,
2431
+ tokenEndpointAuthMethod: row.tokenEndpointAuthMethod,
2432
+ metadata: row.metadata,
2433
+ createdAt: row.createdAt,
2434
+ updatedAt: row.updatedAt,
2435
+ };
2436
+ }
2437
+
2438
+ export async function consumeIntegrationOAuthStateNonce(
2439
+ db: Database,
2440
+ input: ConsumeOAuthStateNonceInput,
2441
+ ): Promise<boolean> {
2442
+ return await withRlsContext(db, { accountId: input.accountId, workspaceId: input.workspaceId }, async (scopedDb) => {
2443
+ await scopedDb.delete(schema.integrationOauthStateNonces)
2444
+ .where(and(
2445
+ eq(schema.integrationOauthStateNonces.workspaceId, input.workspaceId),
2446
+ lt(schema.integrationOauthStateNonces.expiresAt, input.now),
2447
+ ));
2448
+ const inserted = await scopedDb.insert(schema.integrationOauthStateNonces).values({
2449
+ accountId: input.accountId,
2450
+ workspaceId: input.workspaceId,
2451
+ subjectId: input.subjectId,
2452
+ nonce: input.nonce,
2453
+ expiresAt: input.expiresAt,
2454
+ usedAt: input.now,
2455
+ }).onConflictDoNothing({ target: schema.integrationOauthStateNonces.nonce })
2456
+ .returning({ nonce: schema.integrationOauthStateNonces.nonce });
2457
+ return inserted.length > 0;
2458
+ });
2459
+ }
2460
+
2461
+ export async function createKnowledgeMemory(db: Database, input: CreateKnowledgeMemoryInput): Promise<KnowledgeMemory> {
2462
+ const text = requireDbString(input.text, "knowledge memory text");
2463
+ const scope = cleanDbString(input.scope) ?? "workspace";
2464
+ return await withRlsContext(db, { accountId: input.accountId, workspaceId: input.workspaceId }, async (scopedDb) => {
2465
+ const [row] = await scopedDb.insert(schema.knowledgeMemories).values({
2466
+ accountId: input.accountId,
2467
+ workspaceId: input.workspaceId,
2468
+ status: input.status ?? "proposed",
2469
+ kind: input.kind ?? "semantic",
2470
+ scope,
2471
+ text,
2472
+ sourceRefs: input.sourceRefs ?? [],
2473
+ confidence: confidenceToStorage(input.confidence ?? 0.5),
2474
+ metadata: input.metadata ?? {},
2475
+ createdBySessionId: input.createdBySessionId ?? null,
2476
+ }).returning();
2477
+ if (!row) {
2478
+ throw new Error("Failed to create knowledge memory");
2479
+ }
2480
+ return mapKnowledgeMemory(row);
2481
+ });
2482
+ }
2483
+
2484
+ export async function updateKnowledgeMemory(db: Database, workspaceId: string, memoryId: string, input: UpdateKnowledgeMemoryInput): Promise<KnowledgeMemory> {
2485
+ const reviewStatus = input.status === "approved" || input.status === "rejected";
2486
+ const scope = input.scope !== undefined ? requireDbString(input.scope, "knowledge memory scope") : undefined;
2487
+ const text = input.text !== undefined ? requireDbString(input.text, "knowledge memory text") : undefined;
2488
+ const reviewedBy = input.reviewedBy === null
2489
+ ? null
2490
+ : input.reviewedBy !== undefined
2491
+ ? requireDbString(input.reviewedBy, "knowledge memory reviewer")
2492
+ : undefined;
2493
+ return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
2494
+ const [row] = await scopedDb.update(schema.knowledgeMemories).set({
2495
+ ...(input.status !== undefined ? { status: input.status } : {}),
2496
+ ...(input.kind !== undefined ? { kind: input.kind } : {}),
2497
+ ...(scope !== undefined ? { scope } : {}),
2498
+ ...(text !== undefined ? { text } : {}),
2499
+ ...(input.sourceRefs !== undefined ? { sourceRefs: input.sourceRefs } : {}),
2500
+ ...(input.confidence !== undefined ? { confidence: confidenceToStorage(input.confidence) } : {}),
2501
+ ...(input.metadata !== undefined ? { metadata: input.metadata } : {}),
2502
+ // Re-proposing clears review metadata; an explicit reviewedBy in the same
2503
+ // update still wins via the later spread.
2504
+ ...(input.status === "proposed" ? { reviewedBy: null, reviewedAt: null } : {}),
2505
+ ...(reviewedBy !== undefined ? { reviewedBy } : {}),
2506
+ ...(reviewStatus ? { reviewedAt: new Date() } : {}),
2507
+ updatedAt: new Date(),
2508
+ }).where(and(eq(schema.knowledgeMemories.workspaceId, workspaceId), eq(schema.knowledgeMemories.id, memoryId))).returning();
2509
+ if (!row) {
2510
+ throw new Error(`Knowledge memory not found: ${memoryId}`);
2511
+ }
2512
+ return mapKnowledgeMemory(row);
2513
+ });
2514
+ }
2515
+
2516
+ export async function getKnowledgeMemory(db: Database, workspaceId: string, memoryId: string): Promise<KnowledgeMemory | null> {
2517
+ return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
2518
+ const [row] = await scopedDb.select().from(schema.knowledgeMemories)
2519
+ .where(and(eq(schema.knowledgeMemories.workspaceId, workspaceId), eq(schema.knowledgeMemories.id, memoryId)))
2520
+ .limit(1);
2521
+ return row ? mapKnowledgeMemory(row) : null;
2522
+ });
2523
+ }
2524
+
2525
+ export async function listKnowledgeMemories(db: Database, workspaceId: string, options: ListKnowledgeMemoryOptions = {}): Promise<KnowledgeMemory[]> {
2526
+ return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
2527
+ const conditions: SQL[] = [eq(schema.knowledgeMemories.workspaceId, workspaceId)];
2528
+ if (options.status) {
2529
+ conditions.push(eq(schema.knowledgeMemories.status, options.status));
2530
+ }
2531
+ if (options.kind) {
2532
+ conditions.push(eq(schema.knowledgeMemories.kind, options.kind));
2533
+ }
2534
+ const scope = cleanDbString(options.scope);
2535
+ if (scope) {
2536
+ conditions.push(eq(schema.knowledgeMemories.scope, scope));
2537
+ }
2538
+ const query = cleanDbString(options.query);
2539
+ if (query) {
2540
+ conditions.push(sql`to_tsvector('simple', ${schema.knowledgeMemories.text}) @@ plainto_tsquery('simple', ${query})`);
2541
+ }
2542
+ const limit = Math.min(Math.max(options.limit ?? 20, 1), 100);
2543
+ const rows = await scopedDb.select().from(schema.knowledgeMemories)
2544
+ .where(and(...conditions))
2545
+ .orderBy(desc(schema.knowledgeMemories.updatedAt))
2546
+ .limit(limit);
2547
+ return rows.map(mapKnowledgeMemory);
2548
+ });
2549
+ }
2550
+
1718
2551
  export async function createSocialConnection(db: Database, input: CreateSocialConnectionInput): Promise<SocialConnection> {
1719
2552
  return await withRlsContext(db, { accountId: input.accountId, workspaceId: input.workspaceId }, async (scopedDb) => {
1720
2553
  const [row] = await scopedDb.insert(schema.socialConnections).values({
@@ -3166,6 +3999,7 @@ async function insertSessionMcpServers(db: Database, input: {
3166
3999
  allowedTools: server.allowedTools ?? null,
3167
4000
  timeoutMs: server.timeoutMs ?? null,
3168
4001
  cacheToolsList: server.cacheToolsList ?? false,
4002
+ requireApproval: server.requireApproval ?? null,
3169
4003
  headersEncrypted: server.headersEncrypted ?? {},
3170
4004
  }))).returning();
3171
4005
  return rows.map(mapSessionMcpServerMetadata);
@@ -3257,6 +4091,7 @@ export async function listSessionMcpServersForRun(
3257
4091
  ...(row.allowedTools ? { allowedTools: row.allowedTools } : {}),
3258
4092
  ...(row.timeoutMs ? { timeoutMs: row.timeoutMs } : {}),
3259
4093
  ...(row.cacheToolsList ? { cacheToolsList: row.cacheToolsList } : {}),
4094
+ ...(row.requireApproval != null ? { requireApproval: row.requireApproval } : {}),
3260
4095
  headers,
3261
4096
  };
3262
4097
  });
@@ -3542,6 +4377,41 @@ export async function listSessionEvents(
3542
4377
  });
3543
4378
  }
3544
4379
 
4380
+ export type ToolspaceCallReservation =
4381
+ | { reserved: true; count: number }
4382
+ | { reserved: false };
4383
+
4384
+ /**
4385
+ * Atomically reserve one toolspace call against a turn's per-turn budget.
4386
+ *
4387
+ * A single conditional UPDATE increments `toolspace_call_count` only while it is
4388
+ * below `limit` and returns the post-increment value. Concurrent reservations
4389
+ * for the same turn serialize on the row lock, so exactly `limit` of N
4390
+ * simultaneous callers observe `reserved: true` — closing the read-then-append
4391
+ * TOCTOU the event-count approach had. `reserved: false` means the turn is at or
4392
+ * over budget (or the turn row no longer exists).
4393
+ */
4394
+ export async function reserveToolspaceCallForTurn(
4395
+ db: Database,
4396
+ workspaceId: string,
4397
+ sessionId: string,
4398
+ turnId: string,
4399
+ limit: number,
4400
+ ): Promise<ToolspaceCallReservation> {
4401
+ return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
4402
+ const [row] = await scopedDb.update(schema.sessionTurns)
4403
+ .set({ toolspaceCallCount: sql`${schema.sessionTurns.toolspaceCallCount} + 1` })
4404
+ .where(and(
4405
+ eq(schema.sessionTurns.workspaceId, workspaceId),
4406
+ eq(schema.sessionTurns.sessionId, sessionId),
4407
+ eq(schema.sessionTurns.id, turnId),
4408
+ sql`${schema.sessionTurns.toolspaceCallCount} < ${limit}`,
4409
+ ))
4410
+ .returning({ count: schema.sessionTurns.toolspaceCallCount });
4411
+ return row ? { reserved: true, count: Number(row.count) } : { reserved: false };
4412
+ });
4413
+ }
4414
+
3545
4415
  function normalizeEventSequence(value: number | undefined, fallback: number): number {
3546
4416
  if (value === undefined || !Number.isFinite(value)) {
3547
4417
  return fallback;
@@ -5139,6 +6009,22 @@ export async function countSandboxLeasesByLiveness(db: Database): Promise<Record
5139
6009
  return counts;
5140
6010
  }
5141
6011
 
6012
+ export type CreditBalanceByAccount = {
6013
+ accountId: string;
6014
+ balanceMicros: number;
6015
+ };
6016
+
6017
+ export async function listCreditBalancesByAccount(db: Database): Promise<CreditBalanceByAccount[]> {
6018
+ const rows = await rawRows<{ account_id: string; balance_micros: number | string }>(db, sql`
6019
+ select account_id, balance_micros
6020
+ from opengeni_private.credit_balance_by_account()
6021
+ `);
6022
+ return rows.map((row) => ({
6023
+ accountId: row.account_id,
6024
+ balanceMicros: Number(row.balance_micros),
6025
+ }));
6026
+ }
6027
+
5142
6028
  // Cross-workspace live Modal lease read for the provider-side orphan sweep. The
5143
6029
  // SECURITY DEFINER function is the sanctioned RLS bypass; see migration 0036.
5144
6030
  export async function listLiveModalSandboxLeaseAttributions(db: Database): Promise<LiveModalSandboxLeaseAttribution[]> {
@@ -5410,6 +6296,9 @@ export type EnrollmentRecord = {
5410
6296
  pubkey: string;
5411
6297
  exposure: EnrollmentExposure;
5412
6298
  hasDisplay: boolean;
6299
+ /** Set when a display exists but capture is not permitted (macOS Screen Recording
6300
+ * not granted); null when capture is permitted or the machine is headless. */
6301
+ desktopUnavailableReason: string | null;
5413
6302
  allowScreenControl: boolean;
5414
6303
  status: EnrollmentStatus;
5415
6304
  os: EnrollmentOs;
@@ -5428,6 +6317,7 @@ function mapEnrollment(row: typeof schema.enrollments.$inferSelect): EnrollmentR
5428
6317
  pubkey: row.pubkey,
5429
6318
  exposure: row.exposure as EnrollmentExposure,
5430
6319
  hasDisplay: row.hasDisplay,
6320
+ desktopUnavailableReason: row.desktopUnavailableReason ?? null,
5431
6321
  allowScreenControl: row.allowScreenControl,
5432
6322
  status: row.status as EnrollmentStatus,
5433
6323
  os: row.os as EnrollmentOs,
@@ -5573,23 +6463,40 @@ export async function touchEnrollmentLastSeen(db: Database, input: {
5573
6463
  // at enroll time from the enroll-offer snapshot, this tracks REALITY across the
5574
6464
  // machine's life — a Mac that later grants Screen Recording, or a Linux box whose
5575
6465
  // Xvfb starts after enrollment, flips false→true on its next Hello (and a display
5576
- // that goes away flips true→false). CHANGE-GUARDED at the SQL layer (the `ne`
5577
- // predicate): a Hello that reports the same value the row already holds updates
5578
- // zero rows, so a steady state never churns a write. Returns whether a row was
5579
- // actually changed. Best-effort — the caller swallows failures so a display
5580
- // refresh never breaks the agent's connect.
5581
- export async function setEnrollmentHasDisplay(db: Database, input: {
5582
- accountId: string; workspaceId: string; enrollmentId: string; hasDisplay: boolean;
6466
+ // that goes away flips true→false).
6467
+ //
6468
+ // `desktopUnavailableReason` rides alongside: a machine can have a display it
6469
+ // cannot CAPTURE (macOS Screen Recording / TCC not granted). In that case
6470
+ // has_display is false BUT the reason is a human, actionable string, so the
6471
+ // Machines dashboard can show "display: capture not granted" instead of a bare
6472
+ // "headless". null means capture is permitted OR the machine is genuinely headless.
6473
+ //
6474
+ // CHANGE-GUARDED at the SQL layer: the write fires only when EITHER field differs
6475
+ // from what the row already holds (`hasDisplay` via `ne`, the nullable reason via
6476
+ // `IS DISTINCT FROM`), so a steady-state Hello updates zero rows and never churns.
6477
+ // Returns whether a row was actually changed. Best-effort — the caller swallows
6478
+ // failures so a display refresh never breaks the agent's connect.
6479
+ export async function setEnrollmentDisplayState(db: Database, input: {
6480
+ accountId: string; workspaceId: string; enrollmentId: string;
6481
+ hasDisplay: boolean; desktopUnavailableReason: string | null;
5583
6482
  }): Promise<{ updated: boolean }> {
5584
6483
  return await withRlsContext(db, { accountId: input.accountId, workspaceId: input.workspaceId }, async (scopedDb) => {
5585
6484
  const rows = await scopedDb.update(schema.enrollments)
5586
- .set({ hasDisplay: input.hasDisplay, updatedAt: new Date() })
6485
+ .set({
6486
+ hasDisplay: input.hasDisplay,
6487
+ desktopUnavailableReason: input.desktopUnavailableReason,
6488
+ updatedAt: new Date(),
6489
+ })
5587
6490
  .where(and(
5588
6491
  eq(schema.enrollments.workspaceId, input.workspaceId),
5589
6492
  eq(schema.enrollments.id, input.enrollmentId),
5590
- // Only write on a CHANGE — an unchanged display must not churn a write on
5591
- // every reconnect Hello.
5592
- ne(schema.enrollments.hasDisplay, input.hasDisplay),
6493
+ // Only write on a CHANGE to EITHER field — an unchanged display state must
6494
+ // not churn a write on every reconnect Hello. `IS DISTINCT FROM` is the
6495
+ // null-safe inequality (a plain `ne` skips NULL rows).
6496
+ or(
6497
+ ne(schema.enrollments.hasDisplay, input.hasDisplay),
6498
+ sql`${schema.enrollments.desktopUnavailableReason} IS DISTINCT FROM ${input.desktopUnavailableReason}`,
6499
+ ),
5593
6500
  ))
5594
6501
  .returning({ id: schema.enrollments.id });
5595
6502
  return { updated: rows.length > 0 };
@@ -7941,12 +8848,30 @@ function mapWorkspacePack(row: typeof schema.workspacePacks.$inferSelect): Works
7941
8848
  };
7942
8849
  }
7943
8850
 
8851
+ function mapImportBatch(row: typeof schema.importBatches.$inferSelect): ImportBatch {
8852
+ return {
8853
+ id: row.id,
8854
+ source: row.source,
8855
+ snapshotDate: row.snapshotDate.toISOString(),
8856
+ snapshotRef: row.snapshotRef,
8857
+ attributionNote: row.attributionNote,
8858
+ importedCount: row.importedCount,
8859
+ skippedCount: row.skippedCount,
8860
+ quarantinedCount: row.quarantinedCount,
8861
+ logoFailureCount: row.logoFailureCount,
8862
+ staleCount: row.staleCount,
8863
+ details: row.details,
8864
+ createdAt: row.createdAt.toISOString(),
8865
+ updatedAt: row.updatedAt.toISOString(),
8866
+ };
8867
+ }
8868
+
7944
8869
  function mapCapabilityCatalogItem(row: typeof schema.capabilityCatalogItems.$inferSelect): CapabilityCatalogItem {
7945
8870
  const runtime = row.kind === "mcp" && row.endpointUrl
7946
8871
  ? {
7947
8872
  available: true,
7948
8873
  mcpServerId: mcpServerIdForCapability(row.id, row.metadata),
7949
- transport: "streamable-http",
8874
+ transport: row.transport ?? "streamable-http",
7950
8875
  notes: row.authModel
7951
8876
  ? "Requires credential headers supplied in the enable request."
7952
8877
  : null,
@@ -7959,8 +8884,8 @@ function mapCapabilityCatalogItem(row: typeof schema.capabilityCatalogItems.$inf
7959
8884
  };
7960
8885
  return {
7961
8886
  id: row.id,
7962
- accountId: row.accountId,
7963
- workspaceId: row.workspaceId,
8887
+ ...(row.accountId ? { accountId: row.accountId } : {}),
8888
+ ...(row.workspaceId ? { workspaceId: row.workspaceId } : {}),
7964
8889
  kind: row.kind as CapabilityKind,
7965
8890
  source: row.source as CapabilitySource,
7966
8891
  name: row.name,
@@ -7971,10 +8896,25 @@ function mapCapabilityCatalogItem(row: typeof schema.capabilityCatalogItems.$inf
7971
8896
  endpointUrl: row.endpointUrl,
7972
8897
  installUrl: row.installUrl,
7973
8898
  authModel: row.authModel,
8899
+ providerDomain: row.providerDomain,
8900
+ surfaceType: row.surfaceType,
8901
+ transport: row.transport,
8902
+ mcpUrl: row.mcpUrl,
8903
+ authKind: row.authKind as CapabilityCatalogItem["authKind"],
8904
+ credentialFacts: row.credentialFacts,
8905
+ tier: row.tier as CapabilityCatalogItem["tier"],
8906
+ provenance: row.provenance,
8907
+ logoAssetPath: row.logoAssetPath,
8908
+ importBatchId: row.importBatchId,
8909
+ stale: row.stale,
8910
+ staleAt: row.staleAt?.toISOString() ?? null,
7974
8911
  tools: [],
7975
8912
  runtime,
7976
8913
  enabled: false,
7977
8914
  enabledReason: null,
8915
+ // Overwritten by applyCapabilityEnablement in @opengeni/core, which knows
8916
+ // the installation; a freshly-read catalog row carries no connection.
8917
+ connectionRef: null,
7978
8918
  metadata: row.metadata,
7979
8919
  createdAt: row.createdAt.toISOString(),
7980
8920
  updatedAt: row.updatedAt.toISOString(),
@@ -8035,6 +8975,67 @@ function redactInstallationConfig(config: Record<string, unknown>): Record<strin
8035
8975
  return { ...rest, headerNames: Object.keys(headersEncrypted).sort() };
8036
8976
  }
8037
8977
 
8978
+ function mapConnectionMetadata(row: {
8979
+ id: string;
8980
+ accountId: string;
8981
+ workspaceId: string;
8982
+ subjectId: string | null;
8983
+ providerDomain: string;
8984
+ kind: string;
8985
+ status: string;
8986
+ grantedScopes: string[];
8987
+ expiresAt: Date | null;
8988
+ lastRefreshAt: Date | null;
8989
+ lastUsedAt: Date | null;
8990
+ lastError: string | null;
8991
+ version: number;
8992
+ metadata: Record<string, unknown>;
8993
+ createdBySubjectId: string | null;
8994
+ updatedBySubjectId: string | null;
8995
+ createdAt: Date;
8996
+ updatedAt: Date;
8997
+ }): ConnectionMetadata {
8998
+ return {
8999
+ id: row.id,
9000
+ accountId: row.accountId,
9001
+ workspaceId: row.workspaceId,
9002
+ subjectId: row.subjectId,
9003
+ providerDomain: row.providerDomain,
9004
+ kind: row.kind as ConnectionKind,
9005
+ status: row.status as ConnectionStatus,
9006
+ grantedScopes: row.grantedScopes,
9007
+ expiresAt: row.expiresAt?.toISOString() ?? null,
9008
+ lastRefreshAt: row.lastRefreshAt?.toISOString() ?? null,
9009
+ lastUsedAt: row.lastUsedAt?.toISOString() ?? null,
9010
+ lastError: row.lastError,
9011
+ version: row.version,
9012
+ metadata: row.metadata,
9013
+ createdBySubjectId: row.createdBySubjectId,
9014
+ updatedBySubjectId: row.updatedBySubjectId,
9015
+ createdAt: row.createdAt.toISOString(),
9016
+ updatedAt: row.updatedAt.toISOString(),
9017
+ };
9018
+ }
9019
+
9020
+ function mapKnowledgeMemory(row: typeof schema.knowledgeMemories.$inferSelect): KnowledgeMemory {
9021
+ return {
9022
+ id: row.id,
9023
+ workspaceId: row.workspaceId,
9024
+ status: row.status as KnowledgeMemoryStatus,
9025
+ kind: row.kind as KnowledgeMemoryKind,
9026
+ scope: row.scope,
9027
+ text: row.text,
9028
+ sourceRefs: Array.isArray(row.sourceRefs) ? row.sourceRefs as KnowledgeSourceRef[] : [],
9029
+ confidence: confidenceFromStorage(row.confidence),
9030
+ metadata: row.metadata,
9031
+ createdBySessionId: row.createdBySessionId,
9032
+ reviewedBy: row.reviewedBy,
9033
+ reviewedAt: row.reviewedAt ? row.reviewedAt.toISOString() : null,
9034
+ createdAt: row.createdAt.toISOString(),
9035
+ updatedAt: row.updatedAt.toISOString(),
9036
+ };
9037
+ }
9038
+
8038
9039
  function mapSocialConnection(row: typeof schema.socialConnections.$inferSelect): SocialConnection {
8039
9040
  return {
8040
9041
  id: row.id,
@@ -8128,6 +9129,30 @@ function stringArrayConfig(value: unknown): string[] | undefined {
8128
9129
  return values.length > 0 ? [...new Set(values.map((item) => item.trim()))] : undefined;
8129
9130
  }
8130
9131
 
9132
+ function cleanDbString(value: string | undefined | null): string | undefined {
9133
+ const trimmed = value?.trim();
9134
+ return trimmed ? trimmed : undefined;
9135
+ }
9136
+
9137
+ function requireDbString(value: string, field: string): string {
9138
+ const trimmed = cleanDbString(value);
9139
+ if (!trimmed) {
9140
+ throw new Error(`${field} is required`);
9141
+ }
9142
+ return trimmed;
9143
+ }
9144
+
9145
+ function confidenceToStorage(value: number): number {
9146
+ if (!Number.isFinite(value)) {
9147
+ return 50;
9148
+ }
9149
+ return Math.round(Math.min(Math.max(value, 0), 1) * 100);
9150
+ }
9151
+
9152
+ function confidenceFromStorage(value: number): number {
9153
+ return Number((Math.min(Math.max(value, 0), 100) / 100).toFixed(2));
9154
+ }
9155
+
8131
9156
  function positiveIntegerConfig(value: unknown): number | undefined {
8132
9157
  if (typeof value === "number" && Number.isInteger(value) && value > 0) {
8133
9158
  return value;
@@ -8153,7 +9178,37 @@ function encryptedHeadersConfig(value: unknown): Record<string, string> | undefi
8153
9178
 
8154
9179
  function mcpConnectivityOk(metadata: Record<string, unknown>): boolean {
8155
9180
  const value = metadata.mcpConnectivity;
8156
- return !!value && typeof value === "object" && "status" in value && value.status === "ok";
9181
+ return !!value && typeof value === "object" && "status" in value && (value.status === "ok" || value.status === "auth_deferred");
9182
+ }
9183
+
9184
+ function connectionRefConfig(value: unknown): McpServerConnectionRef | undefined {
9185
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
9186
+ return undefined;
9187
+ }
9188
+ const record = value as Record<string, unknown>;
9189
+ if (typeof record.providerDomain !== "string" || record.providerDomain.length === 0) {
9190
+ return undefined;
9191
+ }
9192
+ const ref: McpServerConnectionRef = { providerDomain: record.providerDomain };
9193
+ if (typeof record.connectionId === "string" && record.connectionId.length > 0) {
9194
+ ref.connectionId = record.connectionId;
9195
+ }
9196
+ if (typeof record.kind === "string" && ["oauth2", "api_key", "app_install", "delegated"].includes(record.kind)) {
9197
+ ref.kind = record.kind as ConnectionKind;
9198
+ }
9199
+ if (Array.isArray(record.scopes)) {
9200
+ const scopes = record.scopes.filter((scope): scope is string => typeof scope === "string" && scope.length > 0);
9201
+ if (scopes.length > 0) {
9202
+ ref.scopes = scopes;
9203
+ }
9204
+ }
9205
+ if (typeof record.resource === "string" && record.resource.length > 0) {
9206
+ ref.resource = record.resource;
9207
+ }
9208
+ if (record.subjectScope === "workspace" || record.subjectScope === "subject") {
9209
+ ref.subjectScope = record.subjectScope;
9210
+ }
9211
+ return ref;
8157
9212
  }
8158
9213
 
8159
9214
  function shortHash(value: string): string {
@@ -8171,3 +9226,4 @@ function shortHash(value: string): string {
8171
9226
  // recordCodexAccountUsage) is already initialized when its default-deps bag
8172
9227
  // evaluates under the index↔resolver module cycle.
8173
9228
  export * from "./codex-token-resolver";
9229
+ export * from "./connection-token-resolver";