chore(release): v1.5.6

Pure-history medication detail page, legacy step consolidation, and the
finish of the outbound-fetch migration.

- /medications/[id] becomes a read-only history surface; every setting
  moves into a new advanced-settings sheet, and the edit trigger becomes
  a two-option picker. The list page is unchanged.
- A pg-boss job folds pre-v1.5.0 granular step rows into one canonical
  daily total per user per day and soft-deletes the originals; migration
  0087 adds a partial index for the converging discovery scan.
- Every remaining server-side fetch routes through safeFetch (Withings,
  Codex OAuth + SSE, bug-report, Umami, GlitchTip, Loki); a new
  healthlog/safe-fetch-required ESLint rule guards the boundary.
- Fixes: intake-edit dialog seeded from real values, consolidation skips
  a unique-constraint day instead of stranding it, avatar upload reads
  the body once under a bounded cap, egress lint rejects absolute URLs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-29 22:07:10 +02:00
1 parent d54addd61a
commit 8528c3b4fe
54 files changed
+2343 -324

No files matched your search

@@ -89,15 +89,20 @@ export const POST = apiHandler(async (request: NextRequest) => {
let lastDetails = "";
for (const targetUrl of targetUrls) {
const upstream = await safeFetch(targetUrl, {
method: "POST",
headers: {
"content-type": "application/json",
"user-agent": "healthlog-admin-monitoring-test",
const upstream = await safeFetch(
targetUrl,
{
method: "POST",
headers: {
"content-type": "application/json",
"user-agent": "healthlog-admin-monitoring-test",
},
body: JSON.stringify(payload),
cache: "no-store",
},
body: JSON.stringify(payload),
cache: "no-store",
});
// Operator-configured Umami host — pin the connect-time IP.
{ requirePublicHost: true },
);
if (upstream.ok) {
return apiSuccess({
+1 -1
View File
@@ -159,7 +159,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
// GitHub doesn't support base64 in comments, so we note the limitation
const commentBody = `### Screenshot\n\n![Screenshot](data:image/${ext};base64,${base64Data.slice(0, 100)}...)\n\n*Note: The full screenshot was truncated due to size limits. Please contact the user directly.*`;
await fetch(
await safeFetch(
`https://api.github.com/repos/${ghRepo}/issues/${issue.number}/comments`,
{
method: "POST",
+10 -3
View File
@@ -26,9 +26,16 @@ export const GET = apiHandler(async () => {
});
}
const response = await safeFetch(settings.umamiScriptUrl, {
next: { revalidate: 3600 },
});
const response = await safeFetch(
settings.umamiScriptUrl,
{
next: { revalidate: 3600 },
},
// Operator-configured host — pin the connect-time IP so a low-TTL
// DNS record cannot rebind the fetch at an internal range between
// the input-time `isPublicUrl` accept and the socket connect.
{ requirePublicHost: true },
);
if (!response.ok) {
return new NextResponse(NOOP_SCRIPT, {
status: 200,
+14 -9
View File
@@ -74,16 +74,21 @@ export const POST = apiHandler(async (request: NextRequest) => {
let lastResponseStatus = 404;
for (const targetUrl of targetUrls) {
const upstream = await safeFetch(targetUrl, {
method: "POST",
headers: {
"content-type":
request.headers.get("content-type") || "application/json",
"user-agent": request.headers.get("user-agent") || "healthlog-proxy",
const upstream = await safeFetch(
targetUrl,
{
method: "POST",
headers: {
"content-type":
request.headers.get("content-type") || "application/json",
"user-agent": request.headers.get("user-agent") || "healthlog-proxy",
},
body,
cache: "no-store",
},
body,
cache: "no-store",
});
// Operator-configured Umami host — pin the connect-time IP.
{ requirePublicHost: true },
);
lastResponseStatus = upstream.status;
@@ -146,6 +146,41 @@ describe("POST /api/user/avatar", () => {
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("rejects an oversize chunked upload with no Content-Length at the stream level (413)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// Build a multipart body as a streamed ReadableStream with NO
// Content-Length header — the shape a `Transfer-Encoding: chunked`
// upload takes. The bounded stream reader must trip before the body
// is fully buffered.
const boundary = "----healthlogtest";
const head = Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="a.png"\r\nContent-Type: image/png\r\n\r\n`,
);
const tail = Buffer.from(`\r\n--${boundary}--\r\n`);
const oversize = Buffer.alloc(2 * 1024 * 1024 + 1, 0x41);
const parts = [head, oversize, tail];
const stream = new ReadableStream<Uint8Array>({
start(controller) {
for (const p of parts) {
controller.enqueue(new Uint8Array(p));
}
controller.close();
},
});
const req = new Request("http://localhost/api/user/avatar", {
method: "POST",
headers: { "content-type": `multipart/form-data; boundary=${boundary}` },
body: stream,
// Node's fetch requires `duplex: "half"` for a streamed body.
duplex: "half",
} as RequestInit & { duplex: "half" });
const res = await post(req);
expect(res.status).toBe(413);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("rejects an unsupported MIME with 415 (magic-byte sniff)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// Plain text content — no JPEG/PNG/WebP magic.
+83 -1
View File
@@ -62,6 +62,67 @@ interface UploadResponse {
updatedAt: string;
}
/** Thrown by `readBoundedBody` when the stream exceeds the byte cap. */
class BodyTooLargeError extends Error {
constructor() {
super("Request body exceeds the configured byte cap");
this.name = "BodyTooLargeError";
}
}
/**
* Read a request body stream into a single buffer while counting bytes,
* throwing `BodyTooLargeError` the moment the running total passes
* `maxBytes`. Bounds the work at the stream level so a chunked /
* unbounded upload cannot park past the cap even when no `Content-Length`
* header is present. The captured bytes are retained (capped at
* `maxBytes`) so the caller can reconstruct `FormData` from them — this
* reads the body exactly once, with no clone and no second parser racing
* the same body, so a cap trip frees the allocation immediately instead
* of leaving a native parse buffering the rest of an oversized upload.
*/
async function readBoundedBody(
body: ReadableStream<Uint8Array> | null,
maxBytes: number,
): Promise<Uint8Array<ArrayBuffer>> {
if (!body) return new Uint8Array(0);
const reader = body.getReader();
const chunks: Uint8Array[] = [];
let retained = 0;
let overflow = false;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
if (overflow) continue;
if (retained + value.byteLength > maxBytes) {
// Past the cap. Drop everything retained so far and stop
// retaining further chunks — memory stays bounded at the cap.
// Keep draining the stream to its natural close rather than
// cancelling it: an abrupt cancel mid-transfer races the
// underlying (undici) producer, which then enqueues into a
// closed controller and surfaces as an unhandled rejection.
chunks.length = 0;
retained = 0;
overflow = true;
continue;
}
chunks.push(value);
retained += value.byteLength;
}
} finally {
reader.releaseLock();
}
if (overflow) throw new BodyTooLargeError();
const out = new Uint8Array(retained);
let offset = 0;
for (const chunk of chunks) {
out.set(chunk, offset);
offset += chunk.byteLength;
}
return out;
}
export const POST = apiHandler(async (request: Request) => {
const { user } = await requireAuth();
@@ -94,10 +155,31 @@ export const POST = apiHandler(async (request: Request) => {
);
}
// Stream-level cap. The Content-Length pre-flight above only fires when
// the header is present; a `Transfer-Encoding: chunked` upload (or any
// client that omits the header) skips it, and `request.formData()`
// would then buffer the whole stream before the post-parse `file.size`
// check can run. Read the body ONCE through a bounded reader that
// aborts on the first byte past AVATAR_MAX_BYTES, then reconstruct the
// FormData from the captured bytes. A single read means the cap trip
// frees the allocation immediately — no clone, no tee, and no native
// parse left buffering the rest of an oversized body. The post-parse
// `file.size` check stays in place as defence-in-depth.
let formData: FormData;
try {
formData = await request.formData();
const bytes = await readBoundedBody(request.body, AVATAR_MAX_BYTES);
formData = await new Response(new Blob([bytes]), {
headers: { "content-type": request.headers.get("content-type") ?? "" },
}).formData();
} catch (err) {
// Surface the most useful failure: a cap overflow always wins.
if (err instanceof BodyTooLargeError) {
annotate({
action: { name: "user.avatar.upload.rejected" },
meta: { reason: "stream_size_exceeded" },
});
return apiError(`Upload exceeds ${AVATAR_MAX_BYTES} byte limit`, 413);
}
return apiError(
`Invalid multipart body: ${err instanceof Error ? err.message : "unknown"}`,
400,