mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
Merge branch 'fix/encrypt-routes-and-practitioner-contact' into release/v1.39.4
This commit is contained in:
commit
2feb622b6a
34 files changed
+1882
-155
No files matched your search
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user