# 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 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: ```bash 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`: ```csharp public partial class Program { } ``` - [ ] **Step 3: Add test framework packages** ```bash 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** ```bash 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** ```bash 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`: ```csharp using Microsoft.EntityFrameworkCore; namespace backend.Data; public class AppDbContext(DbContextOptions options) : DbContext(options) { } ``` - [ ] **Step 3: Configure connection string** Replace `backend/appsettings.json`: ```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`: ```csharp using backend.Data; using Microsoft.EntityFrameworkCore; var builder = WebApplication.CreateBuilder(args); builder.Services.AddDbContext(opt => opt.UseSqlite(builder.Configuration.GetConnectionString("Default"))); var app = builder.Build(); using (var scope = app.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); 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** ```bash 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** ```bash 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** ```bash 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`: ```csharp 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 { 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 { ["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`: ```csharp using System.Net; using FluentAssertions; using Xunit; namespace backend.tests; public class HealthTests(ApiFactory factory) : IClassFixture { 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** ```bash git add backend.tests git commit -m "Add ApiFactory test helper + /health sanity test" ``` --- ## Task 4: Add ASP.NET Identity with cookie auth **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** ```bash dotnet add backend package Microsoft.AspNetCore.Identity.EntityFrameworkCore ``` - [ ] **Step 2: Define ApplicationUser** Create `backend/Auth/ApplicationUser.cs`: ```csharp using Microsoft.AspNetCore.Identity; namespace backend.Auth; public class ApplicationUser : IdentityUser { } ``` - [ ] **Step 3: Define Roles constants** Create `backend/Auth/Roles.cs`: ```csharp 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`: ```csharp using backend.Auth; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; namespace backend.Data; public class AppDbContext(DbContextOptions options) : IdentityDbContext, Guid>(options) { } ``` - [ ] **Step 5: Set up Identity + roles seeding** Create `backend/Auth/IdentitySetup.cs`: ```csharp using backend.Data; using Microsoft.AspNetCore.Identity; namespace backend.Auth; public static class IdentitySetup { public static IServiceCollection AddAppIdentity(this IServiceCollection services) { services.AddIdentity>(opt => { opt.Password.RequiredLength = 8; opt.Password.RequireNonAlphanumeric = false; opt.User.RequireUniqueEmail = true; opt.SignIn.RequireConfirmedAccount = false; }) .AddEntityFrameworkStores() .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>>(); foreach (var name in Roles.All) { if (!await roleMgr.RoleExistsAsync(name)) await roleMgr.CreateAsync(new IdentityRole(name)); } } } ``` - [ ] **Step 6: Wire Identity into Program.cs** Replace `backend/Program.cs`: ```csharp using backend.Auth; using backend.Data; using Microsoft.EntityFrameworkCore; var builder = WebApplication.CreateBuilder(args); builder.Services.AddDbContext(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().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** ```bash 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** ```bash 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`: ```csharp using System.Net; using System.Net.Http.Json; using FluentAssertions; using Xunit; namespace backend.tests; public class AuthTests(ApiFactory factory) : IClassFixture { 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`: ```csharp 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 users, RoleManager> 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 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 signIn) => { await signIn.SignOutAsync(); return Results.Ok(); }).RequireAuthorization(); grp.MapGet("/me", async ( UserManager 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();`: ```csharp 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** ```bash 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`: ```csharp 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`: ```csharp 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`: ```csharp 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`: ```csharp 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 options) : IdentityDbContext, Guid>(options) { public DbSet Devices => Set(); public DbSet DeviceConfigs => Set(); public DbSet SystemSettings => Set(); protected override void OnModelCreating(ModelBuilder b) { base.OnModelCreating(b); b.Entity().HasIndex(d => d.OwnerUserId); b.Entity().HasIndex(d => d.TokenHash).IsUnique(); b.Entity() .HasOne(d => d.Config) .WithOne() .HasForeignKey(c => c.DeviceId) .OnDelete(DeleteBehavior.Cascade); b.Entity().HasKey(c => c.DeviceId); b.Entity().HasKey(s => s.Id); } } ``` - [ ] **Step 5: Seed SystemSettings on startup** In `backend/Program.cs`, replace the existing `using (var scope = ...)` block with: ```csharp using (var scope = app.Services.CreateScope()) { var sp = scope.ServiceProvider; var db = sp.GetRequiredService(); 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** ```bash 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** ```bash 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`: ```csharp 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`: ```csharp using System.Security.Cryptography; using System.Text; namespace backend.Devices; public class DeviceTokenService { public (string Plaintext, string Hash) Generate() { Span bytes = stackalloc byte[32]; RandomNumberGenerator.Fill(bytes); var plaintext = Base64UrlEncode(bytes); return (plaintext, Hash(plaintext)); } public string Hash(string plaintext) { Span 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 bytes) => Convert.ToBase64String(bytes) .TrimEnd('=') .Replace('+', '-') .Replace('/', '_'); } ``` - [ ] **Step 4: Run the tests** Run: `dotnet test --filter DeviceTokenServiceTests` Expected: `Passed: 3`. - [ ] **Step 5: Commit** ```bash 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`: ```csharp 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: ```csharp public DbSet PairingCodes => Set(); ``` And inside `OnModelCreating` add: ```csharp b.Entity().HasKey(p => p.Code); b.Entity().HasIndex(p => p.ExpiresAt); ``` - [ ] **Step 3: Generate migration** ```bash 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** ```bash 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`: ```csharp 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`: ```csharp 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 bytes = stackalloc byte[CodeLength]; RandomNumberGenerator.Fill(bytes); Span 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 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 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** ```bash 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`: ```csharp using System.Net; using System.Net.Http.Json; using FluentAssertions; using Xunit; namespace backend.tests; public class PairingEndpointsTests(ApiFactory factory) : IClassFixture { private readonly ApiFactory _factory = factory; private async Task 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>(); 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>())!["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`: ```csharp 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: ```csharp builder.Services.AddScoped(); builder.Services.AddScoped(); ``` After `app.MapAuthEndpoints();` add: ```csharp app.MapPairingEndpoints(); ``` - [ ] **Step 5: Run the tests** Run: `dotnet test --filter PairingEndpointsTests` Expected: `Passed: 4`. - [ ] **Step 6: Commit** ```bash 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`: ```csharp using System.Net; using System.Net.Http.Json; using FluentAssertions; using Xunit; namespace backend.tests; public class DevicesEndpointsTests(ApiFactory factory) : IClassFixture { 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>(); 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>>("/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>>("/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`: ```csharp 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: ```csharp app.MapDevicesEndpoints(); ``` - [ ] **Step 5: Run the tests** Run: `dotnet test --filter DevicesEndpointsTests` Expected: `Passed: 3`. - [ ] **Step 6: Commit** ```bash 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`: ```csharp 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" </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`: ```csharp 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** ```bash 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`: ```csharp using System.Net; using FluentAssertions; using Xunit; namespace backend.tests; public class InstallEndpointsTests(ApiFactory factory) : IClassFixture { 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 { ["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`: ```csharp 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: ```csharp builder.Services.AddSingleton(); ``` After `app.MapDevicesEndpoints();` add: ```csharp app.MapInstallEndpoints(); ``` - [ ] **Step 5: Run the tests** Run: `dotnet test --filter InstallEndpointsTests` Expected: `Passed: 2`. - [ ] **Step 6: Commit** ```bash 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`: ```python ``` - [ ] **Step 2: Stub `main`** Create `client/main.py`: ```python """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`: ```python """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: ```bash 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** ```bash 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: ```dockerfile # 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** ```bash 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** ```bash 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** ```bash 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);`: ```csharp 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** ```bash 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: ```json { "name": "smart-assistant", "project_uuid": "", "server_uuid": "", "destination_uuid": "", "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)** ```bash 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** ```bash 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** ```bash curl -sf https://assistant.example.com/health ``` Expected: `{"ok":true}`. - [ ] **Step 2: Register, log in, and request a pair code (via the JSON API)** ```bash 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** ```bash 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** ```bash 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: ```markdown # 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) ```sh DOMAIN="https://" 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: ```sh curl -sf -X POST -H "Authorization: Bearer $COOLIFY_KEY" \ "$COOLIFY_URL/api/v1/deploy?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.