@opengeni/db 0.31.1 → 0.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/browser-auth.d.ts +209 -1
- package/dist/browser-downloads.d.ts +51 -0
- package/dist/browser-identities.d.ts +11 -2
- package/dist/browser-sessions.d.ts +83 -1
- package/dist/browser-state-artifacts.d.ts +75 -1
- package/dist/canonical-human-identities.d.ts +48 -0
- package/dist/canonical-human-identities.js +28 -0
- package/dist/canonical-human-identities.js.map +1 -0
- package/dist/capability-integrations.d.ts +8 -6
- package/dist/{chunk-MWDVXQOL.js → chunk-5KTIEDX7.js} +5773 -4212
- package/dist/chunk-5KTIEDX7.js.map +1 -0
- package/dist/{chunk-NBE6CHRI.js → chunk-CQZ4QSP5.js} +49 -9
- package/dist/chunk-CQZ4QSP5.js.map +1 -0
- package/dist/chunk-F32YT7MP.js +117 -0
- package/dist/chunk-F32YT7MP.js.map +1 -0
- package/dist/{chunk-ISI2TIUG.js → chunk-JC5QPLUM.js} +30 -3
- package/dist/chunk-JC5QPLUM.js.map +1 -0
- package/dist/{chunk-F5FT7BEZ.js → chunk-P3RE4D2B.js} +2 -2
- package/dist/chunk-TAUTWOS5.js +259 -0
- package/dist/chunk-TAUTWOS5.js.map +1 -0
- package/dist/{chunk-YHZXQ7HP.js → chunk-VKLB2ZGI.js} +446 -19
- package/dist/chunk-VKLB2ZGI.js.map +1 -0
- package/dist/database.d.ts +5 -0
- package/dist/editable-artifact-durable-export.js +2 -2
- package/dist/editable-artifacts.js +3 -3
- package/dist/index.d.ts +438 -29
- package/dist/index.js +35640 -28969
- package/dist/index.js.map +1 -1
- package/dist/insights.d.ts +23 -7
- package/dist/integration-bindings.d.ts +24 -24
- package/dist/integration-facets.d.ts +99 -0
- package/dist/interaction-revisions.d.ts +11 -0
- package/dist/interaction-schema.d.ts +698 -34
- package/dist/provision-roles.js +1 -1
- package/dist/runtime-posture.d.ts +8 -7
- package/dist/schema.d.ts +9464 -5412
- package/dist/schema.js +51 -9
- package/dist/session-control.d.ts +29 -3
- package/dist/session-tenancy.d.ts +44 -0
- package/dist/session-tenancy.js +16 -0
- package/dist/session-tenancy.js.map +1 -0
- package/dist/slack-task-policy-schema.d.ts +847 -0
- package/dist/slack-task-policy.d.ts +65 -0
- package/dist/task-notes-schema.d.ts +924 -0
- package/dist/task-notes.d.ts +85 -0
- package/dist/video-generation.d.ts +9 -0
- package/dist/video-generation.js +5 -3
- package/dist/xai-subscription.d.ts +326 -0
- package/drizzle/0212_browser_state_transfer_hardening.sql +560 -0
- package/drizzle/0213_browser_interaction_authority.sql +67 -0
- package/drizzle/0214_browser_download_saves.sql +34 -0
- package/drizzle/0215_browser_controller_host.sql +385 -0
- package/drizzle/0216_browser_auth_health_evidence.sql +137 -0
- package/drizzle/0217_external_browser_auth_operations.sql +22 -0
- package/drizzle/0218_organization_tenancy_foundation.sql +299 -0
- package/drizzle/0219_organization_tenancy_managed_human_provisioning.sql +317 -0
- package/drizzle/0219_site_auth_maintenance_sessions.sql +234 -0
- package/drizzle/0220_memory_slack_append_only_cascade.sql +38 -0
- package/drizzle/0220_session_channels.sql +54 -0
- package/drizzle/0221_sessions_channel_index.sql +5 -0
- package/drizzle/0222_session_visibility_authority_epochs.sql +158 -0
- package/drizzle/0222_sessions_channel_fk.sql +89 -0
- package/drizzle/0223_pending_tool_event_output.sql +55 -0
- package/drizzle/0223_sessions_channel_fk_validate.sql +118 -0
- package/drizzle/0224_slack_post_outcome_reconciliation.sql +123 -0
- package/drizzle/0225_session_visibility_fork_activation.sql +1363 -0
- package/drizzle/0226_personal_codex_authority_foundation.sql +365 -0
- package/drizzle/0227_slack_native_actions.sql +145 -0
- package/drizzle/0228_interaction_controller_data_plane.sql +14 -0
- package/drizzle/0228_slack_task_policy.sql +400 -0
- package/drizzle/0229_slack_inbox_file_fact.sql +10 -0
- package/drizzle/0230_user_scoped_variable_sets_rigs.sql +84 -0
- package/drizzle/0231_integration_definition_identity_cutover.sql +271 -0
- package/drizzle/0232_integration_facet_authority_cutover.sql +610 -0
- package/drizzle/0233_skill_and_integration_authority_cutover.sql +1162 -0
- package/drizzle/0234_xai_subscription_authority.sql +1139 -0
- package/drizzle/0235_canonical_human_login_bindings.sql +920 -0
- package/drizzle/0236_browser_identity_lifecycle.sql +39 -0
- package/drizzle/0236_session_visibility_slack_policy.sql +72 -0
- package/drizzle/0237_interaction_transition_reaper.sql +263 -0
- package/drizzle/0238_goal_persistence_policy.sql +471 -0
- package/drizzle/0238_supergrok_realtime_model.sql +29 -0
- package/drizzle/0239_supergrok_video_funding.sql +50 -0
- package/drizzle/0239_task_tree_notes.sql +990 -0
- package/package.json +13 -5
- package/src/browser-auth.ts +2600 -123
- package/src/browser-downloads.ts +517 -0
- package/src/browser-identities.ts +220 -3
- package/src/browser-sessions.ts +334 -45
- package/src/browser-state-artifacts.ts +402 -12
- package/src/canonical-human-identities.ts +317 -0
- package/src/capability-integrations.ts +121 -213
- package/src/database.ts +41 -1
- package/src/index.ts +4157 -394
- package/src/insights.ts +109 -28
- package/src/integration-bindings.ts +101 -101
- package/src/{integration-features.ts → integration-facets.ts} +187 -184
- package/src/interaction-revisions.ts +33 -3
- package/src/interaction-schema.ts +311 -8
- package/src/pack-components.ts +43 -43
- package/src/pack-installations.ts +0 -43
- package/src/plugin-packages.ts +9 -9
- package/src/provision-roles.ts +232 -0
- package/src/runtime-posture.ts +227 -14
- package/src/schema.ts +1188 -75
- package/src/session-control.ts +122 -37
- package/src/session-queue-commands.ts +78 -1
- package/src/session-tenancy.ts +188 -0
- package/src/session-tool-call-settlement.ts +9 -3
- package/src/slack-task-policy-schema.ts +130 -0
- package/src/slack-task-policy.ts +341 -0
- package/src/task-notes-schema.ts +135 -0
- package/src/task-notes.ts +178 -0
- package/src/video-generation.ts +90 -12
- package/src/workspace-artifacts.ts +1 -1
- package/src/xai-subscription.ts +1887 -0
- package/dist/chunk-ISI2TIUG.js.map +0 -1
- package/dist/chunk-MWDVXQOL.js.map +0 -1
- package/dist/chunk-NBE6CHRI.js.map +0 -1
- package/dist/chunk-YHZXQ7HP.js.map +0 -1
- package/dist/integration-features.d.ts +0 -99
- /package/dist/{chunk-F5FT7BEZ.js.map → chunk-P3RE4D2B.js.map} +0 -0
package/src/browser-auth.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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<
|
|
333
|
-
if (!routeId) return;
|
|
609
|
+
): Promise<NetworkRouteValue | null> {
|
|
610
|
+
if (!routeId) return null;
|
|
334
611
|
const [route] = await db
|
|
335
|
-
.select(
|
|
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 & {
|
|
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: {
|
|
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 (
|
|
681
|
-
|
|
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
|
|
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 & {
|
|
1095
|
-
|
|
1096
|
-
|
|
1857
|
+
input: InteractionMutationScope & {
|
|
1858
|
+
authRunId: string;
|
|
1859
|
+
} & ExternalAuthInteractiveRequestValue,
|
|
1860
|
+
): Promise<ExternalAuthInteractiveContext> {
|
|
1861
|
+
const request = ExternalAuthInteractiveRequest.parse({
|
|
1097
1862
|
operationId: input.operationId,
|
|
1098
|
-
|
|
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
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
const
|
|
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 (
|
|
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(
|
|
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: "
|
|
3485
|
+
kind: "verify",
|
|
1315
3486
|
requestDigest: digest,
|
|
1316
|
-
resultVersion:
|
|
3487
|
+
resultVersion: resultRun.version,
|
|
1317
3488
|
result: response,
|
|
1318
3489
|
actorSubjectId: input.actorSubjectId,
|
|
1319
3490
|
});
|
|
1320
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
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
|
-
|
|
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(
|