@opengeni/db 4.2.2 → 4.3.2-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.
- package/dist/canonical-human-identities.js +3 -3
- package/dist/{chunk-S4Y42VSX.js → chunk-AI4NC6XW.js} +212 -7
- package/dist/chunk-AI4NC6XW.js.map +1 -0
- package/dist/{chunk-SNLIKELL.js → chunk-DVSLOUEI.js} +2 -2
- package/dist/{chunk-6GAPAYZR.js → chunk-F4BIHJ7J.js} +562 -86
- package/dist/chunk-F4BIHJ7J.js.map +1 -0
- package/dist/{chunk-B2KR3TEM.js → chunk-I4ITNMX2.js} +2 -2
- package/dist/{chunk-MGVOYVPS.js → chunk-PFBUHOI5.js} +3 -3
- package/dist/{chunk-6Y7ZDVJ5.js → chunk-UUPY6IGP.js} +2 -2
- package/dist/{chunk-FPTI4NP4.js → chunk-UVBPOGA7.js} +2 -2
- package/dist/{chunk-7WX3R3YL.js → chunk-XBXANJQS.js} +7 -2
- package/dist/chunk-XBXANJQS.js.map +1 -0
- package/dist/{chunk-6IQ4X35Y.js → chunk-YVYBAWBU.js} +46 -1
- package/dist/chunk-YVYBAWBU.js.map +1 -0
- package/dist/connect-attempts.d.ts +60 -0
- package/dist/connection-setup-authority.d.ts +10 -0
- package/dist/connection-token-resolver.d.ts +11 -2
- package/dist/editable-artifact-durable-export.js +2 -2
- package/dist/editable-artifacts.js +3 -3
- package/dist/external-identities.d.ts +16 -0
- package/dist/external-identity-links.d.ts +74 -0
- package/dist/external-link-work.d.ts +51 -0
- package/dist/host-mcp-binding-match.d.ts +5 -0
- package/dist/host-mcp-bindings.d.ts +73 -0
- package/dist/host-mcp-task-authority.d.ts +47 -0
- package/dist/host-selection-identity.d.ts +6 -0
- package/dist/index.d.ts +60 -35
- package/dist/index.js +15247 -13793
- package/dist/index.js.map +1 -1
- package/dist/live-session-attempt.d.ts +275 -0
- package/dist/managed-auth-session-sets.js +3 -3
- package/dist/memory-domain.d.ts +3 -3
- package/dist/provision-roles.js +1 -1
- package/dist/retained-provider-commands.js +2 -2
- package/dist/runtime-posture.d.ts +6 -6
- package/dist/scheduled-task-revision-authority.d.ts +11 -0
- package/dist/schema.d.ts +1279 -9
- package/dist/schema.js +11 -1
- package/dist/session-background-commands.js +4 -4
- package/dist/session-command-output.js +4 -4
- package/dist/session-event-slices.js +2 -2
- package/dist/session-queue-commands.d.ts +7 -0
- package/dist/session-tenancy.d.ts +2 -0
- package/dist/session-tenancy.js +3 -3
- package/dist/video-generation.js +3 -3
- package/dist/workspace-authority.d.ts +4 -4
- package/dist/workspace-tool-defaults.js +4 -4
- package/drizzle/0437_organization_scoped_external_workspaces.sql +71 -0
- package/drizzle/0438_durable_connect_attempts.sql +95 -0
- package/drizzle/0439_external_identity_provisioning.sql +128 -0
- package/drizzle/0440_external_workspace_member_removal.sql +59 -0
- package/drizzle/0441_external_identity_membership_lifecycle.sql +163 -0
- package/drizzle/0442_external_owning_user_authority.sql +81 -0
- package/drizzle/0443_host_mcp_binding_registry.sql +93 -0
- package/drizzle/0444_host_mcp_delegations.sql +129 -0
- package/drizzle/0445_host_mcp_turn_authorities.sql +145 -0
- package/drizzle/0446_host_mcp_causal_continuation.sql +126 -0
- package/drizzle/0447_host_mcp_task_authorities.sql +264 -0
- package/drizzle/0448_host_mcp_child_authority.sql +115 -0
- package/drizzle/0449_external_identity_link_lifecycle.sql +107 -0
- package/drizzle/0450_external_identity_link_work.sql +179 -0
- package/drizzle/0451_external_link_preview_and_permission_ceiling.sql +82 -0
- package/drizzle/0452_external_link_scheduled_origin.sql +34 -0
- package/drizzle/0453_host_mcp_native_owner.sql +35 -0
- package/drizzle/0454_connect_origin_authority.sql +42 -0
- package/drizzle/0455_external_link_inventory_labels.sql +44 -0
- package/drizzle/0456_social_connection_versions.sql +14 -0
- package/drizzle/0457_canonical_session_scope_subject.sql +46 -0
- package/drizzle/0458_skill_review_wire_compatibility.sql +36 -0
- package/package.json +6 -6
- package/src/connect-attempts.ts +317 -0
- package/src/connection-authority.ts +9 -0
- package/src/connection-setup-authority.ts +35 -0
- package/src/connection-token-resolver.ts +177 -48
- package/src/external-identities.ts +70 -0
- package/src/external-identity-links.ts +364 -0
- package/src/external-link-work.ts +228 -0
- package/src/host-mcp-binding-match.ts +33 -0
- package/src/host-mcp-bindings.ts +741 -0
- package/src/host-mcp-task-authority.ts +383 -0
- package/src/host-selection-identity.ts +29 -0
- package/src/index.ts +481 -151
- package/src/live-session-attempt.ts +85 -0
- package/src/managed-human-provisioning.ts +6 -1
- package/src/memory-domain.ts +6 -11
- package/src/provision-roles.ts +3 -0
- package/src/runtime-posture.ts +47 -0
- package/src/scheduled-task-revision-authority.ts +30 -0
- package/src/schema.ts +233 -7
- package/src/session-control.ts +14 -0
- package/src/session-queue-commands.ts +11 -0
- package/src/session-tenancy.ts +7 -0
- package/src/workspace-artifacts.ts +10 -4
- package/src/workspace-authority.ts +5 -5
- package/dist/chunk-6GAPAYZR.js.map +0 -1
- package/dist/chunk-6IQ4X35Y.js.map +0 -1
- package/dist/chunk-7WX3R3YL.js.map +0 -1
- package/dist/chunk-S4Y42VSX.js.map +0 -1
- /package/dist/{chunk-SNLIKELL.js.map → chunk-DVSLOUEI.js.map} +0 -0
- /package/dist/{chunk-B2KR3TEM.js.map → chunk-I4ITNMX2.js.map} +0 -0
- /package/dist/{chunk-MGVOYVPS.js.map → chunk-PFBUHOI5.js.map} +0 -0
- /package/dist/{chunk-6Y7ZDVJ5.js.map → chunk-UUPY6IGP.js.map} +0 -0
- /package/dist/{chunk-FPTI4NP4.js.map → chunk-UVBPOGA7.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
|
-
|
|
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
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
329
|
-
|
|
362
|
+
const result = await resolve(structuredClone(snapshot));
|
|
363
|
+
if (requiresAuthorization) {
|
|
364
|
+
const denial = await authorized();
|
|
365
|
+
if (denial) return durableDenial(denial);
|
|
330
366
|
}
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
|
420
|
+
const normalized = normalizeHostCredentialResolution(
|
|
421
|
+
result,
|
|
422
|
+
ref,
|
|
423
|
+
request.credentialTarget ?? "mcp",
|
|
424
|
+
);
|
|
425
|
+
if (normalized.status !== "ok") return normalized;
|
|
351
426
|
return {
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
...(
|
|
356
|
-
connectionId: result.connectionId,
|
|
357
|
-
...(
|
|
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:
|
|
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> {
|