Files
VolcanicCore/docs/superpowers/plans/2026-08-28-plan-1-backend-foundation.md
Claude 49f198ac82 docs: Plan 1 — Backend Foundation
14 tasks with TDD steps:
- Solution + project skeleton
- SQLite + Migrator + first migration
- ASP.NET cookie auth + session endpoints
- Core contracts (INodeType, IActionNode, ITriggerNode, IConnectionProvider)
- NodeRegistry + ConnectionProviderRegistry
- Discovery endpoints
- AES-GCM connection secret encryption
- Connections CRUD + Workflows CRUD
- Live-server smoke via curl

Plans 2-4 previewed at the end.
2026-08-28 10:14:25 +00:00

71 KiB

VolcanicCore — Plan 1: Backend Foundation

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Stand up the ASP.NET Core 9 backend with SQLite, admin auth, and the two extension registries. At the end, the server boots, admin login works, and CRUD is functional for workflows and connections. No executor yet, no real nodes yet — those are Plan 2.

Architecture: Single ASP.NET Core process, minimal APIs, Microsoft.Data.Sqlite (no EF), hand-rolled migrator versioned via PRAGMA user_version, ASP.NET Core cookie auth, AES-GCM for connection secrets. Extension registries scan loaded assemblies via reflection so future node / provider projects auto-surface.

Tech Stack: .NET 9, C# 12, Microsoft.Data.Sqlite, BCrypt.Net-Next, System.Text.Json, xUnit + Microsoft.AspNetCore.Mvc.Testing for integration tests.

Spec: docs/superpowers/specs/2026-08-28-volcaniccore-workflow-engine-design.md


File structure at end of plan

VolcanicCore.sln
global.json
src/
  VolcanicCore.Core/
    VolcanicCore.Core.csproj
    Item.cs                       # Item record
    JsonSchema.cs                 # thin wrapper carrying raw JsonElement
    NodeCategory.cs               # enum Trigger | Action | Terminal
    INodeType.cs                  # base interface
    IActionNode.cs                # per-port ExecuteAsync
    ITriggerNode.cs               # StartAsync + TriggerSink
    NodeContext.cs                # config, connection accessor, logger, answerChannelId
    TriggerSink.cs                # EmitAsync
    IConnectionProvider.cs        # OpenAsync/CloseAsync
  VolcanicCore.Persistence/
    VolcanicCore.Persistence.csproj
    SqliteConnectionFactory.cs    # file path from config, WAL, foreign_keys=ON
    Migrator.cs                   # applies numbered migrations in order
    Migrations/
      IMigration.cs
      Migration001Initial.cs     # admin_config
      Migration002Connections.cs
      Migration003Workflows.cs
    Encryption/
      EncryptionKeyLoader.cs      # env or /data/.enc-key
      EncryptionService.cs        # AES-GCM encrypt/decrypt to base64
    Repositories/
      AdminConfigRepository.cs
      ConnectionRepository.cs
      WorkflowRepository.cs
  VolcanicCore.Api/
    VolcanicCore.Api.csproj
    Program.cs                    # DI wire-up, migrations at boot, endpoint mapping
    AppSettings.cs                # strongly-typed options
    Auth/
      PasswordService.cs          # BCrypt hash/verify
      AdminBootstrap.cs           # seed admin_config.password_hash from ADMIN_PASSWORD
    Registries/
      NodeRegistry.cs             # reflection scan for INodeType
      ConnectionProviderRegistry.cs
    Endpoints/
      SessionEndpoints.cs         # POST/DELETE /api/session, GET /api/session/me
      NodeTypesEndpoints.cs       # GET /api/node-types
      ConnectionProvidersEndpoints.cs
      ConnectionsEndpoints.cs
      WorkflowsEndpoints.cs
    Contracts/                    # DTOs used by endpoints
      SessionDtos.cs
      ConnectionDtos.cs
      WorkflowDtos.cs
tests/
  VolcanicCore.Tests/
    VolcanicCore.Tests.csproj
    WebFactory.cs                 # WebApplicationFactory<Program> w/ tmp SQLite
    AuthTests.cs
    NodeRegistryTests.cs
    ConnectionProviderRegistryTests.cs
    DiscoveryEndpointsTests.cs
    ConnectionsEndpointsTests.cs
    WorkflowsEndpointsTests.cs
    EncryptionServiceTests.cs
    MigratorTests.cs

Task 1: Solution + project skeleton

Files:

  • Create: global.json, VolcanicCore.sln

  • Create: src/VolcanicCore.Core/VolcanicCore.Core.csproj

  • Create: src/VolcanicCore.Persistence/VolcanicCore.Persistence.csproj

  • Create: src/VolcanicCore.Api/VolcanicCore.Api.csproj

  • Create: tests/VolcanicCore.Tests/VolcanicCore.Tests.csproj

  • Step 1: Pin the SDK

Create global.json:

{ "sdk": { "version": "9.0.100", "rollForward": "latestFeature" } }
  • Step 2: Create the solution and projects
dotnet new sln -n VolcanicCore
dotnet new classlib -n VolcanicCore.Core -o src/VolcanicCore.Core -f net9.0
dotnet new classlib -n VolcanicCore.Persistence -o src/VolcanicCore.Persistence -f net9.0
dotnet new web -n VolcanicCore.Api -o src/VolcanicCore.Api -f net9.0
dotnet new xunit -n VolcanicCore.Tests -o tests/VolcanicCore.Tests -f net9.0

dotnet sln add src/VolcanicCore.Core src/VolcanicCore.Persistence src/VolcanicCore.Api tests/VolcanicCore.Tests

rm src/VolcanicCore.Core/Class1.cs src/VolcanicCore.Persistence/Class1.cs
  • Step 3: Wire project references and NuGet packages
dotnet add src/VolcanicCore.Persistence reference src/VolcanicCore.Core
dotnet add src/VolcanicCore.Api reference src/VolcanicCore.Core src/VolcanicCore.Persistence
dotnet add tests/VolcanicCore.Tests reference src/VolcanicCore.Api src/VolcanicCore.Core src/VolcanicCore.Persistence

dotnet add src/VolcanicCore.Persistence package Microsoft.Data.Sqlite --version 9.0.0
dotnet add src/VolcanicCore.Api package BCrypt.Net-Next --version 4.0.3
dotnet add src/VolcanicCore.Api package Microsoft.AspNetCore.Authentication.Cookies --version 2.3.0
dotnet add tests/VolcanicCore.Tests package Microsoft.AspNetCore.Mvc.Testing --version 9.0.0
  • Step 4: Make Program.cs a partial class so WebApplicationFactory can reach it

Replace src/VolcanicCore.Api/Program.cs with:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/api/health", () => Results.Ok(new { ok = true }));
app.Run();

public partial class Program { }
  • Step 5: Verify it all builds
dotnet build

Expected: build succeeds, 0 warnings, 0 errors.

  • Step 6: Commit
git add global.json VolcanicCore.sln src tests
git commit -m "chore: solution skeleton with Core/Persistence/Api/Tests projects"

Task 2: SQLite connection factory + Migrator scaffolding

Files:

  • Create: src/VolcanicCore.Persistence/SqliteConnectionFactory.cs

  • Create: src/VolcanicCore.Persistence/Migrations/IMigration.cs

  • Create: src/VolcanicCore.Persistence/Migrator.cs

  • Create: tests/VolcanicCore.Tests/MigratorTests.cs

  • Step 1: Write the failing test

tests/VolcanicCore.Tests/MigratorTests.cs:

using Microsoft.Data.Sqlite;
using VolcanicCore.Persistence;
using VolcanicCore.Persistence.Migrations;

namespace VolcanicCore.Tests;

public class MigratorTests
{
    private sealed class M1 : IMigration
    {
        public int Version => 1;
        public void Apply(SqliteConnection conn)
        {
            using var cmd = conn.CreateCommand();
            cmd.CommandText = "CREATE TABLE t (id INTEGER PRIMARY KEY);";
            cmd.ExecuteNonQuery();
        }
    }

    private sealed class M2 : IMigration
    {
        public int Version => 2;
        public void Apply(SqliteConnection conn)
        {
            using var cmd = conn.CreateCommand();
            cmd.CommandText = "ALTER TABLE t ADD COLUMN name TEXT;";
            cmd.ExecuteNonQuery();
        }
    }

    [Fact]
    public void Migrator_applies_missing_migrations_in_order_and_sets_user_version()
    {
        using var conn = new SqliteConnection("Data Source=:memory:");
        conn.Open();
        var mig = new Migrator(new IMigration[] { new M2(), new M1() });

        mig.Apply(conn);

        Assert.Equal(2, ReadUserVersion(conn));
        // Re-applying is a no-op.
        mig.Apply(conn);
        Assert.Equal(2, ReadUserVersion(conn));
    }

    private static int ReadUserVersion(SqliteConnection conn)
    {
        using var cmd = conn.CreateCommand();
        cmd.CommandText = "PRAGMA user_version;";
        return Convert.ToInt32(cmd.ExecuteScalar());
    }
}
  • Step 2: Run test to verify it fails
dotnet test tests/VolcanicCore.Tests --filter FullyQualifiedName~MigratorTests

Expected: compile error (IMigration, Migrator not defined).

  • Step 3: Implement

src/VolcanicCore.Persistence/Migrations/IMigration.cs:

using Microsoft.Data.Sqlite;

namespace VolcanicCore.Persistence.Migrations;

public interface IMigration
{
    int Version { get; }             // starts at 1, strictly monotonic
    void Apply(SqliteConnection conn);
}

