Skip to content
Merged
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
63 changes: 63 additions & 0 deletions .changeset/verify-bootstack-app-default-permission-set.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
---
"@objectstack/plugin-security": minor
"@objectstack/verify": minor
"@objectstack/cli": patch
---

fix(verify,plugin-security,cli): `bootStack` honours the app-declared default permission set, like `serve` always did (#7001)

Two boot paths disagreed about whether an application's declared default
permission profile exists.

- **`objectstack serve` honoured it** — it read the permission set marked
`isDefault: true` off `config.permissions` and passed the name as the
`SecurityPlugin` `fallbackPermissionSet`.
- **`bootStack` did not** — `@objectstack/verify` constructed a vanilla
`new SecurityPlugin()` and never read `config.permissions` at all.

So the profile an app declares was in force when a human ran the CLI and
silently absent when the app's own suite booted it: a `declared ≠ enforced`
split inside the harness that exists to catch that split. Green tests,
different production behaviour.

It was invisible until #5491. Until then the platform's `member_default`
carried an `object_permissions['*']` wildcard, so a member with no application
profile reached every object anyway and the declared fallback was never
load-bearing. #5491 removed that floor deliberately and its Migration section
prescribes exactly one consumer action — ship an app default profile via
`isDefault: true` — which `bootStack` had no way to express. Measured in
cloud's `ee-group-showcase`, adding the prescribed profile changed nothing: the
same acceptance cases still failed at the object gate.

**What changed.** The resolution now lives in one place and both boot paths call
it: `appSecurityPluginOptions(config)`, new in `@objectstack/plugin-security`
next to the existing `appDefaultPermissionSetName`. It answers the question a
booter actually has — *what do I hand the `SecurityPlugin` constructor for this
config* — rather than just the name, because the second half
(`name ? { fallbackPermissionSet: name } : undefined`) is a decision, not
formatting, and while `serve.ts` had open-coded it, `bootStack` had simply never
grown one. `serve.ts` is converged onto the same helper, so the two now agree by
construction rather than by each caller remembering.

**Behavioural change, `@objectstack/verify` only.** `bootStack(config)` on an
app that declares an `isDefault` permission set now boots with that profile as
the additive per-request baseline (ADR-0090 D5), matching `objectstack dev`. An
app that declares no such set is unaffected — the resolution yields `undefined`
and the plugin keeps deriving `member_default` from its built-in sets, exactly
as before.

A suite that deliberately wants the platform's own baseline over an app that
declares a default now says so: `bootStack(config, { security: new SecurityPlugin() })`.
A plugin passed in `opts.security` still wins whole and is never merged into —
it arrives carrying its own constructor options, and silently rewriting one of
them would be a worse surprise than the bug being fixed.

Measured blast radius across the framework's own suites: of 86 dogfood files and
524 tests, exactly one assertion moved — `me-apps-and-everyone-baseline`, which
asserts the bootstrap binds `member_default` to the `everyone` anchor and whose
header already read "Deliberately VANILLA". That dependence was real but silent,
expressed only by the harness default; it is now stated in the argument. The
showcase fixtures that needed the app profile were already hand-wiring a
`SecurityPlugin` for it (`test/showcase-security.ts`, added by #5491) — the
"custom security code" these dogfood apps exist to prove unnecessary — and are
unchanged by this release.
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// `objectstack serve` ↔ `@objectstack/verify`'s `bootStack`: the two boot paths
// must construct their `SecurityPlugin` from the SAME resolution — #7001.
//
// The defect this file mechanises: two boot paths disagreed about whether an
// application's declared default permission profile exists.
//
// • `serve.ts` read `appDefaultPermissionSetName(config.permissions)` and
// passed it as `fallbackPermissionSet`.
// • `bootStack` constructed a vanilla `new SecurityPlugin()` and never read
// `config.permissions` at all.
//
// So the profile an app declared was in force when a human ran the CLI and
// silently absent when the app's own dogfood suite booted it — a
// `declared ≠ enforced` split inside the harness that exists to catch that
// split. Green tests, different production behaviour. It stayed invisible until
// #5491 removed `member_default`'s `'*'` wildcard, because until then the floor
// underneath granted everything anyway and the fallback was never load-bearing.
//
// The runtime halves are pinned where they run: the harness's wiring and its
// behavioural consequence in `packages/verify/src/harness.app-default-profile.test.ts`,
// the helper's own contract in
// `packages/plugins/plugin-security/src/app-default-permission-set.test.ts`.
// Neither can see THIS file's failure mode, which is the one that actually
// happened: nothing in the repo pins `serve.ts`'s side, so re-open-coding the
// wiring here — or dropping it — would be green everywhere while the paths
// separate again. Hence a source scan, in the shape of this package's
// `serve-email-config-parity.contract.test.ts`: the grep that would have caught
// it, mechanised, so the second divergence fails a build instead of waiting for
// someone to run it.
//
// It is deliberately a scan of BOTH files rather than an assertion about one.
// A one-sided pin is satisfiable by editing the other side, which is exactly
// how two mirrored literals drift.

import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { appSecurityPluginOptions, appDefaultPermissionSetName } from '@objectstack/plugin-security';

const HERE = path.dirname(fileURLToPath(import.meta.url));

/**
* `packages/cli/src/commands/` → `packages/`. The sibling read is what makes
* this a PARITY assertion instead of a single-file lint; `@objectstack/verify`
* is a real dependency of this package, and the comparison is test-only, so no
* runtime edge is added. Tests never ship (`files: ["dist"]`).
*/
const PACKAGES_DIR = path.resolve(HERE, '../../..');

/**
* Absence must be loud (AGENTS.md, Route & surface ownership §3). A scan that
* silently reports success because it could not find the file it scans is worse
* than no scan — it is this very gate's failure mode, one level up.
*/
function readBootPath(relative: string): string {
const full = path.join(PACKAGES_DIR, relative);
try {
return readFileSync(full, 'utf8');
} catch (e) {
throw new Error(
`serve↔verify parity scan cannot read its subject '${relative}' (looked at ${full}). ` +
'The file moved or was renamed — repoint this scan; do NOT delete it, the two boot ' +
`paths still have to agree. (${(e as Error).message})`,
);
}
}

/**
* Comments stripped, because this scan is about what the two files DO.
*
* Both boot sites are heavily commented — with the very construction shapes
* being asserted about, since each explains what it replaced — so a scan over
* raw text measures the prose and reports on it. (It did: the first run of this
* file counted six constructions where the code has two.) Worse, the
* comment-inclusive form would forbid the next author from ever *describing*
* the old wiring, which is the opposite of what these files need.
*
* Approximate by design, and safe here: the result feeds nothing but the
* `new SecurityPlugin(...)` regex below, so a `//` mangled out of a string
* literal (`'http://localhost:3000'` in `harness.ts`) cannot affect a verdict.
* Do not reuse this for anything that reads string contents.
*/
function stripComments(source: string): string {
return source.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:])\/\/[^\n]*/g, '$1');
}

