@riceawa/dsh-lan-gateway 0.5.4 → 0.6.0

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.
@@ -0,0 +1,137 @@
1
+ /**
2
+ * The `lan-gateway` configuration field table: which settings keys exist, how
3
+ * each one is rendered, and how a draft string becomes a config value or a
4
+ * clear.
5
+ *
6
+ * The host and the browser card share this module. The host derives the key set
7
+ * its config route accepts and the set an empty value clears from it; the card
8
+ * renders its controls from it. A field therefore cannot be editable on one
9
+ * side and unknown on the other, which is what let the card silently rewrite
10
+ * keys it never showed.
11
+ *
12
+ * Because both halves import it, this module must stay dependency-free and
13
+ * side-effect-free: no schemastery (the host schema remains the validating
14
+ * authority, not this table), no node built-ins, no DOM, no I/O.
15
+ *
16
+ * @module @riceawa/dsh-lan-gateway/config-fields
17
+ */
18
+
19
+ /**
20
+ * The wire shape of the `lan-gateway` config section, as the Settings card sees
21
+ * it. Deliberately narrower than the host's `Config`: `cookieName` and the
22
+ * retired `authRequired` are not card-editable. The config route applies a
23
+ * patch, so a key absent here is left untouched rather than reset.
24
+ */
25
+ export interface LanGatewaySettings {
26
+ enabled?: boolean
27
+ gatewayPort?: number
28
+ dshTargetPort?: number
29
+ lanCidrs?: string[]
30
+ lanPasswordless?: boolean
31
+ cookieMaxAgeDays?: number
32
+ tlsEnabled?: boolean
33
+ tlsMode?: 'self-signed' | 'custom'
34
+ tlsCertPath?: string
35
+ tlsKeyPath?: string
36
+ tlsSelfSignedHosts?: string
37
+ tlsCertMaxAgeDays?: number
38
+ allowInsecurePlaintext?: boolean
39
+ trustedTerminator?: string
40
+ secureCookies?: boolean
41
+ }
42
+
43
+ /** A settings key this table knows how to edit. */
44
+ export type ConfigFieldKey = keyof LanGatewaySettings
45
+
46
+ /** How a field is rendered and parsed. */
47
+ export type FieldKind = 'boolean' | 'number' | 'text' | 'cidrs' | 'select' | 'tristate'
48
+
49
+ export interface FieldDef {
50
+ readonly field: ConfigFieldKey
51
+ readonly kind: FieldKind
52
+ /** An empty draft clears the key back to the composition layer. */
53
+ readonly optional?: boolean
54
+ /** The accepted values of a `select` field. */
55
+ readonly options?: readonly string[]
56
+ }
57
+
58
+ /** The three states of a tri-state field, in display order. */
59
+ export const TRISTATE_OPTIONS = ['auto', 'true', 'false'] as const
60
+
61
+ /**
62
+ * The editable settings, in display order. Adding a config key means adding it
63
+ * here (the host whitelist and the card's controls both follow), to the
64
+ * `Config` schema in `index.ts`, and to `listenerKey` when it changes listener
65
+ * behavior.
66
+ */
67
+ export const FIELDS: readonly FieldDef[] = [
68
+ { field: 'enabled', kind: 'boolean' },
69
+ { field: 'gatewayPort', kind: 'number' },
70
+ { field: 'dshTargetPort', kind: 'number', optional: true },
71
+ { field: 'lanCidrs', kind: 'cidrs' },
72
+ { field: 'lanPasswordless', kind: 'boolean' },
73
+ { field: 'cookieMaxAgeDays', kind: 'number' },
74
+ { field: 'tlsEnabled', kind: 'boolean' },
75
+ { field: 'tlsMode', kind: 'select', options: ['self-signed', 'custom'] },
76
+ { field: 'tlsSelfSignedHosts', kind: 'text' },
77
+ { field: 'tlsCertPath', kind: 'text', optional: true },
78
+ { field: 'tlsKeyPath', kind: 'text', optional: true },
79
+ { field: 'tlsCertMaxAgeDays', kind: 'number' },
80
+ { field: 'allowInsecurePlaintext', kind: 'boolean' },
81
+ { field: 'trustedTerminator', kind: 'text', optional: true },
82
+ { field: 'secureCookies', kind: 'tristate' },
83
+ ]
84
+
85
+ /** Every settings key the config route accepts; anything else is ignored. */
86
+ export const CONFIG_FIELD_KEYS: ReadonlySet<string> = new Set(FIELDS.map(def => def.field as string))
87
+
88
+ /** Keys an empty submitted value clears back to the composition layer. */
89
+ export const OPTIONAL_CONFIG_KEYS: ReadonlySet<string> = new Set(
90
+ FIELDS.filter(def => def.optional === true).map(def => def.field as string),
91
+ )
92
+
93
+ /** Render a stored value as draft text. */
94
+ export function formatValue(def: FieldDef, value: unknown): string {
95
+ switch (def.kind) {
96
+ case 'boolean': return value === true ? 'true' : 'false'
97
+ case 'number': return typeof value === 'number' ? String(value) : ''
98
+ case 'cidrs': return Array.isArray(value) ? value.join(', ') : ''
99
+ case 'select': return typeof value === 'string' ? value : (def.options?.[0] ?? '')
100
+ // Tri-state: an unset value is a distinct third state ("auto"), never "false".
101
+ case 'tristate': return value === true ? 'true' : value === false ? 'false' : 'auto'
102
+ case 'text': return typeof value === 'string' ? value : ''
103
+ }
104
+ }
105
+
106
+ /** One field's contribution to a config patch. */
107
+ export type Write = { kind: 'set'; value: unknown } | { kind: 'clear' }
108
+
109
+ /** Parse draft text into a value for the POST body; undefined blocks saving. */
110
+ export function parseValue(def: FieldDef, text: string): Write | undefined {
111
+ const trimmed = text.trim()
112
+ switch (def.kind) {
113
+ case 'boolean':
114
+ if (trimmed === 'true') return { kind: 'set', value: true }
115
+ if (trimmed === 'false') return { kind: 'set', value: false }
116
+ return undefined
117
+ case 'number':
118
+ if (trimmed === '') return def.optional ? { kind: 'clear' } : undefined
119
+ if (!/^\d+$/.test(trimmed)) return undefined
120
+ return { kind: 'set', value: Number(trimmed) }
121
+ case 'cidrs': {
122
+ const cidrs = trimmed.split(',').map(s => s.trim()).filter(s => s !== '')
123
+ return cidrs.length === 0 ? { kind: 'clear' } : { kind: 'set', value: cidrs }
124
+ }
125
+ case 'select':
126
+ return def.options?.includes(trimmed) ? { kind: 'set', value: trimmed } : undefined
127
+ case 'tristate':
128
+ // 'auto' clears the key so it re-inherits the composition layer (and the
129
+ // resolution rule), which is what an unset tri-state means.
130
+ if (trimmed === 'auto') return { kind: 'clear' }
131
+ if (trimmed === 'true') return { kind: 'set', value: true }
132
+ if (trimmed === 'false') return { kind: 'set', value: false }
133
+ return undefined
134
+ case 'text':
135
+ return trimmed === '' ? (def.optional ? { kind: 'clear' } : undefined) : { kind: 'set', value: trimmed }
136
+ }
137
+ }