@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
@@ -18,24 +18,6 @@ const secrets_1 = require("./secrets");
18
18
  // in-process with no other component running. The proxy (D2) is a
19
19
  // deferred amplifier - `require` therefore fails on the operation path
20
20
  // (design §2.1/§14), and `auto` degrades to solo with one warning event.
21
- /** §6.1: `as` IS A TAG, NOT A FREE NAME.
22
- *
23
- * The api comes from the SDK being passed, so the resulting ref is
24
- * `<api>$<tag>` and multi-instance works imperatively too. A full ref is
25
- * also accepted and is VALIDATED: its name must equal the SDK's api
26
- * slug, or it is `station_instance_api`.
27
- *
28
- * Both forms are needed because the imperative path is where the api is
29
- * implicit in an argument rather than written in a key. An `as` that
30
- * took an arbitrary name would reintroduce exactly the second-identity
31
- * problem the ref re-key removed: under the ref invariant
32
- * `as: 'solar-eu'` would denote the untagged `solar-eu` DEFINITION, not
33
- * an instance of the SDK just handed in, and registry grouping, api
34
- * defaults and every ref consumer would disagree about what it is.
35
- *
36
- * A bare connect(SDK) with no name falls back to the descriptor slug,
37
- * which is today's behaviour and why the single-instance case is
38
- * unchanged to the byte. */
39
21
  function instanceRef(api, fopts) {
40
22
  const explicit = firstNonEmpty(fopts?.instance);
41
23
  if (null != explicit) {
@@ -51,30 +33,8 @@ function instanceRef(api, fopts) {
51
33
  }
52
34
  return api;
53
35
  }
54
- // A full ref is validated against the api; a `$`-less string is a TAG
55
- // and is joined to it.
56
- //
57
- // §6.1 gives both branches and does not say how to disambiguate a
58
- // `$`-less string, which is a real ambiguity because a bare name is
59
- // itself a valid (untagged) ref: `as: 'stripe'` on api `stripe` could
60
- // read as the untagged ref `stripe` or as tag `stripe`. It is read as
61
- // a TAG, giving `stripe$stripe`, because §6.1 says twice and
62
- // emphatically that `as` is a tag rather than a free name, and a rule
63
- // with no exceptions is the one that ports the same way twenty times.
64
- // The alternative - collapsing when the tag happens to equal the api -
65
- // is a special case that would make `as` mean different things at
66
- // different values. Someone who wants the untagged instance passes no
67
- // `as` at all, which is the documented spelling for it.
68
36
  return checkref(-1 === as.indexOf('$') ? api + '$' + as : checkapi(api, as));
69
37
  }
70
- // The ref grammar is the JOINT identity model's (station-and-plugin.md
71
- // §2, plugin design §4): a name is a package-ish specifier
72
- // (`^[a-zA-Z@][a-zA-Z0-9.~_\-/]*$`), a tag is not (`^[a-zA-Z0-9.~_-]+$`
73
- // or empty - it MAY start with a digit because auto-tagging assigns
74
- // integer tags, and admits neither `@` nor `/`); both cap at 1024; the
75
- // split is on the FIRST `$`, so `a$b$c` is a good name with a bad tag.
76
- // Discharges the C4 divergence rows that pinned instanceRef accepting
77
- // what plugin's grammar rejects.
78
38
  const REF_NAME_RE = /^[a-zA-Z@][a-zA-Z0-9.~_\-\/]*$/;
79
39
  const REF_TAG_RE = /^[a-zA-Z0-9.~_-]+$/;
80
40
  const REF_MAX = 1024;
