@voxgig/station 0.1.0 → 0.2.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.
Files changed (44) hide show
  1. package/dist/src/Station.d.ts +1 -118
  2. package/dist/src/Station.js +26 -412
  3. package/dist/src/Station.js.map +1 -1
  4. package/dist/src/adapter.js +0 -54
  5. package/dist/src/adapter.js.map +1 -1
  6. package/dist/src/config-shape.js +0 -11
  7. package/dist/src/config-shape.js.map +1 -1
  8. package/dist/src/descriptor.js +0 -38
  9. package/dist/src/descriptor.js.map +1 -1
  10. package/dist/src/error.js +0 -5
  11. package/dist/src/error.js.map +1 -1
  12. package/dist/src/factory.d.ts +0 -9
  13. package/dist/src/factory.js +0 -37
  14. package/dist/src/factory.js.map +1 -1
  15. package/dist/src/feature.d.ts +0 -75
  16. package/dist/src/feature.js +0 -87
  17. package/dist/src/feature.js.map +1 -1
  18. package/dist/src/index.js +0 -7
  19. package/dist/src/index.js.map +1 -1
  20. package/dist/src/loader.d.ts +0 -29
  21. package/dist/src/loader.js +0 -66
  22. package/dist/src/loader.js.map +1 -1
  23. package/dist/src/profile.js +0 -91
  24. package/dist/src/profile.js.map +1 -1
  25. package/dist/src/secrets.d.ts +5 -0
  26. package/dist/src/secrets.js +106 -32
  27. package/dist/src/secrets.js.map +1 -1
  28. package/dist/src/shape.d.ts +0 -22
  29. package/dist/src/shape.js +0 -113
  30. package/dist/src/shape.js.map +1 -1
  31. package/package.json +3 -3
  32. package/src/Station.ts +28 -413
  33. package/src/adapter.ts +0 -57
  34. package/src/config-shape.ts +0 -11
  35. package/src/descriptor.ts +0 -38
  36. package/src/error.ts +0 -5
  37. package/src/factory.ts +0 -37
  38. package/src/feature.ts +0 -87
  39. package/src/index.ts +0 -7
  40. package/src/loader.ts +0 -66
  41. package/src/profile.ts +0 -95
  42. package/src/secrets.ts +104 -32
  43. package/src/shape.ts +0 -113
  44. package/src/types.ts +0 -59
package/src/Station.ts CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  resolveorder,
13
13
  } from './feature'
14
14
  import { normalizeConfig, validateConfig } from './shape'
