@voxgig/station 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +88 -0
  3. package/dist/src/Station.d.ts +238 -0
  4. package/dist/src/Station.js +1213 -0
  5. package/dist/src/Station.js.map +1 -0
  6. package/dist/src/adapter.d.ts +9 -0
  7. package/dist/src/adapter.js +171 -0
  8. package/dist/src/adapter.js.map +1 -0
  9. package/dist/src/config-shape.d.ts +1 -0
  10. package/dist/src/config-shape.js +329 -0
  11. package/dist/src/config-shape.js.map +1 -0
  12. package/dist/src/descriptor.d.ts +8 -0
  13. package/dist/src/descriptor.js +170 -0
  14. package/dist/src/descriptor.js.map +1 -0
  15. package/dist/src/error.d.ts +8 -0
  16. package/dist/src/error.js +60 -0
  17. package/dist/src/error.js.map +1 -0
  18. package/dist/src/events.d.ts +15 -0
  19. package/dist/src/events.js +46 -0
  20. package/dist/src/events.js.map +1 -0
  21. package/dist/src/factory.d.ts +24 -0
  22. package/dist/src/factory.js +79 -0
  23. package/dist/src/factory.js.map +1 -0
  24. package/dist/src/feature.d.ts +106 -0
  25. package/dist/src/feature.js +324 -0
  26. package/dist/src/feature.js.map +1 -0
  27. package/dist/src/index.browser.d.ts +14 -0
  28. package/dist/src/index.browser.js +64 -0
  29. package/dist/src/index.browser.js.map +1 -0
  30. package/dist/src/index.d.ts +14 -0
  31. package/dist/src/index.js +56 -0
  32. package/dist/src/index.js.map +1 -0
  33. package/dist/src/loader.d.ts +45 -0
  34. package/dist/src/loader.js +181 -0
  35. package/dist/src/loader.js.map +1 -0
  36. package/dist/src/profile.d.ts +6 -0
  37. package/dist/src/profile.js +77 -0
  38. package/dist/src/profile.js.map +1 -0
  39. package/dist/src/profilecore.d.ts +10 -0
  40. package/dist/src/profilecore.js +180 -0
  41. package/dist/src/profilecore.js.map +1 -0
  42. package/dist/src/secrets.d.ts +12 -0
  43. package/dist/src/secrets.js +93 -0
  44. package/dist/src/secrets.js.map +1 -0
  45. package/dist/src/shape.d.ts +42 -0
  46. package/dist/src/shape.js +444 -0
  47. package/dist/src/shape.js.map +1 -0
  48. package/dist/src/types.d.ts +138 -0
  49. package/dist/src/types.js +5 -0
  50. package/dist/src/types.js.map +1 -0
  51. package/package.json +60 -0
  52. package/src/Station.ts +1310 -0
  53. package/src/adapter.ts +183 -0
  54. package/src/config-shape.ts +326 -0
  55. package/src/descriptor.ts +179 -0
  56. package/src/error.ts +64 -0
  57. package/src/events.ts +45 -0
  58. package/src/factory.ts +93 -0
  59. package/src/feature.ts +321 -0
  60. package/src/index.browser.ts +56 -0
  61. package/src/index.ts +50 -0
  62. package/src/loader.ts +188 -0
  63. package/src/profile.ts +67 -0
  64. package/src/profilecore.ts +201 -0
  65. package/src/secrets.ts +96 -0
  66. package/src/shape.ts +435 -0
  67. package/src/types.ts +185 -0
