diff --git a/docs/drift-guards.md b/docs/drift-guards.md index f2c502da..a5afe666 100644 --- a/docs/drift-guards.md +++ b/docs/drift-guards.md @@ -68,6 +68,18 @@ sites the 2026-08-13 audit froze). Three layers enforce it: - **`*WireParityTests`**: each migrated site carries an ordinal old-anonymous-literal vs. new-DTO serialization proof (production `ApiJsonOptions`), including a null case per key that WhenWritingNull omits. +- **Object-slot ratchet** (2026-08-31 typisierung follow-up): a reflection walk over + the reachable wire-type graph fails on any `object` / collection-of-`object` + property outside `ObjectSlotBaseline` (sole entry: `RuleDryRunCondition.Evidence`, + heterogeneous by design). `[ProjectedItems]` slots and dictionary VALUES are exempt. + The former deliberately-untyped slots (RuleDryRun trace, HealthCheck, + GlobalNotificationDto, TenantConfigFieldSchema, maintenance run reports, + MetricsSummary items, auth/me, auth/mcp) are concrete Shared types now. The raw + table entities left the wire entirely in the same pass: global-admins, + tenant-admins and preview-whitelist serve flat rows (`GlobalAdminRow` / + `TenantAdminRow` / `PreviewWhitelistTenantEntry`) WITHOUT + partitionKey/rowKey/eTag/timestamp — a deliberate wire change pinned by exact-JSON + shape tests instead of parity facts. The DTO rules: declaration order == wire order; a property is nullable exactly when a site can emit null (the key then vanishes — never add non-null defaults that would diff --git a/docs/log.md b/docs/log.md index d85a1a8d..d8a2f6dd 100644 --- a/docs/log.md +++ b/docs/log.md @@ -1,5 +1,9 @@ # Log +## 2026-08-31 (2) + +* **Update**: `drift-guards.md` — typisierung follow-up: the deliberately-untyped remainder (RuleDryRun trace, HealthCheck, GlobalNotificationDto, TenantConfigFieldSchema, maintenance run reports, MetricsSummary items, auth/me, auth/mcp) is typed end-to-end; a new object-slot reflection ratchet in `TypedResponseGuardTests` closes the class (baseline: `RuleDryRunCondition.Evidence` only). Deliberate wire change: global-admins / tenant-admins / preview-whitelist rows are flat read models (`GlobalAdminRow` / `TenantAdminRow` / `PreviewWhitelistTenantEntry`) without partitionKey/rowKey/eTag/timestamp, pinned by exact-JSON shape tests. + ## 2026-08-31 (1) * **Update**: `drift-guards.md` — the shrink-only OkAsync baseline became the typed API contract (`feat/typed-api-contract`): all 175 anonymous success bodies (44 helper + 134 raw minus overlaps already typed) are flat `IApiResponse` DTOs in Shared, both `TypedResponseGuardTests` baselines are empty regression guards, the untyped `OkAsync/CreatedAsync(object)` overloads are gone, and every migrated site carries an ordinal wire-parity proof. `SharedManifestParityTests` schemaVersion 2 exports the full type graph (208 types incl. `[WireContract]` extras) with C# `` JSDoc into `utils/wire-types.generated.ts`; web hand-types re-export from it; freshness pinned by vitest + the new `shared-manifests-in-sync` CI job. diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AdminWireParityTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AdminWireParityTests.cs index a127ffdf..daf0b581 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/AdminWireParityTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/AdminWireParityTests.cs @@ -305,9 +305,9 @@ public class AdminWireParityTests // ---- BackfillOccurredUtc / ReclassifyLegacySessions (shared envelope) ---------------- [Fact] - public void MaintenanceJobRunResponse_matches_the_backfill_shape() + public void BackfillJobRunResponse_matches_the_backfill_shape() { - var result = new OccurredUtcBackfillService.BackfillResult + var result = new BackfillResult { Table = "audit", DryRun = true, @@ -330,7 +330,7 @@ public class AdminWireParityTests triggeredBy = userEmail, triggeredAt, }, - new MaintenanceJobRunResponse + new BackfillJobRunResponse { Success = true, Result = result, @@ -340,9 +340,9 @@ public class AdminWireParityTests } [Fact] - public void MaintenanceJobRunResponse_matches_the_reclassify_shape() + public void ReclassifyJobRunResponse_matches_the_reclassify_shape() { - var result = new LegacyReclassificationService.ReclassificationResult + var result = new ReclassificationResult { Mode = "legacy_timeouts", DryRun = false, @@ -368,7 +368,7 @@ public class AdminWireParityTests triggeredBy = userEmail, triggeredAt, }, - new MaintenanceJobRunResponse + new ReclassifyJobRunResponse { Success = true, Result = result, @@ -768,14 +768,15 @@ public class AdminWireParityTests } // ---- TenantAdminManagement (AddTenantAdmin) ------------------------------------------ + // DELIBERATE wire change (2026-08-31 entity-hygiene pass): the row is TenantAdminRow, not + // the raw TenantAdminEntity — partitionKey/rowKey/eTag/timestamp are gone from the wire. + // These pins fix the NEW shape exactly (key names, order, table-key absence). [Fact] - public void TenantAdminCreatedResponse_matches_the_created_member_shape() + public void TenantAdminCreatedResponse_pins_the_created_member_shape() { - var newAdmin = new TenantAdminEntity + var newAdmin = new TenantAdminRow { - PartitionKey = SampleTenantId, - RowKey = "operator@contoso.com", TenantId = SampleTenantId, Upn = "operator@contoso.com", IsEnabled = true, @@ -784,9 +785,46 @@ public class AdminWireParityTests Role = "Operator", }; - AssertParity( - new { admin = newAdmin }, - new TenantAdminCreatedResponse { Admin = newAdmin }); + Assert.Equal( + "{\"admin\":{" + + $"\"tenantId\":\"{SampleTenantId}\"," + + "\"upn\":\"operator@contoso.com\"," + + "\"isEnabled\":true," + + "\"addedDate\":\"2026-08-30T14:00:00Z\"," + + "\"addedBy\":\"admin@contoso.com\"," + + "\"role\":\"Operator\"," + + "\"canManageBootstrapTokens\":false}}", + TestWire.Serialize(new TenantAdminCreatedResponse { Admin = newAdmin })); + } + + [Fact] + public void TenantAdminRow_omits_a_null_role_and_never_emits_table_keys() + { + // Legacy pre-role rows have Role = null → the key vanishes (WhenWritingNull), and the + // ITableEntity keys of the storage entity must never reappear on the wire. + var row = new TenantAdminRow + { + TenantId = SampleTenantId, + Upn = "legacy@contoso.com", + IsEnabled = true, + AddedDate = new DateTime(2026, 8, 30, 14, 5, 0, DateTimeKind.Utc), + AddedBy = "admin@contoso.com", + Role = null, + }; + + var json = TestWire.Serialize(row); + Assert.Equal( + $"{{\"tenantId\":\"{SampleTenantId}\"," + + "\"upn\":\"legacy@contoso.com\"," + + "\"isEnabled\":true," + + "\"addedDate\":\"2026-08-30T14:05:00Z\"," + + "\"addedBy\":\"admin@contoso.com\"," + + "\"canManageBootstrapTokens\":false}", + json); + Assert.DoesNotContain("partitionKey", json); + Assert.DoesNotContain("rowKey", json); + Assert.DoesNotContain("eTag", json); + Assert.DoesNotContain("timestamp", json); } // ---- TenantGroupManagement ----------------------------------------------------------- diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionSideEffectTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionSideEffectTests.cs index 0548a6c0..98d82956 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionSideEffectTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionSideEffectTests.cs @@ -435,7 +435,7 @@ public class AuthFunctionSideEffectTests { _tenantAdminsMock .Setup(x => x.AddTenantAdminAsync(TenantId, Upn, "System")) - .ReturnsAsync(new TenantAdminEntity()); + .ReturnsAsync(new TenantAdminRow()); var decision = AuthDecisionResult.Success(new { }, needsAutoAdmin: true); @@ -473,7 +473,7 @@ public class AuthFunctionSideEffectTests { _tenantAdminsMock .Setup(x => x.AddTenantAdminAsync(TenantId, Upn, "System")) - .ReturnsAsync(new TenantAdminEntity()); + .ReturnsAsync(new TenantAdminRow()); var decision = AuthDecisionResult.Success(new { }, needsAutoAdmin: true); diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionTests.cs index 3f300c77..ea4c2bc1 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionTests.cs @@ -471,7 +471,8 @@ public class AuthFunctionTests // ------------------------------------------------------------------------- /// - /// Converts anonymous object to dynamic for property access in tests. + /// Exposes a body (typed DTO or anonymous error shape) as dynamic under its WIRE key names + /// (camelCase), so the asserts read the same casing a client would. /// private static dynamic ToDynamic(object obj) { @@ -479,7 +480,7 @@ public class AuthFunctionTests var dict = new System.Dynamic.ExpandoObject() as IDictionary; foreach (var prop in type.GetProperties()) { - dict[prop.Name] = prop.GetValue(obj); + dict[System.Text.Json.JsonNamingPolicy.CamelCase.ConvertName(prop.Name)] = prop.GetValue(obj); } return dict; } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/ConfigWireParityTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/ConfigWireParityTests.cs index 513374c9..19515bb4 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/ConfigWireParityTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/ConfigWireParityTests.cs @@ -224,23 +224,23 @@ public class ConfigWireParityTests IReadOnlyList schema = new List { new TenantConfigFieldSchema( - Name: "contactEmail", - Type: "string", - Format: "email", - Nullable: true, - Writable: true, - Reason: null, - GaOnly: false, - RevertProtected: false), + name: "contactEmail", + type: "string", + format: "email", + nullable: true, + writable: true, + reason: null, + gaOnly: false, + revertProtected: false), new TenantConfigFieldSchema( - Name: "homedAppClientId", - Type: "string", - Format: null, - Nullable: true, - Writable: false, - Reason: "system-owned", - GaOnly: true, - RevertProtected: true), + name: "homedAppClientId", + type: "string", + format: null, + nullable: true, + writable: false, + reason: "system-owned", + gaOnly: true, + revertProtected: true), }; AssertParity( diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/InfrastructureWireParityTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/InfrastructureWireParityTests.cs index 15883caf..c232613b 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/InfrastructureWireParityTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/InfrastructureWireParityTests.cs @@ -16,6 +16,107 @@ namespace AutopilotMonitor.Functions.Tests; /// public class InfrastructureWireParityTests { + // ---- AuthFunction: auth/me success body ---------------------------------------------- + // Left side: the exact anonymous literal BuildAuthResult produced before AuthMeResponse. + + [Fact] + public void AuthMeResponse_matches_the_success_shape() + { + var tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d"; + var upn = "admin@contoso.com"; + var displayName = "Admin User"; + var objectId = "0b6f7a37-1111-4d61-9c93-0aa111111111"; + var managedTenantIds = new[] { "7b7b46b3-40c3-4f2f-9a1b-6d9f1a2b3c4e" }; + + AssertParity( + new + { + tenantId, + upn, + displayName, + objectId, + isGlobalAdmin = false, + isGlobalReader = false, + isTenantAdmin = true, + isDelegated = true, + delegatedTenantIds = managedTenantIds, + role = "Admin", + canManageBootstrapTokens = true, + hasMcpAccess = true, + homedApp = "primary", + bootstrapTokenEnabled = true, + unrestrictedModeEnabled = false + }, + new AuthMeResponse + { + TenantId = tenantId, + Upn = upn, + DisplayName = displayName, + ObjectId = objectId, + IsGlobalAdmin = false, + IsGlobalReader = false, + IsTenantAdmin = true, + IsDelegated = true, + DelegatedTenantIds = managedTenantIds, + Role = "Admin", + CanManageBootstrapTokens = true, + HasMcpAccess = true, + HomedApp = "primary", + BootstrapTokenEnabled = true, + UnrestrictedModeEnabled = false + }); + } + + [Fact] + public void AuthMeResponse_omits_a_null_role() + { + // A roleless caller (e.g. pure GlobalReader): role was null in the anonymous literal, + // so the key vanished — the DTO must vanish it identically. + var tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d"; + var upn = "reader@contoso.com"; + var displayName = "Reader User"; + var objectId = "0b6f7a37-2222-4d61-9c93-0aa222222222"; + string? role = null; + + AssertParity( + new + { + tenantId, + upn, + displayName, + objectId, + isGlobalAdmin = false, + isGlobalReader = true, + isTenantAdmin = false, + isDelegated = false, + delegatedTenantIds = Array.Empty(), + role, + canManageBootstrapTokens = false, + hasMcpAccess = true, + homedApp = "legacy", + bootstrapTokenEnabled = false, + unrestrictedModeEnabled = false + }, + new AuthMeResponse + { + TenantId = tenantId, + Upn = upn, + DisplayName = displayName, + ObjectId = objectId, + IsGlobalAdmin = false, + IsGlobalReader = true, + IsTenantAdmin = false, + IsDelegated = false, + DelegatedTenantIds = Array.Empty(), + Role = null, + CanManageBootstrapTokens = false, + HasMcpAccess = true, + HomedApp = "legacy", + BootstrapTokenEnabled = false, + UnrestrictedModeEnabled = false + }); + } + // ---- AuthFunction: IsGlobalAdmin ----------------------------------------------------- [Fact] @@ -40,26 +141,111 @@ public class InfrastructureWireParityTests new IsGlobalAdminResponse { IsGlobalAdmin = isAdmin, Upn = null }); } - // ---- AuthFunction: GetGlobalAdmins --------------------------------------------------- + // ---- McpUserFunction: CheckMcpAccess (auth/mcp) -------------------------------------- + // Left side: the exact Dictionary the site built before the DTO — + // insertion order == wire order, conditional keys only present when the tier applies. [Fact] - public void GetGlobalAdminsResponse_matches_the_entity_listing_shape() + public void CheckMcpAccessResponse_matches_the_plain_user_shape() { - var admins = new List + var payload = new Dictionary { - new GlobalAdminEntity + ["allowed"] = true, + ["upn"] = "analyst@contoso.com", + ["accessGrant"] = "whitelist", + ["reason"] = "whitelisted", + }; + + AssertParity( + payload, + new CheckMcpAccessResponse + { + Allowed = true, + Upn = "analyst@contoso.com", + AccessGrant = "whitelist", + Reason = "whitelisted", + }); + } + + [Fact] + public void CheckMcpAccessResponse_matches_the_platform_and_delegated_shape() + { + var delegatedTenantIds = new[] { "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d" }; + var payload = new Dictionary + { + ["allowed"] = true, + ["upn"] = "admin@contoso.com", + ["accessGrant"] = "global-admin", + ["reason"] = "platform role", + ["isGlobalAdmin"] = true, + ["globalRole"] = "GlobalAdmin", + ["delegatedTenantIds"] = delegatedTenantIds, + ["delegatedRole"] = "DelegatedAdmin", + }; + + AssertParity( + payload, + new CheckMcpAccessResponse + { + Allowed = true, + Upn = "admin@contoso.com", + AccessGrant = "global-admin", + Reason = "platform role", + IsGlobalAdmin = true, + GlobalRole = "GlobalAdmin", + DelegatedTenantIds = delegatedTenantIds, + DelegatedRole = "DelegatedAdmin", + }); + } + + [Fact] + public void CheckMcpAccessResponse_matches_the_denied_shape() + { + // 403 body: same shape, allowed=false, no conditional keys. isGlobalAdmin must never + // appear as false — the DTO's null slot omits it exactly like the old dictionary did. + var payload = new Dictionary + { + ["allowed"] = false, + ["upn"] = "user@contoso.com", + ["accessGrant"] = "", + ["reason"] = "not whitelisted", + }; + + AssertParity( + payload, + new CheckMcpAccessResponse + { + Allowed = false, + Upn = "user@contoso.com", + AccessGrant = "", + Reason = "not whitelisted", + IsGlobalAdmin = null, + GlobalRole = null, + DelegatedTenantIds = null, + DelegatedRole = null, + }); + } + + // ---- AuthFunction: GetGlobalAdmins / AddGlobalAdmin ---------------------------------- + // DELIBERATE wire change (2026-08-31 entity-hygiene pass): rows are GlobalAdminRow, not the + // raw GlobalAdminEntity — partitionKey/rowKey/eTag/timestamp are gone from the wire. + // These pins fix the NEW shape exactly (key names, order, table-key absence). + + [Fact] + public void GetGlobalAdminsResponse_pins_the_row_listing_shape() + { + var admins = new List + { + new GlobalAdminRow { - RowKey = "admin@contoso.com", - Timestamp = new DateTimeOffset(2026, 8, 30, 10, 0, 0, TimeSpan.Zero), Upn = "admin@contoso.com", IsEnabled = true, AddedDate = new DateTime(2026, 8, 1, 9, 0, 0, DateTimeKind.Utc), AddedBy = "root@fabrikam.com", Role = "GlobalAdmin", }, - new GlobalAdminEntity + new GlobalAdminRow { - RowKey = "reader@fabrikam.com", Upn = "reader@fabrikam.com", IsEnabled = false, AddedDate = new DateTime(2026, 8, 2, 9, 0, 0, DateTimeKind.Utc), @@ -68,19 +254,24 @@ public class InfrastructureWireParityTests }, }; - AssertParity( - new { admins }, - new GetGlobalAdminsResponse { Admins = admins }); + var json = TestWire.Serialize(new GetGlobalAdminsResponse { Admins = admins }); + Assert.Equal( + "{\"admins\":[" + + "{\"upn\":\"admin@contoso.com\",\"isEnabled\":true,\"addedDate\":\"2026-08-01T09:00:00Z\",\"addedBy\":\"root@fabrikam.com\",\"role\":\"GlobalAdmin\"}," + + "{\"upn\":\"reader@fabrikam.com\",\"isEnabled\":false,\"addedDate\":\"2026-08-02T09:00:00Z\",\"addedBy\":\"admin@contoso.com\",\"role\":\"GlobalReader\"}" + + "]}", + json); + Assert.DoesNotContain("partitionKey", json); + Assert.DoesNotContain("rowKey", json); + Assert.DoesNotContain("eTag", json); + Assert.DoesNotContain("timestamp", json); } - // ---- AuthFunction: AddGlobalAdmin ---------------------------------------------------- - [Fact] - public void AddGlobalAdminResponse_matches_the_created_entity_shape() + public void AddGlobalAdminResponse_pins_the_created_row_shape() { - var newAdmin = new GlobalAdminEntity + var newAdmin = new GlobalAdminRow { - RowKey = "new.admin@contoso.com", Upn = "new.admin@contoso.com", IsEnabled = true, AddedDate = new DateTime(2026, 8, 30, 12, 0, 0, DateTimeKind.Utc), @@ -88,9 +279,9 @@ public class InfrastructureWireParityTests Role = "GlobalAdmin", }; - AssertParity( - new { admin = newAdmin }, - new AddGlobalAdminResponse { Admin = newAdmin }); + Assert.Equal( + "{\"admin\":{\"upn\":\"new.admin@contoso.com\",\"isEnabled\":true,\"addedDate\":\"2026-08-30T12:00:00Z\",\"addedBy\":\"admin@contoso.com\",\"role\":\"GlobalAdmin\"}}", + TestWire.Serialize(new AddGlobalAdminResponse { Admin = newAdmin })); } // ---- HealthCheckFunction: HealthCheck ------------------------------------------------ diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/MetricsWireParityTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/MetricsWireParityTests.cs index 82a16ff2..f3fda112 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/MetricsWireParityTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/MetricsWireParityTests.cs @@ -560,8 +560,9 @@ public class MetricsWireParityTests public void MetricsSummaryResponse_matches_the_tally_shape() { var days = 30; - // GetMetricsSummaryAsync returns List of repository-built per-tenant rows - // (shape mirrored from TableStorageService.GetMetricsSummaryAsync). + // Left: the exact anonymous per-tenant row GetMetricsSummaryAsync built before the + // MetricsSummaryTenantItem substitution — proves the DTO item is wire-identical + // (incl. the windowDays duplicate on every item). var summary = new List { new @@ -586,7 +587,24 @@ public class MetricsWireParityTests new MetricsSummaryResponse { Success = true, - Summary = summary, + Summary = new List + { + new MetricsSummaryTenantItem + { + TenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d", + TotalSessions = 10, + Succeeded = 6, + Failed = 2, + InProgress = 1, + Pending = 0, + Stalled = 0, + AwaitingUser = 0, + Incomplete = 1, + Other = 0, + FailureRate = 25.0, + WindowDays = days, + }, + }, WindowDays = days, }); } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/PreviewNotificationEmailWriteGateTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/PreviewNotificationEmailWriteGateTests.cs index 5e6d5fce..dba1d82c 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/PreviewNotificationEmailWriteGateTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/PreviewNotificationEmailWriteGateTests.cs @@ -6,6 +6,7 @@ using AutopilotMonitor.Functions.Security; using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Shared; using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Moq; @@ -79,7 +80,7 @@ public class PreviewNotificationEmailWriteGateTests var admins = new Mock( Mock.Of(), Mock.Of(), Mock.Of>()) { CallBase = false }; - admins.Setup(a => a.GetTenantAdminsAsync(TenantId)).ReturnsAsync(new List + admins.Setup(a => a.GetTenantAdminsAsync(TenantId)).ReturnsAsync(new List { new() { TenantId = TenantId, Upn = "former@contoso.com", IsEnabled = false, Role = Constants.TenantRoles.Admin }, }); @@ -93,9 +94,9 @@ public class PreviewNotificationEmailWriteGateTests var admins = new Mock( Mock.Of(), Mock.Of(), Mock.Of>()) { CallBase = false }; - var members = new List(); + var members = new List(); if (enabledMember) - members.Add(new TenantAdminEntity { TenantId = TenantId, Upn = "admin@contoso.com", IsEnabled = true, Role = Constants.TenantRoles.Admin }); + members.Add(new TenantAdminRow { TenantId = TenantId, Upn = "admin@contoso.com", IsEnabled = true, Role = Constants.TenantRoles.Admin }); admins.Setup(a => a.GetTenantAdminsAsync(TenantId)).ReturnsAsync(members); return admins; } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/RulesWireParityTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/RulesWireParityTests.cs index 3d387d4f..b15d1610 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/RulesWireParityTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/RulesWireParityTests.cs @@ -302,24 +302,29 @@ public class RulesWireParityTests } // ---- GetPreviewWhitelist ------------------------------------------------------------- + // DELIBERATE wire change (2026-08-31 entity-hygiene pass): rows carried the tenant id only + // in partitionKey plus garbage entity defaults; the contract is now { tenantId } per row. [Fact] - public void GetPreviewWhitelistResponse_matches_the_approved_tenant_listing_shape() + public void GetPreviewWhitelistResponse_pins_the_approved_tenant_listing_shape() { - var approved = new List + var approved = new List { - new PreviewWhitelistEntity - { - PartitionKey = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d", - RowKey = "approved", - ApprovedAt = new DateTime(2026, 8, 30, 9, 30, 0, DateTimeKind.Utc), - ApprovedBy = "admin@contoso.com", - }, + new PreviewWhitelistTenantEntry { TenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d" }, + new PreviewWhitelistTenantEntry { TenantId = "7b7b46b3-40c3-4f2f-9a1b-6d9f1a2b3c4e" }, }; - AssertParity( - new { tenants = approved }, - new GetPreviewWhitelistResponse { Tenants = approved }); + var json = TestWire.Serialize(new GetPreviewWhitelistResponse { Tenants = approved }); + Assert.Equal( + "{\"tenants\":[" + + "{\"tenantId\":\"6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d\"}," + + "{\"tenantId\":\"7b7b46b3-40c3-4f2f-9a1b-6d9f1a2b3c4e\"}" + + "]}", + json); + Assert.DoesNotContain("partitionKey", json); + Assert.DoesNotContain("rowKey", json); + Assert.DoesNotContain("eTag", json); + Assert.DoesNotContain("approvedAt", json); } // ---- GetPreviewNotificationEmail ----------------------------------------------------- diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TypedResponseGuardTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TypedResponseGuardTests.cs index 6107c3a8..e56e3f08 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/TypedResponseGuardTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TypedResponseGuardTests.cs @@ -176,6 +176,101 @@ public class TypedResponseGuardTests + string.Join("\n ", nonFlat)); } + // ── Object-slot ratchet (closed 2026-08-31, typisierung follow-up) ────────────────── + // Regexes A-E police the FUNCTION side; this one polices the DTOs themselves: a Shared + // wire type carrying an `object` (or collection-of-object) property hides that slot's + // shape from the manifest exactly like an anonymous body would. `[ProjectedItems]` slots + // (fields=-projections) and dictionary VALUES (heterogeneous bags by design, e.g. + // HealthCheck.Details) are exempt. + + /// + /// The deliberately-open object slots. Every other object-typed property on a reachable + /// wire type is a regression: give the slot a concrete Shared type. + /// + private static readonly HashSet ObjectSlotBaseline = new(StringComparer.Ordinal) + { + // Matched: heterogeneous evidence dictionary; not matched: the evaluator's reason string. + "AutopilotMonitor.Shared.Models.RuleDryRunCondition.Evidence", + }; + + [Fact] + public void No_reachable_wire_type_property_is_object_typed_outside_the_baseline() + { + var shared = typeof(IApiResponse).Assembly; + var roots = shared.GetTypes().Where(t => + !t.IsInterface && !t.IsAbstract && + (typeof(IApiResponse).IsAssignableFrom(t) || + t.GetCustomAttributes(typeof(WireContractAttribute), inherit: false).Length > 0)); + + var seen = new HashSet(); + var queue = new Queue(roots); + var offenders = new List(); + var baselineHits = new HashSet(StringComparer.Ordinal); + + while (queue.Count > 0) + { + var type = queue.Dequeue(); + if (!seen.Add(type)) + continue; + + foreach (var prop in type.GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)) + { + if (prop.GetCustomAttributes(typeof(ProjectedItemsAttribute), inherit: false).Length > 0) + continue; // fields=-projection slot: IReadOnlyList by design. + + var slotType = prop.PropertyType; + var elementType = EnumerableElementType(slotType); + var effective = elementType ?? slotType; + + if (effective == typeof(object)) + { + var slot = $"{type.FullName}.{prop.Name}"; + if (ObjectSlotBaseline.Contains(slot)) + baselineHits.Add(slot); + else + offenders.Add(slot); + } + else if (effective.Assembly == shared && effective.IsClass && effective != typeof(string)) + { + queue.Enqueue(effective); + } + } + } + + Assert.True(offenders.Count == 0, + "object-typed properties on reachable wire types (shape invisible to the manifest) — " + + "give the slot a concrete Shared type or document it in ObjectSlotBaseline:\n " + + string.Join("\n ", offenders.OrderBy(s => s, StringComparer.Ordinal))); + + // Ratchet down: a baseline entry whose slot no longer exists (or is no longer object) + // must be removed, so the documented-debt list stays truthful. + var stale = ObjectSlotBaseline.Except(baselineHits).ToList(); + Assert.True(stale.Count == 0, + "ObjectSlotBaseline entries that no longer match an object-typed slot — remove them:\n " + + string.Join("\n ", stale)); + } + + /// + /// Element type of a generic IEnumerable<T> slot (arrays included), EXCEPT dictionaries — + /// dictionary values are heterogeneous bags by design and stay exempt. Null for scalars. + /// + private static Type? EnumerableElementType(Type type) + { + if (type == typeof(string)) + return null; + if (type.IsArray) + return type.GetElementType(); + var enumerable = new[] { type }.Concat(type.GetInterfaces()) + .FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>)); + if (enumerable == null) + return null; + var element = enumerable.GetGenericArguments()[0]; + // KeyValuePair element ⇒ the slot is a dictionary; values are exempt by design. + if (element.IsGenericType && element.GetGenericTypeDefinition() == typeof(KeyValuePair<,>)) + return null; + return element; + } + /// /// Error shape = first property of the anonymous object is error or message /// (assigned or C# shorthand), or the literal success = false. Everything else — diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMetricsRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMetricsRepository.cs index 2f2c0fae..1b46675e 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMetricsRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMetricsRepository.cs @@ -98,7 +98,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage public Task DeleteUserPresenceOlderThanAsync(DateTime cutoffUtc) => _storage.DeleteUserPresenceOlderThanAsync(cutoffUtc); - public Task> GetMetricsSummaryAsync(string? tenantId, int days = 30) + public Task> GetMetricsSummaryAsync(string? tenantId, int days = 30) => _storage.GetMetricsSummaryAsync(tenantId, days); public Task IncrementRuleStatAsync(string date, string tenantId, string ruleId, string ruleType, diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/BackfillOccurredUtcFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/BackfillOccurredUtcFunction.cs index ab03a08d..05032f16 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/BackfillOccurredUtcFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/BackfillOccurredUtcFunction.cs @@ -83,7 +83,7 @@ namespace AutopilotMonitor.Functions.Functions.Admin var result = await _service.RunAsync(table, dryRun, maxRows, continuation); var response = req.CreateResponse(HttpStatusCode.OK); - await response.WriteAsJsonAsync(new MaintenanceJobRunResponse + await response.WriteAsJsonAsync(new BackfillJobRunResponse { Success = true, Result = result, diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/ReclassifyLegacySessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/ReclassifyLegacySessionsFunction.cs index 75958b09..4cf47d41 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/ReclassifyLegacySessionsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/ReclassifyLegacySessionsFunction.cs @@ -101,7 +101,7 @@ namespace AutopilotMonitor.Functions.Functions.Admin } var response = req.CreateResponse(HttpStatusCode.OK); - await response.WriteAsJsonAsync(new MaintenanceJobRunResponse + await response.WriteAsJsonAsync(new ReclassifyJobRunResponse { Success = true, Result = result, diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs index 4211dcb3..2ed5c6f3 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs @@ -275,7 +275,7 @@ public class AuthFunction return unresolved; } - GlobalAdminEntity newAdmin; + GlobalAdminRow newAdmin; try { newAdmin = await _globalAdminService.AddGlobalAdminAsync(body.Upn, currentUpn!, identity.Value.TenantId, identity.Value.ObjectId); @@ -588,30 +588,30 @@ public class AuthFunction string? role = needsAutoAdmin ? Constants.TenantRoles.Admin : memberRole?.Role; bool canManageBootstrapTokens = needsAutoAdmin || (memberRole?.CanManageBootstrapTokens ?? false); - return AuthDecisionResult.Success(new + return AuthDecisionResult.Success(new AuthMeResponse { - tenantId, - upn, - displayName, - objectId, - isGlobalAdmin, - isGlobalReader, - isTenantAdmin, + TenantId = tenantId, + Upn = upn, + DisplayName = displayName, + ObjectId = objectId, + IsGlobalAdmin = isGlobalAdmin, + IsGlobalReader = isGlobalReader, + IsTenantAdmin = isTenantAdmin, // Delegated ("MSP") scope: the OTHER tenants this caller may manage (read-only this phase) and a // convenience flag. The web app uses these for fleet/switcher UI, bounded to this set. - isDelegated, - delegatedTenantIds = managedTenantIds, - role, - canManageBootstrapTokens, - hasMcpAccess = mcpCheck.IsAllowed, + IsDelegated = isDelegated, + DelegatedTenantIds = managedTenantIds, + Role = role, + CanManageBootstrapTokens = canManageBootstrapTokens, + HasMcpAccess = mcpCheck.IsAllowed, // Dual app-reg window: which app registration this tenant is homed on. The web app // stores this per browser (localStorage) so the NEXT login uses the right app — // the deferred, no-roundtrip learning mechanism of the parallel operation model. - homedApp, + HomedApp = homedApp, // EFFECTIVE availability flags (drive sidebar/section visibility): bootstrap is // included in Pro, Unrestricted Mode requires Pro + the GA on-request gate. - bootstrapTokenEnabled = TenantEntitlementService.IsBootstrapEnabled(tenantConfig, DateTime.UtcNow), - unrestrictedModeEnabled = + BootstrapTokenEnabled = TenantEntitlementService.IsBootstrapEnabled(tenantConfig, DateTime.UtcNow), + UnrestrictedModeEnabled = FeatureEntitlementCatalog.Get(TenantEntitlementService.ResolveEdition(tenantConfig, DateTime.UtcNow)).UnrestrictedModeAvailable && tenantConfig.UnrestrictedModeEnabled }, needsAutoAdmin); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/McpUserFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/McpUserFunction.cs index 32a56ae4..ff839546 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/McpUserFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/McpUserFunction.cs @@ -203,38 +203,23 @@ public class McpUserFunction var result = await _mcpUserService.IsAllowedAsync(upn, principal?.GetTenantId(), principal?.GetObjectId()); var response = req.CreateResponse(result.IsAllowed ? HttpStatusCode.OK : HttpStatusCode.Forbidden); - var payload = new Dictionary + // Only surface platform-role flags when the caller actually has one (null ⇒ key omitted). + // A normal tenant user gets neither field: the MCP access-guard reads `globalRole` to decide + // cross-tenant routing (and keeps reading `isGlobalAdmin === true` for back-compat / + // write-tier hints), and we avoid hinting to ordinary callers that a platform tier even + // exists. delegatedTenantIds bounds cross-tenant routing (/api/global/*?tenantId=) + // to exactly these tenants and is only emitted for a caller holding a delegated assignment. + await response.WriteAsJsonAsync(new CheckMcpAccessResponse { - ["allowed"] = result.IsAllowed, - ["upn"] = result.Upn, - ["accessGrant"] = result.AccessGrant, - ["reason"] = result.Reason, - }; - // Only surface platform-role flags when the caller actually has one. A normal tenant user gets - // neither field: the MCP access-guard reads `globalRole` to decide cross-tenant routing (and - // keeps reading `isGlobalAdmin === true` for back-compat / write-tier hints), and we avoid - // hinting to ordinary callers that a platform tier even exists. - if (result.IsGlobalAdmin) - { - payload["isGlobalAdmin"] = true; - } - if (!string.IsNullOrEmpty(result.GlobalRole)) - { - payload["globalRole"] = result.GlobalRole; // "GlobalAdmin" | "GlobalReader" - } - // Delegated (scoped-global / MSP) scope, when present. The MCP access-guard reads - // delegatedTenantIds to route the caller cross-tenant (/api/global/*?tenantId=) bounded to - // exactly these tenants, and to reject any tool call that does not name one of them. Only emitted - // for a caller that actually holds a delegated assignment — ordinary tenant users get neither field. - if (result.DelegatedTenantIds is { Count: > 0 }) - { - payload["delegatedTenantIds"] = result.DelegatedTenantIds; // string[] (lowercase) - } - if (!string.IsNullOrEmpty(result.DelegatedRole)) - { - payload["delegatedRole"] = result.DelegatedRole; // "DelegatedAdmin" | "DelegatedReader" - } - await response.WriteAsJsonAsync(payload); + Allowed = result.IsAllowed, + Upn = result.Upn, + AccessGrant = result.AccessGrant, + Reason = result.Reason, + IsGlobalAdmin = result.IsGlobalAdmin ? true : null, + GlobalRole = string.IsNullOrEmpty(result.GlobalRole) ? null : result.GlobalRole, + DelegatedTenantIds = result.DelegatedTenantIds is { Count: > 0 } ? result.DelegatedTenantIds : null, + DelegatedRole = string.IsNullOrEmpty(result.DelegatedRole) ? null : result.DelegatedRole, + }); return response; } } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/GlobalAdminService.cs b/src/Backend/AutopilotMonitor.Functions/Services/GlobalAdminService.cs index ffb055ad..43f9408c 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/GlobalAdminService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/GlobalAdminService.cs @@ -1,6 +1,7 @@ using AutopilotMonitor.Functions.Security; using AutopilotMonitor.Shared; using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; using Azure.Data.Tables; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; @@ -109,7 +110,7 @@ public class GlobalAdminService /// The Entra tenant the person signs in from (JWT tid). /// The person's Entra object id, or null to pin it on their first sign-in. /// The UPN is already bound to a different identity. - public async Task AddGlobalAdminAsync(string upn, string addedBy, string homeTenantId, string? objectId) + public async Task AddGlobalAdminAsync(string upn, string addedBy, string homeTenantId, string? objectId) { upn = upn.ToLowerInvariant(); addedBy = addedBy.ToLowerInvariant(); @@ -120,10 +121,8 @@ public class GlobalAdminService // Invalidate cache _cache.Remove($"global-role:{upn}"); - return new GlobalAdminEntity + return new GlobalAdminRow { - PartitionKey = "GlobalAdmins", - RowKey = upn, Upn = upn, IsEnabled = true, AddedDate = DateTime.UtcNow, @@ -161,14 +160,12 @@ public class GlobalAdminService /// /// Gets all Global Admins /// - public async Task> GetAllGlobalAdminsAsync() + public async Task> GetAllGlobalAdminsAsync() { var entries = await _adminRepo.GetAllGlobalAdminsAsync(); - return entries.Select(e => new GlobalAdminEntity + return entries.Select(e => new GlobalAdminRow { - PartitionKey = "GlobalAdmins", - RowKey = e.Upn, Upn = e.Upn, IsEnabled = e.IsEnabled, AddedDate = e.AddedAt, diff --git a/src/Backend/AutopilotMonitor.Functions/Services/GlobalNotificationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/GlobalNotificationService.cs index 5e8d8332..901063f9 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/GlobalNotificationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/GlobalNotificationService.cs @@ -1,4 +1,5 @@ using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; using Azure; using Azure.Data.Tables; using Microsoft.Extensions.Logging; @@ -25,18 +26,8 @@ public class GlobalNotificationEntity : ITableEntity public bool Dismissed { get; set; } } -/// -/// DTO returned by the API for global notifications. -/// -public class GlobalNotificationDto -{ - public string Id { get; set; } = string.Empty; - public string Type { get; set; } = "info"; - public string Title { get; set; } = string.Empty; - public string Message { get; set; } = string.Empty; - public string? Href { get; set; } - public DateTime CreatedAt { get; set; } -} +// GlobalNotificationDto is wire contract and lives in AutopilotMonitor.Shared.Models +// (NotificationsApiModels.cs). /// /// Service for managing persistent Global Admin in-app notifications. diff --git a/src/Backend/AutopilotMonitor.Functions/Services/HealthCheckService.cs b/src/Backend/AutopilotMonitor.Functions/Services/HealthCheckService.cs index 80f2ff61..afa7de0d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/HealthCheckService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/HealthCheckService.cs @@ -1,5 +1,6 @@ using AutopilotMonitor.Functions.Services.Monitoring; using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.Models; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; @@ -562,7 +563,9 @@ public class HealthCheckService } /// -/// Result of a health check operation +/// Result of a health check operation. Not itself on the wire — HealthCheckFunction decomposes +/// it into the typed response envelopes; the per-check item (HealthCheck) is wire contract and +/// lives in AutopilotMonitor.Shared.Models (InfrastructureApiModels.cs). /// public class HealthCheckResult { @@ -570,15 +573,3 @@ public class HealthCheckResult public string OverallStatus { get; set; } = "unknown"; public List Checks { get; set; } = new(); } - -/// -/// Individual health check result -/// -public class HealthCheck -{ - public string Name { get; set; } = string.Empty; - public string Description { get; set; } = string.Empty; - public string Status { get; set; } = "unknown"; - public string Message { get; set; } = string.Empty; - public Dictionary? Details { get; set; } -} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/LegacyReclassificationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/LegacyReclassificationService.cs index 08360ff4..00e4f3fc 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/LegacyReclassificationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/LegacyReclassificationService.cs @@ -50,32 +50,8 @@ namespace AutopilotMonitor.Functions.Services _configService = configService; } - public sealed class ReclassificationSample - { - public string TenantId { get; set; } = string.Empty; - public string SessionId { get; set; } = string.Empty; - public string OldStatus { get; set; } = string.Empty; - public string NewStatus { get; set; } = string.Empty; - public string Reason { get; set; } = string.Empty; - } - - public sealed class ReclassificationResult - { - public string Mode { get; set; } = string.Empty; - public bool DryRun { get; set; } - public int TenantsExamined { get; set; } - public int SessionsExamined { get; set; } - public int WouldChange { get; set; } - public int Changed { get; set; } - public int ToSucceeded { get; set; } - public int ToIncomplete { get; set; } - public int KeptFailed { get; set; } - public int Skipped { get; set; } - public int Errors { get; set; } - /// True when the maxSessions cap stopped the run before the backlog was exhausted — re-run to continue. - public bool CapReached { get; set; } - public List Samples { get; } = new(); - } + // ReclassificationResult/ReclassificationSample are wire contract and live in + // AutopilotMonitor.Shared.Models (AdminApiModels.cs). public async Task ReclassifyLegacyTimeoutsAsync( string? tenantIdScope, bool dryRun, int maxSessions, string triggeredBy) diff --git a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs index 716bbeb5..c90afcda 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs @@ -16,24 +16,7 @@ using Newtonsoft.Json; namespace AutopilotMonitor.Functions.Services { - /// - /// Result of a maintenance operation - /// - public class MaintenanceResult - { - public bool Success { get; set; } - public string? Error { get; set; } - public string TriggeredBy { get; set; } = string.Empty; - public DateTime TriggeredAt { get; set; } - public int DurationMs { get; set; } - public bool StalledSessionsChecked { get; set; } - public bool MetricsAggregated { get; set; } - public string? AggregatedDate { get; set; } - public bool DataCleanupExecuted { get; set; } - public bool PlatformStatsRecomputed { get; set; } - public int DevicesBlockedForExcessiveData { get; set; } - public int ContactEmailsBackfilled { get; set; } - } + // MaintenanceResult is wire contract and lives in AutopilotMonitor.Shared.Models (AdminApiModels.cs). /// /// Dedicated service for maintenance tasks: diff --git a/src/Backend/AutopilotMonitor.Functions/Services/OccurredUtcBackfillService.cs b/src/Backend/AutopilotMonitor.Functions/Services/OccurredUtcBackfillService.cs index 8d4c0d39..18d35950 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/OccurredUtcBackfillService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/OccurredUtcBackfillService.cs @@ -3,6 +3,7 @@ using Azure.Data.Tables; using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Pagination; using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.Models; using Microsoft.Extensions.Logging; namespace AutopilotMonitor.Functions.Services @@ -34,26 +35,8 @@ namespace AutopilotMonitor.Functions.Services _logger = logger; } - public class BackfillResult - { - public string Table { get; set; } = string.Empty; - public bool DryRun { get; set; } - public int RowsExamined { get; set; } - public int WouldWrite { get; set; } - public int Written { get; set; } - public int SkippedAlreadySet { get; set; } - public int SkippedUndecodable { get; set; } - public int Errors { get; set; } - public string? NextContinuation { get; set; } - public List Samples { get; set; } = new(); - } - - public class BackfillSample - { - public string PartitionKey { get; set; } = string.Empty; - public string RowKey { get; set; } = string.Empty; - public DateTime DecodedUtc { get; set; } - } + // BackfillResult/BackfillSample are wire contract and live in + // AutopilotMonitor.Shared.Models (AdminApiModels.cs). internal enum RowDecision { AlreadySet, Write, Undecodable } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/PreviewWhitelistService.cs b/src/Backend/AutopilotMonitor.Functions/Services/PreviewWhitelistService.cs index 832ef818..afa7e871 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/PreviewWhitelistService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/PreviewWhitelistService.cs @@ -1,5 +1,6 @@ using AutopilotMonitor.Functions.DataAccess.TableStorage; using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; @@ -124,18 +125,12 @@ public class PreviewWhitelistService /// /// Returns all approved tenants (for Global Admin overview). - /// Returns PreviewWhitelistEntity list for backward compatibility with existing API consumers. /// - public async Task> GetAllApprovedAsync() + public async Task> GetAllApprovedAsync() { var tenantIds = await _configRepo.GetPreviewWhitelistAsync(); - // Convert string list back to entity list for backward compatibility - return tenantIds.Select(id => new PreviewWhitelistEntity - { - PartitionKey = id, - RowKey = "approved" - }).ToList(); + return tenantIds.Select(id => new PreviewWhitelistTenantEntry { TenantId = id }).ToList(); } /// diff --git a/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.DryRun.cs b/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.DryRun.cs index d9b85b35..75484ff4 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.DryRun.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.DryRun.cs @@ -156,83 +156,6 @@ namespace AutopilotMonitor.Functions.Services } } - /// Verdict strings for . Stable API contract. - public static class RuleDryRunVerdict - { - public const string Fired = "fired"; - public const string SkippedByPrecondition = "skipped_by_precondition"; - public const string RequiredConditionNotMet = "required_condition_not_met"; - public const string NoConditionsMatched = "no_conditions_matched"; - public const string BelowConfidenceThreshold = "below_confidence_threshold"; - public const string NoEvents = "no_events"; - } - - /// Full diagnostic trace of one dry-run evaluation. Serialized camelCase to clients. - public sealed class RuleDryRun - { - public string Verdict { get; set; } = string.Empty; - - /// Number of events in the session the rule was evaluated against. - public int EventCount { get; set; } - - public List Preconditions { get; } = new(); - public List Conditions { get; } = new(); - - /// Empty unless all required conditions were met (mirrors the production path, - /// which never reaches factor evaluation otherwise). - public List ConfidenceFactors { get; } = new(); - - public int BaseConfidence { get; set; } - - /// base + matched factor weights, capped at 100. Null when the evaluation ended - /// before the confidence stage (precondition skip / required miss / nothing matched). - public int? FinalConfidence { get; set; } - - public int ConfidenceThreshold { get; set; } - - /// True only for verdict "fired" AND the rule's effective MarkSessionAsFailed flag. - /// The dry-run itself never touches the session. - public bool WouldMarkSessionAsFailed { get; set; } - - /// The evidence map exactly as the production path would persist it on a - /// RuleResult — keys are condition signals (plus factor_* markers). Clients use it to - /// preview {{token}} interpolation of explanation/remediation. - public Dictionary? MatchedConditions { get; set; } - } - - public sealed class RuleDryRunPrecondition - { - public string Source { get; set; } = string.Empty; - public string EventType { get; set; } = string.Empty; - public string? DataField { get; set; } - public string? Operator { get; set; } - public string? Value { get; set; } - public bool Passed { get; set; } - } - - public sealed class RuleDryRunCondition - { - public string Signal { get; set; } = string.Empty; - public string Source { get; set; } = string.Empty; - public string? EventType { get; set; } - public bool Required { get; set; } - public bool Matched { get; set; } - - /// Matched: the evidence dictionary (eventId, timestamp, field, value, …). - /// Not matched: the evaluator's reason string (e.g. "no matching events"). - public object? Evidence { get; set; } - - /// How many session events have this condition's eventType at all — the first - /// thing an author checks when a condition unexpectedly doesn't match. Null when the - /// condition has no eventType. - public int? MatchingEventCount { get; set; } - } - - public sealed class RuleDryRunFactor - { - public string Signal { get; set; } = string.Empty; - public string Condition { get; set; } = string.Empty; - public int Weight { get; set; } - public bool Matched { get; set; } - } + // RuleDryRun, RuleDryRunVerdict and the per-stage trace classes are wire contract and live in + // AutopilotMonitor.Shared.Models (RulesApiModels.cs). } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs index 1fdf5cf3..6f053f64 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs @@ -1457,7 +1457,7 @@ namespace AutopilotMonitor.Functions.Services internal static string? BuildDeviceSnapshotTenantFilter(string? tenantId) => string.IsNullOrEmpty(tenantId) ? null : $"PartitionKey eq '{ODataSanitizer.EscapeValue(tenantId)}'"; - public async Task> GetMetricsSummaryAsync(string? tenantId, int days = 30) + public async Task> GetMetricsSummaryAsync(string? tenantId, int days = 30) { // Clamp to a sane range so callers can't accidentally trigger an unbounded scan. if (days < 1) days = 1; @@ -1489,22 +1489,22 @@ namespace AutopilotMonitor.Functions.Services // Incomplete (unknown, non-failure) is deliberately excluded from the denominator, and // non-terminal states (InProgress/AwaitingUser/Pending/Stalled) never belonged in it. var terminal = b.Succeeded + b.Failed; - return (object)new + return new MetricsSummaryTenantItem { - tenantId = kvp.Key, - totalSessions = b.Total, - succeeded = b.Succeeded, - failed = b.Failed, - inProgress = b.InProgress, - pending = b.Pending, - stalled = b.Stalled, - awaitingUser = b.AwaitingUser, - incomplete = b.Incomplete, - other = b.Other, - failureRate = terminal > 0 + TenantId = kvp.Key, + TotalSessions = b.Total, + Succeeded = b.Succeeded, + Failed = b.Failed, + InProgress = b.InProgress, + Pending = b.Pending, + Stalled = b.Stalled, + AwaitingUser = b.AwaitingUser, + Incomplete = b.Incomplete, + Other = b.Other, + FailureRate = terminal > 0 ? Math.Round((double)b.Failed / terminal * 100, 1) : 0.0, - windowDays = days + WindowDays = days }; }).ToList(); } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TenantAdminsService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TenantAdminsService.cs index 421d2f69..099945c4 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TenantAdminsService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TenantAdminsService.cs @@ -1,6 +1,7 @@ using AutopilotMonitor.Functions.Security; using AutopilotMonitor.Shared; using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; using Azure.Data.Tables; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; @@ -83,7 +84,7 @@ public class TenantAdminsService /// Tenant ID /// User Principal Name /// UPN of the admin who is adding this user - public virtual async Task AddTenantAdminAsync(string tenantId, string upn, string addedBy) + public virtual async Task AddTenantAdminAsync(string tenantId, string upn, string addedBy) { tenantId = tenantId.ToLowerInvariant(); upn = upn.ToLowerInvariant(); @@ -95,10 +96,8 @@ public class TenantAdminsService _logger.LogInformation($"Added Tenant Admin: {upn} to tenant {tenantId} by {addedBy}"); - return new TenantAdminEntity + return new TenantAdminRow { - PartitionKey = tenantId, - RowKey = upn, TenantId = tenantId, Upn = upn, IsEnabled = true, @@ -155,7 +154,7 @@ public class TenantAdminsService /// /// Gets all Tenant Admins for a specific tenant /// - public virtual async Task> GetTenantAdminsAsync(string tenantId) + public virtual async Task> GetTenantAdminsAsync(string tenantId) { SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); @@ -165,10 +164,8 @@ public class TenantAdminsService _logger.LogInformation($"Retrieved {members.Count} Tenant Admins for tenant {tenantId}"); - return members.Select(m => new TenantAdminEntity + return members.Select(m => new TenantAdminRow { - PartitionKey = m.TenantId, - RowKey = m.Upn, TenantId = m.TenantId, Upn = m.Upn, IsEnabled = m.IsEnabled, @@ -255,7 +252,7 @@ public class TenantAdminsService /// /// Adds a tenant member with a specific role. /// - public async Task AddTenantMemberAsync(string tenantId, string upn, string addedBy, string role, bool canManageBootstrapTokens = false) + public async Task AddTenantMemberAsync(string tenantId, string upn, string addedBy, string role, bool canManageBootstrapTokens = false) { tenantId = tenantId.ToLowerInvariant(); upn = upn.ToLowerInvariant(); @@ -267,10 +264,8 @@ public class TenantAdminsService _logger.LogInformation("Added tenant member: {Upn} with role {Role} to tenant {TenantId} by {AddedBy}", upn, role, tenantId, addedBy); - return new TenantAdminEntity + return new TenantAdminRow { - PartitionKey = tenantId, - RowKey = upn, TenantId = tenantId, Upn = upn, IsEnabled = true, diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs index 6c72e818..7b965896 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs @@ -57,20 +57,8 @@ namespace AutopilotMonitor.Functions.Services => new(false, failure, error, backupId, Array.Empty(), null, drift); } - /// - /// One entry of the tenant-config field schema (see TenantConfigPatchService.BuildFieldsSchema). - /// Serialized camelCase to the API. Nulls/false flags are serialized as-is — the payload is - /// tiny and consumed by a model, which reads explicit values better than absent keys. - /// - public sealed record TenantConfigFieldSchema( - string Name, - string Type, - string? Format, - bool Nullable, - bool Writable, - string? Reason, - bool GaOnly, - bool RevertProtected); + // TenantConfigFieldSchema is wire contract and lives in AutopilotMonitor.Shared.Models + // (ConfigApiModels.cs); the schema builder (BuildFieldsSchema) stays here. /// /// Transactional field-level writes against a tenant's configuration row: @@ -498,14 +486,14 @@ namespace AutopilotMonitor.Functions.Services var writable = !BaseDeniedFields.Contains(prop.Name); fields.Add(new TenantConfigFieldSchema( - Name: char.ToLowerInvariant(prop.Name[0]) + prop.Name.Substring(1), - Type: type, - Format: format, - Nullable: nullable, - Writable: writable, - Reason: writable ? null : DeniedReason(prop.Name), - GaOnly: writable && GaOnlyFields.Contains(prop.Name), - RevertProtected: RevertProtectedFields.Contains(prop.Name))); + name: char.ToLowerInvariant(prop.Name[0]) + prop.Name.Substring(1), + type: type, + format: format, + nullable: nullable, + writable: writable, + reason: writable ? null : DeniedReason(prop.Name), + gaOnly: writable && GaOnlyFields.Contains(prop.Name), + revertProtected: RevertProtectedFields.Contains(prop.Name))); } return fields.OrderBy(f => f.Name, StringComparer.Ordinal).ToList(); }); diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TenantNotificationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TenantNotificationService.cs index b3f2f766..6e437ed4 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TenantNotificationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TenantNotificationService.cs @@ -1,4 +1,5 @@ using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; using Microsoft.Extensions.Logging; namespace AutopilotMonitor.Functions.Services; diff --git a/src/McpServer/autopilot-monitor-mcp/src/access-guard.ts b/src/McpServer/autopilot-monitor-mcp/src/access-guard.ts index 5c17338c..bd04faa4 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/access-guard.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/access-guard.ts @@ -12,6 +12,7 @@ import crypto from 'node:crypto'; import { extractTokenClaims, isTokenExpired } from './auth.js'; import { runWithCaller } from './client.js'; import { API_BASE_URL, getPublicBaseUrl, parsePositiveInt } from './config.js'; +import type { CheckMcpAccessResponse } from './generated/wire-types.generated.js'; const BASE_URL = API_BASE_URL; @@ -187,19 +188,9 @@ async function checkAccess(upn: string, token: string, clientIp: string): Promis return { allowed: false, reason: `Backend returned ${res.status} with empty body`, isGlobalAdmin: false, isGlobalReader: false, infraError: true }; } - const data = JSON.parse(text) as { - allowed: boolean; - reason?: string; - accessGrant?: string; - isGlobalAdmin?: boolean; - // Platform role: "GlobalAdmin" | "GlobalReader" (absent → no platform role). The read-only - // GlobalReader gets the same cross-tenant routing as GA because this server is read-only. - globalRole?: string; - // Delegated (scoped-global / MSP) scope: the managed tenant IDs (lowercase) and strongest role. - // Present only for a caller that holds a delegated assignment. - delegatedTenantIds?: unknown; - delegatedRole?: string; - }; + // Wire type is generated from the backend DTO (CheckMcpAccessResponse). The read-only + // GlobalReader gets the same cross-tenant routing as GA because this server is read-only. + const data = JSON.parse(text) as CheckMcpAccessResponse; // Normalize defensively: accept only a non-empty array of strings, lowercased. Anything else // (missing, wrong type, empty) collapses to undefined → the caller is treated as non-delegated. const delegatedTenantIds = Array.isArray(data.delegatedTenantIds) diff --git a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts index fe48faa5..9376e4d0 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts @@ -17,9 +17,9 @@ export interface ActiveUserItem { secondsAgo: number; } -/** Response of POST auth/global-admins (201): the created GlobalAdminEntity (backend-project type, serialized by runtime type). */ +/** Response of POST auth/global-admins (201): the created row. */ export interface AddGlobalAdminResponse { - admin: unknown; + admin: GlobalAdminRow; } /** Response of POST vulnerability/ignored-software: how many entries were added. */ @@ -540,6 +540,29 @@ export interface AuditLogListResponse { nextLink?: string; } +/** Success body of GET auth/me: the caller's resolved identity, roles and effective entitlement flags. Blocked outcomes (TenantSuspended / PendingActivation) are error shapes and stay anonymous by design. */ +export interface AuthMeResponse { + tenantId: string; + upn: string; + displayName: string; + objectId: string; + isGlobalAdmin: boolean; + isGlobalReader: boolean; + isTenantAdmin: boolean; + /** True when the caller holds delegated ("MSP") assignments to other tenants. */ + isDelegated: boolean; + /** The OTHER tenants this caller may manage; empty for non-delegated callers. */ + delegatedTenantIds: string[]; + /** Tenant role (Admin / Operator / Viewer); the key is omitted for a roleless caller. */ + role?: string; + canManageBootstrapTokens: boolean; + hasMcpAccess: boolean; + /** "primary" or "legacy" — which app registration this tenant is homed on. */ + homedApp: string; + bootstrapTokenEnabled: boolean; + unrestrictedModeEnabled: boolean; +} + /** Response of POST vulnerability/cpe-mapping/auto-resolve: per-item outcomes plus totals. */ export interface AutoResolveCpeMappingResponse { resolved: AutoResolveResultItem[]; @@ -588,6 +611,35 @@ export interface AutopilotConsentUrlResponse { willAutoFlipHoming: boolean; } +/** Response of POST maintenance/backfill-occurred-utc: the backfill run report plus trigger attribution. Key layout is identical to ReclassifyJobRunResponse — the two one-shot job triggers share one wire shape, split into two classes only for the typed slot. */ +export interface BackfillJobRunResponse { + success: boolean; + result: BackfillResult; + triggeredBy: string; + triggeredAt: string; +} + +/** Run report of the one-shot OccurredUtc backfill (POST maintenance/backfill-occurred-utc). */ +export interface BackfillResult { + table: string; + dryRun: boolean; + rowsExamined: number; + wouldWrite: number; + written: number; + skippedAlreadySet: number; + skippedUndecodable: number; + errors: number; + nextContinuation?: string; + samples: BackfillSample[]; +} + +/** One sampled row of a backfill run. PartitionKey/RowKey are PAYLOAD here — they identify the sampled storage row in a Global-Admin-only run report, not ITableEntity metadata. */ +export interface BackfillSample { + partitionKey: string; + rowKey: string; + decodedUtc: string; +} + /** Response of POST devices/block: block/kill acknowledgement. */ export interface BlockDeviceResponse { success: boolean; @@ -648,6 +700,22 @@ export interface BlockingAppInterval { seconds: number; } +/** Body of GET auth/mcp (200 when allowed, 403 when denied — same shape on both). The four platform/delegated keys are emitted ONLY when the caller actually holds the tier (null ⇒ key omitted): ordinary tenant users must not learn a platform tier exists, and IsGlobalAdmin is only ever emitted as literal true. */ +export interface CheckMcpAccessResponse { + allowed: boolean; + upn: string; + accessGrant: string; + reason: string; + /** Back-compat / write-tier hint for the MCP access-guard; true or omitted, never false. */ + isGlobalAdmin?: boolean; + /** "GlobalAdmin" | "GlobalReader"; omitted without a platform role. */ + globalRole?: string; + /** Managed tenant ids (lowercase) of a delegated (MSP) caller; omitted otherwise. */ + delegatedTenantIds?: string[]; + /** "DelegatedAdmin" | "DelegatedReader"; omitted without delegated scope. */ + delegatedRole?: string; +} + /** A factor that increases confidence when matched */ export interface ConfidenceFactor { /** Descriptive name for this factor */ @@ -851,12 +919,12 @@ export interface DeploymentTypeMetrics { whiteGlovePercentage: number; } -/** Response of GET health/detailed: the full system health report (always 200; per-check status is in the body). Items are HealthCheck objects (backend-project type, serialized by runtime type); non-GA callers get a filtered list. */ +/** Response of GET health/detailed: the full system health report (always 200; per-check status is in the body). Non-GA callers get a filtered check list. */ export interface DetailedHealthCheckResponse { service: string; timestamp: string; overallStatus: string; - checks: unknown[]; + checks: HealthCheck[]; version: string; commitHash: string; buildUtc: string; @@ -1062,8 +1130,8 @@ export interface DistressReportListResponse { export interface DryRunAnalyzeRuleResponse { success: boolean; sessionId: string; - /** The RuleDryRun trace (AutopilotMonitor.Functions.Services) — typed object here only because that class lives in the Functions assembly; System.Text.Json serializes the runtime type, so the wire shape is unchanged. */ - result: unknown; + /** The full diagnostic trace of the evaluation. */ + result: RuleDryRun; } export interface EfficiencyOffender { @@ -1393,9 +1461,9 @@ export interface GetDeviceHistoryResponse { attemptNumber?: number; } -/** Response of GET auth/global-admins: every Global Admin/Reader row. Items are GlobalAdminEntity table entities (backend-project type, serialized by runtime type — includes the ITableEntity keys, exactly as the anonymous site did). */ +/** Response of GET auth/global-admins: every Global Admin/Reader row. */ export interface GetGlobalAdminsResponse { - admins: unknown[]; + admins: GlobalAdminRow[]; } /** Global daily MCP/API usage summaries envelope (GetGlobalMcpUsageDaily). The MCP server paginates over the summaries key — its name is wire-critical. */ @@ -1485,8 +1553,7 @@ export interface GetPreviewNotificationEmailResponse { /** Response of GET preview/whitelist: every approved tenant (Global Admin only). */ export interface GetPreviewWhitelistResponse { - /** PreviewWhitelistEntity rows (AutopilotMonitor.Functions.DataAccess.TableStorage) — element-typed object here only because that entity lives in the Functions assembly; System.Text.Json serializes the runtime type, so the wire shape is unchanged. */ - tenants: unknown[]; + tenants: PreviewWhitelistTenantEntry[]; } /** Session ids where a rule produced a result within the window (GetRuleHitSessions) — powers the dashboard's ?ruleId= deep link. */ @@ -1632,8 +1699,7 @@ export interface GetSessionTimeAttributionResponse { export interface GetTenantConfigFieldsSchemaResponse { count: number; writableCount: number; - /** Schema entries (TenantConfigFieldSchema records, defined in the Functions project — typed as object here because Shared cannot reference it). */ - fields: unknown[]; + fields: TenantConfigFieldSchema[]; } /** Per-tenant deletion-manifest tree envelope (GetTenantDeletionManifests). */ @@ -1694,6 +1760,17 @@ export interface GetVulnerabilitySyncStatusResponse { epssLastRun?: EpssRefreshRunWire; } +/** One Global Admin/Reader row on the wire. Deliberately NOT the storage entity: the ITableEntity keys (partitionKey/rowKey/eTag/timestamp) that the pre-2026-08-31 wire carried are storage internals and were dropped from the contract (no consumer read them). */ +export interface GlobalAdminRow { + /** User Principal Name (lowercase). */ + upn: string; + isEnabled: boolean; + addedDate: string; + addedBy: string; + /** "GlobalAdmin" or "GlobalReader" (legacy empty rows are normalized to GlobalAdmin). */ + role: string; +} + /** Global average benchmarks for geographic comparison. */ export interface GlobalAverages { avgDurationMinutes: number; @@ -1713,6 +1790,16 @@ export interface GlobalAverages { totalDoBytesFromHttp: number; } +/** One in-app notification (global pool or tenant-scoped; both endpoints share this DTO). A null Href key is omitted (WhenWritingNull). */ +export interface GlobalNotificationDto { + id: string; + type: string; + title: string; + message: string; + href?: string; + createdAt: string; +} + /** One feature row of the graph-permissions status matrix: the feature identifier, the granted verdict (null while the snapshot is transient) and the Graph application permissions the feature requires. */ export interface GraphFeatureStatusItem { name: string; @@ -1753,6 +1840,15 @@ export interface HardwareRejectedResponse { dataQualityNotice: string; } +/** One health check result inside GET health/detailed / health/mcp. Details is a heterogeneous per-check bag by design (endpoint URLs only for Global Admins); a null Details key is omitted (WhenWritingNull). */ +export interface HealthCheck { + name: string; + description: string; + status: string; + message: string; + details?: Record; +} + /** Response of GET health: liveness probe plus the backend build identity. */ export interface HealthCheckResponse { status: string; @@ -2100,19 +2196,26 @@ export interface LogPatternTestResult { readonly notes: string[]; } -/** Shared response of the one-shot maintenance job triggers (POST maintenance/backfill-occurred-utc, POST maintenance/reclassify-legacy): the service's run report plus trigger attribution. */ -export interface MaintenanceJobRunResponse { +/** Run report of a manual maintenance run (POST maintenance/trigger). */ +export interface MaintenanceResult { success: boolean; - /** The service-specific run report (BackfillResult / ReclassificationResult — types owned by the Functions project, so this stays object; serialized by runtime type, wire-identical). */ - result: unknown; + error?: string; triggeredBy: string; triggeredAt: string; + durationMs: number; + stalledSessionsChecked: boolean; + metricsAggregated: boolean; + aggregatedDate?: string; + dataCleanupExecuted: boolean; + platformStatsRecomputed: boolean; + devicesBlockedForExcessiveData: number; + contactEmailsBackfilled: number; } -/** Response of GET health/mcp: the standalone MCP-server reachability probe. The check is a HealthCheck object (backend-project type, serialized by runtime type). */ +/** Response of GET health/mcp: the standalone MCP-server reachability probe. */ export interface McpHealthCheckResponse { timestamp: string; - check: unknown; + check: HealthCheck; } /** 429 body written by McpQuotaEnforcementMiddleware when the per-user MCP daily/monthly quota is exhausted (structurally a success shape: first key is quotaExceeded). */ @@ -2147,14 +2250,14 @@ export interface McpUserEntry { usagePlan?: string; } -/** Per-tenant session-status tally envelope shared by MetricsSummary and MetricsSummaryGlobal. Summary items are repository-built rows shaped like MetricsSummaryTenantItem. */ +/** Per-tenant session-status tally envelope shared by MetricsSummary and MetricsSummaryGlobal. */ export interface MetricsSummaryResponse { success: boolean; - summary: Partial[]; + summary: MetricsSummaryTenantItem[]; windowDays: number; } -/** Wire shape of one per-tenant status tally in MetricsSummaryResponse (documentation type — the repository still builds the rows; every key is always present). */ +/** One per-tenant status tally in MetricsSummaryResponse. WindowDays is repeated per item (envelope carries it too — historical wire shape, kept for parity). */ export interface MetricsSummaryTenantItem { tenantId: string; totalSessions: number; @@ -2171,10 +2274,10 @@ export interface MetricsSummaryTenantItem { windowDays: number; } -/** Shared response of GET global/notifications and GET notifications: the active (non-dismissed) notifications visible to the caller, newest first. Items are GlobalNotificationDto objects (backend-project type, serialized by runtime type). */ +/** Shared response of GET global/notifications and GET notifications: the active (non-dismissed) notifications visible to the caller, newest first. */ export interface NotificationListResponse { success: boolean; - notifications: unknown[]; + notifications: GlobalNotificationDto[]; } /** Outcome of the last NVD stale-cache refresh walk on THIS instance (in-memory). */ @@ -2322,6 +2425,11 @@ export interface PlatformUsageMetrics { windowDays: number; } +/** One approved tenant on the wire. Deliberately NOT the storage entity: the pre-2026-08-31 wire carried synthetic PreviewWhitelistEntity rows whose only real datum was the tenant id in partitionKey (plus garbage defaults) — the contract is now just the id. */ +export interface PreviewWhitelistTenantEntry { + tenantId: string; +} + /** Response of GET /api/progress/sessions/{sessionId}/events — the session's event stream after the serial knowledge proof passed. */ export interface ProgressGetSessionEventsResponse { success: boolean; @@ -2403,6 +2511,40 @@ export interface RebootSpan { segmentKey: string; } +/** Run report of the legacy-session reclassification job (POST maintenance/reclassify-legacy). */ +export interface ReclassificationResult { + mode: string; + dryRun: boolean; + tenantsExamined: number; + sessionsExamined: number; + wouldChange: number; + changed: number; + toSucceeded: number; + toIncomplete: number; + keptFailed: number; + skipped: number; + errors: number; + /** True when the maxSessions cap stopped the run before the backlog was exhausted — re-run to continue. */ + capReached: boolean; + readonly samples: ReclassificationSample[]; +} + +export interface ReclassificationSample { + tenantId: string; + sessionId: string; + oldStatus: string; + newStatus: string; + reason: string; +} + +/** Response of POST maintenance/reclassify-legacy: the reclassification run report plus trigger attribution. See BackfillJobRunResponse for the shared key layout. */ +export interface ReclassifyJobRunResponse { + success: boolean; + result: ReclassificationResult; + triggeredBy: string; + triggeredAt: string; +} + /** Structural health report for a session's persisted SignalLog + DecisionTransitions journal. Produced by GET /api/sessions/{id}/reducer-verification (Plan §M5, admin/ops endpoint, not tenant-exposed). Scope: this report covers structural invariants that can be checked without running the reducer — ordinal contiguity, cross-references, ReducerVersion drift, counts. A full engine replay with per-step diff would require polymorphic deserialisation of the DecisionSignal.Evidence payload and is a dedicated follow-up. */ export interface ReducerVerificationReport { tenantId: string; @@ -2530,6 +2672,53 @@ export interface RuleCondition { eventAFilterValue: string; } +/** Full diagnostic trace of one dry-run evaluation. Serialized camelCase to clients. */ +export interface RuleDryRun { + verdict: string; + /** Number of events in the session the rule was evaluated against. */ + eventCount: number; + readonly preconditions: RuleDryRunPrecondition[]; + readonly conditions: RuleDryRunCondition[]; + /** Empty unless all required conditions were met (mirrors the production path, which never reaches factor evaluation otherwise). */ + readonly confidenceFactors: RuleDryRunFactor[]; + baseConfidence: number; + /** base + matched factor weights, capped at 100. Null when the evaluation ended before the confidence stage (precondition skip / required miss / nothing matched). */ + finalConfidence?: number; + confidenceThreshold: number; + /** True only for verdict "fired" AND the rule's effective MarkSessionAsFailed flag. The dry-run itself never touches the session. */ + wouldMarkSessionAsFailed: boolean; + /** The evidence map exactly as the production path would persist it on a RuleResult — keys are condition signals (plus factor_* markers). Clients use it to preview {{token}} interpolation of explanation/remediation. Values are heterogeneous evidence objects by design. */ + matchedConditions?: Record; +} + +export interface RuleDryRunCondition { + signal: string; + source: string; + eventType?: string; + required: boolean; + matched: boolean; + /** Matched: the evidence dictionary (eventId, timestamp, field, value, …). Not matched: the evaluator's reason string (e.g. "no matching events"). */ + evidence?: unknown; + /** How many session events have this condition's eventType at all — the first thing an author checks when a condition unexpectedly doesn't match. Null when the condition has no eventType. */ + matchingEventCount?: number; +} + +export interface RuleDryRunFactor { + signal: string; + condition: string; + weight: number; + matched: boolean; +} + +export interface RuleDryRunPrecondition { + source: string; + eventType: string; + dataField?: string; + operator?: string; + value?: string; + passed: boolean; +} + /** A device-fact gate evaluated before a rule's conditions. Pure boolean filter — does not contribute to evidence or confidence. When any precondition on a rule fails, the rule is silently skipped (no result emitted). Currently only event_data source is supported. */ export interface RulePrecondition { /** Source of the fact. Currently only event_data. */ @@ -3223,8 +3412,22 @@ export interface TemplateVariable { /** Response of POST tenants/{tenantId}/admins: the created tenant member. */ export interface TenantAdminCreatedResponse { - /** The stored TenantAdminEntity row (type owned by the Functions project, so this stays object; serialized by runtime type, wire-identical). */ - admin: unknown; + admin: TenantAdminRow; +} + +/** One tenant member (Admin / Operator / Viewer) on the wire — the item of the bare-array GET tenants/{tenantId}/admins and the created member of the POST. Deliberately NOT the storage entity: the ITableEntity keys (partitionKey/rowKey/eTag/timestamp) that the pre-2026-08-31 wire carried are storage internals and were dropped from the contract (no consumer read them). */ +export interface TenantAdminRow { + /** Tenant ID (lowercase). */ + tenantId: string; + /** User Principal Name (lowercase). */ + upn: string; + isEnabled: boolean; + addedDate: string; + addedBy: string; + /** Role: "Admin", "Operator", "Viewer". A null (legacy pre-role row) omits the key and means Admin. */ + role?: string; + /** Whether this Operator can manage bootstrap tokens (only relevant for Operator). */ + canManageBootstrapTokens: boolean; } /** One pre-write config snapshot in GET config/{tenantId}/backups — metadata only, the raw EntityJson (clear-text secrets) is never returned. */ @@ -3241,6 +3444,18 @@ export interface TenantConfigBackupItem { diff?: Record; } +/** One entry of the tenant-config field schema (built by TenantConfigPatchService.BuildFieldsSchema). Serialized camelCase; null Format/Reason keys are omitted (WhenWritingNull) — the null and set cases are pinned by ConfigWireParityTests. */ +export interface TenantConfigFieldSchema { + name: string; + type: string; + format?: string; + nullable: boolean; + writable: boolean; + reason?: string; + gaOnly: boolean; + revertProtected: boolean; +} + /** Success response of PATCH config/{tenantId}/fields and POST config/{tenantId}/revert (both flow through the same outcome writer): which fields changed, the masked diff, and the pre-write backup id. */ export interface TenantConfigPatchOutcomeResponse { success: boolean; @@ -3696,8 +3911,7 @@ export interface TriggerEpssSyncResponse { export interface TriggerMaintenanceResponse { success: boolean; message: string; - /** The MaintenanceResult run report (type owned by the Functions project, so this stays object; serialized by runtime type, wire-identical). */ - result: unknown; + result: MaintenanceResult; triggeredBy: string; triggeredAt: string; } diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts index cbe971a8..aa02ff07 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts @@ -13,6 +13,7 @@ import type { GetGlobalMcpUsageResponse, GetMcpUserUsageResponse, GetMyMcpUsageResponse, + GetTenantConfigFieldsSchemaResponse, GetUnmatchedSoftwareResponse, PlatformAgentMetricsResponse, QueryRawTableResponse, @@ -1322,7 +1323,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo }, async (args) => withToolTelemetry('get_tenant_config_schema', args, async () => { try { - const data = await apiFetch('/api/config/fields-schema'); + const data = await apiFetch('/api/config/fields-schema') as GetTenantConfigFieldsSchemaResponse; return toolResultText(data, MAX_RESULT_SIZE_CHARS.small); } catch (error: unknown) { return toolError('get_tenant_config_schema', args, error); diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/rules.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/rules.ts index c21140de..7f63e016 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/rules.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/rules.ts @@ -127,11 +127,8 @@ export function registerRuleTools(server: McpServer, ga: boolean): void { body: JSON.stringify({ sessionId: args.sessionId, rule: args.rule }), }) as DryRunAnalyzeRuleResponse; - // `result` is `unknown` on the wire by design (the RuleDryRun trace lives in the - // Functions assembly) — read the two fields this tool needs via a local view. - const result = data.result as { verdict?: string; matchedConditions?: Record } | undefined; const rule = args.rule as Record; - const mc = result?.matchedConditions; + const mc = data.result.matchedConditions; // Preview the explanation/remediation exactly as the portal would render them // for this session's evidence ({{token}} interpolation). diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IMetricsRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IMetricsRepository.cs index 64f10540..9bf6ae30 100644 --- a/src/Shared/AutopilotMonitor.Shared/DataAccess/IMetricsRepository.cs +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IMetricsRepository.cs @@ -92,7 +92,7 @@ namespace AutopilotMonitor.Shared.DataAccess Task DeleteUserPresenceOlderThanAsync(DateTime cutoffUtc); // --- Metrics Summary (Agent API) --- - Task> GetMetricsSummaryAsync(string? tenantId, int days = 30); + Task> GetMetricsSummaryAsync(string? tenantId, int days = 30); // --- Rule Stats --- Task IncrementRuleStatAsync(string date, string tenantId, string ruleId, string ruleType, diff --git a/src/Shared/AutopilotMonitor.Shared/Models/AdminApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/AdminApiModels.cs index 9093669a..81561ef0 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/AdminApiModels.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/AdminApiModels.cs @@ -101,20 +101,28 @@ namespace AutopilotMonitor.Shared.Models } /// - /// Shared response of the one-shot maintenance job triggers (POST maintenance/backfill-occurred-utc, - /// POST maintenance/reclassify-legacy): the service's run report plus trigger attribution. + /// Response of POST maintenance/backfill-occurred-utc: the backfill run report plus trigger + /// attribution. Key layout is identical to — the two + /// one-shot job triggers share one wire shape, split into two classes only for the typed slot. /// // Declaration order == wire order. - public class MaintenanceJobRunResponse : IApiResponse + public class BackfillJobRunResponse : IApiResponse { public bool Success { get; set; } + public BackfillResult Result { get; set; } = default!; + public string TriggeredBy { get; set; } = default!; + public DateTime TriggeredAt { get; set; } + } - /// - /// The service-specific run report (BackfillResult / ReclassificationResult — types owned by the - /// Functions project, so this stays object; serialized by runtime type, wire-identical). - /// - public object Result { get; set; } = default!; - + /// + /// Response of POST maintenance/reclassify-legacy: the reclassification run report plus + /// trigger attribution. See for the shared key layout. + /// + // Declaration order == wire order. + public class ReclassifyJobRunResponse : IApiResponse + { + public bool Success { get; set; } + public ReclassificationResult Result { get; set; } = default!; public string TriggeredBy { get; set; } = default!; public DateTime TriggeredAt { get; set; } } @@ -125,17 +133,91 @@ namespace AutopilotMonitor.Shared.Models { public bool Success { get; set; } public string Message { get; set; } = default!; - - /// - /// The MaintenanceResult run report (type owned by the Functions project, so this stays - /// object; serialized by runtime type, wire-identical). - /// - public object Result { get; set; } = default!; - + public MaintenanceResult Result { get; set; } = default!; public string TriggeredBy { get; set; } = default!; public DateTime TriggeredAt { get; set; } } + /// + /// Run report of the one-shot OccurredUtc backfill (POST maintenance/backfill-occurred-utc). + /// + // Declaration order == wire order. + public class BackfillResult + { + public string Table { get; set; } = string.Empty; + public bool DryRun { get; set; } + public int RowsExamined { get; set; } + public int WouldWrite { get; set; } + public int Written { get; set; } + public int SkippedAlreadySet { get; set; } + public int SkippedUndecodable { get; set; } + public int Errors { get; set; } + public string? NextContinuation { get; set; } + public List Samples { get; set; } = new List(); + } + + /// + /// One sampled row of a backfill run. PartitionKey/RowKey are PAYLOAD here — they identify + /// the sampled storage row in a Global-Admin-only run report, not ITableEntity metadata. + /// + // Declaration order == wire order. + public class BackfillSample + { + public string PartitionKey { get; set; } = string.Empty; + public string RowKey { get; set; } = string.Empty; + public DateTime DecodedUtc { get; set; } + } + + /// + /// Run report of the legacy-session reclassification job (POST maintenance/reclassify-legacy). + /// + // Declaration order == wire order. + public sealed class ReclassificationResult + { + public string Mode { get; set; } = string.Empty; + public bool DryRun { get; set; } + public int TenantsExamined { get; set; } + public int SessionsExamined { get; set; } + public int WouldChange { get; set; } + public int Changed { get; set; } + public int ToSucceeded { get; set; } + public int ToIncomplete { get; set; } + public int KeptFailed { get; set; } + public int Skipped { get; set; } + public int Errors { get; set; } + /// True when the maxSessions cap stopped the run before the backlog was exhausted — re-run to continue. + public bool CapReached { get; set; } + public List Samples { get; } = new List(); + } + + // Declaration order == wire order. + public sealed class ReclassificationSample + { + public string TenantId { get; set; } = string.Empty; + public string SessionId { get; set; } = string.Empty; + public string OldStatus { get; set; } = string.Empty; + public string NewStatus { get; set; } = string.Empty; + public string Reason { get; set; } = string.Empty; + } + + /// Run report of a manual maintenance run (POST maintenance/trigger). + // Declaration order == wire order. + public class MaintenanceResult + { + public bool Success { get; set; } + public string? Error { get; set; } + public string TriggeredBy { get; set; } = string.Empty; + public DateTime TriggeredAt { get; set; } + public int DurationMs { get; set; } + public bool StalledSessionsChecked { get; set; } + public bool MetricsAggregated { get; set; } + public string? AggregatedDate { get; set; } + public bool DataCleanupExecuted { get; set; } + public bool PlatformStatsRecomputed { get; set; } + public int DevicesBlockedForExcessiveData { get; set; } + public int ContactEmailsBackfilled { get; set; } + } + /// /// One customs-archive run (one (tenantId, historyRowKey) partition) with per-source-table /// row counts, as listed by GET global/customs-archive. @@ -358,11 +440,34 @@ namespace AutopilotMonitor.Shared.Models // Declaration order == wire order. public class TenantAdminCreatedResponse : IApiResponse { - /// - /// The stored TenantAdminEntity row (type owned by the Functions project, so this stays - /// object; serialized by runtime type, wire-identical). - /// - public object Admin { get; set; } = default!; + public TenantAdminRow Admin { get; set; } = default!; + } + + /// + /// One tenant member (Admin / Operator / Viewer) on the wire — the item of the bare-array + /// GET tenants/{tenantId}/admins and the created member of the POST. Deliberately NOT the + /// storage entity: the ITableEntity keys (partitionKey/rowKey/eTag/timestamp) that the + /// pre-2026-08-31 wire carried are storage internals and were dropped from the contract + /// (no consumer read them). + /// + // Declaration order == wire order. + public class TenantAdminRow + { + /// Tenant ID (lowercase). + public string TenantId { get; set; } = string.Empty; + + /// User Principal Name (lowercase). + public string Upn { get; set; } = string.Empty; + + public bool IsEnabled { get; set; } + public DateTime AddedDate { get; set; } + public string AddedBy { get; set; } = string.Empty; + + /// Role: "Admin", "Operator", "Viewer". A null (legacy pre-role row) omits the key and means Admin. + public string? Role { get; set; } + + /// Whether this Operator can manage bootstrap tokens (only relevant for Operator). + public bool CanManageBootstrapTokens { get; set; } } /// Response of GET global/tenant-groups: every group with tenants + assignees. diff --git a/src/Shared/AutopilotMonitor.Shared/Models/ConfigApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/ConfigApiModels.cs index 7d00000b..5a86dea8 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/ConfigApiModels.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/ConfigApiModels.cs @@ -86,11 +86,45 @@ namespace AutopilotMonitor.Shared.Models public int Count { get; set; } public int WritableCount { get; set; } - /// - /// Schema entries (TenantConfigFieldSchema records, defined in the Functions - /// project — typed as object here because Shared cannot reference it). - /// - public IReadOnlyList Fields { get; set; } = default!; + public IReadOnlyList Fields { get; set; } = default!; + } + + /// + /// One entry of the tenant-config field schema (built by TenantConfigPatchService.BuildFieldsSchema). + /// Serialized camelCase; null Format/Reason keys are omitted (WhenWritingNull) — the null and + /// set cases are pinned by ConfigWireParityTests. + /// + // Declaration order == wire order (matches the former positional-record constructor order). + public sealed class TenantConfigFieldSchema + { + public string Name { get; set; } + public string Type { get; set; } + public string? Format { get; set; } + public bool Nullable { get; set; } + public bool Writable { get; set; } + public string? Reason { get; set; } + public bool GaOnly { get; set; } + public bool RevertProtected { get; set; } + + public TenantConfigFieldSchema( + string name, + string type, + string? format, + bool nullable, + bool writable, + string? reason, + bool gaOnly, + bool revertProtected) + { + Name = name; + Type = type; + Format = format; + Nullable = nullable; + Writable = writable; + Reason = reason; + GaOnly = gaOnly; + RevertProtected = revertProtected; + } } /// diff --git a/src/Shared/AutopilotMonitor.Shared/Models/InfrastructureApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/InfrastructureApiModels.cs index 378080a3..e2959b74 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/InfrastructureApiModels.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/InfrastructureApiModels.cs @@ -4,6 +4,41 @@ using AutopilotMonitor.Shared.DataAccess; namespace AutopilotMonitor.Shared.Models { + /// + /// Success body of GET auth/me: the caller's resolved identity, roles and effective + /// entitlement flags. Blocked outcomes (TenantSuspended / PendingActivation) are error + /// shapes and stay anonymous by design. + /// + // Declaration order == wire order. + public class AuthMeResponse : IApiResponse + { + public string TenantId { get; set; } = default!; + public string Upn { get; set; } = default!; + public string DisplayName { get; set; } = default!; + public string ObjectId { get; set; } = default!; + public bool IsGlobalAdmin { get; set; } + public bool IsGlobalReader { get; set; } + public bool IsTenantAdmin { get; set; } + + /// True when the caller holds delegated ("MSP") assignments to other tenants. + public bool IsDelegated { get; set; } + + /// The OTHER tenants this caller may manage; empty for non-delegated callers. + public IReadOnlyCollection DelegatedTenantIds { get; set; } = default!; + + /// Tenant role (Admin / Operator / Viewer); the key is omitted for a roleless caller. + public string? Role { get; set; } + + public bool CanManageBootstrapTokens { get; set; } + public bool HasMcpAccess { get; set; } + + /// "primary" or "legacy" — which app registration this tenant is homed on. + public string HomedApp { get; set; } = default!; + + public bool BootstrapTokenEnabled { get; set; } + public bool UnrestrictedModeEnabled { get; set; } + } + /// /// Response of GET auth/is-global-admin: whether the caller holds the Global Admin /// platform role, echoing the caller's UPN. @@ -18,24 +53,38 @@ namespace AutopilotMonitor.Shared.Models } /// - /// Response of GET auth/global-admins: every Global Admin/Reader row. Items are - /// GlobalAdminEntity table entities (backend-project type, serialized by runtime - /// type — includes the ITableEntity keys, exactly as the anonymous site did). + /// Response of GET auth/global-admins: every Global Admin/Reader row. /// // Declaration order == wire order. public class GetGlobalAdminsResponse : IApiResponse { - public IReadOnlyList Admins { get; set; } = default!; + public IReadOnlyList Admins { get; set; } = default!; } /// - /// Response of POST auth/global-admins (201): the created GlobalAdminEntity - /// (backend-project type, serialized by runtime type). + /// Response of POST auth/global-admins (201): the created row. /// // Declaration order == wire order. public class AddGlobalAdminResponse : IApiResponse { - public object Admin { get; set; } = default!; + public GlobalAdminRow Admin { get; set; } = default!; + } + + /// + /// One Global Admin/Reader row on the wire. Deliberately NOT the storage entity: the + /// ITableEntity keys (partitionKey/rowKey/eTag/timestamp) that the pre-2026-08-31 wire + /// carried are storage internals and were dropped from the contract (no consumer read them). + /// + // Declaration order == wire order. + public class GlobalAdminRow + { + /// User Principal Name (lowercase). + public string Upn { get; set; } = string.Empty; + public bool IsEnabled { get; set; } + public DateTime AddedDate { get; set; } + public string AddedBy { get; set; } = string.Empty; + /// "GlobalAdmin" or "GlobalReader" (legacy empty rows are normalized to GlobalAdmin). + public string Role { get; set; } = string.Empty; } /// @@ -54,8 +103,7 @@ namespace AutopilotMonitor.Shared.Models /// /// Response of GET health/detailed: the full system health report (always 200; per-check - /// status is in the body). Items are HealthCheck objects (backend-project type, - /// serialized by runtime type); non-GA callers get a filtered list. + /// status is in the body). Non-GA callers get a filtered check list. /// // Declaration order == wire order. public class DetailedHealthCheckResponse : IApiResponse @@ -63,21 +111,62 @@ namespace AutopilotMonitor.Shared.Models public string Service { get; set; } = default!; public DateTime Timestamp { get; set; } public string OverallStatus { get; set; } = default!; - public IReadOnlyList Checks { get; set; } = default!; + public IReadOnlyList Checks { get; set; } = default!; public string Version { get; set; } = default!; public string CommitHash { get; set; } = default!; public DateTime BuildUtc { get; set; } } /// - /// Response of GET health/mcp: the standalone MCP-server reachability probe. The check is - /// a HealthCheck object (backend-project type, serialized by runtime type). + /// Response of GET health/mcp: the standalone MCP-server reachability probe. /// // Declaration order == wire order. public class McpHealthCheckResponse : IApiResponse { public DateTime Timestamp { get; set; } - public object Check { get; set; } = default!; + public HealthCheck Check { get; set; } = default!; + } + + /// + /// One health check result inside GET health/detailed / health/mcp. Details is a + /// heterogeneous per-check bag by design (endpoint URLs only for Global Admins); + /// a null Details key is omitted (WhenWritingNull). + /// + // Declaration order == wire order. + public class HealthCheck + { + public string Name { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public string Status { get; set; } = "unknown"; + public string Message { get; set; } = string.Empty; + public Dictionary? Details { get; set; } + } + + /// + /// Body of GET auth/mcp (200 when allowed, 403 when denied — same shape on both). + /// The four platform/delegated keys are emitted ONLY when the caller actually holds the + /// tier (null ⇒ key omitted): ordinary tenant users must not learn a platform tier exists, + /// and is only ever emitted as literal true. + /// + // Declaration order == wire order (matches the former Dictionary insertion order). + public class CheckMcpAccessResponse : IApiResponse + { + public bool Allowed { get; set; } + public string Upn { get; set; } = default!; + public string AccessGrant { get; set; } = default!; + public string Reason { get; set; } = default!; + + /// Back-compat / write-tier hint for the MCP access-guard; true or omitted, never false. + public bool? IsGlobalAdmin { get; set; } + + /// "GlobalAdmin" | "GlobalReader"; omitted without a platform role. + public string? GlobalRole { get; set; } + + /// Managed tenant ids (lowercase) of a delegated (MSP) caller; omitted otherwise. + public IReadOnlyCollection? DelegatedTenantIds { get; set; } + + /// "DelegatedAdmin" | "DelegatedReader"; omitted without delegated scope. + public string? DelegatedRole { get; set; } } /// diff --git a/src/Shared/AutopilotMonitor.Shared/Models/MetricsApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/MetricsApiModels.cs index e185ce98..9997fc38 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/MetricsApiModels.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/MetricsApiModels.cs @@ -143,23 +143,19 @@ namespace AutopilotMonitor.Shared.Models /// /// Per-tenant session-status tally envelope shared by MetricsSummary and - /// MetricsSummaryGlobal. items are repository-built rows shaped - /// like . + /// MetricsSummaryGlobal. /// // Declaration order == wire order. public class MetricsSummaryResponse : IApiResponse { public bool Success { get; set; } - - [ProjectedItems(typeof(MetricsSummaryTenantItem))] - public IReadOnlyList Summary { get; set; } = default!; - + public IReadOnlyList Summary { get; set; } = default!; public int WindowDays { get; set; } } /// - /// Wire shape of one per-tenant status tally in - /// (documentation type — the repository still builds the rows; every key is always present). + /// One per-tenant status tally in . WindowDays is + /// repeated per item (envelope carries it too — historical wire shape, kept for parity). /// // Declaration order == wire order. public class MetricsSummaryTenantItem diff --git a/src/Shared/AutopilotMonitor.Shared/Models/NotificationsApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/NotificationsApiModels.cs index f2d48eb6..42f42f6a 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/NotificationsApiModels.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/NotificationsApiModels.cs @@ -1,17 +1,32 @@ +using System; using System.Collections.Generic; namespace AutopilotMonitor.Shared.Models { /// /// Shared response of GET global/notifications and GET notifications: the active - /// (non-dismissed) notifications visible to the caller, newest first. Items are - /// GlobalNotificationDto objects (backend-project type, serialized by runtime type). + /// (non-dismissed) notifications visible to the caller, newest first. /// // Declaration order == wire order. public class NotificationListResponse : IApiResponse { public bool Success { get; set; } - public IReadOnlyList Notifications { get; set; } = default!; + public IReadOnlyList Notifications { get; set; } = default!; + } + + /// + /// One in-app notification (global pool or tenant-scoped; both endpoints share this DTO). + /// A null Href key is omitted (WhenWritingNull). + /// + // Declaration order == wire order. + public class GlobalNotificationDto + { + public string Id { get; set; } = string.Empty; + public string Type { get; set; } = "info"; + public string Title { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; + public string? Href { get; set; } + public DateTime CreatedAt { get; set; } } /// diff --git a/src/Shared/AutopilotMonitor.Shared/Models/RulesApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/RulesApiModels.cs index ea96fd0c..ec7a9f71 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/RulesApiModels.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/RulesApiModels.cs @@ -54,12 +54,93 @@ namespace AutopilotMonitor.Shared.Models public bool Success { get; set; } public string SessionId { get; set; } = default!; - /// - /// The RuleDryRun trace (AutopilotMonitor.Functions.Services) — typed - /// object here only because that class lives in the Functions assembly; - /// System.Text.Json serializes the runtime type, so the wire shape is unchanged. - /// - public object Result { get; set; } = default!; + /// The full diagnostic trace of the evaluation. + public RuleDryRun Result { get; set; } = default!; + } + + /// Verdict strings for . Stable API contract. + public static class RuleDryRunVerdict + { + public const string Fired = "fired"; + public const string SkippedByPrecondition = "skipped_by_precondition"; + public const string RequiredConditionNotMet = "required_condition_not_met"; + public const string NoConditionsMatched = "no_conditions_matched"; + public const string BelowConfidenceThreshold = "below_confidence_threshold"; + public const string NoEvents = "no_events"; + } + + /// Full diagnostic trace of one dry-run evaluation. Serialized camelCase to clients. + // Declaration order == wire order. + public sealed class RuleDryRun + { + public string Verdict { get; set; } = string.Empty; + + /// Number of events in the session the rule was evaluated against. + public int EventCount { get; set; } + + public List Preconditions { get; } = new List(); + public List Conditions { get; } = new List(); + + /// Empty unless all required conditions were met (mirrors the production path, + /// which never reaches factor evaluation otherwise). + public List ConfidenceFactors { get; } = new List(); + + public int BaseConfidence { get; set; } + + /// base + matched factor weights, capped at 100. Null when the evaluation ended + /// before the confidence stage (precondition skip / required miss / nothing matched). + public int? FinalConfidence { get; set; } + + public int ConfidenceThreshold { get; set; } + + /// True only for verdict "fired" AND the rule's effective MarkSessionAsFailed flag. + /// The dry-run itself never touches the session. + public bool WouldMarkSessionAsFailed { get; set; } + + /// The evidence map exactly as the production path would persist it on a + /// RuleResult — keys are condition signals (plus factor_* markers). Clients use it to + /// preview {{token}} interpolation of explanation/remediation. Values are heterogeneous + /// evidence objects by design. + public Dictionary? MatchedConditions { get; set; } + } + + // Declaration order == wire order. + public sealed class RuleDryRunPrecondition + { + public string Source { get; set; } = string.Empty; + public string EventType { get; set; } = string.Empty; + public string? DataField { get; set; } + public string? Operator { get; set; } + public string? Value { get; set; } + public bool Passed { get; set; } + } + + // Declaration order == wire order. + public sealed class RuleDryRunCondition + { + public string Signal { get; set; } = string.Empty; + public string Source { get; set; } = string.Empty; + public string? EventType { get; set; } + public bool Required { get; set; } + public bool Matched { get; set; } + + /// Matched: the evidence dictionary (eventId, timestamp, field, value, …). + /// Not matched: the evaluator's reason string (e.g. "no matching events"). + public object? Evidence { get; set; } + + /// How many session events have this condition's eventType at all — the first + /// thing an author checks when a condition unexpectedly doesn't match. Null when the + /// condition has no eventType. + public int? MatchingEventCount { get; set; } + } + + // Declaration order == wire order. + public sealed class RuleDryRunFactor + { + public string Signal { get; set; } = string.Empty; + public string Condition { get; set; } = string.Empty; + public int Weight { get; set; } + public bool Matched { get; set; } } /// @@ -151,12 +232,18 @@ namespace AutopilotMonitor.Shared.Models // Declaration order == wire order. public class GetPreviewWhitelistResponse : IApiResponse { - /// - /// PreviewWhitelistEntity rows (AutopilotMonitor.Functions.DataAccess.TableStorage) - /// — element-typed object here only because that entity lives in the Functions - /// assembly; System.Text.Json serializes the runtime type, so the wire shape is unchanged. - /// - public IReadOnlyList Tenants { get; set; } = default!; + public IReadOnlyList Tenants { get; set; } = default!; + } + + /// + /// One approved tenant on the wire. Deliberately NOT the storage entity: the pre-2026-08-31 + /// wire carried synthetic PreviewWhitelistEntity rows whose only real datum was the + /// tenant id in partitionKey (plus garbage defaults) — the contract is now just the id. + /// + // Declaration order == wire order. + public class PreviewWhitelistTenantEntry + { + public string TenantId { get; set; } = string.Empty; } /// diff --git a/src/Web/autopilot-monitor-web/app/admin/AdminConfigContext.tsx b/src/Web/autopilot-monitor-web/app/admin/AdminConfigContext.tsx index f64eb6d9..6050c33b 100644 --- a/src/Web/autopilot-monitor-web/app/admin/AdminConfigContext.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/AdminConfigContext.tsx @@ -5,6 +5,7 @@ import { useAuth } from "../../contexts/AuthContext"; import { api } from "@/lib/api"; import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; import type { AdminConfiguration, OpsAlertRule } from "@/types/adminConfig"; +import type { GetPreviewWhitelistResponse } from "@/utils/wire-types.generated"; // Re-export so existing `import { AdminConfiguration } from "../AdminConfigContext"` consumers keep working export type { AdminConfiguration, OpsAlertRule }; @@ -297,9 +298,9 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode }) setTenants(data); if (previewRes.ok) { - const previewData = await previewRes.json(); + const previewData = (await previewRes.json()) as GetPreviewWhitelistResponse; const approvedIds = new Set( - (previewData.tenants || []).map((t: { partitionKey: string }) => t.partitionKey) + (previewData.tenants || []).map((t) => t.tenantId) ); setPreviewApproved(approvedIds); } diff --git a/src/Web/autopilot-monitor-web/app/admin/components/TenantAdminSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/TenantAdminSection.tsx index 84e08d33..29396265 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/TenantAdminSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/TenantAdminSection.tsx @@ -4,16 +4,10 @@ import { useEffect, useState } from "react"; import { api } from "@/lib/api"; import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; import { useCanMutatePlatform } from "@/hooks/useCanMutatePlatform"; +import type { TenantAdminRow } from "@/utils/wire-types.generated"; -interface TenantAdmin { - tenantId: string; - upn: string; - isEnabled: boolean; - addedDate: string; - addedBy: string; - role: string | null; - canManageBootstrapTokens: boolean; -} +// Wire type is generated from the backend DTO ("role" is absent for legacy pre-role rows). +type TenantAdmin = TenantAdminRow; interface TenantAdminSectionProps { tenantId: string; 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 6437abbe..4aefeda8 100644 --- a/src/Web/autopilot-monitor-web/app/health-check/page.tsx +++ b/src/Web/autopilot-monitor-web/app/health-check/page.tsx @@ -8,31 +8,14 @@ import { useState, useEffect, useCallback } from 'react'; import * as signalR from '@microsoft/signalr'; import { api } from '@/lib/api'; import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; - -interface HealthCheck { - name: string; - description: string; - status: string; - message: string; - details?: Record; -} - -interface HealthCheckResult { - service: string; - timestamp: string; - overallStatus: string; - checks: HealthCheck[]; - version?: string; - commitHash?: string; - buildUtc?: string; -} +import type { DetailedHealthCheckResponse, HealthCheck, McpHealthCheckResponse } from '@/utils/wire-types.generated'; export default function HealthCheckPage() { const { user, getAccessToken } = useAuth(); const { addNotification } = useNotifications(); const { connectionState, isConnected, joinedGroups, joinGroup } = useSignalR(); const { tenantId } = useTenant(); - const [healthResult, setHealthResult] = useState(null); + const [healthResult, setHealthResult] = useState(null); const [loading, setLoading] = useState(false); const [hasRun, setHasRun] = useState(false); const [groupJoinTest, setGroupJoinTest] = useState<'idle' | 'testing' | 'success' | 'failed'>('idle'); @@ -60,7 +43,7 @@ export default function HealthCheckPage() { return; } - const data = await response.json(); + const data = (await response.json()) as DetailedHealthCheckResponse; setHealthResult(data); } catch (error) { if (error instanceof TokenExpiredError) { @@ -94,8 +77,8 @@ export default function HealthCheckPage() { }); return; } - const data = await response.json(); - if (data?.check) setMcpCheck(data.check as HealthCheck); + const data = (await response.json()) as McpHealthCheckResponse; + if (data?.check) setMcpCheck(data.check); } catch (error) { // Network/token errors: surface on the card itself, never as a blocking page error. setMcpCheck({ diff --git a/src/Web/autopilot-monitor-web/app/settings/components/AdminManagementSection.tsx b/src/Web/autopilot-monitor-web/app/settings/components/AdminManagementSection.tsx index 1a29469c..1cfeaa11 100644 --- a/src/Web/autopilot-monitor-web/app/settings/components/AdminManagementSection.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/components/AdminManagementSection.tsx @@ -23,7 +23,7 @@ interface AdminManagementSectionProps { onUpdatePermissions: (upn: string, role: string, canManageBootstrapTokens: boolean) => void; } -function getRoleBadge(role: string | null) { +function getRoleBadge(role: string | null | undefined) { const effectiveRole = role ?? "Admin"; switch (effectiveRole) { case "Admin": diff --git a/src/Web/autopilot-monitor-web/app/settings/types.ts b/src/Web/autopilot-monitor-web/app/settings/types.ts index db74fde4..bf5dcef8 100644 --- a/src/Web/autopilot-monitor-web/app/settings/types.ts +++ b/src/Web/autopilot-monitor-web/app/settings/types.ts @@ -139,12 +139,5 @@ export interface TenantConfiguration { trialConsumed?: boolean; } -export interface TenantAdmin { - tenantId: string; - upn: string; - isEnabled: boolean; - addedDate: string; - addedBy: string; - role: string | null; - canManageBootstrapTokens: boolean; -} +// Wire type is generated from the backend DTO ("role" is absent for legacy pre-role rows). +export type { TenantAdminRow as TenantAdmin } from "@/utils/wire-types.generated"; diff --git a/src/Web/autopilot-monitor-web/contexts/AuthContext.tsx b/src/Web/autopilot-monitor-web/contexts/AuthContext.tsx index 532fe368..7dcd48e4 100644 --- a/src/Web/autopilot-monitor-web/contexts/AuthContext.tsx +++ b/src/Web/autopilot-monitor-web/contexts/AuthContext.tsx @@ -19,6 +19,7 @@ import { } from '@/lib/authApp'; import { api } from '@/lib/api'; import { trackEvent } from '@/lib/appInsights'; +import type { AuthMeResponse } from '@/types/auth'; // Initialize MSAL instance for the ACTIVE app registration (dual app-reg window: the // selection was decided at module boot in lib/msalConfig.ts; app switches always reload). @@ -33,7 +34,7 @@ let msalReady = false; // fetch failed / token acquisition failed. // Runs as a fire-and-forget side-effect — MUST NOT block msalInitPromise, // otherwise a cold backend would keep the UI on a white screen. -let prefetchedAuthMePromise: Promise | null> | null = null; +let prefetchedAuthMePromise: Promise | null = null; /** * Safety net of the dual app-reg model, for the rare browser that lands on an app its tenant @@ -108,7 +109,7 @@ const msalInitPromise = msalInstance headers: { 'Authorization': `Bearer ${tokenResponse.accessToken}` }, signal: AbortSignal.timeout(8000), }); - return res.ok ? (await res.json()) as Record : null; + return res.ok ? (await res.json()) as AuthMeResponse : null; }).catch(() => null); } }) @@ -158,33 +159,23 @@ async function safeAcquireTokenRedirect( } } -interface UserInfo { - displayName: string; - upn: string; - tenantId: string; - objectId: string; - isGlobalAdmin: boolean; - /** - * Read-only platform tier: cross-tenant VISIBILITY like a Global Admin but no platform mutations. - * ADDITIVE — a user may be both a GlobalReader and their own tenant's Admin (then isTenantAdmin is - * also true and they keep edit rights on their own tenant). Use {@link AuthContextType.hasGlobalScope} - * for visibility gating and isGlobalAdmin for platform-mutation gating. - */ - isGlobalReader: boolean; - isTenantAdmin: boolean; - /** - * Delegated ("MSP") admin: the caller manages a SUBSET of OTHER tenants (read-only this phase) — the - * "scoped global" tier between a single-tenant member and a platform Global Admin. True iff - * {@link delegatedTenantIds} is non-empty. Use {@link AuthContextType.hasFleetScope} for fleet/switcher gating. - */ - isDelegated: boolean; - /** The tenant IDs this caller manages as a delegated admin (lowercase). Empty for non-delegated users. */ - delegatedTenantIds: string[]; +/** + * Client-side auth state, derived from the generated auth/me wire type — a backend key + * rename goes tsc-red here instead of silently degrading to fallbacks. + * Deviations from the wire: `role` is an explicit null (the wire omits the key for a + * roleless caller), and `homedApp` is not carried (it is learned into localStorage via + * {@link learnHomedAppFromAuthMe}, never React state). + * + * Semantics reminders: + * - isGlobalReader: read-only platform tier — cross-tenant VISIBILITY like a Global Admin + * but no platform mutations. ADDITIVE (a GlobalReader can also be their own tenant's + * Admin). Use {@link AuthContextType.hasGlobalScope} for visibility gating and + * isGlobalAdmin for platform-mutation gating. + * - isDelegated / delegatedTenantIds: delegated ("MSP") admin managing a SUBSET of other + * tenants — use {@link AuthContextType.hasFleetScope} for fleet/switcher gating. + */ +interface UserInfo extends Omit { role: 'Admin' | 'Operator' | 'Viewer' | null; - canManageBootstrapTokens: boolean; - hasMcpAccess: boolean; - bootstrapTokenEnabled: boolean; - unrestrictedModeEnabled: boolean; } interface AuthContextType { @@ -214,7 +205,7 @@ interface AuthContextType { * This is also how an operator flip propagates: flip → next login of every user runs via the * new app, at which point the tenant's admin consent already exists. */ -function learnHomedAppFromAuthMe(data: Record): void { +function learnHomedAppFromAuthMe(data: AuthMeResponse): void { const homedApp = data.homedApp; if (homedApp === 'legacy' || homedApp === 'primary') { // Change-only trace: this browser just learned its tenant was flipped — the propagation @@ -226,6 +217,30 @@ function learnHomedAppFromAuthMe(data: Record): void { } } +/** + * Maps a successful auth/me body onto client auth state. Token-claim fallbacks cover the + * cold-start edge where a field arrives empty; the shape itself is compiler-checked + * against the generated wire type. + */ +function toUserInfo(data: AuthMeResponse, account: AccountInfo): UserInfo { + return { + displayName: data.displayName || account.name || '', + upn: data.upn || account.username || '', + tenantId: data.tenantId || account.tenantId || '', + objectId: data.objectId || account.homeAccountId || '', + isGlobalAdmin: data.isGlobalAdmin || false, + isGlobalReader: data.isGlobalReader || false, + isTenantAdmin: data.isTenantAdmin || false, + isDelegated: data.isDelegated || false, + delegatedTenantIds: data.delegatedTenantIds || [], + role: data.role ?? null, + canManageBootstrapTokens: data.canManageBootstrapTokens || false, + hasMcpAccess: data.hasMcpAccess || false, + bootstrapTokenEnabled: data.bootstrapTokenEnabled || false, + unrestrictedModeEnabled: data.unrestrictedModeEnabled || false, + }; +} + const AuthContext = createContext(undefined); /** @@ -255,22 +270,7 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) { prefetchedAuthMePromise = null; const data = await pending; if (data) learnHomedAppFromAuthMe(data); - if (data) return { - displayName: (data.displayName as string) || account.name || '', - upn: (data.upn as string) || account.username || '', - tenantId: (data.tenantId as string) || account.tenantId || '', - objectId: (data.objectId as string) || account.homeAccountId || '', - isGlobalAdmin: (data.isGlobalAdmin as boolean) || false, - isGlobalReader: (data.isGlobalReader as boolean) || false, - isTenantAdmin: (data.isTenantAdmin as boolean) || false, - isDelegated: (data.isDelegated as boolean) || false, - delegatedTenantIds: (data.delegatedTenantIds as string[]) || [], - role: (data.role as 'Admin' | 'Operator' | 'Viewer' | null) || null, - canManageBootstrapTokens: (data.canManageBootstrapTokens as boolean) || false, - hasMcpAccess: (data.hasMcpAccess as boolean) || false, - bootstrapTokenEnabled: (data.bootstrapTokenEnabled as boolean) || false, - unrestrictedModeEnabled: (data.unrestrictedModeEnabled as boolean) || false, - }; + if (data) return toUserInfo(data, account); } // Get access token for API @@ -334,31 +334,16 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) { throw new Error(`Failed to fetch user info: ${response.statusText}`); } - const data = await response.json(); + const data = (await response.json()) as AuthMeResponse; // A successful auth/me means the tenant is (now) activated — clear any pending // state so the activation page's poll can redirect into the portal. setActivationPending(false); setActivationMessage(''); - learnHomedAppFromAuthMe(data as Record); + learnHomedAppFromAuthMe(data); - return { - displayName: data.displayName || account.name || '', - upn: data.upn || account.username || '', - tenantId: data.tenantId || account.tenantId || '', - objectId: data.objectId || account.homeAccountId || '', - isGlobalAdmin: data.isGlobalAdmin || false, - isGlobalReader: data.isGlobalReader || false, - isTenantAdmin: data.isTenantAdmin || false, - isDelegated: data.isDelegated || false, - delegatedTenantIds: data.delegatedTenantIds || [], - role: data.role || null, - canManageBootstrapTokens: data.canManageBootstrapTokens || false, - hasMcpAccess: data.hasMcpAccess || false, - bootstrapTokenEnabled: data.bootstrapTokenEnabled || false, - unrestrictedModeEnabled: data.unrestrictedModeEnabled || false, - }; + return toUserInfo(data, account); } catch (error) { // If the refresh token is expired or consent is required, redirect to // interactive login immediately instead of falling back to stale claims. diff --git a/src/Web/autopilot-monitor-web/contexts/GlobalNotificationContext.tsx b/src/Web/autopilot-monitor-web/contexts/GlobalNotificationContext.tsx index 7415da62..5e29e6dc 100644 --- a/src/Web/autopilot-monitor-web/contexts/GlobalNotificationContext.tsx +++ b/src/Web/autopilot-monitor-web/contexts/GlobalNotificationContext.tsx @@ -5,15 +5,10 @@ import { useAuth } from './AuthContext'; import { useSignalR } from './SignalRContext'; import { api } from '@/lib/api'; import { authenticatedFetch } from '@/lib/authenticatedFetch'; +import type { GlobalNotificationDto, NotificationListResponse } from '@/utils/wire-types.generated'; -export interface GlobalNotification { - id: string; - type: string; - title: string; - message: string; - href?: string; - createdAt: string; -} +// Wire type is generated from the backend DTO; the alias keeps the established local name. +export type GlobalNotification = GlobalNotificationDto; interface GlobalNotificationContextType { notifications: GlobalNotification[]; @@ -51,7 +46,7 @@ export function GlobalNotificationProvider({ children }: { children: React.React getAccessToken, ); if (response.ok) { - const data = await response.json(); + const data = (await response.json()) as NotificationListResponse; setNotifications(data.notifications ?? []); } } catch { diff --git a/src/Web/autopilot-monitor-web/contexts/TenantNotificationContext.tsx b/src/Web/autopilot-monitor-web/contexts/TenantNotificationContext.tsx index f87c2ce1..9e005260 100644 --- a/src/Web/autopilot-monitor-web/contexts/TenantNotificationContext.tsx +++ b/src/Web/autopilot-monitor-web/contexts/TenantNotificationContext.tsx @@ -6,15 +6,10 @@ import { useSignalR } from './SignalRContext'; import { canFetchTenantNotifications } from './tenantNotificationsGate'; import { api } from '@/lib/api'; import { authenticatedFetch } from '@/lib/authenticatedFetch'; +import type { GlobalNotificationDto, NotificationListResponse } from '@/utils/wire-types.generated'; -export interface TenantNotification { - id: string; - type: string; - title: string; - message: string; - href?: string; - createdAt: string; -} +// Both notification endpoints share one backend DTO; the alias keeps the established local name. +export type TenantNotification = GlobalNotificationDto; interface TenantNotificationContextType { tenantNotifications: TenantNotification[]; @@ -54,7 +49,7 @@ export function TenantNotificationProvider({ children }: { children: React.React getAccessToken, ); if (response.ok) { - const data = await response.json(); + const data = (await response.json()) as NotificationListResponse; setTenantNotifications(data.notifications ?? []); } } catch { diff --git a/src/Web/autopilot-monitor-web/types/auth.ts b/src/Web/autopilot-monitor-web/types/auth.ts new file mode 100644 index 00000000..c5ef2e64 --- /dev/null +++ b/src/Web/autopilot-monitor-web/types/auth.ts @@ -0,0 +1,16 @@ +// The auth/me wire shape is GENERATED from the C# contract (AuthMeResponse in +// AutopilotMonitor.Shared → utils/wire-types.generated.ts). This module narrows the +// two plain-string wire slots to the canonical literals the backend actually emits, +// so a backend rename or type change goes tsc-red in every consumer. +import type { AuthMeResponse as WireAuthMeResponse } from "@/utils/wire-types.generated"; + +/** Tenant role emitted by auth/me ("role" is absent on the wire for a roleless caller). */ +export type TenantRole = "Admin" | "Operator" | "Viewer"; + +/** Which app registration the tenant is homed on (dual app-reg operation model). */ +export type HomedApp = "primary" | "legacy"; + +export type AuthMeResponse = Omit & { + role?: TenantRole; + homedApp: HomedApp; +}; diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.json b/src/Web/autopilot-monitor-web/utils/shared-manifests.json index 435c129f..6b4b06ee 100644 --- a/src/Web/autopilot-monitor-web/utils/shared-manifests.json +++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.json @@ -494,13 +494,13 @@ }, "AddGlobalAdminResponse": { "kind": "object", - "doc": "Response of POST auth/global-admins (201): the created GlobalAdminEntity (backend-project type, serialized by runtime type).", + "doc": "Response of POST auth/global-admins (201): the created row.", "fields": [ { "name": "admin", "type": { - "kind": "primitive", - "name": "unknown" + "kind": "ref", + "name": "GlobalAdminRow" } } ] @@ -2905,6 +2905,125 @@ } ] }, + "AuthMeResponse": { + "kind": "object", + "doc": "Success body of GET auth/me: the caller's resolved identity, roles and effective entitlement flags. Blocked outcomes (TenantSuspended / PendingActivation) are error shapes and stay anonymous by design.", + "fields": [ + { + "name": "tenantId", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "upn", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "displayName", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "objectId", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "isGlobalAdmin", + "type": { + "kind": "primitive", + "name": "boolean" + } + }, + { + "name": "isGlobalReader", + "type": { + "kind": "primitive", + "name": "boolean" + } + }, + { + "name": "isTenantAdmin", + "type": { + "kind": "primitive", + "name": "boolean" + } + }, + { + "name": "isDelegated", + "type": { + "kind": "primitive", + "name": "boolean" + }, + "doc": "True when the caller holds delegated (\"MSP\") assignments to other tenants." + }, + { + "name": "delegatedTenantIds", + "type": { + "kind": "array", + "element": { + "kind": "primitive", + "name": "string" + } + }, + "doc": "The OTHER tenants this caller may manage; empty for non-delegated callers." + }, + { + "name": "role", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true, + "doc": "Tenant role (Admin / Operator / Viewer); the key is omitted for a roleless caller." + }, + { + "name": "canManageBootstrapTokens", + "type": { + "kind": "primitive", + "name": "boolean" + } + }, + { + "name": "hasMcpAccess", + "type": { + "kind": "primitive", + "name": "boolean" + } + }, + { + "name": "homedApp", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "\"primary\" or \"legacy\" — which app registration this tenant is homed on." + }, + { + "name": "bootstrapTokenEnabled", + "type": { + "kind": "primitive", + "name": "boolean" + } + }, + { + "name": "unrestrictedModeEnabled", + "type": { + "kind": "primitive", + "name": "boolean" + } + } + ] + }, "AutoResolveCpeMappingResponse": { "kind": "object", "doc": "Response of POST vulnerability/cpe-mapping/auto-resolve: per-item outcomes plus totals.", @@ -3086,6 +3205,147 @@ } ] }, + "BackfillJobRunResponse": { + "kind": "object", + "doc": "Response of POST maintenance/backfill-occurred-utc: the backfill run report plus trigger attribution. Key layout is identical to ReclassifyJobRunResponse — the two one-shot job triggers share one wire shape, split into two classes only for the typed slot.", + "fields": [ + { + "name": "success", + "type": { + "kind": "primitive", + "name": "boolean" + } + }, + { + "name": "result", + "type": { + "kind": "ref", + "name": "BackfillResult" + } + }, + { + "name": "triggeredBy", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "triggeredAt", + "type": { + "kind": "primitive", + "name": "string" + } + } + ] + }, + "BackfillResult": { + "kind": "object", + "doc": "Run report of the one-shot OccurredUtc backfill (POST maintenance/backfill-occurred-utc).", + "fields": [ + { + "name": "table", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "dryRun", + "type": { + "kind": "primitive", + "name": "boolean" + } + }, + { + "name": "rowsExamined", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "wouldWrite", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "written", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "skippedAlreadySet", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "skippedUndecodable", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "errors", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "nextContinuation", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true + }, + { + "name": "samples", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "BackfillSample" + } + } + } + ] + }, + "BackfillSample": { + "kind": "object", + "doc": "One sampled row of a backfill run. PartitionKey/RowKey are PAYLOAD here — they identify the sampled storage row in a Global-Admin-only run report, not ITableEntity metadata.", + "fields": [ + { + "name": "partitionKey", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "rowKey", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "decodedUtc", + "type": { + "kind": "primitive", + "name": "string" + } + } + ] + }, "BlockDeviceResponse": { "kind": "object", "doc": "Response of POST devices/block: block/kill acknowledgement.", @@ -3357,6 +3617,79 @@ } ] }, + "CheckMcpAccessResponse": { + "kind": "object", + "doc": "Body of GET auth/mcp (200 when allowed, 403 when denied — same shape on both). The four platform/delegated keys are emitted ONLY when the caller actually holds the tier (null ⇒ key omitted): ordinary tenant users must not learn a platform tier exists, and IsGlobalAdmin is only ever emitted as literal true.", + "fields": [ + { + "name": "allowed", + "type": { + "kind": "primitive", + "name": "boolean" + } + }, + { + "name": "upn", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "accessGrant", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "reason", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "isGlobalAdmin", + "type": { + "kind": "primitive", + "name": "boolean" + }, + "optional": true, + "doc": "Back-compat / write-tier hint for the MCP access-guard; true or omitted, never false." + }, + { + "name": "globalRole", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true, + "doc": "\"GlobalAdmin\" | \"GlobalReader\"; omitted without a platform role." + }, + { + "name": "delegatedTenantIds", + "type": { + "kind": "array", + "element": { + "kind": "primitive", + "name": "string" + } + }, + "optional": true, + "doc": "Managed tenant ids (lowercase) of a delegated (MSP) caller; omitted otherwise." + }, + { + "name": "delegatedRole", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true, + "doc": "\"DelegatedAdmin\" | \"DelegatedReader\"; omitted without delegated scope." + } + ] + }, "ConfidenceFactor": { "kind": "object", "doc": "A factor that increases confidence when matched", @@ -4247,7 +4580,7 @@ }, "DetailedHealthCheckResponse": { "kind": "object", - "doc": "Response of GET health/detailed: the full system health report (always 200; per-check status is in the body). Items are HealthCheck objects (backend-project type, serialized by runtime type); non-GA callers get a filtered list.", + "doc": "Response of GET health/detailed: the full system health report (always 200; per-check status is in the body). Non-GA callers get a filtered check list.", "fields": [ { "name": "service", @@ -4275,8 +4608,8 @@ "type": { "kind": "array", "element": { - "kind": "primitive", - "name": "unknown" + "kind": "ref", + "name": "HealthCheck" } } }, @@ -5200,10 +5533,10 @@ { "name": "result", "type": { - "kind": "primitive", - "name": "unknown" + "kind": "ref", + "name": "RuleDryRun" }, - "doc": "The RuleDryRun trace (AutopilotMonitor.Functions.Services) — typed object here only because that class lives in the Functions assembly; System.Text.Json serializes the runtime type, so the wire shape is unchanged." + "doc": "The full diagnostic trace of the evaluation." } ] }, @@ -6579,15 +6912,15 @@ }, "GetGlobalAdminsResponse": { "kind": "object", - "doc": "Response of GET auth/global-admins: every Global Admin/Reader row. Items are GlobalAdminEntity table entities (backend-project type, serialized by runtime type — includes the ITableEntity keys, exactly as the anonymous site did).", + "doc": "Response of GET auth/global-admins: every Global Admin/Reader row.", "fields": [ { "name": "admins", "type": { "kind": "array", "element": { - "kind": "primitive", - "name": "unknown" + "kind": "ref", + "name": "GlobalAdminRow" } } } @@ -6951,11 +7284,10 @@ "type": { "kind": "array", "element": { - "kind": "primitive", - "name": "unknown" + "kind": "ref", + "name": "PreviewWhitelistTenantEntry" } - }, - "doc": "PreviewWhitelistEntity rows (AutopilotMonitor.Functions.DataAccess.TableStorage) — element-typed object here only because that entity lives in the Functions assembly; System.Text.Json serializes the runtime type, so the wire shape is unchanged." + } } ] }, @@ -7599,11 +7931,10 @@ "type": { "kind": "array", "element": { - "kind": "primitive", - "name": "unknown" + "kind": "ref", + "name": "TenantConfigFieldSchema" } - }, - "doc": "Schema entries (TenantConfigFieldSchema records, defined in the Functions project — typed as object here because Shared cannot reference it)." + } } ] }, @@ -7877,6 +8208,49 @@ } ] }, + "GlobalAdminRow": { + "kind": "object", + "doc": "One Global Admin/Reader row on the wire. Deliberately NOT the storage entity: the ITableEntity keys (partitionKey/rowKey/eTag/timestamp) that the pre-2026-08-31 wire carried are storage internals and were dropped from the contract (no consumer read them).", + "fields": [ + { + "name": "upn", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "User Principal Name (lowercase)." + }, + { + "name": "isEnabled", + "type": { + "kind": "primitive", + "name": "boolean" + } + }, + { + "name": "addedDate", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "addedBy", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "role", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "\"GlobalAdmin\" or \"GlobalReader\" (legacy empty rows are normalized to GlobalAdmin)." + } + ] + }, "GlobalAverages": { "kind": "object", "doc": "Global average benchmarks for geographic comparison.", @@ -7958,6 +8332,55 @@ } ] }, + "GlobalNotificationDto": { + "kind": "object", + "doc": "One in-app notification (global pool or tenant-scoped; both endpoints share this DTO). A null Href key is omitted (WhenWritingNull).", + "fields": [ + { + "name": "id", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "type", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "title", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "message", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "href", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true + }, + { + "name": "createdAt", + "type": { + "kind": "primitive", + "name": "string" + } + } + ] + }, "GraphFeatureStatusItem": { "kind": "object", "doc": "One feature row of the graph-permissions status matrix: the feature identifier, the granted verdict (null while the snapshot is transient) and the Graph application permissions the feature requires.", @@ -8138,6 +8561,51 @@ } ] }, + "HealthCheck": { + "kind": "object", + "doc": "One health check result inside GET health/detailed / health/mcp. Details is a heterogeneous per-check bag by design (endpoint URLs only for Global Admins); a null Details key is omitted (WhenWritingNull).", + "fields": [ + { + "name": "name", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "description", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "status", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "message", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "details", + "type": { + "kind": "map", + "value": { + "kind": "primitive", + "name": "unknown" + } + }, + "optional": true + } + ] + }, "HealthCheckResponse": { "kind": "object", "doc": "Response of GET health: liveness probe plus the backend build identity.", @@ -9882,9 +10350,9 @@ } ] }, - "MaintenanceJobRunResponse": { + "MaintenanceResult": { "kind": "object", - "doc": "Shared response of the one-shot maintenance job triggers (POST maintenance/backfill-occurred-utc, POST maintenance/reclassify-legacy): the service's run report plus trigger attribution.", + "doc": "Run report of a manual maintenance run (POST maintenance/trigger).", "fields": [ { "name": "success", @@ -9894,12 +10362,12 @@ } }, { - "name": "result", + "name": "error", "type": { "kind": "primitive", - "name": "unknown" + "name": "string" }, - "doc": "The service-specific run report (BackfillResult / ReclassificationResult — types owned by the Functions project, so this stays object; serialized by runtime type, wire-identical)." + "optional": true }, { "name": "triggeredBy", @@ -9914,12 +10382,69 @@ "kind": "primitive", "name": "string" } + }, + { + "name": "durationMs", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "stalledSessionsChecked", + "type": { + "kind": "primitive", + "name": "boolean" + } + }, + { + "name": "metricsAggregated", + "type": { + "kind": "primitive", + "name": "boolean" + } + }, + { + "name": "aggregatedDate", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true + }, + { + "name": "dataCleanupExecuted", + "type": { + "kind": "primitive", + "name": "boolean" + } + }, + { + "name": "platformStatsRecomputed", + "type": { + "kind": "primitive", + "name": "boolean" + } + }, + { + "name": "devicesBlockedForExcessiveData", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "contactEmailsBackfilled", + "type": { + "kind": "primitive", + "name": "number" + } } ] }, "McpHealthCheckResponse": { "kind": "object", - "doc": "Response of GET health/mcp: the standalone MCP-server reachability probe. The check is a HealthCheck object (backend-project type, serialized by runtime type).", + "doc": "Response of GET health/mcp: the standalone MCP-server reachability probe.", "fields": [ { "name": "timestamp", @@ -9931,8 +10456,8 @@ { "name": "check", "type": { - "kind": "primitive", - "name": "unknown" + "kind": "ref", + "name": "HealthCheck" } } ] @@ -10081,7 +10606,7 @@ }, "MetricsSummaryResponse": { "kind": "object", - "doc": "Per-tenant session-status tally envelope shared by MetricsSummary and MetricsSummaryGlobal. Summary items are repository-built rows shaped like MetricsSummaryTenantItem.", + "doc": "Per-tenant session-status tally envelope shared by MetricsSummary and MetricsSummaryGlobal.", "fields": [ { "name": "success", @@ -10095,7 +10620,7 @@ "type": { "kind": "array", "element": { - "kind": "partial", + "kind": "ref", "name": "MetricsSummaryTenantItem" } } @@ -10111,7 +10636,7 @@ }, "MetricsSummaryTenantItem": { "kind": "object", - "doc": "Wire shape of one per-tenant status tally in MetricsSummaryResponse (documentation type — the repository still builds the rows; every key is always present).", + "doc": "One per-tenant status tally in MetricsSummaryResponse. WindowDays is repeated per item (envelope carries it too — historical wire shape, kept for parity).", "fields": [ { "name": "tenantId", @@ -10202,7 +10727,7 @@ }, "NotificationListResponse": { "kind": "object", - "doc": "Shared response of GET global/notifications and GET notifications: the active (non-dismissed) notifications visible to the caller, newest first. Items are GlobalNotificationDto objects (backend-project type, serialized by runtime type).", + "doc": "Shared response of GET global/notifications and GET notifications: the active (non-dismissed) notifications visible to the caller, newest first.", "fields": [ { "name": "success", @@ -10216,8 +10741,8 @@ "type": { "kind": "array", "element": { - "kind": "primitive", - "name": "unknown" + "kind": "ref", + "name": "GlobalNotificationDto" } } } @@ -10847,6 +11372,19 @@ } ] }, + "PreviewWhitelistTenantEntry": { + "kind": "object", + "doc": "One approved tenant on the wire. Deliberately NOT the storage entity: the pre-2026-08-31 wire carried synthetic PreviewWhitelistEntity rows whose only real datum was the tenant id in partitionKey (plus garbage defaults) — the contract is now just the id.", + "fields": [ + { + "name": "tenantId", + "type": { + "kind": "primitive", + "name": "string" + } + } + ] + }, "ProgressGetSessionEventsResponse": { "kind": "object", "doc": "Response of GET /api/progress/sessions/{sessionId}/events — the session's event stream after the serial knowledge proof passed.", @@ -11186,6 +11724,182 @@ } ] }, + "ReclassificationResult": { + "kind": "object", + "doc": "Run report of the legacy-session reclassification job (POST maintenance/reclassify-legacy).", + "fields": [ + { + "name": "mode", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "dryRun", + "type": { + "kind": "primitive", + "name": "boolean" + } + }, + { + "name": "tenantsExamined", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "sessionsExamined", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "wouldChange", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "changed", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "toSucceeded", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "toIncomplete", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "keptFailed", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "skipped", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "errors", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "capReached", + "type": { + "kind": "primitive", + "name": "boolean" + }, + "doc": "True when the maxSessions cap stopped the run before the backlog was exhausted — re-run to continue." + }, + { + "name": "samples", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "ReclassificationSample" + } + }, + "readonly": true + } + ] + }, + "ReclassificationSample": { + "kind": "object", + "fields": [ + { + "name": "tenantId", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "sessionId", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "oldStatus", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "newStatus", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "reason", + "type": { + "kind": "primitive", + "name": "string" + } + } + ] + }, + "ReclassifyJobRunResponse": { + "kind": "object", + "doc": "Response of POST maintenance/reclassify-legacy: the reclassification run report plus trigger attribution. See BackfillJobRunResponse for the shared key layout.", + "fields": [ + { + "name": "success", + "type": { + "kind": "primitive", + "name": "boolean" + } + }, + { + "name": "result", + "type": { + "kind": "ref", + "name": "ReclassificationResult" + } + }, + { + "name": "triggeredBy", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "triggeredAt", + "type": { + "kind": "primitive", + "name": "string" + } + } + ] + }, "ReducerVerificationReport": { "kind": "object", "doc": "Structural health report for a session's persisted SignalLog + DecisionTransitions journal. Produced by GET /api/sessions/{id}/reducer-verification (Plan §M5, admin/ops endpoint, not tenant-exposed). Scope: this report covers structural invariants that can be checked without running the reducer — ordinal contiguity, cross-references, ReducerVersion drift, counts. A full engine replay with per-step diff would require polymorphic deserialisation of the DecisionSignal.Evidence payload and is a dedicated follow-up.", @@ -11701,6 +12415,246 @@ } ] }, + "RuleDryRun": { + "kind": "object", + "doc": "Full diagnostic trace of one dry-run evaluation. Serialized camelCase to clients.", + "fields": [ + { + "name": "verdict", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "eventCount", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Number of events in the session the rule was evaluated against." + }, + { + "name": "preconditions", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "RuleDryRunPrecondition" + } + }, + "readonly": true + }, + { + "name": "conditions", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "RuleDryRunCondition" + } + }, + "readonly": true + }, + { + "name": "confidenceFactors", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "RuleDryRunFactor" + } + }, + "readonly": true, + "doc": "Empty unless all required conditions were met (mirrors the production path, which never reaches factor evaluation otherwise)." + }, + { + "name": "baseConfidence", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "finalConfidence", + "type": { + "kind": "primitive", + "name": "number" + }, + "optional": true, + "doc": "base + matched factor weights, capped at 100. Null when the evaluation ended before the confidence stage (precondition skip / required miss / nothing matched)." + }, + { + "name": "confidenceThreshold", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "wouldMarkSessionAsFailed", + "type": { + "kind": "primitive", + "name": "boolean" + }, + "doc": "True only for verdict \"fired\" AND the rule's effective MarkSessionAsFailed flag. The dry-run itself never touches the session." + }, + { + "name": "matchedConditions", + "type": { + "kind": "map", + "value": { + "kind": "primitive", + "name": "unknown" + } + }, + "optional": true, + "doc": "The evidence map exactly as the production path would persist it on a RuleResult — keys are condition signals (plus factor_* markers). Clients use it to preview {{token}} interpolation of explanation/remediation. Values are heterogeneous evidence objects by design." + } + ] + }, + "RuleDryRunCondition": { + "kind": "object", + "fields": [ + { + "name": "signal", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "source", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "eventType", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true + }, + { + "name": "required", + "type": { + "kind": "primitive", + "name": "boolean" + } + }, + { + "name": "matched", + "type": { + "kind": "primitive", + "name": "boolean" + } + }, + { + "name": "evidence", + "type": { + "kind": "primitive", + "name": "unknown" + }, + "optional": true, + "doc": "Matched: the evidence dictionary (eventId, timestamp, field, value, …). Not matched: the evaluator's reason string (e.g. \"no matching events\")." + }, + { + "name": "matchingEventCount", + "type": { + "kind": "primitive", + "name": "number" + }, + "optional": true, + "doc": "How many session events have this condition's eventType at all — the first thing an author checks when a condition unexpectedly doesn't match. Null when the condition has no eventType." + } + ] + }, + "RuleDryRunFactor": { + "kind": "object", + "fields": [ + { + "name": "signal", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "condition", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "weight", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "matched", + "type": { + "kind": "primitive", + "name": "boolean" + } + } + ] + }, + "RuleDryRunPrecondition": { + "kind": "object", + "fields": [ + { + "name": "source", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "eventType", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "dataField", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true + }, + { + "name": "operator", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true + }, + { + "name": "value", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true + }, + { + "name": "passed", + "type": { + "kind": "primitive", + "name": "boolean" + } + } + ] + }, "RulePrecondition": { "kind": "object", "doc": "A device-fact gate evaluated before a rule's conditions. Pure boolean filter — does not contribute to evidence or confidence. When any precondition on a rule fails, the rule is silently skipped (no result emitted). Currently only event_data source is supported.", @@ -14851,11 +15805,70 @@ "fields": [ { "name": "admin", + "type": { + "kind": "ref", + "name": "TenantAdminRow" + } + } + ] + }, + "TenantAdminRow": { + "kind": "object", + "doc": "One tenant member (Admin / Operator / Viewer) on the wire — the item of the bare-array GET tenants/{tenantId}/admins and the created member of the POST. Deliberately NOT the storage entity: the ITableEntity keys (partitionKey/rowKey/eTag/timestamp) that the pre-2026-08-31 wire carried are storage internals and were dropped from the contract (no consumer read them).", + "fields": [ + { + "name": "tenantId", "type": { "kind": "primitive", - "name": "unknown" + "name": "string" }, - "doc": "The stored TenantAdminEntity row (type owned by the Functions project, so this stays object; serialized by runtime type, wire-identical)." + "doc": "Tenant ID (lowercase)." + }, + { + "name": "upn", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "User Principal Name (lowercase)." + }, + { + "name": "isEnabled", + "type": { + "kind": "primitive", + "name": "boolean" + } + }, + { + "name": "addedDate", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "addedBy", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "role", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true, + "doc": "Role: \"Admin\", \"Operator\", \"Viewer\". A null (legacy pre-role row) omits the key and means Admin." + }, + { + "name": "canManageBootstrapTokens", + "type": { + "kind": "primitive", + "name": "boolean" + }, + "doc": "Whether this Operator can manage bootstrap tokens (only relevant for Operator)." } ] }, @@ -14916,6 +15929,70 @@ } ] }, + "TenantConfigFieldSchema": { + "kind": "object", + "doc": "One entry of the tenant-config field schema (built by TenantConfigPatchService.BuildFieldsSchema). Serialized camelCase; null Format/Reason keys are omitted (WhenWritingNull) — the null and set cases are pinned by ConfigWireParityTests.", + "fields": [ + { + "name": "name", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "type", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "format", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true + }, + { + "name": "nullable", + "type": { + "kind": "primitive", + "name": "boolean" + } + }, + { + "name": "writable", + "type": { + "kind": "primitive", + "name": "boolean" + } + }, + { + "name": "reason", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true + }, + { + "name": "gaOnly", + "type": { + "kind": "primitive", + "name": "boolean" + } + }, + { + "name": "revertProtected", + "type": { + "kind": "primitive", + "name": "boolean" + } + } + ] + }, "TenantConfigPatchOutcomeResponse": { "kind": "object", "doc": "Success response of PATCH config/{tenantId}/fields and POST config/{tenantId}/revert (both flow through the same outcome writer): which fields changed, the masked diff, and the pre-write backup id.", @@ -16838,10 +17915,9 @@ { "name": "result", "type": { - "kind": "primitive", - "name": "unknown" - }, - "doc": "The MaintenanceResult run report (type owned by the Functions project, so this stays object; serialized by runtime type, wire-identical)." + "kind": "ref", + "name": "MaintenanceResult" + } }, { "name": "triggeredBy", diff --git a/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts b/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts index 6eabadfd..7dc79dd0 100644 --- a/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts +++ b/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts @@ -17,9 +17,9 @@ export interface ActiveUserItem { secondsAgo: number; } -/** Response of POST auth/global-admins (201): the created GlobalAdminEntity (backend-project type, serialized by runtime type). */ +/** Response of POST auth/global-admins (201): the created row. */ export interface AddGlobalAdminResponse { - admin: unknown; + admin: GlobalAdminRow; } /** Response of POST vulnerability/ignored-software: how many entries were added. */ @@ -540,6 +540,29 @@ export interface AuditLogListResponse { nextLink?: string; } +/** Success body of GET auth/me: the caller's resolved identity, roles and effective entitlement flags. Blocked outcomes (TenantSuspended / PendingActivation) are error shapes and stay anonymous by design. */ +export interface AuthMeResponse { + tenantId: string; + upn: string; + displayName: string; + objectId: string; + isGlobalAdmin: boolean; + isGlobalReader: boolean; + isTenantAdmin: boolean; + /** True when the caller holds delegated ("MSP") assignments to other tenants. */ + isDelegated: boolean; + /** The OTHER tenants this caller may manage; empty for non-delegated callers. */ + delegatedTenantIds: string[]; + /** Tenant role (Admin / Operator / Viewer); the key is omitted for a roleless caller. */ + role?: string; + canManageBootstrapTokens: boolean; + hasMcpAccess: boolean; + /** "primary" or "legacy" — which app registration this tenant is homed on. */ + homedApp: string; + bootstrapTokenEnabled: boolean; + unrestrictedModeEnabled: boolean; +} + /** Response of POST vulnerability/cpe-mapping/auto-resolve: per-item outcomes plus totals. */ export interface AutoResolveCpeMappingResponse { resolved: AutoResolveResultItem[]; @@ -588,6 +611,35 @@ export interface AutopilotConsentUrlResponse { willAutoFlipHoming: boolean; } +/** Response of POST maintenance/backfill-occurred-utc: the backfill run report plus trigger attribution. Key layout is identical to ReclassifyJobRunResponse — the two one-shot job triggers share one wire shape, split into two classes only for the typed slot. */ +export interface BackfillJobRunResponse { + success: boolean; + result: BackfillResult; + triggeredBy: string; + triggeredAt: string; +} + +/** Run report of the one-shot OccurredUtc backfill (POST maintenance/backfill-occurred-utc). */ +export interface BackfillResult { + table: string; + dryRun: boolean; + rowsExamined: number; + wouldWrite: number; + written: number; + skippedAlreadySet: number; + skippedUndecodable: number; + errors: number; + nextContinuation?: string; + samples: BackfillSample[]; +} + +/** One sampled row of a backfill run. PartitionKey/RowKey are PAYLOAD here — they identify the sampled storage row in a Global-Admin-only run report, not ITableEntity metadata. */ +export interface BackfillSample { + partitionKey: string; + rowKey: string; + decodedUtc: string; +} + /** Response of POST devices/block: block/kill acknowledgement. */ export interface BlockDeviceResponse { success: boolean; @@ -648,6 +700,22 @@ export interface BlockingAppInterval { seconds: number; } +/** Body of GET auth/mcp (200 when allowed, 403 when denied — same shape on both). The four platform/delegated keys are emitted ONLY when the caller actually holds the tier (null ⇒ key omitted): ordinary tenant users must not learn a platform tier exists, and IsGlobalAdmin is only ever emitted as literal true. */ +export interface CheckMcpAccessResponse { + allowed: boolean; + upn: string; + accessGrant: string; + reason: string; + /** Back-compat / write-tier hint for the MCP access-guard; true or omitted, never false. */ + isGlobalAdmin?: boolean; + /** "GlobalAdmin" | "GlobalReader"; omitted without a platform role. */ + globalRole?: string; + /** Managed tenant ids (lowercase) of a delegated (MSP) caller; omitted otherwise. */ + delegatedTenantIds?: string[]; + /** "DelegatedAdmin" | "DelegatedReader"; omitted without delegated scope. */ + delegatedRole?: string; +} + /** A factor that increases confidence when matched */ export interface ConfidenceFactor { /** Descriptive name for this factor */ @@ -851,12 +919,12 @@ export interface DeploymentTypeMetrics { whiteGlovePercentage: number; } -/** Response of GET health/detailed: the full system health report (always 200; per-check status is in the body). Items are HealthCheck objects (backend-project type, serialized by runtime type); non-GA callers get a filtered list. */ +/** Response of GET health/detailed: the full system health report (always 200; per-check status is in the body). Non-GA callers get a filtered check list. */ export interface DetailedHealthCheckResponse { service: string; timestamp: string; overallStatus: string; - checks: unknown[]; + checks: HealthCheck[]; version: string; commitHash: string; buildUtc: string; @@ -1062,8 +1130,8 @@ export interface DistressReportListResponse { export interface DryRunAnalyzeRuleResponse { success: boolean; sessionId: string; - /** The RuleDryRun trace (AutopilotMonitor.Functions.Services) — typed object here only because that class lives in the Functions assembly; System.Text.Json serializes the runtime type, so the wire shape is unchanged. */ - result: unknown; + /** The full diagnostic trace of the evaluation. */ + result: RuleDryRun; } export interface EfficiencyOffender { @@ -1393,9 +1461,9 @@ export interface GetDeviceHistoryResponse { attemptNumber?: number; } -/** Response of GET auth/global-admins: every Global Admin/Reader row. Items are GlobalAdminEntity table entities (backend-project type, serialized by runtime type — includes the ITableEntity keys, exactly as the anonymous site did). */ +/** Response of GET auth/global-admins: every Global Admin/Reader row. */ export interface GetGlobalAdminsResponse { - admins: unknown[]; + admins: GlobalAdminRow[]; } /** Global daily MCP/API usage summaries envelope (GetGlobalMcpUsageDaily). The MCP server paginates over the summaries key — its name is wire-critical. */ @@ -1485,8 +1553,7 @@ export interface GetPreviewNotificationEmailResponse { /** Response of GET preview/whitelist: every approved tenant (Global Admin only). */ export interface GetPreviewWhitelistResponse { - /** PreviewWhitelistEntity rows (AutopilotMonitor.Functions.DataAccess.TableStorage) — element-typed object here only because that entity lives in the Functions assembly; System.Text.Json serializes the runtime type, so the wire shape is unchanged. */ - tenants: unknown[]; + tenants: PreviewWhitelistTenantEntry[]; } /** Session ids where a rule produced a result within the window (GetRuleHitSessions) — powers the dashboard's ?ruleId= deep link. */ @@ -1632,8 +1699,7 @@ export interface GetSessionTimeAttributionResponse { export interface GetTenantConfigFieldsSchemaResponse { count: number; writableCount: number; - /** Schema entries (TenantConfigFieldSchema records, defined in the Functions project — typed as object here because Shared cannot reference it). */ - fields: unknown[]; + fields: TenantConfigFieldSchema[]; } /** Per-tenant deletion-manifest tree envelope (GetTenantDeletionManifests). */ @@ -1694,6 +1760,17 @@ export interface GetVulnerabilitySyncStatusResponse { epssLastRun?: EpssRefreshRunWire; } +/** One Global Admin/Reader row on the wire. Deliberately NOT the storage entity: the ITableEntity keys (partitionKey/rowKey/eTag/timestamp) that the pre-2026-08-31 wire carried are storage internals and were dropped from the contract (no consumer read them). */ +export interface GlobalAdminRow { + /** User Principal Name (lowercase). */ + upn: string; + isEnabled: boolean; + addedDate: string; + addedBy: string; + /** "GlobalAdmin" or "GlobalReader" (legacy empty rows are normalized to GlobalAdmin). */ + role: string; +} + /** Global average benchmarks for geographic comparison. */ export interface GlobalAverages { avgDurationMinutes: number; @@ -1713,6 +1790,16 @@ export interface GlobalAverages { totalDoBytesFromHttp: number; } +/** One in-app notification (global pool or tenant-scoped; both endpoints share this DTO). A null Href key is omitted (WhenWritingNull). */ +export interface GlobalNotificationDto { + id: string; + type: string; + title: string; + message: string; + href?: string; + createdAt: string; +} + /** One feature row of the graph-permissions status matrix: the feature identifier, the granted verdict (null while the snapshot is transient) and the Graph application permissions the feature requires. */ export interface GraphFeatureStatusItem { name: string; @@ -1753,6 +1840,15 @@ export interface HardwareRejectedResponse { dataQualityNotice: string; } +/** One health check result inside GET health/detailed / health/mcp. Details is a heterogeneous per-check bag by design (endpoint URLs only for Global Admins); a null Details key is omitted (WhenWritingNull). */ +export interface HealthCheck { + name: string; + description: string; + status: string; + message: string; + details?: Record; +} + /** Response of GET health: liveness probe plus the backend build identity. */ export interface HealthCheckResponse { status: string; @@ -2100,19 +2196,26 @@ export interface LogPatternTestResult { readonly notes: string[]; } -/** Shared response of the one-shot maintenance job triggers (POST maintenance/backfill-occurred-utc, POST maintenance/reclassify-legacy): the service's run report plus trigger attribution. */ -export interface MaintenanceJobRunResponse { +/** Run report of a manual maintenance run (POST maintenance/trigger). */ +export interface MaintenanceResult { success: boolean; - /** The service-specific run report (BackfillResult / ReclassificationResult — types owned by the Functions project, so this stays object; serialized by runtime type, wire-identical). */ - result: unknown; + error?: string; triggeredBy: string; triggeredAt: string; + durationMs: number; + stalledSessionsChecked: boolean; + metricsAggregated: boolean; + aggregatedDate?: string; + dataCleanupExecuted: boolean; + platformStatsRecomputed: boolean; + devicesBlockedForExcessiveData: number; + contactEmailsBackfilled: number; } -/** Response of GET health/mcp: the standalone MCP-server reachability probe. The check is a HealthCheck object (backend-project type, serialized by runtime type). */ +/** Response of GET health/mcp: the standalone MCP-server reachability probe. */ export interface McpHealthCheckResponse { timestamp: string; - check: unknown; + check: HealthCheck; } /** 429 body written by McpQuotaEnforcementMiddleware when the per-user MCP daily/monthly quota is exhausted (structurally a success shape: first key is quotaExceeded). */ @@ -2147,14 +2250,14 @@ export interface McpUserEntry { usagePlan?: string; } -/** Per-tenant session-status tally envelope shared by MetricsSummary and MetricsSummaryGlobal. Summary items are repository-built rows shaped like MetricsSummaryTenantItem. */ +/** Per-tenant session-status tally envelope shared by MetricsSummary and MetricsSummaryGlobal. */ export interface MetricsSummaryResponse { success: boolean; - summary: Partial[]; + summary: MetricsSummaryTenantItem[]; windowDays: number; } -/** Wire shape of one per-tenant status tally in MetricsSummaryResponse (documentation type — the repository still builds the rows; every key is always present). */ +/** One per-tenant status tally in MetricsSummaryResponse. WindowDays is repeated per item (envelope carries it too — historical wire shape, kept for parity). */ export interface MetricsSummaryTenantItem { tenantId: string; totalSessions: number; @@ -2171,10 +2274,10 @@ export interface MetricsSummaryTenantItem { windowDays: number; } -/** Shared response of GET global/notifications and GET notifications: the active (non-dismissed) notifications visible to the caller, newest first. Items are GlobalNotificationDto objects (backend-project type, serialized by runtime type). */ +/** Shared response of GET global/notifications and GET notifications: the active (non-dismissed) notifications visible to the caller, newest first. */ export interface NotificationListResponse { success: boolean; - notifications: unknown[]; + notifications: GlobalNotificationDto[]; } /** Outcome of the last NVD stale-cache refresh walk on THIS instance (in-memory). */ @@ -2322,6 +2425,11 @@ export interface PlatformUsageMetrics { windowDays: number; } +/** One approved tenant on the wire. Deliberately NOT the storage entity: the pre-2026-08-31 wire carried synthetic PreviewWhitelistEntity rows whose only real datum was the tenant id in partitionKey (plus garbage defaults) — the contract is now just the id. */ +export interface PreviewWhitelistTenantEntry { + tenantId: string; +} + /** Response of GET /api/progress/sessions/{sessionId}/events — the session's event stream after the serial knowledge proof passed. */ export interface ProgressGetSessionEventsResponse { success: boolean; @@ -2403,6 +2511,40 @@ export interface RebootSpan { segmentKey: string; } +/** Run report of the legacy-session reclassification job (POST maintenance/reclassify-legacy). */ +export interface ReclassificationResult { + mode: string; + dryRun: boolean; + tenantsExamined: number; + sessionsExamined: number; + wouldChange: number; + changed: number; + toSucceeded: number; + toIncomplete: number; + keptFailed: number; + skipped: number; + errors: number; + /** True when the maxSessions cap stopped the run before the backlog was exhausted — re-run to continue. */ + capReached: boolean; + readonly samples: ReclassificationSample[]; +} + +export interface ReclassificationSample { + tenantId: string; + sessionId: string; + oldStatus: string; + newStatus: string; + reason: string; +} + +/** Response of POST maintenance/reclassify-legacy: the reclassification run report plus trigger attribution. See BackfillJobRunResponse for the shared key layout. */ +export interface ReclassifyJobRunResponse { + success: boolean; + result: ReclassificationResult; + triggeredBy: string; + triggeredAt: string; +} + /** Structural health report for a session's persisted SignalLog + DecisionTransitions journal. Produced by GET /api/sessions/{id}/reducer-verification (Plan §M5, admin/ops endpoint, not tenant-exposed). Scope: this report covers structural invariants that can be checked without running the reducer — ordinal contiguity, cross-references, ReducerVersion drift, counts. A full engine replay with per-step diff would require polymorphic deserialisation of the DecisionSignal.Evidence payload and is a dedicated follow-up. */ export interface ReducerVerificationReport { tenantId: string; @@ -2530,6 +2672,53 @@ export interface RuleCondition { eventAFilterValue: string; } +/** Full diagnostic trace of one dry-run evaluation. Serialized camelCase to clients. */ +export interface RuleDryRun { + verdict: string; + /** Number of events in the session the rule was evaluated against. */ + eventCount: number; + readonly preconditions: RuleDryRunPrecondition[]; + readonly conditions: RuleDryRunCondition[]; + /** Empty unless all required conditions were met (mirrors the production path, which never reaches factor evaluation otherwise). */ + readonly confidenceFactors: RuleDryRunFactor[]; + baseConfidence: number; + /** base + matched factor weights, capped at 100. Null when the evaluation ended before the confidence stage (precondition skip / required miss / nothing matched). */ + finalConfidence?: number; + confidenceThreshold: number; + /** True only for verdict "fired" AND the rule's effective MarkSessionAsFailed flag. The dry-run itself never touches the session. */ + wouldMarkSessionAsFailed: boolean; + /** The evidence map exactly as the production path would persist it on a RuleResult — keys are condition signals (plus factor_* markers). Clients use it to preview {{token}} interpolation of explanation/remediation. Values are heterogeneous evidence objects by design. */ + matchedConditions?: Record; +} + +export interface RuleDryRunCondition { + signal: string; + source: string; + eventType?: string; + required: boolean; + matched: boolean; + /** Matched: the evidence dictionary (eventId, timestamp, field, value, …). Not matched: the evaluator's reason string (e.g. "no matching events"). */ + evidence?: unknown; + /** How many session events have this condition's eventType at all — the first thing an author checks when a condition unexpectedly doesn't match. Null when the condition has no eventType. */ + matchingEventCount?: number; +} + +export interface RuleDryRunFactor { + signal: string; + condition: string; + weight: number; + matched: boolean; +} + +export interface RuleDryRunPrecondition { + source: string; + eventType: string; + dataField?: string; + operator?: string; + value?: string; + passed: boolean; +} + /** A device-fact gate evaluated before a rule's conditions. Pure boolean filter — does not contribute to evidence or confidence. When any precondition on a rule fails, the rule is silently skipped (no result emitted). Currently only event_data source is supported. */ export interface RulePrecondition { /** Source of the fact. Currently only event_data. */ @@ -3223,8 +3412,22 @@ export interface TemplateVariable { /** Response of POST tenants/{tenantId}/admins: the created tenant member. */ export interface TenantAdminCreatedResponse { - /** The stored TenantAdminEntity row (type owned by the Functions project, so this stays object; serialized by runtime type, wire-identical). */ - admin: unknown; + admin: TenantAdminRow; +} + +/** One tenant member (Admin / Operator / Viewer) on the wire — the item of the bare-array GET tenants/{tenantId}/admins and the created member of the POST. Deliberately NOT the storage entity: the ITableEntity keys (partitionKey/rowKey/eTag/timestamp) that the pre-2026-08-31 wire carried are storage internals and were dropped from the contract (no consumer read them). */ +export interface TenantAdminRow { + /** Tenant ID (lowercase). */ + tenantId: string; + /** User Principal Name (lowercase). */ + upn: string; + isEnabled: boolean; + addedDate: string; + addedBy: string; + /** Role: "Admin", "Operator", "Viewer". A null (legacy pre-role row) omits the key and means Admin. */ + role?: string; + /** Whether this Operator can manage bootstrap tokens (only relevant for Operator). */ + canManageBootstrapTokens: boolean; } /** One pre-write config snapshot in GET config/{tenantId}/backups — metadata only, the raw EntityJson (clear-text secrets) is never returned. */ @@ -3241,6 +3444,18 @@ export interface TenantConfigBackupItem { diff?: Record; } +/** One entry of the tenant-config field schema (built by TenantConfigPatchService.BuildFieldsSchema). Serialized camelCase; null Format/Reason keys are omitted (WhenWritingNull) — the null and set cases are pinned by ConfigWireParityTests. */ +export interface TenantConfigFieldSchema { + name: string; + type: string; + format?: string; + nullable: boolean; + writable: boolean; + reason?: string; + gaOnly: boolean; + revertProtected: boolean; +} + /** Success response of PATCH config/{tenantId}/fields and POST config/{tenantId}/revert (both flow through the same outcome writer): which fields changed, the masked diff, and the pre-write backup id. */ export interface TenantConfigPatchOutcomeResponse { success: boolean; @@ -3696,8 +3911,7 @@ export interface TriggerEpssSyncResponse { export interface TriggerMaintenanceResponse { success: boolean; message: string; - /** The MaintenanceResult run report (type owned by the Functions project, so this stays object; serialized by runtime type, wire-identical). */ - result: unknown; + result: MaintenanceResult; triggeredBy: string; triggeredAt: string; }