@voxgig/station 0.0.1
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/LICENSE +21 -0
- package/README.md +88 -0
- package/dist/src/Station.d.ts +238 -0
- package/dist/src/Station.js +1213 -0
- package/dist/src/Station.js.map +1 -0
- package/dist/src/adapter.d.ts +9 -0
- package/dist/src/adapter.js +171 -0
- package/dist/src/adapter.js.map +1 -0
- package/dist/src/config-shape.d.ts +1 -0
- package/dist/src/config-shape.js +329 -0
- package/dist/src/config-shape.js.map +1 -0
- package/dist/src/descriptor.d.ts +8 -0
- package/dist/src/descriptor.js +170 -0
- package/dist/src/descriptor.js.map +1 -0
- package/dist/src/error.d.ts +8 -0
- package/dist/src/error.js +60 -0
- package/dist/src/error.js.map +1 -0
- package/dist/src/events.d.ts +15 -0
- package/dist/src/events.js +46 -0
- package/dist/src/events.js.map +1 -0
- package/dist/src/factory.d.ts +24 -0
- package/dist/src/factory.js +79 -0
- package/dist/src/factory.js.map +1 -0
- package/dist/src/feature.d.ts +106 -0
- package/dist/src/feature.js +324 -0
- package/dist/src/feature.js.map +1 -0
- package/dist/src/index.browser.d.ts +14 -0
- package/dist/src/index.browser.js +64 -0
- package/dist/src/index.browser.js.map +1 -0
- package/dist/src/index.d.ts +14 -0
- package/dist/src/index.js +56 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/loader.d.ts +45 -0
- package/dist/src/loader.js +181 -0
- package/dist/src/loader.js.map +1 -0
- package/dist/src/profile.d.ts +6 -0
- package/dist/src/profile.js +77 -0
- package/dist/src/profile.js.map +1 -0
- package/dist/src/profilecore.d.ts +10 -0
- package/dist/src/profilecore.js +180 -0
- package/dist/src/profilecore.js.map +1 -0
- package/dist/src/secrets.d.ts +12 -0
- package/dist/src/secrets.js +93 -0
- package/dist/src/secrets.js.map +1 -0
- package/dist/src/shape.d.ts +42 -0
- package/dist/src/shape.js +444 -0
- package/dist/src/shape.js.map +1 -0
- package/dist/src/types.d.ts +138 -0
- package/dist/src/types.js +5 -0
- package/dist/src/types.js.map +1 -0
- package/package.json +60 -0
- package/src/Station.ts +1310 -0
- package/src/adapter.ts +183 -0
- package/src/config-shape.ts +326 -0
- package/src/descriptor.ts +179 -0
- package/src/error.ts +64 -0
- package/src/events.ts +45 -0
- package/src/factory.ts +93 -0
- package/src/feature.ts +321 -0
- package/src/index.browser.ts +56 -0
- package/src/index.ts +50 -0
- package/src/loader.ts +188 -0
- package/src/profile.ts +67 -0
- package/src/profilecore.ts +201 -0
- package/src/secrets.ts +96 -0
- package/src/shape.ts +435 -0
- package/src/types.ts +185 -0
package/src/shape.ts
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
/* The config grammar, as data (design §4).
|
|
2
|
+
*
|
|
3
|
+
* TWO STEPS, AND THE FIRST IS WHAT MAKES THE SECOND HONEST.
|
|
4
|
+
*
|
|
5
|
+
* struct drops the unexpected-key check for a map whose spec node ends
|
|
6
|
+
* up empty - "an empty spec object means the object can be open". An
|
|
7
|
+
* optional key is `['$ONE','$NIL', spec]`, and when the data does not
|
|
8
|
+
* carry that key the validator REMOVES it from the spec node. So a
|
|
9
|
+
* block whose keys are all optional degenerates into an open map
|
|
10
|
+
* exactly when the data has none of them, and `{"solar": {"bass": 1}}`
|
|
11
|
+
* validates clean - the one property the whole exercise is for,
|
|
12
|
+
* silently absent in the one case that matters.
|
|
13
|
+
*
|
|
14
|
+
* So: normalizeConfig materializes every documented default, and
|
|
15
|
+
* validateConfig then runs a shape WITH NO OPTIONAL CONTAINERS AT ALL.
|
|
16
|
+
* After normalization every container is present, so the shape can
|
|
17
|
+
* require them, so unexpected-key detection is live at every level and
|
|
18
|
+
* every error names its path. */
|
|
19
|
+
|
|
20
|
+
import { clone, validate } from '@voxgig/struct'
|
|
21
|
+
import { validname } from '@voxgig/sekreto'
|
|
22
|
+
|
|
23
|
+
import { CONFIG_SHAPE } from './config-shape'
|
|
24
|
+
import { StationError } from './error'
|
|
25
|
+
import { envtoken } from './descriptor'
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------
|
|
28
|
+
// The defaults table - ONE table, two callers
|
|
29
|
+
// ---------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
/** Profile-level containers. Safe to materialize early either way:
|
|
32
|
+
* they are containers, and a missing one merges as empty regardless. */
|
|
33
|
+
export const PROFILE_DEFAULTS: { [k: string]: () => any } = {
|
|
34
|
+
secrets: () => ({ providers: [{ kind: 'env' }] }),
|
|
35
|
+
api: () => ({}),
|
|
36
|
+
sdk: () => ({}),
|
|
37
|
+
feature: () => ({}),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Block-level. `feature` is a container and safe early.
|
|
41
|
+
*
|
|
42
|
+
* `active` IS NOT, and that is the whole timing rule: a default
|
|
43
|
+
* synthesized into an OVERLAY block overwrites the base's real value
|
|
44
|
+
* and silently reactivates an integration the base deliberately barred
|
|
45
|
+
* (§3.3). So the two consumers read this same table at different
|
|
46
|
+
* moments - validateConfig before, to every block, because a block with
|
|
47
|
+
* no present keys is an open map; the resolver AFTER, to the merged
|
|
48
|
+
* instance, because an absent key must stay absent through the merge. */
|
|
49
|
+
export const BLOCK_DEFAULTS: { [k: string]: () => any } = {
|
|
50
|
+
active: () => true,
|
|
51
|
+
feature: () => ({}),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The one block key carrying the timing rule. Named rather than
|
|
55
|
+
* inferred, so a reader does not have to work out which of the two it
|
|
56
|
+
* is, and so a port can assert it. */
|
|
57
|
+
export const MERGE_SENSITIVE = ['active']
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------
|
|
60
|
+
// normalizeConfig
|
|
61
|
+
// ---------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
/** Materialize every documented default, DEFENSIVELY: a node that is
|
|
64
|
+
* not the kind it expects is left alone for validate to reject with a
|
|
65
|
+
* proper message. Pure data-in/data-out, which is what makes it
|
|
66
|
+
* portable to 22 languages and expressible in the corpus.
|
|
67
|
+
*
|
|
68
|
+
* THE NORMALIZED FORM IS AN INPUT TO VALIDATION AND TO NOTHING ELSE. */
|
|
69
|
+
export function normalizeConfig(raw: any): any {
|
|
70
|
+
if (!ismap(raw)) { return raw }
|
|
71
|
+
const out: any = { ...raw }
|
|
72
|
+
|
|
73
|
+
if (undefined === out.station) { out.station = 1 }
|
|
74
|
+
if (undefined === out.profiles) { out.profiles = {} }
|
|
75
|
+
if (!ismap(out.profiles)) { return out }
|
|
76
|
+
|
|
77
|
+
const profiles: any = {}
|
|
78
|
+
for (const pname of Object.keys(out.profiles)) {
|
|
79
|
+
const p = out.profiles[pname]
|
|
80
|
+
if (!ismap(p)) { profiles[pname] = p; continue }
|
|
81
|
+
const prof: any = { ...p }
|
|
82
|
+
|
|
83
|
+
for (const k of Object.keys(PROFILE_DEFAULTS)) {
|
|
84
|
+
if (undefined === prof[k]) { prof[k] = PROFILE_DEFAULTS[k]() }
|
|
85
|
+
}
|
|
86
|
+
// A `secrets` written without `providers` still gets the chain.
|
|
87
|
+
if (ismap(prof.secrets) && undefined === prof.secrets.providers) {
|
|
88
|
+
prof.secrets = { ...prof.secrets, providers: [{ kind: 'env' }] }
|
|
89
|
+
}
|
|
90
|
+
prof.feature = normfeatures(prof.feature)
|
|
91
|
+
|
|
92
|
+
for (const bkey of ['api', 'sdk']) {
|
|
93
|
+
if (!ismap(prof[bkey])) { continue }
|
|
94
|
+
const blocks: any = {}
|
|
95
|
+
for (const ref of Object.keys(prof[bkey])) {
|
|
96
|
+
const b = prof[bkey][ref]
|
|
97
|
+
if (!ismap(b)) { blocks[ref] = b; continue }
|
|
98
|
+
const block: any = { ...b }
|
|
99
|
+
for (const k of Object.keys(BLOCK_DEFAULTS)) {
|
|
100
|
+
if (undefined === block[k]) { block[k] = BLOCK_DEFAULTS[k]() }
|
|
101
|
+
}
|
|
102
|
+
block.feature = normfeatures(block.feature)
|
|
103
|
+
blocks[ref] = block
|
|
104
|
+
}
|
|
105
|
+
prof[bkey] = blocks
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
profiles[pname] = prof
|
|
109
|
+
}
|
|
110
|
+
out.profiles = profiles
|
|
111
|
+
return out
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Per feature entry, at every level: `active` -> true.
|
|
115
|
+
*
|
|
116
|
+
* A FEATURE NAMED IN THE CONFIG IS ONE YOU ARE ASKING FOR. The SDK's
|
|
117
|
+
* own default is `active: false` for all but `log`, and
|
|
118
|
+
* `{"retry": {"retries": 3}}` plainly means "retry, with three
|
|
119
|
+
* attempts". It also keeps the feature map closed, for the same reason
|
|
120
|
+
* every other block needs one present key.
|
|
121
|
+
*
|
|
122
|
+
* Defensive like the rest: a non-map is returned untouched for validate
|
|
123
|
+
* to reject by path. */
|
|
124
|
+
function normfeatures(f: any): any {
|
|
125
|
+
if (!ismap(f)) { return f }
|
|
126
|
+
const out: any = {}
|
|
127
|
+
for (const name of Object.keys(f)) {
|
|
128
|
+
const e = f[name]
|
|
129
|
+
out[name] = ismap(e) && undefined === e.active ? { ...e, active: true } : e
|
|
130
|
+
}
|
|
131
|
+
return out
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------
|
|
135
|
+
// validateConfig
|
|
136
|
+
// ---------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
/** `spec/config-shape.json`, §4.3 verbatim, through the shipped mirror.
|
|
139
|
+
* Every validate gets a CLONE: struct's validate consumes the spec it
|
|
140
|
+
* walks (it deletes satisfied `$ONE` branches as it goes), so handing it
|
|
141
|
+
* the module constant twice would validate the second config against a
|
|
142
|
+
* spec the first had already eaten. */
|
|
143
|
+
export function configShape(): any {
|
|
144
|
+
return clone(CONFIG_SHAPE)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Credential-shaped keys (§5.2). `secret` is here AND is the one
|
|
148
|
+
* exempt key - see secretvalue below; a blanket deny would reject the
|
|
149
|
+
* very mechanism that keeps values out of the file. */
|
|
150
|
+
const CREDENTIAL_KEYS = [
|
|
151
|
+
'apikey', 'auth', 'authorization', 'token',
|
|
152
|
+
'secret', 'password', 'credential', 'bearer',
|
|
153
|
+
]
|
|
154
|
+
|
|
155
|
+
/** The suffix rule catches `access_key`, `X-Api-Token` and friends in
|
|
156
|
+
* one rule rather than a growing list of spellings. */
|
|
157
|
+
const CREDENTIAL_SUFFIX = ['_KEY', '_TOKEN', '_SECRET', '_PASSWORD']
|
|
158
|
+
|
|
159
|
+
/** §5.2's backstop, and it is stated as one rather than as a grammar.
|
|
160
|
+
* `validname()` is a NAME grammar, not a credential filter: it rejects
|
|
161
|
+
* uppercase, hyphens, `+`, `/` and `=`, so it excludes most real
|
|
162
|
+
* credential formats - but a lowercase hex token passes it cleanly. A
|
|
163
|
+
* character class cannot tell a name from a secret.
|
|
164
|
+
*
|
|
165
|
+
* Derived names break on every separator (`voxgig_solardemo.apikey`
|
|
166
|
+
* runs 6/9/6) and a hand-written name for a human to read does too; a
|
|
167
|
+
* 24-character unbroken run is not a name anybody writes. Note this is
|
|
168
|
+
* a RUN bound, not a length bound: `acme_internal_billing_service.apikey`
|
|
169
|
+
* is 36 characters and passes, which is the false positive a naive
|
|
170
|
+
* length bound would produce. */
|
|
171
|
+
const RUN_BOUND = 24
|
|
172
|
+
const UNBROKEN_RUN = new RegExp('[A-Za-z0-9]{' + RUN_BOUND + ',}')
|
|
173
|
+
|
|
174
|
+
/** Normalize, then validate (§4.2). Raises `station_config_invalid`
|
|
175
|
+
* with EVERY struct error at once - an eighteen-instance config that
|
|
176
|
+
* touches three of them must not die because the eighteenth has a
|
|
177
|
+
* typo'd package name - then the §5.2 scans.
|
|
178
|
+
*
|
|
179
|
+
* Takes the NORMALIZED form. Handing it a raw config is the mistake
|
|
180
|
+
* §4.2 exists to prevent, so callers go through open()/loadConfig. */
|
|
181
|
+
export function validateConfig(normalized: any): any {
|
|
182
|
+
const errs: string[] = []
|
|
183
|
+
validate(normalized, configShape(), { errs })
|
|
184
|
+
if (0 < errs.length) {
|
|
185
|
+
throw new StationError('station_config_invalid',
|
|
186
|
+
errs.join('; ') + renamehint(normalized))
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
scanConfig(normalized)
|
|
190
|
+
return normalized
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** `plugin` is REMOVED, not aliased (§3.4) - a deprecated alias would
|
|
194
|
+
* be a second grammar for one concept in 17 ports. The shape already
|
|
195
|
+
* rejects it as an unexpected key; this says what to rename, because
|
|
196
|
+
* "unexpected key: plugin" alone does not, and the migration for a
|
|
197
|
+
* single-instance project is exactly this one rename. */
|
|
198
|
+
function renamehint(cfg: any): string {
|
|
199
|
+
const profiles = ismap(cfg) && ismap(cfg.profiles) ? cfg.profiles : {}
|
|
200
|
+
const hit = Object.keys(profiles)
|
|
201
|
+
.filter((p) => ismap(profiles[p]) && undefined !== profiles[p].plugin)
|
|
202
|
+
if (0 === hit.length) { return '' }
|
|
203
|
+
return '; rename `plugin` to `sdk` in ' +
|
|
204
|
+
hit.map((p) => 'profiles.' + p).join(', ') +
|
|
205
|
+
' - the keys are unchanged, an untagged ref IS an api slug (§3.4)'
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The §5.2 scans, over the parts of the grammar that hold arbitrary
|
|
209
|
+
* data. Everything else is closed by construction and needs no scan. */
|
|
210
|
+
function scanConfig(cfg: any): void {
|
|
211
|
+
const secrets: string[] = []
|
|
212
|
+
const reserved: string[] = []
|
|
213
|
+
const invalid: string[] = []
|
|
214
|
+
|
|
215
|
+
const profiles = ismap(cfg) && ismap(cfg.profiles) ? cfg.profiles : {}
|
|
216
|
+
for (const pname of Object.keys(profiles)) {
|
|
217
|
+
const prof = profiles[pname]
|
|
218
|
+
if (!ismap(prof)) { continue }
|
|
219
|
+
const ppath = 'profiles.' + pname
|
|
220
|
+
|
|
221
|
+
checkfeatures(prof.feature, ppath + '.feature', secrets, reserved, invalid)
|
|
222
|
+
|
|
223
|
+
for (const bkey of ['api', 'sdk']) {
|
|
224
|
+
if (!ismap(prof[bkey])) { continue }
|
|
225
|
+
for (const ref of Object.keys(prof[bkey])) {
|
|
226
|
+
const block = prof[bkey][ref]
|
|
227
|
+
if (!ismap(block)) { continue }
|
|
228
|
+
const bpath = ppath + '.' + bkey + '.' + ref
|
|
229
|
+
|
|
230
|
+
// The block's own `secret` holds a NAME. resolveProfile checks
|
|
231
|
+
// it again per instance (station_secret_name); this catches it
|
|
232
|
+
// at open(), for the whole file at once.
|
|
233
|
+
if (undefined !== block.secret) {
|
|
234
|
+
secretvalue(block.secret, bpath + '.secret', secrets)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// `options` is passthrough to a generated constructor, so it is
|
|
238
|
+
// the one place a value can hide.
|
|
239
|
+
scan(block.options, bpath + '.options', secrets, reserved)
|
|
240
|
+
checkfeatures(block.feature, bpath + '.feature', secrets, reserved, invalid)
|
|
241
|
+
|
|
242
|
+
// §4.4's explicit checks, applied where the shape cannot reach,
|
|
243
|
+
// raising the same code the shape would - and pinned in the
|
|
244
|
+
// corpus so each workaround is removed deliberately when struct
|
|
245
|
+
// is fixed rather than forgotten.
|
|
246
|
+
checkpolicy(block.policy, bpath + '.policy', invalid)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (0 < invalid.length) {
|
|
252
|
+
throw new StationError('station_config_invalid', invalid.join('; '))
|
|
253
|
+
}
|
|
254
|
+
if (0 < reserved.length) {
|
|
255
|
+
throw new StationError('station_feature_reserved', reserved.join('; '))
|
|
256
|
+
}
|
|
257
|
+
if (0 < secrets.length) {
|
|
258
|
+
throw new StationError('station_config_secret', secrets.join('; '))
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** A feature map at any level. `station` is reserved: station composes
|
|
263
|
+
* its own wrap and a config that reconfigures it is asking for a state
|
|
264
|
+
* the ordering rules cannot express (§8.4). */
|
|
265
|
+
function checkfeatures(
|
|
266
|
+
f: any, path: string, secrets: string[], reserved: string[],
|
|
267
|
+
invalid: string[]
|
|
268
|
+
): void {
|
|
269
|
+
if (!ismap(f)) { return }
|
|
270
|
+
for (const name of Object.keys(f)) {
|
|
271
|
+
const fpath = path + '.' + name
|
|
272
|
+
if ('station' === name) {
|
|
273
|
+
reserved.push(path + '.station is reserved: station composes its own ' +
|
|
274
|
+
'wrap and it cannot be configured from station.json')
|
|
275
|
+
}
|
|
276
|
+
const order = ismap(f[name]) ? f[name].order : undefined
|
|
277
|
+
if (ismap(order)) {
|
|
278
|
+
firstelement(order.before, fpath + '.order.before', invalid)
|
|
279
|
+
firstelement(order.after, fpath + '.order.after', invalid)
|
|
280
|
+
}
|
|
281
|
+
scan(f[name], fpath, secrets, reserved)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** The policy block's §4.4 workarounds, in one place because they are
|
|
286
|
+
* one class of gap: struct cannot check what its own defects hide.
|
|
287
|
+
*
|
|
288
|
+
* - `hosts`, `allow.op` and `allow.method` are `$CHILD` string lists,
|
|
289
|
+
* so element 0 escapes the shape (see firstelement below).
|
|
290
|
+
* - `budget` is a map whose keys are ALL optional scalars, and struct
|
|
291
|
+
* removes an unsatisfied optional key from the spec node - so
|
|
292
|
+
* `budget: {rp: 1}` degenerates the spec into an open map and the
|
|
293
|
+
* typo passes. `allow` does not have this problem (its `$CHILD` keys
|
|
294
|
+
* stay in the spec whether or not the data carries them, keeping the
|
|
295
|
+
* map closed), and neither does `policy` itself (`hosts` anchors it);
|
|
296
|
+
* `budget` alone needs the explicit unexpected-key check, phrased as
|
|
297
|
+
* struct would phrase it. */
|
|
298
|
+
const BUDGET_KEYS = ['concurrency', 'rps']
|
|
299
|
+
|
|
300
|
+
function checkpolicy(policy: any, path: string, invalid: string[]): void {
|
|
301
|
+
if (!ismap(policy)) { return }
|
|
302
|
+
|
|
303
|
+
firstelement(policy.hosts, path + '.hosts', invalid)
|
|
304
|
+
|
|
305
|
+
const allow = policy.allow
|
|
306
|
+
if (ismap(allow)) {
|
|
307
|
+
firstelement(allow.op, path + '.allow.op', invalid)
|
|
308
|
+
firstelement(allow.method, path + '.allow.method', invalid)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const budget = policy.budget
|
|
312
|
+
if (ismap(budget)) {
|
|
313
|
+
const unknown = Object.keys(budget)
|
|
314
|
+
.filter((k) => !BUDGET_KEYS.includes(k)).sort()
|
|
315
|
+
if (0 < unknown.length) {
|
|
316
|
+
invalid.push('Unexpected keys at field ' + path + '.budget: ' +
|
|
317
|
+
unknown.join(', '))
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** §4.4: `$CHILD` in list mode DOES NOT VALIDATE ELEMENT 0. Verified:
|
|
323
|
+
* `["a", 1]` fails at index 1, `[1]` passes. validate_CHILD installs the
|
|
324
|
+
* template across the data's indices, sets keyI = 0 and returns element
|
|
325
|
+
* 0 as the slot value, and the injection loop resumes past it. An
|
|
326
|
+
* upstream struct defect; filing it is a deliverable of this plan.
|
|
327
|
+
*
|
|
328
|
+
* It reaches EVERY string list in the shape. §4.4 says the only one left
|
|
329
|
+
* is `policy.hosts` because the profile-level `order` list is gone - but
|
|
330
|
+
* §8.4's per-feature `order` carries `before`/`after` string lists, so
|
|
331
|
+
* the reach is three lists, not one, and §4.5 pins `order: [7]`
|
|
332
|
+
* accordingly. Applied where the shape cannot reach, raising the same
|
|
333
|
+
* code the shape would, and pinned in the corpus so the workaround is
|
|
334
|
+
* removed deliberately when struct is fixed rather than forgotten. */
|
|
335
|
+
function firstelement(list: any, path: string, invalid: string[]): void {
|
|
336
|
+
if (!Array.isArray(list) || 0 === list.length) { return }
|
|
337
|
+
if ('string' === typeof list[0]) { return }
|
|
338
|
+
invalid.push('Expected field ' + path + '.0 to be string, but found ' +
|
|
339
|
+
kindof(list[0]) + ': ' + JSON.stringify(list[0]))
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Recursive over EVERY nested map and list, not just the top level -
|
|
343
|
+
* a credential one level down is the case a top-level scan misses. */
|
|
344
|
+
function scan(
|
|
345
|
+
node: any, path: string, secrets: string[], reserved: string[]
|
|
346
|
+
): void {
|
|
347
|
+
if (Array.isArray(node)) {
|
|
348
|
+
for (let i = 0; i < node.length; i++) {
|
|
349
|
+
scan(node[i], path + '.' + i, secrets, reserved)
|
|
350
|
+
}
|
|
351
|
+
return
|
|
352
|
+
}
|
|
353
|
+
if ('string' === typeof node) { userinfo(node, path, secrets); return }
|
|
354
|
+
if (!ismap(node)) { return }
|
|
355
|
+
|
|
356
|
+
for (const key of Object.keys(node)) {
|
|
357
|
+
const kpath = path + '.' + key
|
|
358
|
+
const val = node[key]
|
|
359
|
+
|
|
360
|
+
// §8.6: station owns feature composition, so an `options.feature`
|
|
361
|
+
// in a declarative config is a second, unreconciled ordering input.
|
|
362
|
+
if ('feature' === key) {
|
|
363
|
+
reserved.push(kpath + ' is reserved: configure features under the ' +
|
|
364
|
+
'block\'s own `feature` key, not through `options`')
|
|
365
|
+
continue
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if ('secret' === key.toLowerCase()) {
|
|
369
|
+
secretvalue(val, kpath, secrets)
|
|
370
|
+
continue
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (credentialkey(key)) {
|
|
374
|
+
secrets.push(kpath + ' is a credential-shaped key: station.json ' +
|
|
375
|
+
'holds secret NAMES, never values (§5.2)')
|
|
376
|
+
continue
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
scan(val, kpath, secrets, reserved)
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function credentialkey(key: string): boolean {
|
|
384
|
+
const low = String(key).toLowerCase().replace(/[^a-z0-9]+/g, '')
|
|
385
|
+
if (CREDENTIAL_KEYS.includes(low)) { return true }
|
|
386
|
+
const tok = envtoken(key)
|
|
387
|
+
return CREDENTIAL_SUFFIX.some((s) => tok.endsWith(s))
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** A `secret`-named key holds a NAME, and that exemption is not a
|
|
391
|
+
* loophole - it is the whole design. Two checks, not one, and they live
|
|
392
|
+
* in the same three lines precisely so a port cannot implement only the
|
|
393
|
+
* first and inherit the gap the second exists to close. */
|
|
394
|
+
function secretvalue(val: any, path: string, secrets: string[]): void {
|
|
395
|
+
if ('string' !== typeof val) {
|
|
396
|
+
secrets.push(path + ' must be a secret name (a string), but found ' +
|
|
397
|
+
kindof(val))
|
|
398
|
+
return
|
|
399
|
+
}
|
|
400
|
+
if (!validname(val)) {
|
|
401
|
+
secrets.push(path + ' is not a valid sekreto name, so it cannot be a ' +
|
|
402
|
+
'name and must not be a value: ' + JSON.stringify(val))
|
|
403
|
+
return
|
|
404
|
+
}
|
|
405
|
+
if (UNBROKEN_RUN.test(val)) {
|
|
406
|
+
secrets.push(path + ' contains an unbroken alphanumeric run of ' +
|
|
407
|
+
RUN_BOUND + ' or more characters, which is not a name anybody writes')
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** One rule about values rather than keys, because the `proxy` feature
|
|
412
|
+
* makes it concrete: `http://user:pass@proxy.internal:8080`. */
|
|
413
|
+
function userinfo(val: string, path: string, secrets: string[]): void {
|
|
414
|
+
if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(val)) { return }
|
|
415
|
+
let u: URL
|
|
416
|
+
try { u = new URL(val) } catch (e) { return }
|
|
417
|
+
if ('' !== u.username || '' !== u.password) {
|
|
418
|
+
secrets.push(path + ' is a URL carrying userinfo, which puts a ' +
|
|
419
|
+
'credential in the config file; use the proxy feature\'s ' +
|
|
420
|
+
'`fromEnv` option instead (§8.6)')
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function kindof(v: any): string {
|
|
425
|
+
if (null === v) { return 'null' }
|
|
426
|
+
if (Array.isArray(v)) { return 'list' }
|
|
427
|
+
if ('number' === typeof v) {
|
|
428
|
+
return Number.isInteger(v) ? 'integer' : 'decimal'
|
|
429
|
+
}
|
|
430
|
+
return typeof v
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function ismap(v: any): boolean {
|
|
434
|
+
return null != v && 'object' === typeof v && !Array.isArray(v)
|
|
435
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Shared station types. Descriptor and StationEvent shapes are pinned by
|
|
2
|
+
// the conformance corpus (spec/station.json) - evolve them additively.
|
|
3
|
+
|
|
4
|
+
export type StationOptions = {
|
|
5
|
+
profile?: string
|
|
6
|
+
proxy?: 'auto' | 'off' | 'require' | string
|
|
7
|
+
folder?: string
|
|
8
|
+
config?: StationConfig | null
|
|
9
|
+
// §6.3: disables the loader outright. Only self-registered and
|
|
10
|
+
// explicitly provided factories are used.
|
|
11
|
+
load?: boolean
|
|
12
|
+
// Set when the config came from ~/.voxgig/station.json rather than
|
|
13
|
+
// from inside the repo. A user-level file is outside the repo's
|
|
14
|
+
// review boundary, so a `package` key arriving from it is IGNORED
|
|
15
|
+
// WITH A WARNING rather than imported - everything else in it still
|
|
16
|
+
// applies.
|
|
17
|
+
repoScoped?: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// station.json shape (profiles carry sekreto ProviderSpecs verbatim).
|
|
21
|
+
export type StationConfig = {
|
|
22
|
+
station: number
|
|
23
|
+
profiles?: Record<string, Profile>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type Profile = {
|
|
27
|
+
secrets?: { providers?: any[] }
|
|
28
|
+
feature?: Record<string, any>
|
|
29
|
+
// Keyed by api slug: defaults inherited by every instance of that
|
|
30
|
+
// api, declaring no instance of its own (design §3.1).
|
|
31
|
+
api?: Record<string, SdkBlock>
|
|
32
|
+
// Keyed by REF (`api$tag`, or bare `api` for the untagged instance).
|
|
33
|
+
// This replaces `plugin`, which is REMOVED rather than aliased (§3.4)
|
|
34
|
+
// - a deprecated alias would be a second grammar for one concept in
|
|
35
|
+
// 17 ports. An untagged ref is an api slug, so every existing block
|
|
36
|
+
// means exactly what it meant before under the new key.
|
|
37
|
+
sdk?: Record<string, SdkBlock>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The same key set in both block positions. The two differ in what
|
|
41
|
+
// they KEY, not in what they hold, which is why the two spec objects in
|
|
42
|
+
// config-shape.json are identical and a guard test asserts it (§3.1).
|
|
43
|
+
// Per-plugin policy (design §16). The solo-expressible keys:
|
|
44
|
+
// `hosts` and `mode` gate the operation path in the library; `allow`
|
|
45
|
+
// is set into the SDK's own `options.allow` so enforcement rides the
|
|
46
|
+
// SDK's own pipeline; `budget` configures the SDK's `ratelimit`
|
|
47
|
+
// feature through the ordinary feature composition. `record`,
|
|
48
|
+
// `replay` and `mock` are proxy-era vocabulary, accepted now so a
|
|
49
|
+
// config written for the proxy is not a validation error, and failing
|
|
50
|
+
// `station_no_proxy` on the operation path until one is attached.
|
|
51
|
+
export type PolicyBlock = {
|
|
52
|
+
hosts?: string[]
|
|
53
|
+
allow?: { op?: string[], method?: string[] }
|
|
54
|
+
budget?: { rps?: number, concurrency?: number }
|
|
55
|
+
mode?: 'live' | 'record' | 'replay' | 'mock' | 'block'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type SdkBlock = {
|
|
59
|
+
package?: string
|
|
60
|
+
export?: string
|
|
61
|
+
base?: string
|
|
62
|
+
secret?: string
|
|
63
|
+
resolve?: 'library' | 'proxy'
|
|
64
|
+
policy?: PolicyBlock
|
|
65
|
+
// Read by the PROXY out of this very file (design §8.3: the proxy
|
|
66
|
+
// loads profiles ITSELF): the capture depth it records this
|
|
67
|
+
// instance's exchanges at (§8.5), and §16's per-instance agent write
|
|
68
|
+
// opt-in, which a mutating agent operation needs alongside the
|
|
69
|
+
// daemon's own --agent-write flag. They belong to the SHARED shape
|
|
70
|
+
// because one station.json serves both the application and the
|
|
71
|
+
// proxy; a shape that rejected them would make that impossible.
|
|
72
|
+
capture?: 'meta' | 'headers' | 'full'
|
|
73
|
+
agent?: { write: boolean }
|
|
74
|
+
feature?: Record<string, any>
|
|
75
|
+
options?: Record<string, any>
|
|
76
|
+
// `active: false` means BARRED FROM RUNNING - a declaration that stays
|
|
77
|
+
// in the file and in instances() while being refused a client. It is
|
|
78
|
+
// not a runtime state; voxgig/plugin's lifecycle status is `live`
|
|
79
|
+
// precisely so this key can keep the name it already has.
|
|
80
|
+
active?: boolean
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Descriptor v1 (design §4): a view over the SDK's embedded config,
|
|
84
|
+
// normalized - never a second model.
|
|
85
|
+
export type Descriptor = {
|
|
86
|
+
station: 1
|
|
87
|
+
name: string
|
|
88
|
+
slug: string
|
|
89
|
+
envtoken: string
|
|
90
|
+
version: string
|
|
91
|
+
target: string
|
|
92
|
+
base: string
|
|
93
|
+
server: { name: string, value: string }[]
|
|
94
|
+
auth: { active: boolean, prefix: string, secretname: string }
|
|
95
|
+
entities: Record<string, DescriptorEntity>
|
|
96
|
+
// `options` is the feature's declared key set with typed defaults
|
|
97
|
+
// (§8.5's schema); `transport` its role (§8.4). Both additive, and
|
|
98
|
+
// absent when the SDK does not carry them.
|
|
99
|
+
features: {
|
|
100
|
+
name: string, active: boolean,
|
|
101
|
+
options?: Record<string, any>, transport?: string,
|
|
102
|
+
}[]
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export type DescriptorEntity = {
|
|
106
|
+
fields: Record<string, { kind: string }>
|
|
107
|
+
ops: Record<string, { points: DescriptorPoint[] }>
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export type DescriptorPoint = {
|
|
111
|
+
method: string
|
|
112
|
+
path: string
|
|
113
|
+
params: string[]
|
|
114
|
+
select?: Record<string, any>
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// StationEvent v1 (design §6). Unknown fields are ignored by consumers.
|
|
118
|
+
export type StationEvent = {
|
|
119
|
+
t: number
|
|
120
|
+
// The INSTANCE name (§7.3). Keeps its name, so a consumer that only
|
|
121
|
+
// knows `plugin` keeps working - it simply sees instance-grained
|
|
122
|
+
// events, which is what it wants at 20 SDKs anyway.
|
|
123
|
+
plugin?: string
|
|
124
|
+
// The api slug, for grouping. ADDITIVE, so station.md §8.6's wire
|
|
125
|
+
// compatibility rule holds.
|
|
126
|
+
api?: string
|
|
127
|
+
corr?: string
|
|
128
|
+
kind: 'construct' | 'op' | 'http' | 'error' | 'feature' | 'station'
|
|
129
|
+
op?: { entity: string, op: string, outcome: string, durationMs: number }
|
|
130
|
+
http?: {
|
|
131
|
+
method: string, host: string, path: string,
|
|
132
|
+
status: number, durationMs: number, bytes: number
|
|
133
|
+
}
|
|
134
|
+
err?: { code?: string, status?: number, message: string }
|
|
135
|
+
meta?: Record<string, any>
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// What registration hands back to the adapter (design §3 item 1).
|
|
139
|
+
export type Binding = {
|
|
140
|
+
// The INSTANCE name (a ref). Keeps the field name for wire
|
|
141
|
+
// compatibility - a consumer that only knows `plugin` keeps working
|
|
142
|
+
// and simply sees instance-grained bindings (§7.3).
|
|
143
|
+
plugin: string
|
|
144
|
+
// The api slug, for grouping. Additive.
|
|
145
|
+
instance?: string
|
|
146
|
+
base?: string
|
|
147
|
+
placeholder?: string
|
|
148
|
+
// The instance's EFFECTIVE secret name, and the authority for it
|
|
149
|
+
// (§7.4). A descriptor is shared by every instance of one api and so
|
|
150
|
+
// cannot hold two different instance-derived names; the descriptor's
|
|
151
|
+
// own `auth.secretname` stays the api-level default, which is
|
|
152
|
+
// documentation.
|
|
153
|
+
secretname?: string
|
|
154
|
+
rung: 'none' | 'R1'
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export type PluginEntry = {
|
|
158
|
+
// The instance name - the registry key (§7.1).
|
|
159
|
+
name: string
|
|
160
|
+
// The api slug. One descriptor per api is shared by every instance of
|
|
161
|
+
// it, so this is what `descriptorOf` and the cache key on.
|
|
162
|
+
api: string
|
|
163
|
+
descriptor: Descriptor
|
|
164
|
+
rung: 'none' | 'R1'
|
|
165
|
+
client: any
|
|
166
|
+
warnings: string[]
|
|
167
|
+
// The instance's effective secret name, mirroring Binding.secretname
|
|
168
|
+
// so `plugins()` can report it without re-deriving.
|
|
169
|
+
secretname?: string
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// A declared instance, resolved (§6.1's `instances()`).
|
|
173
|
+
export type ResolvedInstance = {
|
|
174
|
+
name: string
|
|
175
|
+
api: string
|
|
176
|
+
// `active: false` means BARRED FROM RUNNING - declared, visible, and
|
|
177
|
+
// refused a client. Not a runtime state.
|
|
178
|
+
active: boolean
|
|
179
|
+
// Exactly plugin's `status == "live"`; the two answer different
|
|
180
|
+
// questions and the answers differ routinely, since a lazily-started
|
|
181
|
+
// instance is active and not yet live.
|
|
182
|
+
live: boolean
|
|
183
|
+
rung: 'none' | 'R1'
|
|
184
|
+
block: SdkBlock
|
|
185
|
+
}
|