@voxgig/station 0.0.1 → 0.1.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.
@@ -1,28 +1,193 @@
1
1
  "use strict";
2
- /* The FILE half of profile handling - finding and reading
3
- * station.json - and the scope rule that hangs off where it was found.
4
- * NODE-ONLY by construction (fs/os/path), which is the §2.2 / §17
5
- * Phase 1 split: the browser entry never imports this module, and
6
- * `Station` reaches it through the ConfigFileIO seam the node entry
7
- * registers. The pure half (selectProfile, refapi, resolveProfile)
8
- * lives in `profilecore.ts` and is re-exported here so every existing
9
- * import of `./profile` keeps working. */
2
+ /* Profile handling, whole: the pure half - selection, the ref grammar
3
+ * and the §3.3 merge - and the file half that finds and reads
4
+ * station.json plus the scope rule hanging off where it was found.
5
+ *
6
+ * These were two modules. `profilecore.ts` held the pure half so the
7
+ * BROWSER ENTRY could reach it without pulling `node:fs`, and `Station`
8
+ * reached the file half through a `ConfigFileIO` seam the node entry
9
+ * registered. Station is server-side only, so the split, the seam and
10
+ * the second entry are all gone and this is one module again. Every
11
+ * import of `./profile` is unchanged - that path always re-exported the
12
+ * pure half. */
10
13
  var __importDefault = (this && this.__importDefault) || function (mod) {
11
14
  return (mod && mod.__esModule) ? mod : { "default": mod };
12
15
  };
13
16
  Object.defineProperty(exports, "__esModule", { value: true });
14
- exports.selectProfile = exports.resolveProfile = exports.refapi = void 0;
17
+ exports.selectProfile = selectProfile;
18
+ exports.refapi = refapi;
19
+ exports.resolveProfile = resolveProfile;
15
20
  exports.findConfigFile = findConfigFile;
16
21
  exports.loadConfig = loadConfig;
17
22
  exports.configScope = configScope;
18
23
  const node_fs_1 = __importDefault(require("node:fs"));
19
24
  const node_os_1 = __importDefault(require("node:os"));
20
25
  const node_path_1 = __importDefault(require("node:path"));
26
+ const sekreto_1 = require("@voxgig/sekreto");
27
+ const descriptor_1 = require("./descriptor");
21
28
  const error_1 = require("./error");
