@toa.io/extensions.exposition 1.0.0-alpha.278 → 1.0.0-alpha.282

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 (243) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/components/identity.basic/events/principal.js +5 -1
  3. package/components/identity.basic/manifest.toa.yaml +11 -1
  4. package/components/identity.basic/operations/authenticate.js +9 -5
  5. package/components/identity.basic/operations/authenticate.js.map +1 -1
  6. package/components/identity.basic/operations/check.js +2 -1
  7. package/components/identity.basic/operations/check.js.map +1 -1
  8. package/components/identity.basic/operations/incept.js +4 -5
  9. package/components/identity.basic/operations/incept.js.map +1 -1
  10. package/components/identity.basic/operations/lib/credentials.d.ts +5 -0
  11. package/components/identity.basic/operations/lib/credentials.js +12 -0
  12. package/components/identity.basic/operations/lib/credentials.js.map +1 -0
  13. package/components/identity.basic/operations/transit.d.ts +2 -0
  14. package/components/identity.basic/operations/transit.js +12 -2
  15. package/components/identity.basic/operations/transit.js.map +1 -1
  16. package/components/identity.basic/operations/types.d.ts +11 -1
  17. package/components/identity.basic/source/authenticate.ts +11 -5
  18. package/components/identity.basic/source/check.ts +2 -1
  19. package/components/identity.basic/source/incept.ts +5 -5
  20. package/components/identity.basic/source/lib/credentials.test.ts +18 -0
  21. package/components/identity.basic/source/lib/credentials.ts +13 -0
  22. package/components/identity.basic/source/transit.ts +16 -4
  23. package/components/identity.basic/source/types.ts +13 -1
  24. package/components/identity.basic/tsconfig.tsbuildinfo +1 -1
  25. package/components/identity.federation/events/principal.js +1 -0
  26. package/components/identity.federation/manifest.toa.yaml +5 -1
  27. package/components/identity.federation/operations/authenticate.js +3 -3
  28. package/components/identity.federation/operations/authenticate.js.map +1 -1
  29. package/components/identity.federation/operations/create.js +5 -1
  30. package/components/identity.federation/operations/create.js.map +1 -1
  31. package/components/identity.federation/operations/lib/decode.js +5 -1
  32. package/components/identity.federation/operations/lib/decode.js.map +1 -1
  33. package/components/identity.federation/operations/lib/exchange.js +3 -3
  34. package/components/identity.federation/operations/lib/exchange.js.map +1 -1
  35. package/components/identity.federation/operations/list.js +2 -1
  36. package/components/identity.federation/operations/list.js.map +1 -1
  37. package/components/identity.federation/operations/types/configuration.d.ts +2 -1
  38. package/components/identity.federation/source/authenticate.ts +3 -3
  39. package/components/identity.federation/source/create.ts +5 -1
  40. package/components/identity.federation/source/lib/decode.ts +5 -1
  41. package/components/identity.federation/source/lib/exchange.ts +3 -3
  42. package/components/identity.federation/source/list.test.ts +1 -1
  43. package/components/identity.federation/source/list.ts +2 -1
  44. package/components/identity.federation/source/types/configuration.ts +2 -1
  45. package/components/identity.federation/tsconfig.tsbuildinfo +1 -1
  46. package/components/identity.keys/manifest.toa.yaml +18 -1
  47. package/components/identity.keys/operations/disable.d.ts +5 -0
  48. package/components/identity.keys/operations/disable.js +5 -0
  49. package/components/identity.keys/operations/disable.js.map +1 -0
  50. package/components/identity.keys/operations/revoke.d.ts +15 -0
  51. package/components/identity.keys/operations/revoke.js +12 -0
  52. package/components/identity.keys/operations/revoke.js.map +1 -0
  53. package/components/identity.keys/receivers/identity.bans.created.js +1 -0
  54. package/components/identity.keys/receivers/identity.bans.updated.js +1 -0
  55. package/components/identity.keys/receivers/identity.roles.deleted.js +1 -0
  56. package/components/identity.keys/source/disable.ts +9 -0
  57. package/components/identity.keys/source/revoke.ts +30 -0
  58. package/components/identity.keys/tsconfig.tsbuildinfo +1 -1
  59. package/components/identity.otp/manifest.toa.yaml +6 -0
  60. package/components/identity.otp/operations/authenticate.js +13 -1
  61. package/components/identity.otp/operations/authenticate.js.map +1 -1
  62. package/components/identity.otp/operations/issue.js +3 -2
  63. package/components/identity.otp/operations/issue.js.map +1 -1
  64. package/components/identity.otp/operations/lib/Context.d.ts +1 -0
  65. package/components/identity.otp/source/authenticate.ts +19 -1
  66. package/components/identity.otp/source/issue.ts +3 -2
  67. package/components/identity.otp/source/lib/Context.ts +1 -0
  68. package/components/identity.otp/tsconfig.tsbuildinfo +1 -1
  69. package/components/identity.passkeys/operations/authenticate.js +2 -1
  70. package/components/identity.passkeys/operations/authenticate.js.map +1 -1
  71. package/components/identity.passkeys/operations/challenge.js +3 -3
  72. package/components/identity.passkeys/operations/challenge.js.map +1 -1
  73. package/components/identity.passkeys/operations/delete.js +2 -1
  74. package/components/identity.passkeys/operations/delete.js.map +1 -1
  75. package/components/identity.passkeys/operations/list.js +2 -1
  76. package/components/identity.passkeys/operations/list.js.map +1 -1
  77. package/components/identity.passkeys/source/authenticate.ts +2 -1
  78. package/components/identity.passkeys/source/challenge.ts +3 -3
  79. package/components/identity.passkeys/source/delete.ts +2 -1
  80. package/components/identity.passkeys/source/list.ts +2 -1
  81. package/components/identity.passkeys/tsconfig.tsbuildinfo +1 -1
  82. package/components/identity.roles/manifest.toa.yaml +4 -0
  83. package/components/identity.roles/operations/grant.js +5 -2
  84. package/components/identity.roles/operations/grant.js.map +1 -1
  85. package/components/identity.roles/source/grant.ts +6 -2
  86. package/components/identity.roles/tsconfig.tsbuildinfo +1 -1
  87. package/components/identity.tokens/manifest.toa.yaml +3 -0
  88. package/components/identity.tokens/operations/authenticate.js +4 -3
  89. package/components/identity.tokens/operations/authenticate.js.map +1 -1
  90. package/components/identity.tokens/operations/decrypt.js +7 -1
  91. package/components/identity.tokens/operations/decrypt.js.map +1 -1
  92. package/components/identity.tokens/operations/encrypt.js +3 -2
  93. package/components/identity.tokens/operations/encrypt.js.map +1 -1
  94. package/components/identity.tokens/operations/issue.js +2 -1
  95. package/components/identity.tokens/operations/issue.js.map +1 -1
  96. package/components/identity.tokens/operations/lib/types.d.ts +3 -0
  97. package/components/identity.tokens/source/authenticate.test.ts +26 -2
  98. package/components/identity.tokens/source/authenticate.ts +4 -3
  99. package/components/identity.tokens/source/decrypt.test.ts +35 -1
  100. package/components/identity.tokens/source/decrypt.ts +10 -1
  101. package/components/identity.tokens/source/encrypt.test.ts +15 -0
  102. package/components/identity.tokens/source/encrypt.ts +4 -2
  103. package/components/identity.tokens/source/issue.ts +2 -1
  104. package/components/identity.tokens/source/lib/types.ts +4 -0
  105. package/components/identity.tokens/tsconfig.tsbuildinfo +1 -1
  106. package/documentation/authorities.md +3 -0
  107. package/documentation/components.md +31 -11
  108. package/documentation/identity.md +38 -6
  109. package/documentation/io.md +2 -2
  110. package/documentation/ip.md +24 -0
  111. package/documentation/passkeys.md +6 -0
  112. package/features/auth.assert.feature +2 -4
  113. package/features/auth.claims.feature +1 -0
  114. package/features/auth.incept.feature +46 -0
  115. package/features/authorities.feature +39 -0
  116. package/features/authorities.federation.feature +1 -0
  117. package/features/identity.bans.feature +63 -0
  118. package/features/identity.basic.feature +95 -3
  119. package/features/identity.feature +151 -0
  120. package/features/identity.federation.feature +5 -0
  121. package/features/identity.otp.feature +41 -0
  122. package/features/identity.roles.feature +252 -0
  123. package/features/identity.tokens.custom.feature +145 -0
  124. package/features/map.feature +2 -0
  125. package/features/steps/Gateway.ts +8 -2
  126. package/features/steps/Realtime.ts +1 -1
  127. package/package.json +5 -5
  128. package/readme.md +3 -0
  129. package/schemas/annotation.cos.yaml +16 -0
  130. package/source/Annotation.ts +14 -0
  131. package/source/Composition.ts +5 -5
  132. package/source/Directive.test.ts +3 -1
  133. package/source/Directive.ts +6 -2
  134. package/source/Endpoint.ts +4 -2
  135. package/source/Factory.ts +12 -9
  136. package/source/HTTP/Context.ts +12 -2
  137. package/source/HTTP/Server.ts +16 -3
  138. package/source/HTTP/address.test.ts +22 -0
  139. package/source/HTTP/address.ts +17 -0
  140. package/source/Query.test.ts +15 -1
  141. package/source/Query.ts +3 -1
  142. package/source/RTD/Directives.ts +10 -1
  143. package/source/Remotes.test.ts +7 -12
  144. package/source/Remotes.ts +5 -5
  145. package/source/const.ts +3 -0
  146. package/source/deployment.ts +7 -1
  147. package/source/directives/auth/Assert.ts +3 -0
  148. package/source/directives/auth/Authorization.test.ts +24 -0
  149. package/source/directives/auth/Authorization.ts +80 -11
  150. package/source/directives/auth/Incept.ts +9 -4
  151. package/source/directives/auth/Role.test.ts +1 -1
  152. package/source/directives/auth/Role.ts +2 -1
  153. package/source/directives/auth/schemes.test.ts +14 -0
  154. package/source/directives/auth/schemes.ts +14 -0
  155. package/source/directives/auth/types.ts +3 -0
  156. package/source/directives/io/IO.ts +10 -3
  157. package/source/directives/io/lib/throttle/Keys.ts +10 -3
  158. package/source/directives/io/lib/throttle/Quotas.test.ts +14 -4
  159. package/source/directives/io/lib/throttle/Quotas.ts +15 -2
  160. package/source/directives/io/lib/throttle/Sync.test.ts +7 -9
  161. package/source/directives/io/lib/throttle/Sync.ts +4 -7
  162. package/source/directives/io/lib/throttle/Warning.test.ts +32 -0
  163. package/source/directives/io/lib/throttle/Warning.ts +30 -0
  164. package/source/directives/io/lib/throttle/components/Component.ts +2 -1
  165. package/source/directives/io/lib/throttle/components/IP.ts +19 -28
  166. package/source/directives/io/lib/throttle/index.ts +1 -0
  167. package/source/redact.test.ts +27 -0
  168. package/source/redact.ts +21 -0
  169. package/source/schemas.test.ts +17 -0
  170. package/transpiled/Annotation.d.ts +10 -0
  171. package/transpiled/Composition.d.ts +3 -3
  172. package/transpiled/Composition.js +4 -4
  173. package/transpiled/Composition.js.map +1 -1
  174. package/transpiled/Directive.d.ts +3 -2
  175. package/transpiled/Directive.js +3 -1
  176. package/transpiled/Directive.js.map +1 -1
  177. package/transpiled/Endpoint.js +3 -2
  178. package/transpiled/Endpoint.js.map +1 -1
  179. package/transpiled/Factory.d.ts +3 -3
  180. package/transpiled/Factory.js +11 -8
  181. package/transpiled/Factory.js.map +1 -1
  182. package/transpiled/HTTP/Context.d.ts +4 -0
  183. package/transpiled/HTTP/Context.js +7 -2
  184. package/transpiled/HTTP/Context.js.map +1 -1
  185. package/transpiled/HTTP/Server.d.ts +5 -0
  186. package/transpiled/HTTP/Server.js +8 -3
  187. package/transpiled/HTTP/Server.js.map +1 -1
  188. package/transpiled/HTTP/address.d.ts +7 -0
  189. package/transpiled/HTTP/address.js +14 -0
  190. package/transpiled/HTTP/address.js.map +1 -0
  191. package/transpiled/Query.js +3 -1
  192. package/transpiled/Query.js.map +1 -1
  193. package/transpiled/RTD/Directives.d.ts +9 -1
  194. package/transpiled/Remotes.d.ts +3 -3
  195. package/transpiled/Remotes.js +4 -4
  196. package/transpiled/Remotes.js.map +1 -1
  197. package/transpiled/const.d.ts +2 -0
  198. package/transpiled/const.js +2 -0
  199. package/transpiled/const.js.map +1 -1
  200. package/transpiled/deployment.js +4 -0
  201. package/transpiled/deployment.js.map +1 -1
  202. package/transpiled/directives/auth/Assert.js +2 -0
  203. package/transpiled/directives/auth/Assert.js.map +1 -1
  204. package/transpiled/directives/auth/Authorization.d.ts +8 -0
  205. package/transpiled/directives/auth/Authorization.js +61 -11
  206. package/transpiled/directives/auth/Authorization.js.map +1 -1
  207. package/transpiled/directives/auth/Incept.d.ts +3 -1
  208. package/transpiled/directives/auth/Incept.js +8 -4
  209. package/transpiled/directives/auth/Incept.js.map +1 -1
  210. package/transpiled/directives/auth/Role.js +2 -1
  211. package/transpiled/directives/auth/Role.js.map +1 -1
  212. package/transpiled/directives/auth/schemes.d.ts +10 -0
  213. package/transpiled/directives/auth/schemes.js +12 -0
  214. package/transpiled/directives/auth/schemes.js.map +1 -1
  215. package/transpiled/directives/auth/types.d.ts +2 -0
  216. package/transpiled/directives/io/IO.d.ts +4 -1
  217. package/transpiled/directives/io/IO.js +7 -3
  218. package/transpiled/directives/io/IO.js.map +1 -1
  219. package/transpiled/directives/io/lib/throttle/Keys.d.ts +2 -1
  220. package/transpiled/directives/io/lib/throttle/Keys.js +7 -2
  221. package/transpiled/directives/io/lib/throttle/Keys.js.map +1 -1
  222. package/transpiled/directives/io/lib/throttle/Quotas.d.ts +3 -2
  223. package/transpiled/directives/io/lib/throttle/Quotas.js +8 -1
  224. package/transpiled/directives/io/lib/throttle/Quotas.js.map +1 -1
  225. package/transpiled/directives/io/lib/throttle/Sync.d.ts +2 -3
  226. package/transpiled/directives/io/lib/throttle/Sync.js +1 -3
  227. package/transpiled/directives/io/lib/throttle/Sync.js.map +1 -1
  228. package/transpiled/directives/io/lib/throttle/Warning.d.ts +11 -0
  229. package/transpiled/directives/io/lib/throttle/Warning.js +24 -0
  230. package/transpiled/directives/io/lib/throttle/Warning.js.map +1 -0
  231. package/transpiled/directives/io/lib/throttle/components/Component.d.ts +2 -1
  232. package/transpiled/directives/io/lib/throttle/components/IP.d.ts +10 -2
  233. package/transpiled/directives/io/lib/throttle/components/IP.js +18 -23
  234. package/transpiled/directives/io/lib/throttle/components/IP.js.map +1 -1
  235. package/transpiled/directives/io/lib/throttle/index.d.ts +1 -0
  236. package/transpiled/directives/io/lib/throttle/index.js +1 -0
  237. package/transpiled/directives/io/lib/throttle/index.js.map +1 -1
  238. package/transpiled/redact.d.ts +5 -0
  239. package/transpiled/redact.js +17 -0
  240. package/transpiled/redact.js.map +1 -0
  241. package/tsconfig.tsbuildinfo +1 -1
  242. package/components/exposition.atom/manifest.toa.yaml +0 -21
  243. package/components/exposition.atom/operations/meter.js +0 -5
