mandrel-platform 0.17.2 → 0.19.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 (43) hide show
  1. package/README.md +254 -34
  2. package/config/commitlint.base.mjs +36 -0
  3. package/config/edge-security/rate-limit.mjs +103 -20
  4. package/config/repo-settings.schema.json +78 -0
  5. package/default.json +4 -19
  6. package/package.json +2 -1
  7. package/scripts/apply-uptime-monitors.mjs +378 -0
  8. package/scripts/apply-uptime-monitors.test.mjs +372 -0
  9. package/scripts/audit-check.mjs +321 -180
  10. package/scripts/audit-check.test.mjs +263 -0
  11. package/scripts/check-action-pins.mjs +106 -173
  12. package/scripts/check-coverage-threshold.mjs +44 -6
  13. package/scripts/check-coverage-threshold.test.mjs +43 -0
  14. package/scripts/check-docs-staleness.mjs +130 -81
  15. package/scripts/check-docs-staleness.test.mjs +130 -0
  16. package/scripts/check-pin-drift.mjs +61 -110
  17. package/scripts/check-pin-drift.test.mjs +175 -3
  18. package/scripts/check-repo-settings.mjs +363 -0
  19. package/scripts/check-repo-settings.test.mjs +320 -0
  20. package/scripts/check-required-contexts.mjs +247 -129
  21. package/scripts/check-required-contexts.test.mjs +137 -0
  22. package/scripts/check-ruleset.mjs +435 -0
  23. package/scripts/check-ruleset.test.mjs +439 -0
  24. package/scripts/check-workflow-portability.mjs +163 -118
  25. package/scripts/check-workflow-portability.test.mjs +199 -0
  26. package/scripts/check-wrangler-baseline.mjs +514 -0
  27. package/scripts/check-wrangler-baseline.test.mjs +454 -0
  28. package/scripts/edge-security.test.mjs +81 -1
  29. package/scripts/lib/args.mjs +93 -0
  30. package/scripts/lib/args.test.mjs +152 -0
  31. package/scripts/lib/gh-json.mjs +119 -0
  32. package/scripts/lib/semver-duration.mjs +84 -0
  33. package/scripts/lib/uses-pins.mjs +220 -0
  34. package/scripts/lib/uses-pins.test.mjs +219 -0
  35. package/scripts/lib/walk.mjs +74 -0
  36. package/scripts/platform-repair.mjs +9 -3
  37. package/scripts/platform-sync.mjs +533 -5
  38. package/scripts/platform-sync.test.mjs +477 -0
  39. package/scripts/update-semgrep-rules.mjs +76 -5
  40. package/templates/runbooks/README.md +9 -5
  41. package/templates/runbooks/branch-protection-setup.md +9 -3
  42. package/templates/workflows/deploy-staging.yml +86 -0
  43. package/templates/workflows/uptime-apply.yml +54 -0
