@toa.io/extensions.exposition 1.0.0-alpha.273 → 1.0.0-alpha.274

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 (47) hide show
  1. package/CHANGELOG.md +197 -0
  2. package/components/identity.basic/manifest.toa.yaml +1 -1
  3. package/components/identity.basic/operations/authenticate.js +1 -1
  4. package/components/identity.basic/operations/authenticate.js.map +1 -1
  5. package/components/identity.basic/operations/transit.js +1 -1
  6. package/components/identity.basic/operations/transit.js.map +1 -1
  7. package/components/identity.basic/operations/tsconfig.tsbuildinfo +1 -1
  8. package/components/identity.basic/operations/types.d.ts +2 -2
  9. package/components/identity.basic/source/authenticate.ts +1 -1
  10. package/components/identity.basic/source/transit.ts +1 -1
  11. package/components/identity.basic/source/types.ts +2 -2
  12. package/components/identity.credentials/operations/tsconfig.tsbuildinfo +1 -1
  13. package/components/identity.federation/operations/lib/exchange.js +2 -2
  14. package/components/identity.federation/operations/lib/exchange.js.map +1 -1
  15. package/components/identity.federation/operations/tsconfig.tsbuildinfo +1 -1
  16. package/components/identity.federation/operations/types/configuration.d.ts +3 -2
  17. package/components/identity.federation/source/lib/exchange.ts +2 -2
  18. package/components/identity.federation/source/types/configuration.ts +4 -2
  19. package/components/identity.keys/operations/tsconfig.tsbuildinfo +1 -1
  20. package/components/identity.otp/operations/tsconfig.tsbuildinfo +1 -1
  21. package/components/identity.passkeys/operations/tsconfig.tsbuildinfo +1 -1
  22. package/components/identity.roles/operations/tsconfig.tsbuildinfo +1 -1
  23. package/components/identity.tokens/operations/decrypt.js +1 -1
  24. package/components/identity.tokens/operations/decrypt.js.map +1 -1
  25. package/components/identity.tokens/operations/encrypt.js +1 -1
  26. package/components/identity.tokens/operations/encrypt.js.map +1 -1
  27. package/components/identity.tokens/operations/lib/types.d.ts +3 -2
  28. package/components/identity.tokens/operations/tsconfig.tsbuildinfo +1 -1
  29. package/components/identity.tokens/source/authenticate.test.ts +7 -2
  30. package/components/identity.tokens/source/decrypt.test.ts +13 -7
  31. package/components/identity.tokens/source/decrypt.ts +1 -1
  32. package/components/identity.tokens/source/encrypt.test.ts +9 -4
  33. package/components/identity.tokens/source/encrypt.ts +1 -1
  34. package/components/identity.tokens/source/lib/types.ts +3 -2
  35. package/context.toa.yaml +2 -2
  36. package/documentation/components.md +1 -1
  37. package/features/configuration.feature +207 -0
  38. package/features/identity.federation.feature +15 -7
  39. package/features/introspection.map.feature +16 -0
  40. package/features/steps/Components.ts +29 -0
  41. package/features/steps/Gateway.ts +48 -10
  42. package/package.json +3 -3
  43. package/source/HTTP/formats/yaml.test.ts +6 -0
  44. package/source/HTTP/formats/yaml.ts +21 -1
  45. package/transpiled/HTTP/formats/yaml.js +15 -1
  46. package/transpiled/HTTP/formats/yaml.js.map +1 -1
  47. package/transpiled/tsconfig.tsbuildinfo +1 -1
@@ -1,6 +1,7 @@
1
1
  import { generate } from 'randomstring'
2
2
  import { Computation as Authenticate } from './authenticate'
3
3
  import type { Configuration, Context, DecryptOutput, Identity } from './lib'
4
+ import type { Secret } from '@toa.io/types'
4
5
 
5
6
  let configuration: Configuration
6
7
  let context: Context
