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

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

No files matched your search

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