Files
Assistant/docs/superpowers/plans/2026-06-11-smart-assistant-backend-foundation.md
tes cf055c8928 Plan 1: backend foundation + pairing + Coolify deploy
18 bite-sized tasks taking the repo from empty to a deployed ASP.NET app
where users can sign up, generate pairing codes, and pair a device via
curl. Includes data-protection keys, multi-stage Dockerfile, and the
end-to-end smoke test. Defers WS hub, Realtime relay, tools, the real
Python client, and the React UI to Plans 2-4.
2026-06-11 17:48:18 +00:00

62 KiB

Smart Assistant — Backend Foundation Implementation Plan

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 backend skeleton with Identity, devices, pairing-code flow, install endpoints, and deploy it to Coolify — enough that a curl flow can pair a device, but with no voice / Realtime / tool plane yet.

Architecture: Single ASP.NET 9 project using Minimal APIs, EF Core + SQLite (file on a Coolify named volume), cookie-based ASP.NET Identity. Module folders (Auth/, Devices/, Pairing/, Install/, Settings/, Data/) each expose an IEndpointRouteBuilder extension that Program.cs calls. Python client/ directory holds placeholder files so the Dockerfile builds a non-empty client.tar.gz. Deploys triggered fire-and-forget via the Coolify v4 API per the global CLAUDE.md rule.

Tech Stack: .NET 9, ASP.NET Minimal APIs, EF Core 9 + Microsoft.EntityFrameworkCore.Sqlite, ASP.NET Identity (cookie auth), Microsoft.AspNetCore.Mvc.Testing for integration tests, xUnit, Docker multi-stage build, Coolify v4 REST API.

Scope (in): Solution skeleton, EF + SQLite, Identity + roles + admin seed, devices CRUD, device config defaults, pairing-code service + endpoints, install.sh + client.tar.gz serving, Dockerfile, Coolify deploy + smoke test.

Scope (out, later plans): Device WebSocket hub (Plan 2), Realtime relay (Plan 2), tools registry (Plan 2), full Python client (Plan 3), React UI + remaining admin endpoints (Plan 4).

Reference spec: docs/superpowers/specs/2026-06-11-smart-assistant-design.md


File structure

This plan creates the following files. Each module owns one concern.

Assistant/
├── Assistant.sln
├── Dockerfile
├── .dockerignore
├── deploy.json                              # Coolify config (project/server UUIDs, domain)
├── README.md                                # smoke-test instructions
├── client/                                  # Python placeholders (Plan 3 fills these in)
│   ├── __init__.py
│   └── main.py                              # `def main(): print("client placeholder")`
├── requirements.txt
├── requirements-nodeps.txt
├── backend/
│   ├── backend.csproj
│   ├── Program.cs
│   ├── appsettings.json
│   ├── appsettings.Development.json
│   ├── Auth/
│   │   ├── ApplicationUser.cs
│   │   ├── AuthEndpoints.cs                 # /api/auth/{register,login,logout,me}
│   │   ├── Roles.cs                         # static class with Admin/User string consts
│   │   └── IdentitySetup.cs                 # AddIdentity + role seeding + admin seeding
│   ├── Devices/
│   │   ├── Device.cs
│   │   ├── DeviceConfig.cs
│   │   ├── DeviceTokenService.cs            # generate + sha256 + verify
│   │   └── DevicesEndpoints.cs              # /api/devices CRUD
│   ├── Pairing/
│   │   ├── PairingCode.cs
│   │   ├── PairingService.cs                # generate, validate, consume
│   │   └── PairingEndpoints.cs              # /api/pair-code (auth) + /api/pair (public)
│   ├── Settings/
│   │   └── SystemSettings.cs                # singleton entity + seeder
│   ├── Install/
│   │   ├── InstallScriptBuilder.cs          # builds install.sh body
│   │   └── InstallEndpoints.cs              # /install.sh + /client.tar.gz
│   ├── Data/
│   │   ├── AppDbContext.cs
│   │   └── Migrations/                      # populated by EF
│   └── wwwroot/                             # filled at build: client.tar.gz; SPA dist later
└── backend.tests/
    ├── backend.tests.csproj
    ├── ApiFactory.cs                        # WebApplicationFactory<Program> for tests
    ├── AuthTests.cs
    ├── DeviceTokenServiceTests.cs
    ├── PairingServiceTests.cs
    ├── PairingEndpointsTests.cs
    ├── DevicesEndpointsTests.cs
    └── InstallEndpointsTests.cs

Task 1: Scaffold solution and projects

Files:

  • Create: Assistant.sln

  • Create: backend/backend.csproj

  • Create: backend/Program.cs

  • Create: backend.tests/backend.tests.csproj

  • Step 1: Create the solution and projects

Run from the repo root:

dotnet new sln -n Assistant
dotnet new web -o backend -n backend --framework net9.0
dotnet new xunit -o backend.tests -n backend.tests --framework net9.0
dotnet sln Assistant.sln add backend/backend.csproj backend.tests/backend.tests.csproj
dotnet add backend.tests/backend.tests.csproj reference backend/backend.csproj
  • Step 2: Make Program public so tests can reach it

Append to backend/Program.cs:

public partial class Program { }
  • Step 3: Add test framework packages
dotnet add backend.tests package Microsoft.AspNetCore.Mvc.Testing
dotnet add backend.tests package FluentAssertions
  • Step 4: Verify the build

Run: dotnet build Expected: Build succeeded. with 0 errors, 0 warnings.

  • Step 5: Commit
git add Assistant.sln backend backend.tests
git commit -m "Scaffold backend + backend.tests projects"

Task 2: Add EF Core + SQLite + empty AppDbContext

Files:

  • Modify: backend/backend.csproj

  • Create: backend/Data/AppDbContext.cs

  • Modify: backend/Program.cs

  • Modify: backend/appsettings.json

  • Step 1: Add EF Core packages

dotnet add backend package Microsoft.EntityFrameworkCore.Sqlite
dotnet add backend package Microsoft.EntityFrameworkCore.Design
  • Step 2: Create the empty DbContext

Create backend/Data/AppDbContext.cs:

using Microsoft.EntityFrameworkCore;

namespace backend.Data;

public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
}
  • Step 3: Configure connection string

Replace backend/appsettings.json:

{
  "ConnectionStrings": {
    "Default": "Data Source=./assistant.db"
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*"
}
  • Step 4: Wire DbContext in Program.cs

Replace backend/Program.cs:

using backend.Data;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<AppDbContext>(opt =>
    opt.UseSqlite(builder.Configuration.GetConnectionString("Default")));

var app = builder.Build();

using (var scope = app.Services.CreateScope())
{
    var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    db.Database.Migrate();
}

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

app.Run();

public partial class Program { }
  • Step 5: Install dotnet-ef tool and create the initial migration
dotnet tool install --global dotnet-ef
dotnet ef migrations add Initial --project backend --startup-project backend

Expected: a new backend/Migrations/ directory with three files.

  • Step 6: Run the app and hit /health
dotnet run --project backend &
sleep 2
curl -s http://localhost:5000/health
kill %1

Expected: {"ok":true}. A backend/assistant.db file should be created.

  • Step 7: Commit
git add backend/backend.csproj backend/Data backend/Program.cs backend/appsettings.json backend/Migrations
git commit -m "Add EF Core + SQLite with empty AppDbContext"

Task 3: Add ApiFactory test helper

Files:

  • Create: backend.tests/ApiFactory.cs

  • Modify: backend.tests/backend.tests.csproj (no manual change — packages added via CLI)

  • Step 1: Write the test helper

Create backend.tests/ApiFactory.cs:

using System.IO;
using backend.Data;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;

namespace backend.tests;

public class ApiFactory : WebApplicationFactory<Program>
{
    private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"assistant-test-{Guid.NewGuid():N}.db");

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.UseEnvironment("Test");
        builder.ConfigureAppConfiguration((_, cfg) =>
        {
            cfg.AddInMemoryCollection(new Dictionary<string, string?>
            {
                ["ConnectionStrings:Default"] = $"Data Source={_dbPath}",
            });
        });
    }

    protected override void Dispose(bool disposing)
    {
        base.Dispose(disposing);
        if (File.Exists(_dbPath))
            File.Delete(_dbPath);
    }
}
  • Step 2: Write a sanity test against /health

