@voxgig/station 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/Station.d.ts +1 -118
- package/dist/src/Station.js +26 -412
- 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 -38
- 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.js +0 -7
- 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.js +0 -91
- 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 -3
- package/src/Station.ts +28 -413
- package/src/adapter.ts +0 -57
- package/src/config-shape.ts +0 -11
- package/src/descriptor.ts +0 -38
- package/src/error.ts +0 -5
- package/src/factory.ts +0 -37
- package/src/feature.ts +0 -87
- package/src/index.ts +0 -7
- package/src/loader.ts +0 -66
- package/src/profile.ts +0 -95
- package/src/secrets.ts +104 -32
- package/src/shape.ts +0 -113
- package/src/types.ts +0 -59
package/src/profile.ts
CHANGED
|
@@ -1,14 +1,3 @@
|
|
|
1
|
-
/* Profile handling, whole: the pure half - selection, the ref grammar
|
|
2
|
-
* and the §3.3 merge - and the file half that finds and reads
|
|
3
|
-
* station.json plus the scope rule hanging off where it was found.
|
|
4
|
-
*
|
|
5
|
-
* These were two modules. `profilecore.ts` held the pure half so the
|
|
6
|
-
* BROWSER ENTRY could reach it without pulling `node:fs`, and `Station`
|
|
7
|
-
* reached the file half through a `ConfigFileIO` seam the node entry
|
|
8
|
-
* registered. Station is server-side only, so the split, the seam and
|
|
9
|
-
* the second entry are all gone and this is one module again. Every
|
|
10
|
-
* import of `./profile` is unchanged - that path always re-exported the
|
|
11
|
-
* pure half. */
|
|
12
1
|
|
|
13
2
|
import Fs from 'node:fs'
|
|
14
3
|
import Os from 'node:os'
|
|
@@ -21,14 +10,6 @@ import { StationError } from './error'
|
|
|
21
10
|
import { BLOCK_DEFAULTS } from './shape'
|
|
22
11
|
import { StationConfig, SdkBlock } from './types'
|
|
23
12
|
|
|
24
|
-
// Profile selection: VOXGIG_STATION_PROFILE, else the open() option,
|
|
25
|
-
// else 'default' (design §3.5 - env vars rank above station.json but
|
|
26
|
-
// below open() opts; profile NAME selection follows the same order with
|
|
27
|
-
// open() opts winning).
|
|
28
|
-
//
|
|
29
|
-
// `process` is read directly. It used to go through a `typeof` guard
|
|
30
|
-
// because the browser entry reached this code and has no `process`;
|
|
31
|
-
// station is server-side only, so the guard guarded nothing.
|
|
32
13
|
export function selectProfile(optProfile?: string): string {
|
|
33
14
|
if (null != optProfile && '' !== optProfile) { return optProfile }
|
|
34
15
|
const env = process.env.VOXGIG_STATION_PROFILE
|
|
@@ -36,14 +17,6 @@ export function selectProfile(optProfile?: string): string {
|
|
|
36
17
|
return 'default'
|
|
37
18
|
}
|
|
38
19
|
|
|
39
|
-
// The api half of a ref is the substring before the first `$`, and an
|
|
40
|
-
// untagged ref IS an api slug (design §3.4) - which is why the whole
|
|
41
|
-
// `plugin` -> `sdk` migration is a key rename and nothing else.
|
|
42
|
-
//
|
|
43
|
-
// LEXICAL, and that is the point: under the old free-form identity
|
|
44
|
-
// which api an instance used was itself a merged value, so the merge
|
|
45
|
-
// could not be a flat pass over both maps and a port that got the
|
|
46
|
-
// phasing wrong silently picked another api's defaults (§3.3).
|
|
47
20
|
export function refapi(ref: string): string {
|
|
48
21
|
const at = String(ref).indexOf('$')
|
|
49
22
|
return -1 === at ? String(ref) : String(ref).slice(0, at)
|
|
@@ -52,23 +25,12 @@ export function refapi(ref: string): string {
|
|
|
52
25
|
export type ResolvedProfile = {
|
|
53
26
|
name: string
|
|
54
27
|
providers: any[]
|
|
55
|
-
// The api-level defaults in effect for this profile, base ⊕ overlay
|
|
56
|
-
// per api slug. A REPORT, not an input to the instance merge below -
|
|
57
|
-
// collapsing each namespace first and composing at the end is the
|
|
58
|
-
// exact algorithm §3.3 forbids.
|
|
59
28
|
api: Record<string, SdkBlock>
|
|
60
29
|
// Instances, keyed by ref. An `api` block declares no instance of its
|
|
61
30
|
// own (§3.1), so it never creates an entry here.
|
|
62
31
|
sdk: Record<string, SdkBlock>
|
|
63
32
|
}
|
|
64
33
|
|
|
65
|
-
// Shallow merge, per key, left to right - each source over the one
|
|
66
|
-
// before it. Declared rather than assumed: plugin's library default is
|
|
67
|
-
// a deep map merge, and an allowlist that widens because two precedence
|
|
68
|
-
// levels merged is the failure this rule exists to prevent
|
|
69
|
-
// (station-and-plugin.md §2.5). The visible consequence: an overlay's
|
|
70
|
-
// `policy` REPLACES the base's entirely rather than merging `hosts`
|
|
71
|
-
// into it. That is also the safer reading for an allowlist.
|
|
72
34
|
function shallow(...sources: any[]): any {
|
|
73
35
|
const out: any = {}
|
|
74
36
|
for (const src of sources) {
|
|
@@ -78,29 +40,6 @@ function shallow(...sources: any[]): any {
|
|
|
78
40
|
return out
|
|
79
41
|
}
|
|
80
42
|
|
|
81
|
-
// Merge the base profile ('default') with the selected overlay.
|
|
82
|
-
//
|
|
83
|
-
// §3.3's total order, for the two block levels - lowest precedence
|
|
84
|
-
// first:
|
|
85
|
-
//
|
|
86
|
-
// base.api[<api>] ⊕ base.sdk[<ref>] ⊕ overlay.api[<api>] ⊕ overlay.sdk[<ref>]
|
|
87
|
-
//
|
|
88
|
-
// PROFILE SPECIFICITY OUTRANKS BLOCK SPECIFICITY. A `prod` api-level
|
|
89
|
-
// `base` beats a `default` instance-level `base`, because that is what
|
|
90
|
-
// an environment overlay is for; within one profile the instance beats
|
|
91
|
-
// the api default, because that is what an instance is for.
|
|
92
|
-
//
|
|
93
|
-
// THIS IS ONE FLAT LEFT-TO-RIGHT MERGE AND MUST NOT BE REORGANIZED INTO
|
|
94
|
-
// "collapse each namespace, then put instance over api". Collapsing
|
|
95
|
-
// first computes (base.api ⊕ overlay.api) and (base.sdk ⊕ overlay.sdk)
|
|
96
|
-
// and then lets EVERY instance value beat EVERY api value - the exact
|
|
97
|
-
// opposite. A production `api.stripe.policy` would then fail to
|
|
98
|
-
// override a default profile's `sdk.stripe$test.policy`, silently
|
|
99
|
-
// keeping the wider allowlist in production.
|
|
100
|
-
//
|
|
101
|
-
// `secrets.providers` replaces wholesale, never merges (§3.5, §5.2):
|
|
102
|
-
// chain order decides which store wins, so a positional merge would be
|
|
103
|
-
// actively dangerous.
|
|
104
43
|
export function resolveProfile(
|
|
105
44
|
config: StationConfig | null, profileName: string
|
|
106
45
|
): ResolvedProfile {
|
|
@@ -129,13 +68,6 @@ export function resolveProfile(
|
|
|
129
68
|
overlay.sdk?.[ref],
|
|
130
69
|
)
|
|
131
70
|
|
|
132
|
-
// Defaults are applied ONCE, to the fully merged instance, and this
|
|
133
|
-
// is the rule §4.2's normalization must not be confused with. Had
|
|
134
|
-
// the overlay block carried a synthesized `active: true` into the
|
|
135
|
-
// merge, a one-key environment override would silently re-enable an
|
|
136
|
-
// integration the base declared `active: false` - live in
|
|
137
|
-
// production despite being declared inactive. The `instance` corpus
|
|
138
|
-
// section carries exactly this case.
|
|
139
71
|
for (const k of Object.keys(BLOCK_DEFAULTS)) {
|
|
140
72
|
if (undefined === merged[k]) { merged[k] = BLOCK_DEFAULTS[k]() }
|
|
141
73
|
}
|
|
@@ -148,23 +80,6 @@ export function resolveProfile(
|
|
|
148
80
|
return { name: profileName, providers, api, sdk }
|
|
149
81
|
}
|
|
150
82
|
|
|
151
|
-
// A configured secret name sekreto would reject is caught at profile
|
|
152
|
-
// load, not first request (§14 station_secret_name) - and then the
|
|
153
|
-
// derived names are checked for uniqueness, because envtoken is LOSSY.
|
|
154
|
-
//
|
|
155
|
-
// It collapses any run of non-alphanumerics to `_`, so `$` and `-` are
|
|
156
|
-
// indistinguishable downstream: the ref `stripe$test` and an untagged
|
|
157
|
-
// instance of an api slugged `stripe-test` both derive
|
|
158
|
-
// `stripe_test.apikey`. That pairing is the realistic one rather than a
|
|
159
|
-
// contrived one, because api slugs are hyphenated by construction - so
|
|
160
|
-
// a fleet with both a `stripe-test` api and a `test` instance of
|
|
161
|
-
// `stripe` collides by default. Since the resolution cache is keyed by
|
|
162
|
-
// secret name (§5.3), the second instance would silently receive the
|
|
163
|
-
// first's value.
|
|
164
|
-
//
|
|
165
|
-
// Two instances that EXPLICITLY name the same secret are not a
|
|
166
|
-
// collision - that is the shared-key case the api-level `secret` exists
|
|
167
|
-
// for, and saying so out loud is how you ask for it.
|
|
168
83
|
function checksecrets(
|
|
169
84
|
sdk: Record<string, SdkBlock>, profileName: string
|
|
170
85
|
): void {
|
|
@@ -206,9 +121,6 @@ function sortedkeys(...maps: any[]): string[] {
|
|
|
206
121
|
return Array.from(keys).sort()
|
|
207
122
|
}
|
|
208
123
|
|
|
209
|
-
// station.json lookup: cwd upward to the repo root, then
|
|
210
|
-
// ~/.voxgig/station.json (design §3.5). A repo root is where .git lives;
|
|
211
|
-
// with no repo the walk stops at the filesystem root.
|
|
212
124
|
export function findConfigFile(from?: string): string | null {
|
|
213
125
|
let dir = Path.resolve(from || process.cwd())
|
|
214
126
|
for (; ;) {
|
|
@@ -239,13 +151,6 @@ export function loadConfig(from?: string): StationConfig | null {
|
|
|
239
151
|
}
|
|
240
152
|
}
|
|
241
153
|
|
|
242
|
-
// Which side of the review boundary the config came from (§6.3).
|
|
243
|
-
//
|
|
244
|
-
// `package` and `export` are honoured only from REPO-SCOPED config,
|
|
245
|
-
// because a user-level file is outside the repo's review boundary and a
|
|
246
|
-
// `package` key arriving from it names code to import. Everything else
|
|
247
|
-
// in a user-level config still applies - this narrows one key rather
|
|
248
|
-
// than distrusting the file.
|
|
249
154
|
export function configScope(from?: string): 'repo' | 'user' | 'none' {
|
|
250
155
|
const file = findConfigFile(from)
|
|
251
156
|
if (null == file) { return 'none' }
|
package/src/secrets.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Sekreto, SekretoError } from '@voxgig/sekreto'
|
|
2
|
+
import { allplugins } from '@voxgig/sekreto/plugins'
|
|
2
3
|
|
|
3
4
|
import { StationError } from './error'
|
|
4
5
|
|
|
@@ -6,14 +7,109 @@ import { StationError } from './error'
|
|
|
6
7
|
// broker holds resolved values privately - they never enter options,
|
|
7
8
|
// events, or captures; the SDK sees only the placeholder.
|
|
8
9
|
|
|
9
|
-
// §7.2: keyed by INSTANCE. Two live instances of one api must have
|
|
10
|
-
// distinct placeholders or the injection seam cannot tell which
|
|
11
|
-
// credential a header wants. For an untagged instance this is the api
|
|
12
|
-
// slug, so the single-instance case is unchanged.
|
|
13
10
|
export function placeholderFor(slug: string): string {
|
|
14
11
|
return '[station:' + slug + ']'
|
|
15
12
|
}
|
|
16
13
|
|
|
14
|
+
const UNRESERVED = /^[A-Za-z0-9\-._~]$/
|
|
15
|
+
const HEXPAIR = /^[0-9A-Fa-f]{2}$/
|
|
16
|
+
|
|
17
|
+
// Every UTF-8 byte outside RFC 3986's unreserved set becomes upper-case %XX.
|
|
18
|
+
export function encodeQueryValue(value: string): string {
|
|
19
|
+
let out = ''
|
|
20
|
+
for (const byte of Buffer.from(value, 'utf8')) {
|
|
21
|
+
const ch = String.fromCharCode(byte)
|
|
22
|
+
out += byte < 0x80 && UNRESERVED.test(ch)
|
|
23
|
+
? ch
|
|
24
|
+
: '%' + byte.toString(16).toUpperCase().padStart(2, '0')
|
|
25
|
+
}
|
|
26
|
+
return out
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function matchAt(text: string, at: number, end: number, placeholder: string): number {
|
|
30
|
+
let i = at
|
|
31
|
+
for (const ch of placeholder) {
|
|
32
|
+
if (i < end && text[i] === ch) { i++; continue }
|
|
33
|
+
const hex = text.substring(i + 1, i + 3)
|
|
34
|
+
if (i + 3 <= end && '%' === text[i] && HEXPAIR.test(hex) &&
|
|
35
|
+
parseInt(hex, 16) === ch.charCodeAt(0)) {
|
|
36
|
+
i += 3
|
|
37
|
+
continue
|
|
38
|
+
}
|
|
39
|
+
return -1
|
|
40
|
+
}
|
|
41
|
+
return i - at
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The placeholder becomes the value in header values and in the URL query,
|
|
45
|
+
// where an SDK percent-encodes it (any hex case) and the value is encoded in
|
|
46
|
+
// turn. The path and fragment are never touched; the headers are a copy.
|
|
47
|
+
export function substitute(url: string, headers: Record<string, any> | null | undefined,
|
|
48
|
+
placeholder: string, value: string): { url: string, headers: Record<string, any> } {
|
|
49
|
+
const out: Record<string, any> = { ...(headers || {}) }
|
|
50
|
+
if ('' === placeholder) { return { url, headers: out } }
|
|
51
|
+
|
|
52
|
+
for (const h of Object.keys(out)) {
|
|
53
|
+
const v = out[h]
|
|
54
|
+
if ('string' === typeof v && v.includes(placeholder)) {
|
|
55
|
+
out[h] = v.split(placeholder).join(value)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const q = url.indexOf('?')
|
|
60
|
+
const hash = url.indexOf('#')
|
|
61
|
+
if (-1 === q || (-1 !== hash && hash < q)) { return { url, headers: out } }
|
|
62
|
+
const end = -1 === hash ? url.length : hash
|
|
63
|
+
const encoded = encodeQueryValue(value)
|
|
64
|
+
let query = ''
|
|
65
|
+
for (let i = q + 1; i < end;) {
|
|
66
|
+
const n = matchAt(url, i, end, placeholder)
|
|
67
|
+
if (0 < n) {
|
|
68
|
+
query += encoded
|
|
69
|
+
i += n
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
query += url[i]
|
|
73
|
+
i++
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { url: url.substring(0, q + 1) + query + url.substring(end), headers: out }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Every form of every value is matched against the original text and the
|
|
80
|
+
// union of the matches redacted, so no replacement can hide another form.
|
|
81
|
+
function redactValues(text: string, values: string[]): string {
|
|
82
|
+
const forms = new Set<string>()
|
|
83
|
+
for (const value of values) {
|
|
84
|
+
if ('' !== value) {
|
|
85
|
+
forms.add(value)
|
|
86
|
+
forms.add(encodeQueryValue(value))
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const reach = (at: number): number => {
|
|
90
|
+
let end = at
|
|
91
|
+
for (const form of forms) {
|
|
92
|
+
if (at + form.length > end && text.startsWith(form, at)) { end = at + form.length }
|
|
93
|
+
}
|
|
94
|
+
return end
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let out = ''
|
|
98
|
+
for (let i = 0; i < text.length;) {
|
|
99
|
+
let stop = reach(i)
|
|
100
|
+
if (stop === i) {
|
|
101
|
+
out += text[i++]
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
for (let j = i + 1; j < stop; j++) {
|
|
105
|
+
stop = Math.max(stop, reach(j))
|
|
106
|
+
}
|
|
107
|
+
out += '[redacted]'
|
|
108
|
+
i = stop
|
|
109
|
+
}
|
|
110
|
+
return out
|
|
111
|
+
}
|
|
112
|
+
|
|
17
113
|
export class SecretBroker {
|
|
18
114
|
private sekreto: Sekreto
|
|
19
115
|
// Values hoisted by adopt() from resident options.apikey (design §3.1).
|
|
@@ -23,7 +119,7 @@ export class SecretBroker {
|
|
|
23
119
|
private held: string[] = []
|
|
24
120
|
|
|
25
121
|
constructor(providers: any[]) {
|
|
26
|
-
this.sekreto = new Sekreto({ providers })
|
|
122
|
+
this.sekreto = new Sekreto({ plugins: allplugins, providers })
|
|
27
123
|
}
|
|
28
124
|
|
|
29
125
|
hoist(slug: string, value: string): void {
|
|
@@ -31,21 +127,6 @@ export class SecretBroker {
|
|
|
31
127
|
this.held.push(value)
|
|
32
128
|
}
|
|
33
129
|
|
|
34
|
-
// Resolve the value for a plugin's secret name. Misses and store
|
|
35
|
-
// errors keep sekreto's distinction (design §5.2): a miss is
|
|
36
|
-
// station_secret_no_value, a store that could not answer is
|
|
37
|
-
// station_secret_error with sekreto's message intact - and never a
|
|
38
|
-
// retry against a weaker store (sekreto owns the chain).
|
|
39
|
-
// OVERRIDES ARE KEYED BY INSTANCE; THE RESOLUTION CACHE IS KEYED BY
|
|
40
|
-
// SECRET NAME (§5.3). A hoisted credential belongs to the one instance
|
|
41
|
-
// it was resident in, but a resolved VALUE belongs to the name it was
|
|
42
|
-
// resolved for - so several instances sharing one api-level `secret`
|
|
43
|
-
// cost one lookup rather than one each, and every client an
|
|
44
|
-
// auto-tagged `create()` produces shares the declared instance's entry
|
|
45
|
-
// instead of re-resolving per request.
|
|
46
|
-
//
|
|
47
|
-
// Keying the cache by instance instead is the defect this replaces: at
|
|
48
|
-
// 26 instances over 20 apis it turns one store round-trip into 26.
|
|
49
130
|
async value(slug: string, name: string): Promise<string> {
|
|
50
131
|
const override = this.overrides.get(slug)
|
|
51
132
|
if (null != override) { return override }
|
|
@@ -70,19 +151,10 @@ export class SecretBroker {
|
|
|
70
151
|
return value
|
|
71
152
|
}
|
|
72
153
|
|
|
73
|
-
//
|
|
74
|
-
// readability floor (design §7 as revised): on boundaries where the
|
|
75
|
-
// promise is absolute, every held value is scrubbed whatever its
|
|
76
|
-
// length. sekreto's own redact() runs too, covering values resolved
|
|
77
|
-
// by the underlying instance that station never held.
|
|
154
|
+
// sekreto's redact runs second; run first, it could split an encoded form.
|
|
78
155
|
scrub(text: string): string {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if ('' !== value) {
|
|
82
|
-
out = out.split(value).join('[redacted]')
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
return out
|
|
156
|
+
const raw = 'string' === typeof text ? text : ''
|
|
157
|
+
return this.sekreto.redact(redactValues(raw, this.held))
|
|
86
158
|
}
|
|
87
159
|
|
|
88
160
|
// Drop caches so the next resolve asks the stores again (rotation
|
package/src/shape.ts
CHANGED
|
@@ -1,21 +1,3 @@
|
|
|
1
|
-
/* The config grammar, as data (design §4).
|
|
2
|
-
*
|
|
3
|
-
* TWO STEPS, AND THE FIRST IS WHAT MAKES THE SECOND HONEST.
|
|
4
|
-
*
|
|
5
|
-
* struct drops the unexpected-key check for a map whose spec node ends
|
|
6
|
-
* up empty - "an empty spec object means the object can be open". An
|
|
7
|
-
* optional key is `['$ONE','$NIL', spec]`, and when the data does not
|
|
8
|
-
* carry that key the validator REMOVES it from the spec node. So a
|
|
9
|
-
* block whose keys are all optional degenerates into an open map
|
|
10
|
-
* exactly when the data has none of them, and `{"solar": {"bass": 1}}`
|
|
11
|
-
* validates clean - the one property the whole exercise is for,
|
|
12
|
-
* silently absent in the one case that matters.
|
|
13
|
-
*
|
|
14
|
-
* So: normalizeConfig materializes every documented default, and
|
|
15
|
-
* validateConfig then runs a shape WITH NO OPTIONAL CONTAINERS AT ALL.
|
|
16
|
-
* After normalization every container is present, so the shape can
|
|
17
|
-
* require them, so unexpected-key detection is live at every level and
|
|
18
|
-
* every error names its path. */
|
|
19
1
|
|
|
20
2
|
import { clone, validate } from '@voxgig/struct'
|
|
21
3
|
import { validname } from '@voxgig/sekreto'
|
|
@@ -24,9 +6,6 @@ import { CONFIG_SHAPE } from './config-shape'
|
|
|
24
6
|
import { StationError } from './error'
|
|
25
7
|
import { envtoken } from './descriptor'
|
|
26
8
|
|
|
27
|
-
// ---------------------------------------------------------------------
|
|
28
|
-
// The defaults table - ONE table, two callers
|
|
29
|
-
// ---------------------------------------------------------------------
|
|
30
9
|
|
|
31
10
|
/** Profile-level containers. Safe to materialize early either way:
|
|
32
11
|
* they are containers, and a missing one merges as empty regardless. */
|
|
@@ -37,15 +16,6 @@ export const PROFILE_DEFAULTS: { [k: string]: () => any } = {
|
|
|
37
16
|
feature: () => ({}),
|
|
38
17
|
}
|
|
39
18
|
|
|
40
|
-
/** Block-level. `feature` is a container and safe early.
|
|
41
|
-
*
|
|
42
|
-
* `active` IS NOT, and that is the whole timing rule: a default
|
|
43
|
-
* synthesized into an OVERLAY block overwrites the base's real value
|
|
44
|
-
* and silently reactivates an integration the base deliberately barred
|
|
45
|
-
* (§3.3). So the two consumers read this same table at different
|
|
46
|
-
* moments - validateConfig before, to every block, because a block with
|
|
47
|
-
* no present keys is an open map; the resolver AFTER, to the merged
|
|
48
|
-
* instance, because an absent key must stay absent through the merge. */
|
|
49
19
|
export const BLOCK_DEFAULTS: { [k: string]: () => any } = {
|
|
50
20
|
active: () => true,
|
|
51
21
|
feature: () => ({}),
|
|
@@ -60,12 +30,6 @@ export const MERGE_SENSITIVE = ['active']
|
|
|
60
30
|
// normalizeConfig
|
|
61
31
|
// ---------------------------------------------------------------------
|
|
62
32
|
|
|
63
|
-
/** Materialize every documented default, DEFENSIVELY: a node that is
|
|
64
|
-
* not the kind it expects is left alone for validate to reject with a
|
|
65
|
-
* proper message. Pure data-in/data-out, which is what makes it
|
|
66
|
-
* portable to 22 languages and expressible in the corpus.
|
|
67
|
-
*
|
|
68
|
-
* THE NORMALIZED FORM IS AN INPUT TO VALIDATION AND TO NOTHING ELSE. */
|
|
69
33
|
export function normalizeConfig(raw: any): any {
|
|
70
34
|
if (!ismap(raw)) { return raw }
|
|
71
35
|
const out: any = { ...raw }
|
|
@@ -83,7 +47,6 @@ export function normalizeConfig(raw: any): any {
|
|
|
83
47
|
for (const k of Object.keys(PROFILE_DEFAULTS)) {
|
|
84
48
|
if (undefined === prof[k]) { prof[k] = PROFILE_DEFAULTS[k]() }
|
|
85
49
|
}
|
|
86
|
-
// A `secrets` written without `providers` still gets the chain.
|
|
87
50
|
if (ismap(prof.secrets) && undefined === prof.secrets.providers) {
|
|
88
51
|
prof.secrets = { ...prof.secrets, providers: [{ kind: 'env' }] }
|
|
89
52
|
}
|
|
@@ -111,16 +74,6 @@ export function normalizeConfig(raw: any): any {
|
|
|
111
74
|
return out
|
|
112
75
|
}
|
|
113
76
|
|
|
114
|
-
/** Per feature entry, at every level: `active` -> true.
|
|
115
|
-
*
|
|
116
|
-
* A FEATURE NAMED IN THE CONFIG IS ONE YOU ARE ASKING FOR. The SDK's
|
|
117
|
-
* own default is `active: false` for all but `log`, and
|
|
118
|
-
* `{"retry": {"retries": 3}}` plainly means "retry, with three
|
|
119
|
-
* attempts". It also keeps the feature map closed, for the same reason
|
|
120
|
-
* every other block needs one present key.
|
|
121
|
-
*
|
|
122
|
-
* Defensive like the rest: a non-map is returned untouched for validate
|
|
123
|
-
* to reject by path. */
|
|
124
77
|
function normfeatures(f: any): any {
|
|
125
78
|
if (!ismap(f)) { return f }
|
|
126
79
|
const out: any = {}
|
|
@@ -152,32 +105,11 @@ const CREDENTIAL_KEYS = [
|
|
|
152
105
|
'secret', 'password', 'credential', 'bearer',
|
|
153
106
|
]
|
|
154
107
|
|
|
155
|
-
/** The suffix rule catches `access_key`, `X-Api-Token` and friends in
|
|
156
|
-
* one rule rather than a growing list of spellings. */
|
|
157
108
|
const CREDENTIAL_SUFFIX = ['_KEY', '_TOKEN', '_SECRET', '_PASSWORD']
|
|
158
109
|
|
|
159
|
-
/** §5.2's backstop, and it is stated as one rather than as a grammar.
|
|
160
|
-
* `validname()` is a NAME grammar, not a credential filter: it rejects
|
|
161
|
-
* uppercase, hyphens, `+`, `/` and `=`, so it excludes most real
|
|
162
|
-
* credential formats - but a lowercase hex token passes it cleanly. A
|
|
163
|
-
* character class cannot tell a name from a secret.
|
|
164
|
-
*
|
|
165
|
-
* Derived names break on every separator (`voxgig_solardemo.apikey`
|
|
166
|
-
* runs 6/9/6) and a hand-written name for a human to read does too; a
|
|
167
|
-
* 24-character unbroken run is not a name anybody writes. Note this is
|
|
168
|
-
* a RUN bound, not a length bound: `acme_internal_billing_service.apikey`
|
|
169
|
-
* is 36 characters and passes, which is the false positive a naive
|
|
170
|
-
* length bound would produce. */
|
|
171
110
|
const RUN_BOUND = 24
|
|
172
111
|
const UNBROKEN_RUN = new RegExp('[A-Za-z0-9]{' + RUN_BOUND + ',}')
|
|
173
112
|
|
|
174
|
-
/** Normalize, then validate (§4.2). Raises `station_config_invalid`
|
|
175
|
-
* with EVERY struct error at once - an eighteen-instance config that
|
|
176
|
-
* touches three of them must not die because the eighteenth has a
|
|
177
|
-
* typo'd package name - then the §5.2 scans.
|
|
178
|
-
*
|
|
179
|
-
* Takes the NORMALIZED form. Handing it a raw config is the mistake
|
|
180
|
-
* §4.2 exists to prevent, so callers go through open()/loadConfig. */
|
|
181
113
|
export function validateConfig(normalized: any): any {
|
|
182
114
|
const errs: string[] = []
|
|
183
115
|
validate(normalized, configShape(), { errs })
|
|
@@ -190,11 +122,6 @@ export function validateConfig(normalized: any): any {
|
|
|
190
122
|
return normalized
|
|
191
123
|
}
|
|
192
124
|
|
|
193
|
-
/** `plugin` is REMOVED, not aliased (§3.4) - a deprecated alias would
|
|
194
|
-
* be a second grammar for one concept in 17 ports. The shape already
|
|
195
|
-
* rejects it as an unexpected key; this says what to rename, because
|
|
196
|
-
* "unexpected key: plugin" alone does not, and the migration for a
|
|
197
|
-
* single-instance project is exactly this one rename. */
|
|
198
125
|
function renamehint(cfg: any): string {
|
|
199
126
|
const profiles = ismap(cfg) && ismap(cfg.profiles) ? cfg.profiles : {}
|
|
200
127
|
const hit = Object.keys(profiles)
|
|
@@ -205,8 +132,6 @@ function renamehint(cfg: any): string {
|
|
|
205
132
|
' - the keys are unchanged, an untagged ref IS an api slug (§3.4)'
|
|
206
133
|
}
|
|
207
134
|
|
|
208
|
-
/** The §5.2 scans, over the parts of the grammar that hold arbitrary
|
|
209
|
-
* data. Everything else is closed by construction and needs no scan. */
|
|
210
135
|
function scanConfig(cfg: any): void {
|
|
211
136
|
const secrets: string[] = []
|
|
212
137
|
const reserved: string[] = []
|
|
@@ -234,15 +159,9 @@ function scanConfig(cfg: any): void {
|
|
|
234
159
|
secretvalue(block.secret, bpath + '.secret', secrets)
|
|
235
160
|
}
|
|
236
161
|
|
|
237
|
-
// `options` is passthrough to a generated constructor, so it is
|
|
238
|
-
// the one place a value can hide.
|
|
239
162
|
scan(block.options, bpath + '.options', secrets, reserved)
|
|
240
163
|
checkfeatures(block.feature, bpath + '.feature', secrets, reserved, invalid)
|
|
241
164
|
|
|
242
|
-
// §4.4's explicit checks, applied where the shape cannot reach,
|
|
243
|
-
// raising the same code the shape would - and pinned in the
|
|
244
|
-
// corpus so each workaround is removed deliberately when struct
|
|
245
|
-
// is fixed rather than forgotten.
|
|
246
165
|
checkpolicy(block.policy, bpath + '.policy', invalid)
|
|
247
166
|
}
|
|
248
167
|
}
|
|
@@ -282,19 +201,6 @@ function checkfeatures(
|
|
|
282
201
|
}
|
|
283
202
|
}
|
|
284
203
|
|
|
285
|
-
/** The policy block's §4.4 workarounds, in one place because they are
|
|
286
|
-
* one class of gap: struct cannot check what its own defects hide.
|
|
287
|
-
*
|
|
288
|
-
* - `hosts`, `allow.op` and `allow.method` are `$CHILD` string lists,
|
|
289
|
-
* so element 0 escapes the shape (see firstelement below).
|
|
290
|
-
* - `budget` is a map whose keys are ALL optional scalars, and struct
|
|
291
|
-
* removes an unsatisfied optional key from the spec node - so
|
|
292
|
-
* `budget: {rp: 1}` degenerates the spec into an open map and the
|
|
293
|
-
* typo passes. `allow` does not have this problem (its `$CHILD` keys
|
|
294
|
-
* stay in the spec whether or not the data carries them, keeping the
|
|
295
|
-
* map closed), and neither does `policy` itself (`hosts` anchors it);
|
|
296
|
-
* `budget` alone needs the explicit unexpected-key check, phrased as
|
|
297
|
-
* struct would phrase it. */
|
|
298
204
|
const BUDGET_KEYS = ['concurrency', 'rps']
|
|
299
205
|
|
|
300
206
|
function checkpolicy(policy: any, path: string, invalid: string[]): void {
|
|
@@ -319,19 +225,6 @@ function checkpolicy(policy: any, path: string, invalid: string[]): void {
|
|
|
319
225
|
}
|
|
320
226
|
}
|
|
321
227
|
|
|
322
|
-
/** §4.4: `$CHILD` in list mode DOES NOT VALIDATE ELEMENT 0. Verified:
|
|
323
|
-
* `["a", 1]` fails at index 1, `[1]` passes. validate_CHILD installs the
|
|
324
|
-
* template across the data's indices, sets keyI = 0 and returns element
|
|
325
|
-
* 0 as the slot value, and the injection loop resumes past it. An
|
|
326
|
-
* upstream struct defect; filing it is a deliverable of this plan.
|
|
327
|
-
*
|
|
328
|
-
* It reaches EVERY string list in the shape. §4.4 says the only one left
|
|
329
|
-
* is `policy.hosts` because the profile-level `order` list is gone - but
|
|
330
|
-
* §8.4's per-feature `order` carries `before`/`after` string lists, so
|
|
331
|
-
* the reach is three lists, not one, and §4.5 pins `order: [7]`
|
|
332
|
-
* accordingly. Applied where the shape cannot reach, raising the same
|
|
333
|
-
* code the shape would, and pinned in the corpus so the workaround is
|
|
334
|
-
* removed deliberately when struct is fixed rather than forgotten. */
|
|
335
228
|
function firstelement(list: any, path: string, invalid: string[]): void {
|
|
336
229
|
if (!Array.isArray(list) || 0 === list.length) { return }
|
|
337
230
|
if ('string' === typeof list[0]) { return }
|
|
@@ -357,8 +250,6 @@ function scan(
|
|
|
357
250
|
const kpath = path + '.' + key
|
|
358
251
|
const val = node[key]
|
|
359
252
|
|
|
360
|
-
// §8.6: station owns feature composition, so an `options.feature`
|
|
361
|
-
// in a declarative config is a second, unreconciled ordering input.
|
|
362
253
|
if ('feature' === key) {
|
|
363
254
|
reserved.push(kpath + ' is reserved: configure features under the ' +
|
|
364
255
|
'block\'s own `feature` key, not through `options`')
|
|
@@ -387,10 +278,6 @@ function credentialkey(key: string): boolean {
|
|
|
387
278
|
return CREDENTIAL_SUFFIX.some((s) => tok.endsWith(s))
|
|
388
279
|
}
|
|
389
280
|
|
|
390
|
-
/** A `secret`-named key holds a NAME, and that exemption is not a
|
|
391
|
-
* loophole - it is the whole design. Two checks, not one, and they live
|
|
392
|
-
* in the same three lines precisely so a port cannot implement only the
|
|
393
|
-
* first and inherit the gap the second exists to close. */
|
|
394
281
|
function secretvalue(val: any, path: string, secrets: string[]): void {
|
|
395
282
|
if ('string' !== typeof val) {
|
|
396
283
|
secrets.push(path + ' must be a secret name (a string), but found ' +
|
package/src/types.ts
CHANGED
|
@@ -6,18 +6,10 @@ export type StationOptions = {
|
|
|
6
6
|
proxy?: 'auto' | 'off' | 'require' | string
|
|
7
7
|
folder?: string
|
|
8
8
|
config?: StationConfig | null
|
|
9
|
-
// §6.3: disables the loader outright. Only self-registered and
|
|
10
|
-
// explicitly provided factories are used.
|
|
11
9
|
load?: boolean
|
|
12
|
-
// Set when the config came from ~/.voxgig/station.json rather than
|
|
13
|
-
// from inside the repo. A user-level file is outside the repo's
|
|
14
|
-
// review boundary, so a `package` key arriving from it is IGNORED
|
|
15
|
-
// WITH A WARNING rather than imported - everything else in it still
|
|
16
|
-
// applies.
|
|
17
10
|
repoScoped?: boolean
|
|
18
11
|
}
|
|
19
12
|
|
|
20
|
-
// station.json shape (profiles carry sekreto ProviderSpecs verbatim).
|
|
21
13
|
export type StationConfig = {
|
|
22
14
|
station: number
|
|
23
15
|
profiles?: Record<string, Profile>
|
|
@@ -26,28 +18,10 @@ export type StationConfig = {
|
|
|
26
18
|
export type Profile = {
|
|
27
19
|
secrets?: { providers?: any[] }
|
|
28
20
|
feature?: Record<string, any>
|
|
29
|
-
// Keyed by api slug: defaults inherited by every instance of that
|
|
30
|
-
// api, declaring no instance of its own (design §3.1).
|
|
31
21
|
api?: Record<string, SdkBlock>
|
|
32
|
-
// Keyed by REF (`api$tag`, or bare `api` for the untagged instance).
|
|
33
|
-
// This replaces `plugin`, which is REMOVED rather than aliased (§3.4)
|
|
34
|
-
// - a deprecated alias would be a second grammar for one concept in
|
|
35
|
-
// 17 ports. An untagged ref is an api slug, so every existing block
|
|
36
|
-
// means exactly what it meant before under the new key.
|
|
37
22
|
sdk?: Record<string, SdkBlock>
|
|
38
23
|
}
|
|
39
24
|
|
|
40
|
-
// The same key set in both block positions. The two differ in what
|
|
41
|
-
// they KEY, not in what they hold, which is why the two spec objects in
|
|
42
|
-
// config-shape.json are identical and a guard test asserts it (§3.1).
|
|
43
|
-
// Per-plugin policy (design §16). The solo-expressible keys:
|
|
44
|
-
// `hosts` and `mode` gate the operation path in the library; `allow`
|
|
45
|
-
// is set into the SDK's own `options.allow` so enforcement rides the
|
|
46
|
-
// SDK's own pipeline; `budget` configures the SDK's `ratelimit`
|
|
47
|
-
// feature through the ordinary feature composition. `record`,
|
|
48
|
-
// `replay` and `mock` are proxy-era vocabulary, accepted now so a
|
|
49
|
-
// config written for the proxy is not a validation error, and failing
|
|
50
|
-
// `station_no_proxy` on the operation path until one is attached.
|
|
51
25
|
export type PolicyBlock = {
|
|
52
26
|
hosts?: string[]
|
|
53
27
|
allow?: { op?: string[], method?: string[] }
|
|
@@ -62,13 +36,6 @@ export type SdkBlock = {
|
|
|
62
36
|
secret?: string
|
|
63
37
|
resolve?: 'library' | 'proxy'
|
|
64
38
|
policy?: PolicyBlock
|
|
65
|
-
// Read by the PROXY out of this very file (design §8.3: the proxy
|
|
66
|
-
// loads profiles ITSELF): the capture depth it records this
|
|
67
|
-
// instance's exchanges at (§8.5), and §16's per-instance agent write
|
|
68
|
-
// opt-in, which a mutating agent operation needs alongside the
|
|
69
|
-
// daemon's own --agent-write flag. They belong to the SHARED shape
|
|
70
|
-
// because one station.json serves both the application and the
|
|
71
|
-
// proxy; a shape that rejected them would make that impossible.
|
|
72
39
|
capture?: 'meta' | 'headers' | 'full'
|
|
73
40
|
agent?: { write: boolean }
|
|
74
41
|
feature?: Record<string, any>
|
|
@@ -80,8 +47,6 @@ export type SdkBlock = {
|
|
|
80
47
|
active?: boolean
|
|
81
48
|
}
|
|
82
49
|
|
|
83
|
-
// Descriptor v1 (design §4): a view over the SDK's embedded config,
|
|
84
|
-
// normalized - never a second model.
|
|
85
50
|
export type Descriptor = {
|
|
86
51
|
station: 1
|
|
87
52
|
name: string
|
|
@@ -114,15 +79,9 @@ export type DescriptorPoint = {
|
|
|
114
79
|
select?: Record<string, any>
|
|
115
80
|
}
|
|
116
81
|
|
|
117
|
-
// StationEvent v1 (design §6). Unknown fields are ignored by consumers.
|
|
118
82
|
export type StationEvent = {
|
|
119
83
|
t: number
|
|
120
|
-
// The INSTANCE name (§7.3). Keeps its name, so a consumer that only
|
|
121
|
-
// knows `plugin` keeps working - it simply sees instance-grained
|
|
122
|
-
// events, which is what it wants at 20 SDKs anyway.
|
|
123
84
|
plugin?: string
|
|
124
|
-
// The api slug, for grouping. ADDITIVE, so station.md §8.6's wire
|
|
125
|
-
// compatibility rule holds.
|
|
126
85
|
api?: string
|
|
127
86
|
corr?: string
|
|
128
87
|
kind: 'construct' | 'op' | 'http' | 'error' | 'feature' | 'station'
|
|
@@ -135,50 +94,32 @@ export type StationEvent = {
|
|
|
135
94
|
meta?: Record<string, any>
|
|
136
95
|
}
|
|
137
96
|
|
|
138
|
-
// What registration hands back to the adapter (design §3 item 1).
|
|
139
97
|
export type Binding = {
|
|
140
98
|
// The INSTANCE name (a ref). Keeps the field name for wire
|
|
141
99
|
// compatibility - a consumer that only knows `plugin` keeps working
|
|
142
100
|
// and simply sees instance-grained bindings (§7.3).
|
|
143
101
|
plugin: string
|
|
144
|
-
// The api slug, for grouping. Additive.
|
|
145
102
|
instance?: string
|
|
146
103
|
base?: string
|
|
147
104
|
placeholder?: string
|
|
148
|
-
// The instance's EFFECTIVE secret name, and the authority for it
|
|
149
|
-
// (§7.4). A descriptor is shared by every instance of one api and so
|
|
150
|
-
// cannot hold two different instance-derived names; the descriptor's
|
|
151
|
-
// own `auth.secretname` stays the api-level default, which is
|
|
152
|
-
// documentation.
|
|
153
105
|
secretname?: string
|
|
154
106
|
rung: 'none' | 'R1'
|
|
155
107
|
}
|
|
156
108
|
|
|
157
109
|
export type PluginEntry = {
|
|
158
|
-
// The instance name - the registry key (§7.1).
|
|
159
110
|
name: string
|
|
160
|
-
// The api slug. One descriptor per api is shared by every instance of
|
|
161
|
-
// it, so this is what `descriptorOf` and the cache key on.
|
|
162
111
|
api: string
|
|
163
112
|
descriptor: Descriptor
|
|
164
113
|
rung: 'none' | 'R1'
|
|
165
114
|
client: any
|
|
166
115
|
warnings: string[]
|
|
167
|
-
// The instance's effective secret name, mirroring Binding.secretname
|
|
168
|
-
// so `plugins()` can report it without re-deriving.
|
|
169
116
|
secretname?: string
|
|
170
117
|
}
|
|
171
118
|
|
|
172
|
-
// A declared instance, resolved (§6.1's `instances()`).
|
|
173
119
|
export type ResolvedInstance = {
|
|
174
120
|
name: string
|
|
175
121
|
api: string
|
|
176
|
-
// `active: false` means BARRED FROM RUNNING - declared, visible, and
|
|
177
|
-
// refused a client. Not a runtime state.
|
|
178
122
|
active: boolean
|
|
179
|
-
// Exactly plugin's `status == "live"`; the two answer different
|
|
180
|
-
// questions and the answers differ routinely, since a lazily-started
|
|
181
|
-
// instance is active and not yet live.
|
|
182
123
|
live: boolean
|
|
183
124
|
rung: 'none' | 'R1'
|
|
184
125
|
block: SdkBlock
|