@c9up/station 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +3 -0
  3. package/dist/ResourceRegistry.d.ts +18 -0
  4. package/dist/ResourceRegistry.d.ts.map +1 -0
  5. package/dist/ResourceRegistry.js +38 -0
  6. package/dist/ResourceRegistry.js.map +1 -0
  7. package/dist/StationProvider.d.ts +109 -0
  8. package/dist/StationProvider.d.ts.map +1 -0
  9. package/dist/StationProvider.js +1144 -0
  10. package/dist/StationProvider.js.map +1 -0
  11. package/dist/casing.d.ts +27 -0
  12. package/dist/casing.d.ts.map +1 -0
  13. package/dist/casing.js +75 -0
  14. package/dist/casing.js.map +1 -0
  15. package/dist/defineResource.d.ts +9 -0
  16. package/dist/defineResource.d.ts.map +1 -0
  17. package/dist/defineResource.js +84 -0
  18. package/dist/defineResource.js.map +1 -0
  19. package/dist/index.d.ts +7 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +6 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/services/main.d.ts +18 -0
  24. package/dist/services/main.d.ts.map +1 -0
  25. package/dist/services/main.js +31 -0
  26. package/dist/services/main.js.map +1 -0
  27. package/dist/types.d.ts +85 -0
  28. package/dist/types.d.ts.map +1 -0
  29. package/dist/types.js +8 -0
  30. package/dist/types.js.map +1 -0
  31. package/dist/views/errors/404.d.ts +12 -0
  32. package/dist/views/errors/404.d.ts.map +1 -0
  33. package/dist/views/errors/404.js +19 -0
  34. package/dist/views/errors/404.js.map +1 -0
  35. package/dist/views/escape.d.ts +30 -0
  36. package/dist/views/escape.d.ts.map +1 -0
  37. package/dist/views/escape.js +34 -0
  38. package/dist/views/escape.js.map +1 -0
  39. package/dist/views/form.d.ts +34 -0
  40. package/dist/views/form.d.ts.map +1 -0
  41. package/dist/views/form.js +139 -0
  42. package/dist/views/form.js.map +1 -0
  43. package/dist/views/layout.d.ts +24 -0
  44. package/dist/views/layout.d.ts.map +1 -0
  45. package/dist/views/layout.js +85 -0
  46. package/dist/views/layout.js.map +1 -0
  47. package/dist/views/list.d.ts +22 -0
  48. package/dist/views/list.d.ts.map +1 -0
  49. package/dist/views/list.js +85 -0
  50. package/dist/views/list.js.map +1 -0
  51. package/dist/views/login.d.ts +25 -0
  52. package/dist/views/login.d.ts.map +1 -0
  53. package/dist/views/login.js +44 -0
  54. package/dist/views/login.js.map +1 -0
  55. package/dist/views/show.d.ts +17 -0
  56. package/dist/views/show.d.ts.map +1 -0
  57. package/dist/views/show.js +24 -0
  58. package/dist/views/show.js.map +1 -0
  59. package/package.json +63 -0
  60. package/src/ResourceRegistry.ts +49 -0
  61. package/src/StationProvider.ts +1579 -0
  62. package/src/casing.ts +86 -0
  63. package/src/defineResource.ts +126 -0
  64. package/src/index.ts +14 -0
  65. package/src/services/main.ts +39 -0
  66. package/src/types.ts +108 -0
  67. package/src/views/errors/404.ts +27 -0
  68. package/src/views/escape.ts +46 -0
  69. package/src/views/form.ts +191 -0
  70. package/src/views/layout.ts +90 -0
  71. package/src/views/list.ts +121 -0
  72. package/src/views/login.ts +65 -0
  73. package/src/views/show.ts +37 -0