Replace backend.tests/UnitTest1.cs with backend.tests/HealthTests.cs:

using System.Net;
using FluentAssertions;
using Xunit;

namespace backend.tests;

public class HealthTests(ApiFactory factory) : IClassFixture<ApiFactory>
{
    private readonly ApiFactory _factory = factory;

    [Fact]
    public async Task Health_returns_ok()
    {
        var client = _factory.CreateClient();
        var res = await client.GetAsync("/health");
        res.StatusCode.Should().Be(HttpStatusCode.OK);
        (await res.Content.ReadAsStringAsync()).Should().Contain("\"ok\":true");
    }
}

Delete backend.tests/UnitTest1.cs if present.

  • Step 3: Run tests

Run: dotnet test Expected: Passed: 1, Failed: 0.

  • Step 4: Commit
git add backend.tests
git commit -m "Add ApiFactory test helper + /health sanity test"

Files:

  • Create: backend/Auth/ApplicationUser.cs

  • Create: backend/Auth/Roles.cs

  • Create: backend/Auth/IdentitySetup.cs

  • Modify: backend/backend.csproj (via CLI)

  • Modify: backend/Data/AppDbContext.cs

  • Modify: backend/Program.cs

  • Step 1: Add Identity packages

dotnet add backend package Microsoft.AspNetCore.Identity.EntityFrameworkCore
  • Step 2: Define ApplicationUser

Create backend/Auth/ApplicationUser.cs:

using Microsoft.AspNetCore.Identity;

namespace backend.Auth;

public class ApplicationUser : IdentityUser<Guid>
{
}
  • Step 3: Define Roles constants

Create backend/Auth/Roles.cs:

namespace backend.Auth;

public static class Roles
{
    public const string Admin = "Admin";
    public const string User = "User";

    public static readonly string[] All = { Admin, User };
}
  • Step 4: Change AppDbContext to inherit from IdentityDbContext

Replace backend/Data/AppDbContext.cs:

using backend.Auth;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;

namespace backend.Data;

public class AppDbContext(DbContextOptions<AppDbContext> options)
    : IdentityDbContext<ApplicationUser, IdentityRole<Guid>, Guid>(options)
{
}
  • Step 5: Set up Identity + roles seeding

Create backend/Auth/IdentitySetup.cs:

using backend.Data;
using Microsoft.AspNetCore.Identity;

namespace backend.Auth;

public static class IdentitySetup
{
    public static IServiceCollection AddAppIdentity(this IServiceCollection services)
    {
        services.AddIdentity<ApplicationUser, IdentityRole<Guid>>(opt =>
        {
            opt.Password.RequiredLength = 8;
            opt.Password.RequireNonAlphanumeric = false;
            opt.User.RequireUniqueEmail = true;
            opt.SignIn.RequireConfirmedAccount = false;
        })
        .AddEntityFrameworkStores<AppDbContext>()
        .AddDefaultTokenProviders();

        services.ConfigureApplicationCookie(opt =>
        {
            opt.Cookie.HttpOnly = true;
            opt.Cookie.SameSite = SameSiteMode.Lax;
            opt.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
            opt.ExpireTimeSpan = TimeSpan.FromDays(30);
            opt.SlidingExpiration = true;
            opt.Events.OnRedirectToLogin = ctx =>
            {
                ctx.Response.StatusCode = 401;
                return Task.CompletedTask;
            };
            opt.Events.OnRedirectToAccessDenied = ctx =>
            {
                ctx.Response.StatusCode = 403;
                return Task.CompletedTask;
            };
        });

        return services;
    }

    public static async Task SeedRolesAsync(IServiceProvider sp)
    {
        var roleMgr = sp.GetRequiredService<RoleManager<IdentityRole<Guid>>>();
        foreach (var name in Roles.All)
        {
            if (!await roleMgr.RoleExistsAsync(name))
                await roleMgr.CreateAsync(new IdentityRole<Guid>(name));
        }
    }
}
  • Step 6: Wire Identity into Program.cs

Replace backend/Program.cs:

using backend.Auth;
using backend.Data;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<AppDbContext>(opt =>
    opt.UseSqlite(builder.Configuration.GetConnectionString("Default")));

builder.Services.AddAppIdentity();
builder.Services.AddAuthorization();

var app = builder.Build();

using (var scope = app.Services.CreateScope())
{
    var sp = scope.ServiceProvider;
    sp.GetRequiredService<AppDbContext>().Database.Migrate();
    await IdentitySetup.SeedRolesAsync(sp);
}

app.UseAuthentication();
app.UseAuthorization();

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

app.Run();

public partial class Program { }
  • Step 7: Generate Identity migration
dotnet ef migrations add AddIdentity --project backend --startup-project backend

Expected: a new migration named *_AddIdentity.cs adding AspNet* tables.

  • Step 8: Run tests (should still pass — /health unchanged)

Run: dotnet test Expected: Passed: 1.

  • Step 9: Commit
git add backend backend.tests
git commit -m "Add ASP.NET Identity (cookie auth) and role seeding"

Task 5: Auth endpoints (register, login, logout, /me)

Files:

  • Create: backend/Auth/AuthEndpoints.cs

  • Create: backend.tests/AuthTests.cs

  • Modify: backend/Program.cs

  • Step 1: Write the failing test

Create backend.tests/AuthTests.cs:

using System.Net;
using System.Net.Http.Json;
using FluentAssertions;
using Xunit;

namespace backend.tests;

public class AuthTests(ApiFactory factory) : IClassFixture<ApiFactory>
{
    private readonly ApiFactory _factory = factory;

    [Fact]
    public async Task Register_then_login_then_me_returns_user()
    {
        var client = _factory.CreateClient();

        var reg = await client.PostAsJsonAsync("/api/auth/register",
            new { email = "first@example.com", password = "Passw0rd!" });
        reg.StatusCode.Should().Be(HttpStatusCode.OK);

        var login = await client.PostAsJsonAsync("/api/auth/login",
            new { email = "first@example.com", password = "Passw0rd!" });
        login.StatusCode.Should().Be(HttpStatusCode.OK);

        var me = await client.GetAsync("/api/auth/me");
        me.StatusCode.Should().Be(HttpStatusCode.OK);
        (await me.Content.ReadAsStringAsync()).Should().Contain("first@example.com");
    }

    [Fact]
    public async Task Me_without_login_returns_401()
    {
        var client = _factory.CreateClient();
        var res = await client.GetAsync("/api/auth/me");
        res.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
    }

    [Fact]
    public async Task First_user_is_admin()
    {
        var client = _factory.CreateClient();
        await client.PostAsJsonAsync("/api/auth/register",
            new { email = "first@example.com", password = "Passw0rd!" });
        await client.PostAsJsonAsync("/api/auth/login",
            new { email = "first@example.com", password = "Passw0rd!" });

        var me = await client.GetAsync("/api/auth/me");
        (await me.Content.ReadAsStringAsync()).Should().Contain("\"isAdmin\":true");
    }
}
  • Step 2: Run the test to see it fail

Run: dotnet test --filter AuthTests Expected: 3 failures (endpoints not defined).

  • Step 3: Implement the endpoints

Create backend/Auth/AuthEndpoints.cs:

using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

namespace backend.Auth;

public record RegisterRequest(string Email, string Password);
public record LoginRequest(string Email, string Password);

