Push receiver: history filter; the session-watch button hides when watching is impossible

- History page: one dropdown built from what the history holds — Platform (ops alerts, one
  entry per Category fact), Enrollment (one per tenant alert kind) and This device (the
  receiver's own messages) — with counts, a "12 of 48" readout and a clear button while a
  filter is active, hidden while there is only one kind to show. The choice is stored on the
  device (meta key historyFilter); a deep link to a hidden entry shows everything. A
  customer's device holds tenant pushes only, so it never sees a Platform group.
- SessionWatchResponse.Available: GET/DELETE sessions/{id}/watch say whether this person can
  watch at all (an enabled Push channel in one of their own scopes where they hold Admin or
  Operator); the portal's "Notify me when done" renders only then, instead of letting a tenant
  admin click into "enable a Push channel first", which only a Global Admin can do. An active
  watch stays available so it can be switched off.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-10-08 16:38:09 +02:00
parent cd009071db
commit 0cd8baa85d
11 changed files with 430 additions and 12 deletions
@@ -200,6 +200,42 @@ public class PushSessionWatchServiceTests
Assert.Null(status.ExpiresUtc);
}
[Fact]
public async Task Get_says_watching_is_available_when_an_own_scope_has_a_channel_and_the_caller_is_eligible()
{
var h = new Harness();
var status = await h.Sut.GetAsync(HomeTenantId, SessionId, Admin);
Assert.False(status.Watching);
Assert.True(status.Available);
}
[Fact]
public async Task Get_says_watching_is_unavailable_without_a_push_channel_or_without_eligibility()
{
var noChannel = new Harness();
noChannel.Eligibility.Setup(e => e.HasPushChannelAsync(It.IsAny<NotificationScope>())).ReturnsAsync(false);
Assert.False((await noChannel.Sut.GetAsync(HomeTenantId, SessionId, Admin)).Available);
var notEligible = new Harness();
notEligible.Eligibility.Setup(e => e.IsEligibleAsync(It.IsAny<NotificationScope>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(false);
Assert.False((await notEligible.Sut.GetAsync(HomeTenantId, SessionId, Admin)).Available);
}
[Fact]
public async Task Get_keeps_an_active_watch_available_so_it_can_still_be_switched_off()
{
var h = new Harness();
h.AddWatch(HomeTenantId, SessionId, Admin, HomeTenantId);
h.Eligibility.Setup(e => e.HasPushChannelAsync(It.IsAny<NotificationScope>())).ReturnsAsync(false);
var status = await h.Sut.GetAsync(HomeTenantId, SessionId, Admin);
Assert.True(status.Watching);
Assert.True(status.Available);
}
[Fact]
public async Task Unwatch_deletes_the_callers_row_only()
{
@@ -98,20 +98,42 @@ public class PushSessionWatchService : IPushSessionWatchNotifier
ExpiresUtc = now + WatchLifetime,
};
await _repo.UpsertWatchAsync(watch).ConfigureAwait(false);
return PushOpResult<SessionWatchResponse>.Success(new SessionWatchResponse { Watching = true, ExpiresUtc = watch.ExpiresUtc });
return PushOpResult<SessionWatchResponse>.Success(new SessionWatchResponse { Watching = true, ExpiresUtc = watch.ExpiresUtc, Available = true });
}
public async Task<SessionWatchResponse> UnwatchAsync(string tenantId, string sessionId, PushCaller caller)
{
await _repo.DeleteWatchAsync(tenantId, sessionId, caller.ObjectId).ConfigureAwait(false);
return new SessionWatchResponse { Watching = false };
return new SessionWatchResponse { Watching = false, Available = await IsAvailableAsync(caller).ConfigureAwait(false) };
}
public async Task<SessionWatchResponse> GetAsync(string tenantId, string sessionId, PushCaller caller)
{
var watch = await _repo.GetWatchAsync(tenantId, sessionId, caller.ObjectId).ConfigureAwait(false);
var active = watch != null && watch.ExpiresUtc >= DateTime.UtcNow;
return new SessionWatchResponse { Watching = active, ExpiresUtc = active ? watch!.ExpiresUtc : null };
return new SessionWatchResponse
{
Watching = active,
ExpiresUtc = active ? watch!.ExpiresUtc : null,
Available = active || await IsAvailableAsync(caller).ConfigureAwait(false),
};
}
/// <summary>
/// Whether watching is possible for this person at all: an enabled Push channel in one of the
/// caller's own scopes where the caller is eligible. A device is not required here — the PUT
/// says "pair a device first" — so the portal can show the button as soon as the channel exists.
/// </summary>
private async Task<bool> IsAvailableAsync(PushCaller caller)
{
foreach (var scope in OwnScopes(caller))
{
if (!await _eligibility.HasPushChannelAsync(scope).ConfigureAwait(false))
continue;
if (await _eligibility.IsEligibleAsync(scope, caller.Upn, caller.ObjectId, caller.HomeTenantId).ConfigureAwait(false))
return true;
}
return false;
}
/// <summary>Fail-soft: a watch push must never cost the ingest path anything.</summary>
@@ -4507,10 +4507,12 @@ export interface SessionTimeBreakdown {
qualityFlags: TimeAttributionFlags;
}
/** PUT sessions/{sessionId}/watch — "notify my devices when this session ends". */
/** GET/PUT/DELETE sessions/{sessionId}/watch — "notify my devices when this session ends". */
export interface SessionWatchResponse {
watching: boolean;
expiresUtc?: string;
/** Whether this caller can watch at all: an enabled Push channel in one of the caller's own scopes (home tenant; the platform for a Global Admin) where the caller holds an Admin or Operator role. False hides the portal's button instead of letting a click fail. */
available: boolean;
}
/** Response of PATCH global/mcp-users/{upn}/usage-plan: the UPN and the plan now in effect ("(inherit)" when cleared to the tenant default). */
@@ -135,10 +135,16 @@ namespace AutopilotMonitor.Shared.Models
public bool IsOwn { get; set; }
}
/// <summary>PUT sessions/{sessionId}/watch — "notify my devices when this session ends".</summary>
/// <summary>GET/PUT/DELETE sessions/{sessionId}/watch — "notify my devices when this session ends".</summary>
public class SessionWatchResponse : IApiResponse
{
public bool Watching { get; set; }
public DateTime? ExpiresUtc { get; set; }
/// <summary>
/// Whether this caller can watch at all: an enabled Push channel in one of the caller's own
/// scopes (home tenant; the platform for a Global Admin) where the caller holds an Admin or
/// Operator role. False hides the portal's button instead of letting a click fail.
/// </summary>
public bool Available { get; set; }
}
}
@@ -4,6 +4,7 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { useLatest } from "@/hooks/useLatest";
import { ALL_FILTER, applyHistoryFilter, buildFilterOptions, hasFilterOption, isFilterUseful, normalizeFilterKey } from "@/lib/push/historyFilter";
import { sameHistory } from "@/lib/push/historyList";
import { createLongPress } from "@/lib/push/longPress";
import { createPullToRefresh, type PullState } from "@/lib/push/pullToRefresh";
@@ -14,6 +15,8 @@ import {
parseFragment,
pruneStoredHistory,
readHistory,
readMeta,
writeMeta,
type HistoryEntry,
} from "@/lib/push/pushCore";
import { readPairCookie, reconcileDevice, type ReconcileResult } from "@/lib/push/pushClient";
@@ -53,6 +56,7 @@ export default function PushHistoryPage() {
const [now, setNow] = useState(() => 0);
const [sheet, setSheet] = useState<HistoryEntry | null>(null);
const [pull, setPull] = useState<PullState>({ pulling: false, distance: 0, armed: false, refreshing: false });
const [filter, setFilter] = useState(ALL_FILTER);
const target = parseFragment(hash);
const focusId = target?.kind === "entry" ? target.id : null;
@@ -132,6 +136,42 @@ export default function PushHistoryPage() {
);
useEffect(() => () => puller.dispose(), [puller]);
// The filter is a per-device preference, stored next to the retention; a stale key keeps everything.
useEffect(() => {
const run = async () => {
try {
setFilter(normalizeFilterKey((await readMeta())[META_KEYS.historyFilter]));
} catch {
// No IndexedDB: the default "all" stands.
}
};
void run();
}, []);
const chooseFilter = (key: string) => {
setFilter(key);
writeMeta({ [META_KEYS.historyFilter]: key }).catch(() => {});
};
const groups = buildFilterOptions(entries ?? []);
const filterShown = isFilterUseful(groups);
const selected = hasFilterOption(groups, filter) ? filter : ALL_FILTER;
const visible = entries ? applyHistoryFilter(entries, selected, groups) : null;
// A deep link to an entry the filter hides shows everything (adjust-during-render, once per link).
const [filterAdjust, setFilterAdjust] = useState<string | null>(null);
if (
focusId &&
filterAdjust !== focusId &&
entries &&
visible &&
selected !== ALL_FILTER &&
entries.some((e) => e.id === focusId) &&
!visible.some((e) => e.id === focusId)
) {
setFilterAdjust(focusId);
chooseFilter(ALL_FILTER);
}
// Reconcile with the server; an unpaired device holding a Safari hand-off cookie continues to pairing.
useEffect(() => {
let cancelled = false;
@@ -207,6 +247,56 @@ export default function PushHistoryPage() {
</div>
)}
{filterShown && entries && visible && (
<div className="flex items-center gap-2 text-sm">
<label htmlFor="history-filter" className="sr-only">
Filter alerts
</label>
<select
id="history-filter"
value={selected}
onChange={(event) => chooseFilter(event.target.value)}
className="flex-1 min-w-0 bg-white border border-gray-300 rounded-lg px-3 py-2 text-sm text-gray-900"
>
<option value={ALL_FILTER}>All alerts ({entries.length})</option>
{groups.map((group) => (
<optgroup key={group.group} label={group.label}>
{group.options.map((option) => (
<option key={option.key} value={option.key}>
{option.label} ({option.count})
</option>
))}
</optgroup>
))}
</select>
{selected !== ALL_FILTER && (
<>
<span className="text-xs text-gray-500 shrink-0">
{visible.length} of {entries.length}
</span>
<button
type="button"
onClick={() => chooseFilter(ALL_FILTER)}
aria-label="Clear filter"
title="Show all alerts"
className="shrink-0 w-8 h-8 inline-flex items-center justify-center rounded-lg border border-gray-300 bg-white text-gray-600 hover:text-gray-900"
>
×
</button>
</>
)}
</div>
)}
{entries && visible && entries.length > 0 && visible.length === 0 && (
<div className="bg-white rounded-lg shadow p-6 text-center">
<p className="text-sm text-gray-600">No alerts match this filter.</p>
<button type="button" onClick={() => chooseFilter(ALL_FILTER)} className="mt-2 text-sm font-medium text-green-700 hover:underline">
Show all alerts
</button>
</div>
)}
{isClient && entries !== null && entries.length === 0 && (
<div className="bg-white rounded-lg shadow p-6 text-center">
<p className="text-sm text-gray-600">No alerts yet.</p>
@@ -223,9 +313,9 @@ export default function PushHistoryPage() {
</div>
)}
{entries && entries.length > 0 && (
{visible && visible.length > 0 && (
<ul className="space-y-2">
{entries.map((entry) => (
{visible.map((entry) => (
<EntryCard
key={entry.id}
entry={entry}
@@ -23,10 +23,12 @@ interface SessionWatchButtonProps {
* "Notify me when done" (plan push-relay): registers a watch that pushes to the caller's own
* paired devices when this session reaches a terminal status. Hidden once the session is
* terminal; the host gates it to Admins/Operators (and Global Admins) like Report Session.
* Whether the tenant has a Push channel is not known cheaply here, so the PUT's 409 explains.
* The GET says whether watching is possible for this person at all (a Push channel in one of
* their own scopes); without that the button stays hidden instead of letting a click fail.
*/
export default function SessionWatchButton({ sessionId, effectiveTenantId, sessionStatus, getAccessToken, addNotification }: SessionWatchButtonProps) {
const [watching, setWatching] = useState<boolean | null>(null);
const [available, setAvailable] = useState(true);
const [busy, setBusy] = useState(false);
const active = sessionId.length > 0 && !!sessionStatus && !TERMINAL_STATUSES.has(sessionStatus);
@@ -37,7 +39,9 @@ export default function SessionWatchButton({ sessionId, effectiveTenantId, sessi
const run = async () => {
try {
const result = await getSessionWatch(sessionId, effectiveTenantId, getAccessToken);
if (!cancelled) setWatching(result.watching);
if (cancelled) return;
setWatching(result.watching);
setAvailable(result.available !== false);
} catch {
// Best effort: an unknown state renders as "not watching"; the click reports the real error.
if (!cancelled) setWatching(false);
@@ -49,7 +53,7 @@ export default function SessionWatchButton({ sessionId, effectiveTenantId, sessi
};
}, [active, sessionId, effectiveTenantId, getAccessToken]);
if (!active) return null;
if (!active || !available) return null;
const toggle = async () => {
if (busy || watching === null) return;
@@ -0,0 +1,104 @@
import { describe, expect, it } from "vitest";
import {
ALL_FILTER,
applyHistoryFilter,
buildFilterOptions,
classifyEntry,
hasFilterOption,
isFilterUseful,
normalizeFilterKey,
} from "../push/historyFilter";
import type { HistoryEntry } from "../push/pushCore";
const entry = (id: string, type: string, scope = "tenant", facts: { name: string; value: string }[] = []): HistoryEntry => ({
id,
ts: "2026-10-08T12:00:00.000Z",
title: "t",
body: "b",
type,
severity: "info",
facts,
portalUrl: null,
navigate: null,
tag: null,
scope,
generic: false,
});
const history: HistoryEntry[] = [
entry("1", "enrollment_failed"),
entry("2", "enrollment_failed"),
entry("3", "sla_breach"),
entry("4", "whats_new"),
entry("5", "TenantSignup", "platform", [{ name: "Category", value: "Tenant" }]),
entry("6", "CertRotation", "platform", [{ name: "category", value: "Security" }]),
entry("7", "test", "platform"),
entry("8", "push_test"),
entry("9", "push_paired"),
entry("10", "session_watch"),
];
describe("classifyEntry", () => {
it("puts the receiver's own messages, platform alerts (by Category fact) and tenant alerts into their groups", () => {
expect(classifyEntry(entry("a", "push_paused"))).toEqual({ group: "device", kind: "push_paused", label: "Paused" });
expect(classifyEntry(entry("b", "X", "platform", [{ name: "Category", value: "Consent" }]))).toEqual({ group: "platform", kind: "consent", label: "Consent" });
expect(classifyEntry(entry("c", "test", "platform"))).toEqual({ group: "platform", kind: "other", label: "Other" });
expect(classifyEntry(entry("d", "enrollment_succeeded"))).toEqual({ group: "enrollment", kind: "enrollment_succeeded", label: "Enrollment succeeded" });
expect(classifyEntry(entry("e", "some_new_kind"))).toEqual({ group: "enrollment", kind: "some_new_kind", label: "Some new kind" });
});
});
describe("buildFilterOptions", () => {
it("offers only the groups and kinds the history holds, ordered Platform / Enrollment / This device, with counts", () => {
const groups = buildFilterOptions(history);
expect(groups.map((g) => g.label)).toEqual(["Platform", "Enrollment", "This device"]);
expect(groups[0].options.map((o) => [o.label, o.count])).toEqual([
["Other", 1],
["Security", 1],
["Tenant", 1],
]);
// Locale order: "Session watch" sorts before "SLA breach" (letters compare before case).
expect(groups[1].options.map((o) => [o.label, o.count])).toEqual([
["Enrollment failed", 2],
["Session watch", 1],
["SLA breach", 1],
["What's new", 1],
]);
expect(groups[2].options.map((o) => o.key)).toEqual(["device:push_paired", "device:push_test"]);
});
it("has no Platform group for a customer's device and hides the dropdown for a single kind", () => {
const tenantOnly = buildFilterOptions([entry("1", "enrollment_failed"), entry("2", "sla_breach")]);
expect(tenantOnly.map((g) => g.group)).toEqual(["enrollment"]);
expect(isFilterUseful(tenantOnly)).toBe(true);
expect(isFilterUseful(buildFilterOptions([entry("1", "enrollment_failed"), entry("2", "enrollment_failed")]))).toBe(false);
expect(isFilterUseful(buildFilterOptions([]))).toBe(false);
});
});
describe("applyHistoryFilter", () => {
it("keeps the matching entries for a key, everything for all, and everything for a key the history no longer has", () => {
expect(applyHistoryFilter(history, "enrollment:enrollment_failed").map((e) => e.id)).toEqual(["1", "2"]);
expect(applyHistoryFilter(history, "platform:tenant").map((e) => e.id)).toEqual(["5"]);
expect(applyHistoryFilter(history, "device:push_test").map((e) => e.id)).toEqual(["8"]);
expect(applyHistoryFilter(history, ALL_FILTER)).toBe(history);
expect(applyHistoryFilter(history, "platform:security-gone")).toBe(history);
});
it("knows which keys the current history offers", () => {
const groups = buildFilterOptions(history);
expect(hasFilterOption(groups, ALL_FILTER)).toBe(true);
expect(hasFilterOption(groups, "platform:security")).toBe(true);
expect(hasFilterOption(groups, "platform:consent")).toBe(false);
});
});
describe("normalizeFilterKey", () => {
it("accepts well-formed stored keys and maps everything else to all", () => {
expect(normalizeFilterKey("enrollment:sla_breach")).toBe("enrollment:sla_breach");
expect(normalizeFilterKey(" platform:tenant ")).toBe("platform:tenant");
for (const raw of [undefined, null, "", "all", "bogus", "platform:", "other:x", "platform:a:b", 7]) {
expect(normalizeFilterKey(raw), String(raw)).toBe(ALL_FILTER);
}
});
});
@@ -0,0 +1,142 @@
import type { HistoryEntry } from "./pushCore";
/**
* The history page's filter: one dropdown whose entries come from what the history holds, so a
* customer's device (tenant pushes only) never sees a "Platform" group and an empty category is
* never offered. Three groups: Platform (ops alerts, one entry per Category fact), Enrollment
* (the tenant alerts, one entry per alert kind) and This device (the receiver's own messages).
* Pure: the page applies the result and stores the chosen key on the device.
*/
export type FilterGroup = "platform" | "enrollment" | "device";
/** The "show everything" key; any other key is `${group}:${kind}`. */
export const ALL_FILTER = "all";
export interface FilterOption {
key: string;
label: string;
count: number;
}
export interface FilterGroupOptions {
group: FilterGroup;
label: string;
options: FilterOption[];
}
const GROUP_ORDER: FilterGroup[] = ["platform", "enrollment", "device"];
const GROUP_LABELS: Record<FilterGroup, string> = {
platform: "Platform",
enrollment: "Enrollment",
device: "This device",
};
/** Tenant alert kinds as the backend names them (NotificationAlert.EventType). */
const ENROLLMENT_LABELS: Record<string, string> = {
enrollment_started: "Enrollment started",
enrollment_succeeded: "Enrollment succeeded",
enrollment_failed: "Enrollment failed",
enrollment_complete: "Enrollment complete",
enrollment_end: "Enrollment ended",
session_stalled: "Session stalled",
session_watch: "Session watch",
consecutive_failures: "Consecutive failures",
hardware_rejected: "Hardware rejected",
sla_breach: "SLA breach",
sla_resolved: "SLA resolved",
analyze_rule_fired: "Rule findings",
whats_new: "What's new",
test: "Test",
};
/** The receiver's own messages (PushAlertProjector.SystemMessage types). */
const DEVICE_LABELS: Record<string, string> = {
push_paired: "Paired",
push_paused: "Paused",
push_muted: "Muted",
push_revoked: "Unpaired",
push_test: "Test",
};
const DEVICE_TYPE_PREFIX = "push_";
export interface EntryClass {
group: FilterGroup;
kind: string;
label: string;
}
function humanize(value: string): string {
const words = value.replace(/[_-]+/g, " ").trim();
return words === "" ? "Other" : words.charAt(0).toUpperCase() + words.slice(1);
}
function categoryOf(entry: HistoryEntry): string {
const fact = entry.facts.find((f) => f.name.toLowerCase() === "category");
return fact ? fact.value.trim() : "";
}
export function classifyEntry(entry: HistoryEntry): EntryClass {
if (entry.type.startsWith(DEVICE_TYPE_PREFIX)) {
return { group: "device", kind: entry.type, label: DEVICE_LABELS[entry.type] ?? humanize(entry.type.slice(DEVICE_TYPE_PREFIX.length)) };
}
if (entry.scope === "platform") {
const category = categoryOf(entry);
return { group: "platform", kind: category === "" ? "other" : category.toLowerCase(), label: category === "" ? "Other" : category };
}
return { group: "enrollment", kind: entry.type, label: ENROLLMENT_LABELS[entry.type] ?? humanize(entry.type) };
}
export function filterKeyOf(group: FilterGroup, kind: string): string {
return `${group}:${kind}`;
}
/** The groups and options the dropdown offers for this history, non-empty ones only, counts included. */
export function buildFilterOptions(entries: HistoryEntry[]): FilterGroupOptions[] {
const byGroup = new Map<FilterGroup, Map<string, FilterOption>>();
for (const entry of entries) {
const c = classifyEntry(entry);
const key = filterKeyOf(c.group, c.kind);
let options = byGroup.get(c.group);
if (!options) {
options = new Map();
byGroup.set(c.group, options);
}
const existing = options.get(key);
if (existing) existing.count += 1;
else options.set(key, { key, label: c.label, count: 1 });
}
return GROUP_ORDER.filter((group) => byGroup.has(group)).map((group) => ({
group,
label: GROUP_LABELS[group],
options: [...byGroup.get(group)!.values()].sort((a, b) => a.label.localeCompare(b.label)),
}));
}
export function hasFilterOption(groups: FilterGroupOptions[], key: string): boolean {
return key === ALL_FILTER || groups.some((g) => g.options.some((o) => o.key === key));
}
/** More than one option across the groups — otherwise there is nothing to filter and the dropdown stays hidden. */
export function isFilterUseful(groups: FilterGroupOptions[]): boolean {
return groups.reduce((n, g) => n + g.options.length, 0) > 1;
}
/** The entries a key keeps; a key the history has no option for (stale preference) keeps everything. */
export function applyHistoryFilter(entries: HistoryEntry[], key: string, groups = buildFilterOptions(entries)): HistoryEntry[] {
if (key === ALL_FILTER || !hasFilterOption(groups, key)) return entries;
return entries.filter((entry) => {
const c = classifyEntry(entry);
return filterKeyOf(c.group, c.kind) === key;
});
}
/** A stored preference, or "all" for anything that is not a well-formed key. */
export function normalizeFilterKey(raw: unknown): string {
if (typeof raw !== "string") return ALL_FILTER;
const value = raw.trim();
if (value === ALL_FILTER) return ALL_FILTER;
return /^(platform|enrollment|device):[^\s:]{1,64}$/.test(value) ? value : ALL_FILTER;
}
@@ -64,6 +64,8 @@ export const META_KEYS = /** @type {const} */ ({
apiBaseUrl: "apiBaseUrl",
/** Days the history is kept (string of an integer 0–365; 0 = until the entry cap; absent = default). */
historyRetentionDays: "historyRetentionDays",
/** The history page's chosen filter key ("all" or "<group>:<kind>"); absent = all. */
historyFilter: "historyFilter",
});
/** @typedef {(typeof PLATFORMS)[number]} Platform */
@@ -20784,7 +20784,7 @@
},
"SessionWatchResponse": {
"kind": "object",
"doc": "PUT sessions/{sessionId}/watch — \"notify my devices when this session ends\".",
"doc": "GET/PUT/DELETE sessions/{sessionId}/watch — \"notify my devices when this session ends\".",
"fields": [
{
"name": "watching",
@@ -20800,6 +20800,14 @@
"name": "string"
},
"optional": true
},
{
"name": "available",
"type": {
"kind": "primitive",
"name": "boolean"
},
"doc": "Whether this caller can watch at all: an enabled Push channel in one of the caller's own scopes (home tenant; the platform for a Global Admin) where the caller holds an Admin or Operator role. False hides the portal's button instead of letting a click fail."
}
]
},
@@ -4507,10 +4507,12 @@ export interface SessionTimeBreakdown {
qualityFlags: TimeAttributionFlags;
}
/** PUT sessions/{sessionId}/watch — "notify my devices when this session ends". */
/** GET/PUT/DELETE sessions/{sessionId}/watch — "notify my devices when this session ends". */
export interface SessionWatchResponse {
watching: boolean;
expiresUtc?: string;
/** Whether this caller can watch at all: an enabled Push channel in one of the caller's own scopes (home tenant; the platform for a Global Admin) where the caller holds an Admin or Operator role. False hides the portal's button instead of letting a click fail. */
available: boolean;
}
/** Response of PATCH global/mcp-users/{upn}/usage-plan: the UPN and the plan now in effect ("(inherit)" when cleared to the tenant default). */