Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/dev-app-default-profile.md
Original file line number Diff line number Diff line change
@@ -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.
176 changes: 176 additions & 0 deletions packages/dogfood/test/showcase-scope-depth-fallback.dogfood.test.ts
Original file line number Diff line number Diff line change
@@ -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<Who, string>; }

// 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<World> {
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<Who, string>;
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<string[]> {
const meId = c.userId as string;
const ids = new Set<string>([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<string>(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<Who, string>;
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<string[]> {
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');
});
});
123 changes: 123 additions & 0 deletions packages/runtime/src/standalone-stack.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// 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;
}

// 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<ReturnType<typeof createStandaloneStack>>;

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', () => {
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', () => {
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)', () => {
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)', () => {
// Reproduce serve.ts: `config = { ...originalConfig, ...standaloneStack }`,
// then `appDefaultProfileName(config.permissions)` → SecurityPlugin fallback.
const config: any = { ...{}, ...result };
expect(appDefaultProfileName(config.permissions)).toBe('app_member_default');
});

it('createDefaultHostConfig (the actual serve artifact-fallback) surfaces the same', async () => {
const r = await createDefaultHostConfig({
requireArtifact: true,
artifactPath,
databaseUrl: 'memory://standalone-rbac',
});
expect(appDefaultProfileName(r.permissions)).toBe('app_member_default');
expect(r.roles!.map((x: any) => x.name).sort()).toEqual(['contributor', 'manager']);
}, BOOT_TIMEOUT);
});
22 changes: 22 additions & 0 deletions packages/runtime/src/standalone-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -260,5 +280,7 @@ export async function createStandaloneStack(config?: StandaloneStackConfig): Pro
...(requires ? { requires } : {}),
...(objects ? { objects } : {}),
...(manifest ? { manifest } : {}),
...(permissions ? { permissions } : {}),
...(roles ? { roles } : {}),
};
}