diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml index fc69a8a5..c2444ac9 100644 --- a/.github/workflows/deploy-web.yml +++ b/.github/workflows/deploy-web.yml @@ -16,6 +16,10 @@ on: - 'src/Web/autopilot-monitor-web/**' - '.github/workflows/deploy-web.yml' +env: + PORTAL_VERSION_URL: https://portal.autopilotmonitor.com/version.json + VERSIONS_CONTAINER: https://autopilotmonitoreu.blob.core.windows.net/versions + jobs: build_and_deploy_job: if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.action != 'closed') @@ -48,6 +52,9 @@ jobs: NEXT_PUBLIC_API_BASE_URL: ${{ vars.NEXT_PUBLIC_API_BASE_URL }} NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL: ${{ vars.NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL }} NEXT_PUBLIC_APPINSIGHTS_CONNECTION_STRING: ${{ secrets.NEXT_PUBLIC_APPINSIGHTS_CONNECTION_STRING }} + # Build stamp for public/version.json (scripts/generate-version.js in prebuild); + # the verify step below polls the deployed file for exactly this commit. + GITHUB_SHA: ${{ github.sha }} with: azure_static_web_apps_api_token: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }} repo_token: ${{ secrets.GITHUB_TOKEN }} # Used for Github integrations (i.e. PR comments) @@ -64,6 +71,55 @@ jobs: output_location: "out" # Built app content directory - optional ###### End of Repository/Build Configurations ###### + # The SWA action reporting success proves the upload, not that the production host + # serves the new build — verify against the live /version.json (Cache-Control: + # no-store via staticwebapp.config.json) before publishing the version manifest. + # PR builds deploy to per-PR preview environments, so only main deploys are verified. + - name: Verify deployed portal build is live + if: env.DEPLOY_TOKEN != '' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') + shell: pwsh + run: | + $expectedCommit = '${{ github.sha }}'.Substring(0, 7) + $deadline = [DateTime]::UtcNow.AddMinutes(5) + $attempt = 0 + + while ([DateTime]::UtcNow -lt $deadline) { + $attempt++ + try { + $v = Invoke-RestMethod -Uri '${{ env.PORTAL_VERSION_URL }}' -Method Get -TimeoutSec 30 -Headers @{ 'Cache-Control' = 'no-cache' } + if ($v.commit -eq $expectedCommit) { + Write-Host "Live after $attempt attempt(s): web @ $($v.commit), built $($v.buildUtc)" + exit 0 + } + Write-Host "Attempt ${attempt}: serving $($v.commit) -- waiting for $expectedCommit" + } catch { + Write-Host "Attempt ${attempt}: /version.json not answering yet -- $($_.Exception.Message)" + } + Start-Sleep -Seconds 15 + } + + throw "Portal did not serve commit $expectedCommit within 5 minutes -- deployment not verified, version manifest NOT published" + + # Cross-component deploy record, written last and only on a verified deploy — + # same contract as versions/backend.json and versions/mcp.json. + - name: Publish version manifest + if: env.DEPLOY_TOKEN != '' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') + shell: pwsh + run: | + $manifest = [ordered]@{ + component = 'web' + commit = '${{ github.sha }}'.Substring(0, 7) + deployedUtc = [DateTime]::UtcNow.ToString('o') + runId = '${{ github.run_id }}' + } | ConvertTo-Json + + $sas = '${{ secrets.AZURE_BLOB_VERSIONS_SAS_TOKEN }}'.TrimStart('?') + $url = "${{ env.VERSIONS_CONTAINER }}/web.json?$sas" + # no-cache: rotating pointer, must never be served stale by any fronting cache. + Invoke-RestMethod -Uri $url -Method Put -Headers @{ 'x-ms-blob-type' = 'BlockBlob'; 'x-ms-blob-cache-control' = 'no-cache' } -Body $manifest -ContentType 'application/json' | Out-Null + Write-Host "Published versions/web.json" + Write-Host $manifest + close_pull_request_job: if: github.event_name == 'pull_request' && github.event.action == 'closed' runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 9e790e58..94260e41 100644 --- a/.gitignore +++ b/.gitignore @@ -154,3 +154,7 @@ docs/~$Introduction-AutopilotMonitor.pptx # Playwright MCP browser artifacts (snapshots, screenshots, console logs) .playwright-mcp/ + +# Web build stamp: generated by scripts/generate-version.js on every build (buildUtc +# changes each run) and served at /version.json from the static export. +src/Web/autopilot-monitor-web/public/version.json diff --git a/docs/drift-guards.md b/docs/drift-guards.md new file mode 100644 index 00000000..35deed54 --- /dev/null +++ b/docs/drift-guards.md @@ -0,0 +1,106 @@ +--- +type: Concept +title: Drift Guards — SignalR Names, RowKeys, Config Sections, Scoped Routing +description: The 2026-08 fragility-audit follow-up contracts that turn formerly hand-synchronized mirrors into enforced ones — SignalR message-name catalog, the inverted-tick RowKey codec, tombstone table tagging, the OkAsync ratchet, per-section config PATCH, and the web's scoped-endpoint builders. +resource: /src/Backend/AutopilotMonitor.Functions.Tests/OkAsyncBaselineGuardTests.cs +tags: + - contracts + - guard-tests + - signalr + - web +timestamp: 2026-08-13T00:00:00+02:00 +--- + +# Drift Guards + +Follow-up to the 2026-08 fragility audit (see [Lifecycle Manifests & Session Scope +Resolution](backend/lifecycle-manifests-and-session-scope.md) for the earlier rounds). +Each section below replaces a hand-synchronized mirror with an enforced contract: a +change on one side now fails a build, a type check, or a test on the other side. + +# Schema + +## SignalR message names — one catalog, typed on both ends + +`Constants.SignalRMessages` (Shared) is the complete catalog of SignalR target names. +Backend senders (output bindings and the imperative `SignalRNotificationService`) +reference the constants; the hub name is `SignalRGroupHelper.HubName`. The catalog is +exported into `shared-manifests.json` (`signalRMessages` section) by +`SharedManifestParityTests`, and the web's `SignalRContext.on/off` type their event +name against the generated union (`lib/signalrMessages.ts`) — subscribing to a name +the backend never sends is a tsc error, not a silent no-op. `newevents` is the one +legacy lowercase name; it is a persisted wire name, do not "fix" the casing. + +## Inverted-tick RowKeys — one codec + +`RowKeyCodec` (Functions/Helpers) is the single encoder/decoder for +`rev(t) = MaxTicks - t.Ticks` RowKeys. Every writer routes through it; the persisted +per-table shapes (D19 standard, AuditLogs `!`-prefix, UserActivity's legacy D20 width, +ConfigurationBackups' 28-char truncation) are pinned by `RowKeyCodecTests` and must +never change — a width/prefix change reorders live tables against their existing rows. +`BusinessTimestamp` keeps the OData clause builders and delegates decode to the codec. + +## Tombstone rows carry their table + +`DeletionRowDump.Table` names the source table on FINAL (Tombstone) rows — the one +step that mixes two tables while `DeletionStep.Table` is null. +`DeletionTombstoneTables.Resolve` is the only consumer-side resolution (delete + +restore + dry-run); the historical `Contains('_')` RowKey-shape heuristic survives +solely as the fallback for manifests written before the field existed and must outlive +one full manifest-retention cycle. + +## Anonymous OkAsync responses — frozen, shrink-only + +`OkAsyncBaselineGuardTests` freezes the 44 existing `req.OkAsync(new { … })` call +sites as a per-file baseline. A new anonymous response (new file or count above +baseline) fails; converting a site to a typed DTO (Shared response models, picked up +by the manifest parity tests) fails too until its baseline entry is lowered — the debt +list stays truthful and only ever shrinks. + +## Tenant settings — per-section PATCH + +The web's Settings sections no longer PUT the full ~92-field configuration. Each +section's exact write surface lives in `app/settings/sectionFieldMap.ts` (owned fields +plus documented `alsoWrites` write-throughs); `saveConfiguration` diffs those fields +against the loaded config and PATCHes only the changes through +`PATCH config/{tenantId}/fields` — the transactional endpoint (CAS + fail-closed +backup + exactly-these-fields verify + auto-rollback) that previously served MCP only. +The policy line is TenantAdminOrGA; a tenant admin runs on the stricter +`TenantConfigCallerTier.TenantAdmin` whose deny-list turns GA-only fields into +explicit 400s. Deploy order: backend before web. `sectionFieldMap.test.ts` pins +field↔section ownership (each field exactly one owner, all fields on the model, none +server-denied); backups/revert stay GlobalAdminOnly. + +## Scoped endpoint routing — one decision object + +`lib/scopedApi.ts` owns the tenant/global endpoint-pair choice: pages pass their scope +hook object (`routeGlobal`, `selectedTenantId`, `effectiveTenantId`) instead of +hand-rolling `routeGlobal ? global(..., sel || undefined) : tenant(tid, ...)` with the +tenant parameter at a different position per pair. `useGlobalAdminScope` is now a thin +projection over `useAggregatedAdminScope` (one hook, aggregated as a mode) via the +pure `resolveConcreteScopeView`. `lib/scopedFetch.ts` adds the shared JSON+ok-check +fetch layer. `lib/navVisibility.ts` extracts the sidebar's roles × nav-config × guard +logic; its matrix test also ratchets the known "sidebar shows it, guard bounces it" +mismatches (plain Operator and GlobalReader vs. `/settings` — pending a user decision). + +# Examples + +Renaming a SignalR message end to end: change the constant in +`Constants.SignalRMessages` → `AM_WRITE_SHARED_MANIFESTS=1 dotnet test --filter +SharedManifestParityTests` → `node scripts/generate-shared-manifest-types.js` → tsc +now flags every stale web subscription literal. + +Adding a field to a Settings section: add it to the section in `sectionFieldMap.ts` +(tsc validates the name against the manifest), wire the form state into the +`updatedConfig` construction in `TenantConfigContext`, and extend the expected-fields +list in `sectionFieldMap.test.ts`. + +# Citations + +- `src/Shared/AutopilotMonitor.Shared/Constants.cs` — SignalRMessages catalog +- `src/Backend/AutopilotMonitor.Functions/Helpers/RowKeyCodec.cs` + `RowKeyCodecTests.cs` +- `src/Shared/AutopilotMonitor.Shared/Models/Deletion/DeletionManifest.cs` — `DeletionRowDump.Table`, `DeletionTombstoneTables` +- `src/Backend/AutopilotMonitor.Functions.Tests/OkAsyncBaselineGuardTests.cs` +- `src/Web/autopilot-monitor-web/app/settings/sectionFieldMap.ts` + `__tests__/sectionFieldMap.test.ts` +- `src/Web/autopilot-monitor-web/lib/scopedApi.ts`, `lib/navVisibility.ts`, `hooks/concreteAdminScopeView.ts` +- [Version Contract](versioning.md) — the web `/version.json` stamp + deploy verify added in the same round diff --git a/docs/index.md b/docs/index.md index 890d2177..7d2812de 100644 --- a/docs/index.md +++ b/docs/index.md @@ -14,6 +14,7 @@ https://docs.autopilotmonitor.com (separate repository). * [Architecture Guide](architecture.md) - High-level architecture overview and solution layout for contributors. * [Version Contract](versioning.md) - One version shape for agent, backend and MCP: the ETag-CAS counter blob that mints build numbers, and the manifests published only after a deploy is verified live. * [URL Registry](url-registry.md) - Every well-known host lives in one registry file per component (Constants.cs / utils/config.ts / src/config.ts), enforced by guard tests; includes the agent-download migration to the download alias and its deploy-order constraint. +* [Drift Guards](drift-guards.md) - The 2026-08 fragility-audit follow-up contracts: SignalR message-name catalog (typed on both ends), the inverted-tick RowKey codec, tombstone table tagging, the shrink-only OkAsync baseline, per-section config PATCH with the section field map, and the web's scoped-endpoint builders + sidebar visibility matrix. # Agent diff --git a/docs/log.md b/docs/log.md index 72e6f2eb..7ec7857a 100644 --- a/docs/log.md +++ b/docs/log.md @@ -1,5 +1,11 @@ # Log +## 2026-08-13 (late evening) + +* **Creation**: Added `drift-guards.md` — the fragility-audit follow-up contracts (P5.x + P7): SignalR message-name catalog in `Constants.SignalRMessages`, exported through the shared manifest and typed into the web's `SignalRContext.on/off` union; the `RowKeyCodec` single encoder for inverted-tick RowKeys (persisted widths pinned, incl. UserActivity's legacy D20); `DeletionRowDump.Table` on tombstone rows with `DeletionTombstoneTables.Resolve` replacing the duplicated `Contains('_')` heuristic (legacy fallback retained one retention cycle); the shrink-only `OkAsyncBaselineGuardTests` per-file baseline (44 sites frozen); the Settings per-section PATCH (section field map + diff-based `config/{tenantId}/fields` writes on the TenantAdmin caller tier); and the web's `scopedApi` endpoint-pair builders, single admin-scope hook, `navVisibility` matrix and guard-consistency ratchet. + +* **Update**: `versioning.md` — the web portal joins the version contract commit-only: `public/version.json` generated at build time from `GITHUB_SHA`, served with `Cache-Control: no-store`, polled by `deploy-web.yml` until the deployed commit matches before `versions/web.json` is published. + ## 2026-08-13 (evening) * **Creation**: Added `backend/lifecycle-manifests-and-session-scope.md` — the fragility-audit P5/P6 completeness nets: (1) `TableLifecycleBucketTests` classifies every `TableNames` constant into offboarding-wipe / per-session-cascade / kept-by-design (fixed en route: UsageMetrics wiped via the new RowKey-anchored SafeWipe Variant D — the exact-PK wipe matched 0 rows; PlatformStats tenant partition + ScriptNameCache added to the tenant-PK bucket); (2) `SessionIndexFieldManifest` as the declared SessionsIndex column set with bidirectional builder/reader test pinning, Replace-mode full rebuilds, and a fail-soft merge-key guard; (3) `ResolveSessionScopeAsync` as the single session-scope fallback (16 former copies), backed by the new `SessionTenantLookup` point-read table (scan fallback + self-heal, cascade step before the tombstone), plus the `RecomputeTriggerGate` 403 for reanalyze/rescan by read-only roles; (4) server-side dashboard free-text search (`q=`) with page-backfill loop and fingerprint binding, replacing the client's full-history loadAll sweep. diff --git a/docs/versioning.md b/docs/versioning.md index e82c2594..43d11a01 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -1,7 +1,7 @@ --- type: Concept -title: Version Contract — Agent, Backend, MCP -description: One version shape for all three shipping components, the ETag-CAS counter blob that mints build numbers, and the published manifests that make "what is deployed" checkable from outside. +title: Version Contract — Agent, Backend, MCP, Web +description: One version shape for all shipping components, the ETag-CAS counter blob that mints build numbers, and the published manifests that make "what is deployed" checkable from outside. resource: /.github/scripts/Request-BuildNumber.ps1 tags: - build @@ -13,9 +13,12 @@ timestamp: 2026-07-22T00:00:00+02:00 # Version Contract -Three components ship from this repository — the agent, the Functions backend and the -MCP server. They share one version shape, one way of minting build numbers, and one -manifest format, so that a version string means the same thing whoever emits it. +Four components ship from this repository — the agent, the Functions backend, the +MCP server and the web portal. They share one version shape, one way of minting build +numbers, and one manifest format, so that a version string means the same thing +whoever emits it. The web portal is the commit-only exception: a static export has no +runtime that could report a `major.minor.build`, so it carries `commit` + `buildUtc` +alone (no counter blob). ``` version = .. major.minor curated by hand, build minted per build @@ -28,6 +31,7 @@ buildUtc = ISO-8601 build moment | Agent V2 | `VersionPrefix` in `AutopilotMonitor.Agent.V2.csproj` | `agent/buildcounter-v2.txt` | `agent/version.json` | | Backend | `VersionPrefix` in `AutopilotMonitor.Functions.csproj` | `versions/backend.txt` | `GET /api/health` | | MCP | `version` in `package.json` | `versions/mcp.txt` | `GET /health`, MCP handshake | +| Web portal | — (commit-only) | — | `GET /version.json` on the portal host | # Schema @@ -77,7 +81,12 @@ exception: `build.ps1` produces uploadable artifacts and reserves a real number ``` The MCP manifest adds `docsCommit`, because for that component "which build" and "which -documentation" are separate questions. +documentation" are separate questions. `versions/web.json` carries `component`, `commit`, +`deployedUtc` and `runId` (no `version`/`buildUtc` — the served `/version.json` holds the +build moment). The portal's own stamp is generated into `public/version.json` by +`scripts/generate-version.js` (prebuild) from `GITHUB_SHA`, served with +`Cache-Control: no-store`, and `deploy-web.yml` polls it until the deployed commit +matches the pushed SHA before publishing the manifest. Each is written **last and only after the deploy workflow has polled the component's health endpoint until it reports that exact version and commit**. A green deploy step @@ -104,5 +113,6 @@ remain, being the customer-facing integrity contract. - `.github/scripts/Request-BuildNumber.ps1` — the CAS protocol - `.github/workflows/deploy-backend.yml`, `deploy-mcp.yml` — reserve, verify, publish +- `.github/workflows/deploy-web.yml`, `src/Web/autopilot-monitor-web/scripts/generate-version.js` — web stamp, verify, publish - `.github/workflows/build-agent.yml`, `scripts/Deployment/build.ps1` — agent line - [Build Counter Blob](agent/build-counter-blob.md) — agent-specific consequences diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/DeletionManifestBuilderTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/DeletionManifestBuilderTests.cs index fdb347db..15d68f37 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/DeletionManifestBuilderTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/DeletionManifestBuilderTests.cs @@ -293,6 +293,11 @@ public class DeletionManifestBuilderTests Assert.NotEqual(SessionId, tombstone.Rows[0].Rk); // SessionsIndex first Assert.Contains(SessionId, tombstone.Rows[0].Rk, StringComparison.Ordinal); // RK still references the session Assert.Equal(SessionId, tombstone.Rows[1].Rk); // Sessions row second + + // FINAL rows carry their source table explicitly (DeletionStep.Table is null on this + // step); delete/restore route by it instead of the legacy RowKey-shape heuristic. + Assert.Equal(Constants.TableNames.SessionsIndex, tombstone.Rows[0].Table); + Assert.Equal(Constants.TableNames.Sessions, tombstone.Rows[1].Table); } [Fact] diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/DeletionTombstoneTablesTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/DeletionTombstoneTablesTests.cs new file mode 100644 index 00000000..5598afb0 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/DeletionTombstoneTablesTests.cs @@ -0,0 +1,61 @@ +using System.Text.Json; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.Models.Deletion; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Pins the FINAL-step table resolution: an explicit wins +/// unconditionally; only legacy manifests (written before the field existed) fall back to the +/// RowKey-shape heuristic. The adversarial rows deliberately contradict the heuristic so a +/// regression to Contains('_') routing flips the assertion. +/// +public class DeletionTombstoneTablesTests +{ + [Fact] + public void Explicit_table_wins_over_contradicting_rowkey_shape() + { + // RK contains '_' (heuristic would say SessionsIndex) but Table says Sessions — and vice versa. + var sessionsRow = new DeletionRowDump { Pk = "t", Rk = "id_with_underscores", Table = Constants.TableNames.Sessions }; + var indexRow = new DeletionRowDump { Pk = "t", Rk = "plainkey", Table = Constants.TableNames.SessionsIndex }; + + Assert.Equal(Constants.TableNames.Sessions, DeletionTombstoneTables.Resolve(sessionsRow)); + Assert.Equal(Constants.TableNames.SessionsIndex, DeletionTombstoneTables.Resolve(indexRow)); + } + + [Fact] + public void Legacy_rows_without_table_fall_back_to_rowkey_shape() + { + // Pre-field manifests: Sessions RK is the bare session GUID, index RK is composed. + var legacySessions = new DeletionRowDump { Pk = "t", Rk = "22222222-2222-2222-2222-222222222222" }; + var legacyIndex = new DeletionRowDump { Pk = "t", Rk = "6299999999999999999_22222222-2222-2222-2222-222222222222" }; + + Assert.Equal(Constants.TableNames.Sessions, DeletionTombstoneTables.Resolve(legacySessions)); + Assert.Equal(Constants.TableNames.SessionsIndex, DeletionTombstoneTables.Resolve(legacyIndex)); + } + + [Fact] + public void Table_field_round_trips_through_manifest_json() + { + var dump = new DeletionRowDump { Pk = "t", Rk = "rk", Table = Constants.TableNames.SessionsIndex }; + var json = JsonSerializer.Serialize(dump, DeletionManifestJson.SerializerOptions); + Assert.Contains("\"table\":", json); + + var roundTripped = JsonSerializer.Deserialize(json, DeletionManifestJson.SerializerOptions); + Assert.Equal(Constants.TableNames.SessionsIndex, roundTripped!.Table); + } + + [Fact] + public void Legacy_manifest_json_without_table_deserializes_to_null_and_is_omitted_on_write() + { + // WhenWritingNull keeps rows of table-targeted steps (Table always null there) byte- + // identical to the pre-field format — SchemaHash of those steps is unaffected. + var legacyJson = "{\"pk\":\"t\",\"rk\":\"rk\",\"props\":{}}"; + var dump = JsonSerializer.Deserialize(legacyJson, DeletionManifestJson.SerializerOptions); + Assert.Null(dump!.Table); + + var rewritten = JsonSerializer.Serialize(dump, DeletionManifestJson.SerializerOptions); + Assert.DoesNotContain("table", rewritten); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/OkAsyncBaselineGuardTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/OkAsyncBaselineGuardTests.cs new file mode 100644 index 00000000..955f3217 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/OkAsyncBaselineGuardTests.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Ratchet against NEW anonymous-object API responses: req.OkAsync(new { ... }) ships a +/// shape no type checks, no manifest exports, and the web mirrors by hand — the drift class the +/// 2026-08-13 fragility audit found across 44 call sites. New/changed endpoints return typed +/// response DTOs (AutopilotMonitor.Shared.Models, picked up by SharedManifestParityTests); +/// the existing sites are frozen as a per-file baseline that may only SHRINK: +/// - a new anonymous OkAsync (new file, or count above baseline) fails, +/// - converting a site to a typed DTO fails too until its baseline entry is lowered/removed, +/// so the baseline always reflects reality and the debt is visible in the diff. +/// Typed object initializers (OkAsync(new SomeResponse { ... })) do not match. +/// +public class OkAsyncBaselineGuardTests +{ + private static readonly Regex AnonymousOkAsync = new(@"OkAsync\(\s*new\s*\{", RegexOptions.Compiled); + + /// + /// Frozen 2026-08-13 baseline (44 sites / 34 files), relative to the Functions project. + /// Only ever lower a count or delete an entry — never raise or add. + /// + private static readonly Dictionary Baseline = new(StringComparer.OrdinalIgnoreCase) + { + ["Functions/Admin/GetActiveUsersFunction.cs"] = 1, + ["Functions/Admin/GetAuditLogsFunction.cs"] = 2, + ["Functions/Admin/GetGlobalAuditLogsFunction.cs"] = 2, + ["Functions/Admin/GetOpsEventsFunction.cs"] = 2, + ["Functions/Annotations/GetSessionAnnotationsFunction.cs"] = 1, + ["Functions/Annotations/ListSessionAnnotationsFunction.cs"] = 1, + ["Functions/Annotations/ListTenantSessionAnnotationsFunction.cs"] = 1, + ["Functions/Annotations/UpsertSessionAnnotationFunction.cs"] = 2, + ["Functions/Config/GetAllTenantConfigurationsFunction.cs"] = 2, + ["Functions/Graph/GetGraphPermissionsStatusFunction.cs"] = 1, + ["Functions/Graph/GetScriptDisplayNamesFunction.cs"] = 3, + ["Functions/Graph/RefreshGraphPermissionsFunction.cs"] = 1, + ["Functions/Raw/QueryRawEventsFunction.cs"] = 2, + ["Functions/Raw/QueryRawSessionsFunction.cs"] = 1, + ["Functions/Reports/GetDeviceNotRegisteredFunction.cs"] = 1, + ["Functions/Reports/GetHardwareRejectedFunction.cs"] = 1, + ["Functions/Reports/GetSessionReportsFunction.cs"] = 2, + ["Functions/Reports/GetTpmPssUnsupportedFunction.cs"] = 1, + ["Functions/Sessions/GetAllSessionStatsFunction.cs"] = 1, + ["Functions/Sessions/GetAllSessionsFunction.cs"] = 1, + ["Functions/Sessions/GetSessionDecisionGraphFunction.cs"] = 1, + ["Functions/Sessions/GetSessionDeletionsListFunction.cs"] = 1, + ["Functions/Sessions/GetSessionEventsFunction.cs"] = 2, + ["Functions/Sessions/GetSessionFunction.cs"] = 1, + ["Functions/Sessions/GetSessionReducerVerificationFunction.cs"] = 1, + ["Functions/Sessions/GetSessionSignalsFunction.cs"] = 1, + ["Functions/Sessions/GetSessionStatsFunction.cs"] = 1, + ["Functions/Sessions/GetSessionsFunction.cs"] = 1, + ["Functions/Sessions/GetTenantDeletionManifestsFunction.cs"] = 1, + ["Functions/Sessions/GetTenantsWithDeletionManifestsFunction.cs"] = 1, + ["Functions/Sessions/QuickSearchSessionsFunction.cs"] = 1, + ["Functions/Sessions/SearchSessionsByCveFunction.cs"] = 1, + ["Functions/Sessions/SearchSessionsByEventFunction.cs"] = 1, + ["Functions/Sessions/SearchSessionsFunction.cs"] = 1, + }; + + [Fact] + public void Anonymous_OkAsync_sites_never_exceed_the_frozen_baseline() + { + var functionsRoot = Path.Combine(FindRepoRoot(), "src", "Backend", "AutopilotMonitor.Functions"); + var actual = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var file in Directory.EnumerateFiles(functionsRoot, "*.cs", SearchOption.AllDirectories)) + { + var relative = Path.GetRelativePath(functionsRoot, file).Replace(Path.DirectorySeparatorChar, '/'); + if (relative.StartsWith("bin/", StringComparison.OrdinalIgnoreCase) || + relative.StartsWith("obj/", StringComparison.OrdinalIgnoreCase) || + relative.Contains("/bin/", StringComparison.OrdinalIgnoreCase) || + relative.Contains("/obj/", StringComparison.OrdinalIgnoreCase)) + continue; + + var count = AnonymousOkAsync.Matches(File.ReadAllText(file)).Count; + if (count > 0) + actual[relative] = count; + } + + var violations = new List(); + + foreach (var (file, count) in actual.OrderBy(kv => kv.Key, StringComparer.Ordinal)) + { + if (!Baseline.TryGetValue(file, out var allowed)) + violations.Add($"{file}: {count} NEW anonymous OkAsync site(s) — return a typed response DTO instead."); + else if (count > allowed) + violations.Add($"{file}: {count} anonymous OkAsync site(s), baseline allows {allowed} — return a typed response DTO for the new one(s)."); + } + + // Ratchet: a converted site must also lower its baseline entry, so the frozen debt + // list stays truthful (and its shrink is visible in the diff). + foreach (var (file, allowed) in Baseline.OrderBy(kv => kv.Key, StringComparer.Ordinal)) + { + actual.TryGetValue(file, out var count); + if (count < allowed) + violations.Add($"{file}: baseline says {allowed} but only {count} remain — lower/remove its entry in {nameof(OkAsyncBaselineGuardTests)} (ratchet down)."); + } + + Assert.True(violations.Count == 0, + "Anonymous OkAsync(new { ... }) baseline violated:\n " + string.Join("\n ", violations)); + } + + 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; + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs index 9e4c04cb..b1a2d6e8 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs @@ -605,7 +605,7 @@ public class PolicyEnforcementMiddlewareTests Assert.True(result.Allowed); } - // ── Transactional config patch / backups / revert — GlobalAdminOnly (phase 1) ── + // ── Transactional config patch (TenantAdminOrGA) / backups + revert (GlobalAdminOnly) ── [Fact] public async Task ConfigFieldPatchRoutes_GlobalAdmin_IsAllowed() @@ -658,13 +658,12 @@ public class PolicyEnforcementMiddlewareTests } [Fact] - public async Task ConfigFieldPatchRoutes_TenantAdmin_GlobalReader_Delegated_AreForbidden() + public async Task ConfigBackupAndRevertRoutes_TenantAdmin_GlobalReader_Delegated_AreForbidden() { - // Phase 1 is deliberately GA-only — even the tenant's OWN admin is denied until the - // phase-2 flip to TenantAdminOrGA (+ the caller-tier field deny-list in the service). + // Backups + revert stay deliberately GA-only (operator restore surface) — even the + // tenant's OWN admin is denied. Only the PATCH line was widened in phase 2. foreach (var (method, path) in new[] { - ("PATCH", $"/api/config/{TenantA}/fields"), ("GET", $"/api/config/{TenantA}/backups"), ("POST", $"/api/config/{TenantA}/revert"), }) @@ -672,7 +671,7 @@ public class PolicyEnforcementMiddlewareTests var ownAdmin = BuildHarness(); ownAdmin.AsTenantAdmin(TenantA, "admin@contoso.com"); var adminResult = await ownAdmin.Middleware.DecideAsync(method, path, null, AuthedPrincipal(TenantA, "admin@contoso.com")); - Assert.False(adminResult.Allowed, $"{method} {path} must deny a tenant admin in phase 1"); + Assert.False(adminResult.Allowed, $"{method} {path} must deny a tenant admin"); var reader = BuildHarness(); reader.AsGlobalRole(Constants.GlobalRoles.GlobalReader); @@ -682,10 +681,49 @@ public class PolicyEnforcementMiddlewareTests var delegated = BuildHarness(); delegated.AsDelegated(TenantA, Constants.DelegatedRoles.DelegatedAdmin); var delegatedResult = await delegated.Middleware.DecideAsync(method, path, null, AuthedPrincipal(TenantB, "msp@partner.example")); - Assert.False(delegatedResult.Allowed, $"{method} {path} must deny a delegated (MSP) admin in phase 1"); + Assert.False(delegatedResult.Allowed, $"{method} {path} must deny a delegated (MSP) admin"); } } + [Fact] + public async Task ConfigFieldPatch_OwnTenantAdmin_IsAllowed() + { + // Phase 2: the web's per-section save PATCHes the caller's own config row. The + // GA-only field protection moved into the service's TenantAdmin caller tier + // (explicit 400 per field) — the route itself admits the own-tenant admin. + var h = BuildHarness(); + h.AsTenantAdmin(TenantA, "admin@contoso.com"); + + var result = await h.Middleware.DecideAsync( + "PATCH", $"/api/config/{TenantA}/fields", null, AuthedPrincipal(TenantA, "admin@contoso.com")); + + Assert.True(result.Allowed); + } + + [Fact] + public async Task ConfigFieldPatch_ForeignTenant_Reader_Delegated_AreForbidden() + { + // RouteParam scoping: a tenant admin cannot patch ANOTHER tenant's row; the read-only + // Global Reader and delegated (MSP) admins stay excluded entirely. + var foreignAdmin = BuildHarness(); + foreignAdmin.AsTenantAdmin(TenantA, "admin@contoso.com"); + var foreignResult = await foreignAdmin.Middleware.DecideAsync( + "PATCH", $"/api/config/{TenantB}/fields", null, AuthedPrincipal(TenantA, "admin@contoso.com")); + Assert.False(foreignResult.Allowed, "own-tenant admin must not patch a foreign tenant's config"); + + var reader = BuildHarness(); + reader.AsGlobalRole(Constants.GlobalRoles.GlobalReader); + var readerResult = await reader.Middleware.DecideAsync( + "PATCH", $"/api/config/{TenantA}/fields", null, AuthedPrincipal(TenantB, "reader@vendor.example")); + Assert.False(readerResult.Allowed, "read-only Global Reader must not patch config fields"); + + var delegated = BuildHarness(); + delegated.AsDelegated(TenantA, Constants.DelegatedRoles.DelegatedAdmin); + var delegatedResult = await delegated.Middleware.DecideAsync( + "PATCH", $"/api/config/{TenantA}/fields", null, AuthedPrincipal(TenantB, "msp@partner.example")); + Assert.False(delegatedResult.Allowed, "delegated (MSP) admin must not patch config fields"); + } + [Fact] public async Task Delegated_OwnHomeTenant_NonMember_IsStillForbidden() { diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/RowKeyCodecTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/RowKeyCodecTests.cs new file mode 100644 index 00000000..793e9525 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/RowKeyCodecTests.cs @@ -0,0 +1,82 @@ +using System; +using AutopilotMonitor.Functions.Helpers; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Pins the single inverted-tick RowKey encoder every table-key builder now routes through. +/// The encodings are PERSISTED — a change here reorders live tables against their existing +/// rows, so the exact widths and boundary literals are asserted, not just round-trips. +/// +public class RowKeyCodecTests +{ + [Fact] + public void InvertedTicks_boundary_literals_are_pinned() + { + // rev(MaxValue) = 0 and rev(MinValue) = MaxValue.Ticks — exact strings, no derivation. + Assert.Equal("0000000000000000000", RowKeyCodec.InvertedTicks(DateTime.MaxValue)); + Assert.Equal("3155378975999999999", RowKeyCodec.InvertedTicks(DateTime.MinValue)); + } + + [Fact] + public void InvertedTicks_is_fixed_width_19_digits() + { + foreach (var utc in SampleInstants()) + { + var key = RowKeyCodec.InvertedTicks(utc); + Assert.Equal(19, key.Length); + Assert.All(key, c => Assert.InRange(c, '0', '9')); + } + } + + [Fact] + public void InvertedTicksD20_is_the_same_value_one_zero_wider() + { + // UserActivity legacy width: identical rev value, padded to 20 digits. Existing D20 + // rows pin this — a D19 key would sort after every D20 key in that table. + foreach (var utc in SampleInstants()) + { + var d20 = RowKeyCodec.InvertedTicksD20(utc); + Assert.Equal(20, d20.Length); + Assert.Equal("0" + RowKeyCodec.InvertedTicks(utc), d20); + } + } + + [Fact] + public void Later_instants_sort_lexicographically_first() + { + var earlier = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var later = earlier.AddSeconds(1); + Assert.True(string.CompareOrdinal(RowKeyCodec.InvertedTicks(later), RowKeyCodec.InvertedTicks(earlier)) < 0); + } + + [Fact] + public void Decode_round_trips_encode_exactly() + { + foreach (var utc in SampleInstants()) + { + Assert.True(RowKeyCodec.TryDecodeInvertedTicks(RowKeyCodec.InvertedTicks(utc), out var decoded)); + Assert.Equal(utc.Ticks, decoded.Ticks); + Assert.Equal(DateTimeKind.Utc, decoded.Kind); + } + } + + [Theory] + [InlineData("")] // empty + [InlineData("123456789012345678x")] // non-digit + [InlineData("!155378975999999999")] // prefix leaked into the digit run + [InlineData("9999999999999999999")] // rev > MaxValue.Ticks → negative tick value + public void Decode_rejects_foreign_input(string digits) + { + Assert.False(RowKeyCodec.TryDecodeInvertedTicks(digits, out _)); + } + + private static DateTime[] SampleInstants() => new[] + { + new DateTime(2026, 8, 13, 18, 30, 12, DateTimeKind.Utc), + new DateTime(2001, 2, 3, 4, 5, 6, DateTimeKind.Utc).AddTicks(7), + DateTime.MinValue, + DateTime.MaxValue, + }; +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SessionRestoreServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SessionRestoreServiceTests.cs index 2fa7abb8..de0f9cac 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/SessionRestoreServiceTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/SessionRestoreServiceTests.cs @@ -92,6 +92,36 @@ public class SessionRestoreServiceTests Assert.Equal(3, result.InventoryReIncrements); } + [Fact] + public async Task Restore_routes_tombstone_rows_by_explicit_table_not_rowkey_shape() + { + var harness = new Harness(); + harness.SetCompletedCascade(); + // Adversarial fixture: each row's explicit Table CONTRADICTS the legacy RowKey-shape + // heuristic (Sessions RK carries '_', SessionsIndex RK is bare). New manifests always + // carry Table — a call-site regression back to Contains('_') routing flips both + // tables and this test goes red. (BuildFullManifest's tombstone rows deliberately + // stay Table-less to keep exercising the legacy fallback in the other facts.) + var tombstone = harness.Manifest.Steps.First(s => s.Class == DeletionStepClass.Final); + tombstone.Rows = new List + { + new DeletionRowDump { Pk = TenantId, Rk = "bare-index-key", Table = Constants.TableNames.SessionsIndex }, + new DeletionRowDump { Pk = TenantId, Rk = $"under_scored_{SessionId}", Table = Constants.TableNames.Sessions }, + }; + + var result = await harness.Sut.RestoreAsync(TenantId, SessionId, ManifestId, dryRun: false, actor: "ga@example.com"); + + Assert.Equal(SessionRestoreOutcome.Restored, result.Outcome); + harness.Storage.Verify(s => s.RestoreRowsByExactKeysInBatchesAsync( + Constants.TableNames.SessionsIndex, + It.Is>(rows => rows.Count == 1 && rows[0].Rk == "bare-index-key"), + RestoreMode.Full, It.IsAny()), Times.Once); + harness.Storage.Verify(s => s.RestoreRowsByExactKeysInBatchesAsync( + Constants.TableNames.Sessions, + It.Is>(rows => rows.Count == 1 && rows[0].Rk.StartsWith("under_scored_")), + RestoreMode.Full, It.IsAny()), Times.Once); + } + [Fact] public async Task Restore_full_re_increments_software_inventory_via_contributions() { diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SharedManifestParityTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SharedManifestParityTests.cs index f49d98e0..a90649a3 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/SharedManifestParityTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/SharedManifestParityTests.cs @@ -81,6 +81,9 @@ public sealed class SharedManifestParityTests ["analyzeRuleSources"] = DryRunAnalyzeRuleFunction.KnownSources.OrderBy(s => s, StringComparer.Ordinal).ToArray(), ["analyzeRuleOperators"] = DryRunAnalyzeRuleFunction.KnownOperators.OrderBy(s => s, StringComparer.Ordinal).ToArray(), ["eventTypes"] = ConstStrings(typeof(Constants.EventTypes)).OrderBy(s => s, StringComparer.Ordinal).ToArray(), + // Declaration order kept: the web derives a union type from this list, order is cosmetic + // but a stable order keeps the generated file diff-minimal. + ["signalRMessages"] = ConstStrings(typeof(Constants.SignalRMessages)), }; var json = JsonSerializer.Serialize(manifest, new JsonSerializerOptions diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigBackupRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigBackupRepository.cs index 833edc56..0320feab 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigBackupRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigBackupRepository.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Shared; using AutopilotMonitor.Shared.DataAccess; @@ -99,7 +100,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage /// same tick cannot collide. The value is the public backupId. /// public static string BuildRowKey(DateTime utcNow) - => $"{DateTime.MaxValue.Ticks - utcNow.Ticks:D19}_{Guid.NewGuid():N}"[..28]; + => $"{RowKeyCodec.InvertedTicks(utcNow)}_{Guid.NewGuid():N}"[..28]; private static string Escape(string s) => s.Replace("'", "''"); diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableDistressReportRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableDistressReportRepository.cs index 40c917bd..a24947bd 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableDistressReportRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableDistressReportRepository.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; using Azure.Data.Tables; +using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Shared; using AutopilotMonitor.Shared.DataAccess; @@ -29,7 +30,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage public async Task SaveDistressReportAsync(string tenantId, DistressReportEntry entry) { var pk = tenantId.ToLowerInvariant(); - var rk = $"{DateTime.MaxValue.Ticks - DateTime.UtcNow.Ticks:D19}"; + var rk = RowKeyCodec.InvertedTicks(DateTime.UtcNow); var entity = new TableEntity(pk, rk) { diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableNotificationRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableNotificationRepository.cs index d6d29e61..e593f1bf 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableNotificationRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableNotificationRepository.cs @@ -1,3 +1,4 @@ +using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Pagination; using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Shared; @@ -36,7 +37,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage { try { - var invertedTicks = (DateTime.MaxValue.Ticks - notification.CreatedAt.Ticks).ToString("D19"); + var invertedTicks = RowKeyCodec.InvertedTicks(notification.CreatedAt); var notificationId = notification.NotificationId; if (string.IsNullOrEmpty(notificationId)) notificationId = Guid.NewGuid().ToString("N")[..12]; @@ -199,7 +200,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage { try { - var invertedTicks = (DateTime.MaxValue.Ticks - metadata.SubmittedAt.Ticks).ToString("D19"); + var invertedTicks = RowKeyCodec.InvertedTicks(metadata.SubmittedAt); var entity = new TableEntity("reports", $"{invertedTicks}_{metadata.ReportId}") { ["ReportId"] = metadata.ReportId ?? string.Empty, diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableOpsEventRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableOpsEventRepository.cs index 2c3d10b4..2571b386 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableOpsEventRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableOpsEventRepository.cs @@ -34,7 +34,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage public async Task SaveOpsEventAsync(OpsEventEntry entry) { var pk = entry.Category; - var rk = $"{DateTime.MaxValue.Ticks - DateTime.UtcNow.Ticks:D19}"; + var rk = RowKeyCodec.InvertedTicks(DateTime.UtcNow); var entity = new TableEntity(pk, rk) { diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableTenantNotificationRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableTenantNotificationRepository.cs index 99a4e02f..ca606983 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableTenantNotificationRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableTenantNotificationRepository.cs @@ -1,3 +1,4 @@ +using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Shared; using AutopilotMonitor.Shared.DataAccess; @@ -33,7 +34,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage try { var partitionKey = tenantId.ToLowerInvariant(); - var invertedTicks = (DateTime.MaxValue.Ticks - notification.CreatedAt.Ticks).ToString("D19"); + var invertedTicks = RowKeyCodec.InvertedTicks(notification.CreatedAt); var notificationId = notification.NotificationId; if (string.IsNullOrEmpty(notificationId)) notificationId = Guid.NewGuid().ToString("N")[..12]; diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/PatchTenantConfigurationFieldsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/PatchTenantConfigurationFieldsFunction.cs index d26d7ca1..cc2e504a 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/PatchTenantConfigurationFieldsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/PatchTenantConfigurationFieldsFunction.cs @@ -14,12 +14,12 @@ using Newtonsoft.Json.Linq; namespace AutopilotMonitor.Functions.Functions.Config { /// - /// Transactional field-level patch of a tenant's configuration (GlobalAdminOnly for now; - /// phase 2 widens the policy line + caller tier). Unlike the full-model PUT, this takes - /// ONLY the fields to change, and the service verifies after the conditional write that - /// exactly those fields changed — rolling back automatically on drift. Every write is - /// preceded by a fail-closed snapshot into ConfigurationBackups (revertible via - /// POST config/{tenantId}/revert). + /// Transactional field-level patch of a tenant's configuration (TenantAdminOrGA: a tenant + /// admin patches their own row on the stricter TenantAdmin caller tier — GA-only fields are + /// an explicit 400 there). Unlike the full-model PUT, this takes ONLY the fields to change, + /// and the service verifies after the conditional write that exactly those fields changed — + /// rolling back automatically on drift. Every write is preceded by a fail-closed snapshot + /// into ConfigurationBackups (revertible via POST config/{tenantId}/revert, GA-only). /// public class PatchTenantConfigurationFieldsFunction { @@ -49,7 +49,8 @@ namespace AutopilotMonitor.Functions.Functions.Config { try { - // Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware. + // Authentication + TenantAdminOrGA authorization enforced by PolicyEnforcementMiddleware + // (RouteParam scoping binds non-GA callers to their own tenant row). var requestCtx = req.GetRequestContext(); if (req.Headers.TryGetValues("Content-Length", out var clValues) @@ -79,13 +80,19 @@ namespace AutopilotMonitor.Functions.Functions.Config "PatchTenantConfigurationFields: {TenantId} by {User} ({FieldCount} fields)", requestCtx.TargetTenantId, requestCtx.UserPrincipalName, request.Fields.Count); + // Caller tier selects the field deny-list: tenant admins additionally lose the + // GA-only toggles (explicit 400 instead of the PUT's silent restore). + var callerTier = requestCtx.IsGlobalAdmin + ? TenantConfigCallerTier.GlobalAdmin + : TenantConfigCallerTier.TenantAdmin; + var outcome = await _patchService.ApplyFieldPatchAsync( requestCtx.TargetTenantId, request.Fields, requestCtx.UserPrincipalName, ResolveSource(req, "patch"), request.Reason, - TenantConfigCallerTier.GlobalAdmin); + callerTier); return await WriteOutcome(req, outcome); } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRAddToGroupFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRAddToGroupFunction.cs index 98d8fe4b..b96d6ebe 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRAddToGroupFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRAddToGroupFunction.cs @@ -195,7 +195,7 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure [HttpResult] public HttpResponseData? HttpResponse { get; set; } - [SignalROutput(HubName = "autopilotmonitor")] + [SignalROutput(HubName = SignalRGroupHelper.HubName)] public SignalRGroupAction? SignalRGroupAction { get; set; } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRRemoveFromGroupFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRRemoveFromGroupFunction.cs index 4f91e31f..3546a53d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRRemoveFromGroupFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRRemoveFromGroupFunction.cs @@ -167,7 +167,7 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure [HttpResult] public HttpResponseData? HttpResponse { get; set; } - [SignalROutput(HubName = "autopilotmonitor")] + [SignalROutput(HubName = SignalRGroupHelper.HubName)] public SignalRGroupAction? SignalRGroupAction { get; set; } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestTelemetryFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestTelemetryFunction.cs index 4b65373c..7ba03f64 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestTelemetryFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestTelemetryFunction.cs @@ -1,5 +1,6 @@ using System.IO; using System.Net; +using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Security; using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Functions.Services.Deletion; @@ -614,7 +615,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest [HttpResult] public HttpResponseData? HttpResponse { get; set; } - [SignalROutput(HubName = "autopilotmonitor")] + [SignalROutput(HubName = SignalRGroupHelper.HubName)] public SignalRMessageAction[]? SignalRMessages { get; set; } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/MarkSessionFailedFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/MarkSessionFailedFunction.cs index 83b5d303..19a4098b 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/MarkSessionFailedFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/MarkSessionFailedFunction.cs @@ -1,5 +1,6 @@ using System.Net; using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Shared; using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; using Microsoft.Azure.Functions.Worker; @@ -102,7 +103,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions sessionUpdate = sessionDelta }; - var tenantMessage = new SignalRMessageAction("newevents") + var tenantMessage = new SignalRMessageAction(Constants.SignalRMessages.NewEvents) { GroupName = $"tenant-{tenantId}", Arguments = new[] { messagePayload } @@ -111,7 +112,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions // Also push to the per-session group: the tenant broadcast group is member-role // gated, so a roleless Progress-Portal watcher of this session would otherwise // never see the admin-marked failure. - var sessionMessage = new SignalRMessageAction("newevents") + var sessionMessage = new SignalRMessageAction(Constants.SignalRMessages.NewEvents) { GroupName = $"session-{tenantId}-{sessionId}", Arguments = new[] { messagePayload } @@ -156,7 +157,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions [HttpResult] public HttpResponseData? HttpResponse { get; set; } - [SignalROutput(HubName = "autopilotmonitor")] + [SignalROutput(HubName = SignalRGroupHelper.HubName)] public SignalRMessageAction[]? SignalRMessages { get; set; } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/MarkSessionSucceededFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/MarkSessionSucceededFunction.cs index 1ce6ea7f..b13d5037 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/MarkSessionSucceededFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/MarkSessionSucceededFunction.cs @@ -1,5 +1,6 @@ using System.Net; using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Shared; using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; using Microsoft.Azure.Functions.Worker; @@ -99,7 +100,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions sessionUpdate = sessionDelta }; - var tenantMessage = new SignalRMessageAction("newevents") + var tenantMessage = new SignalRMessageAction(Constants.SignalRMessages.NewEvents) { GroupName = $"tenant-{tenantId}", Arguments = new[] { messagePayload } @@ -108,7 +109,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions // Also push to the per-session group: the tenant broadcast group is member-role // gated, so a roleless Progress-Portal watcher of this session would otherwise // never see the admin-marked completion. - var sessionMessage = new SignalRMessageAction("newevents") + var sessionMessage = new SignalRMessageAction(Constants.SignalRMessages.NewEvents) { GroupName = $"session-{tenantId}-{sessionId}", Arguments = new[] { messagePayload } @@ -153,7 +154,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions [HttpResult] public HttpResponseData? HttpResponse { get; set; } - [SignalROutput(HubName = "autopilotmonitor")] + [SignalROutput(HubName = SignalRGroupHelper.HubName)] public SignalRMessageAction[]? SignalRMessages { get; set; } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs index 7caaa35c..50ee74a5 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs @@ -1,5 +1,6 @@ using AutopilotMonitor.Shared; using System.Net; +using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Security; using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Functions.Services.Deletion; @@ -241,14 +242,14 @@ namespace AutopilotMonitor.Functions.Functions.Sessions }; // 1. Tenant-specific notification (normal users) - var tenantMessage = new SignalRMessageAction("newSession") + var tenantMessage = new SignalRMessageAction(Constants.SignalRMessages.NewSession) { GroupName = $"tenant-{registration.TenantId}", Arguments = new[] { messagePayload } }; // 2. Global Admins notification (cross-tenant visibility) - var globalAdminMessage = new SignalRMessageAction("newSession") + var globalAdminMessage = new SignalRMessageAction(Constants.SignalRMessages.NewSession) { GroupName = "global-admins", Arguments = new[] { messagePayload } @@ -313,7 +314,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions "Superseded stale {StaleStatus} session {StaleSessionId} (started {StaleStartedAt:yyyy-MM-dd}) with new session {SessionId} for device serial (tenant {TenantId})", stale.Status, stale.SessionId, stale.StartedAt, registration.SessionId, registration.TenantId); - messages.Add(new SignalRMessageAction("newevents") + messages.Add(new SignalRMessageAction(Constants.SignalRMessages.NewEvents) { GroupName = $"tenant-{registration.TenantId}", Arguments = new object[] { new { @@ -408,7 +409,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions [HttpResult] public HttpResponseData? HttpResponse { get; set; } - [SignalROutput(HubName = "autopilotmonitor")] + [SignalROutput(HubName = SignalRGroupHelper.HubName)] public SignalRMessageAction[]? SignalRMessages { get; set; } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Helpers/BusinessTimestamp.cs b/src/Backend/AutopilotMonitor.Functions/Helpers/BusinessTimestamp.cs index ed2f1d08..8d64c071 100644 --- a/src/Backend/AutopilotMonitor.Functions/Helpers/BusinessTimestamp.cs +++ b/src/Backend/AutopilotMonitor.Functions/Helpers/BusinessTimestamp.cs @@ -94,21 +94,7 @@ namespace AutopilotMonitor.Functions.Helpers } private static bool TryDecodeReverseTicks(ReadOnlySpan revTickDigits, out DateTime utc) - { - utc = default; - foreach (var c in revTickDigits) - { - if (c < '0' || c > '9') - return false; - } - if (!long.TryParse(revTickDigits, NumberStyles.None, CultureInfo.InvariantCulture, out var rev)) - return false; - var ticks = DateTime.MaxValue.Ticks - rev; - if (ticks < DateTime.MinValue.Ticks || ticks > DateTime.MaxValue.Ticks) - return false; - utc = new DateTime(ticks, DateTimeKind.Utc); - return true; - } + => RowKeyCodec.TryDecodeInvertedTicks(revTickDigits, out utc); // ===== OData date-window clauses (RowKey ranges) ===== // Date windows filter on the RowKey rather than a property: rows missing a property are @@ -117,7 +103,7 @@ namespace AutopilotMonitor.Functions.Helpers // because the reverse-tick is fixed-width D19. rev(t) = MaxTicks - t.Ticks, so larger // timestamps have lexically smaller RowKeys (newest-first). - private static long Rev(DateTime utc) => DateTime.MaxValue.Ticks - utc.Ticks; + private static long Rev(DateTime utc) => RowKeyCodec.InvertedTicksValue(utc); /// Audit rows with business time ≥ (inclusive): /// rev ≤ rev(from) < rev(from)+1. The exclusive upper bound also excludes all legacy diff --git a/src/Backend/AutopilotMonitor.Functions/Helpers/RowKeyCodec.cs b/src/Backend/AutopilotMonitor.Functions/Helpers/RowKeyCodec.cs new file mode 100644 index 00000000..c652e50d --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Helpers/RowKeyCodec.cs @@ -0,0 +1,52 @@ +using System; +using System.Globalization; + +namespace AutopilotMonitor.Functions.Helpers +{ + /// + /// Single encoder/decoder for inverted-tick RowKeys: rev(t) = DateTime.MaxValue.Ticks - t.Ticks, + /// zero-padded to a fixed width so lexicographic RowKey order equals newest-first time order. + /// + /// Every format built on top of this codec is PERSISTED in production tables — the codec + /// unifies construction, it must never change an existing table's padding/prefix/suffix shape: + /// D19 (standard): SessionsIndex/EventTypeIndex "{D19}_{sessionId}", AuditLogs + /// "!{D19}_{guid:N}", OpsEvents/DistressReports bare "{D19}", Notifications/ + /// TenantNotifications/SessionReports "{D19}_{id}", ConfigurationBackups "({D19}_{guid:N})[..28]". + /// D20: UserActivity ONLY — its existing rows pin the 20-digit width; a 19-digit key would + /// sort after every 20-digit key and silently break that table's newest-first ordering. + /// + internal static class RowKeyCodec + { + /// Raw reverse-tick value; use for OData clause arithmetic (e.g. rev+1 bounds). + internal static long InvertedTicksValue(DateTime utc) => DateTime.MaxValue.Ticks - utc.Ticks; + + /// Standard fixed-width D19 inverted-tick encoding. + internal static string InvertedTicks(DateTime utc) + => InvertedTicksValue(utc).ToString("D19", CultureInfo.InvariantCulture); + + /// UserActivity-only legacy D20 width — see class remarks. Do not use for new tables. + internal static string InvertedTicksD20(DateTime utc) + => InvertedTicksValue(utc).ToString("D20", CultureInfo.InvariantCulture); + + /// + /// Decodes a fixed-width inverted-tick digit run back to the original UTC instant + /// (Kind=Utc). Rejects non-digit characters and out-of-range values. + /// + internal static bool TryDecodeInvertedTicks(ReadOnlySpan revTickDigits, out DateTime utc) + { + utc = default; + foreach (var c in revTickDigits) + { + if (c < '0' || c > '9') + return false; + } + if (!long.TryParse(revTickDigits, NumberStyles.None, CultureInfo.InvariantCulture, out var rev)) + return false; + var ticks = DateTime.MaxValue.Ticks - rev; + if (ticks < DateTime.MinValue.Ticks || ticks > DateTime.MaxValue.Ticks) + return false; + utc = new DateTime(ticks, DateTimeKind.Utc); + return true; + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Helpers/SignalRGroupHelper.cs b/src/Backend/AutopilotMonitor.Functions/Helpers/SignalRGroupHelper.cs index 22327cc9..4d8e5749 100644 --- a/src/Backend/AutopilotMonitor.Functions/Helpers/SignalRGroupHelper.cs +++ b/src/Backend/AutopilotMonitor.Functions/Helpers/SignalRGroupHelper.cs @@ -13,6 +13,12 @@ namespace AutopilotMonitor.Functions.Helpers; /// public static class SignalRGroupHelper { + /// + /// The single SignalR hub every binding and imperative send targets. Const so it is usable + /// in [SignalROutput(HubName = ...)] attribute arguments. + /// + public const string HubName = "autopilotmonitor"; + public const string TenantNotifyMemberSuffix = "-notify-member"; public const string TenantNotifyAdminSuffix = "-notify-admin"; diff --git a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs index fd2c2ef3..11f5747d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs @@ -414,12 +414,14 @@ public static class EndpointAccessPolicyCatalog new("PUT", "global/config", EndpointPolicy.GlobalAdminOnly), new("POST", "global/config", EndpointPolicy.GlobalAdminOnly), new("GET", "config/all", EndpointPolicy.GlobalReadOrDelegatedSubset), - // Transactional field-level config writes + snapshot list/revert (MCP-first surface). - // GlobalAdminOnly for now; phase 2 widens these three lines to TenantAdminOrGA (own row) - // and a delegated-admin write policy — the service's caller-tier field deny-lists and - // TenantScoping.RouteParam already carry the per-tenant guardrail. The backups GET is - // metadata-only (masked diff, never the raw EntityJson snapshot). - new("PATCH", "config/{tenantId}/fields", EndpointPolicy.GlobalAdminOnly, TenantScoping.RouteParam), + // Transactional field-level config writes + snapshot list/revert (MCP + web Settings). + // PATCH is TenantAdminOrGA (phase 2, the web's per-section save): RouteParam binds a + // tenant admin to their OWN row, and the service's caller-tier deny-list turns the + // GA-only toggles into explicit 400s for the TenantAdmin tier. Backups GET + revert + // POST stay GlobalAdminOnly (operator-only restore surface; the backups GET is + // metadata-only — masked diff, never the raw EntityJson snapshot). Delegated (MSP) + // config writes remain a future policy decision. + new("PATCH", "config/{tenantId}/fields", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam), new("GET", "config/{tenantId}/backups", EndpointPolicy.GlobalAdminOnly, TenantScoping.RouteParam), new("POST", "config/{tenantId}/revert", EndpointPolicy.GlobalAdminOnly, TenantScoping.RouteParam), // Field schema for the write surface above: tenant-independent reflection over the model diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Deletion/DeletionManifestBuilder.cs b/src/Backend/AutopilotMonitor.Functions/Services/Deletion/DeletionManifestBuilder.cs index edf274f3..8c6de92b 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Deletion/DeletionManifestBuilder.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Deletion/DeletionManifestBuilder.cs @@ -323,13 +323,20 @@ namespace AutopilotMonitor.Functions.Services.Deletion { var rows = new List(2); // §5 PR4 mandates SessionsIndex deleted FIRST so UI listings drop the session before the canonical row goes. + // The FINAL step mixes two tables while DeletionStep.Table stays null, so each row + // carries its source table explicitly (DeletionTombstoneTables.Resolve falls back to + // the RowKey-shape heuristic only for manifests written before the field existed). if (sessionsIndexRow != null) { - rows.Add(TableEntityDumpConverter.MapEntityToDump(sessionsIndexRow)); + var dump = TableEntityDumpConverter.MapEntityToDump(sessionsIndexRow); + dump.Table = Constants.TableNames.SessionsIndex; + rows.Add(dump); } if (sessionRow != null) { - rows.Add(TableEntityDumpConverter.MapEntityToDump(sessionRow)); + var dump = TableEntityDumpConverter.MapEntityToDump(sessionRow); + dump.Table = Constants.TableNames.Sessions; + rows.Add(dump); } manifest.Steps.Add(new DeletionStep { diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Deletion/SessionDeletionHandler.cs b/src/Backend/AutopilotMonitor.Functions/Services/Deletion/SessionDeletionHandler.cs index 9563783b..3f2262cb 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Deletion/SessionDeletionHandler.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Deletion/SessionDeletionHandler.cs @@ -544,28 +544,17 @@ namespace AutopilotMonitor.Functions.Services.Deletion ct); // The builder (DeletionManifestBuilder.AddTombstoneStep) emits SessionsIndex first - // (if present) then Sessions. Determine table by row.Rk shape: anything matching the - // session id is the Sessions row; everything else is SessionsIndex. This works even - // if the manifest carries only one of the two rows (corruption-recovery scenario). + // (if present) then Sessions, each row carrying its source table (legacy manifests + // without the field resolve via the RowKey-shape heuristic). Works even if the + // manifest carries only one of the two rows (corruption-recovery scenario). foreach (var row in tombstone.Rows) { ct.ThrowIfCancellationRequested(); - var table = IsSessionsRow(row) ? Constants.TableNames.Sessions : Constants.TableNames.SessionsIndex; + var table = DeletionTombstoneTables.Resolve(row); await _storage.DeleteByExactKeysInBatchesAsync(table, new[] { (row.Pk, row.Rk) }, ct); } } - private static bool IsSessionsRow(DeletionRowDump row) - { - // Sessions row: PK = {tenantId}, RK = {sessionId}. - // SessionsIndex row: PK = {tenantId}, RK = {indexRowKey} (typically inverted-timestamp + sessionId). - // The discriminator is whether the RowKey *equals* a sessionId-shaped string. We use a - // simple heuristic: if the RowKey contains a "_" the row is the SessionsIndex (its - // RK is always composed); otherwise it's the Sessions row. This matches the builder's - // current schema (Sessions RK is the bare session GUID, IndexRowKey is "inverted_…_GUID"). - return row.Rk != null && !row.Rk.Contains('_'); - } - // ============================================================ Progress CAS retry ==== /// diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Deletion/SessionRestoreService.cs b/src/Backend/AutopilotMonitor.Functions/Services/Deletion/SessionRestoreService.cs index 02ca6c98..0f707fad 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Deletion/SessionRestoreService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Deletion/SessionRestoreService.cs @@ -233,7 +233,7 @@ namespace AutopilotMonitor.Functions.Services.Deletion { foreach (var row in tombstone.Rows) { - var t = IsSessionsRow(row) ? Constants.TableNames.Sessions : Constants.TableNames.SessionsIndex; + var t = DeletionTombstoneTables.Resolve(row); if (!result.WouldRestoreByTable.TryGetValue(t, out var c)) c = 0; result.WouldRestoreByTable[t] = c + 1; } @@ -346,7 +346,7 @@ namespace AutopilotMonitor.Functions.Services.Deletion foreach (var row in tombstone.Rows) { ct.ThrowIfCancellationRequested(); - var table = IsSessionsRow(row) ? Constants.TableNames.Sessions : Constants.TableNames.SessionsIndex; + var table = DeletionTombstoneTables.Resolve(row); var res = await _storage.RestoreRowsByExactKeysInBatchesAsync( table, new[] { row }, RestoreMode.Full, ct).ConfigureAwait(false); AddCount(result.RowsRestoredByTable, table, res.Restored); @@ -439,7 +439,7 @@ namespace AutopilotMonitor.Functions.Services.Deletion foreach (var row in tombstone.Rows) { ct.ThrowIfCancellationRequested(); - var table = IsSessionsRow(row) ? Constants.TableNames.Sessions : Constants.TableNames.SessionsIndex; + var table = DeletionTombstoneTables.Resolve(row); var res = await _storage.RestoreRowsByExactKeysInBatchesAsync( table, new[] { row }, RestoreMode.Partial, ct).ConfigureAwait(false); AddCount(result.RowsRestoredByTable, table, res.Restored); @@ -579,8 +579,6 @@ namespace AutopilotMonitor.Functions.Services.Deletion // ============================================================ Helpers ==== - private static bool IsSessionsRow(DeletionRowDump row) - => row.Rk != null && !row.Rk.Contains('_'); private static void AddCount(Dictionary map, string key, int delta) { diff --git a/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Notifications.cs b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Notifications.cs index b0c31b82..085bd9bb 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Notifications.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Notifications.cs @@ -137,7 +137,7 @@ namespace AutopilotMonitor.Functions.Services updatedSession.IsPreProvisioned } : null; - var summaryMessage = new SignalRMessageAction("newevents") + var summaryMessage = new SignalRMessageAction(Constants.SignalRMessages.NewEvents) { GroupName = $"tenant-{request.TenantId}", Arguments = new object[] { new { @@ -164,7 +164,7 @@ namespace AutopilotMonitor.Functions.Services }).ToList() : null; - var eventsMessage = new SignalRMessageAction("eventStream") + var eventsMessage = new SignalRMessageAction(Constants.SignalRMessages.EventStream) { GroupName = $"session-{request.TenantId}-{request.SessionId}", Arguments = new object[] { new { diff --git a/src/Backend/AutopilotMonitor.Functions/Services/SignalRNotificationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/SignalRNotificationService.cs index f884f031..817a1fde 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/SignalRNotificationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/SignalRNotificationService.cs @@ -2,6 +2,7 @@ using System; using System.Threading; using System.Threading.Tasks; using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Shared; using Microsoft.Azure.SignalR.Management; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; @@ -20,7 +21,7 @@ namespace AutopilotMonitor.Functions.Services private ServiceHubContext? _hubContext; private static readonly SemaphoreSlim _initLock = new(1, 1); - private const string HubName = "autopilotmonitor"; + private const string HubName = SignalRGroupHelper.HubName; /// /// Hard ceiling for every imperative send (hub-context resolution + SendCoreAsync). @@ -64,7 +65,7 @@ namespace AutopilotMonitor.Functions.Services var hub = await GetHubContextAsync(cts.Token); var groupName = $"session-{tenantId}-{sessionId}"; - await hub.Clients.Group(groupName).SendCoreAsync("ruleResultsReady", new object[] + await hub.Clients.Group(groupName).SendCoreAsync(Constants.SignalRMessages.RuleResultsReady, new object[] { new { @@ -94,7 +95,7 @@ namespace AutopilotMonitor.Functions.Services var hub = await GetHubContextAsync(cts.Token); var groupName = $"session-{tenantId}-{sessionId}"; - await hub.Clients.Group(groupName).SendCoreAsync("vulnerabilityReportReady", new object[] + await hub.Clients.Group(groupName).SendCoreAsync(Constants.SignalRMessages.VulnerabilityReportReady, new object[] { new { @@ -127,7 +128,7 @@ namespace AutopilotMonitor.Functions.Services var hub = await GetHubContextAsync(cts.Token); var groupName = $"session-{tenantId}-{sessionId}"; - await hub.Clients.Group(groupName).SendCoreAsync("sessionDeleted", new object[] + await hub.Clients.Group(groupName).SendCoreAsync(Constants.SignalRMessages.SessionDeleted, new object[] { new { @@ -160,7 +161,7 @@ namespace AutopilotMonitor.Functions.Services ? SignalRGroupHelper.TenantNotifyAdminGroup(tenantId) : SignalRGroupHelper.TenantNotifyMemberGroup(tenantId); - await hub.Clients.Group(groupName).SendCoreAsync("tenantNotification", new[] { dto }, cts.Token); + await hub.Clients.Group(groupName).SendCoreAsync(Constants.SignalRMessages.TenantNotification, new[] { dto }, cts.Token); _logger.LogDebug("Sent tenantNotification to group {Group}", groupName); } catch (Exception ex) @@ -185,8 +186,8 @@ namespace AutopilotMonitor.Functions.Services var payload = new { id = notificationId }; await Task.WhenAll( - hub.Clients.Group(memberGroup).SendCoreAsync("tenantNotificationDismissed", new object[] { payload }, cts.Token), - hub.Clients.Group(adminGroup).SendCoreAsync("tenantNotificationDismissed", new object[] { payload }, cts.Token) + hub.Clients.Group(memberGroup).SendCoreAsync(Constants.SignalRMessages.TenantNotificationDismissed, new object[] { payload }, cts.Token), + hub.Clients.Group(adminGroup).SendCoreAsync(Constants.SignalRMessages.TenantNotificationDismissed, new object[] { payload }, cts.Token) ); _logger.LogDebug("Sent tenantNotificationDismissed for tenant {TenantId} id {Id}", tenantId, notificationId); } @@ -209,8 +210,8 @@ namespace AutopilotMonitor.Functions.Services var adminGroup = SignalRGroupHelper.TenantNotifyAdminGroup(tenantId); await Task.WhenAll( - hub.Clients.Group(memberGroup).SendCoreAsync("tenantNotificationsDismissedAll", Array.Empty(), cts.Token), - hub.Clients.Group(adminGroup).SendCoreAsync("tenantNotificationsDismissedAll", Array.Empty(), cts.Token) + hub.Clients.Group(memberGroup).SendCoreAsync(Constants.SignalRMessages.TenantNotificationsDismissedAll, Array.Empty(), cts.Token), + hub.Clients.Group(adminGroup).SendCoreAsync(Constants.SignalRMessages.TenantNotificationsDismissedAll, Array.Empty(), cts.Token) ); _logger.LogDebug("Sent tenantNotificationsDismissedAll for tenant {TenantId}", tenantId); } @@ -229,7 +230,7 @@ namespace AutopilotMonitor.Functions.Services { using var cts = new CancellationTokenSource(SendTimeout); var hub = await GetHubContextAsync(cts.Token); - await hub.Clients.Group(SignalRGroupHelper.GlobalAdminsGroup).SendCoreAsync("globalNotification", new[] { dto }, cts.Token); + await hub.Clients.Group(SignalRGroupHelper.GlobalAdminsGroup).SendCoreAsync(Constants.SignalRMessages.GlobalNotification, new[] { dto }, cts.Token); _logger.LogDebug("Sent globalNotification to group {Group}", SignalRGroupHelper.GlobalAdminsGroup); } catch (Exception ex) @@ -245,7 +246,7 @@ namespace AutopilotMonitor.Functions.Services using var cts = new CancellationTokenSource(SendTimeout); var hub = await GetHubContextAsync(cts.Token); var payload = new { id = notificationId }; - await hub.Clients.Group(SignalRGroupHelper.GlobalAdminsGroup).SendCoreAsync("globalNotificationDismissed", new object[] { payload }, cts.Token); + await hub.Clients.Group(SignalRGroupHelper.GlobalAdminsGroup).SendCoreAsync(Constants.SignalRMessages.GlobalNotificationDismissed, new object[] { payload }, cts.Token); _logger.LogDebug("Sent globalNotificationDismissed for id {Id}", notificationId); } catch (Exception ex) @@ -260,7 +261,7 @@ namespace AutopilotMonitor.Functions.Services { using var cts = new CancellationTokenSource(SendTimeout); var hub = await GetHubContextAsync(cts.Token); - await hub.Clients.Group(SignalRGroupHelper.GlobalAdminsGroup).SendCoreAsync("globalNotificationsDismissedAll", Array.Empty(), cts.Token); + await hub.Clients.Group(SignalRGroupHelper.GlobalAdminsGroup).SendCoreAsync(Constants.SignalRMessages.GlobalNotificationsDismissedAll, Array.Empty(), cts.Token); _logger.LogDebug("Sent globalNotificationsDismissedAll"); } catch (Exception ex) @@ -312,7 +313,7 @@ namespace AutopilotMonitor.Functions.Services using var cts = new CancellationTokenSource(SendTimeout); var hub = await GetHubContextAsync(cts.Token); await hub.UserGroups.RemoveFromAllGroupsAsync(userId, cts.Token); - await hub.Clients.User(userId).SendCoreAsync("accessRevoked", new object[] + await hub.Clients.User(userId).SendCoreAsync(Constants.SignalRMessages.AccessRevoked, new object[] { new { timestamp = DateTime.UtcNow.ToString("o") } }, cts.Token); diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs index d8b519be..68ca930f 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs @@ -43,7 +43,9 @@ namespace AutopilotMonitor.Functions.Services startedAt = eventList.Min(e => e.Timestamp); } - var rowKey = $"{(DateTime.MaxValue.Ticks - startedAt.Ticks):D19}_{sessionId}"; + // Same shape as the SessionsIndex key so per-event-type listings share the + // newest-first ordering (partial-class sibling in TableStorageService.Sessions.cs). + var rowKey = ComputeIndexRowKey(startedAt, sessionId); var groups = eventList.GroupBy(e => e.EventType); diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Maintenance.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Maintenance.cs index 8a192ada..5217deaf 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Maintenance.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Maintenance.cs @@ -31,10 +31,7 @@ namespace AutopilotMonitor.Functions.Services /// of the ordering invariants. /// internal static string BuildAuditLogRowKey(DateTime timestampUtc, Guid collisionSuffix) - { - var revTick = DateTime.MaxValue.Ticks - timestampUtc.Ticks; - return $"{AuditLogRowKeyPrefix}{revTick:D19}_{collisionSuffix:N}"; - } + => $"{AuditLogRowKeyPrefix}{RowKeyCodec.InvertedTicks(timestampUtc)}_{collisionSuffix:N}"; /// /// Logs an audit entry. RowKey uses a fixed prefix + reverse-tick (newest-first diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Metrics.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Metrics.cs index 317a4b5b..d2517be4 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Metrics.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Metrics.cs @@ -949,7 +949,7 @@ namespace AutopilotMonitor.Functions.Services { var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.UserActivity); var now = DateTime.UtcNow; - var invertedTicks = (DateTime.MaxValue.Ticks - now.Ticks).ToString("D20"); + var invertedTicks = RowKeyCodec.InvertedTicksD20(now); var entity = new TableEntity(tenantId, $"{invertedTicks}_{Guid.NewGuid():N}") { diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs index 9a5915fb..56f4395f 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs @@ -22,7 +22,7 @@ namespace AutopilotMonitor.Functions.Services /// Format: "{invertedTicks:D19}_{sessionId}" to guarantee uniqueness. /// private static string ComputeIndexRowKey(DateTime startedAt, string sessionId) - => $"{(DateTime.MaxValue.Ticks - startedAt.Ticks):D19}_{sessionId}"; + => $"{RowKeyCodec.InvertedTicks(startedAt)}_{sessionId}"; /// /// Computes the SessionsIndex RowKey upper bound (D19 inverted-tick prefix) for a @@ -32,7 +32,7 @@ namespace AutopilotMonitor.Functions.Services internal static string ComputeCutoffRowKeyPrefix(int days) { var cutoffDate = DateTime.UtcNow.AddDays(-days); - return $"{(DateTime.MaxValue.Ticks - cutoffDate.Ticks):D19}"; + return RowKeyCodec.InvertedTicks(cutoffDate); } /// diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs index 0e5df924..52fe06cf 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -834,6 +834,36 @@ namespace AutopilotMonitor.Shared public const string DelegatedAdmin = "DelegatedAdmin"; } + // ----------------------------------------------------------------------- + // SignalR message names + // ----------------------------------------------------------------------- + + /// + /// The complete catalog of SignalR message (target) names sent by the backend and + /// subscribed by the web portal. Exported into shared-manifests.json by + /// SharedManifestParityTests; the web's SignalRContext types its on/off event names + /// against the generated manifest, so adding/renaming a name here without regenerating + /// the manifest fails xUnit, and a web subscription to an unknown name fails tsc. + /// NOTE: "newevents" predates the camelCase convention the other names follow — it is + /// a persisted wire name, do not "fix" the casing. + /// + public static class SignalRMessages + { + public const string NewSession = "newSession"; + public const string NewEvents = "newevents"; + public const string EventStream = "eventStream"; + public const string RuleResultsReady = "ruleResultsReady"; + public const string VulnerabilityReportReady = "vulnerabilityReportReady"; + public const string SessionDeleted = "sessionDeleted"; + public const string TenantNotification = "tenantNotification"; + public const string TenantNotificationDismissed = "tenantNotificationDismissed"; + public const string TenantNotificationsDismissedAll = "tenantNotificationsDismissedAll"; + public const string GlobalNotification = "globalNotification"; + public const string GlobalNotificationDismissed = "globalNotificationDismissed"; + public const string GlobalNotificationsDismissedAll = "globalNotificationsDismissedAll"; + public const string AccessRevoked = "accessRevoked"; + } + /// /// Lifecycle status of a DelegatedAdmins assignment row. Only rows confer scope; /// (customer-delegation awaiting tenant-admin approval) and diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Deletion/DeletionManifest.cs b/src/Shared/AutopilotMonitor.Shared/Models/Deletion/DeletionManifest.cs index 10b2cbd5..5d2bcf40 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Deletion/DeletionManifest.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Deletion/DeletionManifest.cs @@ -127,9 +127,38 @@ namespace AutopilotMonitor.Shared.Models.Deletion /// ETag as observed at preflight; informational only (not used for restore). public string? Etag { get; set; } + /// + /// Source table of this row. Set only on FINAL (Tombstone) rows — that step mixes rows + /// from two tables while is null. Null on rows of + /// table-targeted steps (their step carries the table) and on manifests written before + /// the field existed; resolve via . + /// + public string? Table { get; set; } + public Dictionary Props { get; set; } = new Dictionary(); } + /// + /// Resolves which table a FINAL (Tombstone) row belongs to. Rows written since 2026-08 carry + /// explicitly; older manifests (alive for up to the + /// manifest retention window) fall back to the historical RowKey-shape heuristic: the + /// Sessions RK is the bare session GUID while the SessionsIndex RK is composed + /// ("{invertedTicks}_{sessionId}"), so a '_' marks the index row. The fallback must survive + /// at least one full manifest-retention cycle after the field's introduction. + /// + public static class DeletionTombstoneTables + { + public static string Resolve(DeletionRowDump row) + { + if (!string.IsNullOrEmpty(row.Table)) + return row.Table!; + // IndexOf (not Contains(char)): Shared targets netstandard2.0 for the net48 agent. + return row.Rk != null && row.Rk.IndexOf('_') < 0 + ? Constants.TableNames.Sessions + : Constants.TableNames.SessionsIndex; + } + } + /// /// Single Azure Table column value with its EDM type tag. Restore looks at /// to decide which Get* / strongly-typed entity setter to use; without the tag, a string diff --git a/src/Web/autopilot-monitor-web/app/annotations/page.tsx b/src/Web/autopilot-monitor-web/app/annotations/page.tsx index d1869029..24293a6e 100644 --- a/src/Web/autopilot-monitor-web/app/annotations/page.tsx +++ b/src/Web/autopilot-monitor-web/app/annotations/page.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useState } from "react"; import Link from "next/link"; import { sessionUrl } from "@/lib/routes"; -import { api } from "@/lib/api"; +import { scopedApi } from "@/lib/scopedApi"; import { API_BASE_URL } from "@/utils/config"; import { authenticatedFetch } from "@/lib/authenticatedFetch"; import { ProtectedRoute } from "@/components/ProtectedRoute"; @@ -63,7 +63,7 @@ export default function AnnotationsPage() { // GA/Reader: "All tenants" aggregate by default (mirrors the MCP evaluation stream) with // per-tenant drill-down; delegated ("MSP"): managed tenants only, never aggregated. const scope = useAggregatedAdminScope({ defaultAggregated: true }); - const { isGlobalAdmin: crossTenant, routeGlobal, selectedTenantId, scopeInitialized, tenants } = scope; + const { isGlobalAdmin: crossTenant, routeGlobal, selectedTenantId, effectiveTenantId, scopeInitialized, tenants } = scope; const lanes = visibleLanes(user); @@ -108,14 +108,12 @@ export default function AnnotationsPage() { lane: laneFilter || undefined, }; await fetchPage( - routeGlobal - ? api.annotations.globalList({ ...filters, tenantId: selectedTenantId || undefined }) - : api.annotations.list(filters), + scopedApi.annotationsList({ routeGlobal, selectedTenantId, effectiveTenantId }, filters), false ); }; void loadFirstPage(); - }, [fetchPage, verdictFilter, laneFilter, scopeInitialized, routeGlobal, selectedTenantId]); + }, [fetchPage, verdictFilter, laneFilter, scopeInitialized, routeGlobal, selectedTenantId, effectiveTenantId]); return ( diff --git a/src/Web/autopilot-monitor-web/app/apps/components/InstallsTab.tsx b/src/Web/autopilot-monitor-web/app/apps/components/InstallsTab.tsx index a6d0d9b6..482b7cd4 100644 --- a/src/Web/autopilot-monitor-web/app/apps/components/InstallsTab.tsx +++ b/src/Web/autopilot-monitor-web/app/apps/components/InstallsTab.tsx @@ -6,7 +6,7 @@ import { useRouter } from "next/navigation"; import { useTenant } from "../../../contexts/TenantContext"; import { useAuth } from "../../../contexts/AuthContext"; import { useNotifications } from "../../../contexts/NotificationContext"; -import { api } from "@/lib/api"; +import { scopedApi } from "@/lib/scopedApi"; import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; import { formatBytes, formatDuration } from "@/lib/formatting"; import DoBreakdownBar from "@/components/DoBreakdownBar"; @@ -82,7 +82,7 @@ export default function InstallsTab({ scope, timeRange }: InstallsTabProps) { const { tenantId } = useTenant(); const { getAccessToken } = useAuth(); const { addNotification } = useNotifications(); - const { isGlobalAdmin, routeGlobal, selectedTenantId, scopeInitialized, scopeKey } = scope; + const { isGlobalAdmin, scopeInitialized, scopeKey } = scope; const [data, setData] = useState(null); const [doRollup, setDoRollup] = useState(null); @@ -106,12 +106,8 @@ export default function InstallsTab({ scope, timeRange }: InstallsTabProps) { try { setLoading(true); progressBegin(); - const listUrl = routeGlobal - ? api.apps.globalList(days, selectedTenantId || undefined) - : api.apps.list(tenantId, days); - const metricsUrl = routeGlobal - ? api.metrics.globalApp(days, selectedTenantId || undefined) - : api.metrics.app(tenantId, days); + const listUrl = scopedApi.appsList(scope, days); + const metricsUrl = scopedApi.appMetrics(scope, days); const [listRes, metricsRes] = await Promise.all([ authenticatedFetch(listUrl, getAccessToken, { signal: AbortSignal.timeout(APPS_FETCH_TIMEOUT_MS) }), diff --git a/src/Web/autopilot-monitor-web/app/apps/components/VulnerabilitiesTab.tsx b/src/Web/autopilot-monitor-web/app/apps/components/VulnerabilitiesTab.tsx index 08a58410..fb7ad323 100644 --- a/src/Web/autopilot-monitor-web/app/apps/components/VulnerabilitiesTab.tsx +++ b/src/Web/autopilot-monitor-web/app/apps/components/VulnerabilitiesTab.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react"; import { useAuth } from "../../../contexts/AuthContext"; import { useNotifications } from "../../../contexts/NotificationContext"; -import { api } from "@/lib/api"; +import { scopedApi } from "@/lib/scopedApi"; import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; import VulnerabilityExposurePanel, { type VulnerabilitySummary } from "@/components/VulnerabilityExposurePanel"; import type { SoftwareTabScope, TimeRange } from "./types"; @@ -14,7 +14,7 @@ const TOP_N = 20; export default function VulnerabilitiesTab({ scope, timeRange }: { scope: SoftwareTabScope; timeRange: TimeRange }) { const { getAccessToken } = useAuth(); const { addNotification } = useNotifications(); - const { isGlobalAdmin, routeGlobal, selectedTenantId, scopeInitialized, scopeKey } = scope; + const { isGlobalAdmin, selectedTenantId, scopeInitialized, scopeKey } = scope; const [summary, setSummary] = useState(null); const [loading, setLoading] = useState(true); @@ -30,9 +30,7 @@ export default function VulnerabilitiesTab({ scope, timeRange }: { scope: Softwa const run = async () => { try { setLoading(true); - const url = routeGlobal - ? api.metrics.globalVulnerability(days, TOP_N, selectedTenantId || undefined) - : api.metrics.vulnerability(days, TOP_N); + const url = scopedApi.vulnerability(scope, days, TOP_N); const res = await authenticatedFetch(url, getAccessToken); if (cancelled) return; if (res.ok) { diff --git a/src/Web/autopilot-monitor-web/app/apps/detail/page.tsx b/src/Web/autopilot-monitor-web/app/apps/detail/page.tsx index 69c80598..d10cbba7 100644 --- a/src/Web/autopilot-monitor-web/app/apps/detail/page.tsx +++ b/src/Web/autopilot-monitor-web/app/apps/detail/page.tsx @@ -8,7 +8,7 @@ import { ProtectedRoute } from "../../../components/ProtectedRoute"; import { useTenant } from "../../../contexts/TenantContext"; import { useAuth } from "../../../contexts/AuthContext"; import { useNotifications } from "../../../contexts/NotificationContext"; -import { api } from "@/lib/api"; +import { scopedApi } from "@/lib/scopedApi"; import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; import { getErrorCodeEntry, formatErrorCode } from "@/utils/errorCodeMap"; import { useAggregatedAdminScope } from "@/hooks"; @@ -231,11 +231,12 @@ function AppDetailContent() { // Global admin tenant scope (aggregated-capable), seeded from the persisted tab scope or a ?tenantId= // deep-link; falls back to the GA's own tenant. const scope = useAggregatedAdminScope({ urlTenantId }); - const { isGlobalAdmin, selectedTenantId, tenants, scopeInitialized, scopeKey } = scope; + const { isGlobalAdmin, tenants, scopeInitialized, scopeKey } = scope; - // Detail-page endpoint rule: use the /global/ endpoint when viewing a tenant other than the user's own - // (including the aggregated "" view), or when the own tenant isn't resolved yet. - const useGlobalEndpoint = Boolean(isGlobalAdmin && (selectedTenantId !== tenantId || !tenantId)); + // Endpoint routing comes from the scope hook's routeGlobal (via scopedApi) — the former + // local formula was equivalent except that a GA viewing their OWN tenant used the member + // variant; routeGlobal sends them to the /global/ variant with an explicit tenantId, which + // returns the same data under the same authorization. // Build a tid → friendly name lookup once for the Tenant column. const tenantLookup = useMemo(() => { @@ -315,9 +316,7 @@ function AppDetailContent() { try { setLoading(true); progressBegin(); - const url = useGlobalEndpoint - ? api.apps.globalAnalytics(appName, days, selectedTenantId || undefined) - : api.apps.analytics(tenantId, appName, days); + const url = scopedApi.appAnalytics(scope, appName, days); const response = await authenticatedFetch(url, getAccessToken, { signal: AbortSignal.timeout(APPS_FETCH_TIMEOUT_MS), }); @@ -355,9 +354,7 @@ function AppDetailContent() { if (!isGlobalAdmin && !tenantId) return; try { setSessionsLoading(true); - const url = useGlobalEndpoint - ? api.apps.globalSessions(appName, days, status, offset, SESSIONS_PAGE_SIZE, selectedTenantId || undefined) - : api.apps.sessions(tenantId, appName, days, status, offset, SESSIONS_PAGE_SIZE); + const url = scopedApi.appSessions(scope, appName, days, status, offset, SESSIONS_PAGE_SIZE); const response = await authenticatedFetch(url, getAccessToken, { signal: AbortSignal.timeout(APPS_FETCH_TIMEOUT_MS), }); diff --git a/src/Web/autopilot-monitor-web/app/audit/page.tsx b/src/Web/autopilot-monitor-web/app/audit/page.tsx index f6c674f1..d668ea34 100644 --- a/src/Web/autopilot-monitor-web/app/audit/page.tsx +++ b/src/Web/autopilot-monitor-web/app/audit/page.tsx @@ -5,7 +5,7 @@ import { TableSkeleton } from '@/components/skeletons/TableSkeleton'; import { useAuth } from '../../contexts/AuthContext'; import { useNotifications } from '../../contexts/NotificationContext'; import { ProtectedRoute } from '../../components/ProtectedRoute'; -import { api } from '@/lib/api'; +import { scopedApi } from "@/lib/scopedApi"; import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; import { extractContinuation } from "@/lib/paginationLink"; import { useAggregatedAdminScope } from "@/hooks"; @@ -89,7 +89,7 @@ export default function AuditPage() { // GA/Reader starts in the aggregated "All tenants" view; a delegated ("MSP") admin has no aggregate and // still defaults to its first managed tenant via the normal tenant switcher. const scope = useAggregatedAdminScope({ defaultAggregated: true }); - const { isGlobalAdmin: crossTenant, routeGlobal, selectedTenantId, scopeInitialized, scopeKey } = scope; + const { isGlobalAdmin: crossTenant, routeGlobal, selectedTenantId, effectiveTenantId, scopeInitialized, scopeKey } = scope; // The DEFAULT view ("All (excl. deletions)") is resolved server-side: the // backend drops per-session deletion bookkeeping and back-fills the page, so @@ -114,9 +114,7 @@ export default function AuditPage() { // Cross-tenant: globalLogs with the selected tenant ("" → GA aggregate over all tenants; a managed // tenant for a delegated caller, validated server-side). Own-tenant member — including a delegated // caller viewing their HOME tenant (routeGlobal false): the tenant-scoped logs. - const endpoint = routeGlobal - ? api.audit.globalLogs({ ...opts, tenantId: selectedTenantId || undefined }) - : api.audit.logs(opts); + const endpoint = scopedApi.auditLogs({ routeGlobal, selectedTenantId, effectiveTenantId }, opts); const response = await authenticatedFetch(endpoint, getAccessToken); if (!response.ok) { addNotification('error', 'Backend Error', `Failed to load audit logs: ${response.statusText}`, 'audit-fetch-error'); @@ -140,7 +138,7 @@ export default function AuditPage() { setLoading(false); setRefreshing(false); } - }, [addNotification, dateFromIso, dateToIso, getAccessToken, routeGlobal, selectedTenantId, excludeDeletions]); + }, [addNotification, dateFromIso, dateToIso, getAccessToken, routeGlobal, selectedTenantId, effectiveTenantId, excludeDeletions]); // Initial / window-change fetch resets pagination state. // fetchPage is intentionally excluded from deps: its identity churns whenever diff --git a/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDashboardSessions.ts b/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDashboardSessions.ts index c864dc59..7c131948 100644 --- a/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDashboardSessions.ts +++ b/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDashboardSessions.ts @@ -12,6 +12,7 @@ import { isHomeTenantTarget } from "@/utils/homeTenantScope"; import { hasTenantReadScope } from "@/lib/tenantScope"; import type { NotificationType } from "@/contexts/NotificationContext"; import type { Session } from "../types"; +import type { SignalRMessageName } from "@/lib/signalrMessages"; const DEFAULT_PAGE_SIZE = 10; const MAX_PAGE_SIZE = 1000; @@ -43,9 +44,9 @@ interface User { interface SignalRApi { // eslint-disable-next-line @typescript-eslint/no-explicit-any - on: (event: string, handler: (...args: any[]) => void) => void; + on: (event: SignalRMessageName, handler: (...args: any[]) => void) => void; // eslint-disable-next-line @typescript-eslint/no-explicit-any - off: (event: string, handler: (...args: any[]) => void) => void; + off: (event: SignalRMessageName, handler: (...args: any[]) => void) => void; isConnected: boolean; joinGroup: (group: string) => Promise; leaveGroup: (group: string) => Promise; diff --git a/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDashboardStats.ts b/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDashboardStats.ts index f7c30b0d..9a5cbc49 100644 --- a/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDashboardStats.ts +++ b/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDashboardStats.ts @@ -8,6 +8,7 @@ import { asGuidOrUndefined } from "@/utils/inputValidation"; import { boundTenantToDelegatedScope } from "@/utils/delegatedScope"; import { isHomeTenantTarget } from "@/utils/homeTenantScope"; import type { NotificationType } from "@/contexts/NotificationContext"; +import type { SignalRMessageName } from "@/lib/signalrMessages"; export interface DashboardStats { days: number; @@ -32,9 +33,9 @@ type AddNotification = ( interface SignalRApi { // eslint-disable-next-line @typescript-eslint/no-explicit-any - on: (event: string, handler: (...args: any[]) => void) => void; + on: (event: SignalRMessageName, handler: (...args: any[]) => void) => void; // eslint-disable-next-line @typescript-eslint/no-explicit-any - off: (event: string, handler: (...args: any[]) => void) => void; + off: (event: SignalRMessageName, handler: (...args: any[]) => void) => void; isConnected: boolean; } diff --git a/src/Web/autopilot-monitor-web/app/diagnosis/page.tsx b/src/Web/autopilot-monitor-web/app/diagnosis/page.tsx index c79e4e77..1ee7186f 100644 --- a/src/Web/autopilot-monitor-web/app/diagnosis/page.tsx +++ b/src/Web/autopilot-monitor-web/app/diagnosis/page.tsx @@ -217,13 +217,14 @@ function DiagnosisContent() { // Real-time analysis updates useEffect(() => { + // eventStream carries { sessionId, tenantId, newEventCount, newRuleResults } — the full + // session object was removed from the payload long ago (see useSessionSignalR), so only + // the rule-result delta is consumed here. const handleEventStream = (data: { sessionId: string; - session: Session; newRuleResults?: RuleResult[]; }) => { if (data.sessionId !== sessionIdRef.current) return; - if (data.session) setSession(data.session); if (data.newRuleResults?.length) { setAnalysisResults((prev) => { const existingIds = new Set(prev.map((r) => r.ruleId)); diff --git a/src/Web/autopilot-monitor-web/app/fleet-health/hooks/useFleetHealth.ts b/src/Web/autopilot-monitor-web/app/fleet-health/hooks/useFleetHealth.ts index f18f4694..ac8ed858 100644 --- a/src/Web/autopilot-monitor-web/app/fleet-health/hooks/useFleetHealth.ts +++ b/src/Web/autopilot-monitor-web/app/fleet-health/hooks/useFleetHealth.ts @@ -1,11 +1,11 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; -import { api } from "@/lib/api"; +import { scopedApi } from "@/lib/scopedApi"; import { useLatest } from "@/hooks/useLatest"; import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; -import { asGuidOrUndefined } from "@/utils/inputValidation"; import type { NotificationType } from "@/contexts/NotificationContext"; +import type { SignalRMessageName } from "@/lib/signalrMessages"; // Wire shape of the server-aggregated Fleet Health payload (camelCase of the // backend FleetHealthMetrics DTO). Presentation-only derivations (bar maxima, @@ -70,9 +70,9 @@ type AddNotification = ( interface SignalRApi { // eslint-disable-next-line @typescript-eslint/no-explicit-any - on: (event: string, handler: (...args: any[]) => void) => void; + on: (event: SignalRMessageName, handler: (...args: any[]) => void) => void; // eslint-disable-next-line @typescript-eslint/no-explicit-any - off: (event: string, handler: (...args: any[]) => void) => void; + off: (event: SignalRMessageName, handler: (...args: any[]) => void) => void; isConnected: boolean; } @@ -147,12 +147,15 @@ export function useFleetHealth({ setError(null); try { - const url = routeGlobalRef.current - ? api.metrics.globalFleetHealth( - daysRef.current, - asGuidOrUndefined(selectedTenantIdRef.current.trim()), - ) - : api.metrics.fleetHealth(daysRef.current); + const url = scopedApi.fleetHealth( + { + routeGlobal: routeGlobalRef.current, + selectedTenantId: selectedTenantIdRef.current, + // Member path (routeGlobal false) needs no tenant on this endpoint (JWT-scoped). + effectiveTenantId: tenantIdRef.current ?? "", + }, + daysRef.current, + ); const response = await authenticatedFetch(url, getAccessToken); @@ -188,7 +191,7 @@ export function useFleetHealth({ } finally { if (myGen === fetchGenRef.current) setLoading(false); } - }, [getAccessToken, addNotification, routeGlobalRef, selectedTenantIdRef, daysRef]); + }, [getAccessToken, addNotification, routeGlobalRef, selectedTenantIdRef, tenantIdRef, daysRef]); const refresh = useCallback(() => { if (debounceTimerRef.current) { diff --git a/src/Web/autopilot-monitor-web/app/fleet-health/page.tsx b/src/Web/autopilot-monitor-web/app/fleet-health/page.tsx index fb7f2421..a1bf60d4 100644 --- a/src/Web/autopilot-monitor-web/app/fleet-health/page.tsx +++ b/src/Web/autopilot-monitor-web/app/fleet-health/page.tsx @@ -9,7 +9,8 @@ import { useSignalR } from "../../contexts/SignalRContext"; import { useTenant } from "../../contexts/TenantContext"; import { useAuth } from "../../contexts/AuthContext"; import { useNotifications } from "../../contexts/NotificationContext"; -import { api } from "@/lib/api"; +import { scopedApi } from "@/lib/scopedApi"; +import { fetchJson } from "@/lib/scopedFetch"; import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; import FleetStatCard from "./components/FleetStatCard"; import TimeAttributionSection, { TimeAttributionResponseDto } from "./components/TimeAttributionSection"; @@ -81,9 +82,7 @@ export default function FleetHealthPage() { const fetchAppMetrics = async (range: "7d" | "30d" | "90d" = timeRange) => { try { const d = range === "7d" ? 7 : range === "30d" ? 30 : 90; - const endpoint = routeGlobal - ? api.metrics.globalApp(d, selectedTenantId || undefined) - : api.metrics.app(tenantId, d); + const endpoint = scopedApi.appMetrics(scope, d); const response = await authenticatedFetch(endpoint, getAccessToken); if (response.ok) { const json = await response.json(); @@ -119,12 +118,7 @@ export default function FleetHealthPage() { let cancelled = false; (async () => { try { - const endpoint = routeGlobal - ? api.metrics.globalTimeAttribution(selectedTenantId || undefined) - : api.metrics.timeAttribution(); - const response = await authenticatedFetch(endpoint, getAccessToken); - if (!response.ok) return; - const json = await response.json(); + const json = await fetchJson(scopedApi.timeAttribution(scope), getAccessToken); if (!cancelled) setTimeAttribution(json); } catch { // fail-soft: section stays hidden @@ -141,12 +135,7 @@ export default function FleetHealthPage() { let cancelled = false; (async () => { try { - const endpoint = routeGlobal - ? api.metrics.globalDeviceJourneys(days, selectedTenantId || undefined) - : api.metrics.deviceJourneys(days); - const response = await authenticatedFetch(endpoint, getAccessToken); - if (!response.ok) return; - const json = await response.json(); + const json = await fetchJson(scopedApi.deviceJourneys(scope, days), getAccessToken); if (!cancelled) setDeviceJourneys(json); } catch { // fail-soft: section stays hidden diff --git a/src/Web/autopilot-monitor-web/app/geographic-performance/page.tsx b/src/Web/autopilot-monitor-web/app/geographic-performance/page.tsx index 96b4a7f3..5bbc2c43 100644 --- a/src/Web/autopilot-monitor-web/app/geographic-performance/page.tsx +++ b/src/Web/autopilot-monitor-web/app/geographic-performance/page.tsx @@ -5,9 +5,8 @@ import { useRouter } from "next/navigation"; import dynamic from "next/dynamic"; import { ProtectedRoute } from "../../components/ProtectedRoute"; import TruncatedLabel from "@/components/TruncatedLabel"; -import { useTenant } from "../../contexts/TenantContext"; import { useAuth } from "../../contexts/AuthContext"; -import { api } from "@/lib/api"; +import { scopedApi } from "@/lib/scopedApi"; import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; import { useAggregatedAdminScope } from "@/hooks"; import { useFetchProgress } from "@/hooks/useFetchProgress"; @@ -146,13 +145,12 @@ export default function GeographicPerformancePage() { const isTimeRangeMount = useRef(true); - const { tenantId } = useTenant(); const { getAccessToken } = useAuth(); // Global admin tenant scope (aggregated-capable): tenant list, selection ("" = all tenants), // and scope flags. Default selection is the GA's own tenant. const scope = useAggregatedAdminScope(); - const { routeGlobal, selectedTenantId, isAggregatedGlobalView, scopeInitialized, scopeKey } = scope; + const { routeGlobal, selectedTenantId, effectiveTenantId, isAggregatedGlobalView, scopeInitialized, scopeKey } = scope; const progress = useFetchProgress("geoPerf.lastFetchMs"); const { begin: progressBegin, finish: progressFinish } = progress; @@ -162,9 +160,7 @@ export default function GeographicPerformancePage() { try { progressBegin(); const days = range === "7d" ? 7 : range === "30d" ? 30 : 90; - const endpoint = routeGlobal - ? api.metrics.globalGeographic(days, group, selectedTenantId || undefined) - : api.metrics.geographic(tenantId, days, group); + const endpoint = scopedApi.geographic({ routeGlobal, selectedTenantId, effectiveTenantId }, days, group); const response = await authenticatedFetch(endpoint, getAccessToken, { signal: AbortSignal.timeout(GEO_FETCH_TIMEOUT_MS), }); @@ -183,7 +179,7 @@ export default function GeographicPerformancePage() { progressFinish(succeeded); setLoading(false); } - }, [routeGlobal, selectedTenantId, tenantId, getAccessToken, timeRange, groupBy, progressBegin, progressFinish]); + }, [routeGlobal, selectedTenantId, effectiveTenantId, getAccessToken, timeRange, groupBy, progressBegin, progressFinish]); useEffect(() => { if (!scopeInitialized) return; diff --git a/src/Web/autopilot-monitor-web/app/health-check/page.tsx b/src/Web/autopilot-monitor-web/app/health-check/page.tsx index 71cea7e1..6437abbe 100644 --- a/src/Web/autopilot-monitor-web/app/health-check/page.tsx +++ b/src/Web/autopilot-monitor-web/app/health-check/page.tsx @@ -41,6 +41,10 @@ export default function HealthCheckPage() { // dashboard on it. The card renders a "checking" state immediately and updates in place. const [mcpCheck, setMcpCheck] = useState(null); const [mcpLoading, setMcpLoading] = useState(false); + // Portal build stamp: /version.json is a static file from this same origin (generated at + // build time, Cache-Control: no-store) — plain fetch, no auth. Card stays hidden when the + // file is absent (e.g. a dev server without the prebuild step). + const [webBuild, setWebBuild] = useState<{ commit?: string; buildUtc?: string } | null>(null); const performHealthCheck = useCallback(async () => { setLoading(true); @@ -105,6 +109,16 @@ export default function HealthCheckPage() { } }, [getAccessToken]); + useEffect(() => { + if (!user?.isGlobalAdmin) return; + let cancelled = false; + fetch("/version.json", { cache: "no-store" }) + .then((r) => (r.ok ? r.json() : null)) + .then((d) => { if (!cancelled && d?.commit) setWebBuild(d); }) + .catch(() => { /* card simply stays hidden */ }); + return () => { cancelled = true; }; + }, [user?.isGlobalAdmin]); + // Auto-run health checks on page load. The two probes are fired independently so the // fast backend checks render immediately while the MCP probe (possible cold start) catches up. useEffect(() => { @@ -275,6 +289,44 @@ export default function HealthCheckPage() { )} + {/* Portal Build (Global Admin only) */} + {user?.isGlobalAdmin && webBuild && ( +
+