src/VolcanicCore.Persistence/Migrator.cs:

using Microsoft.Data.Sqlite;
using VolcanicCore.Persistence.Migrations;

namespace VolcanicCore.Persistence;

public sealed class Migrator
{
    private readonly IReadOnlyList<IMigration> _migrations;

    public Migrator(IEnumerable<IMigration> migrations)
    {
        _migrations = migrations.OrderBy(m => m.Version).ToArray();
        for (var i = 1; i < _migrations.Count; i++)
            if (_migrations[i].Version == _migrations[i - 1].Version)
                throw new InvalidOperationException($"Duplicate migration version {_migrations[i].Version}");
    }

    public void Apply(SqliteConnection conn)
    {
        var current = ReadUserVersion(conn);
        foreach (var m in _migrations)
        {
            if (m.Version <= current) continue;
            using var tx = conn.BeginTransaction();
            m.Apply(conn);
            using (var cmd = conn.CreateCommand())
            {
                cmd.Transaction = tx;
                cmd.CommandText = $"PRAGMA user_version = {m.Version};";
                cmd.ExecuteNonQuery();
            }
            tx.Commit();
        }
    }

    private static int ReadUserVersion(SqliteConnection conn)
    {
        using var cmd = conn.CreateCommand();
        cmd.CommandText = "PRAGMA user_version;";
        return Convert.ToInt32(cmd.ExecuteScalar());
    }
}

src/VolcanicCore.Persistence/SqliteConnectionFactory.cs:

using Microsoft.Data.Sqlite;

namespace VolcanicCore.Persistence;

public sealed class SqliteConnectionFactory
{
    private readonly string _connectionString;

    public SqliteConnectionFactory(string filePath)
    {
        _connectionString = new SqliteConnectionStringBuilder
        {
            DataSource = filePath,
            Cache = SqliteCacheMode.Shared,
            Pooling = true
        }.ToString();
    }

    public SqliteConnection Open()
    {
        var conn = new SqliteConnection(_connectionString);
        conn.Open();
        using (var cmd = conn.CreateCommand())
        {
            cmd.CommandText = "PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;";
            cmd.ExecuteNonQuery();
        }
        return conn;
    }
}
  • Step 4: Run test — should pass
dotnet test tests/VolcanicCore.Tests --filter FullyQualifiedName~MigratorTests

Expected: PASS.

  • Step 5: Commit
git add src/VolcanicCore.Persistence tests/VolcanicCore.Tests
git commit -m "feat(persistence): SQLite factory + versioned Migrator"

Task 3: AppSettings + first migration (admin_config) + boot-time migrate

Files:

  • Create: src/VolcanicCore.Api/AppSettings.cs

  • Create: src/VolcanicCore.Persistence/Migrations/Migration001Initial.cs

  • Create: src/VolcanicCore.Persistence/Repositories/AdminConfigRepository.cs

  • Modify: src/VolcanicCore.Api/Program.cs

  • Step 1: Add options class

src/VolcanicCore.Api/AppSettings.cs:

namespace VolcanicCore.Api;

public sealed class AppSettings
{
    public string DataDirectory { get; set; } = "/data";
    public string AdminPassword { get; set; } = "";
    public string? EncryptionKeyBase64 { get; set; }
    public int ExecutionRetentionDays { get; set; } = 7;
    public int ExecutionKeepPerWorkflow { get; set; } = 500;
    public int WebhookDefaultTimeoutSeconds { get; set; } = 30;
    public int MaxConcurrentExecutions { get; set; } = 32;

    public string DatabasePath => Path.Combine(DataDirectory, "volcaniccore.db");
    public string EncryptionKeyFilePath => Path.Combine(DataDirectory, ".enc-key");
}
  • Step 2: First migration

src/VolcanicCore.Persistence/Migrations/Migration001Initial.cs:

using Microsoft.Data.Sqlite;

namespace VolcanicCore.Persistence.Migrations;

public sealed class Migration001Initial : IMigration
{
    public int Version => 1;

    public void Apply(SqliteConnection conn)
    {
        using var cmd = conn.CreateCommand();
        cmd.CommandText = @"
            CREATE TABLE admin_config (
                key   TEXT PRIMARY KEY,
                value TEXT NOT NULL
            );
        ";
        cmd.ExecuteNonQuery();
    }
}

src/VolcanicCore.Persistence/Repositories/AdminConfigRepository.cs:

using Microsoft.Data.Sqlite;

namespace VolcanicCore.Persistence.Repositories;

public sealed class AdminConfigRepository
{
    private readonly SqliteConnectionFactory _factory;
    public AdminConfigRepository(SqliteConnectionFactory factory) => _factory = factory;

    public string? Get(string key)
    {
        using var conn = _factory.Open();
        using var cmd = conn.CreateCommand();
        cmd.CommandText = "SELECT value FROM admin_config WHERE key = $k";
        cmd.Parameters.AddWithValue("$k", key);
        return cmd.ExecuteScalar() as string;
    }

    public void Set(string key, string value)
    {
        using var conn = _factory.Open();
        using var cmd = conn.CreateCommand();
        cmd.CommandText = @"
            INSERT INTO admin_config (key, value) VALUES ($k, $v)
            ON CONFLICT(key) DO UPDATE SET value = excluded.value;
        ";
        cmd.Parameters.AddWithValue("$k", key);
        cmd.Parameters.AddWithValue("$v", value);
        cmd.ExecuteNonQuery();
    }
}
  • Step 3: Wire migrations at boot

Replace src/VolcanicCore.Api/Program.cs:

using VolcanicCore.Api;
using VolcanicCore.Persistence;
using VolcanicCore.Persistence.Migrations;
using VolcanicCore.Persistence.Repositories;

var builder = WebApplication.CreateBuilder(args);

var settings = new AppSettings();
builder.Configuration.Bind(settings);
builder.Services.AddSingleton(settings);

Directory.CreateDirectory(settings.DataDirectory);
builder.Services.AddSingleton(new SqliteConnectionFactory(settings.DatabasePath));
builder.Services.AddSingleton<AdminConfigRepository>();
builder.Services.AddSingleton<Migrator>(_ => new Migrator(new IMigration[]
{
    new Migration001Initial(),
}));

var app = builder.Build();

// Apply migrations at boot.
using (var scope = app.Services.CreateScope())
{
    var mig = scope.ServiceProvider.GetRequiredService<Migrator>();
    var factory = scope.ServiceProvider.GetRequiredService<SqliteConnectionFactory>();
    using var conn = factory.Open();
    mig.Apply(conn);
}

app.MapGet("/api/health", () => Results.Ok(new { ok = true }));
app.Run();

public partial class Program { }
  • Step 4: Configure from env vars — with test-friendly override precedence

Add to Program.cs right after CreateBuilder:

// Flat-name env-var overrides for the deploy contract. Only fill in a key if
// nothing (e.g. WebFactory's UseSetting in tests) already set it — this lets
// test fixtures own their own config regardless of process env state.
foreach (var (env, prop) in new (string, string)[] {
    ("ADMIN_PASSWORD", nameof(AppSettings.AdminPassword)),
    ("DATA_DIRECTORY", nameof(AppSettings.DataDirectory)),
    ("ENCRYPTION_KEY", nameof(AppSettings.EncryptionKeyBase64)),
    ("EXECUTION_RETENTION_DAYS", nameof(AppSettings.ExecutionRetentionDays)),
    ("EXECUTION_KEEP_PER_WORKFLOW", nameof(AppSettings.ExecutionKeepPerWorkflow)),
    ("WEBHOOK_DEFAULT_TIMEOUT_SECONDS", nameof(AppSettings.WebhookDefaultTimeoutSeconds)),
    ("MAX_CONCURRENT_EXECUTIONS", nameof(AppSettings.MaxConcurrentExecutions)),
})
{
    if (!string.IsNullOrEmpty(builder.Configuration[prop])) continue;
    var v = Environment.GetEnvironmentVariable(env);
    if (v is not null) builder.Configuration[prop] = v;
}
  • Step 5: Verify boot still works
DATA_DIRECTORY=./_test_data ADMIN_PASSWORD=x dotnet run --project src/VolcanicCore.Api --urls http://127.0.0.1:5099 &
sleep 3
curl -s http://127.0.0.1:5099/api/health
kill %1 2>/dev/null
ls -la ./_test_data

Expected: {"ok":true} from curl, and ./_test_data/volcaniccore.db exists. Then rm -rf ./_test_data.

  • Step 6: Commit
git add src
git commit -m "feat(persistence): admin_config migration, boot-time migrator, AppSettings"

Task 4: PasswordService + AdminBootstrap (hash admin pw on startup)

Files:

  • Create: src/VolcanicCore.Api/Auth/PasswordService.cs

  • Create: src/VolcanicCore.Api/Auth/AdminBootstrap.cs

  • Create: tests/VolcanicCore.Tests/AdminBootstrapTests.cs

  • Modify: src/VolcanicCore.Api/Program.cs

  • Step 1: Failing test

tests/VolcanicCore.Tests/AdminBootstrapTests.cs:

using Microsoft.Data.Sqlite;
using VolcanicCore.Api.Auth;
using VolcanicCore.Persistence;
using VolcanicCore.Persistence.Migrations;
using VolcanicCore.Persistence.Repositories;

namespace VolcanicCore.Tests;

