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.
This commit is contained in:
2026-10-21 15:35:09 -06:00
parent cf3afb403c
commit eab717b7b2
2 changed files with 60 additions and 17 deletions
+34 -17
View File
@@ -78,18 +78,19 @@ function readPresigned(
): { uploadUrl: string; attachmentId: string; s3Key: string } | null { ): { uploadUrl: string; attachmentId: string; s3Key: string } | null {
const root = rec(body); const root = rec(body);
const data = root.data; const data = root.data;
// The entry may be the first element of an array, the first element of a // The confirmed shape is { data: { uploads: [{ attachmentId, presignedUrl,
// named list, or the data object itself. // 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[] = []; const candidates: unknown[] = [];
if (Array.isArray(data)) candidates.push(...data); if (Array.isArray(data)) candidates.push(...data);
else if (Array.isArray(body)) candidates.push(...(body as unknown[])); else if (Array.isArray(body)) candidates.push(...(body as unknown[]));
else { // Named lists can live under `data` or at the root.
const d = rec(data); for (const container of [rec(data), root]) {
for (const key of ["files", "urls", "attachments", "presignedUrls"]) { for (const key of ["uploads", "files", "urls", "attachments", "presignedUrls"]) {
if (Array.isArray(d[key])) candidates.push(...(d[key] as unknown[])); if (Array.isArray(container[key])) candidates.push(...(container[key] as unknown[]));
} }
candidates.push(d);
} }
candidates.push(rec(data));
for (const c of candidates) { for (const c of candidates) {
const e = rec(c); const e = rec(c);
const uploadUrl = const uploadUrl =
@@ -189,7 +190,21 @@ export async function runMediaPipeline(
options: RefreshOptions = {}, options: RefreshOptions = {},
): Promise<CreateMediaPostResult> { ): Promise<CreateMediaPostResult> {
const signIn = { ok: false, status: 401, error: "sign_in" } as const; 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 doFetch = options.fetchImpl ?? fetch;
const { file, mediaType } = upload; const { file, mediaType } = upload;
@@ -222,9 +237,9 @@ export async function runMediaPipeline(
visibility: "public", visibility: "public",
}); });
if (created === "sign_in") return signIn; 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)); 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. // 2. Presigned URL for the one file.
const presignedRes = await call("/api/v1/attachments/presigned-urls", { 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 }], files: [{ name: file.name, type: file.type, size: file.size }],
}); });
if (presignedRes === "sign_in") return signIn; 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)); 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 // 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. // 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 }, headers: { "Content-Type": file.type },
body: bytes, 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). // 4. Confirm (image confirmation blocks until processing finishes).
const confirmed = await call("/api/v1/attachments/confirm-uploads", { 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 === "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. // 5. Publish, same call as a text post.
const published = await call(`/api/v1/posts/${encodeURIComponent(info.id)}/publish`, { const published = await call(`/api/v1/posts/${encodeURIComponent(info.id)}/publish`, {
content: caption, content: caption,
}); });
if (published === "sign_in") return signIn; 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 }; return { ok: true, id: info.id, status: info.status };
} catch { } catch (e) {
return failed; 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);
} }
} }
+26
View File
@@ -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); 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 () => { it("never returns the feed token to the browser", async () => {
const sid = session(); const sid = session();
const fn = vi.fn(async (url: string | URL) => { const fn = vi.fn(async (url: string | URL) => {