public static class AuthEndpoints
{
    public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder app)
    {
        var grp = app.MapGroup("/api/auth");

        grp.MapPost("/register", async (
            [FromBody] RegisterRequest req,
            UserManager<ApplicationUser> users,
            RoleManager<IdentityRole<Guid>> roles) =>
        {
            var user = new ApplicationUser { UserName = req.Email, Email = req.Email };
            var create = await users.CreateAsync(user, req.Password);
            if (!create.Succeeded)
                return Results.BadRequest(new { errors = create.Errors.Select(e => e.Description) });

            var anyAdmin = await users.GetUsersInRoleAsync(Roles.Admin);
            var role = anyAdmin.Count == 0 ? Roles.Admin : Roles.User;
            await users.AddToRoleAsync(user, role);
            return Results.Ok(new { id = user.Id, email = user.Email, role });
        });

        grp.MapPost("/login", async (
            [FromBody] LoginRequest req,
            SignInManager<ApplicationUser> signIn) =>
        {
            var res = await signIn.PasswordSignInAsync(req.Email, req.Password,
                isPersistent: true, lockoutOnFailure: false);
            return res.Succeeded ? Results.Ok() : Results.Unauthorized();
        });

        grp.MapPost("/logout", async (SignInManager<ApplicationUser> signIn) =>
        {
            await signIn.SignOutAsync();
            return Results.Ok();
        }).RequireAuthorization();

        grp.MapGet("/me", async (
            UserManager<ApplicationUser> users,
            HttpContext http) =>
        {
            var user = await users.GetUserAsync(http.User);
            if (user is null) return Results.Unauthorized();
            var rolesList = await users.GetRolesAsync(user);
            return Results.Ok(new
            {
                id = user.Id,
                email = user.Email,
                isAdmin = rolesList.Contains(Roles.Admin),
            });
        }).RequireAuthorization();

        return app;
    }
}
  • Step 4: Call MapAuthEndpoints in Program.cs

In backend/Program.cs, add the call after app.UseAuthorization();:

app.UseAuthentication();
app.UseAuthorization();

app.MapAuthEndpoints();

app.MapGet("/health", () => Results.Ok(new { ok = true }));
  • Step 5: Run the tests

Run: dotnet test --filter AuthTests Expected: Passed: 3.

  • Step 6: Commit
git add backend/Auth backend/Program.cs backend.tests/AuthTests.cs
git commit -m "Add /api/auth/{register,login,logout,me} with first-user-is-admin rule"

Task 6: Device + DeviceConfig entities

Files:

  • Create: backend/Devices/Device.cs

  • Create: backend/Devices/DeviceConfig.cs

  • Create: backend/Settings/SystemSettings.cs

  • Modify: backend/Data/AppDbContext.cs

  • Step 1: Define Device

Create backend/Devices/Device.cs:

namespace backend.Devices;

public class Device
{
    public Guid Id { get; set; } = Guid.NewGuid();
    public Guid OwnerUserId { get; set; }
    public string Name { get; set; } = "";
    public DateTimeOffset PairedAt { get; set; }
    public DateTimeOffset? LastSeenAt { get; set; }
    public string TokenHash { get; set; } = "";
    public bool IsRevoked { get; set; }
    public DeviceConfig? Config { get; set; }
}
  • Step 2: Define DeviceConfig

Create backend/Devices/DeviceConfig.cs:

namespace backend.Devices;

public class DeviceConfig
{
    public Guid DeviceId { get; set; }
    public string SystemPrompt { get; set; } = "";
    public string Voice { get; set; } = "";
    public string Model { get; set; } = "";
    public int IdleTimeoutSeconds { get; set; } = 30;
    public string EnabledToolsJson { get; set; } = "[\"end_session\",\"get_current_time\",\"set_volume\"]";
}
  • Step 3: Define SystemSettings (singleton)

Create backend/Settings/SystemSettings.cs:

namespace backend.Settings;

public class SystemSettings
{
    public int Id { get; set; } = 1;
    public string DefaultSystemPrompt { get; set; } =
        "You are a helpful voice assistant. Keep responses concise.";
    public string DefaultVoice { get; set; } = "alloy";
    public string DefaultModel { get; set; } = "gpt-4o-realtime-preview";
    public int DefaultIdleTimeoutSeconds { get; set; } = 30;
}
  • Step 4: Register entities in AppDbContext

Replace backend/Data/AppDbContext.cs:

using backend.Auth;
using backend.Devices;
using backend.Settings;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;

namespace backend.Data;

public class AppDbContext(DbContextOptions<AppDbContext> options)
    : IdentityDbContext<ApplicationUser, IdentityRole<Guid>, Guid>(options)
{
    public DbSet<Device> Devices => Set<Device>();
    public DbSet<DeviceConfig> DeviceConfigs => Set<DeviceConfig>();
    public DbSet<SystemSettings> SystemSettings => Set<SystemSettings>();

    protected override void OnModelCreating(ModelBuilder b)
    {
        base.OnModelCreating(b);

        b.Entity<Device>().HasIndex(d => d.OwnerUserId);
        b.Entity<Device>().HasIndex(d => d.TokenHash).IsUnique();
        b.Entity<Device>()
            .HasOne(d => d.Config)
            .WithOne()
            .HasForeignKey<DeviceConfig>(c => c.DeviceId)
            .OnDelete(DeleteBehavior.Cascade);
        b.Entity<DeviceConfig>().HasKey(c => c.DeviceId);
        b.Entity<SystemSettings>().HasKey(s => s.Id);
    }
}
  • Step 5: Seed SystemSettings on startup

In backend/Program.cs, replace the existing using (var scope = ...) block with:

using (var scope = app.Services.CreateScope())
{
    var sp = scope.ServiceProvider;
    var db = sp.GetRequiredService<AppDbContext>();
    db.Database.Migrate();
    await IdentitySetup.SeedRolesAsync(sp);

    if (!db.SystemSettings.Any())
    {
        db.SystemSettings.Add(new backend.Settings.SystemSettings());
        await db.SaveChangesAsync();
    }
}
  • Step 6: Generate migration
dotnet ef migrations add AddDevicesAndSettings --project backend --startup-project backend
  • Step 7: Verify build + tests still pass

Run: dotnet test Expected: 4 passing (Health + 3 Auth).

  • Step 8: Commit
git add backend/Devices backend/Settings backend/Data backend/Program.cs backend/Migrations
git commit -m "Add Device, DeviceConfig, SystemSettings entities and seed defaults"

Task 7: DeviceTokenService (generate / hash / verify)

Files:

  • Create: backend/Devices/DeviceTokenService.cs

  • Create: backend.tests/DeviceTokenServiceTests.cs

  • Step 1: Write the failing test

Create backend.tests/DeviceTokenServiceTests.cs:

using backend.Devices;
using FluentAssertions;
using Xunit;

namespace backend.tests;

public class DeviceTokenServiceTests
{
    private readonly DeviceTokenService _svc = new();

    [Fact]
    public void Generate_returns_url_safe_token_of_expected_length()
    {
        var (plaintext, hash) = _svc.Generate();
        plaintext.Should().MatchRegex("^[A-Za-z0-9_-]+$");
        plaintext.Length.Should().BeGreaterOrEqualTo(40);
        hash.Should().NotBeNullOrEmpty();
        hash.Should().NotBe(plaintext);
    }

    [Fact]
    public void Hash_is_deterministic_for_same_input()
    {
        var (plaintext, hash) = _svc.Generate();
        _svc.Hash(plaintext).Should().Be(hash);
    }

    [Fact]
    public void Verify_accepts_matching_plaintext_and_rejects_others()
    {
        var (plaintext, hash) = _svc.Generate();
        _svc.Verify(plaintext, hash).Should().BeTrue();
        _svc.Verify(plaintext + "x", hash).Should().BeFalse();
        _svc.Verify("totally-different", hash).Should().BeFalse();
    }
}
  • Step 2: Run the failing test

Run: dotnet test --filter DeviceTokenServiceTests Expected: build error (DeviceTokenService not defined).

  • Step 3: Implement the service

