@@ -1070,6 +1070,13 @@ describe('[#21405] the plugin route door answers the same refusal with the same
10701070 * for. Each refusal is read as the pair (`status`, `code`) and against the
10711071 * STORE: a refused mint writes no row.
10721072 *
1073+ * ## The capability hard stop
1074+ *
1075+ * A second object is gated on a capability too (`requiredPermissions`,
1076+ * ADR-0066 D3). An owner who lacks it is refused there: neither alternative
1077+ * applies past that gate. The control is an owner who holds the capability and
1078+ * is refused only by the CRUD grant, and mints.
1079+ *
10731080 * ## What is real here
10741081 *
10751082 * Both doors' production entries — the dispatcher domain body and the plugin's
@@ -1090,6 +1097,10 @@ describe('[#21329] mint authority on an owner-private object, at both doors (rul
10901097 const STRANGER = 'u_stranger' ;
10911098 const ADMIN = 'u_admin' ;
10921099 const MANAGER = 'u_manager' ;
1100+ /** Owns a record on the capability-gated object AND holds its capability. */
1101+ const CAPABLE_OWNER = 'u_capable_owner' ;
1102+ /** Owner-private AND capability-gated (ADR-0066 D3): a read needs `view_vault`. */
1103+ const VAULT = 'vault_notes' ;
10931104
10941105 const CONVERSATION_SCHEMA = {
10951106 name : CONV ,
@@ -1136,7 +1147,28 @@ describe('[#21329] mint authority on an owner-private object, at both doors (rul
11361147 objects : { [ CONV ] : { allowEdit : true , writeScope : 'unit' } } ,
11371148 } ) ;
11381149
1139- const SETS = [ MEMBER_BASELINE , MODIFY_ALL , UNIT_WRITER ] ;
1150+ /** The object a capability gates, on top of being owner-private. */
1151+ const VAULT_SCHEMA = {
1152+ name : VAULT ,
1153+ access : { default : 'private' } ,
1154+ requiredPermissions : [ 'view_vault' ] ,
1155+ fields : {
1156+ id : { name : 'id' } ,
1157+ title : { name : 'title' } ,
1158+ owner_id : { name : 'owner_id' } ,
1159+ } ,
1160+ publicSharing : { enabled : true , allowedAudiences : [ 'link_only' ] , allowedPermissions : [ 'view' ] } ,
1161+ } ;
1162+
1163+ /** The capability, and nothing else: no object grant rides with it. */
1164+ const VAULT_CAPABLE : PermissionSet = PermissionSetSchema . parse ( {
1165+ name : 'conv_vault_capable' ,
1166+ label : 'Holds view_vault' ,
1167+ objects : { } ,
1168+ systemPermissions : [ 'view_vault' ] ,
1169+ } ) ;
1170+
1171+ const SETS = [ MEMBER_BASELINE , MODIFY_ALL , UNIT_WRITER , VAULT_CAPABLE ] ;
11401172
11411173 /** The envelope `resolveExecutionContext` assembles for a human member of org A. */
11421174 const principal = ( userId : string , permissions : string [ ] ) : ExecutionContext =>
@@ -1158,13 +1190,19 @@ describe('[#21329] mint authority on an owner-private object, at both doors (rul
11581190 const stranger = ( ) => principal ( STRANGER , [ 'conv_member_baseline' ] ) ;
11591191 const admin = ( ) => principal ( ADMIN , [ 'conv_member_baseline' , 'conv_modify_all' ] ) ;
11601192 const manager = ( ) => principal ( MANAGER , [ 'conv_member_baseline' , 'conv_unit_writer' ] ) ;
1193+ const capableOwner = ( ) => principal ( CAPABLE_OWNER , [ 'conv_member_baseline' , 'conv_vault_capable' ] ) ;
11611194
11621195 interface World {
11631196 tables : Record < string , any [ ] > ;
11641197 sharing : any ;
11651198 mint ( as : ExecutionContext ) : Promise < { status : number ; body : any } > ;
11661199 /** The same mint through the plugin's route door. */
11671200 mintOnPlugin ( as : ExecutionContext ) : Promise < { status : number ; body : any } > ;
1201+ /** The same mint on the capability-gated object, at each door. */
1202+ mintVault ( recordId : string , as : ExecutionContext ) : Promise < { status : number ; body : any } > ;
1203+ mintVaultOnPlugin ( recordId : string , as : ExecutionContext ) : Promise < { status : number ; body : any } > ;
1204+ /** A data-door read of a vault record under `as`. */
1205+ readVault ( recordId : string , as : ExecutionContext ) : Promise < unknown > ;
11681206 resolve ( token : string ) : Promise < { status : number ; body : any } > ;
11691207 revoke ( idOrToken : string , as : ExecutionContext ) : Promise < { status : number ; body : any } > ;
11701208 /** A data-door read of the record under `as` — the visibility leg itself. */
@@ -1175,10 +1213,14 @@ describe('[#21329] mint authority on an owner-private object, at both doors (rul
11751213 async function bootWorld ( ) : Promise < World > {
11761214 const tables : Record < string , any [ ] > = {
11771215 [ CONV ] : [ { id : CONV_ID , title : 'My chat' , owner_id : OWNER , organization_id : ORG_A } ] ,
1216+ [ VAULT ] : [
1217+ { id : 'vault_owner' , title : 'Owner\'s note' , owner_id : OWNER , organization_id : ORG_A } ,
1218+ { id : 'vault_capable' , title : 'Capable owner\'s note' , owner_id : CAPABLE_OWNER , organization_id : ORG_A } ,
1219+ ] ,
11781220 sys_share_link : [ ] ,
11791221 sys_permission_set : [ ] ,
11801222 } ;
1181- const engine = makeEngine ( tables , { [ CONV ] : CONVERSATION_SCHEMA } ) ;
1223+ const engine = makeEngine ( tables , { [ CONV ] : CONVERSATION_SCHEMA , [ VAULT ] : VAULT_SCHEMA } ) ;
11821224 const services : Record < string , any > = {
11831225 manifest : { register : vi . fn ( ) } ,
11841226 objectql : engine ,
@@ -1245,6 +1287,9 @@ describe('[#21329] mint authority on an owner-private object, at both doors (rul
12451287 sharing : services . sharing ,
12461288 mint : ( as ) => drive ( '' , 'POST' , { object : CONV , recordId : CONV_ID } , as ) ,
12471289 mintOnPlugin : ( as ) => mintOnPluginDoor ( engine , svc , as , { object : CONV , recordId : CONV_ID } ) ,
1290+ mintVault : ( recordId , as ) => drive ( '' , 'POST' , { object : VAULT , recordId } , as ) ,
1291+ mintVaultOnPlugin : ( recordId , as ) => mintOnPluginDoor ( engine , svc , as , { object : VAULT , recordId } ) ,
1292+ readVault : async ( recordId , as ) => engine . find ( VAULT , { where : { id : recordId } , limit : 1 , context : as } ) ,
12481293 resolve : ( token ) => drive ( `/${ token } /resolve` , 'GET' , undefined , undefined ) ,
12491294 revoke : ( idOrToken , as ) => drive ( `/${ idOrToken } ` , 'DELETE' , undefined , as ) ,
12501295 read : async ( as ) => engine . find ( CONV , { where : { id : CONV_ID } , limit : 1 , context : as } ) ,
@@ -1328,4 +1373,41 @@ describe('[#21329] mint authority on an owner-private object, at both doors (rul
13281373 expect ( revoked . status , JSON . stringify ( revoked . body ) ) . toBe ( 200 ) ;
13291374 expect ( w . tables . sys_share_link [ 0 ] ?. revoked_at ) . toBeTruthy ( ) ;
13301375 } , 30_000 ) ;
1376+ it ( '[capability persona] the vault refuses the owner at the CAPABILITY gate, and the capable owner only at the CRUD grant' , async ( ) => {
1377+ const w = await bootWorld ( ) ;
1378+ const ownerRead = await w . readVault ( 'vault_owner' , owner ( ) ) . then ( ( ) => null , ( e ) => e ) ;
1379+ expect ( ownerRead ) . toMatchObject ( { code : 'PERMISSION_DENIED' , statusCode : 403 } ) ;
1380+ expect ( ownerRead ?. details ?. missingPermissions , 'the owner lacks view_vault' ) . toEqual ( [ 'view_vault' ] ) ;
1381+ const capableRead = await w . readVault ( 'vault_capable' , capableOwner ( ) ) . then ( ( ) => null , ( e ) => e ) ;
1382+ expect ( capableRead ) . toMatchObject ( { code : 'PERMISSION_DENIED' , statusCode : 403 } ) ;
1383+ expect ( capableRead ?. details ?. missingPermissions , 'the capable owner is refused by the CRUD grant, not the capability' ) . toBeUndefined ( ) ;
1384+ } , 30_000 ) ;
1385+
1386+ it ( '[capability] an owner lacking the object\'s required capability is refused with that refusal at both doors, and nothing lands' , async ( ) => {
1387+ const w = await bootWorld ( ) ;
1388+ const res = await w . mintVault ( 'vault_owner' , owner ( ) ) ;
1389+ const plugin = await w . mintVaultOnPlugin ( 'vault_owner' , owner ( ) ) ;
1390+
1391+ expect ( res . status , JSON . stringify ( res . body ) ) . toBe ( 403 ) ;
1392+ expect ( expectDeclaredEnvelope ( res ) . code ) . toBe ( 'PERMISSION_DENIED' ) ;
1393+ // The wire carries the refusal's user-facing sentence only — which gate
1394+ // refused (the capability's `missingPermissions`) stays off the wire, so
1395+ // that half is pinned beside the service, on the rejection itself.
1396+ expect ( res . body . error . message ) . toBe ( BUILTIN_OPERATION_MESSAGES . en . permission_denied ) ;
1397+ expect ( plugin . status , JSON . stringify ( plugin . body ) ) . toBe ( 403 ) ;
1398+ expect ( plugin . body ) . toMatchObject ( { success : false , error : { code : 'PERMISSION_DENIED' } } ) ;
1399+ expect ( w . tables . sys_share_link ) . toEqual ( [ ] ) ;
1400+ // The owner alternative itself holds; the hard stop is what refused.
1401+ expect ( await w . sharing . canManageShares ( VAULT , 'vault_owner' , owner ( ) ) ) . toBe ( true ) ;
1402+ } , 30_000 ) ;
1403+
1404+ it ( '[capability control] the owner who HOLDS the capability mints on the same object at both doors' , async ( ) => {
1405+ const w = await bootWorld ( ) ;
1406+ const res = await w . mintVault ( 'vault_capable' , capableOwner ( ) ) ;
1407+ const plugin = await w . mintVaultOnPlugin ( 'vault_capable' , capableOwner ( ) ) ;
1408+
1409+ expect ( res . status , JSON . stringify ( res . body ) ) . toBe ( 201 ) ;
1410+ expect ( plugin . status , JSON . stringify ( plugin . body ) ) . toBe ( 201 ) ;
1411+ expect ( w . tables . sys_share_link . map ( ( r ) => r . created_by ) ) . toEqual ( [ CAPABLE_OWNER , CAPABLE_OWNER ] ) ;
1412+ } , 30_000 ) ;
13311413} ) ;
0 commit comments