public class AdminBootstrapTests
{
    private static (SqliteConnectionFactory f, AdminConfigRepository r) Setup()
    {
        var path = Path.Combine(Path.GetTempPath(), $"vc-{Guid.NewGuid():N}.db");
        var f = new SqliteConnectionFactory(path);
        using (var conn = f.Open())
            new Migrator(new IMigration[] { new Migration001Initial() }).Apply(conn);
        return (f, new AdminConfigRepository(f));
    }

    [Fact]
    public void Refuses_to_boot_without_admin_password()
    {
        var (_, repo) = Setup();
        var pw = new PasswordService();
        Assert.Throws<InvalidOperationException>(() => AdminBootstrap.Seed(repo, pw, ""));
    }

    [Fact]
    public void Stores_hash_on_first_boot_and_updates_it_when_password_changes()
    {
        var (_, repo) = Setup();
        var pw = new PasswordService();
        AdminBootstrap.Seed(repo, pw, "hunter2");
        var h1 = repo.Get("admin.password_hash");
        Assert.NotNull(h1);
        Assert.True(pw.Verify("hunter2", h1!));

        AdminBootstrap.Seed(repo, pw, "correct-horse");
        var h2 = repo.Get("admin.password_hash");
        Assert.NotEqual(h1, h2);
        Assert.True(pw.Verify("correct-horse", h2!));
    }
}
  • Step 2: Run test — should fail (missing types)
dotnet test tests/VolcanicCore.Tests --filter FullyQualifiedName~AdminBootstrapTests

Expected: compile errors on PasswordService, AdminBootstrap.

  • Step 3: Implement

src/VolcanicCore.Api/Auth/PasswordService.cs:

namespace VolcanicCore.Api.Auth;

public sealed class PasswordService
{
    public string Hash(string plaintext) => BCrypt.Net.BCrypt.HashPassword(plaintext, workFactor: 12);
    public bool Verify(string plaintext, string hash) => BCrypt.Net.BCrypt.Verify(plaintext, hash);
}

src/VolcanicCore.Api/Auth/AdminBootstrap.cs:

using VolcanicCore.Persistence.Repositories;

namespace VolcanicCore.Api.Auth;

public static class AdminBootstrap
{
    public const string PasswordHashKey = "admin.password_hash";
    public const string PasswordSourceKey = "admin.password_hash_source";

    public static void Seed(AdminConfigRepository repo, PasswordService pw, string adminPassword)
    {
        if (string.IsNullOrWhiteSpace(adminPassword))
            throw new InvalidOperationException(
                "ADMIN_PASSWORD is required. Set the env var to a non-empty value.");

        var existingHash = repo.Get(PasswordHashKey);
        var existingSource = repo.Get(PasswordSourceKey);

        // Re-hash only if the env value has changed since last boot.
        if (existingHash is not null && existingSource == adminPassword) return;

        repo.Set(PasswordHashKey, pw.Hash(adminPassword));
        repo.Set(PasswordSourceKey, adminPassword);
    }
}
  • Step 4: Wire into Program.cs

Two edits to Program.cs:

Edit A — add before var app = builder.Build();:

builder.Services.AddSingleton<PasswordService>();

Edit B — add after the migrator-apply using block, still before app.Run():

// Bootstrap admin credentials at boot.
using (var scope = app.Services.CreateScope())
{
    var repo = scope.ServiceProvider.GetRequiredService<AdminConfigRepository>();
    var pw = scope.ServiceProvider.GetRequiredService<PasswordService>();
    AdminBootstrap.Seed(repo, pw, settings.AdminPassword);
}

Add using VolcanicCore.Api.Auth; to the top.

  • Step 5: Run test — should pass
dotnet test tests/VolcanicCore.Tests --filter FullyQualifiedName~AdminBootstrapTests

Expected: PASS.

  • Step 6: Commit
git add src tests
git commit -m "feat(auth): PasswordService + AdminBootstrap seeds hash from ADMIN_PASSWORD"

Files:

  • Create: src/VolcanicCore.Api/Contracts/SessionDtos.cs

  • Create: src/VolcanicCore.Api/Endpoints/SessionEndpoints.cs

  • Create: tests/VolcanicCore.Tests/WebFactory.cs

  • Create: tests/VolcanicCore.Tests/AuthTests.cs

  • Modify: src/VolcanicCore.Api/Program.cs

  • Step 1: WebApplicationFactory

tests/VolcanicCore.Tests/WebFactory.cs:

using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;

// Disable test parallelization across the assembly. Multiple WebFactory instances
// share the same process, and pre-Bind config wiring reads process-wide state;
// serial execution avoids interleaving flakes for a small suite like ours.
[assembly: CollectionBehavior(DisableTestParallelization = true)]

namespace VolcanicCore.Tests;

public sealed class WebFactory : WebApplicationFactory<Program>
{
    public string DataDir { get; } = Path.Combine(Path.GetTempPath(), $"vc-{Guid.NewGuid():N}");
    public string AdminPassword { get; } = "hunter2";

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.UseEnvironment("Testing");
        // UseSetting writes into the configuration chain synchronously so
        // Program.cs's Configuration.Bind picks these up without env vars.
        builder.UseSetting(nameof(VolcanicCore.Api.AppSettings.DataDirectory), DataDir);
        builder.UseSetting(nameof(VolcanicCore.Api.AppSettings.AdminPassword), AdminPassword);
    }

    protected override void Dispose(bool disposing)
    {
        base.Dispose(disposing);
        try { Directory.Delete(DataDir, recursive: true); } catch { }
    }
}
  • Step 2: Failing test

tests/VolcanicCore.Tests/AuthTests.cs:

using System.Net;
using System.Net.Http.Json;

namespace VolcanicCore.Tests;

public class AuthTests : IClassFixture<WebFactory>
{
    private readonly WebFactory _factory;
    public AuthTests(WebFactory f) => _factory = f;

    private HttpClient Fresh() => _factory.CreateClient(new()
    {
        HandleCookies = true,
        AllowAutoRedirect = false,
    });

    [Fact]
    public async Task Me_without_cookie_is_401()
    {
        var res = await Fresh().GetAsync("/api/session/me");
        Assert.Equal(HttpStatusCode.Unauthorized, res.StatusCode);
    }

    [Fact]
    public async Task Bad_password_is_401()
    {
        var res = await Fresh().PostAsJsonAsync("/api/session",
            new { password = "nope" });
        Assert.Equal(HttpStatusCode.Unauthorized, res.StatusCode);
    }

    [Fact]
    public async Task Good_password_sets_cookie_and_me_returns_200()
    {
        var c = Fresh();
        var login = await c.PostAsJsonAsync("/api/session", new { password = _factory.AdminPassword });
        Assert.Equal(HttpStatusCode.NoContent, login.StatusCode);
        var me = await c.GetAsync("/api/session/me");
        Assert.Equal(HttpStatusCode.OK, me.StatusCode);
    }

    [Fact]
    public async Task Logout_clears_the_session()
    {
        var c = Fresh();
        await c.PostAsJsonAsync("/api/session", new { password = _factory.AdminPassword });
        var logout = await c.DeleteAsync("/api/session");
        Assert.Equal(HttpStatusCode.NoContent, logout.StatusCode);
        var me = await c.GetAsync("/api/session/me");
        Assert.Equal(HttpStatusCode.Unauthorized, me.StatusCode);
    }
}
  • Step 3: Implement DTOs and endpoints

src/VolcanicCore.Api/Contracts/SessionDtos.cs:

namespace VolcanicCore.Api.Contracts;

public sealed record LoginRequest(string Password);
public sealed record MeResponse(bool Authenticated, string? Username);

src/VolcanicCore.Api/Endpoints/SessionEndpoints.cs:

using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using VolcanicCore.Api.Auth;
using VolcanicCore.Api.Contracts;
using VolcanicCore.Persistence.Repositories;

namespace VolcanicCore.Api.Endpoints;

public static class SessionEndpoints
{
    public const string AdminUser = "admin";

    public static void MapSessionEndpoints(this IEndpointRouteBuilder app)
    {
        app.MapPost("/api/session", async (LoginRequest req, HttpContext ctx,
            AdminConfigRepository repo, PasswordService pw) =>
        {
            var hash = repo.Get(AdminBootstrap.PasswordHashKey);
            if (hash is null || !pw.Verify(req.Password ?? "", hash))
                return Results.Unauthorized();

            var claims = new[] { new Claim(ClaimTypes.Name, AdminUser) };
            var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
            await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
                new ClaimsPrincipal(identity));
            return Results.NoContent();
        });

        app.MapDelete("/api/session", async (HttpContext ctx) =>
        {
            await ctx.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
            return Results.NoContent();
        });

        app.MapGet("/api/session/me", (HttpContext ctx) =>
            ctx.User.Identity?.IsAuthenticated == true
                ? Results.Ok(new MeResponse(true, ctx.User.Identity.Name))
                : Results.Unauthorized())
            .RequireAuthorization();
    }
}
  • Step 4: Add cookie auth + endpoints in Program.cs

In Program.cs, add after the Configuration.Bind:

using Microsoft.AspNetCore.Authentication.Cookies;
using VolcanicCore.Api.Endpoints;

Register services (before builder.Build()):

builder.Services
    .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(o =>
    {
        o.Cookie.Name = "volcaniccore.sid";
        o.Cookie.HttpOnly = true;
        o.Cookie.SameSite = SameSiteMode.Lax;
        o.ExpireTimeSpan = TimeSpan.FromDays(7);
        o.SlidingExpiration = true;
        o.Events.OnRedirectToLogin = ctx =>
        {
            ctx.Response.StatusCode = 401; return Task.CompletedTask;
        };
        o.Events.OnRedirectToAccessDenied = ctx =>
        {
            ctx.Response.StatusCode = 403; return Task.CompletedTask;
        };
    });
