@c9up/station 0.1.15 → 0.1.16

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.
@@ -19,9 +19,11 @@
19
19
  */
20
20
  import "./augmentations.js";
21
21
  import { fileURLToPath } from "node:url";
22
+ import { editableColumns, isFrameworkManaged, isSecretColumn, viewColumns, writeOnlyKeys, } from "./columns.js";
23
+ import { loginKeys, MemoryLoginLimiter, } from "./loginLimiter.js";
22
24
  import { ResourceRegistry } from "./ResourceRegistry.js";
23
25
  import { clearStation, getStation, setStation } from "./services/main.js";
24
- import { inProduction } from "./vendor/nodeEnv.js";
26
+ import { inProduction, normalizeNodeEnv } from "./vendor/nodeEnv.js";
25
27
  // note: AuditEvent + ResourceAction are used by the CRUD handlers below;
26
28
  // the imports stay in one block for clarity.
27
29
  import { buildFormViewModel } from "./views/form.js";
@@ -45,6 +47,12 @@ function isInkerViewRenderer(value) {
45
47
  typeof value.renderToString === "function");
46
48
  }
47
49
  const MAX_PER_PAGE = 100;
50
+ const DEFAULT_PERMISSION_PREFIX = "station";
51
+ /**
52
+ * The auth cookie travels only to the admin: scoped to `/`, the admin bearer
53
+ * token rode along with every request to the rest of the application.
54
+ */
55
+ const AUTH_COOKIE_PATH = "/admin";
48
56
  const DEFAULT_PER_PAGE = 25;
49
57
  const POSITIVE_INT_RE = /^[1-9][0-9]*$/;
50
58
  /** Process-scoped flags so we warn once per process, not once per request. */
@@ -53,6 +61,7 @@ let perPageClampWarned = false;
53
61
  let seedPermsWarned = false;
54
62
  let missingAuditWarned = false;
55
63
  let csrfBlockedWarned = false;
64
+ let revokeUnavailableWarned = false;
56
65
  /** @internal Reset module-level flags between tests. */
