@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.
@@ -26,10 +26,29 @@ import type {
26
26
  DatabaseConnection,
27
27
  DateColumnConfig,
28
28
  } from "@c9up/atlas";
29
+ import {
30
+ editableColumns,
31
+ isFrameworkManaged,
32
+ isSecretColumn,
33
+ viewColumns,
34
+ writeOnlyKeys,
35
+ } from "./columns.js";
36
+ import {
37
+ loginKeys,
38
+ MemoryLoginLimiter,
39
+ type StationLoginLimiter,
40
+ type StationLoginRateLimitConfig,
41
+ } from "./loginLimiter.js";
29
42
  import { ResourceRegistry } from "./ResourceRegistry.js";
30
43
  import { clearStation, getStation, setStation } from "./services/main.js";
31
- import type { AuditEvent, Resource, ResourceAction } from "./types.js";
32
- import { inProduction } from "./vendor/nodeEnv.js";
44
+ import type {
45
+ AuditEvent,
46
+ Resource,
47
+ ResourceAction,
48
+ SecurityAuditEvent,
49
+ SecurityAuditSink,
50
+ } from "./types.js";
51
+ import { inProduction, normalizeNodeEnv } from "./vendor/nodeEnv.js";
33
52
  // note: AuditEvent + ResourceAction are used by the CRUD handlers below;
34
53
  // the imports stay in one block for clarity.
35
54
  import { buildFormViewModel } from "./views/form.js";
@@ -69,9 +88,18 @@ interface StationHttpContext {
69
88
  request: {
70
89
  qs(): Record<string, string | undefined>;
71
90
  body?(): Promise<unknown> | unknown;
72
- url?(): string;
73
91
  header?(name: string): string | undefined;
74
92
  cookie?(name: string): string | undefined;
93
+ /** The request path (ream's `url()` without the query string). */
94
+ url?(): string;
95
+ /** `X-Request-Id`, when the proxy or client set one (AdonisJS `id`). */
96
+ id?(): string | undefined;
97
+ /** `:authority` or `Host` — never a forwarded host (AdonisJS `authority`). */
98
+ authority?(): string | null;
99
+ /** Client IP as the host resolved it (trusted-proxy aware in ream). */
100
+ ip?(): string;
101
+ /** Whether the request came over HTTPS (trusted-proxy aware in ream). */
102
+ secure?(): boolean;
75
103
  /**
76
104
  * `true` only when the host's `@c9up/blackhole` middleware enforced +
77
105
  * validated CSRF for this request (a first-class ream `HttpContext`
@@ -200,7 +228,18 @@ interface StationPaginator {
200
228
  /** Per-resource snapshot built once at `start()`, reused on every request. */
201
229
  interface ResourceContext {
202
230
  repo: StationRepository;
231
+ /** Every column — audit snapshots read these (secrets redacted there). */
203
232
  columns: ReadonlyArray<ColumnMetadata>;
233
+ /** What the list page renders (bug 209: secrets excluded by default). */
234
+ listColumns: ReadonlyArray<ColumnMetadata>;
235
+ /** What the show page renders. */
236
+ showColumns: ReadonlyArray<ColumnMetadata>;
237
+ /** The editable allow-list — the form's fields and the schema's keys. */
238
+ formColumns: ReadonlyArray<ColumnMetadata>;
239
+ /** Editable columns never pre-filled; an empty submission keeps them. */
240
+ writeOnly: ReadonlySet<string>;
241
+ /** Columns whose values audit snapshots replace with "[redacted]". */
242
+ redacted: ReadonlySet<string>;
204
243
  pkColumn: string;
205
244
  /**
206
245
  * Property keys of framework-managed timestamp columns — those declared
@@ -220,6 +259,8 @@ interface ResourceContext {
220
259
  * unchecked box submits nothing).
221
260
  */
222
261
  writableSchema?: RuneSchema;
262
+ /** Same keys, write-only columns optional: an edit may leave them as stored. */
263
+ updateSchema?: RuneSchema;
223
264
  booleanKeys: ReadonlySet<string>;
224
265
  }
225
266
 
@@ -419,6 +460,12 @@ interface WardenAuthManager {
419
460
  role: string,
420
461
  scope?: StationScope,
421
462
  ): Promise<boolean>;
463
+ /**
464
+ * The guard `verify(token)` uses when no name is given (warden's
465
+ * `getStrategy()`). Logout calls its `revoke(token)` when it has one —
466
+ * JwtStrategy does, backed by its jti blacklist.
467
+ */
468
+ getStrategy?(name?: string): unknown;
422
469
  }
423
470
 
424
471
  /**
@@ -435,11 +482,23 @@ export interface StationConfig {
435
482
  */
436
483
  requireAuth?: boolean;
437
484
  /**
438
- * Role required to pass the auth gate. When omitted, any
439
- * authenticated user can access `/admin/*` (the per-action
440
- * `<resource>.<action>` permission gate still applies).
485
+ * Role required to pass the auth gate, on top of {@link accessPermission}.
441
486
  */
442
487
  requireRole?: string;
488
+ /**
489
+ * Namespace of every admin permission: actions are gated behind
490
+ * `<prefix>.<resource>.<action>` (e.g. `station.users.edit`) and entry
491
+ * behind `<prefix>.access`. Defaults to `"station"`, so a grant the
492
+ * application made for its own features (`users.edit`) never opens the
493
+ * admin.
494
+ */
495
+ permissionPrefix?: string;
496
+ /**
497
+ * Permission every request must hold to enter `/admin/*`, checked before
498
+ * the per-action one. Defaults to `<permissionPrefix>.access`. `false`
499
+ * removes this gate (the per-action permissions still apply).
500
+ */
501
+ accessPermission?: string | false;
443
502
  /**
444
503
  * Where to redirect on a failed auth check. Defaults to
445
504
  * `/admin/login`.
@@ -450,9 +509,38 @@ export interface StationConfig {
450
509
  * to avoid colliding with app-level session cookies.
451
510
  */
452
511
  cookieName?: string;
512
+ /**
513
+ * `Secure` flag of the auth cookie. Defaults to on everywhere except a
514
+ * development/test process served over plain HTTP, so an unknown
515
+ * environment behaves like production.
516
+ */
517
+ cookieSecure?: boolean;
518
+ /**
519
+ * Brute-force throttle on `POST /admin/login`: 5 attempts per IP and per
520
+ * identifier every 15 minutes by default, in this process's memory. Pass
521
+ * `limiter` (warden's `AuthRateLimiter` fits) to share counts across
522
+ * instances. `false` turns it off — only for an app that throttles the
523
+ * route itself (see {@link middleware}).
524
+ */
525
+ loginRateLimit?: StationLoginRateLimitConfig | false;
526
+ /** Named router middleware attached to every route Station mounts. */
527
+ middleware?: ReadonlyArray<string>;
528
+ /**
529
+ * Sink for the admin's security events: sign-in success, failure and
530
+ * throttling, logout, authorization denials and CSRF blocks, each with the
531
+ * client IP and request id. Write events keep going to each resource's
532
+ * own `audit`.
533
+ */
534
+ audit?: SecurityAuditSink;
453
535
  }
454
536
 
455
537
  const MAX_PER_PAGE = 100;
538
+ const DEFAULT_PERMISSION_PREFIX = "station";
539
+ /**
540
+ * The auth cookie travels only to the admin: scoped to `/`, the admin bearer
541
+ * token rode along with every request to the rest of the application.
542
+ */
543
+ const AUTH_COOKIE_PATH = "/admin";
456
544
  const DEFAULT_PER_PAGE = 25;
457
545
  const POSITIVE_INT_RE = /^[1-9][0-9]*$/;
458
546
 
@@ -462,6 +550,7 @@ let perPageClampWarned = false;
462
550
  let seedPermsWarned = false;
463
551
  let missingAuditWarned = false;
464
552
  let csrfBlockedWarned = false;
553
+ let revokeUnavailableWarned = false;
465
554
 
466
555
  /** @internal Reset module-level flags between tests. */
