diff --git a/docker-compose.yml b/docker-compose.yml
index b173c7de4..67bc2ddbb 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -4,15 +4,22 @@ services:
# For horizontal scaling switch to the web/worker split below
# (HEALTHLOG_PROCESS_TYPE=web on this service + uncomment app-worker).
app:
- # Tracks the GHCR `:latest` multi-arch image. `pull_policy: always`
- # is the load-bearing line — without it, Docker keeps a stale
- # `:latest` digest cached on the host and skips the registry round-
- # trip on `compose up`, which is what kept Coolify re-deploying the
- # previous image after every tag push. With pull_policy=always the
- # compose-up always re-checks the registry digest and pulls when
- # GHCR has a fresher manifest. The small bandwidth cost on every
- # `compose up` is the price of digest-correctness.
- image: ghcr.io/mbombeck/healthlog:latest
+ # Tracks the GHCR `:latest` multi-arch image by default.
+ # `pull_policy: always` is the load-bearing line — without it,
+ # Docker keeps a stale `:latest` digest cached on the host and
+ # skips the registry round-trip on `compose up`, which is what kept
+ # Coolify re-deploying the previous image after every tag push.
+ # With pull_policy=always the compose-up always re-checks the
+ # registry digest and pulls when GHCR has a fresher manifest.
+ #
+ # HEALTHLOG_IMAGE_REF pins the image deterministically without
+ # editing this file: set it in the compose `.env` (or the Coolify
+ # env vars) to `:vX.Y.Z` for a tag or `@sha256:` for an
+ # immutable digest reference (see docs/ops/deploy.md, "Digest
+ # pinning"; scripts/promote-digest.ts resolves + verifies the
+ # digest after a release). Unset, everything behaves exactly as
+ # before — `:latest` plus pull_policy.
+ image: ghcr.io/mbombeck/healthlog${HEALTHLOG_IMAGE_REF:-:latest}
pull_policy: always
container_name: healthlog-app
# The app runs as the unprivileged `nextjs` user and never needs a
@@ -108,7 +115,7 @@ services:
# See docs/self-hosting/scaling.md for the full guide.
#
# app-worker:
- # image: ghcr.io/mbombeck/healthlog:latest
+ # image: ghcr.io/mbombeck/healthlog${HEALTHLOG_IMAGE_REF:-:latest}
# build:
# context: .
# dockerfile: Dockerfile
diff --git a/docs/ops/deploy.md b/docs/ops/deploy.md
index ad890d7d9..7e2065897 100644
--- a/docs/ops/deploy.md
+++ b/docs/ops/deploy.md
@@ -121,19 +121,72 @@ image's only immutable identity, and it is what the signature actually
covers. For any deploy where supply-chain integrity matters more than
convenience:
-1. Resolve and verify the digest once:
+The compose file carries the hook for this: the `app` service image is
+`ghcr.io/mbombeck/healthlog${HEALTHLOG_IMAGE_REF:-:latest}`. Unset, the
+day-to-day `:latest` + `pull_policy: always` flow stays the documented
+default for self-hosters. Set `HEALTHLOG_IMAGE_REF` in the compose
+`.env` (or the deployment platform's env vars) to switch the reference
+without touching the file:
- ```sh
- cosign verify ... ghcr.io/mbombeck/healthlog:1.16.4 \
- | jq -r '.[0].critical.image."docker-manifest-digest"'
- ```
+- `HEALTHLOG_IMAGE_REF=:v1.16.5` — pin to a release tag (still mutable).
+- `HEALTHLOG_IMAGE_REF=@sha256:` — pin to the immutable digest.
-2. Pin the compose `image:` (or the Coolify image reference) to
- `ghcr.io/mbombeck/healthlog@sha256:` instead of the tag.
+### Promote a release to a pinned digest
-The day-to-day `:latest` + `pull_policy: always` flow stays the
-documented default for self-hosters; digest pinning is the recommended
-hardening for operators with a threat model that includes the registry.
+`scripts/promote-digest.ts` automates the whole promotion after
+`docker-publish.yml` has pushed a tag:
+
+```sh
+pnpm dlx tsx scripts/promote-digest.ts v1.16.5
+```
+
+It resolves the multi-arch index digest from the GHCR registry API
+(anonymous pull token, no docker daemon), verifies the keyless cosign
+signature against the workflow identity (fails closed — no cosign, no
+promotion), and prints the resulting `HEALTHLOG_IMAGE_REF=@sha256:…`
+value.
+
+For a Coolify-managed deployment it can also apply the pin. Two facts
+about Coolify shape the mechanism (verified against the Coolify
+OpenAPI spec and a live instance):
+
+- For a git-based "Docker Compose" application, Coolify re-reads the
+ compose file from the repository checkout on every deploy.
+ `docker_compose_raw` is its stored copy of that file, and the
+ application-update API (`PATCH /api/v1/applications/{uuid}`) does not
+ accept the field — editing the stored compose is neither possible
+ via API nor durable.
+- Application **env vars** are durable, exposed via the documented
+ `/api/v1/applications/{uuid}/envs` endpoints, and interpolated into
+ the compose at deploy time.
+
+So the pin travels as the `HEALTHLOG_IMAGE_REF` env var. With
+`COOLIFY_API_TOKEN`, `COOLIFY_API_URL` and `COOLIFY_APP_UUID` in the
+environment the script creates/updates the var itself (add
+`PROMOTE_DEPLOY=1` to also trigger the redeploy); without a token it
+prints the equivalent curl commands:
+
+```sh
+curl -sS -X PATCH "https:///api/v1/applications//envs" \
+ -H "Authorization: Bearer " -H "Content-Type: application/json" \
+ -d '{"key":"HEALTHLOG_IMAGE_REF","value":"@sha256:"}'
+
+curl -sS "https:///api/v1/deploy?uuid=" \
+ -H "Authorization: Bearer "
+```
+
+After the deploy settles, confirm `/api/version` reports the released
+`version` + `buildSha` on every target (see above).
+
+**Rollback:** re-run the script with the previous tag (or PATCH the env
+var back to the previous digest / to empty for `:latest`) and redeploy.
+Digest pinning trades the automatic `:latest` pickup for determinism —
+a pinned instance only moves when the var moves, so the promotion step
+becomes part of the release routine, not an optional extra.
+
+Digest pinning is the recommended hardening for operators with a
+threat model that includes the registry; `:latest` remains the
+low-friction default.
## Deploy-status webhook: replay protection
diff --git a/scripts/promote-digest.ts b/scripts/promote-digest.ts
new file mode 100644
index 000000000..4426a958d
--- /dev/null
+++ b/scripts/promote-digest.ts
@@ -0,0 +1,242 @@
+/**
+ * promote-digest.ts — pin a released image to its immutable digest.
+ *
+ * After `docker-publish.yml` pushes `vX.Y.Z` (+ `:latest`) to GHCR, this
+ * script turns the mutable tag into a deterministic deploy reference:
+ *
+ * 1. Resolves the multi-arch index digest for the tag straight from
+ * the GHCR registry API (anonymous pull token — the image is
+ * public; no docker daemon needed).
+ * 2. Verifies the keyless cosign signature the publish workflow
+ * attached to that digest (GitHub Actions OIDC identity). Fails
+ * closed: no cosign binary or a failed verification aborts the
+ * promotion.
+ * 3. Emits the `HEALTHLOG_IMAGE_REF=@sha256:…` value the compose
+ * file interpolates into `image:` — and, when a Coolify API token
+ * is present in the environment, writes it to the application's
+ * env vars via the documented `/envs` endpoint. Without a token
+ * it prints the exact curl commands instead of guessing.
+ *
+ * Why an env var and not the compose file itself: for a git-based
+ * "Docker Compose" application Coolify re-reads the compose file from
+ * the repository checkout on every deploy (`docker_compose_raw` is its
+ * stored copy of that file and is not part of the application-update
+ * API surface), so editing the stored compose is not durable. The
+ * application env vars ARE durable and are interpolated into the
+ * compose at deploy time — `image: ghcr.io/mbombeck/healthlog${HEALTHLOG_IMAGE_REF:-:latest}`
+ * resolves to the pinned digest when the var is set and to `:latest`
+ * when it is not.
+ *
+ * Usage (one-shot, not a recurring job):
+ * pnpm dlx tsx scripts/promote-digest.ts v1.16.5
+ *
+ * Env (all optional — without them the script prints commands only):
+ * COOLIFY_API_TOKEN API token (Coolify → Keys & Tokens → API tokens)
+ * COOLIFY_API_URL Base URL of the Coolify instance, e.g. https://coolify.example.com
+ * COOLIFY_APP_UUID UUID of the HealthLog application resource
+ * PROMOTE_DEPLOY=1 Additionally trigger a deploy after the env update
+ *
+ * See docs/ops/deploy.md ("Digest pinning") for the full runbook,
+ * including rollback (re-point the var at the previous digest).
+ */
+
+import { execFileSync } from "node:child_process";
+
+const IMAGE_REPO = "mbombeck/healthlog";
+const IMAGE = `ghcr.io/${IMAGE_REPO}`;
+const ENV_KEY = "HEALTHLOG_IMAGE_REF";
+const COSIGN_ISSUER = "https://token.actions.githubusercontent.com";
+const COSIGN_IDENTITY_RE = "^https://github.com/MBombeck/HealthLog/";
+
+// The OCI index media types a multi-arch publish can produce. GHCR
+// answers the manifest request with the digest of whichever it stores;
+// accepting both keeps the script working across buildx versions.
+const INDEX_ACCEPT = [
+ "application/vnd.oci.image.index.v1+json",
+ "application/vnd.docker.distribution.manifest.list.v2+json",
+].join(", ");
+
+function fail(message: string): never {
+ console.error(`\nERROR: ${message}`);
+ process.exit(1);
+}
+
+async function resolveDigest(tag: string): Promise {
+ // Anonymous pull token — the package is public, so no credentials.
+ const tokenRes = await fetch(
+ `https://ghcr.io/token?service=ghcr.io&scope=repository:${IMAGE_REPO}:pull`,
+ );
+ if (!tokenRes.ok) {
+ fail(`GHCR token request failed: HTTP ${tokenRes.status}`);
+ }
+ const { token } = (await tokenRes.json()) as { token?: string };
+ if (!token) fail("GHCR token response carried no token");
+
+ // HEAD is enough — the digest rides the Docker-Content-Digest header.
+ const manifestRes = await fetch(
+ `https://ghcr.io/v2/${IMAGE_REPO}/manifests/${encodeURIComponent(tag)}`,
+ {
+ method: "HEAD",
+ headers: { Authorization: `Bearer ${token}`, Accept: INDEX_ACCEPT },
+ },
+ );
+ if (!manifestRes.ok) {
+ fail(
+ `manifest lookup for ${IMAGE}:${tag} failed: HTTP ${manifestRes.status} — does the tag exist on GHCR?`,
+ );
+ }
+ const digest = manifestRes.headers.get("docker-content-digest");
+ if (!digest || !/^sha256:[0-9a-f]{64}$/.test(digest)) {
+ fail(`registry returned no usable digest (got: ${digest ?? "nothing"})`);
+ }
+ return digest;
+}
+
+function verifyCosign(digest: string): void {
+ const ref = `${IMAGE}@${digest}`;
+ try {
+ execFileSync(
+ "cosign",
+ [
+ "verify",
+ "--certificate-oidc-issuer",
+ COSIGN_ISSUER,
+ "--certificate-identity-regexp",
+ COSIGN_IDENTITY_RE,
+ ref,
+ ],
+ { stdio: ["ignore", "ignore", "pipe"] },
+ );
+ } catch (err) {
+ const e = err as NodeJS.ErrnoException & { stderr?: Buffer };
+ if (e.code === "ENOENT") {
+ fail(
+ "cosign binary not found. Install it (https://docs.sigstore.dev/cosign/system_config/installation/) — the promotion fails closed without signature verification.",
+ );
+ }
+ fail(
+ `cosign verification FAILED for ${ref} — do not deploy this digest.\n${e.stderr?.toString() ?? ""}`,
+ );
+ }
+ console.log(`cosign signature verified for ${ref}`);
+}
+
+interface CoolifyEnvRow {
+ uuid: string;
+ key: string;
+ value?: string;
+}
+
+async function coolifyRequest(
+ base: string,
+ token: string,
+ path: string,
+ init?: RequestInit,
+): Promise {
+ return fetch(`${base.replace(/\/$/, "")}/api/v1${path}`, {
+ ...init,
+ headers: {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/json",
+ ...(init?.headers ?? {}),
+ },
+ redirect: "manual",
+ signal: AbortSignal.timeout(15_000),
+ });
+}
+
+async function updateCoolifyEnv(
+ base: string,
+ token: string,
+ appUuid: string,
+ value: string,
+): Promise {
+ const list = await coolifyRequest(base, token, `/applications/${appUuid}/envs`);
+ if (!list.ok) {
+ fail(`Coolify env list failed: HTTP ${list.status} — check token/UUID`);
+ }
+ const rows = (await list.json()) as CoolifyEnvRow[];
+ const existing = Array.isArray(rows)
+ ? rows.find((r) => r.key === ENV_KEY)
+ : undefined;
+
+ const method = existing ? "PATCH" : "POST";
+ const res = await coolifyRequest(
+ base,
+ token,
+ `/applications/${appUuid}/envs`,
+ { method, body: JSON.stringify({ key: ENV_KEY, value }) },
+ );
+ if (!res.ok) {
+ fail(`Coolify env ${method} failed: HTTP ${res.status}`);
+ }
+ console.log(
+ `Coolify env ${ENV_KEY} ${existing ? "updated" : "created"} (was: ${existing?.value ?? "unset"})`,
+ );
+}
+
+async function triggerDeploy(
+ base: string,
+ token: string,
+ appUuid: string,
+): Promise {
+ const res = await coolifyRequest(base, token, `/deploy?uuid=${appUuid}`);
+ if (!res.ok) fail(`deploy trigger failed: HTTP ${res.status}`);
+ console.log("Deploy triggered. Verify /api/version after it settles.");
+}
+
+function printManualCommands(value: string): void {
+ console.log(`
+No COOLIFY_API_TOKEN in the environment — manual path (fill in host,
+token and application UUID; the UUID is in the Coolify URL of the app):
+
+ # set / update the pin (POST creates, PATCH updates an existing key)
+ curl -sS -X PATCH "https:///api/v1/applications//envs" \\
+ -H "Authorization: Bearer " -H "Content-Type: application/json" \\
+ -d '{"key":"${ENV_KEY}","value":"${value}"}'
+
+ # redeploy with the pinned digest
+ curl -sS "https:///api/v1/deploy?uuid=" \\
+ -H "Authorization: Bearer "
+`);
+}
+
+async function main(): Promise {
+ const tag = process.argv[2];
+ if (!tag || !/^v\d+\.\d+\.\d+/.test(tag)) {
+ fail("usage: pnpm dlx tsx scripts/promote-digest.ts vX.Y.Z");
+ }
+
+ console.log(`Resolving multi-arch digest for ${IMAGE}:${tag} …`);
+ const digest = await resolveDigest(tag);
+ console.log(`digest: ${digest}`);
+
+ verifyCosign(digest);
+
+ const value = `@${digest}`;
+ console.log(`\n${ENV_KEY}=${value}`);
+
+ const token = process.env.COOLIFY_API_TOKEN;
+ const base = process.env.COOLIFY_API_URL;
+ const appUuid = process.env.COOLIFY_APP_UUID;
+
+ if (token && base && appUuid) {
+ await updateCoolifyEnv(base, token, appUuid, value);
+ if (process.env.PROMOTE_DEPLOY === "1") {
+ await triggerDeploy(base, token, appUuid);
+ } else {
+ console.log(
+ "Env updated only — trigger the deploy from Coolify (or re-run with PROMOTE_DEPLOY=1).",
+ );
+ }
+ } else {
+ if (token || base || appUuid) {
+ console.log(
+ "Partial Coolify config — need COOLIFY_API_TOKEN + COOLIFY_API_URL + COOLIFY_APP_UUID together; printing the manual path instead.",
+ );
+ }
+ printManualCommands(value);
+ }
+}
+
+main().catch((err) => fail(err instanceof Error ? err.message : String(err)));
diff --git a/src/app/api/coach/about-me/adopt/__tests__/route.test.ts b/src/app/api/coach/about-me/adopt/__tests__/route.test.ts
new file mode 100644
index 000000000..09e0fea66
--- /dev/null
+++ b/src/app/api/coach/about-me/adopt/__tests__/route.test.ts
@@ -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: 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;
+const getCtx = getSelfContextForUser as ReturnType;
+
+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;
+
+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();
+ });
+});
diff --git a/src/app/api/coach/about-me/adopt/route.ts b/src/app/api/coach/about-me/adopt/route.ts
index d158bb8b4..ee5463153 100644
--- a/src/app/api/coach/about-me/adopt/route.ts
+++ b/src/app/api/coach/about-me/adopt/route.ts
@@ -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),
diff --git a/src/components/admin/__tests__/admin-shell.test.tsx b/src/components/admin/__tests__/admin-shell.test.tsx
index fbbd28d3d..166d35e6f 100644
--- a/src/components/admin/__tests__/admin-shell.test.tsx
+++ b/src/components/admin/__tests__/admin-shell.test.tsx
@@ -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(
@@ -133,6 +147,22 @@ describe("", () => {
).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.
diff --git a/src/components/admin/__tests__/invite-tokens-section.test.tsx b/src/components/admin/__tests__/invite-tokens-section.test.tsx
new file mode 100644
index 000000000..3b76578ed
--- /dev/null
+++ b/src/components/admin/__tests__/invite-tokens-section.test.tsx
@@ -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 {
+ 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(
+
+
+
+
+ ,
+ );
+}
+
+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);
+ });
+});
diff --git a/src/components/admin/admin-shell.tsx b/src/components/admin/admin-shell.tsx
index a8b34b9a4..9a583fedb 100644
--- a/src/components/admin/admin-shell.tsx
+++ b/src/components/admin/admin-shell.tsx
@@ -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
diff --git a/src/components/admin/invite-tokens-section.tsx b/src/components/admin/invite-tokens-section.tsx
index 67b27dbfe..0759631da 100644
--- a/src/components/admin/invite-tokens-section.tsx
+++ b/src/components/admin/invite-tokens-section.tsx
@@ -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,
})}
-
+ {/* v1.16.5 — a 50-use invite lists up to 50 signups; cap the
+ list height so the popover never outgrows the viewport. */}
+
{invite.redemptions.map((entry) => (
-
diff --git a/src/lib/ai/coach/about-me.ts b/src/lib/ai/coach/about-me.ts
index 33430c669..d87a39225 100644
--- a/src/lib/ai/coach/about-me.ts
+++ b/src/lib/ai/coach/about-me.ts
@@ -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 = prisma,
): Promise {
try {
- const row = await prisma.userHealthProfile.findUnique({
+ const row = await db.userHealthProfile.findUnique({
where: { userId },
select: {
aboutMeEncrypted: true,
diff --git a/src/lib/api/__tests__/api-fetch.test.ts b/src/lib/api/__tests__/api-fetch.test.ts
index 6cdcf430b..f3e822aa4 100644
--- a/src/lib/api/__tests__/api-fetch.test.ts
+++ b/src/lib/api/__tests__/api-fetch.test.ts
@@ -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",
+ );
});
});
diff --git a/src/lib/api/api-fetch.ts b/src/lib/api/api-fetch.ts
index 87b4f250e..6b2afcaf2 100644
--- a/src/lib/api/api-fetch.ts
+++ b/src/lib/api/api-fetch.ts
@@ -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;