package/source/Factory.ts CHANGED
@@ -10,6 +10,7 @@ import { families, interceptors } from './directives/index.js'
10
10
  import { DirectivesFactory } from './Directive.js'
11
11
  import { Composition } from './Composition.js'
12
12
  import * as root from './root.js'
13
+ import { ATOM_GROUP } from './const.js'
13
14
  import { Interception } from './Interception.js'
14
15
  import * as http from './HTTP/index.js'
15
16
  import type { Branch } from './Branch.js'
@@ -18,14 +19,14 @@ import type { Broadcast } from './Gateway.js'
18
19
  import type { Connector, Locator, extensions } from '@toa.io/core'
19
20
 
20
21
  export class Factory implements extensions.Factory {
21
- private readonly boot: Bootloader
22
+ private readonly host: Host
22
23
 
23
- public constructor (boot: Bootloader) {
24
- this.boot = boot
24
+ public constructor (host: Host) {
25
+ this.host = host
25
26
  }
26
27
 
27
28
  public async tenant (locator: Locator, node: syntax.Node): Promise<Connector> {
28
- const broadcast: Broadcast = await this.boot.bindings.broadcast(CHANNEL, locator.id)
29
+ const broadcast: Broadcast = await this.host.broadcast(CHANNEL, locator.id)
29
30
  const hash = createHash('sha256').update(JSON.stringify(node)).digest('hex')
30
31
 
31
32
  // no timestamp: the tenant stamps each announcement with its own start time
@@ -47,20 +48,22 @@ export class Factory implements extensions.Factory {
47
48
  configureLogs()
48
49
 
49
50
  const options = JSON.parse(process.env.TOA_EXPOSITION_PROPERTIES) as http.Options
50
- const broadcast: Broadcast = await this.boot.bindings.broadcast(CHANNEL)
51
+ const broadcast: Broadcast = await this.host.broadcast(CHANNEL)
51
52
  const server = http.Server.create({ ...options })
52
- const remotes = new Remotes(this.boot)
53
+ const remotes = new Remotes(this.host)
53
54
  const node = root.resolve()
54
55
  const methods = new EndpointsFactory(remotes)
55
- const directives = new DirectivesFactory(families, remotes)
56
+ const directives = new DirectivesFactory(families, remotes, this.host, options)
56
57
  const interception = new Interception(interceptors)
57
58
  const tree = new Tree(node, methods, directives)
58
59
 
59
- const composition = new Composition(this.boot)
60
+ const composition = new Composition(this.host)
60
61
  const gateway = new Gateway(broadcast, tree, interception)
61
62
 
62
63
  gateway.depends(remotes)
63
64
  gateway.depends(composition)
65
+ // what the directives meter through; one atom per process, connected once
66
+ gateway.depends(this.host.atom(ATOM_GROUP))
64
67
 
65
68
  server.attach(gateway.process.bind(gateway))
66
69
  server.depends(gateway)
@@ -101,4 +104,4 @@ function development (): TracesOptions {
101
104
  }
102
105
 
103
106
  // eslint-disable-next-line @typescript-eslint/consistent-type-imports
104
- export type Bootloader = typeof import('@toa.io/boot')
107
+ export type Host = extensions.Host
@@ -5,6 +5,7 @@ import { console } from 'openspan'
5
5
  import { Timing } from './Timing.js'
6
6
  import { type Format, formats, types } from './formats/index.js'
7
7
  import { read } from './messages.js'
8
+ import { address } from './address.js'
8
9
  import type { OutgoingMessage } from './messages.js'
9
10
  import type { IncomingMessage } from './types.js'
10
11
 
@@ -12,6 +13,9 @@ export class Context {
12
13
  public readonly id: string
13
14
  public readonly authority: string
14
15
  public readonly request: IncomingMessage
16
+
17
+ /** the client address, read as the deployment says; none otherwise, see `documentation/ip.md` */
18
+ public readonly ip: string | undefined
15
19
  public readonly url: URL
16
20
  public readonly subtype: string | null = null
17
21
  public readonly encoder: Format | null = null
@@ -30,6 +34,7 @@ export class Context {
30
34
  url: URL) {
31
35
  this.authority = authority
32
36
  this.request = request
37
+ this.ip = address(request, properties.ip)
33
38
 
34
39
  this.id = crypto.randomUUID()
35
40
  // parsed by the server, which had to parse it anyway to know the request is valid
@@ -80,13 +85,15 @@ export class Context {
80
85
  const headers = { ...request.headers }
81
86
 
82
87
  if (headers.authorization !== undefined)
83
- // only scheme
84
- headers.authorization = headers.authorization.slice(0, headers.authorization.indexOf(' '))
88
+ headers.authorization = SCHEME.exec(headers.authorization)?.[1] ?? '[malformed]'
85
89
 
86
90
  console.debug('Received request', { method: request.method, url: request.url, headers })
87
91
  }
88
92
  }
89
93
 
94
+ /** The scheme of an `authorization` header; the value after it is a credential. */
95
+ const SCHEME = /^(\w{1,32})(?: |$)/
96
+
90
97
  interface Pipelines {
91
98
  body: Array<(input: unknown) => unknown>
92
99
  response: Array<(output: OutgoingMessage) => void | Promise<void>>
@@ -94,6 +101,9 @@ interface Pipelines {
94
101
 
95
102
  interface Properties {
96
103
  debug: boolean
104
+
105
+ /** the header the client address is read from; the connection's without one */
106
+ ip?: string
97
107
  }
98
108
 
99
109
  /**
@@ -12,6 +12,7 @@ import { ClientError, Exception } from './exceptions.js'
12
12
  import { Context } from './Context.js'
13
13
  import { PROBE, Probe } from './Probe.js'
14
14
  import type { IncomingMessage, Protocol, ServerResponse } from './types.js'
15
+ import type { Bouncer } from '../Annotation.js'
15
16
 
16
17
  export class Server extends Connector {
17
18
  private readonly server: http.Server | http2.Http2Server
@@ -129,10 +130,10 @@ export class Server extends Connector {
129
130
  // no listener on `request.socket`: under HTTP/2 it is the session's socket, shared by
130
131
  // every concurrent stream, and removing listeners on it would strip the siblings'
131
132
 
132
- const host = authorityOf(request)
133
+ const host = authorityOf(request)?.toLowerCase()
133
134
 
134
- if (host === undefined) {
135
- console.warn('Request without an authority', errorAttributes(request, new Error('No authority')))
135
+ if (host === undefined || !HOST.test(host)) {
136
+ console.warn('Request without a valid authority', errorAttributes(request, new Error('Invalid authority')))
136
137
 
137
138
  response.writeHead(400).end()
138
139
 
@@ -350,6 +351,12 @@ function trace (headers: IncomingMessage['headers']): SpanContext | null {
350
351
  return null
351
352
  }
352
353
 
354
+ /**
355
+ * A host name with an optional port, or a bracketed IPv6 literal. The URL parser admits
356
+ * `,` `;` `=` `(` `)` and quotes in a host, and the authority is written into criteria.
357
+ */
358
+ const HOST = /^(?:[a-z0-9.-]{1,253}|\[[0-9a-f:.]{2,45}\])(?::\d{1,5})?$/
359
+
353
360
  const RAY = /^[\da-f]{32}$/i
354
361
  const ZERO_RAY = '0'.repeat(32)
355
362
 
@@ -372,6 +379,12 @@ interface Properties {
372
379
 
373
380
  /** Port of the readiness probe, which is HTTP/1.1 whatever the gateway serves. */
374
381
  probe: number
382
+
383
+ /** The header the client address is read from; the connection's address without one. */
384
+ ip?: string
385
+
386
+ /** Failed authentications an address may make; none are metered unless set. */
387
+ bouncer?: Bouncer
375
388
  }
376
389
 
377
390
  export type Options = { authorities: Properties['authorities'] } & {
@@ -0,0 +1,22 @@
1
+ import { it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { address } from './address.js'
5
+ import type { IncomingMessage } from './types.js'
6
+
7
+ const request = (headers: Record<string, string>): IncomingMessage =>
8
+ ({ headers, socket: { remoteAddress: '9.9.9.9' } }) as unknown as IncomingMessage
9
+
10
+ it('should be nothing without a header named', () => {
11
+ assert.equal(address(request({ 'x-forwarded-for': '1.1.1.1' })), undefined)
12
+ })
13
+
14
+ it('should be the last value of the named header', () => {
15
+ assert.equal(address(request({ 'x-forwarded-for': '1.1.1.1, 2.2.2.2' }), 'x-forwarded-for'), '2.2.2.2')
16
+ assert.equal(address(request({ 'cf-connecting-ip': '3.3.3.3' }), 'cf-connecting-ip'), '3.3.3.3')
17
+ })
18
+
19
+ it('should be nothing when the named header is absent or empty', () => {
20
+ assert.equal(address(request({}), 'x-real-ip'), undefined)
21
+ assert.equal(address(request({ 'x-real-ip': ' ' }), 'x-real-ip'), undefined)
22
+ })
@@ -0,0 +1,17 @@
1
+ import type { IncomingMessage } from './types.js'
2
+
3
+ /**
4
+ * The client address: the value of the header the deployment names, and nothing otherwise.
5
+ * Of a header holding a list, the last value is the one the edge in front appended. The
6
+ * connection's address is never it: behind an edge it is the edge's.
7
+ */
8
+ export function address (request: IncomingMessage, header?: string): string | undefined {
9
+ if (header === undefined)
10
+ return
11
+
12
+ const value = request.headers[header]
13
+ const raw = Array.isArray(value) ? value[value.length - 1] : value
14
+ const last = raw?.slice(raw.lastIndexOf(',') + 1).trim()
15
+
16
+ return last === '' ? undefined : last
17
+ }
@@ -19,7 +19,21 @@ it('should combine request criteria', async () => {
19
19
  const instance = new Query(query)
20
20
  const result = instance.fit({ criteria: 'qux==4' }, parameters)
21
21
 
22
- assert.deepStrictEqual(result.query!.criteria, '(bar==2;baz==3);(foo==1);(qux==4)')
22
+ assert.deepStrictEqual(result.query!.criteria, '(bar=="2";baz=="3");(foo==1);(qux==4)')
23
+ })
24
+
25
+ it('should quote parameter values', async () => {
26
+ const query: syntax.Query = {
27
+ omit: { range: [0, 1] },
28
+ limit: { range: [0, 1] }
29
+ }
30
+
31
+ const parameters: Parameter[] = [{ name: 'user', value: 'me"),(user=="victim' }]
32
+
33
+ const instance = new Query(query)
34
+ const result = instance.fit({}, parameters)
35
+
36
+ assert.deepStrictEqual(result.query!.criteria, '(user=="me\\"),(user==\\"victim")')
23
37
  })
24
38
 
25
39
  it('should set id parameter as query.id', async () => {
package/source/Query.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import assert from 'node:assert'
2
+ import { quote } from '@toa.io/generic'
2
3
  import * as http from './HTTP/index.js'
3
4
  import { type Parameter } from './RTD/index.js'
4
5
  import * as schemas from './schemas.js'
@@ -132,8 +133,9 @@ export class Query {
132
133
  }
133
134
 
134
135
  if (parameters.length > 0) {
136
+ // a segment may carry `,` `;` `(` `)` `=`, which is criteria grammar when unquoted
135
137
  const criteria = parameters
136
- .map(({ name, value }) => `${name}==${value}`)
138
+ .map(({ name, value }) => `${name}==${quote(value)}`)
137
139
  .join(';')
138
140
 
139
141
  groups.push({ criteria, operator: this.prepend })
@@ -1,7 +1,10 @@
1
1
  import type { Parameter } from './Match.js'
2
2
  import type * as syntax from './syntax/index.js'
3
- import type { Context, OutgoingMessage } from '../HTTP/index.js'
3
+ import type { Context, OutgoingMessage, Options } from '../HTTP/index.js'
4
4
  import type { Output } from '../io.js'
5
+ import type { extensions } from '@toa.io/core'
6
+
7
+ type Host = extensions.Host
5
8
 
6
9
  export interface Directives {
7
10
  preflight: (context: Context, parameters: Parameter[]) => Promise<Output>
@@ -28,6 +31,12 @@ export interface DirectiveFamily<TDirective = any, TExtension = any> {
28
31
 
29
32
  create: (name: string, ...rest: any[]) => TDirective
30
33
 
34
+ /**
35
+ * Called once per gateway with what the host provides and how the gateway is configured,
36
+ * before any directive is created.
37
+ */
38
+ mount?: (host: Host, options: Options) => void
39
+
31
40
  /**
32
41
  * Puts the directives of one route in the order they must run in. Called once, when
33
42
  * the set is built — the order cannot depend on the request.
@@ -5,19 +5,14 @@ import { isDeepStrictEqual } from 'node:util'
5
5
  import { generate } from 'randomstring'
6
6
  import { Connector } from '@toa.io/core'
7
7
  import { Remotes } from './Remotes.js'
8
- import type { Bootloader } from './Factory.js'
9
- import type { Locator, Source } from '@toa.io/core'
8
+ import type { Host } from './Factory.js'
10
9
 
11
- mock.module('@toa.io/boot', { namedExports: ({
12
- remote: async (locator: Locator, source?: Source) => await boot.remote(locator, source)
13
- }) })
14
-
15
- const boot = {
10
+ const host = {
16
11
  remote: mock.fn(async () => ({
17
12
  connect: mock.fn(() => undefined),
18
13
  link: mock.fn(() => undefined)
19
14
  }))
20
- } as unknown as Bootloader
15
+ } as unknown as Host
21
16
 
22
17
  const namespace = generate()
23
18
  const name = generate()
@@ -25,15 +20,15 @@ const name = generate()
25
20
  let remotes: Remotes
26
21
 
27
22
  beforeEach(() => {
28
- remotes = new Remotes(boot)
23
+ remotes = new Remotes(host)
29
24
  })
30
25
 
31
26
  it('should create remote', async () => {
32
27
  const remote = await remotes.discover(namespace, name)
33
28
 
34
- assert.ok(boot.remote.mock.calls.some((call: any) => call.arguments.length === 2 && isPartial(call.arguments[0], { namespace, name }) && call.arguments[1] !== null && call.arguments[1] !== undefined))
29
+ assert.ok(host.remote.mock.calls.some((call: any) => call.arguments.length === 2 && isPartial(call.arguments[0], { namespace, name }) && call.arguments[1] !== null && call.arguments[1] !== undefined))
35
30
 
36
- assert.deepStrictEqual(remote, await boot.remote.mock.calls[0].result)
31
+ assert.deepStrictEqual(remote, await host.remote.mock.calls[0].result)
37
32
  })
38
33
 
39
34
  it('should be instance of Connector', async () => {
@@ -49,7 +44,7 @@ it('should depend on created remotes', async () => {
49
44
  it('should attribute calls to the gateway', async () => {
50
45
  await remotes.discover(namespace, name)
51
46
 
52
- assert.ok(boot.remote.mock.calls.some((call: any) => call.arguments.length === 2 && call.arguments[0] !== null && call.arguments[0] !== undefined && isDeepStrictEqual(call.arguments[1], { service: 'exposition' })))
47
+ assert.ok(host.remote.mock.calls.some((call: any) => call.arguments.length === 2 && call.arguments[0] !== null && call.arguments[0] !== undefined && isDeepStrictEqual(call.arguments[1], { service: 'exposition' })))
53
48
  })
54
49
 
55
50
  function isPartial (actual, expected) {
package/source/Remotes.ts CHANGED
@@ -1,13 +1,13 @@
1
1
  import { Locator, Connector, type Remote, type Source } from '@toa.io/core'
2
- import { type Bootloader } from './Factory.js'
2
+ import { type Host } from './Factory.js'
3
3
 
4
4
  export class Remotes extends Connector {
5
- private readonly boot: Bootloader
5
+ private readonly host: Host
6
6
  private readonly cache: Record<string, Promise<Remote>> = {}
7
7
 
8
- public constructor (boot: Bootloader) {
8
+ public constructor (host: Host) {
9
9
  super()
10
- this.boot = boot
10
+ this.host = host
11
11
  }
12
12
 
13
13
  public async discover (namespace: string, name: string, version: string = 'local'): Promise<Remote> {
@@ -21,7 +21,7 @@ export class Remotes extends Connector {
21
21
 
22
22
  private async locate (locator: Locator): Promise<Remote> {
23
23
  // the gateway is the origin of every call it forwards
24
- const remote = await this.boot.remote(locator, SOURCE)
24
+ const remote = await this.host.remote(locator, SOURCE)
25
25
 
26
26
  this.depends(remote)
27
27
 
package/source/const.ts CHANGED
@@ -1 +1,4 @@
1
1
  export const BRANCH_TTL = 1_800_000
2
+
3
+ /** The replicas of the gateway decide together, whatever context they serve. */
4
+ export const ATOM_GROUP = 'exposition'
@@ -61,6 +61,12 @@ export function deployment (_: unknown, annotation?: Annotation): Dependency {
61
61
  if (annotation.protocol !== undefined)
62
62
  properties.protocol = annotation.protocol
63
63
 
64
+ if (annotation.ip !== undefined)
65
+ properties.ip = annotation.ip
66
+
67
+ if (annotation.bouncer !== undefined)
68
+ properties.bouncer = annotation.bouncer
69
+
64
70
  service.variables!.push({
65
71
  name: 'TOA_EXPOSITION_PROPERTIES',
66
72
  value: JSON.stringify(properties)
@@ -77,4 +83,4 @@ export function deployment (_: unknown, annotation?: Annotation): Dependency {
77
83
  return { services: [service] }
78
84
  }
79
85
 
80
- type Properties = Pick<Annotation, 'authorities' | 'debug' | 'protocol'>
86
+ type Properties = Pick<Annotation, 'authorities' | 'debug' | 'protocol' | 'bouncer' | 'ip'>
@@ -25,6 +25,9 @@ export class Assert implements Directive {
25
25
  throw new http.Unauthorized()
26
26
 
27
27
  if (identity === null) {
28
+ if (!Incept.acceptable(context))
29
+ throw new http.Unauthorized()
30
+
28
31
  context.identity = await Incept.incept(context, newid())
29
32
 
30
33
  context.pipelines.response.push((response) => {
@@ -0,0 +1,24 @@
1
+ import { it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { permits } from './Authorization.js'
5
+
6
+ const permissions = { '/users/me/**': ['GET'], '/notes/': ['*'] }
7
+
8
+ it('should permit a matching method and path', () => {
9
+ assert.equal(permits(permissions, 'GET', '/users/me/posts/'), true)
10
+ assert.equal(permits(permissions, 'DELETE', '/notes/'), true)
11
+ })
12
+
13
+ it('should refuse another method or path', () => {
14
+ assert.equal(permits(permissions, 'POST', '/users/me/posts/'), false)
15
+ assert.equal(permits(permissions, 'GET', '/admin/'), false)
16
+ })
17
+
18
+ it('should be matched on the routed path, not on the request URL', () => {
19
+ const raw = '/users/me/%2e%2e/admin/'
20
+ const routed = new URL(raw, 'https://example.com').pathname
21
+
22
+ assert.equal(routed, '/users/admin/')
23
+ assert.equal(permits(permissions, 'GET', routed), false)
24
+ })
@@ -16,11 +16,15 @@ import { Federation } from './Federation.js'
16
16
  import { Anyone } from './Anyone.js'
17
17
  import { Input, type Declaration } from './Input.js'
18
18
  import { split } from './split.js'
19
- import { PRIMARY, PROVIDERS } from './schemes.js'
19
+ import { PRIMARY, provider as providerOf } from './schemes.js'
20
+ import { ATOM_GROUP } from '../../const.js'
21
+ import { Quotas, Sync } from '../io/lib/throttle/index.js'
22
+ import { Keys } from '../io/lib/throttle/Keys.js'
20
23
  import type { Output } from '../../io.js'
21
24
  import type { Component } from '@toa.io/core'
22
25
  import type { Remotes } from '../../Remotes.js'
23
26
  import type { Parameter, DirectiveFamily } from '../../RTD/index.js'
27
+ import type { Host } from '../../Factory.js'
24
28
  import type {
25
29
  AuthenticationResult,
26
30
  Ban,
@@ -43,6 +47,37 @@ export class Authorization implements DirectiveFamily<Directive, Extension> {
43
47
  private tokens: Component | null = null
44
48
  private bans: Component | null = null
45
49
 
50
+ /** Failed authentications per address, reconciled with the other gateways. */
51
+ private meter: Quotas | null = null
52
+ private sync: Sync | null = null
53
+
54
+ public mount (host: Host, options: http.Options): void {
55
+ this.sync?.dispose()
56
+ this.sync = null
57
+ this.meter = null
58
+
59
+ const bouncer = options.bouncer
60
+
61
+ // keyed by address, which is the deployment's to name, so off until it is set
62
+ if (bouncer === undefined)
63
+ return
64
+
65
+ this.meter = new Quotas({
66
+ keys: Keys.create([{ method: 'ip', options: BOUNCER }]),
67
+ requests: bouncer.attempts ?? ATTEMPTS,
68
+ interval: (bouncer.interval ?? INTERVAL) * 1000,
69
+ conditional: true, // charged on a rejection, not on a check
70
+ name: METER
71
+ })
72
+
73
+ this.sync = new Sync(host.atom(ATOM_GROUP))
74
+ this.sync.register(this.meter)
75
+ }
76
+
77
+ public dispose (): void {
78
+ this.sync?.dispose()
79
+ }
80
+
46
81
  public create (name: string, value: any, remotes: Remotes): Directive {
47
82
  assert.ok(name in constructors,
48
83
  `Directive 'auth:${name}' is not implemented`)
@@ -68,7 +103,7 @@ export class Authorization implements DirectiveFamily<Directive, Extension> {
68
103
  public async preflight (directives: Directive[],
69
104
  context: Context,
70
105
  parameters: Parameter[]): Promise<Output> {
71
- context.identity = await this.resolve(context.authority, context.request.headers.authorization)
106
+ context.identity = await this.resolve(context)
72
107
 
73
108
  for (const directive of directives) {
74
109
  const allow = await directive.authorize(context.identity, context, parameters)
@@ -103,8 +138,12 @@ export class Authorization implements DirectiveFamily<Directive, Extension> {
103
138
  if (await this.banned(identity))
104
139
  throw new http.Unauthorized()
105
140
 
106
- // Role directive may have already set the value
107
- identity.roles ??= await Role.get(identity, this.discovery.roles)
141
+ // a token carries the roles it was issued with, and the refresh is where they are read again;
142
+ // any other scheme has just read them, unless a Role directive already did
143
+ if (identity.scheme === PRIMARY)
144
+ identity.roles = await Role.get(identity, this.discovery.roles)
145
+ else
146
+ identity.roles ??= await Role.get(identity, this.discovery.roles)
108
147
  this.tokens ??= await this.discovery.tokens
109
148
 
110
149
  const token = await this.tokens.invoke<string>('encrypt', {
@@ -118,12 +157,20 @@ export class Authorization implements DirectiveFamily<Directive, Extension> {
118
157
  response.headers.set('cache-control', 'no-store')
119
158
  }
120
159
 
121
- private async resolve (authority: string, authorization: string | undefined): Promise<Identity | null> {
160
+ private async resolve (context: Context): Promise<Identity | null> {
161
+ const { authority } = context
162
+ const { authorization } = context.request.headers
163
+
122
164
  if (authorization === undefined)
123
165
  return null
124
166
 
167
+ const retry = this.meter?.check(context, NONE) ?? 0
168
+
169
+ if (retry > 0)
170
+ throw new http.TooManyRequests(retry)
171
+
125
172
  const [scheme, credentials] = split(authorization)
126
- const provider = PROVIDERS[scheme]
173
+ const provider = providerOf(scheme)
127
174
 
128
175
  if (provider === undefined)
129
176
  throw new http.Unauthorized(`Unknown authentication scheme '${scheme}'`)
@@ -141,9 +188,14 @@ export class Authorization implements DirectiveFamily<Directive, Extension> {
141
188
  if (result instanceof Error) {
142
189
  const code: string | unknown = (result as unknown as { code: string }).code
143
190
 
144
- if (typeof code === 'string')
191
+ if (typeof code === 'string') {
145
192
  console.info('Authentication failed', { code })
146
193
 
194
+ context.rejection = code
195
+ }
196
+
197
+ this.meter?.use(context, null)
198
+
147
199
  return null
148
200
  }
149
201
 
@@ -163,10 +215,8 @@ export class Authorization implements DirectiveFamily<Directive, Extension> {
163
215
  if (permissions === undefined)
164
216
  return true
165
217
 
166
- return Object.entries(permissions).some(([pattern, methods]) => {
167
- return methods.some((method) => method === '*' || method === context.request.method) &&
168
- glob(pattern).match(context.request.url)
169
- })
218
+ // the route is matched on the normalized path, so the permission is too
219
+ return permits(permissions, context.request.method, context.url.pathname)
170
220
  }
171
221
 
172
222
  private async banned (identity: Identity): Promise<boolean> {
@@ -178,6 +228,13 @@ export class Authorization implements DirectiveFamily<Directive, Extension> {
178
228
  }
179
229
  }
180
230
 
231
+ /** Whether the permissions admit the method on the path the request is routed by. */
232
+ export function permits (permissions: Record<string, string[]>, method: string, pathname: string): boolean {
233
+ return Object.entries(permissions).some(([pattern, methods]) =>
234
+ methods.some((allowed) => allowed === '*' || allowed === method) &&
235
+ glob(pattern).match(pathname))
236
+ }
237
+
181
238
  /**
182
239
  * `minimatch(str, pattern)` compiles the pattern on every call, and a permission is
183
240
  * matched on every request the identity makes. Patterns arrive with an identity, hence
@@ -198,6 +255,18 @@ function glob (pattern: string): Minimatch {
198
255
  return compiled
199
256
  }
200
257
 
258
+ /** What an address may fail at once, and the seconds it takes to earn them back. */
259
+ const ATTEMPTS = 20
260
+ const INTERVAL = 60
261
+
262
+ /** Who is speaking when a request cannot be keyed. */
263
+ const BOUNCER = 'Authentication bouncer'
264
+
265
+ /** The meter's name in the atom: `atom:exposition:meter:credentials:<address>`. */
266
+ const METER = 'credentials'
267
+
268
+ const NONE: Parameter[] = []
269
+
201
270
  const GLOBS = new Map<string, Minimatch>()
202
271
  const GLOBS_LIMIT = 1024
203
272
 
@@ -3,7 +3,7 @@ import { console } from 'openspan'
3
3
  import * as http from '../../HTTP/index.js'
4
4
  import { split } from './split.js'
5
5
  import { create } from './create.js'
6
- import { PROVIDERS, INCEPTION } from './schemes.js'
6
+ import { INCEPTION, UNKNOWN, provider as providerOf } from './schemes.js'
7
7
  import { Role } from './Role.js'
8
8
  import type { Component } from '@toa.io/core'
9
9
  import type { Maybe } from '@toa.io/types'
@@ -26,7 +26,7 @@ export class Incept implements Directive {
26
26
 
27
27
  public static async incept (context: Context, id: string): Promise<Identity> {
28
28
  const [scheme, credentials] = split(context.request.headers.authorization!)
29
- const provider = PROVIDERS[scheme]
29
+ const provider = providerOf(scheme)
30
30
 
31
31
  if (provider === undefined)
32
32
  throw new http.BadRequest('Authentication scheme is not supported')
@@ -61,8 +61,13 @@ export class Incept implements Directive {
61
61
  return identity
62
62
  }
63
63
 
64
- public authorize (identity: Identity | null): boolean {
65
- return identity === null
64
+ /** Credentials that were rejected for any reason but being unknown are not incepted. */
65
+ public static acceptable (context: Context): boolean {
66
+ return context.request.headers.authorization === undefined || context.rejection === UNKNOWN
67
+ }
68
+
69
+ public authorize (identity: Identity | null, context: Context): boolean {
70
+ return identity === null && Incept.acceptable(context)
66
71
  }
67
72
 
68
73
  public reply (context: Context): http.OutgoingMessage | null {
@@ -36,7 +36,7 @@ it('should return false if not matched', async () => {
36
36
 
37
37
  assert.ok(remote.invoke.mock.calls.some((call: any) => call.arguments.length === 2 && isDeepStrictEqual(call.arguments[0], 'list') && isDeepStrictEqual(call.arguments[1], {
38
38
  query: {
39
- criteria: `identity==${identity.id}`,
39
+ criteria: `identity=="${identity.id}"`,
40
40
  limit: 1024
41
41
  }
42
42
  })))
@@ -1,4 +1,5 @@
1
1
  import assert from 'node:assert'
2
+ import { quote } from '@toa.io/generic'
2
3
  import { type Component, type Query } from '@toa.io/core'
3
4
  import { type Directive, type Identity } from './types.js'
4
5
  import type { Parameter } from '../../RTD/index.js'
@@ -19,7 +20,7 @@ export class Role implements Directive {
19
20
  this.remote ??= await discovery
20
21
 
21
22
  const query: Query = {
22
- criteria: `identity==${identity.id}`,
23
+ criteria: `identity==${quote(identity.id)}`,
23
24
  limit: 1024
24
25
  }
25
26
 
@@ -0,0 +1,14 @@
1
+ import { it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { provider } from './schemes.js'
5
+
6
+ it('should resolve a known scheme', () => {
7
+ assert.equal(provider('basic'), 'basic')
8
+ assert.equal(provider('bearer'), 'federation')
9
+ })
10
+
11
+ it('should not resolve a property of Object.prototype', () => {
12
+ for (const name of ['constructor', '__proto__', 'toString', 'hasOwnProperty'])
13
+ assert.equal(provider(name), undefined)
14
+ })