Compare commits

..
3 Commits
Author SHA1 Message Date
calcazar 25dd577681 Merge pull request 'Promote to main: fix media post failure (presigned-url shape)' (#13) from develop into main 2026-10-21 15:37:49 -06:00
calcazar aeb659f3de Merge pull request 'Feed browser: fix media post failure (presigned-url shape)' (#12) from feature/feed-browser-fix-media-presign-shape into develop 2026-10-21 15:35:49 -06:00
calcazar eab717b7b2 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.
2026-10-21 15:35:09 -06:00
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 {
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<CreateMediaPostResult> {
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);
}
}
+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);
});
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) => {