Create backend/Devices/DeviceTokenService.cs:

using System.Security.Cryptography;
using System.Text;

namespace backend.Devices;

public class DeviceTokenService
{
    public (string Plaintext, string Hash) Generate()
    {
        Span<byte> bytes = stackalloc byte[32];
        RandomNumberGenerator.Fill(bytes);
        var plaintext = Base64UrlEncode(bytes);
        return (plaintext, Hash(plaintext));
    }

    public string Hash(string plaintext)
    {
        Span<byte> hash = stackalloc byte[32];
        SHA256.HashData(Encoding.UTF8.GetBytes(plaintext), hash);
        return Convert.ToHexString(hash);
    }

    public bool Verify(string plaintext, string expectedHash)
    {
        var actual = Hash(plaintext);
        return CryptographicOperations.FixedTimeEquals(
            Encoding.UTF8.GetBytes(actual),
            Encoding.UTF8.GetBytes(expectedHash));
    }

    private static string Base64UrlEncode(ReadOnlySpan<byte> bytes)
        => Convert.ToBase64String(bytes)
            .TrimEnd('=')
            .Replace('+', '-')
            .Replace('/', '_');
}
  • Step 4: Run the tests

Run: dotnet test --filter DeviceTokenServiceTests Expected: Passed: 3.

  • Step 5: Commit
git add backend/Devices/DeviceTokenService.cs backend.tests/DeviceTokenServiceTests.cs
git commit -m "Add DeviceTokenService (random token, SHA-256 hash, fixed-time verify)"

Task 8: PairingCode entity + migration

Files:

  • Create: backend/Pairing/PairingCode.cs

  • Modify: backend/Data/AppDbContext.cs

  • Step 1: Define PairingCode

Create backend/Pairing/PairingCode.cs:

namespace backend.Pairing;

public class PairingCode
{
    public string Code { get; set; } = "";        // 8 chars, primary key
    public Guid OwnerUserId { get; set; }
    public string DeviceName { get; set; } = "";
    public DateTimeOffset ExpiresAt { get; set; }
    public DateTimeOffset? ConsumedAt { get; set; }
}
  • Step 2: Register in DbContext

In backend/Data/AppDbContext.cs, add the using using backend.Pairing;, the DbSet:

public DbSet<PairingCode> PairingCodes => Set<PairingCode>();

And inside OnModelCreating add:

b.Entity<PairingCode>().HasKey(p => p.Code);
b.Entity<PairingCode>().HasIndex(p => p.ExpiresAt);
  • Step 3: Generate migration
dotnet ef migrations add AddPairingCodes --project backend --startup-project backend
  • Step 4: Verify build + tests still pass

Run: dotnet test Expected: 7 passing (Health + Auth + DeviceToken).

  • Step 5: Commit
git add backend/Pairing backend/Data backend/Migrations
git commit -m "Add PairingCode entity + migration"

Task 9: PairingService

Files:

  • Create: backend/Pairing/PairingService.cs

  • Create: backend.tests/PairingServiceTests.cs

  • Step 1: Write the failing test

Create backend.tests/PairingServiceTests.cs:

using backend.Pairing;
using FluentAssertions;
using Xunit;

namespace backend.tests;

public class PairingServiceTests
{
    [Fact]
    public void Generated_codes_use_only_safe_alphabet_and_are_8_chars()
    {
        var svc = new PairingService();
        for (var i = 0; i < 50; i++)
        {
            var code = svc.GenerateCode();
            code.Length.Should().Be(8);
            code.Should().MatchRegex("^[A-HJ-KM-NP-Z2-9]+$");
        }
    }

    [Fact]
    public void Generated_codes_are_not_all_identical()
    {
        var svc = new PairingService();
        var codes = Enumerable.Range(0, 20).Select(_ => svc.GenerateCode()).ToHashSet();
        codes.Count.Should().BeGreaterThan(15);
    }
}
  • Step 2: Run the failing test

Run: dotnet test --filter PairingServiceTests Expected: build error (PairingService not defined).

  • Step 3: Implement the service

Create backend/Pairing/PairingService.cs:

using System.Security.Cryptography;
using backend.Data;
using backend.Devices;
using backend.Settings;
using Microsoft.EntityFrameworkCore;

namespace backend.Pairing;

public record PairResult(Guid DeviceId, string DeviceToken);

public class PairingService(AppDbContext db, DeviceTokenService tokens)
{
    private const string Alphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; // no O, 0, I, 1, L
    private const int CodeLength = 8;
    public static readonly TimeSpan CodeTtl = TimeSpan.FromMinutes(10);

    public string GenerateCode()
    {
        Span<byte> bytes = stackalloc byte[CodeLength];
        RandomNumberGenerator.Fill(bytes);
        Span<char> chars = stackalloc char[CodeLength];
        for (var i = 0; i < CodeLength; i++)
            chars[i] = Alphabet[bytes[i] % Alphabet.Length];
        return new string(chars);
    }

    public async Task<PairingCode> IssueAsync(Guid ownerUserId, string deviceName, CancellationToken ct)
    {
        var entity = new PairingCode
        {
            Code = GenerateCode(),
            OwnerUserId = ownerUserId,
            DeviceName = deviceName,
            ExpiresAt = DateTimeOffset.UtcNow.Add(CodeTtl),
        };
        db.PairingCodes.Add(entity);
        await db.SaveChangesAsync(ct);
        return entity;
    }

    public async Task<PairResult?> ConsumeAsync(string code, CancellationToken ct)
    {
        var now = DateTimeOffset.UtcNow;
        var pc = await db.PairingCodes.FirstOrDefaultAsync(p => p.Code == code, ct);
        if (pc is null || pc.ConsumedAt is not null || pc.ExpiresAt < now)
            return null;

        var (plaintext, hash) = tokens.Generate();
        var defaults = await db.SystemSettings.FirstAsync(ct);

        var device = new Device
        {
            OwnerUserId = pc.OwnerUserId,
            Name = pc.DeviceName,
            PairedAt = now,
            TokenHash = hash,
        };
        device.Config = new DeviceConfig
        {
            DeviceId = device.Id,
            SystemPrompt = defaults.DefaultSystemPrompt,
            Voice = defaults.DefaultVoice,
            Model = defaults.DefaultModel,
            IdleTimeoutSeconds = defaults.DefaultIdleTimeoutSeconds,
        };
        db.Devices.Add(device);
        pc.ConsumedAt = now;
        await db.SaveChangesAsync(ct);
        return new PairResult(device.Id, plaintext);
    }
}
  • Step 4: Run the tests

Run: dotnet test --filter PairingServiceTests Expected: Passed: 2.

  • Step 5: Commit
git add backend/Pairing/PairingService.cs backend.tests/PairingServiceTests.cs
git commit -m "Add PairingService with safe alphabet and 10-min TTL"

Task 10: Pairing endpoints (/api/pair-code, /api/pair)

Files:

  • Create: backend/Pairing/PairingEndpoints.cs

  • Create: backend.tests/PairingEndpointsTests.cs

  • Modify: backend/Program.cs

  • Step 1: Write the failing test

Create backend.tests/PairingEndpointsTests.cs:

using System.Net;
using System.Net.Http.Json;
using FluentAssertions;
using Xunit;

namespace backend.tests;

public class PairingEndpointsTests(ApiFactory factory) : IClassFixture<ApiFactory>
{
    private readonly ApiFactory _factory = factory;

    private async Task<HttpClient> SignedInClient()
    {
        var client = _factory.CreateClient();
        await client.PostAsJsonAsync("/api/auth/register",
            new { email = "owner@example.com", password = "Passw0rd!" });
        await client.PostAsJsonAsync("/api/auth/login",
            new { email = "owner@example.com", password = "Passw0rd!" });
        return client;
    }