57
66
  export function resetStationProviderFlags() {
58
67
  authWarnEmitted = false;
@@ -60,6 +69,7 @@ export function resetStationProviderFlags() {
60
69
  seedPermsWarned = false;
61
70
  missingAuditWarned = false;
62
71
  csrfBlockedWarned = false;
72
+ revokeUnavailableWarned = false;
63
73
  }
64
74
  /**
65
75
  * Stable fingerprint for a failed `station::errors/404` render, used to log each
@@ -80,16 +90,6 @@ function notFoundFailFingerprint(cause) {
80
90
  * unforeseen unbounded error-code space still cannot inflate the log or memory.
81
91
  */
82
92
  const NOT_FOUND_RENDER_FAIL_CAP = 32;
83
- const TIMESTAMP_PROPERTY_KEYS = new Set([
84
- "createdAt",
85
- "updatedAt",
86
- "deletedAt",
87
- ]);
88
- const TIMESTAMP_COLUMN_NAMES = new Set([
89
- "created_at",
90
- "updated_at",
91
- "deleted_at",
92
- ]);
93
93
  /**
94
94
  * Column `type` strings mapped to a numeric rune rule (mirrors form.ts input
95
95
  * inference). `bigint` is deliberately EXCLUDED: coercing it through `Number()`
@@ -118,9 +118,9 @@ export function resourcesNeedValidation(resources) {
118
118
  * Derive a `@c9up/rune` validation schema (57.7) from an entity's writable
119
119
  * column metadata — the structural replacement for the hand-rolled
120
120
  * `filterWritableBody`. The mass-assignment guarantee is now INHERENT:
121
- * `schema.validateResult()` returns only declared-column keys, so unknown body keys
122
- * (`role`, `passwordHash`, PK, timestamps) are dropped automatically — no
123
- * explicit filtering pass.
121
+ * `schema.validateResult()` returns only the keys of the editable columns it
122
+ * was given, so any other body key — an unknown one, a column outside the
123
+ * allow-list, the PK, a timestamp — is dropped automatically.
124
124
  *
125
125
  * Excluded, exactly as the old guard did: the primary key, framework-managed
126
126
  * timestamps (both the Lucid-style `autoManaged` flag AND the `created_at /
@@ -141,22 +141,16 @@ export function resourcesNeedValidation(resources) {
141
141
  * Returns the built schema plus the set of boolean columns — the handler
142
142
  * default-fills an ABSENT boolean key to `false` before validating (an
143
143
  * unchecked checkbox submits nothing; this preserves the old edit-clears-box
144
- * behaviour).
144
+ * behaviour). `columns` is the editable allow-list; framework-owned columns
145
+ * are dropped here too, so no caller can make them writable. `updateSchema`
146
+ * differs only for `writeOnly` columns, which an edit may leave out.
145
147
  */
146
- export function deriveWritableSchema(columns, pkColumn, autoManaged, excludedProps, rune) {
148
+ export function deriveWritableSchema(columns, pkColumn, autoManaged, excludedProps, rune, writeOnly = new Set()) {
147
149
  const fields = {};
150
+ const updateFields = {};
148
151
  const booleanKeys = new Set();
149
152
  for (const c of columns) {
150
- if (c.propertyKey === pkColumn)
151
- continue;
152
- if (autoManaged.has(c.propertyKey))
153
- continue;
154
- if (TIMESTAMP_PROPERTY_KEYS.has(c.propertyKey))
155
- continue;
156
- const snake = c.propertyKey.replace(/([A-Z])/g, "_$1").toLowerCase();
157
- if (TIMESTAMP_COLUMN_NAMES.has(snake))
158
- continue;
159
- if (excludedProps.has(c.propertyKey))
153
+ if (isFrameworkManaged(c, pkColumn, autoManaged, excludedProps))
160
154
  continue;
161
155
  const type = (c.type ?? "").toString().toLowerCase();
162
156
  if (type === "boolean") {
@@ -164,26 +158,38 @@ export function deriveWritableSchema(columns, pkColumn, autoManaged, excludedPro
164
158
  // boolean is always present here → required, no `.optional()`.
165
159
  booleanKeys.add(c.propertyKey);
166
160
  fields[c.propertyKey] = rune.rules.boolean().parse(isCheckedValue);
161
+ updateFields[c.propertyKey] = rune.rules.boolean().parse(isCheckedValue);
167
162
  continue;
168
163
  }
169
- // A column is required iff the model declares neither nullability nor a
170
- // default. DB-side defaults (serial, `DEFAULT`) are invisible to metadata
171
- // (documented residual limitation — see story Dev Notes); the PK, the
172
- // common serial case, is already excluded above.
173
- const required = c.nullable !== true && c.default === undefined;
174
- let chain = NUMBER_COLUMN_TYPES.has(type)
175
- ? rune.rules.number().parse(parseNumeric)
176
- : JSON_COLUMN_TYPES.has(type)
177
- ? rune.rules.any()
178
- : rune.rules.string();
179
- if (!required) {
180
- chain = chain.optional();
181
- if (c.nullable === true)
182
- chain = chain.nullable();
183
- }
184
- fields[c.propertyKey] = chain;
164
+ fields[c.propertyKey] = columnChain(c, type, rune, false);
165
+ updateFields[c.propertyKey] = columnChain(c, type, rune, writeOnly.has(c.propertyKey));
185
166
  }
186
- return { schema: rune.schema(fields), booleanKeys };
167
+ return {
168
+ schema: rune.schema(fields),
169
+ updateSchema: rune.schema(updateFields),
170
+ booleanKeys,
171
+ };
172
+ }
173
+ /**
174
+ * The rule for one non-boolean column. A column is required iff the model
175
+ * declares neither nullability nor a default — unless `keepable`: an edit may
176
+ * omit a write-only column to keep its stored value. DB-side defaults
177
+ * (serial, `DEFAULT`) are invisible to metadata (documented residual
178
+ * limitation); the PK, the common serial case, is never editable.
179
+ */
180
+ function columnChain(c, type, rune, keepable) {
181
+ const required = !keepable && c.nullable !== true && c.default === undefined;
182
+ let chain = NUMBER_COLUMN_TYPES.has(type)
183
+ ? rune.rules.number().parse(parseNumeric)
184
+ : JSON_COLUMN_TYPES.has(type)
185
+ ? rune.rules.any()
186
+ : rune.rules.string();
187
+ if (!required) {
188
+ chain = chain.optional();
189
+ if (c.nullable === true)
190
+ chain = chain.nullable();
191
+ }
192
+ return chain;
187
193
  }
188
194
  /**
189
195
  * Validate a request body against a derived schema (57.7). Default-fills any
@@ -191,10 +197,15 @@ export function deriveWritableSchema(columns, pkColumn, autoManaged, excludedPro
191
197
  * preserving the old `filterWritableBody` checkbox semantics, then runs rune's
192
198
  * synchronous, never-throwing `validateResult()`. On success `result.data` holds only
193
199
  * declared-column keys (mass-assignment inherent). Exported for the unit tests
194
- * that pin the coercion CRUX.
200
+ * that pin the coercion CRUX. A blank `writeOnly` value counts as not
201
+ * submitted: the form never pre-fills those, so blank means "unchanged".
195
202
  */
196
- export function validateWritableBody(schema, booleanKeys, body) {
203
+ export function validateWritableBody(schema, booleanKeys, body, writeOnly = new Set()) {
197
204
  const input = { ...body };
205
+ for (const key of writeOnly) {
206
+ if (input[key] === "")
207
+ delete input[key];
208
+ }
198
209
  for (const key of booleanKeys) {
199
210
  if (!(key in input))
200
211
  input[key] = false;
@@ -243,8 +254,6 @@ function fieldErrors(errors) {
243
254
  }
244
255
  /** Session flash key under which the per-field validation errors are stashed (AdonisJS `flashMessages.errors` parity). */
245
256
  const FLASH_ERRORS_KEY = "errors";
246
- /** Column propertyKeys whose values are secrets — never flashed to the session nor echoed back into HTML (AdonisJS parity). */
247
- const SENSITIVE_KEY_RE = /password|passwd|secret|token|apikey/i;
248
257
  /**
249
258
  * The subset of a submitted body safe to re-surface on a validation error:
250
259
  * declared columns only — so `_csrf`, `_method`, and any mass-assignment /
@@ -255,7 +264,7 @@ const SENSITIVE_KEY_RE = /password|passwd|secret|token|apikey/i;
255
264
  function flashableInput(submitted, columns) {
256
265
  const out = {};
257
266
  for (const c of columns) {
258
- if (SENSITIVE_KEY_RE.test(c.propertyKey))
267
+ if (isSecretColumn(c))
259
268
  continue;
260
269
  if (c.propertyKey in submitted)
261
270
  out[c.propertyKey] = submitted[c.propertyKey];
@@ -295,42 +304,46 @@ function readFlash(ctx, columns) {
295
304
  };
296
305
  }
297
306
  /**
298
- * Same-origin / relative check for a `Referer` — replicates ream's
299
- * `RedirectBuilder.back()` open-redirect guard (Station consumes the response
300
- * through its own `redirect(url)` seam and can't reach ream's builder). A
301
- * relative path is trusted; an absolute URL only when its origin matches the
302
- * request URL. Anything else is untrusted → the caller uses its safe fallback.
307
+ * The path a `Referer` points back to, when it may be trusted — AdonisJS's
308
+ * `isValidRedirectUrl` + `redirect().back()`: a relative path is trusted, an
309
+ * absolute URL only when its host is the request's own authority (`Host` /
310
+ * `:authority`, never a forwarded host), and the redirect goes to the path
311
+ * alone. Comparing with `request.url()` could never work: in ream that is
312
+ * the path, while a browser's Referer is a full URL. Deliberately stricter
313
+ * than upstream in one place: with no authority to compare against, an
314
+ * absolute Referer is refused rather than accepted.
303
315
  */
304
- function isSameOriginReferer(referer, requestUrl) {
316
+ function trustedRefererPath(referer, authority) {
305
317
  // A relative path is trusted only when it starts with a single "/" and
306
318
  // contains no backslash: browsers normalise "\\" to "/", so "/\\evil.com"
307
319
  // becomes the protocol-relative "//evil.com" that a lone `!startsWith("//")`
308
320
  // check would let through.
309
- if (referer.startsWith("/") &&
310
- !referer.startsWith("//") &&
311
- !referer.includes("\\")) {
312
- return true;
313
- }
314
- if (requestUrl === undefined)
315
- return false;
321
+ if (referer.startsWith("//") || referer.includes("\\"))
322
+ return undefined;
316
323
  try {
317
- return new URL(referer).origin === new URL(requestUrl).origin;
324
+ if (referer.startsWith("/")) {
325
+ const url = new URL(referer, "http://localhost");
326
+ return url.host === "localhost" ? url.pathname : undefined;
327
+ }
328
+ if (authority === null || authority === undefined || authority === "") {
329
+ return undefined;
330
+ }
331
+ const url = new URL(referer);
332
+ return url.host === authority ? url.pathname : undefined;
318
333
  }
319
334
  catch {
320
- return false;
335
+ return undefined;
321
336
  }
322
337
  }
323
338
  /**
324
- * `redirect().back()` over Station's `redirect(url)` seam (57.7): prefer a
325
- * trusted same-origin `Referer`, else the caller's `fallback` (the form URL) —
326
- * the same behaviour as ream's `RedirectBuilder.back(fallback)`, open-redirect
327
- * safe.
339
+ * `redirect().back()` over Station's `redirect(url)` seam (57.7): the path of a
340
+ * trusted `Referer`, else the caller's `fallback` (the form URL).
328
341
  */
329
342
  function redirectBack(ctx, fallback) {
330
343
  const referer = ctx.request.header?.("referer");
331
- const target = typeof referer === "string" &&
332
- isSameOriginReferer(referer, ctx.request.url?.())
333
- ? referer
344
+ const authority = ctx.request.authority?.() ?? ctx.request.header?.("host") ?? undefined;
345
+ const target = typeof referer === "string"
346
+ ? (trustedRefererPath(referer, authority) ?? fallback)
334
347
  : fallback;
335
348
  ctx.response.redirect(target);
336
349
  }
@@ -355,10 +368,13 @@ function isCheckedValue(value) {
355
368
  * field). The fallback is shallow but warned ONCE per process so
356
369
  * operators see it.
357
370
  */
358
- function snapshotEntity(entity, columns) {
371
+ function snapshotEntity(entity, columns, redacted) {
359
372
  const out = {};
360
- for (const c of columns)
361
- out[c.propertyKey] = entity[c.propertyKey];
373
+ for (const c of columns) {
374
+ out[c.propertyKey] = redacted.has(c.propertyKey)
375
+ ? REDACTED
376
+ : entity[c.propertyKey];
377
+ }
362
378
  try {
363
379
  return structuredClone(out);
364
380
  }
@@ -386,6 +402,15 @@ function snapshotEntity(entity, columns) {
386
402
  }
387
403
  }
388
404
  let auditCloneWarnEmitted = false;
405
+ /** What an audit snapshot holds in place of a secret column's value. */
406
+ const REDACTED = "[redacted]";
407
+ /**
408
+ * The redacted columns an edit changed — read from the raw values, so the
409
+ * audit trail says "password changed" without carrying either hash.
410
+ */
411
+ function changedRedacted(before, entity, redacted) {
412
+ return [...redacted].filter((key) => !Object.is(before[key], entity[key]));
413
+ }
389
414
  /**
390
415
  * Filesystem path to the `.inker` templates Station ships inside the
391
416
  * package (57.1, D3). Resolved from `import.meta.url` so the SAME path
@@ -410,11 +435,16 @@ export default class StationProvider {
410
435
  // 54.7 auth state — populated when warden is wired AND
411
436
  // StationConfig.requireAuth is true (default when warden is present).
412
437
  #authManager;
438
+ #loginLimiter;
439
+ #cookieSecure;
440
+ #securityAudit;
413
441
  #authConfig = {
414
442
  requireAuth: false,
415
443
  requireRole: undefined,
416
444
  loginPath: "/admin/login",
417
445
  cookieName: "station_auth",
446
+ permissionPrefix: DEFAULT_PERMISSION_PREFIX,
447
+ accessPermission: undefined,
418
448
  };
419
449
  constructor(app) {
420
450
  this.app = app;
@@ -454,7 +484,15 @@ export default class StationProvider {
454
484
  const peers = await this.#loadPeers();
455
485
  if (!peers)
456
486
  return;
457
- const { router, atlas, rune } = peers;
487
+ const { atlas, rune } = peers;
488
+ const router = withRouteMiddleware(peers.router, userConfig.middleware);
489
+ this.#loginLimiter = buildLoginLimiter(userConfig.loginRateLimit);
490
+ this.#cookieSecure = userConfig.cookieSecure;
491
+ if (userConfig.audit !== undefined &&
492
+ typeof userConfig.audit !== "function") {
493
+ throw new TypeError("[station] `station.audit` must be a function");
494
+ }
495
+ this.#securityAudit = userConfig.audit;
458
496
  // Phase 1a2 — resolve the SHARED inker renderer (AdonisJS/Edge parity)
459
497
  // and mount Station's package templates as the `station` disk. Unlike
460
498
  // warden (whose absence keeps the open dev-preview path), a view engine
@@ -697,6 +735,10 @@ export default class StationProvider {
697
735
  * not installed/bound the gate stays open and a one-time warning is emitted.
698
736
  */
699
737
  async #configureAuth(userConfig) {
738
+ const permissionPrefix = userConfig.permissionPrefix ?? DEFAULT_PERMISSION_PREFIX;
739
+ if (typeof permissionPrefix !== "string" || permissionPrefix === "") {
740
+ throw new Error("[station] `station.permissionPrefix` must be a non-empty string: admin permissions need their own namespace.");
741
+ }
700
742
  const wardenWanted = userConfig.requireAuth !== false;
701
743
  if (wardenWanted) {
702
744
  try {
@@ -707,6 +749,10 @@ export default class StationProvider {
707
749
  requireRole: userConfig.requireRole,
708
750
  loginPath: userConfig.loginPath ?? "/admin/login",
709
751
  cookieName: userConfig.cookieName ?? "station_auth",
752
+ permissionPrefix,
753
+ accessPermission: userConfig.accessPermission === false
754
+ ? undefined
755
+ : (userConfig.accessPermission ?? `${permissionPrefix}.access`),
710
756
  };
711
757
  }
712
758
  catch (cause) {
@@ -725,14 +771,22 @@ export default class StationProvider {
725
771
  return;
726
772
  // An admin panel with no auth is full CRUD on every registered resource
727
773
  // for anyone who can reach the route. That is a different product, and
728
- // in production it has to be asked for rather than fallen into — the
729
- // same bet bay makes about losing a job on a Redis without LMOVE.
774
+ // it has to be asked for rather than fallen into — the same bet bay
775
+ // makes about losing a job on a Redis without LMOVE.
730
776
  //
731
777
  // `requireAuth: false` written down IS the asking. Defaulted (undefined)
732
- // is not: it means nobody decided, and nobody deciding must not be how
733
- // an unauthenticated admin reaches production.
734
- if (inProduction() && userConfig.requireAuth !== false) {
735
- throw new Error("[station] Admin routes would mount with NO authorisation in production: " +
778
+ // is not: it means nobody decided, and nobody deciding is only tolerated
779
+ // on a machine positively named development or test. An unset NODE_ENV,
780
+ // `staging`, `preview`… fail closed like production: they are exactly
781
+ // the boxes nobody configured, and staging often holds a copy of
782
+ // production data (ream's `Application.inDev` takes the same stance).
783
+ const env = normalizeNodeEnv(process.env.NODE_ENV);
784
+ const devOrTest = env === "development" || env === "test";
785
+ if (!devOrTest && userConfig.requireAuth !== false) {
786
+ const where = inProduction()
787
+ ? "production"
788
+ : `a non-development environment (NODE_ENV=${process.env.NODE_ENV === undefined ? "unset" : JSON.stringify(process.env.NODE_ENV)})`;
789
+ throw new Error(`[station] Admin routes would mount with NO authorisation in ${where}: ` +
736
790
  "no working `auth` binding is registered, so every `/admin/*` route — " +
737
791
  "including create, update and delete — is open to anyone who can reach it.\n" +
738
792
  " Wire @c9up/warden's WardenProvider, or set `station.requireAuth: false` " +
@@ -744,10 +798,11 @@ export default class StationProvider {
744
798
  // Said even when the deployment opted in: agreeing once in a config file
745
799
  // is not the same as being reminded that this process is running that
746
800
  // way. The line has to be in the logs of the incident.
747
- const optedIn = inProduction() && userConfig.requireAuth === false;
748
- console.warn("[station] Admin routes mounted without auth. Wire @c9up/warden (and set `station.requireAuth: true` if you opted out) and seed the per-action `<resource>.<action>` permissions in the Warden rights store BEFORE production. See https://ream.dev/modules/station#auth." +
801
+ const optedIn = !devOrTest && userConfig.requireAuth === false;
802
+ const envName = env === "unknown" ? "AN UNCONFIGURED ENVIRONMENT" : env.toUpperCase();
803
+ console.warn("[station] Admin routes mounted without auth. Wire @c9up/warden (and set `station.requireAuth: true` if you opted out) and seed the per-action admin permissions in the Warden rights store BEFORE production. See https://ream.dev/modules/station#auth." +
749
804
  (optedIn
750
- ? "\n Running this way in PRODUCTION because `station.requireAuth: false` was set."
805
+ ? `\n Running this way in ${envName} because \`station.requireAuth: false\` was set.`
751
806
  : ""));
752
807
  }
753
808
  /** Phase 3 — mount the login surface + per-resource CRUD routes. */
@@ -801,18 +856,22 @@ export default class StationProvider {
801
856
  if (resource.actions.includes("edit")) {
802
857
  router.get(`/admin/${slug}/:id/edit`, gate(this.#buildEditFormHandler(resource)));
803
858
  router.put(`/admin/${slug}/:id`, gate(this.#buildUpdateHandler(resource)));
804
- // Browser forms can't issue PUT — accept POST with `_method=PUT`
805
- // from the auto-generated form (form.ts stamps the hidden input).
806
- router.post(`/admin/${slug}/:id`, gate(this.#buildMethodOverrideHandler(resource)));
807
859
  }
808
860
  if (resource.actions.includes("destroy")) {
809
861
  router.delete(`/admin/${slug}/:id`, gate(this.#buildDestroyHandler(resource)));
810
862
  }
863
+ // Browser forms can't issue PUT/DELETE — accept POST with `_method`
864
+ // from the auto-generated form (form.ts stamps the hidden input),
865
+ // dispatching only to the actions this resource enables.
866
+ if (resource.actions.includes("edit") ||
867
+ resource.actions.includes("destroy")) {
868
+ router.post(`/admin/${slug}/:id`, gate(this.#buildMethodOverrideHandler(resource)));
869
+ }
811
870
  }
812
871
  }
813
872
  /**
814
873
  * 56.5 — every admin action is now gated behind a
815
- * `<resource>.<action>` permission resolved through @c9up/warden
874
+ * `<prefix>.<resource>.<action>` permission resolved through @c9up/warden
816
875
  * (`auth.hasPermission`). Warn ONCE at boot, only when the auth layer
817
876
  * is wired, that the host must seed roles/grants in the Warden rights
818
877
  * store — otherwise every admin request 403s with no hint. The old
@@ -831,7 +890,14 @@ export default class StationProvider {
831
890
  const [example] = resources;
832
891
  if (example === undefined)
833
892
  return;
834
- console.warn(`[station] Admin actions are gated behind '<resource>.<action>' permissions resolved through @c9up/warden (e.g. '${example.name}.list', '${example.name}.create'). Seed roles/grants in the Warden rights store (store.defineRole('admin', ['${example.name}.list', '${example.name}.create', ...]) then assignRole) or every admin request will 403. See https://ream.dev/modules/station#authorization.`);
893
+ const prefix = this.#authConfig.permissionPrefix;
894
+ const access = this.#authConfig.accessPermission;
895
+ const perms = [
896
+ ...(access === undefined ? [] : [access]),
897
+ `${prefix}.${example.name}.list`,
898
+ `${prefix}.${example.name}.create`,
899
+ ];
900
+ console.warn(`[station] Admin actions are gated behind '${prefix}.<resource>.<action>' permissions resolved through @c9up/warden${access === undefined ? "" : `, and entering the admin behind '${access}'`}. Seed roles/grants in the Warden rights store (store.defineRole('admin', [${perms.map((p) => `'${p}'`).join(", ")}, ...]) then assignRole) or every admin request will 403. See https://ream.dev/modules/station#authorization.`);
835
901
  }
836
902
  #warnAuditGapsOnce(resources) {
837
903
  if (missingAuditWarned)
@@ -870,8 +936,9 @@ export default class StationProvider {
870
936
  * `@c9up/rune` — so an undefined here is an internal invariant break; throw
871
937
  * loud (fail-closed) rather than silently accept an unvalidated body.
872
938
  */
873
- #requireWritableSchema(resource) {
874
- const { writableSchema } = this.#requireContext(resource);
939
+ #requireWritableSchema(resource, mode) {
940
+ const context = this.#requireContext(resource);
941
+ const writableSchema = mode === "create" ? context.writableSchema : context.updateSchema;
875
942
  if (writableSchema === undefined) {
876
943
  throw new Error(`[station] No validation schema for ${resource.entity.name} — @c9up/rune must be installed to accept admin writes.`);
877
944
  }
@@ -911,8 +978,8 @@ export default class StationProvider {
911
978
  // Echo only declared, non-sensitive columns back to the form — never
912
979
  // `_csrf`, secrets, or mass-assignment keys (both the flash store and the
913
980
  // re-rendered HTML). An empty result falls back to `row`.
914
- const { columns, pkColumn } = this.#requireContext(resource);
915
- const safe = flashableInput(submitted, columns);
981
+ const { formColumns, pkColumn, writeOnly } = this.#requireContext(resource);
982
+ const safe = flashableInput(submitted, formColumns);
916
983
  if (ctx.session !== undefined) {
917
984
  // AdonisJS PRG: flash old input + errors, redirect back to the form.
918
985
  ctx.session.flashAll(safe);
@@ -924,8 +991,9 @@ export default class StationProvider {
924
991
  // submitted values + errors are still shown rather than silently dropped.
925
992
  const vm = buildFormViewModel({
926
993
  resource,
927
- columns,
994
+ columns: formColumns,
928
995
  pkColumn,
996
+ writeOnly,
929
997
  row,
930
998
  values: Object.keys(safe).length > 0 ? safe : undefined,
931
999
  errors: fieldErrors(errors),
@@ -938,15 +1006,13 @@ export default class StationProvider {
938
1006
  }
939
1007
  #buildListHandler(resource) {
940
1008
  return async (ctx) => {
941
- const { repo, columns, pkColumn } = this.#requireContext(resource);
1009
+ const { repo, listColumns, pkColumn } = this.#requireContext(resource);
942
1010
  // 56.5: the list action is gated behind `<resource>.list` like
943
1011
  // every other action — resolved through the Warden `"auth"`
944
1012
  // layer. (Retro 2026-06-01: list previously skipped the gate
945
1013
  // entirely, leaking the index regardless of authorization.)
946
- if (!(await authorizeAction(resource, "list", ctx, this.#authManager))) {
947
- deny(ctx);
1014
+ if (!(await this.#authorize(ctx, resource, "list")))
948
1015
  return;
949
- }
950
1016
  const qs = ctx.request.qs();
951
1017
  const page = clampPositiveInt(qs.page, 1);
952
1018
  const perPageRaw = clampPositiveInt(qs.perPage, DEFAULT_PER_PAGE);
@@ -961,6 +1027,18 @@ export default class StationProvider {
961
1027
  // Station keeps only the `MAX_PER_PAGE` clamp (above) and the
962
1028
  // redirect-to-last-page wrapper — atlas floors perPage to ≥1 but
963
1029
  // does not cap it.
1030
+ // Past this page `(page-1)*perPage` is no longer an exact integer:
1031
+ // atlas / the driver reject that offset, which surfaced as a 500.
1032
+ // No table that size exists, so such a page is past the end — count
1033
+ // through page 1 and send the caller to the real last page.
1034
+ if (page > Math.floor(Number.MAX_SAFE_INTEGER / perPage) + 1) {
1035
+ const first = await repo
1036
+ .query()
1037
+ .orderBy(pkColumn, "desc")
1038
+ .paginate(1, perPage);
1039
+ ctx.response.redirect(`/admin/${resource.name}?page=${Math.max(1, first.meta.lastPage)}&perPage=${perPage}`);
1040
+ return;
1041
+ }
964
1042
  const result = await repo
965
1043
  .query()
966
1044
  .orderBy(pkColumn, "desc")
@@ -974,7 +1052,7 @@ export default class StationProvider {
974
1052
  const viewModel = buildListViewModel({
975
1053
  resource,
976
1054
  rows: result.all(),
977
- columns,
1055
+ columns: listColumns,
978
1056
  pkColumn,
979
1057
  page: result.meta.currentPage,
980
1058
  perPage: result.meta.perPage,
@@ -988,14 +1066,12 @@ export default class StationProvider {
988
1066
  }
989
1067
  #buildShowHandler(resource) {
990
1068
  return async (ctx) => {
991
- const { repo, columns, pkColumn } = this.#requireContext(resource);
1069
+ const { repo, showColumns, pkColumn } = this.#requireContext(resource);
992
1070
  // Authorize BEFORE the existence check so an unauthorized caller
993
1071
  // can't distinguish 404 (absent) from 403 (exists) — no row-existence
994
1072
  // oracle for a user without `<resource>.show`.
995
- if (!(await authorizeAction(resource, "show", ctx, this.#authManager))) {
996
- deny(ctx);
1073
+ if (!(await this.#authorize(ctx, resource, "show")))
997
1074
  return;
998
- }
999
1075
  const id = ctx.params.id ?? "";
1000
1076
  const row = await repo.find(id);
1001
1077
  if (row === null) {
@@ -1008,7 +1084,7 @@ export default class StationProvider {
1008
1084
  const viewModel = buildShowViewModel({
1009
1085
  resource,
1010
1086
  row,
1011
- columns,
1087
+ columns: showColumns,
1012
1088
  pkColumn,
1013
1089
  });
1014
1090
  const html = await this.#renderView(ctx, "station::show", viewModel);
@@ -1018,18 +1094,17 @@ export default class StationProvider {
1018
1094
  }
1019
1095
  #buildNewFormHandler(resource) {
1020
1096
  return async (ctx) => {
1021
- const { columns, pkColumn } = this.#requireContext(resource);
1022
- if (!(await authorizeAction(resource, "create", ctx, this.#authManager))) {
1023
- deny(ctx);
1097
+ const { formColumns, pkColumn, writeOnly } = this.#requireContext(resource);
1098
+ if (!(await this.#authorize(ctx, resource, "create")))
1024
1099
  return;
1025
- }
1026
1100
  // 57.7 — after a failed create redirected back (PRG), the flash carries
1027
1101
  // the old input + per-field errors; re-populate + surface them.
1028
- const { values, errors } = readFlash(ctx, columns);
1102
+ const { values, errors } = readFlash(ctx, formColumns);
1029
1103
  const vm = buildFormViewModel({
1030
1104
  resource,
1031
- columns,
1105
+ columns: formColumns,
1032
1106
  pkColumn,
1107
+ writeOnly,
1033
1108
  values,
1034
1109
  errors,
1035
1110
  csrfEnabled: ctx.store?.get("csrfToken") != null,
@@ -1046,21 +1121,20 @@ export default class StationProvider {
1046
1121
  // DB work — no 404-vs-403 oracle to a forged request. A seeded token is
1047
1122
  // not proof; only `csrfProtected === true` is (AC4/AC7).
1048
1123
  if (ctx.request.csrfProtected !== true) {
1049
- denyCsrf(ctx);
1124
+ await this.#denyCsrf(ctx);
1050
1125
  return;
1051
1126
  }
1052
- const { repo, pkColumn, columns, booleanKeys } = this.#requireContext(resource);
1053
- if (!(await authorizeAction(resource, "create", ctx, this.#authManager))) {
1054
- deny(ctx);
1127
+ const { repo, pkColumn, columns, booleanKeys, writeOnly, redacted } = this.#requireContext(resource);
1128
+ if (!(await this.#authorize(ctx, resource, "create")))
1055
1129
  return;
1056
- }
1057
1130
  const body = await readBody(ctx);
1058
1131
  // 57.7 — validate + mass-assignment guard in one pass. `result.data`
1059
- // holds ONLY declared-column keys, so an attacker's `{ role: "admin" }`
1060
- // / `{ passwordHash: "x" }` / PK / timestamps are never present (dropped
1061
- // by the schema, not a separate filter), and each field is type-checked.
1132
+ // holds ONLY editable-column keys (the allow-list: `editable`, or every
1133
+ // non-secret, non-hidden, non-framework column), so a forged `isAdmin`
1134
+ // on a hidden column, a secret column, the PK or a timestamp is dropped
1135
+ // by the schema, and each field is type-checked.
1062
1136
  // Invalid input fail-closes with a 422 BEFORE any repository write (AC4).
1063
- const result = validateWritableBody(this.#requireWritableSchema(resource), booleanKeys, body);
1137
+ const result = validateWritableBody(this.#requireWritableSchema(resource, "create"), booleanKeys, body, writeOnly);
1064
1138
  if (!result.valid) {
1065
1139
  // PRG fallback URL = the create form (AdonisJS `redirect().back()`).
1066
1140
  const formUrl = `/admin/${encodeURIComponent(resource.name)}/new`;
@@ -1073,7 +1147,8 @@ export default class StationProvider {
1073
1147
  resource: resource.name,
1074
1148
  recordId: created[pkColumn],
1075
1149
  userId: ctx.auth?.user?.id,
1076
- after: snapshotEntity(created, columns),
1150
+ ...requestOrigin(ctx),
1151
+ after: snapshotEntity(created, columns, redacted),
1077
1152
  at: new Date(),
1078
1153
  });
1079
1154
  redirectToShow(ctx, resource, created[pkColumn]);
@@ -1081,12 +1156,10 @@ export default class StationProvider {
1081
1156
  }
1082
1157
  #buildEditFormHandler(resource) {
1083
1158
  return async (ctx) => {
1084
- const { repo, columns, pkColumn } = this.#requireContext(resource);
1159
+ const { repo, formColumns, pkColumn, writeOnly } = this.#requireContext(resource);
1085
1160
  // Authorize before the existence check (no 404-vs-403 oracle).
1086
- if (!(await authorizeAction(resource, "edit", ctx, this.#authManager))) {
1087
- deny(ctx);
1161
+ if (!(await this.#authorize(ctx, resource, "edit")))
1088
1162
  return;
1089
- }
1090
1163
  const id = ctx.params.id ?? "";
1091
1164
  const row = await repo.find(id);
1092
1165
  if (row === null) {
@@ -1099,11 +1172,12 @@ export default class StationProvider {
1099
1172
  // 57.7 — after a failed update redirected back (PRG), the flash carries
1100
1173
  // the submitted values + errors; they win over the DB `row` so the user
1101
1174
  // sees what they typed. A normal edit GET has no flash → fields from row.
1102
- const { values, errors } = readFlash(ctx, columns);
1175
+ const { values, errors } = readFlash(ctx, formColumns);
1103
1176
  const vm = buildFormViewModel({
1104
1177
  resource,
1105
- columns,
1178
+ columns: formColumns,
1106
1179
  pkColumn,
1180
+ writeOnly,
1107
1181
  row,
1108
1182
  values,
1109
1183
  errors,
@@ -1118,15 +1192,13 @@ export default class StationProvider {
1118
1192
  return async (ctx) => {
1119
1193
  // Fail-close on CSRF before any auth/DB work (see #buildCreateHandler).
1120
1194
  if (ctx.request.csrfProtected !== true) {
1121
- denyCsrf(ctx);
1195
+ await this.#denyCsrf(ctx);
1122
1196
  return;
1123
1197
  }
1124
- const { repo, pkColumn, columns, booleanKeys } = this.#requireContext(resource);
1198
+ const { repo, pkColumn, columns, booleanKeys, writeOnly, redacted } = this.#requireContext(resource);
1125
1199
  // Authorize before the existence check (no 404-vs-403 oracle).
1126
- if (!(await authorizeAction(resource, "edit", ctx, this.#authManager))) {
1127
- deny(ctx);
1200
+ if (!(await this.#authorize(ctx, resource, "edit")))
1128
1201
  return;
1129
- }
1130
1202
  const id = ctx.params.id ?? "";
1131
1203
  const entity = await repo.find(id);
1132
1204
  if (entity === null) {
@@ -1142,7 +1214,7 @@ export default class StationProvider {
1142
1214
  // poisons the audit diff nor emits a success event; the form re-renders
1143
1215
  // 422 with the submitted values + per-field errors (the existing entity
1144
1216
  // still supplies the id/action).
1145
- const result = validateWritableBody(this.#requireWritableSchema(resource), booleanKeys, body);
1217
+ const result = validateWritableBody(this.#requireWritableSchema(resource, "update"), booleanKeys, body, writeOnly);
1146
1218
  if (!result.valid) {
1147
1219
  // PRG fallback URL = this row's edit form (AdonisJS `redirect().back()`).
1148
1220
  const formUrl = `/admin/${encodeURIComponent(resource.name)}/${encodeURIComponent(id)}/edit`;
@@ -1154,19 +1226,24 @@ export default class StationProvider {
1154
1226
  // would shadow the original values after setProp). `result.data`
1155
1227
  // holds only declared, type-checked columns — the mass-assignment
1156
1228
  // guarantee (PK / timestamps / unknown keys dropped) is inherent.
1157
- const beforeSnapshot = snapshotEntity(entity, columns);
1229
+ const beforeSnapshot = snapshotEntity(entity, columns, redacted);
1230
+ const rawSecrets = {};
1231
+ for (const key of redacted)
1232
+ rawSecrets[key] = entity[key];
1158
1233
  for (const [key, value] of Object.entries(result.data)) {
1159
1234
  entity.setProp(key, value);
1160
1235
  }
1161
1236
  await repo.save(entity);
1162
- const afterSnapshot = snapshotEntity(entity, columns);
1237
+ const afterSnapshot = snapshotEntity(entity, columns, redacted);
1163
1238
  await emitAudit(resource, {
1164
1239
  action: "edit",
1165
1240
  resource: resource.name,
1166
1241
  recordId: entity[pkColumn],
1167
1242
  userId: ctx.auth?.user?.id,
1243
+ ...requestOrigin(ctx),
1168
1244
  before: beforeSnapshot,
1169
1245
  after: afterSnapshot,
1246
+ redactedChanges: changedRedacted(rawSecrets, entity, redacted),
1170
1247
  at: new Date(),
1171
1248
  });
1172
1249
  redirectToShow(ctx, resource, entity[pkColumn]);
@@ -1176,47 +1253,55 @@ export default class StationProvider {
1176
1253
  // Browser forms can only emit GET / POST. The auto-generated edit
1177
1254
  // form ships `<input type="hidden" name="_method" value="PUT">`
1178
1255
  // so the POST /admin/:r/:id endpoint can route to the update
1179
- // handler. A POST without `_method=PUT` (or with `_method=DELETE`)
1180
- // dispatches accordingly.
1181
- const updateHandler = this.#buildUpdateHandler(resource);
1182
- const destroyHandler = this.#buildDestroyHandler(resource);
1256
+ // handler. Only the enabled actions get a handler: an override naming a
1257
+ // disabled one is refused like any unsupported method, so `actions`
1258
+ // stays the boundary of what the admin can do to a resource.
1259
+ const updateHandler = resource.actions.includes("edit")
1260
+ ? this.#buildUpdateHandler(resource)
1261
+ : undefined;
1262
+ const destroyHandler = resource.actions.includes("destroy")
1263
+ ? this.#buildDestroyHandler(resource)
1264
+ : undefined;
1265
+ const allowed = [
1266
+ ...(updateHandler === undefined ? [] : ["PUT", "PATCH"]),
1267
+ ...(destroyHandler === undefined ? [] : ["DELETE"]),
1268
+ ];
1183
1269
  return async (ctx) => {
1184
1270
  // Fail-close on CSRF before reading the body (see #buildCreateHandler).
1185
1271
  // The PUT/DELETE branches delegate to already-guarded handlers, but the
1186
1272
  // unsupported-override 405 branch would otherwise do work on a forged
1187
1273
  // request and leak a 403-vs-405 oracle — guard the entrypoint too.
1188
1274
  if (ctx.request.csrfProtected !== true) {
1189
- denyCsrf(ctx);
1275
+ await this.#denyCsrf(ctx);
1190
1276
  return;
1191
1277
  }
1192
1278
  const body = await readBody(ctx);
1193
1279
  const override = String(body._method ?? "").toUpperCase();
1194
- if (override === "PUT" || override === "PATCH") {
1280
+ if (updateHandler !== undefined &&
1281
+ (override === "PUT" || override === "PATCH")) {
1195
1282
  return updateHandler(ctx);
1196
1283
  }
1197
- if (override === "DELETE") {
1284
+ if (destroyHandler !== undefined && override === "DELETE") {
1198
1285
  return destroyHandler(ctx);
1199
1286
  }
1200
- // Unsupported override — refuse rather than silently downgrade
1201
- // to a no-op so misconfigured forms surface immediately.
1287
+ // Unsupported or disabled override — refuse rather than silently
1288
+ // downgrade to a no-op so misconfigured forms surface immediately.
1202
1289
  ctx.response.status(405);
1203
1290
  ctx.response.type("text/html; charset=utf-8");
1204
- ctx.response.send(`<h1>405 Method Not Allowed</h1><p>POST /admin/${escapeMin(resource.name)}/:id requires <code>_method=PUT</code> or <code>_method=DELETE</code>.</p>`);
1291
+ ctx.response.send(`<h1>405 Method Not Allowed</h1><p>POST /admin/${escapeMin(resource.name)}/:id accepts <code>_method</code> = ${escapeMin(allowed.join(" | "))}.</p>`);
1205
1292
  };
1206
1293
  }
1207
1294
  #buildDestroyHandler(resource) {
1208
1295
  return async (ctx) => {
1209
1296
  // Fail-close on CSRF before any auth/DB work (see #buildCreateHandler).
1210
1297
  if (ctx.request.csrfProtected !== true) {
1211
- denyCsrf(ctx);
1298
+ await this.#denyCsrf(ctx);
1212
1299
  return;
1213
1300
  }
1214
- const { repo, pkColumn, columns } = this.#requireContext(resource);
1301
+ const { repo, pkColumn, columns, redacted } = this.#requireContext(resource);
1215
1302
  // Authorize before the existence check (no 404-vs-403 oracle).
1216
- if (!(await authorizeAction(resource, "destroy", ctx, this.#authManager))) {
1217
- deny(ctx);
1303
+ if (!(await this.#authorize(ctx, resource, "destroy")))
1218
1304
  return;
1219
- }
1220
1305
  const id = ctx.params.id ?? "";
1221
1306
  const row = await repo.find(id);
1222
1307
  if (row === null) {
@@ -1226,13 +1311,14 @@ export default class StationProvider {
1226
1311
  ctx.response.send(notFoundHtml);
1227
1312
  return;
1228
1313
  }
1229
- const before = snapshotEntity(row, columns);
1314
+ const before = snapshotEntity(row, columns, redacted);
1230
1315
  await repo.delete(row);
1231
1316
  await emitAudit(resource, {
1232
1317
  action: "destroy",
1233
1318
  resource: resource.name,
1234
1319
  recordId: row[pkColumn],
1235
1320
  userId: ctx.auth?.user?.id,
1321
+ ...requestOrigin(ctx),
1236
1322
  before,
1237
1323
  at: new Date(),
1238
1324
  });
@@ -1333,7 +1419,7 @@ export default class StationProvider {
1333
1419
  // Clear the stale cookie regardless of response shape, so neither
1334
1420
  // a browser refresh nor an XHR caller retries with the dead token.
1335
1421
  ctx.response.clearCookie?.(this.#authConfig.cookieName, {
1336
- path: "/",
1422
+ path: AUTH_COOKIE_PATH,
1337
1423
  });
1338
1424
  if (this.#wantsJsonResponse(ctx)) {
1339
1425
  ctx.response.status(401);
@@ -1369,6 +1455,11 @@ export default class StationProvider {
1369
1455
  roleOk = false;
1370
1456
  }
1371
1457
  if (!roleOk) {
1458
+ await this.#securityEvent(ctx, {
1459
+ action: "denied",
1460
+ userId: user.id,
1461
+ permission: `role:${required}`,
1462
+ });
1372
1463
  if (this.#wantsJsonResponse(ctx)) {
1373
1464
  ctx.response.status(403);
1374
1465
  ctx.response.json({ error: "insufficient role" });
@@ -1380,6 +1471,30 @@ export default class StationProvider {
1380
1471
  return;
1381
1472
  }
1382
1473
  }
1474
+ // Entering the admin is its own grant (bug 214): without it, any app
1475
+ // user holding a same-named permission for the public app got in.
1476
+ const access = this.#authConfig.accessPermission;
1477
+ if (access !== undefined) {
1478
+ let accessOk;
1479
+ try {
1480
+ accessOk =
1481
+ (await manager.hasPermission(user, access, "global")) === true;
1482
+ }
1483
+ catch (err) {
1484
+ const detail = err instanceof Error ? err.message : String(err);
1485
+ console.error(`[station] access check threw for '${access}' — denying (fail-closed): ${detail}`);
1486
+ accessOk = false;
1487
+ }
1488
+ if (!accessOk) {
1489
+ await this.#securityEvent(ctx, {
1490
+ action: "denied",
1491
+ userId: user.id,
1492
+ permission: access,
1493
+ });
1494
+ deny(ctx);
1495
+ return;
1496
+ }
1497
+ }
1383
1498
  const existingAuth = ctx.auth ?? {};
1384
1499
  ctx.auth = {
1385
1500
  ...existingAuth,
@@ -1407,11 +1522,11 @@ export default class StationProvider {
1407
1522
  }
1408
1523
  }
1409
1524
  }
1410
- const qs = ctx.request.qs();
1411
- const errorParam = qs.error;
1525
+ // No `?error=` read: Station never redirects with one (a failed POST
1526
+ // re-renders inline), so its only use was a third party writing
1527
+ // arbitrary text into the sign-in page's alert.
1412
1528
  const vm = buildLoginViewModel({
1413
1529
  action: this.#authConfig.loginPath,
1414
- error: typeof errorParam === "string" ? errorParam : undefined,
1415
1530
  csrfEnabled: ctx.store?.get("csrfToken") != null,
1416
1531
  });
1417
1532
  const html = await this.#renderView(ctx, "station::login", vm);
@@ -1431,7 +1546,7 @@ export default class StationProvider {
1431
1546
  // AuthManager work (see #buildCreateHandler). Login is open by design
1432
1547
  // (no auth gate), so CSRF is the sole pre-check here.
1433
1548
  if (ctx.request.csrfProtected !== true) {
1434
- denyCsrf(ctx);
1549
+ await this.#denyCsrf(ctx);
1435
1550
  return;
1436
1551
  }
1437
1552
  const manager = this.#authManager;
@@ -1457,12 +1572,21 @@ export default class StationProvider {
1457
1572
  ctx.response.send(html);
1458
1573
  return;
1459
1574
  }
1575
+ // Counted BEFORE the credential check (warden's AuthRateLimiter
1576
+ // contract): a refused attempt never reaches authenticate.
1577
+ const keys = loginKeys(ctx.request.ip?.() ?? "unknown", email);
1578
+ if (!(await this.#admitLogin(ctx, keys, email)))
1579
+ return;
1460
1580
  const result = await manager.authenticate({ email, password });
1461
1581
  // Warden returns the issued token on `user.token`, not at the
1462
1582
  // top level — reading `result.token` (which never exists) sent
1463
1583
  // every valid login down the 401 branch.
1464
1584
  const token = typeof result.user?.token === "string" ? result.user.token : undefined;
1465
1585
  if (!result.authenticated || token === undefined) {
1586
+ await this.#securityEvent(ctx, {
1587
+ action: "login_failed",
1588
+ identifier: email,
1589
+ });
1466
1590
  const vm = buildLoginViewModel({
1467
1591
  action: this.#authConfig.loginPath,
1468
1592
  email,
@@ -1475,15 +1599,132 @@ export default class StationProvider {
1475
1599
  ctx.response.send(html);
1476
1600
  return;
1477
1601
  }
1602
+ await this.#loginLimiter?.clear(...keys);
1603
+ await this.#securityEvent(ctx, {
1604
+ action: "login",
1605
+ userId: result.user?.id,
1606
+ identifier: email,
1607
+ });
1608
+ // No maxAge: a session cookie, gone with the browser; the token's own
1609
+ // expiry is enforced by verify() on every request.
1478
1610
  ctx.response.cookie?.(this.#authConfig.cookieName, token, {
1479
1611
  httpOnly: true,
1480
1612
  sameSite: "Lax",
1481
- secure: process.env.NODE_ENV === "production",
1482
- path: "/",
1613
+ secure: this.#cookieSecure ?? secureByDefault(ctx),
1614
+ path: AUTH_COOKIE_PATH,
1483
1615
  });
1484
1616
  ctx.response.redirect("/admin");
1485
1617
  };
1486
1618
  }
1619
+ /**
1620
+ * The per-action gate: allow, or record the refusal and answer 403.
1621
+ */
1622
+ async #authorize(ctx, resource, action) {
1623
+ const prefix = this.#authConfig.permissionPrefix;
1624
+ if (await authorizeAction(resource, action, ctx, this.#authManager, prefix)) {
1625
+ return true;
1626
+ }
1627
+ await this.#securityEvent(ctx, {
1628
+ action: "denied",
1629
+ userId: ctx.auth?.user?.id,
1630
+ resource: resource.name,
1631
+ resourceAction: action,
1632
+ permission: `${prefix}.${resource.name}.${action}`,
1633
+ });
1634
+ deny(ctx);
1635
+ return false;
1636
+ }
1637
+ /** Record a CSRF block, then refuse it. */
1638
+ async #denyCsrf(ctx) {
1639
+ await this.#securityEvent(ctx, { action: "csrf_denied" });
1640
+ denyCsrf(ctx);
1641
+ }
1642
+ /**
1643
+ * Send a security event to `station.audit`, stamped with the request
1644
+ * origin. A failing sink is logged, never allowed to change the response.
1645
+ */
1646
+ async #securityEvent(ctx, event) {
1647
+ const sink = this.#securityAudit;
1648
+ if (sink === undefined)
1649
+ return;
1650
+ try {
1651
+ await sink({
1652
+ ...event,
1653
+ ...requestOrigin(ctx),
1654
+ path: ctx.request.url?.(),
1655
+ at: new Date(),
1656
+ });
1657
+ }
1658
+ catch (err) {
1659
+ const detail = err instanceof Error ? err.message : String(err);
1660
+ console.error(`[station] security audit sink threw on ${event.action}: ${detail}. The event was NOT recorded.`);
1661
+ }
1662
+ }
1663
+ /**
1664
+ * Revoke `token` through the default guard when it supports revocation.
1665
+ * A guard without it (or a JwtStrategy without a blacklist, whose
1666
+ * `revoke` throws) is reported once: logout then only clears the cookie.
1667
+ */
1668
+ async #revoke(token) {
1669
+ const strategy = this.#authManager?.getStrategy?.();
1670
+ let reason = "the auth guard has no revoke(token)";
1671
+ if (typeof strategy === "object" &&
1672
+ strategy !== null &&
1673
+ "revoke" in strategy &&
1674
+ typeof strategy.revoke === "function") {
1675
+ try {
1676
+ await strategy.revoke(token);
1677
+ return;
1678
+ }
1679
+ catch (err) {
1680
+ reason = err instanceof Error ? err.message : String(err);
1681
+ }
1682
+ }
1683
+ if (revokeUnavailableWarned)
1684
+ return;
1685
+ revokeUnavailableWarned = true;
1686
+ console.warn(`[station] Logout cannot revoke the admin token (${reason}); it stays valid until it expires. Give warden's JwtStrategy a \`blacklist\` so a logged-out token is refused.`);
1687
+ }
1688
+ /**
1689
+ * Count a login attempt and answer the refusal when the limiter says no:
1690
+ * 429 + Retry-After when exhausted, 503 when the limiter itself failed (a
1691
+ * throttle that cannot count must not wave attempts through).
1692
+ */
1693
+ async #admitLogin(ctx, keys, email) {
1694
+ const limiter = this.#loginLimiter;
1695
+ if (limiter === undefined)
1696
+ return true;
1697
+ let decision;
1698
+ try {
1699
+ decision = await limiter.attempt(...keys);
1700
+ }
1701
+ catch (err) {
1702
+ const detail = err instanceof Error ? err.message : String(err);
1703
+ console.error(`[station] login rate limiter failed — refusing the attempt (fail-closed): ${detail}`);
1704
+ ctx.response.status(503);
1705
+ ctx.response.type("text/plain; charset=utf-8");
1706
+ ctx.response.send("Sign-in is temporarily unavailable.");
1707
+ return false;
1708
+ }
1709
+ if (decision.allowed)
1710
+ return true;
1711
+ await this.#securityEvent(ctx, {
1712
+ action: "login_throttled",
1713
+ identifier: email,
1714
+ });
1715
+ ctx.response.status(429);
1716
+ ctx.response.header("Retry-After", String(Math.max(1, Math.ceil(decision.retryAfterSeconds))));
1717
+ const vm = buildLoginViewModel({
1718
+ action: this.#authConfig.loginPath,
1719
+ email,
1720
+ error: "Too many sign-in attempts. Try again later.",
1721
+ csrfEnabled: ctx.store?.get("csrfToken") != null,
1722
+ });
1723
+ const html = await this.#renderView(ctx, "station::login", vm);
1724
+ ctx.response.type("text/html; charset=utf-8");
1725
+ ctx.response.send(html);
1726
+ return false;
1727
+ }
1487
1728
  /**
1488
1729
  * `POST /admin/logout` — clear the session cookie and redirect to
1489
1730
  * the login page. POST (not GET) so a crafted `<img src>` can't log
@@ -1494,16 +1735,83 @@ export default class StationProvider {
1494
1735
  // Fail-close on CSRF: a forged cross-site POST must not be able to log
1495
1736
  // an admin out (session-fixation nuisance) (see #buildCreateHandler).
1496
1737
  if (ctx.request.csrfProtected !== true) {
1497
- denyCsrf(ctx);
1738
+ await this.#denyCsrf(ctx);
1498
1739
  return;
1499
1740
  }
1741
+ // Clearing the cookie alone left the token valid until it expired:
1742
+ // a copy taken before logout (shared machine, proxy log) still
1743
+ // opened the admin.
1744
+ const token = this.#readAuthToken(ctx);
1745
+ if (token !== undefined) {
1746
+ // Who is leaving, read before the token is revoked — only when
1747
+ // someone is listening, since it costs a verify().
1748
+ const userId = this.#securityAudit === undefined
1749
+ ? undefined
1750
+ : (await this.#authManager?.verify(token))?.user?.id;
1751
+ await this.#revoke(token);
1752
+ await this.#securityEvent(ctx, { action: "logout", userId });
1753
+ }
1754
+ else {
1755
+ await this.#securityEvent(ctx, { action: "logout" });
1756
+ }
1500
1757
  ctx.response.clearCookie?.(this.#authConfig.cookieName, {
1501
- path: "/",
1758
+ path: AUTH_COOKIE_PATH,
1502
1759
  });
1503
1760
  ctx.response.redirect(this.#authConfig.loginPath);
1504
1761
  };
1505
1762
  }
1506
1763
  }
1764
+ /** Who sent the request, as far as the host can tell. */
1765
+ function requestOrigin(ctx) {
1766
+ return {
1767
+ ip: ctx.request.ip?.(),
1768
+ userAgent: ctx.request.header?.("user-agent"),
1769
+ requestId: ctx.request.id?.() ?? ctx.request.header?.("x-request-id"),
1770
+ };
1771
+ }
1772
+ /**
1773
+ * `Secure` unless this is a development/test process on plain HTTP — an
1774
+ * unknown environment gets the production answer, and `NODE_ENV=prod` is
1775
+ * production too.
1776
+ */
1777
+ function secureByDefault(ctx) {
1778
+ const env = normalizeNodeEnv(process.env.NODE_ENV);
1779
+ if (env !== "development" && env !== "test")
1780
+ return true;
1781
+ return ctx.request.secure?.() === true;
1782
+ }
1783
+ function buildLoginLimiter(config) {
1784
+ if (config === false)
1785
+ return undefined;
1786
+ if (config?.limiter !== undefined)
1787
+ return config.limiter;
1788
+ return new MemoryLoginLimiter(config?.maxAttempts, config?.windowSeconds);
1789
+ }
1790
+ /**
1791
+ * A router whose every route gets `names` attached through the route's own
1792
+ * `middleware(...names)` (ream's / AdonisJS's route API). A router that hands
1793
+ * back no such method cannot honour the config, which is refused loudly.
1794
+ */
1795
+ function withRouteMiddleware(router, names) {
1796
+ if (names === undefined || names.length === 0)
1797
+ return router;
1798
+ const attach = (route) => {
1799
+ if (typeof route !== "object" ||
1800
+ route === null ||
1801
+ !("middleware" in route) ||
1802
+ typeof route.middleware !== "function") {
1803
+ throw new Error("[station] `station.middleware` is set but the router's routes have no middleware() method to attach it with.");
1804
+ }
1805
+ route.middleware(...names);
1806
+ return route;
1807
+ };
1808
+ return {
1809
+ get: (path, handler) => attach(router.get(path, handler)),
1810
+ post: (path, handler) => attach(router.post(path, handler)),
1811
+ put: (path, handler) => attach(router.put(path, handler)),
1812
+ delete: (path, handler) => attach(router.delete(path, handler)),
1813
+ };
1814
+ }
1507
1815
  /**
1508
1816
  * Cross-package bridge — Station's `Resource.entity` is intentionally
1509
1817
  * typed `new (...args: never[]) => unknown` so the package type-compiles
@@ -1538,15 +1846,27 @@ function buildResourceContext(resource, db, atlas, rune) {
1538
1846
  const excludedProps = new Set(atlas.getRelationMetadata(resource.entity).map((r) => r.propertyKey));
1539
1847
  if (atlas.hasSoftDeletes(resource.entity))
1540
1848
  excludedProps.add("deletedAt");
1849
+ const listColumns = viewColumns(columns, resource.list.columns, resource, "list.columns");
1850
+ const showColumns = viewColumns(columns, resource.show.columns, resource, "show.columns");
1851
+ const formColumns = editableColumns(columns, resource, pkColumn, autoManaged, excludedProps);
1852
+ const writeOnly = writeOnlyKeys(formColumns, resource);
1541
1853
  const derived = rune === null
1542
1854
  ? undefined
1543
- : deriveWritableSchema(columns, pkColumn, autoManaged, excludedProps, rune);
1855
+ : deriveWritableSchema(formColumns, pkColumn, autoManaged, excludedProps, rune, writeOnly);
1544
1856
  return {
1545
1857
  repo,
1546
1858
  columns,
1859
+ listColumns,
1860
+ showColumns,
1861
+ formColumns,
1862
+ writeOnly,
1863
+ redacted: new Set(resource.auditSecrets
1864
+ ? []
1865
+ : columns.filter(isSecretColumn).map((c) => c.propertyKey)),
1547
1866
  pkColumn,
1548
1867
  autoManaged,
1549
1868
  writableSchema: derived?.schema,
1869
+ updateSchema: derived?.updateSchema,
1550
1870
  booleanKeys: derived?.booleanKeys ?? new Set(),
1551
1871
  };
1552
1872
  }
@@ -1624,7 +1944,7 @@ function isRouterProxyUninit(err) {
1624
1944
  /**
1625
1945
  * 56.5 authorization gate. Returns true when the action is allowed.
1626
1946
  * Station authorizes EXCLUSIVELY through Warden's unified layer: the
1627
- * decision is `auth.hasPermission(user, "<resource>.<action>", scope)`
1947
+ * decision is `auth.hasPermission(user, "<prefix>.<resource>.<action>", scope)`
1628
1948
  * resolved via the container `"auth"` AuthManager (D1/D2). No
1629
1949
  * Station-local RBAC computation, no token-payload read.
1630
1950
  *
@@ -1644,7 +1964,8 @@ function isRouterProxyUninit(err) {
1644
1964
  * The result MUST be awaited at every call site — a forgotten `await`
1645
1965
  * yields a truthy Promise = silent ALLOW (AC10 probe 3).
1646
1966
  */
1647
- async function authorizeAction(resource, action, ctx, authManager, scope = "global") {
1967
+ async function authorizeAction(resource, action, ctx, authManager, prefix, scope = "global") {
1968
+ const permission = `${prefix}.${resource.name}.${action}`;
1648
1969
  if (authManager === undefined)
1649
1970
  return true;
1650
1971
  const user = ctx.auth?.user;
@@ -1657,13 +1978,13 @@ async function authorizeAction(resource, action, ctx, authManager, scope = "glob
1657
1978
  // Coerce to a strict boolean: the duck-typed manager could resolve a
1658
1979
  // truthy non-boolean, and `!truthy` would silently ALLOW. Anything
1659
1980
  // not exactly `true` denies (fail-closed).
1660
- return ((await authManager.hasPermission(user, `${resource.name}.${action}`, scope)) === true);
1981
+ return (await authManager.hasPermission(user, permission, scope)) === true;
1661
1982
  }
1662
1983
  catch (err) {
1663
1984
  // A rights-store I/O failure (DB/Redis-backed resolver) must not pass
1664
1985
  // the gate. Deny and surface it loud rather than 500-ing or allowing.
1665
1986
  const detail = err instanceof Error ? err.message : String(err);
1666
- console.error(`[station] authorization check threw for '${resource.name}.${action}' — denying (fail-closed): ${detail}`);
1987
+ console.error(`[station] authorization check threw for '${permission}' — denying (fail-closed): ${detail}`);
1667
1988
  return false;
1668
1989
  }
1669
1990
  }