fix(api,ops): transactional adopt, admin gate without a flash, and a digest path to deploys

The self-context adopt runs read-modify-write inside one transaction,
the admin shell renders nothing until the role is confirmed, and the
json-body helper merges Headers instances correctly. The compose image
reference accepts a pinned digest through an env var, and a promote
script resolves the published digest, verifies its signature and hands
the operator the exact pinning call — the documented path away from a
moving latest tag.
This commit is contained in:
Marc-André Bombeck committed 2026-06-11 11:45:58 +02:00
1 parent 599b7e8c99
commit f13aa684f9
12 files changed
+876 -79

No files matched your search

@@ -0,0 +1,160 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
/**
* POST /api/coach/about-me/adopt — locks the read-modify-write.
*
* The original handler read the self-context through the global client,
* computed the appended value, then upserted: two concurrent adoptions
* could read the same base value and one append silently lost. The
* suite pins the transactional shape — empty upsert → `FOR UPDATE` row
* lock → read through the SAME `tx` client → single-column update —
* so a refactor can't quietly fall back to the racy read.
*/
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
}));
const tx = {
userHealthProfile: {
upsert: vi.fn(),
update: vi.fn(),
findUnique: vi.fn(),
},
$queryRaw: vi.fn(),
};
vi.mock("@/lib/db", () => ({
prisma: {
$transaction: vi.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)),
},
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(async () => ({ allowed: true })),
rateLimitHeaders: vi.fn(() => ({})),
}));
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
encryptToBytes: (s: string) => Buffer.from(s),
}));
vi.mock("@/lib/ai/coach/about-me", () => ({
getSelfContextForUser: vi.fn(),
}));
vi.mock("@/lib/api-response", () => ({
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
apiError: (error: string, status: number) => ({ data: null, error, status }),
getClientIp: () => "127.0.0.1",
returnAllZodIssues: (_e: unknown, status: number) => ({
data: null,
error: "validation",
status,
}),
safeJson: async (req: Request) => ({ data: await req.json(), error: null }),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSelfContextForUser } from "@/lib/ai/coach/about-me";
const transaction = prisma.$transaction as ReturnType<typeof vi.fn>;
const getCtx = getSelfContextForUser as ReturnType<typeof vi.fn>;
function adoptRequest(body: { question: string; answer: string }): Request {
return new Request("http://localhost/api/coach/about-me/adopt", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
type Envelope = { data: unknown; error: string | null; status: number };
const post = POST as unknown as (req: Request) => Promise<Envelope>;
const emptyCtx = {
aboutMe: null,
conditions: null,
allergies: null,
coachFocus: null,
};
describe("POST /api/coach/about-me/adopt", () => {
beforeEach(() => {
vi.clearAllMocks();
tx.userHealthProfile.upsert.mockResolvedValue({ id: "p1" });
tx.userHealthProfile.update.mockResolvedValue({ updatedAt: new Date() });
tx.$queryRaw.mockResolvedValue([{ id: "p1" }]);
});
it("runs the read-modify-write inside one transaction with a row lock", async () => {
getCtx.mockResolvedValue(emptyCtx);
const res = await post(
adoptRequest({ question: "What matters?", answer: "evening walks" }),
);
expect(res.status).toBe(200);
expect(res.data).toEqual({ adopted: true, field: "coachFocus" });
expect(transaction).toHaveBeenCalledTimes(1);
// Row pinned, then locked FOR UPDATE before the read.
expect(tx.userHealthProfile.upsert).toHaveBeenCalledWith({
where: { userId: "u1" },
create: { userId: "u1" },
update: {},
select: { id: true },
});
expect(tx.$queryRaw).toHaveBeenCalledTimes(1);
// The read goes through the SAME tx client, not the global one.
expect(getCtx).toHaveBeenCalledWith("u1", tx);
// Exactly one encrypted column written.
const data = tx.userHealthProfile.update.mock.calls[0][0].data;
expect(Object.keys(data)).toEqual(["coachFocusEncrypted"]);
});
it("dedupes inside the locked section without writing", async () => {
getCtx.mockResolvedValue({ ...emptyCtx, coachFocus: "Evening walks" });
const res = await post(
adoptRequest({ question: "What matters?", answer: "evening walks" }),
);
expect(res.data).toEqual({
adopted: false,
field: "coachFocus",
reason: "duplicate",
});
expect(tx.userHealthProfile.update).not.toHaveBeenCalled();
});
it("appends to an existing value read under the lock", async () => {
getCtx.mockResolvedValue({ ...emptyCtx, allergies: "pollen" });
const res = await post(
adoptRequest({ question: "Any allergies?", answer: "penicillin" }),
);
expect(res.data).toEqual({ adopted: true, field: "allergies" });
const data = tx.userHealthProfile.update.mock.calls[0][0].data;
expect(Buffer.from(data.allergiesEncrypted).toString()).toBe(
"pollen\npenicillin",
);
});
it("422s when the overflow target aboutMe is full, still without a write", async () => {
getCtx.mockResolvedValue({
...emptyCtx,
coachFocus: "x".repeat(499),
aboutMe: "y".repeat(3999),
});
const res = await post(
adoptRequest({ question: "What matters?", answer: "z".repeat(10) }),
);
expect(res.status).toBe(422);
expect(tx.userHealthProfile.update).not.toHaveBeenCalled();
});
});
+66 -40
View File
@@ -113,56 +113,82 @@ export const POST = apiHandler(async (req: Request) => {
}
const { question, answer } = parsed.data;
const ctx = await getSelfContextForUser(user.id);
let field: StructuredField | "aboutMe" = matchSelfContextField(question);
const normAnswer = normalise(answer);
// Dedupe BEFORE any write: an answer already present in the target
// field or in the free-text aboutMe never stacks a second copy.
const targetExisting = ctx[field];
if (
(targetExisting && normalise(targetExisting).includes(normAnswer)) ||
(ctx.aboutMe && normalise(ctx.aboutMe).includes(normAnswer))
) {
// Read-modify-write under a row lock: two concurrent adoptions used
// to read the same base value and one append silently lost (or the
// dedupe missed the other's in-flight write). The empty upsert pins
// the row, `FOR UPDATE` serialises every adoption for this user, and
// the read happens through the same `tx` so it sees the locked state.
const outcome = await prisma.$transaction(async (tx) => {
await tx.userHealthProfile.upsert({
where: { userId: user.id },
create: { userId: user.id },
update: {},
select: { id: true },
});
await tx.$queryRaw`
SELECT id FROM user_health_profiles
WHERE user_id = ${user.id}
FOR UPDATE
`;
const ctx = await getSelfContextForUser(user.id, tx);
let field: StructuredField | "aboutMe" = matchSelfContextField(question);
// Dedupe BEFORE any write: an answer already present in the target
// field or in the free-text aboutMe never stacks a second copy.
const targetExisting = ctx[field];
if (
(targetExisting && normalise(targetExisting).includes(normAnswer)) ||
(ctx.aboutMe && normalise(ctx.aboutMe).includes(normAnswer))
) {
return { kind: "duplicate" as const, field };
}
// Append on its own line; a structured field at its cap overflows
// into the free-text aboutMe (4000 chars) rather than failing the
// adoption outright.
let nextValue = targetExisting ? `${targetExisting}\n${answer}` : answer;
if (nextValue.length > ABOUT_ME_FIELD_MAX_CHARS) {
field = "aboutMe";
nextValue = ctx.aboutMe ? `${ctx.aboutMe}\n${answer}` : answer;
if (nextValue.length > ABOUT_ME_MAX_CHARS) {
return { kind: "full" as const, field };
}
}
// Field-by-field assembly (no mass assignment): exactly one
// encrypted column is written per adoption.
const column = (
{
conditions: "conditionsEncrypted",
allergies: "allergiesEncrypted",
coachFocus: "coachFocusEncrypted",
aboutMe: "aboutMeEncrypted",
} as const
)[field];
await tx.userHealthProfile.update({
where: { userId: user.id },
data: { [column]: encryptToBytes(nextValue) },
select: { updatedAt: true },
});
return { kind: "adopted" as const, field };
});
const { field } = outcome;
if (outcome.kind === "duplicate") {
annotate({
action: { name: "coach.about_me.adopt_deduped" },
meta: { field },
});
return apiSuccess({ adopted: false, field, reason: "duplicate" });
}
// Append on its own line; a structured field at its cap overflows
// into the free-text aboutMe (4000 chars) rather than failing the
// adoption outright.
let nextValue = targetExisting ? `${targetExisting}\n${answer}` : answer;
if (nextValue.length > ABOUT_ME_FIELD_MAX_CHARS) {
field = "aboutMe";
nextValue = ctx.aboutMe ? `${ctx.aboutMe}\n${answer}` : answer;
if (nextValue.length > ABOUT_ME_MAX_CHARS) {
return apiError("Self-context is full", 422);
}
if (outcome.kind === "full") {
return apiError("Self-context is full", 422);
}
// Field-by-field assembly (no mass assignment): exactly one encrypted
// column is written per adoption.
const column = (
{
conditions: "conditionsEncrypted",
allergies: "allergiesEncrypted",
coachFocus: "coachFocusEncrypted",
aboutMe: "aboutMeEncrypted",
} as const
)[field];
const payload = { [column]: encryptToBytes(nextValue) };
await prisma.userHealthProfile.upsert({
where: { userId: user.id },
create: { userId: user.id, ...payload },
update: payload,
select: { updatedAt: true },
});
await auditLog("coach.about_me.adopted", {
userId: user.id,
ipAddress: getClientIp(req),
@@ -21,6 +21,17 @@ vi.mock("next/navigation", () => ({
usePathname: () => mockPathnameRef.value,
}));
// The shell gates its own frame on the confirmed ADMIN role — a
// non-admin (or a still-loading auth state) must not see the section
// nav while AuthShell's redirect effect is catching up.
const mockUserRef = { value: { role: "ADMIN", username: "op" } as {
role: string;
username: string;
} | null };
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: mockUserRef.value }),
}));
import { readdirSync, statSync } from "node:fs";
import { join } from "node:path";
@@ -31,8 +42,11 @@ import { ADMIN_SECTIONS, AdminShell } from "../admin-shell";
function renderShell(props: {
active?: (typeof ADMIN_SECTIONS)[number]["slug"];
pathname?: string;
user?: { role: string; username: string } | null;
}) {
mockPathnameRef.value = props.pathname ?? "/admin/system-status";
mockUserRef.value =
props.user === undefined ? { role: "ADMIN", username: "op" } : props.user;
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<AdminShell active={props.active}>
@@ -133,6 +147,22 @@ describe("<AdminShell>", () => {
).toBe(true);
});
it("renders neither frame nor children until the role is confirmed ADMIN", () => {
// Non-admin: no section nav, no children — the frame must not
// flash while AuthShell's redirect effect moves the user away.
const asUser = renderShell({
active: "system-status",
user: { role: "USER", username: "u" },
});
expect(asUser).toBe("");
// Auth still in flight (user === null): same blank render.
const loading = renderShell({ active: "system-status", user: null });
expect(loading).toBe("");
// Admin: frame + children render.
const asAdmin = renderShell({ active: "system-status" });
expect(asAdmin).toContain("section body");
});
it("does NOT mark any section active on `/admin` overview", () => {
const html = renderShell({ pathname: "/admin" });
// Overview-link only — no section gets aria-current.
@@ -0,0 +1,203 @@
/**
* v1.16.5 — admin invite section: multi-use surface contracts.
*
* SSR smoke tests per project convention (`renderToStaticMarkup` +
* seeded QueryClient; no `@testing-library/react`). Pinned:
*
* 1. The table renders `uses / maxUses` for a multi-use invite.
* 2. uses == maxUses surfaces the "Aufgebraucht" (exhausted) chip.
* 3. Multiple redemptions render the "+N weitere" popover trigger;
* a single redemption renders inline without one.
* 4. `clampMaxUses` pins the create-dialog field to the 1–50 range
* (the same clamp the submit path sends).
*/
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import {
InviteTokensSection,
clampMaxUses,
} from "@/components/admin/invite-tokens-section";
import { queryKeys } from "@/lib/query-keys";
const FUTURE = new Date(Date.now() + 7 * 86_400_000).toISOString();
const PAST = new Date(Date.now() - 86_400_000).toISOString();
interface SeedInvite {
id: string;
createdAt: string;
expiresAt: string;
usedAt: string | null;
revokedAt: string | null;
uses: number;
maxUses: number;
creator: { id: string; username: string } | null;
consumer: { id: string; username: string } | null;
redemptions: Array<{
id: string;
redeemedAt: string;
user: { id: string; username: string; email: string | null } | null;
}>;
}
function makeInvite(overrides: Partial<SeedInvite>): SeedInvite {
return {
id: "inv-1",
createdAt: PAST,
expiresAt: FUTURE,
usedAt: null,
revokedAt: null,
uses: 0,
maxUses: 1,
creator: { id: "admin-1", username: "operator" },
consumer: null,
redemptions: [],
...overrides,
};
}
function render(invites: SeedInvite[]): string {
const client = new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: Number.POSITIVE_INFINITY,
refetchOnMount: false,
},
},
});
client.setQueryData(queryKeys.adminInvites(), invites);
return renderToStaticMarkup(
<I18nProvider initialLocale="de">
<QueryClientProvider client={client}>
<InviteTokensSection />
</QueryClientProvider>
</I18nProvider>,
);
}
describe("InviteTokensSection (multi-use)", () => {
it("renders uses / maxUses for a partially used multi-use invite", () => {
const html = render([
makeInvite({
uses: 2,
maxUses: 5,
redemptions: [
{
id: "r1",
redeemedAt: PAST,
user: { id: "u1", username: "alpha", email: null },
},
{
id: "r2",
redeemedAt: PAST,
user: { id: "u2", username: "beta", email: null },
},
],
}),
]);
expect(html).toContain("/ 5");
expect(html).toContain("invite-status-active");
});
it("surfaces the exhausted chip when uses reaches maxUses", () => {
const html = render([
makeInvite({
uses: 3,
maxUses: 3,
redemptions: [
{
id: "r1",
redeemedAt: PAST,
user: { id: "u1", username: "alpha", email: null },
},
{
id: "r2",
redeemedAt: PAST,
user: { id: "u2", username: "beta", email: null },
},
{
id: "r3",
redeemedAt: PAST,
user: { id: "u3", username: "gamma", email: null },
},
],
}),
]);
expect(html).toContain("invite-status-exhausted");
expect(html).toContain("Aufgebraucht");
});
it("multiple redemptions render the popover trigger with the +N summary", () => {
const html = render([
makeInvite({
uses: 3,
maxUses: 5,
redemptions: [
{
id: "r1",
redeemedAt: PAST,
user: { id: "u1", username: "alpha", email: null },
},
{
id: "r2",
redeemedAt: PAST,
user: { id: "u2", username: "beta", email: null },
},
{
id: "r3",
redeemedAt: PAST,
user: { id: "u3", username: "gamma", email: null },
},
],
}),
]);
expect(html).toContain("admin-invite-redemptions-trigger");
// de: "{username} + {count} weitere" — newest redemption leads.
expect(html).toContain("alpha + 2 weitere");
});
it("a single redemption renders inline without a popover trigger", () => {
const html = render([
makeInvite({
uses: 1,
maxUses: 5,
redemptions: [
{
id: "r1",
redeemedAt: PAST,
user: { id: "u1", username: "alpha", email: null },
},
],
}),
]);
expect(html).toContain("alpha");
expect(html).not.toContain("admin-invite-redemptions-trigger");
});
});
describe("clampMaxUses", () => {
it("passes the 1–50 range through", () => {
expect(clampMaxUses("1")).toBe(1);
expect(clampMaxUses("25")).toBe(25);
expect(clampMaxUses("50")).toBe(50);
});
it("clamps above the cap to 50", () => {
expect(clampMaxUses("51")).toBe(50);
expect(clampMaxUses("9999")).toBe(50);
});
it("falls back to 1 for zero, negatives, and garbage", () => {
expect(clampMaxUses("0")).toBe(1);
expect(clampMaxUses("-3")).toBe(1);
expect(clampMaxUses("")).toBe(1);
expect(clampMaxUses("abc")).toBe(1);
});
it("truncates decimals to the integer part", () => {
expect(clampMaxUses("2.9")).toBe(2);
});
});
+9
View File
@@ -39,6 +39,7 @@ import {
import { cn } from "@/lib/utils";
import { scrollBehaviorForUser } from "@/lib/motion";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { isAdminSectionSlug, type AdminSectionSlug } from "./section-slugs";
@@ -172,6 +173,7 @@ function deriveActiveSlug(
export function AdminShell({ active, children }: AdminShellProps) {
const pathname = usePathname();
const { t } = useTranslations();
const { user } = useAuth();
const activeSlug = deriveActiveSlug(pathname, active);
// v1.4.34 IW-G — mirror the settings-shell auto-scroll pattern so
@@ -199,6 +201,13 @@ export function AdminShell({ active, children }: AdminShellProps) {
});
}, [activeSlug]);
// The section bodies are already role-gated, but the shell frame
// itself (the full section nav in two layouts) used to paint for a
// non-admin during the frames between auth resolving and AuthShell's
// redirect effect replacing the route. Render nothing — frame AND
// children — until the role is confirmed ADMIN.
if (!user || user.role !== "ADMIN") return null;
// v1.4.25 W8 — AuthShell wraps the page in `px-4 py-6 md:px-6`
// already, so this inner shell only carries the wider max-width.
// Previously the duplicate `px-4 py-6 md:px-6 md:py-8` here was
+25 -7
View File
@@ -123,6 +123,18 @@ export function deriveInviteStatus(
const TTL_CHOICES = [7, 14, 30] as const;
const MAX_USES_CAP = 50;
/**
* Clamp the raw max-uses field to the mintable range (integer 1–50).
* Shared by the submit path and the blur echo so the admin always sees
* exactly what the POST will carry. Exported for unit tests.
*/
export function clampMaxUses(raw: string): number {
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) && parsed >= 1
? Math.min(parsed, MAX_USES_CAP)
: 1;
}
/** Whole days (ceil) from `now` until `iso`; negative when past. */
function daysUntil(iso: string, now: Date): number {
const ms = new Date(iso).getTime() - now.getTime();
@@ -253,12 +265,7 @@ export function InviteTokensSection() {
}
function submitCreate() {
const parsedMax = Number.parseInt(maxUses, 10);
const safeMax =
Number.isFinite(parsedMax) && parsedMax >= 1
? Math.min(parsedMax, MAX_USES_CAP)
: 1;
create.mutate({ expiresInDays: ttlDays, maxUses: safeMax });
create.mutate({ expiresInDays: ttlDays, maxUses: clampMaxUses(maxUses) });
}
const now = new Date();
@@ -591,8 +598,14 @@ export function InviteTokensSection() {
inputMode="numeric"
min={1}
max={MAX_USES_CAP}
step={1}
value={maxUses}
onChange={(e) => setMaxUses(e.target.value)}
onBlur={() => {
// Echo the submit-time clamp (1–50, integer) into
// the field so the admin sees what will be minted.
setMaxUses(String(clampMaxUses(maxUses)));
}}
className="w-24 tabular-nums"
data-testid="admin-invites-max-uses"
/>
@@ -753,7 +766,12 @@ function RedemptionsCell({
count: invite.redemptions.length,
})}
</p>
<ul className="mt-2 space-y-2">
{/* v1.16.5 — a 50-use invite lists up to 50 signups; cap the
list height so the popover never outgrows the viewport. */}
<ul
className="mt-2 max-h-64 space-y-2 overflow-y-auto pr-1"
data-testid="admin-invite-redemptions-list"
>
{invite.redemptions.map((entry) => (
<li key={entry.id} className="text-sm leading-tight">
<div>
+6 -1
View File
@@ -61,12 +61,17 @@ function decryptOrNull(payload: Uint8Array | null): string | null {
/**
* Load + decrypt the caller's structured self-context. Every field is
* independently fail-closed; a row that never existed yields all-null.
*
* `db` lets callers inside a transaction (the adopt endpoint's locked
* read-modify-write) read through their `tx` client; default is the
* global client.
*/
export async function getSelfContextForUser(
userId: string,
db: Pick<typeof prisma, "userHealthProfile"> = prisma,
): Promise<SelfContext> {
try {
const row = await prisma.userHealthProfile.findUnique({
const row = await db.userHealthProfile.findUnique({
where: { userId },
select: {
aboutMeEncrypted: true,
+46 -10
View File
@@ -192,11 +192,13 @@ describe("verb helpers", () => {
await helper("/api/foo", { a: 1 });
expect(fetchMock).toHaveBeenCalledWith("/api/foo", {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ a: 1 }),
});
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe("/api/foo");
expect(init.method).toBe(method);
expect(init.body).toBe(JSON.stringify({ a: 1 }));
expect(new Headers(init.headers).get("Content-Type")).toBe(
"application/json",
);
});
it("omits body and Content-Type when no body is given", async () => {
@@ -212,10 +214,44 @@ describe("verb helpers", () => {
await apiPost("/api/foo", { a: 1 }, { headers: { "X-Req": "1" } });
expect(fetchMock).toHaveBeenCalledWith("/api/foo", {
method: "POST",
headers: { "Content-Type": "application/json", "X-Req": "1" },
body: JSON.stringify({ a: 1 }),
});
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
const headers = new Headers(init.headers);
expect(headers.get("Content-Type")).toBe("application/json");
expect(headers.get("X-Req")).toBe("1");
expect(init.body).toBe(JSON.stringify({ a: 1 }));
});
it("keeps caller headers passed as a Headers instance (no spread loss)", async () => {
// A `Headers` instance has no enumerable own properties, so the
// previous `{ ...init.headers }` merge silently dropped every
// caller header for this `HeadersInit` shape.
fetchMock.mockResolvedValueOnce(jsonResponse({ data: null, error: null }));
await apiPost(
"/api/foo",
{ a: 1 },
{ headers: new Headers({ "X-Req": "1", "Idempotency-Key": "k1" }) },
);
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
const headers = new Headers(init.headers);
expect(headers.get("X-Req")).toBe("1");
expect(headers.get("Idempotency-Key")).toBe("k1");
expect(headers.get("Content-Type")).toBe("application/json");
});
it("respects a caller-supplied Content-Type override", async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({ data: null, error: null }));
await apiPost(
"/api/foo",
{ a: 1 },
{ headers: new Headers({ "Content-Type": "application/json-patch+json" }) },
);
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(new Headers(init.headers).get("Content-Type")).toBe(
"application/json-patch+json",
);
});
});
+9 -1
View File
@@ -141,7 +141,15 @@ function withJsonBody(
): RequestInit {
const merged: RequestInit = { ...init, method };
if (body !== undefined) {
merged.headers = { "Content-Type": "application/json", ...init?.headers };
// `HeadersInit` is a union — a plain object spreads fine, but a
// `Headers` instance (or entries array) has no enumerable own
// properties, so `{ ...init.headers }` silently dropped every
// caller header. `new Headers(...)` normalises all three shapes.
const headers = new Headers(init?.headers);
if (!headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
merged.headers = headers;
merged.body = JSON.stringify(body);
}
return merged;