@opengeni/db 0.31.1 → 0.36.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/dist/browser-auth.d.ts +209 -1
  2. package/dist/browser-downloads.d.ts +51 -0
  3. package/dist/browser-identities.d.ts +11 -2
  4. package/dist/browser-sessions.d.ts +83 -1
  5. package/dist/browser-state-artifacts.d.ts +75 -1
  6. package/dist/canonical-human-identities.d.ts +48 -0
  7. package/dist/canonical-human-identities.js +28 -0
  8. package/dist/canonical-human-identities.js.map +1 -0
  9. package/dist/capability-integrations.d.ts +8 -6
  10. package/dist/{chunk-MWDVXQOL.js → chunk-5KTIEDX7.js} +5773 -4212
  11. package/dist/chunk-5KTIEDX7.js.map +1 -0
  12. package/dist/chunk-FPVAD4CO.js +117 -0
  13. package/dist/chunk-FPVAD4CO.js.map +1 -0
  14. package/dist/chunk-GYO7ED2Q.js +259 -0
  15. package/dist/chunk-GYO7ED2Q.js.map +1 -0
  16. package/dist/{chunk-F5FT7BEZ.js → chunk-HSOWSYOX.js} +2 -2
  17. package/dist/{chunk-YHZXQ7HP.js → chunk-VKLB2ZGI.js} +446 -19
  18. package/dist/chunk-VKLB2ZGI.js.map +1 -0
  19. package/dist/{chunk-NBE6CHRI.js → chunk-VTPRIXKG.js} +49 -9
  20. package/dist/chunk-VTPRIXKG.js.map +1 -0
  21. package/dist/{chunk-ISI2TIUG.js → chunk-Z7VYMNPZ.js} +76 -4
  22. package/dist/chunk-Z7VYMNPZ.js.map +1 -0
  23. package/dist/database.d.ts +5 -0
  24. package/dist/editable-artifact-durable-export.js +2 -2
  25. package/dist/editable-artifacts.js +3 -3
  26. package/dist/index.d.ts +467 -29
  27. package/dist/index.js +23247 -16232
  28. package/dist/index.js.map +1 -1
  29. package/dist/insights.d.ts +23 -7
  30. package/dist/integration-bindings.d.ts +24 -24
  31. package/dist/integration-facets.d.ts +99 -0
  32. package/dist/interaction-revisions.d.ts +11 -0
  33. package/dist/interaction-schema.d.ts +698 -34
  34. package/dist/persistence-errors.d.ts +8 -0
  35. package/dist/provision-roles.js +1 -1
  36. package/dist/runtime-posture.d.ts +8 -7
  37. package/dist/schema.d.ts +9464 -5412
  38. package/dist/schema.js +51 -9
  39. package/dist/session-control.d.ts +29 -3
  40. package/dist/session-tenancy.d.ts +44 -0
  41. package/dist/session-tenancy.js +16 -0
  42. package/dist/session-tenancy.js.map +1 -0
  43. package/dist/slack-task-policy-schema.d.ts +847 -0
  44. package/dist/slack-task-policy.d.ts +65 -0
  45. package/dist/task-notes-schema.d.ts +924 -0
  46. package/dist/task-notes.d.ts +85 -0
  47. package/dist/video-generation.d.ts +9 -0
  48. package/dist/video-generation.js +5 -3
  49. package/dist/xai-subscription.d.ts +326 -0
  50. package/drizzle/0212_browser_state_transfer_hardening.sql +560 -0
  51. package/drizzle/0213_browser_interaction_authority.sql +67 -0
  52. package/drizzle/0214_browser_download_saves.sql +34 -0
  53. package/drizzle/0215_browser_controller_host.sql +385 -0
  54. package/drizzle/0216_browser_auth_health_evidence.sql +137 -0
  55. package/drizzle/0217_external_browser_auth_operations.sql +22 -0
  56. package/drizzle/0218_organization_tenancy_foundation.sql +299 -0
  57. package/drizzle/0219_organization_tenancy_managed_human_provisioning.sql +317 -0
  58. package/drizzle/0219_site_auth_maintenance_sessions.sql +234 -0
  59. package/drizzle/0220_memory_slack_append_only_cascade.sql +38 -0
  60. package/drizzle/0220_session_channels.sql +54 -0
  61. package/drizzle/0221_sessions_channel_index.sql +5 -0
  62. package/drizzle/0222_session_visibility_authority_epochs.sql +158 -0
  63. package/drizzle/0222_sessions_channel_fk.sql +89 -0
  64. package/drizzle/0223_pending_tool_event_output.sql +55 -0
  65. package/drizzle/0223_sessions_channel_fk_validate.sql +118 -0
  66. package/drizzle/0224_slack_post_outcome_reconciliation.sql +123 -0
  67. package/drizzle/0225_session_visibility_fork_activation.sql +1363 -0
  68. package/drizzle/0226_personal_codex_authority_foundation.sql +365 -0
  69. package/drizzle/0227_slack_native_actions.sql +145 -0
  70. package/drizzle/0228_interaction_controller_data_plane.sql +14 -0
  71. package/drizzle/0228_slack_task_policy.sql +400 -0
  72. package/drizzle/0229_slack_inbox_file_fact.sql +10 -0
  73. package/drizzle/0230_user_scoped_variable_sets_rigs.sql +84 -0
  74. package/drizzle/0231_integration_definition_identity_cutover.sql +271 -0
  75. package/drizzle/0232_integration_facet_authority_cutover.sql +610 -0
  76. package/drizzle/0233_skill_and_integration_authority_cutover.sql +1162 -0
  77. package/drizzle/0234_xai_subscription_authority.sql +1139 -0
  78. package/drizzle/0235_canonical_human_login_bindings.sql +920 -0
  79. package/drizzle/0236_browser_identity_lifecycle.sql +39 -0
  80. package/drizzle/0236_session_visibility_slack_policy.sql +72 -0
  81. package/drizzle/0237_interaction_transition_reaper.sql +263 -0
  82. package/drizzle/0238_goal_persistence_policy.sql +471 -0
  83. package/drizzle/0238_recover_unclaimed_session_turns.sql +307 -0
  84. package/drizzle/0238_supergrok_realtime_model.sql +29 -0
  85. package/drizzle/0239_supergrok_video_funding.sql +50 -0
  86. package/drizzle/0239_task_tree_notes.sql +990 -0
  87. package/package.json +13 -5
  88. package/src/browser-auth.ts +2600 -123
  89. package/src/browser-downloads.ts +517 -0
  90. package/src/browser-identities.ts +220 -3
  91. package/src/browser-sessions.ts +334 -45
  92. package/src/browser-state-artifacts.ts +402 -12
  93. package/src/canonical-human-identities.ts +317 -0
  94. package/src/capability-integrations.ts +121 -213
  95. package/src/database.ts +41 -1
  96. package/src/index.ts +4625 -411
  97. package/src/insights.ts +109 -28
  98. package/src/integration-bindings.ts +101 -101
  99. package/src/{integration-features.ts → integration-facets.ts} +187 -184
  100. package/src/interaction-revisions.ts +33 -3
  101. package/src/interaction-schema.ts +311 -8
  102. package/src/pack-components.ts +43 -43
  103. package/src/pack-installations.ts +0 -43
  104. package/src/persistence-errors.ts +60 -1
  105. package/src/plugin-packages.ts +9 -9
  106. package/src/provision-roles.ts +232 -0
  107. package/src/runtime-posture.ts +227 -14
  108. package/src/schema.ts +1188 -75
  109. package/src/session-control.ts +122 -37
  110. package/src/session-queue-commands.ts +78 -1
  111. package/src/session-tenancy.ts +188 -0
  112. package/src/session-tool-call-settlement.ts +9 -3
  113. package/src/slack-task-policy-schema.ts +130 -0
  114. package/src/slack-task-policy.ts +341 -0
  115. package/src/task-notes-schema.ts +135 -0
  116. package/src/task-notes.ts +178 -0
  117. package/src/video-generation.ts +90 -12
  118. package/src/workspace-artifacts.ts +1 -1
  119. package/src/xai-subscription.ts +1887 -0
  120. package/dist/chunk-ISI2TIUG.js.map +0 -1
  121. package/dist/chunk-MWDVXQOL.js.map +0 -1
  122. package/dist/chunk-NBE6CHRI.js.map +0 -1
  123. package/dist/chunk-YHZXQ7HP.js.map +0 -1
  124. package/dist/integration-features.d.ts +0 -99
  125. /package/dist/{chunk-F5FT7BEZ.js.map → chunk-HSOWSYOX.js.map} +0 -0
