@c9up/station 0.1.11 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,9 +6,28 @@ import type { Resource } from "./types.js";
6
6
  * lands in story 54.7. Iteration order is registration order, which is what
7
7
  * the admin sidebar wants.
8
8
  */
9
+ /**
10
+ * How a registered resource reaches the router.
11
+ *
12
+ * Installed by `StationProvider` once the admin surface is prepared, so a
13
+ * resource declared in a preload becomes mounted routes at the moment it is
14
+ * declared. The provider used to read a SNAPSHOT of this registry in `start()`
15
+ * and give up when it was empty — and providers start before preloads run, so
16
+ * `station.register(...)` written where the docs say to write it arrived after
17
+ * the only pass that would have mounted it.
18
+ */
19
+ export type ResourceMounter = (resource: Resource) => void;
9
20
  export declare class ResourceRegistry {
10
21
  #private;
11
22
  register<T>(resource: Resource<T>): void;
23
+ /**
24
+ * @internal Give the registry a way to mount what it is told about.
25
+ *
26
+ * Mounts everything already registered before it arrived: an application is
27
+ * free to declare a resource at module scope, which runs while the provider
28
+ * is still starting.
29
+ */
30
+ useMounter(mounter: ResourceMounter): void;
12
31
  get(name: string): Resource | undefined;
13
32
  getOrThrow(name: string): Resource;
14
33
  has(name: string): boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"ResourceRegistry.d.ts","sourceRoot":"","sources":["../src/ResourceRegistry.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAE3C;;;;;;GAMG;AACH,qBAAa,gBAAgB;;IAG5B,QAAQ,CAAC,CAAC,EAAE,QAAQ,EAAE,QAAQ,CAAC,CAAC,CAAC,GAAG,IAAI;IAUxC,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,QAAQ,GAAG,SAAS;IAIvC,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,QAAQ;IAUlC,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAI1B,KAAK,IAAI,MAAM;IAIf,GAAG,IAAI,aAAa,CAAC,QAAQ,CAAC;CAI9B"}
1
+ {"version":3,"file":"ResourceRegistry.d.ts","sourceRoot":"","sources":["../src/ResourceRegistry.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAE3C;;;;;;GAMG;AACH;;;;;;;;;GASG;AACH,MAAM,MAAM,eAAe,GAAG,CAAC,QAAQ,EAAE,QAAQ,KAAK,IAAI,CAAC;AAE3D,qBAAa,gBAAgB;;IAI5B,QAAQ,CAAC,CAAC,EAAE,QAAQ,EAAE,QAAQ,CAAC,CAAC,CAAC,GAAG,IAAI;IAWxC;;;;;;OAMG;IACH,UAAU,CAAC,OAAO,EAAE,eAAe,GAAG,IAAI;IAK1C,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,QAAQ,GAAG,SAAS;IAIvC,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,QAAQ;IAUlC,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAI1B,KAAK,IAAI,MAAM;IAIf,GAAG,IAAI,aAAa,CAAC,QAAQ,CAAC;CAI9B"}
@@ -1,18 +1,25 @@
1
- /**
2
- * Central catalogue of declared resources for a Station-powered admin surface.
3
- *
4
- * Plain data structure: no boot()/start() lifecycle here — provider wiring
5
- * lands in story 54.7. Iteration order is registration order, which is what
6
- * the admin sidebar wants.
7
- */
8
1
  export class ResourceRegistry {
9
2
  #map = new Map();
3
+ #mounter;
10
4
  register(resource) {
11
5
  const existing = this.#map.get(resource.name);
12
6
  if (existing !== undefined) {
13
7
  throw new Error(`[station] ResourceRegistry: duplicate resource name '${resource.name}' (already registered for ${existing.entity.name})`);
14
8
  }
15
9
  this.#map.set(resource.name, resource);
10
+ this.#mounter?.(resource);
11
+ }
12
+ /**
13
+ * @internal Give the registry a way to mount what it is told about.
14
+ *
15
+ * Mounts everything already registered before it arrived: an application is
16
+ * free to declare a resource at module scope, which runs while the provider
17
+ * is still starting.
18
+ */
19
+ useMounter(mounter) {
20
+ this.#mounter = mounter;
21
+ for (const resource of this.#map.values())
22
+ mounter(resource);
16
23
  }
17
24
  get(name) {
18
25
  return this.#map.get(name);
@@ -1 +1 @@
1
- {"version":3,"file":"ResourceRegistry.js","sourceRoot":"","sources":["../src/ResourceRegistry.ts"],"names":[],"mappings":"AAEA;;;;;;GAMG;AACH,MAAM,OAAO,gBAAgB;IACnB,IAAI,GAAG,IAAI,GAAG,EAAoB,CAAC;IAE5C,QAAQ,CAAI,QAAqB;QAChC,MAAM,QAAQ,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;QAC9C,IAAI,QAAQ,KAAK,SAAS,EAAE,CAAC;YAC5B,MAAM,IAAI,KAAK,CACd,wDAAwD,QAAQ,CAAC,IAAI,6BAA6B,QAAQ,CAAC,MAAM,CAAC,IAAI,GAAG,CACzH,CAAC;QACH,CAAC;QACD,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC;IACxC,CAAC;IAED,GAAG,CAAC,IAAY;QACf,OAAO,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IAC5B,CAAC;IAED,UAAU,CAAC,IAAY;QACtB,MAAM,KAAK,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QAClC,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;YACzB,MAAM,IAAI,KAAK,CACd,kDAAkD,IAAI,GAAG,CACzD,CAAC;QACH,CAAC;QACD,OAAO,KAAK,CAAC;IACd,CAAC;IAED,GAAG,CAAC,IAAY;QACf,OAAO,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IAC5B,CAAC;IAED,KAAK;QACJ,OAAO,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC;IACvB,CAAC;IAED,GAAG;QACF,MAAM,QAAQ,GAAe,CAAC,GAAG,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,CAAC,CAAC;QACrD,OAAO,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;IAChC,CAAC;CACD"}
1
+ {"version":3,"file":"ResourceRegistry.js","sourceRoot":"","sources":["../src/ResourceRegistry.ts"],"names":[],"mappings":"AAqBA,MAAM,OAAO,gBAAgB;IACnB,IAAI,GAAG,IAAI,GAAG,EAAoB,CAAC;IAC5C,QAAQ,CAAmB;IAE3B,QAAQ,CAAI,QAAqB;QAChC,MAAM,QAAQ,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;QAC9C,IAAI,QAAQ,KAAK,SAAS,EAAE,CAAC;YAC5B,MAAM,IAAI,KAAK,CACd,wDAAwD,QAAQ,CAAC,IAAI,6BAA6B,QAAQ,CAAC,MAAM,CAAC,IAAI,GAAG,CACzH,CAAC;QACH,CAAC;QACD,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC;QACvC,IAAI,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,CAAC;IAC3B,CAAC;IAED;;;;;;OAMG;IACH,UAAU,CAAC,OAAwB;QAClC,IAAI,CAAC,QAAQ,GAAG,OAAO,CAAC;QACxB,KAAK,MAAM,QAAQ,IAAI,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE;YAAE,OAAO,CAAC,QAAQ,CAAC,CAAC;IAC9D,CAAC;IAED,GAAG,CAAC,IAAY;QACf,OAAO,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IAC5B,CAAC;IAED,UAAU,CAAC,IAAY;QACtB,MAAM,KAAK,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QAClC,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;YACzB,MAAM,IAAI,KAAK,CACd,kDAAkD,IAAI,GAAG,CACzD,CAAC;QACH,CAAC;QACD,OAAO,KAAK,CAAC;IACd,CAAC;IAED,GAAG,CAAC,IAAY;QACf,OAAO,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IAC5B,CAAC;IAED,KAAK;QACJ,OAAO,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC;IACvB,CAAC;IAED,GAAG;QACF,MAAM,QAAQ,GAAe,CAAC,GAAG,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,CAAC,CAAC;QACrD,OAAO,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;IAChC,CAAC;CACD"}
@@ -72,7 +72,7 @@ type RuneValidationResult = {
72
72
  /**
73
73
  * A built rune schema — only the result-based check is consumed.
74
74
  *
75
- * `validateResult`, NOT `validate`: rune reserves `validate()` for the VineJS
75
+ * `validateResult`, NOT `validate`: rune reserves `validate()` for the
76
76
  * contract (async, throwing). Reading `.valid` off a Promise gives `undefined`,
77
77
  * so keeping the old name would have made every admin write fail closed with an
78
78
  * empty error list.
@@ -141,11 +141,14 @@ export declare function resourcesNeedValidation(resources: ReadonlyArray<Resourc
141
141
  * NEVER `in` on an instance (`@Column() declare` makes `in` always false —
142
142
  * mirror `BaseRepository.#hydrate`).
143
143
  *
144
- * Per-column rule mapping tolerates the form-post wire shape (values arrive as
145
- * strings — rune does NOT coerce): booleans `.parse(isCheckedValue)`, numbers
144
+ * Per-column rule mapping tolerates the form-post wire shape, where every
145
+ * value arrives as a string: booleans `.parse(isCheckedValue)`, numbers
146
146
  * `.parse` string→Number (NaN falls through to a type error), everything else
147
- * `rules.string()`. `.parse`/`.optional`/`.nullable` force rune's TS path, so
148
- * no native `.node` is touched.
147
+ * `rules.string()`. The explicit `.parse` is deliberate even though rune now
148
+ * coerces both itself — a checkbox posts the values IT chooses (`isCheckedValue`
149
+ * is the column's own truthy spelling), which is a wider set than any
150
+ * general-purpose coercion should accept. `.parse`/`.optional`/`.nullable`
151
+ * force rune's TS path, so no native `.node` is touched.
149
152
  *
150
153
  * Returns the built schema plus the set of boolean columns — the handler
151
154
  * default-fills an ABSENT boolean key to `false` before validating (an
@@ -172,6 +175,13 @@ export default class StationProvider {
172
175
  register(): void;
173
176
  boot(): Promise<void>;
174
177
  start(): Promise<void>;
178
+ /**
179
+ * The gaps worth reporting once, when the full set of resources is known.
180
+ *
181
+ * `ready()` runs after every preload, so this is the first moment the whole
182
+ * admin surface exists. Nothing is mounted here — the routes were built as
183
+ * each resource was declared.
184
+ */
175
185
  ready(): Promise<void>;
176
186
  shutdown(): Promise<void>;
177
187
  }
@@ -182,5 +192,23 @@ export default class StationProvider {
182
192
  * mock graph.
183
193
  */
184
194
  export declare function isModuleNotFound(err: unknown): boolean;
195
+ /**
196
+ * Is THIS package the one that is missing?
197
+ *
198
+ * The code alone does not say. `@c9up/atlas` installed but missing one of its
199
+ * OWN dependencies raises the same `ERR_MODULE_NOT_FOUND`, and station read
200
+ * that as "atlas is absent" and disabled the admin — silently, which is the
201
+ * whole point of the degraded-host path and exactly wrong here: the peer IS
202
+ * there and something inside it is broken, which the operator has to be told.
203
+ *
204
+ * Node distinguishes the two in the message it writes:
205
+ *
206
+ * Cannot find package '@c9up/atlas' imported from … ← the peer is absent
207
+ * Cannot find module '/…/node_modules/@c9up/atlas/…' ← something inside it
208
+ *
209
+ * A bare-specifier miss names the PACKAGE; a miss inside an installed package
210
+ * names a path. Anything that is not unmistakably the first is re-thrown.
211
+ */
212
+ export declare function isPackageMissing(err: unknown, specifier: string): boolean;
185
213
  export {};
186
214
  //# sourceMappingURL=StationProvider.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"StationProvider.d.ts","sourceRoot":"","sources":["../src/StationProvider.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"StationProvider.d.ts","sourceRoot":"","sources":["../src/StationProvider.ts"],"names":[],"mappings":"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"}
@@ -19,9 +19,9 @@
19
19
  */
20
20
  import "./augmentations.js";
21
21
  import { fileURLToPath } from "node:url";
22
- import { inProduction } from "./nodeEnv.js";
23
22
  import { ResourceRegistry } from "./ResourceRegistry.js";
24
- import { setStation } from "./services/main.js";
23
+ import { clearStation, getStation, setStation } from "./services/main.js";
24
+ import { inProduction } from "./vendor/nodeEnv.js";
25
25
  // note: AuditEvent + ResourceAction are used by the CRUD handlers below;
26
26
  // the imports stay in one block for clarity.
27
27
  import { buildFormViewModel } from "./views/form.js";
@@ -129,11 +129,14 @@ export function resourcesNeedValidation(resources) {
129
129
  * NEVER `in` on an instance (`@Column() declare` makes `in` always false —
130
130
  * mirror `BaseRepository.#hydrate`).
131
131
  *
132
- * Per-column rule mapping tolerates the form-post wire shape (values arrive as
133
- * strings — rune does NOT coerce): booleans `.parse(isCheckedValue)`, numbers
132
+ * Per-column rule mapping tolerates the form-post wire shape, where every
133
+ * value arrives as a string: booleans `.parse(isCheckedValue)`, numbers
134
134
  * `.parse` string→Number (NaN falls through to a type error), everything else
135
- * `rules.string()`. `.parse`/`.optional`/`.nullable` force rune's TS path, so
136
- * no native `.node` is touched.
135
+ * `rules.string()`. The explicit `.parse` is deliberate even though rune now
136
+ * coerces both itself — a checkbox posts the values IT chooses (`isCheckedValue`
137
+ * is the column's own truthy spelling), which is a wider set than any
138
+ * general-purpose coercion should accept. `.parse`/`.optional`/`.nullable`
139
+ * force rune's TS path, so no native `.node` is touched.
137
140
  *
138
141
  * Returns the built schema plus the set of boolean columns — the handler
139
142
  * default-fills an ABSENT boolean key to `false` before validating (an
@@ -229,7 +232,7 @@ function fieldErrors(errors) {
229
232
  for (const e of errors) {
230
233
  if (e.field === "_root")
231
234
  continue;
232
- // Collapse a nested VineJS-style path (`profile.name`) to its top-level
235
+ // Collapse a nested dotted path (`profile.name`) to its top-level
233
236
  // key — `form.ts` keys errors by column propertyKey. `split` always yields
234
237
  // a non-empty array, so `[0]` is a string (no fallback needed).
235
238
  const [key = e.field] = e.field.split(".");
@@ -394,6 +397,8 @@ let auditCloneWarnEmitted = false;
394
397
  const TEMPLATES_ROOT = fileURLToPath(new URL("../templates/", import.meta.url));
395
398
  export default class StationProvider {
396
399
  app;
400
+ /** What this provider bound, so shutdown only clears its own. */
401
+ #owned;
397
402
  #contexts = new Map();
398
403
  #viewRenderer;
399
404
  /**
@@ -425,15 +430,20 @@ export default class StationProvider {
425
430
  async boot() {
426
431
  // Force-resolve so `setStation` runs even if no preload touches the
427
432
  // singleton. Mirrors AuroraProvider.boot().
428
- await this.app.container.resolve(ResourceRegistry);
433
+ this.#owned =
434
+ await this.app.container.resolve(ResourceRegistry);
429
435
  }
430
436
  async start() {
431
437
  if (this.#started)
432
438
  return;
433
439
  const registry = await this.app.container.resolve(ResourceRegistry);
434
- const resources = registry.all();
435
- if (resources.length === 0)
436
- return;
440
+ // NOT gated on the registry being non-empty any more. Providers start
441
+ // BEFORE preloads run, and a preload is where the docs say to write
442
+ // `station.register(...)`, so reading a snapshot here and giving up when
443
+ // it was empty meant an application that followed the documentation got
444
+ // no admin at all. The surface is prepared here and each resource is
445
+ // mounted as it is declared — the shape upstream's Transmit uses for the
446
+ // same ordering problem.
437
447
  // 54.7 — read the optional `station` config block. Defaults bake
438
448
  // in `requireAuth: true` when @c9up/warden is detected later in
439
449
  // Phase 1, so a host that just installs the peer gets the auth
@@ -445,58 +455,89 @@ export default class StationProvider {
445
455
  if (!peers)
446
456
  return;
447
457
  const { router, atlas, rune } = peers;
448
- // 57.7 fail-closed: a write-capable admin (create/edit) validates + guards
449
- // mass-assignment through a rune-derived schema. If atlas is present (CRUD
450
- // is live) but `@c9up/rune` is NOT installed, refuse to boot rather than
451
- // silently accept unvalidated bodies — there is NO fallback to the deleted
452
- // key-filter (`feedback_security_first`). Read-only admins need no rune.
453
- if (rune === null && resourcesNeedValidation(resources)) {
454
- throw new Error("[station] @c9up/rune is required to validate admin write forms (create/edit) but is not installed. Add @c9up/rune (a Station peer dependency) — Station will NOT fall back to unvalidated mass-assignment. See https://ream.dev/modules/station#mass-assignment.");
455
- }
456
458
  // Phase 1a2 — resolve the SHARED inker renderer (AdonisJS/Edge parity)
457
459
  // and mount Station's package templates as the `station` disk. Unlike
458
460
  // warden (whose absence keeps the open dev-preview path), a view engine
459
461
  // is a HARD render requirement (D2): once an admin surface exists there
460
- // is no page without it. Fail LOUD at boot when @c9up/inker is not wired
461
- // — mirrors #resolveDb's loud-on-missing posture — rather than degrade
462
- // silently or 500 on the first request.
463
- const renderer = await this.#resolveViewRenderer();
464
- // Mount once: `station::<template>` now resolves to this package's
465
- // templates/ dir on the host's single inker engine (edge.mount parity).
466
- renderer.mount("station", TEMPLATES_ROOT);
467
- this.#viewRenderer = renderer;
462
+ // is no page without it.
463
+ //
464
+ // The failure is HELD rather than thrown: with no resource declared
465
+ // there is no admin surface, and an application that installed the
466
+ // provider without ever registering a resource must not be forced to
467
+ // wire a view engine. It is rethrown on the first resource, which is the
468
+ // moment the requirement becomes real.
469
+ let rendererFailure;
470
+ try {
471
+ const renderer = await this.#resolveViewRenderer();
472
+ // Mount once: `station::<template>` now resolves to this package's
473
+ // templates/ dir on the host's single inker engine (edge.mount parity).
474
+ renderer.mount("station", TEMPLATES_ROOT);
475
+ this.#viewRenderer = renderer;
476
+ }
477
+ catch (err) {
478
+ rendererFailure = err;
479
+ }
468
480
  // Phase 1b — wire the warden auth gate (or warn-once when it stays open).
469
481
  await this.#configureAuth(userConfig);
470
- // Phase 2 — build per-resource context ONCE. `#resolveDb()` is
471
- // loud: if the host installed `@c9up/atlas` but didn't register
472
- // `@c9up/atlas/provider` in `reamrc.ts`, AC11's "surface
473
- // AtlasProvider misconfiguration" intent kicks in.
474
482
  const db = await this.#resolveDb();
475
- for (const resource of resources) {
483
+ // Phase 2 — mount every resource, now and as they arrive. The shell (the
484
+ // login surface, the `/admin` index) goes up with the first one: an
485
+ // application with no resources has no admin, and had none before.
486
+ let shellMounted = false;
487
+ registry.useMounter((resource) => {
488
+ if (!shellMounted) {
489
+ if (rendererFailure !== undefined)
490
+ throw rendererFailure;
491
+ try {
492
+ this.#registerShellRoutes(router);
493
+ }
494
+ catch (err) {
495
+ // The router proxy may still throw "Router accessed before
496
+ // initialization" on first property access (a boot-ordering
497
+ // hazard where the proxy module imported but Ignitor's
498
+ // `setRouter` never fired). That is a legitimate degraded-host
499
+ // shape — give up quietly. Anything else propagates.
500
+ if (isRouterProxyUninit(err))
501
+ return;
502
+ throw err;
503
+ }
504
+ shellMounted = true;
505
+ }
506
+ // 57.7 fail-closed: a write-capable admin (create/edit) validates +
507
+ // guards mass-assignment through a rune-derived schema. If atlas is
508
+ // present (CRUD is live) but `@c9up/rune` is NOT installed, refuse
509
+ // rather than silently accept unvalidated bodies — there is NO
510
+ // fallback to the deleted key-filter. Read-only admins need no rune.
511
+ // Checked per resource, so the refusal names the one at fault.
512
+ if (rune === null && resourcesNeedValidation([resource])) {
513
+ throw new Error(`[station] @c9up/rune is required to validate the write forms of the '${resource.name}' admin (create/edit) but is not installed. Add @c9up/rune (a Station peer dependency) — Station will NOT fall back to unvalidated mass-assignment. See https://ream.dev/modules/station#mass-assignment.`);
514
+ }
476
515
  this.#contexts.set(resource, buildResourceContext(resource, db, atlas, rune));
477
- }
478
- // 56.5 + 54.6 boot-time warn-onces. We surface the "auth wired but no
479
- // permissions seeded" and "no audit sink" gaps loud-and-once so a
480
- // half-wired install can't ship to prod without operators noticing.
481
- // (CSRF is no longer a boot-time warn — enforcement is request-time and
482
- // honest now: every write route fail-closes on `ctx.request.csrfProtected`.)
516
+ this.#registerResourceRoutes(router, resource);
517
+ });
518
+ this.#started = true;
519
+ }
520
+ /**
521
+ * The gaps worth reporting once, when the full set of resources is known.
522
+ *
523
+ * `ready()` runs after every preload, so this is the first moment the whole
524
+ * admin surface exists. Nothing is mounted here — the routes were built as
525
+ * each resource was declared.
526
+ */
527
+ async ready() {
528
+ if (!this.#started)
529
+ return;
530
+ const registry = await this.app.container.resolve(ResourceRegistry);
531
+ const resources = registry.all();
532
+ if (resources.length === 0)
533
+ return;
534
+ // 56.5 + 54.6 — "auth wired but no permissions seeded" and "no audit
535
+ // sink" are surfaced loud-and-once so a half-wired install cannot ship to
536
+ // prod without operators noticing. (CSRF is not a boot-time warn:
537
+ // enforcement is request-time, and every write route fail-closes on
538
+ // `ctx.request.csrfProtected`.)
483
539
  this.#warnSeedPermissionsOnce(resources);
484
540
  this.#warnAuditGapsOnce(resources);
485
- // Phase 3 — route registration. The router proxy may still throw
486
- // "Router accessed before initialization" on first property access
487
- // (boot ordering hazard where the proxy module imported but
488
- // Ignitor's `setRouter` never fired). That's another legitimate
489
- // degraded-host shape — silent return. Anything else (slug
490
- // collision, future validation) propagates.
491
- try {
492
- this.#registerAdminRoutes(router, resources);
493
- }
494
- catch (err) {
495
- if (isRouterProxyUninit(err))
496
- return;
497
- throw err;
498
- }
499
- this.#started = true;
500
541
  }
501
542
  /**
502
543
  * Phase 1 — resolve the host router from the container (Ream registers it as
@@ -525,7 +566,11 @@ export default class StationProvider {
525
566
  return { router, atlas, rune };
526
567
  }
527
568
  catch (err) {
528
- if (isModuleNotFound(err))
569
+ // ONLY when atlas itself is what is missing. An atlas that is
570
+ // installed but cannot load — a dependency of its own gone, a broken
571
+ // build — is not a host without atlas, and disabling the admin over
572
+ // it hides the actual fault.
573
+ if (isPackageMissing(err, "@c9up/atlas"))
529
574
  return null;
530
575
  throw err;
531
576
  }
@@ -541,7 +586,7 @@ export default class StationProvider {
541
586
  return loadBearingCast(await import("@c9up/rune"));
542
587
  }
543
588
  catch (err) {
544
- if (isModuleNotFound(err))
589
+ if (isPackageMissing(err, "@c9up/rune"))
545
590
  return null;
546
591
  throw err;
547
592
  }
@@ -706,7 +751,17 @@ export default class StationProvider {
706
751
  : ""));
707
752
  }
708
753
  /** Phase 3 — mount the login surface + per-resource CRUD routes. */
709
- #registerAdminRoutes(router, resources) {
754
+ /** Wrap a handler in the auth gate when one is configured. */
755
+ #gate(handler) {
756
+ return this.#authConfig.requireAuth ? this.#withAuth(handler) : handler;
757
+ }
758
+ /**
759
+ * The routes that belong to the admin itself rather than to a resource.
760
+ *
761
+ * Registered once, while the provider starts — before the HTTP server is
762
+ * listening, and before any resource has necessarily been declared.
763
+ */
764
+ #registerShellRoutes(router) {
710
765
  // 54.7 — mount login surface first when auth is required, so
711
766
  // `/admin/login` is reachable even when the auth gate redirects every
712
767
  // other path to it.
@@ -715,14 +770,23 @@ export default class StationProvider {
715
770
  router.post("/admin/login", this.#buildLoginPostHandler());
716
771
  router.post("/admin/logout", this.#buildLogoutHandler());
717
772
  }
718
- const gate = (handler) => this.#authConfig.requireAuth ? this.#withAuth(handler) : handler;
719
- // `/admin` index — send the operator to the first listable resource (or the
720
- // login surface). Without it, the post-login redirect("/admin") lands on a 404.
721
- router.get("/admin", gate(async (ctx) => {
722
- const home = resources.find((r) => r.actions.includes("list"));
773
+ // `/admin` index — send the operator to the first listable resource (or
774
+ // the login surface). Without it, the post-login redirect("/admin") lands
775
+ // on a 404.
776
+ //
777
+ // The registry is read WHEN THE REQUEST ARRIVES, not when this route is
778
+ // built: resources are declared in preloads, so a list captured here
779
+ // would name whatever happened to exist first — or nothing at all.
780
+ router.get("/admin", this.#gate(async (ctx) => {
781
+ const registry = await this.app.container.resolve(ResourceRegistry);
782
+ const home = registry.all().find((r) => r.actions.includes("list"));
723
783
  ctx.response.redirect(home ? `/admin/${home.name}` : "/admin/login");
724
784
  }));
725
- for (const resource of resources) {
785
+ }
786
+ /** Every route one resource contributes, mounted the moment it is declared. */
787
+ #registerResourceRoutes(router, resource) {
788
+ const gate = (handler) => this.#gate(handler);
789
+ {
726
790
  const slug = resource.name;
727
791
  if (resource.actions.includes("list")) {
728
792
  router.get(`/admin/${slug}`, gate(this.#buildListHandler(resource)));
@@ -784,8 +848,15 @@ export default class StationProvider {
784
848
  missingAuditWarned = true;
785
849
  console.warn(`[station] No audit sink configured for write-enabled resources: ${missing.map((r) => r.name).join(", ")}. Pass 'audit:' in defineResource() to persist mutations to your audit log.`);
786
850
  }
787
- async ready() { }
788
- async shutdown() { }
851
+ async shutdown() {
852
+ // Release the module-level singleton, while it is still ours. A stopped
853
+ // application left a dead resource registry reachable through `services/main`, and
854
+ // with two applications in one process the survivor's binding must not
855
+ // be the one cleared.
856
+ if (this.#owned !== undefined && getStation() === this.#owned)
857
+ clearStation();
858
+ this.#owned = undefined;
859
+ }
789
860
  #requireContext(resource) {
790
861
  const ctx = this.#contexts.get(resource);
791
862
  if (!ctx) {
@@ -810,7 +881,7 @@ export default class StationProvider {
810
881
  * Fail-closed response for invalid write input (57.7, AC4). Content-negotiated:
811
882
  *
812
883
  * - **JSON/XHR** → `422` with `{ error, code: "E_VALIDATION_ERROR", messages }`
813
- * (Adonis/Vine parity — API clients get the machine-readable errors).
884
+ * (Adonis parity — API clients get the machine-readable errors).
814
885
  * - **HTML with a session** → the AdonisJS web idiom (PRG): flash the old
815
886
  * input + per-field errors, then `redirect().back()` to the form `GET`,
816
887
  * which re-reads the flash and re-renders with values + errors. No inline
@@ -826,7 +897,7 @@ export default class StationProvider {
826
897
  async #denyValidation(ctx, resource, submitted, errors, formUrl, row) {
827
898
  if (wantsJsonResponse(ctx)) {
828
899
  ctx.response.status(422);
829
- // AdonisJS/VineJS parity: `{ errors: [{ field, rule, message }] }` —
900
+ // AdonisJS parity: `{ errors: [{ field, rule, message }] }` —
830
901
  // the same shape ream's own `E_VALIDATION_ERROR` handler emits.
831
902
  ctx.response.json({
832
903
  errors: errors.map((e) => ({
@@ -1518,6 +1589,33 @@ export function isModuleNotFound(err) {
1518
1589
  const { code } = err;
1519
1590
  return code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND";
1520
1591
  }
1592
+ /**
1593
+ * Is THIS package the one that is missing?
1594
+ *
1595
+ * The code alone does not say. `@c9up/atlas` installed but missing one of its
1596
+ * OWN dependencies raises the same `ERR_MODULE_NOT_FOUND`, and station read
1597
+ * that as "atlas is absent" and disabled the admin — silently, which is the
1598
+ * whole point of the degraded-host path and exactly wrong here: the peer IS
1599
+ * there and something inside it is broken, which the operator has to be told.
1600
+ *
1601
+ * Node distinguishes the two in the message it writes:
1602
+ *
1603
+ * Cannot find package '@c9up/atlas' imported from … ← the peer is absent
1604
+ * Cannot find module '/…/node_modules/@c9up/atlas/…' ← something inside it
1605
+ *
1606
+ * A bare-specifier miss names the PACKAGE; a miss inside an installed package
1607
+ * names a path. Anything that is not unmistakably the first is re-thrown.
1608
+ */
1609
+ export function isPackageMissing(err, specifier) {
1610
+ if (!isModuleNotFound(err))
1611
+ return false;
1612
+ const message = err instanceof Error ? err.message : String(err);
1613
+ // The exact forms Node emits for a bare specifier that resolves to nothing.
1614
+ return (message.includes(`Cannot find package '${specifier}'`) ||
1615
+ message.includes(`Cannot find module '${specifier}'`) ||
1616
+ // The CJS wording, kept for a host that transpiles.
1617
+ message.includes(`Cannot find module "${specifier}"`));
1618
+ }
1521
1619
  /** Ream's router proxy throws this exact string before Ignitor wires it. */
1522
1620
  function isRouterProxyUninit(err) {
1523
1621
  return (err instanceof Error &&