@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
@@ -17,15 +17,31 @@
17
17
  * the catch — real bugs in route registration surface instead of being
18
18
  * swallowed.
19
19
  */
20
+ import { fileURLToPath } from "node:url";
20
21
  import { ResourceRegistry } from "./ResourceRegistry.js";
21
22
  import { setStation } from "./services/main.js";
22
23
  // note: AuditEvent + ResourceAction are used by the CRUD handlers below;
23
24
  // the imports stay in one block for clarity.
24
- import { renderNotFoundPage } from "./views/errors/404.js";
25
- import { renderFormPage } from "./views/form.js";
26
- import { renderListPage } from "./views/list.js";
27
- import { renderLoginPage } from "./views/login.js";
28
- import { renderShowPage } from "./views/show.js";
25
+ import { buildFormViewModel } from "./views/form.js";
26
+ import { buildListViewModel } from "./views/list.js";
27
+ import { buildLoginViewModel } from "./views/login.js";
28
+ import { buildShowViewModel } from "./views/show.js";
29
+ /**
30
+ * Runtime shape check for the resolved `"inker"` binding. `container.has("inker")`
31
+ * only proves the token is registered — a null/partial/mis-registered value would
32
+ * otherwise slip through `#resolveViewRenderer` and blow up as a raw `TypeError`
33
+ * at `renderer.mount(...)` during boot, defeating the actionable fail-loud gate
34
+ * (57.1 review). Guarding here turns a null/non-conforming binding into the same
35
+ * clear "not usable" boot error, with the specific reason chained as `cause`.
36
+ */
37
+ function isInkerViewRenderer(value) {
38
+ return (typeof value === "object" &&
39
+ value !== null &&
40
+ "mount" in value &&
41
+ typeof value.mount === "function" &&
42
+ "renderToString" in value &&
43
+ typeof value.renderToString === "function");
44
+ }
29
45
  const MAX_PER_PAGE = 100;
30
46
  const DEFAULT_PER_PAGE = 25;
31
47
  const POSITIVE_INT_RE = /^[1-9][0-9]*$/;
@@ -34,15 +50,34 @@ let authWarnEmitted = false;
34
50
  let perPageClampWarned = false;
35
51
  let seedPermsWarned = false;
36
52
  let missingAuditWarned = false;
37
- let csrfWarnEmitted = false;
53
+ let csrfBlockedWarned = false;
38
54
  /** @internal Reset module-level flags between tests. */
39
55
  export function resetStationProviderFlags() {
40
56
  authWarnEmitted = false;
41
57
  perPageClampWarned = false;
42
58
  seedPermsWarned = false;
43
59
  missingAuditWarned = false;
44
- csrfWarnEmitted = false;
60
+ csrfBlockedWarned = false;
61
+ }
62
+ /**
63
+ * Stable fingerprint for a failed `station::errors/404` render, used to log each
64
+ * distinct failure MODE once per provider instance rather than once per request.
65
+ * Keys on the error `code`/`name` ONLY — never the message, which can embed the
66
+ * request-controlled `id` and would let attacker traffic inflate the log past
67
+ * the very bound this mechanism exists to hold.
68
+ */
69
+ function notFoundFailFingerprint(cause) {
70
+ if (!(cause instanceof Error))
71
+ return "non-error";
72
+ if ("code" in cause && typeof cause.code === "string")
73
+ return cause.code;
74
+ return cause.name;
45
75
  }
