@opengeni/db 4.2.2 → 4.3.0-canary.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/dist/canonical-human-identities.js +3 -3
  2. package/dist/{chunk-6GAPAYZR.js → chunk-7JPLGBSA.js} +562 -86
  3. package/dist/chunk-7JPLGBSA.js.map +1 -0
  4. package/dist/{chunk-SNLIKELL.js → chunk-IHADTE7C.js} +2 -2
  5. package/dist/{chunk-7WX3R3YL.js → chunk-ILKJKDII.js} +7 -2
  6. package/dist/chunk-ILKJKDII.js.map +1 -0
  7. package/dist/{chunk-MGVOYVPS.js → chunk-NCSPNCQK.js} +3 -3
  8. package/dist/{chunk-B2KR3TEM.js → chunk-SGJDIF2D.js} +2 -2
  9. package/dist/{chunk-S4Y42VSX.js → chunk-Y5XFVJJM.js} +207 -3
  10. package/dist/chunk-Y5XFVJJM.js.map +1 -0
  11. package/dist/{chunk-FPTI4NP4.js → chunk-YGBOVXC7.js} +2 -2
  12. package/dist/{chunk-6Y7ZDVJ5.js → chunk-YGRT6VZO.js} +2 -2
  13. package/dist/{chunk-6IQ4X35Y.js → chunk-YVYBAWBU.js} +46 -1
  14. package/dist/chunk-YVYBAWBU.js.map +1 -0
  15. package/dist/connect-attempts.d.ts +60 -0
  16. package/dist/connection-setup-authority.d.ts +10 -0
  17. package/dist/connection-token-resolver.d.ts +11 -2
  18. package/dist/editable-artifact-durable-export.js +2 -2
  19. package/dist/editable-artifacts.js +3 -3
  20. package/dist/external-identities.d.ts +16 -0
  21. package/dist/external-identity-links.d.ts +74 -0
  22. package/dist/external-link-work.d.ts +51 -0
  23. package/dist/host-mcp-binding-match.d.ts +5 -0
  24. package/dist/host-mcp-bindings.d.ts +73 -0
  25. package/dist/host-mcp-task-authority.d.ts +47 -0
  26. package/dist/host-selection-identity.d.ts +6 -0
  27. package/dist/index.d.ts +51 -35
  28. package/dist/index.js +15171 -13775
  29. package/dist/index.js.map +1 -1
  30. package/dist/live-session-attempt.d.ts +275 -0
  31. package/dist/managed-auth-session-sets.js +3 -3
  32. package/dist/memory-domain.d.ts +3 -3
  33. package/dist/provision-roles.js +1 -1
  34. package/dist/retained-provider-commands.js +2 -2
  35. package/dist/runtime-posture.d.ts +6 -6
  36. package/dist/scheduled-task-revision-authority.d.ts +11 -0
  37. package/dist/schema.d.ts +1279 -9
  38. package/dist/schema.js +11 -1
  39. package/dist/session-background-commands.js +4 -4
  40. package/dist/session-command-output.js +4 -4
  41. package/dist/session-event-slices.js +2 -2
  42. package/dist/session-queue-commands.d.ts +7 -0
  43. package/dist/session-tenancy.d.ts +2 -0
  44. package/dist/session-tenancy.js +3 -3
  45. package/dist/video-generation.js +3 -3
  46. package/dist/workspace-authority.d.ts +4 -4
  47. package/dist/workspace-tool-defaults.js +4 -4
  48. package/drizzle/0437_organization_scoped_external_workspaces.sql +71 -0
  49. package/drizzle/0438_durable_connect_attempts.sql +95 -0
  50. package/drizzle/0439_external_identity_provisioning.sql +128 -0
  51. package/drizzle/0440_external_workspace_member_removal.sql +59 -0
  52. package/drizzle/0441_external_identity_membership_lifecycle.sql +163 -0
  53. package/drizzle/0442_external_owning_user_authority.sql +81 -0
  54. package/drizzle/0443_host_mcp_binding_registry.sql +93 -0
  55. package/drizzle/0444_host_mcp_delegations.sql +129 -0
  56. package/drizzle/0445_host_mcp_turn_authorities.sql +145 -0
  57. package/drizzle/0446_host_mcp_causal_continuation.sql +126 -0
  58. package/drizzle/0447_host_mcp_task_authorities.sql +264 -0
  59. package/drizzle/0448_host_mcp_child_authority.sql +115 -0
  60. package/drizzle/0449_external_identity_link_lifecycle.sql +107 -0
  61. package/drizzle/0450_external_identity_link_work.sql +179 -0
  62. package/drizzle/0451_external_link_preview_and_permission_ceiling.sql +82 -0
  63. package/drizzle/0452_external_link_scheduled_origin.sql +34 -0
  64. package/drizzle/0453_host_mcp_native_owner.sql +35 -0
  65. package/drizzle/0454_connect_origin_authority.sql +42 -0
  66. package/drizzle/0455_external_link_inventory_labels.sql +44 -0
  67. package/drizzle/0456_social_connection_versions.sql +14 -0
  68. package/drizzle/0457_canonical_session_scope_subject.sql +46 -0
  69. package/drizzle/0458_skill_review_wire_compatibility.sql +36 -0
  70. package/package.json +6 -6
  71. package/src/connect-attempts.ts +317 -0
  72. package/src/connection-authority.ts +9 -0
  73. package/src/connection-setup-authority.ts +35 -0
  74. package/src/connection-token-resolver.ts +177 -48
  75. package/src/external-identities.ts +70 -0
  76. package/src/external-identity-links.ts +364 -0
  77. package/src/external-link-work.ts +228 -0
  78. package/src/host-mcp-binding-match.ts +33 -0
  79. package/src/host-mcp-bindings.ts +741 -0
  80. package/src/host-mcp-task-authority.ts +383 -0
  81. package/src/host-selection-identity.ts +29 -0
  82. package/src/index.ts +398 -147
  83. package/src/live-session-attempt.ts +85 -0
  84. package/src/managed-human-provisioning.ts +6 -1
  85. package/src/memory-domain.ts +6 -11
  86. package/src/provision-roles.ts +3 -0
  87. package/src/runtime-posture.ts +47 -0
  88. package/src/scheduled-task-revision-authority.ts +30 -0
  89. package/src/schema.ts +228 -3
  90. package/src/session-control.ts +14 -0
  91. package/src/session-queue-commands.ts +11 -0
  92. package/src/session-tenancy.ts +7 -0
  93. package/src/workspace-authority.ts +5 -5
  94. package/dist/chunk-6GAPAYZR.js.map +0 -1
  95. package/dist/chunk-6IQ4X35Y.js.map +0 -1
  96. package/dist/chunk-7WX3R3YL.js.map +0 -1
  97. package/dist/chunk-S4Y42VSX.js.map +0 -1
  98. /package/dist/{chunk-SNLIKELL.js.map → chunk-IHADTE7C.js.map} +0 -0
  99. /package/dist/{chunk-MGVOYVPS.js.map → chunk-NCSPNCQK.js.map} +0 -0
  100. /package/dist/{chunk-B2KR3TEM.js.map → chunk-SGJDIF2D.js.map} +0 -0
  101. /package/dist/{chunk-FPTI4NP4.js.map → chunk-YGBOVXC7.js.map} +0 -0
  102. /package/dist/{chunk-6Y7ZDVJ5.js.map → chunk-YGRT6VZO.js.map} +0 -0
