@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.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +88 -0
  3. package/dist/src/Station.d.ts +238 -0
  4. package/dist/src/Station.js +1213 -0
  5. package/dist/src/Station.js.map +1 -0
  6. package/dist/src/adapter.d.ts +9 -0
  7. package/dist/src/adapter.js +171 -0
  8. package/dist/src/adapter.js.map +1 -0
  9. package/dist/src/config-shape.d.ts +1 -0
  10. package/dist/src/config-shape.js +329 -0
  11. package/dist/src/config-shape.js.map +1 -0
  12. package/dist/src/descriptor.d.ts +8 -0
  13. package/dist/src/descriptor.js +170 -0
  14. package/dist/src/descriptor.js.map +1 -0
  15. package/dist/src/error.d.ts +8 -0
  16. package/dist/src/error.js +60 -0
  17. package/dist/src/error.js.map +1 -0
  18. package/dist/src/events.d.ts +15 -0
  19. package/dist/src/events.js +46 -0
  20. package/dist/src/events.js.map +1 -0
  21. package/dist/src/factory.d.ts +24 -0
  22. package/dist/src/factory.js +79 -0
  23. package/dist/src/factory.js.map +1 -0
  24. package/dist/src/feature.d.ts +106 -0
  25. package/dist/src/feature.js +324 -0
  26. package/dist/src/feature.js.map +1 -0
  27. package/dist/src/index.browser.d.ts +14 -0
  28. package/dist/src/index.browser.js +64 -0
  29. package/dist/src/index.browser.js.map +1 -0
  30. package/dist/src/index.d.ts +14 -0
  31. package/dist/src/index.js +56 -0
  32. package/dist/src/index.js.map +1 -0
  33. package/dist/src/loader.d.ts +45 -0
  34. package/dist/src/loader.js +181 -0
  35. package/dist/src/loader.js.map +1 -0
  36. package/dist/src/profile.d.ts +6 -0
  37. package/dist/src/profile.js +77 -0
  38. package/dist/src/profile.js.map +1 -0
  39. package/dist/src/profilecore.d.ts +10 -0
  40. package/dist/src/profilecore.js +180 -0
  41. package/dist/src/profilecore.js.map +1 -0
  42. package/dist/src/secrets.d.ts +12 -0
  43. package/dist/src/secrets.js +93 -0
  44. package/dist/src/secrets.js.map +1 -0
  45. package/dist/src/shape.d.ts +42 -0
  46. package/dist/src/shape.js +444 -0
  47. package/dist/src/shape.js.map +1 -0
  48. package/dist/src/types.d.ts +138 -0
  49. package/dist/src/types.js +5 -0
  50. package/dist/src/types.js.map +1 -0
  51. package/package.json +60 -0
  52. package/src/Station.ts +1310 -0
  53. package/src/adapter.ts +183 -0
  54. package/src/config-shape.ts +326 -0
  55. package/src/descriptor.ts +179 -0
  56. package/src/error.ts +64 -0
  57. package/src/events.ts +45 -0
  58. package/src/factory.ts +93 -0
  59. package/src/feature.ts +321 -0
  60. package/src/index.browser.ts +56 -0
  61. package/src/index.ts +50 -0
  62. package/src/loader.ts +188 -0
  63. package/src/profile.ts +67 -0
  64. package/src/profilecore.ts +201 -0
  65. package/src/secrets.ts +96 -0
  66. package/src/shape.ts +435 -0
  67. 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
+ }