44// booting from `dist/objectstack.json`, no host `objectstack.config.ts`) must
55// surface the artifact's app-declared RBAC — `permissions[]` and `positions[]`
66// — at the top level of the returned stack config. The CLI reads
7- // `config.permissions` to honour an app-declared default profile (ADR-0056 D7 —
8- // `appDefaultPermissionSetName` → SecurityPlugin `fallbackPermissionSet`); the
7+ // `config.permissions` to honour an app-declared default profile (ADR-0056 D7 /
8+ // ADR-0090 D5 — `appSecurityPluginOptions(config)` → SecurityPlugin
9+ // `fallbackPermissionSet`, one resolution for every boot path since #7001); the
910// positions are distributed through `sys_user_position`, never as organization
1011// roles (ADR-0108). Before this was fixed, `createStandaloneStack`
1112// surfaced `objects`/`requires`/`manifest` but dropped `permissions`/`roles`, so
@@ -19,6 +20,28 @@ import { tmpdir } from 'node:os';
1920import { join } from 'node:path' ;
2021import { createStandaloneStack } from './standalone-stack.js' ;
2122import { createDefaultHostConfig , resolveDefaultArtifactPath } from './default-host.js' ;
23+ // The REAL resolution, imported — not reproduced. `@objectstack/plugin-security`
24+ // is a plain `dependencies` entry of this package (and another test in this same
25+ // package, src/domains/share-links-enforcement-context.test.ts, already imports
26+ // SecurityPlugin from it), so the "not a runtime dependency" that once justified
27+ // hand-copying the rule here does not hold. #7092: the copy WAS the defect —
28+ // a case titled "the exact CLI wiring" that could only ever prove a duplicate of
29+ // the rule equals itself, and would have stayed green through any change to the
30+ // real helper (last-`isDefault` instead of first, an anchor pre-filter, a shape
31+ // change) while `objectstack dev`/`serve` did something else.
32+ //
33+ // `appSecurityPluginOptions` is the anchor rather than the bare name helper
34+ // because it is what `serve.ts` actually calls today (#7001):
35+ // `new SecurityPlugin(appSecurityPluginOptions(config))`. That construction —
36+ // serve's side of it, and its parity with `bootStack` — is pinned in
37+ // packages/cli/src/commands/serve-verify-security-parity.contract.test.ts; the
38+ // helper's own contract (first-`isDefault` wins, the undefined-vs-`{...undefined}`
39+ // distinction, top-level-only) in
40+ // packages/plugins/plugin-security/src/app-default-permission-set.test.ts. What
41+ // neither of those can see, and what THIS file owns, is the composition: that the
42+ // config `createStandaloneStack` / `createDefaultHostConfig` actually return is a
43+ // config that resolution reads correctly.
44+ import { appDefaultPermissionSetName , appSecurityPluginOptions } from '@objectstack/plugin-security' ;
2245
2346// A minimal `objectstack build` artifact carrying an app-declared default
2447// profile with a hierarchy read scope, an add-on permission set, app roles,
@@ -31,7 +54,16 @@ const ARTIFACT = {
3154 { name : 'manager' , label : 'Manager' } ,
3255 { name : 'contributor' , label : 'Contributor' } ,
3356 ] ,
57+ // Declaration order is deliberate and load-bearing (#7092): the NON-default set
58+ // comes FIRST, so the resolution cases below discriminate `isDefault` rather
59+ // than agreeing with "take permissions[0]". With the default set first, a
60+ // resolution that had degenerated to the first entry would have been green.
3461 permissions : [
62+ {
63+ name : 'app_contributor' ,
64+ label : 'Contributor add-on' ,
65+ objects : { note : { allowEdit : true } } ,
66+ } ,
3567 {
3668 name : 'app_member_default' ,
3769 label : 'App Member (Default)' ,
@@ -40,30 +72,9 @@ const ARTIFACT = {
4072 note : { allowRead : true , allowCreate : true , readScope : 'unit_and_below' , writeScope : 'unit' } ,
4173 } ,
4274 } ,
43- {
44- name : 'app_contributor' ,
45- label : 'Contributor add-on' ,
46- objects : { note : { allowEdit : true } } ,
47- } ,
4875 ] ,
4976} ;
5077
51- // Mirrors `appDefaultPermissionSetName` from @objectstack/plugin-security (not a
52- // runtime dependency, so the resolution rule is reproduced here): the first
53- // first `isDefault` permission set's name (ADR-0090 D5).
54- function appDefaultPermissionSetName ( permissions : unknown ) : string | undefined {
55- if ( ! Array . isArray ( permissions ) ) return undefined ;
56- for ( const p of permissions ) {
57- if ( p && typeof p === 'object' ) {
58- const ps = p as { name ?: unknown ; isDefault ?: unknown } ;
59- if ( ps . isDefault === true && typeof ps . name === 'string' && ps . name . length > 0 ) {
60- return ps . name ;
61- }
62- }
63- }
64- return undefined ;
65- }
66-
6778// The first createStandaloneStack call cold-loads heavy deps (objectql,
6879// metadata, driver-memory) via dynamic import — on a cold CI worker that can
6980// exceed vitest's default 5s test timeout. Do the one-time boot in beforeAll
@@ -83,9 +94,14 @@ describe('createStandaloneStack — surfaces app RBAC from the artifact (ADR-005
8394 } , BOOT_TIMEOUT ) ;
8495 afterAll ( ( ) => { try { rmSync ( dir , { recursive : true , force : true } ) ; } catch { /* noop */ } } ) ;
8596
86- it ( 'surfaces permissions[] (with isDefault profile + readScope) at the top level' , ( ) => {
97+ it ( 'surfaces permissions[] (with isDefault profile + readScope) at the top level, in DECLARATION order ' , ( ) => {
8798 expect ( Array . isArray ( result . permissions ) ) . toBe ( true ) ;
88- expect ( result . permissions ! . map ( ( p : any ) => p . name ) . sort ( ) ) . toEqual ( [ 'app_contributor' , 'app_member_default' ] ) ;
99+ // Unsorted, on purpose. `appDefaultPermissionSetName` resolves the FIRST
100+ // `isDefault` set, so "which order does the artifact's array arrive in" is a
101+ // precondition of that rule, not a presentation detail — and it is this
102+ // package's half of it. A `.sort()` here erases exactly the property the
103+ // resolution depends on (#7092).
104+ expect ( result . permissions ! . map ( ( p : any ) => p . name ) ) . toEqual ( [ 'app_contributor' , 'app_member_default' ] ) ;
89105 const def = result . permissions ! . find ( ( p : any ) => p . name === 'app_member_default' ) ;
90106 expect ( def . isDefault ) . toBe ( true ) ;
91107 // the hierarchy read scope must ride through intact — this is what was lost.
@@ -103,10 +119,17 @@ describe('createStandaloneStack — surfaces app RBAC from the artifact (ADR-005
103119 expect ( result . manifest ?. id ) . toBe ( 'com.test.scope-app' ) ;
104120 } ) ;
105121
106- it ( 'the surfaced config drives appDefaultPermissionSetName → the app profile (the exact CLI wiring) ' , ( ) => {
107- // Reproduce serve.ts: `config = { ...originalConfig, ...standaloneStack }`,
108- // then `appDefaultPermissionSetName( config.permissions)` → SecurityPlugin fallback .
122+ it ( 'the surfaced config feeds the REAL appSecurityPluginOptions → the app profile' , ( ) => {
123+ // Reproduce serve.ts's merge : `config = { ...originalConfig, ...standaloneStack }`,
124+ // then `new SecurityPlugin(appSecurityPluginOptions( config))` .
109125 const config : any = { ...{ } , ...result } ;
126+ // The whole constructor argument, deep-equalled — the OPTIONS shape is the
127+ // half that was a decision (`name ? { fallbackPermissionSet: name }
128+ // : undefined`), so asserting only the name would leave it unmeasured here.
129+ expect ( appSecurityPluginOptions ( config ) ) . toEqual ( { fallbackPermissionSet : 'app_member_default' } ) ;
130+ // …and the name half, read off the surfaced array exactly as the helper does.
131+ // `app_contributor` sits ahead of it in the artifact, so this is a real
132+ // discrimination on `isDefault`, not agreement with permissions[0].
110133 expect ( appDefaultPermissionSetName ( config . permissions ) ) . toBe ( 'app_member_default' ) ;
111134 } ) ;
112135
@@ -116,6 +139,7 @@ describe('createStandaloneStack — surfaces app RBAC from the artifact (ADR-005
116139 artifactPath,
117140 databaseUrl : 'memory://standalone-rbac' ,
118141 } ) ;
142+ expect ( appSecurityPluginOptions ( r ) ) . toEqual ( { fallbackPermissionSet : 'app_member_default' } ) ;
119143 expect ( appDefaultPermissionSetName ( r . permissions ) ) . toBe ( 'app_member_default' ) ;
120144 expect ( r . positions ! . map ( ( x : any ) => x . name ) . sort ( ) ) . toEqual ( [ 'contributor' , 'manager' ] ) ;
121145 } , BOOT_TIMEOUT ) ;
0 commit comments