Skip to content

Commit 6714690

Browse files
committed
wip(spec): declare devHint / devLogins on the stack definition (#17556)
Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <noreply@anthropic.com>
1 parent b7eaf6a commit 6714690

2 files changed

Lines changed: 186 additions & 9 deletions

File tree

‎packages/spec/src/assembled-package-body.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ const ARTIFACT_ENVELOPE_KEYS = [
7777
'onEnable', // one bundle, one lifecycle hook (AppPlugin invokes a single one)
7878
'plugins', // runtime assembly instructions a host hands to `kernel.use()` — not metadata (#15219 ruling A)
7979
'devPlugins', // the `os dev` load list — the same class as `plugins` (#15219 ruling A)
80+
'devHint', // one sentence the development BOOT BANNER prints — a property of the boot, not of a package (#17556)
81+
'devLogins', // the first-run credentials that banner prints — read off the top level, never out of a body (#17556)
8082
].sort();
8183

8284
const shapeKeys = (schema: unknown): string[] =>

‎packages/spec/src/stack.zod.ts‎

Lines changed: 184 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,119 @@ export type ArtifactPackageEntry = z.input<typeof ArtifactPackageEntrySchema>;
237237
/** Post-parse shape of {@link ArtifactPackageEntry} — defaults applied, transforms run (ADR-0122). */
238238
export type ArtifactPackageEntryParsed = z.infer<typeof ArtifactPackageEntrySchema>;
239239

240+
/**
241+
* ONE first-run credential an application contributes to the development boot
242+
* banner (#17556 — suggestion 1 of #17081).
243+
*
244+
* ## Why an app has to be the one to say this
245+
*
246+
* `os dev` seeds a platform admin on an empty DB and the banner prints it as
247+
* the ONLY credential a first-run operator is handed. That account holds every
248+
* PLATFORM capability (`ADMIN_FULL_ACCESS_CAPABILITIES`) and no APP-declared
249+
* one, because a capability an app declares is the app's to grant. So in any
250+
* application that gates its apps/tabs/nav on `requiredPermissions` it is by
251+
* construction the account that resolves to an empty menu — measured on a
252+
* downstream app where four of five personas rendered their group and the one
253+
* the banner named rendered none.
254+
*
255+
* `packages/cli` cannot fix that from its own side: the platform can describe
256+
* the account it seeds, and it cannot know that an application's `Hiring` group
257+
* needs a position or which of five personas a demo should open with. The
258+
* application knows. This key is the channel it says so through.
259+
*
260+
* ## What it is NOT
261+
*
262+
* ⛔ Not an identity, not a seed, and not an authorization: declaring an entry
263+
* here CREATES NOTHING. It is presentation — the application naming accounts it
264+
* seeds by some other means (`data` fixtures, an `onEnable` hook, its own
265+
* script) so the banner can point the operator at one that shows something.
266+
* An entry naming an account nothing seeds prints a credential that does not
267+
* work, exactly as a README line would.
268+
*
269+
* ## Read only in development
270+
*
271+
* The consuming banner prints this block only on a development boot
272+
* (`os dev`, `objectstack serve --dev`, or `NODE_ENV=development`). A
273+
* production boot renders byte-identically to one that declares nothing.
274+
*/
275+
export const DevLoginSchema = lazySchema(() => strictObject(
276+
{
277+
surface: 'a `devLogins` entry',
278+
history:
279+
'This key is new in @objectstack/spec 17.5 and strict from birth — an unknown key here was '
280+
+ 'never accepted.',
281+
aliases: {
282+
user: 'email',
283+
username: 'email',
284+
login: 'email',
285+
account: 'email',
286+
name: 'label',
287+
persona: 'label',
288+
role: 'label',
289+
description: 'label',
290+
note: 'label',
291+
hint: 'label',
292+
secret: 'password',
293+
pass: 'password',
294+
},
295+
guidance: {
296+
permissions:
297+
'a `devLogins` entry grants nothing — it only names an account for the boot banner to '
298+
+ 'print. Grant capabilities with a permission set (`permissions`) or a position '
299+
+ '(`positions`), and seed the account itself in `data`.',
300+
capabilities:
301+
'a `devLogins` entry grants nothing — it only names an account for the boot banner to '
302+
+ 'print. Declare capabilities in `capabilities` and distribute them with `positions` / '
303+
+ '`permissions`; seed the account itself in `data`.',
304+
seed:
305+
'a `devLogins` entry creates no account. Seed the user record in the stack\'s `data` '
306+
+ 'fixtures (or in `onEnable`) and name it here so the banner can print it.',
307+
},
308+
},
309+
{
310+
/**
311+
* The address the operator types into the sign-in form.
312+
*
313+
* Required, because an entry that cannot be signed in with is a line of
314+
* banner text pretending to be a credential. Validated as an email for the
315+
* same reason the identity schemas are: `sys_user.email` is what the
316+
* sign-in form and every seeded fixture key on, so a value that is not one
317+
* names an account the operator cannot reach.
318+
*/
319+
email: z.string().email().describe('Email of an account this application seeds — printed by the development boot banner'),
320+
321+
/**
322+
* The password to print beside {@link DevLogin.email}.
323+
*
324+
* OPTIONAL, and omitted deliberately rather than defaulted: a development
325+
* deployment may sign in by magic link, by SSO, or with a password the
326+
* operator supplies, and inventing one would print a credential that fails.
327+
* Omitted → the banner prints the address alone.
328+
*
329+
* ⚠️ Whatever is written here is committed to the application's repository
330+
* and printed to a terminal. It is a DEVELOPMENT fixture — ⛔ never a real
331+
* secret, and ⛔ never a value that also opens a deployed environment.
332+
*/
333+
password: z.string().optional().describe('Development-fixture password printed beside the address; omit when the account signs in another way'),
334+
335+
/**
336+
* What this account SEES, in a few words — the whole point of the key.
337+
*
338+
* The defect this family exists for is an operator holding a credential
339+
* with no idea what audience it belongs to, so `Hiring admin` or
340+
* `Job seeker` is the part that repairs it; the address alone repeats the
341+
* failure one account over. Omitted → the banner prints the credential with
342+
* no audience column.
343+
*/
344+
label: z.string().optional().describe('The audience this account belongs to (e.g. `Hiring admin`) — printed before the address'),
345+
},
346+
));
347+
348+
/** Authoring shape of one {@link DevLoginSchema} entry. */
349+
export type DevLogin = z.input<typeof DevLoginSchema>;
350+
/** Post-parse shape of {@link DevLogin} — defaults applied, transforms run (ADR-0122). */
351+
export type DevLoginParsed = z.infer<typeof DevLoginSchema>;
352+
240353
/**
241354
* Every metadata COLLECTION `ObjectStackDefinitionSchema` declares, as one
242355
* named shape.
@@ -259,11 +372,14 @@ export type ArtifactPackageEntryParsed = z.infer<typeof ArtifactPackageEntrySche
259372
* built from this shape, so putting it in the shape would make the declaration
260373
* circular.
261374
*
262-
* Two members of this shape are envelope keys as well: `plugins` and
263-
* `devPlugins` are stack collections (they compose by `concat`, so they live
264-
* here), but they are runtime ASSEMBLY instructions rather than metadata, and
265-
* {@link ASSEMBLED_PACKAGE_BODY_ENVELOPE_KEYS} keeps them out of the assembled
266-
* package body (#15219 — {@link AssembledPackageBodyKey} says why).
375+
* Three members of this shape are envelope keys as well: `plugins`,
376+
* `devPlugins` and `devLogins` are stack collections (they compose by
377+
* `concat`, so they live here), but they describe the BOOT rather than a
378+
* package's metadata — the first two are runtime ASSEMBLY instructions, the
379+
* third is what the development banner prints — and
380+
* {@link ASSEMBLED_PACKAGE_BODY_ENVELOPE_KEYS} keeps all three out of the
381+
* assembled package body (#15219, #17556 — {@link AssembledPackageBodyKey}
382+
* says why).
267383
*
268384
* @internal
269385
*/
@@ -804,6 +920,47 @@ const STACK_DEFINITION_COLLECTIONS_SHAPE = {
804920
*/
805921
devPlugins: z.array(z.union([ManifestSchema, z.string()])).optional().describe('Plugins to load only in development (CLI dev command)'),
806922

923+
/**
924+
* DevHint: One Sentence For The First-Run Operator
925+
*
926+
* Free-form text the development boot banner prints under the credential
927+
* block — the channel for what {@link DevLoginSchema} cannot express: "run
928+
* `pnpm seed:demo` first", "sign in with SSO against the local IdP", "the
929+
* Hiring group needs a position, see README".
930+
*
931+
* Composes as `'single'`: two stacks declaring DIFFERENT hints is a
932+
* composition ERROR naming both, never a silent last-wins — the same rule
933+
* `api` / `server` / `i18n` carry, for the same reason (an application's
934+
* first-run instruction is not a detail a composer may pick for the author).
935+
*
936+
* Read only on a development boot; a production boot prints nothing.
937+
*/
938+
devHint: z.string().optional().describe('One sentence the development boot banner prints under the credential block (dev only)'),
939+
940+
/**
941+
* DevLogins: First-Run Credentials The Application Contributes
942+
*
943+
* The accounts an operator should actually sign in with on this application,
944+
* printed beneath the platform's own seeded dev admin (#17556). See
945+
* {@link DevLoginSchema} for what one entry means — in particular that it
946+
* CREATES NOTHING and grants nothing; it names accounts the application seeds
947+
* by other means so the banner can point at one that shows something.
948+
*
949+
* ADDITIVE, never a replacement: the seeded-admin line still prints, because
950+
* that account exists whether or not the application mentions it and an
951+
* application-controlled key must not be able to suppress a platform
952+
* disclosure.
953+
*
954+
* Composes as `'concat'`: composing two applications yields BOTH publishers'
955+
* personas, since dropping one would hide an audience the composed artifact
956+
* still serves. An artifact ENVELOPE key like `plugins` / `devPlugins` — the
957+
* banner reads it off the top level, so it stays there rather than being
958+
* folded into an assembled package body where nothing would read it.
959+
*
960+
* Read only on a development boot; a production boot prints nothing.
961+
*/
962+
devLogins: z.array(DevLoginSchema).optional().describe('First-run credentials the application contributes to the development boot banner (dev only)'),
963+
807964
/**
808965
* Compiled Runtime Bundle Reference
809966
*
@@ -994,11 +1151,18 @@ export const COMPOSE_KEY_DISPOSITIONS = Object.freeze({
9941151
requires: 'concat',
9951152
tiers: 'concat',
9961153
devPlugins: 'concat',
1154+
// #17556 — BOTH publishers' personas survive a compose, for the reason
1155+
// `packages` concatenates: dropping one would hide an audience the composed
1156+
// artifact still serves. `devHint` is the scalar half and is `'single'` below.
1157+
devLogins: 'concat',
9971158

9981159
// ── Single-valued configuration — same value passes, difference throws ──
9991160
api: 'single',
10001161
server: 'single',
10011162
runtimeModule: 'single',
1163+
// #17556 — two stacks declaring different first-run instructions is a
1164+
// conflict to name, not a last-wins to resolve behind the authors' backs.
1165+
devHint: 'single',
10021166
// #8687: declared alongside the strict close (it was undeclared-but-honoured
10031167
// before, so composition never saw it through a parsed stack). One bundle
10041168
// gets one `onEnable` (`AppPlugin` invokes a single hook at start()); two
@@ -1045,7 +1209,7 @@ const CONCAT_ARRAY_FIELDS = STACK_DEFINITION_KEYS
10451209
* would refuse a multi-package artifact naming a key its author correctly
10461210
* wrote, and the refusal would look like a defect in the author's metadata.
10471211
*
1048-
* Three `concat` keys are excluded — the artifact ENVELOPE keys, declared once
1212+
* Four `concat` keys are excluded — the artifact ENVELOPE keys, declared once
10491213
* in {@link ASSEMBLED_PACKAGE_BODY_ENVELOPE_KEYS} for this type and the runtime
10501214
* shape alike:
10511215
*
@@ -1064,6 +1228,15 @@ const CONCAT_ARRAY_FIELDS = STACK_DEFINITION_KEYS
10641228
* in-memory composition) and where `os serve` / `os migrate` read them.
10651229
* Inside a body both are refused by the manifest's strict close, naming the
10661230
* key.
1231+
* - `devLogins`: what the DEVELOPMENT BOOT BANNER prints (#17556), not metadata
1232+
* a package registers. Its one reader is the CLI, which reads it off the top
1233+
* level of the definition it booted, so an entry folded into
1234+
* `packages[i].manifest` would be parsed, stored and never printed — the
1235+
* declared-but-unread shape ADR-0049 exists about. Excluded for the same
1236+
* reason `plugins` is, one layer over: the artifact is inert JSON describing
1237+
* an application, and which account an operator should sign in with is a
1238+
* property of the BOOT, not of a package inside it. `concat` stays at the top
1239+
* level, where composing two applications keeps both publishers' personas.
10671240
*
10681241
* @internal
10691242
*/
@@ -1080,7 +1253,7 @@ type AssembledPackageBodyKey = Exclude<{
10801253
* runtime halves cannot name different sets.
10811254
* @internal
10821255
*/
1083-
const ASSEMBLED_PACKAGE_BODY_ENVELOPE_KEYS = ['packages', 'plugins', 'devPlugins'] as const satisfies readonly StackDefinitionKey[];
1256+
const ASSEMBLED_PACKAGE_BODY_ENVELOPE_KEYS = ['packages', 'plugins', 'devPlugins', 'devLogins'] as const satisfies readonly StackDefinitionKey[];
10841257

10851258
/** One member of {@link ASSEMBLED_PACKAGE_BODY_ENVELOPE_KEYS}. @internal */
10861259
type AssembledPackageBodyEnvelopeKey = (typeof ASSEMBLED_PACKAGE_BODY_ENVELOPE_KEYS)[number];
@@ -1106,8 +1279,10 @@ const ASSEMBLED_PACKAGE_BODY_DISPOSITIONS: readonly string[] = ['concat', 'objec
11061279
function assembledPackageBodyShape(): Pick<typeof STACK_DEFINITION_COLLECTIONS_SHAPE, AssembledPackageBodyKey> {
11071280
const shape: Record<string, unknown> = {};
11081281
for (const [key, disposition] of Object.entries(COMPOSE_KEY_DISPOSITIONS)) {
1109-
// Envelope keys stay on the artifact: `packages` cannot nest, and
1110-
// `plugins` / `devPlugins` are assembly instructions no body could carry.
1282+
// Envelope keys stay on the artifact: `packages` cannot nest,
1283+
// `plugins` / `devPlugins` are assembly instructions no body could carry,
1284+
// and `devLogins` is boot-banner text whose only reader looks at the top
1285+
// level.
11111286
if ((ASSEMBLED_PACKAGE_BODY_ENVELOPE_KEYS as readonly string[]).includes(key)) continue;
11121287
if (!ASSEMBLED_PACKAGE_BODY_DISPOSITIONS.includes(disposition)) continue;
11131288
shape[key] = (STACK_DEFINITION_COLLECTIONS_SHAPE as Record<string, unknown>)[key];

0 commit comments

Comments
 (0)