refactor(host): single-source ChannelPolicy, space-signed file grants, whole-file registration

- all authorization rules (owner/creator, per-channel read/write grants, who may
  remove whom, delete rules, structure management) live in ChannelPolicy; a test
  fails if they are reimplemented elsewhere
- ChannelInfo carries what the caller may do (can_*), member and invitation lists
  say who can be removed; clients display it and never derive it
- the host no longer stores or calls the file service: it only signs short-lived
  tickets from space permissions (create/put/get/delete/domain), and keeps an opaque
  fileDomain reference on the channel
- a whole file is registered in one request (last frame header + per-frame length and
  digest) instead of one request per 1 MiB frame; the signed manifest still has to
  match every digest
- drop file quota/threshold, receipts, reconcile and delete-queue code from the host
- contract generator emits constants for enums, error codes, token roles, permissions
  and share states
This commit is contained in:
justme committed 2026-10-19 13:38:40 +08:00
1 parent ccb2c074d6
commit 17638b6e20
36 files changed
+926 -589

No files matched your search

@@ -159,25 +159,36 @@ public class ChannelServiceTests : IDisposable
}
[Fact]
public async Task FileGroupQuotaIsPerWorkspaceChannelAndFreedOnDelete()
public async Task FileObjectBytesAreNeverStoredByTheHostAWholeFileIsRegisteredByDigestsInOneStep()
{
_env.Limits.MaxWorkspaceChannelBytes = 200;
var space = _env.Create(_ownerEp, ChannelMode.Raw);
var owner = space.Owner(_ownerEp);
var channelId = Svc.GetWorkspace(space.Id, owner).DefaultChannelId!;
var first = TestEnv.NewId(); var second = TestEnv.NewId();
Svc.DeclareObject(space.Id, owner, new ObjectEnvelope { ItemId = first.ToBase64Url(), ChannelId = channelId, Domain = CryptoDomain.Files, Epoch = 0, KeyEnvelope = "" });
Svc.DeclareObject(space.Id, owner, new ObjectEnvelope { ItemId = second.ToBase64Url(), ChannelId = channelId, Domain = CryptoDomain.Files, Epoch = 0, KeyEnvelope = "" });
var item = TestEnv.NewId();
Svc.DeclareObject(space.Id, owner, new ObjectEnvelope { ItemId = item.ToBase64Url(), ChannelId = channelId, Domain = CryptoDomain.Files, Epoch = 0, KeyEnvelope = "" });
byte[] firstFrame = TestEnv.Frame(first, 0, FrameFlags.Final, 120);
await Svc.AppendFrameAsync(space.Id, owner, firstFrame, null);
Assert.Equal(firstFrame.Length, Svc.GetWorkspace(space.Id, owner).Channels.Single().FileBytes);
await AssertCode(ErrorCode.TooLarge, () => Svc.AppendFrameAsync(space.Id, owner, TestEnv.Frame(second, 0, FrameFlags.Final, 60), null));
// 文件域的对象不能把字节直接交给主后端
byte[] first = TestEnv.Frame(item, 0, FrameFlags.Gated, 120);
await AssertCode(ErrorCode.UseFileApi, () => Svc.AppendFrameAsync(space.Id, owner, first, null));
Svc.DeleteItem(space.Id, owner, first);
Assert.Equal(0, Svc.GetWorkspace(space.Id, owner).Channels.Single().FileBytes);
await Svc.AppendFrameAsync(space.Id, owner, TestEnv.Frame(second, 0, FrameFlags.Final, 60), null);
Assert.Equal(86, Svc.GetWorkspace(space.Id, owner).Channels.Single().FileBytes);
// 整个文件一次登记:末帧头部 + 每一帧的(负载长度,整帧摘要)
byte[][] frames = [first, TestEnv.Frame(item, 1, FrameFlags.Gated, 80), TestEnv.Frame(item, 2, FrameFlags.Gated | FrameFlags.Final, 40)];
ChannelService.FrameEntry[] entries = frames.Select(f => new ChannelService.FrameEntry(f.Length - Envelope.HeaderSize, System.Security.Cryptography.SHA256.HashData(f))).ToArray();
var header = frames[^1].AsMemory(0, Envelope.HeaderSize);
Assert.True(Svc.RegisterFileItem(space.Id, owner, header, entries, 3).Created);
Assert.False(Svc.RegisterFileItem(space.Id, owner, header, entries, 3).Created); // 同一个文件重复登记幂等
var other = entries.ToArray(); other[1] = new ChannelService.FrameEntry(other[1].PayloadLength, new byte[32]);
Assert.Equal(ErrorCode.Conflict, Assert.Throws<WebDropException>(() => Svc.RegisterFileItem(space.Id, owner, header, other, 3)).Code); // 摘要不同就是冲突
Assert.Equal(ErrorCode.BadRequest, Assert.Throws<WebDropException>(() => Svc.RegisterFileItem(space.Id, owner, frames[0].AsMemory(0, Envelope.HeaderSize), entries, 3)).Code); // 头部必须是末帧
// 聊天域的对象不能这样登记(它们的字节就存在主后端)
var chatItem = TestEnv.NewId();
Svc.DeclareObject(space.Id, owner, new ObjectEnvelope { ItemId = chatItem.ToBase64Url(), ChannelId = channelId, Domain = CryptoDomain.Chat, Epoch = 0, KeyEnvelope = "" });
var chatFrame = TestEnv.Frame(chatItem, 0, FrameFlags.Final, 40);
Assert.Equal(ErrorCode.BadRequest, Assert.Throws<WebDropException>(() => Svc.RegisterFileItem(space.Id, owner, chatFrame.AsMemory(0, Envelope.HeaderSize),
[new ChannelService.FrameEntry(40, System.Security.Cryptography.SHA256.HashData(chatFrame))], null)).Code);
Svc.DeleteItem(space.Id, owner, item);
}
[Fact]