Skip to content

Commit f721ef0

Browse files
os-justinclaude
andauthored
fix(cli): the boot banner’s 🔑 Dev admin says what that account will and will not see (#17555)
* fix(cli): the boot banner's Dev admin line says what that account will and will not see The seeded dev admin is the only credential a first-run operator is given, and it is by construction the account with every platform capability and no app-declared one. In an app that gates navigation on requiredPermissions it resolves to an empty menu, and the banner said nothing about that. Three dim continuation lines under the existing credential row name what the account reaches (Setup, Studio, every record), the mechanism that hides an app's navigation from it, and two routes to a scoped account. The seed itself is untouched; a boot that seeds nothing renders byte-identically. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DapQyvYrFb1MxSYe7BL2nt * test(cli): narrow the Setup nav-entry read so it typechecks against the item union `NavigationItemInput` includes a separator variant with no `label`, so reading `.label` off the found entry needed a narrowing cast rather than the union. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DapQyvYrFb1MxSYe7BL2nt --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 66cbfa6 commit f721ef0

3 files changed

Lines changed: 279 additions & 0 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): the boot banner's `🔑 Dev admin` says what that account will and will not see (#17081)
6+
7+
`--seed-admin` (on by default in `os dev`) prints one credential, and it is the
8+
**only** one a first-run operator is given. It is also, by construction, the
9+
account with every *platform* capability and no *app-declared* one: its standing
10+
is `admin_full_access`, whose `systemPermissions` are `setup.access`,
11+
`studio.access`, `manage_users`, `manage_metadata`, `manage_platform_settings`
12+
and `manage_sharing` — all platform built-ins — plus the `'*'`
13+
view-all/modify-all record bits.
14+
15+
So in any app that gates its apps, tabs or nav entries on
16+
`requiredPermissions` — the filter `/me/apps` and `/meta/app` apply, and a
17+
first-class platform feature the docs teach — the credential the terminal hands
18+
over is the account that resolves to an **empty navigation**. A downstream
19+
maintainer ran `pnpm dev`, signed in with it, and read the empty shell as a
20+
broken product. The app was correct. The banner had asserted a login and said
21+
nothing about its audience, and it outranks whatever the app's own README says,
22+
because it sits directly under the command that was just run.
23+
24+
FROM → TO, on a boot that seeds:
25+
26+
```
27+
🔑 Dev admin: admin@objectos.ai / admin123
28+
seeded on empty DB · dev only — do not use in production
29+
+ platform admin — Setup, Studio and every record, but NO app-declared capability, so
30+
+ an app that gates navigation on requiredPermissions may show it an empty menu; grant
31+
+ it a permission set under Setup → Users, or sign in as an account your app seeds
32+
```
33+
34+
**Nothing about the seed changes.** What the first run creates — the account,
35+
its address, its password, its promotion to platform admin — is a product-shape
36+
decision and is untouched; only the banner's words move. The three lines print
37+
only inside the branch that already prints the credential, so a boot that seeds
38+
nothing is byte-identical to before.
39+
40+
Dim continuation lines rather than a warning, deliberately: ADR-0115's
41+
`OS_ALLOW_DEV_PLUGIN` amendment excluded the dev-admin seed from that hazard set
42+
because "a warning about a non-event spends the attention the real ones need".
43+
That exclusion is kept — this qualifies an event that just happened, on the line
44+
that already announces it, and adds no new line where there was none.
45+
46+
The route the sentence names is asserted against the declarations that make it
47+
reachable, not re-spelled: `SETUP_APP.requiredPermissions` is a subset of what
48+
this account holds, the `Users` entry is ungated, and the `sys_user` detail page
49+
carries the "Grant permission set" related list. A rename on any of those reds
50+
the pin instead of leaving the banner pointing at nothing.
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, expect, it, vi } from 'vitest';
4+
import { ADMIN_FULL_ACCESS_CAPABILITIES } from '@objectstack/spec/identity';
5+
import { PLATFORM_CAPABILITY_NAMES } from '@objectstack/spec/security';
6+
import { SETUP_APP, SETUP_NAV_CONTRIBUTIONS } from '@objectstack/platform-objects/apps';
7+
import { SysUserDetailPage } from '@objectstack/platform-objects/pages';
8+
import { printServerReady, type ServerReadyOptions } from './format.js';
9+
10+
/**
11+
* #17081 — the `🔑 Dev admin` line must say what that account SEES.
12+
*
13+
* ## The defect this pins shut
14+
*
15+
* `--seed-admin` (on by default in `os dev`) prints one credential, and it is
16+
* the ONLY one a first-run operator is given. It is also, by construction, the
17+
* account with every PLATFORM capability and no APP-declared one. In an app
18+
* that gates its apps/tabs/nav on `requiredPermissions` — a first-class
19+
* platform feature — that account resolves to an EMPTY navigation. A downstream
20+
* maintainer ran `pnpm dev`, signed in with the printed credential, and read the
21+
* empty shell as a broken product: 「我刚管理员登录进去,看不到 ATS 的左侧菜单」.
22+
* The app was correct; the banner had pointed them at the one account that sees
23+
* nothing, and said nothing about it.
24+
*
25+
* ## Why a wording pin is the right instrument here, unusually
26+
*
27+
* The repo's default is ⛔ don't pin prose. This file pins it because the words
28+
* ARE the deliverable: the change adds no branch, no key and no behaviour — the
29+
* only thing that can regress is the sentence. `DEV_ADMIN_BLOCK` below is
30+
* transcribed from a real render (see the PR body's before/after), not
31+
* regenerated from the implementation.
32+
*
33+
* ## The leg that stops the fix from becoming the defect
34+
*
35+
* ADR-0115's `:93` amendment is about a guard that branded a state where the
36+
* operator could not act on it. A banner that confidently names a route that
37+
* does not exist is the same defect wearing the fix's clothes. So the route the
38+
* sentence names — `Setup → Users`, then grant a permission set — is asserted
39+
* against the DECLARATIONS that make it reachable, from the packages that own
40+
* them, rather than re-spelled here:
41+
*
42+
* 1. `SETUP_APP.requiredPermissions` ⊆ the capabilities this very account
43+
* holds — so the account we hand over can open the place we send it;
44+
* 2. the `Users` entry exists in the Setup nav and is itself ungated;
45+
* 3. the `sys_user` detail page carries the grant surface the sentence
46+
* promises.
47+
*
48+
* Rename any of those and this file reddens, instead of the banner quietly
49+
* starting to lie.
50+
*/
51+
52+
/** Strip SGR so assertions hold whether or not chalk colours this run. */
53+
const SGR = new RegExp(String.fromCharCode(27) + '\\[[0-9;]*m', 'g');
54+
55+
function render(opts: Partial<ServerReadyOptions>): string[] {
56+
const lines: string[] = [];
57+
const spy = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
58+
lines.push(args.join(' ').replace(SGR, ''));
59+
});
60+
try {
61+
printServerReady({
62+
externalBaseOrigin: 'http://localhost:4721',
63+
uiEnabled: true,
64+
consolePath: '/_console',
65+
isDev: true,
66+
pluginCount: 12,
67+
...opts,
68+
} as ServerReadyOptions);
69+
} finally {
70+
spy.mockRestore();
71+
}
72+
return lines;
73+
}
74+
75+
const SEEDED = { email: 'admin@objectos.ai', password: 'admin123' };
76+
77+
/**
78+
* The credential block, verbatim, as a real render emits it under NO_COLOR.
79+
*
80+
* ⛔ Do not regenerate this from `format.ts`. The first three entries are
81+
* byte-identical to what shipped before #17081 — the change APPENDS, it does
82+
* not restate — and that identity is asserted separately below.
83+
*/
84+
const DEV_ADMIN_BLOCK = [
85+
'',
86+
' 🔑 Dev admin: admin@objectos.ai / admin123',
87+
' seeded on empty DB · dev only — do not use in production',
88+
' platform admin — Setup, Studio and every record, but NO app-declared capability, so',
89+
' an app that gates navigation on requiredPermissions may show it an empty menu; grant',
90+
' it a permission set under Setup → Users, or sign in as an account your app seeds',
91+
];
92+
93+
describe('#17081 — the dev-admin banner says what the account will and will not see', () => {
94+
it('renders the credential block verbatim, audience sentence included', () => {
95+
const lines = render({ seededAdmin: SEEDED });
96+
const start = lines.findIndex((l) => l.includes('Dev admin:'));
97+
expect(start).toBeGreaterThan(-1);
98+
expect(lines.slice(start - 1, start - 1 + DEV_ADMIN_BLOCK.length)).toEqual(DEV_ADMIN_BLOCK);
99+
});
100+
101+
it('says what the account WILL see, what it will NOT, and where to get a scoped one', () => {
102+
const block = render({ seededAdmin: SEEDED }).join('\n');
103+
// WILL: the platform surfaces `admin_full_access` actually carries.
104+
expect(block).toContain('Setup, Studio and every record');
105+
// WILL NOT: the mechanism, named by the field an app author writes.
106+
expect(block).toContain('NO app-declared capability');
107+
expect(block).toContain('requiredPermissions');
108+
expect(block).toContain('empty menu');
109+
// The two routes out.
110+
expect(block).toContain('permission set under Setup → Users');
111+
expect(block).toContain('an account your app seeds');
112+
});
113+
114+
it('appends only — the three pre-existing credential lines are unmoved', () => {
115+
const lines = render({ seededAdmin: SEEDED });
116+
const start = lines.findIndex((l) => l.includes('Dev admin:'));
117+
expect(lines.slice(start - 1, start + 2)).toEqual(DEV_ADMIN_BLOCK.slice(0, 3));
118+
});
119+
120+
it('is silent when nothing was seeded — the qualification belongs to the event', () => {
121+
// ADR-0115 `:93`: "a warning about a non-event spends the attention the
122+
// real ones need". No seed ⇒ no credential ⇒ nothing to qualify.
123+
const lines = render({});
124+
expect(lines.some((l) => l.includes('Dev admin:'))).toBe(false);
125+
expect(lines.some((l) => l.includes('app-declared capability'))).toBe(false);
126+
expect(lines.some((l) => l.includes('Setup → Users'))).toBe(false);
127+
});
128+
});
129+
130+
describe('#17081 — the claim and the route are the platform\'s own declarations', () => {
131+
const held = new Set<string>(ADMIN_FULL_ACCESS_CAPABILITIES.systemPermissions ?? []);
132+
133+
it('"Setup, Studio" is what the seeded admin\'s permission set actually grants', () => {
134+
// Positive control first: the set is non-empty, so the membership
135+
// assertions below are reading something.
136+
expect(held.size).toBeGreaterThan(0);
137+
expect(held.has('setup.access')).toBe(true);
138+
expect(held.has('studio.access')).toBe(true);
139+
// "every record" — the `'*'` super-user bits on the same declaration.
140+
expect(ADMIN_FULL_ACCESS_CAPABILITIES.objects?.['*']?.viewAllRecords).toBe(true);
141+
expect(ADMIN_FULL_ACCESS_CAPABILITIES.objects?.['*']?.modifyAllRecords).toBe(true);
142+
});
143+
144+
it('"NO app-declared capability" — every capability it holds is a platform built-in', () => {
145+
const notPlatform = [...held].filter((name) => !PLATFORM_CAPABILITY_NAMES.has(name));
146+
expect(notPlatform).toEqual([]);
147+
// Fabricated control: an app-declared capability is NOT a platform
148+
// built-in, so the filter above is capable of returning a name.
149+
expect(PLATFORM_CAPABILITY_NAMES.has('someapp.export_data')).toBe(false);
150+
});
151+
152+
it('the route is reachable BY THIS ACCOUNT — Setup gates on a capability it holds', () => {
153+
expect(SETUP_APP.name).toBe('setup');
154+
const gate = SETUP_APP.requiredPermissions ?? [];
155+
expect(gate.length).toBeGreaterThan(0);
156+
expect(gate.filter((p) => !held.has(p))).toEqual([]);
157+
});
158+
159+
it('"→ Users" is a real, ungated Setup entry on the object that carries the grant', () => {
160+
const entries = SETUP_NAV_CONTRIBUTIONS
161+
.filter((c) => c.app === 'setup')
162+
.flatMap((c) => c.items ?? []);
163+
const users = entries.find((i) => (i as { objectName?: string }).objectName === 'sys_user') as
164+
| { label?: unknown; requiredPermissions?: string[] }
165+
| undefined;
166+
expect(users).toBeDefined();
167+
expect(users!.label).toBe('Users');
168+
// Ungated: an operator holding only `setup.access` still sees the entry.
169+
expect(users!.requiredPermissions ?? []).toEqual([]);
170+
// …and the group anchor it lands in is ungated too.
171+
const group = (SETUP_APP.navigation ?? []).find((g) => g.id === 'group_people_org');
172+
expect(group).toBeDefined();
173+
expect((group as { requiredPermissions?: string[] }).requiredPermissions ?? []).toEqual([]);
174+
});
175+
176+
it('"grant it a permission set" is a surface the sys_user page really offers', () => {
177+
const page = JSON.stringify(SysUserDetailPage);
178+
expect(page).toContain('sys_user_permission_set');
179+
expect(page).toContain('Grant permission set');
180+
// Fabricated control: the same haystack does not answer to an invented name.
181+
expect(page).not.toContain('Grant fabricated permission set');
182+
});
183+
});

