From 2517e8eb28fdc0944431929b53edc021a70c06fe Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Thu, 8 Oct 2026 15:45:33 +0200 Subject: [PATCH] Push receiver: delete entries, user-set retention, self-migrating VAPID key (#180) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Push receiver: delete entries, user-set retention, self-migrating VAPID key (B-y4z) - History: a long press (or right-click) on an entry opens a small menu — Delete (local only; the history never leaves the device) and Open in portal when the entry carries a link. - Retention: the status page lets the user keep the history 0-365 days (default 30 as before, 0 = until the entry cap of 200, which is unchanged and stated); stored in the receiver's IndexedDB meta, honoured by the worker and the pages when pruning. - Key rotation is a re-registration, not a re-pair campaign: GET push/device names the active kid and public key; a receiver whose stored kid differs subscribes again with the new key on its next open and PUTs the new kid (reconcile action "rekey"); a Stale vapid_key_mismatch row re-arms the same way. Operator texts (settings template, generator script) follow. Co-Authored-By: Claude Fable 5.1 * bump pointer: internal, infra (push-relay addendum: delete, retention, key migration) Co-Authored-By: Claude Fable 5.1 * Push receiver: name a failed subscription repair; the worker re-subscribes with the server's active key - reconcileDevice returns repairError when the subscribe() or the PUT after it failed (400 InvalidSubscription, 429, network); the status page shows "present, server not updated" plus the reason instead of a silent "present". Retried on the next open as before. - pushsubscriptionchange (Chrome, Firefox) asks GET push/device for the active key before it re-subscribes (planSubscriptionKeys: active key when it differs from the stored kid or no key is stored, else the stored key, else nothing), drops the browser's replacement subscription after a rotation, PUTs the kid it used and stores kid + key; never a PUT with an empty kid. Worker fetches are bounded to 10 s. Co-Authored-By: Claude Fable 5.1 * bump pointer: internal (push-relay: worker key rule) Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: Claude Fable 5.1 --- infra | 2 +- internal | 2 +- .../Push/PushPairingServiceTests.cs | 70 ++++++- .../Services/Push/PushPairingService.cs | 7 +- .../local.settings.json.template | 2 +- .../src/generated/wire-types.generated.ts | 4 + .../Models/PushApiModels.cs | 8 + .../app/push/EntryActionSheet.tsx | 112 ++++++++++ .../autopilot-monitor-web/app/push/page.tsx | 197 ++++++++++++------ .../app/push/status/page.tsx | 118 ++++++++++- .../lib/__tests__/longPress.test.ts | 157 ++++++++++++++ .../lib/__tests__/pushCore.test.ts | 62 ++++++ .../lib/__tests__/pushReconcile.test.ts | 59 +++++- .../lib/__tests__/retentionInput.test.ts | 49 +++++ .../lib/push/longPress.ts | 108 ++++++++++ .../lib/push/pushClient.ts | 53 +++-- .../lib/push/pushReconcile.ts | 21 +- .../lib/push/retentionInput.ts | 27 +++ .../public/push/sw-core.js | 101 ++++++++- .../autopilot-monitor-web/public/push/sw.js | 59 +++++- .../utils/shared-manifests.json | 16 ++ .../utils/wire-types.generated.ts | 4 + 22 files changed, 1130 insertions(+), 108 deletions(-) create mode 100644 src/Web/autopilot-monitor-web/app/push/EntryActionSheet.tsx create mode 100644 src/Web/autopilot-monitor-web/lib/__tests__/longPress.test.ts create mode 100644 src/Web/autopilot-monitor-web/lib/__tests__/retentionInput.test.ts create mode 100644 src/Web/autopilot-monitor-web/lib/push/longPress.ts create mode 100644 src/Web/autopilot-monitor-web/lib/push/retentionInput.ts diff --git a/infra b/infra index d8111089..f93e475b 160000 --- a/infra +++ b/infra @@ -1 +1 @@ -Subproject commit d8111089f4c4e1cca54083299d466207cfe7483c +Subproject commit f93e475b78c49b2402a69e1611e738dd38ed9cbd diff --git a/internal b/internal index 71ee3d7c..f27172a5 160000 --- a/internal +++ b/internal @@ -1 +1 @@ -Subproject commit 71ee3d7ca8896677c2450c3026207daa7879f544 +Subproject commit f27172a599c693fa35404ce076b65dbdb268adaf diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushPairingServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushPairingServiceTests.cs index a1afd106..c1e03e8e 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushPairingServiceTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushPairingServiceTests.cs @@ -164,6 +164,66 @@ public class PushPairingServiceTests Assert.Equal("https://web.push.apple.com/QF/new-token", h.Repo.Devices.Values.Single().Endpoint); } + [Fact] + public async Task A_rotated_key_is_a_re_registration_not_a_re_pair() + { + var h = new Harness(); + var created = await h.Sut.CreateAsync(Tenant, Admin); + var redeemed = await h.Sut.RedeemAsync(h.RedeemRequest(created.Value!.Code)); + await h.Sut.ConfirmAsync(Tenant, created.Value.PairingId, Admin); + var token = redeemed.Value!.DeviceToken; + var device = (await h.Sut.ResolveDeviceAsync(token))!; + + // Before the rotation the status names the key the device subscribed with as the active one. + var before = await h.Sut.GetDeviceStatusAsync(device); + Assert.Equal(h.Keys.Active.Kid, before.Kid); + Assert.Equal(h.Keys.Active.Kid, before.ActiveKid); + Assert.Equal(h.Keys.Active.PublicKeyBase64Url, before.ActiveVapidPublicKey); + + // Rotation: a new active key, the old one retired — the same device rows under the new ring. + var rotated = new Harness(new VapidKeyRing(VapidKey.Generate(), new[] { h.Keys.Active }), h.Repo); + var status = await rotated.Sut.GetDeviceStatusAsync(device); + Assert.Equal(h.Keys.Active.Kid, status.Kid); + Assert.Equal(rotated.Keys.Active.Kid, status.ActiveKid); + Assert.Equal(rotated.Keys.Active.PublicKeyBase64Url, status.ActiveVapidPublicKey); + Assert.NotEqual(status.Kid, status.ActiveKid); + + // The receiver subscribes again with the new key and PUTs the new kid: the row moves over, no re-pair. + var moved = await rotated.Sut.ResubscribeAsync(device, new ResubscribeRequest + { + Endpoint = "https://web.push.apple.com/QF/rotated-token", P256dh = h.Subscription.P256dh, Auth = h.Subscription.Auth, Kid = rotated.Keys.Active.Kid, + }); + Assert.True(moved.Ok); + Assert.Equal(rotated.Keys.Active.Kid, moved.Value!.Kid); + Assert.Equal(rotated.Keys.Active.Kid, h.Repo.Devices.Values.Single().VapidKid); + Assert.Equal(token.Split('.')[2], token.Split('.')[2]); // the device token is untouched by the migration + + // Once the old key left the ring, a row still on it is Stale (vapid_key_mismatch): the same PUT re-arms it, + // while a PUT that still names the retired kid is refused. + var withoutOld = new Harness(new VapidKeyRing(rotated.Keys.Active), h.Repo); + await h.Repo.MutateDeviceAsync(device.Scope, device.DeviceId, _ => new Dictionary + { + ["VapidKid"] = h.Keys.Active.Kid, ["Status"] = Constants.Push.DeviceStatus.Stale, ["StatusReason"] = "vapid_key_mismatch", + }); + var staleRow = (await withoutOld.Sut.ResolveDeviceAsync(token))!; + Assert.Equal(Constants.Push.DeviceStatus.Stale, staleRow.Status); + + var refused = await withoutOld.Sut.ResubscribeAsync(staleRow, new ResubscribeRequest + { + Endpoint = "https://web.push.apple.com/QF/rotated-token", P256dh = h.Subscription.P256dh, Auth = h.Subscription.Auth, Kid = h.Keys.Active.Kid, + }); + Assert.Equal(PushOpError.InvalidSubscription, refused.Error); + + var reArmed = await withoutOld.Sut.ResubscribeAsync(staleRow, new ResubscribeRequest + { + Endpoint = "https://web.push.apple.com/QF/rotated-token-2", P256dh = h.Subscription.P256dh, Auth = h.Subscription.Auth, Kid = withoutOld.Keys.Active.Kid, + }); + Assert.True(reArmed.Ok); + Assert.Equal(Constants.Push.DeviceStatus.Active, reArmed.Value!.Status); + Assert.Equal(withoutOld.Keys.Active.Kid, h.Repo.Devices.Values.Single().VapidKid); + Assert.Null(h.Repo.Devices.Values.Single().StatusReason); + } + [Fact] public async Task Operators_see_only_their_own_devices_and_cannot_remove_others() { @@ -264,8 +324,8 @@ public class PushPairingServiceTests internal sealed class Harness { - public InMemoryPushDeviceRepository Repo { get; } = new(); - public VapidKeyRing Keys { get; } = new(VapidKey.Generate()); + public InMemoryPushDeviceRepository Repo { get; } + public VapidKeyRing Keys { get; } public Mock Eligibility { get; } public Mock OpsEvents { get; } public RecordingHandler Http { get; } = new(); @@ -273,8 +333,12 @@ public class PushPairingServiceTests public PushDeliveryService Delivery { get; } public (string Endpoint, string P256dh, string Auth) Subscription { get; } - public Harness() + /// The platform's key ring; a rotated ring for the key-migration tests. + /// A repository shared with another harness: the same device rows seen under a new ring. + public Harness(VapidKeyRing? keys = null, InMemoryPushDeviceRepository? repo = null) { + Keys = keys ?? new VapidKeyRing(VapidKey.Generate()); + Repo = repo ?? new InMemoryPushDeviceRepository(); var settings = PushSettings.ForKeys(Keys); Eligibility = new Mock(null!, null!, null!, null!, null!) { CallBase = false }; Eligibility.Setup(e => e.HasPushChannelAsync(It.IsAny())).ReturnsAsync(true); diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Push/PushPairingService.cs b/src/Backend/AutopilotMonitor.Functions/Services/Push/PushPairingService.cs index bde27a32..34b827f9 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Push/PushPairingService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Push/PushPairingService.cs @@ -324,7 +324,10 @@ public class PushPairingService _logger.LogWarning(ex, "LastOpenedUtc stamp failed for push device {DeviceId}", device.DeviceId); } - var scope = device.Scope == Constants.Push.PlatformScope ? NotificationScope.Platform : NotificationScope.Tenant(device.Scope); + var scope = NotificationScope.FromKey(device.Scope); + // The active key travels with every status answer so a rotated key is a re-registration + // on the receiver's next open (B-y4z), never a re-pair campaign. + var active = _settings.Keys?.Active; return new PushDeviceStatusResponse { DeviceId = device.DeviceId, @@ -337,6 +340,8 @@ public class PushPairingService LastDeliveredUtc = device.LastDeliveredUtc, OwnerUpn = device.OwnerUpn, ScopeName = await ScopeNameAsync(scope).ConfigureAwait(false), + ActiveKid = active?.Kid ?? string.Empty, + ActiveVapidPublicKey = active?.PublicKeyBase64Url ?? string.Empty, }; } diff --git a/src/Backend/AutopilotMonitor.Functions/local.settings.json.template b/src/Backend/AutopilotMonitor.Functions/local.settings.json.template index 124a11f9..c7475c42 100644 --- a/src/Backend/AutopilotMonitor.Functions/local.settings.json.template +++ b/src/Backend/AutopilotMonitor.Functions/local.settings.json.template @@ -9,7 +9,7 @@ "// DataStorage": "Connection the [QueueTrigger] functions under Functions/Queue bind to (Constants.QueueNames.TriggerConnection). Locally a connection string; in Azure the identity form 'DataStorage__queueServiceUri=https://.queue.core.windows.net'. The Functions host resolves it, so it cannot be derived from AzureStorageAccountName in code.", "DataStorage": "UseDevelopmentStorage=true", - "// Web Push channel": "Optional. Without Push__Vapid__ActiveKey the Push notification provider is off (pairing refuses, sends are skipped). ActiveKey = the 96-byte base64url form from infra/scripts/push/New-VapidKeys.ps1 (generated once; rotating it invalidates every paired device, see plan push-relay K17); RetiredKeys = ';'-separated older keys devices may still reference; Subject = VAPID contact (mailto: or https://, default: the product website); OwnerInactivityDays = devices pause when their owner has not signed in for this long (default 30); PausedDeviceRetentionDays = paused devices are deleted this long after the owner's last sign-in (default 90); StaleDeviceRetentionDays = devices whose endpoint is gone are deleted after this long (default 14).", + "// Web Push channel": "Optional. Without Push__Vapid__ActiveKey the Push notification provider is off (pairing refuses, sends are skipped). ActiveKey = the 96-byte base64url form from infra/scripts/push/New-VapidKeys.ps1 (generated once; to rotate, set the new key here, move the old one to RetiredKeys and let every device migrate on its next open, see plan push-relay K17); RetiredKeys = ';'-separated older keys devices may still reference; Subject = VAPID contact (mailto: or https://, default: the product website); OwnerInactivityDays = devices pause when their owner has not signed in for this long (default 30); PausedDeviceRetentionDays = paused devices are deleted this long after the owner's last sign-in (default 90); StaleDeviceRetentionDays = devices whose endpoint is gone are deleted after this long (default 14).", "Push__Vapid__ActiveKey": "", "Push__Vapid__RetiredKeys": "", "Push__Vapid__Subject": "", diff --git a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts index 3be8c3cc..4f5119fd 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts @@ -3351,6 +3351,10 @@ export interface PushDeviceStatusResponse { ownerUpn: string; /** Tenant display name, or "Platform operator" for the platform scope. */ scopeName: string; + /** The platform's current VAPID key id. Differs from Kid after a key rotation: the receiver then subscribes again with ActiveVapidPublicKey and PUTs the new subscription with this kid — no re-pairing. Empty while the channel is unconfigured. */ + activeKid: string; + /** Uncompressed P-256 point, base64url, of the current key (what to subscribe with). */ + activeVapidPublicKey: string; } /** Error body of POST /api/global/raw/logs when the telemetry store rejected or failed the query (400 for a caller-side KQL error, 502 for store/grant failures): the envelope prefix plus the store's own error code, HTTP status and — capped — its full response, so nothing the CLI would print is lost. hint tells the caller how to fix the query. */ diff --git a/src/Shared/AutopilotMonitor.Shared/Models/PushApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/PushApiModels.cs index d99ce54f..5d835ce4 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/PushApiModels.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/PushApiModels.cs @@ -94,6 +94,14 @@ namespace AutopilotMonitor.Shared.Models public string OwnerUpn { get; set; } = default!; /// Tenant display name, or "Platform operator" for the platform scope. public string ScopeName { get; set; } = default!; + /// + /// The platform's current VAPID key id. Differs from after a key rotation: + /// the receiver then subscribes again with and PUTs the + /// new subscription with this kid — no re-pairing. Empty while the channel is unconfigured. + /// + public string ActiveKid { get; set; } = default!; + /// Uncompressed P-256 point, base64url, of the current key (what to subscribe with). + public string ActiveVapidPublicKey { get; set; } = default!; } /// PUT push/device — a changed or re-created subscription (same device, same owner). diff --git a/src/Web/autopilot-monitor-web/app/push/EntryActionSheet.tsx b/src/Web/autopilot-monitor-web/app/push/EntryActionSheet.tsx new file mode 100644 index 00000000..3f2e7862 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/push/EntryActionSheet.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { ModalPortal } from "@/components/ModalPortal"; +import { useLatest } from "@/hooks/useLatest"; +import type { HistoryEntry } from "@/lib/push/pushCore"; + +interface EntryActionSheetProps { + entry: HistoryEntry; + /** Removes the entry from the local history; nothing leaves the device. */ + onDelete: () => void; + onClose: () => void; +} + +/** + * The actions of one history entry, opened by a long press or a right-click on its card: a + * bottom sheet on the phone, a centred panel on a wide screen. Escape and a tap beside the + * panel close it; focus moves into the sheet and returns to the card afterwards. + * + * The sheet opens while the finger is still on the card, and the release may land as a click + * on whatever is under it now — the overlay or even the Delete button. A click therefore only + * counts after a pointer went down (or a key was pressed) inside the sheet itself. + */ +export function EntryActionSheet({ entry, onDelete, onClose }: EntryActionSheetProps) { + const panelRef = useRef(null); + const armedRef = useRef(false); + const onCloseRef = useLatest(onClose); + + // Focus the dialog on open and give it back to the card on close. + useEffect(() => { + const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null; + panelRef.current?.focus(); + return () => { + if (previous?.isConnected) previous.focus(); + }; + }, []); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") onCloseRef.current(); + }; + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [onCloseRef]); + + const arm = () => { + armedRef.current = true; + }; + const armed = () => armedRef.current; + + return ( + +
{ + if (armed()) onClose(); + }} + > +
event.stopPropagation()} + > +

