@voxgig/station 0.0.2 → 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 (55) hide show
  1. package/README.md +26 -32
  2. package/dist/src/Station.d.ts +1 -133
  3. package/dist/src/Station.js +46 -434
  4. package/dist/src/Station.js.map +1 -1
  5. package/dist/src/adapter.js +0 -54
  6. package/dist/src/adapter.js.map +1 -1
  7. package/dist/src/config-shape.js +0 -11
  8. package/dist/src/config-shape.js.map +1 -1
  9. package/dist/src/descriptor.js +0 -36
  10. package/dist/src/descriptor.js.map +1 -1
  11. package/dist/src/error.js +0 -5
  12. package/dist/src/error.js.map +1 -1
  13. package/dist/src/factory.d.ts +0 -9
  14. package/dist/src/factory.js +0 -37
  15. package/dist/src/factory.js.map +1 -1
  16. package/dist/src/feature.d.ts +0 -75
  17. package/dist/src/feature.js +0 -87
  18. package/dist/src/feature.js.map +1 -1
  19. package/dist/src/index.d.ts +1 -2
  20. package/dist/src/index.js +11 -22
  21. package/dist/src/index.js.map +1 -1
  22. package/dist/src/loader.d.ts +0 -29
  23. package/dist/src/loader.js +0 -66
  24. package/dist/src/loader.js.map +1 -1
  25. package/dist/src/profile.d.ts +10 -3
  26. package/dist/src/profile.js +97 -23
  27. package/dist/src/profile.js.map +1 -1
  28. package/dist/src/secrets.d.ts +5 -0
  29. package/dist/src/secrets.js +106 -32
  30. package/dist/src/secrets.js.map +1 -1
  31. package/dist/src/shape.d.ts +0 -22
  32. package/dist/src/shape.js +0 -113
  33. package/dist/src/shape.js.map +1 -1
  34. package/package.json +3 -4
  35. package/src/Station.ts +34 -441
  36. package/src/adapter.ts +0 -57
  37. package/src/config-shape.ts +0 -11
  38. package/src/descriptor.ts +0 -36
  39. package/src/error.ts +0 -5
  40. package/src/factory.ts +0 -37
  41. package/src/feature.ts +0 -87
  42. package/src/index.ts +1 -14
  43. package/src/loader.ts +0 -66
  44. package/src/profile.ts +115 -23
  45. package/src/secrets.ts +104 -32
  46. package/src/shape.ts +0 -113
  47. package/src/types.ts +0 -59
  48. package/dist/src/index.browser.d.ts +0 -14
  49. package/dist/src/index.browser.js +0 -64
  50. package/dist/src/index.browser.js.map +0 -1
  51. package/dist/src/profilecore.d.ts +0 -10
  52. package/dist/src/profilecore.js +0 -180
  53. package/dist/src/profilecore.js.map +0 -1
  54. package/src/index.browser.ts +0 -56
  55. package/src/profilecore.ts +0 -201
package/src/Station.ts CHANGED
@@ -3,8 +3,8 @@ import { StationError } from './error'
3
3
  import { EventBuffer } from './events'
4
4
  import { canonicalSerialize, normalizeDescriptor, secretnameDefault } from './descriptor'
5
5
  import {
6
- refapi, resolveProfile, selectProfile, ResolvedProfile,
7
- } from './profilecore'
6
+ configScope, loadConfig, refapi, resolveProfile, selectProfile, ResolvedProfile,
7
+ } from './profile'
8
8
  import { FactoryEntry, factoryFor, provide } from './factory'
9
9
  import { loadAsync, loadSync } from './loader'
