@@ -1193,6 +1193,8 @@ describe('[ADR-0111 D8 rule 1 / ruling A′] mint authority: visibility, or the
11931193 const ADMIN = 'u_admin' ;
11941194 const MANAGER = 'u_manager' ;
11951195 const READER = 'u_reader' ;
1196+ /** Owns a record on the capability-gated object AND holds the capability. */
1197+ const CAPABLE_OWNER = 'u_capable_owner' ;
11961198
11971199 const SCHEMAS = {
11981200 sys_share_link : { name : 'sys_share_link' , fields : { } } ,
@@ -1211,18 +1213,52 @@ describe('[ADR-0111 D8 rule 1 / ruling A′] mint authority: visibility, or the
12111213 } ,
12121214 // Never opted in.
12131215 notes : { name : 'notes' , access : { default : 'private' } , fields : { id : { } , owner_id : { } } } ,
1216+ // Owner-private AND capability-gated (ADR-0066 D3): a read needs `view_vault`.
1217+ vault : {
1218+ name : 'vault' ,
1219+ access : { default : 'private' } ,
1220+ requiredPermissions : [ 'view_vault' ] ,
1221+ publicSharing : { enabled : true , allowedAudiences : [ 'link_only' ] , allowedPermissions : [ 'view' ] } ,
1222+ fields : { id : { } , owner_id : { } } ,
1223+ } ,
12141224 } ;
1215- const PRIVATE_OBJECTS = new Set ( [ 'conversations' , 'briefs' , 'notes' ] ) ;
1225+ const PRIVATE_OBJECTS = new Set ( [ 'conversations' , 'briefs' , 'notes' , 'vault' ] ) ;
1226+ /** The capabilities each principal holds; the doubles below read only this. */
1227+ const HELD_CAPABILITIES : Record < string , string [ ] > = { [ CAPABLE_OWNER ] : [ 'view_vault' ] } ;
1228+ const requiredOf = ( object : string ) : string [ ] =>
1229+ ( ( SCHEMAS as Record < string , any > ) [ object ] ?. requiredPermissions as string [ ] | undefined ) ?? [ ] ;
1230+ const lacksCapability = ( object : string , userId : unknown ) : boolean =>
1231+ requiredOf ( object ) . some ( ( c ) => ! ( HELD_CAPABILITIES [ String ( userId ) ] ?? [ ] ) . includes ( c ) ) ;
12161232
12171233 const denial = ( ) =>
12181234 Object . assign ( new Error ( 'You do not have permission to perform this action.' ) , {
12191235 code : 'PERMISSION_DENIED' ,
12201236 statusCode : 403 ,
12211237 } ) ;
1238+ /** The capability AND-gate's refusal: the same class, carrying what was missing. */
1239+ const capabilityDenial = ( object : string ) =>
1240+ Object . assign ( new Error ( 'You do not have permission to perform this action.' ) , {
1241+ code : 'PERMISSION_DENIED' ,
1242+ statusCode : 403 ,
1243+ details : { object, requiredPermissions : requiredOf ( object ) , missingPermissions : requiredOf ( object ) } ,
1244+ } ) ;
1245+ /**
1246+ * The explain report's `required_permissions` layer, computed from the same
1247+ * `HELD_CAPABILITIES` the read double refuses with — so the two agree by
1248+ * construction, as the real engine and middleware do.
1249+ */
1250+ const explainDouble = async ( request : { object : string } , ctx : any ) => {
1251+ explainCalls += 1 ;
1252+ const verdict = requiredOf ( request . object ) . length === 0
1253+ ? 'not_applicable'
1254+ : lacksCapability ( request . object , ctx ?. userId ) ? 'denies' : 'neutral' ;
1255+ return { layers : [ { layer : 'required_permissions' , verdict, detail : 'double' } ] } as any ;
1256+ } ;
12221257
12231258 let engine : ReturnType < typeof makeFakeEngine > ;
12241259 let posture : string | undefined ;
12251260 let writeScopeCalls : number ;
1261+ let explainCalls : number ;
12261262 let sharing : SharingService ;
12271263 let service : ShareLinkService ;
12281264 let mintProbeCalls : Array < [ string , string , string | undefined ] > ;
@@ -1241,15 +1277,24 @@ describe('[ADR-0111 D8 rule 1 / ruling A′] mint authority: visibility, or the
12411277 { id : 'b_pub' , title : 'Published' , status : 'published' , owner_id : OWNER } ,
12421278 ] ;
12431279 engine . _tables . notes = [ { id : 'n1' , owner_id : OWNER } ] ;
1280+ engine . _tables . vault = [
1281+ { id : 'v_owner' , owner_id : OWNER } ,
1282+ { id : 'v_capable' , owner_id : CAPABLE_OWNER } ,
1283+ ] ;
12441284 const plainFind = engine . find . bind ( engine ) ;
12451285 engine . find = async ( object : string , options ?: any ) => {
12461286 const ctx = options ?. context ?? { } ;
1247- if ( PRIVATE_OBJECTS . has ( object ) && ctx . isSystem !== true && ctx . userId !== READER ) throw denial ( ) ;
1287+ if ( PRIVATE_OBJECTS . has ( object ) && ctx . isSystem !== true ) {
1288+ // The capability AND-gate runs BEFORE the CRUD grant (ADR-0066 D3).
1289+ if ( lacksCapability ( object , ctx . userId ) ) throw capabilityDenial ( object ) ;
1290+ if ( ctx . userId !== READER ) throw denial ( ) ;
1291+ }
12481292 return plainFind ( object , options ) ;
12491293 } ;
12501294
12511295 posture = 'single' ;
12521296 writeScopeCalls = 0 ;
1297+ explainCalls = 0 ;
12531298 mintProbeCalls = [ ] ;
12541299 sharing = new SharingService ( {
12551300 engine : engine as any ,
@@ -1259,6 +1304,7 @@ describe('[ADR-0111 D8 rule 1 / ruling A′] mint authority: visibility, or the
12591304 writeScopeCalls += 1 ;
12601305 return ctx ?. userId === MANAGER ? 'unit' : 'own' ;
12611306 } ,
1307+ explain : explainDouble ,
12621308 } ) ,
12631309 // The enterprise seam: the manager's `unit` covers the owner.
12641310 hierarchyResolver : ( ) =>
@@ -1340,6 +1386,65 @@ describe('[ADR-0111 D8 rule 1 / ruling A′] mint authority: visibility, or the
13401386 await expect ( service . createLink ( conversation ( ) , as ( OWNER ) ) ) . resolves . toMatchObject ( { created_by : OWNER } ) ;
13411387 } ) ;
13421388
1389+ describe ( 'the capability hard stop (ADR-0066 D3): no alternative applies past a missing required capability' , ( ) => {
1390+ it ( 'an owner lacking the capability is refused with the capability refusal itself, and nothing lands' , async ( ) => {
1391+ const refusal = await service . createLink ( mintIn ( 'vault' , 'v_owner' ) , as ( OWNER ) ) . then (
1392+ ( ) => { throw new Error ( 'the owner minted past the capability gate' ) ; } ,
1393+ ( err ) => err ,
1394+ ) ;
1395+ expect ( refusal ) . toMatchObject ( {
1396+ code : 'PERMISSION_DENIED' ,
1397+ statusCode : 403 ,
1398+ details : { missingPermissions : [ 'view_vault' ] } ,
1399+ } ) ;
1400+ expect ( minted ( ) ) . toEqual ( [ ] ) ;
1401+ // The owner alternative itself was admitted; the stop is what refused.
1402+ expect ( await sharing . canManageShares ( 'vault' , 'v_owner' , as ( OWNER ) ) ) . toBe ( true ) ;
1403+ } ) ;
1404+
1405+ it ( 'a Modify-All holder lacking the capability is refused the same way' , async ( ) => {
1406+ await expect ( service . createLink ( mintIn ( 'vault' , 'v_owner' ) , as ( ADMIN ) ) )
1407+ . rejects . toMatchObject ( { code : 'PERMISSION_DENIED' , details : { missingPermissions : [ 'view_vault' ] } } ) ;
1408+ expect ( minted ( ) ) . toEqual ( [ ] ) ;
1409+ } ) ;
1410+
1411+ it ( 'control: an owner who HOLDS the capability mints on the same object (refused only by the CRUD grant)' , async ( ) => {
1412+ await expect ( engine . find ( 'vault' , { where : { id : 'v_capable' } , context : as ( CAPABLE_OWNER ) } ) )
1413+ . rejects . toMatchObject ( { code : 'PERMISSION_DENIED' } ) ;
1414+ await expect ( service . createLink ( mintIn ( 'vault' , 'v_capable' ) , as ( CAPABLE_OWNER ) ) )
1415+ . resolves . toMatchObject ( { record_id : 'v_capable' , created_by : CAPABLE_OWNER } ) ;
1416+ } ) ;
1417+
1418+ it ( 'control: the owner of an object that requires no capability still mints' , async ( ) => {
1419+ await expect ( service . createLink ( conversation ( ) , as ( OWNER ) ) ) . resolves . toMatchObject ( { created_by : OWNER } ) ;
1420+ } ) ;
1421+
1422+ it ( 'a refused stranger never pays for the explain walk' , async ( ) => {
1423+ await expect ( service . createLink ( mintIn ( 'vault' , 'v_owner' ) , as ( STRANGER ) ) ) . rejects . toMatchObject ( { code : 'PERMISSION_DENIED' } ) ;
1424+ await expect ( service . createLink ( conversation ( ) , as ( STRANGER ) ) ) . rejects . toMatchObject ( { code : 'PERMISSION_DENIED' } ) ;
1425+ expect ( explainCalls ) . toBe ( 0 ) ;
1426+ } ) ;
1427+
1428+ it . each ( [
1429+ [ 'a security service without explain' , { hasWriteBypass : async ( ) => false } ] ,
1430+ [ 'an explain that throws' , { explain : async ( ) => { throw new Error ( 'explain down' ) ; } } ] ,
1431+ [ 'a report without the layer' , { explain : async ( ) => ( { layers : [ ] } ) } ] ,
1432+ [ 'a verdict outside admits' , { explain : async ( ) => ( { layers : [ { layer : 'required_permissions' , verdict : 'narrows' , detail : 'x' } ] } ) } ] ,
1433+ ] ) ( 'fails closed: %s refuses the owner' , async ( _name , probe ) => {
1434+ const closed = new SharingService ( {
1435+ engine : engine as any ,
1436+ securityService : ( ) => probe as any ,
1437+ tenancy : ( ) => ( { posture : 'single' } ) ,
1438+ } ) ;
1439+ expect ( await closed . canMintWithoutVisibility ( 'conversations' , 'c1' , as ( OWNER ) ) ) . toBe ( false ) ;
1440+ } ) ;
1441+
1442+ it ( 'a deployment with no security service at all enforces no capability gate, so the owner alternative stands' , async ( ) => {
1443+ const open = new SharingService ( { engine : engine as any , tenancy : ( ) => ( { posture : 'single' } ) } ) ;
1444+ expect ( await open . canMintWithoutVisibility ( 'conversations' , 'c1' , as ( OWNER ) ) ) . toBe ( true ) ;
1445+ } ) ;
1446+ } ) ;
1447+
13431448 describe ( 'the order: opt-in, then authority, then eligibility' , ( ) => {
13441449 it ( 'the opt-in comes first — the owner of a record on an object that never opted in is refused 422' , async ( ) => {
13451450 await expect ( service . createLink ( mintIn ( 'notes' , 'n1' ) , as ( OWNER ) ) )
0 commit comments