@c9up/station 0.1.6 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/StationProvider.d.ts +94 -18
- package/dist/StationProvider.d.ts.map +1 -1
- package/dist/StationProvider.js +693 -148
- package/dist/StationProvider.js.map +1 -1
- package/dist/views/form.d.ts +56 -16
- package/dist/views/form.d.ts.map +1 -1
- package/dist/views/form.js +53 -75
- package/dist/views/form.js.map +1 -1
- package/dist/views/list.d.ts +43 -5
- package/dist/views/list.d.ts.map +1 -1
- package/dist/views/list.js +63 -60
- package/dist/views/list.js.map +1 -1
- package/dist/views/login.d.ts +20 -8
- package/dist/views/login.d.ts.map +1 -1
- package/dist/views/login.js +15 -33
- package/dist/views/login.js.map +1 -1
- package/dist/views/show.d.ts +18 -4
- package/dist/views/show.d.ts.map +1 -1
- package/dist/views/show.js +29 -15
- package/dist/views/show.js.map +1 -1
- package/package.json +21 -4
- package/src/StationProvider.ts +941 -155
- package/src/views/form.ts +111 -113
- package/src/views/list.ts +108 -75
- package/src/views/login.ts +31 -44
- package/src/views/show.ts +47 -18
- package/templates/errors/404.inker +4 -0
- package/templates/form.inker +3 -0
- package/{dist/views/layout.js → templates/layout.inker} +10 -35
- package/templates/list.inker +8 -0
- package/templates/login.inker +4 -0
- package/templates/show.inker +4 -0
- package/dist/views/errors/404.d.ts +0 -12
- package/dist/views/errors/404.d.ts.map +0 -1
- package/dist/views/errors/404.js +0 -19
- package/dist/views/errors/404.js.map +0 -1
- package/dist/views/escape.d.ts +0 -30
- package/dist/views/escape.d.ts.map +0 -1
- package/dist/views/escape.js +0 -34
- package/dist/views/escape.js.map +0 -1
- package/dist/views/layout.d.ts +0 -24
- package/dist/views/layout.d.ts.map +0 -1
- package/dist/views/layout.js.map +0 -1
- package/src/views/errors/404.ts +0 -27
- package/src/views/escape.ts +0 -46
- package/src/views/layout.ts +0 -90
package/src/StationProvider.ts
CHANGED
|
@@ -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 {
|
|
33
|
-
import {
|
|
34
|
-
import {
|
|
35
|
-
import {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
*
|
|
331
|
-
*
|
|
332
|
-
*
|
|
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
|
-
*
|
|
335
|
-
*
|
|
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
|
-
*
|
|
338
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
357
|
-
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
618
|
+
if (!(key in input)) input[key] = false;
|
|
367
619
|
}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
|
660
|
+
return out;
|
|
374
661
|
}
|
|
375
662
|
|
|
376
|
-
/**
|
|
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(
|
|
928
|
+
this.#contexts.set(
|
|
929
|
+
resource,
|
|
930
|
+
buildResourceContext(resource, db, atlas, rune),
|
|
931
|
+
);
|
|
490
932
|
}
|
|
491
933
|
|
|
492
|
-
// 56.5 + 54.6
|
|
493
|
-
//
|
|
494
|
-
//
|
|
495
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
749
|
-
|
|
750
|
-
|
|
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
|
|
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(
|
|
1458
|
+
ctx.response.send(notFoundHtml);
|
|
797
1459
|
return;
|
|
798
1460
|
}
|
|
799
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
846
|
-
//
|
|
847
|
-
// `{
|
|
848
|
-
//
|
|
849
|
-
//
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
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(
|
|
1567
|
+
ctx.response.send(notFoundHtml);
|
|
881
1568
|
return;
|
|
882
1569
|
}
|
|
883
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
|
1922
|
+
const vm = buildLoginViewModel({
|
|
1191
1923
|
action: this.#authConfig.loginPath,
|
|
1192
1924
|
error: typeof errorParam === "string" ? errorParam : undefined,
|
|
1193
|
-
|
|
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
|
|
1959
|
+
const vm = buildLoginViewModel({
|
|
1220
1960
|
action: this.#authConfig.loginPath,
|
|
1221
1961
|
email,
|
|
1222
1962
|
error: "Email and password are both required.",
|
|
1223
|
-
|
|
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
|
|
1978
|
+
const vm = buildLoginViewModel({
|
|
1238
1979
|
action: this.#authConfig.loginPath,
|
|
1239
1980
|
email,
|
|
1240
1981
|
error: result.error ?? "Invalid email or password.",
|
|
1241
|
-
|
|
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
|
-
|
|
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
|
|
1569
|
-
*
|
|
1570
|
-
*
|
|
1571
|
-
* the renderer
|
|
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, "&")
|
|
1576
2361
|
.replace(/</g, "<")
|
|
1577
2362
|
.replace(/>/g, ">")
|
|
1578
|
-
.replace(/"/g, """)
|
|
2363
|
+
.replace(/"/g, """)
|
|
2364
|
+
.replace(/'/g, "'");
|
|
1579
2365
|
}
|