From 5eb7956bad22f7be69d8f1c60cdd1411608d14a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 27 Sep 2026 09:59:34 +0200 Subject: [PATCH 1/3] fix(privacy): encrypt workout GPS tracks and practitioner contact details at rest The GPS track of an outdoor workout was stored readable, and its first and last points are usually the person's front door. The phone number and address in the address book of doctors and practices were readable too, while the note beside them was already encrypted. Migration 0361 adds workout_routes.geometry_encrypted (the geometry loses its NOT NULL) and practitioners.phone_encrypted / location_encrypted, plus partial indexes on the backfill's discovery predicates. A track is sealed as one binary AES-256-GCM value bound to its purpose; the contact fields use the same Bytes codec as the note. The practitioner's name, practice and specialty stay readable, because the picker searches and sorts on them. Every writer stores ciphertext only: workout batch ingest, practitioner create and edit, and both restore formats. Readers prefer the ciphertext and fall back to the old column for rows not yet migrated: the workout detail (map, splits, GPX), the activity insight's climb figure, the practitioner DTO behind the address book, visits and vaccinations. The wire shape does not change. An edit's audit row now names a changed phone number or address without recording either value. free-text-encryption-backfill covers the three columns in the same pass, finding a route through its workout. All three are registered for key rotation (registry, CLI script, and the in-app rotation). A disaster-recovery backup carries the contact fields as ciphertext only; a portable export carries them readable. Workout routes are still not part of a backup, as before. The old columns are marked for removal in a later release. --- .../migration.sql | 37 ++ prisma/schema.prisma | 63 +++- scripts/rotate-encryption-key.ts | 29 ++ src/app/api/practitioners/[id]/route.ts | 43 ++- src/app/api/practitioners/route.ts | 11 +- src/app/api/workouts/[id]/route.ts | 18 +- .../workouts/__tests__/batch-create.test.ts | 39 ++- src/app/api/workouts/__tests__/detail.test.ts | 87 +++++ src/app/api/workouts/batch/route.ts | 5 +- src/lib/crypto/encrypted-columns.ts | 24 ++ src/lib/export/visits-backup.ts | 62 +++- .../free-text-encryption-backfill.test.ts | 152 +++++++- src/lib/jobs/free-text-encryption-backfill.ts | 210 ++++++++--- src/lib/jobs/job-outcome.ts | 2 + src/lib/jobs/reminder/register-maintenance.ts | 21 +- src/lib/jobs/workout-insight-generate.ts | 7 +- src/lib/practitioners/dto.ts | 37 +- src/lib/validations/backup.ts | 3 + .../__tests__/route-geometry-cipher.test.ts | 66 ++++ src/lib/workouts/route-geometry-cipher.ts | 56 +++ ...ion-key-rotation-contact-and-route.test.ts | 148 ++++++++ .../free-text-encryption-backfill.test.ts | 179 ++++++++++ .../practitioner-contact-encryption.test.ts | 325 ++++++++++++++++++ .../integration/workout-batch-create.test.ts | 17 + 24 files changed, 1529 insertions(+), 112 deletions(-) create mode 100644 prisma/migrations/0361_encrypt_route_geometry_and_practitioner_contact/migration.sql create mode 100644 src/lib/workouts/__tests__/route-geometry-cipher.test.ts create mode 100644 src/lib/workouts/route-geometry-cipher.ts create mode 100644 tests/integration/encryption-key-rotation-contact-and-route.test.ts create mode 100644 tests/integration/practitioner-contact-encryption.test.ts diff --git a/prisma/migrations/0361_encrypt_route_geometry_and_practitioner_contact/migration.sql b/prisma/migrations/0361_encrypt_route_geometry_and_practitioner_contact/migration.sql new file mode 100644 index 000000000..cd9f3ebf4 --- /dev/null +++ b/prisma/migrations/0361_encrypt_route_geometry_and_practitioner_contact/migration.sql @@ -0,0 +1,37 @@ +-- Two more kinds of personal data were still stored readable: the GPS track of +-- an outdoor workout (whose first and last points are usually the person's +-- front door) and the phone number and address in the address book of doctors +-- and practices. All three move to AES-256-GCM at rest. The practitioner's +-- name and specialty stay readable, because the picker searches and sorts on +-- them. +-- +-- SQL cannot encrypt, so this migration only adds the ciphertext columns and +-- the discovery indexes. The boot-time `free-text-encryption-backfill` job +-- encrypts the existing rows one at a time and nulls the readable values in +-- the same transaction. Readers prefer the ciphertext and fall back to the old +-- columns until the backfill has reached a row. The old columns are dropped in +-- a later release, once the backfill reports nothing left on every instance. +-- +-- Additive only: no table rewrite, no data change. The geometry loses its NOT +-- NULL because every new track is written to the ciphertext column and the +-- readable one stays empty. + +ALTER TABLE "workout_routes" ADD COLUMN "geometry_encrypted" BYTEA; +ALTER TABLE "workout_routes" ALTER COLUMN "geometry" DROP NOT NULL; + +ALTER TABLE "practitioners" ADD COLUMN "location_encrypted" BYTEA; +ALTER TABLE "practitioners" ADD COLUMN "phone_encrypted" BYTEA; + +-- Partial indexes on exactly the backfill's discovery predicates, so the boot +-- scan is index-only and shrinks to nothing as the backfill converges (the +-- 0357 precedent). A route row carries no user id; discovery joins it to its +-- workout, and the index keeps the route side of that join to the rows still +-- to seal. Non-concurrent: Prisma Migrate runs the file in a transaction, and +-- the build lands in the pre-traffic window at boot. +CREATE INDEX IF NOT EXISTS "workout_routes_geometry_backfill_idx" + ON "workout_routes" ("workout_id") + WHERE "geometry" IS NOT NULL; + +CREATE INDEX IF NOT EXISTS "practitioners_contact_backfill_idx" + ON "practitioners" ("user_id") + WHERE "phone" IS NOT NULL OR "location" IS NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e24bf462a..ec8d93d28 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -2272,22 +2272,35 @@ model Workout { } model WorkoutRoute { - id String @id @default(cuid()) - workoutId String @unique @map("workout_id") + id String @id @default(cuid()) + workoutId String @unique @map("workout_id") /// GeoJSON LineString — `{ "type": "LineString", coordinates: /// [[lon, lat, alt?], ...] }`. JSONB rather than PostGIS so the /// Coolify Postgres template stays extension-free (same decision /// v1.4.20 made for correlations); GPX import + Leaflet / MapLibre /// consume GeoJSON natively. - geometry Json + /// @internal: legacy readable track, read only as a fallback for rows the + /// free-text encryption backfill has not reached yet (v1.39.4). Every write + /// since then goes to `geometryEncrypted` and leaves this NULL; the backfill + /// nulls it on the old rows. DROP this column in a later release, once the + /// backfill reports zero remaining rows on every instance. + geometry Json? + /// v1.39.4 — the same GeoJSON LineString, JSON text sealed as one binary + /// AES-256-GCM value (`encryptBytes`, labelled per purpose). Read through + /// `readRouteGeometry` in `src/lib/workouts/route-geometry-cipher.ts`. + geometryEncrypted Bytes? @map("geometry_encrypted") /// Optional per-sample timestamps (ISO strings) and speeds — /// parallel array to `geometry.coordinates`. NULL when the source /// only ships static GPX (e.g. Withings). - sampleTimestamps Json? @map("sample_timestamps") - createdAt DateTime @default(now()) @map("created_at") + sampleTimestamps Json? @map("sample_timestamps") + createdAt DateTime @default(now()) @map("created_at") workout Workout @relation(fields: [workoutId], references: [id], onDelete: Cascade) + // `workout_routes_geometry_backfill_idx` is a partial index over + // `(workout_id) WHERE geometry IS NOT NULL` — see migration 0361 for the + // encryption backfill's discovery scan. Raw SQL only (partial predicate not + // expressible in Prisma). @@map("workout_routes") } @@ -8637,33 +8650,49 @@ enum Laterality { /// instance share a namespace. There is no uniqueness across accounts of /// any kind here. model Practitioner { - id String @id @default(cuid()) - userId String @map("user_id") + id String @id @default(cuid()) + userId String @map("user_id") /// "Dr. med. …" or a practice name — whichever the person thinks of /// first. Plaintext and bounded, so the picker can ILIKE-search and /// ORDER BY in SQL (the `InboundDocument.title` tradeoff). - name String + name String /// Free-text specialty, NOT an enum: German practice signage does not /// fit a closed list, and an enum here would force a migration per /// specialty for no reader that needs the closed set. - specialty String? + specialty String? /// Practice or clinic name when it differs from `name`. - practice String? - /// Free-text address / "wo", the `MeasurementReminder.location` shape. - location String? - phone String? + practice String? + /// @internal: legacy readable address, read only as a fallback for rows the + /// free-text encryption backfill has not reached yet (v1.39.4). Every write + /// since then goes to `locationEncrypted` and leaves this NULL; the backfill + /// nulls it on the old rows. DROP this column in a later release, once the + /// backfill reports zero remaining rows on every instance. + location String? + /// @internal: legacy readable phone number, same status as `location` + /// above. DROP this column in a later release, once the backfill reports + /// zero remaining rows on every instance. + phone String? + /// v1.39.4 — AES-256-GCM ciphertext of the free-text address / "wo". Same + /// Bytes codec as the note below. + locationEncrypted Bytes? @map("location_encrypted") + /// v1.39.4 — AES-256-GCM ciphertext of the phone number. + phoneEncrypted Bytes? @map("phone_encrypted") /// Free-text note (encrypted): parking, which entrance, who to ask for. - noteEncrypted Bytes? @map("note_encrypted") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + noteEncrypted Bytes? @map("note_encrypted") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") /// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant. - deletedAt DateTime? @map("deleted_at") + deletedAt DateTime? @map("deleted_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) encounters Encounter[] vaccinations VaccinationRecord[] @@index([userId, deletedAt, name]) + // `practitioners_contact_backfill_idx` is a partial index over + // `(user_id) WHERE phone IS NOT NULL OR location IS NOT NULL` — see + // migration 0361 for the encryption backfill's discovery scan. Raw SQL only + // (partial predicate not expressible in Prisma). @@map("practitioners") } diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index da64e9060..c007fa0d7 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -700,6 +700,35 @@ async function main() { prisma.practitioner, ), ); + // v1.39.4 — the practice's phone number and address. NULL on a field the + // person left empty and on a row the free-text backfill has not reached + // yet; `rotateBytesColumn` skips both. + // "phoneEncrypted" "locationEncrypted" + results.push( + await rotateBytesColumn( + "Practitioner", + "phoneEncrypted", + prisma.practitioner, + ), + ); + results.push( + await rotateBytesColumn( + "Practitioner", + "locationEncrypted", + prisma.practitioner, + ), + ); + + // ───── v1.39.4 workout GPS track (Bytes, binary2, batched) ───── + // One sealed binary value per route, labelled for its purpose. Walked + // through the registry so the binary codec and the label are honoured, in + // id-cursor batches because a track can be close to a megabyte. NULL on a + // row the free-text backfill has not reached yet, which the walk skips. + results.push( + await rotateRegistryColumn("WorkoutRoute", "geometryEncrypted", { + workoutRoute: prisma.workoutRoute, + } as unknown as CorpusClient), + ); // ───── v1.39.3 custom-metric reading note (Bytes column) ───── // NULL on a reading without a note, and on a row the free-text backfill has diff --git a/src/app/api/practitioners/[id]/route.ts b/src/app/api/practitioners/[id]/route.ts index 5d8de3fbb..3377dd7b2 100644 --- a/src/app/api/practitioners/[id]/route.ts +++ b/src/app/api/practitioners/[id]/route.ts @@ -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, }), }, }); diff --git a/src/app/api/practitioners/route.ts b/src/app/api/practitioners/route.ts index cbd8fcabc..39e15a3a2 100644 --- a/src/app/api/practitioners/route.ts +++ b/src/app/api/practitioners/route.ts @@ -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 { 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, }, }); diff --git a/src/app/api/workouts/[id]/route.ts b/src/app/api/workouts/[id]/route.ts index 05d7f2bc5..362f6bef4 100644 --- a/src/app/api/workouts/[id]/route.ts +++ b/src/app/api/workouts/[id]/route.ts @@ -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. diff --git a/src/app/api/workouts/__tests__/batch-create.test.ts b/src/app/api/workouts/__tests__/batch-create.test.ts index 37e971e2a..ae164b7e8 100644 --- a/src/app/api/workouts/__tests__/batch-create.test.ts +++ b/src/app/api/workouts/__tests__/batch-create.test.ts @@ -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], + ], + }); }); }); diff --git a/src/app/api/workouts/__tests__/detail.test.ts b/src/app/api/workouts/__tests__/detail.test.ts index 3b3e280ab..56af87362 100644 --- a/src/app/api/workouts/__tests__/detail.test.ts +++ b/src/app/api/workouts/__tests__/detail.test.ts @@ -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) { + 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 } } }) + .include.route.select.geometryEncrypted, + ).toBe(true); + }); + }); }); /** diff --git a/src/app/api/workouts/batch/route.ts b/src/app/api/workouts/batch/route.ts index 61cfec22c..5e1fe8b95 100644 --- a/src/app/api/workouts/batch/route.ts +++ b/src/app/api/workouts/batch/route.ts @@ -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 { 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 diff --git a/src/lib/crypto/encrypted-columns.ts b/src/lib/crypto/encrypted-columns.ts index 61c335556..56c1b5c55 100644 --- a/src/lib/crypto/encrypted-columns.ts +++ b/src/lib/crypto/encrypted-columns.ts @@ -46,6 +46,13 @@ export type EncryptedColumnKind = "string" | "bytes"; */ export const BACKUP_CHUNK_AAD = "healthlog/data-backup-chunk/v1"; +/** + * The associated-data label a workout's GPS track is sealed with, so a value + * sealed for another purpose never opens as a route and a route never opens as + * anything else. + */ +export const WORKOUT_ROUTE_GEOMETRY_AAD = "healthlog/workout-route-geometry/v1"; + export interface EncryptedColumn { /** Prisma model name (PascalCase, as declared in schema.prisma). */ readonly model: string; @@ -354,6 +361,23 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [ // (`laterality`) stays plaintext: a side alone says nothing. { model: "Encounter", field: "bodySiteEncrypted", kind: "bytes" }, { model: "Practitioner", field: "noteEncrypted", kind: "bytes" }, + // v1.39.4 — the practice's phone number and address. The name and the + // specialty stay plaintext: the picker searches and sorts on them. + { model: "Practitioner", field: "phoneEncrypted", kind: "bytes" }, + { model: "Practitioner", field: "locationEncrypted", kind: "bytes" }, + + // ───── v1.39.4 workout GPS track (Bytes, binary2) ───── + // The GeoJSON LineString of an outdoor workout. Its first and last points + // are usually the person's front door. Sealed as one binary value per route + // (`encryptBytes`, labelled), since a track of 20 000 points is a blob + // rather than a short string; the binary codec implies batched rotation. + { + model: "WorkoutRoute", + field: "geometryEncrypted", + kind: "bytes", + codec: "binary2", + aad: WORKOUT_ROUTE_GEOMETRY_AAD, + }, // ───── v1.38.0 dose free text (Bytes column) ───── // What the person wrote about the dose — a reaction, a sore arm, why the diff --git a/src/lib/export/visits-backup.ts b/src/lib/export/visits-backup.ts index 99f7c8d4b..32ebca5f4 100644 --- a/src/lib/export/visits-backup.ts +++ b/src/lib/export/visits-backup.ts @@ -41,6 +41,8 @@ import type { Laterality, } from "@/generated/prisma/client"; +import { encryptNote } from "@/lib/crypto/note-cipher"; +import { readPractitionerContact } from "@/lib/practitioners/dto"; import { recordUnknownKeys, type RestoreSkipLog, @@ -58,8 +60,17 @@ export interface PractitionerBackupEntry { name: string; specialty: string | null; practice: string | null; + /** + * v1.39.4 — the address and phone number are encrypted at rest. A portable + * export carries them readable here; a disaster-recovery payload leaves + * these null and carries the ciphertext in the two fields below. + */ location: string | null; phone: string | null; + /** Base64 ciphertext of the address, disaster-recovery payloads only. */ + locationEncrypted?: string | null; + /** Base64 ciphertext of the phone number, disaster-recovery payloads only. */ + phoneEncrypted?: string | null; /** Base64 ciphertext, carried verbatim — never decrypted into the file. */ noteEncrypted: string | null; createdAt: string; @@ -134,6 +145,8 @@ const PRACTITIONER_BACKUP_SELECT = { practice: true, location: true, phone: true, + locationEncrypted: true, + phoneEncrypted: true, noteEncrypted: true, createdAt: true, updatedAt: true, @@ -236,8 +249,32 @@ export async function buildVisitsBackupSection( name: row.name, specialty: row.specialty, practice: row.practice, - location: row.location, - phone: row.phone, + // v1.39.4 — a disaster-recovery file carries the contact fields as + // ciphertext only, sealing a row the backfill has not reached yet on + // the way out; a portable export carries them readable. + ...(disasterRecovery + ? { + location: null, + phone: null, + locationEncrypted: encodeCiphertext( + row.locationEncrypted ?? encryptNote(row.location), + ), + phoneEncrypted: encodeCiphertext( + row.phoneEncrypted ?? encryptNote(row.phone), + ), + } + : { + location: readPractitionerContact( + row.locationEncrypted, + row.location, + "location", + ), + phone: readPractitionerContact( + row.phoneEncrypted, + row.phone, + "phone", + ), + }), noteEncrypted: encodeCiphertext(row.noteEncrypted), createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), @@ -336,7 +373,13 @@ export type RestoredPractitioner = Pick< OptionalNullable< Pick< PractitionerBackupEntry, - "specialty" | "practice" | "location" | "phone" | "noteEncrypted" + | "specialty" + | "practice" + | "location" + | "phone" + | "locationEncrypted" + | "phoneEncrypted" + | "noteEncrypted" > > & { deletedAt?: string | null }; @@ -405,8 +448,17 @@ export async function restoreVisitsData( name: entry.name, specialty: entry.specialty ?? null, practice: entry.practice ?? null, - location: entry.location ?? null, - phone: entry.phone ?? null, + // v1.39.4 — ciphertext wins when a disaster-recovery file carried it; + // a portable file's (or an older file's) readable value is sealed + // under this instance's key. The readable columns are never written. + locationEncrypted: + entry.locationEncrypted != null + ? decodeCiphertext(entry.locationEncrypted) + : encryptNote(entry.location), + phoneEncrypted: + entry.phoneEncrypted != null + ? decodeCiphertext(entry.phoneEncrypted) + : encryptNote(entry.phone), noteEncrypted: entry.noteEncrypted == null ? null diff --git a/src/lib/jobs/__tests__/free-text-encryption-backfill.test.ts b/src/lib/jobs/__tests__/free-text-encryption-backfill.test.ts index 49a172cd4..96a3ed459 100644 --- a/src/lib/jobs/__tests__/free-text-encryption-backfill.test.ts +++ b/src/lib/jobs/__tests__/free-text-encryption-backfill.test.ts @@ -1,6 +1,10 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { readNote } from "@/lib/crypto/note-cipher"; import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { + decryptRouteGeometry, + encryptRouteGeometry, +} from "@/lib/workouts/route-geometry-cipher"; // ── in-memory store backing a minimal prisma mock ────────────────────────── interface ConversationRow { @@ -15,10 +19,27 @@ interface EntryRow { note: string | null; noteEncrypted: Uint8Array | null; } +interface PractitionerRow { + id: string; + userId: string; + phone: string | null; + location: string | null; + phoneEncrypted: Uint8Array | null; + locationEncrypted: Uint8Array | null; +} +interface RouteRow { + id: string; + /** The owning workout's user; the real table reaches it through the join. */ + userId: string; + geometry: unknown; + geometryEncrypted: Uint8Array | null; +} const store = vi.hoisted(() => ({ conversations: [] as ConversationRow[], entries: [] as EntryRow[], + practitioners: [] as PractitionerRow[], + routes: [] as RouteRow[], })); vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: () => null })); @@ -27,27 +48,46 @@ vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); vi.mock("@/lib/db", () => { function delegate( rows: () => R[], - readable: keyof R, + readable: Array, ) { return { - // The handler pages on `{ userId, : { not: null } }`. - findMany: async (args: { where: { userId: string }; take: number }) => - rows() - .filter((r) => r.userId === args.where.userId && r[readable] !== null) + // The handler pages on the owner plus "a readable column is set": the + // user id directly, or through the workout for a route. + findMany: async (args: { + where: { userId?: string; workout?: { userId: string } }; + take: number; + }) => { + const userId = args.where.userId ?? args.where.workout?.userId; + return rows() + .filter( + (r) => r.userId === userId && readable.some((k) => r[k] !== null), + ) .slice(0, args.take) - .map((r) => ({ id: r.id })), + .map((r) => ({ id: r.id })); + }, findUnique: async (args: { where: { id: string } }) => rows().find((x) => x.id === args.where.id) ?? null, update: async (args: { where: { id: string }; data: Partial }) => { const r = rows().find((x) => x.id === args.where.id)!; - Object.assign(r, args.data); + // Prisma's DbNull sentinel stores SQL NULL. + const data = Object.fromEntries( + Object.entries(args.data).map(([k, v]) => [ + k, + v !== null && typeof v === "object" && !(v instanceof Uint8Array) + ? null + : v, + ]), + ); + Object.assign(r, data); return r; }, }; } const delegates = { - coachConversation: delegate(() => store.conversations, "title"), - customMetricEntry: delegate(() => store.entries, "note"), + coachConversation: delegate(() => store.conversations, ["title"]), + customMetricEntry: delegate(() => store.entries, ["note"]), + practitioner: delegate(() => store.practitioners, ["phone", "location"]), + workoutRoute: delegate(() => store.routes, ["geometry"]), }; return { prisma: { @@ -61,6 +101,13 @@ vi.mock("@/lib/db", () => { import { runFreeTextEncryptionBackfillForUser } from "@/lib/jobs/free-text-encryption-backfill"; const KEY = "a".repeat(64); +const TRACK = { + type: "LineString", + coordinates: [ + [13.4012, 52.5201], + [13.4051, 52.5233], + ], +}; beforeEach(() => { vi.stubEnv("ENCRYPTION_KEYS", ""); @@ -94,6 +141,52 @@ beforeEach(() => { { id: "e3", userId: "u1", note: "", noteEncrypted: null }, { id: "e4", userId: "u2", note: "other user", noteEncrypted: null }, ]; + store.practitioners = [ + { + id: "p1", + userId: "u1", + phone: "+49 30 1234567", + location: "Hauptstr. 1, Berlin", + phoneEncrypted: null, + locationEncrypted: null, + }, + // Only an address: the phone ciphertext column stays as it was. + { + id: "p2", + userId: "u1", + phone: null, + location: "Am Markt 3", + phoneEncrypted: null, + locationEncrypted: null, + }, + // Already sealed, nothing readable. + { + id: "p3", + userId: "u1", + phone: null, + location: null, + phoneEncrypted: encryptToBytes("+49 40 7654321"), + locationEncrypted: null, + }, + { + id: "p4", + userId: "u2", + phone: "other user", + location: null, + phoneEncrypted: null, + locationEncrypted: null, + }, + ]; + store.routes = [ + { id: "r1", userId: "u1", geometry: TRACK, geometryEncrypted: null }, + { + id: "r2", + userId: "u1", + geometry: null, + geometryEncrypted: encryptRouteGeometry(TRACK), + }, + { id: "r3", userId: "u2", geometry: TRACK, geometryEncrypted: null }, + ]; }); afterEach(() => { @@ -107,6 +200,8 @@ describe("runFreeTextEncryptionBackfillForUser", () => { expect(summary).toEqual({ conversationTitlesMigrated: 1, metricNotesMigrated: 2, + practitionerContactsMigrated: 2, + routeGeometriesMigrated: 1, }); const c1 = store.conversations.find((r) => r.id === "c1")!; @@ -157,6 +252,43 @@ describe("runFreeTextEncryptionBackfillForUser", () => { }); }); + it("seals practitioner phone numbers and addresses and nulls the readable columns", async () => { + await runFreeTextEncryptionBackfillForUser("u1"); + const p1 = store.practitioners.find((r) => r.id === "p1")!; + expect(p1).toMatchObject({ phone: null, location: null }); + expect(readNote(p1.phoneEncrypted, null)).toBe("+49 30 1234567"); + expect(readNote(p1.locationEncrypted, null)).toBe("Hauptstr. 1, Berlin"); + const p2 = store.practitioners.find((r) => r.id === "p2")!; + expect(p2.location).toBeNull(); + expect(p2.phoneEncrypted).toBeNull(); + expect(readNote(p2.locationEncrypted, null)).toBe("Am Markt 3"); + const p3Before = store.practitioners.find( + (r) => r.id === "p3", + )!.phoneEncrypted; + expect(store.practitioners.find((r) => r.id === "p3")!.phoneEncrypted).toBe( + p3Before, + ); + expect(store.practitioners.find((r) => r.id === "p4")).toMatchObject({ + phone: "other user", + phoneEncrypted: null, + }); + }); + + it("seals workout GPS tracks through the workout's owner and nulls the readable column", async () => { + const r2Before = store.routes.find((r) => r.id === "r2")!.geometryEncrypted; + await runFreeTextEncryptionBackfillForUser("u1"); + const r1 = store.routes.find((r) => r.id === "r1")!; + expect(r1.geometry).toBeNull(); + expect(decryptRouteGeometry(r1.geometryEncrypted!)).toEqual(TRACK); + expect(store.routes.find((r) => r.id === "r2")!.geometryEncrypted).toBe( + r2Before, + ); + expect(store.routes.find((r) => r.id === "r3")).toMatchObject({ + geometry: TRACK, + geometryEncrypted: null, + }); + }); + it("is idempotent: a second run changes nothing", async () => { await runFreeTextEncryptionBackfillForUser("u1"); const sealed = store.conversations.find( @@ -166,6 +298,8 @@ describe("runFreeTextEncryptionBackfillForUser", () => { expect(second).toEqual({ conversationTitlesMigrated: 0, metricNotesMigrated: 0, + practitionerContactsMigrated: 0, + routeGeometriesMigrated: 0, }); expect(store.conversations.find((r) => r.id === "c1")!.titleEncrypted).toBe( sealed, diff --git a/src/lib/jobs/free-text-encryption-backfill.ts b/src/lib/jobs/free-text-encryption-backfill.ts index 4966fb2b6..60c238fa4 100644 --- a/src/lib/jobs/free-text-encryption-backfill.ts +++ b/src/lib/jobs/free-text-encryption-backfill.ts @@ -5,6 +5,14 @@ * first Coach message, or a rename) and * `CustomMetricEntry.note` -> `noteEncrypted`. * + * v1.39.4 extends the same pass to three more columns that are not free text + * in the narrow sense but just as personal: + * `Practitioner.phone` -> `phoneEncrypted`, + * `Practitioner.location` -> `locationEncrypted` (the address book of doctors + * and practices), and `WorkoutRoute.geometry` -> `geometryEncrypted` (the GPS + * track of an outdoor workout, sealed as one binary value). A route row has no + * user id of its own; it is found through its workout. + * * Modelled on `med-notes-encryption-backfill.ts`: a discovery query enqueues * one job per user still holding an un-migrated row, the per-user handler walks * that user's rows, and the pass is idempotent across reboots — once a row is @@ -29,7 +37,7 @@ * * The readable columns are NOT dropped in this release. That is a follow-up * release, once this backfill reports zero remaining rows on every instance; - * both columns carry the marker in `schema.prisma`. + * every one of them carries the marker in `schema.prisma`. * * Boot discovery is staggered past the startup storm (`startAfter`), like the * other boot backfills; the daily discovery tick passes no offset. @@ -38,11 +46,13 @@ * (`src/lib/jobs/reminder/register-maintenance.ts`) so pg-boss provisions it at * boot; an unregistered queue silently never drains. */ +import { Prisma } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { encryptNote } from "@/lib/crypto/note-cipher"; +import { encryptRouteGeometry } from "@/lib/workouts/route-geometry-cipher"; export const FREE_TEXT_ENCRYPTION_BACKFILL_QUEUE = "free-text-encryption-backfill"; @@ -62,6 +72,10 @@ export interface FreeTextEncryptionBackfillPayload { export interface FreeTextEncryptionBackfillSummary { conversationTitlesMigrated: number; metricNotesMigrated: number; + /** v1.39.4 — practitioners whose phone and/or address were sealed. */ + practitionerContactsMigrated: number; + /** v1.39.4 — workout GPS tracks sealed. */ + routeGeometriesMigrated: number; } /** @@ -105,6 +119,85 @@ async function migrateMetricNote(id: string): Promise { }); } +/** + * Migrate one practitioner's readable phone number and address into their + * ciphertext columns and null the readable ones, atomically and idempotently. + * A field that is already NULL keeps whatever ciphertext it has. + */ +async function migratePractitionerContact(id: string): Promise { + return prisma.$transaction(async (tx) => { + const fresh = await tx.practitioner.findUnique({ + where: { id }, + select: { phone: true, location: true }, + }); + if (!fresh || (fresh.phone === null && fresh.location === null)) { + return false; + } + await tx.practitioner.update({ + where: { id }, + data: { + ...(fresh.phone !== null + ? { phoneEncrypted: encryptNote(fresh.phone), phone: null } + : {}), + ...(fresh.location !== null + ? { locationEncrypted: encryptNote(fresh.location), location: null } + : {}), + }, + }); + return true; + }); +} + +/** + * Migrate one workout's readable GPS track into `geometryEncrypted` and null + * the readable column, atomically and idempotently. + */ +async function migrateRouteGeometry(id: string): Promise { + return prisma.$transaction(async (tx) => { + const fresh = await tx.workoutRoute.findUnique({ + where: { id }, + select: { geometry: true }, + }); + if (!fresh || fresh.geometry === null) return false; + await tx.workoutRoute.update({ + where: { id }, + data: { + geometryEncrypted: encryptRouteGeometry(fresh.geometry), + geometry: Prisma.DbNull, + }, + }); + return true; + }); +} + +/** + * Walk one kind of row page by page, migrating each. `page` returns the ids + * still holding a readable value; each migration clears it, so a migrated row + * drops out of the next page. A page where nothing moved (every row taken by + * a racing worker) ends the loop, so it always terminates. + */ +async function drain( + page: () => Promise<{ id: string }[]>, + migrate: (id: string) => Promise, + once: (id: string) => void, +): Promise { + let migrated = 0; + for (;;) { + const rows = await page(); + if (rows.length === 0) break; + let migratedThisPage = 0; + for (const { id } of rows) { + once(id); + if (await migrate(id)) { + migrated += 1; + migratedThisPage += 1; + } + } + if (migratedThisPage === 0) break; + } + return migrated; +} + /** * Per-user queue handler. Walks every un-migrated title and note of one * account and migrates each in its own transaction. Safe to re-run. @@ -112,8 +205,6 @@ async function migrateMetricNote(id: string): Promise { export async function runFreeTextEncryptionBackfillForUser( userId: string, ): Promise { - let conversationTitlesMigrated = 0; - let metricNotesMigrated = 0; // Every row this pass has sealed. A row that comes back on a later page was // not cleared, so the pass would never converge; it stops loudly instead of // re-sealing the same rows forever. @@ -127,45 +218,51 @@ export async function runFreeTextEncryptionBackfillForUser( sealed.add(id); }; - // Each migration nulls the readable column, so a migrated row drops out of - // the next `IS NOT NULL` page. - for (;;) { - const rows = await prisma.coachConversation.findMany({ - where: { userId, title: { not: null } }, - select: { id: true }, - take: PAGE_SIZE, - }); - if (rows.length === 0) break; - let migratedThisPage = 0; - for (const { id } of rows) { - once(id); - if (await migrateConversationTitle(id)) { - conversationTitlesMigrated += 1; - migratedThisPage += 1; - } - } - // A page where nothing moved (every row taken by a racing worker) ends - // the loop, so it always terminates. - if (migratedThisPage === 0) break; - } - - for (;;) { - const rows = await prisma.customMetricEntry.findMany({ - where: { userId, note: { not: null } }, - select: { id: true }, - take: PAGE_SIZE, - }); - if (rows.length === 0) break; - let migratedThisPage = 0; - for (const { id } of rows) { - once(id); - if (await migrateMetricNote(id)) { - metricNotesMigrated += 1; - migratedThisPage += 1; - } - } - if (migratedThisPage === 0) break; - } + const conversationTitlesMigrated = await drain( + () => + prisma.coachConversation.findMany({ + where: { userId, title: { not: null } }, + select: { id: true }, + take: PAGE_SIZE, + }), + migrateConversationTitle, + once, + ); + const metricNotesMigrated = await drain( + () => + prisma.customMetricEntry.findMany({ + where: { userId, note: { not: null } }, + select: { id: true }, + take: PAGE_SIZE, + }), + migrateMetricNote, + once, + ); + const practitionerContactsMigrated = await drain( + () => + prisma.practitioner.findMany({ + where: { + userId, + OR: [{ phone: { not: null } }, { location: { not: null } }], + }, + select: { id: true }, + take: PAGE_SIZE, + }), + migratePractitionerContact, + once, + ); + // A track is the one large value here. The page holds ids only; each track + // is read inside its own transaction, so no more than one is in memory. + const routeGeometriesMigrated = await drain( + () => + prisma.workoutRoute.findMany({ + where: { workout: { userId }, geometry: { not: Prisma.DbNull } }, + select: { id: true }, + take: PAGE_SIZE, + }), + migrateRouteGeometry, + once, + ); annotate({ action: { @@ -173,17 +270,24 @@ export async function runFreeTextEncryptionBackfillForUser( details: { conversation_titles_migrated: conversationTitlesMigrated, metric_notes_migrated: metricNotesMigrated, + practitioner_contacts_migrated: practitionerContactsMigrated, + route_geometries_migrated: routeGeometriesMigrated, }, }, }); - return { conversationTitlesMigrated, metricNotesMigrated }; + return { + conversationTitlesMigrated, + metricNotesMigrated, + practitionerContactsMigrated, + routeGeometriesMigrated, + }; } /** - * Discovery. Finds every user still holding a readable title or note and - * enqueues one backfill job per account. The 0357 partial indexes match both - * predicates, so the scan is index-only and converges to empty. pg-boss + * Discovery. Finds every user still holding a readable value in any of the + * columns above and enqueues one backfill job per account. The 0357 and 0361 + * partial indexes match the predicates, so the scan is index-only and converges to empty. pg-boss * `singletonKey` coalesces duplicate sends. Best-effort: errors come back * through the result value so worker boot never fails on a miss. */ @@ -197,18 +301,26 @@ export async function enqueueBootTimeFreeTextEncryptionBackfill( } try { - const [titleUsers, noteUsers] = await Promise.all([ + const found = await Promise.all([ prisma.$queryRaw<{ user_id: string }[]>` SELECT DISTINCT user_id FROM coach_conversations WHERE title IS NOT NULL`, prisma.$queryRaw<{ user_id: string }[]>` SELECT DISTINCT user_id FROM custom_metric_entries WHERE note IS NOT NULL`, + prisma.$queryRaw<{ user_id: string }[]>` + SELECT DISTINCT user_id FROM practitioners + WHERE phone IS NOT NULL OR location IS NOT NULL`, + prisma.$queryRaw<{ user_id: string }[]>` + SELECT DISTINCT w.user_id FROM workout_routes r + JOIN workouts w ON w.id = r.workout_id + WHERE r.geometry IS NOT NULL`, ]); const userIds = new Set(); - for (const { user_id } of titleUsers) userIds.add(user_id); - for (const { user_id } of noteUsers) userIds.add(user_id); + for (const rows of found) { + for (const { user_id } of rows) userIds.add(user_id); + } let enqueued = 0; let skipped = 0; diff --git a/src/lib/jobs/job-outcome.ts b/src/lib/jobs/job-outcome.ts index 808cbea1b..64ef018c4 100644 --- a/src/lib/jobs/job-outcome.ts +++ b/src/lib/jobs/job-outcome.ts @@ -158,6 +158,7 @@ export const JOB_FACT_ALLOWLIST: ReadonlySet = new Set([ "per_sample_rows_soft_deleted", "persisted", "plan_reviews_minted", + "practitioner_contacts_migrated", "pr_detection_inserted", "pr_detection_jobs", "pr_detection_ties", @@ -192,6 +193,7 @@ export const JOB_FACT_ALLOWLIST: ReadonlySet = new Set([ "retried", "reviewed", "rollup_failed", + "route_geometries_migrated", "rotate_dropped", "rotate_errors", "rotate_rotated", diff --git a/src/lib/jobs/reminder/register-maintenance.ts b/src/lib/jobs/reminder/register-maintenance.ts index efe8c09ba..83828963a 100644 --- a/src/lib/jobs/reminder/register-maintenance.ts +++ b/src/lib/jobs/reminder/register-maintenance.ts @@ -355,7 +355,8 @@ const MED_NOTES_ENCRYPTION_BACKFILL_CRON = "5 4 * * *"; // past the startup storm) enqueues one per-user job; a daily 04:07 // Europe/Berlin discovery tick re-fans for rows written by a previous-release // process after the upgrade. Two minutes after the medication-note tick so the -// two do not share a poll. +// two do not share a poll. v1.39.4 adds the practitioner phone + address and +// the workout GPS track to the same pass. const FREE_TEXT_ENCRYPTION_BACKFILL_CRON = "7 4 * * *"; const allQueues = [ @@ -687,7 +688,7 @@ const queuePolicies: QueuePolicyTable = { [FREE_TEXT_ENCRYPTION_BACKFILL_QUEUE]: { policy: "exclusive", reason: - "Per-user Coach title + custom-metric note encryption backfill; discovery drops the user once no readable value remains.", + "Per-user encryption backfill (Coach title, custom-metric note, practitioner phone + address, workout GPS track); discovery drops the user once no readable value remains.", }, [DOCUMENT_THUMBNAIL_BACKFILL_QUEUE]: { policy: "exclusive", @@ -1246,6 +1247,8 @@ export async function registerMaintenanceQueues( let discoveryFailed = 0; let titles = 0; let notes = 0; + let contacts = 0; + let routes = 0; for (const job of jobs) { const { userId } = job.data; if (!userId) { @@ -1260,14 +1263,20 @@ export async function registerMaintenanceQueues( continue; } try { - const { conversationTitlesMigrated, metricNotesMigrated } = - await runFreeTextEncryptionBackfillForUser(userId); + const { + conversationTitlesMigrated, + metricNotesMigrated, + practitionerContactsMigrated, + routeGeometriesMigrated, + } = await runFreeTextEncryptionBackfillForUser(userId); users++; titles += conversationTitlesMigrated; notes += metricNotesMigrated; + contacts += practitionerContactsMigrated; + routes += routeGeometriesMigrated; workerLog( "info", - `[free-text-encryption-backfill] user=${userId} titles=${conversationTitlesMigrated} notes=${metricNotesMigrated}`, + `[free-text-encryption-backfill] user=${userId} titles=${conversationTitlesMigrated} notes=${metricNotesMigrated} contacts=${practitionerContactsMigrated} routes=${routeGeometriesMigrated}`, ); } catch (err) { recordError(); @@ -1287,6 +1296,8 @@ export async function registerMaintenanceQueues( discovery_failed: discoveryFailed, conversation_titles_migrated: titles, metric_notes_migrated: notes, + practitioner_contacts_migrated: contacts, + route_geometries_migrated: routes, }); }, ); diff --git a/src/lib/jobs/workout-insight-generate.ts b/src/lib/jobs/workout-insight-generate.ts index cc1b29168..cff606fe0 100644 --- a/src/lib/jobs/workout-insight-generate.ts +++ b/src/lib/jobs/workout-insight-generate.ts @@ -67,6 +67,7 @@ import { meetsDurationFloor, workoutInsightInputHash, } from "@/lib/workouts/insight-gates"; +import { readRouteGeometry } from "@/lib/workouts/route-geometry-cipher"; import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets"; import { computeZones, @@ -245,7 +246,7 @@ export async function runWorkoutInsightGenerate( minHeartRate: true, elevationM: true, metadata: true, - route: { select: { geometry: true } }, + route: { select: { geometry: true, geometryEncrypted: true } }, samples: { select: { samples: true } }, }, }); @@ -372,7 +373,9 @@ export async function runWorkoutInsightGenerate( tz, hrSeries, zones, - routeGeometry: row.route?.geometry ?? null, + // Opened in memory only to derive metres climbed; the track itself + // never reaches the prompt. + routeGeometry: row.route ? readRouteGeometry(row.route) : null, history, }); const inputHash = workoutInsightInputHash( diff --git a/src/lib/practitioners/dto.ts b/src/lib/practitioners/dto.ts index d20e35560..145a6979f 100644 --- a/src/lib/practitioners/dto.ts +++ b/src/lib/practitioners/dto.ts @@ -5,6 +5,13 @@ * them. The free-text note lives in `noteEncrypted` (Bytes) and is decrypted * fail-soft on read, so a key-rotation gap on one row reads as a missing note * rather than 500-ing the address book. + * + * v1.39.4 — the phone number and the address follow the note into Bytes + * ciphertext (`phoneEncrypted`, `locationEncrypted`). They read the same way, + * with one addition: a row the boot-time backfill has not reached yet still + * holds them in the readable columns, which are read only when there is no + * ciphertext. A ciphertext that does not open reads as absent, never as the + * readable column. */ import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; import { getEvent } from "@/lib/logging/context"; @@ -39,14 +46,40 @@ export function decryptPractitionerNote( } } +/** + * A contact field (phone, address): the ciphertext when the row has one, the + * legacy readable column otherwise. Fail-soft on the ciphertext, like the note. + */ +export function readPractitionerContact( + ciphertext: Uint8Array | null, + legacy: string | null, + field: "phone" | "location", +): string | null { + if (!ciphertext || ciphertext.byteLength === 0) return legacy; + try { + return decryptFromBytes(ciphertext); + } catch (err) { + getEvent()?.addWarning( + `practitioner ${field} decrypt failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + return null; + } +} + export function toPractitionerDTO(row: Practitioner): PractitionerDTO { return { id: row.id, name: row.name, specialty: row.specialty, practice: row.practice, - location: row.location, - phone: row.phone, + location: readPractitionerContact( + row.locationEncrypted, + row.location, + "location", + ), + phone: readPractitionerContact(row.phoneEncrypted, row.phone, "phone"), note: decryptPractitionerNote(row.noteEncrypted), createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 51c02fb12..40fbd9397 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -1109,6 +1109,9 @@ const practitionerBackupSchema = z practice: z.string().nullable().optional(), location: z.string().nullable().optional(), phone: z.string().nullable().optional(), + /// v1.39.4 — the address and phone ciphertext, DR payloads only. + locationEncrypted: base64BytesSchema.nullable().optional(), + phoneEncrypted: base64BytesSchema.nullable().optional(), noteEncrypted: base64BytesSchema.nullable().optional(), createdAt: isoDateTime, updatedAt: isoDateTime, diff --git a/src/lib/workouts/__tests__/route-geometry-cipher.test.ts b/src/lib/workouts/__tests__/route-geometry-cipher.test.ts new file mode 100644 index 000000000..9432d0976 --- /dev/null +++ b/src/lib/workouts/__tests__/route-geometry-cipher.test.ts @@ -0,0 +1,66 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Buffer } from "node:buffer"; + +import { _resetCryptoCacheForTests, encryptBytes } from "@/lib/crypto"; +import { + decryptRouteGeometry, + encryptRouteGeometry, + readRouteGeometry, +} from "../route-geometry-cipher"; + +const TRACK = { + type: "LineString", + coordinates: [ + [13.4012, 52.5201, 34.5], + [13.4051, 52.5233, 36], + ], +}; + +beforeEach(() => { + vi.stubEnv("ENCRYPTION_KEYS", ""); + vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", ""); + vi.stubEnv("ENCRYPTION_KEY", "d".repeat(64)); + _resetCryptoCacheForTests(); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + _resetCryptoCacheForTests(); +}); + +describe("route geometry cipher", () => { + it("round-trips a track and keeps no coordinate readable in the sealed bytes", () => { + const sealed = encryptRouteGeometry(TRACK); + expect(Buffer.from(sealed).toString("latin1")).not.toContain("52.5201"); + expect(decryptRouteGeometry(sealed)).toEqual(TRACK); + }); + + it("prefers the ciphertext, falls back to the legacy column, and is null for neither", () => { + const sealed = encryptRouteGeometry(TRACK); + expect( + readRouteGeometry({ + geometry: { stale: true }, + geometryEncrypted: sealed, + }), + ).toEqual(TRACK); + expect( + readRouteGeometry({ geometry: TRACK, geometryEncrypted: null }), + ).toBe(TRACK); + expect(readRouteGeometry({ geometry: null, geometryEncrypted: null })).toBe( + null, + ); + }); + + it("does not open a value sealed for another purpose", () => { + const other = encryptBytes(Buffer.from(JSON.stringify(TRACK))); + expect(() => decryptRouteGeometry(new Uint8Array(other))).toThrow(); + }); + + it("throws on a ciphertext that does not open instead of using the legacy column", () => { + const sealed = encryptRouteGeometry(TRACK); + sealed[sealed.byteLength - 1] ^= 0xff; + expect(() => + readRouteGeometry({ geometry: TRACK, geometryEncrypted: sealed }), + ).toThrow(); + }); +}); diff --git a/src/lib/workouts/route-geometry-cipher.ts b/src/lib/workouts/route-geometry-cipher.ts new file mode 100644 index 000000000..7b52fc99e --- /dev/null +++ b/src/lib/workouts/route-geometry-cipher.ts @@ -0,0 +1,56 @@ +/** + * Workout GPS track ↔ encrypted column (v1.39.4). + * + * `WorkoutRoute.geometry` held the GeoJSON LineString of every outdoor workout + * readable in the database, and the first and last points of most tracks are + * the person's front door. From v1.39.4 the track is stored in + * `geometryEncrypted`: the JSON text sealed as one binary AES-256-GCM value + * (`encryptBytes`, labelled with {@link WORKOUT_ROUTE_GEOMETRY_AAD}). The + * binary codec rather than the base64 string codec the notes use, because a + * track of 20 000 points is a blob and base64 would add a third to it. + * + * Every reader goes through {@link readRouteGeometry}: the ciphertext when a + * row has one, the legacy readable column for a row the boot-time backfill has + * not reached yet. A ciphertext that does not open throws (fail-closed), the + * same posture as `readNote`; it is never replaced by the readable column. + * + * The wire shape is unchanged: readers hand back the same GeoJSON object the + * JSONB column used to return. + */ +import { Buffer } from "node:buffer"; + +import { decryptBytes, encryptBytes } from "@/lib/crypto"; +import { WORKOUT_ROUTE_GEOMETRY_AAD } from "@/lib/crypto/encrypted-columns"; + +/** Seal a route's GeoJSON geometry for `WorkoutRoute.geometryEncrypted`. */ +export function encryptRouteGeometry( + geometry: unknown, +): Uint8Array { + const sealed = encryptBytes( + Buffer.from(JSON.stringify(geometry), "utf8"), + WORKOUT_ROUTE_GEOMETRY_AAD, + ); + const out = new Uint8Array(new ArrayBuffer(sealed.byteLength)); + out.set(sealed); + return out; +} + +/** Open a sealed route geometry. Throws on a bad key id or a tampered value. */ +export function decryptRouteGeometry(sealed: Uint8Array): unknown { + const plain = decryptBytes(Buffer.from(sealed), WORKOUT_ROUTE_GEOMETRY_AAD); + return JSON.parse(plain.toString("utf8")) as unknown; +} + +/** + * The geometry a route row holds: the ciphertext first, the legacy readable + * column only when there is no ciphertext. Null when the row holds neither. + */ +export function readRouteGeometry(row: { + geometry?: unknown; + geometryEncrypted?: Uint8Array | null; +}): unknown { + if (row.geometryEncrypted && row.geometryEncrypted.byteLength > 0) { + return decryptRouteGeometry(row.geometryEncrypted); + } + return row.geometry ?? null; +} diff --git a/tests/integration/encryption-key-rotation-contact-and-route.test.ts b/tests/integration/encryption-key-rotation-contact-and-route.test.ts new file mode 100644 index 000000000..30810105f --- /dev/null +++ b/tests/integration/encryption-key-rotation-contact-and-route.test.ts @@ -0,0 +1,148 @@ +/** + * v1.39.4 — key rotation covers the practitioner phone number and address and + * the workout GPS track, against real Postgres. + * + * Rows are sealed under the old key, the registry walk (the one the in-app + * rotation and the CLI script share) re-seals them under the new one, and they + * still open once the old key is gone from the deployment. The route is the + * binary, labelled codec: a walk that re-sealed it with the string codec, or + * without its label, would leave a value that no reader can open. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +process.env.ENCRYPTION_KEYS = JSON.stringify({ + v1: "1".repeat(64), + v2: "2".repeat(64), +}); +process.env.ENCRYPTION_ACTIVE_KEY_ID = "v1"; +delete process.env.ENCRYPTION_KEY; + +import { + _resetCryptoCacheForTests, + extractKeyId, + extractKeyIdFromBytes, +} from "@/lib/crypto"; +import { + ENCRYPTED_COLUMNS, + type EncryptedColumn, +} from "@/lib/crypto/encrypted-columns"; +import { + rotateColumn, + type CorpusClient, +} from "@/lib/crypto/encryption-corpus"; +import { encryptNote, readNote } from "@/lib/crypto/note-cipher"; +import { + decryptRouteGeometry, + encryptRouteGeometry, +} from "@/lib/workouts/route-geometry-cipher"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const USER_ID = "user-rotation-contact-route"; +const TRACK = { + type: "LineString", + coordinates: [ + [13.4012, 52.5201, 34], + [13.4051, 52.5233, 36], + ], +}; + +function column(model: string, field: string): EncryptedColumn { + const col = ENCRYPTED_COLUMNS.find( + (c) => c.model === model && c.field === field, + ); + if (!col) throw new Error(`${model}.${field} is not registered`); + return col; +} + +function activate(keyId: string) { + process.env.ENCRYPTION_ACTIVE_KEY_ID = keyId; + _resetCryptoCacheForTests(); +} + +beforeEach(async () => { + process.env.ENCRYPTION_KEYS = JSON.stringify({ + v1: "1".repeat(64), + v2: "2".repeat(64), + }); + activate("v1"); + await truncateAllTables(getPrismaClient()); + await getPrismaClient().user.create({ + data: { + id: USER_ID, + username: "rotation-contact-route", + email: "rotation-contact-route@example.test", + timezone: "Europe/Berlin", + }, + }); +}); + +describe("key rotation over the v1.39.4 columns", () => { + it("re-seals practitioner contacts and workout tracks under the active key", async () => { + const prisma = getPrismaClient(); + await prisma.practitioner.create({ + data: { + id: "pr-1", + userId: USER_ID, + name: "Praxis Nord", + phoneEncrypted: encryptNote("+49 30 1234567"), + locationEncrypted: encryptNote("Hauptstr. 1"), + }, + }); + const startedAt = new Date("2026-09-01T06:00:00.000Z"); + await prisma.workout.create({ + data: { + id: "wk-1", + userId: USER_ID, + sportType: "running", + startedAt, + endedAt: new Date(startedAt.getTime() + 30 * 60_000), + durationSec: 1800, + route: { + create: { + id: "rt-1", + geometryEncrypted: encryptRouteGeometry(TRACK), + }, + }, + }, + }); + + activate("v2"); + const client = { + practitioner: prisma.practitioner, + workoutRoute: prisma.workoutRoute, + } as unknown as CorpusClient; + for (const [model, field] of [ + ["Practitioner", "phoneEncrypted"], + ["Practitioner", "locationEncrypted"], + ["WorkoutRoute", "geometryEncrypted"], + ] as const) { + const result = await rotateColumn(client, column(model, field)); + expect(result).toMatchObject({ scanned: 1, rotated: 1, errors: 0 }); + const again = await rotateColumn(client, column(model, field)); + expect(again.rotated).toBe(0); + } + + // The old key is gone; everything still opens. + process.env.ENCRYPTION_KEYS = JSON.stringify({ v2: "2".repeat(64) }); + _resetCryptoCacheForTests(); + const practitioner = await prisma.practitioner.findUniqueOrThrow({ + where: { id: "pr-1" }, + }); + for (const sealed of [ + practitioner.phoneEncrypted!, + practitioner.locationEncrypted!, + ]) { + expect(extractKeyId(Buffer.from(sealed).toString("utf8"))).toBe("v2"); + } + expect(readNote(practitioner.phoneEncrypted, null)).toBe("+49 30 1234567"); + expect(readNote(practitioner.locationEncrypted, null)).toBe("Hauptstr. 1"); + + const route = await prisma.workoutRoute.findUniqueOrThrow({ + where: { id: "rt-1" }, + }); + expect(extractKeyIdFromBytes(Buffer.from(route.geometryEncrypted!))).toBe( + "v2", + ); + expect(decryptRouteGeometry(route.geometryEncrypted!)).toEqual(TRACK); + }); +}); diff --git a/tests/integration/free-text-encryption-backfill.test.ts b/tests/integration/free-text-encryption-backfill.test.ts index 30c2cce0b..7ce676292 100644 --- a/tests/integration/free-text-encryption-backfill.test.ts +++ b/tests/integration/free-text-encryption-backfill.test.ts @@ -10,12 +10,19 @@ * column, and reads back the same text; * - a second pass and a second discovery find nothing (it converges); * - a row written the current way is left as it was. + * + * v1.39.4 adds the practitioner phone number and address and the workout GPS + * track (migration 0361) to the same pass; the second case covers them. */ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { setGlobalBoss } from "@/lib/jobs/boss-instance"; import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { readNote } from "@/lib/crypto/note-cipher"; +import { + decryptRouteGeometry, + encryptRouteGeometry, +} from "@/lib/workouts/route-geometry-cipher"; import { enqueueBootTimeFreeTextEncryptionBackfill, runFreeTextEncryptionBackfillForUser, @@ -113,10 +120,14 @@ describe("free-text encryption backfill (real Postgres)", () => { expect(await runFreeTextEncryptionBackfillForUser("ft-a")).toEqual({ conversationTitlesMigrated: 1, metricNotesMigrated: 0, + practitionerContactsMigrated: 0, + routeGeometriesMigrated: 0, }); expect(await runFreeTextEncryptionBackfillForUser("ft-b")).toEqual({ conversationTitlesMigrated: 0, metricNotesMigrated: 1, + practitionerContactsMigrated: 0, + routeGeometriesMigrated: 0, }); const conversations = await prisma.coachConversation.findMany({ @@ -158,9 +169,177 @@ describe("free-text encryption backfill (real Postgres)", () => { expect(await runFreeTextEncryptionBackfillForUser("ft-a")).toEqual({ conversationTitlesMigrated: 0, metricNotesMigrated: 0, + practitionerContactsMigrated: 0, + routeGeometriesMigrated: 0, }); }); + it("seals practitioner contacts and workout GPS tracks, and converges", async () => { + const prisma = getPrismaClient(); + await Promise.all([seedUser("pc-a"), seedUser("pc-b"), seedUser("pc-c")]); + const track = { + type: "LineString", + coordinates: [ + [13.4012, 52.5201, 34], + [13.4051, 52.5233, 36], + ], + }; + + // pc-a: a legacy practitioner with both fields, one with only an address. + await prisma.practitioner.create({ + data: { + id: "pr-both", + userId: "pc-a", + name: "Praxis Nord", + phone: "+49 30 1234567", + location: "Hauptstr. 1", + }, + }); + await prisma.practitioner.create({ + data: { + id: "pr-loc", + userId: "pc-a", + name: "Praxis Sued", + location: "Am Markt 3", + }, + }); + // pc-b: a legacy readable track, found through its workout. + const startedAt = new Date("2026-09-01T06:00:00.000Z"); + await prisma.workout.create({ + data: { + id: "wk-legacy", + userId: "pc-b", + sportType: "running", + startedAt, + endedAt: new Date(startedAt.getTime() + 30 * 60_000), + durationSec: 1800, + route: { create: { id: "rt-legacy", geometry: track } }, + }, + }); + // pc-c: only rows written the current way. + await prisma.practitioner.create({ + data: { + userId: "pc-c", + name: "Praxis West", + phoneEncrypted: encryptToBytes("+49 40 7654321"), + }, + }); + await prisma.workout.create({ + data: { + id: "wk-sealed", + userId: "pc-c", + sportType: "running", + startedAt, + endedAt: new Date(startedAt.getTime() + 30 * 60_000), + durationSec: 1800, + route: { + create: { + id: "rt-sealed", + geometryEncrypted: encryptRouteGeometry(track), + }, + }, + }, + }); + + const first = makeCapturingBoss(); + setGlobalBoss(first.boss as never); + expect( + (await enqueueBootTimeFreeTextEncryptionBackfill()).error, + ).toBeNull(); + expect(first.sent.map((s) => s.userId).sort()).toEqual(["pc-a", "pc-b"]); + + expect(await runFreeTextEncryptionBackfillForUser("pc-a")).toEqual({ + conversationTitlesMigrated: 0, + metricNotesMigrated: 0, + practitionerContactsMigrated: 2, + routeGeometriesMigrated: 0, + }); + expect(await runFreeTextEncryptionBackfillForUser("pc-b")).toEqual({ + conversationTitlesMigrated: 0, + metricNotesMigrated: 0, + practitionerContactsMigrated: 0, + routeGeometriesMigrated: 1, + }); + + const practitioners = await prisma.practitioner.findMany({ + where: { userId: "pc-a" }, + orderBy: { id: "asc" }, + }); + expect( + practitioners.map((p) => ({ + id: p.id, + phone: p.phone, + location: p.location, + phoneText: readNote(p.phoneEncrypted, null), + locationText: readNote(p.locationEncrypted, null), + })), + ).toEqual([ + { + id: "pr-both", + phone: null, + location: null, + phoneText: "+49 30 1234567", + locationText: "Hauptstr. 1", + }, + { + id: "pr-loc", + phone: null, + location: null, + phoneText: null, + locationText: "Am Markt 3", + }, + ]); + + // The track is SQL NULL in the readable column, not a JSON null, and the + // sealed value does not carry a coordinate readable on disk. + const onDisk = await prisma.$queryRaw< + { readable_is_null: boolean; leaks: boolean }[] + >` + SELECT geometry IS NULL AS readable_is_null, + position(convert_to('52.5201', 'UTF8') IN geometry_encrypted) > 0 + AS leaks + FROM workout_routes WHERE id = 'rt-legacy'`; + expect(onDisk).toEqual([{ readable_is_null: true, leaks: false }]); + const route = await prisma.workoutRoute.findUniqueOrThrow({ + where: { id: "rt-legacy" }, + }); + expect(decryptRouteGeometry(route.geometryEncrypted!)).toEqual(track); + + // Converged. + const second = makeCapturingBoss(); + setGlobalBoss(second.boss as never); + expect( + (await enqueueBootTimeFreeTextEncryptionBackfill()).error, + ).toBeNull(); + expect(second.sent).toEqual([]); + expect(await runFreeTextEncryptionBackfillForUser("pc-b")).toEqual({ + conversationTitlesMigrated: 0, + metricNotesMigrated: 0, + practitionerContactsMigrated: 0, + routeGeometriesMigrated: 0, + }); + }); + + it("has the partial indexes migration 0361 adds", async () => { + const indexes = await getPrismaClient().$queryRaw< + { indexname: string; indexdef: string }[] + >` + SELECT indexname, indexdef FROM pg_indexes + WHERE indexname IN ( + 'practitioners_contact_backfill_idx', + 'workout_routes_geometry_backfill_idx' + ) + ORDER BY indexname`; + expect(indexes.map((i) => i.indexname)).toEqual([ + "practitioners_contact_backfill_idx", + "workout_routes_geometry_backfill_idx", + ]); + expect(indexes[0].indexdef).toContain( + "WHERE ((phone IS NOT NULL) OR (location IS NOT NULL))", + ); + expect(indexes[1].indexdef).toContain("WHERE (geometry IS NOT NULL)"); + }); + it("uses the partial indexes migration 0357 adds", async () => { const indexes = await getPrismaClient().$queryRaw< { indexname: string; indexdef: string }[] diff --git a/tests/integration/practitioner-contact-encryption.test.ts b/tests/integration/practitioner-contact-encryption.test.ts new file mode 100644 index 000000000..d98363add --- /dev/null +++ b/tests/integration/practitioner-contact-encryption.test.ts @@ -0,0 +1,325 @@ +/** + * v1.39.4 — a practitioner's phone number and address are encrypted at rest, + * against real Postgres. + * + * - a create and an edit store ciphertext only: the readable columns stay + * NULL and the stored bytes do not contain the number; + * - the wire shape is unchanged, and a row the backfill has not reached yet + * still reads back from its legacy columns; + * - an edit's audit row names the changed contact field without its value; + * - a disaster-recovery backup carries the fields as ciphertext only, a + * portable export carries them readable, and a restore from either (or + * from a file written before this release) writes ciphertext only. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { GET as listPractitioners, POST } from "@/app/api/practitioners/route"; +import { PATCH } from "@/app/api/practitioners/[id]/route"; +import { + buildVisitsBackupSection, + restoreVisitsData, +} from "@/lib/export/visits-backup"; +import { readNote } from "@/lib/crypto/note-cipher"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const OWNER_ID = "practitioner-contact-owner"; +const PHONE = "+49 30 9876543"; +const ADDRESS = "Lindenweg 12, 10115 Berlin"; + +async function seedSession() { + const prisma = getPrismaClient(); + await prisma.user.create({ + data: { + id: OWNER_ID, + username: OWNER_ID, + email: `${OWNER_ID}@example.test`, + timezone: "Europe/Berlin", + }, + }); + const session = await prisma.session.create({ + data: { + userId: OWNER_ID, + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + mfaVerifiedAt: new Date(), + }, + }); + cookieJar.set("healthlog_session", session.id); +} + +function jsonRequest(url: string, method: string, body: unknown) { + return new Request(url, { + method, + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }) as never; +} + +/** The stored row as SQL sees it: readable columns and a leak probe. */ +async function onDisk(id: string, needle: string) { + const rows = await getPrismaClient().$queryRaw< + { + phone: string | null; + location: string | null; + phone_leaks: boolean; + location_leaks: boolean; + }[] + >` + SELECT phone, location, + COALESCE(position(convert_to(${needle}, 'UTF8') IN phone_encrypted) > 0, false) + AS phone_leaks, + COALESCE(position(convert_to(${needle}, 'UTF8') IN location_encrypted) > 0, false) + AS location_leaks + FROM practitioners WHERE id = ${id}`; + return rows[0]; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + await seedSession(); +}); + +describe("practitioner phone and address at rest", () => { + it("stores a created practitioner's contact fields as ciphertext only and serves them unchanged", async () => { + const res = await POST( + jsonRequest("http://localhost/api/practitioners", "POST", { + name: "Dr. Weber", + specialty: "Kardiologie", + phone: PHONE, + location: ADDRESS, + }), + ); + expect(res.status).toBe(201); + const created = (await res.json()).data as { + id: string; + phone: string | null; + location: string | null; + }; + expect(created).toMatchObject({ phone: PHONE, location: ADDRESS }); + + expect(await onDisk(created.id, "9876543")).toEqual({ + phone: null, + location: null, + phone_leaks: false, + location_leaks: false, + }); + expect(await onDisk(created.id, "Lindenweg")).toMatchObject({ + location_leaks: false, + }); + + const list = await listPractitioners( + new Request("http://localhost/api/practitioners") as never, + ); + const rows = (await list.json()).data as { + name: string; + phone: string | null; + location: string | null; + }[]; + expect(rows).toEqual([ + expect.objectContaining({ + name: "Dr. Weber", + phone: PHONE, + location: ADDRESS, + }), + ]); + }); + + it("reads a row the backfill has not reached from its legacy columns", async () => { + await getPrismaClient().practitioner.create({ + data: { + userId: OWNER_ID, + name: "Dr. Alt", + phone: PHONE, + location: ADDRESS, + }, + }); + const list = await listPractitioners( + new Request("http://localhost/api/practitioners") as never, + ); + const rows = (await list.json()).data as { + phone: string | null; + location: string | null; + }[]; + expect(rows[0]).toMatchObject({ phone: PHONE, location: ADDRESS }); + }); + + it("seals an edited field, clears its legacy column, and audits the change without the value", async () => { + const prisma = getPrismaClient(); + const legacy = await prisma.practitioner.create({ + data: { + userId: OWNER_ID, + name: "Dr. Alt", + phone: "+49 30 1111111", + location: ADDRESS, + }, + }); + + const res = await PATCH( + jsonRequest(`http://localhost/api/practitioners/${legacy.id}`, "PATCH", { + phone: PHONE, + }), + { params: Promise.resolve({ id: legacy.id }) }, + ); + expect(res.status).toBe(200); + expect((await res.json()).data).toMatchObject({ + phone: PHONE, + // Untouched by the edit: still served from the legacy column. + location: ADDRESS, + }); + + const row = await prisma.practitioner.findUniqueOrThrow({ + where: { id: legacy.id }, + }); + expect(row.phone).toBeNull(); + expect(readNote(row.phoneEncrypted, null)).toBe(PHONE); + expect(row.location).toBe(ADDRESS); + + const audit = await prisma.auditLog.findFirstOrThrow({ + where: { userId: OWNER_ID, action: "practitioner.contact.update" }, + }); + const details = JSON.parse(audit.details ?? "{}") as { + fields?: string[]; + previous?: Record; + }; + expect(details.fields).toEqual(["phone"]); + expect(details.previous ?? {}).not.toHaveProperty("phone"); + expect(audit.details).not.toContain("1111111"); + expect(audit.details).not.toContain("9876543"); + }); + + it("carries the contact fields as ciphertext in a disaster-recovery backup, readable in a portable one, and restores ciphertext only", async () => { + const prisma = getPrismaClient(); + // One legacy row, one sealed through the route. + await prisma.practitioner.create({ + data: { + id: "pr-legacy", + userId: OWNER_ID, + name: "Dr. Alt", + phone: PHONE, + location: ADDRESS, + }, + }); + const created = await POST( + jsonRequest("http://localhost/api/practitioners", "POST", { + name: "Dr. Neu", + phone: PHONE, + }), + ); + const sealedId = ((await created.json()).data as { id: string }).id; + + const dr = await buildVisitsBackupSection(prisma, OWNER_ID, { + purpose: "disaster-recovery", + }); + const drFile = JSON.stringify(dr); + expect(drFile).not.toContain("9876543"); + expect(drFile).not.toContain("Lindenweg"); + for (const entry of dr.practitioners) { + expect(entry.phone).toBeNull(); + expect(entry.location).toBeNull(); + expect(typeof entry.phoneEncrypted).toBe("string"); + } + + const portable = await buildVisitsBackupSection(prisma, OWNER_ID, { + purpose: "portable-export", + }); + expect( + portable.practitioners.map((p) => ({ + id: p.id, + phone: p.phone, + location: p.location, + })), + ).toEqual( + expect.arrayContaining([ + { id: "pr-legacy", phone: PHONE, location: ADDRESS }, + { id: sealedId, phone: PHONE, location: null }, + ]), + ); + + // A file written before this release: readable fields, no ciphertext. + const older = portable.practitioners.map((p) => ({ + id: p.id, + name: p.name, + phone: p.phone, + location: p.location, + noteEncrypted: p.noteEncrypted, + createdAt: p.createdAt, + updatedAt: p.updatedAt, + })); + + for (const payload of [ + dr.practitioners, + portable.practitioners, + older, + ] as const) { + await prisma.$transaction(async (tx) => { + await restoreVisitsData( + tx, + OWNER_ID, + { + practitioners: payload, + encounters: [], + encounterDocumentLinks: [], + encounterLabLinks: [], + encounterConditionLinks: [], + }, + [], + ); + }); + const restored = await prisma.practitioner.findMany({ + where: { userId: OWNER_ID }, + orderBy: { name: "asc" }, + }); + expect( + restored.map((p) => ({ + id: p.id, + phone: p.phone, + location: p.location, + phoneText: readNote(p.phoneEncrypted, null), + locationText: readNote(p.locationEncrypted, null), + })), + ).toEqual([ + { + id: "pr-legacy", + phone: null, + location: null, + phoneText: PHONE, + locationText: ADDRESS, + }, + { + id: sealedId, + phone: null, + location: null, + phoneText: PHONE, + locationText: null, + }, + ]); + } + }); +}); diff --git a/tests/integration/workout-batch-create.test.ts b/tests/integration/workout-batch-create.test.ts index 005a71dad..b7f7f3274 100644 --- a/tests/integration/workout-batch-create.test.ts +++ b/tests/integration/workout-batch-create.test.ts @@ -197,6 +197,23 @@ describe("POST /api/workouts/batch (real Postgres)", () => { expect(workouts[0]?.totalDistanceM).toBe(7800); expect(workouts[0]?.route).not.toBeNull(); expect(workouts[0]?.durationSec).toBe(45 * 60); + + // v1.39.4 — the track is on disk sealed only. The readable column is SQL + // NULL, the ciphertext does not contain a coordinate, and it opens to the + // exact geometry the client sent. + const onDisk = await getPrismaClient().$queryRaw< + { readable_is_null: boolean; leaks: boolean }[] + >` + SELECT geometry IS NULL AS readable_is_null, + position(convert_to('49.452', 'UTF8') IN geometry_encrypted) > 0 + AS leaks + FROM workout_routes WHERE workout_id = ${workouts[0]!.id}`; + expect(onDisk).toEqual([{ readable_is_null: true, leaks: false }]); + const { decryptRouteGeometry } = + await import("@/lib/workouts/route-geometry-cipher"); + expect( + decryptRouteGeometry(workouts[0]!.route!.geometryEncrypted!), + ).toEqual(body.workouts[0]!.route!.geometry); }); it("persists a route-independent HR series for an indoor workout (v1.10.0)", async () => { From b8e78a78b3b9d0590d232bc801fee716ca515db0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 27 Sep 2026 10:21:48 +0200 Subject: [PATCH 2/3] chore(openapi): regenerate for the practitioner backup ciphertext fields The practitioner entry of the backup payload gains the optional locationEncrypted and phoneEncrypted fields. The rotation script names each new column once, at its call, so a dropped call is noticed rather than covered by a comment. --- docs/api/openapi.yaml | 10 ++++++++++ scripts/rotate-encryption-key.ts | 1 - 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 824b25a94..3f1f82806 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -36886,6 +36886,16 @@ components: anyOf: - type: string - type: "null" + locationEncrypted: + anyOf: + - type: string + minLength: 1 + - type: "null" + phoneEncrypted: + anyOf: + - type: string + minLength: 1 + - type: "null" noteEncrypted: anyOf: - type: string diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index c007fa0d7..db3ef9348 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -703,7 +703,6 @@ async function main() { // v1.39.4 — the practice's phone number and address. NULL on a field the // person left empty and on a row the free-text backfill has not reached // yet; `rotateBytesColumn` skips both. - // "phoneEncrypted" "locationEncrypted" results.push( await rotateBytesColumn( "Practitioner", From f2e32afb242c1985313481dff5663e1e14dd0e4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 27 Sep 2026 10:37:37 +0200 Subject: [PATCH 3/3] fix(privacy): store environment coordinates at 1 decimal instead of 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The home location of the environment module was rounded to 2 decimals, about 1 km, which places a home in a neighbourhood rather than the town the schema comment promised. The weather archive it feeds is gridded at 9 to 25 km, so the extra precision bought nothing. Every stored coordinate of the module now goes through the same 1-decimal rounding (about 11 km north-south, 7 km east-west at 50° N): the home, the travel periods, the geocoder's results, the per-day weather rows, and a restore of a file written with finer values. Migration 0361 rounds the existing users, travel periods and per-day rows; running it twice changes nothing more. The answers keep their shape, plain numbers. The API descriptions and the schema comment say 1 decimal now. --- docs/api/openapi.yaml | 14 +- .../migration.sql | 27 ++- prisma/schema.prisma | 15 +- src/app/api/environment/home/route.ts | 4 +- src/app/api/environment/travel/route.ts | 2 +- .../__tests__/coarse-location.test.ts | 51 +++++ src/lib/environment/open-meteo.ts | 13 +- src/lib/environment/service.ts | 7 +- src/lib/export/environment-backup.ts | 11 +- src/lib/openapi/routes/environment.ts | 16 +- tests/integration/backup-round-trip.test.ts | 24 +- .../environment-coarse-location.test.ts | 209 ++++++++++++++++++ 12 files changed, 347 insertions(+), 46 deletions(-) create mode 100644 src/lib/environment/__tests__/coarse-location.test.ts create mode 100644 tests/integration/environment-coarse-location.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 3f1f82806..b73a874a5 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -20693,8 +20693,8 @@ paths: - Environment summary: Set the coarse home location description: >- - Stores the picked city and stamps the effective-from instant. Coordinates are rounded to 2 dp server-side - regardless of what was sent. + Stores the picked city and stamps the effective-from instant. Coordinates are rounded to 1 decimal (about 11 km) + server-side regardless of what was sent. Re-stamping is the part worth knowing: EVERY write moves `since` to now, including one that changes only the @@ -20850,7 +20850,7 @@ paths: summary: Add a travel override description: >- Declares that the account was somewhere other than home for an inclusive date range; the days it covers resolve - against this location instead. Coordinates are rounded to 2 dp server-side. + against this location instead. Coordinates are rounded to 1 decimal (about 11 km) server-side. Adding one enqueues a refresh over exactly the declared window so its days re-resolve. Nothing checks for @@ -72713,10 +72713,10 @@ components: properties: lat: type: number - description: Coarse latitude, rounded to 2 dp (~1 km). + description: Coarse latitude, rounded to 1 decimal (about 11 km). lon: type: number - description: Coarse longitude, rounded to 2 dp. + description: Coarse longitude, rounded to 1 decimal. label: anyOf: - type: string @@ -72855,10 +72855,10 @@ components: properties: lat: type: number - description: Coarse latitude, rounded to 2 dp. + description: Coarse latitude, rounded to 1 decimal. lon: type: number - description: Coarse longitude, rounded to 2 dp. + description: Coarse longitude, rounded to 1 decimal. label: type: string description: Assembled from name, region and country, e.g. "City, Region, Country". diff --git a/prisma/migrations/0361_encrypt_route_geometry_and_practitioner_contact/migration.sql b/prisma/migrations/0361_encrypt_route_geometry_and_practitioner_contact/migration.sql index cd9f3ebf4..a7b97295e 100644 --- a/prisma/migrations/0361_encrypt_route_geometry_and_practitioner_contact/migration.sql +++ b/prisma/migrations/0361_encrypt_route_geometry_and_practitioner_contact/migration.sql @@ -12,9 +12,11 @@ -- columns until the backfill has reached a row. The old columns are dropped in -- a later release, once the backfill reports nothing left on every instance. -- --- Additive only: no table rewrite, no data change. The geometry loses its NOT --- NULL because every new track is written to the ciphertext column and the --- readable one stays empty. +-- The encryption half is additive: no table rewrite, no data change. The +-- geometry loses its NOT NULL because every new track is written to the +-- ciphertext column and the readable one stays empty. The last block below is +-- the one data change in this file: it coarsens the environment module's +-- stored coordinates. ALTER TABLE "workout_routes" ADD COLUMN "geometry_encrypted" BYTEA; ALTER TABLE "workout_routes" ALTER COLUMN "geometry" DROP NOT NULL; @@ -35,3 +37,22 @@ CREATE INDEX IF NOT EXISTS "workout_routes_geometry_backfill_idx" CREATE INDEX IF NOT EXISTS "practitioners_contact_backfill_idx" ON "practitioners" ("user_id") WHERE "phone" IS NOT NULL OR "location" IS NOT NULL; + +-- The environment module's stored coordinates drop to 1 decimal, about 11 km +-- north-south and 7 km east-west at 50° N. They were rounded to 2 decimals +-- (about 1 km), which places a home in a neighbourhood rather than a town; +-- the weather reanalysis the module reads is gridded at 9 to 25 km, so the +-- coarser value costs no accuracy. The app rounds every new write the same +-- way. Idempotent: rounding an already-rounded value changes nothing. +UPDATE "users" + SET "home_lat" = round("home_lat"::numeric, 1)::double precision, + "home_lon" = round("home_lon"::numeric, 1)::double precision + WHERE "home_lat" IS NOT NULL OR "home_lon" IS NOT NULL; + +UPDATE "environment_travel_locations" + SET "lat" = round("lat"::numeric, 1)::double precision, + "lon" = round("lon"::numeric, 1)::double precision; + +UPDATE "environment_contexts" + SET "lat" = round("lat"::numeric, 1)::double precision, + "lon" = round("lon"::numeric, 1)::double precision; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ec8d93d28..963b5ce71 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -81,10 +81,13 @@ model User { timezone String @default("Europe/Berlin") // v1.25 (W-ENV) — opt-in environmental-context module home location. A coarse, - // user-set city (rounded lat/lon, ~city granularity — never a GPS track) the - // nightly weather fetch resolves against when no travel override covers the - // day. City-level granularity is the privacy floor: the weather model only - // needs ~10–25 km. All nullable — an account without the module enabled (or + // user-set city (never a GPS track) the nightly weather fetch resolves + // against when no travel override covers the day. Stored rounded to 1 + // decimal (`roundCoarse`): about 11 km north-south and 7 km east-west at + // 50° N, a town or a city district. That is the privacy floor, and the + // weather reanalysis is gridded at 9 to 25 km, so nothing finer is needed. + // Up to v1.39.3 the rounding was 2 decimals (about 1 km, a neighbourhood); + // migration 0361 rounded the existing rows. All nullable — an account without the module enabled (or // without a home set) simply has no environment rows. `homeTimezone` anchors // the day-key an observation is stored under (it can differ from the display // `timezone` above for an account that tracks weather for another locale). @@ -1055,8 +1058,8 @@ model EnvironmentContext { /// YYYY-MM-DD anchored to the resolved location's timezone. date String - /// Coarse resolved location for the day (rounded lat/lon + label + the source - /// that won precedence). + /// Coarse resolved location for the day (lat/lon rounded to 1 decimal, about + /// 11 km, + label + the source that won precedence). lat Float lon Float /// @internal: human-readable label of the resolved location, stored as diff --git a/src/app/api/environment/home/route.ts b/src/app/api/environment/home/route.ts index ded1c12e5..507cfc6bb 100644 --- a/src/app/api/environment/home/route.ts +++ b/src/app/api/environment/home/route.ts @@ -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. */ diff --git a/src/app/api/environment/travel/route.ts b/src/app/api/environment/travel/route.ts index 6f5d31f6c..5f0a0eed2 100644 --- a/src/app/api/environment/travel/route.ts +++ b/src/app/api/environment/travel/route.ts @@ -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. */ diff --git a/src/lib/environment/__tests__/coarse-location.test.ts b/src/lib/environment/__tests__/coarse-location.test.ts new file mode 100644 index 000000000..8162e858e --- /dev/null +++ b/src/lib/environment/__tests__/coarse-location.test.ts @@ -0,0 +1,51 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * v1.39.4 — the environment module stores coordinates at 1 decimal (about + * 11 km), not 2 (about 1 km). The geocoder is where a picked city's + * coordinates enter, so its results must already be coarse. + */ +beforeEach(() => { + vi.resetModules(); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); + +describe("coarse location", () => { + it("rounds a coordinate to one decimal", async () => { + const { roundCoarse } = await import("../open-meteo"); + expect(roundCoarse(52.5163)).toBe(52.5); + expect(roundCoarse(13.3777)).toBe(13.4); + expect(roundCoarse(-33.8688)).toBe(-33.9); + // Already coarse values pass through unchanged. + expect(roundCoarse(48.1)).toBe(48.1); + }); + + it("hands back geocoding results at one decimal", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + Response.json( + { + results: [ + { + name: "Berlin", + latitude: 52.52437, + longitude: 13.41053, + country: "Germany", + timezone: "Europe/Berlin", + }, + ], + }, + { status: 200 }, + ), + ), + ); + const { geocodeLocation } = await import("../open-meteo"); + const [hit] = await geocodeLocation("Berlin"); + expect(hit).toMatchObject({ lat: 52.5, lon: 13.4 }); + }); +}); diff --git a/src/lib/environment/open-meteo.ts b/src/lib/environment/open-meteo.ts index 38e947677..130d44232 100644 --- a/src/lib/environment/open-meteo.ts +++ b/src/lib/environment/open-meteo.ts @@ -40,7 +40,7 @@ export const OPEN_METEO_ATTRIBUTION = "Weather data by Open-Meteo.com"; /** One geocoding match — coarse city placement for the home-location picker. */ export interface GeocodeResult { - /** Rounded coarse latitude (2 dp — ~1 km, the privacy floor). */ + /** Rounded coarse latitude (1 dp, about 11 km, the privacy floor). */ lat: number; /** Rounded coarse longitude (2 dp). */ lon: number; @@ -68,9 +68,16 @@ export interface DailyEnvironmentObservation { weatherCode: number | null; } -/** Round a coordinate to 2 decimals (~1 km) — the coarse-location privacy floor. */ +/** + * Round a coordinate to 1 decimal, the coarse-location privacy floor: about + * 11 km north-south and 7 km east-west at 50° N, the size of a town or a + * city district rather than a street. The weather reanalysis behind the + * archive is gridded at 9 to 25 km, so a finer value buys no better weather. + * Every stored coordinate of this module goes through here (v1.39.4; it was + * 2 decimals, about 1 km, before). + */ export function roundCoarse(value: number): number { - return Math.round(value * 100) / 100; + return Math.round(value * 10) / 10; } function firstNumber(value: unknown): number | null { diff --git a/src/lib/environment/service.ts b/src/lib/environment/service.ts index d1f168127..3c2fb43ed 100644 --- a/src/lib/environment/service.ts +++ b/src/lib/environment/service.ts @@ -24,6 +24,7 @@ import { prisma } from "@/lib/db"; import { fetchDailyEnvironment, + roundCoarse, type DailyEnvironmentObservation, } from "@/lib/environment/open-meteo"; import type { EnvironmentLocationSource } from "@/generated/prisma/client"; @@ -256,8 +257,10 @@ function buildUpsert( obs: DailyEnvironmentObservation, ) { const data = { - lat: loc.lat, - lon: loc.lon, + // Already coarse at the source; rounded again so a per-day row never holds + // more precision than the privacy floor, whatever resolved it. + lat: roundCoarse(loc.lat), + lon: roundCoarse(loc.lon), locationLabel: loc.label, source: loc.source, tempMin: obs.tempMin, diff --git a/src/lib/export/environment-backup.ts b/src/lib/export/environment-backup.ts index c02042875..cc96d9853 100644 --- a/src/lib/export/environment-backup.ts +++ b/src/lib/export/environment-backup.ts @@ -60,6 +60,7 @@ */ import type { Prisma, PrismaClient } from "@/generated/prisma/client"; import type { EnvironmentLocationSource } from "@/generated/prisma/client"; +import { roundCoarse } from "@/lib/environment/open-meteo"; /** One day's environmental observation, at the location resolved for that day. */ export interface EnvironmentContextBackupEntry { @@ -312,8 +313,10 @@ export async function restoreEnvironmentData( // neither bound goes near a `Date`. startDate: entry.startDate, endDate: entry.endDate, - lat: entry.lat, - lon: entry.lon, + // A file written before v1.39.4 carries 2-decimal coordinates; they + // come back at today's privacy floor. + lat: roundCoarse(entry.lat), + lon: roundCoarse(entry.lon), label: entry.label, ...(entry.createdAt ? { createdAt: new Date(entry.createdAt) } : {}), ...(entry.updatedAt ? { updatedAt: new Date(entry.updatedAt) } : {}), @@ -326,8 +329,8 @@ export async function restoreEnvironmentData( data: payload.environmentContexts.map((entry) => ({ userId: ownerId, date: entry.date, - lat: entry.lat, - lon: entry.lon, + lat: roundCoarse(entry.lat), + lon: roundCoarse(entry.lon), locationLabel: entry.locationLabel, source: entry.source, tempMin: entry.tempMin ?? null, diff --git a/src/lib/openapi/routes/environment.ts b/src/lib/openapi/routes/environment.ts index 8d4c132ab..40339d20c 100644 --- a/src/lib/openapi/routes/environment.ts +++ b/src/lib/openapi/routes/environment.ts @@ -11,7 +11,7 @@ * the `environment` module off, every one of the six paths answers 403 * `module.disabled` before it does anything else. * - * Coordinates are rounded to ~city granularity (2 dp) on the way in, as a + * Coordinates are rounded to 1 decimal (about 11 km) on the way in, as a * floor under whatever precision a client sends. That rounding is why the * geocode results and the stored home read back coarser than they were * picked. @@ -58,8 +58,10 @@ const geocodeQuery = geocodeQuerySchema.meta({ const environmentHome = z .object({ - lat: z.number().describe("Coarse latitude, rounded to 2 dp (~1 km)."), - lon: z.number().describe("Coarse longitude, rounded to 2 dp."), + lat: z + .number() + .describe("Coarse latitude, rounded to 1 decimal (about 11 km)."), + lon: z.number().describe("Coarse longitude, rounded to 1 decimal."), label: z.string().nullable(), timezone: z .string() @@ -142,8 +144,8 @@ const environmentOverview = z const geocodeResult = z .object({ - lat: z.number().describe("Coarse latitude, rounded to 2 dp."), - lon: z.number().describe("Coarse longitude, rounded to 2 dp."), + lat: z.number().describe("Coarse latitude, rounded to 1 decimal."), + lon: z.number().describe("Coarse longitude, rounded to 1 decimal."), label: z .string() .describe( @@ -186,7 +188,7 @@ export const environmentPaths: NonNullable = { tags: ["Environment"], summary: "Set the coarse home location", description: - "Stores the picked city and stamps the effective-from instant. Coordinates are rounded to 2 dp server-side regardless of what was sent.\n\nRe-stamping is the part worth knowing: EVERY write moves `since` to now, including one that changes only the label. Days before the new `since` stop resolving against this home, so a correction to an existing home un-attributes the history that home used to cover.\n\nOn success a lookback fetch is enqueued so recent days populate without waiting for the nightly tick. That enqueue is best-effort — it no-ops cleanly when no worker is bound and the response is a 200 either way.\n\nBody cap 4 KiB. Module-gated.", + "Stores the picked city and stamps the effective-from instant. Coordinates are rounded to 1 decimal (about 11 km) server-side regardless of what was sent.\n\nRe-stamping is the part worth knowing: EVERY write moves `since` to now, including one that changes only the label. Days before the new `since` stop resolving against this home, so a correction to an existing home un-attributes the history that home used to cover.\n\nOn success a lookback fetch is enqueued so recent days populate without waiting for the nightly tick. That enqueue is best-effort — it no-ops cleanly when no worker is bound and the response is a 200 either way.\n\nBody cap 4 KiB. Module-gated.", requestBody: { required: true, content: { "application/json": { schema: homeLocationRequest } }, @@ -309,7 +311,7 @@ export const environmentPaths: NonNullable = { tags: ["Environment"], summary: "Add a travel override", description: - "Declares that the account was somewhere other than home for an inclusive date range; the days it covers resolve against this location instead. Coordinates are rounded to 2 dp server-side.\n\nAdding one enqueues a refresh over exactly the declared window so its days re-resolve. Nothing checks for overlap with an existing override — two ranges covering the same day are accepted, and the worker picks between them.\n\nBody cap 4 KiB. Module-gated.", + "Declares that the account was somewhere other than home for an inclusive date range; the days it covers resolve against this location instead. Coordinates are rounded to 1 decimal (about 11 km) server-side.\n\nAdding one enqueues a refresh over exactly the declared window so its days re-resolve. Nothing checks for overlap with an existing override — two ranges covering the same day are accepted, and the worker picks between them.\n\nBody cap 4 KiB. Module-gated.", requestBody: { required: true, content: { "application/json": { schema: travelLocationRequest } }, diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 6f4cc1117..a9cae4e6b 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -1206,14 +1206,16 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { // coordinates and different weather, and the trip day's reading only makes // sense next to the period that explains it: with the period gone, the next // refresh re-resolves 2026-06-15 to Berlin and upserts Berlin's weather over - // it. The assertion after the restore reads the two back TOGETHER. + // it. The assertion after the restore reads the two back TOGETHER. The + // coordinates are at 1 decimal, the precision the app stores since v1.39.4; + // a restore rounds anything finer, so finer values would not round-trip. await prisma.environmentTravelLocation.create({ data: { userId: OWNER_ID, startDate: "2026-06-10", endDate: "2026-06-20", - lat: 41.3874, - lon: 2.1686, + lat: 41.4, + lon: 2.2, label: "Barcelona", }, }); @@ -1222,8 +1224,8 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { { userId: OWNER_ID, date: "2026-06-15", - lat: 41.3874, - lon: 2.1686, + lat: 41.4, + lon: 2.2, locationLabel: "Barcelona", source: "TRAVEL", tempMin: 19.4, @@ -1243,8 +1245,8 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { { userId: OWNER_ID, date: "2026-07-01", - lat: 52.52, - lon: 13.405, + lat: 52.5, + lon: 13.4, locationLabel: "Berlin", source: "HOME", tempMin: 13.1, @@ -2394,8 +2396,8 @@ describe("every model the plan claims two-ended survives a real restore", () => ).toEqual([ { date: "2026-06-15", - lat: 41.3874, - lon: 2.1686, + lat: 41.4, + lon: 2.2, locationLabel: "Barcelona", source: "TRAVEL", tempMean: 23.6, @@ -2406,8 +2408,8 @@ describe("every model the plan claims two-ended survives a real restore", () => }, { date: "2026-07-01", - lat: 52.52, - lon: 13.405, + lat: 52.5, + lon: 13.4, locationLabel: "Berlin", source: "HOME", tempMean: 17.9, diff --git a/tests/integration/environment-coarse-location.test.ts b/tests/integration/environment-coarse-location.test.ts new file mode 100644 index 000000000..45ee987ea --- /dev/null +++ b/tests/integration/environment-coarse-location.test.ts @@ -0,0 +1,209 @@ +/** + * v1.39.4 — the environment module's coordinates are stored at 1 decimal + * (about 11 km), against real Postgres. + * + * - setting a home and adding a travel period store the coarse value, and + * the home answer carries it in the same shape as before (numbers); + * - a restore of a file written with 2-decimal coordinates stores them + * coarse; + * - the rounding block of migration 0361 coarsens rows written before it, + * and running it twice changes nothing more. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { PUT as putHome } from "@/app/api/environment/home/route"; +import { POST as postTravel } from "@/app/api/environment/travel/route"; +import { restoreEnvironmentData } from "@/lib/export/environment-backup"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const OWNER_ID = "environment-coarse-owner"; + +function jsonRequest(url: string, method: string, body: unknown) { + return new Request(url, { + method, + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }) as never; +} + +beforeEach(async () => { + const prisma = getPrismaClient(); + await truncateAllTables(prisma); + cookieJar.clear(); + headerJar.clear(); + await prisma.user.create({ + data: { + id: OWNER_ID, + username: OWNER_ID, + email: `${OWNER_ID}@example.test`, + timezone: "Europe/Berlin", + }, + }); + const session = await prisma.session.create({ + data: { + userId: OWNER_ID, + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + mfaVerifiedAt: new Date(), + }, + }); + cookieJar.set("healthlog_session", session.id); +}); + +describe("environment coordinates at one decimal", () => { + it("stores a home and a travel period coarse, and answers in the same shape", async () => { + const home = await putHome( + jsonRequest("http://localhost/api/environment/home", "PUT", { + lat: 52.5163, + lon: 13.3777, + label: "Berlin, Germany", + timezone: "Europe/Berlin", + }), + ); + expect(home.status).toBe(200); + expect((await home.json()).data.home).toMatchObject({ + lat: 52.5, + lon: 13.4, + }); + const user = await getPrismaClient().user.findUniqueOrThrow({ + where: { id: OWNER_ID }, + }); + expect([user.homeLat, user.homeLon]).toEqual([52.5, 13.4]); + + const travel = await postTravel( + jsonRequest("http://localhost/api/environment/travel", "POST", { + startDate: "2026-08-01", + endDate: "2026-08-10", + lat: 48.13743, + lon: 11.57549, + label: "Munich, Germany", + }), + ); + expect(travel.status).toBe(201); + const stored = await getPrismaClient().environmentTravelLocation.findMany({ + where: { userId: OWNER_ID }, + }); + expect(stored.map((t) => [t.lat, t.lon])).toEqual([[48.1, 11.6]]); + }); + + it("restores a file written with two-decimal coordinates at one decimal", async () => { + const prisma = getPrismaClient(); + await prisma.$transaction((tx) => + restoreEnvironmentData(tx, OWNER_ID, { + environmentTravelLocations: [ + { + startDate: "2026-08-01", + endDate: "2026-08-10", + lat: 48.14, + lon: 11.58, + label: "Munich, Germany", + }, + ], + environmentContexts: [ + { + date: "2026-08-02", + lat: 48.14, + lon: 11.58, + locationLabel: "Munich, Germany", + source: "TRAVEL", + }, + ], + }), + ); + const travel = await prisma.environmentTravelLocation.findMany({ + where: { userId: OWNER_ID }, + }); + const days = await prisma.environmentContext.findMany({ + where: { userId: OWNER_ID }, + }); + expect(travel.map((t) => [t.lat, t.lon])).toEqual([[48.1, 11.6]]); + expect(days.map((d) => [d.lat, d.lon])).toEqual([[48.1, 11.6]]); + }); + + it("coarsens rows written before migration 0361, idempotently", async () => { + const prisma = getPrismaClient(); + await prisma.user.update({ + where: { id: OWNER_ID }, + data: { homeLat: 52.52, homeLon: 13.38 }, + }); + await prisma.environmentTravelLocation.create({ + data: { + userId: OWNER_ID, + startDate: "2026-08-01", + endDate: "2026-08-10", + lat: 48.14, + lon: 11.58, + label: "Munich, Germany", + }, + }); + await prisma.environmentContext.create({ + data: { + userId: OWNER_ID, + date: "2026-08-02", + lat: -33.87, + lon: 151.21, + locationLabel: "Sydney, Australia", + source: "TRAVEL", + }, + }); + + const sql = readFileSync( + join( + __dirname, + "../../prisma/migrations/0361_encrypt_route_geometry_and_practitioner_contact/migration.sql", + ), + "utf8", + ); + const statements = sql + .slice(sql.indexOf('UPDATE "users"')) + .split(";") + .map((s) => s.trim()) + .filter((s) => s.length > 0); + expect(statements).toHaveLength(3); + + for (let pass = 0; pass < 2; pass++) { + for (const statement of statements) { + await prisma.$executeRawUnsafe(statement); + } + const user = await prisma.user.findUniqueOrThrow({ + where: { id: OWNER_ID }, + }); + expect([user.homeLat, user.homeLon]).toEqual([52.5, 13.4]); + const travel = await prisma.environmentTravelLocation.findFirstOrThrow({ + where: { userId: OWNER_ID }, + }); + expect([travel.lat, travel.lon]).toEqual([48.1, 11.6]); + const day = await prisma.environmentContext.findFirstOrThrow({ + where: { userId: OWNER_ID }, + }); + expect([day.lat, day.lon]).toEqual([-33.9, 151.2]); + } + }); +});