Skip to content

Commit 61d1119

Browse files
committed
wip(cli): render app-contributed first-run credentials in the boot banner (#17556)
Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6714690 commit 61d1119

4 files changed

Lines changed: 526 additions & 0 deletions

File tree

‎packages/cli/src/commands/serve.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5222,6 +5222,14 @@ export default class Serve extends Command {
52225222
// owns that refusal now, and this row just reports what it decided.
52235223
tenancyPosture,
52245224
seededAdmin,
5225+
// #17556 — read straight off the definition this process booted, the
5226+
// same object `config.devPlugins` is read from twenty lines up. The
5227+
// platform describes the account it seeded; only the APPLICATION knows
5228+
// which of its audiences shows something, and these two keys are how it
5229+
// says so. `printServerReady` gates them on `isDev` and scrubs them —
5230+
// they are author-controlled text reaching a terminal.
5231+
devLogins: Array.isArray((config as any)?.devLogins) ? (config as any).devLogins : undefined,
5232+
devHint: typeof (config as any)?.devHint === 'string' ? (config as any).devHint : undefined,
52255233
automation: automationSummary,
52265234
seeds: seedSummary,
52275235
// #17329 — read HERE, inside the banner thunk, so it is the tally as of
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #17556 — the boot banner prints the first-run credentials the APPLICATION
5+
* contributes (`devLogins` / `devHint`), beneath the one the platform seeded.
6+
*
7+
* ## The half `packages/cli` could not write for itself
8+
*
9+
* #17081 closed the platform's own half: the `🔑 Dev admin` line now says what
10+
* that account will and will not see. What it could not do is name an account
11+
* that DOES see something, because the platform does not know an application's
12+
* audiences — measured downstream, four of five personas rendered their group
13+
* and the one the banner printed rendered none. `@objectstack/spec` now
14+
* declares the channel; this file pins what the banner does with it.
15+
*
16+
* ## Four properties, and why each is a pin rather than a preference
17+
*
18+
* 1. **ADDITIVE.** The seeded-admin block still renders, unchanged, above.
19+
* An application-controlled key able to suppress a platform disclosure
20+
* would let an app hide a live credential the operator was just handed.
21+
* 2. **DEV ONLY.** A non-development boot renders byte-identically to one
22+
* declaring nothing — ADR-0115's attention budget is untouched on the path
23+
* that paragraph was written about.
24+
* 3. **SCRUBBED.** These strings are author-controlled and reach a TTY, where
25+
* control bytes are instructions: an escape sequence could erase the rows
26+
* above or repaint a forged `🔑 Dev admin` row, making the platform's own
27+
* banner lie on the application's behalf. Every C0/C1 byte becomes U+FFFD,
28+
* so an entry occupies exactly the rows it was given.
29+
* 4. **DECLARING IS NOT SEEDING.** The wording says the application declared
30+
* these, because that is all a declaration does — nothing creates an
31+
* account. An entry naming an unseeded account must read as the app's
32+
* claim, not as a platform credential that broke.
33+
*
34+
* The exact sentences are pinned for the same reason #17081's are: the
35+
* deliverable here is words on a terminal, so the wording is the only thing
36+
* that can regress.
37+
*/
38+
39+
import { describe, expect, it, vi } from 'vitest';
40+
import { printServerReady, type ServerReadyOptions } from './format.js';
41+
42+
/** Strip SGR so assertions hold whether or not chalk colours this run. */
43+
const SGR = new RegExp(String.fromCharCode(27) + '\\[[0-9;]*m', 'g');
44+
45+
function render(opts: Partial<ServerReadyOptions>): string[] {
46+
const lines: string[] = [];
47+
const spy = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
48+
lines.push(args.join(' ').replace(SGR, ''));
49+
});
50+
try {
51+
printServerReady({
52+
externalBaseOrigin: 'http://localhost:4721',
53+
uiEnabled: true,
54+
consolePath: '/_console',
55+
isDev: true,
56+
pluginCount: 12,
57+
...opts,
58+
} as ServerReadyOptions);
59+
} finally {
60+
spy.mockRestore();
61+
}
62+
return lines;
63+
}
64+
65+
const SEEDED = { email: 'admin@objectos.ai', password: 'admin123' };
66+
67+
const LOGINS = [
68+
{ label: 'Hiring admin', email: 'admin@quillstone.example', password: 'demo1234' },
69+
{ label: 'Job seeker', email: 'candidate01@mail.example', password: 'demo1234' },
70+
];
71+
72+
/** The application block, verbatim, as a real render emits it under NO_COLOR. */
73+
const APP_LOGIN_BLOCK = [
74+
'',
75+
' 👥 App logins: 2 declared by this app',
76+
' Hiring admin — admin@quillstone.example / demo1234',
77+
' Job seeker — candidate01@mail.example / demo1234',
78+
' declared in this app\'s `devLogins` · dev only — the platform seeded none of them',
79+
];
80+
81+
const blockOf = (lines: string[]): string[] => {
82+
const start = lines.findIndex((l) => l.includes('App logins:'));
83+
return start < 0 ? [] : lines.slice(start - 1, start - 1 + APP_LOGIN_BLOCK.length);
84+
};
85+
86+
// ─── 1. What an operator sees ───────────────────────────────────────
87+
88+
describe('#17556 — the application\'s own credentials reach the banner', () => {
89+
it('renders the block verbatim, one row per declared account', () => {
90+
expect(blockOf(render({ seededAdmin: SEEDED, devLogins: LOGINS }))).toEqual(APP_LOGIN_BLOCK);
91+
});
92+
93+
it('prints the address alone when the entry declares no password', () => {
94+
const lines = render({ devLogins: [{ label: 'SSO admin', email: 'sso@quillstone.example' }] });
95+
expect(lines).toContain(' SSO admin — sso@quillstone.example');
96+
// …and does not invent one: no separator where a password would sit.
97+
expect(lines.some((l) => l.includes('sso@quillstone.example /'))).toBe(false);
98+
});
99+
100+
it('prints an unlabelled entry as the credential alone', () => {
101+
expect(render({ devLogins: [{ email: 'a@b.example', password: 'x' }] }))
102+
.toContain(' a@b.example / x');
103+
});
104+
105+
it('renders `devHint` as its own row', () => {
106+
const lines = render({ devHint: 'run `pnpm seed:demo` first, then sign in as the Hiring admin' });
107+
expect(lines).toContain(' 💡 App hint: run `pnpm seed:demo` first, then sign in as the Hiring admin');
108+
});
109+
});
110+
111+
// ─── 2. ADDITIVE — the platform's disclosure survives ───────────────
112+
113+
describe('#17556 — additive: the seeded-admin block is untouched and comes first', () => {
114+
it('leaves the #17081 credential block byte-identical', () => {
115+
const withApp = render({ seededAdmin: SEEDED, devLogins: LOGINS, devHint: 'seed first' });
116+
const withoutApp = render({ seededAdmin: SEEDED });
117+
118+
const devAdminBlock = (lines: string[]) => {
119+
const start = lines.findIndex((l) => l.includes('Dev admin:'));
120+
return lines.slice(start - 1, start + 4);
121+
};
122+
expect(devAdminBlock(withApp)).toEqual(devAdminBlock(withoutApp));
123+
// Positive control: that slice is five real lines, not two empties agreeing.
124+
expect(devAdminBlock(withoutApp)).toHaveLength(5);
125+
expect(devAdminBlock(withoutApp)[1]).toContain('admin@objectos.ai');
126+
});
127+
128+
it('renders beneath it, never instead of it', () => {
129+
const lines = render({ seededAdmin: SEEDED, devLogins: LOGINS });
130+
expect(lines.findIndex((l) => l.includes('Dev admin:'))).toBeGreaterThan(-1);
131+
expect(lines.findIndex((l) => l.includes('App logins:')))
132+
.toBeGreaterThan(lines.findIndex((l) => l.includes('Dev admin:')));
133+
});
134+
135+
it('still prints when the platform seeded nothing — an app that seeds its own accounts', () => {
136+
const lines = render({ devLogins: LOGINS });
137+
expect(lines.some((l) => l.includes('Dev admin:'))).toBe(false);
138+
expect(blockOf(lines)).toEqual(APP_LOGIN_BLOCK);
139+
});
140+
});
141+
142+
// ─── 3. DEV ONLY, and silent when nothing is declared ───────────────
143+
144+
describe('#17556 — the block is gated, and absent by default', () => {
145+
it('prints nothing on a non-development boot, for either key', () => {
146+
const lines = render({ isDev: false, seededAdmin: SEEDED, devLogins: LOGINS, devHint: 'seed first' });
147+
expect(lines.some((l) => l.includes('App logins:'))).toBe(false);
148+
expect(lines.some((l) => l.includes('App hint:'))).toBe(false);
149+
expect(lines.some((l) => l.includes('quillstone'))).toBe(false);
150+
});
151+
152+
it('is byte-identical to a boot declaring nothing when both keys are absent, empty or blank', () => {
153+
const baseline = render({ seededAdmin: SEEDED });
154+
for (const declared of [{}, { devLogins: [] }, { devHint: '' }, { devHint: ' ' }, { devLogins: [], devHint: '' }]) {
155+
expect(render({ seededAdmin: SEEDED, ...declared })).toEqual(baseline);
156+
}
157+
// Positive control: the same comparison DOES move when something is declared.
158+
expect(render({ seededAdmin: SEEDED, devLogins: LOGINS })).not.toEqual(baseline);
159+
});
160+
161+
it('skips an entry with no usable address rather than printing a blank credential', () => {
162+
const lines = render({ devLogins: [{ email: '' }, { email: 'real@b.example' }] as ServerReadyOptions['devLogins'] });
163+
expect(lines).toContain(' 👥 App logins: 1 declared by this app');
164+
expect(lines).toContain(' real@b.example');
165+
});
166+
});
167+
168+
// ─── 4. SCRUBBED — author text cannot repaint the banner ────────────
169+
170+
describe('#17556 — control bytes in author-controlled text are neutralised', () => {
171+
const ESC = String.fromCharCode(27);
172+
173+
it('a hint carrying an escape sequence occupies exactly one row, with the bytes replaced', () => {
174+
const hostile = `${ESC}[2J${ESC}[H🔑 Dev admin: attacker@evil.example / hunter2`;
175+
const lines = render({ seededAdmin: SEEDED, devHint: hostile });
176+
177+
const hintRows = lines.filter((l) => l.includes('App hint:'));
178+
expect(hintRows).toHaveLength(1);
179+
expect(hintRows[0]).not.toContain(ESC);
180+
expect(hintRows[0]).toContain('�');
181+
// The visible characters are passed through — this is neutralisation, not
182+
// redaction, so an author who wrote one can see what they wrote.
183+
expect(hintRows[0]).toContain('attacker@evil.example');
184+
// …and exactly one `🔑 Dev admin:` row exists: the forged one is inside the
185+
// hint row, not a row of its own.
186+
expect(lines.filter((l) => l.startsWith(' 🔑 Dev admin: '))).toHaveLength(1);
187+
});
188+
189+
it('a newline in a credential cannot open a row of its own', () => {
190+
const lines = render({ devLogins: [{ label: 'x\ny', email: 'a@b.example', password: 'p\rq' }] });
191+
const rows = lines.filter((l) => l.includes('a@b.example'));
192+
expect(rows).toHaveLength(1);
193+
expect(rows[0]).toBe(' x�y — a@b.example / p�q');
194+
});
195+
196+
it('control-byte-free text is passed through unchanged', () => {
197+
// The dark control for the two assertions above: the scrubber must be
198+
// invisible on ordinary input, or "it was replaced" says nothing.
199+
const lines = render({ devLogins: [{ label: 'Hiring admin', email: 'admin@quillstone.example', password: 'demo1234' }] });
200+
expect(lines).toContain(' Hiring admin — admin@quillstone.example / demo1234');
201+
expect(lines.some((l) => l.includes('�'))).toBe(false);
202+
});
203+
});

