11// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22
3- import { describe , it , expect } from 'vitest' ;
3+ import { describe , it , expect , expectTypeOf } from 'vitest' ;
4+ import type { z } from 'zod' ;
45import type {
56 ISecurityService ,
67 AuthoredRowWriteVerdict ,
@@ -9,7 +10,36 @@ import type {
910 ControlledByParentWriteOutcome ,
1011 ControlledByParentWriteDenialLeg ,
1112 ControlledByParentWriteUnresolvedReason ,
13+ ExplainAccessRequest ,
1214} from './security-service' ;
15+ import type { ExplainRequestSchema } from '../security/explain.zod' ;
16+
17+ // ---------------------------------------------------------------------------
18+ // `ExplainAccessRequest.recordIds` — the service contract's batch selector.
19+ //
20+ // The wire schema (`ExplainRequestSchema`) declared `recordIds` and both the
21+ // REST explain route and the reference security service honour it, while the
22+ // service contract the route calls did not declare it. These pins hold the two
23+ // declarations to one type: the member is OPTIONAL (every singular and
24+ // object-level caller omits it) and EQUAL to the wire schema's input member, so
25+ // a retype on either side turns this file red under `check:test-typecheck`.
26+ //
27+ // The optionality pin is a type alias, not `expectTypeOf`: `toEqualTypeOf`
28+ // cannot tell `recordIds?: string[]` from a REQUIRED `recordIds: string[] |
29+ // undefined`, while `{} extends Pick<…>` can. Exported so TS6196 does not
30+ // flag an alias nothing reads.
31+ // ---------------------------------------------------------------------------
32+
33+ type Eq < A , B > = ( < T > ( ) => T extends A ? 1 : 2 ) extends ( < T > ( ) => T extends B ? 1 : 2 ) ? true : false ;
34+ type Assert < T extends true > = T ;
35+
36+ /** Optional: a request may omit it. A required member turns this alias red. */
37+ export type ExplainRecordIdsIsOptional = Assert < { } extends Pick < ExplainAccessRequest , 'recordIds' > ? true : false > ;
38+
39+ /** The wire schema's own input type for the member — neither side may drift. */
40+ export type ExplainRecordIdsMatchesWire = Assert <
41+ Eq < ExplainAccessRequest [ 'recordIds' ] , z . input < typeof ExplainRequestSchema > [ 'recordIds' ] >
42+ > ;
1343
1444/**
1545 * These tests pin the two things a consumer of the `security` service reasons
@@ -563,6 +593,29 @@ describe('Security Service Contract', () => {
563593 expect ( seen [ 0 ] ) . toEqual ( { object : 'deal' , operation : 'update' , userId : 'u2' , recordId : 'r1' } ) ;
564594 } ) ;
565595
596+ it ( 'explain accepts a batch request: recordIds is the wire schema\'s member, typed as a list of ids' , async ( ) => {
597+ expectTypeOf < ExplainAccessRequest [ 'recordIds' ] > ( )
598+ . toEqualTypeOf < z . input < typeof ExplainRequestSchema > [ 'recordIds' ] > ( ) ;
599+ expectTypeOf < ExplainAccessRequest [ 'recordIds' ] > ( ) . toEqualTypeOf < string [ ] | undefined > ( ) ;
600+
601+ const seen : ExplainAccessRequest [ ] = [ ] ;
602+ const service = makeService ( {
603+ explain : async ( request ) => { seen . push ( request ) ; return { } as any ; } ,
604+ } ) ;
605+ // The literal compiling is the point: before the contract declared the
606+ // member, this call was an excess-property error.
607+ await service . explain (
608+ { object : 'deal' , operation : 'read' , recordIds : [ 'r1' , 'r2' , 'r1' ] } ,
609+ { userId : 'admin' } ,
610+ ) ;
611+ expect ( seen [ 0 ] ?. recordIds ) . toEqual ( [ 'r1' , 'r2' , 'r1' ] ) ;
612+
613+ // A bare id is the singular selector's job, not a one-element batch.
614+ // @ts -expect-error — `recordIds` is a list, never a lone string.
615+ const bare : ExplainAccessRequest = { object : 'deal' , operation : 'read' , recordIds : 'r1' } ;
616+ expect ( bare . object ) . toBe ( 'deal' ) ;
617+ } ) ;
618+
566619 it ( 'contributeOwnershipFloorAlternates is OPTIONAL — absence is typed, and an alternate names exactly one floor limb' , ( ) => {
567620 // A security service without the seam still satisfies the contract.
568621 // Absence leaves the floor in force, which is the fail-closed direction:
0 commit comments