@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,1579 @@
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
+
21
+ import type {
22
+ BaseRepository as AtlasBaseRepository,
23
+ ColumnMetadata,
24
+ DatabaseConnection,
25
+ DateColumnConfig,
26
+ } from "@c9up/atlas";
27
+ import { ResourceRegistry } from "./ResourceRegistry.js";
28
+ import { setStation } from "./services/main.js";
29
+ import type { AuditEvent, Resource, ResourceAction } from "./types.js";
30
+ // note: AuditEvent + ResourceAction are used by the CRUD handlers below;
31
+ // the imports stay in one block for clarity.
32
+ import { renderNotFoundPage } from "./views/errors/404.js";
33
+ import { renderFormPage } from "./views/form.js";
34
+ import { renderListPage } from "./views/list.js";
35
+ import { renderLoginPage } from "./views/login.js";
36
+ import { renderShowPage } from "./views/show.js";
37
+
38
+ /**
39
+ * Duck-typed slice of the host's IoC container — Station MUST stay
40
+ * publishable without importing `@c9up/ream` directly (memory
41
+ * `project_package_extraction`). The Ream container fulfils this shape.
42
+ */
43
+ interface StationContainer {
44
+ singleton<T>(key: unknown, factory: () => T): void;
45
+ resolve<T>(key: unknown): T;
46
+ has(key: unknown): boolean;
47
+ }
48
+
49
+ interface StationConfigStore {
50
+ get<T>(key: string): T | undefined;
51
+ }
52
+
53
+ export interface StationAppContext {
54
+ container: StationContainer;
55
+ config: StationConfigStore;
56
+ }
57
+
58
+ /**
59
+ * Minimal HTTP context used by the route handlers. Structurally
60
+ * compatible with `@c9up/ream`'s HttpContext (request / response /
61
+ * params) without forcing the import. `redirect` + `header` are BOTH
62
+ * required — Ream's HttpContext exposes both, and the 302 fallback path
63
+ * requires one of them to ship a real Location (refusing to silently
64
+ * emit a Location-less redirect).
65
+ */
66
+ interface StationHttpContext {
67
+ request: {
68
+ qs(): Record<string, string | undefined>;
69
+ body?(): Promise<unknown> | unknown;
70
+ url?(): string;
71
+ header?(name: string): string | undefined;
72
+ cookie?(name: string): string | undefined;
73
+ };
74
+ response: {
75
+ status(code: number): unknown;
76
+ type(value: string): unknown;
77
+ send(body: string): unknown;
78
+ json(data: unknown): unknown;
79
+ redirect(url: string): unknown;
80
+ header(name: string, value: string): unknown;
81
+ cookie?(
82
+ name: string,
83
+ value: string,
84
+ options?: {
85
+ httpOnly?: boolean;
86
+ secure?: boolean;
87
+ sameSite?: "Strict" | "Lax" | "None";
88
+ maxAge?: number;
89
+ path?: string;
90
+ },
91
+ ): unknown;
92
+ clearCookie?(name: string, options?: { path?: string }): unknown;
93
+ };
94
+ params: Record<string, string>;
95
+ auth?: {
96
+ user?: { id: unknown; [key: string]: unknown };
97
+ roles?: string[];
98
+ };
99
+ /**
100
+ * Optional per-request keyed store. Station reads `csrfToken` here
101
+ * so the auto-generated form embeds a hidden CSRF input — matches
102
+ * the `csrfToken` convention the @c9up/blackhole middleware writes to.
103
+ * Hosts wiring a different CSRF strategy can write the token here
104
+ * under the same key and the form will pick it up.
105
+ */
106
+ store?: {
107
+ get(key: string): unknown;
108
+ set?(key: string, value: unknown): void;
109
+ };
110
+ }
111
+
112
+ interface StationRouter {
113
+ get(
114
+ path: string,
115
+ handler: (ctx: StationHttpContext) => Promise<void> | void,
116
+ ): unknown;
117
+ post(
118
+ path: string,
119
+ handler: (ctx: StationHttpContext) => Promise<void> | void,
120
+ ): unknown;
121
+ put(
122
+ path: string,
123
+ handler: (ctx: StationHttpContext) => Promise<void> | void,
124
+ ): unknown;
125
+ delete(
126
+ path: string,
127
+ handler: (ctx: StationHttpContext) => Promise<void> | void,
128
+ ): unknown;
129
+ }
130
+
131
+ /**
132
+ * Atlas `BaseEntity` instances expose a `setProp(key, value)` helper
133
+ * so Station can mutate columns without poking at internal dirty-
134
+ * tracking state. The repository surface treats rows as indexed maps
135
+ * (for view rendering + audit diff) AND as mutators (for update).
136
+ */
137
+ interface StationEntity {
138
+ [key: string]: unknown;
139
+ setProp(key: string, value: unknown): void;
140
+ }
141
+
142
+ /** Minimum repository surface the CRUD handlers need from atlas. */
143
+ interface StationRepository {
144
+ find(id: string | number | bigint): Promise<StationEntity | null>;
145
+ query(): StationQuery;
146
+ create(data: Record<string, unknown>): Promise<StationEntity>;
147
+ save(entity: StationEntity): Promise<void>;
148
+ delete(entity: StationEntity): Promise<void>;
149
+ }
150
+
151
+ interface StationQuery {
152
+ orderBy(column: string, direction: "asc" | "desc"): StationQuery;
153
+ forPage(page: number, perPage: number): StationQuery;
154
+ exec(): Promise<Record<string, unknown>[]>;
155
+ count(column?: string): Promise<number>;
156
+ }
157
+
158
+ /** Per-resource snapshot built once at `start()`, reused on every request. */
159
+ interface ResourceContext {
160
+ repo: StationRepository;
161
+ columns: ReadonlyArray<ColumnMetadata>;
162
+ pkColumn: string;
163
+ /**
164
+ * Property keys of framework-managed timestamp columns — those declared
165
+ * `@column.dateTime({ autoCreate })` / `{ autoUpdate }` (Lucid-style flag,
166
+ * NOT a name convention). BaseRepository owns these on INSERT/UPDATE, so
167
+ * the mass-assignment guard drops them regardless of their column name.
168
+ */
169
+ autoManaged: ReadonlySet<string>;
170
+ }
171
+
172
+ /** Lazy-imported `@c9up/atlas` value surface. */
173
+ interface AtlasModule {
174
+ BaseRepository: typeof AtlasBaseRepository;
175
+ getColumnMetadata: (entity: unknown) => ReadonlyArray<ColumnMetadata>;
176
+ getPrimaryKey: (entity: unknown) => string | undefined;
177
+ getDateColumnConfig: (entity: unknown) => Record<string, DateColumnConfig>;
178
+ }
179
+
180
+ /**
181
+ * Authorization scope (Epic 56). Declared LOCALLY — Station stays
182
+ * agnostic of `@c9up/warden` and never imports its `Scope` type. The
183
+ * shape mirrors warden's `Scope` (`"global" | { tenant }`) structurally
184
+ * so a value crosses the duck-typed boundary without a cast. Station
185
+ * gates at the implicit `"global"` scope in 56.5 (D6); the param exists
186
+ * so a later story can thread a per-request tenant without touching call
187
+ * sites.
188
+ */
189
+ type StationScope = "global" | { readonly tenant: string };
190
+
191
+ /**
192
+ * Authenticated user shape Station hands to the auth layer. Matches what
193
+ * `ctx.auth.user` carries (and what Warden's `verify`/`authenticate`
194
+ * return on `user`) — an `id` plus arbitrary claims. Kept structural so
195
+ * Station never imports warden's `UserPayload`.
196
+ */
197
+ interface StationAuthUser {
198
+ id: unknown;
199
+ [key: string]: unknown;
200
+ }
201
+
202
+ /**
203
+ * Minimal `AuthManager` surface Station needs from `@c9up/warden`. The
204
+ * full class exposes more (strategy registration, sign-token, password
205
+ * hashing, etc.); Station needs the credentials-in / verify-token path
206
+ * (54.7) plus the coarse authorization helpers (56.5): `hasPermission`
207
+ * (per-action gate) and `hasRole` (the `requireRole` blanket gate), both
208
+ * resolved through Warden's single `RightsResolver` (Epic 56). Consumed
209
+ * duck-typed via the container `"auth"` alias — the SAME uniform pattern
210
+ * Station uses for the rest of the Ream universe it integrates
211
+ * (`@c9up/ream`, `@c9up/atlas`): resolve through the container, never a
212
+ * static `import "@c9up/warden"`, so the optional-peer / degraded-host
213
+ * path keeps working (D1).
214
+ */
215
+ interface WardenAuthManager {
216
+ authenticate(
217
+ credentials: Record<string, unknown>,
218
+ strategyName?: string,
219
+ ): Promise<{
220
+ authenticated: boolean;
221
+ // Warden's AuthResult has NO top-level `token` — JwtStrategy puts
222
+ // the issued token on `user.token` (see warden AuthManager.ts +
223
+ // JwtStrategy.authenticate). The session cookie is read from there.
224
+ user?: { id: unknown; token?: string; [key: string]: unknown };
225
+ error?: string;
226
+ }>;
227
+ verify(
228
+ token: string,
229
+ strategyName?: string,
230
+ ): Promise<{
231
+ authenticated: boolean;
232
+ user?: { id: unknown; [key: string]: unknown };
233
+ error?: string;
234
+ strategyCrash?: boolean;
235
+ }>;
236
+ /**
237
+ * Resolve whether `user` holds `permission` within `scope` (Epic 56,
238
+ * default `"global"`). Backed by the single `RightsResolver` —
239
+ * role→permission + ACL grants ONLY, never the token's `permissions`
240
+ * claim (warden D1). A missing `await` here yields a truthy Promise =
241
+ * silent ALLOW, so every call site MUST await.
242
+ */
243
+ hasPermission(
244
+ user: StationAuthUser,
245
+ permission: string,
246
+ scope?: StationScope,
247
+ ): Promise<boolean>;
248
+ /**
249
+ * Resolve whether `user` holds `role` within `scope` (Epic 56,
250
+ * default `"global"`). Backed by the same resolver as `hasPermission`.
251
+ */
252
+ hasRole(
253
+ user: StationAuthUser,
254
+ role: string,
255
+ scope?: StationScope,
256
+ ): Promise<boolean>;
257
+ }
258
+
259
+ /**
260
+ * Config block read from `app.config.get<StationConfig>('station')`.
261
+ * Every field is optional — the defaults match the 54.2 / 54.3 / 54.4
262
+ * conventions so an app can leave the config out entirely.
263
+ */
264
+ export interface StationConfig {
265
+ /**
266
+ * When true (default `true` if `@c9up/warden` is installed),
267
+ * Station mounts a login surface at `/admin/login` and gates every
268
+ * other `/admin/*` route behind `auth.verify(token)`. Setting this
269
+ * to `false` keeps the old open-by-default behaviour from 54.2.
270
+ */
271
+ requireAuth?: boolean;
272
+ /**
273
+ * Role required to pass the auth gate. When omitted, any
274
+ * authenticated user can access `/admin/*` (the per-action
275
+ * `<resource>.<action>` permission gate still applies).
276
+ */
277
+ requireRole?: string;
278
+ /**
279
+ * Where to redirect on a failed auth check. Defaults to
280
+ * `/admin/login`.
281
+ */
282
+ loginPath?: string;
283
+ /**
284
+ * Cookie name carrying the auth token. Defaults to `station_auth`
285
+ * to avoid colliding with app-level session cookies.
286
+ */
287
+ cookieName?: string;
288
+ }
289
+
290
+ const MAX_PER_PAGE = 100;
291
+ const DEFAULT_PER_PAGE = 25;
292
+ const POSITIVE_INT_RE = /^[1-9][0-9]*$/;
293
+
294
+ /** Process-scoped flags so we warn once per process, not once per request. */
295
+ let authWarnEmitted = false;
296
+ let perPageClampWarned = false;
297
+ let seedPermsWarned = false;
298
+ let missingAuditWarned = false;
299
+ let csrfWarnEmitted = false;
300
+
301
+ /** @internal Reset module-level flags between tests. */
302
+ export function resetStationProviderFlags(): void {
303
+ authWarnEmitted = false;
304
+ perPageClampWarned = false;
305
+ seedPermsWarned = false;
306
+ missingAuditWarned = false;
307
+ csrfWarnEmitted = false;
308
+ }
309
+
310
+ const TIMESTAMP_PROPERTY_KEYS: ReadonlySet<string> = new Set([
311
+ "createdAt",
312
+ "updatedAt",
313
+ "deletedAt",
314
+ ]);
315
+ const TIMESTAMP_COLUMN_NAMES: ReadonlySet<string> = new Set([
316
+ "created_at",
317
+ "updated_at",
318
+ "deleted_at",
319
+ ]);
320
+
321
+ /**
322
+ * Mass-assignment guard. Only accepts keys that are:
323
+ * 1. A declared `@Column` propertyKey on the resource entity, AND
324
+ * 2. Not the primary key (DB-managed), AND
325
+ * 3. Not a framework-managed timestamp. Two signals, both honoured:
326
+ * (a) the Lucid-style auto flag — `@column.dateTime({ autoCreate })` /
327
+ * `{ autoUpdate }` (passed in via `autoManaged`); this is the
328
+ * authoritative one and catches custom-named timestamp columns
329
+ * (e.g. `registeredAt`) the name list below would miss, AND
330
+ * (b) the conventional name fallback (created_at / updated_at /
331
+ * deleted_at) for plain `@Column` timestamps declared without the
332
+ * auto flag.
333
+ *
334
+ * The `_method` synthetic field from browser-form method-overrides is
335
+ * dropped automatically because it never matches a column propertyKey.
336
+ *
337
+ * Returning a fresh object — never the caller's reference — so a
338
+ * downstream mutation can't poison the audit snapshot.
339
+ */
340
+ /** @internal Exported for unit tests — the mass-assignment + checkbox coercion guard. */
341
+ export function filterWritableBody(
342
+ body: Record<string, unknown>,
343
+ columns: ReadonlyArray<ColumnMetadata>,
344
+ pkColumn: string,
345
+ autoManaged: ReadonlySet<string>,
346
+ ): Record<string, unknown> {
347
+ const writable: Record<string, unknown> = {};
348
+ const validKeys = new Set<string>();
349
+ const booleanKeys = new Set<string>();
350
+ for (const c of columns) {
351
+ if (c.propertyKey === pkColumn) continue;
352
+ if (autoManaged.has(c.propertyKey)) continue;
353
+ if (TIMESTAMP_PROPERTY_KEYS.has(c.propertyKey)) continue;
354
+ const snake = c.propertyKey.replace(/([A-Z])/g, "_$1").toLowerCase();
355
+ if (TIMESTAMP_COLUMN_NAMES.has(snake)) continue;
356
+ validKeys.add(c.propertyKey);
357
+ if ((c.type ?? "").toString().toLowerCase() === "boolean") {
358
+ booleanKeys.add(c.propertyKey);
359
+ }
360
+ }
361
+ // Boolean columns render as checkboxes: an unchecked box submits NOTHING, so
362
+ // derive every boolean from presence/value. Otherwise an unchecked box can
363
+ // never clear a previously-true column on edit, and a checked box would store
364
+ // the raw string "1" instead of a real boolean.
365
+ for (const key of booleanKeys) {
366
+ writable[key] = isCheckedValue(body[key]);
367
+ }
368
+ for (const [key, value] of Object.entries(body)) {
369
+ if (!validKeys.has(key)) continue;
370
+ if (booleanKeys.has(key)) continue; // already derived above
371
+ writable[key] = value;
372
+ }
373
+ return writable;
374
+ }
375
+
376
+ /** A checkbox/boolean field is true only for its checked submissions. */
377
+ function isCheckedValue(value: unknown): boolean {
378
+ return value === true || value === "1" || value === "on" || value === "true";
379
+ }
380
+
381
+ /**
382
+ * Snapshot an entity's `@Column`-tracked fields for the audit before/
383
+ * after diff. Deep-cloned via `structuredClone` so a downstream sink
384
+ * that mutates the snapshot (e.g. "redact this field before logging")
385
+ * can't echo the change back into the live entity.
386
+ *
387
+ * Falls back to a per-key copy when the entity contains a non-
388
+ * clonable value (a function, a class instance with a non-clonable
389
+ * field). The fallback is shallow but warned ONCE per process so
390
+ * operators see it.
391
+ */
392
+ function snapshotEntity(
393
+ entity: Record<string, unknown>,
394
+ columns: ReadonlyArray<ColumnMetadata>,
395
+ ): Record<string, unknown> {
396
+ const out: Record<string, unknown> = {};
397
+ for (const c of columns) out[c.propertyKey] = entity[c.propertyKey];
398
+ try {
399
+ return structuredClone(out);
400
+ } catch (err) {
401
+ // structuredClone rejected a value (a function, a class instance with a
402
+ // non-cloneable field, …). Try a JSON deep-clone next, so the before/
403
+ // after audit snapshots still don't share mutable references with the
404
+ // live entity — a shared-ref shallow copy would let a later `setProp`
405
+ // mutation bleed back into the "before" image. Only if JSON also fails
406
+ // (bigint, circular) do we accept the shallow copy + warn.
407
+ try {
408
+ const cloned: unknown = JSON.parse(JSON.stringify(out));
409
+ if (isPlainRecord(cloned)) return cloned;
410
+ } catch {
411
+ // fall through to the warned shallow copy
412
+ }
413
+ if (!auditCloneWarnEmitted) {
414
+ auditCloneWarnEmitted = true;
415
+ const detail = err instanceof Error ? err.message : String(err);
416
+ console.warn(
417
+ `[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.`,
418
+ );
419
+ }
420
+ return out;
421
+ }
422
+ }
423
+ let auditCloneWarnEmitted = false;
424
+
425
+ export default class StationProvider {
426
+ #contexts: Map<Resource, ResourceContext> = new Map();
427
+ #started = false;
428
+ // 54.7 auth state — populated when warden is wired AND
429
+ // StationConfig.requireAuth is true (default when warden is present).
430
+ #authManager: WardenAuthManager | undefined;
431
+ #authConfig: Required<Pick<StationConfig, "loginPath" | "cookieName">> & {
432
+ requireAuth: boolean;
433
+ requireRole: string | undefined;
434
+ } = {
435
+ requireAuth: false,
436
+ requireRole: undefined,
437
+ loginPath: "/admin/login",
438
+ cookieName: "station_auth",
439
+ };
440
+
441
+ constructor(protected app: StationAppContext) {}
442
+
443
+ register(): void {
444
+ this.app.container.singleton(ResourceRegistry, () => {
445
+ const registry = new ResourceRegistry();
446
+ setStation(registry);
447
+ return registry;
448
+ });
449
+ this.app.container.singleton("station", () =>
450
+ this.app.container.resolve<ResourceRegistry>(ResourceRegistry),
451
+ );
452
+ }
453
+
454
+ async boot(): Promise<void> {
455
+ // Force-resolve so `setStation` runs even if no preload touches the
456
+ // singleton. Mirrors AuroraProvider.boot().
457
+ this.app.container.resolve<ResourceRegistry>(ResourceRegistry);
458
+ }
459
+
460
+ async start(): Promise<void> {
461
+ if (this.#started) return;
462
+
463
+ const registry =
464
+ this.app.container.resolve<ResourceRegistry>(ResourceRegistry);
465
+ const resources = registry.all();
466
+ if (resources.length === 0) return;
467
+
468
+ // 54.7 — read the optional `station` config block. Defaults bake
469
+ // in `requireAuth: true` when @c9up/warden is detected later in
470
+ // Phase 1, so a host that just installs the peer gets the auth
471
+ // gate without any extra config.
472
+ const userConfig = this.app.config.get<StationConfig>("station") ?? {};
473
+
474
+ // Phase 1 — lazy peer imports (router + atlas). A missing optional
475
+ // peer is a degraded-host signal: #loadPeers returns null → silent stop.
476
+ const peers = await this.#loadPeers();
477
+ if (!peers) return;
478
+ const { router, atlas } = peers;
479
+
480
+ // Phase 1b — wire the warden auth gate (or warn-once when it stays open).
481
+ this.#configureAuth(userConfig);
482
+
483
+ // Phase 2 — build per-resource context ONCE. `#resolveDb()` is
484
+ // loud: if the host installed `@c9up/atlas` but didn't register
485
+ // `@c9up/atlas/provider` in `reamrc.ts`, AC11's "surface
486
+ // AtlasProvider misconfiguration" intent kicks in.
487
+ const db = this.#resolveDb();
488
+ for (const resource of resources) {
489
+ this.#contexts.set(resource, buildResourceContext(resource, db, atlas));
490
+ }
491
+
492
+ // 56.5 + 54.6 + CSRF boot-time warn-onces. We surface the "auth
493
+ // wired but no permissions seeded", "no audit sink", and "no CSRF
494
+ // check" gaps loud-and-once so a half-wired install can't ship to
495
+ // prod without operators noticing.
496
+ this.#warnSeedPermissionsOnce(resources);
497
+ this.#warnAuditGapsOnce(resources);
498
+ this.#warnCsrfGapOnce(resources);
499
+
500
+ // Phase 3 — route registration. The router proxy may still throw
501
+ // "Router accessed before initialization" on first property access
502
+ // (boot ordering hazard where the proxy module imported but
503
+ // Ignitor's `setRouter` never fired). That's another legitimate
504
+ // degraded-host shape — silent return. Anything else (slug
505
+ // collision, future validation) propagates.
506
+ try {
507
+ this.#registerAdminRoutes(router, resources);
508
+ } catch (err) {
509
+ if (isRouterProxyUninit(err)) return;
510
+ throw err;
511
+ }
512
+
513
+ this.#started = true;
514
+ }
515
+
516
+ /**
517
+ * Phase 1 — resolve the host router from the container (Ream registers it as
518
+ * `'router'` in Ignitor) + lazy-import the optional `@c9up/atlas` peer.
519
+ *
520
+ * Reading the router from the container — instead of importing
521
+ * `@c9up/ream/services/router` — keeps station runtime-agnostic: a non-Ream
522
+ * host never registers `'router'` → null (a legitimate degraded-host signal).
523
+ * `@c9up/atlas` stays a genuine peer MODULE import (it ships agnostic
524
+ * classes, not a framework singleton); module-not-found → null, anything
525
+ * else re-throws.
526
+ */
527
+ async #loadPeers(): Promise<{
528
+ router: StationRouter;
529
+ atlas: AtlasModule;
530
+ } | null> {
531
+ if (!this.app.container.has("router")) return null;
532
+ const router = this.app.container.resolve<StationRouter>("router");
533
+ try {
534
+ const atlas = loadBearingCast<AtlasModule>(await import("@c9up/atlas"));
535
+ return { router, atlas };
536
+ } catch (err) {
537
+ if (isModuleNotFound(err)) return null;
538
+ throw err;
539
+ }
540
+ }
541
+
542
+ /**
543
+ * Wire the warden auth gate from the `station` config block. When warden is
544
+ * not installed/bound the gate stays open and a one-time warning is emitted.
545
+ */
546
+ #configureAuth(userConfig: StationConfig): void {
547
+ const wardenWanted = userConfig.requireAuth !== false;
548
+ if (wardenWanted) {
549
+ try {
550
+ this.#authManager =
551
+ this.app.container.resolve<WardenAuthManager>("auth");
552
+ this.#authConfig = {
553
+ requireAuth: true,
554
+ requireRole: userConfig.requireRole,
555
+ loginPath: userConfig.loginPath ?? "/admin/login",
556
+ cookieName: userConfig.cookieName ?? "station_auth",
557
+ };
558
+ } catch (cause) {
559
+ // Container has no working `auth` binding → warden not wired.
560
+ // If the host EXPLICITLY opted in (`requireAuth: true`), that is a
561
+ // misconfiguration, not the dev-preview path — fail closed rather
562
+ // than silently mounting an unauthenticated admin. When
563
+ // `requireAuth` was merely defaulted (undefined), fall through to
564
+ // the open-by-default mode and warn-once below.
565
+ if (userConfig.requireAuth === true) {
566
+ throw new Error(
567
+ "[station] `station.requireAuth: true` is set but no working `auth` binding is registered (wire @c9up/warden's WardenProvider). Refusing to mount an unauthenticated admin.",
568
+ { cause },
569
+ );
570
+ }
571
+ }
572
+ }
573
+ if (!this.#authConfig.requireAuth && !authWarnEmitted) {
574
+ authWarnEmitted = true;
575
+ console.warn(
576
+ "[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.",
577
+ );
578
+ }
579
+ }
580
+
581
+ /** Phase 3 — mount the login surface + per-resource CRUD routes. */
582
+ #registerAdminRoutes(
583
+ router: StationRouter,
584
+ resources: ReadonlyArray<Resource>,
585
+ ): void {
586
+ // 54.7 — mount login surface first when auth is required, so
587
+ // `/admin/login` is reachable even when the auth gate redirects every
588
+ // other path to it.
589
+ if (this.#authConfig.requireAuth && this.#authManager !== undefined) {
590
+ router.get("/admin/login", this.#buildLoginFormHandler());
591
+ router.post("/admin/login", this.#buildLoginPostHandler());
592
+ router.post("/admin/logout", this.#buildLogoutHandler());
593
+ }
594
+
595
+ const gate = (
596
+ handler: (ctx: StationHttpContext) => Promise<void>,
597
+ ): ((ctx: StationHttpContext) => Promise<void>) =>
598
+ this.#authConfig.requireAuth ? this.#withAuth(handler) : handler;
599
+
600
+ // `/admin` index — send the operator to the first listable resource (or the
601
+ // login surface). Without it, the post-login redirect("/admin") lands on a 404.
602
+ router.get(
603
+ "/admin",
604
+ gate(async (ctx) => {
605
+ const home = resources.find((r) => r.actions.includes("list"));
606
+ ctx.response.redirect(home ? `/admin/${home.name}` : "/admin/login");
607
+ }),
608
+ );
609
+
610
+ for (const resource of resources) {
611
+ const slug = resource.name;
612
+ if (resource.actions.includes("list")) {
613
+ router.get(`/admin/${slug}`, gate(this.#buildListHandler(resource)));
614
+ }
615
+ if (resource.actions.includes("create")) {
616
+ router.get(
617
+ `/admin/${slug}/new`,
618
+ gate(this.#buildNewFormHandler(resource)),
619
+ );
620
+ router.post(`/admin/${slug}`, gate(this.#buildCreateHandler(resource)));
621
+ }
622
+ if (resource.actions.includes("show")) {
623
+ router.get(
624
+ `/admin/${slug}/:id`,
625
+ gate(this.#buildShowHandler(resource)),
626
+ );
627
+ }
628
+ if (resource.actions.includes("edit")) {
629
+ router.get(
630
+ `/admin/${slug}/:id/edit`,
631
+ gate(this.#buildEditFormHandler(resource)),
632
+ );
633
+ router.put(
634
+ `/admin/${slug}/:id`,
635
+ gate(this.#buildUpdateHandler(resource)),
636
+ );
637
+ // Browser forms can't issue PUT — accept POST with `_method=PUT`
638
+ // from the auto-generated form (form.ts stamps the hidden input).
639
+ router.post(
640
+ `/admin/${slug}/:id`,
641
+ gate(this.#buildMethodOverrideHandler(resource)),
642
+ );
643
+ }
644
+ if (resource.actions.includes("destroy")) {
645
+ router.delete(
646
+ `/admin/${slug}/:id`,
647
+ gate(this.#buildDestroyHandler(resource)),
648
+ );
649
+ }
650
+ }
651
+ }
652
+
653
+ /**
654
+ * 56.5 — every admin action is now gated behind a
655
+ * `<resource>.<action>` permission resolved through @c9up/warden
656
+ * (`auth.hasPermission`). Warn ONCE at boot, only when the auth layer
657
+ * is wired, that the host must seed roles/grants in the Warden rights
658
+ * store — otherwise every admin request 403s with no hint. The old
659
+ * "missing policy entry" warning is gone with the 54.4 callback table.
660
+ */
661
+ #warnSeedPermissionsOnce(resources: ReadonlyArray<Resource>): void {
662
+ if (seedPermsWarned) return;
663
+ // Only meaningful once the Warden layer is wired — the dev-preview /
664
+ // no-warden path leaves the gate open, so there's nothing to seed.
665
+ if (this.#authManager === undefined) return;
666
+ if (resources.length === 0) return;
667
+ seedPermsWarned = true;
668
+ const example = resources[0];
669
+ console.warn(
670
+ `[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.`,
671
+ );
672
+ }
673
+
674
+ #warnCsrfGapOnce(resources: ReadonlyArray<Resource>): void {
675
+ if (csrfWarnEmitted) return;
676
+ const writeActions: ReadonlyArray<ResourceAction> = [
677
+ "create",
678
+ "edit",
679
+ "destroy",
680
+ ];
681
+ const writeEnabled = resources.some((r) =>
682
+ r.actions.some((a) => writeActions.includes(a)),
683
+ );
684
+ if (!writeEnabled) return;
685
+ csrfWarnEmitted = true;
686
+ console.warn(
687
+ "[station] Write-enabled resources are mounted but Station does NOT enforce CSRF at the handler level. Wire @c9up/blackhole (csrf: true) or an equivalent middleware in start/kernel.ts BEFORE production — a missing CSRF check on /admin/<resource>/:id POST allows cross-site form submission to mutate rows under any logged-in user's session.",
688
+ );
689
+ }
690
+
691
+ #warnAuditGapsOnce(resources: ReadonlyArray<Resource>): void {
692
+ if (missingAuditWarned) return;
693
+ const writeActions: ReadonlyArray<ResourceAction> = [
694
+ "create",
695
+ "edit",
696
+ "destroy",
697
+ ];
698
+ const missing = resources.filter(
699
+ (r) =>
700
+ r.audit === undefined &&
701
+ r.actions.some((a) => writeActions.includes(a)),
702
+ );
703
+ if (missing.length === 0) return;
704
+ missingAuditWarned = true;
705
+ console.warn(
706
+ `[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.`,
707
+ );
708
+ }
709
+
710
+ async ready(): Promise<void> {}
711
+
712
+ async shutdown(): Promise<void> {}
713
+
714
+ #requireContext(resource: Resource): ResourceContext {
715
+ const ctx = this.#contexts.get(resource);
716
+ if (!ctx) {
717
+ throw new Error(
718
+ `[station] No repository available for ${resource.entity.name}. Did you register @c9up/atlas/provider in reamrc.ts?`,
719
+ );
720
+ }
721
+ return ctx;
722
+ }
723
+
724
+ #buildListHandler(
725
+ resource: Resource,
726
+ ): (ctx: StationHttpContext) => Promise<void> {
727
+ return async (ctx) => {
728
+ const { repo, columns, pkColumn } = this.#requireContext(resource);
729
+ // 56.5: the list action is gated behind `<resource>.list` like
730
+ // every other action — resolved through the Warden `"auth"`
731
+ // layer. (Retro 2026-06-01: list previously skipped the gate
732
+ // entirely, leaking the index regardless of authorization.)
733
+ if (!(await authorizeAction(resource, "list", ctx, this.#authManager))) {
734
+ deny(ctx);
735
+ return;
736
+ }
737
+ const qs = ctx.request.qs();
738
+ const page = clampPositiveInt(qs.page, 1);
739
+ const perPageRaw = clampPositiveInt(qs.perPage, DEFAULT_PER_PAGE);
740
+ const perPage = Math.min(perPageRaw, MAX_PER_PAGE);
741
+ if (perPage < perPageRaw && !perPageClampWarned) {
742
+ perPageClampWarned = true;
743
+ console.warn(
744
+ `[station] perPage clamped to ${MAX_PER_PAGE} (got ${perPageRaw}). Suppressing further warnings.`,
745
+ );
746
+ }
747
+
748
+ const total = await repo.query().count();
749
+ const lastPage = Math.max(1, Math.ceil(total / perPage));
750
+ if (page > lastPage && total > 0) {
751
+ // Never render an empty page when one exists — redirect to the
752
+ // last real page so the user lands on something useful.
753
+ ctx.response.redirect(
754
+ `/admin/${resource.name}?page=${lastPage}&perPage=${perPage}`,
755
+ );
756
+ return;
757
+ }
758
+
759
+ const rows = await repo
760
+ .query()
761
+ .orderBy(pkColumn, "desc")
762
+ .forPage(page, perPage)
763
+ .exec();
764
+ const html = renderListPage({
765
+ resource,
766
+ rows,
767
+ columns,
768
+ pkColumn,
769
+ page,
770
+ perPage,
771
+ total,
772
+ lastPage,
773
+ });
774
+ ctx.response.type("text/html; charset=utf-8");
775
+ ctx.response.send(html);
776
+ };
777
+ }
778
+
779
+ #buildShowHandler(
780
+ resource: Resource,
781
+ ): (ctx: StationHttpContext) => Promise<void> {
782
+ return async (ctx) => {
783
+ const { repo, columns, pkColumn } = this.#requireContext(resource);
784
+ // Authorize BEFORE the existence check so an unauthorized caller
785
+ // can't distinguish 404 (absent) from 403 (exists) — no row-existence
786
+ // oracle for a user without `<resource>.show`.
787
+ if (!(await authorizeAction(resource, "show", ctx, this.#authManager))) {
788
+ deny(ctx);
789
+ return;
790
+ }
791
+ const id = ctx.params.id ?? "";
792
+ const row = await repo.find(id);
793
+ if (row === null) {
794
+ ctx.response.status(404);
795
+ ctx.response.type("text/html; charset=utf-8");
796
+ ctx.response.send(renderNotFoundPage({ resource, id }));
797
+ return;
798
+ }
799
+ const html = renderShowPage({
800
+ resource,
801
+ row,
802
+ columns,
803
+ pkColumn,
804
+ });
805
+ ctx.response.type("text/html; charset=utf-8");
806
+ ctx.response.send(html);
807
+ };
808
+ }
809
+
810
+ #buildNewFormHandler(
811
+ resource: Resource,
812
+ ): (ctx: StationHttpContext) => Promise<void> {
813
+ return async (ctx) => {
814
+ const { columns, pkColumn } = this.#requireContext(resource);
815
+ if (
816
+ !(await authorizeAction(resource, "create", ctx, this.#authManager))
817
+ ) {
818
+ deny(ctx);
819
+ return;
820
+ }
821
+ const html = renderFormPage({
822
+ resource,
823
+ columns,
824
+ pkColumn,
825
+ hiddenInputs: csrfHiddenInputs(ctx),
826
+ });
827
+ ctx.response.type("text/html; charset=utf-8");
828
+ ctx.response.send(html);
829
+ };
830
+ }
831
+
832
+ #buildCreateHandler(
833
+ resource: Resource,
834
+ ): (ctx: StationHttpContext) => Promise<void> {
835
+ return async (ctx) => {
836
+ const { repo, pkColumn, columns, autoManaged } =
837
+ this.#requireContext(resource);
838
+ if (
839
+ !(await authorizeAction(resource, "create", ctx, this.#authManager))
840
+ ) {
841
+ deny(ctx);
842
+ return;
843
+ }
844
+ const body = await readBody(ctx);
845
+ // Mass-assignment guard: only `@Column`-declared properties make
846
+ // it through to repo.create(). An attacker who POSTs
847
+ // `{ role: "admin" }` or `{ passwordHash: "x" }` against a
848
+ // resource that doesn't declare those columns has the keys
849
+ // silently dropped here. PK + framework timestamps are always
850
+ // excluded — they're decided by the DB / hooks, not the caller.
851
+ const filtered = filterWritableBody(body, columns, pkColumn, autoManaged);
852
+ const created = await repo.create(filtered);
853
+ await emitAudit(resource, {
854
+ action: "create",
855
+ resource: resource.name,
856
+ recordId: created[pkColumn],
857
+ userId: ctx.auth?.user?.id,
858
+ after: snapshotEntity(created, columns),
859
+ at: new Date(),
860
+ });
861
+ redirectToShow(ctx, resource, created[pkColumn]);
862
+ };
863
+ }
864
+
865
+ #buildEditFormHandler(
866
+ resource: Resource,
867
+ ): (ctx: StationHttpContext) => Promise<void> {
868
+ return async (ctx) => {
869
+ const { repo, columns, pkColumn } = this.#requireContext(resource);
870
+ // Authorize before the existence check (no 404-vs-403 oracle).
871
+ if (!(await authorizeAction(resource, "edit", ctx, this.#authManager))) {
872
+ deny(ctx);
873
+ return;
874
+ }
875
+ const id = ctx.params.id ?? "";
876
+ const row = await repo.find(id);
877
+ if (row === null) {
878
+ ctx.response.status(404);
879
+ ctx.response.type("text/html; charset=utf-8");
880
+ ctx.response.send(renderNotFoundPage({ resource, id }));
881
+ return;
882
+ }
883
+ const html = renderFormPage({
884
+ resource,
885
+ columns,
886
+ pkColumn,
887
+ row,
888
+ hiddenInputs: csrfHiddenInputs(ctx),
889
+ });
890
+ ctx.response.type("text/html; charset=utf-8");
891
+ ctx.response.send(html);
892
+ };
893
+ }
894
+
895
+ #buildUpdateHandler(
896
+ resource: Resource,
897
+ ): (ctx: StationHttpContext) => Promise<void> {
898
+ return async (ctx) => {
899
+ const { repo, pkColumn, columns, autoManaged } =
900
+ this.#requireContext(resource);
901
+ // Authorize before the existence check (no 404-vs-403 oracle).
902
+ if (!(await authorizeAction(resource, "edit", ctx, this.#authManager))) {
903
+ deny(ctx);
904
+ return;
905
+ }
906
+ const id = ctx.params.id ?? "";
907
+ const entity = await repo.find(id);
908
+ if (entity === null) {
909
+ ctx.response.status(404);
910
+ ctx.response.type("text/html; charset=utf-8");
911
+ ctx.response.send(renderNotFoundPage({ resource, id }));
912
+ return;
913
+ }
914
+ const body = await readBody(ctx);
915
+ // Snapshot BEFORE the mutation runs so the audit diff is
916
+ // meaningful (entity is a BaseEntity; its dirty-tracking
917
+ // would shadow the original values after setProp).
918
+ const beforeSnapshot = snapshotEntity(entity, columns);
919
+ // Mass-assignment guard: filterWritableBody drops every key
920
+ // that isn't an `@Column`-declared property, plus the PK and
921
+ // any framework timestamps (created_at / updated_at /
922
+ // deleted_at). An attacker who POSTs `{ role: "admin" }` to
923
+ // a resource without that column has the field silently
924
+ // dropped instead of overwriting the entity.
925
+ const writable = filterWritableBody(body, columns, pkColumn, autoManaged);
926
+ for (const [key, value] of Object.entries(writable)) {
927
+ entity.setProp(key, value);
928
+ }
929
+ await repo.save(entity);
930
+ const afterSnapshot = snapshotEntity(entity, columns);
931
+ await emitAudit(resource, {
932
+ action: "edit",
933
+ resource: resource.name,
934
+ recordId: entity[pkColumn],
935
+ userId: ctx.auth?.user?.id,
936
+ before: beforeSnapshot,
937
+ after: afterSnapshot,
938
+ at: new Date(),
939
+ });
940
+ redirectToShow(ctx, resource, entity[pkColumn]);
941
+ };
942
+ }
943
+
944
+ #buildMethodOverrideHandler(
945
+ resource: Resource,
946
+ ): (ctx: StationHttpContext) => Promise<void> {
947
+ // Browser forms can only emit GET / POST. The auto-generated edit
948
+ // form ships `<input type="hidden" name="_method" value="PUT">`
949
+ // so the POST /admin/:r/:id endpoint can route to the update
950
+ // handler. A POST without `_method=PUT` (or with `_method=DELETE`)
951
+ // dispatches accordingly.
952
+ const updateHandler = this.#buildUpdateHandler(resource);
953
+ const destroyHandler = this.#buildDestroyHandler(resource);
954
+ return async (ctx) => {
955
+ const body = await readBody(ctx);
956
+ const override = String(body._method ?? "").toUpperCase();
957
+ if (override === "PUT" || override === "PATCH") {
958
+ return updateHandler(ctx);
959
+ }
960
+ if (override === "DELETE") {
961
+ return destroyHandler(ctx);
962
+ }
963
+ // Unsupported override — refuse rather than silently downgrade
964
+ // to a no-op so misconfigured forms surface immediately.
965
+ ctx.response.status(405);
966
+ ctx.response.type("text/html; charset=utf-8");
967
+ ctx.response.send(
968
+ `<h1>405 Method Not Allowed</h1><p>POST /admin/${escapeMin(resource.name)}/:id requires <code>_method=PUT</code> or <code>_method=DELETE</code>.</p>`,
969
+ );
970
+ };
971
+ }
972
+
973
+ #buildDestroyHandler(
974
+ resource: Resource,
975
+ ): (ctx: StationHttpContext) => Promise<void> {
976
+ return async (ctx) => {
977
+ const { repo, pkColumn, columns } = this.#requireContext(resource);
978
+ // Authorize before the existence check (no 404-vs-403 oracle).
979
+ if (
980
+ !(await authorizeAction(resource, "destroy", ctx, this.#authManager))
981
+ ) {
982
+ deny(ctx);
983
+ return;
984
+ }
985
+ const id = ctx.params.id ?? "";
986
+ const row = await repo.find(id);
987
+ if (row === null) {
988
+ ctx.response.status(404);
989
+ ctx.response.type("text/html; charset=utf-8");
990
+ ctx.response.send(renderNotFoundPage({ resource, id }));
991
+ return;
992
+ }
993
+ const before = snapshotEntity(row, columns);
994
+ await repo.delete(row);
995
+ await emitAudit(resource, {
996
+ action: "destroy",
997
+ resource: resource.name,
998
+ recordId: row[pkColumn],
999
+ userId: ctx.auth?.user?.id,
1000
+ before,
1001
+ at: new Date(),
1002
+ });
1003
+ ctx.response.redirect(`/admin/${encodeURIComponent(resource.name)}`);
1004
+ };
1005
+ }
1006
+
1007
+ #resolveDb(): unknown {
1008
+ try {
1009
+ return this.app.container.resolve<unknown>("db");
1010
+ } catch (cause) {
1011
+ throw new Error(
1012
+ `[station] No 'db' connection registered. Did you register @c9up/atlas/provider in reamrc.ts?`,
1013
+ { cause },
1014
+ );
1015
+ }
1016
+ }
1017
+
1018
+ // ───────────────────────────────────────────────────────────────────────
1019
+ // Story 54.7 — Warden integration
1020
+ // ───────────────────────────────────────────────────────────────────────
1021
+
1022
+ /**
1023
+ * Resolve the inbound auth token. Cookie wins over Authorization
1024
+ * header because the cookie is what the login handler set; the
1025
+ * Bearer fallback exists for API-style callers (curl / fetch with
1026
+ * Authorization).
1027
+ */
1028
+ #readAuthToken(ctx: StationHttpContext): string | undefined {
1029
+ const cookieName = this.#authConfig.cookieName;
1030
+ const fromCookie = ctx.request.cookie?.(cookieName);
1031
+ if (typeof fromCookie === "string" && fromCookie.length > 0) {
1032
+ return fromCookie;
1033
+ }
1034
+ const authHeader = ctx.request.header?.("authorization");
1035
+ if (typeof authHeader === "string" && authHeader.length > 0) {
1036
+ const trimmed = authHeader.trim();
1037
+ if (trimmed.toLowerCase().startsWith("bearer ")) {
1038
+ return trimmed.slice(7).trim();
1039
+ }
1040
+ }
1041
+ return undefined;
1042
+ }
1043
+
1044
+ /**
1045
+ * Decide whether to redirect (HTML browser flow) or 401-json (XHR /
1046
+ * API flow). `Accept: application/json` OR `X-Requested-With:
1047
+ * XMLHttpRequest` triggers the JSON shape; everything else gets the
1048
+ * 302 to the login page.
1049
+ */
1050
+ #wantsJsonResponse(ctx: StationHttpContext): boolean {
1051
+ return wantsJsonResponse(ctx);
1052
+ }
1053
+
1054
+ /**
1055
+ * Wrap a CRUD handler with the auth gate. Reads token from cookie/
1056
+ * header → `authManager.verify(token)` → on success populates
1057
+ * `ctx.auth.user` (and `ctx.auth.roles` when present on the user
1058
+ * record) and delegates. On failure: JSON callers get 401, HTML
1059
+ * callers get a 302 to `loginPath`.
1060
+ *
1061
+ * Role check (`requireRole`) is applied AFTER auth: an authenticated
1062
+ * user without the role gets 403 (or 403-json), never a redirect —
1063
+ * a redirect to login wouldn't help them.
1064
+ */
1065
+ #withAuth(
1066
+ handler: (ctx: StationHttpContext) => Promise<void>,
1067
+ ): (ctx: StationHttpContext) => Promise<void> {
1068
+ return async (ctx: StationHttpContext): Promise<void> => {
1069
+ const manager = this.#authManager;
1070
+ if (manager === undefined) {
1071
+ // Auth gate enabled but no manager wired — shouldn't happen
1072
+ // because we only set requireAuth=true when the container
1073
+ // resolved `auth`. Fail closed: treat as a 500.
1074
+ ctx.response.status(500);
1075
+ ctx.response.type("text/plain; charset=utf-8");
1076
+ ctx.response.send(
1077
+ "[station] auth gate enabled but AuthManager missing",
1078
+ );
1079
+ return;
1080
+ }
1081
+ const token = this.#readAuthToken(ctx);
1082
+ if (token === undefined) {
1083
+ if (this.#wantsJsonResponse(ctx)) {
1084
+ ctx.response.status(401);
1085
+ ctx.response.json({ error: "authentication required" });
1086
+ return;
1087
+ }
1088
+ ctx.response.redirect(this.#authConfig.loginPath);
1089
+ return;
1090
+ }
1091
+ const result = await manager.verify(token);
1092
+ if (!result.authenticated || result.user === undefined) {
1093
+ // A strategy CRASH (the auth backend threw) is a server fault, not a
1094
+ // failed/expired login — don't clear the cookie or bounce to /login
1095
+ // as if the session died. Surface 503 so the real error isn't masked
1096
+ // as a routine re-login (audit 2026-06-13).
1097
+ if (result.strategyCrash) {
1098
+ ctx.response.status(503);
1099
+ if (this.#wantsJsonResponse(ctx)) {
1100
+ ctx.response.json({ error: "authentication service unavailable" });
1101
+ } else {
1102
+ ctx.response.type("text/html; charset=utf-8");
1103
+ ctx.response.send(
1104
+ "<h1>503 Service Unavailable</h1><p>Authentication is temporarily unavailable. Please try again.</p>",
1105
+ );
1106
+ }
1107
+ return;
1108
+ }
1109
+ // Clear the stale cookie regardless of response shape, so neither
1110
+ // a browser refresh nor an XHR caller retries with the dead token.
1111
+ ctx.response.clearCookie?.(this.#authConfig.cookieName, {
1112
+ path: "/",
1113
+ });
1114
+ if (this.#wantsJsonResponse(ctx)) {
1115
+ ctx.response.status(401);
1116
+ ctx.response.json({
1117
+ error: result.error ?? "invalid or expired session",
1118
+ });
1119
+ return;
1120
+ }
1121
+ ctx.response.redirect(this.#authConfig.loginPath);
1122
+ return;
1123
+ }
1124
+ const user = result.user;
1125
+ const rawRoles = user.roles;
1126
+ // `userRoles` is still derived for view rendering (ctx.auth.roles
1127
+ // below), but the GATE decision now comes from the Warden
1128
+ // resolver via `auth.hasRole` — no parallel Station-local RBAC
1129
+ // (D5/AC-E6). A forgotten `await` would make a truthy Promise
1130
+ // pass the `!` test = silent allow, so the call is awaited.
1131
+ const userRoles: string[] = Array.isArray(rawRoles)
1132
+ ? rawRoles.filter((r): r is string => typeof r === "string")
1133
+ : [];
1134
+ const required = this.#authConfig.requireRole;
1135
+ if (required !== undefined) {
1136
+ let roleOk: boolean;
1137
+ try {
1138
+ // `=== true`: a non-boolean resolution must not pass. A throw
1139
+ // (rights-store outage) denies rather than 500-ing.
1140
+ roleOk = (await manager.hasRole(user, required, "global")) === true;
1141
+ } catch (err) {
1142
+ const detail = err instanceof Error ? err.message : String(err);
1143
+ console.error(
1144
+ `[station] role check threw for '${required}' — denying (fail-closed): ${detail}`,
1145
+ );
1146
+ roleOk = false;
1147
+ }
1148
+ if (!roleOk) {
1149
+ if (this.#wantsJsonResponse(ctx)) {
1150
+ ctx.response.status(403);
1151
+ ctx.response.json({ error: "insufficient role" });
1152
+ return;
1153
+ }
1154
+ ctx.response.status(403);
1155
+ ctx.response.type("text/plain; charset=utf-8");
1156
+ ctx.response.send("Forbidden");
1157
+ return;
1158
+ }
1159
+ }
1160
+ const existingAuth = ctx.auth ?? {};
1161
+ ctx.auth = {
1162
+ ...existingAuth,
1163
+ user,
1164
+ roles: userRoles.length > 0 ? userRoles : existingAuth.roles,
1165
+ };
1166
+ await handler(ctx);
1167
+ };
1168
+ }
1169
+
1170
+ /**
1171
+ * `GET /admin/login` — render the sign-in form. If the caller is
1172
+ * already authenticated, redirect to `/admin` to avoid bouncing them
1173
+ * back through the form they don't need.
1174
+ */
1175
+ #buildLoginFormHandler(): (ctx: StationHttpContext) => Promise<void> {
1176
+ return async (ctx: StationHttpContext): Promise<void> => {
1177
+ const manager = this.#authManager;
1178
+ if (manager !== undefined) {
1179
+ const token = this.#readAuthToken(ctx);
1180
+ if (typeof token === "string" && token.length > 0) {
1181
+ const result = await manager.verify(token);
1182
+ if (result.authenticated) {
1183
+ ctx.response.redirect("/admin");
1184
+ return;
1185
+ }
1186
+ }
1187
+ }
1188
+ const qs = ctx.request.qs();
1189
+ const errorParam = qs.error;
1190
+ const html = renderLoginPage({
1191
+ action: this.#authConfig.loginPath,
1192
+ error: typeof errorParam === "string" ? errorParam : undefined,
1193
+ hiddenInputs: csrfHiddenInputs(ctx),
1194
+ });
1195
+ ctx.response.type("text/html; charset=utf-8");
1196
+ ctx.response.send(html);
1197
+ };
1198
+ }
1199
+
1200
+ /**
1201
+ * `POST /admin/login` — accept `{email, password}`, run them through
1202
+ * `authManager.authenticate`, set the session cookie on success.
1203
+ * Re-renders the form with an inline error on failure (preserves the
1204
+ * submitted email so the user doesn't retype it).
1205
+ */
1206
+ #buildLoginPostHandler(): (ctx: StationHttpContext) => Promise<void> {
1207
+ return async (ctx: StationHttpContext): Promise<void> => {
1208
+ const manager = this.#authManager;
1209
+ if (manager === undefined) {
1210
+ ctx.response.status(500);
1211
+ ctx.response.type("text/plain; charset=utf-8");
1212
+ ctx.response.send("[station] login posted but AuthManager missing");
1213
+ return;
1214
+ }
1215
+ const body = await readBody(ctx);
1216
+ const email = typeof body.email === "string" ? body.email.trim() : "";
1217
+ const password = typeof body.password === "string" ? body.password : "";
1218
+ if (email.length === 0 || password.length === 0) {
1219
+ const html = renderLoginPage({
1220
+ action: this.#authConfig.loginPath,
1221
+ email,
1222
+ error: "Email and password are both required.",
1223
+ hiddenInputs: csrfHiddenInputs(ctx),
1224
+ });
1225
+ ctx.response.status(400);
1226
+ ctx.response.type("text/html; charset=utf-8");
1227
+ ctx.response.send(html);
1228
+ return;
1229
+ }
1230
+ const result = await manager.authenticate({ email, password });
1231
+ // Warden returns the issued token on `user.token`, not at the
1232
+ // top level — reading `result.token` (which never exists) sent
1233
+ // every valid login down the 401 branch.
1234
+ const token =
1235
+ typeof result.user?.token === "string" ? result.user.token : undefined;
1236
+ if (!result.authenticated || token === undefined) {
1237
+ const html = renderLoginPage({
1238
+ action: this.#authConfig.loginPath,
1239
+ email,
1240
+ error: result.error ?? "Invalid email or password.",
1241
+ hiddenInputs: csrfHiddenInputs(ctx),
1242
+ });
1243
+ ctx.response.status(401);
1244
+ ctx.response.type("text/html; charset=utf-8");
1245
+ ctx.response.send(html);
1246
+ return;
1247
+ }
1248
+ ctx.response.cookie?.(this.#authConfig.cookieName, token, {
1249
+ httpOnly: true,
1250
+ sameSite: "Lax",
1251
+ secure: process.env.NODE_ENV === "production",
1252
+ path: "/",
1253
+ });
1254
+ ctx.response.redirect("/admin");
1255
+ };
1256
+ }
1257
+
1258
+ /**
1259
+ * `POST /admin/logout` — clear the session cookie and redirect to
1260
+ * the login page. POST (not GET) so a crafted `<img src>` can't log
1261
+ * someone out via CSRF.
1262
+ */
1263
+ #buildLogoutHandler(): (ctx: StationHttpContext) => Promise<void> {
1264
+ return async (ctx: StationHttpContext): Promise<void> => {
1265
+ ctx.response.clearCookie?.(this.#authConfig.cookieName, {
1266
+ path: "/",
1267
+ });
1268
+ ctx.response.redirect(this.#authConfig.loginPath);
1269
+ };
1270
+ }
1271
+ }
1272
+
1273
+ /**
1274
+ * Cross-package bridge — Station's `Resource.entity` is intentionally
1275
+ * typed `new (...args: never[]) => unknown` so the package type-compiles
1276
+ * without `@c9up/atlas` installed (peer is optional, memory
1277
+ * `project_package_extraction`). At the route-mount boundary we hand
1278
+ * the same constructor to Atlas's `BaseRepository`, whose signature is
1279
+ * `new () => T extends BaseEntity`. The narrowing casts live in this
1280
+ * single helper rather than at every call site (mirrors AC9-style
1281
+ * single-load-bearing-site convention from 54.1).
1282
+ */
1283
+ function buildResourceContext(
1284
+ resource: Resource,
1285
+ db: unknown,
1286
+ atlas: AtlasModule,
1287
+ ): ResourceContext {
1288
+ const entityCtor = loadBearingCast<
1289
+ ConstructorParameters<typeof AtlasBaseRepository>[0]
1290
+ >(resource.entity);
1291
+ const conn = loadBearingCast<DatabaseConnection>(db);
1292
+ const repo = loadBearingCast<StationRepository>(
1293
+ new atlas.BaseRepository(entityCtor, conn),
1294
+ );
1295
+ const columns = atlas.getColumnMetadata(resource.entity);
1296
+ const pkColumn = atlas.getPrimaryKey(resource.entity);
1297
+ if (pkColumn === undefined) {
1298
+ // Refusing to fall back to "id": a silently-wrong PK would leave the
1299
+ // REAL primary key out of the mass-assignment exclusion (client could
1300
+ // overwrite it) and mis-key the audit `recordId` + the post-write
1301
+ // redirect. Surface the metadata gap loud at boot instead.
1302
+ throw new Error(
1303
+ `[station] Could not resolve a primary key for ${resource.entity.name}. Declare an @PrimaryKey() column on the entity so atlas can report it.`,
1304
+ );
1305
+ }
1306
+ const dateColumns = atlas.getDateColumnConfig(resource.entity);
1307
+ const autoManaged = new Set<string>(
1308
+ Object.entries(dateColumns)
1309
+ .filter(([, cfg]) => cfg.autoCreate === true || cfg.autoUpdate === true)
1310
+ .map(([prop]) => prop),
1311
+ );
1312
+ return { repo, columns, pkColumn, autoManaged };
1313
+ }
1314
+
1315
+ /**
1316
+ * SANCTIONED CROSS-PACKAGE NARROWING — the ONE production site in
1317
+ * `@c9up/station` where `as T` is permitted. Memory `feedback_no_any_types`
1318
+ * is honoured by funnelling every load-bearing narrow (dynamic peer
1319
+ * imports, IoC-resolved `db`, atlas-agnostic `Resource.entity` handed to
1320
+ * Atlas's `BaseRepository`) through this single function. Analogous to
1321
+ * 54.1's AC9 exception (`{} as ResourceRegistry` in `services/main.ts`)
1322
+ * and the test-side `tests/__helpers__/bypass-type-check.ts`. Every
1323
+ * call site MUST carry a rationale comment explaining why static
1324
+ * narrowing isn't expressible at the boundary. NEVER widen this helper
1325
+ * beyond `unknown → T`.
1326
+ */
1327
+ function loadBearingCast<T>(value: unknown): T {
1328
+ return value as T;
1329
+ }
1330
+
1331
+ /**
1332
+ * Parse a query-string value as a positive integer with a fallback.
1333
+ * Strict: only `^[1-9][0-9]*$` is accepted — empty, missing, leading
1334
+ * zero, fractional (`1.7`), exponent (`1e3`), trailing garbage (`1abc`),
1335
+ * negative, and non-numeric all fall back. Clamp range is [1, +∞).
1336
+ */
1337
+ function clampPositiveInt(raw: string | undefined, fallback: number): number {
1338
+ if (typeof raw !== "string" || !POSITIVE_INT_RE.test(raw)) return fallback;
1339
+ const n = Number.parseInt(raw, 10);
1340
+ return Number.isFinite(n) && n >= 1 ? n : fallback;
1341
+ }
1342
+
1343
+ /**
1344
+ * Node's ERR_MODULE_NOT_FOUND surfaces on an Error subclass with `code`.
1345
+ * Exported for the 54.8 agnostic-peer-missing unit test, which can't
1346
+ * realistically simulate the dynamic-import failure path inside vitest's
1347
+ * mock graph.
1348
+ */
1349
+ export function isModuleNotFound(err: unknown): boolean {
1350
+ if (err === null || typeof err !== "object" || !("code" in err)) return false;
1351
+ const { code } = err;
1352
+ return code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND";
1353
+ }
1354
+
1355
+ /** Ream's router proxy throws this exact string before Ignitor wires it. */
1356
+ function isRouterProxyUninit(err: unknown): boolean {
1357
+ return (
1358
+ err instanceof Error &&
1359
+ err.message.includes("Router accessed before initialization")
1360
+ );
1361
+ }
1362
+
1363
+ /**
1364
+ * 56.5 authorization gate. Returns true when the action is allowed.
1365
+ * Station authorizes EXCLUSIVELY through Warden's unified layer: the
1366
+ * decision is `auth.hasPermission(user, "<resource>.<action>", scope)`
1367
+ * resolved via the container `"auth"` AuthManager (D1/D2). No
1368
+ * Station-local RBAC computation, no token-payload read.
1369
+ *
1370
+ * - `authManager === undefined` ⇒ OPEN (returns true). This is the
1371
+ * dev-preview / no-warden path — UNCHANGED from the legacy
1372
+ * `if (!authEnabled) return true`. That mode already emits the loud
1373
+ * "no auth — not for production" boot warning; this does NOT widen
1374
+ * any production path.
1375
+ * - A guest (no `ctx.auth.user`) ⇒ DENIED (fail-closed).
1376
+ * - Otherwise the answer is the resolver's — permission present ⇒
1377
+ * allow, absent ⇒ 403.
1378
+ *
1379
+ * Per-row ownership is NOT expressible here (D7): the coarse permission
1380
+ * gate has no `row`. Ownership is a Warden Bouncer-policy concern,
1381
+ * reachable through the fuller Bouncer path (a documented follow-up).
1382
+ *
1383
+ * The result MUST be awaited at every call site — a forgotten `await`
1384
+ * yields a truthy Promise = silent ALLOW (AC10 probe 3).
1385
+ */
1386
+ async function authorizeAction(
1387
+ resource: Resource,
1388
+ action: ResourceAction,
1389
+ ctx: StationHttpContext,
1390
+ authManager: WardenAuthManager | undefined,
1391
+ scope: StationScope = "global",
1392
+ ): Promise<boolean> {
1393
+ if (authManager === undefined) return true;
1394
+ const user = ctx.auth?.user;
1395
+ // `=== undefined` alone is too narrow: a host whose middleware writes
1396
+ // `ctx.auth = { user: null }` as a logged-out sentinel must still be
1397
+ // denied. Fail closed on any nullish user.
1398
+ if (user === undefined || user === null) return false;
1399
+ try {
1400
+ // Coerce to a strict boolean: the duck-typed manager could resolve a
1401
+ // truthy non-boolean, and `!truthy` would silently ALLOW. Anything
1402
+ // not exactly `true` denies (fail-closed).
1403
+ return (
1404
+ (await authManager.hasPermission(
1405
+ user,
1406
+ `${resource.name}.${action}`,
1407
+ scope,
1408
+ )) === true
1409
+ );
1410
+ } catch (err) {
1411
+ // A rights-store I/O failure (DB/Redis-backed resolver) must not pass
1412
+ // the gate. Deny and surface it loud rather than 500-ing or allowing.
1413
+ const detail = err instanceof Error ? err.message : String(err);
1414
+ console.error(
1415
+ `[station] authorization check threw for '${resource.name}.${action}' — denying (fail-closed): ${detail}`,
1416
+ );
1417
+ return false;
1418
+ }
1419
+ }
1420
+
1421
+ /** Content-negotiation: JSON for `Accept: application/json` or an XHR, else HTML. */
1422
+ function wantsJsonResponse(ctx: StationHttpContext): boolean {
1423
+ const accept = ctx.request.header?.("accept");
1424
+ if (typeof accept === "string" && accept.includes("application/json")) {
1425
+ return true;
1426
+ }
1427
+ const xrw = ctx.request.header?.("x-requested-with");
1428
+ if (typeof xrw === "string" && xrw.toLowerCase() === "xmlhttprequest") {
1429
+ return true;
1430
+ }
1431
+ return false;
1432
+ }
1433
+
1434
+ function deny(ctx: StationHttpContext): void {
1435
+ ctx.response.status(403);
1436
+ // Match the auth gate's content negotiation — a JSON / XHR caller used to get
1437
+ // an HTML 403 body here, inconsistent with the gate (audit 2026-06-13).
1438
+ if (wantsJsonResponse(ctx)) {
1439
+ ctx.response.json({
1440
+ error: "Forbidden",
1441
+ message: "Your account does not have access to this resource action.",
1442
+ });
1443
+ return;
1444
+ }
1445
+ ctx.response.type("text/html; charset=utf-8");
1446
+ ctx.response.send(
1447
+ "<h1>403 Forbidden</h1><p>Your account does not have access to this resource action.</p>",
1448
+ );
1449
+ }
1450
+
1451
+ /**
1452
+ * Memoise the parsed body per request (Adonis BodyParser semantics — the
1453
+ * body is parsed once and stays re-readable). `#buildMethodOverrideHandler`
1454
+ * reads it to inspect `_method`, then delegates to the update/destroy
1455
+ * handler which reads it again; without this, a single-shot
1456
+ * `ctx.request.body()` would return `{}` on the second read and the edit
1457
+ * would silently no-op.
1458
+ */
1459
+ const parsedBodyCache = new WeakMap<
1460
+ StationHttpContext,
1461
+ Record<string, unknown>
1462
+ >();
1463
+
1464
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
1465
+ return value !== null && typeof value === "object" && !Array.isArray(value);
1466
+ }
1467
+
1468
+ async function readBody(
1469
+ ctx: StationHttpContext,
1470
+ ): Promise<Record<string, unknown>> {
1471
+ const cached = parsedBodyCache.get(ctx);
1472
+ if (cached !== undefined) return cached;
1473
+ const parsed = await parseBody(ctx);
1474
+ parsedBodyCache.set(ctx, parsed);
1475
+ return parsed;
1476
+ }
1477
+
1478
+ async function parseBody(
1479
+ ctx: StationHttpContext,
1480
+ ): Promise<Record<string, unknown>> {
1481
+ if (typeof ctx.request.body !== "function") return {};
1482
+ const raw = await ctx.request.body();
1483
+ return isPlainRecord(raw) ? raw : {};
1484
+ }
1485
+
1486
+ /**
1487
+ * Read the CSRF token (when present) from `ctx.store` and shape it as
1488
+ * a `hiddenInputs[]` entry for `renderFormPage`. The key `csrfToken`
1489
+ * matches the @c9up/blackhole `csrfToken` convention so a fully-
1490
+ * wired host stamps the token automatically; a host that doesn't wire
1491
+ * CSRF returns no hidden input, and the form is unprotected (the
1492
+ * boot-time warn-once already flagged this).
1493
+ *
1494
+ * The form field is named `_csrf` to match Adonis / Blackhole's
1495
+ * default. Hosts using a different field name can override by writing
1496
+ * their own hiddenInputs into ctx.store under a richer key, but for
1497
+ * the common case this is the zero-config path.
1498
+ */
1499
+ function csrfHiddenInputs(
1500
+ ctx: StationHttpContext,
1501
+ ): ReadonlyArray<{ name: string; value: string }> | undefined {
1502
+ if (ctx.store === undefined) return undefined;
1503
+ const token = ctx.store.get("csrfToken");
1504
+ if (typeof token !== "string" || token.length === 0) return undefined;
1505
+ return [{ name: "_csrf", value: token }];
1506
+ }
1507
+
1508
+ function redirectToShow(
1509
+ ctx: StationHttpContext,
1510
+ resource: Resource,
1511
+ id: unknown,
1512
+ ): void {
1513
+ const slug = encodeURIComponent(resource.name);
1514
+ const safeId = encodeURIComponent(String(id ?? ""));
1515
+ // defineResource allows action subsets (e.g. [list, create, edit]), so the
1516
+ // show route may not be mounted. Redirect to the most specific ENABLED view
1517
+ // instead of blindly hitting show and 404-ing: show → edit → list → index.
1518
+ if (resource.actions.includes("show")) {
1519
+ ctx.response.redirect(`/admin/${slug}/${safeId}`);
1520
+ return;
1521
+ }
1522
+ if (resource.actions.includes("edit")) {
1523
+ ctx.response.redirect(`/admin/${slug}/${safeId}/edit`);
1524
+ return;
1525
+ }
1526
+ if (resource.actions.includes("list")) {
1527
+ ctx.response.redirect(`/admin/${slug}`);
1528
+ return;
1529
+ }
1530
+ ctx.response.redirect("/admin");
1531
+ }
1532
+
1533
+ /**
1534
+ * 54.6 audit emission. The sink runs AFTER the write commits, so a
1535
+ * failed mutation never produces a misleading audit row. Sink errors
1536
+ * are logged to stderr but never re-thrown — an audit pipeline outage
1537
+ * must not block the user-facing request.
1538
+ */
1539
+ async function emitAudit(resource: Resource, event: AuditEvent): Promise<void> {
1540
+ if (resource.audit === undefined) return;
1541
+ try {
1542
+ await resource.audit(event);
1543
+ } catch (err) {
1544
+ const detail = err instanceof Error ? err.message : String(err);
1545
+ // COMPLIANCE GAP: the mutation committed but its audit row was
1546
+ // lost. Logged at error level (not warn) so monitoring surfaces
1547
+ // it, and handed to the optional onAuditError hook so a
1548
+ // compliance-serious host can alert / enqueue a retry. The hook
1549
+ // is wrapped so a throwing handler can't crash the request.
1550
+ console.error(
1551
+ `[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.`,
1552
+ );
1553
+ if (resource.onAuditError !== undefined) {
1554
+ try {
1555
+ resource.onAuditError(event, err);
1556
+ } catch (hookErr) {
1557
+ const hookDetail =
1558
+ hookErr instanceof Error ? hookErr.message : String(hookErr);
1559
+ console.error(
1560
+ `[station] onAuditError handler for resource '${resource.name}' itself threw: ${hookDetail}.`,
1561
+ );
1562
+ }
1563
+ }
1564
+ }
1565
+ }
1566
+
1567
+ /**
1568
+ * Tiny HTML-escape for the 405 error body — duplicated here rather
1569
+ * than imported from views/escape.ts to keep the dependency surface
1570
+ * of StationProvider minimal (views are otherwise only reached via
1571
+ * the renderer modules).
1572
+ */
1573
+ function escapeMin(value: string): string {
1574
+ return value
1575
+ .replace(/&/g, "&amp;")
1576
+ .replace(/</g, "&lt;")
1577
+ .replace(/>/g, "&gt;")
1578
+ .replace(/"/g, "&quot;");
1579
+ }