@c9up/station 0.1.14 → 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.
package/src/columns.ts ADDED
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Which columns each admin view shows and which ones a write may touch.
3
+ *
4
+ * Two rules keep secrets out of the admin by default:
5
+ * - a column the model hides from serialization (`serializeAs: null`, the
6
+ * marker `toJSON` honours) or whose name says it holds a secret is neither
7
+ * rendered nor writable unless the resource names it explicitly;
8
+ * - the writable set is an allow-list: `editable` when the resource declares
9
+ * one, otherwise every non-secret, non-framework column the form shows.
10
+ */
11
+
12
+ import type { ColumnMetadata } from "@c9up/atlas";
13
+ import type { Resource } from "./types.js";
14
+
15
+ /** Column names that hold credentials — hidden, never flashed, never echoed. */
16
+ export const SENSITIVE_KEY_RE = /password|passwd|secret|token|apikey/i;
17
+
18
+ const TIMESTAMP_PROPERTY_KEYS: ReadonlySet<string> = new Set([
19
+ "createdAt",
20
+ "updatedAt",
21
+ "deletedAt",
22
+ ]);
23
+ const TIMESTAMP_COLUMN_NAMES: ReadonlySet<string> = new Set([
24
+ "created_at",
25
+ "updated_at",
26
+ "deleted_at",
27
+ ]);
28
+
29
+ export function isSecretColumn(c: ColumnMetadata): boolean {
30
+ return c.serializeAs === null || SENSITIVE_KEY_RE.test(c.propertyKey);
31
+ }
32
+
33
+ /**
34
+ * Columns the framework owns: the primary key, managed timestamps (flagged
35
+ * `autoCreate`/`autoUpdate` or named by convention), relations and the
36
+ * soft-delete column. Never writable from a request body.
37
+ */
38
+ export function isFrameworkManaged(
39
+ c: ColumnMetadata,
40
+ pkColumn: string,
41
+ autoManaged: ReadonlySet<string>,
42
+ excludedProps: ReadonlySet<string>,
43
+ ): boolean {
44
+ if (c.propertyKey === pkColumn) return true;
45
+ if (autoManaged.has(c.propertyKey)) return true;
46
+ if (TIMESTAMP_PROPERTY_KEYS.has(c.propertyKey)) return true;
47
+ const snake = c.propertyKey.replace(/([A-Z])/g, "_$1").toLowerCase();
48
+ if (TIMESTAMP_COLUMN_NAMES.has(snake)) return true;
49
+ return excludedProps.has(c.propertyKey);
50
+ }
51
+
52
+ function byName(
53
+ columns: ReadonlyArray<ColumnMetadata>,
54
+ names: ReadonlyArray<string>,
55
+ resource: Resource,
56
+ option: string,
57
+ ): ColumnMetadata[] {
58
+ return names.map((name) => {
59
+ const column = columns.find((c) => c.propertyKey === name);
60
+ if (column === undefined) {
61
+ throw new Error(
62
+ `[station] defineResource '${resource.name}': ${option} names '${name}', which is not a column of ${resource.entity.name}.`,
63
+ );
64
+ }
65
+ return column;
66
+ });
67
+ }
68
+
69
+ /**
70
+ * The columns a list or show view renders: the declared allow-list in its
71
+ * order, or every non-secret column.
72
+ */
73
+ export function viewColumns(
74
+ columns: ReadonlyArray<ColumnMetadata>,
75
+ allow: ReadonlyArray<string> | undefined,
76
+ resource: Resource,
77
+ option: string,
78
+ ): ReadonlyArray<ColumnMetadata> {
79
+ if (allow !== undefined) return byName(columns, allow, resource, option);
80
+ return columns.filter((c) => !isSecretColumn(c));
81
+ }
82
+
83
+ /**
84
+ * The columns a create/edit body may set, which are also the form's fields.
85
+ * An explicit `editable` naming a framework-owned or form-hidden column is a
86
+ * contradiction and fails at mount rather than being silently trimmed.
87
+ */
88
+ export function editableColumns(
89
+ columns: ReadonlyArray<ColumnMetadata>,
90
+ resource: Resource,
91
+ pkColumn: string,
92
+ autoManaged: ReadonlySet<string>,
93
+ excludedProps: ReadonlySet<string>,
94
+ ): ReadonlyArray<ColumnMetadata> {
95
+ if (resource.editable !== undefined) {
96
+ const picked = byName(columns, resource.editable, resource, "editable");
97
+ for (const c of picked) {
98
+ if (isFrameworkManaged(c, pkColumn, autoManaged, excludedProps)) {
99
+ throw new Error(
100
+ `[station] defineResource '${resource.name}': editable names '${c.propertyKey}', which the framework manages (primary key, timestamp, relation or soft-delete column).`,
101
+ );
102
+ }
103
+ if (resource.formFields[c.propertyKey]?.hidden === true) {
104
+ throw new Error(
105
+ `[station] defineResource '${resource.name}': editable names '${c.propertyKey}', which formFields hides.`,
106
+ );
107
+ }
108
+ }
109
+ return picked;
110
+ }
111
+ return columns.filter(
112
+ (c) =>
113
+ !isFrameworkManaged(c, pkColumn, autoManaged, excludedProps) &&
114
+ !isSecretColumn(c) &&
115
+ resource.formFields[c.propertyKey]?.hidden !== true,
116
+ );
117
+ }
118
+
119
+ /**
120
+ * Editable columns whose stored value is never sent back to the browser: the
121
+ * form renders them empty and an empty submission leaves them unchanged.
122
+ */
123
+ export function writeOnlyKeys(
124
+ editable: ReadonlyArray<ColumnMetadata>,
125
+ resource: Resource,
126
+ ): ReadonlySet<string> {
127
+ return new Set(
128
+ editable
129
+ .filter(
130
+ (c) =>
131
+ isSecretColumn(c) ||
132
+ resource.formFields[c.propertyKey]?.inputType === "password",
133
+ )
134
+ .map((c) => c.propertyKey),
135
+ );
136
+ }
@@ -17,6 +17,26 @@ function isResourceAction(value: unknown): value is ResourceAction {
17
17
  return typeof value === "string" && ACTION_LOOKUP.has(value);
18
18
  }