builder.Services.AddAuthorization();

Before app.Run():

app.UseAuthentication();
app.UseAuthorization();
app.MapSessionEndpoints();
  • Step 5: Run tests — should pass
dotnet test tests/VolcanicCore.Tests --filter FullyQualifiedName~AuthTests

Expected: 4 tests PASS.

  • Step 6: Commit
git add src tests
git commit -m "feat(auth): cookie session endpoints + WebApplicationFactory harness"

Task 6: Core types — Item, JsonSchema, NodeCategory, NodeContext, TriggerSink

Files:

  • Create: src/VolcanicCore.Core/Item.cs

  • Create: src/VolcanicCore.Core/JsonSchema.cs

  • Create: src/VolcanicCore.Core/NodeCategory.cs

  • Create: src/VolcanicCore.Core/NodeContext.cs

  • Create: src/VolcanicCore.Core/TriggerSink.cs

  • Step 1: Add the types (no tests yet — pure data / interfaces)

src/VolcanicCore.Core/NodeCategory.cs:

namespace VolcanicCore.Core;

public enum NodeCategory { Trigger, Action, Terminal }

src/VolcanicCore.Core/Item.cs:

using System.Text.Json;

namespace VolcanicCore.Core;

public sealed record Item(JsonElement Json, IReadOnlyDictionary<string, byte[]>? Binary = null);

src/VolcanicCore.Core/JsonSchema.cs:

using System.Text.Json;

namespace VolcanicCore.Core;

// Thin transport for a JSON-schema-shaped document. v1 does not validate against it
// on the backend beyond deserialization; the SPA renders forms from it. Node
// implementations pull their strongly-typed config out of NodeContext.Config.
public sealed record JsonSchema(JsonElement Root);

src/VolcanicCore.Core/NodeContext.cs:

using System.Text.Json;
using Microsoft.Extensions.Logging;

namespace VolcanicCore.Core;

// Runtime handle passed to every node. Concrete implementation lives in
// VolcanicCore.Api (Plan 2). This interface is enough for node code to compile.
public interface INodeContext
{
    string ExecutionId { get; }
    string NodeId { get; }
    JsonElement Config { get; }                 // deserialize into your own DTO
    Guid? AnswerChannelId { get; }              // set when a Webhook trigger is in scope
    ILogger Logger { get; }

    // Returns the live pooled connection object opened by the connection provider,
    // or throws if the node's ConnectionProviderId is null / the ID is missing.
    T GetConnection<T>() where T : class;
}

src/VolcanicCore.Core/TriggerSink.cs:

using System.Text.Json;

namespace VolcanicCore.Core;

// Passed to a trigger node's StartAsync. Calling EmitAsync starts one execution.
public interface ITriggerSink
{
    Task EmitAsync(IReadOnlyList<Item> items, JsonElement? triggerData = null,
        CancellationToken ct = default);
}
  • Step 2: Build to verify
dotnet build src/VolcanicCore.Core

Expected: success.

  • Step 3: Commit
git add src/VolcanicCore.Core
git commit -m "feat(core): Item, JsonSchema, NodeCategory, INodeContext, ITriggerSink"

Task 7: INodeType, IActionNode, ITriggerNode

Files:

  • Create: src/VolcanicCore.Core/INodeType.cs

  • Create: src/VolcanicCore.Core/IActionNode.cs

  • Create: src/VolcanicCore.Core/ITriggerNode.cs

  • Step 1: Add the interfaces

src/VolcanicCore.Core/INodeType.cs:

namespace VolcanicCore.Core;

public interface INodeType
{
    string Id { get; }                       // e.g. "nats.hook"
    string DisplayName { get; }
    NodeCategory Category { get; }
    int InputCount { get; }                  // 0 for triggers
    int OutputCount { get; }                 // 0 for terminals
    string? ConnectionProviderId { get; }    // null if the node opens no connection
    JsonSchema ConfigSchema { get; }         // drives the SPA form
}

src/VolcanicCore.Core/IActionNode.cs:

namespace VolcanicCore.Core;

public interface IActionNode : INodeType
{
    // inputsByPort[i] is the list delivered on input port i.
    // Return value indexed by output port; empty outer list for terminals.
    Task<IReadOnlyList<IReadOnlyList<Item>>> ExecuteAsync(
        INodeContext ctx,
        IReadOnlyList<IReadOnlyList<Item>> inputsByPort,
        CancellationToken ct);
}

src/VolcanicCore.Core/ITriggerNode.cs:

namespace VolcanicCore.Core;

public interface ITriggerNode : INodeType
{
    // Starts the trigger. Return value is disposed when the workflow is deactivated
    // or the process is shutting down.
    Task<IAsyncDisposable> StartAsync(
        INodeContext ctx,
        ITriggerSink sink,
        CancellationToken ct);
}
  • Step 2: Build
dotnet build src/VolcanicCore.Core

Expected: success.

  • Step 3: Commit
git add src/VolcanicCore.Core
git commit -m "feat(core): INodeType + IActionNode + ITriggerNode contracts"

Task 8: IConnectionProvider

Files:

  • Create: src/VolcanicCore.Core/IConnectionProvider.cs

  • Step 1: Add the interface

using System.Text.Json;

namespace VolcanicCore.Core;

public interface IConnectionProvider
{
    string Id { get; }                                    // e.g. "nats"
    string DisplayName { get; }
    JsonSchema ConfigSchema { get; }                      // fields; mark secrets via schema convention

    Task<object> OpenAsync(JsonElement config, CancellationToken ct);
    Task CloseAsync(object handle);
}
  • Step 2: Build
dotnet build src/VolcanicCore.Core

Expected: success.

  • Step 3: Commit
git add src/VolcanicCore.Core
git commit -m "feat(core): IConnectionProvider contract"

Task 9: NodeRegistry (reflection scan) + ConnectionProviderRegistry

Files:

  • Create: src/VolcanicCore.Api/Registries/NodeRegistry.cs

  • Create: src/VolcanicCore.Api/Registries/ConnectionProviderRegistry.cs

  • Create: tests/VolcanicCore.Tests/NodeRegistryTests.cs

  • Create: tests/VolcanicCore.Tests/ConnectionProviderRegistryTests.cs

  • Step 1: Failing tests

tests/VolcanicCore.Tests/NodeRegistryTests.cs:

using System.Text.Json;
using VolcanicCore.Api.Registries;
using VolcanicCore.Core;

namespace VolcanicCore.Tests;

public class NodeRegistryTests
{
    private sealed class FakeAction : IActionNode
    {
        public string Id => "test.echo";
        public string DisplayName => "Echo";
        public NodeCategory Category => NodeCategory.Action;
        public int InputCount => 1;
        public int OutputCount => 1;
        public string? ConnectionProviderId => null;
        public JsonSchema ConfigSchema => new(JsonDocument.Parse("{}").RootElement);
        public Task<IReadOnlyList<IReadOnlyList<Item>>> ExecuteAsync(
            INodeContext ctx, IReadOnlyList<IReadOnlyList<Item>> inputsByPort, CancellationToken ct)
            => Task.FromResult<IReadOnlyList<IReadOnlyList<Item>>>(new[] { inputsByPort[0] });
    }

    [Fact]
    public void Registers_instances_and_looks_them_up_by_id()
    {
        var reg = new NodeRegistry(new INodeType[] { new FakeAction() });
        Assert.Single(reg.All);
        Assert.Same(reg.All[0], reg.Get("test.echo"));
        Assert.Throws<KeyNotFoundException>(() => reg.Get("does.not.exist"));
    }

    [Fact]
    public void Duplicate_ids_throw()
    {
        Assert.Throws<InvalidOperationException>(() =>
            new NodeRegistry(new INodeType[] { new FakeAction(), new FakeAction() }));
    }
}

tests/VolcanicCore.Tests/ConnectionProviderRegistryTests.cs:

using System.Text.Json;
using VolcanicCore.Api.Registries;
using VolcanicCore.Core;

namespace VolcanicCore.Tests;

public class ConnectionProviderRegistryTests
{
    private sealed class FakeProvider : IConnectionProvider
    {
        public string Id => "test.provider";
        public string DisplayName => "Fake";
        public JsonSchema ConfigSchema => new(JsonDocument.Parse("{}").RootElement);
        public Task<object> OpenAsync(JsonElement config, CancellationToken ct) => Task.FromResult<object>(new());
        public Task CloseAsync(object handle) => Task.CompletedTask;
    }

    [Fact]
    public void Registers_and_looks_up_by_id()
    {
        var reg = new ConnectionProviderRegistry(new IConnectionProvider[] { new FakeProvider() });
        Assert.Single(reg.All);
        Assert.Same(reg.All[0], reg.Get("test.provider"));
    }
}
  • Step 2: Run — expect failures
dotnet test tests/VolcanicCore.Tests --filter "FullyQualifiedName~NodeRegistryTests|FullyQualifiedName~ConnectionProviderRegistryTests"

Expected: compile errors on both registries.

  • Step 3: Implement

src/VolcanicCore.Api/Registries/NodeRegistry.cs:

using VolcanicCore.Core;

namespace VolcanicCore.Api.Registries;

public sealed class NodeRegistry
{
    private readonly IReadOnlyDictionary<string, INodeType> _byId;
    public IReadOnlyList<INodeType> All { get; }