packages/cli/src/utils/format.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -686,6 +686,12 @@ export interface ServerReadyOptions {
686686
* Credentials of the dev admin seeded on an empty DB this boot (dev only).
687687
* When present, the banner surfaces them so backend debugging never has to
688688
* guess the login. Absent when nothing was seeded.
689+
*
690+
* [#17081] The banner also says what this account SEES, because it is the
691+
* only credential a first-run operator is given and it holds no app-declared
692+
* capability — in an app that gates navigation on `requiredPermissions` it is
693+
* the account that renders an empty menu. See the render site in
694+
* {@link printServerReady} for the wording and the restraints on it.
689695
*/
690696
seededAdmin?: { email: string; password: string };
691697
/**
@@ -905,6 +911,46 @@ export function printServerReady(opts: ServerReadyOptions) {
905911
chalk.bold.green(`${opts.seededAdmin.email} / ${opts.seededAdmin.password}`),
906912
);
907913
console.error(chalk.dim(' seeded on empty DB · dev only — do not use in production'));
914+
// [#17081] Say what this account SEES. It is the only credential a
915+
// first-run operator is handed, and the banner used to stop at the line
916+
// above — which asserts a login and says nothing about its audience. The
917+
// account's standing is `admin_full_access`
918+
// (`ADMIN_FULL_ACCESS_CAPABILITIES`, `@objectstack/spec/identity`): every
919+
// PLATFORM capability (`setup.access`, `studio.access`, …) plus the `'*'`
920+
// view-all/modify-all record bits — and NO app-declared capability, because
921+
// a capability an app declares is the app's to grant. So in any app that
922+
// gates its apps/tabs/nav on `requiredPermissions` (the `/me/apps` and
923+
// `/meta/app` filters), this is by construction the account that resolves
924+
// to an empty menu. Measured downstream on `objectstack-ai/ats`: of five
925+
// personas, the four the app seeds each render their group and the one the
926+
// banner prints renders none — and the operator read the empty shell as a
927+
// broken product rather than as a scoped account.
928+
//
929+
// ⚠️ Three deliberate restraints, each ADR-0115's `:93` amendment applied
930+
// here rather than routed around:
931+
// • DIM, not a warning. That paragraph excluded the dev-admin seed from
932+
// the `OS_ALLOW_DEV_PLUGIN` hazard set because "a warning about a
933+
// non-event spends the attention the real ones need". The exclusion is
934+
// KEPT, not overturned: these lines print only inside
935+
// `if (opts.seededAdmin)` — i.e. only when the seed actually fired and
936+
// the operator is holding the credential, so the subject is an event,
937+
// not a non-event — and they add no new line where there was none,
938+
// they finish a line already printed. A yellow `⚠` here would spend
939+
// precisely the attention that paragraph is protecting.
940+
// • It names a REACHABLE route. The same paragraph's rule is that a
941+
// degraded state must be branded "where an operator looks"; a route
942+
// that 404s would be that defect wearing the fix's clothes.
943+
// `Setup → Users` is `SETUP_APP` (`requiredPermissions:
944+
// ['setup.access']`, which this account holds) → the `nav_users`
945+
// contribution (`sys_user`, ungated), whose detail page carries the
946+
// "Grant permission set" related list. Pinned against those
947+
// declarations in `format.server-ready-dev-admin-audience.test.ts`, so
948+
// a rename there cannot leave this sentence pointing at nothing.
949+
// • It changes no seed. What the first run CREATES is a product-shape
950+
// decision and stays exactly as it was; only the banner's words move.
951+
console.error(chalk.dim(' platform admin — Setup, Studio and every record, but NO app-declared capability, so'));
952+
console.error(chalk.dim(' an app that gates navigation on requiredPermissions may show it an empty menu; grant'));
953+
console.error(chalk.dim(' it a permission set under Setup → Users, or sign in as an account your app seeds'));
908954
}
909955
console.error('');
910956
// #8978 — name what actually booted, never a file that was not read.

0 commit comments

Comments
 (0)