15
- import { SecretBroker, placeholderFor } from './secrets'
15
+ import { SecretBroker, placeholderFor, substitute } from './secrets'
16
16
  import {
17
17
  Binding, PluginEntry, ResolvedInstance, SdkBlock, StationEvent,
18
18
  StationOptions,
@@ -23,24 +23,6 @@ import {
23
23
  // deferred amplifier - `require` therefore fails on the operation path
24
24
  // (design §2.1/§14), and `auto` degrades to solo with one warning event.
25
25
 
26
- /** §6.1: `as` IS A TAG, NOT A FREE NAME.
27
- *
28
- * The api comes from the SDK being passed, so the resulting ref is
29
- * `<api>$<tag>` and multi-instance works imperatively too. A full ref is
30
- * also accepted and is VALIDATED: its name must equal the SDK's api
31
- * slug, or it is `station_instance_api`.
32
- *
33
- * Both forms are needed because the imperative path is where the api is
34
- * implicit in an argument rather than written in a key. An `as` that
35
- * took an arbitrary name would reintroduce exactly the second-identity
36
- * problem the ref re-key removed: under the ref invariant
37
- * `as: 'solar-eu'` would denote the untagged `solar-eu` DEFINITION, not
38
- * an instance of the SDK just handed in, and registry grouping, api
39
- * defaults and every ref consumer would disagree about what it is.
40
- *
41
- * A bare connect(SDK) with no name falls back to the descriptor slug,
42
- * which is today's behaviour and why the single-instance case is
43
- * unchanged to the byte. */
44
26
  export function instanceRef(api: string, fopts?: any): string {
45
27
  const explicit = firstNonEmpty(fopts?.instance)
46
28
  if (null != explicit) { return checkref(checkapi(api, explicit)) }
@@ -57,31 +39,9 @@ export function instanceRef(api: string, fopts?: any): string {
57
39
  return api
58
40
  }
59
41
 
60
- // A full ref is validated against the api; a `$`-less string is a TAG
61
- // and is joined to it.
62
- //
63
- // §6.1 gives both branches and does not say how to disambiguate a
64
- // `$`-less string, which is a real ambiguity because a bare name is
65
- // itself a valid (untagged) ref: `as: 'stripe'` on api `stripe` could
66
- // read as the untagged ref `stripe` or as tag `stripe`. It is read as
67
- // a TAG, giving `stripe$stripe`, because §6.1 says twice and
68
- // emphatically that `as` is a tag rather than a free name, and a rule
69
- // with no exceptions is the one that ports the same way twenty times.
70
- // The alternative - collapsing when the tag happens to equal the api -
71
- // is a special case that would make `as` mean different things at
72
- // different values. Someone who wants the untagged instance passes no
73
- // `as` at all, which is the documented spelling for it.
74
42
  return checkref(-1 === as.indexOf('$') ? api + '$' + as : checkapi(api, as))
75
43
  }
76
44
 
77
- // The ref grammar is the JOINT identity model's (station-and-plugin.md
78
- // §2, plugin design §4): a name is a package-ish specifier
79
- // (`^[a-zA-Z@][a-zA-Z0-9.~_\-/]*$`), a tag is not (`^[a-zA-Z0-9.~_-]+$`
80
- // or empty - it MAY start with a digit because auto-tagging assigns
81
- // integer tags, and admits neither `@` nor `/`); both cap at 1024; the
82
- // split is on the FIRST `$`, so `a$b$c` is a good name with a bad tag.
83
- // Discharges the C4 divergence rows that pinned instanceRef accepting
84
- // what plugin's grammar rejects.
85
45
  const REF_NAME_RE = /^[a-zA-Z@][a-zA-Z0-9.~_\-\/]*$/
86
46
  const REF_TAG_RE = /^[a-zA-Z0-9.~_-]+$/
87
47
  const REF_MAX = 1024
@@ -94,8 +54,6 @@ export function checkInstanceName(name: string): boolean {
94
54
 
95
55
  export function checkInstanceTag(tag: string): boolean {
96
56
  if ('string' !== typeof tag) { return false }
97
- // The empty tag is an ordinary tag: the single-instance case writes
98
- // no tag and never learns tags exist.
99
57
  if (0 === tag.length) { return true }
100
58
  if (REF_MAX < tag.length) { return false }
101
59
  return REF_TAG_RE.test(tag)
@@ -134,14 +92,6 @@ export class Station {
134
92
  private static ambient: Station | null = null
135
93
  private static ambientOpts: string | null = null
136
94
 
137
- /** §6.2's second path, and the front door the docs name.
138
- *
139
- * The package exported only the free `provide()` and the class had no
140
- * static — so the documented `Station.provide(...)` call failed, and
141
- * so did the remediation `station_no_factory` prints, which tells the
142
- * user to make exactly that call. Delegates to the same
143
- * process-global table the free function fills; there is one
144
- * registry, not two. */
145
95
  static provide(api: string, factory: any): void {
146
96
  provide(api, factory)
147
97
  }
@@ -151,7 +101,6 @@ export class Station {
151
101
  private broker: SecretBroker
152
102
  private buffer: EventBuffer
153
103
  private registry = new Map<string, PluginEntry>()
154
- // §6.1: `sdk(name)` caches; `create()` deliberately does not.
155
104
  private clients = new Map<string, any>()
156
105
  // The raw config, kept for §8.7's provenance: the resolved profile
157
106
  // has already collapsed the levels that provenance has to name.
@@ -193,7 +142,6 @@ export class Station {
193
142
  return Station.ambient
194
143
  }
195
144
 
196
- // Test seam: drop the ambient instance.
197
145
  static reset(): void {
198
146
  Station.ambient = null
199
147
  Station.ambientOpts = null
@@ -206,29 +154,11 @@ export class Station {
206
154
  ? this.opts.config
207
155
  : loadConfig(this.opts.folder)
208
156
 
209
- // §6.3: an in-code config is repo-scoped by construction - the
210
- // application wrote it. A file is repo-scoped unless it came from
211
- // ~/.voxgig/station.json.
212
- // Explicit wins, then an in-code config (the application wrote it,
213
- // so it is repo-scoped by construction), then where the file was
214
- // found. Inferring BEFORE reading the explicit option was a real
215
- // precedence bug: it made `repoScoped: false` unsettable for any
216
- // caller passing a config in code, which is every test of the rule.
217
157
  this.repoScoped = this.opts.repoScoped
218
158
  ?? (undefined !== this.opts.config
219
159
  ? true
220
160
  : 'user' !== configScope(this.opts.folder))
221
161
 
222
- // Normalize, then validate (design §4.2). A malformed station.json
223
- // fails open() with EVERY error at once - an eighteen-instance
224
- // config must not die because the eighteenth has a typo'd package
225
- // name.
226
- //
227
- // resolveProfile then reads the RAW config, NOT the normalized one.
228
- // The normalized form is an input to validation and to nothing
229
- // else: block defaults synthesized before the profile merge would
230
- // let a one-key overlay overwrite the base's `active: false` and
231
- // silently re-enable a barred integration (§3.3, §4.2).
232
162
  if (null != config) {
233
163
  validateConfig(normalizeConfig(config))
234
164
  }
@@ -251,7 +181,6 @@ export class Station {
251
181
  }
252
182
  }
253
183
 
254
- // --- binding forms (design §3.1) ---
255
184
 
256
185
  // connect(SDK, opts): station constructs the SDK itself, activating
257
186
  // the adapter with §3.3 ordering. The activation entry plus the
@@ -273,36 +202,27 @@ export class Station {
273
202
  throw new StationError('station_no_plugin', 'station is closed')
274
203
  }
275
204
  opts = opts || {}
276
- const fmap = { ...(opts.feature || {}) }
205
+ const { as, instance, ...sdkopts } = opts
206
+ const fmap = { ...(sdkopts.feature || {}) }
277
207
  fmap['station'] = {
278
208
  ...(fmap['station'] || {}),
279
209
  active: true, station: this, calleropts: opts,
280
- // §6.1: `as` is a TAG, resolved against the api in _register -
281
- // the api comes from the SDK being passed and is not knowable
282
- // here until that SDK's config has been normalized.
283
- ...(null == opts.as ? {} : { as: opts.as }),
284
- ...(null == opts.instance ? {} : { instance: opts.instance }),
210
+ // §6.1: `as` is a tag, resolved against the SDK's api in _register.
211
+ ...(null == as ? {} : { as }),
212
+ ...(null == instance ? {} : { instance }),
285
213
  }
286
214
  const options = {
287
- ...opts,
215
+ ...sdkopts,
288
216
  feature: fmap,
289
217
  // The carried adapter rides extend for SDKs generated WITHOUT
290
218
  // the station feature; when the generated class exists the
291
219
  // constructor uses it and the extend copy is skipped by name
292
220
  // (both delegate to featureBinding, so behavior is identical).
293
- extend: [...(opts.extend || []), adapterFeature(this, opts)],
221
+ extend: [...(sdkopts.extend || []), adapterFeature(this, opts)],
294
222
  }
295
223
  return new SDK(options)
296
224
  }
297
225
 
298
- // Inverted binding (design §3.1): build the plain options map a
299
- // generated constructor already accepts - the handle, the activation
300
- // entry, and the profile's per-plugin base (applied here, at
301
- // options-build time, so caller opts passed in `extra` still win).
302
- // §6.1: `options(instanceName?, extra?)`. The name is optional and
303
- // leading, so every existing `options({...})` call is unchanged - the
304
- // inverted binding is the static languages' path and they need to say
305
- // which instance they are building without a second method.
306
226
  options(a?: any, b?: any): any {
307
227
  const named = 'string' === typeof a
308
228
  const instance: string | undefined = named ? a : undefined
@@ -316,15 +236,7 @@ export class Station {
316
236
  return { ...extra, feature: fmap }
317
237
  }
318
238
 
319
- // --- registration (design §3 item 1, called by the adapter) ---
320
239
 
321
- // The registry entry whose client IS this object, or null. Used by
322
- // featureBinding for idempotency: connect/adopt activate the station
323
- // entry AND ride the carried adapter on extend, so on an SDK whose
324
- // generated config carries a real station feature class the same
325
- // construction reaches featureBinding twice - the second arrival
326
- // must no-op, while a genuinely second client of the same SDK class
327
- // still fails _register's slug check (§10.2).
328
240
  _boundEntry(client: any): PluginEntry | null {
329
241
  for (const entry of this.registry.values()) {
330
242
  if (entry.client === client) { return entry }
@@ -332,30 +244,11 @@ export class Station {
332
244
  return null
333
245
  }
334
246
 
335
- /** The profile block that governs an instance — its own if the
336
- * profile declares it, otherwise its **api's**.
337
- *
338
- * `resolveProfile` builds `profile.sdk` from the declared refs alone
339
- * ("an api block declares no instance, so the ref set comes from the
340
- * two `sdk` maps"), shallow-merging `profile.api[a]` into each. That
341
- * is right for a declared instance and leaves an IMPERATIVE one —
342
- * `connect(SDK, { as: 'test' })`, named but never written into
343
- * config — with no block at all. The api-level `secret`, `base` and
344
- * most seriously `policy.hosts` then did not reach it, so a profile
345
- * that denies egress everywhere denied nothing for a tagged client.
346
- *
347
- * ONE RULE, ONE PLACE: registration and the transport seam both ask
348
- * here, because they disagreeing is how the credential and the
349
- * allowlist came apart in the first place. */
350
247
  private blockFor(name: string): SdkBlock | undefined {
351
248
  return this.profile.sdk[this.declaredRef(name)] ??
352
249
  this.profile.api[refapi(name)]
353
250
  }
354
251
 
355
- /** The DECLARED instance an assigned tag stands for, or the name
356
- * itself. `create('stripe$prod')` registers under `stripe$1`, and
357
- * every question about that client's configuration — its secret, its
358
- * base, its egress policy — is a question about `stripe$prod`. */
359
252
  private declaredRef(name: string): string {
360
253
  return this.aliasOf.get(name) ?? name
361
254
  }
@@ -373,9 +266,6 @@ export class Station {
373
266
  // behaviour and why the single-instance case is unchanged.
374
267
  const name = instanceRef(api, fopts)
375
268
 
376
- // §7.1: the check moves to the instance key. Two clients of one api
377
- // is the NORMAL case now; two bindings of one instance is still the
378
- // error it was.
379
269
  if (this.registry.has(name)) {
380
270
  throw new StationError('station_bound_twice',
381
271
  'instance "' + name + '" is already registered; binding one client ' +
@@ -383,28 +273,6 @@ export class Station {
383
273
  }
384
274
 
385
275
  const profilePlugin = this.blockFor(name)
386
- // Secret name precedence: the feature option (in-code, design §9
387
- // config.options.secret) beats the profile, which beats the
388
- // INSTANCE-derived default.
389
- //
390
- // §5.1: `secretnameDefault` takes the instance name, not the slug.
391
- // For an untagged instance the two are the same string, so the
392
- // single-instance case is unchanged to the byte - `voxgig-solardemo`
393
- // still derives `voxgig_solardemo.apikey` and still reads
394
- // VOXGIG_SOLARDEMO_APIKEY. `stripe$test` derives
395
- // `stripe_test.apikey`, because each instance IS a separate
396
- // credentialed use of the API.
397
- //
398
- // The descriptor's own `auth.secretname` stays the API-level
399
- // default and is NOT used here (§7.4): one descriptor is shared by
400
- // every instance of an api and cannot hold two instance-derived
401
- // names, so whichever instance built the cached copy would report
402
- // the other's secret metadata wrongly. This is the authority; that
403
- // field is documentation.
404
- // ...and the DEFAULT takes the declared name too, not the assigned
405
- // tag: `stripe$1` created from `stripe$test` derives
406
- // `stripe_test.apikey`, so every per-request client of one instance
407
- // shares one broker cache entry (§5.3).
408
276
  const secretname = firstNonEmpty(fopts?.secret, profilePlugin?.secret) ||
409
277
  secretnameDefault(this.declaredRef(name))
410
278
 
@@ -412,9 +280,6 @@ export class Station {
412
280
  const binding: Binding = {
413
281
  plugin: name,
414
282
  instance: api,
415
- // §7.2: two live instances of one api MUST have distinct
416
- // placeholders or the injection seam cannot tell which credential
417
- // a header wants.
418
283
  placeholder: descriptor.auth.active ? placeholderFor(name) : undefined,
419
284
  secretname: descriptor.auth.active ? secretname : undefined,
420
285
  rung,
@@ -439,34 +304,8 @@ export class Station {
439
304
  return { binding, profilePlugin }
440
305
  }
441
306
 
442
- // §7.4: THE DESCRIPTOR IS SHARED, because it describes the api rather
443
- // than any use of it. `normalizeDescriptor` runs once per api and
444
- // every instance of that api holds a reference to the same object - at
445
- // 26 instances over 20 apis that is 20 normalizations, not 26, and the
446
- // canonical serialization the proxy dedupes registrations by is
447
- // computed once per api too.
448
- //
449
- // Keyed by the slug the config carries, which is available before
450
- // normalization only from the config itself, so the miss path
451
- // normalizes and then keys on the result.
452
307
  private descriptorCache = new Map<string, { descriptor: any, warnings: string[] }>()
453
308
 
454
- /** The descriptor describes the API, and is SHARED by every instance
455
- * of it — which is §7.4's own reasoning for why the effective secret
456
- * name moved to `Binding` and is not read off the descriptor.
457
- *
458
- * The same argument applies to feature activation, and this did not
459
- * follow it: the cache is keyed by slug alone while
460
- * `normalizeDescriptor`'s second argument decides each feature row's
461
- * `active`. So two instances of one api constructed with different
462
- * feature maps shared the FIRST one's feature state, and
463
- * `descriptorOf()` / `canonicalDescriptor()` became
464
- * construction-order-dependent.
465
- *
466
- * Normalized with no per-instance features, so the shared value holds
467
- * only API-stable metadata — which is what `factory.ts` already does
468
- * when it normalizes at provide time. Per-instance activation is
469
- * `featuresOf(name)`'s answer, and always was. */
470
309
  private describe(config: any, _feature: any):
471
310
  { descriptor: any, warnings: string[] } {
472
311
  const slug = String(config?.main?.slug ?? '')
@@ -491,7 +330,6 @@ export class Station {
491
330
  })
492
331
  }
493
332
 
494
- // --- the transport middleware (design §3.3, §5.3) ---
495
333
 
496
334
  async _transport(name: string, inner: any, fctx: any, fullurl: string,
497
335
  fetchdef: any): Promise<any> {
@@ -511,20 +349,6 @@ export class Station {
511
349
  const live = 'live' === fctx.client._mode
512
350
  const profilePlugin = this.blockFor(name)
513
351
 
514
- // Policy mode (design §16): `live` is the default; `block` is the
515
- // kill switch and refuses live egress outright. Enforced at the
516
- // same seam as the hosts allowlist, and like it only on LIVE
517
- // traffic - a test-mode client's mock transport is not egress.
518
- //
519
- // §14 names no mode-specific code, so `block` raises
520
- // `station_host_allow` - the closest existing catalog code: the
521
- // same `_allow` gate grammar, the same seam, the same meaning
522
- // (egress denied by this plugin's policy), with the message naming
523
- // the mode. `record`/`replay`/`mock` are proxy-era modes accepted
524
- // by the grammar now (so a config written for the proxy is not a
525
- // validation error) and fail `station_no_proxy` on the operation
526
- // path until a proxy is attached - the same fail-closed behaviour
527
- // `require` shows above.
528
352
  const mode = profilePlugin?.policy?.mode ?? 'live'
529
353
  if ('live' !== mode && live) {
530
354
  const err = 'block' === mode
@@ -538,12 +362,6 @@ export class Station {
538
362
  return err
539
363
  }
540
364
 
541
- // Egress policy (design §16), solo half: the hosts allowlist is
542
- // enforced at the seam every request crosses. When a policy is
543
- // present, redirects come back manual - a 3xx is a response like
544
- // any other, so a Location off the allowlist cannot pull an
545
- // automatic credentialed follow-up to an unapproved host (§8.2's
546
- // rule, applied at the library seam).
547
365
  const hosts = profilePlugin?.policy?.hosts
548
366
  if (null != hosts && live) {
549
367
  let hostname = ''
@@ -558,6 +376,8 @@ export class Station {
558
376
  }
559
377
 
560
378
  let senddef = fetchdef
379
+ let sendurl = fullurl
380
+ let placed = false
561
381
  if (null != hosts && live) {
562
382
  senddef = { ...senddef, redirect: 'manual' }
563
383
  }
@@ -568,20 +388,6 @@ export class Station {
568
388
  // enter in-memory mock stores. Copy-on-inject: the object graph
569
389
  // reachable from ctx/spec/ctrl keeps the placeholder, ever (§5.3).
570
390
  if (live && null != entry && 'R1' === entry.rung) {
571
- // §7.4: THE EFFECTIVE NAME, resolved once at registration and
572
- // stored on the entry. Re-deriving it here got the precedence
573
- // right and the FALLBACK wrong: `descriptor.auth.secretname` is
574
- // the API-level default, and one descriptor is shared by every
575
- // instance of an api — so a tagged instance with no explicit
576
- // `secret` read `stripe.apikey` where registration had recorded
577
- // `stripe_test.apikey`. Either the request fails despite the
578
- // credential being configured, or it succeeds with a sibling's.
579
- //
580
- // No fallback, and the `!` is the invariant rather than a shrug:
581
- // this branch is guarded by `'R1' === entry.rung`, which is set
582
- // only when `descriptor.auth.active` — the same condition under
583
- // which `entry.secretname` is populated. Substituting a different
584
- // name when it is absent is the bug above, written again.
585
391
  const secretname = entry.secretname!
586
392
 
587
393
  let value: string
@@ -593,13 +399,14 @@ export class Station {
593
399
  return e instanceof Error ? e : new Error(String(e))
594
400
  }
595
401
 
596
- senddef = { ...senddef, headers: { ...(senddef.headers || {}) } }
597
- for (const h of Object.keys(senddef.headers)) {
598
- const v = senddef.headers[h]
599
- if ('string' === typeof v && v.includes(placeholder)) {
600
- senddef.headers[h] = v.split(placeholder).join(value)
601
- }
402
+ const sent = substitute(fullurl, senddef.headers, placeholder, value)
403
+ sendurl = sent.url
404
+ senddef = { ...senddef, headers: sent.headers }
405
+ const defurl = senddef.url
406
+ if ('string' === typeof defurl) {
407
+ senddef.url = substitute(defurl, null, placeholder, value).url
602
408
  }
409
+ placed = sendurl !== fullurl || senddef.url !== defurl
603
410
  }
604
411
 
605
412
  const corr = fctx.station$?.corr
@@ -607,18 +414,18 @@ export class Station {
607
414
 
608
415
  let res: any
609
416
  try {
610
- res = await inner(fctx, fullurl, senddef)
417
+ res = await inner(fctx, sendurl, senddef)
611
418
  }
612
419
  catch (e: any) {
613
420
  this.emitHttp(name, corr, fullurl, senddef, 0, started, 0)
614
421
  this.emitErr(name, fctx, e)
615
- throw e
422
+ throw this.scrubbed(e, placed)
616
423
  }
617
424
 
618
425
  if (res instanceof Error) {
619
426
  this.emitHttp(name, corr, fullurl, senddef, 0, started, 0)
620
427
  this.emitErr(name, fctx, res)
621
- return res
428
+ return this.scrubbed(res, placed)
622
429
  }
623
430
 
624
431
  let bytes = 0
@@ -629,6 +436,14 @@ export class Station {
629
436
  return res
630
437
  }
631
438
 
439
+ // A transport error may quote its URL, which carries a query-placed value.
440
+ private scrubbed(err: any, placed: boolean): any {
441
+ if (placed && err instanceof Error) {
442
+ err.message = this.redact(err.message)
443
+ }
444
+ return err
445
+ }
446
+
632
447
  private emitHttp(slug: string, corr: string | undefined, fullurl: string,
633
448
  fetchdef: any, status: number, started: number, bytes: number): void {
634
449
  let host = '', path = ''
@@ -648,12 +463,6 @@ export class Station {
648
463
 
649
464
  private emitErr(name: string, fctx: any, err: any): void {
650
465
  this.emit({
651
- // §7.3's grouping contract: `plugin` is the INSTANCE and `api` is
652
- // what groups its siblings. Construction events carried both and
653
- // the runtime ones did not, so a consumer could group `construct`
654
- // by api and then get `api: undefined` for every request, error
655
- // and hoist from the same client — the grouping working exactly
656
- // until it was used.
657
466
  t: Date.now(), kind: 'error', plugin: name, api: refapi(name),
658
467
  corr: fctx?.station$?.corr,
659
468
  err: {
@@ -665,13 +474,10 @@ export class Station {
665
474
  })
666
475
  }
667
476
 
668
- // Op events from the hook bridge (design §3 item 3).
669
477
  _opEvent(name: string, ctx: any, outcome: string): void {
670
478
  const st = ctx.station$ || {}
671
479
  this.emit({
672
480
  t: Date.now(), kind: 'op', plugin: name, api: refapi(name), corr: st.corr,
673
- // ctx.op is the SDK's resolved Operation: name + entity, in the
674
- // descriptor's lowercase spelling.
675
481
  op: {
676
482
  entity: String(ctx.op?.entity ?? ctx.entity?.name ?? ''),
677
483
  op: String(ctx.op?.name ?? ''),
@@ -681,7 +487,6 @@ export class Station {
681
487
  })
682
488
  }
683
489
 
684
- // --- the query/observe surface (design §3.2, §6) ---
685
490
 
686
491
  // One entry per LIVE INSTANCE (§6.1), and exhaustive: auto-tagged
687
492
  // entries are not collapsed here, because inspection, health
@@ -706,15 +511,7 @@ export class Station {
706
511
  }))
707
512
  }
708
513
 
709
- // --- the declarative front door (design §6) ---
710
514
 
711
- /** The instance, constructed on first call and CACHED: same name ->
712
- * same object. That caching is what makes "get it where you need it"
713
- * a real instruction - call it in a request handler, in a worker, in
714
- * a test, and the first call pays construction while the rest are a
715
- * map lookup.
716
- *
717
- * SYNCHRONOUS (§6.3), which is what bounds the loader to CommonJS. */
718
515
  sdk(name: string): any {
719
516
  const cached = this.clients.get(name)
720
517
  if (null != cached) { return cached }
@@ -723,39 +520,10 @@ export class Station {
723
520
  return client
724
521
  }
725
522
 
726
- /** An UNCACHED client from the same resolved config plus overrides,
727
- * for the case that genuinely wants a distinct one - a per-request
728
- * credential scope, a test double. Deliberately the longer name.
729
- *
730
- * It registers under an AUTO-ASSIGNED TAG, because §7.5 registers
731
- * every constructed adapter under its instance name and
732
- * `station_bound_twice` fires on a second binding of one name: a
733
- * second `create('stripe')` would otherwise throw, which is exactly
734
- * the per-request case this exists for. The tag is the lowest unused
735
- * positive integer, so an auto-tagged instance is an ORDINARY
736
- * instance rather than a parallel identity scheme - `plugins()`, the
737
- * placeholder, the event stream and `station_bound_twice` all keep
738
- * working on one identity model.
739
- *
740
- * The SECRET NAME does not follow the assigned tag: it resolves from
741
- * the DECLARED instance the tag was assigned under, so every client
742
- * of one instance shares one broker cache entry rather than
743
- * re-resolving per request (§5.3). */
744
523
  create(name: string, overrides?: any): any {
745
524
  return this.build(name, this.autotag(name), overrides)
746
525
  }
747
526
 
748
- /** The lowest positive integer tag not already taken, by a LIVE
749
- * instance or a DECLARED one.
750
- *
751
- * The registry alone was not enough: a profile may declare
752
- * `stripe$1`, and until something constructs it `registry.has` says
753
- * false — so `create('stripe$prod')` took that identity for a client
754
- * built from the `stripe$prod` block. `instances()` then reported the
755
- * declared `stripe$1` as live with the wrong client, and a later
756
- * `sdk('stripe$1')` failed `station_bound_twice` against a binding
757
- * that was never its own. Declaration reserves the name whether or
758
- * not it has been built. */
759
527
  private autotag(name: string): string {
760
528
  const api = refapi(name)
761
529
  for (let n = 1; ; n++) {
@@ -786,38 +554,12 @@ export class Station {
786
554
  const api = refapi(name)
787
555
  const entry = this.resolveFactory(api, block)
788
556
 
789
- // §8.5 VALIDATES HERE, not only in `check()`. The schema arrives
790
- // with the factory, so the moment a factory is resolved is the
791
- // first moment validation is possible — and it was running in
792
- // `check()` alone. Two gaps followed: production `sdk()` silently
793
- // ignored an unknown option like `retry.retires`, and `check()`
794
- // itself missed the case where the factory is discovered by the
795
- // LOADER (its pre-check sees no registered factory, then `sdk()`
796
- // loads and constructs unvalidated). One call here closes both,
797
- // because every path to a constructor comes through this line.
798
557
  const faults = checkfeatures(this.featuresOf(name).merged, entry.descriptor)
799
558
  if (0 < faults.length) {
800
- // `Fault.code` is a plain string in `feature.ts`; the two codes it
801
- // actually produces are both in the union, and `check()` passes
802
- // the same value through untyped. Narrowed here rather than
803
- // widening the union, so a new fault code is a compile error at
804
- // the place that has to decide what it means.
805
559
  throw new StationError(faults[0].code as any,
806
560
  faults.map((f) => f.message).join('; '))
807
561
  }
808
562
 
809
- // §8.4: compose the merged feature map into the ORDERED form and
810
- // hand it to the constructor. No new seam - it is the same
811
- // `options.feature` map `connect()` already uses for station's own
812
- // placement, with more in it, and a JS object preserves the
813
- // insertion order of string keys so the order rides the map.
814
- //
815
- // Station's own entry is composed AFTER the user merge and always
816
- // wins (§8.4), which is why `station` is dropped here and added by
817
- // `options()`: a config file that can switch off the component
818
- // reading it is not a surface, it is a trap. `feature.station` is
819
- // already `station_feature_reserved` at validation, so this is the
820
- // second half of one rule rather than a second rule.
821
563
  const resolved = this.featuresOf(name)
822
564
  const fmap: { [k: string]: any } = {}
823
565
  for (const f of composefeatures(
@@ -833,37 +575,8 @@ export class Station {
833
575
  feature: { ...fmap, ...((overrides || {}).feature || {}) },
834
576
  }
835
577
 
836
- // §5.3, and `create`'s own doc comment: "the SECRET NAME does not
837
- // follow the assigned tag: it resolves from the DECLARED instance
838
- // the tag was assigned under."
839
- //
840
- // THE ALIAS IS RECORDED, NOT THE FIELDS. The first fix for this
841
- // carried the declared `secret` through the feature options and
842
- // stopped there — which left `policy`, `base` and everything else
843
- // behind, so an auto-tagged client silently lost its declared
844
- // instance's HOSTS ALLOWLIST and fell back to the wider api-level
845
- // one. Carrying configuration a field at a time is how that
846
- // happens; recording what the tag STANDS FOR is one rule that every
847
- // lookup already goes through.
848
- //
849
- // Only when the tag was ASSIGNED — a caller naming its own is
850
- // naming an instance, not aliasing one.
851
578
  if (null != as && as !== name) { this.aliasOf.set(as, name) }
852
579
 
853
- // ...AND THE CARRIED ADAPTER RIDES EXTEND, exactly as it does on
854
- // `connect`. §3.1's retrofit case — an SDK generated before the
855
- // station feature, which `factoryFromModule` explicitly supports —
856
- // has no generated feature to consume the `feature.station`
857
- // activation this path sets, so declarative `sdk()` either failed
858
- // on an unknown feature or returned an unregistered, unwrapped
859
- // client with no credential injection and no events. The imperative
860
- // path carried it and the declarative one did not, which is the
861
- // whole defect.
862
- //
863
- // Safe on a REGENERATED SDK too: the constructor uses its own
864
- // station feature and skips the extend copy by name, and both
865
- // delegate to `featureBinding`, whose `_boundEntry` check no-ops a
866
- // second arrival for the same client.
867
580
  const withAdapter = {
868
581
  ...opts,
869
582
  extend: [...((opts as any).extend || []), adapterFeature(this, opts)],
@@ -874,8 +587,6 @@ export class Station {
874
587
  return entry.construct(this.options(as ?? name, withAdapter))
875
588
  }
876
589
 
877
- /** §6.2's three paths, in order of preference: self-registration,
878
- * `Station.provide`, then the loader. */
879
590
  private resolveFactory(api: string, block: SdkBlock): FactoryEntry {
880
591
  const direct = factoryFor(api)
881
592
  if (null != direct) { return direct }
@@ -893,9 +604,6 @@ export class Station {
893
604
  'set `api.' + api + '.package` so the loader can import it')
894
605
  }
895
606
 
896
- /** `package` is honoured only from repo-scoped config (§6.3), and a
897
- * user-level one is IGNORED WITH A WARNING rather than imported - it
898
- * names code to load and sits outside the repo's review boundary. */
899
607
  private loaderPackage(api: string, block: SdkBlock): string | undefined {
900
608
  const pkg = block.package
901
609
  if (null == pkg || '' === pkg) { return undefined }
@@ -931,12 +639,6 @@ export class Station {
931
639
  }
932
640
  }
933
641
 
934
- /** The merged, ordered feature set for one instance, WITH
935
- * PROVENANCE (§8.7): which config level set each value.
936
- *
937
- * Provenance is the half that makes a fleet view usable rather than
938
- * merely correct - at 26 instances "why is retry off here" is the
939
- * question, and a merged map alone cannot answer it. */
940
642
  featuresOf(name: string): {
941
643
  ordered: string[]
942
644
  merged: { [k: string]: any }
@@ -956,8 +658,6 @@ export class Station {
956
658
  ]
957
659
  const sources = featuresources(base, overlay, api, name)
958
660
 
959
- // Last writer per (feature, key) wins, and the level that wrote it
960
- // is what `from` records.
961
661
  const from: { [k: string]: { [k: string]: string } } = {}
962
662
  sources.forEach((src, i) => {
963
663
  if (null == src || 'object' !== typeof src) { return }
@@ -971,23 +671,6 @@ export class Station {
971
671
 
972
672
  let merged = mergefeatures(sources)
973
673
 
974
- // Policy budget (design §16): rps/concurrency ceilings ride "the
975
- // SDK `ratelimit` feature, configured by station". Composed HERE,
976
- // into the merged map every consumer reads, rather than patched in
977
- // at construction alone - so `build()` orders it with the ordinary
978
- // constraint-and-band rules (ordering and the station pin hold),
979
- // `check()`'s §8.5 pass validates it against the SDK's own
980
- // declaration (a budget on an SDK with no ratelimit feature is
981
- // `station_feature_unknown`, not a setting that quietly did
982
- // nothing), and the §8.7 fleet view answers "is ratelimit on?"
983
- // truthfully, with `policy.budget` as the provenance level.
984
- //
985
- // `rps` maps to the token bucket's refill `rate` (per second -
986
- // the same unit); `concurrency` to its capacity `burst`, the
987
- // number of requests that can be in flight from a full bucket.
988
- // POLICY WINS over a `feature.ratelimit` config entry on the keys
989
- // it sets - it is enforcement, not a default - and other tuning
990
- // keys survive beside it.
991
674
  const budget = this.blockFor(name)?.policy?.budget
992
675
  if (null != budget && 'object' === typeof budget && !Array.isArray(budget)) {
993
676
  const prior = merged.ratelimit
@@ -1008,18 +691,6 @@ export class Station {
1008
691
  merged = { ...merged, ratelimit: entry }
1009
692
  }
1010
693
 
1011
- // THE IMPLICIT STATION ENTRY, added for ORDERING ONLY. `station` is
1012
- // never in `merged` — `feature.station` is reserved and rejected at
1013
- // validation (§8.4) — so `checkpin` found no station row and
1014
- // returned immediately, every time. It was a permanent no-op: a
1015
- // constraint like `retry.order.after: 'station'` was treated as
1016
- // vacuous rather than rejected, and the reported order omitted the
1017
- // one feature whose position is supposedly pinned.
1018
- //
1019
- // Added here rather than into `merged`, which stays the user's own
1020
- // merge result. `build` already filters `station` out of the map it
1021
- // hands the constructor, because §8.4 composes station's entry
1022
- // after the user merge and always wins.
1023
694
  const ordered = resolveorder({ ...merged, station: { active: true } })
1024
695
  checkpin(ordered)
1025
696
  return { ordered: ordered.map((o) => o.name), merged, from }
@@ -1028,16 +699,6 @@ export class Station {
1028
699
  /** The fleet feature view: instance x feature, effective options, and
1029
700
  * which config level set each (§8.7). */
1030
701
  features(filter?: string | { instance?: string, api?: string, feature?: string }): any[] {
1031
- // §8.7's documented shape is an OBJECT, and only the object form
1032
- // can express the question the view exists for: `{feature: 'debug'}`
1033
- // — "is debug on anywhere?", the one that is twenty greps today.
1034
- // The implementation took a bare string and compared it against
1035
- // both `name` and `api`, so every documented call returned an empty
1036
- // list, an object never equalling either string.
1037
- //
1038
- // The string form is kept as shorthand for "this instance or this
1039
- // api", because it is what the tests and the imperative path
1040
- // already use and it costs one line.
1041
702
  const f = 'string' === typeof filter
1042
703
  ? { instance: filter, api: filter, loose: true }
1043
704
  : { ...(filter || {}), loose: false }
@@ -1072,20 +733,12 @@ export class Station {
1072
733
  }))
1073
734
  }
1074
735
 
1075
- /** Eagerly resolve and construct every ACTIVE instance - for CI
1076
- * (§6.6). The point is to turn availability errors, which are
1077
- * deliberately deferred to first use, into one failure at a moment
1078
- * somebody is watching. */
1079
736
  check(): { ok: string[], failed: { name: string, code?: string, message: string }[] } {
1080
737
  const ok: string[] = []
1081
738
  const failed: { name: string, code?: string, message: string }[] = []
1082
739
  for (const row of this.instances()) {
1083
740
  if (!row.active) { continue }
1084
741
  try {
1085
- // §8.5 runs FIRST and needs no construction: the schema arrives
1086
- // with the factory, not with a live client, so a feature typo
1087
- // is a CI failure rather than a setting that quietly did
1088
- // nothing in production.
1089
742
  const entry = factoryFor(row.api)
1090
743
  if (null != entry) {
1091
744
  const faults = checkfeatures(
@@ -1109,43 +762,15 @@ export class Station {
1109
762
  return { ok, failed }
1110
763
  }
1111
764
 
1112
- /** Batch-resolve secrets for ACTIVE instances (§5.5).
1113
- *
1114
- * With no argument it warms the active ones only, because reaching
1115
- * for a credential belonging to a disabled integration is the wrong
1116
- * default. `warm(names)` warms exactly what it is given, inactive
1117
- * included, because an explicit name is an explicit request. */
1118
765
  async warm(names?: string[]): Promise<{ warmed: string[], missed: string[] }> {
1119
766
  const wanted = null != names
1120
767
  ? names
1121
768
  : this.instances().filter((r) => r.active).map((r) => r.name)
1122
769
 
1123
- // CONCURRENT, not serial. Awaiting inside the loop made `warm`
1124
- // cost the SUM of every provider round-trip, which defeats the one
1125
- // thing the method exists for: resolving the fleet in a batch at
1126
- // startup rather than paying per first-request. sekreto exposes no
1127
- // batch `get`, so concurrency is the available shape.
1128
- //
1129
- // Names are deduped first: the broker's resolution cache is keyed
1130
- // by SECRET NAME (§5.3), so several instances sharing one api-level
1131
- // `secret` should cost one round-trip — and firing them together
1132
- // would race past the cache and make several.
1133
770
  const plan: { name: string, secretname: string }[] = []
1134
771
  const warmed: string[] = []
1135
772
  const missed: string[] = []
1136
773
  for (const name of wanted) {
1137
- // THE REGISTRY IS THE AUTHORITY, and asking the profile instead
1138
- // was wrong twice: an IMPERATIVE instance has no `profile.sdk`
1139
- // block, so it was pushed to `missed` and never warmed at all —
1140
- // and the re-derivation dropped the in-code `secret` feature
1141
- // option, which beats the profile (§9). A registered instance
1142
- // already carries the resolved name.
1143
- // A NAME NOBODY DECLARED OR REGISTERED IS A MISS, not a lookup.
1144
- // Widening the fallback to cover imperative instances also let a
1145
- // typo — `stripe$prodd` — derive a secret name and call the
1146
- // provider, so a nonexistent instance could be reported `warmed`
1147
- // off a shared api-level credential. Registered OR declared, and
1148
- // nothing else.
1149
774
  const entry = this.registry.get(name)
1150
775
  if (null == entry && null == this.profile.sdk[name]) {
1151
776
  missed.push(name)
@@ -1231,12 +856,6 @@ export class Station {
1231
856
  return {
1232
857
  mode: 'solo',
1233
858
  profile: this.profile.name,
1234
- // §7.1: the registry is keyed by INSTANCE, so a status page that
1235
- // projects only `slug` shows two indistinguishable rows for
1236
- // `stripe$test` and `stripe$live` and omits the names it is keyed
1237
- // by — an operator cannot tell which one is unhealthy. `slug`
1238
- // stays for compatibility; `name` and `api` are what answer the
1239
- // question.
1240
859
  plugins: this.plugins().map((p) => ({
1241
860
  name: p.name, api: p.api, slug: p.slug, rung: p.rung,
1242
861
  })),
@@ -1252,10 +871,6 @@ export class Station {
1252
871
  this.broker.refresh()
1253
872
  }
1254
873
 
1255
- // close(): flush (solo: nothing in flight), then warn on profile
1256
- // plugin keys that matched no registered plugin - a typo'd key
1257
- // silently configuring nothing is the worst outcome for a
1258
- // secrets-and-policy file (design §11).
1259
874
  close(): void {
1260
875
  if (this.closed) { return }
1261
876
  for (const slug of Object.keys(this.profile.sdk)) {