From eab717b7b2e1d0829c824439dba2a60ec7094511 Mon Sep 17 00:00:00 2001 From: Cesar Alcazar Date: Wed, 21 Oct 2026 15:35:09 -0600 Subject: [PATCH] Fix media post failure by reading the real presigned-url response shape Image and video posts failed at the presigned-url step: the reader looked for the upload slot under data keys files/urls/attachments/presignedUrls but Minnect returns it under `uploads` ({ data: { uploads: [{ attachmentId, presignedUrl, s3Key, index }] } }), so no slot was found and every media post returned post_failed. Read the `uploads` key (under data or at the root), add per-step pipeline logging that names the failing step with its status and message (never a token, URL or bytes), and add a regression test for the real response shape. --- src/lib/post-media.ts | 51 ++++++++++++++++++++++++++-------------- tests/post-media.test.ts | 26 ++++++++++++++++++++ 2 files changed, 60 insertions(+), 17 deletions(-) diff --git a/src/lib/post-media.ts b/src/lib/post-media.ts index 393aa72..15cfc13 100644 --- a/src/lib/post-media.ts +++ b/src/lib/post-media.ts @@ -78,18 +78,19 @@ function readPresigned( ): { uploadUrl: string; attachmentId: string; s3Key: string } | null { const root = rec(body); const data = root.data; - // The entry may be the first element of an array, the first element of a - // named list, or the data object itself. + // The confirmed shape is { data: { uploads: [{ attachmentId, presignedUrl, + // s3Key, index }] } }. The entry may also arrive as a bare array, under a + // differently named list, or as the data object itself, so try each. const candidates: unknown[] = []; if (Array.isArray(data)) candidates.push(...data); else if (Array.isArray(body)) candidates.push(...(body as unknown[])); - else { - const d = rec(data); - for (const key of ["files", "urls", "attachments", "presignedUrls"]) { - if (Array.isArray(d[key])) candidates.push(...(d[key] as unknown[])); + // Named lists can live under `data` or at the root. + for (const container of [rec(data), root]) { + for (const key of ["uploads", "files", "urls", "attachments", "presignedUrls"]) { + if (Array.isArray(container[key])) candidates.push(...(container[key] as unknown[])); } - candidates.push(d); } + candidates.push(rec(data)); for (const c of candidates) { const e = rec(c); const uploadUrl = @@ -189,7 +190,21 @@ export async function runMediaPipeline( options: RefreshOptions = {}, ): Promise { const signIn = { ok: false, status: 401, error: "sign_in" } as const; - const failed = { ok: false, status: 502, error: "post_failed" } as const; + const failedResult = { ok: false, status: 502, error: "post_failed" } as const; + // Log which step failed with its status and Minnect's message, never a token, + // presigned URL or file bytes, so a failed publish can be diagnosed. + const fail = async (step: string, res?: Response, note?: string) => { + let message = note ?? ""; + if (res) { + const body = rec(await res.clone().json().catch(() => null)); + const m = body.message ?? body.error ?? rec(body.error).message; + message = [`status ${res.status}`, typeof m === "string" ? m : "", note ?? ""] + .filter(Boolean) + .join(" "); + } + console.warn(`[post-media] ${mediaType} ${step} failed: ${message}`); + return failedResult; + }; const doFetch = options.fetchImpl ?? fetch; const { file, mediaType } = upload; @@ -222,9 +237,9 @@ export async function runMediaPipeline( visibility: "public", }); if (created === "sign_in") return signIn; - if (created.status !== 200 && created.status !== 201) return failed; + if (created.status !== 200 && created.status !== 201) return fail("draft", created); const info = readCreated(await created.json().catch(() => null)); - if (!info) return failed; + if (!info) return fail("draft", undefined, "no post id in response"); // 2. Presigned URL for the one file. const presignedRes = await call("/api/v1/attachments/presigned-urls", { @@ -232,9 +247,9 @@ export async function runMediaPipeline( files: [{ name: file.name, type: file.type, size: file.size }], }); if (presignedRes === "sign_in") return signIn; - if (presignedRes.status < 200 || presignedRes.status > 299) return failed; + if (presignedRes.status < 200 || presignedRes.status > 299) return fail("presign", presignedRes); const slot = readPresigned(await presignedRes.json().catch(() => null)); - if (!slot) return failed; + if (!slot) return fail("presign", undefined, "no upload slot in response"); // 3. The server PUTs the bytes. No Authorization header: the presigned URL // is pre-authorized, and the feed token must never reach S3 or the browser. @@ -244,7 +259,7 @@ export async function runMediaPipeline( headers: { "Content-Type": file.type }, body: bytes, }); - if (!put.ok) return failed; + if (!put.ok) return fail("upload", undefined, `status ${put.status}`); // 4. Confirm (image confirmation blocks until processing finishes). const confirmed = await call("/api/v1/attachments/confirm-uploads", { @@ -259,17 +274,19 @@ export async function runMediaPipeline( ], }); if (confirmed === "sign_in") return signIn; - if (confirmed.status < 200 || confirmed.status > 299) return failed; + if (confirmed.status < 200 || confirmed.status > 299) return fail("confirm", confirmed); // 5. Publish, same call as a text post. const published = await call(`/api/v1/posts/${encodeURIComponent(info.id)}/publish`, { content: caption, }); if (published === "sign_in") return signIn; - if (published.status < 200 || published.status > 299) return failed; + if (published.status < 200 || published.status > 299) return fail("publish", published); return { ok: true, id: info.id, status: info.status }; - } catch { - return failed; + } catch (e) { + const code = e instanceof Error ? (e.cause as { code?: unknown } | undefined)?.code : undefined; + const note = e instanceof Error ? `${e.name}: ${e.message} ${code ?? ""}`.trim() : "error"; + return fail("pipeline", undefined, note); } } diff --git a/tests/post-media.test.ts b/tests/post-media.test.ts index 5d11545..a3d8de7 100644 --- a/tests/post-media.test.ts +++ b/tests/post-media.test.ts @@ -100,6 +100,32 @@ describe("POST /api/posts (image)", () => { expect(calls.filter((c) => c.url.includes("/api/v1/")).every((c) => c.auth === "Bearer T")).toBe(true); }); + it("reads the real { data: { uploads: [{ presignedUrl, ... }] } } shape", async () => { + const sid = session(); + const calls: { url: string; method: string }[] = []; + const fn = vi.fn(async (url: string | URL, init?: RequestInit) => { + const u = String(url); + calls.push({ url: u, method: String(init?.method ?? "GET") }); + if (u.endsWith("/api/v1/posts")) return jsonRes({ data: { id: "p9", status: "draft" } }, 201); + if (u.endsWith("/attachments/presigned-urls")) + return jsonRes( + { data: { uploads: [{ attachmentId: "a1", presignedUrl: "https://s3.example/put?sig=9", s3Key: "k/1.jpg", index: 0 }] } }, + 200, + ); + if (u === "https://s3.example/put?sig=9") return new Response(null, { status: 200 }); + if (u.endsWith("/attachments/confirm-uploads")) return jsonRes({ data: { success: true } }, 200); + if (u.includes("/publish")) return jsonRes({ data: { success: true } }, 200); + return new Response("{}", { status: 404 }); + }); + const res = await withFetch(fn as unknown as typeof fetch, () => + POST(multipart("look", imageFile(), sid)), + ); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ id: "p9" }); + const put = calls.find((c) => c.method === "PUT"); + expect(put?.url).toBe("https://s3.example/put?sig=9"); + }); + it("never returns the feed token to the browser", async () => { const sid = session(); const fn = vi.fn(async (url: string | URL) => { -- 2.54.0