@opengeni/db 0.31.1 → 0.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/browser-auth.d.ts +209 -1
- package/dist/browser-downloads.d.ts +51 -0
- package/dist/browser-identities.d.ts +11 -2
- package/dist/browser-sessions.d.ts +83 -1
- package/dist/browser-state-artifacts.d.ts +75 -1
- package/dist/canonical-human-identities.d.ts +48 -0
- package/dist/canonical-human-identities.js +28 -0
- package/dist/canonical-human-identities.js.map +1 -0
- package/dist/capability-integrations.d.ts +8 -6
- package/dist/{chunk-MWDVXQOL.js → chunk-5KTIEDX7.js} +5773 -4212
- package/dist/chunk-5KTIEDX7.js.map +1 -0
- package/dist/{chunk-NBE6CHRI.js → chunk-CQZ4QSP5.js} +49 -9
- package/dist/chunk-CQZ4QSP5.js.map +1 -0
- package/dist/chunk-F32YT7MP.js +117 -0
- package/dist/chunk-F32YT7MP.js.map +1 -0
- package/dist/{chunk-ISI2TIUG.js → chunk-JC5QPLUM.js} +30 -3
- package/dist/chunk-JC5QPLUM.js.map +1 -0
- package/dist/{chunk-F5FT7BEZ.js → chunk-P3RE4D2B.js} +2 -2
- package/dist/chunk-TAUTWOS5.js +259 -0
- package/dist/chunk-TAUTWOS5.js.map +1 -0
- package/dist/{chunk-YHZXQ7HP.js → chunk-VKLB2ZGI.js} +446 -19
- package/dist/chunk-VKLB2ZGI.js.map +1 -0
- package/dist/database.d.ts +5 -0
- package/dist/editable-artifact-durable-export.js +2 -2
- package/dist/editable-artifacts.js +3 -3
- package/dist/index.d.ts +438 -29
- package/dist/index.js +35640 -28969
- package/dist/index.js.map +1 -1
- package/dist/insights.d.ts +23 -7
- package/dist/integration-bindings.d.ts +24 -24
- package/dist/integration-facets.d.ts +99 -0
- package/dist/interaction-revisions.d.ts +11 -0
- package/dist/interaction-schema.d.ts +698 -34
- package/dist/provision-roles.js +1 -1
- package/dist/runtime-posture.d.ts +8 -7
- package/dist/schema.d.ts +9464 -5412
- package/dist/schema.js +51 -9
- package/dist/session-control.d.ts +29 -3
- package/dist/session-tenancy.d.ts +44 -0
- package/dist/session-tenancy.js +16 -0
- package/dist/session-tenancy.js.map +1 -0
- package/dist/slack-task-policy-schema.d.ts +847 -0
- package/dist/slack-task-policy.d.ts +65 -0
- package/dist/task-notes-schema.d.ts +924 -0
- package/dist/task-notes.d.ts +85 -0
- package/dist/video-generation.d.ts +9 -0
- package/dist/video-generation.js +5 -3
- package/dist/xai-subscription.d.ts +326 -0
- package/drizzle/0212_browser_state_transfer_hardening.sql +560 -0
- package/drizzle/0213_browser_interaction_authority.sql +67 -0
- package/drizzle/0214_browser_download_saves.sql +34 -0
- package/drizzle/0215_browser_controller_host.sql +385 -0
- package/drizzle/0216_browser_auth_health_evidence.sql +137 -0
- package/drizzle/0217_external_browser_auth_operations.sql +22 -0
- package/drizzle/0218_organization_tenancy_foundation.sql +299 -0
- package/drizzle/0219_organization_tenancy_managed_human_provisioning.sql +317 -0
- package/drizzle/0219_site_auth_maintenance_sessions.sql +234 -0
- package/drizzle/0220_memory_slack_append_only_cascade.sql +38 -0
- package/drizzle/0220_session_channels.sql +54 -0
- package/drizzle/0221_sessions_channel_index.sql +5 -0
- package/drizzle/0222_session_visibility_authority_epochs.sql +158 -0
- package/drizzle/0222_sessions_channel_fk.sql +89 -0
- package/drizzle/0223_pending_tool_event_output.sql +55 -0
- package/drizzle/0223_sessions_channel_fk_validate.sql +118 -0
- package/drizzle/0224_slack_post_outcome_reconciliation.sql +123 -0
- package/drizzle/0225_session_visibility_fork_activation.sql +1363 -0
- package/drizzle/0226_personal_codex_authority_foundation.sql +365 -0
- package/drizzle/0227_slack_native_actions.sql +145 -0
- package/drizzle/0228_interaction_controller_data_plane.sql +14 -0
- package/drizzle/0228_slack_task_policy.sql +400 -0
- package/drizzle/0229_slack_inbox_file_fact.sql +10 -0
- package/drizzle/0230_user_scoped_variable_sets_rigs.sql +84 -0
- package/drizzle/0231_integration_definition_identity_cutover.sql +271 -0
- package/drizzle/0232_integration_facet_authority_cutover.sql +610 -0
- package/drizzle/0233_skill_and_integration_authority_cutover.sql +1162 -0
- package/drizzle/0234_xai_subscription_authority.sql +1139 -0
- package/drizzle/0235_canonical_human_login_bindings.sql +920 -0
- package/drizzle/0236_browser_identity_lifecycle.sql +39 -0
- package/drizzle/0236_session_visibility_slack_policy.sql +72 -0
- package/drizzle/0237_interaction_transition_reaper.sql +263 -0
- package/drizzle/0238_goal_persistence_policy.sql +471 -0
- package/drizzle/0238_supergrok_realtime_model.sql +29 -0
- package/drizzle/0239_supergrok_video_funding.sql +50 -0
- package/drizzle/0239_task_tree_notes.sql +990 -0
- package/package.json +13 -5
- package/src/browser-auth.ts +2600 -123
- package/src/browser-downloads.ts +517 -0
- package/src/browser-identities.ts +220 -3
- package/src/browser-sessions.ts +334 -45
- package/src/browser-state-artifacts.ts +402 -12
- package/src/canonical-human-identities.ts +317 -0
- package/src/capability-integrations.ts +121 -213
- package/src/database.ts +41 -1
- package/src/index.ts +4157 -394
- package/src/insights.ts +109 -28
- package/src/integration-bindings.ts +101 -101
- package/src/{integration-features.ts → integration-facets.ts} +187 -184
- package/src/interaction-revisions.ts +33 -3
- package/src/interaction-schema.ts +311 -8
- package/src/pack-components.ts +43 -43
- package/src/pack-installations.ts +0 -43
- package/src/plugin-packages.ts +9 -9
- package/src/provision-roles.ts +232 -0
- package/src/runtime-posture.ts +227 -14
- package/src/schema.ts +1188 -75
- package/src/session-control.ts +122 -37
- package/src/session-queue-commands.ts +78 -1
- package/src/session-tenancy.ts +188 -0
- package/src/session-tool-call-settlement.ts +9 -3
- package/src/slack-task-policy-schema.ts +130 -0
- package/src/slack-task-policy.ts +341 -0
- package/src/task-notes-schema.ts +135 -0
- package/src/task-notes.ts +178 -0
- package/src/video-generation.ts +90 -12
- package/src/workspace-artifacts.ts +1 -1
- package/src/xai-subscription.ts +1887 -0
- package/dist/chunk-ISI2TIUG.js.map +0 -1
- package/dist/chunk-MWDVXQOL.js.map +0 -1
- package/dist/chunk-NBE6CHRI.js.map +0 -1
- package/dist/chunk-YHZXQ7HP.js.map +0 -1
- package/dist/integration-features.d.ts +0 -99
- /package/dist/{chunk-F5FT7BEZ.js.map → chunk-P3RE4D2B.js.map} +0 -0
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
BrowserDownload,
|
|
4
|
+
BrowserDownloadSaveRequest,
|
|
5
|
+
BrowserDownloadSaveResponse,
|
|
6
|
+
type BrowserDownload as BrowserDownloadValue,
|
|
7
|
+
type BrowserDownloadSaveRequest as BrowserDownloadSaveRequestValue,
|
|
8
|
+
type BrowserDownloadSaveResponse as BrowserDownloadSaveResponseValue,
|
|
9
|
+
} from "@opengeni/contracts";
|
|
10
|
+
import { and, eq, sql } from "drizzle-orm";
|
|
11
|
+
import { type Database, withRlsContext, withWorkspaceRls } from "./database";
|
|
12
|
+
import {
|
|
13
|
+
InteractionResourceConflictError,
|
|
14
|
+
InteractionResourceNotFoundError,
|
|
15
|
+
InteractionResourceStateError,
|
|
16
|
+
} from "./browser-auth";
|
|
17
|
+
import * as schema from "./schema";
|
|
18
|
+
|
|
19
|
+
type OperationRow = typeof schema.interactionResourceOperations.$inferSelect;
|
|
20
|
+
|
|
21
|
+
type SaveMetadata = {
|
|
22
|
+
browserSessionId: string;
|
|
23
|
+
controllerGeneration: string;
|
|
24
|
+
sourceSessionId: string;
|
|
25
|
+
destinationPath: string;
|
|
26
|
+
overwrite: boolean;
|
|
27
|
+
fileId: string;
|
|
28
|
+
uploadId: string;
|
|
29
|
+
objectKey: string;
|
|
30
|
+
safeFilename: string;
|
|
31
|
+
contentType: string;
|
|
32
|
+
download: BrowserDownloadValue;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type BrowserDownloadSavePreparation = SaveMetadata & {
|
|
36
|
+
operationId: string;
|
|
37
|
+
state: OperationRow["state"];
|
|
38
|
+
errorCode: string | null;
|
|
39
|
+
replayed: boolean;
|
|
40
|
+
dispatchedNow: boolean;
|
|
41
|
+
response: BrowserDownloadSaveResponseValue | null;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type SaveIdentity = {
|
|
45
|
+
accountId: string;
|
|
46
|
+
workspaceId: string;
|
|
47
|
+
actorSubjectId: string;
|
|
48
|
+
browserSessionId: string;
|
|
49
|
+
downloadId: string;
|
|
50
|
+
request: BrowserDownloadSaveRequestValue;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export async function findBrowserDownloadSave(
|
|
54
|
+
db: Database,
|
|
55
|
+
inputValue: SaveIdentity,
|
|
56
|
+
): Promise<BrowserDownloadSavePreparation | null> {
|
|
57
|
+
const input = parseIdentity(inputValue);
|
|
58
|
+
return await withWorkspaceRls(db, input.workspaceId, async (scopedDb) => {
|
|
59
|
+
const [operation] = await scopedDb
|
|
60
|
+
.select()
|
|
61
|
+
.from(schema.interactionResourceOperations)
|
|
62
|
+
.where(
|
|
63
|
+
and(
|
|
64
|
+
eq(schema.interactionResourceOperations.workspaceId, input.workspaceId),
|
|
65
|
+
eq(schema.interactionResourceOperations.operationId, input.request.operationId),
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
.limit(1);
|
|
69
|
+
if (!operation) return null;
|
|
70
|
+
return preparation(operation, input, true);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function prepareBrowserDownloadSave(
|
|
75
|
+
db: Database,
|
|
76
|
+
inputValue: SaveIdentity & {
|
|
77
|
+
sourceSessionId: string;
|
|
78
|
+
download: BrowserDownloadValue;
|
|
79
|
+
fileId: string;
|
|
80
|
+
safeFilename: string;
|
|
81
|
+
contentType: string;
|
|
82
|
+
bucket: string;
|
|
83
|
+
objectKey: string;
|
|
84
|
+
uploadExpiresAt: Date;
|
|
85
|
+
},
|
|
86
|
+
): Promise<BrowserDownloadSavePreparation> {
|
|
87
|
+
const input = parseIdentity(inputValue);
|
|
88
|
+
const download = BrowserDownload.parse(inputValue.download);
|
|
89
|
+
if (
|
|
90
|
+
download.id !== input.downloadId ||
|
|
91
|
+
download.browserSessionId !== input.browserSessionId ||
|
|
92
|
+
download.status !== "completed" ||
|
|
93
|
+
!download.sha256
|
|
94
|
+
) {
|
|
95
|
+
throw new InteractionResourceStateError("Browser download is not ready to save");
|
|
96
|
+
}
|
|
97
|
+
const sourceSessionId = requireUuid(inputValue.sourceSessionId, "sourceSessionId");
|
|
98
|
+
const fileId = requireUuid(inputValue.fileId, "fileId");
|
|
99
|
+
const safeFilename = requireBoundedString(inputValue.safeFilename, "safeFilename", 4_096);
|
|
100
|
+
const contentType = requireBoundedString(inputValue.contentType, "contentType", 512);
|
|
101
|
+
const bucket = requireBoundedString(inputValue.bucket, "bucket", 1_024);
|
|
102
|
+
const objectKey = requireBoundedString(inputValue.objectKey, "objectKey", 2_048);
|
|
103
|
+
const expectedObjectKey = `workspaces/${input.workspaceId}/files/${fileId}/original/${safeFilename}`;
|
|
104
|
+
if (objectKey !== expectedObjectKey) {
|
|
105
|
+
throw new InteractionResourceConflictError("Browser download object key is not canonical");
|
|
106
|
+
}
|
|
107
|
+
if (!Number.isFinite(inputValue.uploadExpiresAt.valueOf())) {
|
|
108
|
+
throw new InteractionResourceStateError("Browser download upload expiry is invalid");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return await withRlsContext(
|
|
112
|
+
db,
|
|
113
|
+
input,
|
|
114
|
+
async (scopedDb) =>
|
|
115
|
+
await scopedDb.transaction(async (tx) => {
|
|
116
|
+
await lockOperation(tx, input.request.operationId);
|
|
117
|
+
const [existing] = await tx
|
|
118
|
+
.select()
|
|
119
|
+
.from(schema.interactionResourceOperations)
|
|
120
|
+
.where(
|
|
121
|
+
and(
|
|
122
|
+
eq(schema.interactionResourceOperations.workspaceId, input.workspaceId),
|
|
123
|
+
eq(schema.interactionResourceOperations.operationId, input.request.operationId),
|
|
124
|
+
),
|
|
125
|
+
)
|
|
126
|
+
.for("update")
|
|
127
|
+
.limit(1);
|
|
128
|
+
if (existing) {
|
|
129
|
+
const replay = preparation(existing, input, true);
|
|
130
|
+
if (replay.state === "prepared" || replay.state === "dispatched") {
|
|
131
|
+
await tx
|
|
132
|
+
.update(schema.fileUploads)
|
|
133
|
+
.set({ expiresAt: inputValue.uploadExpiresAt, updatedAt: sql`now()` })
|
|
134
|
+
.where(
|
|
135
|
+
and(
|
|
136
|
+
eq(schema.fileUploads.workspaceId, input.workspaceId),
|
|
137
|
+
eq(schema.fileUploads.id, replay.uploadId),
|
|
138
|
+
eq(schema.fileUploads.status, "pending"),
|
|
139
|
+
),
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
return { ...replay, dispatchedNow: false };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const [session] = await tx
|
|
146
|
+
.select({
|
|
147
|
+
lifecycle: schema.browserSessions.lifecycle,
|
|
148
|
+
controllerGeneration: schema.browserSessions.controllerGeneration,
|
|
149
|
+
})
|
|
150
|
+
.from(schema.browserSessions)
|
|
151
|
+
.where(
|
|
152
|
+
and(
|
|
153
|
+
eq(schema.browserSessions.accountId, input.accountId),
|
|
154
|
+
eq(schema.browserSessions.workspaceId, input.workspaceId),
|
|
155
|
+
eq(schema.browserSessions.id, input.browserSessionId),
|
|
156
|
+
),
|
|
157
|
+
)
|
|
158
|
+
.limit(1);
|
|
159
|
+
if (!session) throw new InteractionResourceNotFoundError("BrowserSession not found");
|
|
160
|
+
if (
|
|
161
|
+
session.lifecycle !== "active" ||
|
|
162
|
+
session.controllerGeneration !== download.controllerGeneration
|
|
163
|
+
) {
|
|
164
|
+
throw new InteractionResourceStateError(
|
|
165
|
+
"Browser download controller is no longer active",
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
const [association] = await tx
|
|
169
|
+
.select({ id: schema.browserSessionAssociations.id })
|
|
170
|
+
.from(schema.browserSessionAssociations)
|
|
171
|
+
.where(
|
|
172
|
+
and(
|
|
173
|
+
eq(schema.browserSessionAssociations.workspaceId, input.workspaceId),
|
|
174
|
+
eq(schema.browserSessionAssociations.browserSessionId, input.browserSessionId),
|
|
175
|
+
eq(schema.browserSessionAssociations.sessionId, sourceSessionId),
|
|
176
|
+
eq(schema.browserSessionAssociations.relationship, "created"),
|
|
177
|
+
),
|
|
178
|
+
)
|
|
179
|
+
.limit(1);
|
|
180
|
+
if (!association) {
|
|
181
|
+
throw new InteractionResourceConflictError(
|
|
182
|
+
"Browser download source session lost its creation binding",
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
await tx.insert(schema.files).values({
|
|
187
|
+
id: fileId,
|
|
188
|
+
accountId: input.accountId,
|
|
189
|
+
workspaceId: input.workspaceId,
|
|
190
|
+
status: "pending_upload",
|
|
191
|
+
filename: download.filename,
|
|
192
|
+
safeFilename,
|
|
193
|
+
contentType,
|
|
194
|
+
sizeBytes: download.receivedBytes,
|
|
195
|
+
sha256: download.sha256,
|
|
196
|
+
bucket,
|
|
197
|
+
objectKey,
|
|
198
|
+
});
|
|
199
|
+
const [upload] = await tx
|
|
200
|
+
.insert(schema.fileUploads)
|
|
201
|
+
.values({
|
|
202
|
+
accountId: input.accountId,
|
|
203
|
+
workspaceId: input.workspaceId,
|
|
204
|
+
fileId,
|
|
205
|
+
status: "pending",
|
|
206
|
+
expiresAt: inputValue.uploadExpiresAt,
|
|
207
|
+
})
|
|
208
|
+
.returning({ id: schema.fileUploads.id });
|
|
209
|
+
if (!upload) throw new Error("Browser download upload insert returned no row");
|
|
210
|
+
const metadata: SaveMetadata = {
|
|
211
|
+
browserSessionId: input.browserSessionId,
|
|
212
|
+
controllerGeneration: download.controllerGeneration,
|
|
213
|
+
sourceSessionId,
|
|
214
|
+
destinationPath: input.request.destinationPath,
|
|
215
|
+
overwrite: input.request.overwrite,
|
|
216
|
+
fileId,
|
|
217
|
+
uploadId: upload.id,
|
|
218
|
+
objectKey,
|
|
219
|
+
safeFilename,
|
|
220
|
+
contentType,
|
|
221
|
+
download,
|
|
222
|
+
};
|
|
223
|
+
const [operation] = await tx
|
|
224
|
+
.insert(schema.interactionResourceOperations)
|
|
225
|
+
.values({
|
|
226
|
+
operationId: input.request.operationId,
|
|
227
|
+
accountId: input.accountId,
|
|
228
|
+
workspaceId: input.workspaceId,
|
|
229
|
+
resourceKind: "browser_download",
|
|
230
|
+
resourceId: input.downloadId,
|
|
231
|
+
kind: "save",
|
|
232
|
+
requestDigest: requestDigest(input),
|
|
233
|
+
metadata,
|
|
234
|
+
state: "prepared",
|
|
235
|
+
actorSubjectId: input.actorSubjectId,
|
|
236
|
+
})
|
|
237
|
+
.returning();
|
|
238
|
+
if (!operation) throw new Error("Browser download save insert returned no row");
|
|
239
|
+
return { ...preparation(operation, input, false), dispatchedNow: false };
|
|
240
|
+
}),
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export async function dispatchBrowserDownloadSave(
|
|
245
|
+
db: Database,
|
|
246
|
+
inputValue: SaveIdentity,
|
|
247
|
+
): Promise<BrowserDownloadSavePreparation> {
|
|
248
|
+
const input = parseIdentity(inputValue);
|
|
249
|
+
return await withRlsContext(
|
|
250
|
+
db,
|
|
251
|
+
input,
|
|
252
|
+
async (scopedDb) =>
|
|
253
|
+
await scopedDb.transaction(async (tx) => {
|
|
254
|
+
await lockOperation(tx, input.request.operationId);
|
|
255
|
+
const operation = await requireOperation(tx, input.workspaceId, input.request.operationId);
|
|
256
|
+
const current = preparation(operation, input, true);
|
|
257
|
+
if (current.state === "prepared") {
|
|
258
|
+
const [updated] = await tx
|
|
259
|
+
.update(schema.interactionResourceOperations)
|
|
260
|
+
.set({ state: "dispatched" })
|
|
261
|
+
.where(
|
|
262
|
+
and(
|
|
263
|
+
eq(schema.interactionResourceOperations.operationId, input.request.operationId),
|
|
264
|
+
eq(schema.interactionResourceOperations.state, "prepared"),
|
|
265
|
+
),
|
|
266
|
+
)
|
|
267
|
+
.returning();
|
|
268
|
+
if (!updated) throw new InteractionResourceConflictError("Save dispatch lost its fence");
|
|
269
|
+
return { ...preparation(updated, input, current.replayed), dispatchedNow: true };
|
|
270
|
+
}
|
|
271
|
+
return { ...current, dispatchedNow: false };
|
|
272
|
+
}),
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export async function completeBrowserDownloadSave(
|
|
277
|
+
db: Database,
|
|
278
|
+
inputValue: SaveIdentity,
|
|
279
|
+
): Promise<BrowserDownloadSaveResponseValue> {
|
|
280
|
+
const input = parseIdentity(inputValue);
|
|
281
|
+
return await withRlsContext(
|
|
282
|
+
db,
|
|
283
|
+
input,
|
|
284
|
+
async (scopedDb) =>
|
|
285
|
+
await scopedDb.transaction(async (tx) => {
|
|
286
|
+
await lockOperation(tx, input.request.operationId);
|
|
287
|
+
const operation = await requireOperation(tx, input.workspaceId, input.request.operationId);
|
|
288
|
+
const current = preparation(operation, input, true);
|
|
289
|
+
if (current.response) return { ...current.response, replayed: true };
|
|
290
|
+
if (current.state !== "dispatched") {
|
|
291
|
+
throw terminalStateError(current);
|
|
292
|
+
}
|
|
293
|
+
const response = BrowserDownloadSaveResponse.parse({
|
|
294
|
+
download: current.download,
|
|
295
|
+
destinationPath: current.destinationPath,
|
|
296
|
+
fileId: current.fileId,
|
|
297
|
+
operationId: input.request.operationId,
|
|
298
|
+
replayed: false,
|
|
299
|
+
});
|
|
300
|
+
const [completed] = await tx
|
|
301
|
+
.update(schema.interactionResourceOperations)
|
|
302
|
+
.set({
|
|
303
|
+
state: "completed",
|
|
304
|
+
resultVersion: current.download.version,
|
|
305
|
+
result: response,
|
|
306
|
+
settledAt: sql`now()`,
|
|
307
|
+
})
|
|
308
|
+
.where(
|
|
309
|
+
and(
|
|
310
|
+
eq(schema.interactionResourceOperations.operationId, input.request.operationId),
|
|
311
|
+
eq(schema.interactionResourceOperations.workspaceId, input.workspaceId),
|
|
312
|
+
eq(schema.interactionResourceOperations.state, "dispatched"),
|
|
313
|
+
),
|
|
314
|
+
)
|
|
315
|
+
.returning({ operationId: schema.interactionResourceOperations.operationId });
|
|
316
|
+
if (!completed)
|
|
317
|
+
throw new InteractionResourceConflictError("Save completion lost its fence");
|
|
318
|
+
return response;
|
|
319
|
+
}),
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export async function settleBrowserDownloadSaveFailure(
|
|
324
|
+
db: Database,
|
|
325
|
+
inputValue: SaveIdentity & {
|
|
326
|
+
state: "failed" | "outcome_unknown";
|
|
327
|
+
errorCode: string;
|
|
328
|
+
},
|
|
329
|
+
): Promise<void> {
|
|
330
|
+
const input = parseIdentity(inputValue);
|
|
331
|
+
const errorCode = requireBoundedString(inputValue.errorCode, "errorCode", 512);
|
|
332
|
+
await withRlsContext(db, input, async (scopedDb) => {
|
|
333
|
+
await scopedDb.transaction(async (tx) => {
|
|
334
|
+
await lockOperation(tx, input.request.operationId);
|
|
335
|
+
const operation = await requireOperation(tx, input.workspaceId, input.request.operationId);
|
|
336
|
+
const current = preparation(operation, input, true);
|
|
337
|
+
if (
|
|
338
|
+
current.state === "completed" ||
|
|
339
|
+
current.state === "failed" ||
|
|
340
|
+
current.state === "outcome_unknown"
|
|
341
|
+
) {
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
const [settled] = await tx
|
|
345
|
+
.update(schema.interactionResourceOperations)
|
|
346
|
+
.set({ state: inputValue.state, errorCode, settledAt: sql`now()` })
|
|
347
|
+
.where(
|
|
348
|
+
and(
|
|
349
|
+
eq(schema.interactionResourceOperations.operationId, input.request.operationId),
|
|
350
|
+
eq(schema.interactionResourceOperations.workspaceId, input.workspaceId),
|
|
351
|
+
sql`${schema.interactionResourceOperations.state} in ('prepared', 'dispatched')`,
|
|
352
|
+
),
|
|
353
|
+
)
|
|
354
|
+
.returning({ operationId: schema.interactionResourceOperations.operationId });
|
|
355
|
+
if (!settled) throw new InteractionResourceConflictError("Save failure lost its fence");
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function parseIdentity(input: SaveIdentity): SaveIdentity {
|
|
361
|
+
const request = BrowserDownloadSaveRequest.parse(input.request);
|
|
362
|
+
return {
|
|
363
|
+
accountId: requireUuid(input.accountId, "accountId"),
|
|
364
|
+
workspaceId: requireUuid(input.workspaceId, "workspaceId"),
|
|
365
|
+
actorSubjectId: requireBoundedString(input.actorSubjectId, "actorSubjectId", 1_024),
|
|
366
|
+
browserSessionId: requireUuid(input.browserSessionId, "browserSessionId"),
|
|
367
|
+
downloadId: requireUuid(input.downloadId, "downloadId"),
|
|
368
|
+
request,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function requestDigest(input: SaveIdentity): string {
|
|
373
|
+
return createHash("sha256")
|
|
374
|
+
.update(
|
|
375
|
+
JSON.stringify({
|
|
376
|
+
browserSessionId: input.browserSessionId,
|
|
377
|
+
destinationPath: input.request.destinationPath,
|
|
378
|
+
downloadId: input.downloadId,
|
|
379
|
+
overwrite: input.request.overwrite,
|
|
380
|
+
}),
|
|
381
|
+
)
|
|
382
|
+
.digest("hex");
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function preparation(
|
|
386
|
+
operation: OperationRow,
|
|
387
|
+
input: SaveIdentity,
|
|
388
|
+
replayed: boolean,
|
|
389
|
+
): BrowserDownloadSavePreparation {
|
|
390
|
+
if (
|
|
391
|
+
operation.accountId !== input.accountId ||
|
|
392
|
+
operation.workspaceId !== input.workspaceId ||
|
|
393
|
+
operation.resourceKind !== "browser_download" ||
|
|
394
|
+
operation.resourceId !== input.downloadId ||
|
|
395
|
+
operation.kind !== "save" ||
|
|
396
|
+
operation.requestDigest !== requestDigest(input) ||
|
|
397
|
+
operation.actorSubjectId !== input.actorSubjectId
|
|
398
|
+
) {
|
|
399
|
+
throw new InteractionResourceConflictError(
|
|
400
|
+
"Operation id is already bound to another browser download save",
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
const metadata = saveMetadata(operation.metadata);
|
|
404
|
+
if (
|
|
405
|
+
metadata.browserSessionId !== input.browserSessionId ||
|
|
406
|
+
metadata.download.id !== input.downloadId
|
|
407
|
+
) {
|
|
408
|
+
throw new InteractionResourceConflictError("Browser download save metadata lost its binding");
|
|
409
|
+
}
|
|
410
|
+
let response: BrowserDownloadSaveResponseValue | null = null;
|
|
411
|
+
if (operation.state === "completed") {
|
|
412
|
+
response = BrowserDownloadSaveResponse.parse(operation.result);
|
|
413
|
+
if (
|
|
414
|
+
response.operationId !== operation.operationId ||
|
|
415
|
+
response.download.id !== input.downloadId ||
|
|
416
|
+
response.fileId !== metadata.fileId ||
|
|
417
|
+
response.destinationPath !== metadata.destinationPath
|
|
418
|
+
) {
|
|
419
|
+
throw new InteractionResourceStateError("Browser download save result is corrupted");
|
|
420
|
+
}
|
|
421
|
+
response = { ...response, replayed: true };
|
|
422
|
+
}
|
|
423
|
+
return {
|
|
424
|
+
...metadata,
|
|
425
|
+
operationId: operation.operationId,
|
|
426
|
+
state: operation.state,
|
|
427
|
+
errorCode: operation.errorCode,
|
|
428
|
+
replayed,
|
|
429
|
+
dispatchedNow: false,
|
|
430
|
+
response,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function saveMetadata(value: unknown): SaveMetadata {
|
|
435
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
436
|
+
throw new InteractionResourceStateError("Browser download save metadata is invalid");
|
|
437
|
+
}
|
|
438
|
+
const record = value as Record<string, unknown>;
|
|
439
|
+
return {
|
|
440
|
+
browserSessionId: requireUuid(record.browserSessionId, "metadata.browserSessionId"),
|
|
441
|
+
controllerGeneration: requireBoundedString(
|
|
442
|
+
record.controllerGeneration,
|
|
443
|
+
"metadata.controllerGeneration",
|
|
444
|
+
256,
|
|
445
|
+
),
|
|
446
|
+
sourceSessionId: requireUuid(record.sourceSessionId, "metadata.sourceSessionId"),
|
|
447
|
+
destinationPath: BrowserDownloadSaveRequest.shape.destinationPath.parse(record.destinationPath),
|
|
448
|
+
overwrite: requireBoolean(record.overwrite, "metadata.overwrite"),
|
|
449
|
+
fileId: requireUuid(record.fileId, "metadata.fileId"),
|
|
450
|
+
uploadId: requireUuid(record.uploadId, "metadata.uploadId"),
|
|
451
|
+
objectKey: requireBoundedString(record.objectKey, "metadata.objectKey", 2_048),
|
|
452
|
+
safeFilename: requireBoundedString(record.safeFilename, "metadata.safeFilename", 4_096),
|
|
453
|
+
contentType: requireBoundedString(record.contentType, "metadata.contentType", 512),
|
|
454
|
+
download: BrowserDownload.parse(record.download),
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async function lockOperation(db: Database, operationId: string): Promise<void> {
|
|
459
|
+
await db.execute(
|
|
460
|
+
sql`select pg_advisory_xact_lock(hashtextextended(${`browser-download-save:${operationId}`}, 0))`,
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
async function requireOperation(
|
|
465
|
+
db: Database,
|
|
466
|
+
workspaceId: string,
|
|
467
|
+
operationId: string,
|
|
468
|
+
): Promise<OperationRow> {
|
|
469
|
+
const [operation] = await db
|
|
470
|
+
.select()
|
|
471
|
+
.from(schema.interactionResourceOperations)
|
|
472
|
+
.where(
|
|
473
|
+
and(
|
|
474
|
+
eq(schema.interactionResourceOperations.workspaceId, workspaceId),
|
|
475
|
+
eq(schema.interactionResourceOperations.operationId, operationId),
|
|
476
|
+
),
|
|
477
|
+
)
|
|
478
|
+
.for("update")
|
|
479
|
+
.limit(1);
|
|
480
|
+
if (!operation) throw new InteractionResourceNotFoundError("Browser download save not found");
|
|
481
|
+
return operation;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function terminalStateError(
|
|
485
|
+
prepared: BrowserDownloadSavePreparation,
|
|
486
|
+
): InteractionResourceStateError {
|
|
487
|
+
return new InteractionResourceStateError(
|
|
488
|
+
`Browser download save is ${prepared.state.replace("_", " ")}${prepared.errorCode ? ` (${prepared.errorCode})` : ""}`,
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function requireUuid(value: unknown, label: string): string {
|
|
493
|
+
if (
|
|
494
|
+
typeof value !== "string" ||
|
|
495
|
+
!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(value)
|
|
496
|
+
) {
|
|
497
|
+
throw new InteractionResourceStateError(`${label} is invalid`);
|
|
498
|
+
}
|
|
499
|
+
return value;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function requireBoundedString(value: unknown, label: string, maxBytes: number): string {
|
|
503
|
+
if (
|
|
504
|
+
typeof value !== "string" ||
|
|
505
|
+
value.length < 1 ||
|
|
506
|
+
Buffer.byteLength(value) > maxBytes ||
|
|
507
|
+
/[\u0000-\u001f\u007f]/u.test(value)
|
|
508
|
+
) {
|
|
509
|
+
throw new InteractionResourceStateError(`${label} is invalid`);
|
|
510
|
+
}
|
|
511
|
+
return value;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function requireBoolean(value: unknown, label: string): boolean {
|
|
515
|
+
if (typeof value !== "boolean") throw new InteractionResourceStateError(`${label} is invalid`);
|
|
516
|
+
return value;
|
|
517
|
+
}
|