@toa.io/extensions.exposition 1.0.0-alpha.267 → 1.0.0-alpha.269

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 (119) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/components/{octets.storage → exposition.octets}/manifest.toa.yaml +2 -2
  3. package/components/exposition.stash/manifest.toa.yaml +12 -0
  4. package/components/exposition.stash/operations/count.js +7 -0
  5. package/components/identity.basic/operations/tsconfig.tsbuildinfo +1 -1
  6. package/components/identity.credentials/operations/tsconfig.tsbuildinfo +1 -1
  7. package/components/identity.federation/operations/tsconfig.tsbuildinfo +1 -1
  8. package/components/identity.keys/operations/tsconfig.tsbuildinfo +1 -1
  9. package/components/identity.otp/operations/tsconfig.tsbuildinfo +1 -1
  10. package/components/identity.passkeys/operations/tsconfig.tsbuildinfo +1 -1
  11. package/components/identity.roles/operations/tsconfig.tsbuildinfo +1 -1
  12. package/components/identity.tokens/operations/tsconfig.tsbuildinfo +1 -1
  13. package/documentation/io.md +45 -17
  14. package/documentation/notes/throttling.md +6 -0
  15. package/features/access.feature +55 -0
  16. package/features/io.throttle.feature +127 -0
  17. package/features/map.feature +5 -5
  18. package/features/octets.cloudinary.feature +12 -9
  19. package/features/octets.download.feature +4 -3
  20. package/features/octets.entries.feature +5 -3
  21. package/features/octets.feature +4 -4
  22. package/features/octets.head.feature +1 -1
  23. package/features/octets.location.feature +1 -1
  24. package/features/octets.meta.feature +2 -2
  25. package/features/octets.workflows.feature +7 -7
  26. package/package.json +8 -8
  27. package/schemas/io/throttle.cos.yaml +9 -2
  28. package/source/Directive.test.ts +72 -7
  29. package/source/Directive.ts +15 -5
  30. package/source/HTTP/messages.ts +5 -1
  31. package/source/Interception.ts +8 -0
  32. package/source/RTD/Context.ts +3 -0
  33. package/source/RTD/Directives.ts +1 -1
  34. package/source/RTD/Tree.ts +17 -0
  35. package/source/RTD/factory.ts +11 -1
  36. package/source/directives/cors/CORS.ts +26 -9
  37. package/source/directives/io/Directive.ts +4 -2
  38. package/source/directives/io/IO.ts +18 -7
  39. package/source/directives/io/Input.ts +5 -1
  40. package/source/directives/io/Throttle.ts +8 -6
  41. package/source/directives/io/lib/throttle/Configuration.test.ts +21 -0
  42. package/source/directives/io/lib/throttle/Configuration.ts +11 -4
  43. package/source/directives/io/lib/throttle/Keys.ts +12 -11
  44. package/source/directives/io/lib/throttle/Quotas.test.ts +207 -46
  45. package/source/directives/io/lib/throttle/Quotas.ts +59 -35
  46. package/source/directives/io/lib/throttle/components/Component.ts +2 -1
  47. package/source/directives/io/lib/throttle/components/Identity.ts +19 -0
  48. package/source/directives/io/lib/throttle/components/Route.ts +19 -0
  49. package/source/directives/io/lib/throttle/components/Segment.ts +19 -0
  50. package/source/directives/io/lib/throttle/components/index.ts +8 -2
  51. package/source/directives/io/schemas.test.ts +16 -0
  52. package/source/directives/octets/Octets.ts +1 -1
  53. package/source/directives/octets/workflows/Execution.ts +4 -2
  54. package/transpiled/Directive.d.ts +3 -1
  55. package/transpiled/Directive.js +12 -3
  56. package/transpiled/Directive.js.map +1 -1
  57. package/transpiled/HTTP/messages.js +4 -1
  58. package/transpiled/HTTP/messages.js.map +1 -1
  59. package/transpiled/Interception.d.ts +2 -0
  60. package/transpiled/Interception.js +4 -0
  61. package/transpiled/Interception.js.map +1 -1
  62. package/transpiled/RTD/Context.d.ts +2 -0
  63. package/transpiled/RTD/Directives.d.ts +1 -1
  64. package/transpiled/RTD/Tree.js +14 -0
  65. package/transpiled/RTD/Tree.js.map +1 -1
  66. package/transpiled/RTD/factory.js +8 -1
  67. package/transpiled/RTD/factory.js.map +1 -1
  68. package/transpiled/directives/cors/CORS.d.ts +8 -1
  69. package/transpiled/directives/cors/CORS.js +23 -9
  70. package/transpiled/directives/cors/CORS.js.map +1 -1
  71. package/transpiled/directives/io/Directive.d.ts +4 -2
  72. package/transpiled/directives/io/IO.d.ts +6 -3
  73. package/transpiled/directives/io/IO.js +11 -5
  74. package/transpiled/directives/io/IO.js.map +1 -1
  75. package/transpiled/directives/io/Input.js +5 -1
  76. package/transpiled/directives/io/Input.js.map +1 -1
  77. package/transpiled/directives/io/Throttle.d.ts +5 -3
  78. package/transpiled/directives/io/Throttle.js +6 -6
  79. package/transpiled/directives/io/Throttle.js.map +1 -1
  80. package/transpiled/directives/io/lib/throttle/Configuration.d.ts +6 -2
  81. package/transpiled/directives/io/lib/throttle/Configuration.js +5 -2
  82. package/transpiled/directives/io/lib/throttle/Configuration.js.map +1 -1
  83. package/transpiled/directives/io/lib/throttle/Keys.d.ts +4 -3
  84. package/transpiled/directives/io/lib/throttle/Keys.js +7 -10
  85. package/transpiled/directives/io/lib/throttle/Keys.js.map +1 -1
  86. package/transpiled/directives/io/lib/throttle/Quotas.d.ts +27 -8
  87. package/transpiled/directives/io/lib/throttle/Quotas.js +47 -31
  88. package/transpiled/directives/io/lib/throttle/Quotas.js.map +1 -1
  89. package/transpiled/directives/io/lib/throttle/components/Component.d.ts +2 -1
  90. package/transpiled/directives/io/lib/throttle/components/Identity.d.ts +11 -0
  91. package/transpiled/directives/io/lib/throttle/components/Identity.js +16 -0
  92. package/transpiled/directives/io/lib/throttle/components/Identity.js.map +1 -0
  93. package/transpiled/directives/io/lib/throttle/components/Route.d.ts +12 -0
  94. package/transpiled/directives/io/lib/throttle/components/Route.js +20 -0
  95. package/transpiled/directives/io/lib/throttle/components/Route.js.map +1 -0
  96. package/transpiled/directives/io/lib/throttle/components/Segment.d.ts +9 -0
  97. package/transpiled/directives/io/lib/throttle/components/Segment.js +20 -0
  98. package/transpiled/directives/io/lib/throttle/components/Segment.js.map +1 -0
  99. package/transpiled/directives/io/lib/throttle/components/index.d.ts +1 -1
  100. package/transpiled/directives/io/lib/throttle/components/index.js +7 -1
  101. package/transpiled/directives/io/lib/throttle/components/index.js.map +1 -1
  102. package/transpiled/directives/octets/Octets.js +1 -1
  103. package/transpiled/directives/octets/Octets.js.map +1 -1
  104. package/transpiled/directives/octets/workflows/Execution.js +3 -1
  105. package/transpiled/directives/octets/workflows/Execution.js.map +1 -1
  106. package/transpiled/tsconfig.tsbuildinfo +1 -1
  107. package/features/auth.issue.feature +0 -32
  108. package/source/directives/io/lib/throttle/Interval.ts +0 -40
  109. package/source/directives/io/lib/throttle/Quota.ts +0 -22
  110. package/transpiled/directives/io/lib/throttle/Interval.d.ts +0 -10
  111. package/transpiled/directives/io/lib/throttle/Interval.js +0 -39
  112. package/transpiled/directives/io/lib/throttle/Interval.js.map +0 -1
  113. package/transpiled/directives/io/lib/throttle/Quota.d.ts +0 -8
  114. package/transpiled/directives/io/lib/throttle/Quota.js +0 -22
  115. package/transpiled/directives/io/lib/throttle/Quota.js.map +0 -1
  116. /package/components/{octets.storage → exposition.octets}/operations/delete.js +0 -0
  117. /package/components/{octets.storage → exposition.octets}/operations/get.js +0 -0
  118. /package/components/{octets.storage → exposition.octets}/operations/head.js +0 -0
  119. /package/components/{octets.storage → exposition.octets}/operations/put.js +0 -0
