@opengeni/db 0.31.1 → 0.36.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/dist/browser-auth.d.ts +209 -1
  2. package/dist/browser-downloads.d.ts +51 -0
  3. package/dist/browser-identities.d.ts +11 -2
  4. package/dist/browser-sessions.d.ts +83 -1
  5. package/dist/browser-state-artifacts.d.ts +75 -1
  6. package/dist/canonical-human-identities.d.ts +48 -0
  7. package/dist/canonical-human-identities.js +28 -0
  8. package/dist/canonical-human-identities.js.map +1 -0
  9. package/dist/capability-integrations.d.ts +8 -6
  10. package/dist/{chunk-MWDVXQOL.js → chunk-5KTIEDX7.js} +5773 -4212
  11. package/dist/chunk-5KTIEDX7.js.map +1 -0
  12. package/dist/chunk-FPVAD4CO.js +117 -0
  13. package/dist/chunk-FPVAD4CO.js.map +1 -0
  14. package/dist/chunk-GYO7ED2Q.js +259 -0
  15. package/dist/chunk-GYO7ED2Q.js.map +1 -0
  16. package/dist/{chunk-F5FT7BEZ.js → chunk-HSOWSYOX.js} +2 -2
  17. package/dist/{chunk-YHZXQ7HP.js → chunk-VKLB2ZGI.js} +446 -19
  18. package/dist/chunk-VKLB2ZGI.js.map +1 -0
  19. package/dist/{chunk-NBE6CHRI.js → chunk-VTPRIXKG.js} +49 -9
  20. package/dist/chunk-VTPRIXKG.js.map +1 -0
  21. package/dist/{chunk-ISI2TIUG.js → chunk-Z7VYMNPZ.js} +76 -4
  22. package/dist/chunk-Z7VYMNPZ.js.map +1 -0
  23. package/dist/database.d.ts +5 -0
  24. package/dist/editable-artifact-durable-export.js +2 -2
  25. package/dist/editable-artifacts.js +3 -3
  26. package/dist/index.d.ts +467 -29
  27. package/dist/index.js +23247 -16232
  28. package/dist/index.js.map +1 -1
  29. package/dist/insights.d.ts +23 -7
  30. package/dist/integration-bindings.d.ts +24 -24
  31. package/dist/integration-facets.d.ts +99 -0
  32. package/dist/interaction-revisions.d.ts +11 -0
  33. package/dist/interaction-schema.d.ts +698 -34
  34. package/dist/persistence-errors.d.ts +8 -0
  35. package/dist/provision-roles.js +1 -1
  36. package/dist/runtime-posture.d.ts +8 -7
  37. package/dist/schema.d.ts +9464 -5412
  38. package/dist/schema.js +51 -9
  39. package/dist/session-control.d.ts +29 -3
  40. package/dist/session-tenancy.d.ts +44 -0
  41. package/dist/session-tenancy.js +16 -0
  42. package/dist/session-tenancy.js.map +1 -0
  43. package/dist/slack-task-policy-schema.d.ts +847 -0
  44. package/dist/slack-task-policy.d.ts +65 -0
  45. package/dist/task-notes-schema.d.ts +924 -0
  46. package/dist/task-notes.d.ts +85 -0
  47. package/dist/video-generation.d.ts +9 -0
  48. package/dist/video-generation.js +5 -3
  49. package/dist/xai-subscription.d.ts +326 -0
  50. package/drizzle/0212_browser_state_transfer_hardening.sql +560 -0
  51. package/drizzle/0213_browser_interaction_authority.sql +67 -0
  52. package/drizzle/0214_browser_download_saves.sql +34 -0
  53. package/drizzle/0215_browser_controller_host.sql +385 -0
  54. package/drizzle/0216_browser_auth_health_evidence.sql +137 -0
  55. package/drizzle/0217_external_browser_auth_operations.sql +22 -0
  56. package/drizzle/0218_organization_tenancy_foundation.sql +299 -0
  57. package/drizzle/0219_organization_tenancy_managed_human_provisioning.sql +317 -0
  58. package/drizzle/0219_site_auth_maintenance_sessions.sql +234 -0
  59. package/drizzle/0220_memory_slack_append_only_cascade.sql +38 -0
  60. package/drizzle/0220_session_channels.sql +54 -0
  61. package/drizzle/0221_sessions_channel_index.sql +5 -0
  62. package/drizzle/0222_session_visibility_authority_epochs.sql +158 -0
  63. package/drizzle/0222_sessions_channel_fk.sql +89 -0
  64. package/drizzle/0223_pending_tool_event_output.sql +55 -0
  65. package/drizzle/0223_sessions_channel_fk_validate.sql +118 -0
  66. package/drizzle/0224_slack_post_outcome_reconciliation.sql +123 -0
  67. package/drizzle/0225_session_visibility_fork_activation.sql +1363 -0
  68. package/drizzle/0226_personal_codex_authority_foundation.sql +365 -0
  69. package/drizzle/0227_slack_native_actions.sql +145 -0
  70. package/drizzle/0228_interaction_controller_data_plane.sql +14 -0
  71. package/drizzle/0228_slack_task_policy.sql +400 -0
  72. package/drizzle/0229_slack_inbox_file_fact.sql +10 -0
  73. package/drizzle/0230_user_scoped_variable_sets_rigs.sql +84 -0
  74. package/drizzle/0231_integration_definition_identity_cutover.sql +271 -0
  75. package/drizzle/0232_integration_facet_authority_cutover.sql +610 -0
  76. package/drizzle/0233_skill_and_integration_authority_cutover.sql +1162 -0
  77. package/drizzle/0234_xai_subscription_authority.sql +1139 -0
  78. package/drizzle/0235_canonical_human_login_bindings.sql +920 -0
  79. package/drizzle/0236_browser_identity_lifecycle.sql +39 -0
  80. package/drizzle/0236_session_visibility_slack_policy.sql +72 -0
  81. package/drizzle/0237_interaction_transition_reaper.sql +263 -0
  82. package/drizzle/0238_goal_persistence_policy.sql +471 -0
  83. package/drizzle/0238_recover_unclaimed_session_turns.sql +307 -0
  84. package/drizzle/0238_supergrok_realtime_model.sql +29 -0
  85. package/drizzle/0239_supergrok_video_funding.sql +50 -0
  86. package/drizzle/0239_task_tree_notes.sql +990 -0
  87. package/package.json +13 -5
  88. package/src/browser-auth.ts +2600 -123
  89. package/src/browser-downloads.ts +517 -0
  90. package/src/browser-identities.ts +220 -3
  91. package/src/browser-sessions.ts +334 -45
  92. package/src/browser-state-artifacts.ts +402 -12
  93. package/src/canonical-human-identities.ts +317 -0
  94. package/src/capability-integrations.ts +121 -213
  95. package/src/database.ts +41 -1
  96. package/src/index.ts +4625 -411
  97. package/src/insights.ts +109 -28
  98. package/src/integration-bindings.ts +101 -101
  99. package/src/{integration-features.ts → integration-facets.ts} +187 -184
  100. package/src/interaction-revisions.ts +33 -3
  101. package/src/interaction-schema.ts +311 -8
  102. package/src/pack-components.ts +43 -43
  103. package/src/pack-installations.ts +0 -43
  104. package/src/persistence-errors.ts +60 -1
  105. package/src/plugin-packages.ts +9 -9
  106. package/src/provision-roles.ts +232 -0
  107. package/src/runtime-posture.ts +227 -14
  108. package/src/schema.ts +1188 -75
  109. package/src/session-control.ts +122 -37
  110. package/src/session-queue-commands.ts +78 -1
  111. package/src/session-tenancy.ts +188 -0
  112. package/src/session-tool-call-settlement.ts +9 -3
  113. package/src/slack-task-policy-schema.ts +130 -0
  114. package/src/slack-task-policy.ts +341 -0
  115. package/src/task-notes-schema.ts +135 -0
  116. package/src/task-notes.ts +178 -0
  117. package/src/video-generation.ts +90 -12
  118. package/src/workspace-artifacts.ts +1 -1
  119. package/src/xai-subscription.ts +1887 -0
  120. package/dist/chunk-ISI2TIUG.js.map +0 -1
  121. package/dist/chunk-MWDVXQOL.js.map +0 -1
  122. package/dist/chunk-NBE6CHRI.js.map +0 -1
  123. package/dist/chunk-YHZXQ7HP.js.map +0 -1
  124. package/dist/integration-features.d.ts +0 -99
  125. /package/dist/{chunk-F5FT7BEZ.js.map → chunk-HSOWSYOX.js.map} +0 -0
