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:
1 parent
0fd2a269d9
commit
d946fc5e8a
35 files changed
+517
-490
No files matched your search
@@ -18,7 +18,7 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
|
||||
[Fact]
|
||||
public async Task AccountKeyPackagesAreScopedToAcceptedMembersAndRemovedOnRevocation()
|
||||
{
|
||||
var ch = await server.CreateAsync(settings: new { name = "Key package space", seats = 8 });
|
||||
var ch = await server.CreateAsync(settings: new { name = "Key package space", max_members = 8 });
|
||||
var username = "keys_" + Guid.NewGuid().ToString("N")[..8]; var token = await Register(username);
|
||||
var member = new Who("read", ch.ReadToken, RandomNumberGenerator.GetBytes(32));
|
||||
using (var ownerPackage = server.Req(HttpMethod.Put, $"/api/v1/channels/{ch.Id}/key-package", ch.Owner, JsonBody(new { payload = "opaque-owner-package" })))
|
||||
@@ -31,7 +31,7 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
|
||||
using (var invite = await InviteAsync(ch, username, "opaque-member-package")) Assert.Equal(HttpStatusCode.NoContent, invite.StatusCode);
|
||||
using (var pending = Request(HttpMethod.Get, "/api/v1/accounts/channels", member, token))
|
||||
Assert.Empty((await ReadJsonAsync(await server.Http.SendAsync(pending))).EnumerateArray());
|
||||
using (var accept = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", member, token))
|
||||
using (var accept = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", member, token))
|
||||
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(accept)).StatusCode);
|
||||
using (var delivered = Request(HttpMethod.Get, "/api/v1/accounts/channels", member, token))
|
||||
{
|
||||
@@ -90,7 +90,7 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
|
||||
byte[] ownerToken = RandomNumberGenerator.GetBytes(32); byte[] endpoint = RandomNumberGenerator.GetBytes(32);
|
||||
var owner = new Who("owner", ownerToken, endpoint); string spaceId = NewId();
|
||||
using (var create = Request(HttpMethod.Post, "/api/v1/channels", owner, token,
|
||||
new { id = spaceId, mode = "raw", settings = new { name = "Signed chat", seats = 2 }, owner_hash = B64(TokenHash.Compute(ownerToken)) }))
|
||||
new { id = spaceId, mode = "raw", settings = new { name = "Signed chat", max_members = 2 }, owner_hash = B64(TokenHash.Compute(ownerToken)) }))
|
||||
Assert.Equal(HttpStatusCode.Created, (await server.Http.SendAsync(create)).StatusCode);
|
||||
string channelId;
|
||||
using (var structure = Request(HttpMethod.Get, $"/api/v1/spaces/{spaceId}/structure", owner, token))
|
||||
@@ -163,26 +163,27 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
|
||||
var firstId = Guid.NewGuid().ToString(); var secondId = Guid.NewGuid().ToString();
|
||||
var firstToken = accounts.Authenticate(username, verifier, firstId, "Desktop")!;
|
||||
var secondToken = accounts.Authenticate(username, verifier, secondId, "Phone")!;
|
||||
var ch = await server.CreateAsync(settings: new { name = "Device admission space", seats = 2, join_policy = "open", allow_guests = true });
|
||||
var ch = await server.CreateAsync(settings: new { name = "Device admission space", max_members = 2, join_policy = "open", allow_guests = true });
|
||||
using (var invite = await InviteAsync(ch, username, "opaque-key-package")) Assert.Equal(HttpStatusCode.NoContent, invite.StatusCode);
|
||||
var first = new Who("read", ch.ReadToken, RandomNumberGenerator.GetBytes(32)); var second = first with { EndpointSecret = RandomNumberGenerator.GetBytes(32) };
|
||||
foreach (var (who, token) in new[] { (first, firstToken), (second, secondToken) })
|
||||
{
|
||||
using var joined = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", who, token);
|
||||
using var joined = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", who, token);
|
||||
using var response = await server.Http.SendAsync(joined);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(2, (await ReadJsonAsync(response)).GetProperty("seats_used").GetInt32());
|
||||
Assert.Equal(2, (await ReadJsonAsync(response)).GetProperty("members_used").GetInt32());
|
||||
}
|
||||
using (var info = server.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}", ch.Owner))
|
||||
Assert.Equal(2, (await ReadJsonAsync(await server.Http.SendAsync(info))).GetProperty("seats_used").GetInt32());
|
||||
Assert.Equal(2, (await ReadJsonAsync(await server.Http.SendAsync(info))).GetProperty("members_used").GetInt32());
|
||||
var unrelated = await Register("full_" + Guid.NewGuid().ToString("N")[..8]);
|
||||
var third = new Who("read", ch.ReadToken, RandomNumberGenerator.GetBytes(32));
|
||||
using (var full = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", third, unrelated))
|
||||
await AssertErrorAsync(await server.Http.SendAsync(full), 403, "seats_full");
|
||||
using (var full = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", third, unrelated))
|
||||
await AssertErrorAsync(await server.Http.SendAsync(full), 403, "members_full");
|
||||
Assert.Equal(2, accounts.Devices(username, firstId).Count);
|
||||
Assert.Single(accounts.Devices(username, firstId), d => d.Current);
|
||||
using (var impersonate = Request(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/log", second, firstToken))
|
||||
await AssertErrorAsync(await server.Http.SendAsync(impersonate), 401, "account_required");
|
||||
// 账号成员只凭账号会话鉴权,不再绑定到某个终端:同一账号的任一设备会话都能读取
|
||||
using (var anyDevice = Request(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/log", second, firstToken))
|
||||
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(anyDevice)).StatusCode);
|
||||
using (var revoke = Request(HttpMethod.Delete, $"/api/v1/accounts/devices/{secondId}", first, firstToken))
|
||||
Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(revoke)).StatusCode);
|
||||
Assert.True(accounts.HasSession(firstToken, out _)); Assert.False(accounts.HasSession(secondToken, out _));
|
||||
@@ -195,7 +196,7 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
|
||||
[Fact]
|
||||
public void PartialSettingsPreserveAdmissionDefaults()
|
||||
{
|
||||
var request = JsonSerializer.Deserialize("{\"settings\":{\"seats\":8}}", ApiJson.Default.CreateChannelRequest)!;
|
||||
var request = JsonSerializer.Deserialize("{\"settings\":{\"max_members\":8}}", ApiJson.Default.CreateChannelRequest)!;
|
||||
Assert.Equal(JoinPolicy.Open, request.Settings!.JoinPolicy);
|
||||
Assert.True(request.Settings.AllowGuests);
|
||||
request.Settings.Validate(new ServerLimits());
|
||||
@@ -212,6 +213,32 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
|
||||
await AssertErrorAsync(response, 400, "bad_request");
|
||||
}
|
||||
}
|
||||
[Fact]
|
||||
public async Task AccountMembersNeedNoTerminalKeyAndDoNotOccupyGuestRegistrations()
|
||||
{
|
||||
var ch = await server.CreateAsync(settings: new { name = "Account only", max_members = 3, join_policy = "invite", allow_guests = false });
|
||||
var username = "member_" + Guid.NewGuid().ToString("N")[..8];
|
||||
var account = await Register(username);
|
||||
using (var invite = await InviteAsync(ch, username, "opaque-key-package")) Assert.Equal(HttpStatusCode.NoContent, invite.StatusCode);
|
||||
var who = new Who("read", ch.ReadToken, RandomNumberGenerator.GetBytes(32));
|
||||
using (var join = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", who, account))
|
||||
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(join)).StatusCode);
|
||||
|
||||
// 登录账号不带终端密钥也能鉴权
|
||||
using (var log = Request(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/log", who, account))
|
||||
{
|
||||
log.Headers.Remove("X-WD-Endpoint");
|
||||
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(log)).StatusCode);
|
||||
}
|
||||
// 账号成员不在访客清单里;成员清单里有它,设备数不小于在线设备数
|
||||
using (var guests = server.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/guests", ch.Owner))
|
||||
Assert.Equal(0, (await ReadJsonAsync(await server.Http.SendAsync(guests))).GetArrayLength());
|
||||
using var members = server.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/members", ch.Owner);
|
||||
var list = await ReadJsonAsync(await server.Http.SendAsync(members));
|
||||
var mine = list.EnumerateArray().Single(m => m.GetProperty("username").GetString() == username);
|
||||
Assert.True(mine.GetProperty("devices").GetInt32() >= mine.GetProperty("online_devices").GetInt32());
|
||||
}
|
||||
|
||||
private async Task<string> Register(string username)
|
||||
{
|
||||
var accounts = server.App.Services.GetRequiredService<SqliteAccountStore>();
|
||||
@@ -290,32 +317,33 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
|
||||
[InlineData(ChannelMode.Raw)]
|
||||
public async Task PasswordInvitationsAndRevocationUseTheSameChannelRules(ChannelMode mode)
|
||||
{
|
||||
var ch = await server.CreateAsync(mode, new { name = "Password space", seats = 8 });
|
||||
var ch = await server.CreateAsync(mode, new { name = "Password space", max_members = 8 });
|
||||
using (var patch = server.Req(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Owner, JsonBody(new { join_password = "channel-secret" })))
|
||||
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(patch)).StatusCode);
|
||||
var guest = new Who("read", mode == ChannelMode.Enc ? ch.ReadToken : null, RandomNumberGenerator.GetBytes(32));
|
||||
using (var missing = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", guest))
|
||||
using (var missing = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", guest))
|
||||
await AssertErrorAsync(await server.Http.SendAsync(missing), 403, "channel_password_required");
|
||||
using (var wrong = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", guest, body: new { password = "wrong" }))
|
||||
using (var wrong = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", guest, body: new { password = "wrong" }))
|
||||
await AssertErrorAsync(await server.Http.SendAsync(wrong), 403, "channel_password_invalid");
|
||||
using (var correct = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", guest, body: new { password = "channel-secret" }))
|
||||
using (var correct = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", guest, body: new { password = "channel-secret" }))
|
||||
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(correct)).StatusCode);
|
||||
using (var closed = server.Req(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Owner, JsonBody(new { allow_guests = false })))
|
||||
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(closed)).StatusCode);
|
||||
using (var stranger = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", guest with { EndpointSecret = RandomNumberGenerator.GetBytes(32) }, body: new { password = "channel-secret" }))
|
||||
using (var stranger = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", guest with { EndpointSecret = RandomNumberGenerator.GetBytes(32) }, body: new { password = "channel-secret" }))
|
||||
await AssertErrorAsync(await server.Http.SendAsync(stranger), 401, "account_required");
|
||||
using (var patch = server.Req(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Owner, JsonBody(new { join_policy = "invite", allow_guests = false })))
|
||||
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(patch)).StatusCode);
|
||||
var username = "invited_" + Guid.NewGuid().ToString("N")[..8];
|
||||
var account = await Register(username);
|
||||
var member = new Who("read", mode == ChannelMode.Enc ? ch.ReadToken : null, RandomNumberGenerator.GetBytes(32));
|
||||
using (var uninvited = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", member, account))
|
||||
using (var uninvited = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", member, account))
|
||||
await AssertErrorAsync(await server.Http.SendAsync(uninvited), 403, "invitation_required");
|
||||
using (var invite = await InviteAsync(ch, username, "client-encrypted-payload")) Assert.Equal(HttpStatusCode.NoContent, invite.StatusCode);
|
||||
using (var accepted = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", member, account))
|
||||
using (var accepted = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", member, account))
|
||||
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(accepted)).StatusCode);
|
||||
using (var impersonated = Request(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/log", member))
|
||||
await AssertErrorAsync(await server.Http.SendAsync(impersonated), 401, "account_required");
|
||||
// 只有链接令牌与终端密钥、没有账号会话的请求没有成员身份,也没有访客登记
|
||||
using (var withoutAccount = Request(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/log", member))
|
||||
await AssertErrorAsync(await server.Http.SendAsync(withoutAccount), 403, "not_joined");
|
||||
var writer = member with { Role = "write", Token = ch.WriteToken };
|
||||
string channelId;
|
||||
using (var structureRequest = server.Req(HttpMethod.Get, $"/api/v1/spaces/{ch.Id}/structure", ch.Owner))
|
||||
@@ -335,7 +363,7 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
|
||||
}
|
||||
using (var revoke = server.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}/invitations/{username}", ch.Owner))
|
||||
Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(revoke)).StatusCode);
|
||||
using (var rejoin = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", member, account))
|
||||
using (var rejoin = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", member, account))
|
||||
await AssertErrorAsync(await server.Http.SendAsync(rejoin), 403, "forbidden");
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user