@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/src/adapter.ts ADDED
@@ -0,0 +1,183 @@
1
+ import { StationError } from './error'
2
+ import { Station } from './Station'
3
+
4
+ // The station side of the plugin contract (design §3), in ONE place:
5
+ // featureBinding() is what both entry paths delegate to -
6
+ // - the GENERATED station feature (sdkgen-station's ts template) calls
7
+ // it from its init() and forwards its hook methods;
8
+ // - the library's carried adapter (adapterFeature, the adopt/connect
9
+ // retrofit for SDKs generated without the feature) is a thin shell
10
+ // over the same call.
11
+ // Registration at init, wrap position verified, transport wrapped with
12
+ // copy-on-inject, hooks bridged to op events. Anything changed here
13
+ // changes both paths - which is the point.
14
+
15
+ let corrSeq = 0
16
+
17
+ export type FeatureBinding = {
18
+ // The INSTANCE name (§7.1). Keeps the field name so the generated
19
+ // adapter contract is unchanged; for a single-instance project it is
20
+ // the api slug, exactly as before.
21
+ slug: string
22
+ PrePoint(ctx: any): void
23
+ PreDone(ctx: any): void
24
+ PreUnexpected(ctx: any): void
25
+ }
26
+
27
+ // Resolve the station this activation binds to: an explicit handle in
28
+ // the feature options (connect/adopt and st.options() pass one), else
29
+ // the ambient instance. No station open -> null: an activated feature
30
+ // with no opened station is an inert no-op that emits nothing and
31
+ // fails nothing (design §3.1).
32
+ export function featureBinding(ctx: any, fopts: any): FeatureBinding | null {
33
+ const station: Station | null =
34
+ (fopts?.station instanceof Station ? fopts.station : null) ?? Station.current()
35
+ if (null == station) { return null }
36
+
37
+ const client = ctx.client
38
+
39
+ // Same construction, second arrival (generated feature + carried
40
+ // adapter both active on one client): the first bind won, this one
41
+ // is inert. See Station._boundEntry.
42
+ if (null != station._boundEntry(client)) { return null }
43
+ const utility = ctx.utility
44
+ const options = ctx.options
45
+ const calleropts = fopts?.calleropts
46
+
47
+ // Position guard (design §3.3): the wrap must sit immediately
48
+ // outside the base transport - inside retry/cache/ratelimit - or
49
+ // its http events stop being wire truth. Position in
50
+ // client._features IS init order, so verify it and fail loudly.
51
+ const names = client._features.map((f: any) => f.name)
52
+ const self = names.indexOf('station')
53
+ const testAt = names.indexOf('test')
54
+ const expected = -1 === testAt ? 0 : testAt + 1
55
+ if (self !== expected) {
56
+ throw new StationError('station_wrap_order',
57
+ 'station must init immediately after the base transport; ' +
58
+ 'feature order is [' + names.join(', ') + ']')
59
+ }
60
+
61
+ // §7.5: registration is driven by station now. `fopts.instance` is
62
+ // where station puts the instance name it knew before construction
63
+ // began; `_register` reads it and falls back to the descriptor slug,
64
+ // which is today's behaviour for a bare connect(SDK).
65
+ const reg = station._register(client, ctx.config, options, calleropts, fopts)
66
+ const { binding, profilePlugin } = reg
67
+ // The INSTANCE name, not the api slug. Everything below keys on it -
68
+ // the placeholder, the transport seam, the op events - because two
69
+ // live instances of one api must be distinguishable at each.
70
+ const name = binding.plugin
71
+
72
+ // Base URL precedence (design §3.5): caller opts (7) beat the
73
+ // profile (4), which beats the SDK's config default (1) already in
74
+ // options.base. Applied only on the connect/adopt path, where the
75
+ // caller opts are knowable; station.options() applies the profile
76
+ // base at options-build time instead.
77
+ if (null != calleropts && null == calleropts.base && null != profilePlugin?.base) {
78
+ options.base = profilePlugin.base
79
+ }
80
+
81
+ // Policy allowlists (design §16): `allow.op` / `allow.method` are
82
+ // "the same vocabulary the SDKs already enforce (`options.allow`, and
83
+ // the raw-access gate every target implements); station sets these
84
+ // SDK options from policy so enforcement is in the SDK's own
85
+ // pipeline" - the point_op_allow / spec_method_allow gates. The SDK's
86
+ // own option form is a comma-separated string, so the policy's list
87
+ // joins into it. Applied at binding time, which is inside the
88
+ // constructor, and on BOTH entry paths - connect/adopt and the
89
+ // declarative build both delegate here. Unlike `base` above, which is
90
+ // a default the caller may override, an allowlist is ENFORCEMENT:
91
+ // policy wins over whatever the options carry, on exactly the keys it
92
+ // sets.
93
+ const pallow = profilePlugin?.policy?.allow
94
+ if (null != pallow && 'object' === typeof pallow) {
95
+ const allow = {
96
+ ...(null != options.allow && 'object' === typeof options.allow
97
+ ? options.allow : {}),
98
+ }
99
+ if (Array.isArray(pallow.op)) { allow.op = pallow.op.join(',') }
100
+ if (Array.isArray(pallow.method)) { allow.method = pallow.method.join(',') }
101
+ options.allow = allow
102
+ }
103
+
104
+ if ('none' !== binding.rung) {
105
+ const placeholder = binding.placeholder!
106
+
107
+ // A real credential already resident in the options is hoisted
108
+ // into the broker and replaced by the placeholder before
109
+ // construction completes (design §3.1 adopt) - options() and
110
+ // prepare() output become placeholder-safe from here on.
111
+ const resident = options.apikey
112
+ if ('string' === typeof resident && '' !== resident && placeholder !== resident) {
113
+ station._hoist(name, resident)
114
+ }
115
+ options.apikey = placeholder
116
+ }
117
+
118
+ // Wrap the transport. Copy-on-inject (design §5.3) happens inside
119
+ // station._transport; auth-inactive plugins skip credential
120
+ // planning but the wrap still observes (design §5.3).
121
+ const inner = utility.fetcher
122
+ if (true === (inner as any).__station__) {
123
+ throw new StationError('station_bound_twice',
124
+ 'plugin "' + name + '" already carries a station wrap')
125
+ }
126
+ const wrapped = async (fctx: any, fullurl: string, fetchdef: any) => {
127
+ return station._transport(name, inner, fctx, fullurl, fetchdef)
128
+ }
129
+ ;(wrapped as any).__station__ = true
130
+ utility.fetcher = wrapped
131
+
132
+ return {
133
+ slug: name,
134
+
135
+ // Hook bridge (design §3 item 3): operation semantics correlated
136
+ // with the HTTP events via a per-operation id on the SDK's own ctx.
137
+ PrePoint(opctx: any): void {
138
+ opctx.station$ = { corr: 'c' + (++corrSeq), start: Date.now() }
139
+ },
140
+ PreDone(opctx: any): void {
141
+ station._opEvent(name, opctx, resultOutcome(opctx))
142
+ },
143
+ PreUnexpected(opctx: any): void {
144
+ station._opEvent(name, opctx, 'unexpected')
145
+ },
146
+ }
147
+ }
148
+
149
+ // The carried adapter: the retrofit path for SDKs generated without
150
+ // the station feature (design §3.1 adopt). A duck-typed Feature whose
151
+ // init/hooks delegate to featureBinding - it exists so connect/adopt
152
+ // work on any regenerated SDK, and it must stay behaviorally identical
153
+ // to the generated feature template in sdkgen-station.
154
+ export function adapterFeature(station: Station, calleropts: any): any {
155
+ const feature: any = {
156
+ name: 'station',
157
+ version: '0.0.1',
158
+ active: true,
159
+ _binding: null as FeatureBinding | null,
160
+
161
+ // featureAdd reads _options for positioning: immediately after the
162
+ // test feature's base transport (design §3.3). When test is absent
163
+ // from the add order this is a no-op append, which for a bare SDK
164
+ // still lands the wrap immediately outside the base transport.
165
+ _options: { __after__: 'test' },
166
+
167
+ init(ctx: any, fopts: any): void {
168
+ feature._binding = featureBinding(ctx, { ...fopts, station, calleropts })
169
+ },
170
+ PrePoint(ctx: any): void { feature._binding?.PrePoint(ctx) },
171
+ PreDone(ctx: any): void { feature._binding?.PreDone(ctx) },
172
+ PreUnexpected(ctx: any): void { feature._binding?.PreUnexpected(ctx) },
173
+ }
174
+ return feature
175
+ }
176
+
177
+ function resultOutcome(ctx: any): string {
178
+ const result = ctx.result
179
+ if (null == result) { return 'unknown' }
180
+ if (null != result.err) { return 'err' }
181
+ if (false === result.ok) { return 'err' }
182
+ return 'ok'
183
+ }
@@ -0,0 +1,326 @@
1
+ /* MIRROR of `spec/config-shape.json` - design §4.3, verbatim.
2
+ *
3
+ * The JSON under `spec/` is the artifact every port reads; this module
4
+ * is the TypeScript port's shipped copy of it. It exists because
5
+ * `package.json` ships `dist/src` only, so a runtime read of `spec/`
6
+ * works in the repo and breaks in a published consumer - and
7
+ * `validateConfig` runs at `open()`, not just under test.
8
+ *
9
+ * DO NOT EDIT THIS FILE. Edit `spec/config-shape.json`, then re-run
10
+ * `make sync-shape`. `test/shape.test.ts` fails on drift.
11
+ */
12
+
13
+ export const CONFIG_SHAPE: any = {
14
+ "station": [
15
+ "`$EXACT`",
16
+ 1
17
+ ],
18
+ "profiles": {
19
+ "`$CHILD`": {
20
+ "secrets": {
21
+ "providers": "`$LIST`"
22
+ },
23
+ "api": {
24
+ "`$CHILD`": {
25
+ "active": "`$BOOLEAN`",
26
+ "package": [
27
+ "`$ONE`",
28
+ "`$NIL`",
29
+ "`$STRING`"
30
+ ],
31
+ "export": [
32
+ "`$ONE`",
33
+ "`$NIL`",
34
+ "`$STRING`"
35
+ ],
36
+ "base": [
37
+ "`$ONE`",
38
+ "`$NIL`",
39
+ "`$STRING`"
40
+ ],
41
+ "secret": [
42
+ "`$ONE`",
43
+ "`$NIL`",
44
+ "`$STRING`"
45
+ ],
46
+ "resolve": [
47
+ "`$ONE`",
48
+ "`$NIL`",
49
+ [
50
+ "`$EXACT`",
51
+ "library"
52
+ ],
53
+ [
54
+ "`$EXACT`",
55
+ "proxy"
56
+ ]
57
+ ],
58
+ "capture": [
59
+ "`$ONE`",
60
+ "`$NIL`",
61
+ [
62
+ "`$EXACT`",
63
+ "meta"
64
+ ],
65
+ [
66
+ "`$EXACT`",
67
+ "headers"
68
+ ],
69
+ [
70
+ "`$EXACT`",
71
+ "full"
72
+ ]
73
+ ],
74
+ "policy": [
75
+ "`$ONE`",
76
+ "`$NIL`",
77
+ {
78
+ "allow": [
79
+ "`$ONE`",
80
+ "`$NIL`",
81
+ {
82
+ "method": [
83
+ "`$CHILD`",
84
+ "`$STRING`"
85
+ ],
86
+ "op": [
87
+ "`$CHILD`",
88
+ "`$STRING`"
89
+ ]
90
+ }
91
+ ],
92
+ "budget": [
93
+ "`$ONE`",
94
+ "`$NIL`",
95
+ {
96
+ "concurrency": [
97
+ "`$ONE`",
98
+ "`$NIL`",
99
+ "`$INTEGER`"
100
+ ],
101
+ "rps": [
102
+ "`$ONE`",
103
+ "`$NIL`",
104
+ "`$NUMBER`"
105
+ ]
106
+ }
107
+ ],
108
+ "hosts": [
109
+ "`$CHILD`",
110
+ "`$STRING`"
111
+ ],
112
+ "mode": [
113
+ "`$ONE`",
114
+ "`$NIL`",
115
+ [
116
+ "`$EXACT`",
117
+ "live"
118
+ ],
119
+ [
120
+ "`$EXACT`",
121
+ "record"
122
+ ],
123
+ [
124
+ "`$EXACT`",
125
+ "replay"
126
+ ],
127
+ [
128
+ "`$EXACT`",
129
+ "mock"
130
+ ],
131
+ [
132
+ "`$EXACT`",
133
+ "block"
134
+ ]
135
+ ]
136
+ }
137
+ ],
138
+ "agent": [
139
+ "`$ONE`",
140
+ "`$NIL`",
141
+ {
142
+ "write": "`$BOOLEAN`"
143
+ }
144
+ ],
145
+ "options": [
146
+ "`$ONE`",
147
+ "`$NIL`",
148
+ "`$MAP`"
149
+ ],
150
+ "feature": {
151
+ "`$CHILD`": {
152
+ "active": "`$BOOLEAN`",
153
+ "`$OPEN`": true
154
+ }
155
+ }
156
+ }
157
+ },
158
+ "sdk": {
159
+ "`$CHILD`": {
160
+ "active": "`$BOOLEAN`",
161
+ "package": [
162
+ "`$ONE`",
163
+ "`$NIL`",
164
+ "`$STRING`"
165
+ ],
166
+ "export": [
167
+ "`$ONE`",
168
+ "`$NIL`",
169
+ "`$STRING`"
170
+ ],
171
+ "base": [
172
+ "`$ONE`",
173
+ "`$NIL`",
174
+ "`$STRING`"
175
+ ],
176
+ "secret": [
177
+ "`$ONE`",
178
+ "`$NIL`",
179
+ "`$STRING`"
180
+ ],
181
+ "resolve": [
182
+ "`$ONE`",
183
+ "`$NIL`",
184
+ [
185
+ "`$EXACT`",
186
+ "library"
187
+ ],
188
+ [
189
+ "`$EXACT`",
190
+ "proxy"
191
+ ]
192
+ ],
193
+ "capture": [
194
+ "`$ONE`",
195
+ "`$NIL`",
196
+ [
197
+ "`$EXACT`",
198
+ "meta"
199
+ ],
200
+ [
201
+ "`$EXACT`",
202
+ "headers"
203
+ ],
204
+ [
205
+ "`$EXACT`",
206
+ "full"
207
+ ]
208
+ ],
209
+ "policy": [
210
+ "`$ONE`",
211
+ "`$NIL`",
212
+ {
213
+ "allow": [
214
+ "`$ONE`",
215
+ "`$NIL`",
216
+ {
217
+ "method": [
218
+ "`$CHILD`",
219
+ "`$STRING`"
220
+ ],
221
+ "op": [
222
+ "`$CHILD`",
223
+ "`$STRING`"
224
+ ]
225
+ }
226
+ ],
227
+ "budget": [
228
+ "`$ONE`",
229
+ "`$NIL`",
230
+ {
231
+ "concurrency": [
232
+ "`$ONE`",
233
+ "`$NIL`",
234
+ "`$INTEGER`"
235
+ ],
236
+ "rps": [
237
+ "`$ONE`",
238
+ "`$NIL`",
239
+ "`$NUMBER`"
240
+ ]
241
+ }
242
+ ],
243
+ "hosts": [
244
+ "`$CHILD`",
245
+ "`$STRING`"
246
+ ],
247
+ "mode": [
248
+ "`$ONE`",
249
+ "`$NIL`",
250
+ [
251
+ "`$EXACT`",
252
+ "live"
253
+ ],
254
+ [
255
+ "`$EXACT`",
256
+ "record"
257
+ ],
258
+ [
259
+ "`$EXACT`",
260
+ "replay"
261
+ ],
262
+ [
263
+ "`$EXACT`",
264
+ "mock"
265
+ ],
266
+ [
267
+ "`$EXACT`",
268
+ "block"
269
+ ]
270
+ ]
271
+ }
272
+ ],
273
+ "agent": [
274
+ "`$ONE`",
275
+ "`$NIL`",
276
+ {
277
+ "write": "`$BOOLEAN`"
278
+ }
279
+ ],
280
+ "options": [
281
+ "`$ONE`",
282
+ "`$NIL`",
283
+ "`$MAP`"
284
+ ],
285
+ "feature": {
286
+ "`$CHILD`": {
287
+ "active": "`$BOOLEAN`",
288
+ "`$OPEN`": true
289
+ }
290
+ }
291
+ }
292
+ },
293
+ "feature": {
294
+ "`$CHILD`": {
295
+ "active": "`$BOOLEAN`",
296
+ "order": {
297
+ "before": [
298
+ "`$ONE`",
299
+ "`$NIL`",
300
+ "`$STRING`",
301
+ [
302
+ "`$CHILD`",
303
+ "`$STRING`"
304
+ ]
305
+ ],
306
+ "after": [
307
+ "`$ONE`",
308
+ "`$NIL`",
309
+ "`$STRING`",
310
+ [
311
+ "`$CHILD`",
312
+ "`$STRING`"
313
+ ]
314
+ ],
315
+ "band": [
316
+ "`$ONE`",
317
+ "`$NIL`",
318
+ "`$INTEGER`"
319
+ ]
320
+ },
321
+ "`$OPEN`": true
322
+ }
323
+ }
324
+ }
325
+ }
326
+ }
@@ -0,0 +1,179 @@
1
+ import { Descriptor, DescriptorEntity, DescriptorPoint } from './types'
2
+
3
+ // The ONLY way to build an env-var token in station, mirroring sdkgen's
4
+ // packageMeta envToken exactly: 'gnarly-pets' -> 'GNARLY_PETS'. The
5
+ // `secretname` corpus section pins the round-trip against sekreto's
6
+ // envkey() and sdkgen's envName() - the one place three grammars meet.
7
+ export function envtoken(name: any): string {
8
+ return String(name || '')
9
+ .toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, '')
10
+ }
11
+
12
+ // The default sekreto name for a plugin (design §5.1): envtoken(slug)
13
+ // lowercased, plus '.apikey'. sekreto's envkey() then yields exactly the
14
+ // env var the SDK's README documents: gnarly_pets.apikey -> GNARLY_PETS_APIKEY.
15
+ export function secretnameDefault(slug: string): string {
16
+ return envtoken(slug).toLowerCase() + '.apikey'
17
+ }
18
+
19
+ // Best-effort slug from a camel name, for SDKs whose embedded config
20
+ // predates main.slug (design §4 legacy sentinels). The hyphen caveat is
21
+ // real: 'VoxgigSolardemo' -> 'voxgigsolardemo', NOT 'voxgig-solardemo' -
22
+ // callers surface a warning event when this path is taken.
23
+ function legacySlug(name: string): string {
24
+ return String(name || '').toLowerCase()
25
+ }
26
+
27
+ // Normalize a generated SDK's embedded config into descriptor v1
28
+ // (design §4). The config is the one every SDK carries (Config.main /
29
+ // .feature / .options / .entity); the descriptor is a VIEW over it.
30
+ // Returns the descriptor plus any legacy warnings.
31
+ export function normalizeDescriptor(config: any, activeFeatures?: Record<string, any>):
32
+ { descriptor: Descriptor, warnings: string[] } {
33
+
34
+ const warnings: string[] = []
35
+ const main = config?.main || {}
36
+ const options = config?.options || {}
37
+
38
+ const name = String(main.name || '')
39
+ let slug: string = main.slug
40
+ if (null == slug || '' === slug) {
41
+ slug = legacySlug(name)
42
+ warnings.push('descriptor: legacy config has no main.slug; derived "' +
43
+ slug + '" from the camel name - hyphens in the original name are lost')
44
+ }
45
+
46
+ const version = null != main.version ? String(main.version) : '0.0.0'
47
+ const target = null != main.target ? String(main.target) : 'unknown'
48
+
49
+ const server: { name: string, value: string }[] = []
50
+ const svr = options.server || {}
51
+ for (const k of Object.keys(svr).sort()) {
52
+ server.push({ name: k, value: String(svr[k]) })
53
+ }
54
+
55
+ const authActive = null != options.auth
56
+ const auth = {
57
+ active: authActive,
58
+ prefix: authActive ? String(options.auth.prefix || '') : '',
59
+ secretname: secretnameDefault(slug),
60
+ }
61
+
62
+ const entities: Record<string, DescriptorEntity> = {}
63
+ const entdefs = config?.entity || {}
64
+ for (const ename of Object.keys(entdefs).sort()) {
65
+ const e = entdefs[ename] || {}
66
+ const fields: Record<string, { kind: string }> = {}
67
+ for (const f of e.fields || []) {
68
+ if (null != f && null != f.name) {
69
+ fields[f.name] = { kind: String(f.kind || f.type || '') }
70
+ }
71
+ }
72
+ const ops: Record<string, { points: DescriptorPoint[] }> = {}
73
+ const opdefs = e.op || {}
74
+ for (const opname of Object.keys(opdefs).sort()) {
75
+ const op = opdefs[opname] || {}
76
+ const points: DescriptorPoint[] = []
77
+ for (const p of op.points || []) {
78
+ if (null == p) { continue }
79
+ const point: DescriptorPoint = {
80
+ method: String(p.method || ''),
81
+ path: String(p.orig || p.path || ''),
82
+ params: (p.parts || []).filter((s: any) => 'string' === typeof s &&
83
+ s.startsWith(':')).map((s: string) => s.slice(1)),
84
+ }
85
+ if (null != p.select) { point.select = p.select }
86
+ points.push(point)
87
+ }
88
+ ops[opname] = { points }
89
+ }
90
+ entities[ename] = { fields, ops }
91
+ }
92
+
93
+ // §7.4: the features list gains `options` and `transport`, because
94
+ // it was throwing away what the SDK already embeds -
95
+ // `config.feature[name].options` is the feature's own declared key
96
+ // set WITH TYPED DEFAULTS, which is the schema §8.5 validates
97
+ // against, and `transport` is the role §8.4 orders by.
98
+ //
99
+ // Both are already inside the SDK; the descriptor stops discarding
100
+ // them. ADDITIVE, so descriptor v1 consumers are unaffected.
101
+ //
102
+ // `transport` is carried rather than inferred: the obvious signal, an
103
+ // empty `hook: {}`, is wrong for station, which both wraps AND
104
+ // dispatches hooks. Absent until sdkgen emits it (§11 items 6-7), and
105
+ // §8.4's role checks degrade to nothing until then rather than
106
+ // guessing.
107
+ const features: {
108
+ name: string, active: boolean,
109
+ options?: Record<string, any>, transport?: string,
110
+ }[] = []
111
+ const fdefs = config?.feature || {}
112
+ const factive = activeFeatures || {}
113
+ for (const fname of Object.keys(fdefs).sort()) {
114
+ const fdef = fdefs[fname] || {}
115
+ const row: any = { name: fname, active: true === factive[fname]?.active }
116
+ if (null != fdef.options && 'object' === typeof fdef.options) {
117
+ row.options = fdef.options
118
+ }
119
+ if (null != fdef.transport && '' !== fdef.transport) {
120
+ row.transport = String(fdef.transport)
121
+ }
122
+ features.push(row)
123
+ }
124
+
125
+ const descriptor: Descriptor = {
126
+ station: 1,
127
+ name, slug,
128
+ envtoken: envtoken(slug),
129
+ version, target,
130
+ base: String(options.base || ''),
131
+ server, auth, entities, features,
132
+ }
133
+
134
+ return { descriptor, warnings }
135
+ }
136
+
137
+ // Canonical serialization (design §4): UTF-8, object keys sorted
138
+ // bytewise, no insignificant whitespace, minimal JSON escaping. The
139
+ // proxy dedupes registrations by a hash of this, so every language must
140
+ // produce identical bytes - the `canonical-serialize` corpus section
141
+ // carries the adversarial cases.
142
+ //
143
+ // The bytewise sort compares UTF-8 BYTE SEQUENCES, not UTF-16 code
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
148
+ // comparison IS the bytewise order Buffer.compare gave - the corpus'
149
+ // non-ASCII sort case pins the equivalence.
150
+ const UTF8 = new TextEncoder()
151
+
152
+ function bytecompare(a: string, b: string): number {
153
+ const ab = UTF8.encode(a), bb = UTF8.encode(b)
154
+ const n = ab.length < bb.length ? ab.length : bb.length
155
+ for (let i = 0; i < n; i++) {
156
+ if (ab[i] !== bb[i]) { return ab[i] - bb[i] }
157
+ }
158
+ return ab.length - bb.length
159
+ }
160
+
161
+ export function canonicalSerialize(value: any): string {
162
+ if (null === value || 'boolean' === typeof value || 'number' === typeof value) {
163
+ return JSON.stringify(value)
164
+ }
165
+ if ('string' === typeof value) {
166
+ return JSON.stringify(value)
167
+ }
168
+ if (Array.isArray(value)) {
169
+ return '[' + value.map(canonicalSerialize).join(',') + ']'
170
+ }
171
+ if ('object' === typeof value) {
172
+ const keys = Object.keys(value).filter((k) => undefined !== value[k])
173
+ // Bytewise sort: compare UTF-8 byte sequences, not UTF-16 code units.
174
+ keys.sort(bytecompare)
175
+ return '{' + keys.map((k) =>
176
+ JSON.stringify(k) + ':' + canonicalSerialize(value[k])).join(',') + '}'
177
+ }
178
+ return 'null'
179
+ }