@@ -0,0 +1,454 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-wrangler-baseline.test.mjs — node:test suite for the Story #177
4
+ * wrangler configuration baseline gate.
5
+ *
6
+ * Exercises the pure rule/parser functions directly and the full
7
+ * `runCli` pipeline against real temp files (wrangler.toml AND
8
+ * wrangler.jsonc) so both config formats are covered end to end.
9
+ *
10
+ * Run: node scripts/check-wrangler-baseline.test.mjs (or `node --test scripts/`)
11
+ */
12
+
13
+ import assert from 'node:assert/strict';
14
+ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
15
+ import { tmpdir } from 'node:os';
16
+ import { join } from 'node:path';
17
+ import { afterEach, beforeEach, test } from 'node:test';
18
+ import {
19
+ parseArgv,
20
+ resolveConfigPath,
21
+ parseJsonc,
22
+ parseWranglerToml,
23
+ parseWranglerConfig,
24
+ readExceptions,
25
+ checkEnvSplit,
26
+ checkLogpush,
27
+ checkAnalyticsEngine,
28
+ checkCompatibilityDate,
29
+ evaluateBaseline,
30
+ renderReport,
31
+ runCli,
32
+ } from './check-wrangler-baseline.mjs';
33
+
34
+ let tmpDir;
35
+
36
+ beforeEach(() => {
37
+ tmpDir = mkdtempSync(join(tmpdir(), 'wrangler-baseline-test-'));
38
+ });
39
+
40
+ afterEach(() => {
41
+ rmSync(tmpDir, { recursive: true, force: true });
42
+ });
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // parseArgv
46
+ // ---------------------------------------------------------------------------
47
+
48
+ test('parseArgv defaults maxAgeDays to 90 and flags to false', () => {
49
+ const parsed = parseArgv([]);
50
+ assert.deepEqual(parsed, { file: null, maxAgeDays: 90, warnOnly: false, json: false, help: false });
51
+ });
52
+
53
+ test('parseArgv reads --file, --max-age-days, --warn-only, --json, --help', () => {
54
+ const parsed = parseArgv(['--file', 'custom.toml', '--max-age-days', '30', '--warn-only', '--json', '--help']);
55
+ assert.deepEqual(parsed, { file: 'custom.toml', maxAgeDays: 30, warnOnly: true, json: true, help: true });
56
+ });
57
+
58
+ test('parseArgv falls back to 90 on a non-numeric --max-age-days', () => {
59
+ const parsed = parseArgv(['--max-age-days', 'nope']);
60
+ assert.equal(parsed.maxAgeDays, 90);
61
+ });
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // resolveConfigPath
65
+ // ---------------------------------------------------------------------------
66
+
67
+ test('resolveConfigPath returns null when no config exists', () => {
68
+ assert.equal(resolveConfigPath(null, tmpDir), null);
69
+ });
70
+
71
+ test('resolveConfigPath auto-detects wrangler.jsonc over wrangler.toml', () => {
72
+ writeFileSync(join(tmpDir, 'wrangler.toml'), 'name = "x"\n');
73
+ writeFileSync(join(tmpDir, 'wrangler.jsonc'), '{"name": "x"}\n');
74
+ assert.equal(resolveConfigPath(null, tmpDir), join(tmpDir, 'wrangler.jsonc'));
75
+ });
76
+
77
+ test('resolveConfigPath falls back to wrangler.toml when no jsonc/json present', () => {
78
+ writeFileSync(join(tmpDir, 'wrangler.toml'), 'name = "x"\n');
79
+ assert.equal(resolveConfigPath(null, tmpDir), join(tmpDir, 'wrangler.toml'));
80
+ });
81
+
82
+ test('resolveConfigPath honors an explicit --file and returns null if missing', () => {
83
+ const explicit = join(tmpDir, 'custom.jsonc');
84
+ assert.equal(resolveConfigPath(explicit, tmpDir), null);
85
+ writeFileSync(explicit, '{}');
86
+ assert.equal(resolveConfigPath(explicit, tmpDir), explicit);
87
+ });
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // parseJsonc / parseWranglerToml / parseWranglerConfig
91
+ // ---------------------------------------------------------------------------
92
+
93
+ test('parseJsonc strips // and block comments', () => {
94
+ const text = `{
95
+ // top comment
96
+ "name": "x", /* inline */
97
+ "logpush": true
98
+ }`;
99
+ assert.deepEqual(parseJsonc(text), { name: 'x', logpush: true });
100
+ });
101
+
102
+ test('parseWranglerToml parses top-level keys, booleans, and strings', () => {
103
+ const text = `
104
+ name = "my-worker"
105
+ logpush = true
106
+ compatibility_date = "2026-01-01"
107
+ `;
108
+ const parsed = parseWranglerToml(text);
109
+ assert.equal(parsed.name, 'my-worker');
110
+ assert.equal(parsed.logpush, true);
111
+ assert.equal(parsed.compatibility_date, '2026-01-01');
112
+ });
113
+
114
+ test('parseWranglerToml parses [env.<name>] tables as nested objects', () => {
115
+ const text = `
116
+ name = "my-worker"
117
+
118
+ [env.staging]
119
+ logpush = true
120
+
121
+ [env.production]
122
+ logpush = false
123
+ `;
124
+ const parsed = parseWranglerToml(text);
125
+ assert.deepEqual(Object.keys(parsed.env), ['staging', 'production']);
126
+ assert.equal(parsed.env.staging.logpush, true);
127
+ assert.equal(parsed.env.production.logpush, false);
128
+ });
129
+
130
+ test('parseWranglerToml parses [[analytics_engine_datasets]] as an array of tables', () => {
131
+ const text = `
132
+ name = "my-worker"
133
+
134
+ [[analytics_engine_datasets]]
135
+ binding = "AE"
136
+ dataset = "events"
137
+ `;
138
+ const parsed = parseWranglerToml(text);
139
+ assert.ok(Array.isArray(parsed.analytics_engine_datasets));
140
+ assert.equal(parsed.analytics_engine_datasets.length, 1);
141
+ assert.equal(parsed.analytics_engine_datasets[0].binding, 'AE');
142
+ });
143
+
144
+ test('parseWranglerToml parses [[env.production.analytics_engine_datasets]] nested under an env table', () => {
145
+ const text = `
146
+ [[env.production.analytics_engine_datasets]]
147
+ binding = "AE"
148
+ dataset = "events"
149
+ `;
150
+ const parsed = parseWranglerToml(text);
151
+ assert.ok(Array.isArray(parsed.env.production.analytics_engine_datasets));
152
+ assert.equal(parsed.env.production.analytics_engine_datasets[0].dataset, 'events');
153
+ });
154
+
155
+ test('parseWranglerToml strips trailing # comments outside quotes', () => {
156
+ const text = `compatibility_date = "2026-01-01" # bumped by renovate\n`;
157
+ assert.equal(parseWranglerToml(text).compatibility_date, '2026-01-01');
158
+ });
159
+
160
+ test('parseWranglerToml parses a [mandrel.wranglerBaselineExceptions] table', () => {
161
+ const text = `
162
+ [mandrel.wranglerBaselineExceptions]
163
+ analyticsEngine = "no telemetry sink for this static-asset Worker"
164
+ `;
165
+ const parsed = parseWranglerToml(text);
166
+ assert.equal(
167
+ parsed.mandrel.wranglerBaselineExceptions.analyticsEngine,
168
+ 'no telemetry sink for this static-asset Worker',
169
+ );
170
+ });
171
+
172
+ test('parseWranglerConfig dispatches by extension', () => {
173
+ assert.deepEqual(parseWranglerConfig('wrangler.jsonc', '{"a":1}'), { a: 1 });
174
+ assert.deepEqual(parseWranglerConfig('wrangler.toml', 'a = 1\n'), { a: 1 });
175
+ });
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // readExceptions
179
+ // ---------------------------------------------------------------------------
180
+
181
+ test('readExceptions returns {} when no mandrel block is present', () => {
182
+ assert.deepEqual(readExceptions({}), {});
183
+ });
184
+
185
+ test('readExceptions reads string-valued exceptions and ignores non-strings', () => {
186
+ const config = {
187
+ mandrel: {
188
+ wranglerBaselineExceptions: {
189
+ logpush: 'no log sink budget for this Worker',
190
+ analyticsEngine: 42,
191
+ },
192
+ },
193
+ };
194
+ assert.deepEqual(readExceptions(config), { logpush: 'no log sink budget for this Worker' });
195
+ });
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // Individual rules
199
+ // ---------------------------------------------------------------------------
200
+
201
+ test('checkEnvSplit fails with no env block', () => {
202
+ assert.equal(checkEnvSplit({}).pass, false);
203
+ });
204
+
205
+ test('checkEnvSplit passes with at least one named environment', () => {
206
+ assert.equal(checkEnvSplit({ env: { staging: {} } }).pass, true);
207
+ });
208
+
209
+ test('checkLogpush passes on a top-level logpush = true', () => {
210
+ assert.equal(checkLogpush({ logpush: true }).pass, true);
211
+ });
212
+
213
+ test('checkLogpush passes when every named env sets logpush = true', () => {
214
+ const config = { env: { staging: { logpush: true }, production: { logpush: true } } };
215
+ assert.equal(checkLogpush(config).pass, true);
216
+ });
217
+
218
+ test('checkLogpush fails when only some named envs set logpush', () => {
219
+ const config = { env: { staging: { logpush: true }, production: {} } };
220
+ assert.equal(checkLogpush(config).pass, false);
221
+ });
222
+
223
+ test('checkLogpush fails with no logpush anywhere', () => {
224
+ assert.equal(checkLogpush({}).pass, false);
225
+ });
226
+
227
+ test('checkAnalyticsEngine passes with a top-level binding', () => {
228
+ const config = { analytics_engine_datasets: [{ binding: 'AE' }] };
229
+ assert.equal(checkAnalyticsEngine(config).pass, true);
230
+ });
231
+
232
+ test('checkAnalyticsEngine passes with a binding on a named environment', () => {
233
+ const config = { env: { production: { analytics_engine_datasets: [{ binding: 'AE' }] } } };
234
+ assert.equal(checkAnalyticsEngine(config).pass, true);
235
+ });
236
+
237
+ test('checkAnalyticsEngine fails with no binding anywhere', () => {
238
+ assert.equal(checkAnalyticsEngine({}).pass, false);
239
+ });
240
+
241
+ test('checkCompatibilityDate passes within the policy window', () => {
242
+ const now = new Date('2026-07-01T00:00:00Z');
243
+ const result = checkCompatibilityDate({ compatibility_date: '2026-06-01' }, 90, now);
244
+ assert.equal(result.pass, true);
245
+ });
246
+
247
+ test('checkCompatibilityDate fails beyond the policy window', () => {
248
+ const now = new Date('2026-07-01T00:00:00Z');
249
+ const result = checkCompatibilityDate({ compatibility_date: '2025-01-01' }, 90, now);
250
+ assert.equal(result.pass, false);
251
+ });
252
+
253
+ test('checkCompatibilityDate fails when the field is missing', () => {
254
+ assert.equal(checkCompatibilityDate({}, 90).pass, false);
255
+ });
256
+
257
+ test('checkCompatibilityDate fails on a malformed date string', () => {
258
+ assert.equal(checkCompatibilityDate({ compatibility_date: 'not-a-date' }, 90).pass, false);
259
+ });
260
+
261
+ // ---------------------------------------------------------------------------
262
+ // evaluateBaseline (exception reconciliation)
263
+ // ---------------------------------------------------------------------------
264
+
265
+ test('evaluateBaseline reports zero violations for a fully-compliant config', () => {
266
+ const now = new Date('2026-07-01T00:00:00Z');
267
+ const config = {
268
+ env: { production: {} },
269
+ logpush: true,
270
+ analytics_engine_datasets: [{ binding: 'AE' }],
271
+ compatibility_date: '2026-06-01',
272
+ };
273
+ const report = evaluateBaseline(config, 90, now);
274
+ assert.deepEqual(report.violations, []);
275
+ assert.equal(report.findings.every((f) => f.pass), true);
276
+ });
277
+
278
+ test('evaluateBaseline reports every failing rule as a violation with no exceptions declared', () => {
279
+ const report = evaluateBaseline({}, 90, new Date('2026-07-01T00:00:00Z'));
280
+ const ids = report.violations.map((v) => v.id).sort();
281
+ assert.deepEqual(ids, ['analytics-engine', 'compat-date-stale', 'env-split', 'logpush']);
282
+ });
283
+
284
+ test('evaluateBaseline suppresses a violation with a declared exception, but still reports it', () => {
285
+ const config = {
286
+ env: { production: {} },
287
+ logpush: true,
288
+ compatibility_date: '2026-06-01',
289
+ mandrel: {
290
+ wranglerBaselineExceptions: {
291
+ 'analytics-engine': 'no telemetry sink for this static-asset Worker',
292
+ },
293
+ },
294
+ };
295
+ const report = evaluateBaseline(config, 90, new Date('2026-07-01T00:00:00Z'));
296
+ assert.deepEqual(report.violations, []);
297
+ assert.deepEqual(report.exceptions, [
298
+ { id: 'analytics-engine', reason: 'no telemetry sink for this static-asset Worker' },
299
+ ]);
300
+ const aeFinding = report.findings.find((f) => f.id === 'analytics-engine');
301
+ assert.equal(aeFinding.excepted, true);
302
+ });
303
+
304
+ // ---------------------------------------------------------------------------
305
+ // renderReport
306
+ // ---------------------------------------------------------------------------
307
+
308
+ test('renderReport prints a pass line for a clean config', () => {
309
+ const report = evaluateBaseline(
310
+ {
311
+ env: { production: {} },
312
+ logpush: true,
313
+ analytics_engine_datasets: [{ binding: 'AE' }],
314
+ compatibility_date: '2026-06-01',
315
+ },
316
+ 90,
317
+ new Date('2026-07-01T00:00:00Z'),
318
+ );
319
+ const text = renderReport(report, 'wrangler.toml');
320
+ assert.match(text, /All baseline rules satisfied/);
321
+ });
322
+
323
+ test('renderReport prints a failure summary and points at the exception mechanism', () => {
324
+ const report = evaluateBaseline({}, 90, new Date('2026-07-01T00:00:00Z'));
325
+ const text = renderReport(report, 'wrangler.toml');
326
+ assert.match(text, /violation\(s\)/);
327
+ assert.match(text, /wranglerBaselineExceptions/);
328
+ });
329
+
330
+ // ---------------------------------------------------------------------------
331
+ // runCli — end to end against real temp files, both formats
332
+ // ---------------------------------------------------------------------------
333
+
334
+ function noopStreams() {
335
+ let out = '';
336
+ let err = '';
337
+ return { stdout: { write: (s) => (out += s) }, stderr: { write: (s) => (err += s) }, get out() { return out; }, get err() { return err; } };
338
+ }
339
+
340
+ test('runCli exits 0 with a no-op message when no wrangler config exists', () => {
341
+ const streams = noopStreams();
342
+ const exit = runCli({ argv: [], cwd: tmpDir, stdout: streams.stdout, stderr: streams.stderr });
343
+ assert.equal(exit, 0);
344
+ assert.match(streams.out, /No wrangler\.toml/);
345
+ });
346
+
347
+ test('runCli exits 1 on a violating wrangler.toml (strict/default mode)', () => {
348
+ writeFileSync(join(tmpDir, 'wrangler.toml'), 'name = "x"\n');
349
+ const streams = noopStreams();
350
+ const exit = runCli({ argv: [], cwd: tmpDir, stdout: streams.stdout, stderr: streams.stderr, now: new Date('2026-07-01T00:00:00Z') });
351
+ assert.equal(exit, 1);
352
+ assert.match(streams.out, /❌/);
353
+ });
354
+
355
+ test('runCli exits 0 on a violating config with --warn-only (advisory rollout)', () => {
356
+ writeFileSync(join(tmpDir, 'wrangler.toml'), 'name = "x"\n');
357
+ const streams = noopStreams();
358
+ const exit = runCli({
359
+ argv: ['--warn-only'],
360
+ cwd: tmpDir,
361
+ stdout: streams.stdout,
362
+ stderr: streams.stderr,
363
+ now: new Date('2026-07-01T00:00:00Z'),
364
+ });
365
+ assert.equal(exit, 0);
366
+ assert.match(streams.out, /❌/);
367
+ });
368
+
369
+ test('runCli exits 0 on a fully-compliant wrangler.jsonc', () => {
370
+ const jsonc = `{
371
+ // named environment split
372
+ "env": { "production": {} },
373
+ "logpush": true,
374
+ "analytics_engine_datasets": [{ "binding": "AE", "dataset": "events" }],
375
+ "compatibility_date": "2026-06-15"
376
+ }`;
377
+ writeFileSync(join(tmpDir, 'wrangler.jsonc'), jsonc);
378
+ const streams = noopStreams();
379
+ const exit = runCli({ argv: [], cwd: tmpDir, stdout: streams.stdout, stderr: streams.stderr, now: new Date('2026-07-01T00:00:00Z') });
380
+ assert.equal(exit, 0);
381
+ assert.match(streams.out, /All baseline rules satisfied/);
382
+ });
383
+
384
+ test('runCli exits 0 on a fully-compliant wrangler.toml', () => {
385
+ const toml = `
386
+ name = "my-worker"
387
+ logpush = true
388
+ compatibility_date = "2026-06-15"
389
+
390
+ [env.production]
391
+
392
+ [[analytics_engine_datasets]]
393
+ binding = "AE"
394
+ dataset = "events"
395
+ `;
396
+ writeFileSync(join(tmpDir, 'wrangler.toml'), toml);
397
+ const streams = noopStreams();
398
+ const exit = runCli({ argv: [], cwd: tmpDir, stdout: streams.stdout, stderr: streams.stderr, now: new Date('2026-07-01T00:00:00Z') });
399
+ assert.equal(exit, 0);
400
+ });
401
+
402
+ test('runCli --json emits a machine-readable envelope', () => {
403
+ writeFileSync(join(tmpDir, 'wrangler.toml'), 'name = "x"\n');
404
+ const streams = noopStreams();
405
+ const exit = runCli({
406
+ argv: ['--json', '--warn-only'],
407
+ cwd: tmpDir,
408
+ stdout: streams.stdout,
409
+ stderr: streams.stderr,
410
+ now: new Date('2026-07-01T00:00:00Z'),
411
+ });
412
+ assert.equal(exit, 0);
413
+ const parsed = JSON.parse(streams.out);
414
+ assert.equal(parsed.kind, 'wrangler-baseline-report');
415
+ assert.equal(parsed.found, true);
416
+ assert.ok(parsed.violations.length > 0);
417
+ });
418
+
419
+ test('runCli --json reports found:false when no config exists', () => {
420
+ const streams = noopStreams();
421
+ const exit = runCli({ argv: ['--json'], cwd: tmpDir, stdout: streams.stdout, stderr: streams.stderr });
422
+ assert.equal(exit, 0);
423
+ const parsed = JSON.parse(streams.out);
424
+ assert.equal(parsed.found, false);
425
+ });
426
+
427
+ test('runCli --file honors an explicit path outside the default candidates', () => {
428
+ writeFileSync(join(tmpDir, 'custom-wrangler.jsonc'), '{"logpush": true}');
429
+ const streams = noopStreams();
430
+ const exit = runCli({
431
+ argv: ['--file', 'custom-wrangler.jsonc', '--warn-only'],
432
+ cwd: tmpDir,
433
+ stdout: streams.stdout,
434
+ stderr: streams.stderr,
435
+ now: new Date('2026-07-01T00:00:00Z'),
436
+ });
437
+ assert.equal(exit, 0);
438
+ assert.match(streams.out, /custom-wrangler\.jsonc/);
439
+ });
440
+
441
+ test('runCli exits 1 with a parse error on malformed JSON', () => {
442
+ writeFileSync(join(tmpDir, 'wrangler.jsonc'), '{ not valid json');
443
+ const streams = noopStreams();
444
+ const exit = runCli({ argv: [], cwd: tmpDir, stdout: streams.stdout, stderr: streams.stderr });
445
+ assert.equal(exit, 1);
446
+ assert.match(streams.err, /failed to parse/);
447
+ });
448
+
449
+ test('runCli --help prints usage and exits 0 without touching the filesystem', () => {
450
+ const streams = noopStreams();
451
+ const exit = runCli({ argv: ['--help'], cwd: tmpDir, stdout: streams.stdout, stderr: streams.stderr });
452
+ assert.equal(exit, 0);
453
+ assert.match(streams.out, /check-wrangler-baseline\.mjs/);
454
+ });
@@ -252,12 +252,92 @@ test("default key extractor fails closed to a shared bucket when no IP", async (
252
252
  assert.equal((await limiter.check(bare())).allowed, false); // same "anonymous" bucket
253
253
  });
