diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 824b25a94..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 @@ -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 @@ -72703,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 @@ -72845,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 new file mode 100644 index 000000000..a7b97295e --- /dev/null +++ b/prisma/migrations/0361_encrypt_route_geometry_and_practitioner_contact/migration.sql @@ -0,0 +1,58 @@ +-- 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. +-- +-- 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; + +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; + +-- 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 e24bf462a..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 @@ -2272,22 +2275,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 +8653,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..db3ef9348 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -700,6 +700,34 @@ 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. + 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/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/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/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/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/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/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/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/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/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]); + } + }); +}); 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 () => {