transloadit 4.11.1 → 4.13.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.
Files changed (140) hide show
  1. package/README.md +231 -23
  2. package/dist/Transloadit.d.ts +55 -1
  3. package/dist/Transloadit.d.ts.map +1 -1
  4. package/dist/Transloadit.js +143 -2
  5. package/dist/Transloadit.js.map +1 -1
  6. package/dist/alphalib/types/assemblyStatus.d.ts +574 -198
  7. package/dist/alphalib/types/assemblyStatus.d.ts.map +1 -1
  8. package/dist/alphalib/types/assemblyStatus.js +17 -0
  9. package/dist/alphalib/types/assemblyStatus.js.map +1 -1
  10. package/dist/alphalib/types/robots/_index.d.ts +205 -93
  11. package/dist/alphalib/types/robots/_index.d.ts.map +1 -1
  12. package/dist/alphalib/types/robots/_index.js +8 -0
  13. package/dist/alphalib/types/robots/_index.js.map +1 -1
  14. package/dist/alphalib/types/robots/_instructions-primitives.d.ts +4 -4
  15. package/dist/alphalib/types/robots/_instructions-primitives.d.ts.map +1 -1
  16. package/dist/alphalib/types/robots/_instructions-primitives.js +2 -0
  17. package/dist/alphalib/types/robots/_instructions-primitives.js.map +1 -1
  18. package/dist/alphalib/types/robots/ai-chat.d.ts +2 -1
  19. package/dist/alphalib/types/robots/ai-chat.d.ts.map +1 -1
  20. package/dist/alphalib/types/robots/ai-chat.js +6 -5
  21. package/dist/alphalib/types/robots/ai-chat.js.map +1 -1
  22. package/dist/alphalib/types/robots/file-serve.d.ts +12 -0
  23. package/dist/alphalib/types/robots/file-serve.d.ts.map +1 -1
  24. package/dist/alphalib/types/robots/file-serve.js +9 -3
  25. package/dist/alphalib/types/robots/file-serve.js.map +1 -1
  26. package/dist/alphalib/types/robots/transloadit-import.d.ts +195 -0
  27. package/dist/alphalib/types/robots/transloadit-import.d.ts.map +1 -0
  28. package/dist/alphalib/types/robots/transloadit-import.js +108 -0
  29. package/dist/alphalib/types/robots/transloadit-import.js.map +1 -0
  30. package/dist/alphalib/types/robots/transloadit-store.d.ts +441 -0
  31. package/dist/alphalib/types/robots/transloadit-store.d.ts.map +1 -0
  32. package/dist/alphalib/types/robots/transloadit-store.js +69 -0
  33. package/dist/alphalib/types/robots/transloadit-store.js.map +1 -0
  34. package/dist/alphalib/types/robots/video-ondemand.d.ts +8 -8
  35. package/dist/alphalib/types/storageAsset.d.ts +226 -0
  36. package/dist/alphalib/types/storageAsset.d.ts.map +1 -0
  37. package/dist/alphalib/types/storageAsset.js +67 -0
  38. package/dist/alphalib/types/storageAsset.js.map +1 -0
  39. package/dist/alphalib/types/template.d.ts.map +1 -1
  40. package/dist/alphalib/types/template.js +5 -2
  41. package/dist/alphalib/types/template.js.map +1 -1
  42. package/dist/alphalib/types/templateCredential.d.ts +2 -2
  43. package/dist/cli/commands/BaseCommand.d.ts +3 -1
  44. package/dist/cli/commands/BaseCommand.d.ts.map +1 -1
  45. package/dist/cli/commands/BaseCommand.js +3 -2
  46. package/dist/cli/commands/BaseCommand.js.map +1 -1
  47. package/dist/cli/commands/assemblies.d.ts.map +1 -1
  48. package/dist/cli/commands/assemblies.js +9 -4
  49. package/dist/cli/commands/assemblies.js.map +1 -1
  50. package/dist/cli/commands/auth.d.ts +6 -0
  51. package/dist/cli/commands/auth.d.ts.map +1 -1
  52. package/dist/cli/commands/auth.js +21 -2
  53. package/dist/cli/commands/auth.js.map +1 -1
  54. package/dist/cli/commands/image.d.ts +15 -0
  55. package/dist/cli/commands/image.d.ts.map +1 -0
  56. package/dist/cli/commands/image.js +234 -0
  57. package/dist/cli/commands/image.js.map +1 -0
  58. package/dist/cli/commands/index.d.ts.map +1 -1
  59. package/dist/cli/commands/index.js +15 -1
  60. package/dist/cli/commands/index.js.map +1 -1
  61. package/dist/cli/commands/login.d.ts +24 -0
  62. package/dist/cli/commands/login.d.ts.map +1 -0
  63. package/dist/cli/commands/login.js +275 -0
  64. package/dist/cli/commands/login.js.map +1 -0
  65. package/dist/cli/commands/storage.d.ts +58 -0
  66. package/dist/cli/commands/storage.d.ts.map +1 -0
  67. package/dist/cli/commands/storage.js +589 -0
  68. package/dist/cli/commands/storage.js.map +1 -0
  69. package/dist/cli/commands/templates.d.ts.map +1 -1
  70. package/dist/cli/commands/templates.js +10 -6
  71. package/dist/cli/commands/templates.js.map +1 -1
  72. package/dist/cli/deviceLogin.d.ts +11 -0
  73. package/dist/cli/deviceLogin.d.ts.map +1 -0
  74. package/dist/cli/deviceLogin.js +187 -0
  75. package/dist/cli/deviceLogin.js.map +1 -0
  76. package/dist/cli/helpers.d.ts +23 -1
  77. package/dist/cli/helpers.d.ts.map +1 -1
  78. package/dist/cli/helpers.js +159 -25
  79. package/dist/cli/helpers.js.map +1 -1
  80. package/dist/cli/secretInput.d.ts +3 -0
  81. package/dist/cli/secretInput.d.ts.map +1 -0
  82. package/dist/cli/secretInput.js +11 -0
  83. package/dist/cli/secretInput.js.map +1 -0
  84. package/dist/cli/storageCatalog.d.ts +19 -0
  85. package/dist/cli/storageCatalog.d.ts.map +1 -0
  86. package/dist/cli/storageCatalog.js +129 -0
  87. package/dist/cli/storageCatalog.js.map +1 -0
  88. package/dist/cli/storagePublic.d.ts +3 -0
  89. package/dist/cli/storagePublic.d.ts.map +1 -0
  90. package/dist/cli/storagePublic.js +15 -0
  91. package/dist/cli/storagePublic.js.map +1 -0
  92. package/dist/cli/storageReceipts.d.ts +67 -0
  93. package/dist/cli/storageReceipts.d.ts.map +1 -0
  94. package/dist/cli/storageReceipts.js +242 -0
  95. package/dist/cli/storageReceipts.js.map +1 -0
  96. package/dist/cli/storageSnippets.d.ts +11 -0
  97. package/dist/cli/storageSnippets.d.ts.map +1 -0
  98. package/dist/cli/storageSnippets.js +89 -0
  99. package/dist/cli/storageSnippets.js.map +1 -0
  100. package/dist/storageImage.d.ts +40 -0
  101. package/dist/storageImage.d.ts.map +1 -0
  102. package/dist/storageImage.js +181 -0
  103. package/dist/storageImage.js.map +1 -0
  104. package/dist/storagePublicPrefixes.d.ts +62 -0
  105. package/dist/storagePublicPrefixes.d.ts.map +1 -0
  106. package/dist/storagePublicPrefixes.js +35 -0
  107. package/dist/storagePublicPrefixes.js.map +1 -0
  108. package/dist/storageResults.d.ts +31 -0
  109. package/dist/storageResults.d.ts.map +1 -0
  110. package/dist/storageResults.js +79 -0
  111. package/dist/storageResults.js.map +1 -0
  112. package/package.json +8 -3
  113. package/src/Transloadit.ts +251 -4
  114. package/src/alphalib/types/assemblyStatus.ts +17 -0
  115. package/src/alphalib/types/robots/_index.ts +28 -0
  116. package/src/alphalib/types/robots/_instructions-primitives.ts +2 -0
  117. package/src/alphalib/types/robots/ai-chat.ts +6 -5
  118. package/src/alphalib/types/robots/file-serve.ts +11 -3
  119. package/src/alphalib/types/robots/transloadit-import.ts +154 -0
  120. package/src/alphalib/types/robots/transloadit-store.ts +98 -0
  121. package/src/alphalib/types/storageAsset.ts +88 -0
  122. package/src/alphalib/types/template.ts +3 -0
  123. package/src/cli/commands/BaseCommand.ts +4 -2
  124. package/src/cli/commands/assemblies.ts +13 -5
  125. package/src/cli/commands/auth.ts +26 -3
  126. package/src/cli/commands/image.ts +282 -0
  127. package/src/cli/commands/index.ts +27 -1
  128. package/src/cli/commands/login.ts +334 -0
  129. package/src/cli/commands/storage.ts +733 -0
  130. package/src/cli/commands/templates.ts +14 -7
  131. package/src/cli/deviceLogin.ts +226 -0
  132. package/src/cli/helpers.ts +204 -27
  133. package/src/cli/secretInput.ts +12 -0
  134. package/src/cli/storageCatalog.ts +192 -0
  135. package/src/cli/storagePublic.ts +15 -0
  136. package/src/cli/storageReceipts.ts +307 -0
  137. package/src/cli/storageSnippets.ts +96 -0
  138. package/src/storageImage.ts +266 -0
  139. package/src/storagePublicPrefixes.ts +39 -0
  140. package/src/storageResults.ts +119 -0