@@ -14,6 +14,14 @@ const families: Array<jest.MockedObjectDeep<DirectiveFamily>> = [
14
14
  settle: jest.fn(),
15
15
  dispose: jest.fn()
16
16
  },
17
+ {
18
+ name: 'qux',
19
+ mandatory: true,
20
+ create: jest.fn((_0: any, _1: any, _2: any) => generate() as any),
21
+ preflight: jest.fn(),
22
+ settle: jest.fn(),
23
+ dispose: jest.fn()
24
+ },
17
25
  {
18
26
  name: 'bar',
19
27
  mandatory: false,
@@ -29,11 +37,12 @@ let factory: DirectivesFactory
29
37
  beforeEach(() => {
30
38
  jest.clearAllMocks()
31
39
 
32
- assert.ok(families[0].preflight !== undefined)
33
- assert.ok(families[1].preflight !== undefined)
40
+ for (const family of families) {
41
+ assert.ok(family.preflight !== undefined)
42
+
43
+ family.preflight.mockImplementation(() => null)
44
+ }
34
45
 
35
- families[0].preflight.mockImplementation(() => null)
36
- families[1].preflight.mockImplementation(() => null)
37
46
  factory = new DirectivesFactory(families, {} as unknown as Remotes)
38
47
  })
39
48
 
@@ -53,12 +62,22 @@ it('should create directive', async () => {
53
62
 
54
63
  factory.create(declarations)
55
64
 
56
- for (let i = 0; i < declarations.length; i++) {
57
- expect(families[i].create.mock.calls[0][0]).toBe(declarations[i].name)
58
- expect(families[i].create.mock.calls[0][1]).toBe(declarations[i].value)
65
+ for (const declaration of declarations) {
66
+ const family = families.find(({ name }) => name === declaration.family)!
67
+
68
+ expect(family.create.mock.calls[0][0]).toBe(declaration.name)
69
+ expect(family.create.mock.calls[0][1]).toBe(declaration.value)
59
70
  }
60
71
  })
61
72
 
73
+ it('should pass the route to the families', async () => {
74
+ const route = '/' + generate()
75
+
76
+ factory.create([{ family: 'foo', name: generate(), value: generate() }], route)
77
+
78
+ expect(families[0].create.mock.calls[0][3]).toBe(route)
79
+ })
80
+
62
81
  it('should throw error if directive family is not found', async () => {
63
82
  const declaration: syntax.Directive = {
64
83
  family: generate(),
@@ -97,3 +116,49 @@ it('should apply mandatory families', async () => {
97
116
 
98
117
  expect(families[0].preflight).toHaveBeenCalled()
99
118
  })
119
+
120
+ describe('order', () => {
121
+ function order (): string[] {
122
+ return families
123
+ .filter(({ preflight }) => preflight!.mock.calls.length > 0)
124
+ .sort((a, b) => a.preflight!.mock.invocationCallOrder[0] -
125
+ b.preflight!.mock.invocationCallOrder[0])
126
+ .map(({ name }) => name)
127
+ }
128
+
129
+ it('should run mandatory families in their own order, whatever a manifest says', async () => {
130
+ // `qux` declared first, but `foo` is registered first and both are mandatory
131
+ const directives = factory.create([
132
+ { family: 'qux', name: generate(), value: generate() },
133
+ { family: 'foo', name: generate(), value: generate() }
134
+ ])
135
+
136
+ await directives.preflight(generate() as unknown as Context, [])
137
+
138
+ expect(order()).toStrictEqual(['foo', 'qux'])
139
+ })
140
+
141
+ it('should run a mandatory family before a declared one it is not declared with',
142
+ async () => {
143
+ // only `qux` is declared: `foo` still has to run first, not merely first of
144
+ // whatever had no declarations
145
+ const directives = factory.create([
146
+ { family: 'qux', name: generate(), value: generate() }
147
+ ])
148
+
149
+ await directives.preflight(generate() as unknown as Context, [])
150
+
151
+ expect(order()).toStrictEqual(['foo', 'qux'])
152
+ })
153
+
154
+ it('should run mandatory families before the rest', async () => {
155
+ const directives = factory.create([
156
+ { family: 'bar', name: generate(), value: generate() },
157
+ { family: 'qux', name: generate(), value: generate() }
158
+ ])
159
+
160
+ await directives.preflight(generate() as unknown as Context, [])
161
+
162
+ expect(order()).toStrictEqual(['foo', 'qux', 'bar'])
163
+ })
164
+ })
@@ -63,13 +63,10 @@ export class DirectivesFactory implements RTD.DirectiveFactory {
63
63
  this.remotes = remotes
64
64
  }
65
65
 
66
- public create (declarations: RTD.syntax.Directive[]): Directives {
66
+ public create (declarations: RTD.syntax.Directive[], route: string = ''): Directives {
67
67
  const groups: Record<string, any> = {}
68
68
  const mandatory = new Set(this.mandatory)
69
69
 
70
- declarations.sort((a, b) =>
71
- (mandatory.has(b.family) ? 1 : 0) - (mandatory.has(a.family) ? 1 : 0))
72
-
73
70
  const names: Record<string, string[]> = {}
74
71
 
75
72
  for (const declaration of declarations) {
@@ -78,7 +75,7 @@ export class DirectivesFactory implements RTD.DirectiveFactory {
78
75
  if (family === undefined)
79
76
  throw new Error(`Directive family '${declaration.family}' is not found`)
80
77
 
81
- const directive = family.create(declaration.name, declaration.value, this.remotes)
78
+ const directive = family.create(declaration.name, declaration.value, this.remotes, route)
82
79
 
83
80
  groups[family.name] ??= []
84
81
  groups[family.name].push(directive)
@@ -103,6 +100,12 @@ export class DirectivesFactory implements RTD.DirectiveFactory {
103
100
  names: names[family]
104
101
  })
105
102
 
103
+ // Mandatory families run in the order they are registered in, not in the order a
104
+ // manifest happens to mention them: `auth` must resolve the identity before `io`
105
+ // can key a quota on it, and a request it denies should not reach `io` at all.
106
+ // The rest keep the order they were declared in, the sort being stable.
107
+ sets.sort((a, b) => this.rank(a.family.name) - this.rank(b.family.name))
108
+
106
109
  const directives = new Directives(sets)
107
110
 
108
111
  this.instances.push(directives)
@@ -114,6 +117,13 @@ export class DirectivesFactory implements RTD.DirectiveFactory {
114
117
  for (const directives of this.instances)
115
118
  directives.dispose()
116
119
  }
120
+
121
+ /** Mandatory families first, in their own order; everything else keeps its own. */
122
+ private rank (family: string): number {
123
+ const index = this.mandatory.indexOf(family)
124
+
125
+ return index === -1 ? this.mandatory.length : index
126
+ }
117
127
  }
118
128
 
119
129
  function options (set: RTD.DirectiveSet, stage: 'preflight' | 'settle'): SpanOptions {
@@ -66,7 +66,11 @@ export async function read (context: Context): Promise<any> {
66
66
 
67
67
  function send (message: OutgoingMessage, context: Context, response: http.ServerResponse): void {
68
68
  if (message.body === undefined || message.body === null) {
69
- response.setHeader('content-length', '0')
69
+ // a HEAD reply carries no body but must still report the length a GET would
70
+ // have returned, so a length already set by a directive is left alone
71
+ if (!response.hasHeader('content-length'))
72
+ response.setHeader('content-length', '0')
73
+
70
74
  response.end()
71
75
 
72
76
  return
@@ -5,6 +5,11 @@ export class Interception implements Interceptor {
5
5
 
6
6
  public constructor (interceptors: Interceptor[]) {
7
7
  this.interceptors = interceptors
8
+
9
+ // interceptors are module singletons, so a second gateway in one process —
10
+ // which is how the features run — would otherwise inherit the first one's state
11
+ for (const interceptor of interceptors)
12
+ interceptor.reset?.()
8
13
  }
9
14
 
10
15
  public async intercept (input: Input): Promise<Output> {
@@ -21,4 +26,7 @@ export class Interception implements Interceptor {
21
26
 
22
27
  export interface Interceptor {
23
28
  intercept: (input: Input) => Output | Promise<Output>
29
+
30
+ /** Discards whatever the interceptor accumulated while serving. */
31
+ reset?: () => void
24
32
  }
@@ -9,5 +9,8 @@ export interface Context<TExtension = any> {
9
9
  readonly factory: DirectiveFactory
10
10
  stack: Directive[]
11
11
  }
12
+
13
+ /** The route being built, accumulated on the way down and restored on the way up. */
14
+ path: string
12
15
  readonly extension: TExtension
13
16
  }
@@ -10,7 +10,7 @@ export interface Directives {
10
10
  }
11
11
 
12
12
  export interface DirectiveFactory {
13
- create: (directives: syntax.Directive[]) => Directives
13
+ create: (directives: syntax.Directive[], route: string) => Directives
14
14
  dispose: () => void
15
15
  }
16
16
 
@@ -61,6 +61,7 @@ export class Tree {
61
61
  factory: this.directives,
62
62
  stack: this.root.directives ?? []
63
63
  },
64
+ path: label(extension),
64
65
  extension
65
66
  }
66
67
 
@@ -68,4 +69,20 @@ export class Tree {
68
69
  }
69
70
  }
70
71
 
72
+ /**
73
+ * A branch's routes are relative to wherever it is merged, and the mount point is not
74
+ * known while it is being built — so the component it came from is what keeps two
75
+ * branches from looking like the same route.
76
+ */
77
+ function label (extension: unknown): string {
78
+ if (extension === null || typeof extension !== 'object')
79
+ return ''
80
+
81
+ const { namespace, component } = extension as Record<string, unknown>
82
+
83
+ return typeof namespace === 'string' && typeof component === 'string'
84
+ ? `${namespace}.${component}`
85
+ : ''
86
+ }
87
+
71
88
  const PROTECTED = true
@@ -42,17 +42,27 @@ export function branchTTL (): number {
42
42
 
43
43
  function createRoute (route: syntax.Route, context: Context): Route {
44
44
  const stack = context.directives.stack.slice()
45
+ const path = context.path
45
46
  const segments = segment(route.path)
47
+
48
+ context.path = join(path, route.path)
49
+
46
50
  const node = createNode(route.node, context)
47
51
 
48
52
  context.directives.stack = stack // restore
53
+ context.path = path
49
54
 
50
55
  return new Route(segments, node)
51
56
  }
52
57
 
58
+ function join (base: string, path: string): string {
59
+ // '/one/' and '/two/' would otherwise read as '/one//two/'
60
+ return (base + path).replace(/\/+/g, '/').replace(/(.)\/$/, '$1')
61
+ }
62
+
53
63
  function createMethod (method: syntax.Method, context: Context): Method {
54
64
  const stack = method.directives.concat(context.directives.stack)
55
- const directives = context.directives.factory.create(stack)
65
+ const directives = context.directives.factory.create(stack, context.path)
56
66
 
57
67
  const endpoint = method.mapping?.endpoint === undefined
58
68
  ? null
@@ -1,21 +1,24 @@
1
1
  import type { Input, Output } from '../../io'
2
2
  import type { Interceptor } from '../../Interception'
3
3
 
4
+ /** What is allowed before any directive asks for more. */
5
+ const REQUEST_HEADERS = [
6
+ 'accept',
7
+ 'authorization',
8
+ 'content-type',
9
+ 'if-match',
10
+ 'if-none-match'
11
+ ]
12
+
4
13
  export class CORS implements Interceptor {
5
14
  public readonly name = 'cors'
6
15
 
7
- private readonly requestHeaders = new Set<string>([
8
- 'accept',
9
- 'authorization',
10
- 'content-type',
11
- 'if-match',
12
- 'if-none-match'
13
- ])
16
+ private requestHeaders = new Set<string>(REQUEST_HEADERS)
14
17
 
15
18
  private readonly headers = new Headers({
16
19
  'access-control-allow-methods': 'GET, POST, PUT, PATCH, DELETE, LOCK, UNLOCK',
17
20
  'access-control-allow-credentials': 'true',
18
- 'access-control-allow-headers': Array.from(this.requestHeaders).join(', '),
21
+ 'access-control-allow-headers': this.allowedHeaders(),
19
22
  'access-control-max-age': '3600',
20
23
  'cache-control': 'max-age=3600',
21
24
  vary: 'origin'
@@ -46,9 +49,23 @@ export class CORS implements Interceptor {
46
49
  return null
47
50
  }
48
51
 
52
+ public reset (): void {
53
+ this.requestHeaders = new Set(REQUEST_HEADERS)
54
+ this.headers.set('access-control-allow-headers', this.allowedHeaders())
55
+ }
56
+
49
57
  public allow (header: string): void {
50
58
  this.requestHeaders.add(header.toLowerCase())
51
- this.headers.set('access-control-allow-headers', Array.from(this.requestHeaders).join(', '))
59
+ this.headers.set('access-control-allow-headers', this.allowedHeaders())
60
+ }
61
+
62
+ /**
63
+ * Sorted, because the set is filled as branches merge and their order is not
64
+ * fixed — an unsorted value would differ between otherwise identical processes,
65
+ * and between one restart and the next.
66
+ */
67
+ private allowedHeaders (): string {
68
+ return Array.from(this.requestHeaders).sort().join(', ')
52
69
  }
53
70
 
54
71
  private preflightResponse (origin: string): Output {
@@ -1,8 +1,10 @@
1
+ import type { Remote } from '@toa.io/core'
2
+ import type { Parameter } from '../../RTD'
1
3
  import type { Input as Context } from '../../io'
2
4
  import type * as http from '../../HTTP'
3
5
 
4
6
  export interface Directive {
5
- preflight: (context: Context) => void
7
+ preflight: (context: Context, parameters: Parameter[]) => void
6
8
  settle?: (context: Context, response: http.OutgoingMessage) => Promise<void> | void
7
9
  dispose?: () => void
8
10
  }
@@ -10,5 +12,5 @@ export interface Directive {
10
12
  export interface Constructor {
11
13
  validate: (value: unknown) => void
12
14
 
13
- new (value: any): Directive
15
+ new (value: any, counter: Promise<Remote>, route: string): Directive
14
16
  }
@@ -1,15 +1,21 @@
1
1
  import { Output } from './Output'
2
2
  import { Input } from './Input'
3
3
  import { Throttle } from './Throttle'
4
+ import type { Remote } from '@toa.io/core'
4
5
  import type * as http from '../../HTTP'
6
+ import type { Parameter, DirectiveFamily } from '../../RTD'
7
+ import type { Remotes } from '../../Remotes'
5
8
  import type { Constructor, Directive } from './Directive'
6
- import type { DirectiveFamily } from '../../RTD'
7
9
 
8
10
  export class IO implements DirectiveFamily<Directive> {
9
11
  public readonly name = 'io'
10
12
  public readonly mandatory = true
11
13
 
12
- public create (name: string, value: unknown): Directive {
14
+ /** Throttling counts through a component, because only a component has a stash aspect. */
15
+ private counter: Promise<Remote> | null = null
16
+
17
+ // eslint-disable-next-line max-params
18
+ public create (name: string, value: unknown, remotes: Remotes, route: string): Directive {
13
19
  if (!(name in constructors))
14
20
  throw new Error(`Directive 'io:${name}' is not implemented`)
15
21
 
@@ -17,20 +23,25 @@ export class IO implements DirectiveFamily<Directive> {
17
23
 
18
24
  Directive.validate(value)
19
25
 
20
- return new Directive(value)
26
+ // discovering boots the component, so nothing is discovered until something throttles
27
+ if (name === 'throttle')
28
+ this.counter ??= remotes.discover('exposition', 'stash')
29
+
30
+ return new Directive(value, this.counter!, route)
21
31
  }
22
32
 
23
- public preflight (directives: Directive[], context: http.Context): null {
33
+ public preflight (directives: Directive[], context: http.Context,
34
+ parameters: Parameter[]): null {
24
35
  let restricted = false
25
36
 
26
37
  for (const directive of directives) {
27
38
  restricted ||= directive instanceof Output
28
39
 
29
- directive.preflight(context)
40
+ directive.preflight(context, parameters)
30
41
  }
31
42
 
32
43
  if (!restricted)
33
- DENIAL.preflight(context)
44
+ DENIAL.preflight(context, parameters)
34
45
 
35
46
  return null
36
47
  }
@@ -52,4 +63,4 @@ const constructors: Record<string, Constructor> = {
52
63
  throttle: Throttle
53
64
  }
54
65
 
55
- const DENIAL: Output = new Output([])
66
+ const DENIAL: Directive = new Output([])
@@ -16,7 +16,11 @@ export class Input implements Directive {
16
16
  }
17
17
 
18
18
  public preflight (context: Context): void {
19
- context.pipelines.body.push((body) => this.check(body))
19
+ // Restrictions are on what the client sent, so the check goes to the front of the
20
+ // pipeline whatever order the families ran in: `auth:delegate` embeds the identity
21
+ // and `map:*` assigns mapped properties, and those additions are the server's own,
22
+ // not input to be restricted.
23
+ context.pipelines.body.unshift((body) => this.check(body))
20
24
  }
21
25
 
22
26
  private check (body: unknown): Message | Message[] | undefined {
@@ -1,29 +1,31 @@
1
1
  import { TooManyRequests } from '../../HTTP'
2
2
  import * as schemas from './schemas'
3
3
  import { parse, Quotas, type Declaration } from './lib/throttle'
4
+ import type { Remote } from '@toa.io/core'
4
5
  import type * as http from '../../HTTP'
6
+ import type { Parameter } from '../../RTD'
5
7
  import type { Directive } from './Directive'
6
8
 
7
9
  export class Throttle implements Directive {
8
10
  private readonly quotas: Quotas
9
11
 
10
- public constructor (declaration: Declaration) {
12
+ public constructor (declaration: Declaration, counter: Promise<Remote>, route: string) {
11
13
  const configuration = parse(declaration)
12
14
 
13
- this.quotas = Quotas.create(configuration)
15
+ this.quotas = Quotas.create(configuration, counter, route)
14
16
  }
15
17
 
16
18
  public static validate (declaration: unknown): asserts declaration is Declaration {
17
19
  schemas.throttle.validate(declaration, 'Incorrect \'io:throttle\' format')
18
20
  }
19
21
 
20
- public preflight (context: http.Context): void {
21
- if (!this.quotas.ok(context))
22
+ public preflight (context: http.Context, parameters: Parameter[]): void {
23
+ if (!this.quotas.ok(context, parameters))
22
24
  throw new TooManyRequests()
23
25
  }
24
26
 
25
- public settle (context: http.Context, output: http.OutgoingMessage): void {
26
- this.quotas.use(context, output)
27
+ public async settle (context: http.Context, output: http.OutgoingMessage): Promise<void> {
28
+ await this.quotas.use(context, output)
27
29
  }
28
30
 
29
31
  public dispose (): void {
@@ -38,3 +38,24 @@ it('should convert condition', () => {
38
38
  ]
39
39
  })
40
40
  })
41
+
42
+ it('should convert a key component that takes an argument', () => {
43
+ expect(parse({ key: { segment: 'id' }, ...rest })).toMatchObject({
44
+ key: [
45
+ {
46
+ method: 'segment',
47
+ options: 'id'
48
+ }
49
+ ]
50
+ })
51
+ })
52
+
53
+ it('should convert a mixed key', () => {
54
+ expect(parse({ key: ['route', { segment: 'id' }, 'identity'], ...rest })).toMatchObject({
55
+ key: [
56
+ { method: 'route' },
57
+ { method: 'segment', options: 'id' },
58
+ { method: 'identity' }
59
+ ]
60
+ })
61
+ })
@@ -11,13 +11,15 @@ interface Rule<T, K = unknown> {
11
11
  options?: K
12
12
  }
13
13
 
14
- export type KeyComponentMethod = 'ip' | 'path'
14
+ export type KeyComponentMethod = 'ip' | 'path' | 'route' | 'identity' | 'segment'
15
15
  export type KeyConditionMethod = 'status'
16
16
 
17
17
  export type KeyComponent = Rule<KeyComponentMethod>
18
18
  export type KeyCondition = Rule<KeyConditionMethod>
19
19
 
20
- type KeyDeclaration = KeyComponentMethod | KeyComponentMethod[]
20
+ /** A bare component, or the one that takes an argument. */
21
+ type KeyEntry = KeyComponentMethod | { segment: string }
22
+ type KeyDeclaration = KeyEntry | KeyEntry[]
21
23
 
22
24
  type ConditionDeclaration =
23
25
  Record<KeyConditionMethod, unknown>
@@ -41,9 +43,14 @@ export function parse (declaration: Declaration): Configuration {
41
43
  }
42
44
 
43
45
  function mapKey (declaration: KeyDeclaration): KeyComponent[] {
44
- const methods = Array.isArray(declaration) ? declaration : [declaration]
46
+ const entries = Array.isArray(declaration) ? declaration : [declaration]
45
47
 
46
- return methods.map((method) => ({ method }))
48
+ // a bare `path`, or `segment: id` for the ones that take an argument
49
+ return entries.flatMap((entry): KeyComponent[] =>
50
+ typeof entry === 'string'
51
+ ? [{ method: entry }]
52
+ : Object.entries(entry).map(([method, options]) =>
53
+ ({ method: method as KeyComponentMethod, options })))
47
54
  }
48
55
 
49
56
  function mapCondition (declaration?: ConditionDeclaration): KeyCondition[] | undefined {
@@ -2,8 +2,11 @@ import { createHash } from 'node:crypto'
2
2
  import { Components, type Component } from './components'
3
3
  import { Conditions, type Condition } from './conditions'
4
4
  import type { KeyComponent, KeyCondition } from './Configuration'
5
+ import type { Parameter } from '../../../../RTD'
5
6
  import type { Input as Context, Output } from '../../../../io'
6
7
 
8
+ const NONE: Parameter[] = []
9
+
7
10
  export class Keys {
8
11
  private readonly components: Component[]
9
12
  private readonly conditions?: Condition[]
@@ -13,28 +16,26 @@ export class Keys {
13
16
  this.conditions = conditions
14
17
  }
15
18
 
16
- public static create (componentRules: KeyComponent[], conditionRules?: KeyCondition[]): Keys {
17
- const components = componentRules.map((rule) => new Components[rule.method](rule.options))
19
+ public static create (componentRules: KeyComponent[], conditionRules?: KeyCondition[],
20
+ route: string = ''): Keys {
21
+ const components = componentRules.map((rule) =>
22
+ new Components[rule.method](rule.options, route))
23
+
18
24
  const conditions = conditionRules?.map((rule) => new Conditions[rule.method](rule.options))
19
25
 
20
26
  return new this(components, conditions)
21
27
  }
22
28
 
23
- public get (context: Context): string {
29
+ public get (context: Context, parameters: Parameter[] = NONE): string {
24
30
  const hash = createHash('sha256')
25
31
 
26
32
  for (const component of this.components)
27
- hash.update(component.get(context))
33
+ hash.update(component.get(context, parameters))
28
34
 
29
35
  return hash.digest('hex')
30
36
  }
31
37
 
32
- public match (input: Context, output: Output): string | null {
33
- const miss = this.conditions?.some((condition) => !condition.match(input, output))
34
-
35
- if (miss === true)
36
- return null
37
- else
38
- return this.get(input)
38
+ public matches (input: Context, output: Output): boolean {
39
+ return this.conditions?.some((condition) => !condition.match(input, output)) !== true
39
40
  }
40
41
  }