From a334329180261ac10de2efce52ccf3a13659ac30 Mon Sep 17 00:00:00 2001 From: Jon Gallant Date: Sun, 19 Jul 2026 03:11:43 -0700 Subject: [PATCH 1/5] fix(sdk): sanitize untrusted cloud fields and parallelize device-group fetch Extend the parse-boundary sanitizer to strip ANSI/control characters from every attacker-influenceable free-text field (device/channel status, type, firmware, sessionLabel, notes, and the archive equivalents), not just labels. This closes a terminal control-character injection vector (CWE-150) in CLI output where those fields were printed raw. Also fetch device-group documents in parallel instead of sequentially; Promise.all preserves order and the existing 404 handling while avoiding N sequential round trips. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ed65448a-d217-4a10-996d-a873a14f6e32 --- packages/sdk/src/client.ts | 70 ++++++++++++++++--------------- packages/sdk/tests/client.test.ts | 36 ++++++++++++++++ 2 files changed, 72 insertions(+), 34 deletions(-) diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 6403e8a..71f08e8 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -909,27 +909,29 @@ export class ThermoworksCloud { if (groupIds.length === 0) return []; - // Fetch each group document from the account's deviceGroups subcollection - const groups: DeviceGroup[] = []; - for (const groupId of groupIds) { - const groupPath = `documents/accounts/${encodeURIComponent(accountId)}/deviceGroups/${encodeURIComponent(groupId)}`; - const groupResponse = await session.request("GET", groupPath); - - if (groupResponse.status === 404) { - await groupResponse.text().catch(() => {}); - // Group referenced but document missing - include with minimal info - groups.push({ id: groupId, name: "", devices: [] }); - continue; - } + // Fetch each group document from the account's deviceGroups subcollection. + // The requests are independent, so run them in parallel to avoid N sequential + // round trips; Promise.all preserves groupIds order in the result. + const groups: DeviceGroup[] = await Promise.all( + groupIds.map(async (groupId): Promise => { + const groupPath = `documents/accounts/${encodeURIComponent(accountId)}/deviceGroups/${encodeURIComponent(groupId)}`; + const groupResponse = await session.request("GET", groupPath); + + if (groupResponse.status === 404) { + await groupResponse.text().catch(() => {}); + // Group referenced but document missing - include with minimal info + return { id: groupId, name: "", devices: [] }; + } - const groupDoc = (await groupResponse.json()) as { fields?: FirestoreFields }; - const groupFields = groupDoc.fields ?? {}; - groups.push({ - id: groupId, - name: getString(groupFields, "name") ?? "", - devices: getStringArray(groupFields, "devices") ?? [], - }); - } + const groupDoc = (await groupResponse.json()) as { fields?: FirestoreFields }; + const groupFields = groupDoc.fields ?? {}; + return { + id: groupId, + name: getString(groupFields, "name") ?? "", + devices: getStringArray(groupFields, "devices") ?? [], + }; + }), + ); return groups; } @@ -1211,13 +1213,13 @@ function parseDevice(fields: FirestoreFields): Device { serial: getString(fields, "serial") ?? "", deviceId: getString(fields, "deviceId"), label: sanitizeLabel(getString(fields, "label")), - type: getString(fields, "type"), + type: sanitizeLabel(getString(fields, "type")), device: getString(fields, "device"), - status: getString(fields, "status"), + status: sanitizeLabel(getString(fields, "status")), battery: getNumber(fields, "battery"), batteryState: getString(fields, "battery_state") ?? getString(fields, "batteryState"), wifiStrength: getNumber(fields, "wifi_stength") ?? getNumber(fields, "wifiStrength"), - firmware: getString(fields, "firmware"), + firmware: sanitizeLabel(getString(fields, "firmware")), color: getString(fields, "color"), thumbnail: getString(fields, "thumbnail"), deviceDisplayUnits: getString(fields, "deviceDisplayUnits"), @@ -1236,12 +1238,12 @@ function parseDevice(fields: FirestoreFields): Device { lastWifiConnection: getTimestamp(fields, "lastWifiConnection"), lastBluetoothConnection: getTimestamp(fields, "lastBluetoothConnection"), sessionStart: getTimestamp(fields, "sessionStart"), - sessionLabel: getString(fields, "sessionLabel"), + sessionLabel: sanitizeLabel(getString(fields, "sessionLabel")), lastArchive: getTimestamp(fields, "lastArchive"), lastPurged: getTimestamp(fields, "lastPurged"), assignedToAccountOn: getTimestamp(fields, "assignedToAccountOn"), accountId: getString(fields, "accountId"), - notes: getString(fields, "notes"), + notes: sanitizeLabel(getString(fields, "notes")), public: getBoolean(fields, "public"), publicLink: getString(fields, "publicLink"), searModeEnabled: getBoolean(fields, "searModeEnabled"), @@ -1279,8 +1281,8 @@ function parseDeviceChannel(fields: FirestoreFields): DeviceChannel { value: getNumber(fields, "value"), units: getString(fields, "units"), label: sanitizeLabel(getString(fields, "label")), - status: getString(fields, "status"), - type: getString(fields, "type"), + status: sanitizeLabel(getString(fields, "status")), + type: sanitizeLabel(getString(fields, "type")), number: getString(fields, "number"), enabled: getBoolean(fields, "enabled"), color: getString(fields, "color"), @@ -1423,10 +1425,10 @@ function parseArchive(fields: FirestoreFields, id: string): Archive { start: getTimestamp(fields, "start"), end: getTimestamp(fields, "end"), count: getNumber(fields, "count"), - type: getString(fields, "type"), - label: getString(fields, "label"), - deviceLabel: getString(fields, "deviceLabel"), - notes: getString(fields, "notes"), + type: sanitizeLabel(getString(fields, "type")), + label: sanitizeLabel(getString(fields, "label")), + deviceLabel: sanitizeLabel(getString(fields, "deviceLabel")), + notes: sanitizeLabel(getString(fields, "notes")), createdOn: getTimestamp(fields, "createdOn"), public: getBoolean(fields, "public"), publicLink: getString(fields, "publicLink"), @@ -1457,13 +1459,13 @@ function parseArchiveChannel(fields: FirestoreFields): ArchiveChannel { return { number: getString(fields, "number"), - label: getString(fields, "label"), + label: sanitizeLabel(getString(fields, "label")), units: getString(fields, "units"), value: getNumber(fields, "value"), - status: getString(fields, "status"), + status: sanitizeLabel(getString(fields, "status")), enabled: getBoolean(fields, "enabled"), color: getString(fields, "color"), - type: getString(fields, "type"), + type: sanitizeLabel(getString(fields, "type")), alarmHigh: parseAlarm(getMapFields(fields, "alarmHigh")), alarmLow: parseAlarm(getMapFields(fields, "alarmLow")), minimum: parseMinMaxReading(getMapFields(fields, "minimum")), diff --git a/packages/sdk/tests/client.test.ts b/packages/sdk/tests/client.test.ts index 138cb8f..625c7da 100644 --- a/packages/sdk/tests/client.test.ts +++ b/packages/sdk/tests/client.test.ts @@ -102,6 +102,42 @@ describe("ThermoworksCloud", () => { client.close(); }); + it("sanitizes ANSI/control characters from untrusted device fields", async () => { + setupAuth(); + mockRequest.mockResolvedValueOnce( + mockRes(200, { fields: { accountId: { stringValue: "acct-123" } } }) as any, + ); + mockRequest.mockResolvedValueOnce( + mockRes(200, [ + { + document: { + fields: { + serial: { stringValue: "ABC123" }, + label: { stringValue: "\x1b[31mPit\x1b[0m" }, + type: { stringValue: "no\x1bde" }, + status: { stringValue: "on\x07line" }, + firmware: { stringValue: "1.2.3\x1b[2J" }, + sessionLabel: { stringValue: "Bris\x1b[Aket" }, + notes: { stringValue: "line1\x00line2" }, + accountId: { stringValue: "acct-123" }, + }, + }, + }, + ]) as any, + ); + + const client = new ThermoworksCloud({ email: "test@example.com", password: "pass" }); + const device = (await client.getDevices())[0]; + // Untrusted cloud free-text must not carry terminal escape/control sequences. + expect(device?.label).toBe("Pit"); + expect(device?.type).toBe("node"); + expect(device?.status).toBe("online"); + expect(device?.firmware).toBe("1.2.3"); + expect(device?.sessionLabel).toBe("Brisket"); + expect(device?.notes).toBe("line1line2"); + client.close(); + }); + it("filters devices by serial", async () => { setupAuth(); mockRequest.mockResolvedValueOnce( From 8d24a4006558c98ec335610abef707bbe44eba29 Mon Sep 17 00:00:00 2001 From: Jon Gallant Date: Sun, 19 Jul 2026 03:12:03 -0700 Subject: [PATCH 2/5] fix(sdk): honor Retry-After as a hard floor in retry backoff computeRetryDelay documented Retry-After as a floor, but full jitter was applied over max(exponential, retryAfter), so the delay could fall below the server-requested wait. Jitter only the exponential component and clamp the result to at least retryAfterMs (still capped at maxDelay), so a 429/503 carrying Retry-After is never retried early. Adds regression tests covering the floor and its cap. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ed65448a-d217-4a10-996d-a873a14f6e32 --- packages/sdk/src/auth.ts | 10 +++++----- packages/sdk/tests/retry.test.ts | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/packages/sdk/src/auth.ts b/packages/sdk/src/auth.ts index 2ac170a..f813665 100644 --- a/packages/sdk/src/auth.ts +++ b/packages/sdk/src/auth.ts @@ -81,12 +81,12 @@ export function computeRetryDelay( // Exponential backoff: baseDelay * 2^attempt, capped at maxDelay const exponentialDelay = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs); - // Use Retry-After as floor if larger than computed delay - const baseDelay = Math.max(exponentialDelay, retryAfterMs); + // Full jitter on the exponential component: uniform random in [0, exponentialDelay). + const jitteredDelay = Math.random() * exponentialDelay; - // Full jitter: uniform random in [0, baseDelay], capped at maxDelay - const jitteredDelay = Math.random() * baseDelay; - return Math.min(jitteredDelay, maxDelayMs); + // Honor Retry-After as a hard floor: never retry sooner than the server asked, + // even after jitter. Still capped at maxDelay. + return Math.min(Math.max(jitteredDelay, retryAfterMs), maxDelayMs); } function isRetryableStatus(statusCode: number): boolean { diff --git a/packages/sdk/tests/retry.test.ts b/packages/sdk/tests/retry.test.ts index 114197d..6cbb0e8 100644 --- a/packages/sdk/tests/retry.test.ts +++ b/packages/sdk/tests/retry.test.ts @@ -103,6 +103,21 @@ describe("computeRetryDelay", () => { expect(delay).toBe(5000); }); + it("honors Retry-After as a hard floor even with small jitter", () => { + // Without the floor, full jitter (random→0) would drop the delay to 0, + // retrying sooner than the server's Retry-After asked. + vi.spyOn(Math, "random").mockReturnValue(0); + const delay = computeRetryDelay(0, 1000, 30_000, "5"); + expect(delay).toBe(5000); + }); + + it("caps the Retry-After floor at maxDelayMs", () => { + vi.spyOn(Math, "random").mockReturnValue(0); + // Retry-After 60s exceeds maxDelay 30s → capped at 30000. + const delay = computeRetryDelay(0, 1000, 30_000, "60"); + expect(delay).toBe(30_000); + }); + it("ignores invalid Retry-After header", () => { vi.spyOn(Math, "random").mockReturnValue(0.5); const delay = computeRetryDelay(0, 1000, 30_000, "not-a-number-or-date"); From aa0b398bc095fe090671341234f81683ad31aa90 Mon Sep 17 00:00:00 2001 From: Jon Gallant Date: Sun, 19 Jul 2026 03:12:30 -0700 Subject: [PATCH 3/5] fix(cli): resolve positional args without consuming flag values Commands resolved their positional (serial/meat/value) with args.find(a => !a.startsWith("--")), which mistakes a value-taking flag's value for the positional when the flag comes first, e.g. `thermoworks eta --target 203 ABC123` queried device "203". Add a shared firstPositional(args, valueFlags) helper that skips value flags and their values, and use it in eta, temp, stall, alarm-suggest, and convert. Adds unit tests for the helper. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ed65448a-d217-4a10-996d-a873a14f6e32 --- packages/cli/src/args.ts | 33 +++++++++++++++ packages/cli/src/commands/alarm-suggest.ts | 3 +- packages/cli/src/commands/convert.ts | 3 +- packages/cli/src/commands/eta.ts | 3 +- packages/cli/src/commands/stall.ts | 3 +- packages/cli/src/commands/temp.ts | 3 +- packages/cli/tests/args.test.ts | 49 ++++++++++++++++++++++ 7 files changed, 92 insertions(+), 5 deletions(-) create mode 100644 packages/cli/src/args.ts create mode 100644 packages/cli/tests/args.test.ts diff --git a/packages/cli/src/args.ts b/packages/cli/src/args.ts new file mode 100644 index 0000000..dff2f13 --- /dev/null +++ b/packages/cli/src/args.ts @@ -0,0 +1,33 @@ +/** + * Shared CLI argument helpers. + */ + +/** + * Find the first positional argument in a CLI arg list, skipping option flags + * (`--foo`) and — for value-taking flags — the value token that follows them. + * + * Without skipping flag values, a value placed before the positional (e.g. + * `thermoworks eta --target 203 ABC123`) would be mistaken for the positional + * itself, silently operating on the wrong target. Callers pass the set of + * value-taking flags so boolean flags (e.g. `--json`) fall through without + * consuming the following token. + * + * @param args CLI arguments (already scoped to the command). + * @param valueFlags Flags that consume the next token as their value. + * @returns The first positional argument, or `undefined` when none is present. + */ +export function firstPositional( + args: string[], + valueFlags: readonly string[] = [], +): string | undefined { + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === undefined) continue; + if (arg.startsWith("--")) { + if (valueFlags.includes(arg)) i++; + continue; + } + return arg; + } + return undefined; +} diff --git a/packages/cli/src/commands/alarm-suggest.ts b/packages/cli/src/commands/alarm-suggest.ts index e564dea..80eeb53 100644 --- a/packages/cli/src/commands/alarm-suggest.ts +++ b/packages/cli/src/commands/alarm-suggest.ts @@ -1,5 +1,6 @@ import { type MeatProfile, resolveMeatProfile } from "thermoworks-sdk"; +import { firstPositional } from "../args.js"; import { type OutputOptions, outputJson } from "../output.js"; /** Parse a named flag value from args (e.g., "--pit-band" "30" -> "30"). */ @@ -107,7 +108,7 @@ function formatSuggestion(s: AlarmSuggestion): string { * This command only suggests; it never writes to a device. */ export async function alarmSuggest(args: string[], options: OutputOptions): Promise { - const meat = args.find((a) => !a.startsWith("--")); + const meat = firstPositional(args, ["--pit-band", "--serial", "--meat-channel", "--pit-channel"]); if (!meat) { console.error( "Usage: thermoworks alarm suggest [--pit-band ] [--serial ] [--meat-channel <1-9>] [--pit-channel <1-9>] [--json]", diff --git a/packages/cli/src/commands/convert.ts b/packages/cli/src/commands/convert.ts index b329d0c..fabd5b4 100644 --- a/packages/cli/src/commands/convert.ts +++ b/packages/cli/src/commands/convert.ts @@ -1,5 +1,6 @@ import { toCelsius, toFahrenheit } from "thermoworks-sdk"; +import { firstPositional } from "../args.js"; import { type OutputOptions, outputJson } from "../output.js"; /** A parsed and computed temperature conversion. */ @@ -53,7 +54,7 @@ export function parseConversion(raw: string | undefined, to?: string): Conversio * 107c` prints the Fahrenheit value. A bare number needs `--to c|f`. */ export function convert(args: string[], options: OutputOptions): void { - const value = args.find((a) => !a.startsWith("--")); + const value = firstPositional(args, ["--to"]); const toIdx = args.indexOf("--to"); const to = toIdx !== -1 && toIdx + 1 < args.length ? args[toIdx + 1] : undefined; diff --git a/packages/cli/src/commands/eta.ts b/packages/cli/src/commands/eta.ts index b46e378..94e05ef 100644 --- a/packages/cli/src/commands/eta.ts +++ b/packages/cli/src/commands/eta.ts @@ -1,5 +1,6 @@ import { predictDoneTime, ThermoworksCloud } from "thermoworks-sdk"; +import { firstPositional } from "../args.js"; import { getCredentials } from "../credentials.js"; import { type OutputOptions, outputJson } from "../output.js"; @@ -45,7 +46,7 @@ export interface EtaArgs { * Channel defaults to 1. Returns null when the serial is missing. */ export function parseEtaArgs(args: string[]): EtaArgs | null { - const serial = args.find((a) => !a.startsWith("--")); + const serial = firstPositional(args, ["--channel", "--target"]); if (!serial) return null; const channel = parseChannelFlag(getFlagValue(args, "--channel")) ?? 1; diff --git a/packages/cli/src/commands/stall.ts b/packages/cli/src/commands/stall.ts index d78100d..6162f2b 100644 --- a/packages/cli/src/commands/stall.ts +++ b/packages/cli/src/commands/stall.ts @@ -1,5 +1,6 @@ import { detectStall, type TemperatureReading, ThermoworksCloud } from "thermoworks-sdk"; +import { firstPositional } from "../args.js"; import { getCredentials } from "../credentials.js"; import { type OutputOptions, outputJson } from "../output.js"; @@ -34,7 +35,7 @@ export interface StallArgs { * Returns null when the serial is missing. */ export function parseStallArgs(args: string[]): StallArgs | null { - const serial = args.find((a) => !a.startsWith("--")); + const serial = firstPositional(args, ["--threshold", "--duration"]); if (!serial) return null; const thresholdDegrees = parsePositiveFlag(getFlagValue(args, "--threshold"), "--threshold"); diff --git a/packages/cli/src/commands/temp.ts b/packages/cli/src/commands/temp.ts index ab4879b..ed86772 100644 --- a/packages/cli/src/commands/temp.ts +++ b/packages/cli/src/commands/temp.ts @@ -1,5 +1,6 @@ import { ThermoworksCloud, toCelsius, toFahrenheit } from "thermoworks-sdk"; +import { firstPositional } from "../args.js"; import { getCredentials } from "../credentials.js"; import { type OutputOptions, outputJson } from "../output.js"; @@ -56,7 +57,7 @@ function convertReading(value: number, sourceUnits: string | null, unit: TempUni * for `{ serial, channel, value, units }`. */ export async function temp(args: string[], options: OutputOptions): Promise { - const serial = args.find((a) => !a.startsWith("--")); + const serial = firstPositional(args, ["--channel", "--unit"]); if (!serial) { console.error("Usage: thermoworks temp [--channel <1-9>] [--unit auto|f|c] [--json]"); process.exit(1); diff --git a/packages/cli/tests/args.test.ts b/packages/cli/tests/args.test.ts new file mode 100644 index 0000000..54ba914 --- /dev/null +++ b/packages/cli/tests/args.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { firstPositional } from "../src/args.js"; + +describe("firstPositional", () => { + it("returns the first positional when it comes first", () => { + expect(firstPositional(["ABC123", "--channel", "2"], ["--channel"])).toBe("ABC123"); + }); + + it("skips a value-taking flag's value placed before the positional", () => { + // Regression: `--target 203 ABC123` must not treat 203 as the serial. + expect(firstPositional(["--target", "203", "ABC123"], ["--target"])).toBe("ABC123"); + }); + + it("skips multiple leading value flags", () => { + expect( + firstPositional(["--channel", "2", "--unit", "f", "ABC123"], ["--channel", "--unit"]), + ).toBe("ABC123"); + }); + + it("does not skip the token after a boolean flag", () => { + // `--json` takes no value, so the following positional must be found. + expect(firstPositional(["--json", "ABC123"], ["--channel"])).toBe("ABC123"); + }); + + it("finds the positional between flags", () => { + expect(firstPositional(["--json", "ABC123", "--channel", "2"], ["--channel"])).toBe("ABC123"); + }); + + it("returns undefined when there is no positional", () => { + expect(firstPositional(["--channel", "2"], ["--channel"])).toBeUndefined(); + }); + + it("returns undefined for an empty arg list", () => { + expect(firstPositional([], ["--channel"])).toBeUndefined(); + }); + + it("treats unknown flags as boolean (does not consume the next token)", () => { + // `--verbose` is not a declared value flag, so `meat` is the positional. + expect(firstPositional(["--verbose", "brisket"], ["--target"])).toBe("brisket"); + }); + + it("handles a trailing value flag with no value", () => { + expect(firstPositional(["ABC123", "--channel"], ["--channel"])).toBe("ABC123"); + }); + + it("defaults to no value flags", () => { + expect(firstPositional(["ABC123", "--json"])).toBe("ABC123"); + }); +}); From ef32d6ced95d6a0c6786f125eec422b649b124c3 Mon Sep 17 00:00:00 2001 From: Jon Gallant Date: Sun, 19 Jul 2026 03:12:54 -0700 Subject: [PATCH 4/5] fix(web): harden CSV escaping, storage writes, offline replay, and error state - export: add carriage return to the CSV formula-injection prefix set, matching the CLI escaper. - cook-annotations: wrap the localStorage write in try/catch so a quota or private-mode error can't break the report UI, matching the reader. - offline-mutations: wrap each queued replay in try/catch so one failing mutation no longer blocks replay of the rest (adds a regression test). - SharedArchiveView: clear stale archive state on a failed or absent fetch so a previous archive can't linger on an invalid link. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ed65448a-d217-4a10-996d-a873a14f6e32 --- packages/web/src/lib/cook-annotations.ts | 7 ++++++- packages/web/src/lib/export.ts | 2 +- packages/web/src/lib/offline-mutations.ts | 21 ++++++++++++------- packages/web/src/pages/SharedArchiveView.tsx | 2 ++ packages/web/tests/offline-mutations.test.tsx | 18 ++++++++++++++++ 5 files changed, 40 insertions(+), 10 deletions(-) diff --git a/packages/web/src/lib/cook-annotations.ts b/packages/web/src/lib/cook-annotations.ts index afb345f..64e0fb8 100644 --- a/packages/web/src/lib/cook-annotations.ts +++ b/packages/web/src/lib/cook-annotations.ts @@ -65,7 +65,12 @@ export function saveCookAnnotations( }, ]; }); - localStorage.setItem(annotationStorageKey(sessionId), JSON.stringify(stored)); + try { + localStorage.setItem(annotationStorageKey(sessionId), JSON.stringify(stored)); + } catch { + // Best-effort: ignore quota/availability errors (private mode, storage full), + // matching loadCookAnnotations which tolerates read failures. + } } interface SerializableReading { diff --git a/packages/web/src/lib/export.ts b/packages/web/src/lib/export.ts index 87f2739..41f1a0d 100644 --- a/packages/web/src/lib/export.ts +++ b/packages/web/src/lib/export.ts @@ -158,7 +158,7 @@ export async function downloadPNG(container: HTMLElement, filename: string): Pro /** Escape a CSV field per RFC 4180 with formula injection prevention (OWASP). */ function escapeCSVField(value: string): string { // Prevent CSV formula injection: prefix dangerous characters with a single quote - const formulaChars = /^[=+\-@|\t]/; + const formulaChars = /^[=+\-@|\t\r]/; let sanitized = value; if (formulaChars.test(sanitized)) { sanitized = `'${sanitized}`; diff --git a/packages/web/src/lib/offline-mutations.ts b/packages/web/src/lib/offline-mutations.ts index e41e5d5..49ba410 100644 --- a/packages/web/src/lib/offline-mutations.ts +++ b/packages/web/src/lib/offline-mutations.ts @@ -331,14 +331,19 @@ export async function replayQueuedMutations(client: ThermoworksWebClient): Promi const mutations = (await getAllMutations()).filter((mutation) => mutation.status === "pending"); for (const mutation of mutations) { - const result = - mutation.type === "setAlarm" - ? await replayAlarmMutation(client, mutation) - : await replaySessionMutation(client, mutation); - if (result === "conflict") { - conflicts += 1; - } else { - replayed += 1; + try { + const result = + mutation.type === "setAlarm" + ? await replayAlarmMutation(client, mutation) + : await replaySessionMutation(client, mutation); + if (result === "conflict") { + conflicts += 1; + } else { + replayed += 1; + } + } catch { + // Leave this mutation queued for the next reconnect attempt; one failing + // mutation must not block replay of the remaining queued mutations. } } diff --git a/packages/web/src/pages/SharedArchiveView.tsx b/packages/web/src/pages/SharedArchiveView.tsx index feef24b..130b076 100644 --- a/packages/web/src/pages/SharedArchiveView.tsx +++ b/packages/web/src/pages/SharedArchiveView.tsx @@ -20,11 +20,13 @@ export function SharedArchiveView() { try { const result = await getPublicArchive(serial, archiveId); if (!result) { + setArchive(null); setError("Archive not found or not publicly shared."); } else { setArchive(result); } } catch (err) { + setArchive(null); setError(err instanceof Error ? err.message : "Failed to load shared archive."); } finally { setIsLoading(false); diff --git a/packages/web/tests/offline-mutations.test.tsx b/packages/web/tests/offline-mutations.test.tsx index 9efbfb1..725be43 100644 --- a/packages/web/tests/offline-mutations.test.tsx +++ b/packages/web/tests/offline-mutations.test.tsx @@ -140,6 +140,24 @@ describe("offline mutation outbox", () => { await expect(getOutboxSnapshot()).resolves.toEqual({ pendingCount: 0, conflictCount: 0 }); }); + it("keeps replaying remaining mutations when one mutation throws", async () => { + await enqueueStartSessionMutation({ serial: "TW-001", label: "Ribs", wasActive: false }); + await enqueueStartSessionMutation({ serial: "TW-002", label: "Brisket", wasActive: false }); + + const client = makeClient({ + getDevice: vi.fn().mockResolvedValue({ sessionStart: null } as Device), + startSession: vi + .fn() + .mockRejectedValueOnce(new Error("network error")) + .mockResolvedValueOnce({ success: true }), + }); + + // A single failure must not abort replay of the remaining queued mutations. + await expect(replayQueuedMutations(client)).resolves.toEqual({ replayed: 1, conflicts: 0 }); + // The failed mutation stays queued for the next reconnect; the successful one is cleared. + await expect(getOutboxSnapshot()).resolves.toEqual({ pendingCount: 1, conflictCount: 0 }); + }); + it("shows and clears a pending badge count for queued actions", async () => { await enqueueStartSessionMutation({ serial: "TW-001", label: "Ribs", wasActive: false }); From 9b947272b27fcd757f9166bf6d6e9f8dda6780ce Mon Sep 17 00:00:00 2001 From: Jon Gallant Date: Sun, 19 Jul 2026 03:13:12 -0700 Subject: [PATCH 5/5] docs: correct pnpm minimum version in CONTRIBUTING Match the pnpm version pinned in package.json (11.14.0). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ed65448a-d217-4a10-996d-a873a14f6e32 --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7c2628d..17b5279 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,7 +9,7 @@ The following tools must be installed on your machine to build and run the full | Tool | Version | Purpose | Install | |------|---------|---------|---------| | **Node.js** | ≥ 18.0.0 | JavaScript runtime | [nodejs.org](https://nodejs.org) or `nvm install 22` | -| **pnpm** | 11.9.0+ | Package manager (monorepo workspaces) | `corepack enable && corepack prepare pnpm@latest --activate` | +| **pnpm** | 11.14.0+ | Package manager (monorepo workspaces) | `corepack enable && corepack prepare pnpm@latest --activate` | | **Git** | Any recent | Source control | [git-scm.com](https://git-scm.com) | ### Platform-Specific Dependencies