19
19
 
20
+ /**
21
+ * Shape-check a column allow-list. Whether each name is a real column is
22
+ * checked at mount, where the entity metadata is known.
23
+ */
24
+ function columnList(
25
+ value: ReadonlyArray<string> | undefined,
26
+ option: string,
27
+ ): ReadonlyArray<string> | undefined {
28
+ if (value === undefined) return undefined;
29
+ if (
30
+ !Array.isArray(value) ||
31
+ !value.every((v) => typeof v === "string" && v.length > 0)
32
+ ) {
33
+ throw new TypeError(
34
+ `[station] defineResource: '${option}' must be an array of column names`,
35
+ );
36
+ }
37
+ return Object.freeze([...value]);
38
+ }
39
+
20
40
  /**
21
41
  * Build a frozen `Resource` from a developer's declaration.
22
42
  *
@@ -89,6 +109,14 @@ export function defineResource<T>(options: ResourceOptions<T>): Resource<T> {
89
109
  formFields[field] = override;
90
110
  }
91
111
 
112
+ const list = Object.freeze({
113
+ columns: columnList(options.list?.columns, "list.columns"),
114
+ });
115
+ const show = Object.freeze({
116
+ columns: columnList(options.show?.columns, "show.columns"),
117
+ });
118
+ const editable = columnList(options.editable, "editable");
119
+
92
120
  // 54.6: audit sink shape-check — if provided, must be callable. No
93
121
  // extra validation; the sink is exercised lazily on first write.
94
122
  let audit: AuditSink | undefined;
@@ -118,8 +146,12 @@ export function defineResource<T>(options: ResourceOptions<T>): Resource<T> {
118
146
  label,
119
147
  actions: orderedActions,
120
148
  audit,
149
+ auditSecrets: options.auditSecrets === true,
121
150
  onAuditError,
122
151
  formFields: Object.freeze(formFields),
152
+ list,
153
+ show,
154
+ editable,
123
155
  };
124
156
 
125
157
  return Object.freeze(resource);
package/src/index.ts CHANGED
@@ -2,6 +2,10 @@ import "./augmentations.js";
2
2
 
3
3
  export { kebabCase, pluralise, titleCase } from "./casing.js";
4
4
  export { defineResource } from "./defineResource.js";
5
+ export type {
6
+ StationLoginLimiter,
7
+ StationLoginRateLimitConfig,
8
+ } from "./loginLimiter.js";
5
9
  export { ResourceRegistry } from "./ResourceRegistry.js";
6
10
  export type { StationAppContext, StationConfig } from "./StationProvider.js";
7
11
  export { default as StationProvider } from "./StationProvider.js";
@@ -23,4 +27,7 @@ export {
23
27
  type Resource,
24
28
  type ResourceAction,
25
29
  type ResourceOptions,
30
+ type SecurityAuditAction,
31
+ type SecurityAuditEvent,
32
+ type SecurityAuditSink,
26
33
  } from "./types.js";
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Brute-force throttle for `POST /admin/login`.
3
+ *
4
+ * The contract is the one `@c9up/warden`'s `AuthRateLimiter` exposes, so an
5
+ * application passes its own instance (a Redis-backed one in a cluster) as
6
+ * `station.loginRateLimit.limiter`. Station cannot construct warden's class —
7
+ * it never imports `@c9up/warden` — so the default is this in-process copy of
8
+ * the same semantics: one atomic count per key BEFORE the credential check,
9
+ * keys `ip:<ip>` and `id:<identifier>`, a bounded store that refuses new keys
10
+ * rather than forgiving live windows when it is full.
11
+ */
12
+
13
+ export interface StationLoginDecision {
14
+ allowed: boolean;
15
+ retryAfterSeconds: number;
16
+ }
17
+
18
+ /** Structural slice of warden's `AuthRateLimiter`. */
19
+ export interface StationLoginLimiter {
20
+ attempt(...keys: string[]): Promise<StationLoginDecision>;
21
+ clear(...keys: string[]): Promise<void>;
22
+ }
23
+
24
+ export interface StationLoginRateLimitConfig {
25
+ /** Attempts allowed per window, per key. Default 5. */
26
+ maxAttempts?: number;
27
+ /** Window length in seconds. Default 900 (15 min). */
28
+ windowSeconds?: number;
29
+ /**
30
+ * The limiter to use instead of the in-process default — e.g. warden's
31
+ * `AuthRateLimiter` over a `RedisAttemptStore`, so a lockout holds on every
32
+ * instance. `maxAttempts`/`windowSeconds` then belong to that limiter.
33
+ */
34
+ limiter?: StationLoginLimiter;
35
+ }
36
+
37
+ /** Same keys as warden's `AuthRateLimiter.loginKeys`, so a shared limiter shares budgets. */
38
+ export function loginKeys(ip: string, identifier: string): string[] {
39
+ return [`ip:${ip.trim()}`, `id:${identifier.trim().toLowerCase()}`];
40
+ }
41
+
42
+ const MAX_KEYS = 100_000;
43
+
44
+ export class MemoryLoginLimiter implements StationLoginLimiter {
45
+ readonly #maxAttempts: number;
46
+ readonly #windowMs: number;
47
+ readonly #entries = new Map<string, { count: number; resetAt: number }>();
48
+
49
+ constructor(maxAttempts = 5, windowSeconds = 900) {
50
+ this.#maxAttempts = positiveInteger("maxAttempts", maxAttempts);
51
+ this.#windowMs = positiveInteger("windowSeconds", windowSeconds) * 1000;
52
+ }
53
+
54
+ async attempt(...keys: string[]): Promise<StationLoginDecision> {
55
+ const now = Date.now();
56
+ let highest = 0;
57
+ let retryMs = 0;
58
+ for (const key of keys) {
59
+ const counted = this.#increment(key, now);
60
+ if (counted.count > highest) {
61
+ highest = counted.count;
62
+ retryMs = counted.resetAt - now;
63
+ }
64
+ }
65
+ return {
66
+ allowed: highest <= this.#maxAttempts,
67
+ retryAfterSeconds: Math.max(1, Math.ceil(retryMs / 1000)),
68
+ };
69
+ }
70
+
71
+ async clear(...keys: string[]): Promise<void> {
72
+ for (const key of keys) this.#entries.delete(key);
73
+ }
74
+
75
+ #increment(key: string, now: number): { count: number; resetAt: number } {
76
+ let entry = this.#entries.get(key);
77
+ if (entry === undefined || entry.resetAt <= now) {
78
+ this.#entries.delete(key);
79
+ const earliest = this.#makeRoom(now);
80
+ // Full of live windows: the keys are attacker-chosen, so evicting
81
+ // one would forgive a victim's lockout. Refuse the new key instead.
82
+ if (earliest !== undefined) {
83
+ return { count: Number.POSITIVE_INFINITY, resetAt: earliest };
84
+ }
85
+ entry = { count: 0, resetAt: now + this.#windowMs };
86
+ this.#entries.set(key, entry);
87
+ }
88
+ entry.count++;
89
+ return entry;
90
+ }
91
+
92
+ #makeRoom(now: number): number | undefined {
93
+ if (this.#entries.size < MAX_KEYS) return undefined;
94
+ let earliest = Number.POSITIVE_INFINITY;
95
+ for (const [key, entry] of this.#entries) {
96
+ if (entry.resetAt <= now) this.#entries.delete(key);
97
+ else earliest = Math.min(earliest, entry.resetAt);
98
+ }
99
+ return this.#entries.size < MAX_KEYS ? undefined : earliest;
100
+ }
101
+ }
102
+
103
+ function positiveInteger(field: string, value: number): number {
104
+ if (!Number.isSafeInteger(value) || value <= 0) {
105
+ throw new Error(
106
+ `[station] station.loginRateLimit.${field} must be a positive whole number, got ${String(value)}`,
107
+ );
108
+ }
109
+ return value;
110
+ }
package/src/types.ts CHANGED
@@ -21,11 +21,57 @@ export interface AuditEvent {
21
21
  readonly userId?: unknown;
22
22
  readonly before?: Readonly<Record<string, unknown>>;
23
23
  readonly after?: Readonly<Record<string, unknown>>;
24
+ /**
25
+ * On an edit, the secret columns whose value changed. Their values are
26
+ * replaced with `"[redacted]"` in `before`/`after` (unless the resource
27
+ * sets `auditSecrets`), so this is how a password change stays visible.
28
+ */
29
+ readonly redactedChanges?: ReadonlyArray<string>;
30
+ /** Client IP of the request that made the change. */
31
+ readonly ip?: string;
32
+ readonly userAgent?: string;
33
+ /** `X-Request-Id` of that request, when the proxy or client set one. */
34
+ readonly requestId?: string;
24
35
  readonly at: Date;
25
36
  }