{entry.title}

+
+ + {entry.portalUrl && ( + { + if (!armed()) { + event.preventDefault(); + return; + } + onClose(); + }} + className="block w-full px-4 py-3 bg-white border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors text-sm font-medium text-center" + > + Open in portal + + )} + +
+
+
+
+ ); +} diff --git a/src/Web/autopilot-monitor-web/app/push/page.tsx b/src/Web/autopilot-monitor-web/app/push/page.tsx index a5fd2323..5ad86c28 100644 --- a/src/Web/autopilot-monitor-web/app/push/page.tsx +++ b/src/Web/autopilot-monitor-web/app/push/page.tsx @@ -3,7 +3,10 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; +import { useLatest } from "@/hooks/useLatest"; +import { createLongPress } from "@/lib/push/longPress"; import { + deleteHistoryEntry, entryFragment, META_KEYS, parseFragment, @@ -13,6 +16,7 @@ import { } from "@/lib/push/pushCore"; import { readPairCookie, reconcileDevice, type ReconcileResult } from "@/lib/push/pushClient"; import { formatDateTime, formatRelativeTime } from "@/lib/push/pushFormat"; +import { EntryActionSheet } from "./EntryActionSheet"; import { useIsClient, useLocationHash } from "./pushEnvironment"; import { DANGER_CHIP, DEVICE_STATUS_CHIP, NEUTRAL_CHIP, SEVERITY_ACCENT } from "./pushStyles"; @@ -23,8 +27,10 @@ interface WorkerMessage { } /** - * The local history, like a chat log (F12): newest first, 30 days / 200 entries, written by the - * service worker and read here. Every open reconciles the device with the server (K14). + * The local history, like a chat log (F12): newest first, kept for the retention chosen on the + * status page (30 days by default) and capped at 200 entries, written by the service worker and + * read here. Every open reconciles the device with the server (K14). A long press (or right-click) + * on an entry opens its actions; a delete stays on the device. */ export default function PushHistoryPage() { const router = useRouter(); @@ -32,8 +38,10 @@ export default function PushHistoryPage() { const hash = useLocationHash(); const [entries, setEntries] = useState(null); const [device, setDevice] = useState(null); + const [historyKey, setHistoryKey] = useState(0); const [reloadKey, setReloadKey] = useState(0); const [now, setNow] = useState(() => 0); + const [sheet, setSheet] = useState(null); const target = parseFragment(hash); const focusId = target?.kind === "entry" ? target.id : null; @@ -42,7 +50,7 @@ export default function PushHistoryPage() { const [expanded, setExpanded] = useState<{ focusId: string | null; id: string | null }>({ focusId: null, id: null }); if (expanded.focusId !== focusId) setExpanded({ focusId, id: focusId }); - // Load (prune on open), then keep in step with the worker and with the tab coming back. + // Load (prune on open); re-runs whenever the worker, the returning tab or a delete asks for it. useEffect(() => { let cancelled = false; const load = async () => { @@ -58,18 +66,24 @@ export default function PushHistoryPage() { setNow(Date.now()); }; void load(); + return () => { + cancelled = true; + }; + }, [historyKey]); + // Keep in step with the worker and with the tab coming back. + useEffect(() => { const onMessage = (event: MessageEvent) => { if (event.data?.channel !== "am-push") return; if (event.data.type === "open" && typeof event.data.id === "string") { window.location.hash = entryFragment(event.data.id); } - void load(); + setHistoryKey((k) => k + 1); if (event.data.type === "wiped") setReloadKey((k) => k + 1); }; const onVisible = () => { if (document.visibilityState === "visible") { - void load(); + setHistoryKey((k) => k + 1); setReloadKey((k) => k + 1); } }; @@ -77,7 +91,6 @@ export default function PushHistoryPage() { worker?.addEventListener("message", onMessage); document.addEventListener("visibilitychange", onVisible); return () => { - cancelled = true; worker?.removeEventListener("message", onMessage); document.removeEventListener("visibilitychange", onVisible); }; @@ -109,6 +122,19 @@ export default function PushHistoryPage() { const toggle = (id: string) => setExpanded((prev) => ({ ...prev, id: prev.id === id ? null : id })); + // The delete never leaves the device. IndexedDB may refuse (private mode): the entry just stays. + const deleteEntry = async (entry: HistoryEntry) => { + setSheet(null); + try { + await deleteHistoryEntry(entry.id); + } catch { + return; + } + setExpanded((prev) => (prev.id === entry.id ? { ...prev, id: null } : prev)); + if (focusId === entry.id) window.location.hash = ""; + setHistoryKey((k) => k + 1); + }; + return (
@@ -141,71 +167,112 @@ export default function PushHistoryPage() { {entries && entries.length > 0 && (
    - {entries.map((entry) => { - const open = expanded.id === entry.id; - return ( -
  • - - {open && ( -
    - {entry.facts.length > 0 && ( -
    - {entry.facts.map((fact) => ( - - ))} -
    - )} -
    - {entry.type} - {entry.scope} - {formatDateTime(entry.ts)} -
    - {entry.portalUrl && ( - - Open in the portal - - )} -
    - )} -
  • - ); - })} + {entries.map((entry) => ( + + ))}
)} + + {sheet && void deleteEntry(sheet)} onClose={() => setSheet(null)} />}
); } +interface EntryCardProps { + entry: HistoryEntry; + open: boolean; + focused: boolean; + now: number; + onToggle: (id: string) => void; + onActions: (entry: HistoryEntry) => void; +} + +/** + * One history entry. A tap expands it; a long press or a right-click opens its actions. The + * card suppresses the browser's own text selection and the iOS callout so the press reads as + * a press, and it ignores the click that the release after a long press may produce. + */ +function EntryCard({ entry, open, focused, now, onToggle, onActions }: EntryCardProps) { + const openActions = useLatest(() => onActions(entry)); + const [press] = useState(() => createLongPress({ onLongPress: () => openActions.current() })); + useEffect(() => () => press.dispose(), [press]); + + return ( +
  • { + event.preventDefault(); + onActions(entry); + }} + > + + {open && ( +
    + {entry.facts.length > 0 && ( +
    + {entry.facts.map((fact) => ( + + ))} +
    + )} +
    + {entry.type} + {entry.scope} + {formatDateTime(entry.ts)} +
    + {entry.portalUrl && ( + { + if (press.shouldSuppressClick()) event.preventDefault(); + }} + className="inline-block text-sm font-medium text-sky-700 dark:text-sky-400 hover:underline" + > + Open in the portal + + )} +
    + )} +
  • + ); +} + function FactRow({ name, value }: { name: string; value: string }) { return ( <> diff --git a/src/Web/autopilot-monitor-web/app/push/status/page.tsx b/src/Web/autopilot-monitor-web/app/push/status/page.tsx index 0291055d..d75e8c39 100644 --- a/src/Web/autopilot-monitor-web/app/push/status/page.tsx +++ b/src/Web/autopilot-monitor-web/app/push/status/page.tsx @@ -2,15 +2,22 @@ import Link from "next/link"; import { useEffect, useState } from "react"; -import { META_KEYS } from "@/lib/push/pushCore"; +import { HISTORY_MAX_ENTRIES, META_KEYS, readRetentionDays, writeRetentionDays } from "@/lib/push/pushCore"; import { describePushError } from "@/lib/push/pushApi"; import { isStoragePersisted, reconcileDevice, unpairDevice, type ReconcileResult } from "@/lib/push/pushClient"; import { describeDeviceStatus, formatDateTime } from "@/lib/push/pushFormat"; +import { describeRetention, parseRetentionInput } from "@/lib/push/retentionInput"; import { useIsClient, useNotificationPermission } from "../pushEnvironment"; import { DEVICE_STATUS_CHIP, NEUTRAL_CHIP } from "../pushStyles"; type UnpairState = { kind: "idle" } | { kind: "confirm" } | { kind: "working" } | { kind: "done" } | { kind: "error"; message: string }; +/** The retention field's feedback line; "saved" carries its time so a second save restarts the timer. */ +type RetentionNote = { kind: "idle" } | { kind: "working" } | { kind: "saved"; at: number } | { kind: "error"; message: string }; + +/** How long "Saved." stays under the retention field. */ +const SAVED_NOTE_MS = 2000; + /** Device status as the server sees it, plus the local facts that decide whether a push can arrive. */ export default function PushStatusPage() { const isClient = useIsClient(); @@ -18,14 +25,23 @@ export default function PushStatusPage() { const [result, setResult] = useState(null); const [persisted, setPersisted] = useState(null); const [unpair, setUnpair] = useState({ kind: "idle" }); + // The stored retention (null: not readable), what is typed (null: show the stored value), the feedback line. + const [retention, setRetention] = useState(null); + const [retentionInput, setRetentionInput] = useState(null); + const [retentionNote, setRetentionNote] = useState({ kind: "idle" }); useEffect(() => { let cancelled = false; const run = async () => { - const [reconciled, storage] = await Promise.all([reconcileDevice(), isStoragePersisted()]); + const [reconciled, storage, days] = await Promise.all([ + reconcileDevice(), + isStoragePersisted(), + readRetentionDays().catch(() => null), + ]); if (cancelled) return; setResult(reconciled); setPersisted(storage); + setRetention(days); }; void run(); return () => { @@ -33,6 +49,34 @@ export default function PushStatusPage() { }; }, []); + // "Saved." fades after a moment; typing or another save replaces it, and the cleanup drops the timer. + const savedAt = retentionNote.kind === "saved" ? retentionNote.at : null; + useEffect(() => { + if (savedAt === null) return; + const timer = setTimeout(() => setRetentionNote({ kind: "idle" }), SAVED_NOTE_MS); + return () => clearTimeout(timer); + }, [savedAt]); + + const retentionText = retentionInput ?? (retention === null ? "" : String(retention)); + + const saveRetention = async () => { + const parsed = parseRetentionInput(retentionText); + if (!parsed.ok) { + setRetentionNote({ kind: "error", message: parsed.message }); + return; + } + setRetentionNote({ kind: "working" }); + try { + // Stores and prunes at once; the stored value is what the field shows from now on. + const stored = await writeRetentionDays(parsed.days); + setRetention(stored); + setRetentionInput(null); + setRetentionNote({ kind: "saved", at: Date.now() }); + } catch { + setRetentionNote({ kind: "error", message: "Could not save on this device." }); + } + }; + const doUnpair = async () => { setUnpair({ kind: "working" }); try { @@ -88,6 +132,10 @@ export default function PushStatusPage() { + )} @@ -104,12 +152,76 @@ export default function PushStatusPage() {

    On this device

    - +
    + {result.state === "ok" && result.repairError && ( +

    + The subscription could not be registered with the server: {result.repairError} It is retried the next time this app is opened. +

    + )} {result.state === "ok" && !result.subscriptionPresent && permission !== "granted" && (

    Without notification permission the browser holds no push subscription; alerts cannot arrive.

    )} + + {paired && ( +
    +
    +

    History retention

    + {retention === null ? "—" : describeRetention(retention)} +
    +
    { + e.preventDefault(); + void saveRetention(); + }} + className="flex items-end gap-2" + > + + +
    +

    + 0 keeps entries until the cap of {HISTORY_MAX_ENTRIES}; the newest {HISTORY_MAX_ENTRIES} are kept in any case. +

    + {retentionNote.kind === "error" && ( +

    + {retentionNote.message} +

    + )} + {retentionNote.kind === "saved" && ( +

    + Saved. +

    + )} +
    + )} {paired && ( diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/longPress.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/longPress.test.ts new file mode 100644 index 00000000..a66f77b0 --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/__tests__/longPress.test.ts @@ -0,0 +1,157 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createLongPress, type LongPressPointerEvent } from "../push/longPress"; + +/** + * Pins the long-press contract of the history rows: fires after the delay while the pointer is + * still down, cancels on movement or release, and suppresses exactly the one click that the + * release after a fired press may produce. + */ + +const DELAY = 500; +const TOLERANCE = 10; + +function pointer(overrides: Partial = {}): LongPressPointerEvent { + return { pointerId: 1, pointerType: "touch", isPrimary: true, button: 0, clientX: 100, clientY: 100, ...overrides }; +} + +function harness(options: { delayMs?: number; moveTolerancePx?: number } = {}) { + const onLongPress = vi.fn(); + const press = createLongPress({ delayMs: DELAY, moveTolerancePx: TOLERANCE, onLongPress, ...options }); + return { onLongPress, press }; +} + +describe("createLongPress", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("fires once after the delay, not before, while the pointer stays down", () => { + const { onLongPress, press } = harness(); + press.onPointerDown(pointer()); + vi.advanceTimersByTime(DELAY - 1); + expect(onLongPress).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(onLongPress).toHaveBeenCalledTimes(1); + vi.advanceTimersByTime(5_000); + expect(onLongPress).toHaveBeenCalledTimes(1); + }); + + it("uses the defaults (500 ms, 10 px) when no options are given", () => { + const onLongPress = vi.fn(); + const press = createLongPress({ onLongPress }); + press.onPointerDown(pointer()); + press.onPointerMove(pointer({ clientX: 110 })); // exactly at the tolerance: still a press + vi.advanceTimersByTime(499); + expect(onLongPress).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(onLongPress).toHaveBeenCalledTimes(1); + }); + + it("is cancelled by a release before the delay", () => { + const { onLongPress, press } = harness(); + press.onPointerDown(pointer()); + vi.advanceTimersByTime(DELAY - 1); + press.onPointerUp(pointer()); + vi.advanceTimersByTime(5_000); + expect(onLongPress).not.toHaveBeenCalled(); + expect(press.shouldSuppressClick()).toBe(false); + }); + + it("is cancelled by pointercancel and by the pointer leaving", () => { + for (const end of ["onPointerCancel", "onPointerLeave"] as const) { + const { onLongPress, press } = harness(); + press.onPointerDown(pointer()); + vi.advanceTimersByTime(100); + press[end](pointer()); + vi.advanceTimersByTime(5_000); + expect(onLongPress, end).not.toHaveBeenCalled(); + } + }); + + it("is cancelled by movement beyond the tolerance, not by movement within it", () => { + const within = harness(); + within.press.onPointerDown(pointer()); + within.press.onPointerMove(pointer({ clientX: 106, clientY: 108 })); // 10 px exactly + vi.advanceTimersByTime(DELAY); + expect(within.onLongPress).toHaveBeenCalledTimes(1); + + const beyond = harness(); + beyond.press.onPointerDown(pointer()); + beyond.press.onPointerMove(pointer({ clientX: 108, clientY: 108 })); // ~11.3 px + vi.advanceTimersByTime(DELAY); + expect(beyond.onLongPress).not.toHaveBeenCalled(); + }); + + it("ignores a non-primary button and a non-primary pointer", () => { + const { onLongPress, press } = harness(); + press.onPointerDown(pointer({ pointerType: "mouse", button: 2 })); + press.onPointerDown(pointer({ pointerId: 2, isPrimary: false })); + vi.advanceTimersByTime(DELAY); + expect(onLongPress).not.toHaveBeenCalled(); + }); + + it("counts the primary mouse button and the pen tip", () => { + for (const pointerType of ["mouse", "pen"]) { + const { onLongPress, press } = harness(); + press.onPointerDown(pointer({ pointerType })); + vi.advanceTimersByTime(DELAY); + expect(onLongPress, pointerType).toHaveBeenCalledTimes(1); + } + }); + + it("ends a pending press when a second finger lands", () => { + const { onLongPress, press } = harness(); + press.onPointerDown(pointer()); + vi.advanceTimersByTime(100); + press.onPointerDown(pointer({ pointerId: 2, isPrimary: false })); + vi.advanceTimersByTime(5_000); + expect(onLongPress).not.toHaveBeenCalled(); + }); + + it("ignores move and release events of another pointer", () => { + const { onLongPress, press } = harness(); + press.onPointerDown(pointer()); + press.onPointerMove(pointer({ pointerId: 2, clientX: 300 })); + press.onPointerUp(pointer({ pointerId: 2 })); + vi.advanceTimersByTime(DELAY); + expect(onLongPress).toHaveBeenCalledTimes(1); + }); + + it("suppresses exactly one click after a press fired", () => { + const { press } = harness(); + expect(press.shouldSuppressClick()).toBe(false); + press.onPointerDown(pointer()); + vi.advanceTimersByTime(DELAY); + press.onPointerUp(pointer()); + expect(press.shouldSuppressClick()).toBe(true); + expect(press.shouldSuppressClick()).toBe(false); + }); + + it("drops a stale suppression when a new press starts", () => { + const { press } = harness(); + press.onPointerDown(pointer()); + vi.advanceTimersByTime(DELAY); + press.onPointerUp(pointer()); + // The browser swallowed the click of that release; the next tap must count. + press.onPointerDown(pointer()); + press.onPointerUp(pointer()); + expect(press.shouldSuppressClick()).toBe(false); + }); + + it("dispose clears a pending timer and any suppression", () => { + const { onLongPress, press } = harness(); + press.onPointerDown(pointer()); + press.dispose(); + vi.advanceTimersByTime(5_000); + expect(onLongPress).not.toHaveBeenCalled(); + + press.onPointerDown(pointer()); + vi.advanceTimersByTime(DELAY); + expect(onLongPress).toHaveBeenCalledTimes(1); + press.dispose(); + expect(press.shouldSuppressClick()).toBe(false); + }); +}); diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/pushCore.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/pushCore.test.ts index b7b3ec9a..68ab780c 100644 --- a/src/Web/autopilot-monitor-web/lib/__tests__/pushCore.test.ts +++ b/src/Web/autopilot-monitor-web/lib/__tests__/pushCore.test.ts @@ -12,15 +12,20 @@ import { GENERIC_TITLE, HISTORY_MAX_AGE_MS, HISTORY_MAX_ENTRIES, + HISTORY_RETENTION_DEFAULT_DAYS, + HISTORY_RETENTION_MAX_DAYS, isIosDevice, isValidPairingCode, isWipeCommand, LABEL_MAX_CHARS, normalizePairingCode, normalizePayload, + normalizeRetentionDays, PAIRING_CODE_ALPHABET, PAIRING_CODE_LENGTH, parseFragment, + planSubscriptionKeys, + META_KEYS, PLATFORMS, pruneHistory, pushApiRequest, @@ -205,6 +210,63 @@ describe("pruneHistory", () => { pruneHistory(input, NOW); expect(input).toEqual(copy); }); + + it("honours the user's retention: 7 days drops week-old entries, 365 keeps them, 0 keeps everything up to the cap", () => { + const dayMs = 24 * 60 * 60 * 1000; + const recent = entry("recent", new Date(NOW - 3 * dayMs).toISOString()); + const weekOld = entry("week", new Date(NOW - 8 * dayMs).toISOString()); + const yearOld = entry("year", new Date(NOW - 400 * dayMs).toISOString()); + expect(pruneHistory([yearOld, weekOld, recent], NOW, 7).map((e) => e.id)).toEqual(["recent"]); + expect(pruneHistory([yearOld, weekOld, recent], NOW, 365).map((e) => e.id)).toEqual(["recent", "week"]); + expect(pruneHistory([yearOld, weekOld, recent], NOW, 0).map((e) => e.id)).toEqual(["recent", "week", "year"]); + const many = Array.from({ length: 250 }, (_, i) => entry(`m${i}`, new Date(NOW - i * 10 * dayMs).toISOString())); + expect(pruneHistory(many, NOW, 0).length).toBe(HISTORY_MAX_ENTRIES); + }); + + it("falls back to the 30-day default for an invalid retention value", () => { + const dayMs = 24 * 60 * 60 * 1000; + const old = entry("old", new Date(NOW - 31 * dayMs).toISOString()); + expect(pruneHistory([old], NOW, Number.NaN)).toEqual([]); + expect(pruneHistory([old], NOW, 400)).toEqual([]); + }); +}); + +describe("planSubscriptionKeys (worker repair after a browser-side subscription change)", () => { + const stored = { [META_KEYS.kid]: "k1", [META_KEYS.vapidPublicKey]: "key-1" }; + + it("uses the stored key while the server's active key matches or is unknown", () => { + expect(planSubscriptionKeys(stored, { activeKid: "k1", activeVapidPublicKey: "key-1" })).toEqual({ kid: "k1", publicKey: "key-1", rekey: false }); + expect(planSubscriptionKeys(stored, null)).toEqual({ kid: "k1", publicKey: "key-1", rekey: false }); + expect(planSubscriptionKeys(stored, { activeKid: "", activeVapidPublicKey: "" })).toEqual({ kid: "k1", publicKey: "key-1", rekey: false }); + }); + + it("switches to the server's active key after a rotation, and when the stored key is missing", () => { + expect(planSubscriptionKeys(stored, { activeKid: "k2", activeVapidPublicKey: "key-2" })).toEqual({ kid: "k2", publicKey: "key-2", rekey: true }); + expect(planSubscriptionKeys({ [META_KEYS.kid]: "k1" }, { activeKid: "k1", activeVapidPublicKey: "key-1" })).toEqual({ kid: "k1", publicKey: "key-1", rekey: false }); + expect(planSubscriptionKeys({}, { activeKid: "k2", activeVapidPublicKey: "key-2" })).toEqual({ kid: "k2", publicKey: "key-2", rekey: true }); + }); + + it("subscribes with nothing when neither a stored key nor a usable active key exists", () => { + expect(planSubscriptionKeys({}, null)).toBeNull(); + expect(planSubscriptionKeys({ [META_KEYS.kid]: "k1" }, null)).toBeNull(); + expect(planSubscriptionKeys({}, { activeKid: "k2", activeVapidPublicKey: 7 })).toBeNull(); + }); +}); + +describe("normalizeRetentionDays", () => { + it("accepts integers 0–365 as numbers or stored strings", () => { + expect(normalizeRetentionDays(0)).toBe(0); + expect(normalizeRetentionDays("0")).toBe(0); + expect(normalizeRetentionDays(7)).toBe(7); + expect(normalizeRetentionDays("365")).toBe(HISTORY_RETENTION_MAX_DAYS); + }); + + it("maps absent, blank, fractional, negative and out-of-range values to the default, never to forever", () => { + expect(HISTORY_RETENTION_DEFAULT_DAYS).toBe(30); + for (const value of [undefined, null, "", " ", "abc", 1.5, -1, 366, "1e3", Number.POSITIVE_INFINITY]) { + expect(normalizeRetentionDays(value), String(value)).toBe(HISTORY_RETENTION_DEFAULT_DAYS); + } + }); }); describe("pairing code grammar (K8)", () => { diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/pushReconcile.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/pushReconcile.test.ts index 493281b6..f9c0a363 100644 --- a/src/Web/autopilot-monitor-web/lib/__tests__/pushReconcile.test.ts +++ b/src/Web/autopilot-monitor-web/lib/__tests__/pushReconcile.test.ts @@ -3,12 +3,14 @@ import { decideReconcileAction, type ReconcileInput } from "../push/pushReconcil /** * Pins the reconcile-on-open decision (K14): a Stale row is re-armed by replacing the dead - * subscription, never by comparing endpoints; Paused behaves like Active. + * subscription, never by comparing endpoints; Paused behaves like Active; a rotated platform + * key (B-y4z) is a re-registration with the server's active key that wins over everything else. */ const HELD = "https://push.example.test/sub/held"; const STORED = "https://push.example.test/sub/stored"; +/** Defaults describe a healthy device without a key rotation (stored kid = active kid). */ function input(overrides: Partial): ReconcileInput { return { serverStatus: "Active", @@ -16,6 +18,9 @@ function input(overrides: Partial): ReconcileInput { metaEndpoint: HELD, permission: "granted", canSubscribe: true, + metaKid: "k1", + activeKid: "k1", + canRekey: true, ...overrides, }; } @@ -60,4 +65,56 @@ describe("decideReconcileAction", () => { expect(decideReconcileAction(input({ serverStatus: "Whatever" }))).toBe("none"); expect(decideReconcileAction(input({ serverStatus: "Whatever", metaEndpoint: STORED }))).toBe("put"); }); + + describe("key rotation (B-y4z: a new platform key is a re-registration, never a re-pair)", () => { + it("rekeys when the active kid differs from the stored one, whatever the endpoints say", () => { + expect(decideReconcileAction(input({ activeKid: "k2" }))).toBe("rekey"); // would be none + expect(decideReconcileAction(input({ activeKid: "k2", metaEndpoint: STORED }))).toBe("rekey"); // would be put + expect(decideReconcileAction(input({ activeKid: "k2", metaEndpoint: null }))).toBe("rekey"); // would be put + expect(decideReconcileAction(input({ activeKid: "k2", heldEndpoint: null }))).toBe("rekey"); // would be resubscribe + }); + + it.each(["Active", "Pending", "Paused", "Whatever"])("%s: rekeys regardless of the status", (serverStatus) => { + expect(decideReconcileAction(input({ serverStatus, activeKid: "k2" }))).toBe("rekey"); + }); + + it("rekeys a Stale row too, with or without a held subscription or a stored key", () => { + expect(decideReconcileAction(input({ serverStatus: "Stale", activeKid: "k2" }))).toBe("rekey"); + expect(decideReconcileAction(input({ serverStatus: "Stale", activeKid: "k2", heldEndpoint: null }))).toBe("rekey"); + expect(decideReconcileAction(input({ serverStatus: "Stale", activeKid: "k2", canSubscribe: false }))).toBe("rekey"); + expect(decideReconcileAction(input({ serverStatus: "Stale", activeKid: "k2", heldEndpoint: null, canSubscribe: false }))).toBe("rekey"); + }); + + it("rekeys when no kid is stored (the held subscription's key is unknown)", () => { + expect(decideReconcileAction(input({ metaKid: null }))).toBe("rekey"); + expect(decideReconcileAction(input({ metaKid: null, heldEndpoint: null }))).toBe("rekey"); + }); + + it("leaves every decision unchanged when the kids match", () => { + expect(decideReconcileAction(input({}))).toBe("none"); + expect(decideReconcileAction(input({ metaEndpoint: STORED }))).toBe("put"); + expect(decideReconcileAction(input({ heldEndpoint: null }))).toBe("resubscribe"); + expect(decideReconcileAction(input({ heldEndpoint: null, permission: "denied" }))).toBe("repair-required"); + expect(decideReconcileAction(input({ serverStatus: "Stale" }))).toBe("resubscribe"); + }); + + it("never rekeys when the server names no active key (unconfigured channel, older server)", () => { + expect(decideReconcileAction(input({ activeKid: null }))).toBe("none"); + expect(decideReconcileAction(input({ activeKid: "" }))).toBe("none"); + expect(decideReconcileAction(input({ activeKid: null, metaKid: null }))).toBe("none"); + expect(decideReconcileAction(input({ activeKid: "", metaEndpoint: STORED }))).toBe("put"); + expect(decideReconcileAction(input({ activeKid: null, heldEndpoint: null }))).toBe("resubscribe"); + expect(decideReconcileAction(input({ activeKid: "", serverStatus: "Stale" }))).toBe("resubscribe"); + }); + + it("falls back to the old decision when it cannot rekey (permission or key missing)", () => { + // The caller folds "active key sent", "PushManager exists" and "permission granted" into canRekey. + expect(decideReconcileAction(input({ activeKid: "k2", canRekey: false }))).toBe("none"); + expect(decideReconcileAction(input({ activeKid: "k2", canRekey: false, metaEndpoint: STORED }))).toBe("put"); + expect(decideReconcileAction(input({ activeKid: "k2", canRekey: false, heldEndpoint: null }))).toBe("resubscribe"); + expect(decideReconcileAction(input({ activeKid: "k2", canRekey: false, serverStatus: "Stale" }))).toBe("resubscribe"); + expect(decideReconcileAction(input({ activeKid: "k2", canRekey: false, serverStatus: "Stale", canSubscribe: false }))).toBe("put"); + expect(decideReconcileAction(input({ activeKid: "k2", canRekey: false, permission: "denied", heldEndpoint: null }))).toBe("repair-required"); + }); + }); }); diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/retentionInput.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/retentionInput.test.ts new file mode 100644 index 00000000..2c64db0d --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/__tests__/retentionInput.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { HISTORY_MAX_ENTRIES, HISTORY_RETENTION_MAX_DAYS } from "../push/pushCore"; +import { describeRetention, parseRetentionInput } from "../push/retentionInput"; + +/** + * The status page's retention field: what the person types becomes an integer 0–365 or one of + * two messages, and the stored value reads back as "30 days" / "1 day" / "Until the 200-entry cap". + */ +describe("retention input", () => { + const rangeMessage = "Enter a whole number of days between 0 and 365."; + + it("accepts the boundaries 0 and 365", () => { + expect(parseRetentionInput("0")).toEqual({ ok: true, days: 0 }); + expect(parseRetentionInput("365")).toEqual({ ok: true, days: 365 }); + expect(parseRetentionInput(String(HISTORY_RETENTION_MAX_DAYS))).toEqual({ ok: true, days: HISTORY_RETENTION_MAX_DAYS }); + }); + + it("asks for a number when the field is blank", () => { + expect(parseRetentionInput("")).toEqual({ ok: false, message: "Enter a number of days." }); + expect(parseRetentionInput(" ")).toEqual({ ok: false, message: "Enter a number of days." }); + }); + + it("rejects anything but a whole number", () => { + expect(parseRetentionInput("abc")).toEqual({ ok: false, message: rangeMessage }); + expect(parseRetentionInput("1.5")).toEqual({ ok: false, message: rangeMessage }); + expect(parseRetentionInput("1,5")).toEqual({ ok: false, message: rangeMessage }); + expect(parseRetentionInput("+5")).toEqual({ ok: false, message: rangeMessage }); + expect(parseRetentionInput("5 days")).toEqual({ ok: false, message: rangeMessage }); + }); + + it("rejects values outside 0 to 365", () => { + expect(parseRetentionInput("-1")).toEqual({ ok: false, message: rangeMessage }); + expect(parseRetentionInput("366")).toEqual({ ok: false, message: rangeMessage }); + expect(parseRetentionInput("99999999999999999999")).toEqual({ ok: false, message: rangeMessage }); + }); + + it("ignores surrounding whitespace and leading zeros", () => { + expect(parseRetentionInput(" 30 ")).toEqual({ ok: true, days: 30 }); + expect(parseRetentionInput("\t7\n")).toEqual({ ok: true, days: 7 }); + expect(parseRetentionInput("007")).toEqual({ ok: true, days: 7 }); + }); + + it("describes the stored value in words", () => { + expect(describeRetention(30)).toBe("30 days"); + expect(describeRetention(1)).toBe("1 day"); + expect(describeRetention(0)).toBe("Until the 200-entry cap"); + expect(describeRetention(0)).toBe(`Until the ${HISTORY_MAX_ENTRIES}-entry cap`); + }); +}); diff --git a/src/Web/autopilot-monitor-web/lib/push/longPress.ts b/src/Web/autopilot-monitor-web/lib/push/longPress.ts new file mode 100644 index 00000000..44a3ee0e --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/push/longPress.ts @@ -0,0 +1,108 @@ +/** + * Pointer-based long press, as chat apps use it to open a message's actions. Pure timers and + * plain state, no React: the row component creates one controller per mount, wires the + * handlers onto its element and disposes it on unmount. + * + * Rules: only the primary pointer with the primary button counts (touch contact, pen tip, + * left mouse button); moving beyond the tolerance or releasing before the delay cancels; the + * press fires once, while the pointer is still down. The release after a fired press may + * produce a click, which the element must not treat as a tap — `shouldSuppressClick()` is + * true exactly once after a press fired. A new press drops a stale suppression, because the + * browser may swallow that click (Android's own long-press gesture does) and the next real tap + * must not pay for it. + */ + +/** The fields the controller reads; a React or DOM PointerEvent satisfies it. */ +export interface LongPressPointerEvent { + pointerId: number; + pointerType: string; + isPrimary: boolean; + button: number; + clientX: number; + clientY: number; +} + +export interface LongPressOptions { + /** Hold time before the press fires (ms). */ + delayMs?: number; + /** Movement from the press origin that turns the press into a scroll or drag (px). */ + moveTolerancePx?: number; + onLongPress: () => void; +} + +export interface LongPressHandlers { + onPointerDown: (event: LongPressPointerEvent) => void; + onPointerMove: (event: LongPressPointerEvent) => void; + onPointerUp: (event: LongPressPointerEvent) => void; + onPointerCancel: (event: LongPressPointerEvent) => void; + onPointerLeave: (event: LongPressPointerEvent) => void; +} + +export interface LongPressController extends LongPressHandlers { + /** True exactly once after a press fired: the click of that release is not a tap. */ + shouldSuppressClick: () => boolean; + /** Drops a pending press and any stale click suppression (unmount). */ + dispose: () => void; +} + +export const LONG_PRESS_DELAY_MS = 500; +export const LONG_PRESS_MOVE_TOLERANCE_PX = 10; + +interface PendingPress { + pointerId: number; + x: number; + y: number; + timer: ReturnType; +} + +export function createLongPress(options: LongPressOptions): LongPressController { + const delayMs = options.delayMs ?? LONG_PRESS_DELAY_MS; + const moveTolerancePx = options.moveTolerancePx ?? LONG_PRESS_MOVE_TOLERANCE_PX; + const { onLongPress } = options; + + let pending: PendingPress | null = null; + let suppressClick = false; + + const cancel = () => { + if (!pending) return; + clearTimeout(pending.timer); + pending = null; + }; + + const endIfPending = (event: LongPressPointerEvent) => { + if (pending && pending.pointerId === event.pointerId) cancel(); + }; + + return { + onPointerDown: (event) => { + // A second finger or a non-primary button is not a press; it also ends a pending one. + cancel(); + if (!event.isPrimary || event.button !== 0) return; + suppressClick = false; + const timer = setTimeout(() => { + pending = null; + suppressClick = true; + onLongPress(); + }, delayMs); + pending = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, timer }; + }, + onPointerMove: (event) => { + if (!pending || pending.pointerId !== event.pointerId) return; + const dx = event.clientX - pending.x; + const dy = event.clientY - pending.y; + if (Math.hypot(dx, dy) > moveTolerancePx) cancel(); + }, + onPointerUp: endIfPending, + onPointerCancel: endIfPending, + onPointerLeave: endIfPending, + shouldSuppressClick: () => { + const suppress = suppressClick; + suppressClick = false; + return suppress; + }, + dispose: () => { + cancel(); + suppressClick = false; + }, + }; +} diff --git a/src/Web/autopilot-monitor-web/lib/push/pushClient.ts b/src/Web/autopilot-monitor-web/lib/push/pushClient.ts index 74fae696..46d9cce7 100644 --- a/src/Web/autopilot-monitor-web/lib/push/pushClient.ts +++ b/src/Web/autopilot-monitor-web/lib/push/pushClient.ts @@ -158,7 +158,14 @@ export async function unpairDevice(): Promise { export type ReconcileResult = | { state: "unpaired" } | { state: "gone" } - | { state: "ok"; device: PushDeviceResponse; meta: Record; subscriptionPresent: boolean } + | { + state: "ok"; + device: PushDeviceResponse; + meta: Record; + subscriptionPresent: boolean; + /** Why the subscription repair (subscribe or PUT) failed on this open, or null — the status page shows it; it is retried on the next open. */ + repairError: string | null; + } | { state: "error"; message: string; meta: Record }; /** @@ -166,7 +173,10 @@ export type ReconcileResult = * `pushsubscriptionchange` never fires on iOS, so this is the only repair path there. * The decision itself is pure (decideReconcileAction); this function carries it out: a Stale * row means the held subscription is dead, so it is replaced and the new keys are PUT whatever - * the endpoint comparison says — the server re-arms a Stale row only on PUT. + * the endpoint comparison says — the server re-arms a Stale row only on PUT. A rotated platform + * key (device.activeKid differs from the stored kid, B-y4z) is carried out the same way with the + * server's active key: subscribe again, PUT with the active kid, store key and kid — a + * re-registration on the next open, never a re-pair; the device token stays as it is. */ export async function reconcileDevice(): Promise { const meta = await readMeta().catch(() => ({}) as Record); @@ -185,31 +195,48 @@ export async function reconcileDevice(): Promise { } let subscriptionPresent = false; + let repairError: string | null = null; try { const registration = (await getPushRegistration()) ?? (isPushSupported() ? await registerPushWorker() : null); if (registration) { let subscription = await registration.pushManager.getSubscription(); + const permission = notificationPermission(); const vapidPublicKey = meta[META_KEYS.vapidPublicKey]; + // Empty while the channel is unconfigured; absent from a server older than B-y4z. + const activeVapidPublicKey = device.activeVapidPublicKey || ""; const action = decideReconcileAction({ serverStatus: device.status, heldEndpoint: subscription?.endpoint ?? null, metaEndpoint: meta[META_KEYS.endpoint] ?? null, - permission: notificationPermission(), - canSubscribe: Boolean(vapidPublicKey), + permission, + canSubscribe: Boolean(vapidPublicKey || activeVapidPublicKey), + metaKid: meta[META_KEYS.kid] ?? null, + activeKid: device.activeKid || null, + canRekey: Boolean(activeVapidPublicKey) && permission === "granted", }); - if (action === "resubscribe") { - // The held one is dead (Stale) or absent; a failed subscribe() below leaves none. + // A fresh subscription uses the server's active key after a rotation, and also when the + // stored key is lost; otherwise the stored key, which is what the server's row expects. + const useActiveKey = action === "rekey" || (action === "resubscribe" && !vapidPublicKey); + const kid = useActiveKey ? device.activeKid : (meta[META_KEYS.kid] ?? device.kid); + if (action === "rekey" || action === "resubscribe") { + // The held one is dead (Stale), absent or made with a retired key; a failed subscribe() below leaves none. const held = subscription; subscription = null; if (held) await held.unsubscribe().catch(() => false); - subscription = await subscribeToPush(registration, vapidPublicKey); + subscription = await subscribeToPush(registration, useActiveKey ? activeVapidPublicKey : vapidPublicKey); } if (subscription) { subscriptionPresent = true; - if (action === "put" || action === "resubscribe") { + if (action === "put" || action === "resubscribe" || action === "rekey") { const keys = subscriptionKeys(subscription); - device = await updateDeviceSubscription(token, { ...keys, kid: meta[META_KEYS.kid] ?? device.kid }); - await writeMeta({ [META_KEYS.endpoint]: keys.endpoint }); + device = await updateDeviceSubscription(token, { ...keys, kid }); + const written: Record = useActiveKey + ? { [META_KEYS.endpoint]: keys.endpoint, [META_KEYS.kid]: kid, [META_KEYS.vapidPublicKey]: activeVapidPublicKey } + : { [META_KEYS.endpoint]: keys.endpoint }; + await writeMeta(written); + // The result carries what is stored now, not the snapshot read before the repair — + // the status page's "Server key" row compares the kid in it with the active one. + Object.assign(meta, written); } } } @@ -218,9 +245,11 @@ export async function reconcileDevice(): Promise { await wipeLocalDevice().catch(() => {}); return { state: "gone" }; } - // Subscription repair is best effort; the status page shows "subscription: no". + // Subscription repair is best effort: the device keeps working on what the server knows, + // and the status page names the failure so a wrong key or a refused endpoint is not silent. + repairError = describePushError(error); } - return { state: "ok", device, meta, subscriptionPresent }; + return { state: "ok", device, meta, subscriptionPresent, repairError }; } function isGone(error: unknown): boolean { diff --git a/src/Web/autopilot-monitor-web/lib/push/pushReconcile.ts b/src/Web/autopilot-monitor-web/lib/push/pushReconcile.ts index 3f709fac..8a10a1f5 100644 --- a/src/Web/autopilot-monitor-web/lib/push/pushReconcile.ts +++ b/src/Web/autopilot-monitor-web/lib/push/pushReconcile.ts @@ -14,6 +14,11 @@ export type ReconcileAction = | "put" /** Drop the held subscription (if any), subscribe again with the stored key and PUT the result. */ | "resubscribe" + /** + * Drop the held subscription (if any), subscribe with the server's active key and PUT the + * result with the active kid: the platform rotated its VAPID key (B-y4z). + */ + | "rekey" /** No subscription and no way to create one (permission not granted / no key): the status page says so. */ | "repair-required"; @@ -25,11 +30,24 @@ export interface ReconcileInput { /** The endpoint stored after the last successful pairing/PUT (what the server knows). */ metaEndpoint: string | null; permission: NotificationPermission | "unsupported"; - /** A VAPID public key is stored and a PushManager exists, so subscribe() can be called. */ + /** A VAPID public key is available and a PushManager exists, so subscribe() can be called. */ canSubscribe: boolean; + /** The key id stored at pairing or the last re-key (the key the held subscription was made with), null when missing. */ + metaKid: string | null; + /** PushDeviceStatusResponse.activeKid: the platform's current key id; null or "" while the channel is unconfigured. */ + activeKid: string | null; + /** The server sent its active public key, a PushManager exists and permission is granted — the caller computes it. */ + canRekey: boolean; } /** + * - Key rotation (B-y4z): the server names its current key (activeKid). When it differs from the + * kid the held subscription was made with (metaKid) and a fresh subscription can be made with + * the server's active key (canRekey), the device re-registers itself: "rekey" wins over every + * other decision, whatever the status or the endpoint comparison says, and the device token + * stays — a rotation is never a re-pair. Without canRekey (permission not granted, no key sent) + * the old key keeps working while the server retires it gracefully, so the decisions below + * stand. An empty activeKid (unconfigured channel, older server) never triggers it. * - Stale: the backend set it on 404/410 or vapid_key_mismatch and re-arms only on PUT. The held * subscription is dead, so it is replaced when possible and otherwise PUT as it is; without any * subscription the device needs a new permission grant. @@ -39,6 +57,7 @@ export interface ReconcileInput { * is PUT, a matching one needs nothing. */ export function decideReconcileAction(input: ReconcileInput): ReconcileAction { + if (input.activeKid && input.activeKid !== input.metaKid && input.canRekey) return "rekey"; const canResubscribe = input.canSubscribe && input.permission === "granted"; if (input.serverStatus === "Stale") { if (canResubscribe) return "resubscribe"; diff --git a/src/Web/autopilot-monitor-web/lib/push/retentionInput.ts b/src/Web/autopilot-monitor-web/lib/push/retentionInput.ts new file mode 100644 index 00000000..e87555ce --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/push/retentionInput.ts @@ -0,0 +1,27 @@ +/** + * The status page's history-retention field: parsing what the person typed and wording the + * stored value. Pure, so lib/__tests__/retentionInput.test.ts pins both. The value itself lives + * in IndexedDB meta (pushCore readRetentionDays / writeRetentionDays) and never leaves the device. + */ +import { HISTORY_MAX_ENTRIES, HISTORY_RETENTION_MAX_DAYS } from "@/lib/push/pushCore"; + +export type RetentionInputResult = { ok: true; days: number } | { ok: false; message: string }; + +const BLANK_MESSAGE = "Enter a number of days."; +const RANGE_MESSAGE = `Enter a whole number of days between 0 and ${HISTORY_RETENTION_MAX_DAYS}.`; + +/** Trims, then accepts only an integer 0–365: digits alone, no sign, no decimals, no unit. */ +export function parseRetentionInput(text: string): RetentionInputResult { + const trimmed = text.trim(); + if (trimmed === "") return { ok: false, message: BLANK_MESSAGE }; + if (!/^[0-9]+$/.test(trimmed)) return { ok: false, message: RANGE_MESSAGE }; + const days = Number(trimmed); + if (days > HISTORY_RETENTION_MAX_DAYS) return { ok: false, message: RANGE_MESSAGE }; + return { ok: true, days }; +} + +/** "30 days", "1 day", or for 0 "Until the 200-entry cap" (the entry cap applies either way). */ +export function describeRetention(days: number): string { + if (days === 0) return `Until the ${HISTORY_MAX_ENTRIES}-entry cap`; + return days === 1 ? "1 day" : `${days} days`; +} diff --git a/src/Web/autopilot-monitor-web/public/push/sw-core.js b/src/Web/autopilot-monitor-web/public/push/sw-core.js index 47110754..ee0abcef 100644 --- a/src/Web/autopilot-monitor-web/public/push/sw-core.js +++ b/src/Web/autopilot-monitor-web/public/push/sw-core.js @@ -15,7 +15,10 @@ export const HISTORY_TS_INDEX = "ts"; export const META_STORE = "meta"; export const HISTORY_MAX_ENTRIES = 200; -export const HISTORY_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; +/** Retention in days, chosen by the user on the status page; 0 keeps entries until the cap. */ +export const HISTORY_RETENTION_DEFAULT_DAYS = 30; +export const HISTORY_RETENTION_MAX_DAYS = 365; +export const HISTORY_MAX_AGE_MS = HISTORY_RETENTION_DEFAULT_DAYS * 24 * 60 * 60 * 1000; /** The backend caps the label at 40 characters (K13). */ export const LABEL_MAX_CHARS = 40; @@ -59,6 +62,8 @@ export const META_KEYS = /** @type {const} */ ({ pairedAt: "pairedAt", /** The pages know the API origin from their build; the worker learns it from here. */ apiBaseUrl: "apiBaseUrl", + /** Days the history is kept (string of an integer 0–365; 0 = until the entry cap; absent = default). */ + historyRetentionDays: "historyRetentionDays", }); /** @typedef {(typeof PLATFORMS)[number]} Platform */ @@ -300,8 +305,9 @@ export function toNotificationOptions(entry) { * @param {number} nowMs * @returns {HistoryEntry[]} */ -export function pruneHistory(entries, nowMs) { - const cutoff = nowMs - HISTORY_MAX_AGE_MS; +export function pruneHistory(entries, nowMs, retentionDays = HISTORY_RETENTION_DEFAULT_DAYS) { + const days = normalizeRetentionDays(retentionDays); + const cutoff = days === 0 ? Number.NEGATIVE_INFINITY : nowMs - days * 24 * 60 * 60 * 1000; const kept = entries .map((entry) => ({ entry, time: Date.parse(entry.ts) })) .filter((item) => !Number.isNaN(item.time) && item.time >= cutoff) @@ -310,6 +316,19 @@ export function pruneHistory(entries, nowMs) { return kept.map((item) => item.entry); } +/** + * The user's retention choice as the pages store it and the worker reads it: an integer 0–365 + * (0 = keep until the entry cap). Anything else — absent, non-numeric, fractional, out of range — + * is the default, never a silent "forever". + * @param {unknown} value a number or the string stored in meta + * @returns {number} + */ +export function normalizeRetentionDays(value) { + const n = typeof value === "number" ? value : typeof value === "string" && value.trim() !== "" ? Number(value) : Number.NaN; + if (!Number.isInteger(n) || n < 0 || n > HISTORY_RETENTION_MAX_DAYS) return HISTORY_RETENTION_DEFAULT_DAYS; + return n; +} + // --------------------------------------------------------------------------- // Platform // --------------------------------------------------------------------------- @@ -426,6 +445,27 @@ export const DEVICE_TOKEN_HEADER = "X-Push-Device-Token"; * @param {{ method?: string, token?: string | null, body?: unknown }} [options] * @returns {PushApiRequest} */ +/** + * Which key the worker subscribes with when the browser rotates the subscription + * (pushsubscriptionchange; the pages' reconcile has the same rule in decideReconcileAction): + * the server's active key when it is known and differs from the stored kid, or when no stored + * key exists; otherwise the stored key; null when there is nothing to subscribe with. + * @param {Record} meta the stored meta (kid, vapidPublicKey) + * @param {{ activeKid?: unknown, activeVapidPublicKey?: unknown } | null} status GET push/device, or null when it failed + * @returns {{ kid: string, publicKey: string, rekey: boolean } | null} + */ +export function planSubscriptionKeys(meta, status) { + const storedKid = typeof meta[META_KEYS.kid] === "string" ? meta[META_KEYS.kid] : ""; + const storedKey = typeof meta[META_KEYS.vapidPublicKey] === "string" ? meta[META_KEYS.vapidPublicKey] : ""; + const activeKid = status && typeof status.activeKid === "string" ? status.activeKid : ""; + const activeKey = status && typeof status.activeVapidPublicKey === "string" ? status.activeVapidPublicKey : ""; + if (activeKid !== "" && activeKey !== "" && (activeKid !== storedKid || storedKey === "")) { + return { kid: activeKid, publicKey: activeKey, rekey: activeKid !== storedKid }; + } + if (storedKid !== "" && storedKey !== "") return { kid: storedKid, publicKey: storedKey, rekey: false }; + return null; +} + export function pushApiRequest(apiBaseUrl, path, options = {}) { /** @type {Record} */ const headers = { Accept: "application/json" }; @@ -544,9 +584,9 @@ export function writeMeta(values) { * @returns {Promise} all entries, newest first, after applying the retention rule */ export function readHistory() { - return withStores("readonly", async ({ history }) => { + return withStores("readonly", async ({ history, meta }) => { const all = /** @type {HistoryEntry[]} */ (await requestToPromise(history.getAll())); - return pruneHistory(all, Date.now()); + return pruneHistory(all, Date.now(), await retentionDaysFrom(meta)); }); } @@ -556,30 +596,71 @@ export function readHistory() { * @returns {Promise} false when an entry with this id already existed */ export function writeHistoryEntry(entry) { - return withStores("readwrite", async ({ history }) => { + return withStores("readwrite", async ({ history, meta }) => { const existing = await requestToPromise(history.get(entry.id)); if (existing) return false; await requestToPromise(history.put(entry)); - await pruneStore(history); + await pruneStore(history, meta); return true; }); } +/** + * Removes one entry from the local history (the user's delete; nothing leaves the device). + * @param {string} id + * @returns {Promise} + */ +export function deleteHistoryEntry(id) { + return withStores("readwrite", async ({ history }) => { + await requestToPromise(history.delete(id)); + }); +} + /** * Applies the retention rule to the stored rows (called on open as well as on write). * @returns {Promise} */ export function pruneStoredHistory() { - return withStores("readwrite", ({ history }) => pruneStore(history)); + return withStores("readwrite", ({ history, meta }) => pruneStore(history, meta)); +} + +/** + * @returns {Promise} the stored retention in days (default when unset or invalid) + */ +export function readRetentionDays() { + return withStores("readonly", ({ meta }) => retentionDaysFrom(meta)); +} + +/** + * Stores the user's retention choice and applies it at once. + * @param {number} days 0–365, 0 = keep until the entry cap + * @returns {Promise} the value as stored + */ +export function writeRetentionDays(days) { + const value = normalizeRetentionDays(days); + return withStores("readwrite", async ({ history, meta }) => { + await requestToPromise(meta.put(String(value), META_KEYS.historyRetentionDays)); + await pruneStore(history, meta); + return value; + }); +} + +/** + * @param {IDBObjectStore} meta + * @returns {Promise} + */ +async function retentionDaysFrom(meta) { + return normalizeRetentionDays(await requestToPromise(meta.get(META_KEYS.historyRetentionDays))); } /** * @param {IDBObjectStore} history + * @param {IDBObjectStore} meta * @returns {Promise} */ -async function pruneStore(history) { +async function pruneStore(history, meta) { const all = /** @type {HistoryEntry[]} */ (await requestToPromise(history.getAll())); - const keep = new Set(pruneHistory(all, Date.now()).map((entry) => entry.id)); + const keep = new Set(pruneHistory(all, Date.now(), await retentionDaysFrom(meta)).map((entry) => entry.id)); for (const entry of all) { if (!keep.has(entry.id)) await requestToPromise(history.delete(entry.id)); } diff --git a/src/Web/autopilot-monitor-web/public/push/sw.js b/src/Web/autopilot-monitor-web/public/push/sw.js index 8ebe646d..944c1183 100644 --- a/src/Web/autopilot-monitor-web/public/push/sw.js +++ b/src/Web/autopilot-monitor-web/public/push/sw.js @@ -19,6 +19,7 @@ import { isWipeCommand, META_KEYS, normalizePayload, + planSubscriptionKeys, pushApiRequest, readMeta, toNotificationOptions, @@ -187,32 +188,72 @@ self.addEventListener("notificationclick", (event) => { ); }); -// Chrome ≥ 138 and Firefox rotate subscriptions here; iOS never fires it (the pages reconcile on open). +/** Network calls from the worker are bounded; a hanging push API must not pin the event. */ +const FETCH_TIMEOUT_MS = 10000; + +/** + * @param {RequestInit} init + * @returns {RequestInit} + */ +function bounded(init) { + return typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function" + ? { ...init, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) } + : init; +} + +/** + * The device's status as the server sees it, or null when the server did not answer — the + * repair then falls back to the stored key. + * @param {string} apiBaseUrl + * @param {string} token + * @returns {Promise<{ activeKid?: unknown, activeVapidPublicKey?: unknown } | null>} + */ +async function fetchDeviceStatus(apiBaseUrl, token) { + try { + const { url, init } = pushApiRequest(apiBaseUrl, "/api/push/device", { method: "GET", token }); + const response = await fetch(url, bounded(init)); + return response.ok ? await response.json() : null; + } catch { + return null; + } +} + +// Chrome ≥ 138 and Firefox rotate subscriptions here; iOS never fires it (the pages reconcile on +// open). The server's active key decides what to subscribe with — a key rotation may have +// happened while no page was open — and the stored key is the fallback when the server is silent. self.addEventListener("pushsubscriptionchange", (event) => { event.waitUntil( (async () => { const meta = await readMeta(); const token = meta[META_KEYS.deviceToken]; const apiBaseUrl = meta[META_KEYS.apiBaseUrl]; - const vapidPublicKey = meta[META_KEYS.vapidPublicKey]; if (!token || !apiBaseUrl) return; - let subscription = event.newSubscription ?? null; - if (!subscription && vapidPublicKey) { + const plan = planSubscriptionKeys(meta, await fetchDeviceStatus(apiBaseUrl, token)); + if (!plan) return; + // The browser's replacement subscription was made with the old key; after a rotation it is dropped. + let subscription = plan.rekey ? null : (event.newSubscription ?? null); + if (plan.rekey && event.newSubscription) await event.newSubscription.unsubscribe().catch(() => false); + if (!subscription) { subscription = await self.registration.pushManager.subscribe({ userVisibleOnly: true, - applicationServerKey: base64UrlToUint8Array(vapidPublicKey), + applicationServerKey: base64UrlToUint8Array(plan.publicKey), }); } - if (!subscription) return; const body = { endpoint: subscription.endpoint, p256dh: bufferToBase64Url(subscription.getKey("p256dh")), auth: bufferToBase64Url(subscription.getKey("auth")), - kid: meta[META_KEYS.kid] ?? "", + kid: plan.kid, }; const { url, init } = pushApiRequest(apiBaseUrl, "/api/push/device", { method: "PUT", token, body }); - const response = await fetch(url, init); - if (response.ok) await writeMeta({ [META_KEYS.endpoint]: subscription.endpoint }); + const response = await fetch(url, bounded(init)); + if (response.ok) { + await writeMeta({ + [META_KEYS.endpoint]: subscription.endpoint, + [META_KEYS.kid]: plan.kid, + [META_KEYS.vapidPublicKey]: plan.publicKey, + }); + } })(), ); }); diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.json b/src/Web/autopilot-monitor-web/utils/shared-manifests.json index f08c5782..5699ee1a 100644 --- a/src/Web/autopilot-monitor-web/utils/shared-manifests.json +++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.json @@ -15642,6 +15642,22 @@ "name": "string" }, "doc": "Tenant display name, or \"Platform operator\" for the platform scope." + }, + { + "name": "activeKid", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "The platform's current VAPID key id. Differs from Kid after a key rotation: the receiver then subscribes again with ActiveVapidPublicKey and PUTs the new subscription with this kid — no re-pairing. Empty while the channel is unconfigured." + }, + { + "name": "activeVapidPublicKey", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "Uncompressed P-256 point, base64url, of the current key (what to subscribe with)." } ] }, diff --git a/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts b/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts index e6741d3a..526142f9 100644 --- a/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts +++ b/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts @@ -3351,6 +3351,10 @@ export interface PushDeviceStatusResponse { ownerUpn: string; /** Tenant display name, or "Platform operator" for the platform scope. */ scopeName: string; + /** The platform's current VAPID key id. Differs from Kid after a key rotation: the receiver then subscribes again with ActiveVapidPublicKey and PUTs the new subscription with this kid — no re-pairing. Empty while the channel is unconfigured. */ + activeKid: string; + /** Uncompressed P-256 point, base64url, of the current key (what to subscribe with). */ + activeVapidPublicKey: string; } /** Error body of POST /api/global/raw/logs when the telemetry store rejected or failed the query (400 for a caller-side KQL error, 502 for store/grant failures): the envelope prefix plus the store's own error code, HTTP status and — capped — its full response, so nothing the CLI would print is lost. hint tells the caller how to fix the query. */