@@ -0,0 +1,317 @@
1
+ import { createHash } from "node:crypto";
2
+ import { sql } from "drizzle-orm";
3
+ import { ConnectAttempt, type ConnectAttempt as Attempt } from "@opengeni/contracts/connect";
4
+ import { ExternalActorContinuation } from "@opengeni/contracts/external-identities";
5
+ import { stableJson } from "@opengeni/contracts";
6
+ import { rawRows, withWorkspaceSubjectRls, type Database } from "./database";
7
+
8
+ export type ConnectActorScope = { accountId: string; workspaceId: string; subjectId: string };
9
+ /** Live admission under the attempt transaction. Implementations must acquire
10
+ * their key/membership/link fences in a consistent order and hold them through
11
+ * commit. No network I/O. The attempt argument is an isolated copy. */
12
+ export type ConnectOperationAuthorization = (
13
+ tx: Database,
14
+ attempt: Attempt,
15
+ origin: ExternalActorContinuation | null,
16
+ ) => Promise<void>;
17
+ type Receipt = { digest: string; result: Attempt };
18
+ type Row = {
19
+ id: string;
20
+ account_id: string;
21
+ workspace_id: string;
22
+ subject_id: string;
23
+ request_digest: string;
24
+ return_url: string;
25
+ projection: unknown;
26
+ operation_id: string | null;
27
+ operation_digest: string | null;
28
+ receipts: Record<string, Receipt>;
29
+ external_continuation: unknown;
30
+ };
31
+
32
+ export class ConnectAttemptConflictError extends Error {
33
+ readonly code = "CONNECT_ATTEMPT_CONFLICT";
34
+ constructor(message = "Connect attempt changed; reload its current state") {
35
+ super(message);
36
+ }
37
+ }
38
+ export class ConnectAttemptNotFoundError extends Error {
39
+ readonly code = "CONNECT_ATTEMPT_NOT_FOUND";
40
+ constructor() {
41
+ super("Connect attempt not found");
42
+ }
43
+ }
44
+
45
+ function projection(row: Row, scope: ConnectActorScope): Attempt {
46
+ const value = ConnectAttempt.parse(row.projection);
47
+ if (
48
+ row.account_id !== scope.accountId ||
49
+ row.workspace_id !== scope.workspaceId ||
50
+ row.subject_id !== scope.subjectId ||
51
+ value.id !== row.id ||
52
+ value.workspaceId !== scope.workspaceId
53
+ ) {
54
+ throw new ConnectAttemptNotFoundError();
55
+ }
56
+ return value;
57
+ }
58
+ function scoped<T>(
59
+ db: Database,
60
+ scope: ConnectActorScope,
61
+ run: (tx: Database) => Promise<T>,
62
+ ): Promise<T> {
63
+ return withWorkspaceSubjectRls(db, scope.workspaceId, scope.subjectId, run);
64
+ }
65
+ async function lockAttempt(
66
+ tx: Database,
67
+ scope: ConnectActorScope,
68
+ id: string,
69
+ ): Promise<Row & { expired: boolean }> {
70
+ const [row] = await rawRows<Row & { expired: boolean }>(
71
+ tx,
72
+ sql`select *, expires_at <= clock_timestamp() as expired from connect_attempts
73
+ where id = ${id}::uuid and account_id = ${scope.accountId}::uuid
74
+ and workspace_id = ${scope.workspaceId}::uuid and subject_id = ${scope.subjectId}
75
+ for update`,
76
+ );
77
+ if (!row) throw new ConnectAttemptNotFoundError();
78
+ return row;
79
+ }
80
+
81
+ function receiptFor(
82
+ row: Row,
83
+ operationId: string,
84
+ digest: string,
85
+ scope: ConnectActorScope,
86
+ ): Attempt | null {
87
+ if (!Object.hasOwn(row.receipts, operationId)) return null;
88
+ const receipt = row.receipts[operationId]!;
89
+ if (receipt.digest !== digest)
90
+ throw new ConnectAttemptConflictError("Connect operation ID was reused");
91
+ return projection({ ...row, projection: receipt.result }, scope);
92
+ }
93
+ function validateOperation(id: string, digest: string): void {
94
+ if (!id || new TextEncoder().encode(id).byteLength > 512 || !/^[0-9a-f]{64}$/.test(digest)) {
95
+ throw new ConnectAttemptConflictError("Invalid Connect operation identity");
96
+ }
97
+ }
98
+
99
+ /** Caller must establish live workspace/actor authority before this storage
100
+ * seam. RLS scope is a consistency boundary, not proof of authentication. */
101
+ export async function beginConnectAttempt(
102
+ db: Database,
103
+ scope: ConnectActorScope,
104
+ input: {
105
+ idempotencyKey: string;
106
+ requestDigest: string;
107
+ returnUrl: string;
108
+ attempt: Attempt;
109
+ externalContinuation?: ExternalActorContinuation;
110
+ },
111
+ ): Promise<Attempt> {
112
+ const value = ConnectAttempt.parse(input.attempt);
113
+ const origin = input.externalContinuation
114
+ ? ExternalActorContinuation.parse(input.externalContinuation)
115
+ : null;
116
+ validateOperation(input.idempotencyKey, input.requestDigest);
117
+ if (value.workspaceId !== scope.workspaceId || value.revision !== 1)
118
+ throw new ConnectAttemptConflictError();
119
+ const key = createHash("sha256").update(input.idempotencyKey).digest("hex");
120
+ return scoped(db, scope, async (tx) => {
121
+ // Serialize per-actor creation and quota, not the provider's remote request.
122
+ await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(
123
+ ${`connect:${scope.workspaceId}:${scope.subjectId}`}, 0))`);
124
+ // Bounded actor-local cleanup; no Connection credentials are deleted.
125
+ await tx.execute(sql`with expired as (
126
+ select id from connect_attempts where workspace_id = ${scope.workspaceId}::uuid
127
+ and account_id = ${scope.accountId}::uuid and subject_id = ${scope.subjectId}
128
+ and expires_at < now() - interval '30 days'
129
+ order by expires_at, id limit 100 for update skip locked
130
+ ) delete from connect_attempts where id in (select id from expired)`);
131
+ const [existing] = await rawRows<Row>(
132
+ tx,
133
+ sql`select * from connect_attempts
134
+ where workspace_id = ${scope.workspaceId}::uuid and subject_id = ${scope.subjectId}
135
+ and idempotency_key_hash = ${key} and account_id = ${scope.accountId}::uuid`,
136
+ );
137
+ if (existing) {
138
+ if (
139
+ existing.request_digest !== input.requestDigest ||
140
+ existing.return_url !== input.returnUrl ||
141
+ stableJson(existing.external_continuation ?? null) !== stableJson(origin)
142
+ ) {
143
+ throw new ConnectAttemptConflictError(
144
+ "Connect idempotency key was reused with different input",
145
+ );
146
+ }
147
+ return projection(existing, scope);
148
+ }
149
+ const [count] = await rawRows<{ count: number }>(
150
+ tx,
151
+ sql`select count(*)::int as count from connect_attempts
152
+ where workspace_id = ${scope.workspaceId}::uuid and subject_id = ${scope.subjectId}
153
+ and (expires_at > now() or operation_id is not null)
154
+ and projection->>'state' not in ('complete','cancelled','expired')`,
155
+ );
156
+ if ((count?.count ?? 0) >= 32)
157
+ throw new ConnectAttemptConflictError("Too many pending Connect attempts");
158
+ const [row] = await rawRows<Row>(
159
+ tx,
160
+ sql`insert into connect_attempts
161
+ (id, account_id, workspace_id, subject_id, idempotency_key_hash, request_digest, return_url, projection, expires_at, external_continuation)
162
+ values (${value.id}::uuid, ${scope.accountId}::uuid, ${scope.workspaceId}::uuid, ${scope.subjectId},
163
+ ${key}, ${input.requestDigest}, ${input.returnUrl}, ${JSON.stringify(value)}::jsonb, ${value.expiresAt}::timestamptz, ${origin ? JSON.stringify(origin) : null}::jsonb)
164
+ returning *`,
165
+ );
166
+ if (!row) throw new Error("Connect attempt insert returned no result");
167
+ return projection(row, scope);
168
+ });
169
+ }
170
+
171
+ export async function getConnectAttempt(
172
+ db: Database,
173
+ scope: ConnectActorScope,
174
+ id: string,
175
+ ): Promise<{
176
+ attempt: Attempt;
177
+ returnUrl: string;
178
+ operationInFlight: boolean;
179
+ }> {
180
+ return scoped(db, scope, async (tx) => {
181
+ const row = await lockAttempt(tx, scope, id);
182
+ const current = projection(row, scope);
183
+ if (row.expired && !["complete", "cancelled", "expired", "uncertain"].includes(current.state)) {
184
+ const expired = ConnectAttempt.parse({
185
+ ...current,
186
+ revision: current.revision + 1,
187
+ state: row.operation_id ? "uncertain" : "expired",
188
+ nextAction: { type: "none" },
189
+ });
190
+ await tx.execute(sql`update connect_attempts set projection = ${JSON.stringify(expired)}::jsonb,
191
+ updated_at = now() where id = ${row.id}::uuid`);
192
+ row.projection = expired;
193
+ }
194
+ return {
195
+ attempt: projection(row, scope),
196
+ returnUrl: row.return_url,
197
+ operationInFlight: row.operation_id !== null,
198
+ };
199
+ });
200
+ }
201
+
202
+ export async function listPendingConnectAttempts(
203
+ db: Database,
204
+ scope: ConnectActorScope,
205
+ ): Promise<Attempt[]> {
206
+ return scoped(db, scope, async (tx) => {
207
+ const rows = await rawRows<Row>(
208
+ tx,
209
+ sql`select * from connect_attempts
210
+ where workspace_id = ${scope.workspaceId}::uuid and account_id = ${scope.accountId}::uuid
211
+ and subject_id = ${scope.subjectId} and (expires_at > now() or operation_id is not null)
212
+ and projection->>'state' not in ('complete','cancelled','expired')
213
+ order by created_at desc, id desc limit 32`,
214
+ );
215
+ return rows.map((row) => projection(row, scope));
216
+ });
217
+ }
218
+
219
+ /** A claim is committed before remote effects. Never expire/reclaim it merely
220
+ * because the caller timed out: the provider may have accepted the operation. */
221
+ export async function claimConnectOperation(
222
+ db: Database,
223
+ scope: ConnectActorScope,
224
+ input: {
225
+ attemptId: string;
226
+ expectedRevision: number;
227
+ operationId: string;
228
+ inputDigest: string;
229
+ authorize?: ConnectOperationAuthorization;
230
+ },
231
+ ): Promise<{ status: "claimed"; attempt: Attempt } | { status: "replayed"; attempt: Attempt }> {
232
+ validateOperation(input.operationId, input.inputDigest);
233
+ return scoped(db, scope, async (tx) => {
234
+ const row = await lockAttempt(tx, scope, input.attemptId);
235
+ const current = projection(row, scope);
236
+ await input.authorize?.(
237
+ tx,
238
+ structuredClone(current),
239
+ row.external_continuation ? ExternalActorContinuation.parse(row.external_continuation) : null,
240
+ );
241
+ const receipt = receiptFor(row, input.operationId, input.inputDigest, scope);
242
+ if (receipt) {
243
+ return { status: "replayed", attempt: receipt };
244
+ }
245
+ if (row.operation_id)
246
+ throw new ConnectAttemptConflictError(
247
+ "Connect operation is in flight or uncertain; reconcile before retrying",
248
+ );
249
+ if (
250
+ Object.keys(row.receipts).length >= 64 ||
251
+ current.revision !== input.expectedRevision ||
252
+ row.expired ||
253
+ ["complete", "cancelled", "expired"].includes(current.state)
254
+ ) {
255
+ throw new ConnectAttemptConflictError();
256
+ }
257
+ await tx.execute(sql`update connect_attempts set operation_id = ${input.operationId},
258
+ operation_digest = ${input.inputDigest}, updated_at = now() where id = ${row.id}::uuid`);
259
+ return { status: "claimed", attempt: current };
260
+ });
261
+ }
262
+
263
+ /** Pass the same transaction to credential/installation persistence through
264
+ * commit so its receipt and the public setup result become visible atomically.
265
+ * Do not perform network effects inside commit. */
266
+ export async function finishConnectOperation(
267
+ db: Database,
268
+ scope: ConnectActorScope,
269
+ input: {
270
+ attemptId: string;
271
+ operationId: string;
272
+ inputDigest: string;
273
+ commit: (tx: Database, current: Attempt) => Promise<Attempt>;
274
+ authorize?: ConnectOperationAuthorization;
275
+ },
276
+ ): Promise<Attempt> {
277
+ validateOperation(input.operationId, input.inputDigest);
278
+ return scoped(db, scope, async (tx) => {
279
+ const row = await lockAttempt(tx, scope, input.attemptId);
280
+ const current = projection(row, scope);
281
+ await input.authorize?.(
282
+ tx,
283
+ structuredClone(current),
284
+ row.external_continuation ? ExternalActorContinuation.parse(row.external_continuation) : null,
285
+ );
286
+ const receipt = receiptFor(row, input.operationId, input.inputDigest, scope);
287
+ if (receipt) {
288
+ return receipt;
289
+ }
290
+ if (row.operation_id !== input.operationId || row.operation_digest !== input.inputDigest)
291
+ throw new ConnectAttemptConflictError();
292
+ const next = ConnectAttempt.parse(await input.commit(tx, structuredClone(current)));
293
+ if (
294
+ next.id !== current.id ||
295
+ next.workspaceId !== current.workspaceId ||
296
+ next.providerId !== current.providerId ||
297
+ next.ownership !== current.ownership ||
298
+ next.completionRequirement !== current.completionRequirement ||
299
+ stableJson(next.installationTarget ?? null) !==
300
+ stableJson(current.installationTarget ?? null) ||
301
+ (current.source !== undefined && stableJson(next.source) !== stableJson(current.source)) ||
302
+ next.expiresAt !== current.expiresAt ||
303
+ next.revision !== current.revision + 1 ||
304
+ (current.credentialsCommitted && !next.credentialsCommitted) ||
305
+ (current.integrationInstalled && !next.integrationInstalled)
306
+ )
307
+ throw new ConnectAttemptConflictError();
308
+ const receipts = {
309
+ ...row.receipts,
310
+ [input.operationId]: { digest: input.inputDigest, result: next },
311
+ };
312
+ await tx.execute(sql`update connect_attempts set projection = ${JSON.stringify(next)}::jsonb,
313
+ receipts = ${JSON.stringify(receipts)}::jsonb, operation_id = null, operation_digest = null,
314
+ updated_at = now() where id = ${row.id}::uuid`);
315
+ return next;
316
+ });
317
+ }
@@ -14,6 +14,7 @@ import {
14
14
  type UserResourceDelegation,
15
15
  } from "@opengeni/contracts";
16
16
  import { sql } from "drizzle-orm";
17
+ import { getExternalLinkTurnAuthorization } from "./external-link-work";
17
18
  import { rawRows, setSubjectRlsContext, withRlsContext, type Database } from "./database";
18
19
  import {
19
20
  issueSelfUserResourceGrant,
@@ -218,6 +219,14 @@ export async function resolveAcceptedConnectionUse(
218
219
  db,
219
220
  { accountId: input.accountId, workspaceId: input.workspaceId },
220
221
  async (scopedDb) => {
222
+ const linked = await getExternalLinkTurnAuthorization(scopedDb, input, input.turnId);
223
+ if (
224
+ linked &&
225
+ (!linked.authorized ||
226
+ (!linked.permissions.includes("connections:read") &&
227
+ !linked.permissions.includes("workspace:admin")))
228
+ )
229
+ return { status: "denied", reason: "grant_status_inactive" };
221
230
  const [row] = await rawRows<{
222
231
  authorizationStatus: "authorized" | "denied";
223
232
  denialReason: string | null;
@@ -0,0 +1,35 @@
1
+ import { sql } from "drizzle-orm";
2
+ import { Permission } from "@opengeni/contracts";
3
+ import { rawRows, withRlsContext, type Database } from "./database";
4
+
5
+ /** Callback authority for a server-signed API-key principal. No bearer is stored
6
+ * in OAuth state. Organization keys remain shared-workspace-only; workspace keys
7
+ * remain bound to their original workspace. Hold the row lock through commit. */
8
+ export async function lockConnectionSetupKey(
9
+ db: Database,
10
+ input: { accountId: string; workspaceId: string; subjectId: string },
11
+ ): Promise<Permission[] | null> {
12
+ const match = /^api_key:([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i.exec(
13
+ input.subjectId,
14
+ );
15
+ if (!match) return null;
16
+ return withRlsContext(
17
+ db,
18
+ { accountId: input.accountId, workspaceId: input.workspaceId },
19
+ async (tx) => {
20
+ const [row] = await rawRows<{ permissions: unknown }>(
21
+ tx,
22
+ sql`
23
+ select k.permissions from api_keys k join workspaces w
24
+ on w.id = ${input.workspaceId}::uuid and w.account_id = k.account_id
25
+ where k.id = ${match[1]}::uuid and k.account_id = ${input.accountId}::uuid
26
+ and k.revoked_at is null and (k.expires_at is null or k.expires_at > clock_timestamp())
27
+ and get_workspace_kind(w.account_id, w.id) = 'shared'
28
+ and ((k.workspace_id is null and k.credential_kind = 'organization')
29
+ or k.workspace_id = w.id)
30
+ for share of k`,
31
+ );
32
+ return row ? Permission.array().parse(row.permissions) : null;
33
+ },
34
+ );
35
+ }
@@ -11,6 +11,9 @@ import type {
11
11
  McpConnectionResourceScope,
12
12
  McpCredentialAuthNeededReason,
13
13
  McpCredentialsRequest,
14
+ McpCredentialResolution,
15
+ McpGatewayCredentialsRequest,
16
+ McpGatewayCredentialResolution,
14
17
  TurnInitiator,
15
18
  TurnInitiatorContext,
16
19
  } from "@opengeni/contracts";
@@ -183,6 +186,11 @@ export type HostMcpCredentialResolverContext = {
183
186
  initiator: TurnInitiator;
184
187
  initiatorContext: TurnInitiatorContext;
185
188
  surface: McpCredentialsRequest["surface"];
189
+ /** Backend-owned attempt liveness, independent of binding delegation. */
190
+ authorizeExecution?: (request: McpCredentialsRequest) => Promise<boolean>;
191
+ /** Backend-owned live validator. Never populate from caller JSON or a host
192
+ * credential response. Omission denies every explicit durable reference. */
193
+ authorizeDurableBinding?: (request: McpCredentialsRequest) => Promise<boolean>;
186
194
  };
187
195
 
188
196
  export class HostMcpCredentialScopeError extends Error {
@@ -247,8 +255,15 @@ function isOfficialGmailRestDestination(
247
255
  */
248
256
  export function buildHostConnectionTokenResolver(
249
257
  resolve: NonNullable<ConnectionCredentialsPort["mcpCredentials"]>,
250
- context: HostMcpCredentialResolverContext,
258
+ suppliedContext: HostMcpCredentialResolverContext,
251
259
  ): (input: ResolveConnectionCredentialInput) => Promise<ResolveConnectionCredentialResult> {
260
+ // Bind the accepted execution once. A caller retaining its constructor
261
+ // object must not retarget later requests or replace the post-use validator.
262
+ const context: HostMcpCredentialResolverContext = {
263
+ ...suppliedContext,
264
+ initiator: structuredClone(suppliedContext.initiator),
265
+ initiatorContext: structuredClone(suppliedContext.initiatorContext),
266
+ };
252
267
  return async (input) => {
253
268
  if (input.workspaceId !== context.workspaceId) {
254
269
  throw new HostMcpCredentialScopeError("workspaceId");
@@ -281,6 +296,9 @@ export function buildHostConnectionTokenResolver(
281
296
  ...(input.connectionRef.authoritySource
282
297
  ? { authoritySource: input.connectionRef.authoritySource }
283
298
  : {}),
299
+ ...(input.connectionRef.hostBinding
300
+ ? { hostBinding: { ...input.connectionRef.hostBinding } }
301
+ : {}),
284
302
  ...(input.connectionRef.provider ? { provider: input.connectionRef.provider } : {}),
285
303
  ...(input.connectionRef.connectionId
286
304
  ? { connectionId: input.connectionRef.connectionId }
@@ -305,62 +323,173 @@ export function buildHostConnectionTokenResolver(
305
323
  ...(toolName ? { toolName } : {}),
306
324
  ...(input.subjectId ? { callerSubjectId: input.subjectId } : {}),
307
325
  };
308
- const result = await resolve(request);
309
- assertHostMcpCredentialScope(result, context);
310
- assertHostMcpCredentialBinding(result, input.connectionRef);
311
- if (result.status === "auth_needed") {
312
- const authorizationUrl = normalizedAuthorizationUrl(result.authorizationUrl);
313
- return {
314
- status: "auth_needed",
315
- reason: result.reason,
316
- providerDomain: result.providerDomain,
317
- authoritySource: "host",
318
- ...(result.provider ? { provider: result.provider } : {}),
319
- ...(result.connectionId ? { connectionId: result.connectionId } : {}),
320
- ...(result.scopes ? { scopes: [...result.scopes] } : {}),
321
- ...(result.resource ? { resource: result.resource } : {}),
322
- ...(result.selectedResources
323
- ? { selectedResources: copySelectedResources(result.selectedResources) }
324
- : {}),
325
- ...(authorizationUrl ? { authorizationUrl } : {}),
326
- };
326
+ const snapshot = structuredClone(request);
327
+ const requestedRef = structuredClone(input.connectionRef);
328
+ const credentialTarget = input.credentialTarget ?? "mcp";
329
+ const durableDenial = (
330
+ reason: McpCredentialAuthNeededReason,
331
+ ): ResolveConnectionCredentialResult => ({
332
+ status: "auth_needed",
333
+ reason,
334
+ providerDomain: requestedRef.providerDomain,
335
+ authoritySource: "host",
336
+ ...(requestedRef.connectionId ? { connectionId: requestedRef.connectionId } : {}),
337
+ });
338
+ const authorized = async () => {
339
+ try {
340
+ if (
341
+ context.authorizeExecution &&
342
+ !(await context.authorizeExecution(structuredClone(snapshot)))
343
+ )
344
+ return "resource_scope_unavailable" as const;
345
+ if (
346
+ requestedRef.hostBinding &&
347
+ !(await context.authorizeDurableBinding?.(structuredClone(snapshot)))
348
+ )
349
+ return "personal_authority_unavailable" as const;
350
+ return null;
351
+ } catch {
352
+ return "refresh_failed" as const;
353
+ }
354
+ };
355
+ if (requestedRef.hostBinding && !context.authorizeDurableBinding)
356
+ return durableDenial("unsupported_auth");
357
+ const requiresAuthorization = Boolean(requestedRef.hostBinding || context.authorizeExecution);
358
+ if (requiresAuthorization) {
359
+ const denial = await authorized();
360
+ if (denial) return durableDenial(denial);
327
361
  }
328
- if (result.connectionId.length === 0) {
329
- throw new Error("host MCP credential returned an empty connectionId");
362
+ const result = await resolve(structuredClone(snapshot));
363
+ if (requiresAuthorization) {
364
+ const denial = await authorized();
365
+ if (denial) return durableDenial(denial);
330
366
  }
331
- const explicitPlacements =
332
- result.placements === undefined
333
- ? undefined
334
- : normalizedCredentialPlacements(result.placements);
335
- const headers = normalizedHostCredentialHeaders(
336
- result.headers,
337
- explicitPlacements !== undefined,
338
- );
339
- if (explicitPlacements) {
340
- if (!sameHeaderMap(headers, headerMapForPlacements(explicitPlacements))) {
341
- throw new HostMcpCredentialBindingError("credentialPlacements");
342
- }
343
- if (
344
- (input.credentialTarget ?? "mcp") !== "http_api" &&
345
- explicitPlacements.some((placement) => placement.carrier !== "header")
346
- ) {
347
- throw new HostMcpCredentialBindingError("credentialPlacements");
348
- }
367
+ assertHostMcpCredentialScope(result, context);
368
+ const normalized = normalizeHostCredentialResolution(result, requestedRef, credentialTarget);
369
+ return normalized.status === "ok" && requiresAuthorization
370
+ ? { ...normalized, authorizeProviderRequest: async () => (await authorized()) === null }
371
+ : normalized;
372
+ };
373
+ }
374
+
375
+ /** Authenticated request-time adapter. No synthetic session/turn authority and
376
+ * no durable binding admission; every physical use must retain live API proof. */
377
+ export function buildHostGatewayConnectionTokenResolver(
378
+ resolve: NonNullable<ConnectionCredentialsPort["mcpGatewayCredentials"]>,
379
+ suppliedContext: Pick<McpGatewayCredentialsRequest, "accountId" | "workspaceId" | "authority">,
380
+ reauthorize: () => Promise<void>,
381
+ ): (input: ResolveConnectionCredentialInput) => Promise<ResolveConnectionCredentialResult> {
382
+ const context = structuredClone(suppliedContext);
383
+ return async (input) => {
384
+ const ref = structuredClone(input.connectionRef);
385
+ const deny = (reason: McpCredentialAuthNeededReason): ResolveConnectionCredentialResult => ({
386
+ status: "auth_needed",
387
+ reason,
388
+ authoritySource: "host",
389
+ providerDomain: ref.providerDomain,
390
+ ...(ref.connectionId ? { connectionId: ref.connectionId } : {}),
391
+ });
392
+ if (input.workspaceId !== context.workspaceId)
393
+ throw new HostMcpCredentialScopeError("workspaceId");
394
+ if (ref.authoritySource !== "host" || ref.hostBinding) return deny("unsupported_auth");
395
+ const destinationUrl = canonicalHttpUrl(input.destinationUrl);
396
+ if (
397
+ !destinationUrl ||
398
+ (!destinationHostMatchesProvider(destinationUrl, ref.providerDomain) &&
399
+ !isOfficialGmailRestDestination(destinationUrl, ref))
400
+ )
401
+ throw new HostMcpCredentialBindingError("destinationUrl");
402
+ const request: McpGatewayCredentialsRequest = {
403
+ ...structuredClone(context),
404
+ surface: "workspace_gateway",
405
+ requestId: crypto.randomUUID(),
406
+ destinationUrl,
407
+ credentialTarget: input.credentialTarget ?? "mcp",
408
+ serverId: input.serverId,
409
+ connectionRef: ref,
410
+ forceRefresh: input.forceRefresh === true,
411
+ ...((input.toolName ?? input.toolId) ? { toolName: input.toolName ?? input.toolId } : {}),
412
+ };
413
+ await reauthorize();
414
+ const result = await resolve(structuredClone(request));
415
+ await reauthorize();
416
+ for (const field of ["accountId", "workspaceId", "requestId"] as const) {
417
+ if (result[field] !== request[field])
418
+ throw new Error(`host gateway credential ${field} scope mismatch`);
349
419
  }
350
- const expiresAt = parseHostCredentialExpiry(result.expiresAt);
420
+ const normalized = normalizeHostCredentialResolution(
421
+ result,
422
+ ref,
423
+ request.credentialTarget ?? "mcp",
424
+ );
425
+ if (normalized.status !== "ok") return normalized;
351
426
  return {
352
- status: "ok",
353
- headers,
427
+ ...normalized,
428
+ authorizeProviderRequest: async () => {
429
+ try {
430
+ await reauthorize();
431
+ return true;
432
+ } catch {
433
+ return false;
434
+ }
435
+ },
436
+ };
437
+ };
438
+ }
439
+
440
+ function normalizeHostCredentialResolution(
441
+ result: McpCredentialResolution | McpGatewayCredentialResolution,
442
+ requestedRef: McpServerConnectionRef,
443
+ credentialTarget: "mcp" | "http_api",
444
+ ): ResolveConnectionCredentialResult {
445
+ assertHostMcpCredentialBinding(result, requestedRef);
446
+ if (result.status === "auth_needed") {
447
+ const authorizationUrl = normalizedAuthorizationUrl(result.authorizationUrl);
448
+ return {
449
+ status: "auth_needed",
450
+ reason: result.reason,
451
+ providerDomain: result.providerDomain,
354
452
  authoritySource: "host",
355
- ...(explicitPlacements ? { placements: explicitPlacements } : {}),
356
- connectionId: result.connectionId,
357
- ...(expiresAt !== undefined ? { expiresAt } : {}),
453
+ ...(result.provider ? { provider: result.provider } : {}),
454
+ ...(result.connectionId ? { connectionId: result.connectionId } : {}),
455
+ ...(result.scopes ? { scopes: [...result.scopes] } : {}),
456
+ ...(result.resource ? { resource: result.resource } : {}),
457
+ ...(result.selectedResources
458
+ ? { selectedResources: copySelectedResources(result.selectedResources) }
459
+ : {}),
460
+ ...(authorizationUrl ? { authorizationUrl } : {}),
358
461
  };
462
+ }
463
+ if (result.connectionId.length === 0) {
464
+ throw new Error("host MCP credential returned an empty connectionId");
465
+ }
466
+ const explicitPlacements =
467
+ result.placements === undefined ? undefined : normalizedCredentialPlacements(result.placements);
468
+ const headers = normalizedHostCredentialHeaders(result.headers, explicitPlacements !== undefined);
469
+ if (explicitPlacements) {
470
+ if (!sameHeaderMap(headers, headerMapForPlacements(explicitPlacements))) {
471
+ throw new HostMcpCredentialBindingError("credentialPlacements");
472
+ }
473
+ if (
474
+ credentialTarget !== "http_api" &&
475
+ explicitPlacements.some((placement) => placement.carrier !== "header")
476
+ ) {
477
+ throw new HostMcpCredentialBindingError("credentialPlacements");
478
+ }
479
+ }
480
+ const expiresAt = parseHostCredentialExpiry(result.expiresAt);
481
+ return {
482
+ status: "ok",
483
+ headers,
484
+ authoritySource: "host",
485
+ ...(explicitPlacements ? { placements: explicitPlacements } : {}),
486
+ connectionId: result.connectionId,
487
+ ...(expiresAt !== undefined ? { expiresAt } : {}),
359
488
  };
360
489
  }
361
490
 
362
491
  function assertHostMcpCredentialBinding(
363
- result: Awaited<ReturnType<NonNullable<ConnectionCredentialsPort["mcpCredentials"]>>>,
492
+ result: McpCredentialResolution | McpGatewayCredentialResolution,
364
493
  requested: McpServerConnectionRef,
365
494
  ): void {
366
495
  if (result.providerDomain !== requested.providerDomain) {
@@ -446,7 +575,7 @@ function assertHostMcpCredentialScope(
446
575
  }
447
576
  }
448
577
 
449
- function normalizedHostCredentialHeaders(
578
+ export function normalizedHostCredentialHeaders(
450
579
  headers: Record<string, string>,
451
580
  allowEmpty = false,
452
581
  ): Record<string, string> {