22
- var profilecore_1 = require("./profilecore");
23
- Object.defineProperty(exports, "refapi", { enumerable: true, get: function () { return profilecore_1.refapi; } });
24
- Object.defineProperty(exports, "resolveProfile", { enumerable: true, get: function () { return profilecore_1.resolveProfile; } });
25
- Object.defineProperty(exports, "selectProfile", { enumerable: true, get: function () { return profilecore_1.selectProfile; } });
29
+ const shape_1 = require("./shape");
30
+ // Profile selection: VOXGIG_STATION_PROFILE, else the open() option,
31
+ // else 'default' (design §3.5 - env vars rank above station.json but
32
+ // below open() opts; profile NAME selection follows the same order with
33
+ // open() opts winning).
34
+ //
35
+ // `process` is read directly. It used to go through a `typeof` guard
36
+ // because the browser entry reached this code and has no `process`;
37
+ // station is server-side only, so the guard guarded nothing.
38
+ function selectProfile(optProfile) {
39
+ if (null != optProfile && '' !== optProfile) {
40
+ return optProfile;
41
+ }
42
+ const env = process.env.VOXGIG_STATION_PROFILE;
43
+ if (null != env && '' !== env) {
44
+ return env;
45
+ }
46
+ return 'default';
47
+ }
48
+ // The api half of a ref is the substring before the first `$`, and an
49
+ // untagged ref IS an api slug (design §3.4) - which is why the whole
50
+ // `plugin` -> `sdk` migration is a key rename and nothing else.
51
+ //
52
+ // LEXICAL, and that is the point: under the old free-form identity
53
+ // which api an instance used was itself a merged value, so the merge
54
+ // could not be a flat pass over both maps and a port that got the
55
+ // phasing wrong silently picked another api's defaults (§3.3).
56
+ function refapi(ref) {
57
+ const at = String(ref).indexOf('$');
58
+ return -1 === at ? String(ref) : String(ref).slice(0, at);
59
+ }
60
+ // Shallow merge, per key, left to right - each source over the one
61
+ // before it. Declared rather than assumed: plugin's library default is
62
+ // a deep map merge, and an allowlist that widens because two precedence
63
+ // levels merged is the failure this rule exists to prevent
64
+ // (station-and-plugin.md §2.5). The visible consequence: an overlay's
65
+ // `policy` REPLACES the base's entirely rather than merging `hosts`
66
+ // into it. That is also the safer reading for an allowlist.
67
+ function shallow(...sources) {
68
+ const out = {};
69
+ for (const src of sources) {
70
+ if (null == src || 'object' !== typeof src || Array.isArray(src)) {
71
+ continue;
72
+ }
73
+ for (const k of Object.keys(src)) {
74
+ out[k] = src[k];
75
+ }
76
+ }
77
+ return out;
78
+ }
79
+ // Merge the base profile ('default') with the selected overlay.
80
+ //
81
+ // §3.3's total order, for the two block levels - lowest precedence
82
+ // first:
83
+ //
84
+ // base.api[<api>] ⊕ base.sdk[<ref>] ⊕ overlay.api[<api>] ⊕ overlay.sdk[<ref>]
85
+ //
86
+ // PROFILE SPECIFICITY OUTRANKS BLOCK SPECIFICITY. A `prod` api-level
87
+ // `base` beats a `default` instance-level `base`, because that is what
88
+ // an environment overlay is for; within one profile the instance beats
89
+ // the api default, because that is what an instance is for.
90
+ //
91
+ // THIS IS ONE FLAT LEFT-TO-RIGHT MERGE AND MUST NOT BE REORGANIZED INTO
92
+ // "collapse each namespace, then put instance over api". Collapsing
93
+ // first computes (base.api ⊕ overlay.api) and (base.sdk ⊕ overlay.sdk)
94
+ // and then lets EVERY instance value beat EVERY api value - the exact
95
+ // opposite. A production `api.stripe.policy` would then fail to
96
+ // override a default profile's `sdk.stripe$test.policy`, silently
97
+ // keeping the wider allowlist in production.
98
+ //
99
+ // `secrets.providers` replaces wholesale, never merges (§3.5, §5.2):
100
+ // chain order decides which store wins, so a positional merge would be
101
+ // actively dangerous.
102
+ function resolveProfile(config, profileName) {
103
+ const profiles = config?.profiles || {};
104
+ const base = profiles['default'] || {};
105
+ const overlay = 'default' === profileName
106
+ ? {} : (profiles[profileName] || {});
107
+ const providers = overlay.secrets?.providers ?? base.secrets?.providers ??
108
+ [{ kind: 'env' }];
109
+ const api = {};
110
+ for (const slug of sortedkeys(base.api, overlay.api)) {
111
+ api[slug] = shallow(base.api?.[slug], overlay.api?.[slug]);
112
+ }
113
+ // An api block declares no instance, so the ref set comes from the
114
+ // two `sdk` maps alone.
115
+ const sdk = {};
116
+ for (const ref of sortedkeys(base.sdk, overlay.sdk)) {
117
+ const a = refapi(ref);
118
+ const merged = shallow(base.api?.[a], base.sdk?.[ref], overlay.api?.[a], overlay.sdk?.[ref]);
119
+ // Defaults are applied ONCE, to the fully merged instance, and this
120
+ // is the rule §4.2's normalization must not be confused with. Had
121
+ // the overlay block carried a synthesized `active: true` into the
122
+ // merge, a one-key environment override would silently re-enable an
123
+ // integration the base declared `active: false` - live in
124
+ // production despite being declared inactive. The `instance` corpus
125
+ // section carries exactly this case.
126
+ for (const k of Object.keys(shape_1.BLOCK_DEFAULTS)) {
127
+ if (undefined === merged[k]) {
128
+ merged[k] = shape_1.BLOCK_DEFAULTS[k]();
129
+ }
130
+ }
131
+ sdk[ref] = merged;
132
+ }
133
+ checksecrets(sdk, profileName);
134
+ return { name: profileName, providers, api, sdk };
135
+ }
136
+ // A configured secret name sekreto would reject is caught at profile
137
+ // load, not first request (§14 station_secret_name) - and then the
138
+ // derived names are checked for uniqueness, because envtoken is LOSSY.
139
+ //
140
+ // It collapses any run of non-alphanumerics to `_`, so `$` and `-` are
141
+ // indistinguishable downstream: the ref `stripe$test` and an untagged
142
+ // instance of an api slugged `stripe-test` both derive
143
+ // `stripe_test.apikey`. That pairing is the realistic one rather than a
144
+ // contrived one, because api slugs are hyphenated by construction - so
145
+ // a fleet with both a `stripe-test` api and a `test` instance of
146
+ // `stripe` collides by default. Since the resolution cache is keyed by
147
+ // secret name (§5.3), the second instance would silently receive the
148
+ // first's value.
149
+ //
150
+ // Two instances that EXPLICITLY name the same secret are not a
151
+ // collision - that is the shared-key case the api-level `secret` exists
152
+ // for, and saying so out loud is how you ask for it.
153
+ function checksecrets(sdk, profileName) {
154
+ const refs = Object.keys(sdk).sort();
155
+ for (const ref of refs) {
156
+ const name = sdk[ref].secret;
157
+ if (null != name && !(0, sekreto_1.validname)(name)) {
158
+ throw new error_1.StationError('station_secret_name', 'profile "' + profileName + '" sdk "' + ref +
159
+ '": secret name rejected by sekreto: ' + JSON.stringify(name));
160
+ }
161
+ }
162
+ const seen = new Map();
163
+ for (const ref of refs) {
164
+ const written = sdk[ref].secret;
165
+ const derived = null == written || '' === written;
166
+ const name = derived ? (0, descriptor_1.secretnameDefault)(ref) : written;
167
+ const prior = seen.get(name);
168
+ if (undefined !== prior && (derived || prior.derived)) {
169
+ throw new error_1.StationError('station_secret_collision', 'profile "' + profileName + '": instances "' + prior.ref + '" and "' +
170
+ ref + '" both resolve to secret name "' + name +
171
+ '", so they would share one credential; name it explicitly on ' +
172
+ 'each, or at the api level to share it deliberately (§5.1)');
173
+ }
174
+ if (undefined === prior) {
175
+ seen.set(name, { ref, derived });
176
+ }
177
+ }
178
+ }
179
+ function sortedkeys(...maps) {
180
+ const keys = new Set();
181
+ for (const m of maps) {
182
+ if (null == m || 'object' !== typeof m || Array.isArray(m)) {
183
+ continue;
184
+ }
185
+ for (const k of Object.keys(m)) {
186
+ keys.add(k);
187
+ }
188
+ }
189
+ return Array.from(keys).sort();
190
+ }
26
191
  // station.json lookup: cwd upward to the repo root, then
