@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.
- package/dist/ResourceRegistry.d.ts +19 -0
- package/dist/ResourceRegistry.d.ts.map +1 -1
- package/dist/ResourceRegistry.js +14 -7
- package/dist/ResourceRegistry.js.map +1 -1
- package/dist/StationProvider.d.ts +33 -5
- package/dist/StationProvider.d.ts.map +1 -1
- package/dist/StationProvider.js +164 -66
- package/dist/StationProvider.js.map +1 -1
- package/dist/services/main.d.ts +9 -0
- package/dist/services/main.d.ts.map +1 -1
- package/dist/services/main.js +11 -0
- package/dist/services/main.js.map +1 -1
- package/dist/vendor/nodeEnv.d.ts +5 -0
- package/dist/vendor/nodeEnv.d.ts.map +1 -0
- package/dist/{nodeEnv.js → vendor/nodeEnv.js} +13 -7
- package/dist/vendor/nodeEnv.js.map +1 -0
- package/package.json +11 -11
- package/src/ResourceRegistry.ts +26 -0
- package/src/StationProvider.ts +174 -77
- package/src/services/main.ts +12 -0
- package/src/{nodeEnv.ts → vendor/nodeEnv.ts} +14 -7
- package/dist/nodeEnv.d.ts +0 -16
- package/dist/nodeEnv.d.ts.map +0 -1
- package/dist/nodeEnv.js.map +0 -1
|
@@ -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;;
|
|
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"}
|
package/dist/ResourceRegistry.js
CHANGED
|
@@ -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":"
|
|
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
|
|
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
|
|
145
|
-
*
|
|
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
|
|
148
|
-
*
|
|
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":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,oBAAoB,CAAC;AAE5B,OAAO,KAAK,EAEX,cAAc,EAGd,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"StationProvider.d.ts","sourceRoot":"","sources":["../src/StationProvider.ts"],"names":[],"mappings":"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"}
|
package/dist/StationProvider.js
CHANGED
|
@@ -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
|
|
133
|
-
*
|
|
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
|
|
136
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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.
|
|
461
|
-
//
|
|
462
|
-
//
|
|
463
|
-
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
719
|
-
//
|
|
720
|
-
//
|
|
721
|
-
|
|
722
|
-
|
|
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
|
-
|
|
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
|
|
788
|
-
|
|
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
|
|
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
|
|
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 &&
|