@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.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +88 -0
  3. package/dist/src/Station.d.ts +238 -0
  4. package/dist/src/Station.js +1213 -0
  5. package/dist/src/Station.js.map +1 -0
  6. package/dist/src/adapter.d.ts +9 -0
  7. package/dist/src/adapter.js +171 -0
  8. package/dist/src/adapter.js.map +1 -0
  9. package/dist/src/config-shape.d.ts +1 -0
  10. package/dist/src/config-shape.js +329 -0
  11. package/dist/src/config-shape.js.map +1 -0
  12. package/dist/src/descriptor.d.ts +8 -0
  13. package/dist/src/descriptor.js +170 -0
  14. package/dist/src/descriptor.js.map +1 -0
  15. package/dist/src/error.d.ts +8 -0
  16. package/dist/src/error.js +60 -0
  17. package/dist/src/error.js.map +1 -0
  18. package/dist/src/events.d.ts +15 -0
  19. package/dist/src/events.js +46 -0
  20. package/dist/src/events.js.map +1 -0
  21. package/dist/src/factory.d.ts +24 -0
  22. package/dist/src/factory.js +79 -0
  23. package/dist/src/factory.js.map +1 -0
  24. package/dist/src/feature.d.ts +106 -0
  25. package/dist/src/feature.js +324 -0
  26. package/dist/src/feature.js.map +1 -0
  27. package/dist/src/index.browser.d.ts +14 -0
  28. package/dist/src/index.browser.js +64 -0
  29. package/dist/src/index.browser.js.map +1 -0
  30. package/dist/src/index.d.ts +14 -0
  31. package/dist/src/index.js +56 -0
  32. package/dist/src/index.js.map +1 -0
  33. package/dist/src/loader.d.ts +45 -0
  34. package/dist/src/loader.js +181 -0
  35. package/dist/src/loader.js.map +1 -0
  36. package/dist/src/profile.d.ts +6 -0
  37. package/dist/src/profile.js +77 -0
  38. package/dist/src/profile.js.map +1 -0
  39. package/dist/src/profilecore.d.ts +10 -0
  40. package/dist/src/profilecore.js +180 -0
  41. package/dist/src/profilecore.js.map +1 -0
  42. package/dist/src/secrets.d.ts +12 -0
  43. package/dist/src/secrets.js +93 -0
  44. package/dist/src/secrets.js.map +1 -0
  45. package/dist/src/shape.d.ts +42 -0
  46. package/dist/src/shape.js +444 -0
  47. package/dist/src/shape.js.map +1 -0
  48. package/dist/src/types.d.ts +138 -0
  49. package/dist/src/types.js +5 -0
  50. package/dist/src/types.js.map +1 -0
  51. package/package.json +60 -0
  52. package/src/Station.ts +1310 -0
  53. package/src/adapter.ts +183 -0
  54. package/src/config-shape.ts +326 -0
  55. package/src/descriptor.ts +179 -0
  56. package/src/error.ts +64 -0
  57. package/src/events.ts +45 -0
  58. package/src/factory.ts +93 -0
  59. package/src/feature.ts +321 -0
  60. package/src/index.browser.ts +56 -0
  61. package/src/index.ts +50 -0
  62. package/src/loader.ts +188 -0
  63. package/src/profile.ts +67 -0
  64. package/src/profilecore.ts +201 -0
  65. package/src/secrets.ts +96 -0
  66. package/src/shape.ts +435 -0
  67. package/src/types.ts +185 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 voxgig
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,88 @@
1
+ # station — TypeScript
2
+
3
+ The TypeScript port of [station](../README.md): one control surface for
4
+ outbound integrations.
5
+
6
+ ```sh
7
+ npm test # conformance + integration suites
8
+ ```
9
+
10
+ This port is **canonical**. `src/Station.ts` and its siblings define
11
+ station's behaviour; every other port is a translation of them, and a
12
+ change belongs here first.
13
+
14
+ Secrets are brokered through `@voxgig/sekreto`; the config grammar
15
+ validates through [voxgig/struct](https://github.com/voxgig/struct).
16
+ Those are the only two runtime dependencies a station library takes
17
+ (design §10).
18
+
19
+ ## Two entries, one surface
20
+
21
+ `package.json` `exports` selects between them by condition:
22
+
23
+ | condition | entry | |
24
+ |---|---|---|
25
+ | default | `dist/src/index.js` | the node entry — the whole surface |
26
+ | `browser` | `dist/src/index.browser.js` | the same surface **minus** the file-loading half |
27
+
28
+ `loadConfig`, `findConfigFile` and `configScope` are node-only: they live
29
+ in `profile.ts`, the one module here with top-level `node:` imports, and
30
+ the browser entry simply does not carry them. So the browser graph pulls
31
+ no `node:` builtin at all — `test/browser.test.ts` asserts it by walking
32
+ the *compiled* `dist` files, the same requires a bundler follows, rather
33
+ than trusting the source. The boundary, as that test states it: the walk
34
+ covers this package's own modules; `@voxgig/sekreto` and
35
+ `@voxgig/struct` are recorded but not entered, since their browser
36
+ posture is their own.
37
+
38
+ That is why `profilecore.ts` exists: the pure half of profile handling
39
+ (selection, the ref grammar, the §3.3 merge) is data-in/data-out and the
40
+ browser entry can reach it. `profile.ts` re-exports it, so no import path
41
+ in the node half changes.
42
+
43
+ On the browser entry `Station.open()` takes `config` as an object — the
44
+ explicit-config path, which skips file loading entirely. With no `config`
45
+ it behaves as node does when no `station.json` exists: null config,
46
+ repo-scoped.
47
+
48
+ ## Layout
49
+
50
+ | | |
51
+ |---|---|
52
+ | `src/Station.ts` | the core: open/connect/adopt/options, sdk/create/check/warm, registry, transport middleware, the ref grammar |
53
+ | `src/index.ts` | the node entry, and the registration that hands Station the file half |
54
+ | `src/index.browser.ts` | the browser entry (above) |
55
+ | `src/adapter.ts` | `featureBinding` (the one binding entry) + the carried adapter |
56
+ | `src/shape.ts` | `normalizeConfig`/`validateConfig`: the §4 grammar, the §4.4 workarounds, the §5.2 credential scans |
57
+ | `src/config-shape.ts` | a verbatim mirror of [`spec/config-shape.json`](../spec/config-shape.json) — generated by `make sync-shape`, never edited by hand |
58
+ | `src/feature.ts` | the §8 three-level merge, order resolver, and descriptor-derived checker |
59
+ | `src/factory.ts` | the process-global factory table (`provide`, §6.2) |
60
+ | `src/loader.ts` | `loadSync`/`loadAsync`: CommonJS require + guarded dynamic import (§6.3) |
61
+ | `src/descriptor.ts` | descriptor v1 normalizer + canonical serializer |
62
+ | `src/secrets.ts` | the sekreto-backed broker + the placeholder |
63
+ | `src/profile.ts` | the node-only file half: `station.json` lookup, config scope |
64
+ | `src/profilecore.ts` | the pure half: profile selection, ref grammar, the §3.3 merge |
65
+ | `src/events.ts` | the bounded event ring + tap |
66
+ | `src/error.ts` | the error-code catalog |
67
+ | `src/types.ts` | shared types; the descriptor and event shapes are pinned by the corpus, so evolve them additively |
68
+ | `src/omnihome.ts` | locates the sibling voxgig/omni checkout (test plumbing — it is not shipped) |
69
+
70
+ ## Testing
71
+
72
+ The conformance suite runs [`spec/station.json`](../spec/station.json) —
73
+ the same file every port runs — through the TypeScript
74
+ [voxgig/omni](https://github.com/voxgig/omni) runner. Set `OMNI_HOME` if
75
+ your omni checkout is not a sibling of this repository.
76
+
77
+ Note one real gap, recorded in [`spec/README.md`](../spec/README.md):
78
+ unlike ten of the other ports, this one has **no completeness guard**. It
79
+ hand-writes a test per section rather than deriving them from a table, so
80
+ a section added to the corpus would silently not run *here* — in the very
81
+ port the others are translated from. Verified by mutation: a bogus
82
+ section added to `station.json` passes this suite and fails ten others.
83
+
84
+ The integration suites run against a REAL generated SDK (generated by
85
+ sdkgen with the station feature) and the test API servers in
86
+ [`test/api/`](../test/api/). Without a generated checkout
87
+ (`STATION_TEST_SDKS`, default `/home/user/voxgig-sdk`) they skip rather
88
+ than fail.
@@ -0,0 +1,238 @@
1
+ import { Binding, PluginEntry, ResolvedInstance, SdkBlock, StationEvent, StationOptions } from './types';
2
+ /** The FILE half of config loading, behind a seam so this module never
3
+ * imports it (§2.2, §17 Phase 1: no top-level `node:` imports reachable
4
+ * from the browser entry). The NODE entry (`index.ts`) registers
5
+ * `profile.ts`'s loadConfig/configScope at module load; the browser
6
+ * entry registers nothing, so a browser `open()` with no explicit
7
+ * `config` behaves exactly as node does when no station.json exists -
8
+ * null config, repo-scoped - rather than inventing a third state.
9
+ * Passing `config` (the documented browser spelling, §2.2) skips the
10
+ * seam entirely on both entries, which is the pre-existing
11
+ * explicit-config path unchanged. */
12
+ export type ConfigFileIO = {
13
+ loadConfig: (from?: string) => any;
14
+ configScope: (from?: string) => 'repo' | 'user' | 'none';
15
+ };
16
+ export declare function setConfigFileIO(io: ConfigFileIO): void;
17
+ /** §6.1: `as` IS A TAG, NOT A FREE NAME.
18
+ *
19
+ * The api comes from the SDK being passed, so the resulting ref is
20
+ * `<api>$<tag>` and multi-instance works imperatively too. A full ref is
21
+ * also accepted and is VALIDATED: its name must equal the SDK's api
22
+ * slug, or it is `station_instance_api`.
23
+ *
24
+ * Both forms are needed because the imperative path is where the api is
25
+ * implicit in an argument rather than written in a key. An `as` that
26
+ * took an arbitrary name would reintroduce exactly the second-identity
27
+ * problem the ref re-key removed: under the ref invariant
28
+ * `as: 'solar-eu'` would denote the untagged `solar-eu` DEFINITION, not
29
+ * an instance of the SDK just handed in, and registry grouping, api
30
+ * defaults and every ref consumer would disagree about what it is.
31
+ *
32
+ * A bare connect(SDK) with no name falls back to the descriptor slug,
33
+ * which is today's behaviour and why the single-instance case is
34
+ * unchanged to the byte. */
35
+ export declare function instanceRef(api: string, fopts?: any): string;
36
+ export declare function checkInstanceName(name: string): boolean;
37
+ export declare function checkInstanceTag(tag: string): boolean;
38
+ export declare class Station {
39
+ private static ambient;
40
+ private static ambientOpts;
41
+ /** §6.2's second path, and the front door the docs name.
42
+ *
43
+ * The package exported only the free `provide()` and the class had no
44
+ * static — so the documented `Station.provide(...)` call failed, and
45
+ * so did the remediation `station_no_factory` prints, which tells the
46
+ * user to make exactly that call. Delegates to the same
47
+ * process-global table the free function fills; there is one
48
+ * registry, not two. */
49
+ static provide(api: string, factory: any): void;
50
+ private opts;
51
+ private profile;
52
+ private broker;
53
+ private buffer;
54
+ private registry;
55
+ private clients;
56
+ private raw;
57
+ private repoScoped;
58
+ private requireProxy;
59
+ /** An auto-assigned tag to the DECLARED instance it stands for
60
+ * (§5.3). Kept beside the registry rather than inside it because the
61
+ * mapping exists before construction, and `blockFor` needs it during
62
+ * registration. */
63
+ private aliasOf;
64
+ private closed;
65
+ static open(opts?: StationOptions): Station;
66
+ static current(): Station | null;
67
+ static reset(): void;
68
+ constructor(opts?: StationOptions);
69
+ connect(SDK: any, opts?: any): any;
70
+ adopt(SDK: any, opts?: any): any;
71
+ private construct;
72
+ options(a?: any, b?: any): any;
73
+ _boundEntry(client: any): PluginEntry | null;
74
+ /** The profile block that governs an instance — its own if the
75
+ * profile declares it, otherwise its **api's**.
76
+ *
77
+ * `resolveProfile` builds `profile.sdk` from the declared refs alone
78
+ * ("an api block declares no instance, so the ref set comes from the
79
+ * two `sdk` maps"), shallow-merging `profile.api[a]` into each. That
80
+ * is right for a declared instance and leaves an IMPERATIVE one —
81
+ * `connect(SDK, { as: 'test' })`, named but never written into
82
+ * config — with no block at all. The api-level `secret`, `base` and
83
+ * most seriously `policy.hosts` then did not reach it, so a profile
84
+ * that denies egress everywhere denied nothing for a tagged client.
85
+ *
86
+ * ONE RULE, ONE PLACE: registration and the transport seam both ask
87
+ * here, because they disagreeing is how the credential and the
88
+ * allowlist came apart in the first place. */
89
+ private blockFor;
90
+ /** The DECLARED instance an assigned tag stands for, or the name
91
+ * itself. `create('stripe$prod')` registers under `stripe$1`, and
92
+ * every question about that client's configuration — its secret, its
93
+ * base, its egress policy — is a question about `stripe$prod`. */
94
+ private declaredRef;
95
+ _register(client: any, config: any, options: any, _calleropts: any, fopts?: any): {
96
+ binding: Binding;
97
+ profilePlugin?: SdkBlock;
98
+ };
99
+ private descriptorCache;
100
+ /** The descriptor describes the API, and is SHARED by every instance
101
+ * of it — which is §7.4's own reasoning for why the effective secret
102
+ * name moved to `Binding` and is not read off the descriptor.
103
+ *
104
+ * The same argument applies to feature activation, and this did not
105
+ * follow it: the cache is keyed by slug alone while
106
+ * `normalizeDescriptor`'s second argument decides each feature row's
107
+ * `active`. So two instances of one api constructed with different
108
+ * feature maps shared the FIRST one's feature state, and
109
+ * `descriptorOf()` / `canonicalDescriptor()` became
110
+ * construction-order-dependent.
111
+ *
112
+ * Normalized with no per-instance features, so the shared value holds
113
+ * only API-stable metadata — which is what `factory.ts` already does
114
+ * when it normalizes at provide time. Per-instance activation is
115
+ * `featuresOf(name)`'s answer, and always was. */
116
+ private describe;
117
+ _hoist(name: string, value: string): void;
118
+ _transport(name: string, inner: any, fctx: any, fullurl: string, fetchdef: any): Promise<any>;
119
+ private emitHttp;
120
+ private emitErr;
121
+ _opEvent(name: string, ctx: any, outcome: string): void;
122
+ plugins(): {
123
+ name: string;
124
+ api: string;
125
+ slug: string;
126
+ descriptor: any;
127
+ rung: string;
128
+ secretname?: string;
129
+ warnings: string[];
130
+ }[];
131
+ /** The instance, constructed on first call and CACHED: same name ->
132
+ * same object. That caching is what makes "get it where you need it"
133
+ * a real instruction - call it in a request handler, in a worker, in
134
+ * a test, and the first call pays construction while the rest are a
135
+ * map lookup.
136
+ *
137
+ * SYNCHRONOUS (§6.3), which is what bounds the loader to CommonJS. */
138
+ sdk(name: string): any;
139
+ /** An UNCACHED client from the same resolved config plus overrides,
140
+ * for the case that genuinely wants a distinct one - a per-request
141
+ * credential scope, a test double. Deliberately the longer name.
142
+ *
143
+ * It registers under an AUTO-ASSIGNED TAG, because §7.5 registers
144
+ * every constructed adapter under its instance name and
145
+ * `station_bound_twice` fires on a second binding of one name: a
146
+ * second `create('stripe')` would otherwise throw, which is exactly
147
+ * the per-request case this exists for. The tag is the lowest unused
148
+ * positive integer, so an auto-tagged instance is an ORDINARY
149
+ * instance rather than a parallel identity scheme - `plugins()`, the
150
+ * placeholder, the event stream and `station_bound_twice` all keep
151
+ * working on one identity model.
152
+ *
153
+ * The SECRET NAME does not follow the assigned tag: it resolves from
154
+ * the DECLARED instance the tag was assigned under, so every client
155
+ * of one instance shares one broker cache entry rather than
156
+ * re-resolving per request (§5.3). */
157
+ create(name: string, overrides?: any): any;
158
+ /** The lowest positive integer tag not already taken, by a LIVE
159
+ * instance or a DECLARED one.
160
+ *
161
+ * The registry alone was not enough: a profile may declare
162
+ * `stripe$1`, and until something constructs it `registry.has` says
163
+ * false — so `create('stripe$prod')` took that identity for a client
164
+ * built from the `stripe$prod` block. `instances()` then reported the
165
+ * declared `stripe$1` as live with the wrong client, and a later
166
+ * `sdk('stripe$1')` failed `station_bound_twice` against a binding
167
+ * that was never its own. Declaration reserves the name whether or
168
+ * not it has been built. */
169
+ private autotag;
170
+ private build;
171
+ /** §6.2's three paths, in order of preference: self-registration,
172
+ * `Station.provide`, then the loader. */
173
+ private resolveFactory;
174
+ /** `package` is honoured only from repo-scoped config (§6.3), and a
175
+ * user-level one is IGNORED WITH A WARNING rather than imported - it
176
+ * names code to load and sits outside the repo's review boundary. */
177
+ private loaderPackage;
178
+ /** ts/js only: preload ESM packages into the factory table, after
179
+ * which `sdk()` is synchronous again for everything (§6.3). One
180
+ * `await` at startup rather than one per call site. */
181
+ load(): Promise<void>;
182
+ /** The merged, ordered feature set for one instance, WITH
183
+ * PROVENANCE (§8.7): which config level set each value.
184
+ *
185
+ * Provenance is the half that makes a fleet view usable rather than
186
+ * merely correct - at 26 instances "why is retry off here" is the
187
+ * question, and a merged map alone cannot answer it. */
188
+ featuresOf(name: string): {
189
+ ordered: string[];
190
+ merged: {
191
+ [k: string]: any;
192
+ };
193
+ from: {
194
+ [k: string]: {
195
+ [k: string]: string;
196
+ };
197
+ };
198
+ };
199
+ /** The fleet feature view: instance x feature, effective options, and
200
+ * which config level set each (§8.7). */
201
+ features(filter?: string | {
202
+ instance?: string;
203
+ api?: string;
204
+ feature?: string;
205
+ }): any[];
206
+ /** Eagerly resolve and construct every ACTIVE instance - for CI
207
+ * (§6.6). The point is to turn availability errors, which are
208
+ * deliberately deferred to first use, into one failure at a moment
209
+ * somebody is watching. */
210
+ check(): {
211
+ ok: string[];
212
+ failed: {
213
+ name: string;
214
+ code?: string;
215
+ message: string;
216
+ }[];
217
+ };
218
+ /** Batch-resolve secrets for ACTIVE instances (§5.5).
219
+ *
220
+ * With no argument it warms the active ones only, because reaching
221
+ * for a credential belonging to a disabled integration is the wrong
222
+ * default. `warm(names)` warms exactly what it is given, inactive
223
+ * included, because an explicit name is an explicit request. */
224
+ warm(names?: string[]): Promise<{
225
+ warmed: string[];
226
+ missed: string[];
227
+ }>;
228
+ instances(): ResolvedInstance[];
229
+ descriptorOf(slug: string): any;
230
+ canonicalDescriptor(slug: string): string;
231
+ events(): StationEvent[];
232
+ tap(fn: (ev: StationEvent) => void): () => void;
233
+ status(): any;
234
+ redact(text: string): string;
235
+ refreshSecrets(): void;
236
+ close(): void;
237
+ private emit;
238
+ }