cf055c8928
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.
2156 lines
62 KiB
Markdown
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.
|