@c9up/station 0.1.6 → 0.1.8

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.
Files changed (46) hide show
  1. package/dist/StationProvider.d.ts +94 -18
  2. package/dist/StationProvider.d.ts.map +1 -1
  3. package/dist/StationProvider.js +693 -148
  4. package/dist/StationProvider.js.map +1 -1
  5. package/dist/views/form.d.ts +56 -16
  6. package/dist/views/form.d.ts.map +1 -1
  7. package/dist/views/form.js +53 -75
  8. package/dist/views/form.js.map +1 -1
  9. package/dist/views/list.d.ts +43 -5
  10. package/dist/views/list.d.ts.map +1 -1
  11. package/dist/views/list.js +63 -60
  12. package/dist/views/list.js.map +1 -1
  13. package/dist/views/login.d.ts +20 -8
  14. package/dist/views/login.d.ts.map +1 -1
  15. package/dist/views/login.js +15 -33
  16. package/dist/views/login.js.map +1 -1
  17. package/dist/views/show.d.ts +18 -4
  18. package/dist/views/show.d.ts.map +1 -1
  19. package/dist/views/show.js +29 -15
  20. package/dist/views/show.js.map +1 -1
  21. package/package.json +22 -5
  22. package/src/StationProvider.ts +941 -155
  23. package/src/views/form.ts +111 -113
  24. package/src/views/list.ts +108 -75
  25. package/src/views/login.ts +31 -44
  26. package/src/views/show.ts +47 -18
  27. package/templates/errors/404.inker +4 -0
  28. package/templates/form.inker +3 -0
  29. package/{dist/views/layout.js → templates/layout.inker} +10 -35
  30. package/templates/list.inker +8 -0
  31. package/templates/login.inker +4 -0
  32. package/templates/show.inker +4 -0
  33. package/dist/views/errors/404.d.ts +0 -12
  34. package/dist/views/errors/404.d.ts.map +0 -1
  35. package/dist/views/errors/404.js +0 -19
  36. package/dist/views/errors/404.js.map +0 -1
  37. package/dist/views/escape.d.ts +0 -30
  38. package/dist/views/escape.d.ts.map +0 -1
  39. package/dist/views/escape.js +0 -34
  40. package/dist/views/escape.js.map +0 -1
  41. package/dist/views/layout.d.ts +0 -24
  42. package/dist/views/layout.d.ts.map +0 -1
  43. package/dist/views/layout.js.map +0 -1
  44. package/src/views/errors/404.ts +0 -27
  45. package/src/views/escape.ts +0 -46
  46. package/src/views/layout.ts +0 -90
@@ -18,6 +18,7 @@
18
18
  * swallowed.
19
19
  */
20
20
 
