diff --git a/backend/FactoryConnect.Api/EdgeMtConnectConfiguration.cs b/backend/FactoryConnect.Api/EdgeMtConnectConfiguration.cs
new file mode 100644
index 00000000..926e9aeb
--- /dev/null
+++ b/backend/FactoryConnect.Api/EdgeMtConnectConfiguration.cs
@@ -0,0 +1,54 @@
+using System.Text.Json;
+using Microsoft.Extensions.Configuration;
+
+namespace FactoryConnect.Api;
+
+///
+/// Imports only MTConnect machine addresses from a trusted local Edge configuration file.
+/// Never merges the Edge document's security, database or identity settings into the API.
+///
+internal static class EdgeMtConnectConfiguration
+{
+ internal static void Load(WebApplicationBuilder builder)
+ {
+ var path = builder.Configuration["MTConnect:EdgeConfigurationFile"];
+ if (string.IsNullOrWhiteSpace(path)) return;
+ if (!Path.IsPathFullyQualified(path))
+ throw new InvalidOperationException("MTConnect:EdgeConfigurationFile must be an absolute path.");
+ using var file = File.OpenRead(path);
+ if (file.Length > 2_000_000)
+ throw new InvalidOperationException("Edge configuration exceeds the 2 MB limit.");
+ using var document = JsonDocument.Parse(file, new JsonDocumentOptions { MaxDepth = 32 });
+ if (!document.RootElement.TryGetProperty("MTConnect", out var mtconnect) ||
+ !mtconnect.TryGetProperty("Machines", out var machines) ||
+ machines.ValueKind != JsonValueKind.Array || machines.GetArrayLength() > 500)
+ throw new InvalidOperationException("Edge configuration must contain MTConnect.Machines.");
+ var values = new Dictionary();
+ var seen = new HashSet();
+ var index = 0;
+ foreach (var machine in machines.EnumerateArray())
+ {
+ if (machine.ValueKind != JsonValueKind.Object ||
+ !machine.TryGetProperty("MachineId", out var idValue) ||
+ idValue.ValueKind != JsonValueKind.String ||
+ !Guid.TryParse(idValue.GetString(), out var machineId) ||
+ !seen.Add(machineId) ||
+ !machine.TryGetProperty("DeviceKey", out var keyValue) ||
+ keyValue.ValueKind != JsonValueKind.String ||
+ string.IsNullOrWhiteSpace(keyValue.GetString()) ||
+ !machine.TryGetProperty("BaseUri", out var uriValue) ||
+ uriValue.ValueKind != JsonValueKind.String ||
+ !Uri.TryCreate(uriValue.GetString(), UriKind.Absolute, out var uri) ||
+ uri.Scheme is not ("http" or "https") ||
+ !string.IsNullOrEmpty(uri.UserInfo) ||
+ !string.IsNullOrEmpty(uri.Query) ||
+ !string.IsNullOrEmpty(uri.Fragment))
+ throw new InvalidOperationException("Invalid or duplicate MTConnect machine entry in Edge configuration.");
+ var prefix = $"MTConnect:Machines:{index++}:";
+ values[prefix + "MachineId"] = machineId.ToString();
+ values[prefix + "DeviceKey"] = keyValue.GetString();
+ values[prefix + "BaseUri"] = uri.ToString();
+ }
+ builder.Configuration.AddInMemoryCollection(values);
+ }
+}
diff --git a/backend/FactoryConnect.Api/EvidenceApiHost.cs b/backend/FactoryConnect.Api/EvidenceApiHost.cs
index 2cf7a67f..242ee9a2 100644
--- a/backend/FactoryConnect.Api/EvidenceApiHost.cs
+++ b/backend/FactoryConnect.Api/EvidenceApiHost.cs
@@ -16,6 +16,8 @@ public static WebApplication Build(string[] args, Action?
var builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = args,
ApplicationName = typeof(EvidenceApiHost).Assembly.FullName, WebRootPath = webRoot });
configure?.Invoke(builder);
+// Opt-in: import only MTConnect machine entries from the trusted local Edge file.
+EdgeMtConnectConfiguration.Load(builder);
builder.Services.AddOperatorLogin(builder.Configuration);
// Forwarding is opt-in and accepts only explicitly enumerated proxy IP addresses.
// An unset configuration keeps direct HTTPS behavior unchanged.
@@ -247,6 +249,8 @@ public static WebApplication Build(string[] args, Action?
}).RequireRateLimiting("security-boundary");
app.MapActivityHistory();
+app.MapMasterRegistration();
+app.MapMtConnectDiscovery();
app.MapMethods("/api/{**path}", ["GET", "POST"], NotReady);
app.MapFallbackToFile("index.html");
diff --git a/backend/FactoryConnect.Api/MasterRegistrationApi.cs b/backend/FactoryConnect.Api/MasterRegistrationApi.cs
new file mode 100644
index 00000000..27418791
--- /dev/null
+++ b/backend/FactoryConnect.Api/MasterRegistrationApi.cs
@@ -0,0 +1,234 @@
+using System.Security.Cryptography;
+using FactoryConnect.Sql.Security;
+
+namespace FactoryConnect.Api;
+
+public static class MasterRegistrationApi
+{
+ private sealed record CommissioningRequest(Guid OperationId, Guid MachineId, Guid ProductionLineId, string DeviceId);
+ private sealed record RegistrationRequest(Guid OperationId, string Kind, Guid? SiteId, string DisplayName, Guid? MachineId = null);
+
+ public static void MapMasterRegistration(this WebApplication app)
+ {
+ app.MapGet("/api/admin/v2/sites", async (HttpContext http, IConfiguration config, CancellationToken ct) =>
+ {
+ http.Response.Headers.CacheControl = "no-store";
+ if (!http.Request.IsHttps) return Results.Problem(statusCode: 403, title: "HTTPS required");
+ if (!Guid.TryParse(config["Evidence:InstallationId"], out var installationId) ||
+ string.IsNullOrWhiteSpace(config.GetConnectionString("Evidence")))
+ return Results.Problem(statusCode: 503, title: "Master-data authority unavailable");
+ if (!http.Request.Headers.TryGetValue("Authorization", out var authorization) ||
+ authorization.Count != 1 || !authorization[0]!.StartsWith("Bearer ", StringComparison.Ordinal) ||
+ authorization[0]!.Length > 192) return Results.Unauthorized();
+ byte[] bearer;
+ try { bearer = Convert.FromBase64String(authorization[0]![7..]); }
+ catch (FormatException) { return Results.Unauthorized(); }
+ if (bearer.Length != 32)
+ {
+ CryptographicOperations.ZeroMemory(bearer);
+ return Results.Unauthorized();
+ }
+ var hash = SHA256.HashData(bearer);
+ CryptographicOperations.ZeroMemory(bearer);
+ try
+ {
+ var sites = await new SqlSecurityAuthority(config.GetConnectionString("Evidence")!, installationId)
+ .ReadAdministrationSitesAsync(hash, ct);
+ return Results.Ok(new { sites });
+ }
+ catch (SecurityDeniedException) { return Results.Unauthorized(); }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
+ catch (Exception ex) { return AuthorityDiagnostics.Unavailable(http, ex, "administration-sites", "Site inventory unavailable"); }
+ finally { CryptographicOperations.ZeroMemory(hash); }
+ }).RequireRateLimiting("security-boundary");
+
+ app.MapGet("/api/admin/v2/machines", async (HttpContext http, IConfiguration config, CancellationToken ct) =>
+ {
+ http.Response.Headers.CacheControl = "no-store";
+ if (!http.Request.IsHttps) return Results.Problem(statusCode: 403, title: "HTTPS required");
+ if (!Guid.TryParse(config["Evidence:InstallationId"], out var installationId) ||
+ string.IsNullOrWhiteSpace(config.GetConnectionString("Evidence")))
+ return Results.Problem(statusCode: 503, title: "Master-data authority unavailable");
+ if (!http.Request.Headers.TryGetValue("Authorization", out var authorization) ||
+ authorization.Count != 1 || !authorization[0]!.StartsWith("Bearer ", StringComparison.Ordinal) ||
+ authorization[0]!.Length > 192) return Results.Unauthorized();
+ byte[] bearer;
+ try { bearer = Convert.FromBase64String(authorization[0]![7..]); }
+ catch (FormatException) { return Results.Unauthorized(); }
+ if (bearer.Length != 32)
+ {
+ CryptographicOperations.ZeroMemory(bearer);
+ return Results.Unauthorized();
+ }
+ var hash = SHA256.HashData(bearer);
+ CryptographicOperations.ZeroMemory(bearer);
+ try
+ {
+ var machines = await new SqlSecurityAuthority(config.GetConnectionString("Evidence")!, installationId)
+ .ReadAdministrationMachinesAsync(hash, ct);
+ return Results.Ok(new { machines });
+ }
+ catch (SecurityDeniedException) { return Results.Unauthorized(); }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
+ catch (Exception ex) { return AuthorityDiagnostics.Unavailable(http, ex, "administration-machines", "Machine inventory unavailable"); }
+ finally { CryptographicOperations.ZeroMemory(hash); }
+ }).RequireRateLimiting("security-boundary");
+
+ app.MapGet("/api/admin/v2/commissioning-assignments", async (HttpContext http, IConfiguration config, CancellationToken ct) =>
+ {
+ http.Response.Headers.CacheControl = "no-store";
+ if (!http.Request.IsHttps) return Results.Problem(statusCode: 403, title: "HTTPS required");
+ if (!Guid.TryParse(config["Evidence:InstallationId"], out var installationId) ||
+ string.IsNullOrWhiteSpace(config.GetConnectionString("Evidence")))
+ return Results.Problem(statusCode: 503, title: "Master-data authority unavailable");
+ if (!http.Request.Headers.TryGetValue("Authorization", out var authorization) ||
+ authorization.Count != 1 || !authorization[0]!.StartsWith("Bearer ", StringComparison.Ordinal) ||
+ authorization[0]!.Length > 192) return Results.Unauthorized();
+ byte[] bearer;
+ try { bearer = Convert.FromBase64String(authorization[0]![7..]); }
+ catch (FormatException) { return Results.Unauthorized(); }
+ if (bearer.Length != 32)
+ {
+ CryptographicOperations.ZeroMemory(bearer);
+ return Results.Unauthorized();
+ }
+ var hash = SHA256.HashData(bearer);
+ CryptographicOperations.ZeroMemory(bearer);
+ try
+ {
+ var assignments = await new SqlSecurityAuthority(config.GetConnectionString("Evidence")!, installationId)
+ .ReadCommissioningAssignmentsAsync(hash, ct);
+ return Results.Ok(new { assignments });
+ }
+ catch (SecurityDeniedException) { return Results.Unauthorized(); }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
+ catch (Exception ex) { return AuthorityDiagnostics.Unavailable(http, ex, "commissioning-assignments", "Commissioning assignments unavailable"); }
+ finally { CryptographicOperations.ZeroMemory(hash); }
+ }).RequireRateLimiting("security-boundary");
+
+ app.MapGet("/api/admin/v2/lines", async (HttpContext http, IConfiguration config, CancellationToken ct) =>
+ {
+ http.Response.Headers.CacheControl = "no-store";
+ if (!http.Request.IsHttps) return Results.Problem(statusCode: 403, title: "HTTPS required");
+ if (!Guid.TryParse(config["Evidence:InstallationId"], out var installationId) ||
+ string.IsNullOrWhiteSpace(config.GetConnectionString("Evidence")))
+ return Results.Problem(statusCode: 503, title: "Master-data authority unavailable");
+ if (!http.Request.Headers.TryGetValue("Authorization", out var authorization) ||
+ authorization.Count != 1 || !authorization[0]!.StartsWith("Bearer ", StringComparison.Ordinal) ||
+ authorization[0]!.Length > 192) return Results.Unauthorized();
+ byte[] bearer;
+ try { bearer = Convert.FromBase64String(authorization[0]![7..]); }
+ catch (FormatException) { return Results.Unauthorized(); }
+ if (bearer.Length != 32)
+ {
+ CryptographicOperations.ZeroMemory(bearer);
+ return Results.Unauthorized();
+ }
+ var hash = SHA256.HashData(bearer);
+ CryptographicOperations.ZeroMemory(bearer);
+ try
+ {
+ var lines = await new SqlSecurityAuthority(config.GetConnectionString("Evidence")!, installationId)
+ .ReadAdministrationLinesAsync(hash, ct);
+ return Results.Ok(new { lines });
+ }
+ catch (SecurityDeniedException) { return Results.Unauthorized(); }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
+ catch (Exception ex) { return AuthorityDiagnostics.Unavailable(http, ex, "administration-lines", "Production-line inventory unavailable"); }
+ finally { CryptographicOperations.ZeroMemory(hash); }
+ }).RequireRateLimiting("security-boundary");
+
+ app.MapPost("/api/admin/v2/master-registration", async (
+ HttpContext http, IConfiguration config, CancellationToken ct) =>
+ {
+ http.Response.Headers.CacheControl = "no-store";
+ if (!http.Request.IsHttps)
+ return Results.Problem(statusCode: 403, title: "HTTPS required");
+ if (!Guid.TryParse(config["Evidence:InstallationId"], out var installationId) ||
+ string.IsNullOrWhiteSpace(config.GetConnectionString("Evidence")))
+ return Results.Problem(statusCode: 503, title: "Master-data authority unavailable");
+ if (!http.Request.Headers.TryGetValue("Authorization", out var authorization) ||
+ authorization.Count != 1 || !authorization[0]!.StartsWith("Bearer ", StringComparison.Ordinal) ||
+ authorization[0]!.Length > 192)
+ return Results.Unauthorized();
+ byte[] bearer;
+ try { bearer = Convert.FromBase64String(authorization[0]![7..]); }
+ catch (FormatException) { return Results.Unauthorized(); }
+ if (bearer.Length != 32)
+ {
+ CryptographicOperations.ZeroMemory(bearer);
+ return Results.Unauthorized();
+ }
+ var sessionHash = SHA256.HashData(bearer);
+ CryptographicOperations.ZeroMemory(bearer);
+ try
+ {
+ RegistrationRequest? request;
+ try { request = await http.Request.ReadFromJsonAsync(cancellationToken: ct); }
+ catch (System.Text.Json.JsonException) { return Results.BadRequest(new { error = "Invalid registration JSON" }); }
+ if (request is null || request.OperationId == Guid.Empty ||
+ request.Kind is not ("site" or "line" or "machine") ||
+ (request.Kind == "site" ? request.SiteId is not null :
+ request.SiteId is null || request.SiteId == Guid.Empty) ||
+ string.IsNullOrWhiteSpace(request.DisplayName) ||
+ request.DisplayName.Trim().Length > 200 ||
+ (request.MachineId.HasValue && (request.Kind != "machine" || request.MachineId == Guid.Empty)))
+ return Results.BadRequest(new { error = "Invalid registration request" });
+ var recordId = await new SqlSecurityAuthority(config.GetConnectionString("Evidence")!, installationId)
+ .RegisterMasterRecordAsync(sessionHash, request.OperationId, request.Kind,
+ request.SiteId, request.DisplayName, ct, request.MachineId);
+ return Results.Ok(new { recordId, request.OperationId, request.Kind });
+ }
+ catch (SecurityDeniedException) { return Results.Unauthorized(); }
+ catch (SecurityConflictException) { return Results.Conflict(new { error = "Operation ID conflict" }); }
+ catch (InvalidOperationException) { return Results.Conflict(new { error = "Registration conflicts with existing master data" }); }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
+ catch (Exception ex) { return AuthorityDiagnostics.Unavailable(http, ex, "master-registration", "Master-data authority unavailable"); }
+ finally { CryptographicOperations.ZeroMemory(sessionHash); }
+ }).RequireRateLimiting("security-boundary");
+ app.MapPost("/api/admin/v2/commissioning-assignments", async (
+ HttpContext http, IConfiguration config, CancellationToken ct) =>
+ {
+ http.Response.Headers.CacheControl = "no-store";
+ if (!http.Request.IsHttps)
+ return Results.Problem(statusCode: 403, title: "HTTPS required");
+ if (!Guid.TryParse(config["Evidence:InstallationId"], out var installationId) ||
+ string.IsNullOrWhiteSpace(config.GetConnectionString("Evidence")))
+ return Results.Problem(statusCode: 503, title: "Master-data authority unavailable");
+ if (!http.Request.Headers.TryGetValue("Authorization", out var authorization) ||
+ authorization.Count != 1 || !authorization[0]!.StartsWith("Bearer ", StringComparison.Ordinal) ||
+ authorization[0]!.Length > 192)
+ return Results.Unauthorized();
+ byte[] bearer;
+ try { bearer = Convert.FromBase64String(authorization[0]![7..]); }
+ catch (FormatException) { return Results.Unauthorized(); }
+ if (bearer.Length != 32)
+ {
+ CryptographicOperations.ZeroMemory(bearer);
+ return Results.Unauthorized();
+ }
+ var sessionHash = SHA256.HashData(bearer);
+ CryptographicOperations.ZeroMemory(bearer);
+ try
+ {
+ CommissioningRequest? request;
+ try { request = await http.Request.ReadFromJsonAsync(cancellationToken: ct); }
+ catch (System.Text.Json.JsonException) { return Results.BadRequest(new { error = "Invalid commissioning JSON" }); }
+ if (request is null || request.OperationId == Guid.Empty ||
+ request.MachineId == Guid.Empty || request.ProductionLineId == Guid.Empty ||
+ string.IsNullOrWhiteSpace(request.DeviceId) || request.DeviceId.Trim().Length > 200)
+ return Results.BadRequest(new { error = "Invalid commissioning assignment" });
+ await new SqlSecurityAuthority(config.GetConnectionString("Evidence")!, installationId)
+ .SaveCommissioningAssignmentAsync(sessionHash, request.OperationId,
+ request.MachineId, request.ProductionLineId, request.DeviceId, ct);
+ return Results.Ok(new { saved = true, request.OperationId, acquisitionActivated = false });
+ }
+ catch (SecurityDeniedException) { return Results.Unauthorized(); }
+ catch (SecurityConflictException) { return Results.Conflict(new { error = "Operation ID conflict" }); }
+ catch (InvalidOperationException) { return Results.Conflict(new { error = "Machine or production line invalid for commissioning" }); }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
+ catch (Exception ex) { return AuthorityDiagnostics.Unavailable(http, ex, "commissioning-save", "Commissioning authority unavailable"); }
+ finally { CryptographicOperations.ZeroMemory(sessionHash); }
+ }).RequireRateLimiting("security-boundary");
+ }
+}
diff --git a/backend/FactoryConnect.Api/MtConnectDiscoveryApi.cs b/backend/FactoryConnect.Api/MtConnectDiscoveryApi.cs
new file mode 100644
index 00000000..f7c441f1
--- /dev/null
+++ b/backend/FactoryConnect.Api/MtConnectDiscoveryApi.cs
@@ -0,0 +1,153 @@
+using System.Security.Cryptography;
+using FactoryConnect.Sql.Security;
+
+namespace FactoryConnect.Api;
+
+/// Read-only discovery against explicitly configured Edge MTConnect machines.
+public static class MtConnectDiscoveryApi
+{
+ private sealed record ConfiguredMachine(string MachineId, string DeviceKey, string BaseUri);
+
+ public static void MapMtConnectDiscovery(this WebApplication app)
+ {
+ app.MapGet("/api/admin/v2/mtconnect/configured-machines", async (
+ HttpContext http, IConfiguration config, CancellationToken ct) =>
+ {
+ http.Response.Headers.CacheControl = "no-store";
+ if (!http.Request.IsHttps) return Results.Problem(statusCode: 403, title: "HTTPS required");
+ if (!Guid.TryParse(config["Evidence:InstallationId"], out var installationId) ||
+ string.IsNullOrWhiteSpace(config.GetConnectionString("Evidence")))
+ return Results.Problem(statusCode: 503, title: "Administration authority unavailable");
+ if (!http.Request.Headers.TryGetValue("Authorization", out var authorization) ||
+ authorization.Count != 1 || !authorization[0]!.StartsWith("Bearer ", StringComparison.Ordinal) ||
+ authorization[0]!.Length > 192) return Results.Unauthorized();
+ byte[] bearer;
+ try { bearer = Convert.FromBase64String(authorization[0]![7..]); }
+ catch (FormatException) { return Results.Unauthorized(); }
+ if (bearer.Length != 32)
+ {
+ CryptographicOperations.ZeroMemory(bearer);
+ return Results.Unauthorized();
+ }
+ var hash = SHA256.HashData(bearer);
+ CryptographicOperations.ZeroMemory(bearer);
+ try
+ {
+ var machines = await new SqlSecurityAuthority(config.GetConnectionString("Evidence")!, installationId)
+ .ReadAdministrationMachinesAsync(hash, ct);
+ var registered = machines.Where(machine => !machine.IsRetired)
+ .Select(machine => machine.MachineId).ToHashSet();
+ var configured = config.GetSection("MTConnect:Machines").Get() ?? [];
+ var duplicateIds = configured.GroupBy(machine => machine.MachineId, StringComparer.OrdinalIgnoreCase)
+ .Where(group => group.Count() > 1)
+ .Select(group => group.Key)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+ var entries = configured.Select(machine => new
+ {
+ machineId = machine.MachineId,
+ deviceKey = machine.DeviceKey,
+ registered = Guid.TryParse(machine.MachineId, out var id) && registered.Contains(id),
+ duplicateConfiguration = duplicateIds.Contains(machine.MachineId)
+ }).ToArray();
+ return Results.Ok(new { configured = entries, configuredCount = entries.Length });
+ }
+ catch (SecurityDeniedException) { return Results.Unauthorized(); }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
+ catch (Exception ex) { return AuthorityDiagnostics.Unavailable(http, ex,
+ "mtconnect-configuration-inventory", "MTConnect configuration inventory unavailable"); }
+ finally { CryptographicOperations.ZeroMemory(hash); }
+ }).RequireRateLimiting("security-boundary");
+
+ app.MapGet("/api/admin/v2/mtconnect/discover/{machineId:guid}", async (
+ Guid machineId, HttpContext http, IConfiguration config, CancellationToken ct) =>
+ {
+ http.Response.Headers.CacheControl = "no-store";
+ if (!http.Request.IsHttps) return Results.Problem(statusCode: 403, title: "HTTPS required");
+ if (!Guid.TryParse(config["Evidence:InstallationId"], out var installationId) ||
+ string.IsNullOrWhiteSpace(config.GetConnectionString("Evidence")))
+ return Results.Problem(statusCode: 503, title: "Administration authority unavailable");
+ if (!http.Request.Headers.TryGetValue("Authorization", out var authorization) ||
+ authorization.Count != 1 || !authorization[0]!.StartsWith("Bearer ", StringComparison.Ordinal) ||
+ authorization[0]!.Length > 192) return Results.Unauthorized();
+ byte[] bearer;
+ try { bearer = Convert.FromBase64String(authorization[0]![7..]); }
+ catch (FormatException) { return Results.Unauthorized(); }
+ if (bearer.Length != 32)
+ {
+ CryptographicOperations.ZeroMemory(bearer);
+ return Results.Unauthorized();
+ }
+ var hash = SHA256.HashData(bearer);
+ CryptographicOperations.ZeroMemory(bearer);
+ try
+ {
+ // The same administration permission as machine inventory is required.
+ var machines = await new SqlSecurityAuthority(config.GetConnectionString("Evidence")!, installationId)
+ .ReadAdministrationMachinesAsync(hash, ct);
+ if (!machines.Any(machine => machine.MachineId == machineId && !machine.IsRetired))
+ return Results.NotFound(new { error = "Active registered machine not found" });
+
+ // Read the Edge configuration as supplied to this host; no second Agent URL registry.
+ var configured = config.GetSection("MTConnect:Machines").Get() ?? [];
+ var targets = configured.Where(machine =>
+ Guid.TryParse(machine.MachineId, out var id) && id == machineId).ToArray();
+ if (targets.Length == 0) return Results.NotFound(new { error = "Machine not configured for MTConnect discovery" });
+ if (targets.Length > 1) return Results.Conflict(new { error = "Multiple Edge entries reference this MachineId; resolve the configuration before discovery" });
+ var target = targets[0];
+ if (!Uri.TryCreate(target.BaseUri, UriKind.Absolute, out var baseUri) ||
+ baseUri.Scheme is not ("http" or "https") || !string.IsNullOrEmpty(baseUri.UserInfo) ||
+ !string.IsNullOrEmpty(baseUri.Query) || !string.IsNullOrEmpty(baseUri.Fragment))
+ return Results.Problem(statusCode: 503, title: "Configured MTConnect Agent URI invalid");
+ // The endpoint is derived exclusively from trusted host configuration, never user input.
+ var probeUri = new Uri(baseUri.ToString().TrimEnd('/') + "/probe");
+ using var handler = new HttpClientHandler
+ {
+ AllowAutoRedirect = false,
+ UseCookies = false,
+ UseProxy = false
+ };
+ using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(8) };
+ using var response = await client.GetAsync(probeUri, HttpCompletionOption.ResponseHeadersRead, ct);
+ if (!response.IsSuccessStatusCode)
+ return Results.Problem(statusCode: 502, title: "Configured MTConnect Agent probe failed");
+ if (response.Content.Headers.ContentLength is > 4_000_000)
+ return Results.Problem(statusCode: 502, title: "MTConnect probe response exceeds limit");
+ await using var source = await response.Content.ReadAsStreamAsync(ct);
+ await using var limited = new MemoryStream();
+ var chunk = new byte[8192];
+ int count;
+ while ((count = await source.ReadAsync(chunk, ct)) != 0)
+ {
+ if (limited.Length + count > 4_000_000)
+ return Results.Problem(statusCode: 502, title: "MTConnect probe response exceeds limit");
+ limited.Write(chunk, 0, count);
+ }
+ limited.Position = 0;
+ var devices = MtConnectProbeDevices.Parse(limited);
+ var matching = devices.Where(device =>
+ string.Equals(device.Id, target.DeviceKey, StringComparison.Ordinal) ||
+ string.Equals(device.Name, target.DeviceKey, StringComparison.Ordinal) ||
+ (!string.IsNullOrWhiteSpace(device.Uuid) &&
+ string.Equals(device.Uuid, target.DeviceKey, StringComparison.OrdinalIgnoreCase))).ToArray();
+ var identityStatus = matching.Length switch
+ {
+ 0 => "unmatched",
+ 1 => "potential-match",
+ _ => "ambiguous"
+ };
+ return Results.Ok(new { machineId, target.DeviceKey, devices, identityStatus,
+ matchingDeviceIds = matching.Select(device => device.Id).ToArray(),
+ acquisitionActivated = false });
+ }
+ catch (SecurityDeniedException) { return Results.Unauthorized(); }
+ catch (InvalidDataException) { return Results.Problem(statusCode: 502, title: "Invalid MTConnect probe document"); }
+ catch (System.Xml.XmlException) { return Results.Problem(statusCode: 502, title: "Invalid MTConnect probe XML"); }
+ catch (HttpRequestException) { return Results.Problem(statusCode: 502, title: "Configured MTConnect Agent unreachable"); }
+ catch (TaskCanceledException) when (!ct.IsCancellationRequested)
+ { return Results.Problem(statusCode: 504, title: "MTConnect Agent probe timed out"); }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
+ catch (Exception ex) { return AuthorityDiagnostics.Unavailable(http, ex, "mtconnect-discovery", "MTConnect discovery unavailable"); }
+ finally { CryptographicOperations.ZeroMemory(hash); }
+ }).RequireRateLimiting("security-boundary");
+ }
+}
diff --git a/backend/FactoryConnect.Api/MtConnectProbeDevices.cs b/backend/FactoryConnect.Api/MtConnectProbeDevices.cs
new file mode 100644
index 00000000..15cbb5b3
--- /dev/null
+++ b/backend/FactoryConnect.Api/MtConnectProbeDevices.cs
@@ -0,0 +1,63 @@
+using System.Xml;
+using System.Xml.Linq;
+
+namespace FactoryConnect.Api;
+
+///
+/// Reads MTConnect /probe device metadata without making network requests or changing
+/// machine configuration. The caller is responsible for using a trusted configured Agent.
+///
+internal static class MtConnectProbeDevices
+{
+ internal sealed record DataItem(string Id, string? Name, string? Type, string? SubType);
+ internal sealed record Device(string Id, string Name, string? Uuid, IReadOnlyList DataItems);
+
+ internal static IReadOnlyList Parse(Stream xml)
+ {
+ ArgumentNullException.ThrowIfNull(xml);
+ using var reader = XmlReader.Create(xml, new XmlReaderSettings
+ {
+ DtdProcessing = DtdProcessing.Prohibit,
+ XmlResolver = null,
+ MaxCharactersInDocument = 4_000_000,
+ MaxCharactersFromEntities = 0,
+ IgnoreComments = true
+ });
+ var document = XDocument.Load(reader);
+ if (document.Root?.Name.LocalName != "MTConnectDevices")
+ throw new InvalidDataException("Response is not an MTConnect Devices document.");
+ var devices = document.Descendants().Where(node => node.Name.LocalName == "Device")
+ .Take(501).ToArray();
+ if (devices.Length > 500) throw new InvalidDataException("Too many MTConnect devices.");
+ var result = new List(devices.Length);
+ var deviceIds = new HashSet(StringComparer.Ordinal);
+ var deviceUuids = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var device in devices)
+ {
+ var id = (string?)device.Attribute("id");
+ var name = (string?)device.Attribute("name");
+ if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(name))
+ throw new InvalidDataException("MTConnect device is missing its ID or name.");
+ if (!deviceIds.Add(id))
+ throw new InvalidDataException("Duplicate MTConnect device ID.");
+ var uuid = ((string?)device.Attribute("uuid"))?.Trim();
+ if (!string.IsNullOrEmpty(uuid) && !deviceUuids.Add(uuid))
+ throw new InvalidDataException("Duplicate MTConnect device UUID.");
+ var items = device.Descendants().Where(node => node.Name.LocalName == "DataItem")
+ .Take(5001).ToArray();
+ if (items.Length > 5000) throw new InvalidDataException("Too many data items in MTConnect device.");
+ var dataItemIds = new HashSet(StringComparer.Ordinal);
+ foreach (var item in items)
+ {
+ var dataItemId = (string?)item.Attribute("id");
+ if (string.IsNullOrWhiteSpace(dataItemId) || !dataItemIds.Add(dataItemId))
+ throw new InvalidDataException("Missing or duplicate MTConnect data item ID.");
+ }
+ result.Add(new Device(id, name, uuid,
+ items.Select(item => new DataItem((string?)item.Attribute("id") ?? "",
+ (string?)item.Attribute("name"), (string?)item.Attribute("type"),
+ (string?)item.Attribute("subType"))).ToArray()));
+ }
+ return result;
+ }
+}
diff --git a/backend/FactoryConnect.Sql/MasterData/MachineAdministrationStore.cs b/backend/FactoryConnect.Sql/MasterData/MachineAdministrationStore.cs
new file mode 100644
index 00000000..9d718708
--- /dev/null
+++ b/backend/FactoryConnect.Sql/MasterData/MachineAdministrationStore.cs
@@ -0,0 +1,122 @@
+using System.Data;
+using Microsoft.Data.SqlClient;
+
+namespace FactoryConnect.Sql.MasterData;
+
+///
+/// SQL-backed site, line and machine inventory. Registration must be called only
+/// after an audited, transaction-bound SQL authorization command has verified the actor.
+/// No runtime configuration, acquisition or activation is performed here.
+///
+internal sealed class MachineAdministrationStore(string connectionString)
+{
+ public sealed record Site(Guid SiteId, string DisplayName, bool IsRetired);
+ public sealed record ProductionLine(Guid ProductionLineId, Guid SiteId, string DisplayName, bool IsRetired);
+ public sealed record Machine(Guid MachineId, Guid SiteId, string DisplayName, bool IsRetired);
+
+ public async Task> ListSitesAsync(CancellationToken ct = default)
+ {
+ await using var connection = new SqlConnection(connectionString);
+ await connection.OpenAsync(ct);
+ await using var command = new SqlCommand(
+ "SELECT SiteId, DisplayName, IsRetired FROM master.Site ORDER BY DisplayName, SiteId", connection);
+ await using var reader = await command.ExecuteReaderAsync(ct);
+ var rows = new List();
+ while (await reader.ReadAsync(ct))
+ rows.Add(new Site(reader.GetGuid(0), reader.GetString(1), reader.GetBoolean(2)));
+ return rows;
+ }
+
+ public async Task> ListLinesAsync(Guid siteId, CancellationToken ct = default)
+ {
+ await using var connection = new SqlConnection(connectionString);
+ await connection.OpenAsync(ct);
+ await using var command = new SqlCommand(
+ "SELECT ProductionLineId, SiteId, DisplayName, IsRetired FROM master.ProductionLine WHERE SiteId=@site ORDER BY DisplayName, ProductionLineId", connection);
+ command.Parameters.Add("@site", SqlDbType.UniqueIdentifier).Value = siteId;
+ await using var reader = await command.ExecuteReaderAsync(ct);
+ var rows = new List();
+ while (await reader.ReadAsync(ct))
+ rows.Add(new ProductionLine(reader.GetGuid(0), reader.GetGuid(1), reader.GetString(2), reader.GetBoolean(3)));
+ return rows;
+ }
+
+ public async Task> ListMachinesAsync(Guid siteId, CancellationToken ct = default)
+ {
+ await using var connection = new SqlConnection(connectionString);
+ await connection.OpenAsync(ct);
+ await using var command = new SqlCommand(
+ "SELECT MachineId, SiteId, DisplayName, IsRetired FROM master.Machine WHERE SiteId=@site ORDER BY DisplayName, MachineId", connection);
+ command.Parameters.Add("@site", SqlDbType.UniqueIdentifier).Value = siteId;
+ await using var reader = await command.ExecuteReaderAsync(ct);
+ var rows = new List();
+ while (await reader.ReadAsync(ct))
+ rows.Add(new Machine(reader.GetGuid(0), reader.GetGuid(1), reader.GetString(2), reader.GetBoolean(3)));
+ return rows;
+ }
+
+ // The caller must supply the SQL transaction that performed authorization.
+ // No independent connection or commit is permitted for administrative writes.
+ internal Task RegisterSiteAsync(SqlConnection connection, SqlTransaction transaction, string name, CancellationToken ct = default)
+ => RegisterAsync(connection, transaction, "Site", "SiteId", null, name, ct);
+
+ internal Task RegisterLineAsync(SqlConnection connection, SqlTransaction transaction, Guid siteId, string name, CancellationToken ct = default)
+ => RegisterAsync(connection, transaction, "ProductionLine", "ProductionLineId", siteId, name, ct);
+
+ internal Task RegisterMachineAsync(SqlConnection connection, SqlTransaction transaction, Guid siteId, string name, Guid? machineId = null, CancellationToken ct = default)
+ => RegisterAsync(connection, transaction, "Machine", "MachineId", siteId, name, ct, machineId);
+
+ private static async Task RegisterAsync(SqlConnection connection, SqlTransaction transaction,
+ string table, string idColumn, Guid? siteId, string name, CancellationToken ct, Guid? requestedId = null)
+ {
+ // Identifiers are selected exclusively from the fixed methods above.
+ if (string.IsNullOrWhiteSpace(name) || name.Trim().Length > 200)
+ throw new ArgumentException("Display name must contain 1 to 200 characters.", nameof(name));
+ if (siteId == Guid.Empty) throw new ArgumentException("Site ID is required.", nameof(siteId));
+ name = name.Trim();
+ var id = requestedId ?? Guid.NewGuid();
+ if (id == Guid.Empty) throw new ArgumentException("Machine ID cannot be empty.");
+ // A supplied Edge identity must not silently collide with an existing V2 machine.
+ // Check it under the same transaction as the registration insert.
+ if (requestedId.HasValue)
+ {
+ await using var identityCheck = new SqlCommand(
+ "SELECT COUNT_BIG(*) FROM master.Machine WITH (UPDLOCK, HOLDLOCK) WHERE MachineId=@id",
+ connection, transaction);
+ identityCheck.Parameters.Add("@id", SqlDbType.UniqueIdentifier).Value = id;
+ if ((long)(await identityCheck.ExecuteScalarAsync(ct) ?? 0L) != 0)
+ throw new InvalidOperationException("This Edge machine identity is already registered in V2.");
+ }
+ // The security authority owns transaction isolation and commit. UPDLOCK/HOLDLOCK
+ // serializes duplicate-name checks until that transaction completes.
+ var predicate = siteId.HasValue ? "SiteId=@site AND " : "";
+ await using (var check = new SqlCommand(
+ $"SELECT COUNT_BIG(*) FROM master.{table} WITH (UPDLOCK, HOLDLOCK) WHERE {predicate}UPPER(LTRIM(RTRIM(DisplayName)))=UPPER(@name)",
+ connection, transaction))
+ {
+ check.Parameters.Add("@name", SqlDbType.NVarChar, 200).Value = name;
+ if (siteId.HasValue) check.Parameters.Add("@site", SqlDbType.UniqueIdentifier).Value = siteId.Value;
+ if ((long)(await check.ExecuteScalarAsync(ct) ?? 0L) != 0)
+ throw new InvalidOperationException("A record with this display name already exists in the selected scope.");
+ }
+ if (siteId.HasValue)
+ {
+ await using var parent = new SqlCommand(
+ "SELECT COUNT_BIG(*) FROM master.Site WITH (UPDLOCK, HOLDLOCK) WHERE SiteId=@site AND IsRetired=0",
+ connection, transaction);
+ parent.Parameters.Add("@site", SqlDbType.UniqueIdentifier).Value = siteId.Value;
+ if ((long)(await parent.ExecuteScalarAsync(ct) ?? 0L) != 1)
+ throw new InvalidOperationException("The selected site does not exist or is retired.");
+ }
+ await using (var insert = new SqlCommand(
+ $"INSERT master.{table} ({idColumn}, {(siteId.HasValue ? "SiteId, " : "")}DisplayName) VALUES (@id, {(siteId.HasValue ? "@site, " : "")}@name)",
+ connection, transaction))
+ {
+ insert.Parameters.Add("@id", SqlDbType.UniqueIdentifier).Value = id;
+ insert.Parameters.Add("@name", SqlDbType.NVarChar, 200).Value = name;
+ if (siteId.HasValue) insert.Parameters.Add("@site", SqlDbType.UniqueIdentifier).Value = siteId.Value;
+ await insert.ExecuteNonQueryAsync(ct);
+ }
+ return id;
+ }
+}
diff --git a/backend/FactoryConnect.Sql/Migrations/022-machine-commissioning.sql b/backend/FactoryConnect.Sql/Migrations/022-machine-commissioning.sql
new file mode 100644
index 00000000..bd6b5c2a
--- /dev/null
+++ b/backend/FactoryConnect.Sql/Migrations/022-machine-commissioning.sql
@@ -0,0 +1,14 @@
+-- Commissioning assignments are administrative intent only; acquisition remains independently disabled.
+CREATE TABLE master.MachineCommissioning (
+ MachineId uniqueidentifier NOT NULL PRIMARY KEY,
+ ProductionLineId uniqueidentifier NOT NULL,
+ MtConnectDeviceId nvarchar(200) NOT NULL,
+ LastOperationId uniqueidentifier NOT NULL,
+ CONSTRAINT FK_MachineCommissioning_Machine FOREIGN KEY (MachineId)
+ REFERENCES master.Machine(MachineId),
+ CONSTRAINT FK_MachineCommissioning_Line FOREIGN KEY (ProductionLineId)
+ REFERENCES master.ProductionLine(ProductionLineId),
+ CONSTRAINT FK_MachineCommissioning_Operation FOREIGN KEY (LastOperationId)
+ REFERENCES security.SecurityOperation(OperationId),
+ CONSTRAINT CK_MachineCommissioning_DeviceId CHECK (LEN(LTRIM(RTRIM(MtConnectDeviceId))) BETWEEN 1 AND 200)
+);
diff --git a/backend/FactoryConnect.Sql/Security/SqlSecurityAuthority.MasterRegistration.cs b/backend/FactoryConnect.Sql/Security/SqlSecurityAuthority.MasterRegistration.cs
new file mode 100644
index 00000000..3fb836d2
--- /dev/null
+++ b/backend/FactoryConnect.Sql/Security/SqlSecurityAuthority.MasterRegistration.cs
@@ -0,0 +1,213 @@
+using System.Security.Cryptography;
+using System.Text.Json;
+using FactoryConnect.Sql.MasterData;
+
+namespace FactoryConnect.Sql.Security;
+
+public sealed partial class SqlSecurityAuthority
+{
+ public sealed record AdministrationSite(Guid SiteId, string DisplayName, bool IsRetired);
+
+ public Task> ReadAdministrationSitesAsync(
+ byte[] sessionHash, CancellationToken ct = default)
+ {
+ if (sessionHash is null || sessionHash.Length != 32) throw new SecurityDeniedException();
+ return ReadAsync(sessionHash, "configuration.master.manage", AuthorizationScope.Installation,
+ async (connection, transaction, token) =>
+ {
+ await using var command = new Microsoft.Data.SqlClient.SqlCommand(
+ "SELECT SiteId, DisplayName, IsRetired FROM master.Site ORDER BY DisplayName, SiteId",
+ connection, transaction);
+ await using var reader = await command.ExecuteReaderAsync(token);
+ var sites = new List();
+ while (await reader.ReadAsync(token))
+ sites.Add(new AdministrationSite(reader.GetGuid(0), reader.GetString(1), reader.GetBoolean(2)));
+ return (IReadOnlyList)sites;
+ }, ct);
+ }
+
+
+ public sealed record AdministrationLine(Guid ProductionLineId, Guid SiteId, string DisplayName, bool IsRetired);
+ public Task> ReadAdministrationLinesAsync(
+ byte[] sessionHash, CancellationToken ct = default)
+ {
+ if (sessionHash is null || sessionHash.Length != 32) throw new SecurityDeniedException();
+ return ReadAsync(sessionHash, "configuration.master.manage", AuthorizationScope.Installation,
+ async (connection, transaction, token) =>
+ {
+ await using var command = new Microsoft.Data.SqlClient.SqlCommand(
+ "SELECT ProductionLineId, SiteId, DisplayName, IsRetired FROM master.ProductionLine ORDER BY DisplayName, ProductionLineId",
+ connection, transaction);
+ await using var reader = await command.ExecuteReaderAsync(token);
+ var lines = new List();
+ while (await reader.ReadAsync(token))
+ lines.Add(new AdministrationLine(reader.GetGuid(0), reader.GetGuid(1),
+ reader.GetString(2), reader.GetBoolean(3)));
+ return (IReadOnlyList)lines;
+ }, ct);
+ }
+
+ public sealed record AdministrationMachine(Guid MachineId, Guid SiteId, string DisplayName, bool IsRetired);
+ public Task> ReadAdministrationMachinesAsync(
+ byte[] sessionHash, CancellationToken ct = default)
+ {
+ if (sessionHash is null || sessionHash.Length != 32) throw new SecurityDeniedException();
+ return ReadAsync(sessionHash, "configuration.master.manage", AuthorizationScope.Installation,
+ async (connection, transaction, token) =>
+ {
+ await using var command = new Microsoft.Data.SqlClient.SqlCommand(
+ "SELECT MachineId, SiteId, DisplayName, IsRetired FROM master.Machine ORDER BY DisplayName, MachineId",
+ connection, transaction);
+ await using var reader = await command.ExecuteReaderAsync(token);
+ var machines = new List();
+ while (await reader.ReadAsync(token))
+ machines.Add(new AdministrationMachine(reader.GetGuid(0), reader.GetGuid(1),
+ reader.GetString(2), reader.GetBoolean(3)));
+ return (IReadOnlyList)machines;
+ }, ct);
+ }
+
+ public sealed record CommissioningAssignment(Guid MachineId, Guid ProductionLineId,
+ string MtConnectDeviceId, Guid LastOperationId);
+
+ public Task> ReadCommissioningAssignmentsAsync(
+ byte[] sessionHash, CancellationToken ct = default)
+ {
+ if (sessionHash is null || sessionHash.Length != 32) throw new SecurityDeniedException();
+ return ReadAsync(sessionHash, "configuration.master.manage", AuthorizationScope.Installation,
+ async (connection, transaction, token) =>
+ {
+ await using var command = new Microsoft.Data.SqlClient.SqlCommand(
+ "SELECT MachineId, ProductionLineId, MtConnectDeviceId, LastOperationId FROM master.MachineCommissioning ORDER BY MachineId",
+ connection, transaction);
+ await using var reader = await command.ExecuteReaderAsync(token);
+ var rows = new List();
+ while (await reader.ReadAsync(token))
+ rows.Add(new CommissioningAssignment(reader.GetGuid(0), reader.GetGuid(1),
+ reader.GetString(2), reader.GetGuid(3)));
+ return (IReadOnlyList)rows;
+ }, ct);
+ }
+
+ // A commissioning assignment is administrative configuration, never acquisition activation.
+ public Task SaveCommissioningAssignmentAsync(byte[] sessionHash, Guid operationId,
+ Guid machineId, Guid productionLineId, string deviceId, CancellationToken ct = default)
+ {
+ if (sessionHash is null || sessionHash.Length != 32 || operationId == Guid.Empty)
+ throw new SecurityDeniedException();
+ if (machineId == Guid.Empty || productionLineId == Guid.Empty ||
+ string.IsNullOrWhiteSpace(deviceId) || deviceId.Trim().Length > 200)
+ throw new ArgumentException("Invalid commissioning assignment.");
+ var normalizedDeviceId = deviceId.Trim();
+ var token = sessionHash.ToArray();
+ var request = JsonSerializer.SerializeToUtf8Bytes(new
+ {
+ version = 1, action = "commissioning-assign", machineId, productionLineId,
+ deviceId = normalizedDeviceId
+ });
+ return Transaction(operationId, true, async (connection, transaction) =>
+ {
+ var state = await State(connection, transaction, ct);
+ var actor = await Session(connection, transaction, token, state.Now, ct);
+ await Authorize(connection, transaction, actor, "configuration.master.manage",
+ AuthorizationScope.Installation, state.Now, ct);
+ var completed = await FindOperation(connection, transaction, operationId, ct);
+ if (completed is not null)
+ {
+ if (completed.Actor != actor || !completed.Request.AsSpan().SequenceEqual(request))
+ throw new SecurityConflictException();
+ return 0;
+ }
+
+ await using (var check = new Microsoft.Data.SqlClient.SqlCommand("""
+ SELECT COUNT_BIG(*)
+ FROM master.Machine AS m WITH (UPDLOCK, HOLDLOCK)
+ JOIN master.ProductionLine AS l WITH (UPDLOCK, HOLDLOCK) ON l.SiteId=m.SiteId
+ WHERE m.MachineId=@machine AND l.ProductionLineId=@line
+ AND m.IsRetired=0 AND l.IsRetired=0
+ """, connection, transaction))
+ {
+ check.Parameters.AddWithValue("@machine", machineId);
+ check.Parameters.AddWithValue("@line", productionLineId);
+ if ((long)(await check.ExecuteScalarAsync(ct) ?? 0L) != 1)
+ throw new InvalidOperationException("Machine and production line must be active and in the same site.");
+ }
+ await Exec(connection, transaction, """
+ INSERT security.SecurityOperation
+ (OperationId,ActorPrincipalId,RequestBytes,RequestHash,ScopeKind,SiteId,MachineId,ResultBytes)
+ VALUES (@op,@actor,@bytes,@hash,'I',NULL,NULL,@result);
+ """, ct, ("@op", operationId), ("@actor", actor), ("@bytes", request),
+ ("@hash", SHA256.HashData(request)),
+ ("@result", JsonSerializer.SerializeToUtf8Bytes(new { saved = true })));
+ await Exec(connection, transaction, """
+ MERGE master.MachineCommissioning WITH (HOLDLOCK) AS target
+ USING (SELECT @machine AS MachineId) AS source ON target.MachineId=source.MachineId
+ WHEN MATCHED THEN UPDATE SET ProductionLineId=@line,
+ MtConnectDeviceId=@device, LastOperationId=@op
+ WHEN NOT MATCHED THEN INSERT (MachineId,ProductionLineId,MtConnectDeviceId,LastOperationId)
+ VALUES (@machine,@line,@device,@op);
+ """, ct, ("@machine", machineId), ("@line", productionLineId),
+ ("@device", normalizedDeviceId), ("@op", operationId));
+ await Exec(connection, transaction, """
+ INSERT security.SecurityAudit(AuditId,OperationId,ActorPrincipalId,Action,Reason)
+ VALUES (@id,@op,@actor,@action,@reason);
+ """, ct, ("@id", Guid.NewGuid()), ("@op", operationId), ("@actor", actor),
+ ("@action", "machine-commissioning-assign"),
+ ("@reason", "Administrator assigned production line and MTConnect device"));
+ return 0;
+ }, ct);
+ }
+
+ // Registration is a master-data change, not an acquisition or machine activation.
+ // The installation-level grant is deliberately separate from evidence-reading rights.
+ public Task RegisterMasterRecordAsync(byte[] sessionHash, Guid operationId,
+ string kind, Guid? siteId, string displayName, CancellationToken ct = default, Guid? machineId = null)
+ {
+ if (sessionHash is null || sessionHash.Length != 32 || operationId == Guid.Empty)
+ throw new SecurityDeniedException();
+ if (kind is not ("site" or "line" or "machine") ||
+ (kind == "site" ? siteId is not null : siteId is null || siteId == Guid.Empty) ||
+ string.IsNullOrWhiteSpace(displayName) || displayName.Trim().Length > 200 ||
+ (machineId.HasValue && (kind != "machine" || machineId == Guid.Empty)))
+ throw new ArgumentException("Invalid master-data registration request.");
+ var name = displayName.Trim();
+ var token = sessionHash.ToArray();
+ var request = JsonSerializer.SerializeToUtf8Bytes(new { version = 1, kind, siteId, displayName = name, machineId });
+ return Transaction(operationId, true, async (connection, transaction) =>
+ {
+ var state = await State(connection, transaction, ct);
+ var actor = await Session(connection, transaction, token, state.Now, ct);
+ await Authorize(connection, transaction, actor, "configuration.master.manage",
+ AuthorizationScope.Installation, state.Now, ct);
+ var completed = await FindOperation(connection, transaction, operationId, ct);
+ if (completed is not null)
+ {
+ if (completed.Actor != actor || !completed.Request.AsSpan().SequenceEqual(request))
+ throw new SecurityConflictException();
+ using var result = JsonDocument.Parse(completed.Result);
+ return result.RootElement.GetProperty("recordId").GetGuid();
+ }
+
+ var store = new MachineAdministrationStore(connectionString);
+ var recordId = kind switch
+ {
+ "site" => await store.RegisterSiteAsync(connection, transaction, name, ct),
+ "line" => await store.RegisterLineAsync(connection, transaction, siteId!.Value, name, ct),
+ _ => await store.RegisterMachineAsync(connection, transaction, siteId!.Value, name, machineId, ct)
+ };
+ var resultBytes = JsonSerializer.SerializeToUtf8Bytes(new { recordId });
+ await Exec(connection, transaction, """
+ INSERT security.SecurityOperation
+ (OperationId,ActorPrincipalId,RequestBytes,RequestHash,ScopeKind,SiteId,MachineId,ResultBytes)
+ VALUES (@op,@actor,@bytes,@hash,'I',NULL,NULL,@result);
+ """, ct, ("@op", operationId), ("@actor", actor), ("@bytes", request),
+ ("@hash", SHA256.HashData(request)), ("@result", resultBytes));
+ await Exec(connection, transaction, """
+ INSERT security.SecurityAudit(AuditId,OperationId,ActorPrincipalId,Action,Reason)
+ VALUES (@id,@op,@actor,@action,@reason);
+ """, ct, ("@id", Guid.NewGuid()), ("@op", operationId), ("@actor", actor),
+ ("@action", "master-register-" + kind), ("@reason", "Administrator registered " + kind));
+ return recordId;
+ }, ct);
+ }
+}
diff --git a/docs/commissioning/mtconnect-agent-discovery.md b/docs/commissioning/mtconnect-agent-discovery.md
new file mode 100644
index 00000000..65a8eaeb
--- /dev/null
+++ b/docs/commissioning/mtconnect-agent-discovery.md
@@ -0,0 +1,79 @@
+# FC2-COM-002 — MTConnect Agent discovery and machine commissioning
+
+Status: implementation contract; discovery and activation are **not** implemented by PR #76.
+
+## Reuse existing acquisition configuration
+
+FactoryConnect already has Edge-side MTConnect acquisition. Before adding any new persistent Agent configuration, inspect the existing Edge configuration model, its storage, deployment flow and the actual URL consumer. Extend that model and its approved configuration path instead of creating a second independent list of MTConnect URLs.
+
+An Agent endpoint and a CNC machine are different identities. One Agent can expose many devices. Store or reference the Agent configuration once; associate selected device identities with commissioned machine records. Never infer identity from a display name alone.
+
+## Required administrator workflow
+
+1. List existing configured Agents (URL, descriptive label and configuration status) from the authoritative configuration source. Do not expose credentials or secrets.
+2. Read the Agent's MTConnect `/probe` document through a server-side, explicitly configured Agent endpoint; do not accept arbitrary URLs from an unauthenticated browser request.
+3. Parse `Devices` and `Device` identities, including UUID when supplied, device name, data items and data item IDs. Treat probe data as untrusted external XML; disable DTD/external entities and bound response size and request duration.
+4. Show discovered devices separately from registered machines. Indicate matched, available-to-add and ambiguous identities. Require explicit review for ambiguous matches.
+5. For selected devices, register or associate the machine with an existing site and production line, then configure signal mappings through the existing configuration approval workflow.
+6. Test reachability and usable observations using the existing Edge/MTConnect integration. A successful probe does not prove a live device or usable OEE data.
+7. Activate acquisition only through the existing controlled activation path, after an administrator explicitly confirms the target machine and configuration. Do not activate on discovery or registration.
+
+## Product acceptance
+
+- One Agent with multiple devices: administrator can select a subset; no duplicate Agent URL per machine.
+- Multiple Agents: distinguish devices by stable Agent and device identity; report ambiguous identity conflicts.
+- Missing, malformed or unreachable Agent: actionable diagnostics, no machine registration or activation side effect.
+- Existing machine: reconcile by durable configured identity; no silent duplicate registration.
+- Machine assignment: site and production line are visible and editable through a controlled configuration path.
+- Read-only discovery and test: no acquisition activation, no production database change.
+- Once activated, Edge observations can be traced through accepted activity/quantity processing to operator screens and management OEE reports.
+
+## Delivery order
+
+1. Identify existing Edge Agent URL configuration and source-of-truth (V1 and V2), plus configuration publishing/acknowledgement contract.
+2. Add a read-only, authenticated API that enumerates configured Agents and discovers their `/probe` devices using that configuration.
+3. Add discovery and selection UI to Machine Administration.
+4. Implement machine-to-line assignment and durable device-to-machine mapping using the established master/configuration schema.
+5. Implement connection test, signal mapping, controlled activation and end-to-end data acceptance.
+
+Keep production activation and deployment out of repository-only work.
+
+
+## Local developer-machine live discovery (opt-in)
+
+The V2 API can now read the existing Edge `MTConnect.Machines` entries from a trusted, absolute local file path. No Agent URL is entered through the browser and no new Agent registry is created.
+
+In the **API process environment** set `MTConnect__EdgeConfigurationFile` to the absolute path of the existing Edge JSON configuration (for example, a local development copy of `edge.production.json`). Restart the API after changing the path. The API fails startup if an explicitly configured file is absent, malformed, duplicated by MachineId, or contains invalid machine addresses. Only MachineId, DeviceKey and BaseUri are imported; database credentials, security configuration and other Edge settings are not loaded.
+
+The machine must already be registered in V2 with the **same MachineId** and must not be retired. Open **Administration → Machines**, select the registered machine, then choose **Discover Agent devices**. The API makes a bounded read-only `/probe` request to the configured Agent. It reports the devices and whether the configured DeviceKey has zero, one, or multiple matching devices. A potential match still requires administrator verification. Discovery never enables acquisition or saves a mapping.
+
+The API host must be able to reach the configured Agent address from its own network context. Edge loopback URLs such as `127.0.0.1` only work when the API and Agent run on the same host; do not assume a copied production Edge configuration is reachable from a developer machine. Never activate production machines just to test discovery.
+
+
+## Local commissioning save/read acceptance (COM-001)
+
+Run against the developer V2 database only, after applying migration
+`022-machine-commissioning.sql`. Use an administrator session with
+`configuration.master.manage`. No Edge acquisition or production deployment is required.
+
+1. Open `/admin/machines` and register a site and production line if none exist.
+2. Register an Edge-configured machine using its existing MachineId. Verify that a
+ second registration using the same MachineId is rejected, not duplicated.
+3. Select the registered machine for live Agent discovery. Confirm that the
+ configured DeviceKey has exactly one match; ambiguous or failed discovery
+ must not allow the Save commissioning assignment action.
+4. Choose a production line belonging to the machine's site and save the
+ matching device. Confirm the UI reports **saved and verified**, then refresh
+ the browser and confirm the assignment is still listed.
+5. Change the assignment to another valid line in the same site, if available.
+ Confirm the machine has one current assignment and the audit history records
+ the change. Reusing an operation ID with different request bytes must fail.
+6. Attempt to assign a production line from a different site (using the
+ authenticated API, not just the UI). Expect HTTP 409 and no assignment change.
+7. Confirm acquisition has not been activated: the saved assignment is
+ administrative intent, not a running connector or proof of production data.
+
+If any step fails, keep PR #76 in draft and record the request, HTTP status,
+database migration version, and relevant application log without exposing
+session tokens or Edge credentials. Do not perform these checks against Gajra
+production systems.
diff --git a/scripts/ci/activity-browser.mjs b/scripts/ci/activity-browser.mjs
index 16a5ebf4..57c76eb0 100644
--- a/scripts/ci/activity-browser.mjs
+++ b/scripts/ci/activity-browser.mjs
@@ -17,7 +17,7 @@ try {
const url = process.env.FC2_BROWSER_URL;
async function login() {
await page.goto(url + '/operator-evidence');
- await page.getByRole('link', { name: 'Sign in to view machine evidence' }).click();
+ await page.getByRole('link', { name: 'Sign in to FactoryConnect' }).click();
await page.getByLabel('Password').fill(process.env.FC2_BROWSER_PASSWORD);
await page.getByRole('button', { name: 'Provider sign in' }).click();
}
diff --git a/scripts/ci/operator-browser.mjs b/scripts/ci/operator-browser.mjs
index 3f56082c..a0ff5035 100644
--- a/scripts/ci/operator-browser.mjs
+++ b/scripts/ci/operator-browser.mjs
@@ -17,7 +17,7 @@ try {
const url = process.env.FC2_BROWSER_URL;
async function login() {
await page.goto(url + '/operator-evidence');
- await page.getByRole('link', { name: 'Sign in to view machine evidence' }).click();
+ await page.getByRole('link', { name: 'Sign in to FactoryConnect' }).click();
await page.getByLabel('Password').fill(process.env.FC2_BROWSER_PASSWORD);
await page.getByRole('button', { name: 'Provider sign in' }).click();
}
diff --git a/src/App.tsx b/src/App.tsx
index 34702bbf..c564daf3 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,4 +1,5 @@
import { lazy, Suspense } from "react";
+import { MachineAdministrationPage } from "./presentation/MachineAdministrationPage.tsx";
import { CommissioningPage } from "./presentation/CommissioningPage.tsx";
import { OperatorEvidencePage } from "./presentation/OperatorEvidencePage.tsx";
import { RetainedMachineEvidence } from "./presentation/RetainedMachineEvidence.tsx";
@@ -103,10 +104,11 @@ export function App({ runtime }: AppProps) {
+ : Sign in to FactoryConnect }
{SupervisorWorkspace && Supervisor desk preview }
Operator evidence {" "}
+ Machine administration {" "}
Commissioning {" "}
Overview {" "}
Fleet status {" "}
@@ -160,6 +162,8 @@ function RouteView({ route, navigate, runtime }: RouteViewProps) {
? Loading screen review…
}>
: Supervisor workspace not enabled The screen-review preview is available only in development builds. No production data-entry service is connected.
;
case "commissioning": return ;
+ case "machineAdministration": return ;
case "operatorEvidence": return ;
case "overview": return ;
diff --git a/src/presentation/MachineAdministrationPage.tsx b/src/presentation/MachineAdministrationPage.tsx
new file mode 100644
index 00000000..df482820
--- /dev/null
+++ b/src/presentation/MachineAdministrationPage.tsx
@@ -0,0 +1,801 @@
+import { useEffect, useMemo, useRef, useState } from "react";
+import { readAllOperatorEvidenceMachines, type OperatorEvidenceMachine } from "../api/evidence/operator-evidence-machines-client.ts";
+
+/**
+ * Administrative inventory and registration require a current authorized session.
+ * Evidence discovery is independently permission-scoped and may be incomplete.
+ */
+export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
+ readonly sessionBearer: string | null;
+ readonly baseAddress: string;
+}) {
+ const [probeDevices, setProbeDevices] = useState([]);
+ const [probeError, setProbeError] = useState("");
+
+ const [edgeConfigured, setEdgeConfigured] = useState([]);
+ const [edgeConfigError, setEdgeConfigError] = useState("");
+
+ const [liveDiscoveryMachineId, setLiveDiscoveryMachineId] = useState("");
+ const [liveDiscoveryBusy, setLiveDiscoveryBusy] = useState(false);
+ const [liveDiscoveryMessage, setLiveDiscoveryMessage] = useState("");
+ const [discoverySourceMachineId, setDiscoverySourceMachineId] = useState("");
+ const [discoveryIdentityStatus, setDiscoveryIdentityStatus] = useState("");
+ const [discoveryMatchedIds, setDiscoveryMatchedIds] = useState([]);
+ const [commissioningLineId, setCommissioningLineId] = useState("");
+ const [commissioningDeviceId, setCommissioningDeviceId] = useState("");
+ const [commissioningSaveMessage, setCommissioningSaveMessage] = useState("");
+ const [commissioningSaving, setCommissioningSaving] = useState(false);
+ const pendingCommissioning = useRef<{ fingerprint: string; operationId: string } | null>(null);
+ async function saveCommissioningAssignment() {
+ if (!sessionBearer || commissioningSaving || !commissioningSelectionValid) return;
+ const fingerprint = JSON.stringify({
+ sessionBearer, machineId: discoverySourceMachineId, productionLineId: commissioningLineId,
+ deviceId: commissioningDeviceId
+ });
+ if (pendingCommissioning.current?.fingerprint !== fingerprint)
+ pendingCommissioning.current = { fingerprint, operationId: crypto.randomUUID() };
+ setCommissioningSaving(true);
+ setCommissioningSaveMessage("");
+ try {
+ const response = await fetch(new URL("/api/admin/v2/commissioning-assignments", baseAddress), {
+ method: "POST",
+ headers: { Authorization: "Bearer " + sessionBearer, "Content-Type": "application/json" },
+ body: JSON.stringify({ machineId: discoverySourceMachineId,
+ productionLineId: commissioningLineId, deviceId: commissioningDeviceId,
+ operationId: pendingCommissioning.current.operationId })
+ });
+ if (!response.ok) throw new Error("Commissioning assignment was not saved (HTTP " +
+ response.status + "). Retry the same selection to preserve the operation ID.");
+ pendingCommissioning.current = null;
+ setAssignmentRevision(value => value + 1);
+ const readback = await fetch(new URL("/api/admin/v2/commissioning-assignments", baseAddress), {
+ headers: { Authorization: "Bearer " + sessionBearer }
+ });
+ if (!readback.ok) {
+ setCommissioningSaveMessage("Save accepted, but the assignment could not be verified by read-back. Refresh the inventory before continuing.");
+ return;
+ }
+ let persisted: { assignments: {
+ machineId: string; productionLineId: string; mtConnectDeviceId: string; lastOperationId: string
+ }[] };
+ try { persisted = await readback.json(); }
+ catch {
+ setCommissioningSaveMessage("Save accepted, but the read-back response was invalid. Refresh the inventory to verify.");
+ return;
+ }
+ const match = persisted.assignments.find(assignment =>
+ assignment.machineId.toLowerCase() === discoverySourceMachineId.toLowerCase() &&
+ assignment.productionLineId.toLowerCase() === commissioningLineId.toLowerCase() &&
+ assignment.mtConnectDeviceId === commissioningDeviceId);
+ setSavedAssignments(persisted.assignments);
+ setCommissioningSaveMessage(match
+ ? "Assignment saved and verified in V2. Machine acquisition remains inactive."
+ : "Save accepted, but the selected assignment was not found on read-back. Review the saved assignments.");
+ } catch (error) {
+ setCommissioningSaveMessage(error instanceof Error ? error.message : "Assignment save failed.");
+ } finally { setCommissioningSaving(false); }
+ }
+ function exportCommissioningReview() {
+ const machine = inventory?.find(item => item.machineId === discoverySourceMachineId);
+ const device = probeDevices.find(item => item.id === commissioningDeviceId);
+ const line = lines?.find(item => item.productionLineId === commissioningLineId);
+ if (!machine || !device || !line || line.siteId !== machine.siteId ||
+ machine.isRetired || line.isRetired) return;
+ const review = {
+ format: "factoryconnect-v2-mtconnect-commissioning-review-v1",
+ status: "review-only-not-approved",
+ acquisitionActivated: false,
+ machineId: machine.machineId,
+ machineName: machine.displayName,
+ siteId: machine.siteId,
+ productionLineId: line.productionLineId,
+ productionLineName: line.displayName,
+ device: { id: device.id, name: device.name, uuid: device.uuid },
+ configuredIdentityStatus: discoveryIdentityStatus,
+ configuredIdentityMatches: discoveryMatchedIds,
+ dataItems: device.dataItems
+ };
+ const url = URL.createObjectURL(new Blob([JSON.stringify(review, null, 2)], {
+ type: "application/json"
+ }));
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = "factoryconnect-commissioning-review-" + machine.machineId + ".json";
+ document.body.append(link);
+ link.click();
+ link.remove();
+ URL.revokeObjectURL(url);
+ }
+
+ async function discoverConfiguredMachine() {
+ if (!sessionBearer || !liveDiscoveryMachineId || liveDiscoveryBusy) return;
+ setDiscoverySourceMachineId("");
+ setDiscoveryIdentityStatus("");
+ setDiscoveryMatchedIds([]);
+ setCommissioningDeviceId("");
+ setCommissioningLineId("");
+ setProbeDevices([]);
+ setCommissioningSaveMessage("");
+ setLiveDiscoveryBusy(true);
+ setLiveDiscoveryMessage("");
+ try {
+ const response = await fetch(new URL("/api/admin/v2/mtconnect/discover/" +
+ encodeURIComponent(liveDiscoveryMachineId), baseAddress), {
+ headers: { Authorization: "Bearer " + sessionBearer }
+ });
+ if (!response.ok) {
+ if (response.status === 404)
+ throw new Error("This machine is not configured in the API host's existing Edge MTConnect settings.");
+ if (response.status === 401 || response.status === 403)
+ throw new Error("Administrator authorization is required for live discovery.");
+ throw new Error("Agent discovery failed (HTTP " + response.status + ").");
+ }
+ const payload: { identityStatus: string; matchingDeviceIds: readonly string[]; devices: readonly {
+ id: string; name: string; uuid?: string | null;
+ dataItems: readonly { id: string; name?: string | null; type?: string | null; subType?: string | null }[]
+ }[] } = await response.json();
+ const discovered = payload.devices.map(device => ({
+ id: device.id, name: device.name, uuid: device.uuid ?? "",
+ dataItems: device.dataItems.map(item => ({
+ id: item.id, name: item.name ?? "", type: item.type ?? "", subType: item.subType ?? ""
+ }))
+ }));
+ setProbeDevices(discovered);
+ setDiscoverySourceMachineId(liveDiscoveryMachineId);
+ setDiscoveryIdentityStatus(payload.identityStatus);
+ setDiscoveryMatchedIds(payload.matchingDeviceIds);
+ setCommissioningDeviceId(payload.matchingDeviceIds.length === 1 ? (payload.matchingDeviceIds[0] ?? "") : "");
+ setCommissioningLineId("");
+ setSelectedProbeIds([]);
+ setExpandedProbeId(null);
+ setProbeError("");
+ setLiveDiscoveryMessage("Read-only Agent probe completed: " + discovered.length +
+ " devices. Configured DeviceKey identity: " + payload.identityStatus +
+ " (" + payload.matchingDeviceIds.length + " matches). " +
+ (payload.identityStatus === "potential-match"
+ ? "Verify the matching device before commissioning."
+ : "Identity requires administrator review; no device mapping was saved."));
+ } catch (error) {
+ setLiveDiscoveryMessage(error instanceof Error ? error.message : "Agent discovery unavailable.");
+ } finally { setLiveDiscoveryBusy(false); }
+ }
+
+
+
+ async function previewEdgeConfig(file: File | undefined) {
+ setEdgeConfigured([]);
+ setEdgeConfigError("");
+ if (!file) return;
+ if (file.size > 2_000_000) { setEdgeConfigError("Edge configuration exceeds 2 MB."); return; }
+ try {
+ const parsed: unknown = JSON.parse(await file.text());
+ if (!parsed || typeof parsed !== "object") throw new Error("Invalid Edge configuration.");
+ const root = parsed as Record;
+ const mtconnect = root.MTConnect;
+ if (!mtconnect || typeof mtconnect !== "object") throw new Error("MTConnect configuration missing.");
+ const machines = (mtconnect as Record).Machines;
+ if (!Array.isArray(machines) || machines.length > 500) throw new Error("Invalid MTConnect machines list.");
+ const identities = new Set();
+ const configured = machines.map((entry: unknown) => {
+ if (!entry || typeof entry !== "object") throw new Error("Invalid configured machine.");
+ const value = entry as Record;
+ const machineId = value.MachineId;
+ const deviceKey = value.DeviceKey;
+ const baseUri = value.BaseUri;
+ if (typeof machineId !== "string" || typeof deviceKey !== "string" ||
+ typeof baseUri !== "string" || !machineId.trim() || !deviceKey.trim() ||
+ !/^https?:\/\//i.test(baseUri))
+ throw new Error("Configured machine is missing MachineId, DeviceKey or BaseUri.");
+ const identity = machineId.toLowerCase();
+ if (identities.has(identity)) throw new Error("Duplicate configured MachineId.");
+ identities.add(identity);
+ return { machineId, deviceKey, baseUri };
+ });
+ setEdgeConfigured(configured);
+ } catch (error) {
+ setEdgeConfigError(error instanceof Error ? error.message : "Cannot read Edge configuration.");
+ }
+ }
+
+ const [expandedProbeId, setExpandedProbeId] = useState(null);
+ const [selectedProbeIds, setSelectedProbeIds] = useState([]);
+ function exportProbeSelection() {
+ const selected = probeDevices.filter(device => selectedProbeIds.includes(device.id));
+ if (!selected.length) return;
+ const quote = (value: string) => '"' + value.replaceAll('"', '""') + '"';
+ const rows = [
+ ["MTConnect Device ID", "MTConnect Device UUID", "Device Name", "Data Item Count", "Commissioning Status"],
+ ...selected.map(device => [device.id, device.uuid, device.name, String(device.dataItems.length), "Review required"])
+ ];
+ const csv = "\uFEFF" + rows.map(row => row.map(quote).join(",")).join("\r\n");
+ const url = URL.createObjectURL(new Blob([csv], { type: "text/csv;charset=utf-8" }));
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = "factoryconnect-mtconnect-discovered-devices.csv";
+ document.body.append(link);
+ link.click();
+ link.remove();
+ URL.revokeObjectURL(url);
+ }
+
+ async function previewProbeFile(file: File | undefined) {
+ setProbeDevices([]);
+ setDiscoverySourceMachineId("");
+ setDiscoveryIdentityStatus("");
+ setDiscoveryMatchedIds([]);
+ setCommissioningDeviceId("");
+ setCommissioningLineId("");
+ setSelectedProbeIds([]);
+ setExpandedProbeId(null);
+ setProbeError("");
+ if (!file) return;
+ if (file.size > 4_000_000) { setProbeError("Devices XML must be smaller than 4 MB."); return; }
+ try {
+ const source = await file.text();
+ if (/ node.localName === "Device");
+ if (devices.length > 500) throw new Error("Too many devices.");
+ const ids = new Set();
+ const uuids = new Set();
+ const found = devices.map(device => {
+ const id = device.getAttribute("id")?.trim() ?? "";
+ const name = device.getAttribute("name")?.trim() ?? "";
+ const uuid = device.getAttribute("uuid")?.trim() ?? "";
+ if (!id || !name || ids.has(id) || (uuid && uuids.has(uuid)))
+ throw new Error("Missing or duplicate device identity.");
+ ids.add(id);
+ if (uuid) uuids.add(uuid);
+ const dataItems = Array.from(device.getElementsByTagName("*")).filter(node => node.localName === "DataItem");
+ if (dataItems.length > 5000) throw new Error("Too many data items.");
+ const dataItemIds = new Set();
+ for (const item of dataItems) {
+ const dataItemId = item.getAttribute("id")?.trim() ?? "";
+ if (!dataItemId || dataItemIds.has(dataItemId))
+ throw new Error("Missing or duplicate MTConnect data item ID.");
+ dataItemIds.add(dataItemId);
+ }
+ return { id, name, uuid, dataItems: dataItems.map(item => ({
+ id: item.getAttribute("id") ?? "", name: item.getAttribute("name") ?? "",
+ type: item.getAttribute("type") ?? "", subType: item.getAttribute("subType") ?? ""
+ })) };
+ });
+ setProbeDevices(found);
+ } catch (error) {
+ setProbeError(error instanceof Error ? error.message : "Unable to read MTConnect Devices XML.");
+ }
+ }
+ const [revision, setRevision] = useState(0);
+ const [search, setSearch] = useState("");
+ const [kind, setKind] = useState<"site" | "line" | "machine">("site");
+ const [siteId, setSiteId] = useState("");
+ const [sites, setSites] = useState([]);
+ const [sitesError, setSitesError] = useState("");
+ useEffect(() => {
+ if (!sessionBearer) { setSites([]); return; }
+ const controller = new AbortController();
+ void fetch(new URL("/api/admin/v2/sites", baseAddress), {
+ headers: { Authorization: "Bearer " + sessionBearer }, signal: controller.signal
+ }).then(async response => {
+ if (!response.ok) throw new Error("Site inventory unavailable.");
+ const data: { sites: { siteId: string; displayName: string; isRetired: boolean }[] } = await response.json();
+ if (!controller.signal.aborted) { setSites(data.sites); setSitesError(""); }
+ }).catch(() => {
+ if (!controller.signal.aborted) { setSites([]); setSitesError("Site selection unavailable for this account."); }
+ });
+ return () => controller.abort();
+ }, [baseAddress, sessionBearer, revision]);
+
+
+ const [inventory, setInventory] = useState(null);
+ const [inventoryError, setInventoryError] = useState("");
+ const [configuredMachines, setConfiguredMachines] = useState(null);
+ const [configuredError, setConfiguredError] = useState("");
+ useEffect(() => {
+ if (!sessionBearer) { setConfiguredMachines(null); return; }
+ const controller = new AbortController();
+ void fetch(new URL("/api/admin/v2/mtconnect/configured-machines", baseAddress), {
+ headers: { Authorization: "Bearer " + sessionBearer }, signal: controller.signal
+ }).then(async response => {
+ if (!response.ok) throw new Error("Configured Edge machine inventory unavailable.");
+ const data: { configured: { machineId: string; deviceKey: string; registered: boolean; duplicateConfiguration: boolean }[] } =
+ await response.json();
+ if (!controller.signal.aborted) { setConfiguredMachines(data.configured); setConfiguredError(""); }
+ }).catch(() => {
+ if (!controller.signal.aborted) {
+ setConfiguredMachines(null);
+ setConfiguredError("Edge configuration status unavailable.");
+ }
+ });
+ return () => controller.abort();
+ }, [baseAddress, sessionBearer, revision]);
+
+ const probeEdgeComparison = useMemo(() => probeDevices.map(device => {
+ const matches = edgeConfigured.filter(configured =>
+ configured.deviceKey === device.name || configured.deviceKey === device.id ||
+ (device.uuid !== "" && configured.deviceKey === device.uuid));
+ const configuredIds = new Set(matches.map(match => match.machineId.toLowerCase()));
+ const duplicateDeviceKey = matches.length > 1;
+ const registered = matches.length === 1 && inventory?.find(machine =>
+ machine.machineId.toLowerCase() === matches[0]?.machineId.toLowerCase());
+ return { device, matches, duplicateDeviceKey, registered,
+ identityConflict: configuredIds.size > 1 || duplicateDeviceKey };
+ }), [probeDevices, edgeConfigured, inventory]);
+
+ function exportMachineInventory() {
+ if (!inventory) return;
+ const quote = (value: string) => '"' + value.replaceAll('"', '""') + '"';
+ const rows = [
+ ["Machine ID", "Machine", "Site ID", "Site", "Status"],
+ ...visibleInventory.map(machine => [
+ machine.machineId, machine.displayName, machine.siteId,
+ sites.find(site => site.siteId === machine.siteId)?.displayName ?? "",
+ machine.isRetired ? "Retired" : "Registered"
+ ])
+ ];
+ const csv = "\uFEFF" + rows.map(row => row.map(quote).join(",")).join("\r\n");
+ const url = URL.createObjectURL(new Blob([csv], { type: "text/csv;charset=utf-8" }));
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = "factoryconnect-machine-inventory.csv";
+ document.body.append(link);
+ link.click();
+ link.remove();
+ URL.revokeObjectURL(url);
+ }
+ const [inventorySearch, setInventorySearch] = useState("");
+ const [inventorySite, setInventorySite] = useState("");
+ const [includeRetired, setIncludeRetired] = useState(false);
+ const visibleInventory = useMemo(() => (inventory ?? []).filter(machine => {
+ if (!includeRetired && machine.isRetired) return false;
+ if (inventorySite && machine.siteId !== inventorySite) return false;
+ const term = inventorySearch.trim().toLocaleLowerCase();
+ return !term || machine.displayName.toLocaleLowerCase().includes(term) ||
+ machine.machineId.toLocaleLowerCase().includes(term) ||
+ (sites.find(site => site.siteId === machine.siteId)?.displayName ?? "").toLocaleLowerCase().includes(term);
+ }), [inventory, inventorySearch, inventorySite, includeRetired, sites]);
+ useEffect(() => {
+ if (!sessionBearer) { setInventory(null); return; }
+ const controller = new AbortController();
+ void fetch(new URL("/api/admin/v2/machines", baseAddress), {
+ headers: { Authorization: "Bearer " + sessionBearer }, signal: controller.signal
+ }).then(async response => {
+ if (!response.ok) throw new Error("Administrator machine inventory unavailable.");
+ const data: { machines: { machineId: string; siteId: string; displayName: string; isRetired: boolean }[] } = await response.json();
+ if (!controller.signal.aborted) { setInventory(data.machines); setInventoryError(""); }
+ }).catch(() => {
+ if (!controller.signal.aborted) { setInventory(null); setInventoryError("Full machine inventory requires administrator access."); }
+ });
+ return () => controller.abort();
+ }, [baseAddress, sessionBearer, revision]);
+
+ const [lineSiteFilter, setLineSiteFilter] = useState("");
+ const [showRetiredLines, setShowRetiredLines] = useState(false);
+ const [lines, setLines] = useState(null);
+ const visibleLines = useMemo(() => (lines ?? []).filter(line =>
+ (showRetiredLines || !line.isRetired) && (!lineSiteFilter || line.siteId === lineSiteFilter)
+ ), [lines, lineSiteFilter, showRetiredLines]);
+ useEffect(() => {
+ if (!sessionBearer) { setLines(null); return; }
+ const controller = new AbortController();
+ void fetch(new URL("/api/admin/v2/lines", baseAddress), {
+ headers: { Authorization: "Bearer " + sessionBearer }, signal: controller.signal
+ }).then(async response => {
+ if (!response.ok) throw new Error("Production-line inventory unavailable.");
+ const data: { lines: { productionLineId: string; siteId: string; displayName: string; isRetired: boolean }[] } = await response.json();
+ if (!controller.signal.aborted) setLines(data.lines);
+ }).catch(() => { if (!controller.signal.aborted) setLines(null); });
+ return () => controller.abort();
+ }, [baseAddress, sessionBearer, revision]);
+
+ const [savedAssignments, setSavedAssignments] = useState(null);
+ const [savedAssignmentsError, setSavedAssignmentsError] = useState("");
+ const [assignmentRevision, setAssignmentRevision] = useState(0);
+ useEffect(() => {
+ if (!sessionBearer) { setSavedAssignments(null); return; }
+ const controller = new AbortController();
+ void fetch(new URL("/api/admin/v2/commissioning-assignments", baseAddress), {
+ headers: { Authorization: "Bearer " + sessionBearer }, signal: controller.signal
+ }).then(async response => {
+ if (!response.ok) throw new Error("Saved commissioning assignments unavailable.");
+ const payload: { assignments: {
+ machineId: string; productionLineId: string; mtConnectDeviceId: string; lastOperationId: string
+ }[] } = await response.json();
+ if (!controller.signal.aborted) {
+ setSavedAssignments(payload.assignments);
+ setSavedAssignmentsError("");
+ }
+ }).catch(() => {
+ if (!controller.signal.aborted) {
+ setSavedAssignments(null);
+ setSavedAssignmentsError("Cannot load saved commissioning assignments.");
+ }
+ });
+ return () => controller.abort();
+ }, [baseAddress, sessionBearer, revision, assignmentRevision]);
+ const selectedCommissioningMachine = inventory?.find(machine =>
+ machine.machineId === discoverySourceMachineId && !machine.isRetired);
+ const selectedCommissioningLine = lines?.find(line =>
+ line.productionLineId === commissioningLineId && !line.isRetired);
+ const commissioningSelectionValid = Boolean(selectedCommissioningMachine &&
+ selectedCommissioningLine &&
+ selectedCommissioningLine.siteId === selectedCommissioningMachine.siteId &&
+ probeDevices.some(device => device.id === commissioningDeviceId) &&
+ discoveryIdentityStatus === "potential-match" &&
+ discoveryMatchedIds.length === 1 &&
+ discoveryMatchedIds[0] === commissioningDeviceId &&
+ liveDiscoveryMachineId === discoverySourceMachineId);
+ const [name, setName] = useState("");
+ const [existingMachineId, setExistingMachineId] = useState("");
+ const [registrationMessage, setRegistrationMessage] = useState("");
+ const [saving, setSaving] = useState(false);
+ const pendingRegistration = useRef<{ fingerprint: string; operationId: string } | null>(null);
+ async function register(event: { preventDefault(): void }) {
+ event.preventDefault();
+ if (!sessionBearer || saving) return;
+ const fingerprint = JSON.stringify({ sessionBearer, kind,
+ siteId: kind === "site" ? null : siteId.trim(), displayName: name.trim(),
+ machineId: kind === "machine" && existingMachineId ? existingMachineId : null });
+ if (pendingRegistration.current?.fingerprint !== fingerprint)
+ pendingRegistration.current = { fingerprint, operationId: crypto.randomUUID() };
+ const operationId = pendingRegistration.current.operationId;
+ setSaving(true);
+ setRegistrationMessage("");
+ try {
+ const response = await fetch(new URL("/api/admin/v2/master-registration", baseAddress), {
+ method: "POST",
+ headers: { Authorization: "Bearer " + sessionBearer, "Content-Type": "application/json" },
+ body: JSON.stringify({ operationId, kind,
+ siteId: kind === "site" ? null : siteId.trim(), displayName: name.trim(),
+ machineId: kind === "machine" && existingMachineId ? existingMachineId : null })
+ });
+ if (!response.ok) {
+ if (response.status === 409) {
+ let detail = "";
+ try {
+ const body: { error?: string } = await response.json();
+ detail = body.error ?? "";
+ } catch { /* The status alone is sufficient. */ }
+ if (detail === "Operation ID conflict")
+ throw new Error("This registration retry conflicts with a previous request. Change the details to start a new registration.");
+ throw new Error("A record with this name already exists, or its selected site is no longer active. Review the inventory before retrying.");
+ }
+ if (response.status === 401 || response.status === 403)
+ throw new Error("Your session cannot register master data. Sign in with an administrator account that has master-data management access.");
+ if (response.status === 400)
+ throw new Error("Invalid registration details. Check the name and selected site.");
+ throw new Error("Registration could not be confirmed (HTTP " + response.status + "). Retry the unchanged request to avoid duplicates.");
+ }
+ const result: { recordId: string } = await response.json();
+ setRegistrationMessage("Registered " + kind + ": " + result.recordId + ". Acquisition remains inactive.");
+ pendingRegistration.current = null;
+ setName("");
+ setExistingMachineId("");
+ setRevision(value => value + 1);
+ } catch (error) {
+ setRegistrationMessage(error instanceof Error ? error.message : "Registration failed.");
+ } finally { setSaving(false); }
+ }
+
+ const [state, setState] = useState<
+ { kind: "loading" | "signIn" | "unavailable" } |
+ { kind: "ready"; bearer: string; machines: readonly OperatorEvidenceMachine[]; complete: boolean }
+ >({ kind: "loading" });
+
+ useEffect(() => {
+ const controller = new AbortController();
+ setState({ kind: "loading" });
+ if (!sessionBearer) {
+ setState({ kind: "signIn" });
+ return () => controller.abort();
+ }
+ void readAllOperatorEvidenceMachines(baseAddress, sessionBearer, controller.signal)
+ .then(result => {
+ if (!controller.signal.aborted)
+ setState({ kind: "ready", bearer: sessionBearer, machines: result.machines, complete: result.isComplete });
+ })
+ .catch(() => { if (!controller.signal.aborted) setState({ kind: "unavailable" }); });
+ return () => controller.abort();
+ }, [baseAddress, sessionBearer, revision]);
+
+ const current = state.kind === "ready" && state.bearer !== sessionBearer
+ ? { kind: "signIn" as const } : state;
+ const matches = useMemo(() => current.kind === "ready" ? current.machines.filter(machine =>
+ machine.displayName.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase()) ||
+ machine.machineId.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase()) ||
+ machine.siteId.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())) : [],
+ [current, search]);
+
+ return
+ Machine administration
+ Authorized machine discovery — read only. This list includes only machines for which your session has
+ commissioning evidence access. It is not the complete registered machine inventory or proof of live connectivity.
+
+ Register master data below. Registration does not activate MTConnect acquisition.
+ {sessionBearer && }
+
+
+
+ Registered sites
+ {sitesError && {sitesError}
}
+ {!sitesError &&
+
Site Site ID Status
+ {sites.map(site =>
+ {site.displayName} {site.siteId}
+ {site.isRetired ? "Retired" : "Registered"}
+ )}
+ {sites.length === 0 &&
No sites available to this administrator.
}
+
}
+ Registered production lines
+ Production-line site
+ setLineSiteFilter(event.currentTarget.value)}>
+ All sites
+ {sites.map(site => {site.displayName} )}
+
+
+ setShowRetiredLines(event.currentTarget.checked)} /> Include retired lines
+
+ {lines &&
+
Production line Site Status
+ {visibleLines.map(line =>
+ {line.displayName}
+ {sites.find(site => site.siteId === line.siteId)?.displayName ?? line.siteId}
+ {line.isRetired ? "Retired" : "Registered"}
+ )}
+ {lines.length === 0 &&
No production lines registered yet.
}
+ {lines.length > 0 && visibleLines.length === 0 &&
No production lines match the current filters.
}
+
}
+ Existing Edge configuration readiness
+ {configuredError && {configuredError}
}
+ {configuredMachines !== null &&
+ {configuredMachines.length} Edge machine entries loaded by API;
+ {" "}{configuredMachines.filter(machine => machine.duplicateConfiguration).length} duplicate entries;
+ {" "}{configuredMachines.filter(machine => machine.registered).length} registered and active in V2.
+
}
+ {configuredMachines !== null && configuredMachines.length > 0 &&
+ {configuredMachines.map((machine, index) =>
+ {machine.deviceKey} — {machine.registered ? "Registered in V2" : "Registration required"}
+ {" "}({machine.machineId})
+ {machine.duplicateConfiguration && — Duplicate MachineId in Edge configuration; resolve before commissioning. }
+ {!machine.duplicateConfiguration && !machine.registered && {
+ setKind("machine");
+ setExistingMachineId(machine.machineId);
+ setName(machine.deviceKey);
+ setRegistrationMessage("Registration prepared for existing Edge machine " +
+ machine.deviceKey + ". Select its site and submit to register without activating acquisition.");
+ document.getElementById("admin-record-kind")?.scrollIntoView({ behavior: "smooth" });
+ }}>Prepare registration }
+ {machine.registered && !machine.duplicateConfiguration && {
+ setLiveDiscoveryMachineId(machine.machineId);
+ setLiveDiscoveryMessage("Selected " + machine.deviceKey +
+ ". Use Discover Agent devices to inspect its configured Agent.");
+ }}>Select for discovery }
+ )}
+ }
+ Discover devices from a configured MTConnect Agent
+ Read-only server-side /probe request. The API host must already have the trusted Edge MTConnect configuration loaded. Discovery does not register or activate machines.
+ Configured machine
+ setLiveDiscoveryMachineId(event.currentTarget.value)}>
+ Choose an existing registered machine
+ {(inventory ?? []).filter(machine => !machine.isRetired).map(machine =>
+ {machine.displayName} )}
+
+ void discoverConfiguredMachine()}>
+ {liveDiscoveryBusy ? "Discovering…" : "Discover Agent devices"}
+
+ {liveDiscoveryMessage && {liveDiscoveryMessage}
}
+ {discoverySourceMachineId && probeDevices.length > 0 &&
+ Prepare commissioning review
+ Select the exact MTConnect device and a production line in the registered machine's site.
+ Export a review or save the verified assignment in V2. Saving never activates acquisition.
+ Discovered device
+ setCommissioningDeviceId(event.currentTarget.value)}>
+ Select a device
+ {probeDevices.map(device =>
+ {device.name} ({device.id}) )}
+
+ Production line
+ setCommissioningLineId(event.currentTarget.value)}>
+ Select a production line
+ {(lines ?? []).filter(line => !line.isRetired && line.siteId ===
+ inventory?.find(machine => machine.machineId === discoverySourceMachineId)?.siteId)
+ .map(line =>
+ {line.displayName} )}
+
+ Export commissioning review JSON
+ { void saveCommissioningAssignment(); }}>
+ {commissioningSaving ? "Saving…" : "Save commissioning assignment"}
+
+ {commissioningSaveMessage && {commissioningSaveMessage}
}
+ }
+ Saved commissioning assignments
+ Persisted machine-to-line and MTConnect device mappings. These are administrative records, not proof of active acquisition.
+ {savedAssignmentsError && {savedAssignmentsError}
}
+ {savedAssignments &&
+
Machine Production line MTConnect device ID Acquisition
+ {savedAssignments.map(assignment =>
+ {inventory?.find(machine => machine.machineId === assignment.machineId)?.displayName ??
+ assignment.machineId}
+ {lines?.find(line => line.productionLineId === assignment.productionLineId)?.displayName ??
+ assignment.productionLineId}
+ {assignment.mtConnectDeviceId}
+ Not activated by commissioning
+ )}
+ {savedAssignments.length === 0 &&
No commissioning assignments saved yet.
}
+
}
+ Review existing Edge MTConnect configuration
+ Choose an existing Edge JSON configuration file locally to compare configured Agent URLs and machine identities. The file stays in your browser; no configuration is uploaded, saved or activated.
+ Existing Edge configuration JSON
+ void previewEdgeConfig(event.currentTarget.files?.[0])} />
+ {edgeConfigError && {edgeConfigError}
}
+ {edgeConfigured.length > 0 &&
+
Device key Machine ID Agent URL Registered machine
+ {edgeConfigured.map(machine =>
+ {machine.deviceKey} {machine.machineId}
+ {machine.baseUri}
+ {inventory === null ? "Inventory unavailable" :
+ inventory.some(item => item.machineId.toLowerCase() === machine.machineId.toLowerCase())
+ ? "Existing MachineId found" : "MachineId not registered"}
+ )}
+
}
+ Preview MTConnect devices.xml
+ Read-only commissioning preview: choose a Devices XML file exported from an existing MTConnect Agent /probe endpoint. This does not save URLs, register machines or activate acquisition.
+ MTConnect Devices XML
+ void previewProbeFile(event.currentTarget.files?.[0])} />
+ {probeError && {probeError}
}
+ {probeDevices.length > 0 &&
+
+ Export selected devices ({selectedProbeIds.length})
+
+ {probeDevices.filter(device => device.id === expandedProbeId).map(device =>
+
Signals: {device.name}
+
Read-only MTConnect DataItem metadata. Signal roles and mappings are not yet approved or saved.
+
DataItem ID Name Type Sub-type
+ {device.dataItems.map(item =>
+ {item.id}{item.name || "—"}
+ {item.type || "—"} {item.subType || "—"}
+ )}
+
+
)}
+
}
+ {probeDevices.length > 0 && edgeConfigured.length > 0 &&
+
MTConnect / Edge identity comparison
+
Comparison uses exact DeviceKey against discovered device name, ID or UUID. Multiple matching Edge entries block automatic reconciliation. Existing MachineIds must be reused; names are not authoritative identities. No mapping is approved or saved.
+
+
Discovered device Configured MachineId Result
+ {probeEdgeComparison.map(({device, matches, registered, identityConflict}) =>
+ {device.name} ({device.id})
+ {matches.length === 1 && matches[0] ? {matches[0].machineId} : "—"}
+ {identityConflict ? "BLOCKED: multiple Edge entries match this device" :
+ matches.length === 0 ? "No matching Edge DeviceKey" :
+ registered ? "Configured MachineId exists in V2 — verify identity" :
+ "Configured MachineId absent from V2 — review before registration"}
+ )}
+
+ {edgeConfigured.filter(configured => !probeEdgeComparison.some(row =>
+ row.matches.some(match => match.machineId === configured.machineId))).length > 0 &&
+
Some configured Edge machines were not found in the selected Devices XML. Check Agent selection and device identities.
}
+
}
+ Registered machine inventory
+ Export filtered machines (CSV)
+ This administrator inventory includes registered machines whether or not they have published evidence. Registration does not establish an MTConnect connection, prove acquisition is running, or activate a machine. Acquisition status is not yet verified by this screen.
+ Search registered machines
+ setInventorySearch(event.currentTarget.value)}
+ placeholder="Machine name or ID" />
+ Filter by site
+ setInventorySite(event.currentTarget.value)}>
+ All sites
+ {sites.map(site => {site.displayName} )}
+
+
+ setIncludeRetired(event.currentTarget.checked)} /> Include retired machines
+
+ {inventoryError && {inventoryError}
}
+ {inventory &&
+
Machine Machine ID Site Registration Acquisition
+ {visibleInventory.map(machine =>
+ {machine.displayName} {machine.machineId}
+ {sites.find(site => site.siteId === machine.siteId)?.displayName ?? machine.siteId} {machine.isRetired ? "Retired" : "Registered"} Not verified — configure connection and activate separately
+ )}
+ {inventory.length === 0 &&
No machines registered yet.
}
+ {inventory.length > 0 && visibleInventory.length === 0 &&
No machines match the current filters.
}
+
}
+ setRevision(value => value + 1)}>Refresh authorized machines
+ {current.kind === "loading" && Checking authorized machines…
}
+ {current.kind === "signIn" && Sign in to view authorized machines. Sign in to FactoryConnect
}
+ {current.kind === "unavailable" && Machine discovery is unavailable. No cached inventory is shown.
}
+ {current.kind === "ready" && <>
+ {!current.complete && The authorized machine list is incomplete. Counts below are partial.
}
+ Search by machine, site or ID
+ setSearch(event.currentTarget.value)} />
+ {matches.length} matching machines from {current.machines.length} returned.
+
+
Machine name Machine ID Site ID Evidence
+ {matches.map(machine =>
+ {machine.displayName} {machine.machineId}
+ {machine.siteId}
+ View evidence
+ )}
+
+
+ {matches.length === 0 && {current.machines.length === 0 ? "No machines are available to this account. This does not mean the factory has no registered machines." : "No authorized machines match the current search."}
}
+ >}
+ ;
+}
diff --git a/src/presentation/OperatorEvidencePage.tsx b/src/presentation/OperatorEvidencePage.tsx
index 02e0a28a..6b3e1c76 100644
--- a/src/presentation/OperatorEvidencePage.tsx
+++ b/src/presentation/OperatorEvidencePage.tsx
@@ -10,13 +10,13 @@ export function OperatorEvidencePage({
{ kind: "loading" | "denied" | "failed" } |
{ kind: "ready"; bearer: string; machines: readonly OperatorEvidenceMachine[]; isComplete: boolean }
>({ kind: "loading" });
- const [selectedMachine, setSelectedMachine] = useState("");
+ const [selectedMachine, setSelectedMachine] = useState(() => new URLSearchParams(window.location.search).get("machineId") ?? "");
const [machineSearch, setMachineSearch] = useState("");
const [accessRefresh, setAccessRefresh] = useState(0);
useEffect(() => {
const controller = new AbortController();
setState({ kind: "loading" });
- setSelectedMachine("");
+ setSelectedMachine(new URLSearchParams(window.location.search).get("machineId") ?? "");
setMachineSearch("");
if (!sessionBearer) {
setState({ kind: "denied" });
diff --git a/src/routing/application-route.ts b/src/routing/application-route.ts
index 9e0563af..8d061f06 100644
--- a/src/routing/application-route.ts
+++ b/src/routing/application-route.ts
@@ -1,6 +1,7 @@
export type ApplicationRoute =
| { kind: "supervisorPreview" }
| { kind: "commissioning" }
+ | { kind: "machineAdministration" }
| { kind: "operatorEvidence" }
| { kind: "overview"; search: string }
| { kind: "fleet" }
@@ -19,6 +20,7 @@ export const applicationBasePath = "/";
export function parseApplicationRoute(pathname: string, search = ""): ApplicationRoute {
if (pathname === "/supervisor-preview") return { kind: "supervisorPreview" };
if (pathname === "/operator-evidence") return { kind: "operatorEvidence" };
+ if (pathname === "/admin/machines") return { kind: "machineAdministration" };
if (pathname === "/commissioning") return { kind: "commissioning" };
if (pathname === "/overview") return { kind: "overview", search };
if (pathname === "/fleet") return { kind: "fleet" };
@@ -91,6 +93,7 @@ export function routePath(route: Exclude
switch (route.kind) {
case "supervisorPreview": return "/supervisor-preview";
case "commissioning": return "/commissioning";
+ case "machineAdministration": return "/admin/machines";
case "operatorEvidence": return "/operator-evidence";
case "overview": return `/overview${route.search}`;
case "fleet": return "/fleet";