@@ -0,0 +1,266 @@
1
+ import type { AssemblyStatus } from './alphalib/types/assemblyStatus.ts'
2
+ import type { StoredAsset } from './alphalib/types/storageAsset.ts'
3
+ import type { CreateAssemblyOptions, Transloadit } from './Transloadit.ts'
4
+
5
+ import { createHash } from 'node:crypto'
6
+ import { createReadStream } from 'node:fs'
7
+
8
+ import debug from 'debug'
9
+ import { z } from 'zod'
10
+
11
+ import { ApiError } from './ApiError.ts'
12
+ import { storedAssetSchema } from './alphalib/types/storageAsset.ts'
13
+ import InconsistentResponseError from './InconsistentResponseError.ts'
14
+
15
+ /**
16
+ * Verified Storage metadata that can be saved and passed directly to an image renderer.
17
+ * Width and height reflect EXIF auto-orientation, matching Storage preview delivery.
18
+ */
19
+ export interface StoredImageReceipt extends Readonly<StoredAsset> {
20
+ /** Present as true only when the locally decoded original has an alpha channel. */
21
+ readonly hasAlpha?: boolean
22
+ readonly height: number
23
+ readonly md5hash: string
24
+ /** Optional base64 ThumbHash of the original pixels, for an inline blur placeholder. */
25
+ readonly thumbhash?: string
26
+ readonly width: number
27
+ }
28
+
29
+ /** One explicit destination, with the existing Assembly upload and polling controls. */
30
+ export interface StoreImageOptions
31
+ extends Pick<
32
+ CreateAssemblyOptions,
33
+ 'chunkSize' | 'onAssemblyProgress' | 'onUploadProgress' | 'signal' | 'timeout'
34
+ > {
35
+ /** Complete relative Storage filename. Directories and interpolation expressions are rejected. */
36
+ path: string
37
+ /** Explicit opt-in replacement of an existing path; defaults to false. */
38
+ overwrite?: boolean
39
+ /** Observe verified metadata. Not awaited; sync and async observer errors cannot undo a write. */
40
+ onReceipt?: (
41
+ receipt: StoredImageReceipt,
42
+ input: StoredImageExpectation,
43
+ assemblyId: string | undefined,
44
+ ) => void | Promise<void>
45
+ }
46
+
47
+ /** Trusted upload facts used to correlate a stored result with an application-owned upload. */
48
+ export interface StoredImageExpectation {
49
+ path: string
50
+ size: number
51
+ md5hash: string
52
+ }
53
+
54
+ /** Recovers one original's receipt without re-uploading or trusting notification payloads. */
55
+ export interface GetStoredImageReceiptOptions {
56
+ assemblyId: string
57
+ expected: StoredImageExpectation
58
+ }
59
+
60
+ const positiveIntegerSchema = z.number().int().positive().max(Number.MAX_SAFE_INTEGER)
61
+ const receiptRequestSchema = z.object({
62
+ assemblyId: z.string().regex(/^[A-Za-z0-9_-]+$/),
63
+ expected: z.object({
64
+ path: z.string(),
65
+ size: positiveIntegerSchema,
66
+ md5hash: z.string().regex(/^[a-f0-9]{32}$/),
67
+ }),
68
+ })
69
+ const completedImageSchema = z.object({
70
+ ok: z.literal('ASSEMBLY_COMPLETED'),
71
+ results: z.object({
72
+ ':original': z.tuple([
73
+ storedAssetSchema.extend({
74
+ md5hash: storedAssetSchema.shape.md5hash.unwrap(),
75
+ height: positiveIntegerSchema,
76
+ width: positiveIntegerSchema,
77
+ size: positiveIntegerSchema,
78
+ }),
79
+ ]),
80
+ }),
81
+ })
82
+
83
+ function validateDestination(path: string): void {
84
+ if (
85
+ typeof path !== 'string' ||
86
+ path.trim() !== path ||
87
+ Buffer.byteLength(path) > 1024 ||
88
+ path.normalize('NFC') !== path ||
89
+ /[\p{Cc}\p{Cs}\\|]/u.test(path) ||
90
+ path.includes('${') ||
91
+ path.split('/').some((segment) => segment.trim() === '' || segment === '.' || segment === '..')
92
+ ) {
93
+ throw new TypeError('Storage path must be a complete relative filename without interpolation')
94
+ }
95
+ }
96
+
97
+ async function imagePlaceholderMetadata(
98
+ bytes: Buffer,
99
+ ): Promise<Pick<StoredImageReceipt, 'thumbhash' | 'hasAlpha'> | undefined> {
100
+ try {
101
+ const [{ default: sharp }, { rgbaToThumbHash }] = await Promise.all([
102
+ import('sharp'),
103
+ import('thumbhash'),
104
+ ])
105
+ const image = sharp(bytes, { limitInputPixels: 40_000_000 })
106
+ // Read original-channel metadata, not the alpha channel added for ThumbHash's RGBA input.
107
+ const { hasAlpha } = await image.metadata()
108
+ const { data, info } = await image
109
+ .autoOrient()
110
+ .resize(100, 100, { fit: 'inside', withoutEnlargement: true })
111
+ .ensureAlpha()
112
+ .raw()
113
+ .timeout({ seconds: 2 })
114
+ .toBuffer({ resolveWithObject: true })
115
+ return {
116
+ thumbhash: Buffer.from(rgbaToThumbHash(info.width, info.height, data)).toString('base64'),
117
+ ...(hasAlpha ? { hasAlpha: true } : {}),
118
+ }
119
+ } catch {
120
+ // A locally unsupported/oversized image can still be stored and decoded by the origin.
121
+ debug('transloadit:warn')('Omitted optional ThumbHash: local image decoding was unavailable')
122
+ return undefined
123
+ }
124
+ }
125
+
126
+ /** Stores one original without overwriting; receipt validation occurs after the Storage write. */
127
+ export async function storeImage(
128
+ client: Transloadit,
129
+ filePath: string,
130
+ options: StoreImageOptions,
131
+ ): Promise<StoredImageReceipt> {
132
+ const {
133
+ path,
134
+ chunkSize,
135
+ onAssemblyProgress,
136
+ onUploadProgress,
137
+ onReceipt,
138
+ overwrite,
139
+ signal,
140
+ timeout,
141
+ } = options
142
+ validateDestination(path)
143
+ if (overwrite !== undefined && typeof overwrite !== 'boolean')
144
+ throw new TypeError('overwrite must be a boolean')
145
+ signal?.throwIfAborted()
146
+ const checksum = createHash('md5')
147
+ // Reuse the checksum read, bounded independently of file size; uploads still stream from disk.
148
+ let thumbnailChunks: Buffer[] | undefined = []
149
+ let size = 0
150
+ for await (const chunk of createReadStream(filePath, { signal })) {
151
+ checksum.update(chunk)
152
+ size += chunk.length
153
+ if (size > 32 * 1024 * 1024) thumbnailChunks = undefined
154
+ else thumbnailChunks?.push(chunk)
155
+ }
156
+ if (size === 0) throw new Error('Cannot store an empty image')
157
+ const md5hash = checksum.digest('hex')
158
+ signal?.throwIfAborted()
159
+ const placeholder =
160
+ thumbnailChunks === undefined
161
+ ? undefined
162
+ : await imagePlaceholderMetadata(Buffer.concat(thumbnailChunks))
163
+ thumbnailChunks = undefined
164
+ signal?.throwIfAborted()
165
+ const assembly = await client.createAssembly({
166
+ chunkSize,
167
+ files: { image: filePath },
168
+ onAssemblyProgress,
169
+ onUploadProgress,
170
+ params: {
171
+ steps: {
172
+ stored: {
173
+ robot: '/transloadit/store',
174
+ use: ':original',
175
+ path,
176
+ conflict_strategy: overwrite === true ? 'overwrite' : 'error',
177
+ },
178
+ },
179
+ },
180
+ signal,
181
+ timeout,
182
+ waitForCompletion: true,
183
+ })
184
+ const input = { path, size, md5hash }
185
+ // API2 can watermark uploads before Robots run; this exact write's result describes stored bytes.
186
+ const receipt = validateReceipt(assembly, input, true)
187
+ // A watermark or other origin-side rewrite makes the local pixels the wrong placeholder.
188
+ const withPlaceholder =
189
+ placeholder === undefined || receipt.md5hash !== md5hash || receipt.size !== size
190
+ ? receipt
191
+ : { ...receipt, ...placeholder }
192
+ const observerFailed = (): void => {
193
+ debug('transloadit:warn')('Ignored onReceipt observer failure after a completed Storage write')
194
+ }
195
+ try {
196
+ // Neither a stalled observer nor its rejection may hide a successfully stored receipt.
197
+ void Promise.resolve(onReceipt?.({ ...withPlaceholder }, input, assembly.assembly_id)).catch(
198
+ observerFailed,
199
+ )
200
+ } catch {
201
+ observerFailed()
202
+ }
203
+ return withPlaceholder
204
+ }
205
+
206
+ /** Fetches authoritative Assembly status and applies the same validation as a local image store. */
207
+ export async function getStoredImageReceipt(
208
+ client: Transloadit,
209
+ options: GetStoredImageReceiptOptions,
210
+ ): Promise<StoredImageReceipt> {
211
+ const { assemblyId, expected } = receiptRequestSchema.parse(options)
212
+ validateDestination(expected.path)
213
+ const assembly = await client.getAssembly(assemblyId)
214
+ if (assembly.assembly_id !== assemblyId) {
215
+ throw new InconsistentResponseError('The response did not match the requested Assembly', {
216
+ cause: { assemblyId },
217
+ })
218
+ }
219
+ return validateReceipt(assembly, expected)
220
+ }
221
+
222
+ function validateReceipt(
223
+ assembly: AssemblyStatus,
224
+ expected: StoredImageExpectation,
225
+ acceptTransformed = false,
226
+ ): StoredImageReceipt {
227
+ const { path, size, md5hash } = expected
228
+ if (typeof assembly.error === 'string') throw new ApiError({ body: assembly })
229
+ if (assembly.ok === 'ASSEMBLY_CANCELED') {
230
+ throw new InconsistentResponseError('The Storage Assembly ended with ASSEMBLY_CANCELED', {
231
+ cause: { assemblyId: assembly.assembly_id },
232
+ })
233
+ }
234
+ if (
235
+ assembly.ok === 'ASSEMBLY_UPLOADING' ||
236
+ assembly.ok === 'ASSEMBLY_EXECUTING' ||
237
+ assembly.ok === 'ASSEMBLY_REPLAYING'
238
+ ) {
239
+ throw new InconsistentResponseError(`The Storage Assembly is not complete (${assembly.ok})`, {
240
+ cause: { assemblyId: assembly.assembly_id },
241
+ })
242
+ }
243
+ const parsed = completedImageSchema.safeParse(assembly)
244
+ const result = parsed.success ? parsed.data.results[':original'][0] : undefined
245
+ const originals = assembly.results?.[':original']
246
+ const receiptCheck = {
247
+ originalCount: Array.isArray(originals) ? originals.length : 0,
248
+ metadataValid: parsed.success,
249
+ pathMatches: result === undefined ? undefined : result.path === path,
250
+ sizeMatches: result === undefined ? undefined : result.size === size,
251
+ md5Matches: result === undefined ? undefined : result.md5hash === md5hash,
252
+ }
253
+ if (
254
+ result === undefined ||
255
+ result.path !== path ||
256
+ (!acceptTransformed && (!receiptCheck.sizeMatches || !receiptCheck.md5Matches))
257
+ ) {
258
+ throw new InconsistentResponseError(
259
+ 'The Assembly did not return a matching Storage image receipt',
260
+ {
261
+ cause: { assemblyId: assembly.assembly_id, receiptCheck },
262
+ },
263
+ )
264
+ }
265
+ return result
266
+ }
@@ -0,0 +1,39 @@
1
+ import { validateStoragePathPrefix } from '@transloadit/utils'
2
+ import { z } from 'zod'
3
+
4
+ /** Normalize a public directory, never implicit workspace-wide access. */
5
+ export function normalizeStoragePublicPrefix(prefix: string): string {
6
+ if (typeof prefix !== 'string' || prefix === '' || prefix === '/')
7
+ throw new TypeError('A public prefix must name a non-root directory')
8
+ const normalized = prefix.endsWith('/') ? prefix : `${prefix}/`
9
+ validateStoragePathPrefix(normalized, 0, 'public prefix')
10
+ if (new TextEncoder().encode(normalized).byteLength > 512)
11
+ throw new TypeError('A public prefix must be at most 512 UTF-8 bytes')
12
+ return normalized
13
+ }
14
+
15
+ const prefixSchema = z.string().refine((prefix) => {
16
+ try {
17
+ return normalizeStoragePublicPrefix(prefix) === prefix
18
+ } catch {
19
+ return false
20
+ }
21
+ }, 'Expected a normalized public directory')
22
+ const publicPrefixSchema = z.object({ prefix: prefixSchema, created_at: z.string() })
23
+ export const storagePublicPrefixesSchema = z.object({
24
+ ok: z.literal('STORAGE_PUBLIC_PREFIXES_LISTED'),
25
+ public_prefixes: z.array(publicPrefixSchema),
26
+ })
27
+ export const storagePublicPrefixDeclaredSchema = publicPrefixSchema.extend({
28
+ ok: z.literal('STORAGE_PUBLIC_PREFIX_DECLARED'),
29
+ created: z.boolean(),
30
+ })
31
+ export const storagePublicPrefixRevokedSchema = z.object({
32
+ ok: z.literal('STORAGE_PUBLIC_PREFIX_REVOKED'),
33
+ prefix: prefixSchema,
34
+ deleted: z.boolean(),
35
+ })
36
+
37
+ export type StoragePublicPrefixes = z.infer<typeof storagePublicPrefixesSchema>
38
+ export type StoragePublicPrefixDeclared = z.infer<typeof storagePublicPrefixDeclaredSchema>
39
+ export type StoragePublicPrefixRevoked = z.infer<typeof storagePublicPrefixRevokedSchema>
@@ -0,0 +1,119 @@
1
+ import type { StoredAsset } from './alphalib/types/storageAsset.ts'
2
+ import type { Transloadit } from './Transloadit.ts'
3
+
4
+ import { z } from 'zod'
5
+
6
+ import { ApiError } from './ApiError.ts'
7
+ import { robotFileServeInstructionsSchema } from './alphalib/types/robots/file-serve.ts'
8
+ import { storedAssetSchema } from './alphalib/types/storageAsset.ts'
9
+ import InconsistentResponseError from './InconsistentResponseError.ts'
10
+
11
+ /** Original-byte delivery, not a preview or transcode; authorize the application's asset first. */
12
+ export interface StoredAssetUrlOptions {
13
+ /** True uses the receipt filename; a string supplies a different attachment filename. */
14
+ download?: boolean | string
15
+ /** Maximum signed lifetime, from one second to 48 hours. Defaults to five minutes. */
16
+ lifetimeMs?: number
17
+ }
18
+
19
+ /** Signs a retained original, including non-image media, without fetching or proxying its bytes. */
20
+ export function getStoredAssetUrl(
21
+ client: Transloadit,
22
+ input: StoredAsset,
23
+ options: StoredAssetUrlOptions = {},
24
+ ): string {
25
+ const asset = storedAssetSchema.parse(input)
26
+ const lifetimeMs = options.lifetimeMs ?? 5 * 60_000
27
+ if (!Number.isSafeInteger(lifetimeMs) || lifetimeMs < 1000 || lifetimeMs > 48 * 3600_000) {
28
+ throw new RangeError('lifetimeMs must be an integer between 1000 and 172800000')
29
+ }
30
+ const download = options.download === true ? asset.path.split('/').at(-1) : options.download
31
+ if (download !== undefined && download !== false) {
32
+ robotFileServeInstructionsSchema.shape.download_name.unwrap().min(1).parse(download)
33
+ if (typeof download !== 'string' || /[\uD800-\uDFFF]/u.test(download))
34
+ throw new TypeError('download must contain a valid Unicode filename')
35
+ }
36
+ // Bunny includes the full query in its cache key. Rotate at most once a minute, never at
37
+ // the lifetime boundary where rounding could issue an almost-expired grant.
38
+ const rotationMs = Math.min(60_000, Math.floor(lifetimeMs / 2))
39
+ const expiresAt = Math.floor(Date.now() / rotationMs) * rotationMs + lifetimeMs
40
+ return client.getSignedSmartCDNUrl({
41
+ workspace: asset.workspace,
42
+ template: 'builtin/storage-serve@0.0.3',
43
+ input: asset.asset_id,
44
+ urlParams: { v: asset.version_id, ...(typeof download === 'string' ? { download } : {}) },
45
+ expiresAt,
46
+ })
47
+ }
48
+
49
+ /** Server-owned Assembly identity and expected Workspace, not values from an untrusted notification. */
50
+ export interface GetStoredAssemblyResultsOptions {
51
+ assemblyId: string
52
+ workspace: string
53
+ }
54
+
55
+ /** A canonical stored asset with the producing result's provenance; one input can have many outputs. */
56
+ export interface StoredAssemblyResult {
57
+ assembly_id: string
58
+ step: string
59
+ result_id: string
60
+ original_id?: string | (string | null)[]
61
+ asset: StoredAsset
62
+ }
63
+
64
+ const requestSchema = z.object({
65
+ assemblyId: z.string().regex(/^[A-Za-z0-9_-]+$/),
66
+ workspace: z.string().min(1),
67
+ })
68
+ const resultSchema = storedAssetSchema.extend({
69
+ id: z.string().min(1),
70
+ original_id: z.union([z.string(), z.array(z.string().nullable())]).optional(),
71
+ })
72
+
73
+ /**
74
+ * Fetches a completed Assembly and verifies all retained results, including non-image media.
75
+ * The caller must first bind the Assembly to an authorized application upload. Register results
76
+ * idempotently by Assembly/step/result ID (or asset/version), not by filename or notification count.
77
+ */
78
+ export async function getStoredAssemblyResults(
79
+ client: Transloadit,
80
+ options: GetStoredAssemblyResultsOptions,
81
+ ): Promise<StoredAssemblyResult[]> {
82
+ const { assemblyId, workspace } = requestSchema.parse(options)
83
+ const assembly = await client.getAssembly(assemblyId)
84
+ if (assembly.assembly_id !== assemblyId) {
85
+ throw new InconsistentResponseError('The response did not match the requested Assembly')
86
+ }
87
+ if (typeof assembly.error === 'string') throw new ApiError({ body: assembly })
88
+ if (assembly.ok !== 'ASSEMBLY_COMPLETED') {
89
+ throw new InconsistentResponseError(`The Storage Assembly is not complete (${assembly.ok})`)
90
+ }
91
+ if (assembly.results === undefined) {
92
+ throw new InconsistentResponseError('The completed Assembly did not contain results')
93
+ }
94
+ const results: StoredAssemblyResult[] = []
95
+ for (const [step, files] of Object.entries(assembly.results)) {
96
+ for (const file of files) {
97
+ // Ordinary temporary/exported outputs are not Storage receipts. Partial Storage records
98
+ // must fail validation rather than disappearing from an otherwise successful batch.
99
+ if (
100
+ file.asset_id === undefined &&
101
+ file.version_id === undefined &&
102
+ file.workspace === undefined
103
+ )
104
+ continue
105
+ const parsed = resultSchema.safeParse(file)
106
+ if (!parsed.success || parsed.data.workspace !== workspace) {
107
+ throw new InconsistentResponseError(
108
+ 'The Assembly returned an invalid or cross-Workspace Storage result',
109
+ {
110
+ cause: { assemblyId, step },
111
+ },
112
+ )
113
+ }
114
+ const { id, original_id, ...asset } = parsed.data
115
+ results.push({ assembly_id: assemblyId, step, result_id: id, original_id, asset })
116
+ }
117
+ }
118
+ return results
119
+ }