    public NodeRegistry(IEnumerable<INodeType> nodes)
    {
        All = nodes.ToArray();
        var byId = new Dictionary<string, INodeType>(StringComparer.Ordinal);
        foreach (var n in All)
        {
            if (!byId.TryAdd(n.Id, n))
                throw new InvalidOperationException($"Duplicate node type id: {n.Id}");
        }
        _byId = byId;
    }

    public INodeType Get(string id) =>
        _byId.TryGetValue(id, out var n) ? n
            : throw new KeyNotFoundException($"Unknown node type: {id}");
}

src/VolcanicCore.Api/Registries/ConnectionProviderRegistry.cs:

using VolcanicCore.Core;

namespace VolcanicCore.Api.Registries;

public sealed class ConnectionProviderRegistry
{
    private readonly IReadOnlyDictionary<string, IConnectionProvider> _byId;
    public IReadOnlyList<IConnectionProvider> All { get; }

    public ConnectionProviderRegistry(IEnumerable<IConnectionProvider> providers)
    {
        All = providers.ToArray();
        var byId = new Dictionary<string, IConnectionProvider>(StringComparer.Ordinal);
        foreach (var p in All)
            if (!byId.TryAdd(p.Id, p))
                throw new InvalidOperationException($"Duplicate connection provider id: {p.Id}");
        _byId = byId;
    }

    public IConnectionProvider Get(string id) =>
        _byId.TryGetValue(id, out var p) ? p
            : throw new KeyNotFoundException($"Unknown connection provider: {id}");
}
  • Step 4: Wire DI in Program.cs

Add before builder.Build():

// Reflection scan — pick up INodeType / IConnectionProvider implementations
// registered as DI services by any loaded assembly. In Plan 2, node/provider
// assemblies will register their types via extension methods.
builder.Services.AddSingleton<NodeRegistry>(sp =>
    new NodeRegistry(sp.GetServices<INodeType>()));
builder.Services.AddSingleton<ConnectionProviderRegistry>(sp =>
    new ConnectionProviderRegistry(sp.GetServices<IConnectionProvider>()));

Add using VolcanicCore.Api.Registries; using VolcanicCore.Core; at the top.

  • Step 5: Run tests
dotnet test tests/VolcanicCore.Tests --filter "FullyQualifiedName~NodeRegistryTests|FullyQualifiedName~ConnectionProviderRegistryTests"

Expected: PASS.

  • Step 6: Commit
git add src tests
git commit -m "feat(api): NodeRegistry + ConnectionProviderRegistry (DI-collected)"

Task 10: Discovery endpoints — GET /api/node-types, GET /api/connection-providers

Files:

  • Create: src/VolcanicCore.Api/Endpoints/NodeTypesEndpoints.cs

  • Create: src/VolcanicCore.Api/Endpoints/ConnectionProvidersEndpoints.cs

  • Create: tests/VolcanicCore.Tests/DiscoveryEndpointsTests.cs

  • Modify: src/VolcanicCore.Api/Program.cs

  • Step 1: Failing test

tests/VolcanicCore.Tests/DiscoveryEndpointsTests.cs:

using System.Net;
using System.Net.Http.Json;

namespace VolcanicCore.Tests;

public class DiscoveryEndpointsTests : IClassFixture<WebFactory>
{
    private readonly WebFactory _factory;
    public DiscoveryEndpointsTests(WebFactory f) => _factory = f;

    private async Task<HttpClient> LoggedIn()
    {
        var c = _factory.CreateClient(new() { HandleCookies = true, AllowAutoRedirect = false });
        await c.PostAsJsonAsync("/api/session", new { password = _factory.AdminPassword });
        return c;
    }

    [Fact]
    public async Task Node_types_requires_auth()
    {
        var c = _factory.CreateClient();
        var res = await c.GetAsync("/api/node-types");
        Assert.Equal(HttpStatusCode.Unauthorized, res.StatusCode);
    }

    [Fact]
    public async Task Node_types_returns_registered_types_empty_in_plan_1()
    {
        var c = await LoggedIn();
        var res = await c.GetFromJsonAsync<List<NodeTypeDto>>("/api/node-types");
        Assert.NotNull(res);
        Assert.Empty(res!);            // Plan 2 adds the 5 nodes.
    }

    [Fact]
    public async Task Connection_providers_returns_registered_providers_empty_in_plan_1()
    {
        var c = await LoggedIn();
        var res = await c.GetFromJsonAsync<List<ConnectionProviderDto>>("/api/connection-providers");
        Assert.NotNull(res);
        Assert.Empty(res!);
    }

    private sealed record NodeTypeDto(string Id, string DisplayName, string Category,
        int InputCount, int OutputCount, string? ConnectionProviderId, object ConfigSchema);
    private sealed record ConnectionProviderDto(string Id, string DisplayName, object ConfigSchema);
}
  • Step 2: Run — expect 4xx routing failure or missing endpoints
dotnet test tests/VolcanicCore.Tests --filter FullyQualifiedName~DiscoveryEndpointsTests

Expected: Node_types_returns_... fails (404 or similar).

  • Step 3: Implement

src/VolcanicCore.Api/Endpoints/NodeTypesEndpoints.cs:

using VolcanicCore.Api.Registries;

namespace VolcanicCore.Api.Endpoints;

public static class NodeTypesEndpoints
{
    public static void MapNodeTypesEndpoints(this IEndpointRouteBuilder app)
    {
        app.MapGet("/api/node-types", (NodeRegistry reg) =>
            Results.Ok(reg.All.Select(n => new
            {
                id = n.Id,
                displayName = n.DisplayName,
                category = n.Category.ToString(),
                inputCount = n.InputCount,
                outputCount = n.OutputCount,
                connectionProviderId = n.ConnectionProviderId,
                configSchema = n.ConfigSchema.Root,
            })))
            .RequireAuthorization();
    }
}

src/VolcanicCore.Api/Endpoints/ConnectionProvidersEndpoints.cs:

using VolcanicCore.Api.Registries;

namespace VolcanicCore.Api.Endpoints;

public static class ConnectionProvidersEndpoints
{
    public static void MapConnectionProvidersEndpoints(this IEndpointRouteBuilder app)
    {
        app.MapGet("/api/connection-providers", (ConnectionProviderRegistry reg) =>
            Results.Ok(reg.All.Select(p => new
            {
                id = p.Id,
                displayName = p.DisplayName,
                configSchema = p.ConfigSchema.Root,
            })))
            .RequireAuthorization();
    }
}

Register in Program.cs before app.Run():

app.MapNodeTypesEndpoints();
app.MapConnectionProvidersEndpoints();
  • Step 4: Run tests — should pass
dotnet test tests/VolcanicCore.Tests --filter FullyQualifiedName~DiscoveryEndpointsTests

Expected: PASS.

  • Step 5: Commit
git add src tests
git commit -m "feat(api): discovery endpoints for node types + connection providers"

Task 11: EncryptionKeyLoader + EncryptionService (AES-GCM)

Files:

  • Create: src/VolcanicCore.Persistence/Encryption/EncryptionKeyLoader.cs

  • Create: src/VolcanicCore.Persistence/Encryption/EncryptionService.cs

  • Create: tests/VolcanicCore.Tests/EncryptionServiceTests.cs

  • Step 1: Failing test

tests/VolcanicCore.Tests/EncryptionServiceTests.cs:

using System.Security.Cryptography;
using VolcanicCore.Persistence.Encryption;

namespace VolcanicCore.Tests;

public class EncryptionServiceTests
{
    private static byte[] NewKey() { var k = new byte[32]; RandomNumberGenerator.Fill(k); return k; }

    [Fact]
    public void Round_trip()
    {
        var svc = new EncryptionService(NewKey());
        var wire = svc.Encrypt("hunter2");
        Assert.NotEqual("hunter2", wire);
        Assert.Equal("hunter2", svc.Decrypt(wire));
    }

    [Fact]
    public void Different_ciphertexts_for_same_plaintext()
    {
        var svc = new EncryptionService(NewKey());
        Assert.NotEqual(svc.Encrypt("a"), svc.Encrypt("a"));
    }

    [Fact]
    public void Tamper_fails()
    {
        var svc = new EncryptionService(NewKey());
        var wire = svc.Encrypt("hello");
        var bytes = Convert.FromBase64String(wire);
        bytes[^1] ^= 0x01;          // flip a bit in the tag
        var tampered = Convert.ToBase64String(bytes);
        Assert.Throws<CryptographicException>(() => svc.Decrypt(tampered));
    }

    [Fact]
    public void KeyLoader_generates_and_persists_a_key_when_env_and_file_missing()
    {
        var dir = Path.Combine(Path.GetTempPath(), $"vc-{Guid.NewGuid():N}");
        Directory.CreateDirectory(dir);
        var path = Path.Combine(dir, ".enc-key");

        var k1 = EncryptionKeyLoader.Load(envBase64: null, keyFilePath: path);
        Assert.Equal(32, k1.Length);
        Assert.True(File.Exists(path));

        var k2 = EncryptionKeyLoader.Load(envBase64: null, keyFilePath: path);
        Assert.Equal(k1, k2);   // stable across calls
    }

    [Fact]
    public void KeyLoader_prefers_env_over_file()
    {
        var envKey = new byte[32]; RandomNumberGenerator.Fill(envKey);
        var loaded = EncryptionKeyLoader.Load(
            envBase64: Convert.ToBase64String(envKey),
            keyFilePath: Path.Combine(Path.GetTempPath(), "does-not-matter"));
        Assert.Equal(envKey, loaded);
    }
}
  • Step 2: Run — expect failure
