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