21
+ import { fileURLToPath } from "node:url";
21
22
  import type {
22
23
  BaseRepository as AtlasBaseRepository,
23
24
  ColumnMetadata,
@@ -29,11 +30,10 @@ import { setStation } from "./services/main.js";
29
30
  import type { AuditEvent, Resource, ResourceAction } from "./types.js";
30
31
  // note: AuditEvent + ResourceAction are used by the CRUD handlers below;
31
32
  // the imports stay in one block for clarity.
32
- import { renderNotFoundPage } from "./views/errors/404.js";
33
- import { renderFormPage } from "./views/form.js";
34
- import { renderListPage } from "./views/list.js";
35
- import { renderLoginPage } from "./views/login.js";
36
- import { renderShowPage } from "./views/show.js";
33
+ import { buildFormViewModel } from "./views/form.js";
34
+ import { buildListViewModel } from "./views/list.js";
35
+ import { buildLoginViewModel } from "./views/login.js";
36
+ import { buildShowViewModel } from "./views/show.js";
37
37
 
38
38
  /**
39
39
  * Duck-typed slice of the host's IoC container — Station MUST stay
@@ -42,7 +42,7 @@ import { renderShowPage } from "./views/show.js";
42
42
  */
43
43
  interface StationContainer {
44
44
  singleton<T>(key: unknown, factory: () => T): void;
45
- resolve<T>(key: unknown): T;
45
+ resolve<T>(key: unknown): Promise<T>;
46
46
  has(key: unknown): boolean;
47
47
  }
48
48
 
@@ -70,6 +70,14 @@ interface StationHttpContext {
70
70
  url?(): string;
71
71
  header?(name: string): string | undefined;
72
72
  cookie?(name: string): string | undefined;
73
+ /**
74
+ * `true` only when the host's `@c9up/blackhole` middleware enforced +
75
+ * validated CSRF for this request (a first-class ream `HttpContext`
76
+ * contract). Station fail-closes every admin write route on this — a
77
+ * seeded token is NOT proof of verification, so this is the ONLY signal
78
+ * trusted for enforcement.
79
+ */
80
+ csrfProtected?: boolean;
73
81
  };
74
82
  response: {
75
83
  status(code: number): unknown;
@@ -107,6 +115,23 @@ interface StationHttpContext {
107
115
  get(key: string): unknown;
108
116
  set?(key: string, value: unknown): void;
109
117
  };
118
+ /**
119
+ * The request session, when the host registered ream's `SessionMiddleware`
120
+ * (`ctx.session`, AdonisJS parity). Station uses only the flash surface (57.7):
121
+ * on an invalid HTML write it flashes the old input + errors and redirects
122
+ * back to the form, which reads them back via `flashMessages`. Undefined
123
+ * when no session middleware ran — Station then falls back to an inline 422
124
+ * form re-render (no session ⇒ no flash round-trip possible).
125
+ */
126
+ session?: {
127
+ flash(key: string, value: unknown): void;
128
+ flashAll(input: Record<string, unknown>): void;
129
+ /**
130
+ * A store, not a method — the AdonisJS shape ream now matches. Only
131
+ * `all()` is needed here, so that is all the structural type asks for.
132
+ */
133
+ flashMessages: { all(): Record<string, unknown> };
134
+ };
110
135
  }
111
136
 
112
137
  interface StationRouter {
@@ -150,9 +175,24 @@ interface StationRepository {
150
175
 
151
176
  interface StationQuery {
152
177
  orderBy(column: string, direction: "asc" | "desc"): StationQuery;
153
- forPage(page: number, perPage: number): StationQuery;
154
178
  exec(): Promise<Record<string, unknown>[]>;
155
- count(column?: string): Promise<number>;
179
+ paginate(page: number, perPage: number): Promise<StationPaginator>;
180
+ }
181
+
182
+ /**
183
+ * Minimal structural mirror of atlas's `Paginator` — only the surface the list
184
+ * handler reads. Kept structural (like `StationQuery`/`StationRepository`) so
185
+ * Station never couples to atlas's generic `Paginator<T>` value/type.
186
+ */
187
+ interface StationPaginator {
188
+ all(): Record<string, unknown>[];
189
+ meta: {
190
+ total: number;
191
+ perPage: number;
192
+ currentPage: number;
193
+ lastPage: number;
194
+ firstPage: number;
195
+ };
156
196
  }
157
197
 
158
198
  /** Per-resource snapshot built once at `start()`, reused on every request. */
@@ -167,6 +207,18 @@ interface ResourceContext {
167
207
  * the mass-assignment guard drops them regardless of their column name.
168
208
  */
169
209
  autoManaged: ReadonlySet<string>;
210
+ /**
211
+ * The `@c9up/rune` validation schema derived once (57.7) from the entity's
212
+ * writable column metadata — replaces the hand-rolled `filterWritableBody`.
213
+ * `validateResult()` both drops non-schema keys (the mass-assignment guarantee is
214
+ * now inherent — `result.data` holds only declared columns) AND type-checks
215
+ * each field. Built only when a write action is mounted (create/edit); a
216
+ * read-only resource leaves it `undefined`. `booleanKeys` names the checkbox
217
+ * columns the handler must default to `false` when their key is absent (an
218
+ * unchecked box submits nothing).
219
+ */
220
+ writableSchema?: RuneSchema;
221
+ booleanKeys: ReadonlySet<string>;
170
222
  }
171
223
 
172
224
  /** Lazy-imported `@c9up/atlas` value surface. */
@@ -175,6 +227,117 @@ interface AtlasModule {
175
227
  getColumnMetadata: (entity: unknown) => ReadonlyArray<ColumnMetadata>;
176
228
  getPrimaryKey: (entity: unknown) => string | undefined;
177
229
  getDateColumnConfig: (entity: unknown) => Record<string, DateColumnConfig>;
230
+ /** Relation propertyKeys — excluded from the writable schema (never columns). */
231
+ getRelationMetadata: (
232
+ entity: unknown,
233
+ ) => ReadonlyArray<{ propertyKey: string }>;
234
+ /** `@SoftDeletes()` present ⇒ the `deletedAt` column is framework-owned. */
235
+ hasSoftDeletes: (entity: unknown) => boolean;
236
+ }
237
+
238
+ // ── @c9up/rune structural surface (57.7) ───────────────────────────────────
239
+ // Station consumes rune EXACTLY like atlas: a tolerant dynamic
240
+ // `import("@c9up/rune")` (never a static value import — banned by
241
+ // `tests/unit/no-rune-static-import.test.ts`). The runtime object is typed by
242
+ // these LOCAL structural interfaces (like `AtlasModule` / `StationRepository`),
243
+ // so Station couples to rune's *shape*, not its generic types — zero
244
+ // `import ... from "@c9up/rune"`, `import type` included, keeps the schema
245
+ // derivation tsc-safe and fully decoupled.
246
+
247
+ /** A rune `RuleChain` — only the builders the derivation calls. */
248
+ interface RuneRuleChain {
249
+ optional(): RuneRuleChain;
250
+ nullable(): RuneRuleChain;
251
+ parse(fn: (value: unknown) => unknown): RuneRuleChain;
252
+ }
253
+
254
+ /** rune's `rules` factory — the constructors Station maps columns to. */
255
+ interface RuneRules {
256
+ string(): RuneRuleChain;
257
+ number(): RuneRuleChain;
258
+ boolean(): RuneRuleChain;
259
+ /** Type-free chain — a structural (`json`/`jsonb`) column that must accept objects/arrays. */
260
+ any(): RuneRuleChain;
261
+ }
262
+
263
+ /** A single rune `ValidationError`. */
264
+ interface RuneValidationError {
265
+ field: string;
266
+ rule: string;
267
+ message: string;
268
+ index?: number;
269
+ meta?: Record<string, unknown>;
270
+ }
271
+
272
+ /** Result of `schema.validateResult()` — synchronous, never throws. */
273
+ type RuneValidationResult =
274
+ | {
275
+ valid: true;
276
+ errors: ReadonlyArray<RuneValidationError>;
277
+ data: Record<string, unknown>;
278
+ }
279
+ | {
280
+ valid: false;
281
+ errors: ReadonlyArray<RuneValidationError>;
282
+ data?: undefined;
283
+ };
284
+
285
+ /**
286
+ * A built rune schema — only the result-based check is consumed.
287
+ *
288
+ * `validateResult`, NOT `validate`: rune reserves `validate()` for the VineJS
289
+ * contract (async, throwing). Reading `.valid` off a Promise gives `undefined`,
290
+ * so keeping the old name would have made every admin write fail closed with an
291
+ * empty error list.
292
+ */
293
+ interface RuneSchema {
294
+ validateResult(data: unknown): RuneValidationResult;
295
+ }
296
+
297
+ /** Lazy-imported `@c9up/rune` value surface (mirror `AtlasModule`). */
298
+ interface RuneModule {
299
+ schema(fields: Record<string, RuneRuleChain>): RuneSchema;
300
+ rules: RuneRules;
301
+ }
302
+
303
+ /**
304
+ * Minimal slice of the host's SHARED inker renderer — the `"inker"` container
305
+ * alias `InkerProvider` binds. Station consumes inker EXACTLY the way it
306
+ * consumes `@c9up/warden`: resolved through the container, never a static OR
307
+ * dynamic `import "@c9up/inker"` in `src/` (57.1/D5). Following the AdonisJS
308
+ * package-views pattern (Edge `edge.mount(name, dir)` + `namespace::template`),
309
+ * Station mounts its package `templates/` as a named disk (`mount("station",
310
+ * dir)`) on this shared renderer and renders `station::…`. `renderToString`'s
311
+ * `ctx` carries the per-request store the inker helpers read: the form + login
312
+ * views thread the real `ctx.store` (57.3/D3) so `{{ csrfField() }}` reaches the
313
+ * token; the 404/list/show views call no helpers, so the store is inert there.
314
+ */
315
+ interface InkerViewRenderer {
316
+ mount(diskName: string, dir: string): void;
317
+ renderToString(
318
+ ctx: object,
319
+ name: string,
320
+ data: Readonly<Record<string, unknown>>,
321
+ ): Promise<string>;
322
+ }
323
+
324
+ /**
325
+ * Runtime shape check for the resolved `"inker"` binding. `container.has("inker")`
326
+ * only proves the token is registered — a null/partial/mis-registered value would
327
+ * otherwise slip through `#resolveViewRenderer` and blow up as a raw `TypeError`
328
+ * at `renderer.mount(...)` during boot, defeating the actionable fail-loud gate
329
+ * (57.1 review). Guarding here turns a null/non-conforming binding into the same
330
+ * clear "not usable" boot error, with the specific reason chained as `cause`.
331
+ */
332
+ function isInkerViewRenderer(value: unknown): value is InkerViewRenderer {
333
+ return (
334
+ typeof value === "object" &&
335
+ value !== null &&
336
+ "mount" in value &&
337
+ typeof value.mount === "function" &&
338
+ "renderToString" in value &&
339
+ typeof value.renderToString === "function"
340
+ );
178
341
  }
179
342
 
180
343
  /**
@@ -296,7 +459,7 @@ let authWarnEmitted = false;
296
459
  let perPageClampWarned = false;
297
460
  let seedPermsWarned = false;
298
461
  let missingAuditWarned = false;
299
- let csrfWarnEmitted = false;
462
+ let csrfBlockedWarned = false;
300
463
 
301
464
  /** @internal Reset module-level flags between tests. */
302
465
  export function resetStationProviderFlags(): void {
@@ -304,9 +467,28 @@ export function resetStationProviderFlags(): void {
304
467
  perPageClampWarned = false;
305
468
  seedPermsWarned = false;
306
469
  missingAuditWarned = false;
307
- csrfWarnEmitted = false;
470
+ csrfBlockedWarned = false;
471
+ }
472
+
473
+ /**
474
+ * Stable fingerprint for a failed `station::errors/404` render, used to log each
475
+ * distinct failure MODE once per provider instance rather than once per request.
476
+ * Keys on the error `code`/`name` ONLY — never the message, which can embed the
477
+ * request-controlled `id` and would let attacker traffic inflate the log past
478
+ * the very bound this mechanism exists to hold.
479
+ */
480
+ function notFoundFailFingerprint(cause: unknown): string {
481
+ if (!(cause instanceof Error)) return "non-error";
482
+ if ("code" in cause && typeof cause.code === "string") return cause.code;
483
+ return cause.name;
308
484
  }
309
485
 
486
+ /**
487
+ * Hard cap on distinct 404-render failure modes logged per instance, so an
488
+ * unforeseen unbounded error-code space still cannot inflate the log or memory.
489
+ */
490
+ const NOT_FOUND_RENDER_FAIL_CAP = 32;
491
+
310
492
  const TIMESTAMP_PROPERTY_KEYS: ReadonlySet<string> = new Set([
311
493
  "createdAt",
312
494
  "updatedAt",
@@ -319,33 +501,70 @@ const TIMESTAMP_COLUMN_NAMES: ReadonlySet<string> = new Set([
319
501
  ]);
320
502
 
321
503
  /**
322
- * Mass-assignment guard. Only accepts keys that are:
323
- * 1. A declared `@Column` propertyKey on the resource entity, AND
324
- * 2. Not the primary key (DB-managed), AND
325
- * 3. Not a framework-managed timestamp. Two signals, both honoured:
326
- * (a) the Lucid-style auto flag — `@column.dateTime({ autoCreate })` /
327
- * `{ autoUpdate }` (passed in via `autoManaged`); this is the
328
- * authoritative one and catches custom-named timestamp columns
329
- * (e.g. `registeredAt`) the name list below would miss, AND
330
- * (b) the conventional name fallback (created_at / updated_at /
331
- * deleted_at) for plain `@Column` timestamps declared without the
332
- * auto flag.
504
+ * Column `type` strings mapped to a numeric rune rule (mirrors form.ts input
505
+ * inference). `bigint` is deliberately EXCLUDED: coercing it through `Number()`
506
+ * silently loses precision above `Number.MAX_SAFE_INTEGER`, so a bigint column
507
+ * validates as a string (its exact submitted digits reach atlas intact).
508
+ */
509
+ const NUMBER_COLUMN_TYPES: ReadonlySet<string> = new Set(["integer", "number"]);
510
+
511
+ /**
512
+ * Structural column `type` strings — validated with `rules.any()` so a JSON/XHR
513
+ * client can submit an object/array (a `rules.string()` would reject it, a
514
+ * regression vs. the pre-57.7 pass-through `filterWritableBody`).
515
+ */
516
+ const JSON_COLUMN_TYPES: ReadonlySet<string> = new Set(["json", "jsonb"]);
517
+
518
+ /**
519
+ * True when at least one resource mounts a write action that maps a request
520
+ * body onto columns (create / edit). `destroy` needs no body validation. This
521
+ * is the AC7 predicate: it decides whether `@c9up/rune` is REQUIRED at boot —
522
+ * exported so `agnostic-peers-missing.test.ts` can pin the decision directly
523
+ * (the dynamic-import-absent path can't be simulated inside vitest, exactly as
524
+ * for atlas — see that file's header).
525
+ */
526
+ export function resourcesNeedValidation(
527
+ resources: ReadonlyArray<Resource>,
528
+ ): boolean {
529
+ return resources.some(
530
+ (r) => r.actions.includes("create") || r.actions.includes("edit"),
531
+ );
532
+ }
533
+
534
+ /**
535
+ * Derive a `@c9up/rune` validation schema (57.7) from an entity's writable
536
+ * column metadata — the structural replacement for the hand-rolled
537
+ * `filterWritableBody`. The mass-assignment guarantee is now INHERENT:
538
+ * `schema.validateResult()` returns only declared-column keys, so unknown body keys
539
+ * (`role`, `passwordHash`, PK, timestamps) are dropped automatically — no
540
+ * explicit filtering pass.
333
541
  *
334
- * The `_method` synthetic field from browser-form method-overrides is
335
- * dropped automatically because it never matches a column propertyKey.
542
+ * Excluded, exactly as the old guard did: the primary key, framework-managed
543
+ * timestamps (both the Lucid-style `autoManaged` flag AND the `created_at /
544
+ * updated_at / deleted_at` name convention) — plus, new in 57.7, relations and
545
+ * the soft-delete column (`excludedProps`). Metadata is read column-by-column,
546
+ * NEVER `in` on an instance (`@Column() declare` makes `in` always false —
547
+ * mirror `BaseRepository.#hydrate`).
336
548
  *
337
- * Returning a fresh object — never the caller's reference — so a
338
- * downstream mutation can't poison the audit snapshot.
549
+ * Per-column rule mapping tolerates the form-post wire shape (values arrive as
550
+ * strings — rune does NOT coerce): booleans `.parse(isCheckedValue)`, numbers
551
+ * `.parse` string→Number (NaN falls through to a type error), everything else
552
+ * `rules.string()`. `.parse`/`.optional`/`.nullable` force rune's TS path, so
553
+ * no native `.node` is touched.
554
+ *
555
+ * Returns the built schema plus the set of boolean columns — the handler
556
+ * default-fills an ABSENT boolean key to `false` before validating (an
557
+ * unchecked checkbox submits nothing; this preserves the old edit-clears-box
558
+ * behaviour).
339
559
  */
340
- /** @internal Exported for unit tests — the mass-assignment + checkbox coercion guard. */
341
- export function filterWritableBody(
342
- body: Record<string, unknown>,
560
+ export function deriveWritableSchema(
343
561
  columns: ReadonlyArray<ColumnMetadata>,
344
562
  pkColumn: string,
345
563
  autoManaged: ReadonlySet<string>,
346
- ): Record<string, unknown> {
347
- const writable: Record<string, unknown> = {};
348
- const validKeys = new Set<string>();
564
+ excludedProps: ReadonlySet<string>,
565
+ rune: RuneModule,
566
+ ): { schema: RuneSchema; booleanKeys: ReadonlySet<string> } {
567
+ const fields: Record<string, RuneRuleChain> = {};
349
568
  const booleanKeys = new Set<string>();
350
569
  for (const c of columns) {
351
570
  if (c.propertyKey === pkColumn) continue;
@@ -353,28 +572,208 @@ export function filterWritableBody(
353
572
  if (TIMESTAMP_PROPERTY_KEYS.has(c.propertyKey)) continue;
354
573
  const snake = c.propertyKey.replace(/([A-Z])/g, "_$1").toLowerCase();
355
574
  if (TIMESTAMP_COLUMN_NAMES.has(snake)) continue;
356
- validKeys.add(c.propertyKey);
357
- if ((c.type ?? "").toString().toLowerCase() === "boolean") {
575
+ if (excludedProps.has(c.propertyKey)) continue;
576
+ const type = (c.type ?? "").toString().toLowerCase();
577
+ if (type === "boolean") {
578
+ // Checkbox: the handler pre-fills an absent key to `false`, so every
579
+ // boolean is always present here → required, no `.optional()`.
358
580
  booleanKeys.add(c.propertyKey);
581
+ fields[c.propertyKey] = rune.rules.boolean().parse(isCheckedValue);
582
+ continue;
583
+ }
584
+ // A column is required iff the model declares neither nullability nor a
585
+ // default. DB-side defaults (serial, `DEFAULT`) are invisible to metadata
586
+ // (documented residual limitation — see story Dev Notes); the PK, the
587
+ // common serial case, is already excluded above.
588
+ const required = c.nullable !== true && c.default === undefined;
589
+ let chain: RuneRuleChain = NUMBER_COLUMN_TYPES.has(type)
590
+ ? rune.rules.number().parse(parseNumeric)
591
+ : JSON_COLUMN_TYPES.has(type)
592
+ ? rune.rules.any()
593
+ : rune.rules.string();
594
+ if (!required) {
595
+ chain = chain.optional();
596
+ if (c.nullable === true) chain = chain.nullable();
359
597
  }
598
+ fields[c.propertyKey] = chain;
360
599
  }
361
- // Boolean columns render as checkboxes: an unchecked box submits NOTHING, so
362
- // derive every boolean from presence/value. Otherwise an unchecked box can
363
- // never clear a previously-true column on edit, and a checked box would store
364
- // the raw string "1" instead of a real boolean.
600
+ return { schema: rune.schema(fields), booleanKeys };
601
+ }
602
+
603
+ /**
604
+ * Validate a request body against a derived schema (57.7). Default-fills any
605
+ * ABSENT boolean column to `false` first (unchecked checkbox = no submission),
606
+ * preserving the old `filterWritableBody` checkbox semantics, then runs rune's
607
+ * synchronous, never-throwing `validateResult()`. On success `result.data` holds only
608
+ * declared-column keys (mass-assignment inherent). Exported for the unit tests
609
+ * that pin the coercion CRUX.
610
+ */
611
+ export function validateWritableBody(
612
+ schema: RuneSchema,
613
+ booleanKeys: ReadonlySet<string>,
614
+ body: Record<string, unknown>,
615
+ ): RuneValidationResult {
616
+ const input: Record<string, unknown> = { ...body };
365
617
  for (const key of booleanKeys) {
366
- writable[key] = isCheckedValue(body[key]);
618
+ if (!(key in input)) input[key] = false;
367
619
  }
368
- for (const [key, value] of Object.entries(body)) {
369
- if (!validKeys.has(key)) continue;
370
- if (booleanKeys.has(key)) continue; // already derived above
371
- writable[key] = value;
620
+ return schema.validateResult(input);
621
+ }
622
+
623
+ /**
624
+ * Form-post number coercion: string→Number, NaN falls through to a type error.
625
+ * `null`/`undefined` pass through untouched so rune's optional/nullable handling
626
+ * applies (never `Number(null) === 0`), and a blank/whitespace-only string —
627
+ * a cleared optional field — becomes `null` (treated as absent) rather than a
628
+ * fabricated `0`.
629
+ */
630
+ function parseNumeric(value: unknown): unknown {
631
+ if (value === null || value === undefined) return value;
632
+ if (typeof value === "string") {
633
+ const trimmed = value.trim();
634
+ if (trimmed === "") return null;
635
+ const n = Number(trimmed);
636
+ return Number.isNaN(n) ? value : n;
637
+ }
638
+ const n = Number(value);
639
+ return Number.isNaN(n) ? value : n;
640
+ }
641
+
642
+ /**
643
+ * Collapse rune's flat `ValidationError[]` to one message per top-level field
644
+ * for the HTML form re-render (`form.inker` keys errors by column propertyKey).
645
+ * The `_root` non-object error (a body that isn't a record — never happens for
646
+ * a parsed form post) is skipped.
647
+ */
648
+ function fieldErrors(
649
+ errors: ReadonlyArray<RuneValidationError>,
650
+ ): Record<string, string> {
651
+ const out: Record<string, string> = {};
652
+ for (const e of errors) {
653
+ if (e.field === "_root") continue;
654
+ // Collapse a nested VineJS-style path (`profile.name`) to its top-level
655
+ // key — `form.ts` keys errors by column propertyKey. `split` always yields
656
+ // a non-empty array, so `[0]` is a string (no fallback needed).
657
+ const key = e.field.split(".")[0];
658
+ if (!(key in out)) out[key] = e.message;
372
659
  }
373
- return writable;
660
+ return out;
374
661
  }
375
662
 
376
- /** A checkbox/boolean field is true only for its checked submissions. */
663
+ /** Session flash key under which the per-field validation errors are stashed (AdonisJS `flashMessages.errors` parity). */
664
+ const FLASH_ERRORS_KEY = "errors";
665
+
666
+ /** Column propertyKeys whose values are secrets — never flashed to the session nor echoed back into HTML (AdonisJS parity). */
667
+ const SENSITIVE_KEY_RE = /password|passwd|secret|token|apikey/i;
668
+
669
+ /**
670
+ * The subset of a submitted body safe to re-surface on a validation error:
671
+ * declared columns only — so `_csrf`, `_method`, and any mass-assignment /
672
+ * arbitrary key are dropped from the flash store — minus sensitive fields (a
673
+ * password is never written to the session nor reflected into the re-rendered
674
+ * form). An empty result lets the caller fall back to the existing `row`.
675
+ */
676
+ function flashableInput(
677
+ submitted: Record<string, unknown>,
678
+ columns: ReadonlyArray<ColumnMetadata>,
679
+ ): Record<string, unknown> {
680
+ const out: Record<string, unknown> = {};
681
+ for (const c of columns) {
682
+ if (SENSITIVE_KEY_RE.test(c.propertyKey)) continue;
683
+ if (c.propertyKey in submitted)
684
+ out[c.propertyKey] = submitted[c.propertyKey];
685
+ }
686
+ return out;
687
+ }
688
+
689
+ function isStringRecord(value: unknown): value is Record<string, string> {
690
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
691
+ return false;
692
+ }
693
+ return Object.values(value).every((v) => typeof v === "string");
694
+ }
695
+
696
+ /**
697
+ * Read flashed old input + per-field errors for a form `GET` re-render after a
698
+ * failed write redirected back (57.7, AdonisJS PRG). Returns `{}` when nothing
699
+ * was flashed (a normal GET) — the form then renders from `row`/empty as usual.
700
+ * `values` is restricted to declared column keys so the flashed `errors` bag (or
701
+ * any other flashed key) never leaks into a field value.
702
+ */
703
+ function readFlash(
704
+ ctx: StationHttpContext,
705
+ columns: ReadonlyArray<ColumnMetadata>,
706
+ ): {
707
+ values?: Record<string, unknown>;
708
+ errors?: Record<string, string>;
709
+ } {
710
+ const flashed = ctx.session?.flashMessages.all() ?? {};
711
+ if (Object.keys(flashed).length === 0) return {};
712
+ const values: Record<string, unknown> = {};
713
+ for (const c of columns) {
714
+ // `errors` is reserved for the per-field error bag — a column literally
715
+ // named `errors` must not receive that object as its field value.
716
+ if (c.propertyKey === FLASH_ERRORS_KEY) continue;
717
+ if (c.propertyKey in flashed)
718
+ values[c.propertyKey] = flashed[c.propertyKey];
719
+ }
720
+ const rawErrors = flashed[FLASH_ERRORS_KEY];
721
+ return {
722
+ values: Object.keys(values).length > 0 ? values : undefined,
723
+ errors: isStringRecord(rawErrors) ? rawErrors : undefined,
724
+ };
725
+ }
726
+
727
+ /**
728
+ * Same-origin / relative check for a `Referer` — replicates ream's
729
+ * `RedirectBuilder.back()` open-redirect guard (Station consumes the response
730
+ * through its own `redirect(url)` seam and can't reach ream's builder). A
731
+ * relative path is trusted; an absolute URL only when its origin matches the
732
+ * request URL. Anything else is untrusted → the caller uses its safe fallback.
733
+ */
734
+ function isSameOriginReferer(referer: string, requestUrl?: string): boolean {
735
+ // A relative path is trusted only when it starts with a single "/" and
736
+ // contains no backslash: browsers normalise "\\" to "/", so "/\\evil.com"
737
+ // becomes the protocol-relative "//evil.com" that a lone `!startsWith("//")`
738
+ // check would let through.
739
+ if (
740
+ referer.startsWith("/") &&
741
+ !referer.startsWith("//") &&
742
+ !referer.includes("\\")
743
+ ) {
744
+ return true;
745
+ }
746
+ if (requestUrl === undefined) return false;
747
+ try {
748
+ return new URL(referer).origin === new URL(requestUrl).origin;
749
+ } catch {
750
+ return false;
751
+ }
752
+ }
753
+
754
+ /**
755
+ * `redirect().back()` over Station's `redirect(url)` seam (57.7): prefer a
756
+ * trusted same-origin `Referer`, else the caller's `fallback` (the form URL) —
757
+ * the same behaviour as ream's `RedirectBuilder.back(fallback)`, open-redirect
758
+ * safe.
759
+ */
760
+ function redirectBack(ctx: StationHttpContext, fallback: string): void {
761
+ const referer = ctx.request.header?.("referer");
762
+ const target =
763
+ typeof referer === "string" &&
764
+ isSameOriginReferer(referer, ctx.request.url?.())
765
+ ? referer
766
+ : fallback;
767
+ ctx.response.redirect(target);
768
+ }
769
+
770
+ /**
771
+ * A checkbox/boolean field is true only for its checked submissions. Duplicate
772
+ * keys (a hidden `0` companion + the checkbox `1`) arrive as an array — the box
773
+ * is checked if ANY submitted value is a checked token (the checkbox wins).
774
+ */
377
775
  function isCheckedValue(value: unknown): boolean {
776
+ if (Array.isArray(value)) return value.some(isCheckedValue);
378
777
  return value === true || value === "1" || value === "on" || value === "true";
379
778
  }
380
779
 
@@ -422,8 +821,24 @@ function snapshotEntity(
422
821
  }
423
822
  let auditCloneWarnEmitted = false;
424
823
 
824
+ /**
825
+ * Filesystem path to the `.inker` templates Station ships inside the
826
+ * package (57.1, D3). Resolved from `import.meta.url` so the SAME path
827
+ * holds src-run (dev, `exports` → `./src/StationProvider.ts`) and dist-run
828
+ * (published, `./dist/StationProvider.js`) — `templates/` is a sibling of
829
+ * both `src/` and `dist/`. `fileURLToPath` (not `URL.pathname`) so a repo
830
+ * path containing spaces or reserved characters decodes correctly.
831
+ */
832
+ const TEMPLATES_ROOT = fileURLToPath(new URL("../templates/", import.meta.url));
833
+
425
834
  export default class StationProvider {
426
835
  #contexts: Map<Resource, ResourceContext> = new Map();
836
+ #viewRenderer: InkerViewRenderer | undefined;
837
+ /**
838
+ * Distinct 404-render failure modes already logged (fingerprints), scoped
839
+ * per instance so one provider's fault never suppresses another's.
840
+ */
841
+ #notFoundRenderFailCauses = new Set<string>();
427
842
  #started = false;
428
843
  // 54.7 auth state — populated when warden is wired AND
429
844
  // StationConfig.requireAuth is true (default when warden is present).
@@ -454,14 +869,14 @@ export default class StationProvider {
454
869
  async boot(): Promise<void> {
455
870
  // Force-resolve so `setStation` runs even if no preload touches the
456
871
  // singleton. Mirrors AuroraProvider.boot().
457
- this.app.container.resolve<ResourceRegistry>(ResourceRegistry);
872
+ await this.app.container.resolve<ResourceRegistry>(ResourceRegistry);
458
873
  }
459
874
 
460
875
  async start(): Promise<void> {
461
876
  if (this.#started) return;
462
877
 
463
878
  const registry =
464
- this.app.container.resolve<ResourceRegistry>(ResourceRegistry);
879
+ await this.app.container.resolve<ResourceRegistry>(ResourceRegistry);
465
880
  const resources = registry.all();
466
881
  if (resources.length === 0) return;
467
882
 
@@ -475,27 +890,54 @@ export default class StationProvider {
475
890
  // peer is a degraded-host signal: #loadPeers returns null → silent stop.
476
891
  const peers = await this.#loadPeers();
477
892
  if (!peers) return;
478
- const { router, atlas } = peers;
893
+ const { router, atlas, rune } = peers;
894
+
895
+ // 57.7 fail-closed: a write-capable admin (create/edit) validates + guards
896
+ // mass-assignment through a rune-derived schema. If atlas is present (CRUD
897
+ // is live) but `@c9up/rune` is NOT installed, refuse to boot rather than
898
+ // silently accept unvalidated bodies — there is NO fallback to the deleted
899
+ // key-filter (`feedback_security_first`). Read-only admins need no rune.
900
+ if (rune === null && resourcesNeedValidation(resources)) {
901
+ throw new Error(
902
+ "[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.",
903
+ );
904
+ }
905
+
906
+ // Phase 1a2 — resolve the SHARED inker renderer (AdonisJS/Edge parity)
907
+ // and mount Station's package templates as the `station` disk. Unlike
908
+ // warden (whose absence keeps the open dev-preview path), a view engine
909
+ // is a HARD render requirement (D2): once an admin surface exists there
910
+ // is no page without it. Fail LOUD at boot when @c9up/inker is not wired
911
+ // — mirrors #resolveDb's loud-on-missing posture — rather than degrade
912
+ // silently or 500 on the first request.
913
+ const renderer = await this.#resolveViewRenderer();
914
+ // Mount once: `station::<template>` now resolves to this package's
915
+ // templates/ dir on the host's single inker engine (edge.mount parity).
916
+ renderer.mount("station", TEMPLATES_ROOT);
917
+ this.#viewRenderer = renderer;
479
918
 
480
919
  // Phase 1b — wire the warden auth gate (or warn-once when it stays open).
481
- this.#configureAuth(userConfig);
920
+ await this.#configureAuth(userConfig);
482
921
 
483
922
  // Phase 2 — build per-resource context ONCE. `#resolveDb()` is
484
923
  // loud: if the host installed `@c9up/atlas` but didn't register
485
924
  // `@c9up/atlas/provider` in `reamrc.ts`, AC11's "surface
486
925
  // AtlasProvider misconfiguration" intent kicks in.
487
- const db = this.#resolveDb();
926
+ const db = await this.#resolveDb();
488
927
  for (const resource of resources) {
489
- this.#contexts.set(resource, buildResourceContext(resource, db, atlas));
928
+ this.#contexts.set(
929
+ resource,
930
+ buildResourceContext(resource, db, atlas, rune),
931
+ );
490
932
  }
491
933
 
492
- // 56.5 + 54.6 + CSRF boot-time warn-onces. We surface the "auth
493
- // wired but no permissions seeded", "no audit sink", and "no CSRF
494
- // check" gaps loud-and-once so a half-wired install can't ship to
495
- // prod without operators noticing.
934
+ // 56.5 + 54.6 boot-time warn-onces. We surface the "auth wired but no
935
+ // permissions seeded" and "no audit sink" gaps loud-and-once so a
936
+ // half-wired install can't ship to prod without operators noticing.
937
+ // (CSRF is no longer a boot-time warn — enforcement is request-time and
938
+ // honest now: every write route fail-closes on `ctx.request.csrfProtected`.)
496
939
  this.#warnSeedPermissionsOnce(resources);
497
940
  this.#warnAuditGapsOnce(resources);
498
- this.#warnCsrfGapOnce(resources);
499
941
 
500
942
  // Phase 3 — route registration. The router proxy may still throw
501
943
  // "Router accessed before initialization" on first property access
@@ -527,28 +969,177 @@ export default class StationProvider {
527
969
  async #loadPeers(): Promise<{
528
970
  router: StationRouter;
529
971
  atlas: AtlasModule;
972
+ rune: RuneModule | null;
530
973
  } | null> {
531
974
  if (!this.app.container.has("router")) return null;
532
- const router = this.app.container.resolve<StationRouter>("router");
975
+ const router = await this.app.container.resolve<StationRouter>("router");
533
976
  try {
534
977
  const atlas = loadBearingCast<AtlasModule>(await import("@c9up/atlas"));
535
- return { router, atlas };
978
+ // rune is loaded here but its ABSENCE is NOT a degraded-host signal the
979
+ // way atlas's is: atlas-missing ⇒ no CRUD at all (silent stop), whereas
980
+ // rune-missing while a write resource IS mounted is a fail-CLOSED
981
+ // misconfiguration (start() throws loud — see below). So `#loadRune`
982
+ // swallows only module-not-found → null; start() decides whether that
983
+ // null is fatal.
984
+ const rune = await this.#loadRune();
985
+ return { router, atlas, rune };
536
986
  } catch (err) {
537
987
  if (isModuleNotFound(err)) return null;
538
988
  throw err;
539
989
  }
540
990
  }
541
991
 
992
+ /**
993
+ * Tolerantly load `@c9up/rune` (57.7) — the SAME seam as `@c9up/atlas`
994
+ * (dynamic module import, module-not-found → null, real error re-thrown).
995
+ * Returning null does NOT degrade the host: the caller fails loud only when
996
+ * a write-capable resource actually needs the validator (AC7, fail-closed).
997
+ */
998
+ async #loadRune(): Promise<RuneModule | null> {
999
+ try {
1000
+ return loadBearingCast<RuneModule>(await import("@c9up/rune"));
1001
+ } catch (err) {
1002
+ if (isModuleNotFound(err)) return null;
1003
+ throw err;
1004
+ }
1005
+ }
1006
+
1007
+ /**
1008
+ * Resolve the host's shared inker renderer from the container (the `"inker"`
1009
+ * alias `InkerProvider` binds) — the AdonisJS package-views pattern (57.1,
1010
+ * D1/D2). A view engine is mandatory once an admin surface exists, so an
1011
+ * unbound or not-yet-ready `"inker"` throws a clear, actionable error at
1012
+ * boot (mirrors `#resolveDb`'s loud-on-missing), rather than a silent
1013
+ * degrade or a runtime 500. Consumed via the container only — never a
1014
+ * static or dynamic `import "@c9up/inker"` (D5), exactly like warden.
1015
+ */
1016
+ async #resolveViewRenderer(): Promise<InkerViewRenderer> {
1017
+ if (!this.app.container.has("inker")) {
1018
+ throw new Error(
1019
+ "[station] register @c9up/inker (InkerProvider) to render admin views",
1020
+ );
1021
+ }
1022
+ try {
1023
+ const resolved = await this.app.container.resolve<unknown>("inker");
1024
+ if (!isInkerViewRenderer(resolved)) {
1025
+ throw new Error(
1026
+ 'the "inker" binding is not a usable view renderer (missing mount/renderToString)',
1027
+ );
1028
+ }
1029
+ return resolved;
1030
+ } catch (cause) {
1031
+ throw new Error(
1032
+ "[station] @c9up/inker is registered but not usable — it must fully boot (InkerProvider + its rosetta/router peers) before Station, and resolve to a real view renderer. See `cause` for the specific reason.",
1033
+ { cause },
1034
+ );
1035
+ }
1036
+ }
1037
+
1038
+ /**
1039
+ * Render Station's 404 page through the shared inker renderer (57.1).
1040
+ * Builds the same data the retired `renderNotFoundPage` composed and
1041
+ * renders the `station::errors/404` template (the package's mounted disk);
1042
+ * inker's `{{ }}` auto-escaping replaces the hand-rolled escaper for
1043
+ * this view. `slug` is `encodeURIComponent`-safe (no HTML-special output)
1044
+ * so its auto-escape is a no-op in the back-link `href`. The 404 uses no
1045
+ * inker helpers, so a minimal render ctx is sufficient.
1046
+ */
1047
+ async #renderNotFound(
1048
+ ctx: StationHttpContext,
1049
+ resource: Resource,
1050
+ id: string,
1051
+ ): Promise<string> {
1052
+ if (this.#viewRenderer === undefined) {
1053
+ throw new Error("[station] view engine not initialised");
1054
+ }
1055
+ const renderCtx = {
1056
+ request: ctx.request,
1057
+ response: ctx.response,
1058
+ store: new Map<string, unknown>(),
1059
+ locale: "en",
1060
+ };
1061
+ try {
1062
+ return await this.#viewRenderer.renderToString(
1063
+ renderCtx,
1064
+ "station::errors/404",
1065
+ {
1066
+ title: "Not Found",
1067
+ id,
1068
+ label: resource.label,
1069
+ labelLower: resource.label.toLowerCase(),
1070
+ slug: encodeURIComponent(resource.name),
1071
+ },
1072
+ );
1073
+ } catch (cause) {
1074
+ // The error page itself failed to render (missing/faulty template,
1075
+ // fs fault, parse error). A not-found must never escalate into a 500:
1076
+ // fall back to a minimal, dependency-free static body. `id` is
1077
+ // request-controlled, so it is HTML-escaped here. The fault is logged
1078
+ // so a persistent deploy misconfig (unpublished templates/, corrupt
1079
+ // station::layout) is not silently swallowed — but once per failure
1080
+ // MODE per instance, not once per request: a durably-broken template
1081
+ // on a 404-sprayed endpoint must not turn attacker traffic into
1082
+ // unbounded log volume, while a genuinely new failure mode still
1083
+ // surfaces its `cause`. The fingerprint keys on code/name only (never
1084
+ // the request-controlled message) and is capped, so both stay bounded.
1085
+ const fingerprint = notFoundFailFingerprint(cause);
1086
+ if (
1087
+ this.#notFoundRenderFailCauses.size < NOT_FOUND_RENDER_FAIL_CAP &&
1088
+ !this.#notFoundRenderFailCauses.has(fingerprint)
1089
+ ) {
1090
+ this.#notFoundRenderFailCauses.add(fingerprint);
1091
+ console.error(
1092
+ `[station] failed to render station::errors/404 (${fingerprint}) — serving the minimal fallback page; repeats of this failure mode are suppressed this instance`,
1093
+ cause,
1094
+ );
1095
+ }
1096
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Not Found</title></head><body><h1>404 — Not Found</h1><p>No ${escapeMin(resource.label)} matches <code>${escapeMin(id)}</code>.</p></body></html>`;
1097
+ }
1098
+ }
1099
+
1100
+ /**
1101
+ * Render a Station template through the shared inker renderer (57.2). Builds
1102
+ * the same minimal render ctx as `#renderNotFound` (the list/show views use
1103
+ * no inker helpers) and delegates escaping to inker's `{{ }}`. Used by the
1104
+ * list + show handlers. Unlike the retired hand-rolled builders — pure
1105
+ * `(input) => string` functions that could only throw on their own logic —
1106
+ * this path can additionally fault on template lookup, fs, or parse errors.
1107
+ * Such a fault propagates to the root error boundary as a 500, which is the
1108
+ * correct outcome on the success path: a broken list/show template is a real
1109
+ * server error and must not be masked as a degraded 200. (`#renderNotFound`
1110
+ * wraps its render in a static fallback only because a not-found is already
1111
+ * an error response that must never escalate into a 500.)
1112
+ */
1113
+ async #renderView(
1114
+ ctx: StationHttpContext,
1115
+ name: string,
1116
+ data: Readonly<Record<string, unknown>>,
1117
+ ): Promise<string> {
1118
+ if (this.#viewRenderer === undefined) {
1119
+ throw new Error("[station] view engine not initialised");
1120
+ }
1121
+ // Thread the real per-request store (57.3/D3) so the als-backed inker
1122
+ // helpers — `csrfField()` on the form/login views — read the token. Inert
1123
+ // for list/show, whose templates call no helpers.
1124
+ const renderCtx = {
1125
+ request: ctx.request,
1126
+ response: ctx.response,
1127
+ store: ctx.store ?? new Map<string, unknown>(),
1128
+ locale: "en",
1129
+ };
1130
+ return await this.#viewRenderer.renderToString(renderCtx, name, data);
1131
+ }
1132
+
542
1133
  /**
543
1134
  * Wire the warden auth gate from the `station` config block. When warden is
544
1135
  * not installed/bound the gate stays open and a one-time warning is emitted.
545
1136
  */
546
- #configureAuth(userConfig: StationConfig): void {
1137
+ async #configureAuth(userConfig: StationConfig): Promise<void> {
547
1138
  const wardenWanted = userConfig.requireAuth !== false;
548
1139
  if (wardenWanted) {
549
1140
  try {
550
1141
  this.#authManager =
551
- this.app.container.resolve<WardenAuthManager>("auth");
1142
+ await this.app.container.resolve<WardenAuthManager>("auth");
552
1143
  this.#authConfig = {
553
1144
  requireAuth: true,
554
1145
  requireRole: userConfig.requireRole,
@@ -671,23 +1262,6 @@ export default class StationProvider {
671
1262
  );
672
1263
  }
673
1264
 
674
- #warnCsrfGapOnce(resources: ReadonlyArray<Resource>): void {
675
- if (csrfWarnEmitted) return;
676
- const writeActions: ReadonlyArray<ResourceAction> = [
677
- "create",
678
- "edit",
679
- "destroy",
680
- ];
681
- const writeEnabled = resources.some((r) =>
682
- r.actions.some((a) => writeActions.includes(a)),
683
- );
684
- if (!writeEnabled) return;
685
- csrfWarnEmitted = true;
686
- console.warn(
687
- "[station] Write-enabled resources are mounted but Station does NOT enforce CSRF at the handler level. Wire @c9up/blackhole (csrf: true) or an equivalent middleware in start/kernel.ts BEFORE production — a missing CSRF check on /admin/<resource>/:id POST allows cross-site form submission to mutate rows under any logged-in user's session.",
688
- );
689
- }
690
-
691
1265
  #warnAuditGapsOnce(resources: ReadonlyArray<Resource>): void {
692
1266
  if (missingAuditWarned) return;
693
1267
  const writeActions: ReadonlyArray<ResourceAction> = [
@@ -721,6 +1295,89 @@ export default class StationProvider {
721
1295
  return ctx;
722
1296
  }
723
1297
 
1298
+ /**
1299
+ * The derived rune schema for a write handler (57.7). Present by construction
1300
+ * — start() fail-closes at boot when a write action is mounted without
1301
+ * `@c9up/rune` — so an undefined here is an internal invariant break; throw
1302
+ * loud (fail-closed) rather than silently accept an unvalidated body.
1303
+ */
1304
+ #requireWritableSchema(resource: Resource): RuneSchema {
1305
+ const { writableSchema } = this.#requireContext(resource);
1306
+ if (writableSchema === undefined) {
1307
+ throw new Error(
1308
+ `[station] No validation schema for ${resource.entity.name} — @c9up/rune must be installed to accept admin writes.`,
1309
+ );
1310
+ }
1311
+ return writableSchema;
1312
+ }
1313
+
1314
+ /**
1315
+ * Fail-closed response for invalid write input (57.7, AC4). Content-negotiated:
1316
+ *
1317
+ * - **JSON/XHR** → `422` with `{ error, code: "E_VALIDATION_ERROR", messages }`
1318
+ * (Adonis/Vine parity — API clients get the machine-readable errors).
1319
+ * - **HTML with a session** → the AdonisJS web idiom (PRG): flash the old
1320
+ * input + per-field errors, then `redirect().back()` to the form `GET`,
1321
+ * which re-reads the flash and re-renders with values + errors. No inline
1322
+ * body — the browser lands on a fresh form (no re-POST on refresh).
1323
+ * - **HTML without a session** → graceful fallback: re-render the form inline
1324
+ * at `422` so the errors are never silently lost when no `SessionMiddleware`
1325
+ * ran. (`row` supplies the id/action on EDIT; undefined keeps CREATE mode.)
1326
+ *
1327
+ * `redirect().back()` semantics are replicated over Station's `redirect(url)`
1328
+ * seam ({@link redirectBack}) with the same same-origin guard ream's
1329
+ * `RedirectBuilder.back()` uses — `formUrl` is the safe fallback.
1330
+ */
1331
+ async #denyValidation(
1332
+ ctx: StationHttpContext,
1333
+ resource: Resource,
1334
+ submitted: Record<string, unknown>,
1335
+ errors: ReadonlyArray<RuneValidationError>,
1336
+ formUrl: string,
1337
+ row?: Readonly<Record<string, unknown>>,
1338
+ ): Promise<void> {
1339
+ if (wantsJsonResponse(ctx)) {
1340
+ ctx.response.status(422);
1341
+ // AdonisJS/VineJS parity: `{ errors: [{ field, rule, message }] }` —
1342
+ // the same shape ream's own `E_VALIDATION_ERROR` handler emits.
1343
+ ctx.response.json({
1344
+ errors: errors.map((e) => ({
1345
+ field: e.field,
1346
+ rule: e.rule,
1347
+ message: e.message,
1348
+ })),
1349
+ });
1350
+ return;
1351
+ }
1352
+ // Echo only declared, non-sensitive columns back to the form — never
1353
+ // `_csrf`, secrets, or mass-assignment keys (both the flash store and the
1354
+ // re-rendered HTML). An empty result falls back to `row`.
1355
+ const { columns, pkColumn } = this.#requireContext(resource);
1356
+ const safe = flashableInput(submitted, columns);
1357
+ if (ctx.session !== undefined) {
1358
+ // AdonisJS PRG: flash old input + errors, redirect back to the form.
1359
+ ctx.session.flashAll(safe);
1360
+ ctx.session.flash(FLASH_ERRORS_KEY, fieldErrors(errors));
1361
+ redirectBack(ctx, formUrl);
1362
+ return;
1363
+ }
1364
+ // No session middleware — fall back to an inline 422 re-render so the
1365
+ // submitted values + errors are still shown rather than silently dropped.
1366
+ const vm = buildFormViewModel({
1367
+ resource,
1368
+ columns,
1369
+ pkColumn,
1370
+ row,
1371
+ values: Object.keys(safe).length > 0 ? safe : undefined,
1372
+ errors: fieldErrors(errors),
1373
+ csrfEnabled: ctx.store?.get("csrfToken") != null,
1374
+ });
1375
+ ctx.response.status(422);
1376
+ const html = await this.#renderView(ctx, "station::form", vm);
1377
+ ctx.response.type("text/html; charset=utf-8");
1378
+ ctx.response.send(html);
1379
+ }
1380
+
724
1381
  #buildListHandler(
725
1382
  resource: Resource,
726
1383
  ): (ctx: StationHttpContext) => Promise<void> {
@@ -745,32 +1402,36 @@ export default class StationProvider {
745
1402
  );
746
1403
  }
747
1404
 
748
- const total = await repo.query().count();
749
- const lastPage = Math.max(1, Math.ceil(total / perPage));
750
- if (page > lastPage && total > 0) {
1405
+ // De-hand-rolled (57.5): atlas `paginate()` runs the parallel
1406
+ // COUNT(*) + LIMIT/OFFSET data fetch in one call and owns the
1407
+ // `lastPage = ceil(total/perPage)` math Station used to duplicate.
1408
+ // Station keeps only the `MAX_PER_PAGE` clamp (above) and the
1409
+ // redirect-to-last-page wrapper — atlas floors perPage to ≥1 but
1410
+ // does not cap it.
1411
+ const result = await repo
1412
+ .query()
1413
+ .orderBy(pkColumn, "desc")
1414
+ .paginate(page, perPage);
1415
+ if (page > result.meta.lastPage && result.meta.total > 0) {
751
1416
  // Never render an empty page when one exists — redirect to the
752
1417
  // last real page so the user lands on something useful.
753
1418
  ctx.response.redirect(
754
- `/admin/${resource.name}?page=${lastPage}&perPage=${perPage}`,
1419
+ `/admin/${resource.name}?page=${result.meta.lastPage}&perPage=${perPage}`,
755
1420
  );
756
1421
  return;
757
1422
  }
758
1423
 
759
- const rows = await repo
760
- .query()
761
- .orderBy(pkColumn, "desc")
762
- .forPage(page, perPage)
763
- .exec();
764
- const html = renderListPage({
1424
+ const viewModel = buildListViewModel({
765
1425
  resource,
766
- rows,
1426
+ rows: result.all(),
767
1427
  columns,
768
1428
  pkColumn,
769
- page,
770
- perPage,
771
- total,
772
- lastPage,
1429
+ page: result.meta.currentPage,
1430
+ perPage: result.meta.perPage,
1431
+ total: result.meta.total,
1432
+ lastPage: result.meta.lastPage,
773
1433
  });
1434
+ const html = await this.#renderView(ctx, "station::list", viewModel);
774
1435
  ctx.response.type("text/html; charset=utf-8");
775
1436
  ctx.response.send(html);
776
1437
  };
@@ -791,17 +1452,19 @@ export default class StationProvider {
791
1452
  const id = ctx.params.id ?? "";
792
1453
  const row = await repo.find(id);
793
1454
  if (row === null) {
1455
+ const notFoundHtml = await this.#renderNotFound(ctx, resource, id);
794
1456
  ctx.response.status(404);
795
1457
  ctx.response.type("text/html; charset=utf-8");
796
- ctx.response.send(renderNotFoundPage({ resource, id }));
1458
+ ctx.response.send(notFoundHtml);
797
1459
  return;
798
1460
  }
799
- const html = renderShowPage({
1461
+ const viewModel = buildShowViewModel({
800
1462
  resource,
801
1463
  row,
802
1464
  columns,
803
1465
  pkColumn,
804
1466
  });
1467
+ const html = await this.#renderView(ctx, "station::show", viewModel);
805
1468
  ctx.response.type("text/html; charset=utf-8");
806
1469
  ctx.response.send(html);
807
1470
  };
@@ -818,12 +1481,18 @@ export default class StationProvider {
818
1481
  deny(ctx);
819
1482
  return;
820
1483
  }
821
- const html = renderFormPage({
1484
+ // 57.7 — after a failed create redirected back (PRG), the flash carries
1485
+ // the old input + per-field errors; re-populate + surface them.
1486
+ const { values, errors } = readFlash(ctx, columns);
1487
+ const vm = buildFormViewModel({
822
1488
  resource,
823
1489
  columns,
824
1490
  pkColumn,
825
- hiddenInputs: csrfHiddenInputs(ctx),
1491
+ values,
1492
+ errors,
1493
+ csrfEnabled: ctx.store?.get("csrfToken") != null,
826
1494
  });
1495
+ const html = await this.#renderView(ctx, "station::form", vm);
827
1496
  ctx.response.type("text/html; charset=utf-8");
828
1497
  ctx.response.send(html);
829
1498
  };
@@ -833,7 +1502,15 @@ export default class StationProvider {
833
1502
  resource: Resource,
834
1503
  ): (ctx: StationHttpContext) => Promise<void> {
835
1504
  return async (ctx) => {
836
- const { repo, pkColumn, columns, autoManaged } =
1505
+ // Fail-close: reject a request whose CSRF was not enforced+validated
1506
+ // (blackhole unwired / csrf:false / route excepted) BEFORE any auth or
1507
+ // DB work — no 404-vs-403 oracle to a forged request. A seeded token is
1508
+ // not proof; only `csrfProtected === true` is (AC4/AC7).
1509
+ if (ctx.request.csrfProtected !== true) {
1510
+ denyCsrf(ctx);
1511
+ return;
1512
+ }
1513
+ const { repo, pkColumn, columns, booleanKeys } =
837
1514
  this.#requireContext(resource);
838
1515
  if (
839
1516
  !(await authorizeAction(resource, "create", ctx, this.#authManager))
@@ -842,14 +1519,23 @@ export default class StationProvider {
842
1519
  return;
843
1520
  }
844
1521
  const body = await readBody(ctx);
845
- // Mass-assignment guard: only `@Column`-declared properties make
846
- // it through to repo.create(). An attacker who POSTs
847
- // `{ role: "admin" }` or `{ passwordHash: "x" }` against a
848
- // resource that doesn't declare those columns has the keys
849
- // silently dropped here. PK + framework timestamps are always
850
- // excluded — they're decided by the DB / hooks, not the caller.
851
- const filtered = filterWritableBody(body, columns, pkColumn, autoManaged);
852
- const created = await repo.create(filtered);
1522
+ // 57.7 — validate + mass-assignment guard in one pass. `result.data`
1523
+ // holds ONLY declared-column keys, so an attacker's `{ role: "admin" }`
1524
+ // / `{ passwordHash: "x" }` / PK / timestamps are never present (dropped
1525
+ // by the schema, not a separate filter), and each field is type-checked.
1526
+ // Invalid input fail-closes with a 422 BEFORE any repository write (AC4).
1527
+ const result = validateWritableBody(
1528
+ this.#requireWritableSchema(resource),
1529
+ booleanKeys,
1530
+ body,
1531
+ );
1532
+ if (!result.valid) {
1533
+ // PRG fallback URL = the create form (AdonisJS `redirect().back()`).
1534
+ const formUrl = `/admin/${encodeURIComponent(resource.name)}/new`;
1535
+ await this.#denyValidation(ctx, resource, body, result.errors, formUrl);
1536
+ return;
1537
+ }
1538
+ const created = await repo.create(result.data);
853
1539
  await emitAudit(resource, {
854
1540
  action: "create",
855
1541
  resource: resource.name,
@@ -875,18 +1561,26 @@ export default class StationProvider {
875
1561
  const id = ctx.params.id ?? "";
876
1562
  const row = await repo.find(id);
877
1563
  if (row === null) {
1564
+ const notFoundHtml = await this.#renderNotFound(ctx, resource, id);
878
1565
  ctx.response.status(404);
879
1566
  ctx.response.type("text/html; charset=utf-8");
880
- ctx.response.send(renderNotFoundPage({ resource, id }));
1567
+ ctx.response.send(notFoundHtml);
881
1568
  return;
882
1569
  }
883
- const html = renderFormPage({
1570
+ // 57.7 — after a failed update redirected back (PRG), the flash carries
1571
+ // the submitted values + errors; they win over the DB `row` so the user
1572
+ // sees what they typed. A normal edit GET has no flash → fields from row.
1573
+ const { values, errors } = readFlash(ctx, columns);
1574
+ const vm = buildFormViewModel({
884
1575
  resource,
885
1576
  columns,
886
1577
  pkColumn,
887
1578
  row,
888
- hiddenInputs: csrfHiddenInputs(ctx),
1579
+ values,
1580
+ errors,
1581
+ csrfEnabled: ctx.store?.get("csrfToken") != null,
889
1582
  });
1583
+ const html = await this.#renderView(ctx, "station::form", vm);
890
1584
  ctx.response.type("text/html; charset=utf-8");
891
1585
  ctx.response.send(html);
892
1586
  };
@@ -896,7 +1590,12 @@ export default class StationProvider {
896
1590
  resource: Resource,
897
1591
  ): (ctx: StationHttpContext) => Promise<void> {
898
1592
  return async (ctx) => {
899
- const { repo, pkColumn, columns, autoManaged } =
1593
+ // Fail-close on CSRF before any auth/DB work (see #buildCreateHandler).
1594
+ if (ctx.request.csrfProtected !== true) {
1595
+ denyCsrf(ctx);
1596
+ return;
1597
+ }
1598
+ const { repo, pkColumn, columns, booleanKeys } =
900
1599
  this.#requireContext(resource);
901
1600
  // Authorize before the existence check (no 404-vs-403 oracle).
902
1601
  if (!(await authorizeAction(resource, "edit", ctx, this.#authManager))) {
@@ -906,24 +1605,43 @@ export default class StationProvider {
906
1605
  const id = ctx.params.id ?? "";
907
1606
  const entity = await repo.find(id);
908
1607
  if (entity === null) {
1608
+ const notFoundHtml = await this.#renderNotFound(ctx, resource, id);
909
1609
  ctx.response.status(404);
910
1610
  ctx.response.type("text/html; charset=utf-8");
911
- ctx.response.send(renderNotFoundPage({ resource, id }));
1611
+ ctx.response.send(notFoundHtml);
912
1612
  return;
913
1613
  }
914
1614
  const body = await readBody(ctx);
1615
+ // 57.7 — validate BEFORE any snapshot or mutation (AC4). On failure the
1616
+ // entity is untouched (no `setProp`, no `save`), so a bad edit never
1617
+ // poisons the audit diff nor emits a success event; the form re-renders
1618
+ // 422 with the submitted values + per-field errors (the existing entity
1619
+ // still supplies the id/action).
1620
+ const result = validateWritableBody(
1621
+ this.#requireWritableSchema(resource),
1622
+ booleanKeys,
1623
+ body,
1624
+ );
1625
+ if (!result.valid) {
1626
+ // PRG fallback URL = this row's edit form (AdonisJS `redirect().back()`).
1627
+ const formUrl = `/admin/${encodeURIComponent(resource.name)}/${encodeURIComponent(id)}/edit`;
1628
+ await this.#denyValidation(
1629
+ ctx,
1630
+ resource,
1631
+ body,
1632
+ result.errors,
1633
+ formUrl,
1634
+ entity,
1635
+ );
1636
+ return;
1637
+ }
915
1638
  // Snapshot BEFORE the mutation runs so the audit diff is
916
1639
  // meaningful (entity is a BaseEntity; its dirty-tracking
917
- // would shadow the original values after setProp).
1640
+ // would shadow the original values after setProp). `result.data`
1641
+ // holds only declared, type-checked columns — the mass-assignment
1642
+ // guarantee (PK / timestamps / unknown keys dropped) is inherent.
918
1643
  const beforeSnapshot = snapshotEntity(entity, columns);
919
- // Mass-assignment guard: filterWritableBody drops every key
920
- // that isn't an `@Column`-declared property, plus the PK and
921
- // any framework timestamps (created_at / updated_at /
922
- // deleted_at). An attacker who POSTs `{ role: "admin" }` to
923
- // a resource without that column has the field silently
924
- // dropped instead of overwriting the entity.
925
- const writable = filterWritableBody(body, columns, pkColumn, autoManaged);
926
- for (const [key, value] of Object.entries(writable)) {
1644
+ for (const [key, value] of Object.entries(result.data)) {
927
1645
  entity.setProp(key, value);
928
1646
  }
929
1647
  await repo.save(entity);
@@ -952,6 +1670,14 @@ export default class StationProvider {
952
1670
  const updateHandler = this.#buildUpdateHandler(resource);
953
1671
  const destroyHandler = this.#buildDestroyHandler(resource);
954
1672
  return async (ctx) => {
1673
+ // Fail-close on CSRF before reading the body (see #buildCreateHandler).
1674
+ // The PUT/DELETE branches delegate to already-guarded handlers, but the
1675
+ // unsupported-override 405 branch would otherwise do work on a forged
1676
+ // request and leak a 403-vs-405 oracle — guard the entrypoint too.
1677
+ if (ctx.request.csrfProtected !== true) {
1678
+ denyCsrf(ctx);
1679
+ return;
1680
+ }
955
1681
  const body = await readBody(ctx);
956
1682
  const override = String(body._method ?? "").toUpperCase();
957
1683
  if (override === "PUT" || override === "PATCH") {
@@ -974,6 +1700,11 @@ export default class StationProvider {
974
1700
  resource: Resource,
975
1701
  ): (ctx: StationHttpContext) => Promise<void> {
976
1702
  return async (ctx) => {
1703
+ // Fail-close on CSRF before any auth/DB work (see #buildCreateHandler).
1704
+ if (ctx.request.csrfProtected !== true) {
1705
+ denyCsrf(ctx);
1706
+ return;
1707
+ }
977
1708
  const { repo, pkColumn, columns } = this.#requireContext(resource);
978
1709
  // Authorize before the existence check (no 404-vs-403 oracle).
979
1710
  if (
@@ -985,9 +1716,10 @@ export default class StationProvider {
985
1716
  const id = ctx.params.id ?? "";
986
1717
  const row = await repo.find(id);
987
1718
  if (row === null) {
1719
+ const notFoundHtml = await this.#renderNotFound(ctx, resource, id);
988
1720
  ctx.response.status(404);
989
1721
  ctx.response.type("text/html; charset=utf-8");
990
- ctx.response.send(renderNotFoundPage({ resource, id }));
1722
+ ctx.response.send(notFoundHtml);
991
1723
  return;
992
1724
  }
993
1725
  const before = snapshotEntity(row, columns);
@@ -1004,9 +1736,9 @@ export default class StationProvider {
1004
1736
  };
1005
1737
  }
1006
1738
 
1007
- #resolveDb(): unknown {
1739
+ async #resolveDb(): Promise<unknown> {
1008
1740
  try {
1009
- return this.app.container.resolve<unknown>("db");
1741
+ return await this.app.container.resolve<unknown>("db");
1010
1742
  } catch (cause) {
1011
1743
  throw new Error(
1012
1744
  `[station] No 'db' connection registered. Did you register @c9up/atlas/provider in reamrc.ts?`,
@@ -1187,11 +1919,12 @@ export default class StationProvider {
1187
1919
  }
1188
1920
  const qs = ctx.request.qs();
1189
1921
  const errorParam = qs.error;
1190
- const html = renderLoginPage({
1922
+ const vm = buildLoginViewModel({
1191
1923
  action: this.#authConfig.loginPath,
1192
1924
  error: typeof errorParam === "string" ? errorParam : undefined,
1193
- hiddenInputs: csrfHiddenInputs(ctx),
1925
+ csrfEnabled: ctx.store?.get("csrfToken") != null,
1194
1926
  });
1927
+ const html = await this.#renderView(ctx, "station::login", vm);
1195
1928
  ctx.response.type("text/html; charset=utf-8");
1196
1929
  ctx.response.send(html);
1197
1930
  };
@@ -1205,6 +1938,13 @@ export default class StationProvider {
1205
1938
  */
1206
1939
  #buildLoginPostHandler(): (ctx: StationHttpContext) => Promise<void> {
1207
1940
  return async (ctx: StationHttpContext): Promise<void> => {
1941
+ // Fail-close on CSRF first — a forged login POST is refused before any
1942
+ // AuthManager work (see #buildCreateHandler). Login is open by design
1943
+ // (no auth gate), so CSRF is the sole pre-check here.
1944
+ if (ctx.request.csrfProtected !== true) {
1945
+ denyCsrf(ctx);
1946
+ return;
1947
+ }
1208
1948
  const manager = this.#authManager;
1209
1949
  if (manager === undefined) {
1210
1950
  ctx.response.status(500);
@@ -1216,12 +1956,13 @@ export default class StationProvider {
1216
1956
  const email = typeof body.email === "string" ? body.email.trim() : "";
1217
1957
  const password = typeof body.password === "string" ? body.password : "";
1218
1958
  if (email.length === 0 || password.length === 0) {
1219
- const html = renderLoginPage({
1959
+ const vm = buildLoginViewModel({
1220
1960
  action: this.#authConfig.loginPath,
1221
1961
  email,
1222
1962
  error: "Email and password are both required.",
1223
- hiddenInputs: csrfHiddenInputs(ctx),
1963
+ csrfEnabled: ctx.store?.get("csrfToken") != null,
1224
1964
  });
1965
+ const html = await this.#renderView(ctx, "station::login", vm);
1225
1966
  ctx.response.status(400);
1226
1967
  ctx.response.type("text/html; charset=utf-8");
1227
1968
  ctx.response.send(html);
@@ -1234,12 +1975,13 @@ export default class StationProvider {
1234
1975
  const token =
1235
1976
  typeof result.user?.token === "string" ? result.user.token : undefined;
1236
1977
  if (!result.authenticated || token === undefined) {
1237
- const html = renderLoginPage({
1978
+ const vm = buildLoginViewModel({
1238
1979
  action: this.#authConfig.loginPath,
1239
1980
  email,
1240
1981
  error: result.error ?? "Invalid email or password.",
1241
- hiddenInputs: csrfHiddenInputs(ctx),
1982
+ csrfEnabled: ctx.store?.get("csrfToken") != null,
1242
1983
  });
1984
+ const html = await this.#renderView(ctx, "station::login", vm);
1243
1985
  ctx.response.status(401);
1244
1986
  ctx.response.type("text/html; charset=utf-8");
1245
1987
  ctx.response.send(html);
@@ -1262,6 +2004,12 @@ export default class StationProvider {
1262
2004
  */
1263
2005
  #buildLogoutHandler(): (ctx: StationHttpContext) => Promise<void> {
1264
2006
  return async (ctx: StationHttpContext): Promise<void> => {
2007
+ // Fail-close on CSRF: a forged cross-site POST must not be able to log
2008
+ // an admin out (session-fixation nuisance) (see #buildCreateHandler).
2009
+ if (ctx.request.csrfProtected !== true) {
2010
+ denyCsrf(ctx);
2011
+ return;
2012
+ }
1265
2013
  ctx.response.clearCookie?.(this.#authConfig.cookieName, {
1266
2014
  path: "/",
1267
2015
  });
@@ -1284,6 +2032,7 @@ function buildResourceContext(
1284
2032
  resource: Resource,
1285
2033
  db: unknown,
1286
2034
  atlas: AtlasModule,
2035
+ rune: RuneModule | null,
1287
2036
  ): ResourceContext {
1288
2037
  const entityCtor = loadBearingCast<
1289
2038
  ConstructorParameters<typeof AtlasBaseRepository>[0]
@@ -1309,7 +2058,32 @@ function buildResourceContext(
1309
2058
  .filter(([, cfg]) => cfg.autoCreate === true || cfg.autoUpdate === true)
1310
2059
  .map(([prop]) => prop),
1311
2060
  );
1312
- return { repo, columns, pkColumn, autoManaged };
2061
+ // 57.7 — derive the writable validation schema from the SAME column metadata
2062
+ // the old `filterWritableBody` read. `rune === null` here means no write
2063
+ // action is mounted for this resource (start() already fail-closed the
2064
+ // atlas-present-but-rune-absent write case), so the schema stays undefined.
2065
+ const excludedProps = new Set<string>(
2066
+ atlas.getRelationMetadata(resource.entity).map((r) => r.propertyKey),
2067
+ );
2068
+ if (atlas.hasSoftDeletes(resource.entity)) excludedProps.add("deletedAt");
2069
+ const derived =
2070
+ rune === null
2071
+ ? undefined
2072
+ : deriveWritableSchema(
2073
+ columns,
2074
+ pkColumn,
2075
+ autoManaged,
2076
+ excludedProps,
2077
+ rune,
2078
+ );
2079
+ return {
2080
+ repo,
2081
+ columns,
2082
+ pkColumn,
2083
+ autoManaged,
2084
+ writableSchema: derived?.schema,
2085
+ booleanKeys: derived?.booleanKeys ?? new Set<string>(),
2086
+ };
1313
2087
  }
1314
2088
 
1315
2089
  /**
@@ -1448,6 +2222,37 @@ function deny(ctx: StationHttpContext): void {
1448
2222
  );
1449
2223
  }
1450
2224
 
2225
+ /**
2226
+ * Fail-closed CSRF denial (403), mirroring {@link deny}'s content negotiation.
2227
+ * Fires when a write reaches an admin handler without a CSRF-verified request
2228
+ * (`ctx.request.csrfProtected !== true`). The FIRST block per process also emits
2229
+ * one `console.error` — unlike the removed boot-time warning it can never
2230
+ * false-alarm (it accompanies a real unprotected write attempt), pointing the
2231
+ * operator at the actual misconfiguration (blackhole unwired / `csrf: false` /
2232
+ * `/admin/*` excepted).
2233
+ */
2234
+ function denyCsrf(ctx: StationHttpContext): void {
2235
+ if (!csrfBlockedWarned) {
2236
+ csrfBlockedWarned = true;
2237
+ console.error(
2238
+ "[station] Blocked a write to /admin/* — CSRF was not enforced for this request. Check that @c9up/blackhole is wired (csrf: true) in start/kernel.ts, /admin/* is NOT in csrf.exceptRoutes, and the blackhole native addon has been rebuilt (a stale .node predates the csrfProtected signal and always reads undefined). Returning 403.",
2239
+ );
2240
+ }
2241
+ ctx.response.status(403);
2242
+ if (wantsJsonResponse(ctx)) {
2243
+ ctx.response.json({
2244
+ error: "Forbidden",
2245
+ code: "CSRF_REQUIRED",
2246
+ message: "This admin action requires an active, verified CSRF token.",
2247
+ });
2248
+ return;
2249
+ }
2250
+ ctx.response.type("text/html; charset=utf-8");
2251
+ ctx.response.send(
2252
+ "<h1>403 Forbidden</h1><p>This admin action requires an active, verified CSRF token.</p>",
2253
+ );
2254
+ }
2255
+
1451
2256
  /**
1452
2257
  * Memoise the parsed body per request (Adonis BodyParser semantics — the
1453
2258
  * body is parsed once and stays re-readable). `#buildMethodOverrideHandler`
@@ -1483,28 +2288,6 @@ async function parseBody(
1483
2288
  return isPlainRecord(raw) ? raw : {};
1484
2289
  }
1485
2290
 
1486
- /**
1487
- * Read the CSRF token (when present) from `ctx.store` and shape it as
1488
- * a `hiddenInputs[]` entry for `renderFormPage`. The key `csrfToken`
1489
- * matches the @c9up/blackhole `csrfToken` convention so a fully-
1490
- * wired host stamps the token automatically; a host that doesn't wire
1491
- * CSRF returns no hidden input, and the form is unprotected (the
1492
- * boot-time warn-once already flagged this).
1493
- *
1494
- * The form field is named `_csrf` to match Adonis / Blackhole's
1495
- * default. Hosts using a different field name can override by writing
1496
- * their own hiddenInputs into ctx.store under a richer key, but for
1497
- * the common case this is the zero-config path.
1498
- */
1499
- function csrfHiddenInputs(
1500
- ctx: StationHttpContext,
1501
- ): ReadonlyArray<{ name: string; value: string }> | undefined {
1502
- if (ctx.store === undefined) return undefined;
1503
- const token = ctx.store.get("csrfToken");
1504
- if (typeof token !== "string" || token.length === 0) return undefined;
1505
- return [{ name: "_csrf", value: token }];
1506
- }
1507
-
1508
2291
  function redirectToShow(
1509
2292
  ctx: StationHttpContext,
1510
2293
  resource: Resource,
@@ -1565,15 +2348,18 @@ async function emitAudit(resource: Resource, event: AuditEvent): Promise<void> {
1565
2348
  }
1566
2349
 
1567
2350
  /**
1568
- * Tiny HTML-escape for the 405 error body — duplicated here rather
1569
- * than imported from views/escape.ts to keep the dependency surface
1570
- * of StationProvider minimal (views are otherwise only reached via
1571
- * the renderer modules).
2351
+ * Tiny HTML-escape for the 404 / 405 error bodies — a self-contained
2352
+ * local helper. Station keeps no shared TS escaper (inker's `{{ }}`
2353
+ * owns view escaping); this covers the error bodies that are sent as
2354
+ * `text/html` without going through the inker renderer. Escapes the
2355
+ * same five characters as inker so the two escaping paths stay in
2356
+ * parity (`&` first to avoid double-encoding).
1572
2357
  */
1573
2358
  function escapeMin(value: string): string {
1574
2359
  return value
1575
2360
  .replace(/&/g, "&amp;")
1576
2361
  .replace(/</g, "&lt;")
1577
2362
  .replace(/>/g, "&gt;")
1578
- .replace(/"/g, "&quot;");
2363
+ .replace(/"/g, "&quot;")
2364
+ .replace(/'/g, "&#39;");
1579
2365
  }