Let a plugin grant and revoke file access

WO-FL-009. The files family reaches the CMS File access grant store: a plugin
grants one file to a member or an address, lists what a file has handed out,
and revokes what it gave. The response carries the signed unlock link, so a
seller can deliver a file to somebody with no account. Nothing on the wire
names the calling plugin: the host stamps plugin:<name> on what this family
writes and refuses a revoke of anything else. ADR 0008.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Alex Dunmow 2026-09-17 10:47:13 +08:00
parent 5f0cc20bbf
commit 6dd745bc34
13 changed files with 763 additions and 24 deletions

View File

@ -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:<name>" 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;
}

View File

@ -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:<name>" 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,
},

View File

@ -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:<name>` 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

View File

@ -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.

View File

@ -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",

View File

@ -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),

View File

@ -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()
}

View File

@ -0,0 +1,2 @@
library$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"reader@example.test

View File

@ -0,0 +1,3 @@
»
$44444444-4444-4444-4444-444444444444library$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb*reader@example.test2plugin:testpluginR<https://example.test/files/l/abcdefghjkmnpqrs/guide?unlock=t

View File

@ -0,0 +1,2 @@
library$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb

View File

@ -0,0 +1,3 @@
Ž
$44444444-4444-4444-4444-444444444444library$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"$11111111-1111-1111-1111-1111111111112plugin:testplugin

View File

@ -0,0 +1,2 @@
$44444444-4444-4444-4444-444444444444