dotnet test tests/VolcanicCore.Tests --filter FullyQualifiedName~EncryptionServiceTests

Expected: types missing.

  • Step 3: Implement

src/VolcanicCore.Persistence/Encryption/EncryptionKeyLoader.cs:

using System.Security.Cryptography;

namespace VolcanicCore.Persistence.Encryption;

public static class EncryptionKeyLoader
{
    public static byte[] Load(string? envBase64, string keyFilePath)
    {
        if (!string.IsNullOrWhiteSpace(envBase64))
        {
            var k = Convert.FromBase64String(envBase64);
            if (k.Length != 32) throw new InvalidOperationException("ENCRYPTION_KEY must decode to 32 bytes.");
            return k;
        }

        if (File.Exists(keyFilePath))
        {
            var k = Convert.FromBase64String(File.ReadAllText(keyFilePath).Trim());
            if (k.Length != 32) throw new InvalidOperationException($"{keyFilePath} must contain a 32-byte base64 key.");
            return k;
        }

        var fresh = new byte[32];
        RandomNumberGenerator.Fill(fresh);
        File.WriteAllText(keyFilePath, Convert.ToBase64String(fresh));
        try { File.SetUnixFileMode(keyFilePath, UnixFileMode.UserRead | UnixFileMode.UserWrite); } catch { }
        return fresh;
    }
}

src/VolcanicCore.Persistence/Encryption/EncryptionService.cs:

using System.Security.Cryptography;

namespace VolcanicCore.Persistence.Encryption;

public sealed class EncryptionService
{
    private readonly byte[] _key;
    private const int NonceSize = 12;
    private const int TagSize = 16;

    public EncryptionService(byte[] key)
    {
        if (key.Length != 32) throw new ArgumentException("Key must be 32 bytes.", nameof(key));
        _key = key;
    }

    public string Encrypt(string plaintext)
    {
        var plainBytes = System.Text.Encoding.UTF8.GetBytes(plaintext);
        var nonce = new byte[NonceSize]; RandomNumberGenerator.Fill(nonce);
        var cipher = new byte[plainBytes.Length];
        var tag = new byte[TagSize];
        using var aes = new AesGcm(_key, TagSize);
        aes.Encrypt(nonce, plainBytes, cipher, tag);
        // wire format: nonce || cipher || tag
        var wire = new byte[NonceSize + cipher.Length + TagSize];
        Buffer.BlockCopy(nonce, 0, wire, 0, NonceSize);
        Buffer.BlockCopy(cipher, 0, wire, NonceSize, cipher.Length);
        Buffer.BlockCopy(tag, 0, wire, NonceSize + cipher.Length, TagSize);
        return Convert.ToBase64String(wire);
    }

    public string Decrypt(string wireBase64)
    {
        var wire = Convert.FromBase64String(wireBase64);
        if (wire.Length < NonceSize + TagSize) throw new CryptographicException("Ciphertext too short.");
        var nonce = wire.AsSpan(0, NonceSize);
        var tag = wire.AsSpan(wire.Length - TagSize, TagSize);
        var cipher = wire.AsSpan(NonceSize, wire.Length - NonceSize - TagSize);
        var plain = new byte[cipher.Length];
        using var aes = new AesGcm(_key, TagSize);
        aes.Decrypt(nonce, cipher, tag, plain);
        return System.Text.Encoding.UTF8.GetString(plain);
    }
}
  • Step 4: Wire DI in Program.cs

Before builder.Build():

using VolcanicCore.Persistence.Encryption;
// ...
var encKey = EncryptionKeyLoader.Load(settings.EncryptionKeyBase64, settings.EncryptionKeyFilePath);
builder.Services.AddSingleton(new EncryptionService(encKey));
  • Step 5: Run tests
dotnet test tests/VolcanicCore.Tests --filter FullyQualifiedName~EncryptionServiceTests

Expected: 5 PASS.

  • Step 6: Commit
git add src tests
git commit -m "feat(persistence): AES-GCM encryption service + key loader"

Task 12: Connections table + repository + REST API

Files:

  • Create: src/VolcanicCore.Persistence/Migrations/Migration002Connections.cs

  • Create: src/VolcanicCore.Persistence/Repositories/ConnectionRepository.cs

  • Create: src/VolcanicCore.Api/Contracts/ConnectionDtos.cs

  • Create: src/VolcanicCore.Api/Endpoints/ConnectionsEndpoints.cs

  • Create: tests/VolcanicCore.Tests/ConnectionsEndpointsTests.cs

  • Modify: src/VolcanicCore.Api/Program.cs

  • Step 1: Failing test

tests/VolcanicCore.Tests/ConnectionsEndpointsTests.cs:

using System.Net;
using System.Net.Http.Json;
using System.Text.Json;

namespace VolcanicCore.Tests;

public class ConnectionsEndpointsTests : IClassFixture<WebFactory>
{
    private readonly WebFactory _factory;
    public ConnectionsEndpointsTests(WebFactory f) => _factory = f;

    private async Task<HttpClient> LoggedIn()
    {
        var c = _factory.CreateClient(new() { HandleCookies = true, AllowAutoRedirect = false });
        await c.PostAsJsonAsync("/api/session", new { password = _factory.AdminPassword });
        return c;
    }

    [Fact]
    public async Task Create_list_delete_round_trip_and_secrets_are_not_returned()
    {
        var c = await LoggedIn();
        var create = await c.PostAsJsonAsync("/api/connections", new
        {
            providerId = "nats",
            name = "local",
            config = new { url = "nats://127.0.0.1:4222", user = "u", pass = "p" }
        });
        Assert.Equal(HttpStatusCode.Created, create.StatusCode);
        var created = await create.Content.ReadFromJsonAsync<ConnectionDto>();
        Assert.NotNull(created);
        Assert.False(string.IsNullOrEmpty(created!.Id));

        var list = await c.GetFromJsonAsync<List<ConnectionDto>>("/api/connections");
        Assert.Contains(list!, x => x.Id == created.Id && x.Name == "local");

        var get = await c.GetFromJsonAsync<ConnectionDto>($"/api/connections/{created.Id}");
        Assert.NotNull(get);
        // Config on GET must not contain the plaintext secret (only non-secret fields).
        Assert.False(get!.Config.EnumerateObject().Any(p => p.Name == "pass"));

        var del = await c.DeleteAsync($"/api/connections/{created.Id}");
        Assert.Equal(HttpStatusCode.NoContent, del.StatusCode);

        var list2 = await c.GetFromJsonAsync<List<ConnectionDto>>("/api/connections");
        Assert.DoesNotContain(list2!, x => x.Id == created.Id);
    }

    private sealed record ConnectionDto(string Id, string ProviderId, string Name, JsonElement Config);
}

Note: the test asserts secrets aren't returned. This plan achieves that by having the provider declare which fields are secret via a secretFields array on its configSchema (["pass"] for NATS in Plan 2). For Plan 1 we hard-code an empty secret set — since no real providers are registered yet, the ConnectionsEndpoints code path that strips secret fields is exercised only in Plan 2. To make the current test meaningful, POST /api/connections accepts arbitrary providerId in Plan 1 (validation waits until Plan 2 wires providers). Adjust the test to accept a fixed set of hard-coded secret field names — but that hides the design. Instead, we introduce a light indirection: an IConnectionSecretPolicy service that returns the secret field names for a given provider id. Plan 1 ships a default HardcodedSecretPolicy that returns ["pass", "password", "token", "secret"] heuristically. Plan 2 replaces it with one driven by the registered provider's schema.

Add the DTO type to the test as shown.

  • Step 2: Migration

src/VolcanicCore.Persistence/Migrations/Migration002Connections.cs:

using Microsoft.Data.Sqlite;

namespace VolcanicCore.Persistence.Migrations;

public sealed class Migration002Connections : IMigration
{
    public int Version => 2;
    public void Apply(SqliteConnection conn)
    {
        using var cmd = conn.CreateCommand();
        cmd.CommandText = @"
            CREATE TABLE connections (
                id                    TEXT PRIMARY KEY,
                provider_id           TEXT NOT NULL,
                name                  TEXT NOT NULL,
                config_json_encrypted TEXT NOT NULL,
                created_at            TEXT NOT NULL
            );
            CREATE UNIQUE INDEX ux_connections_name_per_provider
                ON connections (provider_id, name);
        ";
        cmd.ExecuteNonQuery();
    }
}
  • Step 3: Repository

src/VolcanicCore.Persistence/Repositories/ConnectionRepository.cs:

using Microsoft.Data.Sqlite;
using VolcanicCore.Persistence.Encryption;

namespace VolcanicCore.Persistence.Repositories;

public sealed record ConnectionRow(string Id, string ProviderId, string Name,
    string ConfigJson, DateTimeOffset CreatedAt);

public sealed class ConnectionRepository
{
    private readonly SqliteConnectionFactory _factory;
    private readonly EncryptionService _enc;

    public ConnectionRepository(SqliteConnectionFactory factory, EncryptionService enc)
    { _factory = factory; _enc = enc; }

    public string Create(string providerId, string name, string configJson)
    {
        var id = Guid.NewGuid().ToString("N");
        using var conn = _factory.Open();
        using var cmd = conn.CreateCommand();
        cmd.CommandText = @"
            INSERT INTO connections (id, provider_id, name, config_json_encrypted, created_at)
            VALUES ($id, $pid, $name, $enc, $at);
        ";
        cmd.Parameters.AddWithValue("$id", id);
        cmd.Parameters.AddWithValue("$pid", providerId);
        cmd.Parameters.AddWithValue("$name", name);
        cmd.Parameters.AddWithValue("$enc", _enc.Encrypt(configJson));
        cmd.Parameters.AddWithValue("$at", DateTimeOffset.UtcNow.ToString("o"));
        cmd.ExecuteNonQuery();
        return id;
    }

