From cf055c8928948e14175c5e8c79def63e613317e4 Mon Sep 17 00:00:00 2001 From: Assistant builder Date: Thu, 11 Jun 2026 17:48:18 +0000 Subject: [PATCH] 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. --- ...6-11-smart-assistant-backend-foundation.md | 2155 +++++++++++++++++ 1 file changed, 2155 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-11-smart-assistant-backend-foundation.md diff --git a/docs/superpowers/plans/2026-06-11-smart-assistant-backend-foundation.md b/docs/superpowers/plans/2026-06-11-smart-assistant-backend-foundation.md new file mode 100644 index 0000000..e115cf9 --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-smart-assistant-backend-foundation.md @@ -0,0 +1,2155 @@ +# 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.