package/src/Station.ts ADDED
@@ -0,0 +1,1310 @@
1
+ import { adapterFeature } from './adapter'
2
+ import { StationError } from './error'
3
+ import { EventBuffer } from './events'
4
+ import { canonicalSerialize, normalizeDescriptor, secretnameDefault } from './descriptor'
5
+ import {
6
+ refapi, resolveProfile, selectProfile, ResolvedProfile,
7
+ } from './profilecore'
8
+ import { FactoryEntry, factoryFor, provide } from './factory'
9
+ import { loadAsync, loadSync } from './loader'
10
+ import {
11
+ checkfeatures, checkpin, composefeatures, featuresources, mergefeatures,
12
+ resolveorder,
13
+ } from './feature'
14
+ import { normalizeConfig, validateConfig } from './shape'
15
+ import { SecretBroker, placeholderFor } from './secrets'
16
+ import {
17
+ Binding, PluginEntry, ResolvedInstance, SdkBlock, StationEvent,
18
+ StationOptions,
19
+ } from './types'
20
+
21
+ // The station library core, solo mode (design D1): fully functional
22
+ // in-process with no other component running. The proxy (D2) is a
23
+ // deferred amplifier - `require` therefore fails on the operation path
24
+ // (design §2.1/§14), and `auto` degrades to solo with one warning event.
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
+ export function instanceRef(api: string, fopts?: any): string {
66
+ const explicit = firstNonEmpty(fopts?.instance)
67
+ if (null != explicit) { return checkref(checkapi(api, explicit)) }
68
+
69
+ const as = firstNonEmpty(fopts?.as)
70
+ // The bare fallback is the SLUG - a name, never a ref: a `$` in it is
71
+ // an invalid name, not an implicit tag.
72
+ if (null == as) {
73
+ if (!checkInstanceName(api)) {
74
+ throw new StationError('station_instance_api',
75
+ 'invalid instance name "' + api + '": a name starts with a letter ' +
76
+ 'or `@` and uses `[a-zA-Z0-9.~_-/]`, max 1024 (§6.1)')
77
+ }
78
+ return api
79
+ }
80
+
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
+ return checkref(-1 === as.indexOf('$') ? api + '$' + as : checkapi(api, as))
96
+ }
97
+
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
+ const REF_NAME_RE = /^[a-zA-Z@][a-zA-Z0-9.~_\-\/]*$/
107
+ const REF_TAG_RE = /^[a-zA-Z0-9.~_-]+$/
108
+ const REF_MAX = 1024
109
+
110
+ export function checkInstanceName(name: string): boolean {
111
+ if ('string' !== typeof name) { return false }
112
+ if (0 === name.length || REF_MAX < name.length) { return false }
113
+ return REF_NAME_RE.test(name)
114
+ }
115
+
116
+ export function checkInstanceTag(tag: string): boolean {
117
+ 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
+ if (0 === tag.length) { return true }
121
+ if (REF_MAX < tag.length) { return false }
122
+ return REF_TAG_RE.test(tag)
123
+ }
124
+
125
+ /** Validate a ref against the joint grammar and return its CANONICAL
126
+ * spelling: a trailing `$` (empty tag) is never kept, so `stripe$` and
127
+ * `stripe` are one registry key rather than two. */
128
+ function checkref(ref: string): string {
129
+ const cut = ref.indexOf('$')
130
+ const name = -1 === cut ? ref : ref.substring(0, cut)
131
+ const tag = -1 === cut ? '' : ref.substring(cut + 1)
132
+ if (!checkInstanceName(name)) {
133
+ throw new StationError('station_instance_api',
134
+ 'invalid instance name "' + name + '" in ref "' + ref + '": a name ' +
135
+ 'starts with a letter or `@` and uses `[a-zA-Z0-9.~_-/]`, max 1024 (§6.1)')
136
+ }
137
+ if (!checkInstanceTag(tag)) {
138
+ throw new StationError('station_instance_api',
139
+ 'invalid instance tag "' + tag + '" in ref "' + ref + '": a tag ' +
140
+ 'uses `[a-zA-Z0-9.~_-]`, max 1024 (§6.1)')
141
+ }
142
+ return '' === tag ? name : ref
143
+ }
144
+
145
+ function checkapi(api: string, ref: string): string {
146
+ if (refapi(ref) !== api) {
147
+ throw new StationError('station_instance_api',
148
+ 'instance "' + ref + '" names api "' + refapi(ref) + '", but the SDK ' +
149
+ 'passed is api "' + api + '"; `as` is a tag, not a free name (§6.1)')
150
+ }
151
+ return ref
152
+ }
153
+
154
+ export class Station {
155
+ private static ambient: Station | null = null
156
+ private static ambientOpts: string | null = null
157
+
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
+ static provide(api: string, factory: any): void {
167
+ provide(api, factory)
168
+ }
169
+
170
+ private opts: StationOptions
171
+ private profile: ResolvedProfile
172
+ private broker: SecretBroker
173
+ private buffer: EventBuffer
174
+ private registry = new Map<string, PluginEntry>()
175
+ // §6.1: `sdk(name)` caches; `create()` deliberately does not.
176
+ private clients = new Map<string, any>()
177
+ // The raw config, kept for §8.7's provenance: the resolved profile
178
+ // has already collapsed the levels that provenance has to name.
179
+ private raw: any = null
180
+ private repoScoped = true
181
+ private requireProxy: boolean
182
+ /** An auto-assigned tag to the DECLARED instance it stands for
183
+ * (§5.3). Kept beside the registry rather than inside it because the
184
+ * mapping exists before construction, and `blockFor` needs it during
185
+ * registration. */
186
+ private aliasOf = new Map<string, string>()
187
+
188
+ private closed = false
189
+
190
+ // Ambient instance (design §10.2): open() is the idempotent
191
+ // process-wide singleton; a second open() with conflicting options is
192
+ // an error; `new Station(opts)` stays isolated for tests and
193
+ // multi-tenant hosts. open() is non-blocking - solo involves no
194
+ // network, and the deferred proxy probe must never change that.
195
+ static open(opts?: StationOptions): Station {
196
+ const key = JSON.stringify(opts || {})
197
+ if (null != Station.ambient) {
198
+ if (key !== Station.ambientOpts) {
199
+ throw new StationError('station_open_conflict',
200
+ 'Station.open() was already called with different options')
201
+ }
202
+ return Station.ambient
203
+ }
204
+ Station.ambient = new Station(opts)
205
+ Station.ambientOpts = key
206
+ return Station.ambient
207
+ }
208
+
209
+ // The ambient instance, or null - never creates one. The generated
210
+ // station feature binds through this when no explicit handle rides
211
+ // its options (design §3.1: binding is never implicit; only open()
212
+ // creates the ambient instance).
213
+ static current(): Station | null {
214
+ return Station.ambient
215
+ }
216
+
217
+ // Test seam: drop the ambient instance.
218
+ static reset(): void {
219
+ Station.ambient = null
220
+ Station.ambientOpts = null
221
+ }
222
+
223
+ constructor(opts?: StationOptions) {
224
+ this.opts = opts || {}
225
+
226
+ const config = undefined !== this.opts.config
227
+ ? 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.
238
+ this.repoScoped = this.opts.repoScoped
239
+ ?? (undefined !== this.opts.config
240
+ ? 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).
254
+ if (null != config) {
255
+ validateConfig(normalizeConfig(config))
256
+ }
257
+
258
+ this.raw = config ?? null
259
+ this.profile = resolveProfile(config ?? null, selectProfile(this.opts.profile))
260
+ this.broker = new SecretBroker(this.profile.providers)
261
+ this.buffer = new EventBuffer()
262
+
263
+ const proxy = this.opts.proxy ?? 'auto'
264
+ this.requireProxy = 'require' === proxy
265
+
266
+ if ('auto' === proxy) {
267
+ // The probe is deferred with the proxy itself; absence degrades
268
+ // to solo with a single warning event naming the cause (§14).
269
+ this.emit({
270
+ t: Date.now(), kind: 'station',
271
+ meta: { warn: 'proxy absent (not found); running solo' },
272
+ })
273
+ }
274
+ }
275
+
276
+ // --- binding forms (design §3.1) ---
277
+
278
+ // connect(SDK, opts): station constructs the SDK itself, activating
279
+ // the adapter with §3.3 ordering. The activation entry plus the
280
+ // extend-supplied instance ride the tolerance added to the generated
281
+ // constructor (sdkgen §9.3 change).
282
+ connect(SDK: any, opts?: any): any {
283
+ return this.construct(SDK, opts)
284
+ }
285
+
286
+ // adopt(SDK, opts): the retrofit path - construction-time sugar, not
287
+ // post-hoc attachment (§3.1). In ts it is the same construction as
288
+ // connect; a resident options.apikey is hoisted by the adapter.
289
+ adopt(SDK: any, opts?: any): any {
290
+ return this.construct(SDK, opts)
291
+ }
292
+
293
+ private construct(SDK: any, opts?: any): any {
294
+ if (this.closed) {
295
+ throw new StationError('station_no_plugin', 'station is closed')
296
+ }
297
+ opts = opts || {}
298
+ const fmap = { ...(opts.feature || {}) }
299
+ fmap['station'] = {
300
+ ...(fmap['station'] || {}),
301
+ 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 }),
307
+ }
308
+ const options = {
309
+ ...opts,
310
+ feature: fmap,
311
+ // The carried adapter rides extend for SDKs generated WITHOUT
312
+ // the station feature; when the generated class exists the
313
+ // constructor uses it and the extend copy is skipped by name
314
+ // (both delegate to featureBinding, so behavior is identical).
315
+ extend: [...(opts.extend || []), adapterFeature(this, opts)],
316
+ }
317
+ return new SDK(options)
318
+ }
319
+
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
+ options(a?: any, b?: any): any {
329
+ const named = 'string' === typeof a
330
+ const instance: string | undefined = named ? a : undefined
331
+ const extra = (named ? b : a) || {}
332
+ const fmap = { ...(extra.feature || {}) }
333
+ fmap['station'] = {
334
+ ...(fmap['station'] || {}),
335
+ active: true, station: this, calleropts: extra,
336
+ ...(null == instance ? {} : { instance }),
337
+ }
338
+ return { ...extra, feature: fmap }
339
+ }
340
+
341
+ // --- registration (design §3 item 1, called by the adapter) ---
342
+
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
+ _boundEntry(client: any): PluginEntry | null {
351
+ for (const entry of this.registry.values()) {
352
+ if (entry.client === client) { return entry }
353
+ }
354
+ return null
355
+ }
356
+
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
+ private blockFor(name: string): SdkBlock | undefined {
373
+ return this.profile.sdk[this.declaredRef(name)] ??
374
+ this.profile.api[refapi(name)]
375
+ }
376
+
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
+ private declaredRef(name: string): string {
382
+ return this.aliasOf.get(name) ?? name
383
+ }
384
+
385
+ _register(client: any, config: any, options: any, _calleropts: any,
386
+ fopts?: any):
387
+ { binding: Binding, profilePlugin?: SdkBlock } {
388
+
389
+ const { descriptor, warnings } = this.describe(config, options.feature)
390
+ const api = descriptor.slug
391
+
392
+ // §7.5: station knows the instance name before construction begins
393
+ // and passes it through the feature options. A bare connect(SDK)
394
+ // with no name falls back to the descriptor slug, which is today's
395
+ // behaviour and why the single-instance case is unchanged.
396
+ const name = instanceRef(api, fopts)
397
+
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
+ if (this.registry.has(name)) {
402
+ throw new StationError('station_bound_twice',
403
+ 'instance "' + name + '" is already registered; binding one client ' +
404
+ 'twice is an error (§10.2)')
405
+ }
406
+
407
+ 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
+ const secretname = firstNonEmpty(fopts?.secret, profilePlugin?.secret) ||
431
+ secretnameDefault(this.declaredRef(name))
432
+
433
+ const rung = descriptor.auth.active ? 'R1' : 'none'
434
+ const binding: Binding = {
435
+ plugin: name,
436
+ 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
+ placeholder: descriptor.auth.active ? placeholderFor(name) : undefined,
441
+ secretname: descriptor.auth.active ? secretname : undefined,
442
+ rung,
443
+ }
444
+
445
+ this.registry.set(name, {
446
+ name, api, descriptor, rung, client, warnings,
447
+ secretname: descriptor.auth.active ? secretname : undefined,
448
+ })
449
+
450
+ for (const w of warnings) {
451
+ this.emit({
452
+ t: Date.now(), kind: 'station', plugin: name, api,
453
+ meta: { warn: w },
454
+ })
455
+ }
456
+ this.emit({
457
+ t: Date.now(), kind: 'construct', plugin: name, api,
458
+ meta: { name: descriptor.name, version: descriptor.version, rung },
459
+ })
460
+
461
+ return { binding, profilePlugin }
462
+ }
463
+
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
+ private descriptorCache = new Map<string, { descriptor: any, warnings: string[] }>()
475
+
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
+ private describe(config: any, _feature: any):
493
+ { descriptor: any, warnings: string[] } {
494
+ const slug = String(config?.main?.slug ?? '')
495
+ if ('' !== slug) {
496
+ const hit = this.descriptorCache.get(slug)
497
+ if (null != hit) { return hit }
498
+ }
499
+ const out = normalizeDescriptor(config, undefined)
500
+ this.descriptorCache.set(out.descriptor.slug, out)
501
+ return out
502
+ }
503
+
504
+ _hoist(name: string, value: string): void {
505
+ this.broker.hoist(name, value)
506
+ this.emit({
507
+ t: Date.now(), kind: 'station', plugin: name, api: refapi(name),
508
+ meta: {
509
+ warn: 'a resident credential was hoisted into the broker and ' +
510
+ 'replaced by the placeholder; prefer configuring the secret ' +
511
+ 'name and letting sekreto resolve it',
512
+ },
513
+ })
514
+ }
515
+
516
+ // --- the transport middleware (design §3.3, §5.3) ---
517
+
518
+ async _transport(name: string, inner: any, fctx: any, fullurl: string,
519
+ fetchdef: any): Promise<any> {
520
+
521
+ // Fail-closed means traffic (§2.1): with the proxy deferred,
522
+ // `require` can never attach, so every operation fails here - the
523
+ // operation path, never the constructor.
524
+ if (this.requireProxy) {
525
+ const err = new StationError('station_no_proxy',
526
+ 'proxy: "require" is set and no proxy is attached')
527
+ this.emitErr(name, fctx, err)
528
+ return err
529
+ }
530
+
531
+ const entry = this.registry.get(name)
532
+ const placeholder = placeholderFor(name)
533
+ const live = 'live' === fctx.client._mode
534
+ const profilePlugin = this.blockFor(name)
535
+
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
+ const mode = profilePlugin?.policy?.mode ?? 'live'
551
+ if ('live' !== mode && live) {
552
+ const err = 'block' === mode
553
+ ? new StationError('station_host_allow',
554
+ 'egress denied: plugin "' + name + '" is policy mode "block", ' +
555
+ 'the kill switch (§16)')
556
+ : new StationError('station_no_proxy',
557
+ 'policy mode "' + mode + '" for plugin "' + name + '" needs an ' +
558
+ 'attached proxy, and none is attached')
559
+ this.emitErr(name, fctx, err)
560
+ return err
561
+ }
562
+
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
+ const hosts = profilePlugin?.policy?.hosts
570
+ if (null != hosts && live) {
571
+ let hostname = ''
572
+ try { hostname = new URL(fullurl).hostname } catch (_e) { }
573
+ if (!hosts.includes(hostname)) {
574
+ const err = new StationError('station_host_allow',
575
+ 'egress to "' + hostname + '" denied by the hosts policy of ' +
576
+ 'plugin "' + name + '"')
577
+ this.emitErr(name, fctx, err)
578
+ return err
579
+ }
580
+ }
581
+
582
+ let senddef = fetchdef
583
+ if (null != hosts && live) {
584
+ senddef = { ...senddef, redirect: 'manual' }
585
+ }
586
+
587
+ // Injection: at the last boundary, below every recording feature,
588
+ // and never into mock transports (§3.3) - in test/mock modes the
589
+ // placeholder rides through untouched, so real credentials never
590
+ // enter in-memory mock stores. Copy-on-inject: the object graph
591
+ // reachable from ctx/spec/ctrl keeps the placeholder, ever (§5.3).
592
+ 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
+ const secretname = entry.secretname!
608
+
609
+ let value: string
610
+ try {
611
+ value = await this.broker.value(name, secretname)
612
+ }
613
+ catch (e: any) {
614
+ this.emitErr(name, fctx, e)
615
+ return e instanceof Error ? e : new Error(String(e))
616
+ }
617
+
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
+ }
624
+ }
625
+ }
626
+
627
+ const corr = fctx.station$?.corr
628
+ const started = Date.now()
629
+
630
+ let res: any
631
+ try {
632
+ res = await inner(fctx, fullurl, senddef)
633
+ }
634
+ catch (e: any) {
635
+ this.emitHttp(name, corr, fullurl, senddef, 0, started, 0)
636
+ this.emitErr(name, fctx, e)
637
+ throw e
638
+ }
639
+
640
+ if (res instanceof Error) {
641
+ this.emitHttp(name, corr, fullurl, senddef, 0, started, 0)
642
+ this.emitErr(name, fctx, res)
643
+ return res
644
+ }
645
+
646
+ let bytes = 0
647
+ const cl = res?.headers?.get?.('content-length')
648
+ if (null != cl) { bytes = parseInt(cl, 10) || 0 }
649
+ this.emitHttp(name, corr, fullurl, senddef, res?.status || 0, started, bytes)
650
+
651
+ return res
652
+ }
653
+
654
+ private emitHttp(slug: string, corr: string | undefined, fullurl: string,
655
+ fetchdef: any, status: number, started: number, bytes: number): void {
656
+ let host = '', path = ''
657
+ try {
658
+ const u = new URL(fullurl)
659
+ host = u.host
660
+ path = u.pathname
661
+ } catch (_e) { path = fullurl }
662
+ this.emit({
663
+ t: started, kind: 'http', plugin: slug, api: refapi(slug), corr,
664
+ http: {
665
+ method: fetchdef?.method || 'GET', host, path, status,
666
+ durationMs: Date.now() - started, bytes,
667
+ },
668
+ })
669
+ }
670
+
671
+ private emitErr(name: string, fctx: any, err: any): void {
672
+ 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
+ t: Date.now(), kind: 'error', plugin: name, api: refapi(name),
680
+ corr: fctx?.station$?.corr,
681
+ err: {
682
+ code: err?.code,
683
+ // The scrub keeps an upstream echo of a credential out of the
684
+ // event stream (§7 as revised: exact-value, no length floor).
685
+ message: this.redact(String(err?.message || err)),
686
+ },
687
+ })
688
+ }
689
+
690
+ // Op events from the hook bridge (design §3 item 3).
691
+ _opEvent(name: string, ctx: any, outcome: string): void {
692
+ const st = ctx.station$ || {}
693
+ this.emit({
694
+ 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
+ op: {
698
+ entity: String(ctx.op?.entity ?? ctx.entity?.name ?? ''),
699
+ op: String(ctx.op?.name ?? ''),
700
+ outcome,
701
+ durationMs: null != st.start ? Date.now() - st.start : 0,
702
+ },
703
+ })
704
+ }
705
+
706
+ // --- the query/observe surface (design §3.2, §6) ---
707
+
708
+ // One entry per LIVE INSTANCE (§6.1), and exhaustive: auto-tagged
709
+ // entries are not collapsed here, because inspection, health
710
+ // reporting and cleanup all need to enumerate the clients `create()`
711
+ // produced, which is exactly when you most want them. Truncation is a
712
+ // presentation decision and belongs to `status()`.
713
+ plugins(): {
714
+ name: string, api: string, slug: string, descriptor: any,
715
+ rung: string, secretname?: string, warnings: string[]
716
+ }[] {
717
+ return Array.from(this.registry.values()).map((e) => ({
718
+ name: e.name,
719
+ api: e.api,
720
+ // Retained: it is the api, which is what `slug` always meant here,
721
+ // and dropping it would break every consumer for no gain while
722
+ // the two are equal for untagged instances.
723
+ slug: e.api,
724
+ descriptor: e.descriptor,
725
+ rung: e.rung,
726
+ secretname: e.secretname,
727
+ warnings: e.warnings.slice(),
728
+ }))
729
+ }
730
+
731
+ // --- the declarative front door (design §6) ---
732
+
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
+ sdk(name: string): any {
741
+ const cached = this.clients.get(name)
742
+ if (null != cached) { return cached }
743
+ const client = this.build(name, undefined)
744
+ this.clients.set(name, client)
745
+ return client
746
+ }
747
+
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
+ create(name: string, overrides?: any): any {
767
+ return this.build(name, this.autotag(name), overrides)
768
+ }
769
+
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
+ private autotag(name: string): string {
782
+ const api = refapi(name)
783
+ for (let n = 1; ; n++) {
784
+ const ref = api + '$' + n
785
+ if (!this.registry.has(ref) && null == this.profile.sdk[ref]) {
786
+ return ref
787
+ }
788
+ }
789
+ }
790
+
791
+ private build(name: string, as?: string, overrides?: any): any {
792
+ if (this.closed) {
793
+ throw new StationError('station_no_plugin', 'station is closed')
794
+ }
795
+
796
+ const block = this.profile.sdk[name]
797
+ if (null == block) {
798
+ throw new StationError('station_no_instance',
799
+ 'no declared instance "' + name + '"; declared: [' +
800
+ Object.keys(this.profile.sdk).sort().join(', ') + ']')
801
+ }
802
+ if (false === block.active) {
803
+ throw new StationError('station_instance_inactive',
804
+ 'instance "' + name + '" is declared with `active: false`, which ' +
805
+ 'bars it from running while keeping it visible in instances()')
806
+ }
807
+
808
+ const api = refapi(name)
809
+ const entry = this.resolveFactory(api, block)
810
+
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
+ const faults = checkfeatures(this.featuresOf(name).merged, entry.descriptor)
821
+ 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
+ throw new StationError(faults[0].code as any,
828
+ faults.map((f) => f.message).join('; '))
829
+ }
830
+
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
+ const resolved = this.featuresOf(name)
844
+ const fmap: { [k: string]: any } = {}
845
+ for (const f of composefeatures(
846
+ resolveorder(resolved.merged).filter((o) => 'station' !== o.name))) {
847
+ const { name: fname, ...rest } = f
848
+ fmap[fname] = rest
849
+ }
850
+
851
+ const opts = {
852
+ ...(block.options || {}),
853
+ ...(null == block.base ? {} : { base: block.base }),
854
+ ...(overrides || {}),
855
+ feature: { ...fmap, ...((overrides || {}).feature || {}) },
856
+ }
857
+
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
+ if (null != as && as !== name) { this.aliasOf.set(as, name) }
874
+
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
+ const withAdapter = {
890
+ ...opts,
891
+ extend: [...((opts as any).extend || []), adapterFeature(this, opts)],
892
+ }
893
+
894
+ // The instance name reaches the adapter the same way it does on the
895
+ // imperative path, so registration has one spelling (§7.5).
896
+ return entry.construct(this.options(as ?? name, withAdapter))
897
+ }
898
+
899
+ /** §6.2's three paths, in order of preference: self-registration,
900
+ * `Station.provide`, then the loader. */
901
+ private resolveFactory(api: string, block: SdkBlock): FactoryEntry {
902
+ const direct = factoryFor(api)
903
+ if (null != direct) { return direct }
904
+
905
+ const pkg = this.loaderPackage(api, block)
906
+ if (null != pkg) {
907
+ loadSync(api, pkg, block.export)
908
+ const loaded = factoryFor(api)
909
+ if (null != loaded) { return loaded }
910
+ }
911
+
912
+ throw new StationError('station_no_factory',
913
+ 'no factory for api "' + api + '"; either link a generated package ' +
914
+ 'that self-registers, call Station.provide("' + api + '", ...), or ' +
915
+ 'set `api.' + api + '.package` so the loader can import it')
916
+ }
917
+
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
+ private loaderPackage(api: string, block: SdkBlock): string | undefined {
922
+ const pkg = block.package
923
+ if (null == pkg || '' === pkg) { return undefined }
924
+ if (false === this.opts.load) { return undefined }
925
+
926
+ if (!this.repoScoped) {
927
+ this.emit({
928
+ t: Date.now(), kind: 'station', plugin: api, api,
929
+ meta: {
930
+ warn: 'ignoring `package` for api "' + api + '": it came from a ' +
931
+ 'user-level station.json, which is outside the repo\'s review ' +
932
+ 'boundary; everything else in that config still applies',
933
+ },
934
+ })
935
+ return undefined
936
+ }
937
+ return pkg
938
+ }
939
+
940
+ /** ts/js only: preload ESM packages into the factory table, after
941
+ * which `sdk()` is synchronous again for everything (§6.3). One
942
+ * `await` at startup rather than one per call site. */
943
+ async load(): Promise<void> {
944
+ if (false === this.opts.load) { return }
945
+ for (const name of Object.keys(this.profile.sdk).sort()) {
946
+ const block = this.profile.sdk[name]
947
+ if (false === block.active) { continue }
948
+ const api = refapi(name)
949
+ if (null != factoryFor(api)) { continue }
950
+ const pkg = this.loaderPackage(api, block)
951
+ if (null == pkg) { continue }
952
+ await loadAsync(api, pkg, block.export)
953
+ }
954
+ }
955
+
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
+ featuresOf(name: string): {
963
+ ordered: string[]
964
+ merged: { [k: string]: any }
965
+ from: { [k: string]: { [k: string]: string } }
966
+ } {
967
+ const api = refapi(name)
968
+ const profiles = (this.raw?.profiles || {}) as any
969
+ const base = profiles['default'] || {}
970
+ const overlay = 'default' === this.profile.name
971
+ ? {} : (profiles[this.profile.name] || {})
972
+
973
+ const LEVELS = [
974
+ 'default.feature', 'default.api', 'default.sdk',
975
+ this.profile.name + '.feature',
976
+ this.profile.name + '.api',
977
+ this.profile.name + '.sdk',
978
+ ]
979
+ const sources = featuresources(base, overlay, api, name)
980
+
981
+ // Last writer per (feature, key) wins, and the level that wrote it
982
+ // is what `from` records.
983
+ const from: { [k: string]: { [k: string]: string } } = {}
984
+ sources.forEach((src, i) => {
985
+ if (null == src || 'object' !== typeof src) { return }
986
+ for (const fname of Object.keys(src)) {
987
+ const entry = src[fname]
988
+ if (null == entry || 'object' !== typeof entry) { continue }
989
+ from[fname] = from[fname] || {}
990
+ for (const k of Object.keys(entry)) { from[fname][k] = LEVELS[i] }
991
+ }
992
+ })
993
+
994
+ let merged = mergefeatures(sources)
995
+
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
+ const budget = this.blockFor(name)?.policy?.budget
1014
+ if (null != budget && 'object' === typeof budget && !Array.isArray(budget)) {
1015
+ const prior = merged.ratelimit
1016
+ const entry: any = {
1017
+ ...(null != prior && 'object' === typeof prior ? prior : {}),
1018
+ active: true,
1019
+ }
1020
+ from.ratelimit = from.ratelimit || {}
1021
+ from.ratelimit.active = 'policy.budget'
1022
+ if (null != budget.rps) {
1023
+ entry.rate = budget.rps
1024
+ from.ratelimit.rate = 'policy.budget'
1025
+ }
1026
+ if (null != budget.concurrency) {
1027
+ entry.burst = budget.concurrency
1028
+ from.ratelimit.burst = 'policy.budget'
1029
+ }
1030
+ merged = { ...merged, ratelimit: entry }
1031
+ }
1032
+
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
+ const ordered = resolveorder({ ...merged, station: { active: true } })
1046
+ checkpin(ordered)
1047
+ return { ordered: ordered.map((o) => o.name), merged, from }
1048
+ }
1049
+
1050
+ /** The fleet feature view: instance x feature, effective options, and
1051
+ * which config level set each (§8.7). */
1052
+ 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
+ const f = 'string' === typeof filter
1064
+ ? { instance: filter, api: filter, loose: true }
1065
+ : { ...(filter || {}), loose: false }
1066
+
1067
+ const rows = this.instances()
1068
+ .filter((r) => {
1069
+ if (f.loose) {
1070
+ return null == f.instance || r.name === f.instance || r.api === f.api
1071
+ }
1072
+ if (null != f.instance && r.name !== f.instance && r.api !== f.instance) {
1073
+ return false
1074
+ }
1075
+ return null == f.api || r.api === f.api
1076
+ })
1077
+ .map((r) => ({ instance: r.name, api: r.api, ...this.featuresOf(r.name) }))
1078
+
1079
+ // `feature` filters the ROWS, not the instances: an instance that
1080
+ // does not carry the named feature is not part of the answer, and
1081
+ // the rows that remain are narrowed to it so the view answers
1082
+ // "where is debug on, and with what" rather than "here is
1083
+ // everything, go and look".
1084
+ const want = (filter as any)?.feature
1085
+ if (null == want) { return rows }
1086
+ return rows
1087
+ .filter((row: any) => null != row.merged[want])
1088
+ .map((row: any) => ({
1089
+ instance: row.instance,
1090
+ api: row.api,
1091
+ ordered: row.ordered.filter((n: string) => n === want),
1092
+ merged: { [want]: row.merged[want] },
1093
+ from: { [want]: row.from[want] || {} },
1094
+ }))
1095
+ }
1096
+
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
+ check(): { ok: string[], failed: { name: string, code?: string, message: string }[] } {
1102
+ const ok: string[] = []
1103
+ const failed: { name: string, code?: string, message: string }[] = []
1104
+ for (const row of this.instances()) {
1105
+ if (!row.active) { continue }
1106
+ 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
+ const entry = factoryFor(row.api)
1112
+ if (null != entry) {
1113
+ const faults = checkfeatures(
1114
+ this.featuresOf(row.name).merged, entry.descriptor)
1115
+ if (0 < faults.length) {
1116
+ failed.push({
1117
+ name: row.name, code: faults[0].code,
1118
+ message: faults.map((f) => f.message).join('; '),
1119
+ })
1120
+ continue
1121
+ }
1122
+ }
1123
+ this.sdk(row.name); ok.push(row.name)
1124
+ }
1125
+ catch (e: any) {
1126
+ failed.push({
1127
+ name: row.name, code: e?.code, message: String(e?.message || e),
1128
+ })
1129
+ }
1130
+ }
1131
+ return { ok, failed }
1132
+ }
1133
+
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
+ async warm(names?: string[]): Promise<{ warmed: string[], missed: string[] }> {
1141
+ const wanted = null != names
1142
+ ? names
1143
+ : this.instances().filter((r) => r.active).map((r) => r.name)
1144
+
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
+ const plan: { name: string, secretname: string }[] = []
1156
+ const warmed: string[] = []
1157
+ const missed: string[] = []
1158
+ 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
+ const entry = this.registry.get(name)
1172
+ if (null == entry && null == this.profile.sdk[name]) {
1173
+ missed.push(name)
1174
+ continue
1175
+ }
1176
+ const secretname = entry?.secretname ??
1177
+ (this.blockFor(name)?.secret ||
1178
+ secretnameDefault(this.declaredRef(name)))
1179
+ plan.push({ name, secretname })
1180
+ }
1181
+
1182
+ // One resolution per distinct secret name, awaited together; the
1183
+ // per-instance results are mapped back afterwards so the reported
1184
+ // shape is unchanged.
1185
+ const bysecret = new Map<string, string[]>()
1186
+ for (const p of plan) {
1187
+ const at = bysecret.get(p.secretname)
1188
+ if (null == at) { bysecret.set(p.secretname, [p.name]) }
1189
+ else { at.push(p.name) }
1190
+ }
1191
+
1192
+ const results = await Promise.all(
1193
+ Array.from(bysecret.entries()).map(async ([secretname, names]) => {
1194
+ try {
1195
+ await this.broker.value(names[0], secretname)
1196
+ return { names, ok: true }
1197
+ }
1198
+ catch (_e) { return { names, ok: false } }
1199
+ }))
1200
+
1201
+ for (const r of results) {
1202
+ for (const n of r.names) { (r.ok ? warmed : missed).push(n) }
1203
+ }
1204
+ warmed.sort()
1205
+ missed.sort()
1206
+ return { warmed, missed }
1207
+ }
1208
+
1209
+ // Every DECLARED instance (§6.1) - a different question from
1210
+ // `plugins()`, and the answers differ routinely: a lazily-started
1211
+ // instance is `active: true` and not yet live.
1212
+ instances(): ResolvedInstance[] {
1213
+ const sdk = this.profile.sdk
1214
+ return Object.keys(sdk).sort().map((name) => {
1215
+ const entry = this.registry.get(name)
1216
+ return {
1217
+ name,
1218
+ api: refapi(name),
1219
+ // `active: false` means BARRED FROM RUNNING - a declaration that
1220
+ // stays in the file and here while being refused a client.
1221
+ active: false !== sdk[name].active,
1222
+ live: null != entry,
1223
+ rung: entry?.rung ?? 'none',
1224
+ block: sdk[name],
1225
+ }
1226
+ })
1227
+ }
1228
+
1229
+ // §7.4: accepts an INSTANCE name and returns its api's descriptor -
1230
+ // one object shared by every instance of that api.
1231
+ descriptorOf(slug: string): any {
1232
+ const entry = this.registry.get(slug)
1233
+ if (null == entry) {
1234
+ throw new StationError('station_no_plugin', 'unknown plugin "' + slug +
1235
+ '"; known: [' + Array.from(this.registry.keys()).join(', ') + ']')
1236
+ }
1237
+ return entry.descriptor
1238
+ }
1239
+
1240
+ canonicalDescriptor(slug: string): string {
1241
+ return canonicalSerialize(this.descriptorOf(slug))
1242
+ }
1243
+
1244
+ events(): StationEvent[] {
1245
+ return this.buffer.events()
1246
+ }
1247
+
1248
+ tap(fn: (ev: StationEvent) => void): () => void {
1249
+ return this.buffer.tap(fn)
1250
+ }
1251
+
1252
+ status(): any {
1253
+ return {
1254
+ mode: 'solo',
1255
+ 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
+ plugins: this.plugins().map((p) => ({
1263
+ name: p.name, api: p.api, slug: p.slug, rung: p.rung,
1264
+ })),
1265
+ events: this.buffer.status(),
1266
+ }
1267
+ }
1268
+
1269
+ redact(text: string): string {
1270
+ return this.broker.scrub(text)
1271
+ }
1272
+
1273
+ refreshSecrets(): void {
1274
+ this.broker.refresh()
1275
+ }
1276
+
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
+ close(): void {
1282
+ if (this.closed) { return }
1283
+ for (const slug of Object.keys(this.profile.sdk)) {
1284
+ if (!this.registry.has(slug)) {
1285
+ this.emit({
1286
+ t: Date.now(), kind: 'station',
1287
+ meta: {
1288
+ warn: 'profile plugin key "' + slug +
1289
+ '" matched no registered plugin',
1290
+ },
1291
+ })
1292
+ }
1293
+ }
1294
+ this.closed = true
1295
+ if (Station.ambient === this) {
1296
+ Station.reset()
1297
+ }
1298
+ }
1299
+
1300
+ private emit(ev: StationEvent): void {
1301
+ this.buffer.emit(ev)
1302
+ }
1303
+ }
1304
+
1305
+ function firstNonEmpty(...vals: (string | undefined)[]): string | undefined {
1306
+ for (const v of vals) {
1307
+ if (null != v && '' !== v) { return v }
1308
+ }
1309
+ return undefined
1310
+ }