@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.
- 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 +22 -5
- 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/dist/StationProvider.js
CHANGED
|
@@ -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 {
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
28
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
|
|
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
|
-
*
|
|
70
|
-
*
|
|
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
|
-
*
|
|
73
|
-
*
|
|
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
|
-
|
|
76
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
237
|
+
return out;
|
|
110
238
|
}
|
|
111
|
-
/**
|
|
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
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
|
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(
|
|
906
|
+
ctx.response.send(notFoundHtml);
|
|
458
907
|
return;
|
|
459
908
|
}
|
|
460
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
496
|
-
//
|
|
497
|
-
// `{
|
|
498
|
-
//
|
|
499
|
-
//
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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(
|
|
997
|
+
ctx.response.send(notFoundHtml);
|
|
528
998
|
return;
|
|
529
999
|
}
|
|
530
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
|
1313
|
+
const vm = buildLoginViewModel({
|
|
810
1314
|
action: this.#authConfig.loginPath,
|
|
811
1315
|
error: typeof errorParam === "string" ? errorParam : undefined,
|
|
812
|
-
|
|
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
|
|
1349
|
+
const vm = buildLoginViewModel({
|
|
838
1350
|
action: this.#authConfig.loginPath,
|
|
839
1351
|
email,
|
|
840
1352
|
error: "Email and password are both required.",
|
|
841
|
-
|
|
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
|
|
1367
|
+
const vm = buildLoginViewModel({
|
|
855
1368
|
action: this.#authConfig.loginPath,
|
|
856
1369
|
email,
|
|
857
1370
|
error: result.error ?? "Invalid email or password.",
|
|
858
|
-
|
|
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
|
-
|
|
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
|
|
1133
|
-
*
|
|
1134
|
-
*
|
|
1135
|
-
* the renderer
|
|
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, "&")
|
|
1140
1684
|
.replace(/</g, "<")
|
|
1141
1685
|
.replace(/>/g, ">")
|
|
1142
|
-
.replace(/"/g, """)
|
|
1686
|
+
.replace(/"/g, """)
|
|
1687
|
+
.replace(/'/g, "'");
|
|
1143
1688
|
}
|
|
1144
1689
|
//# sourceMappingURL=StationProvider.js.map
|