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

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 (151) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/components/{exposition.stash → exposition.atom}/manifest.toa.yaml +4 -4
  3. package/components/{exposition.stash → exposition.atom}/operations/meter.js +1 -1
  4. package/components/exposition.octets/manifest.toa.yaml +28 -10
  5. package/components/exposition.octets/operations/put.js +20 -5
  6. package/components/identity.bans/manifest.toa.yaml +26 -7
  7. package/components/identity.bans/operations/tsconfig.tsbuildinfo +1 -1
  8. package/components/identity.basic/manifest.toa.yaml +106 -30
  9. package/components/identity.basic/operations/authenticate.js +6 -3
  10. package/components/identity.basic/operations/authenticate.js.map +1 -1
  11. package/components/identity.basic/operations/incept.js +3 -2
  12. package/components/identity.basic/operations/incept.js.map +1 -1
  13. package/components/identity.basic/operations/transit.js +16 -5
  14. package/components/identity.basic/operations/transit.js.map +1 -1
  15. package/components/identity.basic/operations/tsconfig.tsbuildinfo +1 -1
  16. package/components/identity.basic/source/authenticate.ts +7 -3
  17. package/components/identity.basic/source/incept.ts +3 -2
  18. package/components/identity.basic/source/transit.ts +19 -5
  19. package/components/identity.credentials/manifest.toa.yaml +34 -13
  20. package/components/identity.credentials/operations/tsconfig.tsbuildinfo +1 -1
  21. package/components/identity.federation/manifest.toa.yaml +120 -28
  22. package/components/identity.federation/operations/authenticate.js +3 -2
  23. package/components/identity.federation/operations/authenticate.js.map +1 -1
  24. package/components/identity.federation/operations/create.js +4 -2
  25. package/components/identity.federation/operations/create.js.map +1 -1
  26. package/components/identity.federation/operations/lib/errors.d.ts +70 -11
  27. package/components/identity.federation/operations/lib/errors.js +40 -11
  28. package/components/identity.federation/operations/lib/errors.js.map +1 -1
  29. package/components/identity.federation/operations/tsconfig.tsbuildinfo +1 -1
  30. package/components/identity.federation/source/authenticate.ts +3 -2
  31. package/components/identity.federation/source/create.ts +4 -2
  32. package/components/identity.federation/source/lib/errors.ts +49 -12
  33. package/components/identity.keys/manifest.toa.yaml +14 -6
  34. package/components/identity.keys/operations/tsconfig.tsbuildinfo +1 -1
  35. package/components/identity.otp/manifest.toa.yaml +44 -15
  36. package/components/identity.otp/operations/authenticate.js +9 -4
  37. package/components/identity.otp/operations/authenticate.js.map +1 -1
  38. package/components/identity.otp/operations/tsconfig.tsbuildinfo +1 -1
  39. package/components/identity.otp/source/authenticate.ts +11 -4
  40. package/components/identity.passkeys/manifest.toa.yaml +38 -8
  41. package/components/identity.passkeys/operations/authenticate.js +3 -2
  42. package/components/identity.passkeys/operations/authenticate.js.map +1 -1
  43. package/components/identity.passkeys/operations/create.js +6 -3
  44. package/components/identity.passkeys/operations/create.js.map +1 -1
  45. package/components/identity.passkeys/operations/tsconfig.tsbuildinfo +1 -1
  46. package/components/identity.passkeys/operations/use.js +6 -3
  47. package/components/identity.passkeys/operations/use.js.map +1 -1
  48. package/components/identity.passkeys/source/authenticate.ts +3 -2
  49. package/components/identity.passkeys/source/create.ts +7 -3
  50. package/components/identity.passkeys/source/use.ts +7 -3
  51. package/components/identity.roles/manifest.toa.yaml +41 -10
  52. package/components/identity.roles/operations/grant.js +3 -2
  53. package/components/identity.roles/operations/grant.js.map +1 -1
  54. package/components/identity.roles/operations/tsconfig.tsbuildinfo +1 -1
  55. package/components/identity.roles/source/grant.ts +3 -2
  56. package/components/identity.tokens/manifest.toa.yaml +99 -39
  57. package/components/identity.tokens/operations/authenticate.js +6 -3
  58. package/components/identity.tokens/operations/authenticate.js.map +1 -1
  59. package/components/identity.tokens/operations/decrypt.js +9 -4
  60. package/components/identity.tokens/operations/decrypt.js.map +1 -1
  61. package/components/identity.tokens/operations/encrypt.js +3 -2
  62. package/components/identity.tokens/operations/encrypt.js.map +1 -1
  63. package/components/identity.tokens/operations/tsconfig.tsbuildinfo +1 -1
  64. package/components/identity.tokens/source/authenticate.ts +7 -3
  65. package/components/identity.tokens/source/decrypt.ts +11 -4
  66. package/components/identity.tokens/source/encrypt.ts +3 -2
  67. package/documentation/identity.md +4 -1
  68. package/documentation/notes/throttling.md +1 -1
  69. package/features/cors.feature +1 -1
  70. package/features/dev.feature +1 -1
  71. package/features/identity.federation.feature +3 -2
  72. package/features/{identtiy.tokens.custom.feature → identity.tokens.custom.feature} +39 -0
  73. package/features/introspection.feature +27 -34
  74. package/features/map.feature +1 -1
  75. package/features/octets.download.feature +1 -1
  76. package/features/octets.meta.feature +1 -1
  77. package/features/passkeys.feature +8 -8
  78. package/features/query.feature +27 -0
  79. package/features/require.feature +2 -2
  80. package/features/response.feature +3 -3
  81. package/features/steps/Components.ts +12 -4
  82. package/features/steps/Database.ts +9 -0
  83. package/features/steps/Gateway.ts +13 -13
  84. package/features/steps/HTTP.ts +3 -5
  85. package/features/steps/Parameters.ts +2 -3
  86. package/features/steps/Realtime.ts +4 -4
  87. package/features/steps/Workspace.ts +8 -3
  88. package/features/steps/components/audit/manifest.toa.yaml +12 -5
  89. package/features/steps/components/echo/manifest.toa.yaml +33 -8
  90. package/features/steps/components/notify/manifest.toa.yaml +12 -5
  91. package/features/steps/components/octets.tester/manifest.toa.yaml +43 -14
  92. package/features/steps/components/orders/manifest.toa.yaml +22 -5
  93. package/features/steps/components/pots/manifest.toa.yaml +71 -17
  94. package/features/steps/components/pricing/manifest.toa.yaml +8 -2
  95. package/features/steps/components/sequences/manifest.toa.yaml +3 -1
  96. package/features/steps/components/users/manifest.toa.yaml +18 -3
  97. package/features/steps/components/users.properties/manifest.toa.yaml +12 -2
  98. package/package.json +6 -7
  99. package/schemas/annotation.cos.yaml +37 -17
  100. package/schemas/directive.cos.yaml +7 -3
  101. package/schemas/io/throttle.cos.yaml +47 -8
  102. package/schemas/method.cos.yaml +21 -9
  103. package/schemas/node.cos.yaml +22 -7
  104. package/schemas/octets/delete.cos.yaml +15 -2
  105. package/schemas/octets/get.cos.yaml +5 -2
  106. package/schemas/octets/put.cos.yaml +6 -3
  107. package/schemas/octets/workflow.cos.yaml +0 -1
  108. package/schemas/query.cos.yaml +31 -14
  109. package/schemas/querystring.cos.yaml +19 -7
  110. package/schemas/range.cos.yaml +10 -2
  111. package/schemas/route.cos.yaml +6 -2
  112. package/source/Branch.test.ts +37 -0
  113. package/source/Branch.ts +37 -0
  114. package/source/Factory.ts +5 -5
  115. package/source/Gateway.ts +29 -14
  116. package/source/HTTP/formats/yaml.test.ts +4 -2
  117. package/source/RTD/Tree.ts +4 -1
  118. package/source/Tenant.ts +15 -4
  119. package/source/deployment.ts +3 -4
  120. package/source/directives/io/IO.ts +2 -2
  121. package/source/directives/io/lib/throttle/Sync.ts +4 -4
  122. package/source/directives/map/Headers.ts +8 -1
  123. package/source/directives/octets/Put.ts +6 -3
  124. package/source/root.ts +2 -2
  125. package/transpiled/Branch.d.ts +23 -0
  126. package/transpiled/Branch.js +16 -0
  127. package/transpiled/Branch.js.map +1 -1
  128. package/transpiled/Factory.js +4 -4
  129. package/transpiled/Factory.js.map +1 -1
  130. package/transpiled/Gateway.d.ts +7 -0
  131. package/transpiled/Gateway.js +26 -8
  132. package/transpiled/Gateway.js.map +1 -1
  133. package/transpiled/RTD/Tree.js +4 -1
  134. package/transpiled/RTD/Tree.js.map +1 -1
  135. package/transpiled/Tenant.d.ts +8 -2
  136. package/transpiled/Tenant.js +12 -2
  137. package/transpiled/Tenant.js.map +1 -1
  138. package/transpiled/deployment.js +3 -4
  139. package/transpiled/deployment.js.map +1 -1
  140. package/transpiled/directives/io/IO.d.ts +1 -1
  141. package/transpiled/directives/io/IO.js +2 -2
  142. package/transpiled/directives/io/IO.js.map +1 -1
  143. package/transpiled/directives/io/lib/throttle/Sync.d.ts +2 -2
  144. package/transpiled/directives/io/lib/throttle/Sync.js +4 -4
  145. package/transpiled/directives/io/lib/throttle/Sync.js.map +1 -1
  146. package/transpiled/directives/map/Headers.js +7 -1
  147. package/transpiled/directives/map/Headers.js.map +1 -1
  148. package/transpiled/directives/octets/Put.js.map +1 -1
  149. package/transpiled/root.js +1 -1
  150. package/transpiled/root.js.map +1 -1
  151. package/transpiled/tsconfig.tsbuildinfo +1 -1