@@ -3,9 +3,13 @@ import {
3
3
  AuthRun,
4
4
  AuthRunListResponse,
5
5
  AuthRunMutationResponse,
6
+ BrowserExternalAuthResult,
6
7
  CreateInteractionInterventionRequest,
7
8
  CreateNetworkRouteRequest,
8
9
  CreateSiteAuthConnectionRequest,
10
+ ExternalAuthInteractiveRequest,
11
+ ExternalAuthRunRequest,
12
+ ExternalAuthRunResponse,
9
13
  InteractionIntervention,
10
14
  InteractionInterventionListResponse,
11
15
  InteractionInterventionMutationResponse,
@@ -14,7 +18,15 @@ import {
14
18
  NetworkRouteConsistency,
15
19
  NetworkRouteListResponse,
16
20
  NetworkRouteMutationResponse,
21
+ interactionPlacementsEqual,
22
+ networkRoutePlacementCompatibilityIssue,
23
+ ProtectedAuthFillRequest,
24
+ ProtectedAuthFillResponse,
17
25
  ReportAuthRunRequest,
26
+ RequestHumanInteractionToolInput,
27
+ SITE_AUTH_MAINTENANCE_CONNECTION_CONTEXT_KEY,
28
+ SITE_AUTH_MAINTENANCE_OPERATION_CONTEXT_KEY,
29
+ SiteAuthAuthority,
18
30
  ResolveInteractionInterventionRequest,
19
31
  SiteAuthConnection,
20
32
  SiteAuthConnectionListResponse,
@@ -22,12 +34,17 @@ import {
22
34
  StartAuthRunRequest,
23
35
  UpdateNetworkRouteRequest,
24
36
  UpdateSiteAuthConnectionRequest,
37
+ VerifyAuthRunRequest,
25
38
  type AuthRun as AuthRunValue,
26
39
  type AuthRunListResponse as AuthRunListResponseValue,
27
40
  type AuthRunMutationResponse as AuthRunMutationResponseValue,
28
41
  type CreateInteractionInterventionRequest as CreateInteractionInterventionRequestValue,
29
42
  type CreateNetworkRouteRequest as CreateNetworkRouteRequestValue,
30
43
  type CreateSiteAuthConnectionRequest as CreateSiteAuthConnectionRequestValue,
44
+ type BrowserExternalAuthResult as BrowserExternalAuthResultValue,
45
+ type ExternalAuthInteractiveRequest as ExternalAuthInteractiveRequestValue,
46
+ type ExternalAuthRunRequest as ExternalAuthRunRequestValue,
47
+ type ExternalAuthRunResponse as ExternalAuthRunResponseValue,
31
48
  type InteractionCredentialAuthorityRef,
32
49
  type InteractionIntervention as InteractionInterventionValue,
33
50
  type InteractionInterventionListResponse as InteractionInterventionListResponseValue,
@@ -36,18 +53,23 @@ import {
36
53
  type NetworkRouteConfiguration as NetworkRouteConfigurationValue,
37
54
  type NetworkRouteListResponse as NetworkRouteListResponseValue,
38
55
  type NetworkRouteMutationResponse as NetworkRouteMutationResponseValue,
56
+ type ProtectedAuthFillRequest as ProtectedAuthFillRequestValue,
57
+ type ProtectedAuthFillResponse as ProtectedAuthFillResponseValue,
39
58
  type ReportAuthRunRequest as ReportAuthRunRequestValue,
40
59
  type ResolveInteractionInterventionRequest as ResolveInteractionInterventionRequestValue,
41
- type SiteAuthAuthority,
60
+ type RequestHumanInteractionToolInput as RequestHumanInteractionToolInputValue,
61
+ type SessionEvent,
62
+ type SiteAuthAuthority as SiteAuthAuthorityValue,
42
63
  type SiteAuthConnection as SiteAuthConnectionValue,
43
64
  type SiteAuthConnectionListResponse as SiteAuthConnectionListResponseValue,
44
65
  type SiteAuthConnectionMutationResponse as SiteAuthConnectionMutationResponseValue,
45
66
  type StartAuthRunRequest as StartAuthRunRequestValue,
46
67
  type UpdateNetworkRouteRequest as UpdateNetworkRouteRequestValue,
47
68
  type UpdateSiteAuthConnectionRequest as UpdateSiteAuthConnectionRequestValue,
69
+ type VerifyAuthRunRequest as VerifyAuthRunRequestValue,
48
70
  } from "@opengeni/contracts";
49
71
  import { and, asc, eq, inArray, sql } from "drizzle-orm";
50
- import { type Database, setSubjectRlsContext, withRlsContext } from "./database";
72
+ import { type Database, rawRows, setSubjectRlsContext, withRlsContext } from "./database";
51
73
  import {
52
74
  advanceWorkspaceInteractionRevision,
53
75
  readWorkspaceInteractionRevision,
@@ -74,6 +96,7 @@ const CONSISTENT_READ = {
74
96
  isolationLevel: "repeatable read",
75
97
  accessMode: "read only",
76
98
  } as const;
99
+ const MAX_SITE_AUTH_MAINTENANCE_RETRY_MS = 15 * 60 * 1_000;
77
100
 
78
101
  export class InteractionResourceNotFoundError extends Error {
79
102
  readonly name = "InteractionResourceNotFoundError";
@@ -148,6 +171,16 @@ function siteAuthConnectionFromRow(row: SiteAuthConnectionRow): SiteAuthConnecti
148
171
  verificationState: row.verificationState,
149
172
  lastVerifiedAt: row.lastVerifiedAt ? iso(row.lastVerifiedAt) : null,
150
173
  lastVerifiedUrl: row.lastVerifiedUrl,
174
+ lastCheckedAt: row.lastCheckedAt ? iso(row.lastCheckedAt) : null,
175
+ nextCheckAt: row.nextCheckAt ? iso(row.nextCheckAt) : null,
176
+ maintenance: row.maintenanceOperationId
177
+ ? {
178
+ action: row.maintenanceAction,
179
+ sessionId: row.maintenanceStartedAt ? row.maintenanceSessionId : null,
180
+ dueAt: iso(row.maintenanceDueAt!),
181
+ startedAt: row.maintenanceStartedAt ? iso(row.maintenanceStartedAt) : null,
182
+ }
183
+ : null,
151
184
  repairCode: row.repairCode,
152
185
  version: row.version,
153
186
  createdBySubjectId: row.createdBySubjectId,
@@ -156,6 +189,237 @@ function siteAuthConnectionFromRow(row: SiteAuthConnectionRow): SiteAuthConnecti
156
189
  });
157
190
  }
158
191
 
192
+ function nextMaintainedAuthCheck(
193
+ status: SiteAuthConnectionRow["status"],
194
+ policy: SiteAuthConnectionRow["healthPolicy"],
195
+ lastCheckedAt: Date | null,
196
+ ): Date | null {
197
+ if (status !== "active" || policy.mode !== "maintained" || policy.intervalSeconds === null) {
198
+ return null;
199
+ }
200
+ return lastCheckedAt
201
+ ? new Date(lastCheckedAt.getTime() + policy.intervalSeconds * 1_000)
202
+ : new Date();
203
+ }
204
+
205
+ export type SiteAuthMaintenanceClaim = {
206
+ operationId: string;
207
+ sessionId: string;
208
+ accountId: string;
209
+ workspaceId: string;
210
+ siteAuthConnectionId: string;
211
+ connectionVersion: number;
212
+ action: "health_check" | "repair";
213
+ dueAt: Date;
214
+ claimedAt: Date;
215
+ name: string;
216
+ accountLabel: string;
217
+ loginUrl: string | null;
218
+ verificationUrlPrefixes: string[];
219
+ preferredIdentityId: string | null;
220
+ preferredPlacement: SiteAuthConnectionValue["preferredPlacement"];
221
+ preferredNetworkRouteId: string | null;
222
+ healthPolicy: SiteAuthConnectionValue["healthPolicy"];
223
+ verificationState: SiteAuthConnectionValue["verificationState"];
224
+ };
225
+
226
+ /** Claim a bounded cross-workspace batch. Stale pre-start claims keep the same
227
+ * operation/session ids, so worker loss repairs one durable dispatch. */
228
+ export async function claimSiteAuthMaintenance(
229
+ db: Database,
230
+ input: { claimTimeoutMs: number; limit: number },
231
+ ): Promise<SiteAuthMaintenanceClaim[]> {
232
+ if (!Number.isSafeInteger(input.claimTimeoutMs) || input.claimTimeoutMs < 0) {
233
+ throw new Error("site auth maintenance claim timeout must be a non-negative safe integer");
234
+ }
235
+ if (!Number.isSafeInteger(input.limit) || input.limit < 1 || input.limit > 1_000) {
236
+ throw new Error("site auth maintenance claim limit must be between 1 and 1000");
237
+ }
238
+ const rows = await rawRows<{
239
+ operation_id: string;
240
+ session_id: string;
241
+ account_id: string;
242
+ workspace_id: string;
243
+ site_auth_connection_id: string;
244
+ connection_version: number;
245
+ action: "health_check" | "repair";
246
+ due_at: Date;
247
+ claimed_at: Date;
248
+ name: string;
249
+ account_label: string;
250
+ login_url: string | null;
251
+ verification_url_prefixes: string[];
252
+ preferred_identity_id: string | null;
253
+ preferred_placement: SiteAuthConnectionValue["preferredPlacement"];
254
+ preferred_network_route_id: string | null;
255
+ health_policy: SiteAuthConnectionValue["healthPolicy"];
256
+ verification_state: SiteAuthConnectionValue["verificationState"];
257
+ }>(
258
+ db,
259
+ sql`
260
+ select *
261
+ from opengeni_private.claim_site_auth_maintenance(
262
+ ${input.claimTimeoutMs},
263
+ ${input.limit}
264
+ )
265
+ `,
266
+ );
267
+ return rows.map((row) => ({
268
+ operationId: row.operation_id,
269
+ sessionId: row.session_id,
270
+ accountId: row.account_id,
271
+ workspaceId: row.workspace_id,
272
+ siteAuthConnectionId: row.site_auth_connection_id,
273
+ connectionVersion: Number(row.connection_version),
274
+ action: row.action,
275
+ dueAt: new Date(row.due_at),
276
+ claimedAt: new Date(row.claimed_at),
277
+ name: row.name,
278
+ accountLabel: row.account_label,
279
+ loginUrl: row.login_url,
280
+ verificationUrlPrefixes: row.verification_url_prefixes,
281
+ preferredIdentityId: row.preferred_identity_id,
282
+ preferredPlacement: row.preferred_placement,
283
+ preferredNetworkRouteId: row.preferred_network_route_id,
284
+ healthPolicy: row.health_policy,
285
+ verificationState: row.verification_state,
286
+ }));
287
+ }
288
+
289
+ /** Link the exact hidden maintenance claim to its service-created session in
290
+ * the caller's create transaction. A false result must abort that transaction. */
291
+ export async function confirmSiteAuthMaintenanceSessionInTransaction(
292
+ tx: Database,
293
+ input: {
294
+ accountId: string;
295
+ workspaceId: string;
296
+ siteAuthConnectionId: string;
297
+ operationId: string;
298
+ sessionId: string;
299
+ },
300
+ ): Promise<boolean> {
301
+ const [session] = await tx
302
+ .select({
303
+ createdByKind: schema.sessions.createdByKind,
304
+ createdBySubjectId: schema.sessions.createdBySubjectId,
305
+ createdByContext: schema.sessions.createdByContext,
306
+ })
307
+ .from(schema.sessions)
308
+ .where(
309
+ and(
310
+ eq(schema.sessions.workspaceId, input.workspaceId),
311
+ eq(schema.sessions.id, input.sessionId),
312
+ ),
313
+ )
314
+ .limit(1);
315
+ if (
316
+ !session ||
317
+ session.createdByKind !== "service" ||
318
+ session.createdBySubjectId !== "site-auth-maintenance" ||
319
+ session.createdByContext[SITE_AUTH_MAINTENANCE_OPERATION_CONTEXT_KEY] !== input.operationId ||
320
+ session.createdByContext[SITE_AUTH_MAINTENANCE_CONNECTION_CONTEXT_KEY] !==
321
+ input.siteAuthConnectionId
322
+ ) {
323
+ return false;
324
+ }
325
+ const startedAt = new Date();
326
+ const [confirmed] = await tx
327
+ .update(schema.siteAuthConnections)
328
+ .set({
329
+ maintenanceStartedAt: startedAt,
330
+ version: sql`${schema.siteAuthConnections.version} + 1`,
331
+ updatedBySubjectId: "site-auth-maintenance",
332
+ updatedAt: startedAt,
333
+ })
334
+ .where(
335
+ and(
336
+ eq(schema.siteAuthConnections.workspaceId, input.workspaceId),
337
+ eq(schema.siteAuthConnections.id, input.siteAuthConnectionId),
338
+ eq(schema.siteAuthConnections.maintenanceOperationId, input.operationId),
339
+ eq(schema.siteAuthConnections.maintenanceSessionId, input.sessionId),
340
+ sql`${schema.siteAuthConnections.maintenanceStartedAt} is null`,
341
+ ),
342
+ )
343
+ .returning({ id: schema.siteAuthConnections.id });
344
+ if (!confirmed) {
345
+ const [existing] = await tx
346
+ .select({ startedAt: schema.siteAuthConnections.maintenanceStartedAt })
347
+ .from(schema.siteAuthConnections)
348
+ .where(
349
+ and(
350
+ eq(schema.siteAuthConnections.workspaceId, input.workspaceId),
351
+ eq(schema.siteAuthConnections.id, input.siteAuthConnectionId),
352
+ eq(schema.siteAuthConnections.maintenanceOperationId, input.operationId),
353
+ eq(schema.siteAuthConnections.maintenanceSessionId, input.sessionId),
354
+ ),
355
+ )
356
+ .limit(1);
357
+ return existing?.startedAt !== null && existing?.startedAt !== undefined;
358
+ }
359
+ await advanceWorkspaceInteractionRevision(tx, input.accountId, input.workspaceId);
360
+ return true;
361
+ }
362
+
363
+ /** Standalone wrapper retained for exact DB callers and tests. Session creation
364
+ * uses the in-transaction form so a changed claim cannot leave an empty shell. */
365
+ export async function confirmSiteAuthMaintenanceSession(
366
+ db: Database,
367
+ input: Parameters<typeof confirmSiteAuthMaintenanceSessionInTransaction>[1],
368
+ ): Promise<boolean> {
369
+ return await withRlsContext(db, input, async (scopedDb) =>
370
+ confirmSiteAuthMaintenanceSessionInTransaction(scopedDb, input),
371
+ );
372
+ }
373
+
374
+ /** Release an exact claim that failed before a maintenance session started. */
375
+ export async function deferSiteAuthMaintenance(
376
+ db: Database,
377
+ input: {
378
+ accountId: string;
379
+ workspaceId: string;
380
+ siteAuthConnectionId: string;
381
+ operationId: string;
382
+ sessionId: string;
383
+ retryAt: Date;
384
+ },
385
+ ): Promise<boolean> {
386
+ return await withRlsContext(db, input, async (scopedDb) =>
387
+ scopedDb.transaction(async (tx) => {
388
+ const [deferred] = await tx
389
+ .update(schema.siteAuthConnections)
390
+ .set({
391
+ maintenanceOperationId: null,
392
+ maintenanceAction: null,
393
+ maintenanceDueAt: null,
394
+ maintenanceClaimedAt: null,
395
+ maintenanceSessionId: null,
396
+ maintenanceStartedAt: null,
397
+ nextCheckAt: input.retryAt,
398
+ version: sql`${schema.siteAuthConnections.version} + 1`,
399
+ updatedBySubjectId: "site-auth-maintenance",
400
+ updatedAt: new Date(),
401
+ })
402
+ .where(
403
+ and(
404
+ eq(schema.siteAuthConnections.workspaceId, input.workspaceId),
405
+ eq(schema.siteAuthConnections.id, input.siteAuthConnectionId),
406
+ eq(schema.siteAuthConnections.maintenanceOperationId, input.operationId),
407
+ eq(schema.siteAuthConnections.maintenanceSessionId, input.sessionId),
408
+ sql`${schema.siteAuthConnections.maintenanceStartedAt} is null`,
409
+ ),
410
+ )
411
+ .returning({ id: schema.siteAuthConnections.id });
412
+ if (!deferred) return false;
413
+ await advanceWorkspaceInteractionRevision(
414
+ tx as unknown as Database,
415
+ input.accountId,
416
+ input.workspaceId,
417
+ );
418
+ return true;
419
+ }),
420
+ );
421
+ }
422
+
159
423
  async function lockOperation(db: Database, operationId: string): Promise<void> {
160
424
  await db.execute(
161
425
  sql`select pg_advisory_xact_lock(hashtextextended(${`interaction-resource:${operationId}`}, 0))`,
@@ -180,7 +444,7 @@ async function loadOperation(
180
444
  return row ?? null;
181
445
  }
182
446
 
183
- function assertOperation(
447
+ function assertOperationIdentity(
184
448
  operation: ResourceOperationRow,
185
449
  expected: {
186
450
  resourceKind: ResourceKind;
@@ -201,6 +465,19 @@ function assertOperation(
201
465
  "Operation id is already bound to another interaction request",
202
466
  );
203
467
  }
468
+ }
469
+
470
+ function assertOperation(
471
+ operation: ResourceOperationRow,
472
+ expected: {
473
+ resourceKind: ResourceKind;
474
+ resourceId?: string;
475
+ kind: ResourceOperationKind;
476
+ requestDigest: string;
477
+ actorSubjectId: string;
478
+ },
479
+ ): void {
480
+ assertOperationIdentity(operation, expected);
204
481
  if (operation.state !== "completed" || !operation.result || !operation.resultVersion) {
205
482
  throw new InteractionResourceStateError("Interaction operation is not complete");
206
483
  }
@@ -250,7 +527,7 @@ function credentialRefsFromRoute(
250
527
  }
251
528
 
252
529
  function credentialRefsFromAuthorities(
253
- authorities: readonly SiteAuthAuthority[],
530
+ authorities: readonly SiteAuthAuthorityValue[],
254
531
  ): InteractionCredentialAuthorityRef[] {
255
532
  return authorities.flatMap((authority) =>
256
533
  "credential" in authority && authority.credential ? [authority.credential] : [],
@@ -329,19 +606,74 @@ async function assertActiveNetworkRoute(
329
606
  db: Database,
330
607
  workspaceId: string,
331
608
  routeId: string | null,
332
- ): Promise<void> {
333
- if (!routeId) return;
609
+ ): Promise<NetworkRouteValue | null> {
610
+ if (!routeId) return null;
334
611
  const [route] = await db
335
- .select({ status: schema.networkRoutes.status })
612
+ .select()
336
613
  .from(schema.networkRoutes)
337
614
  .where(
338
615
  and(eq(schema.networkRoutes.workspaceId, workspaceId), eq(schema.networkRoutes.id, routeId)),
339
616
  )
617
+ .for("share")
340
618
  .limit(1);
341
619
  if (!route) throw new InteractionResourceNotFoundError("Preferred network route not found");
342
620
  if (route.status !== "active") {
343
621
  throw new InteractionResourceStateError("Preferred network route is archived");
344
622
  }
623
+ return routeFromRow(route);
624
+ }
625
+
626
+ async function assertNetworkRouteUpdateCompatibleWithSiteAuthConnections(
627
+ db: Database,
628
+ input: {
629
+ workspaceId: string;
630
+ routeId: string;
631
+ status: NetworkRouteValue["status"];
632
+ configuration: NetworkRouteValue["configuration"];
633
+ consistency: NetworkRouteValue["consistency"];
634
+ },
635
+ ): Promise<void> {
636
+ const references = await db
637
+ .select({ preferredPlacement: schema.siteAuthConnections.preferredPlacement })
638
+ .from(schema.siteAuthConnections)
639
+ .where(
640
+ and(
641
+ eq(schema.siteAuthConnections.workspaceId, input.workspaceId),
642
+ eq(schema.siteAuthConnections.preferredNetworkRouteId, input.routeId),
643
+ eq(schema.siteAuthConnections.status, "active"),
644
+ ),
645
+ );
646
+ if (references.length === 0) return;
647
+ if (input.status !== "active") {
648
+ throw new InteractionResourceStateError(
649
+ "Network route is selected by an active site auth connection",
650
+ );
651
+ }
652
+ for (const reference of references) {
653
+ const issue = networkRoutePlacementCompatibilityIssue(
654
+ input.configuration,
655
+ input.consistency,
656
+ reference.preferredPlacement,
657
+ );
658
+ if (issue) {
659
+ throw new InteractionResourceStateError(
660
+ `Network route update is incompatible with an active site auth connection: ${issue}`,
661
+ );
662
+ }
663
+ }
664
+ }
665
+
666
+ function assertSiteAuthRouteCompatibility(
667
+ route: NetworkRouteValue | null,
668
+ placement: SiteAuthConnectionValue["preferredPlacement"],
669
+ ): void {
670
+ if (!route) return;
671
+ const issue = networkRoutePlacementCompatibilityIssue(
672
+ route.configuration,
673
+ route.consistency,
674
+ placement,
675
+ );
676
+ if (issue) throw new InteractionResourceStateError(`Preferred network route: ${issue}`);
345
677
  }
346
678
 
347
679
  export async function listNetworkRoutes(
@@ -481,7 +813,9 @@ export async function createNetworkRoute(
481
813
 
482
814
  export async function updateNetworkRoute(
483
815
  db: Database,
484
- input: InteractionMutationScope & { routeId: string } & UpdateNetworkRouteRequestValue,
816
+ input: InteractionMutationScope & {
817
+ routeId: string;
818
+ } & UpdateNetworkRouteRequestValue,
485
819
  ): Promise<NetworkRouteMutationResponseValue> {
486
820
  const request = UpdateNetworkRouteRequest.parse({
487
821
  operationId: input.operationId,
@@ -542,6 +876,19 @@ export async function updateNetworkRoute(
542
876
  credentialRefsFromRoute(configuration),
543
877
  ),
544
878
  );
879
+ if (
880
+ request.configuration !== undefined ||
881
+ request.consistency !== undefined ||
882
+ (request.status !== undefined && request.status !== current.status)
883
+ ) {
884
+ await assertNetworkRouteUpdateCompatibleWithSiteAuthConnections(scopedDb, {
885
+ workspaceId: input.workspaceId,
886
+ routeId: input.routeId,
887
+ status: request.status ?? current.status,
888
+ configuration,
889
+ consistency,
890
+ });
891
+ }
545
892
  const [row] = await scopedDb
546
893
  .update(schema.networkRoutes)
547
894
  .set({
@@ -620,7 +967,11 @@ export async function listSiteAuthConnections(
620
967
 
621
968
  export async function getSiteAuthConnection(
622
969
  db: Database,
623
- input: { accountId: string; workspaceId: string; siteAuthConnectionId: string },
970
+ input: {
971
+ accountId: string;
972
+ workspaceId: string;
973
+ siteAuthConnectionId: string;
974
+ },
624
975
  ): Promise<SiteAuthConnectionValue> {
625
976
  return await withRlsContext(
626
977
  db,
@@ -648,7 +999,7 @@ async function validateSiteAuthReferences(
648
999
  workspaceId: string,
649
1000
  connection: Pick<
650
1001
  SiteAuthConnectionValue,
651
- "authorities" | "preferredIdentityId" | "preferredNetworkRouteId"
1002
+ "authorities" | "preferredIdentityId" | "preferredNetworkRouteId" | "preferredPlacement"
652
1003
  >,
653
1004
  ): Promise<void> {
654
1005
  await assertCredentialAuthorities(
@@ -657,7 +1008,8 @@ async function validateSiteAuthReferences(
657
1008
  credentialRefsFromAuthorities(connection.authorities),
658
1009
  );
659
1010
  await assertActiveBrowserIdentity(db, workspaceId, connection.preferredIdentityId);
660
- await assertActiveNetworkRoute(db, workspaceId, connection.preferredNetworkRouteId);
1011
+ const route = await assertActiveNetworkRoute(db, workspaceId, connection.preferredNetworkRouteId);
1012
+ assertSiteAuthRouteCompatibility(route, connection.preferredPlacement);
661
1013
  }
662
1014
 
663
1015
  async function validateSiteAuthUpdateReferences(
@@ -677,8 +1029,21 @@ async function validateSiteAuthUpdateReferences(
677
1029
  if (candidate.preferredIdentityId !== current.preferredIdentityId) {
678
1030
  await assertActiveBrowserIdentity(db, workspaceId, candidate.preferredIdentityId);
679
1031
  }
680
- if (candidate.preferredNetworkRouteId !== current.preferredNetworkRouteId) {
681
- await assertActiveNetworkRoute(db, workspaceId, candidate.preferredNetworkRouteId);
1032
+ if (
1033
+ candidate.status === "active" &&
1034
+ (current.status !== "active" ||
1035
+ candidate.preferredNetworkRouteId !== current.preferredNetworkRouteId ||
1036
+ (candidate.preferredPlacement === null) !== (current.preferredPlacement === null) ||
1037
+ (candidate.preferredPlacement !== null &&
1038
+ current.preferredPlacement !== null &&
1039
+ !interactionPlacementsEqual(candidate.preferredPlacement, current.preferredPlacement)))
1040
+ ) {
1041
+ const route = await assertActiveNetworkRoute(
1042
+ db,
1043
+ workspaceId,
1044
+ candidate.preferredNetworkRouteId,
1045
+ );
1046
+ assertSiteAuthRouteCompatibility(route, candidate.preferredPlacement);
682
1047
  }
683
1048
  }
684
1049
 
@@ -725,6 +1090,7 @@ export async function createSiteAuthConnection(
725
1090
  await validateSiteAuthReferences(scopedDb, input.workspaceId, {
726
1091
  authorities: request.authorities,
727
1092
  preferredIdentityId: request.preferredIdentityId,
1093
+ preferredPlacement: request.preferredPlacement,
728
1094
  preferredNetworkRouteId: request.preferredNetworkRouteId,
729
1095
  });
730
1096
  const id = randomUUID();
@@ -745,6 +1111,7 @@ export async function createSiteAuthConnection(
745
1111
  preferredPlacement: request.preferredPlacement,
746
1112
  preferredNetworkRouteId: request.preferredNetworkRouteId,
747
1113
  healthPolicy: request.healthPolicy,
1114
+ nextCheckAt: nextMaintainedAuthCheck("active", request.healthPolicy, null),
748
1115
  createOperationId: request.operationId,
749
1116
  createdBySubjectId: input.actorSubjectId,
750
1117
  updatedBySubjectId: input.actorSubjectId,
@@ -860,6 +1227,10 @@ export async function updateSiteAuthConnection(
860
1227
  const candidate = SiteAuthConnection.parse({
861
1228
  ...currentValue,
862
1229
  ...candidatePatch,
1230
+ // Any configuration change invalidates the scheduler's frozen prompt.
1231
+ // The already-created session remains auditable but can no longer bind
1232
+ // a maintenance AuthRun to this connection.
1233
+ maintenance: null,
863
1234
  id: current.id,
864
1235
  accountId: current.accountId,
865
1236
  workspaceId: current.workspaceId,
@@ -884,6 +1255,17 @@ export async function updateSiteAuthConnection(
884
1255
  preferredPlacement: candidate.preferredPlacement,
885
1256
  preferredNetworkRouteId: candidate.preferredNetworkRouteId,
886
1257
  healthPolicy: candidate.healthPolicy,
1258
+ nextCheckAt: nextMaintainedAuthCheck(
1259
+ candidate.status,
1260
+ candidate.healthPolicy,
1261
+ current.lastCheckedAt,
1262
+ ),
1263
+ maintenanceOperationId: null,
1264
+ maintenanceAction: null,
1265
+ maintenanceDueAt: null,
1266
+ maintenanceClaimedAt: null,
1267
+ maintenanceSessionId: null,
1268
+ maintenanceStartedAt: null,
887
1269
  version: candidate.version,
888
1270
  updatedBySubjectId: input.actorSubjectId,
889
1271
  updatedAt: sql`now()`,
@@ -940,6 +1322,7 @@ function authRunFromRow(row: AuthRunRow): AuthRunValue {
940
1322
  controllerGeneration: row.controllerGeneration,
941
1323
  targetGeneration: row.targetGeneration,
942
1324
  documentGeneration: row.documentGeneration,
1325
+ purpose: row.purpose,
943
1326
  methodId: row.methodId,
944
1327
  authorityId: row.authorityId,
945
1328
  state: row.state,
@@ -958,6 +1341,111 @@ function authRunFromRow(row: AuthRunRow): AuthRunValue {
958
1341
  });
959
1342
  }
960
1343
 
1344
+ /**
1345
+ * Project only terminal authentication evidence. `healthSequence` is allocated
1346
+ * by PostgreSQL when a run starts, so an older browser may finish later without
1347
+ * overwriting evidence from a newer run. A cancelled run carries no evidence.
1348
+ */
1349
+ async function projectSettledAuthRunHealth(
1350
+ db: Database,
1351
+ input: { run: AuthRunRow; actorSubjectId: string },
1352
+ ): Promise<boolean> {
1353
+ const { run } = input;
1354
+ if (!run.settledAt) return false;
1355
+ const carriesEvidence = run.state === "verified" || run.state === "failed";
1356
+ if (!carriesEvidence && (run.state !== "cancelled" || !run.maintenanceOperationId)) {
1357
+ return false;
1358
+ }
1359
+ const [connection] = await db
1360
+ .select()
1361
+ .from(schema.siteAuthConnections)
1362
+ .where(
1363
+ and(
1364
+ eq(schema.siteAuthConnections.workspaceId, run.workspaceId),
1365
+ eq(schema.siteAuthConnections.id, run.siteAuthConnectionId),
1366
+ ),
1367
+ )
1368
+ .for("update")
1369
+ .limit(1);
1370
+ if (!connection) throw new InteractionResourceNotFoundError("Site auth connection not found");
1371
+ const checkedAt = run.settledAt;
1372
+ const maintenanceMatches =
1373
+ run.maintenanceOperationId !== null &&
1374
+ connection.maintenanceOperationId === run.maintenanceOperationId;
1375
+ const evidenceAdvances =
1376
+ carriesEvidence &&
1377
+ connection.healthSequence < run.healthSequence &&
1378
+ (run.maintenanceOperationId === null || maintenanceMatches);
1379
+ if (!maintenanceMatches && !evidenceAdvances) return false;
1380
+ const intervalSeconds = connection.healthPolicy.intervalSeconds;
1381
+ const nextEvidenceCheckAt =
1382
+ connection.status === "active" &&
1383
+ connection.healthPolicy.mode === "maintained" &&
1384
+ intervalSeconds !== null
1385
+ ? run.state === "failed" &&
1386
+ run.purpose === "health_check" &&
1387
+ connection.healthPolicy.automaticRepair
1388
+ ? checkedAt
1389
+ : new Date(checkedAt.getTime() + intervalSeconds * 1_000)
1390
+ : null;
1391
+ const failureState = run.purpose === "repair" ? "failed" : "needs_repair";
1392
+ const cancelledMaintenanceCheckAt =
1393
+ connection.status === "active" &&
1394
+ connection.healthPolicy.mode === "maintained" &&
1395
+ intervalSeconds !== null
1396
+ ? new Date(
1397
+ checkedAt.getTime() +
1398
+ Math.min(intervalSeconds * 1_000, MAX_SITE_AUTH_MAINTENANCE_RETRY_MS),
1399
+ )
1400
+ : null;
1401
+ const [projected] = await db
1402
+ .update(schema.siteAuthConnections)
1403
+ .set({
1404
+ ...(evidenceAdvances
1405
+ ? {
1406
+ verificationState: run.state === "verified" ? "verified" : failureState,
1407
+ ...(run.state === "verified"
1408
+ ? {
1409
+ lastVerifiedAt: checkedAt,
1410
+ lastVerifiedUrl: run.verifiedUrl,
1411
+ repairCode: null,
1412
+ }
1413
+ : { repairCode: run.failureCode }),
1414
+ lastCheckedAt: checkedAt,
1415
+ nextCheckAt: nextEvidenceCheckAt,
1416
+ healthSequence: run.healthSequence,
1417
+ }
1418
+ : maintenanceMatches && run.state === "cancelled"
1419
+ ? { nextCheckAt: cancelledMaintenanceCheckAt }
1420
+ : {}),
1421
+ ...(maintenanceMatches
1422
+ ? {
1423
+ maintenanceOperationId: null,
1424
+ maintenanceAction: null,
1425
+ maintenanceDueAt: null,
1426
+ maintenanceClaimedAt: null,
1427
+ maintenanceSessionId: null,
1428
+ maintenanceStartedAt: null,
1429
+ }
1430
+ : {}),
1431
+ version: sql`${schema.siteAuthConnections.version} + 1`,
1432
+ updatedBySubjectId: input.actorSubjectId,
1433
+ updatedAt: checkedAt,
1434
+ })
1435
+ .where(
1436
+ and(
1437
+ eq(schema.siteAuthConnections.workspaceId, run.workspaceId),
1438
+ eq(schema.siteAuthConnections.id, run.siteAuthConnectionId),
1439
+ sql`(
1440
+ ${schema.siteAuthConnections.healthSequence} < ${run.healthSequence}
1441
+ or ${schema.siteAuthConnections.maintenanceOperationId} = ${run.maintenanceOperationId}
1442
+ )`,
1443
+ ),
1444
+ )
1445
+ .returning({ id: schema.siteAuthConnections.id });
1446
+ return Boolean(projected);
1447
+ }
1448
+
961
1449
  async function loadAuthRunRow(
962
1450
  db: Database,
963
1451
  workspaceId: string,
@@ -1040,6 +1528,274 @@ const AUTH_RUN_TRANSITIONS: Readonly<
1040
1528
  cancelled: new Set(),
1041
1529
  };
1042
1530
 
1531
+ type ProtectedAuthOperationMetadata = {
1532
+ schemaVersion: 1;
1533
+ authRunVersion: number;
1534
+ authority: SiteAuthAuthorityValue;
1535
+ origins: string[];
1536
+ fields: Array<{
1537
+ id: string;
1538
+ purpose: "identifier" | "password" | "secret" | "totp";
1539
+ }>;
1540
+ credentialVersion: number | null;
1541
+ };
1542
+
1543
+ type ExternalAuthOperationMetadata = {
1544
+ schemaVersion: 1;
1545
+ authRunVersion: number;
1546
+ authority: Extract<SiteAuthAuthorityValue, { kind: "external_provider" }>;
1547
+ };
1548
+
1549
+ export type ExternalAuthPreparation = {
1550
+ run: AuthRunValue;
1551
+ authority: ExternalAuthOperationMetadata["authority"];
1552
+ operationState: ResourceOperationRow["state"];
1553
+ response: ExternalAuthRunResponseValue | null;
1554
+ replayed: boolean;
1555
+ };
1556
+
1557
+ export type ProtectedAuthFillPreparation = {
1558
+ run: AuthRunValue;
1559
+ authority: SiteAuthAuthorityValue;
1560
+ origins: string[];
1561
+ credentialVersion: number | null;
1562
+ operationState: ResourceOperationRow["state"];
1563
+ response: ProtectedAuthFillResponseValue | null;
1564
+ replayed: boolean;
1565
+ };
1566
+
1567
+ function protectedAuthFillDigest(
1568
+ authRunId: string,
1569
+ request: ProtectedAuthFillRequestValue,
1570
+ actorSubjectId: string,
1571
+ ): string {
1572
+ const { operationId: _operationId, ...digestRequest } = request;
1573
+ return operationDigest({
1574
+ version: 1,
1575
+ authRunId,
1576
+ request: digestRequest,
1577
+ actorSubjectId,
1578
+ });
1579
+ }
1580
+
1581
+ function parseExternalAuthRunRequest(
1582
+ input: ExternalAuthRunRequestValue,
1583
+ ): ExternalAuthRunRequestValue {
1584
+ return ExternalAuthRunRequest.parse({
1585
+ operationId: input.operationId,
1586
+ expectedVersion: input.expectedVersion,
1587
+ action: input.action,
1588
+ });
1589
+ }
1590
+
1591
+ function externalAuthDigest(
1592
+ authRunId: string,
1593
+ request: ExternalAuthRunRequestValue,
1594
+ actorSubjectId: string,
1595
+ ): string {
1596
+ const { operationId: _operationId, ...digestRequest } = request;
1597
+ return operationDigest({
1598
+ version: 1,
1599
+ authRunId,
1600
+ request: digestRequest,
1601
+ actorSubjectId,
1602
+ });
1603
+ }
1604
+
1605
+ function externalAuthOperationMetadata(value: unknown): ExternalAuthOperationMetadata {
1606
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1607
+ throw new InteractionResourceStateError("External-auth operation metadata is invalid");
1608
+ }
1609
+ const record = value as Record<string, unknown>;
1610
+ const authority = SiteAuthAuthority.parse(record.authority);
1611
+ if (
1612
+ record.schemaVersion !== 1 ||
1613
+ !Number.isSafeInteger(record.authRunVersion) ||
1614
+ (record.authRunVersion as number) < 1 ||
1615
+ authority.kind !== "external_provider"
1616
+ ) {
1617
+ throw new InteractionResourceStateError("External-auth operation metadata is invalid");
1618
+ }
1619
+ return {
1620
+ schemaVersion: 1,
1621
+ authRunVersion: record.authRunVersion as number,
1622
+ authority,
1623
+ };
1624
+ }
1625
+
1626
+ function externalAuthPreparationFromOperation(
1627
+ operation: ResourceOperationRow,
1628
+ run: AuthRunRow,
1629
+ ): ExternalAuthPreparation {
1630
+ const metadata = externalAuthOperationMetadata(operation.metadata);
1631
+ return {
1632
+ run: authRunFromRow(run),
1633
+ authority: metadata.authority,
1634
+ operationState: operation.state,
1635
+ response:
1636
+ operation.state === "completed" && operation.result
1637
+ ? {
1638
+ ...ExternalAuthRunResponse.parse(operation.result),
1639
+ replayed: true,
1640
+ }
1641
+ : null,
1642
+ replayed: operation.state === "completed",
1643
+ };
1644
+ }
1645
+
1646
+ function parseProtectedAuthFillRequest(
1647
+ input: ProtectedAuthFillRequestValue,
1648
+ ): ProtectedAuthFillRequestValue {
1649
+ return ProtectedAuthFillRequest.parse({
1650
+ operationId: input.operationId,
1651
+ expectedVersion: input.expectedVersion,
1652
+ expectedTargetGeneration: input.expectedTargetGeneration,
1653
+ expectedDocumentGeneration: input.expectedDocumentGeneration,
1654
+ expectedFrameId: input.expectedFrameId,
1655
+ authorityId: input.authorityId,
1656
+ fields: input.fields,
1657
+ submit: input.submit,
1658
+ });
1659
+ }
1660
+
1661
+ function protectedAuthPreparationFromOperation(
1662
+ operation: ResourceOperationRow,
1663
+ run: AuthRunRow,
1664
+ ): ProtectedAuthFillPreparation {
1665
+ const metadata = protectedAuthOperationMetadata(operation.metadata);
1666
+ return {
1667
+ run: authRunFromRow(run),
1668
+ authority: metadata.authority,
1669
+ origins: metadata.origins,
1670
+ credentialVersion: metadata.credentialVersion,
1671
+ operationState: operation.state,
1672
+ response:
1673
+ operation.state === "completed" && operation.result
1674
+ ? {
1675
+ ...ProtectedAuthFillResponse.parse(operation.result),
1676
+ replayed: true,
1677
+ }
1678
+ : null,
1679
+ replayed: operation.state === "completed",
1680
+ };
1681
+ }
1682
+
1683
+ export async function getProtectedAuthFillPreparation(
1684
+ db: Database,
1685
+ input: InteractionMutationScope & {
1686
+ authRunId: string;
1687
+ } & ProtectedAuthFillRequestValue,
1688
+ ): Promise<ProtectedAuthFillPreparation | null> {
1689
+ const request = parseProtectedAuthFillRequest(input);
1690
+ const digest = protectedAuthFillDigest(input.authRunId, request, input.actorSubjectId);
1691
+ return await withRlsContext(
1692
+ db,
1693
+ input,
1694
+ async (scopedDb) => {
1695
+ const operation = await loadOperation(scopedDb, input.workspaceId, request.operationId);
1696
+ if (!operation) return null;
1697
+ assertOperationIdentity(operation, {
1698
+ resourceKind: "auth_run",
1699
+ resourceId: input.authRunId,
1700
+ kind: "protected_fill",
1701
+ requestDigest: digest,
1702
+ actorSubjectId: input.actorSubjectId,
1703
+ });
1704
+ const run = await loadAuthRunRow(scopedDb, input.workspaceId, input.authRunId);
1705
+ if (!run) throw new InteractionResourceNotFoundError("Auth run not found");
1706
+ return protectedAuthPreparationFromOperation(operation, run);
1707
+ },
1708
+ CONSISTENT_READ,
1709
+ );
1710
+ }
1711
+
1712
+ function protectedAuthOperationMetadata(value: unknown): ProtectedAuthOperationMetadata {
1713
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1714
+ throw new InteractionResourceStateError("Protected-fill operation metadata is invalid");
1715
+ }
1716
+ const record = value as Record<string, unknown>;
1717
+ const authority = SiteAuthAuthority.parse(record.authority);
1718
+ if (
1719
+ record.schemaVersion !== 1 ||
1720
+ !Number.isSafeInteger(record.authRunVersion) ||
1721
+ (record.authRunVersion as number) < 1 ||
1722
+ !Array.isArray(record.origins) ||
1723
+ record.origins.some(
1724
+ (origin) =>
1725
+ typeof origin !== "string" ||
1726
+ (() => {
1727
+ try {
1728
+ return new URL(origin).origin !== origin;
1729
+ } catch {
1730
+ return true;
1731
+ }
1732
+ })(),
1733
+ ) ||
1734
+ !Array.isArray(record.fields) ||
1735
+ record.fields.length < 1 ||
1736
+ record.fields.length > 32 ||
1737
+ !(
1738
+ record.credentialVersion === null ||
1739
+ (Number.isSafeInteger(record.credentialVersion) && (record.credentialVersion as number) > 0)
1740
+ )
1741
+ ) {
1742
+ throw new InteractionResourceStateError("Protected-fill operation metadata is invalid");
1743
+ }
1744
+ const purposes = new Set(["identifier", "password", "secret", "totp"]);
1745
+ const fields = record.fields.map((field) => {
1746
+ if (!field || typeof field !== "object" || Array.isArray(field)) {
1747
+ throw new InteractionResourceStateError("Protected-fill operation metadata is invalid");
1748
+ }
1749
+ const candidate = field as Record<string, unknown>;
1750
+ if (
1751
+ typeof candidate.id !== "string" ||
1752
+ candidate.id.length < 1 ||
1753
+ candidate.id.length > 512 ||
1754
+ typeof candidate.purpose !== "string" ||
1755
+ !purposes.has(candidate.purpose)
1756
+ ) {
1757
+ throw new InteractionResourceStateError("Protected-fill operation metadata is invalid");
1758
+ }
1759
+ return {
1760
+ id: candidate.id,
1761
+ purpose: candidate.purpose as ProtectedAuthOperationMetadata["fields"][number]["purpose"],
1762
+ };
1763
+ });
1764
+ if (new Set(fields.map((field) => field.id)).size !== fields.length) {
1765
+ throw new InteractionResourceStateError("Protected-fill operation metadata is invalid");
1766
+ }
1767
+ if ((authority.kind === "connection_fields") !== (record.credentialVersion !== null)) {
1768
+ throw new InteractionResourceStateError("Protected-fill credential metadata is invalid");
1769
+ }
1770
+ return {
1771
+ schemaVersion: 1,
1772
+ authRunVersion: record.authRunVersion as number,
1773
+ authority,
1774
+ origins: [...(record.origins as string[])],
1775
+ fields,
1776
+ credentialVersion: record.credentialVersion as number | null,
1777
+ };
1778
+ }
1779
+
1780
+ function protectedAuthFields(
1781
+ authority: SiteAuthAuthorityValue,
1782
+ requested: readonly ProtectedAuthFillRequestValue["fields"][number][],
1783
+ ): ProtectedAuthOperationMetadata["fields"] {
1784
+ if (authority.kind === "external_provider") {
1785
+ throw new InteractionResourceStateError(
1786
+ "External auth providers cannot use protected field fill",
1787
+ );
1788
+ }
1789
+ const configured = new Map(authority.fields.map((field) => [field.id, field]));
1790
+ return requested.map((field) => {
1791
+ const match = configured.get(field.fieldId);
1792
+ if (!match) {
1793
+ throw new InteractionResourceStateError("Protected-fill field is not configured");
1794
+ }
1795
+ return { id: match.id, purpose: match.purpose };
1796
+ });
1797
+ }
1798
+
1043
1799
  export async function listAuthRuns(
1044
1800
  db: Database,
1045
1801
  input: {
@@ -1089,31 +1845,112 @@ export async function getAuthRun(
1089
1845
  );
1090
1846
  }
1091
1847
 
1092
- export async function startAuthRun(
1848
+ export type ExternalAuthInteractiveContext = {
1849
+ run: AuthRunValue;
1850
+ authority: Extract<SiteAuthAuthorityValue, { kind: "external_provider" }>;
1851
+ };
1852
+
1853
+ /** Resolve only the durable authority needed to reveal an external provider's
1854
+ * hosted login UI. The hosted URL itself is deliberately never persisted. */
1855
+ export async function getExternalAuthInteractiveContext(
1093
1856
  db: Database,
1094
- input: InteractionMutationScope & { browserSessionId: string } & StartAuthRunRequestValue,
1095
- ): Promise<AuthRunMutationResponseValue> {
1096
- const request = StartAuthRunRequest.parse({
1857
+ input: InteractionMutationScope & {
1858
+ authRunId: string;
1859
+ } & ExternalAuthInteractiveRequestValue,
1860
+ ): Promise<ExternalAuthInteractiveContext> {
1861
+ const request = ExternalAuthInteractiveRequest.parse({
1097
1862
  operationId: input.operationId,
1098
- siteAuthConnectionId: input.siteAuthConnectionId,
1099
- targetId: input.targetId,
1100
- expectedTargetGeneration: input.expectedTargetGeneration,
1101
- expectedDocumentGeneration: input.expectedDocumentGeneration,
1102
- ...(input.methodId !== undefined ? { methodId: input.methodId } : {}),
1103
- ...(input.authorityId !== undefined ? { authorityId: input.authorityId } : {}),
1104
- });
1105
- const { operationId: _operationId, ...digestRequest } = request;
1106
- const digest = operationDigest({
1107
- version: 1,
1108
- browserSessionId: input.browserSessionId,
1109
- request: digestRequest,
1110
- actorSubjectId: input.actorSubjectId,
1863
+ expectedVersion: input.expectedVersion,
1111
1864
  });
1112
- try {
1113
- return await withRlsContext(db, input, async (scopedDb) => {
1114
- await assertWorkspaceAccount(scopedDb, input.accountId, input.workspaceId);
1115
- await lockOperation(scopedDb, request.operationId);
1116
- const existing = await loadOperation(scopedDb, input.workspaceId, request.operationId);
1865
+ return await withRlsContext(
1866
+ db,
1867
+ input,
1868
+ async (scopedDb) => {
1869
+ const run = await loadAuthRunRow(scopedDb, input.workspaceId, input.authRunId);
1870
+ if (!run) throw new InteractionResourceNotFoundError("Auth run not found");
1871
+ if (run.settledAt || run.version !== request.expectedVersion) {
1872
+ throw new InteractionResourceConflictError(
1873
+ "Auth run changed before its hosted login flow was opened",
1874
+ );
1875
+ }
1876
+ if (run.state !== "awaiting_external_action" || !run.externalAction || !run.interventionId) {
1877
+ throw new InteractionResourceStateError("Auth run is not waiting for a hosted login flow");
1878
+ }
1879
+ const [browser] = await scopedDb
1880
+ .select({
1881
+ lifecycle: schema.browserSessions.lifecycle,
1882
+ controllerGeneration: schema.browserSessions.controllerGeneration,
1883
+ })
1884
+ .from(schema.browserSessions)
1885
+ .where(
1886
+ and(
1887
+ eq(schema.browserSessions.workspaceId, input.workspaceId),
1888
+ eq(schema.browserSessions.id, run.browserSessionId),
1889
+ ),
1890
+ )
1891
+ .limit(1);
1892
+ if (
1893
+ !browser ||
1894
+ browser.lifecycle !== "active" ||
1895
+ browser.controllerGeneration !== run.controllerGeneration
1896
+ ) {
1897
+ throw new InteractionResourceConflictError(
1898
+ "Hosted login belongs to a stale browser controller",
1899
+ );
1900
+ }
1901
+ const connectionRow = await getSiteAuthConnectionRow(
1902
+ scopedDb,
1903
+ input.workspaceId,
1904
+ run.siteAuthConnectionId,
1905
+ );
1906
+ if (connectionRow.status !== "active") {
1907
+ throw new InteractionResourceStateError("Site auth connection is archived");
1908
+ }
1909
+ const connection = siteAuthConnectionFromRow(connectionRow);
1910
+ const authority = connection.authorities.find(
1911
+ (candidate) => candidate.id === run.authorityId,
1912
+ );
1913
+ if (!authority || authority.kind !== "external_provider") {
1914
+ throw new InteractionResourceStateError("Auth run has no external provider authority");
1915
+ }
1916
+ return { run: authRunFromRow(run), authority };
1917
+ },
1918
+ CONSISTENT_READ,
1919
+ );
1920
+ }
1921
+
1922
+ export async function startAuthRun(
1923
+ db: Database,
1924
+ input: InteractionMutationScope & {
1925
+ browserSessionId: string;
1926
+ controllerGeneration: string;
1927
+ /** Trusted attempt provenance supplied by the API grant. */
1928
+ originatingSessionId?: string | null;
1929
+ } & StartAuthRunRequestValue,
1930
+ ): Promise<AuthRunMutationResponseValue> {
1931
+ const request = StartAuthRunRequest.parse({
1932
+ operationId: input.operationId,
1933
+ siteAuthConnectionId: input.siteAuthConnectionId,
1934
+ targetId: input.targetId,
1935
+ expectedTargetGeneration: input.expectedTargetGeneration,
1936
+ expectedDocumentGeneration: input.expectedDocumentGeneration,
1937
+ ...(input.purpose !== undefined ? { purpose: input.purpose } : {}),
1938
+ ...(input.methodId !== undefined ? { methodId: input.methodId } : {}),
1939
+ ...(input.authorityId !== undefined ? { authorityId: input.authorityId } : {}),
1940
+ });
1941
+ const { operationId: _operationId, ...digestRequest } = request;
1942
+ const digest = operationDigest({
1943
+ version: 1,
1944
+ browserSessionId: input.browserSessionId,
1945
+ controllerGeneration: input.controllerGeneration,
1946
+ request: digestRequest,
1947
+ actorSubjectId: input.actorSubjectId,
1948
+ });
1949
+ try {
1950
+ return await withRlsContext(db, input, async (scopedDb) => {
1951
+ await assertWorkspaceAccount(scopedDb, input.accountId, input.workspaceId);
1952
+ await lockOperation(scopedDb, request.operationId);
1953
+ const existing = await loadOperation(scopedDb, input.workspaceId, request.operationId);
1117
1954
  if (existing) {
1118
1955
  assertOperation(existing, {
1119
1956
  resourceKind: "auth_run",
@@ -1136,7 +1973,11 @@ export async function startAuthRun(
1136
1973
  .for("update")
1137
1974
  .limit(1);
1138
1975
  if (!browser) throw new InteractionResourceNotFoundError("Browser session not found");
1139
- if (browser.lifecycle !== "active" || !browser.controllerGeneration) {
1976
+ if (
1977
+ browser.lifecycle !== "active" ||
1978
+ !browser.controllerGeneration ||
1979
+ browser.controllerGeneration !== input.controllerGeneration
1980
+ ) {
1140
1981
  throw new InteractionResourceStateError("Browser session is not active");
1141
1982
  }
1142
1983
  const authConnection = await getSiteAuthConnectionRow(
@@ -1152,6 +1993,55 @@ export async function startAuthRun(
1152
1993
  methodId: request.methodId ?? null,
1153
1994
  authorityId: request.authorityId ?? null,
1154
1995
  });
1996
+ let originatingMaintenanceOperationId: string | null = null;
1997
+ if (input.originatingSessionId) {
1998
+ const [originatingSession] = await scopedDb
1999
+ .select({
2000
+ createdByKind: schema.sessions.createdByKind,
2001
+ createdBySubjectId: schema.sessions.createdBySubjectId,
2002
+ createdByContext: schema.sessions.createdByContext,
2003
+ })
2004
+ .from(schema.sessions)
2005
+ .where(
2006
+ and(
2007
+ eq(schema.sessions.workspaceId, input.workspaceId),
2008
+ eq(schema.sessions.id, input.originatingSessionId),
2009
+ ),
2010
+ )
2011
+ .limit(1);
2012
+ const contextOperationId =
2013
+ originatingSession?.createdByContext[SITE_AUTH_MAINTENANCE_OPERATION_CONTEXT_KEY];
2014
+ const contextConnectionId =
2015
+ originatingSession?.createdByContext[SITE_AUTH_MAINTENANCE_CONNECTION_CONTEXT_KEY];
2016
+ if (
2017
+ originatingSession?.createdByKind === "service" &&
2018
+ originatingSession.createdBySubjectId === "site-auth-maintenance" &&
2019
+ typeof contextOperationId === "string"
2020
+ ) {
2021
+ if (contextConnectionId !== request.siteAuthConnectionId) {
2022
+ throw new InteractionResourceStateError(
2023
+ "Site auth maintenance session belongs to another connection",
2024
+ );
2025
+ }
2026
+ originatingMaintenanceOperationId = contextOperationId;
2027
+ }
2028
+ }
2029
+ if (
2030
+ originatingMaintenanceOperationId &&
2031
+ (input.originatingSessionId !== authConnection.maintenanceSessionId ||
2032
+ originatingMaintenanceOperationId !== authConnection.maintenanceOperationId)
2033
+ ) {
2034
+ throw new InteractionResourceStateError("Site auth maintenance claim is no longer active");
2035
+ }
2036
+ const maintenanceOperationId = originatingMaintenanceOperationId;
2037
+ if (
2038
+ maintenanceOperationId &&
2039
+ (request.purpose ?? "authenticate") !== authConnection.maintenanceAction
2040
+ ) {
2041
+ throw new InteractionResourceStateError(
2042
+ "Maintenance auth run purpose does not match its durable claim",
2043
+ );
2044
+ }
1155
2045
  const id = randomUUID();
1156
2046
  const [row] = await scopedDb
1157
2047
  .insert(schema.authRuns)
@@ -1165,6 +2055,8 @@ export async function startAuthRun(
1165
2055
  controllerGeneration: browser.controllerGeneration,
1166
2056
  targetGeneration: request.expectedTargetGeneration,
1167
2057
  documentGeneration: request.expectedDocumentGeneration,
2058
+ purpose: request.purpose ?? "authenticate",
2059
+ maintenanceOperationId,
1168
2060
  methodId: request.methodId ?? null,
1169
2061
  authorityId: request.authorityId ?? null,
1170
2062
  operationId: request.operationId,
@@ -1193,6 +2085,11 @@ export async function startAuthRun(
1193
2085
  return response;
1194
2086
  });
1195
2087
  } catch (error) {
2088
+ if (postgresConstraint(error) === "auth_runs_workspace_maintenance_operation_uq") {
2089
+ throw new InteractionResourceConflictError(
2090
+ "This maintenance session already has an auth run",
2091
+ );
2092
+ }
1196
2093
  if (postgresConstraint(error) === "auth_runs_active_browser_target_uq") {
1197
2094
  throw new InteractionResourceConflictError(
1198
2095
  "This browser target already has an active auth run",
@@ -1202,6 +2099,131 @@ export async function startAuthRun(
1202
2099
  }
1203
2100
  }
1204
2101
 
2102
+ export type SettleSessionMaintenanceResult = {
2103
+ cancelledAuthRuns: number;
2104
+ releasedClaim: boolean;
2105
+ };
2106
+
2107
+ /** A terminal-for-now maintenance session cannot leave an AuthRun or its claim
2108
+ * pinned. Interventions settle linked runs first; this covers every other path. */
2109
+ export async function settleSessionMaintenanceInTransaction(
2110
+ db: Database,
2111
+ input: {
2112
+ accountId: string;
2113
+ workspaceId: string;
2114
+ sessionId: string;
2115
+ },
2116
+ ): Promise<SettleSessionMaintenanceResult> {
2117
+ const [session] = await db
2118
+ .select({
2119
+ createdByKind: schema.sessions.createdByKind,
2120
+ createdBySubjectId: schema.sessions.createdBySubjectId,
2121
+ createdByContext: schema.sessions.createdByContext,
2122
+ })
2123
+ .from(schema.sessions)
2124
+ .where(
2125
+ and(
2126
+ eq(schema.sessions.workspaceId, input.workspaceId),
2127
+ eq(schema.sessions.id, input.sessionId),
2128
+ ),
2129
+ )
2130
+ .limit(1);
2131
+ const maintenanceOperationId =
2132
+ session?.createdByContext[SITE_AUTH_MAINTENANCE_OPERATION_CONTEXT_KEY];
2133
+ const siteAuthConnectionId =
2134
+ session?.createdByContext[SITE_AUTH_MAINTENANCE_CONNECTION_CONTEXT_KEY];
2135
+ if (
2136
+ session?.createdByKind !== "service" ||
2137
+ session.createdBySubjectId !== "site-auth-maintenance" ||
2138
+ typeof maintenanceOperationId !== "string" ||
2139
+ typeof siteAuthConnectionId !== "string"
2140
+ ) {
2141
+ return { cancelledAuthRuns: 0, releasedClaim: false };
2142
+ }
2143
+ const settledAt = new Date();
2144
+ const cancelled = await db
2145
+ .update(schema.authRuns)
2146
+ .set({
2147
+ state: "cancelled",
2148
+ choices: [],
2149
+ pendingFields: [],
2150
+ externalAction: null,
2151
+ verifiedUrl: null,
2152
+ failureCode: null,
2153
+ version: sql`${schema.authRuns.version} + 1`,
2154
+ updatedAt: settledAt,
2155
+ settledAt,
2156
+ })
2157
+ .where(
2158
+ and(
2159
+ eq(schema.authRuns.workspaceId, input.workspaceId),
2160
+ eq(schema.authRuns.siteAuthConnectionId, siteAuthConnectionId),
2161
+ eq(schema.authRuns.maintenanceOperationId, maintenanceOperationId),
2162
+ sql`${schema.authRuns.settledAt} is null`,
2163
+ ),
2164
+ )
2165
+ .returning();
2166
+ for (const run of cancelled) {
2167
+ await projectSettledAuthRunHealth(db, {
2168
+ run,
2169
+ actorSubjectId: "site-auth-maintenance",
2170
+ });
2171
+ }
2172
+ const [orphanedClaim] = await db
2173
+ .select()
2174
+ .from(schema.siteAuthConnections)
2175
+ .where(
2176
+ and(
2177
+ eq(schema.siteAuthConnections.workspaceId, input.workspaceId),
2178
+ eq(schema.siteAuthConnections.id, siteAuthConnectionId),
2179
+ eq(schema.siteAuthConnections.maintenanceOperationId, maintenanceOperationId),
2180
+ eq(schema.siteAuthConnections.maintenanceSessionId, input.sessionId),
2181
+ ),
2182
+ )
2183
+ .for("update")
2184
+ .limit(1);
2185
+ let releasedClaim = false;
2186
+ if (orphanedClaim) {
2187
+ const intervalSeconds = orphanedClaim.healthPolicy.intervalSeconds;
2188
+ const retryAt =
2189
+ orphanedClaim.status === "active" &&
2190
+ orphanedClaim.healthPolicy.mode === "maintained" &&
2191
+ intervalSeconds !== null
2192
+ ? new Date(
2193
+ settledAt.getTime() +
2194
+ Math.min(intervalSeconds * 1_000, MAX_SITE_AUTH_MAINTENANCE_RETRY_MS),
2195
+ )
2196
+ : null;
2197
+ const [released] = await db
2198
+ .update(schema.siteAuthConnections)
2199
+ .set({
2200
+ maintenanceOperationId: null,
2201
+ maintenanceAction: null,
2202
+ maintenanceDueAt: null,
2203
+ maintenanceClaimedAt: null,
2204
+ maintenanceSessionId: null,
2205
+ maintenanceStartedAt: null,
2206
+ nextCheckAt: retryAt,
2207
+ version: sql`${schema.siteAuthConnections.version} + 1`,
2208
+ updatedBySubjectId: "site-auth-maintenance",
2209
+ updatedAt: settledAt,
2210
+ })
2211
+ .where(
2212
+ and(
2213
+ eq(schema.siteAuthConnections.workspaceId, input.workspaceId),
2214
+ eq(schema.siteAuthConnections.id, orphanedClaim.id),
2215
+ eq(schema.siteAuthConnections.maintenanceOperationId, maintenanceOperationId),
2216
+ ),
2217
+ )
2218
+ .returning({ id: schema.siteAuthConnections.id });
2219
+ releasedClaim = released !== undefined;
2220
+ }
2221
+ if (cancelled.length > 0 || releasedClaim) {
2222
+ await advanceWorkspaceInteractionRevision(db, input.accountId, input.workspaceId);
2223
+ }
2224
+ return { cancelledAuthRuns: cancelled.length, releasedClaim };
2225
+ }
2226
+
1205
2227
  export async function reportAuthRun(
1206
2228
  db: Database,
1207
2229
  input: InteractionMutationScope & {
@@ -1298,10 +2320,1159 @@ export async function reportAuthRun(
1298
2320
  eq(schema.authRuns.controllerGeneration, input.controllerGeneration),
1299
2321
  ),
1300
2322
  )
1301
- .returning();
1302
- if (!row) throw new InteractionResourceConflictError("Auth run report lost its fence");
2323
+ .returning();
2324
+ if (!row) throw new InteractionResourceConflictError("Auth run report lost its fence");
2325
+ await projectSettledAuthRunHealth(scopedDb, {
2326
+ run: row,
2327
+ actorSubjectId: input.actorSubjectId,
2328
+ });
2329
+ const response = AuthRunMutationResponse.parse({
2330
+ run: authRunFromRow(row),
2331
+ operationId: request.operationId,
2332
+ replayed: false,
2333
+ });
2334
+ await insertCompletedOperation(scopedDb, {
2335
+ operationId: request.operationId,
2336
+ accountId: input.accountId,
2337
+ workspaceId: input.workspaceId,
2338
+ resourceKind: "auth_run",
2339
+ resourceId: input.authRunId,
2340
+ kind: "report",
2341
+ requestDigest: digest,
2342
+ resultVersion: row.version,
2343
+ result: response,
2344
+ actorSubjectId: input.actorSubjectId,
2345
+ });
2346
+ await advanceWorkspaceInteractionRevision(scopedDb, input.accountId, input.workspaceId);
2347
+ return response;
2348
+ });
2349
+ }
2350
+
2351
+ export async function getExternalAuthPreparation(
2352
+ db: Database,
2353
+ input: InteractionMutationScope & {
2354
+ authRunId: string;
2355
+ } & ExternalAuthRunRequestValue,
2356
+ ): Promise<ExternalAuthPreparation | null> {
2357
+ const request = parseExternalAuthRunRequest(input);
2358
+ const digest = externalAuthDigest(input.authRunId, request, input.actorSubjectId);
2359
+ return await withRlsContext(
2360
+ db,
2361
+ input,
2362
+ async (scopedDb) => {
2363
+ const operation = await loadOperation(scopedDb, input.workspaceId, request.operationId);
2364
+ if (!operation) return null;
2365
+ assertOperationIdentity(operation, {
2366
+ resourceKind: "auth_run",
2367
+ resourceId: input.authRunId,
2368
+ kind: "external_auth",
2369
+ requestDigest: digest,
2370
+ actorSubjectId: input.actorSubjectId,
2371
+ });
2372
+ const run = await loadAuthRunRow(scopedDb, input.workspaceId, input.authRunId);
2373
+ if (!run) throw new InteractionResourceNotFoundError("Auth run not found");
2374
+ return externalAuthPreparationFromOperation(operation, run);
2375
+ },
2376
+ CONSISTENT_READ,
2377
+ );
2378
+ }
2379
+
2380
+ export async function prepareExternalAuth(
2381
+ db: Database,
2382
+ input: InteractionMutationScope & {
2383
+ authRunId: string;
2384
+ } & ExternalAuthRunRequestValue,
2385
+ ): Promise<ExternalAuthPreparation> {
2386
+ const request = parseExternalAuthRunRequest(input);
2387
+ const digest = externalAuthDigest(input.authRunId, request, input.actorSubjectId);
2388
+ return await withRlsContext(db, input, async (scopedDb) => {
2389
+ await assertWorkspaceAccount(scopedDb, input.accountId, input.workspaceId);
2390
+ await setSubjectRlsContext(scopedDb, input.actorSubjectId);
2391
+ await lockOperation(scopedDb, request.operationId);
2392
+ const existing = await loadOperation(scopedDb, input.workspaceId, request.operationId);
2393
+ if (existing) {
2394
+ assertOperationIdentity(existing, {
2395
+ resourceKind: "auth_run",
2396
+ resourceId: input.authRunId,
2397
+ kind: "external_auth",
2398
+ requestDigest: digest,
2399
+ actorSubjectId: input.actorSubjectId,
2400
+ });
2401
+ const run = await loadAuthRunRow(scopedDb, input.workspaceId, input.authRunId);
2402
+ if (!run) throw new InteractionResourceNotFoundError("Auth run not found");
2403
+ return externalAuthPreparationFromOperation(existing, run);
2404
+ }
2405
+
2406
+ const [run] = await scopedDb
2407
+ .select()
2408
+ .from(schema.authRuns)
2409
+ .where(
2410
+ and(
2411
+ eq(schema.authRuns.workspaceId, input.workspaceId),
2412
+ eq(schema.authRuns.id, input.authRunId),
2413
+ ),
2414
+ )
2415
+ .for("update")
2416
+ .limit(1);
2417
+ if (!run) throw new InteractionResourceNotFoundError("Auth run not found");
2418
+ if (run.settledAt) throw new InteractionResourceStateError("Auth run is already settled");
2419
+ if (run.version !== request.expectedVersion) {
2420
+ throw new InteractionResourceConflictError("Auth run changed before external authentication");
2421
+ }
2422
+ const [browser] = await scopedDb
2423
+ .select({
2424
+ lifecycle: schema.browserSessions.lifecycle,
2425
+ controllerGeneration: schema.browserSessions.controllerGeneration,
2426
+ })
2427
+ .from(schema.browserSessions)
2428
+ .where(
2429
+ and(
2430
+ eq(schema.browserSessions.workspaceId, input.workspaceId),
2431
+ eq(schema.browserSessions.id, run.browserSessionId),
2432
+ ),
2433
+ )
2434
+ .for("update")
2435
+ .limit(1);
2436
+ if (
2437
+ !browser ||
2438
+ browser.lifecycle !== "active" ||
2439
+ browser.controllerGeneration !== run.controllerGeneration
2440
+ ) {
2441
+ throw new InteractionResourceConflictError(
2442
+ "External authentication belongs to a stale browser controller",
2443
+ );
2444
+ }
2445
+ const connectionRow = await getSiteAuthConnectionRow(
2446
+ scopedDb,
2447
+ input.workspaceId,
2448
+ run.siteAuthConnectionId,
2449
+ );
2450
+ if (connectionRow.status !== "active") {
2451
+ throw new InteractionResourceStateError("Site auth connection is archived");
2452
+ }
2453
+ const connection = siteAuthConnectionFromRow(connectionRow);
2454
+ if (!run.authorityId) {
2455
+ throw new InteractionResourceStateError(
2456
+ "Auth run must select an external authority before it can start",
2457
+ );
2458
+ }
2459
+ assertAuthSelection(connection, {
2460
+ methodId: run.methodId,
2461
+ authorityId: run.authorityId,
2462
+ });
2463
+ const selected = connection.authorities.find((authority) => authority.id === run.authorityId);
2464
+ if (!selected || selected.kind !== "external_provider") {
2465
+ throw new InteractionResourceStateError(
2466
+ "Selected auth authority is not an external provider",
2467
+ );
2468
+ }
2469
+ const metadata: ExternalAuthOperationMetadata = {
2470
+ schemaVersion: 1,
2471
+ authRunVersion: run.version,
2472
+ authority: selected,
2473
+ };
2474
+ await scopedDb.insert(schema.interactionResourceOperations).values({
2475
+ operationId: request.operationId,
2476
+ accountId: input.accountId,
2477
+ workspaceId: input.workspaceId,
2478
+ resourceKind: "auth_run",
2479
+ resourceId: input.authRunId,
2480
+ kind: "external_auth",
2481
+ requestDigest: digest,
2482
+ metadata,
2483
+ state: "prepared",
2484
+ actorSubjectId: input.actorSubjectId,
2485
+ });
2486
+ return {
2487
+ run: authRunFromRow(run),
2488
+ authority: selected,
2489
+ operationState: "prepared",
2490
+ response: null,
2491
+ replayed: false,
2492
+ };
2493
+ });
2494
+ }
2495
+
2496
+ export async function dispatchExternalAuth(
2497
+ db: Database,
2498
+ input: InteractionMutationScope & { authRunId: string; operationId: string },
2499
+ ): Promise<ResourceOperationRow["state"]> {
2500
+ return await withRlsContext(db, input, async (scopedDb) => {
2501
+ await lockOperation(scopedDb, input.operationId);
2502
+ const operation = await loadOperation(scopedDb, input.workspaceId, input.operationId);
2503
+ if (!operation) throw new InteractionResourceNotFoundError("External-auth operation not found");
2504
+ assertExternalAuthOperationBinding(operation, input);
2505
+ if (operation.state !== "prepared") return operation.state;
2506
+ const [dispatched] = await scopedDb
2507
+ .update(schema.interactionResourceOperations)
2508
+ .set({ state: "dispatched" })
2509
+ .where(
2510
+ and(
2511
+ eq(schema.interactionResourceOperations.operationId, input.operationId),
2512
+ eq(schema.interactionResourceOperations.state, "prepared"),
2513
+ ),
2514
+ )
2515
+ .returning({ state: schema.interactionResourceOperations.state });
2516
+ if (!dispatched) {
2517
+ throw new InteractionResourceConflictError("External-auth dispatch lost its fence");
2518
+ }
2519
+ return dispatched.state;
2520
+ });
2521
+ }
2522
+
2523
+ export async function completeExternalAuth(
2524
+ db: Database,
2525
+ input: InteractionMutationScope & {
2526
+ authRunId: string;
2527
+ operationId: string;
2528
+ result: BrowserExternalAuthResultValue;
2529
+ target?: {
2530
+ id: string;
2531
+ targetGeneration: string;
2532
+ documentGeneration: string | null;
2533
+ };
2534
+ intervention?: {
2535
+ originatingSessionId: string;
2536
+ originatingTurnId?: string | null;
2537
+ originatingAttemptId?: string | null;
2538
+ originatingToolOperationId?: string | null;
2539
+ expiresInSeconds: number;
2540
+ };
2541
+ },
2542
+ ): Promise<ExternalAuthRunResponseValue> {
2543
+ const providerResult = BrowserExternalAuthResult.parse(input.result);
2544
+ try {
2545
+ return await withRlsContext(db, input, async (scopedDb) => {
2546
+ await lockOperation(scopedDb, input.operationId);
2547
+ const operation = await loadOperation(scopedDb, input.workspaceId, input.operationId);
2548
+ if (!operation)
2549
+ throw new InteractionResourceNotFoundError("External-auth operation not found");
2550
+ assertExternalAuthOperationBinding(operation, input);
2551
+ if (operation.state === "completed" && operation.result) {
2552
+ return {
2553
+ ...ExternalAuthRunResponse.parse(operation.result),
2554
+ replayed: true,
2555
+ };
2556
+ }
2557
+ if (operation.state !== "dispatched") {
2558
+ throw new InteractionResourceStateError(
2559
+ `External-auth operation is ${operation.state.replace("_", " ")}`,
2560
+ );
2561
+ }
2562
+ const metadata = externalAuthOperationMetadata(operation.metadata);
2563
+ const [run] = await scopedDb
2564
+ .select()
2565
+ .from(schema.authRuns)
2566
+ .where(
2567
+ and(
2568
+ eq(schema.authRuns.workspaceId, input.workspaceId),
2569
+ eq(schema.authRuns.id, input.authRunId),
2570
+ ),
2571
+ )
2572
+ .for("update")
2573
+ .limit(1);
2574
+ if (!run) throw new InteractionResourceNotFoundError("Auth run not found");
2575
+ if (run.version !== metadata.authRunVersion || run.settledAt) {
2576
+ throw new InteractionResourceConflictError("Auth run changed after external auth began");
2577
+ }
2578
+ if ((providerResult.state === "authenticated") !== Boolean(input.target)) {
2579
+ throw new InteractionResourceStateError(
2580
+ "Authenticated external auth requires the current browser target",
2581
+ );
2582
+ }
2583
+ if ((providerResult.state === "needs_human") !== Boolean(input.intervention)) {
2584
+ throw new InteractionResourceStateError(
2585
+ "Human external auth requires intervention provenance",
2586
+ );
2587
+ }
2588
+ const nextState =
2589
+ providerResult.state === "needs_human"
2590
+ ? "awaiting_external_action"
2591
+ : providerResult.state === "failed"
2592
+ ? "failed"
2593
+ : "working";
2594
+ if (!AUTH_RUN_TRANSITIONS[run.state].has(nextState)) {
2595
+ throw new InteractionResourceStateError(
2596
+ `Auth run cannot transition from ${run.state} to ${nextState}`,
2597
+ );
2598
+ }
2599
+
2600
+ if (providerResult.profileLoaded) {
2601
+ const otherRuns = await scopedDb
2602
+ .update(schema.authRuns)
2603
+ .set({
2604
+ state: "failed",
2605
+ choices: [],
2606
+ pendingFields: [],
2607
+ externalAction: null,
2608
+ interventionId: null,
2609
+ verifiedUrl: null,
2610
+ failureCode: "browser_profile_reconfigured",
2611
+ version: sql`${schema.authRuns.version} + 1`,
2612
+ updatedAt: sql`now()`,
2613
+ settledAt: sql`now()`,
2614
+ })
2615
+ .where(
2616
+ and(
2617
+ eq(schema.authRuns.workspaceId, input.workspaceId),
2618
+ eq(schema.authRuns.browserSessionId, run.browserSessionId),
2619
+ sql`${schema.authRuns.id} <> ${run.id}`,
2620
+ sql`${schema.authRuns.settledAt} is null`,
2621
+ ),
2622
+ )
2623
+ .returning();
2624
+ if (otherRuns.length > 0) {
2625
+ const otherIds = otherRuns.map((entry) => entry.id);
2626
+ await scopedDb
2627
+ .update(schema.interactionResourceOperations)
2628
+ .set({
2629
+ state: "failed",
2630
+ errorCode: "browser_profile_reconfigured",
2631
+ settledAt: sql`now()`,
2632
+ })
2633
+ .where(
2634
+ and(
2635
+ eq(schema.interactionResourceOperations.workspaceId, input.workspaceId),
2636
+ eq(schema.interactionResourceOperations.resourceKind, "auth_run"),
2637
+ inArray(schema.interactionResourceOperations.resourceId, otherIds),
2638
+ inArray(schema.interactionResourceOperations.state, ["prepared", "dispatched"]),
2639
+ ),
2640
+ );
2641
+ await scopedDb
2642
+ .update(schema.interactionInterventions)
2643
+ .set({
2644
+ status: "cancelled",
2645
+ responseActorSubjectId: input.actorSubjectId,
2646
+ version: sql`${schema.interactionInterventions.version} + 1`,
2647
+ updatedAt: sql`now()`,
2648
+ settledAt: sql`now()`,
2649
+ })
2650
+ .where(
2651
+ and(
2652
+ eq(schema.interactionInterventions.workspaceId, input.workspaceId),
2653
+ inArray(schema.interactionInterventions.authRunId, otherIds),
2654
+ eq(schema.interactionInterventions.status, "open"),
2655
+ ),
2656
+ );
2657
+ for (const other of otherRuns) {
2658
+ await projectSettledAuthRunHealth(scopedDb, {
2659
+ run: other,
2660
+ actorSubjectId: input.actorSubjectId,
2661
+ });
2662
+ }
2663
+ }
2664
+ }
2665
+
2666
+ let interventionId: string | null = null;
2667
+ if (providerResult.state === "needs_human") {
2668
+ const currentIntervention = run.interventionId
2669
+ ? (
2670
+ await scopedDb
2671
+ .select()
2672
+ .from(schema.interactionInterventions)
2673
+ .where(
2674
+ and(
2675
+ eq(schema.interactionInterventions.workspaceId, input.workspaceId),
2676
+ eq(schema.interactionInterventions.id, run.interventionId),
2677
+ ),
2678
+ )
2679
+ .for("update")
2680
+ .limit(1)
2681
+ )[0]
2682
+ : null;
2683
+ if (currentIntervention?.status === "open" && currentIntervention.expiresAt > new Date()) {
2684
+ interventionId = currentIntervention.id;
2685
+ } else {
2686
+ if (currentIntervention?.status === "open") {
2687
+ await scopedDb
2688
+ .update(schema.interactionInterventions)
2689
+ .set({
2690
+ status: "expired",
2691
+ version: currentIntervention.version + 1,
2692
+ updatedAt: sql`now()`,
2693
+ settledAt: sql`now()`,
2694
+ })
2695
+ .where(
2696
+ and(
2697
+ eq(schema.interactionInterventions.id, currentIntervention.id),
2698
+ eq(schema.interactionInterventions.version, currentIntervention.version),
2699
+ eq(schema.interactionInterventions.status, "open"),
2700
+ ),
2701
+ );
2702
+ }
2703
+ const intervention = input.intervention!;
2704
+ if (
2705
+ (intervention.originatingAttemptId && !intervention.originatingTurnId) ||
2706
+ (intervention.originatingToolOperationId && !intervention.originatingAttemptId)
2707
+ ) {
2708
+ throw new InteractionResourceStateError(
2709
+ "External-auth intervention provenance is incomplete",
2710
+ );
2711
+ }
2712
+ interventionId = randomUUID();
2713
+ const [created] = await scopedDb
2714
+ .insert(schema.interactionInterventions)
2715
+ .values({
2716
+ id: interventionId,
2717
+ accountId: input.accountId,
2718
+ workspaceId: input.workspaceId,
2719
+ resourceKind: "browser_session",
2720
+ resourceId: run.browserSessionId,
2721
+ targetId: run.targetId,
2722
+ controllerGeneration: run.controllerGeneration,
2723
+ targetGeneration: run.targetGeneration,
2724
+ documentGeneration: run.documentGeneration,
2725
+ kind: "external_action",
2726
+ reason: providerResult.externalAction!.label,
2727
+ authRunId: run.id,
2728
+ originatingSessionId: intervention.originatingSessionId,
2729
+ originatingTurnId: intervention.originatingTurnId ?? null,
2730
+ originatingAttemptId: intervention.originatingAttemptId ?? null,
2731
+ originatingToolOperationId: intervention.originatingToolOperationId ?? null,
2732
+ operationId: input.operationId,
2733
+ expiresAt: providerResult.externalAction!.expiresAt
2734
+ ? new Date(providerResult.externalAction!.expiresAt)
2735
+ : sql`now() + (${intervention.expiresInSeconds} * interval '1 second')`,
2736
+ })
2737
+ .returning({ id: schema.interactionInterventions.id });
2738
+ if (!created) throw new Error("External-auth intervention insert returned no row");
2739
+ }
2740
+ } else if (run.interventionId) {
2741
+ await scopedDb
2742
+ .update(schema.interactionInterventions)
2743
+ .set({
2744
+ status: "completed",
2745
+ responseActorSubjectId: input.actorSubjectId,
2746
+ version: sql`${schema.interactionInterventions.version} + 1`,
2747
+ updatedAt: sql`now()`,
2748
+ settledAt: sql`now()`,
2749
+ })
2750
+ .where(
2751
+ and(
2752
+ eq(schema.interactionInterventions.workspaceId, input.workspaceId),
2753
+ eq(schema.interactionInterventions.id, run.interventionId),
2754
+ eq(schema.interactionInterventions.status, "open"),
2755
+ ),
2756
+ );
2757
+ }
2758
+
2759
+ const [updated] = await scopedDb
2760
+ .update(schema.authRuns)
2761
+ .set({
2762
+ authorityId: metadata.authority.id,
2763
+ state: nextState,
2764
+ targetId: input.target?.id ?? run.targetId,
2765
+ targetGeneration: input.target?.targetGeneration ?? run.targetGeneration,
2766
+ documentGeneration: input.target?.documentGeneration ?? run.documentGeneration,
2767
+ choices: [],
2768
+ pendingFields: [],
2769
+ externalAction:
2770
+ providerResult.state === "needs_human" ? providerResult.externalAction : null,
2771
+ interventionId,
2772
+ verifiedUrl: null,
2773
+ failureCode: providerResult.state === "failed" ? providerResult.failureCode : null,
2774
+ version: run.version + 1,
2775
+ updatedAt: sql`now()`,
2776
+ settledAt: providerResult.state === "failed" ? sql`now()` : null,
2777
+ })
2778
+ .where(
2779
+ and(
2780
+ eq(schema.authRuns.workspaceId, input.workspaceId),
2781
+ eq(schema.authRuns.id, input.authRunId),
2782
+ eq(schema.authRuns.version, metadata.authRunVersion),
2783
+ sql`${schema.authRuns.settledAt} is null`,
2784
+ ),
2785
+ )
2786
+ .returning();
2787
+ if (!updated) {
2788
+ throw new InteractionResourceConflictError("External-auth settlement lost its auth fence");
2789
+ }
2790
+ await projectSettledAuthRunHealth(scopedDb, {
2791
+ run: updated,
2792
+ actorSubjectId: input.actorSubjectId,
2793
+ });
2794
+ const status =
2795
+ providerResult.state === "authenticated"
2796
+ ? "ready_to_verify"
2797
+ : providerResult.state === "needs_human"
2798
+ ? "needs_human"
2799
+ : providerResult.state === "failed"
2800
+ ? "failed"
2801
+ : "working";
2802
+ const response = ExternalAuthRunResponse.parse({
2803
+ run: authRunFromRow(updated),
2804
+ status,
2805
+ operationId: input.operationId,
2806
+ replayed: false,
2807
+ });
2808
+ const [completed] = await scopedDb
2809
+ .update(schema.interactionResourceOperations)
2810
+ .set({
2811
+ state: "completed",
2812
+ resultVersion: updated.version,
2813
+ result: response,
2814
+ settledAt: sql`now()`,
2815
+ })
2816
+ .where(
2817
+ and(
2818
+ eq(schema.interactionResourceOperations.operationId, input.operationId),
2819
+ eq(schema.interactionResourceOperations.state, "dispatched"),
2820
+ ),
2821
+ )
2822
+ .returning({ operationId: schema.interactionResourceOperations.operationId });
2823
+ if (!completed) {
2824
+ throw new InteractionResourceConflictError(
2825
+ "External-auth settlement lost its operation fence",
2826
+ );
2827
+ }
2828
+ await advanceWorkspaceInteractionRevision(scopedDb, input.accountId, input.workspaceId);
2829
+ return response;
2830
+ });
2831
+ } catch (error) {
2832
+ if (
2833
+ postgresConstraint(error) === "interaction_interventions_workspace_operation_uq" ||
2834
+ postgresConstraint(error) === "interaction_interventions_open_target_kind_uq" ||
2835
+ postgresConstraint(error) === "interaction_interventions_open_auth_run_uq"
2836
+ ) {
2837
+ throw new InteractionResourceConflictError(
2838
+ "A matching interaction intervention is already open",
2839
+ );
2840
+ }
2841
+ throw error;
2842
+ }
2843
+ }
2844
+
2845
+ function assertExternalAuthOperationBinding(
2846
+ operation: ResourceOperationRow,
2847
+ input: { authRunId: string; actorSubjectId: string },
2848
+ ): void {
2849
+ if (
2850
+ operation.resourceKind !== "auth_run" ||
2851
+ operation.resourceId !== input.authRunId ||
2852
+ operation.kind !== "external_auth" ||
2853
+ operation.actorSubjectId !== input.actorSubjectId
2854
+ ) {
2855
+ throw new InteractionResourceConflictError(
2856
+ "Operation id is bound to another external-auth request",
2857
+ );
2858
+ }
2859
+ }
2860
+
2861
+ export async function prepareProtectedAuthFill(
2862
+ db: Database,
2863
+ input: InteractionMutationScope & {
2864
+ authRunId: string;
2865
+ credentialVersion: number | null;
2866
+ } & ProtectedAuthFillRequestValue,
2867
+ ): Promise<ProtectedAuthFillPreparation> {
2868
+ const request = parseProtectedAuthFillRequest(input);
2869
+ const digest = protectedAuthFillDigest(input.authRunId, request, input.actorSubjectId);
2870
+ return await withRlsContext(db, input, async (scopedDb) => {
2871
+ await assertWorkspaceAccount(scopedDb, input.accountId, input.workspaceId);
2872
+ await setSubjectRlsContext(scopedDb, input.actorSubjectId);
2873
+ await lockOperation(scopedDb, request.operationId);
2874
+ const existing = await loadOperation(scopedDb, input.workspaceId, request.operationId);
2875
+ if (existing) {
2876
+ assertOperationIdentity(existing, {
2877
+ resourceKind: "auth_run",
2878
+ resourceId: input.authRunId,
2879
+ kind: "protected_fill",
2880
+ requestDigest: digest,
2881
+ actorSubjectId: input.actorSubjectId,
2882
+ });
2883
+ const run = await loadAuthRunRow(scopedDb, input.workspaceId, input.authRunId);
2884
+ if (!run) throw new InteractionResourceNotFoundError("Auth run not found");
2885
+ return protectedAuthPreparationFromOperation(existing, run);
2886
+ }
2887
+
2888
+ const [run] = await scopedDb
2889
+ .select()
2890
+ .from(schema.authRuns)
2891
+ .where(
2892
+ and(
2893
+ eq(schema.authRuns.workspaceId, input.workspaceId),
2894
+ eq(schema.authRuns.id, input.authRunId),
2895
+ ),
2896
+ )
2897
+ .for("update")
2898
+ .limit(1);
2899
+ if (!run) throw new InteractionResourceNotFoundError("Auth run not found");
2900
+ if (run.settledAt) throw new InteractionResourceStateError("Auth run is already settled");
2901
+ if (run.version !== request.expectedVersion) {
2902
+ throw new InteractionResourceConflictError("Auth run changed before protected fill");
2903
+ }
2904
+ if (
2905
+ run.targetGeneration !== request.expectedTargetGeneration ||
2906
+ run.documentGeneration !== request.expectedDocumentGeneration ||
2907
+ !request.expectedDocumentGeneration ||
2908
+ !request.expectedFrameId
2909
+ ) {
2910
+ throw new InteractionResourceConflictError(
2911
+ "Protected fill does not match the exact auth-run document",
2912
+ );
2913
+ }
2914
+ const [browser] = await scopedDb
2915
+ .select({
2916
+ lifecycle: schema.browserSessions.lifecycle,
2917
+ controllerGeneration: schema.browserSessions.controllerGeneration,
2918
+ })
2919
+ .from(schema.browserSessions)
2920
+ .where(
2921
+ and(
2922
+ eq(schema.browserSessions.workspaceId, input.workspaceId),
2923
+ eq(schema.browserSessions.id, run.browserSessionId),
2924
+ ),
2925
+ )
2926
+ .for("update")
2927
+ .limit(1);
2928
+ if (
2929
+ !browser ||
2930
+ browser.lifecycle !== "active" ||
2931
+ browser.controllerGeneration !== run.controllerGeneration
2932
+ ) {
2933
+ throw new InteractionResourceConflictError(
2934
+ "Protected fill belongs to a stale browser controller",
2935
+ );
2936
+ }
2937
+ const connectionRow = await getSiteAuthConnectionRow(
2938
+ scopedDb,
2939
+ input.workspaceId,
2940
+ run.siteAuthConnectionId,
2941
+ );
2942
+ if (connectionRow.status !== "active") {
2943
+ throw new InteractionResourceStateError("Site auth connection is archived");
2944
+ }
2945
+ const connection = siteAuthConnectionFromRow(connectionRow);
2946
+ assertAuthSelection(connection, {
2947
+ methodId: run.methodId,
2948
+ authorityId: request.authorityId,
2949
+ });
2950
+ if (run.authorityId && run.authorityId !== request.authorityId) {
2951
+ throw new InteractionResourceConflictError("Auth run selected another authority");
2952
+ }
2953
+ const authority = connection.authorities.find(
2954
+ (candidate) => candidate.id === request.authorityId,
2955
+ );
2956
+ if (!authority) throw new InteractionResourceStateError("Auth authority is not configured");
2957
+ const fields = protectedAuthFields(authority, request.fields);
2958
+ if (
2959
+ (authority.kind === "connection_fields" &&
2960
+ (!Number.isSafeInteger(input.credentialVersion) || input.credentialVersion! < 1)) ||
2961
+ (authority.kind !== "connection_fields" && input.credentialVersion !== null)
2962
+ ) {
2963
+ throw new InteractionResourceStateError("Protected-fill credential version is invalid");
2964
+ }
2965
+ const metadata: ProtectedAuthOperationMetadata = {
2966
+ schemaVersion: 1,
2967
+ authRunVersion: run.version,
2968
+ authority,
2969
+ origins: [...connection.origins],
2970
+ fields,
2971
+ credentialVersion: input.credentialVersion,
2972
+ };
2973
+ await scopedDb.insert(schema.interactionResourceOperations).values({
2974
+ operationId: request.operationId,
2975
+ accountId: input.accountId,
2976
+ workspaceId: input.workspaceId,
2977
+ resourceKind: "auth_run",
2978
+ resourceId: input.authRunId,
2979
+ kind: "protected_fill",
2980
+ requestDigest: digest,
2981
+ metadata,
2982
+ state: "prepared",
2983
+ actorSubjectId: input.actorSubjectId,
2984
+ });
2985
+ return {
2986
+ run: authRunFromRow(run),
2987
+ authority,
2988
+ origins: [...connection.origins],
2989
+ credentialVersion: input.credentialVersion,
2990
+ operationState: "prepared",
2991
+ response: null,
2992
+ replayed: false,
2993
+ };
2994
+ });
2995
+ }
2996
+
2997
+ export async function dispatchProtectedAuthFill(
2998
+ db: Database,
2999
+ input: InteractionMutationScope & { authRunId: string; operationId: string },
3000
+ ): Promise<ResourceOperationRow["state"]> {
3001
+ return await withRlsContext(db, input, async (scopedDb) => {
3002
+ await lockOperation(scopedDb, input.operationId);
3003
+ const [operation] = await scopedDb
3004
+ .select()
3005
+ .from(schema.interactionResourceOperations)
3006
+ .where(
3007
+ and(
3008
+ eq(schema.interactionResourceOperations.workspaceId, input.workspaceId),
3009
+ eq(schema.interactionResourceOperations.operationId, input.operationId),
3010
+ ),
3011
+ )
3012
+ .for("update")
3013
+ .limit(1);
3014
+ if (!operation)
3015
+ throw new InteractionResourceNotFoundError("Protected-fill operation not found");
3016
+ assertProtectedFillOperationBinding(operation, input);
3017
+ if (operation.state === "prepared") {
3018
+ const [dispatched] = await scopedDb
3019
+ .update(schema.interactionResourceOperations)
3020
+ .set({ state: "dispatched" })
3021
+ .where(
3022
+ and(
3023
+ eq(schema.interactionResourceOperations.operationId, input.operationId),
3024
+ eq(schema.interactionResourceOperations.state, "prepared"),
3025
+ ),
3026
+ )
3027
+ .returning({ state: schema.interactionResourceOperations.state });
3028
+ if (!dispatched) {
3029
+ throw new InteractionResourceConflictError("Protected-fill dispatch lost its fence");
3030
+ }
3031
+ return dispatched.state;
3032
+ }
3033
+ return operation.state;
3034
+ });
3035
+ }
3036
+
3037
+ export async function completeProtectedAuthFill(
3038
+ db: Database,
3039
+ input: InteractionMutationScope & {
3040
+ authRunId: string;
3041
+ operationId: string;
3042
+ status: ProtectedAuthFillResponseValue["status"];
3043
+ targetGeneration?: string;
3044
+ documentGeneration?: string | null;
3045
+ failureCode?: string;
3046
+ intervention?: {
3047
+ originatingSessionId: string;
3048
+ originatingTurnId?: string | null;
3049
+ originatingAttemptId?: string | null;
3050
+ originatingToolOperationId?: string | null;
3051
+ kind: "manual_login" | "mfa" | "external_action" | "confirmation" | "other";
3052
+ reason: string;
3053
+ expiresInSeconds: number;
3054
+ };
3055
+ },
3056
+ ): Promise<ProtectedAuthFillResponseValue> {
3057
+ try {
3058
+ return await withRlsContext(db, input, async (scopedDb) => {
3059
+ await lockOperation(scopedDb, input.operationId);
3060
+ const [operation] = await scopedDb
3061
+ .select()
3062
+ .from(schema.interactionResourceOperations)
3063
+ .where(
3064
+ and(
3065
+ eq(schema.interactionResourceOperations.workspaceId, input.workspaceId),
3066
+ eq(schema.interactionResourceOperations.operationId, input.operationId),
3067
+ ),
3068
+ )
3069
+ .for("update")
3070
+ .limit(1);
3071
+ if (!operation)
3072
+ throw new InteractionResourceNotFoundError("Protected-fill operation not found");
3073
+ assertProtectedFillOperationBinding(operation, input);
3074
+ if (operation.state === "completed" && operation.result) {
3075
+ return {
3076
+ ...ProtectedAuthFillResponse.parse(operation.result),
3077
+ replayed: true,
3078
+ };
3079
+ }
3080
+ if (operation.state === "failed" || operation.state === "outcome_unknown") {
3081
+ throw new InteractionResourceStateError(
3082
+ `Protected-fill operation is ${operation.state.replace("_", " ")}`,
3083
+ );
3084
+ }
3085
+ if (
3086
+ (input.status === "submitted" || input.status === "working") &&
3087
+ operation.state !== "dispatched"
3088
+ ) {
3089
+ throw new InteractionResourceStateError("Protected fill was not dispatched");
3090
+ }
3091
+ const metadata = protectedAuthOperationMetadata(operation.metadata);
3092
+ const [run] = await scopedDb
3093
+ .select()
3094
+ .from(schema.authRuns)
3095
+ .where(
3096
+ and(
3097
+ eq(schema.authRuns.workspaceId, input.workspaceId),
3098
+ eq(schema.authRuns.id, input.authRunId),
3099
+ ),
3100
+ )
3101
+ .for("update")
3102
+ .limit(1);
3103
+ if (!run) throw new InteractionResourceNotFoundError("Auth run not found");
3104
+ if (run.version !== metadata.authRunVersion || run.settledAt) {
3105
+ throw new InteractionResourceConflictError("Auth run changed after protected fill began");
3106
+ }
3107
+ if (input.targetGeneration && input.targetGeneration !== run.targetGeneration) {
3108
+ throw new InteractionResourceConflictError("Protected-fill result targets another tab");
3109
+ }
3110
+ const submitted = input.status === "submitted" || input.status === "working";
3111
+ const needsHuman = input.status === "needs_human";
3112
+ if (needsHuman !== Boolean(input.intervention)) {
3113
+ throw new InteractionResourceStateError(
3114
+ "Human protected fill requires one durable intervention",
3115
+ );
3116
+ }
3117
+ if (needsHuman && metadata.authority.kind !== "human") {
3118
+ throw new InteractionResourceStateError(
3119
+ "Only a human auth authority can request human intervention",
3120
+ );
3121
+ }
3122
+ let interventionId: string | null = null;
3123
+ if (needsHuman) {
3124
+ const intervention = input.intervention!;
3125
+ if (
3126
+ (intervention.originatingAttemptId && !intervention.originatingTurnId) ||
3127
+ (intervention.originatingToolOperationId && !intervention.originatingAttemptId)
3128
+ ) {
3129
+ throw new InteractionResourceStateError(
3130
+ "Protected-fill intervention provenance is incomplete",
3131
+ );
3132
+ }
3133
+ const request = CreateInteractionInterventionRequest.parse({
3134
+ operationId: input.operationId,
3135
+ resourceKind: "browser_session",
3136
+ resourceId: run.browserSessionId,
3137
+ targetId: run.targetId,
3138
+ expectedControllerGeneration: run.controllerGeneration,
3139
+ expectedTargetGeneration: run.targetGeneration,
3140
+ expectedDocumentGeneration: run.documentGeneration,
3141
+ kind: intervention.kind,
3142
+ reason: intervention.reason,
3143
+ authRunId: run.id,
3144
+ expiresInSeconds: intervention.expiresInSeconds,
3145
+ });
3146
+ interventionId = randomUUID();
3147
+ const [created] = await scopedDb
3148
+ .insert(schema.interactionInterventions)
3149
+ .values({
3150
+ id: interventionId,
3151
+ accountId: input.accountId,
3152
+ workspaceId: input.workspaceId,
3153
+ resourceKind: request.resourceKind,
3154
+ resourceId: request.resourceId,
3155
+ targetId: request.targetId,
3156
+ controllerGeneration: request.expectedControllerGeneration,
3157
+ targetGeneration: request.expectedTargetGeneration,
3158
+ documentGeneration: request.expectedDocumentGeneration,
3159
+ kind: request.kind,
3160
+ reason: request.reason,
3161
+ authRunId: run.id,
3162
+ originatingSessionId: intervention.originatingSessionId,
3163
+ originatingTurnId: intervention.originatingTurnId ?? null,
3164
+ originatingAttemptId: intervention.originatingAttemptId ?? null,
3165
+ originatingToolOperationId: intervention.originatingToolOperationId ?? null,
3166
+ operationId: input.operationId,
3167
+ expiresAt: sql`now() + (${request.expiresInSeconds} * interval '1 second')`,
3168
+ })
3169
+ .returning({ id: schema.interactionInterventions.id });
3170
+ if (!created) {
3171
+ throw new Error("Protected-fill intervention insert returned no row");
3172
+ }
3173
+ }
3174
+ const failureCode =
3175
+ submitted || needsHuman ? null : (input.failureCode ?? "protected_fill_failed");
3176
+ const [updated] = await scopedDb
3177
+ .update(schema.authRuns)
3178
+ .set({
3179
+ authorityId: metadata.authority.id,
3180
+ state: submitted ? "working" : needsHuman ? "awaiting_secret" : "failed",
3181
+ choices: [],
3182
+ pendingFields: needsHuman
3183
+ ? metadata.fields.map((field) => ({
3184
+ id: field.id,
3185
+ label: field.id,
3186
+ purpose: field.purpose,
3187
+ }))
3188
+ : [],
3189
+ externalAction: null,
3190
+ interventionId,
3191
+ verifiedUrl: null,
3192
+ failureCode,
3193
+ documentGeneration:
3194
+ submitted && input.documentGeneration !== undefined
3195
+ ? input.documentGeneration
3196
+ : run.documentGeneration,
3197
+ version: run.version + 1,
3198
+ updatedAt: sql`now()`,
3199
+ settledAt: failureCode ? sql`now()` : null,
3200
+ })
3201
+ .where(
3202
+ and(
3203
+ eq(schema.authRuns.workspaceId, input.workspaceId),
3204
+ eq(schema.authRuns.id, input.authRunId),
3205
+ eq(schema.authRuns.version, metadata.authRunVersion),
3206
+ sql`${schema.authRuns.settledAt} is null`,
3207
+ ),
3208
+ )
3209
+ .returning();
3210
+ if (!updated) {
3211
+ throw new InteractionResourceConflictError("Protected-fill settlement lost its auth fence");
3212
+ }
3213
+ await projectSettledAuthRunHealth(scopedDb, {
3214
+ run: updated,
3215
+ actorSubjectId: input.actorSubjectId,
3216
+ });
3217
+ const response = ProtectedAuthFillResponse.parse({
3218
+ run: authRunFromRow(updated),
3219
+ status: input.status,
3220
+ operationId: input.operationId,
3221
+ replayed: false,
3222
+ });
3223
+ const [completed] = await scopedDb
3224
+ .update(schema.interactionResourceOperations)
3225
+ .set({
3226
+ state: "completed",
3227
+ resultVersion: updated.version,
3228
+ result: response,
3229
+ settledAt: sql`now()`,
3230
+ })
3231
+ .where(
3232
+ and(
3233
+ eq(schema.interactionResourceOperations.operationId, input.operationId),
3234
+ inArray(schema.interactionResourceOperations.state, ["prepared", "dispatched"]),
3235
+ ),
3236
+ )
3237
+ .returning({
3238
+ operationId: schema.interactionResourceOperations.operationId,
3239
+ });
3240
+ if (!completed) {
3241
+ throw new InteractionResourceConflictError(
3242
+ "Protected-fill settlement lost its operation fence",
3243
+ );
3244
+ }
3245
+ await advanceWorkspaceInteractionRevision(scopedDb, input.accountId, input.workspaceId);
3246
+ return response;
3247
+ });
3248
+ } catch (error) {
3249
+ if (
3250
+ postgresConstraint(error) === "interaction_interventions_workspace_operation_uq" ||
3251
+ postgresConstraint(error) === "interaction_interventions_open_target_kind_uq" ||
3252
+ postgresConstraint(error) === "interaction_interventions_open_auth_run_uq"
3253
+ ) {
3254
+ throw new InteractionResourceConflictError(
3255
+ "A matching interaction intervention is already open",
3256
+ );
3257
+ }
3258
+ throw error;
3259
+ }
3260
+ }
3261
+
3262
+ export async function markProtectedAuthFillOutcomeUnknown(
3263
+ db: Database,
3264
+ input: InteractionMutationScope & {
3265
+ authRunId: string;
3266
+ operationId: string;
3267
+ errorCode: string;
3268
+ },
3269
+ ): Promise<void> {
3270
+ await withRlsContext(db, input, async (scopedDb) => {
3271
+ await lockOperation(scopedDb, input.operationId);
3272
+ const [operation] = await scopedDb
3273
+ .select()
3274
+ .from(schema.interactionResourceOperations)
3275
+ .where(
3276
+ and(
3277
+ eq(schema.interactionResourceOperations.workspaceId, input.workspaceId),
3278
+ eq(schema.interactionResourceOperations.operationId, input.operationId),
3279
+ ),
3280
+ )
3281
+ .for("update")
3282
+ .limit(1);
3283
+ if (!operation)
3284
+ throw new InteractionResourceNotFoundError("Protected-fill operation not found");
3285
+ assertProtectedFillOperationBinding(operation, input);
3286
+ if (
3287
+ operation.state === "completed" ||
3288
+ operation.state === "failed" ||
3289
+ operation.state === "outcome_unknown"
3290
+ ) {
3291
+ return;
3292
+ }
3293
+ const [marked] = await scopedDb
3294
+ .update(schema.interactionResourceOperations)
3295
+ .set({
3296
+ state: "outcome_unknown",
3297
+ errorCode: input.errorCode,
3298
+ settledAt: sql`now()`,
3299
+ })
3300
+ .where(
3301
+ and(
3302
+ eq(schema.interactionResourceOperations.operationId, input.operationId),
3303
+ inArray(schema.interactionResourceOperations.state, ["prepared", "dispatched"]),
3304
+ ),
3305
+ )
3306
+ .returning({
3307
+ operationId: schema.interactionResourceOperations.operationId,
3308
+ });
3309
+ if (!marked) {
3310
+ throw new InteractionResourceConflictError(
3311
+ "Protected-fill unknown outcome lost its operation fence",
3312
+ );
3313
+ }
3314
+ const metadata = protectedAuthOperationMetadata(operation.metadata);
3315
+ const [failedRun] = await scopedDb
3316
+ .update(schema.authRuns)
3317
+ .set({
3318
+ state: "failed",
3319
+ choices: [],
3320
+ pendingFields: [],
3321
+ externalAction: null,
3322
+ verifiedUrl: null,
3323
+ failureCode: input.errorCode,
3324
+ version: sql`${schema.authRuns.version} + 1`,
3325
+ updatedAt: sql`now()`,
3326
+ settledAt: sql`now()`,
3327
+ })
3328
+ .where(
3329
+ and(
3330
+ eq(schema.authRuns.workspaceId, input.workspaceId),
3331
+ eq(schema.authRuns.id, input.authRunId),
3332
+ eq(schema.authRuns.version, metadata.authRunVersion),
3333
+ sql`${schema.authRuns.settledAt} is null`,
3334
+ ),
3335
+ )
3336
+ .returning();
3337
+ if (failedRun) {
3338
+ await projectSettledAuthRunHealth(scopedDb, {
3339
+ run: failedRun,
3340
+ actorSubjectId: input.actorSubjectId,
3341
+ });
3342
+ }
3343
+ await advanceWorkspaceInteractionRevision(scopedDb, input.accountId, input.workspaceId);
3344
+ });
3345
+ }
3346
+
3347
+ function assertProtectedFillOperationBinding(
3348
+ operation: ResourceOperationRow,
3349
+ input: { authRunId: string; actorSubjectId: string },
3350
+ ): void {
3351
+ if (
3352
+ operation.resourceKind !== "auth_run" ||
3353
+ operation.resourceId !== input.authRunId ||
3354
+ operation.kind !== "protected_fill" ||
3355
+ operation.actorSubjectId !== input.actorSubjectId
3356
+ ) {
3357
+ throw new InteractionResourceConflictError(
3358
+ "Operation id is bound to another protected-fill request",
3359
+ );
3360
+ }
3361
+ }
3362
+
3363
+ export async function verifyAuthRun(
3364
+ db: Database,
3365
+ input: InteractionMutationScope & {
3366
+ authRunId: string;
3367
+ controllerGeneration: string;
3368
+ targetId: string;
3369
+ targetGeneration: string;
3370
+ documentGeneration: string | null;
3371
+ url: string;
3372
+ } & VerifyAuthRunRequestValue,
3373
+ ): Promise<AuthRunMutationResponseValue> {
3374
+ const request = VerifyAuthRunRequest.parse({
3375
+ operationId: input.operationId,
3376
+ expectedVersion: input.expectedVersion,
3377
+ });
3378
+ const digest = operationDigest({
3379
+ version: 1,
3380
+ authRunId: input.authRunId,
3381
+ controllerGeneration: input.controllerGeneration,
3382
+ targetId: input.targetId,
3383
+ targetGeneration: input.targetGeneration,
3384
+ documentGeneration: input.documentGeneration,
3385
+ url: input.url,
3386
+ expectedVersion: request.expectedVersion,
3387
+ actorSubjectId: input.actorSubjectId,
3388
+ });
3389
+ return await withRlsContext(db, input, async (scopedDb) => {
3390
+ await assertWorkspaceAccount(scopedDb, input.accountId, input.workspaceId);
3391
+ await lockOperation(scopedDb, request.operationId);
3392
+ const existing = await loadOperation(scopedDb, input.workspaceId, request.operationId);
3393
+ if (existing) {
3394
+ assertOperation(existing, {
3395
+ resourceKind: "auth_run",
3396
+ resourceId: input.authRunId,
3397
+ kind: "verify",
3398
+ requestDigest: digest,
3399
+ actorSubjectId: input.actorSubjectId,
3400
+ });
3401
+ return {
3402
+ ...AuthRunMutationResponse.parse(existing.result),
3403
+ replayed: true,
3404
+ };
3405
+ }
3406
+ const [run] = await scopedDb
3407
+ .select()
3408
+ .from(schema.authRuns)
3409
+ .where(
3410
+ and(
3411
+ eq(schema.authRuns.workspaceId, input.workspaceId),
3412
+ eq(schema.authRuns.id, input.authRunId),
3413
+ ),
3414
+ )
3415
+ .for("update")
3416
+ .limit(1);
3417
+ if (!run) throw new InteractionResourceNotFoundError("Auth run not found");
3418
+ if (run.version !== request.expectedVersion) {
3419
+ throw new InteractionResourceConflictError("Auth run changed before verification");
3420
+ }
3421
+ if (
3422
+ run.controllerGeneration !== input.controllerGeneration ||
3423
+ run.targetId !== input.targetId ||
3424
+ run.targetGeneration !== input.targetGeneration
3425
+ ) {
3426
+ throw new InteractionResourceConflictError("Verification targets another browser tab");
3427
+ }
3428
+ const connection = await getSiteAuthConnectionRow(
3429
+ scopedDb,
3430
+ input.workspaceId,
3431
+ run.siteAuthConnectionId,
3432
+ );
3433
+ const verified = connection.verificationUrlPrefixes.some((prefix) =>
3434
+ input.url.startsWith(prefix),
3435
+ );
3436
+ let resultRun = run;
3437
+ if (verified) {
3438
+ if (run.settledAt && run.state !== "verified") {
3439
+ throw new InteractionResourceStateError("Auth run is already settled");
3440
+ }
3441
+ if (!run.settledAt) {
3442
+ const [updated] = await scopedDb
3443
+ .update(schema.authRuns)
3444
+ .set({
3445
+ state: "verified",
3446
+ choices: [],
3447
+ pendingFields: [],
3448
+ externalAction: null,
3449
+ interventionId: null,
3450
+ documentGeneration: input.documentGeneration,
3451
+ verifiedUrl: input.url,
3452
+ failureCode: null,
3453
+ version: run.version + 1,
3454
+ updatedAt: sql`now()`,
3455
+ settledAt: sql`now()`,
3456
+ })
3457
+ .where(
3458
+ and(
3459
+ eq(schema.authRuns.workspaceId, input.workspaceId),
3460
+ eq(schema.authRuns.id, input.authRunId),
3461
+ eq(schema.authRuns.version, request.expectedVersion),
3462
+ ),
3463
+ )
3464
+ .returning();
3465
+ if (!updated)
3466
+ throw new InteractionResourceConflictError("Auth verification lost its fence");
3467
+ resultRun = updated;
3468
+ await projectSettledAuthRunHealth(scopedDb, {
3469
+ run: updated,
3470
+ actorSubjectId: input.actorSubjectId,
3471
+ });
3472
+ }
3473
+ }
1303
3474
  const response = AuthRunMutationResponse.parse({
1304
- run: authRunFromRow(row),
3475
+ run: authRunFromRow(resultRun),
1305
3476
  operationId: request.operationId,
1306
3477
  replayed: false,
1307
3478
  });
@@ -1311,13 +3482,15 @@ export async function reportAuthRun(
1311
3482
  workspaceId: input.workspaceId,
1312
3483
  resourceKind: "auth_run",
1313
3484
  resourceId: input.authRunId,
1314
- kind: "report",
3485
+ kind: "verify",
1315
3486
  requestDigest: digest,
1316
- resultVersion: row.version,
3487
+ resultVersion: resultRun.version,
1317
3488
  result: response,
1318
3489
  actorSubjectId: input.actorSubjectId,
1319
3490
  });
1320
- await advanceWorkspaceInteractionRevision(scopedDb, input.accountId, input.workspaceId);
3491
+ if (verified && !run.settledAt) {
3492
+ await advanceWorkspaceInteractionRevision(scopedDb, input.accountId, input.workspaceId);
3493
+ }
1321
3494
  return response;
1322
3495
  });
1323
3496
  }
@@ -1493,12 +3666,16 @@ async function settleLinkedAuthRun(
1493
3666
  eq(schema.authRuns.interventionId, input.interventionId),
1494
3667
  ),
1495
3668
  )
1496
- .returning({ id: schema.authRuns.id });
3669
+ .returning();
1497
3670
  if (!settled) {
1498
3671
  throw new InteractionResourceConflictError(
1499
3672
  "Linked auth run changed before intervention settlement",
1500
3673
  );
1501
3674
  }
3675
+ await projectSettledAuthRunHealth(db, {
3676
+ run: settled,
3677
+ actorSubjectId: run.createdBySubjectId,
3678
+ });
1502
3679
  }
1503
3680
 
1504
3681
  async function expireInterventionsInScope(
@@ -1517,6 +3694,10 @@ async function expireInterventionsInScope(
1517
3694
  and(
1518
3695
  eq(schema.interactionInterventions.workspaceId, input.workspaceId),
1519
3696
  eq(schema.interactionInterventions.status, "open"),
3697
+ // Model-owned interventions are also pending approvals. Only the
3698
+ // owning session workflow may expire those rows, because it must append
3699
+ // the exact rejection that resumes the frozen RunState atomically.
3700
+ sql`${schema.interactionInterventions.originatingToolCallId} is null`,
1520
3701
  sql`${schema.interactionInterventions.expiresAt} <= now()`,
1521
3702
  ),
1522
3703
  )
@@ -1586,6 +3767,242 @@ export async function getInteractionIntervention(
1586
3767
  });
1587
3768
  }
1588
3769
 
3770
+ export type AttemptInteractionInterventionRequest = {
3771
+ id: string;
3772
+ operationId: string;
3773
+ accountId: string;
3774
+ workspaceId: string;
3775
+ originatingSessionId: string;
3776
+ originatingTurnId: string;
3777
+ originatingAttemptId: string;
3778
+ toolCallId: string;
3779
+ input: RequestHumanInteractionToolInputValue;
3780
+ };
3781
+
3782
+ /**
3783
+ * Persist one interaction interruption inside the same transaction as its
3784
+ * frozen Agent RunState. This is deliberately transaction-local: callers must
3785
+ * already hold the session/turn/attempt settlement locks and RLS context.
3786
+ */
3787
+ export async function persistAttemptInteractionInterventionInTransaction(
3788
+ db: Database,
3789
+ raw: AttemptInteractionInterventionRequest,
3790
+ ): Promise<InteractionInterventionValue> {
3791
+ const input = RequestHumanInteractionToolInput.parse(raw.input);
3792
+ if (Buffer.byteLength(raw.toolCallId) < 1 || Buffer.byteLength(raw.toolCallId) > 1_024) {
3793
+ throw new InteractionResourceStateError("Interaction tool call id is invalid");
3794
+ }
3795
+ if (input.operation === "wait") {
3796
+ const [current] = await db
3797
+ .select()
3798
+ .from(schema.interactionInterventions)
3799
+ .where(
3800
+ and(
3801
+ eq(schema.interactionInterventions.workspaceId, raw.workspaceId),
3802
+ eq(schema.interactionInterventions.id, input.interventionId),
3803
+ ),
3804
+ )
3805
+ .for("update")
3806
+ .limit(1);
3807
+ if (!current) throw new InteractionResourceNotFoundError("Interaction intervention not found");
3808
+ if (current.status !== "open" || current.expiresAt.getTime() <= Date.now()) {
3809
+ throw new InteractionResourceStateError("Interaction intervention is no longer open");
3810
+ }
3811
+ if (
3812
+ current.originatingSessionId !== raw.originatingSessionId ||
3813
+ current.originatingTurnId !== raw.originatingTurnId
3814
+ ) {
3815
+ throw new InteractionResourceConflictError(
3816
+ "Interaction intervention belongs to another agent operation",
3817
+ );
3818
+ }
3819
+ if (
3820
+ current.originatingToolCallId !== null &&
3821
+ current.originatingToolCallId !== raw.toolCallId
3822
+ ) {
3823
+ throw new InteractionResourceConflictError(
3824
+ "Interaction intervention already has another waiting tool call",
3825
+ );
3826
+ }
3827
+ const row =
3828
+ current.originatingToolCallId === raw.toolCallId
3829
+ ? current
3830
+ : (
3831
+ await db
3832
+ .update(schema.interactionInterventions)
3833
+ .set({
3834
+ originatingToolCallId: raw.toolCallId,
3835
+ updatedAt: sql`now()`,
3836
+ })
3837
+ .where(
3838
+ and(
3839
+ eq(schema.interactionInterventions.workspaceId, raw.workspaceId),
3840
+ eq(schema.interactionInterventions.id, current.id),
3841
+ eq(schema.interactionInterventions.version, current.version),
3842
+ eq(schema.interactionInterventions.status, "open"),
3843
+ sql`${schema.interactionInterventions.originatingToolCallId} is null`,
3844
+ ),
3845
+ )
3846
+ .returning()
3847
+ )[0];
3848
+ if (!row) throw new InteractionResourceConflictError("Intervention wait lost its fence");
3849
+ return interventionFromRow(row);
3850
+ }
3851
+
3852
+ await assertActiveInteractionResource(db, {
3853
+ workspaceId: raw.workspaceId,
3854
+ resourceKind: input.resourceKind,
3855
+ resourceId: input.resourceId,
3856
+ expectedControllerGeneration: input.expectedControllerGeneration,
3857
+ });
3858
+ let authRun: AuthRunRow | null = null;
3859
+ if (input.authRunId) {
3860
+ if (input.resourceKind !== "browser_session") {
3861
+ throw new InteractionResourceStateError("Only browser interventions can belong to auth runs");
3862
+ }
3863
+ authRun = await loadAuthRunRow(db, raw.workspaceId, input.authRunId);
3864
+ if (!authRun) throw new InteractionResourceNotFoundError("Auth run not found");
3865
+ if (
3866
+ authRun.browserSessionId !== input.resourceId ||
3867
+ authRun.targetId !== input.targetId ||
3868
+ authRun.controllerGeneration !== input.expectedControllerGeneration ||
3869
+ authRun.targetGeneration !== input.expectedTargetGeneration ||
3870
+ authRun.documentGeneration !== input.expectedDocumentGeneration ||
3871
+ authRun.settledAt
3872
+ ) {
3873
+ throw new InteractionResourceConflictError(
3874
+ "Interaction intervention does not match the active auth run",
3875
+ );
3876
+ }
3877
+ }
3878
+ const [existing] = await db
3879
+ .select()
3880
+ .from(schema.interactionInterventions)
3881
+ .where(
3882
+ and(
3883
+ eq(schema.interactionInterventions.workspaceId, raw.workspaceId),
3884
+ eq(schema.interactionInterventions.id, raw.id),
3885
+ ),
3886
+ )
3887
+ .for("update")
3888
+ .limit(1);
3889
+ if (existing) {
3890
+ if (
3891
+ existing.operationId !== raw.operationId ||
3892
+ existing.originatingSessionId !== raw.originatingSessionId ||
3893
+ existing.originatingTurnId !== raw.originatingTurnId ||
3894
+ existing.originatingToolCallId !== raw.toolCallId ||
3895
+ existing.resourceKind !== input.resourceKind ||
3896
+ existing.resourceId !== input.resourceId ||
3897
+ existing.targetId !== input.targetId ||
3898
+ existing.controllerGeneration !== input.expectedControllerGeneration ||
3899
+ existing.targetGeneration !== input.expectedTargetGeneration ||
3900
+ existing.documentGeneration !== input.expectedDocumentGeneration ||
3901
+ existing.kind !== input.kind ||
3902
+ existing.reason !== input.reason ||
3903
+ existing.authRunId !== (input.authRunId ?? null) ||
3904
+ existing.status !== "open"
3905
+ ) {
3906
+ throw new InteractionResourceConflictError("Interaction interruption replay changed");
3907
+ }
3908
+ return interventionFromRow(existing);
3909
+ }
3910
+ const [row] = await db
3911
+ .insert(schema.interactionInterventions)
3912
+ .values({
3913
+ id: raw.id,
3914
+ accountId: raw.accountId,
3915
+ workspaceId: raw.workspaceId,
3916
+ resourceKind: input.resourceKind,
3917
+ resourceId: input.resourceId,
3918
+ targetId: input.targetId,
3919
+ controllerGeneration: input.expectedControllerGeneration,
3920
+ targetGeneration: input.expectedTargetGeneration,
3921
+ documentGeneration: input.expectedDocumentGeneration,
3922
+ kind: input.kind,
3923
+ reason: input.reason,
3924
+ authRunId: input.authRunId ?? null,
3925
+ originatingSessionId: raw.originatingSessionId,
3926
+ originatingTurnId: raw.originatingTurnId,
3927
+ originatingAttemptId: raw.originatingAttemptId,
3928
+ originatingToolOperationId: raw.operationId,
3929
+ originatingToolCallId: raw.toolCallId,
3930
+ operationId: raw.operationId,
3931
+ expiresAt: sql`now() + (${input.expiresInSeconds} * interval '1 second')`,
3932
+ })
3933
+ .returning();
3934
+ if (!row) throw new Error("Interaction interruption insert returned no row");
3935
+ if (authRun) {
3936
+ const linked = await db
3937
+ .update(schema.authRuns)
3938
+ .set({
3939
+ interventionId: row.id,
3940
+ version: authRun.version + 1,
3941
+ updatedAt: sql`now()`,
3942
+ })
3943
+ .where(
3944
+ and(
3945
+ eq(schema.authRuns.workspaceId, raw.workspaceId),
3946
+ eq(schema.authRuns.id, authRun.id),
3947
+ eq(schema.authRuns.version, authRun.version),
3948
+ sql`${schema.authRuns.settledAt} is null`,
3949
+ ),
3950
+ )
3951
+ .returning({ id: schema.authRuns.id });
3952
+ if (linked.length !== 1) {
3953
+ throw new InteractionResourceConflictError("Auth run changed before intervention link");
3954
+ }
3955
+ }
3956
+ await advanceWorkspaceInteractionRevision(db, raw.accountId, raw.workspaceId);
3957
+ return interventionFromRow(row);
3958
+ }
3959
+
3960
+ export async function getInteractionInterventionApprovalTarget(
3961
+ db: Database,
3962
+ input: { accountId: string; workspaceId: string; interventionId: string },
3963
+ ): Promise<{ sessionId: string; toolCallId: string } | null> {
3964
+ return await withRlsContext(db, input, async (scopedDb) => {
3965
+ const row = await loadInterventionRow(scopedDb, input.workspaceId, input.interventionId);
3966
+ return row?.originatingToolCallId
3967
+ ? {
3968
+ sessionId: row.originatingSessionId,
3969
+ toolCallId: row.originatingToolCallId,
3970
+ }
3971
+ : null;
3972
+ });
3973
+ }
3974
+
3975
+ export async function getInteractionInterventionResumeForEvent(
3976
+ db: Database,
3977
+ workspaceId: string,
3978
+ sessionId: string,
3979
+ event: Pick<SessionEvent, "type" | "payload">,
3980
+ ): Promise<{
3981
+ toolCallId: string;
3982
+ intervention: InteractionInterventionValue;
3983
+ } | null> {
3984
+ if (event.type !== "user.approvalDecision") return null;
3985
+ const payload = event.payload as { approvalId?: unknown; decision?: unknown };
3986
+ if (typeof payload.approvalId !== "string" || payload.decision !== "approve") return null;
3987
+ const [row] = await db
3988
+ .select()
3989
+ .from(schema.interactionInterventions)
3990
+ .where(
3991
+ and(
3992
+ eq(schema.interactionInterventions.workspaceId, workspaceId),
3993
+ eq(schema.interactionInterventions.originatingSessionId, sessionId),
3994
+ eq(schema.interactionInterventions.originatingToolCallId, payload.approvalId),
3995
+ ),
3996
+ )
3997
+ .orderBy(asc(schema.interactionInterventions.createdAt))
3998
+ .limit(1);
3999
+ if (!row || row.status !== "completed") return null;
4000
+ return {
4001
+ toolCallId: payload.approvalId,
4002
+ intervention: interventionFromRow(row),
4003
+ };
4004
+ }
4005
+
1589
4006
  export async function createInteractionIntervention(
1590
4007
  db: Database,
1591
4008
  input: InteractionMutationScope &
@@ -1744,7 +4161,7 @@ export async function createInteractionIntervention(
1744
4161
  }
1745
4162
  }
1746
4163
 
1747
- export async function resolveInteractionIntervention(
4164
+ export async function resolveInteractionInterventionInTransaction(
1748
4165
  db: Database,
1749
4166
  input: InteractionMutationScope & {
1750
4167
  interventionId: string;
@@ -1762,92 +4179,152 @@ export async function resolveInteractionIntervention(
1762
4179
  request: digestRequest,
1763
4180
  actorSubjectId: input.actorSubjectId,
1764
4181
  });
1765
- return await withRlsContext(db, input, async (scopedDb) => {
1766
- await assertWorkspaceAccount(scopedDb, input.accountId, input.workspaceId);
1767
- await lockOperation(scopedDb, request.operationId);
1768
- const existing = await loadOperation(scopedDb, input.workspaceId, request.operationId);
1769
- if (existing) {
1770
- assertOperation(existing, {
1771
- resourceKind: "intervention",
1772
- resourceId: input.interventionId,
1773
- kind: "resolve",
1774
- requestDigest: digest,
1775
- actorSubjectId: input.actorSubjectId,
1776
- });
1777
- const response = InteractionInterventionMutationResponse.parse(existing.result);
1778
- return { ...response, replayed: true };
1779
- }
1780
- const [current] = await scopedDb
1781
- .select()
1782
- .from(schema.interactionInterventions)
1783
- .where(
1784
- and(
1785
- eq(schema.interactionInterventions.workspaceId, input.workspaceId),
1786
- eq(schema.interactionInterventions.id, input.interventionId),
1787
- ),
1788
- )
1789
- .for("update")
1790
- .limit(1);
1791
- if (!current) {
1792
- throw new InteractionResourceNotFoundError("Interaction intervention not found");
1793
- }
1794
- if (current.version !== request.expectedVersion) {
1795
- throw new InteractionResourceConflictError(
1796
- "Interaction intervention changed before this response",
1797
- );
1798
- }
1799
- if (current.status !== "open") {
1800
- throw new InteractionResourceStateError("Interaction intervention is already settled");
1801
- }
1802
- const expired = current.expiresAt.getTime() <= Date.now();
1803
- const status = expired ? "expired" : request.outcome;
1804
- const [row] = await scopedDb
1805
- .update(schema.interactionInterventions)
1806
- .set({
1807
- status,
1808
- responseActorSubjectId: expired ? null : input.actorSubjectId,
1809
- version: current.version + 1,
1810
- updatedAt: sql`now()`,
1811
- settledAt: sql`now()`,
1812
- })
1813
- .where(
1814
- and(
1815
- eq(schema.interactionInterventions.workspaceId, input.workspaceId),
1816
- eq(schema.interactionInterventions.id, input.interventionId),
1817
- eq(schema.interactionInterventions.version, request.expectedVersion),
1818
- eq(schema.interactionInterventions.status, "open"),
1819
- ),
1820
- )
1821
- .returning();
1822
- if (!row) {
1823
- throw new InteractionResourceConflictError("Intervention response lost its fence");
1824
- }
1825
- await settleLinkedAuthRun(scopedDb, {
1826
- workspaceId: input.workspaceId,
1827
- authRunId: row.authRunId,
1828
- interventionId: row.id,
1829
- outcome: status,
1830
- });
1831
- const response = InteractionInterventionMutationResponse.parse({
1832
- intervention: interventionFromRow(row),
1833
- operationId: request.operationId,
1834
- replayed: false,
1835
- });
1836
- await insertCompletedOperation(scopedDb, {
1837
- operationId: request.operationId,
1838
- accountId: input.accountId,
1839
- workspaceId: input.workspaceId,
4182
+ const scopedDb = db;
4183
+ await assertWorkspaceAccount(scopedDb, input.accountId, input.workspaceId);
4184
+ await lockOperation(scopedDb, request.operationId);
4185
+ const existing = await loadOperation(scopedDb, input.workspaceId, request.operationId);
4186
+ if (existing) {
4187
+ assertOperation(existing, {
1840
4188
  resourceKind: "intervention",
1841
4189
  resourceId: input.interventionId,
1842
4190
  kind: "resolve",
1843
4191
  requestDigest: digest,
1844
- resultVersion: row.version,
1845
- result: response,
1846
4192
  actorSubjectId: input.actorSubjectId,
1847
4193
  });
1848
- await advanceWorkspaceInteractionRevision(scopedDb, input.accountId, input.workspaceId);
1849
- return response;
4194
+ const response = InteractionInterventionMutationResponse.parse(existing.result);
4195
+ return { ...response, replayed: true };
4196
+ }
4197
+ const [current] = await scopedDb
4198
+ .select()
4199
+ .from(schema.interactionInterventions)
4200
+ .where(
4201
+ and(
4202
+ eq(schema.interactionInterventions.workspaceId, input.workspaceId),
4203
+ eq(schema.interactionInterventions.id, input.interventionId),
4204
+ ),
4205
+ )
4206
+ .for("update")
4207
+ .limit(1);
4208
+ if (!current) {
4209
+ throw new InteractionResourceNotFoundError("Interaction intervention not found");
4210
+ }
4211
+ if (current.version !== request.expectedVersion) {
4212
+ throw new InteractionResourceConflictError(
4213
+ "Interaction intervention changed before this response",
4214
+ );
4215
+ }
4216
+ if (current.status !== "open") {
4217
+ throw new InteractionResourceStateError("Interaction intervention is already settled");
4218
+ }
4219
+ const expired = current.expiresAt.getTime() <= Date.now();
4220
+ const status = expired ? "expired" : request.outcome;
4221
+ const [row] = await scopedDb
4222
+ .update(schema.interactionInterventions)
4223
+ .set({
4224
+ status,
4225
+ responseActorSubjectId: expired ? null : input.actorSubjectId,
4226
+ version: current.version + 1,
4227
+ updatedAt: sql`now()`,
4228
+ settledAt: sql`now()`,
4229
+ })
4230
+ .where(
4231
+ and(
4232
+ eq(schema.interactionInterventions.workspaceId, input.workspaceId),
4233
+ eq(schema.interactionInterventions.id, input.interventionId),
4234
+ eq(schema.interactionInterventions.version, request.expectedVersion),
4235
+ eq(schema.interactionInterventions.status, "open"),
4236
+ ),
4237
+ )
4238
+ .returning();
4239
+ if (!row) {
4240
+ throw new InteractionResourceConflictError("Intervention response lost its fence");
4241
+ }
4242
+ await settleLinkedAuthRun(scopedDb, {
4243
+ workspaceId: input.workspaceId,
4244
+ authRunId: row.authRunId,
4245
+ interventionId: row.id,
4246
+ outcome: status,
4247
+ });
4248
+ const response = InteractionInterventionMutationResponse.parse({
4249
+ intervention: interventionFromRow(row),
4250
+ operationId: request.operationId,
4251
+ replayed: false,
4252
+ });
4253
+ await insertCompletedOperation(scopedDb, {
4254
+ operationId: request.operationId,
4255
+ accountId: input.accountId,
4256
+ workspaceId: input.workspaceId,
4257
+ resourceKind: "intervention",
4258
+ resourceId: input.interventionId,
4259
+ kind: "resolve",
4260
+ requestDigest: digest,
4261
+ resultVersion: row.version,
4262
+ result: response,
4263
+ actorSubjectId: input.actorSubjectId,
1850
4264
  });
4265
+ await advanceWorkspaceInteractionRevision(scopedDb, input.accountId, input.workspaceId);
4266
+ return response;
4267
+ }
4268
+
4269
+ export async function resolveInteractionIntervention(
4270
+ db: Database,
4271
+ input: InteractionMutationScope & {
4272
+ interventionId: string;
4273
+ } & ResolveInteractionInterventionRequestValue,
4274
+ ): Promise<InteractionInterventionMutationResponseValue> {
4275
+ return await withRlsContext(
4276
+ db,
4277
+ input,
4278
+ async (scopedDb) => await resolveInteractionInterventionInTransaction(scopedDb, input),
4279
+ );
4280
+ }
4281
+
4282
+ /**
4283
+ * Cancel every still-open intervention created by a logical agent turn when
4284
+ * that turn becomes terminal. The caller already owns the turn-settlement
4285
+ * transaction, so no resource can outlive the terminal session truth.
4286
+ */
4287
+ export async function cancelTurnInteractionInterventionsInTransaction(
4288
+ db: Database,
4289
+ input: {
4290
+ accountId: string;
4291
+ workspaceId: string;
4292
+ sessionId: string;
4293
+ turnId: string;
4294
+ },
4295
+ ): Promise<number> {
4296
+ const cancelled = await db
4297
+ .update(schema.interactionInterventions)
4298
+ .set({
4299
+ status: "cancelled",
4300
+ version: sql`${schema.interactionInterventions.version} + 1`,
4301
+ updatedAt: sql`now()`,
4302
+ settledAt: sql`now()`,
4303
+ })
4304
+ .where(
4305
+ and(
4306
+ eq(schema.interactionInterventions.workspaceId, input.workspaceId),
4307
+ eq(schema.interactionInterventions.originatingSessionId, input.sessionId),
4308
+ eq(schema.interactionInterventions.originatingTurnId, input.turnId),
4309
+ eq(schema.interactionInterventions.status, "open"),
4310
+ ),
4311
+ )
4312
+ .returning({
4313
+ id: schema.interactionInterventions.id,
4314
+ authRunId: schema.interactionInterventions.authRunId,
4315
+ });
4316
+ for (const intervention of cancelled) {
4317
+ await settleLinkedAuthRun(db, {
4318
+ workspaceId: input.workspaceId,
4319
+ authRunId: intervention.authRunId,
4320
+ interventionId: intervention.id,
4321
+ outcome: "cancelled",
4322
+ });
4323
+ }
4324
+ if (cancelled.length > 0) {
4325
+ await advanceWorkspaceInteractionRevision(db, input.accountId, input.workspaceId);
4326
+ }
4327
+ return cancelled.length;
1851
4328
  }
1852
4329
 
1853
4330
  export async function cancelOpenInteractionInterventions(