@next-buildr/payload 2.0.0 → 3.0.0-rc.0

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 (52) hide show
  1. package/README.md +7 -0
  2. package/UPGRADING.md +322 -0
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/plugin/access.d.ts +7 -0
  5. package/dist/plugin/access.d.ts.map +1 -1
  6. package/dist/plugin/access.js +19 -0
  7. package/dist/plugin/collections/templates.d.ts.map +1 -1
  8. package/dist/plugin/collections/templates.js +9 -3
  9. package/dist/plugin/endpoints/context.d.ts +8 -0
  10. package/dist/plugin/endpoints/context.d.ts.map +1 -1
  11. package/dist/plugin/endpoints/forms-captcha.test.d.ts +2 -0
  12. package/dist/plugin/endpoints/forms-captcha.test.d.ts.map +1 -0
  13. package/dist/plugin/endpoints/forms-captcha.test.js +676 -0
  14. package/dist/plugin/endpoints/forms.d.ts.map +1 -1
  15. package/dist/plugin/endpoints/forms.js +110 -12
  16. package/dist/plugin/endpoints/publish.d.ts.map +1 -1
  17. package/dist/plugin/endpoints/publish.js +4 -0
  18. package/dist/plugin/endpoints/save.d.ts.map +1 -1
  19. package/dist/plugin/endpoints/save.js +3 -0
  20. package/dist/plugin/forms/captcha.d.ts +45 -0
  21. package/dist/plugin/forms/captcha.d.ts.map +1 -0
  22. package/dist/plugin/forms/captcha.js +195 -0
  23. package/dist/plugin/forms/captcha.test.d.ts +2 -0
  24. package/dist/plugin/forms/captcha.test.d.ts.map +1 -0
  25. package/dist/plugin/forms/captcha.test.js +262 -0
  26. package/dist/plugin/forms/client-ip.d.ts +8 -0
  27. package/dist/plugin/forms/client-ip.d.ts.map +1 -0
  28. package/dist/plugin/forms/client-ip.js +41 -0
  29. package/dist/plugin/forms/client-ip.test.d.ts +2 -0
  30. package/dist/plugin/forms/client-ip.test.d.ts.map +1 -0
  31. package/dist/plugin/forms/client-ip.test.js +45 -0
  32. package/dist/plugin/forms/rate-limit.d.ts +2 -0
  33. package/dist/plugin/forms/rate-limit.d.ts.map +1 -1
  34. package/dist/plugin/forms/rate-limit.js +10 -1
  35. package/dist/plugin/hooks/author-code.test.d.ts +2 -0
  36. package/dist/plugin/hooks/author-code.test.d.ts.map +1 -0
  37. package/dist/plugin/hooks/author-code.test.js +285 -0
  38. package/dist/plugin/hooks/layout-hook.d.ts +1 -1
  39. package/dist/plugin/hooks/layout-hook.d.ts.map +1 -1
  40. package/dist/plugin/hooks/layout-hook.js +12 -2
  41. package/dist/plugin/hooks/process-layout.d.ts +10 -0
  42. package/dist/plugin/hooks/process-layout.d.ts.map +1 -1
  43. package/dist/plugin/hooks/process-layout.js +94 -1
  44. package/dist/plugin/hooks/read-layout.d.ts +1 -1
  45. package/dist/plugin/hooks/read-layout.d.ts.map +1 -1
  46. package/dist/plugin/index.d.ts.map +1 -1
  47. package/dist/plugin/index.js +20 -1
  48. package/dist/plugin/options.d.ts +11 -0
  49. package/dist/plugin/options.d.ts.map +1 -1
  50. package/dist/plugin/options.js +49 -1
  51. package/dist/plugin/plugin.test.js +81 -2
  52. package/package.json +13 -11
