Files
scadalink-design/docs/plans/2026-05-24-transport.md
Joseph Doherty 1bc98e10a1 docs(plans): add Transport (Component #24) implementation plan
30-task plan covering ScadaLink.Transport project, AES-256-GCM bundle
encryption, IAuditCorrelationContext for BundleImportId threading,
TreeView checkbox-selection mode + TemplateFolderTree wrapper, two
Central UI wizard pages, EF migration, integration tests, README +
cross-reference updates. Single shipping slice, no feature flag.
2026-05-24 03:43:18 -04:00

1666 lines
60 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Transport (Component #24) Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
**Goal:** Ship Component #24 (Transport) — file-based, encrypted bundle export/import of templates, system artifacts, and central-only configuration via the Central UI — as a single shipping slice.
**Architecture:** New `ScadaLink.Transport` project peer to Template Engine + Deployment Manager. Two new Central UI pages under the Design nav group. Persistence flows through existing audited repositories with a new scoped `IAuditCorrelationContext` carrying `BundleImportId`. AES-256-GCM + PBKDF2-SHA256 (600 000 iterations) for content encryption. No site changes.
**Tech Stack:** .NET 10, EF Core, ASP.NET Core / Blazor Server, xUnit + NSubstitute + FluentAssertions, in-memory EF for integration tests, existing `TreeView.razor` component extended with a checkbox-selection mode.
**Source design doc:** `docs/plans/2026-05-24-transport-design.md` (already committed).
**Key codebase facts that shape this plan:**
- Audit entity is `AuditLogEntry` (`src/ScadaLink.Commons/Entities/Audit/AuditLogEntry.cs`), DbSet `_context.AuditLogEntries`.
- `IAuditService.LogAsync(user, action, entityType, entityId, entityName, afterState, ct)` — no existing correlation parameter; we add a scoped `IAuditCorrelationContext` rather than changing every repository signature.
- **No persisted `DeployedRevisionHash` on Instance.** `DeploymentService.CompareAsync` computes stale-vs-fresh live by comparing `snapshot.RevisionHash` to the current flattened-config hash. Overwriting a template naturally changes the hash, so the Deployments page surfaces affected instances automatically — **no explicit stale-mark write is required.**
- `TreeView.razor` exists at `src/ScadaLink.CentralUI/Components/Shared/TreeView.razor` with single-select navigation. We extend it with a checkbox / multi-select mode rather than building a parallel widget.
- DI extension convention: `AddTransport()` in `ServiceCollectionExtensions.cs`, registered in `src/ScadaLink.Host/Program.cs` under "Central-only components".
- Test integration pattern: `ScadaLinkWebApplicationFactory : WebApplicationFactory<Program>` with in-memory EF.
**No feature flag** — backward compatible additive change; users who never open the new pages see no behavior difference.
---
## Task Index
| # | Task | Class | Time |
|---|---|---|---|
| 0 | Bundle DTOs in Commons | small | ~3 min |
| 1 | Transport interfaces | small | ~3 min |
| 2 | `BundleImportId` on `AuditLogEntry` + EF Fluent config | small | ~3 min |
| 3 | EF migration `AddBundleImportIdToAuditLog` | trivial | ~2 min |
| 4 | `IAuditCorrelationContext` scoped service | small | ~3 min |
| 5 | `AuditService` reads correlation context | small | ~3 min |
| 6 | `ScadaLink.Transport` project skeleton + slnx + DI | small | ~3 min |
| 7 | `TransportOptions` | trivial | ~2 min |
| 8 | `BundleSecretEncryptor` (AES-256-GCM + PBKDF2) + tests | standard | ~4 min |
| 9 | `ManifestBuilder` + manifest schema validation | standard | ~4 min |
| 10 | `EntitySerializer` — DTOs + secret carving | standard | ~5 min |
| 11 | `BundleSerializer` (ZIP packer + reader + content hash) | standard | ~4 min |
| 12 | `DependencyResolver` + topology tests | standard | ~5 min |
| 13 | `BundleSessionStore` (TTL + 3-strike lockout) | standard | ~4 min |
| 14 | `BundleExporter.ExportAsync` + audit + tests | standard | ~5 min |
| 15 | `BundleImporter.LoadAsync` | standard | ~4 min |
| 16 | `BundleImporter.PreviewAsync` (diff engine) | standard | ~5 min |
| 17 | `BundleImporter.ApplyAsync` (transaction + validation + audit) | high-risk | ~5 min |
| 18 | Host registration + transport options binding | trivial | ~2 min |
| 19 | `TreeView` checkbox-selection mode + tri-state propagation | standard | ~5 min |
| 20 | `TemplateFolderTree.razor` wrapper + Templates.razor refactor | standard | ~4 min |
| 21 | `TransportExport.razor` wizard | high-risk | ~5 min |
| 22 | `TransportImport.razor` wizard | high-risk | ~5 min |
| 23 | NavMenu entries + UI auth attributes verified | small | ~3 min |
| 24 | Audit log "Bundle Import" filter on existing page | small | ~3 min |
| 25 | Integration: export → wipe → import round-trip | standard | ~5 min |
| 26 | Integration: conflict resolutions + rollback on validation fail | standard | ~5 min |
| 27 | `Component-Transport.md` design doc | small | ~3 min |
| 28 | README + cross-reference updates | small | ~3 min |
| 29 | Manual cluster verification checklist | trivial | ~3 min |
---
## Task 0: Bundle DTOs in Commons
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 1, Task 2, Task 4, Task 19
**Files:**
- Create: `src/ScadaLink.Commons/Types/Transport/BundleManifest.cs`
- Create: `src/ScadaLink.Commons/Types/Transport/BundleSummary.cs`
- Create: `src/ScadaLink.Commons/Types/Transport/EncryptionMetadata.cs`
- Create: `src/ScadaLink.Commons/Types/Transport/ManifestContentEntry.cs`
**Step 1: Write the DTOs**
`BundleManifest.cs`:
```csharp
namespace ScadaLink.Commons.Types.Transport;
public sealed record BundleManifest(
int BundleFormatVersion,
string SchemaVersion,
DateTimeOffset CreatedAtUtc,
string SourceEnvironment,
string ExportedBy,
string ScadaLinkVersion,
string ContentHash,
EncryptionMetadata? Encryption,
BundleSummary Summary,
IReadOnlyList<ManifestContentEntry> Contents);
```
`EncryptionMetadata.cs`:
```csharp
namespace ScadaLink.Commons.Types.Transport;
public sealed record EncryptionMetadata(
string Algorithm, // "AES-256-GCM"
string Kdf, // "PBKDF2-SHA256"
int Iterations,
string SaltB64,
string IvB64);
```
`BundleSummary.cs`:
```csharp
namespace ScadaLink.Commons.Types.Transport;
public sealed record BundleSummary(
int Templates,
int TemplateFolders,
int SharedScripts,
int ExternalSystems,
int DbConnections,
int NotificationLists,
int SmtpConfigs,
int ApiKeys,
int ApiMethods);
```
`ManifestContentEntry.cs`:
```csharp
namespace ScadaLink.Commons.Types.Transport;
public sealed record ManifestContentEntry(
string Type,
string Name,
int Version,
IReadOnlyList<string> DependsOn);
```
**Step 2: Build**
Run: `dotnet build src/ScadaLink.Commons/ScadaLink.Commons.csproj`
Expected: build succeeds.
**Step 3: Commit**
```bash
git add src/ScadaLink.Commons/Types/Transport/
git commit -m "feat(transport): add bundle manifest DTOs in Commons"
```
---
## Task 1: Transport interfaces
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 0, Task 2, Task 4, Task 19
**Files:**
- Create: `src/ScadaLink.Commons/Types/Transport/ExportSelection.cs`
- Create: `src/ScadaLink.Commons/Types/Transport/ImportPreview.cs`
- Create: `src/ScadaLink.Commons/Types/Transport/ImportResolution.cs`
- Create: `src/ScadaLink.Commons/Types/Transport/ImportResult.cs`
- Create: `src/ScadaLink.Commons/Types/Transport/BundleSession.cs`
- Create: `src/ScadaLink.Commons/Interfaces/Transport/IBundleExporter.cs`
- Create: `src/ScadaLink.Commons/Interfaces/Transport/IBundleImporter.cs`
- Create: `src/ScadaLink.Commons/Interfaces/Transport/IBundleSessionStore.cs`
- Create: `src/ScadaLink.Commons/Interfaces/Transport/IAuditCorrelationContext.cs`
**Step 1: Write the types and interfaces**
`ExportSelection.cs`:
```csharp
namespace ScadaLink.Commons.Types.Transport;
public sealed record ExportSelection(
IReadOnlyList<int> TemplateIds,
IReadOnlyList<int> SharedScriptIds,
IReadOnlyList<int> ExternalSystemIds,
IReadOnlyList<int> DatabaseConnectionIds,
IReadOnlyList<int> NotificationListIds,
IReadOnlyList<int> SmtpConfigurationIds,
IReadOnlyList<int> ApiKeyIds,
IReadOnlyList<int> ApiMethodIds,
bool IncludeDependencies);
```
`ImportPreview.cs`:
```csharp
namespace ScadaLink.Commons.Types.Transport;
public enum ConflictKind { Identical, Modified, New, Blocker }
public sealed record ImportPreviewItem(
string EntityType,
string Name,
int? ExistingVersion,
int? IncomingVersion,
ConflictKind Kind,
string? FieldDiffJson,
string? BlockerReason);
public sealed record ImportPreview(
Guid SessionId,
IReadOnlyList<ImportPreviewItem> Items);
```
`ImportResolution.cs`:
```csharp
namespace ScadaLink.Commons.Types.Transport;
public enum ResolutionAction { Add, Overwrite, Skip, Rename }
public sealed record ImportResolution(
string EntityType,
string Name,
ResolutionAction Action,
string? RenameTo);
```
`ImportResult.cs`:
```csharp
namespace ScadaLink.Commons.Types.Transport;
public sealed record ImportResult(
Guid BundleImportId,
int Added,
int Overwritten,
int Skipped,
int Renamed,
IReadOnlyList<int> StaleInstanceIds,
string AuditEventCorrelation);
```
`BundleSession.cs`:
```csharp
namespace ScadaLink.Commons.Types.Transport;
public sealed class BundleSession
{
public Guid SessionId { get; init; }
public BundleManifest Manifest { get; init; } = null!;
public byte[] DecryptedContent { get; init; } = Array.Empty<byte>();
public DateTimeOffset ExpiresAt { get; init; }
public int FailedUnlockAttempts { get; set; }
public bool Locked => FailedUnlockAttempts >= 3;
}
```
`IBundleExporter.cs`:
```csharp
using ScadaLink.Commons.Types.Transport;
namespace ScadaLink.Commons.Interfaces.Transport;
public interface IBundleExporter
{
Task<Stream> ExportAsync(
ExportSelection selection,
string user,
string sourceEnvironment,
string? passphrase,
CancellationToken cancellationToken = default);
}
```
`IBundleImporter.cs`:
```csharp
using ScadaLink.Commons.Types.Transport;
namespace ScadaLink.Commons.Interfaces.Transport;
public interface IBundleImporter
{
Task<BundleSession> LoadAsync(Stream bundleStream, string? passphrase, CancellationToken ct = default);
Task<ImportPreview> PreviewAsync(Guid sessionId, CancellationToken ct = default);
Task<ImportResult> ApplyAsync(
Guid sessionId,
IReadOnlyList<ImportResolution> resolutions,
string user,
CancellationToken ct = default);
}
```
`IBundleSessionStore.cs`:
```csharp
using ScadaLink.Commons.Types.Transport;
namespace ScadaLink.Commons.Interfaces.Transport;
public interface IBundleSessionStore
{
BundleSession Open(BundleSession session);
BundleSession? Get(Guid sessionId);
void Remove(Guid sessionId);
void EvictExpired();
}
```
`IAuditCorrelationContext.cs`:
```csharp
namespace ScadaLink.Commons.Interfaces.Transport;
/// <summary>
/// Scoped service the bundle importer sets to thread a BundleImportId through to
/// the audit log entries emitted by the audited repository methods invoked during
/// ApplyAsync. AuditService reads this and stamps every AuditLogEntry it writes.
/// </summary>
public interface IAuditCorrelationContext
{
Guid? BundleImportId { get; set; }
}
```
**Step 2: Build**
Run: `dotnet build src/ScadaLink.Commons/ScadaLink.Commons.csproj`
Expected: build succeeds.
**Step 3: Commit**
```bash
git add src/ScadaLink.Commons/Types/Transport/ src/ScadaLink.Commons/Interfaces/Transport/
git commit -m "feat(transport): add IBundleExporter / IBundleImporter interfaces"
```
---
## Task 2: `BundleImportId` on `AuditLogEntry` + EF Fluent config
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 0, Task 1, Task 19
**Files:**
- Modify: `src/ScadaLink.Commons/Entities/Audit/AuditLogEntry.cs` (add nullable `BundleImportId`)
- Modify: `src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs` (add Fluent config in `OnModelCreating` near the existing `AuditLogEntry` mapping; add `IX_AuditLogEntries_BundleImportId` non-clustered index)
**Step 1: Add property**
In `AuditLogEntry.cs`, after `Timestamp`:
```csharp
public Guid? BundleImportId { get; set; }
```
**Step 2: Add Fluent config + index**
In `ScadaLinkDbContext.OnModelCreating`, find the existing `modelBuilder.Entity<AuditLogEntry>(...)` block and add:
```csharp
e.HasIndex(x => x.BundleImportId).HasDatabaseName("IX_AuditLogEntries_BundleImportId");
```
If no existing block, add one near other audit configs.
**Step 3: Build**
Run: `dotnet build src/ScadaLink.ConfigurationDatabase/ScadaLink.ConfigurationDatabase.csproj`
Expected: build succeeds.
**Step 4: Commit**
```bash
git add src/ScadaLink.Commons/Entities/Audit/AuditLogEntry.cs \
src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs
git commit -m "feat(transport): add BundleImportId column on AuditLogEntry"
```
---
## Task 3: EF migration `AddBundleImportIdToAuditLog`
**Classification:** trivial
**Estimated implement time:** ~2 min
**Parallelizable with:** none (depends on Task 2)
**Files:**
- Create: `src/ScadaLink.ConfigurationDatabase/Migrations/<timestamp>_AddBundleImportIdToAuditLog.cs` (generated)
- Create: `src/ScadaLink.ConfigurationDatabase/Migrations/<timestamp>_AddBundleImportIdToAuditLog.Designer.cs` (generated)
**Step 1: Generate**
Run:
```bash
dotnet ef migrations add AddBundleImportIdToAuditLog \
-p src/ScadaLink.ConfigurationDatabase \
-s src/ScadaLink.Host
```
Expected: two files generated under `Migrations/` with naming pattern `yyyyMMddHHmmss_AddBundleImportIdToAuditLog.cs`.
**Step 2: Sanity-check the migration**
Open the generated `.cs` file. It must:
- Call `AddColumn<Guid>(...)` for `BundleImportId` with `nullable: true`.
- Call `CreateIndex(...)` for `IX_AuditLogEntries_BundleImportId`.
If the generated migration includes anything else (drift from other entity changes), abort and surface it — that's a plan defect.
**Step 3: Build**
Run: `dotnet build src/ScadaLink.ConfigurationDatabase/ScadaLink.ConfigurationDatabase.csproj`
Expected: build succeeds.
**Step 4: Commit**
```bash
git add src/ScadaLink.ConfigurationDatabase/Migrations/
git commit -m "feat(transport): EF migration AddBundleImportIdToAuditLog"
```
---
## Task 4: `IAuditCorrelationContext` scoped service
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 0, Task 1, Task 2, Task 19
**Files:**
- Create: `src/ScadaLink.ConfigurationDatabase/Services/AuditCorrelationContext.cs`
- Modify: `src/ScadaLink.ConfigurationDatabase/ServiceCollectionExtensions.cs` (add scoped registration; if no file, create alongside existing convention used by audit service)
**Step 1: Write the implementation**
`AuditCorrelationContext.cs`:
```csharp
using ScadaLink.Commons.Interfaces.Transport;
namespace ScadaLink.ConfigurationDatabase.Services;
/// <summary>
/// Per-scope mutable holder for the active bundle import id. AuditService reads it
/// while writing AuditLogEntry rows. Registered as Scoped so each Blazor circuit /
/// request gets its own value; ApplyAsync explicitly creates a service scope and
/// sets the id at the top of the transaction.
/// </summary>
public sealed class AuditCorrelationContext : IAuditCorrelationContext
{
public Guid? BundleImportId { get; set; }
}
```
**Step 2: Register**
In `src/ScadaLink.ConfigurationDatabase/ServiceCollectionExtensions.cs` (the `AddConfigurationDatabase()` method), add:
```csharp
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
```
If a `ServiceCollectionExtensions.cs` doesn't exist in `ScadaLink.ConfigurationDatabase`, look at where `IAuditService` is currently registered (search the repo for `AddScoped<IAuditService`) and add the registration adjacent to it.
**Step 3: Build**
Run: `dotnet build src/ScadaLink.ConfigurationDatabase/ScadaLink.ConfigurationDatabase.csproj`
Expected: build succeeds.
**Step 4: Commit**
```bash
git add src/ScadaLink.ConfigurationDatabase/Services/AuditCorrelationContext.cs \
src/ScadaLink.ConfigurationDatabase/ServiceCollectionExtensions.cs
git commit -m "feat(transport): add IAuditCorrelationContext scoped service"
```
---
## Task 5: `AuditService` reads correlation context
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** none (depends on Tasks 2 + 4)
**Files:**
- Modify: `src/ScadaLink.ConfigurationDatabase/Services/AuditService.cs`
- Modify: `tests/ScadaLink.Commons.Tests/AuditServiceTests.cs` (if exists) OR create `tests/ScadaLink.ConfigurationDatabase.Tests/AuditServiceTests.cs`
**Step 1: Write the failing test first**
Add test that sets `IAuditCorrelationContext.BundleImportId`, calls `IAuditService.LogAsync`, asserts the emitted `AuditLogEntry.BundleImportId` matches. Test that with `BundleImportId = null`, the column is null.
**Step 2: Run to verify it fails**
Run: `dotnet test tests/ScadaLink.ConfigurationDatabase.Tests/`
Expected: FAIL (correlation not yet wired through).
**Step 3: Implement**
In `AuditService.cs`:
1. Inject `IAuditCorrelationContext` via constructor.
2. In `LogAsync`, set `entry.BundleImportId = _correlationContext.BundleImportId;` right after constructing `entry`.
**Step 4: Run tests**
Run: `dotnet test tests/ScadaLink.ConfigurationDatabase.Tests/`
Expected: PASS.
**Step 5: Commit**
```bash
git add src/ScadaLink.ConfigurationDatabase/Services/AuditService.cs \
tests/ScadaLink.ConfigurationDatabase.Tests/
git commit -m "feat(transport): AuditService stamps BundleImportId from correlation context"
```
---
## Task 6: `ScadaLink.Transport` project skeleton
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** none (subsequent tasks depend on the project existing)
**Files:**
- Create: `src/ScadaLink.Transport/ScadaLink.Transport.csproj`
- Create: `src/ScadaLink.Transport/ServiceCollectionExtensions.cs`
- Modify: `ScadaLink.slnx` (add `<Project Path="src/ScadaLink.Transport/ScadaLink.Transport.csproj" />` in alphabetical position)
- Create: `tests/ScadaLink.Transport.Tests/ScadaLink.Transport.Tests.csproj`
- Create: `tests/ScadaLink.Transport.IntegrationTests/ScadaLink.Transport.IntegrationTests.csproj`
- Modify: `ScadaLink.slnx` (add the two test projects)
**Step 1: Write the csproj**
`ScadaLink.Transport.csproj`:
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../ScadaLink.Commons/ScadaLink.Commons.csproj" />
<ProjectReference Include="../ScadaLink.ConfigurationDatabase/ScadaLink.ConfigurationDatabase.csproj" />
<ProjectReference Include="../ScadaLink.TemplateEngine/ScadaLink.TemplateEngine.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ScadaLink.Transport.Tests" />
<InternalsVisibleTo Include="ScadaLink.Transport.IntegrationTests" />
</ItemGroup>
</Project>
```
`ServiceCollectionExtensions.cs`:
```csharp
using Microsoft.Extensions.DependencyInjection;
namespace ScadaLink.Transport;
public static class ServiceCollectionExtensions
{
public const string OptionsSection = "ScadaLink:Transport";
public static IServiceCollection AddTransport(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.AddOptions<TransportOptions>().BindConfiguration(OptionsSection);
// Concrete services added in later tasks.
return services;
}
}
```
Test csprojs follow the existing test pattern (look at `tests/ScadaLink.NotificationOutbox.Tests/ScadaLink.NotificationOutbox.Tests.csproj` for shape — xUnit + NSubstitute + FluentAssertions + ProjectReference back to ScadaLink.Transport).
**Step 2: Add to slnx**
Insert in alphabetical order under `/src/` and `/tests/` folder sections.
**Step 3: Build**
Run: `dotnet build ScadaLink.slnx`
Expected: build succeeds (Transport empty + tests empty).
**Step 4: Commit**
```bash
git add src/ScadaLink.Transport/ tests/ScadaLink.Transport.Tests/ tests/ScadaLink.Transport.IntegrationTests/ ScadaLink.slnx
git commit -m "feat(transport): scaffold ScadaLink.Transport project + test projects"
```
---
## Task 7: `TransportOptions`
**Classification:** trivial
**Estimated implement time:** ~2 min
**Parallelizable with:** Task 8 (different file)
**Files:**
- Create: `src/ScadaLink.Transport/TransportOptions.cs`
**Step 1: Write the options class**
```csharp
namespace ScadaLink.Transport;
public sealed class TransportOptions
{
public int BundleSessionTtlMinutes { get; set; } = 30;
public int MaxBundleSizeMb { get; set; } = 100;
public int MaxUnlockAttemptsPerSession { get; set; } = 3;
public int MaxUnlockAttemptsPerIpPerHour { get; set; } = 10;
public int Pbkdf2Iterations { get; set; } = 600_000;
public int SchemaVersionMajor { get; set; } = 1;
}
```
**Step 2: Commit**
```bash
git add src/ScadaLink.Transport/TransportOptions.cs
git commit -m "feat(transport): add TransportOptions"
```
---
## Task 8: `BundleSecretEncryptor` (AES-256-GCM + PBKDF2)
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 7, Task 9, Task 10, Task 12, Task 13, Task 19
**Files:**
- Create: `src/ScadaLink.Transport/Encryption/BundleSecretEncryptor.cs`
- Create: `tests/ScadaLink.Transport.Tests/BundleSecretEncryptorTests.cs`
**Step 1: Write failing tests first**
Tests to write (all in `BundleSecretEncryptorTests.cs`):
1. `Encrypt_then_Decrypt_roundtrips_arbitrary_bytes`
2. `Decrypt_with_wrong_passphrase_throws_CryptographicException`
3. `Decrypt_with_tampered_ciphertext_throws_CryptographicException` (flip one byte in ciphertext)
4. `Encrypt_produces_distinct_ciphertext_for_same_input_due_to_random_iv`
5. `Encrypt_emits_metadata_matching_decryption_inputs` (salt, iv, iterations, algorithm)
**Step 2: Run tests to verify failure**
Run: `dotnet test tests/ScadaLink.Transport.Tests/ --filter FullyQualifiedName~BundleSecretEncryptorTests`
Expected: FAIL (class not yet defined).
**Step 3: Implement**
```csharp
using System.Security.Cryptography;
using ScadaLink.Commons.Types.Transport;
namespace ScadaLink.Transport.Encryption;
public sealed class BundleSecretEncryptor
{
private const int KeyBytes = 32; // AES-256
private const int SaltBytes = 16;
private const int NonceBytes = 12; // GCM standard
private const int TagBytes = 16;
public (byte[] Ciphertext, EncryptionMetadata Metadata) Encrypt(
ReadOnlySpan<byte> plaintext,
string passphrase,
int iterations)
{
var salt = RandomNumberGenerator.GetBytes(SaltBytes);
var nonce = RandomNumberGenerator.GetBytes(NonceBytes);
var key = DeriveKey(passphrase, salt, iterations);
var ciphertext = new byte[plaintext.Length];
var tag = new byte[TagBytes];
using var aes = new AesGcm(key, TagBytes);
aes.Encrypt(nonce, plaintext, ciphertext, tag);
// Format: ciphertext || tag
var output = new byte[ciphertext.Length + TagBytes];
Buffer.BlockCopy(ciphertext, 0, output, 0, ciphertext.Length);
Buffer.BlockCopy(tag, 0, output, ciphertext.Length, TagBytes);
return (output, new EncryptionMetadata(
"AES-256-GCM", "PBKDF2-SHA256", iterations,
Convert.ToBase64String(salt),
Convert.ToBase64String(nonce)));
}
public byte[] Decrypt(ReadOnlySpan<byte> payload, EncryptionMetadata metadata, string passphrase)
{
if (metadata.Algorithm != "AES-256-GCM" || metadata.Kdf != "PBKDF2-SHA256")
throw new CryptographicException("Unsupported bundle encryption parameters.");
var salt = Convert.FromBase64String(metadata.SaltB64);
var nonce = Convert.FromBase64String(metadata.IvB64);
var key = DeriveKey(passphrase, salt, metadata.Iterations);
if (payload.Length < TagBytes) throw new CryptographicException("Bundle payload too short.");
var ctLen = payload.Length - TagBytes;
var ciphertext = payload[..ctLen];
var tag = payload[ctLen..];
var plaintext = new byte[ctLen];
using var aes = new AesGcm(key, TagBytes);
aes.Decrypt(nonce, ciphertext, tag, plaintext);
return plaintext;
}
private static byte[] DeriveKey(string passphrase, byte[] salt, int iterations)
{
using var pbkdf2 = new Rfc2898DeriveBytes(passphrase, salt, iterations, HashAlgorithmName.SHA256);
return pbkdf2.GetBytes(KeyBytes);
}
}
```
**Step 4: Run tests**
Run: `dotnet test tests/ScadaLink.Transport.Tests/ --filter FullyQualifiedName~BundleSecretEncryptorTests`
Expected: PASS.
**Step 5: Commit**
```bash
git add src/ScadaLink.Transport/Encryption/ tests/ScadaLink.Transport.Tests/BundleSecretEncryptorTests.cs
git commit -m "feat(transport): AES-256-GCM + PBKDF2 BundleSecretEncryptor"
```
---
## Task 9: `ManifestBuilder` + schema validation
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 7, Task 8, Task 10, Task 12, Task 13, Task 19
**Files:**
- Create: `src/ScadaLink.Transport/Serialization/ManifestBuilder.cs`
- Create: `src/ScadaLink.Transport/Serialization/ManifestValidator.cs`
- Create: `tests/ScadaLink.Transport.Tests/ManifestBuilderTests.cs`
**Step 1: Write failing tests**
Tests:
1. `Build_populates_summary_from_contents`
2. `Build_serializes_to_valid_json`
3. `Validate_rejects_unsupported_bundleFormatVersion`
4. `Validate_rejects_when_contentHash_mismatch`
5. `Validate_accepts_well_formed_v1_manifest`
**Step 2-5:** Run-fail → implement → run-pass → commit.
`ManifestBuilder` accepts: `sourceEnvironment, exportedBy, scadaLinkVersion, encryption?, contents[], contentBytes` and returns a `BundleManifest` with `ContentHash = SHA-256(contentBytes)`.
`ManifestValidator.Validate(BundleManifest manifest, byte[] contentBytes)` returns a `ValidationResult` enum (`Ok | UnsupportedFormatVersion | ContentHashMismatch | MalformedManifest`).
**Commit:**
```bash
git commit -m "feat(transport): ManifestBuilder + ManifestValidator with schema-version gating"
```
---
## Task 10: `EntitySerializer` — DTOs + secret carving
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 7, Task 8, Task 9, Task 12, Task 13, Task 19
**Files:**
- Create: `src/ScadaLink.Transport/Serialization/EntityDtos.cs` (all bundle DTOs: `TemplateDto`, `TemplateFolderDto`, `SharedScriptDto`, `ExternalSystemDto`, `DatabaseConnectionDto`, `NotificationListDto`, `SmtpConfigDto`, `ApiKeyDto`, `ApiMethodDto`, each with a nested `SecretsBlock` where applicable)
- Create: `src/ScadaLink.Transport/Serialization/EntitySerializer.cs`
- Create: `tests/ScadaLink.Transport.Tests/EntitySerializerTests.cs`
**Step 1: Write failing tests**
Tests:
1. `ToDto_carves_external_system_credentials_into_secrets_block`
2. `ToDto_carves_smtp_password_into_secrets_block`
3. `ToDto_carves_api_key_hash_into_secrets_block`
4. `Roundtrip_template_preserves_attributes_alarms_scripts_composition`
5. `Roundtrip_template_folder_preserves_hierarchy`
**Step 3: Implement**
DTOs are flat records mirroring the Commons POCOs minus EF-only navigation properties. Secrets fields move into a nested `SecretsBlock` record on the DTO:
```csharp
public sealed record ExternalSystemDto(
string Name,
string BaseUrl,
string AuthType,
IReadOnlyList<ExternalSystemMethodDto> Methods,
SecretsBlock? Secrets);
public sealed record SecretsBlock(IReadOnlyDictionary<string, string> Values);
```
`EntitySerializer` exposes:
- `BundleContentDto ToBundleContent(EntityAggregate aggregate)` — converts in-memory aggregate to top-level DTO grouped by type.
- `EntityAggregate FromBundleContent(BundleContentDto content)` — inverse.
`BundleContentDto`:
```csharp
public sealed record BundleContentDto(
IReadOnlyList<TemplateFolderDto> TemplateFolders,
IReadOnlyList<TemplateDto> Templates,
IReadOnlyList<SharedScriptDto> SharedScripts,
IReadOnlyList<ExternalSystemDto> ExternalSystems,
IReadOnlyList<DatabaseConnectionDto> DatabaseConnections,
IReadOnlyList<NotificationListDto> NotificationLists,
IReadOnlyList<SmtpConfigDto> SmtpConfigs,
IReadOnlyList<ApiKeyDto> ApiKeys,
IReadOnlyList<ApiMethodDto> ApiMethods);
```
**Step 5: Commit**
```bash
git commit -m "feat(transport): bundle entity DTOs + secret carving in EntitySerializer"
```
---
## Task 11: `BundleSerializer` (ZIP packer + reader + content hash)
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** none (depends on Tasks 8, 9, 10)
**Files:**
- Create: `src/ScadaLink.Transport/Serialization/BundleSerializer.cs`
- Create: `tests/ScadaLink.Transport.Tests/BundleSerializerTests.cs`
**Step 1: Write failing tests**
Tests:
1. `Pack_emits_manifest_and_content_entries`
2. `Pack_encrypts_content_when_passphrase_supplied`
3. `Pack_leaves_content_plaintext_when_no_passphrase`
4. `Roundtrip_through_temp_stream_recovers_identical_content`
5. `Read_rejects_zip_without_manifest`
**Step 3: Implement**
`BundleSerializer` uses `System.IO.Compression.ZipArchive`:
- `Stream Pack(BundleContentDto content, ManifestContext ctx, string? passphrase)` — serializes `content` → JSON bytes → optional encrypt → writes `manifest.json` + (`content.json` OR `content.enc`) to a `MemoryStream`-backed ZIP.
- `(BundleManifest manifest, byte[] contentBytes) ReadHeader(Stream zipStream)` — opens, reads `manifest.json` only.
- `BundleContentDto Unpack(Stream zipStream, BundleManifest manifest, string? passphrase)` — full unpack + decrypt.
**Step 5: Commit**
```bash
git commit -m "feat(transport): BundleSerializer ZIP packer/reader"
```
---
## Task 12: `DependencyResolver`
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 7, Task 8, Task 9, Task 10, Task 13, Task 19
**Files:**
- Create: `src/ScadaLink.Transport/Export/DependencyResolver.cs`
- Create: `tests/ScadaLink.Transport.Tests/DependencyResolverTests.cs`
**Step 1: Write failing tests**
Tests:
1. `Resolve_includes_base_template_for_composed_template`
2. `Resolve_includes_shared_script_referenced_by_template`
3. `Resolve_includes_external_system_referenced_by_template`
4. `Resolve_includes_api_method_shared_script_dependency`
5. `Resolve_handles_diamond_dependency_without_duplication`
6. `Resolve_includes_template_folder_for_each_selected_template`
7. `Resolve_returns_topological_order_base_before_derived`
**Step 3: Implement**
`DependencyResolver` takes `ExportSelection` + repository readers, expands the selection through the edges in design §6.1, and returns a `ResolvedExport` aggregate:
```csharp
public sealed record ResolvedExport(
IReadOnlyList<TemplateFolder> TemplateFolders,
IReadOnlyList<Template> Templates,
IReadOnlyList<SharedScript> SharedScripts,
// ... one list per entity type
IReadOnlyList<ManifestContentEntry> ContentManifest); // for the manifest
```
Use Kahn's algorithm for topological ordering of templates (base before derived). Cycle detection — throw `InvalidOperationException` (templates are acyclic by Template Engine invariant, but defensive).
**Step 5: Commit**
```bash
git commit -m "feat(transport): DependencyResolver with topological closure"
```
---
## Task 13: `BundleSessionStore`
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 7, Task 8, Task 9, Task 10, Task 12, Task 19
**Files:**
- Create: `src/ScadaLink.Transport/Import/BundleSessionStore.cs`
- Create: `tests/ScadaLink.Transport.Tests/BundleSessionStoreTests.cs`
**Step 1: Write failing tests**
Tests:
1. `Open_then_Get_returns_session`
2. `Get_after_TTL_returns_null`
3. `Remove_evicts_session`
4. `EvictExpired_removes_all_past_ttl`
5. `Three_failed_unlock_attempts_locks_session` (Locked == true)
**Step 3: Implement**
Backed by `ConcurrentDictionary<Guid, BundleSession>`. Constructor takes `IOptions<TransportOptions>` and `TimeProvider` for testable expiry. Implements `IBundleSessionStore`.
**Step 5: Commit**
```bash
git commit -m "feat(transport): in-memory BundleSessionStore with TTL + lockout"
```
---
## Task 14: `BundleExporter.ExportAsync`
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (depends on Tasks 812)
**Files:**
- Create: `src/ScadaLink.Transport/Export/BundleExporter.cs`
- Create: `tests/ScadaLink.Transport.IntegrationTests/BundleExporterTests.cs` (integration — needs DB)
- Modify: `src/ScadaLink.Transport/ServiceCollectionExtensions.cs` (register `IBundleExporter -> BundleExporter`)
**Step 1: Write failing integration test**
Test against in-memory EF:
```csharp
[Fact]
public async Task ExportAsync_writes_audit_event_and_returns_valid_bundle()
```
Seeds two templates with composition + a shared script. Calls `ExportAsync` with selection={Template1Id}, includeDependencies=true. Asserts:
- Returned stream is non-empty and is a valid zip.
- A `BundleExported` audit row exists with the user, content hash, encrypted=false.
- Manifest summary lists 2 templates + 1 shared script.
**Step 3: Implement**
`BundleExporter.ExportAsync`:
1. Resolve dependencies via `DependencyResolver`.
2. Convert to `BundleContentDto` via `EntitySerializer`.
3. Serialize content to JSON bytes.
4. Optionally encrypt with `BundleSecretEncryptor`.
5. Build manifest via `ManifestBuilder`.
6. Pack via `BundleSerializer`.
7. Emit audit row: `IAuditService.LogAsync(user, "BundleExported", "Bundle", manifest.ContentHash, manifest.SourceEnvironment, manifest)`.
8. `await _context.SaveChangesAsync(ct)`.
9. Return stream.
Register in `ServiceCollectionExtensions.AddTransport`:
```csharp
services.AddScoped<IBundleExporter, BundleExporter>();
services.AddSingleton<BundleSecretEncryptor>();
services.AddSingleton<ManifestBuilder>();
services.AddSingleton<ManifestValidator>();
services.AddSingleton<BundleSerializer>();
services.AddSingleton<EntitySerializer>();
services.AddScoped<DependencyResolver>();
```
**Step 5: Commit**
```bash
git commit -m "feat(transport): BundleExporter with audit logging"
```
---
## Task 15: `BundleImporter.LoadAsync`
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** none (depends on Tasks 11, 13)
**Files:**
- Create: `src/ScadaLink.Transport/Import/BundleImporter.cs`
- Create: `tests/ScadaLink.Transport.Tests/BundleImporterLoadTests.cs`
- Modify: `src/ScadaLink.Transport/ServiceCollectionExtensions.cs`
**Step 1: Write failing tests**
Tests:
1. `LoadAsync_returns_session_for_well_formed_bundle`
2. `LoadAsync_rejects_unsupported_bundleFormatVersion_with_clear_message`
3. `LoadAsync_rejects_content_hash_mismatch`
4. `LoadAsync_rejects_missing_manifest`
5. `LoadAsync_decrypts_when_passphrase_correct`
6. `LoadAsync_throws_CryptographicException_on_wrong_passphrase` (caller increments lockout counter)
**Step 3: Implement**
Logic:
1. Open zip.
2. `BundleSerializer.ReadHeader` → manifest + content bytes.
3. `ManifestValidator.Validate` → reject on mismatch.
4. If manifest has encryption metadata: passphrase required; decrypt content bytes.
5. Deserialize content to `BundleContentDto`.
6. Create `BundleSession { SessionId = Guid.NewGuid(), Manifest, DecryptedContent, ExpiresAt = now + TTL }``_sessionStore.Open(session)`.
Register in DI:
```csharp
services.AddScoped<IBundleImporter, BundleImporter>();
services.AddSingleton<IBundleSessionStore, BundleSessionStore>();
```
**Step 5: Commit**
```bash
git commit -m "feat(transport): BundleImporter.LoadAsync with manifest validation"
```
---
## Task 16: `BundleImporter.PreviewAsync` (diff engine)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (depends on Task 15)
**Files:**
- Modify: `src/ScadaLink.Transport/Import/BundleImporter.cs`
- Create: `src/ScadaLink.Transport/Import/ArtifactDiff.cs`
- Create: `tests/ScadaLink.Transport.IntegrationTests/BundleImporterPreviewTests.cs`
**Step 1: Write failing integration tests**
Tests against in-memory EF:
1. `PreviewAsync_classifies_artifact_as_Identical_when_fields_match`
2. `PreviewAsync_classifies_artifact_as_Modified_with_field_diff`
3. `PreviewAsync_classifies_artifact_as_New_when_absent_from_target`
4. `PreviewAsync_emits_Blocker_when_required_dependency_is_missing_in_bundle_and_target`
**Step 3: Implement**
`ArtifactDiff.Compare(string entityType, object incoming, object? existing) → (ConflictKind, string? fieldDiffJson)`. Uses reflection over the DTO's properties; emits a per-field `+/-/~` summary in JSON format. For script bodies, emits a line-based diff (use a simple Myers-style diff implemented in-project; ~80 LOC). No third-party diff library.
`PreviewAsync` walks each top-level DTO list in `session.DecryptedContent`, looks up existing by name via repositories, calls `ArtifactDiff.Compare`, builds `ImportPreviewItem`s. Blocker items appear when a referenced shared script / external system is neither in the bundle nor pre-existing in the target.
**Step 5: Commit**
```bash
git commit -m "feat(transport): BundleImporter.PreviewAsync diff engine"
```
---
## Task 17: `BundleImporter.ApplyAsync` (transaction + validation + audit)
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none (depends on Task 16)
**Files:**
- Modify: `src/ScadaLink.Transport/Import/BundleImporter.cs`
- Create: `tests/ScadaLink.Transport.IntegrationTests/BundleImporterApplyTests.cs`
**Step 1: Write failing integration tests**
Tests:
1. `ApplyAsync_adds_new_artifacts_in_single_transaction`
2. `ApplyAsync_overwrites_artifact_when_resolution_is_Overwrite`
3. `ApplyAsync_skips_artifact_when_resolution_is_Skip`
4. `ApplyAsync_renames_artifact_when_resolution_is_Rename`
5. `ApplyAsync_rolls_back_all_changes_when_semantic_validation_fails`
6. `ApplyAsync_writes_BundleImportId_on_every_emitted_audit_row` (the correlation guarantee)
7. `ApplyAsync_writes_BundleImported_summary_row_inside_transaction`
8. `ApplyAsync_writes_BundleImportFailed_outside_rolled_back_transaction`
**Step 3: Implement**
Flow:
```csharp
public async Task<ImportResult> ApplyAsync(Guid sessionId, IReadOnlyList<ImportResolution> resolutions, string user, CancellationToken ct)
{
var session = _sessionStore.Get(sessionId) ?? throw new InvalidOperationException("Session expired");
var bundleImportId = Guid.NewGuid();
_correlation.BundleImportId = bundleImportId;
var content = DeserializeContent(session.DecryptedContent);
var resolutionMap = resolutions.ToDictionary(r => (r.EntityType, r.Name));
await using var tx = await _context.Database.BeginTransactionAsync(ct);
try
{
// Apply in dependency order: folders → templates (base→derived) → shared scripts
// → external systems → db conn → notification lists
// → smtp configs → api keys → api methods
var summary = await ApplyAllAsync(content, resolutionMap, user, ct);
// Pre-deployment semantic validator — fail fast
var validation = _semanticValidator.Validate(BuildFlattenedConfig(content));
if (!validation.IsValid)
throw new SemanticValidationException(validation.Errors);
// BundleImported summary row inside the transaction
await _auditService.LogAsync(user, "BundleImported", "Bundle",
bundleImportId.ToString(), session.Manifest.SourceEnvironment,
new { session.Manifest.SourceEnvironment, session.Manifest.ContentHash, Summary = summary }, ct);
await _context.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
_sessionStore.Remove(sessionId);
return new ImportResult(bundleImportId, summary.Added, summary.Overwritten, summary.Skipped, summary.Renamed, StaleInstanceIds: Array.Empty<int>(), AuditEventCorrelation: bundleImportId.ToString());
}
catch (Exception ex)
{
await tx.RollbackAsync(ct);
// BundleImportFailed row OUTSIDE the rolled-back tx
_correlation.BundleImportId = null;
await _auditService.LogAsync(user, "BundleImportFailed", "Bundle",
bundleImportId.ToString(), session.Manifest.SourceEnvironment,
new { Reason = ex.Message }, ct);
await _context.SaveChangesAsync(ct);
throw;
}
finally
{
_correlation.BundleImportId = null;
}
}
```
Note: `StaleInstanceIds: Array.Empty<int>()` because the brainstorming survey showed there's no persisted stale flag — `DeploymentService.CompareAsync` detects staleness live by comparing revision hashes, so overwriting a template surfaces stale instances automatically on the Deployments page.
**Step 5: Commit**
```bash
git commit -m "feat(transport): BundleImporter.ApplyAsync transactional with audit correlation"
```
---
## Task 18: Host registration + transport options binding
**Classification:** trivial
**Estimated implement time:** ~2 min
**Parallelizable with:** Task 19, Task 27, Task 28
**Files:**
- Modify: `src/ScadaLink.Host/Program.cs` (add `builder.Services.AddTransport();` in the "Central-only components" section, alongside `AddNotificationOutbox()`)
- Modify: `src/ScadaLink.Host/appsettings.json` (add `ScadaLink:Transport` section with defaults that match `TransportOptions`)
- Modify: `src/ScadaLink.Host/ScadaLink.Host.csproj` (add `ProjectReference` to ScadaLink.Transport)
**Step 1: Modify Program.cs**
Find the existing `if (centralRolesActive) { ... builder.Services.AddNotificationOutbox(); ... }` block (or equivalent — search for `AddNotificationOutbox`) and add `builder.Services.AddTransport();` directly after.
**Step 2: appsettings.json**
```json
"ScadaLink": {
"Transport": {
"BundleSessionTtlMinutes": 30,
"MaxBundleSizeMb": 100,
"MaxUnlockAttemptsPerSession": 3,
"MaxUnlockAttemptsPerIpPerHour": 10,
"Pbkdf2Iterations": 600000,
"SchemaVersionMajor": 1
}
}
```
**Step 3: Build**
Run: `dotnet build ScadaLink.slnx`
Expected: build succeeds.
**Step 4: Commit**
```bash
git add src/ScadaLink.Host/
git commit -m "feat(transport): register AddTransport() on central nodes"
```
---
## Task 19: `TreeView` checkbox-selection mode + tri-state propagation
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 0, Task 1, Task 2, Task 4, Task 7, Task 8, Task 9, Task 10, Task 12, Task 13
**Files:**
- Modify: `src/ScadaLink.CentralUI/Components/Shared/TreeView.razor`
- Create: `tests/ScadaLink.CentralUI.Tests/TreeViewMultiSelectTests.cs`
**Step 1: Extend the component API**
Add parameters:
```razor
[Parameter] public TreeViewSelectionMode SelectionMode { get; set; } = TreeViewSelectionMode.Single;
[Parameter] public HashSet<object>? SelectedKeys { get; set; }
[Parameter] public EventCallback<HashSet<object>> SelectedKeysChanged { get; set; }
```
`TreeViewSelectionMode { Single, Checkbox }`.
In `Checkbox` mode, render a `<input type="checkbox">` left of the node content. State is:
- `Checked` — node and all descendant leaves are in `SelectedKeys`.
- `Unchecked` — none are.
- `Indeterminate` — some are. Set via `ref` + `element.indeterminate = true` in JS interop (use the existing JS bundle helper if there is one; otherwise a tiny `treeview.js` file is acceptable).
Toggle semantics:
- Clicking a folder toggles all descendants on/off.
- Clicking a leaf toggles just itself; parent indeterminate state recomputed.
**Step 2: Write failing tests**
Use bUnit (already in `tests/ScadaLink.CentralUI.Tests/`):
1. `Checkbox_mode_renders_checkbox_per_node`
2. `Clicking_folder_selects_all_descendants`
3. `Clicking_leaf_makes_parent_indeterminate_when_sibling_unchecked`
4. `Single_mode_still_works_unchanged` (regression)
**Step 4: Run tests**
Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ --filter FullyQualifiedName~TreeViewMultiSelect`
Expected: PASS.
**Step 5: Commit**
```bash
git add src/ScadaLink.CentralUI/Components/Shared/TreeView.razor \
tests/ScadaLink.CentralUI.Tests/TreeViewMultiSelectTests.cs
git commit -m "feat(centralui): TreeView checkbox-selection mode with tri-state"
```
---
## Task 20: `TemplateFolderTree.razor` wrapper + Templates.razor refactor
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** none (depends on Task 19)
**Files:**
- Create: `src/ScadaLink.CentralUI/Components/Shared/TemplateFolderTree.razor`
- Modify: `src/ScadaLink.CentralUI/Components/Pages/Design/Templates.razor` (use the new component in `Single` mode)
**Step 1: Build the wrapper**
`TemplateFolderTree.razor` wraps `TreeView<TemplateTreeNode>` with template-folder-specific configuration:
- `[Parameter] IReadOnlyList<TemplateFolder> Folders { get; set; }`
- `[Parameter] IReadOnlyList<Template> Templates { get; set; }`
- `[Parameter] TreeViewSelectionMode SelectionMode { get; set; } = TreeViewSelectionMode.Single`
- `[Parameter] HashSet<object>? SelectedKeys { get; set; }`
- `[Parameter] EventCallback<HashSet<object>> SelectedKeysChanged { get; set; }`
- `[Parameter] string Filter { get; set; } = ""` — applies search filter in-place
- `[Parameter] RenderFragment<TemplateTreeNode>? NodeExtras { get; set; }` — optional content rendered next to node label
Internally builds a `TemplateTreeNode { Kind: Folder|Template, Id, Name, Children }` adapter tree from the input lists.
**Step 2: Refactor Templates.razor**
Replace the existing inline tree usage with `<TemplateFolderTree Folders="..." Templates="..." SelectionMode="TreeViewSelectionMode.Single" SelectedKey="@_selectedKey" ... />`. Keep all current behavior (navigation, search). The replacement should be ≤30 LOC change in Templates.razor.
**Step 3: Manual smoke (no new tests needed — existing Templates.razor tests cover this)**
Run: `dotnet build src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj`
Then: `dotnet test tests/ScadaLink.CentralUI.Tests/`
Expected: all existing Templates page tests still pass.
**Step 4: Commit**
```bash
git add src/ScadaLink.CentralUI/Components/Shared/TemplateFolderTree.razor \
src/ScadaLink.CentralUI/Components/Pages/Design/Templates.razor
git commit -m "refactor(centralui): extract TemplateFolderTree as shared component"
```
---
## Task 21: `TransportExport.razor` wizard
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 22 (separate page; only NavMenu touches both)
**Files:**
- Create: `src/ScadaLink.CentralUI/Components/Pages/Design/TransportExport.razor`
- Create: `src/ScadaLink.CentralUI/Components/Pages/Design/TransportExport.razor.cs` (code-behind for the wizard state machine)
- Create: `tests/ScadaLink.CentralUI.Tests/TransportExportPageTests.cs`
**Step 1: Page skeleton**
```razor
@page "/design/transport/export"
@using ScadaLink.Security
@using ScadaLink.Commons.Types.Transport
@using ScadaLink.Commons.Interfaces.Transport
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject IBundleExporter BundleExporter
@inject ITemplateEngineRepository TemplateRepo
@inject IExternalSystemRepository ExternalRepo
@inject INotificationRepository NotificationRepo
@inject IInboundApiRepository InboundApiRepo
@inject IJSRuntime JS
@inject AuthenticationStateProvider Auth
@inject IOptions<TransportOptions> Options
```
Four-step wizard rendered with a step indicator at top and a single content area beneath. State machine in code-behind (`Step1Select`, `Step2Review`, `Step3Encrypt`, `Step4Download`).
Step 1 hosts the `<TemplateFolderTree SelectionMode="TreeViewSelectionMode.Checkbox" ... />` plus flat checkbox lists for shared scripts / external systems / notification lists / SMTP configs / API keys / API methods.
Step 2 calls `DependencyResolver` server-side and shows the expanded selection (user-selected + auto-included).
Step 3 collects passphrase + confirm (or explicit "Export without encryption" path with warning banner).
Step 4 calls `IBundleExporter.ExportAsync`, streams the result to the browser via `IJSRuntime.InvokeVoidAsync("scadalinkTransport.downloadBundle", filename, bytes)`. Display SHA-256 + size.
**Step 2: Page tests with bUnit**
1. `Renders_step1_with_template_tree`
2. `Step2_shows_resolved_dependencies`
3. `Step4_triggers_ExportAsync_on_user_with_Design_role`
4. `Page_returns_403_for_user_without_Design_role`
**Step 3: Run tests**
Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ --filter FullyQualifiedName~TransportExportPage`
Expected: PASS.
**Step 4: Commit**
```bash
git add src/ScadaLink.CentralUI/Components/Pages/Design/TransportExport.razor \
src/ScadaLink.CentralUI/Components/Pages/Design/TransportExport.razor.cs \
tests/ScadaLink.CentralUI.Tests/TransportExportPageTests.cs
git commit -m "feat(centralui): TransportExport wizard under Design nav group"
```
---
## Task 22: `TransportImport.razor` wizard
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 21
**Files:**
- Create: `src/ScadaLink.CentralUI/Components/Pages/Design/TransportImport.razor`
- Create: `src/ScadaLink.CentralUI/Components/Pages/Design/TransportImport.razor.cs`
- Create: `tests/ScadaLink.CentralUI.Tests/TransportImportPageTests.cs`
**Step 1: Page skeleton**
```razor
@page "/design/transport/import"
@using ScadaLink.Security
@using ScadaLink.Commons.Types.Transport
@using ScadaLink.Commons.Interfaces.Transport
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@inject IBundleImporter BundleImporter
@inject NavigationManager Nav
@inject AuthenticationStateProvider Auth
```
Five-step wizard:
1. **Upload**`InputFile` component (size-limited to `Options.MaxBundleSizeMb`). On select, call `LoadAsync(stream, passphrase: null)` to read the manifest only.
2. **Passphrase** — render iff `manifest.Encryption != null`. Submit calls `LoadAsync` again with the passphrase. On wrong passphrase, increment a session-local counter; after 3 attempts, evict the session and force re-upload.
3. **Diff & resolve** — render `ImportPreview` rows with per-row radio buttons (Skip/Overwrite/Rename). Show field-level diff via `<details>` disclosure. Bulk "Apply to all" header.
4. **Confirm** — text input requires user to retype `manifest.SourceEnvironment` exactly.
5. **Result** — show counts + link to Deployments page (`<a href="/deployment/deployments">View on Deployments →</a>`) + link to filtered audit log (`<a href="/audit/configuration?bundleImportId={result.BundleImportId}">Audit trail →</a>`).
**Step 2: Page tests**
1. `Renders_step1_upload_input`
2. `Decryption_failure_increments_attempt_counter`
3. `Three_failed_unlocks_force_reupload`
4. `Confirm_step_requires_exact_environment_name_match`
5. `Apply_step_invokes_BundleImporter_ApplyAsync_with_resolutions`
6. `Page_returns_403_for_user_without_Admin_role`
**Step 3: Run + commit**
```bash
git add src/ScadaLink.CentralUI/Components/Pages/Design/TransportImport.razor \
src/ScadaLink.CentralUI/Components/Pages/Design/TransportImport.razor.cs \
tests/ScadaLink.CentralUI.Tests/TransportImportPageTests.cs
git commit -m "feat(centralui): TransportImport wizard under Design nav group"
```
---
## Task 23: NavMenu entries + UI auth attributes verified
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** none (depends on Tasks 21, 22)
**Files:**
- Modify: `src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor`
**Step 1: Add nav entries**
Inside the existing Design `<NavSection>`, after the Templates entry:
```razor
<li class="nav-item">
<NavLink class="nav-link" href="/design/transport/export">Export Bundle</NavLink>
</li>
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
<Authorized>
<li class="nav-item">
<NavLink class="nav-link" href="/design/transport/import">Import Bundle</NavLink>
</li>
</Authorized>
</AuthorizeView>
```
(Export shows for anyone with Design; Import only renders when the user also has Admin.)
**Step 2: Build + smoke-test**
Run: `dotnet build src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj`
Expected: build succeeds.
**Step 3: Commit**
```bash
git add src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor
git commit -m "feat(centralui): add Export/Import Bundle nav entries"
```
---
## Task 24: Audit log "Bundle Import" filter on existing page
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 22, Task 23
**Files:**
- Modify: `src/ScadaLink.CentralUI/Components/Pages/Audit/ConfigurationAuditLog.razor` (locate by `git grep -l ConfigurationAuditLog src/ScadaLink.CentralUI/`)
- Modify: `src/ScadaLink.ConfigurationDatabase/Repositories/` (the repository that drives the audit page — extend the query with an optional `Guid? bundleImportId` filter)
**Step 1: Extend the repository query**
Find the audit log query method (likely `IAuditLogRepository.QueryAsync(...)` or similar). Add an optional `Guid? bundleImportId = null` parameter and:
```csharp
if (bundleImportId.HasValue)
query = query.Where(x => x.BundleImportId == bundleImportId.Value);
```
**Step 2: Wire the page filter**
In `ConfigurationAuditLog.razor`:
- Accept `[Parameter, SupplyParameterFromQuery] public Guid? BundleImportId { get; set; }`.
- Display the active filter as a removable badge ("Filtered by Bundle Import: {id}").
- Pass `BundleImportId` through to the repository call.
This is what the `TransportImport.razor` Step 5 link (`/audit/configuration?bundleImportId=…`) consumes.
**Step 3: Commit**
```bash
git commit -m "feat(centralui): Bundle Import filter on ConfigurationAuditLog page"
```
---
## Task 25: Integration: export → wipe → import round-trip
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 26, Task 27, Task 28
**Files:**
- Create: `tests/ScadaLink.Transport.IntegrationTests/RoundTripTests.cs`
**Step 1: Write the test**
```csharp
[Fact]
public async Task Export_then_wipe_then_import_restores_state()
```
1. Seed in-memory DB with 3 templates (one composed), 1 shared script, 1 external system, 1 notification list.
2. Call `IBundleExporter.ExportAsync` selecting all of them.
3. Read the returned stream into bytes.
4. Wipe DB: delete all those entities.
5. Call `IBundleImporter.LoadAsync(new MemoryStream(bytes), passphrase)`.
6. `PreviewAsync` → all `ConflictKind.New`.
7. Apply with all `Add` resolutions.
8. Re-query DB. Assert every entity is back, field-equivalent to the seed.
9. Assert one `BundleExported` + one `BundleImported` audit row exists, both linked by content hash.
10. Assert every per-entity audit row written during apply has `BundleImportId == result.BundleImportId`.
**Step 2: Run + commit**
```bash
git add tests/ScadaLink.Transport.IntegrationTests/RoundTripTests.cs
git commit -m "test(transport): integration round-trip export → wipe → import"
```
---
## Task 26: Integration: conflict resolutions + rollback on validation failure
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 25, Task 27, Task 28
**Files:**
- Create: `tests/ScadaLink.Transport.IntegrationTests/ConflictResolutionTests.cs`
- Create: `tests/ScadaLink.Transport.IntegrationTests/ValidationFailureTests.cs`
**Step 1: Write the tests**
`ConflictResolutionTests`:
1. `Overwrite_replaces_existing_template_fields`
2. `Skip_leaves_existing_template_unchanged`
3. `Rename_creates_new_template_alongside_existing`
`ValidationFailureTests`:
1. `Semantic_validation_failure_rolls_back_all_writes` — craft a bundle with a template whose script references a missing call target. Apply must throw, transaction must roll back, no rows from the bundle persist, AND a `BundleImportFailed` row exists outside the rolled-back transaction.
**Step 3: Commit**
```bash
git add tests/ScadaLink.Transport.IntegrationTests/
git commit -m "test(transport): integration conflict resolution + rollback"
```
---
## Task 27: `Component-Transport.md` design doc
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 25, Task 26, Task 28
**Files:**
- Create: `docs/requirements/Component-Transport.md`
**Step 1: Write the doc**
Follow the standard structure used by `docs/requirements/Component-NotificationOutbox.md`:
- **Purpose** — one-paragraph summary.
- **Location** — paths.
- **Responsibilities** — bullet list.
- **Bundle format** — copy §4 from `2026-05-24-transport-design.md` plus JSON schema appendix.
- **Architecture** — copy §5.
- **Export flow / Import flow** — copy §6 + §7.
- **Error handling** — copy §8.
- **Security** — copy §9.
- **Configuration audit trail** — copy §10.
- **Authorization** — copy §12.
- **Dependencies** — Commons, ConfigurationDatabase, TemplateEngine.
- **Interactions** — Central UI (pages), Deployment Manager (stale-detection via existing CompareAsync), Security & Auth (RequireDesign/RequireAdmin).
**Step 2: Commit**
```bash
git add docs/requirements/Component-Transport.md
git commit -m "docs: Component-Transport.md (component #24)"
```
---
## Task 28: README + cross-reference updates
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 25, Task 26, Task 27
**Files:**
- Modify: `README.md` (component table — add row #24)
- Modify: `docs/requirements/Component-TemplateEngine.md` (Interactions section — mention Transport as consumer)
- Modify: `docs/requirements/Component-DeploymentManager.md` (Interactions section — note that Transport-driven template overwrites surface naturally on existing CompareAsync)
- Modify: `docs/requirements/Component-SecurityAuth.md` (record the new `RequireDesign` export + `RequireAdmin` import mapping)
- Modify: `docs/requirements/Component-ConfigurationDatabase.md` (record the new `BundleImportId` column + index)
- Modify: `docs/requirements/Component-CentralUI.md` (record the two new pages under Design)
- Modify: `CLAUDE.md` (component list — add #24 Transport)
**Step 1: README table row**
After the row for component #23:
```markdown
| 24 | Transport | Bundle export/import for templates, shared scripts, external systems, central-only artifacts. AES-256-GCM encryption; per-conflict resolution on import; correlated audit trail. |
```
**Step 2: CLAUDE.md**
Add to the Current Component List:
```markdown
24. Transport — File-based, encrypted bundle export/import via Central UI; templates, system artifacts, central-only configuration; per-conflict resolution; correlated audit via `BundleImportId`.
```
**Step 3: Cross-reference updates** — single sentence per doc as described in the design doc §16.
**Step 4: Commit**
```bash
git add README.md CLAUDE.md docs/requirements/
git commit -m "docs: README + component cross-references for Transport (#24)"
```
---
## Task 29: Manual cluster verification checklist
**Classification:** trivial
**Estimated implement time:** ~3 min
**Parallelizable with:** none (final verification gate)
**Files:**
- Create: `docs/plans/2026-05-24-transport-manual-verification.md`
**Step 1: Write the checklist**
The verification doc lists steps to be done manually against the docker cluster (no automation in this task — the checklist is the deliverable):
```markdown
# Transport Manual Verification
## Prerequisites
- `bash docker/deploy.sh` succeeded (image rebuilt with Transport).
- `cd infra && docker compose up -d` (LDAP, SQL ready).
## Steps
1. Log in to http://localhost:9000 as `multi-role` / `password`.
2. Design → Export Bundle.
3. Select 1 template + 1 shared script. Verify dependency expansion in Step 2.
4. Set passphrase "test123". Export. Verify .scadabundle file downloads.
5. Log out, log in as `admin` / `password`.
6. Design → Import Bundle. Upload the bundle.
7. Enter passphrase "test123". Verify diff page shows the artifacts.
8. Apply with Add for all. Confirm by retyping source env.
9. Verify Step 5 result page shows counts + links.
10. Open Audit → Configuration Audit Log → filter by the BundleImported row's id.
Verify all per-entity rows from the import are listed.
11. Open Deployments page → verify any instance that referenced the overwritten
template appears stale (revision-hash mismatch).
12. Verify wrong passphrase fails cleanly (3 attempts → re-upload required).
```
**Step 2: Commit**
```bash
git add docs/plans/2026-05-24-transport-manual-verification.md
git commit -m "docs(transport): manual cluster verification checklist"
```
---
## Acceptance Gate (run before declaring done)
After Task 29:
```bash
dotnet build ScadaLink.slnx
dotnet test tests/ScadaLink.Transport.Tests/
dotnet test tests/ScadaLink.Transport.IntegrationTests/
dotnet test tests/ScadaLink.CentralUI.Tests/
dotnet test tests/ScadaLink.ConfigurationDatabase.Tests/
```
All must pass. Then proceed to Task 29 manual checklist against the docker cluster.
---
## Out-of-Scope Reminders (do NOT do)
- Do NOT add a feature flag.
- Do NOT touch site-side projects (`ScadaLink.SiteRuntime` etc.).
- Do NOT add a stale-mark column on `Instance` — the existing `DeploymentService.CompareAsync` already detects this.
- Do NOT build CLI commands — those are deferred per design doc §13.
- Do NOT add bundle signing — content hash in manifest is sufficient for v1.
- Do NOT retain bundles server-side after export download or import apply.