@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.
- package/CHANGELOG.md +26 -0
- package/components/{octets.storage → exposition.octets}/manifest.toa.yaml +2 -2
- package/components/exposition.stash/manifest.toa.yaml +12 -0
- package/components/exposition.stash/operations/count.js +7 -0
- package/components/identity.basic/operations/tsconfig.tsbuildinfo +1 -1
- package/components/identity.credentials/operations/tsconfig.tsbuildinfo +1 -1
- package/components/identity.federation/operations/tsconfig.tsbuildinfo +1 -1
- package/components/identity.keys/operations/tsconfig.tsbuildinfo +1 -1
- package/components/identity.otp/operations/tsconfig.tsbuildinfo +1 -1
- package/components/identity.passkeys/operations/tsconfig.tsbuildinfo +1 -1
- package/components/identity.roles/operations/tsconfig.tsbuildinfo +1 -1
- package/components/identity.tokens/operations/tsconfig.tsbuildinfo +1 -1
- package/documentation/io.md +45 -17
- package/documentation/notes/throttling.md +6 -0
- package/features/access.feature +55 -0
- package/features/io.throttle.feature +127 -0
- package/features/map.feature +5 -5
- package/features/octets.cloudinary.feature +12 -9
- package/features/octets.download.feature +4 -3
- package/features/octets.entries.feature +5 -3
- package/features/octets.feature +4 -4
- package/features/octets.head.feature +1 -1
- package/features/octets.location.feature +1 -1
- package/features/octets.meta.feature +2 -2
- package/features/octets.workflows.feature +7 -7
- package/package.json +8 -8
- package/schemas/io/throttle.cos.yaml +9 -2
- package/source/Directive.test.ts +72 -7
- package/source/Directive.ts +15 -5
- package/source/HTTP/messages.ts +5 -1
- package/source/Interception.ts +8 -0
- package/source/RTD/Context.ts +3 -0
- package/source/RTD/Directives.ts +1 -1
- package/source/RTD/Tree.ts +17 -0
- package/source/RTD/factory.ts +11 -1
- package/source/directives/cors/CORS.ts +26 -9
- package/source/directives/io/Directive.ts +4 -2
- package/source/directives/io/IO.ts +18 -7
- package/source/directives/io/Input.ts +5 -1
- package/source/directives/io/Throttle.ts +8 -6
- package/source/directives/io/lib/throttle/Configuration.test.ts +21 -0
- package/source/directives/io/lib/throttle/Configuration.ts +11 -4
- package/source/directives/io/lib/throttle/Keys.ts +12 -11
- package/source/directives/io/lib/throttle/Quotas.test.ts +207 -46
- package/source/directives/io/lib/throttle/Quotas.ts +59 -35
- package/source/directives/io/lib/throttle/components/Component.ts +2 -1
- package/source/directives/io/lib/throttle/components/Identity.ts +19 -0
- package/source/directives/io/lib/throttle/components/Route.ts +19 -0
- package/source/directives/io/lib/throttle/components/Segment.ts +19 -0
- package/source/directives/io/lib/throttle/components/index.ts +8 -2
- package/source/directives/io/schemas.test.ts +16 -0
- package/source/directives/octets/Octets.ts +1 -1
- package/source/directives/octets/workflows/Execution.ts +4 -2
- package/transpiled/Directive.d.ts +3 -1
- package/transpiled/Directive.js +12 -3
- package/transpiled/Directive.js.map +1 -1
- package/transpiled/HTTP/messages.js +4 -1
- package/transpiled/HTTP/messages.js.map +1 -1
- package/transpiled/Interception.d.ts +2 -0
- package/transpiled/Interception.js +4 -0
- package/transpiled/Interception.js.map +1 -1
- package/transpiled/RTD/Context.d.ts +2 -0
- package/transpiled/RTD/Directives.d.ts +1 -1
- package/transpiled/RTD/Tree.js +14 -0
- package/transpiled/RTD/Tree.js.map +1 -1
- package/transpiled/RTD/factory.js +8 -1
- package/transpiled/RTD/factory.js.map +1 -1
- package/transpiled/directives/cors/CORS.d.ts +8 -1
- package/transpiled/directives/cors/CORS.js +23 -9
- package/transpiled/directives/cors/CORS.js.map +1 -1
- package/transpiled/directives/io/Directive.d.ts +4 -2
- package/transpiled/directives/io/IO.d.ts +6 -3
- package/transpiled/directives/io/IO.js +11 -5
- package/transpiled/directives/io/IO.js.map +1 -1
- package/transpiled/directives/io/Input.js +5 -1
- package/transpiled/directives/io/Input.js.map +1 -1
- package/transpiled/directives/io/Throttle.d.ts +5 -3
- package/transpiled/directives/io/Throttle.js +6 -6
- package/transpiled/directives/io/Throttle.js.map +1 -1
- package/transpiled/directives/io/lib/throttle/Configuration.d.ts +6 -2
- package/transpiled/directives/io/lib/throttle/Configuration.js +5 -2
- package/transpiled/directives/io/lib/throttle/Configuration.js.map +1 -1
- package/transpiled/directives/io/lib/throttle/Keys.d.ts +4 -3
- package/transpiled/directives/io/lib/throttle/Keys.js +7 -10
- package/transpiled/directives/io/lib/throttle/Keys.js.map +1 -1
- package/transpiled/directives/io/lib/throttle/Quotas.d.ts +27 -8
- package/transpiled/directives/io/lib/throttle/Quotas.js +47 -31
- package/transpiled/directives/io/lib/throttle/Quotas.js.map +1 -1
- package/transpiled/directives/io/lib/throttle/components/Component.d.ts +2 -1
- package/transpiled/directives/io/lib/throttle/components/Identity.d.ts +11 -0
- package/transpiled/directives/io/lib/throttle/components/Identity.js +16 -0
- package/transpiled/directives/io/lib/throttle/components/Identity.js.map +1 -0
- package/transpiled/directives/io/lib/throttle/components/Route.d.ts +12 -0
- package/transpiled/directives/io/lib/throttle/components/Route.js +20 -0
- package/transpiled/directives/io/lib/throttle/components/Route.js.map +1 -0
- package/transpiled/directives/io/lib/throttle/components/Segment.d.ts +9 -0
- package/transpiled/directives/io/lib/throttle/components/Segment.js +20 -0
- package/transpiled/directives/io/lib/throttle/components/Segment.js.map +1 -0
- package/transpiled/directives/io/lib/throttle/components/index.d.ts +1 -1
- package/transpiled/directives/io/lib/throttle/components/index.js +7 -1
- package/transpiled/directives/io/lib/throttle/components/index.js.map +1 -1
- package/transpiled/directives/octets/Octets.js +1 -1
- package/transpiled/directives/octets/Octets.js.map +1 -1
- package/transpiled/directives/octets/workflows/Execution.js +3 -1
- package/transpiled/directives/octets/workflows/Execution.js.map +1 -1
- package/transpiled/tsconfig.tsbuildinfo +1 -1
- package/features/auth.issue.feature +0 -32
- package/source/directives/io/lib/throttle/Interval.ts +0 -40
- package/source/directives/io/lib/throttle/Quota.ts +0 -22
- package/transpiled/directives/io/lib/throttle/Interval.d.ts +0 -10
- package/transpiled/directives/io/lib/throttle/Interval.js +0 -39
- package/transpiled/directives/io/lib/throttle/Interval.js.map +0 -1
- package/transpiled/directives/io/lib/throttle/Quota.d.ts +0 -8
- package/transpiled/directives/io/lib/throttle/Quota.js +0 -22
- package/transpiled/directives/io/lib/throttle/Quota.js.map +0 -1
- /package/components/{octets.storage → exposition.octets}/operations/delete.js +0 -0
- /package/components/{octets.storage → exposition.octets}/operations/get.js +0 -0
- /package/components/{octets.storage → exposition.octets}/operations/head.js +0 -0
- /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
|
-
|
|
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
|
|
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
|
-
|
|
51
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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 },
|
|
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 {
|
|
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
|
|
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
|
|
26
|
-
|
|
27
|
-
const cooldown = configuration
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
68
|
+
const count = await this.remote.invoke<number>('count',
|
|
69
|
+
{ input: { name: key, interval: this.interval, amount: 1 } })
|
|
49
70
|
|
|
50
|
-
if (
|
|
71
|
+
if (count >= this.requests)
|
|
51
72
|
this.block(key)
|
|
52
73
|
}
|
|
53
74
|
|
|
54
75
|
public dispose (): void {
|
|
55
|
-
this.
|
|
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
|
-
|
|
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(() =>
|
|
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
|
-
|
|
106
|
+
counter: Promise<Remote>
|
|
83
107
|
}
|
|
@@ -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('
|
|
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
|
-
|
|
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?:
|
|
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;
|
package/transpiled/Directive.js
CHANGED
|
@@ -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) {
|