Compare commits

...

31 Commits

Author SHA1 Message Date
Oliver Kieselbach 3392a23da6 feat(bootstrap): promote dev script v2.2 to stable — download.autopilotmonitor.com endpoint + OOBE-relax (#118)
The stable Install-AutopilotMonitor.ps1 is now the previously field-tested
-dev variant (v2.2): agent download via the Front Door alias
https://download.autopilotmonitor.com/agent/ instead of the legacy blob
host, mandatory SHA-256 manifest verification (Content-MD5 fallback
removed), and the OOBE-relax exemption for the Windows Backup for
Organizations restore case. Test-ShouldBootstrapAgent.ps1 mirrors the
same guard logic; the separate -dev.ps1 files are gone.

CI dev-render anchor literals in build-agent.yml updated to the alias
URLs (stable + dev channel) so the literal-substitution check keeps
passing; local build.ps1 anchors updated too (gitignored).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 01:37:17 +02:00
Oliver Kieselbach 5312216f6b fix(web): hooks-warnings batch 1 — static-components, purity, immutability to zero (#117)
First burn-down batch of the React-Compiler hooks warnings (163 -> 141,
CI ratchet lowered to match).

static-components (14, real remount bugs): SortIcon (geographic-
performance), SortIndicator (VulnerabilityReportSection) and SearchIcon/
ResultsDropdown (GlobalSearch) were defined inside render — a new
component type every render, so React unmounted and rebuilt their
subtrees each time (GlobalSearch tore down its dropdown on every
keystroke). All hoisted to module level with explicit props.

purity (6): Date.now() during render replaced with mount-time clocks
(useState lazy init) for the date-input caps (Maintenance sections) and
trial-expiry checks (TenantManagement, SectionTenantConfigReport);
FeedbackSection refreshes its clock per fetch so "Xm ago" labels track
the data; DownloadProgress falls back to NaN instead of wall-clock for
unparseable event timestamps (rate calc already guards via
Number.isFinite).

immutability (5): fetch functions were declared below the effects that
call them — moved above (diagnosis, fleet-health, DistressReports).
This unblocked deeper compiler analysis, surfacing 3 latent
set-state-in-effect findings in those files (93 -> 96); they belong to
the set-state category and stay for its own batch.

Verified: eslint 0 errors / 141 warnings (gate exits 0), tsc clean,
vitest 611/611, static export builds.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 01:21:29 +02:00
Oliver Kieselbach 72392f6f4c chore(web): lint findings to zero errors + CI lint gate (#116)
* feat(web): typed routes — broken internal links are now compile errors

typedRoutes: true (stable in Next 16; `next typegen` already runs in CI
before tsc since #114). A typo'd <Link>/router.push target now fails the
build instead of 404ing at runtime — the failure class behind the recent
RSC/routing incidents.

- lib/routes.ts: registry helpers return `Route`; withQuery validates the
  base path literal. New: deviceBlockUrl (OpsEventsSection previously
  hand-built /admin/security/device-block against the registry rule),
  route() to compile-check dynamic-section literals (nav configs,
  section index redirects), trustedRoute() as the single named seam for
  runtime-data hrefs (backend-emitted notification deep links, persisted
  post-login return URL).
- Nav types (NavItem, ExpandableSubItem, PageSectionItem) carry Route
  hrefs — every sidebar/navbar entry is compile-checked.
- ClientRedirect is generic so dynamic-section literals infer.

Verified: tsc clean; negative test (typo'd static + dynamic routes)
fails compilation as intended; 611/611 vitest; static export builds
with an unchanged route list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(web): lint findings to zero errors + CI lint gate

Package deal — fixes only land together with the gate, otherwise they
drift right back. CI web job now runs `eslint . --max-warnings 163`:
errors fail outright, the warning cap is a ratchet (lower it when fixing
warnings, never raise it).

Fixed to zero (mechanical categories, behavior unchanged):
- no-unused-vars (49): dead imports/locals removed; where a prop is part
  of a live call-site contract, only the destructured binding dropped
- no-explicit-any (29): real structural types for event payloads
  (EnrollmentEvent.data et al -> Record<string, unknown> + per-event
  interfaces), SignalR passthroughs typed via the library's own
  signatures; five consumer files got typing-only follow-through
- no-unescaped-entities (15), prefer-const, jsx-no-comment-textnodes
- no-require-imports (5): scripts/**/*.js override — Node CJS scripts,
  require() is correct there
- one stale eslint-disable directive removed

Deliberately NOT fixed inline: the React-Compiler hooks rules new in
eslint-plugin-react-hooks v6 (set-state-in-effect 93, refs 26,
static-components 14, purity 6, immutability 5,
preserve-manual-memoization 3) are demoted to warn in eslint.config.mjs —
each is a per-site behavioral refactor in auth/SignalR-adjacent code, to
be burned down incrementally under the ratchet.

Verified: eslint 0 errors / 163 warnings (gate command exits 0), tsc
clean, vitest 611/611, static export builds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 00:55:38 +02:00
Oliver Kieselbach d65157a092 feat(web): typed routes — broken internal links are now compile errors (#115)
typedRoutes: true (stable in Next 16; `next typegen` already runs in CI
before tsc since #114). A typo'd <Link>/router.push target now fails the
build instead of 404ing at runtime — the failure class behind the recent
RSC/routing incidents.

- lib/routes.ts: registry helpers return `Route`; withQuery validates the
  base path literal. New: deviceBlockUrl (OpsEventsSection previously
  hand-built /admin/security/device-block against the registry rule),
  route() to compile-check dynamic-section literals (nav configs,
  section index redirects), trustedRoute() as the single named seam for
  runtime-data hrefs (backend-emitted notification deep links, persisted
  post-login return URL).
- Nav types (NavItem, ExpandableSubItem, PageSectionItem) carry Route
  hrefs — every sidebar/navbar entry is compile-checked.
- ClientRedirect is generic so dynamic-section literals infer.

Verified: tsc clean; negative test (typo'd static + dynamic routes)
fails compilation as intended; 611/611 vitest; static export builds
with an unchanged route list.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 00:55:26 +02:00
Oliver Kieselbach cf45911b69 Web upgrade: Next 16 + React 19 + MSAL v4 (#114)
* chore(web): bump to next 16, react 19, msal-browser 4, msal-react 3

react 19.2 aligns the declared dependency with the React the app router
has been running all along (Next-vendored 19 canary). msal-react 3.0.29
is the React-19-compatible bridge that only needs msal-browser 4 —
deliberately NOT msal v5, whose COOP redirect bridge requires new
bridge pages per origin plus app-registration changes (separate
project). The react-leaflet peer overrides are gone: react 19
satisfies react-leaflet 5 natively.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): react 19 / msal v4 / next 16 code adaptations

- msalConfig: allowNativeBroker -> allowPlatformBroker (v4 rename);
  storeAuthStateInCookie stays (deprecated in v4, removed only in v5)
- zero-arg useRef() removed in @types/react 19: GlobalSearch (2x),
  dashboard autoLoadTimerRef
- React.MutableRefObject -> React.RefObject in 5 session/progress hooks
  (all refs are mutable in 19 types)
- layout: data-scroll-behavior="smooth" opt-in so route changes keep
  jumping instead of animating (Next 16 stopped suppressing CSS
  smooth-scroll during navigation)
- next.config: analyzer comment - Turbopack default build skips the
  webpack-only analyzer, use --webpack for ANALYZE runs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(web): untrack Next-managed next-env.d.ts, generate types in CI

Next 16 rewrites next-env.d.ts on every dev/build run and the routes
import inside flips between .next/types and .next/dev/types (vercel/
next.js#86001) - official guidance is to gitignore it. CI now runs
`next typegen` before tsc so the type stubs exist on a fresh checkout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(web): migrate lint script to eslint CLI (next lint removed in 16)

Minimal flat config from eslint-config-next@16 (core-web-vitals +
typescript) with build-output ignores. Lint stays out of CI as before;
the 264 pre-existing findings are untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:54:12 +02:00
Oliver Kieselbach 1593232898 fix(web): every nav click was a full-page reload — skipTrailingSlashRedirect broke RSC fetches
skipTrailingSlashRedirect (added for the next-dev /api proxy 308) also defines
__NEXT_MANUAL_TRAILING_SLASH, which disables the client router's trailing-slash
normalization. Under output:'export' the router then requested RSC payloads as
/<path>.txt while the build only emits /<path>/index.txt — every fetch 404ed and
Next silently degraded EVERY <Link> navigation to an MPA (full-page) load. Most
visible symptom: the sidebar remounted on each click and its expanded groups
collapsed.

- next.config.ts: dev-gate skipTrailingSlashRedirect (prod build normalizes
  links to /foo/ again; RSC fetches hit the emitted index.txt files)
- GlobalSidebar: normalize usePathname() (client nav now yields /foo/, hard
  loads /foo — exact matches against the slash-less nav hrefs need both), and
  re-run the auto-expand effect when visibleExpandableGroups arrives so a hard
  load (deep link, F5) still expands the active group once MSAL resolves

Verified: static export + local serve, marker survives Link nav (no document
request, /terms/index.txt 200); tsc clean; vitest 611/611.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:03:52 +02:00
Oliver Kieselbach e4c06a9564 Landing page v2 — scroll story, portal-first hero, Autopilot Monitor rebrand (#113)
* feat(web): landing page v2 — scroll story, green brand, Autopilot Monitor wordmark

Complete rebuild of the landing page as a scroll story of one enrollment:
- Hero shows the product live (animated event-stream timeline, real event vocabulary)
- Story acts: 09:02 power-on -> 09:41 ANALYZE-ESP-001 hit (most-firing rule, ~22% hit rate)
  -> 09:43 MCP terminal demo (full session report via get_session_summary +
  get_time_attribution) -> fleet zoom-out
- Floating pill navbar, capabilities strip, restyled comparison, shared SiteFooter
  (now also mounted on /about, /terms, /privacy which had no footer at all)
- Green mark (#33B161) unified as the only brand mark via BrandMark.tsx;
  blue-indigo gradient marketing mark removed
- Wordmark is now "Autopilot Monitor" (two words) across all user-visible text,
  metadata and JSON-LD; technical identifiers (script names, ProgramData paths) untouched
- Theming via lp-* CSS variables on :root/.dark — new components never collide with
  the global .dark !important overrides; old .landing-page block kept for /preview
- StatsBand replaces PlatformStats: adds success rate (successfulEnrollments payload
  field, previously unused) and a loading skeleton instead of fake defaults
- New opengraph/twitter/apple images in the new design (green mark on dark slate)
- All animations hand-rolled (rAF/CSS/IntersectionObserver), CSP-safe, zero new
  dependencies, prefers-reduced-motion respected; auth path (AuthGate/LoginButton/
  hostRouting) and staticwebapp.config.json unchanged

Checks: tsc clean, vitest 611/611, static export build clean, no horizontal
overflow at 375px.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): landing feedback round — linear story timeline, honest examples, dev stats

- Story is now one vertical timeline with a strict top-down reading order
  (time rail left, text left, visual right) instead of the zigzag grid;
  act 1 gets a "session appeared" card so no column is left empty
- Hero live session decoupled from the story device (CONTOSO-0912/Surface
  Laptop 7 completes; the story's CONTOSO-4711 is the one that gets stuck)
- Defender for Endpoint removed from the demo stream (it is onboarded, not
  installed during ESP) — replaced with Microsoft 365 Apps
- StatsBand: sample numbers in local dev only; in production the band shows
  real figures or hides entirely on fetch failure (never fake numbers,
  never an endless skeleton)
- Capabilities copy: "Pull a diagnostics package" instead of the incorrect
  "Pull ETL and event logs"

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): hero v3 — mission-control cockpit instead of single-session card

The first screen now shows the product as a live fleet cockpit: KPI strip
(active-now count ticks), five enrollments running concurrently with
advancing progress bars, a new session popping in, two completing during
the ~23s loop, and the analysis engine visibly flagging the stuck device
inline (HIGH ANALYZE-ESP-001 with the culprit app). Event stream column on
desktop. Headline block is left-aligned and more compact to break the
centered-template look. Deterministic single-tick choreography,
IntersectionObserver start, prefers-reduced-motion gets a rich static
frame. Replaces LiveTimeline.tsx.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): hero v4 — real portal screenshots instead of fabricated cockpit

Hero visual is now the actual product: the live portal dashboard (captured
via Playwright from portal.autopilotmonitor.com, demo fleet staged in the
DOM — no customer data, preview banner removed, wordmark as it ships) with
a real analysis finding card (ANALYZE-APP-002 incl. remediation + evidence
JSON, from the docs assets) overlapping bottom-right. Bonus asset
time-attribution.png captured for later use. Removes HeroCockpit.tsx.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): hero v5 — clean dashboard, real-analysis terminal, refined header

- Analysis-finding overlay removed: the dashboard shot stands alone (was
  colliding with the stats band and not catching)
- Terminal demo moved into the hero as its own "Then just ask" section and
  rescripted from a real production analysis (anonymized): failed attempt
  with 0x8007EA61 decoded to unmapped installer exit code 60001 (PSADT),
  transient proof via clean retry, actionable recommendation
- Headline block refined: uppercase eyebrow instead of pill badge, tighter
  type and buttons
- Wordmark "Monitor" back to ink color (green variant parked for compare)
- Story act 3 now shows the real time-attribution screenshot instead of
  duplicating the terminal

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): story act 3 — notification + one-click diagnostics instead of cropped screenshot

"See where the minutes went" (half-cut time-attribution raster) replaced
with the admin's real next move: the Teams/webhook alert that fired with
the rule, and the on-demand diagnostics flow (requested -> collected
mid-enrollment -> ZIP ready). Crafted JSX cards consistent with the other
acts; unused time-attribution.png removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): hero v6 — portal-first layout from approved design draft

- Full-width sticky navbar (brand, Product/Capabilities/Compare/Docs,
  Sign in + Get started) replaces the floating pill
- Hero per draft: gray eyebrow, "See every Autopilot enrollment. Live.",
  sub left / CTA group right with trust line, portal shot nearly full
  width (max-w-7xl), green background glow removed
- Stats as a full-bleed band with dividers under the shot; "issues
  detected" replaces success rate (underlines automated analysis),
  events compact-formatted (8.3M)
- "Then just ask" terminal extracted into its own JustAsk section after
  the band

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): get-started flow page, honest Teams card, calm buttons, light-only landing

- New public /get-started page: five-step schematic (sign in -> request
  access -> consent -> deploy bootstrapper -> watch live); "Get started"
  in navbar and hero now lead here, "Sign in" keeps the login handoff —
  resolving the two-identical-CTAs problem. Wired into hostRouting
  PUBLIC_PATH_PREFIXES, sitemap, robots allow-list.
- StoryAction Teams card corrected against the backend renderer: the real
  adaptive card has exactly one action ("Open session") — the invented
  "Collect diagnostics" button is gone (NotificationAlertBuilder.cs:60).
- Buttons no longer jump (translate-y removed); hover is brightness +
  shadow only. Final CTA rebuilt as a calm two-column band per draft
  (no dark box, no glow).
- Landing/marketing surface is light-only for now: lp-* tokens re-declared
  under .dark .landing-v2, theme toggle removed from LandingNavbar
  (screenshots are light; portal keeps full dark support).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): about/terms/privacy on the landing shell — no more sidebar flash

The three public pages now render exactly like /get-started: LandingNavbar
+ landing-v2 tokens + eyebrow/headline header + SiteFooter. GlobalSidebar
no longer treats them as sidebar pages for anonymous visitors — that was
the "weird loading": app sidebar + PublicSiteNavbar popping in after
hydration on marketing pages. PublicSiteNavbar and PublicPageHeader are
dead and deleted; terms/privacy links switched from indigo to the accent
green. Authenticated users keep the portal chrome on these pages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): landing navbar anchors root-anchored so they work from subpages

#story/#features/#comparison -> /#story/... — clicking Product/Capabilities/
Compare on /get-started (or any public subpage) now navigates back to the
landing section instead of dead-ending on a missing local anchor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): calmer CTA green, about de-blued, public-page typography aligned

- All real CTAs (navbar, hero, final CTA, get-started, story mock button)
  switch from bright #33b161 to the deep accent-ink green — matches the
  dark-green family in the portal screenshot, less loud
- About page fully on the new design language: rainbow colorMap collapsed
  to the single green accent, audience cards/step bubbles/links/quick-link
  hovers de-blued
- About/terms/privacy typography scaled to landing sizes: 15px body,
  smaller page h1s and section headings

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): terms callouts on lp tokens — no more stray blue/amber banners

Info callout ("Who can use it") now uses the green accent-soft style;
the AS-IS warning uses the palette warn tones. Verified terms/about carry
no remaining raw blue/amber/indigo utility colors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): about tech stack — drop version numbers, fix stale SSR claim

Versions age silently on marketing pages (.NET 10 / Next.js 15 / React 18
removed, technologies stay). Also corrects "server-rendered React app" —
the portal has been a static export since the RSC-prefetch incident fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): terminal demo app renamed to Contoso CRM App

Neutral fictional app instead of a vendor-adjacent print driver name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): analysis card confidence score, 40+ rules claim, final CTA to /get-started

- StoryAnalysis card gets the portal-style confidence bar (80%)
- Act 2 copy: "40+ built-in rules" (exactly 40 in rules/analyze today)
- "Start monitoring now" leads to /get-started like the other Get-started
  CTAs; sign-in stays in the navbar and on /get-started itself

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): portal brand aligned to landing, subtle Enterprise anchoring

- Portal navbar (both variants) now uses BrandMark + the landing wordmark
  style (w-6 mark, 15px bold tracking-tight, strong ink in light/dark) —
  completes the brand consolidation follow-up
- Enterprise plan anchored softly, free-first tone preserved: final-CTA
  subline + /get-started closing note ("same service, nothing to
  migrate") + "Plans" link in the footer Product column, all pointing to
  the docs plans page

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): final CTA right-aligned, get-started enterprise note "and more"

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(web): social images refreshed to current hero claim and tagline

"See every Autopilot enrollment. Live." + the site tagline (real-time
monitoring, automated analysis, AI-ready telemetry) on the green-mark
dark card; opengraph + twitter regenerated at 1200x630.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(web): social card wordmark single-color, matching the site

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): portal sidebar selection states green — brand-aligned like the docs

Active nav items, expandable group actives, page-section title/divider
switch from blue to the green brand accent (light + dark variants).
Purple Global Admin mode stays purple (semantic distinction); broader
portal blues (status pills, buttons, KPI tiles) deliberately untouched —
that is a separate re-theme pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): preview feedback round — dark-mode leak, alignment, renamed repo links

- About/terms/privacy no longer render half dark for dark-mode visitors:
  the light-only marketing scope now neutralizes the global .dark utility
  overrides (bg-white / text-gray-* / border-gray-* / gray hovers) inside
  .landing-v2
- All landing sections aligned to one container width (max-w-7xl): story,
  just-ask, capabilities, comparison, how-it-works now share the same
  left edge as hero, stats band and final CTA
- GitHub links updated to the renamed repository okieselbach/AutopilotMonitor
  (old hyphenated URLs redirected, now correct at the source)
- /get-started wording: "Preview" instead of "Private Preview" (legal pages
  and portal status texts intentionally unchanged)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): preview page on the new design + ?demo=1 review mode; footer width aligned

- /preview rebuilt on lp tokens: BrandMark, single wordmark, calm card,
  accent notify box, neutral next-steps, ghost actions — "Preview"
  wording instead of "Private Preview"
- ?demo=1 skips the auth redirects and renders placeholder identity so
  the page can be reviewed unauthenticated (locally and on SWA previews)
- SiteFooter container widened to max-w-7xl to match navbar and sections

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): preview page — prominent approval-pending badge, honest status copy

- Amber "Approval pending" status pill above the headline
- Next-steps wording per review ("sign out and wait a bit or contact
  me…", capacity clause dropped)
- Corrected the false "approval status on your dashboard" claim: a
  blocked tenant never reaches a dashboard — signing in again lands back
  on this page while pending, and goes straight into the portal once
  approved (verified against AuthContext PrivatePreview handling)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): about/terms/privacy cards flattened to the landing pinstripe style

All raised cards (shadow / shadow-sm) become flat lp-surface panels with
the thin lp-line-soft border; inner plan boxes and quick-link tiles
switch from gray-200 borders to the same pinstripe line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): portal primary actions green — buttons, avatar, Progress Portal icon

- Primary buttons (bg-blue-600/indigo-600 + hover pairs) switch to
  green-600/700: admin backups, customs archive, vulnerability data,
  session reports, and the settings SaveResetBar
- Navbar user avatar circles green instead of blue (all four variants)
- Progress Portal sidebar MonitorIcon gets a deliberate green accent
  (light + dark) instead of inheriting gray
- Semantic blues (status pills, info banners, KPI tiles) untouched

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): portal links and focus rings on brand green

- All focus states unified: focus:ring/border blue-500 and indigo-500
  (207 occurrences) -> green-500 across inputs, selects, buttons
- Link patterns text-blue-600/indigo-600 with hover 800, hover:underline
  variants (36) -> text-green-700 hover:text-green-800; icon hovers
  hover:text-blue-600 (12) -> green
- New dark override .dark .text-green-700 -> green-400, mirroring the
  existing blue-700 convention
- Semantic blues untouched: info banners, status pills, KPI tiles

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): local dev sign-in repaired; progress portal brand accents green

Local sign-in appeared broken (always landed on /progress as a role-less
user) — two stacked regressions in the dev API proxy:
1. trailingSlash:true (static-export change) made next dev 308-redirect
   /api/* to /api/*/ BEFORE the proxy rewrite; the Functions backend
   rejects trailing-slash routes, so /me silently failed.
   -> skipTrailingSlashRedirect: true (no effect on the static export;
   SWA handles trailing slashes itself).
2. .env.local DEV_API_PROXY_TARGET still pointed at the pre-EU-cutover
   autopilotmonitor-api (dead since 07-18) -> now -api-eu; proxy verified
   healthy (v1.5.1027).

Progress portal: header icon, icon circle, "Change device" link and
"Check Status" button on brand green; the blue activity indicators
(downloading/installing) stay blue — that is the portal-wide in-progress
semantic, not branding.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): loading screens and remaining primary buttons on brand green

- All four loading spinners (AuthGate, ProtectedRoute, CalculatingCard,
  UnmappedSoftwareTab) green instead of blue
- Blue-indigo gradient loading/error backgrounds (AuthGate overlay,
  ProtectedRoute states, error page, CalculatingCard, SectionPlatformUsage)
  replaced with the neutral lp background (theme-aware in the portal)
- 19 non-contiguous primary buttons (bg-blue-600 text-white ...
  hover:bg-blue-700) the first sweep missed -> green, incl. ProtectedRoute
  retry/back buttons, error page, audit, diagnosis, session table

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): last blue interactive states green; monitoring headers wrap on mobile

- Time-range toggles (fleet health, software, usage, dashboard, platform
  usage), software lens tabs, MCP-usage segmented controls, SLA report
  button, gather/analyze rule save buttons, and the page-header icons on
  fleet health / geographic performance -> brand green. Zero remaining
  bg-blue-600/indigo-600 interactive patterns.
- Monitoring page headers (fleet health, software, SLA, usage metrics,
  geographic performance, audit): title row gets flex-wrap + gap-y-3 and
  the control group (tenant scope + range buttons) wraps as a compact
  second line on narrow screens instead of pushing off the right edge;
  range buttons slightly compacted (px-3 py-1.5).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): geo map dev crash — per-mount container key for Leaflet

"Map container is already initialized": dev-mode remounts (StrictMode
re-runs, Fast Refresh over the dynamic() wrapper) hand Leaflet the same
container div twice and it refuses. A per-mount key on MapContainer
forces a virgin div for every (re)mount; MapCleanup keeps handling
instance teardown. No production behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): form control accents (radios/checkboxes) green portal-wide

Checked-state colors for radios and checkboxes (indigo/blue/amber/teal/
sky variants, 19 controls incl. the diagnostics destination radios and
subfolder checkboxes) unified on green, plus the diagnostics "Add" button.
Deliberately kept: red danger-confirmation checkboxes and the purple
global-admin control.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): pin native form-control accent-color to brand green

Root cause of the "red" radios/checkboxes: there is no @tailwindcss/forms
plugin, so text-* utilities never styled native controls — their check
color silently followed the user's OS accent color. A global accent-color
rule (green-600, green-500 in dark) pins all radios/checkboxes to the
brand; the danger-red and global-admin-purple controls override via
accent-* utilities.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): progress portal icon inherits item color again

Permanent green stood out against the gray siblings — the sidebar icon
now follows the item state like all others: gray at rest, green when
active.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): geo map root fix (react-leaflet 5) + readable dark muted text

- Root cause of "Map container is already initialized": Next 15 app
  router runs its bundled React 19 regardless of the installed react@18,
  and react-leaflet 4.x is incompatible with React 19 StrictMode
  double-invoked effects. Upgraded react-leaflet 4.2.1 -> 5.0.0
  (@react-leaflet/core 3) and removed our now-counterproductive
  workarounds (MapCleanup manual remove, per-mount container key) — v5
  owns the map lifecycle including unmount teardown. Verified via a
  throwaway repro page: map renders, remount toggle survives, console
  clean (repro page deleted).
- Dark mode: .dark .text-gray-400/500 lightened (#64748b -> #7b8ba3 /
  #8896ad) — notification timestamps and other muted text were nearly
  invisible on raised dark surfaces (unread bg, hover rows).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): subtler dark hover surfaces — hovered rows stay readable

.dark hover:bg-gray-50/100 dropped from #334155 to #283548/#2e3c52 (one
step above the card surface): the bright hover wash made notification
timestamps and other muted text illegible on highlighted rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): GA/tenant notification rows dark-aware — opacity variants escaped the overrides

The global-admin and tenant notification rows use bg-purple-50/30 and
bg-blue-50/30 (+ /50 hovers) — opacity-variant class tokens the global
.dark .bg-*-50 overrides never match, leaving light rows with light text
on dark mode. Explicit dark: variants (purple/blue-900 at low opacity)
fix the readability.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): notification row hover clearly distinguishable in dark mode

GA (purple) and tenant (blue) rows: dark hover raised from /30 to /45
opacity so the highlighted row visibly differs from its resting tint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): one SegmentedControl for all range/group toggles

New shared components/SegmentedControl.tsx (the geo-style connected
pills on a gray track) replaces the three mixed patterns: loose button
trios on fleet health and software, the months <select> on SLA, and
geo's two inline toggles (time range + group-by) now all render the
same control with shared 7/30/90-day labels. Dark mode: active pill
uses arbitrary-value colors because the global .dark .bg-white override
would blend it into the track.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): MCP usage sections use the shared SegmentedControl too

The last remaining variant (joined bordered buttons with raw 7d/30d/90d
labels in admin metrics + reporting MCP usage) replaced by the shared
control with the unified "7/30/90 Days" labels.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: clickable notification deep links in the bell dropdown

GA notifications now carry hrefs (session/diag-files report -> report
modal, preview signup -> tenant management filtered), GA rows render the
View link, and every bell row with an href navigates on row click and
closes the dropdown. Target sections accept ?reportId= / ?tenantId= via
the proven Suspense+useSearchParams pattern from DeviceBlockSection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(web): npm overrides pin react-leaflet peer deps to installed react

react-leaflet 5 peer-depends on react 19; the app router runs Next's
bundled React 19 at runtime while the installed package stays react 18.
Without the overrides a plain npm ci (SWA/Oryx build) fails on the peer
conflict.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 22:21:18 +02:00
Oliver Kieselbach 9c50d98792 fix(web): sign-in landed back on www — HostRoutingGuard bounced before MSAL settled
Two defects of the same shape in the portal→www direction:
- Deterministic: a signed-in user opening portal root was always bounced to
  www (guard effect fires at hydration, before MSAL restores the session, so
  AuthGate never got to route "/" to /dashboard).
- Race: MSAL's module-level init strips the #code hash before hydration, so
  the auth-response exception saw nothing and the bounce destroyed the
  in-flight token redemption — the user ended up unauthenticated on www.

Portal→www now waits for settled, unauthenticated auth state; www→portal
stays auth-free. Decision extracted as pure decideHostBounce with the safety
rules pinned in hostRouting.guard.test.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 12:18:12 +02:00
Oliver Kieselbach 20b43e5349 feat(agent): gather debug log — phase transitions + per-match regex traces
- New "phase" stage: OnPhaseChanged writes a transition marker line
  (phase change: Old -> New) so rule-level lines read against the current
  phase; only on real changes, written outside _scopeLock.
- LogParser traces each regex match: line number, matched text (<=200 chars)
  and capture groups (<=100 chars, same selection as event data), capped at
  10 lines per file per run; the per-file summary keeps the full count.
- Invalid-regex error line now includes the offending pattern string.

Tests: 4 new (transition dedup, match trace, cap, pattern in error);
agent suite 2137/2137 green. OKF doc + docs/log.md updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 20:07:52 +02:00
Oliver Kieselbach d0d1d28371 perf(backend): column projection for unmatched-software scan
GetUnmatchedSoftwareFunction runs a full cross-tenant scan of
SoftwareInventory on every request (including every pagination click) but
only reads 9 properties. GetAllSoftwareInventoryAsync now accepts an
optional $select projection and the function passes the consumed columns,
cutting transfer and entity materialization.