@@ -18,11 +18,14 @@ properties:
18
18
  type: string
19
19
  workflow:
20
20
  anyOf:
21
- - &unit
22
- type: object
21
+ - type: object
23
22
  patternProperties:
24
23
  ^.+$:
25
24
  type: string
26
25
  - type: array
27
- items: *unit
26
+ items:
27
+ type: object
28
+ patternProperties:
29
+ ^.+$:
30
+ type: string
28
31
  additionalProperties: false
@@ -4,7 +4,6 @@ definitions:
4
4
  patternProperties:
5
5
  ^[a-zA-Z0-9_]+$:
6
6
  type: string
7
-
8
7
  oneOf:
9
8
  - $ref: '#/definitions/unit'
10
9
  - type: array
@@ -1,14 +1,31 @@
1
- id?: string
2
- criteria?: string
3
- sort?: string
4
- search?: boolean
5
- sample?: integer
6
- omit?:
7
- $ref: range
8
- limit?:
9
- $ref: range
10
- selectors?: [string]
11
- projection?: [string]
12
- parameters?: [string]
13
- deleted?: boolean
14
- _: true
1
+ properties:
2
+ id:
3
+ type: string
4
+ criteria:
5
+ type: string
6
+ sort:
7
+ type: string
8
+ search:
9
+ type: boolean
10
+ sample:
11
+ type: integer
12
+ omit:
13
+ $ref: range
14
+ limit:
15
+ $ref: range
16
+ selectors:
17
+ type: array
18
+ items:
19
+ type: string
20
+ projection:
21
+ type: array
22
+ items:
23
+ type: string
24
+ parameters:
25
+ type: array
26
+ items:
27
+ type: string
28
+ deleted:
29
+ type: boolean
30
+ nullable: true
31
+ type: object
@@ -1,7 +1,19 @@
1
- id: string
2
- criteria: string
3
- search: string
4
- sort: string
5
- omit: number
6
- limit: number
7
- version: number
1
+ properties:
2
+ id:
3
+ type: string
4
+ criteria:
5
+ type: string
6
+ search:
7
+ type: string
8
+ sample:
9
+ type: number
10
+ sort:
11
+ type: string
12
+ omit:
13
+ type: number
14
+ limit:
15
+ type: number
16
+ version:
17
+ type: number
18
+ additionalProperties: false
19
+ type: object
@@ -1,2 +1,10 @@
1
- value?: number
2
- range: [number]
1
+ properties:
2
+ range:
3
+ type: array
4
+ items:
5
+ type: number
6
+ value:
7
+ type: number
8
+ type: object
9
+ required:
10
+ - range
@@ -1,2 +1,6 @@
1
- path: string
2
- node: ref:node
1
+ properties:
2
+ path:
3
+ type: string
4
+ node:
5
+ $ref: node
6
+ type: object
@@ -0,0 +1,37 @@
1
+ import { decide } from './Branch'
2
+ import type { Branch, Exposed } from './Branch'
3
+
4
+ function branch (version: string, timestamp: number): Branch {
5
+ return {
6
+ namespace: 'default',
7
+ component: 'one',
8
+ isolated: false,
9
+ node: { routes: [], methods: [], directives: [] },
10
+ version,
11
+ timestamp
12
+ }
13
+ }
14
+
15
+ function exposed (version: string, timestamp: number): Exposed {
16
+ return { version, timestamp, nodes: [] }
17
+ }
18
+
19
+ it('should refresh the same version', () => {
20
+ expect(decide(exposed('a', 1), branch('a', 2))).toStrictEqual('refresh')
21
+ })
22
+
23
+ it('should refresh the same version announced by an older tenant', () => {
24
+ expect(decide(exposed('a', 2), branch('a', 1))).toStrictEqual('refresh')
25
+ })
26
+
27
+ it('should merge a newer tenant', () => {
28
+ expect(decide(exposed('a', 1), branch('b', 2))).toStrictEqual('merge')
29
+ })
30
+
31
+ it('should not merge a tenant that started earlier', () => {
32
+ expect(decide(exposed('b', 2), branch('a', 1))).toStrictEqual('superseded')
33
+ })
34
+
35
+ it('should merge when tenants started at the same time', () => {
36
+ expect(decide(exposed('a', 1), branch('b', 1))).toStrictEqual('merge')
37
+ })
package/source/Branch.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type * as RTD from './RTD/syntax'
2
+ import type { Node } from './RTD'
2
3
 
