From 857ae8b83fce45fe5d0946bed146ba22913c9495 Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sat, 19 Sep 2026 16:34:49 +0200 Subject: [PATCH 1/4] feat(media): upload local files through the direct-upload flow (presign, PUT, complete) --- src/media.test.ts | 67 ++++++++++++++++++++------ src/media.ts | 119 +++++++++++++++++++++++++++++++++++----------- src/run.test.ts | 4 +- 3 files changed, 146 insertions(+), 44 deletions(-) diff --git a/src/media.test.ts b/src/media.test.ts index b5ace5e..20e6337 100644 --- a/src/media.test.ts +++ b/src/media.test.ts @@ -48,28 +48,65 @@ describe('remoteMediaItem', () => { }); describe('resolveMedia', () => { - it('uploads a local file with the API key header and attaches what came back', async () => { + it('presigns, PUTs the bytes without the API key, then completes', async () => { const path = tempFile('card.png'); - const fetchImpl = vi.fn(async () => - jsonResponse( - { data: [{ id: 'media_1', type: 'image', name: 'card.png', url: 'r2://card.png' }] }, - 201, - ), - ); + const uploadUrl = 'https://bucket.example.test/staging/card.png?sig=abc'; + const fetchImpl = vi.fn(async (url: string, init: RequestInit) => { + if (url.endsWith('/v1/media/presign')) { + return jsonResponse( + { + data: { + uploadId: 'up_1', + uploadUrl, + method: 'PUT', + headers: { 'Content-Type': 'image/png' }, + expiresAt: '2026-09-19T00:15:00.000Z', + }, + }, + 201, + ); + } + if (url === uploadUrl && init.method === 'PUT') return new Response(null, { status: 200 }); + if (url.endsWith('/v1/media/presign/up_1/complete')) { + return jsonResponse( + { data: { id: 'media_1', type: 'image', name: 'card.png', url: 'r2://card.png' } }, + 201, + ); + } + return jsonResponse({ error: 'not_found', message: `unstubbed ${url}` }, 404); + }); const items = await resolveMedia([path], { ...CTX, fetchImpl: fetchImpl as never }); expect(items).toEqual([ { id: 'media_1', type: 'image', name: 'card.png', url: 'r2://card.png' }, ]); - const [url, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit]; - // This request is ours, not the SDK's, so the exact path is pinned here. + const calls = fetchImpl.mock.calls as unknown as [string, RequestInit][]; + expect(calls.map(([url, init]) => `${init.method} ${url}`)).toEqual([ + 'POST https://api.fopost.test/v1/media/presign', + `PUT ${uploadUrl}`, + 'POST https://api.fopost.test/v1/media/presign/up_1/complete', + ]); + // These requests are ours, not the SDK's, so the exact paths are pinned here. // The API serves /v1; /api/v1 is a 404 and was shipped once already. - expect(url).toBe('https://api.fopost.test/v1/media/upload'); - expect(url).not.toContain('/api/v1'); - expect((init.headers as Record)['X-API-Key']).toBe(CTX.apiKey); - expect(init.body).toBeInstanceOf(FormData); - expect((init.body as FormData).get('workspaceId')).toBe(CTX.workspaceId); + expect(calls.some(([url]) => url.includes('/api/v1'))).toBe(false); + + const [, presign] = calls[0]; + expect((presign.headers as Record)['X-API-Key']).toBe(CTX.apiKey); + expect(JSON.parse(presign.body as string)).toEqual({ + workspaceId: CTX.workspaceId, + filename: 'card.png', + mimeType: 'image/png', + size: 'binary-ish'.length, + }); + + const [, put] = calls[1]; + expect(put.headers).toEqual({ 'Content-Type': 'image/png' }); + expect(put.headers).not.toHaveProperty('X-API-Key'); + expect(put.body).toBeInstanceOf(Uint8Array); + + const [, complete] = calls[2]; + expect((complete.headers as Record)['X-API-Key']).toBe(CTX.apiKey); }); it('passes a remote URL through without uploading', async () => { @@ -83,7 +120,7 @@ describe('resolveMedia', () => { expect(items[0].type).toBe('video'); }); - it('surfaces a rate limit with the retry delay from the header', async () => { + it('surfaces a rate limit on presign with the retry delay from the header', async () => { const path = tempFile('card.png'); const fetchImpl = vi.fn(async () => jsonResponse({ error: 'rate_limited', message: 'Too many uploads' }, 429, { diff --git a/src/media.ts b/src/media.ts index 7c1de9a..ecbbddb 100644 --- a/src/media.ts +++ b/src/media.ts @@ -54,9 +54,37 @@ export type UploadContext = { type UploadedMedia = MediaItem & { id?: string }; +type PresignedUpload = { + uploadId: string; + uploadUrl: string; + method: string; + headers: Record; +}; + +/** Reads the body as JSON; a non-JSON body counts as empty. */ +async function readJson(res: Response): Promise> { + return (await res.json().catch(() => ({}))) as Record; +} + +function apiError(res: Response, payload: Record, fallback: string): FoPostError { + // Only the retry delay is lifted off the response; no other header is read + // or logged, so nothing incidental reaches the build log. + const retryAfter = Number(res.headers.get('retry-after')); + const body = + Number.isFinite(retryAfter) && retryAfter > 0 + ? { ...payload, retry_after: retryAfter } + : payload; + const message = + (typeof payload.message === 'string' && payload.message) || + (typeof payload.error === 'string' && payload.error) || + fallback; + return new FoPostError(message, res.status, payload.error as string | undefined, body); +} + /** - * Upload one local file to the media library. The SDK does not wrap the - * multipart endpoint, so this posts directly with the same auth header. + * Upload one local file to the media library through the direct-upload flow: + * presign, PUT the bytes to the returned URL, then complete. The SDK does not + * wrap these endpoints, so this calls them directly with the same auth header. */ export async function uploadMediaFile(path: string, ctx: UploadContext): Promise { const absolute = resolveWorkspacePath(path); @@ -70,43 +98,80 @@ export async function uploadMediaFile(path: string, ctx: UploadContext): Promise const name = basename(absolute); const ext = extname(name).toLowerCase(); - const form = new FormData(); - form.append('workspaceId', ctx.workspaceId); - form.append('files', new Blob([new Uint8Array(bytes)], { type: MIME_TYPES[ext] }), name); + const mimeType = MIME_TYPES[ext] ?? 'application/octet-stream'; const doFetch = ctx.fetchImpl ?? globalThis.fetch; - const url = `${ctx.baseUrl ?? resolveBaseUrl()}/v1/media/upload`; + const base = `${ctx.baseUrl ?? resolveBaseUrl()}/v1/media/presign`; + const authHeaders = { + 'X-API-Key': ctx.apiKey, + Accept: 'application/json', + 'Content-Type': 'application/json', + }; debug(`Uploading ${name} (${bytes.byteLength} bytes) to the media library`); - const res = await doFetch(url, { + const presignRes = await doFetch(base, { method: 'POST', - headers: { 'X-API-Key': ctx.apiKey, Accept: 'application/json' }, - body: form, + headers: authHeaders, + body: JSON.stringify({ + workspaceId: ctx.workspaceId, + filename: name, + mimeType, + size: bytes.byteLength, + }), + }); + const presignPayload = await readJson(presignRes); + if (!presignRes.ok) { + throw apiError( + presignRes, + presignPayload, + `Uploading ${name} failed with HTTP ${presignRes.status}`, + ); + } + const presigned = presignPayload.data as Partial | undefined; + if ( + !presigned || + typeof presigned.uploadId !== 'string' || + typeof presigned.uploadUrl !== 'string' + ) { + throw new FoPostError( + `Uploading ${name} returned no upload URL`, + presignRes.status, + undefined, + presignPayload, + ); + } + + // The upload URL is pre-authorised: it carries exactly the presigned headers and no API key. + const putRes = await doFetch(presigned.uploadUrl, { + method: presigned.method ?? 'PUT', + headers: presigned.headers ?? { 'Content-Type': mimeType }, + body: new Uint8Array(bytes), }); + if (!putRes.ok) { + throw new FoPostError( + `Uploading ${name} failed with HTTP ${putRes.status} from storage`, + putRes.status, + ); + } - const payload = (await res.json().catch(() => ({}))) as Record; - - if (!res.ok) { - // Only the retry delay is lifted off the response; no other header is read - // or logged, so nothing incidental reaches the build log. - const retryAfter = Number(res.headers.get('retry-after')); - const body = - Number.isFinite(retryAfter) && retryAfter > 0 - ? { ...payload, retry_after: retryAfter } - : payload; - const message = - (typeof payload.message === 'string' && payload.message) || - (typeof payload.error === 'string' && payload.error) || - `Uploading ${name} failed with HTTP ${res.status}`; - throw new FoPostError(message, res.status, payload.error as string | undefined, body); + const completeRes = await doFetch(`${base}/${encodeURIComponent(presigned.uploadId)}/complete`, { + method: 'POST', + headers: authHeaders, + }); + const payload = await readJson(completeRes); + if (!completeRes.ok) { + throw apiError( + completeRes, + payload, + `Uploading ${name} failed with HTTP ${completeRes.status}`, + ); } - const items = Array.isArray(payload.data) ? (payload.data as Record[]) : []; - const uploaded = items[0]; + const uploaded = payload.data as Record | undefined; if (!uploaded || typeof uploaded.url !== 'string') { throw new FoPostError( `Uploading ${name} returned no media URL`, - res.status, + completeRes.status, undefined, payload, ); diff --git a/src/run.test.ts b/src/run.test.ts index b48df92..0a6edfe 100644 --- a/src/run.test.ts +++ b/src/run.test.ts @@ -97,7 +97,7 @@ function stubFetch(handler: (req: RecordedRequest) => Response) { * makes are matched by the resource suffix below rather than the full path: * the SDK owns its base path, and a test that pins it is asserting someone * else's implementation detail. Only this action's own requests — the media - * upload — get an exact-URL assertion. + * presign and complete calls — get an exact-URL assertion. */ function pathOf(url: string): string { return new URL(url).pathname; @@ -216,7 +216,7 @@ describe('run', () => { expect(recorder.failed).toEqual([]); expect(requests.every((r) => r.method === 'GET')).toBe(true); - expect(requests.some((r) => pathOf(r.url).endsWith('/media/upload'))).toBe(false); + expect(requests.some((r) => pathOf(r.url).includes('/media/presign'))).toBe(false); expect(recorder.outputs.status).toBe('dry-run'); expect(recorder.outputs['post-id']).toBe(''); expect(recorder.outputs['delivery-count']).toBe('0'); From 5ae87c1f229d967ef78672957270dcbf258aec23 Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sat, 19 Sep 2026 16:34:49 +0200 Subject: [PATCH 2/4] build: rebuild the bundle for the direct-upload flow --- dist/index.js | 88 ++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 63 insertions(+), 25 deletions(-) diff --git a/dist/index.js b/dist/index.js index c2a4af4..8e2bbd2 100644 --- a/dist/index.js +++ b/dist/index.js @@ -32050,9 +32050,26 @@ function remoteMediaItem(url) { const name = (0,external_node_path_namespaceObject.basename)(new URL(url).pathname) || 'media'; return { type: classifyMedia(name), name, url }; } +/** Reads the body as JSON; a non-JSON body counts as empty. */ +async function readJson(res) { + return (await res.json().catch(() => ({}))); +} +function apiError(res, payload, fallback) { + // Only the retry delay is lifted off the response; no other header is read + // or logged, so nothing incidental reaches the build log. + const retryAfter = Number(res.headers.get('retry-after')); + const body = Number.isFinite(retryAfter) && retryAfter > 0 + ? { ...payload, retry_after: retryAfter } + : payload; + const message = (typeof payload.message === 'string' && payload.message) || + (typeof payload.error === 'string' && payload.error) || + fallback; + return new FoPostError(message, res.status, payload.error, body); +} /** - * Upload one local file to the media library. The SDK does not wrap the - * multipart endpoint, so this posts directly with the same auth header. + * Upload one local file to the media library through the direct-upload flow: + * presign, PUT the bytes to the returned URL, then complete. The SDK does not + * wrap these endpoints, so this calls them directly with the same auth header. */ async function uploadMediaFile(path, ctx) { const absolute = resolveWorkspacePath(path); @@ -32066,34 +32083,55 @@ async function uploadMediaFile(path, ctx) { } const name = (0,external_node_path_namespaceObject.basename)(absolute); const ext = (0,external_node_path_namespaceObject.extname)(name).toLowerCase(); - const form = new FormData(); - form.append('workspaceId', ctx.workspaceId); - form.append('files', new Blob([new Uint8Array(bytes)], { type: MIME_TYPES[ext] }), name); + const mimeType = MIME_TYPES[ext] ?? 'application/octet-stream'; const doFetch = ctx.fetchImpl ?? globalThis.fetch; - const url = `${ctx.baseUrl ?? resolveBaseUrl()}/v1/media/upload`; + const base = `${ctx.baseUrl ?? resolveBaseUrl()}/v1/media/presign`; + const authHeaders = { + 'X-API-Key': ctx.apiKey, + Accept: 'application/json', + 'Content-Type': 'application/json', + }; debug(`Uploading ${name} (${bytes.byteLength} bytes) to the media library`); - const res = await doFetch(url, { + const presignRes = await doFetch(base, { method: 'POST', - headers: { 'X-API-Key': ctx.apiKey, Accept: 'application/json' }, - body: form, + headers: authHeaders, + body: JSON.stringify({ + workspaceId: ctx.workspaceId, + filename: name, + mimeType, + size: bytes.byteLength, + }), }); - const payload = (await res.json().catch(() => ({}))); - if (!res.ok) { - // Only the retry delay is lifted off the response; no other header is read - // or logged, so nothing incidental reaches the build log. - const retryAfter = Number(res.headers.get('retry-after')); - const body = Number.isFinite(retryAfter) && retryAfter > 0 - ? { ...payload, retry_after: retryAfter } - : payload; - const message = (typeof payload.message === 'string' && payload.message) || - (typeof payload.error === 'string' && payload.error) || - `Uploading ${name} failed with HTTP ${res.status}`; - throw new FoPostError(message, res.status, payload.error, body); - } - const items = Array.isArray(payload.data) ? payload.data : []; - const uploaded = items[0]; + const presignPayload = await readJson(presignRes); + if (!presignRes.ok) { + throw apiError(presignRes, presignPayload, `Uploading ${name} failed with HTTP ${presignRes.status}`); + } + const presigned = presignPayload.data; + if (!presigned || + typeof presigned.uploadId !== 'string' || + typeof presigned.uploadUrl !== 'string') { + throw new FoPostError(`Uploading ${name} returned no upload URL`, presignRes.status, undefined, presignPayload); + } + // The upload URL is pre-authorised: it carries exactly the presigned headers and no API key. + const putRes = await doFetch(presigned.uploadUrl, { + method: presigned.method ?? 'PUT', + headers: presigned.headers ?? { 'Content-Type': mimeType }, + body: new Uint8Array(bytes), + }); + if (!putRes.ok) { + throw new FoPostError(`Uploading ${name} failed with HTTP ${putRes.status} from storage`, putRes.status); + } + const completeRes = await doFetch(`${base}/${encodeURIComponent(presigned.uploadId)}/complete`, { + method: 'POST', + headers: authHeaders, + }); + const payload = await readJson(completeRes); + if (!completeRes.ok) { + throw apiError(completeRes, payload, `Uploading ${name} failed with HTTP ${completeRes.status}`); + } + const uploaded = payload.data; if (!uploaded || typeof uploaded.url !== 'string') { - throw new FoPostError(`Uploading ${name} returned no media URL`, res.status, undefined, payload); + throw new FoPostError(`Uploading ${name} returned no media URL`, completeRes.status, undefined, payload); } info(`Uploaded ${name}`); return { From d508bf38a0937a00224a9cea78bf711b8b943b3b Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sat, 19 Sep 2026 16:34:49 +0200 Subject: [PATCH 3/4] docs: describe the direct-upload media flow in CLAUDE.md --- CLAUDE.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ca45df6..7356e67 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,10 +87,13 @@ Inherited from `@fopost/sdk` (read that repo before changing request shapes): - Error envelope `{"error": "", "message": ""}`; 402 carries `upgrade_url`. - `posts.create` then `posts.publish` — publish returns when delivery is **queued**, not live. Its body is `{ post_status, deliveries[], healthWarnings }`. -- Media upload is `POST /v1/media/upload`, multipart, files under the `files` - field plus a `workspaceId` field. The SDK does not wrap it, so `src/media.ts` posts - directly with the same auth header — which makes the path ours to get right, and the - one URL `src/media.test.ts` asserts exactly. +- Media upload is the direct-upload flow: `POST /v1/media/presign` + `{ workspaceId, filename, mimeType, size }` → `{ uploadId, uploadUrl, method, headers }`, + a `PUT` of the raw bytes to `uploadUrl` with exactly those headers and **no API key**, + then `POST /v1/media/presign//complete`, which answers the same media shape + as the old `/v1/media/upload`. The SDK does not wrap it, so `src/media.ts` calls it + directly with the same auth header — which makes the paths ours to get right, and the + URLs `src/media.test.ts` asserts exactly. - The dashboard URL for a post is `https://fopost.com/dashboard/posts/`, overridable with `FOPOST_APP_URL`. From d3b1f8c605566dd759b009e8d61393111c787ba9 Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sat, 19 Sep 2026 17:51:40 +0200 Subject: [PATCH 4/4] style: format errors.test.ts so format:check passes --- src/errors.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/errors.test.ts b/src/errors.test.ts index b3dbf3b..7dc0d6e 100644 --- a/src/errors.test.ts +++ b/src/errors.test.ts @@ -56,7 +56,9 @@ describe('describeError', () => { const error = new FoPostError('Plan does not cover this', 402, 'subscription_required', { upgrade_url: 'https://fopost.com/dashboard/settings/billing', }); - expect(describeError(error)).toContain('Upgrade: https://fopost.com/dashboard/settings/billing'); + expect(describeError(error)).toContain( + 'Upgrade: https://fopost.com/dashboard/settings/billing', + ); }); it('says when to retry on a 429', () => {