@@ -237,6 +237,119 @@ export type ArtifactPackageEntry = z.input<typeof ArtifactPackageEntrySchema>;
237237/** Post-parse shape of {@link ArtifactPackageEntry} — defaults applied, transforms run (ADR-0122). */
238238export 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 */
10861259type AssembledPackageBodyEnvelopeKey = ( typeof ASSEMBLED_PACKAGE_BODY_ENVELOPE_KEYS ) [ number ] ;
@@ -1106,8 +1279,10 @@ const ASSEMBLED_PACKAGE_BODY_DISPOSITIONS: readonly string[] = ['concat', 'objec
11061279function 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