@@ -0,0 +1,262 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { ancestorFormHasCaptcha, CAPTCHA_ACTION, CAPTCHA_FIELD, CAPTCHA_TYPE, captchaCData, createCaptchaGate, createTurnstileVerifier, findFormCaptchas, } from './captcha.js';
3
+ import { createMemoryRateLimiter } from './rate-limit.js';
4
+ // Pin these exact UTF-8 JSON tuple vectors in the components package too.
5
+ const SIMPLE = '39d82018d38dd1145f9903261b443a1556acf5f672cc5a15e8fa61999b15d6bd';
6
+ const UNICODE = 'ea58a41440286e8b7194961ae8b63704e6ceca9e305a190cf7637bc8d6dc4cc5';
7
+ const SECRET = 'private-turnstile-secret';
8
+ const TOKEN = 'opaque-token';
9
+ const success = { success: true, hostname: 'example.com', action: CAPTCHA_ACTION, cdata: SIMPLE };
10
+ const signal = () => new AbortController().signal;
11
+ const run = (fetcher, input = TOKEN, log = vi.fn()) => createTurnstileVerifier({ secretKey: SECRET, fetch: fetcher, log }).verify({
12
+ token: input,
13
+ signal: signal(),
14
+ });
15
+ describe('captcha contract', () => {
16
+ it('pins constants and simple and Unicode golden vectors', () => {
17
+ expect([CAPTCHA_FIELD, CAPTCHA_ACTION, CAPTCHA_TYPE]).toEqual([
18
+ 'buildr-captcha',
19
+ 'buildr_form',
20
+ 'buildr/captcha',
21
+ ]);
22
+ expect(captchaCData('pages:1', 'form000001')).toBe(SIMPLE);
23
+ expect(captchaCData('pages:za\u017c\u00f3\u0142\u0107-\u6771\u4eac', 'formularz-\u{1f6e1}\ufe0f')).toBe(UNICODE);
24
+ });
25
+ it('gates per instance and releases idempotently', () => {
26
+ const gate = createCaptchaGate(1);
27
+ const release = gate.tryAcquire();
28
+ expect(release).not.toBeNull();
29
+ expect(gate.tryAcquire()).toBeNull();
30
+ expect(createCaptchaGate(1).tryAcquire()).not.toBeNull();
31
+ release?.();
32
+ release?.();
33
+ expect(gate.tryAcquire()).not.toBeNull();
34
+ expect(gate.tryAcquire()).toBeNull();
35
+ });
36
+ });
37
+ describe('Turnstile verifier', () => {
38
+ it('uses the exact fixed, uncached, non-redirecting POST with only secret and response', async () => {
39
+ const fetcher = vi.fn().mockResolvedValue(Response.json(success));
40
+ const abort = signal();
41
+ const result = await createTurnstileVerifier({ secretKey: SECRET, fetch: fetcher }).verify({
42
+ token: TOKEN,
43
+ signal: abort,
44
+ });
45
+ expect(result).toEqual({
46
+ ok: true,
47
+ hostname: 'example.com',
48
+ action: CAPTCHA_ACTION,
49
+ cdata: SIMPLE,
50
+ });
51
+ expect(fetcher).toHaveBeenCalledOnce();
52
+ const [url, init] = fetcher.mock.calls[0] ?? [];
53
+ expect(url).toBe('https://challenges.cloudflare.com/turnstile/v0/siteverify');
54
+ expect(init).toEqual({
55
+ method: 'POST',
56
+ body: new URLSearchParams({ secret: SECRET, response: TOKEN }),
57
+ redirect: 'error',
58
+ cache: 'no-store',
59
+ signal: abort,
60
+ });
61
+ const body = init?.body;
62
+ expect(body).toBeInstanceOf(URLSearchParams);
63
+ expect(body instanceof URLSearchParams ? [...body.entries()] : []).toEqual([
64
+ ['secret', SECRET],
65
+ ['response', TOKEN],
66
+ ]);
67
+ });
68
+ it.each(['', 'a b', 'non-ASCII-\u00e9', 'valid\n', 'a'.repeat(2049), ['one', 'two'], 123, null])('makes no outbound call for malformed token %j', async (token) => {
69
+ const fetcher = vi.fn();
70
+ expect(await run(fetcher, token)).toEqual({ ok: false, reason: 'invalid' });
71
+ expect(fetcher).not.toHaveBeenCalled();
72
+ });
73
+ it.each([
74
+ ['missing-input-secret', 'unavailable', 'configuration'],
75
+ ['invalid-input-secret', 'unavailable', 'configuration'],
76
+ ['timeout-or-duplicate', 'expired-or-reused', 'expired-or-reused'],
77
+ ['internal-error', 'unavailable', 'unavailable'],
78
+ ['invalid-input-response', 'invalid', 'invalid'],
79
+ ['unknown-code', 'invalid', 'invalid'],
80
+ ])('maps Cloudflare %s safely', async (code, reason, loggedReason) => {
81
+ const log = vi.fn();
82
+ expect(await run(vi
83
+ .fn()
84
+ .mockResolvedValue(Response.json({ success: false, 'error-codes': [code] })), TOKEN, log)).toEqual({ ok: false, reason });
85
+ expect(log).toHaveBeenCalledWith({ reason: loggedReason, codes: [code] });
86
+ expect(JSON.stringify(log.mock.calls)).not.toContain(SECRET);
87
+ expect(JSON.stringify(log.mock.calls)).not.toContain(TOKEN);
88
+ });
89
+ it('filters and caps logged codes and gives configuration errors precedence', async () => {
90
+ const log = vi.fn();
91
+ const codes = [
92
+ TOKEN,
93
+ SECRET,
94
+ '<script>',
95
+ 'a'.repeat(65),
96
+ 'UPPER',
97
+ 'bad-code\n',
98
+ 'timeout-or-duplicate',
99
+ 'invalid-input-secret',
100
+ ...Array.from({ length: 20 }, () => 'bad-request'),
101
+ ];
102
+ const response = { success: false, 'error-codes': codes, token: TOKEN, secret: SECRET };
103
+ expect(await run(vi.fn().mockResolvedValue(Response.json(response)), TOKEN, log)).toEqual({ ok: false, reason: 'unavailable' });
104
+ expect(log.mock.calls[0]?.[0].codes).toHaveLength(10);
105
+ expect(log.mock.calls[0]?.[0].codes).toEqual([
106
+ 'timeout-or-duplicate',
107
+ 'invalid-input-secret',
108
+ ...Array.from({ length: 8 }, () => 'bad-request'),
109
+ ]);
110
+ expect(JSON.stringify(log.mock.calls)).not.toContain(SECRET);
111
+ expect(JSON.stringify(log.mock.calls)).not.toContain(TOKEN);
112
+ });
113
+ it.each([302, 400, 500])('fails closed for HTTP %s', async (status) => {
114
+ expect(await run(vi.fn().mockResolvedValue(new Response('', { status })))).toEqual({ ok: false, reason: 'unavailable' });
115
+ });
116
+ it.each([
117
+ new TypeError('redirect'),
118
+ new DOMException('abort', 'AbortError'),
119
+ new DOMException('timeout', 'TimeoutError'),
120
+ new Error(SECRET),
121
+ ])('does not log thrown transport errors', async (error) => {
122
+ const log = vi.fn();
123
+ expect(await run(vi.fn().mockRejectedValue(error), TOKEN, log)).toEqual({
124
+ ok: false,
125
+ reason: 'unavailable',
126
+ });
127
+ expect(log).not.toHaveBeenCalled();
128
+ });
129
+ it.each([
130
+ '{',
131
+ '{}',
132
+ 'null',
133
+ JSON.stringify({ success: 'true' }),
134
+ JSON.stringify({ success: true, hostname: 1 }),
135
+ JSON.stringify({ success: true, hostname: 'example.com' }),
136
+ JSON.stringify({ success: false, 'error-codes': [1] }),
137
+ ])('rejects malformed response %s', async (body) => {
138
+ expect(await run(vi.fn().mockResolvedValue(new Response(body)))).toEqual({
139
+ ok: false,
140
+ reason: 'unavailable',
141
+ });
142
+ });
143
+ it('defaults missing cdata to empty for the endpoint to reject', async () => {
144
+ expect(await run(vi.fn().mockResolvedValue(Response.json({ ...success, cdata: undefined })))).toMatchObject({ ok: true, cdata: '' });
145
+ });
146
+ it('accepts a chunked response at the byte limit and rejects a cumulative overflow', async () => {
147
+ const bytes = new TextEncoder().encode(JSON.stringify(success));
148
+ for (const extra of [0, 1]) {
149
+ const cancel = vi.fn();
150
+ const body = new ReadableStream({
151
+ start(controller) {
152
+ controller.enqueue(bytes.subarray(0, 10));
153
+ controller.enqueue(bytes.subarray(10));
154
+ controller.enqueue(new TextEncoder().encode(' '.repeat(16 * 1024 - bytes.byteLength + extra)));
155
+ if (extra === 0)
156
+ controller.close();
157
+ },
158
+ cancel,
159
+ });
160
+ expect(await run(vi.fn().mockResolvedValue(new Response(body)))).toMatchObject(extra === 0 ? { ok: true } : { ok: false, reason: 'unavailable' });
161
+ expect(cancel).toHaveBeenCalledTimes(extra);
162
+ }
163
+ });
164
+ it('cancels an oversized streaming body without waiting for cancellation', async () => {
165
+ const cancel = vi.fn(() => new Promise(() => { }));
166
+ const body = new ReadableStream({
167
+ start(controller) {
168
+ controller.enqueue(new Uint8Array(16 * 1024 + 1));
169
+ },
170
+ cancel,
171
+ });
172
+ expect(await run(vi.fn().mockResolvedValue(new Response(body)))).toEqual({
173
+ ok: false,
174
+ reason: 'unavailable',
175
+ });
176
+ expect(cancel).toHaveBeenCalledOnce();
177
+ });
178
+ it('aborts a stalled body reader and a never-settling fetch', async () => {
179
+ for (const stallBody of [true, false]) {
180
+ const controller = new AbortController();
181
+ const cancel = vi.fn();
182
+ const fetcher = vi
183
+ .fn()
184
+ .mockImplementation(() => stallBody
185
+ ? Promise.resolve(new Response(new ReadableStream({ cancel })))
186
+ : new Promise(() => { }));
187
+ const pending = createTurnstileVerifier({ secretKey: SECRET, fetch: fetcher }).verify({
188
+ token: TOKEN,
189
+ signal: controller.signal,
190
+ });
191
+ await vi.waitFor(() => expect(fetcher).toHaveBeenCalledOnce());
192
+ controller.abort();
193
+ expect(await pending).toEqual({ ok: false, reason: 'unavailable' });
194
+ if (stallBody)
195
+ expect(cancel).toHaveBeenCalledOnce();
196
+ }
197
+ });
198
+ it('does not fetch with an already aborted signal', async () => {
199
+ const fetcher = vi.fn();
200
+ const controller = new AbortController();
201
+ controller.abort();
202
+ expect(await createTurnstileVerifier({ secretKey: SECRET, fetch: fetcher }).verify({
203
+ token: TOKEN,
204
+ signal: controller.signal,
205
+ })).toEqual({ ok: false, reason: 'unavailable' });
206
+ expect(fetcher).not.toHaveBeenCalled();
207
+ });
208
+ });
209
+ describe('literal nearest-form detection', () => {
210
+ const graph = () => ({
211
+ schemaVersion: 1,
212
+ root: 'root',
213
+ components: {},
214
+ nodes: {
215
+ root: { id: 'root', type: 'buildr/page', slots: { default: ['outer'] } },
216
+ outer: {
217
+ id: 'outer',
218
+ type: 'buildr/form',
219
+ slots: { default: ['wrapper', 'inner', 'dangling', 'toString'] },
220
+ },
221
+ wrapper: {
222
+ id: 'wrapper',
223
+ type: 'unregistered',
224
+ slots: { anything: ['cap', 'cap', 'outer'] },
225
+ },
226
+ cap: {
227
+ id: 'cap',
228
+ type: CAPTCHA_TYPE,
229
+ styles: { base: { visibility: { hidden: true } } },
230
+ visibleIf: { kind: 'static', value: false },
231
+ },
232
+ inner: { id: 'inner', type: 'buildr/form', slots: { default: ['inside'] } },
233
+ inside: { id: 'inside', type: CAPTCHA_TYPE },
234
+ },
235
+ });
236
+ it('tolerates cycles, duplicate/dangling/prototype references, ignores conditions, and skips nested forms', () => {
237
+ expect(findFormCaptchas(graph(), 'outer').map((node) => node.id)).toEqual(['cap']);
238
+ expect(findFormCaptchas(graph(), 'inner').map((node) => node.id)).toEqual(['inside']);
239
+ expect(findFormCaptchas(graph(), 'missing')).toEqual([]);
240
+ expect(findFormCaptchas(graph(), 'toString')).toEqual([]);
241
+ });
242
+ it('detects any protected form ancestor even across corrupt reverse edges', () => {
243
+ expect(ancestorFormHasCaptcha(graph(), 'inner')).toBe(true);
244
+ expect(ancestorFormHasCaptcha(graph(), 'outer')).toBe(false);
245
+ expect(ancestorFormHasCaptcha(graph(), 'missing')).toBe(false);
246
+ });
247
+ });
248
+ it('retains live captcha counters at capacity and admits new keys only after expiry', async () => {
249
+ let now = 0;
250
+ const limiter = createMemoryRateLimiter({
251
+ limit: 1,
252
+ windowMs: 1000,
253
+ now: () => now,
254
+ failClosed: true,
255
+ });
256
+ for (let i = 0; i < 10000; i++)
257
+ expect(await limiter.hit(String(i))).toEqual({ allowed: true });
258
+ expect(await limiter.hit('new')).toEqual({ allowed: false, retryAfterSeconds: 1 });
259
+ expect(await limiter.hit('0')).toEqual({ allowed: false, retryAfterSeconds: 1 });
260
+ now = 1000;
261
+ expect(await limiter.hit('new')).toEqual({ allowed: true });
262
+ });
@@ -0,0 +1,8 @@
1
+ import type { PayloadRequest } from 'payload';
2
+ export type ClientIpSource = {
3
+ readonly header: string;
4
+ readonly hops?: number;
5
+ } | ((req: PayloadRequest) => string | undefined);
6
+ /** A configured, trusted source never falls back to client-controlled forwarding headers. */
7
+ export declare function clientIpOf(req: PayloadRequest, source?: ClientIpSource): string;
8
+ //# sourceMappingURL=client-ip.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client-ip.d.ts","sourceRoot":"","sources":["../../../src/plugin/forms/client-ip.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAE9C,MAAM,MAAM,cAAc,GACtB;IAAE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,GACnD,CAAC,CAAC,GAAG,EAAE,cAAc,KAAK,MAAM,GAAG,SAAS,CAAC,CAAC;AAElD,6FAA6F;AAC7F,wBAAgB,UAAU,CAAC,GAAG,EAAE,cAAc,EAAE,MAAM,CAAC,EAAE,cAAc,GAAG,MAAM,CAmC/E"}
@@ -0,0 +1,41 @@
1
+ import { isIP } from 'node:net';
2
+ /** A configured, trusted source never falls back to client-controlled forwarding headers. */
3
+ export function clientIpOf(req, source) {
4
+ if (source === undefined) {
5
+ const forwarded = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim();
6
+ return forwarded || req.headers.get('x-real-ip') || 'unknown';
7
+ }
8
+ try {
9
+ const chain = typeof source === 'function' ? undefined : req.headers.get(source.header)?.split(',');
10
+ const raw = typeof source === 'function' ? source(req) : chain?.at(-(source.hops ?? 1));
11
+ const ip = typeof raw === 'string' ? raw.trim() : '';
12
+ if (ip.includes('%'))
13
+ return 'unknown';
14
+ const family = isIP(ip);
15
+ if (family === 4)
16
+ return ip;
17
+ if (family !== 6)
18
+ return 'unknown';
19
+ // URL canonicalisation expands dotted IPv4 tails and compresses equivalent IPv6 spellings.
20
+ const canonical = new URL(`http://[${ip}]/`).hostname.slice(1, -1);
21
+ const [left = '', right = ''] = canonical.split('::');
22
+ const head = left ? left.split(':') : [];
23
+ const tail = right ? right.split(':') : [];
24
+ const words = canonical.includes('::')
25
+ ? [...head, ...Array(8 - head.length - tail.length).fill('0'), ...tail]
26
+ : head;
27
+ const nums = words.map((word) => Number.parseInt(word, 16));
28
+ if (nums.slice(0, 5).every((word) => word === 0) && nums[5] === 65535) {
29
+ const a = nums[6] ?? 0;
30
+ const b = nums[7] ?? 0;
31
+ return `${a >> 8}.${a & 255}.${b >> 8}.${b & 255}`;
32
+ }
33
+ return `${nums
34
+ .slice(0, 4)
35
+ .map((word) => word.toString(16))
36
+ .join(':')}::/64`;
37
+ }
38
+ catch {
39
+ return 'unknown';
40
+ }
41
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=client-ip.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client-ip.test.d.ts","sourceRoot":"","sources":["../../../src/plugin/forms/client-ip.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { clientIpOf } from './client-ip.js';
3
+ const request = (headers = {}) => ({ headers: new Headers(headers) });
4
+ describe('clientIpOf', () => {
5
+ it('preserves the old default including unvalidated values and whitespace', () => {
6
+ expect(clientIpOf(request({ 'x-forwarded-for': ' spoofed , 192.0.2.1', 'x-real-ip': 'other' }))).toBe('spoofed');
7
+ expect(clientIpOf(request({ 'x-forwarded-for': ' ,x', 'x-real-ip': ' untrimmed ' }))).toBe('untrimmed');
8
+ expect(clientIpOf(request())).toBe('unknown');
9
+ });
10
+ it('selects the configured hop from the right and validates IPv4 and IPv6', () => {
11
+ const req = request({ 'trusted-ip': '192.0.2.1, 2001:db8::1, 198.51.100.1' });
12
+ expect(clientIpOf(req, { header: 'trusted-ip' })).toBe('198.51.100.1');
13
+ expect(clientIpOf(req, { header: 'trusted-ip', hops: 2 })).toBe('2001:db8:0:0::/64');
14
+ expect(clientIpOf(req, { header: 'trusted-ip', hops: 3 })).toBe('192.0.2.1');
15
+ });
16
+ it.each([undefined, '', 'unknown', '192.0.2.1:80', '[2001:db8::1]', '999.2.3.4', 'example.com'])('shares one unknown bucket for bad configured addresses: %s', (value) => {
17
+ const req = request({
18
+ 'x-forwarded-for': '192.0.2.9',
19
+ 'x-real-ip': '192.0.2.8',
20
+ ...(value === undefined ? {} : { trusted: value }),
21
+ });
22
+ expect(clientIpOf(req, { header: 'trusted' })).toBe('unknown');
23
+ expect(clientIpOf(req, () => value)).toBe('unknown');
24
+ });
25
+ it('never falls back for a short chain or throwing callback, and passes the request', () => {
26
+ const req = request({ trusted: '192.0.2.1', 'x-real-ip': '192.0.2.8' });
27
+ expect(clientIpOf(req, { header: 'trusted', hops: 2 })).toBe('unknown');
28
+ const source = (received) => {
29
+ expect(received).toBe(req);
30
+ return ' 2001:db8::1 ';
31
+ };
32
+ expect(clientIpOf(req, source)).toBe('2001:db8:0:0::/64');
33
+ expect(clientIpOf(req, () => {
34
+ throw new Error('private details');
35
+ })).toBe('unknown');
36
+ });
37
+ });
38
+ it('normalises mapped IPv4 and groups IPv6 by /64 across spellings and zones', () => {
39
+ const key = (ip) => clientIpOf(request(), () => ip);
40
+ expect(key('::ffff:192.0.2.1')).toBe(key('192.0.2.1'));
41
+ expect(key('::ffff:c000:201')).toBe(key('192.0.2.1'));
42
+ expect(key('2001:db8::1')).toBe(key('2001:0db8:0000:0000:ffff:0:0:2'));
43
+ expect(key('2001:db8:0:1::1')).not.toBe(key('2001:db8::1'));
44
+ expect(key('fe80::1%eth0')).toBe('unknown');
45
+ });
@@ -18,6 +18,8 @@ export interface MemoryRateLimiterOptions {
18
18
  readonly windowMs: number;
19
19
  /** Replaceable clock, for tests. */
20
20
  readonly now?: () => number;
21
+ /** Reject new keys rather than evict live counters (captcha only). */
22
+ readonly failClosed?: boolean;
21
23
  }
22
24
  /** A fixed-window counter in memory. Each call creates an independent limiter (no shared state). */
23
25
  export declare function createMemoryRateLimiter(options: MemoryRateLimiterOptions): RateLimiter;
@@ -1 +1 @@
1
- {"version":3,"file":"rate-limit.d.ts","sourceRoot":"","sources":["../../../src/plugin/forms/rate-limit.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,eAAe,GACvB;IAAE,QAAQ,CAAC,OAAO,EAAE,IAAI,CAAA;CAAE,GAC1B;IAAE,QAAQ,CAAC,OAAO,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,iBAAiB,EAAE,MAAM,CAAA;CAAE,CAAC;AAEpE;;;;GAIG;AACH,MAAM,WAAW,WAAW;IAC1B,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,eAAe,GAAG,OAAO,CAAC,eAAe,CAAC,CAAC;CAC9D;AAED,MAAM,WAAW,wBAAwB;IACvC,mCAAmC;IACnC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,oCAAoC;IACpC,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CAC7B;AAKD,oGAAoG;AACpG,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,wBAAwB,GAAG,WAAW,CA6BtF"}
1
+ {"version":3,"file":"rate-limit.d.ts","sourceRoot":"","sources":["../../../src/plugin/forms/rate-limit.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,eAAe,GACvB;IAAE,QAAQ,CAAC,OAAO,EAAE,IAAI,CAAA;CAAE,GAC1B;IAAE,QAAQ,CAAC,OAAO,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,iBAAiB,EAAE,MAAM,CAAA;CAAE,CAAC;AAEpE;;;;GAIG;AACH,MAAM,WAAW,WAAW;IAC1B,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,eAAe,GAAG,OAAO,CAAC,eAAe,CAAC,CAAC;CAC9D;AAED,MAAM,WAAW,wBAAwB;IACvC,mCAAmC;IACnC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,oCAAoC;IACpC,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IAC5B,sEAAsE;IACtE,QAAQ,CAAC,UAAU,CAAC,EAAE,OAAO,CAAC;CAC/B;AAKD,oGAAoG;AACpG,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,wBAAwB,GAAG,WAAW,CAqCtF"}
@@ -12,9 +12,18 @@ export function createMemoryRateLimiter(options) {
12
12
  if (window.resetAt <= at)
13
13
  windows.delete(candidate);
14
14
  }
15
+ if (options.failClosed && windows.size >= MAX_KEYS && !windows.has(key)) {
16
+ let resetAt = at + options.windowMs;
17
+ for (const window of windows.values())
18
+ resetAt = Math.min(resetAt, window.resetAt);
19
+ return {
20
+ allowed: false,
21
+ retryAfterSeconds: Math.max(1, Math.ceil((resetAt - at) / 1000)),
22
+ };
23
+ }
15
24
  // Still full of live windows: drop the oldest ones.
16
25
  for (const candidate of windows.keys()) {
17
- if (windows.size < MAX_KEYS)
26
+ if (options.failClosed || windows.size < MAX_KEYS)
18
27
  break;
19
28
  windows.delete(candidate);
20
29
  }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=author-code.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"author-code.test.d.ts","sourceRoot":"","sources":["../../../src/plugin/hooks/author-code.test.ts"],"names":[],"mappings":""}