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