26
37
 
27
38
  export type AuditSink = (event: AuditEvent) => void | Promise<void>;
28
39
 
40
+ /** What a {@link SecurityAuditEvent} records. */
41
+ export type SecurityAuditAction =
42
+ | "login"
43
+ | "login_failed"
44
+ | "login_throttled"
45
+ | "logout"
46
+ | "denied"
47
+ | "csrf_denied";
48
+
49
+ /**
50
+ * A sign-in, sign-out or refusal on the admin surface, sent to the
51
+ * `station.audit` sink so brute force and probing show up in the trail.
52
+ */
53
+ export interface SecurityAuditEvent {
54
+ readonly action: SecurityAuditAction;
55
+ /** The authenticated user, when one is known. */
56
+ readonly userId?: unknown;
57
+ /** The email a sign-in attempt submitted. */
58
+ readonly identifier?: string;
59
+ /** For `denied` on a resource action: its slug, action and permission. */
60
+ readonly resource?: string;
61
+ readonly resourceAction?: ResourceAction;
62
+ /** For `denied`: the permission or role that was missing. */
63
+ readonly permission?: string;
64
+ readonly path?: string;
65
+ readonly ip?: string;
66
+ readonly userAgent?: string;
67
+ readonly requestId?: string;
68
+ readonly at: Date;
69
+ }
70
+
71
+ export type SecurityAuditSink = (
72
+ event: SecurityAuditEvent,
73
+ ) => void | Promise<void>;
74
+
29
75
  /**
30
76
  * Per-field override for the auto-generated form (Story 54.5). Station
31
77
  * infers the form from the entity's `@Column` metadata; consumers can
@@ -33,7 +79,10 @@ export type AuditSink = (event: AuditEvent) => void | Promise<void>;
33
79
  * others.
34
80
  */
