Compare commits

...

10 Commits

28 changed files with 1234 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
**/bin
**/obj
**/.vs
.git
.gitignore
.vscode
.idea
docs
tests
backend.tests
*.md
.dockerignore
Dockerfile
+1
View File
@@ -14,3 +14,4 @@ backend.tests/obj/
backend/*.db
backend/*.db-shm
backend/*.db-wal
deploy.json
+32
View File
@@ -0,0 +1,32 @@
# 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"]
+36
View File
@@ -0,0 +1,36 @@
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().BeGreaterThanOrEqualTo(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();
}
}
+77
View File
@@ -0,0 +1,77 @@
using System.Net;
using System.Net.Http.Json;
using FluentAssertions;
using Xunit;
namespace backend.tests;
public class DevicesEndpointsTests
{
private static async Task<(HttpClient signedIn, string code)> SetupWithPairCode(ApiFactory factory, string email = "owner@example.com")
{
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()
{
using var factory = new ApiFactory();
var (alice, codeA) = await SetupWithPairCode(factory, "alice@example.com");
await factory.CreateClient().PostAsJsonAsync("/api/pair",
new { code = codeA, hostname = "rpi", client_version = "0.1" });
var (bob, codeB) = await SetupWithPairCode(factory, "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");
aliceList.Should().NotBe(bobList);
}
[Fact]
public async Task Rename_device_updates_name()
{
using var factory = new ApiFactory();
var (client, code) = await SetupWithPairCode(factory);
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()
{
using var factory = new ApiFactory();
var (client, code) = await SetupWithPairCode(factory);
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");
}
}
+55
View File
@@ -0,0 +1,55 @@
using System.Net;
using FluentAssertions;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Xunit;
namespace backend.tests;
public class InstallEndpointsTests
{
[Fact]
public async Task Install_sh_returns_shell_script_with_request_host()
{
using var factory = new ApiFactory();
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-test-{Guid.NewGuid():N}.tar.gz");
File.WriteAllBytes(tmp, new byte[] { 0x1f, 0x8b }); // tiny gzip-ish marker
try
{
using var factory = new ApiFactory();
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);
}
finally
{
if (File.Exists(tmp)) File.Delete(tmp);
}
}
}
@@ -0,0 +1,18 @@
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");
}
}
+73
View File
@@ -0,0 +1,73 @@
using System.Net;
using System.Net.Http.Json;
using FluentAssertions;
using Xunit;
namespace backend.tests;
public class PairingEndpointsTests
{
private static async Task<HttpClient> SignedInClient(ApiFactory factory, string email = "owner@example.com")
{
var client = factory.CreateClient();
await client.PostAsJsonAsync("/api/auth/register",
new { email, password = "Passw0rd!" });
await client.PostAsJsonAsync("/api/auth/login",
new { email, password = "Passw0rd!" });
return client;
}
[Fact]
public async Task Anonymous_user_cannot_request_a_pairing_code()
{
using var factory = new ApiFactory();
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()
{
using var factory = new ApiFactory();
var client = await SignedInClient(factory);
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()
{
using var factory = new ApiFactory();
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()
{
using var factory = new ApiFactory();
var client = await SignedInClient(factory);
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);
}
}
+28
View File
@@ -0,0 +1,28 @@
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);
}
}
+4
View File
@@ -1,5 +1,6 @@
using backend.Auth;
using backend.Devices;
using backend.Pairing;
using backend.Settings;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
@@ -13,6 +14,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<Device> Devices => Set<Device>();
public DbSet<DeviceConfig> DeviceConfigs => Set<DeviceConfig>();
public DbSet<SystemSettings> SystemSettings => Set<SystemSettings>();
public DbSet<PairingCode> PairingCodes => Set<PairingCode>();
protected override void OnModelCreating(ModelBuilder b)
{
@@ -32,5 +34,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options)
b.Entity<DeviceConfig>().HasKey(c => c.DeviceId);
b.Entity<SystemSettings>().HasKey(s => s.Id);
b.Entity<SystemSettings>().Property(s => s.Id).ValueGeneratedNever();
b.Entity<PairingCode>().HasKey(p => p.Code);
b.Entity<PairingCode>().HasIndex(p => p.ExpiresAt);
}
}
+36
View File
@@ -0,0 +1,36 @@
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('/', '_');
}
+83
View File
@@ -0,0 +1,83 @@
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;
}
}
+28
View File
@@ -0,0 +1,28 @@
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;
}
}
+70
View File
@@ -0,0 +1,70 @@
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"
""";
}
}
@@ -0,0 +1,405 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using backend.Data;
#nullable disable
namespace backend.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260611185048_AddPairingCodes")]
partial class AddPairingCodes
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.17");
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<Guid>("RoleId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("ProviderKey")
.HasColumnType("TEXT");
b.Property<string>("ProviderDisplayName")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.Property<Guid>("RoleId")
.HasColumnType("TEXT");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("backend.Auth.ApplicationUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("backend.Devices.Device", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("IsRevoked")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LastSeenAt")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<Guid>("OwnerUserId")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("PairedAt")
.HasColumnType("TEXT");
b.Property<string>("TokenHash")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("OwnerUserId");
b.HasIndex("TokenHash")
.IsUnique();
b.ToTable("Devices");
});
modelBuilder.Entity("backend.Devices.DeviceConfig", b =>
{
b.Property<Guid>("DeviceId")
.HasColumnType("TEXT");
b.Property<string>("EnabledToolsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("IdleTimeoutSeconds")
.HasColumnType("INTEGER");
b.Property<string>("Model")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("SystemPrompt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Voice")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("DeviceId");
b.ToTable("DeviceConfigs");
});
modelBuilder.Entity("backend.Pairing.PairingCode", b =>
{
b.Property<string>("Code")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("ConsumedAt")
.HasColumnType("TEXT");
b.Property<string>("DeviceName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("TEXT");
b.Property<Guid>("OwnerUserId")
.HasColumnType("TEXT");
b.HasKey("Code");
b.HasIndex("ExpiresAt");
b.ToTable("PairingCodes");
});
modelBuilder.Entity("backend.Settings.SystemSettings", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER");
b.Property<int>("DefaultIdleTimeoutSeconds")
.HasColumnType("INTEGER");
b.Property<string>("DefaultModel")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("DefaultSystemPrompt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("DefaultVoice")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("SystemSettings");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.HasOne("backend.Auth.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("backend.Auth.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("backend.Auth.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.HasOne("backend.Auth.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("backend.Devices.Device", b =>
{
b.HasOne("backend.Auth.ApplicationUser", null)
.WithMany()
.HasForeignKey("OwnerUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("backend.Devices.DeviceConfig", b =>
{
b.HasOne("backend.Devices.Device", null)
.WithOne("Config")
.HasForeignKey("backend.Devices.DeviceConfig", "DeviceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("backend.Devices.Device", b =>
{
b.Navigation("Config");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,42 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace backend.Migrations
{
/// <inheritdoc />
public partial class AddPairingCodes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PairingCodes",
columns: table => new
{
Code = table.Column<string>(type: "TEXT", nullable: false),
OwnerUserId = table.Column<Guid>(type: "TEXT", nullable: false),
DeviceName = table.Column<string>(type: "TEXT", nullable: false),
ExpiresAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
ConsumedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PairingCodes", x => x.Code);
});
migrationBuilder.CreateIndex(
name: "IX_PairingCodes_ExpiresAt",
table: "PairingCodes",
column: "ExpiresAt");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PairingCodes");
}
}
}
@@ -273,6 +273,31 @@ namespace backend.Migrations
b.ToTable("DeviceConfigs");
});
modelBuilder.Entity("backend.Pairing.PairingCode", b =>
{
b.Property<string>("Code")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("ConsumedAt")
.HasColumnType("TEXT");
b.Property<string>("DeviceName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("TEXT");
b.Property<Guid>("OwnerUserId")
.HasColumnType("TEXT");
b.HasKey("Code");
b.HasIndex("ExpiresAt");
b.ToTable("PairingCodes");
});
modelBuilder.Entity("backend.Settings.SystemSettings", b =>
{
b.Property<int>("Id")
+10
View File
@@ -0,0 +1,10 @@
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; }
}
+47
View File
@@ -0,0 +1,47 @@
using System.Security.Claims;
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;
}
}
+84
View File
@@ -0,0 +1,84 @@
using System.Security.Cryptography;
using backend.Data;
using backend.Devices;
using Microsoft.EntityFrameworkCore;
namespace backend.Pairing;
public record PairResult(Guid DeviceId, string DeviceToken);
public class PairingService
{
private const string Alphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; // no O, 0, I, 1, L
private const int CodeLength = 8;
public static readonly TimeSpan CodeTtl = TimeSpan.FromMinutes(10);
private readonly AppDbContext? _db;
private readonly DeviceTokenService? _tokens;
public PairingService() { }
public PairingService(AppDbContext db, DeviceTokenService tokens)
{
_db = db;
_tokens = tokens;
}
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)
{
if (_db is null) throw new InvalidOperationException("PairingService not constructed with DB context.");
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)
{
if (_db is null || _tokens is null)
throw new InvalidOperationException("PairingService not constructed with DB context + token service.");
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);
}
}
+19
View File
@@ -1,15 +1,31 @@
using backend.Auth;
using backend.Data;
using backend.Devices;
using backend.Install;
using backend.Pairing;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
var keysDir = Environment.GetEnvironmentVariable("DataProtection__KeysDir") ?? "/keys";
if (Directory.Exists(keysDir))
{
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(keysDir))
.SetApplicationName("smart-assistant");
}
builder.Services.AddDbContext<AppDbContext>(opt =>
opt.UseSqlite(builder.Configuration.GetConnectionString("Default")));
builder.Services.AddAppIdentity();
builder.Services.AddAuthorization();
builder.Services.AddScoped<backend.Devices.DeviceTokenService>();
builder.Services.AddScoped<backend.Pairing.PairingService>();
builder.Services.AddSingleton<backend.Install.InstallScriptBuilder>();
var app = builder.Build();
using (var scope = app.Services.CreateScope())
@@ -30,6 +46,9 @@ app.UseAuthentication();
app.UseAuthorization();
app.MapAuthEndpoints();
app.MapPairingEndpoints();
app.MapDevicesEndpoints();
app.MapInstallEndpoints();
app.MapGet("/health", () => Results.Ok(new { ok = true }));
+1
View File
@@ -7,6 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="9.*" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.*" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.*">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
View File
+9
View File
@@ -0,0 +1,9 @@
"""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()
+9
View File
@@ -0,0 +1,9 @@
"""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()
+20
View File
@@ -0,0 +1,20 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
expose:
- "8080"
environment:
- ASPNETCORE_URLS=http://+:8080
- 'ConnectionStrings__Default=Data Source=/data/assistant.db'
- Install__ClientTarballPath=/app/wwwroot/client.tar.gz
- SERVICE_FQDN_APP_8080
volumes:
- assistant-data:/data
- assistant-keys:/keys
restart: unless-stopped
volumes:
assistant-data:
assistant-keys:
+1
View File
@@ -0,0 +1 @@
openwakeword==0.6.0
+8
View File
@@ -0,0 +1,8 @@
sounddevice==0.5.1
numpy>=2.0
scipy>=1.13
onnxruntime>=1.18
scikit-learn
tqdm
requests
websockets>=12.0