From 618a91c873ae12d1d467cb4e18adafcb0d77ee84 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 2 Jun 2026 02:36:38 +0200 Subject: [PATCH 01/25] Add generated assembly feature markers --- packages/node/src/Transloadit.ts | 61 ++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/packages/node/src/Transloadit.ts b/packages/node/src/Transloadit.ts index 18ad3ef8..2ef5f71f 100644 --- a/packages/node/src/Transloadit.ts +++ b/packages/node/src/Transloadit.ts @@ -575,6 +575,67 @@ export class Transloadit { return result.data } + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + + async createTusAssembly(fileCount: number): Promise { + return await this._remoteJson< + AssemblyStatusWithUploadUrls, + CreateAssemblyParams & Record + >({ + urlSuffix: '/assemblies', + method: 'post', + params: { + await: false, + steps: { + ':original': { + output_meta: true, + result: 'debug', + robot: '/upload/handle', + }, + }, + }, + fields: { + num_expected_upload_files: fileCount, + }, + }) + } + + // + + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + + async waitForAssembly(assemblyUrl: string): Promise { + while (true) { + const result = await this._remoteJson({ + url: assemblyUrl, + isTrustedUrl: true, + method: 'get', + }) + + // Abort polling if the assembly has entered an error state + if (result.error) { + return result + } + + // The polling is done if the assembly is not uploading or executing anymore. + if (result.ok !== 'ASSEMBLY_UPLOADING' && result.ok !== 'ASSEMBLY_EXECUTING') { + return result + } + + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + } + + // + async resumeAssemblyUploads( opts: ResumeAssemblyUploadsOptions, ): Promise { From 380d7a269cb5e0a7464acf90151648db762e4e0e Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 2 Jun 2026 02:45:03 +0200 Subject: [PATCH 02/25] Add Node SDK devdock TUS assembly example --- .../api2-devdock-tus-assembly/main.ts | 237 ++++++++++++++++++ 1 file changed, 237 insertions(+) create mode 100644 packages/node/examples/api2-devdock-tus-assembly/main.ts diff --git a/packages/node/examples/api2-devdock-tus-assembly/main.ts b/packages/node/examples/api2-devdock-tus-assembly/main.ts new file mode 100644 index 00000000..fcdc53f4 --- /dev/null +++ b/packages/node/examples/api2-devdock-tus-assembly/main.ts @@ -0,0 +1,237 @@ +import { readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' + +import { Upload } from 'tus-js-client' + +import { Transloadit } from '../../src/Transloadit.ts' + +type JsonRecord = Record + +function fail(message: string): never { + throw new Error(message) +} + +function requiredEnv(name: string): string { + const value = process.env[name] + if (!value) { + fail(`${name} must be set`) + } + + return value +} + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function requireRecord(value: unknown, label: string): JsonRecord { + if (!isRecord(value)) { + fail(`${label} must be an object`) + } + + return value +} + +function requireString(value: unknown, label: string): string { + if (typeof value !== 'string') { + fail(`${label} must be a string`) + } + + return value +} + +function requireNumber(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + fail(`${label} must be a number`) + } + + return value +} + +function requireArray(value: unknown, label: string): unknown[] { + if (!Array.isArray(value)) { + fail(`${label} must be an array`) + } + + return value +} + +function readPath(value: unknown, pathParts: readonly unknown[], label: string): unknown { + let current = value + for (const part of pathParts) { + if (Array.isArray(current) && Number.isInteger(part)) { + if (part >= current.length) { + fail(`${label} path ${JSON.stringify(pathParts)} index ${part} is out of range`) + } + current = current[part] + continue + } + + if (isRecord(current) && typeof part === 'string') { + if (!Object.hasOwn(current, part)) { + fail(`${label} path ${JSON.stringify(pathParts)} is missing key ${JSON.stringify(part)}`) + } + current = current[part] + continue + } + + fail(`${label} path ${JSON.stringify(pathParts)} cannot read ${JSON.stringify(part)}`) + } + + return current +} + +function resolveValue(valueSpec: unknown, context: JsonRecord, label: string): unknown { + const spec = requireRecord(valueSpec, label) + if (Object.hasOwn(spec, 'value')) { + return spec.value + } + + const source = requireRecord(spec.source, `${label}.source`) + const root = requireString(source.root, `${label}.source.root`) + const pathParts = requireArray(source.path, `${label}.source.path`) + if (!Object.hasOwn(context, root)) { + fail(`${label} value source root ${JSON.stringify(root)} is unavailable`) + } + + return readPath(context[root], pathParts, label) +} + +function scalarString(value: unknown): string { + if (value === null) { + return 'null' + } + + if (typeof value === 'boolean') { + return value ? 'true' : 'false' + } + + return String(value) +} + +async function loadScenario(): Promise { + const scenarioPath = + process.env.API2_SDK_EXAMPLE_SCENARIO ?? path.join(import.meta.dirname, 'api2-scenario.json') + const parsed: unknown = JSON.parse(await readFile(scenarioPath, 'utf8')) + + return requireRecord(parsed, 'scenario') +} + +function scenarioBytes(uploadConfig: JsonRecord): Buffer { + const source = requireRecord(uploadConfig.source, 'upload.source') + const kind = requireString(source.kind, 'upload.source.kind') + const encoding = requireString(source.encoding, 'upload.source.encoding') + if (kind !== 'bytes') { + fail(`unsupported scenario source kind ${JSON.stringify(kind)}`) + } + + if (encoding !== 'utf8') { + fail(`unsupported scenario source encoding ${JSON.stringify(encoding)}`) + } + + return Buffer.from(requireString(source.value, 'upload.source.value'), 'utf8') +} + +function uploadMetadata( + uploadConfig: JsonRecord, + scenario: JsonRecord, + createResponse: JsonRecord, +): Record { + const context = { createResponse, scenario } + const metadata: Record = {} + for (const fieldValue of requireArray(uploadConfig.metadata, 'upload.metadata')) { + const field = requireRecord(fieldValue, 'upload.metadata[]') + const name = requireString(field.name, 'upload.metadata[].name') + metadata[name] = scalarString(resolveValue(field.value, context, `upload.metadata.${name}`)) + } + + return metadata +} + +function retryDelays(retries: unknown): number[] { + const retryCount = requireNumber(retries, 'upload.retries') + if (!Number.isInteger(retryCount) || retryCount < 0) { + fail(`unsupported retry count ${JSON.stringify(retryCount)}`) + } + + return Array.from({ length: retryCount }, () => 0) +} + +async function uploadWithTus(scenario: JsonRecord, createResponse: JsonRecord): Promise { + const uploadConfig = requireRecord(scenario.upload, 'upload') + const context = { createResponse, scenario } + const endpoint = scalarString(resolveValue(uploadConfig.tusUrl, context, 'upload.tusUrl')) + const content = scenarioBytes(uploadConfig) + if (uploadConfig.chunkSize !== 'full-file') { + fail(`unsupported chunk size policy ${JSON.stringify(uploadConfig.chunkSize)}`) + } + + return await new Promise((resolve, reject) => { + let upload: Upload | null = null + upload = new Upload(content, { + endpoint, + chunkSize: content.length, + metadata: uploadMetadata(uploadConfig, scenario, createResponse), + retryDelays: retryDelays(uploadConfig.retries), + onError: reject, + onSuccess: () => { + if (!upload?.url) { + reject(new Error('TUS upload did not expose an upload URL')) + return + } + + resolve(upload.url) + }, + }) + + upload.start() + }) +} + +async function writeResult(result: JsonRecord): Promise { + const resultPath = process.env.API2_SDK_EXAMPLE_RESULT + if (!resultPath) { + return + } + + await writeFile(resultPath, `${JSON.stringify(result, undefined, 2)}\n`) +} + +async function main(): Promise { + const scenario = await loadScenario() + const createTusAssembly = requireRecord(scenario.createTusAssembly, 'createTusAssembly') + const createInput = requireRecord(createTusAssembly.input, 'createTusAssembly.input') + + const client = new Transloadit({ + authKey: requiredEnv('TRANSLOADIT_KEY'), + authSecret: requiredEnv('TRANSLOADIT_SECRET'), + endpoint: requiredEnv('TRANSLOADIT_ENDPOINT'), + }) + + const createResponse = requireRecord( + await client.createTusAssembly(requireNumber(createInput.file_count, 'file_count')), + 'createTusAssembly response', + ) + const uploadUrl = await uploadWithTus(scenario, createResponse) + const status = requireRecord( + await client.waitForAssembly( + requireString(createResponse.assembly_ssl_url, 'createTusAssembly response.assembly_ssl_url'), + ), + 'waitForAssembly response', + ) + + await writeResult({ + createResponse, + uploadUrl, + waitOk: status.ok, + }) + + console.log( + `Node SDK devdock scenario ${requireString(scenario.scenarioId, 'scenarioId')} uploaded to ${uploadUrl}`, + ) +} + +main().catch((err: unknown) => { + console.error(err) + process.exit(1) +}) From 4c3d9724c3baa6d0f0527fb08b35729c3e60185d Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 2 Jun 2026 02:49:53 +0200 Subject: [PATCH 03/25] Mark generated Node SDK endpoint blocks --- packages/node/src/Transloadit.ts | 88 ++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/packages/node/src/Transloadit.ts b/packages/node/src/Transloadit.ts index 2ef5f71f..f41fa408 100644 --- a/packages/node/src/Transloadit.ts +++ b/packages/node/src/Transloadit.ts @@ -1029,6 +1029,12 @@ export class Transloadit { * @param params optional request options * @returns when the Credential is created */ + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + async createTemplateCredential( params: CreateTemplateCredentialParams, ): Promise { @@ -1039,6 +1045,8 @@ export class Transloadit { }) } + // + /** * Edit a Credential * @@ -1046,6 +1054,12 @@ export class Transloadit { * @param params optional request options * @returns when the Credential is edited */ + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + async editTemplateCredential( credentialId: string, params: CreateTemplateCredentialParams, @@ -1057,12 +1071,20 @@ export class Transloadit { }) } + // + /** * Delete a Credential * * @param credentialId the Credential ID * @returns when the Credential is deleted */ + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + async deleteTemplateCredential(credentialId: string): Promise { return await this._remoteJson({ urlSuffix: `/template_credentials/${credentialId}`, @@ -1070,12 +1092,20 @@ export class Transloadit { }) } + // + /** * Get a Credential * * @param credentialId the Credential ID * @returns when the Credential is retrieved */ + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + async getTemplateCredential(credentialId: string): Promise { return await this._remoteJson({ urlSuffix: `/template_credentials/${credentialId}`, @@ -1083,12 +1113,20 @@ export class Transloadit { }) } + // + /** * List all TemplateCredentials * * @param params optional request options * @returns the list of templates */ + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + async listTemplateCredentials( params?: ListTemplateCredentialsParams, ): Promise { @@ -1099,6 +1137,8 @@ export class Transloadit { }) } + // + streamTemplateCredentials(params: ListTemplateCredentialsParams) { return new PaginationStream(async (page) => ({ items: (await this.listTemplateCredentials({ ...params, page })).credentials, @@ -1111,6 +1151,12 @@ export class Transloadit { * @param params optional request options * @returns when the template is created */ + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + async createTemplate(params: CreateTemplateParams): Promise { return await this._remoteJson({ urlSuffix: '/templates', @@ -1119,6 +1165,8 @@ export class Transloadit { }) } + // + /** * Edit an Assembly Template * @@ -1126,6 +1174,12 @@ export class Transloadit { * @param params optional request options * @returns when the template is edited */ + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + async editTemplate(templateId: string, params: EditTemplateParams): Promise { return await this._remoteJson({ urlSuffix: `/templates/${templateId}`, @@ -1134,12 +1188,20 @@ export class Transloadit { }) } + // + /** * Delete an Assembly Template * * @param templateId the template ID * @returns when the template is deleted */ + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + async deleteTemplate(templateId: string): Promise { return await this._remoteJson({ urlSuffix: `/templates/${templateId}`, @@ -1147,12 +1209,20 @@ export class Transloadit { }) } + // + /** * Get an Assembly Template * * @param templateId the template ID * @returns when the template is retrieved */ + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + async getTemplate(templateId: string): Promise { return await this._remoteJson({ urlSuffix: `/templates/${templateId}`, @@ -1160,12 +1230,20 @@ export class Transloadit { }) } + // + /** * List all Assembly Templates * * @param params optional request options * @returns the list of templates */ + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + async listTemplates( params?: ListTemplatesParams, ): Promise> { @@ -1176,6 +1254,8 @@ export class Transloadit { }) } + // + streamTemplates(params?: ListTemplatesParams): PaginationStream { return new PaginationStream(async (page) => this.listTemplates({ ...params, page })) } @@ -1187,6 +1267,12 @@ export class Transloadit { * @returns with billing data * @see https://transloadit.com/docs/api/bill-date-get/ */ + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + async getBill(month: string): Promise { assert.ok(month, 'month is required') return await this._remoteJson({ @@ -1195,6 +1281,8 @@ export class Transloadit { }) } + // + calcSignature( params: OptionalAuthParams, algorithm?: string, From c647ce0474c02cbe39c38f881695d55163cb6dbc Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 2 Jun 2026 03:36:34 +0200 Subject: [PATCH 04/25] Add devdock template lifecycle example --- .../api2-devdock-template-lifecycle/main.ts | 255 ++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 packages/node/examples/api2-devdock-template-lifecycle/main.ts diff --git a/packages/node/examples/api2-devdock-template-lifecycle/main.ts b/packages/node/examples/api2-devdock-template-lifecycle/main.ts new file mode 100644 index 00000000..116bb020 --- /dev/null +++ b/packages/node/examples/api2-devdock-template-lifecycle/main.ts @@ -0,0 +1,255 @@ +import { readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' + +import { ApiError, Transloadit } from '../../src/Transloadit.ts' + +type JsonRecord = Record + +interface ScenarioContent { + additionalProperties: JsonRecord + steps: JsonRecord +} + +interface TemplateConfig { + content: ScenarioContent + namePrefix: string + requireSignatureAuth: boolean +} + +interface UpdateConfig { + content: ScenarioContent + nameSuffix: string + requireSignatureAuth: boolean +} + +interface TemplateLifecycleScenario { + delete: { + errorCodeIncludes: string + } + list: { + minimumCount: number + pageSize: number + } + scenarioId: string + template: TemplateConfig + update: UpdateConfig +} + +function fail(message: string): never { + throw new Error(message) +} + +function requiredEnv(name: string): string { + const value = process.env[name] + if (!value) { + fail(`${name} must be set`) + } + + return value +} + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function requireRecord(value: unknown, label: string): JsonRecord { + if (!isRecord(value)) { + fail(`${label} must be an object`) + } + + return value +} + +function requireString(value: unknown, label: string): string { + if (typeof value !== 'string') { + fail(`${label} must be a string`) + } + + return value +} + +function requireNumber(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + fail(`${label} must be a number`) + } + + return value +} + +function requireBoolean(value: unknown, label: string): boolean { + if (typeof value !== 'boolean') { + fail(`${label} must be a boolean`) + } + + return value +} + +function scenarioContent(value: unknown, label: string): ScenarioContent { + const content = requireRecord(value, label) + + return { + additionalProperties: requireRecord( + content.additionalProperties, + `${label}.additionalProperties`, + ), + steps: requireRecord(content.steps, `${label}.steps`), + } +} + +function templateConfig(value: unknown, label: string): TemplateConfig { + const config = requireRecord(value, label) + + return { + content: scenarioContent(config.content, `${label}.content`), + namePrefix: requireString(config.namePrefix, `${label}.namePrefix`), + requireSignatureAuth: requireBoolean( + config.requireSignatureAuth, + `${label}.requireSignatureAuth`, + ), + } +} + +function updateConfig(value: unknown, label: string): UpdateConfig { + const config = requireRecord(value, label) + + return { + content: scenarioContent(config.content, `${label}.content`), + nameSuffix: requireString(config.nameSuffix, `${label}.nameSuffix`), + requireSignatureAuth: requireBoolean( + config.requireSignatureAuth, + `${label}.requireSignatureAuth`, + ), + } +} + +async function loadScenario(): Promise { + const scenarioPath = + process.env.API2_SDK_EXAMPLE_SCENARIO ?? path.join(import.meta.dirname, 'api2-scenario.json') + const scenario = requireRecord(JSON.parse(await readFile(scenarioPath, 'utf8')), 'scenario') + const list = requireRecord(scenario.list, 'scenario.list') + const deleteConfig = requireRecord(scenario.delete, 'scenario.delete') + + return { + delete: { + errorCodeIncludes: requireString( + deleteConfig.errorCodeIncludes, + 'scenario.delete.errorCodeIncludes', + ), + }, + list: { + minimumCount: requireNumber(list.minimumCount, 'scenario.list.minimumCount'), + pageSize: requireNumber(list.pageSize, 'scenario.list.pageSize'), + }, + scenarioId: requireString(scenario.scenarioId, 'scenario.scenarioId'), + template: templateConfig(scenario.template, 'scenario.template'), + update: updateConfig(scenario.update, 'scenario.update'), + } +} + +function templatePayload(name: string, config: TemplateConfig | UpdateConfig): JsonRecord { + return { + name, + require_signature_auth: config.requireSignatureAuth ? 1 : 0, + template: { + ...config.content.additionalProperties, + steps: config.content.steps, + }, + } +} + +function requireTemplateId(value: unknown, label: string): string { + const template = requireRecord(value, label) + + return requireString(template.id, `${label}.id`) +} + +function templateResult(value: unknown): JsonRecord { + const template = requireRecord(value, 'template response') + const content = requireRecord(template.content, 'template response.content') + const requireSignatureAuth = requireNumber( + template.require_signature_auth, + 'template response.require_signature_auth', + ) + + return { + content, + id: requireString(template.id, 'template response.id'), + name: requireString(template.name, 'template response.name'), + requireSignatureAuth: requireSignatureAuth !== 0, + } +} + +async function deletedGetResult(client: Transloadit, templateId: string): Promise { + try { + await client.getTemplate(templateId) + return { + deletedErrorCode: '', + deletedGetSucceeded: true, + } + } catch (err) { + if (!(err instanceof ApiError)) { + throw err + } + + return { + deletedErrorCode: err.code ?? '', + deletedGetSucceeded: false, + } + } +} + +async function writeResult(result: JsonRecord): Promise { + const resultPath = process.env.API2_SDK_EXAMPLE_RESULT + if (!resultPath) { + return + } + + await writeFile(resultPath, `${JSON.stringify(result, undefined, 2)}\n`) +} + +async function main(): Promise { + const scenario = await loadScenario() + const client = new Transloadit({ + authKey: requiredEnv('TRANSLOADIT_KEY'), + authSecret: requiredEnv('TRANSLOADIT_SECRET'), + endpoint: requiredEnv('TRANSLOADIT_ENDPOINT'), + }) + + const templateName = `${scenario.template.namePrefix}-${Date.now()}` + const created = await client.createTemplate(templatePayload(templateName, scenario.template)) + const templateId = requireTemplateId(created, 'createTemplate response') + let deleteTemplate = true + + try { + const fetched = await client.getTemplate(templateId) + const listed = await client.listTemplates({ pagesize: scenario.list.pageSize }) + const updatedTemplateName = `${templateName}${scenario.update.nameSuffix}` + + await client.editTemplate(templateId, templatePayload(updatedTemplateName, scenario.update)) + const updated = await client.getTemplate(templateId) + + await client.deleteTemplate(templateId) + deleteTemplate = false + + await writeResult({ + ...(await deletedGetResult(client, templateId)), + fetched: templateResult(fetched), + listCount: listed.count, + templateId, + templateName, + updated: templateResult(updated), + updatedTemplateName, + }) + } finally { + if (deleteTemplate) { + await client.deleteTemplate(templateId) + } + } + + console.log(`Node SDK devdock scenario ${scenario.scenarioId} passed for ${templateId}`) +} + +main().catch((err: unknown) => { + console.error(err) + process.exit(1) +}) From fbaaf0fc5a783540d77f1e3922bd0b4098fc9310 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 2 Jun 2026 05:37:05 +0200 Subject: [PATCH 05/25] Read TUS scenario preparations generically --- .../api2-devdock-tus-assembly/main.ts | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/node/examples/api2-devdock-tus-assembly/main.ts b/packages/node/examples/api2-devdock-tus-assembly/main.ts index fcdc53f4..1ae288af 100644 --- a/packages/node/examples/api2-devdock-tus-assembly/main.ts +++ b/packages/node/examples/api2-devdock-tus-assembly/main.ts @@ -97,6 +97,28 @@ function resolveValue(valueSpec: unknown, context: JsonRecord, label: string): u return readPath(context[root], pathParts, label) } +function featurePreparation( + scenario: JsonRecord, + featureId: string, +): { label: string; preparation: JsonRecord } { + const preparations = requireArray(scenario.preparations, 'preparations') + for (const [index, rawPreparation] of preparations.entries()) { + const label = `preparations[${index}]` + const preparation = requireRecord(rawPreparation, label) + if (requireString(preparation.featureId, `${label}.featureId`) !== featureId) { + continue + } + + if (requireString(preparation.kind, `${label}.kind`) !== 'feature-call') { + fail(`${label} must be a feature-call preparation`) + } + + return { label, preparation } + } + + fail(`scenario has no preparation for feature ${JSON.stringify(featureId)}`) +} + function scalarString(value: unknown): string { if (value === null) { return 'null' @@ -199,8 +221,11 @@ async function writeResult(result: JsonRecord): Promise { async function main(): Promise { const scenario = await loadScenario() - const createTusAssembly = requireRecord(scenario.createTusAssembly, 'createTusAssembly') - const createInput = requireRecord(createTusAssembly.input, 'createTusAssembly.input') + const { label: createTusAssemblyLabel, preparation: createTusAssembly } = featurePreparation( + scenario, + 'createTusAssembly', + ) + const createInput = requireRecord(createTusAssembly.input, `${createTusAssemblyLabel}.input`) const client = new Transloadit({ authKey: requiredEnv('TRANSLOADIT_KEY'), From 95f7b0e54d5a4a2e344b2d03e5c47e23fc81de27 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 2 Jun 2026 08:03:08 +0200 Subject: [PATCH 06/25] Generate TUS assembly upload helper --- .../api2-devdock-tus-assembly/main.ts | 152 +++--------------- packages/node/src/Transloadit.ts | 103 ++++++++++++ 2 files changed, 126 insertions(+), 129 deletions(-) diff --git a/packages/node/examples/api2-devdock-tus-assembly/main.ts b/packages/node/examples/api2-devdock-tus-assembly/main.ts index 1ae288af..b4d126eb 100644 --- a/packages/node/examples/api2-devdock-tus-assembly/main.ts +++ b/packages/node/examples/api2-devdock-tus-assembly/main.ts @@ -1,8 +1,6 @@ import { readFile, writeFile } from 'node:fs/promises' import path from 'node:path' -import { Upload } from 'tus-js-client' - import { Transloadit } from '../../src/Transloadit.ts' type JsonRecord = Record @@ -56,45 +54,11 @@ function requireArray(value: unknown, label: string): unknown[] { return value } -function readPath(value: unknown, pathParts: readonly unknown[], label: string): unknown { - let current = value - for (const part of pathParts) { - if (Array.isArray(current) && Number.isInteger(part)) { - if (part >= current.length) { - fail(`${label} path ${JSON.stringify(pathParts)} index ${part} is out of range`) - } - current = current[part] - continue - } - - if (isRecord(current) && typeof part === 'string') { - if (!Object.hasOwn(current, part)) { - fail(`${label} path ${JSON.stringify(pathParts)} is missing key ${JSON.stringify(part)}`) - } - current = current[part] - continue - } - - fail(`${label} path ${JSON.stringify(pathParts)} cannot read ${JSON.stringify(part)}`) - } - - return current -} - -function resolveValue(valueSpec: unknown, context: JsonRecord, label: string): unknown { - const spec = requireRecord(valueSpec, label) - if (Object.hasOwn(spec, 'value')) { - return spec.value - } - - const source = requireRecord(spec.source, `${label}.source`) - const root = requireString(source.root, `${label}.source.root`) - const pathParts = requireArray(source.path, `${label}.source.path`) - if (!Object.hasOwn(context, root)) { - fail(`${label} value source root ${JSON.stringify(root)} is unavailable`) - } - - return readPath(context[root], pathParts, label) +function stringRecord(value: unknown, label: string): Record { + const record = requireRecord(value, label) + return Object.fromEntries( + Object.entries(record).map(([key, entryValue]) => [key, String(entryValue)]), + ) } function featurePreparation( @@ -119,18 +83,6 @@ function featurePreparation( fail(`scenario has no preparation for feature ${JSON.stringify(featureId)}`) } -function scalarString(value: unknown): string { - if (value === null) { - return 'null' - } - - if (typeof value === 'boolean') { - return value ? 'true' : 'false' - } - - return String(value) -} - async function loadScenario(): Promise { const scenarioPath = process.env.API2_SDK_EXAMPLE_SCENARIO ?? path.join(import.meta.dirname, 'api2-scenario.json') @@ -139,6 +91,13 @@ async function loadScenario(): Promise { return requireRecord(parsed, 'scenario') } +function fileCount(scenario: JsonRecord): number { + const { label, preparation } = featurePreparation(scenario, 'createTusAssembly') + const input = requireRecord(preparation.input, `${label}.input`) + + return requireNumber(input.file_count, `${label}.input.file_count`) +} + function scenarioBytes(uploadConfig: JsonRecord): Buffer { const source = requireRecord(uploadConfig.source, 'upload.source') const kind = requireString(source.kind, 'upload.source.kind') @@ -154,62 +113,6 @@ function scenarioBytes(uploadConfig: JsonRecord): Buffer { return Buffer.from(requireString(source.value, 'upload.source.value'), 'utf8') } -function uploadMetadata( - uploadConfig: JsonRecord, - scenario: JsonRecord, - createResponse: JsonRecord, -): Record { - const context = { createResponse, scenario } - const metadata: Record = {} - for (const fieldValue of requireArray(uploadConfig.metadata, 'upload.metadata')) { - const field = requireRecord(fieldValue, 'upload.metadata[]') - const name = requireString(field.name, 'upload.metadata[].name') - metadata[name] = scalarString(resolveValue(field.value, context, `upload.metadata.${name}`)) - } - - return metadata -} - -function retryDelays(retries: unknown): number[] { - const retryCount = requireNumber(retries, 'upload.retries') - if (!Number.isInteger(retryCount) || retryCount < 0) { - fail(`unsupported retry count ${JSON.stringify(retryCount)}`) - } - - return Array.from({ length: retryCount }, () => 0) -} - -async function uploadWithTus(scenario: JsonRecord, createResponse: JsonRecord): Promise { - const uploadConfig = requireRecord(scenario.upload, 'upload') - const context = { createResponse, scenario } - const endpoint = scalarString(resolveValue(uploadConfig.tusUrl, context, 'upload.tusUrl')) - const content = scenarioBytes(uploadConfig) - if (uploadConfig.chunkSize !== 'full-file') { - fail(`unsupported chunk size policy ${JSON.stringify(uploadConfig.chunkSize)}`) - } - - return await new Promise((resolve, reject) => { - let upload: Upload | null = null - upload = new Upload(content, { - endpoint, - chunkSize: content.length, - metadata: uploadMetadata(uploadConfig, scenario, createResponse), - retryDelays: retryDelays(uploadConfig.retries), - onError: reject, - onSuccess: () => { - if (!upload?.url) { - reject(new Error('TUS upload did not expose an upload URL')) - return - } - - resolve(upload.url) - }, - }) - - upload.start() - }) -} - async function writeResult(result: JsonRecord): Promise { const resultPath = process.env.API2_SDK_EXAMPLE_RESULT if (!resultPath) { @@ -221,38 +124,29 @@ async function writeResult(result: JsonRecord): Promise { async function main(): Promise { const scenario = await loadScenario() - const { label: createTusAssemblyLabel, preparation: createTusAssembly } = featurePreparation( - scenario, - 'createTusAssembly', - ) - const createInput = requireRecord(createTusAssembly.input, `${createTusAssemblyLabel}.input`) - + const upload = requireRecord(scenario.upload, 'upload') const client = new Transloadit({ authKey: requiredEnv('TRANSLOADIT_KEY'), authSecret: requiredEnv('TRANSLOADIT_SECRET'), endpoint: requiredEnv('TRANSLOADIT_ENDPOINT'), }) - const createResponse = requireRecord( - await client.createTusAssembly(requireNumber(createInput.file_count, 'file_count')), - 'createTusAssembly response', - ) - const uploadUrl = await uploadWithTus(scenario, createResponse) - const status = requireRecord( - await client.waitForAssembly( - requireString(createResponse.assembly_ssl_url, 'createTusAssembly response.assembly_ssl_url'), - ), - 'waitForAssembly response', + const result = await client.uploadTusAssembly( + fileCount(scenario), + scenarioBytes(upload), + requireString(upload.fieldName, 'upload.fieldName'), + requireString(upload.fileName, 'upload.fileName'), + stringRecord(upload.userMeta, 'upload.userMeta'), ) await writeResult({ - createResponse, - uploadUrl, - waitOk: status.ok, + createResponse: result.assembly, + uploadUrl: result.uploadUrl, + waitOk: result.assembly.ok, }) console.log( - `Node SDK devdock scenario ${requireString(scenario.scenarioId, 'scenarioId')} uploaded to ${uploadUrl}`, + `Node SDK devdock scenario ${requireString(scenario.scenarioId, 'scenarioId')} uploaded to ${result.uploadUrl}`, ) } diff --git a/packages/node/src/Transloadit.ts b/packages/node/src/Transloadit.ts index f41fa408..a9f1965c 100644 --- a/packages/node/src/Transloadit.ts +++ b/packages/node/src/Transloadit.ts @@ -108,6 +108,11 @@ export type AssemblyStatusWithUploadUrls = AssemblyStatus & { upload_urls?: Record } +export interface UploadTusAssemblyResult { + assembly: AssemblyStatus + uploadUrl: string +} + const { version } = packageJson export type AssemblyProgress = (assembly: AssemblyStatus) => void @@ -636,6 +641,104 @@ export class Transloadit { // + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + + async uploadTusAssembly( + fileCount: number, + content: Buffer | Uint8Array | string, + fieldname: string, + filename: string, + userMeta: Record, + ): Promise { + const createdAssembly = await this.createTusAssembly(fileCount) + + const endpointUrl = createdAssembly.tus_url + if (!endpointUrl) { + throw new Error('TUS singleUploadLifecycle needs input.endpointUrl') + } + + const contentBytes = Buffer.isBuffer(content) ? content : Buffer.from(content) + + const metadataMap = new Map() + for (const [key, value] of Object.entries(userMeta)) { + metadataMap.set(String(key), String(value)) + } + metadataMap.set('assembly_url', String(createdAssembly.assembly_url)) + metadataMap.set('fieldname', String(fieldname)) + metadataMap.set('filename', String(filename)) + + const createHeaders: Record = {} + createHeaders['Tus-Resumable'] = '1.0.0' + createHeaders['Upload-Length'] = String(contentBytes.length) + const createMetadataParts: string[] = [] + for (const [key, value] of metadataMap) { + const encodedValue = Buffer.from(String(value), 'utf8').toString('base64') + createMetadataParts.push(`${key} ${encodedValue}`) + } + createHeaders['Upload-Metadata'] = createMetadataParts.join(',') + const createResponse = await got(endpointUrl, { + method: 'POST', + body: Buffer.alloc(0), + headers: createHeaders, + retry: this._gotRetry, + throwHttpErrors: false, + timeout: { request: this._defaultTimeout }, + }) + + if (createResponse.statusCode !== 201) { + throw new Error(`TUS create returned HTTP ${createResponse.statusCode}, expected 201`) + } + const uploadUrlLocation = createResponse.headers.location + const uploadUrlLocationText = Array.isArray(uploadUrlLocation) + ? uploadUrlLocation[0] + : uploadUrlLocation + if (!uploadUrlLocationText) { + throw new Error('TUS create did not return a Location header') + } + const uploadUrlText = new URL(uploadUrlLocationText, endpointUrl).toString() + + const uploadHeaders: Record = {} + uploadHeaders['Tus-Resumable'] = '1.0.0' + uploadHeaders['Upload-Offset'] = '0' + uploadHeaders['Content-Type'] = 'application/offset+octet-stream' + const uploadResponse = await got(uploadUrlText, { + method: 'PATCH', + body: contentBytes, + headers: uploadHeaders, + retry: this._gotRetry, + throwHttpErrors: false, + timeout: { request: this._defaultTimeout }, + }) + + if (uploadResponse.statusCode !== 204) { + throw new Error(`TUS upload returned HTTP ${uploadResponse.statusCode}, expected 204`) + } + const uploadOffsetHeader = uploadResponse.headers['upload-offset'] + const uploadOffsetText = Array.isArray(uploadOffsetHeader) + ? uploadOffsetHeader[0] + : uploadOffsetHeader + if (!uploadOffsetText) { + throw new Error('TUS upload returned an invalid Upload-Offset header') + } + const remoteOffset = Number(uploadOffsetText) + if (!Number.isInteger(remoteOffset)) { + throw new Error('TUS upload returned an invalid Upload-Offset header') + } + if (remoteOffset !== contentBytes.length) { + throw new Error(`TUS upload offset ${remoteOffset}, expected ${contentBytes.length}`) + } + + const completedAssembly = await this.waitForAssembly(createdAssembly.assembly_ssl_url ?? '') + + return { assembly: completedAssembly, uploadUrl: uploadUrlText } + } + + // + async resumeAssemblyUploads( opts: ResumeAssemblyUploadsOptions, ): Promise { From a5b4c9aa6cb250601e66360227c5cbb876dd68e7 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 3 Jun 2026 01:01:35 +0200 Subject: [PATCH 07/25] Use header-derived TUS offset variable --- packages/node/src/Transloadit.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/node/src/Transloadit.ts b/packages/node/src/Transloadit.ts index a9f1965c..5e1768bc 100644 --- a/packages/node/src/Transloadit.ts +++ b/packages/node/src/Transloadit.ts @@ -724,12 +724,12 @@ export class Transloadit { if (!uploadOffsetText) { throw new Error('TUS upload returned an invalid Upload-Offset header') } - const remoteOffset = Number(uploadOffsetText) - if (!Number.isInteger(remoteOffset)) { + const uploadOffset = Number(uploadOffsetText) + if (!Number.isInteger(uploadOffset)) { throw new Error('TUS upload returned an invalid Upload-Offset header') } - if (remoteOffset !== contentBytes.length) { - throw new Error(`TUS upload offset ${remoteOffset}, expected ${contentBytes.length}`) + if (uploadOffset !== contentBytes.length) { + throw new Error(`TUS upload offset ${uploadOffset}, expected ${contentBytes.length}`) } const completedAssembly = await this.waitForAssembly(createdAssembly.assembly_ssl_url ?? '') From 0a88f25d197324a6a9acc015e2def5de9711b8df Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 3 Jun 2026 05:05:48 +0200 Subject: [PATCH 08/25] Read TUS example input from SDK feature call --- .../api2-devdock-tus-assembly/main.ts | 67 ++++++++++--------- 1 file changed, 35 insertions(+), 32 deletions(-) diff --git a/packages/node/examples/api2-devdock-tus-assembly/main.ts b/packages/node/examples/api2-devdock-tus-assembly/main.ts index b4d126eb..5b334b53 100644 --- a/packages/node/examples/api2-devdock-tus-assembly/main.ts +++ b/packages/node/examples/api2-devdock-tus-assembly/main.ts @@ -61,26 +61,34 @@ function stringRecord(value: unknown, label: string): Record { ) } -function featurePreparation( +function optionalStringRecord(value: unknown, label: string): Record { + if (value == null) { + return {} + } + + return stringRecord(value, label) +} + +function sdkFeatureCall( scenario: JsonRecord, featureId: string, -): { label: string; preparation: JsonRecord } { - const preparations = requireArray(scenario.preparations, 'preparations') - for (const [index, rawPreparation] of preparations.entries()) { - const label = `preparations[${index}]` - const preparation = requireRecord(rawPreparation, label) - if (requireString(preparation.featureId, `${label}.featureId`) !== featureId) { +): { featureCall: JsonRecord; label: string } { + const featureCalls = requireArray(scenario.sdkFeatureCalls, 'sdkFeatureCalls') + for (const [index, rawFeatureCall] of featureCalls.entries()) { + const label = `sdkFeatureCalls[${index}]` + const featureCall = requireRecord(rawFeatureCall, label) + if (requireString(featureCall.featureId, `${label}.featureId`) !== featureId) { continue } - if (requireString(preparation.kind, `${label}.kind`) !== 'feature-call') { - fail(`${label} must be a feature-call preparation`) + if (requireString(featureCall.kind, `${label}.kind`) !== 'sdk-feature-call') { + fail(`${label} must be an sdk-feature-call`) } - return { label, preparation } + return { featureCall, label } } - fail(`scenario has no preparation for feature ${JSON.stringify(featureId)}`) + fail(`scenario has no SDK feature call for feature ${JSON.stringify(featureId)}`) } async function loadScenario(): Promise { @@ -91,26 +99,17 @@ async function loadScenario(): Promise { return requireRecord(parsed, 'scenario') } -function fileCount(scenario: JsonRecord): number { - const { label, preparation } = featurePreparation(scenario, 'createTusAssembly') - const input = requireRecord(preparation.input, `${label}.input`) +function uploadTusAssemblyInput(scenario: JsonRecord): JsonRecord { + const { featureCall, label } = sdkFeatureCall(scenario, 'uploadTusAssembly') - return requireNumber(input.file_count, `${label}.input.file_count`) + return requireRecord(featureCall.input, `${label}.input`) } function scenarioBytes(uploadConfig: JsonRecord): Buffer { - const source = requireRecord(uploadConfig.source, 'upload.source') - const kind = requireString(source.kind, 'upload.source.kind') - const encoding = requireString(source.encoding, 'upload.source.encoding') - if (kind !== 'bytes') { - fail(`unsupported scenario source kind ${JSON.stringify(kind)}`) - } - - if (encoding !== 'utf8') { - fail(`unsupported scenario source encoding ${JSON.stringify(encoding)}`) - } - - return Buffer.from(requireString(source.value, 'upload.source.value'), 'utf8') + return Buffer.from( + requireString(uploadConfig.content, 'sdkFeatureCalls.uploadTusAssembly.input.upload.content'), + 'utf8', + ) } async function writeResult(result: JsonRecord): Promise { @@ -124,7 +123,8 @@ async function writeResult(result: JsonRecord): Promise { async function main(): Promise { const scenario = await loadScenario() - const upload = requireRecord(scenario.upload, 'upload') + const input = uploadTusAssemblyInput(scenario) + const upload = requireRecord(input.upload, 'sdkFeatureCalls.uploadTusAssembly.input.upload') const client = new Transloadit({ authKey: requiredEnv('TRANSLOADIT_KEY'), authSecret: requiredEnv('TRANSLOADIT_SECRET'), @@ -132,11 +132,14 @@ async function main(): Promise { }) const result = await client.uploadTusAssembly( - fileCount(scenario), + requireNumber(input.file_count, 'sdkFeatureCalls.uploadTusAssembly.input.file_count'), scenarioBytes(upload), - requireString(upload.fieldName, 'upload.fieldName'), - requireString(upload.fileName, 'upload.fileName'), - stringRecord(upload.userMeta, 'upload.userMeta'), + requireString(upload.fieldname, 'sdkFeatureCalls.uploadTusAssembly.input.upload.fieldname'), + requireString(upload.filename, 'sdkFeatureCalls.uploadTusAssembly.input.upload.filename'), + optionalStringRecord( + upload.user_meta, + 'sdkFeatureCalls.uploadTusAssembly.input.upload.user_meta', + ), ) await writeResult({ From 49bde3b8c08a590234487c9f21f9b776e3c5f09d Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 3 Jun 2026 20:51:26 +0200 Subject: [PATCH 09/25] Read SDK example input projection --- .../api2-devdock-tus-assembly/main.ts | 115 ++++++++++-------- 1 file changed, 65 insertions(+), 50 deletions(-) diff --git a/packages/node/examples/api2-devdock-tus-assembly/main.ts b/packages/node/examples/api2-devdock-tus-assembly/main.ts index 5b334b53..25c7d90e 100644 --- a/packages/node/examples/api2-devdock-tus-assembly/main.ts +++ b/packages/node/examples/api2-devdock-tus-assembly/main.ts @@ -5,6 +5,29 @@ import { Transloadit } from '../../src/Transloadit.ts' type JsonRecord = Record +interface ExampleInput { + scenarioId: string + sdkFeatureInputs: { + uploadTusAssembly: UploadTusAssemblyInput + } +} + +interface TusAssemblyScenario { + exampleInput: ExampleInput +} + +interface UploadConfig { + content: string + fieldname: string + filename: string + user_meta: Record +} + +interface UploadTusAssemblyInput { + file_count: number + upload: UploadConfig +} + function fail(message: string): never { throw new Error(message) } @@ -46,14 +69,6 @@ function requireNumber(value: unknown, label: string): number { return value } -function requireArray(value: unknown, label: string): unknown[] { - if (!Array.isArray(value)) { - fail(`${label} must be an array`) - } - - return value -} - function stringRecord(value: unknown, label: string): Record { const record = requireRecord(value, label) return Object.fromEntries( @@ -69,47 +84,50 @@ function optionalStringRecord(value: unknown, label: string): Record { +async function loadScenario(): Promise { const scenarioPath = process.env.API2_SDK_EXAMPLE_SCENARIO ?? path.join(import.meta.dirname, 'api2-scenario.json') const parsed: unknown = JSON.parse(await readFile(scenarioPath, 'utf8')) + const scenario = requireRecord(parsed, 'scenario') - return requireRecord(parsed, 'scenario') -} - -function uploadTusAssemblyInput(scenario: JsonRecord): JsonRecord { - const { featureCall, label } = sdkFeatureCall(scenario, 'uploadTusAssembly') - - return requireRecord(featureCall.input, `${label}.input`) -} - -function scenarioBytes(uploadConfig: JsonRecord): Buffer { - return Buffer.from( - requireString(uploadConfig.content, 'sdkFeatureCalls.uploadTusAssembly.input.upload.content'), - 'utf8', - ) + return { + exampleInput: exampleInput(scenario.exampleInput, 'scenario.exampleInput'), + } } async function writeResult(result: JsonRecord): Promise { @@ -123,8 +141,8 @@ async function writeResult(result: JsonRecord): Promise { async function main(): Promise { const scenario = await loadScenario() - const input = uploadTusAssemblyInput(scenario) - const upload = requireRecord(input.upload, 'sdkFeatureCalls.uploadTusAssembly.input.upload') + const input = scenario.exampleInput.sdkFeatureInputs.uploadTusAssembly + const upload = input.upload const client = new Transloadit({ authKey: requiredEnv('TRANSLOADIT_KEY'), authSecret: requiredEnv('TRANSLOADIT_SECRET'), @@ -132,14 +150,11 @@ async function main(): Promise { }) const result = await client.uploadTusAssembly( - requireNumber(input.file_count, 'sdkFeatureCalls.uploadTusAssembly.input.file_count'), - scenarioBytes(upload), - requireString(upload.fieldname, 'sdkFeatureCalls.uploadTusAssembly.input.upload.fieldname'), - requireString(upload.filename, 'sdkFeatureCalls.uploadTusAssembly.input.upload.filename'), - optionalStringRecord( - upload.user_meta, - 'sdkFeatureCalls.uploadTusAssembly.input.upload.user_meta', - ), + input.file_count, + Buffer.from(upload.content, 'utf8'), + upload.fieldname, + upload.filename, + upload.user_meta, ) await writeResult({ @@ -149,7 +164,7 @@ async function main(): Promise { }) console.log( - `Node SDK devdock scenario ${requireString(scenario.scenarioId, 'scenarioId')} uploaded to ${result.uploadUrl}`, + `Node SDK devdock scenario ${scenario.exampleInput.scenarioId} uploaded to ${result.uploadUrl}`, ) } From feefa6dde506fca7b4f0cba2912a4930a9996984 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 5 Jun 2026 17:07:54 +0200 Subject: [PATCH 10/25] Regenerate contract-owned TUS Assembly surfaces --- packages/node/src/Transloadit.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/node/src/Transloadit.ts b/packages/node/src/Transloadit.ts index 5e1768bc..08d3d1b5 100644 --- a/packages/node/src/Transloadit.ts +++ b/packages/node/src/Transloadit.ts @@ -108,11 +108,19 @@ export type AssemblyStatusWithUploadUrls = AssemblyStatus & { upload_urls?: Record } +// + +// This block is generated from Transloadit API2 contracts. If it looks wrong, +// please report the issue instead of editing this block by hand; the source fix +// belongs in the contract generator so all SDKs stay in sync. + export interface UploadTusAssemblyResult { assembly: AssemblyStatus uploadUrl: string } +// + const { version } = packageJson export type AssemblyProgress = (assembly: AssemblyStatus) => void @@ -586,6 +594,9 @@ export class Transloadit { // please report the issue instead of editing this block by hand; the source fix // belongs in the contract generator so all SDKs stay in sync. + /** + * Creates a TUS-ready Assembly that waits for the requested number of resumable uploads before execution continues. + */ async createTusAssembly(fileCount: number): Promise { return await this._remoteJson< AssemblyStatusWithUploadUrls, @@ -617,6 +628,10 @@ export class Transloadit { // please report the issue instead of editing this block by hand; the source fix // belongs in the contract generator so all SDKs stay in sync. + /** + * Waits for an Assembly to finish uploading and executing. + * Use the returned assembly_ssl_url as the assembly URL. + */ async waitForAssembly(assemblyUrl: string): Promise { while (true) { const result = await this._remoteJson({ @@ -647,6 +662,9 @@ export class Transloadit { // please report the issue instead of editing this block by hand; the source fix // belongs in the contract generator so all SDKs stay in sync. + /** + * Creates a TUS-ready Assembly, uploads one file with the TUS protocol, and waits for the Assembly to finish. + */ async uploadTusAssembly( fileCount: number, content: Buffer | Uint8Array | string, From 8b235a10e195e8ab668922b996b0b37d0abe96fb Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 5 Jun 2026 17:13:48 +0200 Subject: [PATCH 11/25] Skip coverage publish on pull requests --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f2d1e9ea..d1b1296c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -229,7 +229,10 @@ jobs: env: COVERAGE_REPO_SSH_PRIVATE_KEY: ${{ secrets.COVERAGE_REPO_SSH_PRIVATE_KEY }} run: | - if [ -n "$COVERAGE_REPO_SSH_PRIVATE_KEY" ]; then + if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then + echo "enabled=false" >> "$GITHUB_OUTPUT" + echo "Coverage publish skipped for pull request runs." + elif [ -n "$COVERAGE_REPO_SSH_PRIVATE_KEY" ]; then echo "enabled=true" >> "$GITHUB_OUTPUT" else echo "enabled=false" >> "$GITHUB_OUTPUT" From eaa74e8abd4da469465626bcbb0a579f9f34b7bc Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 5 Jun 2026 18:29:58 +0200 Subject: [PATCH 12/25] Regenerate required feature value guards --- packages/node/src/Transloadit.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/node/src/Transloadit.ts b/packages/node/src/Transloadit.ts index 08d3d1b5..0693f3e9 100644 --- a/packages/node/src/Transloadit.ts +++ b/packages/node/src/Transloadit.ts @@ -750,7 +750,11 @@ export class Transloadit { throw new Error(`TUS upload offset ${uploadOffset}, expected ${contentBytes.length}`) } - const completedAssembly = await this.waitForAssembly(createdAssembly.assembly_ssl_url ?? '') + const createdAssemblyAssemblySslUrl = createdAssembly.assembly_ssl_url + if (!createdAssemblyAssemblySslUrl) { + throw new Error('uploadTusAssembly needs createdAssembly.assembly_ssl_url') + } + const completedAssembly = await this.waitForAssembly(createdAssemblyAssemblySslUrl) return { assembly: completedAssembly, uploadUrl: uploadUrlText } } From c939584bc29c4dda67cc6b4ec317042c3d62bb1b Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 10 Jun 2026 08:15:49 +0200 Subject: [PATCH 13/25] Add Assembly lifecycle devdock example --- .../api2-devdock-assembly-lifecycle/main.ts | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 packages/node/examples/api2-devdock-assembly-lifecycle/main.ts diff --git a/packages/node/examples/api2-devdock-assembly-lifecycle/main.ts b/packages/node/examples/api2-devdock-assembly-lifecycle/main.ts new file mode 100644 index 00000000..9a085a81 --- /dev/null +++ b/packages/node/examples/api2-devdock-assembly-lifecycle/main.ts @@ -0,0 +1,134 @@ +import { readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' + +import { Transloadit } from '../../src/Transloadit.ts' + +type JsonRecord = Record + +interface AssemblyLifecycleScenario { + assembly: { + fileCount: number + } + list: { + minimumCount: number + pageSize: number + } + scenarioId: string +} + +function fail(message: string): never { + throw new Error(message) +} + +function requiredEnv(name: string): string { + const value = process.env[name] + if (!value) { + fail(`${name} must be set`) + } + + return value +} + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function requireRecord(value: unknown, label: string): JsonRecord { + if (!isRecord(value)) { + fail(`${label} must be an object`) + } + + return value +} + +function requireString(value: unknown, label: string): string { + if (typeof value !== 'string') { + fail(`${label} must be a string`) + } + + return value +} + +function requireNumber(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + fail(`${label} must be a number`) + } + + return value +} + +async function loadScenario(): Promise { + const scenarioPath = + process.env.API2_SDK_EXAMPLE_SCENARIO ?? path.join(import.meta.dirname, 'api2-scenario.json') + const scenario = requireRecord(JSON.parse(await readFile(scenarioPath, 'utf8')), 'scenario') + const assembly = requireRecord(scenario.assembly, 'scenario.assembly') + const list = requireRecord(scenario.list, 'scenario.list') + + return { + assembly: { + fileCount: requireNumber(assembly.fileCount, 'scenario.assembly.fileCount'), + }, + list: { + minimumCount: requireNumber(list.minimumCount, 'scenario.list.minimumCount'), + pageSize: requireNumber(list.pageSize, 'scenario.list.pageSize'), + }, + scenarioId: requireString(scenario.scenarioId, 'scenario.scenarioId'), + } +} + +function assemblyResult(value: JsonRecord): JsonRecord { + return { + assemblyId: value.assembly_id, + assemblySslUrl: value.assembly_ssl_url, + assemblyUrl: value.assembly_url, + ok: value.ok, + } +} + +async function writeResult(result: JsonRecord): Promise { + const resultPath = process.env.API2_SDK_EXAMPLE_RESULT + if (!resultPath) { + return + } + + await writeFile(resultPath, `${JSON.stringify(result, undefined, 2)}\n`) +} + +async function main(): Promise { + const scenario = await loadScenario() + const client = new Transloadit({ + authKey: requiredEnv('TRANSLOADIT_KEY'), + authSecret: requiredEnv('TRANSLOADIT_SECRET'), + endpoint: requiredEnv('TRANSLOADIT_ENDPOINT'), + }) + + const created = await client.createTusAssembly(scenario.assembly.fileCount) + const assemblyId = requireString(created.assembly_id, 'createTusAssembly response.assembly_id') + let cancelOnExit = true + + try { + const fetched = await client.getAssembly(assemblyId) + const listed = await client.listAssemblies({ pagesize: scenario.list.pageSize }) + const cancelled = await client.cancelAssembly(assemblyId) + cancelOnExit = false + + await writeResult({ + cancelled: assemblyResult(cancelled), + created: assemblyResult(created), + fetched: assemblyResult(fetched), + listContainsCreated: listed.items.some((assembly) => assembly.id === assemblyId), + listCount: listed.count, + }) + } finally { + if (cancelOnExit) { + await client.cancelAssembly(assemblyId) + } + } + + console.log(`Node SDK devdock scenario ${scenario.scenarioId} canceled Assembly ${assemblyId}`) +} + +main().catch((err: unknown) => { + console.error(err) + process.exit(1) +}) From f44dc76f365df61bec4e3dd81bc87ddb7cf2a8f7 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 10 Jun 2026 08:23:03 +0200 Subject: [PATCH 14/25] Use SSL Assembly URL for TUS metadata --- packages/node/src/Transloadit.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/node/src/Transloadit.ts b/packages/node/src/Transloadit.ts index 0693f3e9..137600ee 100644 --- a/packages/node/src/Transloadit.ts +++ b/packages/node/src/Transloadit.ts @@ -685,7 +685,7 @@ export class Transloadit { for (const [key, value] of Object.entries(userMeta)) { metadataMap.set(String(key), String(value)) } - metadataMap.set('assembly_url', String(createdAssembly.assembly_url)) + metadataMap.set('assembly_url', String(createdAssembly.assembly_ssl_url)) metadataMap.set('fieldname', String(fieldname)) metadataMap.set('filename', String(filename)) From 35ccbbda138571e1a304e5ba071159a26135a6f2 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 10 Jun 2026 09:20:06 +0200 Subject: [PATCH 15/25] Filter Assembly lifecycle list by assembly_id The devdock Assembly lifecycle proof listed the first page of Assemblies and could miss the created one on a busy server. The contract models an assembly_id list filter (already used by the Go example), so expose it on ListAssembliesParams and use it in the example. Co-Authored-By: Claude Fable 5 --- .../node/examples/api2-devdock-assembly-lifecycle/main.ts | 5 ++++- packages/node/src/apiTypes.ts | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/node/examples/api2-devdock-assembly-lifecycle/main.ts b/packages/node/examples/api2-devdock-assembly-lifecycle/main.ts index 9a085a81..7d8f1633 100644 --- a/packages/node/examples/api2-devdock-assembly-lifecycle/main.ts +++ b/packages/node/examples/api2-devdock-assembly-lifecycle/main.ts @@ -108,7 +108,10 @@ async function main(): Promise { try { const fetched = await client.getAssembly(assemblyId) - const listed = await client.listAssemblies({ pagesize: scenario.list.pageSize }) + const listed = await client.listAssemblies({ + assembly_id: assemblyId, + pagesize: scenario.list.pageSize, + }) const cancelled = await client.cancelAssembly(assemblyId) cancelOnExit = false diff --git a/packages/node/src/apiTypes.ts b/packages/node/src/apiTypes.ts index 702e42e2..8ec3e83b 100644 --- a/packages/node/src/apiTypes.ts +++ b/packages/node/src/apiTypes.ts @@ -34,6 +34,7 @@ export interface PaginationListWithCount extends PaginationList { export type CreateAssemblyParams = Omit & OptionalAuthParams export type ListAssembliesParams = OptionalAuthParams & { + assembly_id?: string page?: number pagesize?: number type?: 'all' | 'uploading' | 'executing' | 'canceled' | 'completed' | 'failed' | 'request_aborted' From f050b8663f6e82c73992868777b2e9b69aa6353d Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 11 Jun 2026 08:40:23 +0200 Subject: [PATCH 16/25] Prove TUS resume upload via generated SDK method resumeTusUpload() is generated from the API2 resumeUpload TUS protocol contract: it discovers the server offset with a HEAD request, PATCHes the remaining bytes from that offset, asserts the final offset matches the content length, and waits for the Assembly to finish. The new api2-devdock-tus-resume-upload example interrupts an upload after the first chunk like a dropped connection would, then resumes it through the public SDK method. Co-Authored-By: Claude Fable 5 --- .../api2-devdock-tus-resume-upload/main.ts | 276 ++++++++++++++++++ packages/node/src/Transloadit.ts | 84 ++++++ 2 files changed, 360 insertions(+) create mode 100644 packages/node/examples/api2-devdock-tus-resume-upload/main.ts diff --git a/packages/node/examples/api2-devdock-tus-resume-upload/main.ts b/packages/node/examples/api2-devdock-tus-resume-upload/main.ts new file mode 100644 index 00000000..4665e3ad --- /dev/null +++ b/packages/node/examples/api2-devdock-tus-resume-upload/main.ts @@ -0,0 +1,276 @@ +// Run the API2 contract TUS resume scenario against a devdock API2 server. +// +// This example is intentionally checked into the SDK repository: it reads the +// API/TUS facts from API2's injected scenario JSON, interrupts an upload like +// an unlucky user would, and resumes it through the public SDK method. +import { readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' + +import { Transloadit } from '../../src/Transloadit.ts' + +type JsonRecord = Record + +interface MetadataField { + name: string + value: unknown +} + +interface ResumePlan { + fingerprint: string + removeFingerprintOnSuccess: boolean + stopAfterAcceptedBytes: number +} + +interface ResumeUploadScenario { + createResponse: JsonRecord + scenario: JsonRecord + scenarioId: string +} + +function fail(message: string): never { + throw new Error(message) +} + +function requiredEnv(name: string): string { + const value = process.env[name] + if (!value) { + fail(`${name} must be set`) + } + + return value +} + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function requireRecord(value: unknown, label: string): JsonRecord { + if (!isRecord(value)) { + fail(`${label} must be an object`) + } + + return value +} + +function requireString(value: unknown, label: string): string { + if (typeof value !== 'string') { + fail(`${label} must be a string`) + } + + return value +} + +function requireNumber(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + fail(`${label} must be a number`) + } + + return value +} + +function requireBoolean(value: unknown, label: string): boolean { + if (typeof value !== 'boolean') { + fail(`${label} must be a boolean`) + } + + return value +} + +async function loadScenario(): Promise { + const scenarioPath = + process.env.API2_SDK_EXAMPLE_SCENARIO ?? path.join(import.meta.dirname, 'api2-scenario.json') + const scenario = requireRecord(JSON.parse(await readFile(scenarioPath, 'utf8')), 'scenario') + const exampleInput = requireRecord(scenario.exampleInput, 'scenario.exampleInput') + const prepared = requireRecord(scenario.prepared, 'scenario.prepared') + + return { + createResponse: requireRecord(prepared.createResponse, 'scenario.prepared.createResponse'), + scenario, + scenarioId: requireString(exampleInput.scenarioId, 'scenario.exampleInput.scenarioId'), + } +} + +function resolveValue(valueSpec: unknown, context: JsonRecord, label: string): unknown { + const spec = requireRecord(valueSpec, `${label} value spec`) + if ('value' in spec) { + return spec.value + } + + const source = requireRecord(spec.source, `${label} value source`) + const root = requireString(source.root, `${label} value source root`) + let current: unknown = context[root] ?? fail(`${label} value source root is unavailable`) + if (!Array.isArray(source.path)) { + fail(`${label} value source path must be an array`) + } + for (const part of source.path) { + const record = requireRecord(current, `${label} value source step`) + current = record[String(part)] + } + + return current +} + +function scenarioBytes(upload: JsonRecord): Buffer { + const source = requireRecord(upload.source, 'upload.source') + if (source.kind !== 'bytes') { + fail('upload.source.kind must be bytes') + } + if (source.encoding !== 'utf8') { + fail('upload.source.encoding must be utf8') + } + + return Buffer.from(requireString(source.value, 'upload.source.value'), 'utf8') +} + +function resumePlan(upload: JsonRecord): ResumePlan { + const resume = requireRecord(upload.resume, 'upload.resume') + + return { + fingerprint: requireString(resume.fingerprint, 'upload.resume.fingerprint'), + removeFingerprintOnSuccess: requireBoolean( + resume.removeFingerprintOnSuccess, + 'upload.resume.removeFingerprintOnSuccess', + ), + stopAfterAcceptedBytes: requireNumber( + resume.stopAfterAcceptedBytes, + 'upload.resume.stopAfterAcceptedBytes', + ), + } +} + +function uploadMetadata(upload: JsonRecord, context: JsonRecord): Map { + const metadata = new Map() + if (!Array.isArray(upload.metadata)) { + fail('upload.metadata must be an array') + } + for (const fieldValue of upload.metadata) { + const fieldRecord = requireRecord(fieldValue, 'upload.metadata field') + const field: MetadataField = { + name: requireString(fieldRecord.name, 'upload.metadata field.name'), + value: fieldRecord.value, + } + metadata.set(field.name, String(resolveValue(field.value, context, field.name))) + } + + return metadata +} + +// Create a TUS upload and only send the first chunk, leaving the upload +// interrupted the way a dropped connection would. +async function createInterruptedUpload({ + content, + metadata, + stopAfterAcceptedBytes, + tusUrl, +}: { + content: Buffer + metadata: Map + stopAfterAcceptedBytes: number + tusUrl: string +}): Promise { + const metadataParts: string[] = [] + for (const [name, value] of metadata) { + metadataParts.push(`${name} ${Buffer.from(value, 'utf8').toString('base64')}`) + } + const createResponse = await fetch(tusUrl, { + headers: { + 'Tus-Resumable': '1.0.0', + 'Upload-Length': String(content.length), + 'Upload-Metadata': metadataParts.join(','), + }, + method: 'POST', + }) + if (createResponse.status !== 201) { + fail(`TUS create returned HTTP ${createResponse.status}, expected 201`) + } + const location = createResponse.headers.get('location') + if (!location) { + fail('TUS create did not return a Location header') + } + const uploadUrl = new URL(location, tusUrl).toString() + + const patchResponse = await fetch(uploadUrl, { + body: new Uint8Array(content.subarray(0, stopAfterAcceptedBytes)), + headers: { + 'Content-Type': 'application/offset+octet-stream', + 'Tus-Resumable': '1.0.0', + 'Upload-Offset': '0', + }, + method: 'PATCH', + }) + if (patchResponse.status !== 204) { + fail(`TUS first chunk returned HTTP ${patchResponse.status}, expected 204`) + } + const acceptedBytes = Number(patchResponse.headers.get('upload-offset')) + if (acceptedBytes !== stopAfterAcceptedBytes) { + fail(`TUS first chunk accepted ${acceptedBytes} bytes, expected ${stopAfterAcceptedBytes}`) + } + + return uploadUrl +} + +async function writeResult(result: JsonRecord): Promise { + const resultPath = process.env.API2_SDK_EXAMPLE_RESULT + if (!resultPath) { + return + } + + await writeFile(resultPath, `${JSON.stringify(result, undefined, 2)}\n`) +} + +async function main(): Promise { + const { createResponse, scenario, scenarioId } = await loadScenario() + const upload = requireRecord(scenario.upload, 'scenario.upload') + const resume = resumePlan(upload) + const client = new Transloadit({ + authKey: requiredEnv('TRANSLOADIT_KEY'), + authSecret: requiredEnv('TRANSLOADIT_SECRET'), + endpoint: requiredEnv('TRANSLOADIT_ENDPOINT'), + }) + + const context: JsonRecord = { createResponse, scenario } + const content = scenarioBytes(upload) + const tusUrl = requireString(resolveValue(upload.tusUrl, context, 'upload.tusUrl'), 'tusUrl') + const metadata = uploadMetadata(upload, context) + + const firstUploadUrl = await createInterruptedUpload({ + content, + metadata, + stopAfterAcceptedBytes: resume.stopAfterAcceptedBytes, + tusUrl, + }) + + // Remember the interrupted upload by fingerprint, like a TUS client URL storage would. + const storedUploads = new Map([[resume.fingerprint, firstUploadUrl]]) + const previousUploadCount = storedUploads.size + + const storedUploadUrl = + storedUploads.get(resume.fingerprint) ?? fail('stored upload URL is unavailable') + const assemblySslUrl = requireString( + createResponse.assembly_ssl_url, + 'createResponse.assembly_ssl_url', + ) + const completedAssembly = await client.resumeTusUpload(storedUploadUrl, content, assemblySslUrl) + if (completedAssembly.error) { + fail(`resumeTusUpload returned ${completedAssembly.error}: ${completedAssembly.message ?? ''}`) + } + + if (resume.removeFingerprintOnSuccess) { + storedUploads.delete(resume.fingerprint) + } + const remainingPreviousUploadCount = storedUploads.size + + await writeResult({ + firstUploadUrl, + previousUploadCount, + remainingPreviousUploadCount, + uploadUrl: firstUploadUrl, + }) + + console.log(`Node SDK devdock scenario ${scenarioId} resumed ${firstUploadUrl}`) +} + +main().catch((err: unknown) => { + console.error(err) + process.exit(1) +}) diff --git a/packages/node/src/Transloadit.ts b/packages/node/src/Transloadit.ts index 137600ee..5367bc1a 100644 --- a/packages/node/src/Transloadit.ts +++ b/packages/node/src/Transloadit.ts @@ -656,6 +656,90 @@ export class Transloadit { // + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + + /** + * Resumes an interrupted TUS upload from the server-reported offset and waits for the Assembly to finish. + */ + async resumeTusUpload( + uploadUrl: string, + content: Buffer | Uint8Array | string, + assemblySslUrl: string, + ): Promise { + const storedUploadUrl = uploadUrl + if (!storedUploadUrl) { + throw new Error('TUS resumeUpload needs input.storedUploadUrl') + } + + const offsetHeaders: Record = {} + offsetHeaders['Tus-Resumable'] = '1.0.0' + const offsetResponse = await got(storedUploadUrl, { + method: 'HEAD', + headers: offsetHeaders, + retry: this._gotRetry, + throwHttpErrors: false, + timeout: { request: this._defaultTimeout }, + }) + + if (offsetResponse.statusCode !== 200) { + throw new Error(`TUS offset returned HTTP ${offsetResponse.statusCode}, expected 200`) + } + const resumeOffsetHeader = offsetResponse.headers['upload-offset'] + const resumeOffsetHeaderText = Array.isArray(resumeOffsetHeader) + ? resumeOffsetHeader[0] + : resumeOffsetHeader + if (!resumeOffsetHeaderText) { + throw new Error('TUS offset did not return a Upload-Offset header') + } + const resumeOffset = Number(resumeOffsetHeaderText) + if (!Number.isInteger(resumeOffset)) { + throw new Error('TUS offset returned an invalid Upload-Offset header') + } + + const contentBytes = Buffer.isBuffer(content) ? content : Buffer.from(content) + + const uploadHeaders: Record = {} + uploadHeaders['Tus-Resumable'] = '1.0.0' + uploadHeaders['Upload-Offset'] = String(resumeOffset) + uploadHeaders['Content-Type'] = 'application/offset+octet-stream' + const uploadResponse = await got(storedUploadUrl, { + method: 'PATCH', + body: contentBytes.subarray(resumeOffset), + headers: uploadHeaders, + retry: this._gotRetry, + throwHttpErrors: false, + timeout: { request: this._defaultTimeout }, + }) + + if (uploadResponse.statusCode !== 204) { + throw new Error(`TUS upload returned HTTP ${uploadResponse.statusCode}, expected 204`) + } + const uploadOffsetHeader = uploadResponse.headers['upload-offset'] + const uploadOffsetText = Array.isArray(uploadOffsetHeader) + ? uploadOffsetHeader[0] + : uploadOffsetHeader + if (!uploadOffsetText) { + throw new Error('TUS upload returned an invalid Upload-Offset header') + } + const uploadOffset = Number(uploadOffsetText) + if (!Number.isInteger(uploadOffset)) { + throw new Error('TUS upload returned an invalid Upload-Offset header') + } + if (uploadOffset !== contentBytes.length) { + throw new Error(`TUS upload offset ${uploadOffset}, expected ${contentBytes.length}`) + } + + const completedAssembly = await this.waitForAssembly(assemblySslUrl) + + return completedAssembly + } + + // + // // This block is generated from Transloadit API2 contracts. If it looks wrong, From 405dd11a0706443286a9c3c2e492cb34e7db22a0 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 11 Jun 2026 09:18:49 +0200 Subject: [PATCH 17/25] Poll Assembly list until the created Assembly lands The API acknowledges Assembly creation before the list storage row is inserted (the SQL save is fire-and-forget at identification time), so an immediate list can miss the just-created Assembly. Devdock logs show the insert completing 126ms after the list query ran. Poll briefly so the lifecycle proof asserts list contents deterministically instead of racing the insert. Co-Authored-By: Claude Fable 5 --- .../api2-devdock-assembly-lifecycle/main.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/node/examples/api2-devdock-assembly-lifecycle/main.ts b/packages/node/examples/api2-devdock-assembly-lifecycle/main.ts index 7d8f1633..8e65f487 100644 --- a/packages/node/examples/api2-devdock-assembly-lifecycle/main.ts +++ b/packages/node/examples/api2-devdock-assembly-lifecycle/main.ts @@ -108,10 +108,22 @@ async function main(): Promise { try { const fetched = await client.getAssembly(assemblyId) - const listed = await client.listAssemblies({ + // The Assembly list is eventually consistent: the API acknowledges creation before the + // list storage row lands, so poll briefly until the created Assembly shows up. + let listed = await client.listAssemblies({ assembly_id: assemblyId, pagesize: scenario.list.pageSize, }) + for (let attempt = 0; attempt < 20; attempt += 1) { + if (listed.items.some((assembly) => assembly.id === assemblyId)) { + break + } + await new Promise((resolve) => setTimeout(resolve, 500)) + listed = await client.listAssemblies({ + assembly_id: assemblyId, + pagesize: scenario.list.pageSize, + }) + } const cancelled = await client.cancelAssembly(assemblyId) cancelOnExit = false From ef7d56634d8c7de925f4906bcdc06a4c437a676d Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sat, 11 Jul 2026 03:55:02 +0200 Subject: [PATCH 18/25] Generate bearer token issuance --- packages/node/src/Transloadit.ts | 25 +++++++++ packages/node/src/bearerToken.ts | 25 +++++++-- .../node/test/unit/mint-bearer-token.test.ts | 55 +++++++++++++++++++ 3 files changed, 99 insertions(+), 6 deletions(-) diff --git a/packages/node/src/Transloadit.ts b/packages/node/src/Transloadit.ts index 5367bc1a..2ff45aa4 100644 --- a/packages/node/src/Transloadit.ts +++ b/packages/node/src/Transloadit.ts @@ -1723,4 +1723,29 @@ export class Transloadit { } } } + + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + + async issueBearerToken( + options: import('./bearerToken.ts').IssueBearerTokenOptions = {}, + ): Promise { + if (this._authToken) { + throw new Error('Cannot issue bearer tokens when using authToken authentication.') + } + + const { issueBearerTokenWithCredentials } = await import('./bearerToken.ts') + const result = await issueBearerTokenWithCredentials( + { authKey: this._authKey, authSecret: this._authSecret }, + { ...options, endpoint: this._endpoint, allowProcessEnvEndpointFallback: false }, + ) + if (!result.ok) throw new Error(result.error) + if (result.data.scope == null) throw new Error('Bearer token response omitted scope.') + return { ...result.data, scope: result.data.scope } + } + + // } diff --git a/packages/node/src/bearerToken.ts b/packages/node/src/bearerToken.ts index 956caba0..f967bf25 100644 --- a/packages/node/src/bearerToken.ts +++ b/packages/node/src/bearerToken.ts @@ -9,6 +9,13 @@ export type BearerTokenResponse = { scope?: string } +export type IssueBearerTokenOptions = { + aud?: BearerTokenAudience | string + scope?: string +} + +export type IssueBearerTokenResponse = BearerTokenResponse & { scope: string } + export type MintBearerTokenOptions = { allowProcessEnvEndpointFallback?: boolean endpoint?: string @@ -122,7 +129,7 @@ const normalizeScopeInput = (input?: string[] | string): string | undefined => { return out.length > 0 ? out.join(' ') : undefined } -export async function mintBearerTokenWithCredentials( +export async function issueBearerTokenWithCredentials( credentials: { authKey: string; authSecret: string }, options: MintBearerTokenOptions = {}, ): Promise { @@ -135,14 +142,12 @@ export async function mintBearerTokenWithCredentials( } const url = new URL('token', endpointResult.baseUrl).toString() - const aud = (options.aud ?? 'mcp').trim() || 'mcp' + const aud = options.aud?.trim() const scope = normalizeScopeInput(options.scope) const timeoutMs = options.timeoutMs ?? 15_000 - const params = new URLSearchParams({ - grant_type: 'client_credentials', - aud, - }) + const params = new URLSearchParams({ grant_type: 'client_credentials' }) + if (aud) params.set('aud', aud) if (scope) params.set('scope', scope) let res: Response @@ -217,3 +222,11 @@ export async function mintBearerTokenWithCredentials( error: `Token request failed (${res.status}): ${trimmed || res.statusText}`, } } + +export async function mintBearerTokenWithCredentials( + credentials: { authKey: string; authSecret: string }, + options: MintBearerTokenOptions = {}, +): Promise { + const aud = (options.aud ?? 'mcp').trim() || 'mcp' + return await issueBearerTokenWithCredentials(credentials, { ...options, aud }) +} diff --git a/packages/node/test/unit/mint-bearer-token.test.ts b/packages/node/test/unit/mint-bearer-token.test.ts index ac05a027..75c4b52e 100644 --- a/packages/node/test/unit/mint-bearer-token.test.ts +++ b/packages/node/test/unit/mint-bearer-token.test.ts @@ -7,6 +7,61 @@ afterEach(() => { }) describe('Transloadit.mintBearerToken', () => { + it('rejects a contract token response without scope', async () => { + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response( + JSON.stringify({ access_token: 'abc', token_type: 'Bearer', expires_in: 21600 }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ), + ) + const client = new Transloadit({ authKey: 'key', authSecret: 'secret' }) + + await expect(client.issueBearerToken()).rejects.toThrow( + 'Bearer token response omitted scope.', + ) + }) + + it('issues the contract token without overriding the server audience default', async () => { + const fetchSpy = vi.fn( + async () => + new Response( + JSON.stringify({ + access_token: 'abc', + token_type: 'Bearer', + expires_in: 21600, + scope: 'assemblies:read', + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ) + vi.stubGlobal('fetch', fetchSpy as unknown as typeof fetch) + + const client = new Transloadit({ + authKey: 'key', + authSecret: 'secret', + endpoint: 'https://api2.transloadit.com', + }) + + const response = await client.issueBearerToken({ scope: 'assemblies:read' }) + + expect(response.access_token).toBe('abc') + const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit] + expect(url).toBe('https://api2.transloadit.com/token') + expect(init.redirect).toBe('error') + expect(init.headers).toMatchObject({ + Authorization: `Basic ${Buffer.from('key:secret').toString('base64')}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }) + const params = new URLSearchParams(init.body as string) + expect(params.get('grant_type')).toBe('client_credentials') + expect(params.has('aud')).toBe(false) + expect(params.get('scope')).toBe('assemblies:read') + }) + it('requests a narrowed scope when provided', async () => { const fetchSpy = vi.fn( async () => From 58a3bb8f72888cbe7233407495d317d12a60838d Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sat, 11 Jul 2026 10:07:55 +0200 Subject: [PATCH 19/25] Align generated client with API contract --- packages/node/src/Transloadit.ts | 80 +++++++++---------- .../node/src/alphalib/types/assemblyStatus.ts | 7 +- packages/node/src/bearerToken.ts | 13 ++- .../node/test/unit/mint-bearer-token.test.ts | 25 ++++-- .../test/unit/test-transloadit-client.test.ts | 20 +++++ 5 files changed, 94 insertions(+), 51 deletions(-) diff --git a/packages/node/src/Transloadit.ts b/packages/node/src/Transloadit.ts index 2ff45aa4..c850c5a7 100644 --- a/packages/node/src/Transloadit.ts +++ b/packages/node/src/Transloadit.ts @@ -44,8 +44,6 @@ import type { TemplateCredentialsResponse, TemplateResponse, } from './apiTypes.ts' -import type { BearerTokenResponse, MintBearerTokenOptions } from './bearerToken.ts' -import { mintBearerTokenWithCredentials } from './bearerToken.ts' import InconsistentResponseError from './InconsistentResponseError.ts' import type { LintAssemblyInstructionsInput, @@ -564,29 +562,6 @@ export class Transloadit { * This uses HTTP Basic Auth (authKey + authSecret) and can optionally request a narrowed scope. * If `scope` is omitted, the token inherits the auth key's scope. */ - async mintBearerToken( - options: Omit & { endpoint?: string } = {}, - ): Promise { - if (this._authToken) { - throw new Error( - 'Cannot mint bearer tokens when using authToken authentication. Provide authKey + authSecret instead.', - ) - } - - const result = await mintBearerTokenWithCredentials( - { authKey: this._authKey, authSecret: this._authSecret }, - { - ...options, - endpoint: options.endpoint ?? this._endpoint, - }, - ) - - if (!result.ok) { - throw new Error(result.error) - } - - return result.data - } // @@ -1149,10 +1124,7 @@ export class Transloadit { async listAssemblies( params?: ListAssembliesParams, ): Promise> { - const rawResponse = await this._remoteJson< - PaginationListWithCount>, - ListAssembliesParams - >({ + const rawResponse = await this._remoteJson({ urlSuffix: '/assemblies', method: 'get', params: params || {}, @@ -1168,18 +1140,16 @@ export class Transloadit { ) } - const parsedResult = zodParseWithContext(assemblyIndexSchema, rawResponse.items) + const parsedResult = zodParseWithContext(assemblyIndexSchema, rawResponse) if (!parsedResult.success) { this.maybeThrowInconsistentResponseError( - `API response for listAssemblies contained items that do not match the expected schema.\n${parsedResult.humanReadable}`, + `API response for listAssemblies does not match the expected schema.\n${parsedResult.humanReadable}`, ) + return rawResponse } - return { - items: rawResponse.items as AssemblyIndex, - count: rawResponse.count, - } + return parsedResult.safe } streamAssemblies(params: ListAssembliesParams): Readable { @@ -1730,17 +1700,43 @@ export class Transloadit { // please report the issue instead of editing this block by hand; the source fix // belongs in the contract generator so all SDKs stay in sync. - async issueBearerToken( - options: import('./bearerToken.ts').IssueBearerTokenOptions = {}, - ): Promise { + async mintBearerToken( + options: Omit & { + endpoint?: string + } = {}, + ): Promise { if (this._authToken) { - throw new Error('Cannot issue bearer tokens when using authToken authentication.') + throw new Error('Cannot mint bearer tokens when using authToken authentication.') } - const { issueBearerTokenWithCredentials } = await import('./bearerToken.ts') - const result = await issueBearerTokenWithCredentials( + const endpointRaw = options.endpoint ?? this._endpoint ?? 'https://api2.transloadit.com' + const endpoint = new URL(endpointRaw) + const hostname = + endpoint.hostname.startsWith('[') && endpoint.hostname.endsWith(']') + ? endpoint.hostname.slice(1, -1) + : endpoint.hostname + const octet = '(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)' + const isNumericIpv4Loopback = new RegExp(`^127\\.${octet}\\.${octet}\\.${octet}$`).test( + hostname, + ) + const loopback = hostname === 'localhost' || hostname === '::1' || isNumericIpv4Loopback + if ( + endpoint.username || + endpoint.password || + (endpoint.protocol !== 'https:' && !(endpoint.protocol === 'http:' && loopback)) + ) { + throw new Error('Refusing to send credentials to an insecure bearer token endpoint.') + } + + const { mintBearerTokenWithCredentials } = await import('./bearerToken.ts') + const result = await mintBearerTokenWithCredentials( { authKey: this._authKey, authSecret: this._authSecret }, - { ...options, endpoint: this._endpoint, allowProcessEnvEndpointFallback: false }, + { + ...options, + aud: options.aud ?? 'api2', + endpoint: endpointRaw, + allowProcessEnvEndpointFallback: false, + }, ) if (!result.ok) throw new Error(result.error) if (result.data.scope == null) throw new Error('Bearer token response omitted scope.') diff --git a/packages/node/src/alphalib/types/assemblyStatus.ts b/packages/node/src/alphalib/types/assemblyStatus.ts index f0433637..ddeacab2 100644 --- a/packages/node/src/alphalib/types/assemblyStatus.ts +++ b/packages/node/src/alphalib/types/assemblyStatus.ts @@ -1089,5 +1089,10 @@ export const assemblyIndexItemSchema = z export type AssemblyIndexItem = z.infer -export const assemblyIndexSchema = z.array(assemblyIndexItemSchema) +export const assemblyIndexSchema = z + .object({ + count: z.number().int().nonnegative(), + items: z.array(assemblyIndexItemSchema), + }) + .passthrough() export type AssemblyIndex = z.infer diff --git a/packages/node/src/bearerToken.ts b/packages/node/src/bearerToken.ts index f967bf25..df5c4e0d 100644 --- a/packages/node/src/bearerToken.ts +++ b/packages/node/src/bearerToken.ts @@ -1,3 +1,5 @@ +import { isIP } from 'node:net' + import { z } from 'zod' export type BearerTokenAudience = 'mcp' | 'api2' | (string & {}) @@ -52,8 +54,15 @@ const tokenSuccessSchema = z const buildBasicAuthHeaderValue = (credentials: { authKey: string; authSecret: string }): string => `Basic ${Buffer.from(`${credentials.authKey}:${credentials.authSecret}`, 'utf8').toString('base64')}` -const isLoopbackHost = (hostname: string): boolean => - hostname === 'localhost' || hostname === '::1' || hostname.startsWith('127.') +const isLoopbackHost = (hostname: string): boolean => { + const normalizedHostname = + hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname + return ( + normalizedHostname === 'localhost' || + normalizedHostname === '::1' || + (isIP(normalizedHostname) === 4 && normalizedHostname.startsWith('127.')) + ) +} type TokenBaseResult = { ok: true; baseUrl: URL } | { ok: false; error: string } diff --git a/packages/node/test/unit/mint-bearer-token.test.ts b/packages/node/test/unit/mint-bearer-token.test.ts index 75c4b52e..622fec48 100644 --- a/packages/node/test/unit/mint-bearer-token.test.ts +++ b/packages/node/test/unit/mint-bearer-token.test.ts @@ -7,6 +7,21 @@ afterEach(() => { }) describe('Transloadit.mintBearerToken', () => { + it('does not treat a 127-prefixed domain as a loopback host', async () => { + const fetchSpy = vi.fn() + vi.stubGlobal('fetch', fetchSpy) + const client = new Transloadit({ + authKey: 'key', + authSecret: 'secret', + endpoint: 'http://127.attacker.com', + }) + + await expect(client.mintBearerToken()).rejects.toThrow( + 'Refusing to send credentials to an insecure bearer token endpoint.', + ) + expect(fetchSpy).not.toHaveBeenCalled() + }) + it('rejects a contract token response without scope', async () => { vi.stubGlobal( 'fetch', @@ -20,12 +35,10 @@ describe('Transloadit.mintBearerToken', () => { ) const client = new Transloadit({ authKey: 'key', authSecret: 'secret' }) - await expect(client.issueBearerToken()).rejects.toThrow( - 'Bearer token response omitted scope.', - ) + await expect(client.mintBearerToken()).rejects.toThrow('Bearer token response omitted scope.') }) - it('issues the contract token without overriding the server audience default', async () => { + it('issues the token with the API2 contract audience default', async () => { const fetchSpy = vi.fn( async () => new Response( @@ -46,7 +59,7 @@ describe('Transloadit.mintBearerToken', () => { endpoint: 'https://api2.transloadit.com', }) - const response = await client.issueBearerToken({ scope: 'assemblies:read' }) + const response = await client.mintBearerToken({ scope: 'assemblies:read' }) expect(response.access_token).toBe('abc') const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit] @@ -58,7 +71,7 @@ describe('Transloadit.mintBearerToken', () => { }) const params = new URLSearchParams(init.body as string) expect(params.get('grant_type')).toBe('client_credentials') - expect(params.has('aud')).toBe(false) + expect(params.get('aud')).toBe('api2') expect(params.get('scope')).toBe('assemblies:read') }) diff --git a/packages/node/test/unit/test-transloadit-client.test.ts b/packages/node/test/unit/test-transloadit-client.test.ts index 2fc7066a..d382486c 100644 --- a/packages/node/test/unit/test-transloadit-client.test.ts +++ b/packages/node/test/unit/test-transloadit-client.test.ts @@ -30,6 +30,26 @@ const mockRemoteJson = (client: Transloadit) => .mockImplementation(() => ({ body: {} })) describe('Transloadit', () => { + it('validates the complete listAssemblies response envelope', async () => { + const client = new Transloadit({ + authKey: 'foo_key', + authSecret: 'foo_secret', + validateResponses: true, + }) + vi.spyOn( + client as unknown as Record unknown>, + '_remoteJson', + ).mockResolvedValue({ + count: 1, + items: [{ created: '2026-07-11T00:00:00.000Z', id: 'assembly-id' }], + }) + + await expect(client.listAssemblies()).resolves.toEqual({ + count: 1, + items: [{ created: '2026-07-11T00:00:00.000Z', id: 'assembly-id' }], + }) + }) + it('should throw a proper error for request stream', async () => { const client = new Transloadit({ authKey: 'foo_key', authSecret: 'foo_secret' }) From 003275448b156822d5f7c94077404a8acc1eb064 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sat, 11 Jul 2026 13:10:26 +0200 Subject: [PATCH 20/25] Generate invoice bill endpoint --- packages/node/src/Transloadit.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/node/src/Transloadit.ts b/packages/node/src/Transloadit.ts index c850c5a7..67534f03 100644 --- a/packages/node/src/Transloadit.ts +++ b/packages/node/src/Transloadit.ts @@ -1744,4 +1744,21 @@ export class Transloadit { } // + + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + + async getBillForInvoice(month: string, invoiceId: string): Promise { + assert.ok(month, 'month is required') + assert.ok(invoiceId, 'invoiceId is required') + return await this._remoteJson({ + urlSuffix: `/bill/${month}/${invoiceId}`, + method: 'get', + }) + } + + // } From a1c185888dd669f8369e6c0eca02fd50ae851804 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sat, 11 Jul 2026 14:56:35 +0200 Subject: [PATCH 21/25] Generate exact-one-file TUS helper --- packages/node/examples/api2-devdock-tus-assembly/main.ts | 1 - packages/node/src/Transloadit.ts | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/node/examples/api2-devdock-tus-assembly/main.ts b/packages/node/examples/api2-devdock-tus-assembly/main.ts index 25c7d90e..a4b6457e 100644 --- a/packages/node/examples/api2-devdock-tus-assembly/main.ts +++ b/packages/node/examples/api2-devdock-tus-assembly/main.ts @@ -150,7 +150,6 @@ async function main(): Promise { }) const result = await client.uploadTusAssembly( - input.file_count, Buffer.from(upload.content, 'utf8'), upload.fieldname, upload.filename, diff --git a/packages/node/src/Transloadit.ts b/packages/node/src/Transloadit.ts index 67534f03..9164f74b 100644 --- a/packages/node/src/Transloadit.ts +++ b/packages/node/src/Transloadit.ts @@ -725,13 +725,12 @@ export class Transloadit { * Creates a TUS-ready Assembly, uploads one file with the TUS protocol, and waits for the Assembly to finish. */ async uploadTusAssembly( - fileCount: number, content: Buffer | Uint8Array | string, fieldname: string, filename: string, userMeta: Record, ): Promise { - const createdAssembly = await this.createTusAssembly(fileCount) + const createdAssembly = await this.createTusAssembly(1) const endpointUrl = createdAssembly.tus_url if (!endpointUrl) { From 0be3531431f8846da9e8e13146d0175976cde785 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sat, 11 Jul 2026 15:49:20 +0200 Subject: [PATCH 22/25] Protect no-auth Assembly URL requests --- packages/node/src/Transloadit.ts | 39 ++++++++++++++--- .../test/unit/test-transloadit-client.test.ts | 43 +++++++++++++++++++ 2 files changed, 75 insertions(+), 7 deletions(-) diff --git a/packages/node/src/Transloadit.ts b/packages/node/src/Transloadit.ts index 9164f74b..e9c3b755 100644 --- a/packages/node/src/Transloadit.ts +++ b/packages/node/src/Transloadit.ts @@ -608,9 +608,23 @@ export class Transloadit { * Use the returned assembly_ssl_url as the assembly URL. */ async waitForAssembly(assemblyUrl: string): Promise { + const candidateUrl = new URL(assemblyUrl) + const configuredUrl = new URL(this._endpoint) + const hasUrlCredentials = candidateUrl.username !== '' || candidateUrl.password !== '' + const isConfiguredOrigin = !hasUrlCredentials && candidateUrl.origin === configuredUrl.origin + const isTransloaditApi2Cell = + !hasUrlCredentials && + candidateUrl.protocol === 'https:' && + candidateUrl.port === '' && + candidateUrl.hostname.startsWith('api2-') && + candidateUrl.hostname.endsWith('.transloadit.com') + if (!isConfiguredOrigin && !isTransloaditApi2Cell) { + throw new Error(`Untrusted Assembly URL: ${candidateUrl.origin}`) + } while (true) { const result = await this._remoteJson({ url: assemblyUrl, + authenticate: false, isTrustedUrl: true, method: 'get', }) @@ -1497,8 +1511,13 @@ export class Transloadit { // Sets the multipart/form-data for POST, PUT and DELETE requests, including // the streams, the signed params, and any additional fields. - private _appendForm(form: FormData, params: OptionalAuthParams, fields?: Fields): void { - const shouldSign = Boolean(this._authKey && this._authSecret) + private _appendForm( + form: FormData, + params: OptionalAuthParams, + fields?: Fields, + authenticate = true, + ): void { + const shouldSign = authenticate && Boolean(this._authKey && this._authSecret) let jsonParams = JSON.stringify(params ?? {}) let signature: string | undefined @@ -1523,10 +1542,12 @@ export class Transloadit { // Implements HTTP GET query params, handling the case where the url already // has params. - private _appendParamsToUrl(url: string, params: OptionalAuthParams): string { + private _appendParamsToUrl(url: string, params: OptionalAuthParams, authenticate = true): string { + if (!authenticate && Object.keys(params).length === 0) return url + const prefix = url.indexOf('?') === -1 ? '?' : '&' - const shouldSign = Boolean(this._authKey && this._authSecret) + const shouldSign = authenticate && Boolean(this._authKey && this._authSecret) if (!shouldSign) { const jsonParams = JSON.stringify(params ?? {}) return `${url}${prefix}params=${encodeURIComponent(jsonParams)}` @@ -1567,6 +1588,7 @@ export class Transloadit { // PUT or DELETE requests. Automatically adds signature parameters to all // requests. Also automatically parses the JSON response. private async _remoteJson(opts: { + authenticate?: boolean urlSuffix?: string url?: string isTrustedUrl?: boolean @@ -1578,6 +1600,7 @@ export class Transloadit { signal?: AbortSignal }): Promise { const { + authenticate = true, urlSuffix, url: urlInput, isTrustedUrl = false, @@ -1601,7 +1624,7 @@ export class Transloadit { } if (method === 'get') { - url = this._appendParamsToUrl(url, params) + url = this._appendParamsToUrl(url, params, authenticate) } log('Sending request', method, url) @@ -1613,7 +1636,7 @@ export class Transloadit { if (method === 'post' || method === 'put' || method === 'delete') { form = new FormData() - this._appendForm(form, params, fields) + this._appendForm(form, params, fields, authenticate) } const requestOpts: OptionsOfJSONResponseBody = { @@ -1623,7 +1646,9 @@ export class Transloadit { headers: { 'Transloadit-Client': this._clientName, 'User-Agent': undefined, // Remove got's user-agent - ...(this._authToken ? { Authorization: `Bearer ${this._authToken}` } : {}), + ...(authenticate && this._authToken + ? { Authorization: `Bearer ${this._authToken}` } + : {}), ...headers, }, responseType: 'json', diff --git a/packages/node/test/unit/test-transloadit-client.test.ts b/packages/node/test/unit/test-transloadit-client.test.ts index d382486c..4da3995b 100644 --- a/packages/node/test/unit/test-transloadit-client.test.ts +++ b/packages/node/test/unit/test-transloadit-client.test.ts @@ -30,6 +30,33 @@ const mockRemoteJson = (client: Transloadit) => .mockImplementation(() => ({ body: {} })) describe('Transloadit', () => { + it('rejects untrusted Assembly status URLs before making a request', async () => { + const client = new Transloadit({ authKey: 'foo_key', authSecret: 'foo_secret' }) + const remoteJson = mockRemoteJson(client).mockResolvedValue({ ok: 'ASSEMBLY_COMPLETED' }) + + await expect( + client.waitForAssembly('https://attacker.example/assemblies/assembly-id'), + ).rejects.toThrow('Untrusted Assembly URL') + expect(remoteJson).not.toHaveBeenCalled() + }) + + it('polls cell-specific Assembly URLs without authentication', async () => { + const client = new Transloadit({ authKey: 'foo_key', authSecret: 'foo_secret' }) + const remoteJson = mockRemoteJson(client).mockResolvedValue({ ok: 'ASSEMBLY_COMPLETED' }) + const assemblyUrl = 'https://api2-jenks.transloadit.com/assemblies/assembly-id' + + await expect(client.waitForAssembly(assemblyUrl)).resolves.toEqual({ + ok: 'ASSEMBLY_COMPLETED', + }) + expect(remoteJson).toHaveBeenCalledWith( + expect.objectContaining({ + authenticate: false, + isTrustedUrl: true, + url: assemblyUrl, + }), + ) + }) + it('validates the complete listAssemblies response envelope', async () => { const client = new Transloadit({ authKey: 'foo_key', @@ -394,6 +421,22 @@ describe('Transloadit', () => { }) describe('_remoteJson', () => { + it('omits bearer and signed query authentication when authentication is disabled', async () => { + const client = new Transloadit({ authToken: 'secret-token' }) + const get = mockGot('get') + const url = 'https://api2-jenks.transloadit.com/assemblies/assembly-id' + + // @ts-expect-error This tests private internals + await client._remoteJson({ authenticate: false, isTrustedUrl: true, method: 'get', url }) + + expect(get).toHaveBeenCalledWith( + url, + expect.objectContaining({ + headers: expect.not.objectContaining({ Authorization: expect.anything() }), + }), + ) + }) + it('should add "Transloadit-Client" header to requests', async () => { const client = new Transloadit({ authKey: 'foo_key', authSecret: 'foo_secret' }) From cfff96023c8007ae068a4940c2b10310b9e25021 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 12 Jul 2026 05:57:25 +0200 Subject: [PATCH 23/25] Harden generated request URLs --- packages/node/src/Transloadit.ts | 22 ++++++++++--------- .../test/unit/test-transloadit-client.test.ts | 19 ++++++++++++++++ 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/packages/node/src/Transloadit.ts b/packages/node/src/Transloadit.ts index ca3452ad..12322320 100644 --- a/packages/node/src/Transloadit.ts +++ b/packages/node/src/Transloadit.ts @@ -33,7 +33,6 @@ import type { TemplateCredentialsResponse, TemplateResponse, } from './apiTypes.ts' -import type { BearerTokenResponse, MintBearerTokenOptions } from './bearerToken.ts' import type { LintAssemblyInstructionsInput, LintAssemblyInstructionsResult, @@ -60,7 +59,6 @@ import packageJson from '../package.json' with { type: 'json' } import { ApiError } from './ApiError.ts' import { assemblyIndexSchema, assemblyStatusSchema } from './alphalib/types/assemblyStatus.ts' import { zodParseWithContext } from './alphalib/zodParseWithContext.ts' -import { mintBearerTokenWithCredentials } from './bearerToken.ts' import InconsistentResponseError from './InconsistentResponseError.ts' import { lintAssemblyInstructions as lintAssemblyInstructionsInternal } from './lintAssemblyInstructions.ts' import PaginationStream from './PaginationStream.ts' @@ -740,6 +738,7 @@ export class Transloadit { const result = await this._remoteJson({ url: assemblyUrl, authenticate: false, + followRedirect: false, isTrustedUrl: true, method: 'get', }) @@ -1372,7 +1371,7 @@ export class Transloadit { params: CreateTemplateCredentialParams, ): Promise { return await this._remoteJson({ - urlSuffix: `/template_credentials/${credentialId}`, + urlSuffix: `/template_credentials/${encodeURIComponent(credentialId)}`, method: 'put', params: params || {}, }) @@ -1394,7 +1393,7 @@ export class Transloadit { async deleteTemplateCredential(credentialId: string): Promise { return await this._remoteJson({ - urlSuffix: `/template_credentials/${credentialId}`, + urlSuffix: `/template_credentials/${encodeURIComponent(credentialId)}`, method: 'delete', }) } @@ -1415,7 +1414,7 @@ export class Transloadit { async getTemplateCredential(credentialId: string): Promise { return await this._remoteJson({ - urlSuffix: `/template_credentials/${credentialId}`, + urlSuffix: `/template_credentials/${encodeURIComponent(credentialId)}`, method: 'get', }) } @@ -1489,7 +1488,7 @@ export class Transloadit { async editTemplate(templateId: string, params: EditTemplateParams): Promise { return await this._remoteJson({ - urlSuffix: `/templates/${templateId}`, + urlSuffix: `/templates/${encodeURIComponent(templateId)}`, method: 'put', params: params || {}, }) @@ -1511,7 +1510,7 @@ export class Transloadit { async deleteTemplate(templateId: string): Promise { return await this._remoteJson({ - urlSuffix: `/templates/${templateId}`, + urlSuffix: `/templates/${encodeURIComponent(templateId)}`, method: 'delete', }) } @@ -1532,7 +1531,7 @@ export class Transloadit { async getTemplate(templateId: string): Promise { return await this._remoteJson({ - urlSuffix: `/templates/${templateId}`, + urlSuffix: `/templates/${encodeURIComponent(templateId)}`, method: 'get', }) } @@ -1583,7 +1582,7 @@ export class Transloadit { async getBill(month: string): Promise { assert.ok(month, 'month is required') return await this._remoteJson({ - urlSuffix: `/bill/${month}`, + urlSuffix: `/bill/${encodeURIComponent(month)}`, method: 'get', }) } @@ -1711,6 +1710,7 @@ export class Transloadit { method?: 'delete' | 'get' | 'post' | 'put' params?: TParams fields?: Fields + followRedirect?: boolean headers?: Headers signal?: AbortSignal }): Promise { @@ -1723,6 +1723,7 @@ export class Transloadit { method = 'get', params = {}, fields, + followRedirect = true, headers, signal, } = opts @@ -1757,6 +1758,7 @@ export class Transloadit { const requestOpts: OptionsOfJSONResponseBody = { retry: this._gotRetry, body: form, + followRedirect, timeout, headers: { 'Transloadit-Client': this._clientName, @@ -1894,7 +1896,7 @@ export class Transloadit { assert.ok(month, 'month is required') assert.ok(invoiceId, 'invoiceId is required') return await this._remoteJson({ - urlSuffix: `/bill/${month}/${invoiceId}`, + urlSuffix: `/bill/${encodeURIComponent(month)}/${encodeURIComponent(invoiceId)}`, method: 'get', }) } diff --git a/packages/node/test/unit/test-transloadit-client.test.ts b/packages/node/test/unit/test-transloadit-client.test.ts index b7e15715..fa997e70 100644 --- a/packages/node/test/unit/test-transloadit-client.test.ts +++ b/packages/node/test/unit/test-transloadit-client.test.ts @@ -472,6 +472,25 @@ describe('Transloadit', () => { expect.objectContaining({ headers: { 'Transloadit-Client': 'mcp-server:1.2.3' } }), ) }) + + it('should allow redirects to be disabled for trusted polling URLs', async () => { + const client = new Transloadit({ authKey: 'foo_key', authSecret: 'foo_secret' }) + + const get = mockGot('get') + + // @ts-expect-error This tests private internals + await client._remoteJson({ + url: 'https://api2-eu-west-1.transloadit.com/assemblies/example', + method: 'get', + isTrustedUrl: true, + followRedirect: false, + }) + + expect(get).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ followRedirect: false }), + ) + }) }) describe('getSignedSmartCDNUrl', () => { From b1e60e704d6648f93b146e03bf9925af8655f034 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 12 Jul 2026 06:33:42 +0200 Subject: [PATCH 24/25] Allow HTTPS polling on configured hosts --- packages/node/src/Transloadit.ts | 7 +++++- .../test/unit/test-transloadit-client.test.ts | 22 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/node/src/Transloadit.ts b/packages/node/src/Transloadit.ts index 12322320..ffac15ca 100644 --- a/packages/node/src/Transloadit.ts +++ b/packages/node/src/Transloadit.ts @@ -725,13 +725,18 @@ export class Transloadit { const configuredUrl = new URL(this._endpoint) const hasUrlCredentials = candidateUrl.username !== '' || candidateUrl.password !== '' const isConfiguredOrigin = !hasUrlCredentials && candidateUrl.origin === configuredUrl.origin + const isConfiguredHttpsHost = + !hasUrlCredentials && + candidateUrl.protocol === 'https:' && + candidateUrl.port === '' && + candidateUrl.hostname === configuredUrl.hostname const isTransloaditApi2Cell = !hasUrlCredentials && candidateUrl.protocol === 'https:' && candidateUrl.port === '' && candidateUrl.hostname.startsWith('api2-') && candidateUrl.hostname.endsWith('.transloadit.com') - if (!isConfiguredOrigin && !isTransloaditApi2Cell) { + if (!isConfiguredOrigin && !isConfiguredHttpsHost && !isTransloaditApi2Cell) { throw new Error(`Untrusted Assembly URL: ${candidateUrl.origin}`) } while (true) { diff --git a/packages/node/test/unit/test-transloadit-client.test.ts b/packages/node/test/unit/test-transloadit-client.test.ts index fa997e70..ada35698 100644 --- a/packages/node/test/unit/test-transloadit-client.test.ts +++ b/packages/node/test/unit/test-transloadit-client.test.ts @@ -59,6 +59,28 @@ describe('Transloadit', () => { ) }) + it('allows HTTPS polling on the configured HTTP hostname', async () => { + const client = new Transloadit({ + authKey: 'foo_key', + authSecret: 'foo_secret', + endpoint: 'http://api2-devdock.transloadit.dev', + }) + const remoteJson = mockRemoteJson(client).mockResolvedValue({ ok: 'ASSEMBLY_COMPLETED' }) + const assemblyUrl = 'https://api2-devdock.transloadit.dev/assemblies/assembly-id' + + await expect(client.waitForAssembly(assemblyUrl)).resolves.toEqual({ + ok: 'ASSEMBLY_COMPLETED', + }) + expect(remoteJson).toHaveBeenCalledWith( + expect.objectContaining({ + authenticate: false, + followRedirect: false, + isTrustedUrl: true, + url: assemblyUrl, + }), + ) + }) + it('validates the complete listAssemblies response envelope', async () => { const client = new Transloadit({ authKey: 'foo_key', From 0fadd74cc22de020e532a038d24c3614c2902366 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 12 Jul 2026 08:44:43 +0200 Subject: [PATCH 25/25] Reject dot path segments --- packages/node/src/Transloadit.ts | 32 ++++++++++++++----- .../test/unit/test-transloadit-client.test.ts | 10 ++++++ 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/packages/node/src/Transloadit.ts b/packages/node/src/Transloadit.ts index ffac15ca..d44edc36 100644 --- a/packages/node/src/Transloadit.ts +++ b/packages/node/src/Transloadit.ts @@ -1376,7 +1376,7 @@ export class Transloadit { params: CreateTemplateCredentialParams, ): Promise { return await this._remoteJson({ - urlSuffix: `/template_credentials/${encodeURIComponent(credentialId)}`, + urlSuffix: `/template_credentials/${this.#encodePathSegment(credentialId)}`, method: 'put', params: params || {}, }) @@ -1398,7 +1398,7 @@ export class Transloadit { async deleteTemplateCredential(credentialId: string): Promise { return await this._remoteJson({ - urlSuffix: `/template_credentials/${encodeURIComponent(credentialId)}`, + urlSuffix: `/template_credentials/${this.#encodePathSegment(credentialId)}`, method: 'delete', }) } @@ -1419,7 +1419,7 @@ export class Transloadit { async getTemplateCredential(credentialId: string): Promise { return await this._remoteJson({ - urlSuffix: `/template_credentials/${encodeURIComponent(credentialId)}`, + urlSuffix: `/template_credentials/${this.#encodePathSegment(credentialId)}`, method: 'get', }) } @@ -1493,7 +1493,7 @@ export class Transloadit { async editTemplate(templateId: string, params: EditTemplateParams): Promise { return await this._remoteJson({ - urlSuffix: `/templates/${encodeURIComponent(templateId)}`, + urlSuffix: `/templates/${this.#encodePathSegment(templateId)}`, method: 'put', params: params || {}, }) @@ -1515,7 +1515,7 @@ export class Transloadit { async deleteTemplate(templateId: string): Promise { return await this._remoteJson({ - urlSuffix: `/templates/${encodeURIComponent(templateId)}`, + urlSuffix: `/templates/${this.#encodePathSegment(templateId)}`, method: 'delete', }) } @@ -1536,7 +1536,7 @@ export class Transloadit { async getTemplate(templateId: string): Promise { return await this._remoteJson({ - urlSuffix: `/templates/${encodeURIComponent(templateId)}`, + urlSuffix: `/templates/${this.#encodePathSegment(templateId)}`, method: 'get', }) } @@ -1587,7 +1587,7 @@ export class Transloadit { async getBill(month: string): Promise { assert.ok(month, 'month is required') return await this._remoteJson({ - urlSuffix: `/bill/${encodeURIComponent(month)}`, + urlSuffix: `/bill/${this.#encodePathSegment(month)}`, method: 'get', }) } @@ -1901,10 +1901,26 @@ export class Transloadit { assert.ok(month, 'month is required') assert.ok(invoiceId, 'invoiceId is required') return await this._remoteJson({ - urlSuffix: `/bill/${encodeURIComponent(month)}/${encodeURIComponent(invoiceId)}`, + urlSuffix: `/bill/${this.#encodePathSegment(month)}/${this.#encodePathSegment(invoiceId)}`, method: 'get', }) } // + + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + + #encodePathSegment(value: string): string { + if (value === '.' || value === '..') { + throw new Error('Path parameters cannot be dot segments') + } + + return encodeURIComponent(value) + } + + // } diff --git a/packages/node/test/unit/test-transloadit-client.test.ts b/packages/node/test/unit/test-transloadit-client.test.ts index ada35698..6b40f824 100644 --- a/packages/node/test/unit/test-transloadit-client.test.ts +++ b/packages/node/test/unit/test-transloadit-client.test.ts @@ -81,6 +81,16 @@ describe('Transloadit', () => { ) }) + it.each(['.', '..'])('rejects the %s path segment before making a request', async (segment) => { + const client = new Transloadit({ authKey: 'foo_key', authSecret: 'foo_secret' }) + const remoteJson = mockRemoteJson(client).mockResolvedValue({}) + + await expect(client.getTemplate(segment)).rejects.toThrow( + 'Path parameters cannot be dot segments', + ) + expect(remoteJson).not.toHaveBeenCalled() + }) + it('validates the complete listAssemblies response envelope', async () => { const client = new Transloadit({ authKey: 'foo_key',