    [Fact]
    public async Task Anonymous_user_cannot_request_a_pairing_code()
    {
        var client = _factory.CreateClient();
        var res = await client.PostAsJsonAsync("/api/pair-code", new { name = "kitchen" });
        res.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
    }

    [Fact]
    public async Task Pair_code_then_pair_returns_token_and_device_id()
    {
        var client = await SignedInClient();
        var codeRes = await client.PostAsJsonAsync("/api/pair-code", new { name = "kitchen" });
        codeRes.StatusCode.Should().Be(HttpStatusCode.OK);
        var codeBody = await codeRes.Content.ReadFromJsonAsync<Dictionary<string, object>>();
        var code = codeBody!["code"].ToString()!;

        var pairRes = await _factory.CreateClient().PostAsJsonAsync("/api/pair",
            new { code, hostname = "rpi", client_version = "0.1" });
        pairRes.StatusCode.Should().Be(HttpStatusCode.OK);
        var body = await pairRes.Content.ReadAsStringAsync();
        body.Should().Contain("device_id");
        body.Should().Contain("device_token");
        body.Should().Contain("backend_ws");
    }

    [Fact]
    public async Task Pair_with_invalid_code_returns_404()
    {
        var res = await _factory.CreateClient().PostAsJsonAsync("/api/pair",
            new { code = "ZZZZZZZZ", hostname = "rpi", client_version = "0.1" });
        res.StatusCode.Should().Be(HttpStatusCode.NotFound);
    }

    [Fact]
    public async Task Pair_with_consumed_code_returns_404()
    {
        var client = await SignedInClient();
        var codeRes = await client.PostAsJsonAsync("/api/pair-code", new { name = "kitchen" });
        var code = (await codeRes.Content.ReadFromJsonAsync<Dictionary<string, object>>())!["code"].ToString()!;

        var first = await _factory.CreateClient().PostAsJsonAsync("/api/pair",
            new { code, hostname = "rpi", client_version = "0.1" });
        first.StatusCode.Should().Be(HttpStatusCode.OK);

        var second = await _factory.CreateClient().PostAsJsonAsync("/api/pair",
            new { code, hostname = "rpi", client_version = "0.1" });
        second.StatusCode.Should().Be(HttpStatusCode.NotFound);
    }
}
  • Step 2: Run the tests to see them fail

Run: dotnet test --filter PairingEndpointsTests Expected: 4 failures.

  • Step 3: Implement the endpoints

Create backend/Pairing/PairingEndpoints.cs:

using System.Security.Claims;
using backend.Auth;
using Microsoft.AspNetCore.Mvc;

namespace backend.Pairing;

public record IssueCodeRequest(string Name);
public record PairRequest(string Code, string Hostname, string Client_version);

public static class PairingEndpoints
{
    public static IEndpointRouteBuilder MapPairingEndpoints(this IEndpointRouteBuilder app)
    {
        app.MapPost("/api/pair-code", async (
            [FromBody] IssueCodeRequest req,
            PairingService svc,
            HttpContext http,
            CancellationToken ct) =>
        {
            var uidStr = http.User.FindFirstValue(ClaimTypes.NameIdentifier);
            if (!Guid.TryParse(uidStr, out var uid)) return Results.Unauthorized();
            if (string.IsNullOrWhiteSpace(req.Name))
                return Results.BadRequest(new { error = "name is required" });
            var pc = await svc.IssueAsync(uid, req.Name.Trim(), ct);
            return Results.Ok(new { code = pc.Code, expires_at = pc.ExpiresAt });
        }).RequireAuthorization();

        app.MapPost("/api/pair", async (
            [FromBody] PairRequest req,
            PairingService svc,
            HttpContext http,
            CancellationToken ct) =>
        {
            var result = await svc.ConsumeAsync(req.Code, ct);
            if (result is null) return Results.NotFound(new { error = "code invalid or expired" });
            var scheme = http.Request.Scheme == "https" ? "wss" : "ws";
            var host = http.Request.Host.ToString();
            return Results.Ok(new
            {
                device_id = result.DeviceId,
                device_token = result.DeviceToken,
                backend_ws = $"{scheme}://{host}/device",
            });
        });

        return app;
    }
}
  • Step 4: Register the service and endpoints in Program.cs

In backend/Program.cs, before var app = builder.Build(); add:

builder.Services.AddScoped<backend.Devices.DeviceTokenService>();
builder.Services.AddScoped<backend.Pairing.PairingService>();

After app.MapAuthEndpoints(); add:

app.MapPairingEndpoints();
  • Step 5: Run the tests

Run: dotnet test --filter PairingEndpointsTests Expected: Passed: 4.

  • Step 6: Commit
git add backend/Pairing/PairingEndpoints.cs backend/Program.cs backend.tests/PairingEndpointsTests.cs
git commit -m "Add /api/pair-code (auth) + /api/pair (public) endpoints"

Task 11: Devices endpoints (list / get / rename / revoke)

Files:

  • Create: backend/Devices/DevicesEndpoints.cs

  • Create: backend.tests/DevicesEndpointsTests.cs

  • Modify: backend/Program.cs

  • Step 1: Write the failing test

Create backend.tests/DevicesEndpointsTests.cs:

using System.Net;
using System.Net.Http.Json;
using FluentAssertions;
using Xunit;

namespace backend.tests;

public class DevicesEndpointsTests(ApiFactory factory) : IClassFixture<ApiFactory>
{
    private readonly ApiFactory _factory = factory;

    private async Task<(HttpClient signedIn, string code)> SetupWithPairCode(string email)
    {
        var client = _factory.CreateClient();
        await client.PostAsJsonAsync("/api/auth/register",
            new { email, password = "Passw0rd!" });
        await client.PostAsJsonAsync("/api/auth/login",
            new { email, password = "Passw0rd!" });
        var codeRes = await client.PostAsJsonAsync("/api/pair-code", new { name = "kitchen" });
        var body = await codeRes.Content.ReadFromJsonAsync<Dictionary<string, object>>();
        return (client, body!["code"].ToString()!);
    }

    [Fact]
    public async Task User_sees_only_their_own_devices()
    {
        var (alice, codeA) = await SetupWithPairCode("alice@example.com");
        await _factory.CreateClient().PostAsJsonAsync("/api/pair",
            new { code = codeA, hostname = "rpi", client_version = "0.1" });

        var (bob, codeB) = await SetupWithPairCode("bob@example.com");
        await _factory.CreateClient().PostAsJsonAsync("/api/pair",
            new { code = codeB, hostname = "rpi", client_version = "0.1" });

        var aliceList = await alice.GetStringAsync("/api/devices");
        var bobList = await bob.GetStringAsync("/api/devices");

        aliceList.Should().Contain("kitchen");
        bobList.Should().Contain("kitchen");
        // Each should have exactly one device — IDs differ.
        aliceList.Should().NotBe(bobList);
    }

    [Fact]
    public async Task Rename_device_updates_name()
    {
        var (client, code) = await SetupWithPairCode("owner@example.com");
        await _factory.CreateClient().PostAsJsonAsync("/api/pair",
            new { code, hostname = "rpi", client_version = "0.1" });

        var listJson = await client.GetFromJsonAsync<List<Dictionary<string, object>>>("/api/devices");
        var id = listJson![0]["id"].ToString();

        var patchRes = await client.PatchAsJsonAsync($"/api/devices/{id}", new { name = "living-room" });
        patchRes.StatusCode.Should().Be(HttpStatusCode.OK);

        var after = await client.GetStringAsync($"/api/devices/{id}");
        after.Should().Contain("living-room");
    }

    [Fact]
    public async Task Revoke_device_sets_is_revoked_true()
    {
        var (client, code) = await SetupWithPairCode("owner@example.com");
        await _factory.CreateClient().PostAsJsonAsync("/api/pair",
            new { code, hostname = "rpi", client_version = "0.1" });

        var listJson = await client.GetFromJsonAsync<List<Dictionary<string, object>>>("/api/devices");
        var id = listJson![0]["id"].ToString();

        var del = await client.DeleteAsync($"/api/devices/{id}");
        del.StatusCode.Should().Be(HttpStatusCode.OK);

        var after = await client.GetStringAsync($"/api/devices/{id}");
        after.Should().Contain("\"isRevoked\":true");
    }
}
  • Step 2: Run the tests to see them fail