const BOOT_PATHS: Array<{ label: string; relative: string; source: string }> = [
{ label: 'objectstack serve', relative: 'cli/src/commands/serve.ts' },
{ label: 'verify bootStack', relative: 'verify/src/harness.ts' },
].map((p) => ({ ...p, source: stripComments(readBootPath(p.relative)) }));

/**
* Every `new SecurityPlugin(...)` construction in a file, with its argument.
*
* Walks parentheses rather than matching `\(([^)]*)\)` — the argument is itself
* a call (`appSecurityPluginOptions(config)`), so a non-nesting match stops at
* the INNER `)` and silently reports `appSecurityPluginOptions(config`. That
* truncation compares equal across both files, so the naive form would have
* passed while measuring something that is not the argument.
*/
function securityPluginConstructions(source: string): string[] {
const NEW = 'new SecurityPlugin(';
const found: string[] = [];
for (let i = source.indexOf(NEW); i !== -1; i = source.indexOf(NEW, i + 1)) {
let depth = 1;
let j = i + NEW.length;
for (; j < source.length && depth > 0; j++) {
if (source[j] === '(') depth++;
else if (source[j] === ')') depth--;
}
if (depth !== 0) throw new Error(`unbalanced \`${NEW}…\` at offset ${i} — the scan cannot read this file`);
found.push(source.slice(i + NEW.length, j - 1).trim());
}
return found;
}

