Commit Graph

1407 Commits

Author SHA1 Message Date
Oliver Kieselbach cacfee4bcc fix(web/sla): violator link points to /sessions/{id} not /session/{tenant}/{id}
The violator table on /sla linked to /session/${tenantId}/${sessionId},
which 404s — the canonical route is /sessions/[sessionId] (single
segment, plural). Same convention as /diagnosis, /admin and the
inspector. Restores navigation and makes sla_violator_opened actually
fire.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 22:08:45 +02:00
Oliver Kieselbach 4a0e56a731 fix(web): use submitted (not live) tenant filter for session fetch URL
User typed a tenant filter without clicking Submit, then clicked Next →
fetchSessionsBatch read the live tenantIdFilterRef and built a URL with the
typed filter, but the continuation token in state was issued before the typing
under a different filter scope. Backend recomputed fingerprint, mismatched →
400 filter_mismatch.

Track the submitted filter in a separate ref, updated only via
refetch/refetchWith/initial-fetch/global-mode-toggle. The live ref keeps
driving client-side display via effectiveSessions; backend pagination always
queries under the scope its continuation was issued for.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 21:12:13 +02:00
Oliver Kieselbach c6e46e9a0a fis dashboard session fetch 2026-05-07 20:55:35 +02:00
Oliver Kieselbach 25dc67ff69 fix(mcp): security hardening - 7 review findings
H1 OAuth redirect_uri host allowlist (loopback always; hosted clients
   via MCP_ALLOWED_REDIRECT_HOSTS, default Anthropic/OpenAI/Google).
   Closes auth-code-leak through unvalidated /oauth/callback.
M1 followNextLink rejects continuation paths that don't equal the
   tool's own basePath - closes cross-tool path substitution.
M2 strict UUID schema on every sessionId input - blocks WHATWG-URL
   path-normalization attacks via /api/sessions/{id}.
M3 rate-limit moved after access-check (Step 1) - forged JWTs cannot
   drain a real user's per-minute budget.
L1 get_rule_stats uses pickGlobalOrTenantPath like every other tool.
L3 5xx response bodies no longer relayed to the caller; structured
   4xx errors keep their detail.
L4 hardcoded CLIENT_ID fallback removed - module throws on boot if
   AUTOPILOT_ENTRA_CLIENT_ID is unset.

Adds security-guards.test.ts (32 unit tests) covering all guards.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 20:22:17 +02:00
Oliver Kieselbach 05ce8cac9a fix(mcp,backend): close two gaps from MCP routing PR — get_ops_events tenantId + global metrics summary tenantId filter
Re-test post-deploy of #60 surfaced two minor follow-ups:

1. get_ops_events MCP tool: backend already accepts tenantId filter
   (since C2), but the MCP tool schema didn't expose tenantId so Zod
   silently dropped any caller-passed value. Adds tenantId to the input
   schema and forwards it in the query. 2 LOC.

2. /api/global/metrics/summary: the global handler was hardcoded to
   pass null (all tenants) and ignored ?tenantId=X. Adds the same
   optional filter pattern used elsewhere — null when absent, scoped
   when present. The tenant-scoped /api/metrics/summary route is
   unchanged (JWT-tid still authoritative).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 17:09:43 +02:00
Oliver Kieselbach 688162eb73 fix(web): rename Email to "Communication Email (optional but recommended)" + center Submit Logs card
Submit Logs (tenant settings) and Report Session modal both relabel the
email field so users understand it's the channel we may reply on; the
Submit Logs card also gains mx-auto so it sits centered in the wide
tenant-settings layout instead of left-aligned.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:48:08 +02:00
Oliver Kieselbach e42b42b50b Merge pull request #60 from okieselbach/mcp-routing-and-cleanup
Mcp routing and cleanup
2026-05-07 16:44:38 +02:00
Oliver Kieselbach b692baa5d9 feat(mcp): get_session_summary lean — cap key events + drop heavy payloads
The summary used to embed every key event's full data/dataJson payload —
one busy session could push the response well past 80 KB and trip the
response cap. The point of the summary is triage, not raw payload
delivery; users wanting full event data already have get_session_events.

Now:
  * key events sorted by relevance (Errors > phase transitions > Warnings
    > others), capped at 50 entries; full timeline still drives stats
  * the displayed slice is re-sorted chronologically for readability
  * heavy `details` field dropped — was the main bloat source
  * stats.keyEventsTotal + keyEventsTruncated exposed so callers know if
    more events were filtered out

Description updated to set expectations + point at get_session_events for
full payloads.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:41:22 +02:00
Oliver Kieselbach b4529d2c71 feat(mcp): get_resource tool + description tightening
Workaround for clients (e.g. Claude Code's HTTP-MCP bridge in stateless
mode) that don't expose `resources/list` reliably. Verified with a paired
in-memory client that the server itself advertises `capabilities.resources`
and answers `resources/list` correctly — the breakage is on the client
side. Tools work everywhere, so a tool-call gateway is the pragmatic fix.

Resources now share a single source-of-truth (`resource-catalog.ts`) used
by both the MCP-protocol resource handlers and the new `get_resource(name)`
tool. Tool descriptions previously said "see the device_properties resource"
— updated to "call get_resource(name=...)" so the hint actually leads
somewhere usable.