3
4
  export interface Branch {
4
5
  namespace: string
@@ -6,4 +7,40 @@ export interface Branch {
6
7
  isolated: boolean
7
8
  node: RTD.Node
8
9
  version: string
10
+
11
+ /**
12
+ * When the tenant that announced it started, by its own clock. A replica on its way
13
+ * out announces the same component with an older stamp than the one replacing it, and
14
+ * that is what tells the two apart when their versions differ.
15
+ */
16
+ timestamp: number
17
+ }
18
+
19
+ /** A branch the gateway has merged, and the tenant it came from. */
20
+ export interface Exposed {
21
+ version: string
22
+
23
+ /** The start time of the tenant this branch came from. */
24
+ timestamp: number
25
+
26
+ nodes: Node[]
9
27
  }
28
+
29
+ /**
30
+ * What to do with an announcement about a component that already has a branch exposed.
31
+ *
32
+ * - `refresh`: the same version, so its expiration is extended and its endpoints left alone
33
+ * - `superseded`: it came from a tenant that started before the one exposed now
34
+ * - `merge`: everything else
35
+ */
36
+ export function decide (exposed: Exposed, branch: Branch): Decision {
37
+ if (exposed.version === branch.version)
38
+ return 'refresh'
39
+
40
+ if (branch.timestamp < exposed.timestamp)
41
+ return 'superseded'
42
+
43
+ return 'merge'
44
+ }
45
+
46
+ export type Decision = 'merge' | 'refresh' | 'superseded'
package/source/Factory.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import assert from 'node:assert'
2
2
  import { createHash } from 'node:crypto'
3
3
  import { console, traces, type LevelName, type TracesOptions } from 'openspan'
4
- import { decode } from '@toa.io/generic'
5
4
  import { Tenant } from './Tenant'
6
5
  import { Gateway } from './Gateway'
7
6
  import { Remotes } from './Remotes'
@@ -29,7 +28,8 @@ export class Factory implements extensions.Factory {
29
28
  const broadcast: Broadcast = this.boot.bindings.broadcast(CHANNEL, locator.id)
30
29
  const hash = createHash('sha256').update(JSON.stringify(node)).digest('hex')
31
30
 
32
- const branch: Branch = {
31
+ // no timestamp: the tenant stamps each announcement with its own start time
32
+ const branch: Omit<Branch, 'timestamp'> = {
33
33
  namespace: locator.namespace,
34
34
  component: locator.name,
35
35
  isolated: locator.namespace === 'identity',
@@ -46,7 +46,7 @@ export class Factory implements extensions.Factory {
46
46
 
47
47
  configureLogs()
48
48
 
49
- const options = decode<http.Options>(process.env.TOA_EXPOSITION_PROPERTIES)
49
+ const options = JSON.parse(process.env.TOA_EXPOSITION_PROPERTIES) as http.Options
50
50
  const broadcast: Broadcast = this.boot.bindings.broadcast(CHANNEL)
51
51
  const server = http.Server.create({ ...options })
52
52
  const remotes = new Remotes(this.boot)
@@ -76,13 +76,13 @@ const TRACES_ENV = 'TOA_TELEMETRY_TRACES'
76
76
  function configureLogs (): void {
77
77
  const globEnv = process.env[LOGS_PREFIX]
78
78
  const level: LevelName = process.env.TOA_DEV === '1' ? 'trace' : 'info'
79
- const options = globEnv === undefined ? { level } : decode<{ level?: LevelName }>(globEnv)
79
+ const options = globEnv === undefined ? { level } : JSON.parse(globEnv) as { level?: LevelName }
80
80
 
81
81
  console.configure({ level: options.level ?? level })
82
82
 
83
83
  const tracesEnv = process.env[TRACES_ENV]
84
84
 
85
- traces(tracesEnv === undefined ? development() : decode<TracesOptions>(tracesEnv))
85
+ traces(tracesEnv === undefined ? development() : JSON.parse(tracesEnv) as TracesOptions)
86
86
  }
87
87
 
88
88
  /**
package/source/Gateway.ts CHANGED
@@ -4,10 +4,11 @@ import { console } from 'openspan'
4
4
  import { type bindings, Connector } from '@toa.io/core'
5
5
  import * as http from './HTTP'
6
6
  import { rethrow } from './exceptions'
7
+ import { decide } from './Branch'
7
8
  import type { Interception } from './Interception'
8
9
  import type { Method, Node, Parameter, Tree, Match } from './RTD'
9
10
  import type { Label } from './discovery'
10
- import type { Branch } from './Branch'
11
+ import type { Branch, Exposed } from './Branch'
11
12
 
12
13
  export class Gateway extends Connector {
13
14
  private readonly broadcast: Broadcast
@@ -70,11 +71,19 @@ export class Gateway extends Connector {
70
71
  console.info('Gateway started')
71
72
  }
72
73
 
73
- protected override dispose (): void {
74
+ /**
75
+ * Both of these reach into components, and a dependency is torn down only once this has
76
+ * returned — where `dispose` runs after every one of them already has. The throttling
77
+ * ticker firing in between calls an endpoint that has just been unbound, and reports the
78
+ * refusal as a failure to reconcile.
79
+ */
80
+ protected override async close (): Promise<void> {
74
81
  this.stopped = true
75
82
 
76
83
  this.tree.dispose()
84
+ }
77
85
 
86
+ protected override dispose (): void {
78
87
  console.info('Gateway is closed')
79
88
  }
80
89
 
@@ -223,13 +232,24 @@ export class Gateway extends Connector {
223
232
 
224
233
  const exposed = this.branches.get(id)
225
234
 
226
- // rebuilding an identical branch would only tear down its live endpoints
227
- if (exposed?.version === branch.version) {
228
- this.tree.refresh(exposed.nodes)
229
- console.debug('Branch refreshed', attributes)
235
+ if (exposed !== undefined)
236
+ switch (decide(exposed, branch)) {
237
+ // rebuilding an identical branch would only tear down its live endpoints
238
+ case 'refresh':
239
+ this.tree.refresh(exposed.nodes)
240
+ console.trace('Branch refreshed', attributes)
230
241
 
231
- return
232
- }
242
+ return
243
+
244
+ case 'superseded':
245
+ console.debug('Branch superseded', {
246
+ ...attributes,
247
+ timestamp: branch.timestamp,
248
+ exposed: exposed.timestamp
249
+ })
250
+
251
+ return
252
+ }
233
253
 
234
254
  let nodes: Node[]
235
255
 
@@ -243,7 +263,7 @@ export class Gateway extends Connector {
243
263
  return
244
264
  }
245
265
 
246
- this.branches.set(id, { version: branch.version, nodes })
266
+ this.branches.set(id, { version: branch.version, timestamp: branch.timestamp, nodes })
247
267
 
248
268
  if (this.lastMerge !== 0)
249
269
  this.widestGap = Math.max(this.widestGap, Date.now() - this.lastMerge)
@@ -258,11 +278,6 @@ export class Gateway extends Connector {
258
278
 
259
279
  export type Broadcast = bindings.Broadcast<Label>
260
280
 
261
- interface Exposed {
262
- version: string
263
- nodes: Node[]
264
- }
265
-
266
281
  const SETTLE_QUIET_MIN = 500
267
282
  const SETTLE_QUIET_MAX = 10_000
268
283
  const SETTLE_QUIET_FACTOR = 2
@@ -1,8 +1,10 @@
1
- import { Err } from 'error-value'
2
1
  import { encode } from './yaml'
3
2
 
4
3
  it('encodes error values as their public properties', () => {
5
- const value = new Err('INVALID_USERNAME', 'Username is not meeting the requirements')
4
+ const value = new (class InvalidUsernameError extends Error {
5
+ public readonly code = 'INVALID_USERNAME'
6
+ public override readonly message = 'Username is not meeting the requirements'
7
+ })()
6
8
 
7
9
  expect(encode(value).toString())
8
10
  .toBe('code: INVALID_USERNAME\nmessage: Username is not meeting the requirements\n')
@@ -59,7 +59,10 @@ export class Tree {
59
59
  endpoints: this.endpoints,
60
60
  directives: {
61
61
  factory: this.directives,
62
- stack: this.root.directives ?? []
62
+ // A merged branch is mounted under the root, so it inherits the root's
63
+ // directives. The trunk is the root: createNode adds them itself, and
64
+ // seeding them here too would apply every one of them twice.
65
+ stack: node === this.root ? [] : this.root.directives ?? []
63
66
  },
64
67
  path: label(extension),
65
68
  extension
package/source/Tenant.ts CHANGED
@@ -7,11 +7,11 @@ import type { Branch } from './Branch'
7
7
 
8
8
  export class Tenant extends Connector {
9
9
  private readonly broadcast: Broadcast
10
- private readonly branch: Branch
10
+ private readonly branch: Omit<Branch, 'timestamp'>
11
11
  private started = 0
12
12
  private stopped = false
13
13
 
14
- public constructor (broadcast: Broadcast, branch: Branch) {
14
+ public constructor (broadcast: Broadcast, branch: Omit<Branch, 'timestamp'>) {
15
15
  super()
16
16
 
17
17
  this.broadcast = broadcast
@@ -29,7 +29,13 @@ export class Tenant extends Connector {
29
29
  void this.announce()
30
30
  }
31
31
 
32
- protected override dispose (): void {
32
+ /**
33
+ * Announcing is stopped where the teardown begins, not in `dispose`, which a connector
34
+ * runs after every one of its dependencies has gone. A component on its way out that
35
+ * announces itself once more has its routes held open by whoever is listening, and the
36
+ * requests that follow reach nothing.
37
+ */
38
+ protected override async close (): Promise<void> {
33
39
  this.stopped = true
34
40
  }
35
41
 
@@ -47,7 +53,12 @@ export class Tenant extends Connector {
47
53
  }
48
54
 
49
55
  private async expose (): Promise<void> {
50
- await this.broadcast.transmit('expose', this.branch)
56
+ // the ping subscription outlives the announcing loop, and answering one on the way out
57
+ // is the same announcement by another route
58
+ if (this.stopped)
59
+ return
60
+
61
+ await this.broadcast.transmit('expose', { ...this.branch, timestamp: this.started })
51
62
  }
52
63
  }
53
64
 
@@ -1,6 +1,5 @@
1
1
  import assert from 'node:assert'
2
2
  import { type Dependency, type Service } from '@toa.io/operations'
3
- import { encode } from '@toa.io/generic'
4
3
  import { type Annotation } from './Annotation'
5
4
  import * as schemas from './schemas'
6
5
  import { shortcuts } from './Directive'
@@ -35,7 +34,7 @@ export function deployment (_: unknown, annotation?: Annotation): Dependency {
35
34
 
36
35
  service.variables!.push({
37
36
  name: 'TOA_EXPOSITION',
38
- value: encode(tree)
37
+ value: JSON.stringify(tree)
39
38
  })
40
39
  }
41
40
 
@@ -57,13 +56,13 @@ export function deployment (_: unknown, annotation?: Annotation): Dependency {
57
56
 
58
57
  service.variables!.push({
59
58
  name: 'TOA_EXPOSITION_PROPERTIES',
60
- value: encode(properties)
59
+ value: JSON.stringify(properties)
61
60
  })
62
61
 
63
62
  // Nested identity composition shares this process; gateway already exposes /.ready.
64
63
  service.variables!.push({
65
64
  name: 'TOA_TELEMETRY_READY',
66
- value: encode(false)
65
+ value: JSON.stringify(false)
67
66
  })
68
67
 
69
68
  return { services: [service] }
@@ -11,7 +11,7 @@ export class IO implements DirectiveFamily<Directive> {
11
11
  public readonly name = 'io'
12
12
  public readonly mandatory = true
13
13
 
14
- /** Throttling reconciles through a component, because only a component has a stash aspect. */
14
+ /** Throttling reconciles through a component, because only a component has an atom aspect. */
15
15
  private sync: Sync | null = null
16
16
 
17
17
  // eslint-disable-next-line max-params
@@ -25,7 +25,7 @@ export class IO implements DirectiveFamily<Directive> {
25
25
 
26
26
  // discovering boots the component, so nothing is discovered until something throttles
27
27
  if (name === 'throttle')
28
- this.sync ??= new Sync(remotes.discover('exposition', 'stash'))
28
+ this.sync ??= new Sync(remotes.discover('exposition', 'atom'))
29
29
 
30
30
  return new Directive(value, this.sync!, route)
31
31
  }
@@ -11,7 +11,7 @@ import type { Remote } from '@toa.io/core'
11
11
  * the debt the whole group has reached, which the quotas then decide on locally.
12
12
  */
13
13
  export class Sync {
14
- private readonly stash: Promise<Remote>
14
+ private readonly atom: Promise<Remote>
15
15
  private readonly quotas: Quotas[] = []
16
16
  private remote: Remote | null = null
17
17
  private timer: NodeJS.Timeout | null = null
@@ -20,8 +20,8 @@ export class Sync {
20
20
  /** Ticks do not overlap: a slow round trip delays reconciling, it does not double it. */
21
21
  private reconciling = false
22
22
 
23
- public constructor (stash: Promise<Remote>) {
24
- this.stash = stash
23
+ public constructor (atom: Promise<Remote>) {
24
+ this.atom = atom
25
25
  }
26
26
 
27
27
  public register (quotas: Quotas): void {
@@ -74,7 +74,7 @@ export class Sync {
74
74
 
75
75
  private async reconcile (batch: Batch[]): Promise<void> {
76
76
  try {
77
- this.remote ??= await this.stash
77
+ this.remote ??= await this.atom
78
78
 
79
79
  const keys = batch.map((entry) => entry.quotas.name(entry.key))
80
80
  const deltas = batch.map((entry) => entry.delta)
@@ -3,6 +3,13 @@ import { cors } from '../cors'
3
3
  import { Mapping } from './Mapping'
4
4
  import type { Input } from '../../io'
5
5
 
6
+ /**
7
+ * Forbidden request header names: the browser sets them itself and a script cannot
8
+ * list them in `Access-Control-Request-Headers`, so there is nothing to advertise
9
+ * or vary on. The value is still read, it just never reaches CORS.
10
+ */
11
+ const FORBIDDEN = new Set(['host', 'origin'])
12
+
6
13
  export class Headers extends Mapping<Record<string, string>> {
7
14
  private readonly headers: string[]
8
15
 
@@ -14,7 +21,7 @@ export class Headers extends Mapping<Record<string, string>> {
14
21
 
15
22
  super(map)
16
23
 
17
- this.headers = Object.values(map).filter((header) => header !== 'host')
24
+ this.headers = Object.values(map).filter((header) => !FORBIDDEN.has(header))
18
25
  this.headers.forEach((header) => cors.allow(header))
19
26
  }
20
27
 
@@ -11,7 +11,6 @@ import type { Parameter } from '../../RTD'
11
11
  import type { Unit, Location } from './workflows'
12
12
  import type { Entry } from '@toa.io/extensions.storages'
13
13
  import type { Remotes } from '../../Remotes'
14
- import type { Err } from 'error-value'
15
14
  import type { Component } from '@toa.io/core'
16
15
  import type { Output } from '../../io'
17
16
  import type { Input } from './types'
@@ -73,7 +72,7 @@ export class Put extends Directive {
73
72
  const entry = await this.storage.invoke<Entry>('put', request)
74
73
 
75
74
  return match<Output>(entry,
76
- Error, (error: Err) => this.throw(error),
75
+ Error, (error: CodedError) => this.throw(error),
77
76
  () => this.reply(input, storage, entry, parameters))
78
77
  }
79
78
 
@@ -104,7 +103,7 @@ export class Put extends Directive {
104
103
  return stream
105
104
  }
106
105
 
107
- private throw (error: Err): never {
106
+ private throw (error: CodedError): never {
108
107
  throw match(error.code,
109
108
  'NOT_ACCEPTABLE', () => new http.UnsupportedMediaType(),
110
109
  'TYPE_MISMATCH', () => new http.BadRequest(),
@@ -136,3 +135,7 @@ interface StoreRequest {
136
135
  attributes?: Record<string, string>
137
136
  }
138
137
  }
138
+
139
+ interface CodedError extends Error {
140
+ code: string
141
+ }
package/source/root.ts CHANGED
@@ -1,9 +1,9 @@
1
- import { decode, merge } from '@toa.io/generic'
1
+ import { merge } from '@toa.io/generic'
2
2
  import { syntax } from './RTD'
3
3
 
4
4
  export function resolve (): syntax.Node {
5
5
  const value = process.env.TOA_EXPOSITION
6
- const root = value !== undefined ? decode<syntax.Node>(value) : syntax.createNode()
6
+ const root = value !== undefined ? JSON.parse(value) as syntax.Node : syntax.createNode()
7
7
 
8
8
  merge(root, PREDEFINED)
9
9
 
@@ -1,8 +1,31 @@
1
1
  import type * as RTD from './RTD/syntax';
2
+ import type { Node } from './RTD';
2
3
  export interface Branch {
3
4
  namespace: string;
4
5
  component: string;
5
6
  isolated: boolean;
6
7
  node: RTD.Node;
7
8
  version: string;
9
+ /**
10
+ * When the tenant that announced it started, by its own clock. A replica on its way
11
+ * out announces the same component with an older stamp than the one replacing it, and
12
+ * that is what tells the two apart when their versions differ.
13
+ */
14
+ timestamp: number;
8
15
  }
16
+ /** A branch the gateway has merged, and the tenant it came from. */
17
+ export interface Exposed {
18
+ version: string;
19
+ /** The start time of the tenant this branch came from. */
20
+ timestamp: number;
21
+ nodes: Node[];
22
+ }
23
+ /**
24
+ * What to do with an announcement about a component that already has a branch exposed.
25
+ *
26
+ * - `refresh`: the same version, so its expiration is extended and its endpoints left alone
27
+ * - `superseded`: it came from a tenant that started before the one exposed now
28
+ * - `merge`: everything else
29
+ */
30
+ export declare function decide(exposed: Exposed, branch: Branch): Decision;
31
+ export type Decision = 'merge' | 'refresh' | 'superseded';
@@ -1,3 +1,19 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.decide = void 0;
4
+ /**
5
+ * What to do with an announcement about a component that already has a branch exposed.
6
+ *
7
+ * - `refresh`: the same version, so its expiration is extended and its endpoints left alone
8
+ * - `superseded`: it came from a tenant that started before the one exposed now
9
+ * - `merge`: everything else
10
+ */
11
+ function decide(exposed, branch) {
12
+ if (exposed.version === branch.version)
13
+ return 'refresh';
14
+ if (branch.timestamp < exposed.timestamp)
15
+ return 'superseded';
16
+ return 'merge';
17
+ }
18
+ exports.decide = decide;
3
19
  //# sourceMappingURL=Branch.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"Branch.js","sourceRoot":"","sources":["../source/Branch.ts"],"names":[],"mappings":""}
1
+ {"version":3,"file":"Branch.js","sourceRoot":"","sources":["../source/Branch.ts"],"names":[],"mappings":";;;AA4BA;;;;;;GAMG;AACH,SAAgB,MAAM,CAAE,OAAgB,EAAE,MAAc;IACtD,IAAI,OAAO,CAAC,OAAO,KAAK,MAAM,CAAC,OAAO;QACpC,OAAO,SAAS,CAAA;IAElB,IAAI,MAAM,CAAC,SAAS,GAAG,OAAO,CAAC,SAAS;QACtC,OAAO,YAAY,CAAA;IAErB,OAAO,OAAO,CAAA;AAChB,CAAC;AARD,wBAQC"}
@@ -30,7 +30,6 @@ exports.Factory = void 0;
30
30
  const node_assert_1 = __importDefault(require("node:assert"));
31
31
  const node_crypto_1 = require("node:crypto");
32
32
  const openspan_1 = require("openspan");
33
- const generic_1 = require("@toa.io/generic");
34
33
  const Tenant_1 = require("./Tenant");
35
34
  const Gateway_1 = require("./Gateway");
36
35
  const Remotes_1 = require("./Remotes");
@@ -50,6 +49,7 @@ class Factory {
50
49
  tenant(locator, node) {
51
50
  const broadcast = this.boot.bindings.broadcast(CHANNEL, locator.id);
52
51
  const hash = (0, node_crypto_1.createHash)('sha256').update(JSON.stringify(node)).digest('hex');
52
+ // no timestamp: the tenant stamps each announcement with its own start time
53
53
  const branch = {
54
54
  namespace: locator.namespace,
55
55
  component: locator.name,
@@ -62,7 +62,7 @@ class Factory {
62
62
  service() {
63
63
  node_assert_1.default.ok(process.env.TOA_EXPOSITION_PROPERTIES, 'TOA_EXPOSITION_PROPERTIES is undefined');
64
64
  configureLogs();
65
- const options = (0, generic_1.decode)(process.env.TOA_EXPOSITION_PROPERTIES);
65
+ const options = JSON.parse(process.env.TOA_EXPOSITION_PROPERTIES);
66
66
  const broadcast = this.boot.bindings.broadcast(CHANNEL);
67
67
  const server = http.Server.create({ ...options });
68
68
  const remotes = new Remotes_1.Remotes(this.boot);
@@ -87,10 +87,10 @@ const TRACES_ENV = 'TOA_TELEMETRY_TRACES';
87
87
  function configureLogs() {
88
88
  const globEnv = process.env[LOGS_PREFIX];
89
89
  const level = process.env.TOA_DEV === '1' ? 'trace' : 'info';
90
- const options = globEnv === undefined ? { level } : (0, generic_1.decode)(globEnv);
90
+ const options = globEnv === undefined ? { level } : JSON.parse(globEnv);
91
91
  openspan_1.console.configure({ level: options.level ?? level });
92
92
  const tracesEnv = process.env[TRACES_ENV];
93
- (0, openspan_1.traces)(tracesEnv === undefined ? development() : (0, generic_1.decode)(tracesEnv));
93
+ (0, openspan_1.traces)(tracesEnv === undefined ? development() : JSON.parse(tracesEnv));
94
94
  }
95
95
  /**
96
96
  * Tracing is off unless it is configured. The console exporter is a local development