76
+ /**
77
+ * Hard cap on distinct 404-render failure modes logged per instance, so an
78
+ * unforeseen unbounded error-code space still cannot inflate the log or memory.
79
+ */
80
+ const NOT_FOUND_RENDER_FAIL_CAP = 32;
46
81
  const TIMESTAMP_PROPERTY_KEYS = new Set([
47
82
  "createdAt",
48
83
  "updatedAt",
@@ -54,28 +89,57 @@ const TIMESTAMP_COLUMN_NAMES = new Set([
54
89
  "deleted_at",
55
90
  ]);
56
91
  /**
57
- * Mass-assignment guard. Only accepts keys that are:
58
- * 1. A declared `@Column` propertyKey on the resource entity, AND
59
- * 2. Not the primary key (DB-managed), AND
60
- * 3. Not a framework-managed timestamp. Two signals, both honoured:
61
- * (a) the Lucid-style auto flag — `@column.dateTime({ autoCreate })` /
62
- * `{ autoUpdate }` (passed in via `autoManaged`); this is the
63
- * authoritative one and catches custom-named timestamp columns
64
- * (e.g. `registeredAt`) the name list below would miss, AND
65
- * (b) the conventional name fallback (created_at / updated_at /
66
- * deleted_at) for plain `@Column` timestamps declared without the
67
- * auto flag.
92
+ * Column `type` strings mapped to a numeric rune rule (mirrors form.ts input
93
+ * inference). `bigint` is deliberately EXCLUDED: coercing it through `Number()`
94
+ * silently loses precision above `Number.MAX_SAFE_INTEGER`, so a bigint column
95
+ * validates as a string (its exact submitted digits reach atlas intact).
96
+ */
97
+ const NUMBER_COLUMN_TYPES = new Set(["integer", "number"]);
98
+ /**
99
+ * Structural column `type` strings — validated with `rules.any()` so a JSON/XHR
100
+ * client can submit an object/array (a `rules.string()` would reject it, a
101
+ * regression vs. the pre-57.7 pass-through `filterWritableBody`).
102
+ */
103
+ const JSON_COLUMN_TYPES = new Set(["json", "jsonb"]);
104
+ /**
105
+ * True when at least one resource mounts a write action that maps a request
106
+ * body onto columns (create / edit). `destroy` needs no body validation. This
107
+ * is the AC7 predicate: it decides whether `@c9up/rune` is REQUIRED at boot —
108
+ * exported so `agnostic-peers-missing.test.ts` can pin the decision directly
109
+ * (the dynamic-import-absent path can't be simulated inside vitest, exactly as
110
+ * for atlas — see that file's header).
111
+ */
112
+ export function resourcesNeedValidation(resources) {
113
+ return resources.some((r) => r.actions.includes("create") || r.actions.includes("edit"));
114
+ }
115
+ /**
116
+ * Derive a `@c9up/rune` validation schema (57.7) from an entity's writable
117
+ * column metadata — the structural replacement for the hand-rolled
118
+ * `filterWritableBody`. The mass-assignment guarantee is now INHERENT:
119
+ * `schema.validateResult()` returns only declared-column keys, so unknown body keys
120
+ * (`role`, `passwordHash`, PK, timestamps) are dropped automatically — no
121
+ * explicit filtering pass.
122
+ *
123
+ * Excluded, exactly as the old guard did: the primary key, framework-managed
124
+ * timestamps (both the Lucid-style `autoManaged` flag AND the `created_at /
125
+ * updated_at / deleted_at` name convention) — plus, new in 57.7, relations and
126
+ * the soft-delete column (`excludedProps`). Metadata is read column-by-column,
127
+ * NEVER `in` on an instance (`@Column() declare` makes `in` always false —
128
+ * mirror `BaseRepository.#hydrate`).
68
129
  *
69
- * The `_method` synthetic field from browser-form method-overrides is
70
- * dropped automatically because it never matches a column propertyKey.
130
+ * Per-column rule mapping tolerates the form-post wire shape (values arrive as
131
+ * strings — rune does NOT coerce): booleans `.parse(isCheckedValue)`, numbers
132
+ * `.parse` string→Number (NaN falls through to a type error), everything else
133
+ * `rules.string()`. `.parse`/`.optional`/`.nullable` force rune's TS path, so
134
+ * no native `.node` is touched.
71
135
  *
72
- * Returning a fresh object — never the caller's reference — so a
73
- * downstream mutation can't poison the audit snapshot.
136
+ * Returns the built schema plus the set of boolean columns — the handler
137
+ * default-fills an ABSENT boolean key to `false` before validating (an
138
+ * unchecked checkbox submits nothing; this preserves the old edit-clears-box
139
+ * behaviour).
74
140
  */
75
- /** @internal Exported for unit tests — the mass-assignment + checkbox coercion guard. */
76
- export function filterWritableBody(body, columns, pkColumn, autoManaged) {
77
- const writable = {};
78
- const validKeys = new Set();
141
+ export function deriveWritableSchema(columns, pkColumn, autoManaged, excludedProps, rune) {
142
+ const fields = {};
79
143
  const booleanKeys = new Set();
80
144
  for (const c of columns) {
81
145
  if (c.propertyKey === pkColumn)
@@ -87,29 +151,192 @@ export function filterWritableBody(body, columns, pkColumn, autoManaged) {
87
151
  const snake = c.propertyKey.replace(/([A-Z])/g, "_$1").toLowerCase();
88
152
  if (TIMESTAMP_COLUMN_NAMES.has(snake))
89
153
  continue;
90
- validKeys.add(c.propertyKey);
91
- if ((c.type ?? "").toString().toLowerCase() === "boolean") {
154
+ if (excludedProps.has(c.propertyKey))
155
+ continue;
156
+ const type = (c.type ?? "").toString().toLowerCase();
157
+ if (type === "boolean") {
158
+ // Checkbox: the handler pre-fills an absent key to `false`, so every
159
+ // boolean is always present here → required, no `.optional()`.
92
160
  booleanKeys.add(c.propertyKey);
161
+ fields[c.propertyKey] = rune.rules.boolean().parse(isCheckedValue);
162
+ continue;
163
+ }
164
+ // A column is required iff the model declares neither nullability nor a
165
+ // default. DB-side defaults (serial, `DEFAULT`) are invisible to metadata
166
+ // (documented residual limitation — see story Dev Notes); the PK, the
167
+ // common serial case, is already excluded above.
168
+ const required = c.nullable !== true && c.default === undefined;
169
+ let chain = NUMBER_COLUMN_TYPES.has(type)
170
+ ? rune.rules.number().parse(parseNumeric)
171
+ : JSON_COLUMN_TYPES.has(type)
172
+ ? rune.rules.any()
173
+ : rune.rules.string();
174
+ if (!required) {
175
+ chain = chain.optional();
176
+ if (c.nullable === true)
177
+ chain = chain.nullable();
93
178
  }
179
+ fields[c.propertyKey] = chain;
94
180
  }
95
- // Boolean columns render as checkboxes: an unchecked box submits NOTHING, so
96
- // derive every boolean from presence/value. Otherwise an unchecked box can
97
- // never clear a previously-true column on edit, and a checked box would store
98
- // the raw string "1" instead of a real boolean.
181
+ return { schema: rune.schema(fields), booleanKeys };
182
+ }
183
+ /**
184
+ * Validate a request body against a derived schema (57.7). Default-fills any
185
+ * ABSENT boolean column to `false` first (unchecked checkbox = no submission),
186
+ * preserving the old `filterWritableBody` checkbox semantics, then runs rune's
187
+ * synchronous, never-throwing `validateResult()`. On success `result.data` holds only
188
+ * declared-column keys (mass-assignment inherent). Exported for the unit tests
189
+ * that pin the coercion CRUX.
190
+ */
191
+ export function validateWritableBody(schema, booleanKeys, body) {
192
+ const input = { ...body };
99
193
  for (const key of booleanKeys) {
100
- writable[key] = isCheckedValue(body[key]);
194
+ if (!(key in input))
195
+ input[key] = false;
196
+ }
197
+ return schema.validateResult(input);
198
+ }
199
+ /**
200
+ * Form-post number coercion: string→Number, NaN falls through to a type error.
201
+ * `null`/`undefined` pass through untouched so rune's optional/nullable handling
202
+ * applies (never `Number(null) === 0`), and a blank/whitespace-only string —
203
+ * a cleared optional field — becomes `null` (treated as absent) rather than a
204
+ * fabricated `0`.
205
+ */
206
+ function parseNumeric(value) {
207
+ if (value === null || value === undefined)
208
+ return value;
209
+ if (typeof value === "string") {
210
+ const trimmed = value.trim();
211
+ if (trimmed === "")
212
+ return null;
213
+ const n = Number(trimmed);
214
+ return Number.isNaN(n) ? value : n;
101
215
  }
102
- for (const [key, value] of Object.entries(body)) {
103
- if (!validKeys.has(key))
216
+ const n = Number(value);
217
+ return Number.isNaN(n) ? value : n;
218
+ }
219
+ /**
220
+ * Collapse rune's flat `ValidationError[]` to one message per top-level field
221
+ * for the HTML form re-render (`form.inker` keys errors by column propertyKey).
222
+ * The `_root` non-object error (a body that isn't a record — never happens for
223
+ * a parsed form post) is skipped.
224
+ */
225
+ function fieldErrors(errors) {
226
+ const out = {};
227
+ for (const e of errors) {
228
+ if (e.field === "_root")
104
229
  continue;
105
- if (booleanKeys.has(key))
106
- continue; // already derived above
107
- writable[key] = value;
230
+ // Collapse a nested VineJS-style path (`profile.name`) to its top-level
231
+ // key — `form.ts` keys errors by column propertyKey. `split` always yields
232
+ // a non-empty array, so `[0]` is a string (no fallback needed).
233
+ const key = e.field.split(".")[0];
234
+ if (!(key in out))
235
+ out[key] = e.message;
108
236
  }
109
- return writable;
237
+ return out;
110
238
  }
111
- /** A checkbox/boolean field is true only for its checked submissions. */
239
+ /** Session flash key under which the per-field validation errors are stashed (AdonisJS `flashMessages.errors` parity). */
240
+ const FLASH_ERRORS_KEY = "errors";
241
+ /** Column propertyKeys whose values are secrets — never flashed to the session nor echoed back into HTML (AdonisJS parity). */
242
+ const SENSITIVE_KEY_RE = /password|passwd|secret|token|apikey/i;
243
+ /**
244
+ * The subset of a submitted body safe to re-surface on a validation error:
245
+ * declared columns only — so `_csrf`, `_method`, and any mass-assignment /
246
+ * arbitrary key are dropped from the flash store — minus sensitive fields (a
247
+ * password is never written to the session nor reflected into the re-rendered
248
+ * form). An empty result lets the caller fall back to the existing `row`.
249
+ */
250
+ function flashableInput(submitted, columns) {
251
+ const out = {};
252
+ for (const c of columns) {
253
+ if (SENSITIVE_KEY_RE.test(c.propertyKey))
254
+ continue;
255
+ if (c.propertyKey in submitted)
256
+ out[c.propertyKey] = submitted[c.propertyKey];
257
+ }
258
+ return out;
259
+ }
260
+ function isStringRecord(value) {
261
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
262
+ return false;
263
+ }
264
+ return Object.values(value).every((v) => typeof v === "string");
265
+ }
266
+ /**
267
+ * Read flashed old input + per-field errors for a form `GET` re-render after a
268
+ * failed write redirected back (57.7, AdonisJS PRG). Returns `{}` when nothing
269
+ * was flashed (a normal GET) — the form then renders from `row`/empty as usual.
270
+ * `values` is restricted to declared column keys so the flashed `errors` bag (or
271
+ * any other flashed key) never leaks into a field value.
272
+ */
273
+ function readFlash(ctx, columns) {
274
+ const flashed = ctx.session?.flashMessages.all() ?? {};
275
+ if (Object.keys(flashed).length === 0)
276
+ return {};
277
+ const values = {};
278
+ for (const c of columns) {
279
+ // `errors` is reserved for the per-field error bag — a column literally
280
+ // named `errors` must not receive that object as its field value.
281
+ if (c.propertyKey === FLASH_ERRORS_KEY)
282
+ continue;
283
+ if (c.propertyKey in flashed)
284
+ values[c.propertyKey] = flashed[c.propertyKey];
285
+ }
286
+ const rawErrors = flashed[FLASH_ERRORS_KEY];
287
+ return {
288
+ values: Object.keys(values).length > 0 ? values : undefined,
289
+ errors: isStringRecord(rawErrors) ? rawErrors : undefined,
290
+ };
291
+ }
292
+ /**
293
+ * Same-origin / relative check for a `Referer` — replicates ream's
294
+ * `RedirectBuilder.back()` open-redirect guard (Station consumes the response
295
+ * through its own `redirect(url)` seam and can't reach ream's builder). A
296
+ * relative path is trusted; an absolute URL only when its origin matches the
297
+ * request URL. Anything else is untrusted → the caller uses its safe fallback.
298
+ */
299
+ function isSameOriginReferer(referer, requestUrl) {
300
+ // A relative path is trusted only when it starts with a single "/" and
301
+ // contains no backslash: browsers normalise "\\" to "/", so "/\\evil.com"
302
+ // becomes the protocol-relative "//evil.com" that a lone `!startsWith("//")`
303
+ // check would let through.
304
+ if (referer.startsWith("/") &&
305
+ !referer.startsWith("//") &&
306
+ !referer.includes("\\")) {
307
+ return true;
308
+ }
309
+ if (requestUrl === undefined)
310
+ return false;
311
+ try {
312
+ return new URL(referer).origin === new URL(requestUrl).origin;
313
+ }
314
+ catch {
315
+ return false;
316
+ }
317
+ }
318
+ /**
319
+ * `redirect().back()` over Station's `redirect(url)` seam (57.7): prefer a
320
+ * trusted same-origin `Referer`, else the caller's `fallback` (the form URL) —
321
+ * the same behaviour as ream's `RedirectBuilder.back(fallback)`, open-redirect
322
+ * safe.
323
+ */
324
+ function redirectBack(ctx, fallback) {
325
+ const referer = ctx.request.header?.("referer");
326
+ const target = typeof referer === "string" &&
327
+ isSameOriginReferer(referer, ctx.request.url?.())
328
+ ? referer
329
+ : fallback;
330
+ ctx.response.redirect(target);
331
+ }
332
+ /**
333
+ * A checkbox/boolean field is true only for its checked submissions. Duplicate
334
+ * keys (a hidden `0` companion + the checkbox `1`) arrive as an array — the box
335
+ * is checked if ANY submitted value is a checked token (the checkbox wins).
336
+ */
112
337
  function isCheckedValue(value) {
338
+ if (Array.isArray(value))
339
+ return value.some(isCheckedValue);
113
340
  return value === true || value === "1" || value === "on" || value === "true";
114
341
  }
115
342
  /**
@@ -154,9 +381,24 @@ function snapshotEntity(entity, columns) {
154
381
  }
155
382
  }
156
383
  let auditCloneWarnEmitted = false;
384
+ /**
385
+ * Filesystem path to the `.inker` templates Station ships inside the
386
+ * package (57.1, D3). Resolved from `import.meta.url` so the SAME path
387
+ * holds src-run (dev, `exports` → `./src/StationProvider.ts`) and dist-run
388
+ * (published, `./dist/StationProvider.js`) — `templates/` is a sibling of
389
+ * both `src/` and `dist/`. `fileURLToPath` (not `URL.pathname`) so a repo
390
+ * path containing spaces or reserved characters decodes correctly.
391
+ */
392
+ const TEMPLATES_ROOT = fileURLToPath(new URL("../templates/", import.meta.url));
157
393
  export default class StationProvider {
158
394
  app;
159
395
  #contexts = new Map();
396
+ #viewRenderer;
397
+ /**
398
+ * Distinct 404-render failure modes already logged (fingerprints), scoped
399
+ * per instance so one provider's fault never suppresses another's.
400
+ */
401
+ #notFoundRenderFailCauses = new Set();
160
402
  #started = false;
161
403
  // 54.7 auth state — populated when warden is wired AND
162
404
  // StationConfig.requireAuth is true (default when warden is present).
@@ -181,12 +423,12 @@ export default class StationProvider {
181
423
  async boot() {
182
424
  // Force-resolve so `setStation` runs even if no preload touches the
183
425
  // singleton. Mirrors AuroraProvider.boot().
184
- this.app.container.resolve(ResourceRegistry);
426
+ await this.app.container.resolve(ResourceRegistry);
185
427
  }
186
428
  async start() {
187
429
  if (this.#started)
188
430
  return;
189
- const registry = this.app.container.resolve(ResourceRegistry);
431
+ const registry = await this.app.container.resolve(ResourceRegistry);
190
432
  const resources = registry.all();
191
433
  if (resources.length === 0)
192
434
  return;
@@ -200,24 +442,44 @@ export default class StationProvider {
200
442
  const peers = await this.#loadPeers();
201
443
  if (!peers)
202
444
  return;
203
- const { router, atlas } = peers;
445
+ const { router, atlas, rune } = peers;
446
+ // 57.7 fail-closed: a write-capable admin (create/edit) validates + guards
447
+ // mass-assignment through a rune-derived schema. If atlas is present (CRUD
448
+ // is live) but `@c9up/rune` is NOT installed, refuse to boot rather than
449
+ // silently accept unvalidated bodies — there is NO fallback to the deleted
450
+ // key-filter (`feedback_security_first`). Read-only admins need no rune.
451
+ if (rune === null && resourcesNeedValidation(resources)) {
452
+ 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.");
453
+ }
454
+ // Phase 1a2 — resolve the SHARED inker renderer (AdonisJS/Edge parity)
455
+ // and mount Station's package templates as the `station` disk. Unlike
456
+ // warden (whose absence keeps the open dev-preview path), a view engine
457
+ // is a HARD render requirement (D2): once an admin surface exists there
458
+ // is no page without it. Fail LOUD at boot when @c9up/inker is not wired
459
+ // — mirrors #resolveDb's loud-on-missing posture — rather than degrade
460
+ // silently or 500 on the first request.
461
+ const renderer = await this.#resolveViewRenderer();
462
+ // Mount once: `station::<template>` now resolves to this package's
463
+ // templates/ dir on the host's single inker engine (edge.mount parity).
464
+ renderer.mount("station", TEMPLATES_ROOT);
465
+ this.#viewRenderer = renderer;
204
466
  // Phase 1b — wire the warden auth gate (or warn-once when it stays open).
205
- this.#configureAuth(userConfig);
467
+ await this.#configureAuth(userConfig);
206
468
  // Phase 2 — build per-resource context ONCE. `#resolveDb()` is
207
469
  // loud: if the host installed `@c9up/atlas` but didn't register
208
470
  // `@c9up/atlas/provider` in `reamrc.ts`, AC11's "surface
209
471
  // AtlasProvider misconfiguration" intent kicks in.
210
- const db = this.#resolveDb();
472
+ const db = await this.#resolveDb();
211
473
  for (const resource of resources) {
212
- this.#contexts.set(resource, buildResourceContext(resource, db, atlas));
474
+ this.#contexts.set(resource, buildResourceContext(resource, db, atlas, rune));
213
475
  }
214
- // 56.5 + 54.6 + CSRF boot-time warn-onces. We surface the "auth
215
- // wired but no permissions seeded", "no audit sink", and "no CSRF
216
- // check" gaps loud-and-once so a half-wired install can't ship to
217
- // prod without operators noticing.
476
+ // 56.5 + 54.6 boot-time warn-onces. We surface the "auth wired but no
477
+ // permissions seeded" and "no audit sink" gaps loud-and-once so a
478
+ // half-wired install can't ship to prod without operators noticing.
479
+ // (CSRF is no longer a boot-time warn — enforcement is request-time and
480
+ // honest now: every write route fail-closes on `ctx.request.csrfProtected`.)
218
481
  this.#warnSeedPermissionsOnce(resources);
219
482
  this.#warnAuditGapsOnce(resources);
220
- this.#warnCsrfGapOnce(resources);
221
483
  // Phase 3 — route registration. The router proxy may still throw
222
484
  // "Router accessed before initialization" on first property access
223
485
  // (boot ordering hazard where the proxy module imported but
@@ -248,10 +510,33 @@ export default class StationProvider {
248
510
  async #loadPeers() {
249
511
  if (!this.app.container.has("router"))
250
512
  return null;
251
- const router = this.app.container.resolve("router");
513
+ const router = await this.app.container.resolve("router");
252
514
  try {
253
515
  const atlas = loadBearingCast(await import("@c9up/atlas"));
254
- return { router, atlas };
516
+ // rune is loaded here but its ABSENCE is NOT a degraded-host signal the
517
+ // way atlas's is: atlas-missing ⇒ no CRUD at all (silent stop), whereas
518
+ // rune-missing while a write resource IS mounted is a fail-CLOSED
519
+ // misconfiguration (start() throws loud — see below). So `#loadRune`
520
+ // swallows only module-not-found → null; start() decides whether that
521
+ // null is fatal.
522
+ const rune = await this.#loadRune();
523
+ return { router, atlas, rune };
524
+ }
525
+ catch (err) {
526
+ if (isModuleNotFound(err))
527
+ return null;
528
+ throw err;
529
+ }
530
+ }
531
+ /**
532
+ * Tolerantly load `@c9up/rune` (57.7) — the SAME seam as `@c9up/atlas`
533
+ * (dynamic module import, module-not-found → null, real error re-thrown).
534
+ * Returning null does NOT degrade the host: the caller fails loud only when
535
+ * a write-capable resource actually needs the validator (AC7, fail-closed).
536
+ */
537
+ async #loadRune() {
538
+ try {
539
+ return loadBearingCast(await import("@c9up/rune"));
255
540
  }
256
541
  catch (err) {
257
542
  if (isModuleNotFound(err))
@@ -259,16 +544,117 @@ export default class StationProvider {
259
544
  throw err;
260
545
  }
261
546
  }
547
+ /**
548
+ * Resolve the host's shared inker renderer from the container (the `"inker"`
549
+ * alias `InkerProvider` binds) — the AdonisJS package-views pattern (57.1,
550
+ * D1/D2). A view engine is mandatory once an admin surface exists, so an
551
+ * unbound or not-yet-ready `"inker"` throws a clear, actionable error at
552
+ * boot (mirrors `#resolveDb`'s loud-on-missing), rather than a silent
553
+ * degrade or a runtime 500. Consumed via the container only — never a
554
+ * static or dynamic `import "@c9up/inker"` (D5), exactly like warden.
555
+ */
556
+ async #resolveViewRenderer() {
557
+ if (!this.app.container.has("inker")) {
558
+ throw new Error("[station] register @c9up/inker (InkerProvider) to render admin views");
559
+ }
560
+ try {
561
+ const resolved = await this.app.container.resolve("inker");
562
+ if (!isInkerViewRenderer(resolved)) {
563
+ throw new Error('the "inker" binding is not a usable view renderer (missing mount/renderToString)');
564
+ }
565
+ return resolved;
566
+ }
567
+ catch (cause) {
568
+ throw new Error("[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.", { cause });
569
+ }
570
+ }
571
+ /**
572
+ * Render Station's 404 page through the shared inker renderer (57.1).
573
+ * Builds the same data the retired `renderNotFoundPage` composed and
574
+ * renders the `station::errors/404` template (the package's mounted disk);
575
+ * inker's `{{ }}` auto-escaping replaces the hand-rolled escaper for
576
+ * this view. `slug` is `encodeURIComponent`-safe (no HTML-special output)
577
+ * so its auto-escape is a no-op in the back-link `href`. The 404 uses no
578
+ * inker helpers, so a minimal render ctx is sufficient.
579
+ */
580
+ async #renderNotFound(ctx, resource, id) {
581
+ if (this.#viewRenderer === undefined) {
582
+ throw new Error("[station] view engine not initialised");
583
+ }
584
+ const renderCtx = {
585
+ request: ctx.request,
586
+ response: ctx.response,
587
+ store: new Map(),
588
+ locale: "en",
589
+ };
590
+ try {
591
+ return await this.#viewRenderer.renderToString(renderCtx, "station::errors/404", {
592
+ title: "Not Found",
593
+ id,
594
+ label: resource.label,
595
+ labelLower: resource.label.toLowerCase(),
596
+ slug: encodeURIComponent(resource.name),
597
+ });
598
+ }
599
+ catch (cause) {
600
+ // The error page itself failed to render (missing/faulty template,
601
+ // fs fault, parse error). A not-found must never escalate into a 500:
602
+ // fall back to a minimal, dependency-free static body. `id` is
603
+ // request-controlled, so it is HTML-escaped here. The fault is logged
604
+ // so a persistent deploy misconfig (unpublished templates/, corrupt
605
+ // station::layout) is not silently swallowed — but once per failure
606
+ // MODE per instance, not once per request: a durably-broken template
607
+ // on a 404-sprayed endpoint must not turn attacker traffic into
608
+ // unbounded log volume, while a genuinely new failure mode still
609
+ // surfaces its `cause`. The fingerprint keys on code/name only (never
610
+ // the request-controlled message) and is capped, so both stay bounded.
611
+ const fingerprint = notFoundFailFingerprint(cause);
612
+ if (this.#notFoundRenderFailCauses.size < NOT_FOUND_RENDER_FAIL_CAP &&
613
+ !this.#notFoundRenderFailCauses.has(fingerprint)) {
614
+ this.#notFoundRenderFailCauses.add(fingerprint);
615
+ console.error(`[station] failed to render station::errors/404 (${fingerprint}) — serving the minimal fallback page; repeats of this failure mode are suppressed this instance`, cause);
616
+ }
617
+ 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>`;
618
+ }
619
+ }
620
+ /**
621
+ * Render a Station template through the shared inker renderer (57.2). Builds
622
+ * the same minimal render ctx as `#renderNotFound` (the list/show views use
623
+ * no inker helpers) and delegates escaping to inker's `{{ }}`. Used by the
624
+ * list + show handlers. Unlike the retired hand-rolled builders — pure
625
+ * `(input) => string` functions that could only throw on their own logic —
626
+ * this path can additionally fault on template lookup, fs, or parse errors.
627
+ * Such a fault propagates to the root error boundary as a 500, which is the
628
+ * correct outcome on the success path: a broken list/show template is a real
629
+ * server error and must not be masked as a degraded 200. (`#renderNotFound`
630
+ * wraps its render in a static fallback only because a not-found is already
631
+ * an error response that must never escalate into a 500.)
632
+ */
633
+ async #renderView(ctx, name, data) {
634
+ if (this.#viewRenderer === undefined) {
635
+ throw new Error("[station] view engine not initialised");
636
+ }
637
+ // Thread the real per-request store (57.3/D3) so the als-backed inker
638
+ // helpers — `csrfField()` on the form/login views — read the token. Inert
639
+ // for list/show, whose templates call no helpers.
640
+ const renderCtx = {
641
+ request: ctx.request,
642
+ response: ctx.response,
643
+ store: ctx.store ?? new Map(),
644
+ locale: "en",
645
+ };
646
+ return await this.#viewRenderer.renderToString(renderCtx, name, data);
647
+ }
262
648
  /**
263
649
  * Wire the warden auth gate from the `station` config block. When warden is
264
650
  * not installed/bound the gate stays open and a one-time warning is emitted.
265
651
  */
266
- #configureAuth(userConfig) {
652
+ async #configureAuth(userConfig) {
267
653
  const wardenWanted = userConfig.requireAuth !== false;
268
654
  if (wardenWanted) {
269
655
  try {
270
656
  this.#authManager =
271
- this.app.container.resolve("auth");
657
+ await this.app.container.resolve("auth");
272
658
  this.#authConfig = {
273
659
  requireAuth: true,
274
660
  requireRole: userConfig.requireRole,
@@ -355,20 +741,6 @@ export default class StationProvider {
355
741
  const example = resources[0];
356
742
  console.warn(`[station] Admin actions are gated behind '<resource>.<action>' permissions resolved through @c9up/warden (e.g. '${example.name}.list', '${example.name}.create'). Seed roles/grants in the Warden rights store (store.defineRole('admin', ['${example.name}.list', '${example.name}.create', ...]) then assignRole) or every admin request will 403. See https://ream.dev/modules/station#authorization.`);
357
743
  }
358
- #warnCsrfGapOnce(resources) {
359
- if (csrfWarnEmitted)
360
- return;
361
- const writeActions = [
362
- "create",
363
- "edit",
364
- "destroy",
365
- ];
366
- const writeEnabled = resources.some((r) => r.actions.some((a) => writeActions.includes(a)));
367
- if (!writeEnabled)
368
- return;
369
- csrfWarnEmitted = true;
370
- console.warn("[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.");
371
- }
372
744
  #warnAuditGapsOnce(resources) {
373
745
  if (missingAuditWarned)
374
746
  return;
@@ -393,6 +765,78 @@ export default class StationProvider {
393
765
  }
394
766
  return ctx;
395
767
  }
768
+ /**
769
+ * The derived rune schema for a write handler (57.7). Present by construction
770
+ * — start() fail-closes at boot when a write action is mounted without
771
+ * `@c9up/rune` — so an undefined here is an internal invariant break; throw
772
+ * loud (fail-closed) rather than silently accept an unvalidated body.
773
+ */
774
+ #requireWritableSchema(resource) {
775
+ const { writableSchema } = this.#requireContext(resource);
776
+ if (writableSchema === undefined) {
777
+ throw new Error(`[station] No validation schema for ${resource.entity.name} — @c9up/rune must be installed to accept admin writes.`);
778
+ }
779
+ return writableSchema;
780
+ }
781
+ /**
782
+ * Fail-closed response for invalid write input (57.7, AC4). Content-negotiated:
783
+ *
784
+ * - **JSON/XHR** → `422` with `{ error, code: "E_VALIDATION_ERROR", messages }`
785
+ * (Adonis/Vine parity — API clients get the machine-readable errors).
786
+ * - **HTML with a session** → the AdonisJS web idiom (PRG): flash the old
787
+ * input + per-field errors, then `redirect().back()` to the form `GET`,
788
+ * which re-reads the flash and re-renders with values + errors. No inline
789
+ * body — the browser lands on a fresh form (no re-POST on refresh).
790
+ * - **HTML without a session** → graceful fallback: re-render the form inline
791
+ * at `422` so the errors are never silently lost when no `SessionMiddleware`
792
+ * ran. (`row` supplies the id/action on EDIT; undefined keeps CREATE mode.)
793
+ *
794
+ * `redirect().back()` semantics are replicated over Station's `redirect(url)`
795
+ * seam ({@link redirectBack}) with the same same-origin guard ream's
796
+ * `RedirectBuilder.back()` uses — `formUrl` is the safe fallback.
797
+ */
798
+ async #denyValidation(ctx, resource, submitted, errors, formUrl, row) {
799
+ if (wantsJsonResponse(ctx)) {
800
+ ctx.response.status(422);
801
+ // AdonisJS/VineJS parity: `{ errors: [{ field, rule, message }] }` —
802
+ // the same shape ream's own `E_VALIDATION_ERROR` handler emits.
803
+ ctx.response.json({
804
+ errors: errors.map((e) => ({
805
+ field: e.field,
806
+ rule: e.rule,
807
+ message: e.message,
808
+ })),
809
+ });
810
+ return;
811
+ }
812
+ // Echo only declared, non-sensitive columns back to the form — never
813
+ // `_csrf`, secrets, or mass-assignment keys (both the flash store and the
814
+ // re-rendered HTML). An empty result falls back to `row`.
815
+ const { columns, pkColumn } = this.#requireContext(resource);
816
+ const safe = flashableInput(submitted, columns);
817
+ if (ctx.session !== undefined) {
818
+ // AdonisJS PRG: flash old input + errors, redirect back to the form.
819
+ ctx.session.flashAll(safe);
820
+ ctx.session.flash(FLASH_ERRORS_KEY, fieldErrors(errors));
821
+ redirectBack(ctx, formUrl);
822
+ return;
823
+ }
824
+ // No session middleware — fall back to an inline 422 re-render so the
825
+ // submitted values + errors are still shown rather than silently dropped.
826
+ const vm = buildFormViewModel({
827
+ resource,
828
+ columns,
829
+ pkColumn,
830
+ row,
831
+ values: Object.keys(safe).length > 0 ? safe : undefined,
832
+ errors: fieldErrors(errors),
833
+ csrfEnabled: ctx.store?.get("csrfToken") != null,
834
+ });
835
+ ctx.response.status(422);
836
+ const html = await this.#renderView(ctx, "station::form", vm);
837
+ ctx.response.type("text/html; charset=utf-8");
838
+ ctx.response.send(html);
839
+ }
396
840
  #buildListHandler(resource) {
397
841
  return async (ctx) => {
398
842
  const { repo, columns, pkColumn } = this.#requireContext(resource);
@@ -412,29 +856,33 @@ export default class StationProvider {
412
856
  perPageClampWarned = true;
413
857
  console.warn(`[station] perPage clamped to ${MAX_PER_PAGE} (got ${perPageRaw}). Suppressing further warnings.`);
414
858
  }
415
- const total = await repo.query().count();
416
- const lastPage = Math.max(1, Math.ceil(total / perPage));
417
- if (page > lastPage && total > 0) {
859
+ // De-hand-rolled (57.5): atlas `paginate()` runs the parallel
860
+ // COUNT(*) + LIMIT/OFFSET data fetch in one call and owns the
861
+ // `lastPage = ceil(total/perPage)` math Station used to duplicate.
862
+ // Station keeps only the `MAX_PER_PAGE` clamp (above) and the
863
+ // redirect-to-last-page wrapper — atlas floors perPage to ≥1 but
864
+ // does not cap it.
865
+ const result = await repo
866
+ .query()
867
+ .orderBy(pkColumn, "desc")
868
+ .paginate(page, perPage);
869
+ if (page > result.meta.lastPage && result.meta.total > 0) {
418
870
  // Never render an empty page when one exists — redirect to the
419
871
  // last real page so the user lands on something useful.
420
- ctx.response.redirect(`/admin/${resource.name}?page=${lastPage}&perPage=${perPage}`);
872
+ ctx.response.redirect(`/admin/${resource.name}?page=${result.meta.lastPage}&perPage=${perPage}`);
421
873
  return;
422
874
  }
423
- const rows = await repo
424
- .query()
425
- .orderBy(pkColumn, "desc")
426
- .forPage(page, perPage)
427
- .exec();
428
- const html = renderListPage({
875
+ const viewModel = buildListViewModel({
429
876
  resource,
430
- rows,
877
+ rows: result.all(),
431
878
  columns,
432
879
  pkColumn,
433
- page,
434
- perPage,
435
- total,
436
- lastPage,
880
+ page: result.meta.currentPage,
881
+ perPage: result.meta.perPage,
882
+ total: result.meta.total,
883
+ lastPage: result.meta.lastPage,
437
884
  });
885
+ const html = await this.#renderView(ctx, "station::list", viewModel);
438
886
  ctx.response.type("text/html; charset=utf-8");
439
887
  ctx.response.send(html);
440
888
  };
@@ -452,17 +900,19 @@ export default class StationProvider {
452
900
  const id = ctx.params.id ?? "";
453
901
  const row = await repo.find(id);
454
902
  if (row === null) {
903
+ const notFoundHtml = await this.#renderNotFound(ctx, resource, id);
455
904
  ctx.response.status(404);
456
905
  ctx.response.type("text/html; charset=utf-8");
457
- ctx.response.send(renderNotFoundPage({ resource, id }));
906
+ ctx.response.send(notFoundHtml);
458
907
  return;
459
908
  }
460
- const html = renderShowPage({
909
+ const viewModel = buildShowViewModel({
461
910
  resource,
462
911
  row,
463
912
  columns,
464
913
  pkColumn,
465
914
  });
915
+ const html = await this.#renderView(ctx, "station::show", viewModel);
466
916
  ctx.response.type("text/html; charset=utf-8");
467
917
  ctx.response.send(html);
468
918
  };
@@ -474,32 +924,51 @@ export default class StationProvider {
474
924
  deny(ctx);
475
925
  return;
476
926
  }
477
- const html = renderFormPage({
927
+ // 57.7 — after a failed create redirected back (PRG), the flash carries
928
+ // the old input + per-field errors; re-populate + surface them.
929
+ const { values, errors } = readFlash(ctx, columns);
930
+ const vm = buildFormViewModel({
478
931
  resource,
479
932
  columns,
480
933
  pkColumn,
481
- hiddenInputs: csrfHiddenInputs(ctx),
934
+ values,
935
+ errors,
936
+ csrfEnabled: ctx.store?.get("csrfToken") != null,
482
937
  });
938
+ const html = await this.#renderView(ctx, "station::form", vm);
483
939
  ctx.response.type("text/html; charset=utf-8");
484
940
  ctx.response.send(html);
485
941
  };
486
942
  }
487
943
  #buildCreateHandler(resource) {
488
944
  return async (ctx) => {
489
- const { repo, pkColumn, columns, autoManaged } = this.#requireContext(resource);
945
+ // Fail-close: reject a request whose CSRF was not enforced+validated
946
+ // (blackhole unwired / csrf:false / route excepted) BEFORE any auth or
947
+ // DB work — no 404-vs-403 oracle to a forged request. A seeded token is
948
+ // not proof; only `csrfProtected === true` is (AC4/AC7).
949
+ if (ctx.request.csrfProtected !== true) {
950
+ denyCsrf(ctx);
951
+ return;
952
+ }
953
+ const { repo, pkColumn, columns, booleanKeys } = this.#requireContext(resource);
490
954
  if (!(await authorizeAction(resource, "create", ctx, this.#authManager))) {
491
955
  deny(ctx);
492
956
  return;
493
957
  }
494
958
  const body = await readBody(ctx);
495
- // Mass-assignment guard: only `@Column`-declared properties make
496
- // it through to repo.create(). An attacker who POSTs
497
- // `{ role: "admin" }` or `{ passwordHash: "x" }` against a
498
- // resource that doesn't declare those columns has the keys
499
- // silently dropped here. PK + framework timestamps are always
500
- // excluded — they're decided by the DB / hooks, not the caller.
501
- const filtered = filterWritableBody(body, columns, pkColumn, autoManaged);
502
- const created = await repo.create(filtered);
959
+ // 57.7 — validate + mass-assignment guard in one pass. `result.data`
960
+ // holds ONLY declared-column keys, so an attacker's `{ role: "admin" }`
961
+ // / `{ passwordHash: "x" }` / PK / timestamps are never present (dropped
962
+ // by the schema, not a separate filter), and each field is type-checked.
963
+ // Invalid input fail-closes with a 422 BEFORE any repository write (AC4).
964
+ const result = validateWritableBody(this.#requireWritableSchema(resource), booleanKeys, body);
965
+ if (!result.valid) {
966
+ // PRG fallback URL = the create form (AdonisJS `redirect().back()`).
967
+ const formUrl = `/admin/${encodeURIComponent(resource.name)}/new`;
968
+ await this.#denyValidation(ctx, resource, body, result.errors, formUrl);
969
+ return;
970
+ }
971
+ const created = await repo.create(result.data);
503
972
  await emitAudit(resource, {
504
973
  action: "create",
505
974
  resource: resource.name,
@@ -522,25 +991,38 @@ export default class StationProvider {
522
991
  const id = ctx.params.id ?? "";
523
992
  const row = await repo.find(id);
524
993
  if (row === null) {
994
+ const notFoundHtml = await this.#renderNotFound(ctx, resource, id);
525
995
  ctx.response.status(404);
526
996
  ctx.response.type("text/html; charset=utf-8");
527
- ctx.response.send(renderNotFoundPage({ resource, id }));
997
+ ctx.response.send(notFoundHtml);
528
998
  return;
529
999
  }
530
- const html = renderFormPage({
1000
+ // 57.7 — after a failed update redirected back (PRG), the flash carries
1001
+ // the submitted values + errors; they win over the DB `row` so the user
1002
+ // sees what they typed. A normal edit GET has no flash → fields from row.
1003
+ const { values, errors } = readFlash(ctx, columns);
1004
+ const vm = buildFormViewModel({
531
1005
  resource,
532
1006
  columns,
533
1007
  pkColumn,
534
1008
  row,
535
- hiddenInputs: csrfHiddenInputs(ctx),
1009
+ values,
1010
+ errors,
1011
+ csrfEnabled: ctx.store?.get("csrfToken") != null,
536
1012
  });
1013
+ const html = await this.#renderView(ctx, "station::form", vm);
537
1014
  ctx.response.type("text/html; charset=utf-8");
538
1015
  ctx.response.send(html);
539
1016
  };
540
1017
  }
541
1018
  #buildUpdateHandler(resource) {
542
1019
  return async (ctx) => {
543
- const { repo, pkColumn, columns, autoManaged } = this.#requireContext(resource);
1020
+ // Fail-close on CSRF before any auth/DB work (see #buildCreateHandler).
1021
+ if (ctx.request.csrfProtected !== true) {
1022
+ denyCsrf(ctx);
1023
+ return;
1024
+ }
1025
+ const { repo, pkColumn, columns, booleanKeys } = this.#requireContext(resource);
544
1026
  // Authorize before the existence check (no 404-vs-403 oracle).
545
1027
  if (!(await authorizeAction(resource, "edit", ctx, this.#authManager))) {
546
1028
  deny(ctx);
@@ -549,24 +1031,32 @@ export default class StationProvider {
549
1031
  const id = ctx.params.id ?? "";
550
1032
  const entity = await repo.find(id);
551
1033
  if (entity === null) {
1034
+ const notFoundHtml = await this.#renderNotFound(ctx, resource, id);
552
1035
  ctx.response.status(404);
553
1036
  ctx.response.type("text/html; charset=utf-8");
554
- ctx.response.send(renderNotFoundPage({ resource, id }));
1037
+ ctx.response.send(notFoundHtml);
555
1038
  return;
556
1039
  }
557
1040
  const body = await readBody(ctx);
1041
+ // 57.7 — validate BEFORE any snapshot or mutation (AC4). On failure the
1042
+ // entity is untouched (no `setProp`, no `save`), so a bad edit never
1043
+ // poisons the audit diff nor emits a success event; the form re-renders
1044
+ // 422 with the submitted values + per-field errors (the existing entity
1045
+ // still supplies the id/action).
1046
+ const result = validateWritableBody(this.#requireWritableSchema(resource), booleanKeys, body);
1047
+ if (!result.valid) {
1048
+ // PRG fallback URL = this row's edit form (AdonisJS `redirect().back()`).
1049
+ const formUrl = `/admin/${encodeURIComponent(resource.name)}/${encodeURIComponent(id)}/edit`;
1050
+ await this.#denyValidation(ctx, resource, body, result.errors, formUrl, entity);
1051
+ return;
1052
+ }
558
1053
  // Snapshot BEFORE the mutation runs so the audit diff is
559
1054
  // meaningful (entity is a BaseEntity; its dirty-tracking
560
- // would shadow the original values after setProp).
1055
+ // would shadow the original values after setProp). `result.data`
1056
+ // holds only declared, type-checked columns — the mass-assignment
1057
+ // guarantee (PK / timestamps / unknown keys dropped) is inherent.
561
1058
  const beforeSnapshot = snapshotEntity(entity, columns);
562
- // Mass-assignment guard: filterWritableBody drops every key
563
- // that isn't an `@Column`-declared property, plus the PK and
564
- // any framework timestamps (created_at / updated_at /
565
- // deleted_at). An attacker who POSTs `{ role: "admin" }` to
566
- // a resource without that column has the field silently
567
- // dropped instead of overwriting the entity.
568
- const writable = filterWritableBody(body, columns, pkColumn, autoManaged);
569
- for (const [key, value] of Object.entries(writable)) {
1059
+ for (const [key, value] of Object.entries(result.data)) {
570
1060
  entity.setProp(key, value);
571
1061
  }
572
1062
  await repo.save(entity);
@@ -592,6 +1082,14 @@ export default class StationProvider {
592
1082
  const updateHandler = this.#buildUpdateHandler(resource);
593
1083
  const destroyHandler = this.#buildDestroyHandler(resource);
594
1084
  return async (ctx) => {
1085
+ // Fail-close on CSRF before reading the body (see #buildCreateHandler).
1086
+ // The PUT/DELETE branches delegate to already-guarded handlers, but the
1087
+ // unsupported-override 405 branch would otherwise do work on a forged
1088
+ // request and leak a 403-vs-405 oracle — guard the entrypoint too.
1089
+ if (ctx.request.csrfProtected !== true) {
1090
+ denyCsrf(ctx);
1091
+ return;
1092
+ }
595
1093
  const body = await readBody(ctx);
596
1094
  const override = String(body._method ?? "").toUpperCase();
597
1095
  if (override === "PUT" || override === "PATCH") {
@@ -609,6 +1107,11 @@ export default class StationProvider {
609
1107
  }
610
1108
  #buildDestroyHandler(resource) {
611
1109
  return async (ctx) => {
1110
+ // Fail-close on CSRF before any auth/DB work (see #buildCreateHandler).
1111
+ if (ctx.request.csrfProtected !== true) {
1112
+ denyCsrf(ctx);
1113
+ return;
1114
+ }
612
1115
  const { repo, pkColumn, columns } = this.#requireContext(resource);
613
1116
  // Authorize before the existence check (no 404-vs-403 oracle).
614
1117
  if (!(await authorizeAction(resource, "destroy", ctx, this.#authManager))) {
@@ -618,9 +1121,10 @@ export default class StationProvider {
618
1121
  const id = ctx.params.id ?? "";
619
1122
  const row = await repo.find(id);
620
1123
  if (row === null) {
1124
+ const notFoundHtml = await this.#renderNotFound(ctx, resource, id);
621
1125
  ctx.response.status(404);
622
1126
  ctx.response.type("text/html; charset=utf-8");
623
- ctx.response.send(renderNotFoundPage({ resource, id }));
1127
+ ctx.response.send(notFoundHtml);
624
1128
  return;
625
1129
  }
626
1130
  const before = snapshotEntity(row, columns);
@@ -636,9 +1140,9 @@ export default class StationProvider {
636
1140
  ctx.response.redirect(`/admin/${encodeURIComponent(resource.name)}`);
637
1141
  };
638
1142
  }
639
- #resolveDb() {
1143
+ async #resolveDb() {
640
1144
  try {
641
- return this.app.container.resolve("db");
1145
+ return await this.app.container.resolve("db");
642
1146
  }
643
1147
  catch (cause) {
644
1148
  throw new Error(`[station] No 'db' connection registered. Did you register @c9up/atlas/provider in reamrc.ts?`, { cause });
@@ -806,11 +1310,12 @@ export default class StationProvider {
806
1310
  }
807
1311
  const qs = ctx.request.qs();
808
1312
  const errorParam = qs.error;
809
- const html = renderLoginPage({
1313
+ const vm = buildLoginViewModel({
810
1314
  action: this.#authConfig.loginPath,
811
1315
  error: typeof errorParam === "string" ? errorParam : undefined,
812
- hiddenInputs: csrfHiddenInputs(ctx),
1316
+ csrfEnabled: ctx.store?.get("csrfToken") != null,
813
1317
  });
1318
+ const html = await this.#renderView(ctx, "station::login", vm);
814
1319
  ctx.response.type("text/html; charset=utf-8");
815
1320
  ctx.response.send(html);
816
1321
  };
@@ -823,6 +1328,13 @@ export default class StationProvider {
823
1328
  */
824
1329
  #buildLoginPostHandler() {
825
1330
  return async (ctx) => {
1331
+ // Fail-close on CSRF first — a forged login POST is refused before any
1332
+ // AuthManager work (see #buildCreateHandler). Login is open by design
1333
+ // (no auth gate), so CSRF is the sole pre-check here.
1334
+ if (ctx.request.csrfProtected !== true) {
1335
+ denyCsrf(ctx);
1336
+ return;
1337
+ }
826
1338
  const manager = this.#authManager;
827
1339
  if (manager === undefined) {
828
1340
  ctx.response.status(500);
@@ -834,12 +1346,13 @@ export default class StationProvider {
834
1346
  const email = typeof body.email === "string" ? body.email.trim() : "";
835
1347
  const password = typeof body.password === "string" ? body.password : "";
836
1348
  if (email.length === 0 || password.length === 0) {
837
- const html = renderLoginPage({
1349
+ const vm = buildLoginViewModel({
838
1350
  action: this.#authConfig.loginPath,
839
1351
  email,
840
1352
  error: "Email and password are both required.",
841
- hiddenInputs: csrfHiddenInputs(ctx),
1353
+ csrfEnabled: ctx.store?.get("csrfToken") != null,
842
1354
  });
1355
+ const html = await this.#renderView(ctx, "station::login", vm);
843
1356
  ctx.response.status(400);
844
1357
  ctx.response.type("text/html; charset=utf-8");
845
1358
  ctx.response.send(html);
@@ -851,12 +1364,13 @@ export default class StationProvider {
851
1364
  // every valid login down the 401 branch.
852
1365
  const token = typeof result.user?.token === "string" ? result.user.token : undefined;
853
1366
  if (!result.authenticated || token === undefined) {
854
- const html = renderLoginPage({
1367
+ const vm = buildLoginViewModel({
855
1368
  action: this.#authConfig.loginPath,
856
1369
  email,
857
1370
  error: result.error ?? "Invalid email or password.",
858
- hiddenInputs: csrfHiddenInputs(ctx),
1371
+ csrfEnabled: ctx.store?.get("csrfToken") != null,
859
1372
  });
1373
+ const html = await this.#renderView(ctx, "station::login", vm);
860
1374
  ctx.response.status(401);
861
1375
  ctx.response.type("text/html; charset=utf-8");
862
1376
  ctx.response.send(html);
@@ -878,6 +1392,12 @@ export default class StationProvider {
878
1392
  */
879
1393
  #buildLogoutHandler() {
880
1394
  return async (ctx) => {
1395
+ // Fail-close on CSRF: a forged cross-site POST must not be able to log
1396
+ // an admin out (session-fixation nuisance) (see #buildCreateHandler).
1397
+ if (ctx.request.csrfProtected !== true) {
1398
+ denyCsrf(ctx);
1399
+ return;
1400
+ }
881
1401
  ctx.response.clearCookie?.(this.#authConfig.cookieName, {
882
1402
  path: "/",
883
1403
  });
@@ -895,7 +1415,7 @@ export default class StationProvider {
895
1415
  * single helper rather than at every call site (mirrors AC9-style
896
1416
  * single-load-bearing-site convention from 54.1).
897
1417
  */
898
- function buildResourceContext(resource, db, atlas) {
1418
+ function buildResourceContext(resource, db, atlas, rune) {
899
1419
  const entityCtor = loadBearingCast(resource.entity);
900
1420
  const conn = loadBearingCast(db);
901
1421
  const repo = loadBearingCast(new atlas.BaseRepository(entityCtor, conn));
@@ -912,7 +1432,24 @@ function buildResourceContext(resource, db, atlas) {
912
1432
  const autoManaged = new Set(Object.entries(dateColumns)
913
1433
  .filter(([, cfg]) => cfg.autoCreate === true || cfg.autoUpdate === true)
914
1434
  .map(([prop]) => prop));
915
- return { repo, columns, pkColumn, autoManaged };
1435
+ // 57.7 — derive the writable validation schema from the SAME column metadata
1436
+ // the old `filterWritableBody` read. `rune === null` here means no write
1437
+ // action is mounted for this resource (start() already fail-closed the
1438
+ // atlas-present-but-rune-absent write case), so the schema stays undefined.
1439
+ const excludedProps = new Set(atlas.getRelationMetadata(resource.entity).map((r) => r.propertyKey));
1440
+ if (atlas.hasSoftDeletes(resource.entity))
1441
+ excludedProps.add("deletedAt");
1442
+ const derived = rune === null
1443
+ ? undefined
1444
+ : deriveWritableSchema(columns, pkColumn, autoManaged, excludedProps, rune);
1445
+ return {
1446
+ repo,
1447
+ columns,
1448
+ pkColumn,
1449
+ autoManaged,
1450
+ writableSchema: derived?.schema,
1451
+ booleanKeys: derived?.booleanKeys ?? new Set(),
1452
+ };
916
1453
  }
917
1454
  /**
918
1455
  * SANCTIONED CROSS-PACKAGE NARROWING — the ONE production site in
@@ -1030,6 +1567,32 @@ function deny(ctx) {
1030
1567
  ctx.response.type("text/html; charset=utf-8");
1031
1568
  ctx.response.send("<h1>403 Forbidden</h1><p>Your account does not have access to this resource action.</p>");
1032
1569
  }
1570
+ /**
1571
+ * Fail-closed CSRF denial (403), mirroring {@link deny}'s content negotiation.
1572
+ * Fires when a write reaches an admin handler without a CSRF-verified request
1573
+ * (`ctx.request.csrfProtected !== true`). The FIRST block per process also emits
1574
+ * one `console.error` — unlike the removed boot-time warning it can never
1575
+ * false-alarm (it accompanies a real unprotected write attempt), pointing the
1576
+ * operator at the actual misconfiguration (blackhole unwired / `csrf: false` /
1577
+ * `/admin/*` excepted).
1578
+ */
1579
+ function denyCsrf(ctx) {
1580
+ if (!csrfBlockedWarned) {
1581
+ csrfBlockedWarned = true;
1582
+ console.error("[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.");
1583
+ }
1584
+ ctx.response.status(403);
1585
+ if (wantsJsonResponse(ctx)) {
1586
+ ctx.response.json({
1587
+ error: "Forbidden",
1588
+ code: "CSRF_REQUIRED",
1589
+ message: "This admin action requires an active, verified CSRF token.",
1590
+ });
1591
+ return;
1592
+ }
1593
+ ctx.response.type("text/html; charset=utf-8");
1594
+ ctx.response.send("<h1>403 Forbidden</h1><p>This admin action requires an active, verified CSRF token.</p>");
1595
+ }
1033
1596
  /**
1034
1597
  * Memoise the parsed body per request (Adonis BodyParser semantics — the
1035
1598
  * body is parsed once and stays re-readable). `#buildMethodOverrideHandler`
@@ -1056,27 +1619,6 @@ async function parseBody(ctx) {
1056
1619
  const raw = await ctx.request.body();
1057
1620
  return isPlainRecord(raw) ? raw : {};
1058
1621
  }
1059
- /**
1060
- * Read the CSRF token (when present) from `ctx.store` and shape it as
1061
- * a `hiddenInputs[]` entry for `renderFormPage`. The key `csrfToken`
1062
- * matches the @c9up/blackhole `csrfToken` convention so a fully-
1063
- * wired host stamps the token automatically; a host that doesn't wire
1064
- * CSRF returns no hidden input, and the form is unprotected (the
1065
- * boot-time warn-once already flagged this).
1066
- *
1067
- * The form field is named `_csrf` to match Adonis / Blackhole's
1068
- * default. Hosts using a different field name can override by writing
1069
- * their own hiddenInputs into ctx.store under a richer key, but for
1070
- * the common case this is the zero-config path.
1071
- */
1072
- function csrfHiddenInputs(ctx) {
1073
- if (ctx.store === undefined)
1074
- return undefined;
1075
- const token = ctx.store.get("csrfToken");
1076
- if (typeof token !== "string" || token.length === 0)
1077
- return undefined;
1078
- return [{ name: "_csrf", value: token }];
1079
- }
1080
1622
  function redirectToShow(ctx, resource, id) {
1081
1623
  const slug = encodeURIComponent(resource.name);
1082
1624
  const safeId = encodeURIComponent(String(id ?? ""));
@@ -1129,16 +1671,19 @@ async function emitAudit(resource, event) {
1129
1671
  }
1130
1672
  }
1131
1673
  /**
1132
- * Tiny HTML-escape for the 405 error body — duplicated here rather
1133
- * than imported from views/escape.ts to keep the dependency surface
1134
- * of StationProvider minimal (views are otherwise only reached via
1135
- * the renderer modules).
1674
+ * Tiny HTML-escape for the 404 / 405 error bodies — a self-contained
1675
+ * local helper. Station keeps no shared TS escaper (inker's `{{ }}`
1676
+ * owns view escaping); this covers the error bodies that are sent as
1677
+ * `text/html` without going through the inker renderer. Escapes the
1678
+ * same five characters as inker so the two escaping paths stay in
1679
+ * parity (`&` first to avoid double-encoding).
1136
1680
  */
1137
1681
  function escapeMin(value) {
1138
1682
  return value
1139
1683
  .replace(/&/g, "&amp;")
1140
1684
  .replace(/</g, "&lt;")
1141
1685
  .replace(/>/g, "&gt;")
1142
- .replace(/"/g, "&quot;");
1686
+ .replace(/"/g, "&quot;")
1687
+ .replace(/'/g, "&#39;");
1143
1688
  }
1144
1689
  //# sourceMappingURL=StationProvider.js.map