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

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 (90) hide show
  1. package/CHANGELOG.md +209 -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/documentation/protocol.md +24 -0
  38. package/features/configuration.feature +207 -0
  39. package/features/identity.federation.feature +15 -7
  40. package/features/introspection.map.feature +16 -0
  41. package/features/octets.cloudinary.feature +8 -2
  42. package/features/octets.download.feature +3 -1
  43. package/features/probes.feature +8 -4
  44. package/features/steps/Common.ts +24 -1
  45. package/features/steps/Components.ts +29 -0
  46. package/features/steps/Gateway.ts +57 -11
  47. package/features/steps/Parameters.ts +4 -0
  48. package/features/steps/Probe.ts +34 -0
  49. package/package.json +4 -3
  50. package/readme.md +7 -13
  51. package/schemas/annotation.cos.yaml +11 -0
  52. package/source/Annotation.ts +11 -0
  53. package/source/HTTP/Context.ts +1 -6
  54. package/source/HTTP/Probe.ts +112 -0
  55. package/source/HTTP/Server.ts +123 -74
  56. package/source/HTTP/Timing.ts +1 -1
  57. package/source/HTTP/formats/yaml.test.ts +6 -0
  58. package/source/HTTP/formats/yaml.ts +21 -1
  59. package/source/HTTP/index.ts +2 -0
  60. package/source/HTTP/messages.test.ts +2 -5
  61. package/source/HTTP/messages.ts +38 -42
  62. package/source/HTTP/types.ts +39 -0
  63. package/source/deployment.ts +12 -4
  64. package/source/directives/octets/Get.ts +3 -3
  65. package/transpiled/Annotation.d.ts +10 -0
  66. package/transpiled/HTTP/Context.d.ts +1 -6
  67. package/transpiled/HTTP/Context.js.map +1 -1
  68. package/transpiled/HTTP/Probe.d.ts +36 -0
  69. package/transpiled/HTTP/Probe.js +117 -0
  70. package/transpiled/HTTP/Probe.js.map +1 -0
  71. package/transpiled/HTTP/Server.d.ts +11 -7
  72. package/transpiled/HTTP/Server.js +92 -58
  73. package/transpiled/HTTP/Server.js.map +1 -1
  74. package/transpiled/HTTP/Timing.d.ts +1 -2
  75. package/transpiled/HTTP/formats/yaml.js +15 -1
  76. package/transpiled/HTTP/formats/yaml.js.map +1 -1
  77. package/transpiled/HTTP/index.d.ts +2 -0
  78. package/transpiled/HTTP/index.js +2 -0
  79. package/transpiled/HTTP/index.js.map +1 -1
  80. package/transpiled/HTTP/messages.d.ts +8 -3
  81. package/transpiled/HTTP/messages.js +31 -37
  82. package/transpiled/HTTP/messages.js.map +1 -1
  83. package/transpiled/HTTP/types.d.ts +40 -0
  84. package/transpiled/HTTP/types.js +3 -0
  85. package/transpiled/HTTP/types.js.map +1 -0
  86. package/transpiled/deployment.js +8 -2
  87. package/transpiled/deployment.js.map +1 -1
  88. package/transpiled/directives/octets/Get.js +3 -3
  89. package/transpiled/directives/octets/Get.js.map +1 -1
  90. package/transpiled/tsconfig.tsbuildinfo +1 -1
