fix(environment): fail closed on a travel period that does not open

A location period whose sealed location this host cannot open was
dropped from the resolver, so its days fell through to the home and the
next refresh overwrote the stored travel days with the weather at home.
Those days are now skipped and the run logs an error signal.

A restore now opens every sealed location once. One that does not open
is kept sealed, so the refresh keeps skipping its days, and is named in
the restore report under a new catalogue.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 22:37:56 +02:00
1 parent 342d73fb36
commit 78cde56542
14 files changed
+223 -32

No files matched your search

+2 -1
View File
@@ -7811,7 +7811,8 @@
"restoreSkippedCycleCiphertext": "Zyklus-Notiz oder -Bezeichnung mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (Tag oder Symptom ohne sie behalten)",
"restoreSkippedMoodLabelCiphertext": "Name eines Stimmungs-Tags oder einer Gruppe mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (ohne Namen behalten)",
"restoreSkippedVisitCiphertext": "Text zu Arztbesuch oder Praxis mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (ohne ihn behalten)",
"restoreSkippedVaccinationCiphertext": "Impf-Notiz mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (Impfung ohne sie behalten)"
"restoreSkippedVaccinationCiphertext": "Impf-Notiz mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (Impfung ohne sie behalten)",
"restoreSkippedEnvironmentLocationCiphertext": "Ort eines Umweltzeitraums oder -tags mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (verschlüsselt behalten, diese Tage werden nicht neu abgerufen)"
},
"danger-zone": {
"title": "Gefahrenzone",
+2 -1
View File
@@ -7811,7 +7811,8 @@
"restoreSkippedCycleCiphertext": "Cycle note or label sealed with a key this server does not hold (day or symptom kept without it)",
"restoreSkippedMoodLabelCiphertext": "Mood tag or group name sealed with a key this server does not hold (kept without its name)",
"restoreSkippedVisitCiphertext": "Visit or practitioner text sealed with a key this server does not hold (kept without it)",
"restoreSkippedVaccinationCiphertext": "Vaccination note sealed with a key this server does not hold (dose kept without it)"
"restoreSkippedVaccinationCiphertext": "Vaccination note sealed with a key this server does not hold (dose kept without it)",
"restoreSkippedEnvironmentLocationCiphertext": "Location of an environment period or day sealed with a key this server does not hold (kept sealed, those days are not refreshed)"
},
"danger-zone": {
"title": "Danger zone",
+2 -1
View File
@@ -7811,7 +7811,8 @@
"restoreSkippedCycleCiphertext": "Nota o etiqueta del ciclo cifrada con una clave que este servidor no tiene (el día o el síntoma se conserva sin ella)",
"restoreSkippedMoodLabelCiphertext": "Nombre de etiqueta o grupo de ánimo cifrado con una clave que este servidor no tiene (se conserva sin el nombre)",
"restoreSkippedVisitCiphertext": "Texto de visita o profesional cifrado con una clave que este servidor no tiene (se conserva sin él)",
"restoreSkippedVaccinationCiphertext": "Nota de vacunación cifrada con una clave que este servidor no tiene (la dosis se conserva sin ella)"
"restoreSkippedVaccinationCiphertext": "Nota de vacunación cifrada con una clave que este servidor no tiene (la dosis se conserva sin ella)",
"restoreSkippedEnvironmentLocationCiphertext": "Ubicación de un periodo o día ambiental cifrada con una clave que este servidor no tiene (se conserva cifrada, esos días no se actualizan)"
},
"danger-zone": {
"title": "Zona de peligro",
+2 -1
View File
@@ -7811,7 +7811,8 @@
"restoreSkippedCycleCiphertext": "Note ou libellé du cycle chiffré avec une clé que ce serveur ne possède pas (jour ou symptôme conservé sans)",
"restoreSkippedMoodLabelCiphertext": "Nom d’étiquette ou de groupe d’humeur chiffré avec une clé que ce serveur ne possède pas (conservé sans son nom)",
"restoreSkippedVisitCiphertext": "Texte de consultation ou de praticien chiffré avec une clé que ce serveur ne possède pas (conservé sans)",
"restoreSkippedVaccinationCiphertext": "Note de vaccination chiffrée avec une clé que ce serveur ne possède pas (dose conservée sans)"
"restoreSkippedVaccinationCiphertext": "Note de vaccination chiffrée avec une clé que ce serveur ne possède pas (dose conservée sans)",
"restoreSkippedEnvironmentLocationCiphertext": "Lieu d'une période ou d'un jour environnemental chiffré avec une clé que ce serveur ne possède pas (conservé chiffré, ces jours ne sont pas actualisés)"
},
"danger-zone": {
"title": "Zone de danger",
+2 -1
View File
@@ -7811,7 +7811,8 @@
"restoreSkippedCycleCiphertext": "Nota o etichetta del ciclo cifrata con una chiave che questo server non possiede (giorno o sintomo mantenuto senza)",
"restoreSkippedMoodLabelCiphertext": "Nome di tag o gruppo dell’umore cifrato con una chiave che questo server non possiede (mantenuto senza nome)",
"restoreSkippedVisitCiphertext": "Testo di visita o professionista cifrato con una chiave che questo server non possiede (mantenuto senza)",
"restoreSkippedVaccinationCiphertext": "Nota di vaccinazione cifrata con una chiave che questo server non possiede (dose mantenuta senza)"
"restoreSkippedVaccinationCiphertext": "Nota di vaccinazione cifrata con una chiave che questo server non possiede (dose mantenuta senza)",
"restoreSkippedEnvironmentLocationCiphertext": "Luogo di un periodo o giorno ambientale cifrato con una chiave che questo server non possiede (mantenuto cifrato, quei giorni non vengono aggiornati)"
},
"danger-zone": {
"title": "Zona pericolosa",
+2 -1
View File
@@ -7787,7 +7787,8 @@
"restoreSkippedCycleCiphertext": "이 서버에 없는 키로 암호화된 주기 메모 또는 이름(해당 날짜나 증상은 그 내용 없이 유지됨)",
"restoreSkippedMoodLabelCiphertext": "이 서버에 없는 키로 암호화된 기분 태그 또는 그룹 이름(이름 없이 유지됨)",
"restoreSkippedVisitCiphertext": "이 서버에 없는 키로 암호화된 진료 또는 의료진 텍스트(해당 내용 없이 유지됨)",
"restoreSkippedVaccinationCiphertext": "이 서버에 없는 키로 암호화된 예방접종 메모(접종 기록은 메모 없이 유지됨)"
"restoreSkippedVaccinationCiphertext": "이 서버에 없는 키로 암호화된 예방접종 메모(접종 기록은 메모 없이 유지됨)",
"restoreSkippedEnvironmentLocationCiphertext": "이 서버에 없는 키로 암호화된 환경 기간 또는 날짜의 위치(암호화된 채 유지되며 해당 날짜는 새로 가져오지 않음)"
},
"danger-zone": {
"title": "위험 구역",
+2 -1
View File
@@ -7811,7 +7811,8 @@
"restoreSkippedCycleCiphertext": "Notatka lub etykieta cyklu zaszyfrowana kluczem, którego ten serwer nie ma (dzień lub objaw zachowany bez niej)",
"restoreSkippedMoodLabelCiphertext": "Nazwa tagu lub grupy nastroju zaszyfrowana kluczem, którego ten serwer nie ma (zachowana bez nazwy)",
"restoreSkippedVisitCiphertext": "Tekst wizyty lub lekarza zaszyfrowany kluczem, którego ten serwer nie ma (zachowany bez niego)",
"restoreSkippedVaccinationCiphertext": "Notatka szczepienia zaszyfrowana kluczem, którego ten serwer nie ma (dawka zachowana bez niej)"
"restoreSkippedVaccinationCiphertext": "Notatka szczepienia zaszyfrowana kluczem, którego ten serwer nie ma (dawka zachowana bez niej)",
"restoreSkippedEnvironmentLocationCiphertext": "Lokalizacja okresu lub dnia środowiskowego zaszyfrowana kluczem, którego ten serwer nie ma (zachowana w postaci zaszyfrowanej, te dni nie są odświeżane)"
},
"danger-zone": {
"title": "Strefa zagrożenia",
+5
View File
@@ -471,6 +471,11 @@ function catalogueLabel(
if (catalogue === "vaccinationCiphertext") {
return t("admin.section.backups.restoreSkippedVaccinationCiphertext");
}
if (catalogue === "environmentLocationCiphertext") {
return t(
"admin.section.backups.restoreSkippedEnvironmentLocationCiphertext",
);
}
if (catalogue === "moodLabelCiphertext") {
return t("admin.section.backups.restoreSkippedMoodLabelCiphertext");
}
@@ -139,6 +139,41 @@ beforeEach(() => {
afterEach(() => vi.unstubAllEnvs());
describe("fetchAndStoreEnvironment", () => {
it("skips the days of a travel period whose sealed location does not open, never resolving them to home", async () => {
air.mockResolvedValue({ days: [], stopped: null });
const damaged = new Uint8Array([2, 2, 118, 49, ...new Array(40).fill(7)]);
db.travel = [
{
startDate: "2026-05-02",
endDate: "2026-05-03",
lat: null,
lon: null,
label: null,
locationEncrypted: damaged,
},
];
weather.mockImplementation(
async (args: { startDate: string; endDate: string }) => {
const out = [];
for (let d = 1; d <= 4; d++) {
const day = `2026-05-0${d}`;
if (day >= args.startDate && day <= args.endDate) out.push(obs(day));
}
return out;
},
);
const result = await fetchAndStoreEnvironment({
userId: "u1",
startDate: "2026-05-01",
endDate: "2026-05-04",
});
const writtenDays = db.upserts.map((u) => u.create.date);
expect(writtenDays).not.toContain("2026-05-02");
expect(writtenDays).not.toContain("2026-05-03");
expect(writtenDays.sort()).toEqual(["2026-05-01", "2026-05-04"]);
expect(result.skipped).toBe(2);
});
it("asks both feeds with the coarse home only and writes the day sealed", async () => {
air.mockResolvedValue({ days: [airDay("2026-05-01")], stopped: null });
const result = await fetchAndStoreEnvironment({
+59 -10
View File
@@ -42,6 +42,7 @@ import {
type DailyAirQualityObservation,
} from "@/lib/environment/open-meteo-air-quality";
import { readLocation, sealLocation } from "@/lib/environment/location-cipher";
import { emitSignal } from "@/lib/logging/signal";
export { isAirQualityActive };
import { OpenMeteoBudgetExhaustedError } from "@/lib/environment/request-budget";
@@ -148,7 +149,10 @@ function groupKey(loc: ResolvedLocation): string {
export interface FetchAndStoreResult {
/** Days that resolved to a location and were upserted. */
stored: number;
/** Days skipped because no location resolved (no home, no override). */
/**
* Days skipped because no location resolved (no home, no override), or
* because they fall in a period whose sealed location does not open.
*/
skipped: number;
/** Distinct upstream fetches made. */
fetches: number;
@@ -203,8 +207,23 @@ export function resolveHome(user: {
};
}
/** Every dated location period of an account, opened. */
async function readTravelOverrides(userId: string): Promise<TravelOverride[]> {
/** A location period whose sealed location this host cannot open. */
interface UnreadablePeriod {
startDate: string;
endDate: string;
}
/**
* Every dated location period of an account, opened, plus the periods whose
* sealed location did not open (a key missing from `ENCRYPTION_KEYS`, a
* damaged value). The second list exists so the caller can fail closed: a
* period it cannot read still says "this account was not at home on these
* days", so those days must not fall through to the home location.
*/
async function readTravelOverrides(userId: string): Promise<{
overrides: TravelOverride[];
unreadable: UnreadablePeriod[];
}> {
const records = await prisma.environmentTravelLocation.findMany({
where: { userId },
select: {
@@ -216,17 +235,33 @@ async function readTravelOverrides(userId: string): Promise<TravelOverride[]> {
locationEncrypted: true,
},
});
return records.flatMap((row) => {
const overrides: TravelOverride[] = [];
const unreadable: UnreadablePeriod[] = [];
for (const row of records) {
const location = readLocation({
sealed: row.locationEncrypted,
lat: row.lat,
lon: row.lon,
label: row.label,
});
return location
? [{ startDate: row.startDate, endDate: row.endDate, ...location }]
: [];
});
if (location) {
overrides.push({
startDate: row.startDate,
endDate: row.endDate,
...location,
});
} else if (row.locationEncrypted && row.locationEncrypted.byteLength > 0) {
// `readLocation` returns null for a present sealed value only when it
// does not open.
unreadable.push({ startDate: row.startDate, endDate: row.endDate });
}
}
return { overrides, unreadable };
}
/** True when `day` falls inside one of `periods`. */
function inAnyPeriod(day: string, periods: readonly UnreadablePeriod[]) {
return periods.some((p) => day >= p.startDate && day <= p.endDate);
}
/**
@@ -251,7 +286,17 @@ export async function fetchAndStoreEnvironment(args: {
}
const home = resolveHome(user);
const travelRows = await readTravelOverrides(userId);
const travel = await readTravelOverrides(userId);
if (travel.unreadable.length > 0) {
// Fail closed: the days of a period this host cannot open are skipped,
// never resolved to the home location, so a stored travel day is not
// overwritten with the weather at home.
emitSignal({
action: "environment.travel_location.undecryptable",
level: "error",
meta: { periods: travel.unreadable.length },
});
}
const airQuality = isAirQualityActive(user.environmentAirQualityEnabled);
// The timezone used to enumerate AND fetch days — one tz keeps the stored
@@ -262,7 +307,11 @@ export async function fetchAndStoreEnvironment(args: {
const groups = new Map<string, { loc: ResolvedLocation; days: string[] }>();
let skipped = 0;
for (const day of days) {
const loc = resolveLocationForDay(day, home, travelRows);
if (inAnyPeriod(day, travel.unreadable)) {
skipped += 1;
continue;
}
const loc = resolveLocationForDay(day, home, travel.overrides);
if (!loc) {
skipped += 1;
continue;
+59 -14
View File
@@ -79,7 +79,15 @@ import { Buffer } from "node:buffer";
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
import type { EnvironmentLocationSource } from "@/generated/prisma/client";
import type { AirQualityDay } from "@/lib/environment/air-quality-contract";
import { readLocation, sealLocation } from "@/lib/environment/location-cipher";
import {
openLocation,
readLocation,
sealLocation,
} from "@/lib/environment/location-cipher";
import {
recordUnknownKeys,
type RestoreSkipLog,
} from "@/lib/export/restore-skips";
export interface EnvironmentBackupOptions {
purpose?: "portable-export" | "disaster-recovery";
@@ -450,18 +458,35 @@ export type RestoredEnvironmentTravelLocation = Pick<
* 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.
*
* A sealed value is opened once to check it. One this host cannot open is
* still written as it came, and its path is added to `unopened` so the
* restore report names it. Writing it without a location would be worse: a
* period without a location reads as no period at all, and the next refresh
* would rewrite those days with the weather at home. Kept sealed, the
* refresh skips them (`fetchAndStoreEnvironment` fails closed), and the
* location comes back once the missing key is added.
*/
function restoredLocation(entry: {
locationEncrypted?: string | null;
lat: number | null;
lon: number | null;
label: string | null;
}): Uint8Array<ArrayBuffer> | null {
function restoredLocation(
entry: {
locationEncrypted?: string | null;
lat: number | null;
lon: number | null;
label: string | null;
},
path: string,
unopened: string[],
): 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);
try {
openLocation(bytes);
} catch {
unopened.push(path);
}
return bytes;
}
}
@@ -495,7 +520,10 @@ export async function restoreEnvironmentData(
tx: Prisma.TransactionClient,
ownerId: string,
payload: EnvironmentRestoreInput,
skips?: RestoreSkipLog,
): Promise<EnvironmentRestoreCleared> {
// Sealed locations this host's keys do not open, by file path.
const unopened: string[] = [];
const [clearedTravel, clearedContexts] = await Promise.all([
tx.environmentTravelLocation.deleteMany({ where: { userId: ownerId } }),
tx.environmentContext.deleteMany({ where: { userId: ownerId } }),
@@ -510,7 +538,11 @@ export async function restoreEnvironmentData(
startDate: entry.startDate,
endDate: entry.endDate,
// Sealed, never readable: see `restoredLocation`.
locationEncrypted: restoredLocation(entry),
locationEncrypted: restoredLocation(
entry,
`environmentTravelLocations.${entry.startDate}..${entry.endDate}`,
unopened,
),
lat: null,
lon: null,
label: null,
@@ -525,12 +557,16 @@ export async function restoreEnvironmentData(
data: payload.environmentContexts.map((entry) => ({
userId: ownerId,
date: entry.date,
locationEncrypted: restoredLocation({
locationEncrypted: entry.locationEncrypted,
lat: entry.lat,
lon: entry.lon,
label: entry.locationLabel,
}),
locationEncrypted: restoredLocation(
{
locationEncrypted: entry.locationEncrypted,
lat: entry.lat,
lon: entry.lon,
label: entry.locationLabel,
},
`environmentContexts.${entry.date}`,
unopened,
),
lat: null,
lon: null,
locationLabel: null,
@@ -562,6 +598,15 @@ export async function restoreEnvironmentData(
});
}
if (skips) {
recordUnknownKeys(
skips,
"environmentLocationCiphertext",
unopened,
unopened,
);
}
return {
environmentContexts: clearedContexts.count,
environmentTravelLocations: clearedTravel.count,
+1
View File
@@ -2660,6 +2660,7 @@ export async function restoreBackup(
tx,
ownerId,
payload,
skips,
);
const cleared = {
+2 -1
View File
@@ -195,7 +195,8 @@ export type SkippedCatalogue =
| "cycleCiphertext"
| "moodLabelCiphertext"
| "visitCiphertext"
| "vaccinationCiphertext";
| "vaccinationCiphertext"
| "environmentLocationCiphertext";
/** One key this instance does not know, and the links it cost. */
export interface SkippedCatalogueKey {
@@ -38,6 +38,7 @@ function throughFile(section: unknown): EnvironmentRestoreInput {
return JSON.parse(JSON.stringify(section)) as EnvironmentRestoreInput;
}
import type { RestoreSkipLog } from "@/lib/export/restore-skips";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
@@ -426,6 +427,53 @@ describe("the environment backup with sealed locations", () => {
).toBe(true);
});
it("keeps a sealed location this host cannot open sealed, and reports it", async () => {
const prisma = getPrismaClient();
const damaged = Buffer.from([2, 2, 118, 49, ...new Array(40).fill(7)]);
const skips: RestoreSkipLog = [];
await prisma.$transaction((tx) =>
restoreEnvironmentData(
tx,
OWNER,
{
environmentTravelLocations: [
{
startDate: "2026-08-01",
endDate: "2026-08-10",
lat: null,
lon: null,
label: null,
locationEncrypted: damaged.toString("base64"),
},
],
environmentContexts: [
{
date: "2026-08-02",
lat: null,
lon: null,
locationLabel: null,
source: "TRAVEL",
locationEncrypted: damaged.toString("base64"),
tempMin: 20,
},
],
},
skips,
),
);
expect(skips.map((s) => [s.catalogue, s.key]).sort()).toEqual([
["environmentLocationCiphertext", "environmentContexts.2026-08-02"],
[
"environmentLocationCiphertext",
"environmentTravelLocations.2026-08-01..2026-08-10",
],
]);
const period = await prisma.environmentTravelLocation.findFirstOrThrow({
where: { userId: OWNER },
});
expect(Buffer.from(period.locationEncrypted!).equals(damaged)).toBe(true);
});
it("restores a file from before air quality as a day never fetched", async () => {
const prisma = getPrismaClient();
await prisma.$transaction((tx) =>