Run: dotnet test --filter DevicesEndpointsTests Expected: failures (endpoints not defined).

  • Step 3: Implement the endpoints

Create backend/Devices/DevicesEndpoints.cs:

using System.Security.Claims;
using backend.Data;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

namespace backend.Devices;

public record RenameRequest(string Name);

public static class DevicesEndpoints
{
    public static IEndpointRouteBuilder MapDevicesEndpoints(this IEndpointRouteBuilder app)
    {
        var grp = app.MapGroup("/api/devices").RequireAuthorization();

        grp.MapGet("/", async (AppDbContext db, HttpContext http, CancellationToken ct) =>
        {
            var uid = GetUserId(http);
            var devs = await db.Devices
                .Where(d => d.OwnerUserId == uid)
                .Select(d => new
                {
                    id = d.Id,
                    name = d.Name,
                    pairedAt = d.PairedAt,
                    lastSeenAt = d.LastSeenAt,
                    isRevoked = d.IsRevoked,
                })
                .ToListAsync(ct);
            return Results.Ok(devs);
        });

        grp.MapGet("/{id:guid}", async (Guid id, AppDbContext db, HttpContext http, CancellationToken ct) =>
        {
            var uid = GetUserId(http);
            var d = await db.Devices.Include(x => x.Config)
                .FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == uid, ct);
            return d is null
                ? Results.NotFound()
                : Results.Ok(new
                {
                    id = d.Id,
                    name = d.Name,
                    pairedAt = d.PairedAt,
                    lastSeenAt = d.LastSeenAt,
                    isRevoked = d.IsRevoked,
                    config = d.Config,
                });
        });

        grp.MapPatch("/{id:guid}", async (
            Guid id, [FromBody] RenameRequest req,
            AppDbContext db, HttpContext http, CancellationToken ct) =>
        {
            var uid = GetUserId(http);
            var d = await db.Devices.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == uid, ct);
            if (d is null) return Results.NotFound();
            if (string.IsNullOrWhiteSpace(req.Name))
                return Results.BadRequest(new { error = "name is required" });
            d.Name = req.Name.Trim();
            await db.SaveChangesAsync(ct);
            return Results.Ok();
        });

        grp.MapDelete("/{id:guid}", async (Guid id, AppDbContext db, HttpContext http, CancellationToken ct) =>
        {
            var uid = GetUserId(http);
            var d = await db.Devices.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == uid, ct);
            if (d is null) return Results.NotFound();
            d.IsRevoked = true;
            await db.SaveChangesAsync(ct);
            return Results.Ok();
        });

        return app;
    }

    private static Guid GetUserId(HttpContext http)
    {
        var s = http.User.FindFirstValue(ClaimTypes.NameIdentifier);
        return Guid.TryParse(s, out var g) ? g : Guid.Empty;
    }
}
  • Step 4: Register endpoints in Program.cs

In backend/Program.cs, after app.MapPairingEndpoints(); add:

app.MapDevicesEndpoints();
  • Step 5: Run the tests

Run: dotnet test --filter DevicesEndpointsTests Expected: Passed: 3.

  • Step 6: Commit
git add backend/Devices/DevicesEndpoints.cs backend/Program.cs backend.tests/DevicesEndpointsTests.cs
git commit -m "Add /api/devices CRUD (list, get, rename, revoke) with owner scoping"

Task 12: InstallScriptBuilder

Files:

  • Create: backend/Install/InstallScriptBuilder.cs

  • Step 1: Implement the script template

Create backend/Install/InstallScriptBuilder.cs:

namespace backend.Install;

public class InstallScriptBuilder
{
    public string Build(string backendUrl)
    {
        return $$"""
#!/usr/bin/env bash
# Smart Assistant installer (idempotent). Re-run to upgrade.
set -euo pipefail

BACKEND_URL="{{backendUrl}}"
ASSISTANT_HOME="$HOME/assistant"
RESET=0
for arg in "$@"; do
    case "$arg" in
        --reset) RESET=1 ;;
    esac
done

echo "Installing system deps (sudo password may be prompted)..."
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends python3-venv libportaudio2 alsa-utils

mkdir -p "$ASSISTANT_HOME/code" "$ASSISTANT_HOME/state"

if [ ! -d "$ASSISTANT_HOME/.venv" ]; then
    python3 -m venv "$ASSISTANT_HOME/.venv"
fi

echo "Downloading client bundle from $BACKEND_URL ..."
curl -fsSL "$BACKEND_URL/client.tar.gz" | tar -xz -C "$ASSISTANT_HOME/code"

"$ASSISTANT_HOME/.venv/bin/pip" install --quiet -r "$ASSISTANT_HOME/code/requirements.txt"
"$ASSISTANT_HOME/.venv/bin/pip" install --quiet --no-deps -r "$ASSISTANT_HOME/code/requirements-nodeps.txt"

if [ "$RESET" -eq 1 ]; then
    rm -f "$ASSISTANT_HOME/state/config.json"
fi

if [ ! -f "$ASSISTANT_HOME/state/config.json" ]; then
    echo
    read -r -p "Pairing code: " CODE
    "$ASSISTANT_HOME/.venv/bin/python" -m client.pair --backend "$BACKEND_URL" --code "$CODE"
fi

mkdir -p "$HOME/.config/systemd/user"
cat > "$HOME/.config/systemd/user/assistant.service" <<UNIT
[Unit]
Description=Smart Assistant Pi client
After=network-online.target

[Service]
Environment=PA_ALSA_PLUGHW=1
ExecStart=$ASSISTANT_HOME/.venv/bin/python -m client.main
WorkingDirectory=$ASSISTANT_HOME/code
Restart=on-failure
RestartSec=5

[Install]
WantedBy=default.target
UNIT

loginctl enable-linger "$USER" 2>/dev/null || true
systemctl --user daemon-reload
systemctl --user enable --now assistant
echo "Done. systemctl --user status assistant"
""";
    }
}
  • Step 2: Write an inline smoke check

Add to the bottom of the existing backend.tests/HealthTests.cs (or create a tiny test file InstallScriptBuilderTests.cs):

Create backend.tests/InstallScriptBuilderTests.cs:

using backend.Install;
using FluentAssertions;
using Xunit;

namespace backend.tests;

public class InstallScriptBuilderTests
{
    [Fact]
    public void Build_inlines_the_backend_url_into_the_script()
    {
        var script = new InstallScriptBuilder().Build("https://example.test");
        script.Should().Contain("BACKEND_URL=\"https://example.test\"");
        script.Should().Contain("#!/usr/bin/env bash");
        script.Should().Contain("$BACKEND_URL/client.tar.gz");
        script.Should().Contain("client.pair");
    }
}
  • Step 3: Run the test

Run: dotnet test --filter InstallScriptBuilderTests Expected: Passed: 1.

  • Step 4: Commit
git add backend/Install/InstallScriptBuilder.cs backend.tests/InstallScriptBuilderTests.cs
git commit -m "Add InstallScriptBuilder (template for install.sh)"

Task 13: Install endpoints (/install.sh + /client.tar.gz)

Files:

  • Create: backend/Install/InstallEndpoints.cs

  • Create: backend.tests/InstallEndpointsTests.cs

  • Modify: backend/Program.cs

  • Step 1: Write the failing tests

Create backend.tests/InstallEndpointsTests.cs:

using System.Net;
using FluentAssertions;
using Xunit;

namespace backend.tests;

public class InstallEndpointsTests(ApiFactory factory) : IClassFixture<ApiFactory>
{
    private readonly ApiFactory _factory = factory;