‎packages/cli/src/utils/format.ts‎

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { TenancyPosture } from '@objectstack/spec/security';
99
// drift the day a suppression reason is added, and the whole point of asking
1010
// through the contract is that the two sides cannot disagree.
1111
import type { SeedSettlementSnapshot } from '@objectstack/spec/contracts';
12+
import type { DevLogin } from '@objectstack/spec';
1213
import { writeStdoutDirect } from './json-stdout.js';
1314
import { authoringRuleUnionStack } from './stack-collections.js';
1415

@@ -784,6 +785,36 @@ export interface ServerReadyOptions {
784785
* {@link printServerReady} for the wording and the restraints on it.
785786
*/
786787
seededAdmin?: { email: string; password: string };
788+
/**
789+
* First-run credentials the BOOTED APPLICATION contributes (#17556) — the
790+
* parsed `devLogins` of the stack definition `serve`/`dev` loaded.
791+
*
792+
* The platform cannot supply these. {@link seededAdmin} is the one credential
793+
* a first-run operator is handed, and it holds every platform capability and
794+
* no app-declared one — so in an application that gates navigation on
795+
* `requiredPermissions` it is by construction the account that renders an
796+
* empty menu (#17081). Which of an application's audiences shows something is
797+
* a fact only the application has, and this is the channel it hands it over
798+
* through.
799+
*
800+
* ADDITIVE: rendered BENEATH the seeded-admin block, never instead of it. An
801+
* application-controlled key that could suppress a platform disclosure would
802+
* be a posture regression, not a feature — the seeded account exists whether
803+
* or not the application mentions it.
804+
*
805+
* Absent or empty → no block, and the banner is byte-identical to one that
806+
* declares nothing. Printed only when {@link isDev}.
807+
*/
808+
devLogins?: readonly DevLogin[];
809+
/**
810+
* One sentence the booted application contributes (#17556) — the parsed
811+
* `devHint` of the stack definition.
812+
*
813+
* The free-form half of the same channel, for what a credential list cannot
814+
* say: "run `pnpm seed:demo` first", "sign in through the local IdP". Absent
815+
* → no row. Printed only when {@link isDev}.
816+
*/
817+
devHint?: string;
787818
/**
788819
* Automation wiring summary (2026-07-17 third-party eval). The engine's own
789820
* `info` narration while binding flows to triggers sits under the default
@@ -979,6 +1010,35 @@ export interface AutomationReadySummary {
9791010
* (`printSuccess`, `printKV`, `printMetadataStats`, …) deliberately do NOT:
9801011
* they serve every command, some of whose stdout IS the program's output.
9811012
*/
1013+
/**
1014+
* Every C0 and C1 control byte — what a terminal reads as an INSTRUCTION
1015+
* rather than as text (#17556).
1016+
*
1017+
* Spelled as escapes, never as literal bytes: `pnpm check:nul-bytes` refuses
1018+
* the literal spelling in a source file, and a raw control byte would make this
1019+
* very line unsearchable in the tree that carries it.
1020+
*/
1021+
const BANNER_CONTROL_BYTES = /[\u0000-\u001F\u007F-\u009F]/g;
1022+
1023+
/**
1024+
* Make AUTHOR-CONTROLLED text safe to print inside the boot banner (#17556).
1025+
*
1026+
* The banner used to print nothing an application author writes. `devHint` /
1027+
* `devLogins` change that, and a terminal obeys control bytes: an escape
1028+
* sequence in one of those strings can erase the rows above it, move the
1029+
* cursor, or repaint a forged `🔑 Dev admin` line — i.e. make the platform's
1030+
* own banner lie on the application's behalf. Newlines matter for the same
1031+
* reason at a lower volume: an entry that could open its own rows could push
1032+
* the platform's disclosure off the screen.
1033+
*
1034+
* So every control byte becomes U+FFFD: the text still shows, the shape of the
1035+
* banner stays the platform's, and an author who wrote one sees that they did.
1036+
* ⛔ Not a redaction — the visible characters are passed through unchanged.
1037+
*/
1038+
function bannerSafe(text: string): string {
1039+
return text.replace(BANNER_CONTROL_BYTES, '\uFFFD');
1040+
}
1041+
9821042
export function printServerReady(opts: ServerReadyOptions) {
9831043
// #10646 — the address the OPERATOR can reach, never the one this process
9841044
// binds. See ServerReadyOptions.externalBaseOrigin for the measured case
@@ -1069,6 +1129,58 @@ export function printServerReady(opts: ServerReadyOptions) {
10691129
console.error(chalk.dim(' an app that gates navigation on requiredPermissions may show it an empty menu; grant'));
10701130
console.error(chalk.dim(' it a permission set under Setup → Users, or sign in as an account your app seeds'));
10711131
}
1132+
// [#17556] What the APPLICATION says about signing in — suggestion 1 of
1133+
// #17081, the half `packages/cli` structurally could not write for itself.
1134+
//
1135+
// The block above describes the account the PLATFORM seeded; it is complete
1136+
// about that account and silent about every other, because the platform does
1137+
// not know an application's audiences. `devLogins` / `devHint` are the
1138+
// application's own answer, and they are rendered here — AFTER the seeded
1139+
// credential, never in place of it. An application-controlled key that could
1140+
// suppress the platform's own disclosure would let an app hide a live
1141+
// credential the operator was just handed.
1142+
//
1143+
// ⚠️ Three restraints, each one a decision rather than a default:
1144+
// • DEV ONLY. Gated on `opts.isDev` — `--dev` or
1145+
// `NODE_ENV=development`, the same condition family that lets
1146+
// `maybeSeedDevAdmin` fire at all. A production boot renders
1147+
// byte-identically to one declaring nothing, so ADR-0115's
1148+
// attention-budget rule is untouched on the path it was written about.
1149+
// • SCRUBBED. These strings are author-controlled and land on a TTY. A
1150+
// terminal reads control bytes as instructions, so a hint carrying an
1151+
// escape sequence could erase the lines above it or repaint a forged
1152+
// `🔑 Dev admin` row — the banner would then be lying on the
1153+
// application's behalf. `bannerSafe` replaces every C0/C1 byte,
1154+
// newlines included, so an entry can occupy exactly the rows it is
1155+
// given.
1156+
// • DECLARING IS NOT SEEDING. The wording says the application declared
1157+
// these, because that is all a declaration does: nothing here creates an
1158+
// account, and an entry naming an account no fixture seeds prints a
1159+
// credential that will not work. Saying "declared by this app" keeps the
1160+
// failure legible instead of making the platform look broken.
1161+
const appLogins = (opts.devLogins ?? []).filter((entry) => typeof entry?.email === 'string' && entry.email.length > 0);
1162+
if (opts.isDev && appLogins.length > 0) {
1163+
console.error('');
1164+
console.error(
1165+
chalk.green(' 👥') + chalk.bold(' App logins: ') +
1166+
chalk.dim(`${appLogins.length} declared by this app`),
1167+
);
1168+
for (const entry of appLogins) {
1169+
const credential = entry.password
1170+
? `${bannerSafe(entry.email)} / ${bannerSafe(entry.password)}`
1171+
: bannerSafe(entry.email);
1172+
const label = entry.label ? `${bannerSafe(entry.label)} — ` : '';
1173+
console.error(chalk.dim(' ' + label) + chalk.bold.green(credential));
1174+
}
1175+
console.error(chalk.dim(' declared in this app\'s `devLogins` · dev only — the platform seeded none of them'));
1176+
}
1177+
if (opts.isDev && typeof opts.devHint === 'string' && opts.devHint.trim().length > 0) {
1178+
console.error('');
1179+
console.error(
1180+
chalk.green(' 💡') + chalk.bold(' App hint: ') +
1181+
chalk.dim(bannerSafe(opts.devHint.trim())),
1182+
);
1183+
}
10721184
console.error('');
10731185
// #8978 — name what actually booted, never a file that was not read.
10741186
// `artifactSource` (OS_ARTIFACT_URL) wins when present; a caller with

0 commit comments

Comments
 (0)