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),