@toa.io/extensions.exposition 1.0.0-alpha.266 → 1.0.0-alpha.268

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 (116) hide show
  1. package/CHANGELOG.md +23 -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/identtiy.tokens.custom.feature +2 -3
  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/Throttle.ts +8 -6
  40. package/source/directives/io/lib/throttle/Configuration.test.ts +21 -0
  41. package/source/directives/io/lib/throttle/Configuration.ts +11 -4
  42. package/source/directives/io/lib/throttle/Keys.ts +12 -11
  43. package/source/directives/io/lib/throttle/Quotas.test.ts +207 -46
  44. package/source/directives/io/lib/throttle/Quotas.ts +59 -35
  45. package/source/directives/io/lib/throttle/components/Component.ts +2 -1
  46. package/source/directives/io/lib/throttle/components/Identity.ts +19 -0
  47. package/source/directives/io/lib/throttle/components/Route.ts +19 -0
  48. package/source/directives/io/lib/throttle/components/Segment.ts +19 -0
  49. package/source/directives/io/lib/throttle/components/index.ts +8 -2
  50. package/source/directives/io/schemas.test.ts +16 -0
  51. package/source/directives/octets/Octets.ts +1 -1
  52. package/source/directives/octets/workflows/Execution.ts +4 -2
  53. package/transpiled/Directive.d.ts +3 -1
  54. package/transpiled/Directive.js +12 -3
  55. package/transpiled/Directive.js.map +1 -1
  56. package/transpiled/HTTP/messages.js +4 -1
  57. package/transpiled/HTTP/messages.js.map +1 -1
  58. package/transpiled/Interception.d.ts +2 -0
  59. package/transpiled/Interception.js +4 -0
  60. package/transpiled/Interception.js.map +1 -1
  61. package/transpiled/RTD/Context.d.ts +2 -0
  62. package/transpiled/RTD/Directives.d.ts +1 -1
  63. package/transpiled/RTD/Tree.js +14 -0
  64. package/transpiled/RTD/Tree.js.map +1 -1
  65. package/transpiled/RTD/factory.js +8 -1
  66. package/transpiled/RTD/factory.js.map +1 -1
  67. package/transpiled/directives/cors/CORS.d.ts +8 -1
  68. package/transpiled/directives/cors/CORS.js +23 -9
  69. package/transpiled/directives/cors/CORS.js.map +1 -1
  70. package/transpiled/directives/io/Directive.d.ts +4 -2
  71. package/transpiled/directives/io/IO.d.ts +6 -3
  72. package/transpiled/directives/io/IO.js +11 -5
  73. package/transpiled/directives/io/IO.js.map +1 -1
  74. package/transpiled/directives/io/Throttle.d.ts +5 -3
  75. package/transpiled/directives/io/Throttle.js +6 -6
  76. package/transpiled/directives/io/Throttle.js.map +1 -1
  77. package/transpiled/directives/io/lib/throttle/Configuration.d.ts +6 -2
  78. package/transpiled/directives/io/lib/throttle/Configuration.js +5 -2
  79. package/transpiled/directives/io/lib/throttle/Configuration.js.map +1 -1
  80. package/transpiled/directives/io/lib/throttle/Keys.d.ts +4 -3
  81. package/transpiled/directives/io/lib/throttle/Keys.js +7 -10
  82. package/transpiled/directives/io/lib/throttle/Keys.js.map +1 -1
  83. package/transpiled/directives/io/lib/throttle/Quotas.d.ts +27 -8
  84. package/transpiled/directives/io/lib/throttle/Quotas.js +47 -31
  85. package/transpiled/directives/io/lib/throttle/Quotas.js.map +1 -1
  86. package/transpiled/directives/io/lib/throttle/components/Component.d.ts +2 -1
  87. package/transpiled/directives/io/lib/throttle/components/Identity.d.ts +11 -0
  88. package/transpiled/directives/io/lib/throttle/components/Identity.js +16 -0
  89. package/transpiled/directives/io/lib/throttle/components/Identity.js.map +1 -0
  90. package/transpiled/directives/io/lib/throttle/components/Route.d.ts +12 -0
  91. package/transpiled/directives/io/lib/throttle/components/Route.js +20 -0
  92. package/transpiled/directives/io/lib/throttle/components/Route.js.map +1 -0
  93. package/transpiled/directives/io/lib/throttle/components/Segment.d.ts +9 -0
  94. package/transpiled/directives/io/lib/throttle/components/Segment.js +20 -0
  95. package/transpiled/directives/io/lib/throttle/components/Segment.js.map +1 -0
  96. package/transpiled/directives/io/lib/throttle/components/index.d.ts +1 -1
  97. package/transpiled/directives/io/lib/throttle/components/index.js +7 -1
  98. package/transpiled/directives/io/lib/throttle/components/index.js.map +1 -1
  99. package/transpiled/directives/octets/Octets.js +1 -1
  100. package/transpiled/directives/octets/Octets.js.map +1 -1
  101. package/transpiled/directives/octets/workflows/Execution.js +3 -1
  102. package/transpiled/directives/octets/workflows/Execution.js.map +1 -1
  103. package/transpiled/tsconfig.tsbuildinfo +1 -1
  104. package/features/auth.issue.feature +0 -32
  105. package/source/directives/io/lib/throttle/Interval.ts +0 -40
  106. package/source/directives/io/lib/throttle/Quota.ts +0 -22
  107. package/transpiled/directives/io/lib/throttle/Interval.d.ts +0 -10
  108. package/transpiled/directives/io/lib/throttle/Interval.js +0 -39
  109. package/transpiled/directives/io/lib/throttle/Interval.js.map +0 -1
  110. package/transpiled/directives/io/lib/throttle/Quota.d.ts +0 -8
  111. package/transpiled/directives/io/lib/throttle/Quota.js +0 -22
  112. package/transpiled/directives/io/lib/throttle/Quota.js.map +0 -1
  113. /package/components/{octets.storage → exposition.octets}/operations/delete.js +0 -0
  114. /package/components/{octets.storage → exposition.octets}/operations/get.js +0 -0
  115. /package/components/{octets.storage → exposition.octets}/operations/head.js +0 -0
  116. /package/components/{octets.storage → exposition.octets}/operations/put.js +0 -0