467
556
  export function resetStationProviderFlags(): void {
@@ -470,6 +559,7 @@ export function resetStationProviderFlags(): void {
470
559
  seedPermsWarned = false;
471
560
  missingAuditWarned = false;
472
561
  csrfBlockedWarned = false;
562
+ revokeUnavailableWarned = false;
473
563
  }
474
564
 
475
565
  /**
@@ -491,17 +581,6 @@ function notFoundFailFingerprint(cause: unknown): string {
491
581
  */
492
582
  const NOT_FOUND_RENDER_FAIL_CAP = 32;
493
583
 
494
- const TIMESTAMP_PROPERTY_KEYS: ReadonlySet<string> = new Set([
495
- "createdAt",
496
- "updatedAt",
497
- "deletedAt",
498
- ]);
499
- const TIMESTAMP_COLUMN_NAMES: ReadonlySet<string> = new Set([
500
- "created_at",
501
- "updated_at",
502
- "deleted_at",
503
- ]);
504
-
505
584
  /**
506
585
  * Column `type` strings mapped to a numeric rune rule (mirrors form.ts input
507
586
  * inference). `bigint` is deliberately EXCLUDED: coercing it through `Number()`
@@ -537,9 +616,9 @@ export function resourcesNeedValidation(
537
616
  * Derive a `@c9up/rune` validation schema (57.7) from an entity's writable
538
617
  * column metadata — the structural replacement for the hand-rolled
539
618
  * `filterWritableBody`. The mass-assignment guarantee is now INHERENT:
540
- * `schema.validateResult()` returns only declared-column keys, so unknown body keys
541
- * (`role`, `passwordHash`, PK, timestamps) are dropped automatically — no
542
- * explicit filtering pass.
619
+ * `schema.validateResult()` returns only the keys of the editable columns it
620
+ * was given, so any other body key — an unknown one, a column outside the
621
+ * allow-list, the PK, a timestamp — is dropped automatically.
543
622
  *
544
623
  * Excluded, exactly as the old guard did: the primary key, framework-managed
545
624
  * timestamps (both the Lucid-style `autoManaged` flag AND the `created_at /
@@ -560,7 +639,9 @@ export function resourcesNeedValidation(
560
639
  * Returns the built schema plus the set of boolean columns — the handler
561
640
  * default-fills an ABSENT boolean key to `false` before validating (an
562
641
  * unchecked checkbox submits nothing; this preserves the old edit-clears-box
563
- * behaviour).
642
+ * behaviour). `columns` is the editable allow-list; framework-owned columns
643
+ * are dropped here too, so no caller can make them writable. `updateSchema`
644
+ * differs only for `writeOnly` columns, which an edit may leave out.
564
645
  */
565
646
  export function deriveWritableSchema(
566
647
  columns: ReadonlyArray<ColumnMetadata>,
@@ -568,41 +649,65 @@ export function deriveWritableSchema(
568
649
  autoManaged: ReadonlySet<string>,
569
650
  excludedProps: ReadonlySet<string>,
570
651
  rune: RuneModule,
571
- ): { schema: RuneSchema; booleanKeys: ReadonlySet<string> } {
652
+ writeOnly: ReadonlySet<string> = new Set(),
653
+ ): {
654
+ schema: RuneSchema;
655
+ updateSchema: RuneSchema;
656
+ booleanKeys: ReadonlySet<string>;
657
+ } {
572
658
  const fields: Record<string, RuneRuleChain> = {};
659
+ const updateFields: Record<string, RuneRuleChain> = {};
573
660
  const booleanKeys = new Set<string>();
574
661
  for (const c of columns) {
575
- if (c.propertyKey === pkColumn) continue;
576
- if (autoManaged.has(c.propertyKey)) continue;
577
- if (TIMESTAMP_PROPERTY_KEYS.has(c.propertyKey)) continue;
578
- const snake = c.propertyKey.replace(/([A-Z])/g, "_$1").toLowerCase();
579
- if (TIMESTAMP_COLUMN_NAMES.has(snake)) continue;
580
- if (excludedProps.has(c.propertyKey)) continue;
662
+ if (isFrameworkManaged(c, pkColumn, autoManaged, excludedProps)) continue;
581
663
  const type = (c.type ?? "").toString().toLowerCase();
582
664
  if (type === "boolean") {
583
665
  // Checkbox: the handler pre-fills an absent key to `false`, so every
584
666
  // boolean is always present here → required, no `.optional()`.
585
667
  booleanKeys.add(c.propertyKey);
586
668
  fields[c.propertyKey] = rune.rules.boolean().parse(isCheckedValue);
669
+ updateFields[c.propertyKey] = rune.rules.boolean().parse(isCheckedValue);
587
670
  continue;
588
671
  }
589
- // A column is required iff the model declares neither nullability nor a
590
- // default. DB-side defaults (serial, `DEFAULT`) are invisible to metadata
591
- // (documented residual limitation — see story Dev Notes); the PK, the
592
- // common serial case, is already excluded above.
593
- const required = c.nullable !== true && c.default === undefined;
594
- let chain: RuneRuleChain = NUMBER_COLUMN_TYPES.has(type)
595
- ? rune.rules.number().parse(parseNumeric)
596
- : JSON_COLUMN_TYPES.has(type)
597
- ? rune.rules.any()
598
- : rune.rules.string();
599
- if (!required) {
600
- chain = chain.optional();
601
- if (c.nullable === true) chain = chain.nullable();
602
- }
603
- fields[c.propertyKey] = chain;
672
+ fields[c.propertyKey] = columnChain(c, type, rune, false);
673
+ updateFields[c.propertyKey] = columnChain(
674
+ c,
675
+ type,
676
+ rune,
677
+ writeOnly.has(c.propertyKey),
678
+ );
679
+ }
680
+ return {
681
+ schema: rune.schema(fields),
682
+ updateSchema: rune.schema(updateFields),
683
+ booleanKeys,
684
+ };
685
+ }
686
+
687
+ /**
688
+ * The rule for one non-boolean column. A column is required iff the model
689
+ * declares neither nullability nor a default — unless `keepable`: an edit may
690
+ * omit a write-only column to keep its stored value. DB-side defaults
691
+ * (serial, `DEFAULT`) are invisible to metadata (documented residual
692
+ * limitation); the PK, the common serial case, is never editable.
693
+ */
694
+ function columnChain(
695
+ c: ColumnMetadata,
696
+ type: string,
697
+ rune: RuneModule,
698
+ keepable: boolean,
699
+ ): RuneRuleChain {
700
+ const required = !keepable && c.nullable !== true && c.default === undefined;
701
+ let chain: RuneRuleChain = NUMBER_COLUMN_TYPES.has(type)
702
+ ? rune.rules.number().parse(parseNumeric)
703
+ : JSON_COLUMN_TYPES.has(type)
704
+ ? rune.rules.any()
705
+ : rune.rules.string();
706
+ if (!required) {
707
+ chain = chain.optional();
708
+ if (c.nullable === true) chain = chain.nullable();
604
709
  }
605
- return { schema: rune.schema(fields), booleanKeys };
710
+ return chain;
606
711
  }
607
712
 
608
713
  /**
@@ -611,14 +716,19 @@ export function deriveWritableSchema(
611
716
  * preserving the old `filterWritableBody` checkbox semantics, then runs rune's
612
717
  * synchronous, never-throwing `validateResult()`. On success `result.data` holds only
613
718
  * declared-column keys (mass-assignment inherent). Exported for the unit tests
614
- * that pin the coercion CRUX.
719
+ * that pin the coercion CRUX. A blank `writeOnly` value counts as not
720
+ * submitted: the form never pre-fills those, so blank means "unchanged".
615
721
  */
616
722
  export function validateWritableBody(
617
723
  schema: RuneSchema,
618
724
  booleanKeys: ReadonlySet<string>,
619
725
  body: Record<string, unknown>,
726
+ writeOnly: ReadonlySet<string> = new Set(),
620
727
  ): RuneValidationResult {
621
728
  const input: Record<string, unknown> = { ...body };
729
+ for (const key of writeOnly) {
730
+ if (input[key] === "") delete input[key];
731
+ }
622
732
  for (const key of booleanKeys) {
623
733
  if (!(key in input)) input[key] = false;
624
734
  }
@@ -668,9 +778,6 @@ function fieldErrors(
668
778
  /** Session flash key under which the per-field validation errors are stashed (AdonisJS `flashMessages.errors` parity). */
669
779
  const FLASH_ERRORS_KEY = "errors";
670
780
 
671
- /** Column propertyKeys whose values are secrets — never flashed to the session nor echoed back into HTML (AdonisJS parity). */
672
- const SENSITIVE_KEY_RE = /password|passwd|secret|token|apikey/i;
673
-
674
781
  /**
675
782
  * The subset of a submitted body safe to re-surface on a validation error:
676
783
  * declared columns only — so `_csrf`, `_method`, and any mass-assignment /
@@ -684,7 +791,7 @@ function flashableInput(
684
791
  ): Record<string, unknown> {
685
792
  const out: Record<string, unknown> = {};
686
793
  for (const c of columns) {
687
- if (SENSITIVE_KEY_RE.test(c.propertyKey)) continue;
794
+ if (isSecretColumn(c)) continue;
688
795
  if (c.propertyKey in submitted)
689
796
  out[c.propertyKey] = submitted[c.propertyKey];
690
797
  }
@@ -730,44 +837,50 @@ function readFlash(
730
837
  }
731
838
 
732
839
  /**
733
- * Same-origin / relative check for a `Referer` — replicates ream's
734
- * `RedirectBuilder.back()` open-redirect guard (Station consumes the response
735
- * through its own `redirect(url)` seam and can't reach ream's builder). A
736
- * relative path is trusted; an absolute URL only when its origin matches the
737
- * request URL. Anything else is untrusted → the caller uses its safe fallback.
840
+ * The path a `Referer` points back to, when it may be trusted — AdonisJS's
841
+ * `isValidRedirectUrl` + `redirect().back()`: a relative path is trusted, an
842
+ * absolute URL only when its host is the request's own authority (`Host` /
843
+ * `:authority`, never a forwarded host), and the redirect goes to the path
844
+ * alone. Comparing with `request.url()` could never work: in ream that is
845
+ * the path, while a browser's Referer is a full URL. Deliberately stricter
846
+ * than upstream in one place: with no authority to compare against, an
847
+ * absolute Referer is refused rather than accepted.
738
848
  */
739
- function isSameOriginReferer(referer: string, requestUrl?: string): boolean {
849
+ function trustedRefererPath(
850
+ referer: string,
851
+ authority: string | null | undefined,
852
+ ): string | undefined {
740
853
  // A relative path is trusted only when it starts with a single "/" and
741
854
  // contains no backslash: browsers normalise "\\" to "/", so "/\\evil.com"
742
855
  // becomes the protocol-relative "//evil.com" that a lone `!startsWith("//")`
743
856
  // check would let through.
744
- if (
745
- referer.startsWith("/") &&
746
- !referer.startsWith("//") &&
747
- !referer.includes("\\")
748
- ) {
749
- return true;
750
- }
751
- if (requestUrl === undefined) return false;
857
+ if (referer.startsWith("//") || referer.includes("\\")) return undefined;
752
858
  try {
753
- return new URL(referer).origin === new URL(requestUrl).origin;
859
+ if (referer.startsWith("/")) {
860
+ const url = new URL(referer, "http://localhost");
861
+ return url.host === "localhost" ? url.pathname : undefined;
862
+ }
863
+ if (authority === null || authority === undefined || authority === "") {
864
+ return undefined;
865
+ }
866
+ const url = new URL(referer);
867
+ return url.host === authority ? url.pathname : undefined;
754
868
  } catch {
755
- return false;
869
+ return undefined;
756
870
  }
757
871
  }
758
872
 
759
873
  /**
760
- * `redirect().back()` over Station's `redirect(url)` seam (57.7): prefer a
761
- * trusted same-origin `Referer`, else the caller's `fallback` (the form URL) —
762
- * the same behaviour as ream's `RedirectBuilder.back(fallback)`, open-redirect
763
- * safe.
874
+ * `redirect().back()` over Station's `redirect(url)` seam (57.7): the path of a
875
+ * trusted `Referer`, else the caller's `fallback` (the form URL).
764
876
  */
765
877
  function redirectBack(ctx: StationHttpContext, fallback: string): void {
766
878
  const referer = ctx.request.header?.("referer");
879
+ const authority =
880
+ ctx.request.authority?.() ?? ctx.request.header?.("host") ?? undefined;
767
881
  const target =
768
- typeof referer === "string" &&
769
- isSameOriginReferer(referer, ctx.request.url?.())
770
- ? referer
882
+ typeof referer === "string"
883
+ ? (trustedRefererPath(referer, authority) ?? fallback)
771
884
  : fallback;
772
885
  ctx.response.redirect(target);
773
886
  }
@@ -796,9 +909,14 @@ function isCheckedValue(value: unknown): boolean {
796
909
  function snapshotEntity(
797
910
  entity: Record<string, unknown>,
798
911
  columns: ReadonlyArray<ColumnMetadata>,
912
+ redacted: ReadonlySet<string>,
799
913
  ): Record<string, unknown> {
800
914
  const out: Record<string, unknown> = {};
801
- for (const c of columns) out[c.propertyKey] = entity[c.propertyKey];
915
+ for (const c of columns) {
916
+ out[c.propertyKey] = redacted.has(c.propertyKey)
917
+ ? REDACTED
918
+ : entity[c.propertyKey];
919
+ }
802
920
  try {
803
921
  return structuredClone(out);
804
922
  } catch (err) {
@@ -826,6 +944,21 @@ function snapshotEntity(
826
944
  }
827
945
  let auditCloneWarnEmitted = false;
828
946
 
947
+ /** What an audit snapshot holds in place of a secret column's value. */
948
+ const REDACTED = "[redacted]";
949
+
950
+ /**
951
+ * The redacted columns an edit changed — read from the raw values, so the
952
+ * audit trail says "password changed" without carrying either hash.
953
+ */
954
+ function changedRedacted(
955
+ before: Readonly<Record<string, unknown>>,
956
+ entity: Readonly<Record<string, unknown>>,
957
+ redacted: ReadonlySet<string>,
958
+ ): string[] {
959
+ return [...redacted].filter((key) => !Object.is(before[key], entity[key]));
960
+ }
961
+
829
962
  /**
830
963
  * Filesystem path to the `.inker` templates Station ships inside the
831
964
  * package (57.1, D3). Resolved from `import.meta.url` so the SAME path
@@ -851,14 +984,21 @@ export default class StationProvider {
851
984
  // 54.7 auth state — populated when warden is wired AND
852
985
  // StationConfig.requireAuth is true (default when warden is present).
853
986
  #authManager: WardenAuthManager | undefined;
987
+ #loginLimiter: StationLoginLimiter | undefined;
988
+ #cookieSecure: boolean | undefined;
989
+ #securityAudit: SecurityAuditSink | undefined;
854
990
  #authConfig: Required<Pick<StationConfig, "loginPath" | "cookieName">> & {
855
991
  requireAuth: boolean;
856
992
  requireRole: string | undefined;
993
+ permissionPrefix: string;
994
+ accessPermission: string | undefined;
857
995
  } = {
858
996
  requireAuth: false,
859
997
  requireRole: undefined,
860
998
  loginPath: "/admin/login",
861
999
  cookieName: "station_auth",
1000
+ permissionPrefix: DEFAULT_PERMISSION_PREFIX,
1001
+ accessPermission: undefined,
862
1002
  };
863
1003
 
864
1004
  constructor(protected app: StationAppContext) {}
@@ -905,7 +1045,17 @@ export default class StationProvider {
905
1045
  // peer is a degraded-host signal: #loadPeers returns null → silent stop.
906
1046
  const peers = await this.#loadPeers();
907
1047
  if (!peers) return;
908
- const { router, atlas, rune } = peers;
1048
+ const { atlas, rune } = peers;
1049
+ const router = withRouteMiddleware(peers.router, userConfig.middleware);
1050
+ this.#loginLimiter = buildLoginLimiter(userConfig.loginRateLimit);
1051
+ this.#cookieSecure = userConfig.cookieSecure;
1052
+ if (
1053
+ userConfig.audit !== undefined &&
1054
+ typeof userConfig.audit !== "function"
1055
+ ) {
1056
+ throw new TypeError("[station] `station.audit` must be a function");
1057
+ }
1058
+ this.#securityAudit = userConfig.audit;
909
1059
 
910
1060
  // Phase 1a2 — resolve the SHARED inker renderer (AdonisJS/Edge parity)
911
1061
  // and mount Station's package templates as the `station` disk. Unlike
@@ -1181,6 +1331,13 @@ export default class StationProvider {
1181
1331
  * not installed/bound the gate stays open and a one-time warning is emitted.
1182
1332
  */
1183
1333
  async #configureAuth(userConfig: StationConfig): Promise<void> {
1334
+ const permissionPrefix =
1335
+ userConfig.permissionPrefix ?? DEFAULT_PERMISSION_PREFIX;
1336
+ if (typeof permissionPrefix !== "string" || permissionPrefix === "") {
1337
+ throw new Error(
1338
+ "[station] `station.permissionPrefix` must be a non-empty string: admin permissions need their own namespace.",
1339
+ );
1340
+ }
1184
1341
  const wardenWanted = userConfig.requireAuth !== false;
1185
1342
  if (wardenWanted) {
1186
1343
  try {
@@ -1191,6 +1348,11 @@ export default class StationProvider {
1191
1348
  requireRole: userConfig.requireRole,
1192
1349
  loginPath: userConfig.loginPath ?? "/admin/login",
1193
1350
  cookieName: userConfig.cookieName ?? "station_auth",
1351
+ permissionPrefix,
1352
+ accessPermission:
1353
+ userConfig.accessPermission === false
1354
+ ? undefined
1355
+ : (userConfig.accessPermission ?? `${permissionPrefix}.access`),
1194
1356
  };
1195
1357
  } catch (cause) {
1196
1358
  // Container has no working `auth` binding → warden not wired.
@@ -1211,15 +1373,23 @@ export default class StationProvider {
1211
1373
 
1212
1374
  // An admin panel with no auth is full CRUD on every registered resource
1213
1375
  // for anyone who can reach the route. That is a different product, and
1214
- // in production it has to be asked for rather than fallen into — the
1215
- // same bet bay makes about losing a job on a Redis without LMOVE.
1376
+ // it has to be asked for rather than fallen into — the same bet bay
1377
+ // makes about losing a job on a Redis without LMOVE.
1216
1378
  //
1217
1379
  // `requireAuth: false` written down IS the asking. Defaulted (undefined)
1218
- // is not: it means nobody decided, and nobody deciding must not be how
1219
- // an unauthenticated admin reaches production.
1220
- if (inProduction() && userConfig.requireAuth !== false) {
1380
+ // is not: it means nobody decided, and nobody deciding is only tolerated
1381
+ // on a machine positively named development or test. An unset NODE_ENV,
1382
+ // `staging`, `preview`… fail closed like production: they are exactly
1383
+ // the boxes nobody configured, and staging often holds a copy of
1384
+ // production data (ream's `Application.inDev` takes the same stance).
1385
+ const env = normalizeNodeEnv(process.env.NODE_ENV);
1386
+ const devOrTest = env === "development" || env === "test";
1387
+ if (!devOrTest && userConfig.requireAuth !== false) {
1388
+ const where = inProduction()
1389
+ ? "production"
1390
+ : `a non-development environment (NODE_ENV=${process.env.NODE_ENV === undefined ? "unset" : JSON.stringify(process.env.NODE_ENV)})`;
1221
1391
  throw new Error(
1222
- "[station] Admin routes would mount with NO authorisation in production: " +
1392
+ `[station] Admin routes would mount with NO authorisation in ${where}: ` +
1223
1393
  "no working `auth` binding is registered, so every `/admin/*` route — " +
1224
1394
  "including create, update and delete — is open to anyone who can reach it.\n" +
1225
1395
  " Wire @c9up/warden's WardenProvider, or set `station.requireAuth: false` " +
@@ -1232,11 +1402,13 @@ export default class StationProvider {
1232
1402
  // Said even when the deployment opted in: agreeing once in a config file
1233
1403
  // is not the same as being reminded that this process is running that
1234
1404
  // way. The line has to be in the logs of the incident.
1235
- const optedIn = inProduction() && userConfig.requireAuth === false;
1405
+ const optedIn = !devOrTest && userConfig.requireAuth === false;
1406
+ const envName =
1407
+ env === "unknown" ? "AN UNCONFIGURED ENVIRONMENT" : env.toUpperCase();
1236
1408
  console.warn(
1237
- "[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." +
1409
+ "[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." +
1238
1410
  (optedIn
1239
- ? "\n Running this way in PRODUCTION because `station.requireAuth: false` was set."
1411
+ ? `\n Running this way in ${envName} because \`station.requireAuth: false\` was set.`
1240
1412
  : ""),
1241
1413
  );
1242
1414
  }
@@ -1315,12 +1487,6 @@ export default class StationProvider {
1315
1487
  `/admin/${slug}/:id`,
1316
1488
  gate(this.#buildUpdateHandler(resource)),
1317
1489
  );
1318
- // Browser forms can't issue PUT — accept POST with `_method=PUT`
1319
- // from the auto-generated form (form.ts stamps the hidden input).
1320
- router.post(
1321
- `/admin/${slug}/:id`,
1322
- gate(this.#buildMethodOverrideHandler(resource)),
1323
- );
1324
1490
  }
1325
1491
  if (resource.actions.includes("destroy")) {
1326
1492
  router.delete(
@@ -1328,12 +1494,24 @@ export default class StationProvider {
1328
1494
  gate(this.#buildDestroyHandler(resource)),
1329
1495
  );
1330
1496
  }
1497
+ // Browser forms can't issue PUT/DELETE — accept POST with `_method`
1498
+ // from the auto-generated form (form.ts stamps the hidden input),
1499
+ // dispatching only to the actions this resource enables.
1500
+ if (
1501
+ resource.actions.includes("edit") ||
1502
+ resource.actions.includes("destroy")
1503
+ ) {
1504
+ router.post(
1505
+ `/admin/${slug}/:id`,
1506
+ gate(this.#buildMethodOverrideHandler(resource)),
1507
+ );
1508
+ }
1331
1509
  }
1332
1510
  }
1333
1511
 
1334
1512
  /**
1335
1513
  * 56.5 — every admin action is now gated behind a
1336
- * `<resource>.<action>` permission resolved through @c9up/warden
1514
+ * `<prefix>.<resource>.<action>` permission resolved through @c9up/warden
1337
1515
  * (`auth.hasPermission`). Warn ONCE at boot, only when the auth layer
1338
1516
  * is wired, that the host must seed roles/grants in the Warden rights
1339
1517
  * store — otherwise every admin request 403s with no hint. The old
@@ -1348,8 +1526,15 @@ export default class StationProvider {
1348
1526
  seedPermsWarned = true;
1349
1527
  const [example] = resources;
1350
1528
  if (example === undefined) return;
1529
+ const prefix = this.#authConfig.permissionPrefix;
1530
+ const access = this.#authConfig.accessPermission;
1531
+ const perms = [
1532
+ ...(access === undefined ? [] : [access]),
1533
+ `${prefix}.${example.name}.list`,
1534
+ `${prefix}.${example.name}.create`,
1535
+ ];
1351
1536
  console.warn(
1352
- `[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.`,
1537
+ `[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.`,
1353
1538
  );
1354
1539
  }
1355
1540
 
@@ -1398,8 +1583,13 @@ export default class StationProvider {
1398
1583
  * `@c9up/rune` — so an undefined here is an internal invariant break; throw
1399
1584
  * loud (fail-closed) rather than silently accept an unvalidated body.
1400
1585
  */
1401
- #requireWritableSchema(resource: Resource): RuneSchema {
1402
- const { writableSchema } = this.#requireContext(resource);
1586
+ #requireWritableSchema(
1587
+ resource: Resource,
1588
+ mode: "create" | "update",
1589
+ ): RuneSchema {
1590
+ const context = this.#requireContext(resource);
1591
+ const writableSchema =
1592
+ mode === "create" ? context.writableSchema : context.updateSchema;
1403
1593
  if (writableSchema === undefined) {
1404
1594
  throw new Error(
1405
1595
  `[station] No validation schema for ${resource.entity.name} — @c9up/rune must be installed to accept admin writes.`,
@@ -1449,8 +1639,8 @@ export default class StationProvider {
1449
1639
  // Echo only declared, non-sensitive columns back to the form — never
1450
1640
  // `_csrf`, secrets, or mass-assignment keys (both the flash store and the
1451
1641
  // re-rendered HTML). An empty result falls back to `row`.
1452
- const { columns, pkColumn } = this.#requireContext(resource);
1453
- const safe = flashableInput(submitted, columns);
1642
+ const { formColumns, pkColumn, writeOnly } = this.#requireContext(resource);
1643
+ const safe = flashableInput(submitted, formColumns);
1454
1644
  if (ctx.session !== undefined) {
1455
1645
  // AdonisJS PRG: flash old input + errors, redirect back to the form.
1456
1646
  ctx.session.flashAll(safe);
@@ -1462,8 +1652,9 @@ export default class StationProvider {
1462
1652
  // submitted values + errors are still shown rather than silently dropped.
1463
1653
  const vm = buildFormViewModel({
1464
1654
  resource,
1465
- columns,
1655
+ columns: formColumns,
1466
1656
  pkColumn,
1657
+ writeOnly,
1467
1658
  row,
1468
1659
  values: Object.keys(safe).length > 0 ? safe : undefined,
1469
1660
  errors: fieldErrors(errors),
@@ -1479,15 +1670,12 @@ export default class StationProvider {
1479
1670
  resource: Resource,
1480
1671
  ): (ctx: StationHttpContext) => Promise<void> {
1481
1672
  return async (ctx) => {
1482
- const { repo, columns, pkColumn } = this.#requireContext(resource);
1673
+ const { repo, listColumns, pkColumn } = this.#requireContext(resource);
1483
1674
  // 56.5: the list action is gated behind `<resource>.list` like
1484
1675
  // every other action — resolved through the Warden `"auth"`
1485
1676
  // layer. (Retro 2026-06-01: list previously skipped the gate
1486
1677
  // entirely, leaking the index regardless of authorization.)
1487
- if (!(await authorizeAction(resource, "list", ctx, this.#authManager))) {
1488
- deny(ctx);
1489
- return;
1490
- }
1678
+ if (!(await this.#authorize(ctx, resource, "list"))) return;
1491
1679
  const qs = ctx.request.qs();
1492
1680
  const page = clampPositiveInt(qs.page, 1);
1493
1681
  const perPageRaw = clampPositiveInt(qs.perPage, DEFAULT_PER_PAGE);
@@ -1505,6 +1693,20 @@ export default class StationProvider {
1505
1693
  // Station keeps only the `MAX_PER_PAGE` clamp (above) and the
1506
1694
  // redirect-to-last-page wrapper — atlas floors perPage to ≥1 but
1507
1695
  // does not cap it.
1696
+ // Past this page `(page-1)*perPage` is no longer an exact integer:
1697
+ // atlas / the driver reject that offset, which surfaced as a 500.
1698
+ // No table that size exists, so such a page is past the end — count
1699
+ // through page 1 and send the caller to the real last page.
1700
+ if (page > Math.floor(Number.MAX_SAFE_INTEGER / perPage) + 1) {
1701
+ const first = await repo
1702
+ .query()
1703
+ .orderBy(pkColumn, "desc")
1704
+ .paginate(1, perPage);
1705
+ ctx.response.redirect(
1706
+ `/admin/${resource.name}?page=${Math.max(1, first.meta.lastPage)}&perPage=${perPage}`,
1707
+ );
1708
+ return;
1709
+ }
1508
1710
  const result = await repo
1509
1711
  .query()
1510
1712
  .orderBy(pkColumn, "desc")
@@ -1521,7 +1723,7 @@ export default class StationProvider {
1521
1723
  const viewModel = buildListViewModel({
1522
1724
  resource,
1523
1725
  rows: result.all(),
1524
- columns,
1726
+ columns: listColumns,
1525
1727
  pkColumn,
1526
1728
  page: result.meta.currentPage,
1527
1729
  perPage: result.meta.perPage,
@@ -1538,14 +1740,11 @@ export default class StationProvider {
1538
1740
  resource: Resource,
1539
1741
  ): (ctx: StationHttpContext) => Promise<void> {
1540
1742
  return async (ctx) => {
1541
- const { repo, columns, pkColumn } = this.#requireContext(resource);
1743
+ const { repo, showColumns, pkColumn } = this.#requireContext(resource);
1542
1744
  // Authorize BEFORE the existence check so an unauthorized caller
1543
1745
  // can't distinguish 404 (absent) from 403 (exists) — no row-existence
1544
1746
  // oracle for a user without `<resource>.show`.
1545
- if (!(await authorizeAction(resource, "show", ctx, this.#authManager))) {
1546
- deny(ctx);
1547
- return;
1548
- }
1747
+ if (!(await this.#authorize(ctx, resource, "show"))) return;
1549
1748
  const id = ctx.params.id ?? "";
1550
1749
  const row = await repo.find(id);
1551
1750
  if (row === null) {
@@ -1558,7 +1757,7 @@ export default class StationProvider {
1558
1757
  const viewModel = buildShowViewModel({
1559
1758
  resource,
1560
1759
  row,
1561
- columns,
1760
+ columns: showColumns,
1562
1761
  pkColumn,
1563
1762
  });
1564
1763
  const html = await this.#renderView(ctx, "station::show", viewModel);
@@ -1571,20 +1770,17 @@ export default class StationProvider {
1571
1770
  resource: Resource,
1572
1771
  ): (ctx: StationHttpContext) => Promise<void> {
1573
1772
  return async (ctx) => {
1574
- const { columns, pkColumn } = this.#requireContext(resource);
1575
- if (
1576
- !(await authorizeAction(resource, "create", ctx, this.#authManager))
1577
- ) {
1578
- deny(ctx);
1579
- return;
1580
- }
1773
+ const { formColumns, pkColumn, writeOnly } =
1774
+ this.#requireContext(resource);
1775
+ if (!(await this.#authorize(ctx, resource, "create"))) return;
1581
1776
  // 57.7 — after a failed create redirected back (PRG), the flash carries
1582
1777
  // the old input + per-field errors; re-populate + surface them.
1583
- const { values, errors } = readFlash(ctx, columns);
1778
+ const { values, errors } = readFlash(ctx, formColumns);
1584
1779
  const vm = buildFormViewModel({
1585
1780
  resource,
1586
- columns,
1781
+ columns: formColumns,
1587
1782
  pkColumn,
1783
+ writeOnly,
1588
1784
  values,
1589
1785
  errors,
1590
1786
  csrfEnabled: ctx.store?.get("csrfToken") != null,
@@ -1604,27 +1800,24 @@ export default class StationProvider {
1604
1800
  // DB work — no 404-vs-403 oracle to a forged request. A seeded token is
1605
1801
  // not proof; only `csrfProtected === true` is (AC4/AC7).
1606
1802
  if (ctx.request.csrfProtected !== true) {
1607
- denyCsrf(ctx);
1803
+ await this.#denyCsrf(ctx);
1608
1804
  return;
1609
1805
  }
1610
- const { repo, pkColumn, columns, booleanKeys } =
1806
+ const { repo, pkColumn, columns, booleanKeys, writeOnly, redacted } =
1611
1807
  this.#requireContext(resource);
1612
- if (
1613
- !(await authorizeAction(resource, "create", ctx, this.#authManager))
1614
- ) {
1615
- deny(ctx);
1616
- return;
1617
- }
1808
+ if (!(await this.#authorize(ctx, resource, "create"))) return;
1618
1809
  const body = await readBody(ctx);
1619
1810
  // 57.7 — validate + mass-assignment guard in one pass. `result.data`
1620
- // holds ONLY declared-column keys, so an attacker's `{ role: "admin" }`
1621
- // / `{ passwordHash: "x" }` / PK / timestamps are never present (dropped
1622
- // by the schema, not a separate filter), and each field is type-checked.
1811
+ // holds ONLY editable-column keys (the allow-list: `editable`, or every
1812
+ // non-secret, non-hidden, non-framework column), so a forged `isAdmin`
1813
+ // on a hidden column, a secret column, the PK or a timestamp is dropped
1814
+ // by the schema, and each field is type-checked.
1623
1815
  // Invalid input fail-closes with a 422 BEFORE any repository write (AC4).
1624
1816
  const result = validateWritableBody(
1625
- this.#requireWritableSchema(resource),
1817
+ this.#requireWritableSchema(resource, "create"),
1626
1818
  booleanKeys,
1627
1819
  body,
1820
+ writeOnly,
1628
1821
  );
1629
1822
  if (!result.valid) {
1630
1823
  // PRG fallback URL = the create form (AdonisJS `redirect().back()`).
@@ -1638,7 +1831,8 @@ export default class StationProvider {
1638
1831
  resource: resource.name,
1639
1832
  recordId: created[pkColumn],
1640
1833
  userId: ctx.auth?.user?.id,
1641
- after: snapshotEntity(created, columns),
1834
+ ...requestOrigin(ctx),
1835
+ after: snapshotEntity(created, columns, redacted),
1642
1836
  at: new Date(),
1643
1837
  });
1644
1838
  redirectToShow(ctx, resource, created[pkColumn]);
@@ -1649,12 +1843,10 @@ export default class StationProvider {
1649
1843
  resource: Resource,
1650
1844
  ): (ctx: StationHttpContext) => Promise<void> {
1651
1845
  return async (ctx) => {
1652
- const { repo, columns, pkColumn } = this.#requireContext(resource);
1846
+ const { repo, formColumns, pkColumn, writeOnly } =
1847
+ this.#requireContext(resource);
1653
1848
  // Authorize before the existence check (no 404-vs-403 oracle).
1654
- if (!(await authorizeAction(resource, "edit", ctx, this.#authManager))) {
1655
- deny(ctx);
1656
- return;
1657
- }
1849
+ if (!(await this.#authorize(ctx, resource, "edit"))) return;
1658
1850
  const id = ctx.params.id ?? "";
1659
1851
  const row = await repo.find(id);
1660
1852
  if (row === null) {
@@ -1667,11 +1859,12 @@ export default class StationProvider {
1667
1859
  // 57.7 — after a failed update redirected back (PRG), the flash carries
1668
1860
  // the submitted values + errors; they win over the DB `row` so the user
1669
1861
  // sees what they typed. A normal edit GET has no flash → fields from row.
1670
- const { values, errors } = readFlash(ctx, columns);
1862
+ const { values, errors } = readFlash(ctx, formColumns);
1671
1863
  const vm = buildFormViewModel({
1672
1864
  resource,
1673
- columns,
1865
+ columns: formColumns,
1674
1866
  pkColumn,
1867
+ writeOnly,
1675
1868
  row,
1676
1869
  values,
1677
1870
  errors,
@@ -1689,16 +1882,13 @@ export default class StationProvider {
1689
1882
  return async (ctx) => {
1690
1883
  // Fail-close on CSRF before any auth/DB work (see #buildCreateHandler).
1691
1884
  if (ctx.request.csrfProtected !== true) {
1692
- denyCsrf(ctx);
1885
+ await this.#denyCsrf(ctx);
1693
1886
  return;
1694
1887
  }
1695
- const { repo, pkColumn, columns, booleanKeys } =
1888
+ const { repo, pkColumn, columns, booleanKeys, writeOnly, redacted } =
1696
1889
  this.#requireContext(resource);
1697
1890
  // Authorize before the existence check (no 404-vs-403 oracle).
1698
- if (!(await authorizeAction(resource, "edit", ctx, this.#authManager))) {
1699
- deny(ctx);
1700
- return;
1701
- }
1891
+ if (!(await this.#authorize(ctx, resource, "edit"))) return;
1702
1892
  const id = ctx.params.id ?? "";
1703
1893
  const entity = await repo.find(id);
1704
1894
  if (entity === null) {
@@ -1715,9 +1905,10 @@ export default class StationProvider {
1715
1905
  // 422 with the submitted values + per-field errors (the existing entity
1716
1906
  // still supplies the id/action).
1717
1907
  const result = validateWritableBody(
1718
- this.#requireWritableSchema(resource),
1908
+ this.#requireWritableSchema(resource, "update"),
1719
1909
  booleanKeys,
1720
1910
  body,
1911
+ writeOnly,
1721
1912
  );
1722
1913
  if (!result.valid) {
1723
1914
  // PRG fallback URL = this row's edit form (AdonisJS `redirect().back()`).
@@ -1737,19 +1928,23 @@ export default class StationProvider {
1737
1928
  // would shadow the original values after setProp). `result.data`
1738
1929
  // holds only declared, type-checked columns — the mass-assignment
1739
1930
  // guarantee (PK / timestamps / unknown keys dropped) is inherent.
1740
- const beforeSnapshot = snapshotEntity(entity, columns);
1931
+ const beforeSnapshot = snapshotEntity(entity, columns, redacted);
1932
+ const rawSecrets: Record<string, unknown> = {};
1933
+ for (const key of redacted) rawSecrets[key] = entity[key];
1741
1934
  for (const [key, value] of Object.entries(result.data)) {
1742
1935
  entity.setProp(key, value);
1743
1936
  }
1744
1937
  await repo.save(entity);
1745
- const afterSnapshot = snapshotEntity(entity, columns);
1938
+ const afterSnapshot = snapshotEntity(entity, columns, redacted);
1746
1939
  await emitAudit(resource, {
1747
1940
  action: "edit",
1748
1941
  resource: resource.name,
1749
1942
  recordId: entity[pkColumn],
1750
1943
  userId: ctx.auth?.user?.id,
1944
+ ...requestOrigin(ctx),
1751
1945
  before: beforeSnapshot,
1752
1946
  after: afterSnapshot,
1947
+ redactedChanges: changedRedacted(rawSecrets, entity, redacted),
1753
1948
  at: new Date(),
1754
1949
  });
1755
1950
  redirectToShow(ctx, resource, entity[pkColumn]);
@@ -1762,33 +1957,45 @@ export default class StationProvider {
1762
1957
  // Browser forms can only emit GET / POST. The auto-generated edit
1763
1958
  // form ships `<input type="hidden" name="_method" value="PUT">`
1764
1959
  // so the POST /admin/:r/:id endpoint can route to the update
1765
- // handler. A POST without `_method=PUT` (or with `_method=DELETE`)
1766
- // dispatches accordingly.
1767
- const updateHandler = this.#buildUpdateHandler(resource);
1768
- const destroyHandler = this.#buildDestroyHandler(resource);
1960
+ // handler. Only the enabled actions get a handler: an override naming a
1961
+ // disabled one is refused like any unsupported method, so `actions`
1962
+ // stays the boundary of what the admin can do to a resource.
1963
+ const updateHandler = resource.actions.includes("edit")
1964
+ ? this.#buildUpdateHandler(resource)
1965
+ : undefined;
1966
+ const destroyHandler = resource.actions.includes("destroy")
1967
+ ? this.#buildDestroyHandler(resource)
1968
+ : undefined;
1969
+ const allowed = [
1970
+ ...(updateHandler === undefined ? [] : ["PUT", "PATCH"]),
1971
+ ...(destroyHandler === undefined ? [] : ["DELETE"]),
1972
+ ];
1769
1973
  return async (ctx) => {
1770
1974
  // Fail-close on CSRF before reading the body (see #buildCreateHandler).
1771
1975
  // The PUT/DELETE branches delegate to already-guarded handlers, but the
1772
1976
  // unsupported-override 405 branch would otherwise do work on a forged
1773
1977
  // request and leak a 403-vs-405 oracle — guard the entrypoint too.
1774
1978
  if (ctx.request.csrfProtected !== true) {
1775
- denyCsrf(ctx);
1979
+ await this.#denyCsrf(ctx);
1776
1980
  return;
1777
1981
  }
1778
1982
  const body = await readBody(ctx);
1779
1983
  const override = String(body._method ?? "").toUpperCase();
1780
- if (override === "PUT" || override === "PATCH") {
1984
+ if (
1985
+ updateHandler !== undefined &&
1986
+ (override === "PUT" || override === "PATCH")
1987
+ ) {
1781
1988
  return updateHandler(ctx);
1782
1989
  }
1783
- if (override === "DELETE") {
1990
+ if (destroyHandler !== undefined && override === "DELETE") {
1784
1991
  return destroyHandler(ctx);
1785
1992
  }
1786
- // Unsupported override — refuse rather than silently downgrade
1787
- // to a no-op so misconfigured forms surface immediately.
1993
+ // Unsupported or disabled override — refuse rather than silently
1994
+ // downgrade to a no-op so misconfigured forms surface immediately.
1788
1995
  ctx.response.status(405);
1789
1996
  ctx.response.type("text/html; charset=utf-8");
1790
1997
  ctx.response.send(
1791
- `<h1>405 Method Not Allowed</h1><p>POST /admin/${escapeMin(resource.name)}/:id requires <code>_method=PUT</code> or <code>_method=DELETE</code>.</p>`,
1998
+ `<h1>405 Method Not Allowed</h1><p>POST /admin/${escapeMin(resource.name)}/:id accepts <code>_method</code> = ${escapeMin(allowed.join(" | "))}.</p>`,
1792
1999
  );
1793
2000
  };
1794
2001
  }
@@ -1799,17 +2006,13 @@ export default class StationProvider {
1799
2006
  return async (ctx) => {
1800
2007
  // Fail-close on CSRF before any auth/DB work (see #buildCreateHandler).
1801
2008
  if (ctx.request.csrfProtected !== true) {
1802
- denyCsrf(ctx);
2009
+ await this.#denyCsrf(ctx);
1803
2010
  return;
1804
2011
  }
1805
- const { repo, pkColumn, columns } = this.#requireContext(resource);
2012
+ const { repo, pkColumn, columns, redacted } =
2013
+ this.#requireContext(resource);
1806
2014
  // Authorize before the existence check (no 404-vs-403 oracle).
1807
- if (
1808
- !(await authorizeAction(resource, "destroy", ctx, this.#authManager))
1809
- ) {
1810
- deny(ctx);
1811
- return;
1812
- }
2015
+ if (!(await this.#authorize(ctx, resource, "destroy"))) return;
1813
2016
  const id = ctx.params.id ?? "";
1814
2017
  const row = await repo.find(id);
1815
2018
  if (row === null) {
@@ -1819,13 +2022,14 @@ export default class StationProvider {
1819
2022
  ctx.response.send(notFoundHtml);
1820
2023
  return;
1821
2024
  }
1822
- const before = snapshotEntity(row, columns);
2025
+ const before = snapshotEntity(row, columns, redacted);
1823
2026
  await repo.delete(row);
1824
2027
  await emitAudit(resource, {
1825
2028
  action: "destroy",
1826
2029
  resource: resource.name,
1827
2030
  recordId: row[pkColumn],
1828
2031
  userId: ctx.auth?.user?.id,
2032
+ ...requestOrigin(ctx),
1829
2033
  before,
1830
2034
  at: new Date(),
1831
2035
  });
@@ -1938,7 +2142,7 @@ export default class StationProvider {
1938
2142
  // Clear the stale cookie regardless of response shape, so neither
1939
2143
  // a browser refresh nor an XHR caller retries with the dead token.
1940
2144
  ctx.response.clearCookie?.(this.#authConfig.cookieName, {
1941
- path: "/",
2145
+ path: AUTH_COOKIE_PATH,
1942
2146
  });
1943
2147
  if (this.#wantsJsonResponse(ctx)) {
1944
2148
  ctx.response.status(401);
@@ -1975,6 +2179,11 @@ export default class StationProvider {
1975
2179
  roleOk = false;
1976
2180
  }
1977
2181
  if (!roleOk) {
2182
+ await this.#securityEvent(ctx, {
2183
+ action: "denied",
2184
+ userId: user.id,
2185
+ permission: `role:${required}`,
2186
+ });
1978
2187
  if (this.#wantsJsonResponse(ctx)) {
1979
2188
  ctx.response.status(403);
1980
2189
  ctx.response.json({ error: "insufficient role" });
@@ -1986,6 +2195,31 @@ export default class StationProvider {
1986
2195
  return;
1987
2196
  }
1988
2197
  }
2198
+ // Entering the admin is its own grant (bug 214): without it, any app
2199
+ // user holding a same-named permission for the public app got in.
2200
+ const access = this.#authConfig.accessPermission;
2201
+ if (access !== undefined) {
2202
+ let accessOk: boolean;
2203
+ try {
2204
+ accessOk =
2205
+ (await manager.hasPermission(user, access, "global")) === true;
2206
+ } catch (err) {
2207
+ const detail = err instanceof Error ? err.message : String(err);
2208
+ console.error(
2209
+ `[station] access check threw for '${access}' — denying (fail-closed): ${detail}`,
2210
+ );
2211
+ accessOk = false;
2212
+ }
2213
+ if (!accessOk) {
2214
+ await this.#securityEvent(ctx, {
2215
+ action: "denied",
2216
+ userId: user.id,
2217
+ permission: access,
2218
+ });
2219
+ deny(ctx);
2220
+ return;
2221
+ }
2222
+ }
1989
2223
  const existingAuth = ctx.auth ?? {};
1990
2224
  ctx.auth = {
1991
2225
  ...existingAuth,
@@ -2014,11 +2248,11 @@ export default class StationProvider {
2014
2248
  }
2015
2249
  }
2016
2250
  }
2017
- const qs = ctx.request.qs();
2018
- const errorParam = qs.error;
2251
+ // No `?error=` read: Station never redirects with one (a failed POST
2252
+ // re-renders inline), so its only use was a third party writing
2253
+ // arbitrary text into the sign-in page's alert.
2019
2254
  const vm = buildLoginViewModel({
2020
2255
  action: this.#authConfig.loginPath,
2021
- error: typeof errorParam === "string" ? errorParam : undefined,
2022
2256
  csrfEnabled: ctx.store?.get("csrfToken") != null,
2023
2257
  });
2024
2258
  const html = await this.#renderView(ctx, "station::login", vm);
@@ -2039,7 +2273,7 @@ export default class StationProvider {
2039
2273
  // AuthManager work (see #buildCreateHandler). Login is open by design
2040
2274
  // (no auth gate), so CSRF is the sole pre-check here.
2041
2275
  if (ctx.request.csrfProtected !== true) {
2042
- denyCsrf(ctx);
2276
+ await this.#denyCsrf(ctx);
2043
2277
  return;
2044
2278
  }
2045
2279
  const manager = this.#authManager;
@@ -2065,6 +2299,10 @@ export default class StationProvider {
2065
2299
  ctx.response.send(html);
2066
2300
  return;
2067
2301
  }
2302
+ // Counted BEFORE the credential check (warden's AuthRateLimiter
2303
+ // contract): a refused attempt never reaches authenticate.
2304
+ const keys = loginKeys(ctx.request.ip?.() ?? "unknown", email);
2305
+ if (!(await this.#admitLogin(ctx, keys, email))) return;
2068
2306
  const result = await manager.authenticate({ email, password });
2069
2307
  // Warden returns the issued token on `user.token`, not at the
2070
2308
  // top level — reading `result.token` (which never exists) sent
@@ -2072,6 +2310,10 @@ export default class StationProvider {
2072
2310
  const token =
2073
2311
  typeof result.user?.token === "string" ? result.user.token : undefined;
2074
2312
  if (!result.authenticated || token === undefined) {
2313
+ await this.#securityEvent(ctx, {
2314
+ action: "login_failed",
2315
+ identifier: email,
2316
+ });
2075
2317
  const vm = buildLoginViewModel({
2076
2318
  action: this.#authConfig.loginPath,
2077
2319
  email,
@@ -2084,16 +2326,158 @@ export default class StationProvider {
2084
2326
  ctx.response.send(html);
2085
2327
  return;
2086
2328
  }
2329
+ await this.#loginLimiter?.clear(...keys);
2330
+ await this.#securityEvent(ctx, {
2331
+ action: "login",
2332
+ userId: result.user?.id,
2333
+ identifier: email,
2334
+ });
2335
+ // No maxAge: a session cookie, gone with the browser; the token's own
2336
+ // expiry is enforced by verify() on every request.
2087
2337
  ctx.response.cookie?.(this.#authConfig.cookieName, token, {
2088
2338
  httpOnly: true,
2089
2339
  sameSite: "Lax",
2090
- secure: process.env.NODE_ENV === "production",
2091
- path: "/",
2340
+ secure: this.#cookieSecure ?? secureByDefault(ctx),
2341
+ path: AUTH_COOKIE_PATH,
2092
2342
  });
2093
2343
  ctx.response.redirect("/admin");
2094
2344
  };
2095
2345
  }
2096
2346
 
2347
+ /**
2348
+ * The per-action gate: allow, or record the refusal and answer 403.
2349
+ */
2350
+ async #authorize(
2351
+ ctx: StationHttpContext,
2352
+ resource: Resource,
2353
+ action: ResourceAction,
2354
+ ): Promise<boolean> {
2355
+ const prefix = this.#authConfig.permissionPrefix;
2356
+ if (
2357
+ await authorizeAction(resource, action, ctx, this.#authManager, prefix)
2358
+ ) {
2359
+ return true;
2360
+ }
2361
+ await this.#securityEvent(ctx, {
2362
+ action: "denied",
2363
+ userId: ctx.auth?.user?.id,
2364
+ resource: resource.name,
2365
+ resourceAction: action,
2366
+ permission: `${prefix}.${resource.name}.${action}`,
2367
+ });
2368
+ deny(ctx);
2369
+ return false;
2370
+ }
2371
+
2372
+ /** Record a CSRF block, then refuse it. */
2373
+ async #denyCsrf(ctx: StationHttpContext): Promise<void> {
2374
+ await this.#securityEvent(ctx, { action: "csrf_denied" });
2375
+ denyCsrf(ctx);
2376
+ }
2377
+
2378
+ /**
2379
+ * Send a security event to `station.audit`, stamped with the request
2380
+ * origin. A failing sink is logged, never allowed to change the response.
2381
+ */
2382
+ async #securityEvent(
2383
+ ctx: StationHttpContext,
2384
+ event: Omit<
2385
+ SecurityAuditEvent,
2386
+ "at" | "ip" | "userAgent" | "requestId" | "path"
2387
+ >,
2388
+ ): Promise<void> {
2389
+ const sink = this.#securityAudit;
2390
+ if (sink === undefined) return;
2391
+ try {
2392
+ await sink({
2393
+ ...event,
2394
+ ...requestOrigin(ctx),
2395
+ path: ctx.request.url?.(),
2396
+ at: new Date(),
2397
+ });
2398
+ } catch (err) {
2399
+ const detail = err instanceof Error ? err.message : String(err);
2400
+ console.error(
2401
+ `[station] security audit sink threw on ${event.action}: ${detail}. The event was NOT recorded.`,
2402
+ );
2403
+ }
2404
+ }
2405
+
2406
+ /**
2407
+ * Revoke `token` through the default guard when it supports revocation.
2408
+ * A guard without it (or a JwtStrategy without a blacklist, whose
2409
+ * `revoke` throws) is reported once: logout then only clears the cookie.
2410
+ */
2411
+ async #revoke(token: string): Promise<void> {
2412
+ const strategy = this.#authManager?.getStrategy?.();
2413
+ let reason = "the auth guard has no revoke(token)";
2414
+ if (
2415
+ typeof strategy === "object" &&
2416
+ strategy !== null &&
2417
+ "revoke" in strategy &&
2418
+ typeof strategy.revoke === "function"
2419
+ ) {
2420
+ try {
2421
+ await strategy.revoke(token);
2422
+ return;
2423
+ } catch (err) {
2424
+ reason = err instanceof Error ? err.message : String(err);
2425
+ }
2426
+ }
2427
+ if (revokeUnavailableWarned) return;
2428
+ revokeUnavailableWarned = true;
2429
+ console.warn(
2430
+ `[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.`,
2431
+ );
2432
+ }
2433
+
2434
+ /**
2435
+ * Count a login attempt and answer the refusal when the limiter says no:
2436
+ * 429 + Retry-After when exhausted, 503 when the limiter itself failed (a
2437
+ * throttle that cannot count must not wave attempts through).
2438
+ */
2439
+ async #admitLogin(
2440
+ ctx: StationHttpContext,
2441
+ keys: string[],
2442
+ email: string,
2443
+ ): Promise<boolean> {
2444
+ const limiter = this.#loginLimiter;
2445
+ if (limiter === undefined) return true;
2446
+ let decision: { allowed: boolean; retryAfterSeconds: number };
2447
+ try {
2448
+ decision = await limiter.attempt(...keys);
2449
+ } catch (err) {
2450
+ const detail = err instanceof Error ? err.message : String(err);
2451
+ console.error(
2452
+ `[station] login rate limiter failed — refusing the attempt (fail-closed): ${detail}`,
2453
+ );
2454
+ ctx.response.status(503);
2455
+ ctx.response.type("text/plain; charset=utf-8");
2456
+ ctx.response.send("Sign-in is temporarily unavailable.");
2457
+ return false;
2458
+ }
2459
+ if (decision.allowed) return true;
2460
+ await this.#securityEvent(ctx, {
2461
+ action: "login_throttled",
2462
+ identifier: email,
2463
+ });
2464
+ ctx.response.status(429);
2465
+ ctx.response.header(
2466
+ "Retry-After",
2467
+ String(Math.max(1, Math.ceil(decision.retryAfterSeconds))),
2468
+ );
2469
+ const vm = buildLoginViewModel({
2470
+ action: this.#authConfig.loginPath,
2471
+ email,
2472
+ error: "Too many sign-in attempts. Try again later.",
2473
+ csrfEnabled: ctx.store?.get("csrfToken") != null,
2474
+ });
2475
+ const html = await this.#renderView(ctx, "station::login", vm);
2476
+ ctx.response.type("text/html; charset=utf-8");
2477
+ ctx.response.send(html);
2478
+ return false;
2479
+ }
2480
+
2097
2481
  /**
2098
2482
  * `POST /admin/logout` — clear the session cookie and redirect to
2099
2483
  * the login page. POST (not GET) so a crafted `<img src>` can't log
@@ -2104,17 +2488,97 @@ export default class StationProvider {
2104
2488
  // Fail-close on CSRF: a forged cross-site POST must not be able to log
2105
2489
  // an admin out (session-fixation nuisance) (see #buildCreateHandler).
2106
2490
  if (ctx.request.csrfProtected !== true) {
2107
- denyCsrf(ctx);
2491
+ await this.#denyCsrf(ctx);
2108
2492
  return;
2109
2493
  }
2494
+ // Clearing the cookie alone left the token valid until it expired:
2495
+ // a copy taken before logout (shared machine, proxy log) still
2496
+ // opened the admin.
2497
+ const token = this.#readAuthToken(ctx);
2498
+ if (token !== undefined) {
2499
+ // Who is leaving, read before the token is revoked — only when
2500
+ // someone is listening, since it costs a verify().
2501
+ const userId =
2502
+ this.#securityAudit === undefined
2503
+ ? undefined
2504
+ : (await this.#authManager?.verify(token))?.user?.id;
2505
+ await this.#revoke(token);
2506
+ await this.#securityEvent(ctx, { action: "logout", userId });
2507
+ } else {
2508
+ await this.#securityEvent(ctx, { action: "logout" });
2509
+ }
2110
2510
  ctx.response.clearCookie?.(this.#authConfig.cookieName, {
2111
- path: "/",
2511
+ path: AUTH_COOKIE_PATH,
2112
2512
  });
2113
2513
  ctx.response.redirect(this.#authConfig.loginPath);
2114
2514
  };
2115
2515
  }
2116
2516
  }
2117
2517
 
2518
+ /** Who sent the request, as far as the host can tell. */
2519
+ function requestOrigin(ctx: StationHttpContext): {
2520
+ ip?: string;
2521
+ userAgent?: string;
2522
+ requestId?: string;
2523
+ } {
2524
+ return {
2525
+ ip: ctx.request.ip?.(),
2526
+ userAgent: ctx.request.header?.("user-agent"),
2527
+ requestId: ctx.request.id?.() ?? ctx.request.header?.("x-request-id"),
2528
+ };
2529
+ }
2530
+
2531
+ /**
2532
+ * `Secure` unless this is a development/test process on plain HTTP — an
2533
+ * unknown environment gets the production answer, and `NODE_ENV=prod` is
2534
+ * production too.
2535
+ */
2536
+ function secureByDefault(ctx: StationHttpContext): boolean {
2537
+ const env = normalizeNodeEnv(process.env.NODE_ENV);
2538
+ if (env !== "development" && env !== "test") return true;
2539
+ return ctx.request.secure?.() === true;
2540
+ }
2541
+
2542
+ function buildLoginLimiter(
2543
+ config: StationConfig["loginRateLimit"],
2544
+ ): StationLoginLimiter | undefined {
2545
+ if (config === false) return undefined;
2546
+ if (config?.limiter !== undefined) return config.limiter;
2547
+ return new MemoryLoginLimiter(config?.maxAttempts, config?.windowSeconds);
2548
+ }
2549
+
2550
+ /**
2551
+ * A router whose every route gets `names` attached through the route's own
2552
+ * `middleware(...names)` (ream's / AdonisJS's route API). A router that hands
2553
+ * back no such method cannot honour the config, which is refused loudly.
2554
+ */
2555
+ function withRouteMiddleware(
2556
+ router: StationRouter,
2557
+ names: ReadonlyArray<string> | undefined,
2558
+ ): StationRouter {
2559
+ if (names === undefined || names.length === 0) return router;
2560
+ const attach = (route: unknown): unknown => {
2561
+ if (
2562
+ typeof route !== "object" ||
2563
+ route === null ||
2564
+ !("middleware" in route) ||
2565
+ typeof route.middleware !== "function"
2566
+ ) {
2567
+ throw new Error(
2568
+ "[station] `station.middleware` is set but the router's routes have no middleware() method to attach it with.",
2569
+ );
2570
+ }
2571
+ route.middleware(...names);
2572
+ return route;
2573
+ };
2574
+ return {
2575
+ get: (path, handler) => attach(router.get(path, handler)),
2576
+ post: (path, handler) => attach(router.post(path, handler)),
2577
+ put: (path, handler) => attach(router.put(path, handler)),
2578
+ delete: (path, handler) => attach(router.delete(path, handler)),
2579
+ };
2580
+ }
2581
+
2118
2582
  /**
2119
2583
  * Cross-package bridge — Station's `Resource.entity` is intentionally
2120
2584
  * typed `new (...args: never[]) => unknown` so the package type-compiles
@@ -2163,22 +2627,53 @@ function buildResourceContext(
2163
2627
  atlas.getRelationMetadata(resource.entity).map((r) => r.propertyKey),
2164
2628
  );
2165
2629
  if (atlas.hasSoftDeletes(resource.entity)) excludedProps.add("deletedAt");
2630
+ const listColumns = viewColumns(
2631
+ columns,
2632
+ resource.list.columns,
2633
+ resource,
2634
+ "list.columns",
2635
+ );
2636
+ const showColumns = viewColumns(
2637
+ columns,
2638
+ resource.show.columns,
2639
+ resource,
2640
+ "show.columns",
2641
+ );
2642
+ const formColumns = editableColumns(
2643
+ columns,
2644
+ resource,
2645
+ pkColumn,
2646
+ autoManaged,
2647
+ excludedProps,
2648
+ );
2649
+ const writeOnly = writeOnlyKeys(formColumns, resource);
2166
2650
  const derived =
2167
2651
  rune === null
2168
2652
  ? undefined
2169
2653
  : deriveWritableSchema(
2170
- columns,
2654
+ formColumns,
2171
2655
  pkColumn,
2172
2656
  autoManaged,
2173
2657
  excludedProps,
2174
2658
  rune,
2659
+ writeOnly,
2175
2660
  );
2176
2661
  return {
2177
2662
  repo,
2178
2663
  columns,
2664
+ listColumns,
2665
+ showColumns,
2666
+ formColumns,
2667
+ writeOnly,
2668
+ redacted: new Set(
2669
+ resource.auditSecrets
2670
+ ? []
2671
+ : columns.filter(isSecretColumn).map((c) => c.propertyKey),
2672
+ ),
2179
2673
  pkColumn,
2180
2674
  autoManaged,
2181
2675
  writableSchema: derived?.schema,
2676
+ updateSchema: derived?.updateSchema,
2182
2677
  booleanKeys: derived?.booleanKeys ?? new Set<string>(),
2183
2678
  };
2184
2679
  }
@@ -2263,7 +2758,7 @@ function isRouterProxyUninit(err: unknown): boolean {
2263
2758
  /**
2264
2759
  * 56.5 authorization gate. Returns true when the action is allowed.
2265
2760
  * Station authorizes EXCLUSIVELY through Warden's unified layer: the
2266
- * decision is `auth.hasPermission(user, "<resource>.<action>", scope)`
2761
+ * decision is `auth.hasPermission(user, "<prefix>.<resource>.<action>", scope)`
2267
2762
  * resolved via the container `"auth"` AuthManager (D1/D2). No
2268
2763
  * Station-local RBAC computation, no token-payload read.
2269
2764
  *
@@ -2288,8 +2783,10 @@ async function authorizeAction(
2288
2783
  action: ResourceAction,
2289
2784
  ctx: StationHttpContext,
2290
2785
  authManager: WardenAuthManager | undefined,
2786
+ prefix: string,
2291
2787
  scope: StationScope = "global",
2292
2788
  ): Promise<boolean> {
2789
+ const permission = `${prefix}.${resource.name}.${action}`;
2293
2790
  if (authManager === undefined) return true;
2294
2791
  const user = ctx.auth?.user;
2295
2792
  // `=== undefined` alone is too narrow: a host whose middleware writes
@@ -2300,19 +2797,13 @@ async function authorizeAction(
2300
2797
  // Coerce to a strict boolean: the duck-typed manager could resolve a
2301
2798
  // truthy non-boolean, and `!truthy` would silently ALLOW. Anything
2302
2799
  // not exactly `true` denies (fail-closed).
2303
- return (
2304
- (await authManager.hasPermission(
2305
- user,
2306
- `${resource.name}.${action}`,
2307
- scope,
2308
- )) === true
2309
- );
2800
+ return (await authManager.hasPermission(user, permission, scope)) === true;
2310
2801
  } catch (err) {
2311
2802
  // A rights-store I/O failure (DB/Redis-backed resolver) must not pass
2312
2803
  // the gate. Deny and surface it loud rather than 500-ing or allowing.
2313
2804
  const detail = err instanceof Error ? err.message : String(err);
2314
2805
  console.error(
2315
- `[station] authorization check threw for '${resource.name}.${action}' — denying (fail-closed): ${detail}`,
2806
+ `[station] authorization check threw for '${permission}' — denying (fail-closed): ${detail}`,
2316
2807
  );
2317
2808
  return false;
2318
2809
  }