@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,70 @@
1
+ import { sql } from "drizzle-orm";
2
+ import { Permission } from "@opengeni/contracts";
3
+ import {
4
+ ExternalIdentity,
5
+ ExternalIdentityReference,
6
+ } from "@opengeni/contracts/external-identities";
7
+ import { rawRows, withAccountRls, type Database } from "./database";
8
+
9
+ /** Pass an open transaction to retain the key lock through the protected write. */
10
+ export async function lockActiveExternalOrganizationKey(
11
+ tx: Database,
12
+ accountId: string,
13
+ keyId: string,
14
+ ): Promise<Permission[] | null> {
15
+ return withAccountRls(tx, accountId, async (scoped) => {
16
+ const [row] = await rawRows<{ permissions: unknown }>(
17
+ scoped,
18
+ sql`select permissions from api_keys
19
+ where account_id = ${accountId}::uuid and id = ${keyId}::uuid
20
+ and workspace_id is null and credential_kind = 'organization'
21
+ and revoked_at is null and (expires_at is null or expires_at > clock_timestamp()) for share`,
22
+ );
23
+ return row ? Permission.array().parse(row.permissions) : null;
24
+ });
25
+ }
26
+
27
+ /** Transaction-local fence shared with organization member lifecycle writers.
28
+ * Caller must pass an open transaction, not a pool-level database handle. */
29
+ export async function lockExternalWorkspaceMembershipLifecycle(
30
+ tx: Database,
31
+ accountId: string,
32
+ ): Promise<void> {
33
+ await tx.execute(
34
+ sql`select pg_advisory_xact_lock(hashtextextended(${`organization-membership:${accountId}`}, 0))`,
35
+ );
36
+ }
37
+
38
+ /** Internal lifecycle seam. Caller must authenticate an organization key and
39
+ * establish its exact account before invoking; this does not admit HTTP users.
40
+ * It never creates shared-workspace membership or reactivates removed users. */
41
+ export async function ensureExternalIdentity(
42
+ db: Database,
43
+ input: {
44
+ accountId: string;
45
+ externalId: string;
46
+ source?: string;
47
+ },
48
+ ): Promise<ExternalIdentity> {
49
+ const accountId = input.accountId;
50
+ const reference = ExternalIdentityReference.parse({
51
+ externalId: input.externalId,
52
+ source: input.source,
53
+ });
54
+ return withAccountRls(db, accountId, async (tx) => {
55
+ const [row] = await rawRows<{ result: unknown }>(
56
+ tx,
57
+ sql`select ensure_external_identity(
58
+ ${accountId}::uuid, ${reference.source}, ${reference.externalId}) as result`,
59
+ );
60
+ const result = ExternalIdentity.parse(row?.result);
61
+ if (
62
+ result.accountId !== accountId ||
63
+ result.source !== reference.source ||
64
+ result.externalId !== reference.externalId
65
+ ) {
66
+ throw new Error("External identity scope mismatch");
67
+ }
68
+ return result;
69
+ });
70
+ }
@@ -0,0 +1,364 @@
1
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
2
+ import { sql } from "drizzle-orm";
3
+ import {
4
+ BeginExternalIdentityLinkRequest,
5
+ BeginExternalIdentityLinkResponse,
6
+ ConfirmExternalIdentityLinkRequest,
7
+ ExternalIdentityLink,
8
+ ExternalIdentityLinkPage,
9
+ ExternalIdentityReference,
10
+ type ExternalIdentity,
11
+ } from "@opengeni/contracts/external-identities";
12
+ import { rawRows, withAccountRls, setSubjectRlsContext, type Database } from "./database";
13
+ import { lockExternalWorkspaceMembershipLifecycle } from "./external-identities";
14
+
15
+ type LinkRow = {
16
+ id: string;
17
+ account_id: string;
18
+ external_identity_id: string;
19
+ external_subject_id: string;
20
+ external_authorization_revision: number | string;
21
+ native_subject_id: string | null;
22
+ native_membership_id: string | null;
23
+ native_authorization_revision: number | string | null;
24
+ status: "pending" | "active" | "revoked";
25
+ revision: number | string;
26
+ permissions: string[];
27
+ challenge_digest: string;
28
+ confirm_before: Date | string;
29
+ expires_at: Date | string | null;
30
+ };
31
+ export class ExternalIdentityLinkConflictError extends Error {
32
+ readonly name = "ExternalIdentityLinkConflictError";
33
+ constructor() {
34
+ super("Identity link changed or is unavailable");
35
+ }
36
+ }
37
+ const digest = (value: string) => createHash("sha256").update(value).digest("hex");
38
+ const iso = (value: Date | string) => new Date(value).toISOString();
39
+ function project(row: LinkRow): ExternalIdentityLink {
40
+ const expiry = row.status === "pending" ? row.confirm_before : row.expires_at;
41
+ return ExternalIdentityLink.parse({
42
+ id: row.id,
43
+ accountId: row.account_id,
44
+ externalIdentityId: row.external_identity_id,
45
+ nativeSubjectId: row.native_subject_id,
46
+ status:
47
+ row.status !== "revoked" && expiry !== null && new Date(expiry).getTime() <= Date.now()
48
+ ? "expired"
49
+ : row.status,
50
+ revision: Number(row.revision),
51
+ permissions: row.permissions,
52
+ expiresAt: row.expires_at === null ? null : iso(row.expires_at),
53
+ });
54
+ }
55
+ async function activeMembership(tx: Database, accountId: string, subjectId: string) {
56
+ const [prior] = await rawRows<{ subject: string }>(
57
+ tx,
58
+ sql`select coalesce(current_setting('opengeni.subject_id', true), '') as subject`,
59
+ );
60
+ // These subjects come from verified admission or the immutable stored link,
61
+ // not caller-selected impersonation. Restore the GUC after each named probe.
62
+ let succeeded = false;
63
+ let row:
64
+ | { membership: { id: string; authorizationRevision: number; personalWorkspaceId: string } }
65
+ | undefined;
66
+ try {
67
+ await setSubjectRlsContext(tx, subjectId);
68
+ [row] = await rawRows<{
69
+ membership: { id: string; authorizationRevision: number; personalWorkspaceId: string };
70
+ }>(
71
+ tx,
72
+ sql`select value as membership from jsonb_array_elements(list_self_organization_memberships(${subjectId}))
73
+ where value ->> 'organizationId' = ${accountId} and value ->> 'status' = 'active'`,
74
+ );
75
+ succeeded = true;
76
+ } finally {
77
+ const restore = tx.execute(
78
+ sql`select set_config('opengeni.subject_id', ${prior?.subject ?? ""}, true)`,
79
+ );
80
+ if (succeeded) await restore;
81
+ else await restore.catch(() => undefined);
82
+ }
83
+ if (!row) throw new ExternalIdentityLinkConflictError();
84
+ return row.membership;
85
+ }
86
+
87
+ /** Internal persistence seam. The API must prove a host external actor and
88
+ * freeze its key permission ceiling before calling. No login is provisioned. */
89
+ export async function beginExternalIdentityLink(
90
+ db: Database,
91
+ identity: ExternalIdentity,
92
+ raw: unknown,
93
+ ) {
94
+ const input = BeginExternalIdentityLinkRequest.parse(raw);
95
+ return withAccountRls(db, identity.accountId, async (tx) => {
96
+ await lockExternalWorkspaceMembershipLifecycle(tx, identity.accountId);
97
+ const membership = await activeMembership(tx, identity.accountId, identity.subjectId);
98
+ if (
99
+ membership.id !== identity.organizationMembershipId ||
100
+ Number(membership.authorizationRevision) !== identity.authorizationRevision ||
101
+ identity.status !== "active"
102
+ )
103
+ throw new ExternalIdentityLinkConflictError();
104
+ const [quota] = await rawRows<{ count: number | string }>(
105
+ tx,
106
+ sql`select count(*) as count
107
+ from external_identity_links where account_id = ${identity.accountId}::uuid
108
+ and external_identity_id = ${identity.id}::uuid and status = 'pending'
109
+ and confirm_before > clock_timestamp()`,
110
+ );
111
+ if (Number(quota?.count ?? 0) >= 32) throw new ExternalIdentityLinkConflictError();
112
+ const challenge = randomBytes(32).toString("base64url");
113
+ const permissions = [...new Set(input.permissions)].sort();
114
+ const [row] = await rawRows<LinkRow>(
115
+ tx,
116
+ sql`insert into external_identity_links
117
+ (id, account_id, external_identity_id, external_subject_id, external_authorization_revision,
118
+ permissions, challenge_digest, confirm_before, expires_at)
119
+ values (${randomUUID()}::uuid, ${identity.accountId}::uuid, ${identity.id}::uuid,
120
+ ${identity.subjectId}, ${identity.authorizationRevision}, ${JSON.stringify(permissions)}::jsonb,
121
+ ${digest(challenge)}, clock_timestamp() + interval '10 minutes', ${input.expiresAt}::timestamptz)
122
+ returning *`,
123
+ );
124
+ if (!row) throw new Error("Identity link insert returned no row");
125
+ return BeginExternalIdentityLinkResponse.parse({
126
+ link: project(row),
127
+ challenge,
128
+ confirmBefore: iso(row.confirm_before),
129
+ });
130
+ });
131
+ }
132
+
133
+ /** Caller proves either exact host identity or native login, never a caller's
134
+ * claimed subject. The challenge is intentionally absent from every read. */
135
+ export async function getExternalIdentityLink(
136
+ db: Database,
137
+ input: {
138
+ accountId: string;
139
+ linkId: string;
140
+ subjectId: string;
141
+ },
142
+ ): Promise<ExternalIdentityLink | null> {
143
+ return withAccountRls(db, input.accountId, async (tx) => {
144
+ const [row] = await rawRows<LinkRow>(
145
+ tx,
146
+ sql`select * from external_identity_links
147
+ where account_id = ${input.accountId}::uuid and id = ${input.linkId}::uuid
148
+ and (external_subject_id = ${input.subjectId} or native_subject_id = ${input.subjectId})`,
149
+ );
150
+ return row ? project(row) : null;
151
+ });
152
+ }
153
+
154
+ /** Bounded inventory of the verified participant's links. Neither account-wide
155
+ * access nor a supplied cursor reveals another participant's pending requests. */
156
+ export async function listExternalIdentityLinks(
157
+ db: Database,
158
+ input: { accountId: string; subjectId: string; cursor?: string },
159
+ ): Promise<ExternalIdentityLinkPage> {
160
+ return withAccountRls(db, input.accountId, async (tx) => {
161
+ const rows = await rawRows<LinkRow>(
162
+ tx,
163
+ sql`
164
+ select * from external_identity_links
165
+ where account_id = ${input.accountId}::uuid
166
+ and (external_subject_id = ${input.subjectId} or native_subject_id = ${input.subjectId})
167
+ ${input.cursor ? sql`and id > ${input.cursor}::uuid` : sql``}
168
+ order by id limit 51`,
169
+ );
170
+ const links = rows.slice(0, 50).map(project);
171
+ await setSubjectRlsContext(tx, input.subjectId);
172
+ const [labels] = links.length
173
+ ? await rawRows<{ references: Record<string, unknown> }>(
174
+ tx,
175
+ sql`select get_external_identity_link_inventory_references(${input.accountId}::uuid, ARRAY[${sql.join(
176
+ links.map((link) => sql`${link.id}::uuid`),
177
+ sql`, `,
178
+ )}]) as references`,
179
+ )
180
+ : [];
181
+ return ExternalIdentityLinkPage.parse({
182
+ links: links.map((link) => ({
183
+ ...link,
184
+ ...(labels?.references[link.id]
185
+ ? { externalIdentity: ExternalIdentityReference.parse(labels.references[link.id]) }
186
+ : {}),
187
+ })),
188
+ nextCursor: rows.length > 50 ? links.at(-1)!.id : null,
189
+ });
190
+ });
191
+ }
192
+
193
+ /** Consent-screen projection, only after canonical native login admission.
194
+ * The short-lived challenge proves which host request the human is approving. */
195
+ export async function previewExternalIdentityLink(
196
+ db: Database,
197
+ input: {
198
+ accountId: string;
199
+ linkId: string;
200
+ challenge: string;
201
+ },
202
+ ): Promise<{ link: ExternalIdentityLink; externalIdentity: ExternalIdentityReference } | null> {
203
+ return withAccountRls(db, input.accountId, async (tx) => {
204
+ const [row] = await rawRows<LinkRow>(
205
+ tx,
206
+ sql`select * from external_identity_links
207
+ where account_id = ${input.accountId}::uuid and id = ${input.linkId}::uuid
208
+ and challenge_digest = ${digest(input.challenge)} and status = 'pending'
209
+ and confirm_before > clock_timestamp()
210
+ and (expires_at is null or expires_at > clock_timestamp())`,
211
+ );
212
+ if (!row) return null;
213
+ const [identity] = await rawRows<{ reference: unknown }>(
214
+ tx,
215
+ sql`select get_external_identity_link_reference(${input.accountId}::uuid, ${input.linkId}::uuid, ${digest(input.challenge)}) as reference`,
216
+ );
217
+ return identity?.reference
218
+ ? {
219
+ link: project(row),
220
+ externalIdentity: ExternalIdentityReference.parse(identity.reference),
221
+ }
222
+ : null;
223
+ });
224
+ }
225
+
226
+ /** Must only receive the canonical native-session subject after explicit UI
227
+ * consent. Possession of the challenge alone is not native authentication. */
228
+ export async function confirmExternalIdentityLink(
229
+ db: Database,
230
+ input: {
231
+ accountId: string;
232
+ linkId: string;
233
+ nativeSubjectId: string;
234
+ request: ConfirmExternalIdentityLinkRequest;
235
+ },
236
+ ): Promise<ExternalIdentityLink> {
237
+ const request = ConfirmExternalIdentityLinkRequest.parse(input.request);
238
+ if (!/^user:[^\r\n]+$/.test(input.nativeSubjectId)) throw new ExternalIdentityLinkConflictError();
239
+ return withAccountRls(db, input.accountId, async (tx) => {
240
+ await lockExternalWorkspaceMembershipLifecycle(tx, input.accountId);
241
+ const [row] = await rawRows<LinkRow>(
242
+ tx,
243
+ sql`select * from external_identity_links
244
+ where account_id = ${input.accountId}::uuid and id = ${input.linkId}::uuid
245
+ and challenge_digest = ${digest(request.challenge)} for update`,
246
+ );
247
+ if (!row) throw new ExternalIdentityLinkConflictError();
248
+ const external = await activeMembership(tx, input.accountId, row.external_subject_id);
249
+ const native = await activeMembership(tx, input.accountId, input.nativeSubjectId);
250
+ if (Number(external.authorizationRevision) !== Number(row.external_authorization_revision))
251
+ throw new ExternalIdentityLinkConflictError();
252
+ const permissions = [...new Set(request.permissions)].sort();
253
+ if (
254
+ row.status === "active" &&
255
+ Number(row.revision) === request.expectedRevision + 1 &&
256
+ row.native_subject_id === input.nativeSubjectId &&
257
+ row.native_membership_id === native.id &&
258
+ Number(row.native_authorization_revision) === Number(native.authorizationRevision) &&
259
+ JSON.stringify(row.permissions) === JSON.stringify(permissions) &&
260
+ project(row).status === "active"
261
+ )
262
+ return project(row);
263
+ if (
264
+ row.status !== "pending" ||
265
+ Number(row.revision) !== request.expectedRevision ||
266
+ project(row).status !== "pending" ||
267
+ permissions.some((permission) => !row.permissions.includes(permission))
268
+ )
269
+ throw new ExternalIdentityLinkConflictError();
270
+ const [confirmed] = await rawRows<LinkRow>(
271
+ tx,
272
+ sql`update external_identity_links set
273
+ status = 'active', revision = revision + 1, native_subject_id = ${input.nativeSubjectId},
274
+ native_membership_id = ${native.id}::uuid, native_authorization_revision = ${Number(native.authorizationRevision)},
275
+ permissions = ${JSON.stringify(permissions)}::jsonb, confirmed_at = clock_timestamp()
276
+ where id = ${row.id}::uuid returning *`,
277
+ );
278
+ if (!confirmed) throw new ExternalIdentityLinkConflictError();
279
+ return project(confirmed);
280
+ });
281
+ }
282
+
283
+ export async function revokeExternalIdentityLink(
284
+ db: Database,
285
+ input: {
286
+ accountId: string;
287
+ linkId: string;
288
+ subjectId: string;
289
+ expectedRevision: number;
290
+ },
291
+ ): Promise<ExternalIdentityLink> {
292
+ return withAccountRls(db, input.accountId, async (tx) => {
293
+ await lockExternalWorkspaceMembershipLifecycle(tx, input.accountId);
294
+ const [row] = await rawRows<LinkRow>(
295
+ tx,
296
+ sql`select * from external_identity_links
297
+ where account_id = ${input.accountId}::uuid and id = ${input.linkId}::uuid
298
+ and (external_subject_id = ${input.subjectId} or native_subject_id = ${input.subjectId}) for update`,
299
+ );
300
+ if (!row) throw new ExternalIdentityLinkConflictError();
301
+ if (row.status === "revoked" && Number(row.revision) === input.expectedRevision + 1)
302
+ return project(row);
303
+ if (row.status === "revoked" || Number(row.revision) !== input.expectedRevision)
304
+ throw new ExternalIdentityLinkConflictError();
305
+ const [revoked] = await rawRows<LinkRow>(
306
+ tx,
307
+ sql`update external_identity_links
308
+ set status = 'revoked', revision = revision + 1, revoked_at = clock_timestamp()
309
+ where id = ${row.id}::uuid returning *`,
310
+ );
311
+ if (!revoked) throw new ExternalIdentityLinkConflictError();
312
+ return project(revoked);
313
+ });
314
+ }
315
+
316
+ /** Live linked admission primitive. It retains membership/link locks when
317
+ * called in an open transaction. Never uses old creator audit as authority. */
318
+ export async function resolveExternalIdentityLink(
319
+ db: Database,
320
+ input: {
321
+ identity: ExternalIdentity;
322
+ linkId: string;
323
+ expectedRevision: number;
324
+ },
325
+ ): Promise<{ link: ExternalIdentityLink; personalWorkspaceId: string } | null> {
326
+ return withAccountRls(db, input.identity.accountId, async (tx) => {
327
+ await lockExternalWorkspaceMembershipLifecycle(tx, input.identity.accountId);
328
+ const [row] = await rawRows<LinkRow>(
329
+ tx,
330
+ sql`select * from external_identity_links
331
+ where id = ${input.linkId}::uuid and account_id = ${input.identity.accountId}::uuid
332
+ and external_identity_id = ${input.identity.id}::uuid for share`,
333
+ );
334
+ if (
335
+ !row ||
336
+ row.status !== "active" ||
337
+ !row.native_subject_id ||
338
+ Number(row.revision) !== input.expectedRevision ||
339
+ Number(row.external_authorization_revision) !== input.identity.authorizationRevision ||
340
+ input.identity.status !== "active" ||
341
+ project(row).status !== "active"
342
+ )
343
+ return null;
344
+ try {
345
+ const external = await activeMembership(
346
+ tx,
347
+ input.identity.accountId,
348
+ input.identity.subjectId,
349
+ );
350
+ const native = await activeMembership(tx, input.identity.accountId, row.native_subject_id);
351
+ if (
352
+ external.id !== input.identity.organizationMembershipId ||
353
+ Number(external.authorizationRevision) !== input.identity.authorizationRevision ||
354
+ native.id !== row.native_membership_id ||
355
+ Number(native.authorizationRevision) !== Number(row.native_authorization_revision)
356
+ )
357
+ return null;
358
+ return { link: project(row), personalWorkspaceId: native.personalWorkspaceId };
359
+ } catch (error) {
360
+ if (error instanceof ExternalIdentityLinkConflictError) return null;
361
+ throw error;
362
+ }
363
+ });
364
+ }
@@ -0,0 +1,228 @@
1
+ import { sql } from "drizzle-orm";
2
+ import { stableJson, type ScheduledTask } from "@opengeni/contracts";
3
+ import { ExternalLinkWorkSnapshot } from "@opengeni/contracts/external-identities";
4
+ import { rawRows, withWorkspaceRls, withWorkspaceSubjectRls, type Database } from "./database";
5
+ import { ensureExternalIdentity } from "./external-identities";
6
+ import { resolveExternalIdentityLink } from "./external-identity-links";
7
+ import { nestedPostgresSqlState } from "./persistence-errors";
8
+
9
+ type Scope = { accountId: string; workspaceId: string };
10
+ type Source =
11
+ | { kind: "direct" }
12
+ | { kind: "causal" | "child"; turnId: string }
13
+ | { kind: "scheduled"; taskId: string; taskRevision: number };
14
+
15
+ /** Does not consult or resurrect the key used at creation. Its immutable
16
+ * permission ceiling is already captured; membership/link revocation is live. */
17
+ export async function externalLinkWorkSnapshotIsLive(db: Database, raw: unknown): Promise<boolean> {
18
+ const snapshot = ExternalLinkWorkSnapshot.parse(raw);
19
+ try {
20
+ const identity = await ensureExternalIdentity(db, {
21
+ accountId: snapshot.actor.accountId,
22
+ ...snapshot.identity,
23
+ });
24
+ if (
25
+ identity.id !== snapshot.actor.externalIdentityId ||
26
+ identity.subjectId !== snapshot.actor.externalSubjectId ||
27
+ identity.authorizationRevision !== snapshot.actor.externalAuthorizationRevision
28
+ )
29
+ return false;
30
+ const resolved = await resolveExternalIdentityLink(db, {
31
+ identity,
32
+ linkId: snapshot.actor.linkId!,
33
+ expectedRevision: snapshot.actor.linkRevision!,
34
+ });
35
+ return (
36
+ resolved !== null &&
37
+ resolved.link.nativeSubjectId === snapshot.actor.effectiveSubjectId &&
38
+ snapshot.permissions.every(
39
+ (permission) =>
40
+ resolved.link.permissions.includes(permission) ||
41
+ (permission !== "secrets:read" && resolved.link.permissions.includes("workspace:admin")),
42
+ )
43
+ );
44
+ } catch (error) {
45
+ if (nestedPostgresSqlState(error) === "42501") return false;
46
+ throw error;
47
+ }
48
+ }
49
+
50
+ export async function getExternalLinkTurnSnapshot(
51
+ db: Database,
52
+ scope: Scope,
53
+ turnId: string,
54
+ ): Promise<ExternalLinkWorkSnapshot | null> {
55
+ return withWorkspaceRls(db, scope.workspaceId, async (tx) => {
56
+ const [row] = await rawRows<{ canonical_snapshot: unknown }>(
57
+ tx,
58
+ sql`select canonical_snapshot
59
+ from external_link_turn_authorities where account_id = ${scope.accountId}::uuid
60
+ and workspace_id = ${scope.workspaceId}::uuid and turn_id = ${turnId}::uuid`,
61
+ );
62
+ return row ? ExternalLinkWorkSnapshot.parse(row.canonical_snapshot) : null;
63
+ });
64
+ }
65
+
66
+ /** null is an ordinary native/external turn; a revoked linked turn is false,
67
+ * never null. Callers must not fall back to unlinked authority after denial. */
68
+ export async function getExternalLinkTurnAuthorization(db: Database, scope: Scope, turnId: string) {
69
+ const snapshot = await getExternalLinkTurnSnapshot(db, scope, turnId);
70
+ if (!snapshot) return null;
71
+ return {
72
+ authorized: await externalLinkWorkSnapshotIsLive(db, snapshot),
73
+ permissions: snapshot.permissions,
74
+ };
75
+ }
76
+
77
+ export async function captureExternalLinkTurnAuthority(
78
+ db: Database,
79
+ input: Scope & {
80
+ sessionId: string;
81
+ turnId: string;
82
+ snapshot: ExternalLinkWorkSnapshot;
83
+ source?: Source;
84
+ },
85
+ ): Promise<void> {
86
+ const snapshot = ExternalLinkWorkSnapshot.parse(input.snapshot);
87
+ const source = input.source ?? { kind: "direct" };
88
+ if (snapshot.actor.accountId !== input.accountId)
89
+ throw new Error("Linked turn organization mismatch");
90
+ if (source.kind === "direct" && !(await externalLinkWorkSnapshotIsLive(db, snapshot)))
91
+ throw new Error("Linked turn authority revoked");
92
+ await withWorkspaceSubjectRls(
93
+ db,
94
+ input.workspaceId,
95
+ snapshot.actor.effectiveSubjectId,
96
+ async (tx) => {
97
+ const existing = await getExternalLinkTurnSnapshot(tx, input, input.turnId);
98
+ if (existing) {
99
+ if (stableJson(existing) !== stableJson(snapshot))
100
+ throw new Error("Linked turn replay changed authority");
101
+ return;
102
+ }
103
+ await tx.execute(sql`insert into external_link_turn_authorities
104
+ (turn_id, account_id, workspace_id, session_id, link_id, link_revision, canonical_snapshot,
105
+ source_kind, source_turn_id, source_task_id, source_task_revision)
106
+ values (${input.turnId}::uuid, ${input.accountId}::uuid, ${input.workspaceId}::uuid, ${input.sessionId}::uuid,
107
+ ${snapshot.actor.linkId}::uuid, ${snapshot.actor.linkRevision}, ${JSON.stringify(snapshot)}::jsonb, ${source.kind},
108
+ ${source.kind === "causal" || source.kind === "child" ? source.turnId : null}::uuid,
109
+ ${source.kind === "scheduled" ? source.taskId : null}::uuid,
110
+ ${source.kind === "scheduled" ? source.taskRevision : null})`);
111
+ },
112
+ );
113
+ }
114
+
115
+ export async function inheritExternalLinkTurnAuthority(
116
+ db: Database,
117
+ input: Scope & {
118
+ sessionId: string;
119
+ turnId: string;
120
+ sourceTurnId: string;
121
+ kind: "causal" | "child";
122
+ },
123
+ ): Promise<void> {
124
+ const snapshot = await getExternalLinkTurnSnapshot(db, input, input.sourceTurnId);
125
+ if (!snapshot) return;
126
+ // Copy even after revocation. The worker denies the copied lane; dropping it
127
+ // would let an old linked continuation execute as an unrestricted native user.
128
+ await captureExternalLinkTurnAuthority(db, {
129
+ ...input,
130
+ snapshot,
131
+ source: { kind: input.kind, turnId: input.sourceTurnId },
132
+ });
133
+ }
134
+
135
+ export async function getExternalLinkTaskSnapshot(
136
+ db: Database,
137
+ input: Scope & {
138
+ taskId: string;
139
+ taskRevision: number;
140
+ },
141
+ ): Promise<ExternalLinkWorkSnapshot | null> {
142
+ return withWorkspaceRls(db, input.workspaceId, async (tx) => {
143
+ const [row] = await rawRows<{ canonical_snapshot: unknown }>(
144
+ tx,
145
+ sql`select canonical_snapshot
146
+ from external_link_task_authorities where account_id = ${input.accountId}::uuid
147
+ and workspace_id = ${input.workspaceId}::uuid and task_id = ${input.taskId}::uuid and task_revision = ${input.taskRevision}`,
148
+ );
149
+ return row ? ExternalLinkWorkSnapshot.parse(row.canonical_snapshot) : null;
150
+ });
151
+ }
152
+
153
+ export async function captureExternalLinkTaskAuthority(
154
+ db: Database,
155
+ task: ScheduledTask,
156
+ raw: ExternalLinkWorkSnapshot,
157
+ ): Promise<void> {
158
+ const snapshot = ExternalLinkWorkSnapshot.parse(raw);
159
+ if (task.accountId !== snapshot.actor.accountId)
160
+ throw new Error("Linked task organization mismatch");
161
+ await withWorkspaceSubjectRls(
162
+ db,
163
+ task.workspaceId,
164
+ snapshot.actor.effectiveSubjectId,
165
+ async (tx) => {
166
+ const existing = await getExternalLinkTaskSnapshot(tx, {
167
+ ...task,
168
+ taskId: task.id,
169
+ taskRevision: task.authorityRevision,
170
+ });
171
+ if (existing) {
172
+ if (stableJson(existing) !== stableJson(snapshot))
173
+ throw new Error("Linked task replay changed authority");
174
+ return;
175
+ }
176
+ await tx.execute(sql`insert into external_link_task_authorities
177
+ (task_id, task_revision, account_id, workspace_id, link_id, link_revision, canonical_snapshot)
178
+ values (${task.id}::uuid, ${task.authorityRevision}, ${task.accountId}::uuid, ${task.workspaceId}::uuid,
179
+ ${snapshot.actor.linkId}::uuid, ${snapshot.actor.linkRevision}, ${JSON.stringify(snapshot)}::jsonb)`);
180
+ },
181
+ );
182
+ }
183
+
184
+ export async function cloneExternalLinkTaskAuthority(
185
+ db: Database,
186
+ task: ScheduledTask,
187
+ sourceRevision: number,
188
+ ): Promise<void> {
189
+ if (task.authorityRevision === sourceRevision) return;
190
+ const snapshot = await getExternalLinkTaskSnapshot(db, {
191
+ ...task,
192
+ taskId: task.id,
193
+ taskRevision: sourceRevision,
194
+ });
195
+ if (snapshot) await captureExternalLinkTaskAuthority(db, task, snapshot);
196
+ }
197
+
198
+ export async function captureScheduledExternalLinkTurnAuthority(
199
+ db: Database,
200
+ input: Scope & {
201
+ sessionId: string;
202
+ turnId: string;
203
+ runId: string;
204
+ },
205
+ ): Promise<void> {
206
+ const run = await withWorkspaceRls(db, input.workspaceId, async (tx) => {
207
+ const [row] = await rawRows<{ task_id: string; task_authority_revision: number | string }>(
208
+ tx,
209
+ sql`select task_id, task_authority_revision from scheduled_task_runs
210
+ where account_id = ${input.accountId}::uuid and workspace_id = ${input.workspaceId}::uuid
211
+ and session_id = ${input.sessionId}::uuid and id = ${input.runId}::uuid`,
212
+ );
213
+ return row;
214
+ });
215
+ if (!run) throw new Error("Linked schedule run unavailable");
216
+ const taskRevision = Number(run.task_authority_revision);
217
+ const snapshot = await getExternalLinkTaskSnapshot(db, {
218
+ ...input,
219
+ taskId: run.task_id,
220
+ taskRevision,
221
+ });
222
+ if (snapshot)
223
+ await captureExternalLinkTurnAuthority(db, {
224
+ ...input,
225
+ snapshot,
226
+ source: { kind: "scheduled", taskId: run.task_id, taskRevision },
227
+ });
228
+ }
@@ -0,0 +1,33 @@
1
+ import { stableJson, type McpCredentialsRequest } from "@opengeni/contracts";
2
+ import { HostMcpBinding, HostMcpBindingDefinition } from "@opengeni/contracts/host-mcp-bindings";
3
+
4
+ /** Metadata comparison only. A caller must independently establish accepted
5
+ * execution delegation and live owner/workspace authority. A matching registry
6
+ * row never authorizes a turn, a scheduler, or a nested session by itself. */
7
+ export function hostMcpBindingMatchesRequest(
8
+ rawBinding: unknown,
9
+ request: McpCredentialsRequest,
10
+ ): boolean {
11
+ const parsed = HostMcpBinding.safeParse(rawBinding);
12
+ if (!parsed.success) return false;
13
+ const binding = parsed.data;
14
+ const { hostBinding, ...connectionRef } = request.connectionRef;
15
+ if (
16
+ !hostBinding ||
17
+ binding.status !== "active" ||
18
+ binding.revokedAt !== null ||
19
+ binding.id !== hostBinding.bindingId ||
20
+ binding.generation !== hostBinding.generation ||
21
+ binding.accountId !== request.accountId ||
22
+ binding.workspaceId !== request.workspaceId ||
23
+ // The registry currently describes an MCP endpoint, not a REST API grant.
24
+ request.credentialTarget !== "mcp"
25
+ )
26
+ return false;
27
+ const candidate = HostMcpBindingDefinition.safeParse({
28
+ serverId: request.serverId,
29
+ destinationUrl: request.destinationUrl,
30
+ connectionRef,
31
+ });
32
+ return candidate.success && stableJson(candidate.data) === stableJson(binding.definition);
33
+ }