@@ -13,8 +14,8 @@ const authority = generate()
13
14
  beforeEach(() => {
14
15
  configuration = {
15
16
  keys: [
16
- { id: 'key0', key: 'sTxL6qVOadKkUJwh3FveU53XgTEo3Sdfg7k2FfiIKfs' },
17
- { id: 'legacy0', key: 'k3.local.m28p8SrbS467t-2IUjQuSOqmjvi24TbXhyjAW_dOrog', format: 'paseto' }
17
+ { id: 'key0', key: secret('sTxL6qVOadKkUJwh3FveU53XgTEo3Sdfg7k2FfiIKfs') },
18
+ { id: 'legacy0', key: secret('k3.local.m28p8SrbS467t-2IUjQuSOqmjvi24TbXhyjAW_dOrog'), format: 'paseto' }
18
19
  ],
19
20
  lifetime: 2592000,
20
21
  refresh: 600,
@@ -68,3 +69,7 @@ it.each([true, false])('should return stale: %s',
68
69
 
69
70
  expect(result).toEqual({ identity, refresh })
70
71
  })
72
+
73
+ function secret (value: string): Secret {
74
+ return { unwrap: () => value }
75
+ }
@@ -3,6 +3,7 @@ import { V3 } from 'paseto'
3
3
  import { Effect as Encrypt } from './encrypt'
4
4
  import { Computation as Decrypt } from './decrypt'
5
5
  import { type Configuration, type Context, type Identity } from './lib'
6
+ import type { Secret } from '@toa.io/types'
6
7
 
7
8
  let configuration: Configuration
8
9
  let context: Context
@@ -15,10 +16,11 @@ const authority = generate()
15
16
  beforeEach(() => {
16
17
  configuration = {
17
18
  keys: [
18
- { id: 'key0', key: 'sTxL6qVOadKkUJwh3FveU53XgTEo3Sdfg7k2FfiIKfs' },
19
- { id: 'key1', key: '5I0iSKw3yfBkQ4AXfA8eR-tWR0Q1dpn4x3bPrPzHkP0' },
20
- { id: 'legacy0', key: 'k3.local.m28p8SrbS467t-2IUjQuSOqmjvi24TbXhyjAW_dOrog', format: 'paseto' },
21
- { id: 'legacy1', key: 'k3.local.-498jfWenrZH-Dqw3-zQJih_hKzDgBgUMfe37OCqSOA', format: 'paseto' }
19
+ { id: 'key0', key: secret('sTxL6qVOadKkUJwh3FveU53XgTEo3Sdfg7k2FfiIKfs') },
20
+ // keys are secrets, and a secret is an object
21
+ { id: 'key1', key: secret('5I0iSKw3yfBkQ4AXfA8eR-tWR0Q1dpn4x3bPrPzHkP0') },
22
+ { id: 'legacy0', key: secret('k3.local.m28p8SrbS467t-2IUjQuSOqmjvi24TbXhyjAW_dOrog'), format: 'paseto' },
23
+ { id: 'legacy1', key: secret('k3.local.-498jfWenrZH-Dqw3-zQJih_hKzDgBgUMfe37OCqSOA'), format: 'paseto' }
22
24
  ],
23
25
  lifetime: 1000,
24
26
  refresh: 500,
@@ -77,7 +79,7 @@ it('should decrypt with key1', async () => {
77
79
  it('should decrypt legacy PASETO and require refresh', async () => {
78
80
  const identity: Identity = { id: generate(), roles: [] }
79
81
 
80
- const token = await V3.encrypt({ iss: authority, identity }, configuration.keys[2].key, {
82
+ const token = await V3.encrypt({ iss: authority, identity }, configuration.keys[2].key.unwrap(), {
81
83
  footer: { kid: 'legacy0' }
82
84
  })
83
85
 
@@ -96,7 +98,7 @@ it('should separate JWE and PASETO keys with the same id by format', async () =>
96
98
 
97
99
  const identity: Identity = { id: generate(), roles: [] }
98
100
 
99
- const token = await V3.encrypt({ iss: authority, identity }, configuration.keys[2].key, {
101
+ const token = await V3.encrypt({ iss: authority, identity }, configuration.keys[2].key.unwrap(), {
100
102
  footer: { kid: 'key0' }
101
103
  })
102
104
 
@@ -125,7 +127,7 @@ it('should reject JWE with an unknown key', async () => {
125
127
  const token = await encrypt.execute({
126
128
  authority,
127
129
  identity: { id: generate(), roles: [] },
128
- key: { id: 'missing', key: configuration.keys[0].key, label: 'missing' }
130
+ key: { id: 'missing', key: configuration.keys[0].key.unwrap(), label: 'missing' }
129
131
  })
130
132
 
131
133
  if (token instanceof Error)
@@ -133,3 +135,7 @@ it('should reject JWE with an unknown key', async () => {
133
135
 
134
136
  await expect(decrypt.execute(token)).resolves.toMatchObject({ code: 'INVALID_KEY' })
135
137
  })
138
+
139
+ function secret (value: string): Secret {
140
+ return { unwrap: () => value }
141
+ }
@@ -27,7 +27,7 @@ export class Computation implements Operation {
27
27
  for (const { id, key, format } of context.configuration.keys) {
28
28
  const branch = format === 'paseto' ? this.legacy : this.keys
29
29
 
30
- branch[id] = { key }
30
+ branch[id] = { key: key.unwrap() }
31
31
  }
32
32
  }
33
33
 
@@ -4,6 +4,7 @@ import { timeout } from '@toa.io/generic'
4
4
  import { Effect as Encrypt } from './encrypt'
5
5
  import { Computation as Decrypt } from './decrypt'
6
6
  import { type Context, type Identity } from './lib'
7
+ import type { Secret } from '@toa.io/types'
7
8
 
8
9
  let encrypt: Encrypt
9
10
  let decrypt: Decrypt
@@ -18,8 +19,8 @@ const authority = generate()
18
19
  beforeEach(() => {
19
20
  context.configuration = {
20
21
  keys: [
21
- { id: 'key0', key: 'sTxL6qVOadKkUJwh3FveU53XgTEo3Sdfg7k2FfiIKfs' },
22
- { id: 'legacy0', key: 'k3.local.m28p8SrbS467t-2IUjQuSOqmjvi24TbXhyjAW_dOrog', format: 'paseto' }
22
+ { id: 'key0', key: secret('sTxL6qVOadKkUJwh3FveU53XgTEo3Sdfg7k2FfiIKfs') },
23
+ { id: 'legacy0', key: secret('k3.local.m28p8SrbS467t-2IUjQuSOqmjvi24TbXhyjAW_dOrog'), format: 'paseto' }
23
24
  ],
24
25
  lifetime: 1,
25
26
  refresh: 2,
@@ -39,12 +40,12 @@ beforeEach(() => {
39
40
  it('should use the first encryption key as active and expose its id as kid', async () => {
40
41
  context.configuration.keys.unshift({
41
42
  id: 'legacy-first',
42
- key: 'k3.local.m28p8SrbS467t-2IUjQuSOqmjvi24TbXhyjAW_dOrog',
43
+ key: secret('k3.local.m28p8SrbS467t-2IUjQuSOqmjvi24TbXhyjAW_dOrog'),
43
44
  format: 'paseto'
44
45
  })
45
46
  context.configuration.keys.push({
46
47
  id: 'key1',
47
- key: '5I0iSKw3yfBkQ4AXfA8eR-tWR0Q1dpn4x3bPrPzHkP0'
48
+ key: secret('5I0iSKw3yfBkQ4AXfA8eR-tWR0Q1dpn4x3bPrPzHkP0')
48
49
  })
49
50
 
50
51
  encrypt.mount(context)
@@ -119,3 +120,7 @@ it('should encrypt without lifetime INSECURE', async () => {
119
120
 
120
121
  expect(decrypted.identity).toMatchObject(identity)
121
122
  })
123
+
124
+ function secret (value: string): Secret {
125
+ return { unwrap: () => value }
126
+ }
@@ -13,7 +13,7 @@ export class Effect implements Operation {
13
13
  if (key === undefined)
14
14
  throw new TypeError('At least one JWE key must be configured')
15
15
 
16
- this.key = key
16
+ this.key = { id: key.id, key: key.key.unwrap() }
17
17
  this.lifetime = context.configuration.lifetime * 1000
18
18
  }
19
19
 
@@ -1,4 +1,4 @@
1
- import type { Call, Maybe, Observation, telemetry } from '@toa.io/types'
1
+ import type { Call, Maybe, Observation, Secret, telemetry } from '@toa.io/types'
2
2
 
3
3
  export interface Context {
4
4
  local: {
@@ -31,7 +31,8 @@ export interface Configuration {
31
31
  }
32
32
  }
33
33
 
34
- export interface ConfiguredKey extends Pick<Key, 'id' | 'key'> {
34
+ export interface ConfiguredKey extends Pick<Key, 'id'> {
35
+ key: Secret
35
36
  format?: 'jwe' | 'paseto'
36
37
  }
37
38
 
package/context.toa.yaml CHANGED
@@ -10,9 +10,9 @@ configuration:
10
10
  identity.tokens:
11
11
  keys:
12
12
  - id: key0
13
- key: sTxL6qVOadKkUJwh3FveU53XgTEo3Sdfg7k2FfiIKfs
13
+ key: $IDENTITY_TOKENS_ENCRYPTION_KEY0
14
14
  - id: legacy0
15
- key: k3.local.pIZT8-9Fa6U_QtfQHOSStfGtmyzPINyKQq2Xk-hd7vA
15
+ key: $IDENTITY_TOKENS_KEY0
16
16
  format: paseto
17
17
 
18
18
  identity.federation:
@@ -23,7 +23,7 @@ and pepper.
23
23
  configuration:
24
24
  identity.basic:
25
25
  rounds: 10 # salt rounds
26
- pepper: '' # hashing pepper
26
+ pepper: $IDENTITY_BASIC_PEPPER # hashing pepper, a secret
27
27
  ```
28
28
 
29
29
  ### Credentials constraints
@@ -0,0 +1,207 @@
1
+ @security
2
+ Feature: Configuration values
3
+
4
+ The values component declares how it is exposed in its own manifest, and that manifest
5
+ is what this mounts. Reading takes `system:configuration:get`, creating takes
6
+ `system:configuration:create`, and the one who creates is recorded as the originator.
7
+
8
+ Background:
9
+ # developer:secret, user:12345
10
+ Given the `identity.basic` database contains:
11
+ | _id | authority | username | password |
12
+ | efe3a65ebbee47ed95a73edd911ea328 | nex | developer | $2b$10$ZRSKkgZoGnrcTNA5w5eCcu3pxDzdTduhteVYXcp56AaNcilNkwJ.O |
13
+ | e8e4f9c2a68d419b861403d71fabc915 | nex | user | $2b$10$Frszmrmsz9iwSXzBbRRMKeDVKsNxozkrLNSsN.SnVC.KPxLtQr/bK |
14
+ And the `identity.roles` database contains:
15
+ | _id | identity | role |
16
+ | 1c0b2a3d4e5f4a6b8c7d9e0f1a2b3c4d | efe3a65ebbee47ed95a73edd911ea328 | system:configuration |
17
+ | 2c0b2a3d4e5f4a6b8c7d9e0f1a2b3c4d | e8e4f9c2a68d419b861403d71fabc915 | system:configuration:get |
18
+ And the `identity.bans` database is empty
19
+ And the `configuration.values` database is empty
20
+ And the configuration values are deployed:
21
+ """yaml
22
+ dummies.dummy:
23
+ epoch: e1
24
+ schema:
25
+ type: object
26
+ properties:
27
+ foo:
28
+ type: string
29
+ bar:
30
+ type: string
31
+ default: world
32
+ defaults:
33
+ foo: deployed
34
+ """
35
+ And the configuration values are running
36
+
37
+ Scenario: Reading the deployed configuration
38
+ When the following request is received:
39
+ """
40
+ GET /configuration/values/dummies.dummy/ HTTP/1.1
41
+ host: nex.toa.io
42
+ authorization: Basic dXNlcjoxMjM0NQ==
43
+ accept: application/yaml
44
+ """
45
+ Then the following reply is sent:
46
+ """
47
+ 200 OK
48
+ content-type: application/yaml
49
+
50
+ configuration:
51
+ foo: deployed
52
+ schema:
53
+ type: object
54
+ properties:
55
+ foo:
56
+ type: string
57
+ bar:
58
+ type: string
59
+ default: world
60
+ epoch: e1
61
+ """
62
+
63
+ Scenario: Listing the configurations
64
+ When the following request is received:
65
+ """
66
+ GET /configuration/values/ HTTP/1.1
67
+ host: nex.toa.io
68
+ authorization: Basic dXNlcjoxMjM0NQ==
69
+ accept: application/yaml
70
+ """
71
+ Then the following reply is sent:
72
+ """
73
+ 200 OK
74
+ content-type: application/yaml
75
+
76
+ - component: dummies.dummy
77
+ epoch: e1
78
+ schema:
79
+ type: object
80
+ properties:
81
+ foo:
82
+ type: string
83
+ bar:
84
+ type: string
85
+ default: world
86
+ configuration:
87
+ foo: deployed
88
+ """
89
+
90
+ Scenario: Listing without the role
91
+ When the following request is received:
92
+ """
93
+ GET /configuration/values/ HTTP/1.1
94
+ host: nex.toa.io
95
+ """
96
+ Then the following reply is sent:
97
+ """
98
+ 401 Unauthorized
99
+ """
100
+
101
+ Scenario: Reading without the role
102
+ When the following request is received:
103
+ """
104
+ GET /configuration/values/dummies.dummy/ HTTP/1.1
105
+ host: nex.toa.io
106
+ """
107
+ Then the following reply is sent:
108
+ """
109
+ 401 Unauthorized
110
+ """
111
+
112
+ Scenario: Reading the configuration of an unknown component
113
+ When the following request is received:
114
+ """
115
+ GET /configuration/values/dummies.unknown/ HTTP/1.1
116
+ host: nex.toa.io
117
+ authorization: Basic ZGV2ZWxvcGVyOnNlY3JldA==
118
+ """
119
+ Then the following reply is sent:
120
+ """
121
+ 404 Not Found
122
+ """
123
+
124
+ Scenario: Creating configuration
125
+ When the following request is received:
126
+ """
127
+ POST /configuration/values/dummies.dummy/ HTTP/1.1
128
+ host: nex.toa.io
129
+ authorization: Basic ZGV2ZWxvcGVyOnNlY3JldA==
130
+ accept: application/yaml
131
+ content-type: application/yaml
132
+
133
+ configuration:
134
+ foo: created
135
+ """
136
+ Then the following reply is sent:
137
+ """
138
+ 201 Created
139
+ content-type: application/yaml
140
+
141
+ epoch: e1
142
+ """
143
+ When the following request is received:
144
+ """
145
+ GET /configuration/values/dummies.dummy/ HTTP/1.1
146
+ host: nex.toa.io
147
+ authorization: Basic dXNlcjoxMjM0NQ==
148
+ accept: application/yaml
149
+ """
150
+ Then the following reply is sent:
151
+ """
152
+ 200 OK
153
+ content-type: application/yaml
154
+
155
+ configuration:
156
+ foo: created
157
+ bar: world
158
+ epoch: e1
159
+ """
160
+
161
+ Scenario: Creating configuration without the role
162
+ When the following request is received:
163
+ """
164
+ POST /configuration/values/dummies.dummy/ HTTP/1.1
165
+ host: nex.toa.io
166
+ authorization: Basic dXNlcjoxMjM0NQ==
167
+ content-type: application/yaml
168
+
169
+ configuration:
170
+ foo: created
171
+ """
172
+ Then the following reply is sent:
173
+ """
174
+ 403 Forbidden
175
+ """
176
+
177
+ Scenario: Creating configuration not fitting the schema
178
+ When the following request is received:
179
+ """
180
+ POST /configuration/values/dummies.dummy/ HTTP/1.1
181
+ host: nex.toa.io
182
+ authorization: Basic ZGV2ZWxvcGVyOnNlY3JldA==
183
+ content-type: application/yaml
184
+
185
+ configuration:
186
+ foo:
187
+ nested: true
188
+ """
189
+ Then the following reply is sent:
190
+ """
191
+ 422 Unprocessable Entity
192
+ """
193
+
194
+ Scenario: Creating configuration for an unknown component
195
+ When the following request is received:
196
+ """
197
+ POST /configuration/values/dummies.unknown/ HTTP/1.1
198
+ host: nex.toa.io
199
+ authorization: Basic ZGV2ZWxvcGVyOnNlY3JldA==
200
+ content-type: application/yaml
201
+
202
+ configuration: {}
203
+ """
204
+ Then the following reply is sent:
205
+ """
206
+ 422 Unprocessable Entity
207
+ """
@@ -335,7 +335,11 @@ Feature: Identity Federation
335
335
  trust:
336
336
  - iss: http://localhost:44444
337
337
  aud: nex
338
- secret: secret
338
+ secret: $IDP_SECRET
339
+ """
340
+ And the configuration secrets:
341
+ """yaml
342
+ IDP_SECRET: secret
339
343
  """
340
344
  And auth code for Alice is issued for https://web.toa.io/callback/
341
345
  When the following request is received:
@@ -362,12 +366,16 @@ Feature: Identity Federation
362
366
  signature:
363
367
  iss: io.toa.nex.id
364
368
  kid: key-id
365
- key: |
366
- -----BEGIN PRIVATE KEY-----
367
- MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg9x9DatwH0GZHSCo9
368
- TMHTVXyeY0YQ8qb73jHV2v4MsyehRANCAAQwaYlna2hSV3G/RIlNLV41lg8Pm4Kf
369
- HfCuKKiw3BIJTnSArAMJLSy1vYwSIMHz720mkmWTrWuQkkjvkDpZxfRv
370
- -----END PRIVATE KEY-----
369
+ key: $IDP_KEY
370
+ """
371
+ And the configuration secrets:
372
+ """yaml
373
+ IDP_KEY: |
374
+ -----BEGIN PRIVATE KEY-----
375
+ MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg9x9DatwH0GZHSCo9
376
+ TMHTVXyeY0YQ8qb73jHV2v4MsyehRANCAAQwaYlna2hSV3G/RIlNLV41lg8Pm4Kf
377
+ HfCuKKiw3BIJTnSArAMJLSy1vYwSIMHz720mkmWTrWuQkkjvkDpZxfRv
378
+ -----END PRIVATE KEY-----
371
379
  """
372
380
  And auth code for Bob is issued for https://web.toa.io/callback/
373
381
  When the following request is received:
@@ -90,3 +90,19 @@ Feature: Reading the introspection map
90
90
  """
91
91
  403 Forbidden
92
92
  """
93
+
94
+ Scenario: The map does not take the application's root directives
95
+ Given the annotation of the introspection map under:
96
+ """yaml
97
+ /:
98
+ auth:anonymous: true
99
+ """
100
+ When the following request is received:
101
+ """
102
+ GET /introspection/nodes/ HTTP/1.1
103
+ host: nex.toa.io
104
+ """
105
+ Then the following reply is sent:
106
+ """
107
+ 401 Unauthorized
108
+ """
@@ -1,4 +1,5 @@
1
1
  import * as assert from 'node:assert'
2
+ import { dirname, join } from 'node:path'
2
3
  import { after, binding, given } from 'cucumber-tsflow'
3
4
  import * as boot from '@toa.io/boot'
4
5
  import { timeout } from '@toa.io/generic'
@@ -9,6 +10,7 @@ import { Workspace } from './Workspace'
9
10
  import { components as map } from './map'
10
11
 
11
12
  const MAP = 'introspection'
13
+ const VALUES = 'configuration'
12
14
 
13
15
  @binding([Workspace, Gateway])
14
16
  export class Components {
@@ -44,6 +46,25 @@ export class Components {
44
46
  await timeout(50) // discovery
45
47
  }
46
48
 
49
+ /** The values component, as the configuration extension ships it; the service hosts it alone. */
50
+ @given('the configuration values are running')
51
+ public async runValues (): Promise<void> {
52
+ assert.ok(!(VALUES in this.compositions), `Composition '${VALUES}' is already running`)
53
+
54
+ await this.gateway.start()
55
+
56
+ this.compositions[VALUES] = await boot.composition([values()])
57
+
58
+ await this.compositions[VALUES].connect()
59
+ await timeout(50) // discovery
60
+ }
61
+
62
+ /** What the deployment would tell the values service, as the scenario needs it. */
63
+ @given('the configuration values are deployed:')
64
+ public deployValues (yaml: string): void {
65
+ process.env.TOA_CONFIGURATION_VALUES = JSON.stringify(parse(yaml))
66
+ }
67
+
47
68
  @given('the `{word}` is stopped')
48
69
  public async stop (name: string): Promise<void> {
49
70
  assert.ok(name in this.compositions, `Composition '${name}' is not running`)
@@ -57,6 +78,8 @@ export class Components {
57
78
  const promises = Object.values(this.compositions).map((composition) => composition.disconnect())
58
79
 
59
80
  await Promise.all(promises)
81
+
82
+ delete process.env.TOA_CONFIGURATION_VALUES
60
83
  }
61
84
 
62
85
  private async runComponent (name: string, manifest?: object): Promise<void> {
@@ -75,3 +98,9 @@ export class Components {
75
98
  await timeout(50) // discovery
76
99
  }
77
100
  }
101
+
102
+ function values (): string {
103
+ const root = dirname(require.resolve('@toa.io/extensions.configuration/package.json'))
104
+
105
+ return join(root, 'components', 'configuration.values')
106
+ }
@@ -14,6 +14,7 @@ let instance: Connector | null = null
14
14
  @binding()
15
15
  export class Gateway {
16
16
  private default: boolean = true
17
+ private written: string[] = []
17
18
 
18
19
  @given('the annotation:')
19
20
  public async annotate (yaml: string): Promise<void> {
@@ -47,7 +48,15 @@ export class Gateway {
47
48
  */
48
49
  @given('the annotation of the introspection map')
49
50
  public async annotateMap (): Promise<void> {
50
- const tree: syntax.Node = { routes: [], methods: [], directives: [] }
51
+ await this.annotateMapUnder()
52
+ }
53
+
54
+ /** The same routes under a root of the scenario's own: what an application declares around the map. */
55
+ @given('the annotation of the introspection map under:')
56
+ public async annotateMapUnder (yaml?: string): Promise<void> {
57
+ const tree: syntax.Node = yaml === undefined
58
+ ? { routes: [], methods: [], directives: [] }
59
+ : syntax.parse((parse(yaml) as { '/': object })['/'], shortcuts)
51
60
 
52
61
  for (const manifest of await manifests()) {
53
62
  const node = manifest.extensions?.[EXPOSITION] as syntax.Node | undefined
@@ -81,6 +90,21 @@ export class Gateway {
81
90
  this.default = false
82
91
  }
83
92
 
93
+ /** The secrets a scenario's configuration refers to. */
94
+ @given('the configuration secrets:')
95
+ public async secrets (yaml: string): Promise<void> {
96
+ const secrets = parse(yaml) as Record<string, string>
97
+
98
+ for (const [name, value] of Object.entries(secrets)) {
99
+ this.written.push(SECRET + name)
100
+ process.env[SECRET + name] = value
101
+ }
102
+
103
+ await Gateway.stop()
104
+
105
+ this.default = false
106
+ }
107
+
84
108
  @given('the branch TTL is {float} second(s)')
85
109
  public async setBranchTTL (seconds: number): Promise<void> {
86
110
  process.env.__TESTING_EXPOSITION_BRANCH_TTL = String(seconds * 1000)
@@ -116,6 +140,11 @@ export class Gateway {
116
140
  public async cleanup (): Promise<void> {
117
141
  delete process.env.__TESTING_EXPOSITION_BRANCH_TTL
118
142
 
143
+ for (const key of this.written)
144
+ delete process.env[key]
145
+
146
+ this.written = []
147
+
119
148
  if (this.default)
120
149
  return
121
150
 
@@ -138,10 +167,14 @@ export class Gateway {
138
167
 
139
168
  process.env[key] ??= JSON.stringify(configuration)
140
169
  }
170
+
171
+ for (const [name, value] of Object.entries(DEFAULT_SECRETS))
172
+ process.env[SECRET + name] ??= value
141
173
  }
142
174
  }
143
175
 
144
176
  const EXPOSITION = '@toa.io/extensions.exposition'
177
+ const SECRET = 'TOA_CONFIGURATION__'
145
178
 
146
179
  const DEFAULT_TREE = JSON.stringify({
147
180
  routes: [],
@@ -161,18 +194,23 @@ const DEFAULT_PROPERTIES: Partial<http.Options> = {
161
194
  }
162
195
  }
163
196
 
197
+ // the identity components boot inside the gateway, and without a variable each would wait
198
+ // for the values service, which these features do not run
164
199
  const DEFAULT_CONFIGURATION: Record<string, object> = {
200
+ 'identity.basic': {},
201
+ 'identity.federation': {},
202
+ 'identity.otp': {},
203
+ 'identity.passkeys': {},
165
204
  'identity.tokens': {
166
205
  keys: [
167
- {
168
- id: 'key0',
169
- key: 'sTxL6qVOadKkUJwh3FveU53XgTEo3Sdfg7k2FfiIKfs'
170
- },
171
- {
172
- id: 'legacy0',
173
- key: 'k3.local.pIZT8-9Fa6U_QtfQHOSStfGtmyzPINyKQq2Xk-hd7vA',
174
- format: 'paseto'
175
- }
206
+ { id: 'key0', key: '$IDENTITY_TOKENS_KEY0' },
207
+ { id: 'legacy0', key: '$IDENTITY_TOKENS_LEGACY0', format: 'paseto' }
176
208
  ]
177
209
  }
178
210
  }
211
+
212
+ // a secret is given as a reference, and comes to the component as an object
213
+ const DEFAULT_SECRETS: Record<string, string> = {
214
+ IDENTITY_TOKENS_KEY0: 'sTxL6qVOadKkUJwh3FveU53XgTEo3Sdfg7k2FfiIKfs',
215
+ IDENTITY_TOKENS_LEGACY0: 'k3.local.pIZT8-9Fa6U_QtfQHOSStfGtmyzPINyKQq2Xk-hd7vA'
216
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toa.io/extensions.exposition",
3
- "version": "1.0.0-alpha.273",
3
+ "version": "1.0.0-alpha.274",
4
4
  "description": "Toa Exposition",
5
5
  "author": "temich <tema.gurtovoy@gmail.com>",
6
6
  "homepage": "https://github.com/toa-io/toa#readme",
@@ -20,7 +20,7 @@
20
20
  "@simplewebauthn/server": "13.3.3",
21
21
  "@toa.io/core": "1.0.0-alpha.273",
22
22
  "@toa.io/generic": "1.0.0-alpha.273",
23
- "@toa.io/schemas": "1.0.0-alpha.273",
23
+ "@toa.io/schemas": "1.0.0-alpha.274",
24
24
  "bcryptjs": "3.0.3",
25
25
  "content-type": "2.1.0",
26
26
  "jose": "6.2.10",
@@ -61,5 +61,5 @@
61
61
  },
62
62
  "testEnvironment": "node"
63
63
  },
64
- "gitHead": "ad3284850ece95ffd5325fd0995707fc144c9c3d"
64
+ "gitHead": "7df32eb2872155c45681c299036b1efbeefd6518"
65
65
  }
@@ -9,3 +9,9 @@ it('encodes error values as their public properties', () => {
9
9
  expect(encode(value).toString())
10
10
  .toBe('code: INVALID_USERNAME\nmessage: Username is not meeting the requirements\n')
11
11
  })
12
+
13
+ it('writes a value the way its toJSON says', () => {
14
+ const secret = { unwrap: () => 's3cret', toJSON: () => '<REDACTED>' }
15
+
16
+ expect(encode({ a: 1, b: secret }).toString()).toBe('a: 1\nb: <REDACTED>\n')
17
+ })
@@ -10,8 +10,28 @@ export function decode (buffer: Buffer, charset = 'utf-8'): any {
10
10
  }
11
11
 
12
12
  export function encode (value: any): Buffer {
13
- const serializable = value instanceof Error ? Object.assign({}, value) : value
13
+ const serializable = value instanceof Error ? Object.assign({}, value) : represent(value)
14
14
  const text = yaml.dump(serializable, { lineWidth: -1, noRefs: true })
15
15
 
16
16
  return Buffer.from(text)
17
17
  }
18
+
19
+ /** What says how it is to be written — a redacted secret — is written that way, as in JSON. */
20
+ function represent (value: unknown): unknown {
21
+ if (typeof value !== 'object' || value === null || value instanceof Date || Buffer.isBuffer(value))
22
+ return value
23
+
24
+ if ('toJSON' in value && typeof value.toJSON === 'function')
25
+ return value.toJSON()
26
+
27
+ if (Array.isArray(value))
28
+ return value.map(represent)
29
+
30
+ const object = value as Record<string, unknown>
31
+ const represented: Record<string, unknown> = {}
32
+
33
+ for (const key of Object.keys(object))
34
+ represented[key] = represent(object[key])
35
+
36
+ return represented
37
+ }