    [Fact]
    public async Task Install_sh_returns_shell_script_with_request_host()
    {
        var client = _factory.CreateClient();
        var res = await client.GetAsync("/install.sh");
        res.StatusCode.Should().Be(HttpStatusCode.OK);
        res.Content.Headers.ContentType!.MediaType.Should().Be("text/x-shellscript");

        var body = await res.Content.ReadAsStringAsync();
        body.Should().StartWith("#!/usr/bin/env bash");
        body.Should().Contain("BACKEND_URL=\"http://localhost\"");
    }

    [Fact]
    public async Task Client_tarball_returns_octet_stream()
    {
        var tmp = Path.Combine(Path.GetTempPath(), "client.tar.gz");
        File.WriteAllBytes(tmp, new byte[] { 0x1f, 0x8b }); // tiny gzip-ish marker

        var client = _factory.WithWebHostBuilder(b =>
        {
            b.ConfigureAppConfiguration((_, cfg) =>
            {
                cfg.AddInMemoryCollection(new Dictionary<string, string?>
                {
                    ["Install:ClientTarballPath"] = tmp,
                });
            });
        }).CreateClient();

        var res = await client.GetAsync("/client.tar.gz");
        res.StatusCode.Should().Be(HttpStatusCode.OK);
        res.Content.Headers.ContentType!.MediaType.Should().Be("application/gzip");
        (await res.Content.ReadAsByteArrayAsync()).Length.Should().Be(2);
    }
}
  • Step 2: Run the tests to see them fail

Run: dotnet test --filter InstallEndpointsTests Expected: failures (endpoints not defined).

  • Step 3: Implement the endpoints

Create backend/Install/InstallEndpoints.cs:

using Microsoft.Extensions.Configuration;

namespace backend.Install;

public static class InstallEndpoints
{
    public static IEndpointRouteBuilder MapInstallEndpoints(this IEndpointRouteBuilder app)
    {
        app.MapGet("/install.sh", (HttpContext http, InstallScriptBuilder builder) =>
        {
            var scheme = http.Request.Scheme;
            var host = http.Request.Host.ToString();
            var script = builder.Build($"{scheme}://{host}");
            return Results.Text(script, "text/x-shellscript");
        });

        app.MapGet("/client.tar.gz", (IConfiguration cfg, IWebHostEnvironment env) =>
        {
            var path = cfg["Install:ClientTarballPath"]
                ?? Path.Combine(env.WebRootPath ?? "wwwroot", "client.tar.gz");
            if (!File.Exists(path))
                return Results.NotFound(new { error = "client.tar.gz not found at " + path });
            return Results.File(path, "application/gzip", "client.tar.gz");
        });

        return app;
    }
}
  • Step 4: Register service and endpoints in Program.cs

In backend/Program.cs, before var app = builder.Build(); add:

builder.Services.AddSingleton<backend.Install.InstallScriptBuilder>();

After app.MapDevicesEndpoints(); add:

app.MapInstallEndpoints();
  • Step 5: Run the tests

Run: dotnet test --filter InstallEndpointsTests Expected: Passed: 2.

  • Step 6: Commit
git add backend/Install backend/Program.cs backend.tests/InstallEndpointsTests.cs
git commit -m "Add GET /install.sh and GET /client.tar.gz"

Task 14: Python client placeholders

Files:

  • Create: client/__init__.py

  • Create: client/main.py

  • Create: client/pair.py

  • Create: requirements.txt

  • Create: requirements-nodeps.txt

  • Step 1: Empty package init

Create client/__init__.py:

  • Step 2: Stub main

Create client/main.py:

"""Smart Assistant client entry point. Implemented in Plan 3."""


def main() -> None:
    raise SystemExit("client.main: placeholder; full implementation comes in Plan 3.")


if __name__ == "__main__":
    main()
  • Step 3: Stub pair

Create client/pair.py:

"""Smart Assistant pairing CLI. Implemented in Plan 3."""


def main() -> None:
    raise SystemExit("client.pair: placeholder; full implementation comes in Plan 3.")


if __name__ == "__main__":
    main()
  • Step 4: Requirements files (carry forward from prototypes; expand in Plan 3)

Create requirements.txt:

sounddevice==0.5.1
numpy>=2.0
scipy>=1.13
onnxruntime>=1.18
scikit-learn
tqdm
requests
websockets>=12.0

Create requirements-nodeps.txt:

openwakeword==0.6.0
  • Step 5: Verify tarball builds locally

Run from the repo root:

tar czf /tmp/client.tar.gz client/ requirements.txt requirements-nodeps.txt
tar tzf /tmp/client.tar.gz | head

Expected output includes client/__init__.py, client/main.py, client/pair.py, requirements.txt, requirements-nodeps.txt.

  • Step 6: Commit
git add client requirements.txt requirements-nodeps.txt
git commit -m "Add Python client placeholders for bundle build (full impl in Plan 3)"

Task 15: Multi-stage Dockerfile

Files:

  • Create: Dockerfile

  • Create: .dockerignore

  • Step 1: Add Dockerfile

Create Dockerfile at the repo root:

# syntax=docker/dockerfile:1.7

# ---- Backend build ----
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY Assistant.sln ./
COPY backend/backend.csproj backend/
COPY backend.tests/backend.tests.csproj backend.tests/
RUN dotnet restore backend/backend.csproj
COPY backend/ backend/
RUN dotnet publish backend/backend.csproj -c Release -o /publish

# ---- Client bundle ----
FROM alpine:3.20 AS bundle
WORKDIR /src
RUN apk add --no-cache tar gzip
COPY client/ client/
COPY requirements.txt requirements-nodeps.txt ./
RUN tar czf /client.tar.gz client/ requirements.txt requirements-nodeps.txt

# ---- Final ----
FROM mcr.microsoft.com/dotnet/aspnet:9.0
WORKDIR /app
COPY --from=build /publish/ ./
RUN mkdir -p ./wwwroot && mkdir -p /data /keys
COPY --from=bundle /client.tar.gz ./wwwroot/client.tar.gz

ENV ASPNETCORE_URLS=http://+:8080
ENV ConnectionStrings__Default="Data Source=/data/assistant.db"
ENV Install__ClientTarballPath="/app/wwwroot/client.tar.gz"
EXPOSE 8080
ENTRYPOINT ["dotnet", "backend.dll"]
  • Step 2: Add .dockerignore

Create .dockerignore:

**/bin
**/obj
**/.vs
.git
.gitignore
.vscode
.idea
docs
tests
backend.tests
*.md
.dockerignore
Dockerfile
  • Step 3: Build the image locally

Run: docker build -t smart-assistant:dev . Expected: Successfully built ... (or BuildKit equivalent).

  • Step 4: Run the container and hit /health and /install.sh
docker run --rm -d -p 8080:8080 --name sa-dev smart-assistant:dev
sleep 3
curl -s http://localhost:8080/health
curl -s http://localhost:8080/install.sh | head -5
curl -sI http://localhost:8080/client.tar.gz
docker stop sa-dev

Expected: {"ok":true}, the shell script header, and a 200 OK with Content-Type: application/gzip.

  • Step 5: Commit
git add Dockerfile .dockerignore
git commit -m "Add multi-stage Dockerfile (backend + client tarball)"

Task 16: Configure data-protection keys + Coolify env-var-aware connection string

Files:

  • Modify: backend/Program.cs

  • Modify: backend/backend.csproj (via CLI)

  • Step 1: Add the DataProtection package

dotnet add backend package Microsoft.AspNetCore.DataProtection
  • Step 2: Persist keys to /keys when present, otherwise local fallback

In backend/Program.cs, insert immediately after var builder = WebApplication.CreateBuilder(args);:

var keysDir = Environment.GetEnvironmentVariable("DataProtection__KeysDir") ?? "/keys";
if (Directory.Exists(keysDir))
{
    builder.Services.AddDataProtection()
        .PersistKeysToFileSystem(new DirectoryInfo(keysDir))
        .SetApplicationName("smart-assistant");
}

