@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.
- package/LICENSE +21 -0
- package/README.md +3 -0
- package/dist/ResourceRegistry.d.ts +18 -0
- package/dist/ResourceRegistry.d.ts.map +1 -0
- package/dist/ResourceRegistry.js +38 -0
- package/dist/ResourceRegistry.js.map +1 -0
- package/dist/StationProvider.d.ts +109 -0
- package/dist/StationProvider.d.ts.map +1 -0
- package/dist/StationProvider.js +1144 -0
- package/dist/StationProvider.js.map +1 -0
- package/dist/casing.d.ts +27 -0
- package/dist/casing.d.ts.map +1 -0
- package/dist/casing.js +75 -0
- package/dist/casing.js.map +1 -0
- package/dist/defineResource.d.ts +9 -0
- package/dist/defineResource.d.ts.map +1 -0
- package/dist/defineResource.js +84 -0
- package/dist/defineResource.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/services/main.d.ts +18 -0
- package/dist/services/main.d.ts.map +1 -0
- package/dist/services/main.js +31 -0
- package/dist/services/main.js.map +1 -0
- package/dist/types.d.ts +85 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/dist/views/errors/404.d.ts +12 -0
- package/dist/views/errors/404.d.ts.map +1 -0
- package/dist/views/errors/404.js +19 -0
- package/dist/views/errors/404.js.map +1 -0
- package/dist/views/escape.d.ts +30 -0
- package/dist/views/escape.d.ts.map +1 -0
- package/dist/views/escape.js +34 -0
- package/dist/views/escape.js.map +1 -0
- package/dist/views/form.d.ts +34 -0
- package/dist/views/form.d.ts.map +1 -0
- package/dist/views/form.js +139 -0
- package/dist/views/form.js.map +1 -0
- package/dist/views/layout.d.ts +24 -0
- package/dist/views/layout.d.ts.map +1 -0
- package/dist/views/layout.js +85 -0
- package/dist/views/layout.js.map +1 -0
- package/dist/views/list.d.ts +22 -0
- package/dist/views/list.d.ts.map +1 -0
- package/dist/views/list.js +85 -0
- package/dist/views/list.js.map +1 -0
- package/dist/views/login.d.ts +25 -0
- package/dist/views/login.d.ts.map +1 -0
- package/dist/views/login.js +44 -0
- package/dist/views/login.js.map +1 -0
- package/dist/views/show.d.ts +17 -0
- package/dist/views/show.d.ts.map +1 -0
- package/dist/views/show.js +24 -0
- package/dist/views/show.js.map +1 -0
- package/package.json +63 -0
- package/src/ResourceRegistry.ts +49 -0
- package/src/StationProvider.ts +1579 -0
- package/src/casing.ts +86 -0
- package/src/defineResource.ts +126 -0
- package/src/index.ts +14 -0
- package/src/services/main.ts +39 -0
- package/src/types.ts +108 -0
- package/src/views/errors/404.ts +27 -0
- package/src/views/escape.ts +46 -0
- package/src/views/form.ts +191 -0
- package/src/views/layout.ts +90 -0
- package/src/views/list.ts +121 -0
- package/src/views/login.ts +65 -0
- 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, "&")
|
|
1576
|
+
.replace(/</g, "<")
|
|
1577
|
+
.replace(/>/g, ">")
|
|
1578
|
+
.replace(/"/g, """);
|
|
1579
|
+
}
|