@@ -52,10 +52,12 @@ import { PermissionSetSchema } from '@objectstack/spec/security';
5252import type { PermissionSet } from '@objectstack/spec/security' ;
5353import type { ExecutionContext } from '@objectstack/spec/kernel' ;
5454import { SHARE_LINK_SERVICE } from '@objectstack/spec/contracts' ;
55- import { SecurityPlugin } from '@objectstack/plugin-security' ;
55+ import { PermissionDeniedError , SecurityPlugin } from '@objectstack/plugin-security' ;
5656import { ShareLinkService } from '@objectstack/plugin-sharing' ;
57+ import { ApiErrorSchema , BaseResponseSchema , envelopeViolations } from '@objectstack/spec/api' ;
5758import { apiErrorResponse } from '../error-envelope.js' ;
5859import { handleShareLinksRequest } from './share-links.js' ;
60+ import { HttpDispatcher } from '../http-dispatcher.js' ;
5961import type { HttpProtocolContext } from '../http-dispatcher.js' ;
6062import type { DomainHandlerDeps } from '../domain-handler-registry.js' ;
6163
@@ -121,6 +123,24 @@ const EAST_VIEWER: PermissionSet = PermissionSetSchema.parse({
121123 ] ,
122124} ) ;
123125
126+ /**
127+ * [#6649] The CRUD-gate denial's input: an object grant with NO `allowRead`.
128+ *
129+ * The baseline is ADDITIVE for every human principal (ADR-0090 D5 — the
130+ * `fallbackPermissionSet` applies IN ADDITION to whatever else resolved), so a
131+ * caller only reaches the CRUD gate's deny branch when the baseline itself
132+ * withholds read. This set is therefore wired as BOTH the caller's explicit set
133+ * and the fallback in the #6649 cases below: `allowCreate` alone, so
134+ * `checkObjectPermission('find', 'crm_account', …)` is false and the security
135+ * middleware throws `PermissionDeniedError` — the `statusCode`-only shape whose
136+ * status the domain used to drop.
137+ */
138+ const ACCT_NO_READ : PermissionSet = PermissionSetSchema . parse ( {
139+ name : 'acct_no_read' ,
140+ label : 'Account create-only (no read grant)' ,
141+ objects : { crm_account : { allowCreate : true } } ,
142+ } ) ;
143+
124144const PERMISSION_SETS = [ ACCT_MEMBER , EAST_VIEWER ] ;
125145
126146function matches ( row : any , filter : any ) : boolean {
@@ -216,13 +236,21 @@ function makeEngine(tables: Record<string, any[]>) {
216236 * posture arrives the production way — via the `tenancy` service (ADR-0093
217237 * D4 / ADR-0105 D1).
218238 */
219- async function bootSecurity ( engine : any , posture : 'single' | 'group' ) : Promise < void > {
239+ async function bootSecurity (
240+ engine : any ,
241+ posture : 'single' | 'group' ,
242+ // [#6649] The permission-set world and its additive baseline are parameters
243+ // now; the defaults are byte-for-byte what every #6551 case above booted
244+ // with, so those verdicts are untouched.
245+ sets : PermissionSet [ ] = PERMISSION_SETS ,
246+ fallback : string = 'acct_member' ,
247+ ) : Promise < void > {
220248 const services : Record < string , any > = {
221249 manifest : { register : vi . fn ( ) } ,
222250 objectql : engine ,
223251 metadata : {
224252 get : async ( _type : string , name : string ) => ( name === OBJECT ? ACCOUNT_SCHEMA : null ) ,
225- list : async ( ) => PERMISSION_SETS ,
253+ list : async ( ) => sets ,
226254 } ,
227255 tenancy : { posture } ,
228256 } ;
@@ -236,11 +264,11 @@ async function bootSecurity(engine: any, posture: 'single' | 'group'): Promise<v
236264 } ,
237265 } ;
238266 const plugin = new SecurityPlugin ( {
239- defaultPermissionSets : PERMISSION_SETS ,
267+ defaultPermissionSets : sets ,
240268 // The additive human baseline, as in production (member_default's role).
241269 // This is also exactly what a TRUNCATED context degrades to: with
242270 // `permissions` stripped, resolution falls back to this one set.
243- fallbackPermissionSet : 'acct_member' ,
271+ fallbackPermissionSet : fallback ,
244272 } ) ;
245273 await plugin . init ( ctx ) ;
246274 await plugin . start ( ctx ) ;
@@ -273,6 +301,24 @@ function envelopeFor(opts: {
273301 return ctx as ExecutionContext ;
274302}
275303
304+ /**
305+ * [#6649] The dispatcher's own thrown-error mapper — the REAL private method,
306+ * borrowed off a dispatcher constructed over a kernel stub exactly as
307+ * `error-envelope.conformance.test.ts`'s `makeDispatcher()` does.
308+ *
309+ * Deliberately NOT a hand-written `e?.status ?? e?.statusCode ?? 500` double: a
310+ * restatement here would make these cases green against the double's rules
311+ * rather than production's, which is the same class of mistake as the
312+ * hand-written catch this issue is about. Every branch it takes (status from
313+ * `status` OR `statusCode`, `.code` carried through `details` for
314+ * `buildApiError` to promote, `INTERNAL_ERROR` derived when the throw has no
315+ * code, the 5xx leak guard) is production's.
316+ */
317+ const realErrorFromThrown = ( ( ) => {
318+ const dispatcher : any = new HttpDispatcher ( { context : { getService : ( ) => null } } as any ) ;
319+ return ( e : any , fallbackStatus ?: number ) => dispatcher . errorFromThrown ( e , fallbackStatus ) ;
320+ } ) ( ) ;
321+
276322function makeDeps ( engine : any , svc : any ) : DomainHandlerDeps {
277323 const deps : any = {
278324 resolveService : async ( _c : any , name : string ) =>
@@ -284,6 +330,7 @@ function makeDeps(engine: any, svc: any): DomainHandlerDeps {
284330 // ADR-0112 shape, not a lookalike.
285331 error : ( message : string , httpStatus = 500 , details ?: any ) => apiErrorResponse ( { message, httpStatus, details } ) ,
286332 routeNotFound : ( route : string ) => apiErrorResponse ( { message : `Route not found: ${ route } ` , httpStatus : 404 } ) ,
333+ errorFromThrown : realErrorFromThrown ,
287334 } ;
288335 return deps as DomainHandlerDeps ;
289336}
@@ -295,6 +342,10 @@ interface MintOptions {
295342 posture : 'single' | 'group' ;
296343 envelope : ExecutionContext | undefined ;
297344 records : any [ ] ;
345+ /** [#6649] The permission-set world to boot; defaults to the #6551 one. */
346+ permissionSets ?: PermissionSet [ ] ;
347+ /** [#6649] The additive baseline; defaults to the #6551 `acct_member`. */
348+ fallbackPermissionSet ?: string ;
298349}
299350
300351/**
@@ -305,7 +356,7 @@ interface MintOptions {
305356async function mintOnDispatcher ( opts : MintOptions ) : Promise < { status : number ; body : any } > {
306357 const tables : Record < string , any [ ] > = { [ OBJECT ] : opts . records , sys_share_link : [ ] , sys_permission_set : [ ] } ;
307358 const engine = makeEngine ( tables ) ;
308- await bootSecurity ( engine , opts . posture ) ;
359+ await bootSecurity ( engine , opts . posture , opts . permissionSets , opts . fallbackPermissionSet ) ;
309360 const svc = new ShareLinkService ( { engine : engine as any } ) ;
310361 const deps = makeDeps ( engine , svc ) ;
311362 const res = await handleShareLinksRequest (
@@ -468,3 +519,142 @@ describe('[#6551] the dispatcher seam itself', () => {
468519 expect ( svc . createLink ) . not . toHaveBeenCalled ( ) ;
469520 } ) ;
470521} ) ;
522+
523+ /**
524+ * [#6649] The domain's unified catch and the two channels a thrown refusal
525+ * carries its HTTP status on.
526+ *
527+ * The catch read `err?.status ?? 500` only. Every refusal `ShareLinkService`
528+ * raises itself carries `status` (its `makeError` sets `status` + `code`), which
529+ * is why the `403 FORBIDDEN` cases above were already correct and stayed correct
530+ * — but the SECURITY middleware's refusals do not come from that service. The
531+ * CRUD gate throws `PermissionDeniedError { code: 'PERMISSION_DENIED';
532+ * statusCode: 403 }` — no `status` field at all — straight out of
533+ * `svc.createLink`'s visibility read, past a service that does not catch it, into
534+ * this catch. `err?.status` was `undefined`, so the 403 left as a **500** while
535+ * `code` still read `PERMISSION_DENIED`: an envelope that contradicts itself, and
536+ * a status many clients treat as retryable when the answer is permanent.
537+ *
538+ * The fix routes the catch through `deps.errorFromThrown` — the dispatcher's
539+ * shared mapper, which `/meta`, `/actions` and `/mcp` already exit through and
540+ * which reads `status` OR `statusCode`. These cases assert `status` AND `code`
541+ * together on purpose: a denial arriving as 403 under the wrong code would be
542+ * just as wrong as the 500, and only the pair separates them.
543+ */
544+
545+ /** The ADR-0112 envelope checks every case below shares. */
546+ function expectDeclaredEnvelope ( res : { status : number ; body : any } ) : any {
547+ expect ( BaseResponseSchema . safeParse ( res . body ) . success ) . toBe ( true ) ;
548+ expect ( envelopeViolations ( res . body ) , `not the declared envelope: ${ JSON . stringify ( res . body ) } ` ) . toEqual ( [ ] ) ;
549+ const parsed = ApiErrorSchema . safeParse ( res . body . error ) ;
550+ // `ApiErrorSchema.code` validates against the CLOSED set (StandardErrorCode ∪
551+ // ERROR_CODE_LEDGER), so this is also what keeps the code out of the
552+ // free-string space the old `'INTERNAL'` fallback sat in.
553+ expect ( parsed . error ?. issues ?? [ ] ) . toEqual ( [ ] ) ;
554+ expect ( res . body . error . httpStatus ) . toBe ( res . status ) ;
555+ return res . body . error ;
556+ }
557+
558+ /** A `/share-links` call served by a service double that throws `thrown`. */
559+ async function refusalFromService (
560+ thrown : unknown ,
561+ verb : 'POST' | 'GET' | 'DELETE' = 'POST' ,
562+ ) : Promise < { status : number ; body : any } > {
563+ const boom = async ( ) => { throw thrown ; } ;
564+ const svc = {
565+ createLink : vi . fn ( boom ) ,
566+ listLinks : vi . fn ( boom ) ,
567+ revokeLink : vi . fn ( boom ) ,
568+ resolveToken : vi . fn ( async ( ) => null ) ,
569+ } ;
570+ const deps = makeDeps ( makeEngine ( { [ OBJECT ] : [ ] , sys_share_link : [ ] } ) , svc ) ;
571+ const res = await handleShareLinksRequest (
572+ deps ,
573+ verb === 'DELETE' ? '/shl_x' : '' ,
574+ verb ,
575+ verb === 'POST' ? { object : OBJECT , recordId : RECORD } : undefined ,
576+ { } ,
577+ httpContext ( envelopeFor ( { memberOf : [ ORG_A ] , permissions : [ 'acct_member' ] } ) ) ,
578+ ) ;
579+ if ( ! res . handled || ! res . response ) throw new Error ( `${ verb } /share-links was not handled` ) ;
580+ return res . response as { status : number ; body : any } ;
581+ }
582+
583+ /** The caller of the repro: create-only grant, so the visibility read is denied. */
584+ const noReadCaller = ( posture : 'single' | 'group' ) => ( {
585+ posture,
586+ envelope : envelopeFor ( { memberOf : [ ORG_A ] , permissions : [ 'acct_no_read' ] } ) ,
587+ records : ownRecordInA ( ) ,
588+ permissionSets : [ ACCT_NO_READ ] ,
589+ fallbackPermissionSet : 'acct_no_read' ,
590+ } ) ;
591+
592+ describe ( '[#6649] a security-middleware refusal keeps its own status through the domain catch' , ( ) => {
593+ it ( 'single posture: no allowRead on the object answers 403 PERMISSION_DENIED (was 500 + PERMISSION_DENIED)' , async ( ) => {
594+ const res = await mintOnDispatcher ( noReadCaller ( 'single' ) ) ;
595+
596+ // The pair, together. Before the fix `code` was ALREADY
597+ // `PERMISSION_DENIED` here — only the status was wrong — so a case
598+ // asserting the code alone was green on the defect, and one asserting
599+ // "not 500" alone could not tell a right refusal from a wrong one.
600+ expect ( res . status ) . toBe ( 403 ) ;
601+ expect ( expectDeclaredEnvelope ( res ) . code ) . toBe ( 'PERMISSION_DENIED' ) ;
602+ // Coverage, NOT a discriminator: the message does not move between the
603+ // two directions of this fix (the pre-fix 500 exited through this
604+ // harness's `error` double, which has no leak guard, and the security
605+ // message trips no clause of `looksLikeInternalErrorLeak` anyway). It
606+ // pins the refusal's own reason against a FUTURE widening of that
607+ // heuristic swallowing an authorization answer.
608+ expect ( res . body . error . message ) . toContain ( 'Access denied' ) ;
609+ } , 30_000 ) ;
610+
611+ it ( 'group posture: the same denial, the same envelope — the defect was never posture-specific' , async ( ) => {
612+ const res = await mintOnDispatcher ( noReadCaller ( 'group' ) ) ;
613+
614+ expect ( res . status ) . toBe ( 403 ) ;
615+ expect ( expectDeclaredEnvelope ( res ) . code ) . toBe ( 'PERMISSION_DENIED' ) ;
616+ } , 30_000 ) ;
617+
618+ it ( 'the catch is shared, so list and revoke answer the statusCode-only refusal identically' , async ( ) => {
619+ // Driven with a service double raising the REAL `PermissionDeniedError`
620+ // (the production class, `statusCode` and no `status`), because what is
621+ // under test here is the CATCH, not a second trip through the middleware.
622+ for ( const verb of [ 'GET' , 'DELETE' ] as const ) {
623+ const res = await refusalFromService (
624+ new PermissionDeniedError ( `[Security] Access denied: operation on object '${ OBJECT } '` ) ,
625+ verb ,
626+ ) ;
627+ expect ( res . status , `${ verb } status` ) . toBe ( 403 ) ;
628+ expect ( expectDeclaredEnvelope ( res ) . code , `${ verb } code` ) . toBe ( 'PERMISSION_DENIED' ) ;
629+ }
630+ } ) ;
631+
632+ it ( 'a throw carrying neither channel still answers 500 — under the CATALOGUED code, not the unregistered `INTERNAL`' , async ( ) => {
633+ const res = await refusalFromService ( new Error ( 'share link storage exploded' ) ) ;
634+
635+ expect ( res . status ) . toBe ( 500 ) ;
636+ // `'INTERNAL'` is registered in `ERROR_CODE_LEDGER` for `rest` /
637+ // `service-storage` / `service-i18n` / `plugin-sharing` — never for
638+ // `@objectstack/runtime`. The union dedupes, so the schema stayed green
639+ // while this domain emitted a code it had not registered; the shared
640+ // mapper answers with the derived `INTERNAL_ERROR` instead.
641+ expect ( expectDeclaredEnvelope ( res ) . code ) . toBe ( 'INTERNAL_ERROR' ) ;
642+ } ) ;
643+
644+ it ( 'a refusal that DOES carry `status` is untouched — the fix widens the channel, it does not switch it' , async ( ) => {
645+ // Honest note: this case is green in BOTH directions of the fix, by
646+ // construction — `ShareLinkService.makeError` sets `status`, which the
647+ // old chain read first and the shared mapper reads first. It is not a
648+ // regression pin for #6649 but for the NEXT change to this exit: it goes
649+ // red if the `status` channel is ever dropped in favour of `statusCode`.
650+ const res = await refusalFromService (
651+ Object . assign ( new Error ( 'Sharing is not enabled for this object' ) , {
652+ status : 422 ,
653+ code : 'SHARING_NOT_ENABLED' ,
654+ } ) ,
655+ ) ;
656+
657+ expect ( res . status ) . toBe ( 422 ) ;
658+ expect ( expectDeclaredEnvelope ( res ) . code ) . toBe ( 'SHARING_NOT_ENABLED' ) ;
659+ } ) ;
660+ } ) ;
0 commit comments