@voxgig/station 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +88 -0
- package/dist/src/Station.d.ts +238 -0
- package/dist/src/Station.js +1213 -0
- package/dist/src/Station.js.map +1 -0
- package/dist/src/adapter.d.ts +9 -0
- package/dist/src/adapter.js +171 -0
- package/dist/src/adapter.js.map +1 -0
- package/dist/src/config-shape.d.ts +1 -0
- package/dist/src/config-shape.js +329 -0
- package/dist/src/config-shape.js.map +1 -0
- package/dist/src/descriptor.d.ts +8 -0
- package/dist/src/descriptor.js +170 -0
- package/dist/src/descriptor.js.map +1 -0
- package/dist/src/error.d.ts +8 -0
- package/dist/src/error.js +60 -0
- package/dist/src/error.js.map +1 -0
- package/dist/src/events.d.ts +15 -0
- package/dist/src/events.js +46 -0
- package/dist/src/events.js.map +1 -0
- package/dist/src/factory.d.ts +24 -0
- package/dist/src/factory.js +79 -0
- package/dist/src/factory.js.map +1 -0
- package/dist/src/feature.d.ts +106 -0
- package/dist/src/feature.js +324 -0
- package/dist/src/feature.js.map +1 -0
- package/dist/src/index.browser.d.ts +14 -0
- package/dist/src/index.browser.js +64 -0
- package/dist/src/index.browser.js.map +1 -0
- package/dist/src/index.d.ts +14 -0
- package/dist/src/index.js +56 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/loader.d.ts +45 -0
- package/dist/src/loader.js +181 -0
- package/dist/src/loader.js.map +1 -0
- package/dist/src/profile.d.ts +6 -0
- package/dist/src/profile.js +77 -0
- package/dist/src/profile.js.map +1 -0
- package/dist/src/profilecore.d.ts +10 -0
- package/dist/src/profilecore.js +180 -0
- package/dist/src/profilecore.js.map +1 -0
- package/dist/src/secrets.d.ts +12 -0
- package/dist/src/secrets.js +93 -0
- package/dist/src/secrets.js.map +1 -0
- package/dist/src/shape.d.ts +42 -0
- package/dist/src/shape.js +444 -0
- package/dist/src/shape.js.map +1 -0
- package/dist/src/types.d.ts +138 -0
- package/dist/src/types.js +5 -0
- package/dist/src/types.js.map +1 -0
- package/package.json +60 -0
- package/src/Station.ts +1310 -0
- package/src/adapter.ts +183 -0
- package/src/config-shape.ts +326 -0
- package/src/descriptor.ts +179 -0
- package/src/error.ts +64 -0
- package/src/events.ts +45 -0
- package/src/factory.ts +93 -0
- package/src/feature.ts +321 -0
- package/src/index.browser.ts +56 -0
- package/src/index.ts +50 -0
- package/src/loader.ts +188 -0
- package/src/profile.ts +67 -0
- package/src/profilecore.ts +201 -0
- package/src/secrets.ts +96 -0
- package/src/shape.ts +435 -0
- package/src/types.ts +185 -0
package/src/loader.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/* The loader (design §6.3), where the language allows it.
|
|
2
|
+
*
|
|
3
|
+
* In ts, js, py, rb, php, perl, lua, elixir and clojure a module can be
|
|
4
|
+
* imported by name at runtime, so `api.<slug>.package` closes the loop:
|
|
5
|
+
* station imports the package (which triggers self-registration, §6.2
|
|
6
|
+
* path 1) and then looks up the factory.
|
|
7
|
+
*
|
|
8
|
+
* `sdk()` IS SYNCHRONOUS, AND IN TS/JS THAT BOUNDS WHAT THE LOADER CAN
|
|
9
|
+
* IMPORT. A CommonJS package loads synchronously through `require`; an
|
|
10
|
+
* ESM-only package only loads through `import()`, which is a promise.
|
|
11
|
+
* Making `sdk()` async to cover that would put an `await` in front of
|
|
12
|
+
* every call site in every language for a cost only two of them pay.
|
|
13
|
+
* So the rule is explicit rather than discovered at runtime:
|
|
14
|
+
*
|
|
15
|
+
* - the synchronous loader handles CommonJS, which is every SDK sdkgen
|
|
16
|
+
* generates (the ts target is `"type": "commonjs"`);
|
|
17
|
+
* - ESM packages load through an explicit `await station.load()`
|
|
18
|
+
* preload, after which `sdk()` is synchronous again for everything —
|
|
19
|
+
* one `await` at startup rather than one per call site;
|
|
20
|
+
* - a package only `import()` can load, reached through `sdk()`
|
|
21
|
+
* without a preload, is `station_sdk_load` WITH THE REMEDY IN THE
|
|
22
|
+
* MESSAGE. Never a silent half-loaded client.
|
|
23
|
+
*
|
|
24
|
+
* THIS IS A CODE-LOADING SURFACE DRIVEN BY A CONFIG FILE, so it has
|
|
25
|
+
* rules, and they are enforced here rather than documented and hoped
|
|
26
|
+
* for. See `checkPackage` and the `repoScoped` argument. */
|
|
27
|
+
|
|
28
|
+
import { StationError } from './error'
|
|
29
|
+
import { Factory, factoryFor, provide } from './factory'
|
|
30
|
+
|
|
31
|
+
/** The fixed alias every generated package exports.
|
|
32
|
+
*
|
|
33
|
+
* `export` defaults to this rather than to a derived class name because
|
|
34
|
+
* it is the same identifier in every generated package, where
|
|
35
|
+
* `camelify(slug) + 'SDK'` is a rule that has to be recomputed and can
|
|
36
|
+
* be wrong. The derived name is the SECOND attempt and an explicit
|
|
37
|
+
* `export` the third. */
|
|
38
|
+
export const DEFAULT_EXPORT = 'SDK'
|
|
39
|
+
|
|
40
|
+
/** `stripe-eu` -> `StripeEu`, for the second-attempt export name. */
|
|
41
|
+
export function camelify(slug: string): string {
|
|
42
|
+
return String(slug).split(/[^A-Za-z0-9]+/).filter((s) => '' !== s)
|
|
43
|
+
.map((s) => s[0].toUpperCase() + s.slice(1))
|
|
44
|
+
.join('')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Only MODULE NAMES, resolved by the host language's ordinary
|
|
48
|
+
* resolution from the application root. Never a filesystem path, never
|
|
49
|
+
* a URL, never anything relative — a config file naming a path is a
|
|
50
|
+
* config file reaching outside the dependency graph it is allowed to
|
|
51
|
+
* name. */
|
|
52
|
+
export function checkPackage(api: string, pkg: string): string {
|
|
53
|
+
const p = String(pkg)
|
|
54
|
+
// A TRAVERSAL SEGMENT IS NOT A LEADING MARKER, and checking only the
|
|
55
|
+
// first character missed it: `pkg/../../escape` starts with neither
|
|
56
|
+
// `.` nor `/`, so it passed — and node resolves it through
|
|
57
|
+
// `node_modules/pkg/../../escape`, importing application-local code
|
|
58
|
+
// from outside the named dependency. The whole point of this function
|
|
59
|
+
// is that a configured package stays inside the dependency graph a
|
|
60
|
+
// reviewer can see.
|
|
61
|
+
const seg = p.split('/').some((x) => '.' === x || '..' === x)
|
|
62
|
+
const bad =
|
|
63
|
+
'' === p ||
|
|
64
|
+
p.startsWith('.') ||
|
|
65
|
+
p.startsWith('/') ||
|
|
66
|
+
p.startsWith('~') ||
|
|
67
|
+
seg ||
|
|
68
|
+
-1 !== p.indexOf('://') ||
|
|
69
|
+
-1 !== p.indexOf('\\')
|
|
70
|
+
if (bad) {
|
|
71
|
+
throw new StationError('station_sdk_load',
|
|
72
|
+
'api "' + api + '": `package` must be a module name resolved from ' +
|
|
73
|
+
'the application root, not a path or URL: ' + JSON.stringify(pkg))
|
|
74
|
+
}
|
|
75
|
+
return p
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Build a `{construct, config}` pair from a module that self-registered
|
|
79
|
+
* nothing — the retrofit path for a package whose SDK predates the
|
|
80
|
+
* station feature. It is not descriptor-blind: a generated main module
|
|
81
|
+
* exports its constructor AND the `config` singleton beside it. */
|
|
82
|
+
export function factoryFromModule(
|
|
83
|
+
api: string, mod: any, exportName?: string
|
|
84
|
+
): Factory {
|
|
85
|
+
const tried: string[] = []
|
|
86
|
+
const pick = (n: string): any => {
|
|
87
|
+
tried.push(n)
|
|
88
|
+
return null == mod ? undefined : mod[n]
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let ctor = null == exportName || '' === exportName
|
|
92
|
+
? undefined : pick(exportName)
|
|
93
|
+
if (null == ctor) { ctor = pick(DEFAULT_EXPORT) }
|
|
94
|
+
if (null == ctor) { ctor = pick(camelify(api) + 'SDK') }
|
|
95
|
+
|
|
96
|
+
if ('function' !== typeof ctor) {
|
|
97
|
+
throw new StationError('station_sdk_load',
|
|
98
|
+
'api "' + api + '": no SDK constructor found on the module; tried [' +
|
|
99
|
+
tried.join(', ') + ']. Set `export` to the exported name.')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const config = mod.config ?? mod.CONFIG
|
|
103
|
+
if (null == config) {
|
|
104
|
+
throw new StationError('station_sdk_load',
|
|
105
|
+
'api "' + api + '": the module exports a constructor but no `config` ' +
|
|
106
|
+
'singleton, so its feature schema and transport roles cannot be read ' +
|
|
107
|
+
'before construction (§6.2)')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const construct = (options: any) => new (ctor as any)(options)
|
|
111
|
+
return { construct, config }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Synchronous load, for CommonJS. Returns true when the api has a
|
|
115
|
+
* factory afterwards — either because importing the package triggered
|
|
116
|
+
* self-registration, or because one was built from its exports. */
|
|
117
|
+
export function loadSync(api: string, pkg: string, exportName?: string): boolean {
|
|
118
|
+
checkPackage(api, pkg)
|
|
119
|
+
if (null != factoryFor(api)) { return true }
|
|
120
|
+
|
|
121
|
+
let mod: any
|
|
122
|
+
try {
|
|
123
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
124
|
+
mod = require(pkg)
|
|
125
|
+
}
|
|
126
|
+
catch (e: any) {
|
|
127
|
+
// ERR_REQUIRE_ESM is the case the preload exists for, and the
|
|
128
|
+
// message has to name the remedy rather than the symptom.
|
|
129
|
+
const code = String(e?.code || '')
|
|
130
|
+
if ('ERR_REQUIRE_ESM' === code) {
|
|
131
|
+
throw new StationError('station_sdk_load',
|
|
132
|
+
'api "' + api + '": package "' + pkg + '" is ESM-only and cannot be ' +
|
|
133
|
+
'loaded synchronously; `await station.load()` at startup, then ' +
|
|
134
|
+
'sdk() is synchronous again for everything (§6.3)')
|
|
135
|
+
}
|
|
136
|
+
throw new StationError('station_sdk_load',
|
|
137
|
+
'api "' + api + '": package "' + pkg + '" could not be imported: ' +
|
|
138
|
+
String(e?.message || e))
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Path 1: the module self-registered while being imported.
|
|
142
|
+
if (null != factoryFor(api)) { return true }
|
|
143
|
+
|
|
144
|
+
provide(api, factoryFromModule(api, mod, exportName))
|
|
145
|
+
return true
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The ESM preload behind `await station.load()`. Imports the package
|
|
149
|
+
* and fills the SAME table the synchronous path fills. */
|
|
150
|
+
/** A dynamic `import()` that SURVIVES THE COMMONJS EMIT.
|
|
151
|
+
*
|
|
152
|
+
* This package compiles with `module: "commonjs"`, and TypeScript
|
|
153
|
+
* rewrites a literal `import(pkg)` into a promise around `require(pkg)`
|
|
154
|
+
* — so `await station.load()` threw `ERR_REQUIRE_ESM` for exactly the
|
|
155
|
+
* ESM-only packages it exists to preload, leaving no working path to
|
|
156
|
+
* those SDKs at all. Verified against the emitted `dist/src/loader.js`,
|
|
157
|
+
* which read `mod = require(pkg)`.
|
|
158
|
+
*
|
|
159
|
+
* `new Function` is the seam the compiler does not look inside. It is
|
|
160
|
+
* deliberately the only one in this file, and it takes no caller input:
|
|
161
|
+
* `pkg` has already been through `checkPackage`, and the body is a
|
|
162
|
+
* constant. Changing the package to `module: "node16"` would fix it
|
|
163
|
+
* more cleanly and is a whole-package decision rather than a defect
|
|
164
|
+
* fix. */
|
|
165
|
+
export const nativeImport: (p: string) => Promise<any> =
|
|
166
|
+
new Function('p', 'return import(p)') as any
|
|
167
|
+
|
|
168
|
+
export async function loadAsync(
|
|
169
|
+
api: string, pkg: string, exportName?: string
|
|
170
|
+
): Promise<boolean> {
|
|
171
|
+
checkPackage(api, pkg)
|
|
172
|
+
if (null != factoryFor(api)) { return true }
|
|
173
|
+
|
|
174
|
+
let mod: any
|
|
175
|
+
try { mod = await nativeImport(pkg) }
|
|
176
|
+
catch (e: any) {
|
|
177
|
+
throw new StationError('station_sdk_load',
|
|
178
|
+
'api "' + api + '": package "' + pkg + '" could not be imported: ' +
|
|
179
|
+
String(e?.message || e))
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (null != factoryFor(api)) { return true }
|
|
183
|
+
|
|
184
|
+
// An ESM namespace puts a CommonJS module's exports under `default`.
|
|
185
|
+
const flat = null != mod?.default && null == mod?.config ? mod.default : mod
|
|
186
|
+
provide(api, factoryFromModule(api, flat, exportName))
|
|
187
|
+
return true
|
|
188
|
+
}
|
package/src/profile.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
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. */
|
|
9
|
+
|
|
10
|
+
import Fs from 'node:fs'
|
|
11
|
+
import Os from 'node:os'
|
|
12
|
+
import Path from 'node:path'
|
|
13
|
+
|
|
14
|
+
import { StationError } from './error'
|
|
15
|
+
import { StationConfig } from './types'
|
|
16
|
+
|
|
17
|
+
export {
|
|
18
|
+
refapi, resolveProfile, selectProfile,
|
|
19
|
+
} from './profilecore'
|
|
20
|
+
export type { ResolvedProfile } from './profilecore'
|
|
21
|
+
|
|
22
|
+
// station.json lookup: cwd upward to the repo root, then
|
|
23
|
+
// ~/.voxgig/station.json (design §3.5). A repo root is where .git lives;
|
|
24
|
+
// with no repo the walk stops at the filesystem root.
|
|
25
|
+
export function findConfigFile(from?: string): string | null {
|
|
26
|
+
let dir = Path.resolve(from || process.cwd())
|
|
27
|
+
for (; ;) {
|
|
28
|
+
const candidate = Path.join(dir, 'station.json')
|
|
29
|
+
if (Fs.existsSync(candidate)) { return candidate }
|
|
30
|
+
const atRepoRoot = Fs.existsSync(Path.join(dir, '.git'))
|
|
31
|
+
const parent = Path.dirname(dir)
|
|
32
|
+
if (atRepoRoot || parent === dir) { break }
|
|
33
|
+
dir = parent
|
|
34
|
+
}
|
|
35
|
+
const home = Path.join(Os.homedir(), '.voxgig', 'station.json')
|
|
36
|
+
return Fs.existsSync(home) ? home : null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function loadConfig(from?: string): StationConfig | null {
|
|
40
|
+
const file = findConfigFile(from)
|
|
41
|
+
if (null == file) { return null }
|
|
42
|
+
const text = Fs.readFileSync(file, 'utf8')
|
|
43
|
+
// A file that is not JSON is a config error, not a raw SyntaxError
|
|
44
|
+
// escaping open(): the reader found station.json and could not use
|
|
45
|
+
// it, which is exactly what station_config_invalid exists to say.
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(text)
|
|
48
|
+
}
|
|
49
|
+
catch (err: any) {
|
|
50
|
+
throw new StationError('station_config_invalid',
|
|
51
|
+
'station.json at ' + file + ' is not valid JSON: ' + err.message)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Which side of the review boundary the config came from (§6.3).
|
|
56
|
+
//
|
|
57
|
+
// `package` and `export` are honoured only from REPO-SCOPED config,
|
|
58
|
+
// because a user-level file is outside the repo's review boundary and a
|
|
59
|
+
// `package` key arriving from it names code to import. Everything else
|
|
60
|
+
// in a user-level config still applies - this narrows one key rather
|
|
61
|
+
// than distrusting the file.
|
|
62
|
+
export function configScope(from?: string): 'repo' | 'user' | 'none' {
|
|
63
|
+
const file = findConfigFile(from)
|
|
64
|
+
if (null == file) { return 'none' }
|
|
65
|
+
const home = Path.join(Os.homedir(), '.voxgig', 'station.json')
|
|
66
|
+
return file === home ? 'user' : 'repo'
|
|
67
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/* The pure half of profile handling: selection, the ref grammar, and
|
|
2
|
+
* the §3.3 merge. Split from `profile.ts` so the BROWSER ENTRY can
|
|
3
|
+
* reach it (§2.2, §17 Phase 1): everything here is data-in/data-out,
|
|
4
|
+
* while the file half (findConfigFile/loadConfig/configScope) is
|
|
5
|
+
* node-only and stays in `profile.ts`, imported by the node entry
|
|
6
|
+
* alone. `profile.ts` re-exports these, so every existing import path
|
|
7
|
+
* is unchanged. */
|
|
8
|
+
|
|
9
|
+
import { validname } from '@voxgig/sekreto'
|
|
10
|
+
|
|
11
|
+
import { secretnameDefault } from './descriptor'
|
|
12
|
+
import { StationError } from './error'
|
|
13
|
+
import { BLOCK_DEFAULTS } from './shape'
|
|
14
|
+
import { StationConfig, SdkBlock } from './types'
|
|
15
|
+
|
|
16
|
+
// Profile selection: VOXGIG_STATION_PROFILE, else the open() option,
|
|
17
|
+
// else 'default' (design §3.5 - env vars rank above station.json but
|
|
18
|
+
// below open() opts; profile NAME selection follows the same order with
|
|
19
|
+
// open() opts winning).
|
|
20
|
+
//
|
|
21
|
+
// `process` is reached through a typeof guard rather than an import:
|
|
22
|
+
// in the browser there is no process and no environment, so the answer
|
|
23
|
+
// is simply the option or the default - which is the same degradation
|
|
24
|
+
// "no env var set" already means.
|
|
25
|
+
export function selectProfile(optProfile?: string): string {
|
|
26
|
+
if (null != optProfile && '' !== optProfile) { return optProfile }
|
|
27
|
+
const env = 'undefined' !== typeof process
|
|
28
|
+
? process.env.VOXGIG_STATION_PROFILE : undefined
|
|
29
|
+
if (null != env && '' !== env) { return env }
|
|
30
|
+
return 'default'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The api half of a ref is the substring before the first `$`, and an
|
|
34
|
+
// untagged ref IS an api slug (design §3.4) - which is why the whole
|
|
35
|
+
// `plugin` -> `sdk` migration is a key rename and nothing else.
|
|
36
|
+
//
|
|
37
|
+
// LEXICAL, and that is the point: under the old free-form identity
|
|
38
|
+
// which api an instance used was itself a merged value, so the merge
|
|
39
|
+
// could not be a flat pass over both maps and a port that got the
|
|
40
|
+
// phasing wrong silently picked another api's defaults (§3.3).
|
|
41
|
+
export function refapi(ref: string): string {
|
|
42
|
+
const at = String(ref).indexOf('$')
|
|
43
|
+
return -1 === at ? String(ref) : String(ref).slice(0, at)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type ResolvedProfile = {
|
|
47
|
+
name: string
|
|
48
|
+
providers: any[]
|
|
49
|
+
// The api-level defaults in effect for this profile, base ⊕ overlay
|
|
50
|
+
// per api slug. A REPORT, not an input to the instance merge below -
|
|
51
|
+
// collapsing each namespace first and composing at the end is the
|
|
52
|
+
// exact algorithm §3.3 forbids.
|
|
53
|
+
api: Record<string, SdkBlock>
|
|
54
|
+
// Instances, keyed by ref. An `api` block declares no instance of its
|
|
55
|
+
// own (§3.1), so it never creates an entry here.
|
|
56
|
+
sdk: Record<string, SdkBlock>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Shallow merge, per key, left to right - each source over the one
|
|
60
|
+
// before it. Declared rather than assumed: plugin's library default is
|
|
61
|
+
// a deep map merge, and an allowlist that widens because two precedence
|
|
62
|
+
// levels merged is the failure this rule exists to prevent
|
|
63
|
+
// (station-and-plugin.md §2.5). The visible consequence: an overlay's
|
|
64
|
+
// `policy` REPLACES the base's entirely rather than merging `hosts`
|
|
65
|
+
// into it. That is also the safer reading for an allowlist.
|
|
66
|
+
function shallow(...sources: any[]): any {
|
|
67
|
+
const out: any = {}
|
|
68
|
+
for (const src of sources) {
|
|
69
|
+
if (null == src || 'object' !== typeof src || Array.isArray(src)) { continue }
|
|
70
|
+
for (const k of Object.keys(src)) { out[k] = src[k] }
|
|
71
|
+
}
|
|
72
|
+
return out
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Merge the base profile ('default') with the selected overlay.
|
|
76
|
+
//
|
|
77
|
+
// §3.3's total order, for the two block levels - lowest precedence
|
|
78
|
+
// first:
|
|
79
|
+
//
|
|
80
|
+
// base.api[<api>] ⊕ base.sdk[<ref>] ⊕ overlay.api[<api>] ⊕ overlay.sdk[<ref>]
|
|
81
|
+
//
|
|
82
|
+
// PROFILE SPECIFICITY OUTRANKS BLOCK SPECIFICITY. A `prod` api-level
|
|
83
|
+
// `base` beats a `default` instance-level `base`, because that is what
|
|
84
|
+
// an environment overlay is for; within one profile the instance beats
|
|
85
|
+
// the api default, because that is what an instance is for.
|
|
86
|
+
//
|
|
87
|
+
// THIS IS ONE FLAT LEFT-TO-RIGHT MERGE AND MUST NOT BE REORGANIZED INTO
|
|
88
|
+
// "collapse each namespace, then put instance over api". Collapsing
|
|
89
|
+
// first computes (base.api ⊕ overlay.api) and (base.sdk ⊕ overlay.sdk)
|
|
90
|
+
// and then lets EVERY instance value beat EVERY api value - the exact
|
|
91
|
+
// opposite. A production `api.stripe.policy` would then fail to
|
|
92
|
+
// override a default profile's `sdk.stripe$test.policy`, silently
|
|
93
|
+
// keeping the wider allowlist in production.
|
|
94
|
+
//
|
|
95
|
+
// `secrets.providers` replaces wholesale, never merges (§3.5, §5.2):
|
|
96
|
+
// chain order decides which store wins, so a positional merge would be
|
|
97
|
+
// actively dangerous.
|
|
98
|
+
export function resolveProfile(
|
|
99
|
+
config: StationConfig | null, profileName: string
|
|
100
|
+
): ResolvedProfile {
|
|
101
|
+
const profiles = config?.profiles || {}
|
|
102
|
+
const base: any = profiles['default'] || {}
|
|
103
|
+
const overlay: any = 'default' === profileName
|
|
104
|
+
? {} : (profiles[profileName] || {})
|
|
105
|
+
|
|
106
|
+
const providers = overlay.secrets?.providers ?? base.secrets?.providers ??
|
|
107
|
+
[{ kind: 'env' }]
|
|
108
|
+
|
|
109
|
+
const api: Record<string, SdkBlock> = {}
|
|
110
|
+
for (const slug of sortedkeys(base.api, overlay.api)) {
|
|
111
|
+
api[slug] = shallow(base.api?.[slug], overlay.api?.[slug])
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// An api block declares no instance, so the ref set comes from the
|
|
115
|
+
// two `sdk` maps alone.
|
|
116
|
+
const sdk: Record<string, SdkBlock> = {}
|
|
117
|
+
for (const ref of sortedkeys(base.sdk, overlay.sdk)) {
|
|
118
|
+
const a = refapi(ref)
|
|
119
|
+
const merged = shallow(
|
|
120
|
+
base.api?.[a],
|
|
121
|
+
base.sdk?.[ref],
|
|
122
|
+
overlay.api?.[a],
|
|
123
|
+
overlay.sdk?.[ref],
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
// Defaults are applied ONCE, to the fully merged instance, and this
|
|
127
|
+
// is the rule §4.2's normalization must not be confused with. Had
|
|
128
|
+
// the overlay block carried a synthesized `active: true` into the
|
|
129
|
+
// merge, a one-key environment override would silently re-enable an
|
|
130
|
+
// integration the base declared `active: false` - live in
|
|
131
|
+
// production despite being declared inactive. The `instance` corpus
|
|
132
|
+
// section carries exactly this case.
|
|
133
|
+
for (const k of Object.keys(BLOCK_DEFAULTS)) {
|
|
134
|
+
if (undefined === merged[k]) { merged[k] = BLOCK_DEFAULTS[k]() }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
sdk[ref] = merged
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
checksecrets(sdk, profileName)
|
|
141
|
+
|
|
142
|
+
return { name: profileName, providers, api, sdk }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// A configured secret name sekreto would reject is caught at profile
|
|
146
|
+
// load, not first request (§14 station_secret_name) - and then the
|
|
147
|
+
// derived names are checked for uniqueness, because envtoken is LOSSY.
|
|
148
|
+
//
|
|
149
|
+
// It collapses any run of non-alphanumerics to `_`, so `$` and `-` are
|
|
150
|
+
// indistinguishable downstream: the ref `stripe$test` and an untagged
|
|
151
|
+
// instance of an api slugged `stripe-test` both derive
|
|
152
|
+
// `stripe_test.apikey`. That pairing is the realistic one rather than a
|
|
153
|
+
// contrived one, because api slugs are hyphenated by construction - so
|
|
154
|
+
// a fleet with both a `stripe-test` api and a `test` instance of
|
|
155
|
+
// `stripe` collides by default. Since the resolution cache is keyed by
|
|
156
|
+
// secret name (§5.3), the second instance would silently receive the
|
|
157
|
+
// first's value.
|
|
158
|
+
//
|
|
159
|
+
// Two instances that EXPLICITLY name the same secret are not a
|
|
160
|
+
// collision - that is the shared-key case the api-level `secret` exists
|
|
161
|
+
// for, and saying so out loud is how you ask for it.
|
|
162
|
+
function checksecrets(
|
|
163
|
+
sdk: Record<string, SdkBlock>, profileName: string
|
|
164
|
+
): void {
|
|
165
|
+
const refs = Object.keys(sdk).sort()
|
|
166
|
+
|
|
167
|
+
for (const ref of refs) {
|
|
168
|
+
const name = sdk[ref].secret
|
|
169
|
+
if (null != name && !validname(name)) {
|
|
170
|
+
throw new StationError('station_secret_name',
|
|
171
|
+
'profile "' + profileName + '" sdk "' + ref +
|
|
172
|
+
'": secret name rejected by sekreto: ' + JSON.stringify(name))
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const seen = new Map<string, { ref: string, derived: boolean }>()
|
|
177
|
+
for (const ref of refs) {
|
|
178
|
+
const written = sdk[ref].secret
|
|
179
|
+
const derived = null == written || '' === written
|
|
180
|
+
const name = derived ? secretnameDefault(ref) : (written as string)
|
|
181
|
+
|
|
182
|
+
const prior = seen.get(name)
|
|
183
|
+
if (undefined !== prior && (derived || prior.derived)) {
|
|
184
|
+
throw new StationError('station_secret_collision',
|
|
185
|
+
'profile "' + profileName + '": instances "' + prior.ref + '" and "' +
|
|
186
|
+
ref + '" both resolve to secret name "' + name +
|
|
187
|
+
'", so they would share one credential; name it explicitly on ' +
|
|
188
|
+
'each, or at the api level to share it deliberately (§5.1)')
|
|
189
|
+
}
|
|
190
|
+
if (undefined === prior) { seen.set(name, { ref, derived }) }
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function sortedkeys(...maps: any[]): string[] {
|
|
195
|
+
const keys = new Set<string>()
|
|
196
|
+
for (const m of maps) {
|
|
197
|
+
if (null == m || 'object' !== typeof m || Array.isArray(m)) { continue }
|
|
198
|
+
for (const k of Object.keys(m)) { keys.add(k) }
|
|
199
|
+
}
|
|
200
|
+
return Array.from(keys).sort()
|
|
201
|
+
}
|
package/src/secrets.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { Sekreto, SekretoError } from '@voxgig/sekreto'
|
|
2
|
+
|
|
3
|
+
import { StationError } from './error'
|
|
4
|
+
|
|
5
|
+
// The secret broker (design §5): sekreto resolves, station places. The
|
|
6
|
+
// broker holds resolved values privately - they never enter options,
|
|
7
|
+
// events, or captures; the SDK sees only the placeholder.
|
|
8
|
+
|
|
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
|
+
export function placeholderFor(slug: string): string {
|
|
14
|
+
return '[station:' + slug + ']'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class SecretBroker {
|
|
18
|
+
private sekreto: Sekreto
|
|
19
|
+
// Values hoisted by adopt() from resident options.apikey (design §3.1).
|
|
20
|
+
private overrides = new Map<string, string>()
|
|
21
|
+
private cache = new Map<string, string>()
|
|
22
|
+
// Every value this broker ever held, for the exact-value scrub.
|
|
23
|
+
private held: string[] = []
|
|
24
|
+
|
|
25
|
+
constructor(providers: any[]) {
|
|
26
|
+
this.sekreto = new Sekreto({ providers })
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
hoist(slug: string, value: string): void {
|
|
30
|
+
this.overrides.set(slug, value)
|
|
31
|
+
this.held.push(value)
|
|
32
|
+
}
|
|
33
|
+
|
|
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
|
+
async value(slug: string, name: string): Promise<string> {
|
|
50
|
+
const override = this.overrides.get(slug)
|
|
51
|
+
if (null != override) { return override }
|
|
52
|
+
|
|
53
|
+
const cached = this.cache.get(name)
|
|
54
|
+
if (null != cached) { return cached }
|
|
55
|
+
|
|
56
|
+
let value: string
|
|
57
|
+
try {
|
|
58
|
+
value = await this.sekreto.get(name)
|
|
59
|
+
}
|
|
60
|
+
catch (e: any) {
|
|
61
|
+
if (e instanceof SekretoError && e.message.includes('unknown secret')) {
|
|
62
|
+
throw new StationError('station_secret_no_value',
|
|
63
|
+
'no store had "' + name + '" for plugin "' + slug + '"')
|
|
64
|
+
}
|
|
65
|
+
throw new StationError('station_secret_error', String(e?.message || e))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
this.cache.set(name, value)
|
|
69
|
+
this.held.push(value)
|
|
70
|
+
return value
|
|
71
|
+
}
|
|
72
|
+
|
|
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.
|
|
78
|
+
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
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Drop caches so the next resolve asks the stores again (rotation
|
|
89
|
+
// support rides on sekreto's refresh, design §5.3).
|
|
90
|
+
refresh(): void {
|
|
91
|
+
this.cache.clear()
|
|
92
|
+
if ('function' === typeof (this.sekreto as any).refresh) {
|
|
93
|
+
;(this.sekreto as any).refresh()
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|