@@ -1,10 +1,14 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import {
2
3
  BrowserRevisionMaterialization,
3
4
  type BrowserRevisionMaterialization as BrowserRevisionMaterializationValue,
4
5
  } from "@opengeni/contracts";
5
- import type * as schema from "./schema";
6
+ import { and, eq, sql } from "drizzle-orm";
7
+ import { type Database, rawRows, withRlsContext } from "./database";
8
+ import * as schema from "./schema";
6
9
 
7
10
  type BrowserStateArtifactRow = typeof schema.browserStateArtifacts.$inferSelect;
11
+ type BrowserStateUploadRow = typeof schema.browserStateUploads.$inferSelect;
8
12
 
9
13
  const SHA256_PATTERN = /^[0-9a-f]{64}$/u;
10
14
  const OBJECT_KEY_SEGMENT_PATTERN = /^[A-Za-z0-9._=-]+$/u;
@@ -21,26 +25,48 @@ export type BrowserStateArtifactCommitInput = {
21
25
  materialization: BrowserRevisionMaterializationValue;
22
26
  };
23
27
 
28
+ export type BrowserStateArtifactCleanupClaim = {
29
+ claimId: string;
30
+ artifactId: string;
31
+ accountId: string;
32
+ workspaceId: string;
33
+ objectKey: string;
34
+ };
35
+
36
+ export type BrowserStateUploadPurpose = BrowserStateUploadRow["purpose"];
37
+
38
+ export type BrowserStateUploadCleanupClaim = {
39
+ claimId: string;
40
+ uploadId: string;
41
+ accountId: string;
42
+ workspaceId: string;
43
+ objectKey: string;
44
+ };
45
+
46
+ export class BrowserStateUploadStateError extends Error {
47
+ readonly name = "BrowserStateUploadStateError";
48
+ }
49
+
50
+ export type BrowserStateUploadPreparationInput = {
51
+ accountId: string;
52
+ workspaceId: string;
53
+ operationId: string;
54
+ sourceBrowserSessionId: string;
55
+ purpose: BrowserStateUploadPurpose;
56
+ objectKey: string;
57
+ cleanupAfter: Date;
58
+ };
59
+
24
60
  /** Validate storage authority before it can become durable state. */
