Merge branch 'fix/encrypt-routes-and-practitioner-contact' into release/v1.39.4

This commit is contained in:
Marc-André Bombeck committed 2026-09-27 10:38:08 +02:00
commit 2feb622b6a
34 files changed
+1882 -155

No files matched your search

+2 -2
View File
@@ -2,8 +2,8 @@
* `PUT /api/environment/home` — set the account's coarse home location.
*
* The picked city (rounded lat/lon + label + IANA timezone) is stored on the
* user. Coordinates are rounded to ~city granularity here as a defence-in-depth
* floor even if a client sends finer values. Setting a home enqueues a
* user. Coordinates are rounded to 1 decimal (about 11 km) here as a
* defence-in-depth floor even if a client sends finer values. Setting a home enqueues a
* lookback fetch so weather appears without waiting for the nightly tick.
* Module-gated; `userId` is narrowed from auth.
*/
+1 -1
View File
@@ -3,7 +3,7 @@
*
* For any day in [startDate, endDate] the weather fetch resolves against this
* coarse location instead of home (a declared trip dominates the home
* fallback). Coordinates are rounded to ~city granularity. Adding an override
* fallback). Coordinates are rounded to 1 decimal (about 11 km). Adding an override
* enqueues a lookback refresh so its days re-resolve. Module-gated; `userId` is
* narrowed from auth.
*/
+35 -8
View File
@@ -27,7 +27,11 @@ import {
} from "@/lib/api-response";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { practitionerUpdateSchema } from "@/lib/validations/practitioners";
import { toPractitionerDTO } from "@/lib/practitioners/dto";
import { encryptNote } from "@/lib/crypto/note-cipher";
import {
readPractitionerContact,
toPractitionerDTO,
} from "@/lib/practitioners/dto";
import type { Prisma } from "@/generated/prisma/client";
type RouteParams = { params: Promise<{ id: string }> };
@@ -88,14 +92,39 @@ export const PATCH = apiHandler(
if (entry.name !== undefined) data.name = entry.name;
if (entry.specialty !== undefined) data.specialty = entry.specialty;
if (entry.practice !== undefined) data.practice = entry.practice;
if (entry.location !== undefined) data.location = entry.location;
if (entry.phone !== undefined) data.phone = entry.phone;
// v1.39.4 — the phone number and the address are written as ciphertext
// only. Writing one also clears its legacy readable column, so an edit of
// a row the backfill has not reached never leaves the old value behind.
if (entry.location !== undefined) {
data.locationEncrypted = encryptNote(entry.location);
data.location = null;
}
if (entry.phone !== undefined) {
data.phoneEncrypted = encryptNote(entry.phone);
data.phone = null;
}
if (entry.note !== undefined) {
data.noteEncrypted = entry.note ? encryptToBytes(entry.note) : null;
}
const updated = await prisma.practitioner.update({ where: { id }, data });
// The phone number and the address are encrypted at rest, so the audit
// row names them when they changed and never carries their values.
const contactChanged = (
[
["location", existing.locationEncrypted, existing.location],
["phone", existing.phoneEncrypted, existing.phone],
] as const
)
.filter(
([field, ciphertext, legacy]) =>
entry[field] !== undefined &&
(readPractitionerContact(ciphertext, legacy, field) ?? null) !==
(entry[field] || null),
)
.map(([field]) => field);
await auditLog("practitioner.contact.update", {
userId: user.id,
ipAddress: getClientIp(request),
@@ -103,23 +132,21 @@ export const PATCH = apiHandler(
practitionerId: id,
// A manager may edit somebody else's address book, so the owner's
// activity feed has to say what changed rather than only that
// something did. The note is deliberately absent: it is encrypted PHI
// something did. The note is deliberately absent, and the phone
// number and address appear by name only: they are encrypted at rest
// and the audit table is not a second copy of the record.
...overwriteDetails({
before: {
name: existing.name,
specialty: existing.specialty,
practice: existing.practice,
location: existing.location,
phone: existing.phone,
},
after: {
name: updated.name,
specialty: updated.specialty,
practice: updated.practice,
location: updated.location,
phone: updated.phone,
},
redacted: contactChanged,
}),
},
});
+7 -4
View File
@@ -9,8 +9,9 @@
*
* `userId` is narrowed from auth and fed to the Prisma `where`; it is never a
* body field. The `data` object is built field-by-field, never spread from the
* parsed body. `note` is encrypted at rest; `name` stays plaintext so the
* picker can search and sort it in SQL.
* parsed body. `note`, `phone` and `location` are encrypted at rest; `name`,
* `practice` and `specialty` stay plaintext so the picker can search and sort
* them in SQL.
*/
import { NextRequest } from "next/server";
@@ -26,6 +27,7 @@ import {
} from "@/lib/api-response";
import { withIdempotency } from "@/lib/idempotency";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { encryptNote } from "@/lib/crypto/note-cipher";
import {
practitionerCreateSchema,
practitionerListQuerySchema,
@@ -125,8 +127,9 @@ async function postPractitioner(request: NextRequest): Promise<Response> {
name: entry.name,
specialty: entry.specialty ?? null,
practice: entry.practice ?? null,
location: entry.location ?? null,
phone: entry.phone ?? null,
// v1.39.4 — ciphertext only; the readable columns stay NULL.
locationEncrypted: encryptNote(entry.location),
phoneEncrypted: encryptNote(entry.phone),
noteEncrypted: entry.note ? encryptToBytes(entry.note) : null,
},
});
+10 -8
View File
@@ -42,6 +42,7 @@ import {
import { computeSplits } from "@/lib/workouts/splits";
import { buildSportContext } from "@/lib/workouts/sport-context";
import type { RouteCoordinate } from "@/lib/workouts/route-svg";
import { readRouteGeometry } from "@/lib/workouts/route-geometry-cipher";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { userDayKey } from "@/lib/tz/format";
@@ -74,6 +75,7 @@ export const GET = apiHandler(
select: {
id: true,
geometry: true,
geometryEncrypted: true,
sampleTimestamps: true,
createdAt: true,
},
@@ -193,14 +195,14 @@ export const GET = apiHandler(
// so the web client keeps dropping the raw timestamp blob under
// `compact=1` while still rendering splits (server-authoritative
// parity — iOS gets the same resolved figures).
// v1.39.4 — the track is stored sealed; the legacy readable column is
// read only for a row the backfill has not reached yet.
const routeGeometry = row.route ? readRouteGeometry(row.route) : null;
const geometryCoords =
row.route &&
row.route.geometry &&
typeof row.route.geometry === "object" &&
Array.isArray(
(row.route.geometry as { coordinates?: unknown }).coordinates,
)
? (row.route.geometry as { coordinates: RouteCoordinate[] }).coordinates
routeGeometry &&
typeof routeGeometry === "object" &&
Array.isArray((routeGeometry as { coordinates?: unknown }).coordinates)
? (routeGeometry as { coordinates: RouteCoordinate[] }).coordinates
: null;
const splits =
geometryCoords && Array.isArray(row.route?.sampleTimestamps)
@@ -259,7 +261,7 @@ export const GET = apiHandler(
const route = row.route
? {
geometry: row.route.geometry,
geometry: routeGeometry,
// `compact=1` drops the (up to 20k-entry) timestamp array; the
// SVG needs geometry, not the per-sample timestamps, and the
// splits above are already derived from them server-side.
@@ -8,7 +8,7 @@
* (idempotency replay, real-DB dedup, concurrent-write race) live in
* the matching `tests/integration/workout-batch-create.test.ts`.
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
@@ -74,6 +74,8 @@ vi.mock("next/headers", () => ({
}));
import { POST } from "../batch/route";
import { _resetCryptoCacheForTests } from "@/lib/crypto";
import { decryptRouteGeometry } from "@/lib/workouts/route-geometry-cipher";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
@@ -140,8 +142,20 @@ function validWorkout(
};
}
// v1.39.4 — a route is sealed on write, so the ingest needs a configured key.
const TEST_KEY = "b".repeat(64);
afterEach(() => {
vi.unstubAllEnvs();
_resetCryptoCacheForTests();
});
beforeEach(() => {
vi.resetAllMocks();
vi.stubEnv("ENCRYPTION_KEYS", "");
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "");
vi.stubEnv("ENCRYPTION_KEY", TEST_KEY);
_resetCryptoCacheForTests();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
@@ -526,8 +540,29 @@ describe("POST /api/workouts/batch — nested route attachment", () => {
expect(res.status).toBe(200);
expect(prisma.workoutRoute.createMany).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.workoutRoute.createMany).mock.calls[0]?.[0];
const rows = (call as { data: Array<{ workoutId: string }> }).data;
const rows = (
call as {
data: Array<{
workoutId: string;
geometry?: unknown;
geometryEncrypted?: Uint8Array;
}>;
}
).data;
expect(rows[0]?.workoutId).toBe("wkt-fresh-id");
// v1.39.4 — the track is written sealed only: no readable geometry on the
// row, and the ciphertext opens to exactly what the client sent.
expect(rows[0]?.geometry).toBeUndefined();
const sealed = rows[0]?.geometryEncrypted;
expect(sealed).toBeInstanceOf(Uint8Array);
expect(Buffer.from(sealed!).toString("utf8")).not.toContain("49.452");
expect(decryptRouteGeometry(sealed!)).toEqual({
type: "LineString",
coordinates: [
[11.077, 49.452],
[11.078, 49.453],
],
});
});
});
@@ -24,6 +24,7 @@ import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
import { _resetCryptoCacheForTests } from "@/lib/crypto";
import { encryptRouteGeometry } from "@/lib/workouts/route-geometry-cipher";
vi.mock("@/lib/db", () => ({
prisma: {
@@ -551,6 +552,92 @@ describe("GET /api/workouts/{id}", () => {
`${SESSION_DAY}T07:01:00Z`,
]);
});
describe("v1.39.4 — sealed track", () => {
const TEST_KEY = "c".repeat(64);
const routeGeometry = {
type: "LineString",
coordinates: [
[11.0, 49.0],
[11.005, 49.0],
[11.01, 49.0],
],
};
beforeEach(() => {
vi.stubEnv("ENCRYPTION_KEYS", "");
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "");
vi.stubEnv("ENCRYPTION_KEY", TEST_KEY);
_resetCryptoCacheForTests();
});
afterEach(() => {
vi.unstubAllEnvs();
_resetCryptoCacheForTests();
});
function mockRoute(route: Record<string, unknown>) {
vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce({
...BASE_ROW,
route: {
id: "wr-1",
sampleTimestamps: [
`${SESSION_DAY}T07:00:00Z`,
`${SESSION_DAY}T07:02:00Z`,
`${SESSION_DAY}T07:04:00Z`,
],
createdAt: BASE_ROW.createdAt,
...route,
},
} as never);
vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([
{
id: "w-1",
source: "APPLE_HEALTH",
startedAt: BASE_ROW.startedAt,
sportType: "RUNNING",
},
] as never);
}
it("serves a sealed track in the same shape as a readable one, splits included", async () => {
mockRoute({ geometry: routeGeometry, geometryEncrypted: null });
const legacy = (
await (await GET(makeRequest(), makeParams("w-1"))).json()
).data;
mockRoute({
geometry: null,
geometryEncrypted: encryptRouteGeometry(routeGeometry),
});
const sealed = (
await (await GET(makeRequest(), makeParams("w-1"))).json()
).data;
expect(sealed.route).toEqual(legacy.route);
expect(sealed.route.geometry).toEqual(routeGeometry);
expect(sealed.splits).toEqual(legacy.splits);
});
it("prefers the ciphertext over a readable column on the same row", async () => {
mockRoute({
geometry: { type: "LineString", coordinates: [[0, 0]] },
geometryEncrypted: encryptRouteGeometry(routeGeometry),
});
const body = await (await GET(makeRequest(), makeParams("w-1"))).json();
expect(body.data.route.geometry).toEqual(routeGeometry);
});
it("selects the sealed column", async () => {
mockRoute({ geometry: routeGeometry, geometryEncrypted: null });
await GET(makeRequest(), makeParams("w-1"));
const args = vi.mocked(prisma.workout.findUnique).mock.calls.at(-1)?.[0];
expect(
(args as { include: { route: { select: Record<string, boolean> } } })
.include.route.select.geometryEncrypted,
).toBe(true);
});
});
});
/**
+4 -1
View File
@@ -85,6 +85,7 @@ import {
type UnstableExternalIdShape,
} from "@/lib/validations/external-id";
import { dedupeWorkoutBatch } from "@/lib/workouts/canonical-rows";
import { encryptRouteGeometry } from "@/lib/workouts/route-geometry-cipher";
import { Prisma, type MeasurementSource } from "@/generated/prisma/client";
// v1.4.25 W16c — push-suppression threshold for workout PRs. A batch
@@ -561,7 +562,9 @@ async function postBatch(request: NextRequest): Promise<Response> {
if (p.route) {
routesToInsert.push({
workoutId: id,
geometry: p.route.geometry as Prisma.InputJsonValue,
// v1.39.4 — the GPS track is stored sealed only; the readable
// `geometry` column stays NULL.
geometryEncrypted: encryptRouteGeometry(p.route.geometry),
sampleTimestamps:
p.route.sampleTimestamps === null
? Prisma.JsonNull