Merge environment data into release/v1.42.0

# Conflicts:
#	src/__tests__/i18n-reverse-coverage.test.ts
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 10:04:45 +02:00
commit 7bcb6f71d9
73 files changed
+6061 -385

No files matched your search

@@ -6,6 +6,7 @@
* a person instead of ciphertext. Every refusal here leaves the account's
* current value in place and names the column in the restore's skip report.
*/
import { Buffer } from "node:buffer";
import { afterEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
@@ -19,6 +20,7 @@ import {
buildAccountSettingsBackupSection,
} from "@/lib/export/account-settings-backup";
import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker";
import { openLocation, sealLocation } from "@/lib/environment/location-cipher";
import { reasoningEffortFor } from "@/lib/ai/reasoning-effort";
import {
coachReasoningLevel,
@@ -324,3 +326,74 @@ describe("admitAccountSettings", () => {
expect(data).toEqual({ heightCm: 180 });
});
});
describe("the environment home (v1.42, sealed at rest)", () => {
it("carries the home readable in a portable file, opened from its sealed copy", async () => {
const { accountSettings } = await buildAccountSettingsBackupSection(
prismaReturning(
rowWith({
homeLocationEncrypted: sealLocation({
lat: 51.5,
lon: 7.2,
label: "Bochum, Germany",
}),
}),
),
"u1",
{ purpose: "portable-export" },
);
expect(accountSettings).toMatchObject({
homeLat: 51.5,
homeLon: 7.2,
homeLabel: "Bochum, Germany",
});
expect(accountSettings).not.toHaveProperty("homeLocationEncrypted");
});
it("carries the sealed copy verbatim in a disaster-recovery file", async () => {
const sealed = sealLocation({ lat: 51.5, lon: 7.2, label: "Bochum" });
const { accountSettings } = await buildAccountSettingsBackupSection(
prismaReturning(rowWith({ homeLocationEncrypted: sealed })),
"u1",
{ purpose: "disaster-recovery" },
);
expect(accountSettings!.homeLocationEncrypted).toBe(
Buffer.from(sealed).toString("base64"),
);
});
it("seals a readable home from a file and writes the readable columns empty", () => {
const { data, refused } = admitAccountSettings(
{ homeLat: 48.137, homeLon: 11.575, homeLabel: "Munich" },
ctx,
);
expect(refused).toEqual([]);
expect(data).toMatchObject({
homeLat: null,
homeLon: null,
homeLabel: null,
});
expect(openLocation(data.homeLocationEncrypted as Uint8Array)).toEqual({
lat: 48.1,
lon: 11.6,
label: "Munich",
});
});
it("takes a sealed home as it came, and clears the home when the file has none", () => {
const sealed = sealLocation({ lat: 1, lon: 2, label: "x" });
const restored = admitAccountSettings(
{ homeLocationEncrypted: Buffer.from(sealed).toString("base64") },
ctx,
).data.homeLocationEncrypted as Uint8Array;
expect(Buffer.from(restored).equals(Buffer.from(sealed))).toBe(true);
expect(
admitAccountSettings({ homeLat: null, homeLon: null }, ctx).data,
).toEqual({
homeLocationEncrypted: null,
homeLat: null,
homeLon: null,
homeLabel: null,
});
});
});
+97 -31
View File
@@ -32,8 +32,8 @@
*
* ── Encryption ─────────────────────────────────────────────────────────────
*
* One column is sealed, the insurance number, and it follows the split every
* other section uses: a disaster-recovery file carries the ciphertext
* Two values are sealed, the insurance number and (v1.42) the environment
* home, and both follow the split every other section uses: a disaster-recovery file carries the ciphertext
* verbatim (so the restore's key check sees it), a portable file carries the
* readable value and the restore seals it under this host's key.
*/
@@ -52,6 +52,7 @@ import {
readAvatarDimensions,
} from "@/lib/avatar";
import { decrypt, encrypt } from "@/lib/crypto";
import { readLocation, sealLocation } from "@/lib/environment/location-cipher";
import {
USER_COLUMN_BACKUP_CLASS,
type AccountSettingColumn,
@@ -382,36 +383,100 @@ const insuranceNumber: SettingCodec = {
};
/**
* v1.42 (#615) — the sealed home location. A disaster-recovery file carries
* the ciphertext verbatim, like the insurance number, because it is restored
* onto a host holding the same keys. A portable file leaves it out: the
* readable home rides `homeLat` / `homeLon` / `homeLabel` until those columns
* drop, and the receiving host's encryption backfill seals it again.
* v1.42 (#615) — the environment home, sealed at rest as one value
* (`homeLocationEncrypted`, see `src/lib/environment/location-cipher.ts`)
* beside the readable `homeLat` / `homeLon` / `homeLabel` that the
* encryption backfill empties. The four columns travel as one, the way the
* avatar's three do:
*
* - a portable file carries the home readable, opened from the sealed copy
* when there is one, and no sealed value;
* - a disaster-recovery file carries the sealed value verbatim (base64)
* beside whatever the readable columns still hold.
*
* The restore writes the sealed value as it came, or seals a readable home
* under this host's key, and writes the readable columns empty either way. A
* file whose home is null clears the account's home. A home without a label
* (allowed since v1.25) is sealed with an empty label, which every reader
* shows as no label.
*/
const homeLocationSealed: SettingCodec = {
const homeLocation: SettingCodec = {
write: (row, disasterRecovery) => {
if (!disasterRecovery) return {};
const sealed = row.homeLocationEncrypted;
return {
homeLocationEncrypted:
sealed instanceof Uint8Array
? Buffer.from(sealed).toString("base64")
: null,
};
const sealed =
row.homeLocationEncrypted instanceof Uint8Array
? row.homeLocationEncrypted
: null;
if (disasterRecovery) {
return {
homeLat: row.homeLat,
homeLon: row.homeLon,
homeLabel: row.homeLabel,
homeLocationEncrypted:
sealed && sealed.byteLength > 0
? Buffer.from(sealed).toString("base64")
: null,
};
}
const home = readLocation({
sealed,
lat: row.homeLat as number | null,
lon: row.homeLon as number | null,
label: (row.homeLabel as string | null) ?? "",
});
return home
? {
homeLat: home.lat,
homeLon: home.lon,
homeLabel: home.label === "" ? null : home.label,
}
: { homeLat: null, homeLon: null, homeLabel: null };
},
read: (entry) => {
if (!has(entry, "homeLocationEncrypted")) return undefined;
const value = entry.homeLocationEncrypted;
if (value === null) return null;
if (typeof value !== "string") return new Refused("homeLocationEncrypted");
const buffer = Buffer.from(value, "base64");
if (buffer.byteLength === 0) return new Refused("homeLocationEncrypted");
const bytes = new Uint8Array(new ArrayBuffer(buffer.byteLength));
bytes.set(buffer);
return bytes;
const cleared = {
homeLocationEncrypted: null,
homeLat: null,
homeLon: null,
homeLabel: null,
};
const sealedValue = entry.homeLocationEncrypted;
if (has(entry, "homeLocationEncrypted") && sealedValue !== null) {
if (typeof sealedValue !== "string") {
return new Refused("homeLocationEncrypted");
}
const buffer = Buffer.from(sealedValue, "base64");
if (buffer.byteLength === 0) return new Refused("homeLocationEncrypted");
const bytes = new Uint8Array(new ArrayBuffer(buffer.byteLength));
bytes.set(buffer);
return { ...cleared, homeLocationEncrypted: bytes };
}
if (!has(entry, "homeLat") && !has(entry, "homeLon")) {
// Neither form in the file: a sealed null alone still says "no home".
return has(entry, "homeLocationEncrypted") ? cleared : undefined;
}
const lat = entry.homeLat ?? null;
const lon = entry.homeLon ?? null;
const label = entry.homeLabel ?? null;
if (lat === null || lon === null) return cleared;
if (
typeof lat !== "number" ||
typeof lon !== "number" ||
(label !== null && typeof label !== "string")
) {
return new Refused("homeLat");
}
return {
...cleared,
homeLocationEncrypted: sealLocation({ lat, lon, label: label ?? "" }),
};
},
};
/** Carried by the home codec above. */
const ridesWithHome: SettingCodec = {
write: () => ({}),
read: () => undefined,
};
const ACCOUNT_SETTING_CODECS: Readonly<
Record<AccountSettingColumn, SettingCodec>
> = {
@@ -428,12 +493,12 @@ const ACCOUNT_SETTING_CODECS: Readonly<
avatarBytes: avatar,
avatarContentType: ridesWithAvatar,
avatarUpdatedAt: ridesWithAvatar,
homeLat: plain("homeLat"),
homeLon: plain("homeLon"),
homeLabel: plain("homeLabel"),
homeLat: ridesWithHome,
homeLon: ridesWithHome,
homeLabel: ridesWithHome,
homeTimezone: timeZone("homeTimezone", true),
homeSince: instant("homeSince"),
homeLocationEncrypted: homeLocationSealed,
homeLocationEncrypted: homeLocation,
environmentAirQualityEnabled: plain("environmentAirQualityEnabled"),
timezone: timeZone("timezone", false),
locale: oneOf("locale", locales, true),
@@ -560,8 +625,9 @@ export function admitAccountSettings(
refused.push(...value.refused);
continue;
}
if (column === "avatarBytes") {
// The avatar codec answers for all three of its columns at once.
if (column === "avatarBytes" || column === "homeLocationEncrypted") {
// The avatar codec answers for all three of its columns at once, the
// home codec for all four of its.
Object.assign(data, value);
continue;
}
+244 -41
View File
@@ -51,19 +51,73 @@
* settles over a few days after the fact, so a restore that wrote "now" would
* claim a provisional reading from two years ago had just been confirmed.
*
* ## One wire shape, both purposes
* ## Two purposes since v1.42
*
* Neither model has a tombstone column and neither holds ciphertext, so a
* portable export and a disaster-recovery payload carry byte-identical
* sections. This builder therefore takes no `purpose`: a parameter that
* changes nothing would advertise a distinction the file does not have.
* The coarse locations are sealed at rest from v1.42 (`locationEncrypted`,
* see `src/lib/environment/location-cipher.ts`), so the two purposes differ
* the way every other section with a sealed column does:
*
* - a portable export opens the sealed location and writes the readable
* `lat` / `lon` / label, and no `locationEncrypted` at all, so the file
* reads anywhere;
* - a disaster-recovery file carries `locationEncrypted` verbatim (base64)
* beside whatever readable columns the row still holds, for a host with
* the same keys.
*
* The restore takes either: a sealed value is written as it came, a readable
* one is sealed under this host's key, and the readable columns are written
* empty in both cases. A row that holds neither restores without a location;
* it is never invented.
*
* The air-quality columns (v1.42) are plain values and ride both purposes
* alike. Every one is optional on the way in: a file written before them
* says nothing about air quality, and the restored day then reads as never
* fetched (`aqFetchedAt` absent), so the nightly gap fill asks for it.
*/
import { Buffer } from "node:buffer";
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
import type { EnvironmentLocationSource } from "@/generated/prisma/client";
import { roundCoarse } from "@/lib/environment/open-meteo";
import type { AirQualityDay } from "@/lib/environment/air-quality-contract";
import { readLocation, sealLocation } from "@/lib/environment/location-cipher";
export interface EnvironmentBackupOptions {
purpose?: "portable-export" | "disaster-recovery";
}
/** The air-quality columns of a stored day, as they ride the file. */
type AirQualityBackupColumns = Omit<AirQualityDay, "aqFetchedAt"> & {
/** When the air-quality part was fetched; null = not yet. Verbatim. */
aqFetchedAt: string | null;
};
/** The air-quality column names, in file order. */
const AIR_QUALITY_COLUMNS = [
"apparentMax",
"pm25Mean",
"pm25Max",
"pm10Mean",
"no2Mean",
"so2Mean",
"coMean",
"o3Max8h",
"eaqiMax",
"usaqiMax",
"uvIndexMax",
"dustMax",
"aodMax",
"pollenAlderMax",
"pollenBirchMax",
"pollenGrassMax",
"pollenMugwortMax",
"pollenOliveMax",
"pollenRagweedMax",
"aqDomain",
"aqHours",
] as const satisfies ReadonlyArray<keyof AirQualityBackupColumns>;
/** One day's environmental observation, at the location resolved for that day. */
export interface EnvironmentContextBackupEntry {
export interface EnvironmentContextBackupEntry extends AirQualityBackupColumns {
/** `YYYY-MM-DD`, anchored to the resolved location's timezone. */
date: string;
/**
@@ -73,6 +127,11 @@ export interface EnvironmentContextBackupEntry {
lat: number | null;
lon: number | null;
locationLabel: string | null;
/**
* The sealed location, base64. Disaster-recovery files only; a portable
* file carries the readable columns instead.
*/
locationEncrypted?: string | null;
/**
* Which precedence rule chose the location. Carried because it is the only
* record that this day was NOT the home city, and because a re-resolve
@@ -106,6 +165,8 @@ export interface EnvironmentTravelLocationBackupEntry {
lat: number | null;
lon: number | null;
label: string | null;
/** The sealed location, base64; disaster-recovery files only. */
locationEncrypted?: string | null;
createdAt: string;
updatedAt: string;
}
@@ -132,6 +193,7 @@ const ENVIRONMENT_CONTEXT_BACKUP_SELECT = {
lat: true,
lon: true,
locationLabel: true,
locationEncrypted: true,
source: true,
tempMin: true,
tempMax: true,
@@ -145,6 +207,28 @@ const ENVIRONMENT_CONTEXT_BACKUP_SELECT = {
humidityMean: true,
cloudMean: true,
weatherCode: true,
apparentMax: true,
pm25Mean: true,
pm25Max: true,
pm10Mean: true,
no2Mean: true,
so2Mean: true,
coMean: true,
o3Max8h: true,
eaqiMax: true,
usaqiMax: true,
uvIndexMax: true,
dustMax: true,
aodMax: true,
pollenAlderMax: true,
pollenBirchMax: true,
pollenGrassMax: true,
pollenMugwortMax: true,
pollenOliveMax: true,
pollenRagweedMax: true,
aqDomain: true,
aqHours: true,
aqFetchedAt: true,
fetchedAt: true,
createdAt: true,
updatedAt: true,
@@ -156,10 +240,53 @@ const ENVIRONMENT_TRAVEL_LOCATION_BACKUP_SELECT = {
lat: true,
lon: true,
label: true,
locationEncrypted: true,
createdAt: true,
updatedAt: true,
} as const satisfies Prisma.EnvironmentTravelLocationSelect;
/** A sealed column as it rides a disaster-recovery file. */
function toBase64(bytes: Uint8Array | null): string | null {
return bytes && bytes.byteLength > 0
? Buffer.from(bytes).toString("base64")
: null;
}
/**
* The location columns of one entry: opened and readable for a portable
* file, as stored plus the sealed value for a disaster-recovery file.
*/
function locationFields(
row: {
lat: number | null;
lon: number | null;
label: string | null;
locationEncrypted: Uint8Array | null;
},
disasterRecovery: boolean,
): {
lat: number | null;
lon: number | null;
label: string | null;
locationEncrypted?: string | null;
} {
if (disasterRecovery) {
return {
lat: row.lat,
lon: row.lon,
label: row.label,
locationEncrypted: toBase64(row.locationEncrypted),
};
}
const location = readLocation({
sealed: row.locationEncrypted,
lat: row.lat,
lon: row.lon,
label: row.label,
});
return location ?? { lat: null, lon: null, label: null };
}
/**
* Build the environment slice of a user's full backup.
*
@@ -172,7 +299,9 @@ export async function buildEnvironmentBackupSection(
"environmentContext" | "environmentTravelLocation"
>,
userId: string,
options: EnvironmentBackupOptions = {},
): Promise<EnvironmentBackupSection> {
const disasterRecovery = options.purpose === "disaster-recovery";
const [contextRows, travelRows] = await Promise.all([
prisma.environmentContext.findMany({
where: { userId },
@@ -187,34 +316,64 @@ export async function buildEnvironmentBackupSection(
]);
return {
environmentContexts: contextRows.map((row) => ({
date: row.date,
lat: row.lat,
lon: row.lon,
locationLabel: row.locationLabel,
source: row.source,
tempMin: row.tempMin,
tempMax: row.tempMax,
tempMean: row.tempMean,
apparentMean: row.apparentMean,
sunshineSec: row.sunshineSec,
daylightSec: row.daylightSec,
precipSum: row.precipSum,
pressureMean: row.pressureMean,
pressureDelta: row.pressureDelta,
humidityMean: row.humidityMean,
cloudMean: row.cloudMean,
weatherCode: row.weatherCode,
fetchedAt: row.fetchedAt.toISOString(),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
})),
environmentContexts: contextRows.map((row) => {
const { label, ...location } = locationFields(
{
lat: row.lat,
lon: row.lon,
label: row.locationLabel,
locationEncrypted: row.locationEncrypted,
},
disasterRecovery,
);
return {
date: row.date,
...location,
locationLabel: label,
source: row.source,
tempMin: row.tempMin,
tempMax: row.tempMax,
tempMean: row.tempMean,
apparentMean: row.apparentMean,
sunshineSec: row.sunshineSec,
daylightSec: row.daylightSec,
precipSum: row.precipSum,
pressureMean: row.pressureMean,
pressureDelta: row.pressureDelta,
humidityMean: row.humidityMean,
cloudMean: row.cloudMean,
weatherCode: row.weatherCode,
apparentMax: row.apparentMax,
pm25Mean: row.pm25Mean,
pm25Max: row.pm25Max,
pm10Mean: row.pm10Mean,
no2Mean: row.no2Mean,
so2Mean: row.so2Mean,
coMean: row.coMean,
o3Max8h: row.o3Max8h,
eaqiMax: row.eaqiMax,
usaqiMax: row.usaqiMax,
uvIndexMax: row.uvIndexMax,
dustMax: row.dustMax,
aodMax: row.aodMax,
pollenAlderMax: row.pollenAlderMax,
pollenBirchMax: row.pollenBirchMax,
pollenGrassMax: row.pollenGrassMax,
pollenMugwortMax: row.pollenMugwortMax,
pollenOliveMax: row.pollenOliveMax,
pollenRagweedMax: row.pollenRagweedMax,
aqDomain: row.aqDomain,
aqHours: row.aqHours,
aqFetchedAt: row.aqFetchedAt?.toISOString() ?? null,
fetchedAt: row.fetchedAt.toISOString(),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}),
environmentTravelLocations: travelRows.map((row) => ({
startDate: row.startDate,
endDate: row.endDate,
lat: row.lat,
lon: row.lon,
label: row.label,
...locationFields(row, disasterRecovery),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
})),
@@ -254,6 +413,9 @@ export type RestoredEnvironmentContext = Pick<
OptionalNullable<
Pick<
EnvironmentContextBackupEntry,
| "locationEncrypted"
| (typeof AIR_QUALITY_COLUMNS)[number]
| "aqFetchedAt"
| "tempMin"
| "tempMax"
| "tempMean"
@@ -277,9 +439,38 @@ export type RestoredEnvironmentTravelLocation = Pick<
"startDate" | "endDate" | "lat" | "lon" | "label"
> &
OptionalNullable<
Pick<EnvironmentTravelLocationBackupEntry, "createdAt" | "updatedAt">
Pick<
EnvironmentTravelLocationBackupEntry,
"locationEncrypted" | "createdAt" | "updatedAt"
>
>;
/**
* The sealed location a restored row is written with: the file's sealed
* value as it came, or the file's readable location sealed under this host's
* key (rounded to the privacy floor first, so a file written before v1.39.4
* with two decimals comes back coarse). Null when the file carries neither.
*/
function restoredLocation(entry: {
locationEncrypted?: string | null;
lat: number | null;
lon: number | null;
label: string | null;
}): Uint8Array<ArrayBuffer> | null {
if (typeof entry.locationEncrypted === "string") {
const buffer = Buffer.from(entry.locationEncrypted, "base64");
if (buffer.byteLength > 0) {
const bytes = new Uint8Array(new ArrayBuffer(buffer.byteLength));
bytes.set(buffer);
return bytes;
}
}
if (entry.lat == null || entry.lon == null || entry.label == null) {
return null;
}
return sealLocation({ lat: entry.lat, lon: entry.lon, label: entry.label });
}
export interface EnvironmentRestoreInput {
environmentContexts: RestoredEnvironmentContext[];
environmentTravelLocations: RestoredEnvironmentTravelLocation[];
@@ -318,11 +509,11 @@ export async function restoreEnvironmentData(
// neither bound goes near a `Date`.
startDate: entry.startDate,
endDate: entry.endDate,
// A file written before v1.39.4 carries 2-decimal coordinates; they
// come back at today's privacy floor.
lat: entry.lat == null ? null : roundCoarse(entry.lat),
lon: entry.lon == null ? null : roundCoarse(entry.lon),
label: entry.label,
// Sealed, never readable: see `restoredLocation`.
locationEncrypted: restoredLocation(entry),
lat: null,
lon: null,
label: null,
...(entry.createdAt ? { createdAt: new Date(entry.createdAt) } : {}),
...(entry.updatedAt ? { updatedAt: new Date(entry.updatedAt) } : {}),
})),
@@ -334,9 +525,15 @@ export async function restoreEnvironmentData(
data: payload.environmentContexts.map((entry) => ({
userId: ownerId,
date: entry.date,
lat: entry.lat == null ? null : roundCoarse(entry.lat),
lon: entry.lon == null ? null : roundCoarse(entry.lon),
locationLabel: entry.locationLabel,
locationEncrypted: restoredLocation({
locationEncrypted: entry.locationEncrypted,
lat: entry.lat,
lon: entry.lon,
label: entry.locationLabel,
}),
lat: null,
lon: null,
locationLabel: null,
source: entry.source,
tempMin: entry.tempMin ?? null,
tempMax: entry.tempMax ?? null,
@@ -350,6 +547,12 @@ export async function restoreEnvironmentData(
humidityMean: entry.humidityMean ?? null,
cloudMean: entry.cloudMean ?? null,
weatherCode: entry.weatherCode ?? null,
...Object.fromEntries(
AIR_QUALITY_COLUMNS.map((column) => [column, entry[column] ?? null]),
),
// Verbatim, like `fetchedAt`; absent means the air-quality part was
// never fetched, and the nightly gap fill will ask for it.
aqFetchedAt: entry.aqFetchedAt ? new Date(entry.aqFetchedAt) : null,
// Verbatim, not stamped: this says when the feed was read, not when
// the row was written back.
...(entry.fetchedAt ? { fetchedAt: new Date(entry.fetchedAt) } : {}),
+5 -3
View File
@@ -886,9 +886,11 @@ export async function buildFullBackupPayload(
buildAwardsBackupSection(prisma, userId),
// The per-day readings and the location periods that explain them, which
// is why one section carries both. Both ends live in
// `src/lib/export/environment-backup.ts`, and the purpose is absent for
// the same reason as the awards above.
buildEnvironmentBackupSection(prisma, userId),
// `src/lib/export/environment-backup.ts`. Since v1.42 the locations are
// sealed, so the purpose decides whether they ride opened or sealed.
buildEnvironmentBackupSection(prisma, userId, {
purpose: disasterRecovery ? "disaster-recovery" : "portable-export",
}),
// The needs-based setup answers. One small row, and the only copy of what
// the person told the flow. Both ends live in
// `src/lib/export/onboarding-backup.ts` beside each other.