From f13aa684f9fe6773fc47332aec1e463d180c7f25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 11 Jun 2026 11:45:58 +0200 Subject: [PATCH] fix(api,ops): transactional adopt, admin gate without a flash, and a digest path to deploys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docker-compose.yml | 27 +- docs/ops/deploy.md | 73 +++++- scripts/promote-digest.ts | 242 ++++++++++++++++++ .../about-me/adopt/__tests__/route.test.ts | 160 ++++++++++++ src/app/api/coach/about-me/adopt/route.ts | 106 +++++--- .../admin/__tests__/admin-shell.test.tsx | 30 +++ .../__tests__/invite-tokens-section.test.tsx | 203 +++++++++++++++ src/components/admin/admin-shell.tsx | 9 + .../admin/invite-tokens-section.tsx | 32 ++- src/lib/ai/coach/about-me.ts | 7 +- src/lib/api/__tests__/api-fetch.test.ts | 56 +++- src/lib/api/api-fetch.ts | 10 +- 12 files changed, 876 insertions(+), 79 deletions(-) create mode 100644 scripts/promote-digest.ts create mode 100644 src/app/api/coach/about-me/adopt/__tests__/route.test.ts create mode 100644 src/components/admin/__tests__/invite-tokens-section.test.tsx 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;