@@ -1,5 +1,6 @@
1
1
  import { setTimeout } from 'node:timers/promises'
2
2
  import { Quotas } from './Quotas'
3
+ import type { Remote } from '@toa.io/core'
3
4
  import type { Configuration } from './Configuration'
4
5
  import type { Input as Context, Output } from '../../../../io'
5
6
 
@@ -7,98 +8,220 @@ let quotas: Quotas
7
8
  let configuration: Configuration
8
9
  let context: Context
9
10
  let output: Output
11
+ let counted: Request[]
10
12
 
11
13
  beforeEach(() => {
12
14
  output = { status: 200 }
15
+ counted = []
13
16
  })
14
17
 
15
18
  describe('common', () => {
16
19
  beforeEach(() => {
17
20
  context = createContext()
18
21
  configuration = createConfiguration()
19
- quotas = Quotas.create(configuration)
22
+ quotas = Quotas.create(configuration, createCounter())
20
23
  })
21
24
 
22
- it('should be ok', () => {
23
- quotas.ok(context)
24
- quotas.use(context, output)
25
+ it('should be ok', async () => {
26
+ expect(quotas.ok(context, [])).toBe(true)
27
+
28
+ await quotas.use(context, output)
25
29
  })
26
30
 
27
- it('should throttle', () => {
28
- expect(quotas.ok(context)).toBe(true)
31
+ it('should throttle', async () => {
32
+ expect(quotas.ok(context, [])).toBe(true)
29
33
 
30
- quotas.use(context, output)
31
- quotas.use(context, output)
34
+ await quotas.use(context, output)
35
+ await quotas.use(context, output)
32
36
 
33
- expect(quotas.ok(context)).toBe(false)
37
+ expect(quotas.ok(context, [])).toBe(false)
34
38
  })
35
39
 
36
40
  it('should unblock after cooldown', async () => {
37
- quotas.use(context, output)
38
- quotas.use(context, output)
41
+ await quotas.use(context, output)
42
+ await quotas.use(context, output)
39
43
 
40
- expect(quotas.ok(context)).toBe(false)
44
+ expect(quotas.ok(context, [])).toBe(false)
41
45
 
42
46
  await timeout(configuration.cooldown)
43
47
 
44
- expect(quotas.ok(context)).toBe(true)
48
+ expect(quotas.ok(context, [])).toBe(true)
45
49
  })
46
50
 
47
- it('should reset after interval', async () => {
48
- quotas.use(context, output)
51
+ it('should count one request per key and interval', async () => {
52
+ await quotas.use(context, output)
53
+
54
+ expect(counted).toStrictEqual([
55
+ { name: expect.any(String), interval: configuration.interval, amount: 1 }
56
+ ])
57
+ })
58
+ })
59
+
60
+ describe('group', () => {
61
+ beforeEach(() => {
62
+ context = createContext()
63
+ configuration = createConfiguration()
64
+ })
49
65
 
50
- // pseudo-synchronous intervals start somewhere between 0 and interval
51
- await timeout(configuration.interval * 2)
66
+ it('should block on what the group counted, not on what this process did', async () => {
67
+ // one request here, but the group is already at the limit
68
+ quotas = Quotas.create(configuration, createCounter(() => configuration.requests))
52
69
 
53
- quotas.use(context, output)
70
+ await quotas.use(context, output)
54
71
 
55
- expect(quotas.ok(context)).toBe(true)
72
+ expect(quotas.ok(context, [])).toBe(false)
73
+ })
74
+
75
+ it('should not block below the limit', async () => {
76
+ quotas = Quotas.create(configuration, createCounter(() => configuration.requests - 1))
77
+
78
+ await quotas.use(context, output)
79
+
80
+ expect(quotas.ok(context, [])).toBe(true)
56
81
  })
57
82
  })
58
83
 
59
84
  describe('path', () => {
60
85
  beforeEach(() => {
61
86
  configuration = createConfiguration()
62
- quotas = Quotas.create(configuration)
87
+ quotas = Quotas.create(configuration, createCounter())
63
88
  })
64
89
 
65
- it('should have separate quotas', () => {
90
+ it('should have separate quotas', async () => {
66
91
  const one = createContext({ url: new URL('http://localhost/one/') })
67
92
  const two = createContext({ url: new URL('http://localhost/two/') })
68
93
 
69
- quotas.use(one, output)
70
- quotas.use(one, output)
94
+ await quotas.use(one, output)
95
+ await quotas.use(one, output)
71
96
 
72
- expect(quotas.ok(one)).toBe(false)
73
- expect(quotas.ok(two)).toBe(true)
97
+ expect(quotas.ok(one, [])).toBe(false)
98
+ expect(quotas.ok(two, [])).toBe(true)
74
99
 
75
- quotas.use(two, output)
76
- quotas.use(two, output)
100
+ await quotas.use(two, output)
101
+ await quotas.use(two, output)
77
102
 
78
- expect(quotas.ok(two)).toBe(false)
103
+ expect(quotas.ok(two, [])).toBe(false)
79
104
  })
80
105
  })
81
106
 
82
107
  describe('ip', () => {
83
108
  beforeEach(() => {
84
109
  configuration = createConfiguration({ key: [{ method: 'ip' }] })
85
- quotas = Quotas.create(configuration)
110
+ quotas = Quotas.create(configuration, createCounter())
86
111
  })
87
112
 
88
- it('should have separate quotas', () => {
113
+ it('should have separate quotas', async () => {
89
114
  const one = createContext({ request: { headers: { 'x-forwarded-for': '1.1.1.1' } } })
90
115
  const two = createContext({ request: { headers: { 'x-forwarded-for': '2.2.2.2' } } })
91
116
 
92
- quotas.use(one, output)
93
- quotas.use(one, output)
117
+ await quotas.use(one, output)
118
+ await quotas.use(one, output)
94
119
 
95
- expect(quotas.ok(one)).toBe(false)
96
- expect(quotas.ok(two)).toBe(true)
120
+ expect(quotas.ok(one, [])).toBe(false)
121
+ expect(quotas.ok(two, [])).toBe(true)
97
122
 
98
- quotas.use(two, output)
99
- quotas.use(two, output)
123
+ await quotas.use(two, output)
124
+ await quotas.use(two, output)
100
125
 
101
- expect(quotas.ok(two)).toBe(false)
126
+ expect(quotas.ok(two, [])).toBe(false)
127
+ })
128
+ })
129
+
130
+ describe('route', () => {
131
+ it('should key on the route, not on the path it came in on', async () => {
132
+ quotas = Quotas.create(createConfiguration({ key: [{ method: 'route' }] }),
133
+ createCounter(), '/users/:id')
134
+
135
+ const one = createContext({ url: new URL('http://localhost/users/1/') })
136
+ const two = createContext({ url: new URL('http://localhost/users/2/') })
137
+
138
+ await quotas.use(one, output)
139
+ await quotas.use(two, output)
140
+
141
+ // two paths, one route, one quota — which is the whole difference from `path`
142
+ expect(counted[0].name).toBe(counted[1].name)
143
+ expect(quotas.ok(one, [])).toBe(false)
144
+ })
145
+
146
+ it('should have separate quotas per route', async () => {
147
+ const users = Quotas.create(createConfiguration({ key: [{ method: 'route' }] }),
148
+ createCounter(), '/users/:id')
149
+
150
+ const posts = Quotas.create(createConfiguration({ key: [{ method: 'route' }] }),
151
+ createCounter(), '/posts/:id')
152
+
153
+ context = createContext({ url: new URL('http://localhost/users/1/') })
154
+
155
+ await users.use(context, output)
156
+ await posts.use(context, output)
157
+
158
+ expect(counted[0].name).not.toBe(counted[1].name)
159
+ })
160
+ })
161
+
162
+ describe('segment', () => {
163
+ beforeEach(() => {
164
+ configuration = createConfiguration({ key: [{ method: 'segment', options: 'id' }] })
165
+ quotas = Quotas.create(configuration, createCounter())
166
+ })
167
+
168
+ it('should have separate quotas per segment value', async () => {
169
+ context = createContext()
170
+
171
+ const one = [{ name: 'id', value: '1' }]
172
+ const two = [{ name: 'id', value: '2' }]
173
+
174
+ quotas.ok(context, one)
175
+ await quotas.use(context, output)
176
+ quotas.ok(context, one)
177
+ await quotas.use(context, output)
178
+
179
+ expect(quotas.ok(context, one)).toBe(false)
180
+ expect(quotas.ok(context, two)).toBe(true)
181
+ })
182
+
183
+ it('should count under the key preflight saw, which settle cannot recompute', async () => {
184
+ const one = createContext()
185
+ const two = createContext()
186
+
187
+ quotas.ok(one, [{ name: 'id', value: '1' }])
188
+ await quotas.use(one, output)
189
+
190
+ quotas.ok(two, [{ name: 'id', value: '2' }])
191
+ await quotas.use(two, output)
192
+
193
+ // settle is handed no parameters, so recomputing there would hash an empty
194
+ // segment both times and the two requests would count as one key
195
+ expect(counted[0].name).not.toBe(counted[1].name)
196
+ })
197
+ })
198
+
199
+ describe('identity', () => {
200
+ beforeEach(() => {
201
+ configuration = createConfiguration({ key: [{ method: 'identity' }] })
202
+ quotas = Quotas.create(configuration, createCounter())
203
+ })
204
+
205
+ it('should have separate quotas per identity', async () => {
206
+ const one = createContext({ identity: { id: 'one' } })
207
+ const two = createContext({ identity: { id: 'two' } })
208
+
209
+ await quotas.use(one, output)
210
+ await quotas.use(one, output)
211
+
212
+ expect(quotas.ok(one, [])).toBe(false)
213
+ expect(quotas.ok(two, [])).toBe(true)
214
+ })
215
+
216
+ it('should put every anonymous request in one quota', async () => {
217
+ const one = createContext({ identity: null })
218
+ const two = createContext()
219
+
220
+ await quotas.use(one, output)
221
+ await quotas.use(two, output)
222
+
223
+ expect(quotas.ok(one, [])).toBe(false)
224
+ expect(quotas.ok(two, [])).toBe(false)
102
225
  })
103
226
  })
104
227
 
@@ -106,31 +229,69 @@ describe('status', () => {
106
229
  beforeEach(() => {
107
230
  context = createContext()
108
231
  configuration = createConfiguration({ condition: [{ method: 'status', options: 404 }] })
109
- quotas = Quotas.create(configuration)
232
+ quotas = Quotas.create(configuration, createCounter())
233
+ })
234
+
235
+ it('should not throttle on 200', async () => {
236
+ await quotas.use(context, output)
237
+
238
+ expect(quotas.ok(context, [])).toBe(true)
110
239
  })
111
240
 
112
- it('should not throttle on 200', () => {
113
- quotas.use(context, output)
241
+ it('should not count what the condition rejects', async () => {
242
+ await quotas.use(context, output)
114
243
 
115
- expect(quotas.ok(context)).toBe(true)
244
+ expect(counted).toStrictEqual([])
116
245
  })
117
246
 
118
- it('should throttle on 404', () => {
119
- quotas.use(context, { status: 404 })
120
- quotas.use(context, { status: 404 })
247
+ it('should throttle on 404', async () => {
248
+ await quotas.use(context, { status: 404 })
249
+ await quotas.use(context, { status: 404 })
121
250
 
122
- expect(quotas.ok(context)).toBe(false)
251
+ expect(quotas.ok(context, [])).toBe(false)
123
252
  })
124
253
  })
125
254
 
126
255
  function createConfiguration (properties?: Partial<Configuration>): Configuration {
127
- return Object.assign({ key: [{ method: 'path' }], requests: 2, interval: 10, cooldown: 10 }, properties)
256
+ return Object.assign({ key: [{ method: 'path' }], requests: 2, interval: 10, cooldown: 10 },
257
+ properties)
128
258
  }
129
259
 
130
260
  function createContext (properties?: any): Context {
131
261
  return Object.assign({ url: new URL('http://localhost/') }, properties) as unknown as Context
132
262
  }
133
263
 
264
+ /**
265
+ * Stands in for `exposition.stash`. Counting the way one participant does is enough
266
+ * here: what a counter reports is comcount's business, and this asserts what the
267
+ * quotas do with whatever number comes back.
268
+ */
269
+ function createCounter (reply?: (count: number) => number): Promise<Remote> {
270
+ const counts: Record<string, number> = {}
271
+
272
+ const remote = {
273
+ invoke: async (endpoint: string, request: { input: Request }): Promise<number> => {
274
+ expect(endpoint).toBe('count')
275
+
276
+ const { name, interval, amount } = request.input
277
+
278
+ counted.push({ name, interval, amount })
279
+
280
+ counts[name] = (counts[name] ?? 0) + amount
281
+
282
+ return reply === undefined ? counts[name] : reply(counts[name])
283
+ }
284
+ }
285
+
286
+ return Promise.resolve(remote as unknown as Remote)
287
+ }
288
+
134
289
  async function timeout (ms: number): Promise<void> {
135
290
  await setTimeout(ms * 1.2)
136
291
  }
292
+
293
+ interface Request {
294
+ name: string
295
+ interval: number
296
+ amount: number
297
+ }
@@ -1,75 +1,98 @@
1
1
  import { console } from 'openspan'
2
2
  import { Keys } from './Keys'
3
- import { Quota } from './Quota'
4
- import { Interval } from './Interval'
3
+ import type { Remote } from '@toa.io/core'
5
4
  import type { Configuration } from './Configuration'
5
+ import type { Parameter } from '../../../../RTD'
6
6
  import type { Input as Context, Output } from '../../../../io'
7
7
 
8
+ /**
9
+ * Quota usage counted across every gateway process, and blocking decided in this one.
10
+ *
11
+ * The count comes from the `exposition.stash` component, which counts through Redis
12
+ * and hands back a lower bound on what the whole group has reached. Blocking stays
13
+ * local: every process sees the same number and reaches the same conclusion on its
14
+ * own, and a process that cannot reach Redis still enforces what it has seen itself.
15
+ */
8
16
  export class Quotas {
9
17
  private readonly requests: number
18
+ private readonly interval: number
10
19
  private readonly cooldown: number
11
- private readonly interval: Interval
12
20
  private readonly keys: Keys
13
- private readonly quotas: Record<string, Quota | undefined> = {}
21
+ private readonly counter: Promise<Remote>
14
22
  private readonly blocked: Record<string, boolean | undefined> = {}
23
+ private readonly timers = new Set<NodeJS.Timeout>()
24
+
25
+ /**
26
+ * The key computed at preflight, for settle to count against.
27
+ *
28
+ * `segment` reads the route parameters, which settle is not given, and `identity`
29
+ * can be refreshed between the two — so recomputing there would key a request
30
+ * differently than it was checked, and blocking would quietly stop working.
31
+ */
32
+ private readonly keyed = new WeakMap<Context, string>()
33
+ private remote: Remote | null = null
15
34
 
16
35
  public constructor (options: Options) {
17
36
  this.requests = options.requests
18
- this.cooldown = options.cooldown
19
37
  this.interval = options.interval
38
+ this.cooldown = options.cooldown
20
39
  this.keys = options.keys
21
-
22
- this.interval.on('tick', this.reset)
40
+ this.counter = options.counter
23
41
  }
24
42
 
25
- public static create (configuration: Configuration): Quotas {
26
- const requests = configuration.requests
27
- const cooldown = configuration.cooldown
28
- const keys = Keys.create(configuration.key, configuration.condition)
29
- const interval = new Interval(configuration.interval)
43
+ public static create (configuration: Configuration, counter: Promise<Remote>,
44
+ route: string = ''): Quotas {
45
+ const { requests, interval, cooldown } = configuration
46
+ const keys = Keys.create(configuration.key, configuration.condition, route)
30
47
 
31
- return new this({ requests, cooldown, keys, interval })
48
+ return new this({ requests, interval, cooldown, keys, counter })
32
49
  }
33
50
 
34
- public ok (context: Context): boolean {
35
- const key = this.keys.get(context)
51
+ public ok (context: Context, parameters: Parameter[]): boolean {
52
+ const key = this.keys.get(context, parameters)
53
+
54
+ this.keyed.set(context, key)
36
55
 
37
56
  return this.blocked[key] !== true
38
57
  }
39
58
 
40
- public use (input: Context, output: Output): void {
41
- const key = this.keys.match(input, output)
42
-
43
- if (key === null)
59
+ public async use (input: Context, output: Output): Promise<void> {
60
+ if (!this.keys.matches(input, output))
44
61
  return
45
62
 
46
- this.quotas[key] ??= new Quota(this.requests)
63
+ // preflight always runs first, and a request it throws on never reaches settle
64
+ const key = this.keyed.get(input) ?? this.keys.get(input)
65
+
66
+ this.remote ??= await this.counter
47
67
 
48
- const ok = this.quotas[key]!.use()
68
+ const count = await this.remote.invoke<number>('count',
69
+ { input: { name: key, interval: this.interval, amount: 1 } })
49
70
 
50
- if (!ok)
71
+ if (count >= this.requests)
51
72
  this.block(key)
52
73
  }
53
74
 
54
75
  public dispose (): void {
55
- this.interval.dispose()
56
- }
57
-
58
- private readonly reset = (): void => {
59
- for (const key in this.quotas) {
60
- const quota = this.quotas[key]!
76
+ for (const timer of this.timers)
77
+ clearTimeout(timer)
61
78
 
62
- if (quota.idle)
63
- delete this.quotas[key]
64
- else
65
- quota.reset()
66
- }
79
+ this.timers.clear()
67
80
  }
68
81
 
69
82
  private block (key: string): void {
83
+ if (this.blocked[key] === true)
84
+ return
85
+
70
86
  this.blocked[key] = true
71
87
 
72
- setTimeout(() => delete this.blocked[key], this.cooldown)
88
+ const timer = setTimeout(() => {
89
+ delete this.blocked[key]
90
+
91
+ this.timers.delete(timer)
92
+ }, this.cooldown)
93
+
94
+ timer.unref()
95
+ this.timers.add(timer)
73
96
 
74
97
  console.info('Quota exceeded, key blocked', { key, cooldown: this.cooldown })
75
98
  }
@@ -78,6 +101,7 @@ export class Quotas {
78
101
  interface Options {
79
102
  keys: Keys
80
103
  requests: number
104
+ interval: number
81
105
  cooldown: number
82
- interval: Interval
106
+ counter: Promise<Remote>
83
107
  }
@@ -1,5 +1,6 @@
1
+ import type { Parameter } from '../../../../../RTD'
1
2
  import type { Input as Context } from '../../../../../io'
2
3
 
3
4
  export interface Component {
4
- get: (context: Context) => string
5
+ get: (context: Context, parameters: Parameter[]) => string
5
6
  }
@@ -0,0 +1,19 @@
1
+ import type { Input } from '../../../../../io'
2
+ import type { Component } from './Component'
3
+
4
+ /**
5
+ * The authenticated identity, or nothing when the request carries none.
6
+ *
7
+ * Anonymous requests therefore share one quota between them; `ip` is the component
8
+ * that tells them apart.
9
+ */
10
+ export class Identity implements Component {
11
+ public get (context: Input): string {
12
+ return (context as AuthenticatedContext).identity?.id ?? ''
13
+ }
14
+ }
15
+
16
+ // declared here rather than imported from `auth`, the way `cache` does it
17
+ interface AuthenticatedContext extends Input {
18
+ identity?: { id?: string } | null
19
+ }
@@ -0,0 +1,19 @@
1
+ import type { Component } from './Component'
2
+
3
+ /**
4
+ * The route as declared — `/users/:id` — rather than the path a request came in on.
5
+ *
6
+ * A constant per method, resolved when the tree is built, so every request to one
7
+ * route shares a quota however many concrete paths match it.
8
+ */
9
+ export class Route implements Component {
10
+ private readonly route: string
11
+
12
+ public constructor (_: unknown, route: string) {
13
+ this.route = route
14
+ }
15
+
16
+ public get (): string {
17
+ return this.route
18
+ }
19
+ }
@@ -0,0 +1,19 @@
1
+ import assert from 'node:assert'
2
+ import type { Parameter } from '../../../../../RTD'
3
+ import type { Input as Context } from '../../../../../io'
4
+ import type { Component } from './Component'
5
+
6
+ /** The value a named route segment was bound to, or nothing when the route has none. */
7
+ export class Segment implements Component {
8
+ private readonly name: string
9
+
10
+ public constructor (name: unknown) {
11
+ assert.ok(typeof name === 'string', 'Throttle segment must be a string')
12
+
13
+ this.name = name
14
+ }
15
+
16
+ public get (_: Context, parameters: Parameter[]): string {
17
+ return parameters.find(({ name }) => name === this.name)?.value ?? ''
18
+ }
19
+ }
@@ -1,13 +1,19 @@
1
1
  import { Path } from './Path'
2
2
  import { IP } from './IP'
3
+ import { Route } from './Route'
4
+ import { Identity } from './Identity'
5
+ import { Segment } from './Segment'
3
6
  import type { KeyComponentMethod } from '../Configuration'
4
7
  import type { Component } from './Component'
5
8
 
6
- type Constructor<T> = new (options: unknown) => T
9
+ type Constructor<T> = new (options: unknown, route: string) => T
7
10
 
8
11
  export const Components: Record<KeyComponentMethod, Constructor<Component>> = {
9
12
  ip: IP,
10
- path: Path
13
+ path: Path,
14
+ route: Route,
15
+ identity: Identity,
16
+ segment: Segment
11
17
  }
12
18
 
13
19
  export type { Component }
@@ -6,4 +6,20 @@ describe('throttle', () => {
6
6
  it('should validate key', () => {
7
7
  expect(() => schemas.throttle.validate({ key: 'ip', ...rest })).not.toThrow()
8
8
  })
9
+
10
+ it('should validate every key component', () => {
11
+ for (const key of ['ip', 'path', 'route', 'identity'])
12
+ expect(() => schemas.throttle.validate({ key, ...rest })).not.toThrow()
13
+
14
+ expect(() => schemas.throttle.validate({ key: { segment: 'id' }, ...rest })).not.toThrow()
15
+ expect(() => schemas.throttle.validate({
16
+ key: ['route', { segment: 'id' }],
17
+ ...rest
18
+ })).not.toThrow()
19
+ })
20
+
21
+ it('should reject an unknown key component', () => {
22
+ expect(() => schemas.throttle.validate({ key: 'header', ...rest })).toThrow()
23
+ expect(() => schemas.throttle.validate({ key: { header: 'x-real-ip' }, ...rest })).toThrow()
24
+ })
9
25
  })
@@ -23,7 +23,7 @@ export class Octets implements DirectiveFamily<Directive> {
23
23
  if (Class === undefined)
24
24
  throw new Error(`Directive 'octets:${name}' is not implemented`)
25
25
 
26
- this.discovery ??= remotes.discover('octets', 'storage')
26
+ this.discovery ??= remotes.discover('exposition', 'octets')
27
27
 
28
28
  return new Class(value, this.discovery, remotes)
29
29
  }
@@ -73,7 +73,9 @@ export class Execution extends Readable {
73
73
  report.status = 'completed'
74
74
 
75
75
  if (result instanceof Error) {
76
- report.error = result
76
+ // an Error cannot be serialized where it sits, and the encoders only unwrap
77
+ // one they are handed directly — this one travels nested inside the report
78
+ report.error = { ...result }
77
79
  this.interrupted = true
78
80
  } else if (result !== undefined) {
79
81
  report.output = result
@@ -126,5 +128,5 @@ interface Report {
126
128
  step: string
127
129
  status?: 'completed' | 'exception'
128
130
  output?: unknown
129
- error?: Error
131
+ error?: Record<string, unknown>
130
132
  }
@@ -15,7 +15,9 @@ export declare class DirectivesFactory implements RTD.DirectiveFactory {
15
15
  private readonly mandatory;
16
16
  private readonly instances;
17
17
  constructor(families: RTD.DirectiveFamily[], remotes: Remotes);
18
- create(declarations: RTD.syntax.Directive[]): Directives;
18
+ create(declarations: RTD.syntax.Directive[], route?: string): Directives;
19
19
  dispose(): void;
20
+ /** Mandatory families first, in their own order; everything else keeps its own. */
21
+ private rank;
20
22
  }
21
23
  export declare const shortcuts: RTD.syntax.Shortcuts;
@@ -46,16 +46,15 @@ class DirectivesFactory {
46
46
  }
47
47
  this.remotes = remotes;
48
48
  }
49
- create(declarations) {
49
+ create(declarations, route = '') {
50
50
  const groups = {};
51
51
  const mandatory = new Set(this.mandatory);
52
- declarations.sort((a, b) => (mandatory.has(b.family) ? 1 : 0) - (mandatory.has(a.family) ? 1 : 0));
53
52
  const names = {};
54
53
  for (const declaration of declarations) {
55
54
  const family = this.families[declaration.family];
56
55
  if (family === undefined)
57
56
  throw new Error(`Directive family '${declaration.family}' is not found`);
58
- const directive = family.create(declaration.name, declaration.value, this.remotes);
57
+ const directive = family.create(declaration.name, declaration.value, this.remotes, route);
59
58
  groups[family.name] ??= [];
60
59
  groups[family.name].push(directive);
61
60
  names[family.name] ??= [];
@@ -75,6 +74,11 @@ class DirectivesFactory {
75
74
  directives,
76
75
  names: names[family]
77
76
  });
77
+ // Mandatory families run in the order they are registered in, not in the order a
78
+ // manifest happens to mention them: `auth` must resolve the identity before `io`
79
+ // can key a quota on it, and a request it denies should not reach `io` at all.
80
+ // The rest keep the order they were declared in, the sort being stable.
81
+ sets.sort((a, b) => this.rank(a.family.name) - this.rank(b.family.name));
78
82
  const directives = new Directives(sets);
79
83
  this.instances.push(directives);
80
84
  return directives;
@@ -83,6 +87,11 @@ class DirectivesFactory {
83
87
  for (const directives of this.instances)
84
88
  directives.dispose();
85
89
  }
90
+ /** Mandatory families first, in their own order; everything else keeps its own. */
91
+ rank(family) {
92
+ const index = this.mandatory.indexOf(family);
93
+ return index === -1 ? this.mandatory.length : index;
94
+ }
86
95
  }
87
96
  exports.DirectivesFactory = DirectivesFactory;
88
97
  function options(set, stage) {