@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/dist/StationProvider.d.ts +51 -11
- package/dist/StationProvider.d.ts.map +1 -1
- package/dist/StationProvider.js +484 -163
- package/dist/StationProvider.js.map +1 -1
- package/dist/columns.d.ts +38 -0
- package/dist/columns.d.ts.map +1 -0
- package/dist/columns.js +93 -0
- package/dist/columns.js.map +1 -0
- package/dist/defineResource.d.ts.map +1 -1
- package/dist/defineResource.js +24 -0
- package/dist/defineResource.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/loginLimiter.d.ts +41 -0
- package/dist/loginLimiter.d.ts.map +1 -0
- package/dist/loginLimiter.js +80 -0
- package/dist/loginLimiter.js.map +1 -0
- package/dist/types.d.ts +74 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/views/form.d.ts +6 -0
- package/dist/views/form.d.ts.map +1 -1
- package/dist/views/form.js +4 -4
- package/dist/views/form.js.map +1 -1
- package/dist/views/login.d.ts +2 -2
- package/dist/views/login.js +2 -2
- package/package.json +3 -2
- package/src/StationProvider.ts +675 -184
- package/src/columns.ts +136 -0
- package/src/defineResource.ts +32 -0
- package/src/index.ts +7 -0
- package/src/loginLimiter.ts +110 -0
- package/src/types.ts +86 -1
- package/src/views/form.ts +26 -4
- package/src/views/login.ts +2 -2
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
|
+
}
|
package/src/defineResource.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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 {
|
|
91
|
-
|
|
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(
|
|
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 =
|
|
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);
|
package/src/views/login.ts
CHANGED
|
@@ -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)
|
|
11
|
-
*
|
|
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
|
*/
|