@polderlabs/bizar 4.7.0 → 4.7.2
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/bizar-dash/dist/assets/main-DHZmbnxQ.js +361 -0
- package/bizar-dash/dist/assets/main-DHZmbnxQ.js.map +1 -0
- package/bizar-dash/dist/assets/main-DX_Jh8Wc.css +1 -0
- package/bizar-dash/dist/assets/{mobile-CWqPoGaT.js → mobile-BK8-ythT.js} +2 -2
- package/bizar-dash/dist/assets/mobile-BK8-ythT.js.map +1 -0
- package/bizar-dash/dist/assets/{mobile-i4Uv9eW8.js → mobile-Chvf9u_B.js} +1 -1
- package/bizar-dash/dist/assets/{mobile-i4Uv9eW8.js.map → mobile-Chvf9u_B.js.map} +1 -1
- package/bizar-dash/dist/index.html +3 -3
- package/bizar-dash/dist/mobile.html +2 -2
- package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
- package/bizar-dash/skills/publishing/SKILL.md +146 -0
- package/bizar-dash/src/server/api.mjs +8 -0
- package/bizar-dash/src/server/backup-store.mjs +525 -0
- package/bizar-dash/src/server/digest-store.mjs +558 -0
- package/bizar-dash/src/server/lib/rate-limit.mjs +122 -0
- package/bizar-dash/src/server/routes/backup.mjs +112 -0
- package/bizar-dash/src/server/routes/chat.mjs +14 -0
- package/bizar-dash/src/server/routes/digests.mjs +82 -0
- package/bizar-dash/src/server/routes-v2/events.mjs +14 -0
- package/bizar-dash/src/server/schedules-runner.mjs +126 -0
- package/bizar-dash/src/server/server.mjs +17 -0
- package/bizar-dash/src/web/App.tsx +8 -1
- package/bizar-dash/src/web/components/BackupRestore.tsx +330 -0
- package/bizar-dash/src/web/components/SearchModal.tsx +3 -1
- package/bizar-dash/src/web/components/chat/Composer.tsx +2 -0
- package/bizar-dash/src/web/styles/main.css +70 -8
- package/bizar-dash/src/web/views/Activity.tsx +11 -1
- package/bizar-dash/src/web/views/Agents.tsx +57 -42
- package/bizar-dash/src/web/views/Artifacts.tsx +38 -25
- package/bizar-dash/src/web/views/Chat.tsx +8 -0
- package/bizar-dash/src/web/views/History.tsx +4 -1
- package/bizar-dash/src/web/views/MiniMaxUsage.tsx +21 -4
- package/bizar-dash/src/web/views/Mods.tsx +30 -17
- package/bizar-dash/src/web/views/Overview.tsx +8 -1
- package/bizar-dash/src/web/views/Providers.tsx +16 -16
- package/bizar-dash/src/web/views/Schedules.tsx +33 -15
- package/bizar-dash/src/web/views/Settings.tsx +97 -1751
- package/bizar-dash/src/web/views/Skills.tsx +4 -1
- package/bizar-dash/src/web/views/Tasks.tsx +11 -2
- package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +8 -2
- package/bizar-dash/src/web/views/memory/LightragPanel.tsx +3 -0
- package/bizar-dash/src/web/views/memory/ObsidianPanel.tsx +12 -4
- package/bizar-dash/src/web/views/memory/SemanticSearchPanel.tsx +3 -0
- package/bizar-dash/src/web/views/settings/ActivitySection.tsx +205 -0
- package/bizar-dash/src/web/views/settings/AgentSection.tsx +294 -0
- package/bizar-dash/src/web/views/settings/AuthSection.tsx +159 -0
- package/bizar-dash/src/web/views/settings/BackupSection.tsx +16 -0
- package/bizar-dash/src/web/views/settings/EnvVarsSection.tsx +16 -0
- package/bizar-dash/src/web/views/settings/GeneralSection.tsx +105 -0
- package/bizar-dash/src/web/views/settings/HeadroomSection.tsx +39 -0
- package/bizar-dash/src/web/views/settings/MemorySection.tsx +16 -0
- package/bizar-dash/src/web/views/settings/NetworkSection.tsx +87 -0
- package/bizar-dash/src/web/views/settings/NotificationsSection.tsx +34 -0
- package/bizar-dash/src/web/views/settings/ProvidersSection.tsx +16 -0
- package/bizar-dash/src/web/views/settings/SkillsSection.tsx +16 -0
- package/bizar-dash/src/web/views/settings/SystemLlmSection.tsx +81 -0
- package/bizar-dash/src/web/views/settings/ThemeSection.tsx +168 -0
- package/bizar-dash/src/web/views/settings/UpdatesSection.tsx +256 -0
- package/bizar-dash/tests/a11y.test.tsx +206 -0
- package/bizar-dash/tests/backup-restore.test.mjs +217 -0
- package/bizar-dash/tests/backup-restore.test.tsx +123 -0
- package/bizar-dash/tests/backup-store.test.mjs +300 -0
- package/bizar-dash/tests/cli-error-visibility.test.mjs +153 -0
- package/bizar-dash/tests/digest-generation.test.mjs +191 -0
- package/bizar-dash/tests/digest-store.test.mjs +264 -0
- package/bizar-dash/tests/rate-limit.test.mjs +298 -0
- package/cli/bin.mjs +96 -2
- package/cli/commands/minimax.mjs +20 -0
- package/cli/commands/util.mjs +154 -1
- package/cli/digest.mjs +149 -0
- package/package.json +1 -1
- package/bizar-dash/dist/assets/main-DAlLdW8I.css +0 -1
- package/bizar-dash/dist/assets/main-DGGq-iZI.js +0 -361
- package/bizar-dash/dist/assets/main-DGGq-iZI.js.map +0 -1
- package/bizar-dash/dist/assets/mobile-CWqPoGaT.js.map +0 -1
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tests/digest-store.test.mjs
|
|
3
|
+
*
|
|
4
|
+
* v4.8.0 — Tests for the digest store CRUD operations.
|
|
5
|
+
*
|
|
6
|
+
* Verifies:
|
|
7
|
+
* - generateWeeklyDigest returns valid markdown
|
|
8
|
+
* - saveDigest writes file to expected paths
|
|
9
|
+
* - listDigests returns sorted by date
|
|
10
|
+
* - getDigest reads back the saved content
|
|
11
|
+
* - deleteDigest removes the file
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
15
|
+
import assert from 'node:assert';
|
|
16
|
+
import { tmpdir, homedir } from 'node:os';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { mkdirSync, rmSync, existsSync, readFileSync } from 'node:fs';
|
|
19
|
+
|
|
20
|
+
// Point the store to a temp directory
|
|
21
|
+
const storeHome = join(tmpdir(), `bizar-digest-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
22
|
+
process.env.BIZAR_STORE_HOME = storeHome;
|
|
23
|
+
|
|
24
|
+
const DIGEST_STORE = await import('../src/server/digest-store.mjs');
|
|
25
|
+
|
|
26
|
+
describe('digest-store', () => {
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
mkdirSync(storeHome, { recursive: true });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
try { rmSync(storeHome, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// ── computeWeekRange ──────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
describe('computeWeekRange', () => {
|
|
38
|
+
it('returns last 7 days when no args given', () => {
|
|
39
|
+
const { weekStart, weekEnd } = DIGEST_STORE.computeWeekRange();
|
|
40
|
+
assert.ok(typeof weekStart === 'string');
|
|
41
|
+
assert.ok(typeof weekEnd === 'string');
|
|
42
|
+
assert.match(weekStart, /^\d{4}-\d{2}-\d{2}$/);
|
|
43
|
+
assert.match(weekEnd, /^\d{4}-\d{2}-\d{2}$/);
|
|
44
|
+
// weekEnd should be >= weekStart
|
|
45
|
+
assert.ok(weekEnd >= weekStart);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('returns the specified week range', () => {
|
|
49
|
+
const { weekStart, weekEnd } = DIGEST_STORE.computeWeekRange('2026-06-29', '2026-07-05');
|
|
50
|
+
assert.strictEqual(weekStart, '2026-06-29');
|
|
51
|
+
assert.strictEqual(weekEnd, '2026-07-05');
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// ── generateWeeklyDigest ──────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
describe('generateWeeklyDigest', () => {
|
|
58
|
+
it('returns valid markdown with frontmatter', async () => {
|
|
59
|
+
const result = await DIGEST_STORE.generateWeeklyDigest({
|
|
60
|
+
weekStart: '2026-06-29',
|
|
61
|
+
weekEnd: '2026-07-05',
|
|
62
|
+
projectRoot: storeHome,
|
|
63
|
+
dryRun: true,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
assert.ok(result.markdown, 'markdown should be present');
|
|
67
|
+
assert.ok(result.markdown.startsWith('---'), 'markdown should start with frontmatter');
|
|
68
|
+
assert.ok(result.markdown.includes('Weekly Digest'), 'markdown should contain title');
|
|
69
|
+
assert.ok(result.markdown.includes('## Tasks completed'), 'markdown should have tasks section');
|
|
70
|
+
assert.ok(result.markdown.includes('## Memory notes written'), 'markdown should have memory section');
|
|
71
|
+
assert.ok(result.markdown.includes('## Chat sessions'), 'markdown should have chat section');
|
|
72
|
+
assert.ok(result.markdown.includes('## Schedules fired'), 'markdown should have schedules section');
|
|
73
|
+
assert.ok(result.markdown.includes('## Background agents completed'), 'markdown should have bg section');
|
|
74
|
+
assert.ok(result.markdown.includes('## Token usage'), 'markdown should have usage section');
|
|
75
|
+
assert.deepStrictEqual(result.sections, result.sections, 'sections object should be present');
|
|
76
|
+
assert.strictEqual(result.weekStart, '2026-06-29');
|
|
77
|
+
assert.strictEqual(result.weekEnd, '2026-07-05');
|
|
78
|
+
assert.strictEqual(result.dryRun, true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('defaults to last 7 days when dates omitted', async () => {
|
|
82
|
+
const result = await DIGEST_STORE.generateWeeklyDigest({
|
|
83
|
+
projectRoot: storeHome,
|
|
84
|
+
dryRun: true,
|
|
85
|
+
});
|
|
86
|
+
assert.ok(result.markdown);
|
|
87
|
+
assert.ok(result.weekStart, 'weekStart should be present');
|
|
88
|
+
assert.ok(result.weekEnd, 'weekEnd should be present');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('sections contain expected shapes', async () => {
|
|
92
|
+
const result = await DIGEST_STORE.generateWeeklyDigest({
|
|
93
|
+
weekStart: '2026-06-29',
|
|
94
|
+
weekEnd: '2026-07-05',
|
|
95
|
+
projectRoot: storeHome,
|
|
96
|
+
dryRun: true,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// All 7 section keys should be present
|
|
100
|
+
const expectedKeys = ['tasks', 'tasks-created', 'memory-writes', 'chat-sessions',
|
|
101
|
+
'schedules-fired', 'bg-agents-completed', 'usage-stats'];
|
|
102
|
+
for (const key of expectedKeys) {
|
|
103
|
+
assert.ok(key in result.sections, `section "${key}" should be present`);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// ── saveDigest ────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
describe('saveDigest', () => {
|
|
111
|
+
it('writes digest files to expected paths', async () => {
|
|
112
|
+
const markdown = '---\ntitle: "Test"\n---\n\n# Test digest body';
|
|
113
|
+
const result = await DIGEST_STORE.saveDigest({
|
|
114
|
+
markdown,
|
|
115
|
+
weekStart: '2026-07-05',
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
assert.ok(result.ok);
|
|
119
|
+
assert.ok(Array.isArray(result.paths));
|
|
120
|
+
assert.ok(result.paths.length >= 1);
|
|
121
|
+
|
|
122
|
+
// Primary path should exist
|
|
123
|
+
const primaryPath = result.paths[0];
|
|
124
|
+
assert.ok(existsSync(primaryPath), `primary path should exist: ${primaryPath}`);
|
|
125
|
+
const content = readFileSync(primaryPath, 'utf8');
|
|
126
|
+
assert.ok(content.includes(markdown));
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('also writes to project vault if .obsidian exists', async () => {
|
|
130
|
+
const vaultDigests = join(storeHome, '.obsidian', 'digests');
|
|
131
|
+
mkdirSync(vaultDigests, { recursive: true });
|
|
132
|
+
|
|
133
|
+
const markdown = '---\ntitle: "Vault Test"\n---\n\n# Vault test body';
|
|
134
|
+
const result = await DIGEST_STORE.saveDigest({
|
|
135
|
+
markdown,
|
|
136
|
+
weekStart: '2026-06-29',
|
|
137
|
+
projectRoot: storeHome,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
assert.ok(result.ok);
|
|
141
|
+
const vaultPath = join(vaultDigests, 'weekly-2026-06-29.md');
|
|
142
|
+
assert.ok(existsSync(vaultPath), `vault path should exist: ${vaultPath}`);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// ── listDigests ───────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
describe('listDigests', () => {
|
|
149
|
+
it('returns an empty list when no digests exist', async () => {
|
|
150
|
+
const digests = await DIGEST_STORE.listDigests();
|
|
151
|
+
assert.ok(Array.isArray(digests));
|
|
152
|
+
assert.strictEqual(digests.length, 0);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('returns digests sorted by date descending', async () => {
|
|
156
|
+
// Create two digests out of order
|
|
157
|
+
await DIGEST_STORE.saveDigest({
|
|
158
|
+
markdown: '# Alpha',
|
|
159
|
+
weekStart: '2026-07-05',
|
|
160
|
+
});
|
|
161
|
+
await DIGEST_STORE.saveDigest({
|
|
162
|
+
markdown: '# Beta',
|
|
163
|
+
weekStart: '2026-06-28',
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const digests = await DIGEST_STORE.listDigests();
|
|
167
|
+
assert.strictEqual(digests.length, 2);
|
|
168
|
+
// Should be sorted: 2026-07-05 first, then 2026-06-28
|
|
169
|
+
assert.strictEqual(digests[0].weekStart, '2026-07-05');
|
|
170
|
+
assert.strictEqual(digests[1].weekStart, '2026-06-28');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('respects the limit parameter', async () => {
|
|
174
|
+
await DIGEST_STORE.saveDigest({ markdown: '# A', weekStart: '2026-07-05' });
|
|
175
|
+
await DIGEST_STORE.saveDigest({ markdown: '# B', weekStart: '2026-06-28' });
|
|
176
|
+
await DIGEST_STORE.saveDigest({ markdown: '# C', weekStart: '2026-06-21' });
|
|
177
|
+
|
|
178
|
+
const digests = await DIGEST_STORE.listDigests({ limit: 2 });
|
|
179
|
+
assert.strictEqual(digests.length, 2);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// ── getDigest ─────────────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
describe('getDigest', () => {
|
|
186
|
+
it('returns null for non-existent path', async () => {
|
|
187
|
+
const result = await DIGEST_STORE.getDigest('/nonexistent/path.md');
|
|
188
|
+
assert.strictEqual(result, null);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('reads back the saved digest content', async () => {
|
|
192
|
+
const markdown = '---\ntitle: "Read Test"\n---\n\n# Read test body';
|
|
193
|
+
const saveResult = await DIGEST_STORE.saveDigest({
|
|
194
|
+
markdown,
|
|
195
|
+
weekStart: '2026-07-04',
|
|
196
|
+
});
|
|
197
|
+
const primaryPath = saveResult.paths[0];
|
|
198
|
+
|
|
199
|
+
const digest = await DIGEST_STORE.getDigest(primaryPath);
|
|
200
|
+
assert.ok(digest, 'digest should be found');
|
|
201
|
+
assert.strictEqual(digest.content, markdown);
|
|
202
|
+
assert.strictEqual(digest.weekStart, '2026-07-04');
|
|
203
|
+
assert.ok(digest.sizeBytes > 0);
|
|
204
|
+
assert.strictEqual(digest.path, primaryPath);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// ── deleteDigest ──────────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
describe('deleteDigest', () => {
|
|
211
|
+
it('returns ok:false for empty path', async () => {
|
|
212
|
+
const result = await DIGEST_STORE.deleteDigest(null);
|
|
213
|
+
assert.strictEqual(result.ok, false);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('removes the digest file and index entry', async () => {
|
|
217
|
+
const markdown = '# Delete me';
|
|
218
|
+
const saveResult = await DIGEST_STORE.saveDigest({
|
|
219
|
+
markdown,
|
|
220
|
+
weekStart: '2026-07-03',
|
|
221
|
+
});
|
|
222
|
+
const primaryPath = saveResult.paths[0];
|
|
223
|
+
assert.ok(existsSync(primaryPath), 'file should exist before deletion');
|
|
224
|
+
|
|
225
|
+
const delResult = await DIGEST_STORE.deleteDigest(primaryPath);
|
|
226
|
+
assert.strictEqual(delResult.ok, true);
|
|
227
|
+
assert.strictEqual(existsSync(primaryPath), false, 'file should be removed');
|
|
228
|
+
|
|
229
|
+
// Index entry should be gone
|
|
230
|
+
const digests = await DIGEST_STORE.listDigests();
|
|
231
|
+
assert.strictEqual(digests.length, 0);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// ── generateAndSave ───────────────────────────────────────────────────────
|
|
236
|
+
|
|
237
|
+
describe('generateAndSave', () => {
|
|
238
|
+
it('generates and saves when dryRun is false', async () => {
|
|
239
|
+
const result = await DIGEST_STORE.generateAndSave({
|
|
240
|
+
weekStart: '2026-06-28',
|
|
241
|
+
weekEnd: '2026-07-04',
|
|
242
|
+
projectRoot: storeHome,
|
|
243
|
+
dryRun: false,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
assert.ok(result.markdown);
|
|
247
|
+
assert.ok(result.saveResult);
|
|
248
|
+
assert.ok(result.saveResult.ok);
|
|
249
|
+
assert.ok(result.saveResult.paths.length >= 1);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('does not save when dryRun is true', async () => {
|
|
253
|
+
const result = await DIGEST_STORE.generateAndSave({
|
|
254
|
+
weekStart: '2026-06-28',
|
|
255
|
+
weekEnd: '2026-07-04',
|
|
256
|
+
projectRoot: storeHome,
|
|
257
|
+
dryRun: true,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
assert.ok(result.markdown);
|
|
261
|
+
assert.strictEqual(result.saveResult, undefined);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
});
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tests/rate-limit.test.mjs
|
|
3
|
+
*
|
|
4
|
+
* v4.8.0 — Tests for src/server/lib/rate-limit.mjs.
|
|
5
|
+
*
|
|
6
|
+
* Run with:
|
|
7
|
+
* node --test tests/rate-limit.test.mjs
|
|
8
|
+
*
|
|
9
|
+
* Covers:
|
|
10
|
+
* - Request within capacity returns 200 + X-RateLimit-* headers
|
|
11
|
+
* - Request over capacity returns 429 with Retry-After + JSON body
|
|
12
|
+
* - Bucket refills over time (verified via mock.timers tick)
|
|
13
|
+
* - Different IPs have independent buckets
|
|
14
|
+
* - Different scopes (chat vs event) have independent buckets
|
|
15
|
+
* - clearBuckets() resets state between tests
|
|
16
|
+
* - Constructor validates inputs
|
|
17
|
+
* - 429 response carries X-RateLimit-Scope so server.mjs can log it
|
|
18
|
+
*/
|
|
19
|
+
import test from 'node:test';
|
|
20
|
+
import assert from 'node:assert/strict';
|
|
21
|
+
import { mock } from 'node:test';
|
|
22
|
+
import express from 'express';
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
createRateLimiter,
|
|
26
|
+
clearBuckets,
|
|
27
|
+
_peekBucket,
|
|
28
|
+
_bucketSize,
|
|
29
|
+
} from '../src/server/lib/rate-limit.mjs';
|
|
30
|
+
|
|
31
|
+
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Build a minimal Express app with one route guarded by the given
|
|
35
|
+
* limiter. We override `req.ip` via the `trust proxy` setting on the
|
|
36
|
+
* app instance so tests can set it deterministically.
|
|
37
|
+
*/
|
|
38
|
+
function buildApp({ capacity, refillPerSecond, scope = 'test' }) {
|
|
39
|
+
const app = express();
|
|
40
|
+
app.set('trust proxy', true);
|
|
41
|
+
const limiter = createRateLimiter({ capacity, refillPerSecond, scope });
|
|
42
|
+
app.get('/ping', limiter, (_req, res) => res.json({ ok: true }));
|
|
43
|
+
return app;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Drive the Express app without binding a TCP socket. Returns the
|
|
48
|
+
* (status, headers, body) of the response so tests can assert without
|
|
49
|
+
* the noise of supertest.
|
|
50
|
+
*/
|
|
51
|
+
function drive(app, { ip = '127.0.0.1', method = 'GET', path = '/ping' } = {}) {
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
const req = {
|
|
54
|
+
method,
|
|
55
|
+
url: path,
|
|
56
|
+
ip,
|
|
57
|
+
socket: { remoteAddress: ip },
|
|
58
|
+
headers: {},
|
|
59
|
+
};
|
|
60
|
+
const resHeaders = {};
|
|
61
|
+
const res = {
|
|
62
|
+
statusCode: 200,
|
|
63
|
+
headersSent: false,
|
|
64
|
+
finished: false,
|
|
65
|
+
set(name, value) {
|
|
66
|
+
resHeaders[name.toLowerCase()] = value;
|
|
67
|
+
return this;
|
|
68
|
+
},
|
|
69
|
+
setHeader(name, value) {
|
|
70
|
+
resHeaders[name.toLowerCase()] = value;
|
|
71
|
+
return this;
|
|
72
|
+
},
|
|
73
|
+
getHeader(name) {
|
|
74
|
+
return resHeaders[name.toLowerCase()];
|
|
75
|
+
},
|
|
76
|
+
status(code) {
|
|
77
|
+
this.statusCode = code;
|
|
78
|
+
return this;
|
|
79
|
+
},
|
|
80
|
+
json(body) {
|
|
81
|
+
this.headersSent = true;
|
|
82
|
+
this.finished = true;
|
|
83
|
+
resolve({ status: this.statusCode, headers: resHeaders, body });
|
|
84
|
+
return this;
|
|
85
|
+
},
|
|
86
|
+
send(body) {
|
|
87
|
+
this.headersSent = true;
|
|
88
|
+
this.finished = true;
|
|
89
|
+
resolve({ status: this.statusCode, headers: resHeaders, body });
|
|
90
|
+
return this;
|
|
91
|
+
},
|
|
92
|
+
end(body) {
|
|
93
|
+
this.headersSent = true;
|
|
94
|
+
this.finished = true;
|
|
95
|
+
resolve({ status: this.statusCode, headers: resHeaders, body });
|
|
96
|
+
return this;
|
|
97
|
+
},
|
|
98
|
+
on() {
|
|
99
|
+
return this;
|
|
100
|
+
},
|
|
101
|
+
once() {
|
|
102
|
+
return this;
|
|
103
|
+
},
|
|
104
|
+
emit() {
|
|
105
|
+
return true;
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
try {
|
|
109
|
+
app._router.handle(req, res, (err) => {
|
|
110
|
+
if (err) reject(err);
|
|
111
|
+
else if (!res.finished) {
|
|
112
|
+
// Should not happen — limiter always responds.
|
|
113
|
+
reject(new Error('handler fell through without responding'));
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
} catch (err) {
|
|
117
|
+
reject(err);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── Tests ─────────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
test('accepts request within capacity and emits X-RateLimit-* headers', async () => {
|
|
125
|
+
clearBuckets();
|
|
126
|
+
const app = buildApp({ capacity: 5, refillPerSecond: 1, scope: 'test1' });
|
|
127
|
+
const r = await drive(app, { ip: '10.0.0.1' });
|
|
128
|
+
assert.equal(r.status, 200);
|
|
129
|
+
assert.deepEqual(r.body, { ok: true });
|
|
130
|
+
assert.equal(r.headers['x-ratelimit-limit'], '5');
|
|
131
|
+
// 1 token consumed from a full bucket of 5 → 4 remain.
|
|
132
|
+
assert.equal(r.headers['x-ratelimit-remaining'], '4');
|
|
133
|
+
assert.equal(r.headers['x-ratelimit-scope'], 'test1');
|
|
134
|
+
assert.equal(r.headers['retry-after'], undefined);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('rejects over-capacity requests with 429 + Retry-After + JSON body', async () => {
|
|
138
|
+
clearBuckets();
|
|
139
|
+
const app = buildApp({ capacity: 2, refillPerSecond: 0.5, scope: 'test2' });
|
|
140
|
+
// Drain the bucket.
|
|
141
|
+
await drive(app, { ip: '10.0.0.2' });
|
|
142
|
+
await drive(app, { ip: '10.0.0.2' });
|
|
143
|
+
// Third request must be throttled.
|
|
144
|
+
const r = await drive(app, { ip: '10.0.0.2' });
|
|
145
|
+
assert.equal(r.status, 429);
|
|
146
|
+
assert.equal(r.body.error, 'rate_limited');
|
|
147
|
+
assert.equal(r.body.scope, 'test2');
|
|
148
|
+
assert.ok(typeof r.body.retryAfter === 'number' && r.body.retryAfter >= 1);
|
|
149
|
+
assert.equal(r.headers['x-ratelimit-limit'], '2');
|
|
150
|
+
assert.equal(r.headers['x-ratelimit-remaining'], '0');
|
|
151
|
+
assert.equal(r.headers['x-ratelimit-scope'], 'test2');
|
|
152
|
+
assert.ok(r.headers['retry-after']);
|
|
153
|
+
// retry-after is a string of an integer ≥ 1.
|
|
154
|
+
assert.match(r.headers['retry-after'], /^[1-9]\d*$/);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('bucket refills over time', () => {
|
|
158
|
+
clearBuckets();
|
|
159
|
+
mock.timers.enable({ apis: ['Date'] });
|
|
160
|
+
try {
|
|
161
|
+
const limiter = createRateLimiter({
|
|
162
|
+
capacity: 5,
|
|
163
|
+
refillPerSecond: 1,
|
|
164
|
+
scope: 'refill',
|
|
165
|
+
});
|
|
166
|
+
// Build a minimal req/res pair; we'll call the middleware directly.
|
|
167
|
+
function fakeRes() {
|
|
168
|
+
const headers = {};
|
|
169
|
+
let status = 200;
|
|
170
|
+
return {
|
|
171
|
+
statusCode: status,
|
|
172
|
+
set(name, value) { headers[name.toLowerCase()] = value; return this; },
|
|
173
|
+
getHeader(name) { return headers[name.toLowerCase()]; },
|
|
174
|
+
status(code) { this.statusCode = code; return this; },
|
|
175
|
+
json() { /* swallow */ },
|
|
176
|
+
headers,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
function fakeReq(ip) {
|
|
180
|
+
return { ip, socket: { remoteAddress: ip } };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Drain 5 tokens instantly.
|
|
184
|
+
for (let i = 0; i < 5; i++) {
|
|
185
|
+
limiter(fakeReq('10.0.0.3'), fakeRes(), () => {});
|
|
186
|
+
}
|
|
187
|
+
// The 6th request must be rejected.
|
|
188
|
+
const denied = fakeRes();
|
|
189
|
+
let nextCalled = false;
|
|
190
|
+
limiter(fakeReq('10.0.0.3'), denied, () => { nextCalled = true; });
|
|
191
|
+
assert.equal(denied.statusCode, 429);
|
|
192
|
+
assert.equal(nextCalled, false);
|
|
193
|
+
|
|
194
|
+
// Advance time by 1 second → 1 token regenerated.
|
|
195
|
+
mock.timers.tick(1000);
|
|
196
|
+
const accepted = fakeRes();
|
|
197
|
+
let acceptedNext = false;
|
|
198
|
+
limiter(fakeReq('10.0.0.3'), accepted, () => { acceptedNext = true; });
|
|
199
|
+
assert.equal(accepted.statusCode, 200);
|
|
200
|
+
assert.equal(acceptedNext, true);
|
|
201
|
+
|
|
202
|
+
// Bucket state sanity check.
|
|
203
|
+
const bucket = _peekBucket('refill:10.0.0.3');
|
|
204
|
+
assert.ok(bucket, 'expected bucket to exist');
|
|
205
|
+
assert.ok(bucket.tokens >= 0 && bucket.tokens < 5);
|
|
206
|
+
} finally {
|
|
207
|
+
mock.timers.reset();
|
|
208
|
+
clearBuckets();
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('different IPs have independent buckets', async () => {
|
|
213
|
+
clearBuckets();
|
|
214
|
+
const app = buildApp({ capacity: 1, refillPerSecond: 0.01, scope: 'iso-ip' });
|
|
215
|
+
// Drain IP A.
|
|
216
|
+
const a1 = await drive(app, { ip: '10.0.0.10' });
|
|
217
|
+
assert.equal(a1.status, 200);
|
|
218
|
+
const a2 = await drive(app, { ip: '10.0.0.10' });
|
|
219
|
+
assert.equal(a2.status, 429);
|
|
220
|
+
// IP B still has full budget.
|
|
221
|
+
const b1 = await drive(app, { ip: '10.0.0.11' });
|
|
222
|
+
assert.equal(b1.status, 200);
|
|
223
|
+
assert.equal(b1.headers['x-ratelimit-remaining'], '0');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test('different scopes (chat vs event) have independent buckets', async () => {
|
|
227
|
+
clearBuckets();
|
|
228
|
+
const chatApp = buildApp({ capacity: 1, refillPerSecond: 0.01, scope: 'chat' });
|
|
229
|
+
const eventApp = buildApp({ capacity: 1, refillPerSecond: 0.01, scope: 'event' });
|
|
230
|
+
// Drain chat for this IP.
|
|
231
|
+
const c1 = await drive(chatApp, { ip: '10.0.0.20' });
|
|
232
|
+
assert.equal(c1.status, 200);
|
|
233
|
+
const c2 = await drive(chatApp, { ip: '10.0.0.20' });
|
|
234
|
+
assert.equal(c2.status, 429);
|
|
235
|
+
assert.equal(c2.body.scope, 'chat');
|
|
236
|
+
// Event scope for the SAME ip still has full budget.
|
|
237
|
+
const e1 = await drive(eventApp, { ip: '10.0.0.20' });
|
|
238
|
+
assert.equal(e1.status, 200);
|
|
239
|
+
assert.equal(e1.headers['x-ratelimit-scope'], 'event');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('clearBuckets() resets state', async () => {
|
|
243
|
+
clearBuckets();
|
|
244
|
+
const app = buildApp({ capacity: 1, refillPerSecond: 0.01, scope: 'clear' });
|
|
245
|
+
await drive(app, { ip: '10.0.0.30' });
|
|
246
|
+
assert.equal(_bucketSize(), 1);
|
|
247
|
+
// Bucket exhausted — next request 429.
|
|
248
|
+
const r = await drive(app, { ip: '10.0.0.30' });
|
|
249
|
+
assert.equal(r.status, 429);
|
|
250
|
+
// Reset and retry — fresh budget.
|
|
251
|
+
clearBuckets();
|
|
252
|
+
assert.equal(_bucketSize(), 0);
|
|
253
|
+
const r2 = await drive(app, { ip: '10.0.0.30' });
|
|
254
|
+
assert.equal(r2.status, 200);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test('rejects invalid constructor arguments', () => {
|
|
258
|
+
assert.throws(() => createRateLimiter({ capacity: 0, refillPerSecond: 1 }), /capacity/);
|
|
259
|
+
assert.throws(() => createRateLimiter({ capacity: -1, refillPerSecond: 1 }), /capacity/);
|
|
260
|
+
assert.throws(() => createRateLimiter({ capacity: 'x', refillPerSecond: 1 }), /capacity/);
|
|
261
|
+
assert.throws(() => createRateLimiter({ capacity: 5, refillPerSecond: 0 }), /refillPerSecond/);
|
|
262
|
+
assert.throws(() => createRateLimiter({ capacity: 5, refillPerSecond: -1 }), /refillPerSecond/);
|
|
263
|
+
assert.throws(() => createRateLimiter({ capacity: 5, refillPerSecond: 1, scope: '' }), /scope/);
|
|
264
|
+
assert.throws(() => createRateLimiter({ capacity: 5, refillPerSecond: 1, scope: 42 }), /scope/);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test('falls back to socket.remoteAddress when req.ip missing', async () => {
|
|
268
|
+
clearBuckets();
|
|
269
|
+
const app = buildApp({ capacity: 1, refillPerSecond: 0.01, scope: 'sock' });
|
|
270
|
+
// Build a request WITHOUT ip but WITH socket.remoteAddress. We have
|
|
271
|
+
// to drive it manually because buildApp always sets ip.
|
|
272
|
+
const limiter = createRateLimiter({
|
|
273
|
+
capacity: 1,
|
|
274
|
+
refillPerSecond: 0.01,
|
|
275
|
+
scope: 'sock',
|
|
276
|
+
});
|
|
277
|
+
function fakeRes() {
|
|
278
|
+
const headers = {};
|
|
279
|
+
return {
|
|
280
|
+
statusCode: 200,
|
|
281
|
+
set(name, value) { headers[name.toLowerCase()] = value; return this; },
|
|
282
|
+
getHeader(name) { return headers[name.toLowerCase()]; },
|
|
283
|
+
status(code) { this.statusCode = code; return this; },
|
|
284
|
+
json() { /* swallow */ },
|
|
285
|
+
headers,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
// No req.ip, no socket — must default to 'unknown'.
|
|
289
|
+
const req = {};
|
|
290
|
+
const res1 = fakeRes();
|
|
291
|
+
limiter(req, res1, () => {});
|
|
292
|
+
assert.equal(res1.statusCode, 200);
|
|
293
|
+
const res2 = fakeRes();
|
|
294
|
+
limiter(req, res2, () => {});
|
|
295
|
+
assert.equal(res2.statusCode, 429);
|
|
296
|
+
// The unknown-IP bucket must exist.
|
|
297
|
+
assert.ok(_peekBucket('sock:unknown'));
|
|
298
|
+
});
|