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:
Oliver Kieselbach
2026-10-08 15:45:33 +02:00
committed by GitHub
parent d378fabc01
commit 2517e8eb28
22 changed files with 1130 additions and 108 deletions
+1 -1
Submodule infra updated: d8111089f4...f93e475b78
@@ -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>
);
}
+132 -65
View File
@@ -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. */