254
254
 
255
- test("memory store self-prunes expired buckets", async () => {
255
+ test("memory store evicts expired buckets on access", async () => {
256
256
  const store = createMemoryStore();
257
257
  store.set("k", { count: 5, resetAt: Date.now() - 1 });
258
258
  assert.equal(store.get("k"), null);
259
259
  });
260
260
 
261
+ test("memory store sweeps expired buckets on write so distinct keys do not leak", () => {
262
+ const store = createMemoryStore({ maxBuckets: 4 });
263
+ // Seed the store to capacity with already-expired buckets.
264
+ for (let i = 0; i < 4; i += 1) {
265
+ store.set(`expired-${i}`, { count: 1, resetAt: Date.now() - 1 });
266
+ }
267
+ // One more distinct key triggers the amortized sweep; the expired entries
268
+ // are reclaimed instead of growing the map past the cap.
269
+ store.set("live", { count: 1, resetAt: Date.now() + 60_000 });
270
+ assert.equal(store.get("live") !== null, true);
271
+ for (let i = 0; i < 4; i += 1) {
272
+ assert.equal(store.get(`expired-${i}`), null);
273
+ }
274
+ });
275
+
276
+ test("memory store caps live-key count via LRU eviction under a distinct-key flood", () => {
277
+ const maxBuckets = 8;
278
+ const store = createMemoryStore({ maxBuckets });
279
+ const resetAt = Date.now() + 60_000; // all un-expired, so only the cap bounds size
280
+ // A flood of far more distinct, still-live keys than the cap.
281
+ for (let i = 0; i < maxBuckets * 50; i += 1) {
282
+ store.set(`ip-${i}`, { count: 1, resetAt });
283
+ }
284
+ // Size stays bounded — the map never grew to the flood count.
285
+ let liveCount = 0;
286
+ for (let i = 0; i < maxBuckets * 50; i += 1) {
287
+ if (store.get(`ip-${i}`) !== null) {
288
+ liveCount += 1;
289
+ }
290
+ }
291
+ assert.equal(liveCount <= maxBuckets, true, `expected <= ${maxBuckets} live buckets, got ${liveCount}`);
292
+ // The most-recently-inserted key survived; the oldest was evicted (LRU).
293
+ assert.equal(store.get(`ip-${maxBuckets * 50 - 1}`) !== null, true);
294
+ assert.equal(store.get("ip-0"), null);
295
+ });
296
+
297
+ test("a rate limiter over the bounded store stays memory-bounded across a distinct-key flood", async () => {
298
+ const store = createMemoryStore({ maxBuckets: 16 });
299
+ const limiter = createRateLimiter({ limit: 1, windowMs: 60_000, store });
300
+ // Each request presents a distinct client IP; without the cap this would
301
+ // grow one bucket per request forever.
302
+ for (let i = 0; i < 5_000; i += 1) {
303
+ await limiter.check(ipReq(`10.0.${(i >> 8) & 255}.${i & 255}`));
304
+ }
305
+ let liveCount = 0;
306
+ for (let i = 0; i < 5_000; i += 1) {
307
+ if (store.get(`10.0.${(i >> 8) & 255}.${i & 255}`) !== null) {
308
+ liveCount += 1;
309
+ }
310
+ }
311
+ assert.equal(liveCount <= 16, true, `expected <= 16 live buckets, got ${liveCount}`);
312
+ });
313
+
314
+ test("default key extractor ignores spoofable X-Forwarded-For", async () => {
315
+ const limiter = createRateLimiter({ limit: 1, windowMs: 60_000 });
316
+ // Two requests with different X-Forwarded-For values but no CF-Connecting-IP
317
+ // must land in the SAME bucket — a client cannot mint fresh buckets by
318
+ // rotating a forged X-Forwarded-For.
319
+ const xffReq = (xff) =>
320
+ new Request("https://api.example.com/", {
321
+ headers: { "X-Forwarded-For": xff },
322
+ });
323
+ assert.equal((await limiter.check(xffReq("1.2.3.4"))).allowed, true);
324
+ // Different forged header, same shared "anonymous" bucket → denied.
325
+ assert.equal((await limiter.check(xffReq("5.6.7.8"))).allowed, false);
326
+ });
327
+
328
+ test("default key extractor keys off trusted CF-Connecting-IP even when X-Forwarded-For differs", async () => {
329
+ const limiter = createRateLimiter({ limit: 1, windowMs: 60_000 });
330
+ const req = (cf, xff) =>
331
+ new Request("https://api.example.com/", {
332
+ headers: { "CF-Connecting-IP": cf, "X-Forwarded-For": xff },
333
+ });
334
+ // Distinct trusted IPs get distinct buckets regardless of the forged XFF.
335
+ assert.equal((await limiter.check(req("1.1.1.1", "9.9.9.9"))).allowed, true);
336
+ assert.equal((await limiter.check(req("2.2.2.2", "9.9.9.9"))).allowed, true);
337
+ // Same trusted IP, different forged XFF → same bucket → denied.
338
+ assert.equal((await limiter.check(req("1.1.1.1", "8.8.8.8"))).allowed, false);
339
+ });
340
+
261
341
  test("rateLimitHeaders includes Retry-After only when denied", () => {
262
342
  const allowed = rateLimitHeaders({ allowed: true, limit: 10, remaining: 9, resetAt: Date.now() + 1000, retryAfter: 0 });
263
343
  assert.equal("Retry-After" in allowed, false);
@@ -0,0 +1,93 @@
1
+ /**
2
+ * scripts/lib/args.mjs
3
+ *
4
+ * The single argv-parsing seam shared by the pin-tooling CLIs
5
+ * (`check-action-pins.mjs`, `check-workflow-portability.mjs`, …). Each of
6
+ * those scripts had grown its own hand-rolled `parseArgs` — one throwing on
7
+ * an unknown flag, one silently ignoring it, and both re-implementing the
8
+ * same "take the next argv slot as this flag's value" dance. They had already
9
+ * drifted (different alias support, different unknown-flag policy), which is
10
+ * exactly the duplication Story #203 consolidates.
11
+ *
12
+ * `parseFlags(argv, spec)` is a tiny, dependency-free flag reader driven by a
13
+ * declarative spec. It intentionally does NOT try to be a full getopt: it
14
+ * supports the two shapes the pin tooling actually uses —
15
+ *
16
+ * • `string` flags — `--workflows-dir <value>` (optionally with aliases,
17
+ * e.g. `-w`), consuming the next argv slot as the value.
18
+ * • `boolean` flags — `--no-pin-check`, `--help` (present ⇒ the configured
19
+ * boolean value, default the inverse).
20
+ *
21
+ * The unknown-flag policy is a per-call knob (`onUnknown`) so a strict lint
22
+ * (fail loudly on a typo'd flag) and a lenient CLI (ignore stray args) can
23
+ * share one parser without either losing its behavior.
24
+ *
25
+ * This module reads no environment and performs no I/O, so the sibling
26
+ * `args.test.mjs` suite exercises it entirely offline.
27
+ */
28
+
29
+ /**
30
+ * @typedef {Object} FlagSpec
31
+ * @property {"string" | "boolean"} type How to consume the flag.
32
+ * @property {string} dest The result key to write.
33
+ * @property {*} [default] Default value when the flag is absent.
34
+ * @property {boolean} [value] For a boolean flag, the value to set
35
+ * when the flag IS present (default true).
36
+ */
37
+
38
+ /**
39
+ * Parse an argv slice (the array AFTER `node script.mjs`) into an options
40
+ * object driven by `spec`.
41
+ *
42
+ * @param {string[]} argv
43
+ * @param {Object} spec
44
+ * @param {Record<string, FlagSpec>} spec.flags Map of canonical flag token
45
+ * (e.g. `"--workflows-dir"`) to its {@link FlagSpec}.
46
+ * @param {Record<string, string>} [spec.aliases] Map of alias token
47
+ * (e.g. `"-w"`) to a canonical flag token present in `spec.flags`.
48
+ * @param {"throw" | "ignore"} [spec.onUnknown] What to do with an argument
49
+ * that is not a known flag or alias. `"throw"` (default) fails loudly;
50
+ * `"ignore"` skips it.
51
+ * @returns {Record<string, *>} The resolved options, seeded from each flag's
52
+ * `default`.
53
+ */
54
+ export function parseFlags(argv, spec) {
55
+ const flags = spec?.flags ?? {};
56
+ const aliases = spec?.aliases ?? {};
57
+ const onUnknown = spec?.onUnknown ?? "throw";
58
+
59
+ // Seed the result with every flag's declared default.
60
+ const opts = {};
61
+ for (const def of Object.values(flags)) {
62
+ opts[def.dest] = "default" in def ? def.default : undefined;
63
+ }
64
+
65
+ const canonical = (arg) => {
66
+ if (Object.prototype.hasOwnProperty.call(flags, arg)) return arg;
67
+ if (Object.prototype.hasOwnProperty.call(aliases, arg)) return aliases[arg];
68
+ return null;
69
+ };
70
+
71
+ for (let i = 0; i < argv.length; i++) {
72
+ const arg = argv[i];
73
+ const key = canonical(arg);
74
+ if (key === null) {
75
+ if (onUnknown === "ignore") continue;
76
+ throw new Error(`unknown argument "${arg}"`);
77
+ }
78
+ const def = flags[key];
79
+ if (def.type === "boolean") {
80
+ opts[def.dest] = "value" in def ? def.value : true;
81
+ continue;
82
+ }
83
+ // string flag: consume the next argv slot as the value.
84
+ const next = argv[i + 1];
85
+ if (next === undefined || next.startsWith("--")) {
86
+ throw new Error(`missing value for "${arg}"`);
87
+ }
88
+ opts[def.dest] = next;
89
+ i++;
90
+ }
91
+
92
+ return opts;
93
+ }