feat(api)!: fold devices into users and report member presence

Contract 7.0.0 (breaking). Devices become an attribute of the account;
members are managed per user, and only unauthenticated guests keep a
per-terminal registration.

- rename seats -> max_members, seats_used -> members_used,
  seats_full -> members_full, SeatJoined -> JoinResult, the `seats`
  stream control message -> `members`, and POST /channels/{id}/seats ->
  POST /channels/{id}/join (stored channel settings are migrated on start)
- drop per-terminal encrypted device labels and the seat list/release
  endpoints; add GET/DELETE /channels/{id}/guests for guest terminals
- authenticate account members by account session only: the terminal key
  is optional for logged-in accounts, no seat row is written for them,
  and gated-download grants are counted per user (per terminal for guests)
- GET /channels/{id}/members now returns online, devices and
  online_devices per account; presence is tracked in memory from active
  WebSocket/SSE subscriptions and is not pushed over the stream
- owner invitation list no longer returns accounts that already joined
This commit is contained in:
justme committed 2026-10-14 22:18:59 +08:00
1 parent 0fd2a269d9
commit d946fc5e8a
35 files changed
+517 -490

No files matched your search

@@ -47,18 +47,18 @@ public sealed class WorkspaceApiTests(ServerFixture server) : IClassFixture<Serv
[Fact]
public async Task AdvertisedDefaultsRespectDeploymentLimitsAndMatchCreation()
{
await using var isolated = WithSettings(new() { ["WebDrop:Limits:MaxSeats"] = "3", ["WebDrop:Limits:MinTtlSeconds"] = "120", ["WebDrop:Limits:MaxTtlSeconds"] = "3600" });
await using var isolated = WithSettings(new() { ["WebDrop:Limits:MaxMembers"] = "3", ["WebDrop:Limits:MinTtlSeconds"] = "120", ["WebDrop:Limits:MaxTtlSeconds"] = "3600" });
await isolated.InitializeAsync();
using var infoResponse = await isolated.Http.GetAsync("/api/v1/info"); var info = await ReadJsonAsync(infoResponse);
var defaults = info.GetProperty("defaults").GetProperty("channel");
Assert.Equal("enc", info.GetProperty("defaults").GetProperty("channel_mode").GetString());
Assert.Equal(3, defaults.GetProperty("seats").GetInt32()); Assert.Equal(3600, defaults.GetProperty("ttl_seconds").GetInt32());
Assert.Equal(3, defaults.GetProperty("max_members").GetInt32()); Assert.Equal(3600, defaults.GetProperty("ttl_seconds").GetInt32());
var ch = new Chan(NewId(), ChannelMode.Enc);
using var create = isolated.Req(HttpMethod.Post, "/api/v1/channels", ch.Owner, JsonBody(new { id = ch.Id, mode = "enc", settings = new { name = "Limited space", seats = 3, ttl_seconds = 3600 }, read_hash = B64(TokenHash.Compute(ch.ReadToken)), owner_hash = B64(TokenHash.Compute(ch.OwnerToken)), write_hash = B64(TokenHash.Compute(ch.WriteToken)) }));
using var create = isolated.Req(HttpMethod.Post, "/api/v1/channels", ch.Owner, JsonBody(new { id = ch.Id, mode = "enc", settings = new { name = "Limited space", max_members = 3, ttl_seconds = 3600 }, read_hash = B64(TokenHash.Compute(ch.ReadToken)), owner_hash = B64(TokenHash.Compute(ch.OwnerToken)), write_hash = B64(TokenHash.Compute(ch.WriteToken)) }));
using var result = await isolated.Http.SendAsync(create); Assert.Equal(HttpStatusCode.Created, result.StatusCode);
var settings = (await ReadJsonAsync(result)).GetProperty("settings");
Assert.Equal("Limited space", settings.GetProperty("name").GetString());
Assert.Equal(defaults.GetProperty("seats").GetInt32(), settings.GetProperty("seats").GetInt32());
Assert.Equal(defaults.GetProperty("max_members").GetInt32(), settings.GetProperty("max_members").GetInt32());
Assert.Equal(defaults.GetProperty("ttl_seconds").GetInt32(), settings.GetProperty("ttl_seconds").GetInt32());
}
}