diff --git a/abi/proto/v1/capability.proto b/abi/proto/v1/capability.proto index bab24d7..4dfc2b7 100644 --- a/abi/proto/v1/capability.proto +++ b/abi/proto/v1/capability.proto @@ -974,3 +974,53 @@ message HttpRequestResponse { // redirects. string final_url = 4; } + +// --- files.* (plugin.FileGrants, WO-FL-009) --- +// +// A File access grant hands one person one file whatever tier they hold (ADR +// 0181, cms repo). Every grant written here is attributed to the calling +// plugin, and a plugin may revoke only the grants it wrote. + +// FileAccessGrant mirrors plugin.FileGrant. Exactly one subject is set. +message FileAccessGrant { + string grant_id = 1; // UUID + string attachment_kind = 2; // "library" or "table" + string attachment_id = 3; // UUID + string public_user_id = 4; // UUID, empty when the subject is an address + string email = 5; // empty when the subject is a member + string source = 6; // "plugin:" for anything this family wrote + google.protobuf.Timestamp expires_at = 7; + google.protobuf.Timestamp revoked_at = 8; + google.protobuf.Timestamp created_at = 9; + // Signed link that opens this file for this grant, absolute on the site. + // Empty when the file has no permanent link to sign. + string unlock_url = 10; +} + +message FilesGrantAccessRequest { + string attachment_kind = 1; // "library" (default) or "table" + string attachment_id = 2; // UUID + string public_user_id = 3; // UUID; set this or email, never both + string email = 4; + // Optional. Absent means the grant ends only when it is revoked. + google.protobuf.Timestamp expires_at = 5; +} + +message FilesGrantAccessResponse { + FileAccessGrant grant = 1; +} + +message FilesRevokeAccessRequest { + string grant_id = 1; // UUID +} + +message FilesRevokeAccessResponse {} + +message FilesListGrantsRequest { + string attachment_kind = 1; // "library" (default) or "table" + string attachment_id = 2; // UUID +} + +message FilesListGrantsResponse { + repeated FileAccessGrant grants = 1; +} diff --git a/abi/v1/capability.pb.go b/abi/v1/capability.pb.go index 143773d..a6bfb2a 100644 --- a/abi/v1/capability.pb.go +++ b/abi/v1/capability.pb.go @@ -8399,6 +8399,422 @@ func (x *HttpRequestResponse) GetFinalUrl() string { return "" } +// FileAccessGrant mirrors plugin.FileGrant. Exactly one subject is set. +type FileAccessGrant struct { + state protoimpl.MessageState `protogen:"open.v1"` + GrantId string `protobuf:"bytes,1,opt,name=grant_id,json=grantId,proto3" json:"grant_id,omitempty"` // UUID + AttachmentKind string `protobuf:"bytes,2,opt,name=attachment_kind,json=attachmentKind,proto3" json:"attachment_kind,omitempty"` // "library" or "table" + AttachmentId string `protobuf:"bytes,3,opt,name=attachment_id,json=attachmentId,proto3" json:"attachment_id,omitempty"` // UUID + PublicUserId string `protobuf:"bytes,4,opt,name=public_user_id,json=publicUserId,proto3" json:"public_user_id,omitempty"` // UUID, empty when the subject is an address + Email string `protobuf:"bytes,5,opt,name=email,proto3" json:"email,omitempty"` // empty when the subject is a member + Source string `protobuf:"bytes,6,opt,name=source,proto3" json:"source,omitempty"` // "plugin:" for anything this family wrote + ExpiresAt *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` + RevokedAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=revoked_at,json=revokedAt,proto3" json:"revoked_at,omitempty"` + CreatedAt *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + // Signed link that opens this file for this grant, absolute on the site. + // Empty when the file has no permanent link to sign. + UnlockUrl string `protobuf:"bytes,10,opt,name=unlock_url,json=unlockUrl,proto3" json:"unlock_url,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FileAccessGrant) Reset() { + *x = FileAccessGrant{} + mi := &file_v1_capability_proto_msgTypes[157] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FileAccessGrant) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FileAccessGrant) ProtoMessage() {} + +func (x *FileAccessGrant) ProtoReflect() protoreflect.Message { + mi := &file_v1_capability_proto_msgTypes[157] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FileAccessGrant.ProtoReflect.Descriptor instead. +func (*FileAccessGrant) Descriptor() ([]byte, []int) { + return file_v1_capability_proto_rawDescGZIP(), []int{157} +} + +func (x *FileAccessGrant) GetGrantId() string { + if x != nil { + return x.GrantId + } + return "" +} + +func (x *FileAccessGrant) GetAttachmentKind() string { + if x != nil { + return x.AttachmentKind + } + return "" +} + +func (x *FileAccessGrant) GetAttachmentId() string { + if x != nil { + return x.AttachmentId + } + return "" +} + +func (x *FileAccessGrant) GetPublicUserId() string { + if x != nil { + return x.PublicUserId + } + return "" +} + +func (x *FileAccessGrant) GetEmail() string { + if x != nil { + return x.Email + } + return "" +} + +func (x *FileAccessGrant) GetSource() string { + if x != nil { + return x.Source + } + return "" +} + +func (x *FileAccessGrant) GetExpiresAt() *timestamppb.Timestamp { + if x != nil { + return x.ExpiresAt + } + return nil +} + +func (x *FileAccessGrant) GetRevokedAt() *timestamppb.Timestamp { + if x != nil { + return x.RevokedAt + } + return nil +} + +func (x *FileAccessGrant) GetCreatedAt() *timestamppb.Timestamp { + if x != nil { + return x.CreatedAt + } + return nil +} + +func (x *FileAccessGrant) GetUnlockUrl() string { + if x != nil { + return x.UnlockUrl + } + return "" +} + +type FilesGrantAccessRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + AttachmentKind string `protobuf:"bytes,1,opt,name=attachment_kind,json=attachmentKind,proto3" json:"attachment_kind,omitempty"` // "library" (default) or "table" + AttachmentId string `protobuf:"bytes,2,opt,name=attachment_id,json=attachmentId,proto3" json:"attachment_id,omitempty"` // UUID + PublicUserId string `protobuf:"bytes,3,opt,name=public_user_id,json=publicUserId,proto3" json:"public_user_id,omitempty"` // UUID; set this or email, never both + Email string `protobuf:"bytes,4,opt,name=email,proto3" json:"email,omitempty"` + // Optional. Absent means the grant ends only when it is revoked. + ExpiresAt *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FilesGrantAccessRequest) Reset() { + *x = FilesGrantAccessRequest{} + mi := &file_v1_capability_proto_msgTypes[158] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FilesGrantAccessRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FilesGrantAccessRequest) ProtoMessage() {} + +func (x *FilesGrantAccessRequest) ProtoReflect() protoreflect.Message { + mi := &file_v1_capability_proto_msgTypes[158] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FilesGrantAccessRequest.ProtoReflect.Descriptor instead. +func (*FilesGrantAccessRequest) Descriptor() ([]byte, []int) { + return file_v1_capability_proto_rawDescGZIP(), []int{158} +} + +func (x *FilesGrantAccessRequest) GetAttachmentKind() string { + if x != nil { + return x.AttachmentKind + } + return "" +} + +func (x *FilesGrantAccessRequest) GetAttachmentId() string { + if x != nil { + return x.AttachmentId + } + return "" +} + +func (x *FilesGrantAccessRequest) GetPublicUserId() string { + if x != nil { + return x.PublicUserId + } + return "" +} + +func (x *FilesGrantAccessRequest) GetEmail() string { + if x != nil { + return x.Email + } + return "" +} + +func (x *FilesGrantAccessRequest) GetExpiresAt() *timestamppb.Timestamp { + if x != nil { + return x.ExpiresAt + } + return nil +} + +type FilesGrantAccessResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Grant *FileAccessGrant `protobuf:"bytes,1,opt,name=grant,proto3" json:"grant,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FilesGrantAccessResponse) Reset() { + *x = FilesGrantAccessResponse{} + mi := &file_v1_capability_proto_msgTypes[159] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FilesGrantAccessResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FilesGrantAccessResponse) ProtoMessage() {} + +func (x *FilesGrantAccessResponse) ProtoReflect() protoreflect.Message { + mi := &file_v1_capability_proto_msgTypes[159] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FilesGrantAccessResponse.ProtoReflect.Descriptor instead. +func (*FilesGrantAccessResponse) Descriptor() ([]byte, []int) { + return file_v1_capability_proto_rawDescGZIP(), []int{159} +} + +func (x *FilesGrantAccessResponse) GetGrant() *FileAccessGrant { + if x != nil { + return x.Grant + } + return nil +} + +type FilesRevokeAccessRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + GrantId string `protobuf:"bytes,1,opt,name=grant_id,json=grantId,proto3" json:"grant_id,omitempty"` // UUID + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FilesRevokeAccessRequest) Reset() { + *x = FilesRevokeAccessRequest{} + mi := &file_v1_capability_proto_msgTypes[160] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FilesRevokeAccessRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FilesRevokeAccessRequest) ProtoMessage() {} + +func (x *FilesRevokeAccessRequest) ProtoReflect() protoreflect.Message { + mi := &file_v1_capability_proto_msgTypes[160] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FilesRevokeAccessRequest.ProtoReflect.Descriptor instead. +func (*FilesRevokeAccessRequest) Descriptor() ([]byte, []int) { + return file_v1_capability_proto_rawDescGZIP(), []int{160} +} + +func (x *FilesRevokeAccessRequest) GetGrantId() string { + if x != nil { + return x.GrantId + } + return "" +} + +type FilesRevokeAccessResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FilesRevokeAccessResponse) Reset() { + *x = FilesRevokeAccessResponse{} + mi := &file_v1_capability_proto_msgTypes[161] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FilesRevokeAccessResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FilesRevokeAccessResponse) ProtoMessage() {} + +func (x *FilesRevokeAccessResponse) ProtoReflect() protoreflect.Message { + mi := &file_v1_capability_proto_msgTypes[161] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FilesRevokeAccessResponse.ProtoReflect.Descriptor instead. +func (*FilesRevokeAccessResponse) Descriptor() ([]byte, []int) { + return file_v1_capability_proto_rawDescGZIP(), []int{161} +} + +type FilesListGrantsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + AttachmentKind string `protobuf:"bytes,1,opt,name=attachment_kind,json=attachmentKind,proto3" json:"attachment_kind,omitempty"` // "library" (default) or "table" + AttachmentId string `protobuf:"bytes,2,opt,name=attachment_id,json=attachmentId,proto3" json:"attachment_id,omitempty"` // UUID + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FilesListGrantsRequest) Reset() { + *x = FilesListGrantsRequest{} + mi := &file_v1_capability_proto_msgTypes[162] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FilesListGrantsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FilesListGrantsRequest) ProtoMessage() {} + +func (x *FilesListGrantsRequest) ProtoReflect() protoreflect.Message { + mi := &file_v1_capability_proto_msgTypes[162] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FilesListGrantsRequest.ProtoReflect.Descriptor instead. +func (*FilesListGrantsRequest) Descriptor() ([]byte, []int) { + return file_v1_capability_proto_rawDescGZIP(), []int{162} +} + +func (x *FilesListGrantsRequest) GetAttachmentKind() string { + if x != nil { + return x.AttachmentKind + } + return "" +} + +func (x *FilesListGrantsRequest) GetAttachmentId() string { + if x != nil { + return x.AttachmentId + } + return "" +} + +type FilesListGrantsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Grants []*FileAccessGrant `protobuf:"bytes,1,rep,name=grants,proto3" json:"grants,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FilesListGrantsResponse) Reset() { + *x = FilesListGrantsResponse{} + mi := &file_v1_capability_proto_msgTypes[163] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FilesListGrantsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FilesListGrantsResponse) ProtoMessage() {} + +func (x *FilesListGrantsResponse) ProtoReflect() protoreflect.Message { + mi := &file_v1_capability_proto_msgTypes[163] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FilesListGrantsResponse.ProtoReflect.Descriptor instead. +func (*FilesListGrantsResponse) Descriptor() ([]byte, []int) { + return file_v1_capability_proto_rawDescGZIP(), []int{163} +} + +func (x *FilesListGrantsResponse) GetGrants() []*FileAccessGrant { + if x != nil { + return x.Grants + } + return nil +} + var File_v1_capability_proto protoreflect.FileDescriptor const file_v1_capability_proto_rawDesc = "" + @@ -8965,7 +9381,40 @@ const file_v1_capability_proto_rawDesc = "" + "\tfinal_url\x18\x04 \x01(\tR\bfinalUrl\x1aP\n" + "\fHeadersEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12*\n" + - "\x05value\x18\x02 \x01(\v2\x14.abi.v1.HeaderValuesR\x05value:\x028\x01B5Z3git.dev.alexdunmow.com/block/pluginsdk/abi/v1;abiv1b\x06proto3" + "\x05value\x18\x02 \x01(\v2\x14.abi.v1.HeaderValuesR\x05value:\x028\x01\"\x9e\x03\n" + + "\x0fFileAccessGrant\x12\x19\n" + + "\bgrant_id\x18\x01 \x01(\tR\agrantId\x12'\n" + + "\x0fattachment_kind\x18\x02 \x01(\tR\x0eattachmentKind\x12#\n" + + "\rattachment_id\x18\x03 \x01(\tR\fattachmentId\x12$\n" + + "\x0epublic_user_id\x18\x04 \x01(\tR\fpublicUserId\x12\x14\n" + + "\x05email\x18\x05 \x01(\tR\x05email\x12\x16\n" + + "\x06source\x18\x06 \x01(\tR\x06source\x129\n" + + "\n" + + "expires_at\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt\x129\n" + + "\n" + + "revoked_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\trevokedAt\x129\n" + + "\n" + + "created_at\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12\x1d\n" + + "\n" + + "unlock_url\x18\n" + + " \x01(\tR\tunlockUrl\"\xde\x01\n" + + "\x17FilesGrantAccessRequest\x12'\n" + + "\x0fattachment_kind\x18\x01 \x01(\tR\x0eattachmentKind\x12#\n" + + "\rattachment_id\x18\x02 \x01(\tR\fattachmentId\x12$\n" + + "\x0epublic_user_id\x18\x03 \x01(\tR\fpublicUserId\x12\x14\n" + + "\x05email\x18\x04 \x01(\tR\x05email\x129\n" + + "\n" + + "expires_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt\"I\n" + + "\x18FilesGrantAccessResponse\x12-\n" + + "\x05grant\x18\x01 \x01(\v2\x17.abi.v1.FileAccessGrantR\x05grant\"5\n" + + "\x18FilesRevokeAccessRequest\x12\x19\n" + + "\bgrant_id\x18\x01 \x01(\tR\agrantId\"\x1b\n" + + "\x19FilesRevokeAccessResponse\"f\n" + + "\x16FilesListGrantsRequest\x12'\n" + + "\x0fattachment_kind\x18\x01 \x01(\tR\x0eattachmentKind\x12#\n" + + "\rattachment_id\x18\x02 \x01(\tR\fattachmentId\"J\n" + + "\x17FilesListGrantsResponse\x12/\n" + + "\x06grants\x18\x01 \x03(\v2\x17.abi.v1.FileAccessGrantR\x06grantsB5Z3git.dev.alexdunmow.com/block/pluginsdk/abi/v1;abiv1b\x06proto3" var ( file_v1_capability_proto_rawDescOnce sync.Once @@ -8979,7 +9428,7 @@ func file_v1_capability_proto_rawDescGZIP() []byte { return file_v1_capability_proto_rawDescData } -var file_v1_capability_proto_msgTypes = make([]protoimpl.MessageInfo, 161) +var file_v1_capability_proto_msgTypes = make([]protoimpl.MessageInfo, 168) var file_v1_capability_proto_goTypes = []any{ (*HostCallRequest)(nil), // 0: abi.v1.HostCallRequest (*HostCallResponse)(nil), // 1: abi.v1.HostCallResponse @@ -9138,21 +9587,28 @@ var file_v1_capability_proto_goTypes = []any{ (*BridgeInvokeResponse)(nil), // 154: abi.v1.BridgeInvokeResponse (*HttpRequestRequest)(nil), // 155: abi.v1.HttpRequestRequest (*HttpRequestResponse)(nil), // 156: abi.v1.HttpRequestResponse - nil, // 157: abi.v1.AuthorProfile.SocialLinksEntry - nil, // 158: abi.v1.RagResult.MetadataEntry - nil, // 159: abi.v1.HttpRequestRequest.HeadersEntry - nil, // 160: abi.v1.HttpRequestResponse.HeadersEntry - (*AbiError)(nil), // 161: abi.v1.AbiError - (*timestamppb.Timestamp)(nil), // 162: google.protobuf.Timestamp - (*HeaderValues)(nil), // 163: abi.v1.HeaderValues + (*FileAccessGrant)(nil), // 157: abi.v1.FileAccessGrant + (*FilesGrantAccessRequest)(nil), // 158: abi.v1.FilesGrantAccessRequest + (*FilesGrantAccessResponse)(nil), // 159: abi.v1.FilesGrantAccessResponse + (*FilesRevokeAccessRequest)(nil), // 160: abi.v1.FilesRevokeAccessRequest + (*FilesRevokeAccessResponse)(nil), // 161: abi.v1.FilesRevokeAccessResponse + (*FilesListGrantsRequest)(nil), // 162: abi.v1.FilesListGrantsRequest + (*FilesListGrantsResponse)(nil), // 163: abi.v1.FilesListGrantsResponse + nil, // 164: abi.v1.AuthorProfile.SocialLinksEntry + nil, // 165: abi.v1.RagResult.MetadataEntry + nil, // 166: abi.v1.HttpRequestRequest.HeadersEntry + nil, // 167: abi.v1.HttpRequestResponse.HeadersEntry + (*AbiError)(nil), // 168: abi.v1.AbiError + (*timestamppb.Timestamp)(nil), // 169: google.protobuf.Timestamp + (*HeaderValues)(nil), // 170: abi.v1.HeaderValues } var file_v1_capability_proto_depIdxs = []int32{ - 161, // 0: abi.v1.HostCallResponse.error:type_name -> abi.v1.AbiError + 168, // 0: abi.v1.HostCallResponse.error:type_name -> abi.v1.AbiError 4, // 1: abi.v1.ContentGetAuthorProfileResponse.author:type_name -> abi.v1.AuthorProfile - 157, // 2: abi.v1.AuthorProfile.social_links:type_name -> abi.v1.AuthorProfile.SocialLinksEntry + 164, // 2: abi.v1.AuthorProfile.social_links:type_name -> abi.v1.AuthorProfile.SocialLinksEntry 7, // 3: abi.v1.ContentGetPageResponse.page:type_name -> abi.v1.PageInfo 12, // 4: abi.v1.ContentGetPostResponse.post:type_name -> abi.v1.PostInfo - 162, // 5: abi.v1.PostInfo.published_at:type_name -> google.protobuf.Timestamp + 169, // 5: abi.v1.PostInfo.published_at:type_name -> google.protobuf.Timestamp 12, // 6: abi.v1.ContentListPostsResponse.posts:type_name -> abi.v1.PostInfo 33, // 7: abi.v1.GatingEvaluateAccessRequest.rule:type_name -> abi.v1.AccessRule 34, // 8: abi.v1.GatingEvaluateAccessResponse.result:type_name -> abi.v1.AccessResult @@ -9172,21 +9628,27 @@ var file_v1_capability_proto_depIdxs = []int32{ 74, // 22: abi.v1.SubscriptionsGetTierBySlugResponse.tier:type_name -> abi.v1.Tier 74, // 23: abi.v1.SubscriptionsListTiersResponse.tiers:type_name -> abi.v1.Tier 79, // 24: abi.v1.SubscriptionsListActivePlansResponse.plans:type_name -> abi.v1.Plan - 162, // 25: abi.v1.Plan.created_at:type_name -> google.protobuf.Timestamp + 169, // 25: abi.v1.Plan.created_at:type_name -> google.protobuf.Timestamp 102, // 26: abi.v1.RagQueryResponse.results:type_name -> abi.v1.RagResult - 158, // 27: abi.v1.RagResult.metadata:type_name -> abi.v1.RagResult.MetadataEntry + 165, // 27: abi.v1.RagResult.metadata:type_name -> abi.v1.RagResult.MetadataEntry 111, // 28: abi.v1.ContentSetPageBlocksRequest.blocks:type_name -> abi.v1.PageBlock 111, // 29: abi.v1.PageSeed.blocks:type_name -> abi.v1.PageBlock 126, // 30: abi.v1.ProvisionerEnsurePageRequest.page:type_name -> abi.v1.PageSeed - 159, // 31: abi.v1.HttpRequestRequest.headers:type_name -> abi.v1.HttpRequestRequest.HeadersEntry - 160, // 32: abi.v1.HttpRequestResponse.headers:type_name -> abi.v1.HttpRequestResponse.HeadersEntry - 163, // 33: abi.v1.HttpRequestRequest.HeadersEntry.value:type_name -> abi.v1.HeaderValues - 163, // 34: abi.v1.HttpRequestResponse.HeadersEntry.value:type_name -> abi.v1.HeaderValues - 35, // [35:35] is the sub-list for method output_type - 35, // [35:35] is the sub-list for method input_type - 35, // [35:35] is the sub-list for extension type_name - 35, // [35:35] is the sub-list for extension extendee - 0, // [0:35] is the sub-list for field type_name + 166, // 31: abi.v1.HttpRequestRequest.headers:type_name -> abi.v1.HttpRequestRequest.HeadersEntry + 167, // 32: abi.v1.HttpRequestResponse.headers:type_name -> abi.v1.HttpRequestResponse.HeadersEntry + 169, // 33: abi.v1.FileAccessGrant.expires_at:type_name -> google.protobuf.Timestamp + 169, // 34: abi.v1.FileAccessGrant.revoked_at:type_name -> google.protobuf.Timestamp + 169, // 35: abi.v1.FileAccessGrant.created_at:type_name -> google.protobuf.Timestamp + 169, // 36: abi.v1.FilesGrantAccessRequest.expires_at:type_name -> google.protobuf.Timestamp + 157, // 37: abi.v1.FilesGrantAccessResponse.grant:type_name -> abi.v1.FileAccessGrant + 157, // 38: abi.v1.FilesListGrantsResponse.grants:type_name -> abi.v1.FileAccessGrant + 170, // 39: abi.v1.HttpRequestRequest.HeadersEntry.value:type_name -> abi.v1.HeaderValues + 170, // 40: abi.v1.HttpRequestResponse.HeadersEntry.value:type_name -> abi.v1.HeaderValues + 41, // [41:41] is the sub-list for method output_type + 41, // [41:41] is the sub-list for method input_type + 41, // [41:41] is the sub-list for extension type_name + 41, // [41:41] is the sub-list for extension extendee + 0, // [0:41] is the sub-list for field type_name } func init() { file_v1_capability_proto_init() } @@ -9206,7 +9668,7 @@ func file_v1_capability_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_v1_capability_proto_rawDesc), len(file_v1_capability_proto_rawDesc)), NumEnums: 0, - NumMessages: 161, + NumMessages: 168, NumExtensions: 0, NumServices: 0, }, diff --git a/docs/adr/0008-a-plugin-grants-and-revokes-file-access-through-the-files-family.md b/docs/adr/0008-a-plugin-grants-and-revokes-file-access-through-the-files-family.md new file mode 100644 index 0000000..d1d87b6 --- /dev/null +++ b/docs/adr/0008-a-plugin-grants-and-revokes-file-access-through-the-files-family.md @@ -0,0 +1,54 @@ +# A plugin grants and revokes file access through the files family + +Status: accepted (WO-FL-009, 2026-09-17) + +The CMS File library gates a published file and hands it to one person at a time +through a File access grant (cms ADRs 0181, 0186, 0187). ADR 0181 named the +storefront case explicitly: selling a single file belongs to a plugin, which +creates a grant after payment and revokes it on a refund. Until now a plugin had +no way to say either thing, because the capability surface carried no file +family at all. + +Decision: the ABI gains `files.grant_access`, `files.revoke_access` and +`files.list_grants`, mirrored by `plugin.FileGrants` on `CoreServices`. A grant +names a file by its attachment kind and id and exactly one subject: a public +user, or an email address that receives a signed unlock link instead. The +response carries the stored grant, including that link, so a plugin that sells a +file to somebody with no account can deliver it in its own receipt. + +**The source is the host's to write, and so is the revoke rule.** Nothing in +these messages names the calling plugin: the host already authenticates the +caller at the capability boundary, stamps `plugin:` on every grant this +family writes, and refuses a revoke of a grant with any other source. A plugin +therefore cannot attribute a grant to somebody else, and cannot take away access +that an administrator, a workflow or another plugin gave. Passing the plugin +name on the wire was rejected for the same reason the bridge does not: a value a +guest supplies is a value a guest can change. + +A grant with no expiry is the ordinary case, because revocation is the control +the CMS relies on; `expires_at` is optional and absent means "until revoked". + +Alternatives rejected: a general "write a row in a core table" capability, which +would put the CHECK constraints and the unique indexes of a security table +behind a generic escape hatch; and returning only a grant id, which would force +a second call for the link every seller needs. + +Consequences: + +- New: `abi/proto/v1/capability.proto` messages `FileAccessGrant`, + `FilesGrantAccessRequest`/`Response`, `FilesRevokeAccessRequest`/`Response`, + `FilesListGrantsRequest`/`Response`; `plugin/wasmguest/caps/files.go` and its + round-trip goldens; `plugin.FileGrants`, `plugin.FileGrantParams` and + `plugin.FileGrant` in `plugin/deps.go`. +- `CoreServices.FileGrants` is nil on a host that does not wire it, exactly like + the other optional members, and the guest stub then fails the call rather than + pretending. +- The host half, the plugin attribution and the revoke guard live in the cms + repo (`backend/plugin/wasmhost/caps/files.go`, ADR 0190 there). + +Keywords: files.grant_access, files.revoke_access, files.list_grants, +FileGrants, FileGrantParams, FileGrant, FileAccessGrant, FilesGrantAccessRequest, +FilesRevokeAccessRequest, FilesListGrantsRequest, filesStub, capability.proto, +File access grant, unlock link, unlock_url, plugin grant, grant source, +storefront plugin, File purchase, revoke own grants, WO-FL-009, ADR 0181, +CoreServices, wasm ABI, host_call diff --git a/plugin/deps.go b/plugin/deps.go index c498085..7929228 100644 --- a/plugin/deps.go +++ b/plugin/deps.go @@ -3,6 +3,7 @@ package plugin import ( "context" "net/http" + "time" "connectrpc.com/connect" "git.dev.alexdunmow.com/block/pluginsdk/ai" @@ -67,6 +68,7 @@ type CoreServices struct { ReviewSubmitter ReviewSubmitter BadgeRefresher BadgeRefresher SettingsUpdater settings.Updater + FileGrants FileGrants // Extension points — typed as narrow interfaces where possible JobRunner JobRunner @@ -149,6 +151,46 @@ type RAGResult struct { Metadata map[string]string } +// FileGrants hands one File library file to one person and takes it back. A +// grant is "this person may download this file whatever tier they hold", which +// is how a storefront plugin delivers a purchase and withdraws it on a refund. +// The host attributes every grant written here to the calling plugin, and a +// plugin may revoke only the grants it wrote. +type FileGrants interface { + GrantFileAccess(ctx context.Context, params FileGrantParams) (FileGrant, error) + RevokeFileAccess(ctx context.Context, grantID uuid.UUID) error + ListFileGrants(ctx context.Context, attachmentKind string, attachmentID uuid.UUID) ([]FileGrant, error) +} + +// FileGrantParams names the file and exactly one subject: a public user, or an +// email address that receives a signed unlock link instead. +type FileGrantParams struct { + // AttachmentKind is "library" or "table"; empty means "library". + AttachmentKind string + AttachmentID uuid.UUID + PublicUserID uuid.UUID + Email string + // ExpiresAt zero means the grant ends only when it is revoked. + ExpiresAt time.Time +} + +// FileGrant is one stored grant. A revoked grant is kept as the record of who +// was let in and when that stopped. +type FileGrant struct { + GrantID uuid.UUID + AttachmentKind string + AttachmentID uuid.UUID + PublicUserID uuid.UUID + Email string + Source string + ExpiresAt time.Time + RevokedAt time.Time + CreatedAt time.Time + // UnlockURL opens the file for this grant. It is empty when the file has no + // permanent link to sign. + UnlockURL string +} + // BadgeRefresher recomputes badges for a data table row. // The CMS handles loading the table schema, aggregating ratings, // evaluating badge rules, and persisting the updated badge list. diff --git a/plugin/wasmguest/caps/caps_roundtrip_test.go b/plugin/wasmguest/caps/caps_roundtrip_test.go index 623a941..f7e193b 100644 --- a/plugin/wasmguest/caps/caps_roundtrip_test.go +++ b/plugin/wasmguest/caps/caps_roundtrip_test.go @@ -711,6 +711,45 @@ func TestCapabilityRoundTrip(t *testing.T) { } }, }, + // --- files (WO-FL-009) --- + { + name: "files_grant_access", wantMethod: "files.grant_access", + resp: &abiv1.FilesGrantAccessResponse{Grant: &abiv1.FileAccessGrant{ + GrantId: idItem.String(), AttachmentKind: "library", AttachmentId: idRow.String(), + Email: "reader@example.test", Source: "plugin:testplugin", + UnlockUrl: "https://example.test/files/l/abcdefghjkmnpqrs/guide?unlock=t", + }}, + run: func(t *testing.T, cs plugin.CoreServices) { + got, err := cs.FileGrants.GrantFileAccess(ctx, plugin.FileGrantParams{ + AttachmentKind: "library", AttachmentID: idRow, Email: "reader@example.test", + }) + if err != nil || got.GrantID != idItem || got.Source != "plugin:testplugin" || got.UnlockURL == "" { + t.Errorf("grant = %+v err = %v", got, err) + } + }, + }, + { + name: "files_revoke_access", wantMethod: "files.revoke_access", + resp: &abiv1.FilesRevokeAccessResponse{}, + run: func(t *testing.T, cs plugin.CoreServices) { + if err := cs.FileGrants.RevokeFileAccess(ctx, idItem); err != nil { + t.Fatal(err) + } + }, + }, + { + name: "files_list_grants", wantMethod: "files.list_grants", + resp: &abiv1.FilesListGrantsResponse{Grants: []*abiv1.FileAccessGrant{{ + GrantId: idItem.String(), AttachmentKind: "library", AttachmentId: idRow.String(), + PublicUserId: idUser.String(), Source: "plugin:testplugin", + }}}, + run: func(t *testing.T, cs plugin.CoreServices) { + got, err := cs.FileGrants.ListFileGrants(ctx, "library", idRow) + if err != nil || len(got) != 1 || got[0].PublicUserID != idUser { + t.Errorf("grants = %+v err = %v", got, err) + } + }, + }, // --- content writes (WO-WZ-019) --- { name: "content_create_page", wantMethod: "content.create_page", diff --git a/plugin/wasmguest/caps/coreservices.go b/plugin/wasmguest/caps/coreservices.go index 7f0912b..b3f2dfa 100644 --- a/plugin/wasmguest/caps/coreservices.go +++ b/plugin/wasmguest/caps/coreservices.go @@ -44,6 +44,7 @@ func NewCoreServices(call CallFunc) plugin.CoreServices { Bridge: &bridgeStub{base: base{family: "bridge", call: call}}, ReviewSubmitter: &reviewsStub{base{family: "reviews", call: call}}, BadgeRefresher: &badgesStub{base{family: "badges", call: call}}, + FileGrants: &filesStub{base{family: "files", call: call}}, JobRunner: &jobsStub{base{family: "jobs", call: call}}, EmbeddingService: &embeddingsStub{base{family: "embeddings", call: call}}, RAGService: NewRAGStub(call), diff --git a/plugin/wasmguest/caps/files.go b/plugin/wasmguest/caps/files.go new file mode 100644 index 0000000..1a9ec97 --- /dev/null +++ b/plugin/wasmguest/caps/files.go @@ -0,0 +1,79 @@ +package caps + +import ( + "context" + "time" + + abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1" + "git.dev.alexdunmow.com/block/pluginsdk/plugin" + "github.com/google/uuid" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// filesStub implements plugin.FileGrants over the files.* capability calls. +// The grant's source is the host's to decide, so nothing here names the plugin. +type filesStub struct{ base } + +var _ plugin.FileGrants = (*filesStub)(nil) + +func (s *filesStub) GrantFileAccess(ctx context.Context, params plugin.FileGrantParams) (plugin.FileGrant, error) { + req := &abiv1.FilesGrantAccessRequest{ + AttachmentKind: params.AttachmentKind, + AttachmentId: params.AttachmentID.String(), + Email: params.Email, + } + if params.PublicUserID != uuid.Nil { + req.PublicUserId = params.PublicUserID.String() + } + if !params.ExpiresAt.IsZero() { + req.ExpiresAt = timestamppb.New(params.ExpiresAt) + } + resp := &abiv1.FilesGrantAccessResponse{} + if err := s.invoke(ctx, "grant_access", req, resp); err != nil { + return plugin.FileGrant{}, err + } + return fileGrantFromABI(resp.GetGrant()), nil +} + +func (s *filesStub) RevokeFileAccess(ctx context.Context, grantID uuid.UUID) error { + req := &abiv1.FilesRevokeAccessRequest{GrantId: grantID.String()} + return s.invoke(ctx, "revoke_access", req, &abiv1.FilesRevokeAccessResponse{}) +} + +func (s *filesStub) ListFileGrants(ctx context.Context, attachmentKind string, attachmentID uuid.UUID) ([]plugin.FileGrant, error) { + req := &abiv1.FilesListGrantsRequest{AttachmentKind: attachmentKind, AttachmentId: attachmentID.String()} + resp := &abiv1.FilesListGrantsResponse{} + if err := s.invoke(ctx, "list_grants", req, resp); err != nil { + return nil, err + } + grants := make([]plugin.FileGrant, 0, len(resp.GetGrants())) + for _, grant := range resp.GetGrants() { + grants = append(grants, fileGrantFromABI(grant)) + } + return grants, nil +} + +func fileGrantFromABI(grant *abiv1.FileAccessGrant) plugin.FileGrant { + if grant == nil { + return plugin.FileGrant{} + } + return plugin.FileGrant{ + GrantID: parseUUID(grant.GetGrantId()), + AttachmentKind: grant.GetAttachmentKind(), + AttachmentID: parseUUID(grant.GetAttachmentId()), + PublicUserID: parseUUID(grant.GetPublicUserId()), + Email: grant.GetEmail(), + Source: grant.GetSource(), + ExpiresAt: abiTime(grant.GetExpiresAt()), + RevokedAt: abiTime(grant.GetRevokedAt()), + CreatedAt: abiTime(grant.GetCreatedAt()), + UnlockURL: grant.GetUnlockUrl(), + } +} + +func abiTime(stamp *timestamppb.Timestamp) time.Time { + if stamp == nil { + return time.Time{} + } + return stamp.AsTime() +} diff --git a/plugin/wasmguest/caps/testdata/golden/files_grant_access_req.pb b/plugin/wasmguest/caps/testdata/golden/files_grant_access_req.pb new file mode 100644 index 0000000..26909d7 --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/files_grant_access_req.pb @@ -0,0 +1,2 @@ + +library$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"reader@example.test \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/files_grant_access_resp.pb b/plugin/wasmguest/caps/testdata/golden/files_grant_access_resp.pb new file mode 100644 index 0000000..65fdc33 --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/files_grant_access_resp.pb @@ -0,0 +1,3 @@ + +» +$44444444-4444-4444-4444-444444444444library$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb*reader@example.test2plugin:testpluginR