Server-side filtering on CpeUri is documented as impossible: rows
re-created by the cascade-delete 404 path carry no CpeUri property and
Table Storage cannot express "property missing" — those rows must still
count as unmatched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 17:22:07 +02:00
Oliver Kieselbach 5191751ed2 perf(web): lazy admin context + instant shell for software mapping
AdminConfigProvider no longer eagerly fetches global/config, config/all
(46 KB) and preview/whitelist on every /admin/* entry. Sections that
consume adminConfig/tenants trigger the idempotent ensureAdminConfigLoaded/
ensureTenantsLoaded on mount; fetchTenants stays as the explicit post-save
refresh. Pages that only need the token (Software Mapping, Backups, ...)
now pay nothing.

/admin/software gets a route-level loading.tsx and all three software tabs
render a TableSkeleton instead of a centered spinner on initial load. The
Mapped/Ignored mount guards ("!loaded && !loading") could refetch endlessly
after a failed fetch; now fetch only on !loaded.

Also gitignore .playwright-mcp/ (Playwright MCP browser artifacts).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 17:21:51 +02:00
Oliver Kieselbach 907118649b added playwright mcp 2026-07-29 16:00:17 +02:00
Oliver Kieselbach 26ecbf33d6 feat(web): instant-shell loading UX — table skeletons + route loading fallbacks
Perceived hangs on navigation (e.g. Distress Reports) had three causes:
missing loading.tsx fallbacks froze the old page until the new chunk
rendered, loading state initialised false flashed empty states for one
paint, and several pages blocked their entire render on the first fetch.

- New TableSkeleton/SectionSkeleton primitives (server-component-safe),
  loading-UX convention documented in TableSkeleton.tsx
- 20 new route-level loading.tsx fallbacks (admin areas + top-level pages)
- Distress/Session Reports + Feedback: loading init true, skeleton in the
  table slot; refreshes keep the populated table
- Dashboard: skeleton in the sessions slot instead of a blank gap
- Converted full-page loading early returns on audit (also fixes a latent
  auth hang: the spinner sat outside ProtectedRoute so a fresh tab never
  triggered the MSAL redirect), usage-metrics, fleet-health, sla and
  admin Usage Plans; deliberate gates (diagnosis, preview, CalculatingCard
  pages) left untouched

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 15:17:48 +02:00
Oliver Kieselbach adb103fc9e fix(web): always render the diagnostics opt-in in the report modal
Hidden-when-unavailable read as missing. The block now always shows:
active checkbox when the session has an uploaded archive, disabled with
an explanatory hint when it does not. Submit semantics unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 15:11:04 +02:00
Oliver Kieselbach 26c8b80efa updated commands 2026-07-29 15:00:17 +02:00
Oliver Kieselbach 89c5c7da03 feat: richer, self-describing session report content
The report ZIP now captures everything an operator needs even after the
session is gone, and states its own completeness:

- session.csv: append the 15 newer Sessions fields that had drifted out
  of the export (failureSource, reconcileReason, adminMarkedAction,
  validatedBy, rebootCount, isHybridJoin, isSelfDeployingProfile,
  connectionType, geo*, avgApiLatencyMs, apiRequestCount, stalledAt,
  failureSnapshotJson)
- completeness signal: exportedEventCount / sessionEventCount /
  eventStreamActive in the request and report-metadata.json; the modal
  warns while event pages are still streaming
- includedDiagnostics records sourceBlobName/sourceDestination so a
  failed copy still points at the original archive
- tenantContext snapshot (fail-soft): upload mode/destination, gather-
  rule debug flag, custom gather/analyze rule IDs + UpdatedAt, IME
  pattern IDs — no secrets, no definitions
- robustness: 8 MB client-side screenshot cap (20 MB body cap headroom),
  attachments status in metadata instead of silently dropping invalid
  base64, SanitizeZipEntryName strips path components from customer-
  supplied file names (both report types)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 14:13:33 +02:00
Oliver Kieselbach 48308df4ff feat: opt-in preservation of session diagnostics archive with session reports
Session reports persist while sessions and their diagnostics blobs are
removed by user deletion or retention. A new checkbox in the Report
Session modal (shown only when a diagnostics ZIP was uploaded, default
checked) lets the submitter include the archive: the backend streams a
server-side copy into the durable session-reports container.

- SessionReportDiagnosticsArchiveCopier: fail-soft copy (never blocks the
  report), reuses the Hosted/CustomerSas read paths, re-verifies the
  hosted tenant prefix via ClassifyBlobName, honours the admin size cap
  and download timeout; outcome persisted as DiagnosticsCopyStatus
- Report row/API gain DiagnosticsBlobName + DiagnosticsCopyStatus
  (written only when requested, omitted for legacy rows)
- report-metadata.json records includedDiagnostics {requested, blobName, status}
- Admin reports UI: second Download Diagnostics button + failure status line
- Privacy page: explicit exception for the opt-in report copy (Last updated bump)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 13:53:12 +02:00
Oliver Kieselbach cefa57fde5 feat(web): track negotiated SignalR transport in telemetry
Long-poll 404s in dependency telemetry come from clients whose corporate
network blocks WebSockets. Emit the negotiated transport on connect and
reconnect so fallback sessions are attributable instead of mysterious.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 11:25:05 +02:00
Oliver Kieselbach 946d503be0 feat(mcp): emit RFC 9207 iss parameter in OAuth authorization response
MCP spec 2026-07-28 requires clients to validate the iss parameter
against the recorded issuer before redeeming the authorization code
(AS mix-up defense). The /oauth/callback redirect now carries
iss=<public base URL> — the same value the RFC 8414 metadata reports
as issuer — and the metadata advertises
authorization_response_iss_parameter_supported. Pre-9207 clients
ignore unknown response parameters (RFC 6749 s4.1.2), so nothing
regresses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 10:51:41 +02:00
Oliver Kieselbach f744ad8406 fix code review findings 2026-07-29 10:30:47 +02:00
Oliver Kieselbach 5e9876bc5f fix: hard page reloads on family routes — RSC .txt payloads were wildcard-rewritten
Client navigation to an exported route fetches its RSC payload
(<route>/index.txt). The legacy-path wildcards (/admin/backups/* etc.)
matched those .txt paths and served the detail HTML instead — the Next
router cannot parse HTML as a flight payload and falls back to a hard
full-page navigation, resetting all client state (visible as the sidebar
collapsing on every Backups/Customs-Archive navigation).

Exact self-rewrites for all nine exported index.txt files now precede the
wildcards; the swaConfig guard pins txt-before-wildcard for every family.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 02:48:08 +02:00
Oliver Kieselbach ffe88a47c6 fix: /sla dead-ended on www — it is a portal page, not a public one
/sla sat in PUBLIC_PATH_PREFIXES only because SLA notifications
historically linked www…/sla. It is a fully authenticated page
(ProtectedRoute + token fetches), and since ProtectedRoute stands down on
the public host, www/sla waited forever on "Loading SLA metrics...".

Removed from the public prefixes: the nav stays on portal (no bounce),
old www…/sla notification links bounce to portal/sla where auth works,
and SlaBreachEvaluationService.DashboardUrl now links the portal host
directly for future notifications. /sla added to robots disallow.

Web tsc + 603/603, backend build clean + SLA suite 38/38.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 02:39:56 +02:00
Oliver Kieselbach 1f22b65d57 fix: /apps, /admin/backups, /admin/customs-archive list pages shadowed by detail rewrites
SWA's trailingSlash:auto normalizes /apps to /apps/, which the /apps/*
legacy wildcard matches — so the LIST pages were rewritten to the DETAIL
html (prod: /apps rendered the app-detail view with an empty name and hung
on "Aggregating app analytics"). Exact list-route rewrites now precede
each family wildcard; the swaConfig guard pins list-before-wildcard order
for all families.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 02:28:50 +02:00
Oliver Kieselbach 03afbf6ac6 fix: double sign-in on www→portal handover — silent SSO for app-triggered logins
Two causes of the reported "sign in on www, then sign in again on portal":
- Race: on www/<portal-path> ProtectedRoute fired loginRedirect BEFORE
  HostRoutingGuard's replace, signing the user in on the wrong origin.
  ProtectedRoute now stands down on the public host and lets the guard move
  the page.
- prompt "select_account" forced the account picker on EVERY loginRedirect,
  including the automatic portal-side re-auth where Entra's session cookie
  would complete silently. login({auto:true}) (ProtectedRoute) drops the
  prompt; user-clicked logins (LoginButton/PublicSiteNavbar, manual retry)
  keep the picker.

Net UX: sign in once — the portal-side re-auth is a silent Entra roundtrip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 02:26:23 +02:00
Oliver Kieselbach 36172b8cc5 fix: www↔portal redirect loop — MSAL auth is per-origin, guards must not fight it
Production incident after the static-export deploy: authenticated-on-www
users hit a hard ping-pong. Chain: AuthGate (www) pushed /dashboard →
HostRoutingGuard bounced to portal (no MSAL session there — auth state is
per-origin) → ProtectedRoute's login-failure fallback navigated to "/" →
guard bounced back to www → AuthGate pushed again.

Three-part fix:
- HostRoutingGuard: never bounce while the URL carries an MSAL auth
  response (#code=/#state= land on the ORIGIN ROOT because redirectUri has
  no path — a bounce destroys the sign-in), plus a sessionStorage bounce
  counter that stands down after 3 hops/15s as a generic loop breaker.
- ProtectedRoute: login failure no longer navigates to "/" (the loop's
  return edge) — fails in place with a manual "Sign in" retry UI.
- AuthGate: on the public host, hand over to the portal origin in ONE
  window.location.href navigation (getPortalLoginUrl pattern) instead of
  router.push + guard cascade.

tsc clean, vitest 603/603, export build 87 pages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 02:16:32 +02:00
Oliver Kieselbach 8849a68b1c fix: og/twitter/apple-icon as static PNGs — extension-less exports served as octet-stream
The ImageResponse metadata routes export extension-less files and SWA
route-level Content-Type headers do not override the file server's type
(verified in prod: application/octet-stream + nosniff, which breaks social
preview crawlers). Replaced with the one-time-rendered PNGs as static
app/ metadata files — Next now emits /opengraph-image.png URLs and SWA
serves image/png by extension. Content-Type route entries dropped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 02:06:01 +02:00
Oliver Kieselbach cc195990ff fix: SWA deploy — drop invalid is_static_export input, dedupe trailing-slash routes
The pinned Azure/static-web-apps-deploy@v1 has no is_static_export input
(warning only — Oryx detected the export and validated out/ regardless).
The real failure was config validation: with "trailingSlash": "auto" SWA
normalizes /x and /x/ to the same route key, so the defensive
trailing-slash duplicates were rejected as duplicate rules. One entry per
route covers both forms under auto.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 01:52:28 +02:00
Oliver Kieselbach 18b28260a7 feat: portal static export — eliminate the SWA SSR runtime (cold-start root fix)
output:'export' + trailingSlash: the SWA now serves plain static files; the
managed SSR runtime — the portal's last cold-start source ("Backend call
failure" when a cold start exceeded SWA's 45s limit) — no longer exists.

- Detail routes are query-string based (/sessions?id=, /sessions/inspector?id=,
  /diagnosis?id=, /apps/detail?name=, /admin/backups/detail?id=,
  /admin/customs-archive/detail?tenantId=&rowKey=). Single-definition builders
  in lib/routes.ts; all 21 link sites migrated, duplicate sessionHref helpers
  consolidated. Suspense boundaries added everywhere useSearchParams drives
  page identity (hard export-build errors otherwise).
- 9 [section] families prerender via server shells (generateStaticParams from
  the *NavSections registries, dynamicParams=false, client bodies extracted to
  SectionClient) — 37 pages; server redirect() index pages became
  ClientRedirect shells. robots/sitemap/og-image routes force-static.
- Legacy PATH-shaped deep links are preserved FOREVER (sent Teams/Slack/
  webhook notifications cannot be edited): SWA wildcard rewrites serve the
  family page and the globally mounted LegacyPathRedirect rewrites to the
  canonical query URL, preserving query + #event hashes (mapping pinned by
  lib/__tests__/legacyPathRedirect.test.ts).
- middleware.ts deleted → client-side HostRoutingGuard (prefix list moved to
  lib/hostRouting.ts). Legacy /go/[code] route + bootstrapValidation deleted —
  BootstrapSessions table verified free of active pre-cutover sessions.
- staticwebapp.config.json now carries production routing: 19 docs/marketing
  redirects (order-sensitive), legacy rewrites (exact before wildcard), 404
  override, all security headers incl. literalized prod CSP (no unsafe-eval),
  Content-Type routes for the extension-less og images. Guarded by the new
  utils/__tests__/swaConfig.guard.test.ts. Maintenance mode is now a
  first-routes-entry insert, never a whole-file swap (README rewritten).
- Backend notification deep links go through Constants.PortalSessionUrl
  (query shape, escaped, pinned); 3 producers migrated.
- deploy-web.yml uploads out/ with is_static_export.

Export build: 79 static pages, no go/ output. Web: tsc clean, vitest 53
files green (618 incl. new guards; 2 deleted legacy suites). Backend:
solution build clean, 3622/3622 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 01:44:32 +02:00
Oliver Kieselbach 6cbd507b4f fix: client-IP extraction was dead on Flex — RemoteIpAddress fallback + AFD-aware rate-limit key
Post-deploy verification of the go endpoint showed every anonymous caller
keyed as "unknown": Flex Consumption does not surface X-Forwarded-For to
the isolated worker, so ALL per-IP rate-limit buckets (bootstrap-validate,
distress, diag-ticket-download, stats/platform) had silently collapsed
into one shared bucket — confirmed by DistressReports.SourceIp being
"unknown" across the board.

- GetTrustedClientIp falls back to HttpContext.Connection.RemoteIpAddress
  (transport-derived, unspoofable; IPv4-mapped addresses normalized).
  Heals every existing caller without signature changes.
- New GetRateLimitClientIp for AFD-fronted endpoints: honors
  X-Azure-ClientIP only when X-Azure-FDID carries exactly our profile ID
  (FrontDoorProfileId app setting, set on autopilotmonitor-api-eu);
  fail-closed to GetTrustedClientIp on unset/mismatch/multi-value.
  Used by GetBootstrapScriptFunction (traffic arrives via
  go.autopilotmonitor.com, where the trusted hop is AFD egress).
- FdidMatches table tests incl. append/spoof cases.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 00:01:31 +02:00
Oliver Kieselbach 26bcab9054 feat: move OOBE bootstrap script endpoint into the backend (go.autopilotmonitor.com)
The portal's Next.js /go/[code] route was the web app's only request-time
server code and blocked the planned static export. The script generation
now lives in the Function App as GET /api/bootstrap/go/{code}
(GetBootstrapScriptFunction), reached by customers as
irm https://go.autopilotmonitor.com/{code} | iex via a Front Door custom
domain whose /* route rewrites onto the API path.

- New Constants.BootstrapGoBaseUrl; CreateBootstrapSessionFunction now
  hands out the go URL (previously unguarded — CreateBootstrapSessionUrlShapeTests
  pins it now). go host added to both URL-guard enforced lists.
- BootstrapScriptValueValidator ports the bootstrapValidation.ts injection
  invariants (exact GUIDs, https + host allowlist + /agent/*.zip path,
  expiry <= 14d) as defense-in-depth for in-process values.
- OobeBootstrapScriptGenerator ports the PS template as a Replace-token
  raw string; golden test pins byte parity with the TS template
  (TestData/oobe-bootstrap-golden.ps1, captured from route.ts).
- Always-HTTP-200 text/plain contract incl. rate limit (own bucket
  bootstrap-script:{ip} 20/min); disabled tenant indistinguishable from
  unknown code. Catalog: PublicAnonymous; NoStoreCacheMiddleware covers
  /api/bootstrap/go/ (script inlines the bearer token).
- Web: BOOTSTRAP_GO_URL registry entry, sessions list shows the go host,
  legacy route marked LEGACY (alive for pre-migration URLs <= 168h,
  removal in the static-export PR).
- Docs: new OKF backend/oobe-bootstrap-go-flow.md, url-registry go
  section, index/log entries.

Backend tests 3611/3611, web vitest 651/651, tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 21:42:56 +02:00
Oliver Kieselbach 10308664dd small UI fix in session list entry for started 2026-07-28 17:47:33 +02:00
350 changed files with 8578 additions and 5472 deletions
+2 -2
View File
@@ -194,8 +194,8 @@ jobs:
# Dev variant = literal-string substitution of the two URL/manifest defaults.
# Both are uploaded only on -PublishAsStable runs (versioned namespace doesn't carry bootstrap).
if ('${{ inputs.publish_as_stable }}' -eq 'true') {
$stableUrl = '$AgentDownloadUrl = "https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent.zip"'
$devUrl = '$AgentDownloadUrl = "https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent-dev.zip"'
$stableUrl = '$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip"'
$devUrl = '$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent-dev.zip"'
$stableMan = '$VersionJsonName = "version.json"'
$devMan = '$VersionJsonName = "version-dev.json"'
+12
View File
@@ -69,9 +69,21 @@ jobs:
- run: npm ci
# next-env.d.ts + .next type stubs are gitignored (Next-managed since
# v16); typegen generates them so tsc sees the same types as a build.
- name: Generate Next.js types
run: npx next typegen
- name: Typecheck
run: npx tsc --noEmit
# Errors fail outright. Warnings are ratcheted: the cap is the current
# count of accepted warnings (React-Compiler hooks-rule findings demoted
# in eslint.config.mjs + exhaustive-deps) — when you fix warnings, lower
# the cap to match; never raise it.
- name: Lint
run: npm run lint -- --max-warnings 141
- name: Tests
run: npm test
+6 -1
View File
@@ -52,7 +52,12 @@ jobs:
# For more information regarding Static Web App workflow configurations, please visit: https://aka.ms/swaworkflowconfig
app_location: "src/Web/autopilot-monitor-web" # App source code path
api_location: "" # Api source code path - optional
output_location: "" # Built app content directory - optional
# Static export (next.config output:'export'): Oryx runs `npm run build`
# and uploads the exported out/ directory as plain static files — no
# managed SSR runtime is provisioned anymore. (No is_static_export
# input on this action version; Oryx detects the export from
# next.config + output_location.)
output_location: "out" # Built app content directory - optional
###### End of Repository/Build Configurations ######
close_pull_request_job:
+6
View File
@@ -53,6 +53,9 @@ __azurite_db*__.json
node_modules/
.next/
out/
# Managed by Next.js since v16 (regenerated by next dev/build/typegen; path
# inside flips between .next/types and .next/dev/types) — must not be tracked.
next-env.d.ts
build/
dist/
!rules/dist/
@@ -148,3 +151,6 @@ plans/REFACTOR_AGENT_V2.md
scripts/Backup/setup-backup-container.ps1
docs/Introduction-AutopilotMonitor.pptx
docs/~$Introduction-AutopilotMonitor.pptx
# Playwright MCP browser artifacts (snapshots, screenshots, console logs)
.playwright-mcp/
+4
View File
@@ -3,6 +3,10 @@
"autopilot-monitor": {
"type": "http",
"url": "https://mcp.autopilotmonitor.com/mcp"
},
"playwright": {
"command": "cmd",
"args": ["/c", "npx", "@playwright/mcp@latest"]
}
}
}
+24 -6
View File
@@ -58,14 +58,18 @@ when disabled — zero cost on the normal path). Line format:
2026-07-28T09:15:02.123Z | RULE-ID | stage | message
```
Stages: `config` (registration summary per rule, header, zero-rules note), `trigger`
(timer scheduled, phase/event fired, dedup), `scope` (skips with reason incl. current
phase), `exec` (execution start), `collector` (null/empty results with likely cause),
`guard` (allowlist block), `emit` (event emitted, hash change), `suppress` (on_change
Stages: `config` (registration summary per rule, header, zero-rules note), `phase`
(enrollment-phase transition marker `phase change: Old -> New`, ruleId `-` — every
rule-level line below it reads against the new phase), `trigger` (timer scheduled,
phase/event fired, dedup), `scope` (skips with reason incl. current phase), `exec`
(execution start), `collector` (null/empty results with likely cause), `guard`
(allowlist block), `emit` (event emitted, hash change), `suppress` (on_change
suppression with hash prefix + streak), `logparser` (per-file outcome: lines read,
position range, match count, parse failures, regex timeouts, mode; plus the
`format=text` hint when every line fails CMTrace parsing), `error` (full exception
**with stack trace** — the agent log keeps only the message).
`format=text` hint when every line fails CMTrace parsing; plus one line **per regex
match** — line number, matched text, capture groups — capped at 10 per file per run),
`error` (full exception **with stack trace** — the agent log keeps only the message;
an invalid logparser regex is reported here including the offending pattern string).
Volume control: 10 MB cap with a single `.old` rotation generation (~20 MB worst case).
Every write failure is swallowed — tracing must never break gathering. The trace never
@@ -92,6 +96,7 @@ A logparser rule with `emit_mode=on_change` that "delivers nothing" becomes:
```
... | LP-01 | config | registered: trigger=interval, collector=logparser, target=C:\...\app.log, unscoped, emitMode=on_change, interval=300s
... | LP-01 | trigger | interval timer scheduled every 300s (first run after one full interval — no immediate execution)
... | - | phase | phase change: DeviceSetup -> AccountSetup
... | LP-01 | exec | executing: collector=logparser, target=C:\...\app.log
... | LP-01 | logparser | app.log: read 1000 lines from position 0->81234, matched 0, parseFailures=1000, regexTimeouts=0, mode=cmtrace
... | LP-01 | logparser | every line failed CMTrace parsing — if this is a plain-text log, set parameter format=text
@@ -99,6 +104,19 @@ A logparser rule with `emit_mode=on_change` that "delivers nothing" becomes:
— the answer ("plain-text log parsed in CMTrace mode") is in the file.
A matching rule shows *how* it matched (capped at 10 per-match lines per file per run):
```
... | LP-02 | logparser | setupact.log: line 42 matched "Error 0x80070005" — groups: code="0x80070005"
... | LP-02 | logparser | setupact.log: read 500 lines from position 0->40123, matched 1, parseFailures=0, regexTimeouts=0, mode=text
```
and a broken regex names itself:
```
... | LP-03 | error | invalid regex pattern '([unclosed' — rule can never match: Invalid pattern '([unclosed' at offset 10. Not enough )'s.
```
## Citations
* Executor + trace points: `../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleExecutor.cs`
+107
View File
@@ -0,0 +1,107 @@
---
type: Concept
title: OOBE Bootstrap go-Code Flow — Always-200 Script Endpoint
description: The full lifecycle of the bootstrap short-code — created in the portal, typed as `irm go.autopilotmonitor.com/CODE | iex` at Shift+F10, served by GetBootstrapScriptFunction as an always-HTTP-200 PowerShell script — including the two-line injection defense and the rate-limit story behind Front Door.
resource: src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/GetBootstrapScriptFunction.cs
tags:
- backend
- bootstrap
- security
- oobe
timestamp: 2026-07-28T00:00:00+02:00
---
# OOBE Bootstrap go-Code Flow
Bootstrap Sessions let a technician install the agent **before** Intune
enrollment: an admin creates a short-lived session in the portal (Settings →
Bootstrap Sessions), hands the technician a short URL, and the technician types
`irm https://go.autopilotmonitor.com/CODE | iex` into a Shift+F10 console
during OOBE. The URL is deliberately short because it is typed by hand on a
device that has no clipboard.
# Lifecycle
1. **Create** — `POST /api/bootstrap/sessions` (`BootstrapManagerOrGA`).
`BootstrapSessionService.CreateAsync` mints an unambiguous-charset short
code + a GUID token, validity clamped 1–168 h. The response's `bootstrapUrl`
is built from `Constants.BootstrapGoBaseUrl` — the single producer of the
absolute URL (pinned by `CreateBootstrapSessionUrlShapeTests`).
2. **Serve** — `GET /api/bootstrap/go/{code}` (`GetBootstrapScriptFunction`,
`PublicAnonymous`). Customers reach it through the Front Door custom domain
`go.autopilotmonitor.com`, whose `/*` route rewrites onto the API path.
Validation runs in-process (`ValidateCodeAsync` + the tenant's
`BootstrapTokenEnabled` gate) — no HTTP hop to the validate endpoint.
3. **Install** — the generated script downloads the agent ZIP from
`Constants.AgentDownloadBaseUrl`, extracts, and runs
`--install --no-auth --bootstrap-token … --tenant-id …`. The agent monitors
from OOBE start, switches to certificate auth after Intune enrollment, and
self-destructs on completion.
The legacy Next.js `/go/[code]` route (SSR on the SWA) stays alive only for
URLs issued before the migration and is removed with the static-export PR —
see the transition rule in [URL Registry](../url-registry.md).
# Always-200 contract
Every response — success, invalid format, unknown/expired code, disabled
feature, rate limit, internal error — is **HTTP 200 `text/plain`**. The
consumer is `irm | iex`: a non-2xx status would make `irm` throw an opaque
exception on the OOBE console, while a 200 error script surfaces the actual
message via `Write-Host 'ERROR: …'`. Consequences:
- Failures are invisible in status-code metrics; the compensating signal is the
`LogWarning` per rejection (code + client IP).
- Error messages are capped at 200 chars **before** quote-escaping and
single-quote-doubled — a hostile upstream message cannot break out of the
`Write-Host` literal.
A disabled tenant returns the **same generic message** as an unknown code — no
enumeration oracle for whether a code exists.
# Injection defense (two lines)
The script runs as SYSTEM during OOBE, so interpolation is treated as a
security boundary:
1. **`BootstrapScriptValueValidator`** — tenantId/token must be exact canonical
GUIDs, the download URL must be https on an allow-listed host
(`AgentDownloadBaseUrl`/`AgentBlobBaseUrl` hosts) with the strict
`/agent/….zip` path shape, expiry must be future and ≤ 14 days. No accepted
value can contain `$`, quotes, backticks, spaces, or newlines. Kept even
though values are produced in-process — defense-in-depth against tampered
table data.
2. **Single-quoted PS literals** — every interpolated value sits inside
single-quoted PowerShell strings, which do not expand `$()`, `$var`, or
backticks.
The template is a C# raw string with `__TOKEN__` placeholders substituted via
`Replace` (never `$`-interpolation — the script is full of PS `$vars`).
`OobeBootstrapScriptGeneratorTests` pins byte parity with the original
TypeScript template through a golden file, so the legacy route and the backend
serve identical scripts during the transition.
# Rate limiting behind Front Door
The endpoint self-limits per client IP (`bootstrap-script:{ip}`, 20/min via
`ClientIpExtractor.GetRateLimitClientIp`) — anonymous routes are skipped by
`UserRateLimitMiddleware` and must bring their own limiter. Because traffic
arrives through Front Door, the rightmost X-Forwarded-For hop resolves to AFD
egress IPs and buckets would collapse (the shared-bucket 429 incident class);
the AFD-aware extraction therefore honors `X-Azure-ClientIP` only when
`X-Azure-FDID` matches the `FrontDoorProfileId` app setting (fail-closed to
the trusted-hop path otherwise). Verified post-deploy 2026-07-29: real client
IPs are keyed, not AFD egress.
Front Door route caching is DISABLED for this route: the body inlines the
bearer token, so a cache hit would replay one requester's token to another.
`NoStoreCacheMiddleware` covers the `/api/bootstrap/go/` prefix.
# Citations
- `src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/GetBootstrapScriptFunction.cs`
- `src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/OobeBootstrapScriptGenerator.cs`
- `src/Backend/AutopilotMonitor.Functions/Security/BootstrapScriptValueValidator.cs`
- `src/Backend/AutopilotMonitor.Functions.Tests/OobeBootstrapScriptGeneratorTests.cs` — golden parity
- `src/Web/autopilot-monitor-web/app/go/[code]/route.ts` — legacy route (transition only)
- [URL Registry](../url-registry.md) — go-URL registry entries + transition rule
+1
View File
@@ -33,6 +33,7 @@ https://docs.autopilotmonitor.com (separate repository).
# Backend
* [Business Timestamps](backend/business-timestamps.md) - Why the Azure Tables system Timestamp is never authoritative (migrations reset it), and the OccurredUtc + RowKey-decode compensation for AuditLogs/OpsEvents/Events after the 2026-07-18 migration.
* [OOBE Bootstrap go-Code Flow](backend/oobe-bootstrap-go-flow.md) - The bootstrap short-code lifecycle (portal-created session → `irm go.autopilotmonitor.com/CODE | iex` at Shift+F10 → GetBootstrapScriptFunction), the always-HTTP-200 text/plain contract, the two-line injection defense (GUID/URL/expiry validator + single-quoted PS literals, golden-pinned against the legacy TS template), and the per-IP rate-limit story behind Front Door.
* [Device Journeys & First-Time-Right](backend/device-journeys.md) - How a device's terminal enrollment attempts are grouped into journeys (trim+casefold device key with junk-serial exclusion, first-success end, 30-day gap, WhiteGlove = one attempt), the DeviceHistories chain with its tombstone-driven deleted-session pruning, the additive daily FTR aggregates, and the banner/fleet/MCP surfaces.
* [Enrollment Time Attribution](backend/time-attribution.md) - How a terminal session's authoritative wall clock (DurationSeconds, WhiteGlove pause excluded) is partitioned into named segments with an explicit unattributed remainder, ESP-blocking apps as positive evidence with "up to" what-if bounds, reboot spans from event gaps, and the breakdown/aggregate tables the sweep maintains.
* [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.
+7 -1
View File
@@ -1,6 +1,12 @@
# Log
## 2026-07-28
## 2026-07-29
* **Update**: `agent/gather-rule-debug-log.md` — three trace enrichments: (1) new `phase` stage writing an enrollment-phase transition marker (`phase change: Old -> New`, ruleId `-`) so every rule-level line reads against the current phase; (2) logparser now writes one line per regex match — line number, matched text, capture groups — capped at 10 per file per run (the per-file summary keeps the full count); (3) the invalid-regex `error` line includes the offending pattern string.
* **Update**: `url-registry.md` (+ `web/portal-navigation-prefetch.md` context) — the portal is now a **full static export** (`output: 'export'`, `trailingSlash: true`): the SWA serves plain files, the managed SSR runtime — the portal's last cold-start source ("Backend call failure" > 45 s) — no longer exists. Detail routes moved from path params to query strings (`/sessions?id=`, `/sessions/inspector?id=`, `/diagnosis?id=`, `/apps/detail?name=`, `/admin/backups/detail?id=`, `/admin/customs-archive/detail?tenantId=&rowKey=`) with all 21 link sites going through the new single-definition builders in `lib/routes.ts`; the 9 `[section]` families prerender via `generateStaticParams` server shells (37 pages, `dynamicParams=false`), server `redirect()` index pages became `ClientRedirect` shells. Legacy PATH-shaped deep links (sent Teams/Slack/webhook notifications hold `/sessions/{id}` forever) are preserved permanently: SWA wildcard rewrites serve the family page and the globally mounted `LegacyPathRedirect` rewrites to the canonical query URL (hash + query preserved; mapping pinned by `lib/__tests__/legacyPathRedirect.test.ts`). `middleware.ts` host routing became the client-side `HostRoutingGuard` (prefix list moved to `lib/hostRouting.ts`); the legacy `/go/[code]` route + `bootstrapValidation.ts` are deleted (BootstrapSessions table verified free of pre-cutover active sessions). `staticwebapp.config.json` now carries the 19 docs/marketing redirects (order-sensitive), the legacy rewrites, `404.html` override and ALL security headers incl. the literalized prod CSP — guarded by the new `swaConfig.guard.test.ts`; maintenance mode changed from whole-file swap to a first-routes-entry insert (README rewritten). Backend notification deep links go through the new `Constants.PortalSessionUrl` (query shape, pinned). Deploy: `deploy-web.yml` uploads `out/` with `is_static_export: true`.
* **Creation**: Added `backend/oobe-bootstrap-go-flow.md` (and a `Bootstrap go-URL` section + web-registry update in `url-registry.md`) — the OOBE bootstrap script endpoint moved from the portal's Next.js `/go/[code]` SSR route into the Function App (`GET /api/bootstrap/go/{code}`, `GetBootstrapScriptFunction`), unblocking the portal's static export; the customer URL became **`go.autopilotmonitor.com/{code}`** (Front Door custom domain, `/*` route with origin-path rewrite, caching disabled — the script inlines the bearer token). New `Constants.BootstrapGoBaseUrl` (single producer via `CreateBootstrapSessionFunction`) + web `BOOTSTRAP_GO_URL` (display only); both guard host lists extended. Security invariants ported to C#: `BootstrapScriptValueValidator` (exact-GUID tenantId/token, https + host-allowlist + `/agent/….zip` path, expiry ≤ 14 d) and `OobeBootstrapScriptGenerator` (Replace-token raw-string template, single-quoted PS literals, 200-char error cap before quote-doubling; always-HTTP-200 text/plain contract for `irm | iex`). Endpoint is `PublicAnonymous` in the catalog, `NoStoreCacheMiddleware`-covered, self-rate-limited `bootstrap-script:{ip}` 20/min (rate-limit reply is also a 200 error script). Tests: golden byte-parity against the TS template, validator invariants incl. hostile-URL cases, format-gate table, and the previously missing BootstrapUrl shape pin. Transition: the legacy www route + `middleware.ts` `/go` prefix stay alive for pre-migration URLs (validity ≤ 168 h) and are removed in the static-export PR no earlier than 168 h after the last www URL was issued. Customer docs `network-endpoints.md` gained the `go.` host (device-facing, Bootstrap-Sessions-only).
* **Update**: `agent/autopilot-ztd-diagnostics.md` — presentation layer for the startup error backfill (customer feedback: an unannounced wall of pre-agent Error events with a single identical timestamp read like a live failure). Agent: the backfill scan now buffers the capped record set, emits an Info summary event first (`modern_deployment_log`, `backfillSummary: true` + `replayedCount`/`droppedOverCap`/`lookbackMinutes`), then replays oldest-first so the timeline sequence follows event-log chronology. Web: `EventTimeline.tsx` badges `backfilled: true` events („⟲ Backfilled", tooltip) and renders the original `timeCreated` as the row time with a "(reported …)" suffix plus a `Recorded` metadata line — works retroactively for already-stored events. Severity is deliberately not downgraded.
+37 -1
View File
@@ -21,7 +21,7 @@ literal appears nowhere else.
| Component | Registry | Guard test |
|---|---|---|
| C# (Shared/Backend/Agent) | `AutopilotMonitor.Shared.Constants` (`*BaseUrl`) | `HardcodedUrlGuardTests` |
| Web | `utils/config.ts` (`DOCS_URL`, `SITE_URL`, `PORTAL_URL`, `ENTRA_LOGIN_URL`, `API_URL_PROD`, `BLOB_URL_PROD`, `AGENT_DOWNLOAD_HOSTNAMES`) | `utils/__tests__/hardcodedUrls.guard.test.ts` |
| Web | `utils/config.ts` (`DOCS_URL`, `SITE_URL`, `PORTAL_URL`, `BOOTSTRAP_GO_URL`, `ENTRA_LOGIN_URL`, `API_URL_PROD`, `BLOB_URL_PROD`, `AGENT_DOWNLOAD_HOSTNAMES`) | `utils/__tests__/hardcodedUrls.guard.test.ts` |
| MCP | `src/config.ts` (`API_BASE_URL`, `ENTRA_LOGIN_BASE_URL`, `DOCS_BASE_URL`) | `src/__tests__/hardcoded-urls.guard.test.ts` |
Derived registries build on these instead of repeating hosts:
@@ -76,6 +76,42 @@ Deliberately still on the legacy account: the `HealthCheckService` /
`build-agent.yml` — they exist precisely to keep already-deployed customer
bootstrap scripts working until the customer migration completes.
# Bootstrap go-URL (2026-07-28)
The OOBE bootstrap short-URL moved from `www.autopilotmonitor.com/go/{code}`
(Next.js route, SSR) to **`go.autopilotmonitor.com/{code}`** — a Front Door
custom domain whose `/*` route rewrites onto `/api/bootstrap/go/{code}` on the
Function App (`GetBootstrapScriptFunction`). Registry entries:
`Constants.BootstrapGoBaseUrl` (C#, the single producer via
`CreateBootstrapSessionFunction`) and `BOOTSTRAP_GO_URL` (web, display-only in
the sessions list); `go.autopilotmonitor.com` is on both guards' enforced-host
lists. `CreateBootstrapSessionUrlShapeTests` pins the produced URL as an
independent oracle — before this, no test guarded the bootstrap URL shape at
all.
Front Door route caching for `bootstrap-go` is DISABLED — same hazard class as
the `/agent/*` route above, sharpened: the script body inlines the bearer
token, so a cache hit would replay one requester's token to another.
`NoStoreCacheMiddleware` additionally stamps `no-store` via the
`/api/bootstrap/go/` prefix.
Transition COMPLETED with the static-export PR (2026-07-29): the legacy
Next.js `/go/[code]` route, `utils/bootstrapValidation.ts`, and
`middleware.ts` are deleted — verified safe because the BootstrapSessions
table held zero active sessions predating the URL cutover (all expired
2026-03). The backend is now the only script producer;
`OobeBootstrapScriptGeneratorTests`' golden file remains as the pin of the
shipped template.
Portal detail routes are query-string based since the same PR
(`/sessions?id=…` etc., builders in `lib/routes.ts`, notification producer
`Constants.PortalSessionUrl`). Legacy PATH-shaped links in sent
Teams/Slack/webhook notifications are rewritten forever by SWA wildcard
rewrites + `components/LegacyPathRedirect.tsx`; `staticwebapp.config.json`
carries the production redirects/headers and is guarded by
`utils/__tests__/swaConfig.guard.test.ts` (registry-backed hosts, no
unsafe-eval, route order).
# Citations
- `src/Shared/AutopilotMonitor.Shared/Constants.cs` — C# registry
+6
View File
@@ -94,6 +94,12 @@ Do not benchmark this with a synthetic burst. Firing requests in quick successio
and measures the best case: in a 25-round `curl --parallel-max 6` run, round 1 came in at p50 288 ms
and rounds 4–25 at p50 113 ms.
> **Endgame (2026-07-29).** The cold-start class was eliminated at the root: the portal became a
> full static export (`output: 'export'`) — the SWA-managed SSR runtime no longer exists, so there
> is nothing left to cool down. One escalation drove this: on 2026-07-28 a cold start exceeded the
> SWA's 45-second function limit and the landing page served a hard "Backend call failure" 500.
> The availability test that bridged the gap as a keep-alive stays as plain uptime monitoring.
# Diagnosing this class of problem
The backend Application Insights resource is the wrong place to look — a freeze appears there as an
@@ -1,369 +0,0 @@
<#
.SYNOPSIS
Bootstrap script to deploy and start the Autopilot Monitor agent. (DEV)
.DESCRIPTION
Designed to be deployed via Intune as a PowerShell Script during Autopilot.
Runs very early in the enrollment process (first Intune action) and:
1. Runs pre-flight guards to skip productive devices and ghost re-installs.
An OOBE-relax exemption allows install when a single freshly-created profile
appears DURING OOBE (the "Windows Backup for Organizations" restore case).
2. Downloads the monitoring agent ZIP from Azure Blob Storage
3. Verifies integrity via SHA-256 hash from the version manifest (mandatory)
4. Extracts the agent into %ProgramData%\AutopilotMonitor\Agent
5. Runs the agent in --install mode (registers Scheduled Task, spawns the runtime)
6. Verifies the runtime process actually launched
Agent self-destructs when enrollment completes.
OOBE-relax flow (guards 2+3): a real user profile (or a real LastLoggedOnUser)
normally means "productive -> skip". Windows Backup for Organizations breaks that
by restoring settings and creating ONE profile while still in OOBE. We relax guards
2+3 ONLY when all three positives hold, and nothing else:
(a) the OS positively reports OOBE InProgress (Test-OobeInProgress), AND
(b) there is exactly ONE real user profile, AND
(c) that profile was created within $OobeProfileMaxAgeMinutes minutes.
Any miss keeps the original skip (SKIP-safe: we never install on uncertainty).
.PARAMETER AgentDownloadUrl
URL to download the agent ZIP from. Defaults to the production blob; override only
for parallel lab/dev assignments that point at a separate pre-release ZIP.
.PARAMETER VersionJsonName
Filename of the integrity manifest in the same blob container as the agent ZIP.
Defaults to "version.json"; override only for parallel lab/dev manifests.
.PARAMETER MaxBootstrapWindowHours
Maximum device uptime (hours) within which the bootstrap is still considered
valid. Devices booted more than this many hours ago are skipped because we no
longer trust their OOBE state. Default: 12.
.PARAMETER OobeProfileMaxAgeMinutes
Maximum age (minutes) of the single profile that may appear during OOBE for the
OOBE-relax exemption to apply. The restore->bootstrap gap is always minutes; an
older profile is treated as a productive device. Default: 15.
.NOTES
- Agent is temporary and auto-removes after enrollment.
- All files live under C:\ProgramData\AutopilotMonitor (easy cleanup).
- One registry key remains after removal: HKLM\SOFTWARE\AutopilotMonitor\Deployed
(prevents ghost re-installs on re-Autopilot of the same device).
- Scheduled Task survives reboots during enrollment.
- OOBE detection uses Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
Requires Windows 10, version 1809 (introduced in 10.0.17763.0). Older builds lack
the class -> Test-OobeInProgress returns $false -> original (skip) behaviour. Docs:
https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
- This script MUST remain pure ASCII (no Unicode/UTF-8 special chars).
PowerShell 5.1 (IME) reads scripts without BOM as ANSI, corrupting multi-byte chars.
.CHANGELOG
2026-07-20 v2.2 Switched default download endpoint from the legacy blob
(autopilotmonitor.blob.core.windows.net) to the Front Door alias
https://download.autopilotmonitor.com/agent/ (route /agent/*).
version.json is still derived from the same container.
2026-06-25 v2.1 OOBE-relax: detect genuine OOBE via WinRT
SystemSetupInfo.OutOfBoxExperienceState and exempt guards 2+3 when a
single freshly-created profile appears in OOBE (Windows Backup for
Organizations). SKIP-safe when the API is absent (< 1809).
Removed legacy Content-MD5 fallback; SHA-256 manifest verification is
now mandatory (no hash -> bootstrap aborts, no unverified install).
2026-05-09 v2.0 Generic bootstrap: agent owns its own defaults (e.g. 600 s
TenantId-wait), so the script only calls `--install` plain.
Hardened post-install with a 10 s runtime-process verify.
2026-04-09 v1.1 Introduced explicit script version, logged on startup.
2026-03-31 Replaced OS age + MDM pre-flight checks with multi-signal guard
(registry deployment marker, WMI/filesystem user profile, last
logged-on user, 12 h bootstrap window).
2026-03-30 Fixed non-ASCII characters that broke parsing under PowerShell 5.1.
2026-03-29 Hardened integrity check: SHA-256 verification via version.json.
2026-02-13 Simplified bootstrapper, introduced --install parameter for agent.
2026-02-12 Robust download with integrity check (Content-MD5), boot time support,
pre-flight check to skip if agent already installed.
2026-02-05 Initial version.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $false)]
[string]$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip",
[Parameter(Mandatory = $false)]
[string]$VersionJsonName = "version.json",
[Parameter(Mandatory = $false)]
[int]$MaxBootstrapWindowHours = 12,
[Parameter(Mandatory = $false)]
[int]$OobeProfileMaxAgeMinutes = 15
)
# Script version (bump on meaningful changes; see .CHANGELOG above)
$ScriptVersion = "2.2"
# Configuration - Everything in ProgramData for easy cleanup
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
$AgentBinPath = "$AgentBasePath\Agent"
$AgentLogPath = "$AgentBasePath\Logs"
$LogFile = "$AgentLogPath\bootstrap_agent.log"
New-Item -Path $AgentBasePath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
New-Item -Path $AgentBinPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
New-Item -Path $AgentLogPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
function Write-Log {
param([string]$Message)
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$logMessage = "[$timestamp] $Message"
Write-Output $logMessage
Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
}
# Positive OOBE check via the documented WinRT API
# Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
# Returns $true ONLY when the OS reports InProgress. Any other state (Completed/NotStarted),
# an older OS without the class (< Win10 1809 / 10.0.17763.0), or any error returns $false
# -> SKIP-safe (we never relax a guard on uncertainty).
# Docs: https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
function Test-OobeInProgress {
try {
$null = [Windows.System.Profile.SystemSetupInfo, Windows.System.Profile, ContentType = WindowsRuntime]
return ([Windows.System.Profile.SystemSetupInfo]::OutOfBoxExperienceState).ToString() -eq 'InProgress'
}
catch {
return $false
}
}
try {
Write-Log "===== Autopilot Monitor Bootstrap Started ====="
Write-Log "Bootstrap script version: v$ScriptVersion"
# Guard 1: Agent was already deployed on this device (registry marker survives self-destruct)
$deployed = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Name 'Deployed' -ErrorAction SilentlyContinue).Deployed
if ($deployed) {
Write-Log "SKIP: Agent was previously deployed at $deployed."
exit 0
}
# Collect real (non-special) user profiles as full paths (WMI Special flag + filesystem).
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users|WDAGUtilityAccount)$'
# NOTE: the outer @() is required. Without it the trailing Where/Select pipeline
# unwraps a single result back to a scalar string, so $profilePaths[0] would index
# into the string and return its first char ('C') instead of the full path.
$profilePaths = @(
@(
try {
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } |
ForEach-Object { $_.LocalPath }
} catch { Write-Log "INFO: WMI profile query failed, continuing with filesystem check." }
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).FullName
) | Where-Object { $_ -and (Split-Path $_ -Leaf) -notmatch $excludePattern } |
Select-Object -Unique
)
# OOBE-relax: the ONLY exemption to guards 2+3. Matches exactly the Windows Backup for
# Organizations restore (one user signs in + restores DURING OOBE). All three must hold:
# OOBE InProgress AND exactly one profile AND that profile created < N minutes ago.
$oobeRelax = $false
$oobeInProgress = Test-OobeInProgress
if ($profilePaths.Count -eq 1 -and $oobeInProgress) {
try {
$created = (Get-Item -LiteralPath $profilePaths[0] -Force -ErrorAction Stop).CreationTimeUtc
$ageMin = ((Get-Date).ToUniversalTime() - $created).TotalMinutes
if ($ageMin -ge 0 -and $ageMin -lt $OobeProfileMaxAgeMinutes) {
$oobeRelax = $true
Write-Log ("OOBE-relax active: OOBE InProgress + single profile created {0:N1}min ago (< {1}min). Guards 2+3 relaxed (Windows Backup restore case)." -f $ageMin, $OobeProfileMaxAgeMinutes)
} else {
Write-Log ("OOBE-relax NOT applied: OOBE InProgress + single profile '{0}', but profile created {1:N1}min ago (created {2:u}) is outside the [0, {3})min window." -f (Split-Path $profilePaths[0] -Leaf), $ageMin, $created, $OobeProfileMaxAgeMinutes)
}
} catch { Write-Log "INFO: profile age check failed; OOBE-relax not applied. $($_.Exception.Message)" }
} elseif ($profilePaths.Count -eq 1 -and -not $oobeInProgress) {
Write-Log "OOBE-relax NOT applied: single profile present but OutOfBoxExperienceState != InProgress (OOBE already finished / not detectable)."
} elseif ($profilePaths.Count -gt 1) {
Write-Log ("OOBE-relax NOT applied: {0} real profiles present (relax requires exactly one)." -f $profilePaths.Count)
}
# Guard 2: No real user profile should exist yet (primary productive-device guard),
# unless the OOBE-relax signature holds.
if ($profilePaths.Count -gt 0 -and -not $oobeRelax) {
$details = (($profilePaths | ForEach-Object {
$leaf = Split-Path $_ -Leaf
try {
$c = (Get-Item -LiteralPath $_ -Force -ErrorAction Stop).CreationTimeUtc
"{0} (created {1:u}, {2:N1}min ago)" -f $leaf, $c, ((Get-Date).ToUniversalTime() - $c).TotalMinutes
} catch { "$leaf (age unknown)" }
}) | Select-Object -First 3) -join '; '
Write-Log "SKIP: Real user profile(s) found ($details). OOBE InProgress=$oobeInProgress. Device appears productive."
exit 0
}
# Guard 3: LastLoggedOnUser - during Device ESP no real user has logged on yet
# (same OOBE-relax exemption: the restoring user signs in during OOBE).
$lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and -not $oobeRelax) {
Write-Log "SKIP: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive."
exit 0
}
# Guard 4: Bootstrap window check - device uptime must be within accepted OOBE window.
# Sleep/standby does not reset uptime, only real boot/restart does.
$lastBoot = (Get-CimInstance Win32_OperatingSystem).LastBootUpTime
$uptimeHours = ((Get-Date) - $lastBoot).TotalHours
Write-Log "Device uptime: $([int]$uptimeHours)h (last boot: $lastBoot)"
if ($uptimeHours -gt $MaxBootstrapWindowHours) {
Write-Log "SKIP: Device uptime is $([int]$uptimeHours)h. OOBE state is older than accepted bootstrap window of ${MaxBootstrapWindowHours}h."
exit 0
}
# Guard 5: Is the agent already installed? (leftover from previous run)
if (Test-Path $AgentBinPath) {
$existingAgent = Get-ChildItem -Path $AgentBinPath -Filter "AutopilotMonitor.Agent.exe" -ErrorAction SilentlyContinue
if ($existingAgent) {
Write-Log "SKIP: Agent already installed at $($existingAgent.FullName)."
exit 0
}
}
Write-Log "Pre-flight checks passed -- no prior deployment, no productive-device signals, within bootstrap window (oobeRelax=$oobeRelax)"
# Download and extract agent binaries
$agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe"
if (Test-Path $agentExePath) {
Write-Log "Agent already installed at $agentExePath"
}
else {
Write-Log "Downloading agent from $AgentDownloadUrl..."
try {
# Derive manifest URL from the agent download URL (same blob container)
$versionJsonUrl = $AgentDownloadUrl -replace '[^/]+$', $VersionJsonName
$expectedSha256 = $null
$manifestAttempts = 3
$manifestDelays = @(1, 2) # short backoff between attempts; do not block enrollment
for ($attempt = 1; $attempt -le $manifestAttempts; $attempt++) {
try {
Write-Log "Fetching $VersionJsonName (attempt ${attempt}/${manifestAttempts}) from $versionJsonUrl for integrity verification..."
$versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop
if ($versionJsonResponse.sha256) {
$expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant()
Write-Log "SHA-256 hash from manifest: $expectedSha256 (version: $($versionJsonResponse.version))"
} else {
Write-Log "WARNING: Manifest has no sha256 field - integrity cannot be verified."
}
break # manifest reached (with or without hash); retrying would not help
}
catch {
Write-Log "WARNING: Could not fetch integrity manifest (attempt $attempt): $($_.Exception.Message)"
if ($attempt -lt $manifestAttempts) {
Start-Sleep -Seconds $manifestDelays[$attempt - 1]
}
}
}
$zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent.zip"
$maxDownloadAttempts = 3
$downloadAttempt = 0
do {
$downloadAttempt++
try {
Write-Log "Download attempt ${downloadAttempt}/${maxDownloadAttempts}"
$downloadStopwatch = [System.Diagnostics.Stopwatch]::StartNew()
Invoke-WebRequest `
-Uri $AgentDownloadUrl `
-OutFile $zipPath `
-UseBasicParsing `
-TimeoutSec 30 `
-ErrorAction Stop
$downloadStopwatch.Stop()
$downloadSeconds = [math]::Round($downloadStopwatch.Elapsed.TotalSeconds, 1)
Write-Log "Downloaded agent to $zipPath (took ${downloadSeconds}s)"
break
}
catch {
if ($downloadAttempt -ge $maxDownloadAttempts) {
throw
}
$retryDelaysInSeconds = @(2, 4, 8)
$retryDelaySeconds = $retryDelaysInSeconds[$downloadAttempt - 1]
Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${retryDelaySeconds}s..."
Start-Sleep -Seconds $retryDelaySeconds
}
} while ($downloadAttempt -lt $maxDownloadAttempts)
# SHA-256 from the version manifest is the only accepted integrity proof.
# No hash -> refuse to install an unverified ZIP (SKIP-safe security posture).
if (-not $expectedSha256) {
throw "No SHA-256 hash available from manifest - refusing to install unverified agent ZIP."
}
$actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
Write-Log "Validating SHA-256 hash against manifest"
if ($actualSha256 -ne $expectedSha256) {
throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted."
}
Write-Log "SHA-256 integrity check passed"
Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath -Force
Write-Log "Extracted agent to $AgentBinPath"
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
Write-Log "Cleaned up temporary files"
if (-not (Test-Path $agentExePath)) {
throw "Agent executable not found after extraction at $agentExePath"
}
Write-Log "Agent installation completed successfully"
}
catch {
Write-Log "ERROR downloading/extracting agent: $($_.Exception.Message)"
throw
}
}
Write-Log "Calling agent install mode (--install)..."
& $agentExePath --install
$installExitCode = $LASTEXITCODE
if ($installExitCode -ne 0) {
throw "Agent install failed with exit code $installExitCode"
}
Write-Log "Agent install mode completed successfully"
$runtimeProcessName = 'AutopilotMonitor.Agent'
$verifyTimeoutSec = 10
$verifyDeadline = (Get-Date).AddSeconds($verifyTimeoutSec)
$runtimeProc = $null
while ((Get-Date) -lt $verifyDeadline) {
$runtimeProc = Get-Process -Name $runtimeProcessName -ErrorAction SilentlyContinue | Select-Object -First 1
if ($null -ne $runtimeProc) { break }
Start-Sleep -Milliseconds 500
}
if ($null -ne $runtimeProc) {
$startedUtc = 'unavailable'
try { $startedUtc = $runtimeProc.StartTime.ToUniversalTime().ToString('o') } catch { }
Write-Log ("Runtime process verified: name={0}.exe pid={1} startedUtc={2}" -f $runtimeProcessName, $runtimeProc.Id, $startedUtc)
} else {
Write-Log ("WARNING: Runtime process verification FAILED. Agent --install reported success but no '{0}.exe' process appeared within {1}s. Likely silent block (AV/EDR, AppLocker/WDAC) of the runtime launch. Agent should still come up at next boot via the BootTrigger task. Check Event Viewer > Microsoft > Windows > TaskScheduler/Operational and AV/EDR logs for '{0}.exe'." -f $runtimeProcessName, $verifyTimeoutSec)
}
Write-Log "===== Bootstrap Completed Successfully ====="
exit 0
}
catch {
Write-Log "===== Bootstrap FAILED ====="
Write-Log "ERROR: $($_.Exception.Message)"
Write-Log "Stack trace: $($_.ScriptStackTrace)"
Write-Log "Please check log file: $LogFile"
$errMsg = "AutopilotMonitor bootstrap failed: $($_.Exception.Message)"
if ($errMsg.Length -gt 2048) { $errMsg = $errMsg.Substring(0, 1045) + '...' }
[Console]::Error.WriteLine($errMsg)
exit 1
}
+138 -81
View File
@@ -5,18 +5,28 @@
.DESCRIPTION
Designed to be deployed via Intune as a PowerShell Script during Autopilot.
Runs very early in the enrollment process (first Intune action) and:
1. Runs five pre-flight guards to skip productive devices and ghost re-installs
1. Runs pre-flight guards to skip productive devices and ghost re-installs.
An OOBE-relax exemption allows install when a single freshly-created profile
appears DURING OOBE (the "Windows Backup for Organizations" restore case).
2. Downloads the monitoring agent ZIP from Azure Blob Storage
3. Verifies integrity via SHA-256 hash from the version manifest
(legacy fallback: Content-MD5 header from the blob response)
3. Verifies integrity via SHA-256 hash from the version manifest (mandatory)
4. Extracts the agent into %ProgramData%\AutopilotMonitor\Agent
5. Runs the agent in --install mode (registers Scheduled Task, spawns the runtime)
6. Verifies the runtime process actually launched
Agent self-destructs when enrollment completes.
OOBE-relax flow (guards 2+3): a real user profile (or a real LastLoggedOnUser)
normally means "productive -> skip". Windows Backup for Organizations breaks that
by restoring settings and creating ONE profile while still in OOBE. We relax guards
2+3 ONLY when all three positives hold, and nothing else:
(a) the OS positively reports OOBE InProgress (Test-OobeInProgress), AND
(b) there is exactly ONE real user profile, AND
(c) that profile was created within $OobeProfileMaxAgeMinutes minutes.
Any miss keeps the original skip (SKIP-safe: we never install on uncertainty).
.PARAMETER AgentDownloadUrl
URL to download the agent ZIP from. Defaults to the production blob; override only
for parallel lab/dev assignments that point at a separate pre-release ZIP.
URL to download the agent ZIP from. Defaults to the production download endpoint;
override only for parallel lab/dev assignments that point at a separate pre-release ZIP.
.PARAMETER VersionJsonName
Filename of the integrity manifest in the same blob container as the agent ZIP.
@@ -27,16 +37,35 @@
valid. Devices booted more than this many hours ago are skipped because we no
longer trust their OOBE state. Default: 12.
.PARAMETER OobeProfileMaxAgeMinutes
Maximum age (minutes) of the single profile that may appear during OOBE for the
OOBE-relax exemption to apply. The restore->bootstrap gap is always minutes; an
older profile is treated as a productive device. Default: 15.
.NOTES
- Agent is temporary and auto-removes after enrollment.
- All files live under C:\ProgramData\AutopilotMonitor (easy cleanup).
- One registry key remains after removal: HKLM\SOFTWARE\AutopilotMonitor\Deployed
(prevents ghost re-installs on re-Autopilot of the same device).
- Scheduled Task survives reboots during enrollment.
- OOBE detection uses Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
Requires Windows 10, version 1809 (introduced in 10.0.17763.0). Older builds lack
the class -> Test-OobeInProgress returns $false -> original (skip) behaviour. Docs:
https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
- This script MUST remain pure ASCII (no Unicode/UTF-8 special chars).
PowerShell 5.1 (IME) reads scripts without BOM as ANSI, corrupting multi-byte chars.
.CHANGELOG
2026-07-20 v2.2 Switched default download endpoint from the legacy blob
(autopilotmonitor.blob.core.windows.net) to the Front Door alias
https://download.autopilotmonitor.com/agent/ (route /agent/*).
version.json is still derived from the same container.
2026-06-25 v2.1 OOBE-relax: detect genuine OOBE via WinRT
SystemSetupInfo.OutOfBoxExperienceState and exempt guards 2+3 when a
single freshly-created profile appears in OOBE (Windows Backup for
Organizations). SKIP-safe when the API is absent (< 1809).
Removed legacy Content-MD5 fallback; SHA-256 manifest verification is
now mandatory (no hash -> bootstrap aborts, no unverified install).
2026-05-09 v2.0 Generic bootstrap: agent owns its own defaults (e.g. 600 s
TenantId-wait), so the script only calls `--install` plain.
Hardened post-install with a 10 s runtime-process verify.
@@ -55,17 +84,20 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $false)]
[string]$AgentDownloadUrl = "https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent.zip",
[string]$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip",
[Parameter(Mandatory = $false)]
[string]$VersionJsonName = "version.json",
[Parameter(Mandatory = $false)]
[int]$MaxBootstrapWindowHours = 12
[int]$MaxBootstrapWindowHours = 12,
[Parameter(Mandatory = $false)]
[int]$OobeProfileMaxAgeMinutes = 15
)
# Script version (bump on meaningful changes; see .CHANGELOG above)
$ScriptVersion = "2.0"
$ScriptVersion = "2.2"
# Configuration - Everything in ProgramData for easy cleanup
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
@@ -85,26 +117,19 @@ function Write-Log {
Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
}
function Get-FileMd5Base64 {
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
$md5 = [System.Security.Cryptography.MD5]::Create()
# Positive OOBE check via the documented WinRT API
# Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
# Returns $true ONLY when the OS reports InProgress. Any other state (Completed/NotStarted),
# an older OS without the class (< Win10 1809 / 10.0.17763.0), or any error returns $false
# -> SKIP-safe (we never relax a guard on uncertainty).
# Docs: https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
function Test-OobeInProgress {
try {
$stream = [System.IO.File]::OpenRead($Path)
try {
$hashBytes = $md5.ComputeHash($stream)
}
finally {
$stream.Dispose()
}
return [Convert]::ToBase64String($hashBytes)
$null = [Windows.System.Profile.SystemSetupInfo, Windows.System.Profile, ContentType = WindowsRuntime]
return ([Windows.System.Profile.SystemSetupInfo]::OutOfBoxExperienceState).ToString() -eq 'InProgress'
}
finally {
$md5.Dispose()
catch {
return $false
}
}
@@ -119,30 +144,65 @@ try {
exit 0
}
# Guard 2: No real user profile should exist yet (primary productive-device guard).
# Combines WMI (Win32_UserProfile.Special flag) and filesystem for maximum reliability.
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users)$'
# Collect real (non-special) user profiles as full paths (WMI Special flag + filesystem).
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users|WDAGUtilityAccount)$'
$profileNames = @(
# NOTE: the outer @() is required. Without it the trailing Where/Select pipeline
# unwraps a single result back to a scalar string, so $profilePaths[0] would index
# into the string and return its first char ('C') instead of the full path.
$profilePaths = @(
@(
try {
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } |
ForEach-Object { $_.LocalPath }
} catch { Write-Log "INFO: WMI profile query failed, continuing with filesystem check." }
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).FullName
) | Where-Object { $_ -and (Split-Path $_ -Leaf) -notmatch $excludePattern } |
Select-Object -Unique
)
# OOBE-relax: the ONLY exemption to guards 2+3. Matches exactly the Windows Backup for
# Organizations restore (one user signs in + restores DURING OOBE). All three must hold:
# OOBE InProgress AND exactly one profile AND that profile created < N minutes ago.
$oobeRelax = $false
$oobeInProgress = Test-OobeInProgress
if ($profilePaths.Count -eq 1 -and $oobeInProgress) {
try {
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } |
ForEach-Object { Split-Path $_.LocalPath -Leaf }
} catch { Write-Log "INFO: WMI profile query failed, continuing with filesystem check." }
$created = (Get-Item -LiteralPath $profilePaths[0] -Force -ErrorAction Stop).CreationTimeUtc
$ageMin = ((Get-Date).ToUniversalTime() - $created).TotalMinutes
if ($ageMin -ge 0 -and $ageMin -lt $OobeProfileMaxAgeMinutes) {
$oobeRelax = $true
Write-Log ("OOBE-relax active: OOBE InProgress + single profile created {0:N1}min ago (< {1}min). Guards 2+3 relaxed (Windows Backup restore case)." -f $ageMin, $OobeProfileMaxAgeMinutes)
} else {
Write-Log ("OOBE-relax NOT applied: OOBE InProgress + single profile '{0}', but profile created {1:N1}min ago (created {2:u}) is outside the [0, {3})min window." -f (Split-Path $profilePaths[0] -Leaf), $ageMin, $created, $OobeProfileMaxAgeMinutes)
}
} catch { Write-Log "INFO: profile age check failed; OOBE-relax not applied. $($_.Exception.Message)" }
} elseif ($profilePaths.Count -eq 1 -and -not $oobeInProgress) {
Write-Log "OOBE-relax NOT applied: single profile present but OutOfBoxExperienceState != InProgress (OOBE already finished / not detectable)."
} elseif ($profilePaths.Count -gt 1) {
Write-Log ("OOBE-relax NOT applied: {0} real profiles present (relax requires exactly one)." -f $profilePaths.Count)
}
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).Name
) | Where-Object { $_ -and $_ -notmatch $excludePattern } |
Select-Object -Unique
if ($profileNames) {
$names = ($profileNames | Select-Object -First 3) -join ', '
Write-Log "SKIP: Real user profile(s) found ($names). Device appears productive."
# Guard 2: No real user profile should exist yet (primary productive-device guard),
# unless the OOBE-relax signature holds.
if ($profilePaths.Count -gt 0 -and -not $oobeRelax) {
$details = (($profilePaths | ForEach-Object {
$leaf = Split-Path $_ -Leaf
try {
$c = (Get-Item -LiteralPath $_ -Force -ErrorAction Stop).CreationTimeUtc
"{0} (created {1:u}, {2:N1}min ago)" -f $leaf, $c, ((Get-Date).ToUniversalTime() - $c).TotalMinutes
} catch { "$leaf (age unknown)" }
}) | Select-Object -First 3) -join '; '
Write-Log "SKIP: Real user profile(s) found ($details). OOBE InProgress=$oobeInProgress. Device appears productive."
exit 0
}
# Guard 3: LastLoggedOnUser - during Device ESP no real user has logged on yet.
# Guard 3: LastLoggedOnUser - during Device ESP no real user has logged on yet
# (same OOBE-relax exemption: the restoring user signs in during OOBE).
$lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*') {
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and -not $oobeRelax) {
Write-Log "SKIP: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive."
exit 0
}
@@ -166,7 +226,7 @@ try {
}
}
Write-Log "Pre-flight checks passed -- no prior deployment, no real user profiles, no logged-on user, within bootstrap window"
Write-Log "Pre-flight checks passed -- no prior deployment, no productive-device signals, within bootstrap window (oobeRelax=$oobeRelax)"
# Download and extract agent binaries
$agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe"
@@ -181,38 +241,47 @@ try {
# Derive manifest URL from the agent download URL (same blob container)
$versionJsonUrl = $AgentDownloadUrl -replace '[^/]+$', $VersionJsonName
$expectedSha256 = $null
$manifestAttempts = 3
$manifestDelays = @(1, 2) # short backoff between attempts; do not block enrollment
try {
Write-Log "Fetching $VersionJsonName from $versionJsonUrl for integrity verification..."
$versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop
if ($versionJsonResponse.sha256) {
$expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant()
Write-Log "SHA-256 hash from manifest: $expectedSha256 (version: $($versionJsonResponse.version))"
} else {
Write-Log "Manifest has no sha256 field - falling back to legacy MD5 check (older build)"
for ($attempt = 1; $attempt -le $manifestAttempts; $attempt++) {
try {
Write-Log "Fetching $VersionJsonName (attempt ${attempt}/${manifestAttempts}) from $versionJsonUrl for integrity verification..."
$versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop
if ($versionJsonResponse.sha256) {
$expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant()
Write-Log "SHA-256 hash from manifest: $expectedSha256 (version: $($versionJsonResponse.version))"
} else {
Write-Log "WARNING: Manifest has no sha256 field - integrity cannot be verified."
}
break # manifest reached (with or without hash); retrying would not help
}
catch {
Write-Log "WARNING: Could not fetch integrity manifest (attempt $attempt): $($_.Exception.Message)"
if ($attempt -lt $manifestAttempts) {
Start-Sleep -Seconds $manifestDelays[$attempt - 1]
}
}
}
catch {
Write-Log "WARNING: Could not fetch manifest - falling back to legacy MD5 check: $($_.Exception.Message)"
}
$zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent.zip"
$maxDownloadAttempts = 3
$downloadAttempt = 0
$downloadResponse = $null
do {
$downloadAttempt++
try {
Write-Log "Download attempt ${downloadAttempt}/${maxDownloadAttempts}"
$downloadResponse = Invoke-WebRequest `
$downloadStopwatch = [System.Diagnostics.Stopwatch]::StartNew()
Invoke-WebRequest `
-Uri $AgentDownloadUrl `
-OutFile $zipPath `
-UseBasicParsing `
-TimeoutSec 30 `
-ErrorAction Stop `
-PassThru
Write-Log "Downloaded agent to $zipPath"
-ErrorAction Stop
$downloadStopwatch.Stop()
$downloadSeconds = [math]::Round($downloadStopwatch.Elapsed.TotalSeconds, 1)
Write-Log "Downloaded agent to $zipPath (took ${downloadSeconds}s)"
break
}
catch {
@@ -227,29 +296,17 @@ try {
}
} while ($downloadAttempt -lt $maxDownloadAttempts)
if ($expectedSha256) {
$actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
Write-Log "Validating SHA-256 hash against manifest"
if ($actualSha256 -ne $expectedSha256) {
throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted."
}
Write-Log "SHA-256 integrity check passed"
# SHA-256 from the version manifest is the only accepted integrity proof.
# No hash -> refuse to install an unverified ZIP (SKIP-safe security posture).
if (-not $expectedSha256) {
throw "No SHA-256 hash available from manifest - refusing to install unverified agent ZIP."
}
else {
$expectedMd5Header = $downloadResponse.Headers["Content-MD5"]
$expectedMd5 = if ($expectedMd5Header -is [System.Array]) { "$($expectedMd5Header[0])".Trim() } else { "$expectedMd5Header".Trim() }
if ($expectedMd5 -notmatch '\S') {
Write-Log "WARNING: No SHA-256 and no Content-MD5 header - skipping integrity validation"
}
else {
$actualMd5 = Get-FileMd5Base64 -Path $zipPath
Write-Log "Validating Content-MD5 header against downloaded ZIP"
if ($actualMd5 -ne $expectedMd5) {
throw "MD5 integrity check failed. Expected (Content-MD5)='$expectedMd5', Actual='$actualMd5'"
}
Write-Log "MD5 integrity check passed"
}
$actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
Write-Log "Validating SHA-256 hash against manifest"
if ($actualSha256 -ne $expectedSha256) {
throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted."
}
Write-Log "SHA-256 integrity check passed"
Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath -Force
Write-Log "Extracted agent to $AgentBinPath"
@@ -1,213 +0,0 @@
# Autopilot Monitor Bootstrap Guard Test (DEV)
# Dry-run only: does not exit, does not install, only reports what WOULD happen.
# Mirrors Install-AutopilotMonitor-dev.ps1 incl. the OOBE-relax exemption for the
# "Windows Backup for Organizations" restore case (guards 2+3).
param(
[string]$AgentBinPath = 'C:\ProgramData\AutopilotMonitor\Agent',
[int]$OobeProfileMaxAgeMinutes = 15
)
$ErrorActionPreference = 'SilentlyContinue'
function Write-Step {
param(
[string]$Status,
[string]$Message
)
$prefix = switch ($Status) {
'PASS' { '[PASS]' }
'SKIP' { '[SKIP]' }
'WARN' { '[WARN]' }
'INFO' { '[INFO]' }
default { '[....]' }
}
Write-Host "$prefix $Message"
}
function Get-RegistryValueSafe {
param(
[string]$Path,
[string]$Name
)
try {
return (Get-ItemProperty -Path $Path -Name $Name -ErrorAction Stop).$Name
}
catch {
return $null
}
}
# Positive OOBE check via WinRT Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
# Requires Windows 10, version 1809 (10.0.17763.0). Older builds / errors -> $false (SKIP-safe).
# Docs: https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
function Test-OobeInProgress {
try {
$null = [Windows.System.Profile.SystemSetupInfo, Windows.System.Profile, ContentType = WindowsRuntime]
return ([Windows.System.Profile.SystemSetupInfo]::OutOfBoxExperienceState).ToString() -eq 'InProgress'
}
catch {
return $false
}
}
$AgentExePath = Join-Path $AgentBinPath 'AutopilotMonitor.Agent.exe'
$MaxBootstrapWindowHours = 12
$wouldInstall = $true
$reasons = New-Object System.Collections.Generic.List[string]
Write-Host ''
Write-Host '=== Autopilot Monitor Bootstrap Guard Test (Dry Run, DEV) ==='
Write-Host ''
# Guard 1: Agent was already deployed on this device (survives self-destruct)
$deployed = Get-RegistryValueSafe -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Name 'Deployed'
if ($deployed) {
Write-Step -Status 'SKIP' -Message "Guard 1: Agent was previously deployed at '$deployed'."
$wouldInstall = $false
$reasons.Add("Previously deployed marker exists: $deployed")
} else {
Write-Step -Status 'PASS' -Message 'Guard 1: No previous deployment marker found.'
}
# Collect real (non-special) user profiles as full paths (combined WMI/filesystem view).
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users|WDAGUtilityAccount)$'
$wmiProfileQueryFailed = $false
# NOTE: the outer @() is required. Without it the trailing Where/Select pipeline
# unwraps a single result back to a scalar string, so $profilePaths[0] would index
# into the string and return its first char ('C') instead of the full path.
$profilePaths = @(
@(
try {
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } |
ForEach-Object { $_.LocalPath }
}
catch {
$wmiProfileQueryFailed = $true
}
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).FullName
) |
Where-Object { $_ -and (Split-Path $_ -Leaf) -notmatch $excludePattern } |
Select-Object -Unique
)
if ($wmiProfileQueryFailed) {
Write-Step -Status 'WARN' -Message 'Profiles: WMI query failed, filesystem check still applied.'
}
$profileNames = $profilePaths | ForEach-Object { Split-Path $_ -Leaf }
# OOBE-relax: the ONLY exemption to guards 2+3. Matches exactly the Windows Backup for
# Organizations restore: OOBE InProgress AND exactly one profile AND that profile fresh.
$oobeInProgress = Test-OobeInProgress
$profileAgeMin = $null
$oobeRelax = $false
if ($profilePaths.Count -eq 1) {
try {
$created = (Get-Item -LiteralPath $profilePaths[0] -Force -ErrorAction Stop).CreationTimeUtc
$profileAgeMin = [math]::Round(((Get-Date).ToUniversalTime() - $created).TotalMinutes, 1)
} catch { }
if ($oobeInProgress -and $null -ne $profileAgeMin -and $profileAgeMin -ge 0 -and $profileAgeMin -lt $OobeProfileMaxAgeMinutes) {
$oobeRelax = $true
}
}
Write-Step -Status 'INFO' -Message "OOBE: OutOfBoxExperienceState InProgress = $oobeInProgress ; profileCount = $($profilePaths.Count) ; singleProfileAgeMin = $profileAgeMin ; OOBE-relax = $oobeRelax"
# Guard 2: No real user profile should exist yet (unless OOBE-relax holds)
if ($profilePaths.Count -gt 0 -and -not $oobeRelax) {
$names = ($profileNames | Select-Object -First 3) -join ', '
Write-Step -Status 'SKIP' -Message "Guard 2: Real user profile(s) found ($names). Device appears productive."
$wouldInstall = $false
$reasons.Add("Real user profile(s) found: $names")
} elseif ($profilePaths.Count -gt 0 -and $oobeRelax) {
$names = ($profileNames | Select-Object -First 3) -join ', '
Write-Step -Status 'PASS' -Message "Guard 2: Profile ($names) present but OOBE-relax active (single fresh profile in OOBE) -- not blocking."
} else {
Write-Step -Status 'PASS' -Message 'Guard 2: No real user profiles found (combined WMI/filesystem view).'
}
# Guard 3: LastLoggedOnUser (unless OOBE-relax holds)
$lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and -not $oobeRelax) {
Write-Step -Status 'SKIP' -Message "Guard 3: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive."
$wouldInstall = $false
$reasons.Add("LastLoggedOnUser found: $lastLoggedOnUser")
} elseif ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and $oobeRelax) {
Write-Step -Status 'PASS' -Message "Guard 3: LastLoggedOnUser ($lastLoggedOnUser) present but OOBE-relax active -- not blocking."
} else {
Write-Step -Status 'PASS' -Message "Guard 3: No real LastLoggedOnUser (value: '$lastLoggedOnUser')."
}
# Guard 4: Bootstrap window check (sleep/standby does NOT reset uptime, only real boot does)
$lastBoot = $null
$uptimeHours = $null
try {
$lastBoot = (Get-CimInstance Win32_OperatingSystem -ErrorAction Stop).LastBootUpTime
$uptimeHours = ((Get-Date) - $lastBoot).TotalHours
if ($uptimeHours -gt $MaxBootstrapWindowHours) {
Write-Step -Status 'SKIP' -Message "Guard 4: Device uptime is $([int]$uptimeHours)h. Older than accepted bootstrap window of ${MaxBootstrapWindowHours}h."
$wouldInstall = $false
$reasons.Add("Uptime exceeds bootstrap window: $([int]$uptimeHours)h > ${MaxBootstrapWindowHours}h")
} else {
Write-Step -Status 'PASS' -Message "Guard 4: Device uptime is $([int]$uptimeHours)h and within bootstrap window of ${MaxBootstrapWindowHours}h."
}
}
catch {
Write-Step -Status 'WARN' -Message 'Guard 4: Could not determine LastBootUpTime / uptime.'
}
# Guard 5: Agent binary already present
if (Test-Path $AgentExePath) {
Write-Step -Status 'SKIP' -Message "Guard 5: Agent already installed at '$AgentExePath'."
$wouldInstall = $false
$reasons.Add("Agent binary already present: $AgentExePath")
} else {
Write-Step -Status 'PASS' -Message "Guard 5: Agent binary not present at '$AgentExePath'."
}
Write-Host ''
Write-Host '=== Result ==='
Write-Host ''
if ($wouldInstall) {
Write-Host '[DECISION] WOULD INSTALL agent on this device.'
} else {
Write-Host '[DECISION] WOULD SKIP agent installation on this device.'
}
Write-Host ''
Write-Host '=== Summary ==='
if ($reasons.Count -eq 0) {
Write-Host 'No blocking reasons found.'
} else {
foreach ($reason in $reasons) {
Write-Host " - $reason"
}
}
Write-Host ''
Write-Host '=== Raw Values ==='
Write-Host "Deployed marker : $deployed"
Write-Host "DetectedProfileNames : $($profileNames -join ', ')"
Write-Host "ProfileCount : $($profilePaths.Count)"
Write-Host "OobeInProgress (WinRT) : $oobeInProgress"
Write-Host "SingleProfileAgeMinutes : $profileAgeMin"
Write-Host "OobeProfileMaxAgeMinutes : $OobeProfileMaxAgeMinutes"
Write-Host "OobeRelaxActive : $oobeRelax"
Write-Host "LastLoggedOnUser : $lastLoggedOnUser"
Write-Host "MaxBootstrapWindowHours : $MaxBootstrapWindowHours"
Write-Host "LastBootUpTime : $lastBoot"
Write-Host "UptimeHours : $([string]$uptimeHours)"
Write-Host "AgentExePath : $AgentExePath"
Write-Host ''
+73 -36
View File
@@ -1,8 +1,11 @@
# Autopilot Monitor Bootstrap Guard Test
# Dry-run only: does not exit, does not install, only reports what WOULD happen.
# Mirrors Install-AutopilotMonitor.ps1 incl. the OOBE-relax exemption for the
# "Windows Backup for Organizations" restore case (guards 2+3).
param(
[string]$AgentBinPath = 'C:\ProgramData\AutopilotMonitor\Agent'
[string]$AgentBinPath = 'C:\ProgramData\AutopilotMonitor\Agent',
[int]$OobeProfileMaxAgeMinutes = 15
)
$ErrorActionPreference = 'SilentlyContinue'
@@ -38,6 +41,19 @@ function Get-RegistryValueSafe {
}
}
# Positive OOBE check via WinRT Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
# Requires Windows 10, version 1809 (10.0.17763.0). Older builds / errors -> $false (SKIP-safe).
# Docs: https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
function Test-OobeInProgress {
try {
$null = [Windows.System.Profile.SystemSetupInfo, Windows.System.Profile, ContentType = WindowsRuntime]
return ([Windows.System.Profile.SystemSetupInfo]::OutOfBoxExperienceState).ToString() -eq 'InProgress'
}
catch {
return $false
}
}
$AgentExePath = Join-Path $AgentBinPath 'AutopilotMonitor.Agent.exe'
$MaxBootstrapWindowHours = 12
@@ -58,63 +74,79 @@ if ($deployed) {
Write-Step -Status 'PASS' -Message 'Guard 1: No previous deployment marker found.'
}
# Guard 2: No real user profile should exist yet (primary productive-device guard)
# NOTE: OOBEInProgress is NOT used -- it is unreliable (observed =0 during active enrollment).
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users)$'
# Collect real (non-special) user profiles as full paths (combined WMI/filesystem view).
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users|WDAGUtilityAccount)$'
$wmiProfileQueryFailed = $false
$profileNames = @(
# WMI/CIM view -- Special flag reliably excludes SYSTEM/LocalService/NetworkService
try {
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
Where-Object {
-not $_.Special -and
$_.LocalPath -like 'C:\Users\*'
} |
ForEach-Object {
Split-Path $_.LocalPath -Leaf
}
}
catch {
$wmiProfileQueryFailed = $true
}
# NOTE: the outer @() is required. Without it the trailing Where/Select pipeline
# unwraps a single result back to a scalar string, so $profilePaths[0] would index
# into the string and return its first char ('C') instead of the full path.
$profilePaths = @(
@(
try {
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } |
ForEach-Object { $_.LocalPath }
}
catch {
$wmiProfileQueryFailed = $true
}
# Filesystem view -- catches profiles WMI might miss
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).Name
) |
Where-Object {
$_ -and $_ -notmatch $excludePattern
} |
Select-Object -Unique
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).FullName
) |
Where-Object { $_ -and (Split-Path $_ -Leaf) -notmatch $excludePattern } |
Select-Object -Unique
)
if ($wmiProfileQueryFailed) {
Write-Step -Status 'WARN' -Message 'Guard 2: WMI profile query failed, filesystem check still applied.'
Write-Step -Status 'WARN' -Message 'Profiles: WMI query failed, filesystem check still applied.'
}
if ($profileNames) {
$profileNames = $profilePaths | ForEach-Object { Split-Path $_ -Leaf }
# OOBE-relax: the ONLY exemption to guards 2+3. Matches exactly the Windows Backup for
# Organizations restore: OOBE InProgress AND exactly one profile AND that profile fresh.
$oobeInProgress = Test-OobeInProgress
$profileAgeMin = $null
$oobeRelax = $false
if ($profilePaths.Count -eq 1) {
try {
$created = (Get-Item -LiteralPath $profilePaths[0] -Force -ErrorAction Stop).CreationTimeUtc
$profileAgeMin = [math]::Round(((Get-Date).ToUniversalTime() - $created).TotalMinutes, 1)
} catch { }
if ($oobeInProgress -and $null -ne $profileAgeMin -and $profileAgeMin -ge 0 -and $profileAgeMin -lt $OobeProfileMaxAgeMinutes) {
$oobeRelax = $true
}
}
Write-Step -Status 'INFO' -Message "OOBE: OutOfBoxExperienceState InProgress = $oobeInProgress ; profileCount = $($profilePaths.Count) ; singleProfileAgeMin = $profileAgeMin ; OOBE-relax = $oobeRelax"
# Guard 2: No real user profile should exist yet (unless OOBE-relax holds)
if ($profilePaths.Count -gt 0 -and -not $oobeRelax) {
$names = ($profileNames | Select-Object -First 3) -join ', '
Write-Step -Status 'SKIP' -Message "Guard 2: Real user profile(s) found ($names). Device appears productive."
$wouldInstall = $false
$reasons.Add("Real user profile(s) found: $names")
} elseif ($profilePaths.Count -gt 0 -and $oobeRelax) {
$names = ($profileNames | Select-Object -First 3) -join ', '
Write-Step -Status 'PASS' -Message "Guard 2: Profile ($names) present but OOBE-relax active (single fresh profile in OOBE) -- not blocking."
} else {
Write-Step -Status 'PASS' -Message 'Guard 2: No real user profiles found (combined WMI/filesystem view).'
}
# Guard 3: LastLoggedOnUser -- during Device ESP no real user has logged on yet
# Guard 3: LastLoggedOnUser (unless OOBE-relax holds)
$lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*') {
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and -not $oobeRelax) {
Write-Step -Status 'SKIP' -Message "Guard 3: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive."
$wouldInstall = $false
$reasons.Add("LastLoggedOnUser found: $lastLoggedOnUser")
} elseif ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and $oobeRelax) {
Write-Step -Status 'PASS' -Message "Guard 3: LastLoggedOnUser ($lastLoggedOnUser) present but OOBE-relax active -- not blocking."
} else {
Write-Step -Status 'PASS' -Message "Guard 3: No real LastLoggedOnUser (value: '$lastLoggedOnUser')."
}
# Guard 4: Bootstrap window check
# NOT "how long may enrollment take" -- agent handles that internally (6h emergency break).
# This is "how old can the OOBE state be before I no longer trust it for initial install".
# 12h bootstrap window vs 6h agent emergency break = consistent layered approach.
# Sleep/standby does NOT reset uptime -- only real boot/restart does.
# Guard 4: Bootstrap window check (sleep/standby does NOT reset uptime, only real boot does)
$lastBoot = $null
$uptimeHours = $null
@@ -134,7 +166,7 @@ catch {
Write-Step -Status 'WARN' -Message 'Guard 4: Could not determine LastBootUpTime / uptime.'
}
# Guard 4: Agent binary already present
# Guard 5: Agent binary already present
if (Test-Path $AgentExePath) {
Write-Step -Status 'SKIP' -Message "Guard 5: Agent already installed at '$AgentExePath'."
$wouldInstall = $false
@@ -168,6 +200,11 @@ Write-Host ''
Write-Host '=== Raw Values ==='
Write-Host "Deployed marker : $deployed"
Write-Host "DetectedProfileNames : $($profileNames -join ', ')"
Write-Host "ProfileCount : $($profilePaths.Count)"
Write-Host "OobeInProgress (WinRT) : $oobeInProgress"
Write-Host "SingleProfileAgeMinutes : $profileAgeMin"
Write-Host "OobeProfileMaxAgeMinutes : $OobeProfileMaxAgeMinutes"
Write-Host "OobeRelaxActive : $oobeRelax"
Write-Host "LastLoggedOnUser : $lastLoggedOnUser"
Write-Host "MaxBootstrapWindowHours : $MaxBootstrapWindowHours"
Write-Host "LastBootUpTime : $lastBoot"
@@ -138,6 +138,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
Assert.Equal(2, changed["suppressedPolls"]);
}
[Fact]
public void PhaseChange_writes_transition_line()
{
_executor.UpdateRules(new List<GatherRule> { Rule("GATHER-PHASE-001") });
_executor.WaitForStartupRules(30);
_executor.OnPhaseChanged(EnrollmentPhase.DeviceSetup);
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup);
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup); // repeat — no extra line
var trace = TraceContent();
Assert.Contains("- | phase | phase change: Unknown -> DeviceSetup (first phase signal", trace);
Assert.Contains("- | phase | phase change: DeviceSetup -> AccountSetup", trace);
// A repeated signal for the same phase must not produce a second transition line.
var marker = "phase change: DeviceSetup -> AccountSetup";
Assert.Equal(trace.IndexOf(marker), trace.LastIndexOf(marker));
}
[Fact]
public void EmptyCollectorResult_is_traced_via_startup_execution()
{
@@ -108,6 +108,54 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
Assert.Contains("every line failed CMTrace parsing — if this is a plain-text log, set parameter format=text", trace);
}
[Fact]
public void Match_traces_line_number_matched_text_and_groups()
{
var path = Path.Combine(_outsideUsers, "match.log");
File.WriteAllText(path, "boring line\nError 0x80070005 in setup\n");
new LogParserCollector().Execute(
Rule(path, @"Error (?<code>0x[0-9A-Fa-f]+)", format: "text"), _context);
Assert.Single(_events);
var trace = TraceContent();
Assert.Contains("match.log: line 2 matched \"Error 0x80070005\"", trace);
Assert.Contains("groups: code=\"0x80070005\"", trace);
}
[Fact]
public void MatchTraces_are_capped_per_file()
{
var path = Path.Combine(_outsideUsers, "many.log");
var sb = new System.Text.StringBuilder();
for (var i = 1; i <= 15; i++)
sb.Append("hit number ").Append(i).Append('\n');
File.WriteAllText(path, sb.ToString());
new LogParserCollector().Execute(Rule(path, "hit", format: "text"), _context);
Assert.Equal(15, _events.Count); // all matches still emit events
var trace = TraceContent();
Assert.Contains("many.log: line 10 matched", trace);
Assert.Contains("further matches not traced individually", trace);
Assert.DoesNotContain("many.log: line 11 matched", trace);
Assert.Contains("matched 15", trace); // summary still carries the full count
}
[Fact]
public void InvalidRegex_traces_the_pattern_itself()
{
var path = Path.Combine(_outsideUsers, "irrelevant.log");
File.WriteAllText(path, "content\n");
new LogParserCollector().Execute(Rule(path, "([unclosed", format: "text"), _context);
Assert.Empty(_events);
var trace = TraceContent();
Assert.Contains("invalid regex pattern '([unclosed'", trace);
Assert.Contains("rule can never match", trace);
}
[Fact]
public void MissingFile_traces_no_files_matched()
{
@@ -103,7 +103,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
{
context.Logger.Warning($"LogParser rule {rule.RuleId} has invalid regex: {ex.Message}");
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageError,
$"invalid regex pattern — rule can never match: {ex.Message}");
$"invalid regex pattern '{patternStr}' — rule can never match: {ex.Message}");
return null;
}
@@ -133,6 +133,44 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
return null;
}
// Per-match trace lines are capped per file per run — the per-file summary line
// still carries the full match count, this only bounds the per-match detail.
private const int MaxTracedMatchesPerFile = 10;
/// <summary>
/// Writes one debug-trace line per regex match: the line number, the matched text,
/// and every capture group that will land in the emitted event (same "0" exclusion
/// as the event data). Stops after <see cref="MaxTracedMatchesPerFile"/> matches.
/// </summary>
private static void TraceMatch(GatherRuleContext context, GatherRule rule, string fileName,
int lineNumber, Match match, Regex pattern, int matchNumber)
{
if (matchNumber > MaxTracedMatchesPerFile)
{
if (matchNumber == MaxTracedMatchesPerFile + 1)
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
$"{fileName}: further matches not traced individually (cap {MaxTracedMatchesPerFile}/file/run) — see the per-file summary for the total");
return;
}
var sb = new StringBuilder();
sb.Append(fileName).Append(": line ").Append(lineNumber)
.Append(" matched \"").Append(TruncateMessage(match.Value, 200)).Append('"');
var first = true;
foreach (var groupName in pattern.GetGroupNames())
{
if (groupName == "0") continue;
var group = match.Groups[groupName];
if (!group.Success) continue;
sb.Append(first ? " — groups: " : ", ");
first = false;
sb.Append(groupName).Append("=\"").Append(TruncateMessage(group.Value, 100)).Append('"');
}
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser, sb.ToString());
}
private static bool HasWildcard(string fileNamePart)
=> !string.IsNullOrEmpty(fileNamePart) &&
(fileNamePart.Contains("*") || fileNamePart.Contains("?"));
@@ -258,6 +296,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
});
matchCount++;
TraceMatch(context, rule, Path.GetFileName(filePath), linesRead, match, pattern, matchCount);
}
else
{
@@ -317,6 +356,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
});
matchCount++;
TraceMatch(context, rule, Path.GetFileName(filePath), linesRead, match, pattern, matchCount);
}
}
@@ -14,6 +14,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
public sealed class GatherRuleDebugLog
{
public const string StageConfig = "config";
public const string StagePhase = "phase";
public const string StageTrigger = "trigger";
public const string StageScope = "scope";
public const string StageExec = "exec";
@@ -248,10 +248,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
// _currentPhase moves below. A transition INTO Failed still fires the exit rules of the
// phase that failed — that snapshot is the whole point at a failure boundary.
List<GatherRule> exitRules = null;
EnrollmentPhase previousPhase;
var exitTraces = _debug != null ? new List<KeyValuePair<string, string>>() : null;
lock (_scopeLock)
{
var previousPhase = _currentPhase;
previousPhase = _currentPhase;
if (previousPhase != EnrollmentPhase.Unknown && previousPhase != newPhase)
{
var previousPhaseName = previousPhase.ToString();
@@ -291,6 +292,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
}
}
// Transition marker first, so every rule-level line below reads against it.
// Written outside _scopeLock like all trace lines.
if (previousPhase != newPhase)
DebugLog(null, GatherRuleDebugLog.StagePhase,
$"phase change: {previousPhase} -> {phaseName}" +
(previousPhase == EnrollmentPhase.Unknown
? " (first phase signal — scoped rules now evaluate against this phase)"
: ""));
if (exitTraces != null)
{
foreach (var trace in exitTraces)
@@ -189,7 +189,7 @@ public class AnalyzeRuleNotifyTests
var alert = NotificationAlertBuilder.BuildRuleFiredAlert(
result, "DESKTOP-CONTOSO1", "SN-1234",
"https://portal.autopilotmonitor.com/sessions/abc");
"https://portal.autopilotmonitor.com/sessions?id=abc");
Assert.Equal("analyze_rule_fired", alert.EventType);
Assert.Contains("Proxy blocks enrollment endpoints", alert.Title);
@@ -197,7 +197,7 @@ public class AnalyzeRuleNotifyTests
Assert.Contains(alert.Facts, f => f.Name == "Serial" && f.Value == "SN-1234");
Assert.Contains(alert.Facts, f => f.Name == "Rule" && f.Value.Contains("ANALYZE-NET-001"));
Assert.Contains(alert.Facts, f => f.Name == "Confidence" && f.Value == "85%");
Assert.Contains(alert.Actions, a => a.Type == "openUrl" && a.Url == "https://portal.autopilotmonitor.com/sessions/abc");
Assert.Contains(alert.Actions, a => a.Type == "openUrl" && a.Url == "https://portal.autopilotmonitor.com/sessions?id=abc");
Assert.Contains(alert.Sections, s => s.Text.Contains("could not reach"));
}
@@ -131,7 +131,7 @@ public class AuthFunctionSideEffectTests
_globalNotificationMock.Verify(x => x.CreateNotificationAsync(
"preview_signup", "New Preview Signup",
It.Is<string>(m => m.Contains(TenantId) && m.Contains("contoso.com") && m.Contains(Upn)),
null), Times.Once);
$"/admin/tenants/management?tenantId={TenantId}"), Times.Once);
}
[Fact]
@@ -30,6 +30,9 @@
<None Update="device-cert-sample.pem">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="TestData\oobe-bootstrap-golden.ps1">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -0,0 +1,120 @@
using AutopilotMonitor.Functions.Security;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pins the injection-defense invariants of <see cref="BootstrapScriptValueValidator"/>,
/// ported from the portal's utils/bootstrapValidation.ts (and its vitest suite). The
/// validated values are interpolated into a PowerShell script that runs as SYSTEM
/// during OOBE — any relaxation here widens the injection surface.
/// </summary>
public class BootstrapScriptValueValidatorTests
{
private const string GoodTenant = "11111111-1111-1111-1111-111111111111";
private const string GoodToken = "22222222-2222-2222-2222-222222222222";
private const string GoodUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip";
private static DateTime FutureExpiry => DateTime.UtcNow.AddHours(24);
private static bool Validate(
string tenantId = GoodTenant,
string token = GoodToken,
string url = GoodUrl,
DateTime? expiresAt = null,
BootstrapScriptValueValidator.ValidationFailure? expectedFailure = null)
{
var ok = BootstrapScriptValueValidator.TryValidate(
tenantId, token, url, expiresAt ?? FutureExpiry, out _, out var failure);
if (!ok && expectedFailure.HasValue)
Assert.Equal(expectedFailure.Value, failure);
return ok;
}
[Fact]
public void Accepts_valid_values()
{
Assert.True(Validate());
}
// ── tenantId / token: exact canonical GUIDs only ──
[Theory]
[InlineData("")]
[InlineData("not-a-guid")]
[InlineData("11111111111111111111111111111111")] // "N" format — no hyphens
[InlineData("{11111111-1111-1111-1111-111111111111}")] // "B" format
[InlineData(" 11111111-1111-1111-1111-111111111111")] // leading space (TryParseExact would trim)
[InlineData("11111111-1111-1111-1111-11111111111$")]
public void Rejects_non_canonical_tenant_ids(string tenantId)
{
Assert.False(Validate(tenantId: tenantId,
expectedFailure: BootstrapScriptValueValidator.ValidationFailure.TenantId));
}
[Fact]
public void Rejects_non_guid_token()
{
Assert.False(Validate(token: "'; $(calc); '",
expectedFailure: BootstrapScriptValueValidator.ValidationFailure.Token));
}
// ── agentDownloadUrl: https + host allow-list + strict path ──
[Theory]
[InlineData("https://evil.example.com/x.zip'; $(calc); '")] // hostile payload from the vitest suite
[InlineData("https://evil.example.com/agent/AutopilotMonitor-Agent.zip")]
[InlineData("http://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip")] // not https
[InlineData("https://download.autopilotmonitor.com:8443/agent/AutopilotMonitor-Agent.zip")] // explicit port
[InlineData("https://user:pw@download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip")] // userinfo
[InlineData("https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip?x=1")] // query
[InlineData("https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip#f")] // fragment
[InlineData("https://download.autopilotmonitor.com/other/AutopilotMonitor-Agent.zip")] // wrong prefix
[InlineData("https://download.autopilotmonitor.com/agent/.hidden.zip")] // leading dot
[InlineData("https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.exe")] // not .zip
[InlineData("")]
public void Rejects_disallowed_download_urls(string url)
{
Assert.False(Validate(url: url,
expectedFailure: BootstrapScriptValueValidator.ValidationFailure.AgentDownloadUrl));
}
[Theory]
[InlineData("https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip")]
[InlineData("https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent.zip")] // legacy blob host
[InlineData("https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent-v2.zip")]
public void Accepts_allowlisted_download_urls(string url)
{
Assert.True(Validate(url: url));
}
[Fact]
public void Rejects_filename_longer_than_80_chars()
{
// First char + 80 more before ".zip" exceeds the {0,79} tail bound.
var tooLong = "https://download.autopilotmonitor.com/agent/a" + new string('b', 80) + ".zip";
Assert.False(Validate(url: tooLong,
expectedFailure: BootstrapScriptValueValidator.ValidationFailure.AgentDownloadUrl));
var atLimit = "https://download.autopilotmonitor.com/agent/a" + new string('b', 75) + ".zip";
Assert.True(Validate(url: atLimit));
}
// ── expiresAt: strictly future, at most 14 days out ──
[Fact]
public void Rejects_past_expiry()
{
Assert.False(Validate(expiresAt: DateTime.UtcNow.AddMinutes(-1),
expectedFailure: BootstrapScriptValueValidator.ValidationFailure.ExpiresAt));
}
[Fact]
public void Rejects_expiry_beyond_14_days()
{
Assert.False(Validate(expiresAt: DateTime.UtcNow.AddDays(15),
expectedFailure: BootstrapScriptValueValidator.ValidationFailure.ExpiresAt));
}
[Fact]
public void Accepts_expiry_within_the_window()
{
Assert.True(Validate(expiresAt: DateTime.UtcNow.AddDays(7)));
}
}
@@ -120,6 +120,77 @@ public class DistressValidationTests
Assert.Equal("203.0.113.5", ClientIpExtractor.ExtractTrustedHop("10.0.0.1, 203.0.113.5, "));
}
// =========================================================================
// ClientIpExtractor.GetHeaderFromBindingDataJson — Flex Consumption strips the
// X-Forwarded-* family from the proxied HTTP request; the host passes the
// ORIGINAL header set as trigger metadata (BindingData["Headers"], JSON object).
// Malformed metadata must fail soft (null), never throw into request handling.
// =========================================================================
[Fact]
public void GetHeaderFromBindingDataJson_XffPresent_ReturnsValue()
{
var json = """{"Host":"example.com","X-Forwarded-For":"1.1.1.1, 203.0.113.5:443"}""";
Assert.Equal(
"1.1.1.1, 203.0.113.5:443",
ClientIpExtractor.GetHeaderFromBindingDataJson(json, "X-Forwarded-For"));
}
[Fact]
public void GetHeaderFromBindingDataJson_HeaderNameCaseInsensitive()
{
var json = """{"x-forwarded-for":"203.0.113.5"}""";
Assert.Equal(
"203.0.113.5",
ClientIpExtractor.GetHeaderFromBindingDataJson(json, "X-Forwarded-For"));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("not json")]
[InlineData("[]")] // JSON but not an object
[InlineData("""{"Host":"example.com"}""")] // header absent
[InlineData("""{"X-Forwarded-For":42}""")] // non-string value
public void GetHeaderFromBindingDataJson_MissingOrMalformed_ReturnsNull(string? json)
{
Assert.Null(ClientIpExtractor.GetHeaderFromBindingDataJson(json, "X-Forwarded-For"));
}
// =========================================================================
// ClientIpExtractor.RemoteIpToString — transport-level fallback. On Flex the
// worker's TCP peer is the host's LOCAL proxy: loopback means "not a client"
// and must map to "unknown", never be stored as a forensic SourceIp
// (production 2026-07-29: distress reports carried SourceIp 127.0.0.1).
// =========================================================================
[Fact]
public void RemoteIpToString_Null_ReturnsUnknown()
{
Assert.Equal("unknown", ClientIpExtractor.RemoteIpToString(null));
}
[Theory]
[InlineData("127.0.0.1")]
[InlineData("::1")]
[InlineData("::ffff:127.0.0.1")] // IPv4-mapped loopback
public void RemoteIpToString_Loopback_ReturnsUnknown(string ip)
{
Assert.Equal("unknown", ClientIpExtractor.RemoteIpToString(System.Net.IPAddress.Parse(ip)));
}
[Fact]
public void RemoteIpToString_PublicIpv4_ReturnsIp()
{
Assert.Equal("203.0.113.5", ClientIpExtractor.RemoteIpToString(System.Net.IPAddress.Parse("203.0.113.5")));
}
[Fact]
public void RemoteIpToString_Ipv4MappedIpv6_FoldsToIpv4()
{
Assert.Equal("203.0.113.5", ClientIpExtractor.RemoteIpToString(System.Net.IPAddress.Parse("::ffff:203.0.113.5")));
}
// =========================================================================
// Sanitize — Control character stripping and truncation
// =========================================================================
@@ -0,0 +1,153 @@
using AutopilotMonitor.Functions.Functions.Bootstrap;
using AutopilotMonitor.Functions.Middleware;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Shared;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Seam tests for <c>GET /api/bootstrap/go/{code}</c> (GetBootstrapScriptFunction) —
/// the OOBE bootstrap script endpoint reached by customers as
/// <c>irm https://go.autopilotmonitor.com/{code} | iex</c>. Following the project
/// convention (see AppInsightsQueryFunctionTests), the HTTP entrypoint itself is not
/// faked; the compilable seams — code-format gate, policy-catalog registration, and
/// the no-store cache classification — are pinned here. Script generation and value
/// validation have their own suites.
/// </summary>
public class GetBootstrapScriptFunctionTests
{
private const string Route = "/api/bootstrap/go/abcd";
// ── Code-format gate: 4-10 alphanumeric, checked before any service call.
// Table ported from the vitest suite of the original Next.js route. ──
[Theory]
[InlineData("")]
[InlineData("abc")] // too short
[InlineData("abcdefghijk")] // too long
[InlineData("ab cd")]
[InlineData("../etc")]
[InlineData("ab%2Fcd")]
[InlineData("abc$")]
[InlineData("ab'cd")]
[InlineData("ab.cd")]
public void Rejects_invalid_code_formats(string code)
{
Assert.False(GetBootstrapScriptFunction.IsValidCodeFormat(code));
}
[Theory]
[InlineData("abcd")]
[InlineData("ABC123")]
[InlineData("a1b2c3d4e5")]
public void Accepts_valid_code_formats(string code)
{
Assert.True(GetBootstrapScriptFunction.IsValidCodeFormat(code));
}
// ── Catalog registration: anonymous (OOBE devices have no JWT, no cert),
// JWT-exempt via the catalog-derived rule. Unregistered routes fail closed,
// so this pins reachability. ──
[Fact]
public void Route_is_registered_as_PublicAnonymous()
{
var entry = EndpointAccessPolicyCatalog.FindPolicy("GET", Route);
Assert.NotNull(entry);
Assert.Equal(EndpointPolicy.PublicAnonymous, entry!.Policy);
}
[Fact]
public void Route_is_jwt_exempt()
{
Assert.True(AuthenticationMiddleware.SkipsJwtValidation("GET", Route));
}
// ── The script body inlines the bearer token — must never be cached. ──
[Fact]
public void Route_is_classified_no_store()
{
Assert.True(NoStoreCacheMiddleware.IsSensitive(Route));
}
// ── AFD-aware rate-limit extraction: X-Azure-ClientIP is honored only when
// X-Azure-FDID carries EXACTLY our profile ID. Multi-value headers mean
// someone appended to what AFD set — untrusted, fail closed. ──
private const string Fdid = "3490f60d-3639-495f-902e-681b9324d2ba";
[Theory]
[InlineData(new[] { Fdid }, true)]
[InlineData(new[] { "3490F60D-3639-495F-902E-681B9324D2BA" }, true)] // case-insensitive
[InlineData(new[] { " " + Fdid + " " }, true)] // trimmed
[InlineData(new[] { "other-profile-id" }, false)]
[InlineData(new[] { Fdid, Fdid }, false)] // two headers
[InlineData(new[] { Fdid + "," + Fdid }, false)] // comma-appended
[InlineData(new[] { "spoofed," + Fdid }, false)]
[InlineData(new string[0], false)]
[InlineData(new[] { "" }, false)]
public void FdidMatches_requires_exactly_our_profile_id(string[] headerValues, bool expected)
{
Assert.Equal(expected, ClientIpExtractor.FdidMatches(headerValues, Fdid));
}
}
/// <summary>
/// Pins the customer-facing bootstrap URL produced by CreateBootstrapSessionFunction —
/// previously unguarded, which made a silent origin change possible. The literal here
/// is a deliberate independent oracle per the url-registry doctrine (test files are
/// excluded from HardcodedUrlGuardTests).
/// </summary>
public class CreateBootstrapSessionUrlShapeTests
{
[Fact]
public void BootstrapGoBaseUrl_is_the_go_domain()
{
Assert.Equal("https://go.autopilotmonitor.com", Constants.BootstrapGoBaseUrl);
}
// ── Notification session deep link (Teams/Slack/webhook "Open session"): the
// query-string shape the static-export portal understands. Literal is a
// deliberate independent oracle; the legacy /sessions/{id} path shape is
// rewritten client-side by the portal forever. ──
[Fact]
public void PortalSessionUrl_is_the_query_string_shape()
{
Assert.Equal(
"https://portal.autopilotmonitor.com/sessions?id=abc-123",
Constants.PortalSessionUrl("abc-123"));
}
[Fact]
public void PortalSessionUrl_escapes_the_session_id()
{
Assert.Equal(
"https://portal.autopilotmonitor.com/sessions?id=a%26b%3Dc",
Constants.PortalSessionUrl("a&b=c"));
}
[Fact]
public void CreateBootstrapSession_builds_the_url_from_the_go_constant()
{
// Source-level pin (the URL expression sits inside the HTTP entrypoint with no
// injectable seam): the create function must derive BootstrapUrl from
// BootstrapGoBaseUrl + "/{shortCode}" — not from WebsiteBaseUrl, and without a
// path segment between host and code.
var repoRoot = FindRepoRoot();
var source = File.ReadAllText(Path.Combine(
repoRoot, "src", "Backend", "AutopilotMonitor.Functions",
"Functions", "Bootstrap", "CreateBootstrapSessionFunction.cs"));
Assert.Contains("$\"{Constants.BootstrapGoBaseUrl}/{session.ShortCode}\"", source);
Assert.DoesNotContain("WebsiteBaseUrl}/go/", source);
}
private static string FindRepoRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
{
dir = dir.Parent;
}
Assert.NotNull(dir);
return dir!.FullName;
}
}
@@ -26,6 +26,7 @@ public class HardcodedUrlGuardTests
"www.autopilotmonitor.com",
"docs.autopilotmonitor.com",
"download.autopilotmonitor.com",
"go.autopilotmonitor.com",
"mcp.autopilotmonitor.com",
"autopilotmonitor-api-eu.azurewebsites.net",
"autopilotmonitor.blob.core.windows.net",
@@ -0,0 +1,112 @@
using AutopilotMonitor.Functions.Functions.Bootstrap;
using AutopilotMonitor.Functions.Security;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pins the OOBE bootstrap script generator, ported from the portal's
/// app/go/[code]/route.ts. The golden test asserts byte parity (modulo line
/// endings) with the original TypeScript template output — the reference file
/// TestData/oobe-bootstrap-golden.ps1 was captured by evaluating the TS template
/// literal with the same fixed inputs. Ports the behavioral cases of the vitest
/// suite app/go/[code]/__tests__/route.test.ts that concern script generation.
/// </summary>
public class OobeBootstrapScriptGeneratorTests
{
private const string TenantId = "11111111-1111-1111-1111-111111111111";
private const string Token = "22222222-2222-2222-2222-222222222222";
private const string DownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip";
private static BootstrapScriptValueValidator.ValidatedValues FixedValues() =>
new(TenantId, Token, DownloadUrl,
new DateTime(2026, 8, 1, 12, 0, 0, DateTimeKind.Utc));
private static string Generate() =>
OobeBootstrapScriptGenerator.GenerateSuccessScript(
FixedValues(),
new DateTime(2026, 7, 28, 12, 0, 0, DateTimeKind.Utc));
// ── Golden parity with the original Next.js template ──
[Fact]
public void SuccessScript_matches_the_typescript_reference_output()
{
var goldenPath = Path.Combine(AppContext.BaseDirectory, "TestData", "oobe-bootstrap-golden.ps1");
var golden = File.ReadAllText(goldenPath);
var actual = Generate();
// Normalize line endings on both sides: git checkout / editors may flip
// LF↔CRLF in either the .cs raw string or the golden asset; PowerShell
// accepts both. Content parity is what the test pins.
static string Normalize(string s) => s.Replace("\r\n", "\n");
Assert.Equal(Normalize(golden), Normalize(actual));
}
// ── Success-path markers (vitest parity) ──
[Fact]
public void SuccessScript_contains_the_expected_markers()
{
var script = Generate();
Assert.StartsWith("#Requires -RunAsAdministrator", script);
Assert.Contains($"--tenant-id '{TenantId}'", script);
Assert.Contains($"--bootstrap-token '{Token}'", script);
Assert.Contains($"$AgentDownloadUrl = '{DownloadUrl}'", script);
Assert.Contains("Generated: 2026-07-28T12:00:00.000Z", script);
Assert.Contains("Expires: 2026-08-01T12:00:00.000Z", script);
Assert.Contains("https://portal.autopilotmonitor.com", script);
}
// ── SECURITY INVARIANT: every substituted value sits inside a single-quoted
// PS literal (single-quoted strings do not expand $(), $var, or backticks).
// The expiry timestamp appears twice: once in the plain-text comment block
// and once quoted in the Write-Log line. ──
[Fact]
public void SuccessScript_frames_all_interpolated_values_in_single_quotes()
{
var script = Generate();
Assert.Contains($"'{DownloadUrl}'", script);
Assert.Contains($"'{Token}'", script);
Assert.Contains($"'{TenantId}'", script);
Assert.Contains("'Bootstrap token expires: 2026-08-01T12:00:00.000Z'", script);
}
// ── Error script: message surfaces via Write-Host, always as a script ──
[Fact]
public void ErrorScript_contains_the_message()
{
var script = OobeBootstrapScriptGenerator.GenerateErrorScript("Invalid bootstrap code format.");
Assert.Contains("ERROR: Invalid bootstrap code format.", script);
Assert.Contains("Write-Host", script);
}
[Fact]
public void ErrorScript_doubles_single_quotes_for_powershell()
{
var script = OobeBootstrapScriptGenerator.GenerateErrorScript("it's a 'bad' code");
Assert.Contains("it''s a ''bad'' code", script);
}
// ── Cap is applied to the RAW message before quote-escaping, so a
// quote-heavy message cannot double past the cap. ──
[Fact]
public void ErrorScript_caps_the_message_at_200_chars_before_escaping()
{
var script = OobeBootstrapScriptGenerator.GenerateErrorScript(new string('X', 300));
Assert.Contains(new string('X', 200) + "...", script);
Assert.DoesNotContain(new string('X', 201), script);
}
[Fact]
public void ErrorScript_cap_counts_raw_length_not_escaped_length()
{
var script = OobeBootstrapScriptGenerator.GenerateErrorScript(new string('\'', 300));
// 200 raw quotes escape to 400 — allowed, because the cap is pre-escape.
Assert.Contains(new string('\'', 400) + "...", script);
}
}
@@ -0,0 +1,244 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services.Diagnostics;
using AutopilotMonitor.Shared.Models;
using Azure;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The diagnostics-archive copier preserves a session's diag ZIP with a session report.
/// Its contract: NEVER throw (a failed copy must not block the report), re-verify the
/// hosted tenant prefix (a tampered Sessions row must not reach foreign blobs), and honour
/// the same size cap as the download proxy. Tests pin every status branch via a recording
/// subclass — no Azurite, no HTTP.
/// </summary>
public class SessionReportDiagnosticsArchiveCopierTests
{
private const string TenantA = "11111111-1111-1111-1111-111111111111";
private const string TenantB = "22222222-2222-2222-2222-222222222222";
private const string SessionId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
private const string HostedBlob = $"{TenantA}/AgentDiagnostics-{SessionId}-20260729T120000.zip";
private const string CustomerBlob = $"AgentDiagnostics-{SessionId}-20260729T120000.zip";
private const string DestBlob = $"{TenantA}_{SessionId}_diag_archive_20260729_120000.zip";
[Theory]
[InlineData(null)]
[InlineData("")]
public async Task CopyAsync_NoSource_FailsSoft_NoStorageCalls(string? source)
{
var copier = new RecordingCopier();
var result = await copier.CopyAsync(TenantA, SessionId, source, DestBlob);
Assert.False(result.Success);
Assert.Equal(SessionReportDiagnosticsArchiveCopier.Statuses.FailedNoDiagnostics, result.Status);
Assert.Equal(0, copier.HostedOpenCalls + copier.CustomerOpenCalls + copier.UploadCalls);
}
[Fact]
public async Task CopyAsync_HostedBlobOfForeignTenant_Rejected_WithoutRead()
{
// Spoof guard: even if the Sessions row was tampered to point at another tenant's
// hosted blob, the classifier re-checks the prefix against the validated tenant.
var copier = new RecordingCopier();
var foreignBlob = $"{TenantB}/AgentDiagnostics-{SessionId}-x.zip";
var result = await copier.CopyAsync(TenantA, SessionId, foreignBlob, DestBlob);
Assert.False(result.Success);
Assert.Equal(SessionReportDiagnosticsArchiveCopier.Statuses.FailedInvalidBlobName, result.Status);
Assert.Equal(0, copier.HostedOpenCalls + copier.CustomerOpenCalls + copier.UploadCalls);
}
[Fact]
public async Task CopyAsync_Hosted_HappyPath_UploadsWithFlatName()
{
var copier = new RecordingCopier { SourceSizeBytes = 1234 };
var result = await copier.CopyAsync(TenantA, SessionId, HostedBlob, DestBlob);
Assert.True(result.Success);
Assert.Equal(SessionReportDiagnosticsArchiveCopier.Statuses.Copied, result.Status);
Assert.Equal(1234, result.SizeBytes);
Assert.Equal(1, copier.HostedOpenCalls);
Assert.Equal(HostedBlob, copier.HostedOpenLastPath);
Assert.Equal(1, copier.UploadCalls);
Assert.Equal(DestBlob, copier.UploadLastName);
Assert.DoesNotContain("/", copier.UploadLastName);
}
[Fact]
public async Task CopyAsync_CustomerSas_HappyPath_BuildsSasBlobUrl()
{
var copier = new RecordingCopier
{
SourceSizeBytes = 42,
CustomerSasUrl = "https://customer.blob/diag?sv=2024&sp=r&sig=x",
};
var result = await copier.CopyAsync(TenantA, SessionId, CustomerBlob, DestBlob);
Assert.True(result.Success);
Assert.Equal(1, copier.CustomerOpenCalls);
Assert.Equal(
$"https://customer.blob/diag/{CustomerBlob}?sv=2024&sp=r&sig=x",
copier.CustomerOpenLastUri!.ToString());
Assert.Equal(1, copier.UploadCalls);
}
[Fact]
public async Task CopyAsync_CustomerSas_NoSasConfigured_FailsSoft()
{
var copier = new RecordingCopier { CustomerSasUrl = null };
var result = await copier.CopyAsync(TenantA, SessionId, CustomerBlob, DestBlob);
Assert.False(result.Success);
Assert.Equal(SessionReportDiagnosticsArchiveCopier.Statuses.FailedSasNotConfigured, result.Status);
Assert.Equal(0, copier.CustomerOpenCalls + copier.UploadCalls);
}
[Fact]
public async Task CopyAsync_SourceAboveSizeCap_Rejected_NoUpload()
{
var copier = new RecordingCopier
{
MaxDownloadSizeMB = 1,
SourceSizeBytes = 2 * 1024 * 1024,
};
var result = await copier.CopyAsync(TenantA, SessionId, HostedBlob, DestBlob);
Assert.False(result.Success);
Assert.Equal(SessionReportDiagnosticsArchiveCopier.Statuses.FailedTooLarge, result.Status);
Assert.Equal(2 * 1024 * 1024, result.SizeBytes);
Assert.Equal(0, copier.UploadCalls);
}
[Fact]
public async Task CopyAsync_SizeCapZero_MeansUnlimited()
{
var copier = new RecordingCopier
{
MaxDownloadSizeMB = 0,
SourceSizeBytes = 5L * 1024 * 1024 * 1024,
};
var result = await copier.CopyAsync(TenantA, SessionId, HostedBlob, DestBlob);
Assert.True(result.Success);
Assert.Equal(1, copier.UploadCalls);
}
[Theory]
[InlineData(404, SessionReportDiagnosticsArchiveCopier.Statuses.FailedSourceNotFound)]
[InlineData(403, SessionReportDiagnosticsArchiveCopier.Statuses.FailedSasReadDenied)]
[InlineData(500, SessionReportDiagnosticsArchiveCopier.Statuses.FailedError)]
public async Task CopyAsync_RequestFailed_MapsStatus_WithoutThrowing(int httpStatus, string expected)
{
var copier = new RecordingCopier
{
HostedOpenException = new RequestFailedException(httpStatus, $"storage error {httpStatus}"),
};
var result = await copier.CopyAsync(TenantA, SessionId, HostedBlob, DestBlob);
Assert.False(result.Success);
Assert.Equal(expected, result.Status);
Assert.Equal(0, copier.UploadCalls);
}
[Fact]
public async Task CopyAsync_Timeout_MapsToTimeoutStatus()
{
var copier = new RecordingCopier
{
HostedOpenException = new OperationCanceledException(),
};
var result = await copier.CopyAsync(TenantA, SessionId, HostedBlob, DestBlob);
Assert.False(result.Success);
Assert.Equal(SessionReportDiagnosticsArchiveCopier.Statuses.FailedTimeout, result.Status);
}
[Fact]
public async Task CopyAsync_UploadFailure_FailsSoft_AsError()
{
// Read succeeded but the write into session-reports blew up — still fail-soft.
var copier = new RecordingCopier
{
UploadException = new InvalidOperationException("container gone"),
};
var result = await copier.CopyAsync(TenantA, SessionId, HostedBlob, DestBlob);
Assert.False(result.Success);
Assert.Equal(SessionReportDiagnosticsArchiveCopier.Statuses.FailedError, result.Status);
}
/// <summary>
/// Overrides every storage/config seam with an in-memory recording version, so the
/// tests exercise the REAL CopyAsync routing (classify → config → open → cap → upload)
/// without touching Azure.
/// </summary>
private sealed class RecordingCopier : SessionReportDiagnosticsArchiveCopier
{
public int MaxDownloadSizeMB { get; set; } = 500;
public long SourceSizeBytes { get; set; } = 100;
public string? CustomerSasUrl { get; set; }
public Exception? HostedOpenException { get; set; }
public Exception? UploadException { get; set; }
public int HostedOpenCalls { get; private set; }
public string? HostedOpenLastPath { get; private set; }
public int CustomerOpenCalls { get; private set; }
public Uri? CustomerOpenLastUri { get; private set; }
public int UploadCalls { get; private set; }
public string? UploadLastName { get; private set; }
protected override Task<AdminConfiguration> GetAdminConfigurationAsync()
=> Task.FromResult(new AdminConfiguration
{
MaxDiagnosticsDownloadSizeMB = MaxDownloadSizeMB,
DiagnosticsDownloadTimeoutSeconds = 120,
});
protected override Task<TenantConfiguration> GetTenantConfigurationAsync(string tenantId)
=> Task.FromResult(new TenantConfiguration
{
DiagnosticsBlobSasUrl = CustomerSasUrl!,
});
protected override Task<(Stream Content, long ContentLength)> OpenHostedSourceAsync(
string blobPath, CancellationToken cancellationToken)
{
HostedOpenCalls++;
HostedOpenLastPath = blobPath;
if (HostedOpenException != null) throw HostedOpenException;
return Task.FromResult<(Stream, long)>((new MemoryStream(new byte[8]), SourceSizeBytes));
}
protected override Task<(Stream Content, long ContentLength)> OpenCustomerSourceAsync(
Uri blobUri, CancellationToken cancellationToken)
{
CustomerOpenCalls++;
CustomerOpenLastUri = blobUri;
return Task.FromResult<(Stream, long)>((new MemoryStream(new byte[8]), SourceSizeBytes));
}
protected override Task UploadToReportsContainerAsync(
string destinationBlobName, Stream content, CancellationToken cancellationToken)
{
UploadCalls++;
UploadLastName = destinationBlobName;
if (UploadException != null) throw UploadException;
return Task.CompletedTask;
}
}
}
@@ -0,0 +1,39 @@
using AutopilotMonitor.Functions.Services;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Customer-supplied attachment file names go verbatim into ZIP entry names of the
/// session-report archive. The sanitizer must strip path components so a crafted name
/// cannot produce traversal-shaped entries that an extraction tool would write outside
/// the target directory.
/// </summary>
public class SessionReportZipEntryNameTests
{
[Theory]
[InlineData("agent.log", "agent.log")]
[InlineData("agent_20260729.log", "agent_20260729.log")]
[InlineData("logs/agent.log", "agent.log")]
[InlineData("..\\..\\evil.exe", "evil.exe")]
[InlineData("../../../etc/passwd", "passwd")]
[InlineData("C:\\Windows\\System32\\bad.dll", "bad.dll")]
[InlineData(" spaced.log ", "spaced.log")]
public void SanitizeZipEntryName_StripsPathComponents(string input, string expected)
{
Assert.Equal(expected, SessionReportService.SanitizeZipEntryName(input, "fallback.log"));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("..")]
[InlineData(".")]
[InlineData("logs/")]
[InlineData("a/../")]
public void SanitizeZipEntryName_FallsBackWhenNothingUsable(string? input)
{
Assert.Equal("fallback.log", SessionReportService.SanitizeZipEntryName(input, "fallback.log"));
}
}
@@ -0,0 +1,108 @@
#Requires -RunAsAdministrator
<#
.SYNOPSIS
Autopilot Monitor OOBE Bootstrap
Generated: 2026-07-28T12:00:00.000Z
Expires: 2026-08-01T12:00:00.000Z
This script was generated by the Autopilot Monitor platform.
It installs the monitoring agent before Intune enrollment begins,
enabling full session capture from the start of OOBE.
#>
$ErrorActionPreference = "Stop"
$AgentDownloadUrl = 'https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip'
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
$AgentBinPath = "$AgentBasePath\Agent"
$AgentLogPath = "$AgentBasePath\Logs"
$LogFile = "$AgentLogPath\oobe_bootstrap.log"
# Create directories
New-Item -Path $AgentBasePath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
New-Item -Path $AgentBinPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
New-Item -Path $AgentLogPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
function Write-Log {
param([string]$Message)
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$logMessage = "[$timestamp] $Message"
Write-Host $logMessage
Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
}
try {
Write-Log "===== Autopilot Monitor OOBE Bootstrap ====="
Write-Log 'Bootstrap token expires: 2026-08-01T12:00:00.000Z'
# Check if agent is already installed
$agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe"
if (Test-Path $agentExePath) {
Write-Log "Agent already installed at $agentExePath -- skipping download"
}
else {
# Download agent ZIP
$zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent.zip"
Write-Log "Downloading agent from $AgentDownloadUrl..."
$maxDownloadAttempts = 3
$downloadAttempt = 0
do {
$downloadAttempt++
try {
Write-Log "Download attempt $downloadAttempt/$maxDownloadAttempts"
Invoke-WebRequest -Uri $AgentDownloadUrl -OutFile $zipPath -UseBasicParsing -TimeoutSec 60 -ErrorAction Stop
Write-Log "Downloaded agent to $zipPath"
break
}
catch {
if ($downloadAttempt -ge $maxDownloadAttempts) { throw }
$retryDelays = @(2, 4, 8)
$delay = $retryDelays[$downloadAttempt - 1]
Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${delay}s..."
Start-Sleep -Seconds $delay
}
} while ($downloadAttempt -lt $maxDownloadAttempts)
# Extract
Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath -Force
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
Write-Log "Extracted agent to $AgentBinPath"
if (-not (Test-Path $agentExePath)) {
throw "Agent executable not found after extraction at $agentExePath"
}
}
# Install agent with bootstrap token
Write-Log "Installing agent with bootstrap token..."
& $agentExePath --install --no-auth --bootstrap-token '22222222-2222-2222-2222-222222222222' --tenant-id '11111111-1111-1111-1111-111111111111'
$installExitCode = $LASTEXITCODE
if ($installExitCode -ne 0) {
throw "Agent install failed with exit code $installExitCode"
}
Write-Log "===== OOBE Bootstrap Complete ====="
Write-Log "Agent is now monitoring. View progress at https://portal.autopilotmonitor.com"
Write-Log ""
Write-Log "The agent will:"
Write-Log " - Monitor enrollment from the very start"
Write-Log " - Switch to certificate auth after Intune enrollment"
Write-Log " - Self-destruct when enrollment completes"
Write-Host ""
Write-Host "Autopilot Monitor agent installed successfully!" -ForegroundColor Green
Write-Host "View enrollment progress at https://portal.autopilotmonitor.com" -ForegroundColor Cyan
Write-Host ""
}
catch {
Write-Log "===== OOBE Bootstrap FAILED ====="
Write-Log "ERROR: $($_.Exception.Message)"
Write-Log "Stack trace: $($_.ScriptStackTrace)"
Write-Host ""
Write-Host "ERROR: $($_.Exception.Message)" -ForegroundColor Red
Write-Host "Check log file: $LogFile" -ForegroundColor Yellow
Write-Host ""
exit 1
}
@@ -213,6 +213,13 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
["ReportType"] = string.IsNullOrEmpty(metadata.ReportType) ? ReportTypes.Session : metadata.ReportType
};
// Diagnostics-copy columns only exist on rows where the copy was requested —
// legacy-shaped rows stay untouched and map back to null.
if (metadata.DiagnosticsBlobName != null)
entity["DiagnosticsBlobName"] = metadata.DiagnosticsBlobName;
if (metadata.DiagnosticsCopyStatus != null)
entity["DiagnosticsCopyStatus"] = metadata.DiagnosticsCopyStatus;
await _reportsTableClient.UpsertEntityAsync(entity);
return true;
}
@@ -365,8 +372,15 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
SubmittedBy = entity.GetString("SubmittedBy") ?? string.Empty,
SubmittedAt = entity.GetDateTimeOffset("SubmittedAt")?.UtcDateTime ?? DateTime.MinValue,
AdminNote = entity.GetString("AdminNote") ?? string.Empty,
ReportType = string.IsNullOrEmpty(reportType) ? ReportTypes.Session : reportType
ReportType = string.IsNullOrEmpty(reportType) ? ReportTypes.Session : reportType,
DiagnosticsBlobName = NullIfEmpty(entity.GetString("DiagnosticsBlobName")),
DiagnosticsCopyStatus = NullIfEmpty(entity.GetString("DiagnosticsCopyStatus"))
};
}
private static string? NullIfEmpty(string? value)
{
return string.IsNullOrEmpty(value) ? null : value;
}
}
}
@@ -55,11 +55,11 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
public Task<List<Dictionary<string, object>>> GetSoftwareInventoryAsync(string tenantId)
=> _storage.GetSoftwareInventoryAsync(tenantId);
public async Task<List<Dictionary<string, object>>> GetAllSoftwareInventoryAsync()
public async Task<List<Dictionary<string, object>>> GetAllSoftwareInventoryAsync(IEnumerable<string>? selectColumns = null)
{
var tableClient = _storage.GetTableClient(Constants.TableNames.SoftwareInventory);
var results = new List<Dictionary<string, object>>();
await foreach (var entity in tableClient.QueryAsync<TableEntity>(maxPerPage: 1000))
await foreach (var entity in tableClient.QueryAsync<TableEntity>(maxPerPage: 1000, select: selectColumns))
{
var item = new Dictionary<string, object>();
foreach (var prop in entity)
@@ -93,9 +93,10 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
{
Success = true,
ShortCode = session.ShortCode,
// www directly — the bare apex is only a registrar-level 301 to www,
// so pointing there just adds a redirect hop for every bootstrap.
BootstrapUrl = $"{Constants.WebsiteBaseUrl}/go/{session.ShortCode}",
// go domain — Front Door rewrites /{code} to /api/bootstrap/go/{code}
// (GetBootstrapScriptFunction). URLs issued before the migration keep
// working on the legacy www /go route until the static-export cutover.
BootstrapUrl = $"{Constants.BootstrapGoBaseUrl}/{session.ShortCode}",
ExpiresAt = session.ExpiresAt,
Message = $"Bootstrap session created. Valid for {validityHours} hours."
};
@@ -0,0 +1,142 @@
using System;
using System.Net;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Bootstrap
{
/// <summary>
/// GET /api/bootstrap/go/{code} — the OOBE bootstrap script endpoint.
/// Customer-facing as <c>irm https://go.autopilotmonitor.com/{code} | iex</c>
/// (Front Door rewrites /{code} on the go domain to this route). Successor of the
/// portal's Next.js /go/[code] route, moved here so the portal can become a static
/// export; validation happens in-process (BootstrapSessionService), no HTTP hop.
///
/// Contract: ALWAYS HTTP 200 with text/plain — error conditions return a small
/// PowerShell script whose Write-Host surfaces the message, so irm | iex shows the
/// error on the OOBE console instead of crashing on a non-2xx status.
/// Cache-Control: no-store comes from NoStoreCacheMiddleware (the script embeds the
/// bearer token); X-Content-Type-Options: nosniff from SecurityHeadersMiddleware.
/// </summary>
public class GetBootstrapScriptFunction
{
private readonly ILogger<GetBootstrapScriptFunction> _logger;
private readonly BootstrapSessionService _bootstrapService;
private readonly RateLimitService _rateLimitService;
private readonly TenantConfigurationService _configService;
// Same gate the portal route enforced: 4-10 alphanumeric chars, checked before
// any service call. Stricter than the length-only check in
// ValidateBootstrapCodeFunction on purpose.
private static readonly Regex CodeFormatRegex = new(
"^[a-z0-9]{4,10}$",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
internal static bool IsValidCodeFormat(string? code) =>
!string.IsNullOrEmpty(code) && CodeFormatRegex.IsMatch(code);
public GetBootstrapScriptFunction(
ILogger<GetBootstrapScriptFunction> logger,
BootstrapSessionService bootstrapService,
RateLimitService rateLimitService,
TenantConfigurationService configService)
{
_logger = logger;
_bootstrapService = bootstrapService;
_rateLimitService = rateLimitService;
_configService = configService;
}
[Function("GetBootstrapScript")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "bootstrap/go/{code}")] HttpRequestData req,
string code)
{
try
{
// Rate limit by source IP (prevent brute-force enumeration of short codes).
// Own bucket, deliberately not shared with bootstrap-validate. AFD-aware
// extraction: traffic arrives via go.autopilotmonitor.com, where the trusted
// hop is Front Door egress — X-Azure-ClientIP is honored under FDID proof.
// On breach the response is still a 200 error script (irm | iex must surface
// the message); Retry-After is informational for non-iex clients.
var clientIp = ClientIpExtractor.GetRateLimitClientIp(req);
var rateLimitResult = _rateLimitService.CheckRateLimit($"bootstrap-script:{clientIp}", 20); // 20 req/min
if (!rateLimitResult.IsAllowed)
{
_logger.LogWarning("Bootstrap script rate limit hit from IP {ClientIp}", clientIp);
var limited = await ScriptResponseAsync(
req, OobeBootstrapScriptGenerator.GenerateErrorScript("Rate limit exceeded. Try again later."));
if (rateLimitResult.RetryAfter.HasValue)
limited.Headers.Add("Retry-After", ((int)rateLimitResult.RetryAfter.Value.TotalSeconds).ToString());
return limited;
}
if (!IsValidCodeFormat(code))
{
return await ScriptResponseAsync(
req, OobeBootstrapScriptGenerator.GenerateErrorScript("Invalid bootstrap code format."));
}
var session = await _bootstrapService.ValidateCodeAsync(code);
if (session == null)
{
_logger.LogWarning("Bootstrap script request failed for code {Code} from IP {ClientIp}", code, clientIp);
return await ScriptResponseAsync(
req, OobeBootstrapScriptGenerator.GenerateErrorScript("Bootstrap code not found, expired, or revoked."));
}
// Feature gate — same generic message as not-found so a disabled tenant is
// indistinguishable from an unknown code (no enumeration oracle).
var tenantConfig = await _configService.GetConfigurationAsync(session.TenantId);
if (!tenantConfig.BootstrapTokenEnabled)
{
_logger.LogWarning("Bootstrap script for code {Code} rejected — feature disabled for tenant {TenantId}", code, session.TenantId);
return await ScriptResponseAsync(
req, OobeBootstrapScriptGenerator.GenerateErrorScript("Bootstrap code not found, expired, or revoked."));
}
var agentDownloadUrl = $"{Constants.AgentDownloadBaseUrl}/{Constants.AgentZipFileName}";
var expiresAtUtc = session.ExpiresAt.Kind == DateTimeKind.Unspecified
? DateTime.SpecifyKind(session.ExpiresAt, DateTimeKind.Utc)
: session.ExpiresAt.ToUniversalTime();
if (!BootstrapScriptValueValidator.TryValidate(
session.TenantId, session.Token, agentDownloadUrl, expiresAtUtc,
out var values, out var reason))
{
// Log the failure category only — never the offending value, to keep
// potential attack payloads out of Application Insights.
_logger.LogWarning("Bootstrap script value validation failed for code {Code}: {Reason}", code, reason);
return await ScriptResponseAsync(
req, OobeBootstrapScriptGenerator.GenerateErrorScript("Bootstrap response failed validation. Contact support."));
}
var script = OobeBootstrapScriptGenerator.GenerateSuccessScript(values, DateTime.UtcNow);
return await ScriptResponseAsync(req, script);
}
catch (Exception ex)
{
// Never inline exception detail into the script — it would execute on the
// OOBE console and could leak internals.
_logger.LogError(ex, "Error generating bootstrap script for code {Code}", code);
return await ScriptResponseAsync(
req, OobeBootstrapScriptGenerator.GenerateErrorScript("Failed to generate bootstrap script. Please try again."));
}
}
private static async Task<HttpResponseData> ScriptResponseAsync(HttpRequestData req, string script)
{
var response = req.CreateResponse(HttpStatusCode.OK);
response.Headers.Add("Content-Type", "text/plain; charset=utf-8");
await response.WriteStringAsync(script);
return response;
}
}
}
@@ -0,0 +1,179 @@
using System;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Shared;
namespace AutopilotMonitor.Functions.Functions.Bootstrap
{
/// <summary>
/// Generates the OOBE bootstrap PowerShell script served at /api/bootstrap/go/{code}
/// (customer-facing: irm https://go.autopilotmonitor.com/{code} | iex).
/// Ported 1:1 from the portal's app/go/[code]/route.ts when the endpoint moved into
/// the backend for the static-export migration.
/// </summary>
public static class OobeBootstrapScriptGenerator
{
/// <summary>
/// Hard cap on error messages embedded in the generated PS script. Defense
/// against an upstream bug or compromise emitting a huge message that would
/// otherwise be inlined verbatim into the script body.
/// </summary>
internal const int MaxErrorMessageLength = 200;
/// <summary>
/// Builds the always-HTTP-200 error script: irm | iex executes it and the
/// Write-Host surfaces the message on the OOBE console.
/// </summary>
public static string GenerateErrorScript(string message)
{
// Cap length BEFORE quote-escaping so the cap is on raw input, not the
// post-escape size (which can double for '-heavy strings).
var truncated = message.Length > MaxErrorMessageLength
? message.Substring(0, MaxErrorMessageLength) + "..."
: message;
var safeMsg = truncated.Replace("'", "''");
return $"Write-Host ''; Write-Host 'ERROR: {safeMsg}' -ForegroundColor Red; Write-Host ''";
}
// SECURITY INVARIANT: All template parameters MUST come from
// BootstrapScriptValueValidator.TryValidate. The single-quoted PowerShell
// string literals below are the second line of defense against injection
// (single-quoted PS strings do NOT expand $(...), $var, or backticks).
// Do NOT change to double-quoted without re-reviewing the validator's
// character classes end-to-end.
//
// The template is a raw string with __TOKEN__ placeholders substituted via
// Replace — deliberately NOT string interpolation or string.Format, because
// the script body is full of PowerShell $vars and {braces}.
public static string GenerateSuccessScript(
BootstrapScriptValueValidator.ValidatedValues values,
DateTime generatedAtUtc)
{
// "yyyy-MM-ddTHH:mm:ss.fffZ" — byte parity with JS toISOString(), which the
// previous portal implementation used for both timestamps. Inputs are UTC by
// contract; no ToUniversalTime() — it would shift Unspecified-kind values
// (e.g. from table storage) by the server's local offset.
var generatedAt = generatedAtUtc.ToString("yyyy-MM-ddTHH:mm:ss.fff'Z'");
var expiresAt = values.ExpiresAtUtc.ToString("yyyy-MM-ddTHH:mm:ss.fff'Z'");
return Template
.Replace("__GENERATED_AT__", generatedAt)
.Replace("__EXPIRES_AT__", expiresAt)
.Replace("__AGENT_DOWNLOAD_URL__", values.AgentDownloadUrl)
.Replace("__BOOTSTRAP_TOKEN__", values.Token)
.Replace("__TENANT_ID__", values.TenantId)
.Replace("__PORTAL_URL__", Constants.PortalBaseUrl);
}
private const string Template = """
#Requires -RunAsAdministrator
<#
.SYNOPSIS
Autopilot Monitor OOBE Bootstrap
Generated: __GENERATED_AT__
Expires: __EXPIRES_AT__
This script was generated by the Autopilot Monitor platform.
It installs the monitoring agent before Intune enrollment begins,
enabling full session capture from the start of OOBE.
#>
$ErrorActionPreference = "Stop"
$AgentDownloadUrl = '__AGENT_DOWNLOAD_URL__'
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
$AgentBinPath = "$AgentBasePath\Agent"
$AgentLogPath = "$AgentBasePath\Logs"
$LogFile = "$AgentLogPath\oobe_bootstrap.log"
# Create directories
New-Item -Path $AgentBasePath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
New-Item -Path $AgentBinPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
New-Item -Path $AgentLogPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
function Write-Log {
param([string]$Message)
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$logMessage = "[$timestamp] $Message"
Write-Host $logMessage
Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
}
try {
Write-Log "===== Autopilot Monitor OOBE Bootstrap ====="
Write-Log 'Bootstrap token expires: __EXPIRES_AT__'
# Check if agent is already installed
$agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe"
if (Test-Path $agentExePath) {
Write-Log "Agent already installed at $agentExePath -- skipping download"
}
else {
# Download agent ZIP
$zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent.zip"
Write-Log "Downloading agent from $AgentDownloadUrl..."
$maxDownloadAttempts = 3
$downloadAttempt = 0
do {
$downloadAttempt++
try {
Write-Log "Download attempt $downloadAttempt/$maxDownloadAttempts"
Invoke-WebRequest -Uri $AgentDownloadUrl -OutFile $zipPath -UseBasicParsing -TimeoutSec 60 -ErrorAction Stop
Write-Log "Downloaded agent to $zipPath"
break
}
catch {
if ($downloadAttempt -ge $maxDownloadAttempts) { throw }
$retryDelays = @(2, 4, 8)
$delay = $retryDelays[$downloadAttempt - 1]
Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${delay}s..."
Start-Sleep -Seconds $delay
}
} while ($downloadAttempt -lt $maxDownloadAttempts)
# Extract
Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath -Force
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
Write-Log "Extracted agent to $AgentBinPath"
if (-not (Test-Path $agentExePath)) {
throw "Agent executable not found after extraction at $agentExePath"
}
}
# Install agent with bootstrap token
Write-Log "Installing agent with bootstrap token..."
& $agentExePath --install --no-auth --bootstrap-token '__BOOTSTRAP_TOKEN__' --tenant-id '__TENANT_ID__'
$installExitCode = $LASTEXITCODE
if ($installExitCode -ne 0) {
throw "Agent install failed with exit code $installExitCode"
}
Write-Log "===== OOBE Bootstrap Complete ====="
Write-Log "Agent is now monitoring. View progress at __PORTAL_URL__"
Write-Log ""
Write-Log "The agent will:"
Write-Log " - Monitor enrollment from the very start"
Write-Log " - Switch to certificate auth after Intune enrollment"
Write-Log " - Self-destruct when enrollment completes"
Write-Host ""
Write-Host "Autopilot Monitor agent installed successfully!" -ForegroundColor Green
Write-Host "View enrollment progress at __PORTAL_URL__" -ForegroundColor Cyan
Write-Host ""
}
catch {
Write-Log "===== OOBE Bootstrap FAILED ====="
Write-Log "ERROR: $($_.Exception.Message)"
Write-Log "Stack trace: $($_.ScriptStackTrace)"
Write-Host ""
Write-Host "ERROR: $($_.Exception.Message)" -ForegroundColor Red
Write-Host "Check log file: $LogFile" -ForegroundColor Yellow
Write-Host ""
exit 1
}
""";
}
}
@@ -13,7 +13,9 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
{
/// <summary>
/// GET /api/bootstrap/validate/{code} — Validate a bootstrap code and return the token.
/// Anonymous endpoint — called by the Next.js /go/{code} route (server-side).
/// Anonymous endpoint — legacy consumer is the Next.js /go/{code} route (alive for
/// pre-migration URLs until the static-export cutover); the canonical script path
/// is GetBootstrapScriptFunction, which validates in-process instead.
/// Rate-limited by IP to prevent brute-force enumeration.
/// </summary>
public class ValidateBootstrapCodeFunction
@@ -326,7 +326,8 @@ public class AuthFunction
_ = _globalNotificationService.CreateNotificationAsync(
"preview_signup",
"New Preview Signup",
$"Tenant {tenantId} ({domain}), UPN: {upn}");
$"Tenant {tenantId} ({domain}), UPN: {upn}",
href: $"/admin/tenants/management?tenantId={Uri.EscapeDataString(tenantId)}");
}
/// <summary>
@@ -118,19 +118,15 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
return req.CreateResponse(HttpStatusCode.OK);
// Gate 4: Rate limiting (IP + tenant + global circuit breaker).
// ClientIpExtractor returns the rightmost X-Forwarded-For hop (the one
// App Service appended) — never the leftmost, which is caller-controlled
// and would let a single attacker rotate past the per-IP throttle.
var clientIp = ClientIpExtractor.GetTrustedClientIp(req);
// One extraction serves both the bucket key and the stored forensic
// SourceIp: rightmost X-Forwarded-For hop (host-appended, never the
// caller-controlled leftmost), with X-Azure-ClientIP honored only behind
// a verified Front Door FDID — direct callers cannot spoof either value.
var clientIp = ClientIpExtractor.GetRateLimitClientIp(req);
var rateLimitResult = _rateLimitService.Check(clientIp, tenantId);
if (!rateLimitResult.IsAllowed)
return req.CreateResponse(HttpStatusCode.OK);
// For the STORED forensic SourceIp, prefer the real client egress IP. The trusted
// hop above (used as the un-spoofable rate-limit key) is Front Door's own egress IP
// behind Front Door, not the device — which is why stored SourceIp was unusable.
var sourceIp = ClientIpExtractor.GetClientEgressIp(req);
// Gate 5: Tenant existence check (cached — cheap O(1) lookup)
var (_, exists) = await _tenantConfigService.TryGetConfigurationAsync(tenantId);
if (!exists)
@@ -207,7 +203,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
Message = message,
AgentTimestamp = report.Timestamp,
IngestedAt = DateTime.UtcNow,
SourceIp = sourceIp,
SourceIp = clientIp,
CertSourceState = certSourceState,
CertThumbprint = certThumbprint,
CertSubject = certSubject,
@@ -318,7 +314,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
["HttpStatusCode"] = report.HttpStatusCode?.ToString() ?? string.Empty,
["Message"] = message ?? string.Empty,
["AgentTimestamp"] = report.Timestamp.ToString("O"),
["SourceIp"] = sourceIp ?? string.Empty,
["SourceIp"] = clientIp,
["CertSourceState"] = certSourceState ?? string.Empty,
["CertThumbprint"] = certThumbprint ?? string.Empty,
["CertSubject"] = certSubject ?? string.Empty,
@@ -137,7 +137,8 @@ namespace AutopilotMonitor.Functions.Functions.Reports
_ = _globalNotificationService.CreateNotificationAsync(
"diag_files_report",
"New Diag Files Report",
$"{userIdentifier} (Tenant: {request.TenantId})");
$"{userIdentifier} (Tenant: {request.TenantId})",
href: $"/admin/reports/session-reports?reportId={Uri.EscapeDataString(metadata.ReportId)}");
_logger.LogInformation("Diag-files report submitted: ReportId={ReportId}, By={User}",
metadata.ReportId, userIdentifier);
@@ -125,7 +125,9 @@ namespace AutopilotMonitor.Functions.Functions.Reports
{ "HasComment", (!string.IsNullOrEmpty(request.Comment)).ToString() },
{ "HasEmail", (!string.IsNullOrEmpty(request.Email)).ToString() },
{ "HasScreenshot", (!string.IsNullOrEmpty(request.ScreenshotBase64)).ToString() },
{ "HasAgentLog", (!string.IsNullOrEmpty(request.AgentLogBase64)).ToString() }
{ "HasAgentLog", (!string.IsNullOrEmpty(request.AgentLogBase64)).ToString() },
{ "IncludeDiagnostics", request.IncludeDiagnostics.ToString() },
{ "DiagnosticsCopyStatus", metadata.DiagnosticsCopyStatus ?? string.Empty }
}
);
}
@@ -138,7 +140,8 @@ namespace AutopilotMonitor.Functions.Functions.Reports
_ = _globalNotificationService.CreateNotificationAsync(
"session_report",
"New Session Report",
$"{userIdentifier} — Session {sessionId} (Tenant: {request.TenantId})");
$"{userIdentifier} — Session {sessionId} (Tenant: {request.TenantId})",
href: $"/admin/reports/session-reports?reportId={Uri.EscapeDataString(metadata.ReportId)}");
_logger.LogInformation("Session report submitted: ReportId={ReportId}, Session={SessionId}, By={User}",
metadata.ReportId, sessionId, userIdentifier);
@@ -352,7 +352,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
if (startChannels.Count == 0)
return;
var sessionUrl = $"{Constants.PortalBaseUrl}/sessions/{sessionId}";
var sessionUrl = Constants.PortalSessionUrl(sessionId);
var alert = NotificationAlertBuilder.BuildEnrollmentStartedAlert(
session.DeviceName,
session.SerialNumber,
@@ -57,8 +57,16 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
if (int.TryParse(query["take"], out var parsedTake) && parsedTake >= 1)
take = Math.Min(parsedTake, MaxTake);
// Query all SoftwareInventory entries across tenants and filter for unmatched (no CpeUri)
var allEntities = await _vulnRepo.GetAllSoftwareInventoryAsync();
// Query all SoftwareInventory entries across tenants and filter for unmatched (no CpeUri).
// Server-side filtering on CpeUri is NOT possible: rows re-created by the cascade-delete
// 404 path carry no CpeUri property at all, and Table Storage cannot express
// "property missing" — such rows must still count as unmatched. Project to the columns
// this aggregation reads instead of materializing full entities.
var allEntities = await _vulnRepo.GetAllSoftwareInventoryAsync(new[]
{
"CpeUri", "NormalizedName", "DisplayName", "Publisher", "SessionCount",
"LastSeenAt", "LastSessionId", "NormalizedVendor", "NormalizedVersion"
});
// Load the persistent ignore list to exclude ignored software
var ignoredEntities = await _vulnRepo.GetCpeMappingsByPartitionAsync("cpe_ignored");
@@ -57,6 +57,7 @@ public class NoStoreCacheMiddleware : IFunctionsWorkerMiddleware
private static readonly string[] _prefixRoutes =
{
"/api/bootstrap/validate/", // /api/bootstrap/validate/{code} — Bearer token!
"/api/bootstrap/go/", // /api/bootstrap/go/{code} — script body inlines the bearer token!
"/api/config/", // /api/config/{tenantId} + sub-routes
"/api/sessions/", // /api/sessions/{id} + sub-routes (events, signals, …)
"/api/search/", // /api/search/{quick,sessions,sessions-by-event,sessions-by-cve}
@@ -270,6 +270,7 @@ builder.Services.AddSingleton<BlobStorageService>();
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.Diagnostics.HostedDiagnosticsBlobService>();
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.Diagnostics.DiagnosticsBlobStreamer>();
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.Diagnostics.DiagnosticsBlobCascadeDeleter>();
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.Diagnostics.SessionReportDiagnosticsArchiveCopier>();
builder.Services.AddSingleton<SessionReportService>();
builder.Services.AddSingleton<BootstrapSessionService>();
@@ -0,0 +1,131 @@
using System;
using System.Linq;
using System.Text.RegularExpressions;
using AutopilotMonitor.Shared;
namespace AutopilotMonitor.Functions.Security
{
/// <summary>
/// Validates the values interpolated into the OOBE bootstrap PowerShell script.
///
/// SECURITY-CRITICAL: All validated values are interpolated into a PowerShell
/// script that runs as SYSTEM during Windows OOBE (see OobeBootstrapScriptGenerator).
/// Every rule below was chosen to block PS metacharacters ($, backticks, ", ', #>,
/// newline) so that even if the second-line defense (single-quoted PS string
/// literals) is accidentally loosened, the validator still prevents injection.
/// Ported from the portal's utils/bootstrapValidation.ts when the /go route moved
/// into the backend; kept even though values are now produced in-process —
/// defense-in-depth against tampered table data or a service bug. Do NOT relax any
/// rule without re-reviewing OobeBootstrapScriptGenerator end-to-end.
/// </summary>
public static class BootstrapScriptValueValidator
{
public enum ValidationFailure
{
TenantId,
Token,
AgentDownloadUrl,
ExpiresAt,
}
public readonly record struct ValidatedValues(
string TenantId,
string Token,
string AgentDownloadUrl,
DateTime ExpiresAtUtc);
// Byte-identical to AGENT_PATH_RE in the portal's bootstrapValidation.ts:
// fixed /agent/ prefix, no leading dot, ≤80-char filename, .zip suffix.
private static readonly Regex AgentPathRegex = new(
@"^/agent/[A-Za-z0-9_-][A-Za-z0-9._-]{0,79}\.zip$",
RegexOptions.Compiled);
private static readonly Regex PrintableAsciiRegex = new(
@"^[\x20-\x7E]+$",
RegexOptions.Compiled);
// Mirror of AGENT_DOWNLOAD_HOSTNAMES in the portal's utils/config.ts —
// derived from the Constants registry, never repeated as literals.
private static readonly string[] AllowedDownloadHosts =
{
new Uri(Constants.AgentDownloadBaseUrl).Host,
new Uri(Constants.AgentBlobBaseUrl).Host,
};
private static readonly TimeSpan MaxExpiryWindow = TimeSpan.FromDays(14);
public static bool TryValidate(
string tenantId,
string token,
string agentDownloadUrl,
DateTime expiresAtUtc,
out ValidatedValues values,
out ValidationFailure failure)
{
values = default;
if (!IsGuidExact(tenantId))
{
failure = ValidationFailure.TenantId;
return false;
}
if (!IsGuidExact(token))
{
failure = ValidationFailure.Token;
return false;
}
if (!IsValidAgentDownloadUrl(agentDownloadUrl))
{
failure = ValidationFailure.AgentDownloadUrl;
return false;
}
var nowUtc = DateTime.UtcNow;
if (expiresAtUtc <= nowUtc || expiresAtUtc > nowUtc + MaxExpiryWindow)
{
failure = ValidationFailure.ExpiresAt;
return false;
}
values = new ValidatedValues(tenantId, token, agentDownloadUrl, expiresAtUtc);
failure = default;
return true;
}
/// <summary>
/// Exactly 36 chars in canonical "D" format (8-4-4-4-12 hex with hyphens).
/// Guid.TryParseExact alone would accept surrounding whitespace via trim —
/// the length check keeps the character class airtight.
/// </summary>
private static bool IsGuidExact(string value)
{
return value is { Length: 36 }
&& Guid.TryParseExact(value, "D", out _);
}
private static bool IsValidAgentDownloadUrl(string value)
{
if (string.IsNullOrEmpty(value) || value.Length > 256)
return false;
if (!PrintableAsciiRegex.IsMatch(value))
return false;
if (!Uri.TryCreate(value, UriKind.Absolute, out var url))
return false;
if (url.Scheme != Uri.UriSchemeHttps)
return false;
if (!AllowedDownloadHosts.Contains(url.Host, StringComparer.OrdinalIgnoreCase))
return false;
if (!string.IsNullOrEmpty(url.UserInfo))
return false;
if (!url.IsDefaultPort)
return false;
if (!string.IsNullOrEmpty(url.Query) || !string.IsNullOrEmpty(url.Fragment))
return false;
if (!AgentPathRegex.IsMatch(url.AbsolutePath))
return false;
return true;
}
}
}
@@ -1,5 +1,9 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Text.Json;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
namespace AutopilotMonitor.Functions.Security
@@ -26,34 +30,140 @@ namespace AutopilotMonitor.Functions.Security
public static string GetTrustedClientIp(HttpRequestData? req)
{
if (req == null) return Unknown;
if (!req.Headers.TryGetValues("X-Forwarded-For", out var values)) return Unknown;
// If a request carries multiple X-Forwarded-For headers (rare but legal),
// concatenate them so the rightmost entry across all headers wins.
return ExtractTrustedHop(string.Join(",", values));
if (req.Headers.TryGetValues("X-Forwarded-For", out var values))
{
// If a request carries multiple X-Forwarded-For headers (rare but legal),
// concatenate them so the rightmost entry across all headers wins.
var hop = ExtractTrustedHop(string.Join(",", values));
if (hop != Unknown) return hop;
}
// Flex Consumption strips the X-Forwarded-* family from the HTTP request the
// host proxies to the isolated worker (Azure/azure-functions-host#10253/#11254;
// observed 2026-07-28: every anonymous caller keyed as "unknown"). The host does
// pass the ORIGINAL header set out-of-band as trigger metadata, so recover the
// rightmost XFF hop from there — same trust model, different transport.
var bindingHop = ExtractTrustedHop(
GetHeaderFromBindingDataJson(TryGetBindingHeadersJson(req), "X-Forwarded-For"));
if (bindingHop != Unknown) return bindingHop;
// Last resort: the transport-level peer address — unspoofable, and equal to the
// rightmost XFF hop when no proxy chain exists. On Flex the worker's peer is the
// host's local proxy, so loopback means "not a client" and must not be stored or
// used as a bucket key (a stored 127.0.0.1 is forensically misleading).
var remote = req.FunctionContext.GetHttpContext()?.Connection.RemoteIpAddress;
return RemoteIpToString(remote);
}
/// <summary>
/// Best-effort REAL client egress IP for forensic/diagnostic STORAGE (e.g. distress-report
/// SourceIp, audit fields). NOT for rate-limit keys — use <see cref="GetTrustedClientIp"/>
/// there, because the value below is spoofable when no trusted proxy populates it.
/// <para>
/// Behind Azure Front Door the trusted rightmost X-Forwarded-For hop is Front Door's own
/// egress IP, not the device — so a stored SourceIp taken from it is useless for identifying
/// origin. Front Door sets the true client IP in <c>X-Azure-ClientIP</c>; prefer that and
/// fall back to the trusted hop when absent (no Front Door in front).
/// </para>
/// Normalizes a transport-level peer address into a client-IP string:
/// null and loopback (the Functions host's local proxy on Flex) yield
/// <see cref="Unknown"/>; IPv4-mapped IPv6 is folded back to IPv4.
/// </summary>
public static string GetClientEgressIp(HttpRequestData? req)
internal static string RemoteIpToString(IPAddress? remote)
{
if (remote == null) return Unknown;
if (remote.IsIPv4MappedToIPv6) remote = remote.MapToIPv4();
if (IPAddress.IsLoopback(remote)) return Unknown;
return remote.ToString();
}
private static string? TryGetBindingHeadersJson(HttpRequestData req)
{
try
{
return req.FunctionContext.BindingContext.BindingData.TryGetValue("Headers", out var raw)
? raw as string
: null;
}
catch
{
// Fail-soft: absent/odd trigger metadata must never break request handling.
return null;
}
}
/// <summary>
/// Reads one header (case-insensitive) from the JSON object the Functions host
/// places in BindingData["Headers"] — the request headers as the HOST saw them,
/// before the worker-bound proxy hop drops the X-Forwarded-* family.
/// Returns null for missing/malformed input.
/// </summary>
internal static string? GetHeaderFromBindingDataJson(string? headersJson, string headerName)
{
if (string.IsNullOrEmpty(headersJson)) return null;
try
{
using var doc = JsonDocument.Parse(headersJson);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
foreach (var prop in doc.RootElement.EnumerateObject())
{
if (string.Equals(prop.Name, headerName, StringComparison.OrdinalIgnoreCase)
&& prop.Value.ValueKind == JsonValueKind.String)
{
return prop.Value.GetString();
}
}
return null;
}
catch (JsonException)
{
return null;
}
}
/// <summary>
/// Finest-grained trustworthy client IP — used both as the rate-limit key for
/// endpoints reached through Azure Front Door (e.g. go.autopilotmonitor.com →
/// /api/bootstrap/go) and as the stored forensic SourceIp (distress reports).
/// Behind AFD the trusted hop is AFD's egress IP, so all callers would share a
/// handful of buckets — one abuser could starve every legitimate technician. AFD
/// carries the true client IP in X-Azure-ClientIP; it is honored ONLY when the
/// request provably traversed OUR profile: X-Azure-FDID (which AFD overwrites on
/// pass-through, so a client cannot inject it across AFD) must equal the
/// FrontDoorProfileId app setting. The same gate keeps stored forensic IPs
/// unspoofable when the Function App is reached directly, where anyone could
/// send an X-Azure-ClientIP header. Fail-closed: unset setting,
/// missing/mismatched/multi-valued FDID, or empty client IP all fall back to
/// <see cref="GetTrustedClientIp"/>.
/// </summary>
public static string GetRateLimitClientIp(HttpRequestData? req)
{
if (req == null) return Unknown;
if (req.Headers.TryGetValues("X-Azure-ClientIP", out var azValues))
var expectedFdid = Environment.GetEnvironmentVariable("FrontDoorProfileId");
if (!string.IsNullOrWhiteSpace(expectedFdid)
&& req.Headers.TryGetValues("X-Azure-FDID", out var fdidValues)
&& FdidMatches(fdidValues, expectedFdid)
&& req.Headers.TryGetValues("X-Azure-ClientIP", out var clientIpValues))
{
var ip = ExtractTrustedHop(string.Join(",", azValues));
var ip = ExtractTrustedHop(string.Join(",", clientIpValues));
if (ip != Unknown) return ip;
}
return GetTrustedClientIp(req);
}
/// <summary>
/// Exactly one FDID value, equal to the configured profile ID. Multiple values
/// mean someone appended to what AFD set — treated as untrusted.
/// </summary>
internal static bool FdidMatches(IEnumerable<string> headerValues, string expected)
{
string? single = null;
foreach (var raw in headerValues)
{
foreach (var part in raw.Split(','))
{
var trimmed = part.Trim();
if (trimmed.Length == 0) continue;
if (single != null) return false;
single = trimmed;
}
}
return single != null && string.Equals(single, expected, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Takes the rightmost non-empty entry from an X-Forwarded-For header value and
/// strips any port suffix or IPv6 brackets. Returns <see cref="Unknown"/> when
@@ -165,6 +165,9 @@ public static class EndpointAccessPolicyCatalog
new("GET", "health", EndpointPolicy.PublicAnonymous),
new("GET", "stats/platform", EndpointPolicy.PublicAnonymous),
new("GET", "bootstrap/validate/{code}", EndpointPolicy.PublicAnonymous),
// OOBE bootstrap script (irm go.autopilotmonitor.com/{code} | iex — Front Door
// rewrites onto this route). Always-200 text/plain; self-rate-limited per IP.
new("GET", "bootstrap/go/{code}", EndpointPolicy.PublicAnonymous),
new("POST", "agent/distress", EndpointPolicy.PublicAnonymous),
// Ticket-gated diagnostics download (MCP/AI clients with no JWT). The HMAC-signed
// ticket is the sole authority — authz happened at mint time (diagnostics/download-ticket,
@@ -177,7 +177,7 @@ namespace AutopilotMonitor.Functions.Services.Analyze
return;
var session = await _sessionRepo.GetSessionAsync(envelope.TenantId, envelope.SessionId).ConfigureAwait(false);
var sessionUrl = $"{Constants.PortalBaseUrl}/sessions/{envelope.SessionId}";
var sessionUrl = Constants.PortalSessionUrl(envelope.SessionId);
foreach (var (result, rule) in candidates)
{
@@ -114,7 +114,8 @@ namespace AutopilotMonitor.Functions.Services
}
/// <summary>
/// Validates a bootstrap code (anonymous, called by the /go/{code} route).
/// Validates a bootstrap code (anonymous; called by GetBootstrapScriptFunction
/// in-process and by the legacy validate endpoint / Next.js /go route).
/// Returns the session if the code is valid and not expired/revoked.
/// </summary>
public async Task<BootstrapSession?> ValidateCodeAsync(string shortCode)
@@ -199,7 +199,7 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics
public static string? ValidateBlobName(string? rawBlobName, string tenantId)
=> DiagnosticsDownloadFunction.ClassifyBlobName(rawBlobName, tenantId).Reason;
private static string BuildCustomerBlobUrl(string containerSasUrl, string blobName)
internal static string BuildCustomerBlobUrl(string containerSasUrl, string blobName)
{
var questionMarkIndex = containerSasUrl.IndexOf('?');
if (questionMarkIndex >= 0)
@@ -0,0 +1,229 @@
using AutopilotMonitor.Functions.Functions.Diagnostics;
using AutopilotMonitor.Shared.Models;
using Azure;
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace AutopilotMonitor.Functions.Services.Diagnostics
{
/// <summary>
/// Outcome of a diagnostics-archive copy. <see cref="Status"/> is one of the
/// <see cref="SessionReportDiagnosticsArchiveCopier.Statuses"/> constants and is persisted
/// verbatim on the report row so operators can see why no archive is attached.
/// </summary>
public sealed record DiagnosticsArchiveCopyResult(bool Success, string Status, long? SizeBytes);
/// <summary>
/// Copies a session's diagnostics ZIP into the durable <c>session-reports</c> container when
/// a customer opts in while submitting a session report. Session diagnostics blobs are
/// deleted with the session (user delete / retention); report blobs are never pruned — the
/// copy is what keeps the archive available for later debugging.
/// <para>
/// Fail-soft by contract: this class never throws. Every failure maps to a
/// <c>Failed:*</c> status so the report submission itself always proceeds.
/// Reads reuse the two security-reviewed diagnostics read paths
/// (<see cref="HostedDiagnosticsBlobService"/> for Hosted, the tenant's stored container SAS
/// for CustomerSas) and are guarded by the same admin-configured size cap + timeout as the
/// download proxy (<see cref="DiagnosticsBlobStreamer"/>).
/// </para>
/// </summary>
public class SessionReportDiagnosticsArchiveCopier
{
/// <summary>Persisted DiagnosticsCopyStatus values.</summary>
public static class Statuses
{
public const string Copied = "Copied";
public const string FailedNoDiagnostics = "Failed:NoDiagnostics";
public const string FailedSessionNotFound = "Failed:SessionNotFound";
public const string FailedInvalidBlobName = "Failed:InvalidBlobName";
public const string FailedTooLarge = "Failed:TooLarge";
public const string FailedSourceNotFound = "Failed:SourceNotFound";
public const string FailedSasReadDenied = "Failed:SasReadDenied";
public const string FailedSasNotConfigured = "Failed:SasNotConfigured";
public const string FailedTimeout = "Failed:Timeout";
public const string FailedError = "Failed:Error";
}
// Same container as the report ZIPs themselves (flat namespace, never pruned).
private const string ReportsContainerName = "session-reports";
private readonly TenantConfigurationService _configService;
private readonly AdminConfigurationService _adminConfigService;
private readonly HostedDiagnosticsBlobService _hostedDiagnostics;
private readonly BlobStorageService _blobStorage;
private readonly ILogger<SessionReportDiagnosticsArchiveCopier> _logger;
public SessionReportDiagnosticsArchiveCopier(
TenantConfigurationService configService,
AdminConfigurationService adminConfigService,
HostedDiagnosticsBlobService hostedDiagnostics,
BlobStorageService blobStorage,
ILogger<SessionReportDiagnosticsArchiveCopier> logger)
{
_configService = configService;
_adminConfigService = adminConfigService;
_hostedDiagnostics = hostedDiagnostics;
_blobStorage = blobStorage;
_logger = logger;
}
/// <summary>
/// Test seam: Azure-free construction for recording subclasses that override the
/// virtual storage/config seams below.
/// </summary>
protected SessionReportDiagnosticsArchiveCopier()
{
_configService = null!;
_adminConfigService = null!;
_hostedDiagnostics = null!;
_blobStorage = null!;
_logger = NullLogger<SessionReportDiagnosticsArchiveCopier>.Instance;
}
/// <summary>
/// Streams the session's diagnostics blob (<paramref name="sourceBlobName"/>, the value
/// of the Sessions row's <c>DiagnosticsBlobName</c>) into
/// <paramref name="destinationBlobName"/> in the session-reports container.
/// <paramref name="tenantId"/> must already be the validated tenant — the blob-name
/// classifier re-checks the Hosted prefix against it, so a tampered row cannot reach
/// another tenant's blobs.
/// </summary>
public virtual async Task<DiagnosticsArchiveCopyResult> CopyAsync(
string tenantId,
string sessionId,
string? sourceBlobName,
string destinationBlobName,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(sourceBlobName))
return new DiagnosticsArchiveCopyResult(false, Statuses.FailedNoDiagnostics, null);
try
{
var (destination, classifyErr) = DiagnosticsDownloadFunction.ClassifyBlobName(sourceBlobName, tenantId);
if (classifyErr != null)
{
_logger.LogWarning(
"DiagnosticsArchiveCopier: rejecting source blob {Blob} for tenant {TenantId}, session {SessionId}: {Reason}",
sourceBlobName, tenantId, sessionId, classifyErr);
return new DiagnosticsArchiveCopyResult(false, Statuses.FailedInvalidBlobName, null);
}
var adminConfig = await GetAdminConfigurationAsync();
var maxSizeBytes = (long)adminConfig.MaxDiagnosticsDownloadSizeMB * 1024 * 1024;
var timeoutSeconds = adminConfig.DiagnosticsDownloadTimeoutSeconds;
using var cts = timeoutSeconds > 0
? new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds))
: new CancellationTokenSource();
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, cancellationToken);
long contentLength;
Stream content;
if (destination == DiagnosticsDownloadFunction.BlobDestination.Hosted)
{
(content, contentLength) = await OpenHostedSourceAsync(sourceBlobName, linked.Token);
}
else
{
var tenantConfig = await GetTenantConfigurationAsync(tenantId);
if (string.IsNullOrEmpty(tenantConfig.DiagnosticsBlobSasUrl))
return new DiagnosticsArchiveCopyResult(false, Statuses.FailedSasNotConfigured, null);
var blobUrl = DiagnosticsBlobStreamer.BuildCustomerBlobUrl(tenantConfig.DiagnosticsBlobSasUrl, sourceBlobName);
(content, contentLength) = await OpenCustomerSourceAsync(new Uri(blobUrl), linked.Token);
}
using (content)
{
// Same fast-reject as the download proxy: cap of 0 means unlimited.
if (maxSizeBytes > 0 && contentLength > maxSizeBytes)
{
_logger.LogWarning(
"DiagnosticsArchiveCopier: blob {Blob} for tenant {TenantId} rejected — size {SizeBytes} exceeds limit {MaxSizeBytes}",
sourceBlobName, tenantId, contentLength, maxSizeBytes);
return new DiagnosticsArchiveCopyResult(false, Statuses.FailedTooLarge, contentLength);
}
await UploadToReportsContainerAsync(destinationBlobName, content, linked.Token);
}
_logger.LogInformation(
"DiagnosticsArchiveCopier: copied {SourceBlob} ({SizeBytes} bytes) to {DestinationBlob} for tenant {TenantId}, session {SessionId}",
sourceBlobName, contentLength, destinationBlobName, tenantId, sessionId);
return new DiagnosticsArchiveCopyResult(true, Statuses.Copied, contentLength);
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
_logger.LogWarning(
"DiagnosticsArchiveCopier: source blob {Blob} not found for tenant {TenantId}, session {SessionId}",
sourceBlobName, tenantId, sessionId);
return new DiagnosticsArchiveCopyResult(false, Statuses.FailedSourceNotFound, null);
}
catch (RequestFailedException ex) when (ex.Status == 403)
{
_logger.LogWarning(
"DiagnosticsArchiveCopier: read denied for blob {Blob}, tenant {TenantId} — SAS likely lacks read permission",
sourceBlobName, tenantId);
return new DiagnosticsArchiveCopyResult(false, Statuses.FailedSasReadDenied, null);
}
catch (OperationCanceledException)
{
_logger.LogWarning(
"DiagnosticsArchiveCopier: copy of {Blob} for tenant {TenantId} timed out",
sourceBlobName, tenantId);
return new DiagnosticsArchiveCopyResult(false, Statuses.FailedTimeout, null);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"DiagnosticsArchiveCopier: copy of {Blob} for tenant {TenantId}, session {SessionId} failed",
sourceBlobName, tenantId, sessionId);
return new DiagnosticsArchiveCopyResult(false, Statuses.FailedError, null);
}
}
// -------- Test seams (production defaults hit Azure / real services) --------
/// <summary>Fetches the platform admin configuration (size cap + timeout).</summary>
protected virtual Task<AdminConfiguration> GetAdminConfigurationAsync()
=> _adminConfigService.GetConfigurationAsync();
/// <summary>Fetches the tenant configuration (CustomerSas URL).</summary>
protected virtual Task<TenantConfiguration> GetTenantConfigurationAsync(string tenantId)
=> _configService.GetConfigurationAsync(tenantId);
/// <summary>Opens a read stream over a Hosted-destination source blob.</summary>
protected virtual async Task<(Stream Content, long ContentLength)> OpenHostedSourceAsync(
string blobPath, CancellationToken cancellationToken)
{
var download = await _hostedDiagnostics.OpenReadAsync(blobPath, cancellationToken);
return (download.Value.Content, download.Value.Details.ContentLength);
}
/// <summary>Opens a read stream over a CustomerSas source blob.</summary>
protected virtual async Task<(Stream Content, long ContentLength)> OpenCustomerSourceAsync(
Uri blobUri, CancellationToken cancellationToken)
{
var blobClient = new BlobClient(blobUri);
var download = await blobClient.DownloadStreamingAsync(cancellationToken: cancellationToken);
return (download.Value.Content, download.Value.Details.ContentLength);
}
/// <summary>Uploads the source stream into the session-reports container.</summary>
protected virtual async Task UploadToReportsContainerAsync(
string destinationBlobName, Stream content, CancellationToken cancellationToken)
{
var containerClient = _blobStorage.GetContainerClient(ReportsContainerName);
await containerClient.CreateIfNotExistsAsync(cancellationToken: cancellationToken);
var blobClient = containerClient.GetBlobClient(destinationBlobName);
await blobClient.UploadAsync(content, new BlobUploadOptions
{
HttpHeaders = new BlobHttpHeaders { ContentType = "application/zip" }
}, cancellationToken);
}
}
}
@@ -36,7 +36,7 @@ namespace AutopilotMonitor.Functions.Services
return;
var sessionUrl = updatedSession != null
? $"{Constants.PortalBaseUrl}/sessions/{request.SessionId}"
? Constants.PortalSessionUrl(request.SessionId)
: null;
// A failure alert requires an actual failure-ish verdict: an agent_timeout
@@ -2,9 +2,11 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Azure.Storage.Blobs.Models;
using AutopilotMonitor.Functions.Services.Diagnostics;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Pagination;
@@ -23,16 +25,28 @@ namespace AutopilotMonitor.Functions.Services
private readonly INotificationRepository _notificationRepo;
private readonly ILogger<SessionReportService> _logger;
private readonly BlobStorageService _blobStorage;
private readonly TableStorageService _tableStorage;
private readonly SessionReportDiagnosticsArchiveCopier _diagnosticsArchiveCopier;
private readonly TenantConfigurationService _configService;
private readonly IRuleRepository _ruleRepo;
private const string ContainerName = "session-reports";
public SessionReportService(
INotificationRepository notificationRepo,
BlobStorageService blobStorage,
TableStorageService tableStorage,
SessionReportDiagnosticsArchiveCopier diagnosticsArchiveCopier,
TenantConfigurationService configService,
IRuleRepository ruleRepo,
ILogger<SessionReportService> logger)
{
_notificationRepo = notificationRepo;
_logger = logger;
_blobStorage = blobStorage;
_tableStorage = tableStorage;
_diagnosticsArchiveCopier = diagnosticsArchiveCopier;
_configService = configService;
_ruleRepo = ruleRepo;
}
/// <summary>
@@ -47,6 +61,45 @@ namespace AutopilotMonitor.Functions.Services
var timestamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss");
var blobName = $"{request.TenantId}_{request.SessionId}_diag_request_{timestamp}.zip";
// 0. Optional: preserve the session's diagnostics ZIP alongside the report.
// Session diag blobs die with the session (user delete / retention); the copy in
// the session-reports container does not. Fail-soft: a failed copy never blocks
// the report — the status lands on the row + in report-metadata.json instead.
// The source blob name/destination is recorded too: when the copy failed, the
// operator can still fetch the original while the session is alive.
string? copiedDiagnosticsBlobName = null;
string? diagnosticsCopyStatus = null;
string? sourceDiagnosticsBlobName = null;
string? sourceDiagnosticsDestination = null;
if (request.IncludeDiagnostics)
{
var session = await _tableStorage.GetSessionAsync(request.TenantId, request.SessionId);
if (session == null)
{
diagnosticsCopyStatus = SessionReportDiagnosticsArchiveCopier.Statuses.FailedSessionNotFound;
}
else
{
sourceDiagnosticsBlobName = session.DiagnosticsBlobName;
sourceDiagnosticsDestination = session.DiagnosticsBlobDestination;
var destinationName = $"{request.TenantId}_{request.SessionId}_diag_archive_{timestamp}.zip";
var copyResult = await _diagnosticsArchiveCopier.CopyAsync(
request.TenantId, request.SessionId, session.DiagnosticsBlobName, destinationName);
diagnosticsCopyStatus = copyResult.Status;
if (copyResult.Success)
copiedDiagnosticsBlobName = destinationName;
}
}
// Tenant-side detection context (custom rule/pattern state at report time — it may
// have changed by the time an operator investigates). Fail-soft; never blocks.
var tenantContext = await CollectTenantContextAsync(request.TenantId, reportId);
// Decode attachments up front so report-metadata.json can record their outcome
// instead of silently dropping invalid payloads.
var (screenshotBytes, screenshotStatus) = DecodeAttachment(request.ScreenshotBase64, reportId, "screenshot");
var (agentLogBytes, agentLogStatus) = DecodeAttachment(request.AgentLogBase64, reportId, "agent log");
// 1. Create ZIP in memory
using var zipStream = new MemoryStream();
using (var archive = new ZipArchive(zipStream, ZipArchiveMode.Create, leaveOpen: true))
@@ -84,42 +137,46 @@ namespace AutopilotMonitor.Functions.Services
request.Comment,
request.Email,
submittedBy,
submittedAt = DateTime.UtcNow.ToString("O")
submittedAt = DateTime.UtcNow.ToString("O"),
export = new
{
exportedEventCount = request.ExportedEventCount,
sessionEventCount = request.SessionEventCount,
eventStreamActive = request.EventStreamActive
},
includedDiagnostics = new
{
requested = request.IncludeDiagnostics,
blobName = copiedDiagnosticsBlobName,
status = diagnosticsCopyStatus,
sourceBlobName = sourceDiagnosticsBlobName,
sourceDestination = sourceDiagnosticsDestination
},
attachments = new
{
screenshot = screenshotStatus,
agentLog = agentLogStatus
},
tenantContext
});
// Optional screenshot
if (!string.IsNullOrEmpty(request.ScreenshotBase64))
if (screenshotBytes != null)
{
try
{
var screenshotBytes = Convert.FromBase64String(request.ScreenshotBase64);
var ext = Path.GetExtension(request.ScreenshotFileName ?? ".png");
if (string.IsNullOrEmpty(ext)) ext = ".png";
var entry = archive.CreateEntry($"screenshot{ext}", CompressionLevel.Optimal);
using var entryStream = entry.Open();
await entryStream.WriteAsync(screenshotBytes);
}
catch (FormatException ex)
{
_logger.LogWarning(ex, "Invalid base64 screenshot data in report {ReportId}", reportId);
}
var ext = Path.GetExtension(request.ScreenshotFileName ?? ".png");
if (string.IsNullOrEmpty(ext)) ext = ".png";
var entry = archive.CreateEntry($"screenshot{ext}", CompressionLevel.Optimal);
using var entryStream = entry.Open();
await entryStream.WriteAsync(screenshotBytes);
}
// Optional agent log file (max 5 MB enforced by frontend)
if (!string.IsNullOrEmpty(request.AgentLogBase64))
if (agentLogBytes != null)
{
try
{
var logBytes = Convert.FromBase64String(request.AgentLogBase64);
var logFileName = request.AgentLogFileName ?? "agent.log";
var entry = archive.CreateEntry(logFileName, CompressionLevel.Optimal);
using var entryStream = entry.Open();
await entryStream.WriteAsync(logBytes);
}
catch (FormatException ex)
{
_logger.LogWarning(ex, "Invalid base64 agent log data in report {ReportId}", reportId);
}
var logFileName = SanitizeZipEntryName(request.AgentLogFileName, "agent.log");
var entry = archive.CreateEntry(logFileName, CompressionLevel.Optimal);
using var entryStream = entry.Open();
await entryStream.WriteAsync(agentLogBytes);
}
}
@@ -149,7 +206,9 @@ namespace AutopilotMonitor.Functions.Services
BlobName = blobName,
SubmittedBy = submittedBy,
SubmittedAt = now,
ReportType = ReportTypes.Session
ReportType = ReportTypes.Session,
DiagnosticsBlobName = copiedDiagnosticsBlobName,
DiagnosticsCopyStatus = diagnosticsCopyStatus
};
await _notificationRepo.StoreSessionReportMetadataAsync(metadata);
@@ -171,6 +230,9 @@ namespace AutopilotMonitor.Functions.Services
var timestamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss");
var blobName = $"{request.TenantId}_diag_files_{timestamp}.zip";
var (screenshotBytes, screenshotStatus) = DecodeAttachment(request.ScreenshotBase64, reportId, "screenshot");
var (agentLogBytes, agentLogStatus) = DecodeAttachment(request.AgentLogBase64, reportId, "log payload");
using var zipStream = new MemoryStream();
using (var archive = new ZipArchive(zipStream, ZipArchiveMode.Create, leaveOpen: true))
{
@@ -182,42 +244,29 @@ namespace AutopilotMonitor.Functions.Services
request.Comment,
request.Email,
submittedBy,
submittedAt = DateTime.UtcNow.ToString("O")
submittedAt = DateTime.UtcNow.ToString("O"),
attachments = new
{
screenshot = screenshotStatus,
agentLog = agentLogStatus
}
});
if (!string.IsNullOrEmpty(request.ScreenshotBase64))
if (screenshotBytes != null)
{
try
{
var screenshotBytes = Convert.FromBase64String(request.ScreenshotBase64);
var ext = Path.GetExtension(request.ScreenshotFileName ?? ".png");
if (string.IsNullOrEmpty(ext)) ext = ".png";
var entry = archive.CreateEntry($"screenshot{ext}", CompressionLevel.Optimal);
using var entryStream = entry.Open();
await entryStream.WriteAsync(screenshotBytes);
}
catch (FormatException ex)
{
_logger.LogWarning(ex, "Invalid base64 screenshot data in diag-files report {ReportId}", reportId);
}
var ext = Path.GetExtension(request.ScreenshotFileName ?? ".png");
if (string.IsNullOrEmpty(ext)) ext = ".png";
var entry = archive.CreateEntry($"screenshot{ext}", CompressionLevel.Optimal);
using var entryStream = entry.Open();
await entryStream.WriteAsync(screenshotBytes);
}
if (!string.IsNullOrEmpty(request.AgentLogBase64))
if (agentLogBytes != null)
{
try
{
var logBytes = Convert.FromBase64String(request.AgentLogBase64);
var logFileName = string.IsNullOrEmpty(request.AgentLogFileName)
? "diag-files.bin"
: request.AgentLogFileName;
var entry = archive.CreateEntry(logFileName, CompressionLevel.Optimal);
using var entryStream = entry.Open();
await entryStream.WriteAsync(logBytes);
}
catch (FormatException ex)
{
_logger.LogWarning(ex, "Invalid base64 log payload in diag-files report {ReportId}", reportId);
}
var logFileName = SanitizeZipEntryName(request.AgentLogFileName, "diag-files.bin");
var entry = archive.CreateEntry(logFileName, CompressionLevel.Optimal);
using var entryStream = entry.Open();
await entryStream.WriteAsync(agentLogBytes);
}
}
@@ -276,6 +325,81 @@ namespace AutopilotMonitor.Functions.Services
return await _notificationRepo.UpdateSessionReportAdminNoteAsync(reportId, adminNote);
}
/// <summary>
/// Snapshot of the tenant-side detection state relevant to a session report: upload
/// destination flags and the IDs of custom rules/patterns active at report time.
/// Deliberately excludes anything secret-bearing (SAS URLs, tokens) and full rule
/// definitions — IDs + timestamps are enough to correlate with the (persisted) rule
/// tables even after the customer edits them. Fail-soft: null on any error.
/// </summary>
private async Task<object?> CollectTenantContextAsync(string tenantId, string reportId)
{
try
{
var config = await _configService.GetConfigurationAsync(tenantId);
var gatherRules = await _ruleRepo.GetGatherRulesAsync(tenantId);
var analyzeRules = await _ruleRepo.GetAnalyzeRulesAsync(tenantId);
var imePatterns = await _ruleRepo.GetImeLogPatternsAsync(tenantId);
return new
{
diagnosticsUploadMode = config.DiagnosticsUploadMode,
diagnosticsUploadDestination = config.DiagnosticsUploadDestination,
gatherRuleDebugLogEnabled = config.EnableGatherRuleDebugLog,
customGatherRules = gatherRules
.Select(r => new { ruleId = r.RuleId, updatedAt = r.UpdatedAt }).ToList(),
customAnalyzeRules = analyzeRules
.Select(r => new { ruleId = r.RuleId, updatedAt = r.UpdatedAt }).ToList(),
customImePatternIds = imePatterns.Select(p => p.PatternId).ToList()
};
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Report {ReportId}: tenant-context snapshot failed — omitted from metadata", reportId);
return null;
}
}
/// <summary>
/// Decodes an optional base64 attachment. Returns the bytes plus a status string
/// ("none" | "included" | "invalid-base64") that is persisted in report-metadata.json —
/// an invalid payload must be visible to the operator, not silently dropped.
/// </summary>
private (byte[]? Bytes, string Status) DecodeAttachment(string? base64, string reportId, string label)
{
if (string.IsNullOrEmpty(base64))
return (null, "none");
try
{
return (Convert.FromBase64String(base64), "included");
}
catch (FormatException ex)
{
_logger.LogWarning(ex, "Invalid base64 {Label} data in report {ReportId}", label, reportId);
return (null, "invalid-base64");
}
}
/// <summary>
/// Sanitizes a caller-supplied file name for use as a ZIP entry: strips any path
/// components (no traversal-shaped entries inside the archive) and falls back to
/// <paramref name="fallback"/> when nothing usable remains.
/// </summary>
internal static string SanitizeZipEntryName(string? fileName, string fallback)
{
if (string.IsNullOrWhiteSpace(fileName))
return fallback;
// Normalize both separator styles, then keep only the final segment.
var candidate = fileName.Replace('\\', '/');
var lastSlash = candidate.LastIndexOf('/');
if (lastSlash >= 0)
candidate = candidate[(lastSlash + 1)..];
candidate = candidate.Trim();
if (string.IsNullOrEmpty(candidate) || candidate == "." || candidate == "..")
return fallback;
return candidate;
}
private static void AddJsonEntry(ZipArchive archive, string name, object data)
{
var entry = archive.CreateEntry(name, CompressionLevel.Optimal);
@@ -30,7 +30,9 @@ namespace AutopilotMonitor.Functions.Services
/// </summary>
public class SlaBreachEvaluationService
{
private const string DashboardUrl = Constants.WebsiteBaseUrl + "/sla";
// Portal host — /sla is an authenticated portal page; www…/sla links in
// already-sent notifications reach it via the client-side host bounce.
private const string DashboardUrl = Constants.PortalBaseUrl + "/sla";
private const int MaxConflictRetries = 4;
private const int MinAppInstallSampleSize = 5;
@@ -74,6 +74,18 @@ describe('/.well-known/oauth-protected-resource — RFC 9728 resource identifier
});
});
describe('/.well-known/oauth-authorization-server — RFC 8414 metadata', () => {
it('advertises RFC 9207 iss support with issuer matching the callback iss value', async () => {
const res = await fetch(`${baseUrl}/.well-known/oauth-authorization-server`);
expect(res.status).toBe(200);
const json = (await res.json()) as Record<string, unknown>;
// The /oauth/callback redirect stamps iss=<public base URL>; clients
// validate that against this issuer, so the two must agree.
expect(json.issuer).toBe(baseUrl);
expect(json.authorization_response_iss_parameter_supported).toBe(true);
});
});
describe('/oauth/register — dynamic client registration (RFC 7591)', () => {
it('registers a client with a loopback redirect_uri and returns a client_id', async () => {
const { status, json } = await register({
@@ -327,8 +339,12 @@ describe('/oauth/callback — signed state + client enforcement', () => {
expect(res.status).toBe(302);
const location = res.headers.get('location') ?? '';
expect(location.startsWith(`${REDIRECT}?`)).toBe(true);
expect(location).toContain('code=ENTRA_CODE');
expect(location).toContain('state=client-state');
const params = new URL(location).searchParams;
expect(params.get('code')).toBe('ENTRA_CODE');
expect(params.get('state')).toBe('client-state');
// RFC 9207: iss must equal the issuer from the RFC 8414 metadata, or
// spec-2026 clients abort before redeeming the code.
expect(params.get('iss')).toBe(baseUrl);
});
it('rejects a forged client_id carried in the state', async () => {
@@ -430,6 +430,11 @@ export function createOAuthRouter(): Router {
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'],
code_challenge_methods_supported: ['S256'],
// RFC 9207: the authorization response (the /oauth/callback redirect)
// carries an `iss` parameter; advertising it here tells clients they
// MUST validate it against this document's `issuer` before redeeming
// the code — the mix-up defense MCP spec 2026-07-28 made mandatory.
authorization_response_iss_parameter_supported: true,
scopes_supported: ['openid', 'profile', 'offline_access', `api://${CLIENT_ID}/access_as_user`],
});
});
@@ -640,6 +645,7 @@ export function createOAuthRouter(): Router {
// --- Callback: receive code from Entra ID, forward to Claude Code ---
router.get('/oauth/callback', oauthRateLimit, (req, res) => {
const baseUrl = getPublicBaseUrl(req);
const { code, state, error, error_description } = req.query as Record<string, string>;
if (error) {
@@ -697,10 +703,17 @@ export function createOAuthRouter(): Router {
}
}
// Redirect back to Claude Code with the authorization code
// Redirect back to Claude Code with the authorization code. `iss` is the
// RFC 9207 issuer identifier — it MUST equal the `issuer` from our RFC 8414
// metadata (both derive from getPublicBaseUrl), and clients validate it
// before redeeming the code to detect authorization-server mix-up attacks.
// MCP spec 2026-07-28 requires clients to perform this check, so the value
// must be present for them; RFC 9207 clients that predate it ignore
// unknown response parameters (RFC 6749 §4.1.2).
const callbackParams = new URLSearchParams({
code,
...(originalState ? { state: originalState } : {}),
iss: baseUrl,
});
res.redirect(`${redirectUri}?${callbackParams}`);
@@ -99,12 +99,33 @@ namespace AutopilotMonitor.Shared
/// <summary>Customer portal (deep links in notifications and e-mails).</summary>
public const string PortalBaseUrl = "https://portal.autopilotmonitor.com";
/// <summary>
/// The session deep link used in outbound notifications (Teams/Slack/webhook
/// "Open session" buttons). Query-string form since the portal's static-export
/// migration — the single definition of this shape; legacy /sessions/{id}
/// links in already-sent notifications are rewritten client-side by the portal.
/// </summary>
public static string PortalSessionUrl(string sessionId) =>
$"{PortalBaseUrl}/sessions?id={System.Uri.EscapeDataString(sessionId ?? string.Empty)}";
/// <summary>Public marketing/product website.</summary>
public const string WebsiteBaseUrl = "https://www.autopilotmonitor.com";
/// <summary>Published customer documentation.</summary>
public const string DocsBaseUrl = "https://docs.autopilotmonitor.com";
/// <summary>
/// Customer-facing base URL for the OOBE bootstrap script:
/// <c>irm https://go.autopilotmonitor.com/{code} | iex</c>, typed by hand at
/// Shift+F10 during OOBE — kept deliberately short. Front Door route on the
/// go custom domain rewrites <c>/{code}</c> to <c>/api/bootstrap/go/{code}</c>
/// on the Function App (GetBootstrapScriptFunction). Deliberately NOT
/// <see cref="WebsiteBaseUrl"/>: the legacy www /go route stays alive only for
/// URLs issued before the migration, and WebsiteBaseUrl is also the base for
/// the SLA dashboard link.
/// </summary>
public const string BootstrapGoBaseUrl = "https://go.autopilotmonitor.com";
/// <summary>Microsoft Graph API root (no version segment).</summary>
public const string GraphBaseUrl = "https://graph.microsoft.com";
@@ -243,6 +264,9 @@ namespace AutopilotMonitor.Shared
public const string ImeLogPatterns = "/api/rules/ime-log-patterns";
public const string ReseedFromGitHub = "/api/rules/reseed-from-github";
public const string ValidateBootstrapCode = "/api/bootstrap/validate/{code}";
// OOBE bootstrap script (text/plain PowerShell, always HTTP 200). Reached by
// customers as {BootstrapGoBaseUrl}/{code} via the Front Door go-domain route.
public const string BootstrapScript = "/api/bootstrap/go/{code}";
// Agent generic telemetry transport (Plan §2.7a) — THE agent ingest path since the
// legacy V1 NDJSON endpoint (/api/agent/ingest) was decommissioned. Heterogeneous
@@ -26,7 +26,13 @@ namespace AutopilotMonitor.Shared.DataAccess
Task UpsertSoftwareInventoryAsync(string tenantId, List<Dictionary<string, object>> inventoryItems,
string sessionId, Dictionary<string, string?>? cpeMappings = null);
Task<List<Dictionary<string, object>>> GetSoftwareInventoryAsync(string tenantId);
Task<List<Dictionary<string, object>>> GetAllSoftwareInventoryAsync();
/// <summary>
/// Full cross-tenant scan of SoftwareInventory. <paramref name="selectColumns"/> projects the
/// query server-side ($select) — pass the columns actually consumed; a selected column that is
/// absent on a row is simply missing from that dictionary (same ContainsKey semantics as an
/// unprojected read).
/// </summary>
Task<List<Dictionary<string, object>>> GetAllSoftwareInventoryAsync(IEnumerable<string>? selectColumns = null);
Task UpdateSoftwareInventoryCpeAsync(string normalizedName, string cpeUri);
// --- Software Inventory v2: contributions side-row + delta-driven counters (Plan §17) ---
@@ -58,7 +58,8 @@ namespace AutopilotMonitor.Shared.Models
}
/// <summary>
/// Response from validating a bootstrap code (anonymous, called by the /go/{code} route)
/// Response from validating a bootstrap code (anonymous; legacy consumer is the
/// Next.js /go route — the canonical script path is GetBootstrapScriptFunction)
/// </summary>
public class ValidateBootstrapCodeResponse
{
@@ -5,7 +5,7 @@ namespace AutopilotMonitor.Shared.Models
/// <summary>
/// Represents a time-limited bootstrap session for OOBE pre-enrollment agent deployment.
/// Admins create these sessions in the settings UI to generate short URLs
/// (e.g. autopilotmonitor.com/go/abc123) that technicians can run during OOBE.
/// (e.g. go.autopilotmonitor.com/abc123) that technicians can run during OOBE.
/// </summary>
public class BootstrapSession
{
@@ -48,7 +48,7 @@ namespace AutopilotMonitor.Shared.Models
public bool IsRevoked { get; set; }
/// <summary>
/// Number of times the /go/{code} URL has been used to fetch the bootstrap script
/// Number of times the bootstrap URL has been used to fetch the bootstrap script
/// </summary>
public int UsageCount { get; set; }
@@ -50,6 +50,23 @@ namespace AutopilotMonitor.Shared.Models
/// <summary>Original agent log file name</summary>
public string AgentLogFileName { get; set; } = default!;
/// <summary>
/// When true and the session has an uploaded diagnostics archive, the backend copies
/// that archive server-side into the durable session-reports container so it survives
/// session deletion and retention cleanup.
/// </summary>
public bool IncludeDiagnostics { get; set; }
/// <summary>Number of events the client had loaded when it generated the exports.</summary>
public int? ExportedEventCount { get; set; }
/// <summary>The session's EventCount as the client saw it — compared against
/// <see cref="ExportedEventCount"/> this flags a partial export.</summary>
public int? SessionEventCount { get; set; }
/// <summary>True when the client was still streaming event pages at submit time.</summary>
public bool? EventStreamActive { get; set; }
}
/// <summary>
@@ -106,5 +123,17 @@ namespace AutopilotMonitor.Shared.Models
/// <summary>One of <see cref="ReportTypes"/>. Defaults to "session" so legacy rows map cleanly.</summary>
public string ReportType { get; set; } = ReportTypes.Session;
/// <summary>
/// Flat name of the session diagnostics archive copied into the session-reports
/// container at submit time. Null when the copy was never requested or failed.
/// </summary>
public string? DiagnosticsBlobName { get; set; }
/// <summary>
/// Outcome of the diagnostics copy: "Copied" or one of the "Failed:*" reasons.
/// Null when the submitter did not request the copy.
/// </summary>
public string? DiagnosticsCopyStatus { get; set; }
}
}
@@ -26,13 +26,13 @@ export const metadata: Metadata = {
"Autopilot Monitor overview",
],
openGraph: {
title: "About AutopilotMonitor – Real-Time Windows Autopilot Monitoring",
title: "About Autopilot Monitor – Real-Time Windows Autopilot Monitoring",
description:
"Free, open-source real-time monitoring and troubleshooting for Windows Autopilot enrollments. Track every phase, run analyze rules, and resolve issues faster — built by Oliver Kieselbach.",
url: `${SITE_URL}/about`,
},
twitter: {
title: "About AutopilotMonitor – Real-Time Windows Autopilot Monitoring",
title: "About Autopilot Monitor – Real-Time Windows Autopilot Monitoring",
description:
"Free, open-source real-time monitoring and troubleshooting for Windows Autopilot enrollments. Track every phase, run analyze rules, and resolve issues faster.",
},
@@ -1,5 +1,6 @@
import Link from "next/link";
import { PublicPageHeader } from "../../components/PublicPageHeader";
import { LandingNavbar } from "../../components/landing/LandingNavbar";
import { SiteFooter } from "../../components/SiteFooter";
import { DOCS_URL } from "@/utils/config";
const FEATURES = [
@@ -47,25 +48,40 @@ const FEATURES = [
},
];
// Landing v2: one accent, no rainbow — every feature card uses the
// green brand accent regardless of its legacy color key.
const ACCENT_STYLE = {
bg: "bg-[var(--lp-accent-soft)]",
text: "text-[var(--lp-ink)]",
dot: "bg-[var(--lp-accent)]",
};
const colorMap: Record<string, { bg: string; text: string; dot: string }> = {
blue: { bg: "bg-blue-100", text: "text-blue-600", dot: "bg-blue-400" },
indigo: { bg: "bg-indigo-100", text: "text-indigo-600", dot: "bg-indigo-400" },
purple: { bg: "bg-purple-100", text: "text-purple-600", dot: "bg-purple-400" },
green: { bg: "bg-green-100", text: "text-green-600", dot: "bg-green-400" },
orange: { bg: "bg-orange-100", text: "text-orange-600", dot: "bg-orange-400" },
red: { bg: "bg-red-100", text: "text-red-600", dot: "bg-red-400" },
blue: ACCENT_STYLE,
indigo: ACCENT_STYLE,
purple: ACCENT_STYLE,
green: ACCENT_STYLE,
orange: ACCENT_STYLE,
red: ACCENT_STYLE,
};
export default function AboutPage() {
return (
<div className="min-h-screen bg-gray-50">
<PublicPageHeader title="About Autopilot Monitor" />
<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-7xl mx-auto">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-[var(--lp-ink-faint)]">About</p>
<h1 className="mt-3 text-3xl sm:text-4xl font-bold tracking-tight text-[var(--lp-ink)]">
About Autopilot Monitor
</h1>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10 space-y-10">
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10 space-y-10 text-[15px]">
{/* What is Autopilot Monitor */}
<section className="bg-white rounded-xl shadow-sm border border-gray-100 p-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">What is Autopilot Monitor?</h2>
<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">What is Autopilot Monitor?</h2>
<p className="text-gray-700 leading-relaxed mb-4">
<strong>Autopilot Monitor</strong> is a free, open-source, real-time monitoring and troubleshooting
platform for <strong>Windows Autopilot enrollments</strong> managed through{" "}
@@ -100,7 +116,7 @@ export default function AboutPage() {
{FEATURES.map((f) => {
const c = colorMap[f.color];
return (
<div key={f.title} className="bg-white rounded-xl border border-gray-100 shadow-sm p-6">
<div key={f.title} className="bg-[var(--lp-surface)] border border-[var(--lp-line-soft)] rounded-xl p-6">
<h3 className={`text-base font-semibold mb-2 ${c.text}`}>{f.title}</h3>
<p className="text-sm text-gray-600 leading-relaxed mb-3">{f.description}</p>
<ul className="space-y-1">
@@ -118,8 +134,8 @@ export default function AboutPage() {
</section>
{/* Who It's For */}
<section className="bg-white rounded-xl shadow-sm border border-gray-100 p-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">Who Is It For?</h2>
<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">Who Is It For?</h2>
<p className="text-gray-700 leading-relaxed mb-5">
Autopilot Monitor is built for anyone responsible for deploying or supporting
Windows devices through Microsoft Intune and Autopilot.
@@ -142,17 +158,17 @@ export default function AboutPage() {
"Autopilot Monitor is a multi-tenant service with strict per-tenant data isolation — telemetry, configuration, and diagnostics are partitioned and access-scoped to each tenant. MSPs can be granted delegated read access across several customer tenants from a single login.",
},
].map((item) => (
<div key={item.title} className="rounded-lg bg-blue-50 border border-blue-100 p-4">
<h3 className="text-sm font-semibold text-blue-900 mb-2">{item.title}</h3>
<p className="text-sm text-blue-800 leading-relaxed">{item.description}</p>
<div key={item.title} className="rounded-lg bg-[var(--lp-accent-soft)] border border-[var(--lp-accent-line)] p-4">
<h3 className="text-sm font-semibold text-[var(--lp-ink)] mb-2">{item.title}</h3>
<p className="text-sm text-[var(--lp-ink-soft)] leading-relaxed">{item.description}</p>
</div>
))}
</div>
</section>
{/* How It Works */}
<section className="bg-white rounded-xl shadow-sm border border-gray-100 p-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">How It Works</h2>
<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">How It Works</h2>
<p className="text-gray-700 leading-relaxed mb-5">
Autopilot Monitor uses a lightweight .NET agent deployed to devices via an Intune bootstrapper
script. The agent monitors the enrollment process in real time and streams telemetry events
@@ -169,7 +185,7 @@ export default function AboutPage() {
{ step: "5", text: "On completion, the agent uploads a diagnostics bundle if configured, then removes itself." },
].map((item) => (
<li key={item.step} className="flex items-start gap-3 text-sm text-gray-700">
<span className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-blue-600 text-white text-xs font-bold">
<span className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-[var(--lp-accent)] text-white text-xs font-bold">
{item.step}
</span>
{item.text}
@@ -179,8 +195,8 @@ export default function AboutPage() {
</section>
{/* Tech Stack & Platform */}
<section className="bg-white rounded-xl shadow-sm border border-gray-100 p-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">Technology &amp; Platform</h2>
<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">Technology &amp; Platform</h2>
<p className="text-gray-700 leading-relaxed mb-5">
Autopilot Monitor is built on modern, enterprise-grade technology designed to scale with large
device fleets and multi-tenant deployments.
@@ -190,7 +206,7 @@ export default function AboutPage() {
{
title: "Backend",
items: [
"Azure Functions (.NET 10 Isolated, Flex Consumption) — serverless, scalable API",
"Azure Functions (.NET Isolated, Flex Consumption) — serverless, scalable API",
"Azure Table Storage — high-throughput event ingestion",
"Azure Blob Storage — diagnostics and log bundle storage",
"Azure SignalR Service — real-time push to the portal",
@@ -199,7 +215,7 @@ export default function AboutPage() {
{
title: "Portal (Web Frontend)",
items: [
"Next.js 15 (React 18) + TypeScript — fast, server-rendered React app",
"Next.js (React) + TypeScript — fast, statically delivered web app",
"Microsoft Entra ID (MSAL) — secure authentication",
"Role-based access control (Admin / Operator / Viewer)",
"Multi-tenant architecture with delegated MSP access",
@@ -241,8 +257,8 @@ export default function AboutPage() {
</section>
{/* Open Source */}
<section className="bg-white rounded-xl shadow-sm border border-gray-100 p-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">Open Source &amp; Free to Use</h2>
<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">Open Source &amp; Free to Use</h2>
<p className="text-gray-700 leading-relaxed mb-4">
Autopilot Monitor is fully open source and free to use. The complete source code is available on
GitHub under an open license. Contributions, bug reports, and feature requests from the
@@ -257,16 +273,16 @@ export default function AboutPage() {
</p>
<p className="text-gray-700 leading-relaxed mb-4">
The hosted service is operated by <strong>glueckkanja AG</strong> — see the{" "}
<a href="https://www.glueckkanja.com/en/imprint" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:text-blue-800 underline">Imprint</a> for company
details and the <Link href="/terms" className="text-blue-600 hover:text-blue-800 underline">Terms of Use</Link>{" "}
<a href="https://www.glueckkanja.com/en/imprint" target="_blank" rel="noopener noreferrer" className="text-[var(--lp-accent-ink)] hover:opacity-80 underline">Imprint</a> for company
details and the <Link href="/terms" className="text-[var(--lp-accent-ink)] hover:opacity-80 underline">Terms of Use</Link>{" "}
for what each plan does and does not commit to.
</p>
<div className="flex flex-wrap gap-3 mt-5">
<a
href="https://github.com/okieselbach/Autopilot-Monitor"
href="https://github.com/okieselbach/AutopilotMonitor"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-4 py-2.5 text-sm font-medium text-gray-700 hover:border-gray-300 hover:bg-gray-100 transition-colors"
className="inline-flex items-center gap-2 rounded-lg border border-[var(--lp-line-soft)] bg-gray-50 px-4 py-2.5 text-sm font-medium text-gray-700 hover:border-gray-300 hover:bg-gray-100 transition-colors"
>
<svg className="w-4 h-4" 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" />
@@ -274,10 +290,10 @@ export default function AboutPage() {
View on GitHub
</a>
<a
href="https://github.com/okieselbach/Autopilot-Monitor/issues"
href="https://github.com/okieselbach/AutopilotMonitor/issues"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-4 py-2.5 text-sm font-medium text-gray-700 hover:border-gray-300 hover:bg-gray-100 transition-colors"
className="inline-flex items-center gap-2 rounded-lg border border-[var(--lp-line-soft)] bg-gray-50 px-4 py-2.5 text-sm font-medium text-gray-700 hover:border-gray-300 hover:bg-gray-100 transition-colors"
>
Submit Feedback
</a>
@@ -285,7 +301,7 @@ export default function AboutPage() {
href="https://www.linkedin.com/in/oliver-kieselbach/"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-4 py-2.5 text-sm font-medium text-gray-700 hover:border-gray-300 hover:bg-gray-100 transition-colors"
className="inline-flex items-center gap-2 rounded-lg border border-[var(--lp-line-soft)] bg-gray-50 px-4 py-2.5 text-sm font-medium text-gray-700 hover:border-gray-300 hover:bg-gray-100 transition-colors"
>
Oliver Kieselbach on LinkedIn
</a>
@@ -293,22 +309,23 @@ export default function AboutPage() {
</section>
{/* Quick Links */}
<section className="bg-white rounded-xl shadow-sm border border-gray-100 p-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">Explore Further</h2>
<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">Explore Further</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
<a href={DOCS_URL} target="_blank" rel="noopener noreferrer" className="rounded-lg border border-gray-200 px-4 py-3 hover:border-blue-300 hover:bg-blue-50/50 transition-colors text-gray-700 font-medium">
<a href={DOCS_URL} target="_blank" rel="noopener noreferrer" className="rounded-lg border border-[var(--lp-line-soft)] px-4 py-3 hover:border-[var(--lp-accent-line)] hover:bg-[var(--lp-accent-soft)] transition-colors text-gray-700 font-medium">
Documentation →
</a>
<a href={`${DOCS_URL}/changelog/platform-changelog`} target="_blank" rel="noopener noreferrer" className="rounded-lg border border-gray-200 px-4 py-3 hover:border-blue-300 hover:bg-blue-50/50 transition-colors text-gray-700 font-medium">
<a href={`${DOCS_URL}/changelog/platform-changelog`} target="_blank" rel="noopener noreferrer" className="rounded-lg border border-[var(--lp-line-soft)] px-4 py-3 hover:border-[var(--lp-accent-line)] hover:bg-[var(--lp-accent-soft)] transition-colors text-gray-700 font-medium">
Changelog →
</a>
<Link href="/privacy" className="rounded-lg border border-gray-200 px-4 py-3 hover:border-blue-300 hover:bg-blue-50/50 transition-colors text-gray-700 font-medium">
<Link href="/privacy" className="rounded-lg border border-[var(--lp-line-soft)] px-4 py-3 hover:border-[var(--lp-accent-line)] hover:bg-[var(--lp-accent-soft)] transition-colors text-gray-700 font-medium">
Privacy Policy →
</Link>
</div>
</section>
</main>
<SiteFooter />
</div>
);
}
@@ -1,6 +1,6 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useState } from "react";
import { createContext, useCallback, useContext, useRef, useState } from "react";
import { useAuth } from "../../contexts/AuthContext";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
@@ -83,6 +83,12 @@ interface AdminConfigContextValue {
excessiveEventAutoActionDurationHours: number,
) => Promise<void>;
// Lazy loaders — config/tenants are NOT fetched when the admin layout mounts.
// Sections that consume adminConfig/tenants call these once on mount; pages that
// only need getAccessToken/notifications (e.g. Software Mapping) pay nothing.
ensureAdminConfigLoaded: () => void;
ensureTenantsLoaded: () => void;
// Tenants
tenants: TenantConfiguration[];
setTenants: React.Dispatch<React.SetStateAction<TenantConfiguration[]>>;
@@ -170,11 +176,11 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
const isGlobalAdmin = user?.isGlobalAdmin === true;
// Fetch admin configuration
useEffect(() => {
if (!isGlobalAdmin) return;
// Fetch admin configuration — lazy: runs once, triggered by the first section
// that actually consumes adminConfig (via ensureAdminConfigLoaded).
const adminConfigRequested = useRef(false);
const fetchAdminConfig = async () => {
const fetchAdminConfig = useCallback(async () => {
try {
setLoadingConfig(true);
setError(null);
@@ -232,14 +238,23 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
} finally {
setLoadingConfig(false);
}
};
}, [getAccessToken]);
const ensureAdminConfigLoaded = useCallback(() => {
// Global Reader never loads platform config (same as the old eager gate);
// backend would 403/redact anyway.
if (!isGlobalAdmin || adminConfigRequested.current) return;
adminConfigRequested.current = true;
fetchAdminConfig();
}, [isGlobalAdmin, getAccessToken]);
}, [isGlobalAdmin, fetchAdminConfig]);
// Fetch tenants + preview whitelist. Exposed directly as the explicit refresh
// (post-save reloads); ensureTenantsLoaded is the idempotent on-mount trigger.
const tenantsRequested = useRef(false);
// Fetch tenants + preview whitelist
const fetchTenants = useCallback(async () => {
if (!isGlobalAdmin) return;
tenantsRequested.current = true;
try {
setLoadingTenants(true);
@@ -274,7 +289,8 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
}
}, [isGlobalAdmin, getAccessToken]);
useEffect(() => {
const ensureTenantsLoaded = useCallback(() => {
if (tenantsRequested.current) return;
fetchTenants();
}, [fetchTenants]);
@@ -508,6 +524,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
excessiveEventAutoActionThreshold,
excessiveEventAutoActionDurationHours,
savingOpsAlerts,
ensureAdminConfigLoaded, ensureTenantsLoaded,
tenants, setTenants, loadingTenants, fetchTenants,
previewApproved, setPreviewApproved,
error, setError, successMessage, setSuccessMessage,
@@ -3,24 +3,15 @@
import { useMemo } from "react";
import { usePageSections } from "../../hooks/usePageSections";
import { PageSectionItem } from "../../contexts/SidebarContext";
import { route } from "../../lib/routes";
import {
GearIcon,
NoSymbolIcon,
DocumentTextIcon,
ChartBarIcon,
BuildingOfficeIcon,
ShieldCheckIcon,
} from "../../lib/sidebarIcons";
// Inline icon (same as MetricsSidebar had)
function TrendingUpIcon({ className = "w-5 h-5" }: { className?: string }) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 18L9 11.25l4.306 4.307a11.95 11.95 0 015.814-5.519l2.74-1.22m0 0l-5.94-2.28m5.94 2.28l-2.28 5.941" />
</svg>
);
}
// Inline icon for Ops (wrench)
function WrenchIcon({ className = "w-5 h-5" }: { className?: string }) {
return (
@@ -47,29 +38,29 @@ function GlobeIcon({ className = "w-5 h-5" }: { className?: string }) {
export function AdminPageSections() {
const items: PageSectionItem[] = useMemo(() => [
// Tenants
{ id: "management", label: "Tenant Management", href: "/admin/tenants/management", group: "Tenants", groupIcon: <BuildingOfficeIcon /> },
{ id: "config-report", label: "Config Report", href: "/admin/tenants/config-report", group: "Tenants" },
{ id: "management", label: "Tenant Management", href: route("/admin/tenants/management"), group: "Tenants", groupIcon: <BuildingOfficeIcon /> },
{ id: "config-report", label: "Config Report", href: route("/admin/tenants/config-report"), group: "Tenants" },
// Metrics
{ id: "platform-metrics", label: "Platform Metrics", href: "/admin/metrics/platform-metrics", group: "Metrics", groupIcon: <ChartBarIcon /> },
{ id: "usage", label: "Platform Usage", href: "/admin/metrics/usage", group: "Metrics" },
{ id: "mcp-usage", label: "MCP Usage", href: "/admin/metrics/mcp-usage", group: "Metrics" },
{ id: "platform-metrics", label: "Platform Metrics", href: route("/admin/metrics/platform-metrics"), group: "Metrics", groupIcon: <ChartBarIcon /> },
{ id: "usage", label: "Platform Usage", href: route("/admin/metrics/usage"), group: "Metrics" },
{ id: "mcp-usage", label: "MCP Usage", href: route("/admin/metrics/mcp-usage"), group: "Metrics" },
// Reports
{ id: "session-reports", label: "Session Reports", href: "/admin/reports/session-reports", group: "Reports", groupIcon: <DocumentTextIcon /> },
{ id: "user-feedback", label: "User Feedback", href: "/admin/reports/user-feedback", group: "Reports" },
{ id: "session-export", label: "Session Export", href: "/admin/reports/session-export", group: "Reports" },
{ id: "session-reports", label: "Session Reports", href: route("/admin/reports/session-reports"), group: "Reports", groupIcon: <DocumentTextIcon /> },
{ id: "user-feedback", label: "User Feedback", href: route("/admin/reports/user-feedback"), group: "Reports" },
{ id: "session-export", label: "Session Export", href: route("/admin/reports/session-export"), group: "Reports" },
// Security
{ id: "device-block", label: "Device Block", href: "/admin/security/device-block", group: "Security", groupIcon: <ShieldCheckIcon /> },
{ id: "version-block", label: "Version Block", href: "/admin/security/version-block", group: "Security" },
{ id: "vulnerability-data", label: "Vulnerability Data", href: "/admin/security/vulnerability-data", group: "Security" },
{ id: "device-block", label: "Device Block", href: route("/admin/security/device-block"), group: "Security", groupIcon: <ShieldCheckIcon /> },
{ id: "version-block", label: "Version Block", href: route("/admin/security/version-block"), group: "Security" },
{ id: "vulnerability-data", label: "Vulnerability Data", href: route("/admin/security/vulnerability-data"), group: "Security" },
// Settings
{ id: "global", label: "Global Settings", href: "/admin/settings/global", group: "Settings", groupIcon: <GearIcon /> },
{ id: "diagnostics-log-paths", label: "Diagnostics Log Paths", href: "/admin/settings/diagnostics-log-paths", group: "Settings" },
{ id: "config-reseed", label: "Config Reseed", href: "/admin/settings/config-reseed", group: "Settings" },
{ id: "usage-plans", label: "Usage Plans", href: "/admin/settings/usage-plans", group: "Settings" },
{ id: "global", label: "Global Settings", href: route("/admin/settings/global"), group: "Settings", groupIcon: <GearIcon /> },
{ 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" },
// Ops (single page)
{ id: "ops", label: "Maintenance", href: "/admin/ops", group: "Ops", groupIcon: <WrenchIcon /> },
@@ -90,7 +90,7 @@ export function RestoreRowDiffModal({
This is an <strong>authentication / authorization</strong> table
(<code className="font-mono">GlobalAdmins</code>, <code className="font-mono">TenantAdmins</code>,{" "}
<code className="font-mono">McpUsers</code>). Restoring this row will overwrite the live{" "}
<code className="font-mono">IsEnabled</code> flag — confirm that the backup row's enable/disable
<code className="font-mono">IsEnabled</code> flag — confirm that the backup row&apos;s enable/disable
state is what you intend.
</div>
</div>
@@ -159,7 +159,7 @@ export function RestoreRowDiffModal({
type="button"
onClick={commit}
disabled={committing}
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm rounded-md transition-colors"
className="px-3 py-1.5 bg-green-600 hover:bg-green-700 disabled:opacity-50 text-white text-sm rounded-md transition-colors"
>
{committing ? "Restoring…" : "Restore row"}
</button>
@@ -1,8 +1,8 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useSearchParams } from "next/navigation";
import {
api,
type BackupManifest,
@@ -22,8 +22,17 @@ import { RestoreRowDiffModal } from "../components/RestoreRowDiffModal";
* the operator restore a single row from any Ok / Empty table.
*/
export default function BackupDetailPage() {
const params = useParams<{ backupId: string }>();
const backupId = decodeURIComponent(params?.backupId ?? "");
// useSearchParams() in BackupDetailContent requires a Suspense boundary for static prerender.
return (
<Suspense fallback={null}>
<BackupDetailContent />
</Suspense>
);
}
function BackupDetailContent() {
const searchParams = useSearchParams();
const backupId = searchParams?.get("id") ?? "";
const { getAccessToken, setError, setSuccessMessage } = useAdminConfig();
const [manifest, setManifest] = useState<BackupManifest | null>(null);
@@ -267,7 +276,7 @@ function RestoreInputRow({
// — only null is rejected. Match that here so an Azure Table row with
// a literal empty key (legal per spec) can be previewed/restored.
disabled={disabled || !tableName}
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm rounded-md transition-colors"
className="px-3 py-1.5 bg-green-600 hover:bg-green-700 disabled:opacity-50 text-white text-sm rounded-md transition-colors"
>
Preview restore
</button>
@@ -1,5 +1,6 @@
"use client";
import { backupUrl } from "@/lib/routes";
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import {
@@ -133,7 +134,7 @@ export default function BackupsListPage() {
type="button"
onClick={triggerBackup}
disabled={triggering || activeJob !== null}
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm rounded-md transition-colors"
className="px-3 py-1.5 bg-green-600 hover:bg-green-700 disabled:opacity-50 text-white text-sm rounded-md transition-colors"
>
{triggering ? "Triggering…" : "Run backup now"}
</button>
@@ -165,7 +166,7 @@ export default function BackupsListPage() {
{backupIds.map((id) => (
<Link
key={id}
href={`/admin/backups/${encodeURIComponent(id)}`}
href={backupUrl(id)}
className="block bg-white dark:bg-gray-800 rounded-lg shadow p-4 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
<div className="font-mono text-sm text-gray-900 dark:text-white">{id}</div>
@@ -104,7 +104,7 @@ export function AdminConfigSettingsSection({
max="1000"
value={globalRateLimit}
onChange={(e) => setGlobalRateLimit(parseInt(e.target.value) || 100)}
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
/>
</label>
</div>
@@ -121,7 +121,7 @@ export function AdminConfigSettingsSection({
max="10000"
value={userRateLimit}
onChange={(e) => setUserRateLimit(parseInt(e.target.value) || 120)}
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
/>
</label>
</div>
@@ -138,7 +138,7 @@ export function AdminConfigSettingsSection({
max="10000"
value={globalAdminRateLimit}
onChange={(e) => setGlobalAdminRateLimit(parseInt(e.target.value) || 600)}
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
/>
</label>
</div>
@@ -158,7 +158,7 @@ export function AdminConfigSettingsSection({
value={platformStatsBlobSasUrl}
onChange={(e) => setPlatformStatsBlobSasUrl(e.target.value)}
placeholder="https://storageaccount.blob.core.windows.net/publicstats?sv=...&sig=..."
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors font-mono text-sm"
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors font-mono text-sm"
/>
</label>
</div>
@@ -177,7 +177,7 @@ export function AdminConfigSettingsSection({
value={agentMigrateApiBaseUrl}
onChange={(e) => setAgentMigrateApiBaseUrl(e.target.value)}
placeholder="https://autopilotmonitor-api-us.azurewebsites.net"
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors font-mono text-sm"
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors font-mono text-sm"
/>
</label>
</div>
@@ -195,7 +195,7 @@ export function AdminConfigSettingsSection({
onChange={(e) => setAgentMigrateTenantOverridesJson(e.target.value)}
placeholder='{"11111111-2222-3333-4444-555555555555": "https://autopilotmonitor-api-us.azurewebsites.net", "66666666-7777-8888-9999-000000000000": ""}'
rows={3}
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors font-mono text-sm"
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors font-mono text-sm"
/>
</label>
</div>
@@ -214,7 +214,7 @@ export function AdminConfigSettingsSection({
max="120"
value={collectorIdleTimeoutMinutes}
onChange={(e) => setCollectorIdleTimeoutMinutes(parseInt(e.target.value) || 0)}
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
/>
</label>
</div>
@@ -233,7 +233,7 @@ export function AdminConfigSettingsSection({
max="60"
value={desktopDetectorNoCandidateTimeoutMinutes}
onChange={(e) => setDesktopDetectorNoCandidateTimeoutMinutes(parseInt(e.target.value) || 0)}
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
/>
</label>
</div>
@@ -253,7 +253,7 @@ export function AdminConfigSettingsSection({
max="168"
value={slaNotificationCooldownHours}
onChange={(e) => setSlaNotificationCooldownHours(parseInt(e.target.value) || 24)}
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
/>
</label>
</div>
@@ -264,7 +264,7 @@ export function AdminConfigSettingsSection({
type="checkbox"
checked={allowAgentDowngrade}
onChange={(e) => setAllowAgentDowngrade(e.target.checked)}
className="mt-1 h-5 w-5 rounded border-indigo-300 dark:border-indigo-600 text-indigo-600 focus:ring-indigo-500"
className="mt-1 h-5 w-5 rounded border-indigo-300 dark:border-indigo-600 text-green-600 focus:ring-green-500"
/>
<span>
<span className="text-indigo-900 dark:text-indigo-100 font-medium">Allow agent downgrade</span>
@@ -289,7 +289,7 @@ export function AdminConfigSettingsSection({
value={modernDeploymentHarmlessEventIds}
onChange={(e) => setModernDeploymentHarmlessEventIds(e.target.value)}
placeholder="100, 1005, 1010"
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors font-mono text-sm"
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors font-mono text-sm"
/>
</label>
</div>
@@ -300,7 +300,7 @@ export function AdminConfigSettingsSection({
type="checkbox"
checked={enableIndexDualWrite}
onChange={(e) => setEnableIndexDualWrite(e.target.checked)}
className="mt-1 h-5 w-5 rounded border-indigo-300 dark:border-indigo-600 text-indigo-600 focus:ring-indigo-500"
className="mt-1 h-5 w-5 rounded border-indigo-300 dark:border-indigo-600 text-green-600 focus:ring-green-500"
/>
<span>
<span className="text-indigo-900 dark:text-indigo-100 font-medium">V2 Decision Engine: Enable index-table dual-write</span>
@@ -328,7 +328,7 @@ export function AdminConfigSettingsSection({
type="checkbox"
checked={sessionDeletionKillSwitch}
onChange={(e) => setSessionDeletionKillSwitch(e.target.checked)}
className="mt-1 h-5 w-5 rounded border-red-300 dark:border-red-600 text-red-600 focus:ring-red-500"
className="mt-1 h-5 w-5 rounded border-red-300 dark:border-red-600 text-red-600 accent-red-600 focus:ring-red-500"
/>
<span>
<span className="text-red-900 dark:text-red-100 font-medium">Cascade-Delete Kill-Switch (emergency)</span>
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { sessionUrl } from "@/lib/routes";
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { api } from "@/lib/api";
@@ -44,7 +45,18 @@ interface DeviceBlockSectionProps {
setSuccessMessage: (message: string | null) => void;
}
export function DeviceBlockSection({
export function DeviceBlockSection(props: DeviceBlockSectionProps) {
// useSearchParams() in the inner component requires a Suspense boundary for
// static prerender (this section renders inside the prerendered
// /admin/security/device-block page).
return (
<Suspense fallback={null}>
<DeviceBlockSectionInner {...props} />
</Suspense>
);
}
function DeviceBlockSectionInner({
tenants,
getAccessToken,
setError,
@@ -513,7 +525,7 @@ export function DeviceBlockSection({
const count = blockedSessionCount(d.blockedSessionIds);
return (
<Link
href={`/sessions/${encodeURIComponent(sessionId)}`}
href={sessionUrl(sessionId)}
target="_blank"
rel="noopener noreferrer"
className="text-blue-700 dark:text-blue-300 hover:underline"
@@ -530,7 +542,7 @@ export function DeviceBlockSection({
<td className="px-3 py-2">
<button
onClick={() => navigator.clipboard.writeText(d.tenantId)}
className="text-xs font-mono text-gray-500 hover:text-blue-600 transition-colors"
className="text-xs font-mono text-gray-500 hover:text-green-600 transition-colors"
title={d.tenantId}
>
{d.tenantId.split("-").slice(0, 2).join("-")}...
@@ -90,7 +90,7 @@ export function DiagnosticsLogPathsSection({
updated[idx] = { ...entry, includeSubfolders: !entry.includeSubfolders };
setGlobalDiagPaths(updated);
}}
className="w-3.5 h-3.5 rounded border-teal-400 text-teal-600 focus:ring-teal-500"
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>
@@ -148,7 +148,7 @@ export function DiagnosticsLogPathsSection({
type="checkbox"
checked={newDiagSubfolders}
onChange={() => setNewDiagSubfolders(!newDiagSubfolders)}
className="w-3.5 h-3.5 rounded border-gray-400 text-teal-600 focus:ring-teal-500"
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>
@@ -3,6 +3,7 @@
import { useState, useEffect } from "react";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { TableSkeleton } from "@/components/skeletons/TableSkeleton";
interface DistressReport {
tenantId: string;
@@ -108,7 +109,9 @@ export function DistressReportsSection({
setError,
}: DistressReportsSectionProps) {
const [reports, setReports] = useState<DistressReport[]>([]);
const [loading, setLoading] = useState(false);
// true from the start: the fetch fires on mount, and a false initial value
// flashes the "No distress reports" empty state for one paint.
const [loading, setLoading] = useState(true);
const [selectedReport, setSelectedReport] = useState<DistressReport | null>(null);
const [currentPage, setCurrentPage] = useState(0);
const [sortKey, setSortKey] = useState<SortKey>("ingestedAt");
@@ -143,11 +146,6 @@ export function DistressReportsSection({
return sortDir === "asc" ? cmp : -cmp;
});
useEffect(() => {
fetchReports();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const fetchReports = async () => {
try {
setLoading(true);
@@ -169,6 +167,11 @@ export function DistressReportsSection({
}
};
useEffect(() => {
fetchReports();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Summary stats
const errorTypeCounts = reports.reduce<Record<string, number>>((acc, r) => {
acc[r.errorType] = (acc[r.errorType] ?? 0) + 1;
@@ -203,7 +206,9 @@ export function DistressReportsSection({
</div>
</div>
{/* Summary Cards */}
{/* Summary Cards — intentionally hidden while loading: distress is often
empty on a healthy system, and a skeleton band that then vanishes
shifts the layout more than one that appears. */}
{!loading && reports.length > 0 && (
<div className="p-4 border-b border-amber-200 dark:border-amber-700 bg-amber-50/50 dark:bg-gray-800/50">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
@@ -234,10 +239,7 @@ export function DistressReportsSection({
{/* Reports Table */}
<div className="p-6">
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-amber-600"></div>
<span className="ml-3 text-sm text-gray-500 dark:text-gray-400">Loading distress reports...</span>
</div>
<TableSkeleton columns={7} rows={15} />
) : reports.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<svg className="w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -28,11 +28,17 @@ interface FeedbackSectionProps {
}
export function FeedbackSection({ getAccessToken, setError }: FeedbackSectionProps) {
const [loading, setLoading] = useState(false);
// true from the start: the fetch fires on mount, and a false initial value
// flashes the empty state for one paint.
const [loading, setLoading] = useState(true);
const [entries, setEntries] = useState<FeedbackEntry[]>([]);
const [activeTab, setActiveTab] = useState<FeedbackTab>("InApp");
const [inAppFilter, setInAppFilter] = useState<InAppFilter>("all");
const [currentPage, setCurrentPage] = useState(0);
// Reference clock for the "Xm ago" labels — render must stay pure
// (react-hooks/purity), so it's captured at mount and refreshed per fetch,
// keeping the labels in step with the data they describe.
const [nowMs, setNowMs] = useState(() => Date.now());
const entriesPerPage = 5;
const fetchFeedback = useCallback(async () => {
@@ -54,6 +60,7 @@ export function FeedbackSection({ getAccessToken, setError }: FeedbackSectionPro
return dateB - dateA;
});
setEntries(sorted);
setNowMs(Date.now());
} catch (err) {
if (err instanceof TokenExpiredError) {
console.error("Session expired while fetching feedback");
@@ -112,7 +119,7 @@ export function FeedbackSection({ getAccessToken, setError }: FeedbackSectionPro
const formatTimeAgo = (dateStr: string | null): string => {
if (!dateStr) return "unknown";
const diff = Date.now() - new Date(dateStr).getTime();
const diff = nowMs - new Date(dateStr).getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
@@ -5,6 +5,7 @@ import { api } from "@/lib/api";
import { authenticatedFetch } from "@/lib/authenticatedFetch";
import TruncatedLabel from "@/components/TruncatedLabel";
import { trackEvent } from "@/lib/appInsights";
import { TableSkeleton } from "@/components/skeletons/TableSkeleton";
import type { AutoResolveResult, IgnoredSoftwareEntry } from "./SoftwareMappingTypes";
interface IgnoredSoftwareTabProps {
@@ -27,7 +28,8 @@ export function IgnoredSoftwareTab({
onCountChanged,
}: IgnoredSoftwareTabProps) {
const [ignoredEntries, setIgnoredEntries] = useState<IgnoredSoftwareEntry[]>([]);
const [ignoredLoading, setIgnoredLoading] = useState(false);
// true: a fetch fires on mount (TableSkeleton convention — avoids an empty-state flash)
const [ignoredLoading, setIgnoredLoading] = useState(true);
const [ignoredLoaded, setIgnoredLoaded] = useState(false);
const [ignoredPage, setIgnoredPage] = useState(0);
const [restoringRow, setRestoringRow] = useState<string | null>(null);
@@ -56,10 +58,11 @@ export function IgnoredSoftwareTab({
// Lazy load on mount
useEffect(() => {
if (!ignoredLoaded && !ignoredLoading) {
if (!ignoredLoaded) {
fetchIgnoredSoftware();
}
}, [ignoredLoaded, ignoredLoading, fetchIgnoredSoftware]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ignoredLoaded]);
// Reset when refreshTrigger changes (e.g. unmapped tab ignored an item)
useEffect(() => {
@@ -158,11 +161,9 @@ export function IgnoredSoftwareTab({
return (
<>
{ignoredLoading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-amber-600" />
<span className="ml-3 text-sm text-gray-500 dark:text-gray-400">Loading ignored software...</span>
</div>
{ignoredLoading && !ignoredLoaded ? (
// Skeleton only on initial load; refetches (restore) keep the table visible.
<TableSkeleton columns={4} rows={8} className="pt-4" />
) : ignoredEntries.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<svg className="w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -17,6 +17,9 @@ export function MaintenanceSection({
}: MaintenanceSectionProps) {
const [triggeringMaintenance, setTriggeringMaintenance] = useState(false);
const [maintenanceDate, setMaintenanceDate] = useState<string>("");
// Latest selectable day (yesterday), fixed at mount — render must stay pure
// (react-hooks/purity) and the cap doesn't need a live midnight rollover.
const [maxMaintenanceDate] = useState(() => new Date(Date.now() - 86400000).toISOString().split('T')[0]);
const [reseedingRules, setReseedingRules] = useState(false);
const [reseedingGatherRules, setReseedingGatherRules] = useState(false);
const [reseedingImePatterns, setReseedingImePatterns] = useState(false);
@@ -244,7 +247,7 @@ export function MaintenanceSection({
type="date"
value={maintenanceDate}
onChange={(e) => setMaintenanceDate(e.target.value)}
max={new Date(Date.now() - 86400000).toISOString().split('T')[0]}
max={maxMaintenanceDate}
className="w-full max-w-xs px-3 py-2 border border-purple-300 dark:border-purple-600 rounded-lg text-sm bg-white dark:bg-gray-600 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
@@ -17,6 +17,9 @@ export function MaintenanceTriggerSection({
}: MaintenanceTriggerSectionProps) {
const [triggeringMaintenance, setTriggeringMaintenance] = useState(false);
const [maintenanceDate, setMaintenanceDate] = useState<string>("");
// Latest selectable day (yesterday), fixed at mount — render must stay pure
// (react-hooks/purity) and the cap doesn't need a live midnight rollover.
const [maxMaintenanceDate] = useState(() => new Date(Date.now() - 86400000).toISOString().split('T')[0]);
const [refreshingVersions, setRefreshingVersions] = useState(false);
const handleRefreshLatestVersions = async () => {
@@ -143,7 +146,7 @@ export function MaintenanceTriggerSection({
type="date"
value={maintenanceDate}
onChange={(e) => setMaintenanceDate(e.target.value)}
max={new Date(Date.now() - 86400000).toISOString().split('T')[0]}
max={maxMaintenanceDate}
className="w-full max-w-xs px-3 py-2 border border-purple-300 dark:border-purple-600 rounded-lg text-sm bg-white dark:bg-gray-600 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
@@ -5,6 +5,7 @@ import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { trackEvent } from "@/lib/appInsights";
import { CpeMappingEntry } from "./SoftwareMappingTypes";
import { TableSkeleton } from "@/components/skeletons/TableSkeleton";
interface MappedSoftwareTabProps {
getAccessToken: () => Promise<string | null>;
@@ -22,7 +23,8 @@ export function MappedSoftwareTab({
onCountChanged,
}: MappedSoftwareTabProps) {
const [mappedEntries, setMappedEntries] = useState<CpeMappingEntry[]>([]);
const [mappedLoading, setMappedLoading] = useState(false);
// true: a fetch fires on mount (TableSkeleton convention — avoids an empty-state flash)
const [mappedLoading, setMappedLoading] = useState(true);
const [mappedLoaded, setMappedLoaded] = useState(false);
const [mappedPage, setMappedPage] = useState(0);
const [searchQuery, setSearchQuery] = useState("");
@@ -80,10 +82,11 @@ export function MappedSoftwareTab({
// Lazy load on first render
useEffect(() => {
if (!mappedLoaded && !mappedLoading) {
if (!mappedLoaded) {
fetchCpeMappings();
}
}, [mappedLoaded, mappedLoading, fetchCpeMappings]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mappedLoaded]);
// Reset loaded state when refreshTrigger changes (external signal to reload)
useEffect(() => {
@@ -285,13 +288,9 @@ export function MappedSoftwareTab({
// --- Render ---
if (mappedLoading) {
return (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-amber-600" />
<span className="ml-3 text-sm text-gray-500 dark:text-gray-400">Loading CPE mappings...</span>
</div>
);
// Skeleton only on initial load; refetches (search, refresh) keep the table visible.
if (mappedLoading && !mappedLoaded) {
return <TableSkeleton columns={5} rows={8} className="pt-4" />;
}
return (
@@ -232,14 +232,6 @@ export function OpsAlertRulesSection({
const enabledRulesCount = rules.filter(r => r.enabled).length;
const enabledProviders = [telegramEnabled, teamsEnabled, slackEnabled].filter(Boolean).length;
// Get category for a given event type
const getCategoryForEvent = (eventType: string): string => {
for (const [cat, types] of Object.entries(OPS_EVENT_TYPES)) {
if (types.includes(eventType)) return cat;
}
return "Unknown";
};
return (
<div className="space-y-6">
{/* Alert Rules */}
@@ -1,5 +1,6 @@
"use client";
import { sessionUrl, deviceBlockUrl } from "@/lib/routes";
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { api } from "@/lib/api";
@@ -557,7 +558,6 @@ export function OpsEventsSection({
const sessionId = extractSessionId(selectedEvent.details);
if (!sessionId) return null;
const reason = buildAutoReason(selectedEvent.eventType, sessionId);
const baseHref = `/admin/security/device-block?sessionId=${encodeURIComponent(sessionId)}&reason=${encodeURIComponent(reason)}`;
return (
<div className="mt-5 pt-4 border-t border-gray-200 dark:border-gray-700">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">
@@ -565,7 +565,7 @@ export function OpsEventsSection({
</p>
<div className="flex flex-wrap gap-2">
<Link
href={`/sessions/${encodeURIComponent(sessionId)}`}
href={sessionUrl(sessionId)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center px-3 py-1.5 rounded-md text-xs font-medium bg-blue-100 text-blue-800 hover:bg-blue-200 dark:bg-blue-900/40 dark:text-blue-200 dark:hover:bg-blue-900/60 border border-blue-300 dark:border-blue-700"
@@ -573,14 +573,14 @@ export function OpsEventsSection({
View session
</Link>
<Link
href={`${baseHref}&action=Block`}
href={deviceBlockUrl(sessionId, reason, "Block")}
onClick={() => setSelectedEvent(null)}
className="inline-flex items-center px-3 py-1.5 rounded-md text-xs font-medium bg-orange-100 text-orange-800 hover:bg-orange-200 dark:bg-orange-900/40 dark:text-orange-200 dark:hover:bg-orange-900/60 border border-orange-300 dark:border-orange-700"
>
Block this device
</Link>
<Link
href={`${baseHref}&action=Kill`}
href={deviceBlockUrl(sessionId, reason, "Kill")}
onClick={() => setSelectedEvent(null)}
className="inline-flex items-center px-3 py-1.5 rounded-md text-xs font-medium bg-red-700 text-white hover:bg-red-800 dark:bg-red-700 dark:hover:bg-red-800"
>
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
import { useSearchParams } from "next/navigation";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import TruncatedLabel from "@/components/TruncatedLabel";
@@ -8,6 +9,7 @@ import { extractContinuation } from "@/lib/paginationLink";
import { isGuid } from "@/utils/inputValidation";
import { trackEvent } from "@/lib/appInsights";
import { useCanMutatePlatform } from "@/hooks/useCanMutatePlatform";
import { TableSkeleton } from "@/components/skeletons/TableSkeleton";
const PAGE_SIZE = 20;
@@ -23,6 +25,10 @@ interface SessionReport {
adminNote?: string;
/** "session" (legacy default) or "diagFiles" (no session context) */
reportType?: "session" | "diagFiles";
/** Flat name of the preserved diagnostics archive copy — present only when the copy succeeded */
diagnosticsBlobName?: string;
/** "Copied" or a "Failed:*" reason — absent when the submitter did not request the copy */
diagnosticsCopyStatus?: string;
}
function ReportTypeBadge({ type }: { type?: string }) {
@@ -71,7 +77,18 @@ function CopyButton({ value }: { value: string }) {
);
}
export function SessionReportsSection({
export function SessionReportsSection(props: SessionReportsSectionProps) {
// useSearchParams() in the inner component requires a Suspense boundary for
// static prerender (this section renders inside the prerendered
// /admin/reports/session-reports page).
return (
<Suspense fallback={null}>
<SessionReportsSectionInner {...props} />
</Suspense>
);
}
function SessionReportsSectionInner({
getAccessToken,
setError,
}: SessionReportsSectionProps) {
@@ -79,7 +96,9 @@ export function SessionReportsSection({
// Reader may view reports + notes but not edit them.
const canMutate = useCanMutatePlatform();
const [reports, setReports] = useState<SessionReport[]>([]);
const [loading, setLoading] = useState(false);
// true from the start: the fetch fires on mount, and a false initial value
// flashes the empty state for one paint.
const [loading, setLoading] = useState(true);
const [selectedReport, setSelectedReport] = useState<SessionReport | null>(null);
const [downloadingBlob, setDownloadingBlob] = useState<string | null>(null);
const [adminNoteValue, setAdminNoteValue] = useState("");
@@ -94,6 +113,25 @@ export function SessionReportsSection({
const [continuationStack, setContinuationStack] = useState<Array<string | null>>([]);
const [pageNumber, setPageNumber] = useState(1);
// Deep link from GA notifications: ?reportId=… auto-opens the report modal once
// the first page has loaded. Reports are listed newest-first, so a report reached
// via a fresh notification is on page 1; older ones get a hint instead.
const searchParams = useSearchParams();
const autoOpenedRef = useRef(false);
useEffect(() => {
if (autoOpenedRef.current || loading) return;
const reportIdParam = searchParams?.get("reportId");
if (!reportIdParam) return;
autoOpenedRef.current = true;
const match = reports.find((r) => r.reportId === reportIdParam);
if (match) {
setSelectedReport(match);
} else {
setError(`Report ${reportIdParam} is not on the first page — it may be older. Page through or filter by tenant to find it.`);
}
}, [loading, reports, searchParams, setError]);
useEffect(() => {
if (selectedReport) {
setAdminNoteValue(selectedReport.adminNote ?? "");
@@ -272,7 +310,7 @@ export function SessionReportsSection({
onKeyDown={(e) => {
if (e.key === "Enter") handleApplyTenantFilter();
}}
className="w-72 px-2 py-1 text-xs font-mono border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
className="w-72 px-2 py-1 text-xs font-mono border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-green-500 focus:border-green-500"
/>
<button
onClick={handleApplyTenantFilter}
@@ -306,11 +344,10 @@ export function SessionReportsSection({
{/* Reports Table */}
<div className="p-6 pt-2">
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-indigo-600"></div>
<span className="ml-3 text-sm text-gray-500 dark:text-gray-400">Loading reports...</span>
</div>
{/* Skeleton only on the initial (empty) load — a Refresh keeps the
populated table on screen; the button already shows "Loading...". */}
{loading && reports.length === 0 ? (
<TableSkeleton columns={7} rows={8} />
) : reports.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<svg className="w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -476,6 +513,22 @@ export function SessionReportsSection({
{selectedReport.blobName}
</dd>
</div>
{selectedReport.diagnosticsBlobName && (
<div>
<dt className="font-medium text-gray-500 dark:text-gray-400">Diagnostics Archive</dt>
<dd className="font-mono text-xs text-gray-700 dark:text-gray-300 mt-0.5 break-all bg-gray-50 dark:bg-gray-700/50 rounded p-2">
{selectedReport.diagnosticsBlobName}
</dd>
</div>
)}
{selectedReport.diagnosticsCopyStatus && selectedReport.diagnosticsCopyStatus !== "Copied" && (
<div>
<dt className="font-medium text-gray-500 dark:text-gray-400">Diagnostics Copy</dt>
<dd className="text-xs text-amber-700 dark:text-amber-400 mt-0.5">
{selectedReport.diagnosticsCopyStatus}
</dd>
</div>
)}
{/* Admin Note */}
<div className="pt-1 border-t border-gray-100 dark:border-gray-700">
@@ -492,7 +545,7 @@ export function SessionReportsSection({
readOnly={!canMutate}
rows={3}
placeholder={canMutate ? "Add an internal note about this report..." : "No note"}
className="w-full text-sm rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 resize-none placeholder:text-gray-400 read-only:opacity-60 read-only:cursor-not-allowed"
className="w-full text-sm rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500 resize-none placeholder:text-gray-400 read-only:opacity-60 read-only:cursor-not-allowed"
/>
<div className="flex items-center justify-between mt-1.5">
<div className="text-xs">
@@ -511,7 +564,7 @@ export function SessionReportsSection({
<button
onClick={handleSaveAdminNote}
disabled={!canMutate || savingNote}
className="flex items-center gap-1.5 px-3 py-1.5 bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-400 text-white rounded-md transition-colors text-xs font-medium disabled:cursor-not-allowed"
className="flex items-center gap-1.5 px-3 py-1.5 bg-green-600 hover:bg-green-700 disabled:bg-indigo-400 text-white rounded-md transition-colors text-xs font-medium disabled:cursor-not-allowed"
>
{savingNote ? (
<>
@@ -529,28 +582,54 @@ export function SessionReportsSection({
</dl>
<div className="mt-6 flex items-center justify-between">
<button
onClick={() => handleDownload(selectedReport.blobName)}
disabled={downloadingBlob === selectedReport.blobName}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-400 text-white rounded-md transition-colors text-sm font-medium"
>
{downloadingBlob === selectedReport.blobName ? (
<>
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Preparing...
</>
) : (
<>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Download ZIP
</>
<div className="flex items-center gap-2">
<button
onClick={() => handleDownload(selectedReport.blobName)}
disabled={downloadingBlob === selectedReport.blobName}
className="flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-indigo-400 text-white rounded-md transition-colors text-sm font-medium"
>
{downloadingBlob === selectedReport.blobName ? (
<>
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Preparing...
</>
) : (
<>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Download ZIP
</>
)}
</button>
{selectedReport.diagnosticsBlobName && (
<button
onClick={() => handleDownload(selectedReport.diagnosticsBlobName!)}
disabled={downloadingBlob === selectedReport.diagnosticsBlobName}
className="flex items-center gap-2 px-4 py-2 bg-emerald-600 hover:bg-emerald-700 disabled:bg-emerald-400 text-white rounded-md transition-colors text-sm font-medium"
>
{downloadingBlob === selectedReport.diagnosticsBlobName ? (
<>
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Preparing...
</>
) : (
<>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Download Diagnostics
</>
)}
</button>
)}
</button>
</div>
<button
onClick={() => setSelectedReport(null)}
className="px-4 py-2 bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-200 rounded-md hover:bg-gray-300 dark:hover:bg-gray-500 transition-colors"
@@ -423,7 +423,7 @@ export function TenantAdminSection({
onChange={(e) => setNewAdminEmail(e.target.value)}
placeholder="admin@tenant.com"
autoComplete="off"
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Suspense, useEffect, useRef, useState } from "react";
import { useSearchParams } from "next/navigation";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { TenantAdminSection } from "./TenantAdminSection";
@@ -48,7 +49,18 @@ export interface TenantManagementSectionProps {
setSuccessMessage: (message: string | null) => void;
}
export function TenantManagementSection({
export function TenantManagementSection(props: TenantManagementSectionProps) {
// useSearchParams() in the inner component requires a Suspense boundary for
// static prerender (this section renders inside the prerendered
// /admin/tenants/management page).
return (
<Suspense fallback={null}>
<TenantManagementSectionInner {...props} />
</Suspense>
);
}
function TenantManagementSectionInner({
tenants,
loadingTenants,
fetchTenants,
@@ -62,6 +74,21 @@ export function TenantManagementSection({
// Read-only Global Readers may view tenants (incl. config report) but not edit them.
const canMutate = useCanMutatePlatform();
const [searchQuery, setSearchQuery] = useState("");
// Mount-time clock for trial-expiry checks — render must stay pure
// (react-hooks/purity); day-granularity expiry doesn't need a live clock.
const [nowMs] = useState(() => Date.now());
// Deep link from GA notifications: ?tenantId=… seeds the search box once, so the
// list opens filtered to the tenant the notification is about.
const searchParams = useSearchParams();
const seededSearchRef = useRef(false);
useEffect(() => {
if (seededSearchRef.current) return;
const tenantIdParam = searchParams?.get("tenantId");
if (!tenantIdParam) return;
seededSearchRef.current = true;
setSearchQuery(tenantIdParam);
}, [searchParams]);
const [showOnlyWaitlist, setShowOnlyWaitlist] = useState(false);
const [showOnlyReady, setShowOnlyReady] = useState(false);
const [tenantSectionExpanded, setTenantSectionExpanded] = useState(false);
@@ -331,7 +358,7 @@ export function TenantManagementSection({
placeholder={'Search by domain or tenant ID (use "..." for exact match)'}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-10 py-2 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
className="w-full pl-10 pr-10 py-2 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
/>
{searchQuery && (
<button
@@ -349,7 +376,7 @@ export function TenantManagementSection({
onClick={() => { setShowOnlyReady(v => !v); setCurrentPage(0); }}
className={`flex items-center space-x-1 px-3 py-2 text-sm rounded-lg border transition-colors whitespace-nowrap ${
showOnlyReady
? 'bg-blue-600 text-white border-blue-600'
? 'bg-green-600 text-white border-blue-600'
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
}`}
>
@@ -432,7 +459,7 @@ export function TenantManagementSection({
className={`px-3 py-2 text-sm rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
previewApproved.has(tenant.tenantId)
? 'bg-amber-500 text-white hover:bg-amber-600'
: 'bg-blue-600 text-white hover:bg-blue-700'
: 'bg-green-600 text-white hover:bg-green-700'
}`}
>
{togglingPreviewTenant === tenant.tenantId
@@ -573,7 +600,7 @@ export function TenantManagementSection({
{(() => {
const isEnterpriseTier = editingTenant.planTier === "enterprise";
const trialActive = !!editingTenant.trialExpiresUtc &&
new Date(editingTenant.trialExpiresUtc).getTime() > Date.now();
new Date(editingTenant.trialExpiresUtc).getTime() > nowMs;
const effective = isEnterpriseTier || trialActive ? "Enterprise" : "Community";
return (
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
@@ -670,7 +697,7 @@ export function TenantManagementSection({
value={notificationEmail}
onChange={(e) => setNotificationEmail(e.target.value)}
placeholder="user@example.com"
className="flex-1 min-w-0 px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
className="flex-1 min-w-0 px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
/>
<button
onClick={() => handleSendWelcomeEmail(editingTenant.tenantId, notificationEmail)}
@@ -718,7 +745,7 @@ export function TenantManagementSection({
const v = e.target.value.trim();
setEditingTenant({ ...editingTenant, customRateLimitRequestsPerMinute: v === "" ? null : (parseInt(v) || null) });
}}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
/>
<p className="mt-1 text-xs text-gray-500">Per-device (agent/cert) limit. Leave blank to inherit the global default.</p>
</div>
@@ -736,7 +763,7 @@ export function TenantManagementSection({
const v = e.target.value.trim();
setEditingTenant({ ...editingTenant, customUserRateLimitRequestsPerMinute: v === "" ? null : (parseInt(v) || null) });
}}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
/>
<p className="mt-1 text-xs text-gray-500">Per-user (portal) limit for standard users. Leave blank to inherit the global default. Does not apply to Global Admins.</p>
</div>
@@ -787,7 +814,7 @@ export function TenantManagementSection({
const val = parseInt(e.target.value);
setEditingTenant({ ...editingTenant, dataRetentionDays: isNaN(val) ? 90 : val });
}}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
/>
{editingTenant.dataRetentionDays === 0 ? (
<p className="text-xs text-amber-600 mt-1 font-medium">⚠ Infinite retention — data will never be automatically deleted</p>
@@ -16,7 +16,7 @@ export function TenantSearchSelect({
value,
onChange,
placeholder = "Search tenant by name or ID\u2026",
focusRingClass = "focus:ring-blue-500 focus:border-blue-500",
focusRingClass = "focus:ring-green-500 focus:border-green-500",
}: TenantSearchSelectProps) {
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
@@ -1,9 +1,11 @@
"use client";
import { sessionUrl } from "@/lib/routes";
import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import TruncatedLabel from "@/components/TruncatedLabel";
import { TableSkeleton } from "@/components/skeletons/TableSkeleton";
import { trackEvent } from "@/lib/appInsights";
import type { UnmatchedSoftwareEntry, AutoResolveResult } from "./SoftwareMappingTypes";
@@ -26,7 +28,8 @@ export function UnmappedSoftwareTab({
}: UnmappedSoftwareTabProps) {
// Unmapped state — server-side pagination
const pageSize = 20;
const [loading, setLoading] = useState(false);
// true: a fetch fires on mount (TableSkeleton convention — avoids an empty-state flash)
const [loading, setLoading] = useState(true);
const [initialLoaded, setInitialLoaded] = useState(false);
const [entries, setEntries] = useState<UnmatchedSoftwareEntry[]>([]);
const [total, setTotal] = useState(0);
@@ -377,15 +380,12 @@ export function UnmappedSoftwareTab({
// --- Render ---
// Full-page spinner only on initial load. Subsequent page fetches keep the table visible
// Table skeleton only on initial load. Subsequent page fetches keep the table visible
// to avoid flicker; controls are disabled via the `loading` state.
return (
<>
{loading && !initialLoaded ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-amber-600" />
<span className="ml-3 text-sm text-gray-500 dark:text-gray-400">Loading unmatched software...</span>
</div>
<TableSkeleton columns={6} rows={8} className="pt-4" />
) : initialLoaded && total === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<svg className="w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -472,7 +472,7 @@ export function UnmappedSoftwareTab({
{autoResolving && autoResolveProgress && (
<div className="mb-3 px-4 py-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-md text-sm">
<div className="flex items-center gap-2 text-blue-700 dark:text-blue-300">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600" />
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-green-600" />
<span>
Auto-resolving... Batch {autoResolveProgress.batchIndex}/{autoResolveProgress.totalBatches}
{autoResolveProgress.resolvedSoFar > 0 && <> &middot; Resolved: {autoResolveProgress.resolvedSoFar}</>}
@@ -532,7 +532,7 @@ export function UnmappedSoftwareTab({
checked={allOnPageSelected && entries.length > 0}
onChange={allOnPageSelected ? deselectAll : selectAllOnPage}
disabled={autoResolving}
className="h-4 w-4 rounded border-gray-300 text-amber-600 focus:ring-amber-500 disabled:opacity-40"
className="h-4 w-4 rounded border-gray-300 text-green-600 focus:ring-green-500 disabled:opacity-40"
/>
</th>
<th className="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Software Name</th>
@@ -561,7 +561,7 @@ export function UnmappedSoftwareTab({
checked={selectedEntries.has(key)}
onChange={() => toggleRowSelection(entry)}
disabled={autoResolving}
className="h-4 w-4 rounded border-gray-300 text-amber-600 focus:ring-amber-500 disabled:opacity-40"
className="h-4 w-4 rounded border-gray-300 text-green-600 focus:ring-green-500 disabled:opacity-40"
/>
)}
</div>
@@ -579,7 +579,7 @@ export function UnmappedSoftwareTab({
</div>
<div className="px-3 py-3 text-sm flex-shrink-0 w-20">
<a
href={`/sessions/${entry.exampleSessionId}`}
href={sessionUrl(entry.exampleSessionId)}
className="text-amber-600 hover:text-amber-700 dark:text-amber-400 dark:hover:text-amber-300 font-mono text-xs hover:underline"
onClick={(e) => e.stopPropagation()}
>
@@ -517,7 +517,7 @@ export function VulnerabilityDataSection({
<button
onClick={handleSyncMsrc}
disabled={!canMutate || syncingMsrc || syncing || reseedingSeed || reseedingCommunity}
className="inline-flex items-center px-4 py-2 text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-sm"
className="inline-flex items-center px-4 py-2 text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-sm"
title="Refreshes the MSRC CVRF index (last 6 Patch Tuesdays from api.msrc.microsoft.com)"
>
{syncingMsrc ? (
@@ -1,7 +1,8 @@
"use client";
import { useCallback, useEffect, useState, use as usePromise } from "react";
import { Suspense, useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import {
api,
type CustomsArchiveEntrySummary,
@@ -9,19 +10,24 @@ import {
type CustomsArchiveListEntriesResponse,
} from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { useAdminConfig } from "../../../AdminConfigContext";
import { AdminNotifications } from "../../../AdminNotifications";
import { DeleteConfirmModal } from "../../components/DeleteConfirmModal";
import { useAdminConfig } from "../../AdminConfigContext";
import { AdminNotifications } from "../../AdminNotifications";
import { DeleteConfirmModal } from "../components/DeleteConfirmModal";
interface PageProps {
params: Promise<{ tenantId: string; historyRowKey: string }>;
export default function CustomsArchiveDetailPage() {
// useSearchParams() in CustomsArchiveDetailContent requires a Suspense boundary
// for static prerender.
return (
<Suspense fallback={null}>
<CustomsArchiveDetailContent />
</Suspense>
);
}
export default function CustomsArchiveDetailPage({ params }: PageProps) {
// Next.js 15: route params are async. Resolve once.
const resolved = usePromise(params);
const tenantId = decodeURIComponent(resolved.tenantId);
const historyRowKey = decodeURIComponent(resolved.historyRowKey);
function CustomsArchiveDetailContent() {
const searchParams = useSearchParams();
const tenantId = searchParams?.get("tenantId") ?? "";
const historyRowKey = searchParams?.get("rowKey") ?? "";
const { getAccessToken, setError, setSuccessMessage } = useAdminConfig();
const [entries, setEntries] = useState<CustomsArchiveEntrySummary[]>([]);
@@ -137,7 +143,7 @@ export default function CustomsArchiveDetailPage({ params }: PageProps) {
type="button"
onClick={load}
disabled={loading}
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm rounded-md transition-colors"
className="px-3 py-1.5 bg-green-600 hover:bg-green-700 disabled:opacity-50 text-white text-sm rounded-md transition-colors"
>
{loading ? "Loading…" : "Refresh"}
</button>
@@ -1,5 +1,6 @@
"use client";
import { customsArchiveUrl } from "@/lib/routes";
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import {
@@ -101,7 +102,7 @@ export default function CustomsArchivePage() {
type="button"
onClick={load}
disabled={loading}
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm rounded-md transition-colors"
className="px-3 py-1.5 bg-green-600 hover:bg-green-700 disabled:opacity-50 text-white text-sm rounded-md transition-colors"
>
{loading ? "Loading…" : "Refresh"}
</button>
@@ -134,7 +135,7 @@ export default function CustomsArchivePage() {
</div>
<div className="flex items-center gap-2">
<Link
href={`/admin/customs-archive/${encodeURIComponent(run.tenantId)}/${encodeURIComponent(run.historyRowKey)}`}
href={customsArchiveUrl(run.tenantId, run.historyRowKey)}
className="px-3 py-1.5 bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-900 dark:text-white text-sm rounded-md transition-colors"
>
Inspect
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/skeletons/PageSkeleton";
export default function Loading() {
return <PageSkeleton cards={0} rows={8} />;
}
@@ -0,0 +1,19 @@
"use client";
import { notFound } from "next/navigation";
import { type MetricsSectionId } from "../metricsNavSections";
import { SectionAgentMetrics } from "../sections/SectionAgentMetrics";
import { SectionPlatformUsage } from "../sections/SectionPlatformUsage";
import { SectionMcpUsage } from "../sections/SectionMcpUsage";
const SECTION_COMPONENTS: Record<MetricsSectionId, React.ComponentType> = {
"platform-metrics": SectionAgentMetrics,
"usage": SectionPlatformUsage,
"mcp-usage": SectionMcpUsage,
};
export function SectionClient({ section }: { section: string }) {
const SectionContent = SECTION_COMPONENTS[section as MetricsSectionId];
if (!SectionContent) notFound();
return <SectionContent />;
}
@@ -1,22 +1,20 @@
"use client";
import { METRICS_NAV_SECTIONS } from "../metricsNavSections";
import { SectionClient } from "./SectionClient";
import { useParams } from "next/navigation";
import { notFound } from "next/navigation";
import { type MetricsSectionId } from "../metricsNavSections";
import { SectionAgentMetrics } from "../sections/SectionAgentMetrics";
import { SectionPlatformUsage } from "../sections/SectionPlatformUsage";
import { SectionMcpUsage } from "../sections/SectionMcpUsage";
// Static export: prerender exactly the registered sections; anything else is a
// build-time 404 (dynamicParams=false). The interactive body lives in
// SectionClient — generateStaticParams cannot be exported from a client file.
export const dynamicParams = false;
const SECTION_COMPONENTS: Record<MetricsSectionId, React.ComponentType> = {
"platform-metrics": SectionAgentMetrics,
"usage": SectionPlatformUsage,
"mcp-usage": SectionMcpUsage,
};
export default function MetricsSectionPage() {
const params = useParams();
const section = params.section as string;
const SectionContent = SECTION_COMPONENTS[section as MetricsSectionId];
if (!SectionContent) notFound();
return <SectionContent />;
export function generateStaticParams() {
return METRICS_NAV_SECTIONS.map((s) => ({ section: s.id }));
}
export default async function Page({
params,
}: {
params: Promise<{ section: string }>;
}) {
const { section } = await params;
return <SectionClient section={section} />;
}
@@ -0,0 +1,5 @@
import { SectionSkeleton } from "@/components/skeletons/SectionSkeleton";
export default function Loading() {
return <SectionSkeleton columns={6} rows={8} />;
}
@@ -1,5 +1,9 @@
import { redirect } from "next/navigation";
"use client";
export default function MetricsPage() {
redirect("/admin/metrics/platform-metrics");
import { ClientRedirect } from "@/components/ClientRedirect";
// Index route → default section. Client-side replace: server redirect() is not
// supported under output:'export'.
export default function IndexRedirect() {
return <ClientRedirect to="/admin/metrics/platform-metrics" />;
}
@@ -1,7 +1,6 @@
'use client';
import { useEffect, useState, useMemo, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { api } from '@/lib/api';
import TruncatedLabel from '@/components/TruncatedLabel';
import { useAuth } from '../../../../contexts/AuthContext';
@@ -138,9 +137,7 @@ function pN(values: number[], percentile: number): number {
// ── Component ──────────────────────────────────────────────────────────────────
export function SectionAgentMetrics() {
const router = useRouter();
const { getAccessToken, user } = useAuth();
const { getAccessToken } = useAuth();
const { addNotification } = useNotifications();
const [loading, setLoading] = useState(true);
@@ -845,8 +842,6 @@ function StatCard({ label, value, detail, color }: { label: string; value: strin
function FootprintBadge({
value,
thresholds,
unit,
formatFn,
}: {
value: number;
thresholds: [number, number, number];
@@ -1,5 +1,6 @@
"use client";
import { SegmentedControl, TIME_RANGE_OPTIONS } from "@/components/SegmentedControl";
import { useCallback, useEffect, useState } from "react";
import { useAuth } from "../../../../contexts/AuthContext";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
@@ -134,21 +135,11 @@ export function SectionMcpUsage() {
<p className="text-sm text-gray-500">API usage across all MCP-enabled users</p>
</div>
<div className="flex items-center gap-3">
<div className="flex rounded-md shadow-sm">
{(["7d", "30d", "90d"] as DateRange[]).map((range) => (
<button
key={range}
onClick={() => setDateRange(range)}
className={`px-3 py-1.5 text-sm font-medium border ${
dateRange === range
? "bg-indigo-600 text-white border-indigo-600 z-10"
: "bg-white text-gray-700 border-gray-300 hover:bg-gray-50"
} ${range === "7d" ? "rounded-l-md" : ""} ${range === "90d" ? "rounded-r-md" : ""} -ml-px first:ml-0`}
>
{range}
</button>
))}
</div>
<SegmentedControl
options={TIME_RANGE_OPTIONS}
value={dateRange}
onChange={(v) => setDateRange(v as DateRange)}
/>
<button
onClick={() => fetchData(dateRange)}
disabled={loading}
@@ -186,7 +186,7 @@ export function SectionPlatformUsage() {
if (error) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
<div className="min-h-screen bg-[var(--lp-bg)] flex items-center justify-center p-4">
<div className="bg-white rounded-lg shadow-xl p-8 max-w-md w-full">
<div className="text-center">
<svg className="h-12 w-12 text-red-500 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -197,7 +197,7 @@ export function SectionPlatformUsage() {
<div className="mt-6 space-y-3">
<button
onClick={() => fetchMetrics()}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
className="w-full px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
Retry
</button>
@@ -1,5 +1,6 @@
"use client";
import { useEffect } from "react";
import { useAdminConfig } from "../AdminConfigContext";
import { MaintenanceTriggerSection } from "../components/MaintenanceTriggerSection";
import { OpsEventsSection } from "../components/OpsEventsSection";
@@ -7,6 +8,7 @@ import { AdminNotifications } from "../AdminNotifications";
export default function OpsPage() {
const {
ensureAdminConfigLoaded,
getAccessToken,
setError,
setSuccessMessage,
@@ -16,6 +18,9 @@ export default function OpsPage() {
savingConfig,
} = useAdminConfig();
// opsEventRetentionDays + handleSaveAdminConfig operate on adminConfig
useEffect(() => { ensureAdminConfigLoaded(); }, [ensureAdminConfigLoaded]);
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<header className="bg-white dark:bg-gray-800 shadow dark:shadow-gray-700">
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { useAdminConfig } from "../../../AdminConfigContext";
@@ -57,7 +57,9 @@ interface TenantsWithManifestsResponse {
* that opens the dialog with the selection pre-filled.
*/
export function RestoreBrowserTab({ getAccessToken, setError, setSuccessMessage }: RestoreBrowserTabProps) {
const { tenants, loadingTenants } = useAdminConfig();
const { ensureTenantsLoaded, tenants, loadingTenants } = useAdminConfig();
useEffect(() => { ensureTenantsLoaded(); }, [ensureTenantsLoaded]);
const [selectedTenantId, setSelectedTenantId] = useState<string>("");
// loadedTenantId pairs the displayed sessions with the tenant they belong to. The dropdown's
// selectedTenantId can race ahead of an in-flight fetch; rendering only when the two match
@@ -274,7 +276,7 @@ export function RestoreBrowserTab({ getAccessToken, setError, setSuccessMessage
setSelectedTenantId("");
}
}}
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500"
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-purple-600 accent-purple-600 focus:ring-purple-500"
/>
<label
htmlFor="restore-browser-only-with-data"
@@ -136,7 +136,7 @@ export function RestoreConfirmDialog({
placeholder="e.g. customer support — accidental delete"
maxLength={200}
disabled={submitting}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded text-sm text-gray-900 dark:text-white bg-white dark:bg-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-60"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded text-sm text-gray-900 dark:text-white bg-white dark:bg-gray-900 focus:outline-none focus:ring-2 focus:ring-green-500 disabled:opacity-60"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Stored on the <code className="bg-gray-100 dark:bg-gray-700 px-1 rounded">deletion_restored</code> audit row when the real restore runs. Dry-runs are intentionally audit-free.
@@ -174,7 +174,7 @@ export function RestoreConfirmDialog({
<button
onClick={() => submit(true)}
disabled={submitting}
className="px-3 py-2 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
className="px-3 py-2 text-sm bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50"
>
{submitting ? "Running…" : dryRunResult ? "Run dry-run again" : "Run dry-run"}
</button>

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