@opengeni/db 0.28.9 → 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/attached-browser-devices.d.ts +42 -0
- package/dist/attempt-tool-catalogs.d.ts +16 -0
- package/dist/browser-auth.d.ts +299 -0
- package/dist/browser-downloads.d.ts +51 -0
- package/dist/browser-identities.d.ts +137 -0
- package/dist/browser-sessions.d.ts +354 -0
- package/dist/browser-state-artifacts.d.ts +91 -0
- 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-components.d.ts +37 -0
- package/dist/capability-integrations.d.ts +194 -0
- package/dist/chunk-5KTIEDX7.js +13200 -0
- package/dist/chunk-5KTIEDX7.js.map +1 -0
- package/dist/chunk-CQZ4QSP5.js +995 -0
- 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-JC5QPLUM.js +666 -0
- package/dist/chunk-JC5QPLUM.js.map +1 -0
- package/dist/chunk-P3RE4D2B.js +3535 -0
- package/dist/chunk-P3RE4D2B.js.map +1 -0
- package/dist/chunk-TAUTWOS5.js +259 -0
- package/dist/chunk-TAUTWOS5.js.map +1 -0
- package/dist/chunk-VKLB2ZGI.js +2389 -0
- package/dist/chunk-VKLB2ZGI.js.map +1 -0
- package/dist/codemode-operations.d.ts +117 -0
- package/dist/codex-token-resolver.d.ts +2 -2
- package/dist/company-profile-schema.d.ts +798 -0
- package/dist/company-profile.d.ts +268 -0
- package/dist/computer-sessions.d.ts +109 -0
- package/dist/connection-token-resolver.d.ts +9 -4
- package/dist/database.d.ts +57 -3
- package/dist/editable-artifact-durable-export.d.ts +153 -0
- package/dist/editable-artifact-durable-export.js +788 -0
- package/dist/editable-artifact-durable-export.js.map +1 -0
- package/dist/editable-artifact-materialization.d.ts +123 -0
- package/dist/editable-artifacts-schema.d.ts +4152 -0
- package/dist/editable-artifacts.d.ts +610 -0
- package/dist/editable-artifacts.js +26 -0
- package/dist/editable-artifacts.js.map +1 -0
- package/dist/generated-images.d.ts +139 -0
- package/dist/index.d.ts +1122 -126
- package/dist/index.js +42329 -18407
- package/dist/index.js.map +1 -1
- package/dist/insights.d.ts +73 -10
- package/dist/integration-bindings.d.ts +70 -0
- package/dist/integration-facets.d.ts +99 -0
- package/dist/interaction-revisions.d.ts +14 -0
- package/dist/interaction-schema.d.ts +6036 -0
- package/dist/knowledge-source-sync-schema.d.ts +1679 -0
- package/dist/knowledge-source-sync.d.ts +274 -0
- package/dist/memory-governance.d.ts +2 -2
- package/dist/memory-slack-delivery.d.ts +101 -0
- package/dist/new-session-drafts.d.ts +2 -2
- package/dist/pack-components.d.ts +76 -0
- package/dist/pack-installations.d.ts +86 -0
- package/dist/persistence-errors.d.ts +3 -3
- package/dist/plugin-packages.d.ts +126 -0
- package/dist/preference-registry.d.ts +1 -1
- package/dist/provision-roles.d.ts +9 -1
- package/dist/provision-roles.js +1 -1
- package/dist/runtime-posture.d.ts +19 -6
- package/dist/schema.d.ts +22654 -9359
- package/dist/schema.js +209 -3
- package/dist/scoped-knowledge.d.ts +97 -1
- package/dist/session-control.d.ts +33 -7
- package/dist/session-queue-commands.d.ts +19 -11
- package/dist/session-realtime-context.d.ts +2 -2
- package/dist/session-realtime-ledger.d.ts +4 -4
- package/dist/session-realtime-mirror.d.ts +2 -2
- package/dist/session-realtime-state.d.ts +1 -1
- package/dist/session-realtime-terminal.d.ts +1 -1
- package/dist/session-realtime.d.ts +7 -7
- 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/session-tool-call-settlement.d.ts +1 -1
- package/dist/slack-task-policy-schema.d.ts +847 -0
- package/dist/slack-task-policy.d.ts +65 -0
- package/dist/slack-user-link-access.d.ts +54 -0
- package/dist/task-notes-schema.d.ts +924 -0
- package/dist/task-notes.d.ts +85 -0
- package/dist/transcription-recordings.d.ts +2 -2
- package/dist/turn-initiator.d.ts +2 -2
- package/dist/video-generation.d.ts +225 -0
- package/dist/video-generation.js +68 -0
- package/dist/video-generation.js.map +1 -0
- package/dist/workspace-artifacts.d.ts +1 -1
- package/dist/workspace-instruction-policies.d.ts +1 -1
- package/dist/workspace-learning-policy-schema.d.ts +932 -0
- package/dist/workspace-learning-policy.d.ts +161 -0
- package/dist/xai-subscription.d.ts +326 -0
- package/drizzle/0183_model_call_provider_cost_estimates.sql +39 -0
- package/drizzle/0184_sandbox_drain_teardown_fence.sql +682 -0
- package/drizzle/0185_temporal_schedule_cleanup_outbox.sql +168 -0
- package/drizzle/0186_sandbox_capture_provider_contract.sql +400 -0
- package/drizzle/0187_generated_image_artifacts.sql +173 -0
- package/drizzle/0188_image_generation_retention_failure.sql +25 -0
- package/drizzle/0189_retained_session_image_formats.sql +13 -0
- package/drizzle/0190_timeline_annotations.sql +31 -0
- package/drizzle/0191_editable_artifact_engine.sql +3853 -0
- package/drizzle/0192_editable_artifact_live_tickets.sql +338 -0
- package/drizzle/0193_editable_artifact_authorization.sql +190 -0
- package/drizzle/0194_editable_artifact_durable_exports.sql +160 -0
- package/drizzle/0195_editable_artifact_import_authorization.sql +203 -0
- package/drizzle/0196_rig_provider_images.sql +152 -0
- package/drizzle/0197_knowledge_source_sync_schedules.sql +523 -0
- package/drizzle/0198_memory_slack_publication_delivery.sql +393 -0
- package/drizzle/0199_workspace_learning_policy.sql +980 -0
- package/drizzle/0201_company_profile_authority.sql +849 -0
- package/drizzle/0202_document_index_checkpoints.sql +85 -0
- package/drizzle/0203_durable_video_generation.sql +353 -0
- package/drizzle/0204_video_generation_funding.sql +44 -0
- package/drizzle/0205_attempt_tool_catalogs.sql +230 -0
- package/drizzle/0206_browser_sessions.sql +646 -0
- package/drizzle/0207_browser_identities.sql +379 -0
- package/drizzle/0208_attached_browser_devices.sql +165 -0
- package/drizzle/0209_computer_sessions.sql +575 -0
- package/drizzle/0210_browser_auth_network_interventions.sql +441 -0
- package/drizzle/0211_editable_artifact_session_links.sql +58 -0
- package/drizzle/0212_browser_state_transfer_hardening.sql +560 -0
- package/drizzle/0212_slack_installation_bindings.sql +348 -0
- package/drizzle/0213_browser_interaction_authority.sql +67 -0
- package/drizzle/0213_slack_user_link_access_requests.sql +177 -0
- package/drizzle/0214_browser_download_saves.sql +34 -0
- package/drizzle/0214_session_activity_commit_gate.sql +262 -0
- package/drizzle/0215_browser_controller_host.sql +385 -0
- package/drizzle/0215_capabilities_platform.sql +980 -0
- package/drizzle/0216_browser_auth_health_evidence.sql +137 -0
- package/drizzle/0216_pack_component_ownership.sql +235 -0
- package/drizzle/0217_capability_definition_delete_authority.sql +32 -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 +26 -5
- package/src/attached-browser-devices.ts +371 -0
- package/src/attempt-tool-catalogs.ts +131 -0
- package/src/browser-auth.ts +4373 -0
- package/src/browser-downloads.ts +517 -0
- package/src/browser-identities.ts +1385 -0
- package/src/browser-sessions.ts +2294 -0
- package/src/browser-state-artifacts.ts +449 -0
- package/src/canonical-human-identities.ts +317 -0
- package/src/capability-components.ts +166 -0
- package/src/capability-integrations.ts +1571 -0
- package/src/codemode-operations.ts +674 -0
- package/src/company-profile-schema.ts +143 -0
- package/src/company-profile.ts +968 -0
- package/src/computer-sessions.ts +1130 -0
- package/src/connection-token-resolver.ts +270 -25
- package/src/database.ts +468 -24
- package/src/editable-artifact-durable-export.ts +1201 -0
- package/src/editable-artifact-materialization.ts +893 -0
- package/src/editable-artifacts-schema.ts +879 -0
- package/src/editable-artifacts.ts +5404 -0
- package/src/generated-images.ts +740 -0
- package/src/index.ts +9963 -1465
- package/src/insights.ts +251 -23
- package/src/integration-bindings.ts +455 -0
- package/src/integration-facets.ts +862 -0
- package/src/interaction-revisions.ts +70 -0
- package/src/interaction-schema.ts +1606 -0
- package/src/knowledge-source-sync-schema.ts +192 -0
- package/src/knowledge-source-sync.ts +1922 -0
- package/src/memory-slack-delivery.ts +801 -0
- package/src/pack-components.ts +954 -0
- package/src/pack-installations.ts +886 -0
- package/src/persistence-errors.ts +4 -7
- package/src/plugin-packages.ts +1186 -0
- package/src/provision-roles.ts +534 -1
- package/src/runtime-posture-cli.ts +9 -0
- package/src/runtime-posture.ts +721 -5
- package/src/schema.ts +6270 -3159
- package/src/scoped-knowledge-schema.ts +1 -1
- package/src/scoped-knowledge.ts +655 -3
- package/src/session-control.ts +130 -42
- package/src/session-queue-commands.ts +265 -15
- package/src/session-realtime-context.ts +2 -2
- package/src/session-realtime-ledger.ts +3 -3
- package/src/session-realtime.ts +7 -7
- package/src/session-tenancy.ts +188 -0
- package/src/session-tool-call-settlement.ts +18 -3
- package/src/slack-task-policy-schema.ts +130 -0
- package/src/slack-task-policy.ts +341 -0
- package/src/slack-user-link-access.ts +702 -0
- package/src/task-notes-schema.ts +135 -0
- package/src/task-notes.ts +178 -0
- package/src/video-generation.ts +1530 -0
- package/src/workspace-artifacts.ts +1 -1
- package/src/workspace-learning-policy-schema.ts +139 -0
- package/src/workspace-learning-policy.ts +595 -0
- package/src/xai-subscription.ts +1887 -0
- package/dist/chunk-P6CUHXQZ.js +0 -7248
- package/dist/chunk-P6CUHXQZ.js.map +0 -1
- package/dist/chunk-VHBFHMPC.js +0 -1219
- package/dist/chunk-VHBFHMPC.js.map +0 -1
|
@@ -0,0 +1,4373 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
AuthRun,
|
|
4
|
+
AuthRunListResponse,
|
|
5
|
+
AuthRunMutationResponse,
|
|
6
|
+
BrowserExternalAuthResult,
|
|
7
|
+
CreateInteractionInterventionRequest,
|
|
8
|
+
CreateNetworkRouteRequest,
|
|
9
|
+
CreateSiteAuthConnectionRequest,
|
|
10
|
+
ExternalAuthInteractiveRequest,
|
|
11
|
+
ExternalAuthRunRequest,
|
|
12
|
+
ExternalAuthRunResponse,
|
|
13
|
+
InteractionIntervention,
|
|
14
|
+
InteractionInterventionListResponse,
|
|
15
|
+
InteractionInterventionMutationResponse,
|
|
16
|
+
NetworkRoute,
|
|
17
|
+
NetworkRouteConfiguration,
|
|
18
|
+
NetworkRouteConsistency,
|
|
19
|
+
NetworkRouteListResponse,
|
|
20
|
+
NetworkRouteMutationResponse,
|
|
21
|
+
interactionPlacementsEqual,
|
|
22
|
+
networkRoutePlacementCompatibilityIssue,
|
|
23
|
+
ProtectedAuthFillRequest,
|
|
24
|
+
ProtectedAuthFillResponse,
|
|
25
|
+
ReportAuthRunRequest,
|
|
26
|
+
RequestHumanInteractionToolInput,
|
|
27
|
+
SITE_AUTH_MAINTENANCE_CONNECTION_CONTEXT_KEY,
|
|
28
|
+
SITE_AUTH_MAINTENANCE_OPERATION_CONTEXT_KEY,
|
|
29
|
+
SiteAuthAuthority,
|
|
30
|
+
ResolveInteractionInterventionRequest,
|
|
31
|
+
SiteAuthConnection,
|
|
32
|
+
SiteAuthConnectionListResponse,
|
|
33
|
+
SiteAuthConnectionMutationResponse,
|
|
34
|
+
StartAuthRunRequest,
|
|
35
|
+
UpdateNetworkRouteRequest,
|
|
36
|
+
UpdateSiteAuthConnectionRequest,
|
|
37
|
+
VerifyAuthRunRequest,
|
|
38
|
+
type AuthRun as AuthRunValue,
|
|
39
|
+
type AuthRunListResponse as AuthRunListResponseValue,
|
|
40
|
+
type AuthRunMutationResponse as AuthRunMutationResponseValue,
|
|
41
|
+
type CreateInteractionInterventionRequest as CreateInteractionInterventionRequestValue,
|
|
42
|
+
type CreateNetworkRouteRequest as CreateNetworkRouteRequestValue,
|
|
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,
|
|
48
|
+
type InteractionCredentialAuthorityRef,
|
|
49
|
+
type InteractionIntervention as InteractionInterventionValue,
|
|
50
|
+
type InteractionInterventionListResponse as InteractionInterventionListResponseValue,
|
|
51
|
+
type InteractionInterventionMutationResponse as InteractionInterventionMutationResponseValue,
|
|
52
|
+
type NetworkRoute as NetworkRouteValue,
|
|
53
|
+
type NetworkRouteConfiguration as NetworkRouteConfigurationValue,
|
|
54
|
+
type NetworkRouteListResponse as NetworkRouteListResponseValue,
|
|
55
|
+
type NetworkRouteMutationResponse as NetworkRouteMutationResponseValue,
|
|
56
|
+
type ProtectedAuthFillRequest as ProtectedAuthFillRequestValue,
|
|
57
|
+
type ProtectedAuthFillResponse as ProtectedAuthFillResponseValue,
|
|
58
|
+
type ReportAuthRunRequest as ReportAuthRunRequestValue,
|
|
59
|
+
type ResolveInteractionInterventionRequest as ResolveInteractionInterventionRequestValue,
|
|
60
|
+
type RequestHumanInteractionToolInput as RequestHumanInteractionToolInputValue,
|
|
61
|
+
type SessionEvent,
|
|
62
|
+
type SiteAuthAuthority as SiteAuthAuthorityValue,
|
|
63
|
+
type SiteAuthConnection as SiteAuthConnectionValue,
|
|
64
|
+
type SiteAuthConnectionListResponse as SiteAuthConnectionListResponseValue,
|
|
65
|
+
type SiteAuthConnectionMutationResponse as SiteAuthConnectionMutationResponseValue,
|
|
66
|
+
type StartAuthRunRequest as StartAuthRunRequestValue,
|
|
67
|
+
type UpdateNetworkRouteRequest as UpdateNetworkRouteRequestValue,
|
|
68
|
+
type UpdateSiteAuthConnectionRequest as UpdateSiteAuthConnectionRequestValue,
|
|
69
|
+
type VerifyAuthRunRequest as VerifyAuthRunRequestValue,
|
|
70
|
+
} from "@opengeni/contracts";
|
|
71
|
+
import { and, asc, eq, inArray, sql } from "drizzle-orm";
|
|
72
|
+
import { type Database, rawRows, setSubjectRlsContext, withRlsContext } from "./database";
|
|
73
|
+
import {
|
|
74
|
+
advanceWorkspaceInteractionRevision,
|
|
75
|
+
readWorkspaceInteractionRevision,
|
|
76
|
+
} from "./interaction-revisions";
|
|
77
|
+
import { safeDatabaseErrorFacts } from "./persistence-errors";
|
|
78
|
+
import * as schema from "./schema";
|
|
79
|
+
|
|
80
|
+
type NetworkRouteRow = typeof schema.networkRoutes.$inferSelect;
|
|
81
|
+
type SiteAuthConnectionRow = typeof schema.siteAuthConnections.$inferSelect;
|
|
82
|
+
type AuthRunRow = typeof schema.authRuns.$inferSelect;
|
|
83
|
+
type InterventionRow = typeof schema.interactionInterventions.$inferSelect;
|
|
84
|
+
type ResourceOperationRow = typeof schema.interactionResourceOperations.$inferSelect;
|
|
85
|
+
|
|
86
|
+
type InteractionMutationScope = {
|
|
87
|
+
accountId: string;
|
|
88
|
+
workspaceId: string;
|
|
89
|
+
actorSubjectId: string;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
type ResourceKind = ResourceOperationRow["resourceKind"];
|
|
93
|
+
type ResourceOperationKind = ResourceOperationRow["kind"];
|
|
94
|
+
|
|
95
|
+
const CONSISTENT_READ = {
|
|
96
|
+
isolationLevel: "repeatable read",
|
|
97
|
+
accessMode: "read only",
|
|
98
|
+
} as const;
|
|
99
|
+
const MAX_SITE_AUTH_MAINTENANCE_RETRY_MS = 15 * 60 * 1_000;
|
|
100
|
+
|
|
101
|
+
export class InteractionResourceNotFoundError extends Error {
|
|
102
|
+
readonly name = "InteractionResourceNotFoundError";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export class InteractionResourceConflictError extends Error {
|
|
106
|
+
readonly name = "InteractionResourceConflictError";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export class InteractionResourceStateError extends Error {
|
|
110
|
+
readonly name = "InteractionResourceStateError";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function iso(value: Date): string {
|
|
114
|
+
return value.toISOString();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function canonicalJsonValue(value: unknown): unknown {
|
|
118
|
+
if (Array.isArray(value)) return value.map(canonicalJsonValue);
|
|
119
|
+
if (!value || typeof value !== "object") return value;
|
|
120
|
+
return Object.fromEntries(
|
|
121
|
+
Object.entries(value as Record<string, unknown>)
|
|
122
|
+
.filter(([, entry]) => entry !== undefined)
|
|
123
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
124
|
+
.map(([key, entry]) => [key, canonicalJsonValue(entry)]),
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function operationDigest(value: Record<string, unknown>): string {
|
|
129
|
+
return createHash("sha256")
|
|
130
|
+
.update(JSON.stringify(canonicalJsonValue(value)))
|
|
131
|
+
.digest("hex");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function postgresConstraint(error: unknown): string | null {
|
|
135
|
+
return safeDatabaseErrorFacts(error).constraint ?? null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function routeFromRow(row: NetworkRouteRow): NetworkRouteValue {
|
|
139
|
+
return NetworkRoute.parse({
|
|
140
|
+
id: row.id,
|
|
141
|
+
accountId: row.accountId,
|
|
142
|
+
workspaceId: row.workspaceId,
|
|
143
|
+
name: row.name,
|
|
144
|
+
status: row.status,
|
|
145
|
+
configuration: row.configuration,
|
|
146
|
+
consistency: row.consistency,
|
|
147
|
+
version: row.version,
|
|
148
|
+
createdBySubjectId: row.createdBySubjectId,
|
|
149
|
+
createdAt: iso(row.createdAt),
|
|
150
|
+
updatedAt: iso(row.updatedAt),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function siteAuthConnectionFromRow(row: SiteAuthConnectionRow): SiteAuthConnectionValue {
|
|
155
|
+
return SiteAuthConnection.parse({
|
|
156
|
+
id: row.id,
|
|
157
|
+
accountId: row.accountId,
|
|
158
|
+
workspaceId: row.workspaceId,
|
|
159
|
+
name: row.name,
|
|
160
|
+
accountLabel: row.accountLabel,
|
|
161
|
+
status: row.status,
|
|
162
|
+
origins: row.origins,
|
|
163
|
+
loginUrl: row.loginUrl,
|
|
164
|
+
verificationUrlPrefixes: row.verificationUrlPrefixes,
|
|
165
|
+
authorities: row.authorities,
|
|
166
|
+
methods: row.methods,
|
|
167
|
+
preferredIdentityId: row.preferredIdentityId,
|
|
168
|
+
preferredPlacement: row.preferredPlacement,
|
|
169
|
+
preferredNetworkRouteId: row.preferredNetworkRouteId,
|
|
170
|
+
healthPolicy: row.healthPolicy,
|
|
171
|
+
verificationState: row.verificationState,
|
|
172
|
+
lastVerifiedAt: row.lastVerifiedAt ? iso(row.lastVerifiedAt) : null,
|
|
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,
|
|
184
|
+
repairCode: row.repairCode,
|
|
185
|
+
version: row.version,
|
|
186
|
+
createdBySubjectId: row.createdBySubjectId,
|
|
187
|
+
createdAt: iso(row.createdAt),
|
|
188
|
+
updatedAt: iso(row.updatedAt),
|
|
189
|
+
});
|
|
190
|
+
}
|
|
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
|
+
|
|
423
|
+
async function lockOperation(db: Database, operationId: string): Promise<void> {
|
|
424
|
+
await db.execute(
|
|
425
|
+
sql`select pg_advisory_xact_lock(hashtextextended(${`interaction-resource:${operationId}`}, 0))`,
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async function loadOperation(
|
|
430
|
+
db: Database,
|
|
431
|
+
workspaceId: string,
|
|
432
|
+
operationId: string,
|
|
433
|
+
): Promise<ResourceOperationRow | null> {
|
|
434
|
+
const [row] = await db
|
|
435
|
+
.select()
|
|
436
|
+
.from(schema.interactionResourceOperations)
|
|
437
|
+
.where(
|
|
438
|
+
and(
|
|
439
|
+
eq(schema.interactionResourceOperations.workspaceId, workspaceId),
|
|
440
|
+
eq(schema.interactionResourceOperations.operationId, operationId),
|
|
441
|
+
),
|
|
442
|
+
)
|
|
443
|
+
.limit(1);
|
|
444
|
+
return row ?? null;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function assertOperationIdentity(
|
|
448
|
+
operation: ResourceOperationRow,
|
|
449
|
+
expected: {
|
|
450
|
+
resourceKind: ResourceKind;
|
|
451
|
+
resourceId?: string;
|
|
452
|
+
kind: ResourceOperationKind;
|
|
453
|
+
requestDigest: string;
|
|
454
|
+
actorSubjectId: string;
|
|
455
|
+
},
|
|
456
|
+
): void {
|
|
457
|
+
if (
|
|
458
|
+
operation.resourceKind !== expected.resourceKind ||
|
|
459
|
+
(expected.resourceId !== undefined && operation.resourceId !== expected.resourceId) ||
|
|
460
|
+
operation.kind !== expected.kind ||
|
|
461
|
+
operation.requestDigest !== expected.requestDigest ||
|
|
462
|
+
operation.actorSubjectId !== expected.actorSubjectId
|
|
463
|
+
) {
|
|
464
|
+
throw new InteractionResourceConflictError(
|
|
465
|
+
"Operation id is already bound to another interaction request",
|
|
466
|
+
);
|
|
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);
|
|
481
|
+
if (operation.state !== "completed" || !operation.result || !operation.resultVersion) {
|
|
482
|
+
throw new InteractionResourceStateError("Interaction operation is not complete");
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
async function insertCompletedOperation(
|
|
487
|
+
db: Database,
|
|
488
|
+
input: {
|
|
489
|
+
operationId: string;
|
|
490
|
+
accountId: string;
|
|
491
|
+
workspaceId: string;
|
|
492
|
+
resourceKind: ResourceKind;
|
|
493
|
+
resourceId: string;
|
|
494
|
+
kind: ResourceOperationKind;
|
|
495
|
+
requestDigest: string;
|
|
496
|
+
resultVersion: number;
|
|
497
|
+
result: Record<string, unknown>;
|
|
498
|
+
actorSubjectId: string;
|
|
499
|
+
},
|
|
500
|
+
): Promise<void> {
|
|
501
|
+
await db.insert(schema.interactionResourceOperations).values({
|
|
502
|
+
...input,
|
|
503
|
+
state: "completed",
|
|
504
|
+
settledAt: sql`now()`,
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async function assertWorkspaceAccount(
|
|
509
|
+
db: Database,
|
|
510
|
+
accountId: string,
|
|
511
|
+
workspaceId: string,
|
|
512
|
+
): Promise<void> {
|
|
513
|
+
const [workspace] = await db
|
|
514
|
+
.select({ id: schema.workspaces.id })
|
|
515
|
+
.from(schema.workspaces)
|
|
516
|
+
.where(and(eq(schema.workspaces.id, workspaceId), eq(schema.workspaces.accountId, accountId)))
|
|
517
|
+
.limit(1);
|
|
518
|
+
if (!workspace) throw new InteractionResourceNotFoundError("Workspace not found");
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function credentialRefsFromRoute(
|
|
522
|
+
configuration: NetworkRouteConfigurationValue,
|
|
523
|
+
): InteractionCredentialAuthorityRef[] {
|
|
524
|
+
return "credential" in configuration && configuration.credential
|
|
525
|
+
? [configuration.credential]
|
|
526
|
+
: [];
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function credentialRefsFromAuthorities(
|
|
530
|
+
authorities: readonly SiteAuthAuthorityValue[],
|
|
531
|
+
): InteractionCredentialAuthorityRef[] {
|
|
532
|
+
return authorities.flatMap((authority) =>
|
|
533
|
+
"credential" in authority && authority.credential ? [authority.credential] : [],
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function credentialRefKey(reference: InteractionCredentialAuthorityRef): string {
|
|
538
|
+
return `${reference.connectionId}\u0000${reference.connectionSubjectId ?? ""}\u0000${reference.providerDomain}`;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function introducedCredentialRefs(
|
|
542
|
+
current: readonly InteractionCredentialAuthorityRef[],
|
|
543
|
+
candidate: readonly InteractionCredentialAuthorityRef[],
|
|
544
|
+
): InteractionCredentialAuthorityRef[] {
|
|
545
|
+
const trusted = new Set(current.map(credentialRefKey));
|
|
546
|
+
return candidate.filter((reference) => !trusted.has(credentialRefKey(reference)));
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
async function assertCredentialAuthorities(
|
|
550
|
+
db: Database,
|
|
551
|
+
workspaceId: string,
|
|
552
|
+
refs: readonly InteractionCredentialAuthorityRef[],
|
|
553
|
+
): Promise<void> {
|
|
554
|
+
if (refs.length === 0) return;
|
|
555
|
+
const ids = [...new Set(refs.map((reference) => reference.connectionId))];
|
|
556
|
+
const rows = await db
|
|
557
|
+
.select({
|
|
558
|
+
id: schema.connections.id,
|
|
559
|
+
subjectId: schema.connections.subjectId,
|
|
560
|
+
providerDomain: schema.connections.providerDomain,
|
|
561
|
+
status: schema.connections.status,
|
|
562
|
+
})
|
|
563
|
+
.from(schema.connections)
|
|
564
|
+
.where(
|
|
565
|
+
and(eq(schema.connections.workspaceId, workspaceId), inArray(schema.connections.id, ids)),
|
|
566
|
+
);
|
|
567
|
+
const byId = new Map(rows.map((row) => [row.id, row]));
|
|
568
|
+
for (const reference of refs) {
|
|
569
|
+
const row = byId.get(reference.connectionId);
|
|
570
|
+
if (
|
|
571
|
+
!row ||
|
|
572
|
+
row.subjectId !== reference.connectionSubjectId ||
|
|
573
|
+
row.providerDomain !== reference.providerDomain
|
|
574
|
+
) {
|
|
575
|
+
throw new InteractionResourceNotFoundError("Credential authority not found");
|
|
576
|
+
}
|
|
577
|
+
if (row.status !== "active") {
|
|
578
|
+
throw new InteractionResourceStateError("Credential authority is not active");
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
async function assertActiveBrowserIdentity(
|
|
584
|
+
db: Database,
|
|
585
|
+
workspaceId: string,
|
|
586
|
+
identityId: string | null,
|
|
587
|
+
): Promise<void> {
|
|
588
|
+
if (!identityId) return;
|
|
589
|
+
const [identity] = await db
|
|
590
|
+
.select({ status: schema.browserIdentities.status })
|
|
591
|
+
.from(schema.browserIdentities)
|
|
592
|
+
.where(
|
|
593
|
+
and(
|
|
594
|
+
eq(schema.browserIdentities.workspaceId, workspaceId),
|
|
595
|
+
eq(schema.browserIdentities.id, identityId),
|
|
596
|
+
),
|
|
597
|
+
)
|
|
598
|
+
.limit(1);
|
|
599
|
+
if (!identity) throw new InteractionResourceNotFoundError("Preferred browser identity not found");
|
|
600
|
+
if (identity.status !== "active") {
|
|
601
|
+
throw new InteractionResourceStateError("Preferred browser identity is archived");
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
async function assertActiveNetworkRoute(
|
|
606
|
+
db: Database,
|
|
607
|
+
workspaceId: string,
|
|
608
|
+
routeId: string | null,
|
|
609
|
+
): Promise<NetworkRouteValue | null> {
|
|
610
|
+
if (!routeId) return null;
|
|
611
|
+
const [route] = await db
|
|
612
|
+
.select()
|
|
613
|
+
.from(schema.networkRoutes)
|
|
614
|
+
.where(
|
|
615
|
+
and(eq(schema.networkRoutes.workspaceId, workspaceId), eq(schema.networkRoutes.id, routeId)),
|
|
616
|
+
)
|
|
617
|
+
.for("share")
|
|
618
|
+
.limit(1);
|
|
619
|
+
if (!route) throw new InteractionResourceNotFoundError("Preferred network route not found");
|
|
620
|
+
if (route.status !== "active") {
|
|
621
|
+
throw new InteractionResourceStateError("Preferred network route is archived");
|
|
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}`);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
export async function listNetworkRoutes(
|
|
680
|
+
db: Database,
|
|
681
|
+
input: { accountId: string; workspaceId: string; includeArchived?: boolean },
|
|
682
|
+
): Promise<NetworkRouteListResponseValue> {
|
|
683
|
+
return await withRlsContext(
|
|
684
|
+
db,
|
|
685
|
+
input,
|
|
686
|
+
async (scopedDb) => {
|
|
687
|
+
const predicate = input.includeArchived
|
|
688
|
+
? eq(schema.networkRoutes.workspaceId, input.workspaceId)
|
|
689
|
+
: and(
|
|
690
|
+
eq(schema.networkRoutes.workspaceId, input.workspaceId),
|
|
691
|
+
eq(schema.networkRoutes.status, "active"),
|
|
692
|
+
);
|
|
693
|
+
const routes = await scopedDb
|
|
694
|
+
.select()
|
|
695
|
+
.from(schema.networkRoutes)
|
|
696
|
+
.where(predicate)
|
|
697
|
+
.orderBy(asc(schema.networkRoutes.name), asc(schema.networkRoutes.id));
|
|
698
|
+
return NetworkRouteListResponse.parse({
|
|
699
|
+
revision: await readWorkspaceInteractionRevision(scopedDb, input.workspaceId),
|
|
700
|
+
routes: routes.map(routeFromRow),
|
|
701
|
+
});
|
|
702
|
+
},
|
|
703
|
+
CONSISTENT_READ,
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
export async function getNetworkRoute(
|
|
708
|
+
db: Database,
|
|
709
|
+
input: { accountId: string; workspaceId: string; routeId: string },
|
|
710
|
+
): Promise<NetworkRouteValue> {
|
|
711
|
+
return await withRlsContext(
|
|
712
|
+
db,
|
|
713
|
+
input,
|
|
714
|
+
async (scopedDb) => {
|
|
715
|
+
const [row] = await scopedDb
|
|
716
|
+
.select()
|
|
717
|
+
.from(schema.networkRoutes)
|
|
718
|
+
.where(
|
|
719
|
+
and(
|
|
720
|
+
eq(schema.networkRoutes.workspaceId, input.workspaceId),
|
|
721
|
+
eq(schema.networkRoutes.id, input.routeId),
|
|
722
|
+
),
|
|
723
|
+
)
|
|
724
|
+
.limit(1);
|
|
725
|
+
if (!row) throw new InteractionResourceNotFoundError("Network route not found");
|
|
726
|
+
return routeFromRow(row);
|
|
727
|
+
},
|
|
728
|
+
CONSISTENT_READ,
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
export async function createNetworkRoute(
|
|
733
|
+
db: Database,
|
|
734
|
+
input: InteractionMutationScope & CreateNetworkRouteRequestValue,
|
|
735
|
+
): Promise<NetworkRouteMutationResponseValue> {
|
|
736
|
+
const request = CreateNetworkRouteRequest.parse({
|
|
737
|
+
operationId: input.operationId,
|
|
738
|
+
name: input.name,
|
|
739
|
+
configuration: input.configuration,
|
|
740
|
+
consistency: input.consistency,
|
|
741
|
+
});
|
|
742
|
+
const digest = operationDigest({
|
|
743
|
+
version: 1,
|
|
744
|
+
name: request.name,
|
|
745
|
+
configuration: request.configuration,
|
|
746
|
+
consistency: request.consistency,
|
|
747
|
+
actorSubjectId: input.actorSubjectId,
|
|
748
|
+
});
|
|
749
|
+
try {
|
|
750
|
+
return await withRlsContext(db, input, async (scopedDb) => {
|
|
751
|
+
await assertWorkspaceAccount(scopedDb, input.accountId, input.workspaceId);
|
|
752
|
+
await setSubjectRlsContext(scopedDb, input.actorSubjectId);
|
|
753
|
+
await lockOperation(scopedDb, request.operationId);
|
|
754
|
+
const existing = await loadOperation(scopedDb, input.workspaceId, request.operationId);
|
|
755
|
+
if (existing) {
|
|
756
|
+
assertOperation(existing, {
|
|
757
|
+
resourceKind: "network_route",
|
|
758
|
+
kind: "create",
|
|
759
|
+
requestDigest: digest,
|
|
760
|
+
actorSubjectId: input.actorSubjectId,
|
|
761
|
+
});
|
|
762
|
+
const response = NetworkRouteMutationResponse.parse(existing.result);
|
|
763
|
+
return { ...response, replayed: true };
|
|
764
|
+
}
|
|
765
|
+
await assertCredentialAuthorities(
|
|
766
|
+
scopedDb,
|
|
767
|
+
input.workspaceId,
|
|
768
|
+
credentialRefsFromRoute(request.configuration),
|
|
769
|
+
);
|
|
770
|
+
const id = randomUUID();
|
|
771
|
+
const [row] = await scopedDb
|
|
772
|
+
.insert(schema.networkRoutes)
|
|
773
|
+
.values({
|
|
774
|
+
id,
|
|
775
|
+
accountId: input.accountId,
|
|
776
|
+
workspaceId: input.workspaceId,
|
|
777
|
+
name: request.name,
|
|
778
|
+
configuration: request.configuration,
|
|
779
|
+
consistency: request.consistency,
|
|
780
|
+
createOperationId: request.operationId,
|
|
781
|
+
createdBySubjectId: input.actorSubjectId,
|
|
782
|
+
updatedBySubjectId: input.actorSubjectId,
|
|
783
|
+
})
|
|
784
|
+
.returning();
|
|
785
|
+
if (!row) throw new Error("Network route insert returned no row");
|
|
786
|
+
const response = NetworkRouteMutationResponse.parse({
|
|
787
|
+
route: routeFromRow(row),
|
|
788
|
+
operationId: request.operationId,
|
|
789
|
+
replayed: false,
|
|
790
|
+
});
|
|
791
|
+
await insertCompletedOperation(scopedDb, {
|
|
792
|
+
operationId: request.operationId,
|
|
793
|
+
accountId: input.accountId,
|
|
794
|
+
workspaceId: input.workspaceId,
|
|
795
|
+
resourceKind: "network_route",
|
|
796
|
+
resourceId: id,
|
|
797
|
+
kind: "create",
|
|
798
|
+
requestDigest: digest,
|
|
799
|
+
resultVersion: row.version,
|
|
800
|
+
result: response,
|
|
801
|
+
actorSubjectId: input.actorSubjectId,
|
|
802
|
+
});
|
|
803
|
+
await advanceWorkspaceInteractionRevision(scopedDb, input.accountId, input.workspaceId);
|
|
804
|
+
return response;
|
|
805
|
+
});
|
|
806
|
+
} catch (error) {
|
|
807
|
+
if (postgresConstraint(error) === "network_routes_workspace_active_name_uq") {
|
|
808
|
+
throw new InteractionResourceConflictError("An active network route already uses this name");
|
|
809
|
+
}
|
|
810
|
+
throw error;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
export async function updateNetworkRoute(
|
|
815
|
+
db: Database,
|
|
816
|
+
input: InteractionMutationScope & {
|
|
817
|
+
routeId: string;
|
|
818
|
+
} & UpdateNetworkRouteRequestValue,
|
|
819
|
+
): Promise<NetworkRouteMutationResponseValue> {
|
|
820
|
+
const request = UpdateNetworkRouteRequest.parse({
|
|
821
|
+
operationId: input.operationId,
|
|
822
|
+
expectedVersion: input.expectedVersion,
|
|
823
|
+
...(input.name !== undefined ? { name: input.name } : {}),
|
|
824
|
+
...(input.status !== undefined ? { status: input.status } : {}),
|
|
825
|
+
...(input.configuration !== undefined ? { configuration: input.configuration } : {}),
|
|
826
|
+
...(input.consistency !== undefined ? { consistency: input.consistency } : {}),
|
|
827
|
+
});
|
|
828
|
+
const { operationId: _operationId, ...digestRequest } = request;
|
|
829
|
+
const digest = operationDigest({
|
|
830
|
+
version: 1,
|
|
831
|
+
routeId: input.routeId,
|
|
832
|
+
request: digestRequest,
|
|
833
|
+
actorSubjectId: input.actorSubjectId,
|
|
834
|
+
});
|
|
835
|
+
try {
|
|
836
|
+
return await withRlsContext(db, input, async (scopedDb) => {
|
|
837
|
+
await assertWorkspaceAccount(scopedDb, input.accountId, input.workspaceId);
|
|
838
|
+
await setSubjectRlsContext(scopedDb, input.actorSubjectId);
|
|
839
|
+
await lockOperation(scopedDb, request.operationId);
|
|
840
|
+
const existing = await loadOperation(scopedDb, input.workspaceId, request.operationId);
|
|
841
|
+
if (existing) {
|
|
842
|
+
assertOperation(existing, {
|
|
843
|
+
resourceKind: "network_route",
|
|
844
|
+
resourceId: input.routeId,
|
|
845
|
+
kind: "update",
|
|
846
|
+
requestDigest: digest,
|
|
847
|
+
actorSubjectId: input.actorSubjectId,
|
|
848
|
+
});
|
|
849
|
+
const response = NetworkRouteMutationResponse.parse(existing.result);
|
|
850
|
+
return { ...response, replayed: true };
|
|
851
|
+
}
|
|
852
|
+
const [current] = await scopedDb
|
|
853
|
+
.select()
|
|
854
|
+
.from(schema.networkRoutes)
|
|
855
|
+
.where(
|
|
856
|
+
and(
|
|
857
|
+
eq(schema.networkRoutes.workspaceId, input.workspaceId),
|
|
858
|
+
eq(schema.networkRoutes.id, input.routeId),
|
|
859
|
+
),
|
|
860
|
+
)
|
|
861
|
+
.for("update")
|
|
862
|
+
.limit(1);
|
|
863
|
+
if (!current) throw new InteractionResourceNotFoundError("Network route not found");
|
|
864
|
+
if (current.version !== request.expectedVersion) {
|
|
865
|
+
throw new InteractionResourceConflictError("Network route changed before this update");
|
|
866
|
+
}
|
|
867
|
+
const configuration = NetworkRouteConfiguration.parse(
|
|
868
|
+
request.configuration ?? current.configuration,
|
|
869
|
+
);
|
|
870
|
+
const consistency = NetworkRouteConsistency.parse(request.consistency ?? current.consistency);
|
|
871
|
+
await assertCredentialAuthorities(
|
|
872
|
+
scopedDb,
|
|
873
|
+
input.workspaceId,
|
|
874
|
+
introducedCredentialRefs(
|
|
875
|
+
credentialRefsFromRoute(NetworkRouteConfiguration.parse(current.configuration)),
|
|
876
|
+
credentialRefsFromRoute(configuration),
|
|
877
|
+
),
|
|
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
|
+
}
|
|
892
|
+
const [row] = await scopedDb
|
|
893
|
+
.update(schema.networkRoutes)
|
|
894
|
+
.set({
|
|
895
|
+
name: request.name ?? current.name,
|
|
896
|
+
status: request.status ?? current.status,
|
|
897
|
+
configuration,
|
|
898
|
+
consistency,
|
|
899
|
+
version: current.version + 1,
|
|
900
|
+
updatedBySubjectId: input.actorSubjectId,
|
|
901
|
+
updatedAt: sql`now()`,
|
|
902
|
+
})
|
|
903
|
+
.where(
|
|
904
|
+
and(
|
|
905
|
+
eq(schema.networkRoutes.workspaceId, input.workspaceId),
|
|
906
|
+
eq(schema.networkRoutes.id, input.routeId),
|
|
907
|
+
eq(schema.networkRoutes.version, request.expectedVersion),
|
|
908
|
+
),
|
|
909
|
+
)
|
|
910
|
+
.returning();
|
|
911
|
+
if (!row) throw new InteractionResourceConflictError("Network route update lost its fence");
|
|
912
|
+
const response = NetworkRouteMutationResponse.parse({
|
|
913
|
+
route: routeFromRow(row),
|
|
914
|
+
operationId: request.operationId,
|
|
915
|
+
replayed: false,
|
|
916
|
+
});
|
|
917
|
+
await insertCompletedOperation(scopedDb, {
|
|
918
|
+
operationId: request.operationId,
|
|
919
|
+
accountId: input.accountId,
|
|
920
|
+
workspaceId: input.workspaceId,
|
|
921
|
+
resourceKind: "network_route",
|
|
922
|
+
resourceId: input.routeId,
|
|
923
|
+
kind: "update",
|
|
924
|
+
requestDigest: digest,
|
|
925
|
+
resultVersion: row.version,
|
|
926
|
+
result: response,
|
|
927
|
+
actorSubjectId: input.actorSubjectId,
|
|
928
|
+
});
|
|
929
|
+
await advanceWorkspaceInteractionRevision(scopedDb, input.accountId, input.workspaceId);
|
|
930
|
+
return response;
|
|
931
|
+
});
|
|
932
|
+
} catch (error) {
|
|
933
|
+
if (postgresConstraint(error) === "network_routes_workspace_active_name_uq") {
|
|
934
|
+
throw new InteractionResourceConflictError("An active network route already uses this name");
|
|
935
|
+
}
|
|
936
|
+
throw error;
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
export async function listSiteAuthConnections(
|
|
941
|
+
db: Database,
|
|
942
|
+
input: { accountId: string; workspaceId: string; includeArchived?: boolean },
|
|
943
|
+
): Promise<SiteAuthConnectionListResponseValue> {
|
|
944
|
+
return await withRlsContext(
|
|
945
|
+
db,
|
|
946
|
+
input,
|
|
947
|
+
async (scopedDb) => {
|
|
948
|
+
const predicate = input.includeArchived
|
|
949
|
+
? eq(schema.siteAuthConnections.workspaceId, input.workspaceId)
|
|
950
|
+
: and(
|
|
951
|
+
eq(schema.siteAuthConnections.workspaceId, input.workspaceId),
|
|
952
|
+
eq(schema.siteAuthConnections.status, "active"),
|
|
953
|
+
);
|
|
954
|
+
const connections = await scopedDb
|
|
955
|
+
.select()
|
|
956
|
+
.from(schema.siteAuthConnections)
|
|
957
|
+
.where(predicate)
|
|
958
|
+
.orderBy(asc(schema.siteAuthConnections.name), asc(schema.siteAuthConnections.id));
|
|
959
|
+
return SiteAuthConnectionListResponse.parse({
|
|
960
|
+
revision: await readWorkspaceInteractionRevision(scopedDb, input.workspaceId),
|
|
961
|
+
connections: connections.map(siteAuthConnectionFromRow),
|
|
962
|
+
});
|
|
963
|
+
},
|
|
964
|
+
CONSISTENT_READ,
|
|
965
|
+
);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
export async function getSiteAuthConnection(
|
|
969
|
+
db: Database,
|
|
970
|
+
input: {
|
|
971
|
+
accountId: string;
|
|
972
|
+
workspaceId: string;
|
|
973
|
+
siteAuthConnectionId: string;
|
|
974
|
+
},
|
|
975
|
+
): Promise<SiteAuthConnectionValue> {
|
|
976
|
+
return await withRlsContext(
|
|
977
|
+
db,
|
|
978
|
+
input,
|
|
979
|
+
async (scopedDb) => {
|
|
980
|
+
const [row] = await scopedDb
|
|
981
|
+
.select()
|
|
982
|
+
.from(schema.siteAuthConnections)
|
|
983
|
+
.where(
|
|
984
|
+
and(
|
|
985
|
+
eq(schema.siteAuthConnections.workspaceId, input.workspaceId),
|
|
986
|
+
eq(schema.siteAuthConnections.id, input.siteAuthConnectionId),
|
|
987
|
+
),
|
|
988
|
+
)
|
|
989
|
+
.limit(1);
|
|
990
|
+
if (!row) throw new InteractionResourceNotFoundError("Site auth connection not found");
|
|
991
|
+
return siteAuthConnectionFromRow(row);
|
|
992
|
+
},
|
|
993
|
+
CONSISTENT_READ,
|
|
994
|
+
);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
async function validateSiteAuthReferences(
|
|
998
|
+
db: Database,
|
|
999
|
+
workspaceId: string,
|
|
1000
|
+
connection: Pick<
|
|
1001
|
+
SiteAuthConnectionValue,
|
|
1002
|
+
"authorities" | "preferredIdentityId" | "preferredNetworkRouteId" | "preferredPlacement"
|
|
1003
|
+
>,
|
|
1004
|
+
): Promise<void> {
|
|
1005
|
+
await assertCredentialAuthorities(
|
|
1006
|
+
db,
|
|
1007
|
+
workspaceId,
|
|
1008
|
+
credentialRefsFromAuthorities(connection.authorities),
|
|
1009
|
+
);
|
|
1010
|
+
await assertActiveBrowserIdentity(db, workspaceId, connection.preferredIdentityId);
|
|
1011
|
+
const route = await assertActiveNetworkRoute(db, workspaceId, connection.preferredNetworkRouteId);
|
|
1012
|
+
assertSiteAuthRouteCompatibility(route, connection.preferredPlacement);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
async function validateSiteAuthUpdateReferences(
|
|
1016
|
+
db: Database,
|
|
1017
|
+
workspaceId: string,
|
|
1018
|
+
current: SiteAuthConnectionValue,
|
|
1019
|
+
candidate: SiteAuthConnectionValue,
|
|
1020
|
+
): Promise<void> {
|
|
1021
|
+
await assertCredentialAuthorities(
|
|
1022
|
+
db,
|
|
1023
|
+
workspaceId,
|
|
1024
|
+
introducedCredentialRefs(
|
|
1025
|
+
credentialRefsFromAuthorities(current.authorities),
|
|
1026
|
+
credentialRefsFromAuthorities(candidate.authorities),
|
|
1027
|
+
),
|
|
1028
|
+
);
|
|
1029
|
+
if (candidate.preferredIdentityId !== current.preferredIdentityId) {
|
|
1030
|
+
await assertActiveBrowserIdentity(db, workspaceId, candidate.preferredIdentityId);
|
|
1031
|
+
}
|
|
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);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
export async function createSiteAuthConnection(
|
|
1051
|
+
db: Database,
|
|
1052
|
+
input: InteractionMutationScope & CreateSiteAuthConnectionRequestValue,
|
|
1053
|
+
): Promise<SiteAuthConnectionMutationResponseValue> {
|
|
1054
|
+
const request = CreateSiteAuthConnectionRequest.parse({
|
|
1055
|
+
operationId: input.operationId,
|
|
1056
|
+
name: input.name,
|
|
1057
|
+
accountLabel: input.accountLabel,
|
|
1058
|
+
origins: input.origins,
|
|
1059
|
+
loginUrl: input.loginUrl,
|
|
1060
|
+
verificationUrlPrefixes: input.verificationUrlPrefixes,
|
|
1061
|
+
authorities: input.authorities,
|
|
1062
|
+
methods: input.methods,
|
|
1063
|
+
preferredIdentityId: input.preferredIdentityId,
|
|
1064
|
+
preferredPlacement: input.preferredPlacement,
|
|
1065
|
+
preferredNetworkRouteId: input.preferredNetworkRouteId,
|
|
1066
|
+
healthPolicy: input.healthPolicy,
|
|
1067
|
+
});
|
|
1068
|
+
const { operationId: _operationId, ...digestRequest } = request;
|
|
1069
|
+
const digest = operationDigest({
|
|
1070
|
+
version: 1,
|
|
1071
|
+
request: digestRequest,
|
|
1072
|
+
actorSubjectId: input.actorSubjectId,
|
|
1073
|
+
});
|
|
1074
|
+
try {
|
|
1075
|
+
return await withRlsContext(db, input, async (scopedDb) => {
|
|
1076
|
+
await assertWorkspaceAccount(scopedDb, input.accountId, input.workspaceId);
|
|
1077
|
+
await setSubjectRlsContext(scopedDb, input.actorSubjectId);
|
|
1078
|
+
await lockOperation(scopedDb, request.operationId);
|
|
1079
|
+
const existing = await loadOperation(scopedDb, input.workspaceId, request.operationId);
|
|
1080
|
+
if (existing) {
|
|
1081
|
+
assertOperation(existing, {
|
|
1082
|
+
resourceKind: "site_auth_connection",
|
|
1083
|
+
kind: "create",
|
|
1084
|
+
requestDigest: digest,
|
|
1085
|
+
actorSubjectId: input.actorSubjectId,
|
|
1086
|
+
});
|
|
1087
|
+
const response = SiteAuthConnectionMutationResponse.parse(existing.result);
|
|
1088
|
+
return { ...response, replayed: true };
|
|
1089
|
+
}
|
|
1090
|
+
await validateSiteAuthReferences(scopedDb, input.workspaceId, {
|
|
1091
|
+
authorities: request.authorities,
|
|
1092
|
+
preferredIdentityId: request.preferredIdentityId,
|
|
1093
|
+
preferredPlacement: request.preferredPlacement,
|
|
1094
|
+
preferredNetworkRouteId: request.preferredNetworkRouteId,
|
|
1095
|
+
});
|
|
1096
|
+
const id = randomUUID();
|
|
1097
|
+
const [row] = await scopedDb
|
|
1098
|
+
.insert(schema.siteAuthConnections)
|
|
1099
|
+
.values({
|
|
1100
|
+
id,
|
|
1101
|
+
accountId: input.accountId,
|
|
1102
|
+
workspaceId: input.workspaceId,
|
|
1103
|
+
name: request.name,
|
|
1104
|
+
accountLabel: request.accountLabel,
|
|
1105
|
+
origins: request.origins,
|
|
1106
|
+
loginUrl: request.loginUrl,
|
|
1107
|
+
verificationUrlPrefixes: request.verificationUrlPrefixes,
|
|
1108
|
+
authorities: request.authorities,
|
|
1109
|
+
methods: request.methods,
|
|
1110
|
+
preferredIdentityId: request.preferredIdentityId,
|
|
1111
|
+
preferredPlacement: request.preferredPlacement,
|
|
1112
|
+
preferredNetworkRouteId: request.preferredNetworkRouteId,
|
|
1113
|
+
healthPolicy: request.healthPolicy,
|
|
1114
|
+
nextCheckAt: nextMaintainedAuthCheck("active", request.healthPolicy, null),
|
|
1115
|
+
createOperationId: request.operationId,
|
|
1116
|
+
createdBySubjectId: input.actorSubjectId,
|
|
1117
|
+
updatedBySubjectId: input.actorSubjectId,
|
|
1118
|
+
})
|
|
1119
|
+
.returning();
|
|
1120
|
+
if (!row) throw new Error("Site auth connection insert returned no row");
|
|
1121
|
+
const response = SiteAuthConnectionMutationResponse.parse({
|
|
1122
|
+
connection: siteAuthConnectionFromRow(row),
|
|
1123
|
+
operationId: request.operationId,
|
|
1124
|
+
replayed: false,
|
|
1125
|
+
});
|
|
1126
|
+
await insertCompletedOperation(scopedDb, {
|
|
1127
|
+
operationId: request.operationId,
|
|
1128
|
+
accountId: input.accountId,
|
|
1129
|
+
workspaceId: input.workspaceId,
|
|
1130
|
+
resourceKind: "site_auth_connection",
|
|
1131
|
+
resourceId: id,
|
|
1132
|
+
kind: "create",
|
|
1133
|
+
requestDigest: digest,
|
|
1134
|
+
resultVersion: row.version,
|
|
1135
|
+
result: response,
|
|
1136
|
+
actorSubjectId: input.actorSubjectId,
|
|
1137
|
+
});
|
|
1138
|
+
await advanceWorkspaceInteractionRevision(scopedDb, input.accountId, input.workspaceId);
|
|
1139
|
+
return response;
|
|
1140
|
+
});
|
|
1141
|
+
} catch (error) {
|
|
1142
|
+
if (postgresConstraint(error) === "site_auth_connections_workspace_active_name_uq") {
|
|
1143
|
+
throw new InteractionResourceConflictError(
|
|
1144
|
+
"An active site auth connection already uses this name",
|
|
1145
|
+
);
|
|
1146
|
+
}
|
|
1147
|
+
throw error;
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
export async function updateSiteAuthConnection(
|
|
1152
|
+
db: Database,
|
|
1153
|
+
input: InteractionMutationScope & {
|
|
1154
|
+
siteAuthConnectionId: string;
|
|
1155
|
+
} & UpdateSiteAuthConnectionRequestValue,
|
|
1156
|
+
): Promise<SiteAuthConnectionMutationResponseValue> {
|
|
1157
|
+
const request = UpdateSiteAuthConnectionRequest.parse({
|
|
1158
|
+
operationId: input.operationId,
|
|
1159
|
+
expectedVersion: input.expectedVersion,
|
|
1160
|
+
...(input.name !== undefined ? { name: input.name } : {}),
|
|
1161
|
+
...(input.accountLabel !== undefined ? { accountLabel: input.accountLabel } : {}),
|
|
1162
|
+
...(input.origins !== undefined ? { origins: input.origins } : {}),
|
|
1163
|
+
...(input.loginUrl !== undefined ? { loginUrl: input.loginUrl } : {}),
|
|
1164
|
+
...(input.verificationUrlPrefixes !== undefined
|
|
1165
|
+
? { verificationUrlPrefixes: input.verificationUrlPrefixes }
|
|
1166
|
+
: {}),
|
|
1167
|
+
...(input.authorities !== undefined ? { authorities: input.authorities } : {}),
|
|
1168
|
+
...(input.methods !== undefined ? { methods: input.methods } : {}),
|
|
1169
|
+
...(input.preferredIdentityId !== undefined
|
|
1170
|
+
? { preferredIdentityId: input.preferredIdentityId }
|
|
1171
|
+
: {}),
|
|
1172
|
+
...(input.preferredPlacement !== undefined
|
|
1173
|
+
? { preferredPlacement: input.preferredPlacement }
|
|
1174
|
+
: {}),
|
|
1175
|
+
...(input.preferredNetworkRouteId !== undefined
|
|
1176
|
+
? { preferredNetworkRouteId: input.preferredNetworkRouteId }
|
|
1177
|
+
: {}),
|
|
1178
|
+
...(input.healthPolicy !== undefined ? { healthPolicy: input.healthPolicy } : {}),
|
|
1179
|
+
...(input.status !== undefined ? { status: input.status } : {}),
|
|
1180
|
+
});
|
|
1181
|
+
const {
|
|
1182
|
+
operationId: _operationId,
|
|
1183
|
+
expectedVersion: _expectedVersion,
|
|
1184
|
+
...candidatePatch
|
|
1185
|
+
} = request;
|
|
1186
|
+
const digest = operationDigest({
|
|
1187
|
+
version: 1,
|
|
1188
|
+
siteAuthConnectionId: input.siteAuthConnectionId,
|
|
1189
|
+
request: { expectedVersion: request.expectedVersion, ...candidatePatch },
|
|
1190
|
+
actorSubjectId: input.actorSubjectId,
|
|
1191
|
+
});
|
|
1192
|
+
try {
|
|
1193
|
+
return await withRlsContext(db, input, async (scopedDb) => {
|
|
1194
|
+
await assertWorkspaceAccount(scopedDb, input.accountId, input.workspaceId);
|
|
1195
|
+
await setSubjectRlsContext(scopedDb, input.actorSubjectId);
|
|
1196
|
+
await lockOperation(scopedDb, request.operationId);
|
|
1197
|
+
const existing = await loadOperation(scopedDb, input.workspaceId, request.operationId);
|
|
1198
|
+
if (existing) {
|
|
1199
|
+
assertOperation(existing, {
|
|
1200
|
+
resourceKind: "site_auth_connection",
|
|
1201
|
+
resourceId: input.siteAuthConnectionId,
|
|
1202
|
+
kind: "update",
|
|
1203
|
+
requestDigest: digest,
|
|
1204
|
+
actorSubjectId: input.actorSubjectId,
|
|
1205
|
+
});
|
|
1206
|
+
const response = SiteAuthConnectionMutationResponse.parse(existing.result);
|
|
1207
|
+
return { ...response, replayed: true };
|
|
1208
|
+
}
|
|
1209
|
+
const [current] = await scopedDb
|
|
1210
|
+
.select()
|
|
1211
|
+
.from(schema.siteAuthConnections)
|
|
1212
|
+
.where(
|
|
1213
|
+
and(
|
|
1214
|
+
eq(schema.siteAuthConnections.workspaceId, input.workspaceId),
|
|
1215
|
+
eq(schema.siteAuthConnections.id, input.siteAuthConnectionId),
|
|
1216
|
+
),
|
|
1217
|
+
)
|
|
1218
|
+
.for("update")
|
|
1219
|
+
.limit(1);
|
|
1220
|
+
if (!current) throw new InteractionResourceNotFoundError("Site auth connection not found");
|
|
1221
|
+
if (current.version !== request.expectedVersion) {
|
|
1222
|
+
throw new InteractionResourceConflictError(
|
|
1223
|
+
"Site auth connection changed before this update",
|
|
1224
|
+
);
|
|
1225
|
+
}
|
|
1226
|
+
const currentValue = siteAuthConnectionFromRow(current);
|
|
1227
|
+
const candidate = SiteAuthConnection.parse({
|
|
1228
|
+
...currentValue,
|
|
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,
|
|
1234
|
+
id: current.id,
|
|
1235
|
+
accountId: current.accountId,
|
|
1236
|
+
workspaceId: current.workspaceId,
|
|
1237
|
+
version: current.version + 1,
|
|
1238
|
+
createdBySubjectId: current.createdBySubjectId,
|
|
1239
|
+
createdAt: iso(current.createdAt),
|
|
1240
|
+
updatedAt: new Date().toISOString(),
|
|
1241
|
+
});
|
|
1242
|
+
await validateSiteAuthUpdateReferences(scopedDb, input.workspaceId, currentValue, candidate);
|
|
1243
|
+
const [row] = await scopedDb
|
|
1244
|
+
.update(schema.siteAuthConnections)
|
|
1245
|
+
.set({
|
|
1246
|
+
name: candidate.name,
|
|
1247
|
+
accountLabel: candidate.accountLabel,
|
|
1248
|
+
status: candidate.status,
|
|
1249
|
+
origins: candidate.origins,
|
|
1250
|
+
loginUrl: candidate.loginUrl,
|
|
1251
|
+
verificationUrlPrefixes: candidate.verificationUrlPrefixes,
|
|
1252
|
+
authorities: candidate.authorities,
|
|
1253
|
+
methods: candidate.methods,
|
|
1254
|
+
preferredIdentityId: candidate.preferredIdentityId,
|
|
1255
|
+
preferredPlacement: candidate.preferredPlacement,
|
|
1256
|
+
preferredNetworkRouteId: candidate.preferredNetworkRouteId,
|
|
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,
|
|
1269
|
+
version: candidate.version,
|
|
1270
|
+
updatedBySubjectId: input.actorSubjectId,
|
|
1271
|
+
updatedAt: sql`now()`,
|
|
1272
|
+
})
|
|
1273
|
+
.where(
|
|
1274
|
+
and(
|
|
1275
|
+
eq(schema.siteAuthConnections.workspaceId, input.workspaceId),
|
|
1276
|
+
eq(schema.siteAuthConnections.id, input.siteAuthConnectionId),
|
|
1277
|
+
eq(schema.siteAuthConnections.version, request.expectedVersion),
|
|
1278
|
+
),
|
|
1279
|
+
)
|
|
1280
|
+
.returning();
|
|
1281
|
+
if (!row) {
|
|
1282
|
+
throw new InteractionResourceConflictError("Site auth connection update lost its fence");
|
|
1283
|
+
}
|
|
1284
|
+
const response = SiteAuthConnectionMutationResponse.parse({
|
|
1285
|
+
connection: siteAuthConnectionFromRow(row),
|
|
1286
|
+
operationId: request.operationId,
|
|
1287
|
+
replayed: false,
|
|
1288
|
+
});
|
|
1289
|
+
await insertCompletedOperation(scopedDb, {
|
|
1290
|
+
operationId: request.operationId,
|
|
1291
|
+
accountId: input.accountId,
|
|
1292
|
+
workspaceId: input.workspaceId,
|
|
1293
|
+
resourceKind: "site_auth_connection",
|
|
1294
|
+
resourceId: input.siteAuthConnectionId,
|
|
1295
|
+
kind: "update",
|
|
1296
|
+
requestDigest: digest,
|
|
1297
|
+
resultVersion: row.version,
|
|
1298
|
+
result: response,
|
|
1299
|
+
actorSubjectId: input.actorSubjectId,
|
|
1300
|
+
});
|
|
1301
|
+
await advanceWorkspaceInteractionRevision(scopedDb, input.accountId, input.workspaceId);
|
|
1302
|
+
return response;
|
|
1303
|
+
});
|
|
1304
|
+
} catch (error) {
|
|
1305
|
+
if (postgresConstraint(error) === "site_auth_connections_workspace_active_name_uq") {
|
|
1306
|
+
throw new InteractionResourceConflictError(
|
|
1307
|
+
"An active site auth connection already uses this name",
|
|
1308
|
+
);
|
|
1309
|
+
}
|
|
1310
|
+
throw error;
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
function authRunFromRow(row: AuthRunRow): AuthRunValue {
|
|
1315
|
+
return AuthRun.parse({
|
|
1316
|
+
id: row.id,
|
|
1317
|
+
accountId: row.accountId,
|
|
1318
|
+
workspaceId: row.workspaceId,
|
|
1319
|
+
siteAuthConnectionId: row.siteAuthConnectionId,
|
|
1320
|
+
browserSessionId: row.browserSessionId,
|
|
1321
|
+
targetId: row.targetId,
|
|
1322
|
+
controllerGeneration: row.controllerGeneration,
|
|
1323
|
+
targetGeneration: row.targetGeneration,
|
|
1324
|
+
documentGeneration: row.documentGeneration,
|
|
1325
|
+
purpose: row.purpose,
|
|
1326
|
+
methodId: row.methodId,
|
|
1327
|
+
authorityId: row.authorityId,
|
|
1328
|
+
state: row.state,
|
|
1329
|
+
choices: row.choices,
|
|
1330
|
+
pendingFields: row.pendingFields,
|
|
1331
|
+
externalAction: row.externalAction,
|
|
1332
|
+
interventionId: row.interventionId,
|
|
1333
|
+
verifiedUrl: row.verifiedUrl,
|
|
1334
|
+
failureCode: row.failureCode,
|
|
1335
|
+
version: row.version,
|
|
1336
|
+
operationId: row.operationId,
|
|
1337
|
+
createdBySubjectId: row.createdBySubjectId,
|
|
1338
|
+
createdAt: iso(row.createdAt),
|
|
1339
|
+
updatedAt: iso(row.updatedAt),
|
|
1340
|
+
settledAt: row.settledAt ? iso(row.settledAt) : null,
|
|
1341
|
+
});
|
|
1342
|
+
}
|
|
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
|
+
|
|
1449
|
+
async function loadAuthRunRow(
|
|
1450
|
+
db: Database,
|
|
1451
|
+
workspaceId: string,
|
|
1452
|
+
authRunId: string,
|
|
1453
|
+
): Promise<AuthRunRow | null> {
|
|
1454
|
+
const [row] = await db
|
|
1455
|
+
.select()
|
|
1456
|
+
.from(schema.authRuns)
|
|
1457
|
+
.where(and(eq(schema.authRuns.workspaceId, workspaceId), eq(schema.authRuns.id, authRunId)))
|
|
1458
|
+
.limit(1);
|
|
1459
|
+
return row ?? null;
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
function assertAuthSelection(
|
|
1463
|
+
connection: SiteAuthConnectionValue,
|
|
1464
|
+
selection: { methodId?: string | null; authorityId?: string | null },
|
|
1465
|
+
): void {
|
|
1466
|
+
const method = selection.methodId
|
|
1467
|
+
? connection.methods.find((candidate) => candidate.id === selection.methodId)
|
|
1468
|
+
: null;
|
|
1469
|
+
const authority = selection.authorityId
|
|
1470
|
+
? connection.authorities.find((candidate) => candidate.id === selection.authorityId)
|
|
1471
|
+
: null;
|
|
1472
|
+
if (selection.methodId && !method) {
|
|
1473
|
+
throw new InteractionResourceStateError("Auth method is not configured");
|
|
1474
|
+
}
|
|
1475
|
+
if (selection.authorityId && !authority) {
|
|
1476
|
+
throw new InteractionResourceStateError("Auth authority is not configured");
|
|
1477
|
+
}
|
|
1478
|
+
if (method && authority && !method.authorityIds.includes(authority.id)) {
|
|
1479
|
+
throw new InteractionResourceStateError(
|
|
1480
|
+
"Auth authority does not belong to the selected method",
|
|
1481
|
+
);
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
const AUTH_RUN_TRANSITIONS: Readonly<
|
|
1486
|
+
Record<AuthRunRow["state"], ReadonlySet<AuthRunRow["state"]>>
|
|
1487
|
+
> = {
|
|
1488
|
+
discovering: new Set([
|
|
1489
|
+
"awaiting_choice",
|
|
1490
|
+
"awaiting_secret",
|
|
1491
|
+
"awaiting_external_action",
|
|
1492
|
+
"working",
|
|
1493
|
+
"failed",
|
|
1494
|
+
"cancelled",
|
|
1495
|
+
]),
|
|
1496
|
+
awaiting_choice: new Set([
|
|
1497
|
+
"awaiting_choice",
|
|
1498
|
+
"awaiting_secret",
|
|
1499
|
+
"awaiting_external_action",
|
|
1500
|
+
"working",
|
|
1501
|
+
"failed",
|
|
1502
|
+
"cancelled",
|
|
1503
|
+
]),
|
|
1504
|
+
awaiting_secret: new Set([
|
|
1505
|
+
"awaiting_secret",
|
|
1506
|
+
"awaiting_external_action",
|
|
1507
|
+
"working",
|
|
1508
|
+
"failed",
|
|
1509
|
+
"cancelled",
|
|
1510
|
+
]),
|
|
1511
|
+
awaiting_external_action: new Set([
|
|
1512
|
+
"awaiting_external_action",
|
|
1513
|
+
"awaiting_secret",
|
|
1514
|
+
"working",
|
|
1515
|
+
"failed",
|
|
1516
|
+
"cancelled",
|
|
1517
|
+
]),
|
|
1518
|
+
working: new Set([
|
|
1519
|
+
"working",
|
|
1520
|
+
"awaiting_choice",
|
|
1521
|
+
"awaiting_secret",
|
|
1522
|
+
"awaiting_external_action",
|
|
1523
|
+
"failed",
|
|
1524
|
+
"cancelled",
|
|
1525
|
+
]),
|
|
1526
|
+
verified: new Set(),
|
|
1527
|
+
failed: new Set(),
|
|
1528
|
+
cancelled: new Set(),
|
|
1529
|
+
};
|
|
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
|
+
|
|
1799
|
+
export async function listAuthRuns(
|
|
1800
|
+
db: Database,
|
|
1801
|
+
input: {
|
|
1802
|
+
accountId: string;
|
|
1803
|
+
workspaceId: string;
|
|
1804
|
+
browserSessionId?: string;
|
|
1805
|
+
siteAuthConnectionId?: string;
|
|
1806
|
+
includeSettled?: boolean;
|
|
1807
|
+
},
|
|
1808
|
+
): Promise<AuthRunListResponseValue> {
|
|
1809
|
+
return await withRlsContext(
|
|
1810
|
+
db,
|
|
1811
|
+
input,
|
|
1812
|
+
async (scopedDb) => {
|
|
1813
|
+
const predicates = [eq(schema.authRuns.workspaceId, input.workspaceId)];
|
|
1814
|
+
if (input.browserSessionId) {
|
|
1815
|
+
predicates.push(eq(schema.authRuns.browserSessionId, input.browserSessionId));
|
|
1816
|
+
}
|
|
1817
|
+
if (input.siteAuthConnectionId) {
|
|
1818
|
+
predicates.push(eq(schema.authRuns.siteAuthConnectionId, input.siteAuthConnectionId));
|
|
1819
|
+
}
|
|
1820
|
+
if (!input.includeSettled) predicates.push(sql`${schema.authRuns.settledAt} is null`);
|
|
1821
|
+
const runs = await scopedDb
|
|
1822
|
+
.select()
|
|
1823
|
+
.from(schema.authRuns)
|
|
1824
|
+
.where(and(...predicates))
|
|
1825
|
+
.orderBy(asc(schema.authRuns.createdAt), asc(schema.authRuns.id));
|
|
1826
|
+
return AuthRunListResponse.parse({ runs: runs.map(authRunFromRow) });
|
|
1827
|
+
},
|
|
1828
|
+
CONSISTENT_READ,
|
|
1829
|
+
);
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
export async function getAuthRun(
|
|
1833
|
+
db: Database,
|
|
1834
|
+
input: { accountId: string; workspaceId: string; authRunId: string },
|
|
1835
|
+
): Promise<AuthRunValue> {
|
|
1836
|
+
return await withRlsContext(
|
|
1837
|
+
db,
|
|
1838
|
+
input,
|
|
1839
|
+
async (scopedDb) => {
|
|
1840
|
+
const row = await loadAuthRunRow(scopedDb, input.workspaceId, input.authRunId);
|
|
1841
|
+
if (!row) throw new InteractionResourceNotFoundError("Auth run not found");
|
|
1842
|
+
return authRunFromRow(row);
|
|
1843
|
+
},
|
|
1844
|
+
CONSISTENT_READ,
|
|
1845
|
+
);
|
|
1846
|
+
}
|
|
1847
|
+
|
|
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(
|
|
1856
|
+
db: Database,
|
|
1857
|
+
input: InteractionMutationScope & {
|
|
1858
|
+
authRunId: string;
|
|
1859
|
+
} & ExternalAuthInteractiveRequestValue,
|
|
1860
|
+
): Promise<ExternalAuthInteractiveContext> {
|
|
1861
|
+
const request = ExternalAuthInteractiveRequest.parse({
|
|
1862
|
+
operationId: input.operationId,
|
|
1863
|
+
expectedVersion: input.expectedVersion,
|
|
1864
|
+
});
|
|
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);
|
|
1954
|
+
if (existing) {
|
|
1955
|
+
assertOperation(existing, {
|
|
1956
|
+
resourceKind: "auth_run",
|
|
1957
|
+
kind: "start",
|
|
1958
|
+
requestDigest: digest,
|
|
1959
|
+
actorSubjectId: input.actorSubjectId,
|
|
1960
|
+
});
|
|
1961
|
+
const response = AuthRunMutationResponse.parse(existing.result);
|
|
1962
|
+
return { ...response, replayed: true };
|
|
1963
|
+
}
|
|
1964
|
+
const [browser] = await scopedDb
|
|
1965
|
+
.select()
|
|
1966
|
+
.from(schema.browserSessions)
|
|
1967
|
+
.where(
|
|
1968
|
+
and(
|
|
1969
|
+
eq(schema.browserSessions.workspaceId, input.workspaceId),
|
|
1970
|
+
eq(schema.browserSessions.id, input.browserSessionId),
|
|
1971
|
+
),
|
|
1972
|
+
)
|
|
1973
|
+
.for("update")
|
|
1974
|
+
.limit(1);
|
|
1975
|
+
if (!browser) throw new InteractionResourceNotFoundError("Browser session not found");
|
|
1976
|
+
if (
|
|
1977
|
+
browser.lifecycle !== "active" ||
|
|
1978
|
+
!browser.controllerGeneration ||
|
|
1979
|
+
browser.controllerGeneration !== input.controllerGeneration
|
|
1980
|
+
) {
|
|
1981
|
+
throw new InteractionResourceStateError("Browser session is not active");
|
|
1982
|
+
}
|
|
1983
|
+
const authConnection = await getSiteAuthConnectionRow(
|
|
1984
|
+
scopedDb,
|
|
1985
|
+
input.workspaceId,
|
|
1986
|
+
request.siteAuthConnectionId,
|
|
1987
|
+
);
|
|
1988
|
+
if (authConnection.status !== "active") {
|
|
1989
|
+
throw new InteractionResourceStateError("Site auth connection is archived");
|
|
1990
|
+
}
|
|
1991
|
+
const connection = siteAuthConnectionFromRow(authConnection);
|
|
1992
|
+
assertAuthSelection(connection, {
|
|
1993
|
+
methodId: request.methodId ?? null,
|
|
1994
|
+
authorityId: request.authorityId ?? null,
|
|
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
|
+
}
|
|
2045
|
+
const id = randomUUID();
|
|
2046
|
+
const [row] = await scopedDb
|
|
2047
|
+
.insert(schema.authRuns)
|
|
2048
|
+
.values({
|
|
2049
|
+
id,
|
|
2050
|
+
accountId: input.accountId,
|
|
2051
|
+
workspaceId: input.workspaceId,
|
|
2052
|
+
siteAuthConnectionId: request.siteAuthConnectionId,
|
|
2053
|
+
browserSessionId: input.browserSessionId,
|
|
2054
|
+
targetId: request.targetId,
|
|
2055
|
+
controllerGeneration: browser.controllerGeneration,
|
|
2056
|
+
targetGeneration: request.expectedTargetGeneration,
|
|
2057
|
+
documentGeneration: request.expectedDocumentGeneration,
|
|
2058
|
+
purpose: request.purpose ?? "authenticate",
|
|
2059
|
+
maintenanceOperationId,
|
|
2060
|
+
methodId: request.methodId ?? null,
|
|
2061
|
+
authorityId: request.authorityId ?? null,
|
|
2062
|
+
operationId: request.operationId,
|
|
2063
|
+
createdBySubjectId: input.actorSubjectId,
|
|
2064
|
+
})
|
|
2065
|
+
.returning();
|
|
2066
|
+
if (!row) throw new Error("Auth run insert returned no row");
|
|
2067
|
+
const response = AuthRunMutationResponse.parse({
|
|
2068
|
+
run: authRunFromRow(row),
|
|
2069
|
+
operationId: request.operationId,
|
|
2070
|
+
replayed: false,
|
|
2071
|
+
});
|
|
2072
|
+
await insertCompletedOperation(scopedDb, {
|
|
2073
|
+
operationId: request.operationId,
|
|
2074
|
+
accountId: input.accountId,
|
|
2075
|
+
workspaceId: input.workspaceId,
|
|
2076
|
+
resourceKind: "auth_run",
|
|
2077
|
+
resourceId: id,
|
|
2078
|
+
kind: "start",
|
|
2079
|
+
requestDigest: digest,
|
|
2080
|
+
resultVersion: row.version,
|
|
2081
|
+
result: response,
|
|
2082
|
+
actorSubjectId: input.actorSubjectId,
|
|
2083
|
+
});
|
|
2084
|
+
await advanceWorkspaceInteractionRevision(scopedDb, input.accountId, input.workspaceId);
|
|
2085
|
+
return response;
|
|
2086
|
+
});
|
|
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
|
+
}
|
|
2093
|
+
if (postgresConstraint(error) === "auth_runs_active_browser_target_uq") {
|
|
2094
|
+
throw new InteractionResourceConflictError(
|
|
2095
|
+
"This browser target already has an active auth run",
|
|
2096
|
+
);
|
|
2097
|
+
}
|
|
2098
|
+
throw error;
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
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
|
+
|
|
2227
|
+
export async function reportAuthRun(
|
|
2228
|
+
db: Database,
|
|
2229
|
+
input: InteractionMutationScope & {
|
|
2230
|
+
authRunId: string;
|
|
2231
|
+
controllerGeneration: string;
|
|
2232
|
+
} & ReportAuthRunRequestValue,
|
|
2233
|
+
): Promise<AuthRunMutationResponseValue> {
|
|
2234
|
+
const request = ReportAuthRunRequest.parse({
|
|
2235
|
+
operationId: input.operationId,
|
|
2236
|
+
expectedVersion: input.expectedVersion,
|
|
2237
|
+
...(input.methodId !== undefined ? { methodId: input.methodId } : {}),
|
|
2238
|
+
...(input.authorityId !== undefined ? { authorityId: input.authorityId } : {}),
|
|
2239
|
+
state: input.state,
|
|
2240
|
+
...(input.choices !== undefined ? { choices: input.choices } : {}),
|
|
2241
|
+
...(input.pendingFields !== undefined ? { pendingFields: input.pendingFields } : {}),
|
|
2242
|
+
...(input.externalAction !== undefined ? { externalAction: input.externalAction } : {}),
|
|
2243
|
+
...(input.failureCode !== undefined ? { failureCode: input.failureCode } : {}),
|
|
2244
|
+
});
|
|
2245
|
+
const { operationId: _operationId, ...digestRequest } = request;
|
|
2246
|
+
const digest = operationDigest({
|
|
2247
|
+
version: 1,
|
|
2248
|
+
authRunId: input.authRunId,
|
|
2249
|
+
controllerGeneration: input.controllerGeneration,
|
|
2250
|
+
request: digestRequest,
|
|
2251
|
+
actorSubjectId: input.actorSubjectId,
|
|
2252
|
+
});
|
|
2253
|
+
return await withRlsContext(db, input, async (scopedDb) => {
|
|
2254
|
+
await assertWorkspaceAccount(scopedDb, input.accountId, input.workspaceId);
|
|
2255
|
+
await lockOperation(scopedDb, request.operationId);
|
|
2256
|
+
const existing = await loadOperation(scopedDb, input.workspaceId, request.operationId);
|
|
2257
|
+
if (existing) {
|
|
2258
|
+
assertOperation(existing, {
|
|
2259
|
+
resourceKind: "auth_run",
|
|
2260
|
+
resourceId: input.authRunId,
|
|
2261
|
+
kind: "report",
|
|
2262
|
+
requestDigest: digest,
|
|
2263
|
+
actorSubjectId: input.actorSubjectId,
|
|
2264
|
+
});
|
|
2265
|
+
const response = AuthRunMutationResponse.parse(existing.result);
|
|
2266
|
+
return { ...response, replayed: true };
|
|
2267
|
+
}
|
|
2268
|
+
const [current] = await scopedDb
|
|
2269
|
+
.select()
|
|
2270
|
+
.from(schema.authRuns)
|
|
2271
|
+
.where(
|
|
2272
|
+
and(
|
|
2273
|
+
eq(schema.authRuns.workspaceId, input.workspaceId),
|
|
2274
|
+
eq(schema.authRuns.id, input.authRunId),
|
|
2275
|
+
),
|
|
2276
|
+
)
|
|
2277
|
+
.for("update")
|
|
2278
|
+
.limit(1);
|
|
2279
|
+
if (!current) throw new InteractionResourceNotFoundError("Auth run not found");
|
|
2280
|
+
if (current.controllerGeneration !== input.controllerGeneration) {
|
|
2281
|
+
throw new InteractionResourceConflictError("Auth run belongs to a stale browser controller");
|
|
2282
|
+
}
|
|
2283
|
+
if (current.version !== request.expectedVersion) {
|
|
2284
|
+
throw new InteractionResourceConflictError("Auth run changed before this report");
|
|
2285
|
+
}
|
|
2286
|
+
if (!AUTH_RUN_TRANSITIONS[current.state].has(request.state)) {
|
|
2287
|
+
throw new InteractionResourceStateError(
|
|
2288
|
+
`Auth run cannot transition from ${current.state} to ${request.state}`,
|
|
2289
|
+
);
|
|
2290
|
+
}
|
|
2291
|
+
const authConnection = await getSiteAuthConnectionRow(
|
|
2292
|
+
scopedDb,
|
|
2293
|
+
input.workspaceId,
|
|
2294
|
+
current.siteAuthConnectionId,
|
|
2295
|
+
);
|
|
2296
|
+
assertAuthSelection(siteAuthConnectionFromRow(authConnection), {
|
|
2297
|
+
methodId: request.methodId ?? current.methodId,
|
|
2298
|
+
authorityId: request.authorityId ?? current.authorityId,
|
|
2299
|
+
});
|
|
2300
|
+
const settled = request.state === "failed" || request.state === "cancelled";
|
|
2301
|
+
const [row] = await scopedDb
|
|
2302
|
+
.update(schema.authRuns)
|
|
2303
|
+
.set({
|
|
2304
|
+
methodId: request.methodId ?? current.methodId,
|
|
2305
|
+
authorityId: request.authorityId ?? current.authorityId,
|
|
2306
|
+
state: request.state,
|
|
2307
|
+
choices: request.choices ?? [],
|
|
2308
|
+
pendingFields: request.pendingFields ?? [],
|
|
2309
|
+
externalAction: request.externalAction ?? null,
|
|
2310
|
+
failureCode: request.state === "failed" ? request.failureCode! : null,
|
|
2311
|
+
version: current.version + 1,
|
|
2312
|
+
updatedAt: sql`now()`,
|
|
2313
|
+
settledAt: settled ? sql`now()` : null,
|
|
2314
|
+
})
|
|
2315
|
+
.where(
|
|
2316
|
+
and(
|
|
2317
|
+
eq(schema.authRuns.workspaceId, input.workspaceId),
|
|
2318
|
+
eq(schema.authRuns.id, input.authRunId),
|
|
2319
|
+
eq(schema.authRuns.version, request.expectedVersion),
|
|
2320
|
+
eq(schema.authRuns.controllerGeneration, input.controllerGeneration),
|
|
2321
|
+
),
|
|
2322
|
+
)
|
|
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
|
+
}
|
|
3474
|
+
const response = AuthRunMutationResponse.parse({
|
|
3475
|
+
run: authRunFromRow(resultRun),
|
|
3476
|
+
operationId: request.operationId,
|
|
3477
|
+
replayed: false,
|
|
3478
|
+
});
|
|
3479
|
+
await insertCompletedOperation(scopedDb, {
|
|
3480
|
+
operationId: request.operationId,
|
|
3481
|
+
accountId: input.accountId,
|
|
3482
|
+
workspaceId: input.workspaceId,
|
|
3483
|
+
resourceKind: "auth_run",
|
|
3484
|
+
resourceId: input.authRunId,
|
|
3485
|
+
kind: "verify",
|
|
3486
|
+
requestDigest: digest,
|
|
3487
|
+
resultVersion: resultRun.version,
|
|
3488
|
+
result: response,
|
|
3489
|
+
actorSubjectId: input.actorSubjectId,
|
|
3490
|
+
});
|
|
3491
|
+
if (verified && !run.settledAt) {
|
|
3492
|
+
await advanceWorkspaceInteractionRevision(scopedDb, input.accountId, input.workspaceId);
|
|
3493
|
+
}
|
|
3494
|
+
return response;
|
|
3495
|
+
});
|
|
3496
|
+
}
|
|
3497
|
+
|
|
3498
|
+
async function getSiteAuthConnectionRow(
|
|
3499
|
+
db: Database,
|
|
3500
|
+
workspaceId: string,
|
|
3501
|
+
siteAuthConnectionId: string,
|
|
3502
|
+
): Promise<SiteAuthConnectionRow> {
|
|
3503
|
+
const [row] = await db
|
|
3504
|
+
.select()
|
|
3505
|
+
.from(schema.siteAuthConnections)
|
|
3506
|
+
.where(
|
|
3507
|
+
and(
|
|
3508
|
+
eq(schema.siteAuthConnections.workspaceId, workspaceId),
|
|
3509
|
+
eq(schema.siteAuthConnections.id, siteAuthConnectionId),
|
|
3510
|
+
),
|
|
3511
|
+
)
|
|
3512
|
+
.limit(1);
|
|
3513
|
+
if (!row) throw new InteractionResourceNotFoundError("Site auth connection not found");
|
|
3514
|
+
return row;
|
|
3515
|
+
}
|
|
3516
|
+
|
|
3517
|
+
function interventionFromRow(row: InterventionRow): InteractionInterventionValue {
|
|
3518
|
+
return InteractionIntervention.parse({
|
|
3519
|
+
id: row.id,
|
|
3520
|
+
accountId: row.accountId,
|
|
3521
|
+
workspaceId: row.workspaceId,
|
|
3522
|
+
resourceKind: row.resourceKind,
|
|
3523
|
+
resourceId: row.resourceId,
|
|
3524
|
+
targetId: row.targetId,
|
|
3525
|
+
controllerGeneration: row.controllerGeneration,
|
|
3526
|
+
targetGeneration: row.targetGeneration,
|
|
3527
|
+
documentGeneration: row.documentGeneration,
|
|
3528
|
+
kind: row.kind,
|
|
3529
|
+
reason: row.reason,
|
|
3530
|
+
status: row.status,
|
|
3531
|
+
authRunId: row.authRunId,
|
|
3532
|
+
originatingSessionId: row.originatingSessionId,
|
|
3533
|
+
originatingTurnId: row.originatingTurnId,
|
|
3534
|
+
originatingAttemptId: row.originatingAttemptId,
|
|
3535
|
+
originatingToolOperationId: row.originatingToolOperationId,
|
|
3536
|
+
responseActorSubjectId: row.responseActorSubjectId,
|
|
3537
|
+
version: row.version,
|
|
3538
|
+
operationId: row.operationId,
|
|
3539
|
+
expiresAt: iso(row.expiresAt),
|
|
3540
|
+
createdAt: iso(row.createdAt),
|
|
3541
|
+
updatedAt: iso(row.updatedAt),
|
|
3542
|
+
settledAt: row.settledAt ? iso(row.settledAt) : null,
|
|
3543
|
+
});
|
|
3544
|
+
}
|
|
3545
|
+
|
|
3546
|
+
async function loadInterventionRow(
|
|
3547
|
+
db: Database,
|
|
3548
|
+
workspaceId: string,
|
|
3549
|
+
interventionId: string,
|
|
3550
|
+
): Promise<InterventionRow | null> {
|
|
3551
|
+
const [row] = await db
|
|
3552
|
+
.select()
|
|
3553
|
+
.from(schema.interactionInterventions)
|
|
3554
|
+
.where(
|
|
3555
|
+
and(
|
|
3556
|
+
eq(schema.interactionInterventions.workspaceId, workspaceId),
|
|
3557
|
+
eq(schema.interactionInterventions.id, interventionId),
|
|
3558
|
+
),
|
|
3559
|
+
)
|
|
3560
|
+
.limit(1);
|
|
3561
|
+
return row ?? null;
|
|
3562
|
+
}
|
|
3563
|
+
|
|
3564
|
+
async function assertActiveInteractionResource(
|
|
3565
|
+
db: Database,
|
|
3566
|
+
input: {
|
|
3567
|
+
workspaceId: string;
|
|
3568
|
+
resourceKind: "browser_session" | "computer_session";
|
|
3569
|
+
resourceId: string;
|
|
3570
|
+
expectedControllerGeneration: string;
|
|
3571
|
+
},
|
|
3572
|
+
): Promise<void> {
|
|
3573
|
+
if (input.resourceKind === "browser_session") {
|
|
3574
|
+
const [resource] = await db
|
|
3575
|
+
.select({
|
|
3576
|
+
lifecycle: schema.browserSessions.lifecycle,
|
|
3577
|
+
controllerGeneration: schema.browserSessions.controllerGeneration,
|
|
3578
|
+
})
|
|
3579
|
+
.from(schema.browserSessions)
|
|
3580
|
+
.where(
|
|
3581
|
+
and(
|
|
3582
|
+
eq(schema.browserSessions.workspaceId, input.workspaceId),
|
|
3583
|
+
eq(schema.browserSessions.id, input.resourceId),
|
|
3584
|
+
),
|
|
3585
|
+
)
|
|
3586
|
+
.for("update")
|
|
3587
|
+
.limit(1);
|
|
3588
|
+
if (!resource) throw new InteractionResourceNotFoundError("Browser session not found");
|
|
3589
|
+
if (resource.lifecycle !== "active" || !resource.controllerGeneration) {
|
|
3590
|
+
throw new InteractionResourceStateError("Browser session is not active");
|
|
3591
|
+
}
|
|
3592
|
+
if (resource.controllerGeneration !== input.expectedControllerGeneration) {
|
|
3593
|
+
throw new InteractionResourceConflictError(
|
|
3594
|
+
"Browser intervention belongs to a stale controller",
|
|
3595
|
+
);
|
|
3596
|
+
}
|
|
3597
|
+
return;
|
|
3598
|
+
}
|
|
3599
|
+
const [resource] = await db
|
|
3600
|
+
.select({
|
|
3601
|
+
lifecycle: schema.computerSessions.lifecycle,
|
|
3602
|
+
controllerGeneration: schema.computerSessions.controllerGeneration,
|
|
3603
|
+
})
|
|
3604
|
+
.from(schema.computerSessions)
|
|
3605
|
+
.where(
|
|
3606
|
+
and(
|
|
3607
|
+
eq(schema.computerSessions.workspaceId, input.workspaceId),
|
|
3608
|
+
eq(schema.computerSessions.id, input.resourceId),
|
|
3609
|
+
),
|
|
3610
|
+
)
|
|
3611
|
+
.for("update")
|
|
3612
|
+
.limit(1);
|
|
3613
|
+
if (!resource) throw new InteractionResourceNotFoundError("Computer session not found");
|
|
3614
|
+
if (resource.lifecycle !== "active" || !resource.controllerGeneration) {
|
|
3615
|
+
throw new InteractionResourceStateError("Computer session is not active");
|
|
3616
|
+
}
|
|
3617
|
+
if (resource.controllerGeneration !== input.expectedControllerGeneration) {
|
|
3618
|
+
throw new InteractionResourceConflictError(
|
|
3619
|
+
"Computer intervention belongs to a stale controller",
|
|
3620
|
+
);
|
|
3621
|
+
}
|
|
3622
|
+
}
|
|
3623
|
+
|
|
3624
|
+
async function settleLinkedAuthRun(
|
|
3625
|
+
db: Database,
|
|
3626
|
+
input: {
|
|
3627
|
+
workspaceId: string;
|
|
3628
|
+
authRunId: string | null;
|
|
3629
|
+
interventionId: string;
|
|
3630
|
+
outcome: "completed" | "dismissed" | "expired" | "cancelled";
|
|
3631
|
+
},
|
|
3632
|
+
): Promise<void> {
|
|
3633
|
+
if (!input.authRunId) return;
|
|
3634
|
+
const [run] = await db
|
|
3635
|
+
.select()
|
|
3636
|
+
.from(schema.authRuns)
|
|
3637
|
+
.where(
|
|
3638
|
+
and(
|
|
3639
|
+
eq(schema.authRuns.workspaceId, input.workspaceId),
|
|
3640
|
+
eq(schema.authRuns.id, input.authRunId),
|
|
3641
|
+
),
|
|
3642
|
+
)
|
|
3643
|
+
.for("update")
|
|
3644
|
+
.limit(1);
|
|
3645
|
+
if (!run || run.interventionId !== input.interventionId || run.settledAt) return;
|
|
3646
|
+
const resumed = input.outcome === "completed";
|
|
3647
|
+
const [settled] = await db
|
|
3648
|
+
.update(schema.authRuns)
|
|
3649
|
+
.set({
|
|
3650
|
+
state: resumed ? "working" : input.outcome === "cancelled" ? "cancelled" : "failed",
|
|
3651
|
+
choices: [],
|
|
3652
|
+
pendingFields: [],
|
|
3653
|
+
externalAction: null,
|
|
3654
|
+
interventionId: null,
|
|
3655
|
+
failureCode:
|
|
3656
|
+
resumed || input.outcome === "cancelled" ? null : `intervention_${input.outcome}`,
|
|
3657
|
+
version: run.version + 1,
|
|
3658
|
+
updatedAt: sql`now()`,
|
|
3659
|
+
settledAt: resumed ? null : sql`now()`,
|
|
3660
|
+
})
|
|
3661
|
+
.where(
|
|
3662
|
+
and(
|
|
3663
|
+
eq(schema.authRuns.workspaceId, input.workspaceId),
|
|
3664
|
+
eq(schema.authRuns.id, input.authRunId),
|
|
3665
|
+
eq(schema.authRuns.version, run.version),
|
|
3666
|
+
eq(schema.authRuns.interventionId, input.interventionId),
|
|
3667
|
+
),
|
|
3668
|
+
)
|
|
3669
|
+
.returning();
|
|
3670
|
+
if (!settled) {
|
|
3671
|
+
throw new InteractionResourceConflictError(
|
|
3672
|
+
"Linked auth run changed before intervention settlement",
|
|
3673
|
+
);
|
|
3674
|
+
}
|
|
3675
|
+
await projectSettledAuthRunHealth(db, {
|
|
3676
|
+
run: settled,
|
|
3677
|
+
actorSubjectId: run.createdBySubjectId,
|
|
3678
|
+
});
|
|
3679
|
+
}
|
|
3680
|
+
|
|
3681
|
+
async function expireInterventionsInScope(
|
|
3682
|
+
db: Database,
|
|
3683
|
+
input: { accountId: string; workspaceId: string },
|
|
3684
|
+
): Promise<number> {
|
|
3685
|
+
const expired = await db
|
|
3686
|
+
.update(schema.interactionInterventions)
|
|
3687
|
+
.set({
|
|
3688
|
+
status: "expired",
|
|
3689
|
+
version: sql`${schema.interactionInterventions.version} + 1`,
|
|
3690
|
+
updatedAt: sql`now()`,
|
|
3691
|
+
settledAt: sql`now()`,
|
|
3692
|
+
})
|
|
3693
|
+
.where(
|
|
3694
|
+
and(
|
|
3695
|
+
eq(schema.interactionInterventions.workspaceId, input.workspaceId),
|
|
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`,
|
|
3701
|
+
sql`${schema.interactionInterventions.expiresAt} <= now()`,
|
|
3702
|
+
),
|
|
3703
|
+
)
|
|
3704
|
+
.returning({
|
|
3705
|
+
id: schema.interactionInterventions.id,
|
|
3706
|
+
authRunId: schema.interactionInterventions.authRunId,
|
|
3707
|
+
});
|
|
3708
|
+
for (const intervention of expired) {
|
|
3709
|
+
await settleLinkedAuthRun(db, {
|
|
3710
|
+
workspaceId: input.workspaceId,
|
|
3711
|
+
authRunId: intervention.authRunId,
|
|
3712
|
+
interventionId: intervention.id,
|
|
3713
|
+
outcome: "expired",
|
|
3714
|
+
});
|
|
3715
|
+
}
|
|
3716
|
+
if (expired.length > 0) {
|
|
3717
|
+
await advanceWorkspaceInteractionRevision(db, input.accountId, input.workspaceId);
|
|
3718
|
+
}
|
|
3719
|
+
return expired.length;
|
|
3720
|
+
}
|
|
3721
|
+
|
|
3722
|
+
export async function listInteractionInterventions(
|
|
3723
|
+
db: Database,
|
|
3724
|
+
input: {
|
|
3725
|
+
accountId: string;
|
|
3726
|
+
workspaceId: string;
|
|
3727
|
+
resourceKind?: "browser_session" | "computer_session";
|
|
3728
|
+
resourceId?: string;
|
|
3729
|
+
includeSettled?: boolean;
|
|
3730
|
+
},
|
|
3731
|
+
): Promise<InteractionInterventionListResponseValue> {
|
|
3732
|
+
return await withRlsContext(db, input, async (scopedDb) => {
|
|
3733
|
+
await expireInterventionsInScope(scopedDb, input);
|
|
3734
|
+
const predicates = [eq(schema.interactionInterventions.workspaceId, input.workspaceId)];
|
|
3735
|
+
if (input.resourceKind) {
|
|
3736
|
+
predicates.push(eq(schema.interactionInterventions.resourceKind, input.resourceKind));
|
|
3737
|
+
}
|
|
3738
|
+
if (input.resourceId) {
|
|
3739
|
+
predicates.push(eq(schema.interactionInterventions.resourceId, input.resourceId));
|
|
3740
|
+
}
|
|
3741
|
+
if (!input.includeSettled) {
|
|
3742
|
+
predicates.push(eq(schema.interactionInterventions.status, "open"));
|
|
3743
|
+
}
|
|
3744
|
+
const interventions = await scopedDb
|
|
3745
|
+
.select()
|
|
3746
|
+
.from(schema.interactionInterventions)
|
|
3747
|
+
.where(and(...predicates))
|
|
3748
|
+
.orderBy(
|
|
3749
|
+
asc(schema.interactionInterventions.createdAt),
|
|
3750
|
+
asc(schema.interactionInterventions.id),
|
|
3751
|
+
);
|
|
3752
|
+
return InteractionInterventionListResponse.parse({
|
|
3753
|
+
interventions: interventions.map(interventionFromRow),
|
|
3754
|
+
});
|
|
3755
|
+
});
|
|
3756
|
+
}
|
|
3757
|
+
|
|
3758
|
+
export async function getInteractionIntervention(
|
|
3759
|
+
db: Database,
|
|
3760
|
+
input: { accountId: string; workspaceId: string; interventionId: string },
|
|
3761
|
+
): Promise<InteractionInterventionValue> {
|
|
3762
|
+
return await withRlsContext(db, input, async (scopedDb) => {
|
|
3763
|
+
await expireInterventionsInScope(scopedDb, input);
|
|
3764
|
+
const row = await loadInterventionRow(scopedDb, input.workspaceId, input.interventionId);
|
|
3765
|
+
if (!row) throw new InteractionResourceNotFoundError("Interaction intervention not found");
|
|
3766
|
+
return interventionFromRow(row);
|
|
3767
|
+
});
|
|
3768
|
+
}
|
|
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
|
+
|
|
4006
|
+
export async function createInteractionIntervention(
|
|
4007
|
+
db: Database,
|
|
4008
|
+
input: InteractionMutationScope &
|
|
4009
|
+
CreateInteractionInterventionRequestValue & {
|
|
4010
|
+
originatingSessionId: string;
|
|
4011
|
+
originatingTurnId?: string | null;
|
|
4012
|
+
originatingAttemptId?: string | null;
|
|
4013
|
+
originatingToolOperationId?: string | null;
|
|
4014
|
+
},
|
|
4015
|
+
): Promise<InteractionInterventionMutationResponseValue> {
|
|
4016
|
+
const request = CreateInteractionInterventionRequest.parse({
|
|
4017
|
+
operationId: input.operationId,
|
|
4018
|
+
resourceKind: input.resourceKind,
|
|
4019
|
+
resourceId: input.resourceId,
|
|
4020
|
+
targetId: input.targetId,
|
|
4021
|
+
expectedControllerGeneration: input.expectedControllerGeneration,
|
|
4022
|
+
expectedTargetGeneration: input.expectedTargetGeneration,
|
|
4023
|
+
expectedDocumentGeneration: input.expectedDocumentGeneration,
|
|
4024
|
+
kind: input.kind,
|
|
4025
|
+
reason: input.reason,
|
|
4026
|
+
...(input.authRunId !== undefined ? { authRunId: input.authRunId } : {}),
|
|
4027
|
+
expiresInSeconds: input.expiresInSeconds,
|
|
4028
|
+
});
|
|
4029
|
+
const { operationId: _operationId, ...digestRequest } = request;
|
|
4030
|
+
const digest = operationDigest({
|
|
4031
|
+
version: 1,
|
|
4032
|
+
request: digestRequest,
|
|
4033
|
+
originatingSessionId: input.originatingSessionId,
|
|
4034
|
+
originatingTurnId: input.originatingTurnId ?? null,
|
|
4035
|
+
originatingAttemptId: input.originatingAttemptId ?? null,
|
|
4036
|
+
originatingToolOperationId: input.originatingToolOperationId ?? null,
|
|
4037
|
+
actorSubjectId: input.actorSubjectId,
|
|
4038
|
+
});
|
|
4039
|
+
try {
|
|
4040
|
+
return await withRlsContext(db, input, async (scopedDb) => {
|
|
4041
|
+
await assertWorkspaceAccount(scopedDb, input.accountId, input.workspaceId);
|
|
4042
|
+
await lockOperation(scopedDb, request.operationId);
|
|
4043
|
+
const existing = await loadOperation(scopedDb, input.workspaceId, request.operationId);
|
|
4044
|
+
if (existing) {
|
|
4045
|
+
assertOperation(existing, {
|
|
4046
|
+
resourceKind: "intervention",
|
|
4047
|
+
kind: "create",
|
|
4048
|
+
requestDigest: digest,
|
|
4049
|
+
actorSubjectId: input.actorSubjectId,
|
|
4050
|
+
});
|
|
4051
|
+
const response = InteractionInterventionMutationResponse.parse(existing.result);
|
|
4052
|
+
return { ...response, replayed: true };
|
|
4053
|
+
}
|
|
4054
|
+
await expireInterventionsInScope(scopedDb, input);
|
|
4055
|
+
await assertActiveInteractionResource(scopedDb, {
|
|
4056
|
+
workspaceId: input.workspaceId,
|
|
4057
|
+
resourceKind: request.resourceKind,
|
|
4058
|
+
resourceId: request.resourceId,
|
|
4059
|
+
expectedControllerGeneration: request.expectedControllerGeneration,
|
|
4060
|
+
});
|
|
4061
|
+
let authRun: AuthRunRow | null = null;
|
|
4062
|
+
if (request.authRunId) {
|
|
4063
|
+
if (request.resourceKind !== "browser_session") {
|
|
4064
|
+
throw new InteractionResourceStateError(
|
|
4065
|
+
"Only browser interventions can belong to an auth run",
|
|
4066
|
+
);
|
|
4067
|
+
}
|
|
4068
|
+
authRun = await loadAuthRunRow(scopedDb, input.workspaceId, request.authRunId);
|
|
4069
|
+
if (!authRun) throw new InteractionResourceNotFoundError("Auth run not found");
|
|
4070
|
+
if (
|
|
4071
|
+
authRun.browserSessionId !== request.resourceId ||
|
|
4072
|
+
authRun.targetId !== request.targetId ||
|
|
4073
|
+
authRun.controllerGeneration !== request.expectedControllerGeneration ||
|
|
4074
|
+
authRun.targetGeneration !== request.expectedTargetGeneration ||
|
|
4075
|
+
authRun.documentGeneration !== request.expectedDocumentGeneration
|
|
4076
|
+
) {
|
|
4077
|
+
throw new InteractionResourceConflictError(
|
|
4078
|
+
"Intervention does not match the exact auth-run target",
|
|
4079
|
+
);
|
|
4080
|
+
}
|
|
4081
|
+
if (authRun.settledAt) {
|
|
4082
|
+
throw new InteractionResourceStateError("Auth run is already settled");
|
|
4083
|
+
}
|
|
4084
|
+
}
|
|
4085
|
+
const id = randomUUID();
|
|
4086
|
+
const [row] = await scopedDb
|
|
4087
|
+
.insert(schema.interactionInterventions)
|
|
4088
|
+
.values({
|
|
4089
|
+
id,
|
|
4090
|
+
accountId: input.accountId,
|
|
4091
|
+
workspaceId: input.workspaceId,
|
|
4092
|
+
resourceKind: request.resourceKind,
|
|
4093
|
+
resourceId: request.resourceId,
|
|
4094
|
+
targetId: request.targetId,
|
|
4095
|
+
controllerGeneration: request.expectedControllerGeneration,
|
|
4096
|
+
targetGeneration: request.expectedTargetGeneration,
|
|
4097
|
+
documentGeneration: request.expectedDocumentGeneration,
|
|
4098
|
+
kind: request.kind,
|
|
4099
|
+
reason: request.reason,
|
|
4100
|
+
authRunId: request.authRunId ?? null,
|
|
4101
|
+
originatingSessionId: input.originatingSessionId,
|
|
4102
|
+
originatingTurnId: input.originatingTurnId ?? null,
|
|
4103
|
+
originatingAttemptId: input.originatingAttemptId ?? null,
|
|
4104
|
+
originatingToolOperationId: input.originatingToolOperationId ?? null,
|
|
4105
|
+
operationId: request.operationId,
|
|
4106
|
+
expiresAt: sql`now() + (${request.expiresInSeconds} * interval '1 second')`,
|
|
4107
|
+
})
|
|
4108
|
+
.returning();
|
|
4109
|
+
if (!row) throw new Error("Interaction intervention insert returned no row");
|
|
4110
|
+
if (authRun) {
|
|
4111
|
+
const linked = await scopedDb
|
|
4112
|
+
.update(schema.authRuns)
|
|
4113
|
+
.set({
|
|
4114
|
+
interventionId: id,
|
|
4115
|
+
version: authRun.version + 1,
|
|
4116
|
+
updatedAt: sql`now()`,
|
|
4117
|
+
})
|
|
4118
|
+
.where(
|
|
4119
|
+
and(
|
|
4120
|
+
eq(schema.authRuns.workspaceId, input.workspaceId),
|
|
4121
|
+
eq(schema.authRuns.id, authRun.id),
|
|
4122
|
+
eq(schema.authRuns.version, authRun.version),
|
|
4123
|
+
sql`${schema.authRuns.settledAt} is null`,
|
|
4124
|
+
),
|
|
4125
|
+
)
|
|
4126
|
+
.returning({ id: schema.authRuns.id });
|
|
4127
|
+
if (linked.length !== 1) {
|
|
4128
|
+
throw new InteractionResourceConflictError("Auth run changed before intervention link");
|
|
4129
|
+
}
|
|
4130
|
+
}
|
|
4131
|
+
const response = InteractionInterventionMutationResponse.parse({
|
|
4132
|
+
intervention: interventionFromRow(row),
|
|
4133
|
+
operationId: request.operationId,
|
|
4134
|
+
replayed: false,
|
|
4135
|
+
});
|
|
4136
|
+
await insertCompletedOperation(scopedDb, {
|
|
4137
|
+
operationId: request.operationId,
|
|
4138
|
+
accountId: input.accountId,
|
|
4139
|
+
workspaceId: input.workspaceId,
|
|
4140
|
+
resourceKind: "intervention",
|
|
4141
|
+
resourceId: id,
|
|
4142
|
+
kind: "create",
|
|
4143
|
+
requestDigest: digest,
|
|
4144
|
+
resultVersion: row.version,
|
|
4145
|
+
result: response,
|
|
4146
|
+
actorSubjectId: input.actorSubjectId,
|
|
4147
|
+
});
|
|
4148
|
+
await advanceWorkspaceInteractionRevision(scopedDb, input.accountId, input.workspaceId);
|
|
4149
|
+
return response;
|
|
4150
|
+
});
|
|
4151
|
+
} catch (error) {
|
|
4152
|
+
if (
|
|
4153
|
+
postgresConstraint(error) === "interaction_interventions_open_target_kind_uq" ||
|
|
4154
|
+
postgresConstraint(error) === "interaction_interventions_open_auth_run_uq"
|
|
4155
|
+
) {
|
|
4156
|
+
throw new InteractionResourceConflictError(
|
|
4157
|
+
"A matching interaction intervention is already open",
|
|
4158
|
+
);
|
|
4159
|
+
}
|
|
4160
|
+
throw error;
|
|
4161
|
+
}
|
|
4162
|
+
}
|
|
4163
|
+
|
|
4164
|
+
export async function resolveInteractionInterventionInTransaction(
|
|
4165
|
+
db: Database,
|
|
4166
|
+
input: InteractionMutationScope & {
|
|
4167
|
+
interventionId: string;
|
|
4168
|
+
} & ResolveInteractionInterventionRequestValue,
|
|
4169
|
+
): Promise<InteractionInterventionMutationResponseValue> {
|
|
4170
|
+
const request = ResolveInteractionInterventionRequest.parse({
|
|
4171
|
+
operationId: input.operationId,
|
|
4172
|
+
expectedVersion: input.expectedVersion,
|
|
4173
|
+
outcome: input.outcome,
|
|
4174
|
+
});
|
|
4175
|
+
const { operationId: _operationId, ...digestRequest } = request;
|
|
4176
|
+
const digest = operationDigest({
|
|
4177
|
+
version: 1,
|
|
4178
|
+
interventionId: input.interventionId,
|
|
4179
|
+
request: digestRequest,
|
|
4180
|
+
actorSubjectId: input.actorSubjectId,
|
|
4181
|
+
});
|
|
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, {
|
|
4188
|
+
resourceKind: "intervention",
|
|
4189
|
+
resourceId: input.interventionId,
|
|
4190
|
+
kind: "resolve",
|
|
4191
|
+
requestDigest: digest,
|
|
4192
|
+
actorSubjectId: input.actorSubjectId,
|
|
4193
|
+
});
|
|
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,
|
|
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;
|
|
4328
|
+
}
|
|
4329
|
+
|
|
4330
|
+
export async function cancelOpenInteractionInterventions(
|
|
4331
|
+
db: Database,
|
|
4332
|
+
input: {
|
|
4333
|
+
accountId: string;
|
|
4334
|
+
workspaceId: string;
|
|
4335
|
+
resourceKind: "browser_session" | "computer_session";
|
|
4336
|
+
resourceId: string;
|
|
4337
|
+
},
|
|
4338
|
+
): Promise<number> {
|
|
4339
|
+
return await withRlsContext(db, input, async (scopedDb) => {
|
|
4340
|
+
const cancelled = await scopedDb
|
|
4341
|
+
.update(schema.interactionInterventions)
|
|
4342
|
+
.set({
|
|
4343
|
+
status: "cancelled",
|
|
4344
|
+
version: sql`${schema.interactionInterventions.version} + 1`,
|
|
4345
|
+
updatedAt: sql`now()`,
|
|
4346
|
+
settledAt: sql`now()`,
|
|
4347
|
+
})
|
|
4348
|
+
.where(
|
|
4349
|
+
and(
|
|
4350
|
+
eq(schema.interactionInterventions.workspaceId, input.workspaceId),
|
|
4351
|
+
eq(schema.interactionInterventions.resourceKind, input.resourceKind),
|
|
4352
|
+
eq(schema.interactionInterventions.resourceId, input.resourceId),
|
|
4353
|
+
eq(schema.interactionInterventions.status, "open"),
|
|
4354
|
+
),
|
|
4355
|
+
)
|
|
4356
|
+
.returning({
|
|
4357
|
+
id: schema.interactionInterventions.id,
|
|
4358
|
+
authRunId: schema.interactionInterventions.authRunId,
|
|
4359
|
+
});
|
|
4360
|
+
for (const intervention of cancelled) {
|
|
4361
|
+
await settleLinkedAuthRun(scopedDb, {
|
|
4362
|
+
workspaceId: input.workspaceId,
|
|
4363
|
+
authRunId: intervention.authRunId,
|
|
4364
|
+
interventionId: intervention.id,
|
|
4365
|
+
outcome: "cancelled",
|
|
4366
|
+
});
|
|
4367
|
+
}
|
|
4368
|
+
if (cancelled.length > 0) {
|
|
4369
|
+
await advanceWorkspaceInteractionRevision(scopedDb, input.accountId, input.workspaceId);
|
|
4370
|
+
}
|
|
4371
|
+
return cancelled.length;
|
|
4372
|
+
});
|
|
4373
|
+
}
|