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

2156 lines
62 KiB
Markdown

# Smart Assistant — Backend Foundation Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Stand up the ASP.NET backend skeleton with Identity, devices, pairing-code flow, install endpoints, and deploy it to Coolify — enough that a curl flow can pair a device, but with no voice / Realtime / tool plane yet.
**Architecture:** Single ASP.NET 9 project using Minimal APIs, EF Core + SQLite (file on a Coolify named volume), cookie-based ASP.NET Identity. Module folders (`Auth/`, `Devices/`, `Pairing/`, `Install/`, `Settings/`, `Data/`) each expose an `IEndpointRouteBuilder` extension that `Program.cs` calls. Python `client/` directory holds placeholder files so the Dockerfile builds a non-empty `client.tar.gz`. Deploys triggered fire-and-forget via the Coolify v4 API per the global CLAUDE.md rule.
**Tech Stack:** .NET 9, ASP.NET Minimal APIs, EF Core 9 + `Microsoft.EntityFrameworkCore.Sqlite`, ASP.NET Identity (cookie auth), `Microsoft.AspNetCore.Mvc.Testing` for integration tests, xUnit, Docker multi-stage build, Coolify v4 REST API.
**Scope (in):** Solution skeleton, EF + SQLite, Identity + roles + admin seed, devices CRUD, device config defaults, pairing-code service + endpoints, install.sh + client.tar.gz serving, Dockerfile, Coolify deploy + smoke test.
**Scope (out, later plans):** Device WebSocket hub (Plan 2), Realtime relay (Plan 2), tools registry (Plan 2), full Python client (Plan 3), React UI + remaining admin endpoints (Plan 4).
**Reference spec:** `docs/superpowers/specs/2026-06-11-smart-assistant-design.md`
---
## File structure
This plan creates the following files. Each module owns one concern.
```
Assistant/
├── Assistant.sln
├── Dockerfile
├── .dockerignore
├── deploy.json # Coolify config (project/server UUIDs, domain)
├── README.md # smoke-test instructions
├── client/ # Python placeholders (Plan 3 fills these in)
│ ├── __init__.py
│ └── main.py # `def main(): print("client placeholder")`
├── requirements.txt
├── requirements-nodeps.txt
├── backend/
│ ├── backend.csproj
│ ├── Program.cs
│ ├── appsettings.json
│ ├── appsettings.Development.json
│ ├── Auth/
│ │ ├── ApplicationUser.cs
│ │ ├── AuthEndpoints.cs # /api/auth/{register,login,logout,me}
│ │ ├── Roles.cs # static class with Admin/User string consts
│ │ └── IdentitySetup.cs # AddIdentity + role seeding + admin seeding
│ ├── Devices/
│ │ ├── Device.cs
│ │ ├── DeviceConfig.cs
│ │ ├── DeviceTokenService.cs # generate + sha256 + verify
│ │ └── DevicesEndpoints.cs # /api/devices CRUD
│ ├── Pairing/
│ │ ├── PairingCode.cs
│ │ ├── PairingService.cs # generate, validate, consume
│ │ └── PairingEndpoints.cs # /api/pair-code (auth) + /api/pair (public)
│ ├── Settings/
│ │ └── SystemSettings.cs # singleton entity + seeder
│ ├── Install/
│ │ ├── InstallScriptBuilder.cs # builds install.sh body
│ │ └── InstallEndpoints.cs # /install.sh + /client.tar.gz
│ ├── Data/
│ │ ├── AppDbContext.cs
│ │ └── Migrations/ # populated by EF
│ └── wwwroot/ # filled at build: client.tar.gz; SPA dist later
└── backend.tests/
├── backend.tests.csproj
├── ApiFactory.cs # WebApplicationFactory<Program> for tests
├── AuthTests.cs
├── DeviceTokenServiceTests.cs
├── PairingServiceTests.cs
├── PairingEndpointsTests.cs
├── DevicesEndpointsTests.cs
└── InstallEndpointsTests.cs
```
---
## Task 1: Scaffold solution and projects
**Files:**
- Create: `Assistant.sln`
- Create: `backend/backend.csproj`
- Create: `backend/Program.cs`
- Create: `backend.tests/backend.tests.csproj`
- [ ] **Step 1: Create the solution and projects**
Run from the repo root:
```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<AppDbContext> 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<AppDbContext>(opt =>
opt.UseSqlite(builder.Configuration.GetConnectionString("Default")));
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Database.Migrate();
}
app.MapGet("/health", () => Results.Ok(new { ok = true }));
app.Run();
public partial class Program { }
```
- [ ] **Step 5: Install dotnet-ef tool and create the initial migration**
```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<Program>
{
private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"assistant-test-{Guid.NewGuid():N}.db");
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Test");
builder.ConfigureAppConfiguration((_, cfg) =>
{
cfg.AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:Default"] = $"Data Source={_dbPath}",
});
});
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (File.Exists(_dbPath))
File.Delete(_dbPath);
}
}
```
- [ ] **Step 2: Write a sanity test against /health**
Replace `backend.tests/UnitTest1.cs` with `backend.tests/HealthTests.cs`:
```csharp
using System.Net;
using FluentAssertions;
using Xunit;
namespace backend.tests;
public class HealthTests(ApiFactory factory) : IClassFixture<ApiFactory>
{
private readonly ApiFactory _factory = factory;
[Fact]
public async Task Health_returns_ok()
{
var client = _factory.CreateClient();
var res = await client.GetAsync("/health");
res.StatusCode.Should().Be(HttpStatusCode.OK);
(await res.Content.ReadAsStringAsync()).Should().Contain("\"ok\":true");
}
}
```
Delete `backend.tests/UnitTest1.cs` if present.
- [ ] **Step 3: Run tests**
Run: `dotnet test`
Expected: `Passed: 1, Failed: 0`.
- [ ] **Step 4: Commit**
```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<Guid>
{
}
```
- [ ] **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<AppDbContext> options)
: IdentityDbContext<ApplicationUser, IdentityRole<Guid>, 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<ApplicationUser, IdentityRole<Guid>>(opt =>
{
opt.Password.RequiredLength = 8;
opt.Password.RequireNonAlphanumeric = false;
opt.User.RequireUniqueEmail = true;
opt.SignIn.RequireConfirmedAccount = false;
})
.AddEntityFrameworkStores<AppDbContext>()
.AddDefaultTokenProviders();
services.ConfigureApplicationCookie(opt =>
{
opt.Cookie.HttpOnly = true;
opt.Cookie.SameSite = SameSiteMode.Lax;
opt.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
opt.ExpireTimeSpan = TimeSpan.FromDays(30);
opt.SlidingExpiration = true;
opt.Events.OnRedirectToLogin = ctx =>
{
ctx.Response.StatusCode = 401;
return Task.CompletedTask;
};
opt.Events.OnRedirectToAccessDenied = ctx =>
{
ctx.Response.StatusCode = 403;
return Task.CompletedTask;
};
});
return services;
}
public static async Task SeedRolesAsync(IServiceProvider sp)
{
var roleMgr = sp.GetRequiredService<RoleManager<IdentityRole<Guid>>>();
foreach (var name in Roles.All)
{
if (!await roleMgr.RoleExistsAsync(name))
await roleMgr.CreateAsync(new IdentityRole<Guid>(name));
}
}
}
```
- [ ] **Step 6: Wire Identity into Program.cs**
Replace `backend/Program.cs`:
```csharp
using backend.Auth;
using backend.Data;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AppDbContext>(opt =>
opt.UseSqlite(builder.Configuration.GetConnectionString("Default")));
builder.Services.AddAppIdentity();
builder.Services.AddAuthorization();
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var sp = scope.ServiceProvider;
sp.GetRequiredService<AppDbContext>().Database.Migrate();
await IdentitySetup.SeedRolesAsync(sp);
}
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/health", () => Results.Ok(new { ok = true }));
app.Run();
public partial class Program { }
```
- [ ] **Step 7: Generate Identity migration**
```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<ApiFactory>
{
private readonly ApiFactory _factory = factory;
[Fact]
public async Task Register_then_login_then_me_returns_user()
{
var client = _factory.CreateClient();
var reg = await client.PostAsJsonAsync("/api/auth/register",
new { email = "first@example.com", password = "Passw0rd!" });
reg.StatusCode.Should().Be(HttpStatusCode.OK);
var login = await client.PostAsJsonAsync("/api/auth/login",
new { email = "first@example.com", password = "Passw0rd!" });
login.StatusCode.Should().Be(HttpStatusCode.OK);
var me = await client.GetAsync("/api/auth/me");
me.StatusCode.Should().Be(HttpStatusCode.OK);
(await me.Content.ReadAsStringAsync()).Should().Contain("first@example.com");
}
[Fact]
public async Task Me_without_login_returns_401()
{
var client = _factory.CreateClient();
var res = await client.GetAsync("/api/auth/me");
res.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
[Fact]
public async Task First_user_is_admin()
{
var client = _factory.CreateClient();
await client.PostAsJsonAsync("/api/auth/register",
new { email = "first@example.com", password = "Passw0rd!" });
await client.PostAsJsonAsync("/api/auth/login",
new { email = "first@example.com", password = "Passw0rd!" });
var me = await client.GetAsync("/api/auth/me");
(await me.Content.ReadAsStringAsync()).Should().Contain("\"isAdmin\":true");
}
}
```
- [ ] **Step 2: Run the test to see it fail**
Run: `dotnet test --filter AuthTests`
Expected: 3 failures (endpoints not defined).
- [ ] **Step 3: Implement the endpoints**
Create `backend/Auth/AuthEndpoints.cs`:
```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<ApplicationUser> users,
RoleManager<IdentityRole<Guid>> roles) =>
{
var user = new ApplicationUser { UserName = req.Email, Email = req.Email };
var create = await users.CreateAsync(user, req.Password);
if (!create.Succeeded)
return Results.BadRequest(new { errors = create.Errors.Select(e => e.Description) });
var anyAdmin = await users.GetUsersInRoleAsync(Roles.Admin);
var role = anyAdmin.Count == 0 ? Roles.Admin : Roles.User;
await users.AddToRoleAsync(user, role);
return Results.Ok(new { id = user.Id, email = user.Email, role });
});
grp.MapPost("/login", async (
[FromBody] LoginRequest req,
SignInManager<ApplicationUser> signIn) =>
{
var res = await signIn.PasswordSignInAsync(req.Email, req.Password,
isPersistent: true, lockoutOnFailure: false);
return res.Succeeded ? Results.Ok() : Results.Unauthorized();
});
grp.MapPost("/logout", async (SignInManager<ApplicationUser> signIn) =>
{
await signIn.SignOutAsync();
return Results.Ok();
}).RequireAuthorization();
grp.MapGet("/me", async (
UserManager<ApplicationUser> users,
HttpContext http) =>
{
var user = await users.GetUserAsync(http.User);
if (user is null) return Results.Unauthorized();
var rolesList = await users.GetRolesAsync(user);
return Results.Ok(new
{
id = user.Id,
email = user.Email,
isAdmin = rolesList.Contains(Roles.Admin),
});
}).RequireAuthorization();
return app;
}
}
```
- [ ] **Step 4: Call `MapAuthEndpoints` in Program.cs**
In `backend/Program.cs`, add the call after `app.UseAuthorization();`:
```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<AppDbContext> options)
: IdentityDbContext<ApplicationUser, IdentityRole<Guid>, Guid>(options)
{
public DbSet<Device> Devices => Set<Device>();
public DbSet<DeviceConfig> DeviceConfigs => Set<DeviceConfig>();
public DbSet<SystemSettings> SystemSettings => Set<SystemSettings>();
protected override void OnModelCreating(ModelBuilder b)
{
base.OnModelCreating(b);
b.Entity<Device>().HasIndex(d => d.OwnerUserId);
b.Entity<Device>().HasIndex(d => d.TokenHash).IsUnique();
b.Entity<Device>()
.HasOne(d => d.Config)
.WithOne()
.HasForeignKey<DeviceConfig>(c => c.DeviceId)
.OnDelete(DeleteBehavior.Cascade);
b.Entity<DeviceConfig>().HasKey(c => c.DeviceId);
b.Entity<SystemSettings>().HasKey(s => s.Id);
}
}
```
- [ ] **Step 5: Seed SystemSettings on startup**
In `backend/Program.cs`, replace the existing `using (var scope = ...)` block with:
```csharp
using (var scope = app.Services.CreateScope())
{
var sp = scope.ServiceProvider;
var db = sp.GetRequiredService<AppDbContext>();
db.Database.Migrate();
await IdentitySetup.SeedRolesAsync(sp);
if (!db.SystemSettings.Any())
{
db.SystemSettings.Add(new backend.Settings.SystemSettings());
await db.SaveChangesAsync();
}
}
```
- [ ] **Step 6: Generate migration**
```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<byte> bytes = stackalloc byte[32];
RandomNumberGenerator.Fill(bytes);
var plaintext = Base64UrlEncode(bytes);
return (plaintext, Hash(plaintext));
}
public string Hash(string plaintext)
{
Span<byte> hash = stackalloc byte[32];
SHA256.HashData(Encoding.UTF8.GetBytes(plaintext), hash);
return Convert.ToHexString(hash);
}
public bool Verify(string plaintext, string expectedHash)
{
var actual = Hash(plaintext);
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(actual),
Encoding.UTF8.GetBytes(expectedHash));
}
private static string Base64UrlEncode(ReadOnlySpan<byte> bytes)
=> Convert.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
```
- [ ] **Step 4: Run the tests**
Run: `dotnet test --filter DeviceTokenServiceTests`
Expected: `Passed: 3`.
- [ ] **Step 5: Commit**
```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<PairingCode> PairingCodes => Set<PairingCode>();
```
And inside `OnModelCreating` add:
```csharp
b.Entity<PairingCode>().HasKey(p => p.Code);
b.Entity<PairingCode>().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<byte> bytes = stackalloc byte[CodeLength];
RandomNumberGenerator.Fill(bytes);
Span<char> chars = stackalloc char[CodeLength];
for (var i = 0; i < CodeLength; i++)
chars[i] = Alphabet[bytes[i] % Alphabet.Length];
return new string(chars);
}
public async Task<PairingCode> IssueAsync(Guid ownerUserId, string deviceName, CancellationToken ct)
{
var entity = new PairingCode
{
Code = GenerateCode(),
OwnerUserId = ownerUserId,
DeviceName = deviceName,
ExpiresAt = DateTimeOffset.UtcNow.Add(CodeTtl),
};
db.PairingCodes.Add(entity);
await db.SaveChangesAsync(ct);
return entity;
}
public async Task<PairResult?> ConsumeAsync(string code, CancellationToken ct)
{
var now = DateTimeOffset.UtcNow;
var pc = await db.PairingCodes.FirstOrDefaultAsync(p => p.Code == code, ct);
if (pc is null || pc.ConsumedAt is not null || pc.ExpiresAt < now)
return null;
var (plaintext, hash) = tokens.Generate();
var defaults = await db.SystemSettings.FirstAsync(ct);
var device = new Device
{
OwnerUserId = pc.OwnerUserId,
Name = pc.DeviceName,
PairedAt = now,
TokenHash = hash,
};
device.Config = new DeviceConfig
{
DeviceId = device.Id,
SystemPrompt = defaults.DefaultSystemPrompt,
Voice = defaults.DefaultVoice,
Model = defaults.DefaultModel,
IdleTimeoutSeconds = defaults.DefaultIdleTimeoutSeconds,
};
db.Devices.Add(device);
pc.ConsumedAt = now;
await db.SaveChangesAsync(ct);
return new PairResult(device.Id, plaintext);
}
}
```
- [ ] **Step 4: Run the tests**
Run: `dotnet test --filter PairingServiceTests`
Expected: `Passed: 2`.
- [ ] **Step 5: Commit**
```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<ApiFactory>
{
private readonly ApiFactory _factory = factory;
private async Task<HttpClient> SignedInClient()
{
var client = _factory.CreateClient();
await client.PostAsJsonAsync("/api/auth/register",
new { email = "owner@example.com", password = "Passw0rd!" });
await client.PostAsJsonAsync("/api/auth/login",
new { email = "owner@example.com", password = "Passw0rd!" });
return client;
}
[Fact]
public async Task Anonymous_user_cannot_request_a_pairing_code()
{
var client = _factory.CreateClient();
var res = await client.PostAsJsonAsync("/api/pair-code", new { name = "kitchen" });
res.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
[Fact]
public async Task Pair_code_then_pair_returns_token_and_device_id()
{
var client = await SignedInClient();
var codeRes = await client.PostAsJsonAsync("/api/pair-code", new { name = "kitchen" });
codeRes.StatusCode.Should().Be(HttpStatusCode.OK);
var codeBody = await codeRes.Content.ReadFromJsonAsync<Dictionary<string, object>>();
var code = codeBody!["code"].ToString()!;
var pairRes = await _factory.CreateClient().PostAsJsonAsync("/api/pair",
new { code, hostname = "rpi", client_version = "0.1" });
pairRes.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await pairRes.Content.ReadAsStringAsync();
body.Should().Contain("device_id");
body.Should().Contain("device_token");
body.Should().Contain("backend_ws");
}
[Fact]
public async Task Pair_with_invalid_code_returns_404()
{
var res = await _factory.CreateClient().PostAsJsonAsync("/api/pair",
new { code = "ZZZZZZZZ", hostname = "rpi", client_version = "0.1" });
res.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task Pair_with_consumed_code_returns_404()
{
var client = await SignedInClient();
var codeRes = await client.PostAsJsonAsync("/api/pair-code", new { name = "kitchen" });
var code = (await codeRes.Content.ReadFromJsonAsync<Dictionary<string, object>>())!["code"].ToString()!;
var first = await _factory.CreateClient().PostAsJsonAsync("/api/pair",
new { code, hostname = "rpi", client_version = "0.1" });
first.StatusCode.Should().Be(HttpStatusCode.OK);
var second = await _factory.CreateClient().PostAsJsonAsync("/api/pair",
new { code, hostname = "rpi", client_version = "0.1" });
second.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
}
```
- [ ] **Step 2: Run the tests to see them fail**
Run: `dotnet test --filter PairingEndpointsTests`
Expected: 4 failures.
- [ ] **Step 3: Implement the endpoints**
Create `backend/Pairing/PairingEndpoints.cs`:
```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<backend.Devices.DeviceTokenService>();
builder.Services.AddScoped<backend.Pairing.PairingService>();
```
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<ApiFactory>
{
private readonly ApiFactory _factory = factory;
private async Task<(HttpClient signedIn, string code)> SetupWithPairCode(string email)
{
var client = _factory.CreateClient();
await client.PostAsJsonAsync("/api/auth/register",
new { email, password = "Passw0rd!" });
await client.PostAsJsonAsync("/api/auth/login",
new { email, password = "Passw0rd!" });
var codeRes = await client.PostAsJsonAsync("/api/pair-code", new { name = "kitchen" });
var body = await codeRes.Content.ReadFromJsonAsync<Dictionary<string, object>>();
return (client, body!["code"].ToString()!);
}
[Fact]
public async Task User_sees_only_their_own_devices()
{
var (alice, codeA) = await SetupWithPairCode("alice@example.com");
await _factory.CreateClient().PostAsJsonAsync("/api/pair",
new { code = codeA, hostname = "rpi", client_version = "0.1" });
var (bob, codeB) = await SetupWithPairCode("bob@example.com");
await _factory.CreateClient().PostAsJsonAsync("/api/pair",
new { code = codeB, hostname = "rpi", client_version = "0.1" });
var aliceList = await alice.GetStringAsync("/api/devices");
var bobList = await bob.GetStringAsync("/api/devices");
aliceList.Should().Contain("kitchen");
bobList.Should().Contain("kitchen");
// Each should have exactly one device — IDs differ.
aliceList.Should().NotBe(bobList);
}
[Fact]
public async Task Rename_device_updates_name()
{
var (client, code) = await SetupWithPairCode("owner@example.com");
await _factory.CreateClient().PostAsJsonAsync("/api/pair",
new { code, hostname = "rpi", client_version = "0.1" });
var listJson = await client.GetFromJsonAsync<List<Dictionary<string, object>>>("/api/devices");
var id = listJson![0]["id"].ToString();
var patchRes = await client.PatchAsJsonAsync($"/api/devices/{id}", new { name = "living-room" });
patchRes.StatusCode.Should().Be(HttpStatusCode.OK);
var after = await client.GetStringAsync($"/api/devices/{id}");
after.Should().Contain("living-room");
}
[Fact]
public async Task Revoke_device_sets_is_revoked_true()
{
var (client, code) = await SetupWithPairCode("owner@example.com");
await _factory.CreateClient().PostAsJsonAsync("/api/pair",
new { code, hostname = "rpi", client_version = "0.1" });
var listJson = await client.GetFromJsonAsync<List<Dictionary<string, object>>>("/api/devices");
var id = listJson![0]["id"].ToString();
var del = await client.DeleteAsync($"/api/devices/{id}");
del.StatusCode.Should().Be(HttpStatusCode.OK);
var after = await client.GetStringAsync($"/api/devices/{id}");
after.Should().Contain("\"isRevoked\":true");
}
}
```
- [ ] **Step 2: Run the tests to see them fail**
Run: `dotnet test --filter DevicesEndpointsTests`
Expected: failures (endpoints not defined).
- [ ] **Step 3: Implement the endpoints**
Create `backend/Devices/DevicesEndpoints.cs`:
```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" <<UNIT
[Unit]
Description=Smart Assistant Pi client
After=network-online.target
[Service]
Environment=PA_ALSA_PLUGHW=1
ExecStart=$ASSISTANT_HOME/.venv/bin/python -m client.main
WorkingDirectory=$ASSISTANT_HOME/code
Restart=on-failure
RestartSec=5
[Install]
WantedBy=default.target
UNIT
loginctl enable-linger "$USER" 2>/dev/null || true
systemctl --user daemon-reload
systemctl --user enable --now assistant
echo "Done. systemctl --user status assistant"
""";
}
}
```
- [ ] **Step 2: Write an inline smoke check**
Add to the bottom of the existing `backend.tests/HealthTests.cs` (or create a tiny test file `InstallScriptBuilderTests.cs`):
Create `backend.tests/InstallScriptBuilderTests.cs`:
```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<ApiFactory>
{
private readonly ApiFactory _factory = factory;
[Fact]
public async Task Install_sh_returns_shell_script_with_request_host()
{
var client = _factory.CreateClient();
var res = await client.GetAsync("/install.sh");
res.StatusCode.Should().Be(HttpStatusCode.OK);
res.Content.Headers.ContentType!.MediaType.Should().Be("text/x-shellscript");
var body = await res.Content.ReadAsStringAsync();
body.Should().StartWith("#!/usr/bin/env bash");
body.Should().Contain("BACKEND_URL=\"http://localhost\"");
}
[Fact]
public async Task Client_tarball_returns_octet_stream()
{
var tmp = Path.Combine(Path.GetTempPath(), "client.tar.gz");
File.WriteAllBytes(tmp, new byte[] { 0x1f, 0x8b }); // tiny gzip-ish marker
var client = _factory.WithWebHostBuilder(b =>
{
b.ConfigureAppConfiguration((_, cfg) =>
{
cfg.AddInMemoryCollection(new Dictionary<string, string?>
{
["Install:ClientTarballPath"] = tmp,
});
});
}).CreateClient();
var res = await client.GetAsync("/client.tar.gz");
res.StatusCode.Should().Be(HttpStatusCode.OK);
res.Content.Headers.ContentType!.MediaType.Should().Be("application/gzip");
(await res.Content.ReadAsByteArrayAsync()).Length.Should().Be(2);
}
}
```
- [ ] **Step 2: Run the tests to see them fail**
Run: `dotnet test --filter InstallEndpointsTests`
Expected: failures (endpoints not defined).
- [ ] **Step 3: Implement the endpoints**
Create `backend/Install/InstallEndpoints.cs`:
```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<backend.Install.InstallScriptBuilder>();
```
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": "<fill from Coolify project list>",
"server_uuid": "<fill from Coolify server list>",
"destination_uuid": "<fill from server destinations>",
"domain": "https://assistant.example.com",
"build_pack": "dockerfile",
"dockerfile": "Dockerfile",
"ports_exposes": "8080",
"env": {
"ASPNETCORE_URLS": "http://+:8080",
"ConnectionStrings__Default": "Data Source=/data/assistant.db",
"Install__ClientTarballPath": "/app/wwwroot/client.tar.gz"
},
"secrets": [
"OPENAI_API_KEY"
],
"volumes": [
{ "host_path": "smart-assistant-data", "mount_path": "/data" },
{ "host_path": "smart-assistant-keys", "mount_path": "/keys" }
]
}
```
- [ ] **Step 3: Create or update the Coolify application via the API**
Use the `deploying-to-coolify-via-api` skill. Follow it end-to-end: discover project/server UUIDs, POST `/api/v1/applications/dockerfile`, set env vars (including the `OPENAI_API_KEY` secret, value from the user — do not log), set domain, set volumes.
- [ ] **Step 4: Trigger a deploy (fire-and-forget per global CLAUDE.md)**
```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://<your-domain>"
COOKIE=$(mktemp)
curl -sf -c "$COOKIE" -X POST "$DOMAIN/api/auth/register" \
-H 'content-type: application/json' \
-d '{"email":"you@example.com","password":"Passw0rd!"}'
curl -sf -c "$COOKIE" -b "$COOKIE" -X POST "$DOMAIN/api/auth/login" \
-H 'content-type: application/json' \
-d '{"email":"you@example.com","password":"Passw0rd!"}'
CODE=$(curl -sf -c "$COOKIE" -b "$COOKIE" -X POST "$DOMAIN/api/pair-code" \
-H 'content-type: application/json' \
-d '{"name":"kitchen"}' | grep -oE '"code":"[^"]+"' | cut -d'"' -f4)
curl -sf -X POST "$DOMAIN/api/pair" \
-H 'content-type: application/json' \
-d "{\"code\":\"$CODE\",\"hostname\":\"smoke\",\"client_version\":\"0.1\"}"
curl -sIf "$DOMAIN/client.tar.gz"
```
## Deploying
Update `deploy.json` and trigger a deploy via the Coolify v4 API:
```sh
curl -sf -X POST -H "Authorization: Bearer $COOLIFY_KEY" \
"$COOLIFY_URL/api/v1/deploy?uuid=<app-uuid>"
```
Fire-and-forget; do not poll (per global CLAUDE.md).
```
- [ ] **Step 6: Commit**
```bash
git add README.md
git commit -m "Add README with smoke-test instructions"
```
---
## Self-review notes (already applied)
- All spec items in scope of Plan 1 are covered: Identity + roles (Tasks 4, 5), Devices + DeviceConfig (Tasks 6, 11), DeviceTokenService (Task 7), PairingCode + PairingService (Tasks 8, 9), pairing endpoints (Task 10), SystemSettings entity + seed (Task 6 step 5), install endpoints (Tasks 12, 13), Python placeholders (Task 14), Dockerfile (Task 15), data-protection keys (Task 16), Coolify deploy (Task 17), smoke test (Task 18).
- No `TBD`/`TODO`/`placeholder` text in instructional steps (only "placeholder" used in the literal docstrings the engineer copies in for Python stubs, which is correct).
- Type / property / method names match across tasks: `Device.OwnerUserId`, `Device.IsRevoked`, `DeviceConfig.EnabledToolsJson`, `PairingService.GenerateCode()`, `PairingService.IssueAsync(...)`, `PairingService.ConsumeAsync(...)`, `DeviceTokenService.{Generate,Hash,Verify}` — all consistent.
- Out of Plan 1: SystemSettings admin endpoints (Plan 4), DeviceConfig edit endpoint (Plan 4 — Plan 1 returns `config` via the device GET as read-only), device WebSocket hub (Plan 2), Realtime relay (Plan 2), tools registry (Plan 2), full Python client (Plan 3). These are not gaps; they are explicitly deferred.