Compare commits

..

23 Commits

Author SHA1 Message Date
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
230 changed files with 4390 additions and 2264 deletions
+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:
+3
View File
@@ -148,3 +148,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
@@ -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"));
}
@@ -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
@@ -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,
@@ -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 }
}
);
}
@@ -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; }
}
}
@@ -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,
@@ -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);
@@ -1,5 +1,6 @@
"use client";
import { backupUrl } from "@/lib/routes";
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import {
@@ -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>
@@ -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"
@@ -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");
@@ -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,7 +28,9 @@ 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");
@@ -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">
@@ -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 (
@@ -1,5 +1,6 @@
"use client";
import { sessionUrl } from "@/lib/routes";
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { api } from "@/lib/api";
@@ -565,7 +566,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"
@@ -8,6 +8,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 +24,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 }) {
@@ -79,7 +84,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("");
@@ -306,11 +313,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 +482,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">
@@ -529,28 +551,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-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
</>
)}
</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"
@@ -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">
@@ -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()}
>
@@ -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[]>([]);
@@ -1,5 +1,6 @@
"use client";
import { customsArchiveUrl } from "@/lib/routes";
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import {
@@ -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,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">
@@ -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
@@ -0,0 +1,21 @@
"use client";
import { notFound } from "next/navigation";
import { REPORTS_NAV_SECTIONS, type ReportsSectionId } from "../reportsNavSections";
import { SectionSessionReports } from "../sections/SectionSessionReports";
import { SectionDistressReports } from "../sections/SectionDistressReports";
import { SectionUserFeedback } from "../sections/SectionUserFeedback";
import { SectionSessionExport } from "../sections/SectionSessionExport";
const SECTION_COMPONENTS: Record<ReportsSectionId, React.ComponentType> = {
"session-reports": SectionSessionReports,
"distress-reports": SectionDistressReports,
"user-feedback": SectionUserFeedback,
"session-export": SectionSessionExport,
};
export function SectionClient({ section }: { section: string }) {
const SectionContent = SECTION_COMPONENTS[section as ReportsSectionId];
if (!SectionContent) notFound();
return <SectionContent />;
}
@@ -1,24 +1,20 @@
"use client";
import { REPORTS_NAV_SECTIONS } from "../reportsNavSections";
import { SectionClient } from "./SectionClient";
import { useParams } from "next/navigation";
import { notFound } from "next/navigation";
import { REPORTS_NAV_SECTIONS, type ReportsSectionId } from "../reportsNavSections";
import { SectionSessionReports } from "../sections/SectionSessionReports";
import { SectionDistressReports } from "../sections/SectionDistressReports";
import { SectionUserFeedback } from "../sections/SectionUserFeedback";
import { SectionSessionExport } from "../sections/SectionSessionExport";
// 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<ReportsSectionId, React.ComponentType> = {
"session-reports": SectionSessionReports,
"distress-reports": SectionDistressReports,
"user-feedback": SectionUserFeedback,
"session-export": SectionSessionExport,
};
export default function ReportsSectionPage() {
const params = useParams();
const section = params.section as string;
const SectionContent = SECTION_COMPONENTS[section as ReportsSectionId];
if (!SectionContent) notFound();
return <SectionContent />;
export function generateStaticParams() {
return REPORTS_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={7} rows={10} />;
}
@@ -1,5 +1,9 @@
import { redirect } from "next/navigation";
"use client";
export default function ReportsPage() {
redirect("/admin/reports/session-reports");
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/reports/session-reports" />;
}
@@ -1,11 +1,15 @@
"use client";
import { useEffect } from "react";
import { useAdminConfig } from "../../AdminConfigContext";
import { SessionExportSection } from "../../components/SessionExportSection";
import { AdminNotifications } from "../../AdminNotifications";
export function SectionSessionExport() {
const { tenants, getAccessToken } = useAdminConfig();
const { ensureTenantsLoaded, tenants, getAccessToken } = useAdminConfig();
useEffect(() => { ensureTenantsLoaded(); }, [ensureTenantsLoaded]);
return (
<>
<AdminNotifications />
@@ -0,0 +1,19 @@
"use client";
import { notFound } from "next/navigation";
import { SECURITY_NAV_SECTIONS, type SecuritySectionId } from "../securityNavSections";
import { SectionDeviceBlock } from "../sections/SectionDeviceBlock";
import { SectionVersionBlock } from "../sections/SectionVersionBlock";
import { SectionVulnerabilityData } from "../sections/SectionVulnerabilityData";
const SECTION_COMPONENTS: Record<SecuritySectionId, React.ComponentType> = {
"device-block": SectionDeviceBlock,
"version-block": SectionVersionBlock,
"vulnerability-data": SectionVulnerabilityData,
};
export function SectionClient({ section }: { section: string }) {
const SectionContent = SECTION_COMPONENTS[section as SecuritySectionId];
if (!SectionContent) notFound();
return <SectionContent />;
}
@@ -1,22 +1,20 @@
"use client";
import { SECURITY_NAV_SECTIONS } from "../securityNavSections";
import { SectionClient } from "./SectionClient";
import { useParams } from "next/navigation";
import { notFound } from "next/navigation";
import { SECURITY_NAV_SECTIONS, type SecuritySectionId } from "../securityNavSections";
import { SectionDeviceBlock } from "../sections/SectionDeviceBlock";
import { SectionVersionBlock } from "../sections/SectionVersionBlock";
import { SectionVulnerabilityData } from "../sections/SectionVulnerabilityData";
// 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<SecuritySectionId, React.ComponentType> = {
"device-block": SectionDeviceBlock,
"version-block": SectionVersionBlock,
"vulnerability-data": SectionVulnerabilityData,
};
export default function SecuritySectionPage() {
const params = useParams();
const section = params.section as string;
const SectionContent = SECTION_COMPONENTS[section as SecuritySectionId];
if (!SectionContent) notFound();
return <SectionContent />;
export function generateStaticParams() {
return SECURITY_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 SecurityPage() {
redirect("/admin/security/device-block");
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/security/device-block" />;
}
@@ -1,11 +1,14 @@
"use client";
import { useEffect } from "react";
import { useAdminConfig } from "../../AdminConfigContext";
import { DeviceBlockSection } from "../../components/DeviceBlockSection";
import { AdminNotifications } from "../../AdminNotifications";
export function SectionDeviceBlock() {
const { tenants, getAccessToken, setError, setSuccessMessage } = useAdminConfig();
const { ensureTenantsLoaded, tenants, getAccessToken, setError, setSuccessMessage } = useAdminConfig();
useEffect(() => { ensureTenantsLoaded(); }, [ensureTenantsLoaded]);
return (
<>
@@ -1,11 +1,15 @@
"use client";
import { useEffect } from "react";
import { useAdminConfig } from "../../AdminConfigContext";
import { VulnerabilityDataSection } from "../../components/VulnerabilityDataSection";
import { AdminNotifications } from "../../AdminNotifications";
export function SectionVulnerabilityData() {
const { adminConfig, loadingConfig, getAccessToken, setError, setSuccessMessage, setAdminConfig } = useAdminConfig();
const { ensureAdminConfigLoaded, adminConfig, loadingConfig, getAccessToken, setError, setSuccessMessage, setAdminConfig } = useAdminConfig();
useEffect(() => { ensureAdminConfigLoaded(); }, [ensureAdminConfigLoaded]);
return (
<>
<AdminNotifications />
@@ -0,0 +1,29 @@
"use client";
import { notFound } from "next/navigation";
import { type SettingsSectionId } from "../settingsNavSections";
import { SectionGlobalSettings } from "../sections/SectionGlobalSettings";
import { SectionDiagnosticsLogPaths } from "../sections/SectionDiagnosticsLogPaths";
import { SectionMcpUsers } from "../sections/SectionMcpUsers";
import { SectionDelegatedAdmins } from "../sections/SectionDelegatedAdmins";
import { SectionTenantGroups } from "../sections/SectionTenantGroups";
import { SectionConfigReseed } from "../sections/SectionConfigReseed";
import { SectionUsagePlans } from "../sections/SectionUsagePlans";
import { SectionAlerts } from "../sections/SectionAlerts";
const SECTION_COMPONENTS: Record<SettingsSectionId, React.ComponentType> = {
"global": SectionGlobalSettings,
"diagnostics-log-paths": SectionDiagnosticsLogPaths,
"mcp-users": SectionMcpUsers,
"delegated-admins": SectionDelegatedAdmins,
"tenant-groups": SectionTenantGroups,
"config-reseed": SectionConfigReseed,
"usage-plans": SectionUsagePlans,
"alerts": SectionAlerts,
};
export function SectionClient({ section }: { section: string }) {
const SectionContent = SECTION_COMPONENTS[section as SettingsSectionId];
if (!SectionContent) notFound();
return <SectionContent />;
}
@@ -1,32 +1,20 @@
"use client";
import { SETTINGS_NAV_SECTIONS } from "../settingsNavSections";
import { SectionClient } from "./SectionClient";
import { useParams } from "next/navigation";
import { notFound } from "next/navigation";
import { type SettingsSectionId } from "../settingsNavSections";
import { SectionGlobalSettings } from "../sections/SectionGlobalSettings";
import { SectionDiagnosticsLogPaths } from "../sections/SectionDiagnosticsLogPaths";
import { SectionMcpUsers } from "../sections/SectionMcpUsers";
import { SectionDelegatedAdmins } from "../sections/SectionDelegatedAdmins";
import { SectionTenantGroups } from "../sections/SectionTenantGroups";
import { SectionConfigReseed } from "../sections/SectionConfigReseed";
import { SectionUsagePlans } from "../sections/SectionUsagePlans";
import { SectionAlerts } from "../sections/SectionAlerts";
// 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<SettingsSectionId, React.ComponentType> = {
"global": SectionGlobalSettings,
"diagnostics-log-paths": SectionDiagnosticsLogPaths,
"mcp-users": SectionMcpUsers,
"delegated-admins": SectionDelegatedAdmins,
"tenant-groups": SectionTenantGroups,
"config-reseed": SectionConfigReseed,
"usage-plans": SectionUsagePlans,
"alerts": SectionAlerts,
};
export default function SettingsSectionPage() {
const params = useParams();
const section = params.section as string;
const SectionContent = SECTION_COMPONENTS[section as SettingsSectionId];
if (!SectionContent) notFound();
return <SectionContent />;
export function generateStaticParams() {
return SETTINGS_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 SettingsPage() {
redirect("/admin/settings/global");
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/settings/global" />;
}
@@ -1,11 +1,13 @@
"use client";
import { useEffect } from "react";
import { useAdminConfig } from "../../AdminConfigContext";
import { OpsAlertRulesSection } from "../../components/OpsAlertRulesSection";
import { AdminNotifications } from "../../AdminNotifications";
export function SectionAlerts() {
const {
ensureAdminConfigLoaded,
loadingConfig,
savingOpsAlerts,
adminConfig,
@@ -23,6 +25,8 @@ export function SectionAlerts() {
handleSaveOpsAlertConfig,
} = useAdminConfig();
useEffect(() => { ensureAdminConfigLoaded(); }, [ensureAdminConfigLoaded]);
return (
<>
<AdminNotifications />
@@ -1,11 +1,14 @@
"use client";
import { useEffect } from "react";
import { useAdminConfig } from "../../AdminConfigContext";
import { DiagnosticsLogPathsSection } from "../../components/DiagnosticsLogPathsSection";
import { AdminNotifications } from "../../AdminNotifications";
export function SectionDiagnosticsLogPaths() {
const { globalDiagPaths, setGlobalDiagPaths, loadingConfig, savingDiagPaths, adminConfig, handleSaveDiagPaths } = useAdminConfig();
const { ensureAdminConfigLoaded, globalDiagPaths, setGlobalDiagPaths, loadingConfig, savingDiagPaths, adminConfig, handleSaveDiagPaths } = useAdminConfig();
useEffect(() => { ensureAdminConfigLoaded(); }, [ensureAdminConfigLoaded]);
return (
<>
@@ -1,11 +1,13 @@
"use client";
import { useEffect } from "react";
import { useAdminConfig } from "../../AdminConfigContext";
import { AdminConfigSettingsSection } from "../../components/AdminConfigSettingsSection";
import { AdminNotifications } from "../../AdminNotifications";
export function SectionGlobalSettings() {
const {
ensureAdminConfigLoaded,
loadingConfig, savingConfig, adminConfig,
globalRateLimit, setGlobalRateLimit,
userRateLimit, setUserRateLimit,
@@ -23,6 +25,8 @@ export function SectionGlobalSettings() {
handleSaveAdminConfig, handleResetAdminConfig,
} = useAdminConfig();
useEffect(() => { ensureAdminConfigLoaded(); }, [ensureAdminConfigLoaded]);
return (
<>
<AdminNotifications />
@@ -5,6 +5,7 @@ import { useAuth } from "../../../../contexts/AuthContext";
import { useAdminConfig } from "../../AdminConfigContext";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { api } from "@/lib/api";
import { TableSkeleton } from "@/components/skeletons/TableSkeleton";
interface PlanTierDefinition {
name: string;
@@ -22,7 +23,12 @@ const DEFAULT_TIER: PlanTierDefinition = {
export function SectionUsagePlans() {
const { getAccessToken } = useAuth();
const { tenants, userRateLimit } = useAdminConfig();
const { ensureAdminConfigLoaded, ensureTenantsLoaded, tenants, userRateLimit } = useAdminConfig();
useEffect(() => {
ensureAdminConfigLoaded();
ensureTenantsLoaded();
}, [ensureAdminConfigLoaded, ensureTenantsLoaded]);
const [tiers, setTiers] = useState<PlanTierDefinition[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@@ -117,15 +123,6 @@ export function SectionUsagePlans() {
setHasChanges(true);
};
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-indigo-600" />
<span className="ml-3 text-sm text-gray-500">Loading plan definitions...</span>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
@@ -139,7 +136,8 @@ export function SectionUsagePlans() {
<div className="flex items-center gap-2">
<button
onClick={addTier}
className="px-4 py-2 text-sm bg-white border border-gray-300 rounded-md hover:bg-gray-50 transition-colors"
disabled={loading}
className="px-4 py-2 text-sm bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
+ Add Tier
</button>
@@ -175,8 +173,11 @@ export function SectionUsagePlans() {
</div>
)}
{/* Initial load — header stays visible, tier list gets the skeleton */}
{loading && <TableSkeleton wrapped columns={4} rows={3} header={false} />}
{/* Empty State */}
{tiers.length === 0 && (
{!loading && tiers.length === 0 && (
<div className="bg-white rounded-lg shadow p-12 text-center">
<p className="text-gray-500 mb-4">No plan tiers defined yet.</p>
<button
@@ -0,0 +1,21 @@
import { Bar } from "@/components/skeletons/PageSkeleton";
import { TableSkeleton } from "@/components/skeletons/TableSkeleton";
// Route-level fallback matching the Software Mapping page shell: header band +
// mapping card with table. Paints during navigation, before the client bundle
// hydrates and the unmatched-software fetch resolves.
export default function Loading() {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900" aria-busy="true" aria-label="Loading">
<header className="bg-white dark:bg-gray-800 shadow dark:shadow-gray-700">
<div className="py-6 px-4 sm:px-6 lg:px-8 space-y-3">
<Bar className="h-7 w-56" />
<Bar className="h-3 w-80" />
</div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<TableSkeleton columns={6} rows={10} wrapped />
</main>
</div>
);
}
@@ -0,0 +1,17 @@
"use client";
import { notFound } from "next/navigation";
import { type TenantsSectionId } from "../tenantsNavSections";
import { SectionTenantManagement } from "../sections/SectionTenantManagement";
import { SectionTenantConfigReport } from "../sections/SectionTenantConfigReport";
const SECTION_COMPONENTS: Record<TenantsSectionId, React.ComponentType> = {
"management": SectionTenantManagement,
"config-report": SectionTenantConfigReport,
};
export function SectionClient({ section }: { section: string }) {
const SectionContent = SECTION_COMPONENTS[section as TenantsSectionId];
if (!SectionContent) notFound();
return <SectionContent />;
}
@@ -1,19 +1,20 @@
"use client";
import { TENANTS_NAV_SECTIONS } from "../tenantsNavSections";
import { SectionClient } from "./SectionClient";
import { useParams, notFound } from "next/navigation";
import { type TenantsSectionId } from "../tenantsNavSections";
import { SectionTenantManagement } from "../sections/SectionTenantManagement";
import { SectionTenantConfigReport } from "../sections/SectionTenantConfigReport";
// 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<TenantsSectionId, React.ComponentType> = {
"management": SectionTenantManagement,
"config-report": SectionTenantConfigReport,
};
export default function TenantsSectionPage() {
const params = useParams();
const section = params.section as string;
const SectionContent = SECTION_COMPONENTS[section as TenantsSectionId];
if (!SectionContent) notFound();
return <SectionContent />;
export function generateStaticParams() {
return TENANTS_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={10} />;
}
@@ -1,5 +1,9 @@
import { redirect } from "next/navigation";
"use client";
export default function TenantsPage() {
redirect("/admin/tenants/management");
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/tenants/management" />;
}
@@ -1,10 +1,12 @@
"use client";
import { useEffect } from "react";
import { useAdminConfig } from "../../AdminConfigContext";
import { TenantManagementSection } from "../../components/TenantManagementSection";
export function SectionTenantManagement() {
const {
ensureTenantsLoaded,
tenants,
loadingTenants,
fetchTenants,
@@ -16,6 +18,8 @@ export function SectionTenantManagement() {
setSuccessMessage,
} = useAdminConfig();
useEffect(() => { ensureTenantsLoaded(); }, [ensureTenantsLoaded]);
return (
<TenantManagementSection
tenants={tenants}
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/skeletons/PageSkeleton";
export default function Loading() {
return <PageSkeleton cards={0} rows={10} />;
}

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