10
10
  import {
@@ -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,45 +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
- /** The FILE half of config loading, behind a seam so this module never
27
- * imports it (§2.2, §17 Phase 1: no top-level `node:` imports reachable
28
- * from the browser entry). The NODE entry (`index.ts`) registers
29
- * `profile.ts`'s loadConfig/configScope at module load; the browser
30
- * entry registers nothing, so a browser `open()` with no explicit
31
- * `config` behaves exactly as node does when no station.json exists -
32
- * null config, repo-scoped - rather than inventing a third state.
33
- * Passing `config` (the documented browser spelling, §2.2) skips the
34
- * seam entirely on both entries, which is the pre-existing
35
- * explicit-config path unchanged. */
36
- export type ConfigFileIO = {
37
- loadConfig: (from?: string) => any
38
- configScope: (from?: string) => 'repo' | 'user' | 'none'
39
- }
40
-
41
- let configfileio: ConfigFileIO | null = null
42
-
43
- export function setConfigFileIO(io: ConfigFileIO): void {
44
- configfileio = io
45
- }
46
-
47
- /** §6.1: `as` IS A TAG, NOT A FREE NAME.
48
- *
49
- * The api comes from the SDK being passed, so the resulting ref is
50
- * `<api>$<tag>` and multi-instance works imperatively too. A full ref is
51
- * also accepted and is VALIDATED: its name must equal the SDK's api
52
- * slug, or it is `station_instance_api`.
53
- *
54
- * Both forms are needed because the imperative path is where the api is
55
- * implicit in an argument rather than written in a key. An `as` that
56
- * took an arbitrary name would reintroduce exactly the second-identity
57
- * problem the ref re-key removed: under the ref invariant
58
- * `as: 'solar-eu'` would denote the untagged `solar-eu` DEFINITION, not
59
- * an instance of the SDK just handed in, and registry grouping, api
60
- * defaults and every ref consumer would disagree about what it is.
61
- *
62
- * A bare connect(SDK) with no name falls back to the descriptor slug,
63
- * which is today's behaviour and why the single-instance case is
64
- * unchanged to the byte. */
65
26
  export function instanceRef(api: string, fopts?: any): string {
66
27
  const explicit = firstNonEmpty(fopts?.instance)
67
28
  if (null != explicit) { return checkref(checkapi(api, explicit)) }
@@ -78,31 +39,9 @@ export function instanceRef(api: string, fopts?: any): string {
78
39
  return api
79
40
  }
80
41
 
81
- // A full ref is validated against the api; a `$`-less string is a TAG
82
- // and is joined to it.
83
- //
84
- // §6.1 gives both branches and does not say how to disambiguate a
85
- // `$`-less string, which is a real ambiguity because a bare name is
86
- // itself a valid (untagged) ref: `as: 'stripe'` on api `stripe` could
87
- // read as the untagged ref `stripe` or as tag `stripe`. It is read as
88
- // a TAG, giving `stripe$stripe`, because §6.1 says twice and
89
- // emphatically that `as` is a tag rather than a free name, and a rule
90
- // with no exceptions is the one that ports the same way twenty times.
91
- // The alternative - collapsing when the tag happens to equal the api -
92
- // is a special case that would make `as` mean different things at
93
- // different values. Someone who wants the untagged instance passes no
94
- // `as` at all, which is the documented spelling for it.
95
42
  return checkref(-1 === as.indexOf('$') ? api + '$' + as : checkapi(api, as))
96
43
  }
97
44
 
98
- // The ref grammar is the JOINT identity model's (station-and-plugin.md
99
- // §2, plugin design §4): a name is a package-ish specifier
100
- // (`^[a-zA-Z@][a-zA-Z0-9.~_\-/]*$`), a tag is not (`^[a-zA-Z0-9.~_-]+$`
101
- // or empty - it MAY start with a digit because auto-tagging assigns
102
- // integer tags, and admits neither `@` nor `/`); both cap at 1024; the
103
- // split is on the FIRST `$`, so `a$b$c` is a good name with a bad tag.
104
- // Discharges the C4 divergence rows that pinned instanceRef accepting
105
- // what plugin's grammar rejects.
106
45
  const REF_NAME_RE = /^[a-zA-Z@][a-zA-Z0-9.~_\-\/]*$/
107
46
  const REF_TAG_RE = /^[a-zA-Z0-9.~_-]+$/
108
47
  const REF_MAX = 1024
@@ -115,8 +54,6 @@ export function checkInstanceName(name: string): boolean {
115
54
 
116
55
  export function checkInstanceTag(tag: string): boolean {
117
56
  if ('string' !== typeof tag) { return false }
118
- // The empty tag is an ordinary tag: the single-instance case writes
119
- // no tag and never learns tags exist.
120
57
  if (0 === tag.length) { return true }
121
58
  if (REF_MAX < tag.length) { return false }
122
59
  return REF_TAG_RE.test(tag)
@@ -155,14 +92,6 @@ export class Station {
155
92
  private static ambient: Station | null = null
156
93
  private static ambientOpts: string | null = null
157
94
 
158
- /** §6.2's second path, and the front door the docs name.
159
- *
160
- * The package exported only the free `provide()` and the class had no
161
- * static — so the documented `Station.provide(...)` call failed, and
162
- * so did the remediation `station_no_factory` prints, which tells the
163
- * user to make exactly that call. Delegates to the same
164
- * process-global table the free function fills; there is one
165
- * registry, not two. */
166
95
  static provide(api: string, factory: any): void {
167
96
  provide(api, factory)
168
97
  }
@@ -172,7 +101,6 @@ export class Station {
172
101
  private broker: SecretBroker
173
102
  private buffer: EventBuffer
174
103
  private registry = new Map<string, PluginEntry>()
175
- // §6.1: `sdk(name)` caches; `create()` deliberately does not.
176
104
  private clients = new Map<string, any>()
177
105
  // The raw config, kept for §8.7's provenance: the resolved profile
178
106
  // has already collapsed the levels that provenance has to name.
@@ -214,7 +142,6 @@ export class Station {
214
142
  return Station.ambient
215
143
  }
216
144
 
217
- // Test seam: drop the ambient instance.
218
145
  static reset(): void {
219
146
  Station.ambient = null
220
147
  Station.ambientOpts = null
@@ -225,32 +152,13 @@ export class Station {
225
152
 
226
153
  const config = undefined !== this.opts.config
227
154
  ? this.opts.config
228
- : (null != configfileio ? configfileio.loadConfig(this.opts.folder) : null)
229
-
230
- // §6.3: an in-code config is repo-scoped by construction - the
231
- // application wrote it. A file is repo-scoped unless it came from
232
- // ~/.voxgig/station.json.
233
- // Explicit wins, then an in-code config (the application wrote it,
234
- // so it is repo-scoped by construction), then where the file was
235
- // found. Inferring BEFORE reading the explicit option was a real
236
- // precedence bug: it made `repoScoped: false` unsettable for any
237
- // caller passing a config in code, which is every test of the rule.
155
+ : loadConfig(this.opts.folder)
156
+
238
157
  this.repoScoped = this.opts.repoScoped
239
158
  ?? (undefined !== this.opts.config
240
159
  ? true
241
- : (null == configfileio ||
242
- 'user' !== configfileio.configScope(this.opts.folder)))
243
-
244
- // Normalize, then validate (design §4.2). A malformed station.json
245
- // fails open() with EVERY error at once - an eighteen-instance
246
- // config must not die because the eighteenth has a typo'd package
247
- // name.
248
- //
249
- // resolveProfile then reads the RAW config, NOT the normalized one.
250
- // The normalized form is an input to validation and to nothing
251
- // else: block defaults synthesized before the profile merge would
252
- // let a one-key overlay overwrite the base's `active: false` and
253
- // silently re-enable a barred integration (§3.3, §4.2).
160
+ : 'user' !== configScope(this.opts.folder))
161
+
254
162
  if (null != config) {
255
163
  validateConfig(normalizeConfig(config))
256
164
  }
@@ -273,7 +181,6 @@ export class Station {
273
181
  }
274
182
  }
275
183
 
276
- // --- binding forms (design §3.1) ---
277
184
 
278
185
  // connect(SDK, opts): station constructs the SDK itself, activating
279
186
  // the adapter with §3.3 ordering. The activation entry plus the
@@ -295,36 +202,27 @@ export class Station {
295
202
  throw new StationError('station_no_plugin', 'station is closed')
296
203
  }
297
204
  opts = opts || {}
298
- const fmap = { ...(opts.feature || {}) }
205
+ const { as, instance, ...sdkopts } = opts
206
+ const fmap = { ...(sdkopts.feature || {}) }
299
207
  fmap['station'] = {
300
208
  ...(fmap['station'] || {}),
301
209
  active: true, station: this, calleropts: opts,
302
- // §6.1: `as` is a TAG, resolved against the api in _register -
303
- // the api comes from the SDK being passed and is not knowable
304
- // here until that SDK's config has been normalized.
305
- ...(null == opts.as ? {} : { as: opts.as }),
306
- ...(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 }),
307
213
  }
308
214
  const options = {
309
- ...opts,
215
+ ...sdkopts,
310
216
  feature: fmap,
311
217
  // The carried adapter rides extend for SDKs generated WITHOUT
312
218
  // the station feature; when the generated class exists the
313
219
  // constructor uses it and the extend copy is skipped by name
314
220
  // (both delegate to featureBinding, so behavior is identical).
315
- extend: [...(opts.extend || []), adapterFeature(this, opts)],
221
+ extend: [...(sdkopts.extend || []), adapterFeature(this, opts)],
316
222
  }
317
223
  return new SDK(options)
318
224
  }
319
225
 
320
- // Inverted binding (design §3.1): build the plain options map a
321
- // generated constructor already accepts - the handle, the activation
322
- // entry, and the profile's per-plugin base (applied here, at
323
- // options-build time, so caller opts passed in `extra` still win).
324
- // §6.1: `options(instanceName?, extra?)`. The name is optional and
325
- // leading, so every existing `options({...})` call is unchanged - the
326
- // inverted binding is the static languages' path and they need to say
327
- // which instance they are building without a second method.
328
226
  options(a?: any, b?: any): any {
329
227
  const named = 'string' === typeof a
330
228
  const instance: string | undefined = named ? a : undefined
@@ -338,15 +236,7 @@ export class Station {
338
236
  return { ...extra, feature: fmap }
339
237
  }
340
238
 
341
- // --- registration (design §3 item 1, called by the adapter) ---
342
239
 
343
- // The registry entry whose client IS this object, or null. Used by
344
- // featureBinding for idempotency: connect/adopt activate the station
345
- // entry AND ride the carried adapter on extend, so on an SDK whose
346
- // generated config carries a real station feature class the same
347
- // construction reaches featureBinding twice - the second arrival
348
- // must no-op, while a genuinely second client of the same SDK class
349
- // still fails _register's slug check (§10.2).
350
240
  _boundEntry(client: any): PluginEntry | null {
351
241
  for (const entry of this.registry.values()) {
352
242
  if (entry.client === client) { return entry }
@@ -354,30 +244,11 @@ export class Station {
354
244
  return null
355
245
  }
356
246
 
357
- /** The profile block that governs an instance — its own if the
358
- * profile declares it, otherwise its **api's**.
359
- *
360
- * `resolveProfile` builds `profile.sdk` from the declared refs alone
361
- * ("an api block declares no instance, so the ref set comes from the
362
- * two `sdk` maps"), shallow-merging `profile.api[a]` into each. That
363
- * is right for a declared instance and leaves an IMPERATIVE one —
364
- * `connect(SDK, { as: 'test' })`, named but never written into
365
- * config — with no block at all. The api-level `secret`, `base` and
366
- * most seriously `policy.hosts` then did not reach it, so a profile
367
- * that denies egress everywhere denied nothing for a tagged client.
368
- *
369
- * ONE RULE, ONE PLACE: registration and the transport seam both ask
370
- * here, because they disagreeing is how the credential and the
371
- * allowlist came apart in the first place. */
372
247
  private blockFor(name: string): SdkBlock | undefined {
373
248
  return this.profile.sdk[this.declaredRef(name)] ??
374
249
  this.profile.api[refapi(name)]
375
250
  }
376
251
 
377
- /** The DECLARED instance an assigned tag stands for, or the name
378
- * itself. `create('stripe$prod')` registers under `stripe$1`, and
379
- * every question about that client's configuration — its secret, its
380
- * base, its egress policy — is a question about `stripe$prod`. */
381
252
  private declaredRef(name: string): string {
382
253
  return this.aliasOf.get(name) ?? name
383
254
  }
@@ -395,9 +266,6 @@ export class Station {
395
266
  // behaviour and why the single-instance case is unchanged.
396
267
  const name = instanceRef(api, fopts)
397
268
 
398
- // §7.1: the check moves to the instance key. Two clients of one api
399
- // is the NORMAL case now; two bindings of one instance is still the
400
- // error it was.
401
269
  if (this.registry.has(name)) {
402
270
  throw new StationError('station_bound_twice',
403
271
  'instance "' + name + '" is already registered; binding one client ' +
@@ -405,28 +273,6 @@ export class Station {
405
273
  }
406
274
 
407
275
  const profilePlugin = this.blockFor(name)
408
- // Secret name precedence: the feature option (in-code, design §9
409
- // config.options.secret) beats the profile, which beats the
410
- // INSTANCE-derived default.
411
- //
412
- // §5.1: `secretnameDefault` takes the instance name, not the slug.
413
- // For an untagged instance the two are the same string, so the
414
- // single-instance case is unchanged to the byte - `voxgig-solardemo`
415
- // still derives `voxgig_solardemo.apikey` and still reads
416
- // VOXGIG_SOLARDEMO_APIKEY. `stripe$test` derives
417
- // `stripe_test.apikey`, because each instance IS a separate
418
- // credentialed use of the API.
419
- //
420
- // The descriptor's own `auth.secretname` stays the API-level
421
- // default and is NOT used here (§7.4): one descriptor is shared by
422
- // every instance of an api and cannot hold two instance-derived
423
- // names, so whichever instance built the cached copy would report
424
- // the other's secret metadata wrongly. This is the authority; that
425
- // field is documentation.
426
- // ...and the DEFAULT takes the declared name too, not the assigned
427
- // tag: `stripe$1` created from `stripe$test` derives
428
- // `stripe_test.apikey`, so every per-request client of one instance
429
- // shares one broker cache entry (§5.3).
430
276
  const secretname = firstNonEmpty(fopts?.secret, profilePlugin?.secret) ||
431
277
  secretnameDefault(this.declaredRef(name))
432
278
 
@@ -434,9 +280,6 @@ export class Station {
434
280
  const binding: Binding = {
435
281
  plugin: name,
436
282
  instance: api,
437
- // §7.2: two live instances of one api MUST have distinct
438
- // placeholders or the injection seam cannot tell which credential
439
- // a header wants.
440
283
  placeholder: descriptor.auth.active ? placeholderFor(name) : undefined,
441
284
  secretname: descriptor.auth.active ? secretname : undefined,
442
285
  rung,
@@ -461,34 +304,8 @@ export class Station {
461
304
  return { binding, profilePlugin }
462
305
  }
463
306
 
464
- // §7.4: THE DESCRIPTOR IS SHARED, because it describes the api rather
465
- // than any use of it. `normalizeDescriptor` runs once per api and
466
- // every instance of that api holds a reference to the same object - at
467
- // 26 instances over 20 apis that is 20 normalizations, not 26, and the
468
- // canonical serialization the proxy dedupes registrations by is
469
- // computed once per api too.
470
- //
471
- // Keyed by the slug the config carries, which is available before
472
- // normalization only from the config itself, so the miss path
473
- // normalizes and then keys on the result.
474
307
  private descriptorCache = new Map<string, { descriptor: any, warnings: string[] }>()
475
308
 
476
- /** The descriptor describes the API, and is SHARED by every instance
477
- * of it — which is §7.4's own reasoning for why the effective secret
478
- * name moved to `Binding` and is not read off the descriptor.
479
- *
480
- * The same argument applies to feature activation, and this did not
481
- * follow it: the cache is keyed by slug alone while
482
- * `normalizeDescriptor`'s second argument decides each feature row's
483
- * `active`. So two instances of one api constructed with different
484
- * feature maps shared the FIRST one's feature state, and
485
- * `descriptorOf()` / `canonicalDescriptor()` became
486
- * construction-order-dependent.
487
- *
488
- * Normalized with no per-instance features, so the shared value holds
489
- * only API-stable metadata — which is what `factory.ts` already does
490
- * when it normalizes at provide time. Per-instance activation is
491
- * `featuresOf(name)`'s answer, and always was. */
492
309
  private describe(config: any, _feature: any):
493
310
  { descriptor: any, warnings: string[] } {
494
311
  const slug = String(config?.main?.slug ?? '')
@@ -513,7 +330,6 @@ export class Station {
513
330
  })
514
331
  }
515
332
 
516
- // --- the transport middleware (design §3.3, §5.3) ---
517
333
 
518
334
  async _transport(name: string, inner: any, fctx: any, fullurl: string,
519
335
  fetchdef: any): Promise<any> {
@@ -533,20 +349,6 @@ export class Station {
533
349
  const live = 'live' === fctx.client._mode
534
350
  const profilePlugin = this.blockFor(name)
535
351
 
536
- // Policy mode (design §16): `live` is the default; `block` is the
537
- // kill switch and refuses live egress outright. Enforced at the
538
- // same seam as the hosts allowlist, and like it only on LIVE
539
- // traffic - a test-mode client's mock transport is not egress.
540
- //
541
- // §14 names no mode-specific code, so `block` raises
542
- // `station_host_allow` - the closest existing catalog code: the
543
- // same `_allow` gate grammar, the same seam, the same meaning
544
- // (egress denied by this plugin's policy), with the message naming
545
- // the mode. `record`/`replay`/`mock` are proxy-era modes accepted
546
- // by the grammar now (so a config written for the proxy is not a
547
- // validation error) and fail `station_no_proxy` on the operation
548
- // path until a proxy is attached - the same fail-closed behaviour
549
- // `require` shows above.
550
352
  const mode = profilePlugin?.policy?.mode ?? 'live'
551
353
  if ('live' !== mode && live) {
552
354
  const err = 'block' === mode
@@ -560,12 +362,6 @@ export class Station {
560
362
  return err
561
363
  }
562
364
 
563
- // Egress policy (design §16), solo half: the hosts allowlist is
564
- // enforced at the seam every request crosses. When a policy is
565
- // present, redirects come back manual - a 3xx is a response like
566
- // any other, so a Location off the allowlist cannot pull an
567
- // automatic credentialed follow-up to an unapproved host (§8.2's
568
- // rule, applied at the library seam).
569
365
  const hosts = profilePlugin?.policy?.hosts
570
366
  if (null != hosts && live) {
571
367
  let hostname = ''
@@ -580,6 +376,8 @@ export class Station {
580
376
  }
581
377
 
582
378
  let senddef = fetchdef
379
+ let sendurl = fullurl
380
+ let placed = false
583
381
  if (null != hosts && live) {
584
382
  senddef = { ...senddef, redirect: 'manual' }
585
383
  }
@@ -590,20 +388,6 @@ export class Station {
590
388
  // enter in-memory mock stores. Copy-on-inject: the object graph
591
389
  // reachable from ctx/spec/ctrl keeps the placeholder, ever (§5.3).
592
390
  if (live && null != entry && 'R1' === entry.rung) {
593
- // §7.4: THE EFFECTIVE NAME, resolved once at registration and
594
- // stored on the entry. Re-deriving it here got the precedence
595
- // right and the FALLBACK wrong: `descriptor.auth.secretname` is
596
- // the API-level default, and one descriptor is shared by every
597
- // instance of an api — so a tagged instance with no explicit
598
- // `secret` read `stripe.apikey` where registration had recorded
599
- // `stripe_test.apikey`. Either the request fails despite the
600
- // credential being configured, or it succeeds with a sibling's.
601
- //
602
- // No fallback, and the `!` is the invariant rather than a shrug:
603
- // this branch is guarded by `'R1' === entry.rung`, which is set
604
- // only when `descriptor.auth.active` — the same condition under
605
- // which `entry.secretname` is populated. Substituting a different
606
- // name when it is absent is the bug above, written again.
607
391
  const secretname = entry.secretname!
608
392
 
609
393
  let value: string
@@ -615,13 +399,14 @@ export class Station {
615
399
  return e instanceof Error ? e : new Error(String(e))
616
400
  }
617
401
 
618
- senddef = { ...senddef, headers: { ...(senddef.headers || {}) } }
619
- for (const h of Object.keys(senddef.headers)) {
620
- const v = senddef.headers[h]
621
- if ('string' === typeof v && v.includes(placeholder)) {
622
- senddef.headers[h] = v.split(placeholder).join(value)
623
- }
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
624
408
  }
409
+ placed = sendurl !== fullurl || senddef.url !== defurl
625
410
  }
626
411
 
627
412
  const corr = fctx.station$?.corr
@@ -629,18 +414,18 @@ export class Station {
629
414
 
630
415
  let res: any
631
416
  try {
632
- res = await inner(fctx, fullurl, senddef)
417
+ res = await inner(fctx, sendurl, senddef)
633
418
  }
634
419
  catch (e: any) {
635
420
  this.emitHttp(name, corr, fullurl, senddef, 0, started, 0)
636
421
  this.emitErr(name, fctx, e)
637
- throw e
422
+ throw this.scrubbed(e, placed)
638
423
  }
639
424
 
640
425
  if (res instanceof Error) {
641
426
  this.emitHttp(name, corr, fullurl, senddef, 0, started, 0)
642
427
  this.emitErr(name, fctx, res)
643
- return res
428
+ return this.scrubbed(res, placed)
644
429
  }
645
430
 
646
431
  let bytes = 0
@@ -651,6 +436,14 @@ export class Station {
651
436
  return res
652
437
  }
653
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
+
654
447
  private emitHttp(slug: string, corr: string | undefined, fullurl: string,
655
448
  fetchdef: any, status: number, started: number, bytes: number): void {
656
449
  let host = '', path = ''
@@ -670,12 +463,6 @@ export class Station {
670
463
 
671
464
  private emitErr(name: string, fctx: any, err: any): void {
672
465
  this.emit({
673
- // §7.3's grouping contract: `plugin` is the INSTANCE and `api` is
674
- // what groups its siblings. Construction events carried both and
675
- // the runtime ones did not, so a consumer could group `construct`
676
- // by api and then get `api: undefined` for every request, error
677
- // and hoist from the same client — the grouping working exactly
678
- // until it was used.
679
466
  t: Date.now(), kind: 'error', plugin: name, api: refapi(name),
680
467
  corr: fctx?.station$?.corr,
681
468
  err: {
@@ -687,13 +474,10 @@ export class Station {
687
474
  })
688
475
  }
689
476
 
690
- // Op events from the hook bridge (design §3 item 3).
691
477
  _opEvent(name: string, ctx: any, outcome: string): void {
692
478
  const st = ctx.station$ || {}
693
479
  this.emit({
694
480
  t: Date.now(), kind: 'op', plugin: name, api: refapi(name), corr: st.corr,
695
- // ctx.op is the SDK's resolved Operation: name + entity, in the
696
- // descriptor's lowercase spelling.
697
481
  op: {
698
482
  entity: String(ctx.op?.entity ?? ctx.entity?.name ?? ''),
699
483
  op: String(ctx.op?.name ?? ''),
@@ -703,7 +487,6 @@ export class Station {
703
487
  })
704
488
  }
705
489
 
706
- // --- the query/observe surface (design §3.2, §6) ---
707
490
 
708
491
  // One entry per LIVE INSTANCE (§6.1), and exhaustive: auto-tagged
709
492
  // entries are not collapsed here, because inspection, health
@@ -728,15 +511,7 @@ export class Station {
728
511
  }))
729
512
  }
730
513
 
731
- // --- the declarative front door (design §6) ---
732
514
 
733
- /** The instance, constructed on first call and CACHED: same name ->
734
- * same object. That caching is what makes "get it where you need it"
735
- * a real instruction - call it in a request handler, in a worker, in
736
- * a test, and the first call pays construction while the rest are a
737
- * map lookup.
738
- *
739
- * SYNCHRONOUS (§6.3), which is what bounds the loader to CommonJS. */
740
515
  sdk(name: string): any {
741
516
  const cached = this.clients.get(name)
742
517
  if (null != cached) { return cached }
@@ -745,39 +520,10 @@ export class Station {
745
520
  return client
746
521
  }
747
522
 
748
- /** An UNCACHED client from the same resolved config plus overrides,
749
- * for the case that genuinely wants a distinct one - a per-request
750
- * credential scope, a test double. Deliberately the longer name.
751
- *
752
- * It registers under an AUTO-ASSIGNED TAG, because §7.5 registers
753
- * every constructed adapter under its instance name and
754
- * `station_bound_twice` fires on a second binding of one name: a
755
- * second `create('stripe')` would otherwise throw, which is exactly
756
- * the per-request case this exists for. The tag is the lowest unused
757
- * positive integer, so an auto-tagged instance is an ORDINARY
758
- * instance rather than a parallel identity scheme - `plugins()`, the
759
- * placeholder, the event stream and `station_bound_twice` all keep
760
- * working on one identity model.
761
- *
762
- * The SECRET NAME does not follow the assigned tag: it resolves from
763
- * the DECLARED instance the tag was assigned under, so every client
764
- * of one instance shares one broker cache entry rather than
765
- * re-resolving per request (§5.3). */
766
523
  create(name: string, overrides?: any): any {
767
524
  return this.build(name, this.autotag(name), overrides)
768
525
  }
769
526
 
770
- /** The lowest positive integer tag not already taken, by a LIVE
771
- * instance or a DECLARED one.
772
- *
773
- * The registry alone was not enough: a profile may declare
774
- * `stripe$1`, and until something constructs it `registry.has` says
775
- * false — so `create('stripe$prod')` took that identity for a client
776
- * built from the `stripe$prod` block. `instances()` then reported the
777
- * declared `stripe$1` as live with the wrong client, and a later
778
- * `sdk('stripe$1')` failed `station_bound_twice` against a binding
779
- * that was never its own. Declaration reserves the name whether or
780
- * not it has been built. */
781
527
  private autotag(name: string): string {
782
528
  const api = refapi(name)
783
529
  for (let n = 1; ; n++) {
@@ -808,38 +554,12 @@ export class Station {
808
554
  const api = refapi(name)
809
555
  const entry = this.resolveFactory(api, block)
810
556
 
811
- // §8.5 VALIDATES HERE, not only in `check()`. The schema arrives
812
- // with the factory, so the moment a factory is resolved is the
813
- // first moment validation is possible — and it was running in
814
- // `check()` alone. Two gaps followed: production `sdk()` silently
815
- // ignored an unknown option like `retry.retires`, and `check()`
816
- // itself missed the case where the factory is discovered by the
817
- // LOADER (its pre-check sees no registered factory, then `sdk()`
818
- // loads and constructs unvalidated). One call here closes both,
819
- // because every path to a constructor comes through this line.
820
557
  const faults = checkfeatures(this.featuresOf(name).merged, entry.descriptor)
821
558
  if (0 < faults.length) {
822
- // `Fault.code` is a plain string in `feature.ts`; the two codes it
823
- // actually produces are both in the union, and `check()` passes
824
- // the same value through untyped. Narrowed here rather than
825
- // widening the union, so a new fault code is a compile error at
826
- // the place that has to decide what it means.
827
559
  throw new StationError(faults[0].code as any,
828
560
  faults.map((f) => f.message).join('; '))
829
561
  }
830
562
 
831
- // §8.4: compose the merged feature map into the ORDERED form and
832
- // hand it to the constructor. No new seam - it is the same
833
- // `options.feature` map `connect()` already uses for station's own
834
- // placement, with more in it, and a JS object preserves the
835
- // insertion order of string keys so the order rides the map.
836
- //
837
- // Station's own entry is composed AFTER the user merge and always
838
- // wins (§8.4), which is why `station` is dropped here and added by
839
- // `options()`: a config file that can switch off the component
840
- // reading it is not a surface, it is a trap. `feature.station` is
841
- // already `station_feature_reserved` at validation, so this is the
842
- // second half of one rule rather than a second rule.
843
563
  const resolved = this.featuresOf(name)
844
564
  const fmap: { [k: string]: any } = {}
845
565
  for (const f of composefeatures(
@@ -855,37 +575,8 @@ export class Station {
855
575
  feature: { ...fmap, ...((overrides || {}).feature || {}) },
856
576
  }
857
577
 
858
- // §5.3, and `create`'s own doc comment: "the SECRET NAME does not
859
- // follow the assigned tag: it resolves from the DECLARED instance
860
- // the tag was assigned under."
861
- //
862
- // THE ALIAS IS RECORDED, NOT THE FIELDS. The first fix for this
863
- // carried the declared `secret` through the feature options and
864
- // stopped there — which left `policy`, `base` and everything else
865
- // behind, so an auto-tagged client silently lost its declared
866
- // instance's HOSTS ALLOWLIST and fell back to the wider api-level
867
- // one. Carrying configuration a field at a time is how that
868
- // happens; recording what the tag STANDS FOR is one rule that every
869
- // lookup already goes through.
870
- //
871
- // Only when the tag was ASSIGNED — a caller naming its own is
872
- // naming an instance, not aliasing one.
873
578
  if (null != as && as !== name) { this.aliasOf.set(as, name) }
874
579
 
875
- // ...AND THE CARRIED ADAPTER RIDES EXTEND, exactly as it does on
876
- // `connect`. §3.1's retrofit case — an SDK generated before the
877
- // station feature, which `factoryFromModule` explicitly supports —
878
- // has no generated feature to consume the `feature.station`
879
- // activation this path sets, so declarative `sdk()` either failed
880
- // on an unknown feature or returned an unregistered, unwrapped
881
- // client with no credential injection and no events. The imperative
882
- // path carried it and the declarative one did not, which is the
883
- // whole defect.
884
- //
885
- // Safe on a REGENERATED SDK too: the constructor uses its own
886
- // station feature and skips the extend copy by name, and both
887
- // delegate to `featureBinding`, whose `_boundEntry` check no-ops a
888
- // second arrival for the same client.
889
580
  const withAdapter = {
890
581
  ...opts,
891
582
  extend: [...((opts as any).extend || []), adapterFeature(this, opts)],
@@ -896,8 +587,6 @@ export class Station {
896
587
  return entry.construct(this.options(as ?? name, withAdapter))
897
588
  }
898
589
 
899
- /** §6.2's three paths, in order of preference: self-registration,
900
- * `Station.provide`, then the loader. */
901
590
  private resolveFactory(api: string, block: SdkBlock): FactoryEntry {
902
591
  const direct = factoryFor(api)
903
592
  if (null != direct) { return direct }
@@ -915,9 +604,6 @@ export class Station {
915
604
  'set `api.' + api + '.package` so the loader can import it')
916
605
  }
917
606
 
918
- /** `package` is honoured only from repo-scoped config (§6.3), and a
919
- * user-level one is IGNORED WITH A WARNING rather than imported - it
920
- * names code to load and sits outside the repo's review boundary. */
921
607
  private loaderPackage(api: string, block: SdkBlock): string | undefined {
922
608
  const pkg = block.package
923
609
  if (null == pkg || '' === pkg) { return undefined }
@@ -953,12 +639,6 @@ export class Station {
953
639
  }
954
640
  }
955
641
 
956
- /** The merged, ordered feature set for one instance, WITH
957
- * PROVENANCE (§8.7): which config level set each value.
958
- *
959
- * Provenance is the half that makes a fleet view usable rather than
960
- * merely correct - at 26 instances "why is retry off here" is the
961
- * question, and a merged map alone cannot answer it. */
962
642
  featuresOf(name: string): {
963
643
  ordered: string[]
964
644
  merged: { [k: string]: any }
@@ -978,8 +658,6 @@ export class Station {
978
658
  ]
979
659
  const sources = featuresources(base, overlay, api, name)
980
660
 
981
- // Last writer per (feature, key) wins, and the level that wrote it
982
- // is what `from` records.
983
661
  const from: { [k: string]: { [k: string]: string } } = {}
984
662
  sources.forEach((src, i) => {
985
663
  if (null == src || 'object' !== typeof src) { return }
@@ -993,23 +671,6 @@ export class Station {
993
671
 
994
672
  let merged = mergefeatures(sources)
995
673
 
996
- // Policy budget (design §16): rps/concurrency ceilings ride "the
997
- // SDK `ratelimit` feature, configured by station". Composed HERE,
998
- // into the merged map every consumer reads, rather than patched in
999
- // at construction alone - so `build()` orders it with the ordinary
1000
- // constraint-and-band rules (ordering and the station pin hold),
1001
- // `check()`'s §8.5 pass validates it against the SDK's own
1002
- // declaration (a budget on an SDK with no ratelimit feature is
1003
- // `station_feature_unknown`, not a setting that quietly did
1004
- // nothing), and the §8.7 fleet view answers "is ratelimit on?"
1005
- // truthfully, with `policy.budget` as the provenance level.
1006
- //
1007
- // `rps` maps to the token bucket's refill `rate` (per second -
1008
- // the same unit); `concurrency` to its capacity `burst`, the
1009
- // number of requests that can be in flight from a full bucket.
1010
- // POLICY WINS over a `feature.ratelimit` config entry on the keys
1011
- // it sets - it is enforcement, not a default - and other tuning
1012
- // keys survive beside it.
1013
674
  const budget = this.blockFor(name)?.policy?.budget
1014
675
  if (null != budget && 'object' === typeof budget && !Array.isArray(budget)) {
1015
676
  const prior = merged.ratelimit
@@ -1030,18 +691,6 @@ export class Station {
1030
691
  merged = { ...merged, ratelimit: entry }
1031
692
  }
1032
693
 
1033
- // THE IMPLICIT STATION ENTRY, added for ORDERING ONLY. `station` is
1034
- // never in `merged` — `feature.station` is reserved and rejected at
1035
- // validation (§8.4) — so `checkpin` found no station row and
1036
- // returned immediately, every time. It was a permanent no-op: a
1037
- // constraint like `retry.order.after: 'station'` was treated as
1038
- // vacuous rather than rejected, and the reported order omitted the
1039
- // one feature whose position is supposedly pinned.
1040
- //
1041
- // Added here rather than into `merged`, which stays the user's own
1042
- // merge result. `build` already filters `station` out of the map it
1043
- // hands the constructor, because §8.4 composes station's entry
1044
- // after the user merge and always wins.
1045
694
  const ordered = resolveorder({ ...merged, station: { active: true } })
1046
695
  checkpin(ordered)
1047
696
  return { ordered: ordered.map((o) => o.name), merged, from }
@@ -1050,16 +699,6 @@ export class Station {
1050
699
  /** The fleet feature view: instance x feature, effective options, and
1051
700
  * which config level set each (§8.7). */
1052
701
  features(filter?: string | { instance?: string, api?: string, feature?: string }): any[] {
1053
- // §8.7's documented shape is an OBJECT, and only the object form
1054
- // can express the question the view exists for: `{feature: 'debug'}`
1055
- // — "is debug on anywhere?", the one that is twenty greps today.
1056
- // The implementation took a bare string and compared it against
1057
- // both `name` and `api`, so every documented call returned an empty
1058
- // list, an object never equalling either string.
1059
- //
1060
- // The string form is kept as shorthand for "this instance or this
1061
- // api", because it is what the tests and the imperative path
1062
- // already use and it costs one line.
1063
702
  const f = 'string' === typeof filter
1064
703
  ? { instance: filter, api: filter, loose: true }
1065
704
  : { ...(filter || {}), loose: false }
@@ -1094,20 +733,12 @@ export class Station {
1094
733
  }))
1095
734
  }
1096
735
 
1097
- /** Eagerly resolve and construct every ACTIVE instance - for CI
1098
- * (§6.6). The point is to turn availability errors, which are
1099
- * deliberately deferred to first use, into one failure at a moment
1100
- * somebody is watching. */
1101
736
  check(): { ok: string[], failed: { name: string, code?: string, message: string }[] } {
1102
737
  const ok: string[] = []
1103
738
  const failed: { name: string, code?: string, message: string }[] = []
1104
739
  for (const row of this.instances()) {
1105
740
  if (!row.active) { continue }
1106
741
  try {
1107
- // §8.5 runs FIRST and needs no construction: the schema arrives
1108
- // with the factory, not with a live client, so a feature typo
1109
- // is a CI failure rather than a setting that quietly did
1110
- // nothing in production.
1111
742
  const entry = factoryFor(row.api)
1112
743
  if (null != entry) {
1113
744
  const faults = checkfeatures(
@@ -1131,43 +762,15 @@ export class Station {
1131
762
  return { ok, failed }
1132
763
  }
1133
764
 
1134
- /** Batch-resolve secrets for ACTIVE instances (§5.5).
1135
- *
1136
- * With no argument it warms the active ones only, because reaching
1137
- * for a credential belonging to a disabled integration is the wrong
1138
- * default. `warm(names)` warms exactly what it is given, inactive
1139
- * included, because an explicit name is an explicit request. */
1140
765
  async warm(names?: string[]): Promise<{ warmed: string[], missed: string[] }> {
1141
766
  const wanted = null != names
1142
767
  ? names
1143
768
  : this.instances().filter((r) => r.active).map((r) => r.name)
1144
769
 
1145
- // CONCURRENT, not serial. Awaiting inside the loop made `warm`
1146
- // cost the SUM of every provider round-trip, which defeats the one
1147
- // thing the method exists for: resolving the fleet in a batch at
1148
- // startup rather than paying per first-request. sekreto exposes no
1149
- // batch `get`, so concurrency is the available shape.
1150
- //
1151
- // Names are deduped first: the broker's resolution cache is keyed
1152
- // by SECRET NAME (§5.3), so several instances sharing one api-level
1153
- // `secret` should cost one round-trip — and firing them together
1154
- // would race past the cache and make several.
1155
770
  const plan: { name: string, secretname: string }[] = []
1156
771
  const warmed: string[] = []
1157
772
  const missed: string[] = []
1158
773
  for (const name of wanted) {
1159
- // THE REGISTRY IS THE AUTHORITY, and asking the profile instead
1160
- // was wrong twice: an IMPERATIVE instance has no `profile.sdk`
1161
- // block, so it was pushed to `missed` and never warmed at all —
1162
- // and the re-derivation dropped the in-code `secret` feature
1163
- // option, which beats the profile (§9). A registered instance
1164
- // already carries the resolved name.
1165
- // A NAME NOBODY DECLARED OR REGISTERED IS A MISS, not a lookup.
1166
- // Widening the fallback to cover imperative instances also let a
1167
- // typo — `stripe$prodd` — derive a secret name and call the
1168
- // provider, so a nonexistent instance could be reported `warmed`
1169
- // off a shared api-level credential. Registered OR declared, and
1170
- // nothing else.
1171
774
  const entry = this.registry.get(name)
1172
775
  if (null == entry && null == this.profile.sdk[name]) {
1173
776
  missed.push(name)
@@ -1253,12 +856,6 @@ export class Station {
1253
856
  return {
1254
857
  mode: 'solo',
1255
858
  profile: this.profile.name,
1256
- // §7.1: the registry is keyed by INSTANCE, so a status page that
1257
- // projects only `slug` shows two indistinguishable rows for
1258
- // `stripe$test` and `stripe$live` and omits the names it is keyed
1259
- // by — an operator cannot tell which one is unhealthy. `slug`
1260
- // stays for compatibility; `name` and `api` are what answer the
1261
- // question.
1262
859
  plugins: this.plugins().map((p) => ({
1263
860
  name: p.name, api: p.api, slug: p.slug, rung: p.rung,
1264
861
  })),
@@ -1274,10 +871,6 @@ export class Station {
1274
871
  this.broker.refresh()
1275
872
  }
1276
873
 
1277
- // close(): flush (solo: nothing in flight), then warn on profile
1278
- // plugin keys that matched no registered plugin - a typo'd key
1279
- // silently configuring nothing is the worst outcome for a
1280
- // secrets-and-policy file (design §11).
1281
874
  close(): void {
1282
875
  if (this.closed) { return }
1283
876
  for (const slug of Object.keys(this.profile.sdk)) {