mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-23 20:26:10 +00:00
Push receiver: delete entries, user-set retention, self-migrating VAPID key (#180)
* 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 <noreply@anthropic.com> * bump pointer: internal, infra (push-relay addendum: delete, retention, key migration) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * bump pointer: internal (push-relay: worker key rule) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
d378fabc01
commit
2517e8eb28
+1
-1
Submodule infra updated: d8111089f4...f93e475b78
+1
-1
Submodule internal updated: 71ee3d7ca8...f27172a599
@@ -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<string, object?>
|
||||
{
|
||||
["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<PushEligibility> Eligibility { get; }
|
||||
public Mock<OpsEventService> 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()
|
||||
/// <param name="keys">The platform's key ring; a rotated ring for the key-migration tests.</param>
|
||||
/// <param name="repo">A repository shared with another harness: the same device rows seen under a new ring.</param>
|
||||
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<PushEligibility>(null!, null!, null!, null!, null!) { CallBase = false };
|
||||
Eligibility.Setup(e => e.HasPushChannelAsync(It.IsAny<NotificationScope>())).ReturnsAsync(true);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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://<data-account>.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": "",
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -94,6 +94,14 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public string OwnerUpn { get; set; } = default!;
|
||||
/// <summary>Tenant display name, or "Platform operator" for the platform scope.</summary>
|
||||
public string ScopeName { get; set; } = default!;
|
||||
/// <summary>
|
||||
/// The platform's current VAPID key id. Differs from <see cref="Kid"/> after a key rotation:
|
||||
/// the receiver then subscribes again with <see cref="ActiveVapidPublicKey"/> and PUTs the
|
||||
/// new subscription with this kid — no re-pairing. Empty while the channel is unconfigured.
|
||||
/// </summary>
|
||||
public string ActiveKid { get; set; } = default!;
|
||||
/// <summary>Uncompressed P-256 point, base64url, of the current key (what to subscribe with).</summary>
|
||||
public string ActiveVapidPublicKey { get; set; } = default!;
|
||||
}
|
||||
|
||||
/// <summary>PUT push/device — a changed or re-created subscription (same device, same owner).</summary>
|
||||
|
||||
@@ -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<HTMLDivElement>(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 (
|
||||
<ModalPortal>
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-end sm:items-center justify-center"
|
||||
onPointerDown={arm}
|
||||
onKeyDown={arm}
|
||||
onClick={() => {
|
||||
if (armed()) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Alert actions"
|
||||
tabIndex={-1}
|
||||
className="bg-white rounded-t-2xl sm:rounded-lg shadow-xl w-full max-w-md p-4 space-y-3 focus:outline-none"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<p className="text-sm font-medium text-gray-900 truncate">{entry.title}</p>
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (armed()) onDelete();
|
||||
}}
|
||||
className="w-full px-4 py-3 bg-white border border-red-300 text-red-700 rounded-lg hover:bg-red-50 transition-colors text-sm font-medium"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
{entry.portalUrl && (
|
||||
<a
|
||||
href={entry.portalUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(event) => {
|
||||
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
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (armed()) onClose();
|
||||
}}
|
||||
className="w-full px-4 py-3 text-sm text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalPortal>
|
||||
);
|
||||
}
|
||||
@@ -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<HistoryEntry[] | null>(null);
|
||||
const [device, setDevice] = useState<ReconcileResult | null>(null);
|
||||
const [historyKey, setHistoryKey] = useState(0);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const [now, setNow] = useState(() => 0);
|
||||
const [sheet, setSheet] = useState<HistoryEntry | null>(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<WorkerMessage>) => {
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<DeviceChip device={device} />
|
||||
@@ -141,71 +167,112 @@ export default function PushHistoryPage() {
|
||||
|
||||
{entries && entries.length > 0 && (
|
||||
<ul className="space-y-2">
|
||||
{entries.map((entry) => {
|
||||
const open = expanded.id === entry.id;
|
||||
return (
|
||||
<li
|
||||
key={entry.id}
|
||||
id={`entry-${entry.id}`}
|
||||
className={`bg-white rounded-lg shadow border-l-4 ${SEVERITY_ACCENT[entry.severity]} ${
|
||||
focusId === entry.id ? "ring-2 ring-sky-300 dark:ring-sky-700" : ""
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(entry.id)}
|
||||
aria-expanded={open}
|
||||
className="w-full text-left px-3 py-2.5"
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-sm font-medium text-gray-900 break-words min-w-0">{entry.title}</span>
|
||||
<time
|
||||
dateTime={entry.ts}
|
||||
title={formatDateTime(entry.ts)}
|
||||
className="text-xs text-gray-500 shrink-0"
|
||||
>
|
||||
{now ? formatRelativeTime(entry.ts, now) : ""}
|
||||
</time>
|
||||
</div>
|
||||
{entry.body && (
|
||||
<p className={`text-sm text-gray-600 mt-0.5 break-words ${open ? "" : "line-clamp-2"}`}>{entry.body}</p>
|
||||
)}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="px-3 pb-3 space-y-2">
|
||||
{entry.facts.length > 0 && (
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
|
||||
{entry.facts.map((fact) => (
|
||||
<FactRow key={`${fact.name}:${fact.value}`} name={fact.name} value={fact.value} />
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-gray-500">
|
||||
<span>{entry.type}</span>
|
||||
<span>{entry.scope}</span>
|
||||
<span>{formatDateTime(entry.ts)}</span>
|
||||
</div>
|
||||
{entry.portalUrl && (
|
||||
<a
|
||||
href={entry.portalUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-block text-sm font-medium text-sky-700 dark:text-sky-400 hover:underline"
|
||||
>
|
||||
Open in the portal
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{entries.map((entry) => (
|
||||
<EntryCard
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
open={expanded.id === entry.id}
|
||||
focused={focusId === entry.id}
|
||||
now={now}
|
||||
onToggle={toggle}
|
||||
onActions={setSheet}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{sheet && <EntryActionSheet entry={sheet} onDelete={() => void deleteEntry(sheet)} onClose={() => setSheet(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<li
|
||||
id={`entry-${entry.id}`}
|
||||
className={`bg-white rounded-lg shadow border-l-4 select-none ${SEVERITY_ACCENT[entry.severity]} ${
|
||||
focused ? "ring-2 ring-sky-300 dark:ring-sky-700" : ""
|
||||
}`}
|
||||
style={{ WebkitTouchCallout: "none" }}
|
||||
onPointerDown={press.onPointerDown}
|
||||
onPointerMove={press.onPointerMove}
|
||||
onPointerUp={press.onPointerUp}
|
||||
onPointerCancel={press.onPointerCancel}
|
||||
onPointerLeave={press.onPointerLeave}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault();
|
||||
onActions(entry);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!press.shouldSuppressClick()) onToggle(entry.id);
|
||||
}}
|
||||
aria-expanded={open}
|
||||
className="w-full text-left px-3 py-2.5"
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-sm font-medium text-gray-900 break-words min-w-0">{entry.title}</span>
|
||||
<time dateTime={entry.ts} title={formatDateTime(entry.ts)} className="text-xs text-gray-500 shrink-0">
|
||||
{now ? formatRelativeTime(entry.ts, now) : ""}
|
||||
</time>
|
||||
</div>
|
||||
{entry.body && (
|
||||
<p className={`text-sm text-gray-600 mt-0.5 break-words ${open ? "" : "line-clamp-2"}`}>{entry.body}</p>
|
||||
)}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="px-3 pb-3 space-y-2">
|
||||
{entry.facts.length > 0 && (
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
|
||||
{entry.facts.map((fact) => (
|
||||
<FactRow key={`${fact.name}:${fact.value}`} name={fact.name} value={fact.value} />
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-gray-500">
|
||||
<span>{entry.type}</span>
|
||||
<span>{entry.scope}</span>
|
||||
<span>{formatDateTime(entry.ts)}</span>
|
||||
</div>
|
||||
{entry.portalUrl && (
|
||||
<a
|
||||
href={entry.portalUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(event) => {
|
||||
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
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function FactRow({ name, value }: { name: string; value: string }) {
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -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<ReconcileResult | null>(null);
|
||||
const [persisted, setPersisted] = useState<boolean | null>(null);
|
||||
const [unpair, setUnpair] = useState<UnpairState>({ kind: "idle" });
|
||||
// The stored retention (null: not readable), what is typed (null: show the stored value), the feedback line.
|
||||
const [retention, setRetention] = useState<number | null>(null);
|
||||
const [retentionInput, setRetentionInput] = useState<string | null>(null);
|
||||
const [retentionNote, setRetentionNote] = useState<RetentionNote>({ 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() {
|
||||
<Row name="Paired" value={formatDateTime(device.pairedUtc)} />
|
||||
<Row name="Confirmed" value={formatDateTime(device.confirmedUtc)} />
|
||||
<Row name="Last delivered" value={formatDateTime(device.lastDeliveredUtc)} />
|
||||
<Row
|
||||
name="Server key"
|
||||
value={!device.activeKid || device.activeKid === meta?.[META_KEYS.kid] ? "current" : "outdated – migrates on the next open"}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
@@ -104,12 +152,76 @@ export default function PushStatusPage() {
|
||||
<h2 className="text-base font-semibold text-gray-900">On this device</h2>
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-sm">
|
||||
<Row name="Notifications" value={permission === "unsupported" ? "not supported" : permission} />
|
||||
<Row name="Subscription" value={result.state === "ok" ? (result.subscriptionPresent ? "present" : "missing") : "—"} />
|
||||
<Row
|
||||
name="Subscription"
|
||||
value={result.state === "ok" ? (result.subscriptionPresent ? (result.repairError ? "present, server not updated" : "present") : "missing") : "—"}
|
||||
/>
|
||||
<Row name="Persistent storage" value={persisted === null ? "unknown" : persisted ? "granted" : "not granted"} />
|
||||
</dl>
|
||||
{result.state === "ok" && result.repairError && (
|
||||
<p className="text-xs text-red-700 dark:text-red-400">
|
||||
The subscription could not be registered with the server: {result.repairError} It is retried the next time this app is opened.
|
||||
</p>
|
||||
)}
|
||||
{result.state === "ok" && !result.subscriptionPresent && permission !== "granted" && (
|
||||
<p className="text-xs text-gray-500">Without notification permission the browser holds no push subscription; alerts cannot arrive.</p>
|
||||
)}
|
||||
|
||||
{paired && (
|
||||
<div className="pt-3 border-t border-gray-100 space-y-2">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<h3 className="text-sm font-medium text-gray-900">History retention</h3>
|
||||
<span className="text-sm text-gray-600">{retention === null ? "—" : describeRetention(retention)}</span>
|
||||
</div>
|
||||
<form
|
||||
noValidate
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void saveRetention();
|
||||
}}
|
||||
className="flex items-end gap-2"
|
||||
>
|
||||
<label className="block flex-1 min-w-0">
|
||||
<span className="text-xs font-medium text-gray-700">Days to keep</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
autoComplete="off"
|
||||
value={retentionText}
|
||||
onChange={(e) => {
|
||||
setRetentionInput(e.target.value);
|
||||
setRetentionNote({ kind: "idle" });
|
||||
}}
|
||||
disabled={retentionNote.kind === "working"}
|
||||
aria-describedby={retentionNote.kind === "error" ? "retention-help retention-error" : "retention-help"}
|
||||
aria-invalid={retentionNote.kind === "error"}
|
||||
className="mt-1 w-full px-3 py-2 border border-gray-300 rounded-lg text-gray-900 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={retentionNote.kind === "working"}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{retentionNote.kind === "working" ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</form>
|
||||
<p id="retention-help" className="text-xs text-gray-500">
|
||||
0 keeps entries until the cap of {HISTORY_MAX_ENTRIES}; the newest {HISTORY_MAX_ENTRIES} are kept in any case.
|
||||
</p>
|
||||
{retentionNote.kind === "error" && (
|
||||
<p id="retention-error" className="text-sm text-red-700 dark:text-red-400">
|
||||
{retentionNote.message}
|
||||
</p>
|
||||
)}
|
||||
{retentionNote.kind === "saved" && (
|
||||
<p role="status" className="text-sm text-green-700">
|
||||
Saved.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{paired && (
|
||||
|
||||
@@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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>): ReconcileInput {
|
||||
return {
|
||||
serverStatus: "Active",
|
||||
@@ -16,6 +18,9 @@ function input(overrides: Partial<ReconcileInput>): 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof setTimeout>;
|
||||
}
|
||||
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -158,7 +158,14 @@ export async function unpairDevice(): Promise<void> {
|
||||
export type ReconcileResult =
|
||||
| { state: "unpaired" }
|
||||
| { state: "gone" }
|
||||
| { state: "ok"; device: PushDeviceResponse; meta: Record<string, string>; subscriptionPresent: boolean }
|
||||
| {
|
||||
state: "ok";
|
||||
device: PushDeviceResponse;
|
||||
meta: Record<string, string>;
|
||||
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<string, string> };
|
||||
|
||||
/**
|
||||
@@ -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<ReconcileResult> {
|
||||
const meta = await readMeta().catch(() => ({}) as Record<string, string>);
|
||||
@@ -185,31 +195,48 @@ export async function reconcileDevice(): Promise<ReconcileResult> {
|
||||
}
|
||||
|
||||
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<string, string> = 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<ReconcileResult> {
|
||||
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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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<string, string>} 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<string, string>} */
|
||||
const headers = { Accept: "application/json" };
|
||||
@@ -544,9 +584,9 @@ export function writeMeta(values) {
|
||||
* @returns {Promise<HistoryEntry[]>} 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<boolean>} 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<void>}
|
||||
*/
|
||||
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<void>}
|
||||
*/
|
||||
export function pruneStoredHistory() {
|
||||
return withStores("readwrite", ({ history }) => pruneStore(history));
|
||||
return withStores("readwrite", ({ history, meta }) => pruneStore(history, meta));
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<number>} 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<number>} 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<number>}
|
||||
*/
|
||||
async function retentionDaysFrom(meta) {
|
||||
return normalizeRetentionDays(await requestToPromise(meta.get(META_KEYS.historyRetentionDays)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {IDBObjectStore} history
|
||||
* @param {IDBObjectStore} meta
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
})(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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)."
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user