@@ -0,0 +1,1144 @@
1
+ /**
2
+ * StationProvider — Ream provider that wires Station's `ResourceRegistry`
3
+ * into the host container and mounts the list + show routes for every
4
+ * registered resource.
5
+ *
6
+ * Story 54.7 EXTENDS this provider with Warden integration (login
7
+ * surface + `/_assets/station/*` mount). The class shape and lifecycle
8
+ * stay; only `start()` grows.
9
+ *
10
+ * Mirror of `packages/aurora/src/AuroraProvider.ts` — register binds a
11
+ * singleton + sets the `services/main` proxy backing instance, start
12
+ * dynamically imports BOTH `@c9up/ream/services/router` AND `@c9up/atlas`
13
+ * inside try/catch so non-Ream hosts AND Station-without-Atlas consumers
14
+ * are silently tolerated. Once both modules resolve, the per-resource
15
+ * repository + column metadata is built ONCE (cached on the instance)
16
+ * and re-used by every request, then route registration runs OUTSIDE
17
+ * the catch — real bugs in route registration surface instead of being
18
+ * swallowed.
19
+ */
20
+ import { ResourceRegistry } from "./ResourceRegistry.js";
21
+ import { setStation } from "./services/main.js";
22
+ // note: AuditEvent + ResourceAction are used by the CRUD handlers below;
23
+ // the imports stay in one block for clarity.
24
+ import { renderNotFoundPage } from "./views/errors/404.js";
25
+ import { renderFormPage } from "./views/form.js";
26
+ import { renderListPage } from "./views/list.js";
27
+ import { renderLoginPage } from "./views/login.js";
28
+ import { renderShowPage } from "./views/show.js";
29
+ const MAX_PER_PAGE = 100;
30
+ const DEFAULT_PER_PAGE = 25;
31
+ const POSITIVE_INT_RE = /^[1-9][0-9]*$/;
32
+ /** Process-scoped flags so we warn once per process, not once per request. */
33
+ let authWarnEmitted = false;
34
+ let perPageClampWarned = false;
35
+ let seedPermsWarned = false;
36
+ let missingAuditWarned = false;
37
+ let csrfWarnEmitted = false;
38
+ /** @internal Reset module-level flags between tests. */
39
+ export function resetStationProviderFlags() {
40
+ authWarnEmitted = false;
41
+ perPageClampWarned = false;
42
+ seedPermsWarned = false;
43
+ missingAuditWarned = false;
44
+ csrfWarnEmitted = false;
45
+ }
46
+ const TIMESTAMP_PROPERTY_KEYS = new Set([
47
+ "createdAt",
48
+ "updatedAt",
49
+ "deletedAt",
50
+ ]);
51
+ const TIMESTAMP_COLUMN_NAMES = new Set([
52
+ "created_at",
53
+ "updated_at",
54
+ "deleted_at",
55
+ ]);
56
+ /**
57
+ * Mass-assignment guard. Only accepts keys that are:
58
+ * 1. A declared `@Column` propertyKey on the resource entity, AND
59
+ * 2. Not the primary key (DB-managed), AND
60
+ * 3. Not a framework-managed timestamp. Two signals, both honoured:
61
+ * (a) the Lucid-style auto flag — `@column.dateTime({ autoCreate })` /
62
+ * `{ autoUpdate }` (passed in via `autoManaged`); this is the
63
+ * authoritative one and catches custom-named timestamp columns
64
+ * (e.g. `registeredAt`) the name list below would miss, AND
65
+ * (b) the conventional name fallback (created_at / updated_at /
66
+ * deleted_at) for plain `@Column` timestamps declared without the
67
+ * auto flag.
68
+ *
69
+ * The `_method` synthetic field from browser-form method-overrides is
70
+ * dropped automatically because it never matches a column propertyKey.
71
+ *
72
+ * Returning a fresh object — never the caller's reference — so a
73
+ * downstream mutation can't poison the audit snapshot.
74
+ */
75
+ /** @internal Exported for unit tests — the mass-assignment + checkbox coercion guard. */
76
+ export function filterWritableBody(body, columns, pkColumn, autoManaged) {
77
+ const writable = {};
78
+ const validKeys = new Set();
79
+ const booleanKeys = new Set();
80
+ for (const c of columns) {
81
+ if (c.propertyKey === pkColumn)
82
+ continue;
83
+ if (autoManaged.has(c.propertyKey))
84
+ continue;
85
+ if (TIMESTAMP_PROPERTY_KEYS.has(c.propertyKey))
86
+ continue;
87
+ const snake = c.propertyKey.replace(/([A-Z])/g, "_$1").toLowerCase();
88
+ if (TIMESTAMP_COLUMN_NAMES.has(snake))
89
+ continue;
90
+ validKeys.add(c.propertyKey);
91
+ if ((c.type ?? "").toString().toLowerCase() === "boolean") {
92
+ booleanKeys.add(c.propertyKey);
93
+ }
94
+ }
95
+ // Boolean columns render as checkboxes: an unchecked box submits NOTHING, so
96
+ // derive every boolean from presence/value. Otherwise an unchecked box can
97
+ // never clear a previously-true column on edit, and a checked box would store
98
+ // the raw string "1" instead of a real boolean.
99
+ for (const key of booleanKeys) {
100
+ writable[key] = isCheckedValue(body[key]);
101
+ }
102
+ for (const [key, value] of Object.entries(body)) {
103
+ if (!validKeys.has(key))
104
+ continue;
105
+ if (booleanKeys.has(key))
106
+ continue; // already derived above
107
+ writable[key] = value;
108
+ }
109
+ return writable;
110
+ }
111
+ /** A checkbox/boolean field is true only for its checked submissions. */
112
+ function isCheckedValue(value) {
113
+ return value === true || value === "1" || value === "on" || value === "true";
114
+ }
115
+ /**
116
+ * Snapshot an entity's `@Column`-tracked fields for the audit before/
117
+ * after diff. Deep-cloned via `structuredClone` so a downstream sink
118
+ * that mutates the snapshot (e.g. "redact this field before logging")
119
+ * can't echo the change back into the live entity.
120
+ *
121
+ * Falls back to a per-key copy when the entity contains a non-
122
+ * clonable value (a function, a class instance with a non-clonable
123
+ * field). The fallback is shallow but warned ONCE per process so
124
+ * operators see it.
125
+ */
126
+ function snapshotEntity(entity, columns) {
127
+ const out = {};
128
+ for (const c of columns)
129
+ out[c.propertyKey] = entity[c.propertyKey];
130
+ try {
131
+ return structuredClone(out);
132
+ }
133
+ catch (err) {
134
+ // structuredClone rejected a value (a function, a class instance with a
135
+ // non-cloneable field, …). Try a JSON deep-clone next, so the before/
136
+ // after audit snapshots still don't share mutable references with the
137
+ // live entity — a shared-ref shallow copy would let a later `setProp`
138
+ // mutation bleed back into the "before" image. Only if JSON also fails
139
+ // (bigint, circular) do we accept the shallow copy + warn.
140
+ try {
141
+ const cloned = JSON.parse(JSON.stringify(out));
142
+ if (isPlainRecord(cloned))
143
+ return cloned;
144
+ }
145
+ catch {
146
+ // fall through to the warned shallow copy
147
+ }
148
+ if (!auditCloneWarnEmitted) {
149
+ auditCloneWarnEmitted = true;
150
+ const detail = err instanceof Error ? err.message : String(err);
151
+ console.warn(`[station] structuredClone failed on an audit snapshot and the JSON fallback did not apply — using a shallow copy. A column value isn't cloneable: ${detail}. Snapshot mutations downstream MAY reach the live entity.`);
152
+ }
153
+ return out;
154
+ }
155
+ }
156
+ let auditCloneWarnEmitted = false;
157
+ export default class StationProvider {
158
+ app;
159
+ #contexts = new Map();
160
+ #started = false;
161
+ // 54.7 auth state — populated when warden is wired AND
162
+ // StationConfig.requireAuth is true (default when warden is present).
163
+ #authManager;
164
+ #authConfig = {
165
+ requireAuth: false,
166
+ requireRole: undefined,
167
+ loginPath: "/admin/login",
168
+ cookieName: "station_auth",
169
+ };
170
+ constructor(app) {
171
+ this.app = app;
172
+ }
173
+ register() {
174
+ this.app.container.singleton(ResourceRegistry, () => {
175
+ const registry = new ResourceRegistry();
176
+ setStation(registry);
177
+ return registry;
178
+ });
179
+ this.app.container.singleton("station", () => this.app.container.resolve(ResourceRegistry));
180
+ }
181
+ async boot() {
182
+ // Force-resolve so `setStation` runs even if no preload touches the
183
+ // singleton. Mirrors AuroraProvider.boot().
184
+ this.app.container.resolve(ResourceRegistry);
185
+ }
186
+ async start() {
187
+ if (this.#started)
188
+ return;
189
+ const registry = this.app.container.resolve(ResourceRegistry);
190
+ const resources = registry.all();
191
+ if (resources.length === 0)
192
+ return;
193
+ // 54.7 — read the optional `station` config block. Defaults bake
194
+ // in `requireAuth: true` when @c9up/warden is detected later in
195
+ // Phase 1, so a host that just installs the peer gets the auth
196
+ // gate without any extra config.
197
+ const userConfig = this.app.config.get("station") ?? {};
198
+ // Phase 1 — lazy peer imports (router + atlas). A missing optional
199
+ // peer is a degraded-host signal: #loadPeers returns null → silent stop.
200
+ const peers = await this.#loadPeers();
201
+ if (!peers)
202
+ return;
203
+ const { router, atlas } = peers;
204
+ // Phase 1b — wire the warden auth gate (or warn-once when it stays open).
205
+ this.#configureAuth(userConfig);
206
+ // Phase 2 — build per-resource context ONCE. `#resolveDb()` is
207
+ // loud: if the host installed `@c9up/atlas` but didn't register
208
+ // `@c9up/atlas/provider` in `reamrc.ts`, AC11's "surface
209
+ // AtlasProvider misconfiguration" intent kicks in.
210
+ const db = this.#resolveDb();
211
+ for (const resource of resources) {
212
+ this.#contexts.set(resource, buildResourceContext(resource, db, atlas));
213
+ }
214
+ // 56.5 + 54.6 + CSRF boot-time warn-onces. We surface the "auth
215
+ // wired but no permissions seeded", "no audit sink", and "no CSRF
216
+ // check" gaps loud-and-once so a half-wired install can't ship to
217
+ // prod without operators noticing.
218
+ this.#warnSeedPermissionsOnce(resources);
219
+ this.#warnAuditGapsOnce(resources);
220
+ this.#warnCsrfGapOnce(resources);
221
+ // Phase 3 — route registration. The router proxy may still throw
222
+ // "Router accessed before initialization" on first property access
223
+ // (boot ordering hazard where the proxy module imported but
224
+ // Ignitor's `setRouter` never fired). That's another legitimate
225
+ // degraded-host shape — silent return. Anything else (slug
226
+ // collision, future validation) propagates.
227
+ try {
228
+ this.#registerAdminRoutes(router, resources);
229
+ }
230
+ catch (err) {
231
+ if (isRouterProxyUninit(err))
232
+ return;
233
+ throw err;
234
+ }
235
+ this.#started = true;
236
+ }
237
+ /**
238
+ * Phase 1 — resolve the host router from the container (Ream registers it as
239
+ * `'router'` in Ignitor) + lazy-import the optional `@c9up/atlas` peer.
240
+ *
241
+ * Reading the router from the container — instead of importing
242
+ * `@c9up/ream/services/router` — keeps station runtime-agnostic: a non-Ream
243
+ * host never registers `'router'` → null (a legitimate degraded-host signal).
244
+ * `@c9up/atlas` stays a genuine peer MODULE import (it ships agnostic
245
+ * classes, not a framework singleton); module-not-found → null, anything
246
+ * else re-throws.
247
+ */
248
+ async #loadPeers() {
249
+ if (!this.app.container.has("router"))
250
+ return null;
251
+ const router = this.app.container.resolve("router");
252
+ try {
253
+ const atlas = loadBearingCast(await import("@c9up/atlas"));
254
+ return { router, atlas };
255
+ }
256
+ catch (err) {
257
+ if (isModuleNotFound(err))
258
+ return null;
259
+ throw err;
260
+ }
261
+ }
262
+ /**
263
+ * Wire the warden auth gate from the `station` config block. When warden is
264
+ * not installed/bound the gate stays open and a one-time warning is emitted.
265
+ */
266
+ #configureAuth(userConfig) {
267
+ const wardenWanted = userConfig.requireAuth !== false;
268
+ if (wardenWanted) {
269
+ try {
270
+ this.#authManager =
271
+ this.app.container.resolve("auth");
272
+ this.#authConfig = {
273
+ requireAuth: true,
274
+ requireRole: userConfig.requireRole,
275
+ loginPath: userConfig.loginPath ?? "/admin/login",
276
+ cookieName: userConfig.cookieName ?? "station_auth",
277
+ };
278
+ }
279
+ catch (cause) {
280
+ // Container has no working `auth` binding → warden not wired.
281
+ // If the host EXPLICITLY opted in (`requireAuth: true`), that is a
282
+ // misconfiguration, not the dev-preview path — fail closed rather
283
+ // than silently mounting an unauthenticated admin. When
284
+ // `requireAuth` was merely defaulted (undefined), fall through to
285
+ // the open-by-default mode and warn-once below.
286
+ if (userConfig.requireAuth === true) {
287
+ throw new Error("[station] `station.requireAuth: true` is set but no working `auth` binding is registered (wire @c9up/warden's WardenProvider). Refusing to mount an unauthenticated admin.", { cause });
288
+ }
289
+ }
290
+ }
291
+ if (!this.#authConfig.requireAuth && !authWarnEmitted) {
292
+ authWarnEmitted = true;
293
+ console.warn("[station] Admin routes mounted without auth. Wire @c9up/warden (and set `station.requireAuth: true` if you opted out) and seed the per-action `<resource>.<action>` permissions in the Warden rights store BEFORE production. See https://ream.dev/modules/station#auth.");
294
+ }
295
+ }
296
+ /** Phase 3 — mount the login surface + per-resource CRUD routes. */
297
+ #registerAdminRoutes(router, resources) {
298
+ // 54.7 — mount login surface first when auth is required, so
299
+ // `/admin/login` is reachable even when the auth gate redirects every
300
+ // other path to it.
301
+ if (this.#authConfig.requireAuth && this.#authManager !== undefined) {
302
+ router.get("/admin/login", this.#buildLoginFormHandler());
303
+ router.post("/admin/login", this.#buildLoginPostHandler());
304
+ router.post("/admin/logout", this.#buildLogoutHandler());
305
+ }
306
+ const gate = (handler) => this.#authConfig.requireAuth ? this.#withAuth(handler) : handler;
307
+ // `/admin` index — send the operator to the first listable resource (or the
308
+ // login surface). Without it, the post-login redirect("/admin") lands on a 404.
309
+ router.get("/admin", gate(async (ctx) => {
310
+ const home = resources.find((r) => r.actions.includes("list"));
311
+ ctx.response.redirect(home ? `/admin/${home.name}` : "/admin/login");
312
+ }));
313
+ for (const resource of resources) {
314
+ const slug = resource.name;
315
+ if (resource.actions.includes("list")) {
316
+ router.get(`/admin/${slug}`, gate(this.#buildListHandler(resource)));
317
+ }
318
+ if (resource.actions.includes("create")) {
319
+ router.get(`/admin/${slug}/new`, gate(this.#buildNewFormHandler(resource)));
320
+ router.post(`/admin/${slug}`, gate(this.#buildCreateHandler(resource)));
321
+ }
322
+ if (resource.actions.includes("show")) {
323
+ router.get(`/admin/${slug}/:id`, gate(this.#buildShowHandler(resource)));
324
+ }
325
+ if (resource.actions.includes("edit")) {
326
+ router.get(`/admin/${slug}/:id/edit`, gate(this.#buildEditFormHandler(resource)));
327
+ router.put(`/admin/${slug}/:id`, gate(this.#buildUpdateHandler(resource)));
328
+ // Browser forms can't issue PUT — accept POST with `_method=PUT`
329
+ // from the auto-generated form (form.ts stamps the hidden input).
330
+ router.post(`/admin/${slug}/:id`, gate(this.#buildMethodOverrideHandler(resource)));
331
+ }
332
+ if (resource.actions.includes("destroy")) {
333
+ router.delete(`/admin/${slug}/:id`, gate(this.#buildDestroyHandler(resource)));
334
+ }
335
+ }
336
+ }
337
+ /**
338
+ * 56.5 — every admin action is now gated behind a
339
+ * `<resource>.<action>` permission resolved through @c9up/warden
340
+ * (`auth.hasPermission`). Warn ONCE at boot, only when the auth layer
341
+ * is wired, that the host must seed roles/grants in the Warden rights
342
+ * store — otherwise every admin request 403s with no hint. The old
343
+ * "missing policy entry" warning is gone with the 54.4 callback table.
344
+ */
345
+ #warnSeedPermissionsOnce(resources) {
346
+ if (seedPermsWarned)
347
+ return;
348
+ // Only meaningful once the Warden layer is wired — the dev-preview /
349
+ // no-warden path leaves the gate open, so there's nothing to seed.
350
+ if (this.#authManager === undefined)
351
+ return;
352
+ if (resources.length === 0)
353
+ return;
354
+ seedPermsWarned = true;
355
+ const example = resources[0];
356
+ 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
+ }
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
+ #warnAuditGapsOnce(resources) {
373
+ if (missingAuditWarned)
374
+ return;
375
+ const writeActions = [
376
+ "create",
377
+ "edit",
378
+ "destroy",
379
+ ];
380
+ const missing = resources.filter((r) => r.audit === undefined &&
381
+ r.actions.some((a) => writeActions.includes(a)));
382
+ if (missing.length === 0)
383
+ return;
384
+ missingAuditWarned = true;
385
+ console.warn(`[station] No audit sink configured for write-enabled resources: ${missing.map((r) => r.name).join(", ")}. Pass 'audit:' in defineResource() to persist mutations to your audit log.`);
386
+ }
387
+ async ready() { }
388
+ async shutdown() { }
389
+ #requireContext(resource) {
390
+ const ctx = this.#contexts.get(resource);
391
+ if (!ctx) {
392
+ throw new Error(`[station] No repository available for ${resource.entity.name}. Did you register @c9up/atlas/provider in reamrc.ts?`);
393
+ }
394
+ return ctx;
395
+ }
396
+ #buildListHandler(resource) {
397
+ return async (ctx) => {
398
+ const { repo, columns, pkColumn } = this.#requireContext(resource);
399
+ // 56.5: the list action is gated behind `<resource>.list` like
400
+ // every other action — resolved through the Warden `"auth"`
401
+ // layer. (Retro 2026-06-01: list previously skipped the gate
402
+ // entirely, leaking the index regardless of authorization.)
403
+ if (!(await authorizeAction(resource, "list", ctx, this.#authManager))) {
404
+ deny(ctx);
405
+ return;
406
+ }
407
+ const qs = ctx.request.qs();
408
+ const page = clampPositiveInt(qs.page, 1);
409
+ const perPageRaw = clampPositiveInt(qs.perPage, DEFAULT_PER_PAGE);
410
+ const perPage = Math.min(perPageRaw, MAX_PER_PAGE);
411
+ if (perPage < perPageRaw && !perPageClampWarned) {
412
+ perPageClampWarned = true;
413
+ console.warn(`[station] perPage clamped to ${MAX_PER_PAGE} (got ${perPageRaw}). Suppressing further warnings.`);
414
+ }
415
+ const total = await repo.query().count();
416
+ const lastPage = Math.max(1, Math.ceil(total / perPage));
417
+ if (page > lastPage && total > 0) {
418
+ // Never render an empty page when one exists — redirect to the
419
+ // last real page so the user lands on something useful.
420
+ ctx.response.redirect(`/admin/${resource.name}?page=${lastPage}&perPage=${perPage}`);
421
+ return;
422
+ }
423
+ const rows = await repo
424
+ .query()
425
+ .orderBy(pkColumn, "desc")
426
+ .forPage(page, perPage)
427
+ .exec();
428
+ const html = renderListPage({
429
+ resource,
430
+ rows,
431
+ columns,
432
+ pkColumn,
433
+ page,
434
+ perPage,
435
+ total,
436
+ lastPage,
437
+ });
438
+ ctx.response.type("text/html; charset=utf-8");
439
+ ctx.response.send(html);
440
+ };
441
+ }
442
+ #buildShowHandler(resource) {
443
+ return async (ctx) => {
444
+ const { repo, columns, pkColumn } = this.#requireContext(resource);
445
+ // Authorize BEFORE the existence check so an unauthorized caller
446
+ // can't distinguish 404 (absent) from 403 (exists) — no row-existence
447
+ // oracle for a user without `<resource>.show`.
448
+ if (!(await authorizeAction(resource, "show", ctx, this.#authManager))) {
449
+ deny(ctx);
450
+ return;
451
+ }
452
+ const id = ctx.params.id ?? "";
453
+ const row = await repo.find(id);
454
+ if (row === null) {
455
+ ctx.response.status(404);
456
+ ctx.response.type("text/html; charset=utf-8");
457
+ ctx.response.send(renderNotFoundPage({ resource, id }));
458
+ return;
459
+ }
460
+ const html = renderShowPage({
461
+ resource,
462
+ row,
463
+ columns,
464
+ pkColumn,
465
+ });
466
+ ctx.response.type("text/html; charset=utf-8");
467
+ ctx.response.send(html);
468
+ };
469
+ }
470
+ #buildNewFormHandler(resource) {
471
+ return async (ctx) => {
472
+ const { columns, pkColumn } = this.#requireContext(resource);
473
+ if (!(await authorizeAction(resource, "create", ctx, this.#authManager))) {
474
+ deny(ctx);
475
+ return;
476
+ }
477
+ const html = renderFormPage({
478
+ resource,
479
+ columns,
480
+ pkColumn,
481
+ hiddenInputs: csrfHiddenInputs(ctx),
482
+ });
483
+ ctx.response.type("text/html; charset=utf-8");
484
+ ctx.response.send(html);
485
+ };
486
+ }
487
+ #buildCreateHandler(resource) {
488
+ return async (ctx) => {
489
+ const { repo, pkColumn, columns, autoManaged } = this.#requireContext(resource);
490
+ if (!(await authorizeAction(resource, "create", ctx, this.#authManager))) {
491
+ deny(ctx);
492
+ return;
493
+ }
494
+ const body = await readBody(ctx);
495
+ // Mass-assignment guard: only `@Column`-declared properties make
496
+ // it through to repo.create(). An attacker who POSTs
497
+ // `{ role: "admin" }` or `{ passwordHash: "x" }` against a
498
+ // resource that doesn't declare those columns has the keys
499
+ // silently dropped here. PK + framework timestamps are always
500
+ // excluded — they're decided by the DB / hooks, not the caller.
501
+ const filtered = filterWritableBody(body, columns, pkColumn, autoManaged);
502
+ const created = await repo.create(filtered);
503
+ await emitAudit(resource, {
504
+ action: "create",
505
+ resource: resource.name,
506
+ recordId: created[pkColumn],
507
+ userId: ctx.auth?.user?.id,
508
+ after: snapshotEntity(created, columns),
509
+ at: new Date(),
510
+ });
511
+ redirectToShow(ctx, resource, created[pkColumn]);
512
+ };
513
+ }
514
+ #buildEditFormHandler(resource) {
515
+ return async (ctx) => {
516
+ const { repo, columns, pkColumn } = this.#requireContext(resource);
517
+ // Authorize before the existence check (no 404-vs-403 oracle).
518
+ if (!(await authorizeAction(resource, "edit", ctx, this.#authManager))) {
519
+ deny(ctx);
520
+ return;
521
+ }
522
+ const id = ctx.params.id ?? "";
523
+ const row = await repo.find(id);
524
+ if (row === null) {
525
+ ctx.response.status(404);
526
+ ctx.response.type("text/html; charset=utf-8");
527
+ ctx.response.send(renderNotFoundPage({ resource, id }));
528
+ return;
529
+ }
530
+ const html = renderFormPage({
531
+ resource,
532
+ columns,
533
+ pkColumn,
534
+ row,
535
+ hiddenInputs: csrfHiddenInputs(ctx),
536
+ });
537
+ ctx.response.type("text/html; charset=utf-8");
538
+ ctx.response.send(html);
539
+ };
540
+ }
541
+ #buildUpdateHandler(resource) {
542
+ return async (ctx) => {
543
+ const { repo, pkColumn, columns, autoManaged } = this.#requireContext(resource);
544
+ // Authorize before the existence check (no 404-vs-403 oracle).
545
+ if (!(await authorizeAction(resource, "edit", ctx, this.#authManager))) {
546
+ deny(ctx);
547
+ return;
548
+ }
549
+ const id = ctx.params.id ?? "";
550
+ const entity = await repo.find(id);
551
+ if (entity === null) {
552
+ ctx.response.status(404);
553
+ ctx.response.type("text/html; charset=utf-8");
554
+ ctx.response.send(renderNotFoundPage({ resource, id }));
555
+ return;
556
+ }
557
+ const body = await readBody(ctx);
558
+ // Snapshot BEFORE the mutation runs so the audit diff is
559
+ // meaningful (entity is a BaseEntity; its dirty-tracking
560
+ // would shadow the original values after setProp).
561
+ const beforeSnapshot = snapshotEntity(entity, columns);
562
+ // Mass-assignment guard: filterWritableBody drops every key
563
+ // that isn't an `@Column`-declared property, plus the PK and
564
+ // any framework timestamps (created_at / updated_at /
565
+ // deleted_at). An attacker who POSTs `{ role: "admin" }` to
566
+ // a resource without that column has the field silently
567
+ // dropped instead of overwriting the entity.
568
+ const writable = filterWritableBody(body, columns, pkColumn, autoManaged);
569
+ for (const [key, value] of Object.entries(writable)) {
570
+ entity.setProp(key, value);
571
+ }
572
+ await repo.save(entity);
573
+ const afterSnapshot = snapshotEntity(entity, columns);
574
+ await emitAudit(resource, {
575
+ action: "edit",
576
+ resource: resource.name,
577
+ recordId: entity[pkColumn],
578
+ userId: ctx.auth?.user?.id,
579
+ before: beforeSnapshot,
580
+ after: afterSnapshot,
581
+ at: new Date(),
582
+ });
583
+ redirectToShow(ctx, resource, entity[pkColumn]);
584
+ };
585
+ }
586
+ #buildMethodOverrideHandler(resource) {
587
+ // Browser forms can only emit GET / POST. The auto-generated edit
588
+ // form ships `<input type="hidden" name="_method" value="PUT">`
589
+ // so the POST /admin/:r/:id endpoint can route to the update
590
+ // handler. A POST without `_method=PUT` (or with `_method=DELETE`)
591
+ // dispatches accordingly.
592
+ const updateHandler = this.#buildUpdateHandler(resource);
593
+ const destroyHandler = this.#buildDestroyHandler(resource);
594
+ return async (ctx) => {
595
+ const body = await readBody(ctx);
596
+ const override = String(body._method ?? "").toUpperCase();
597
+ if (override === "PUT" || override === "PATCH") {
598
+ return updateHandler(ctx);
599
+ }
600
+ if (override === "DELETE") {
601
+ return destroyHandler(ctx);
602
+ }
603
+ // Unsupported override — refuse rather than silently downgrade
604
+ // to a no-op so misconfigured forms surface immediately.
605
+ ctx.response.status(405);
606
+ ctx.response.type("text/html; charset=utf-8");
607
+ ctx.response.send(`<h1>405 Method Not Allowed</h1><p>POST /admin/${escapeMin(resource.name)}/:id requires <code>_method=PUT</code> or <code>_method=DELETE</code>.</p>`);
608
+ };
609
+ }
610
+ #buildDestroyHandler(resource) {
611
+ return async (ctx) => {
612
+ const { repo, pkColumn, columns } = this.#requireContext(resource);
613
+ // Authorize before the existence check (no 404-vs-403 oracle).
614
+ if (!(await authorizeAction(resource, "destroy", ctx, this.#authManager))) {
615
+ deny(ctx);
616
+ return;
617
+ }
618
+ const id = ctx.params.id ?? "";
619
+ const row = await repo.find(id);
620
+ if (row === null) {
621
+ ctx.response.status(404);
622
+ ctx.response.type("text/html; charset=utf-8");
623
+ ctx.response.send(renderNotFoundPage({ resource, id }));
624
+ return;
625
+ }
626
+ const before = snapshotEntity(row, columns);
627
+ await repo.delete(row);
628
+ await emitAudit(resource, {
629
+ action: "destroy",
630
+ resource: resource.name,
631
+ recordId: row[pkColumn],
632
+ userId: ctx.auth?.user?.id,
633
+ before,
634
+ at: new Date(),
635
+ });
636
+ ctx.response.redirect(`/admin/${encodeURIComponent(resource.name)}`);
637
+ };
638
+ }
639
+ #resolveDb() {
640
+ try {
641
+ return this.app.container.resolve("db");
642
+ }
643
+ catch (cause) {
644
+ throw new Error(`[station] No 'db' connection registered. Did you register @c9up/atlas/provider in reamrc.ts?`, { cause });
645
+ }
646
+ }
647
+ // ───────────────────────────────────────────────────────────────────────
648
+ // Story 54.7 — Warden integration
649
+ // ───────────────────────────────────────────────────────────────────────
650
+ /**
651
+ * Resolve the inbound auth token. Cookie wins over Authorization
652
+ * header because the cookie is what the login handler set; the
653
+ * Bearer fallback exists for API-style callers (curl / fetch with
654
+ * Authorization).
655
+ */
656
+ #readAuthToken(ctx) {
657
+ const cookieName = this.#authConfig.cookieName;
658
+ const fromCookie = ctx.request.cookie?.(cookieName);
659
+ if (typeof fromCookie === "string" && fromCookie.length > 0) {
660
+ return fromCookie;
661
+ }
662
+ const authHeader = ctx.request.header?.("authorization");
663
+ if (typeof authHeader === "string" && authHeader.length > 0) {
664
+ const trimmed = authHeader.trim();
665
+ if (trimmed.toLowerCase().startsWith("bearer ")) {
666
+ return trimmed.slice(7).trim();
667
+ }
668
+ }
669
+ return undefined;
670
+ }
671
+ /**
672
+ * Decide whether to redirect (HTML browser flow) or 401-json (XHR /
673
+ * API flow). `Accept: application/json` OR `X-Requested-With:
674
+ * XMLHttpRequest` triggers the JSON shape; everything else gets the
675
+ * 302 to the login page.
676
+ */
677
+ #wantsJsonResponse(ctx) {
678
+ return wantsJsonResponse(ctx);
679
+ }
680
+ /**
681
+ * Wrap a CRUD handler with the auth gate. Reads token from cookie/
682
+ * header → `authManager.verify(token)` → on success populates
683
+ * `ctx.auth.user` (and `ctx.auth.roles` when present on the user
684
+ * record) and delegates. On failure: JSON callers get 401, HTML
685
+ * callers get a 302 to `loginPath`.
686
+ *
687
+ * Role check (`requireRole`) is applied AFTER auth: an authenticated
688
+ * user without the role gets 403 (or 403-json), never a redirect —
689
+ * a redirect to login wouldn't help them.
690
+ */
691
+ #withAuth(handler) {
692
+ return async (ctx) => {
693
+ const manager = this.#authManager;
694
+ if (manager === undefined) {
695
+ // Auth gate enabled but no manager wired — shouldn't happen
696
+ // because we only set requireAuth=true when the container
697
+ // resolved `auth`. Fail closed: treat as a 500.
698
+ ctx.response.status(500);
699
+ ctx.response.type("text/plain; charset=utf-8");
700
+ ctx.response.send("[station] auth gate enabled but AuthManager missing");
701
+ return;
702
+ }
703
+ const token = this.#readAuthToken(ctx);
704
+ if (token === undefined) {
705
+ if (this.#wantsJsonResponse(ctx)) {
706
+ ctx.response.status(401);
707
+ ctx.response.json({ error: "authentication required" });
708
+ return;
709
+ }
710
+ ctx.response.redirect(this.#authConfig.loginPath);
711
+ return;
712
+ }
713
+ const result = await manager.verify(token);
714
+ if (!result.authenticated || result.user === undefined) {
715
+ // A strategy CRASH (the auth backend threw) is a server fault, not a
716
+ // failed/expired login — don't clear the cookie or bounce to /login
717
+ // as if the session died. Surface 503 so the real error isn't masked
718
+ // as a routine re-login (audit 2026-06-13).
719
+ if (result.strategyCrash) {
720
+ ctx.response.status(503);
721
+ if (this.#wantsJsonResponse(ctx)) {
722
+ ctx.response.json({ error: "authentication service unavailable" });
723
+ }
724
+ else {
725
+ ctx.response.type("text/html; charset=utf-8");
726
+ ctx.response.send("<h1>503 Service Unavailable</h1><p>Authentication is temporarily unavailable. Please try again.</p>");
727
+ }
728
+ return;
729
+ }
730
+ // Clear the stale cookie regardless of response shape, so neither
731
+ // a browser refresh nor an XHR caller retries with the dead token.
732
+ ctx.response.clearCookie?.(this.#authConfig.cookieName, {
733
+ path: "/",
734
+ });
735
+ if (this.#wantsJsonResponse(ctx)) {
736
+ ctx.response.status(401);
737
+ ctx.response.json({
738
+ error: result.error ?? "invalid or expired session",
739
+ });
740
+ return;
741
+ }
742
+ ctx.response.redirect(this.#authConfig.loginPath);
743
+ return;
744
+ }
745
+ const user = result.user;
746
+ const rawRoles = user.roles;
747
+ // `userRoles` is still derived for view rendering (ctx.auth.roles
748
+ // below), but the GATE decision now comes from the Warden
749
+ // resolver via `auth.hasRole` — no parallel Station-local RBAC
750
+ // (D5/AC-E6). A forgotten `await` would make a truthy Promise
751
+ // pass the `!` test = silent allow, so the call is awaited.
752
+ const userRoles = Array.isArray(rawRoles)
753
+ ? rawRoles.filter((r) => typeof r === "string")
754
+ : [];
755
+ const required = this.#authConfig.requireRole;
756
+ if (required !== undefined) {
757
+ let roleOk;
758
+ try {
759
+ // `=== true`: a non-boolean resolution must not pass. A throw
760
+ // (rights-store outage) denies rather than 500-ing.
761
+ roleOk = (await manager.hasRole(user, required, "global")) === true;
762
+ }
763
+ catch (err) {
764
+ const detail = err instanceof Error ? err.message : String(err);
765
+ console.error(`[station] role check threw for '${required}' — denying (fail-closed): ${detail}`);
766
+ roleOk = false;
767
+ }
768
+ if (!roleOk) {
769
+ if (this.#wantsJsonResponse(ctx)) {
770
+ ctx.response.status(403);
771
+ ctx.response.json({ error: "insufficient role" });
772
+ return;
773
+ }
774
+ ctx.response.status(403);
775
+ ctx.response.type("text/plain; charset=utf-8");
776
+ ctx.response.send("Forbidden");
777
+ return;
778
+ }
779
+ }
780
+ const existingAuth = ctx.auth ?? {};
781
+ ctx.auth = {
782
+ ...existingAuth,
783
+ user,
784
+ roles: userRoles.length > 0 ? userRoles : existingAuth.roles,
785
+ };
786
+ await handler(ctx);
787
+ };
788
+ }
789
+ /**
790
+ * `GET /admin/login` — render the sign-in form. If the caller is
791
+ * already authenticated, redirect to `/admin` to avoid bouncing them
792
+ * back through the form they don't need.
793
+ */
794
+ #buildLoginFormHandler() {
795
+ return async (ctx) => {
796
+ const manager = this.#authManager;
797
+ if (manager !== undefined) {
798
+ const token = this.#readAuthToken(ctx);
799
+ if (typeof token === "string" && token.length > 0) {
800
+ const result = await manager.verify(token);
801
+ if (result.authenticated) {
802
+ ctx.response.redirect("/admin");
803
+ return;
804
+ }
805
+ }
806
+ }
807
+ const qs = ctx.request.qs();
808
+ const errorParam = qs.error;
809
+ const html = renderLoginPage({
810
+ action: this.#authConfig.loginPath,
811
+ error: typeof errorParam === "string" ? errorParam : undefined,
812
+ hiddenInputs: csrfHiddenInputs(ctx),
813
+ });
814
+ ctx.response.type("text/html; charset=utf-8");
815
+ ctx.response.send(html);
816
+ };
817
+ }
818
+ /**
819
+ * `POST /admin/login` — accept `{email, password}`, run them through
820
+ * `authManager.authenticate`, set the session cookie on success.
821
+ * Re-renders the form with an inline error on failure (preserves the
822
+ * submitted email so the user doesn't retype it).
823
+ */
824
+ #buildLoginPostHandler() {
825
+ return async (ctx) => {
826
+ const manager = this.#authManager;
827
+ if (manager === undefined) {
828
+ ctx.response.status(500);
829
+ ctx.response.type("text/plain; charset=utf-8");
830
+ ctx.response.send("[station] login posted but AuthManager missing");
831
+ return;
832
+ }
833
+ const body = await readBody(ctx);
834
+ const email = typeof body.email === "string" ? body.email.trim() : "";
835
+ const password = typeof body.password === "string" ? body.password : "";
836
+ if (email.length === 0 || password.length === 0) {
837
+ const html = renderLoginPage({
838
+ action: this.#authConfig.loginPath,
839
+ email,
840
+ error: "Email and password are both required.",
841
+ hiddenInputs: csrfHiddenInputs(ctx),
842
+ });
843
+ ctx.response.status(400);
844
+ ctx.response.type("text/html; charset=utf-8");
845
+ ctx.response.send(html);
846
+ return;
847
+ }
848
+ const result = await manager.authenticate({ email, password });
849
+ // Warden returns the issued token on `user.token`, not at the
850
+ // top level — reading `result.token` (which never exists) sent
851
+ // every valid login down the 401 branch.
852
+ const token = typeof result.user?.token === "string" ? result.user.token : undefined;
853
+ if (!result.authenticated || token === undefined) {
854
+ const html = renderLoginPage({
855
+ action: this.#authConfig.loginPath,
856
+ email,
857
+ error: result.error ?? "Invalid email or password.",
858
+ hiddenInputs: csrfHiddenInputs(ctx),
859
+ });
860
+ ctx.response.status(401);
861
+ ctx.response.type("text/html; charset=utf-8");
862
+ ctx.response.send(html);
863
+ return;
864
+ }
865
+ ctx.response.cookie?.(this.#authConfig.cookieName, token, {
866
+ httpOnly: true,
867
+ sameSite: "Lax",
868
+ secure: process.env.NODE_ENV === "production",
869
+ path: "/",
870
+ });
871
+ ctx.response.redirect("/admin");
872
+ };
873
+ }
874
+ /**
875
+ * `POST /admin/logout` — clear the session cookie and redirect to
876
+ * the login page. POST (not GET) so a crafted `<img src>` can't log
877
+ * someone out via CSRF.
878
+ */
879
+ #buildLogoutHandler() {
880
+ return async (ctx) => {
881
+ ctx.response.clearCookie?.(this.#authConfig.cookieName, {
882
+ path: "/",
883
+ });
884
+ ctx.response.redirect(this.#authConfig.loginPath);
885
+ };
886
+ }
887
+ }
888
+ /**
889
+ * Cross-package bridge — Station's `Resource.entity` is intentionally
890
+ * typed `new (...args: never[]) => unknown` so the package type-compiles
891
+ * without `@c9up/atlas` installed (peer is optional, memory
892
+ * `project_package_extraction`). At the route-mount boundary we hand
893
+ * the same constructor to Atlas's `BaseRepository`, whose signature is
894
+ * `new () => T extends BaseEntity`. The narrowing casts live in this
895
+ * single helper rather than at every call site (mirrors AC9-style
896
+ * single-load-bearing-site convention from 54.1).
897
+ */
898
+ function buildResourceContext(resource, db, atlas) {
899
+ const entityCtor = loadBearingCast(resource.entity);
900
+ const conn = loadBearingCast(db);
901
+ const repo = loadBearingCast(new atlas.BaseRepository(entityCtor, conn));
902
+ const columns = atlas.getColumnMetadata(resource.entity);
903
+ const pkColumn = atlas.getPrimaryKey(resource.entity);
904
+ if (pkColumn === undefined) {
905
+ // Refusing to fall back to "id": a silently-wrong PK would leave the
906
+ // REAL primary key out of the mass-assignment exclusion (client could
907
+ // overwrite it) and mis-key the audit `recordId` + the post-write
908
+ // redirect. Surface the metadata gap loud at boot instead.
909
+ throw new Error(`[station] Could not resolve a primary key for ${resource.entity.name}. Declare an @PrimaryKey() column on the entity so atlas can report it.`);
910
+ }
911
+ const dateColumns = atlas.getDateColumnConfig(resource.entity);
912
+ const autoManaged = new Set(Object.entries(dateColumns)
913
+ .filter(([, cfg]) => cfg.autoCreate === true || cfg.autoUpdate === true)
914
+ .map(([prop]) => prop));
915
+ return { repo, columns, pkColumn, autoManaged };
916
+ }
917
+ /**
918
+ * SANCTIONED CROSS-PACKAGE NARROWING — the ONE production site in
919
+ * `@c9up/station` where `as T` is permitted. Memory `feedback_no_any_types`
920
+ * is honoured by funnelling every load-bearing narrow (dynamic peer
921
+ * imports, IoC-resolved `db`, atlas-agnostic `Resource.entity` handed to
922
+ * Atlas's `BaseRepository`) through this single function. Analogous to
923
+ * 54.1's AC9 exception (`{} as ResourceRegistry` in `services/main.ts`)
924
+ * and the test-side `tests/__helpers__/bypass-type-check.ts`. Every
925
+ * call site MUST carry a rationale comment explaining why static
926
+ * narrowing isn't expressible at the boundary. NEVER widen this helper
927
+ * beyond `unknown → T`.
928
+ */
929
+ function loadBearingCast(value) {
930
+ return value;
931
+ }
932
+ /**
933
+ * Parse a query-string value as a positive integer with a fallback.
934
+ * Strict: only `^[1-9][0-9]*$` is accepted — empty, missing, leading
935
+ * zero, fractional (`1.7`), exponent (`1e3`), trailing garbage (`1abc`),
936
+ * negative, and non-numeric all fall back. Clamp range is [1, +∞).
937
+ */
938
+ function clampPositiveInt(raw, fallback) {
939
+ if (typeof raw !== "string" || !POSITIVE_INT_RE.test(raw))
940
+ return fallback;
941
+ const n = Number.parseInt(raw, 10);
942
+ return Number.isFinite(n) && n >= 1 ? n : fallback;
943
+ }
944
+ /**
945
+ * Node's ERR_MODULE_NOT_FOUND surfaces on an Error subclass with `code`.
946
+ * Exported for the 54.8 agnostic-peer-missing unit test, which can't
947
+ * realistically simulate the dynamic-import failure path inside vitest's
948
+ * mock graph.
949
+ */
950
+ export function isModuleNotFound(err) {
951
+ if (err === null || typeof err !== "object" || !("code" in err))
952
+ return false;
953
+ const { code } = err;
954
+ return code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND";
955
+ }
956
+ /** Ream's router proxy throws this exact string before Ignitor wires it. */
957
+ function isRouterProxyUninit(err) {
958
+ return (err instanceof Error &&
959
+ err.message.includes("Router accessed before initialization"));
960
+ }
961
+ /**
962
+ * 56.5 authorization gate. Returns true when the action is allowed.
963
+ * Station authorizes EXCLUSIVELY through Warden's unified layer: the
964
+ * decision is `auth.hasPermission(user, "<resource>.<action>", scope)`
965
+ * resolved via the container `"auth"` AuthManager (D1/D2). No
966
+ * Station-local RBAC computation, no token-payload read.
967
+ *
968
+ * - `authManager === undefined` ⇒ OPEN (returns true). This is the
969
+ * dev-preview / no-warden path — UNCHANGED from the legacy
970
+ * `if (!authEnabled) return true`. That mode already emits the loud
971
+ * "no auth — not for production" boot warning; this does NOT widen
972
+ * any production path.
973
+ * - A guest (no `ctx.auth.user`) ⇒ DENIED (fail-closed).
974
+ * - Otherwise the answer is the resolver's — permission present ⇒
975
+ * allow, absent ⇒ 403.
976
+ *
977
+ * Per-row ownership is NOT expressible here (D7): the coarse permission
978
+ * gate has no `row`. Ownership is a Warden Bouncer-policy concern,
979
+ * reachable through the fuller Bouncer path (a documented follow-up).
980
+ *
981
+ * The result MUST be awaited at every call site — a forgotten `await`
982
+ * yields a truthy Promise = silent ALLOW (AC10 probe 3).
983
+ */
984
+ async function authorizeAction(resource, action, ctx, authManager, scope = "global") {
985
+ if (authManager === undefined)
986
+ return true;
987
+ const user = ctx.auth?.user;
988
+ // `=== undefined` alone is too narrow: a host whose middleware writes
989
+ // `ctx.auth = { user: null }` as a logged-out sentinel must still be
990
+ // denied. Fail closed on any nullish user.
991
+ if (user === undefined || user === null)
992
+ return false;
993
+ try {
994
+ // Coerce to a strict boolean: the duck-typed manager could resolve a
995
+ // truthy non-boolean, and `!truthy` would silently ALLOW. Anything
996
+ // not exactly `true` denies (fail-closed).
997
+ return ((await authManager.hasPermission(user, `${resource.name}.${action}`, scope)) === true);
998
+ }
999
+ catch (err) {
1000
+ // A rights-store I/O failure (DB/Redis-backed resolver) must not pass
1001
+ // the gate. Deny and surface it loud rather than 500-ing or allowing.
1002
+ const detail = err instanceof Error ? err.message : String(err);
1003
+ console.error(`[station] authorization check threw for '${resource.name}.${action}' — denying (fail-closed): ${detail}`);
1004
+ return false;
1005
+ }
1006
+ }
1007
+ /** Content-negotiation: JSON for `Accept: application/json` or an XHR, else HTML. */
1008
+ function wantsJsonResponse(ctx) {
1009
+ const accept = ctx.request.header?.("accept");
1010
+ if (typeof accept === "string" && accept.includes("application/json")) {
1011
+ return true;
1012
+ }
1013
+ const xrw = ctx.request.header?.("x-requested-with");
1014
+ if (typeof xrw === "string" && xrw.toLowerCase() === "xmlhttprequest") {
1015
+ return true;
1016
+ }
1017
+ return false;
1018
+ }
1019
+ function deny(ctx) {
1020
+ ctx.response.status(403);
1021
+ // Match the auth gate's content negotiation — a JSON / XHR caller used to get
1022
+ // an HTML 403 body here, inconsistent with the gate (audit 2026-06-13).
1023
+ if (wantsJsonResponse(ctx)) {
1024
+ ctx.response.json({
1025
+ error: "Forbidden",
1026
+ message: "Your account does not have access to this resource action.",
1027
+ });
1028
+ return;
1029
+ }
1030
+ ctx.response.type("text/html; charset=utf-8");
1031
+ ctx.response.send("<h1>403 Forbidden</h1><p>Your account does not have access to this resource action.</p>");
1032
+ }
1033
+ /**
1034
+ * Memoise the parsed body per request (Adonis BodyParser semantics — the
1035
+ * body is parsed once and stays re-readable). `#buildMethodOverrideHandler`
1036
+ * reads it to inspect `_method`, then delegates to the update/destroy
1037
+ * handler which reads it again; without this, a single-shot
1038
+ * `ctx.request.body()` would return `{}` on the second read and the edit
1039
+ * would silently no-op.
1040
+ */
1041
+ const parsedBodyCache = new WeakMap();
1042
+ function isPlainRecord(value) {
1043
+ return value !== null && typeof value === "object" && !Array.isArray(value);
1044
+ }
1045
+ async function readBody(ctx) {
1046
+ const cached = parsedBodyCache.get(ctx);
1047
+ if (cached !== undefined)
1048
+ return cached;
1049
+ const parsed = await parseBody(ctx);
1050
+ parsedBodyCache.set(ctx, parsed);
1051
+ return parsed;
1052
+ }
1053
+ async function parseBody(ctx) {
1054
+ if (typeof ctx.request.body !== "function")
1055
+ return {};
1056
+ const raw = await ctx.request.body();
1057
+ return isPlainRecord(raw) ? raw : {};
1058
+ }
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
+ function redirectToShow(ctx, resource, id) {
1081
+ const slug = encodeURIComponent(resource.name);
1082
+ const safeId = encodeURIComponent(String(id ?? ""));
1083
+ // defineResource allows action subsets (e.g. [list, create, edit]), so the
1084
+ // show route may not be mounted. Redirect to the most specific ENABLED view
1085
+ // instead of blindly hitting show and 404-ing: show → edit → list → index.
1086
+ if (resource.actions.includes("show")) {
1087
+ ctx.response.redirect(`/admin/${slug}/${safeId}`);
1088
+ return;
1089
+ }
1090
+ if (resource.actions.includes("edit")) {
1091
+ ctx.response.redirect(`/admin/${slug}/${safeId}/edit`);
1092
+ return;
1093
+ }
1094
+ if (resource.actions.includes("list")) {
1095
+ ctx.response.redirect(`/admin/${slug}`);
1096
+ return;
1097
+ }
1098
+ ctx.response.redirect("/admin");
1099
+ }
1100
+ /**
1101
+ * 54.6 audit emission. The sink runs AFTER the write commits, so a
1102
+ * failed mutation never produces a misleading audit row. Sink errors
1103
+ * are logged to stderr but never re-thrown — an audit pipeline outage
1104
+ * must not block the user-facing request.
1105
+ */
1106
+ async function emitAudit(resource, event) {
1107
+ if (resource.audit === undefined)
1108
+ return;
1109
+ try {
1110
+ await resource.audit(event);
1111
+ }
1112
+ catch (err) {
1113
+ const detail = err instanceof Error ? err.message : String(err);
1114
+ // COMPLIANCE GAP: the mutation committed but its audit row was
1115
+ // lost. Logged at error level (not warn) so monitoring surfaces
1116
+ // it, and handed to the optional onAuditError hook so a
1117
+ // compliance-serious host can alert / enqueue a retry. The hook
1118
+ // is wrapped so a throwing handler can't crash the request.
1119
+ console.error(`[station] COMPLIANCE GAP: audit sink for resource '${resource.name}' threw on ${event.action} (record ${String(event.recordId)}): ${detail}. The operation succeeded but the audit row was NOT recorded.`);
1120
+ if (resource.onAuditError !== undefined) {
1121
+ try {
1122
+ resource.onAuditError(event, err);
1123
+ }
1124
+ catch (hookErr) {
1125
+ const hookDetail = hookErr instanceof Error ? hookErr.message : String(hookErr);
1126
+ console.error(`[station] onAuditError handler for resource '${resource.name}' itself threw: ${hookDetail}.`);
1127
+ }
1128
+ }
1129
+ }
1130
+ }
1131
+ /**
1132
+ * Tiny HTML-escape for the 405 error body — duplicated here rather
1133
+ * than imported from views/escape.ts to keep the dependency surface
1134
+ * of StationProvider minimal (views are otherwise only reached via
1135
+ * the renderer modules).
1136
+ */
1137
+ function escapeMin(value) {
1138
+ return value
1139
+ .replace(/&/g, "&amp;")
1140
+ .replace(/</g, "&lt;")
1141
+ .replace(/>/g, "&gt;")
1142
+ .replace(/"/g, "&quot;");
1143
+ }
1144
+ //# sourceMappingURL=StationProvider.js.map