    public ConnectionRow? Get(string id)
    {
        using var conn = _factory.Open();
        using var cmd = conn.CreateCommand();
        cmd.CommandText = @"SELECT id, provider_id, name, config_json_encrypted, created_at
                            FROM connections WHERE id = $id;";
        cmd.Parameters.AddWithValue("$id", id);
        using var r = cmd.ExecuteReader();
        if (!r.Read()) return null;
        return new ConnectionRow(
            r.GetString(0), r.GetString(1), r.GetString(2),
            _enc.Decrypt(r.GetString(3)),
            DateTimeOffset.Parse(r.GetString(4)));
    }

    public IReadOnlyList<ConnectionRow> List()
    {
        var rows = new List<ConnectionRow>();
        using var conn = _factory.Open();
        using var cmd = conn.CreateCommand();
        cmd.CommandText = @"SELECT id, provider_id, name, config_json_encrypted, created_at
                            FROM connections ORDER BY created_at DESC;";
        using var r = cmd.ExecuteReader();
        while (r.Read())
            rows.Add(new ConnectionRow(
                r.GetString(0), r.GetString(1), r.GetString(2),
                _enc.Decrypt(r.GetString(3)),
                DateTimeOffset.Parse(r.GetString(4))));
        return rows;
    }

    public bool Delete(string id)
    {
        using var conn = _factory.Open();
        using var cmd = conn.CreateCommand();
        cmd.CommandText = @"DELETE FROM connections WHERE id = $id;";
        cmd.Parameters.AddWithValue("$id", id);
        return cmd.ExecuteNonQuery() == 1;
    }
}
  • Step 4: Secret policy

Add src/VolcanicCore.Api/Auth/HardcodedSecretPolicy.cs:

namespace VolcanicCore.Api.Auth;

public interface IConnectionSecretPolicy
{
    IReadOnlySet<string> SecretFieldsFor(string providerId);
}

public sealed class HardcodedSecretPolicy : IConnectionSecretPolicy
{
    private static readonly HashSet<string> Fields =
        new(StringComparer.OrdinalIgnoreCase) { "pass", "password", "token", "secret", "apiKey", "api_key" };
    public IReadOnlySet<string> SecretFieldsFor(string providerId) => Fields;
}
  • Step 5: DTOs + endpoints

src/VolcanicCore.Api/Contracts/ConnectionDtos.cs:

using System.Text.Json;

namespace VolcanicCore.Api.Contracts;

public sealed record CreateConnectionRequest(string ProviderId, string Name, JsonElement Config);
public sealed record ConnectionResponse(string Id, string ProviderId, string Name, JsonElement Config);

src/VolcanicCore.Api/Endpoints/ConnectionsEndpoints.cs:

using System.Text.Json;
using VolcanicCore.Api.Auth;
using VolcanicCore.Api.Contracts;
using VolcanicCore.Persistence.Repositories;

namespace VolcanicCore.Api.Endpoints;

public static class ConnectionsEndpoints
{
    public static void MapConnectionsEndpoints(this IEndpointRouteBuilder app)
    {
        var g = app.MapGroup("/api/connections").RequireAuthorization();

        g.MapGet("", (ConnectionRepository repo, IConnectionSecretPolicy policy) =>
        {
            var rows = repo.List().Select(r => new ConnectionResponse(
                r.Id, r.ProviderId, r.Name, StripSecrets(r.ConfigJson, policy.SecretFieldsFor(r.ProviderId))));
            return Results.Ok(rows);
        });

        g.MapGet("/{id}", (string id, ConnectionRepository repo, IConnectionSecretPolicy policy) =>
        {
            var r = repo.Get(id);
            return r is null
                ? Results.NotFound()
                : Results.Ok(new ConnectionResponse(r.Id, r.ProviderId, r.Name,
                    StripSecrets(r.ConfigJson, policy.SecretFieldsFor(r.ProviderId))));
        });

        g.MapPost("", (CreateConnectionRequest req, ConnectionRepository repo, IConnectionSecretPolicy policy) =>
        {
            var id = repo.Create(req.ProviderId, req.Name, req.Config.GetRawText());
            var stripped = StripSecrets(req.Config.GetRawText(), policy.SecretFieldsFor(req.ProviderId));
            return Results.Created($"/api/connections/{id}",
                new ConnectionResponse(id, req.ProviderId, req.Name, stripped));
        });

        g.MapDelete("/{id}", (string id, ConnectionRepository repo) =>
            repo.Delete(id) ? Results.NoContent() : Results.NotFound());
    }

    private static JsonElement StripSecrets(string configJson, IReadOnlySet<string> secretFields)
    {
        using var doc = JsonDocument.Parse(configJson);
        var stripped = new Dictionary<string, JsonElement>();
        foreach (var p in doc.RootElement.EnumerateObject())
        {
            if (secretFields.Contains(p.Name)) continue;
            stripped[p.Name] = p.Value.Clone();
        }
        return JsonSerializer.SerializeToElement(stripped);
    }
}
  • Step 6: Wire migration + DI + endpoint in Program.cs

  • Register new Migration002Connections() in the Migrator factory alongside Migration001Initial.

  • Register services:

    builder.Services.AddSingleton<ConnectionRepository>();
    builder.Services.AddSingleton<IConnectionSecretPolicy, HardcodedSecretPolicy>();
    
  • Add before app.Run(): app.MapConnectionsEndpoints();

  • Step 7: Run tests

dotnet test tests/VolcanicCore.Tests --filter FullyQualifiedName~ConnectionsEndpointsTests

Expected: PASS.

  • Step 8: Commit
git add src tests
git commit -m "feat(api): connections CRUD with encrypted storage + secret masking"

Task 13: Workflows table + repository + REST API (data only)

Files:

  • Create: src/VolcanicCore.Persistence/Migrations/Migration003Workflows.cs

  • Create: src/VolcanicCore.Persistence/Repositories/WorkflowRepository.cs

  • Create: src/VolcanicCore.Api/Contracts/WorkflowDtos.cs

  • Create: src/VolcanicCore.Api/Endpoints/WorkflowsEndpoints.cs

  • Create: tests/VolcanicCore.Tests/WorkflowsEndpointsTests.cs

  • Modify: src/VolcanicCore.Api/Program.cs

  • Step 1: Failing test

tests/VolcanicCore.Tests/WorkflowsEndpointsTests.cs:

using System.Net;
using System.Net.Http.Json;

namespace VolcanicCore.Tests;

public class WorkflowsEndpointsTests : IClassFixture<WebFactory>
{
    private readonly WebFactory _factory;
    public WorkflowsEndpointsTests(WebFactory f) => _factory = f;

    private async Task<HttpClient> LoggedIn()
    {
        var c = _factory.CreateClient(new() { HandleCookies = true, AllowAutoRedirect = false });
        await c.PostAsJsonAsync("/api/session", new { password = _factory.AdminPassword });
        return c;
    }

    private static object EmptyGraph() => new { nodes = new object[0], edges = new object[0] };

    [Fact]
    public async Task Full_crud_round_trip()
    {
        var c = await LoggedIn();
        var create = await c.PostAsJsonAsync("/api/workflows", new { name = "hello", graph = EmptyGraph() });
        Assert.Equal(HttpStatusCode.Created, create.StatusCode);
        var created = await create.Content.ReadFromJsonAsync<WorkflowDto>();
        Assert.NotNull(created);
        Assert.False(created!.Active);

        var got = await c.GetFromJsonAsync<WorkflowDto>($"/api/workflows/{created.Id}");
        Assert.Equal("hello", got!.Name);

        var upd = await c.PutAsJsonAsync($"/api/workflows/{created.Id}",
            new { name = "renamed", graph = EmptyGraph() });
        Assert.Equal(HttpStatusCode.NoContent, upd.StatusCode);

        var toggle = await c.PostAsync($"/api/workflows/{created.Id}/activate", null);
        Assert.Equal(HttpStatusCode.NoContent, toggle.StatusCode);
        var after = await c.GetFromJsonAsync<WorkflowDto>($"/api/workflows/{created.Id}");
        Assert.True(after!.Active);
        Assert.Equal("renamed", after.Name);

        var deact = await c.PostAsync($"/api/workflows/{created.Id}/deactivate", null);
        Assert.Equal(HttpStatusCode.NoContent, deact.StatusCode);

        var del = await c.DeleteAsync($"/api/workflows/{created.Id}");
        Assert.Equal(HttpStatusCode.NoContent, del.StatusCode);

        var missing = await c.GetAsync($"/api/workflows/{created.Id}");
        Assert.Equal(HttpStatusCode.NotFound, missing.StatusCode);
    }

    private sealed record WorkflowDto(string Id, string Name, bool Active,
        System.Text.Json.JsonElement Graph, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt);
}
  • Step 2: Migration

src/VolcanicCore.Persistence/Migrations/Migration003Workflows.cs:

using Microsoft.Data.Sqlite;

namespace VolcanicCore.Persistence.Migrations;