27
192
  // ~/.voxgig/station.json (design §3.5). A repo root is where .git lives;
28
193
  // with no repo the walk stops at the filesystem root.
@@ -1 +1 @@
1
- {"version":3,"file":"profile.js","sourceRoot":"","sources":["../../src/profile.ts"],"names":[],"mappings":";AAAA;;;;;;;0CAO0C;;;;;;AAiB1C,wCAYC;AAED,gCAcC;AASD,kCAKC;AAzDD,sDAAwB;AACxB,sDAAwB;AACxB,0DAA4B;AAE5B,mCAAsC;AAGtC,6CAEsB;AADpB,qGAAA,MAAM,OAAA;AAAE,6GAAA,cAAc,OAAA;AAAE,4GAAA,aAAa,OAAA;AAIvC,yDAAyD;AACzD,yEAAyE;AACzE,sDAAsD;AACtD,SAAgB,cAAc,CAAC,IAAa;IAC1C,IAAI,GAAG,GAAG,mBAAI,CAAC,OAAO,CAAC,IAAI,IAAI,OAAO,CAAC,GAAG,EAAE,CAAC,CAAA;IAC7C,SAAU,CAAC;QACT,MAAM,SAAS,GAAG,mBAAI,CAAC,IAAI,CAAC,GAAG,EAAE,cAAc,CAAC,CAAA;QAChD,IAAI,iBAAE,CAAC,UAAU,CAAC,SAAS,CAAC,EAAE,CAAC;YAAC,OAAO,SAAS,CAAA;QAAC,CAAC;QAClD,MAAM,UAAU,GAAG,iBAAE,CAAC,UAAU,CAAC,mBAAI,CAAC,IAAI,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC,CAAA;QACxD,MAAM,MAAM,GAAG,mBAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAA;QAChC,IAAI,UAAU,IAAI,MAAM,KAAK,GAAG,EAAE,CAAC;YAAC,MAAK;QAAC,CAAC;QAC3C,GAAG,GAAG,MAAM,CAAA;IACd,CAAC;IACD,MAAM,IAAI,GAAG,mBAAI,CAAC,IAAI,CAAC,iBAAE,CAAC,OAAO,EAAE,EAAE,SAAS,EAAE,cAAc,CAAC,CAAA;IAC/D,OAAO,iBAAE,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAA;AAC1C,CAAC;AAED,SAAgB,UAAU,CAAC,IAAa;IACtC,MAAM,IAAI,GAAG,cAAc,CAAC,IAAI,CAAC,CAAA;IACjC,IAAI,IAAI,IAAI,IAAI,EAAE,CAAC;QAAC,OAAO,IAAI,CAAA;IAAC,CAAC;IACjC,MAAM,IAAI,GAAG,iBAAE,CAAC,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,CAAA;IAC1C,mEAAmE;IACnE,mEAAmE;IACnE,kEAAkE;IAClE,IAAI,CAAC;QACH,OAAO,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAA;IACzB,CAAC;IACD,OAAO,GAAQ,EAAE,CAAC;QAChB,MAAM,IAAI,oBAAY,CAAC,wBAAwB,EAC7C,kBAAkB,GAAG,IAAI,GAAG,sBAAsB,GAAG,GAAG,CAAC,OAAO,CAAC,CAAA;IACrE,CAAC;AACH,CAAC;AAED,iEAAiE;AACjE,EAAE;AACF,oEAAoE;AACpE,wEAAwE;AACxE,uEAAuE;AACvE,qEAAqE;AACrE,6BAA6B;AAC7B,SAAgB,WAAW,CAAC,IAAa;IACvC,MAAM,IAAI,GAAG,cAAc,CAAC,IAAI,CAAC,CAAA;IACjC,IAAI,IAAI,IAAI,IAAI,EAAE,CAAC;QAAC,OAAO,MAAM,CAAA;IAAC,CAAC;IACnC,MAAM,IAAI,GAAG,mBAAI,CAAC,IAAI,CAAC,iBAAE,CAAC,OAAO,EAAE,EAAE,SAAS,EAAE,cAAc,CAAC,CAAA;IAC/D,OAAO,IAAI,KAAK,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAA;AACxC,CAAC"}
1
+ {"version":3,"file":"profile.js","sourceRoot":"","sources":["../../src/profile.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voxgig/station",
3
- "version": "0.0.1",
3
+ "version": "0.1.0",
4
4
  "description": "station: one control surface for outbound integrations (TypeScript, canonical)",
5
5
  "main": "dist/src/index.js",
6
6
  "type": "commonjs",
@@ -8,7 +8,6 @@
8
8
  "exports": {
9
9
  ".": {
10
10
  "types": "./dist/src/index.d.ts",
11
- "browser": "./dist/src/index.browser.js",
12
11
  "default": "./dist/src/index.js"
13
12
  },
14
13
  "./package.json": "./package.json"
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 './profilecore'
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 {
@@ -23,27 +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
26
  /** §6.1: `as` IS A TAG, NOT A FREE NAME.
48
27
  *
49
28
  * The api comes from the SDK being passed, so the resulting ref is
@@ -225,7 +204,7 @@ export class Station {
225
204
 
226
205
  const config = undefined !== this.opts.config
227
206
  ? this.opts.config
228
- : (null != configfileio ? configfileio.loadConfig(this.opts.folder) : null)
207
+ : loadConfig(this.opts.folder)
229
208
 
230
209
  // §6.3: an in-code config is repo-scoped by construction - the
231
210
  // application wrote it. A file is repo-scoped unless it came from
@@ -238,8 +217,7 @@ export class Station {
238
217
  this.repoScoped = this.opts.repoScoped
239
218
  ?? (undefined !== this.opts.config
240
219
  ? true
241
- : (null == configfileio ||
242
- 'user' !== configfileio.configScope(this.opts.folder)))
220
+ : 'user' !== configScope(this.opts.folder))
243
221
 
244
222
  // Normalize, then validate (design §4.2). A malformed station.json
245
223
  // fails open() with EVERY error at once - an eighteen-instance
package/src/descriptor.ts CHANGED
@@ -142,9 +142,11 @@ export function normalizeDescriptor(config: any, activeFeatures?: Record<string,
142
142
  //
143
143
  // The bytewise sort compares UTF-8 BYTE SEQUENCES, not UTF-16 code
144
144
  // units, through TextEncoder rather than Buffer: TextEncoder is a
145
- // standard global in Node and every browser, so this module stays on
146
- // the browser entry's import graph (§2.2, §17 Phase 1) with no node:
147
- // dependency. Uint8Array elements are unsigned bytes, so plain numeric
145
+ // standard global, so this module carries no node: dependency. (That
146
+ // last point used to matter because a browser entry had to reach this
147
+ // module; station is server-side only now. TextEncoder stays because
148
+ // the behaviour below is what the corpus pins, not because of where it
149
+ // runs.) Uint8Array elements are unsigned bytes, so plain numeric
148
150
  // comparison IS the bytewise order Buffer.compare gave - the corpus'
149
151
  // non-ASCII sort case pins the equivalence.
150
152
  const UTF8 = new TextEncoder()
package/src/index.ts CHANGED
@@ -1,18 +1,12 @@
1
1
  // @voxgig/station - one control surface for outbound integrations.
2
2
  //
3
- // THE NODE ENTRY. `index.browser.ts` is the browser condition's entry
4
- // (§2.2, §17 Phase 1): same surface MINUS the file-loading half
5
- // (loadConfig/findConfigFile/configScope) and this registration, which
6
- // is what hands Station the node-only file half without Station itself
7
- // importing a `node:` module.
3
+ // THE ONLY ENTRY. There was a second, `index.browser.ts`, selected by a
4
+ // `browser` condition: the same surface minus the file-loading half, so
5
+ // no `node:` builtin was reachable from it. Station is server-side only,
6
+ // so that entry, the `ConfigFileIO` seam it needed and the graph-walk
7
+ // test that guarded it are all gone.
8
8
 
9
- import { configScope, loadConfig } from './profile'
10
- import { setConfigFileIO } from './Station'
11
-
12
- setConfigFileIO({ loadConfig, configScope })
13
-
14
- export { Station, instanceRef, setConfigFileIO } from './Station'
15
- export type { ConfigFileIO } from './Station'
9
+ export { Station, instanceRef } from './Station'
16
10
  export { provide, factoryFor, provided, resetFactories } from './factory'
17
11
  export type { Factory, FactoryEntry } from './factory'
18
12
  export {
package/src/profile.ts CHANGED
@@ -1,23 +1,210 @@
1
- /* The FILE half of profile handling - finding and reading
2
- * station.json - and the scope rule that hangs off where it was found.
3
- * NODE-ONLY by construction (fs/os/path), which is the §2.2 / §17
4
- * Phase 1 split: the browser entry never imports this module, and
5
- * `Station` reaches it through the ConfigFileIO seam the node entry
6
- * registers. The pure half (selectProfile, refapi, resolveProfile)
7
- * lives in `profilecore.ts` and is re-exported here so every existing
8
- * import of `./profile` keeps working. */
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. */
9
12
 
10
13
  import Fs from 'node:fs'
11
14
  import Os from 'node:os'
12
15
  import Path from 'node:path'
13
16
 
17
+ import { validname } from '@voxgig/sekreto'
18
+
19
+ import { secretnameDefault } from './descriptor'
14
20
  import { StationError } from './error'
15
- import { StationConfig } from './types'
21
+ import { BLOCK_DEFAULTS } from './shape'
22
+ import { StationConfig, SdkBlock } from './types'
23
+
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
+ export function selectProfile(optProfile?: string): string {
33
+ if (null != optProfile && '' !== optProfile) { return optProfile }
34
+ const env = process.env.VOXGIG_STATION_PROFILE
35
+ if (null != env && '' !== env) { return env }
36
+ return 'default'
37
+ }
38
+
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
+ export function refapi(ref: string): string {
48
+ const at = String(ref).indexOf('$')
49
+ return -1 === at ? String(ref) : String(ref).slice(0, at)
50
+ }
51
+
52
+ export type ResolvedProfile = {
53
+ name: string
54
+ 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
+ api: Record<string, SdkBlock>
60
+ // Instances, keyed by ref. An `api` block declares no instance of its
61
+ // own (§3.1), so it never creates an entry here.
62
+ sdk: Record<string, SdkBlock>
63
+ }
64
+
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
+ function shallow(...sources: any[]): any {
73
+ const out: any = {}
74
+ for (const src of sources) {
75
+ if (null == src || 'object' !== typeof src || Array.isArray(src)) { continue }
76
+ for (const k of Object.keys(src)) { out[k] = src[k] }
77
+ }
78
+ return out
79
+ }
80
+
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
+ export function resolveProfile(
105
+ config: StationConfig | null, profileName: string
106
+ ): ResolvedProfile {
107
+ const profiles = config?.profiles || {}
108
+ const base: any = profiles['default'] || {}
109
+ const overlay: any = 'default' === profileName
110
+ ? {} : (profiles[profileName] || {})
16
111
 
17
- export {
18
- refapi, resolveProfile, selectProfile,
19
- } from './profilecore'
20
- export type { ResolvedProfile } from './profilecore'
112
+ const providers = overlay.secrets?.providers ?? base.secrets?.providers ??
113
+ [{ kind: 'env' }]
114
+
115
+ const api: Record<string, SdkBlock> = {}
116
+ for (const slug of sortedkeys(base.api, overlay.api)) {
117
+ api[slug] = shallow(base.api?.[slug], overlay.api?.[slug])
118
+ }
119
+
120
+ // An api block declares no instance, so the ref set comes from the
121
+ // two `sdk` maps alone.
122
+ const sdk: Record<string, SdkBlock> = {}
123
+ for (const ref of sortedkeys(base.sdk, overlay.sdk)) {
124
+ const a = refapi(ref)
125
+ const merged = shallow(
126
+ base.api?.[a],
127
+ base.sdk?.[ref],
128
+ overlay.api?.[a],
129
+ overlay.sdk?.[ref],
130
+ )
131
+
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
+ for (const k of Object.keys(BLOCK_DEFAULTS)) {
140
+ if (undefined === merged[k]) { merged[k] = BLOCK_DEFAULTS[k]() }
141
+ }
142
+
143
+ sdk[ref] = merged
144
+ }
145
+
146
+ checksecrets(sdk, profileName)
147
+
148
+ return { name: profileName, providers, api, sdk }
149
+ }
150
+
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
+ function checksecrets(
169
+ sdk: Record<string, SdkBlock>, profileName: string
170
+ ): void {
171
+ const refs = Object.keys(sdk).sort()
172
+
173
+ for (const ref of refs) {
174
+ const name = sdk[ref].secret
175
+ if (null != name && !validname(name)) {
176
+ throw new StationError('station_secret_name',
177
+ 'profile "' + profileName + '" sdk "' + ref +
178
+ '": secret name rejected by sekreto: ' + JSON.stringify(name))
179
+ }
180
+ }
181
+
182
+ const seen = new Map<string, { ref: string, derived: boolean }>()
183
+ for (const ref of refs) {
184
+ const written = sdk[ref].secret
185
+ const derived = null == written || '' === written
186
+ const name = derived ? secretnameDefault(ref) : (written as string)
187
+
188
+ const prior = seen.get(name)
189
+ if (undefined !== prior && (derived || prior.derived)) {
190
+ throw new StationError('station_secret_collision',
191
+ 'profile "' + profileName + '": instances "' + prior.ref + '" and "' +
192
+ ref + '" both resolve to secret name "' + name +
193
+ '", so they would share one credential; name it explicitly on ' +
194
+ 'each, or at the api level to share it deliberately (§5.1)')
195
+ }
196
+ if (undefined === prior) { seen.set(name, { ref, derived }) }
197
+ }
198
+ }
199
+
200
+ function sortedkeys(...maps: any[]): string[] {
201
+ const keys = new Set<string>()
202
+ for (const m of maps) {
203
+ if (null == m || 'object' !== typeof m || Array.isArray(m)) { continue }
204
+ for (const k of Object.keys(m)) { keys.add(k) }
205
+ }
206
+ return Array.from(keys).sort()
207
+ }
21
208
 
22
209
  // station.json lookup: cwd upward to the repo root, then
23
210
  // ~/.voxgig/station.json (design §3.5). A repo root is where .git lives;
@@ -1,14 +0,0 @@
1
- export { Station, instanceRef, setConfigFileIO } from './Station';
2
- export type { ConfigFileIO } from './Station';
3
- export { provide, factoryFor, provided, resetFactories } from './factory';
4
- export type { Factory, FactoryEntry } from './factory';
5
- export { DEFAULT_EXPORT, camelify, checkPackage, factoryFromModule, loadAsync, loadSync, } from './loader';
6
- export { adapterFeature, featureBinding } from './adapter';
7
- export type { FeatureBinding } from './adapter';
8
- export { StationError } from './error';
9
- export { canonicalSerialize, envtoken, normalizeDescriptor, secretnameDefault, } from './descriptor';
10
- export { placeholderFor } from './secrets';
11
- export { refapi, resolveProfile, selectProfile } from './profilecore';
12
- export type { ResolvedProfile } from './profilecore';
13
- export { BLOCK_DEFAULTS, MERGE_SENSITIVE, PROFILE_DEFAULTS, configShape, normalizeConfig, validateConfig, } from './shape';
14
- export type { Binding, Descriptor, PolicyBlock, Profile, ResolvedInstance, SdkBlock, StationConfig, StationEvent, StationOptions, } from './types';