Portal Build

+
+
+
+
Component
+
web
+
+
+
Commit
+
+ {webBuild.commit && webBuild.commit !== "unknown" ? ( + + {webBuild.commit} + + ) : ( + unknown + )} +
+
+
+
Built
+
+ {webBuild.buildUtc ? new Date(webBuild.buildUtc).toLocaleString() : '—'} +
+
+
+
+
+ )} + {/* Real-Time Connection Status */}

Real-Time Connection

diff --git a/src/Web/autopilot-monitor-web/app/progress/hooks/useProgressSignalR.ts b/src/Web/autopilot-monitor-web/app/progress/hooks/useProgressSignalR.ts index 1fab182b..ff570e01 100644 --- a/src/Web/autopilot-monitor-web/app/progress/hooks/useProgressSignalR.ts +++ b/src/Web/autopilot-monitor-web/app/progress/hooks/useProgressSignalR.ts @@ -2,12 +2,13 @@ import { useEffect } from "react"; import { Session } from "@/types"; +import type { SignalRMessageName } from "@/lib/signalrMessages"; interface SignalRApi { // eslint-disable-next-line @typescript-eslint/no-explicit-any - on: (event: string, handler: (...args: any[]) => void) => void; + on: (event: SignalRMessageName, handler: (...args: any[]) => void) => void; // eslint-disable-next-line @typescript-eslint/no-explicit-any - off: (event: string, handler: (...args: any[]) => void) => void; + off: (event: SignalRMessageName, handler: (...args: any[]) => void) => void; isConnected: boolean; joinGroup: (group: string) => Promise; leaveGroup: (group: string) => Promise; diff --git a/src/Web/autopilot-monitor-web/app/sessions/hooks/useSessionSignalR.ts b/src/Web/autopilot-monitor-web/app/sessions/hooks/useSessionSignalR.ts index 0e2f6869..b95e1b98 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/hooks/useSessionSignalR.ts +++ b/src/Web/autopilot-monitor-web/app/sessions/hooks/useSessionSignalR.ts @@ -3,12 +3,13 @@ import { useEffect, useRef } from "react"; import { Session, RuleResult } from "@/types"; import { isTerminalStatus } from "@/utils/sessionStatus"; +import type { SignalRMessageName } from "@/lib/signalrMessages"; interface SignalRApi { // eslint-disable-next-line @typescript-eslint/no-explicit-any - on: (event: string, handler: (...args: any[]) => void) => void; + on: (event: SignalRMessageName, handler: (...args: any[]) => void) => void; // eslint-disable-next-line @typescript-eslint/no-explicit-any - off: (event: string, handler: (...args: any[]) => void) => void; + off: (event: SignalRMessageName, handler: (...args: any[]) => void) => void; isConnected: boolean; joinGroup: (group: string) => Promise; leaveGroup: (group: string) => Promise; diff --git a/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx b/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx index e611af18..84edc7a5 100644 --- a/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx @@ -28,6 +28,7 @@ const validationEnabledSuffix = (t: ValidationTrigger) => import { parseSasExpiry } from "./components/DiagnosticsSection"; import { COMMUNITY_DEFAULT, parseEditionInfo, type EditionInfo } from "@/lib/edition"; import { TenantConfiguration, TenantAdmin, DiagnosticsLogPath, NotificationChannel, LEGACY_CHANNEL_ID } from "./types"; +import { SECTION_FIELD_MAP, type SectionFieldSpec, type SettingsSectionName } from "./sectionFieldMap"; import { type BootstrapSessionItem } from "./components/BootstrapSessionsSection"; /** @@ -717,9 +718,9 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } // ----------------------------------------------------------------------- // Save configuration (shared by all sections) // ----------------------------------------------------------------------- - const saveConfiguration = useCallback(async (sectionName: string, overrides?: { validateAutopilotDevice?: boolean; validateCorporateIdentifier?: boolean; validateDeviceAssociation?: boolean; validateCloudPcDevice?: boolean; unrestrictedMode?: boolean }): Promise => { + const saveConfiguration = useCallback(async (sectionName: SettingsSectionName, overrides?: { validateAutopilotDevice?: boolean; validateCorporateIdentifier?: boolean; validateDeviceAssociation?: boolean; validateCloudPcDevice?: boolean; unrestrictedMode?: boolean }): Promise => { // Read-only viewers (Operators) have no save affordances; this guard covers any path - // that still reaches a save (the backend would 403 the PUT regardless). + // that still reaches a save (the backend would 403 the PATCH regardless). if (!tenantId || !config || !canEditConfig) return false; try { @@ -794,26 +795,49 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } unrestrictedMode: unrestrictedModeValue, }; - const response = await authenticatedFetch(api.config.tenant(tenantId), getAccessToken, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(updatedConfig), - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.message || errorData.error || `Failed to save configuration: ${response.statusText}`); + // Per-section PATCH: send ONLY this section's fields (plus documented write-throughs) + // that actually differ from the loaded config. The other ~90 fields never round-trip, + // so a stale read cannot revert unrelated fields (the 2026-07-31 incident class), and + // GA-only toggles a tenant admin cannot edit are simply never in the payload. + const spec: SectionFieldSpec = SECTION_FIELD_MAP[sectionName]; + const patchFields: Record = {}; + for (const field of [...spec.fields, ...(spec.alsoWrites ?? [])]) { + // undefined and null both mean "cleared" on the wire; PATCH expresses a clear as + // an explicit JSON null (an omitted key would leave the stored value untouched). + const next = (updatedConfig as unknown as Record)[field] ?? null; + const prev = (config as unknown as Record)[field] ?? null; + if (JSON.stringify(next) !== JSON.stringify(prev)) { + patchFields[field] = next; + } } - const result = await response.json(); - setConfig(result.config); - // Sync form state variables with the server response - setValidateAutopilotDevice(result.config.validateAutopilotDevice); - setValidateCorporateIdentifier(result.config.validateCorporateIdentifier ?? false); - setValidateDeviceAssociation(result.config.validateDeviceAssociation ?? false); - setValidateCloudPcDevice(result.config.validateCloudPcDevice ?? false); - setUnrestrictedMode(result.config.unrestrictedMode ?? false); - trackEvent("settings_saved", { section: sectionName }); + if (Object.keys(patchFields).length > 0) { + const response = await authenticatedFetch(api.config.fields(tenantId), getAccessToken, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ fields: patchFields, reason: `settings:${sectionName}` }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || errorData.error || `Failed to save configuration: ${response.statusText}`); + } + + // The PATCH response carries applied field names + masked diff, not the config — + // the backend verified exactly these fields changed, so merge them locally. + await response.json().catch(() => ({})); + setConfig({ ...config, ...patchFields } as TenantConfiguration); + } + + // Sync the gate-relevant form state with what is now persisted (merge of loaded + // config + this patch) — mirrors the old PUT-response sync. + const persisted = { ...config, ...patchFields } as TenantConfiguration; + setValidateAutopilotDevice(persisted.validateAutopilotDevice); + setValidateCorporateIdentifier(persisted.validateCorporateIdentifier ?? false); + setValidateDeviceAssociation(persisted.validateDeviceAssociation ?? false); + setValidateCloudPcDevice(persisted.validateCloudPcDevice ?? false); + setUnrestrictedMode(persisted.unrestrictedMode ?? false); + trackEvent("settings_saved", { section: sectionName, fieldCount: Object.keys(patchFields).length }); setSuccessMessage("Configuration saved successfully!"); setTimeout(() => setSuccessMessage(null), 3000); return true; @@ -1218,7 +1242,8 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } setKeepLogFile(config.keepLogFile ?? false); setRebootOnComplete(config.rebootOnComplete ?? false); setRebootDelaySeconds(config.rebootDelaySeconds ?? 10); - setContactEmail(config.contactEmail ?? ""); + // contactEmail deliberately NOT reset here — it is owned by the Contact section + // (resetting it from the Agent Settings card silently discarded unsaved Contact edits). setEnableGeoLocation(config.enableGeoLocation ?? true); setEnableTimezoneAutoSet(config.enableTimezoneAutoSet ?? false); setEnableImeMatchLog(config.enableImeMatchLog ?? false); diff --git a/src/Web/autopilot-monitor-web/app/settings/__tests__/sectionFieldMap.test.ts b/src/Web/autopilot-monitor-web/app/settings/__tests__/sectionFieldMap.test.ts new file mode 100644 index 00000000..da2ca5d3 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/settings/__tests__/sectionFieldMap.test.ts @@ -0,0 +1,125 @@ +/** + * Parity pins for the Settings per-section PATCH map (SECTION_FIELD_MAP). + * TenantConfigContext builds every save payload from this map, so the map IS the write + * surface: a field missing here silently stops being saved, a duplicated field lets two + * sections fight over one value. Both directions are pinned against test-local lists + * (never derived from the map itself — see tasks/lessons.md on circular parity fixtures). + */ +import { describe, expect, it } from "vitest"; +import { SHARED_MANIFEST } from "@/utils/shared-manifests.generated"; +import { SECTION_FIELD_MAP } from "../sectionFieldMap"; + +const sections = Object.entries(SECTION_FIELD_MAP) as Array< + [string, { fields: readonly string[]; alsoWrites?: readonly string[] }] +>; + +/** + * Test-local, independent list of every field the Settings UI edits (one owner section + * each). Update it together with SECTION_FIELD_MAP when a section gains/loses a field — + * that is the point: removing a field from the map without noticing fails here. + */ +const EXPECTED_OWNED_FIELDS = [ + // hardwareWhitelist + "manufacturerWhitelist", "modelWhitelist", "webhookNotifyOnHardwareRejection", + // autopilotValidation + "validateAutopilotDevice", "validateCorporateIdentifier", "validateDeviceAssociation", "validateCloudPcDevice", + // notifications + "notificationChannelsJson", + // slaTargets + "slaTargetSuccessRate", "slaTargetMaxDurationMinutes", "slaTargetAppInstallSuccessRate", + "slaNotifyOnSuccessRateBreach", "slaSuccessRateNotifyThreshold", "slaNotifyOnDurationBreach", + "slaNotifyOnAppInstallBreach", "slaNotifyOnConsecutiveFailures", "slaConsecutiveFailureThreshold", + // contact + "contactEmail", + // agentSettings + "enablePerformanceCollector", "performanceCollectorIntervalSeconds", "helloWaitTimeoutSeconds", + "selfDestructOnComplete", "keepLogFile", "rebootOnComplete", "rebootDelaySeconds", + "enableGeoLocation", "enableTimezoneAutoSet", "enableImeMatchLog", "enableGatherRuleDebugLog", + "logLevel", "showScriptOutput", "showEnrollmentSummary", "enrollmentSummaryTimeoutSeconds", + "enrollmentSummaryBrandingImageUrl", "enrollmentSummaryLaunchRetrySeconds", + // agentAnalyzers + "enableLocalAdminAnalyzer", "localAdminAllowedAccountsJson", "enableSoftwareInventoryAnalyzer", + "enableIntegrityBypassAnalyzer", "enableRealmJoinWatcher", "keepAwakeDuringUserEsp", + "enableConsoleBypassDetection", + // diagnostics + "diagnosticsBlobSasUrl", "diagnosticsUploadMode", "diagnosticsUploadDestination", "diagnosticsLogPathsJson", + // unrestrictedMode + "unrestrictedMode", + // dataManagement + "dataRetentionDays", "sessionTimeoutHours", +].sort(); + +/** + * Wire names the backend's patch endpoint denies for every caller (BaseDeniedFields in + * TenantConfigPatchService, camelCased). Independent copy: a section must never own one — + * the save would 400 on the first differing value. + */ +const SERVER_DENIED_FIELDS = new Set([ + "tenantId", "domainName", "partitionKey", "rowKey", "timestamp", "eTag", + "lastUpdated", "updatedBy", "onboardedAt", "onboardedBy", + "homedAppClientId", "lastAuthClientId", "lastAuthClientIdSince", + "planTier", "trialExpiresUtc", "trialStartedUtc", "trialConsumed", "trialGrantedBy", +]); + +describe("SECTION_FIELD_MAP parity", () => { + const manifestFields = new Set(SHARED_MANIFEST.tenantConfiguration.fields); + + it("every mapped field (owned and alsoWrites) exists on the backend model", () => { + for (const [section, spec] of sections) { + for (const field of [...spec.fields, ...(spec.alsoWrites ?? [])]) { + expect(manifestFields, `${section}: unknown field '${field}'`).toContain(field); + } + } + }); + + it("every field is owned by exactly one section", () => { + const seen = new Map(); + for (const [section, spec] of sections) { + for (const field of spec.fields) { + expect(seen.has(field), `'${field}' owned by both '${seen.get(field)}' and '${section}'`).toBe(false); + seen.set(field, section); + } + } + }); + + it("owned fields are exactly the expected Settings write surface", () => { + const owned = sections.flatMap(([, spec]) => [...spec.fields]).sort(); + expect(owned).toEqual(EXPECTED_OWNED_FIELDS); + }); + + it("alsoWrites entries never overlap the same section's owned fields", () => { + for (const [section, spec] of sections) { + for (const field of spec.alsoWrites ?? []) { + expect(spec.fields, `${section}: '${field}' is both owned and alsoWrites`).not.toContain(field); + } + } + }); + + it("no section touches a server-denied field", () => { + for (const [section, spec] of sections) { + for (const field of [...spec.fields, ...(spec.alsoWrites ?? [])]) { + expect(SERVER_DENIED_FIELDS.has(field), `${section}: '${field}' is patch-denied server-side`).toBe(false); + } + } + }); + + it("no section owns a GA-only field a tenant admin could not save", () => { + // GaOnlyFields (TenantConfigPatchService), camelCased — independent copy. Exceptions + // are deliberate: validateDeviceAssociation is GA-gated but lives in the autopilot + // section whose GA-only toggles are hidden for non-GA users, so a tenant admin's + // diff never contains it. + const gaOnly = new Set([ + "disabled", "disabledReason", "disabledUntil", + "allowInsecureAgentRequests", "bootstrapTokenEnabled", + "unrestrictedModeEnabled", "entraAppRolesEnabled", + "enableEspContinueAnywayObservation", + "customRateLimitRequestsPerMinute", "customUserRateLimitRequestsPerMinute", + "maxNdjsonPayloadSizeMB", + ]); + for (const [section, spec] of sections) { + for (const field of [...spec.fields, ...(spec.alsoWrites ?? [])]) { + expect(gaOnly.has(field), `${section}: '${field}' is GA-only — it must not be part of a section save`).toBe(false); + } + } + }); +}); diff --git a/src/Web/autopilot-monitor-web/app/settings/sectionFieldMap.ts b/src/Web/autopilot-monitor-web/app/settings/sectionFieldMap.ts new file mode 100644 index 00000000..257f0bd9 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/settings/sectionFieldMap.ts @@ -0,0 +1,117 @@ +import { SHARED_MANIFEST } from "@/utils/shared-manifests.generated"; + +/** + * Wire (camelCase) name of a TenantConfiguration field, typed against the generated shared + * manifest — a typo or a field the backend model no longer has fails tsc. + */ +export type TenantConfigFieldName = + (typeof SHARED_MANIFEST.tenantConfiguration.fields)[number]; + +export interface SectionFieldSpec { + /** + * Fields this Settings section OWNS (its form edits them directly). Ownership is exclusive: + * every field appears in at most one section's `fields` — pinned by sectionFieldMap.test.ts. + */ + fields: readonly TenantConfigFieldName[]; + /** + * Deliberate cross-section write-throughs: fields this section's save also patches even + * though it does not own them. Kept separate so the ownership test stays strict and every + * exception is visible here with its reason. + */ + alsoWrites?: readonly TenantConfigFieldName[]; +} + +/** + * Section → exact TenantConfiguration fields each Settings section saves. TenantConfigContext + * builds its per-section PATCH from this map: only the section's fields (plus alsoWrites) that + * actually DIFFER from the loaded config are sent — the other ~90 fields never round-trip, so + * a stale read can no longer revert unrelated fields (the 2026-07-31 incident class). + */ +export const SECTION_FIELD_MAP = { + hardwareWhitelist: { + fields: ["manufacturerWhitelist", "modelWhitelist", "webhookNotifyOnHardwareRejection"], + // The hardware-rejection toggle is a convenience view over the per-channel flags: it + // writes through into the channels JSON (owned by `notifications`) by design. + alsoWrites: ["notificationChannelsJson"], + }, + autopilotValidation: { + fields: [ + "validateAutopilotDevice", + "validateCorporateIdentifier", + "validateDeviceAssociation", + "validateCloudPcDevice", + ], + }, + notifications: { + fields: ["notificationChannelsJson"], + // Channels are authoritative; saving them clears the legacy single-webhook fields so + // deleting the last channel cannot resurrect a zombie webhook via legacy synthesis. + // No section owns these legacy fields — they are only ever cleared here. + alsoWrites: ["webhookProviderType", "webhookUrl", "webhookCustomHeadersJson", "teamsWebhookUrl"], + }, + slaTargets: { + fields: [ + "slaTargetSuccessRate", + "slaTargetMaxDurationMinutes", + "slaTargetAppInstallSuccessRate", + "slaNotifyOnSuccessRateBreach", + "slaSuccessRateNotifyThreshold", + "slaNotifyOnDurationBreach", + "slaNotifyOnAppInstallBreach", + "slaNotifyOnConsecutiveFailures", + "slaConsecutiveFailureThreshold", + ], + }, + contact: { + fields: ["contactEmail"], + }, + agentSettings: { + fields: [ + "enablePerformanceCollector", + "performanceCollectorIntervalSeconds", + "helloWaitTimeoutSeconds", + "selfDestructOnComplete", + "keepLogFile", + "rebootOnComplete", + "rebootDelaySeconds", + "enableGeoLocation", + "enableTimezoneAutoSet", + "enableImeMatchLog", + "enableGatherRuleDebugLog", + "logLevel", + "showScriptOutput", + "showEnrollmentSummary", + "enrollmentSummaryTimeoutSeconds", + "enrollmentSummaryBrandingImageUrl", + "enrollmentSummaryLaunchRetrySeconds", + ], + }, + agentAnalyzers: { + fields: [ + "enableLocalAdminAnalyzer", + "localAdminAllowedAccountsJson", + "enableSoftwareInventoryAnalyzer", + "enableIntegrityBypassAnalyzer", + "enableRealmJoinWatcher", + "keepAwakeDuringUserEsp", + "enableConsoleBypassDetection", + ], + }, + diagnostics: { + fields: [ + "diagnosticsBlobSasUrl", + "diagnosticsUploadMode", + "diagnosticsUploadDestination", + "diagnosticsLogPathsJson", + ], + }, + unrestrictedMode: { + fields: ["unrestrictedMode"], + }, + dataManagement: { + fields: ["dataRetentionDays", "sessionTimeoutHours"], + }, +} as const satisfies Record; + +/** Save-label union — saveConfiguration only accepts these, so a section typo fails tsc. */ +export type SettingsSectionName = keyof typeof SECTION_FIELD_MAP; diff --git a/src/Web/autopilot-monitor-web/components/GlobalSidebar.tsx b/src/Web/autopilot-monitor-web/components/GlobalSidebar.tsx index d539f574..99c19299 100644 --- a/src/Web/autopilot-monitor-web/components/GlobalSidebar.tsx +++ b/src/Web/autopilot-monitor-web/components/GlobalSidebar.tsx @@ -7,7 +7,8 @@ import { useAuth } from "../contexts/AuthContext"; import { useSidebar, PageSectionItem } from "../contexts/SidebarContext"; import { CollapseState } from "../hooks/useSidebarState"; import { DefaultSectionIcon, BookOpenIcon, InformationCircleIcon, DocumentTextIcon, ShieldCheckIcon } from "../lib/sidebarIcons"; -import { DASHBOARD_ITEM, NAV_GROUPS, EXPANDABLE_NAV_GROUPS, REGULAR_USER_ITEMS, NavItem, NavGroup, ExpandableNavGroup } from "../lib/globalNavConfig"; +import { DASHBOARD_ITEM, NAV_GROUPS, EXPANDABLE_NAV_GROUPS, REGULAR_USER_ITEMS, NavItem } from "../lib/globalNavConfig"; +import { deriveNavFlags, filterExpandableNavGroups, isNavGroupVisible } from "../lib/navVisibility"; import { useAdminMode } from "../hooks/useAdminMode"; import { useMediaQuery } from "../hooks/useMediaQuery"; import { DOCS_URL } from "@/utils/config"; @@ -168,78 +169,16 @@ export function GlobalSidebar({ children }: { children: ReactNode }) { }, []); // --- Visibility filtering --- - const isTenantAdmin = user?.isTenantAdmin ?? false; - const isOperator = user?.role === "Operator"; - const isViewer = user?.role === "Viewer"; - // Any resolved tenant role — Admin, Operator, or read-only Viewer. The Viewer sees the same - // monitoring/rules/operations/configuration nav; read-only is enforced inside the pages. - const isTenantMember = isTenantAdmin || isOperator || isViewer; - const isGlobalAdmin = user?.isGlobalAdmin ?? false; - const isDelegated = user?.isDelegated ?? false; + // Pure logic in lib/navVisibility.ts (pinned by the roles × nav-config matrix test). + const navFlags = useMemo( + () => deriveNavFlags({ user, hasGlobalScope, hasFleetScope, globalAdminMode }), + [user, hasGlobalScope, hasFleetScope, globalAdminMode], + ); - const isGroupVisible = (group: NavGroup | ExpandableNavGroup): boolean => { - switch (group.visibility) { - case "all": return true; - // A GlobalReader is "GA minus writes" — it must see the SAME sidebar as a real GA. The standard - // monitoring/rules/operations/configuration groups are normally tenant-member scope; open them to any - // platform scope so a pure GlobalReader (no own-tenant role) gets Fleet Health, Usage Metrics, SLA, - // etc. Read-only is enforced inside the pages (useCanMutatePlatform + backend), not by hiding nav. - case "tenantMember": return isTenantMember || hasGlobalScope; - // The (purple) Global Admin section is gated on the Global View toggle — IDENTICAL to a real GA: - // toggle off → hidden, toggle on → shown. Item-level globalAdminOnly entries (Settings/Ops/Software) - // still drop out for a read-only reader via the item filter below. - case "globalAdmin": return hasGlobalScope && globalAdminMode; - // Fleet (MSP) nav: shown to a delegated admin who does NOT have full platform scope. A GA/Reader - // gets the standard monitoring groups above instead, so it stays hidden for them (no duplication). - case "fleet": return isDelegated && !hasGlobalScope; - default: return false; - } - }; - - // Visible expandable groups (with item-level filtering for feature-gated items) - const hasMcpAccess = user?.hasMcpAccess ?? false; - const isAdminLike = isTenantAdmin || isGlobalAdmin; - const canManageBootstrapTokens = user?.canManageBootstrapTokens ?? false; - const bootstrapTokenEnabled = user?.bootstrapTokenEnabled ?? false; - const unrestrictedModeEnabled = user?.unrestrictedModeEnabled ?? false; - const visibleExpandableGroups = useMemo(() => { - return EXPANDABLE_NAV_GROUPS - .filter(isGroupVisible) - .map((group) => { - // Filter out MCP item if user doesn't have MCP access - const filteredItems = group.items - .filter((item) => { - if (item.id === "cfg-reporting") return hasMcpAccess; - // Platform-settings/mutation sub-sections are real-GA-only: hide from a read-only - // Global Reader (who reaches the group via hasGlobalScope visibility). - if ("visibility" in item && item.visibility === "globalAdminOnly") return isGlobalAdmin; - return true; - }) - .map((item) => { - // Sub-item gating for feature-flagged entries (tenant feature flags) - const filteredSubs = item.items.filter((sub) => { - if (sub.id === "cfg-bootstrap-sessions") { - return bootstrapTokenEnabled && (isAdminLike || canManageBootstrapTokens); - } - if (sub.id === "cfg-agent-unrestricted") { - return isAdminLike && unrestrictedModeEnabled; - } - // Tenant-admin-only sub-sections: Operators and Viewers (read-only settings - // viewers) don't see them — matches the in-page "tenant administrators only" gates. A platform - // scope (GA / read-only GlobalReader) keeps the full GA-identical sidebar. - if (sub.id === "cfg-autopilot" || sub.id === "cfg-access-mgmt" || sub.id === "cfg-offboarding") { - return isAdminLike || hasGlobalScope; - } - return true; - }); - return { ...item, items: filteredSubs }; - }) - // Drop items whose sub-items have all been filtered out - .filter((item) => item.items.length > 0); - return { ...group, items: filteredItems }; - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isTenantMember, hasGlobalScope, isGlobalAdmin, globalAdminMode, hasMcpAccess, isAdminLike, canManageBootstrapTokens, bootstrapTokenEnabled, unrestrictedModeEnabled]); + const visibleExpandableGroups = useMemo( + () => filterExpandableNavGroups(EXPANDABLE_NAV_GROUPS, navFlags), + [navFlags], + ); // Auto-expand the group containing the current pathname — adjust-during-render on // (pathname, visibleExpandableGroups). The groups identity matters too: on a hard @@ -409,17 +348,10 @@ export function GlobalSidebar({ children }: { children: ReactNode }) { }; // --- Build the nav content (shared between desktop and mobile) --- - const visibleGroups = NAV_GROUPS.filter(isGroupVisible); + const visibleGroups = NAV_GROUPS.filter((group) => isNavGroupVisible(group, navFlags)); const hasPageSections = pageSections.length > 0; - // Regular users see minimal nav. A read-only Global Reader has platform scope, and a delegated MSP admin - // has fleet scope → both get the (group-filtered) nav rather than the minimal regular-user list. - const isRegularUser = !isTenantMember && !hasFleetScope; - // The Dashboard is the cross-tenant session browser. Own-tenant/platform users see their own/all - // sessions; a delegated ("MSP") admin sees an aggregate across their managed tenants (bounded server-side) - // with the tenant filter scoped to that subset — so they get the link too (the /fleet card grid stays - // their landing overview). - const showDashboard = isTenantMember || hasGlobalScope || isDelegated; + const { isRegularUser, showDashboard } = navFlags; const renderNavContent = (isMobile = false) => ( <> diff --git a/src/Web/autopilot-monitor-web/contexts/SignalRContext.tsx b/src/Web/autopilot-monitor-web/contexts/SignalRContext.tsx index 1a1604d1..2bce8de3 100644 --- a/src/Web/autopilot-monitor-web/contexts/SignalRContext.tsx +++ b/src/Web/autopilot-monitor-web/contexts/SignalRContext.tsx @@ -5,6 +5,7 @@ import * as signalR from '@microsoft/signalr'; import { api } from '@/lib/api'; import { authenticatedFetch } from '@/lib/authenticatedFetch'; import { trackEvent } from '@/lib/appInsights'; +import type { SignalRMessageName } from '@/lib/signalrMessages'; import { useAuth } from './AuthContext'; // Hub payloads are untyped JSON; mirror @microsoft/signalr's own callback signature so @@ -15,8 +16,10 @@ interface SignalRContextType { connection: signalR.HubConnection | null; connectionState: signalR.HubConnectionState; connectionId: string | null; - on: (eventName: string, callback: SignalRHandler) => void; - off: (eventName: string, callback: SignalRHandler) => void; + // Event names are pinned to the backend's message-name catalog (shared manifest) so a + // subscription to a name the backend never sends is a compile error, not a silent no-op. + on: (eventName: SignalRMessageName, callback: SignalRHandler) => void; + off: (eventName: SignalRMessageName, callback: SignalRHandler) => void; invoke: (methodName: string, ...args: unknown[]) => Promise; joinGroup: (groupName: string) => Promise; leaveGroup: (groupName: string) => Promise; @@ -186,7 +189,7 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) { // connection. onclose clears the joined-group tracking, and every consumer hook re-joins on // the next Connected transition — through the join endpoint, which re-runs authorization // against the fresh scope. Revoked groups 403; still-authorized streams recover automatically. - newConnection.on('accessRevoked', async () => { + newConnection.on('accessRevoked' satisfies SignalRMessageName, async () => { // Dropping an event that arrives mid-restart is safe: the rejoin after start() re-runs // authorization against the CURRENT scope, which already reflects that later revoke. if (revokeRestartInFlightRef.current) return; @@ -224,13 +227,13 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) { }; }, [isAuthenticated, getAccessToken, syncJoinedGroups]); - const on = (eventName: string, callback: SignalRHandler) => { + const on = (eventName: SignalRMessageName, callback: SignalRHandler) => { if (connectionRef.current) { connectionRef.current.on(eventName, callback); } }; - const off = (eventName: string, callback: SignalRHandler) => { + const off = (eventName: SignalRMessageName, callback: SignalRHandler) => { if (connectionRef.current) { connectionRef.current.off(eventName, callback); } diff --git a/src/Web/autopilot-monitor-web/hooks/__tests__/concreteAdminScopeView.test.ts b/src/Web/autopilot-monitor-web/hooks/__tests__/concreteAdminScopeView.test.ts new file mode 100644 index 00000000..2c088963 --- /dev/null +++ b/src/Web/autopilot-monitor-web/hooks/__tests__/concreteAdminScopeView.test.ts @@ -0,0 +1,89 @@ +/** + * Pins the concrete (override-only) scope projection that used to live duplicated inside + * useGlobalAdminScope — the ONLY semantic difference between the former hook twins. + * These facts were written against the pre-consolidation hook behavior, so a wrapper + * regression (aggregated "" leaking through, delegated home routing global, transient + * own-tenant fetch for delegated) turns red here. + */ +import { describe, expect, it } from "vitest"; +import { resolveConcreteScopeView } from "../concreteAdminScopeView"; + +const OWN = "11111111-1111-1111-1111-111111111111"; +const OTHER = "22222222-2222-2222-2222-222222222222"; + +describe("resolveConcreteScopeView — GA/Reader", () => { + it("a persisted aggregated ('') intent resolves to the own tenant, not the aggregate", () => { + const v = resolveConcreteScopeView({ + isGlobalAdmin: true, isDelegatedScope: false, + selectedTenantId: "", ownTenantId: OWN, homeTenantId: OWN, + }); + expect(v.selectedTenantId).toBe(OWN); + expect(v.effectiveTenantId).toBe(OWN); + expect(v.isAggregatedGlobalView).toBe(false); + expect(v.isGlobalOverride).toBe(false); + expect(v.routeGlobal).toBe(true); + }); + + it("an override selection targets the picked tenant on the global route", () => { + const v = resolveConcreteScopeView({ + isGlobalAdmin: true, isDelegatedScope: false, + selectedTenantId: OTHER, ownTenantId: OWN, homeTenantId: OWN, + }); + expect(v.effectiveTenantId).toBe(OTHER); + expect(v.isGlobalOverride).toBe(true); + expect(v.routeGlobal).toBe(true); + }); + + it("before the own tenant resolves, nothing is invented", () => { + const v = resolveConcreteScopeView({ + isGlobalAdmin: true, isDelegatedScope: false, + selectedTenantId: "", ownTenantId: "", homeTenantId: undefined, + }); + expect(v.selectedTenantId).toBe(""); + expect(v.effectiveTenantId).toBe(""); + }); +}); + +describe("resolveConcreteScopeView — delegated (MSP)", () => { + it("pre-seed the effective tenant stays empty so pages skip the transient fetch", () => { + const v = resolveConcreteScopeView({ + isGlobalAdmin: true, isDelegatedScope: true, + selectedTenantId: "", ownTenantId: OWN, homeTenantId: OWN, + }); + // Deliberately NOT falling back to the own tenant: a delegated caller has no valid + // own-tenant data view here. + expect(v.selectedTenantId).toBe(""); + expect(v.effectiveTenantId).toBe(""); + expect(v.isAggregatedGlobalView).toBe(false); + }); + + it("a managed tenant routes global", () => { + const v = resolveConcreteScopeView({ + isGlobalAdmin: true, isDelegatedScope: true, + selectedTenantId: OTHER, ownTenantId: OWN, homeTenantId: OWN, + }); + expect(v.effectiveTenantId).toBe(OTHER); + expect(v.routeGlobal).toBe(true); + }); + + it("the HOME tenant takes the member path (carve-out)", () => { + const v = resolveConcreteScopeView({ + isGlobalAdmin: true, isDelegatedScope: true, + selectedTenantId: OWN, ownTenantId: OWN, homeTenantId: OWN, + }); + expect(v.effectiveTenantId).toBe(OWN); + expect(v.routeGlobal).toBe(false); + }); +}); + +describe("resolveConcreteScopeView — regular user", () => { + it("resolves to the own tenant on the member path", () => { + const v = resolveConcreteScopeView({ + isGlobalAdmin: false, isDelegatedScope: false, + selectedTenantId: "", ownTenantId: OWN, homeTenantId: OWN, + }); + expect(v.effectiveTenantId).toBe(OWN); + expect(v.routeGlobal).toBe(false); + expect(v.isGlobalOverride).toBe(false); + }); +}); diff --git a/src/Web/autopilot-monitor-web/hooks/concreteAdminScopeView.ts b/src/Web/autopilot-monitor-web/hooks/concreteAdminScopeView.ts new file mode 100644 index 00000000..260e0137 --- /dev/null +++ b/src/Web/autopilot-monitor-web/hooks/concreteAdminScopeView.ts @@ -0,0 +1,46 @@ +import { isHomeTenantTarget } from "@/utils/homeTenantScope"; + +/** + * Pure projection of the aggregated admin scope onto the CONCRETE (override-only) page + * variant — the single place the two former hook twins actually differed. Extracted so + * useGlobalAdminScope can be a thin wrapper over useAggregatedAdminScope and the + * per-variant semantics stay pinned by unit tests (concreteAdminScopeView.test.ts). + */ +export interface ConcreteScopeInputs { + /** Cross-tenant mode flag (GA/Reader in GA mode, or delegated always-on). */ + isGlobalAdmin: boolean; + isDelegatedScope: boolean; + /** Raw selection from the aggregated hook — "" is a valid aggregated intent there. */ + selectedTenantId: string; + /** The caller's own tenant (useTenant). */ + ownTenantId: string; + /** The caller's home tenant (user.tenantId) — the delegated member-path carve-out target. */ + homeTenantId: string | undefined; +} + +export interface ConcreteScopeView { + /** Selection resolved to a concrete tenant: a GA's persisted aggregated ("") intent maps + * to their own tenant WITHOUT clearing storage, so aggregated pages still honor it. */ + selectedTenantId: string; + /** Tenant to query; "" for a delegated caller before a managed tenant is seeded (pages + * gate their fetch on truthiness to avoid a transient own-tenant request). */ + effectiveTenantId: string; + isGlobalOverride: boolean; + /** Never true in this variant once the own tenant is known. */ + isAggregatedGlobalView: boolean; + /** Same carve-out as the aggregated hook: delegated + home tenant → member path. */ + routeGlobal: boolean; +} + +export function resolveConcreteScopeView(i: ConcreteScopeInputs): ConcreteScopeView { + const selected = i.isDelegatedScope ? i.selectedTenantId : (i.selectedTenantId || i.ownTenantId); + return { + selectedTenantId: selected, + effectiveTenantId: i.isGlobalAdmin && selected + ? selected + : (i.isDelegatedScope ? "" : i.ownTenantId), + isGlobalOverride: Boolean(i.isGlobalAdmin && selected && selected !== i.ownTenantId), + isAggregatedGlobalView: Boolean(i.isGlobalAdmin && !selected && !i.isDelegatedScope), + routeGlobal: i.isGlobalAdmin && !(i.isDelegatedScope && isHomeTenantTarget(selected, i.homeTenantId)), + }; +} diff --git a/src/Web/autopilot-monitor-web/hooks/useGlobalAdminScope.ts b/src/Web/autopilot-monitor-web/hooks/useGlobalAdminScope.ts index e61d5cc0..ad2e2450 100644 --- a/src/Web/autopilot-monitor-web/hooks/useGlobalAdminScope.ts +++ b/src/Web/autopilot-monitor-web/hooks/useGlobalAdminScope.ts @@ -1,12 +1,10 @@ "use client"; -import { useCallback, useMemo, useState } from "react"; import { useTenant } from "@/contexts/TenantContext"; import { useAuth } from "@/contexts/AuthContext"; -import { useAdminMode } from "@/hooks/useAdminMode"; -import { useTenantList, type TenantInfo } from "@/hooks/useTenantList"; -import { readTenantScope, writeTenantScope } from "@/utils/tenantScopeStorage"; -import { delegatedScopedTenantList, isHomeTenantTarget, upnDomain } from "@/utils/homeTenantScope"; +import { useAggregatedAdminScope } from "@/hooks/useAggregatedAdminScope"; +import { resolveConcreteScopeView } from "@/hooks/concreteAdminScopeView"; +import type { TenantInfo } from "@/hooks/useTenantList"; export type { TenantInfo }; @@ -47,88 +45,36 @@ export interface GlobalAdminScope { * own tenant for a GA, or the first managed tenant for a delegated ("MSP") admin — and endpoint choice is * keyed on {@link GlobalAdminScope.isGlobalOverride}. There is no aggregated "All tenants" mode here. * + * Implemented as a thin projection over {@link useAggregatedAdminScope} (one hook, aggregated as a mode): + * all selection/seeding/persistence state lives there; resolveConcreteScopeView maps a GA's persisted + * aggregated ("") intent to their own tenant locally — WITHOUT clearing storage, so aggregated pages + * still honor it. + * * Pair with {@link "@/components/TenantScopeSelector".TenantScopeSelector} for the header dropdown * and {@link "@/components/GlobalAdminBanner".GlobalAdminBanner} for the view bar. */ export function useGlobalAdminScope(): GlobalAdminScope { const { tenantId } = useTenant(); - const { hasGlobalScope, user } = useAuth(); - const { globalAdminMode } = useAdminMode(); + const { user } = useAuth(); + const agg = useAggregatedAdminScope(); - // Cross-tenant mode: GA/Reader in GA mode, OR a delegated ("MSP") admin (always-on). See AggregatedAdminScope. - const isDelegated = user?.isDelegated ?? false; - const isDelegatedScope = Boolean(isDelegated && !hasGlobalScope); - const isGlobalAdmin = Boolean((globalAdminMode && hasGlobalScope) || isDelegatedScope); - - const allTenants = useTenantList(isGlobalAdmin); - // Delegated: managed subset PLUS the caller's own home tenant when they hold a member role there - // (member-path access — see utils/homeTenantScope.ts). config/all is backend-bounded to the managed - // set, so the home entry is synthesized with the UPN-derived domain when absent. - const homeTenantId = user?.tenantId; - const hasHomeRole = !!user?.role; - const tenants = useMemo( - () => - isDelegatedScope - ? delegatedScopedTenantList(allTenants, user?.delegatedTenantIds, homeTenantId, upnDomain(user?.upn), hasHomeRole) - : allTenants, - [allTenants, isDelegatedScope, user?.delegatedTenantIds, homeTenantId, user?.upn, hasHomeRole] - ); - - const [selectedTenantId, setSelectedRaw] = useState(""); - - // Persist ONLY on an explicit user action (the selector's onChange). The auto-defaults below use - // setSelectedRaw so they never write back — in particular a GA's persisted aggregated ("") intent - // is left untouched here (this override-only variant has no aggregate) so aggregated pages still honor it. - const setSelectedTenantId = useCallback((id: string) => { - setSelectedRaw(id); - writeTenantScope(id); - }, []); - - // GA/Reader: seed the selection from the tab-persisted choice, else the user's own tenant (never empty - // in this variant). A persisted aggregated "" resolves locally to the own tenant without clearing storage. - // Render-time (like the delegated seed below), NOT an effect: an effect-time seed exposes one commit - // where effectiveTenantId is the caller's own tenant, so pages fire an own-tenant fetch that races the - // override fetch — last-resolved wins and the wrong tenant's data can stick until a manual refresh. - if (!isDelegatedScope && tenantId && !selectedTenantId) { - const stored = readTenantScope(); - setSelectedRaw(stored ? stored : tenantId); - } - - // Delegated: seed from the persisted managed tenant (if still managed) or the first managed tenant once - // the scoped list arrives; re-default if the selection falls outside the managed set. Render-time (converges). - if (isDelegatedScope && tenants.length > 0 && (!selectedTenantId || !tenants.some((t) => t.tenantId === selectedTenantId))) { - const stored = readTenantScope(); - const storedManaged = stored && tenants.some((t) => t.tenantId === stored) ? stored : null; - setSelectedRaw(storedManaged ?? tenants[0].tenantId); - } - - // Stale-selection guard (GA): a persisted tenant no longer present in the list falls back to the own - // tenant. Local only — never clobbers storage. - if (!isDelegatedScope && isGlobalAdmin && selectedTenantId && tenants.length > 0 && !tenants.some((t) => t.tenantId === selectedTenantId)) { - setSelectedRaw(tenantId); - } - - const isGlobalOverride = Boolean( - isGlobalAdmin && selectedTenantId && selectedTenantId !== tenantId - ); - // Delegated has no valid own-tenant data, so before a managed tenant is selected resolve to "" (empty) - // — pages gate their fetch on a truthy effectiveTenantId, so this avoids a transient own-tenant request. - const effectiveTenantId = isGlobalAdmin && selectedTenantId - ? selectedTenantId - : (isDelegatedScope ? "" : tenantId); - const isAggregatedGlobalView = Boolean(isGlobalAdmin && !selectedTenantId && !isDelegatedScope); - // Delegated + home tenant → member path (see interface doc). GA/Reader always route global here. - const routeGlobal = isGlobalAdmin && !(isDelegatedScope && isHomeTenantTarget(selectedTenantId, homeTenantId)); + const view = resolveConcreteScopeView({ + isGlobalAdmin: agg.isGlobalAdmin, + isDelegatedScope: agg.isDelegatedScope, + selectedTenantId: agg.selectedTenantId, + ownTenantId: tenantId, + homeTenantId: user?.tenantId, + }); return { - isGlobalAdmin, - isDelegatedScope, - tenants, - selectedTenantId, - setSelectedTenantId, - effectiveTenantId, - isGlobalOverride, - isAggregatedGlobalView, - routeGlobal, + isGlobalAdmin: agg.isGlobalAdmin, + isDelegatedScope: agg.isDelegatedScope, + tenants: agg.tenants, + selectedTenantId: view.selectedTenantId, + setSelectedTenantId: agg.setSelectedTenantId, + effectiveTenantId: view.effectiveTenantId, + isGlobalOverride: view.isGlobalOverride, + isAggregatedGlobalView: view.isAggregatedGlobalView, + routeGlobal: view.routeGlobal, }; } diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/navVisibility.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/navVisibility.test.ts new file mode 100644 index 00000000..56d1b68e --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/__tests__/navVisibility.test.ts @@ -0,0 +1,318 @@ +/** + * Roles × nav-config × route-guards matrix for the sidebar visibility logic + * (lib/navVisibility.ts — extracted verbatim from GlobalSidebar). + * + * Three layers: + * 1. deriveNavFlags — role-state derivations (regular user, dashboard link, member tiers), + * 2. group/item/sub-item filtering against the REAL nav config (lib/globalNavConfig), + * 3. nav ↔ route-guard consistency: every href the sidebar shows a role must be admitted + * by that route's layout guard — "sidebar shows it, guard bounces it" is the drift + * class this pins. Known mismatches are an explicit ratchet list: fixing one forces + * removing its entry, a new one fails the test. + */ +import { describe, expect, it } from "vitest"; +import { + DASHBOARD_ITEM, + EXPANDABLE_NAV_GROUPS, + NAV_GROUPS, + REGULAR_USER_ITEMS, +} from "../globalNavConfig"; +import { + deriveNavFlags, + filterExpandableNavGroups, + isNavGroupVisible, + type NavFlags, + type NavUser, + type NavVisibilityInput, +} from "../navVisibility"; + +// ── role-state fixtures ──────────────────────────────────────────────────────── + +function user(overrides: Partial = {}): NavUser { + return { + isTenantAdmin: false, + isGlobalAdmin: false, + isDelegated: false, + role: null, + hasMcpAccess: false, + canManageBootstrapTokens: false, + bootstrapTokenEnabled: false, + unrestrictedModeEnabled: false, + ...overrides, + }; +} + +interface RoleState { + name: string; + input: NavVisibilityInput; +} + +const ROLE_STATES: RoleState[] = [ + { + name: "GlobalAdmin+adminModeOn", + input: { user: user({ isGlobalAdmin: true }), hasGlobalScope: true, hasFleetScope: true, globalAdminMode: true }, + }, + { + name: "GlobalAdmin+adminModeOff", + input: { user: user({ isGlobalAdmin: true }), hasGlobalScope: true, hasFleetScope: true, globalAdminMode: false }, + }, + { + name: "GlobalReader+adminModeOn", + input: { user: user(), hasGlobalScope: true, hasFleetScope: true, globalAdminMode: true }, + }, + { + name: "DelegatedPure", + input: { user: user({ isDelegated: true }), hasGlobalScope: false, hasFleetScope: true, globalAdminMode: false }, + }, + { + name: "Delegated+OwnAdminRole", + input: { user: user({ isDelegated: true, isTenantAdmin: true, role: "Admin" }), hasGlobalScope: false, hasFleetScope: true, globalAdminMode: false }, + }, + { + name: "Delegated+GlobalAdmin", + input: { user: user({ isDelegated: true, isGlobalAdmin: true }), hasGlobalScope: true, hasFleetScope: true, globalAdminMode: true }, + }, + { + name: "TenantAdmin", + input: { user: user({ isTenantAdmin: true, role: "Admin" }), hasGlobalScope: false, hasFleetScope: false, globalAdminMode: false }, + }, + { + name: "Operator", + input: { user: user({ role: "Operator" }), hasGlobalScope: false, hasFleetScope: false, globalAdminMode: false }, + }, + { + name: "Viewer", + input: { user: user({ role: "Viewer" }), hasGlobalScope: false, hasFleetScope: false, globalAdminMode: false }, + }, + { + name: "RegularMember", + input: { user: user(), hasGlobalScope: false, hasFleetScope: false, globalAdminMode: false }, + }, + { + name: "NoUser(authLoading)", + input: { user: null, hasGlobalScope: false, hasFleetScope: false, globalAdminMode: false }, + }, +]; + +const flagsOf = (name: string): NavFlags => + deriveNavFlags(ROLE_STATES.find((s) => s.name === name)!.input); + +const visibleGroupIds = (flags: NavFlags): string[] => + [...NAV_GROUPS, ...EXPANDABLE_NAV_GROUPS] + .filter((g) => isNavGroupVisible(g, flags)) + .map((g) => g.id); + +// ── layer 1: flag derivations ───────────────────────────────────────────────── + +describe("deriveNavFlags", () => { + it.each([ + ["GlobalAdmin+adminModeOn", false, true], + ["GlobalReader+adminModeOn", false, true], + ["DelegatedPure", false, true], + ["TenantAdmin", false, true], + ["Operator", false, true], + ["Viewer", false, true], + ["RegularMember", true, false], + ["NoUser(authLoading)", true, false], + ] as const)("%s → isRegularUser=%s, showDashboard=%s", (name, regular, dashboard) => { + const flags = flagsOf(name); + expect(flags.isRegularUser).toBe(regular); + expect(flags.showDashboard).toBe(dashboard); + }); + + it("all three tenant roles count as tenant members", () => { + for (const name of ["TenantAdmin", "Operator", "Viewer"]) { + expect(flagsOf(name).isTenantMember).toBe(true); + } + expect(flagsOf("RegularMember").isTenantMember).toBe(false); + }); +}); + +// ── layer 2: group visibility against the real nav config ───────────────────── + +describe("group visibility matrix", () => { + it("tenant members and platform scopes see the standard groups; regular users see none", () => { + for (const name of ["TenantAdmin", "Operator", "Viewer", "GlobalReader+adminModeOn", "GlobalAdmin+adminModeOff"]) { + const ids = visibleGroupIds(flagsOf(name)); + for (const groupId of ["monitoring", "rules", "operations", "configuration"]) { + expect(ids, `${name} must see '${groupId}'`).toContain(groupId); + } + } + expect(visibleGroupIds(flagsOf("RegularMember"))).toEqual([]); + expect(visibleGroupIds(flagsOf("NoUser(authLoading)"))).toEqual([]); + }); + + it("the Global Admin group needs platform scope AND the admin-mode toggle", () => { + expect(visibleGroupIds(flagsOf("GlobalAdmin+adminModeOn"))).toContain("global-admin"); + expect(visibleGroupIds(flagsOf("GlobalReader+adminModeOn"))).toContain("global-admin"); + expect(visibleGroupIds(flagsOf("GlobalAdmin+adminModeOff"))).not.toContain("global-admin"); + expect(visibleGroupIds(flagsOf("TenantAdmin"))).not.toContain("global-admin"); + }); + + it("the fleet group shows for delegated admins without platform scope only", () => { + expect(visibleGroupIds(flagsOf("DelegatedPure"))).toContain("fleet"); + expect(visibleGroupIds(flagsOf("Delegated+OwnAdminRole"))).toContain("fleet"); + expect(visibleGroupIds(flagsOf("Delegated+GlobalAdmin"))).not.toContain("fleet"); + expect(visibleGroupIds(flagsOf("TenantAdmin"))).not.toContain("fleet"); + }); + + it("a delegated admin with an own-tenant role sees fleet AND tenant groups", () => { + const ids = visibleGroupIds(flagsOf("Delegated+OwnAdminRole")); + expect(ids).toContain("fleet"); + expect(ids).toContain("monitoring"); + expect(ids).toContain("configuration"); + // Pure delegated: no tenant groups. + expect(visibleGroupIds(flagsOf("DelegatedPure"))).not.toContain("configuration"); + }); +}); + +describe("item and sub-item filtering", () => { + const itemIds = (flags: NavFlags): string[] => + filterExpandableNavGroups(EXPANDABLE_NAV_GROUPS, flags).flatMap((g) => g.items.map((i) => i.id)); + const subIds = (flags: NavFlags): string[] => + filterExpandableNavGroups(EXPANDABLE_NAV_GROUPS, flags).flatMap((g) => + g.items.flatMap((i) => i.items.map((s) => s.id))); + + it("globalAdminOnly items show for the real GA and never for the read-only reader", () => { + const gaItems = itemIds(flagsOf("GlobalAdmin+adminModeOn")); + const readerItems = itemIds(flagsOf("GlobalReader+adminModeOn")); + for (const id of ["ga-settings", "ga-ops", "ga-software"]) { + expect(gaItems).toContain(id); + expect(readerItems, `reader must not see '${id}'`).not.toContain(id); + } + // The reader keeps the non-mutating GA items. + for (const id of ["ga-tenants", "ga-metrics", "ga-reports", "ga-security"]) { + expect(readerItems).toContain(id); + } + }); + + it("cfg-reporting requires per-user MCP access", () => { + expect(itemIds(flagsOf("TenantAdmin"))).not.toContain("cfg-reporting"); + const withMcp = deriveNavFlags({ + user: user({ isTenantAdmin: true, role: "Admin", hasMcpAccess: true }), + hasGlobalScope: false, hasFleetScope: false, globalAdminMode: false, + }); + expect(itemIds(withMcp)).toContain("cfg-reporting"); + }); + + it("tenant-admin-only sub-sections hide from Operators and Viewers but not platform scopes", () => { + for (const sub of ["cfg-autopilot", "cfg-access-mgmt", "cfg-offboarding"]) { + expect(subIds(flagsOf("TenantAdmin"))).toContain(sub); + expect(subIds(flagsOf("GlobalReader+adminModeOn"))).toContain(sub); + expect(subIds(flagsOf("Operator")), `Operator must not see '${sub}'`).not.toContain(sub); + expect(subIds(flagsOf("Viewer")), `Viewer must not see '${sub}'`).not.toContain(sub); + } + }); + + it("feature-flagged sub-items follow their tenant flags and role gates", () => { + // Off by default: + expect(subIds(flagsOf("TenantAdmin"))).not.toContain("cfg-bootstrap-sessions"); + expect(subIds(flagsOf("TenantAdmin"))).not.toContain("cfg-agent-unrestricted"); + // Flag on + admin: + const flaggedAdmin = deriveNavFlags({ + user: user({ isTenantAdmin: true, role: "Admin", bootstrapTokenEnabled: true, unrestrictedModeEnabled: true }), + hasGlobalScope: false, hasFleetScope: false, globalAdminMode: false, + }); + expect(subIds(flaggedAdmin)).toContain("cfg-bootstrap-sessions"); + expect(subIds(flaggedAdmin)).toContain("cfg-agent-unrestricted"); + // Bootstrap manager permission substitutes for admin on bootstrap sessions only: + const bootstrapOperator = deriveNavFlags({ + user: user({ role: "Operator", canManageBootstrapTokens: true, bootstrapTokenEnabled: true, unrestrictedModeEnabled: true }), + hasGlobalScope: false, hasFleetScope: false, globalAdminMode: false, + }); + expect(subIds(bootstrapOperator)).toContain("cfg-bootstrap-sessions"); + expect(subIds(bootstrapOperator)).not.toContain("cfg-agent-unrestricted"); + }); +}); + +// ── layer 3: nav ↔ route-guard consistency ──────────────────────────────────── + +describe("nav ↔ route-guard consistency", () => { + /** + * Independent oracle of the layout guards (NOT derived from the components): + * - app/admin/layout.tsx → ProtectedRoute requireGlobalScope; the deeper GA-only + * layouts (settings/ops/backups/customs-archive/software) → requireGlobalAdmin, + * - app/sessions/inspector/layout.tsx → requireGlobalAdmin, + * - app/fleet/layout.tsx → requireFleetScope, + * - app/settings/layout.tsx → rule A (admin/GA/Operator/Viewer) minus rule B + * (Operator without admin standing needs canManageBootstrapTokens). + * Longest prefix wins. Routes without a declared guard have no layout gate. + */ + const ROUTE_GUARDS: Array<{ prefix: string; admits: (input: NavVisibilityInput, flags: NavFlags) => boolean }> = [ + { prefix: "/admin/settings", admits: (_i, f) => f.isGlobalAdmin }, + { prefix: "/admin/ops", admits: (_i, f) => f.isGlobalAdmin }, + { prefix: "/admin/backups", admits: (_i, f) => f.isGlobalAdmin }, + { prefix: "/admin/customs-archive", admits: (_i, f) => f.isGlobalAdmin }, + { prefix: "/admin/software", admits: (_i, f) => f.isGlobalAdmin }, + { prefix: "/sessions/inspector", admits: (_i, f) => f.isGlobalAdmin }, + { prefix: "/admin", admits: (_i, f) => f.hasGlobalScope }, + { prefix: "/fleet", admits: (_i, f) => f.hasFleetScope }, + { + prefix: "/settings", + admits: (i, f) => { + const role = i.user?.role ?? null; + const admitted = f.isAdminLike || role === "Operator" || role === "Viewer"; + const operatorBounced = role === "Operator" && !f.isAdminLike && !(i.user?.canManageBootstrapTokens ?? false); + return admitted && !operatorBounced; + }, + }, + ]; + + /** + * Ratchet of KNOWN sidebar-shows-it/guard-bounces-it mismatches (pre-existing, user + * decision pending — see tasks/todo.md P7.4): a plain Operator and a read-only + * GlobalReader both see Configuration entries whose /settings layout bounces them. + * Fixing a guard requires deleting its entry here; any NEW mismatch fails the test. + */ + const KNOWN_MISMATCHES = new Set([ + "Operator × /settings", + "GlobalReader+adminModeOn × /settings", + ]); + + const guardFor = (href: string) => + ROUTE_GUARDS.filter((g) => href === g.prefix || href.startsWith(g.prefix + "/")) + .sort((a, b) => b.prefix.length - a.prefix.length)[0]; + + const visibleHrefs = (input: NavVisibilityInput): string[] => { + const flags = deriveNavFlags(input); + const hrefs: string[] = []; + if (flags.showDashboard) hrefs.push(DASHBOARD_ITEM.href); + if (flags.isRegularUser) hrefs.push(...REGULAR_USER_ITEMS.map((i) => i.href)); + for (const group of NAV_GROUPS.filter((g) => isNavGroupVisible(g, flags))) { + hrefs.push(...group.items.map((i) => i.href)); + } + for (const group of filterExpandableNavGroups(EXPANDABLE_NAV_GROUPS, flags)) { + hrefs.push(...group.items.flatMap((i) => i.items.map((s) => s.href))); + } + return hrefs; + }; + + it("every visible href is admitted by its route guard (known mismatches ratcheted)", () => { + const mismatches = new Set(); + for (const state of ROLE_STATES) { + const flags = deriveNavFlags(state.input); + for (const href of visibleHrefs(state.input)) { + const guard = guardFor(href); + if (!guard) continue; + if (!guard.admits(state.input, flags)) { + mismatches.add(`${state.name} × ${guard.prefix}`); + } + } + } + expect([...mismatches].sort()).toEqual([...KNOWN_MISMATCHES].sort()); + }); + + it("guard oracle covers every /admin, /fleet and /settings href in the nav config", () => { + const allHrefs = [ + DASHBOARD_ITEM.href, + ...REGULAR_USER_ITEMS.map((i) => i.href), + ...NAV_GROUPS.flatMap((g) => g.items.map((i) => i.href)), + ...EXPANDABLE_NAV_GROUPS.flatMap((g) => g.items.flatMap((i) => i.items.map((s) => s.href))), + ]; + for (const href of allHrefs) { + if (/^\/(admin|fleet|settings)(\/|$)/.test(href) || href.startsWith("/sessions/inspector")) { + expect(guardFor(href), `no guard oracle entry covers '${href}'`).toBeTruthy(); + } + } + }); +}); diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/scopedApi.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/scopedApi.test.ts new file mode 100644 index 00000000..122dcab2 --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/__tests__/scopedApi.test.ts @@ -0,0 +1,74 @@ +/** + * Routing matrix for the scope-aware URL builders: tenant path / global override / + * global aggregated / delegated-home member path — the four states every one of the + * former hand-rolled call sites had to get right, incl. tenant-parameter placement + * (the audit's positional-argument trap) and the '' → no-param aggregate idiom. + */ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/utils/config", () => ({ API_BASE_URL: "https://test.example" })); + +import { scopedApi, type TenantScopeSelection } from "../scopedApi"; + +const OWN = "11111111-1111-1111-1111-111111111111"; +const OTHER = "22222222-2222-2222-2222-222222222222"; + +const tenantMode: TenantScopeSelection = { routeGlobal: false, selectedTenantId: OWN, effectiveTenantId: OWN }; +const override: TenantScopeSelection = { routeGlobal: true, selectedTenantId: OTHER, effectiveTenantId: OTHER }; +const aggregated: TenantScopeSelection = { routeGlobal: true, selectedTenantId: "", effectiveTenantId: "" }; + +describe("scopedApi routing", () => { + it("tenant mode targets the member-path variant with the own tenant", () => { + const url = scopedApi.appMetrics(tenantMode, 30); + expect(url).toContain("/api/metrics/app"); + expect(url).toContain(`tenantId=${OWN}`); + expect(url).not.toContain("/global/"); + expect(scopedApi.fleetHealth(tenantMode, 30)).not.toContain("/global/"); + expect(scopedApi.geographic(tenantMode, 30, "country")).toContain(`tenantId=${OWN}`); + }); + + it("override targets the global variant with the selected tenant as query param", () => { + const url = scopedApi.appMetrics(override, 30); + expect(url).toContain("/global/"); + expect(url).toContain(`tenantId=${OTHER}`); + expect(scopedApi.auditLogs(override, {})).toContain(`tenantId=${OTHER}`); + expect(scopedApi.annotationsList(override, {})).toContain(`tenantId=${OTHER}`); + }); + + it("aggregated ('') sends the global variant WITHOUT a tenantId param", () => { + for (const url of [ + scopedApi.appMetrics(aggregated, 30), + scopedApi.fleetHealth(aggregated, 30), + scopedApi.vulnerability(aggregated, 30, 10), + scopedApi.auditLogs(aggregated, {}), + scopedApi.appsList(aggregated, 30), + ]) { + expect(url).toContain("/global/"); + expect(url).not.toContain("tenantId="); + } + }); + + it("the delegated-home member path is whatever routeGlobal says — no local re-derivation", () => { + // The carve-out lives in the scope hooks; the builder must follow routeGlobal blindly. + const delegatedHome: TenantScopeSelection = { routeGlobal: false, selectedTenantId: OWN, effectiveTenantId: OWN }; + expect(scopedApi.appAnalytics(delegatedHome, "App", 30)).not.toContain("/global/"); + }); + + it("a corrupted non-GUID selection is dropped from the global query, not sent", () => { + const corrupt: TenantScopeSelection = { routeGlobal: true, selectedTenantId: "not-a-guid", effectiveTenantId: "not-a-guid" }; + expect(scopedApi.appMetrics(corrupt, 30)).not.toContain("tenantId="); + }); + + it("appSessions places every positional argument on both variants", () => { + const t = scopedApi.appSessions(tenantMode, "My App", 7, "failed", 20, 10); + const g = scopedApi.appSessions(override, "My App", 7, "failed", 20, 10); + for (const url of [t, g]) { + expect(url).toContain("My%20App"); + expect(url).toContain("days=7"); + expect(url).toContain("status=failed"); + expect(url).toContain("offset=20"); + expect(url).toContain("limit=10"); + } + expect(g).toContain(`tenantId=${OTHER}`); + }); +}); diff --git a/src/Web/autopilot-monitor-web/lib/api.ts b/src/Web/autopilot-monitor-web/lib/api.ts index dd1116ac..398b6436 100644 --- a/src/Web/autopilot-monitor-web/lib/api.ts +++ b/src/Web/autopilot-monitor-web/lib/api.ts @@ -132,6 +132,13 @@ export const api = { config: { all: () => `${API_BASE_URL}/api/config/all`, tenant: (tenantId: string) => `${API_BASE_URL}/api/config/${tenantId}`, + /** + * PATCH — transactional field-level config write (TenantAdminOrGA, own row): + * { fields: { : value | null, ... }, reason?: string }. The backend + * verifies exactly those fields changed and rolls back on drift; every write snapshots + * to ConfigurationBackups first. The Settings sections save through this per section. + */ + fields: (tenantId: string) => `${API_BASE_URL}/api/config/${tenantId}/fields`, featureFlags: (tenantId: string) => `${API_BASE_URL}/api/config/${tenantId}/feature-flags`, /** PATCH — GA-only plan/trial mutation: { planTier?, trialExpiresUtc?: ISO | null }. */ plan: (tenantId: string) => `${API_BASE_URL}/api/config/${tenantId}/plan`, diff --git a/src/Web/autopilot-monitor-web/lib/navVisibility.ts b/src/Web/autopilot-monitor-web/lib/navVisibility.ts new file mode 100644 index 00000000..68e7a567 --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/navVisibility.ts @@ -0,0 +1,145 @@ +import type { ExpandableNavGroup, NavGroup } from "./globalNavConfig"; + +/** + * Pure sidebar-visibility logic, extracted from GlobalSidebar so the full + * roles × nav-config matrix is testable without a DOM (house pattern — see + * aggregatedAdminScopeSeed / decideHostBounce). GlobalSidebar consumes these + * verbatim; behavior changes belong HERE so the matrix test sees them. + */ + +/** Structural subset of AuthContext's UserInfo the nav logic reads. */ +export interface NavUser { + isTenantAdmin?: boolean; + isGlobalAdmin?: boolean; + isDelegated?: boolean; + role?: string | null; + hasMcpAccess?: boolean; + canManageBootstrapTokens?: boolean; + bootstrapTokenEnabled?: boolean; + unrestrictedModeEnabled?: boolean; +} + +export interface NavVisibilityInput { + user: NavUser | null | undefined; + /** AuthContext derivation: isGlobalAdmin || isGlobalReader. */ + hasGlobalScope: boolean; + /** AuthContext derivation: isGlobalAdmin || isGlobalReader || isDelegated. */ + hasFleetScope: boolean; + /** The Global View toggle (localStorage) — gates the purple Global Admin section. */ + globalAdminMode: boolean; +} + +export interface NavFlags { + isTenantMember: boolean; + isGlobalAdmin: boolean; + isDelegated: boolean; + hasGlobalScope: boolean; + hasFleetScope: boolean; + globalAdminMode: boolean; + hasMcpAccess: boolean; + isAdminLike: boolean; + canManageBootstrapTokens: boolean; + bootstrapTokenEnabled: boolean; + unrestrictedModeEnabled: boolean; + /** Minimal nav (Progress Portal only): no tenant role and no fleet/platform scope. */ + isRegularUser: boolean; + /** The cross-tenant session browser link. */ + showDashboard: boolean; +} + +export function deriveNavFlags({ user, hasGlobalScope, hasFleetScope, globalAdminMode }: NavVisibilityInput): NavFlags { + const isTenantAdmin = user?.isTenantAdmin ?? false; + const isOperator = user?.role === "Operator"; + const isViewer = user?.role === "Viewer"; + // Any resolved tenant role — Admin, Operator, or read-only Viewer. The Viewer sees the same + // monitoring/rules/operations/configuration nav; read-only is enforced inside the pages. + const isTenantMember = isTenantAdmin || isOperator || isViewer; + const isGlobalAdmin = user?.isGlobalAdmin ?? false; + const isDelegated = user?.isDelegated ?? false; + + return { + isTenantMember, + isGlobalAdmin, + isDelegated, + hasGlobalScope, + hasFleetScope, + globalAdminMode, + hasMcpAccess: user?.hasMcpAccess ?? false, + isAdminLike: isTenantAdmin || isGlobalAdmin, + canManageBootstrapTokens: user?.canManageBootstrapTokens ?? false, + bootstrapTokenEnabled: user?.bootstrapTokenEnabled ?? false, + unrestrictedModeEnabled: user?.unrestrictedModeEnabled ?? false, + // Regular users see minimal nav. A read-only Global Reader has platform scope, and a + // delegated MSP admin has fleet scope → both get the group-filtered nav instead. + isRegularUser: !isTenantMember && !hasFleetScope, + // Own-tenant/platform users see their own/all sessions; a delegated ("MSP") admin sees a + // bounded aggregate across their managed tenants — so they get the link too. + showDashboard: isTenantMember || hasGlobalScope || isDelegated, + }; +} + +export function isNavGroupVisible(group: Pick, flags: NavFlags): boolean { + switch (group.visibility) { + case "all": return true; + // A GlobalReader is "GA minus writes" — it must see the SAME sidebar as a real GA. The standard + // monitoring/rules/operations/configuration groups are normally tenant-member scope; open them to any + // platform scope so a pure GlobalReader (no own-tenant role) gets Fleet Health, Usage Metrics, SLA, + // etc. Read-only is enforced inside the pages (useCanMutatePlatform + backend), not by hiding nav. + case "tenantMember": return flags.isTenantMember || flags.hasGlobalScope; + // The (purple) Global Admin section is gated on the Global View toggle — IDENTICAL to a real GA: + // toggle off → hidden, toggle on → shown. Item-level globalAdminOnly entries (Settings/Ops/Software) + // still drop out for a read-only reader via the item filter below. + case "globalAdmin": return flags.hasGlobalScope && flags.globalAdminMode; + // Fleet (MSP) nav: shown to a delegated admin who does NOT have full platform scope. A GA/Reader + // gets the standard monitoring groups above instead, so it stays hidden for them (no duplication). + case "fleet": return flags.isDelegated && !flags.hasGlobalScope; + default: return false; + } +} + +/** + * Group → item → sub-item filtering for the expandable groups (Configuration, Global Admin): + * feature gates (MCP reporting, bootstrap sessions, unrestricted mode) and role gates + * (globalAdminOnly items, tenant-admin-only sub-sections). Items whose sub-items all filter + * away are dropped entirely. + */ +export function filterExpandableNavGroups( + groups: readonly ExpandableNavGroup[], + flags: NavFlags, +): ExpandableNavGroup[] { + return groups + .filter((group) => isNavGroupVisible(group, flags)) + .map((group) => { + const filteredItems = group.items + .filter((item) => { + // MCP reporting requires per-user MCP access. + if (item.id === "cfg-reporting") return flags.hasMcpAccess; + // Platform-settings/mutation sub-sections are real-GA-only: hide from a read-only + // Global Reader (who reaches the group via hasGlobalScope visibility). + if ("visibility" in item && item.visibility === "globalAdminOnly") return flags.isGlobalAdmin; + return true; + }) + .map((item) => { + // Sub-item gating for feature-flagged entries (tenant feature flags) + const filteredSubs = item.items.filter((sub) => { + if (sub.id === "cfg-bootstrap-sessions") { + return flags.bootstrapTokenEnabled && (flags.isAdminLike || flags.canManageBootstrapTokens); + } + if (sub.id === "cfg-agent-unrestricted") { + return flags.isAdminLike && flags.unrestrictedModeEnabled; + } + // Tenant-admin-only sub-sections: Operators and Viewers (read-only settings + // viewers) don't see them — matches the in-page "tenant administrators only" gates. A platform + // scope (GA / read-only GlobalReader) keeps the full GA-identical sidebar. + if (sub.id === "cfg-autopilot" || sub.id === "cfg-access-mgmt" || sub.id === "cfg-offboarding") { + return flags.isAdminLike || flags.hasGlobalScope; + } + return true; + }); + return { ...item, items: filteredSubs }; + }) + // Drop items whose sub-items have all been filtered out + .filter((item) => item.items.length > 0); + return { ...group, items: filteredItems }; + }); +} diff --git a/src/Web/autopilot-monitor-web/lib/scopedApi.ts b/src/Web/autopilot-monitor-web/lib/scopedApi.ts new file mode 100644 index 00000000..8b804ec9 --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/scopedApi.ts @@ -0,0 +1,95 @@ +import { api } from "./api"; +import { asGuidOrUndefined } from "@/utils/inputValidation"; + +/** + * Scope-aware URL builders for the tenant/global endpoint pairs in lib/api.ts. + * + * Every cross-tenant page used to hand-roll the same idiom at each call site: + * routeGlobal ? api.x.globalY(..., selectedTenantId || undefined) : api.x.y(tenantId, ...) + * — with the tenantId parameter sitting at a DIFFERENT position in nearly every global + * variant (the exact trap the 2026-08 audit flagged). These builders take the scope + * selection ONCE and own the routing decision + tenant-parameter placement, so a page + * can no longer pick the wrong variant, misplace the tenant argument, or bypass the + * delegated-home carve-out that the scope hooks encode in `routeGlobal`. + */ +export interface TenantScopeSelection { + /** From the scope hook: global route incl. the delegated-home carve-out. */ + routeGlobal: boolean; + /** From the scope hook: "" = aggregated (GA only), else a tenant id. */ + selectedTenantId: string; + /** + * From the scope hook. Whenever routeGlobal is false this IS the caller's own tenant + * (non-GA user, or delegated on their home tenant) — the member-path variants use it. + * Both scope hooks return these three fields, so pages pass their scope object verbatim. + */ + effectiveTenantId: string; +} + +/** + * Tenant query param for the /global/ variants: "" (aggregated) → undefined, and anything + * that is not a GUID is dropped too (defense in depth — selections come from the tenant + * list, so a non-GUID value is corrupted state, not intent; mirrors useFleetHealth). + */ +const globalTenantParam = (sel: TenantScopeSelection): string | undefined => + asGuidOrUndefined(sel.selectedTenantId.trim()); + +export const scopedApi = { + fleetHealth: (sel: TenantScopeSelection, days: number) => + sel.routeGlobal ? api.metrics.globalFleetHealth(days, globalTenantParam(sel)) : api.metrics.fleetHealth(days), + + appMetrics: (sel: TenantScopeSelection, days: number) => + sel.routeGlobal ? api.metrics.globalApp(days, globalTenantParam(sel)) : api.metrics.app(sel.effectiveTenantId, days), + + timeAttribution: (sel: TenantScopeSelection) => + sel.routeGlobal ? api.metrics.globalTimeAttribution(globalTenantParam(sel)) : api.metrics.timeAttribution(), + + deviceJourneys: (sel: TenantScopeSelection, days: number) => + sel.routeGlobal ? api.metrics.globalDeviceJourneys(days, globalTenantParam(sel)) : api.metrics.deviceJourneys(days), + + geographic: (sel: TenantScopeSelection, days: number, groupBy: string) => + sel.routeGlobal + ? api.metrics.globalGeographic(days, groupBy, globalTenantParam(sel)) + : api.metrics.geographic(sel.effectiveTenantId, days, groupBy), + + vulnerability: (sel: TenantScopeSelection, days: number, topN: number) => + sel.routeGlobal + ? api.metrics.globalVulnerability(days, topN, globalTenantParam(sel)) + : api.metrics.vulnerability(days, topN), + + appsList: (sel: TenantScopeSelection, days: number) => + sel.routeGlobal ? api.apps.globalList(days, globalTenantParam(sel)) : api.apps.list(sel.effectiveTenantId, days), + + appAnalytics: (sel: TenantScopeSelection, appName: string, days: number) => + sel.routeGlobal + ? api.apps.globalAnalytics(appName, days, globalTenantParam(sel)) + : api.apps.analytics(sel.effectiveTenantId, appName, days), + + appSessions: ( + sel: TenantScopeSelection, + appName: string, + days: number, + status: "all" | "failed" | "succeeded", + offset: number, + limit: number, + opts?: { model?: string; version?: string }, + ) => + sel.routeGlobal + ? api.apps.globalSessions(appName, days, status, offset, limit, globalTenantParam(sel), opts) + : api.apps.sessions(sel.effectiveTenantId, appName, days, status, offset, limit, opts), + + annotationsList: ( + sel: TenantScopeSelection, + opts: { verdict?: string; lane?: string; pageSize?: number; continuation?: string }, + ) => + sel.routeGlobal + ? api.annotations.globalList({ ...opts, tenantId: globalTenantParam(sel) }) + : api.annotations.list(opts), + + auditLogs: ( + sel: TenantScopeSelection, + opts: { dateFrom?: string; dateTo?: string; pageSize?: number; continuation?: string; excludeDeletions?: boolean }, + ) => + sel.routeGlobal + ? api.audit.globalLogs({ ...opts, tenantId: globalTenantParam(sel) }) + : api.audit.logs(opts), +}; diff --git a/src/Web/autopilot-monitor-web/lib/scopedFetch.ts b/src/Web/autopilot-monitor-web/lib/scopedFetch.ts new file mode 100644 index 00000000..0730ee22 --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/scopedFetch.ts @@ -0,0 +1,36 @@ +import { authenticatedFetch } from "./authenticatedFetch"; + +/** + * JSON fetch layer for the scope-migrated pages: authenticatedFetch + ok-check + body + * parse in one call, so call sites stop hand-rolling the response boilerplate. Throws + * ApiError on a non-ok response with the backend's `message` when the body carries one + * (TokenExpiredError from authenticatedFetch passes through untouched). + */ +export class ApiError extends Error { + constructor( + public readonly status: number, + message: string, + ) { + super(message); + this.name = "ApiError"; + } +} + +export async function fetchJson( + url: string, + getAccessToken: (forceRefresh?: boolean) => Promise, + init?: RequestInit, +): Promise { + const response = await authenticatedFetch(url, getAccessToken, init); + if (!response.ok) { + let detail = response.statusText; + try { + const body = await response.json(); + if (body?.message) detail = body.message; + } catch { + /* not JSON */ + } + throw new ApiError(response.status, detail); + } + return (await response.json()) as T; +} diff --git a/src/Web/autopilot-monitor-web/lib/signalrMessages.ts b/src/Web/autopilot-monitor-web/lib/signalrMessages.ts new file mode 100644 index 00000000..bd53e9df --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/signalrMessages.ts @@ -0,0 +1,12 @@ +import { SHARED_MANIFEST } from "@/utils/shared-manifests.generated"; + +/** + * SignalR message (target) names the backend can send — derived from the generated shared + * manifest (source of truth: Constants.SignalRMessages in AutopilotMonitor.Shared). + * SignalRContext types its on/off against this union, so subscribing to a name the backend + * never sends fails tsc instead of silently never firing. Renaming a name backend-side + * regenerates the manifest (SharedManifestParityTests) and breaks stale web literals here. + */ +export const SIGNALR_MESSAGES = SHARED_MANIFEST.signalRMessages; + +export type SignalRMessageName = (typeof SIGNALR_MESSAGES)[number]; diff --git a/src/Web/autopilot-monitor-web/package.json b/src/Web/autopilot-monitor-web/package.json index 44476312..d0101f6c 100644 --- a/src/Web/autopilot-monitor-web/package.json +++ b/src/Web/autopilot-monitor-web/package.json @@ -7,7 +7,7 @@ }, "scripts": { "dev": "next dev", - "prebuild": "node scripts/sync-error-codes.js && node scripts/generate-lastmod.js", + "prebuild": "node scripts/sync-error-codes.js && node scripts/generate-lastmod.js && node scripts/generate-version.js", "sync-error-codes": "node scripts/sync-error-codes.js", "build": "next build", "start": "next start", diff --git a/src/Web/autopilot-monitor-web/scripts/generate-version.js b/src/Web/autopilot-monitor-web/scripts/generate-version.js new file mode 100644 index 00000000..e4fcb794 --- /dev/null +++ b/src/Web/autopilot-monitor-web/scripts/generate-version.js @@ -0,0 +1,45 @@ +/** + * Generates public/version.json — the portal's build stamp, served verbatim at /version.json + * by the static export. Runs in prebuild (locally and inside the Oryx build container). + * + * Commit precedence: GITHUB_SHA env (CI, passed through deploy-web.yml) → git rev-parse + * (local dev; git is available in the Oryx container too, but the env var is authoritative) + * → "unknown". The deploy-web workflow polls the deployed /version.json until `commit` + * matches the pushed SHA — the same verify contract the backend has on /api/health. + * + * The file is gitignored: buildUtc changes every build, it must never be committed. + */ + +const { execSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +const WEB_ROOT = path.resolve(__dirname, ".."); +const OUTPUT_FILE = path.join(WEB_ROOT, "public", "version.json"); + +function resolveCommit() { + const envSha = process.env.GITHUB_SHA; + if (envSha && /^[0-9a-f]{7,40}$/i.test(envSha)) { + return envSha.substring(0, 7).toLowerCase(); + } + try { + const sha = execSync("git rev-parse --short=7 HEAD", { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + if (sha) return sha.toLowerCase(); + } catch { + // fall through — a build without git and without GITHUB_SHA still succeeds + } + return "unknown"; +} + +const manifest = { + component: "web", + commit: resolveCommit(), + buildUtc: new Date().toISOString(), +}; + +fs.mkdirSync(path.dirname(OUTPUT_FILE), { recursive: true }); +fs.writeFileSync(OUTPUT_FILE, JSON.stringify(manifest, null, 2) + "\n", "utf-8"); +console.log(`Generated ${OUTPUT_FILE}: ${manifest.commit} @ ${manifest.buildUtc}`); diff --git a/src/Web/autopilot-monitor-web/staticwebapp.config.json b/src/Web/autopilot-monitor-web/staticwebapp.config.json index b81720b6..72088e7e 100644 --- a/src/Web/autopilot-monitor-web/staticwebapp.config.json +++ b/src/Web/autopilot-monitor-web/staticwebapp.config.json @@ -22,6 +22,8 @@ { "route": "/changelog", "redirect": "https://docs.autopilotmonitor.com/changelog/platform-changelog", "statusCode": 301 }, { "route": "/roadmap", "redirect": "/", "statusCode": 302 }, + { "route": "/version.json", "headers": { "Cache-Control": "no-store" } }, + { "route": "/sessions/index.txt", "rewrite": "/sessions/index.txt" }, { "route": "/sessions/inspector/index.txt", "rewrite": "/sessions/inspector/index.txt" }, { "route": "/diagnosis/index.txt", "rewrite": "/diagnosis/index.txt" }, diff --git a/src/Web/autopilot-monitor-web/utils/__tests__/sharedManifestParity.test.ts b/src/Web/autopilot-monitor-web/utils/__tests__/sharedManifestParity.test.ts index 5f15a3b8..7f857418 100644 --- a/src/Web/autopilot-monitor-web/utils/__tests__/sharedManifestParity.test.ts +++ b/src/Web/autopilot-monitor-web/utils/__tests__/sharedManifestParity.test.ts @@ -100,6 +100,29 @@ describe("session statuses", () => { }); }); +describe("signalR message names", () => { + // The real enforcement is compile-time: SignalRContext.on/off type their event names + // against this manifest section (lib/signalrMessages.ts), so a web subscription to a + // name the backend never sends fails tsc. These tests pin the section's shape so a + // regeneration that drops or duplicates names is caught even before tsc runs. + it("exists with the full backend catalog and no duplicates", () => { + const names = SHARED_MANIFEST.signalRMessages; + expect(names.length).toBeGreaterThanOrEqual(13); + expect(new Set(names).size).toBe(names.length); + // Session-lifecycle names every dashboard hook depends on: + for (const required of ["newSession", "newevents", "eventStream", "sessionDeleted"]) { + expect(names).toContain(required); + } + }); + + it("keeps the single legacy lowercase name and camelCase for the rest", () => { + for (const name of SHARED_MANIFEST.signalRMessages) { + if (name === "newevents") continue; // persisted wire name predating the convention + expect(name).toMatch(/^[a-z]+([A-Z][a-z]*)*$/); + } + }); +}); + describe("gather-rule event-type autocomplete", () => { it("contains no event type the agent/backend does not know", () => { // Typo guard: every autocomplete entry must be a real Constants.EventTypes value. diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts b/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts index 04889a06..5a7775d9 100644 --- a/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts +++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts @@ -639,5 +639,20 @@ export const SHARED_MANIFEST = { "windows_update_reboot_pending", "windows_update_started", "windows_update_succeeded" + ], + "signalRMessages": [ + "newSession", + "newevents", + "eventStream", + "ruleResultsReady", + "vulnerabilityReportReady", + "sessionDeleted", + "tenantNotification", + "tenantNotificationDismissed", + "tenantNotificationsDismissedAll", + "globalNotification", + "globalNotificationDismissed", + "globalNotificationsDismissedAll", + "accessRevoked" ] } as const; diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.json b/src/Web/autopilot-monitor-web/utils/shared-manifests.json index d0aeb77f..9c46066c 100644 --- a/src/Web/autopilot-monitor-web/utils/shared-manifests.json +++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.json @@ -635,5 +635,20 @@ "windows_update_reboot_pending", "windows_update_started", "windows_update_succeeded" + ], + "signalRMessages": [ + "newSession", + "newevents", + "eventStream", + "ruleResultsReady", + "vulnerabilityReportReady", + "sessionDeleted", + "tenantNotification", + "tenantNotificationDismissed", + "tenantNotificationsDismissedAll", + "globalNotification", + "globalNotificationDismissed", + "globalNotificationsDismissedAll", + "accessRevoked" ] }