35
81
  export interface FormFieldOverride {
36
- /** Hide the field from the form entirely (still rendered on show). */
82
+ /**
83
+ * Hide the field from the form. A hidden field is not writable either: a
84
+ * body carrying it is ignored and a hidden boolean keeps its stored value.
85
+ */
37
86
  hidden?: boolean;
38
87
  /** Override the rendered `<input type>`. */
39
88
  inputType?:
@@ -53,6 +102,15 @@ export interface FormFieldOverride {
53
102
  required?: boolean;
54
103
  }
55
104
 
105
+ /** Per-view options for the list and show pages. */
106
+ export interface ResourceViewOptions {
107
+ /**
108
+ * The columns the view renders, in this order. When omitted, every column
109
+ * except secrets (`serializeAs: null` or a credential-like name) is shown.
110
+ */
111
+ columns?: ReadonlyArray<string>;
112
+ }
113
+
56
114
  export interface ResourceOptions<TEntity> {
57
115
  /** Entity class (Atlas `@Entity()`-decorated constructor). */
58
116
  entity: new (
@@ -70,6 +128,13 @@ export interface ResourceOptions<TEntity> {
70
128
  * so apps don't silently lose audit visibility.
71
129
  */
72
130
  audit?: AuditSink;
131
+ /**
132
+ * Hand secret column values (`serializeAs: null` or a credential-like
133
+ * name) to the audit sink as they are. Off by default: they are replaced
134
+ * with `"[redacted]"`, because audit logs usually land in a less protected
135
+ * store than the table they describe.
136
+ */
137
+ auditSecrets?: boolean;
73
138
  /**
74
139
  * Invoked when the audit sink throws (Story 54.6 hardening, retro
75
140
  * 2026-06-01). The mutation has already committed, so this is a
@@ -86,6 +151,18 @@ export interface ResourceOptions<TEntity> {
86
151
  * the inferred defaults.
87
152
  */
88
153
  formFields?: Readonly<Record<string, FormFieldOverride>>;
154
+ /** Columns of the list page. */
155
+ list?: ResourceViewOptions;
156
+ /** Columns of the show page. */
157
+ show?: ResourceViewOptions;
158
+ /**
159
+ * The columns a create/edit may set, and the fields the form shows. When
160
+ * omitted: every column except the framework-managed ones, secrets
161
+ * (`serializeAs: null` or a credential-like name) and `formFields` marked
162
+ * `hidden`. A secret listed here is write-only: the form renders it empty
163
+ * and an empty submission keeps the stored value.
164
+ */
165
+ editable?: ReadonlyArray<string>;
89
166
  }
90
167
 
91
168
  export interface Resource<TEntity = unknown> {
@@ -101,8 +178,16 @@ export interface Resource<TEntity = unknown> {
101
178
  readonly actions: ReadonlyArray<ResourceAction>;
102
179
  /** Optional audit sink (Story 54.6). */
103
180
  readonly audit?: AuditSink;
181
+ /** Whether audit snapshots keep secret column values. */
182
+ readonly auditSecrets: boolean;
104
183
  /** Optional audit-failure observability hook (Story 54.6 hardening). */
105
184
  readonly onAuditError?: (event: AuditEvent, error: unknown) => void;
106
185
  /** Frozen per-field overrides for the inferred form (Story 54.5). */
107
186
  readonly formFields: Readonly<Record<string, FormFieldOverride>>;
187
+ /** Frozen list-page options. */
188
+ readonly list: Readonly<ResourceViewOptions>;
189
+ /** Frozen show-page options. */
190
+ readonly show: Readonly<ResourceViewOptions>;
191
+ /** Frozen editable allow-list; undefined means the default set. */
192
+ readonly editable?: ReadonlyArray<string>;
108
193
  }
package/src/views/form.ts CHANGED
@@ -48,6 +48,12 @@ export interface FormPageInput {
48
48
  values?: Readonly<Record<string, unknown>>;
49
49
  /** Validation errors keyed by column propertyKey. */
50
50
  errors?: Readonly<Record<string, string>>;
51
+ /**
52
+ * Fields rendered empty whatever their stored or submitted value (secrets).
53
+ * A `password` input is always treated this way: a credential, even a hash,
54
+ * is never shipped back inside the page.
55
+ */
56
+ writeOnly?: ReadonlySet<string>;
51
57
  /**
52
58
  * Whether the host has a CSRF token in the per-request store. When true the
53
59
  * template emits `{{ csrfField() }}`; the handler derives it from
@@ -87,8 +93,16 @@ export type FormViewModel = {
87
93
  };
88
94
 
89
95
  export function buildFormViewModel(input: FormPageInput): FormViewModel {
90
- const { resource, columns, pkColumn, row, values, errors, csrfEnabled } =
91
- input;
96
+ const {
97
+ resource,
98
+ columns,
99
+ pkColumn,
100
+ row,
101
+ values,
102
+ errors,
103
+ csrfEnabled,
104
+ writeOnly,
105
+ } = input;
92
106
  const isEdit = row !== undefined;
93
107
  const slug = encodeURIComponent(resource.name);
94
108
  const id = isEdit ? String(row[pkColumn] ?? "") : "";
@@ -108,7 +122,13 @@ export function buildFormViewModel(input: FormPageInput): FormViewModel {
108
122
  (c) => !shouldSkipColumn(c, pkColumn, resource.formFields[c.propertyKey]),
109
123
  )
110
124
  .map((c) =>
111
- buildField(c, values ?? row, errors, resource.formFields[c.propertyKey]),
125
+ buildField(
126
+ c,
127
+ values ?? row,
128
+ errors,
129
+ resource.formFields[c.propertyKey],
130
+ writeOnly?.has(c.propertyKey) === true,
131
+ ),
112
132
  );
113
133
 
114
134
  return {
@@ -128,10 +148,12 @@ function buildField(
128
148
  source: Readonly<Record<string, unknown>> | undefined,
129
149
  errors: Readonly<Record<string, string>> | undefined,
130
150
  override: FormFieldOverride | undefined,
151
+ writeOnly: boolean,
131
152
  ): FormField {
132
153
  const inputType = override?.inputType ?? inferInputType(c);
133
154
  const name = c.propertyKey;
134
- const rawValue = source?.[name];
155
+ const rawValue =
156
+ writeOnly || inputType === "password" ? undefined : source?.[name];
135
157
  const isCheckbox = inputType === "checkbox";
136
158
  const value =
137
159
  rawValue === undefined || rawValue === null ? "" : String(rawValue);
@@ -7,8 +7,8 @@
7
7
  *
8
8
  * Pure `(input) => LoginViewModel` builder (Story 57.3): every value reaches
9
9
  * HTML through `templates/login.inker`'s `{{ }}` auto-escape (the retired
10
- * `escape.ts` no longer runs here), so a tampered `?error=…` query parameter
11
- * cannot smuggle markup into the page. The CSRF hidden input is emitted by
10
+ * `escape.ts` no longer runs here). `error` only ever carries a message
11
+ * Station produced; the handler never reads it from the URL. The CSRF hidden input is emitted by
12
12
  * inker's canonical `{{ csrfField() }}` helper, guarded by the precomputed
13
13
  * `csrfEnabled` boolean.
14
14
  */