@@ -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
@@ -74,6 +83,9 @@ export class Gateway {
74
83
  const patch = parse(yaml) as object
75
84
  const configuration = Object.assign({}, def, patch)
76
85
 
86
+ // scenario-scoped, as the secrets it may refer to are: a configuration left behind
87
+ // outlives the secrets it points at, and the next scenario cannot resolve them
88
+ this.written.push(key)
77
89
  process.env[key] = JSON.stringify(configuration)
78
90
 
79
91
  await Gateway.stop()
@@ -81,6 +93,21 @@ export class Gateway {
81
93
  this.default = false
82
94
  }
83
95
 
96
+ /** The secrets a scenario's configuration refers to. */
97
+ @given('the configuration secrets:')
98
+ public async secrets (yaml: string): Promise<void> {
99
+ const secrets = parse(yaml) as Record<string, string>
100
+
101
+ for (const [name, value] of Object.entries(secrets)) {
102
+ this.written.push(SECRET + name)
103
+ process.env[SECRET + name] = value
104
+ }
105
+
106
+ await Gateway.stop()
107
+
108
+ this.default = false
109
+ }
110
+
84
111
  @given('the branch TTL is {float} second(s)')
85
112
  public async setBranchTTL (seconds: number): Promise<void> {
86
113
  process.env.__TESTING_EXPOSITION_BRANCH_TTL = String(seconds * 1000)
@@ -116,6 +143,11 @@ export class Gateway {
116
143
  public async cleanup (): Promise<void> {
117
144
  delete process.env.__TESTING_EXPOSITION_BRANCH_TTL
118
145
 
146
+ for (const key of this.written)
147
+ delete process.env[key]
148
+
149
+ this.written = []
150
+
119
151
  if (this.default)
120
152
  return
121
153
 
@@ -138,10 +170,14 @@ export class Gateway {
138
170
 
139
171
  process.env[key] ??= JSON.stringify(configuration)
140
172
  }
173
+
174
+ for (const [name, value] of Object.entries(DEFAULT_SECRETS))
175
+ process.env[SECRET + name] ??= value
141
176
  }
142
177
  }
143
178
 
144
179
  const EXPOSITION = '@toa.io/extensions.exposition'
180
+ const SECRET = 'TOA_CONFIGURATION__'
145
181
 
146
182
  const DEFAULT_TREE = JSON.stringify({
147
183
  routes: [],
@@ -158,21 +194,31 @@ const DEFAULT_TREE = JSON.stringify({
158
194
  const DEFAULT_PROPERTIES: Partial<http.Options> = {
159
195
  authorities: {
160
196
  nex: 'nex.toa.io'
161
- }
197
+ },
198
+ // `npm run features:h2c` runs the whole suite over cleartext HTTP/2
199
+ protocol: process.env.TOA_EXPOSITION_PROTOCOL === 'h2c' ? 'h2c' : 'h1'
162
200
  }
163
201
 
202
+ // the identity components boot inside the gateway, and without a variable each would wait
203
+ // for the values service, which these features do not run
164
204
  const DEFAULT_CONFIGURATION: Record<string, object> = {
205
+ // a component declaring configuration waits for `configuration.values`, which these
206
+ // features do not run; the variable is the local override that stands in for it
207
+ 'realtime.streams': {},
208
+ 'identity.basic': {},
209
+ 'identity.federation': {},
210
+ 'identity.otp': {},
211
+ 'identity.passkeys': {},
165
212
  'identity.tokens': {
166
213
  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
- }
214
+ { id: 'key0', key: '$IDENTITY_TOKENS_KEY0' },
215
+ { id: 'legacy0', key: '$IDENTITY_TOKENS_LEGACY0', format: 'paseto' }
176
216
  ]
177
217
  }
178
218
  }
219
+
220
+ // a secret is given as a reference, and comes to the component as an object
221
+ const DEFAULT_SECRETS: Record<string, string> = {
222
+ IDENTITY_TOKENS_KEY0: 'sTxL6qVOadKkUJwh3FveU53XgTEo3Sdfg7k2FfiIKfs',
223
+ IDENTITY_TOKENS_LEGACY0: 'k3.local.pIZT8-9Fa6U_QtfQHOSStfGtmyzPINyKQq2Xk-hd7vA'
224
+ }
@@ -16,6 +16,10 @@ setDefaultTimeout(60 * 1000)
16
16
 
17
17
  process.env.TOA_DEV = '1'
18
18
 
19
+ // the gateway answers for itself, as it does in a deployment: telemetry's probe tracks the
20
+ // nested composition, which connects before route discovery settles
21
+ process.env.TOA_TELEMETRY_READY ??= JSON.stringify(false)
22
+
19
23
  // export traces to the local Tempo (`docker compose up tempo grafana`),
20
24
  // unavailability of the endpoint is harmless
21
25
  process.env.TOA_TELEMETRY_TRACES ??= JSON.stringify({
@@ -0,0 +1,34 @@
1
+ import assert from 'node:assert'
2
+ import { Agent, request } from 'undici'
3
+ import { after, binding, then, when } from 'cucumber-tsflow'
4
+ import { PATH, PROBE } from '../../source/HTTP'
5
+
6
+ @binding()
7
+ export class Probe {
8
+ private readonly agent = new Agent()
9
+ private status: number | null = null
10
+ private headers: Record<string, string> = {}
11
+
12
+ @when('the ready probe is requested')
13
+ public async request (): Promise<void> {
14
+ const response = await request(`http://127.0.0.1:${PROBE}${PATH}`, { dispatcher: this.agent })
15
+
16
+ this.status = response.statusCode
17
+ this.headers = response.headers as Record<string, string>
18
+
19
+ await response.body.dump()
20
+ }
21
+
22
+ @then('the ready probe answers {int}')
23
+ public answers (status: number): void {
24
+ assert.equal(this.status, status)
25
+
26
+ if (status === 200)
27
+ assert.equal(this.headers['cache-control'], 'no-store')
28
+ }
29
+
30
+ @after()
31
+ public async close (): Promise<void> {
32
+ await this.agent.close()
33
+ }
34
+ }
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.276",
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",
@@ -46,6 +46,7 @@
46
46
  "transpile:passkeys": "tsc -p ./components/identity.passkeys",
47
47
  "transpile:otp": "tsc -p ./components/identity.otp",
48
48
  "features": "cucumber-js",
49
+ "features:h2c": "TOA_EXPOSITION_PROTOCOL=h2c TOA_AGENT_PROTOCOL=h2c cucumber-js",
49
50
  "features:security": "cucumber-js --tags @security",
50
51
  "features:octets": "cucumber-js features/octets.*"
51
52
  },
@@ -61,5 +62,5 @@
61
62
  },
62
63
  "testEnvironment": "node"
63
64
  },
64
- "gitHead": "ad3284850ece95ffd5325fd0995707fc144c9c3d"
65
+ "gitHead": "6f81af829c7bf8a2db5a409f3d60ea40d15583ef"
65
66
  }
package/readme.md CHANGED
@@ -188,21 +188,15 @@ exposition:
188
188
 
189
189
  ## Ready probe
190
190
 
191
- The gateway serves `GET /.ready` on port `8000` for Kubernetes startup/readiness probes.
191
+ The gateway serves `GET /.ready` on port `8004`, not on the port it serves traffic on, and
192
+ telemetry's probe is disabled in its process.
192
193
 
193
- It returns `200` only after:
194
+ It answers `200` only after the in-process identity composition has connected, initial route
195
+ discovery has settled, and the HTTP server is listening. Before that it answers `503` with a
196
+ `retry-after`. When ready, the process also sends `process.send('ready')` for PM2 `wait_ready`.
194
197
 
195
- 1. The in-process identity composition has connected
196
- 2. Initial route discovery has settled
197
- 3. The HTTP server is listening
198
-
199
- Before listen, probes get connection refused; after listen but before ready, `503`.
200
- When ready, the process also sends `process.send('ready')` (for PM2 `wait_ready`).
201
-
202
- The nested identity composition shares this process. Deployment disables telemetry's port-`8001`
203
- probe (`TOA_TELEMETRY_READY=false`) so only the gateway `/.ready` is used.
204
-
205
- See also [telemetry ready probe](../telemetry/readme.md#ready-probe).
198
+ See also [telemetry ready probe](../telemetry/readme.md#ready-probe) and
199
+ [reserved ports](../../documentation/ports.md).
206
200
 
207
201
  ## See Also
208
202
 
@@ -24,8 +24,19 @@ properties:
24
24
  patternProperties:
25
25
  ^.+$:
26
26
  type: string
27
+ service:
28
+ type: object
29
+ properties:
30
+ annotations:
31
+ type: object
32
+ patternProperties:
33
+ ^.+$:
34
+ type: string
27
35
  debug:
28
36
  type: boolean
37
+ protocol:
38
+ type: string
39
+ enum: [h1, h2c]
29
40
  /: {}
30
41
  authorities:
31
42
  type: object
@@ -1,10 +1,21 @@
1
1
  import type { Resources } from '@toa.io/operations'
2
+ import type { Protocol } from './HTTP'
2
3
 
3
4
  export interface Annotation {
4
5
  authorities: Record<string, string>
6
+
7
+ /**
8
+ * `h2c` requires an ingress controller that proxies cleartext HTTP/2 upstream.
9
+ * See `documentation/protocol.md`.
10
+ */
11
+ protocol?: Protocol
5
12
  class?: string
6
13
  resources?: Resources
7
14
  annotations?: Record<string, string>
15
+
16
+ /** The Service, as opposed to the Ingress that `annotations` above describes. */
17
+ service?: { annotations?: Record<string, string> }
18
+
8
19
  debug?: boolean
9
20
  '/'?: object // parsed and validated by RTD.syntax.parse
10
21
  }
@@ -6,7 +6,7 @@ import { Timing } from './Timing'
6
6
  import { type Format, formats, types } from './formats'
7
7
  import { read } from './messages'
8
8
  import type { OutgoingMessage } from './messages'
9
- import type * as http from 'node:http'
9
+ import type { IncomingMessage } from './types'
10
10
 
11
11
  export class Context {
12
12
  public readonly id: string
@@ -87,11 +87,6 @@ export class Context {
87
87
  }
88
88
  }
89
89
 
90
- export interface IncomingMessage extends http.IncomingMessage {
91
- url: string
92
- method: string
93
- }
94
-
95
90
  interface Pipelines {
96
91
  body: Array<(input: unknown) => unknown>
97
92
  response: Array<(output: OutgoingMessage) => void | Promise<void>>
@@ -0,0 +1,112 @@
1
+ import * as http from 'node:http'
2
+ import { console } from 'openspan'
3
+
4
+ /**
5
+ * The readiness probe, on a port of its own.
6
+ *
7
+ * It cannot share the port the gateway serves traffic on: kubelet speaks HTTP/1.1 only, and a
8
+ * cleartext HTTP/2 server answers nothing else on its port — there is no ALPN to negotiate with.
9
+ *
10
+ * It is the gateway's own readiness, not the process's. The identity composition nested in this
11
+ * process connects before route discovery has settled, so a probe tied to that composition would
12
+ * report ready while the gateway still had no routes and no open port.
13
+ */
14
+ export class Probe {
15
+ private readonly server = http.createServer()
16
+ private readonly port: number
17
+ private readonly path: string
18
+
19
+ private ready = false
20
+ private startedAt = 0
21
+ private listening = false
22
+ private skipped = false
23
+
24
+ public constructor (port: number, path: string = PATH) {
25
+ this.port = port
26
+ this.path = path
27
+
28
+ this.server.on('request', (request, response) => this.listener(request, response))
29
+ }
30
+
31
+ public async listen (): Promise<void> {
32
+ this.startedAt = Date.now()
33
+
34
+ try {
35
+ await new Promise<void>((resolve, reject) => {
36
+ this.server.once('error', reject)
37
+ this.server.listen(this.port, () => {
38
+ this.server.removeListener('error', reject)
39
+ resolve()
40
+ })
41
+ })
42
+ } catch (error: any) {
43
+ // processes sharing a host share the port; in a pod nothing else claims it
44
+ if (error?.code === 'EADDRINUSE') {
45
+ this.skipped = true
46
+
47
+ console.warn('Ready probe port already in use, skipping', { port: this.port })
48
+
49
+ return
50
+ }
51
+
52
+ throw error
53
+ }
54
+
55
+ this.listening = true
56
+
57
+ // a probe answers while the process runs; it must never be the reason it keeps running
58
+ this.server.unref()
59
+ }
60
+
61
+ /** The gateway is listening and has routes: answer `200`. */
62
+ public complete (): void {
63
+ this.ready = true
64
+
65
+ // the IPC signal is not tied to the probe: a process that gave up the shared port is
66
+ // still ready, and pm2 `wait_ready` would otherwise block until `listen_timeout`
67
+ process.send?.('ready')
68
+ }
69
+
70
+ public async close (): Promise<void> {
71
+ this.ready = false
72
+
73
+ if (!this.listening || this.skipped)
74
+ return
75
+
76
+ this.listening = false
77
+
78
+ // keep-alive connections would otherwise hold the server handle, delaying the exit
79
+ this.server.closeAllConnections()
80
+
81
+ await new Promise<void>((resolve) => this.server.close(() => resolve()))
82
+ }
83
+
84
+ private listener (request: http.IncomingMessage, response: http.ServerResponse): void {
85
+ if (request.url !== this.path) {
86
+ response.writeHead(404).end()
87
+
88
+ return
89
+ }
90
+
91
+ if (this.ready)
92
+ response.writeHead(200, { 'cache-control': 'no-store' }).end()
93
+ else {
94
+ const remaining = Math.ceil((Date.now() - this.startedAt) / 1000).toString()
95
+
96
+ response.writeHead(503, { 'retry-after': remaining }).end()
97
+ }
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Reserved for this probe. `8001` is the Telemetry readiness probe's, and `toa export` refuses a
103
+ * port claimed twice — `toa mono` and a local run put every service in one process.
104
+ */
105
+ export const PROBE = 8004
106
+ export const PATH = '/.ready'
107
+
108
+ /**
109
+ * The initial delay of the readiness probe. The server does not sleep for it: whoever
110
+ * probes is the one that waits, and doing it here as well only delayed the process twice.
111
+ */
112
+ export const DELAY = 3 // seconds