@voxgig/station 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/src/Station.d.ts +1 -118
  2. package/dist/src/Station.js +26 -412
  3. package/dist/src/Station.js.map +1 -1
  4. package/dist/src/adapter.js +0 -54
  5. package/dist/src/adapter.js.map +1 -1
  6. package/dist/src/config-shape.js +0 -11
  7. package/dist/src/config-shape.js.map +1 -1
  8. package/dist/src/descriptor.js +0 -38
  9. package/dist/src/descriptor.js.map +1 -1
  10. package/dist/src/error.js +0 -5
  11. package/dist/src/error.js.map +1 -1
  12. package/dist/src/factory.d.ts +0 -9
  13. package/dist/src/factory.js +0 -37
  14. package/dist/src/factory.js.map +1 -1
  15. package/dist/src/feature.d.ts +0 -75
  16. package/dist/src/feature.js +0 -87
  17. package/dist/src/feature.js.map +1 -1
  18. package/dist/src/index.js +0 -7
  19. package/dist/src/index.js.map +1 -1
  20. package/dist/src/loader.d.ts +0 -29
  21. package/dist/src/loader.js +0 -66
  22. package/dist/src/loader.js.map +1 -1
  23. package/dist/src/profile.js +0 -91
  24. package/dist/src/profile.js.map +1 -1
  25. package/dist/src/secrets.d.ts +5 -0
  26. package/dist/src/secrets.js +106 -32
  27. package/dist/src/secrets.js.map +1 -1
  28. package/dist/src/shape.d.ts +0 -22
  29. package/dist/src/shape.js +0 -113
  30. package/dist/src/shape.js.map +1 -1
  31. package/package.json +3 -3
  32. package/src/Station.ts +28 -413
  33. package/src/adapter.ts +0 -57
  34. package/src/config-shape.ts +0 -11
  35. package/src/descriptor.ts +0 -38
  36. package/src/error.ts +0 -5
  37. package/src/factory.ts +0 -37
  38. package/src/feature.ts +0 -87
  39. package/src/index.ts +0 -7
  40. package/src/loader.ts +0 -66
  41. package/src/profile.ts +0 -95
  42. package/src/secrets.ts +104 -32
  43. package/src/shape.ts +0 -113
  44. package/src/types.ts +0 -59
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
- // Exact-value scrub, deliberately WITHOUT sekreto's four-character
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
- let out = this.sekreto.redact(text)
80
- for (const value of this.held) {
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