@@ -91,8 +51,6 @@ function checkInstanceTag(tag) {
91
51
  if ('string' !== typeof tag) {
92
52
  return false;
93
53
  }
94
- // The empty tag is an ordinary tag: the single-instance case writes
95
- // no tag and never learns tags exist.
96
54
  if (0 === tag.length) {
97
55
  return true;
98
56
  }
@@ -126,14 +84,6 @@ function checkapi(api, ref) {
126
84
  return ref;
127
85
  }
128
86
  class Station {
129
- /** §6.2's second path, and the front door the docs name.
130
- *
131
- * The package exported only the free `provide()` and the class had no
132
- * static — so the documented `Station.provide(...)` call failed, and
133
- * so did the remediation `station_no_factory` prints, which tells the
134
- * user to make exactly that call. Delegates to the same
135
- * process-global table the free function fills; there is one
136
- * registry, not two. */
137
87
  static provide(api, factory) {
138
88
  (0, factory_1.provide)(api, factory);
139
89
  }
@@ -161,14 +111,12 @@ class Station {
161
111
  static current() {
162
112
  return Station.ambient;
163
113
  }
164
- // Test seam: drop the ambient instance.
165
114
  static reset() {
166
115
  Station.ambient = null;
167
116
  Station.ambientOpts = null;
168
117
  }
169
118
  constructor(opts) {
170
119
  this.registry = new Map();
171
- // §6.1: `sdk(name)` caches; `create()` deliberately does not.
172
120
  this.clients = new Map();
173
121
  // The raw config, kept for §8.7's provenance: the resolved profile
174
122
  // has already collapsed the levels that provenance has to name.
@@ -180,43 +128,15 @@ class Station {
180
128
  * registration. */
181
129
  this.aliasOf = new Map();
182
130
  this.closed = false;
183
- // §7.4: THE DESCRIPTOR IS SHARED, because it describes the api rather
184
- // than any use of it. `normalizeDescriptor` runs once per api and
185
- // every instance of that api holds a reference to the same object - at
186
- // 26 instances over 20 apis that is 20 normalizations, not 26, and the
187
- // canonical serialization the proxy dedupes registrations by is
188
- // computed once per api too.
189
- //
190
- // Keyed by the slug the config carries, which is available before
191
- // normalization only from the config itself, so the miss path
192
- // normalizes and then keys on the result.
193
131
  this.descriptorCache = new Map();
194
132
  this.opts = opts || {};
195
133
  const config = undefined !== this.opts.config
196
134
  ? this.opts.config
197
135
  : (0, profile_1.loadConfig)(this.opts.folder);
198
- // §6.3: an in-code config is repo-scoped by construction - the
199
- // application wrote it. A file is repo-scoped unless it came from
200
- // ~/.voxgig/station.json.
201
- // Explicit wins, then an in-code config (the application wrote it,
202
- // so it is repo-scoped by construction), then where the file was
203
- // found. Inferring BEFORE reading the explicit option was a real
204
- // precedence bug: it made `repoScoped: false` unsettable for any
205
- // caller passing a config in code, which is every test of the rule.
206
136
  this.repoScoped = this.opts.repoScoped
207
137
  ?? (undefined !== this.opts.config
208
138
  ? true
209
139
  : 'user' !== (0, profile_1.configScope)(this.opts.folder));
210
- // Normalize, then validate (design §4.2). A malformed station.json
211
- // fails open() with EVERY error at once - an eighteen-instance
212
- // config must not die because the eighteenth has a typo'd package
213
- // name.
214
- //
215
- // resolveProfile then reads the RAW config, NOT the normalized one.
216
- // The normalized form is an input to validation and to nothing
217
- // else: block defaults synthesized before the profile merge would
218
- // let a one-key overlay overwrite the base's `active: false` and
219
- // silently re-enable a barred integration (§3.3, §4.2).
220
140
  if (null != config) {
221
141
  (0, shape_1.validateConfig)((0, shape_1.normalizeConfig)(config));
222
142
  }
@@ -235,7 +155,6 @@ class Station {
235
155
  });
236
156
  }
237
157
  }
238
- // --- binding forms (design §3.1) ---
239
158
  // connect(SDK, opts): station constructs the SDK itself, activating
240
159
  // the adapter with §3.3 ordering. The activation entry plus the
241
160
  // extend-supplied instance ride the tolerance added to the generated
@@ -254,35 +173,26 @@ class Station {
254
173
  throw new error_1.StationError('station_no_plugin', 'station is closed');
255
174
  }
256
175
  opts = opts || {};
257
- const fmap = { ...(opts.feature || {}) };
176
+ const { as, instance, ...sdkopts } = opts;
177
+ const fmap = { ...(sdkopts.feature || {}) };
258
178
  fmap['station'] = {
259
179
  ...(fmap['station'] || {}),
260
180
  active: true, station: this, calleropts: opts,
261
- // §6.1: `as` is a TAG, resolved against the api in _register -
262
- // the api comes from the SDK being passed and is not knowable
263
- // here until that SDK's config has been normalized.
264
- ...(null == opts.as ? {} : { as: opts.as }),
265
- ...(null == opts.instance ? {} : { instance: opts.instance }),
181
+ // §6.1: `as` is a tag, resolved against the SDK's api in _register.
182
+ ...(null == as ? {} : { as }),
183
+ ...(null == instance ? {} : { instance }),
266
184
  };
267
185
  const options = {
268
- ...opts,
186
+ ...sdkopts,
269
187
  feature: fmap,
270
188
  // The carried adapter rides extend for SDKs generated WITHOUT
271
189
  // the station feature; when the generated class exists the
272
190
  // constructor uses it and the extend copy is skipped by name
273
191
  // (both delegate to featureBinding, so behavior is identical).
274
- extend: [...(opts.extend || []), (0, adapter_1.adapterFeature)(this, opts)],
192
+ extend: [...(sdkopts.extend || []), (0, adapter_1.adapterFeature)(this, opts)],
275
193
  };
276
194
  return new SDK(options);
277
195
  }
278
- // Inverted binding (design §3.1): build the plain options map a
279
- // generated constructor already accepts - the handle, the activation
280
- // entry, and the profile's per-plugin base (applied here, at
281
- // options-build time, so caller opts passed in `extra` still win).
282
- // §6.1: `options(instanceName?, extra?)`. The name is optional and
283
- // leading, so every existing `options({...})` call is unchanged - the
284
- // inverted binding is the static languages' path and they need to say
285
- // which instance they are building without a second method.
286
196
  options(a, b) {
287
197
  const named = 'string' === typeof a;
288
198
  const instance = named ? a : undefined;
@@ -295,14 +205,6 @@ class Station {
295
205
  };
296
206
  return { ...extra, feature: fmap };
297
207
  }
298
- // --- registration (design §3 item 1, called by the adapter) ---
299
- // The registry entry whose client IS this object, or null. Used by
300
- // featureBinding for idempotency: connect/adopt activate the station
301
- // entry AND ride the carried adapter on extend, so on an SDK whose
302
- // generated config carries a real station feature class the same
303
- // construction reaches featureBinding twice - the second arrival
304
- // must no-op, while a genuinely second client of the same SDK class
305
- // still fails _register's slug check (§10.2).
306
208
  _boundEntry(client) {
307
209
  for (const entry of this.registry.values()) {
308
210
  if (entry.client === client) {
@@ -311,29 +213,10 @@ class Station {
311
213
  }
312
214
  return null;
313
215
  }
314
- /** The profile block that governs an instance — its own if the
315
- * profile declares it, otherwise its **api's**.
316
- *
317
- * `resolveProfile` builds `profile.sdk` from the declared refs alone
318
- * ("an api block declares no instance, so the ref set comes from the
319
- * two `sdk` maps"), shallow-merging `profile.api[a]` into each. That
320
- * is right for a declared instance and leaves an IMPERATIVE one —
321
- * `connect(SDK, { as: 'test' })`, named but never written into
322
- * config — with no block at all. The api-level `secret`, `base` and
323
- * most seriously `policy.hosts` then did not reach it, so a profile
324
- * that denies egress everywhere denied nothing for a tagged client.
325
- *
326
- * ONE RULE, ONE PLACE: registration and the transport seam both ask
327
- * here, because they disagreeing is how the credential and the
328
- * allowlist came apart in the first place. */
329
216
  blockFor(name) {
330
217
  return this.profile.sdk[this.declaredRef(name)] ??
331
218
  this.profile.api[(0, profile_1.refapi)(name)];
332
219
  }
333
- /** The DECLARED instance an assigned tag stands for, or the name
334
- * itself. `create('stripe$prod')` registers under `stripe$1`, and
335
- * every question about that client's configuration — its secret, its
336
- * base, its egress policy — is a question about `stripe$prod`. */
337
220
  declaredRef(name) {
338
221
  return this.aliasOf.get(name) ?? name;
339
222
  }
@@ -345,45 +228,17 @@ class Station {
345
228
  // with no name falls back to the descriptor slug, which is today's
346
229
  // behaviour and why the single-instance case is unchanged.
347
230
  const name = instanceRef(api, fopts);
348
- // §7.1: the check moves to the instance key. Two clients of one api
349
- // is the NORMAL case now; two bindings of one instance is still the
350
- // error it was.
351
231
  if (this.registry.has(name)) {
352
232
  throw new error_1.StationError('station_bound_twice', 'instance "' + name + '" is already registered; binding one client ' +
353
233
  'twice is an error (§10.2)');
354
234
  }
355
235
  const profilePlugin = this.blockFor(name);
356
- // Secret name precedence: the feature option (in-code, design §9
357
- // config.options.secret) beats the profile, which beats the
358
- // INSTANCE-derived default.
359
- //
360
- // §5.1: `secretnameDefault` takes the instance name, not the slug.
361
- // For an untagged instance the two are the same string, so the
362
- // single-instance case is unchanged to the byte - `voxgig-solardemo`
363
- // still derives `voxgig_solardemo.apikey` and still reads
364
- // VOXGIG_SOLARDEMO_APIKEY. `stripe$test` derives
365
- // `stripe_test.apikey`, because each instance IS a separate
366
- // credentialed use of the API.
367
- //
368
- // The descriptor's own `auth.secretname` stays the API-level
369
- // default and is NOT used here (§7.4): one descriptor is shared by
370
- // every instance of an api and cannot hold two instance-derived
371
- // names, so whichever instance built the cached copy would report
372
- // the other's secret metadata wrongly. This is the authority; that
373
- // field is documentation.
374
- // ...and the DEFAULT takes the declared name too, not the assigned
375
- // tag: `stripe$1` created from `stripe$test` derives
376
- // `stripe_test.apikey`, so every per-request client of one instance
377
- // shares one broker cache entry (§5.3).
378
236
  const secretname = firstNonEmpty(fopts?.secret, profilePlugin?.secret) ||
379
237
  (0, descriptor_1.secretnameDefault)(this.declaredRef(name));
380
238
  const rung = descriptor.auth.active ? 'R1' : 'none';
381
239
  const binding = {
382
240
  plugin: name,
383
241
  instance: api,
384
- // §7.2: two live instances of one api MUST have distinct
385
- // placeholders or the injection seam cannot tell which credential
386
- // a header wants.
387
242
  placeholder: descriptor.auth.active ? (0, secrets_1.placeholderFor)(name) : undefined,
388
243
  secretname: descriptor.auth.active ? secretname : undefined,
389
244
  rung,
@@ -404,22 +259,6 @@ class Station {
404
259
  });
405
260
  return { binding, profilePlugin };
406
261
  }
407
- /** The descriptor describes the API, and is SHARED by every instance
408
- * of it — which is §7.4's own reasoning for why the effective secret
409
- * name moved to `Binding` and is not read off the descriptor.
410
- *
411
- * The same argument applies to feature activation, and this did not
412
- * follow it: the cache is keyed by slug alone while
413
- * `normalizeDescriptor`'s second argument decides each feature row's
414
- * `active`. So two instances of one api constructed with different
415
- * feature maps shared the FIRST one's feature state, and
416
- * `descriptorOf()` / `canonicalDescriptor()` became
417
- * construction-order-dependent.
418
- *
419
- * Normalized with no per-instance features, so the shared value holds
420
- * only API-stable metadata — which is what `factory.ts` already does
421
- * when it normalizes at provide time. Per-instance activation is
422
- * `featuresOf(name)`'s answer, and always was. */
423
262
  describe(config, _feature) {
424
263
  const slug = String(config?.main?.slug ?? '');
425
264
  if ('' !== slug) {
@@ -443,7 +282,6 @@ class Station {
443
282
  },
444
283
  });
445
284
  }
446
- // --- the transport middleware (design §3.3, §5.3) ---
447
285
  async _transport(name, inner, fctx, fullurl, fetchdef) {
448
286
  // Fail-closed means traffic (§2.1): with the proxy deferred,
449
287
  // `require` can never attach, so every operation fails here - the
@@ -457,20 +295,6 @@ class Station {
457
295
  const placeholder = (0, secrets_1.placeholderFor)(name);
458
296
  const live = 'live' === fctx.client._mode;
459
297
  const profilePlugin = this.blockFor(name);
460
- // Policy mode (design §16): `live` is the default; `block` is the
461
- // kill switch and refuses live egress outright. Enforced at the
462
- // same seam as the hosts allowlist, and like it only on LIVE
463
- // traffic - a test-mode client's mock transport is not egress.
464
- //
465
- // §14 names no mode-specific code, so `block` raises
466
- // `station_host_allow` - the closest existing catalog code: the
467
- // same `_allow` gate grammar, the same seam, the same meaning
468
- // (egress denied by this plugin's policy), with the message naming
469
- // the mode. `record`/`replay`/`mock` are proxy-era modes accepted
470
- // by the grammar now (so a config written for the proxy is not a
471
- // validation error) and fail `station_no_proxy` on the operation
472
- // path until a proxy is attached - the same fail-closed behaviour
473
- // `require` shows above.
474
298
  const mode = profilePlugin?.policy?.mode ?? 'live';
475
299
  if ('live' !== mode && live) {
476
300
  const err = 'block' === mode
@@ -481,12 +305,6 @@ class Station {
481
305
  this.emitErr(name, fctx, err);
482
306
  return err;
483
307
  }
484
- // Egress policy (design §16), solo half: the hosts allowlist is
485
- // enforced at the seam every request crosses. When a policy is
486
- // present, redirects come back manual - a 3xx is a response like
487
- // any other, so a Location off the allowlist cannot pull an
488
- // automatic credentialed follow-up to an unapproved host (§8.2's
489
- // rule, applied at the library seam).
490
308
  const hosts = profilePlugin?.policy?.hosts;
491
309
  if (null != hosts && live) {
492
310
  let hostname = '';
@@ -502,6 +320,8 @@ class Station {
502
320
  }
503
321
  }
504
322
  let senddef = fetchdef;
323
+ let sendurl = fullurl;
324
+ let placed = false;
505
325
  if (null != hosts && live) {
506
326
  senddef = { ...senddef, redirect: 'manual' };
507
327
  }
@@ -511,20 +331,6 @@ class Station {
511
331
  // enter in-memory mock stores. Copy-on-inject: the object graph
512
332
  // reachable from ctx/spec/ctrl keeps the placeholder, ever (§5.3).
513
333
  if (live && null != entry && 'R1' === entry.rung) {
514
- // §7.4: THE EFFECTIVE NAME, resolved once at registration and
515
- // stored on the entry. Re-deriving it here got the precedence
516
- // right and the FALLBACK wrong: `descriptor.auth.secretname` is
517
- // the API-level default, and one descriptor is shared by every
518
- // instance of an api — so a tagged instance with no explicit
519
- // `secret` read `stripe.apikey` where registration had recorded
520
- // `stripe_test.apikey`. Either the request fails despite the
521
- // credential being configured, or it succeeds with a sibling's.
522
- //
523
- // No fallback, and the `!` is the invariant rather than a shrug:
524
- // this branch is guarded by `'R1' === entry.rung`, which is set
525
- // only when `descriptor.auth.active` — the same condition under
526
- // which `entry.secretname` is populated. Substituting a different
527
- // name when it is absent is the bug above, written again.
528
334
  const secretname = entry.secretname;
529
335
  let value;
530
336
  try {
@@ -534,29 +340,30 @@ class Station {
534
340
  this.emitErr(name, fctx, e);
535
341
  return e instanceof Error ? e : new Error(String(e));
536
342
  }
537
- senddef = { ...senddef, headers: { ...(senddef.headers || {}) } };
538
- for (const h of Object.keys(senddef.headers)) {
539
- const v = senddef.headers[h];
540
- if ('string' === typeof v && v.includes(placeholder)) {
541
- senddef.headers[h] = v.split(placeholder).join(value);
542
- }
343
+ const sent = (0, secrets_1.substitute)(fullurl, senddef.headers, placeholder, value);
344
+ sendurl = sent.url;
345
+ senddef = { ...senddef, headers: sent.headers };
346
+ const defurl = senddef.url;
347
+ if ('string' === typeof defurl) {
348
+ senddef.url = (0, secrets_1.substitute)(defurl, null, placeholder, value).url;
543
349
  }
350
+ placed = sendurl !== fullurl || senddef.url !== defurl;
544
351
  }
545
352
  const corr = fctx.station$?.corr;
546
353
  const started = Date.now();
547
354
  let res;
548
355
  try {
549
- res = await inner(fctx, fullurl, senddef);
356
+ res = await inner(fctx, sendurl, senddef);
550
357
  }
551
358
  catch (e) {
552
359
  this.emitHttp(name, corr, fullurl, senddef, 0, started, 0);
553
360
  this.emitErr(name, fctx, e);
554
- throw e;
361
+ throw this.scrubbed(e, placed);
555
362
  }
556
363
  if (res instanceof Error) {
557
364
  this.emitHttp(name, corr, fullurl, senddef, 0, started, 0);
558
365
  this.emitErr(name, fctx, res);
559
- return res;
366
+ return this.scrubbed(res, placed);
560
367
  }
561
368
  let bytes = 0;
562
369
  const cl = res?.headers?.get?.('content-length');
@@ -566,6 +373,13 @@ class Station {
566
373
  this.emitHttp(name, corr, fullurl, senddef, res?.status || 0, started, bytes);
567
374
  return res;
568
375
  }
376
+ // A transport error may quote its URL, which carries a query-placed value.
377
+ scrubbed(err, placed) {
378
+ if (placed && err instanceof Error) {
379
+ err.message = this.redact(err.message);
380
+ }
381
+ return err;
382
+ }
569
383
  emitHttp(slug, corr, fullurl, fetchdef, status, started, bytes) {
570
384
  let host = '', path = '';
571
385
  try {
@@ -586,12 +400,6 @@ class Station {
586
400
  }
587
401
  emitErr(name, fctx, err) {
588
402
  this.emit({
589
- // §7.3's grouping contract: `plugin` is the INSTANCE and `api` is
590
- // what groups its siblings. Construction events carried both and
591
- // the runtime ones did not, so a consumer could group `construct`
592
- // by api and then get `api: undefined` for every request, error
593
- // and hoist from the same client — the grouping working exactly
594
- // until it was used.
595
403
  t: Date.now(), kind: 'error', plugin: name, api: (0, profile_1.refapi)(name),
596
404
  corr: fctx?.station$?.corr,
597
405
  err: {
@@ -602,13 +410,10 @@ class Station {
602
410
  },
603
411
  });
604
412
  }
605
- // Op events from the hook bridge (design §3 item 3).
606
413
  _opEvent(name, ctx, outcome) {
607
414
  const st = ctx.station$ || {};
608
415
  this.emit({
609
416
  t: Date.now(), kind: 'op', plugin: name, api: (0, profile_1.refapi)(name), corr: st.corr,
610
- // ctx.op is the SDK's resolved Operation: name + entity, in the
611
- // descriptor's lowercase spelling.
612
417
  op: {
613
418
  entity: String(ctx.op?.entity ?? ctx.entity?.name ?? ''),
614
419
  op: String(ctx.op?.name ?? ''),
@@ -617,7 +422,6 @@ class Station {
617
422
  },
618
423
  });
619
424
  }
620
- // --- the query/observe surface (design §3.2, §6) ---
621
425
  // One entry per LIVE INSTANCE (§6.1), and exhaustive: auto-tagged
622
426
  // entries are not collapsed here, because inspection, health
623
427
  // reporting and cleanup all need to enumerate the clients `create()`
@@ -637,14 +441,6 @@ class Station {
637
441
  warnings: e.warnings.slice(),
638
442
  }));
639
443
  }
640
- // --- the declarative front door (design §6) ---
641
- /** The instance, constructed on first call and CACHED: same name ->
642
- * same object. That caching is what makes "get it where you need it"
643
- * a real instruction - call it in a request handler, in a worker, in
644
- * a test, and the first call pays construction while the rest are a
645
- * map lookup.
646
- *
647
- * SYNCHRONOUS (§6.3), which is what bounds the loader to CommonJS. */
648
444
  sdk(name) {
649
445
  const cached = this.clients.get(name);
650
446
  if (null != cached) {
@@ -654,38 +450,9 @@ class Station {
654
450
  this.clients.set(name, client);
655
451
  return client;
656
452
  }
657
- /** An UNCACHED client from the same resolved config plus overrides,
658
- * for the case that genuinely wants a distinct one - a per-request
659
- * credential scope, a test double. Deliberately the longer name.
660
- *
661
- * It registers under an AUTO-ASSIGNED TAG, because §7.5 registers
662
- * every constructed adapter under its instance name and
663
- * `station_bound_twice` fires on a second binding of one name: a
664
- * second `create('stripe')` would otherwise throw, which is exactly
665
- * the per-request case this exists for. The tag is the lowest unused
666
- * positive integer, so an auto-tagged instance is an ORDINARY
667
- * instance rather than a parallel identity scheme - `plugins()`, the
668
- * placeholder, the event stream and `station_bound_twice` all keep
669
- * working on one identity model.
670
- *
671
- * The SECRET NAME does not follow the assigned tag: it resolves from
672
- * the DECLARED instance the tag was assigned under, so every client
673
- * of one instance shares one broker cache entry rather than
674
- * re-resolving per request (§5.3). */
675
453
  create(name, overrides) {
676
454
  return this.build(name, this.autotag(name), overrides);
677
455
  }
678
- /** The lowest positive integer tag not already taken, by a LIVE
679
- * instance or a DECLARED one.
680
- *
681
- * The registry alone was not enough: a profile may declare
682
- * `stripe$1`, and until something constructs it `registry.has` says
683
- * false — so `create('stripe$prod')` took that identity for a client
684
- * built from the `stripe$prod` block. `instances()` then reported the
685
- * declared `stripe$1` as live with the wrong client, and a later
686
- * `sdk('stripe$1')` failed `station_bound_twice` against a binding
687
- * that was never its own. Declaration reserves the name whether or
688
- * not it has been built. */
689
456
  autotag(name) {
690
457
  const api = (0, profile_1.refapi)(name);
691
458
  for (let n = 1;; n++) {
@@ -710,36 +477,10 @@ class Station {
710
477
  }
711
478
  const api = (0, profile_1.refapi)(name);
712
479
  const entry = this.resolveFactory(api, block);
713
- // §8.5 VALIDATES HERE, not only in `check()`. The schema arrives
714
- // with the factory, so the moment a factory is resolved is the
715
- // first moment validation is possible — and it was running in
716
- // `check()` alone. Two gaps followed: production `sdk()` silently
717
- // ignored an unknown option like `retry.retires`, and `check()`
718
- // itself missed the case where the factory is discovered by the
719
- // LOADER (its pre-check sees no registered factory, then `sdk()`
720
- // loads and constructs unvalidated). One call here closes both,
721
- // because every path to a constructor comes through this line.
722
480
  const faults = (0, feature_1.checkfeatures)(this.featuresOf(name).merged, entry.descriptor);
723
481
  if (0 < faults.length) {
724
- // `Fault.code` is a plain string in `feature.ts`; the two codes it
725
- // actually produces are both in the union, and `check()` passes
726
- // the same value through untyped. Narrowed here rather than
727
- // widening the union, so a new fault code is a compile error at
728
- // the place that has to decide what it means.
729
482
  throw new error_1.StationError(faults[0].code, faults.map((f) => f.message).join('; '));
730
483
  }
731
- // §8.4: compose the merged feature map into the ORDERED form and
732
- // hand it to the constructor. No new seam - it is the same
733
- // `options.feature` map `connect()` already uses for station's own
734
- // placement, with more in it, and a JS object preserves the
735
- // insertion order of string keys so the order rides the map.
736
- //
737
- // Station's own entry is composed AFTER the user merge and always
738
- // wins (§8.4), which is why `station` is dropped here and added by
739
- // `options()`: a config file that can switch off the component
740
- // reading it is not a surface, it is a trap. `feature.station` is
741
- // already `station_feature_reserved` at validation, so this is the
742
- // second half of one rule rather than a second rule.
743
484
  const resolved = this.featuresOf(name);
744
485
  const fmap = {};
745
486
  for (const f of (0, feature_1.composefeatures)((0, feature_1.resolveorder)(resolved.merged).filter((o) => 'station' !== o.name))) {
@@ -752,38 +493,9 @@ class Station {
752
493
  ...(overrides || {}),
753
494
  feature: { ...fmap, ...((overrides || {}).feature || {}) },
754
495
  };
755
- // §5.3, and `create`'s own doc comment: "the SECRET NAME does not
756
- // follow the assigned tag: it resolves from the DECLARED instance
757
- // the tag was assigned under."
758
- //
759
- // THE ALIAS IS RECORDED, NOT THE FIELDS. The first fix for this
760
- // carried the declared `secret` through the feature options and
761
- // stopped there — which left `policy`, `base` and everything else
762
- // behind, so an auto-tagged client silently lost its declared
763
- // instance's HOSTS ALLOWLIST and fell back to the wider api-level
764
- // one. Carrying configuration a field at a time is how that
765
- // happens; recording what the tag STANDS FOR is one rule that every
766
- // lookup already goes through.
767
- //
768
- // Only when the tag was ASSIGNED — a caller naming its own is
769
- // naming an instance, not aliasing one.
770
496
  if (null != as && as !== name) {
771
497
  this.aliasOf.set(as, name);
772
498
  }
773
- // ...AND THE CARRIED ADAPTER RIDES EXTEND, exactly as it does on
774
- // `connect`. §3.1's retrofit case — an SDK generated before the
775
- // station feature, which `factoryFromModule` explicitly supports —
776
- // has no generated feature to consume the `feature.station`
777
- // activation this path sets, so declarative `sdk()` either failed
778
- // on an unknown feature or returned an unregistered, unwrapped
779
- // client with no credential injection and no events. The imperative
780
- // path carried it and the declarative one did not, which is the
781
- // whole defect.
782
- //
783
- // Safe on a REGENERATED SDK too: the constructor uses its own
784
- // station feature and skips the extend copy by name, and both
785
- // delegate to `featureBinding`, whose `_boundEntry` check no-ops a
786
- // second arrival for the same client.
787
499
  const withAdapter = {
788
500
  ...opts,
789
501
  extend: [...(opts.extend || []), (0, adapter_1.adapterFeature)(this, opts)],
@@ -792,8 +504,6 @@ class Station {
792
504
  // imperative path, so registration has one spelling (§7.5).
793
505
  return entry.construct(this.options(as ?? name, withAdapter));
794
506
  }
795
- /** §6.2's three paths, in order of preference: self-registration,
796
- * `Station.provide`, then the loader. */
797
507
  resolveFactory(api, block) {
798
508
  const direct = (0, factory_1.factoryFor)(api);
799
509
  if (null != direct) {
@@ -811,9 +521,6 @@ class Station {
811
521
  'that self-registers, call Station.provide("' + api + '", ...), or ' +
812
522
  'set `api.' + api + '.package` so the loader can import it');
813
523
  }
814
- /** `package` is honoured only from repo-scoped config (§6.3), and a
815
- * user-level one is IGNORED WITH A WARNING rather than imported - it
816
- * names code to load and sits outside the repo's review boundary. */
817
524
  loaderPackage(api, block) {
818
525
  const pkg = block.package;
819
526
  if (null == pkg || '' === pkg) {
@@ -858,12 +565,6 @@ class Station {
858
565
  await (0, loader_1.loadAsync)(api, pkg, block.export);
859
566
  }
860
567
  }
861
- /** The merged, ordered feature set for one instance, WITH
862
- * PROVENANCE (§8.7): which config level set each value.
863
- *
864
- * Provenance is the half that makes a fleet view usable rather than
865
- * merely correct - at 26 instances "why is retry off here" is the
866
- * question, and a merged map alone cannot answer it. */
867
568
  featuresOf(name) {
868
569
  const api = (0, profile_1.refapi)(name);
869
570
  const profiles = (this.raw?.profiles || {});
@@ -877,8 +578,6 @@ class Station {
877
578
  this.profile.name + '.sdk',
878
579
  ];
879
580
  const sources = (0, feature_1.featuresources)(base, overlay, api, name);
880
- // Last writer per (feature, key) wins, and the level that wrote it
881
- // is what `from` records.
882
581
  const from = {};
883
582
  sources.forEach((src, i) => {
884
583
  if (null == src || 'object' !== typeof src) {
@@ -896,23 +595,6 @@ class Station {
896
595
  }
897
596
  });
898
597
  let merged = (0, feature_1.mergefeatures)(sources);
899
- // Policy budget (design §16): rps/concurrency ceilings ride "the
900
- // SDK `ratelimit` feature, configured by station". Composed HERE,
901
- // into the merged map every consumer reads, rather than patched in
902
- // at construction alone - so `build()` orders it with the ordinary
903
- // constraint-and-band rules (ordering and the station pin hold),
904
- // `check()`'s §8.5 pass validates it against the SDK's own
905
- // declaration (a budget on an SDK with no ratelimit feature is
906
- // `station_feature_unknown`, not a setting that quietly did
907
- // nothing), and the §8.7 fleet view answers "is ratelimit on?"
908
- // truthfully, with `policy.budget` as the provenance level.
909
- //
910
- // `rps` maps to the token bucket's refill `rate` (per second -
911
- // the same unit); `concurrency` to its capacity `burst`, the
912
- // number of requests that can be in flight from a full bucket.
913
- // POLICY WINS over a `feature.ratelimit` config entry on the keys
914
- // it sets - it is enforcement, not a default - and other tuning
915
- // keys survive beside it.
916
598
  const budget = this.blockFor(name)?.policy?.budget;
917
599
  if (null != budget && 'object' === typeof budget && !Array.isArray(budget)) {
918
600
  const prior = merged.ratelimit;
@@ -932,18 +614,6 @@ class Station {
932
614
  }
933
615
  merged = { ...merged, ratelimit: entry };
934
616
  }
935
- // THE IMPLICIT STATION ENTRY, added for ORDERING ONLY. `station` is
936
- // never in `merged` — `feature.station` is reserved and rejected at
937
- // validation (§8.4) — so `checkpin` found no station row and
938
- // returned immediately, every time. It was a permanent no-op: a
939
- // constraint like `retry.order.after: 'station'` was treated as
940
- // vacuous rather than rejected, and the reported order omitted the
941
- // one feature whose position is supposedly pinned.
942
- //
943
- // Added here rather than into `merged`, which stays the user's own
944
- // merge result. `build` already filters `station` out of the map it
945
- // hands the constructor, because §8.4 composes station's entry
946
- // after the user merge and always wins.
947
617
  const ordered = (0, feature_1.resolveorder)({ ...merged, station: { active: true } });
948
618
  (0, feature_1.checkpin)(ordered);
949
619
  return { ordered: ordered.map((o) => o.name), merged, from };
@@ -951,16 +621,6 @@ class Station {
951
621
  /** The fleet feature view: instance x feature, effective options, and
952
622
  * which config level set each (§8.7). */
953
623
  features(filter) {
954
- // §8.7's documented shape is an OBJECT, and only the object form
955
- // can express the question the view exists for: `{feature: 'debug'}`
956
- // — "is debug on anywhere?", the one that is twenty greps today.
957
- // The implementation took a bare string and compared it against
958
- // both `name` and `api`, so every documented call returned an empty
959
- // list, an object never equalling either string.
960
- //
961
- // The string form is kept as shorthand for "this instance or this
962
- // api", because it is what the tests and the imperative path
963
- // already use and it costs one line.
964
624
  const f = 'string' === typeof filter
965
625
  ? { instance: filter, api: filter, loose: true }
966
626
  : { ...(filter || {}), loose: false };
@@ -994,10 +654,6 @@ class Station {
994
654
  from: { [want]: row.from[want] || {} },
995
655
  }));
996
656
  }
997
- /** Eagerly resolve and construct every ACTIVE instance - for CI
998
- * (§6.6). The point is to turn availability errors, which are
999
- * deliberately deferred to first use, into one failure at a moment
1000
- * somebody is watching. */
1001
657
  check() {
1002
658
  const ok = [];
1003
659
  const failed = [];
@@ -1006,10 +662,6 @@ class Station {
1006
662
  continue;
1007
663
  }
1008
664
  try {
1009
- // §8.5 runs FIRST and needs no construction: the schema arrives
1010
- // with the factory, not with a live client, so a feature typo
1011
- // is a CI failure rather than a setting that quietly did
1012
- // nothing in production.
1013
665
  const entry = (0, factory_1.factoryFor)(row.api);
1014
666
  if (null != entry) {
1015
667
  const faults = (0, feature_1.checkfeatures)(this.featuresOf(row.name).merged, entry.descriptor);
@@ -1032,42 +684,14 @@ class Station {
1032
684
  }
1033
685
  return { ok, failed };
1034
686
  }
1035
- /** Batch-resolve secrets for ACTIVE instances (§5.5).
1036
- *
1037
- * With no argument it warms the active ones only, because reaching
1038
- * for a credential belonging to a disabled integration is the wrong
1039
- * default. `warm(names)` warms exactly what it is given, inactive
1040
- * included, because an explicit name is an explicit request. */
1041
687
  async warm(names) {
1042
688
  const wanted = null != names
1043
689
  ? names
1044
690
  : this.instances().filter((r) => r.active).map((r) => r.name);
1045
- // CONCURRENT, not serial. Awaiting inside the loop made `warm`
1046
- // cost the SUM of every provider round-trip, which defeats the one
1047
- // thing the method exists for: resolving the fleet in a batch at
1048
- // startup rather than paying per first-request. sekreto exposes no
1049
- // batch `get`, so concurrency is the available shape.
1050
- //
1051
- // Names are deduped first: the broker's resolution cache is keyed
1052
- // by SECRET NAME (§5.3), so several instances sharing one api-level
1053
- // `secret` should cost one round-trip — and firing them together
1054
- // would race past the cache and make several.
1055
691
  const plan = [];
1056
692
  const warmed = [];
1057
693
  const missed = [];
1058
694
  for (const name of wanted) {
1059
- // THE REGISTRY IS THE AUTHORITY, and asking the profile instead
1060
- // was wrong twice: an IMPERATIVE instance has no `profile.sdk`
1061
- // block, so it was pushed to `missed` and never warmed at all —
1062
- // and the re-derivation dropped the in-code `secret` feature
1063
- // option, which beats the profile (§9). A registered instance
1064
- // already carries the resolved name.
1065
- // A NAME NOBODY DECLARED OR REGISTERED IS A MISS, not a lookup.
1066
- // Widening the fallback to cover imperative instances also let a
1067
- // typo — `stripe$prodd` — derive a secret name and call the
1068
- // provider, so a nonexistent instance could be reported `warmed`
1069
- // off a shared api-level credential. Registered OR declared, and
1070
- // nothing else.
1071
695
  const entry = this.registry.get(name);
1072
696
  if (null == entry && null == this.profile.sdk[name]) {
1073
697
  missed.push(name);
@@ -1151,12 +775,6 @@ class Station {
1151
775
  return {
1152
776
  mode: 'solo',
1153
777
  profile: this.profile.name,
1154
- // §7.1: the registry is keyed by INSTANCE, so a status page that
1155
- // projects only `slug` shows two indistinguishable rows for
1156
- // `stripe$test` and `stripe$live` and omits the names it is keyed
1157
- // by — an operator cannot tell which one is unhealthy. `slug`
1158
- // stays for compatibility; `name` and `api` are what answer the
1159
- // question.
1160
778
  plugins: this.plugins().map((p) => ({
1161
779
  name: p.name, api: p.api, slug: p.slug, rung: p.rung,
1162
780
  })),
@@ -1169,10 +787,6 @@ class Station {
1169
787
  refreshSecrets() {
1170
788
  this.broker.refresh();
1171
789
  }
1172
- // close(): flush (solo: nothing in flight), then warn on profile
1173
- // plugin keys that matched no registered plugin - a typo'd key
1174
- // silently configuring nothing is the worst outcome for a
1175
- // secrets-and-policy file (design §11).
1176
790
  close() {
1177
791
  if (this.closed) {
1178
792
  return;