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

@@ -118,7 +118,7 @@ public sealed class ServerFixture : IAsyncLifetime, IAsyncDisposable
};
if (mode == ChannelMode.Enc) body["read_hash"] = B64(TokenHash.Compute(ch.ReadToken));
// General transport tests explicitly use open spaces; creation defaults have separate coverage.
body["settings"] = settings ?? new { name = "Test space", seats = 2, join_policy = "open", allow_guests = true };
body["settings"] = settings ?? new { name = "Test space", max_members = 2, join_policy = "open", allow_guests = true };
using var req = Req(HttpMethod.Post, "/api/v1/channels", ch.Owner, JsonBody(body));
if (capToken is not null) req.Headers.Add("X-WD-Cap", capToken);
@@ -136,7 +136,7 @@ public sealed class ServerFixture : IAsyncLifetime, IAsyncDisposable
}
public async Task<HttpResponseMessage> JoinAsync(Chan ch, Who who) =>
await Http.SendAsync(Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", who));
await Http.SendAsync(Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", who));
public async Task<HttpResponseMessage> AppendAsync(
Chan ch,