25
61
  export function validateBrowserStateArtifactCommitInput(
26
62
  workspaceId: string,
27
63
  value: BrowserStateArtifactCommitInput,
28
64
  ): BrowserStateArtifactCommitInput {
29
- const prefix = `workspaces/${workspaceId}/browser-state/`;
30
- const objectKeySuffix = value.objectKey.slice(prefix.length);
31
65
  if (
32
66
  !SHA256_PATTERN.test(value.artifactDigest) ||
33
67
  !SHA256_PATTERN.test(value.contentDigest) ||
34
68
  !SHA256_PATTERN.test(value.manifestDigest) ||
35
- !value.objectKey.startsWith(prefix) ||
36
- objectKeySuffix.length < 1 ||
37
- objectKeySuffix
38
- .split("/")
39
- .some(
40
- (segment) =>
41
- !OBJECT_KEY_SEGMENT_PATTERN.test(segment) || segment === "." || segment === "..",
42
- ) ||
43
- Buffer.byteLength(value.objectKey) > 2_048 ||
69
+ !validBrowserStateObjectKey(workspaceId, value.objectKey) ||
44
70
  value.format.trim() !== value.format ||
45
71
  Buffer.byteLength(value.format) < 1 ||
46
72
  Buffer.byteLength(value.format) > 512 ||
@@ -57,3 +83,367 @@ export function validateBrowserStateArtifactCommitInput(
57
83
  }
58
84
  return { ...value, materialization };
59
85
  }
86
+
87
+ /** Establish durable cleanup authority before a controller receives a PUT URL. */
88
+ export async function prepareBrowserStateUpload(
89
+ db: Database,
90
+ input: BrowserStateUploadPreparationInput,
91
+ ): Promise<{ uploadId: string; state: BrowserStateUploadRow["state"] }> {
92
+ return await withRlsContext(db, input, async (scopedDb) =>
93
+ scopedDb.transaction(async (tx) =>
94
+ prepareBrowserStateUploadInTransaction(tx as unknown as Database, input),
95
+ ),
96
+ );
97
+ }
98
+
99
+ /** Compose upload authority with operation dispatch in one transaction. */
100
+ export async function prepareBrowserStateUploadInTransaction(
101
+ db: Database,
102
+ input: BrowserStateUploadPreparationInput,
103
+ ): Promise<{ uploadId: string; state: BrowserStateUploadRow["state"] }> {
104
+ if (
105
+ !validBrowserStateObjectKey(input.workspaceId, input.objectKey) ||
106
+ !Number.isFinite(input.cleanupAfter.getTime()) ||
107
+ input.cleanupAfter.getTime() <= Date.now()
108
+ ) {
109
+ throw new BrowserStateUploadStateError("Browser state upload authority is invalid");
110
+ }
111
+ const [operation] = await db
112
+ .select({
113
+ resourceKind: schema.interactionOperations.resourceKind,
114
+ resourceId: schema.interactionOperations.resourceId,
115
+ kind: schema.interactionOperations.kind,
116
+ state: schema.interactionOperations.state,
117
+ })
118
+ .from(schema.interactionOperations)
119
+ .where(
120
+ and(
121
+ eq(schema.interactionOperations.workspaceId, input.workspaceId),
122
+ eq(schema.interactionOperations.operationId, input.operationId),
123
+ ),
124
+ )
125
+ .for("update")
126
+ .limit(1);
127
+ const expectedKind = input.purpose === "revision_component" ? "publish" : "suspend";
128
+ if (
129
+ !operation ||
130
+ operation.resourceKind !== "browser_session" ||
131
+ operation.resourceId !== input.sourceBrowserSessionId ||
132
+ operation.kind !== expectedKind ||
133
+ (operation.state !== "prepared" && operation.state !== "dispatched")
134
+ ) {
135
+ throw new BrowserStateUploadStateError(
136
+ "Browser state upload does not belong to an active capture operation",
137
+ );
138
+ }
139
+
140
+ const [existing] = await db
141
+ .select()
142
+ .from(schema.browserStateUploads)
143
+ .where(eq(schema.browserStateUploads.objectKey, input.objectKey))
144
+ .limit(1);
145
+ if (existing) {
146
+ if (
147
+ existing.accountId !== input.accountId ||
148
+ existing.workspaceId !== input.workspaceId ||
149
+ existing.operationId !== input.operationId ||
150
+ existing.sourceBrowserSessionId !== input.sourceBrowserSessionId ||
151
+ existing.purpose !== input.purpose ||
152
+ existing.state !== "prepared"
153
+ ) {
154
+ throw new BrowserStateUploadStateError(
155
+ "Browser state object key belongs to another upload authority",
156
+ );
157
+ }
158
+ if (
159
+ existing.state === "prepared" &&
160
+ existing.cleanupAfter !== null &&
161
+ existing.cleanupAfter < input.cleanupAfter
162
+ ) {
163
+ const [renewed] = await db
164
+ .update(schema.browserStateUploads)
165
+ .set({ cleanupAfter: input.cleanupAfter, updatedAt: new Date() })
166
+ .where(
167
+ and(
168
+ eq(schema.browserStateUploads.workspaceId, input.workspaceId),
169
+ eq(schema.browserStateUploads.id, existing.id),
170
+ eq(schema.browserStateUploads.state, "prepared"),
171
+ ),
172
+ )
173
+ .returning({ id: schema.browserStateUploads.id });
174
+ if (!renewed) throw new Error("Browser state upload authority renewal was lost");
175
+ }
176
+ return { uploadId: existing.id, state: existing.state };
177
+ }
178
+
179
+ const [created] = await db
180
+ .insert(schema.browserStateUploads)
181
+ .values({
182
+ id: randomUUID(),
183
+ accountId: input.accountId,
184
+ workspaceId: input.workspaceId,
185
+ operationId: input.operationId,
186
+ sourceBrowserSessionId: input.sourceBrowserSessionId,
187
+ purpose: input.purpose,
188
+ objectKey: input.objectKey,
189
+ cleanupAfter: input.cleanupAfter,
190
+ })
191
+ .returning({ id: schema.browserStateUploads.id, state: schema.browserStateUploads.state });
192
+ if (!created) throw new Error("Browser state upload authority insert was lost");
193
+ return { uploadId: created.id, state: created.state };
194
+ }
195
+
196
+ /** Queue every uncommitted object owned by a terminal capture operation. */
197
+ export async function markBrowserStateUploadsDeletePendingInTransaction(
198
+ db: Database,
199
+ input: { workspaceId: string; operationId: string },
200
+ ): Promise<void> {
201
+ const now = new Date();
202
+ await db
203
+ .update(schema.browserStateUploads)
204
+ .set({ state: "delete_pending", cleanupAfter: now, updatedAt: now })
205
+ .where(
206
+ and(
207
+ eq(schema.browserStateUploads.workspaceId, input.workspaceId),
208
+ eq(schema.browserStateUploads.operationId, input.operationId),
209
+ eq(schema.browserStateUploads.state, "prepared"),
210
+ ),
211
+ );
212
+ }
213
+
214
+ /** Atomically turn one pre-upload authority into the exact artifact root. */
215
+ export async function commitBrowserStateUploadInTransaction(
216
+ db: Database,
217
+ input: {
218
+ workspaceId: string;
219
+ operationId: string;
220
+ sourceBrowserSessionId: string;
221
+ purpose: BrowserStateUploadPurpose;
222
+ objectKey: string;
223
+ artifactId: string;
224
+ },
225
+ ): Promise<void> {
226
+ const [upload] = await db
227
+ .select({ id: schema.browserStateUploads.id, state: schema.browserStateUploads.state })
228
+ .from(schema.browserStateUploads)
229
+ .where(
230
+ and(
231
+ eq(schema.browserStateUploads.workspaceId, input.workspaceId),
232
+ eq(schema.browserStateUploads.operationId, input.operationId),
233
+ eq(schema.browserStateUploads.sourceBrowserSessionId, input.sourceBrowserSessionId),
234
+ eq(schema.browserStateUploads.purpose, input.purpose),
235
+ eq(schema.browserStateUploads.objectKey, input.objectKey),
236
+ ),
237
+ )
238
+ .for("update")
239
+ .limit(1);
240
+ if (!upload || upload.state !== "prepared") {
241
+ throw new BrowserStateUploadStateError(
242
+ "Browser state upload is not available for durable publication",
243
+ );
244
+ }
245
+ const [committed] = await db
246
+ .update(schema.browserStateUploads)
247
+ .set({
248
+ state: "committed",
249
+ cleanupAfter: null,
250
+ committedArtifactId: input.artifactId,
251
+ updatedAt: new Date(),
252
+ })
253
+ .where(
254
+ and(
255
+ eq(schema.browserStateUploads.workspaceId, input.workspaceId),
256
+ eq(schema.browserStateUploads.id, upload.id),
257
+ eq(schema.browserStateUploads.state, "prepared"),
258
+ ),
259
+ )
260
+ .returning({ id: schema.browserStateUploads.id });
261
+ if (!committed) throw new Error("Browser state upload publication was lost");
262
+ }
263
+
264
+ /**
265
+ * Claim a bounded cross-workspace batch of retired private checkpoints. The
266
+ * privileged SQL function is the only FORCE-RLS bypass; stale claims are
267
+ * reclaimable and published revision components are structurally ineligible.
268
+ */
269
+ export async function claimBrowserStateArtifactCleanup(
270
+ db: Database,
271
+ input: { claimTimeoutMs: number; limit: number },
272
+ ): Promise<BrowserStateArtifactCleanupClaim[]> {
273
+ const rows = await rawRows<{
274
+ claim_id: string;
275
+ artifact_id: string;
276
+ account_id: string;
277
+ workspace_id: string;
278
+ object_key: string;
279
+ }>(
280
+ db,
281
+ sql`
282
+ select claim_id, artifact_id, account_id, workspace_id, object_key
283
+ from opengeni_private.claim_browser_state_artifact_cleanup(
284
+ ${input.claimTimeoutMs},
285
+ ${input.limit}
286
+ )
287
+ `,
288
+ );
289
+ return rows.map((row) => ({
290
+ claimId: row.claim_id,
291
+ artifactId: row.artifact_id,
292
+ accountId: row.account_id,
293
+ workspaceId: row.workspace_id,
294
+ objectKey: row.object_key,
295
+ }));
296
+ }
297
+
298
+ /**
299
+ * Settle one exact claim only after its provider object was deleted. Completion
300
+ * is idempotent after a lost response and erases the wrapped data key.
301
+ */
302
+ export async function completeBrowserStateArtifactCleanup(
303
+ db: Database,
304
+ input: BrowserStateArtifactCleanupClaim,
305
+ ): Promise<boolean> {
306
+ return await withRlsContext(
307
+ db,
308
+ { accountId: input.accountId, workspaceId: input.workspaceId },
309
+ async (scopedDb) =>
310
+ await scopedDb.transaction(async (tx) => {
311
+ const [artifact] = await tx
312
+ .select({
313
+ state: schema.browserStateArtifacts.state,
314
+ deleteClaimId: schema.browserStateArtifacts.deleteClaimId,
315
+ })
316
+ .from(schema.browserStateArtifacts)
317
+ .where(
318
+ and(
319
+ eq(schema.browserStateArtifacts.workspaceId, input.workspaceId),
320
+ eq(schema.browserStateArtifacts.id, input.artifactId),
321
+ eq(schema.browserStateArtifacts.purpose, "private_checkpoint"),
322
+ eq(schema.browserStateArtifacts.objectKey, input.objectKey),
323
+ ),
324
+ )
325
+ .for("update")
326
+ .limit(1);
327
+ if (!artifact) return false;
328
+ if (artifact.state === "deleted") return true;
329
+ if (artifact.state !== "deleting" || artifact.deleteClaimId !== input.claimId) {
330
+ return false;
331
+ }
332
+ const [settled] = await tx
333
+ .update(schema.browserStateArtifacts)
334
+ .set({
335
+ state: "deleted",
336
+ encryptedDataKey: null,
337
+ deleteClaimId: null,
338
+ deleteClaimedAt: null,
339
+ deletedAt: new Date(),
340
+ })
341
+ .where(
342
+ and(
343
+ eq(schema.browserStateArtifacts.workspaceId, input.workspaceId),
344
+ eq(schema.browserStateArtifacts.id, input.artifactId),
345
+ eq(schema.browserStateArtifacts.state, "deleting"),
346
+ eq(schema.browserStateArtifacts.deleteClaimId, input.claimId),
347
+ ),
348
+ )
349
+ .returning({ id: schema.browserStateArtifacts.id });
350
+ return settled !== undefined;
351
+ }),
352
+ );
353
+ }
354
+
355
+ /** Claim orphan/failed upload objects after their safety horizon. */
356
+ export async function claimBrowserStateUploadCleanup(
357
+ db: Database,
358
+ input: { claimTimeoutMs: number; limit: number },
359
+ ): Promise<BrowserStateUploadCleanupClaim[]> {
360
+ const rows = await rawRows<{
361
+ claim_id: string;
362
+ upload_id: string;
363
+ account_id: string;
364
+ workspace_id: string;
365
+ object_key: string;
366
+ }>(
367
+ db,
368
+ sql`
369
+ select claim_id, upload_id, account_id, workspace_id, object_key
370
+ from opengeni_private.claim_browser_state_upload_cleanup(
371
+ ${input.claimTimeoutMs},
372
+ ${input.limit}
373
+ )
374
+ `,
375
+ );
376
+ return rows.map((row) => ({
377
+ claimId: row.claim_id,
378
+ uploadId: row.upload_id,
379
+ accountId: row.account_id,
380
+ workspaceId: row.workspace_id,
381
+ objectKey: row.object_key,
382
+ }));
383
+ }
384
+
385
+ /** Tombstone one exact orphan upload after its provider object is absent. */
386
+ export async function completeBrowserStateUploadCleanup(
387
+ db: Database,
388
+ input: BrowserStateUploadCleanupClaim,
389
+ ): Promise<boolean> {
390
+ return await withRlsContext(
391
+ db,
392
+ { accountId: input.accountId, workspaceId: input.workspaceId },
393
+ async (scopedDb) =>
394
+ scopedDb.transaction(async (tx) => {
395
+ const [upload] = await tx
396
+ .select({
397
+ state: schema.browserStateUploads.state,
398
+ deleteClaimId: schema.browserStateUploads.deleteClaimId,
399
+ })
400
+ .from(schema.browserStateUploads)
401
+ .where(
402
+ and(
403
+ eq(schema.browserStateUploads.workspaceId, input.workspaceId),
404
+ eq(schema.browserStateUploads.id, input.uploadId),
405
+ eq(schema.browserStateUploads.objectKey, input.objectKey),
406
+ ),
407
+ )
408
+ .for("update")
409
+ .limit(1);
410
+ if (!upload) return false;
411
+ if (upload.state === "deleted") return true;
412
+ if (upload.state !== "deleting" || upload.deleteClaimId !== input.claimId) return false;
413
+ const [settled] = await tx
414
+ .update(schema.browserStateUploads)
415
+ .set({
416
+ state: "deleted",
417
+ deleteClaimId: null,
418
+ deleteClaimedAt: null,
419
+ deletedAt: new Date(),
420
+ updatedAt: new Date(),
421
+ })
422
+ .where(
423
+ and(
424
+ eq(schema.browserStateUploads.workspaceId, input.workspaceId),
425
+ eq(schema.browserStateUploads.id, input.uploadId),
426
+ eq(schema.browserStateUploads.state, "deleting"),
427
+ eq(schema.browserStateUploads.deleteClaimId, input.claimId),
428
+ ),
429
+ )
430
+ .returning({ id: schema.browserStateUploads.id });
431
+ return settled !== undefined;
432
+ }),
433
+ );
434
+ }
435
+
436
+ function validBrowserStateObjectKey(workspaceId: string, objectKey: string): boolean {
437
+ const prefix = `workspaces/${workspaceId}/browser-state/`;
438
+ if (!objectKey.startsWith(prefix) || Buffer.byteLength(objectKey) > 2_048) return false;
439
+ const suffix = objectKey.slice(prefix.length);
440
+ return (
441
+ suffix.length > 0 &&
442
+ suffix
443
+ .split("/")
444
+ .every(
445
+ (segment) =>
446
+ OBJECT_KEY_SEGMENT_PATTERN.test(segment) && segment !== "." && segment !== "..",
447
+ )
448
+ );
449
+ }