diff --git a/messages/de.json b/messages/de.json index 010e91b92..0c7173452 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3546,6 +3546,7 @@ "sportLabel": "Aktivität", "sportPlaceholder": "Aktivität wählen", "startLabel": "Beginn", + "startHint": "Leer lassen, wenn du gerade fertig bist: Dann zählt es die Dauer von jetzt zurück.", "durationLabel": "Dauer", "distanceLabel": "Distanz ({unit})", "energyLabel": "Aktive Energie (kcal)", diff --git a/messages/en.json b/messages/en.json index b65774c65..3cd9aa9a4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3546,6 +3546,7 @@ "sportLabel": "Activity", "sportPlaceholder": "Choose an activity", "startLabel": "Start", + "startHint": "Leave empty if you just finished: it counts back from now by the duration.", "durationLabel": "Duration", "distanceLabel": "Distance ({unit})", "energyLabel": "Active energy (kcal)", diff --git a/messages/es.json b/messages/es.json index 1f9b678f2..d40131208 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3546,6 +3546,7 @@ "sportLabel": "Actividad", "sportPlaceholder": "Elige una actividad", "startLabel": "Inicio", + "startHint": "Déjalo vacío si acabas de terminar: se cuenta la duración hacia atrás desde ahora.", "durationLabel": "Duración", "distanceLabel": "Distancia ({unit})", "energyLabel": "Energía activa (kcal)", diff --git a/messages/fr.json b/messages/fr.json index 1c76f7992..a5d0afa08 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3546,6 +3546,7 @@ "sportLabel": "Activité", "sportPlaceholder": "Choisir une activité", "startLabel": "Début", + "startHint": "Laissez vide si vous venez de terminer : la durée est comptée à rebours depuis maintenant.", "durationLabel": "Durée", "distanceLabel": "Distance ({unit})", "energyLabel": "Énergie active (kcal)", diff --git a/messages/it.json b/messages/it.json index 61397873a..e0e489863 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3546,6 +3546,7 @@ "sportLabel": "Attività", "sportPlaceholder": "Scegli un’attività", "startLabel": "Inizio", + "startHint": "Lascia vuoto se hai appena finito: la durata viene contata a ritroso da adesso.", "durationLabel": "Durata", "distanceLabel": "Distanza ({unit})", "energyLabel": "Energia attiva (kcal)", diff --git a/messages/ko.json b/messages/ko.json index cdabb7b22..e51af7017 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -3546,6 +3546,7 @@ "sportLabel": "활동", "sportPlaceholder": "활동 선택", "startLabel": "시작", + "startHint": "방금 끝났다면 비워 두세요. 지금부터 시간을 거꾸로 계산합니다.", "durationLabel": "시간", "distanceLabel": "거리({unit})", "energyLabel": "활동 에너지(kcal)", diff --git a/messages/pl.json b/messages/pl.json index 1f931f743..caa7f9245 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3546,6 +3546,7 @@ "sportLabel": "Aktywność", "sportPlaceholder": "Wybierz aktywność", "startLabel": "Początek", + "startHint": "Zostaw puste, jeśli właśnie skończyłeś: czas trwania liczy się wstecz od teraz.", "durationLabel": "Czas trwania", "distanceLabel": "Dystans ({unit})", "energyLabel": "Energia czynna (kcal)", diff --git a/src/components/workouts/manual-workout-form.tsx b/src/components/workouts/manual-workout-form.tsx index a254605e4..c849056e4 100644 --- a/src/components/workouts/manual-workout-form.tsx +++ b/src/components/workouts/manual-workout-form.tsx @@ -276,12 +276,18 @@ export function ManualWorkoutForm({ value={draft.start} onChange={(value) => update("start", value)} max={wallClockNow(new Date(), timezone)} - required - aria-required="true" aria-invalid={!!errors.start || undefined} - aria-describedby={describedBy("start", `${ids.start}-error`)} + aria-describedby={[ + `${ids.start}-hint`, + describedBy("start", `${ids.start}-error`), + ] + .filter(Boolean) + .join(" ")} data-testid="manual-workout-start" /> +

+ {t("insights.workouts.manual.startHint")} +

diff --git a/src/lib/boot/key-check.ts b/src/lib/boot/key-check.ts index 96b42100c..9b009b71f 100644 --- a/src/lib/boot/key-check.ts +++ b/src/lib/boot/key-check.ts @@ -25,8 +25,9 @@ const INCONCLUSIVE_EXPLANATION: Record = { "single-value": "the only stored value found under that id did not open.", mixed: "the oldest stored values under that id do not open, newer ones do. " + - "That is what a wrong key that served for a while leaves behind; check " + - "that this is the key the database was first written with.", + "Either the old rows were sealed under a key that is gone, or a wrong " + + "key served for a while and wrote the newer ones; check that this is " + + "the key the database was first written with.", unsampled: "the values stored under that id are too large to probe at start-up.", incomplete: diff --git a/src/lib/crypto/__tests__/canary.test.ts b/src/lib/crypto/__tests__/canary.test.ts index f816bbb4b..63e86f94c 100644 --- a/src/lib/crypto/__tests__/canary.test.ts +++ b/src/lib/crypto/__tests__/canary.test.ts @@ -23,6 +23,7 @@ import { keyMismatchLogBlock, probeValueOpens, resolveColumnLocation, + sqlColumnSampler, type CanaryClient, type ColumnSampler, } from "../canary"; @@ -62,6 +63,8 @@ function fakeClient(opts: { probe?: Record; /** Make the sampler throw for these columns. */ unreadable?: string[]; + /** Make the sampler throw for every column when asked for its newest values. */ + newestUnreadable?: boolean; /** Another process seals this id (with this ciphertext) just before us. */ race?: { keyId: string; ciphertext: string }; }) { @@ -92,9 +95,12 @@ function fakeClient(opts: { return 1; }, } as unknown as CanaryClient; - const sampler: ColumnSampler = async (column, keyId, limit) => { + const sampler: ColumnSampler = async (column, keyId, limit, order) => { const key = `${column.model}.${column.field}`; if (opts.unreadable?.includes(key)) throw new Error("relation missing"); + if (opts.newestUnreadable && order === "newest") { + throw new Error("canceling statement due to statement timeout"); + } const rows = (opts.probe?.[key] ?? []).map((v) => typeof v === "string" || v instanceof Uint8Array ? { value: v, ts: null as Date | null, codec: null } @@ -113,15 +119,15 @@ function fakeClient(opts: { extractKeyIdFromBytes(b) === keyId ); }); - return under + const oldestFirst = under .map((r, i) => ({ r, i })) .sort((a, b) => { const ta = a.r.ts?.getTime() ?? Infinity; const tb = b.r.ts?.getTime() ?? Infinity; return ta === tb ? a.i - b.i : ta - tb; - }) - .slice(0, limit) - .map(({ r }) => r); + }); + if (order === "newest") oldestFirst.reverse(); + return oldestFirst.slice(0, limit).map(({ r }) => r); }; const check = (budget?: number) => checkEncryptionKeyCanaries(client, { sampler, probeBudgetMs: budget }); @@ -369,6 +375,103 @@ describe("encryption key canary", () => { }); }); + describe("the newest values are asked before a refusal", () => { + /** + * Eight old values (two in each of four columns, so the oldest-first + * pool holds nothing else) sealed under `oldKey`, and two newer values + * per column sealed under `newKey`. Returns the fake, configured with + * KEY_B. + */ + function seedOldThenNew(oldKey: string, newKey: string) { + configureKey(oldKey); + const old = [ + encrypt("old 1"), + encrypt("old 2"), + encrypt("old 3"), + encrypt("old 4"), + encryptToBytes("old note 1"), + encryptToBytes("old note 2"), + encryptToBytes("old note 3"), + encryptToBytes("old note 4"), + ]; + configureKey(newKey); + const recent = [ + encrypt("new 1"), + encrypt("new 2"), + encrypt("new 3"), + encrypt("new 4"), + encryptToBytes("new note 1"), + encryptToBytes("new note 2"), + encryptToBytes("new note 3"), + encryptToBytes("new note 4"), + ]; + configureKey(KEY_B); + const columns = [ + "User.codexAccessTokenEncrypted", + "User.codexRefreshTokenEncrypted", + "MoodEntry.noteEncrypted", + "Measurement.notesEncrypted", + ]; + const probe: Record = {}; + columns.forEach((key, i) => { + probe[key] = [ + { value: recent[2 * i], ts: `2026-09-0${i + 1}` }, + { value: old[2 * i], ts: `2023-0${i + 1}-01` }, + { value: recent[2 * i + 1], ts: `2026-09-1${i + 1}` }, + { value: old[2 * i + 1], ts: `2023-0${i + 1}-02` }, + ]; + }); + return probe; + } + + it("old rows under a lost key and current data under the configured key: mixed, not a mismatch", async () => { + // A database reinstalled under a fresh key long ago: the old rows are + // sealed under a key nobody has any more, and everything written since + // opens under the configured one. Refusing would take a working + // install down on upgrade. + const { inserts, canaries, check } = fakeClient({ + probe: seedOldThenNew(KEY_A, KEY_B), + }); + expect(await check()).toEqual( + ok({ inconclusive: [{ keyId: "v1", reason: "mixed" }] }), + ); + // A newest-only open never seals. + expect(inserts).toEqual([]); + expect(canaries.size).toBe(0); + }); + + it("old rows under A and newer rows under B, configured B, is never sealed or verified", async () => { + const { inserts, check } = fakeClient({ + probe: seedOldThenNew(KEY_A, KEY_B), + }); + const outcome = await check(); + expect(outcome.state).toBe("ok"); + if (outcome.state !== "ok") return; + expect(outcome.written).toEqual([]); + expect(outcome.verified).toEqual([]); + expect(inserts).toEqual([]); + }); + + it("old AND newest values that all fail are a mismatch", async () => { + const { inserts, check } = fakeClient({ + probe: seedOldThenNew(KEY_A, KEY_A), + }); + expect(await check()).toEqual({ state: "mismatch", keyIds: ["v1"] }); + expect(inserts).toEqual([]); + }); + + it("the newest values that cannot be read are inconclusive, never a mismatch", async () => { + const { inserts, check } = fakeClient({ + probe: seedOldThenNew(KEY_A, KEY_A), + newestUnreadable: true, + }); + expect(await check()).toEqual( + ok({ inconclusive: [{ keyId: "v1", reason: "incomplete" }] }), + ); + expect(inserts).toEqual([]); + }); + }); + describe("nothing unproven seals", () => { it("a probe out of time is inconclusive, even over data that opens", async () => { const { inserts, check } = fakeClient({ @@ -518,6 +621,86 @@ describe("the SQL sampler's column map", () => { }); }); +describe("the SQL sampler's bounds", () => { + beforeEach(() => { + vi.unstubAllEnvs(); + configureKey(KEY_A); + }); + + /** A client whose probe queries the server cancels at the timeout. */ + function timingOutClient() { + const model = new PrismaClient({ + adapter: new PrismaPg({ + connectionString: "postgres://x:y@127.0.0.1:1/x", + }), + }) as unknown as { _runtimeDataModel: unknown }; + const settings: unknown[][] = []; + const statements: string[] = []; + const transactionOptions: unknown[] = []; + const tx = { + async $executeRaw(_q: TemplateStringsArray, ...values: unknown[]) { + settings.push(values); + return 1; + }, + async $queryRawUnsafe(sql: string) { + statements.push(sql); + throw new Error("canceling statement due to statement timeout"); + }, + }; + const client = { + _runtimeDataModel: model._runtimeDataModel, + $queryRaw: async () => [], + $executeRaw: async () => { + throw new Error("nothing may be sealed"); + }, + $queryRawUnsafe: async () => [], + async $transaction( + fn: (t: typeof tx) => Promise, + options: unknown, + ) { + transactionOptions.push(options); + return fn(tx); + }, + } as unknown as CanaryClient; + return { client, settings, statements, transactionOptions }; + } + + it("a probe query cancelled at its statement timeout is inconclusive, not a hang or a mismatch", async () => { + const fake = timingOutClient(); + expect(await checkEncryptionKeyCanaries(fake.client)).toEqual( + ok({ inconclusive: [{ keyId: "v1", reason: "incomplete" }] }), + ); + // Every probe query runs under a transaction-local statement timeout. + expect(fake.settings).toEqual([["2000"]]); + expect(fake.statements).toHaveLength(1); + expect(fake.transactionOptions[0]).toMatchObject({ timeout: 7_000 }); + }); + + it("reads the newest values in the exact mirror of the oldest order", async () => { + const fake = timingOutClient(); + const sampler = sqlColumnSampler(fake.client, { statementTimeoutMs: 50 }); + const user = ENCRYPTED_COLUMNS.find((c) => c.model === "User")!; + await expect(sampler(user, "v1", 2, "oldest")).rejects.toThrow(); + await expect(sampler(user, "v1", 2, "newest")).rejects.toThrow(); + expect(fake.settings).toEqual([["50"], ["50"]]); + expect(fake.statements[0]).toContain( + 'ORDER BY "updated_at" ASC NULLS LAST, "id" ASC LIMIT', + ); + expect(fake.statements[1]).toContain( + 'ORDER BY "updated_at" DESC NULLS FIRST, "id" DESC LIMIT', + ); + }); + + it("refuses to run without transactions rather than run unbounded", () => { + const client = { + $queryRaw: async () => [], + $executeRaw: async () => 0, + $queryRawUnsafe: async () => [], + } as unknown as CanaryClient; + expect(() => sqlColumnSampler(client)).toThrow(); + }); +}); + describe("key fingerprint and copy check", () => { beforeEach(() => { vi.unstubAllEnvs(); diff --git a/src/lib/crypto/canary.ts b/src/lib/crypto/canary.ts index 9cb3b0ce5..a894d5b6f 100644 --- a/src/lib/crypto/canary.ts +++ b/src/lib/crypto/canary.ts @@ -49,7 +49,8 @@ * problem: a wrong key opens nothing. Values newer than the first opening * one do not count against it; they are what a wrong-key process would * have left behind. The canary is written. - * - `fails`: two or more values were tried and none opened. That is what a + * - `fails`: two or more of the oldest values were tried and none opened, + * AND none of the newest values opens either (see below). That is what a * wrong key looks like; the process refuses (mismatch). * - `none`: every column answered, and none holds a single value under the * key id. A genuinely fresh install (or a new id added for rotation); the @@ -64,6 +65,41 @@ * server down with 503 on every request; not sealing because that would * lock the right key out later. * + * Before the oldest values may refuse, the newest are asked: the same columns, + * the same `PROBE_ROWS_PER_COLUMN`, in reverse order, and the newest + * `PROBE_SAMPLE_LIMIT` of those tried. If any opens, the verdict is + * `inconclusive` (`mixed`), never `fails`. An install reinstalled under a + * fresh key long ago holds old rows sealed under a key that is gone and all + * current data under the key it runs with; it works today, and refusing it + * would take it down on upgrade. The newest values only ever turn a refusal + * into a warning. They never seal: a newest-only open is exactly what a wrong + * key that served for a while leaves behind, so sealing stays with the + * oldest-first rule above. A newest pass that cannot finish (time budget, + * unreadable column) is `incomplete`, not `fails`. + * + * Every probe query is bounded on its own: it runs in a transaction with + * `SET LOCAL statement_timeout` (`PROBE_STATEMENT_TIMEOUT_MS`), so one large + * or locked table ends that column with an error, and the probe with + * `incomplete`, instead of holding up the boot. The `ORDER BY` is the plain + * timestamp-then-key order (and its exact mirror for the newest pass), which + * a btree index on the timestamp serves in either direction; the primary key + * is always indexed. The time budget is checked between columns. + * + * ## When the outcome is `mismatch` + * + * Exactly three ways, and no other: + * + * 1. a stored canary for a configured key id does not open under that key + * (or opens to another value); + * 2. no canary, and the probe says `fails`: at least two of the oldest + * values were tried and none opened, and none of the newest values + * opened either; + * 3. the first seal was lost to another process and the canary it wrote + * does not open under this process's key. + * + * Everything else (an unreadable or timed-out column, an exhausted budget, + * a single value, mixed old and new) serves. + * * The first seal can race another process. `ON CONFLICT DO NOTHING` keeps the * row the other process wrote; when this process's insert affected no row, * the stored canary is read back and must open under this process's key, or @@ -110,6 +146,9 @@ export const PROBE_VALUE_MAX_BYTES = 2 * 1024 * 1024; /** How long the probe may take before it gives up as `inconclusive`. */ export const PROBE_BUDGET_MS = 10_000; +/** How long one probe query may run before the server cancels it. */ +export const PROBE_STATEMENT_TIMEOUT_MS = 2_000; + /** The key id the legacy bare-base64 string format belongs to. */ const LEGACY_KEY_ID = "v1"; @@ -128,6 +167,19 @@ export interface CanaryClient { ...values: unknown[] ): Promise; $executeRawUnsafe?(query: string, ...values: unknown[]): Promise; + $transaction?( + fn: (tx: ProbeTransaction) => Promise, + options?: { maxWait?: number; timeout?: number }, + ): Promise; +} + +/** The slice of a transaction client one bounded probe query needs. */ +export interface ProbeTransaction { + $executeRaw( + query: TemplateStringsArray, + ...values: unknown[] + ): Promise; + $queryRawUnsafe(query: string, ...values: unknown[]): Promise; } /** One stored value under the key id, as the probe sees it. */ @@ -140,15 +192,19 @@ export interface ProbeRow { ts: Date | null; } +/** Which end of a column's write-time order a sample is read from. */ +export type SampleOrder = "oldest" | "newest"; + /** - * Reads up to `limit` of the oldest values under `keyId` in one column, oldest - * first. Throws when the column cannot be read; an empty array means the - * column holds no value under that id. + * Reads up to `limit` of the oldest (or newest) values under `keyId` in one + * column, in that order. Throws when the column cannot be read; an empty + * array means the column holds no value under that id. */ export type ColumnSampler = ( column: EncryptedColumn, keyId: string, limit: number, + order: SampleOrder, ) => Promise; export type InconclusiveReason = @@ -225,6 +281,46 @@ export function probeValueOpens( } } +type Pooled = { column: EncryptedColumn; row: ProbeRow; order: number }; + +/** + * One value pool from every column, in `order`. Null when the budget ran out + * or a column could not be read. + */ +async function samplePool( + sampler: ColumnSampler, + keyId: string, + deadline: number, + columns: readonly EncryptedColumn[], + order: SampleOrder, +): Promise<{ pool: Pooled[]; present: number } | null> { + const pool: Pooled[] = []; + let present = 0; + for (const column of columns) { + if (Date.now() > deadline) return null; + let rows: ProbeRow[]; + try { + rows = await sampler(column, keyId, PROBE_ROWS_PER_COLUMN, order); + } catch { + return null; + } + present += rows.length; + for (const row of rows) { + if (row.value !== null) pool.push({ column, row, order: pool.length }); + } + } + if (Date.now() > deadline) return null; + // Oldest first; rows without a timestamp after every row with one. The + // newest pass is the exact mirror. + const sign = order === "oldest" ? 1 : -1; + pool.sort((a, b) => { + const ta = a.row.ts?.getTime() ?? Number.POSITIVE_INFINITY; + const tb = b.row.ts?.getTime() ?? Number.POSITIVE_INFINITY; + return ta === tb ? a.order - b.order : sign * (ta - tb); + }); + return { pool, present }; +} + /** * Probe the stored values under `keyId`; see the module comment for what each * verdict means and why. @@ -235,40 +331,14 @@ export async function probeExistingData( deadline: number, columns: readonly EncryptedColumn[] = ENCRYPTED_COLUMNS, ): Promise { - const pool: Array<{ column: EncryptedColumn; row: ProbeRow; order: number }> = - []; - let present = 0; - for (const column of columns) { - if (Date.now() > deadline) { - return { verdict: "inconclusive", reason: "incomplete" }; - } - let rows: ProbeRow[]; - try { - rows = await sampler(column, keyId, PROBE_ROWS_PER_COLUMN); - } catch { - return { verdict: "inconclusive", reason: "incomplete" }; - } - present += rows.length; - for (const row of rows) { - if (row.value !== null) pool.push({ column, row, order: pool.length }); - } - } - if (Date.now() > deadline) { - return { verdict: "inconclusive", reason: "incomplete" }; - } - if (present === 0) return { verdict: "none" }; - if (pool.length === 0) { + const oldest = await samplePool(sampler, keyId, deadline, columns, "oldest"); + if (oldest === null) return { verdict: "inconclusive", reason: "incomplete" }; + if (oldest.present === 0) return { verdict: "none" }; + if (oldest.pool.length === 0) { return { verdict: "inconclusive", reason: "unsampled" }; } - // Oldest first; rows without a timestamp after every row with one. - pool.sort((a, b) => { - const ta = a.row.ts?.getTime() ?? Number.POSITIVE_INFINITY; - const tb = b.row.ts?.getTime() ?? Number.POSITIVE_INFINITY; - return ta === tb ? a.order - b.order : ta - tb; - }); - const tried = pool.slice(0, PROBE_SAMPLE_LIMIT); - + const tried = oldest.pool.slice(0, PROBE_SAMPLE_LIMIT); let failedBeforeFirstOpen = 0; for (const { column, row } of tried) { if (probeValueOpens(column, row, keyId)) { @@ -278,9 +348,21 @@ export async function probeExistingData( } failedBeforeFirstOpen += 1; } - return tried.length >= 2 - ? { verdict: "fails" } - : { verdict: "inconclusive", reason: "single-value" }; + if (tried.length < 2) { + return { verdict: "inconclusive", reason: "single-value" }; + } + + // Every oldest value failed. Before refusing, ask the newest: one that + // opens means the configured key is the one the install writes with today. + // It never seals; it only keeps a working install serving. + const newest = await samplePool(sampler, keyId, deadline, columns, "newest"); + if (newest === null) return { verdict: "inconclusive", reason: "incomplete" }; + for (const { column, row } of newest.pool.slice(0, PROBE_SAMPLE_LIMIT)) { + if (probeValueOpens(column, row, keyId)) { + return { verdict: "inconclusive", reason: "mixed" }; + } + } + return { verdict: "fails" }; } // ─── The SQL sampler ──────────────────────────────────────────────────────── @@ -359,6 +441,7 @@ function columnQuery( column: EncryptedColumn, keyId: string, mode: "sample" | "exists", + order: SampleOrder = "oldest", ): { sql: string; params: unknown[] } { if (!KEY_ID.test(keyId)) throw new Error("Malformed key id"); const c = quoteIdentifier(loc.column); @@ -405,11 +488,16 @@ function columnQuery( const ts = loc.ts ? quoteIdentifier(loc.ts) : null; const codec = loc.codec ? quoteIdentifier(loc.codec) : "NULL"; const max = Math.floor(PROBE_VALUE_MAX_BYTES); + // The newest order is the exact mirror of the oldest, so an index on the + // timestamp serves both (a backward scan for the newest). + const [dir, nulls] = + order === "oldest" ? ["ASC", "NULLS LAST"] : ["DESC", "NULLS FIRST"]; + const pk = quoteIdentifier(loc.pk); const sql = `SELECT CASE WHEN octet_length(${c}) <= ${max} THEN ${c} END AS value, ` + `${codec} AS codec, ${ts ?? "NULL::timestamp"} AS ts ` + `FROM ${table} WHERE ${where} ` + - `ORDER BY ${ts ? `${ts} ASC NULLS LAST, ` : ""}${quoteIdentifier(loc.pk)} ASC ` + + `ORDER BY ${ts ? `${ts} ${dir} ${nulls}, ` : ""}${pk} ${dir} ` + `LIMIT ${bind(0)}`; return { sql, params }; } @@ -422,20 +510,42 @@ function requireUnsafe(client: CanaryClient) { return query.bind(client); } -/** The production sampler: one bounded query per column. */ -export function sqlColumnSampler(client: CanaryClient): ColumnSampler { - const query = requireUnsafe(client); - return async (column, keyId, limit) => { +/** + * The production sampler: one query per column, each in its own transaction + * under `SET LOCAL statement_timeout`, so a large or locked table throws + * (and the probe ends `incomplete`) instead of holding up the boot. + */ +export function sqlColumnSampler( + client: CanaryClient, + options: { statementTimeoutMs?: number } = {}, +): ColumnSampler { + const transaction = client.$transaction; + if (typeof transaction !== "function") { + throw new Error("The database client cannot run the probe queries"); + } + const run = transaction.bind(client); + const timeoutMs = Math.max( + 1, + Math.floor(options.statementTimeoutMs ?? PROBE_STATEMENT_TIMEOUT_MS), + ); + return async (column, keyId, limit, order) => { const loc = resolveColumnLocation(client, column); - const { sql, params } = columnQuery(loc, column, keyId, "sample"); + const { sql, params } = columnQuery(loc, column, keyId, "sample", order); params[params.length - 1] = limit; - const rows = await query< - Array<{ - value: string | Uint8Array | null; - codec: string | null; - ts: Date | null; - }> - >(sql, ...params); + type Row = { + value: string | Uint8Array | null; + codec: string | null; + ts: Date | null; + }; + const rows = await run( + async (tx) => { + await tx.$executeRaw`SELECT set_config('statement_timeout', ${String(timeoutMs)}, true)`; + return tx.$queryRawUnsafe(sql, ...params); + }, + // The transaction outlives its statement by a margin, so the server's + // cancellation, not the client's, is what ends a slow query. + { maxWait: timeoutMs, timeout: timeoutMs + 5_000 }, + ); return rows.map((r) => ({ value: r.value, codec: r.codec, diff --git a/src/lib/workouts/__tests__/manual-entry.test.ts b/src/lib/workouts/__tests__/manual-entry.test.ts index db7e0f255..0f728fb10 100644 --- a/src/lib/workouts/__tests__/manual-entry.test.ts +++ b/src/lib/workouts/__tests__/manual-entry.test.ts @@ -154,12 +154,31 @@ describe("buildManualWorkoutEntry — validation", () => { ); }); - it("asks for a start", () => { - expect(errorsOf({ start: "" }).start).toBe( + it("refuses a start it cannot read", () => { + expect(errorsOf({ start: "2026-09-15" }).start).toBe( "insights.workouts.manual.errors.startRequired", ); }); + it("reads a blank start as now minus the duration (#1085)", () => { + // NOW is 12:00Z; 45 minutes logged right after finishing. + const result = buildManualWorkoutEntry(draft({ start: "" }), CTX); + expect(result.ok).toBe(true); + expect(result.ok && result.entry.startedAt).toBe( + "2026-09-15T11:15:00.000Z", + ); + expect(result.ok && result.entry.endedAt).toBe("2026-09-15T12:00:00.000Z"); + }); + + it("never reports a blank start as ending in the future", () => { + const result = buildManualWorkoutEntry( + draft({ start: "", hours: "3", minutes: "0" }), + CTX, + ); + expect(result.ok).toBe(true); + expect(result.ok && result.entry.endedAt).toBe("2026-09-15T12:00:00.000Z"); + }); + it("refuses a zero duration", () => { expect(errorsOf({ hours: "", minutes: "" }).duration).toBe( "insights.workouts.manual.errors.durationRequired", @@ -221,11 +240,11 @@ describe("buildManualWorkoutEntry — validation", () => { }); describe("the form's defaults", () => { - it("opens on the current minute in the profile zone", () => { + it("opens with a blank start; the field caps at the current minute", () => { expect(wallClockNow(NOW, "Europe/Berlin")).toBe("2026-09-15T14:00"); expect(emptyManualWorkoutDraft(NOW, "Asia/Seoul")).toMatchObject({ sportType: "", - start: "2026-09-15T21:00", + start: "", hours: "", minutes: "", }); diff --git a/src/lib/workouts/manual-entry.ts b/src/lib/workouts/manual-entry.ts index 07012deb9..6be35a294 100644 --- a/src/lib/workouts/manual-entry.ts +++ b/src/lib/workouts/manual-entry.ts @@ -124,7 +124,9 @@ export function emptyManualWorkoutDraft( ): ManualWorkoutDraft { return { sportType: "", - start: wallClockNow(now, timezone), + // Blank on purpose (#1085): a blank start means "it just ended", so the + // workout is saved as starting `duration` before now. + start: "", hours: "", minutes: "", distance: "", @@ -191,16 +193,17 @@ function manualWorkoutSchema(ctx: ManualWorkoutContext) { }); } - const startedAt = startToUtc(d.start, ctx.timezone); - if (startedAt === null) { + const startBlank = d.start.trim() === ""; + const startedAt = startBlank ? null : startToUtc(d.start, ctx.timezone); + if (!startBlank && startedAt === null) { issue.addIssue({ code: "custom", path: ["start"], message: `${ERR}.startRequired`, }); } else if ( - startedAt.getTime() > - ctx.now.getTime() + FUTURE_TOLERANCE_MS + startedAt !== null && + startedAt.getTime() > ctx.now.getTime() + FUTURE_TOLERANCE_MS ) { issue.addIssue({ code: "custom", @@ -236,8 +239,8 @@ function manualWorkoutSchema(ctx: ManualWorkoutContext) { message: `${ERR}.durationTooLong`, }); } else if ( - // The default start is "now", so a workout logged right after it - // finished would otherwise be stored as ending in the future. + // Only a start the person entered can push the end past now; a + // blank start is read as "now minus the duration". startedAt !== null && startedAt.getTime() + sec * 1000 > ctx.now.getTime() + FUTURE_TOLERANCE_MS @@ -306,10 +309,13 @@ export function buildManualWorkoutEntry( } // Every branch below was proven by the schema above. - const startedAt = startToUtc(draft.start, ctx.timezone)!; const durationSec = ((parseWhole(draft.hours) ?? 0) * 60 + (parseWhole(draft.minutes) ?? 0)) * 60; + const startedAt = + draft.start.trim() === "" + ? new Date(ctx.now.getTime() - durationSec * 1000) + : startToUtc(draft.start, ctx.timezone)!; const distance = parseDecimal(draft.distance); const energy = parseDecimal(draft.energyKcal); diff --git a/tests/integration/encryption-key-check.test.ts b/tests/integration/encryption-key-check.test.ts index 6be82f674..965509974 100644 --- a/tests/integration/encryption-key-check.test.ts +++ b/tests/integration/encryption-key-check.test.ts @@ -56,6 +56,7 @@ import { canaryPlaintext, checkEncryptionKeyCanaries, retireCanariesWithoutData, + sqlColumnSampler, type CanaryClient, } from "@/lib/crypto/canary"; import { @@ -359,6 +360,79 @@ describe("boot key check: what decides, and what may seal (real Postgres)", () = expect(await prisma.encryptionKeyCanary.count()).toBe(1); }); + it("old rows under a lost key, newer rows under the configured key: serves, warns, seals nothing", async () => { + const prisma = getPrismaClient(); + // Sealed under a key that is gone: the eight oldest values the probe + // reads first (two per column, four columns). + await seedUser("lost-1", new Date("2023-01-01T00:00:00Z"), sealNow); + await seedUser("lost-2", new Date("2023-02-01T00:00:00Z"), sealNow); + // Everything since, under the key the install runs with today. + useKey(KEY_B); + for (let i = 0; i < 4; i++) { + await seedUser( + `current-${i}`, + new Date(Date.UTC(2025, 0, 1 + i)), + sealNow, + ); + } + + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const errors = vi.spyOn(console, "error").mockImplementation(() => {}); + expect(await runBootKeyCheck(prisma)).toBe(false); + expect(isKeyMismatch()).toBe(false); + expect(errors).not.toHaveBeenCalled(); + expect(warn.mock.calls[0]?.[0]).toContain("inconclusive"); + warn.mockRestore(); + errors.mockRestore(); + expect(await prisma.encryptionKeyCanary.count()).toBe(0); + expect(await checkEncryptionKeyCanaries(prisma)).toEqual({ + state: "ok", + written: [], + verified: [], + inconclusive: [{ keyId: "v1", reason: "mixed" }], + }); + }); + + it("old AND newest values that do not open still refuse", async () => { + const prisma = getPrismaClient(); + await seedUser("a-1", new Date("2023-01-01T00:00:00Z"), sealNow); + await seedUser("a-2", new Date("2023-02-01T00:00:00Z"), sealNow); + for (let i = 0; i < 4; i++) { + await seedUser(`a-new-${i}`, new Date(Date.UTC(2025, 0, 1 + i)), sealNow); + } + useKey(KEY_B); + expect(await checkEncryptionKeyCanaries(prisma)).toEqual({ + state: "mismatch", + keyIds: ["v1"], + }); + expect(await prisma.encryptionKeyCanary.count()).toBe(0); + }); + + it("a probe query held up by a lock gives up at its statement timeout: inconclusive, not a hang", async () => { + const prisma = getPrismaClient(); + await seedUser("locked", new Date("2024-01-01T00:00:00Z"), sealNow); + const outcome = await prisma.$transaction( + async (tx) => { + // Another session holds the table: every read of it waits. + await tx.$executeRaw`LOCK TABLE users IN ACCESS EXCLUSIVE MODE`; + const started = Date.now(); + const result = await checkEncryptionKeyCanaries(prisma, { + sampler: sqlColumnSampler(prisma, { statementTimeoutMs: 200 }), + }); + return { result, elapsed: Date.now() - started }; + }, + { timeout: 20_000 }, + ); + expect(outcome.result).toEqual({ + state: "ok", + written: [], + verified: [], + inconclusive: [{ keyId: "v1", reason: "incomplete" }], + }); + expect(outcome.elapsed).toBeLessThan(5_000); + expect(await prisma.encryptionKeyCanary.count()).toBe(0); + }); + it("losing the first-seal race to another key is a mismatch, not ok", async () => { const prisma = getPrismaClient(); // The other process, holding A, wins the insert between our probe and