mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
fix(privacy): encrypt workout GPS tracks and practitioner contact details at rest
The GPS track of an outdoor workout was stored readable, and its first and last points are usually the person's front door. The phone number and address in the address book of doctors and practices were readable too, while the note beside them was already encrypted. Migration 0361 adds workout_routes.geometry_encrypted (the geometry loses its NOT NULL) and practitioners.phone_encrypted / location_encrypted, plus partial indexes on the backfill's discovery predicates. A track is sealed as one binary AES-256-GCM value bound to its purpose; the contact fields use the same Bytes codec as the note. The practitioner's name, practice and specialty stay readable, because the picker searches and sorts on them. Every writer stores ciphertext only: workout batch ingest, practitioner create and edit, and both restore formats. Readers prefer the ciphertext and fall back to the old column for rows not yet migrated: the workout detail (map, splits, GPX), the activity insight's climb figure, the practitioner DTO behind the address book, visits and vaccinations. The wire shape does not change. An edit's audit row now names a changed phone number or address without recording either value. free-text-encryption-backfill covers the three columns in the same pass, finding a route through its workout. All three are registered for key rotation (registry, CLI script, and the in-app rotation). A disaster-recovery backup carries the contact fields as ciphertext only; a portable export carries them readable. Workout routes are still not part of a backup, as before. The old columns are marked for removal in a later release.
This commit is contained in:
1 parent
69ddcc28e7
commit
5eb7956bad
24 files changed
+1529
-112
No files matched your search
@@ -0,0 +1,37 @@
|
||||
-- Two more kinds of personal data were still stored readable: the GPS track of
|
||||
-- an outdoor workout (whose first and last points are usually the person's
|
||||
-- front door) and the phone number and address in the address book of doctors
|
||||
-- and practices. All three move to AES-256-GCM at rest. The practitioner's
|
||||
-- name and specialty stay readable, because the picker searches and sorts on
|
||||
-- them.
|
||||
--
|
||||
-- SQL cannot encrypt, so this migration only adds the ciphertext columns and
|
||||
-- the discovery indexes. The boot-time `free-text-encryption-backfill` job
|
||||
-- encrypts the existing rows one at a time and nulls the readable values in
|
||||
-- the same transaction. Readers prefer the ciphertext and fall back to the old
|
||||
-- columns until the backfill has reached a row. The old columns are dropped in
|
||||
-- a later release, once the backfill reports nothing left on every instance.
|
||||
--
|
||||
-- Additive only: no table rewrite, no data change. The geometry loses its NOT
|
||||
-- NULL because every new track is written to the ciphertext column and the
|
||||
-- readable one stays empty.
|
||||
|
||||
ALTER TABLE "workout_routes" ADD COLUMN "geometry_encrypted" BYTEA;
|
||||
ALTER TABLE "workout_routes" ALTER COLUMN "geometry" DROP NOT NULL;
|
||||
|
||||
ALTER TABLE "practitioners" ADD COLUMN "location_encrypted" BYTEA;
|
||||
ALTER TABLE "practitioners" ADD COLUMN "phone_encrypted" BYTEA;
|
||||
|
||||
-- Partial indexes on exactly the backfill's discovery predicates, so the boot
|
||||
-- scan is index-only and shrinks to nothing as the backfill converges (the
|
||||
-- 0357 precedent). A route row carries no user id; discovery joins it to its
|
||||
-- workout, and the index keeps the route side of that join to the rows still
|
||||
-- to seal. Non-concurrent: Prisma Migrate runs the file in a transaction, and
|
||||
-- the build lands in the pre-traffic window at boot.
|
||||
CREATE INDEX IF NOT EXISTS "workout_routes_geometry_backfill_idx"
|
||||
ON "workout_routes" ("workout_id")
|
||||
WHERE "geometry" IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "practitioners_contact_backfill_idx"
|
||||
ON "practitioners" ("user_id")
|
||||
WHERE "phone" IS NOT NULL OR "location" IS NOT NULL;
|
||||
+46
-17
@@ -2272,22 +2272,35 @@ model Workout {
|
||||
}
|
||||
|
||||
model WorkoutRoute {
|
||||
id String @id @default(cuid())
|
||||
workoutId String @unique @map("workout_id")
|
||||
id String @id @default(cuid())
|
||||
workoutId String @unique @map("workout_id")
|
||||
/// GeoJSON LineString — `{ "type": "LineString", coordinates:
|
||||
/// [[lon, lat, alt?], ...] }`. JSONB rather than PostGIS so the
|
||||
/// Coolify Postgres template stays extension-free (same decision
|
||||
/// v1.4.20 made for correlations); GPX import + Leaflet / MapLibre
|
||||
/// consume GeoJSON natively.
|
||||
geometry Json
|
||||
/// @internal: legacy readable track, read only as a fallback for rows the
|
||||
/// free-text encryption backfill has not reached yet (v1.39.4). Every write
|
||||
/// since then goes to `geometryEncrypted` and leaves this NULL; the backfill
|
||||
/// nulls it on the old rows. DROP this column in a later release, once the
|
||||
/// backfill reports zero remaining rows on every instance.
|
||||
geometry Json?
|
||||
/// v1.39.4 — the same GeoJSON LineString, JSON text sealed as one binary
|
||||
/// AES-256-GCM value (`encryptBytes`, labelled per purpose). Read through
|
||||
/// `readRouteGeometry` in `src/lib/workouts/route-geometry-cipher.ts`.
|
||||
geometryEncrypted Bytes? @map("geometry_encrypted")
|
||||
/// Optional per-sample timestamps (ISO strings) and speeds —
|
||||
/// parallel array to `geometry.coordinates`. NULL when the source
|
||||
/// only ships static GPX (e.g. Withings).
|
||||
sampleTimestamps Json? @map("sample_timestamps")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
sampleTimestamps Json? @map("sample_timestamps")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
workout Workout @relation(fields: [workoutId], references: [id], onDelete: Cascade)
|
||||
|
||||
// `workout_routes_geometry_backfill_idx` is a partial index over
|
||||
// `(workout_id) WHERE geometry IS NOT NULL` — see migration 0361 for the
|
||||
// encryption backfill's discovery scan. Raw SQL only (partial predicate not
|
||||
// expressible in Prisma).
|
||||
@@map("workout_routes")
|
||||
}
|
||||
|
||||
@@ -8637,33 +8650,49 @@ enum Laterality {
|
||||
/// instance share a namespace. There is no uniqueness across accounts of
|
||||
/// any kind here.
|
||||
model Practitioner {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
/// "Dr. med. …" or a practice name — whichever the person thinks of
|
||||
/// first. Plaintext and bounded, so the picker can ILIKE-search and
|
||||
/// ORDER BY in SQL (the `InboundDocument.title` tradeoff).
|
||||
name String
|
||||
name String
|
||||
/// Free-text specialty, NOT an enum: German practice signage does not
|
||||
/// fit a closed list, and an enum here would force a migration per
|
||||
/// specialty for no reader that needs the closed set.
|
||||
specialty String?
|
||||
specialty String?
|
||||
/// Practice or clinic name when it differs from `name`.
|
||||
practice String?
|
||||
/// Free-text address / "wo", the `MeasurementReminder.location` shape.
|
||||
location String?
|
||||
phone String?
|
||||
practice String?
|
||||
/// @internal: legacy readable address, read only as a fallback for rows the
|
||||
/// free-text encryption backfill has not reached yet (v1.39.4). Every write
|
||||
/// since then goes to `locationEncrypted` and leaves this NULL; the backfill
|
||||
/// nulls it on the old rows. DROP this column in a later release, once the
|
||||
/// backfill reports zero remaining rows on every instance.
|
||||
location String?
|
||||
/// @internal: legacy readable phone number, same status as `location`
|
||||
/// above. DROP this column in a later release, once the backfill reports
|
||||
/// zero remaining rows on every instance.
|
||||
phone String?
|
||||
/// v1.39.4 — AES-256-GCM ciphertext of the free-text address / "wo". Same
|
||||
/// Bytes codec as the note below.
|
||||
locationEncrypted Bytes? @map("location_encrypted")
|
||||
/// v1.39.4 — AES-256-GCM ciphertext of the phone number.
|
||||
phoneEncrypted Bytes? @map("phone_encrypted")
|
||||
/// Free-text note (encrypted): parking, which entrance, who to ask for.
|
||||
noteEncrypted Bytes? @map("note_encrypted")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
noteEncrypted Bytes? @map("note_encrypted")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
/// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant.
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
encounters Encounter[]
|
||||
vaccinations VaccinationRecord[]
|
||||
|
||||
@@index([userId, deletedAt, name])
|
||||
// `practitioners_contact_backfill_idx` is a partial index over
|
||||
// `(user_id) WHERE phone IS NOT NULL OR location IS NOT NULL` — see
|
||||
// migration 0361 for the encryption backfill's discovery scan. Raw SQL only
|
||||
// (partial predicate not expressible in Prisma).
|
||||
@@map("practitioners")
|
||||
}
|
||||
|
||||
|
||||
@@ -700,6 +700,35 @@ async function main() {
|
||||
prisma.practitioner,
|
||||
),
|
||||
);
|
||||
// v1.39.4 — the practice's phone number and address. NULL on a field the
|
||||
// person left empty and on a row the free-text backfill has not reached
|
||||
// yet; `rotateBytesColumn` skips both.
|
||||
// "phoneEncrypted" "locationEncrypted"
|
||||
results.push(
|
||||
await rotateBytesColumn(
|
||||
"Practitioner",
|
||||
"phoneEncrypted",
|
||||
prisma.practitioner,
|
||||
),
|
||||
);
|
||||
results.push(
|
||||
await rotateBytesColumn(
|
||||
"Practitioner",
|
||||
"locationEncrypted",
|
||||
prisma.practitioner,
|
||||
),
|
||||
);
|
||||
|
||||
// ───── v1.39.4 workout GPS track (Bytes, binary2, batched) ─────
|
||||
// One sealed binary value per route, labelled for its purpose. Walked
|
||||
// through the registry so the binary codec and the label are honoured, in
|
||||
// id-cursor batches because a track can be close to a megabyte. NULL on a
|
||||
// row the free-text backfill has not reached yet, which the walk skips.
|
||||
results.push(
|
||||
await rotateRegistryColumn("WorkoutRoute", "geometryEncrypted", {
|
||||
workoutRoute: prisma.workoutRoute,
|
||||
} as unknown as CorpusClient),
|
||||
);
|
||||
|
||||
// ───── v1.39.3 custom-metric reading note (Bytes column) ─────
|
||||
// NULL on a reading without a note, and on a row the free-text backfill has
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user