describe('serve ↔ bootStack construct SecurityPlugin from one resolution (#7001)', () => {
// Asserting on the CONSTRUCTION rather than on the file's text, because the
// text form does not go red on the defect. Reverting `harness.ts` to its
// pre-#7001 `new SecurityPlugin()` left the `appSecurityPluginOptions` import
// standing, so a `expect(source).toContain('appSecurityPluginOptions')` check
// stayed GREEN over a boot path that had stopped calling it — a mention is
// not a call. Measured, not reasoned: that ablation was run, and this is the
// shape that failed correctly.
it.each(BOOT_PATHS)('$label constructs SecurityPlugin exactly once, via the helper', ({ source }) => {
expect(securityPluginConstructions(source)).toEqual(['appSecurityPluginOptions(config)']);
});

it.each(BOOT_PATHS)('$label does not re-open-code the resolution', ({ source }) => {
// The open-coded shape #7001 replaced, in either spelling. Reaching for the
// NAME helper at a boot site means rebuilding `name ? {...} : undefined` by
// hand — the half that was a decision, not formatting, and the half the
// other path never grew.
expect(source).not.toContain('appDefaultPermissionSetName');
expect(source).not.toMatch(/fallbackPermissionSet\s*:/);
});

it('and the two paths agree with EACH OTHER, not merely with a literal', () => {
// The parity claim proper. The per-path assertions above both compare to
// the same hard-coded string, which a single careless edit could "fix" on
// both sides at once; this one compares the paths to one another, so
// divergence is red however the argument is spelled.
const [serve, verify] = BOOT_PATHS.map(({ source }) => securityPluginConstructions(source));
// A path that stopped constructing one at all would otherwise satisfy any
// "the two agree" claim over two empty sets.
expect(serve.length, 'serve constructs a SecurityPlugin').toBeGreaterThan(0);
expect(verify).toEqual(serve);
});
});