At the top of Program.cs, add using Microsoft.AspNetCore.DataProtection;.

  • Step 3: Verify tests still pass

Run: dotnet test Expected: all previous tests pass; no /keys exists in test env so the block is skipped.

  • Step 4: Commit
git add backend/Program.cs backend/backend.csproj
git commit -m "Persist data-protection keys to /keys when present (Coolify volume)"

Task 17: deploy.json + Coolify deploy

Files:

  • Create: deploy.json

  • (no code changes — uses Coolify API via the deploying-to-coolify-via-api skill)

  • Step 1: Decide the production domain

Ask the user for the domain to use (per the Coolify-deploy skill rules — never invent a domain). Record it for the next steps. Sample placeholder used below: assistant.example.com.

  • Step 2: Create deploy.json

Create deploy.json at the repo root:

{
  "name": "smart-assistant",
  "project_uuid": "<fill from Coolify project list>",
  "server_uuid": "<fill from Coolify server list>",
  "destination_uuid": "<fill from server destinations>",
  "domain": "https://assistant.example.com",
  "build_pack": "dockerfile",
  "dockerfile": "Dockerfile",
  "ports_exposes": "8080",
  "env": {
    "ASPNETCORE_URLS": "http://+:8080",
    "ConnectionStrings__Default": "Data Source=/data/assistant.db",
    "Install__ClientTarballPath": "/app/wwwroot/client.tar.gz"
  },
  "secrets": [
    "OPENAI_API_KEY"
  ],
  "volumes": [
    { "host_path": "smart-assistant-data", "mount_path": "/data" },
    { "host_path": "smart-assistant-keys", "mount_path": "/keys" }
  ]
}
  • Step 3: Create or update the Coolify application via the API

Use the deploying-to-coolify-via-api skill. Follow it end-to-end: discover project/server UUIDs, POST /api/v1/applications/dockerfile, set env vars (including the OPENAI_API_KEY secret, value from the user — do not log), set domain, set volumes.

  • Step 4: Trigger a deploy (fire-and-forget per global CLAUDE.md)
curl -sf -X POST \
  -H "Authorization: Bearer $COOLIFY_KEY" \
  "$COOLIFY_URL/api/v1/deploy?uuid=$APP_UUID"

Expected: 2xx with a deployment UUID in the body. Report and stop — do NOT poll.

  • Step 5: Commit
git add deploy.json
git commit -m "Add deploy.json + Coolify application config"

Task 18: End-to-end smoke test against the live deploy + README

Files:

  • Create: README.md

  • Step 1: Verify /health is reachable on the live domain

curl -sf https://assistant.example.com/health

Expected: {"ok":true}.

  • Step 2: Register, log in, and request a pair code (via the JSON API)
COOKIE=$(mktemp)
curl -sf -c "$COOKIE" -X POST https://assistant.example.com/api/auth/register \
  -H 'content-type: application/json' \
  -d '{"email":"you@example.com","password":"Passw0rd!"}'

curl -sf -c "$COOKIE" -b "$COOKIE" -X POST https://assistant.example.com/api/auth/login \
  -H 'content-type: application/json' \
  -d '{"email":"you@example.com","password":"Passw0rd!"}'

CODE=$(curl -sf -c "$COOKIE" -b "$COOKIE" -X POST https://assistant.example.com/api/pair-code \
  -H 'content-type: application/json' \
  -d '{"name":"kitchen"}' | grep -oE '"code":"[^"]+"' | cut -d'"' -f4)
echo "code=$CODE"

Expected: a printed 8-char code.

  • Step 3: Pair as a "device" using just curl
curl -sf -X POST https://assistant.example.com/api/pair \
  -H 'content-type: application/json' \
  -d "{\"code\":\"$CODE\",\"hostname\":\"smoke\",\"client_version\":\"0.1\"}"

Expected: JSON containing device_id, device_token, backend_ws.

  • Step 4: Verify install.sh and client.tar.gz are served
curl -sf https://assistant.example.com/install.sh | head -5
curl -sIf https://assistant.example.com/client.tar.gz

Expected: shell script header; 200 OK with Content-Type: application/gzip.

  • Step 5: Write the README smoke-test section

Create README.md at the repo root:

# Smart Assistant

Voice assistant on a Raspberry Pi, talking to OpenAI Realtime API via an ASP.NET backend deployed on Coolify.

## Repo layout

See `docs/superpowers/specs/2026-06-11-smart-assistant-design.md` for the full design.

- `backend/` — ASP.NET 9 backend (Identity, devices, pairing, install).
- `backend.tests/` — xUnit + WebApplicationFactory tests.
- `client/` — Python client (placeholder in Plan 1; full implementation in Plan 3).
- `Dockerfile` — multi-stage build; produces a single image containing backend + `client.tar.gz`.
- `deploy.json` — Coolify config.

## Local development

```sh
dotnet test                              # all backend tests
dotnet run --project backend             # start at http://localhost:5000

Smoke test (against a live deploy)

DOMAIN="https://<your-domain>"
COOKIE=$(mktemp)
curl -sf -c "$COOKIE" -X POST "$DOMAIN/api/auth/register" \
  -H 'content-type: application/json' \
  -d '{"email":"you@example.com","password":"Passw0rd!"}'
curl -sf -c "$COOKIE" -b "$COOKIE" -X POST "$DOMAIN/api/auth/login" \
  -H 'content-type: application/json' \
  -d '{"email":"you@example.com","password":"Passw0rd!"}'
CODE=$(curl -sf -c "$COOKIE" -b "$COOKIE" -X POST "$DOMAIN/api/pair-code" \
  -H 'content-type: application/json' \
  -d '{"name":"kitchen"}' | grep -oE '"code":"[^"]+"' | cut -d'"' -f4)
curl -sf -X POST "$DOMAIN/api/pair" \
  -H 'content-type: application/json' \
  -d "{\"code\":\"$CODE\",\"hostname\":\"smoke\",\"client_version\":\"0.1\"}"
curl -sIf "$DOMAIN/client.tar.gz"

Deploying

Update deploy.json and trigger a deploy via the Coolify v4 API:

curl -sf -X POST -H "Authorization: Bearer $COOLIFY_KEY" \
  "$COOLIFY_URL/api/v1/deploy?uuid=<app-uuid>"

Fire-and-forget; do not poll (per global CLAUDE.md).


- [ ] **Step 6: Commit**

```bash
git add README.md
git commit -m "Add README with smoke-test instructions"

Self-review notes (already applied)

  • All spec items in scope of Plan 1 are covered: Identity + roles (Tasks 4, 5), Devices + DeviceConfig (Tasks 6, 11), DeviceTokenService (Task 7), PairingCode + PairingService (Tasks 8, 9), pairing endpoints (Task 10), SystemSettings entity + seed (Task 6 step 5), install endpoints (Tasks 12, 13), Python placeholders (Task 14), Dockerfile (Task 15), data-protection keys (Task 16), Coolify deploy (Task 17), smoke test (Task 18).
  • No TBD/TODO/placeholder text in instructional steps (only "placeholder" used in the literal docstrings the engineer copies in for Python stubs, which is correct).
  • Type / property / method names match across tasks: Device.OwnerUserId, Device.IsRevoked, DeviceConfig.EnabledToolsJson, PairingService.GenerateCode(), PairingService.IssueAsync(...), PairingService.ConsumeAsync(...), DeviceTokenService.{Generate,Hash,Verify} — all consistent.
  • Out of Plan 1: SystemSettings admin endpoints (Plan 4), DeviceConfig edit endpoint (Plan 4 — Plan 1 returns config via the device GET as read-only), device WebSocket hub (Plan 2), Realtime relay (Plan 2), tools registry (Plan 2), full Python client (Plan 3). These are not gaps; they are explicitly deferred.