From 9c64032e6be09349b52120f16fa1e8c297f5b565 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sun, 21 Jun 2026 22:35:57 +0800 Subject: [PATCH 1/3] fix(runtime): honor app-declared default profile on the artifact-serve path (ADR-0056 D7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `createStandaloneStack` (the boot path used by `objectstack dev`/`serve`/`start` when serving a compiled `dist/objectstack.json` with no host `objectstack.config.ts`) surfaced `objects`/`requires`/`manifest` from the artifact bundle but dropped `permissions[]` and `roles[]`. So the CLI's `appDefaultProfileName(config.permissions)` saw `undefined` and SecurityPlugin fell back to the built-in owner-only `member_default` — an app `isDefault` profile carrying e.g. `readScope: 'unit_and_below'` was silently ignored. The config-load path was unaffected (the app's `permissions` survived via the original stack object). Surface `permissions[]` and `roles[]` from the artifact bundle, mirroring the existing `objects`/`requires`/`manifest` handling, so the artifact-serve path applies the app default profile exactly like the config-load path. Tests: - packages/runtime/src/standalone-stack.test.ts — the artifact-serve path now surfaces permissions/roles (incl. readScope) and drives appDefaultProfileName. - packages/dogfood/test/showcase-scope-depth-fallback.dogfood.test.ts — a profile resolved by name as fallbackPermissionSet widens the visibility matrix (unit_and_below) with the reference hierarchy resolver, and fails closed without it. Co-Authored-By: Claude Opus 4.8 --- .changeset/dev-app-default-profile.md | 21 +++ ...wcase-scope-depth-fallback.dogfood.test.ts | 176 ++++++++++++++++++ packages/runtime/src/standalone-stack.test.ts | 119 ++++++++++++ packages/runtime/src/standalone-stack.ts | 22 +++ 4 files changed, 338 insertions(+) create mode 100644 .changeset/dev-app-default-profile.md create mode 100644 packages/dogfood/test/showcase-scope-depth-fallback.dogfood.test.ts create mode 100644 packages/runtime/src/standalone-stack.test.ts diff --git a/.changeset/dev-app-default-profile.md b/.changeset/dev-app-default-profile.md new file mode 100644 index 0000000000..e21f8f9691 --- /dev/null +++ b/.changeset/dev-app-default-profile.md @@ -0,0 +1,21 @@ +--- +"@objectstack/runtime": patch +--- + +Fix: the artifact-serve path now honors an app-declared default permission-set +profile (`isProfile: true, isDefault: true`) under `objectstack dev`/`serve`/`start`. + +`createStandaloneStack` (the boot path used when serving a compiled +`dist/objectstack.json` with no host `objectstack.config.ts`) surfaced +`objects`/`requires`/`manifest` from the artifact bundle but dropped +`permissions[]` and `roles[]`. As a result the CLI's +`appDefaultProfileName(config.permissions)` saw `undefined` and the SecurityPlugin +fell back to the built-in owner-only `member_default` — so an app whose default +profile carries e.g. `readScope: 'unit_and_below'` (ADR-0056 D7 / ADR-0057 D1) +was silently ignored. The config-load path was unaffected because the app's +`permissions` survived via the original stack object. + +`createStandaloneStack` now surfaces `permissions[]` and `roles[]` from the +artifact bundle, mirroring the existing `objects`/`requires`/`manifest` handling, +so the artifact-serve path applies the app default profile exactly like the +config-load path. diff --git a/packages/dogfood/test/showcase-scope-depth-fallback.dogfood.test.ts b/packages/dogfood/test/showcase-scope-depth-fallback.dogfood.test.ts new file mode 100644 index 0000000000..01ac6fd62e --- /dev/null +++ b/packages/dogfood/test/showcase-scope-depth-fallback.dogfood.test.ts @@ -0,0 +1,176 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0056 D7 + ADR-0057 D1 — app-declared DEFAULT PROFILE honored via the CLI +// wiring (`appDefaultProfileName` → SecurityPlugin `fallbackPermissionSet`), +// resolved BY NAME from metadata, carrying a hierarchy `readScope`. +// +// This is the companion to `showcase-scope-depth.dogfood.test.ts`. That test +// injects the scope profile directly as a SecurityPlugin `defaultPermissionSet` +// (an in-memory bootstrap set). THIS test exercises the path that +// `objectstack dev`/`serve`/`start` actually take: the app declares the default +// profile in METADATA, the CLI computes its name with `appDefaultProfileName` +// and passes only that NAME as `fallbackPermissionSet`, and the SecurityPlugin +// resolves the full set (incl. `readScope`) from `sys_permission_set` at request +// time. The bug this guards: the artifact-serve path used to drop `permissions[]` +// from the stack config, so `appDefaultProfileName` saw nothing, the fallback +// silently degraded to the built-in owner-only `member_default`, and a grant-less +// user never got the app's declared `readScope` widening. +// +// @proof: showcase-scope-depth-fallback + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { SecurityPlugin, appDefaultProfileName } from '@objectstack/plugin-security'; + +const OBJ = '/data/showcase_private_note'; +const WHO = ['alice', 'bob', 'carol', 'dave'] as const; +type Who = (typeof WHO)[number]; + +const PROFILE_NAME = 'scope_fallback_unit_and_below'; + +// The app-declared default profile, as it appears in stack `permissions[]` +// metadata. `isDefault: true` is what `appDefaultProfileName` keys off. +const DEFAULT_PROFILE_METADATA = { + name: PROFILE_NAME, + label: 'Scope Fallback (unit_and_below)', + isProfile: true, + isDefault: true, + objects: { + showcase_private_note: { + allowRead: true, allowCreate: true, allowEdit: true, + readScope: 'unit_and_below', writeScope: 'unit_and_below', + }, + }, +}; + +interface World { stack: VerifyStack; tokens: Record; } + +// Same BU world as the reference test: bu_parent ⊃ bu_child (sibling bu_other). +// alice+carol ∈ bu_parent, bob ∈ bu_child, dave ∈ bu_other. Each owns one note. +// The scope profile is NOT passed as a bootstrap permission set — it is seeded +// into `sys_permission_set` (the runtime home of an app-declared `permission`) +// and reached only by NAME via `fallbackPermissionSet`. +async function bootFallbackWorld(withResolver = true): Promise { + const stack = await bootStack(showcaseStack, { + // Mirror the CLI exactly: the app's isDefault profile name, computed off the + // declared `permissions[]`, handed to SecurityPlugin as the fallback. No + // `defaultPermissionSets` carry the scope profile — it must resolve from DB. + security: new SecurityPlugin({ + fallbackPermissionSet: appDefaultProfileName([DEFAULT_PROFILE_METADATA]), + }), + }); + await stack.signIn(); + const tokens = {} as Record; + for (const who of WHO) tokens[who] = await stack.signUp(`fb-${who}@verify.test`); + + const ql: any = await stack.kernel.getServiceAsync('objectql'); + const sys = (o: string, d: any) => ql.insert(o, d, { context: { isSystem: true } }); + + // Reference hierarchy-scope resolver (test fixture; prod = @objectstack/security-enterprise). + const refResolver = { + async resolveOwnerIds(c: any, sc: string): Promise { + const meId = c.userId as string; + const ids = new Set([meId]); + const myBus = await ql.find('sys_business_unit_member', { where: { user_id: meId }, fields: ['business_unit_id'], context: { isSystem: true } }); + let buIds: string[] = [...new Set((myBus ?? []).map((r: any) => String(r.business_unit_id ?? '')).filter(Boolean))] as string[]; + if (!buIds.length) return [meId]; + if (sc === 'unit_and_below') { + const allBu = new Set(buIds); let frontier: string[] = [...buIds]; + for (let d = 0; d < 20 && frontier.length; d++) { + const kids = await ql.find('sys_business_unit', { where: { parent_business_unit_id: { $in: frontier } }, fields: ['id'], context: { isSystem: true } }); + const next: string[] = []; + for (const k of kids ?? []) { const id = String(k.id ?? ''); if (id && !allBu.has(id)) { allBu.add(id); next.push(id); } } + frontier = next; + } + buIds = [...allBu]; + } + const m = await ql.find('sys_business_unit_member', { where: { business_unit_id: { $in: buIds } }, fields: ['user_id'], context: { isSystem: true } }); + for (const x of m ?? []) { const u = String(x.user_id ?? ''); if (u) ids.add(u); } + return [...ids]; + }, + }; + if (withResolver) (stack.kernel as any).registerService('hierarchy-scope-resolver', refResolver); + + // Seed the app-declared profile into `sys_permission_set` — this is what an + // app `permission` metadata becomes at runtime, and what the named fallback + // resolves through the SecurityPlugin dbLoader. `readScope` rides inside + // object_permissions JSON. + await sys('sys_permission_set', { + name: PROFILE_NAME, + label: DEFAULT_PROFILE_METADATA.label, + active: true, + object_permissions: JSON.stringify(DEFAULT_PROFILE_METADATA.objects), + }); + + const uid = async (who: Who) => + (await ql.findOne('sys_user', { where: { email: `fb-${who}@verify.test` }, context: { isSystem: true } }))?.id; + const id = {} as Record; + for (const who of WHO) id[who] = await uid(who); + + let org = await ql.findOne('sys_organization', { where: {}, context: { isSystem: true } }).catch(() => null); + let orgId = org?.id; + if (!orgId) { orgId = 'org_fb'; await sys('sys_organization', { id: orgId, name: 'FB Org', slug: 'fb' }).catch(() => {}); } + + await sys('sys_business_unit', { id: 'bu_parent_fb', name: 'Parent', kind: 'division', organization_id: orgId, active: true }); + await sys('sys_business_unit', { id: 'bu_child_fb', name: 'Child', kind: 'department', parent_business_unit_id: 'bu_parent_fb', organization_id: orgId, active: true }); + await sys('sys_business_unit', { id: 'bu_other_fb', name: 'Other', kind: 'division', organization_id: orgId, active: true }); + await sys('sys_business_unit_member', { id: 'm_a_fb', business_unit_id: 'bu_parent_fb', user_id: id.alice }); + await sys('sys_business_unit_member', { id: 'm_c_fb', business_unit_id: 'bu_parent_fb', user_id: id.carol }); + await sys('sys_business_unit_member', { id: 'm_b_fb', business_unit_id: 'bu_child_fb', user_id: id.bob }); + await sys('sys_business_unit_member', { id: 'm_d_fb', business_unit_id: 'bu_other_fb', user_id: id.dave }); + + for (const who of WHO) { + const r = await stack.apiAs(tokens[who], 'POST', OBJ, { title: `${who} note` }); + expect(r.status, `${who} creates note (fallback grants allowCreate)`).toBeLessThan(300); + } + return { stack, tokens }; +} + +async function titles(stack: VerifyStack, token: string): Promise { + const r = await stack.apiAs(token, 'GET', OBJ); + expect(r.status).toBe(200); + const b: any = await r.json(); + return (b.records ?? b.data ?? b ?? []).map((x: any) => x.title).filter(Boolean); +} + +describe('app-default-profile via fallbackPermissionSet (ADR-0056 D7 / ADR-0057 D1)', () => { + it('appDefaultProfileName picks the isDefault profile name (the CLI helper)', () => { + expect(appDefaultProfileName([DEFAULT_PROFILE_METADATA])).toBe(PROFILE_NAME); + // an add-on (isProfile:false) is never chosen as the default + expect(appDefaultProfileName([{ name: 'addon', isProfile: false, isDefault: true }])).toBeUndefined(); + }); +}); + +describe('fallback profile honored: readScope `unit_and_below` widens the matrix', () => { + let world: World; + beforeAll(async () => { world = await bootFallbackWorld(true); }, 120_000); + afterAll(async () => { await world?.stack?.stop(); }); + + it('a grant-less member gets the app default profile, not owner-only member_default', async () => { + const t = await titles(world.stack, world.tokens.alice); + expect(t).toContain('alice note'); // own + expect(t).toContain('carol note'); // same BU — widened by the fallback profile's readScope + expect(t).toContain('bob note'); // child BU — unit_and_below subtree descent + expect(t).not.toContain('dave note'); // sibling root — still isolated + }); + + it('the child member does NOT roll up into the parent', async () => { + const t = await titles(world.stack, world.tokens.bob); + expect(t.sort()).toEqual(['bob note']); + }); +}); + +describe('open edition — same fallback profile fails CLOSED without the enterprise resolver', () => { + let world: World; + beforeAll(async () => { world = await bootFallbackWorld(false); }, 120_000); + afterAll(async () => { await world?.stack?.stop(); }); + + it('a `unit_and_below` fallback degrades to owner-only — no widening, never fail-open', async () => { + const t = await titles(world.stack, world.tokens.alice); + expect(t).toContain('alice note'); // own still works + expect(t).not.toContain('carol note'); // NO widening without @objectstack/security-enterprise + expect(t).not.toContain('bob note'); + expect(t).not.toContain('dave note'); + }); +}); diff --git a/packages/runtime/src/standalone-stack.test.ts b/packages/runtime/src/standalone-stack.test.ts new file mode 100644 index 0000000000..8d34e4759d --- /dev/null +++ b/packages/runtime/src/standalone-stack.test.ts @@ -0,0 +1,119 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Regression: the artifact-serve path (`objectstack dev`/`serve`/`start` +// booting from `dist/objectstack.json`, no host `objectstack.config.ts`) must +// surface the artifact's app-declared RBAC — `permissions[]` and `roles[]` — at +// the top level of the returned stack config. The CLI reads `config.permissions` +// to honour an app-declared default profile (ADR-0056 D7 — `appDefaultProfileName` +// → SecurityPlugin `fallbackPermissionSet`) and reads `roles[]`/`permissions[]` +// to register app org roles. Before this was fixed, `createStandaloneStack` +// surfaced `objects`/`requires`/`manifest` but dropped `permissions`/`roles`, so +// an `isDefault` profile carrying e.g. `readScope: 'unit_and_below'` was silently +// ignored under `objectstack dev` and every user fell back to the built-in +// owner-only `member_default`. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createStandaloneStack } from './standalone-stack.js'; +import { createDefaultHostConfig } from './default-host.js'; + +// A minimal `objectstack build` artifact carrying an app-declared default +// profile with a hierarchy read scope, an add-on permission set, app roles, +// plus the metadata the path already surfaced (objects/requires/manifest). +const ARTIFACT = { + manifest: { id: 'com.test.scope-app', name: 'Scope App', version: '1.0.0' }, + requires: ['auth'], + objects: [{ name: 'note', label: 'Note', fields: { title: { type: 'text' } } }], + roles: [ + { name: 'manager', label: 'Manager' }, + { name: 'contributor', label: 'Contributor' }, + ], + permissions: [ + { + name: 'app_member_default', + label: 'App Member (Default)', + isProfile: true, + isDefault: true, + objects: { + note: { allowRead: true, allowCreate: true, readScope: 'unit_and_below', writeScope: 'unit' }, + }, + }, + { + name: 'app_contributor', + label: 'Contributor add-on', + isProfile: false, + objects: { note: { allowEdit: true } }, + }, + ], +}; + +// Mirrors `appDefaultProfileName` from @objectstack/plugin-security (not a +// runtime dependency, so the resolution rule is reproduced here): the first +// `isDefault && isProfile !== false` permission set's name. +function appDefaultProfileName(permissions: unknown): string | undefined { + if (!Array.isArray(permissions)) return undefined; + for (const p of permissions) { + if (p && typeof p === 'object') { + const ps = p as { name?: unknown; isProfile?: unknown; isDefault?: unknown }; + if (ps.isDefault === true && ps.isProfile !== false && typeof ps.name === 'string' && ps.name.length > 0) { + return ps.name; + } + } + } + return undefined; +} + +describe('createStandaloneStack — surfaces app RBAC from the artifact (ADR-0056 D7)', () => { + let dir: string; + let artifactPath: string; + + beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'os-standalone-rbac-')); + artifactPath = join(dir, 'objectstack.json'); + writeFileSync(artifactPath, JSON.stringify(ARTIFACT), 'utf-8'); + }); + afterAll(() => { try { rmSync(dir, { recursive: true, force: true }); } catch { /* noop */ } }); + + it('surfaces permissions[] (with isDefault profile + readScope) at the top level', async () => { + const result = await createStandaloneStack({ artifactPath, databaseUrl: 'memory://standalone-rbac' }); + expect(Array.isArray(result.permissions)).toBe(true); + expect(result.permissions!.map((p: any) => p.name).sort()).toEqual(['app_contributor', 'app_member_default']); + const def = result.permissions!.find((p: any) => p.name === 'app_member_default'); + expect(def.isDefault).toBe(true); + // the hierarchy read scope must ride through intact — this is what was lost. + expect(def.objects.note.readScope).toBe('unit_and_below'); + }); + + it('surfaces roles[] at the top level', async () => { + const result = await createStandaloneStack({ artifactPath, databaseUrl: 'memory://standalone-rbac' }); + expect(Array.isArray(result.roles)).toBe(true); + expect(result.roles!.map((r: any) => r.name).sort()).toEqual(['contributor', 'manager']); + }); + + it('still surfaces objects/requires/manifest (no regression)', async () => { + const result = await createStandaloneStack({ artifactPath, databaseUrl: 'memory://standalone-rbac' }); + expect(result.requires).toEqual(['auth']); + expect(result.objects!.map((o: any) => o.name)).toEqual(['note']); + expect(result.manifest?.id).toBe('com.test.scope-app'); + }); + + it('the surfaced config drives appDefaultProfileName → the app profile (the exact CLI wiring)', async () => { + // Reproduce serve.ts: `config = { ...originalConfig, ...standaloneStack }`, + // then `appDefaultProfileName(config.permissions)` → SecurityPlugin fallback. + const standaloneStack = await createStandaloneStack({ artifactPath, databaseUrl: 'memory://standalone-rbac' }); + const config: any = { ...{}, ...standaloneStack }; + expect(appDefaultProfileName(config.permissions)).toBe('app_member_default'); + }); + + it('createDefaultHostConfig (the actual serve artifact-fallback) surfaces the same', async () => { + const result = await createDefaultHostConfig({ + requireArtifact: true, + artifactPath, + databaseUrl: 'memory://standalone-rbac', + }); + expect(appDefaultProfileName(result.permissions)).toBe('app_member_default'); + expect(result.roles!.map((r: any) => r.name).sort()).toEqual(['contributor', 'manager']); + }); +}); diff --git a/packages/runtime/src/standalone-stack.ts b/packages/runtime/src/standalone-stack.ts index d46c1c43e0..9ae1a75a97 100644 --- a/packages/runtime/src/standalone-stack.ts +++ b/packages/runtime/src/standalone-stack.ts @@ -89,6 +89,19 @@ export interface StandaloneStackResult { requires?: string[]; objects?: any[]; manifest?: any; + /** + * App-declared RBAC metadata, surfaced so the CLI (`serve`/`dev`/`start`) + * can wire it without a host `objectstack.config.ts`. In particular the + * `serve` command reads `permissions[]` to honour an app-declared default + * profile (ADR-0056 D7 — `appDefaultProfileName` → SecurityPlugin + * `fallbackPermissionSet`) and reads both `roles[]` and `permissions[]` to + * register application org roles with Better-Auth. Without these the + * artifact-serve path silently fell back to the built-in `member_default` + * (owner-only), so an `isDefault` profile declared purely in app metadata + * was ignored under `objectstack dev`. + */ + permissions?: any[]; + roles?: any[]; } type ResolvedDriverKind = 'memory' | 'postgres' | 'mongodb' | 'sqlite' | 'sqlite-wasm'; @@ -250,6 +263,13 @@ export async function createStandaloneStack(config?: StandaloneStackConfig): Pro const objects: any[] | undefined = Array.isArray(artifactBundle?.objects) ? artifactBundle.objects : undefined; const manifest: any | undefined = artifactBundle?.manifest; + // ADR-0056 D7 — surface app-declared RBAC so the CLI's artifact-serve + // path honours an `isDefault` profile (appDefaultProfileName) and + // registers application org roles, exactly like the config-load path. + const permissions: any[] | undefined = + Array.isArray(artifactBundle?.permissions) ? artifactBundle.permissions : undefined; + const roles: any[] | undefined = + Array.isArray(artifactBundle?.roles) ? artifactBundle.roles : undefined; return { plugins, @@ -260,5 +280,7 @@ export async function createStandaloneStack(config?: StandaloneStackConfig): Pro ...(requires ? { requires } : {}), ...(objects ? { objects } : {}), ...(manifest ? { manifest } : {}), + ...(permissions ? { permissions } : {}), + ...(roles ? { roles } : {}), }; } From 15f98c7706e6ef5eda4c6cfd3c84ea2aca0f2609 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sun, 21 Jun 2026 22:56:43 +0800 Subject: [PATCH 2/3] test(runtime): fix cold-start timeout in standalone-stack RBAC test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first createStandaloneStack call cold-loads heavy deps (objectql/metadata/ driver-memory) via dynamic import, which on a cold CI worker (Node 20) exceeded vitest's default 5s test timeout — failing only the first case. Move the one-time boot into a beforeAll with a 60s timeout and have the assertion cases read the shared result (also removes 4 redundant boots). Co-Authored-By: Claude Opus 4.8 --- packages/runtime/src/standalone-stack.test.ts | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/packages/runtime/src/standalone-stack.test.ts b/packages/runtime/src/standalone-stack.test.ts index 8d34e4759d..1cee081820 100644 --- a/packages/runtime/src/standalone-stack.test.ts +++ b/packages/runtime/src/standalone-stack.test.ts @@ -65,19 +65,26 @@ function appDefaultProfileName(permissions: unknown): string | undefined { return undefined; } +// The first createStandaloneStack call cold-loads heavy deps (objectql, +// metadata, driver-memory) via dynamic import — on a cold CI worker that can +// exceed vitest's default 5s test timeout. Do the one-time boot in beforeAll +// (with a generous timeout) and have the assertion cases read the result. +const BOOT_TIMEOUT = 60_000; + describe('createStandaloneStack — surfaces app RBAC from the artifact (ADR-0056 D7)', () => { let dir: string; let artifactPath: string; + let result: Awaited>; - beforeAll(() => { + beforeAll(async () => { dir = mkdtempSync(join(tmpdir(), 'os-standalone-rbac-')); artifactPath = join(dir, 'objectstack.json'); writeFileSync(artifactPath, JSON.stringify(ARTIFACT), 'utf-8'); - }); + result = await createStandaloneStack({ artifactPath, databaseUrl: 'memory://standalone-rbac' }); + }, BOOT_TIMEOUT); afterAll(() => { try { rmSync(dir, { recursive: true, force: true }); } catch { /* noop */ } }); - it('surfaces permissions[] (with isDefault profile + readScope) at the top level', async () => { - const result = await createStandaloneStack({ artifactPath, databaseUrl: 'memory://standalone-rbac' }); + it('surfaces permissions[] (with isDefault profile + readScope) at the top level', () => { expect(Array.isArray(result.permissions)).toBe(true); expect(result.permissions!.map((p: any) => p.name).sort()).toEqual(['app_contributor', 'app_member_default']); const def = result.permissions!.find((p: any) => p.name === 'app_member_default'); @@ -86,34 +93,31 @@ describe('createStandaloneStack — surfaces app RBAC from the artifact (ADR-005 expect(def.objects.note.readScope).toBe('unit_and_below'); }); - it('surfaces roles[] at the top level', async () => { - const result = await createStandaloneStack({ artifactPath, databaseUrl: 'memory://standalone-rbac' }); + it('surfaces roles[] at the top level', () => { expect(Array.isArray(result.roles)).toBe(true); expect(result.roles!.map((r: any) => r.name).sort()).toEqual(['contributor', 'manager']); }); - it('still surfaces objects/requires/manifest (no regression)', async () => { - const result = await createStandaloneStack({ artifactPath, databaseUrl: 'memory://standalone-rbac' }); + it('still surfaces objects/requires/manifest (no regression)', () => { expect(result.requires).toEqual(['auth']); expect(result.objects!.map((o: any) => o.name)).toEqual(['note']); expect(result.manifest?.id).toBe('com.test.scope-app'); }); - it('the surfaced config drives appDefaultProfileName → the app profile (the exact CLI wiring)', async () => { + it('the surfaced config drives appDefaultProfileName → the app profile (the exact CLI wiring)', () => { // Reproduce serve.ts: `config = { ...originalConfig, ...standaloneStack }`, // then `appDefaultProfileName(config.permissions)` → SecurityPlugin fallback. - const standaloneStack = await createStandaloneStack({ artifactPath, databaseUrl: 'memory://standalone-rbac' }); - const config: any = { ...{}, ...standaloneStack }; + const config: any = { ...{}, ...result }; expect(appDefaultProfileName(config.permissions)).toBe('app_member_default'); }); it('createDefaultHostConfig (the actual serve artifact-fallback) surfaces the same', async () => { - const result = await createDefaultHostConfig({ + const r = await createDefaultHostConfig({ requireArtifact: true, artifactPath, databaseUrl: 'memory://standalone-rbac', }); - expect(appDefaultProfileName(result.permissions)).toBe('app_member_default'); - expect(result.roles!.map((r: any) => r.name).sort()).toEqual(['contributor', 'manager']); - }); + expect(appDefaultProfileName(r.permissions)).toBe('app_member_default'); + expect(r.roles!.map((x: any) => x.name).sort()).toEqual(['contributor', 'manager']); + }, BOOT_TIMEOUT); }); From 9767736cb38b5868e74774de0a955a877967fd07 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sun, 21 Jun 2026 23:11:41 +0800 Subject: [PATCH 3/3] =?UTF-8?q?ci:=20kick=20CI=20(1782054701)=20=E2=80=94?= =?UTF-8?q?=20force=20fresh=20SHA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8