describe('the shared resolution, exercised (#7001)', () => {
// The scan above proves both paths call one helper; these prove the helper
// they call answers correctly. Neither claim implies the other, and a source
// scan alone would be green over a helper that returned nonsense.
const declared = {
permissions: [
{ name: 'ignored_not_default', isDefault: false },
{ name: 'app_member_default', isDefault: true },
],
};

it('carries an app-declared isDefault profile into the constructor options', () => {
expect(appSecurityPluginOptions(declared)).toEqual({ fallbackPermissionSet: 'app_member_default' });
expect(appDefaultPermissionSetName(declared.permissions)).toBe('app_member_default');
});

it('yields undefined when nothing is declared, so the plugin keeps its own derivation', () => {
// Deliberately NOT `{ fallbackPermissionSet: undefined }`: the constructor
// reads an explicit `undefined` as "derive from the built-in sets" only
// because the KEY is absent — `fallbackPermissionSet: null` means "no
// baseline at all". Passing the object shape would work today and is one
// refactor away from silently disabling the platform baseline.
expect(appSecurityPluginOptions({ permissions: [{ name: 'plain' }] })).toBeUndefined();
expect(appSecurityPluginOptions({})).toBeUndefined();
expect(appSecurityPluginOptions(undefined)).toBeUndefined();
});
});
22 changes: 15 additions & 7 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2003,14 +2003,22 @@ export default class Serve extends Command {
// Pair: SecurityPlugin (RBAC) — optional
try {
const securityPkg = '@objectstack/plugin-security';
const { SecurityPlugin, appDefaultPermissionSetName } = await import(/* webpackIgnore: true */ securityPkg);
const { SecurityPlugin, appSecurityPluginOptions } = await import(/* webpackIgnore: true */ securityPkg);
// ADR-0056 D7 — honor an app-declared default profile. A stack
// permission set marked `isDefault` becomes the
// fallback for users with no explicit grants. The SecurityPlugin's
// own scan only sees its built-in sets, so the CLI passes the
// declared name through explicitly (undefined → built-in default).
const appDefaultProfile = appDefaultPermissionSetName((config as any)?.permissions);
await kernel.use(new SecurityPlugin(appDefaultProfile ? { fallbackPermissionSet: appDefaultProfile } : undefined));
// permission set marked `isDefault` becomes the baseline for
// users with no explicit grants. The SecurityPlugin's own scan
// only sees its built-in sets, so the declared name is passed
// through explicitly (undefined → built-in default).
//
// [#7001] Resolved through the SHARED helper rather than
// open-coded here. This was the only boot path that did it at
// all: `@objectstack/verify`'s `bootStack` constructed a vanilla
// `new SecurityPlugin()`, so an app's own dogfood suite ran
// against a boot without the profile the CLI gave its users. The
// two now agree by construction — one helper, one call shape, and
// `serve-verify-security-parity.contract.test.ts` fails if either
// side open-codes its way back out.
await kernel.use(new SecurityPlugin(appSecurityPluginOptions(config)));
trackPlugin('Security');
} catch {
// optional
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
import { describe, it, expect } from 'vitest';
import { appDefaultPermissionSetName } from './app-default-permission-set';
import { appDefaultPermissionSetName, appSecurityPluginOptions } from './app-default-permission-set';
import { SecurityPlugin } from './security-plugin';

describe('appDefaultPermissionSetName (ADR-0090 D5)', () => {
it('returns the name of the first isDefault permission set', () => {
Expand All @@ -25,3 +26,77 @@ describe('appDefaultPermissionSetName (ADR-0090 D5)', () => {
).toBe('ok');
});
});

/**
* [#7001] `appSecurityPluginOptions` — the whole constructor argument, so every
* boot path spells the wiring once.
*
* `appDefaultPermissionSetName` above answers "which profile did the app
* declare". That left the second half — turning a name into constructor options
* — open-coded at each call site, and only ONE site ever had it: `objectstack
* serve`. `@objectstack/verify`'s `bootStack` built a vanilla
* `new SecurityPlugin()`, so an app's own suite ran against a boot without the
* profile the CLI gave its users.
*/
describe('appSecurityPluginOptions (#7001)', () => {
it('reads the declared default off a stack config', () => {
expect(
appSecurityPluginOptions({
permissions: [{ name: 'read_only' }, { name: 'app_member_default', isDefault: true }],
}),
).toEqual({ fallbackPermissionSet: 'app_member_default' });
});

it('returns undefined — NOT { fallbackPermissionSet: undefined } — when nothing is declared', () => {
// The distinction is load-bearing, not stylistic. The constructor reads an
// ABSENT key as "derive my own default from the built-in sets" and an
// explicit `null` as "no baseline at all"; returning the object shape works
// today only because `undefined` happens to hit the same branch, and is one
// refactor away from silently disabling the platform baseline.
for (const config of [{ permissions: [{ name: 'plain' }] }, { permissions: [] }, {}, null, undefined, 'nonsense']) {
expect(appSecurityPluginOptions(config)).toBeUndefined();
}
});

it('reads `permissions` top-level, exactly where serve.ts has always read it', () => {
// Being cleverer here (also looking inside `manifest`) would re-open the
// #7001 gap in the other direction: the harness would honour a declaration
// the CLI ignores, and a suite would again prove something production does
// not do.
expect(appSecurityPluginOptions({ manifest: { permissions: [{ name: 'buried', isDefault: true }] } }))
.toBeUndefined();
});
});

/**
* The options do not merely describe the wiring — they land on the plugin. A
* fake `PluginContext` captures what `init()` publishes as the
* `security.fallbackPermissionSet` service, which is the value the runtime
* resolves every authenticated request's additive baseline from (ADR-0090 D5).
*/
describe('the resolved options reach the constructed plugin (#7001)', () => {
const initAndReadBaseline = async (plugin: SecurityPlugin): Promise<unknown> => {
const services = new Map<string, unknown>();
await plugin.init({
logger: { info() {}, warn() {}, error() {}, debug() {} },
registerService: (name: string, value: unknown) => services.set(name, value),
getService: (name: string) => {
if (name === 'manifest') return { register() {} };
return undefined;
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
return services.get('security.fallbackPermissionSet');
};

it('an app-declared default becomes the plugin baseline', async () => {
const config = { permissions: [{ name: 'app_member_default', isDefault: true }] };
await expect(initAndReadBaseline(new SecurityPlugin(appSecurityPluginOptions(config))))
.resolves.toBe('app_member_default');
});

it('and no declaration leaves the built-in member_default standing', async () => {
await expect(initAndReadBaseline(new SecurityPlugin(appSecurityPluginOptions({}))))
.resolves.toBe('member_default');
});
});
Loading
Loading