Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,13 @@ Inherited from `@fopost/sdk` (read that repo before changing request shapes):
- Error envelope `{"error": "<code>", "message": "<text>"}`; 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/<uploadId>/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/<id>`, overridable with
`FOPOST_APP_URL`.

Expand Down
88 changes: 63 additions & 25 deletions dist/index.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion src/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
67 changes: 52 additions & 15 deletions src/media.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>)['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<string, string>)['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<string, string>)['X-API-Key']).toBe(CTX.apiKey);
});

it('passes a remote URL through without uploading', async () => {
Expand All @@ -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, {
Expand Down
119 changes: 92 additions & 27 deletions src/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,37 @@ export type UploadContext = {

type UploadedMedia = MediaItem & { id?: string };

type PresignedUpload = {
uploadId: string;
uploadUrl: string;
method: string;
headers: Record<string, string>;
};

/** Reads the body as JSON; a non-JSON body counts as empty. */
async function readJson(res: Response): Promise<Record<string, unknown>> {
return (await res.json().catch(() => ({}))) as Record<string, unknown>;
}

function apiError(res: Response, payload: Record<string, unknown>, 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<UploadedMedia> {
const absolute = resolveWorkspacePath(path);
Expand All @@ -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<PresignedUpload> | 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<string, unknown>;

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<string, unknown>[]) : [];
const uploaded = items[0];
const uploaded = payload.data as Record<string, unknown> | undefined;
if (!uploaded || typeof uploaded.url !== 'string') {
throw new FoPostError(
`Uploading ${name} returned no media URL`,
res.status,
completeRes.status,
undefined,
payload,
);
Expand Down
4 changes: 2 additions & 2 deletions src/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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');
Expand Down
Loading