Compare commits

...

29 Commits

Author SHA1 Message Date
Oliver Kieselbach 93de130cd1 feat(diagnostics): built-in collection catalog visible in the portal, RealmJoin + Device Preparation sections gated
The diagnostics package's built-in collection is now ONE catalog in Shared
(DiagnosticsBuiltInSections.All): the agent iterates it when building the ZIP and
the backend serves the same list to the portal (GET /api/diagnostics/paths,
MemberRead), so what administrators see is exactly what the agent collects.

Agent
- New conditional sections: RealmJoin client/package/Chocolatey/per-user logs
  behind the tenant's RealmJoin Watcher toggle (mirrored onto
  AgentConfiguration.EnableRealmJoinWatcher by RemoteConfigMerger) and the Device
  Preparation bootstrapper event log behind the deterministic WDP marker (probe
  once per build, fail toward Classic). ZIP layout mirrors the disk
  (RealmJoinLogs/Windows|Choco|User). Every skip is a package-manifest line.
- Built-ins bypass DiagnosticsPathGuards by design (reviewed code); caps,
  reparse-point skip and evtx export still apply. No ConfigVersion bump.

Backend
- GET diagnostics/paths (MemberRead, JWT-scoped) with the catalog + global paths.
- MergeDiagnosticsLogPaths: global ∪ tenant dedup (trimmed, case-insensitive) —
  duplicates used to produce duplicate ZIP entry names.

Web
- Collapsed read-only built-in list (RealmJoin on/off in the tenant header,
  neutral in the Global-Admin view), one-line DiagnosticsPathRow for every entry,
  add-row above the lists; tenant context no longer fetches /api/global/config.

Tests: agent RealmJoin on/off, no user session, bootstrapper evtx, probe failure,
merger mirror; backend catalog invariants, payload shape, dedup, policy; web
builtInSectionDisplay. Docs: docs/agent/diagnostics-package.md (+index/log).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 04:09:50 +02:00
Oliver Kieselbach b8b95c7e76 refactor(web): move RealmJoin Watcher and Keep Awake from Agent Analyzers to Agent Settings
Both toggles gate collector hosts / device behavior, not IAgentAnalyzers, and
tenants looked for them under Agent Settings. RealmJoin Watcher now sits in the
Agent Collectors card, Keep Awake During User-ESP in Agent Parameters; OOBE
console detection stays with the security analyzers. Section ownership in
SECTION_FIELD_MAP and the per-section reset handlers follow, the Agent Settings
Save/Reset bar moves below both cards it saves, and the Analyzers sidebar
description names all four checks. Wire contract (AnalyzerConfiguration) and
agent are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 04:06:39 +02:00
Oliver Kieselbach e57e081cec docs(web): Discord in notification-provider copy on about, privacy, settings and rule card
Privacy page last-updated bumped to 23 August 2026.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 02:47:52 +02:00
Oliver Kieselbach 3a60186824 revert(email): drop the gradient slant row and corner cell — solid fallbacks made non-gradient clients worse
Back to the 838da4cc header: one facet gradient, three-row brand mark, 113px
band, clip-path edge for clients that support it. Clients without gradient
support get a flat band instead of band-coloured blocks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 02:30:40 +02:00
Oliver Kieselbach c35595503f feat(email): header slant and light corner facet without clip-path — gradients Outlook can draw
Row 1 keeps the single facet gradient and adds a top-right cell with its own
dark/light split (the corner facet); row 2 draws the slanted bottom edge as a
green/white gradient in three band-coloured cells. Pixel-measured against the
reference render: band boundaries identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 02:22:44 +02:00
Oliver Kieselbach 838da4cc8a fix(email): single Outlook-safe facet gradient, no bgcolor inside the brand mark
Outlook web/iOS render exactly one linear-gradient with hex colours and % stops
(multiple layers / rgba stops are dropped), and dark mode inverts cell
backgrounds but not border colours — the white cell in the mark became a black
bar. Facets are now one gradient (top-right light corner dropped), the mark's
middle row is a white-bordered div.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 02:11:17 +02:00
Oliver Kieselbach a9937e0f5d fix(email): brand mark as three non-overlapping rows — Outlook ignores negative margins
Triangle / white cell + triangle / triangle, no overlays, no transparent td
borders; matches the reference mark in a side-by-side zoom render.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 02:02:14 +02:00
Oliver Kieselbach 1dc2cc9a54 fix(email): header rebuilt 1:1 from the approved banner — faceted gradients and four-edge brand mark
Verified side-by-side against the reference render (scratch header-render.html):
three layered half-plane gradients reproduce the light/dark facets, the mark is
a CSS trapezoid plus two green wedges for the slanted right edge and the
descending bottom edge. Still no images in the mail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 01:44:26 +02:00
Oliver Kieselbach dd7ae299a8 fix(email): header restored to the approved rendering (e1de5a89 geometry)
112deg stripe gradient, 13/20/2 border-drawn mark, clip-path edge — the version
the user signed off on; later 'improvements' drifted from it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 01:23:22 +02:00
Oliver Kieselbach d96d10ccec fix(email): header matches the reference banner — mark 12/21/0 trapezoid, stripes thin/wide/thin in the proven 8-stop gradient form
Verified against the original SVG mark in a Chromium render; the gradient keeps
the exact stop count that rendered in Outlook earlier.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 01:05:12 +02:00
Oliver Kieselbach 457fff65f5 fix(email): brand mark back to the div border trick (Outlook paints transparent td borders white)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 00:56:09 +02:00
Oliver Kieselbach dc45694611 docs(okf): email template overrides and image-free mail header
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 00:50:41 +02:00
Oliver Kieselbach 7569061983 feat(admin): editable email templates — preview, test send and HTML override in Settings
- EmailTemplateService: override-or-built-in resolution per send (PreviewConfig
  table, partition EmailTemplates, 5-min cache, {{domainName}} placeholder,
  30k limit); send path fail-soft, admin read path strict
- GA endpoints global/email-templates/{kind} (GET/PUT/DELETE) and /test, which
  mails the effective template or an unsaved draft to the caller's tenant
  contact address
- portal: Settings → Email Templates with preview frame, test send, HTML editor
- mail header: brand mark drawn with CSS borders, no images left in the mails

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 00:50:10 +02:00
Oliver Kieselbach 0f5e36db94 fix(email): exact brand mark as PNG again, stripes direction and widths match the reference
The CSS border trapezoid did not reproduce the mark's shape across clients; the
mark is brand identity, so it is the one image in the mail (empty alt). Stripes
now run top-left to bottom-right, thin/wide/thin as in the reference banner.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 00:40:15 +02:00
Oliver Kieselbach e1de5a8971 feat(email): image-free header — CSS stripes, slanted edge and border-drawn brand mark
Progressive enhancement: solid bgcolor band + text wordmark everywhere; the
brand mark is a CSS border trapezoid; diagonal stripes (linear-gradient) and the
slanted bottom edge (clip-path) render where supported and degrade to a flat
band elsewhere. No remote images left in either mail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 00:22:26 +02:00
Oliver Kieselbach a2d93e884c fix(email): native HTML header that survives blocked images
Solid bgcolor band with the wordmark as real text; only the 36px brand mark is
an image (empty alt). Replaces the full-width header PNG, which collapsed to a
bare green bar on clients that block remote images (iOS Mail).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 00:15:45 +02:00
Oliver Kieselbach 425229deee feat(email): branded header image, sign-in link, drop preview banner in welcome/farewell mails
- header is a hosted PNG (www/email/header.png, rendered from the brand mark +
  wordmark) on a solid bgcolor band; mail clients drop CSS gradients, which left
  the old header white
- welcome mail links "sign in" to the portal, active-development banner removed
- em dashes replaced with en dashes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 23:55:53 +02:00
Oliver Kieselbach 8675e7ee06 feat(backend): provider-neutral EmailService on Mailchimp Transactional (Mandrill), replaces Resend
- EmailService + IEmailService: Mandrill messages/send via typed HttpClient with the
  Notification resilience policy; provider confined to one transport method and the
  Email:* settings (ApiKey, Endpoint, FromAddress, FromName). Resend NuGet dropped.
- open/click tracking explicitly off; sent/queued/scheduled = ok, rejected/invalid = warn
- callers (TenantApprovalService, PreviewWhitelistFunction) depend on IEmailService
- EmailServiceTests pin wire format, result semantics and fail-soft contract
- privacy page + OKF welcome-mail doc updated for the new sub-processor

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 23:26:32 +02:00
Oliver Kieselbach e857e99436 fix(web): list Generic JSON provider last in channel provider dropdown
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 21:54:34 +02:00
Oliver Kieselbach 6435d5f290 feat(notifications): Discord webhook provider + HMAC signing for generic webhooks
Discord (provider 30) renders alerts as a single embed with Discord's
field/length caps; actions become markdown links since webhooks cannot
post buttons. Generic JSON channels gain an optional per-channel signing
secret: requests carry X-AutopilotMonitor-Timestamp and
X-AutopilotMonitor-Signature (sha256 hex HMAC over "{timestamp}.{body}"),
computed over the exact posted JSON with same-named custom headers
stripped first. Secret is redacted for readers and restored by channel
id on save; validation enforces 16-128 printable ASCII characters.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 21:09:12 +02:00
Oliver Kieselbach 445ce4e75b feat(backend): 30-day retention grace period on Pro downgrade
Retention was the only enforcement point that destroyed state on a
Pro -> Community downgrade: the 12-hourly sweep clamps to the Community
cap at read time and would immediately start hard-deleting the
90-365-day band (trial expiry identical, silently).

New policy: for 30 days after losing Pro
(FeatureEntitlementCatalog.RetentionDowngradeGraceDays) the Pro cap
keeps applying. Anchor model: ProDowngradedUtc (backend-only field,
patch-denied + revert-protected) stamped/cleared by the plan endpoint's
new pure ApplyPlanChanges core on EFFECTIVE-edition transitions (covers
explicitly ended trials; a planTier downgrade under an active trial
deliberately does not stamp); trial expiry anchors read-time via
TrialExpiresUtc. days<=0 (infinite) stays untouched.

Ops visibility: TenantPlanDowngraded (PATCH handler, new OpsEventService
dep) plus TenantRetentionGraceExpiring/Ended from the daily
TrialExpirySweep with the same stateless window mechanics, emitted only
when stored retention exceeds the Community cap; all dual-registered in
the web OPS_EVENT_TYPES catalog. Fanout now uses its _utcNow seam
instead of inline DateTime.UtcNow. OKF doc
backend/plan-downgrade-retention-grace.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 21:00:53 +02:00
Oliver Kieselbach 4e54ffe4b4 feat(web): shared plan cards, public /plans and /buy pages
Community/Pro comparison cards extracted into a shared module
(components/plans: planData.ts as single copy source, PlanCards.tsx with
marketing/portal surfaces) so the portal Plan section and the new public
/plans page can never drift. Pro price is one constant (null renders
"Pricing - announced soon").

New public pages following the /help pattern: /plans (free-first
messaging, nav item "Plans") and /buy (direct purchase + Microsoft
Marketplace as coming-soon channels). Landing nav, footer, FinalCta and
get-started now link to /plans instead of the docs plans page; portal
Plan section links to /buy via absolute www URL. Purple utilities
neutralized inside .dark .landing-v2; sitemap/robots/lastmod cover
/plans, /buy and the previously missing /help.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 19:46:44 +02:00
Oliver Kieselbach 65d45a616e feat(web): Community Edition tag under navbar wordmark
Community tenants see a small "Community Edition" line under the brand
wordmark as a standing reminder (and nudge toward upgrade options); Pro
tenants (incl. trial) keep clean chrome. Label renders only after the
feature-flags fetch confirms the edition - no flash for Pro while loading.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 19:38:19 +02:00
Oliver Kieselbach 4aa1f07fbb feat(web): green welcome accent + session-dismissable dev banner with telemetry
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 19:17:53 +02:00
Oliver Kieselbach ede616444a fix(web): honest skip/uninstall/pending semantics in Download/Install Progress
IME reports skips and uninstall enforcements through the same completed
events, so both panels counted them as successful installs/downloads and
their pills contradicted the app_tracking_summary headers (session 502274b4:
"2 of 8 installed" vs "6 completed").

- Install fold: app_install_completed with state Skipped -> Skipped;
  successful uninstall intent -> new terminal state Uninstalled with its own
  pill and neutral row badge; completed now counts only real installs.
- Download fold: rows are classified by observed byte activity, never by
  intent (Win32 uninstalls re-download the full package and stay visible
  with an Uninstall badge); 0-byte rows sit behind a "no download" toggle.
- Both panels render tracker-known pending apps (summary pendingNames)
  behind a "pending" toggle, so every "X of Y" header fully adds up.
- Headers wrap on narrow screens (flex-wrap + gap instead of space-x).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 18:42:23 +02:00
Oliver Kieselbach 61a359ee54 feat(web): public /help page for support channels
Adds www.autopilotmonitor.com/help as a mail-linkable page describing how
to get help: Report Session in the portal for session-specific problems,
GitHub Issues for everything else, LinkedIn for private contact, plus
self-service links (docs, FAQ, announcements, changelog) and the private
security-advisory path. Registered as public path in hostRouting (guard
test pinned) and linked from the site footer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 18:12:09 +02:00
Oliver Kieselbach babc89c776 fix(backend): park silent WhiteGlove Part-2 sessions as AwaitingUser instead of Stalled
fairstone.ca analysis 2026-08-21: after the reseal-reboot technicians routinely
power devices off at the logon screen to box them. Both silence paths (2h
maintenance sweep, agent stall probe) then labeled the expected parking state
"Stalled - Agent silent", and stage 2 later reconciled it to Succeeded on the
Part-1 whiteglove_complete with a reason claiming Account Setup completed.

- EnrollmentTimeoutClassifier: shared pure gate IsWhiteGloveAwaitingUser
  (pre-provisioned + ResumedAt + Device Setup provisioned + zero user evidence
  since resume) with one canonical customer-facing reason; new rollup field
  HasEnrollmentComplete separates real completions from Part-1 whiteglove_complete
- ClassifyTimedOutSession: gated sessions are AwaitingUser within grace and
  reconcile past grace with an honest WhiteGlove reason; on resumed sessions
  whiteglove_complete no longer counts as terminal-complete evidence (rule 2),
  so partial user progress falls through to the honest AwaitingUser/Incomplete rules
- MaintenanceService stage 1 + session_stalled ingest mapping apply the same
  gate (fail-soft to Stalled); sweep audit now carries SessionsAwaitingUser
- Callsites (stage 2, max-lifetime verdict, late-telemetry reconcile, legacy
  reclassification) pass IsPreProvisioned/ResumedAt through

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 22:30:29 +02:00
Oliver Kieselbach a2f448cad0 fix(web): day-scale duration formatting in network timeline
fmtDuration rolls hours into days above 24h ("768d 8h" instead of
"18440h 58m") - minutes are noise at day scale. Surfaced by the first
fleet session with a CMOS clock years off (0bd4af1c, 2024 -> 2026 step);
also keeps the slanted clock-marker label inside its headroom. Test
pinned on the real fleet delta.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 21:05:04 +02:00
Oliver Kieselbach f15e115dea fix(sessions): backfilled system-timeline events no longer anchor StartedAt/duration
Field case d0c5b672: a pre-agent system_clock_changed event (backfilled from
the Windows event log with its original timestamp, ~14min before the first
real activity) became the partition's earliest row and re-anchored the
session's StartedAt, inflating the duration toward pre-enrollment OOBE idle
time. With the watcher's 24h backfill lookback, the 2h backward-shift guard
only caps this effect, it does not prevent it.

SystemTimelineWatcher rows (clock steps, sleep episodes) are environment
observation, not enrollment activity, and are now excluded from all three
anchor derivations: the Events-table earliest-row probe (new
IsSessionAnchorEligible, probe scans past ineligible rows), the ingest
batch's EarliestEventTimestamp fold, and the web's client-side
enrollment-duration derivation. The events themselves stay on the timeline
unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 20:18:57 +02:00
139 changed files with 6393 additions and 1145 deletions
+147
View File
@@ -0,0 +1,147 @@
---
type: concept
title: Diagnostics Package
description: What the agent's diagnostics ZIP contains and why — the built-in section catalog shared with the portal, the RealmJoin and Device Preparation collection gates, the configured-path guards, caps, and the package manifest.
resource: agent
tags: [diagnostics, realmjoin, device-preparation, guardrails, portal]
timestamp: 2026-08-23
---
# Schema
The diagnostics package is the agent's last-resort support artifact: a ZIP built in memory
by `DiagnosticsPackageService.BuildArchiveBytes` and uploaded through a short-lived SAS
URL at the end of a session, at the WhiteGlove seal (`Always` mode) or on a server-requested
on-demand collection ([server actions](../backend/server-actions-on-demand-diagnostics.md)).
## Archive layout
```
sessioninfo.txt session/tenant/device/outcome, hardware via WMI
<built-in sections> catalog order, see below
AdditionalLogs/<last folder>/ admin-configured paths (global ∪ tenant)
package-manifest.txt every packaging decision (always written)
_TRUNCATED.txt only when a cap skipped something
```
## The built-in catalog is the single source of truth
Everything the package collects **before** any configured path is data, not code paths:
`DiagnosticsBuiltInSections.All` in Shared (`Models/Diagnostics/DiagnosticsBuiltInSections.cs`).
The agent iterates it in order; the backend serves the same list to the portal
(`GET /api/diagnostics/paths`, MemberRead) so what administrators see is exactly what the
agent does. There is no second copy to drift.
| Id | ZIP folder | Source (unexpanded) | Patterns | Recursive | Condition |
| --- | --- | --- | --- | --- | --- |
| AgentLogs | `AgentLogs` | `%ProgramData%\AutopilotMonitor\Logs` | log patterns | no | Always |
| ImeLogs | `ImeLogs` | `%ProgramData%\Microsoft\IntuneManagementExtension\Logs` | log patterns | no | Always |
| ImeBootstrapperEventLog | `ImeLogs` | `C:\Windows\System32\winevt\Logs` | `BootstrapperAgentServiceLogProvider.evtx` | no | DevicePreparation |
| AgentState | `AgentState` | `%ProgramData%\AutopilotMonitor\State` | log patterns + `*.complete`, `*.marker` | yes | Always |
| AgentSpool | `AgentSpool` | `%ProgramData%\AutopilotMonitor\Spool` | `*.jsonl`, `*.json` | no | Always |
| AgentMarkers | `AgentMarkers` | `%ProgramData%\AutopilotMonitor` | `*.complete`, `*.marker` | no | Always |
| RealmJoinWindows | `RealmJoinLogs/Windows` | `C:\Windows\Logs` | `realmjoin*.log` | no | RealmJoinWatcher |
| RealmJoinPackages | `RealmJoinLogs/Windows/RealmJoin` | `C:\Windows\Logs\RealmJoin` | `*.log` | yes | RealmJoinWatcher |
| RealmJoinChoco | `RealmJoinLogs/Choco` | `%ProgramData%\RealmJoin\choco\logs` | `*.log` | yes | RealmJoinWatcher |
| RealmJoinUserTray | `RealmJoinLogs/User` | `%LOGGED_ON_USER_PROFILE%\AppData\Local\RealmJoin` | `tray*.log` | no | RealmJoinWatcher |
| RealmJoinUserLogs | `RealmJoinLogs/User/Logs` | `%LOGGED_ON_USER_PROFILE%\AppData\Local\RealmJoin\Logs` | `*.log` | yes | RealmJoinWatcher |
"Log patterns" = `*.log *.txt *.json *.jsonl *.etl *.evtx *.xml *.csv *.cab`. The five
`Always` sections are the historical layout (PR1-B forensics) and are lock-tested in order;
the RealmJoin ZIP layout mirrors the disk (`C:\Windows\Logs\{realmjoin*.log, RealmJoin\…}` →
`RealmJoinLogs/Windows/…`, `%LOCALAPPDATA%\RealmJoin\{tray*.log, Logs\…}` →
`RealmJoinLogs/User/…`), so a flat section and a tree section never produce the same entry
name. Two sections may share a ZIP folder only when they read different source folders
(`ImeLogs` + the bootstrapper evtx) — `(ZipFolder, SourceFolder)` pairs are unique.
### Conditions
`DiagnosticsSectionCondition` is evaluated by the agent (`IsSectionActive`, pure) against its
effective configuration and the enrollment scenario; unknown values fail closed.
* **RealmJoinWatcher** — the tenant's RealmJoin Watcher toggle
(`AnalyzerConfiguration.EnableRealmJoinWatcher`). The package service only sees
`AgentConfiguration`, so `RemoteConfigMerger` mirrors that one Analyzers knob onto
`AgentConfiguration.EnableRealmJoinWatcher` (the RealmJoin host keeps reading the remote
block). Deliberately NOT additionally gated on the compile-time `RealmJoinTrackingEnabled`
kill switch: if the live watcher is ever disabled, the logs become more useful, not less.
No ConfigVersion bump — no new wire field.
* **DevicePreparation** — `EnrollmentRegistryDetector.IsDeterministicDevicePreparation()`,
the same deterministic `DevicePreparation\BootstrapperAgent\ExecutionContext` marker every
other WDP gate keys on ([WDP scenario gates](wdp-scenario-gates.md)); CloudAssigned*
fallback rules count as Classic. The probe is a registry read, evaluated once per build
(`SCENARIO: devicePreparation=… realmJoinWatcher=…` in the manifest); a probe failure is
treated as Classic and logged — the evtx export is skipped, never the package. The channel
is exclusively locked while active, so the existing `wevtutil epl` export path
(`ResolveChannelForEvtxFile`, field case 2026-08-17) handles it.
Inactive sections are never silent: `BUILT-IN SKIPPED (RealmJoin Watcher disabled): <Id>`,
`BUILT-IN SKIPPED (not a Device Preparation enrollment): <Id>`,
`BUILT-IN SKIPPED (no user session for token): <Id> '<source>'` (the
`%LOGGED_ON_USER_PROFILE%` token resolves via `UserProfileResolver` only once an
interactive user exists — identical to configured paths). Active ones log
`BUILT-IN: <Id> -> folder='…' zip='…' recursive=…` followed by the per-pattern
`ADDED / NO MATCH / FOLDER MISSING` lines.
## Built-ins bypass the configured-path guards — on purpose
`DiagnosticsPathGuards` ([gather-rule guardrails](../rules/gather-rule-guardrails.md))
validates **admin-typed** paths: allowlisted prefixes, wildcard only in the last segment,
`C:\Users` blocked except the token's `AppData\Local|Roaming`. The catalog is reviewed code,
exactly like the five sections that never went through the guard before — and
`%ProgramData%\RealmJoin` is not on the allowlist at all. What does apply to every section:
the reparse-point skip (files and directories), the `.evtx` channel export, and the caps
(100 MB per file, 500 MB total, 5000 files) tracked globally across sections by
`BudgetTracker`, with every skip recorded in the manifest and `_TRUNCATED.txt`.
## Configured paths: global ∪ tenant
`GetAgentConfigFunction.MergeDiagnosticsLogPaths` sends global paths (Global Admin,
`AdminConfiguration.DiagnosticsGlobalLogPathsJson`) first, then tenant paths
(`TenantConfiguration.DiagnosticsLogPathsJson`), dropping blank entries and collapsing
duplicates (trimmed, case-insensitive) onto the first occurrence — a path present in both
lists used to produce duplicate ZIP entry names. The agent keys each entry's ZIP folder on
the last directory name (`AdditionalLogs/<folder>`), so two configured paths ending in a
same-named folder share one ZIP folder.
## Portal surfaces
* Global Admin → Settings → Diagnostics Log Paths: the built-in catalog (collapsed, read-only,
neutral condition pills) above the editable global list; add-row first.
* Tenant → Settings → Agent → Diagnostics Package: built-in catalog (header carries the
tenant's persisted RealmJoin Watcher state, RealmJoin rows show on/off), the global paths
read-only, then the tenant's own entries; every role with a tenant membership can read the
catalog (the route is MemberRead, JWT-scoped). One line per entry (`DiagnosticsPathRow`),
full values in tooltips.
## Test seams
* `DiagnosticsPackageService` internal ctor: the five historical folder overrides map onto
catalog ids; `sectionFolderOverrides` covers any other section; `devicePreparationProbe`
replaces the registry read (seam callers default to "not WDP", so existing rigs never touch
the registry). `UserProfileResolver.SetForTesting(path|null)` / `Reset()` drive the token.
* Catalog invariants live in the backend test project (`DiagnosticsBuiltInSectionsTests`),
because Shared has no test project.
# Examples
A RealmJoin tenant (watcher on) with a signed-in user: `RealmJoinLogs/Windows/realmjoin.log`,
`RealmJoinLogs/Windows/RealmJoin/Packages/<pkg>/<timestamp>_install.log`,
`RealmJoinLogs/Choco/<pkg>/<timestamp>_install.log`, `RealmJoinLogs/User/tray.log`,
`RealmJoinLogs/User/Logs/RjImeHost.log`. The same tenant's package collected before the
desktop: the two `User` sections appear as `BUILT-IN SKIPPED (no user session for token)`.
A Classic enrollment: `BUILT-IN SKIPPED (not a Device Preparation enrollment):
ImeBootstrapperEventLog`. A tenant that had added
`%LOGGED_ON_USER_PROFILE%\AppData\Local\RealmJoin\Logs\*.log` manually keeps working — the
files simply appear twice (`AdditionalLogs/Logs/…` and `RealmJoinLogs/User/Logs/…`) until the
entry is removed.
# Citations
* `src/Shared/AutopilotMonitor.Shared/Models/Diagnostics/DiagnosticsBuiltInSections.cs` — the catalog
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/DiagnosticsPackageService.cs` — `BuildArchiveBytes`, `AddBuiltInSection`, `IsSectionActive`, caps, evtx export
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigMerger.cs` — `EnableRealmJoinWatcher` mirror
* `src/Backend/AutopilotMonitor.Functions/Functions/Diagnostics/GetDiagnosticsPathsFunction.cs` — `GET diagnostics/paths`
* `src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs` — `MergeDiagnosticsLogPaths`
* `src/Web/autopilot-monitor-web/components/diagnostics/` — `DiagnosticsPathRow`, `BuiltInSectionsList`, `builtInSectionDisplay`
* Tests: `DiagnosticsPackageServiceTests` (RealmJoin on/off, no user session, bootstrapper evtx, probe failure), `DiagnosticsBuiltInSectionsTests`, `GetDiagnosticsPathsPayloadTests`, `GetAgentConfigFunctionTests.MergeDiagnosticsLogPaths_*`
+1
View File
@@ -5,6 +5,7 @@ How the on-device agent works:
* [Runtime Overview](overview.md) - lifecycle (install → boot guards → runtime → termination), collector hosts, the single-rail event pipeline to the backend, config & kill switch, on-disk layout.
* [Decision Engine](decision-engine.md) - the pure-reducer state machine (DecisionCore): signals, state, effects, the A/B/C completion arms, invariants, threading.
* [Logs, Persistence & Crash Recovery](logs-and-persistence.md) - every persisted file and why it exists; signal log vs spool; snapshot/journal; the replay-based recovery flow and quarantine.
* [Diagnostics Package](diagnostics-package.md) - what the diagnostics ZIP contains: the built-in section catalog shared with the portal (Shared `DiagnosticsBuiltInSections`, served by GET diagnostics/paths), the RealmJoin Watcher and Device Preparation collection gates, why built-ins bypass the configured-path guards, global ∪ tenant path dedup, caps and the package manifest.
* [Hello Wizard Un-Skip](hello-wizard-unskip.md) - why a single "WHfB disabled" policy read never decides completion: the HelloWizardStarted signal (prevention + cure) and the HelloTracker confirmation second read.
* [Autopilot ZTD Diagnostics](autopilot-ztd-diagnostics.md) - Windows' own diagnostic surfaces for the profile-download flow: ModernDeployment event IDs (807/815/164/...), the Diagnostics\Autopilot registry key, deployment-service endpoints, and the known-issue error-code map.
* [MDM Reboot Coalescing](mdm-reboot-coalescing.md) - attributing the mid-ESP coalesced reboot ("second sign-in") to the device-assigned policy URIs that forced it: aggregated 2800 observations (neutral, flush-time watermark) + ANALYZE-ESP-005 gated on an actually-observed reboot.
@@ -0,0 +1,93 @@
---
type: concept
title: Plan Downgrade Policy & the Retention Grace Period
description: What happens when a tenant loses Pro (explicit downgrade or trial expiry) — every entitlement gates read-time and reversibly, except retention, which gets a 30-day grace before the Community cap starts hard-deleting; the anchor model, the ops events, and the deliberate non-grace of everything else.
resource: backend
tags: [plan, edition, downgrade, retention, grace-period, trial, entitlements]
timestamp: 2026-08-22
---
# Schema
## The downgrade model: gate, don't destroy
Edition is resolved at READ time (`FeatureEntitlementCatalog.ResolveEdition`): Pro ⇔
permanent tier `pro`/legacy `enterprise` OR an active trial. A plan mutation is a single
field write (`PATCH config/{tenantId}/plan`, GA-only) — there is no downgrade event
handler, no cascade. On Pro → Community every entitlement simply gates on the next read
(≤ ~5 min config cache):
* **Unrestricted Mode** — effective value flips false; the GA on-request flag and the
tenant toggle survive untouched (inert). Re-upgrade restores it verbatim.
* **Delegated (MSP) admin** — scope suppressed at the home-tenant gate
(`DelegatedAdminService.ApplyHomeTenantGateAsync`); grant rows / tenant groups /
assignments are never touched. Resurrects on re-upgrade.
* **OOBE bootstrap** — endpoints 403/404 unless the additive GA per-tenant flag
(`BootstrapTokenEnabled`) is set; `BootstrapSessions` rows are kept.
* **Rate limits / MCP quota** — pure computed-at-read floors/plan names.
**Retention is the one exception**: the retention sweep (12-hourly
`SessionDeletionMaintenance` → `SessionRetentionFanoutService`) hard-deletes sessions
older than `GetEffectiveRetentionDays` = `min(stored DataRetentionDays, edition cap)`.
Without protection, a downgrade (or trial expiry) with stored retention 365 would start
irreversibly cascade-deleting the 90–365-day band on the next tick.
## The retention grace period (30 days)
`FeatureEntitlementCatalog.RetentionDowngradeGraceDays = 30`. For 30 days after losing
Pro, `GetEffectiveRetentionDays` keeps clamping to the **Pro** cap (365); only after the
grace does the Community cap (90) bite. The stored `DataRetentionDays` is never rewritten.
`days <= 0` (GA-only "infinite") short-circuits before any cap — those tenants are never
swept, grace or not.
**Anchor** — "when was Pro lost" (`TenantEntitlementService.GetRetentionGraceEndUtc`,
returns null while effectively Pro; grace end = latest anchor + 30d):
* **Explicit downgrade**: `TenantConfiguration.ProDowngradedUtc` (backend-only field, no
agent ConfigVersion impact), maintained exclusively by the plan endpoint's pure mutation
core `PlanManagementFunction.ApplyPlanChanges`. Stamped when the EFFECTIVE edition
(before vs. after, same `nowUtc`) transitions Pro → Community — this covers both a
planTier downgrade and a GA explicitly ending a trial (`trialExpiresUtc: null`, where
the trial timestamp disappears and could not anchor). Cleared on any transition back to
effectively Pro (PATCH pro, trial grant, self-service trial start), so a re-upgrade also
resets the grace clock.
* **Trial expiry**: no write happens — the stored `TrialExpiresUtc` itself is the anchor
(read-time, like edition resolution). A planTier downgrade under a still-active trial
deliberately does NOT stamp: the edition is still Pro, and the later trial expiry
anchors the grace then.
The field is on the patch-endpoint `BaseDeniedFields` and `RevertProtectedFields` lists
(a config revert must not time-travel the anchor) and roundtrips the table-serialization
contract (Store+Map + roundtrip tests). Legacy rows read null → no anchor → no grace,
which is correct: no tenant had been downgraded before the feature existed.
## Ops visibility (dual-registered event types)
* `TenantPlanDowngraded` (Warning) — emitted by the PATCH handler on an effective
Pro→Community transition; carries `retentionGraceEndsUtc` + stored retention days. The
response body also returns `retentionGraceEndsUtc`.
* `TenantRetentionGraceExpiring` / `TenantRetentionGraceEnded` (Warning) — emitted by the
daily `TrialExpirySweepFunction` (03:30 UTC) with the same stateless window mechanics as
the trial events (≤3-day heads-up re-fired daily; ended once via the 24h look-back), and
ONLY when data is actually at risk: stored `DataRetentionDays` above the Community cap
(0 = infinite ⇒ never). Re-upgraded tenants go silent automatically (grace resolves null).
All three are registered in the web `OPS_EVENT_TYPES` catalog (OpsAlertRulesSection.tsx)
so Telegram alert rules can route them.
## Deliberate non-goals
* No grace for Unrestricted/MSP/bootstrap/limits — reversible gates need none.
* The retention WRITE cap (`TenantConfigValidation`) does not honor the grace: during the
grace a tenant admin cannot SET a value above the Community cap. The grace protects
existing data; new writes follow the new plan.
* The feature-flags endpoint keeps publishing the plain edition cap
(`retentionCapDays`) — it feeds the settings input maximum, which is the write cap.
# Citations
* `src/Backend/AutopilotMonitor.Functions/Services/TenantEntitlementService.cs` — grace + effective retention
* `src/Backend/AutopilotMonitor.Functions/Functions/Config/PlanManagementFunction.cs` — `ApplyPlanChanges` anchor lifecycle
* `src/Backend/AutopilotMonitor.Functions/Functions/Maintenance/TrialExpirySweepFunction.cs` — grace event windows
* `src/Backend/AutopilotMonitor.Functions/Services/Deletion/SessionRetentionFanoutService.cs` — enforcement point
* Tests: `TenantEntitlementServiceTests`, `PlanManagementTransitionTests`, `TrialExpirySweepFunctionTests`, `SessionRetentionFanoutServiceTests`, `TenantConfigTableSerializationTests`
+46 -1
View File
@@ -4,7 +4,7 @@ title: Tenant Activation & the Once-Only Welcome Mail
description: How a signup becomes an activated tenant (manual approve or auto-approve queue), and how the welcome mail is sent exactly once even though activation and the user-provided address race each other.
resource: backend
tags: [activation, auto-approve, welcome-email, preview-whitelist, race]
timestamp: 2026-08-02
timestamp: 2026-08-23
---
# Schema
@@ -58,6 +58,46 @@ The Global-Admin resend endpoint (`POST /api/preview/send-welcome-email/{tenantI
always sends (explicit intent) and consumes the marker best-effort so the automatic
paths never duplicate afterwards.
## Mail transport
Both transactional mails (welcome, post-offboarding farewell) go through the
provider-neutral `EmailService` (`IEmailService` for the welcome path,
`IOffboardFarewellEmailSender` for the farewell path). The provider is an implementation
detail confined to one private transport method plus the `Email:*` configuration
section — a provider swap must not rename classes, DI registrations or callers.
* Current provider: **Mailchimp Transactional (Mandrill)**, `POST {Email:Endpoint}`
(`messages/send`), API key in the JSON body, typed `HttpClient` with the shared
`ResiliencePolicies.Notification` retry policy (15 s timeout).
* Settings (App Settings use `Email__Key`): `Email:ApiKey` (required — empty means every
send is a logged no-op, never an error), `Email:Endpoint`, `Email:FromAddress`
(default `noreply@autopilotmonitor.com`), `Email:FromName` (default `Autopilot Monitor`).
* Result handling: per-recipient status `sent`/`queued`/`scheduled` = success;
`rejected`/`invalid` (with `reject_reason`), non-2xx or malformed bodies = warning.
Never throws — both callers rely on that fail-soft contract.
* `track_opens`/`track_clicks` are explicitly `false` and `auto_text` is `true`: the provider
receives only the recipient address and the tenant domain, which is exactly the claim on
the privacy page and the trust data-flows page. Changing tracking = changing those pages.
## Template overrides (operator-editable)
Global Admins can replace either HTML body without a deployment (portal: Settings →
Email Templates). `EmailTemplateService` resolves the body for every send: the stored
override wins, otherwise the built-in `EmailTemplates.*`. Overrides live in the
`PreviewConfig` table (partition `EmailTemplates`, row key `welcome` | `farewell`,
properties `Html`, `UpdatedBy`, `UpdatedUtc`), cached 5 minutes, invalidated on save/reset.
* The only placeholder is `{{domainName}}`; an empty domain renders as "your organization",
matching the built-ins. Subjects are not editable (pinned constants).
* Limit 30 000 characters (Table string property cap is 32K UTF-16 chars).
* The send path is fail-soft: a storage error falls back to the built-in body. The admin
read path is not — a broken store surfaces as an error instead of silently showing the
built-in.
* Endpoints (all GlobalAdminOnly): `GET|PUT|DELETE /api/global/email-templates/{kind}` and
`POST /api/global/email-templates/{kind}/test`. The test send goes to the caller's tenant
contact address (activation notification email → `ContactEmail` → caller UPN) with the
caller's tenant domain, optionally with an unsaved draft body; provider tag `test`.
# Examples
* Auto-approve wins the race: approve at T+64 s finds no address → logs "deferred to the
@@ -73,3 +113,8 @@ paths never duplicate afterwards.
* `src/Backend/AutopilotMonitor.Functions/Functions/Rules/PreviewWhitelistFunction.cs` — save-path trigger, GA resend
* `src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs` — conditional inserts (approved / welcome-email-sent)
* `src/Backend/AutopilotMonitor.Functions.Tests/TenantApprovalServiceTests.cs` — ordering + dedup pins
* `src/Backend/AutopilotMonitor.Functions/Services/EmailService.cs` — provider-neutral transport (Mandrill wire format, result handling, tracking off)
* `src/Backend/AutopilotMonitor.Functions.Tests/EmailServiceTests.cs` — wire-format, fail-soft and gate-ordering pins
* `src/Backend/AutopilotMonitor.Functions/Services/EmailTemplateService.cs` — override resolution, placeholder rendering, cache
* `src/Backend/AutopilotMonitor.Functions/Functions/Admin/EmailTemplatesFunction.cs` — GA endpoints (read / override / reset / test send)
* `src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionEmailTemplates.tsx` — portal editor, preview frame, test send
+2 -1
View File
@@ -19,7 +19,7 @@ https://docs.autopilotmonitor.com (separate repository).
# Agent
* [V2 Agent](agent/index.md) - How the on-device agent works: [runtime overview](agent/overview.md), [decision engine](agent/decision-engine.md) (reducer, signals, completion arms), [logs & persistence](agent/logs-and-persistence.md) (signal log, snapshot, crash recovery), [Hello wizard un-skip](agent/hello-wizard-unskip.md) (why a single "WHfB disabled" read never decides completion), [Autopilot ZTD diagnostics](agent/autopilot-ztd-diagnostics.md) (event IDs, diagnostic registry, endpoints, error-code map).
* [V2 Agent](agent/index.md) - How the on-device agent works: [runtime overview](agent/overview.md), [decision engine](agent/decision-engine.md) (reducer, signals, completion arms), [logs & persistence](agent/logs-and-persistence.md) (signal log, snapshot, crash recovery), [diagnostics package](agent/diagnostics-package.md) (built-in section catalog shared with the portal, RealmJoin/Device Preparation collection gates, caps, manifest), [Hello wizard un-skip](agent/hello-wizard-unskip.md) (why a single "WHfB disabled" read never decides completion), [Autopilot ZTD diagnostics](agent/autopilot-ztd-diagnostics.md) (event IDs, diagnostic registry, endpoints, error-code map).
* [Build Counter Blob](agent/build-counter-blob.md) - How agent build numbers are minted: shared counter blob with ETag-CAS so local and CI builds never collide; CI additionally attests build provenance (Sigstore).
* [Agent Endpoint Migration](agent/endpoint-migration.md) - How the backend re-homes agents to a new API base URL via the config channel (backend move or per-tenant region move): live-fetch-only, allowlist-validated on both sides, one hop, kill wins.
* [Gather Rule Debug Log](agent/gather-rule-debug-log.md) - EnableGatherRuleDebugLog (ConfigVersion 36): the per-tenant local trace file that explains every gather-rule evaluation decision — scope skips, on_change suppression, empty collector results, logparser position/parse-mode outcomes — for rules that produce no timeline events.
@@ -53,6 +53,7 @@ https://docs.autopilotmonitor.com (separate repository).
* [Attempt-Scoped App Install Durations](backend/app-install-duration-attempt.md) - AppInstallSummary.DurationSeconds = status-defining (last) install attempt via the LastAttemptStartedAt max-fold anchor instead of the first-observation→last-terminal span that billed multi-pass IME gaps to trivial apps; InstallPassCount, the weaker-terminal guard, the radar's cutover gate, and time attribution's union-of-attempt-segments occupancy.
* [Rule Regression Radar](backend/rule-regression-radar.md) - How analyze-rule hit-rate regressions are detected (7-day window ≥2× the 28-day baseline with disjoint Wilson intervals), which suppressions keep the radar quiet (grace period, edit-in-window, kill switch), how alert episodes live in the ruleregression tracker keyspace with re-arm semantics, and the on-fire dimension correlation with its "correlated, not necessarily causal" wording contract.
* [App Version Duration Regression Radar](backend/app-version-regression-radar.md) - How app versions whose median install duration regressed vs. the previous version are detected (first-seen-ordered versions, median lift ≥2× plus a 5-minute absolute floor over measured installs only), how alert episodes live in the appversionregression tracker keyspace with drain/median re-arm semantics, and the bell/ops-event/analytics surfaces.
* [Plan Downgrade Policy & the Retention Grace Period](backend/plan-downgrade-retention-grace.md) - What happens when a tenant loses Pro: every entitlement gates read-time and reversibly ("gate, don't destroy"), except retention — a 30-day grace keeps the Pro cap before the Community cap starts hard-deleting; the ProDowngradedUtc/trial-expiry anchor model, the TenantPlanDowngraded/RetentionGrace* ops events, and the deliberate non-grace of everything else.
* [Tenant Activation & the Once-Only Welcome Mail](backend/tenant-activation-welcome-mail.md) - How a signup becomes an activated tenant (manual approve or the auto-approve queue), and the write-then-read + conditional-marker arbitration that sends the welcome mail exactly once even though activation and the user-typed notification address race each other.
* [W365 Cloud PC Device Validation](backend/cloudpc-device-validation.md) - Why Cloud PCs can never pass the Autopilot serial lookup, and the cert-CN-bound CloudPc fallback validator: Subject CN (= Intune device id) matched against the tenant's Graph virtualEndpoint/cloudPCs inventory — admits exactly the service-provisioned Cloud PCs, nothing else.
* [ServerActions & On-Demand Diagnostics](backend/server-actions-on-demand-diagnostics.md) - The queue-on-session, deliver-on-ingest action channel, the TenantAdminOrOperator tier with in-function per-type re-gating, the portal's Collect Logs flow (quick-config orders rotate_config before request_diagnostics), and the hosted-prefix sweep in the offboarding cascade.
+12
View File
@@ -1,5 +1,17 @@
# Log
## 2026-08-23
* **New**: `agent/diagnostics-package.md` — the diagnostics ZIP's built-in collection is now ONE catalog in Shared (`DiagnosticsBuiltInSections.All`): the agent iterates it in order and the backend serves the same list to the portal (`GET diagnostics/paths`, MemberRead), so what administrators see is exactly what the agent collects. New conditional sections: RealmJoin client/package/Chocolatey/per-user logs behind the tenant's RealmJoin Watcher toggle (mirrored onto `AgentConfiguration.EnableRealmJoinWatcher` by the merger) and the Device Preparation bootstrapper event log behind the deterministic WDP marker (probe once per build, fail toward Classic); every skip is a manifest line. Built-ins bypass `DiagnosticsPathGuards` by design (reviewed code, `%ProgramData%\RealmJoin` is not allowlisted); caps/reparse/evtx export still apply. `MergeDiagnosticsLogPaths` dedups global ∪ tenant paths. Portal: collapsed read-only catalog + one-line rows in both cards, tenant context dropped the GA-only global fetch.
* **Update**: `rules/gather-rule-guardrails.md` — `DiagnosticsPathGuards` scope clarified: configured diagnostics paths only, never the built-in catalog.
* **Update**: `backend/tenant-activation-welcome-mail.md` — new "Template overrides" section: Global Admins can replace the welcome/farewell HTML in the portal (Settings → Email Templates); `EmailTemplateService` resolves override-or-built-in per send (PreviewConfig table, partition `EmailTemplates`, 5-min cache, `{{domainName}}` placeholder, 30k limit, fail-soft send path), GA endpoints `global/email-templates/{kind}` (+`/test` to the caller's tenant contact address). Also: the mail header is image-free (CSS-drawn brand mark, gradient stripes and clip-path edge as progressive enhancement).
## 2026-08-22
* **Update**: `backend/tenant-activation-welcome-mail.md` — new "Mail transport" section: transactional mail now goes through the provider-neutral `EmailService` (`IEmailService` + `IOffboardFarewellEmailSender`) with the provider confined to one private transport method and the `Email:*` settings (`ApiKey`, `Endpoint`, `FromAddress`, `FromName`); current provider Mailchimp Transactional (Mandrill) replacing Resend (NuGet dropped, typed `HttpClient` + `ResiliencePolicies.Notification`). Result-array semantics (`sent`/`queued`/`scheduled` ok, `rejected`/`invalid` warn), open/click tracking explicitly off because the privacy/trust claim depends on it.
* **New**: `backend/plan-downgrade-retention-grace.md` — plan downgrade policy: every Pro entitlement gates read-time and reversibly on downgrade ("gate, don't destroy"), except retention, which now gets a 30-day grace (`FeatureEntitlementCatalog.RetentionDowngradeGraceDays`) before the Community cap starts hard-deleting the 90–365-day band. Anchor model: `ProDowngradedUtc` stamped/cleared by the plan endpoint's pure `ApplyPlanChanges` core on EFFECTIVE-edition transitions (covers explicitly ended trials; planTier downgrade under an active trial deliberately doesn't stamp), trial expiry anchors read-time via `TrialExpiresUtc`. New Warning ops events `TenantPlanDowngraded` (PATCH handler) and `TenantRetentionGraceExpiring`/`TenantRetentionGraceEnded` (TrialExpirySweep window mechanics, only when stored retention exceeds the Community cap). Field is patch-denied + revert-protected; retention WRITE cap deliberately ignores the grace.
## 2026-08-21
* **New**: `agent/do-group-id-auto-set.md` — optional DO peering-group seed (`EnableDoGroupIdAutoSet`, default off, ConfigVersion 40): `DoGroupIdService` derives a deterministic GUID from the default gateway's IPv4 + SendARP MAC in RealmJoin's exact byte layout (cross-product peering) and writes `DOGroupId` in the 64-bit GPO Policies store with readback verification. Conservative by design: no random-GUID fallback (skip on missing gateway/MAC), `DOGroupIdSource`/MDM-managed values/foreign GPO values always win (skip reasons in the `do_group_id_auto_set` event), own stale values are recognized via the `written:<guid>` StartupEventGate fingerprint and updated after a gateway change. Gateway heuristic extracted into the shared `NetworkInterfaceLocator` (dedup of NetworkChangeDetector + DeviceInfoCollector copies).
+6
View File
@@ -31,6 +31,12 @@ item in `AutopilotMonitor.Agent.V2.Core.csproj`) and regenerated into the portal
| `IsCommandAllowed` | `allowedCommands` | **exact** match, trimmed — never prefix |
| `IsEventLogChannelAllowed` | `eventLogChannels` | prefix, boundary `/` or end |
`DiagnosticsPathGuards.IsDiagnosticsPathAllowed` (allowlist `diagnosticsPathPrefixes`, a C#
literal mirrored in the JSON) applies the same hard blocks and the same user-profile
exception to **admin-configured diagnostics paths** only. The diagnostics package's built-in
sections are reviewed code and never pass through it — see
[Diagnostics Package](../agent/diagnostics-package.md).
**Hard blocks live in code, not in the JSON**, so a parse error cannot lift them — on a
parse failure the allowlists fall back to empty and everything is blocked (fail-closed),
but the hard blocks must hold regardless of configuration:
@@ -53,6 +53,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
// Merge reads two Collectors knobs (AgentMaxLifetimeMinutes, HelloWaitTimeoutSeconds);
// the remaining CollectorConfiguration flags flow untouched to DefaultComponentFactory.
nameof(AgentConfigResponse.Collectors),
// Merge reads one Analyzers knob (EnableRealmJoinWatcher → diagnostics RealmJoin sections);
// the remaining flags flow untouched to DefaultComponentFactory + AgentAnalyzerManager.
nameof(AgentConfigResponse.Analyzers),
};
/// <summary>
@@ -70,8 +73,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
nameof(AgentConfigResponse.ImeLogPatterns),
// Passed by AgentRuntimeHost into IComponentFactory.CreateCollectorHosts → ImeLogHost sealing re-emit.
nameof(AgentConfigResponse.WhiteGloveSealingPatternIds),
// Consumed by DefaultComponentFactory (RealmJoin/KeepAwake/ConsoleBypass host gates) + AgentAnalyzerManager.
nameof(AgentConfigResponse.Analyzers),
// Self-update ZIP integrity second trust channel — BinaryIntegrityVerifier / Program update path.
nameof(AgentConfigResponse.LatestAgentSha256),
// Running-EXE integrity verification — BinaryIntegrityVerifier post-config check.
@@ -368,6 +368,33 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
Assert.Equal(30, agent.HelloWaitTimeoutSeconds);
}
[Fact]
public void Merge_mirrors_analyzers_realmjoin_watcher_onto_agent_config()
{
// The diagnostics package gates its RealmJoin sections on this flag but never
// sees the remote response — the merger carries the one Analyzers knob over.
var agent = NewAgentConfig();
var remote = FullRemote();
remote.Analyzers = new AnalyzerConfiguration { EnableRealmJoinWatcher = true };
RemoteConfigMerger.Merge(agent, remote);
Assert.True(agent.EnableRealmJoinWatcher);
}
[Fact]
public void Merge_clears_realmjoin_watcher_when_remote_analyzers_missing()
{
var agent = NewAgentConfig();
agent.EnableRealmJoinWatcher = true; // stale cached true must not survive
var remote = FullRemote();
remote.Analyzers = null!;
RemoteConfigMerger.Merge(agent, remote);
Assert.False(agent.EnableRealmJoinWatcher);
}
[Fact]
public void Merge_replaces_diagnostics_log_paths_with_empty_list_when_remote_null()
{
@@ -1,9 +1,11 @@
#nullable enable
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
@@ -46,7 +48,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
Directory.CreateDirectory(SpoolFolder);
}
public DiagnosticsPackageService Build(System.Action<AgentConfiguration>? mutateConfig = null)
public DiagnosticsPackageService Build(
System.Action<AgentConfiguration>? mutateConfig = null,
IReadOnlyDictionary<string, string>? sectionFolderOverrides = null,
System.Func<bool>? devicePreparationProbe = null)
{
// BackendApiClient is required by the public ctor but BuildArchiveBytes
// never touches it — construct with a throwaway HttpClient. Tests only
@@ -71,7 +76,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
imeLogFolderOverride: ImeTmp.Path,
agentStateFolderOverride: StateFolder,
agentSpoolFolderOverride: SpoolFolder,
agentDataFolderOverride: DataFolder);
agentDataFolderOverride: DataFolder,
sectionFolderOverrides: sectionFolderOverrides,
devicePreparationProbe: devicePreparationProbe);
}
public void Dispose()
@@ -287,6 +294,185 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
Assert.DoesNotContain(entries, e => e.StartsWith("AgentSpool/"));
}
// ===================================================== built-in catalog gates ====
// The RealmJoin sections ride behind the tenant's RealmJoin Watcher toggle, the
// Device Preparation bootstrapper event log behind the deterministic WDP marker.
// Every conditional section is skipped WITH a manifest line, never silently.
/// <summary>RealmJoin system-side folders redirected to temp (user-side goes via UserProfileResolver).</summary>
private sealed class RealmJoinRig : System.IDisposable
{
public TempDirectory WindowsLogs { get; } = new TempDirectory();
public TempDirectory Choco { get; } = new TempDirectory();
public TempDirectory UserProfile { get; } = new TempDirectory();
public string PackagesFolder => Path.Combine(WindowsLogs.Path, "RealmJoin");
public RealmJoinRig()
{
File.WriteAllText(Path.Combine(WindowsLogs.Path, "realmjoin.log"), "rj");
File.WriteAllText(Path.Combine(WindowsLogs.Path, "realmjoin2.log"), "rj2");
var pkg = Path.Combine(PackagesFolder, "Packages", "generic-7zip");
Directory.CreateDirectory(pkg);
File.WriteAllText(Path.Combine(pkg, "install.log"), "pkg");
var choco = Path.Combine(Choco.Path, "generic-7zip");
Directory.CreateDirectory(choco);
File.WriteAllText(Path.Combine(choco, "2026-06-30_install.log"), "choco");
var userRj = Path.Combine(UserProfile.Path, "AppData", "Local", "RealmJoin");
Directory.CreateDirectory(Path.Combine(userRj, "Logs", "generic-office"));
File.WriteAllText(Path.Combine(userRj, "tray.log"), "tray");
File.WriteAllText(Path.Combine(userRj, "config.pjson"), "not a log");
File.WriteAllText(Path.Combine(userRj, "Logs", "RjImeHost.log"), "host");
File.WriteAllText(Path.Combine(userRj, "Logs", "generic-office", "usersettings.log"), "pkg-user");
}
public IReadOnlyDictionary<string, string> Overrides => new Dictionary<string, string>
{
["RealmJoinWindows"] = WindowsLogs.Path,
["RealmJoinPackages"] = PackagesFolder,
["RealmJoinChoco"] = Choco.Path,
};
public void Dispose()
{
WindowsLogs.Dispose();
Choco.Dispose();
UserProfile.Dispose();
}
}
[Fact]
public void BuildArchiveBytes_skips_realmjoin_sections_when_watcher_disabled()
{
using var rig = new Rig();
using var rj = new RealmJoinRig();
UserProfileResolver.SetForTesting(rj.UserProfile.Path);
try
{
var bytes = rig.Build(sectionFolderOverrides: rj.Overrides).BuildArchiveBytes(enrollmentSucceeded: true);
var entries = ZipEntryNames(bytes);
Assert.DoesNotContain(entries, e => e.StartsWith("RealmJoinLogs/"));
var manifest = ReadZipEntryText(bytes, "package-manifest.txt")!;
Assert.Contains("SCENARIO: devicePreparation=False realmJoinWatcher=False", manifest);
Assert.Contains("BUILT-IN SKIPPED (RealmJoin Watcher disabled): RealmJoinWindows", manifest);
Assert.Contains("BUILT-IN SKIPPED (RealmJoin Watcher disabled): RealmJoinUserLogs", manifest);
}
finally
{
UserProfileResolver.Reset();
}
}
[Fact]
public void BuildArchiveBytes_includes_realmjoin_sections_when_watcher_enabled()
{
using var rig = new Rig();
using var rj = new RealmJoinRig();
UserProfileResolver.SetForTesting(rj.UserProfile.Path);
try
{
var bytes = rig.Build(cfg => cfg.EnableRealmJoinWatcher = true, rj.Overrides)
.BuildArchiveBytes(enrollmentSucceeded: false);
var entries = ZipEntryNames(bytes);
// ZIP layout mirrors the disk layout: flat client logs + package tree under
// Windows/, tray logs + per-user Logs tree under User/.
Assert.Contains("RealmJoinLogs/Windows/realmjoin.log", entries);
Assert.Contains("RealmJoinLogs/Windows/realmjoin2.log", entries);
Assert.Contains("RealmJoinLogs/Windows/RealmJoin/Packages/generic-7zip/install.log", entries);
Assert.Contains("RealmJoinLogs/Choco/generic-7zip/2026-06-30_install.log", entries);
Assert.Contains("RealmJoinLogs/User/tray.log", entries);
Assert.Contains("RealmJoinLogs/User/Logs/RjImeHost.log", entries);
Assert.Contains("RealmJoinLogs/User/Logs/generic-office/usersettings.log", entries);
// Only tray*.log is collected from the user RealmJoin root — config stays out.
Assert.DoesNotContain(entries, e => e.EndsWith("config.pjson"));
var manifest = ReadZipEntryText(bytes, "package-manifest.txt")!;
Assert.Contains("BUILT-IN: RealmJoinUserLogs -> folder=", manifest);
Assert.DoesNotContain("BUILT-IN SKIPPED (RealmJoin Watcher disabled)", manifest);
}
finally
{
UserProfileResolver.Reset();
}
}
[Fact]
public void BuildArchiveBytes_skips_realmjoin_user_sections_without_user_session()
{
using var rig = new Rig();
using var rj = new RealmJoinRig();
// Token present, no interactive user detected → user-profile sections skip with a
// manifest line; the system-side sections are unaffected.
UserProfileResolver.SetForTesting(null!);
try
{
var bytes = rig.Build(cfg => cfg.EnableRealmJoinWatcher = true, rj.Overrides)
.BuildArchiveBytes(enrollmentSucceeded: true);
var entries = ZipEntryNames(bytes);
Assert.Contains("RealmJoinLogs/Windows/realmjoin.log", entries);
Assert.DoesNotContain(entries, e => e.StartsWith("RealmJoinLogs/User"));
var manifest = ReadZipEntryText(bytes, "package-manifest.txt")!;
Assert.Contains("BUILT-IN SKIPPED (no user session for token): RealmJoinUserTray", manifest);
Assert.Contains("BUILT-IN SKIPPED (no user session for token): RealmJoinUserLogs", manifest);
}
finally
{
UserProfileResolver.Reset();
}
}
[Fact]
public void BuildArchiveBytes_collects_bootstrapper_event_log_only_on_device_preparation()
{
using var rig = new Rig();
using var winevt = new TempDirectory();
// Synthetic evtx: no registered channel claims the file, so the export falls
// back to a raw copy — the point here is the scenario gate, not wevtutil.
File.WriteAllBytes(Path.Combine(winevt.Path, "BootstrapperAgentServiceLogProvider.evtx"), new byte[] { 1, 2, 3 });
var overrides = new Dictionary<string, string> { ["ImeBootstrapperEventLog"] = winevt.Path };
var classic = rig.Build(sectionFolderOverrides: overrides, devicePreparationProbe: () => false)
.BuildArchiveBytes(enrollmentSucceeded: true);
Assert.DoesNotContain("ImeLogs/BootstrapperAgentServiceLogProvider.evtx", ZipEntryNames(classic));
Assert.Contains("BUILT-IN SKIPPED (not a Device Preparation enrollment): ImeBootstrapperEventLog",
ReadZipEntryText(classic, "package-manifest.txt"));
var wdp = rig.Build(sectionFolderOverrides: overrides, devicePreparationProbe: () => true)
.BuildArchiveBytes(enrollmentSucceeded: true);
Assert.Contains("ImeLogs/BootstrapperAgentServiceLogProvider.evtx", ZipEntryNames(wdp));
Assert.Contains("SCENARIO: devicePreparation=True", ReadZipEntryText(wdp, "package-manifest.txt"));
}
[Fact]
public void BuildArchiveBytes_treats_failing_device_preparation_probe_as_classic()
{
using var rig = new Rig();
var bytes = rig.Build(devicePreparationProbe: () => throw new System.InvalidOperationException("registry boom"))
.BuildArchiveBytes(enrollmentSucceeded: true);
var manifest = ReadZipEntryText(bytes, "package-manifest.txt")!;
Assert.Contains("SCENARIO PROBE FAILED (treated as Classic): registry boom", manifest);
Assert.Contains("BUILT-IN SKIPPED (not a Device Preparation enrollment): ImeBootstrapperEventLog", manifest);
Assert.Contains("sessioninfo.txt", ZipEntryNames(bytes)); // archive still built
}
[Theory]
[InlineData(DiagnosticsSectionCondition.Always, false, false, true)]
[InlineData(DiagnosticsSectionCondition.RealmJoinWatcher, false, true, false)]
[InlineData(DiagnosticsSectionCondition.RealmJoinWatcher, true, false, true)]
[InlineData(DiagnosticsSectionCondition.DevicePreparation, true, false, false)]
[InlineData(DiagnosticsSectionCondition.DevicePreparation, false, true, true)]
[InlineData((DiagnosticsSectionCondition)999, true, true, false)]
public void IsSectionActive_gates_on_condition(DiagnosticsSectionCondition condition, bool watcher, bool wdp, bool expected)
{
var section = new DiagnosticsBuiltInSection("X", "X", @"C:\X", new[] { "*.log" }, false, "x", condition);
var cfg = Cfg();
cfg.EnableRealmJoinWatcher = watcher;
Assert.Equal(expected, DiagnosticsPackageService.IsSectionActive(section, cfg, wdp));
}
private static string[] ZipEntryNames(byte[] zipBytes)
{
using var ms = new MemoryStream(zipBytes);
@@ -262,6 +262,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
/// </summary>
public bool UnrestrictedMode { get; set; } = false;
/// <summary>
/// Mirror of <c>AnalyzerConfiguration.EnableRealmJoinWatcher</c> (remote, per tenant).
/// Gates the RealmJoin built-in sections of the diagnostics package — the package
/// service only sees this configuration, not the remote response. The RealmJoin host
/// itself keeps reading the remote Analyzers block. Default: false.
/// </summary>
public bool EnableRealmJoinWatcher { get; set; } = false;
/// <summary>
/// Sanitized command-line arguments for the agent_started event.
/// Secrets (e.g. bootstrap token) are redacted before assignment.
@@ -101,6 +101,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
agentConfig.DiagnosticsUploadEnabled = remoteConfig.DiagnosticsUploadEnabled;
agentConfig.DiagnosticsUploadMode = remoteConfig.DiagnosticsUploadMode;
agentConfig.DiagnosticsLogPaths = remoteConfig.DiagnosticsLogPaths ?? new List<DiagnosticsLogPath>();
// The diagnostics package gates its RealmJoin sections on the tenant's watcher
// toggle but never sees the remote response — mirror the one Analyzers knob here.
// The remaining Analyzers flags flow untouched to DefaultComponentFactory.
agentConfig.EnableRealmJoinWatcher = remoteConfig.Analyzers?.EnableRealmJoinWatcher ?? false;
agentConfig.SendTraceEvents = remoteConfig.SendTraceEvents;
@@ -10,7 +10,9 @@ using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using AutopilotMonitor.Agent.V2.Core.Security;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
@@ -124,62 +126,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
}
}
private readonly string _imeLogFolder;
private readonly string _agentLogFolder;
private readonly string _agentStateFolder;
private readonly string _agentSpoolFolder;
private readonly string _agentDataFolder;
// Built-in sections come from the Shared catalog (DiagnosticsBuiltInSections.All) — the
// same list the backend serves to the portal, so what administrators see is exactly
// what this service collects. Test seam: section id → folder; production leaves the
// map empty and every section resolves from the catalog's (unexpanded) source folder.
private readonly IReadOnlyDictionary<string, string> _sectionFolderOverrides;
private static readonly string DefaultImeLogFolder =
Environment.ExpandEnvironmentVariables(@"%ProgramData%\Microsoft\IntuneManagementExtension\Logs");
private static readonly string DefaultAgentLogFolder =
Environment.ExpandEnvironmentVariables(Constants.LogDirectory);
private static readonly string DefaultAgentStateFolder =
Environment.ExpandEnvironmentVariables(Constants.StateDirectory);
private static readonly string DefaultAgentSpoolFolder =
Environment.ExpandEnvironmentVariables(Constants.SpoolDirectory);
private static readonly string DefaultAgentDataFolder =
Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor");
/// <summary>
/// File extensions collected from built-in log folders (Agent + IME).
/// Covers standard logs, structured data, traces, event logs, and diagnostics archives.
/// </summary>
private static readonly string[] LogFilePatterns = new[]
{
"*.log", "*.txt", "*.json", "*.jsonl",
"*.etl", "*.evtx", "*.xml", "*.csv", "*.cab"
};
/// <summary>
/// Patterns collected from the agent state folder. Adds completion markers on top of
/// the standard log patterns so files like <c>enrollment-complete.marker</c> and
/// <c>whiteglove-backfill-state.json</c> reach the diagnostics archive.
/// </summary>
private static readonly string[] StateFilePatterns = new[]
{
"*.log", "*.txt", "*.json", "*.jsonl",
"*.etl", "*.evtx", "*.xml", "*.csv", "*.cab",
"*.complete", "*.marker"
};
/// <summary>
/// Marker patterns from the top-level data directory. Kept tight on purpose: only
/// completion/exit markers, no session.id / bootstrap.json (those are config, not
/// forensic state).
/// </summary>
private static readonly string[] RootMarkerPatterns = new[] { "*.complete", "*.marker" };
/// <summary>
/// Telemetry spool: only the JSON-shaped files. The spool folder may also contain
/// quarantine subfolders, so subfolders are excluded to keep the archive focused
/// on what is actually pending upload.
/// </summary>
private static readonly string[] SpoolFilePatterns = new[] { "*.jsonl", "*.json" };
// Scenario oracle for DiagnosticsSectionCondition.DevicePreparation. Production reads
// the deterministic WDP registry marker (the same gate every other WDP switch uses);
// tests inject a constant so they never touch the registry.
private readonly Func<bool> _devicePreparationProbe;
public DiagnosticsPackageService(AgentConfiguration configuration, AgentLogger logger, BackendApiClient apiClient)
: this(configuration, logger, apiClient, null, null, null, null, null)
@@ -188,7 +144,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
// Test seam: allows xUnit fixtures to redirect log/state/spool/data folders to a
// temp dir without touching real %ProgramData% paths. Production callers always go
// through the public ctor.
// through the public ctor. The five named overrides map onto the catalog's section
// ids; sectionFolderOverrides covers every other section (RealmJoin*, ImeBootstrapper…).
internal DiagnosticsPackageService(
AgentConfiguration configuration,
AgentLogger logger,
@@ -197,18 +154,38 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
string imeLogFolderOverride,
string agentStateFolderOverride,
string agentSpoolFolderOverride,
string agentDataFolderOverride)
string agentDataFolderOverride,
IReadOnlyDictionary<string, string> sectionFolderOverrides = null,
Func<bool> devicePreparationProbe = null)
{
_configuration = configuration;
_logger = logger;
_apiClient = apiClient;
_httpClient = new HttpClient { Timeout = TimeSpan.FromMinutes(5) };
_agentLogFolder = agentLogFolderOverride ?? DefaultAgentLogFolder;
_imeLogFolder = imeLogFolderOverride ?? DefaultImeLogFolder;
_agentStateFolder = agentStateFolderOverride ?? DefaultAgentStateFolder;
_agentSpoolFolder = agentSpoolFolderOverride ?? DefaultAgentSpoolFolder;
_agentDataFolder = agentDataFolderOverride ?? DefaultAgentDataFolder;
var overrides = new Dictionary<string, string>(StringComparer.Ordinal);
void Map(string sectionId, string folder)
{
if (folder != null) overrides[sectionId] = folder;
}
Map("AgentLogs", agentLogFolderOverride);
Map("ImeLogs", imeLogFolderOverride);
Map("AgentState", agentStateFolderOverride);
Map("AgentSpool", agentSpoolFolderOverride);
Map("AgentMarkers", agentDataFolderOverride);
if (sectionFolderOverrides != null)
{
foreach (var kv in sectionFolderOverrides)
overrides[kv.Key] = kv.Value;
}
_sectionFolderOverrides = overrides;
// Tests that pass folder overrides but no probe must stay registry-free: a seam
// caller gets "not WDP" by default, the production ctor gets the real marker.
_devicePreparationProbe = devicePreparationProbe
?? (overrides.Count == 0
? (Func<bool>)EnrollmentRegistryDetector.IsDeterministicDevicePreparation
: () => false);
}
/// <summary>
@@ -370,33 +347,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
// 1. sessioninfo.txt — informational metadata, not subject to caps.
AddSessionInfo(archive, enrollmentSucceeded);
// 2. Agent logs
foreach (var pattern in LogFilePatterns)
AddLogFiles(archive, _agentLogFolder, "AgentLogs", pattern, tracker);
// 2. Built-in sections — ONE catalog shared with the backend (the portal
// shows exactly this list): agent logs, IME logs (+ the Device Preparation
// bootstrapper event log on WDP), agent state (recursive: `.quarantine`
// and `.part1-<utc>/` buckets ride along), telemetry spool, top-level
// markers, and the RealmJoin logs behind the tenant's watcher toggle.
// The scenario probe is a registry read — evaluate it once per build.
var isDevicePreparation = ProbeDevicePreparation();
ManifestLine($"SCENARIO: devicePreparation={isDevicePreparation} realmJoinWatcher={_configuration.EnableRealmJoinWatcher}");
foreach (var section in DiagnosticsBuiltInSections.All)
AddBuiltInSection(archive, section, tracker, isDevicePreparation);
// 3. IME logs (all relevant files in the IME log folder)
foreach (var pattern in LogFilePatterns)
AddLogFiles(archive, _imeLogFolder, "ImeLogs", pattern, tracker);
// 4. Agent state: snapshot, journal, signal-log, ime-tracker-state,
// enrollment-complete.marker, final-status.json, whiteglove-backfill-state.json.
// Recursive to include `.quarantine` subfolders AND `.part1-<utc>/` buckets
// that StateArchiver creates on WG Part-2 boot — both ride along under
// AgentState/ via the same recursive walk.
foreach (var pattern in StateFilePatterns)
AddLogFiles(archive, _agentStateFolder, "AgentState", pattern, tracker, includeSubfolders: true);
// 5. Telemetry spool — pending uploads + upload cursor (forensic: what didn't ship?)
foreach (var pattern in SpoolFilePatterns)
AddLogFiles(archive, _agentSpoolFolder, "AgentSpool", pattern, tracker, includeSubfolders: false);
// 6. Top-level markers (whiteglove.complete, clean-exit marker) — Part-1/Part-2
// bridge + ghost-restart guard. Top-directory only to avoid re-zipping
// State/Spool/Logs/Agent which already have their own sections.
foreach (var pattern in RootMarkerPatterns)
AddLogFiles(archive, _agentDataFolder, "AgentMarkers", pattern, tracker, includeSubfolders: false);
// 7. Configured additional log paths (global + tenant, validated by guards)
// 3. Configured additional log paths (global + tenant, validated by guards)
foreach (var entry in _configuration.DiagnosticsLogPaths ?? new System.Collections.Generic.List<Shared.Models.DiagnosticsLogPath>())
{
// Resolve %LOGGED_ON_USER_PROFILE% token and get profile path for guard exception
@@ -447,6 +409,81 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
}
}
private bool ProbeDevicePreparation()
{
try
{
return _devicePreparationProbe();
}
catch (Exception ex)
{
// Fail toward Classic like every other WDP gate: the evtx export is skipped,
// never the whole package.
_logger.Warning($"Device Preparation probe failed — treating as Classic: {ex.Message}");
ManifestLine($"SCENARIO PROBE FAILED (treated as Classic): {ex.Message}");
return false;
}
}
/// <summary>
/// Pure gate: is this built-in section collected under the given configuration and
/// enrollment scenario? Unknown conditions (a newer Shared catalog than this agent)
/// fail closed.
/// </summary>
internal static bool IsSectionActive(DiagnosticsBuiltInSection section, AgentConfiguration configuration, bool isDevicePreparation)
{
switch (section.Condition)
{
case DiagnosticsSectionCondition.Always:
return true;
case DiagnosticsSectionCondition.RealmJoinWatcher:
return configuration.EnableRealmJoinWatcher;
case DiagnosticsSectionCondition.DevicePreparation:
return isDevicePreparation;
default:
return false;
}
}
private static string DescribeInactiveCondition(DiagnosticsSectionCondition condition)
{
switch (condition)
{
case DiagnosticsSectionCondition.RealmJoinWatcher:
return "RealmJoin Watcher disabled";
case DiagnosticsSectionCondition.DevicePreparation:
return "not a Device Preparation enrollment";
default:
return $"unknown condition {condition}";
}
}
private void AddBuiltInSection(ZipArchive archive, DiagnosticsBuiltInSection section, BudgetTracker tracker, bool isDevicePreparation)
{
if (!IsSectionActive(section, _configuration, isDevicePreparation))
{
ManifestLine($"BUILT-IN SKIPPED ({DescribeInactiveCondition(section.Condition)}): {section.Id}");
return;
}
// Override (test seam) or catalog source folder with %ProgramData% /
// %LOGGED_ON_USER_PROFILE% expanded; null means the token is present but no
// interactive user has been detected yet — same skip as configured paths.
var folder = _sectionFolderOverrides.TryGetValue(section.Id, out var overrideFolder)
? overrideFolder
: UserProfileResolver.ExpandCustomTokens(section.SourceFolder);
if (folder == null)
{
_logger.Warning($"Built-in diagnostics section skipped (no user session for token): {section.Id}");
ManifestLine($"BUILT-IN SKIPPED (no user session for token): {section.Id} '{section.SourceFolder}'");
return;
}
ManifestLine($"BUILT-IN: {section.Id} -> folder='{folder}' zip='{section.ZipFolder}' recursive={section.IncludeSubfolders}");
foreach (var pattern in section.Patterns)
AddLogFiles(archive, folder, section.ZipFolder, pattern, tracker, section.IncludeSubfolders);
}
private void AddSessionInfo(ZipArchive archive, bool? enrollmentSucceeded)
{
var sb = new StringBuilder();
@@ -20,7 +20,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
/// </summary>
public static class UserProfileResolver
{
public const string Token = "%LOGGED_ON_USER_PROFILE%";
// Spelled once, in Shared: the built-in diagnostics catalog uses the same token.
public const string Token = Shared.Models.DiagnosticsBuiltInSections.UserProfileToken;
private static readonly object Lock = new object();
private static bool _resolved;
@@ -0,0 +1,96 @@
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Invariants of the built-in diagnostics catalog (<see cref="DiagnosticsBuiltInSections"/>).
/// Shared has no test project, so the catalog is pinned here: the agent iterates it to build the
/// archive and the backend serves it to the portal, so a malformed entry would either break
/// collection on every device or mislead every administrator reading the list.
/// </summary>
public class DiagnosticsBuiltInSectionsTests
{
[Fact]
public void SectionIds_AreUnique()
{
var ids = DiagnosticsBuiltInSections.All.Select(s => s.Id).ToArray();
Assert.Equal(ids.Length, ids.Distinct(StringComparer.Ordinal).Count());
}
[Fact]
public void ZipFolderAndSourceFolderPairs_AreUnique()
{
// Two sections may share a ZIP folder (ImeLogs + the bootstrapper evtx) only when they
// read from different source folders — the same (zip, source) pair would double-add
// every file under duplicate entry names.
var pairs = DiagnosticsBuiltInSections.All
.Select(s => $"{s.ZipFolder}|{s.SourceFolder}".ToLowerInvariant())
.ToArray();
Assert.Equal(pairs.Length, pairs.Distinct().Count());
}
[Fact]
public void AlwaysSections_KeepHistoricalArchiveLayoutAndOrder()
{
// Archive-layout lock: these five sections (and their order) are what every diagnostics
// ZIP has carried since the PR1-B forensics work; tooling and support habits rely on it.
var always = DiagnosticsBuiltInSections.All
.Where(s => s.Condition == DiagnosticsSectionCondition.Always)
.Select(s => s.Id)
.ToArray();
Assert.Equal(new[] { "AgentLogs", "ImeLogs", "AgentState", "AgentSpool", "AgentMarkers" }, always);
}
[Fact]
public void ConditionalSections_CarryTheExpectedGate()
{
foreach (var section in DiagnosticsBuiltInSections.All)
{
if (section.Id.StartsWith("RealmJoin", StringComparison.Ordinal))
Assert.Equal(DiagnosticsSectionCondition.RealmJoinWatcher, section.Condition);
if (section.Id == "ImeBootstrapperEventLog")
Assert.Equal(DiagnosticsSectionCondition.DevicePreparation, section.Condition);
}
Assert.Contains(DiagnosticsBuiltInSections.All, s => s.Id == "ImeBootstrapperEventLog");
Assert.Equal(5, DiagnosticsBuiltInSections.All.Count(s => s.Condition == DiagnosticsSectionCondition.RealmJoinWatcher));
}
[Fact]
public void UserProfileTokenSections_AreNeverAlwaysOn()
{
// The token resolves only once an interactive user exists; an Always section reading
// from the user profile would be skipped on every pre-desktop package and mislead the
// portal's "collected by every agent" wording.
var tokenSections = DiagnosticsBuiltInSections.All
.Where(s => s.SourceFolder.Contains(DiagnosticsBuiltInSections.UserProfileToken, StringComparison.OrdinalIgnoreCase))
.ToArray();
Assert.NotEmpty(tokenSections);
Assert.All(tokenSections, s => Assert.NotEqual(DiagnosticsSectionCondition.Always, s.Condition));
}
[Fact]
public void Patterns_AreFileNamesWithoutPathSeparators()
{
Assert.All(DiagnosticsBuiltInSections.All, s =>
{
Assert.NotEmpty(s.Patterns);
Assert.All(s.Patterns, p =>
{
Assert.False(string.IsNullOrWhiteSpace(p));
Assert.DoesNotContain('\\', p);
Assert.DoesNotContain('/', p);
});
Assert.False(string.IsNullOrWhiteSpace(s.Description));
Assert.False(s.ZipFolder.StartsWith("/") || s.ZipFolder.EndsWith("/"));
Assert.DoesNotContain('\\', s.ZipFolder);
});
}
[Fact]
public void NoBuiltInSection_UsesTheConfiguredPathsZipFolder()
{
// AdditionalLogs/<folder> is reserved for admin-configured entries.
Assert.All(DiagnosticsBuiltInSections.All,
s => Assert.False(s.ZipFolder.StartsWith("AdditionalLogs", StringComparison.OrdinalIgnoreCase)));
}
}
@@ -0,0 +1,124 @@
using System.Text.Json;
using AutopilotMonitor.Functions.Services.Notifications;
using AutopilotMonitor.Shared.Models.Notifications;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Discord embed rendering: payload shape, color mapping, and the Discord embed limits
/// (25 fields, 256/1024 name/value, 4096 description, 6000 total).
/// </summary>
public class DiscordRendererTests
{
private static JsonElement RenderEmbed(NotificationAlert alert)
{
var json = new DiscordRenderer().RenderToJson(alert);
var doc = JsonDocument.Parse(json);
var embeds = doc.RootElement.GetProperty("embeds");
Assert.Equal(1, embeds.GetArrayLength());
return embeds[0];
}
[Fact]
public void RendersSingleEmbed_WithTitleColorFieldsAndLinks()
{
var alert = new NotificationAlert
{
Title = "Enrollment Succeeded",
Summary = "Enrollment Succeeded: DESK-001",
Severity = NotificationSeverity.Success,
ThemeColor = "00B050",
Facts = { new NotificationFact { Name = "Device", Value = "DESK-001" } },
Sections = { new NotificationSection { Title = "Details", Text = "All apps installed." } },
Actions = { new NotificationAction { Type = "openUrl", Title = "Open session", Url = "https://portal.example.com/sessions/abc" } },
};
var embed = RenderEmbed(alert);
Assert.Equal("✅ Enrollment Succeeded", embed.GetProperty("title").GetString());
Assert.Equal(0x00B050, embed.GetProperty("color").GetInt32());
var description = embed.GetProperty("description").GetString()!;
Assert.StartsWith("Enrollment Succeeded: DESK-001", description);
Assert.Contains("**Details**\nAll apps installed.", description);
// Webhooks cannot post buttons — actions become markdown links.
Assert.Contains("[Open session](https://portal.example.com/sessions/abc)", description);
var fields = embed.GetProperty("fields");
Assert.Equal(1, fields.GetArrayLength());
Assert.Equal("Device", fields[0].GetProperty("name").GetString());
Assert.Equal("DESK-001", fields[0].GetProperty("value").GetString());
Assert.True(fields[0].GetProperty("inline").GetBoolean());
}
[Theory]
[InlineData(NotificationSeverity.Success, 0x2ECC71)]
[InlineData(NotificationSeverity.Error, 0xE74C3C)]
[InlineData(NotificationSeverity.Warning, 0xF1C40F)]
[InlineData(NotificationSeverity.Info, 0x3498DB)]
public void Color_FallsBackToSeverity_WhenThemeColorMissingOrInvalid(NotificationSeverity severity, int expected)
{
foreach (var themeColor in new[] { "", "not-hex" })
{
var embed = RenderEmbed(new NotificationAlert { Title = "T", Severity = severity, ThemeColor = themeColor });
Assert.Equal(expected, embed.GetProperty("color").GetInt32());
}
}
[Fact]
public void Fields_CappedAt25_AndEmptyFactsSkipped()
{
var alert = new NotificationAlert { Title = "T", Summary = "S" };
alert.Facts.Add(new NotificationFact { Name = "", Value = "dropped" });
alert.Facts.Add(new NotificationFact { Name = "dropped", Value = " " });
for (var i = 0; i < 30; i++)
alert.Facts.Add(new NotificationFact { Name = $"f{i}", Value = "v" });
var fields = RenderEmbed(alert).GetProperty("fields");
Assert.Equal(25, fields.GetArrayLength());
Assert.Equal("f0", fields[0].GetProperty("name").GetString()); // empty facts skipped, not counted
}
[Fact]
public void FieldValue_TruncatedTo1024_WithEllipsis()
{
var alert = new NotificationAlert { Title = "T" };
alert.Facts.Add(new NotificationFact { Name = "long", Value = new string('x', 2000) });
var value = RenderEmbed(alert).GetProperty("fields")[0].GetProperty("value").GetString()!;
Assert.Equal(1024, value.Length);
Assert.EndsWith("…", value);
}
[Fact]
public void TotalEmbedLength_CappedAt6000_ByDroppingTrailingFields()
{
var alert = new NotificationAlert { Title = "T", Summary = "S" };
for (var i = 0; i < 25; i++)
alert.Facts.Add(new NotificationFact { Name = $"f{i}", Value = new string('v', 1000) });
var embed = RenderEmbed(alert);
var fields = embed.GetProperty("fields");
var total = embed.GetProperty("title").GetString()!.Length
+ embed.GetProperty("description").GetString()!.Length;
for (var i = 0; i < fields.GetArrayLength(); i++)
total += fields[i].GetProperty("name").GetString()!.Length + fields[i].GetProperty("value").GetString()!.Length;
Assert.True(fields.GetArrayLength() < 25);
Assert.True(total <= 6000);
Assert.Equal("f0", fields[0].GetProperty("name").GetString()); // leading fields kept
}
[Fact]
public void EmptyDescription_Omitted_AndProviderTypeIsDiscord()
{
var embed = RenderEmbed(new NotificationAlert { Title = "T", Severity = NotificationSeverity.Info });
Assert.False(embed.TryGetProperty("description", out _));
Assert.False(embed.TryGetProperty("fields", out _));
Assert.Equal(WebhookProviderType.Discord, new DiscordRenderer().ProviderType);
}
}
@@ -0,0 +1,315 @@
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Coverage for the provider-neutral <see cref="EmailService"/> (welcome + farewell mails).
/// The provider HTTP call is driven through a stub handler, so the Mandrill wire format,
/// the result-array interpretation and the fail-soft contract are pinned without network.
/// <list type="bullet">
/// <item>Neither send method ever throws — not on a missing key, missing recipient,
/// HTTP 500, a rejected recipient, nor a malformed body.</item>
/// <item>Gate ordering: missing-key short-circuits before missing-recipient.</item>
/// <item>Tracking is off on the wire (trust-page claim: provider receives only the
/// recipient address and tenant domain).</item>
/// <item>The farewell template is final: no [DRAFT]/placeholder residue, domain is
/// interpolated, empty domain falls back to "your organization".</item>
/// </list>
/// </summary>
public sealed class EmailServiceTests
{
private const string TenantId = "88888888-8888-8888-8888-888888888888";
// ----- configuration gates -----
[Fact]
public async Task SendAsync_EmptyApiKey_NoOps_AndLogsMissingKeyDebugLine()
{
var (sut, handler, logger) = Build(apiKey: "");
await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId, CancellationToken.None);
Assert.Equal(0, handler.CallCount);
Assert.Contains(logger.Entries,
e => e.Level == LogLevel.Debug && e.Message.Contains("Email:ApiKey not configured"));
}
[Fact]
public async Task SendAsync_EmptyRecipient_NoOps_AndLogsMissingEmailDebugLine()
{
var (sut, handler, logger) = Build(apiKey: "any-non-empty-key");
await sut.SendAsync("", "contoso.invalid", TenantId);
Assert.Equal(0, handler.CallCount);
Assert.Contains(logger.Entries,
e => e.Level == LogLevel.Debug && e.Message.Contains("No notification email captured"));
}
[Fact]
public async Task SendAsync_EmptyApiKey_ShortCircuitsBeforeRecipientCheck()
{
// Pins the gate ordering: missing-key > missing-email. Both are empty here; only
// the key line must show up.
var (sut, _, logger) = Build(apiKey: "");
await sut.SendAsync("", "contoso.invalid", TenantId);
Assert.Contains(logger.Entries,
e => e.Level == LogLevel.Debug && e.Message.Contains("Email:ApiKey not configured"));
Assert.DoesNotContain(logger.Entries,
e => e.Message.Contains("No notification email captured"));
}
[Fact]
public async Task SendPreviewApprovedEmailAsync_EmptyApiKey_NoOps()
{
var (sut, handler, logger) = Build(apiKey: "");
await sut.SendPreviewApprovedEmailAsync("it@contoso.invalid", "contoso.invalid");
Assert.Equal(0, handler.CallCount);
Assert.Contains(logger.Entries,
e => e.Level == LogLevel.Debug && e.Message.Contains("Email:ApiKey not configured"));
}
// ----- wire format -----
[Fact]
public async Task SendPreviewApprovedEmailAsync_PostsMandrillPayload_WithTrackingOff()
{
var (sut, handler, logger) = Build(apiKey: "md-test-key");
await sut.SendPreviewApprovedEmailAsync("it@contoso.invalid", "contoso.invalid");
Assert.Equal(1, handler.CallCount);
Assert.Equal(HttpMethod.Post, handler.LastRequest!.Method);
Assert.Equal(EmailService.DefaultEndpoint, handler.LastRequest.RequestUri!.ToString());
using var doc = JsonDocument.Parse(handler.LastBody!);
var root = doc.RootElement;
Assert.Equal("md-test-key", root.GetProperty("key").GetString());
var message = root.GetProperty("message");
Assert.Equal(EmailService.DefaultFromAddress, message.GetProperty("from_email").GetString());
Assert.Equal(EmailService.DefaultFromName, message.GetProperty("from_name").GetString());
Assert.Equal("it@contoso.invalid", message.GetProperty("to")[0].GetProperty("email").GetString());
Assert.Equal("to", message.GetProperty("to")[0].GetProperty("type").GetString());
Assert.Equal(EmailTemplates.PreviewApprovedSubject, message.GetProperty("subject").GetString());
Assert.Contains("contoso.invalid", message.GetProperty("html").GetString());
Assert.True(message.GetProperty("auto_text").GetBoolean());
Assert.False(message.GetProperty("track_opens").GetBoolean());
Assert.False(message.GetProperty("track_clicks").GetBoolean());
Assert.Equal("welcome", message.GetProperty("tags")[0].GetString());
Assert.Contains(logger.Entries,
e => e.Level == LogLevel.Information && e.Message.Contains("Preview approval email sent"));
}
[Fact]
public async Task SendAsync_UsesFarewellTemplate_AndFarewellTag()
{
var (sut, handler, logger) = Build(apiKey: "md-test-key");
await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId);
using var doc = JsonDocument.Parse(handler.LastBody!);
var message = doc.RootElement.GetProperty("message");
Assert.Equal(EmailTemplates.OffboardingFarewellSubject, message.GetProperty("subject").GetString());
Assert.Equal("offboarding-farewell", message.GetProperty("tags")[0].GetString());
Assert.Contains(logger.Entries,
e => e.Level == LogLevel.Information && e.Message.Contains("Offboard farewell email sent"));
}
[Fact]
public async Task ConfiguredEndpointAndSender_OverrideDefaults()
{
var (sut, handler, _) = Build(apiKey: "md-test-key", extra: new Dictionary<string, string?>
{
["Email:Endpoint"] = "https://mail.example.invalid/send",
["Email:FromAddress"] = "hello@example.invalid",
["Email:FromName"] = "Example Sender",
});
await sut.SendPreviewApprovedEmailAsync("it@contoso.invalid", "contoso.invalid");
Assert.Equal("https://mail.example.invalid/send", handler.LastRequest!.RequestUri!.ToString());
using var doc = JsonDocument.Parse(handler.LastBody!);
var message = doc.RootElement.GetProperty("message");
Assert.Equal("hello@example.invalid", message.GetProperty("from_email").GetString());
Assert.Equal("Example Sender", message.GetProperty("from_name").GetString());
}
// ----- provider responses / fail-soft -----
[Fact]
public async Task ProviderHttpError_DoesNotThrow_AndLogsWarning_WithoutSuccessLine()
{
var (sut, _, logger) = Build(apiKey: "md-test-key",
responder: _ => Json(HttpStatusCode.InternalServerError,
"{\"status\":\"error\",\"code\":-1,\"name\":\"Invalid_Key\",\"message\":\"Invalid API key\"}"));
await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId);
Assert.Contains(logger.Entries,
e => e.Level == LogLevel.Warning && e.Message.Contains("500") && e.Message.Contains("Invalid_Key"));
Assert.DoesNotContain(logger.Entries, e => e.Level == LogLevel.Information);
}
[Fact]
public async Task ProviderRejectsRecipient_LogsWarningWithReason_WithoutSuccessLine()
{
var (sut, _, logger) = Build(apiKey: "md-test-key",
responder: _ => Json(HttpStatusCode.OK,
"[{\"email\":\"ops@contoso.invalid\",\"status\":\"rejected\",\"reject_reason\":\"hard-bounce\",\"_id\":\"abc\"}]"));
await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId);
Assert.Contains(logger.Entries,
e => e.Level == LogLevel.Warning && e.Message.Contains("rejected") && e.Message.Contains("hard-bounce"));
Assert.DoesNotContain(logger.Entries, e => e.Level == LogLevel.Information);
}
[Theory]
[InlineData("sent")]
[InlineData("queued")]
[InlineData("scheduled")]
public async Task ProviderAcceptedStatuses_CountAsSuccess(string status)
{
var (sut, _, logger) = Build(apiKey: "md-test-key",
responder: _ => Json(HttpStatusCode.OK,
$"[{{\"email\":\"ops@contoso.invalid\",\"status\":\"{status}\",\"_id\":\"abc\"}}]"));
await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId);
Assert.Contains(logger.Entries, e => e.Level == LogLevel.Information);
Assert.DoesNotContain(logger.Entries, e => e.Level == LogLevel.Warning);
}
[Fact]
public async Task MalformedProviderBody_DoesNotThrow_AndLogsWarning()
{
var (sut, _, logger) = Build(apiKey: "md-test-key",
responder: _ => Json(HttpStatusCode.OK, "not json"));
await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId);
Assert.Contains(logger.Entries, e => e.Level == LogLevel.Warning);
Assert.DoesNotContain(logger.Entries, e => e.Level == LogLevel.Information);
}
[Fact]
public async Task TransportException_DoesNotThrow_AndLogsWarning()
{
var (sut, _, logger) = Build(apiKey: "md-test-key",
responder: _ => throw new HttpRequestException("simulated provider outage"));
await sut.SendPreviewApprovedEmailAsync("it@contoso.invalid", "contoso.invalid");
Assert.Contains(logger.Entries,
e => e.Level == LogLevel.Warning && e.Message.Contains("Failed to send welcome mail"));
}
// ----- templates -----
[Fact]
public void FarewellTemplate_IsFinal_NoDraftResidue()
{
Assert.DoesNotContain("DRAFT", EmailTemplates.OffboardingFarewellSubject);
var html = EmailTemplates.GetOffboardingFarewellHtml("contoso.invalid");
Assert.DoesNotContain("DRAFT", html);
Assert.DoesNotContain("TEMPLATE NOT FINALISED", html);
Assert.DoesNotContain("TODO", html);
}
[Fact]
public void FarewellTemplate_InterpolatesDomain_AndLinksFeedbackChannels()
{
var html = EmailTemplates.GetOffboardingFarewellHtml("contoso.invalid");
Assert.Contains("contoso.invalid", html);
// The mail is sent from a noreply address after portal access is gone, so the
// feedback pointers must be external channels the recipient can actually reach.
Assert.Contains("https://github.com/okieselbach/AutopilotMonitor/issues", html);
Assert.Contains("linkedin.com", html);
}
[Fact]
public void FarewellTemplate_EmptyDomain_FallsBackToGenericLabel()
{
var html = EmailTemplates.GetOffboardingFarewellHtml("");
Assert.Contains("your organization", html);
}
// ----- harness -----
private static HttpResponseMessage Json(HttpStatusCode status, string body)
=> new(status) { Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") };
private static (EmailService sut, StubHandler handler, CapturingLogger<EmailService> logger) Build(
string apiKey,
Dictionary<string, string?>? extra = null,
System.Func<HttpRequestMessage, HttpResponseMessage>? responder = null)
{
var settings = new Dictionary<string, string?> { ["Email:ApiKey"] = apiKey };
if (extra is not null)
foreach (var kv in extra) settings[kv.Key] = kv.Value;
var config = new ConfigurationBuilder().AddInMemoryCollection(settings).Build();
var handler = new StubHandler();
if (responder is not null) handler.Responder = responder;
var logger = new CapturingLogger<EmailService>();
var templates = new EmailTemplateService(
Moq.Mock.Of<AutopilotMonitor.Shared.DataAccess.IConfigRepository>(),
new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()),
Microsoft.Extensions.Logging.Abstractions.NullLogger<EmailTemplateService>.Instance);
var sut = new EmailService(new HttpClient(handler, disposeHandler: false), config, templates, logger);
return (sut, handler, logger);
}
private sealed class StubHandler : HttpMessageHandler
{
public int CallCount { get; private set; }
public HttpRequestMessage? LastRequest { get; private set; }
public string? LastBody { get; private set; }
public System.Func<HttpRequestMessage, HttpResponseMessage> Responder { get; set; } = _ =>
Json(HttpStatusCode.OK, "[{\"email\":\"x@y.invalid\",\"status\":\"sent\",\"_id\":\"abc\"}]");
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
CallCount++;
LastRequest = request;
LastBody = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken);
return Responder(request);
}
}
private sealed class CapturingLogger<T> : ILogger<T>
{
public readonly List<(LogLevel Level, string Message)> Entries = new();
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel, EventId eventId, TState state, System.Exception? exception,
System.Func<TState, System.Exception?, string> formatter)
{
Entries.Add((logLevel, formatter(state, exception)));
}
private sealed class NullScope : IDisposable
{
public static readonly NullScope Instance = new();
public void Dispose() { }
}
}
}
@@ -0,0 +1,131 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pins the operator-override layer over the built-in transactional templates:
/// override wins when stored, built-in otherwise; placeholder rendering matches the
/// built-ins' empty-domain fallback; the send path is fail-soft on storage errors while the
/// admin path is not; save validates and invalidates the cache; reset is idempotent.
/// </summary>
public sealed class EmailTemplateServiceTests
{
[Fact]
public async Task NoOverride_ReturnsBuiltIn_Rendered()
{
var (sut, _) = Build();
var html = await sut.GetHtmlAsync(EmailTemplateKind.Welcome, "contoso.invalid");
Assert.Equal(EmailTemplates.GetPreviewApprovedHtml("contoso.invalid"), html);
Assert.DoesNotContain(EmailTemplateService.DomainPlaceholder, html);
}
[Fact]
public async Task Override_WinsAndRendersPlaceholder()
{
var (sut, repo) = Build();
repo.Setup(r => r.GetEmailTemplateOverrideAsync("farewell"))
.ReturnsAsync(new EmailTemplateOverride { Kind = "farewell", Html = "<p>Bye {{domainName}}</p>" });
var html = await sut.GetHtmlAsync(EmailTemplateKind.Farewell, "contoso.invalid");
Assert.Equal("<p>Bye contoso.invalid</p>", html);
}
[Fact]
public async Task Override_EmptyDomain_FallsBackToGenericLabel_LikeBuiltIn()
{
var (sut, repo) = Build();
repo.Setup(r => r.GetEmailTemplateOverrideAsync("welcome"))
.ReturnsAsync(new EmailTemplateOverride { Kind = "welcome", Html = "Hi {{domainName}}" });
Assert.Equal("Hi your organization", await sut.GetHtmlAsync(EmailTemplateKind.Welcome, ""));
}
[Fact]
public async Task StorageError_SendPath_FallsBackToBuiltIn_AdminPath_Throws()
{
var (sut, repo) = Build();
repo.Setup(r => r.GetEmailTemplateOverrideAsync(It.IsAny<string>()))
.ThrowsAsync(new System.InvalidOperationException("storage down"));
var html = await sut.GetHtmlAsync(EmailTemplateKind.Welcome, "contoso.invalid");
Assert.Contains("contoso.invalid", html);
await Assert.ThrowsAsync<System.InvalidOperationException>(() => sut.GetOverrideAsync(EmailTemplateKind.Welcome));
}
[Fact]
public async Task Save_Validates_ThenInvalidatesCache()
{
var (sut, repo) = Build();
repo.Setup(r => r.GetEmailTemplateOverrideAsync("welcome")).ReturnsAsync((EmailTemplateOverride?)null);
// warm the cache with "no override"
Assert.Null(await sut.GetOverrideAsync(EmailTemplateKind.Welcome));
await Assert.ThrowsAsync<System.ArgumentException>(() => sut.SaveOverrideAsync(EmailTemplateKind.Welcome, " ", "ga@contoso.invalid"));
await Assert.ThrowsAsync<System.ArgumentException>(() =>
sut.SaveOverrideAsync(EmailTemplateKind.Welcome, new string('x', EmailTemplateService.MaxHtmlLength + 1), "ga@contoso.invalid"));
repo.Verify(r => r.SaveEmailTemplateOverrideAsync(It.IsAny<EmailTemplateOverride>()), Times.Never);
EmailTemplateOverride? stored = null;
repo.Setup(r => r.SaveEmailTemplateOverrideAsync(It.IsAny<EmailTemplateOverride>()))
.Callback<EmailTemplateOverride>(e => stored = e).Returns(Task.CompletedTask);
repo.Setup(r => r.GetEmailTemplateOverrideAsync("welcome")).ReturnsAsync(() => stored);
var saved = await sut.SaveOverrideAsync(EmailTemplateKind.Welcome, "<p>{{domainName}}</p>", "ga@contoso.invalid");
Assert.Equal("welcome", saved.Kind);
Assert.Equal("ga@contoso.invalid", saved.UpdatedBy);
// cache was invalidated: the next read sees the stored override, not the cached null
Assert.NotNull(await sut.GetOverrideAsync(EmailTemplateKind.Welcome));
Assert.Equal("<p>contoso.invalid</p>", await sut.GetHtmlAsync(EmailTemplateKind.Welcome, "contoso.invalid"));
}
[Fact]
public async Task Delete_ClearsCache_ReturnsToBuiltIn()
{
var (sut, repo) = Build();
var stored = new EmailTemplateOverride { Kind = "farewell", Html = "custom {{domainName}}" };
repo.Setup(r => r.GetEmailTemplateOverrideAsync("farewell")).ReturnsAsync(() => stored);
Assert.Equal("custom contoso.invalid", await sut.GetHtmlAsync(EmailTemplateKind.Farewell, "contoso.invalid"));
repo.Setup(r => r.DeleteEmailTemplateOverrideAsync("farewell")).Callback(() => stored = null!).Returns(Task.CompletedTask);
await sut.DeleteOverrideAsync(EmailTemplateKind.Farewell, "ga@contoso.invalid");
Assert.Equal(EmailTemplates.GetOffboardingFarewellHtml("contoso.invalid"),
await sut.GetHtmlAsync(EmailTemplateKind.Farewell, "contoso.invalid"));
}
[Theory]
[InlineData("welcome", true)]
[InlineData("Farewell", true)]
[InlineData("invoice", false)]
[InlineData(null, false)]
public void TryParseKind_AcceptsOnlyKnownKinds(string? value, bool expected)
=> Assert.Equal(expected, EmailTemplateService.TryParseKind(value, out _));
[Fact]
public void BuiltInRaw_ContainsPlaceholder_ForBothKinds()
{
Assert.Contains(EmailTemplateService.DomainPlaceholder, EmailTemplateService.BuiltInRaw(EmailTemplateKind.Welcome));
Assert.Contains(EmailTemplateService.DomainPlaceholder, EmailTemplateService.BuiltInRaw(EmailTemplateKind.Farewell));
}
private static (EmailTemplateService sut, Mock<IConfigRepository> repo) Build()
{
var repo = new Mock<IConfigRepository>(MockBehavior.Loose);
var sut = new EmailTemplateService(repo.Object, new MemoryCache(new MemoryCacheOptions()), NullLogger<EmailTemplateService>.Instance);
return (sut, repo);
}
}
@@ -191,6 +191,21 @@ public class EndpointPolicyCatalogCompletenessTests
Assert.Equal(EndpointPolicy.GlobalAdminOnly, reseed.Policy);
}
/// <summary>
/// The built-in diagnostics catalog + global paths are platform-wide, secret-free data that
/// every settings viewer needs (what is collected from the tenant's devices). Member tier,
/// JWT-scoped — an upgrade to a global tier would hide it from tenant admins again.
/// </summary>
[Fact]
public void DiagnosticsPaths_IsMemberReadJwtScoped()
{
var entry = EndpointAccessPolicyCatalog.FindPolicy("GET", "/api/diagnostics/paths");
Assert.NotNull(entry);
Assert.Equal(EndpointPolicy.MemberRead, entry.Policy);
Assert.Equal(TenantScoping.Jwt, entry.TenantScoping);
}
/// <summary>
/// Unregistered routes must return null (fail-closed).
/// </summary>
@@ -471,4 +471,148 @@ public class EnrollmentTimeoutClassifierTests
Assert.NotEqual(SessionStatus.Failed, status);
}
}
// -------- WhiteGlove Part-2 awaiting-user gate (fairstone.ca analysis 2026-08-21) --------
private static readonly DateTime Resumed = Start.AddMinutes(20);
private static (SessionStatus, string) ClassifyWhiteGlove(
IReadOnlyList<EnrollmentEvent> events, double hoursSinceStart = 6, int grace = 51)
{
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(events);
var now = Start.AddHours(hoursSinceStart);
return EnrollmentTimeoutClassifier.ClassifyTimedOutSession(
rollup, Start, now, grace, isPreProvisioned: true, resumedAt: Resumed);
}
/// <summary>The fairstone event shape: Part 1 sealed, Part 2 resumed, nobody signed in.</summary>
private static EnrollmentEvent[] WgParkedEvents() => new[]
{
Esp(DeviceSetup44), Evt("whiteglove_complete"), Esp(AccountSetup05),
};
[Fact]
public void ExtractRollup_separates_enrollment_complete_from_whiteglove_complete()
{
var wgOnly = EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("whiteglove_complete") });
Assert.True(wgOnly.HasTerminalComplete);
Assert.False(wgOnly.HasEnrollmentComplete);
var real = EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("enrollment_complete") });
Assert.True(real.HasTerminalComplete);
Assert.True(real.HasEnrollmentComplete);
}
[Fact]
public void WhiteGloveGate_requires_preprovisioned_and_resume_and_device_setup()
{
var parked = EnrollmentTimeoutClassifier.ExtractRollup(WgParkedEvents());
Assert.True(EnrollmentTimeoutClassifier.IsWhiteGloveAwaitingUser(parked, true, Resumed));
Assert.False(EnrollmentTimeoutClassifier.IsWhiteGloveAwaitingUser(parked, false, Resumed));
Assert.False(EnrollmentTimeoutClassifier.IsWhiteGloveAwaitingUser(parked, true, null));
// Powered off before Device Setup ever provisioned → not the parking state.
var early = EnrollmentTimeoutClassifier.ExtractRollup(new[]
{
Esp("ESP provisioning status: DeviceSetup — 3 of 4 subcategories completed"),
});
Assert.False(EnrollmentTimeoutClassifier.IsWhiteGloveAwaitingUser(early, true, Resumed));
}
[Fact]
public void WhiteGloveGate_any_user_evidence_or_terminal_disarms_it()
{
foreach (var evidence in new[]
{
Esp(AccountSetup15), // Account Setup progressed → user was there
Evt("desktop_arrived"), // real-user desktop
Evt("hello_provisioning_completed"), // positive Hello terminal
Evt("enrollment_complete"), // real completion
Evt("enrollment_failed"), // explicit failure
})
{
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(new[]
{
Esp(DeviceSetup44), Evt("whiteglove_complete"), Esp(AccountSetup05), evidence,
});
Assert.False(EnrollmentTimeoutClassifier.IsWhiteGloveAwaitingUser(rollup, true, Resumed));
}
}
[Fact]
public void Classify_whiteglove_part2_parked_within_grace_is_AwaitingUser()
{
// The fairstone shape: technician powers the device off at the logon screen minutes
// after the reseal-reboot. Part-1 whiteglove_complete must NOT reconcile to Succeeded.
var (status, reason) = ClassifyWhiteGlove(WgParkedEvents(), hoursSinceStart: 6);
Assert.Equal(SessionStatus.AwaitingUser, status);
Assert.Contains("sealed or powered off awaiting the end user", reason);
Assert.Contains("(Account Setup 0/5)", reason);
}
[Fact]
public void Classify_whiteglove_part2_parked_past_grace_reconciles_with_honest_reason()
{
var (status, reason) = ClassifyWhiteGlove(WgParkedEvents(), hoursSinceStart: 60, grace: 51);
Assert.Equal(SessionStatus.Succeeded, status);
Assert.Contains("pre-provisioning (WhiteGlove Part 1) completed", reason);
Assert.DoesNotContain("Account Setup completed", reason);
}
[Fact]
public void Classify_whiteglove_part2_with_partial_user_progress_is_not_reconciled_on_part1_evidence()
{
// User signed in and Account Setup progressed to 1/5, then silence. The Part-1
// whiteglove_complete previously reconciled this to Succeeded claiming "Account Setup
// completed" — on a resumed session only a real enrollment_complete proves the outcome,
// so this honest shape falls through to the AwaitingUser/Incomplete rules.
var events = new[]
{
Esp(DeviceSetup44), Evt("whiteglove_complete"), Esp(AccountSetup15),
};
var (withinGrace, _) = ClassifyWhiteGlove(events, hoursSinceStart: 6);
Assert.Equal(SessionStatus.AwaitingUser, withinGrace);
var (pastGrace, reason) = ClassifyWhiteGlove(events, hoursSinceStart: 60, grace: 51);
Assert.Equal(SessionStatus.Incomplete, pastGrace);
Assert.Contains("1/5", reason);
}
[Fact]
public void Classify_whiteglove_part2_with_desktop_and_hello_still_reconciles_via_user_evidence()
{
// Both Classic completion prerequisites in → the user provably finished; the
// user-completed reconcile (rule 4) owns this, not the parking gate.
var (status, reason) = ClassifyWhiteGlove(new[]
{
Esp(DeviceSetup44), Evt("whiteglove_complete"), Esp(AccountSetup15),
Evt("desktop_arrived"), Evt("hello_provisioning_completed"),
}, hoursSinceStart: 6);
Assert.Equal(SessionStatus.Succeeded, status);
Assert.Contains("user completed setup", reason);
}
[Fact]
public void Classify_whiteglove_part2_real_enrollment_complete_still_reconciles_to_Succeeded()
{
var (status, _) = ClassifyWhiteGlove(new[]
{
Esp(DeviceSetup44), Evt("whiteglove_complete"), Esp(AccountSetup05), Evt("enrollment_complete"),
}, hoursSinceStart: 6);
Assert.Equal(SessionStatus.Succeeded, status);
}
[Fact]
public void Classify_unresumed_session_keeps_terminal_complete_reconcile()
{
// No resumedAt (plain sessions, or a sealed Part 1 whose Pending write was lost):
// whiteglove_complete keeps reconciling to Succeeded exactly as before.
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(new[]
{
Esp(DeviceSetup44), Evt("whiteglove_complete"), Esp(AccountSetup05),
});
var (status, _) = EnrollmentTimeoutClassifier.ClassifyTimedOutSession(
rollup, Start, Start.AddHours(6), 51, isPreProvisioned: true, resumedAt: null);
Assert.Equal(SessionStatus.Succeeded, status);
}
}
@@ -1,4 +1,5 @@
using AutopilotMonitor.Functions.Functions.Config;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Tests;
@@ -32,6 +33,44 @@ public class GetAgentConfigFunctionTests
Assert.Equal(expectedMajor, actual);
}
// ── MergeDiagnosticsLogPaths — global ∪ tenant without duplicate ZIP entries ──────────
private static DiagnosticsLogPath P(string path, string description = "") =>
new() { Path = path, Description = description };
[Fact]
public void MergeDiagnosticsLogPaths_GlobalFirst_OrderPreserved_DuplicatesDroppedCaseInsensitively()
{
var global = new[] { P(@"C:\Windows\Panther\setupact.log", "global"), P(@"C:\Windows\Logs\DISM\dism.log") };
var tenant = new[]
{
P(@"c:\windows\panther\SETUPACT.LOG", "tenant copy"), // same path, different case → dropped
P(@" C:\Windows\Logs\DISM\dism.log "), // trimmed duplicate → dropped
P(@"C:\Windows\SetupDiag\*.log", "tenant"),
};
var merged = GetAgentConfigFunction.MergeDiagnosticsLogPaths(global, tenant);
Assert.Equal(new[]
{
@"C:\Windows\Panther\setupact.log",
@"C:\Windows\Logs\DISM\dism.log",
@"C:\Windows\SetupDiag\*.log",
}, merged.Select(p => p.Path));
Assert.Equal("global", merged[0].Description); // first occurrence (global) wins
}
[Fact]
public void MergeDiagnosticsLogPaths_DropsBlankEntries()
{
var merged = GetAgentConfigFunction.MergeDiagnosticsLogPaths(
new[] { P(""), P(" "), P(@"C:\Install\Log\*.log") },
new DiagnosticsLogPath[] { null!, P(@"C:\Install\Log\*.log") });
Assert.Single(merged);
Assert.Equal(@"C:\Install\Log\*.log", merged[0].Path);
}
[Theory]
// CustomerSas: gated on the per-tenant SAS URL being present.
[InlineData("https://acct.blob.core.windows.net/c?sv=...", "CustomerSas", true)]
@@ -0,0 +1,70 @@
using System.Text.Json;
using AutopilotMonitor.Functions.Functions.Diagnostics;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Wire shape of <c>GET /api/diagnostics/paths</c> (<see cref="GetDiagnosticsPathsFunction.BuildPayload"/>).
/// The route is MemberRead, so the field set is locked: the payload must stay "what is
/// collected" — never grow admin-only config.
/// </summary>
public class GetDiagnosticsPathsPayloadTests
{
// Mirrors the worker's serializer (Program.cs: PropertyNamingPolicy = CamelCase) so the
// nested DiagnosticsLogPath class serializes the way the portal actually receives it.
private static readonly JsonSerializerOptions WorkerJson = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
private static JsonElement Serialize(AdminConfiguration config)
{
var json = JsonSerializer.Serialize(GetDiagnosticsPathsFunction.BuildPayload(config), WorkerJson);
return JsonDocument.Parse(json).RootElement;
}
[Fact]
public void Payload_HasExactlyBuiltInAndGlobalPaths()
{
var element = Serialize(new AdminConfiguration());
var fieldNames = element.EnumerateObject().Select(p => p.Name).OrderBy(n => n).ToArray();
Assert.Equal(new[] { "builtIn", "globalPaths" }, fieldNames);
Assert.Equal(JsonValueKind.Array, element.GetProperty("globalPaths").ValueKind);
Assert.Equal(0, element.GetProperty("globalPaths").GetArrayLength());
}
[Fact]
public void BuiltIn_MirrorsTheCatalog_WithConditionAsName()
{
var builtIn = Serialize(new AdminConfiguration()).GetProperty("builtIn");
Assert.Equal(DiagnosticsBuiltInSections.All.Count, builtIn.GetArrayLength());
var first = builtIn[0];
Assert.Equal("AgentLogs", first.GetProperty("id").GetString());
Assert.Equal("AgentLogs", first.GetProperty("zipFolder").GetString());
Assert.Equal(AutopilotMonitor.Shared.Constants.LogDirectory, first.GetProperty("sourceFolder").GetString());
Assert.Equal(DiagnosticsBuiltInSections.LogFilePatterns.Length, first.GetProperty("patterns").GetArrayLength());
Assert.False(first.GetProperty("includeSubfolders").GetBoolean());
Assert.Equal("Always", first.GetProperty("condition").GetString());
var conditions = builtIn.EnumerateArray().Select(s => s.GetProperty("condition").GetString()).ToArray();
Assert.Contains("RealmJoinWatcher", conditions);
Assert.Contains("DevicePreparation", conditions);
Assert.All(conditions, c => Assert.False(int.TryParse(c, out _), "condition must serialize as the enum name"));
}
[Fact]
public void GlobalPaths_RoundTripTheStoredJson()
{
var config = new AdminConfiguration
{
DiagnosticsGlobalLogPathsJson =
"[{\"path\":\"C:\\\\Windows\\\\Panther\\\\setupact.log\",\"description\":\"Setup\",\"isBuiltIn\":true,\"includeSubfolders\":false}]",
};
var globalPaths = Serialize(config).GetProperty("globalPaths");
Assert.Equal(1, globalPaths.GetArrayLength());
Assert.Equal(@"C:\Windows\Panther\setupact.log", globalPaths[0].GetProperty("path").GetString());
Assert.Equal("Setup", globalPaths[0].GetProperty("description").GetString());
}
}
@@ -1,4 +1,5 @@
using AutopilotMonitor.Functions.Functions.Config;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Notifications;
@@ -64,6 +65,17 @@ public class NotificationChannelsTests
Assert.Empty(slack.GetCustomHeaders());
}
[Fact]
public void GetSigningSecret_OnlyForGenericJson()
{
var secret = "0123456789abcdef";
Assert.Equal(secret, new NotificationChannel { Id = "g", ProviderType = 20, SigningSecret = secret }.GetSigningSecret());
Assert.Null(new NotificationChannel { Id = "s", ProviderType = 10, SigningSecret = secret }.GetSigningSecret());
Assert.Null(new NotificationChannel { Id = "d", ProviderType = 30, SigningSecret = secret }.GetSigningSecret());
Assert.Null(new NotificationChannel { Id = "w", ProviderType = 20, SigningSecret = " " }.GetSigningSecret());
Assert.Null(new NotificationChannel { Id = "n", ProviderType = 20 }.GetSigningSecret());
}
// ── Legacy synthesis (GetNotificationChannels) ────────────────────────
[Fact]
@@ -141,7 +153,7 @@ public class NotificationChannelsTests
var cfg = new TenantConfiguration
{
NotificationChannelsJson = "[" +
ChannelJson("ch-1", extra: ",\"customHeadersJson\":\"{\\\"X-Api-Key\\\":\\\"k\\\"}\"") + "," +
ChannelJson("ch-1", extra: ",\"customHeadersJson\":\"{\\\"X-Api-Key\\\":\\\"k\\\"}\",\"signingSecret\":\"0123456789abcdef\"") + "," +
ChannelJson("ch-2", providerType: 10, url: "https://slack.example/h") + "]",
};
@@ -151,8 +163,10 @@ public class NotificationChannelsTests
Assert.Equal("ch-1", redacted[0].Id); // structure visible
Assert.Equal(Constants.RedactedSecretPlaceholder, redacted[0].Url); // secrets masked
Assert.Equal(Constants.RedactedSecretPlaceholder, redacted[0].CustomHeadersJson);
Assert.Equal(Constants.RedactedSecretPlaceholder, redacted[0].SigningSecret);
Assert.Equal(Constants.RedactedSecretPlaceholder, redacted[1].Url);
Assert.Null(redacted[1].CustomHeadersJson); // unset stays unset
Assert.Null(redacted[1].SigningSecret);
}
[Fact]
@@ -169,7 +183,7 @@ public class NotificationChannelsTests
{
NotificationChannelsJson = "[" +
ChannelJson("ch-1", url: "https://real.example/hook",
extra: ",\"customHeadersJson\":\"{\\\"X-Api-Key\\\":\\\"real\\\"}\"") + "]",
extra: ",\"customHeadersJson\":\"{\\\"X-Api-Key\\\":\\\"real\\\"}\",\"signingSecret\":\"real-secret-0123\"") + "]",
};
// Round-trip the redacted view back onto a save (defense-in-depth path).
@@ -179,6 +193,7 @@ public class NotificationChannelsTests
var ch = Assert.Single(NotificationChannel.ParseList(incoming.NotificationChannelsJson));
Assert.Equal("https://real.example/hook", ch.Url);
Assert.Equal("{\"X-Api-Key\":\"real\"}", ch.CustomHeadersJson);
Assert.Equal("real-secret-0123", ch.SigningSecret);
}
[Fact]
@@ -244,4 +259,40 @@ public class NotificationChannelsTests
Assert.NotNull(error);
Assert.Contains("headers", error);
}
// ── Signing-secret validation ─────────────────────────────────────────
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("0123456789abcdef")] // 16 chars (minimum)
public void ValidateWebhookSigningSecret_AcceptsAbsentOrValid(string? secret)
{
Assert.Null(TenantConfigValidation.ValidateWebhookSigningSecret(secret));
// 128 chars (maximum) and a typical generated 64-char hex secret
Assert.Null(TenantConfigValidation.ValidateWebhookSigningSecret(new string('a', 128)));
Assert.Null(TenantConfigValidation.ValidateWebhookSigningSecret(new string('f', 64)));
}
[Theory]
[InlineData("short-15-chars.")] // below minimum
[InlineData("has a space in the middle!")] // whitespace
[InlineData("line\nbreak-0123456789")] // control char
[InlineData("umlauts-äöü-0123456789")] // non-ASCII
[InlineData("***REDACTED***")] // unrestored sentinel (too short anyway)
public void ValidateWebhookSigningSecret_RejectsInvalid(string secret)
{
Assert.NotNull(TenantConfigValidation.ValidateWebhookSigningSecret(secret));
Assert.NotNull(TenantConfigValidation.ValidateWebhookSigningSecret(new string('a', 129)));
}
[Fact]
public void ValidateNotificationChannels_RejectsBadPerChannelSigningSecret()
{
var json = "[" + ChannelJson("ch-1", extra: ",\"signingSecret\":\"too-short\"") + "]";
var error = UpdateTenantConfigurationFunction.ValidateNotificationChannels(json);
Assert.NotNull(error);
Assert.Contains("signing secret", error);
}
}
@@ -7,7 +7,7 @@ namespace AutopilotMonitor.Functions.Tests.Offboarding;
/// <summary>
/// Test double for <see cref="IOffboardFarewellEmailSender"/>. Records every invocation so
/// the handler-side tests can assert "did the farewell send fire after History → Completed?"
/// without booting the real Resend client. Default = silent noop; set <see cref="ThrowOnSend"/>
/// without booting the real mail provider client. Default = silent noop; set <see cref="ThrowOnSend"/>
/// to simulate downstream failures and prove the handler's fail-soft contract.
/// </summary>
internal sealed class FakeOffboardFarewellEmailSender : IOffboardFarewellEmailSender
@@ -221,7 +221,7 @@ public class TenantOffboardingHandlerOrderingTests
public async Task PostDrain_FarewellEmail_Skipped_WhenNotificationEmailNull()
{
// Tenants that never set a preview-notification email get null capture; the handler
// skip-gates the send so no Resend call is even attempted.
// skip-gates the send so no provider call is even attempted.
var harness = Harness.New();
Assert.Null(harness.Repo.History[HistoryRowKey].NotificationEmail);
@@ -235,7 +235,7 @@ public class TenantOffboardingHandlerOrderingTests
public async Task PostDrain_FarewellEmail_Skipped_WhenNotificationEmailWhitespace()
{
// Defensive: legacy History rows may have a non-null empty/whitespace value. Treat
// these as "no email captured" so we never call Resend with a useless string.
// these as "no email captured" so we never call the mail provider with a useless string.
var harness = Harness.New();
harness.Repo.History[HistoryRowKey].NotificationEmail = " ";
@@ -248,11 +248,11 @@ public class TenantOffboardingHandlerOrderingTests
[Fact]
public async Task PostDrain_FarewellEmail_SenderThrows_HandlerSurvives_HistoryStillCompleted()
{
// The send is best-effort. A Resend outage / template bug / serializer crash must
// The send is best-effort. A mail-provider outage / template bug / serializer crash must
// NOT propagate to the queue worker — that would re-poison a Completed offboard.
var harness = Harness.New();
harness.Repo.History[HistoryRowKey].NotificationEmail = "ops@contoso.invalid";
harness.FarewellEmail.ThrowOnSend = new InvalidOperationException("simulated Resend outage");
harness.FarewellEmail.ThrowOnSend = new InvalidOperationException("simulated mail-provider outage");
await harness.Sut.HandleAsync(harness.Envelope());
@@ -0,0 +1,145 @@
using AutopilotMonitor.Functions.Functions.Config;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Tests for <see cref="PlanManagementFunction.ApplyPlanChanges"/> — the pure mutation core
/// of the PATCH plan endpoint. Pins the retention-grace anchor lifecycle: an EFFECTIVE
/// Pro→Community transition stamps <see cref="TenantConfiguration.ProDowngradedUtc"/>, any
/// effectively-Pro outcome clears it, and a planTier downgrade under an active trial does
/// NOT stamp (the edition is still Pro — the later trial expiry is the anchor then).
/// </summary>
public class PlanManagementTransitionTests
{
private static readonly DateTime Now = new(2026, 8, 22, 12, 0, 0, DateTimeKind.Utc);
private const string Caller = "ga@operator.example";
private static (TenantEdition Before, TenantEdition After, Dictionary<string, string> Changes) Apply(
TenantConfiguration config, string? planTier = null, bool trialProvided = false, DateTime? trialExpiresUtc = null)
{
var changes = new Dictionary<string, string>();
var (before, after) = PlanManagementFunction.ApplyPlanChanges(
config, planTier, trialProvided, trialExpiresUtc, Caller, Now, changes);
return (before, after, changes);
}
[Fact]
public void Downgrade_ProToCommunity_StampsAnchorAndRecordsChange()
{
var config = new TenantConfiguration { TenantId = "t1", PlanTier = "pro" };
var (before, after, changes) = Apply(config, planTier: "community");
Assert.Equal(TenantEdition.Pro, before);
Assert.Equal(TenantEdition.Community, after);
Assert.Equal(Now, config.ProDowngradedUtc);
Assert.True(changes.ContainsKey("ProDowngradedUtc"));
Assert.True(changes.ContainsKey("PlanTier"));
}
[Fact]
public void Upgrade_CommunityToPro_ClearsAnchor()
{
var config = new TenantConfiguration
{
TenantId = "t1",
PlanTier = "community",
ProDowngradedUtc = Now.AddDays(-10)
};
var (_, after, changes) = Apply(config, planTier: "pro");
Assert.Equal(TenantEdition.Pro, after);
Assert.Null(config.ProDowngradedUtc);
Assert.True(changes.ContainsKey("ProDowngradedUtc"));
}
[Fact]
public void Downgrade_WithActiveTrial_DoesNotStamp_EditionStaysPro()
{
// planTier drops but the trial keeps the EFFECTIVE edition Pro: no anchor now —
// the trial's own expiry timestamp anchors the grace later, read-time.
var config = new TenantConfiguration
{
TenantId = "t1",
PlanTier = "pro",
TrialExpiresUtc = Now.AddDays(5)
};
var (before, after, changes) = Apply(config, planTier: "community");
Assert.Equal(TenantEdition.Pro, before);
Assert.Equal(TenantEdition.Pro, after);
Assert.Null(config.ProDowngradedUtc);
Assert.False(changes.ContainsKey("ProDowngradedUtc"));
}
[Fact]
public void EndingTrialExplicitly_StampsAnchor()
{
// GA sets trialExpiresUtc: null on a trial-Pro tenant — TrialExpiresUtc is gone, so
// WITHOUT the explicit stamp the grace would have no anchor at all.
var config = new TenantConfiguration
{
TenantId = "t1",
PlanTier = "free",
TrialExpiresUtc = Now.AddDays(5)
};
var (before, after, _) = Apply(config, trialProvided: true, trialExpiresUtc: null);
Assert.Equal(TenantEdition.Pro, before);
Assert.Equal(TenantEdition.Community, after);
Assert.Equal(Now, config.ProDowngradedUtc);
Assert.Null(config.TrialExpiresUtc);
}
[Fact]
public void GrantingTrial_ToDowngradedTenant_ClearsAnchor()
{
var config = new TenantConfiguration
{
TenantId = "t1",
PlanTier = "community",
ProDowngradedUtc = Now.AddDays(-10)
};
var (_, after, _) = Apply(config, trialProvided: true, trialExpiresUtc: Now.AddDays(30));
Assert.Equal(TenantEdition.Pro, after);
Assert.Null(config.ProDowngradedUtc);
Assert.Equal(Caller, config.TrialGrantedBy);
}
[Fact]
public void NoOpPatch_SameTier_RecordsNothing()
{
var config = new TenantConfiguration { TenantId = "t1", PlanTier = "community" };
var (before, after, changes) = Apply(config, planTier: "community");
Assert.Equal(TenantEdition.Community, before);
Assert.Equal(TenantEdition.Community, after);
Assert.Empty(changes);
Assert.Null(config.ProDowngradedUtc);
}
[Fact]
public void RepeatedDowngradeAfterReUpgrade_RestampsWithNewTimestamp()
{
var config = new TenantConfiguration { TenantId = "t1", PlanTier = "pro" };
Apply(config, planTier: "community");
Assert.Equal(Now, config.ProDowngradedUtc);
Apply(config, planTier: "pro");
Assert.Null(config.ProDowngradedUtc);
var changes = new Dictionary<string, string>();
var later = Now.AddDays(40);
PlanManagementFunction.ApplyPlanChanges(config, "community", false, null, Caller, later, changes);
Assert.Equal(later, config.ProDowngradedUtc);
}
}
@@ -1,134 +0,0 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Coverage for the post-offboarding farewell-email send path. The actual Resend HTTP
/// call is not exercised here — every test drives one of the no-op gates. Pins:
/// <list type="bullet">
/// <item><see cref="ResendEmailService.SendAsync"/> never throws — not when the API key
/// is empty and not when the recipient is missing. The handler's fail-soft
/// try/catch is a belt; this is the suspenders.</item>
/// <item>Gate ordering: missing-key short-circuits before missing-recipient.</item>
/// <item>The farewell template is final: no [DRAFT]/placeholder residue, domain is
/// interpolated, empty domain falls back to "your organization".</item>
/// </list>
/// </summary>
public sealed class ResendEmailServiceTests
{
[Fact]
public async Task SendAsync_EmptyApiKey_NoOps_AndLogsMissingKeyDebugLine()
{
var logger = new CapturingLogger<ResendEmailService>();
var sut = Build(apiKey: "", logger);
await sut.SendAsync(
toEmail: "ops@contoso.invalid",
domainName: "contoso.invalid",
tenantId: "88888888-8888-8888-8888-888888888888",
ct: CancellationToken.None);
Assert.Contains(logger.Entries,
e => e.Level == LogLevel.Debug && e.Message.Contains("RESEND_API_KEY not configured"));
}
[Fact]
public async Task SendAsync_EmptyRecipient_NoOps_AndLogsMissingEmailDebugLine()
{
var logger = new CapturingLogger<ResendEmailService>();
var sut = Build(apiKey: "any-non-empty-key", logger);
await sut.SendAsync(
toEmail: "",
domainName: "contoso.invalid",
tenantId: "88888888-8888-8888-8888-888888888888");
Assert.Contains(logger.Entries,
e => e.Level == LogLevel.Debug && e.Message.Contains("No notification email captured"));
}
[Fact]
public async Task SendAsync_EmptyApiKey_ShortCircuitsBeforeRecipientCheck()
{
// Pins the gate ordering: missing-key > missing-email. Both are empty here; only
// the key line must show up.
var logger = new CapturingLogger<ResendEmailService>();
var sut = Build(apiKey: "", logger);
await sut.SendAsync(
toEmail: "",
domainName: "contoso.invalid",
tenantId: "88888888-8888-8888-8888-888888888888");
Assert.Contains(logger.Entries,
e => e.Level == LogLevel.Debug && e.Message.Contains("RESEND_API_KEY not configured"));
Assert.DoesNotContain(logger.Entries,
e => e.Message.Contains("No notification email captured"));
}
[Fact]
public void FarewellTemplate_IsFinal_NoDraftResidue()
{
Assert.DoesNotContain("DRAFT", EmailTemplates.OffboardingFarewellSubject);
var html = EmailTemplates.GetOffboardingFarewellHtml("contoso.invalid");
Assert.DoesNotContain("DRAFT", html);
Assert.DoesNotContain("TEMPLATE NOT FINALISED", html);
Assert.DoesNotContain("TODO", html);
}
[Fact]
public void FarewellTemplate_InterpolatesDomain_AndLinksFeedbackChannels()
{
var html = EmailTemplates.GetOffboardingFarewellHtml("contoso.invalid");
Assert.Contains("contoso.invalid", html);
// The mail is sent from a noreply address after portal access is gone, so the
// feedback pointers must be external channels the recipient can actually reach.
Assert.Contains("https://github.com/okieselbach/AutopilotMonitor/issues", html);
Assert.Contains("linkedin.com", html);
}
[Fact]
public void FarewellTemplate_EmptyDomain_FallsBackToGenericLabel()
{
var html = EmailTemplates.GetOffboardingFarewellHtml("");
Assert.Contains("your organization", html);
}
private static ResendEmailService Build(string apiKey, ILogger<ResendEmailService> logger)
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["RESEND_API_KEY"] = apiKey,
})
.Build();
return new ResendEmailService(config, logger);
}
private sealed class CapturingLogger<T> : ILogger<T>
{
public readonly List<(LogLevel Level, string Message)> Entries = new();
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel, EventId eventId, TState state, System.Exception? exception,
System.Func<TState, System.Exception?, string> formatter)
{
Entries.Add((logLevel, formatter(state, exception)));
}
private sealed class NullScope : IDisposable
{
public static readonly NullScope Instance = new();
public void Dispose() { }
}
}
}
@@ -331,8 +331,9 @@ public class SessionRetentionFanoutServiceTests
var baseTime = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
var deadline = baseTime.AddMinutes(50);
var clockCalls = 0;
// Call sequence: tenant-A boundary (1), session s0 (2), session s1 (3), session s2 (4 → past deadline).
var harness = new Harness(utcNow: () => ++clockCalls >= 4 ? deadline.AddMinutes(1) : baseTime);
// Call sequence: tenant-A boundary (1), retention resolution (2), session s0 (3),
// session s1 (4), session s2 (5 → past deadline).
var harness = new Harness(utcNow: () => ++clockCalls >= 5 ? deadline.AddMinutes(1) : baseTime);
var sessions = new List<SessionSummary>();
for (int i = 0; i < 4; i++) sessions.Add(Summary(TenantA, $"s{i}"));
@@ -388,6 +389,38 @@ public class SessionRetentionFanoutServiceTests
};
}
// =========================================== Retention downgrade grace ====
[Fact]
public async Task Tenant_InRetentionGrace_KeepsProCutoff()
{
// Downgraded 10 days ago, stored retention 365: the grace keeps the Pro cap, so the
// cutoff must be now-365d — NOT now-90d (which would enqueue the 90–365d band).
var now = new DateTime(2026, 8, 22, 12, 0, 0, DateTimeKind.Utc);
var harness = new Harness(utcNow: () => now);
harness.WithTenant("t1", retentionDays: 365, sessions: Array.Empty<SessionSummary>(),
planTier: "community", proDowngradedUtc: now.AddDays(-10));
await harness.RunAsync();
harness.MaintenanceRepo.Verify(m => m.GetSessionsOlderThanAsync(
"t1", now.AddDays(-365), It.IsAny<int>(), It.IsAny<bool>()), Times.Once);
}
[Fact]
public async Task Tenant_AfterRetentionGrace_CommunityCutoffApplies()
{
var now = new DateTime(2026, 8, 22, 12, 0, 0, DateTimeKind.Utc);
var harness = new Harness(utcNow: () => now);
harness.WithTenant("t1", retentionDays: 365, sessions: Array.Empty<SessionSummary>(),
planTier: "community", proDowngradedUtc: now.AddDays(-31));
await harness.RunAsync();
harness.MaintenanceRepo.Verify(m => m.GetSessionsOlderThanAsync(
"t1", now.AddDays(-90), It.IsAny<int>(), It.IsAny<bool>()), Times.Once);
}
// ============================================================ Harness ====
private sealed class Harness
@@ -450,11 +483,18 @@ public class SessionRetentionFanoutServiceTests
return result;
}
public void WithTenant(string tenantId, int retentionDays, SessionSummary[] sessions, string planTier = "free")
public void WithTenant(string tenantId, int retentionDays, SessionSummary[] sessions, string planTier = "free",
DateTime? proDowngradedUtc = null)
{
_tenantIds.Add(tenantId);
TenantConfig.Setup(t => t.GetConfigurationAsync(tenantId))
.ReturnsAsync(new TenantConfiguration { TenantId = tenantId, DataRetentionDays = retentionDays, PlanTier = planTier });
.ReturnsAsync(new TenantConfiguration
{
TenantId = tenantId,
DataRetentionDays = retentionDays,
PlanTier = planTier,
ProDowngradedUtc = proDowngradedUtc,
});
MaintenanceRepo.Setup(m => m.GetSessionsOlderThanAsync(tenantId, It.IsAny<DateTime>(), It.IsAny<int>(), It.IsAny<bool>()))
.ReturnsAsync(new List<SessionSummary>(WithTenantId(tenantId, sessions)));
}
@@ -101,6 +101,52 @@ public class SessionStartedAtBackwardShiftGuardTests
Assert.Equal((int)(lastEvent - earliest).TotalSeconds, harness.Written!.GetInt32("DurationSeconds"));
}
// =========================== system-timeline rows are not session anchors ====
[Fact]
public void IsSessionAnchorEligible_excludes_system_timeline_watcher_rows()
{
Assert.False(TableStorageService.IsSessionAnchorEligible(
new TableEntity("p", "r") { ["Source"] = "SystemTimelineWatcher" }));
Assert.True(TableStorageService.IsSessionAnchorEligible(
new TableEntity("p", "r") { ["Source"] = "ImeLogTracker" }));
Assert.True(TableStorageService.IsSessionAnchorEligible(
new TableEntity("p", "r"))); // legacy row without Source projection
}
[Fact]
public async Task Pending_duration_skips_backfilled_system_timeline_row_and_anchors_on_activity()
{
// Field case d0c5b672: a pre-agent system_clock_changed event (backfilled from the
// Windows event log, original timestamp ~14min before the first real activity) was
// the partition's earliest row and re-anchored StartedAt. The probe must skip
// SystemTimelineWatcher rows and anchor on the first activity event instead —
// even when the watcher row sits well inside the 2h plausibility window.
var clockEventAt = StartedAt.AddMinutes(-90);
var firstActivity = StartedAt.AddMinutes(-5);
var lastEvent = StartedAt.AddMinutes(70);
var harness = new Harness(SessionRow(), eventRows: new[]
{
EventRow(clockEventAt, sequence: 18, source: "SystemTimelineWatcher"),
EventRow(firstActivity, sequence: 2, source: "ImeLogTracker"),
});
var ok = await harness.Sut.UpdateSessionStatusAsync(
TenantId, SessionId, SessionStatus.Pending,
latestEventTimestamp: lastEvent);
Assert.True(ok);
Assert.NotNull(harness.Written);
Assert.Equal((int)(lastEvent - firstActivity).TotalSeconds, harness.Written!.GetInt32("DurationSeconds"));
}
private static TableEntity EventRow(DateTime occurredUtc, int sequence, string source) =>
new($"{TenantId}_{SessionId}", $"{occurredUtc:yyyyMMddHHmmssfff}_{sequence:D10}")
{
["OccurredUtc"] = new DateTimeOffset(occurredUtc),
["Source"] = source,
};
// ============================================================ Harness ====
private static TableEntity SessionRow()
@@ -126,7 +172,7 @@ public class SessionStartedAtBackwardShiftGuardTests
public TableStorageService Sut { get; }
public TableEntity? Written { get; private set; }
public Harness(TableEntity existing, DateTime? earliestStoredEvent = null)
public Harness(TableEntity existing, DateTime? earliestStoredEvent = null, TableEntity[]? eventRows = null)
{
var sessions = new Mock<TableClient>();
@@ -150,7 +196,7 @@ public class SessionStartedAtBackwardShiftGuardTests
});
var events = new Mock<TableClient>();
var eventRows = earliestStoredEvent.HasValue
var servedRows = eventRows ?? (earliestStoredEvent.HasValue
? new[]
{
new TableEntity($"{TenantId}_{SessionId}", $"{earliestStoredEvent.Value:yyyyMMddHHmmssfff}_0000000001")
@@ -158,12 +204,12 @@ public class SessionStartedAtBackwardShiftGuardTests
["OccurredUtc"] = new DateTimeOffset(earliestStoredEvent.Value),
},
}
: Array.Empty<TableEntity>();
: Array.Empty<TableEntity>());
events.Setup(t => t.QueryAsync<TableEntity>(
It.IsAny<string>(), It.IsAny<int?>(), It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.Returns(AsyncPageable<TableEntity>.FromPages(new[]
{
Page<TableEntity>.FromValues(eventRows, null, new Mock<Response>().Object),
Page<TableEntity>.FromValues(servedRows, null, new Mock<Response>().Object),
}));
var serviceClient = new Mock<TableServiceClient>();
@@ -21,7 +21,7 @@ public class TenantApprovalServiceTests
private readonly Mock<PreviewWhitelistService> _previewMock;
private readonly Mock<TenantConfigurationService> _tenantConfigMock;
private readonly Mock<ResendEmailService> _resendMock;
private readonly Mock<IEmailService> _emailMock;
private readonly TenantApprovalService _sut;
public TenantApprovalServiceTests()
@@ -37,9 +37,10 @@ public class TenantApprovalServiceTests
configRepo, cache, NullLogger<PreviewWhitelistService>.Instance, _tenantConfigMock.Object)
{ CallBase = false };
_resendMock = new Mock<ResendEmailService>(
Mock.Of<Microsoft.Extensions.Configuration.IConfiguration>(),
NullLogger<ResendEmailService>.Instance);
_emailMock = new Mock<IEmailService>();
_emailMock.Setup(x => x.SendPreviewApprovedEmailAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
_sut = new TenantApprovalService(
NullLogger<TenantApprovalService>.Instance,
@@ -47,7 +48,7 @@ public class TenantApprovalServiceTests
_tenantConfigMock.Object,
new Mock<TenantAdminsService>(
Mock.Of<IAdminRepository>(), cache, NullLogger<TenantAdminsService>.Instance).Object,
_resendMock.Object);
_emailMock.Object);
}
[Fact]
@@ -94,7 +95,7 @@ public class TenantApprovalServiceTests
Assert.False(sent);
_previewMock.Verify(x => x.TryMarkWelcomeEmailSentAsync(It.IsAny<string>()), Times.Never);
_resendMock.Verify(x => x.SendPreviewApprovedEmailAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
_emailMock.Verify(x => x.SendPreviewApprovedEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
@@ -109,7 +110,7 @@ public class TenantApprovalServiceTests
var sent = await _sut.TrySendWelcomeEmailAsync(TenantId);
Assert.True(sent);
_resendMock.Verify(x => x.SendPreviewApprovedEmailAsync("it@contoso.com", "contoso.com"), Times.Once);
_emailMock.Verify(x => x.SendPreviewApprovedEmailAsync("it@contoso.com", "contoso.com", It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
@@ -121,7 +122,7 @@ public class TenantApprovalServiceTests
var sent = await _sut.TrySendWelcomeEmailAsync(TenantId);
Assert.False(sent);
_resendMock.Verify(x => x.SendPreviewApprovedEmailAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
_emailMock.Verify(x => x.SendPreviewApprovedEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
@@ -134,7 +135,7 @@ public class TenantApprovalServiceTests
var sent = await _sut.TrySendWelcomeEmailAsync(TenantId);
Assert.False(sent);
_resendMock.Verify(x => x.SendPreviewApprovedEmailAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
_emailMock.Verify(x => x.SendPreviewApprovedEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
@@ -151,6 +152,6 @@ public class TenantApprovalServiceTests
var newlyApproved = await _sut.ApproveWithSideEffectsAsync(TenantId, ApprovedBy);
Assert.True(newlyApproved);
_resendMock.Verify(x => x.SendPreviewApprovedEmailAsync("it@contoso.com", It.IsAny<string>()), Times.Once);
_emailMock.Verify(x => x.SendPreviewApprovedEmailAsync("it@contoso.com", It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Once);
}
}
@@ -50,9 +50,7 @@ public class TenantAutoApproveHandlerTests
_tenantConfigMock.Object,
new Mock<TenantAdminsService>(
Mock.Of<IAdminRepository>(), cache, NullLogger<TenantAdminsService>.Instance).Object,
new Mock<ResendEmailService>(
Mock.Of<Microsoft.Extensions.Configuration.IConfiguration>(),
NullLogger<ResendEmailService>.Instance).Object)
Mock.Of<IEmailService>())
{ CallBase = false };
var opsRepo = new Mock<IOpsEventRepository>();
@@ -20,6 +20,7 @@ public class TenantConfigTableSerializationTests
{
var expires = new DateTime(2026, 8, 6, 10, 30, 0, DateTimeKind.Utc);
var started = new DateTime(2026, 7, 7, 10, 30, 0, DateTimeKind.Utc);
var downgraded = new DateTime(2026, 8, 10, 8, 0, 0, DateTimeKind.Utc);
var config = new TenantConfiguration
{
TenantId = TenantId,
@@ -29,7 +30,8 @@ public class TenantConfigTableSerializationTests
TrialExpiresUtc = expires,
TrialStartedUtc = started,
TrialConsumed = true,
TrialGrantedBy = "alice@contoso.com"
TrialGrantedBy = "alice@contoso.com",
ProDowngradedUtc = downgraded
};
var entity = TableConfigRepository.ConvertToTenantTableEntity(config);
@@ -40,6 +42,7 @@ public class TenantConfigTableSerializationTests
Assert.Equal(started, mapped.TrialStartedUtc);
Assert.True(mapped.TrialConsumed);
Assert.Equal("alice@contoso.com", mapped.TrialGrantedBy);
Assert.Equal(downgraded, mapped.ProDowngradedUtc);
}
[Fact]
@@ -59,6 +62,7 @@ public class TenantConfigTableSerializationTests
Assert.Null(mapped.TrialStartedUtc);
Assert.False(mapped.TrialConsumed);
Assert.Null(mapped.TrialGrantedBy);
Assert.Null(mapped.ProDowngradedUtc);
}
[Fact]
@@ -77,6 +81,7 @@ public class TenantConfigTableSerializationTests
Assert.Null(mapped.TrialExpiresUtc);
Assert.False(mapped.TrialConsumed);
Assert.Null(mapped.TrialGrantedBy);
Assert.Null(mapped.ProDowngradedUtc); // pre-grace row: no anchor, no grace
}
[Fact]
@@ -194,8 +194,10 @@ public class TenantEntitlementServiceTests
}
[Fact]
public void GetEffectiveRetentionDays_TrialExpiry_DegradesCapFrom365To90()
public void GetEffectiveRetentionDays_TrialExpiry_ProCapHoldsThroughGrace_ThenDegradesTo90()
{
// Trial expiry is a downgrade: the Pro cap keeps applying for the 30-day retention
// grace (anchor = TrialExpiresUtc, no write involved), THEN the Community cap bites.
var config = new TenantConfiguration
{
TenantId = TenantId,
@@ -204,6 +206,93 @@ public class TenantEntitlementServiceTests
TrialExpiresUtc = Now.AddDays(1)
};
Assert.Equal(365, TenantEntitlementService.GetEffectiveRetentionDays(config, Now));
Assert.Equal(90, TenantEntitlementService.GetEffectiveRetentionDays(config, Now.AddDays(2)));
// 1 day after expiry: inside the grace — still the Pro cap.
Assert.Equal(365, TenantEntitlementService.GetEffectiveRetentionDays(config, Now.AddDays(2)));
// 31 days after expiry: grace over — Community cap enforced.
Assert.Equal(90, TenantEntitlementService.GetEffectiveRetentionDays(config, Now.AddDays(32)));
}
// ── Retention downgrade grace (GetRetentionGraceEndUtc + cap interplay) ──────
[Fact]
public void GetRetentionGraceEndUtc_ProTenant_ReturnsNull()
{
var config = new TenantConfiguration
{
TenantId = TenantId,
PlanTier = "pro",
ProDowngradedUtc = Now.AddDays(-5) // stale leftover must not matter while Pro
};
Assert.Null(TenantEntitlementService.GetRetentionGraceEndUtc(config, Now));
}
[Fact]
public void GetRetentionGraceEndUtc_NeverLostPro_ReturnsNull()
{
var config = new TenantConfiguration { TenantId = TenantId, PlanTier = "free" };
Assert.Null(TenantEntitlementService.GetRetentionGraceEndUtc(config, Now));
}
[Fact]
public void GetRetentionGraceEndUtc_ExplicitDowngrade_AnchorPlusGraceDays()
{
var downgraded = Now.AddDays(-10);
var config = new TenantConfiguration
{
TenantId = TenantId,
PlanTier = "community",
ProDowngradedUtc = downgraded
};
Assert.Equal(
downgraded.AddDays(FeatureEntitlementCatalog.RetentionDowngradeGraceDays),
TenantEntitlementService.GetRetentionGraceEndUtc(config, Now));
}
[Fact]
public void GetRetentionGraceEndUtc_LatestAnchorWins()
{
// Downgrade, re-granted trial, trial expired again: the LATER trial expiry anchors.
var config = new TenantConfiguration
{
TenantId = TenantId,
PlanTier = "community",
ProDowngradedUtc = Now.AddDays(-20),
TrialExpiresUtc = Now.AddDays(-3)
};
Assert.Equal(
Now.AddDays(-3).AddDays(FeatureEntitlementCatalog.RetentionDowngradeGraceDays),
TenantEntitlementService.GetRetentionGraceEndUtc(config, Now));
}
[Fact]
public void GetEffectiveRetentionDays_ExplicitDowngrade_ProCapDuringGrace_CommunityCapAfter()
{
var config = new TenantConfiguration
{
TenantId = TenantId,
PlanTier = "community",
DataRetentionDays = 365,
ProDowngradedUtc = Now
};
Assert.Equal(365, TenantEntitlementService.GetEffectiveRetentionDays(config, Now.AddDays(29)));
Assert.Equal(90, TenantEntitlementService.GetEffectiveRetentionDays(config, Now.AddDays(31)));
}
[Fact]
public void GetEffectiveRetentionDays_GraceDoesNotRaiseAnythingElse()
{
// 0 (infinite) passes through untouched, and a stored value below the Community cap
// is unaffected — the grace only swaps which CAP clamps.
var zero = new TenantConfiguration
{
TenantId = TenantId, PlanTier = "community", DataRetentionDays = 0, ProDowngradedUtc = Now
};
Assert.Equal(0, TenantEntitlementService.GetEffectiveRetentionDays(zero, Now.AddDays(5)));
var below = new TenantConfiguration
{
TenantId = TenantId, PlanTier = "community", DataRetentionDays = 60, ProDowngradedUtc = Now
};
Assert.Equal(60, TenantEntitlementService.GetEffectiveRetentionDays(below, Now.AddDays(5)));
}
}
@@ -20,7 +20,8 @@ public class TrialExpirySweepFunctionTests
private static readonly DateTime Now = new(2026, 7, 7, 3, 30, 0, DateTimeKind.Utc);
private static TenantConfiguration Tenant(
string id, DateTime? trialExpiresUtc, string planTier = "free", string domain = "contoso.com") => new()
string id, DateTime? trialExpiresUtc, string planTier = "free", string domain = "contoso.com",
int retentionDays = 90, DateTime? proDowngradedUtc = null) => new()
{
TenantId = id,
DomainName = domain,
@@ -28,6 +29,8 @@ public class TrialExpirySweepFunctionTests
PlanTier = planTier,
TrialExpiresUtc = trialExpiresUtc,
TrialConsumed = trialExpiresUtc.HasValue,
DataRetentionDays = retentionDays,
ProDowngradedUtc = proDowngradedUtc,
};
private static (TrialExpirySweepFunction Sut, List<OpsEventEntry> Events) Build(params TenantConfiguration[] configs)
@@ -150,6 +153,88 @@ public class TrialExpirySweepFunctionTests
Assert.Equal(2, events.Count);
}
// ── Retention downgrade grace events ─────────────────────────────────────────
[Fact]
public async Task GraceEndingSoon_WithDataAtRisk_EmitsGraceExpiring()
{
// Downgraded 28 days ago with stored retention 365: grace ends in 2 days.
var (sut, events) = Build(Tenant("t1", null, planTier: "community",
retentionDays: 365, proDowngradedUtc: Now.AddDays(-28)));
var result = await sut.RunCoreAsync(CancellationToken.None);
Assert.Equal(1, result.GraceExpiringEmitted);
var e = Assert.Single(events);
Assert.Equal("TenantRetentionGraceExpiring", e.EventType);
Assert.Equal(OpsEventSeverity.Warning, e.Severity);
Assert.Contains("2 day", e.Message);
}
[Fact]
public async Task GraceEndedWithinLookBack_EmitsGraceEnded_OlderStaysSilent()
{
var (sut, events) = Build(
// Grace ended 12h ago → emit once.
Tenant("just-ended", null, planTier: "community",
retentionDays: 365, proDowngradedUtc: Now.AddDays(-30).AddHours(-12)),
// Grace ended 3 days ago → reported by a previous run, silent.
Tenant("long-ended", null, planTier: "community",
retentionDays: 365, proDowngradedUtc: Now.AddDays(-33)));
var result = await sut.RunCoreAsync(CancellationToken.None);
Assert.Equal(1, result.GraceEndedEmitted);
var e = Assert.Single(events);
Assert.Equal("TenantRetentionGraceEnded", e.EventType);
Assert.Equal("just-ended", e.TenantId);
}
[Fact]
public async Task Grace_NoDataAtRisk_StaysSilent()
{
var (sut, events) = Build(
// Stored retention at the Community cap — nothing to lose.
Tenant("at-cap", null, planTier: "community",
retentionDays: 90, proDowngradedUtc: Now.AddDays(-29)),
// 0 = infinite escape hatch — the fanout never deletes, no warning needed.
Tenant("infinite", null, planTier: "community",
retentionDays: 0, proDowngradedUtc: Now.AddDays(-29)));
var result = await sut.RunCoreAsync(CancellationToken.None);
Assert.Equal(0, result.GraceExpiringEmitted);
Assert.Equal(0, result.GraceEndedEmitted);
Assert.Empty(events);
}
[Fact]
public async Task Grace_ReUpgradedTenant_StaysSilent()
{
// Back on Pro: GetRetentionGraceEndUtc returns null even with a stale anchor.
var (sut, events) = Build(Tenant("t1", null, planTier: "pro",
retentionDays: 365, proDowngradedUtc: Now.AddDays(-29)));
var result = await sut.RunCoreAsync(CancellationToken.None);
Assert.Equal(0, result.GraceExpiringEmitted);
Assert.Empty(events);
}
[Fact]
public async Task ExpiredTrial_WithDataAtRisk_GetsBothTrialExpiredAndLaterGraceEvents()
{
// The trial-expiry anchor drives the grace window too: expired 2h ago fires
// TenantTrialExpired now; 28 days later the same tenant enters the grace heads-up.
var (sut, events) = Build(Tenant("t1", Now.AddHours(-2), retentionDays: 365));
var result = await sut.RunCoreAsync(CancellationToken.None);
Assert.Equal(1, result.ExpiredEmitted);
Assert.Equal(0, result.GraceExpiringEmitted); // grace end is ~30d out — beyond heads-up
Assert.Single(events);
}
[Fact]
public async Task ConfigLoadFailure_ReturnsEmptyResult_NeverThrows()
{
@@ -0,0 +1,39 @@
using AutopilotMonitor.Functions.Services.Notifications;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// HMAC request-signature scheme for generic JSON webhooks: known-vector stability (the
/// scheme is a published contract receivers implement against), determinism, and
/// secret/message sensitivity.
/// </summary>
public class WebhookSignatureTests
{
[Fact]
public void ComputeSignature_MatchesKnownVector()
{
// Independently computed: HMACSHA256("whsec_test_0123456789abcdef", "1700000000.{"a":1}")
var signature = WebhookSignatureCalculator.ComputeSignature(
"whsec_test_0123456789abcdef", "1700000000", "{\"a\":1}");
Assert.Equal("sha256=07728d7e7b1736bac3de36a46c9bc0b660d59aa4a6f251544124fede1dc42a6c", signature);
}
[Fact]
public void ComputeSignature_IsDeterministic_AndSensitiveToEveryInput()
{
var baseline = WebhookSignatureCalculator.ComputeSignature("secret-0123456789", "1700000000", "{}");
Assert.Equal(baseline, WebhookSignatureCalculator.ComputeSignature("secret-0123456789", "1700000000", "{}"));
Assert.NotEqual(baseline, WebhookSignatureCalculator.ComputeSignature("secret-0123456789X", "1700000000", "{}"));
Assert.NotEqual(baseline, WebhookSignatureCalculator.ComputeSignature("secret-0123456789", "1700000001", "{}"));
Assert.NotEqual(baseline, WebhookSignatureCalculator.ComputeSignature("secret-0123456789", "1700000000", "{ }"));
}
[Fact]
public void HeaderNames_AreTheDocumentedContract()
{
Assert.Equal("X-AutopilotMonitor-Timestamp", WebhookSignatureCalculator.TimestampHeader);
Assert.Equal("X-AutopilotMonitor-Signature", WebhookSignatureCalculator.SignatureHeader);
}
}
@@ -56,7 +56,6 @@
<PackageReference Include="Microsoft.Identity.Web" Version="4.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="8.0.25" />
<PackageReference Include="Resend" Version="0.2.1" />
</ItemGroup>
<ItemGroup>
@@ -494,6 +494,54 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
}
}
// --- Email Template Overrides ---
private const string EmailTemplatesPartition = "EmailTemplates";
public async Task<EmailTemplateOverride?> GetEmailTemplateOverrideAsync(string kind)
{
try
{
var entity = await _previewConfigTableClient.GetEntityAsync<TableEntity>(EmailTemplatesPartition, kind);
var html = entity.Value.GetString("Html");
if (string.IsNullOrEmpty(html)) return null;
return new EmailTemplateOverride
{
Kind = kind,
Html = html,
UpdatedBy = entity.Value.GetString("UpdatedBy") ?? string.Empty,
UpdatedUtc = entity.Value.GetDateTime("UpdatedUtc") ?? DateTime.MinValue,
};
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
return null;
}
}
public async Task SaveEmailTemplateOverrideAsync(EmailTemplateOverride overrideEntry)
{
var entity = new TableEntity(EmailTemplatesPartition, overrideEntry.Kind)
{
["Html"] = overrideEntry.Html,
["UpdatedBy"] = overrideEntry.UpdatedBy,
["UpdatedUtc"] = overrideEntry.UpdatedUtc == default ? DateTime.UtcNow : overrideEntry.UpdatedUtc,
};
await _previewConfigTableClient.UpsertEntityAsync(entity, TableUpdateMode.Replace);
}
public async Task DeleteEmailTemplateOverrideAsync(string kind)
{
try
{
await _previewConfigTableClient.DeleteEntityAsync(EmailTemplatesPartition, kind);
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
// already gone — reset is idempotent
}
}
// --- Preview Notification Email ---
public async Task<string?> GetNotificationEmailAsync(string tenantId)
@@ -671,6 +719,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{ "TrialStartedUtc", config.TrialStartedUtc },
{ "TrialConsumed", config.TrialConsumed },
{ "TrialGrantedBy", config.TrialGrantedBy },
{ "ProDowngradedUtc", config.ProDowngradedUtc },
// SLA targets
{ "SlaTargetSuccessRate", config.SlaTargetSuccessRate.HasValue ? (double)config.SlaTargetSuccessRate.Value : (double?)null },
{ "SlaTargetMaxDurationMinutes", config.SlaTargetMaxDurationMinutes },
@@ -783,6 +832,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
TrialStartedUtc = entity.GetDateTime("TrialStartedUtc"),
TrialConsumed = entity.GetBoolean("TrialConsumed") ?? false,
TrialGrantedBy = entity.GetString("TrialGrantedBy"),
ProDowngradedUtc = entity.GetDateTime("ProDowngradedUtc"),
// SLA targets
SlaTargetSuccessRate = entity.GetDouble("SlaTargetSuccessRate") != null ? (decimal)entity.GetDouble("SlaTargetSuccessRate")! : null,
SlaTargetMaxDurationMinutes = entity.GetInt32("SlaTargetMaxDurationMinutes"),
@@ -0,0 +1,163 @@
using System.Net;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Admin;
/// <summary>
/// Global-Admin management of the two transactional email templates (welcome, farewell):
/// read the effective template, store/reset an HTML override, and send a test mail.
/// All routes are GlobalAdminOnly via <c>EndpointAccessPolicyCatalog</c>.
/// </summary>
public class EmailTemplatesFunction
{
private readonly ILogger<EmailTemplatesFunction> _logger;
private readonly EmailTemplateService _templates;
private readonly IEmailService _email;
private readonly PreviewWhitelistService _previewWhitelistService;
private readonly TenantConfigurationService _tenantConfigurationService;
public EmailTemplatesFunction(
ILogger<EmailTemplatesFunction> logger,
EmailTemplateService templates,
IEmailService email,
PreviewWhitelistService previewWhitelistService,
TenantConfigurationService tenantConfigurationService)
{
_logger = logger;
_templates = templates;
_email = email;
_previewWhitelistService = previewWhitelistService;
_tenantConfigurationService = tenantConfigurationService;
}
/// <summary>
/// GET /api/global/email-templates/{kind}
/// Effective template: the override when stored, otherwise the built-in (raw, with the
/// {{domainName}} placeholder so it can be edited as-is).
/// </summary>
[Function("GetEmailTemplate")]
public async Task<HttpResponseData> Get(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/email-templates/{kind}")] HttpRequestData req,
string kind)
{
if (!EmailTemplateService.TryParseKind(kind, out var templateKind))
return await BadRequest(req, "Unknown template kind. Use 'welcome' or 'farewell'.");
var overrideEntry = await _templates.GetOverrideAsync(templateKind);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
{
kind = EmailTemplateService.KindKey(templateKind),
subject = EmailTemplateService.Subject(templateKind),
isOverridden = overrideEntry is not null,
html = overrideEntry?.Html ?? EmailTemplateService.BuiltInRaw(templateKind),
builtInHtml = EmailTemplateService.BuiltInRaw(templateKind),
updatedBy = overrideEntry?.UpdatedBy,
updatedUtc = overrideEntry?.UpdatedUtc,
placeholder = EmailTemplateService.DomainPlaceholder,
maxLength = EmailTemplateService.MaxHtmlLength,
});
return response;
}
/// <summary>PUT /api/global/email-templates/{kind} — body { html } stores an override.</summary>
[Function("SaveEmailTemplate")]
public async Task<HttpResponseData> Save(
[HttpTrigger(AuthorizationLevel.Anonymous, "put", Route = "global/email-templates/{kind}")] HttpRequestData req,
string kind)
{
if (!EmailTemplateService.TryParseKind(kind, out var templateKind))
return await BadRequest(req, "Unknown template kind. Use 'welcome' or 'farewell'.");
var body = await req.ReadFromJsonAsync<EmailTemplateRequest>();
var error = EmailTemplateService.Validate(body?.Html);
if (error is not null)
return await BadRequest(req, error);
var upn = TenantHelper.GetUserIdentifier(req);
var saved = await _templates.SaveOverrideAsync(templateKind, body!.Html!, upn);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { kind = saved.Kind, isOverridden = true, updatedBy = saved.UpdatedBy, updatedUtc = saved.UpdatedUtc });
return response;
}
/// <summary>DELETE /api/global/email-templates/{kind} — resets to the built-in template.</summary>
[Function("ResetEmailTemplate")]
public async Task<HttpResponseData> Reset(
[HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "global/email-templates/{kind}")] HttpRequestData req,
string kind)
{
if (!EmailTemplateService.TryParseKind(kind, out var templateKind))
return await BadRequest(req, "Unknown template kind. Use 'welcome' or 'farewell'.");
await _templates.DeleteOverrideAsync(templateKind, TenantHelper.GetUserIdentifier(req));
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { kind = EmailTemplateService.KindKey(templateKind), isOverridden = false });
return response;
}
/// <summary>
/// POST /api/global/email-templates/{kind}/test — body { html? } sends the effective template
/// (or the unsaved draft) to the caller's tenant contact address: the activation
/// notification email, then TenantConfiguration.ContactEmail, then the caller's UPN.
/// Domain = the caller's tenant domain.
/// </summary>
[Function("SendEmailTemplateTest")]
public async Task<HttpResponseData> SendTest(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "global/email-templates/{kind}/test")] HttpRequestData req,
string kind)
{
if (!EmailTemplateService.TryParseKind(kind, out var templateKind))
return await BadRequest(req, "Unknown template kind. Use 'welcome' or 'farewell'.");
var body = await req.ReadFromJsonAsync<EmailTemplateRequest>();
var draft = string.IsNullOrWhiteSpace(body?.Html) ? null : body!.Html;
if (draft is not null)
{
var error = EmailTemplateService.Validate(draft);
if (error is not null) return await BadRequest(req, error);
}
var tenantId = TenantHelper.GetTenantId(req);
var upn = TenantHelper.GetUserIdentifier(req);
var tenantConfig = await _tenantConfigurationService.GetConfigurationAsync(tenantId);
var toEmail = await _previewWhitelistService.GetNotificationEmailAsync(tenantId);
if (string.IsNullOrWhiteSpace(toEmail)) toEmail = tenantConfig.ContactEmail;
if (string.IsNullOrWhiteSpace(toEmail)) toEmail = upn.Contains('@') ? upn : null;
if (string.IsNullOrWhiteSpace(toEmail))
return await BadRequest(req, "No contact email found for your tenant — set a notification email or ContactEmail first.");
var sent = await _email.SendTestAsync(templateKind, toEmail, tenantConfig.DomainName, draft);
_logger.LogInformation("Email template test {Kind} requested by {Upn} → {ToEmail}: {Result}", templateKind, upn, toEmail, sent ? "accepted" : "failed");
if (!sent)
{
var failed = req.CreateResponse(HttpStatusCode.BadGateway);
await failed.WriteAsJsonAsync(new { error = "The email provider did not accept the message. Check the backend log for the provider response.", sentTo = toEmail });
return failed;
}
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { sentTo = toEmail, domainName = tenantConfig.DomainName, draft = draft is not null });
return response;
}
private static async Task<HttpResponseData> BadRequest(HttpRequestData req, string error)
{
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
await bad.WriteAsJsonAsync(new { error });
return bad;
}
}
public class EmailTemplateRequest
{
public string? Html { get; set; }
}
@@ -142,6 +142,23 @@ namespace AutopilotMonitor.Functions.Functions.Config
|| string.Equals(destination, "Hosted", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Global ∪ tenant diagnostics paths: global first, order preserved, blank paths dropped,
/// duplicates (trimmed, case-insensitive) collapsed onto the first occurrence. The agent
/// keys ZIP entries on the path, so a path present in both lists would otherwise produce
/// duplicate entry names inside the archive.
/// </summary>
internal static List<DiagnosticsLogPath> MergeDiagnosticsLogPaths(
IEnumerable<DiagnosticsLogPath> global,
IEnumerable<DiagnosticsLogPath> tenant)
{
return global.Concat(tenant)
.Where(p => !string.IsNullOrWhiteSpace(p?.Path))
.GroupBy(p => p.Path.Trim(), StringComparer.OrdinalIgnoreCase)
.Select(g => g.First())
.ToList();
}
/// <summary>
/// Returns the (ZipSha256, ExeSha256) pair appropriate for the calling agent.
/// Reads the X-Agent-Version header, parses the major, and dispatches to the
@@ -242,10 +259,10 @@ namespace AutopilotMonitor.Functions.Functions.Config
// Get active IME log patterns for this tenant (from Table Storage)
var imeLogPatterns = await _imeLogPatternService.GetActivePatternsForTenantAsync(tenantId);
// Merge global + tenant-specific diagnostics log paths
var globalDiagPaths = adminConfig.GetDiagnosticsGlobalLogPaths();
var tenantDiagPaths = tenantConfig.GetDiagnosticsLogPaths();
var diagLogPaths = globalDiagPaths.Concat(tenantDiagPaths).ToList();
// Merge global + tenant-specific diagnostics log paths (the built-in sections are
// compiled into the agent — DiagnosticsBuiltInSections — and never travel here)
var diagLogPaths = MergeDiagnosticsLogPaths(
adminConfig.GetDiagnosticsGlobalLogPaths(), tenantConfig.GetDiagnosticsLogPaths());
// Select per-line hash oracle from the X-Agent-Version header (parametric per major).
// The wire response keeps generic field names (LatestAgentSha256 / LatestAgentExeSha256)
@@ -27,14 +27,16 @@ namespace AutopilotMonitor.Functions.Functions.Config
private readonly TenantConfigurationService _configService;
private readonly AdminConfigurationService _adminConfigService;
private readonly IMaintenanceRepository _maintenanceRepo;
private readonly OpsEventService _opsEvents;
private readonly TimeProvider _time;
public PlanManagementFunction(
ILogger<PlanManagementFunction> logger,
TenantConfigurationService configService,
AdminConfigurationService adminConfigService,
IMaintenanceRepository maintenanceRepo)
: this(logger, configService, adminConfigService, maintenanceRepo, TimeProvider.System)
IMaintenanceRepository maintenanceRepo,
OpsEventService opsEvents)
: this(logger, configService, adminConfigService, maintenanceRepo, opsEvents, TimeProvider.System)
{
}
@@ -44,12 +46,14 @@ namespace AutopilotMonitor.Functions.Functions.Config
TenantConfigurationService configService,
AdminConfigurationService adminConfigService,
IMaintenanceRepository maintenanceRepo,
OpsEventService opsEvents,
TimeProvider time)
{
_logger = logger;
_configService = configService;
_adminConfigService = adminConfigService;
_maintenanceRepo = maintenanceRepo;
_opsEvents = opsEvents;
_time = time;
}
@@ -139,22 +143,8 @@ namespace AutopilotMonitor.Functions.Functions.Config
var nowUtc = _time.GetUtcNow().UtcDateTime;
var changes = new Dictionary<string, string>();
if (newPlanTier != null && !string.Equals(config.PlanTier, newPlanTier, StringComparison.Ordinal))
{
changes["PlanTier"] = $"{config.PlanTier} -> {newPlanTier}";
config.PlanTier = newPlanTier;
}
if (trialProvided && config.TrialExpiresUtc != newTrialExpiresUtc)
{
changes["TrialExpiresUtc"] = $"{FormatUtc(config.TrialExpiresUtc)} -> {FormatUtc(newTrialExpiresUtc)}";
config.TrialExpiresUtc = newTrialExpiresUtc;
if (newTrialExpiresUtc.HasValue)
{
config.TrialStartedUtc ??= nowUtc;
config.TrialGrantedBy = caller;
}
}
var (editionBefore, editionAfter) =
ApplyPlanChanges(config, newPlanTier, trialProvided, newTrialExpiresUtc, caller, nowUtc, changes);
if (changes.Count > 0)
{
@@ -171,9 +161,17 @@ namespace AutopilotMonitor.Functions.Functions.Config
requestCtx.TargetTenantId,
caller,
changes);
}
var effectiveEdition = FeatureEntitlementCatalog.ResolveEdition(config.PlanTier, config.TrialExpiresUtc, nowUtc);
if (editionBefore == TenantEdition.Pro && editionAfter == TenantEdition.Community)
{
await _opsEvents.RecordTenantPlanDowngradedAsync(
requestCtx.TargetTenantId,
config.DomainName,
caller,
TenantEntitlementService.GetRetentionGraceEndUtc(config, nowUtc),
config.DataRetentionDays);
}
}
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
@@ -182,7 +180,8 @@ namespace AutopilotMonitor.Functions.Functions.Config
planTier = config.PlanTier,
trialExpiresUtc = config.TrialExpiresUtc,
trialConsumed = config.TrialConsumed,
effectiveEdition = effectiveEdition.ToString().ToLowerInvariant()
effectiveEdition = editionAfter.ToString().ToLowerInvariant(),
retentionGraceEndsUtc = TenantEntitlementService.GetRetentionGraceEndUtc(config, nowUtc)
});
return response;
}
@@ -195,6 +194,61 @@ namespace AutopilotMonitor.Functions.Functions.Config
}
}
/// <summary>
/// Pure mutation core of the PATCH plan endpoint — no I/O, unit-testable. Applies the
/// plan/trial changes to <paramref name="config"/>, records human-readable entries in
/// <paramref name="changes"/>, and returns the EFFECTIVE edition before/after (same
/// <paramref name="nowUtc"/> for both, so only the mutation itself can flip it).
///
/// Maintains the retention downgrade grace anchor: an effective Pro→Community
/// transition stamps <see cref="TenantConfiguration.ProDowngradedUtc"/> (covers both a
/// planTier downgrade and an explicitly ended trial); any state that is effectively Pro
/// afterwards clears the anchor, so a re-upgrade also resets the grace clock.
/// </summary>
internal static (TenantEdition Before, TenantEdition After) ApplyPlanChanges(
TenantConfiguration config,
string? newPlanTier,
bool trialProvided,
DateTime? newTrialExpiresUtc,
string caller,
DateTime nowUtc,
Dictionary<string, string> changes)
{
var before = FeatureEntitlementCatalog.ResolveEdition(config.PlanTier, config.TrialExpiresUtc, nowUtc);
if (newPlanTier != null && !string.Equals(config.PlanTier, newPlanTier, StringComparison.Ordinal))
{
changes["PlanTier"] = $"{config.PlanTier} -> {newPlanTier}";
config.PlanTier = newPlanTier;
}
if (trialProvided && config.TrialExpiresUtc != newTrialExpiresUtc)
{
changes["TrialExpiresUtc"] = $"{FormatUtc(config.TrialExpiresUtc)} -> {FormatUtc(newTrialExpiresUtc)}";
config.TrialExpiresUtc = newTrialExpiresUtc;
if (newTrialExpiresUtc.HasValue)
{
config.TrialStartedUtc ??= nowUtc;
config.TrialGrantedBy = caller;
}
}
var after = FeatureEntitlementCatalog.ResolveEdition(config.PlanTier, config.TrialExpiresUtc, nowUtc);
if (before == TenantEdition.Pro && after == TenantEdition.Community)
{
changes["ProDowngradedUtc"] = $"{FormatUtc(config.ProDowngradedUtc)} -> {FormatUtc(nowUtc)}";
config.ProDowngradedUtc = nowUtc;
}
else if (after == TenantEdition.Pro && config.ProDowngradedUtc.HasValue)
{
changes["ProDowngradedUtc"] = $"{FormatUtc(config.ProDowngradedUtc)} -> (none)";
config.ProDowngradedUtc = null;
}
return (before, after);
}
/// <summary>
/// POST /api/config/{tenantId}/trial — TenantAdminOrGA (catalog-enforced). Self-service
/// 30-day Pro trial, exactly once per tenant. 409 when the trial was already
@@ -262,6 +316,8 @@ namespace AutopilotMonitor.Functions.Functions.Config
config.TrialExpiresUtc = nowUtc.AddDays(SelfServiceTrialDays);
config.TrialConsumed = true;
config.TrialGrantedBy = caller;
// Effectively Pro again — a leftover retention grace anchor is obsolete.
config.ProDowngradedUtc = null;
config.UpdatedBy = caller;
// Fail-loud: throws when the write did not persist (cache invalidated in finally).
@@ -82,7 +82,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
var testAlert = NotificationAlertBuilder.BuildTestAlert();
var result = await _webhookNotificationService.SendNotificationWithResultAsync(
channel.Url, (WebhookProviderType)channel.ProviderType, testAlert, channel.GetCustomHeaders());
channel.Url, (WebhookProviderType)channel.ProviderType, testAlert, channel.GetCustomHeaders(), channel.GetSigningSecret());
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
@@ -0,0 +1,77 @@
using System;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Diagnostics
{
public class GetDiagnosticsPathsFunction
{
private readonly ILogger<GetDiagnosticsPathsFunction> _logger;
private readonly AdminConfigurationService _adminConfigService;
public GetDiagnosticsPathsFunction(
ILogger<GetDiagnosticsPathsFunction> logger,
AdminConfigurationService adminConfigService)
{
_logger = logger;
_adminConfigService = adminConfigService;
}
/// <summary>
/// GET /api/diagnostics/paths
/// What every diagnostics package collects before a tenant's own entries: the built-in
/// section catalog (compiled into the agent, shared through
/// <see cref="DiagnosticsBuiltInSections"/>) and the platform-wide global paths set by
/// Global Admins. Member-readable by design — the catalog is code, the global list is
/// non-secret admin data, and every settings viewer needs to see what is collected from
/// the tenant's devices. Tenant-less: the payload is platform-wide, not tenant data.
/// </summary>
[Function("GetDiagnosticsPaths")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "diagnostics/paths")] HttpRequestData req)
{
try
{
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware
var adminConfig = await _adminConfigService.GetConfigurationAsync();
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(BuildPayload(adminConfig));
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting diagnostics paths");
var response = req.CreateResponse(HttpStatusCode.InternalServerError);
await response.WriteAsJsonAsync(new { error = "Internal server error" });
return response;
}
}
/// <summary>
/// Static projection so unit tests pin the wire shape without an HttpRequestData mock.
/// <c>condition</c> travels as the enum NAME ("Always" | "RealmJoinWatcher" |
/// "DevicePreparation"), never the integer — the web switches on the string.
/// </summary>
internal static object BuildPayload(AdminConfiguration adminConfig) => new
{
builtIn = DiagnosticsBuiltInSections.All.Select(s => new
{
id = s.Id,
zipFolder = s.ZipFolder,
sourceFolder = s.SourceFolder,
patterns = s.Patterns,
includeSubfolders = s.IncludeSubfolders,
description = s.Description,
condition = s.Condition.ToString(),
}).ToArray(),
globalPaths = adminConfig.GetDiagnosticsGlobalLogPaths(),
};
}
}
@@ -10,15 +10,21 @@ using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Maintenance;
/// <summary>
/// Daily informational sweep over tenant Pro trials. NOT load-bearing — trial expiry
/// degrades a tenant to Community at READ time (FeatureEntitlementCatalog.ResolveEdition);
/// this timer only surfaces the transitions as ops events so the operator has visibility:
/// Daily informational sweep over the tenant plan lifecycle. NOT load-bearing — trial expiry
/// degrades a tenant to Community at READ time (FeatureEntitlementCatalog.ResolveEdition),
/// and the retention grace is likewise resolved read-time by the retention fanout; this timer
/// only surfaces the transitions as ops events so the operator has visibility:
/// <list type="bullet">
/// <item><c>TenantTrialExpired</c> (Warning) — the trial ended within the last sweep window
/// (24h look-back matching the daily cadence).</item>
/// <item><c>TenantTrialExpiring</c> (Info) — the trial ends within the next
/// <see cref="ExpiringHeadsUpDays"/> days (re-emitted each daily run until expiry —
/// acceptable for an Info-tier heads-up, no dedupe state to maintain).</item>
/// <item><c>TenantRetentionGraceExpiring</c> / <c>TenantRetentionGraceEnded</c> (Warning) —
/// same window mechanics for the retention downgrade grace
/// (TenantEntitlementService.GetRetentionGraceEndUtc). Emitted only when data is
/// actually at risk: stored retention above the Community cap (0 = infinite ⇒ never
/// at risk, the fanout skips those tenants).</item>
/// </list>
/// Tenants whose stored <c>PlanTier</c> is already a permanent Pro tier ("pro"/legacy "enterprise") are skipped —
/// their trial timestamps are inert leftovers and expiry changes nothing.
@@ -90,45 +96,93 @@ public class TrialExpirySweepFunction
{
ct.ThrowIfCancellationRequested();
if (config.TrialExpiresUtc is not DateTime expiry)
continue;
// Permanent Pro: the trial timestamps are inert leftovers — expiry changes nothing.
if (FeatureEntitlementCatalog.IsPermanentProTier(config.PlanTier))
continue;
result.TrialsSeen++;
if (expiry <= now)
{
if (expiry > now - ExpiredLookBack)
{
await _opsEvents.RecordTenantTrialExpiredAsync(config.TenantId, config.DomainName, expiry)
.ConfigureAwait(false);
result.ExpiredEmitted++;
}
// Older expiries were reported by a previous run — stay silent.
}
else if (expiry <= now + ExpiringHeadsUp)
{
var daysLeft = Math.Max(1, (int)Math.Ceiling((expiry - now).TotalDays));
await _opsEvents.RecordTenantTrialExpiringAsync(config.TenantId, config.DomainName, expiry, daysLeft)
.ConfigureAwait(false);
result.ExpiringEmitted++;
}
await SweepTrialAsync(config, now, result).ConfigureAwait(false);
await SweepRetentionGraceAsync(config, now, result).ConfigureAwait(false);
}
_logger.LogInformation(
"TrialExpirySweep completed: trialsSeen={TrialsSeen} expiredEmitted={ExpiredEmitted} expiringEmitted={ExpiringEmitted}",
result.TrialsSeen, result.ExpiredEmitted, result.ExpiringEmitted);
"TrialExpirySweep completed: trialsSeen={TrialsSeen} expiredEmitted={ExpiredEmitted} expiringEmitted={ExpiringEmitted} " +
"graceExpiringEmitted={GraceExpiringEmitted} graceEndedEmitted={GraceEndedEmitted}",
result.TrialsSeen, result.ExpiredEmitted, result.ExpiringEmitted,
result.GraceExpiringEmitted, result.GraceEndedEmitted);
return result;
}
private async Task SweepTrialAsync(
AutopilotMonitor.Shared.Models.TenantConfiguration config, DateTime now, SweepResult result)
{
if (config.TrialExpiresUtc is not DateTime expiry)
return;
// Permanent Pro: the trial timestamps are inert leftovers — expiry changes nothing.
if (FeatureEntitlementCatalog.IsPermanentProTier(config.PlanTier))
return;
result.TrialsSeen++;
if (expiry <= now)
{
if (expiry > now - ExpiredLookBack)
{
await _opsEvents.RecordTenantTrialExpiredAsync(config.TenantId, config.DomainName, expiry)
.ConfigureAwait(false);
result.ExpiredEmitted++;
}
// Older expiries were reported by a previous run — stay silent.
}
else if (expiry <= now + ExpiringHeadsUp)
{
var daysLeft = Math.Max(1, (int)Math.Ceiling((expiry - now).TotalDays));
await _opsEvents.RecordTenantTrialExpiringAsync(config.TenantId, config.DomainName, expiry, daysLeft)
.ConfigureAwait(false);
result.ExpiringEmitted++;
}
}
/// <summary>
/// Retention downgrade grace visibility: warn shortly before and once after the grace ends,
/// but only when data is actually at risk — stored retention above the Community cap.
/// (0 = the GA-only infinite escape hatch: the fanout skips those tenants, nothing is ever
/// deleted, so the comparison excludes it naturally.) GetRetentionGraceEndUtc returns null
/// for effectively-Pro tenants, so re-upgraded tenants go silent automatically.
/// </summary>
private async Task SweepRetentionGraceAsync(
AutopilotMonitor.Shared.Models.TenantConfiguration config, DateTime now, SweepResult result)
{
var communityCap = FeatureEntitlementCatalog.Get(TenantEdition.Community).RetentionCapDays;
if (config.DataRetentionDays <= communityCap)
return;
if (TenantEntitlementService.GetRetentionGraceEndUtc(config, now) is not DateTime graceEnd)
return;
if (graceEnd <= now)
{
if (graceEnd > now - ExpiredLookBack)
{
await _opsEvents.RecordTenantRetentionGraceEndedAsync(
config.TenantId, config.DomainName, graceEnd, config.DataRetentionDays)
.ConfigureAwait(false);
result.GraceEndedEmitted++;
}
}
else if (graceEnd <= now + ExpiringHeadsUp)
{
var daysLeft = Math.Max(1, (int)Math.Ceiling((graceEnd - now).TotalDays));
await _opsEvents.RecordTenantRetentionGraceExpiringAsync(
config.TenantId, config.DomainName, graceEnd, daysLeft, config.DataRetentionDays)
.ConfigureAwait(false);
result.GraceExpiringEmitted++;
}
}
/// <summary>Summary counters returned from <see cref="RunCoreAsync"/> so tests can assert outcomes.</summary>
internal sealed class SweepResult
{
public int TrialsSeen { get; set; }
public int ExpiredEmitted { get; set; }
public int ExpiringEmitted { get; set; }
public int GraceExpiringEmitted { get; set; }
public int GraceEndedEmitted { get; set; }
}
}
@@ -19,20 +19,20 @@ public class PreviewWhitelistFunction
private readonly PreviewWhitelistService _previewWhitelistService;
private readonly TenantApprovalService _tenantApprovalService;
private readonly TenantConfigurationService _tenantConfigurationService;
private readonly ResendEmailService _resendEmailService;
private readonly IEmailService _emailService;
public PreviewWhitelistFunction(
ILogger<PreviewWhitelistFunction> logger,
PreviewWhitelistService previewWhitelistService,
TenantApprovalService tenantApprovalService,
TenantConfigurationService tenantConfigurationService,
ResendEmailService resendEmailService)
IEmailService emailService)
{
_logger = logger;
_previewWhitelistService = previewWhitelistService;
_tenantApprovalService = tenantApprovalService;
_tenantConfigurationService = tenantConfigurationService;
_resendEmailService = resendEmailService;
_emailService = emailService;
}
/// <summary>
@@ -221,7 +221,7 @@ public class PreviewWhitelistFunction
}
var tenantConfig = await _tenantConfigurationService.GetConfigurationAsync(tenantId);
await _resendEmailService.SendPreviewApprovedEmailAsync(email, tenantConfig.DomainName);
await _emailService.SendPreviewApprovedEmailAsync(email, tenantConfig.DomainName);
// Explicit GA send always sends; consume the once-only marker (best-effort) so
// the automatic paths won't produce a duplicate afterwards.
@@ -182,6 +182,35 @@ namespace AutopilotMonitor.Functions.Helpers
var headerError = ValidateWebhookCustomHeaders(channel.CustomHeadersJson);
if (headerError != null)
return $"channel \"{label}\" headers: {headerError}";
var secretError = ValidateWebhookSigningSecret(channel.SigningSecret);
if (secretError != null)
return $"channel \"{label}\" signing secret: {secretError}";
}
return null;
}
/// <summary>
/// Validates a generic-webhook HMAC signing secret. Returns an error message, or null when
/// valid/absent. A short key is brute-forceable as an HMAC key; the printable-ASCII rule
/// keeps the secret copy-paste safe and (as a side effect) rejects a stray unrestored
/// redaction sentinel, which is shorter than the minimum. No provider gate here — a secret
/// on a non-generic channel is stored but inert at dispatch (GetSigningSecret), matching
/// how CustomHeadersJson behaves.
/// </summary>
internal static string? ValidateWebhookSigningSecret(string? secret)
{
if (string.IsNullOrWhiteSpace(secret))
return null;
if (secret.Length < 16 || secret.Length > 128)
return "must be 16-128 characters.";
foreach (var ch in secret)
{
if (ch < 0x21 || ch > 0x7E)
return "must be printable ASCII without spaces or line breaks.";
}
return null;
@@ -437,8 +437,15 @@ builder.Services.AddHttpClient<AutopilotMonitor.Functions.Services.Notifications
.AddPolicyHandler((sp, _) => sp.GetRequiredService<ResiliencePolicies>().Notification);
builder.Services.AddHttpClient<TelegramNotificationService>()
.AddPolicyHandler((sp, _) => sp.GetRequiredService<ResiliencePolicies>().Notification);
builder.Services.AddSingleton<ResendEmailService>();
builder.Services.AddSingleton<IOffboardFarewellEmailSender>(sp => sp.GetRequiredService<ResendEmailService>());
// Transactional email (welcome / farewell). Typed client like the other outbound notifiers;
// the provider is an implementation detail of EmailService + the Email:* settings.
builder.Services.AddSingleton<EmailTemplateService>();
builder.Services.AddSingleton<IEmailTemplateProvider>(sp => sp.GetRequiredService<EmailTemplateService>());
builder.Services.AddHttpClient<EmailService>()
.ConfigureHttpClient(client => client.Timeout = TimeSpan.FromSeconds(15))
.AddPolicyHandler((sp, _) => sp.GetRequiredService<ResiliencePolicies>().Notification);
builder.Services.AddTransient<IEmailService>(sp => sp.GetRequiredService<EmailService>());
builder.Services.AddTransient<IOffboardFarewellEmailSender>(sp => sp.GetRequiredService<EmailService>());
builder.Services.AddSingleton<GlobalNotificationService>();
builder.Services.AddSingleton<TenantNotificationService>();
@@ -282,6 +282,9 @@ public static class EndpointAccessPolicyCatalog
new("GET", "audit/tpm-pss-unsupported", EndpointPolicy.MemberRead),
new("GET", "diagnostics/download-url", EndpointPolicy.MemberRead, TenantScoping.QueryParam),
new("POST", "diagnostics/download-ticket", EndpointPolicy.MemberRead, TenantScoping.QueryParam),
// Platform-defined diagnostics collection (built-in catalog + global paths): read-only,
// no secrets, needed by every settings viewer — member tier, JWT-scoped (no tenant parameter).
new("GET", "diagnostics/paths", EndpointPolicy.MemberRead, TenantScoping.Jwt),
new("GET", "rules/gather", EndpointPolicy.MemberRead),
new("GET", "rules/analyze", EndpointPolicy.MemberRead),
new("GET", "rules/ime-log-patterns", EndpointPolicy.MemberRead),
@@ -497,6 +500,12 @@ public static class EndpointAccessPolicyCatalog
new("POST", "versions/block", EndpointPolicy.GlobalAdminOnly),
new("DELETE", "versions/block/{encodedPattern}", EndpointPolicy.GlobalAdminOnly),
new("POST", "maintenance/trigger", EndpointPolicy.GlobalAdminOnly),
// Transactional email templates (welcome / farewell): read, override, reset, test send.
// Read stays GA-only too — the override HTML is operator content, not tenant data.
new("GET", "global/email-templates/{kind}", EndpointPolicy.GlobalAdminOnly),
new("PUT", "global/email-templates/{kind}", EndpointPolicy.GlobalAdminOnly),
new("DELETE", "global/email-templates/{kind}", EndpointPolicy.GlobalAdminOnly),
new("POST", "global/email-templates/{kind}/test", EndpointPolicy.GlobalAdminOnly),
// One-time retro-reconcile of misclassified historical sessions (audit 2026-07-16) —
// mutating, dry-run by default, GA only.
new("POST", "maintenance/reclassify-legacy", EndpointPolicy.GlobalAdminOnly),
@@ -95,6 +95,16 @@ namespace AutopilotMonitor.Functions.Security
/// </summary>
public const string LegacyEnterpriseTierName = "enterprise";
/// <summary>
/// Retention downgrade grace period: for this many days after a tenant loses Pro
/// (explicit downgrade or trial expiry) the retention sweep keeps enforcing the PRO
/// cap, so the downgrade does not immediately hard-delete data older than the
/// Community cap. Retention is the only entitlement with a grace — everything else
/// gates read-time immediately because it is reversible without data loss.
/// Resolved via <see cref="Services.TenantEntitlementService.GetRetentionGraceEndUtc"/>.
/// </summary>
public const int RetentionDowngradeGraceDays = 30;
private static readonly EditionEntitlements Community = new()
{
Edition = TenantEdition.Community,
@@ -162,16 +162,17 @@ namespace AutopilotMonitor.Functions.Services.Deletion
// trial expired, or whose stored value predates the cap, is enforced here even though
// the stored DataRetentionDays is left untouched. days <= 0 stays the GA-only
// "infinite" escape hatch (skipped below, never clamped).
var nowUtc = _utcNow();
var storedDays = config?.DataRetentionDays ?? 90;
var retentionDays = config == null
? 90
: TenantEntitlementService.GetEffectiveRetentionDays(config, DateTime.UtcNow);
: TenantEntitlementService.GetEffectiveRetentionDays(config, nowUtc);
if (retentionDays > 0 && retentionDays < storedDays)
{
_logger.LogWarning(
"Tenant {TenantId}: DataRetentionDays={Stored} exceeds the {Edition}-edition cap — enforcing {Effective} days",
tenantId, storedDays,
TenantEntitlementService.ResolveEdition(config!, DateTime.UtcNow), retentionDays);
TenantEntitlementService.ResolveEdition(config!, nowUtc), retentionDays);
}
if (retentionDays <= 0)
@@ -180,7 +181,7 @@ namespace AutopilotMonitor.Functions.Services.Deletion
return;
}
var cutoffUtc = DateTime.UtcNow.AddDays(-retentionDays);
var cutoffUtc = nowUtc.AddDays(-retentionDays);
// Server-bounded read: fetch one more than the per-run dispatch cap. The loop below
// only ever advances MaxEnqueuesPerTenantPerRun sessions, so loading the whole backlog
@@ -0,0 +1,259 @@
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services;
/// <summary>
/// Sends the product's transactional emails (tenant-activation welcome, post-offboarding
/// farewell). Best-effort: failures are logged as warnings and never propagated.
/// <para>
/// The class and its callers are provider-neutral. Everything provider-specific is confined
/// to the <c>Email:*</c> configuration section and <see cref="SendViaMandrillAsync"/>, so a
/// future provider swap touches the transport method and the app settings only.
/// Current provider: Mailchimp Transactional (Mandrill) — <c>POST {Email:Endpoint}</c> with the
/// API key in the JSON body. Open/click tracking is explicitly disabled so the provider
/// receives nothing beyond the recipient address and the tenant domain (trust-page claim).
/// </para>
/// <para>
/// Configuration (App Settings use <c>Email__Key</c>):
/// <c>Email:ApiKey</c> (required; empty ⇒ every send is a logged no-op),
/// <c>Email:Endpoint</c> (default <see cref="DefaultEndpoint"/>),
/// <c>Email:FromAddress</c> (default <see cref="DefaultFromAddress"/>),
/// <c>Email:FromName</c> (default <see cref="DefaultFromName"/>).
/// </para>
/// </summary>
public class EmailService : IEmailService, IOffboardFarewellEmailSender
{
public const string ApiKeyConfigKey = "Email:ApiKey";
public const string DefaultEndpoint = "https://mandrillapp.com/api/1.0/messages/send";
public const string DefaultFromAddress = "noreply@autopilotmonitor.com";
public const string DefaultFromName = "Autopilot Monitor";
private static readonly JsonSerializerOptions JsonOptions = new()
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
private readonly HttpClient _http;
private readonly IEmailTemplateProvider _templates;
private readonly ILogger<EmailService> _logger;
private readonly string _apiKey;
private readonly string _endpoint;
private readonly string _fromAddress;
private readonly string _fromName;
public EmailService(
HttpClient http,
IConfiguration configuration,
IEmailTemplateProvider templates,
ILogger<EmailService> logger)
{
_http = http;
_templates = templates;
_logger = logger;
_apiKey = configuration[ApiKeyConfigKey] ?? string.Empty;
_endpoint = FirstNonBlank(configuration["Email:Endpoint"], DefaultEndpoint);
_fromAddress = FirstNonBlank(configuration["Email:FromAddress"], DefaultFromAddress);
_fromName = FirstNonBlank(configuration["Email:FromName"], DefaultFromName);
}
/// <inheritdoc />
public async Task SendPreviewApprovedEmailAsync(string toEmail, string domainName, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(_apiKey))
{
_logger.LogDebug("{ConfigKey} not configured — skipping preview approval email", ApiKeyConfigKey);
return;
}
if (string.IsNullOrWhiteSpace(toEmail))
{
_logger.LogDebug("No notification email set — skipping preview approval email for {Domain}", domainName);
return;
}
var sent = await SendViaMandrillAsync(
toEmail,
EmailTemplates.PreviewApprovedSubject,
await _templates.GetHtmlAsync(EmailTemplateKind.Welcome, domainName, ct),
tag: "welcome",
ct);
if (sent)
{
_logger.LogInformation(
"Preview approval email sent to {ToEmail} for domain {Domain}",
toEmail, domainName);
}
}
/// <summary>
/// Sends the post-offboarding "sorry to see you go" farewell email.
/// No-op if the API key or recipient email is not configured. Best-effort: failures
/// are logged as warnings and never propagated (the offboarding correctness contract
/// does not depend on email delivery).
/// </summary>
public async Task SendAsync(string toEmail, string domainName, string tenantId, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(_apiKey))
{
_logger.LogDebug(
"{ConfigKey} not configured — skipping offboard farewell email for tenant {TenantId}",
ApiKeyConfigKey, tenantId);
return;
}
if (string.IsNullOrWhiteSpace(toEmail))
{
_logger.LogDebug(
"No notification email captured — skipping offboard farewell email for tenant {TenantId} ({Domain})",
tenantId, domainName);
return;
}
var sent = await SendViaMandrillAsync(
toEmail,
EmailTemplates.OffboardingFarewellSubject,
await _templates.GetHtmlAsync(EmailTemplateKind.Farewell, domainName, ct),
tag: "offboarding-farewell",
ct);
if (sent)
{
_logger.LogInformation(
"Offboard farewell email sent to {ToEmail} for tenant {TenantId} ({Domain})",
toEmail, tenantId, domainName);
}
}
/// <inheritdoc />
public async Task<bool> SendTestAsync(EmailTemplateKind kind, string toEmail, string domainName, string? draftHtml, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(_apiKey))
{
_logger.LogWarning("{ConfigKey} not configured — cannot send {Kind} test email", ApiKeyConfigKey, kind);
return false;
}
var html = draftHtml is null
? await _templates.GetHtmlAsync(kind, domainName, ct)
: EmailTemplateService.Render(draftHtml, domainName);
var sent = await SendViaMandrillAsync(toEmail, EmailTemplateService.Subject(kind), html, tag: "test", ct);
if (sent)
_logger.LogInformation("{Kind} test email sent to {ToEmail} (draft={IsDraft})", kind, toEmail, draftHtml is not null);
return sent;
}
/// <summary>
/// The only provider-specific code path. Posts a Mandrill <c>messages/send</c> request and
/// interprets the per-recipient result array: <c>sent</c>/<c>queued</c>/<c>scheduled</c>
/// count as success, <c>rejected</c>/<c>invalid</c> as failure (with the provider's
/// <c>reject_reason</c> in the warning). Never throws.
/// </summary>
private async Task<bool> SendViaMandrillAsync(string toEmail, string subject, string html, string tag, CancellationToken ct)
{
try
{
var request = new MandrillSendRequest
{
Key = _apiKey,
Message = new MandrillMessage
{
FromEmail = _fromAddress,
FromName = _fromName,
To = new[] { new MandrillRecipient { Email = toEmail, Type = "to" } },
Subject = subject,
Html = html,
AutoText = true,
TrackOpens = false,
TrackClicks = false,
Tags = new[] { tag },
},
};
using var response = await _http.PostAsJsonAsync(_endpoint, request, JsonOptions, ct);
var body = await response.Content.ReadAsStringAsync(ct);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning(
"Email provider returned {StatusCode} for {Tag} mail to {ToEmail}: {Body}",
(int)response.StatusCode, tag, toEmail, Truncate(body));
return false;
}
var results = JsonSerializer.Deserialize<MandrillSendResult[]>(body, JsonOptions);
var result = results?.FirstOrDefault();
if (result is null)
{
_logger.LogWarning(
"Email provider returned an empty result for {Tag} mail to {ToEmail}: {Body}",
tag, toEmail, Truncate(body));
return false;
}
if (!IsAcceptedStatus(result.Status))
{
_logger.LogWarning(
"Email provider did not accept {Tag} mail to {ToEmail}: status={Status} reason={Reason}",
tag, toEmail, result.Status, result.RejectReason ?? "n/a");
return false;
}
return true;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to send {Tag} mail to {ToEmail}", tag, toEmail);
return false;
}
}
private static bool IsAcceptedStatus(string? status)
=> status is "sent" or "queued" or "scheduled";
private static string FirstNonBlank(string? value, string fallback)
=> string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
private static string Truncate(string value)
=> value.Length <= 500 ? value : value[..500] + "…";
// --- Mandrill wire format (https://mailchimp.com/developer/transactional/api/messages/send-new-message/) ---
private sealed class MandrillSendRequest
{
[JsonPropertyName("key")] public string Key { get; set; } = string.Empty;
[JsonPropertyName("message")] public MandrillMessage Message { get; set; } = new();
}
private sealed class MandrillMessage
{
[JsonPropertyName("from_email")] public string FromEmail { get; set; } = string.Empty;
[JsonPropertyName("from_name")] public string FromName { get; set; } = string.Empty;
[JsonPropertyName("to")] public MandrillRecipient[] To { get; set; } = Array.Empty<MandrillRecipient>();
[JsonPropertyName("subject")] public string Subject { get; set; } = string.Empty;
[JsonPropertyName("html")] public string Html { get; set; } = string.Empty;
[JsonPropertyName("auto_text")] public bool AutoText { get; set; }
[JsonPropertyName("track_opens")] public bool TrackOpens { get; set; }
[JsonPropertyName("track_clicks")] public bool TrackClicks { get; set; }
[JsonPropertyName("tags")] public string[] Tags { get; set; } = Array.Empty<string>();
}
private sealed class MandrillRecipient
{
[JsonPropertyName("email")] public string Email { get; set; } = string.Empty;
[JsonPropertyName("type")] public string Type { get; set; } = "to";
}
private sealed class MandrillSendResult
{
[JsonPropertyName("email")] public string? Email { get; set; }
[JsonPropertyName("status")] public string? Status { get; set; }
[JsonPropertyName("reject_reason")] public string? RejectReason { get; set; }
[JsonPropertyName("_id")] public string? Id { get; set; }
}
}
@@ -0,0 +1,151 @@
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services;
/// <summary>The two transactional mails the product sends.</summary>
public enum EmailTemplateKind
{
Welcome,
Farewell,
}
/// <summary>
/// Resolves the HTML body for a transactional mail: the operator override when one is
/// stored, otherwise the built-in template. Consumed by <see cref="EmailService"/>.
/// </summary>
public interface IEmailTemplateProvider
{
Task<string> GetHtmlAsync(EmailTemplateKind kind, string domainName, CancellationToken ct = default);
}
/// <summary>
/// Operator-editable email templates. Overrides live in the PreviewConfig table (partition
/// "EmailTemplates", row key = kind) and are cached for 5 minutes; the built-in templates in
/// <see cref="EmailTemplates"/> remain the fallback. Subjects are not editable — they are pinned
/// constants.
/// <para>
/// Rendering contract: the only placeholder is <c>{{domainName}}</c>; an empty domain renders as
/// "your organization", exactly like the built-ins. Resolution is fail-soft — a storage error
/// falls back to the built-in template so activation/offboarding mails keep going out.
/// </para>
/// </summary>
public class EmailTemplateService : IEmailTemplateProvider
{
public const string DomainPlaceholder = "{{domainName}}";
public const string EmptyDomainLabel = "your organization";
/// <summary>Azure Table string properties cap at 32K UTF-16 chars; keep headroom.</summary>
public const int MaxHtmlLength = 30_000;
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
private readonly IConfigRepository _configRepo;
private readonly IMemoryCache _cache;
private readonly ILogger<EmailTemplateService> _logger;
public EmailTemplateService(IConfigRepository configRepo, IMemoryCache cache, ILogger<EmailTemplateService> logger)
{
_configRepo = configRepo;
_cache = cache;
_logger = logger;
}
public static bool TryParseKind(string? value, out EmailTemplateKind kind)
{
switch (value?.Trim().ToLowerInvariant())
{
case "welcome": kind = EmailTemplateKind.Welcome; return true;
case "farewell": kind = EmailTemplateKind.Farewell; return true;
default: kind = default; return false;
}
}
public static string KindKey(EmailTemplateKind kind) => kind == EmailTemplateKind.Welcome ? "welcome" : "farewell";
public static string Subject(EmailTemplateKind kind) => kind == EmailTemplateKind.Welcome
? EmailTemplates.PreviewApprovedSubject
: EmailTemplates.OffboardingFarewellSubject;
/// <summary>Built-in template with the placeholder left in place (for the editor).</summary>
public static string BuiltInRaw(EmailTemplateKind kind) => kind == EmailTemplateKind.Welcome
? EmailTemplates.GetPreviewApprovedHtml(DomainPlaceholder)
: EmailTemplates.GetOffboardingFarewellHtml(DomainPlaceholder);
/// <summary>Substitutes the placeholder; an empty domain becomes "your organization".</summary>
public static string Render(string rawHtml, string? domainName)
{
var display = string.IsNullOrWhiteSpace(domainName) ? EmptyDomainLabel : domainName.Trim();
return rawHtml.Replace(DomainPlaceholder, display, StringComparison.Ordinal);
}
/// <summary>Returns null when the HTML is acceptable, otherwise the validation message.</summary>
public static string? Validate(string? html)
{
if (string.IsNullOrWhiteSpace(html)) return "Template HTML must not be empty.";
if (html.Length > MaxHtmlLength) return $"Template HTML exceeds {MaxHtmlLength:N0} characters ({html.Length:N0}).";
return null;
}
public async Task<string> GetHtmlAsync(EmailTemplateKind kind, string domainName, CancellationToken ct = default)
{
var overrideEntry = await GetOverrideAsync(kind, failSoft: true);
return overrideEntry is null
? Render(BuiltInRaw(kind), domainName)
: Render(overrideEntry.Html, domainName);
}
/// <summary>
/// The stored override, or null for built-in. <paramref name="failSoft"/> swallows storage
/// errors (send path must never depend on the override store); the admin endpoints pass
/// false so a broken store surfaces as an error instead of silently showing the built-in.
/// </summary>
public virtual async Task<EmailTemplateOverride?> GetOverrideAsync(EmailTemplateKind kind, bool failSoft = false)
{
var key = CacheKey(kind);
if (_cache.TryGetValue(key, out EmailTemplateOverride? cached))
return cached;
try
{
var entry = await _configRepo.GetEmailTemplateOverrideAsync(KindKey(kind));
// null is a valid (and the common) value — cache it too so the send path
// doesn't hit storage on every mail.
_cache.Set(key, entry, CacheDuration);
return entry;
}
catch (Exception ex) when (failSoft)
{
_logger.LogWarning(ex, "Failed to read {Kind} email template override — using built-in template", kind);
return null;
}
}
public virtual async Task<EmailTemplateOverride> SaveOverrideAsync(EmailTemplateKind kind, string html, string updatedBy)
{
var error = Validate(html);
if (error is not null) throw new ArgumentException(error, nameof(html));
var entry = new EmailTemplateOverride
{
Kind = KindKey(kind),
Html = html,
UpdatedBy = updatedBy,
UpdatedUtc = DateTime.UtcNow,
};
await _configRepo.SaveEmailTemplateOverrideAsync(entry);
_cache.Remove(CacheKey(kind));
_logger.LogInformation("Email template override saved: {Kind} by {UpdatedBy} ({Length} chars)", kind, updatedBy, html.Length);
return entry;
}
public virtual async Task DeleteOverrideAsync(EmailTemplateKind kind, string deletedBy)
{
await _configRepo.DeleteEmailTemplateOverrideAsync(KindKey(kind));
_cache.Remove(CacheKey(kind));
_logger.LogInformation("Email template override reset to built-in: {Kind} by {DeletedBy}", kind, deletedBy);
}
private static string CacheKey(EmailTemplateKind kind) => $"email-template-override:{KindKey(kind)}";
}
@@ -3,7 +3,7 @@ using AutopilotMonitor.Shared;
namespace AutopilotMonitor.Functions.Services;
/// <summary>
/// Email templates for Resend notifications.
/// Templates for the product's transactional emails (sent via <see cref="EmailService"/>).
/// Keep all email content here for easy maintenance.
/// </summary>
public static class EmailTemplates
@@ -37,12 +37,33 @@ public static class EmailTemplates
<tr><td align=""center"">
<table width=""600"" cellpadding=""0"" cellspacing=""0"" style=""background-color:#ffffff; border-radius:12px; overflow:hidden; box-shadow:0 4px 6px rgba(0,0,0,0.07);"">
<!-- Header -->
<!-- Header: image-free. Base for every client: solid bgcolor band, text wordmark, brand mark
drawn with CSS borders (trapezoid + a green wedge for the slanted right edge; must stay
<div>s — Outlook paints transparent <td> borders white). Progressive enhancement where
supported: ONE linear-gradient (hex colours, % stops — the only form Outlook web/iOS
renders; multiple layers or rgba stops are dropped) forms the facets, and clip-path cuts
the slanted bottom edge (Apple Mail only). No bgcolor inside the mark: Outlook dark mode
inverts cell backgrounds but leaves border colours alone.
Geometry is the approved reference (scratch header-render.html): facets
light right of (40%,0)->(70%,100%), dark right of (55%,0)->(85%,100%) and left of
(78%,0)->(100%,60%) — the last one is dropped for the single-gradient constraint.
Change only against a side-by-side render. -->
<tr>
<td style=""background:linear-gradient(135deg,#1e8a4c,#33b161); padding:32px 40px; text-align:center;"">
<h1 style=""color:#ffffff; margin:0; font-size:24px; font-weight:700;"">Autopilot Monitor</h1>
<p style=""color:#c9efd8; margin:8px 0 0; font-size:14px;"">Windows Autopilot monitoring</p>
</td>
<td bgcolor=""#33b161"" style=""background-color:#33b161; background-image:linear-gradient(39.8deg, #33b161 0%, #33b161 53.8%, #3cbb6a 53.8%, #3cbb6a 65.4%, #2ea85a 65.4%, #2ea85a 100%); clip-path:polygon(0 0, 100% 0, 100% 100%, 0 82%); padding:28px 32px 52px; line-height:0; font-size:0;"">
<table cellpadding=""0"" cellspacing=""0"" border=""0""><tr>
<td valign=""middle"" style=""padding:0 12px 0 0; line-height:0; font-size:0;"">
<!-- brand mark, 34x33, three non-overlapping rows (Outlook ignores negative margins and
paints transparent td borders white, so: div triangles + one white td only) -->
<div style=""width:0; height:0; border-left:31px solid #ffffff; border-top:13px solid transparent; border-right:0; border-bottom:0; display:block; font-size:0; line-height:0;""></div>
<table cellpadding=""0"" cellspacing=""0"" border=""0"" style=""border-collapse:collapse;""><tr>
<td style=""padding:0; font-size:0; line-height:0;""><div style=""width:0; height:18px; border-left:31px solid #ffffff; border-top:0; border-right:0; border-bottom:0; display:block; font-size:0; line-height:0;""></div></td>
<td style=""padding:0; font-size:0; line-height:0;""><div style=""width:0; height:0; border-left:3px solid #ffffff; border-top:18px solid transparent; border-right:0; border-bottom:0; display:block; font-size:0; line-height:0;""></div></td>
</tr></table>
<div style=""width:0; height:0; border-right:34px solid #ffffff; border-bottom:2px solid transparent; border-left:0; border-top:0; display:block; font-size:0; line-height:0;""></div>
</td>
<td valign=""middle"" style=""color:#ffffff; font-size:22px; font-weight:600; letter-spacing:-0.2px; line-height:1; white-space:nowrap; font-family:-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;"">Autopilot Monitor</td>
</tr></table>
</td>
</tr>
<!-- Body -->
@@ -56,14 +77,14 @@ public static class EmailTemplates
<p style=""color:#374151; font-size:15px; line-height:1.6; margin:0 0 24px;"">
Thank you for taking the time to try Autopilot Monitor. Every organization that
puts it to work in a real environment helps shape what it becomes &mdash; we're
puts it to work in a real environment helps shape what it becomes &ndash; we're
genuinely grateful you gave it a chance.
</p>
<!-- Gentle feedback ask -->
<p style=""color:#374151; font-size:15px; line-height:1.6; margin:0 0 12px;"">
If something didn't fit &mdash; a missing feature, a bug that got in the way, or
simply a better alternative &mdash; we'd love to hear about it. A couple of honest
If something didn't fit &ndash; a missing feature, a bug that got in the way, or
simply a better alternative &ndash; we'd love to hear about it. A couple of honest
sentences help more than you'd think:
</p>
@@ -78,7 +99,7 @@ public static class EmailTemplates
<p style=""color:#6b7280; font-size:13px; line-height:1.6; margin:0;"">
This is the last email you'll receive from us. This mailbox doesn't accept
replies &mdash; please use the links above if you'd like to get in touch.
replies &ndash; please use the links above if you'd like to get in touch.
</p>
</td>
</tr>
@@ -115,12 +136,33 @@ public static class EmailTemplates
<tr><td align=""center"">
<table width=""600"" cellpadding=""0"" cellspacing=""0"" style=""background-color:#ffffff; border-radius:12px; overflow:hidden; box-shadow:0 4px 6px rgba(0,0,0,0.07);"">
<!-- Header -->
<!-- Header: image-free. Base for every client: solid bgcolor band, text wordmark, brand mark
drawn with CSS borders (trapezoid + a green wedge for the slanted right edge; must stay
<div>s — Outlook paints transparent <td> borders white). Progressive enhancement where
supported: ONE linear-gradient (hex colours, % stops — the only form Outlook web/iOS
renders; multiple layers or rgba stops are dropped) forms the facets, and clip-path cuts
the slanted bottom edge (Apple Mail only). No bgcolor inside the mark: Outlook dark mode
inverts cell backgrounds but leaves border colours alone.
Geometry is the approved reference (scratch header-render.html): facets
light right of (40%,0)->(70%,100%), dark right of (55%,0)->(85%,100%) and left of
(78%,0)->(100%,60%) — the last one is dropped for the single-gradient constraint.
Change only against a side-by-side render. -->
<tr>
<td style=""background:linear-gradient(135deg,#1e8a4c,#33b161); padding:32px 40px; text-align:center;"">
<h1 style=""color:#ffffff; margin:0; font-size:24px; font-weight:700;"">Autopilot Monitor</h1>
<p style=""color:#c9efd8; margin:8px 0 0; font-size:14px;"">Windows Autopilot monitoring</p>
</td>
<td bgcolor=""#33b161"" style=""background-color:#33b161; background-image:linear-gradient(39.8deg, #33b161 0%, #33b161 53.8%, #3cbb6a 53.8%, #3cbb6a 65.4%, #2ea85a 65.4%, #2ea85a 100%); clip-path:polygon(0 0, 100% 0, 100% 100%, 0 82%); padding:28px 32px 52px; line-height:0; font-size:0;"">
<table cellpadding=""0"" cellspacing=""0"" border=""0""><tr>
<td valign=""middle"" style=""padding:0 12px 0 0; line-height:0; font-size:0;"">
<!-- brand mark, 34x33, three non-overlapping rows (Outlook ignores negative margins and
paints transparent td borders white, so: div triangles + one white td only) -->
<div style=""width:0; height:0; border-left:31px solid #ffffff; border-top:13px solid transparent; border-right:0; border-bottom:0; display:block; font-size:0; line-height:0;""></div>
<table cellpadding=""0"" cellspacing=""0"" border=""0"" style=""border-collapse:collapse;""><tr>
<td style=""padding:0; font-size:0; line-height:0;""><div style=""width:0; height:18px; border-left:31px solid #ffffff; border-top:0; border-right:0; border-bottom:0; display:block; font-size:0; line-height:0;""></div></td>
<td style=""padding:0; font-size:0; line-height:0;""><div style=""width:0; height:0; border-left:3px solid #ffffff; border-top:18px solid transparent; border-right:0; border-bottom:0; display:block; font-size:0; line-height:0;""></div></td>
</tr></table>
<div style=""width:0; height:0; border-right:34px solid #ffffff; border-bottom:2px solid transparent; border-left:0; border-top:0; display:block; font-size:0; line-height:0;""></div>
</td>
<td valign=""middle"" style=""color:#ffffff; font-size:22px; font-weight:600; letter-spacing:-0.2px; line-height:1; white-space:nowrap; font-family:-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;"">Autopilot Monitor</td>
</tr></table>
</td>
</tr>
<!-- Body -->
@@ -129,8 +171,8 @@ public static class EmailTemplates
<h2 style=""color:#111827; margin:0 0 16px; font-size:20px;"">Welcome to Autopilot Monitor!</h2>
<p style=""color:#374151; font-size:15px; line-height:1.6; margin:0 0 16px;"">
Great news &mdash; access for <strong>{displayDomain}</strong> has been activated and is ready to use.
You can now sign in and start monitoring your Windows Autopilot enrollments in real time.
Great news &ndash; access for <strong>{displayDomain}</strong> has been activated and is ready to use.
You can now <a href=""{Constants.PortalBaseUrl}"" target=""_blank"" style=""color:#1e8a4c; text-decoration:underline;"">sign in</a> and start monitoring your Windows Autopilot enrollments in real time.
</p>
<p style=""color:#374151; font-size:15px; line-height:1.6; margin:0 0 24px;"">
@@ -149,13 +191,6 @@ public static class EmailTemplates
</tr>
</table>
<!-- Active development note -->
<div style=""background-color:#fef3c7; border:1px solid #fde68a; border-radius:8px; padding:16px 20px; margin:0 0 24px;"">
<p style=""color:#92400e; font-size:14px; line-height:1.5; margin:0;"">
<strong>Please note:</strong> Autopilot Monitor is in active development. Some features are still being built
and things may occasionally not work as expected. Your patience and understanding are greatly appreciated!
</p>
</div>
<!-- Feedback -->
<p style=""color:#374151; font-size:15px; line-height:1.6; margin:0 0 12px;"">
@@ -169,7 +204,7 @@ public static class EmailTemplates
</ul>
<p style=""color:#6b7280; font-size:14px; line-height:1.6; margin:0;"">
Thanks for being part of the journey &mdash; enjoy Autopilot Monitor!
Thanks for being part of the journey &ndash; enjoy Autopilot Monitor!
</p>
</td>
</tr>
@@ -92,6 +92,7 @@ namespace AutopilotMonitor.Functions.Services
bool AccountSetupAllSucceeded,
bool HasExplicitFailure,
bool HasTerminalComplete,
bool HasEnrollmentComplete,
bool HasAgentEmergencyBreak,
bool DesktopArrived,
bool HelloResolved,
@@ -113,7 +114,7 @@ namespace AutopilotMonitor.Functions.Services
bool deviceAll = false;
int acctBestN = 0, acctBestM = 0;
bool acctFallbackAll = false;
bool hasFailure = false, hasComplete = false, hasEmergencyBreak = false;
bool hasFailure = false, hasComplete = false, hasEnrollmentComplete = false, hasEmergencyBreak = false;
bool desktopArrived = false, helloResolved = false;
bool realmJoinDetected = false, realmJoinResolved = false;
bool sawHelloPolicyEnabled = false, sawHelloPolicyDisabled = false;
@@ -135,7 +136,12 @@ namespace AutopilotMonitor.Functions.Services
hasFailure = true;
}
else if (Eq(type, "enrollment_complete") || Eq(type, "whiteglove_complete"))
{
hasComplete = true;
// whiteglove_complete only proves Part 1 (technician flow) — the WhiteGlove
// awaiting-user gate needs to know whether a REAL enrollment completion exists.
if (Eq(type, "enrollment_complete")) hasEnrollmentComplete = true;
}
else if (Eq(type, "agent_emergency_break"))
hasEmergencyBreak = true;
else if (Eq(type, "desktop_arrived"))
@@ -217,6 +223,7 @@ namespace AutopilotMonitor.Functions.Services
AccountSetupAllSucceeded: acctAll,
HasExplicitFailure: hasFailure,
HasTerminalComplete: hasComplete,
HasEnrollmentComplete: hasEnrollmentComplete,
HasAgentEmergencyBreak: hasEmergencyBreak,
DesktopArrived: desktopArrived,
HelloResolved: helloResolved,
@@ -226,6 +233,46 @@ namespace AutopilotMonitor.Functions.Services
RealmJoinResolved: realmJoinResolved);
}
/// <summary>
/// WhiteGlove Part-2 awaiting-user gate (fairstone.ca analysis 2026-08-21). After the
/// reseal-reboot the session is BY DEFINITION in the user-driven final phase: the agent
/// resumed (<c>whiteglove_resumed</c> → ResumedAt, the only writer of that column), the
/// Part-2 Device ESP re-confirms in minutes, and the device sits at the logon screen.
/// Technicians routinely power the machine off there to box it — well before the 10-min
/// hybrid-login evidence window — so agent silence in this state means "sealed / powered
/// off awaiting the end user", not a stall. The gate demands the full evidence set:
/// pre-provisioned + resumed + Device Setup provisioned, and NO trace of a real user
/// since the resume (no Account Setup progress, no real-user desktop, no Hello terminal,
/// no enrollment completion) and no explicit failure. Any user evidence keeps the honest
/// Stalled/timeout handling — a stall after the user showed up is a real stall.
/// Shared by the maintenance sweep stage 1, the <c>session_stalled</c> ingest mapping and
/// <see cref="ClassifyTimedOutSession"/> so all paths apply one evidence rule.
/// </summary>
public static bool IsWhiteGloveAwaitingUser(
in EspProvisioningRollup rollup, bool isPreProvisioned, DateTime? resumedAt)
=> isPreProvisioned
&& resumedAt.HasValue
&& rollup.DeviceSetupAllSucceeded
&& !rollup.HasExplicitFailure
&& !rollup.HasEnrollmentComplete
&& rollup.AccountSetupSucceededCount == 0
&& !rollup.DesktopArrived
&& !rollup.HelloResolved;
/// <summary>
/// Customer-facing reason for a WhiteGlove Part-2 session parked as AwaitingUser by the
/// silence/stall paths. One authoring spot so the sweep, the ingest mapping and the
/// timeout classifier show the identical text.
/// </summary>
public static string WhiteGloveAwaitingUserReason(in EspProvisioningRollup rollup)
{
var acct = rollup.AccountSetupTotal > 0
? $" (Account Setup {rollup.AccountSetupSucceededCount}/{rollup.AccountSetupTotal})"
: " (Account Setup not yet started)";
return "Pre-provisioning (WhiteGlove) completed; no user sign-in since Part 2 resumed — " +
$"device presumably sealed or powered off awaiting the end user{acct}";
}
/// <summary>
/// Decide the honest terminal (or non-terminal) state for a timed-out session. See the
/// decision table in the design note. Returns the target status and a human-readable
@@ -242,17 +289,44 @@ namespace AutopilotMonitor.Functions.Services
/// mechanism declared success too early" — session efbc17ff. Falls back to
/// <paramref name="startedAtUtc"/> when unknown (same fallback the stalled-marker uses).
/// </param>
/// <param name="isPreProvisioned">Session's IsPreProvisioned flag (WhiteGlove Part-2 gate input).</param>
/// <param name="resumedAt">Session's ResumedAt — set exclusively by the whiteglove_resumed ingest.</param>
public static (SessionStatus Status, string Reason) ClassifyTimedOutSession(
EspProvisioningRollup rollup, DateTime startedAtUtc, DateTime nowUtc, int graceHours,
DateTime? lastEventAtUtc = null)
DateTime? lastEventAtUtc = null, bool isPreProvisioned = false, DateTime? resumedAt = null)
{
// 1. An explicit terminal failure event is a real failure (defensive — such a session
// would normally already be terminal via ingest and not reach the sweep).
if (rollup.HasExplicitFailure)
return (SessionStatus.Failed, "Enrollment reported an explicit failure before timeout");
// 1b. WhiteGlove Part 2 with zero user evidence since the resume: the device is parked
// between technician and end user (sealed box / powered off at the logon screen).
// Must run BEFORE rule 2 — the Part-1 whiteglove_complete in the same event stream
// sets HasTerminalComplete and would otherwise reconcile the session to Succeeded
// with a reason claiming Account Setup completed, which never happened. Within
// grace the honest label is AwaitingUser; past grace the Part-1 success stands as
// the session outcome (the user phase is tracked by the session the device
// registers when it is finally unboxed), stated with an honest WhiteGlove reason.
if (IsWhiteGloveAwaitingUser(rollup, isPreProvisioned, resumedAt))
{
if ((nowUtc - startedAtUtc).TotalHours < graceHours)
return (SessionStatus.AwaitingUser, WhiteGloveAwaitingUserReason(rollup));
return (SessionStatus.Succeeded, AppendReconcileTiming(
$"Reconciled at timeout: pre-provisioning (WhiteGlove Part 1) completed — no user sign-in observed within {graceHours}h grace",
startedAtUtc, nowUtc, lastEventAtUtc));
}
// 2. Account Setup fully succeeded (or a terminal completion event) → reconcile to success.
if (rollup.AccountSetupAllSucceeded || rollup.HasTerminalComplete)
// Once Part 2 has resumed, the Part-1 whiteglove_complete in the same stream no longer
// proves the session's outcome — only a real enrollment_complete does. Without this,
// a resumed session with partial user progress (e.g. Account Setup 1/5, then silence)
// reconciled to Succeeded on Part-1 evidence, with a reason claiming Account Setup
// completed; rules 4-6 below hold the honest verdicts for that shape.
var terminalCompleteProvesOutcome = rollup.HasEnrollmentComplete
|| (rollup.HasTerminalComplete && !resumedAt.HasValue);
if (rollup.AccountSetupAllSucceeded || terminalCompleteProvesOutcome)
return (SessionStatus.Succeeded, AppendReconcileTiming(
"Reconciled at timeout: Account Setup completed (all subcategories succeeded / enrollment_complete observed)",
startedAtUtc, nowUtc, lastEventAtUtc));
@@ -14,7 +14,15 @@ namespace AutopilotMonitor.Functions.Services
foreach (var evt in storedEvents)
{
if (!classification.EarliestEventTimestamp.HasValue || evt.Timestamp < classification.EarliestEventTimestamp.Value)
// System-timeline-watcher rows (clock steps, sleep episodes) are backfilled
// from the Windows event log with original timestamps up to 24h before the
// agent started — environment observation, not enrollment activity. They must
// not pull the session anchor toward pre-enrollment OOBE idle time (mirrors
// TableStorageService.IsSessionAnchorEligible on the Events-table probe;
// field case d0c5b672).
var anchorEligible = evt.Source != "SystemTimelineWatcher";
if (anchorEligible &&
(!classification.EarliestEventTimestamp.HasValue || evt.Timestamp < classification.EarliestEventTimestamp.Value))
classification.EarliestEventTimestamp = evt.Timestamp;
if (!classification.LatestEventTimestamp.HasValue || evt.Timestamp > classification.LatestEventTimestamp.Value)
classification.LatestEventTimestamp = evt.Timestamp;
@@ -279,7 +287,8 @@ namespace AutopilotMonitor.Functions.Services
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(sessionEvents);
var (targetStatus, reason) = EnrollmentTimeoutClassifier.ClassifyTimedOutSession(
rollup, effectiveStart, now, graceHours, session?.LastEventAt ?? c.LatestEventTimestamp);
rollup, effectiveStart, now, graceHours, session?.LastEventAt ?? c.LatestEventTimestamp,
isPreProvisioned: session?.IsPreProvisioned == true, resumedAt: session?.ResumedAt);
// Keep the max-lifetime trigger visible: the Succeeded reasons already carry their own
// silence-transparency clause, everything else gets the watchdog context appended.
@@ -348,7 +357,8 @@ namespace AutopilotMonitor.Functions.Services
var now = DateTime.UtcNow;
var effectiveStart = session.ResumedAt ?? session.StartedAt;
var (targetStatus, reason) = EnrollmentTimeoutClassifier.ClassifyTimedOutSession(
rollup, effectiveStart, now, graceHours, session.LastEventAt ?? c.LatestEventTimestamp);
rollup, effectiveStart, now, graceHours, session.LastEventAt ?? c.LatestEventTimestamp,
isPreProvisioned: session.IsPreProvisioned, resumedAt: session.ResumedAt);
if (targetStatus != SessionStatus.Succeeded)
return;
@@ -368,6 +378,49 @@ namespace AutopilotMonitor.Functions.Services
}
}
/// <summary>
/// WhiteGlove Part-2 awaiting-user gate for the agent-reported stall
/// (<c>session_stalled</c>). Pre-provisioned sessions with a ResumedAt whose event
/// stream shows no user evidence since the resume are parked as AwaitingUser instead of
/// Stalled — same evidence rule as the maintenance sweep
/// (<see cref="EnrollmentTimeoutClassifier.IsWhiteGloveAwaitingUser"/>). Returns true
/// when the AwaitingUser write was applied; false (also on any error, fail-soft) lets
/// the caller run the normal Stalled mapping.
/// </summary>
private async Task<bool> TryMapStallToWhiteGloveAwaitingUserAsync(
IngestEventsRequest request, string sessionPrefix, EventClassification c)
{
try
{
var session = await _sessionRepo.GetSessionAsync(request.TenantId, request.SessionId);
if (session?.IsPreProvisioned != true || !session.ResumedAt.HasValue)
return false;
var sessionEvents = await _sessionRepo.GetSessionEventsAsync(
request.TenantId, request.SessionId, maxResults: 1000);
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(sessionEvents);
if (!EnrollmentTimeoutClassifier.IsWhiteGloveAwaitingUser(
rollup, isPreProvisioned: true, session.ResumedAt))
return false;
var transitioned = await _sessionRepo.UpdateSessionStatusAsync(
request.TenantId, request.SessionId, SessionStatus.AwaitingUser,
earliestEventTimestamp: c.EarliestEventTimestamp, latestEventTimestamp: c.LatestEventTimestamp,
failureReason: EnrollmentTimeoutClassifier.WhiteGloveAwaitingUserReason(rollup));
if (transitioned)
_logger.LogInformation(
"{SessionPrefix} Status: AwaitingUser (agent stall probe on WhiteGlove Part 2 with no user evidence since resume)",
sessionPrefix);
return true;
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"{SessionPrefix} WhiteGlove awaiting-user gate failed for session_stalled; falling back to Stalled", sessionPrefix);
return false;
}
}
private static bool IsPeriodicOrStallEvent(string? eventType) => eventType switch
{
"performance_snapshot" => true,
@@ -559,6 +612,16 @@ namespace AutopilotMonitor.Functions.Services
if (c.SessionStalledEvent != null)
{
// WhiteGlove Part-2 awaiting-user gate (fairstone.ca analysis 2026-08-21): the
// agent-side stall probe also fires on a resumed Part-2 device that simply sits
// at the logon screen with nobody signing in. With zero user evidence since the
// resume that is the expected parking state between technician and end user, so
// the honest label is AwaitingUser, not Stalled. Rare path (one probe batch per
// stalling session), so the session + events reads are acceptable; fail-soft —
// any error falls back to the plain Stalled mapping.
if (await TryMapStallToWhiteGloveAwaitingUserAsync(request, sessionPrefix, c))
return (statusTransitioned, whiteGloveStatusTransitioned, failureReason);
var stalledReason = "Agent reported stall after 60min without progress (stall_probe)";
var stalledTransitioned = await _sessionRepo.UpdateSessionStatusAsync(
request.TenantId, request.SessionId, SessionStatus.Stalled,
@@ -0,0 +1,26 @@
namespace AutopilotMonitor.Functions.Services;
/// <summary>
/// Provider-neutral transactional email contract for the tenant-activation welcome mail.
/// <para>
/// Always fail-soft — implementations must NOT throw; activation correctness never depends
/// on email delivery (the Global Admin "Send welcome email" button is the manual fallback).
/// The farewell mail after offboarding lives on the sibling
/// <see cref="IOffboardFarewellEmailSender"/> contract; both are served by <see cref="EmailService"/>.
/// </para>
/// </summary>
public interface IEmailService
{
/// <summary>
/// Sends the tenant-activation welcome email. No-op when the provider is not configured
/// or the recipient is empty.
/// </summary>
Task SendPreviewApprovedEmailAsync(string toEmail, string domainName, CancellationToken ct = default);
/// <summary>
/// Operator test send: the effective template of <paramref name="kind"/> (or an unsaved
/// <paramref name="draftHtml"/>, placeholder-rendered) to <paramref name="toEmail"/>.
/// Returns true when the provider accepted the message. Never throws.
/// </summary>
Task<bool> SendTestAsync(EmailTemplateKind kind, string toEmail, string domainName, string? draftHtml, CancellationToken ct = default);
}
@@ -109,7 +109,8 @@ namespace AutopilotMonitor.Functions.Services
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(events);
var effectiveStart = session.ResumedAt ?? session.StartedAt;
var (target, reason) = EnrollmentTimeoutClassifier.ClassifyTimedOutSession(
rollup, effectiveStart, now, graceHours, session.LastEventAt);
rollup, effectiveStart, now, graceHours, session.LastEventAt,
isPreProvisioned: session.IsPreProvisioned, resumedAt: session.ResumedAt);
if (target == SessionStatus.Failed)
{
@@ -349,6 +349,7 @@ namespace AutopilotMonitor.Functions.Services
tenantId, silenceCutoff, hardCutoff: cutoffTime);
int silentMarked = 0;
int silentAwaitingUser = 0;
foreach (var silent in silentSessions)
{
// The query is a permissive two-frame pre-filter; the decision is made
@@ -360,6 +361,19 @@ namespace AutopilotMonitor.Functions.Services
if (lastContactAt > silenceCutoff)
continue; // provably alive on the server clock
// WhiteGlove Part-2 awaiting-user gate (fairstone.ca analysis
// 2026-08-21): after the reseal-reboot the technician routinely powers
// the device off at the logon screen to box it — that silence is the
// expected parking state between technician and end user, not a stall.
// Only pre-provisioned sessions with a ResumedAt pay the event read;
// any user evidence since the resume falls through to Stalled.
if (silent.IsPreProvisioned && silent.ResumedAt.HasValue
&& await TryMarkWhiteGloveAwaitingUserAsync(silent))
{
silentAwaitingUser++;
continue;
}
var silentMinutes = (int)(now - lastContactAt).TotalMinutes;
await _sessionRepo.UpdateSessionStatusAsync(
silent.TenantId,
@@ -370,19 +384,20 @@ namespace AutopilotMonitor.Functions.Services
silentMarked++;
}
if (silentMarked > 0)
if (silentMarked > 0 || silentAwaitingUser > 0)
{
totalSessionsMarkedStalled += silentMarked;
_logger.LogInformation($"Tenant {tenantId}: Marked {silentMarked} agent-silent sessions as Stalled (silence threshold: {agentSilenceHours}h)");
_logger.LogInformation($"Tenant {tenantId}: Marked {silentMarked} agent-silent sessions as Stalled, {silentAwaitingUser} as AwaitingUser (WhiteGlove Part 2; silence threshold: {agentSilenceHours}h)");
await _maintenanceRepo.LogAuditEntryAsync(
tenantId,
"SessionStalled",
"Session",
$"{silentMarked} sessions",
$"{silentMarked + silentAwaitingUser} sessions",
"System.Maintenance",
new Dictionary<string, string>
{
{ "SessionsMarkedStalled", silentMarked.ToString() },
{ "SessionsAwaitingUser", silentAwaitingUser.ToString() },
{ "AgentSilenceHours", agentSilenceHours.ToString() },
{ "SilenceCutoff", silenceCutoff.ToString("yyyy-MM-ddTHH:mm:ss") }
});
@@ -454,7 +469,8 @@ namespace AutopilotMonitor.Functions.Services
// Device Setup → Incomplete. Never Failed without an explicit failure signal.
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(sessionEvents);
var (targetStatus, reason) = EnrollmentTimeoutClassifier.ClassifyTimedOutSession(
rollup, effectiveStart, now, graceHours, session.LastEventAt);
rollup, effectiveStart, now, graceHours, session.LastEventAt,
isPreProvisioned: session.IsPreProvisioned, resumedAt: session.ResumedAt);
// No-op if the verdict equals the current (non-terminal) state.
if (targetStatus == session.Status)
@@ -569,6 +585,44 @@ namespace AutopilotMonitor.Functions.Services
}
}
/// <summary>
/// Applies the WhiteGlove Part-2 awaiting-user gate to one agent-silent candidate
/// (pre-provisioned + ResumedAt set — the caller pre-filters so only WhiteGlove Part-2
/// runs pay the event read). Reads the session's events, distills the classifier rollup
/// and, when <see cref="EnrollmentTimeoutClassifier.IsWhiteGloveAwaitingUser"/> confirms
/// there is no user evidence since the resume, parks the session as AwaitingUser instead
/// of Stalled. Returns true when the AwaitingUser write happened; false (fail-soft, also
/// on read errors) lets the caller fall through to the normal Stalled marker.
/// </summary>
private async Task<bool> TryMarkWhiteGloveAwaitingUserAsync(SessionSummary silent)
{
try
{
var events = await _sessionRepo.GetSessionEventsAsync(
silent.TenantId, silent.SessionId, maxResults: 1000);
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(events);
if (!EnrollmentTimeoutClassifier.IsWhiteGloveAwaitingUser(
rollup, silent.IsPreProvisioned, silent.ResumedAt))
return false;
var transitioned = await _sessionRepo.UpdateSessionStatusAsync(
silent.TenantId,
silent.SessionId,
SessionStatus.AwaitingUser,
failureReason: EnrollmentTimeoutClassifier.WhiteGloveAwaitingUserReason(rollup));
if (transitioned)
_logger.LogInformation(
$"Session {silent.SessionId}: WhiteGlove Part 2 silent with no user evidence — parked as AwaitingUser instead of Stalled");
return transitioned;
}
catch (Exception ex)
{
_logger.LogWarning(ex,
$"WhiteGlove awaiting-user gate failed for session {silent.SessionId}; falling back to Stalled marker");
return false;
}
}
/// <summary>
/// Builds the server-authored <c>session_timeout</c> event injected into the stream when the
/// maintenance sweep graduates a stalled session to terminal Failed. Static + pure so the field
@@ -0,0 +1,140 @@
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using AutopilotMonitor.Shared.Models.Notifications;
using Newtonsoft.Json;
namespace AutopilotMonitor.Functions.Services.Notifications
{
/// <summary>
/// Renders a NotificationAlert as a Discord webhook payload (single embed).
/// Discord webhooks cannot post buttons, so openUrl actions are appended to the
/// embed description as markdown links.
/// </summary>
public class DiscordRenderer : INotificationRenderer
{
// Discord embed limits: https://discord.com/developers/docs/resources/message#embed-object-embed-limits
private const int MaxTitleLength = 256;
private const int MaxDescriptionLength = 4096;
private const int MaxFields = 25;
private const int MaxFieldNameLength = 256;
private const int MaxFieldValueLength = 1024;
private const int MaxEmbedTotalLength = 6000;
public WebhookProviderType ProviderType => WebhookProviderType.Discord;
public string RenderToJson(NotificationAlert alert)
{
var title = Truncate($"{GetSeverityEmoji(alert.Severity)} {alert.Title}", MaxTitleLength);
var description = Truncate(BuildDescription(alert), MaxDescriptionLength);
// Facts as inline fields. Discord rejects fields with an empty name or value.
var facts = alert.Facts
.Where(f => !string.IsNullOrWhiteSpace(f.Name) && !string.IsNullOrWhiteSpace(f.Value))
.Take(MaxFields)
.Select(f => (Name: Truncate(f.Name, MaxFieldNameLength), Value: Truncate(f.Value, MaxFieldValueLength)))
.ToList();
// Enforce the 6000-char total cap across title + description + fields:
// drop trailing fields first, then shrink the description to the remaining budget.
var fixedLength = title.Length + description.Length;
while (facts.Count > 0 && fixedLength + facts.Sum(f => f.Name.Length + f.Value.Length) > MaxEmbedTotalLength)
facts.RemoveAt(facts.Count - 1);
if (fixedLength > MaxEmbedTotalLength)
description = Truncate(description, MaxEmbedTotalLength - title.Length);
var fields = facts
.Select(f => new { name = f.Name, value = f.Value, inline = true })
.ToArray();
var embed = new
{
title,
description = description.Length > 0 ? description : null,
color = GetColor(alert),
fields = fields.Length > 0 ? fields : null
};
var payload = new { embeds = new[] { embed } };
return JsonConvert.SerializeObject(payload, new JsonSerializerSettings
{
NullValueHandling = NullValueHandling.Ignore,
ContractResolver = new Newtonsoft.Json.Serialization.DefaultContractResolver
{
NamingStrategy = new Newtonsoft.Json.Serialization.CamelCaseNamingStrategy()
}
});
}
private static string BuildDescription(NotificationAlert alert)
{
var sb = new StringBuilder();
if (!string.IsNullOrEmpty(alert.Summary))
sb.Append(alert.Summary);
foreach (var section in alert.Sections)
{
var text = "";
if (!string.IsNullOrEmpty(section.Title))
text += $"**{section.Title}**\n";
if (!string.IsNullOrEmpty(section.Text))
text += section.Text;
if (text.Length > 0)
{
if (sb.Length > 0)
sb.Append("\n\n");
sb.Append(text);
}
}
foreach (var action in alert.Actions.Where(a => a.Type == "openUrl" && !string.IsNullOrEmpty(a.Url)))
{
if (sb.Length > 0)
sb.Append("\n\n");
sb.Append($"[{action.Title}]({action.Url})");
}
return sb.ToString();
}
private static int GetColor(NotificationAlert alert)
{
if (!string.IsNullOrEmpty(alert.ThemeColor)
&& int.TryParse(alert.ThemeColor.TrimStart('#'), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var color))
return color;
return alert.Severity switch
{
NotificationSeverity.Success => 0x2ECC71,
NotificationSeverity.Error => 0xE74C3C,
NotificationSeverity.Warning => 0xF1C40F,
_ => 0x3498DB
};
}
private static string GetSeverityEmoji(NotificationSeverity severity)
{
return severity switch
{
NotificationSeverity.Success => "\u2705",
NotificationSeverity.Error => "\u274c",
NotificationSeverity.Warning => "\u26a0\ufe0f",
NotificationSeverity.Info => "\u2139\ufe0f",
_ => "\u2139\ufe0f"
};
}
private static string Truncate(string value, int maxLength)
{
if (maxLength <= 0)
return "";
if (value.Length <= maxLength)
return value;
return value.Substring(0, maxLength - 1) + "\u2026";
}
}
}
@@ -29,6 +29,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
[WebhookProviderType.TeamsWorkflowWebhook] = new TeamsWorkflowAdaptiveCardRenderer(),
[WebhookProviderType.Slack] = new SlackRenderer(),
[WebhookProviderType.GenericJson] = new GenericJsonRenderer(),
[WebhookProviderType.Discord] = new DiscordRenderer(),
};
}
@@ -36,9 +37,11 @@ namespace AutopilotMonitor.Functions.Services.Notifications
/// Sends a notification (fire-and-forget, non-fatal). Exceptions are logged as warnings.
/// <paramref name="customHeaders"/> (generic webhooks only) are attached to the request,
/// e.g. an API-key/Authorization header for a ticketing system or SMTP gateway.
/// <paramref name="signingSecret"/> (generic webhooks only) adds HMAC signature headers
/// (see <see cref="WebhookSignatureCalculator"/>).
/// </summary>
public async Task SendNotificationAsync(string webhookUrl, WebhookProviderType providerType, NotificationAlert alert,
IReadOnlyDictionary<string, string>? customHeaders = null)
IReadOnlyDictionary<string, string>? customHeaders = null, string? signingSecret = null)
{
if (string.IsNullOrEmpty(webhookUrl) || providerType == WebhookProviderType.None)
return;
@@ -54,7 +57,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
await SsrfGuard.ValidateDestinationAsync(webhookUrl);
var json = renderer.RenderToJson(alert);
var response = await PostAsync(webhookUrl, json, customHeaders);
var response = await PostAsync(webhookUrl, json, customHeaders, signingSecret);
if (response.IsSuccessStatusCode)
_logger.LogInformation("Webhook notification sent: {Summary} (provider={ProviderType})", alert.Summary, providerType);
@@ -88,16 +91,18 @@ namespace AutopilotMonitor.Functions.Services.Notifications
channel.Url,
(WebhookProviderType)channel.ProviderType,
alert,
channel.GetCustomHeaders());
channel.GetCustomHeaders(),
channel.GetSigningSecret());
}
}
/// <summary>
/// Sends a notification and returns the result (for test endpoint). Not fire-and-forget.
/// <paramref name="customHeaders"/> (generic webhooks only) are attached to the request.
/// <paramref name="customHeaders"/> and <paramref name="signingSecret"/> (generic
/// webhooks only) are applied exactly like in <see cref="SendNotificationAsync"/>.
/// </summary>
public async Task<WebhookTestResult> SendNotificationWithResultAsync(string webhookUrl, WebhookProviderType providerType, NotificationAlert alert,
IReadOnlyDictionary<string, string>? customHeaders = null)
IReadOnlyDictionary<string, string>? customHeaders = null, string? signingSecret = null)
{
if (string.IsNullOrEmpty(webhookUrl))
return new WebhookTestResult { Success = false, Message = "Webhook URL is not configured." };
@@ -120,7 +125,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
try
{
var json = renderer.RenderToJson(alert);
var response = await PostAsync(webhookUrl, json, customHeaders);
var response = await PostAsync(webhookUrl, json, customHeaders, signingSecret);
var statusCode = (int)response.StatusCode;
if (response.IsSuccessStatusCode)
@@ -145,8 +150,11 @@ namespace AutopilotMonitor.Functions.Services.Notifications
/// <summary>
/// POSTs the rendered JSON, attaching any custom headers as request headers. Restricted
/// (framing/host/content) headers are already filtered upstream by GetGenericWebhookHeaders().
/// When a signing secret is present, HMAC signature headers are computed over the exact
/// JSON string being posted — after custom headers, so they can never be spoofed by one.
/// </summary>
private Task<HttpResponseMessage> PostAsync(string webhookUrl, string json, IReadOnlyDictionary<string, string>? customHeaders)
private Task<HttpResponseMessage> PostAsync(string webhookUrl, string json, IReadOnlyDictionary<string, string>? customHeaders,
string? signingSecret = null)
{
var request = new HttpRequestMessage(HttpMethod.Post, webhookUrl)
{
@@ -159,6 +167,16 @@ namespace AutopilotMonitor.Functions.Services.Notifications
request.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
if (!string.IsNullOrEmpty(signingSecret))
{
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(System.Globalization.CultureInfo.InvariantCulture);
request.Headers.Remove(WebhookSignatureCalculator.TimestampHeader);
request.Headers.Remove(WebhookSignatureCalculator.SignatureHeader);
request.Headers.TryAddWithoutValidation(WebhookSignatureCalculator.TimestampHeader, timestamp);
request.Headers.TryAddWithoutValidation(WebhookSignatureCalculator.SignatureHeader,
WebhookSignatureCalculator.ComputeSignature(signingSecret, timestamp, json));
}
return _http.SendAsync(request);
}
}
@@ -0,0 +1,31 @@
using System;
using System.Security.Cryptography;
using System.Text;
namespace AutopilotMonitor.Functions.Services.Notifications
{
/// <summary>
/// Computes the HMAC request signature for generic JSON webhooks (Stripe-style scheme).
/// The receiver recomputes HMACSHA256(secret, "{timestamp}.{rawBody}") and compares it
/// (constant-time) against the signature header; the timestamp binds replay protection.
/// </summary>
public static class WebhookSignatureCalculator
{
/// <summary>Header carrying the unix-seconds timestamp the signature was computed with.</summary>
public const string TimestampHeader = "X-AutopilotMonitor-Timestamp";
/// <summary>Header carrying the signature: "sha256=&lt;lowercase hex HMAC&gt;".</summary>
public const string SignatureHeader = "X-AutopilotMonitor-Signature";
/// <summary>
/// Computes "sha256=&lt;hex&gt;" over "{timestamp}.{body}" with the given secret.
/// Secret and message are UTF-8; hex is lowercase.
/// </summary>
public static string ComputeSignature(string secret, string timestamp, string body)
{
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes($"{timestamp}.{body}"));
return "sha256=" + Convert.ToHexString(hash).ToLowerInvariant();
}
}
}
@@ -527,6 +527,60 @@ namespace AutopilotMonitor.Functions.Services
tenantId, "System.TrialSweep", new { domainName, trialExpiredUtc });
}
// ── Plan downgrade / retention grace (informational — enforcement is read-time) ──
// All three types are dual-registered in OpsAlertRulesSection.tsx OPS_EVENT_TYPES
// (memory feedback_ops_event_types_dual_register). Downgraded is dispatched by
// PlanManagementFunction, the grace events by TrialExpirySweepFunction.
/// <summary>
/// A GA plan mutation dropped the tenant's EFFECTIVE edition Pro → Community. The
/// retention downgrade grace period starts now; data older than the Community cap is
/// deleted only after it ends. Warning-tier: the business moment (e.g. non-payment)
/// an operator wants a ping for.
/// </summary>
public Task RecordTenantPlanDowngradedAsync(
string tenantId, string? domainName, string caller, DateTime? retentionGraceEndsUtc, int storedRetentionDays)
{
var tenantLabel = string.IsNullOrWhiteSpace(domainName) ? tenantId : $"{domainName} ({tenantId})";
var graceNote = retentionGraceEndsUtc is DateTime graceEnd
? $"retention grace until {graceEnd:yyyy-MM-dd HH:mm}Z"
: "no retention grace applicable";
return WriteAsync(OpsEventCategory.Tenant, "TenantPlanDowngraded", OpsEventSeverity.Warning,
$"Tenant {tenantLabel} was downgraded Pro → Community by {caller} — {graceNote} (stored retention {storedRetentionDays}d)",
tenantId, caller, new { domainName, retentionGraceEndsUtc, storedRetentionDays });
}
/// <summary>
/// Heads-up: a downgraded tenant's retention grace ends within the next few days —
/// after that, the sweep enforces the Community cap and deletes the older data.
/// Emitted only for tenants that actually have data at risk (stored retention above
/// the Community cap). Re-fired daily during the heads-up window (no dedup state).
/// </summary>
public Task RecordTenantRetentionGraceExpiringAsync(
string tenantId, string? domainName, DateTime graceEndsUtc, int daysLeft, int storedRetentionDays)
{
var tenantLabel = string.IsNullOrWhiteSpace(domainName) ? tenantId : $"{domainName} ({tenantId})";
return WriteAsync(OpsEventCategory.Tenant, "TenantRetentionGraceExpiring", OpsEventSeverity.Warning,
$"Retention grace for downgraded tenant {tenantLabel} ends in {daysLeft} day(s) ({graceEndsUtc:yyyy-MM-dd HH:mm}Z) — " +
$"data older than the Community cap will then be deleted (stored retention {storedRetentionDays}d)",
tenantId, "System.TrialSweep", new { domainName, graceEndsUtc, daysLeft, storedRetentionDays });
}
/// <summary>
/// A downgraded tenant's retention grace ended within the last sweep window — from now
/// on the retention sweep enforces the Community cap and starts deleting the older data.
/// Last call for a GA intervention (re-upgrade / retention decision).
/// </summary>
public Task RecordTenantRetentionGraceEndedAsync(
string tenantId, string? domainName, DateTime graceEndedUtc, int storedRetentionDays)
{
var tenantLabel = string.IsNullOrWhiteSpace(domainName) ? tenantId : $"{domainName} ({tenantId})";
return WriteAsync(OpsEventCategory.Tenant, "TenantRetentionGraceEnded", OpsEventSeverity.Warning,
$"Retention grace for downgraded tenant {tenantLabel} ended ({graceEndedUtc:yyyy-MM-dd HH:mm}Z) — " +
$"the Community retention cap is now enforced and older data will be deleted (stored retention {storedRetentionDays}d)",
tenantId, "System.TrialSweep", new { domainName, graceEndedUtc, storedRetentionDays });
}
/// <summary>
/// F3 regression radar (insights spec §F3): an analyze rule's 7-day hit rate rose ≥2×
/// over its 28-day baseline with disjoint Wilson intervals. Fired ONCE per episode (the
@@ -1,118 +0,0 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Resend;
namespace AutopilotMonitor.Functions.Services;
/// <summary>
/// Sends transactional emails via Resend.com for tenant-activation notifications.
/// Best-effort: failures are logged as warnings and never propagated.
/// Temporary — remove after GA.
/// </summary>
public class ResendEmailService : IOffboardFarewellEmailSender
{
private readonly string _apiKey;
private readonly ILogger<ResendEmailService> _logger;
public ResendEmailService(
IConfiguration configuration,
ILogger<ResendEmailService> logger)
{
_logger = logger;
_apiKey = configuration["RESEND_API_KEY"] ?? string.Empty;
}
/// <summary>
/// Sends the tenant-activation welcome email.
/// No-op if the API key or recipient email is not configured.
/// </summary>
public virtual async Task SendPreviewApprovedEmailAsync(string toEmail, string domainName)
{
if (string.IsNullOrWhiteSpace(_apiKey))
{
_logger.LogDebug("RESEND_API_KEY not configured — skipping preview approval email");
return;
}
if (string.IsNullOrWhiteSpace(toEmail))
{
_logger.LogDebug("No notification email set — skipping preview approval email for {Domain}", domainName);
return;
}
try
{
var resend = ResendClient.Create(_apiKey);
var message = new EmailMessage
{
From = "Autopilot Monitor <noreply@autopilotmonitor.com>",
To = toEmail,
Subject = EmailTemplates.PreviewApprovedSubject,
HtmlBody = EmailTemplates.GetPreviewApprovedHtml(domainName)
};
await resend.EmailSendAsync(message);
_logger.LogInformation(
"Preview approval email sent to {ToEmail} for domain {Domain}",
toEmail, domainName);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Failed to send preview approval email to {ToEmail} for domain {Domain}",
toEmail, domainName);
}
}
/// <summary>
/// Sends the post-offboarding "sorry to see you go" farewell email.
/// No-op if the API key or recipient email is not configured. Best-effort: failures
/// are logged as warnings and never propagated (the offboarding correctness contract
/// does not depend on email delivery).
/// </summary>
public async Task SendAsync(string toEmail, string domainName, string tenantId, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(_apiKey))
{
_logger.LogDebug(
"RESEND_API_KEY not configured — skipping offboard farewell email for tenant {TenantId}",
tenantId);
return;
}
if (string.IsNullOrWhiteSpace(toEmail))
{
_logger.LogDebug(
"No notification email captured — skipping offboard farewell email for tenant {TenantId} ({Domain})",
tenantId, domainName);
return;
}
try
{
var resend = ResendClient.Create(_apiKey);
var message = new EmailMessage
{
From = "Autopilot Monitor <noreply@autopilotmonitor.com>",
To = toEmail,
Subject = EmailTemplates.OffboardingFarewellSubject,
HtmlBody = EmailTemplates.GetOffboardingFarewellHtml(domainName)
};
await resend.EmailSendAsync(message, ct);
_logger.LogInformation(
"Offboard farewell email sent to {ToEmail} for tenant {TenantId} ({Domain})",
toEmail, tenantId, domainName);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Failed to send offboard farewell email to {ToEmail} for tenant {TenantId} ({Domain})",
toEmail, tenantId, domainName);
}
}
}
@@ -3250,12 +3250,27 @@ namespace AutopilotMonitor.Functions.Services
}
/// <summary>
/// Returns the earliest event timestamp persisted for a session, if any.
/// Events are written with RowKey "{Timestamp}_{Sequence}", so querying the partition
/// and taking the first row yields the earliest event. Resolution must NOT rely on
/// the system Timestamp: it is reset by storage migrations, and this value feeds the
/// StartedAt/Duration reconciliation — a migration-reset read would rewrite those
/// session fields with the migration date.
/// Event sources whose rows never anchor the session. The system timeline watcher
/// backfills environment observations (clock steps, sleep episodes) from the Windows
/// event log with their ORIGINAL timestamps — up to 24h before the agent started.
/// They belong on the timeline, but they are not enrollment activity: anchoring on
/// them silently stretches StartedAt/DurationSeconds toward pre-enrollment OOBE idle
/// time (field case d0c5b672: a pre-agent clock correction pulled StartedAt ~14min
/// before the first real activity; the 2h backward-shift guard only caps, not
/// prevents, this). User decision 2026-08-21: the session anchor stays on real
/// enrollment activity.
/// </summary>
internal static bool IsSessionAnchorEligible(TableEntity eventRow)
=> eventRow.GetString("Source") != "SystemTimelineWatcher";
/// <summary>
/// Returns the earliest ANCHOR-ELIGIBLE event timestamp persisted for a session, if
/// any. Events are written with RowKey "{Timestamp}_{Sequence}", so scanning the
/// partition in order and taking the first eligible row (see
/// <see cref="IsSessionAnchorEligible"/>) yields the earliest activity event.
/// Resolution must NOT rely on the system Timestamp: it is reset by storage
/// migrations, and this value feeds the StartedAt/Duration reconciliation — a
/// migration-reset read would rewrite those session fields with the migration date.
/// </summary>
private async Task<DateTime?> GetEarliestSessionEventTimestampAsync(string tenantId, string sessionId)
{
@@ -3265,12 +3280,15 @@ namespace AutopilotMonitor.Functions.Services
var partitionKey = $"{tenantId}_{sessionId}";
var query = tableClient.QueryAsync<TableEntity>(
filter: $"PartitionKey eq '{partitionKey}'",
maxPerPage: 1,
select: new[] { "RowKey", BusinessTimestamp.OccurredUtcColumn, "Timestamp" }
maxPerPage: 25,
select: new[] { "RowKey", BusinessTimestamp.OccurredUtcColumn, "Timestamp", "Source" }
);
await foreach (var entity in query)
{
if (!IsSessionAnchorEligible(entity))
continue; // environment observation, not enrollment activity — keep scanning
return ResolveEventTimestampOrNull(entity);
}
}
@@ -16,20 +16,20 @@ public class TenantApprovalService
private readonly PreviewWhitelistService _previewWhitelistService;
private readonly TenantConfigurationService _tenantConfigurationService;
private readonly TenantAdminsService _tenantAdminsService;
private readonly ResendEmailService _resendEmailService;
private readonly IEmailService _emailService;
public TenantApprovalService(
ILogger<TenantApprovalService> logger,
PreviewWhitelistService previewWhitelistService,
TenantConfigurationService tenantConfigurationService,
TenantAdminsService tenantAdminsService,
ResendEmailService resendEmailService)
IEmailService emailService)
{
_logger = logger;
_previewWhitelistService = previewWhitelistService;
_tenantConfigurationService = tenantConfigurationService;
_tenantAdminsService = tenantAdminsService;
_resendEmailService = resendEmailService;
_emailService = emailService;
}
/// <summary>
@@ -140,7 +140,7 @@ public class TenantApprovalService
}
var tenantConfig = await _tenantConfigurationService.GetConfigurationAsync(tenantId);
_ = _resendEmailService.SendPreviewApprovedEmailAsync(
_ = _emailService.SendPreviewApprovedEmailAsync(
notificationEmail, tenantConfig.DomainName)
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException,
"Fire-and-forget welcome email failed for tenant {TenantId}", tenantId),
@@ -121,6 +121,7 @@ namespace AutopilotMonitor.Functions.Services
"LastUpdated", "UpdatedBy", "OnboardedAt", "OnboardedBy",
"HomedAppClientId", "LastAuthClientId", "LastAuthClientIdSince",
"PlanTier", "TrialExpiresUtc", "TrialStartedUtc", "TrialConsumed", "TrialGrantedBy",
"ProDowngradedUtc",
};
/// <summary>
@@ -148,6 +149,7 @@ namespace AutopilotMonitor.Functions.Services
"HomedAppClientId", "LastAuthClientId", "LastAuthClientIdSince",
"OnboardedBy", "OnboardedAt",
"PlanTier", "TrialExpiresUtc", "TrialStartedUtc", "TrialConsumed", "TrialGrantedBy",
"ProDowngradedUtc",
};
private static readonly Dictionary<string, PropertyInfo> ModelProperties =
@@ -97,10 +97,36 @@ namespace AutopilotMonitor.Functions.Services
&& config.UnrestrictedModeEnabled
&& config.UnrestrictedMode;
/// <summary>
/// End of the retention downgrade grace period, or null when none applies (tenant is
/// effectively Pro, or never lost Pro). The anchor is the LATEST of the explicit
/// downgrade timestamp (<see cref="TenantConfiguration.ProDowngradedUtc"/>, written by
/// the plan endpoint) and an expired trial's <see cref="TenantConfiguration.TrialExpiresUtc"/>
/// (read-time, no write on expiry). A returned value in the past means the grace is over.
/// </summary>
public static DateTime? GetRetentionGraceEndUtc(TenantConfiguration config, DateTime nowUtc)
{
if (ResolveEdition(config, nowUtc) == TenantEdition.Pro)
return null;
DateTime? anchor = config.ProDowngradedUtc;
if (config.TrialExpiresUtc is DateTime trialEnd && trialEnd <= nowUtc &&
(anchor is null || trialEnd > anchor))
{
anchor = trialEnd;
}
return anchor?.AddDays(FeatureEntitlementCatalog.RetentionDowngradeGraceDays);
}
/// <summary>
/// The retention days the platform actually enforces for this config: the stored value
/// clamped to the edition's cap. <c>days &lt;= 0</c> is the GA-only "infinite" escape hatch
/// and is passed through unclamped (the fanout skips those tenants entirely).
/// Downgrade grace: for <see cref="FeatureEntitlementCatalog.RetentionDowngradeGraceDays"/>
/// days after losing Pro the PRO cap keeps applying, so a downgrade or trial expiry never
/// immediately hard-deletes the 90–365-day band. Retention is the only entitlement with
/// a grace — the others are reversible gates.
/// </summary>
public static int GetEffectiveRetentionDays(TenantConfiguration config, DateTime nowUtc)
{
@@ -108,7 +134,14 @@ namespace AutopilotMonitor.Functions.Services
if (days <= 0)
return 0;
var cap = FeatureEntitlementCatalog.Get(ResolveEdition(config, nowUtc)).RetentionCapDays;
var edition = ResolveEdition(config, nowUtc);
var cap = FeatureEntitlementCatalog.Get(edition).RetentionCapDays;
if (edition == TenantEdition.Community &&
GetRetentionGraceEndUtc(config, nowUtc) is DateTime graceEnd && graceEnd > nowUtc)
{
cap = FeatureEntitlementCatalog.Get(TenantEdition.Pro).RetentionCapDays;
}
return Math.Min(days, cap);
}
}
@@ -83,6 +83,12 @@ namespace AutopilotMonitor.Shared.DataAccess
Task<Dictionary<string, string>> GetPreviewConfigAsync();
Task<bool> SavePreviewConfigAsync(string key, string value);
// --- Email Template Overrides (operator-level, PreviewConfig table, partition "EmailTemplates") ---
/// <summary>Returns the stored override for the given kind, or null when the built-in template applies.</summary>
Task<EmailTemplateOverride?> GetEmailTemplateOverrideAsync(string kind);
Task SaveEmailTemplateOverrideAsync(EmailTemplateOverride overrideEntry);
Task DeleteEmailTemplateOverrideAsync(string kind);
// --- Preview Notification Email ---
Task<string?> GetNotificationEmailAsync(string tenantId);
Task SaveNotificationEmailAsync(string tenantId, string? email);
@@ -0,0 +1,17 @@
using System;
namespace AutopilotMonitor.Shared.Models
{
/// <summary>
/// Operator-supplied replacement for one built-in transactional email template
/// (kind = "welcome" | "farewell"). Stored in the PreviewConfig table, partition
/// "EmailTemplates". The HTML may contain the <c>{{domainName}}</c> placeholder.
/// </summary>
public sealed class EmailTemplateOverride
{
public string Kind { get; set; } = string.Empty;
public string Html { get; set; } = string.Empty;
public string UpdatedBy { get; set; } = string.Empty;
public DateTime UpdatedUtc { get; set; }
}
}
@@ -166,6 +166,17 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public string? TrialGrantedBy { get; set; }
/// <summary>
/// When the tenant's EFFECTIVE edition last dropped Pro → Community via the plan
/// endpoint (UTC). Anchors the retention downgrade grace period: for
/// RetentionDowngradeGraceDays after losing Pro the retention cap stays at the Pro
/// value so a downgrade (e.g. non-payment) does not immediately hard-delete data
/// older than the Community cap. Trial expiry needs no write — TrialExpiresUtc
/// itself is the anchor there. Cleared whenever the tenant becomes effectively Pro
/// again. Backend-only: not delivered to the agent (no ConfigVersion impact).
/// </summary>
public DateTime? ProDowngradedUtc { get; set; }
/// <summary>
/// Hardware whitelist: Allowed manufacturers (supports wildcards like "Dell*")
/// Comma-separated list
@@ -809,6 +820,7 @@ namespace AutopilotMonitor.Shared.Models
{
channel.Url = RedactNullable(channel.Url);
channel.CustomHeadersJson = RedactNullable(channel.CustomHeadersJson);
channel.SigningSecret = RedactNullable(channel.SigningSecret);
}
return Notifications.NotificationChannel.SerializeList(channels);
@@ -867,6 +879,7 @@ namespace AutopilotMonitor.Shared.Models
continue;
if (channel.Url == Constants.RedactedSecretPlaceholder) channel.Url = match.Url;
if (channel.CustomHeadersJson == Constants.RedactedSecretPlaceholder) channel.CustomHeadersJson = match.CustomHeadersJson;
if (channel.SigningSecret == Constants.RedactedSecretPlaceholder) channel.SigningSecret = match.SigningSecret;
}
NotificationChannelsJson = Notifications.NotificationChannel.SerializeList(incoming);
@@ -0,0 +1,183 @@
using System.Collections.Generic;
namespace AutopilotMonitor.Shared.Models
{
/// <summary>
/// Gate for a built-in diagnostics section. The agent evaluates it against its effective
/// configuration and the enrollment scenario; the portal renders it as a context pill.
/// </summary>
public enum DiagnosticsSectionCondition
{
/// <summary>Collected by every agent on every enrollment.</summary>
Always = 0,
/// <summary>
/// Collected only when the tenant's RealmJoin Watcher is enabled
/// (<c>AnalyzerConfiguration.EnableRealmJoinWatcher</c>).
/// </summary>
RealmJoinWatcher = 1,
/// <summary>
/// Collected only on Windows Autopilot Device Preparation enrollments (deterministic
/// WDP marker — see <c>EnrollmentRegistryDetector.IsDeterministicDevicePreparation</c>).
/// </summary>
DevicePreparation = 2,
}
/// <summary>
/// One built-in collection section of the diagnostics ZIP. Pure data — no I/O. The agent
/// expands <see cref="SourceFolder"/> (environment variables and the
/// <see cref="DiagnosticsBuiltInSections.UserProfileToken"/>) at collection time.
/// </summary>
public sealed class DiagnosticsBuiltInSection
{
/// <summary>Stable key — used for test overrides and package-manifest lines.</summary>
public string Id { get; }
/// <summary>Folder inside the ZIP (e.g. <c>AgentLogs</c>, <c>RealmJoinLogs/Windows</c>).</summary>
public string ZipFolder { get; }
/// <summary>
/// UNEXPANDED source folder: may contain <c>%ProgramData%</c> or the
/// <see cref="DiagnosticsBuiltInSections.UserProfileToken"/>.
/// </summary>
public string SourceFolder { get; }
/// <summary>File-name patterns collected from <see cref="SourceFolder"/>.</summary>
public IReadOnlyList<string> Patterns { get; }
/// <summary>When true, subdirectories are walked recursively (tree preserved in the ZIP).</summary>
public bool IncludeSubfolders { get; }
/// <summary>Human-readable description shown in the portal.</summary>
public string Description { get; }
public DiagnosticsSectionCondition Condition { get; }
public DiagnosticsBuiltInSection(
string id,
string zipFolder,
string sourceFolder,
string[] patterns,
bool includeSubfolders,
string description,
DiagnosticsSectionCondition condition = DiagnosticsSectionCondition.Always)
{
Id = id;
ZipFolder = zipFolder;
SourceFolder = sourceFolder;
Patterns = patterns;
IncludeSubfolders = includeSubfolders;
Description = description;
Condition = condition;
}
}
/// <summary>
/// The single source of truth for what a diagnostics package collects BEFORE any
/// admin-configured path: the agent iterates <see cref="All"/> in order when building the
/// archive, and the backend serves the same list to the portal (GET diagnostics/paths) so
/// what administrators see is exactly what the agent does. Built-in sections are reviewed
/// code and therefore bypass the configured-path guards (DiagnosticsPathGuards), which exist
/// to validate admin-typed input.
/// </summary>
public static class DiagnosticsBuiltInSections
{
/// <summary>
/// Custom token resolved by the agent to the signed-in user's profile folder
/// (the agent runs as SYSTEM, so <c>%USERPROFILE%</c> would point at the SYSTEM profile).
/// </summary>
public const string UserProfileToken = "%LOGGED_ON_USER_PROFILE%";
/// <summary>
/// File extensions collected from log folders (Agent + IME): standard logs, structured
/// data, traces, event logs, and diagnostics archives.
/// </summary>
public static readonly string[] LogFilePatterns =
{
"*.log", "*.txt", "*.json", "*.jsonl",
"*.etl", "*.evtx", "*.xml", "*.csv", "*.cab",
};
/// <summary>
/// Patterns collected from the agent state folder: the log patterns plus completion
/// markers (<c>enrollment-complete.marker</c>, <c>whiteglove-backfill-state.json</c>).
/// </summary>
public static readonly string[] StateFilePatterns =
{
"*.log", "*.txt", "*.json", "*.jsonl",
"*.etl", "*.evtx", "*.xml", "*.csv", "*.cab",
"*.complete", "*.marker",
};
/// <summary>
/// Marker patterns from the top-level data directory. Kept tight on purpose: only
/// completion/exit markers, no session.id / bootstrap.json (config, not forensic state).
/// </summary>
public static readonly string[] RootMarkerPatterns = { "*.complete", "*.marker" };
/// <summary>Telemetry spool: only the JSON-shaped files (pending uploads + upload cursor).</summary>
public static readonly string[] SpoolFilePatterns = { "*.jsonl", "*.json" };
/// <summary>
/// Ordered catalog — archive order equals catalog order. The Always sections form the
/// historical package layout and are lock-tested; the conditional sections were added
/// 2026-08-23 (RealmJoin logs behind the tenant's RealmJoin Watcher toggle, the Device
/// Preparation bootstrapper event log behind the deterministic WDP marker).
/// </summary>
public static readonly IReadOnlyList<DiagnosticsBuiltInSection> All = new[]
{
new DiagnosticsBuiltInSection(
"AgentLogs", "AgentLogs", Constants.LogDirectory, LogFilePatterns, false,
"Autopilot Monitor agent logs"),
new DiagnosticsBuiltInSection(
"ImeLogs", "ImeLogs", @"%ProgramData%\Microsoft\IntuneManagementExtension\Logs", LogFilePatterns, false,
"Intune Management Extension logs"),
// Active event-log channels hold their .evtx exclusively locked; the agent exports
// the channel via wevtutil, which costs seconds — only worth it on WDP enrollments,
// where this channel IS the provisioning record (batches, workloads, errors).
new DiagnosticsBuiltInSection(
"ImeBootstrapperEventLog", "ImeLogs", @"C:\Windows\System32\winevt\Logs",
new[] { "BootstrapperAgentServiceLogProvider.evtx" }, false,
"Device Preparation event log (IME bootstrapper agent)",
DiagnosticsSectionCondition.DevicePreparation),
new DiagnosticsBuiltInSection(
"AgentState", "AgentState", Constants.StateDirectory, StateFilePatterns, true,
"Agent decision-engine state, journal and markers (incl. quarantine and WhiteGlove Part-1 buckets)"),
new DiagnosticsBuiltInSection(
"AgentSpool", "AgentSpool", Constants.SpoolDirectory, SpoolFilePatterns, false,
"Pending telemetry uploads and upload cursor"),
new DiagnosticsBuiltInSection(
"AgentMarkers", "AgentMarkers", Constants.AgentDataDirectory, RootMarkerPatterns, false,
"Top-level completion and clean-exit markers"),
// RealmJoin — opt-in per tenant (RealmJoin Watcher). ZIP layout mirrors the disk:
// C:\Windows\Logs\{realmjoin*.log, RealmJoin\...} → RealmJoinLogs/Windows/...,
// %LOCALAPPDATA%\RealmJoin\{tray*.log, Logs\...} → RealmJoinLogs/User/...
new DiagnosticsBuiltInSection(
"RealmJoinWindows", "RealmJoinLogs/Windows", @"C:\Windows\Logs",
new[] { "realmjoin*.log" }, false,
"RealmJoin client logs",
DiagnosticsSectionCondition.RealmJoinWatcher),
new DiagnosticsBuiltInSection(
"RealmJoinPackages", "RealmJoinLogs/Windows/RealmJoin", @"C:\Windows\Logs\RealmJoin",
new[] { "*.log" }, true,
"RealmJoin package logs",
DiagnosticsSectionCondition.RealmJoinWatcher),
new DiagnosticsBuiltInSection(
"RealmJoinChoco", "RealmJoinLogs/Choco", @"%ProgramData%\RealmJoin\choco\logs",
new[] { "*.log" }, true,
"RealmJoin Chocolatey install logs",
DiagnosticsSectionCondition.RealmJoinWatcher),
new DiagnosticsBuiltInSection(
"RealmJoinUserTray", "RealmJoinLogs/User", UserProfileToken + @"\AppData\Local\RealmJoin",
new[] { "tray*.log" }, false,
"RealmJoin tray logs (signed-in user)",
DiagnosticsSectionCondition.RealmJoinWatcher),
new DiagnosticsBuiltInSection(
"RealmJoinUserLogs", "RealmJoinLogs/User/Logs", UserProfileToken + @"\AppData\Local\RealmJoin\Logs",
new[] { "*.log" }, true,
"RealmJoin per-user logs (signed-in user)",
DiagnosticsSectionCondition.RealmJoinWatcher),
};
}
}
@@ -43,7 +43,7 @@ namespace AutopilotMonitor.Shared.Models.Notifications
/// <summary>
/// Provider type (int form of <see cref="WebhookProviderType"/>):
/// 1=TeamsLegacyConnector, 2=TeamsWorkflowWebhook, 10=Slack, 20=GenericJson.
/// 1=TeamsLegacyConnector, 2=TeamsWorkflowWebhook, 10=Slack, 20=GenericJson, 30=Discord.
/// </summary>
public int ProviderType { get; set; }
@@ -56,6 +56,12 @@ namespace AutopilotMonitor.Shared.Models.Notifications
/// </summary>
public string? CustomHeadersJson { get; set; }
/// <summary>
/// HMAC key for signing outgoing requests (X-AutopilotMonitor-Signature), applied
/// only when <see cref="ProviderType"/> is GenericJson. Secret — redacted for readers.
/// </summary>
public string? SigningSecret { get; set; }
/// <summary>Master switch: a disabled channel never receives anything.</summary>
public bool Enabled { get; set; } = true;
@@ -93,6 +99,18 @@ namespace AutopilotMonitor.Shared.Models.Notifications
return WebhookHeaderParser.Parse(CustomHeadersJson);
}
/// <summary>
/// Returns the HMAC signing secret for the generic-webhook dispatcher, or null when
/// the channel is not GenericJson or no secret is configured.
/// </summary>
public string? GetSigningSecret()
{
if (ProviderType != (int)WebhookProviderType.GenericJson)
return null;
return string.IsNullOrWhiteSpace(SigningSecret) ? null : SigningSecret;
}
/// <summary>
/// Deserializes a channel list from its JSON storage form. Fail-soft: null/blank/malformed
/// input yields an empty list (dispatch then simply has no channels to send to). Entries
@@ -23,5 +23,11 @@ namespace AutopilotMonitor.Shared.Models.Notifications
/// Supports per-tenant custom request headers for API-key authentication.
/// </summary>
GenericJson = 20,
/// <summary>
/// Discord channel webhook (embed format). Webhooks cannot post buttons, so
/// openUrl actions are rendered as markdown links inside the embed description.
/// </summary>
Discord = 30,
}
}
@@ -235,7 +235,7 @@ export default function AboutPage() {
title: "Integrations",
items: [
"Microsoft Intune — agent deployment target",
"Microsoft Teams, Slack & generic JSON webhooks — start, success, failure, hardware rejection and SLA alerts",
"Microsoft Teams, Slack, Discord & generic JSON webhooks — start, success, failure, hardware rejection and SLA alerts",
"Intune Management Extension (IME) log — event source for log pattern detection",
"WMI & Registry — extended data gather rules",
],
@@ -61,6 +61,7 @@ export function AdminPageSections() {
{ id: "diagnostics-log-paths", label: "Diagnostics Log Paths", href: route("/admin/settings/diagnostics-log-paths"), group: "Settings" },
{ id: "config-reseed", label: "Config Reseed", href: route("/admin/settings/config-reseed"), group: "Settings" },
{ id: "usage-plans", label: "Usage Plans", href: route("/admin/settings/usage-plans"), group: "Settings" },
{ id: "email-templates", label: "Email Templates", href: route("/admin/settings/email-templates"), group: "Settings" },
// Ops (single page)
{ id: "ops", label: "Maintenance", href: "/admin/ops", group: "Ops", groupIcon: <WrenchIcon /> },
@@ -3,7 +3,9 @@
import { useState, useMemo } from "react";
import { validateDiagnosticsPath } from "@/utils/guardValidation";
import { ValidationIndicator } from "@/components/ValidationIndicator";
import type { DiagnosticsLogPath } from "@/types/diagnostics";
import { BuiltInSectionsList } from "@/components/diagnostics/BuiltInSectionsList";
import { DiagnosticsPathRow } from "@/components/diagnostics/DiagnosticsPathRow";
import type { DiagnosticsBuiltInSection, DiagnosticsLogPath } from "@/types/diagnostics";
// Re-export so existing consumers importing from this component keep working
export type { DiagnosticsLogPath };
@@ -11,6 +13,9 @@ export type { DiagnosticsLogPath };
interface DiagnosticsLogPathsSectionProps {
globalDiagPaths: DiagnosticsLogPath[];
setGlobalDiagPaths: React.Dispatch<React.SetStateAction<DiagnosticsLogPath[]>>;
/** Sections compiled into the agent (GET /api/diagnostics/paths) — shown read-only, collapsed. */
builtInSections: DiagnosticsBuiltInSection[];
builtInLoading: boolean;
loadingConfig: boolean;
savingDiagPaths: boolean;
adminConfigExists: boolean;
@@ -20,6 +25,8 @@ interface DiagnosticsLogPathsSectionProps {
export function DiagnosticsLogPathsSection({
globalDiagPaths,
setGlobalDiagPaths,
builtInSections,
builtInLoading,
loadingConfig,
savingDiagPaths,
adminConfigExists,
@@ -34,6 +41,15 @@ export function DiagnosticsLogPathsSection({
[newDiagPath]
);
const addPath = () => {
const p = newDiagPath.trim().replace(/^["']+|["']+$/g, "");
if (!p) return;
setGlobalDiagPaths([...globalDiagPaths, { path: p, description: newDiagDesc.trim(), isBuiltIn: true, includeSubfolders: newDiagSubfolders }]);
setNewDiagPath("");
setNewDiagDesc("");
setNewDiagSubfolders(false);
};
return (
<div className="bg-gradient-to-br from-teal-50 to-cyan-50 dark:from-gray-800 dark:to-gray-800 border-2 border-teal-300 dark:border-teal-700 rounded-lg shadow-lg">
<div className="p-6 border-b border-teal-200 dark:border-teal-700 bg-gradient-to-r from-teal-100 to-cyan-100 dark:from-teal-900/40 dark:to-cyan-900/40">
@@ -43,7 +59,9 @@ export function DiagnosticsLogPathsSection({
</svg>
<div>
<h2 className="text-xl font-semibold text-teal-900 dark:text-teal-100">Diagnostics Log Paths</h2>
<p className="text-sm text-teal-600 dark:text-teal-300 mt-1">Global log file paths included in diagnostics packages for all tenants. Tenants may add their own paths in Settings.</p>
<p className="text-sm text-teal-600 dark:text-teal-300 mt-1">
What every diagnostics package collects: the built-in sections compiled into the agent, plus the global paths below sent to all tenants. Tenants may add their own paths in Settings.
</p>
</div>
</div>
</div>
@@ -54,109 +72,87 @@ export function DiagnosticsLogPathsSection({
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-xs text-teal-700 dark:text-teal-300">
All paths are validated on the agent against an allowlist of safe prefixes (DiagnosticsPathGuards). Wildcards are only allowed in the last path segment. Environment variables are expanded by the agent.
Global paths are validated on the agent against an allowlist of safe prefixes (DiagnosticsPathGuards). Wildcards are only allowed in the last path segment. Environment variables and %LOGGED_ON_USER_PROFILE% are expanded by the agent.
</p>
</div>
{/* Current paths list */}
{loadingConfig ? (
<div className="flex items-center space-x-2 text-sm text-gray-500">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-teal-600"></div>
<span>Loading...</span>
{/* Built-in sections (read-only, collapsed) */}
<BuiltInSectionsList sections={builtInSections} loading={builtInLoading} />
{/* Global paths — add-row first so a long list never pushes the input out of reach */}
<div>
<p className="text-xs font-medium text-gray-400 uppercase tracking-wide mb-2">Global (all tenants)</p>
<div className="flex flex-col sm:flex-row gap-2">
<input
type="text"
placeholder="Path or wildcard (e.g. C:\Windows\Panther\*.log)"
value={newDiagPath}
onChange={(e) => setNewDiagPath(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addPath(); } }}
className="flex-1 px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-teal-500 font-mono"
/>
<input
type="text"
placeholder="Description (optional)"
value={newDiagDesc}
onChange={(e) => setNewDiagDesc(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addPath(); } }}
className="flex-1 px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-teal-500"
/>
<label className="flex items-center gap-1.5 whitespace-nowrap text-xs text-gray-600 dark:text-gray-400 cursor-pointer">
<input
type="checkbox"
checked={newDiagSubfolders}
onChange={() => setNewDiagSubfolders(!newDiagSubfolders)}
className="w-3.5 h-3.5 rounded border-gray-400 text-green-600 focus:ring-green-500"
/>
subfolders
</label>
<button
onClick={addPath}
disabled={!newDiagPath.trim()}
className="px-4 py-1.5 bg-teal-600 text-white rounded-lg text-sm font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors whitespace-nowrap"
>
Add
</button>
</div>
) : globalDiagPaths.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-gray-400 italic">No global paths configured yet.</p>
) : (
<div className="space-y-2">
{globalDiagPaths.map((entry, idx) => (
<div key={idx} className="flex items-start justify-between bg-teal-100 dark:bg-teal-900/40 border border-teal-300 dark:border-teal-700 rounded-lg px-3 py-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<p className="font-mono text-sm text-teal-900 dark:text-teal-100 break-all">{entry.path}</p>
<ValidationIndicator result={validateDiagnosticsPath(entry.path, false)} />
{entry.includeSubfolders && (
<span className="text-xs bg-teal-200 dark:bg-teal-800 text-teal-800 dark:text-teal-200 rounded-full px-1.5 py-0.5">+subfolders</span>
)}
</div>
{entry.description && (
<p className="text-xs text-teal-600 dark:text-teal-400 mt-0.5">{entry.description}</p>
)}
<label className="flex items-center gap-1.5 mt-1 cursor-pointer">
<input
type="checkbox"
checked={entry.includeSubfolders || false}
onChange={() => {
const updated = [...globalDiagPaths];
updated[idx] = { ...entry, includeSubfolders: !entry.includeSubfolders };
setGlobalDiagPaths(updated);
}}
className="w-3.5 h-3.5 rounded border-teal-400 text-green-600 focus:ring-green-500"
/>
<span className="text-xs text-teal-600 dark:text-teal-400">Include subfolders</span>
</label>
</div>
<button
onClick={() => {
const updated = globalDiagPaths.filter((_, i) => i !== idx);
{newPathValidation && (
<div className="mt-1.5">
<ValidationIndicator result={newPathValidation} />
</div>
)}
{loadingConfig ? (
<div className="mt-3 flex items-center space-x-2 text-sm text-gray-500">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-teal-600"></div>
<span>Loading...</span>
</div>
) : globalDiagPaths.length === 0 ? (
<p className="mt-3 text-sm text-gray-500 dark:text-gray-400 italic">
No global paths configured yet — every tenant receives only the built-in collection.
</p>
) : (
<div className="mt-3 space-y-1">
{globalDiagPaths.map((entry, idx) => (
<DiagnosticsPathRow
key={`${entry.path}-${idx}`}
path={entry.path}
description={entry.description}
includeSubfolders={entry.includeSubfolders || false}
validation={validateDiagnosticsPath(entry.path, false)}
className="bg-teal-100 dark:bg-teal-900/40 border-teal-300 dark:border-teal-700"
pathClassName="text-teal-900 dark:text-teal-100"
onToggleSubfolders={() => {
const updated = [...globalDiagPaths];
updated[idx] = { ...entry, includeSubfolders: !entry.includeSubfolders };
setGlobalDiagPaths(updated);
}}
className="ml-3 flex-shrink-0 text-teal-500 hover:text-red-600 dark:hover:text-red-400 transition-colors"
title="Remove"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
</div>
)}
{/* Add new path */}
<div className="flex flex-col sm:flex-row gap-2">
<input
type="text"
placeholder="Path or wildcard (e.g. C:\Windows\Panther\*.log)"
value={newDiagPath}
onChange={(e) => setNewDiagPath(e.target.value)}
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-teal-500 font-mono"
/>
<input
type="text"
placeholder="Description (optional)"
value={newDiagDesc}
onChange={(e) => setNewDiagDesc(e.target.value)}
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-teal-500"
/>
<button
onClick={() => {
const p = newDiagPath.trim().replace(/^["']+|["']+$/g, "");
if (!p) return;
setGlobalDiagPaths([...globalDiagPaths, { path: p, description: newDiagDesc.trim(), isBuiltIn: true, includeSubfolders: newDiagSubfolders }]);
setNewDiagPath("");
setNewDiagDesc("");
setNewDiagSubfolders(false);
}}
disabled={!newDiagPath.trim()}
className="px-4 py-2 bg-teal-600 text-white rounded-lg text-sm font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors whitespace-nowrap"
>
Add
</button>
onRemove={() => setGlobalDiagPaths(globalDiagPaths.filter((_, i) => i !== idx))}
/>
))}
</div>
)}
</div>
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={newDiagSubfolders}
onChange={() => setNewDiagSubfolders(!newDiagSubfolders)}
className="w-3.5 h-3.5 rounded border-gray-400 text-green-600 focus:ring-green-500"
/>
<span className="text-xs text-gray-600 dark:text-gray-400">Include subfolders</span>
</label>
{newPathValidation && (
<div className="mt-1">
<ValidationIndicator result={newPathValidation} />
</div>
)}
{/* Save button */}
<div className="flex justify-end pt-2">
@@ -135,6 +135,15 @@ const OPS_EVENT_TYPES: Record<string, string[]> = {
// (AutoApproveNewTenants enabled). Info-tier. Dual-register per memory
// feedback_ops_event_types_dual_register.
"TenantAutoApproved",
// Plan downgrade / retention grace. Downgraded is dispatched by PlanManagementFunction
// (RecordTenantPlanDowngradedAsync) when a GA plan mutation drops the effective edition
// Pro → Community; the grace events by TrialExpirySweepFunction — Expiring during the
// ≤3-day heads-up (re-emitted daily), Ended once via the 24h look-back, both only when
// stored retention exceeds the Community cap (data actually at risk). All Warning-tier.
// Dual-register per memory feedback_ops_event_types_dual_register.
"TenantPlanDowngraded",
"TenantRetentionGraceExpiring",
"TenantRetentionGraceEnded",
],
Agent: [
"BlobStorageMissing",
@@ -10,6 +10,7 @@ import { SectionTenantGroups } from "../sections/SectionTenantGroups";
import { SectionConfigReseed } from "../sections/SectionConfigReseed";
import { SectionUsagePlans } from "../sections/SectionUsagePlans";
import { SectionAlerts } from "../sections/SectionAlerts";
import { SectionEmailTemplates } from "../sections/SectionEmailTemplates";
const SECTION_COMPONENTS: Record<SettingsSectionId, React.ComponentType> = {
"global": SectionGlobalSettings,
@@ -20,6 +21,7 @@ const SECTION_COMPONENTS: Record<SettingsSectionId, React.ComponentType> = {
"config-reseed": SectionConfigReseed,
"usage-plans": SectionUsagePlans,
"alerts": SectionAlerts,
"email-templates": SectionEmailTemplates,
};
export function SectionClient({ section }: { section: string }) {
@@ -4,9 +4,12 @@ import { useEffect } from "react";
import { useAdminConfig } from "../../AdminConfigContext";
import { DiagnosticsLogPathsSection } from "../../components/DiagnosticsLogPathsSection";
import { AdminNotifications } from "../../AdminNotifications";
import { useDiagnosticsPathsCatalog } from "@/hooks/useDiagnosticsPathsCatalog";
export function SectionDiagnosticsLogPaths() {
const { ensureAdminConfigLoaded, globalDiagPaths, setGlobalDiagPaths, loadingConfig, savingDiagPaths, adminConfig, handleSaveDiagPaths } = useAdminConfig();
// Built-in catalog is read-only context; the editable global list stays on the admin config.
const catalog = useDiagnosticsPathsCatalog();
useEffect(() => { ensureAdminConfigLoaded(); }, [ensureAdminConfigLoaded]);
@@ -16,6 +19,8 @@ export function SectionDiagnosticsLogPaths() {
<DiagnosticsLogPathsSection
globalDiagPaths={globalDiagPaths}
setGlobalDiagPaths={setGlobalDiagPaths}
builtInSections={catalog.builtIn}
builtInLoading={catalog.loading}
loadingConfig={loadingConfig}
savingDiagPaths={savingDiagPaths}
adminConfigExists={!!adminConfig}
@@ -0,0 +1,357 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { useAdminConfig } from "../../AdminConfigContext";
import { AdminNotifications } from "../../AdminNotifications";
type TemplateKind = "welcome" | "farewell";
interface TemplateState {
kind: TemplateKind;
subject: string;
isOverridden: boolean;
html: string;
builtInHtml: string;
updatedBy: string | null;
updatedUtc: string | null;
placeholder: string;
maxLength: number;
}
const KINDS: { kind: TemplateKind; title: string; description: string }[] = [
{ kind: "welcome", title: "Welcome email", description: "Sent once when a tenant is activated, to the notification address entered on the activation page." },
{ kind: "farewell", title: "Farewell email", description: "Sent once after a tenant's offboarding completes, to the address captured at offboarding start." },
];
const SAMPLE_DOMAIN = "contoso.com";
function renderPreview(html: string, placeholder: string): string {
return html.split(placeholder).join(SAMPLE_DOMAIN);
}
function formatUtc(value: string | null): string {
if (!value) return "";
const d = new Date(value);
return Number.isNaN(d.getTime()) ? value : d.toISOString().replace("T", " ").substring(0, 16) + " UTC";
}
export function SectionEmailTemplates() {
const { getAccessToken, setError, setSuccessMessage } = useAdminConfig();
return (
<>
<AdminNotifications />
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Email Templates</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Preview the transactional emails, send a test to your tenant&apos;s contact address, or replace the
HTML without a code deployment. Changes apply to real sends immediately; subjects are fixed.
</p>
</div>
<div className="p-6 space-y-6">
{KINDS.map((k) => (
<TemplateCard
key={k.kind}
kind={k.kind}
title={k.title}
description={k.description}
getAccessToken={getAccessToken}
setError={setError}
setSuccessMessage={setSuccessMessage}
/>
))}
</div>
</div>
</>
);
}
interface TemplateCardProps {
kind: TemplateKind;
title: string;
description: string;
getAccessToken: () => Promise<string | null>;
setError: (error: string | null) => void;
setSuccessMessage: (message: string | null) => void;
}
function TemplateCard({ kind, title, description, getAccessToken, setError, setSuccessMessage }: TemplateCardProps) {
const [state, setState] = useState<TemplateState | null>(null);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState("");
const [busy, setBusy] = useState<"save" | "reset" | "test" | null>(null);
const [previewHtml, setPreviewHtml] = useState<string | null>(null);
const fail = useCallback((err: unknown, fallback: string) => {
if (err instanceof TokenExpiredError) {
console.error("Session expired", err);
} else {
console.error(fallback, err);
}
setError(err instanceof Error ? err.message : fallback);
}, [setError]);
const load = useCallback(async () => {
try {
setLoading(true);
const response = await authenticatedFetch(api.emailTemplates.get(kind), getAccessToken);
if (!response.ok) throw new Error(`Failed to load ${kind} template: ${response.statusText}`);
const data = (await response.json()) as TemplateState;
setState(data);
setDraft(data.html);
} catch (err) {
fail(err, `Failed to load ${kind} template`);
} finally {
setLoading(false);
}
}, [kind, getAccessToken, fail]);
useEffect(() => {
const run = async () => { await load(); };
void run();
}, [load]);
const flash = (message: string) => {
setSuccessMessage(message);
setTimeout(() => setSuccessMessage(null), 6000);
};
const handleSave = async () => {
if (!state) return;
try {
setBusy("save");
setError(null);
const response = await authenticatedFetch(api.emailTemplates.save(kind), getAccessToken, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ html: draft }),
});
if (!response.ok) {
const body = await response.json().catch(() => null);
throw new Error(body?.error ?? `Failed to save ${kind} template: ${response.statusText}`);
}
flash(`${title} saved — real sends use the customized HTML from now on.`);
setEditing(false);
await load();
} catch (err) {
fail(err, `Failed to save ${kind} template`);
} finally {
setBusy(null);
}
};
const handleReset = async () => {
if (!window.confirm(`Reset the ${title.toLowerCase()} to the built-in template? The customized HTML will be deleted.`)) return;
try {
setBusy("reset");
setError(null);
const response = await authenticatedFetch(api.emailTemplates.reset(kind), getAccessToken, { method: "DELETE" });
if (!response.ok) throw new Error(`Failed to reset ${kind} template: ${response.statusText}`);
flash(`${title} reset to the built-in template.`);
setEditing(false);
await load();
} catch (err) {
fail(err, `Failed to reset ${kind} template`);
} finally {
setBusy(null);
}
};
const handleSendTest = async (useDraft: boolean) => {
try {
setBusy("test");
setError(null);
const response = await authenticatedFetch(api.emailTemplates.sendTest(kind), getAccessToken, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(useDraft ? { html: draft } : {}),
});
const body = await response.json().catch(() => null);
if (!response.ok) throw new Error(body?.error ?? `Failed to send ${kind} test email: ${response.statusText}`);
flash(`Test ${title.toLowerCase()} sent to ${body?.sentTo ?? "your tenant contact address"}${useDraft ? " (unsaved draft)" : ""}.`);
} catch (err) {
fail(err, `Failed to send ${kind} test email`);
} finally {
setBusy(null);
}
};
const draftDirty = state !== null && draft !== state.html;
const draftTooLong = state !== null && draft.length > state.maxLength;
return (
<div className="border border-gray-200 dark:border-gray-700 rounded-lg">
<div className="px-5 py-4 flex flex-wrap items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<h3 className="text-base font-semibold text-gray-900 dark:text-white">{title}</h3>
{state && (
state.isOverridden ? (
<span className="inline-flex items-center rounded-full border border-gray-300 dark:border-gray-600 px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300">
Customized{state.updatedBy ? ` by ${state.updatedBy}` : ""}{state.updatedUtc ? ` · ${formatUtc(state.updatedUtc)}` : ""}
</span>
) : (
<span className="inline-flex items-center rounded-full border border-gray-300 dark:border-gray-600 px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300">
Built-in
</span>
)
)}
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">{description}</p>
{state && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Subject: <span className="font-medium text-gray-700 dark:text-gray-300">{state.subject}</span>
</p>
)}
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
disabled={!state || loading}
onClick={() => state && setPreviewHtml(renderPreview(state.html, state.placeholder))}
className="px-3 py-1.5 text-sm rounded border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50"
>
Preview
</button>
<button
type="button"
disabled={!state || busy !== null}
onClick={() => handleSendTest(false)}
className="px-3 py-1.5 text-sm rounded border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50"
>
{busy === "test" ? "Sending…" : "Send test to me"}
</button>
<button
type="button"
disabled={!state || loading}
onClick={() => { setEditing((v) => !v); if (state) setDraft(state.html); }}
className="px-3 py-1.5 text-sm rounded bg-green-600 text-white hover:bg-green-700 disabled:opacity-50"
>
{editing ? "Close editor" : "Edit HTML"}
</button>
</div>
</div>
{editing && state && (
<div className="px-5 pb-5 space-y-3 border-t border-gray-200 dark:border-gray-700 pt-4">
<p className="text-xs text-gray-500 dark:text-gray-400">
Use <code className="font-mono bg-gray-100 dark:bg-gray-700 px-1 rounded">{state.placeholder}</code> where the tenant&apos;s domain
should appear (an empty domain renders as &quot;your organization&quot;). Inline CSS only — mail clients ignore
stylesheets, gradients and images may be blocked. Maximum {state.maxLength.toLocaleString()} characters.
</p>
{draftTooLong && (
<p className="text-xs text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2">
{draft.length.toLocaleString()} characters — exceeds the {state.maxLength.toLocaleString()} character limit.
</p>
)}
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
rows={22}
spellCheck={false}
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg text-sm font-mono text-gray-900 dark:text-gray-100 bg-gray-50 dark:bg-gray-900 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 resize-y"
/>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setPreviewHtml(renderPreview(draft, state.placeholder))}
className="px-3 py-1.5 text-sm rounded border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
>
Preview draft
</button>
<button
type="button"
disabled={busy !== null || draftTooLong || draft.trim() === ""}
onClick={() => handleSendTest(true)}
className="px-3 py-1.5 text-sm rounded border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50"
>
{busy === "test" ? "Sending…" : "Send draft as test"}
</button>
<button
type="button"
disabled={busy !== null}
onClick={() => setDraft(state.builtInHtml)}
className="px-3 py-1.5 text-sm rounded border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50"
>
Load built-in HTML
</button>
</div>
<div className="flex flex-wrap gap-2">
{state.isOverridden && (
<button
type="button"
disabled={busy !== null}
onClick={handleReset}
className="px-3 py-1.5 text-sm rounded border border-red-300 text-red-700 hover:bg-red-50 dark:border-red-700 dark:text-red-300 dark:hover:bg-red-900/30 disabled:opacity-50"
>
{busy === "reset" ? "Resetting…" : "Reset to built-in"}
</button>
)}
<button
type="button"
disabled={busy !== null || !draftDirty || draftTooLong || draft.trim() === ""}
onClick={handleSave}
className="px-3 py-1.5 text-sm rounded bg-green-600 text-white hover:bg-green-700 disabled:opacity-50"
>
{busy === "save" ? "Saving…" : "Save"}
</button>
</div>
</div>
</div>
)}
{previewHtml !== null && (
<PreviewModal title={`${title} · preview (${SAMPLE_DOMAIN})`} html={previewHtml} onClose={() => setPreviewHtml(null)} />
)}
</div>
);
}
function PreviewModal({ title, html, onClose }: { title: string; html: string; onClose: () => void }) {
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4"
onClick={onClose}
>
<div
className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-3xl w-full max-h-[90vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="border-b border-gray-200 dark:border-gray-700 px-6 py-4 flex items-start justify-between rounded-t-lg">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{title}</h2>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Rendered in an isolated frame. Mail clients vary — Outlook drops gradients and slanted edges.
</p>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200" aria-label="Close">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="flex-1 min-h-0 bg-gray-100 dark:bg-gray-900 p-2">
<iframe
title={title}
sandbox=""
srcDoc={html}
className="w-full h-[70vh] bg-white rounded border border-gray-200 dark:border-gray-700"
/>
</div>
<div className="bg-gray-50 dark:bg-gray-900 px-6 py-3 border-t border-gray-200 dark:border-gray-700 flex justify-end rounded-b-lg">
<button
onClick={onClose}
className="px-3 py-2 text-sm bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-100 rounded hover:bg-gray-300 dark:hover:bg-gray-600"
>
Close
</button>
</div>
</div>
</div>
);
}
@@ -7,6 +7,7 @@ export const SETTINGS_NAV_SECTIONS = [
{ id: "config-reseed", label: "Config Reseed", description: "Fetch and reseed rules from GitHub" },
{ id: "usage-plans", label: "Usage Plans", description: "Define MCP usage plan tiers and limits" },
{ id: "alerts", label: "Alerts", description: "Configure ops event alert rules and notification providers" },
{ id: "email-templates", label: "Email Templates", description: "Preview, test and customize the welcome and farewell emails" },
] as const;
export type SettingsSectionId = (typeof SETTINGS_NAV_SECTIONS)[number]["id"];
@@ -211,6 +211,7 @@ const WEBHOOK_PROVIDERS: Record<number, string> = {
2: 'Teams (Workflow Webhook)',
10: 'Slack',
20: 'Generic JSON',
30: 'Discord',
};
function formatValue(val: unknown): string {
@@ -517,7 +517,7 @@ export default function AnalyzeRuleCard({
</div>
<p className="text-xs text-gray-600 leading-relaxed">
When enabled, each <span className="font-semibold">new</span> finding of this rule sends a message to the selected
notification channels (Teams, Slack, webhook — configured under Settings → Notifications) with device, rule, and a
notification channels (Teams, Slack, Discord, webhook — configured under Settings → Notifications) with device, rule, and a
direct link to the session.
</p>
</div>
@@ -0,0 +1,33 @@
import type { Metadata } from "next";
import { SITE_URL } from "@/utils/config";
export const metadata: Metadata = {
title: "Get Pro – Purchase Options",
description:
"How to purchase Autopilot Monitor Pro: direct online purchase and the Microsoft commercial marketplace — both coming soon. Pricing will be announced; Community stays free.",
keywords: [
"Autopilot Monitor Pro",
"Autopilot Monitor buy",
"Autopilot Monitor purchase",
"Autopilot Monitor marketplace",
"Windows Autopilot monitoring Pro",
],
openGraph: {
title: "Get Pro – Autopilot Monitor",
description:
"How to purchase Autopilot Monitor Pro: direct online purchase and the Microsoft commercial marketplace — both coming soon.",
url: `${SITE_URL}/buy`,
},
twitter: {
title: "Get Pro – Autopilot Monitor",
description:
"How to purchase Autopilot Monitor Pro: direct online purchase and the Microsoft commercial marketplace — both coming soon.",
},
alternates: {
canonical: `${SITE_URL}/buy`,
},
};
export default function BuyLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
@@ -0,0 +1,85 @@
import Link from "next/link";
import { LandingNavbar } from "../../components/landing/LandingNavbar";
import { SiteFooter } from "../../components/SiteFooter";
/**
* Purchase page for Pro. Linked from the public /plans page and from the
* portal's Plan section (absolute www URL — /buy is public surface).
*
* Both purchase channels are honest "coming soon" placeholders until the
* checkout links exist. When they do: the provider host must be registered in
* utils/config.ts, the CSP in staticwebapp.config.json, the swaConfig guard
* test, and the dev CSP in next.config.ts — see tasks/todo.md follow-up.
*/
const CHANNELS = [
{
label: "Direct purchase",
description:
"Buy Pro online with a credit card or on invoice — a fast, self-service checkout for a single organization.",
},
{
label: "Microsoft Marketplace",
description:
"Purchase through the Microsoft commercial marketplace using your organization's existing Microsoft billing relationship.",
},
];
export default function BuyPage() {
return (
<div className="landing-v2 min-h-screen bg-[var(--lp-bg)]">
<LandingNavbar />
<header className="px-4 sm:px-6 lg:px-8 pt-14 sm:pt-16">
<div className="max-w-5xl mx-auto">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-[var(--lp-ink-faint)]">Pro</p>
<h1 className="mt-3 text-3xl sm:text-4xl font-bold tracking-tight text-[var(--lp-ink)]">
Get Autopilot Monitor Pro
</h1>
<p className="mt-4 max-w-2xl text-[15px] text-gray-600 leading-relaxed">
Pro will be available through two purchase channels. Neither is open yet — pricing
and availability will be announced. Until then, Community is the full product, free
for everyone.
</p>
</div>
</header>
<main className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-10 space-y-10 text-[15px]">
{/* Purchase channels */}
<section className="grid grid-cols-1 sm:grid-cols-2 gap-5">
{CHANNELS.map((channel) => (
<div key={channel.label} className="bg-[var(--lp-surface)] border border-[var(--lp-line-soft)] rounded-xl p-8 flex flex-col">
<h2 className="text-lg font-bold text-gray-900 mb-3">{channel.label}</h2>
<p className="text-sm text-gray-600 leading-relaxed mb-4 flex-1">{channel.description}</p>
<button
type="button"
disabled
title="Available once Pro pricing is announced."
className="w-full rounded-lg border border-[var(--lp-line-soft)] bg-[var(--lp-surface-2)] px-4 py-2.5 text-sm font-semibold text-[var(--lp-ink-faint)] cursor-not-allowed"
>
Coming soon
</button>
</div>
))}
</section>
{/* Interim path */}
<section className="bg-[var(--lp-surface)] border border-[var(--lp-line-soft)] rounded-xl p-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">Questions About Pro?</h2>
<p className="text-gray-700 leading-relaxed">
Interested in Pro for your organization or an MSP scenario, or want to know when it
opens? Reach out through any channel on the{" "}
<Link href="/help" className="text-[var(--lp-accent-ink)] hover:opacity-80 underline">
Help &amp; Support
</Link>{" "}
page — you&apos;ll talk directly to the person building the platform. And if you
haven&apos;t compared the plans yet, the{" "}
<Link href="/plans" className="text-[var(--lp-accent-ink)] hover:opacity-80 underline">
plans overview
</Link>{" "}
shows exactly what Pro adds.
</p>
</section>
</main>
<SiteFooter />
</div>
);
}
@@ -0,0 +1,105 @@
"use client";
import { useSyncExternalStore } from "react";
import { DOCS_URL } from "@/utils/config";
import { trackEvent } from "@/lib/appInsights";
// Session-scoped dismissal: sessionStorage is per-tab, so the banner stays hidden
// across reloads in this tab (service-desk monitor use case) but reappears in every
// new tab — deliberately never a permanent opt-out. Module-level store in the
// themeStore shape: a same-tab sessionStorage write fires no "storage" event, and
// useSyncExternalStore keeps the statically prerendered markup hydration-safe.
const DismissKey = "devBannerDismissed";
const listeners = new Set<() => void>();
const getSnapshot = (): boolean => sessionStorage.getItem(DismissKey) === "1";
const getServerSnapshot = (): boolean => false;
function subscribe(onStoreChange: () => void): () => void {
listeners.add(onStoreChange);
return () => listeners.delete(onStoreChange);
}
function dismiss(): void {
sessionStorage.setItem(DismissKey, "1");
for (const listener of [...listeners]) listener();
}
const linkClassName =
"underline font-medium hover:text-green-600 dark:hover:text-blue-200";
export function ActivelyDevelopedBanner() {
const dismissed = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
if (dismissed) return null;
const trackLink = (link: string) => trackEvent("dev_banner_link_clicked", { link });
return (
<div className="mb-4 bg-blue-50 border border-blue-300 rounded-lg px-4 py-3 flex items-start gap-3 dark:bg-blue-950/30 dark:border-blue-700/50">
<svg className="w-4 h-4 text-blue-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
</svg>
<p className="text-sm text-blue-800 dark:text-blue-300">
<span className="font-semibold">Actively developed.</span>{" "}
Autopilot Monitor recognizes a wide range of deployment scenarios and improves
continuously — your reports directly shape it.{" "}
If something looks off, check the{" "}
<a
href={`${DOCS_URL}/changelog/platform-changelog`}
target="_blank"
rel="noopener noreferrer"
className={linkClassName}
onClick={() => trackLink("platform_changelog")}
>
Platform Changelog
</a>{" "}
or{" "}
<a
href={`${DOCS_URL}/troubleshooting/service-announcements`}
target="_blank"
rel="noopener noreferrer"
className={linkClassName}
onClick={() => trackLink("service_announcements")}
>
Service Announcements
</a>
.{" "}
Feedback or bug report?{" "}
<a
href="https://github.com/okieselbach/AutopilotMonitor/issues"
target="_blank"
rel="noopener noreferrer"
className={linkClassName}
onClick={() => trackLink("github_issues")}
>
Open a GitHub issue
</a>
{" "}or message me on{" "}
<a
href="https://www.linkedin.com/in/oliver-kieselbach/"
target="_blank"
rel="noopener noreferrer"
className={linkClassName}
onClick={() => trackLink("linkedin")}
>
LinkedIn
</a>
.
</p>
<button
type="button"
onClick={() => {
trackEvent("dev_banner_dismissed");
dismiss();
}}
aria-label="Hide for this browser tab"
title="Hide for this browser tab"
className="ml-auto shrink-0 -mr-1 -mt-1 p-1 rounded text-blue-300 hover:text-blue-600 hover:bg-blue-100 transition-colors dark:text-blue-700 dark:hover:text-blue-300 dark:hover:bg-blue-900/40"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
);
}
@@ -6,12 +6,12 @@ export function WelcomeMessage() {
return (
<div className="bg-white shadow rounded-lg overflow-hidden">
{/* Top accent bar */}
<div className="h-1 bg-gradient-to-r from-blue-500 via-indigo-500 to-purple-500" />
<div className="h-1 bg-gradient-to-r from-green-500 via-emerald-500 to-teal-500" />
<div className="p-6 sm:p-8">
{/* Header */}
<div className="flex items-start gap-4 mb-6">
<div className="shrink-0 w-10 h-10 rounded-xl bg-blue-100 flex items-center justify-center">
<svg className="w-5 h-5 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<div className="shrink-0 w-10 h-10 rounded-xl bg-green-100 flex items-center justify-center">
<svg className="w-5 h-5 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
@@ -49,10 +49,10 @@ export function WelcomeMessage() {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<a
href={DOCS_URL} target="_blank" rel="noopener noreferrer"
className="group flex items-center gap-3 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 hover:border-blue-300 hover:bg-blue-50 transition-colors"
className="group flex items-center gap-3 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 hover:border-green-300 hover:bg-green-50 transition-colors"
>
<span className="shrink-0 w-8 h-8 rounded-lg bg-white border border-gray-200 group-hover:border-blue-200 flex items-center justify-center shadow-sm">
<svg className="w-4 h-4 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<span className="shrink-0 w-8 h-8 rounded-lg bg-white border border-gray-200 group-hover:border-green-200 flex items-center justify-center shadow-sm">
<svg className="w-4 h-4 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
</span>
@@ -60,7 +60,7 @@ export function WelcomeMessage() {
<p className="text-sm font-medium text-gray-900">Documentation</p>
<p className="text-xs text-gray-500">Setup and configuration guides</p>
</div>
<svg className="w-4 h-4 text-gray-300 group-hover:text-blue-400 ml-auto shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<svg className="w-4 h-4 text-gray-300 group-hover:text-green-400 ml-auto shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</a>
@@ -12,6 +12,7 @@ import { useAuth } from "../../contexts/AuthContext";
import { useNotifications } from "../../contexts/NotificationContext";
import { StatsCard } from "./components/StatsCards";
import { WelcomeMessage } from "./components/WelcomeMessage";
import { ActivelyDevelopedBanner } from "./components/ActivelyDevelopedBanner";
import { SessionTable } from "./components/SessionTable";
import { TenantFilterBar } from "./components/TenantFilterBar";
import { DeleteConfirmModal, BlockConfirmModal } from "./components/ConfirmationModals";
@@ -29,7 +30,6 @@ import { api } from "@/lib/api";
import { authenticatedFetch } from "@/lib/authenticatedFetch";
import { formatDuration } from "@/lib/formatting";
import { hasTenantReadScope } from "@/lib/tenantScope";
import { DOCS_URL } from "@/utils/config";
import { TableSkeleton } from "@/components/skeletons/TableSkeleton";
export default function Home() {
@@ -345,55 +345,8 @@ function HomeContent() {
{/* Main content */}
<main className={mainClassName}>
<div className="px-4 sm:px-0">
{/* Feedback & bug report banner */}
<div className="mb-4 bg-blue-50 border border-blue-300 rounded-lg px-4 py-3 flex items-start gap-3 dark:bg-blue-950/30 dark:border-blue-700/50">
<svg className="w-4 h-4 text-blue-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
</svg>
<p className="text-sm text-blue-800 dark:text-blue-300">
<span className="font-semibold">Actively developed.</span>{" "}
Autopilot Monitor recognizes a wide range of deployment scenarios and improves
continuously — your reports directly shape it.{" "}
If something looks off, check the{" "}
<a
href={`${DOCS_URL}/changelog/platform-changelog`}
target="_blank"
rel="noopener noreferrer"
className="underline font-medium hover:text-green-600 dark:hover:text-blue-200"
>
Platform Changelog
</a>{" "}
or{" "}
<a
href={`${DOCS_URL}/troubleshooting/service-announcements`}
target="_blank"
rel="noopener noreferrer"
className="underline font-medium hover:text-green-600 dark:hover:text-blue-200"
>
Service Announcements
</a>
.{" "}
Feedback or bug report?{" "}
<a
href="https://github.com/okieselbach/AutopilotMonitor/issues"
target="_blank"
rel="noopener noreferrer"
className="underline font-medium hover:text-green-600 dark:hover:text-blue-200"
>
Open a GitHub issue
</a>
{" "}or message me on{" "}
<a
href="https://www.linkedin.com/in/oliver-kieselbach/"
target="_blank"
rel="noopener noreferrer"
className="underline font-medium hover:text-green-600 dark:hover:text-blue-200"
>
LinkedIn
</a>
.
</p>
</div>
{/* Feedback & bug report banner (session-dismissable, telemetry-instrumented) */}
<ActivelyDevelopedBanner />
{serialValidationEnabled === false && (
<div className="mb-6 bg-red-600 border-2 border-red-700 rounded-xl p-5 shadow-lg">
@@ -103,9 +103,7 @@ export default function GetStartedPage() {
<p className="mt-6 text-sm text-[var(--lp-ink-faint)] leading-relaxed max-w-xl">
When you need more later: the{" "}
<a
href={`${DOCS_URL}/plans`}
target="_blank"
rel="noopener noreferrer"
href="/plans"
className="text-[var(--lp-accent-ink)] hover:opacity-80 underline"
>
Pro plan
@@ -861,6 +861,14 @@ html:hover ::-webkit-scrollbar-thumb {
.dark .landing-v2 .border-gray-300 { border-color: #d1d5db !important; }
.dark .landing-v2 .hover\:bg-gray-50:hover { background-color: #f9fafb !important; }
.dark .landing-v2 .hover\:bg-gray-100:hover { background-color: #f3f4f6 !important; }
/* Purple utilities on the shared plan cards (/plans): only the classes that
HAVE a global .dark override need neutralizing here — the slash-opacity
surfaces (bg-purple-50/20) are separate tokens the override map never
matched in the first place. */
.dark .landing-v2 .text-purple-900 { color: #581c87 !important; }
.dark .landing-v2 .text-purple-700 { color: #7e22ce !important; }
.dark .landing-v2 .bg-purple-100 { background-color: #f3e8ff !important; }
.dark .landing-v2 .border-purple-200 { border-color: #e9d5ff !important; }
/* Scroll-triggered reveal: Reveal.tsx adds .lp-in when the element
enters the viewport. Reduced motion renders everything visible. */
@@ -0,0 +1,34 @@
import type { Metadata } from "next";
import { SITE_URL } from "@/utils/config";
export const metadata: Metadata = {
title: "Help & Support – How to Get Help",
description:
"How to get help with Autopilot Monitor: open a GitHub issue for bugs and feature requests, or reach out directly to Oliver Kieselbach on LinkedIn. Plus documentation, FAQ, and service announcements.",
keywords: [
"Autopilot Monitor support",
"Autopilot Monitor help",
"Windows Autopilot monitoring support",
"Autopilot Monitor GitHub issues",
"Autopilot Monitor contact",
"Oliver Kieselbach",
],
openGraph: {
title: "Help & Support – Autopilot Monitor",
description:
"How to get help with Autopilot Monitor: open a GitHub issue or reach out directly on LinkedIn.",
url: `${SITE_URL}/help`,
},
twitter: {
title: "Help & Support – Autopilot Monitor",
description:
"How to get help with Autopilot Monitor: open a GitHub issue or reach out directly on LinkedIn.",
},
alternates: {
canonical: `${SITE_URL}/help`,
},
};
export default function HelpLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
@@ -0,0 +1,175 @@
import { LandingNavbar } from "../../components/landing/LandingNavbar";
import { SiteFooter } from "../../components/SiteFooter";
import { DOCS_URL } from "@/utils/config";
const GITHUB_ISSUES = "https://github.com/okieselbach/AutopilotMonitor/issues";
const LINKEDIN_PROFILE = "https://www.linkedin.com/in/oliver-kieselbach/";
const GITHUB_ADVISORY = "https://github.com/okieselbach/AutopilotMonitor/security/advisories/new";
const SELF_SERVICE_LINKS = [
{
label: "Documentation",
description: "Setup guides, concepts, and reference for every feature.",
href: DOCS_URL,
},
{
label: "FAQ",
description: "Answers to the most common questions.",
href: `${DOCS_URL}/troubleshooting/faq`,
},
{
label: "Service Announcements",
description: "Known issues and current service status.",
href: `${DOCS_URL}/troubleshooting/service-announcements`,
},
{
label: "Platform Changelog",
description: "What changed recently in the portal and backend.",
href: `${DOCS_URL}/changelog/platform-changelog`,
},
];
export default function HelpPage() {
return (
<div className="landing-v2 min-h-screen bg-[var(--lp-bg)]">
<LandingNavbar />
<header className="px-4 sm:px-6 lg:px-8 pt-14 sm:pt-16">
<div className="max-w-5xl mx-auto">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-[var(--lp-ink-faint)]">Support</p>
<h1 className="mt-3 text-3xl sm:text-4xl font-bold tracking-tight text-[var(--lp-ink)]">
Help &amp; Support
</h1>
<p className="mt-4 max-w-2xl text-[15px] text-gray-600 leading-relaxed">
Autopilot Monitor is free and open source, and support is direct and personal — no
ticket queues, no call centers. Pick the channel that fits and you&apos;ll hear back
from the person who actually builds the platform.
</p>
</div>
</header>
<main className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-10 space-y-10 text-[15px]">
{/* Primary support channels */}
<section className="grid grid-cols-1 sm:grid-cols-2 gap-5">
<div className="bg-[var(--lp-surface)] border border-[var(--lp-line-soft)] rounded-xl p-8 flex flex-col">
<div className="flex items-center gap-3 mb-3">
<span className="inline-flex h-10 w-10 items-center justify-center rounded-lg bg-[var(--lp-accent-soft)]">
<svg className="w-5 h-5 text-[var(--lp-ink)]" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
</svg>
</span>
<h2 className="text-lg font-bold text-gray-900">GitHub Issues</h2>
</div>
<p className="text-sm text-gray-600 leading-relaxed mb-4 flex-1">
The best channel for bug reports, feature requests, and questions. Issues are
public, so other admins benefit from the answer too — chances are someone already
reported what you&apos;re seeing.
</p>
<a
href={GITHUB_ISSUES}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center gap-2 rounded-lg bg-[var(--lp-accent)] px-4 py-2.5 text-sm font-semibold text-white hover:opacity-90 transition-opacity"
>
Open a GitHub Issue
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
</a>
</div>
<div className="bg-[var(--lp-surface)] border border-[var(--lp-line-soft)] rounded-xl p-8 flex flex-col">
<div className="flex items-center gap-3 mb-3">
<span className="inline-flex h-10 w-10 items-center justify-center rounded-lg bg-[var(--lp-accent-soft)]">
<svg className="w-5 h-5 text-[var(--lp-ink)]" fill="currentColor" viewBox="0 0 24 24">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
</svg>
</span>
<h2 className="text-lg font-bold text-gray-900">LinkedIn</h2>
</div>
<p className="text-sm text-gray-600 leading-relaxed mb-4 flex-1">
Prefer a direct, private conversation? Message Oliver Kieselbach on LinkedIn —
for anything that shouldn&apos;t go into a public issue, such as tenant-specific
details, or just to say hi.
</p>
<a
href={LINKEDIN_PROFILE}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center gap-2 rounded-lg border border-[var(--lp-line)] bg-[var(--lp-surface)] px-4 py-2.5 text-sm font-semibold text-[var(--lp-ink)] hover:border-[var(--lp-ink-faint)] transition-colors"
>
Message on LinkedIn
<svg className="w-3.5 h-3.5 text-[var(--lp-ink-faint)]" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
</a>
</div>
</section>
{/* How to report a problem */}
<section className="bg-[var(--lp-surface)] border border-[var(--lp-line-soft)] rounded-xl p-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">Something Wrong with an Enrollment?</h2>
<p className="text-gray-700 leading-relaxed mb-4">
If your question is about a specific enrollment, the easiest way is the{" "}
<strong>Report Session</strong> button right in the portal&apos;s session view. It
flags that session for analysis, and you can add a problem description and attach
screenshots directly in the dialog — everything needed arrives in one place.
</p>
<p className="text-gray-700 leading-relaxed mb-4">
For everything else — general questions, portal issues, or feature ideas — a GitHub
issue works best. A short description of what you expected versus what happened, plus
a screenshot where helpful, makes it much faster to track down. Attachments work in
both places, so use whichever you prefer.
</p>
<p className="text-sm text-gray-500 leading-relaxed">
GitHub issues are public — leave out anything confidential like tenant names, user
identities, or internal hostnames. If the details are sensitive, use Report Session
or LinkedIn instead and reference the issue number.
</p>
</section>
{/* Self-service resources */}
<section>
<h2 className="text-xl font-bold text-gray-900 mb-2">Help Yourself First</h2>
<p className="text-gray-600 mb-6 leading-relaxed">
Many questions are already answered in the documentation — it&apos;s the fastest way
to a solution.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{SELF_SERVICE_LINKS.map((link) => (
<a
key={link.label}
href={link.href}
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border border-[var(--lp-line-soft)] bg-[var(--lp-surface)] px-5 py-4 hover:border-[var(--lp-accent-line)] hover:bg-[var(--lp-accent-soft)] transition-colors"
>
<span className="block text-sm font-semibold text-gray-800 mb-1">{link.label} →</span>
<span className="block text-sm text-gray-600 leading-relaxed">{link.description}</span>
</a>
))}
</div>
</section>
{/* Security */}
<section className="bg-[var(--lp-surface)] border border-[var(--lp-line-soft)] rounded-xl p-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">Reporting a Security Issue</h2>
<p className="text-gray-700 leading-relaxed">
Please do not report security vulnerabilities in a public issue. Use a private{" "}
<a
href={GITHUB_ADVISORY}
target="_blank"
rel="noopener noreferrer"
className="text-[var(--lp-accent-ink)] hover:opacity-80 underline"
>
GitHub security advisory
</a>{" "}
instead, so the issue can be assessed and fixed before details become public.
</p>
</section>
</main>
<SiteFooter />
</div>
);
}
@@ -0,0 +1,34 @@
import type { Metadata } from "next";
import { SITE_URL } from "@/utils/config";
export const metadata: Metadata = {
title: "Plans – Community & Pro",
description:
"Autopilot Monitor plans: Community is the full product and free — live session monitoring, rules engine, fleet analytics, and AI integration. Pro adds extended retention, higher limits, MSP delegation, and priority support.",
keywords: [
"Autopilot Monitor plans",
"Autopilot Monitor pricing",
"Autopilot Monitor free",
"Autopilot Monitor Pro",
"Windows Autopilot monitoring free",
"Autopilot monitoring MSP",
],
openGraph: {
title: "Plans – Autopilot Monitor",
description:
"Community is the full product and free — and stays free. Pro adds extended retention, higher limits, MSP delegation, and priority support.",
url: `${SITE_URL}/plans`,
},
twitter: {
title: "Plans – Autopilot Monitor",
description:
"Community is the full product and free — and stays free. Pro adds extended retention, higher limits, MSP delegation, and priority support.",
},
alternates: {
canonical: `${SITE_URL}/plans`,
},
};
export default function PlansLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
@@ -0,0 +1,72 @@
import Link from "next/link";
import { LandingNavbar } from "../../components/landing/LandingNavbar";
import { SiteFooter } from "../../components/SiteFooter";
import { PlanCards } from "../../components/plans/PlanCards";
import { DOCS_URL } from "@/utils/config";
/**
* Public plans page. Deliberately named "Plans" (not "Pricing"): the message is
* free-first — Community is the full product and stays free; Pro is the optional
* step up. The cards are the shared PlanCards, so this page can never drift from
* the portal's Plan section.
*/
export default function PlansPage() {
return (
<div className="landing-v2 min-h-screen bg-[var(--lp-bg)]">
<LandingNavbar />
<header className="px-4 sm:px-6 lg:px-8 pt-14 sm:pt-16">
<div className="max-w-5xl mx-auto">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-[var(--lp-ink-faint)]">Plans</p>
<h1 className="mt-3 text-3xl sm:text-4xl font-bold tracking-tight text-[var(--lp-ink)]">
The full product is free — and stays free.
</h1>
<p className="mt-4 max-w-2xl text-[15px] text-gray-600 leading-relaxed">
Community is not a trial and not a cut-down teaser: live monitoring, the full rules
engine, fleet analytics, and AI integration are free for everyone. Pro is the optional
step up for organizations that want longer retention, higher limits, MSP delegation,
and reliability commitments.
</p>
</div>
</header>
<main className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-10 space-y-8 text-[15px]">
<PlanCards
surface="marketing"
communityBadge={
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-[var(--lp-accent-soft)] text-[var(--lp-accent-ink)]">
Free forever
</span>
}
proBadge={
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-purple-100 text-purple-700 border border-purple-200">
Coming soon
</span>
}
proCta={
<Link
href="/buy"
className="block w-full text-center text-sm font-semibold text-white bg-purple-600 rounded-lg px-4 py-2.5 hover:bg-purple-700 transition-colors"
>
How to get Pro
</Link>
}
/>
<p className="text-sm text-gray-600">
Pricing and availability for Pro will be announced. Community stays free.{" "}
For a feature-by-feature reference, see the{" "}
<a
href={`${DOCS_URL}/plans`}
target="_blank"
rel="noopener noreferrer"
className="text-[var(--lp-accent-ink)] hover:opacity-80 underline"
>
plans page in the documentation
</a>
.
</p>
</main>
<SiteFooter />
</div>
);
}
@@ -2,7 +2,7 @@ import { LandingNavbar } from "../../components/landing/LandingNavbar";
import { SiteFooter } from "../../components/SiteFooter";
import { DOCS_URL } from "@/utils/config";
const LAST_UPDATED = "11 August 2026";
const LAST_UPDATED = "23 August 2026";
const DOCS_SECURITY_FAQ = `${DOCS_URL}/trust/security-faq`;
const DOCS_DATA_FLOWS = `${DOCS_URL}/trust/data-flows`;
@@ -140,14 +140,15 @@ export default function PrivacyPage() {
<h2 className="text-lg font-semibold text-gray-900 mt-6">External Services</h2>
<p className="text-gray-700">
<strong>Microsoft Azure is the only place your data is stored.</strong> Resend delivers the onboarding
approval email when a tenant is activated — it receives an administrator&apos;s email address and the tenant
domain, never enrollment telemetry. Vulnerability reference data is read inbound from NVD, the CISA KEV
<strong>Microsoft Azure is the only place your data is stored.</strong> Mailchimp Transactional (Mandrill)
delivers the welcome email when a tenant is activated and the farewell email after offboarding — it receives
an administrator&apos;s email address and the tenant domain, never enrollment telemetry; open and click
tracking is disabled. Vulnerability reference data is read inbound from NVD, the CISA KEV
catalog, and MSRC; nothing about your environment is sent to them.
</p>
<p className="text-gray-700">
Further connections exist only because you configure them: your own Azure storage account for diagnostics,
notification channels such as Teams, Slack, or a generic webhook, and your own AI assistant if a user connects
notification channels such as Teams, Slack, Discord, or a generic webhook, and your own AI assistant if a user connects
one through the MCP integration. The platform itself makes no calls to any AI or LLM provider.{" "}
<DocsLink href={DOCS_DATA_FLOWS}>Data Flows &amp; External Services</DocsLink> maps every outbound connection
and what it carries. The data processing agreement, available on request, is the authoritative document for
+1 -1
View File
@@ -9,7 +9,7 @@ export default function robots(): MetadataRoute.Robots {
rules: [
{
userAgent: "*",
allow: ["/", "/about", "/get-started", "/privacy", "/terms"],
allow: ["/", "/about", "/buy", "/get-started", "/help", "/plans", "/privacy", "/terms"],
disallow: [
"/dashboard",
"/fleet-health",
@@ -87,6 +87,12 @@ export function useSessionDerivedData(
// `esp_apps_timeout`; rendered separately by InstallProgress so the user
// sees confirmed failures and ESP-timeout-induced presumptions side by side.
likelyStuck: parseInt(d.likelyStuck ?? "0", 10),
// Names of apps the tracker knows about that have not produced any install/download
// event yet — the only way the progress panels can render the "missing" rows that
// make their "X of Y" headers add up (session 502274b4: Windows Media Player).
pendingNames: Array.isArray(d.pendingNames)
? (d.pendingNames as unknown[]).filter((n): n is string => typeof n === "string")
: [],
};
}, [events]);
@@ -124,12 +130,18 @@ export function useSessionDerivedData(
const displayStatus = gatherRulesSucceeded ? "Succeeded" : (session?.status ?? "");
// Calculate enrollment duration from events (first event → enrollment_complete or last event)
// More accurate than session.durationSeconds which is based on registration StartedAt
// More accurate than session.durationSeconds which is based on registration StartedAt.
// System-timeline-watcher rows (clock steps, sleep episodes) are excluded: they are
// backfilled from the Windows event log with original timestamps up to 24h before the
// agent started — environment observation, not enrollment activity — and would drag the
// start toward pre-enrollment OOBE idle time (mirrors the backend anchor eligibility;
// field case d0c5b672).
const enrollmentDurationFromEvents = useMemo(() => {
if (events.length === 0) return null;
const timestamps = events.map(e => new Date(e.timestamp).getTime());
const activityEvents = events.filter(e => e.source !== "SystemTimelineWatcher");
if (activityEvents.length === 0) return null;
const timestamps = activityEvents.map(e => new Date(e.timestamp).getTime());
const firstEventTime = Math.min(...timestamps);
const completeEvent = events.find(e => e.eventType === "enrollment_complete");
const completeEvent = activityEvents.find(e => e.eventType === "enrollment_complete");
const endTime = completeEvent
? new Date(completeEvent.timestamp).getTime()
: Math.max(...timestamps);
@@ -91,6 +91,22 @@ describe('clock-era correction', () => {
expect(clockMarker!.detail).toContain('before agent start');
});
it('formats day-scale steps in days (CMOS clock years off)', () => {
// Real fleet case 0bd4af1c: clock stepped +66,387,532,699 ms (2024 → 2026).
const events = [
ethernetInfo(1, 0),
ev(2, 'system_clock_changed', 5 * MIN, {
timeDeltaMs: 66_387_532_699,
backfilled: true,
timeCreated: iso(2 * MIN),
}),
ev(3, 'log_entry', 10 * MIN),
];
const model = buildNetworkModel(session, events)!;
const marker = model.lifeMarkers.find((m) => m.label.startsWith('Clock'));
expect(marker!.label).toBe('Clock +768d 8h');
});
it('ignores sub-30s steps (NTP nudges)', () => {
const events = [
ethernetInfo(1, 0),
@@ -130,7 +130,10 @@ export function fmtDuration(ms: number): string {
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ${s % 60}s`;
const h = Math.floor(m / 60);
return `${h}h ${m % 60}m`;
if (h < 24) return `${h}h ${m % 60}m`;
// Day scale (e.g. a CMOS clock jumping years): minutes are noise here.
const d = Math.floor(h / 24);
return `${d}d ${h % 24}h`;
}
export function segmentTitle(seg: NetSegment): string {
@@ -191,6 +191,8 @@ interface TenantConfigContextValue {
setEnableTimezoneAutoSet: (v: boolean) => void;
enableDoGroupIdAutoSet: boolean;
setEnableDoGroupIdAutoSet: (v: boolean) => void;
keepAwakeDuringUserEsp: boolean;
setKeepAwakeDuringUserEsp: (v: boolean) => void;
enableImeMatchLog: boolean;
setEnableImeMatchLog: (v: boolean) => void;
enableGatherRuleDebugLog: boolean;
@@ -207,6 +209,8 @@ interface TenantConfigContextValue {
setEnrollmentSummaryBrandingImageUrl: (v: string) => void;
enrollmentSummaryLaunchRetrySeconds: number;
setEnrollmentSummaryLaunchRetrySeconds: (v: number) => void;
enableRealmJoinWatcher: boolean;
setEnableRealmJoinWatcher: (v: boolean) => void;
handleSaveAgentSettings: () => void;
handleResetAgentSettings: () => void;
@@ -221,10 +225,6 @@ interface TenantConfigContextValue {
setEnableSoftwareInventoryAnalyzer: (v: boolean) => void;
enableIntegrityBypassAnalyzer: boolean;
setEnableIntegrityBypassAnalyzer: (v: boolean) => void;
enableRealmJoinWatcher: boolean;
setEnableRealmJoinWatcher: (v: boolean) => void;
keepAwakeDuringUserEsp: boolean;
setKeepAwakeDuringUserEsp: (v: boolean) => void;
enableConsoleBypassDetection: boolean;
setEnableConsoleBypassDetection: (v: boolean) => void;
handleSaveAgentAnalyzers: () => void;
@@ -275,7 +275,6 @@ interface TenantConfigContextValue {
setDiagnosticsUploadDestination: (v: string) => void;
tenantDiagPaths: DiagnosticsLogPath[];
setTenantDiagPaths: (v: DiagnosticsLogPath[]) => void;
globalDiagPaths: DiagnosticsLogPath[];
newDiagPath: string;
setNewDiagPath: (v: string) => void;
newDiagDesc: string;
@@ -421,6 +420,8 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
const [enrollmentSummaryTimeoutSeconds, setEnrollmentSummaryTimeoutSeconds] = useState(60);
const [enrollmentSummaryBrandingImageUrl, setEnrollmentSummaryBrandingImageUrl] = useState("");
const [enrollmentSummaryLaunchRetrySeconds, setEnrollmentSummaryLaunchRetrySeconds] = useState(120);
const [enableRealmJoinWatcher, setEnableRealmJoinWatcher] = useState(false);
const [keepAwakeDuringUserEsp, setKeepAwakeDuringUserEsp] = useState(false);
// Notification channels
const [notificationChannels, setNotificationChannels] = useState<NotificationChannel[]>([]);
@@ -445,7 +446,6 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
// Hosted requires an explicit admin click (no silent flip).
const [diagnosticsUploadDestination, setDiagnosticsUploadDestination] = useState("CustomerSas");
const [tenantDiagPaths, setTenantDiagPaths] = useState<DiagnosticsLogPath[]>([]);
const [globalDiagPaths, setGlobalDiagPaths] = useState<DiagnosticsLogPath[]>([]);
const [newDiagPath, setNewDiagPath] = useState("");
const [newDiagDesc, setNewDiagDesc] = useState("");
@@ -455,8 +455,6 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
const [newAllowedAccount, setNewAllowedAccount] = useState("");
const [enableSoftwareInventoryAnalyzer, setEnableSoftwareInventoryAnalyzer] = useState(false);
const [enableIntegrityBypassAnalyzer, setEnableIntegrityBypassAnalyzer] = useState(true);
const [enableRealmJoinWatcher, setEnableRealmJoinWatcher] = useState(false);
const [keepAwakeDuringUserEsp, setKeepAwakeDuringUserEsp] = useState(false);
const [enableConsoleBypassDetection, setEnableConsoleBypassDetection] = useState(true);
// Unrestricted mode
@@ -558,6 +556,8 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
setEnrollmentSummaryTimeoutSeconds(data.enrollmentSummaryTimeoutSeconds ?? 60);
setEnrollmentSummaryBrandingImageUrl(data.enrollmentSummaryBrandingImageUrl ?? "");
setEnrollmentSummaryLaunchRetrySeconds(data.enrollmentSummaryLaunchRetrySeconds ?? 120);
setEnableRealmJoinWatcher(data.enableRealmJoinWatcher ?? false);
setKeepAwakeDuringUserEsp(data.keepAwakeDuringUserEsp ?? false);
// Notification channels (auto-migrates from the legacy single-webhook fields)
setNotificationChannels(channelsFromConfig(data));
// SLA Targets
@@ -587,8 +587,6 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
}
setEnableSoftwareInventoryAnalyzer(data.enableSoftwareInventoryAnalyzer ?? false);
setEnableIntegrityBypassAnalyzer(data.enableIntegrityBypassAnalyzer ?? true);
setEnableRealmJoinWatcher(data.enableRealmJoinWatcher ?? false);
setKeepAwakeDuringUserEsp(data.keepAwakeDuringUserEsp ?? false);
setEnableConsoleBypassDetection(data.enableConsoleBypassDetection ?? true);
setUnrestrictedMode(data.unrestrictedMode ?? false);
@@ -699,25 +697,8 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
void run();
}, [tenantId, editionInfo.edition, config?.bootstrapTokenEnabled, fetchBootstrapSessions]);
// -----------------------------------------------------------------------
// Fetch global diagnostics paths (global-admin only)
// -----------------------------------------------------------------------
useEffect(() => {
if (!user?.isGlobalAdmin) return;
const fetchGlobalDiagPaths = async () => {
try {
const res = await authenticatedFetch(api.globalConfig.get(), getAccessToken);
if (!res.ok) return;
const data = await res.json();
if (data.diagnosticsGlobalLogPathsJson) {
setGlobalDiagPaths(JSON.parse(data.diagnosticsGlobalLogPathsJson));
}
} catch {
// Non-fatal
}
};
fetchGlobalDiagPaths();
}, [user?.isGlobalAdmin, getAccessToken]);
// Built-in sections + global diagnostics paths are read-only context for every role and
// live in useDiagnosticsPathsCatalog (GET /api/diagnostics/paths), not in this provider.
// -----------------------------------------------------------------------
// Save configuration (shared by all sections)
@@ -768,6 +749,8 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
enrollmentSummaryTimeoutSeconds,
enrollmentSummaryBrandingImageUrl: enrollmentSummaryBrandingImageUrl || undefined,
enrollmentSummaryLaunchRetrySeconds,
enableRealmJoinWatcher,
keepAwakeDuringUserEsp,
// Notification channels are the authoritative config; the legacy single-webhook
// fields are cleared on save so deleting the last channel can't resurrect a zombie
// webhook via the backend's legacy-synthesis fallback.
@@ -794,8 +777,6 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
localAdminAllowedAccountsJson: localAdminAllowedAccounts.length > 0 ? JSON.stringify(localAdminAllowedAccounts) : "",
enableSoftwareInventoryAnalyzer,
enableIntegrityBypassAnalyzer,
enableRealmJoinWatcher,
keepAwakeDuringUserEsp,
enableConsoleBypassDetection,
unrestrictedMode: unrestrictedModeValue,
};
@@ -865,14 +846,14 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
helloWaitTimeoutSeconds, selfDestructOnComplete, keepLogFile, rebootOnComplete, rebootDelaySeconds,
contactEmail, enableGeoLocation, enableTimezoneAutoSet, enableDoGroupIdAutoSet, enableImeMatchLog, enableGatherRuleDebugLog, logLevel, showScriptOutput, showEnrollmentSummary,
enrollmentSummaryTimeoutSeconds, enrollmentSummaryBrandingImageUrl, enrollmentSummaryLaunchRetrySeconds,
enableRealmJoinWatcher, keepAwakeDuringUserEsp,
notificationChannels,
slaTargetSuccessRate, slaTargetMaxDurationMinutes, slaTargetAppInstallSuccessRate,
slaNotifyOnSuccessRateBreach, slaSuccessRateNotifyThreshold, slaNotifyOnDurationBreach,
slaNotifyOnAppInstallBreach, slaNotifyOnConsecutiveFailures, slaConsecutiveFailureThreshold,
diagnosticsBlobSasUrl, diagnosticsUploadMode, diagnosticsUploadDestination, tenantDiagPaths,
enableLocalAdminAnalyzer, localAdminAllowedAccounts, enableSoftwareInventoryAnalyzer,
enableIntegrityBypassAnalyzer, enableRealmJoinWatcher, keepAwakeDuringUserEsp,
enableConsoleBypassDetection, unrestrictedMode,
enableIntegrityBypassAnalyzer, enableConsoleBypassDetection, unrestrictedMode,
]);
// -----------------------------------------------------------------------
@@ -1260,6 +1241,8 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
setEnrollmentSummaryTimeoutSeconds(config.enrollmentSummaryTimeoutSeconds ?? 60);
setEnrollmentSummaryBrandingImageUrl(config.enrollmentSummaryBrandingImageUrl ?? "");
setEnrollmentSummaryLaunchRetrySeconds(config.enrollmentSummaryLaunchRetrySeconds ?? 120);
setEnableRealmJoinWatcher(config.enableRealmJoinWatcher ?? false);
setKeepAwakeDuringUserEsp(config.keepAwakeDuringUserEsp ?? false);
}, [config]);
const handleSaveAgentAnalyzers = useCallback(() => saveConfiguration("agentAnalyzers"), [saveConfiguration]);
@@ -1272,8 +1255,6 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
setNewAllowedAccount("");
setEnableSoftwareInventoryAnalyzer(config.enableSoftwareInventoryAnalyzer ?? false);
setEnableIntegrityBypassAnalyzer(config.enableIntegrityBypassAnalyzer ?? true);
setEnableRealmJoinWatcher(config.enableRealmJoinWatcher ?? false);
setKeepAwakeDuringUserEsp(config.keepAwakeDuringUserEsp ?? false);
setEnableConsoleBypassDetection(config.enableConsoleBypassDetection ?? true);
}, [config]);
@@ -1711,6 +1692,8 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
enrollmentSummaryTimeoutSeconds, setEnrollmentSummaryTimeoutSeconds,
enrollmentSummaryBrandingImageUrl, setEnrollmentSummaryBrandingImageUrl,
enrollmentSummaryLaunchRetrySeconds, setEnrollmentSummaryLaunchRetrySeconds,
enableRealmJoinWatcher, setEnableRealmJoinWatcher,
keepAwakeDuringUserEsp, setKeepAwakeDuringUserEsp,
handleSaveAgentSettings, handleResetAgentSettings,
// Agent analyzers
@@ -1719,8 +1702,6 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
newAllowedAccount, setNewAllowedAccount,
enableSoftwareInventoryAnalyzer, setEnableSoftwareInventoryAnalyzer,
enableIntegrityBypassAnalyzer, setEnableIntegrityBypassAnalyzer,
enableRealmJoinWatcher, setEnableRealmJoinWatcher,
keepAwakeDuringUserEsp, setKeepAwakeDuringUserEsp,
enableConsoleBypassDetection, setEnableConsoleBypassDetection,
handleSaveAgentAnalyzers, handleResetAgentAnalyzers,
@@ -1750,7 +1731,6 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
diagnosticsUploadMode, setDiagnosticsUploadMode,
diagnosticsUploadDestination, setDiagnosticsUploadDestination,
tenantDiagPaths, setTenantDiagPaths,
globalDiagPaths,
newDiagPath, setNewDiagPath,
newDiagDesc, setNewDiagDesc,
handleSaveDiagnostics, handleResetDiagnostics,
@@ -1796,10 +1776,10 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
enableGeoLocation, enableTimezoneAutoSet, enableDoGroupIdAutoSet, enableImeMatchLog, enableGatherRuleDebugLog,
logLevel, showScriptOutput, showEnrollmentSummary, enrollmentSummaryTimeoutSeconds,
enrollmentSummaryBrandingImageUrl, enrollmentSummaryLaunchRetrySeconds,
enableRealmJoinWatcher, keepAwakeDuringUserEsp,
handleSaveAgentSettings, handleResetAgentSettings,
enableLocalAdminAnalyzer, localAdminAllowedAccounts, newAllowedAccount,
enableSoftwareInventoryAnalyzer, enableIntegrityBypassAnalyzer, enableRealmJoinWatcher,
keepAwakeDuringUserEsp, enableConsoleBypassDetection,
enableSoftwareInventoryAnalyzer, enableIntegrityBypassAnalyzer, enableConsoleBypassDetection,
handleSaveAgentAnalyzers, handleResetAgentAnalyzers,
unrestrictedMode, handleSaveUnrestrictedMode,
notificationChannels, testingChannelId, testChannelResult,
@@ -1809,7 +1789,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
slaNotifyOnAppInstallBreach, slaNotifyOnConsecutiveFailures, slaConsecutiveFailureThreshold,
handleSaveSlaTargets, handleResetSlaTargets,
diagnosticsBlobSasUrl, diagnosticsUploadMode, diagnosticsUploadDestination,
tenantDiagPaths, globalDiagPaths, newDiagPath, newDiagDesc,
tenantDiagPaths, newDiagPath, newDiagDesc,
handleSaveDiagnostics, handleResetDiagnostics,
admins, loadingAdmins, newAdminEmail, newMemberRole, addingAdmin, removingAdmin, togglingAdmin,
adminSearchQuery, currentAdminPage,
@@ -33,14 +33,15 @@ const EXPECTED_OWNED_FIELDS = [
"contactEmail",
// agentSettings
"enablePerformanceCollector", "performanceCollectorIntervalSeconds", "helloWaitTimeoutSeconds",
"enableRealmJoinWatcher",
"selfDestructOnComplete", "keepLogFile", "rebootOnComplete", "rebootDelaySeconds",
"enableGeoLocation", "enableTimezoneAutoSet", "enableDoGroupIdAutoSet", "enableImeMatchLog", "enableGatherRuleDebugLog",
"enableGeoLocation", "enableTimezoneAutoSet", "enableDoGroupIdAutoSet", "keepAwakeDuringUserEsp",
"enableImeMatchLog", "enableGatherRuleDebugLog",
"logLevel", "showScriptOutput", "showEnrollmentSummary", "enrollmentSummaryTimeoutSeconds",
"enrollmentSummaryBrandingImageUrl", "enrollmentSummaryLaunchRetrySeconds",
// agentAnalyzers
"enableLocalAdminAnalyzer", "localAdminAllowedAccountsJson", "enableSoftwareInventoryAnalyzer",
"enableIntegrityBypassAnalyzer", "enableRealmJoinWatcher", "keepAwakeDuringUserEsp",
"enableConsoleBypassDetection",
"enableIntegrityBypassAnalyzer", "enableConsoleBypassDetection",
// diagnostics
"diagnosticsBlobSasUrl", "diagnosticsUploadMode", "diagnosticsUploadDestination", "diagnosticsLogPathsJson",
// unrestrictedMode
@@ -59,6 +60,7 @@ const SERVER_DENIED_FIELDS = new Set([
"lastUpdated", "updatedBy", "onboardedAt", "onboardedBy",
"homedAppClientId", "lastAuthClientId", "lastAuthClientIdSince",
"planTier", "trialExpiresUtc", "trialStartedUtc", "trialConsumed", "trialGrantedBy",
"proDowngradedUtc",
]);
describe("SECTION_FIELD_MAP parity", () => {

Some files were not shown because too many files have changed in this diff Show More