public sealed class Migration003Workflows : IMigration
{
    public int Version => 3;
    public void Apply(SqliteConnection conn)
    {
        using var cmd = conn.CreateCommand();
        cmd.CommandText = @"
            CREATE TABLE workflows (
                id         TEXT PRIMARY KEY,
                name       TEXT NOT NULL,
                graph_json TEXT NOT NULL,
                active     INTEGER NOT NULL DEFAULT 0,
                created_at TEXT NOT NULL,
                updated_at TEXT NOT NULL
            );
        ";
        cmd.ExecuteNonQuery();
    }
}
  • Step 3: Repository

src/VolcanicCore.Persistence/Repositories/WorkflowRepository.cs:

using Microsoft.Data.Sqlite;

namespace VolcanicCore.Persistence.Repositories;

public sealed record WorkflowRow(string Id, string Name, string GraphJson, bool Active,
    DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt);

public sealed class WorkflowRepository
{
    private readonly SqliteConnectionFactory _factory;
    public WorkflowRepository(SqliteConnectionFactory f) => _factory = f;

    public string Create(string name, string graphJson)
    {
        var id = Guid.NewGuid().ToString("N");
        var now = DateTimeOffset.UtcNow.ToString("o");
        using var conn = _factory.Open();
        using var cmd = conn.CreateCommand();
        cmd.CommandText = @"
            INSERT INTO workflows (id, name, graph_json, active, created_at, updated_at)
            VALUES ($id, $name, $graph, 0, $now, $now);
        ";
        cmd.Parameters.AddWithValue("$id", id);
        cmd.Parameters.AddWithValue("$name", name);
        cmd.Parameters.AddWithValue("$graph", graphJson);
        cmd.Parameters.AddWithValue("$now", now);
        cmd.ExecuteNonQuery();
        return id;
    }

    public WorkflowRow? Get(string id)
    {
        using var conn = _factory.Open();
        using var cmd = conn.CreateCommand();
        cmd.CommandText = @"SELECT id, name, graph_json, active, created_at, updated_at
                            FROM workflows WHERE id = $id;";
        cmd.Parameters.AddWithValue("$id", id);
        using var r = cmd.ExecuteReader();
        return r.Read() ? Materialize(r) : null;
    }

    public IReadOnlyList<WorkflowRow> List()
    {
        var rows = new List<WorkflowRow>();
        using var conn = _factory.Open();
        using var cmd = conn.CreateCommand();
        cmd.CommandText = @"SELECT id, name, graph_json, active, created_at, updated_at
                            FROM workflows ORDER BY created_at DESC;";
        using var r = cmd.ExecuteReader();
        while (r.Read()) rows.Add(Materialize(r));
        return rows;
    }

    public bool Update(string id, string name, string graphJson)
    {
        using var conn = _factory.Open();
        using var cmd = conn.CreateCommand();
        cmd.CommandText = @"UPDATE workflows SET name = $name, graph_json = $graph,
                            updated_at = $now WHERE id = $id;";
        cmd.Parameters.AddWithValue("$id", id);
        cmd.Parameters.AddWithValue("$name", name);
        cmd.Parameters.AddWithValue("$graph", graphJson);
        cmd.Parameters.AddWithValue("$now", DateTimeOffset.UtcNow.ToString("o"));
        return cmd.ExecuteNonQuery() == 1;
    }

    public bool SetActive(string id, bool active)
    {
        using var conn = _factory.Open();
        using var cmd = conn.CreateCommand();
        cmd.CommandText = @"UPDATE workflows SET active = $a, updated_at = $now WHERE id = $id;";
        cmd.Parameters.AddWithValue("$id", id);
        cmd.Parameters.AddWithValue("$a", active ? 1 : 0);
        cmd.Parameters.AddWithValue("$now", DateTimeOffset.UtcNow.ToString("o"));
        return cmd.ExecuteNonQuery() == 1;
    }

    public bool Delete(string id)
    {
        using var conn = _factory.Open();
        using var cmd = conn.CreateCommand();
        cmd.CommandText = @"DELETE FROM workflows WHERE id = $id;";
        cmd.Parameters.AddWithValue("$id", id);
        return cmd.ExecuteNonQuery() == 1;
    }

    private static WorkflowRow Materialize(SqliteDataReader r) => new(
        r.GetString(0), r.GetString(1), r.GetString(2), r.GetInt64(3) != 0,
        DateTimeOffset.Parse(r.GetString(4)), DateTimeOffset.Parse(r.GetString(5)));
}
  • Step 4: DTOs + endpoints

src/VolcanicCore.Api/Contracts/WorkflowDtos.cs:

using System.Text.Json;

namespace VolcanicCore.Api.Contracts;

public sealed record CreateWorkflowRequest(string Name, JsonElement Graph);
public sealed record UpdateWorkflowRequest(string Name, JsonElement Graph);
public sealed record WorkflowResponse(string Id, string Name, bool Active,
    JsonElement Graph, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt);

src/VolcanicCore.Api/Endpoints/WorkflowsEndpoints.cs:

using System.Text.Json;
using VolcanicCore.Api.Contracts;
using VolcanicCore.Persistence.Repositories;

namespace VolcanicCore.Api.Endpoints;

public static class WorkflowsEndpoints
{
    public static void MapWorkflowsEndpoints(this IEndpointRouteBuilder app)
    {
        var g = app.MapGroup("/api/workflows").RequireAuthorization();

        g.MapGet("", (WorkflowRepository repo) =>
            Results.Ok(repo.List().Select(ToDto)));

        g.MapGet("/{id}", (string id, WorkflowRepository repo) =>
        {
            var w = repo.Get(id);
            return w is null ? Results.NotFound() : Results.Ok(ToDto(w));
        });

        g.MapPost("", (CreateWorkflowRequest req, WorkflowRepository repo) =>
        {
            var id = repo.Create(req.Name, req.Graph.GetRawText());
            var w = repo.Get(id)!;
            return Results.Created($"/api/workflows/{id}", ToDto(w));
        });

        g.MapPut("/{id}", (string id, UpdateWorkflowRequest req, WorkflowRepository repo) =>
            repo.Update(id, req.Name, req.Graph.GetRawText())
                ? Results.NoContent() : Results.NotFound());

        g.MapPost("/{id}/activate", (string id, WorkflowRepository repo) =>
            repo.SetActive(id, true) ? Results.NoContent() : Results.NotFound());

        g.MapPost("/{id}/deactivate", (string id, WorkflowRepository repo) =>
            repo.SetActive(id, false) ? Results.NoContent() : Results.NotFound());

        g.MapDelete("/{id}", (string id, WorkflowRepository repo) =>
            repo.Delete(id) ? Results.NoContent() : Results.NotFound());
    }

    private static WorkflowResponse ToDto(WorkflowRow w)
    {
        using var doc = JsonDocument.Parse(w.GraphJson);
        return new WorkflowResponse(w.Id, w.Name, w.Active, doc.RootElement.Clone(),
            w.CreatedAt, w.UpdatedAt);
    }
}
  • Step 5: Wire up in Program.cs

  • Register new Migration003Workflows() in the Migrator factory alongside 001 and 002.

  • builder.Services.AddSingleton<WorkflowRepository>();

  • Before app.Run(): app.MapWorkflowsEndpoints();

  • Step 6: Run tests

dotnet test tests/VolcanicCore.Tests --filter FullyQualifiedName~WorkflowsEndpointsTests

Expected: PASS.

  • Step 7: Commit
git add src tests
git commit -m "feat(api): workflows CRUD + activate/deactivate flag"

Task 14: Full test suite + smoke a live server

  • Step 1: Run every test
dotnet test

Expected: all tests pass.

  • Step 2: Boot the real server, exercise auth + CRUD via curl
rm -rf ./_smoke && mkdir -p ./_smoke
DATA_DIRECTORY=./_smoke ADMIN_PASSWORD=hunter2 dotnet run --project src/VolcanicCore.Api --urls http://127.0.0.1:5099 &
SERVER=$!
sleep 3

# Login
curl -s -c ./_smoke/jar -X POST -H 'content-type: application/json' \
  -d '{"password":"hunter2"}' -o /dev/null -w "login:%{http_code}\n" \
  http://127.0.0.1:5099/api/session

# Discovery
curl -s -b ./_smoke/jar http://127.0.0.1:5099/api/node-types
echo
curl -s -b ./_smoke/jar http://127.0.0.1:5099/api/connection-providers
echo

# Create + list workflow
curl -s -b ./_smoke/jar -X POST -H 'content-type: application/json' \
  -d '{"name":"hello","graph":{"nodes":[],"edges":[]}}' \
  http://127.0.0.1:5099/api/workflows
echo
curl -s -b ./_smoke/jar http://127.0.0.1:5099/api/workflows

kill $SERVER
rm -rf ./_smoke

Expected: login:204, empty [] from discovery, created workflow JSON returned, list contains it.

  • Step 3: Commit no code (this is a verification step). If anything failed, fix inline and add regression tests before continuing to Plan 2.

What Plan 2 delivers (preview, not in this plan)

  • Executions + node_executions tables and repositories.
  • INodeContext concrete implementation and Executor with the in-degree scheduler + failure propagation.
  • Manual test-run endpoint POST /api/workflows/{id}/executions.
  • WebhookRouter + POST /wh/{workflow-id}/{*path} HTTP endpoint.
  • TriggerHost lifecycle: activating a workflow starts its triggers, deactivating disposes them.
  • NATS IConnectionProvider + NatsHookNode + NatsPublishNode.
  • WebhookNode + WebResponseNode.
  • PythonTransformNode backed by IronPython 3.
  • Executions REST API.
  • Retention BackgroundService.
  • IConnectionSecretPolicy upgraded to read secretFields off each provider's schema (replacing the hard-coded heuristic).