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

+17 -10
View File
@@ -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:<digest>` 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
+63 -10
View File
@@ -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:<digest>` — pin to the immutable digest.
2. Pin the compose `image:` (or the Coolify image reference) to
`ghcr.io/mbombeck/healthlog@sha256:<digest>` 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://<coolify-host>/api/v1/applications/<app-uuid>/envs" \
-H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
-d '{"key":"HEALTHLOG_IMAGE_REF","value":"@sha256:<digest>"}'
curl -sS "https://<coolify-host>/api/v1/deploy?uuid=<app-uuid>" \
-H "Authorization: Bearer <token>"
```
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
+242
View File
@@ -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<string> {
// 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<Response> {
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<void> {
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<void> {
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://<coolify-host>/api/v1/applications/<app-uuid>/envs" \\
-H "Authorization: Bearer <token>" -H "Content-Type: application/json" \\
-d '{"key":"${ENV_KEY}","value":"${value}"}'
# redeploy with the pinned digest
curl -sS "https://<coolify-host>/api/v1/deploy?uuid=<app-uuid>" \\
-H "Authorization: Bearer <token>"
`);
}
async function main(): Promise<void> {
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)));
@@ -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;