mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +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
|
||||
|
||||
Reference in new issue
Block a user