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.
60 KiB
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 scopedIAuditCorrelationContextrather than changing every repository signature.- No persisted
DeployedRevisionHashon Instance.DeploymentService.CompareAsynccomputes stale-vs-fresh live by comparingsnapshot.RevisionHashto 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.razorexists atsrc/ScadaLink.CentralUI/Components/Shared/TreeView.razorwith single-select navigation. We extend it with a checkbox / multi-select mode rather than building a parallel widget.- DI extension convention:
AddTransport()inServiceCollectionExtensions.cs, registered insrc/ScadaLink.Host/Program.csunder "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:
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:
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:
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:
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
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:
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:
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:
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:
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:
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:
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:
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:
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:
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
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 nullableBundleImportId) - Modify:
src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs(add Fluent config inOnModelCreatingnear the existingAuditLogEntrymapping; addIX_AuditLogEntries_BundleImportIdnon-clustered index)
Step 1: Add property
In AuditLogEntry.cs, after Timestamp:
public Guid? BundleImportId { get; set; }
Step 2: Add Fluent config + index
In ScadaLinkDbContext.OnModelCreating, find the existing modelBuilder.Entity<AuditLogEntry>(...) block and add:
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
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:
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>(...)forBundleImportIdwithnullable: true. - Call
CreateIndex(...)forIX_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
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:
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:
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
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 createtests/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:
- Inject
IAuditCorrelationContextvia constructor. - In
LogAsync, setentry.BundleImportId = _correlationContext.BundleImportId;right after constructingentry.
Step 4: Run tests
Run: dotnet test tests/ScadaLink.ConfigurationDatabase.Tests/
Expected: PASS.
Step 5: Commit
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:
<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:
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
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
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
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):
Encrypt_then_Decrypt_roundtrips_arbitrary_bytesDecrypt_with_wrong_passphrase_throws_CryptographicExceptionDecrypt_with_tampered_ciphertext_throws_CryptographicException(flip one byte in ciphertext)Encrypt_produces_distinct_ciphertext_for_same_input_due_to_random_ivEncrypt_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
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
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:
Build_populates_summary_from_contentsBuild_serializes_to_valid_jsonValidate_rejects_unsupported_bundleFormatVersionValidate_rejects_when_contentHash_mismatchValidate_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:
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 nestedSecretsBlockwhere applicable) - Create:
src/ScadaLink.Transport/Serialization/EntitySerializer.cs - Create:
tests/ScadaLink.Transport.Tests/EntitySerializerTests.cs
Step 1: Write failing tests
Tests:
ToDto_carves_external_system_credentials_into_secrets_blockToDto_carves_smtp_password_into_secrets_blockToDto_carves_api_key_hash_into_secrets_blockRoundtrip_template_preserves_attributes_alarms_scripts_compositionRoundtrip_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:
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:
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
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:
Pack_emits_manifest_and_content_entriesPack_encrypts_content_when_passphrase_suppliedPack_leaves_content_plaintext_when_no_passphraseRoundtrip_through_temp_stream_recovers_identical_contentRead_rejects_zip_without_manifest
Step 3: Implement
BundleSerializer uses System.IO.Compression.ZipArchive:
Stream Pack(BundleContentDto content, ManifestContext ctx, string? passphrase)— serializescontent→ JSON bytes → optional encrypt → writesmanifest.json+ (content.jsonORcontent.enc) to aMemoryStream-backed ZIP.(BundleManifest manifest, byte[] contentBytes) ReadHeader(Stream zipStream)— opens, readsmanifest.jsononly.BundleContentDto Unpack(Stream zipStream, BundleManifest manifest, string? passphrase)— full unpack + decrypt.
Step 5: Commit
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:
Resolve_includes_base_template_for_composed_templateResolve_includes_shared_script_referenced_by_templateResolve_includes_external_system_referenced_by_templateResolve_includes_api_method_shared_script_dependencyResolve_handles_diamond_dependency_without_duplicationResolve_includes_template_folder_for_each_selected_templateResolve_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:
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
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:
Open_then_Get_returns_sessionGet_after_TTL_returns_nullRemove_evicts_sessionEvictExpired_removes_all_past_ttlThree_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
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 8–12)
Files:
- Create:
src/ScadaLink.Transport/Export/BundleExporter.cs - Create:
tests/ScadaLink.Transport.IntegrationTests/BundleExporterTests.cs(integration — needs DB) - Modify:
src/ScadaLink.Transport/ServiceCollectionExtensions.cs(registerIBundleExporter -> BundleExporter)
Step 1: Write failing integration test
Test against in-memory EF:
[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
BundleExportedaudit row exists with the user, content hash, encrypted=false. - Manifest summary lists 2 templates + 1 shared script.
Step 3: Implement
BundleExporter.ExportAsync:
- Resolve dependencies via
DependencyResolver. - Convert to
BundleContentDtoviaEntitySerializer. - Serialize content to JSON bytes.
- Optionally encrypt with
BundleSecretEncryptor. - Build manifest via
ManifestBuilder. - Pack via
BundleSerializer. - Emit audit row:
IAuditService.LogAsync(user, "BundleExported", "Bundle", manifest.ContentHash, manifest.SourceEnvironment, manifest). await _context.SaveChangesAsync(ct).- Return stream.
Register in ServiceCollectionExtensions.AddTransport:
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
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:
LoadAsync_returns_session_for_well_formed_bundleLoadAsync_rejects_unsupported_bundleFormatVersion_with_clear_messageLoadAsync_rejects_content_hash_mismatchLoadAsync_rejects_missing_manifestLoadAsync_decrypts_when_passphrase_correctLoadAsync_throws_CryptographicException_on_wrong_passphrase(caller increments lockout counter)
Step 3: Implement
Logic:
- Open zip.
BundleSerializer.ReadHeader→ manifest + content bytes.ManifestValidator.Validate→ reject on mismatch.- If manifest has encryption metadata: passphrase required; decrypt content bytes.
- Deserialize content to
BundleContentDto. - Create
BundleSession { SessionId = Guid.NewGuid(), Manifest, DecryptedContent, ExpiresAt = now + TTL }→_sessionStore.Open(session).
Register in DI:
services.AddScoped<IBundleImporter, BundleImporter>();
services.AddSingleton<IBundleSessionStore, BundleSessionStore>();
Step 5: Commit
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:
PreviewAsync_classifies_artifact_as_Identical_when_fields_matchPreviewAsync_classifies_artifact_as_Modified_with_field_diffPreviewAsync_classifies_artifact_as_New_when_absent_from_targetPreviewAsync_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 ImportPreviewItems. Blocker items appear when a referenced shared script / external system is neither in the bundle nor pre-existing in the target.
Step 5: Commit
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:
ApplyAsync_adds_new_artifacts_in_single_transactionApplyAsync_overwrites_artifact_when_resolution_is_OverwriteApplyAsync_skips_artifact_when_resolution_is_SkipApplyAsync_renames_artifact_when_resolution_is_RenameApplyAsync_rolls_back_all_changes_when_semantic_validation_failsApplyAsync_writes_BundleImportId_on_every_emitted_audit_row(the correlation guarantee)ApplyAsync_writes_BundleImported_summary_row_inside_transactionApplyAsync_writes_BundleImportFailed_outside_rolled_back_transaction
Step 3: Implement
Flow:
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
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(addbuilder.Services.AddTransport();in the "Central-only components" section, alongsideAddNotificationOutbox()) - Modify:
src/ScadaLink.Host/appsettings.json(addScadaLink:Transportsection with defaults that matchTransportOptions) - Modify:
src/ScadaLink.Host/ScadaLink.Host.csproj(addProjectReferenceto 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
"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
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:
[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 inSelectedKeys.Unchecked— none are.Indeterminate— some are. Set viaref+element.indeterminate = truein JS interop (use the existing JS bundle helper if there is one; otherwise a tinytreeview.jsfile 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/):
Checkbox_mode_renders_checkbox_per_nodeClicking_folder_selects_all_descendantsClicking_leaf_makes_parent_indeterminate_when_sibling_uncheckedSingle_mode_still_works_unchanged(regression)
Step 4: Run tests
Run: dotnet test tests/ScadaLink.CentralUI.Tests/ --filter FullyQualifiedName~TreeViewMultiSelect
Expected: PASS.
Step 5: Commit
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 inSinglemode)
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
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
@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
Renders_step1_with_template_treeStep2_shows_resolved_dependenciesStep4_triggers_ExportAsync_on_user_with_Design_rolePage_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
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
@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:
- Upload —
InputFilecomponent (size-limited toOptions.MaxBundleSizeMb). On select, callLoadAsync(stream, passphrase: null)to read the manifest only. - Passphrase — render iff
manifest.Encryption != null. Submit callsLoadAsyncagain with the passphrase. On wrong passphrase, increment a session-local counter; after 3 attempts, evict the session and force re-upload. - Diff & resolve — render
ImportPreviewrows with per-row radio buttons (Skip/Overwrite/Rename). Show field-level diff via<details>disclosure. Bulk "Apply to all" header. - Confirm — text input requires user to retype
manifest.SourceEnvironmentexactly. - 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
Renders_step1_upload_inputDecryption_failure_increments_attempt_counterThree_failed_unlocks_force_reuploadConfirm_step_requires_exact_environment_name_matchApply_step_invokes_BundleImporter_ApplyAsync_with_resolutionsPage_returns_403_for_user_without_Admin_role
Step 3: Run + commit
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:
<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
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 bygit grep -l ConfigurationAuditLog src/ScadaLink.CentralUI/) - Modify:
src/ScadaLink.ConfigurationDatabase/Repositories/(the repository that drives the audit page — extend the query with an optionalGuid? bundleImportIdfilter)
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:
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
BundleImportIdthrough to the repository call.
This is what the TransportImport.razor Step 5 link (/audit/configuration?bundleImportId=…) consumes.
Step 3: Commit
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
[Fact]
public async Task Export_then_wipe_then_import_restores_state()
- Seed in-memory DB with 3 templates (one composed), 1 shared script, 1 external system, 1 notification list.
- Call
IBundleExporter.ExportAsyncselecting all of them. - Read the returned stream into bytes.
- Wipe DB: delete all those entities.
- Call
IBundleImporter.LoadAsync(new MemoryStream(bytes), passphrase). PreviewAsync→ allConflictKind.New.- Apply with all
Addresolutions. - Re-query DB. Assert every entity is back, field-equivalent to the seed.
- Assert one
BundleExported+ oneBundleImportedaudit row exists, both linked by content hash. - Assert every per-entity audit row written during apply has
BundleImportId == result.BundleImportId.
Step 2: Run + commit
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:
Overwrite_replaces_existing_template_fieldsSkip_leaves_existing_template_unchangedRename_creates_new_template_alongside_existing
ValidationFailureTests:
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 aBundleImportFailedrow exists outside the rolled-back transaction.
Step 3: Commit
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.mdplus 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
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 newRequireDesignexport +RequireAdminimport mapping) - Modify:
docs/requirements/Component-ConfigurationDatabase.md(record the newBundleImportIdcolumn + 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:
| 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:
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
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):
# 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
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:
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.SiteRuntimeetc.). - Do NOT add a stale-mark column on
Instance— the existingDeploymentService.CompareAsyncalready 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.