Merge pull request #557 from MBombeck/release/v1.30.33

release/v1.30.33 — the session cookie becomes a secret
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-19 12:31:18 +02:00
commit 571e415eaa
21 files changed
+862 -82

No files matched your search

+32
View File
@@ -2,6 +2,38 @@
## [Unreleased]
## [1.30.33] — 2026-07-19
Security release. It ships on its own because it changes how a browser
session is recognised.
- **The session cookie is a purpose-generated secret.** It used to be the
session record's own database key — an identifier built to avoid
collisions, not to be unguessable, and one that appears wherever that
record is referenced. The cookie now carries 32 bytes from the system's
random source, and only its hash is stored, so the value in your
browser exists nowhere on the server.
- **You are not signed out by this.** Sessions from before the change
keep working and are simply not renewed, so they run out their normal
30 days and disappear. Every sign-in from this release onward is on the
new scheme.
- **The device list no longer hands out working credentials.** It
identified each device by that same record key — which, for a session
predating this release, is that device's cookie. It now shows an opaque
handle that is useless as a login, and the same handle is what signs a
device out.
- **A refresh token cannot be rotated from a different device**, and an
expired one is refused. The test that was supposed to prove the expiry
passed a token that had never been issued, so it proved nothing: with
the expiry check deleted, the suite still went green. It is a real test
now.
- **A Nightscout address is checked when you save it**, not only when it
happens to be probed first. The check itself already refused private,
loopback and cloud-metadata addresses.
Operators: rotating `API_TOKEN_HMAC_KEY` now also signs out every browser
session, not just API tokens. One migration (0255), additive.
## [1.30.32] — 2026-07-19
- **A withdrawn AI consent stays withdrawn.** Opening the AI settings
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.30.32
version: 1.30.33
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+5 -5
View File
@@ -47,11 +47,11 @@ echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env
echo "API_TOKEN_HMAC_KEY=$(openssl rand -hex 32)" >> .env
```
| Variable | Purpose | Rotation cost |
| -------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| `POSTGRES_PASSWORD` | Bundled Postgres password — used by both the `db` service and `DATABASE_URL` | Database restart + connection-string update |
| `ENCRYPTION_KEY` | AES-256-GCM key for at-rest secrets (Withings tokens, AI provider keys, VAPID secrets) | Coordinated; see `docs/ops/encryption-key-rotation.md` |
| `API_TOKEN_HMAC_KEY` | HMAC-SHA256 key for hashing Bearer API tokens before storage | Invalidates every issued `hlk_*` token |
| Variable | Purpose | Rotation cost |
| -------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| `POSTGRES_PASSWORD` | Bundled Postgres password — used by both the `db` service and `DATABASE_URL` | Database restart + connection-string update |
| `ENCRYPTION_KEY` | AES-256-GCM key for at-rest secrets (Withings tokens, AI provider keys, VAPID secrets) | Coordinated; see `docs/ops/encryption-key-rotation.md` |
| `API_TOKEN_HMAC_KEY` | HMAC-SHA256 key for hashing Bearer API tokens and web session cookies before storage | Invalidates every issued `hlk_*` token and signs out every browser session |
You do **not** edit `DATABASE_URL` on the bundled `docker-compose.yml`
stack: compose constructs the connection string inside the `app`
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.30.32",
"version": "1.30.33",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
@@ -0,0 +1,21 @@
-- Session cookies carry a purpose-generated secret rather than the row's cuid
-- primary key.
--
-- A cuid is built for collision-resistant identity, not for unguessability: it
-- embeds a timestamp and a monotonic counter and does not come from a CSPRNG.
-- A session cookie is a bearer credential and must. The cookie now carries a
-- 32-byte random token (`hls_<hex>`) and only its HMAC-SHA256 hash is stored,
-- matching how api_tokens and refresh_tokens already hold their secrets.
--
-- Additive and nullable ON PURPOSE: every session row that exists at deploy
-- time keeps NULL here and stays resolvable by its id, so the release signs
-- nobody out. Those rows cannot have their sliding expiry extended, so the
-- id-as-credential path drains itself within the 30-day session lifetime with
-- no operator action. Postgres permits many NULLs under a unique index, so the
-- constraint below does not conflict with the backfill-free transition.
-- AlterTable
ALTER TABLE "sessions" ADD COLUMN "token_hash" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "sessions_token_hash_key" ON "sessions"("token_hash");
+10
View File
@@ -1023,6 +1023,16 @@ model Passkey {
model Session {
id String @id @default(cuid())
userId String @map("user_id")
// v1.30.32 — HMAC-SHA256 of the session cookie's secret. The cookie carries
// a 32-byte CSPRNG token (`hls_<hex>`), never this row's cuid: a cuid is
// collision-resistant, not unguessable (embedded timestamp + counter), and a
// session identifier is a bearer credential. Hash-at-rest mirrors the
// ApiToken / RefreshToken posture — a database read yields no usable cookie.
//
// Nullable for the transition only: rows minted before this column existed
// carry NULL and are still resolvable by id until they expire. See the
// resolution comment in `src/lib/auth/session.ts`.
tokenHash String? @unique @map("token_hash")
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
ipAddress String? @map("ip_address")
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.30.32";
/* @sw-version-fallback */ "v1.30.33";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
+1 -1
View File
@@ -31,7 +31,7 @@
},
{
"name": "API_TOKEN_HMAC_KEY",
"purpose": "HMAC-SHA256 key for hashing external Bearer tokens. 64 hex chars."
"purpose": "HMAC-SHA256 key for hashing external Bearer tokens AND web session cookies. 64 hex chars. Rotating it signs every browser session out on top of invalidating every issued token."
}
]
},
@@ -1,15 +1,25 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
// The handle is an HMAC, so the route needs the key the real helper reads.
process.env.API_TOKEN_HMAC_KEY ??= "a".repeat(64);
vi.mock("@/lib/db", () => ({
prisma: { session: { findMany: vi.fn() } },
}));
vi.mock("@/lib/auth/session", () => ({
getSession: vi.fn(),
destroyOtherSessions: vi.fn(),
destroySessionById: vi.fn(),
}));
// `sessionHandle` stays REAL: the point of the handle test is that the route
// emits the actual derivation, and a stubbed one would let the route return
// row ids while the assertion still passed.
vi.mock("@/lib/auth/session", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/auth/session")>();
return {
getSession: vi.fn(),
destroyOtherSessions: vi.fn(),
destroySessionById: vi.fn(),
sessionHandle: actual.sessionHandle,
};
});
vi.mock("@/lib/geo", () => ({
lookupIpLocation: vi.fn(async () => "Berlin, DE"),
@@ -39,6 +49,7 @@ import {
getSession,
destroyOtherSessions,
destroySessionById,
sessionHandle,
} from "@/lib/auth/session";
const SESSION_OK = {
@@ -90,7 +101,6 @@ describe("GET /api/auth/me/sessions", () => {
const list = body.data.sessions;
expect(list).toHaveLength(2);
expect(list[0]).toMatchObject({
id: "sess-current",
device: "Firefox on macOS",
ipMasked: "203.0.x.x",
location: "Berlin, DE",
@@ -100,6 +110,42 @@ describe("GET /api/auth/me/sessions", () => {
// The full IP never appears in the response body.
expect(JSON.stringify(body)).not.toContain("203.0.113.7");
});
it("never puts a session row id in the response body", async () => {
// A row id created before the secret cookie landed IS that session's
// cookie value, so listing it hands the client working logins for the
// account's other devices. The list carries an opaque handle instead;
// it is what the revoke route accepts.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.session.findMany).mockResolvedValue([
{
id: "sess-current",
ipAddress: "203.0.113.7",
userAgent: "Mozilla/5.0 (Macintosh) Firefox/120.0",
lastActiveAt: new Date(),
createdAt: new Date(),
},
{
id: "sess-other",
ipAddress: "198.51.100.4",
userAgent: "Mozilla/5.0 (Windows NT 10.0) Chrome/119.0.0.0",
lastActiveAt: null,
createdAt: new Date(),
},
] as never);
const res = await GET();
const body = await res.json();
const serialised = JSON.stringify(body);
expect(serialised).not.toContain("sess-current");
expect(serialised).not.toContain("sess-other");
// Still identified well enough to act on: distinct, stable handles.
const [a, b] = body.data.sessions.map((s: { id: string }) => s.id);
expect(a).toBeTruthy();
expect(a).not.toBe(b);
expect(a).toBe(sessionHandle("sess-current"));
});
});
describe("DELETE /api/auth/me/sessions (sign out everywhere)", () => {
+2 -2
View File
@@ -18,7 +18,7 @@ import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { getClientIp } from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { destroyOtherSessions } from "@/lib/auth/session";
import { destroyOtherSessions, sessionHandle } from "@/lib/auth/session";
import { lookupIpLocation } from "@/lib/geo";
import { coarseDeviceLabel, maskIp } from "@/lib/auth/device-fingerprint";
@@ -68,7 +68,7 @@ export const GET = apiHandler(async () => {
);
const result: SessionDTO[] = sessions.map((s) => ({
id: s.id,
id: sessionHandle(s.id),
device: coarseDeviceLabel(s.userAgent),
ipMasked: maskIp(s.ipAddress),
location: s.ipAddress ? (locationByIp.get(s.ipAddress) ?? null) : null,
@@ -140,6 +140,63 @@ describe("POST /api/nightscout/connect", () => {
expect(userUpdate).not.toHaveBeenCalled();
});
// Input-time SSRF floor. Every case below leaves the default happy-path
// `fetchSgvEntries` mock in place — it RESOLVES — so the only thing that
// can produce a 422 is the check that runs before the probe. Asserting the
// probe was never called is what separates this from the fetch-time guard:
// a URL that never reaches the egress layer cannot have been refused by it.
describe("refuses private / non-public hosts before the probe runs", () => {
const cases: Array<[string, string]> = [
["loopback", "http://127.0.0.1:1337"],
["loopback by name", "http://localhost:1337"],
["link-local", "http://169.254.1.1"],
["cloud metadata endpoint", "http://169.254.169.254/latest/meta-data/"],
["RFC1918 class A", "http://10.0.0.5"],
["RFC1918 class C", "http://192.168.1.5"],
["CGNAT", "http://100.64.0.1"],
["IPv4-mapped IPv6 loopback", "http://[::ffff:127.0.0.1]"],
["IPv6 loopback", "http://[::1]:1337"],
["IPv6 unique-local", "http://[fd00::1]"],
["octal IPv4 loopback", "http://0177.0.0.1"],
["hex IPv4 loopback", "http://0x7f000001"],
["decimal IPv4 loopback", "http://2130706433"],
[".internal suffix", "http://cgm.internal"],
[".local suffix", "http://cgm.local"],
];
it.each(cases)("refuses %s", async (_label, url) => {
const res = await post(req({ url, token: "tok" }));
expect(res.status).toBe(422);
expect(String(res.error)).toMatch(/private network/i);
expect(fetchSgvEntriesMock).not.toHaveBeenCalled();
expect(userUpdate).not.toHaveBeenCalled();
});
it("still lets the explicit self-hoster opt-in through to the probe", async () => {
// The floor is gated, not absolute: a LAN instance stays reachable for
// the operator who asked for it. Without this the opt-in is dead code.
const res = await post(
req({
url: "http://192.168.1.5:1337",
token: "tok",
allowPrivateHost: true,
}),
);
expect(res.status).toBe(200);
expect(fetchSgvEntriesMock).toHaveBeenCalledTimes(1);
expect(userUpdate).toHaveBeenCalledTimes(1);
});
it("lets an ordinary public instance through", async () => {
const res = await post(req({ url: "https://ns.example.com" }));
expect(res.status).toBe(200);
expect(fetchSgvEntriesMock).toHaveBeenCalledTimes(1);
});
});
it("rate-limits the connect surface", async () => {
rateLimitMock.mockResolvedValue({
allowed: false,
+20
View File
@@ -12,6 +12,7 @@ import { markReconnected } from "@/lib/integrations/status";
import { SafeFetchError } from "@/lib/safe-fetch";
import { fetchSgvEntries, NightscoutApiError } from "@/lib/nightscout/client";
import { nightscoutConnectSchema } from "@/lib/validations/nightscout";
import { isPublicUrl } from "@/lib/validations/notifications";
/**
* Connect (or update) the user's Nightscout instance (v1.17.0).
@@ -49,6 +50,25 @@ export const POST = apiHandler(async (request: NextRequest) => {
const { url, token, allowPrivateHost } = result.data;
// SSRF floor at INPUT time, not merely as a side effect of the live probe
// below. `fetchSgvEntries` does route through `safeFetch` with the same
// public-host pin, so today both paths refuse the same URLs — but that
// makes the guard on the value we PERSIST contingent on the probe staying
// exactly where it is. Reorder it, make it best-effort, or add a "save
// without testing" affordance and the stored URL is suddenly unchecked.
// Assert it here so the thing that gets encrypted onto the row is the
// thing that was validated.
//
// Gated on the same explicit opt-in the fetch path uses: a self-hoster who
// deliberately points at a LAN instance has asked for the private host and
// must still be able to connect.
if (!allowPrivateHost && !isPublicUrl(url)) {
return apiError(
"That instance is on a private network. Enable the private-network option to connect to it.",
422,
);
}
// Live validation: pull a single SGV entry. A connection that can't even
// fetch one row is not worth storing.
try {
+79 -5
View File
@@ -394,21 +394,95 @@ describe("rotateRefreshToken", () => {
expect(liveDev1).toHaveLength(0);
});
it("rejects expired refresh tokens", async () => {
await issueAccessAndRefresh({
it("rejects an expired refresh token by its own secret", async () => {
// The token presented here is the REAL one, so the row is found and the
// expiry branch is what refuses it. Presenting an unknown string instead
// would pass on `not_found` and prove nothing about expiry at all.
const initial = await issueAccessAndRefresh({
userId: "u1",
policy: NATIVE_POLICY,
source: "login.password",
});
// Manually expire
dbState.refreshTokens[0].expiresAt = new Date(Date.now() - 1000);
const result = await rotateRefreshToken({
refreshToken: "hlr_doesnt_matter",
refreshToken: initial.refreshToken,
policy: NATIVE_POLICY,
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe("not_found");
expect(result.reason).toBe("expired");
// An expired token must not mint a replacement pair.
expect(dbState.refreshTokens).toHaveLength(1);
});
it("accepts the same token one millisecond before it expires", async () => {
// Boundary companion: proves the expiry refusal above is driven by the
// clock and not by the rotation path refusing everything.
const initial = await issueAccessAndRefresh({
userId: "u1",
policy: NATIVE_POLICY,
source: "login.password",
});
dbState.refreshTokens[0].expiresAt = new Date(Date.now() + 60_000);
const result = await rotateRefreshToken({
refreshToken: initial.refreshToken,
policy: NATIVE_POLICY,
});
expect(result.ok).toBe(true);
});
it("refuses a live token presented under a different device id", async () => {
// Device A's token replayed from device B. The token is valid and unused,
// so only the device binding can refuse it.
const dev1 = await issueAccessAndRefresh({
userId: "u1",
policy: NATIVE_POLICY,
source: "login.password",
deviceId: "dev-1",
});
const result = await rotateRefreshToken({
refreshToken: dev1.refreshToken,
policy: NATIVE_POLICY,
deviceId: "dev-2",
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe("device_mismatch");
// Refused, not consumed: the legitimate device can still rotate it.
expect(dbState.refreshTokens[0].usedAt).toBeNull();
expect(dbState.refreshTokens).toHaveLength(1);
const legitimate = await rotateRefreshToken({
refreshToken: dev1.refreshToken,
policy: NATIVE_POLICY,
deviceId: "dev-1",
});
expect(legitimate.ok).toBe(true);
});
it("still rotates when the caller sends no device id at all", async () => {
// An older client that never sends `X-Device-Id` must not be locked out
// by the device binding — a presented null is unattributable, not a
// mismatch.
const dev1 = await issueAccessAndRefresh({
userId: "u1",
policy: NATIVE_POLICY,
source: "login.password",
deviceId: "dev-1",
});
const result = await rotateRefreshToken({
refreshToken: dev1.refreshToken,
policy: NATIVE_POLICY,
});
expect(result.ok).toBe(true);
});
it("rejects revoked refresh tokens", async () => {
+218 -15
View File
@@ -6,23 +6,48 @@ const cookieState = vi.hoisted(() => ({
set: vi.fn(),
}));
interface SessionRow {
id: string;
userId: string;
tokenHash: string | null;
expiresAt: Date;
lastActiveAt: Date | null;
user: { id: string; locale?: string | null };
}
const store = vi.hoisted(() => ({ sessions: [] as unknown[] }));
vi.mock("@/lib/db", () => ({
prisma: {
session: {
findUnique: vi.fn(),
// Dispatches on the `where` shape so the two resolution paths — by
// secret hash and by primary key — stay distinguishable in assertions.
findUnique: vi.fn(
async ({ where }: { where: { id?: string; tokenHash?: string } }) => {
const rows = store.sessions as SessionRow[];
if (where.tokenHash !== undefined) {
return rows.find((r) => r.tokenHash === where.tokenHash) ?? null;
}
return rows.find((r) => r.id === where.id) ?? null;
},
),
create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
const row = {
id: `sess_cuid_${(store.sessions as SessionRow[]).length + 1}`,
lastActiveAt: null,
user: { id: data.userId as string },
...data,
} as unknown as SessionRow;
(store.sessions as SessionRow[]).push(row);
return row;
}),
delete: vi.fn(),
deleteMany: vi.fn(),
update: vi.fn(),
},
apiToken: {
updateMany: vi.fn(),
},
refreshToken: {
updateMany: vi.fn(),
},
trustedDevice: {
deleteMany: vi.fn(),
},
apiToken: { updateMany: vi.fn() },
refreshToken: { updateMany: vi.fn() },
trustedDevice: { deleteMany: vi.fn() },
$transaction: vi.fn(async (ops: Promise<unknown>[]) => Promise.all(ops)),
},
}));
@@ -35,6 +60,12 @@ vi.mock("@/lib/logging/context", () => ({
getEvent: () => undefined,
}));
// Deterministic stand-in for the HMAC so tests assert the *shape* of the
// stored value rather than re-deriving a keyed digest.
vi.mock("@/lib/auth/hmac", () => ({
hashToken: (raw: string) => `hash:${raw}`,
}));
vi.mock("next/headers", () => ({
cookies: vi.fn(async () => ({
get: (name: string) =>
@@ -47,20 +78,165 @@ vi.mock("next/headers", () => ({
}));
import { prisma } from "@/lib/db";
import { getSession, destroyAllSessions, destroySession } from "../session";
import {
getSession,
createSession,
destroyAllSessions,
destroySession,
} from "../session";
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
function seed(row: Partial<SessionRow> & { id: string }): SessionRow {
const full: SessionRow = {
userId: "user-1",
tokenHash: null,
expiresAt: new Date(Date.now() + THIRTY_DAYS_MS),
lastActiveAt: new Date(),
user: { id: row.userId ?? "user-1" },
...row,
} as SessionRow;
(store.sessions as SessionRow[]).push(full);
return full;
}
beforeEach(() => {
vi.resetAllMocks();
vi.clearAllMocks();
store.sessions = [];
cookieState.sessionId = "sess-expired";
// `clearAllMocks` drops recorded calls but keeps implementations, so a
// rejection staged by one test would otherwise leak into the next. Re-arm
// the write mocks with a benign default each time.
vi.mocked(prisma.session.delete).mockResolvedValue({} as never);
vi.mocked(prisma.session.deleteMany).mockResolvedValue({ count: 1 } as never);
vi.mocked(prisma.session.update).mockResolvedValue({} as never);
});
describe("createSession", () => {
it("puts a CSPRNG secret in the cookie, never the row's primary key", async () => {
cookieState.sessionId = undefined;
await createSession("user-1", false);
const row = (store.sessions as SessionRow[])[0];
const cookieValue = cookieState.set.mock.calls.find(
(c) => c[0] === "healthlog_session",
)![1] as string;
// A cuid is collision-resistant, not unguessable. The cookie must not be
// the primary key under any encoding.
expect(cookieValue).not.toBe(row.id);
expect(cookieValue).not.toContain(row.id);
// 32 bytes of randomness, hex-encoded, behind a greppable prefix.
expect(cookieValue).toMatch(/^hls_[0-9a-f]{64}$/);
// Only the hash is persisted — a table dump yields no usable cookie.
expect(row.tokenHash).toBe(`hash:${cookieValue}`);
expect(row.tokenHash).not.toContain(row.id);
});
it("mints a distinct secret per session", async () => {
cookieState.sessionId = undefined;
await createSession("user-1", false);
await createSession("user-1", false);
const [first, second] = cookieState.set.mock.calls
.filter((c) => c[0] === "healthlog_session")
.map((c) => c[1] as string);
expect(first).not.toBe(second);
});
});
describe("getSession — cookie resolution", () => {
it("resolves a modern cookie by its hash, not by primary key", async () => {
const row = seed({ id: "sess_cuid_1", tokenHash: "hash:hls_abc" });
cookieState.sessionId = "hls_abc";
const result = await getSession();
expect(result?.session.id).toBe(row.id);
// The lookup went through the hash column.
expect(prisma.session.findUnique).toHaveBeenCalledWith(
expect.objectContaining({ where: { tokenHash: "hash:hls_abc" } }),
);
});
it("refuses a modern-shaped cookie whose hash matches nothing", async () => {
seed({ id: "sess_cuid_1", tokenHash: "hash:hls_real" });
cookieState.sessionId = "hls_forged";
await expect(getSession()).resolves.toBeNull();
});
it("still accepts a pre-upgrade cookie carrying the row id (no forced logout)", async () => {
// The compatibility path. A session minted before the secret existed has
// a NULL token_hash and the browser holds its cuid; the deploy must not
// sign that user out.
const row = seed({ id: "sess_legacy", tokenHash: null });
cookieState.sessionId = "sess_legacy";
const result = await getSession();
expect(result?.session.id).toBe(row.id);
expect(result?.user.id).toBe("user-1");
});
it("retires the row id once that row holds a secret", async () => {
// Same cuid, but the row has been issued a secret. The id is an internal
// identifier again and must not authenticate anything — otherwise the
// guessable-credential surface never actually closes.
seed({ id: "sess_upgraded", tokenHash: "hash:hls_xyz" });
cookieState.sessionId = "sess_upgraded";
await expect(getSession()).resolves.toBeNull();
});
});
describe("getSession — sliding expiry", () => {
it("extends a modern session and re-emits the same secret", async () => {
seed({
id: "sess_cuid_1",
tokenHash: "hash:hls_abc",
expiresAt: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000),
});
cookieState.sessionId = "hls_abc";
await getSession();
expect(prisma.session.update).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: "sess_cuid_1" } }),
);
const cookieValue = cookieState.set.mock.calls.find(
(c) => c[0] === "healthlog_session",
)![1];
expect(cookieValue).toBe("hls_abc");
});
it("withholds the extension from a legacy session so the id path drains", async () => {
// A legacy row must not renew itself indefinitely, or an active user could
// ride a cuid-as-credential session forever. Capped at the expiry it
// already carries, the path is gone within the session lifetime.
seed({
id: "sess_legacy",
tokenHash: null,
expiresAt: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000),
});
cookieState.sessionId = "sess_legacy";
const result = await getSession();
expect(result).not.toBeNull();
expect(prisma.session.update).not.toHaveBeenCalled();
});
});
describe("getSession", () => {
it("swallows expired-session delete races and clears cookies", async () => {
vi.mocked(prisma.session.findUnique).mockResolvedValue({
seed({
id: "sess-expired",
tokenHash: null,
expiresAt: new Date(Date.now() - 1000),
user: { id: "user-1" },
} as never);
});
vi.mocked(prisma.session.deleteMany).mockRejectedValue(
new Error("already deleted") as never,
);
@@ -76,7 +252,33 @@ describe("getSession", () => {
});
describe("destroySession", () => {
it("deletes the row the secret resolves to, not the cookie value", async () => {
// The cookie is no longer the primary key, so a logout that deletes by
// cookie value would no-op and leave the session alive.
seed({ id: "sess_cuid_1", tokenHash: "hash:hls_abc" });
cookieState.sessionId = "hls_abc";
await destroySession();
expect(prisma.session.delete).toHaveBeenCalledWith({
where: { id: "sess_cuid_1" },
});
expect(cookieState.delete).toHaveBeenCalledWith("healthlog_session");
});
it("logs a legacy cookie out too", async () => {
seed({ id: "sess_legacy", tokenHash: null });
cookieState.sessionId = "sess_legacy";
await destroySession();
expect(prisma.session.delete).toHaveBeenCalledWith({
where: { id: "sess_legacy" },
});
});
it("treats an already-deleted session row (P2025) as an idempotent logout", async () => {
seed({ id: "sess-gone", tokenHash: null });
cookieState.sessionId = "sess-gone";
vi.mocked(prisma.session.delete).mockRejectedValue(
Object.assign(new Error("record not found"), { code: "P2025" }) as never,
@@ -92,6 +294,7 @@ describe("destroySession", () => {
});
it("clears the cookie even when the row delete fails on a transient fault", async () => {
seed({ id: "sess-live", tokenHash: null });
cookieState.sessionId = "sess-live";
vi.mocked(prisma.session.delete).mockRejectedValue(
Object.assign(new Error("connection reset"), { code: "P1001" }) as never,
+21 -1
View File
@@ -83,7 +83,7 @@ export async function issueAccessAndRefresh(
}
export type RotationFailureReason =
"not_found" | "expired" | "already_used" | "revoked";
"not_found" | "expired" | "already_used" | "revoked" | "device_mismatch";
export type RotationResult =
| { ok: true; bundle: IssuedRefreshBundle }
@@ -158,6 +158,26 @@ export async function rotateRefreshToken(input: {
return { ok: false, reason: "already_used" };
}
// A live token belongs to the device it was issued to. The M-4 escalation
// above already treats a mismatched deviceId on a REPLAY as a spoofing
// signal; the same mismatch on a still-live token is refused outright
// rather than silently rotated. Without this the reuse-detection scope is
// only as honest as the caller: a thief could rotate a stolen token under
// their own fabricated device id, and the resulting family — keyed on the
// attacker's id, because `issueAccessAndRefresh` below inherits
// `row.deviceId ?? input.deviceId` — would sit outside the victim device's
// containment blast radius.
//
// Both ids must be present to compare. A stored null means the row predates
// per-device issuance and cannot be attributed; a presented null means the
// caller sent no `X-Device-Id` header. Neither is treated as a mismatch, so
// this cannot lock out an older client that never sends the header.
const presentedId = input.deviceId ?? null;
const storedId = row.deviceId ?? null;
if (presentedId !== null && storedId !== null && presentedId !== storedId) {
return { ok: false, reason: "device_mismatch" };
}
// Issue the new pair first, THEN mark old consumed in a transaction.
// (Race: if two concurrent refreshes hit the same row, both will try to
// mark `usedAt`. We use updateMany with a `usedAt: null` guard so only
+156 -32
View File
@@ -1,6 +1,8 @@
import { randomBytes } from "node:crypto";
import { prisma } from "@/lib/db";
import { cookies } from "next/headers";
import type { User } from "@/generated/prisma/client";
import { hashToken } from "@/lib/auth/hmac";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { getEvent } from "@/lib/logging/context";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
@@ -11,6 +13,77 @@ import { LOCALE_COOKIE, setLocaleCookie } from "@/lib/i18n/locale-cookie";
const SESSION_COOKIE = "healthlog_session";
const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
/**
* Marks a cookie value as a v1.30.32 session secret. Doubles as the branch
* between the two resolution paths below, so a cookie is only ever looked up
* one way and a raw cuid can never be tried as a secret (or vice versa).
*/
const SESSION_TOKEN_PREFIX = "hls_";
const SESSION_SECRET_BYTES = 32;
/**
* Mint a session cookie secret: 32 CSPRNG bytes, hex-encoded.
*
* This exists because the cookie used to carry `Session.id` — a `cuid()`.
* cuid targets collision-resistant identity across distributed writers, not
* unguessability: it encodes a millisecond timestamp and a process-local
* counter and never touches a CSPRNG. That is a fine primary key and an unfit
* bearer credential. Only `hashToken(secret)` reaches the database, so a
* leaked table dump yields no usable cookie — the same posture ApiToken and
* RefreshToken already hold.
*/
function mintSessionSecret(): string {
return `${SESSION_TOKEN_PREFIX}${randomBytes(SESSION_SECRET_BYTES).toString("hex")}`;
}
/**
* Resolve the session row a cookie value refers to, or null.
*
* COMPATIBILITY DECISION (v1.30.32). Two paths, and the transition is
* deliberately passive:
*
* 1. `hls_…` — the modern secret. Looked up by `hashToken(value)`.
* 2. anything else — a pre-upgrade cookie carrying the row's cuid. Accepted
* ONLY while that row's `tokenHash` is still NULL.
*
* A migration-time backfill cannot work: the cookie already in the user's
* browser is the cuid, and no token we generate server-side can be pushed into
* it. Backfilling would sign out every logged-in user at deploy — the one
* outcome this change must not cause.
*
* Upgrading a legacy row in place on next use was the other candidate and was
* rejected as unsafe. A browser fires several requests in parallel, all
* carrying the same legacy cookie; each would mint its own secret and race to
* claim the row. Exactly one write can win, and the losers cannot learn the
* winner's secret to re-emit it — the row stores only a hash. Whichever
* response reached the browser last would decide the cookie, and if that was a
* loser's, the user is signed out. A guarded update narrows the write but not
* the outcome: a loser that declines to re-cookie leaves the browser holding a
* legacy id the row no longer honours. The race has no correct resolution, and
* its failure mode is precisely the forced logout we are avoiding.
*
* So legacy rows are left alone and simply live out the expiry they already
* have — `getSession` withholds the sliding-expiry extension from them (see
* there). That bounds the id-as-credential path to the 30-day session lifetime
* from the deploy, self-draining, with no operator step and no backfill. Every
* login from the deploy onward is on a secret immediately.
*/
async function findSessionByCookie(cookieValue: string) {
if (cookieValue.startsWith(SESSION_TOKEN_PREFIX)) {
return prisma.session.findUnique({
where: { tokenHash: hashToken(cookieValue) },
include: { user: true },
});
}
const legacy = await prisma.session.findUnique({
where: { id: cookieValue },
include: { user: true },
});
// A row that already holds a secret has retired its id as a credential.
return legacy && legacy.tokenHash === null ? legacy : null;
}
// v1.23 — throttle window for the user-facing active-session list's "last
// seen" stamp. A write only happens when the prior stamp is older than this,
// so the high-churn `getSession` path costs at most one extra UPDATE per
@@ -73,9 +146,13 @@ export async function createSession(
// "single-factor session" and never satisfies step-up.
mfaVerifiedAt?: Date | null,
): Promise<string> {
// The secret is the cookie; the row id stays an internal identifier and is
// never handed to the client.
const secret = mintSessionSecret();
const session = await prisma.session.create({
data: {
userId,
tokenHash: hashToken(secret),
expiresAt: new Date(Date.now() + SESSION_MAX_AGE_MS),
ipAddress: ipAddress ?? null,
userAgent: userAgent ?? null,
@@ -84,7 +161,7 @@ export async function createSession(
});
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE, session.id, {
cookieStore.set(SESSION_COOKIE, secret, {
httpOnly: true,
secure: shouldEmitSecureCookie(),
// OAuth callbacks (e.g. Withings) arrive via top-level cross-site redirect.
@@ -117,18 +194,15 @@ export async function getSession(): Promise<{
}
const cookieStore = await cookies();
const sessionId = cookieStore.get(SESSION_COOKIE)?.value;
if (!sessionId) return null;
const cookieValue = cookieStore.get(SESSION_COOKIE)?.value;
if (!cookieValue) return null;
const session = await prisma.session.findUnique({
where: { id: sessionId },
include: { user: true },
});
const session = await findSessionByCookie(cookieValue);
if (!session || session.expiresAt < new Date()) {
if (session) {
await prisma.session
.deleteMany({ where: { id: sessionId } })
.deleteMany({ where: { id: session.id } })
.catch(() => {});
}
cookieStore.delete(SESSION_COOKIE);
@@ -136,18 +210,26 @@ export async function getSession(): Promise<{
return null;
}
// Sliding expiry: refresh if more than 1 day old
// Sliding expiry: refresh if more than 1 day old.
//
// Withheld from legacy (`tokenHash === null`) rows on purpose. Extending
// those would let a cuid-as-credential session renew itself indefinitely for
// an active user, and the whole point of the passive transition is that the
// id-resolvable path drains on its own. Capping them at the expiry they
// already carry bounds it to at most 30 days past the deploy; the user then
// logs in once and lands on a secret. Re-emitting the cookie is likewise
// pointless for a legacy row — its value cannot be improved in place.
const oneDayMs = 24 * 60 * 60 * 1000;
if (
session.expiresAt.getTime() - Date.now() <
SESSION_MAX_AGE_MS - oneDayMs
session.tokenHash !== null &&
session.expiresAt.getTime() - Date.now() < SESSION_MAX_AGE_MS - oneDayMs
) {
const newExpiry = new Date(Date.now() + SESSION_MAX_AGE_MS);
await prisma.session.update({
where: { id: session.id },
data: { expiresAt: newExpiry },
});
cookieStore.set(SESSION_COOKIE, session.id, {
cookieStore.set(SESSION_COOKIE, cookieValue, {
httpOnly: true,
secure: shouldEmitSecureCookie(),
sameSite: "lax",
@@ -211,33 +293,43 @@ export async function getSession(): Promise<{
*/
export async function getSessionUserLocale(): Promise<string | null> {
const cookieStore = await cookies();
const sessionId = cookieStore.get(SESSION_COOKIE)?.value;
if (!sessionId) return null;
const cookieValue = cookieStore.get(SESSION_COOKIE)?.value;
if (!cookieValue) return null;
const session = await prisma.session.findUnique({
where: { id: sessionId },
select: { expiresAt: true, user: { select: { locale: true } } },
});
// Same two-path resolution as `getSession`, read-only: this runs in the root
// layout where the cookie store cannot be written, so there is nothing to
// refresh or clear here.
const session = await findSessionByCookie(cookieValue);
if (!session || session.expiresAt < new Date()) return null;
return session.user.locale;
}
export async function destroySession(): Promise<void> {
const cookieStore = await cookies();
const sessionId = cookieStore.get(SESSION_COOKIE)?.value;
if (sessionId) {
const cookieValue = cookieStore.get(SESSION_COOKIE)?.value;
if (cookieValue) {
// Resolve the cookie to its row before deleting — the cookie is a secret,
// not the primary key, so `delete({ where: { id: cookieValue } })` would
// silently no-op on every modern session and leave the row alive after a
// logout.
//
// Logout is idempotent: a session row that no longer exists (P2025) is a
// no-op. Any other failure is a real delete error — record it on the wide
// event rather than swallowing it silently, but never block the cookie
// clear below: a transient DB fault must not leave the client logged in
// with the cookie intact.
await prisma.session.delete({ where: { id: sessionId } }).catch((err) => {
if (!isP2025(err)) {
getEvent()?.addWarning(
`destroySession delete failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
});
const session = await findSessionByCookie(cookieValue).catch(() => null);
if (session) {
await prisma.session
.delete({ where: { id: session.id } })
.catch((err) => {
if (!isP2025(err)) {
getEvent()?.addWarning(
`destroySession delete failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
});
}
}
cookieStore.delete(SESSION_COOKIE);
cookieStore.delete(ONBOARDING_COOKIE);
@@ -309,16 +401,48 @@ export async function destroyOtherSessions(
}
/**
* v1.23 — revoke a single web session by id, scoped to the owning user so a
* caller can never delete another user's session row. Returns whether a row
* was actually removed (false → not found or not owned).
* The public handle for a session row, used by the session-management list
* and its revoke route.
*
* The list needs a per-row identifier the client can send back to revoke one
* device. It used to send the row id — which, for every session created
* before the secret cookie landed, IS that session's cookie value. The list
* was therefore handing the client live credentials for all of the account's
* other devices, and anything that captured one response body (an error
* reporter, a proxy log, a screenshot of devtools) captured usable logins.
*
* The handle is an HMAC of the row id: stable within a deployment so the
* client can round-trip it, and structurally useless as a cookie because the
* legacy lookup matches a cuid primary key and a 64-char hex digest is not
* one. Nothing is stored — the handle is recomputed per request, so this
* needs no column and no migration.
*/
export function sessionHandle(sessionId: string): string {
return hashToken(sessionId);
}
/**
* v1.23 — revoke a single web session, scoped to the owning user so a caller
* can never delete another user's session row. Returns whether a row was
* actually removed (false → not found or not owned).
*
* Takes the public handle rather than the row id. The account's own rows are
* a handful, so resolving the handle by walking them costs one indexed read
* and a few HMACs — cheaper than a column, and it keeps the id server-side.
*/
export async function destroySessionById(
userId: string,
sessionId: string,
handle: string,
): Promise<boolean> {
const own = await prisma.session.findMany({
where: { userId },
select: { id: true },
});
const match = own.find((s) => sessionHandle(s.id) === handle);
if (!match) return false;
const result = await prisma.session.deleteMany({
where: { id: sessionId, userId },
where: { id: match.id, userId },
});
return result.count > 0;
}
+11 -5
View File
@@ -7,11 +7,17 @@ import { z } from "zod/v4";
* on `User` (`nightscoutUrlEncrypted` / `nightscoutTokenEncrypted`); the
* private-host opt-in maps to `nightscoutAllowPrivateHost`.
*
* The URL is validated to be a parseable http(s) origin at input time. The
* SSRF floor (public-host requirement, unless `allowPrivateHost` is set) is
* enforced at fetch time by the client through `safeFetch` — input-shape
* validation here only rejects garbage early so the connect route can return
* a clear 422 instead of letting a malformed string reach the egress layer.
* The URL is validated to be a parseable http(s) origin here. The SSRF floor
* (public-host requirement, unless `allowPrivateHost` is set) is asserted
* twice on purpose: the connect route runs `isPublicUrl` before it persists
* anything, and `safeFetch` re-checks plus pins the resolved IP at connect
* time. The shape check here only rejects garbage early so the route can
* return a clear 422 instead of letting a malformed string reach either.
*
* The floor is NOT expressed as a Zod refinement because it depends on the
* sibling `allowPrivateHost` field and needs its own error message; folding
* it in here would collapse "malformed URL" and "private host, opt in to
* reach it" into one opaque 422.
*/
export const nightscoutConnectSchema = z.object({
// Trimmed: a trailing space / newline from a copy button reaches the
+132
View File
@@ -132,6 +132,138 @@ describe("session lifecycle (real Postgres)", () => {
expect(cookieJar.get("hl_onboarding")).toBeUndefined();
});
/**
* v1.30.32 — the cookie carries a CSPRNG secret, not the row's cuid.
* These run against real Postgres because the thing under test is the
* migration plus the unique index plus the two lookup paths; a mocked
* client can assert the shape but not that the column and constraint
* actually exist on a migrated database.
*/
describe("session cookie secret (migration 0255)", () => {
it("issues a secret cookie and stores only its hash", async () => {
const { createSession } = await import("@/lib/auth/session");
const { hashToken } = await import("@/lib/auth/hmac");
const user = await getPrismaClient().user.create({
data: { username: "secret-cookie", email: "secret@example.test" },
});
const sessionId = await createSession(user.id, false);
const cookie = cookieJar.get("healthlog_session")!;
expect(cookie).toMatch(/^hls_[0-9a-f]{64}$/);
expect(cookie).not.toBe(sessionId);
const row = await getPrismaClient().session.findUnique({
where: { id: sessionId },
});
// The raw secret is nowhere on the row.
expect(row?.tokenHash).toBe(hashToken(cookie));
expect(row?.tokenHash).not.toBe(cookie);
});
it("authenticates by the secret and refuses the row id", async () => {
const { createSession, getSession } = await import("@/lib/auth/session");
const user = await getPrismaClient().user.create({
data: { username: "id-retired", email: "retired@example.test" },
});
const sessionId = await createSession(user.id, false);
// The secret works.
expect((await getSession())?.session.id).toBe(sessionId);
// The primary key does not. This is the whole point: knowing or
// guessing a cuid must no longer authenticate anything.
cookieJar.set("healthlog_session", sessionId);
expect(await getSession()).toBeNull();
});
it("keeps a pre-upgrade session alive on its row id", async () => {
// The compatibility contract. This row is exactly what the migration
// leaves behind: a real session with a NULL token_hash whose cookie in
// the user's browser is the cuid. The deploy must not sign them out.
const { getSession } = await import("@/lib/auth/session");
const user = await getPrismaClient().user.create({
data: { username: "legacy-session", email: "legacy@example.test" },
});
const legacy = await getPrismaClient().session.create({
data: {
userId: user.id,
expiresAt: new Date(Date.now() + 20 * 24 * 60 * 60 * 1000),
},
});
expect(legacy.tokenHash).toBeNull();
cookieJar.set("healthlog_session", legacy.id);
const result = await getSession();
expect(result).not.toBeNull();
expect(result?.session.id).toBe(legacy.id);
expect(result?.user.id).toBe(user.id);
});
it("does not extend a pre-upgrade session's expiry", async () => {
// Withholding the sliding refresh is what bounds the transition: the
// id-resolvable path drains within the session lifetime instead of
// renewing forever under an active user.
const { getSession } = await import("@/lib/auth/session");
const user = await getPrismaClient().user.create({
data: { username: "legacy-drain", email: "drain@example.test" },
});
const expiresAt = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000);
const legacy = await getPrismaClient().session.create({
data: { userId: user.id, expiresAt },
});
cookieJar.set("healthlog_session", legacy.id);
expect(await getSession()).not.toBeNull();
const after = await getPrismaClient().session.findUnique({
where: { id: legacy.id },
});
expect(after?.expiresAt.getTime()).toBe(expiresAt.getTime());
});
it("gives concurrent sessions distinct secrets under the unique index", async () => {
const { createSession } = await import("@/lib/auth/session");
const user = await getPrismaClient().user.create({
data: { username: "many-secrets", email: "many@example.test" },
});
await createSession(user.id, false);
const first = cookieJar.get("healthlog_session")!;
await createSession(user.id, false);
const second = cookieJar.get("healthlog_session")!;
expect(first).not.toBe(second);
const rows = await getPrismaClient().session.findMany({
where: { userId: user.id },
});
expect(rows).toHaveLength(2);
expect(new Set(rows.map((r) => r.tokenHash)).size).toBe(2);
});
it("logs out by resolving the secret to its row", async () => {
const { createSession, destroySession } =
await import("@/lib/auth/session");
const user = await getPrismaClient().user.create({
data: { username: "secret-logout", email: "logout@example.test" },
});
const sessionId = await createSession(user.id, false);
await destroySession();
// A logout that deleted by cookie value would leave this row behind.
const row = await getPrismaClient().session.findUnique({
where: { id: sessionId },
});
expect(row).toBeNull();
});
});
it("destroySession clears the hl_onboarding cookie alongside the session cookie", async () => {
const { createSession, destroySession } =
await import("@/lib/auth/session");
@@ -21,6 +21,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
import { hashToken } from "@/lib/auth/hmac";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
@@ -140,7 +141,14 @@ describe("POST /api/auth/password (real Postgres)", () => {
expect(remainingSessions).toHaveLength(1);
expect(remainingSessions[0].id).not.toBe(sessionId);
expect(remainingSessions[0].id).not.toBe(siblingSession.id);
expect(cookieJar.get("healthlog_session")).toBe(remainingSessions[0].id);
// v1.30.32 — the cookie carries the session secret, not the row id, so
// bind them through the stored hash instead. Strictly stronger than the
// old identity check: it proves the cookie actually authenticates the
// surviving row, and that the row id never leaves the server.
const rotatedCookie = cookieJar.get("healthlog_session")!;
expect(rotatedCookie).not.toBe(remainingSessions[0].id);
expect(remainingSessions[0].tokenHash).toBe(hashToken(rotatedCookie));
// The API token is flipped to revoked rather than deleted so the
// audit trail survives.
@@ -5,12 +5,17 @@
* Covers the DB-layer contracts the unit mocks can't pin:
* - destroyOtherSessions keeps the current session, deletes the rest, revokes
* native refresh tokens, and leaves API tokens untouched.
* - destroySessionById is scoped to the owning user (no cross-user delete).
* - destroySessionById is scoped to the owning user (no cross-user delete)
* and takes the public handle, never the row id.
* - the (userId, deviceHash) unique index enforces the login-alert dedupe.
*/
import { beforeEach, describe, expect, it } from "vitest";
import { destroyOtherSessions, destroySessionById } from "@/lib/auth/session";
import {
destroyOtherSessions,
destroySessionById,
sessionHandle,
} from "@/lib/auth/session";
import { getPrismaClient, truncateAllTables } from "./setup";
beforeEach(async () => {
@@ -85,14 +90,26 @@ describe("destroySessionById", () => {
data: { userId: other.id, expiresAt: new Date(Date.now() + 1e6) },
});
// Cross-user attempt: owner tries to delete the intruder's session.
expect(await destroySessionById(owner.id, otherSession.id)).toBe(false);
// Cross-user attempt: owner presents the intruder's handle.
expect(
await destroySessionById(owner.id, sessionHandle(otherSession.id)),
).toBe(false);
expect(
await prisma.session.findUnique({ where: { id: otherSession.id } }),
).not.toBeNull();
// Owned delete succeeds.
expect(await destroySessionById(owner.id, ownerSession.id)).toBe(true);
// The row id is not an accepted key: a caller who somehow learned it
// still cannot revoke with it, which is what keeps the id out of the
// client's hands from being load-bearing in both directions.
expect(await destroySessionById(owner.id, ownerSession.id)).toBe(false);
expect(
await prisma.session.findUnique({ where: { id: ownerSession.id } }),
).not.toBeNull();
// Owned delete succeeds on the handle.
expect(
await destroySessionById(owner.id, sessionHandle(ownerSession.id)),
).toBe(true);
expect(
await prisma.session.findUnique({ where: { id: ownerSession.id } }),
).toBeNull();
+10
View File
@@ -24,6 +24,16 @@ import type { PrismaClient } from "@/generated/prisma/client";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
// v1.30.32 — session cookies now carry a CSPRNG secret whose HMAC is what
// lands in `sessions.token_hash`, so `createSession` needs this key exactly
// like token issuance already did. It is a Core-required variable in
// `scripts/env-manifest.json` (the app refuses to boot without it), so
// defaulting it here matches production rather than papering over a gap.
// Same `??=` treatment as the encryption key above: files that set their own
// still win.
process.env.API_TOKEN_HMAC_KEY ??=
"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210";
/**
* The application's Prisma singleton. Tests use this so any code
* imported via `await import("@/lib/...")` shares the exact same client