Also tightens get_rule_stats description: the default window × number of
rules × per-day trend can easily exceed the response cap, so the doc now
recommends passing a tighter date range or ruleType filter for routine
queries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:40:01 +02:00
Oliver Kieselbach 629c12eb55 feat(mcp): role-aware routing — GA → /api/global/*, tenant-admin → /api/*
Fixes the silent confusion where a Global Admin who passed `tenantId=X`
got routed to the tenant-scoped endpoint, which used the GA's own JWT
tenant (home), not the requested one. The user saw their home tenant's
data instead of X — no error, just wrong data.

Caller role flows from /api/auth/mcp (now returns isGlobalAdmin) into
an AsyncLocalStorage caller context (token + isGlobalAdmin). Tools pick
the route via a small helper:

  pickGlobalOrTenantPath('/api/global/X', '/api/X')

  GA           → /api/global/X (cross-tenant; tenantId is a filter param)
  Tenant-Admin → /api/X (tenant-scoped; JWT-tid authoritative, isolation strict)

Refactors 13 routing sites across tools/{sessions,admin,search}.ts. Each
backend pair was verified to exist (both `/api/X` and `/api/global/X`
have a Function defined).

Also drops the MCP-side filter pass in get_session_events — server-side
filtering was added in the previous commit so count and nextLink are
now coherent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:37:27 +02:00
Oliver Kieselbach 3116a0ecc9 feat(backend,web): geographic-sessions default-lean (?full=1 opt-out)
The full LocationSessionRow shape is ~40 fields per session — for
locations with dozens of sessions the response easily blew past 100KB
and tripped MCP token caps. Most fields aren't needed for the typical
"which sessions enrolled from here" triage flow.

Default is now a 17-field lean projection (sessionId, device basics,
status/duration, geo, app/DO summary). UI keeps the full shape via
?full=1 — single-line addition in lib/api.ts.

Also adds an optional tenantId filter on /api/global/metrics/geographic/
sessions so a GA can scope a single tenant's sessions for a location
without leaving the global endpoint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:31:39 +02:00
Oliver Kieselbach 84a1aa14cb feat(backend): server-side filters for /api/sessions/{id}/events
Push eventType/severity/source filters from MCP-tool layer down into the
Function so count and nextLink stay coherent. Previously filters lived
client-side: backend returned the full page, MCP filtered → could
produce count: 0 with nextLink set, which read as a bug ("nextLink is
emitted even though there's nothing left").

With server-side filters:
- count reflects post-filter items in this page
- nextLink is still gated on NextRawToken (more raw rows ahead in this
  session — filter may or may not match further down)
- filter values echo back onto nextLink so callers can follow pagination
  without having to re-supply them

SessionEventsPagination.BuildNextLink learns an optional `extras` arg so
arbitrary query params survive the round-trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:27:49 +02:00
Oliver Kieselbach b9997809ed feat(backend): tenantId filter on global audit/logs + ops-events
Both endpoints already supported optional date-window + pagination but
ignored a tenantId query param. With the upcoming MCP role-aware routing
GA needs /api/global/* endpoints to filter to a single tenant while
keeping cross-tenant capability when the param is absent.

- audit/logs: routes to existing tenant-scoped IMaintenanceRepository
  methods when tenantId is set (cheap PK lookup); fingerprint scope
  widened to "audit:global:tenant:<id>" so cross-token reuse fails.
- ops-events: OpsEvents are partitioned by category, not tenant — the
  filter is applied post-fetch. Pages may report fewer items than
  pageSize when the filter is narrow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:22:52 +02:00
Oliver Kieselbach fae4d5eb2f feat(backend): /api/auth/mcp returns isGlobalAdmin
The MCP server needs to know the caller's GA status to make routing
decisions (GA → /api/global/* with tenantId filter; non-GA → /api/*
JWT-bound). Previously the access-check response only carried
allowed/reason/accessGrant — the MCP layer had no way to detect role.

McpUserService now resolves IsGlobalAdmin unconditionally (it's needed
for both whitelist and AllMembers paths), and the result rides through
on McpAccessCheckResult.IsGlobalAdmin. Existing factory signature stays
backward-compatible via default-param.

Prep for the upcoming MCP role-aware routing refactor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:22:40 +02:00
Oliver Kieselbach 3fe519d839 Merge branch 'main' of https://github.com/okieselbach/Autopilot-Monitor 2026-05-07 16:18:37 +02:00
Oliver Kieselbach 5292cd2dfe fix submit diag sidebar entry 2026-05-07 16:17:52 +02:00
Oliver Kieselbach 2725626744 Merge pull request #59 from okieselbach/mcp-tool-fixes-comprehensive
feat(reports): add Submit Logs page for diag-files reports without session context
2026-05-07 16:08:02 +02:00
Oliver Kieselbach 089c0123e6 feat(reports): add Submit Logs page for session-less diag-files reports
Tenant Admins can now ship log files, JSON state snapshots (.log/.txt/.zip/
.json/.jsonl/.ndjson), and screenshots to the Autopilot Monitor team from
/settings/tenant/support without needing a session context — no more
OneDrive round-trips for ad-hoc client-log requests.

Backend
- New POST /api/diag-files-reports (TenantAdminOrGA) -> SessionReports table
  + session-reports container, distinguished by ReportType="diagFiles".
- ReportType discriminator added to SessionReportMetadata + Store/Map
  (legacy rows default to "session").
- Telegram + Global-Admin bell notifications mirror the session-report flow.

Frontend
- New "Submit Logs" tenant settings tab with comment / email / log+state
  files (5 MB cap) / screenshot pickers; multi-file auto-zip via fflate.
- Admin SessionReportsSection now shows a Type column (Session / Diag Files)
  and hides Session ID in the detail modal for diag-files rows.

Verification: dotnet build clean, 1058 backend xUnit tests pass, tsc
--noEmit clean, 259 vitest tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:02:55 +02:00
Oliver Kieselbach 93152d86ec fix whiteglove split 2026-05-07 15:02:52 +02:00
Oliver Kieselbach d4d828d8c6 feat(web): in-flight request collapser (dedupedAuthFetch) + 2 callsites
Adds a drop-in wrapper around authenticatedFetch that collapses concurrent
GET/HEAD requests to the same URL into a single underlying network round-
trip. Each caller receives an independently readable Response clone.

This is a request-collapser, NOT a cache. Entries live only as long as
the underlying Promise is unresolved; once it settles, the entry is
removed on the next microtask. No TTL, no eviction, no staleness — by
design, since SignalR push-truth would race against any pull-cache. Calls
that merely happen to hit the same URL after a response has arrived go
fresh.

Mutations (POST/PUT/PATCH/DELETE) bypass the collapser unconditionally
so write semantics are unchanged.

Migrated two known-duplicating callsites:
- useTenantSecurityConfig (dashboard) — feature-flags GET that re-fired
  on the user=null → UserInfo transition.
- useSessionTenantConfig (session detail) — feature-flags GET that
  could overlap with other consumers on the same render burst.

Belt-and-suspenders: the prior dep-stabilization + !user guard on
useTenantSecurityConfig stays in place; the collapser is defense-in-
depth so future regressions of the same shape are caught generically.

8 new vitest cases cover: concurrent collapse, independent body reads,
no caching across microtask boundaries, different URLs not collapsed,
mutations bypass, rejection propagation + slot release, HEAD collapsing,
and GET vs HEAD keyed separately. 257/257 web tests, tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:17:59 +02:00
Oliver Kieselbach 22427e24c5 perf(web,backend): finish off the duplicate-fetch + response-header fixes
Post-deploy re-validation in the browser surfaced two issues that the
previous commit did not actually solve:

- Timing-Allow-Origin was still missing on every API response (curl
  confirmed) and X-Correlation-ID was missing too — a preexisting bug.
  Both middlewares used httpContext.Response.OnStarting() to attach the
  header, but that callback is not reliably triggered in the .NET 8
  isolated worker: the host bridges the worker's response to the wire,
  so OnStarting fires on a shadow object that never reaches the client.
  UserRateLimitMiddleware already writes X-RateLimit-* directly on
  Response.Headers before calling next(), and those headers do come
  through in production. Switching both header-emitting middlewares
  to that pattern.

- /dashboard kept firing /api/config/<t>/feature-flags twice. The
  earlier dep-stabilization on useTenantSecurityConfig was incomplete:
  the effect fires once on initial render with user=null (deps
  [tenantId, undefined, undefined, undefined]) and once after AuthContext
  resolves user (deps [tenantId, bool, bool, role]) — two distinct dep
  tuples, two fetches. The user=null path also bypassed the role gate
  (the guard was `if (user && ...) return`). Adding `!user` to the
  early-return at the top of the effect collapses to one fetch and
  also drops the would-be 401 we were producing during the loading
  window.

Backend tests 1058/1058, web vitest 249/249, tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:01:09 +02:00
Oliver Kieselbach 0fc81f99ed perf(web,backend): cut dashboard load time + fix duplicate fetches
Browser RUM on portal/dashboard surfaced ~1.7s time-to-data driven by
sequential backend reads, two stray duplicate fetches, and a 2s SignalR
defer. Five focused changes:

- AuthContext: store the auth/me prefetch as a Promise instead of its
  resolved value so fetchUserInfo awaits the in-flight request rather
  than racing a second fetch when the prefetch hasn't settled yet.
- useTenantSecurityConfig: depend on user.isTenantAdmin/isGlobalAdmin/
  role primitives instead of the whole user object so the effect stops
  refiring when AuthContext swaps prefetched user for fresh user (the
  duplicate /config/<t>/feature-flags call observed on /dashboard).
- SignalRContext: drop the hardcoded 2000ms setTimeout before
  startConnection. The original "avoid competing with critical API
  calls" rationale doesn't apply across origins (web is HTTP/2, API is
  a different host); the delay only pushed live updates ~2s into the
  load for no benefit.
- FeedbackFunction: keep the kill-switch check sequential, then run the
  remaining four Table reads (role, tenant config, sessions existence,
  feedback cooldown) in parallel via Task.WhenAll. Endpoint dropped
  from ~1285ms (6 sequential reads on Flex Consumption) toward the
  slowest single read.
- TimingAllowOriginMiddleware: emit Timing-Allow-Origin: * on every
  function response so the portal's Resource Timing API can read API
  payload sizes (transferSize / nextHopProtocol) for our own RUM —
  cross-origin entries currently report 0/unknown.

Backend tests 1058/1058, Web tsc clean, vitest 249/249.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 13:37:52 +02:00
Oliver Kieselbach b61e327752 added full width button 2026-05-07 12:19:11 +02:00
Oliver Kieselbach b91b6c6d52 fix(backend): push AgentVersion(Prefix) filter server-side in search_sessions scan
The paged scan path (`SearchSessionsByScanPageAsync`) routed AgentVersion +
AgentVersionPrefix through `MatchesScanClientFilters` — applied AFTER the
SessionsIndex page was already fetched. For sparse subsets like "all V2
enrollments" (~1% of sessions) this meant the LLM had to follow 10-30 nextLink
roundtrips of mostly count:0 pages while the index walked past the dense
non-V2 majority.

Move both filters into `BuildSearchScanFilter` as native OData clauses, same
trick `DeviceName` and `OsBuild` already use:

  AgentVersion eq 'X'                                       (exact)
  AgentVersion ge 'X' and AgentVersion lt 'X~'              (prefix)

Drop the now-redundant client-side check in `MatchesScanClientFilters`. The
device-snapshot path (`SearchSessionsByDeviceSnapshotPageAsync`) keeps the
client-side filter in `ApplyBasicFilters` because DeviceSnapshot doesn't carry
AgentVersion as a column.

Result: search_sessions(agentVersionPrefix="2.0.") becomes a single-call
operation for typical tenants instead of a roundtrip marathon.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 12:01:31 +02:00
Oliver Kieselbach 00f3bf61ef feat(mcp,backend): server-driven response cap + version prefix + lean projection
Real-world test exposed two distinct LLM-context bottlenecks the pagination
rollout alone didn't solve:

  1. Anthropic's MCP client truncates tool responses at ~25-30k chars by
     default, mid-page; nextLink follows but rows in the truncated middle are
     silently lost. pageSize alone can't fix this — even pageSize=50 of full
     SessionSummary objects (~1.5KB each) blows the cap.

  2. Aggregation queries ("how many V2 enrollments?") shouldn't need 1.5MB of
     SessionSummary blobs paginated across 4 round-trips. The LLM only needs a
     handful of fields to count.

Three coordinated fixes, all server-driven so customers connecting via MCP
get them automatically without any client config:

* `_meta.anthropic/maxResultSizeChars` annotation on every tool response,
  calibrated per response density (sessions=300k, events=250k, rawTable=500k,
  small=50k). The Anthropic client respects it and stops mid-page truncation.
  New `toolResultText()` helper in shared.ts wraps the pattern.

* `fields=` projection on `search_sessions` and `query_raw_sessions` (server-
  side, prunes wire payload 5-20x) and on `query_table` (client-side, since
  TableEntity columns are dynamic). Always retains PartitionKey + RowKey for
  cursor stability. Tool descriptions now actively recommend it for counting /
  aggregation queries.

* `agentVersionPrefix=` / `imeAgentVersionPrefix=` filters on `search_sessions`
  and `query_raw_sessions`. Replaces the "one call per build" loop pattern when
  the LLM wants a whole version line. Mutually exclusive with the exact-match
  variants (exact wins). Fingerprint-bound so a token from "2.0." can't replay
  into "2.1.".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 11:31:50 +02:00
Oliver Kieselbach d4464619b5 fix span param 2026-05-07 11:04:10 +02:00
Oliver Kieselbach abf71f7c14 chore(mcp): bump hono / ip-address / express-rate-limit via npm audit fix
Clears 3 moderate Dependabot alerts (GHSA-9vqf-7f2p-gf9v, GHSA-69xw-7hcm-h432,
GHSA-v2v4-37r5-5v8g) — all transitive via @modelcontextprotocol/sdk and
semver-compatible patches:
  hono              4.12.15 -> 4.12.18
  ip-address        10.1.0  -> 10.2.0
  express-rate-limit 8.3.1  -> 8.5.1

The vulnerabilities are not on hot paths in our code (MCP uses express not
hono; ip-address only for rate-limit key derivation, never HTML emission), but
keeping the lockfile clean removes a recurring noise source on PRs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 10:59:58 +02:00
Oliver Kieselbach 861159873e added width query param to session list 2026-05-07 10:58:49 +02:00
Oliver Kieselbach 840ac9f21f feat(backend,mcp): paginate the last three legacy MCP diagnostic tools
query_raw_sessions, search_sessions_by_cve, and query_table were still on the
legacy `?limit=` contract (capped at 200/500/100 with no nextLink). On large
deployments this made "all devices affected by CVE-X" or "every row in
TenantConfiguration" structurally un-answerable.

Migrate to the established pageSize/continuation/nextLink contract:

- query_raw_sessions reuses SearchSessionsPagination; field projection logic
  unchanged.
- search_sessions_by_cve gets a new SearchSessionsByCvePageAsync repo method
  (analog to by-event) plus SearchSessionsByCvePagination helper with
  cveId+CVSS/risk fingerprint binding.
- query_table gets a generic RawTablePagination helper covering tableName +
  PK/RK/filter scope.

Sweep all pageable MCP tools to use followNextLink so backend-echoed query
params round-trip correctly. Raise session-list defaults 50 to 200 and unify
tool descriptions on a "pagination-first" message: pageSize is the batch size,
not a truncation point.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 10:55:39 +02:00
Oliver Kieselbach 08107d7b68 fixed scripts v2 leftovers 2026-05-07 10:17:35 +02:00
Oliver Kieselbach d8e1eb9292 Changed bootstrap script and V2 agent binary name (now without V2, preperation for V1 replacement) 2026-05-07 10:12:37 +02:00
Oliver Kieselbach c337970143 fix audit fetching 2026-05-07 09:58:53 +02:00
Oliver Kieselbach 939bc2ebc7 fixed platform metrics, ops and session reports fetching 2026-05-07 09:14:37 +02:00
Oliver Kieselbach da8be3e508 added badges to readme 2026-05-07 08:51:22 +02:00
Oliver Kieselbach e7e501d8cd added audit filter for unnecessary entries 2026-05-07 08:39:04 +02:00
Oliver Kieselbach 5a44a9e495 fix(web): advance dashboard pagination on first Next click
Driving loadMore() from the auto-load effect in page.tsx left a one-render
gap where loadingMore was still false, so the snap-back effect in
useDashboardFilters fired first and reverted currentPage from 2 back to
totalPages=1 before the fetch even started. The fetch then ran (the
auto-load effect saw the captured currentPage=2 from that render) and the
list grew to totalPages=2, but currentPage had already been snapped to 1
— giving the visible "Page 1 of 2+" state and forcing the user to click
Next a second time to actually see the new batch.

Now handleNextPage calls loadMore() in the same click handler as
setCurrentPage(prev+1), so React 18's automatic batching commits both
state changes in the same render. Snap-back's `if (loadingMore) return`
guard then correctly skips, currentPage stays at the overshoot, and once
the fetch resolves totalPages catches up and the new batch renders.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 21:29:14 +02:00
Oliver Kieselbach 9b4100f7d6 fix(web): unify dashboard pagination + bump fleet-health page size
Dashboard: removed the standalone "Load 100 more sessions..." button. The
session table now has a single Prev/Next surface that transparently fetches
the next server batch when the user pages past locally-loaded sessions —
the existing auto-load effect in page.tsx already drives loadMore() once
currentPage * sessionsPerPage exceeds sessions.length, but handleNextPage
was clamping to totalPages so currentPage could never overshoot, leaving
"Next" disabled until the user clicked the separate Load-More button.

  - useDashboardFilters now takes hasMore + loadingMore; handleNextPage
    advances past totalPages when hasMore && !loadingMore. A snap-back
    effect resets currentPage to totalPages once a load finishes that
    didn't grow totalPages (filter case: new batch yielded zero matches).
  - SessionTable renders the Prev/Next controls whenever there's somewhere
    to navigate (totalPages > 1 || hasMore), shows "Page X of Y+" while
    more is on the server, disables Next only at the true end, and shows
    an inline spinner inside Next during loadingMore. The empty tbody
    distinguishes "loading sessions..." from "no results".

Fleet Health: bumped FLEET_PAGE_SIZE from 200 to 1000 (backend MaxPageSize
is 1000). A 90-day window on a busy install now drains in ~1/5 the
round-trips, eliminating the 5x roundtrip-amplification users observed
on initial load.

Type-check clean, vitest 249 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 21:15:37 +02:00
Oliver Kieselbach 70382e991b feat(backend,web,mcp,shared): MS-Graph nextLink pagination rollout
Lands the planned 6-PR pagination rollout (project_mcp_pagination_rollout)
as one tested batch covering session_events, audit+ops, session_reports,
search_sessions/list, and query_raw_events. Backend builds clean; Backend
1024 / Agent V2 1007 / Web 249 tests green.

Primitives (Shared/Pagination):
- ContinuationToken with fingerprint binding (tenantId, dateFrom/To, filters)
  so a forged or cross-tenant token fails validation on the next page.
- PaginatedResult<T> + RawPage<TEntity> as the canonical wire shapes.

Backend (AutopilotMonitor.Functions/Pagination):
- AzureTablesPaginator drives single-partition + per-partition pages.
- DateWindowPagination sweeps day-buckets for global audit/ops queries
  (the backend resolves "last 30d" once and echoes dateFrom/dateTo on
  nextLink so follow-up calls don't recompute "now" and break the token).
- PerPartitionFanOutMerge stable-merges N partition cursors for cross-
  tenant session search.
- Per-feature plans (SessionEventsPagination, SearchSessionsPagination,
  SearchSessionsByEventPagination, SessionListPagination,
  SessionReportsPagination, QueryRawEventsPagination).
- Functions adopt nextLink: GetSessionEvents, GetAuditLogs (tenant +
  global), GetOpsEvents, GetSessionReports, SearchSessions,
  SearchSessionsByEvent, GetSessions, GetAllSessions, QueryRawEvents.
- Repository contracts (ISessionRepository, IOpsEventRepository,
  INotificationRepository, IMaintenanceRepository) gain *PageAsync
  variants returning PaginatedResult; legacy GetSessionsAsync becomes
  a drain-all helper used by maintenance/feedback/SLA paths that still
  need bounded full-list semantics. SessionPage DTO removed.
- PlatformMetricsService now drains all date-bounded sessions via
  GetAllSessionsAsync (previous maxResults:500 was a silent truncation).

Web:
- lib/paginationLink.ts + ?? api.ts wrappers expose follow-nextLink in
  hooks (useSessionEvents, useDashboardSessions) and pages (audit,
  fleet-health, session detail, OpsEvents, SessionReports admin).
- Session list uses Pattern B2 (load-more append); audit + ops + reports
  use Pattern B1 (click-next replace); session detail timeline keeps
  Pattern A (eager-fetch full set).

MCP:
- followNextLink helper accepts either an opaque continuation OR the
  whole "/api/?" path the backend emitted, and follows the latter
  verbatim so server-resolved query params (tenantId fingerprint,
  resolved dateFrom/dateTo, page size) round-trip correctly. Without
  this, get_audit_logs / get_ops_events / list_session_reports / sessions
  follow-ups would silently fail token validation.
- get_audit_logs, get_ops_events, list_session_reports, and the session
  tools migrate to pageSize + continuation/nextLink, with tool docs
  spelling out the round-trip rule so external MCP clients pass the
  full nextLink instead of stripping it down to "continuation".

Tests:
- New: ContinuationTokenTests, DateWindowPaginationTests,
  PerPartitionFanOutMergeTests, AuditLogRowKeyOrderingTests,
  SessionEventsPaginationTests, SessionListPaginationTests,
  SearchSessionsPaginationTests, SearchSessionsByEventPaginationTests,
  SessionReportsPaginationTests, QueryRawEventsPaginationTests, and
  TestSetup helpers.
- Web: paginationLink.test.ts + sessionListLoadMore.test.ts.
- PlatformMetricsServiceWindowTests adjusted for the drain-all change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 20:53:46 +02:00
Oliver Kieselbach 9a511bac29 fix(web): exempt static assets and well-known files from middleware
The previous matcher ran middleware on /.well-known/microsoft-identity-association.json,
BingSiteAuth.xml and other public/ assets at the root, which on www would
have 302-redirected them to portal — breaking publisher-domain
verification fetches and host-bound webmaster files.

Excluding any path containing a dot is the standard Next.js pattern: app
routes and route handlers have no extension, so they still flow through
middleware; static assets and well-known files are served directly by
the host they're requested on.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 20:44:17 +02:00
Oliver Kieselbach 576ea02b92 feat(web,backend): public/portal split via host-based middleware
Adds host-based routing middleware so www.autopilotmonitor.com keeps the
public/marketing surface (SEO-indexed) and portal.autopilotmonitor.com
hosts the authenticated app. Apex is handled by Strato HTTP forwarder
(301 to www, path-preserved); the apex branch in middleware is
defense-in-depth for direct DNS hits.

Sign-In on the public host now hands off cross-origin to portal so MSAL
fires on the right origin and the token lands in portal's sessionStorage
directly. This avoids a second login round-trip after the post-auth
redirect, which would not be silent given prompt:"select_account".
ProtectedRoute now triggers MSAL login itself for never-authed users so
the cross-origin nav lands cleanly on /dashboard.

Backend deeplinks (session notifications, Teams alert) now point at
portal. Bootstrap URL switches to the apex shortform
(autopilotmonitor.com/go/{code}) since Strato 301-redirects to www
preserving the path. PowerShell bootstrap output points users at portal
for progress.

SEO metadata, sitemap, robots, OG and SLA/docs links remain on www.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:37:07 +02:00
Oliver Kieselbach a35726dcf0 fix(web): split WhiteGlove timeline at Part-2 agent_started, not at whiteglove_resumed
The post-reseal agent_started + its agent_version_check are part of the
User Enrollment block in the user's mental model, but the resumed-marker-based
split kept them inside Part 1 because whiteglove_resumed is emitted ~0-2s
after the boot. Walk back from whiteglove_resumed to the agent_started that
sits between Part-1 close and resume, and split there. Falls back to the
resumed marker when the boot event isn't present.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:23:23 +02:00
Oliver Kieselbach b86af63055 changed msal to use window.location.origin 2026-05-06 12:36:59 +02:00
Oliver Kieselbach b4e98e32eb fix(agent): add MS-Organization-Access cert fallback to TenantIdResolver
Adds EntraDeviceCertHelper that reads the AAD-Join device certificate's
TenantId extension (OID 1.2.840.113556.1.5.284.5) from LocalMachine\My.
Wired as the 5th and final synchronous probe in TenantIdResolver, after
the four registry probes:

  1. enrollments_registry         (Type=6, Intune MDM)
  2. enrollments_registry_fallback (any Type, AADTenantID present)
  3. cloud_domain_join_tenant_info
  4. cloud_domain_join_join_info
  5. ms_organization_access_cert  <- new

Targets the Autopilot pre-provisioning / hybrid window where the cert
can land before the CloudDomainJoin registry writes complete. Lets the
agent boot without falling through to the registry-watcher awaiter and
burning the 600s wait window.

Helper hardening over the original draft:
- Filters to currently-valid certs (NotBefore <= now <= NotAfter) and
  picks latest NotAfter when multiple match, defending against an
  expired sibling from a prior re-enrollment.
- If the highest-priority cert lacks the OID extension, walks through
  the remaining candidates instead of bailing on the first miss.
- Logger emits Info on hit (thumbprint + NotAfter) and Warning on miss
  with the specific reason (no cert / extension missing / parse failed).

Mirrored across V1 (Agent.Core) and V2 (Agent.V2.Core).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 22:19:02 +02:00
Oliver Kieselbach b67d68c552 fix(agent): TenantIdResolver fallback for non-Type-6 enrollments + UPN redaction
Two-pass scan of HKLM\SOFTWARE\Microsoft\Enrollments: pass 1 keeps the
existing Type=6 (Intune MDM) preference, pass 2 falls back to any
non-Type-6 sub-key carrying a non-empty AADTenantID. Targets the
Autopilot pre-provisioning / hybrid window where Type=6 sits at
EnrollmentState=1 with AADTenantID still missing while a Type=26
AAD-Join entry already carries the TenantID. Observed at customer
where the agent burned ~30min waiting for CloudDomainJoin\TenantInfo
instead of resolving immediately from Type=26.

Captures the enrollment UPN and classifies it. Known Autopilot
pre-provisioning placeholders (fooUser@... Intune-MDM leg,
autopilot@... Entra-Join leg, both generated by the Windows
MakeFakeUserEmail code path) are logged in full with annotation;
anything else is treated as a real user UPN and redacted to
<redacted_real_user_upn> since UPNs are PII.

Mirrored across V1 (Agent.Core) and V2 (Agent.V2.Core).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 17:13:48 +02:00
Oliver Kieselbach 6935ca8e00 fix(backend,web): expose member-readable tenant flags via feature-flags endpoint
Three frontend hooks fetched /api/config/{tenantId} from non-admin contexts
(dashboard banner, session detail UI flags, gather-rules validation indicator)
and silently 403'd for Operator and Viewer because the endpoint is gated
TenantAdminOrGA — same anti-pattern as the tenant notifications bell.

The full /api/config/{tenantId} response cannot be relaxed because it carries
admin-only data (SAS URLs, webhook URLs). Instead, the already-MemberRead
/api/config/{tenantId}/feature-flags endpoint is extended with the five UI
display flags those hooks actually need:

- validateAutopilotDevice (Autopilot Device Validation banner)
- showScriptOutput (session PowerShell output visibility)
- enableSoftwareInventoryAnalyzer (session analyzer flag)
- enableIntegrityBypassAnalyzer (session analyzer flag)
- unrestrictedMode (gather-rules validation indicator)

Nullable model fields are flattened to their agent-side defaults so the UI
does not need a second nullable layer. The projection lives in a static
BuildPayload helper for unit-testability.

Frontend hooks switched from api.config.tenant() to api.config.featureFlags();
gather-rules GA-override path keeps using globalConfig.tenant (GA-only).

Tests: 4 new xUnit cases including a sentinel that fails if SAS URLs or
webhook URLs ever leak into the payload. Backend 886/886 green, web 234/234.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 13:18:47 +02:00
Oliver Kieselbach 9b0a2dca95 fix(backend,web): role-gated tenant notifications + audience visibility
Tenant-bell endpoint silently 403'd every 60s for Operator/Viewer because the
GET was gated TenantAdminOrGA but the navbar shows the bell to every member.
Three real bugs were tangled in this:

- SLA breach + consecutive-failures notifications were emitted into the global
  channel even though they are per-tenant data. Only Global Admins ever saw
  them; the actual tenant got no in-app indication.
- Frontend polled /api/notifications for any authenticated user regardless of
  role and silently swallowed the 403 — masking the underlying mis-gate.
- Hardware-rejection notifications would have leaked admin-only context (link
  to whitelist editor) once the policy relaxed.

Fixes:

- New TenantNotificationAudienceCatalog (Type to Member/Admin map) drives
  per-type visibility server-side. Unknown types fail closed to Admin so a
  newly added type does not leak to non-admin members until registered.
- GET /api/notifications relaxed from TenantAdminOrGA to MemberRead. Dismiss
  endpoints stay TenantAdminOrGA (tenant-shared dismiss; per-user dismiss
  deferred with TODO + memory note).
- SLA breach + consecutive-failures emitters moved to TenantNotificationService.
  GlobalNotificationService injection removed from SlaBreachEvaluationService
  entirely (we do not use it anymore, so it is gone).
- Frontend polls only when user has a tenant role or is Global Admin; dismiss
  buttons hidden for Operator/Viewer.

Tests: 6 new xUnit, 7 new vitest. Backend 873/873 green, web 234/234 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 12:35:43 +02:00
Oliver Kieselbach 5ef6c8b1d2 fix(mcp): MCP server security & capability audit (Phases A-E)
Critical: GET /api/metrics/mcp-usage/user/{userId} (TenantAdminOrGA, no
TenantScoping) handed the oid to the repo without checking that returned
records belong to the caller's tenant. A tenant admin from tenant A could
read MCP usage records of any oid — including oids in tenant B.

McpUsageMetricsFunction.GetUserUsage now blocks 403 CrossTenantAccessDenied
when records are tenant-foreign and caller is not a Global Admin. The pure
decision logic lives in UsageCrossTenantGuard for unit-testability (9 tests
covering own/foreign/mixed/GA/empty/null/case-insensitive/empty-tid paths).

Also:
- MCP get_api_usage tool was calling /api/metrics/mcp-usage/{userId}
  (without /user/), which is unregistered and returned 404 — corrected.
- list_blocked_devices description honestly states the endpoint is
  GA-only on both variants (catalog policy, no change to the gate).
- days clamp [1, 365] added to /metrics/{app, geographic, geographic/sessions}
  for parity with /metrics/summary, /metrics/usage, /metrics/platform.
- get_geographic_metrics, get_geographic_sessions zod schemas now constrain
  days to int().min(1).max(365) — same pattern as the other tools.
- search/sessions, search/sessions-by-event, search/sessions-by-cve switched
  limit handling from Math.Min(lim, 100) to Math.Clamp(lim, 1, 100); the old
  form let a negative ?limit=-5 reach the repo.

Audit static analysis confirmed all other tenant-scoped endpoints route
tenantId through TenantHelper.GetTenantId(req) (JWT-only, ignores query),
so ?tenantId=<other> is silently dropped. Live-verified with an Operator
token: 14 cross-tenant query probes returned only the caller's tenant,
13 GlobalAdminOnly routes returned 403.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 12:12:54 +02:00
Oliver Kieselbach feba2a2551 fix(backend): materialize CurrentPhase=Failed on terminal failed status
V2 emits enrollment_failed with Phase=Unknown (per feedback_phase_strategy),
so currentPhase.HasValue was false in UpdateSessionStatusAsync — leaving
Sessions.CurrentPhase at -1 on Failed sessions even though decisionState
already knew the last observed phase. UI PhaseTimeline could not render a
Failed marker. Mirror the existing Succeeded => Complete override in both
the normal and the post-ETag-exhaustion force-write path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 10:42:20 +02:00
Oliver Kieselbach 0687612a96 fix(v2): preserve source timestamps + boot-anchor deadline math
Replay-safety fix for two related defects that surface when the agent
reads accumulated CMTrace / Shell-Core log content on first boot
(per-enrollment lifecycle, fresh install).

PR1 — IME adapter (SignalAdapters/ImeLogTrackerAdapter.cs)
  Every emit site (EspPhaseChanged, AppState, UserSessionCompleted,
  WhiteGloveSealing, ImeAgentVersion, DoTelemetry, ScriptCompleted)
  now stamps DecisionSignal.OccurredAtUtc and the InformationalEvent
  with the CMTrace log entry timestamp the tracker captures into
  LastMatchedLogTimestamp. Falls back to clock when no source time is
  available; tags the event with derivedTimestamp=true on fallback.
  Pathological clamp: source TS older than 24h or in the future >1h
  is rejected, original preserved as rejectedSourceTimestamp.

PR2 — Shell-Core backfill + EspAndHelloTrackerAdapter
  ShellCoreTracker.HandleBackfillRecord now propagates record.TimeCreated
  via a new LastEventOccurredAtUtc property on the tracker; the
  EspAndHelloTracker coordinator mirrors this property in its event
  forwarders. EspAndHelloTrackerAdapter reads the coordinator value at
  every Post site (HelloResolved, FinalizingSetup, WhiteGloveSuccess,
  EspTerminalFailure, DeviceSetupComplete, HelloPolicyDetected) so
  backfilled events carry source time, not "agent woke up just now".

PR3 — DecisionState.AgentBootUtc + boot-anchor guard
  Without this guard, PR1+PR2 are a footgun: replayed signals carry
  historical timestamps, and the reducer sites that arm deadlines as
  signal.OccurredAtUtc + window would produce due-times in the past,
  collapsing the Hello-safety window, device-only ESP detection,
  finalizing grace, and classifier-tick to immediate fires at boot.

  Added DecisionState.AgentBootUtc + DecisionEngine.EffectiveDeadlineBase
  helper that floors the arming base at the boot anchor. Applied at all
  six reducer sites (HelloSafety, DeviceOnlyEspDetection, FinalizingGrace,
  ClassifierTick @ SessionStarted + AttachWG + re-arm).

  EnrollmentOrchestrator.Start() stamps AgentBootUtc from the live IClock
  on initial state and re-stamps it on snapshot rehydration so the prior
  run's anchor doesn't bleed into deadline math after restart. Backward
  compat: legacy snapshots without the field deserialize with null and
  fall back to signal.OccurredAtUtc — pre-fix behavior preserved.

Tests: 287/287 DecisionCore + 1007/1007 V2.Core + 862/862 Functions +
530/530 Agent.Core. New: BootAnchorDeadlineFloorTests (5) +
ImeLogTrackerAdapterSourceTimestampTests (6).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 20:24:24 +02:00
Oliver Kieselbach 84610f9fb0 refactor(v2): unify signal census between final-status.json and audit trail
The on-disk final-status.json and the on-the-wire enrollment_complete audit
trail both project the same DecisionState into a "which signals were observed"
view, but did so via two independent code paths that drifted: AAD device-only
joins were dropped entirely from final-status.json, ShellCore + IME pattern
strings differed in spelling, and account_setup_entered was missing client-side.

Extract the census into a shared DecisionStateSignalCensus helper in DecisionCore
so adding a new milestone signal lights it up in both surfaces automatically.
Final-status.json gains a top-level imeMatchedPatternId mirror so the IME pattern
id survives the format switch from "ime_pattern:{id}" to "ime_pattern_matched".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 19:57:50 +02:00