mandrel-platform 0.18.0 → 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.
package/README.md CHANGED
@@ -155,32 +155,45 @@ mutate globs, bundle paths, score floors, and budgets — stays
155
155
  #### `knip.base.json`
156
156
 
157
157
  Shared Knip defaults (`ignoreExportsUsedInFile`, the `mandrel` binary +
158
- `mandrel-platform` dependency ignores). Knip supports a native `extends`,
159
- so consumers point at the base and add their own `entry` / `project`
160
- globs (`knip.json`):
158
+ `mandrel-platform` dependency ignores). Knip has **no native top-level
159
+ `extends`** to an npm-package config, so consumers import the base JSON in a
160
+ `knip.config.ts` (or `.js`) module and spread it, layering their own `entry`
161
+ / `project` globs on top:
161
162
 
162
- ```jsonc
163
- {
164
- "extends": ["mandrel-platform/knip.base.json"],
165
- "entry": ["src/index.ts", "scripts/*.ts"],
166
- "project": ["src/**", "scripts/**"]
167
- }
163
+ ```ts
164
+ // knip.config.ts — import the base and spread it
165
+ import type { KnipConfig } from "knip";
166
+ import base from "mandrel-platform/knip.base.json" with { type: "json" };
167
+
168
+ const config: KnipConfig = {
169
+ ...base,
170
+ entry: ["src/index.ts", "scripts/*.ts"],
171
+ project: ["src/**", "scripts/**"],
172
+ };
173
+
174
+ export default config;
168
175
  ```
169
176
 
170
177
  #### `stryker.base.json`
171
178
 
172
179
  Shared Stryker mutation-testing defaults (pnpm package manager,
173
180
  `perTest` coverage analysis, HTML + clear-text + progress reporters,
174
- `ignoreStatic`, a 60 s timeout, and high/low/break thresholds). Stryker
175
- supports a native `extends`; the consumer pins its test runner and
176
- mutate set (`stryker.config.json`):
181
+ `ignoreStatic`, a 60 s timeout, and high/low/break thresholds). Stryker's
182
+ `extends` resolves a **local JSON path**, not an npm-package specifier, so
183
+ the working mechanism is a `stryker.config.mjs` (or `.js` when
184
+ `"type": "module"`) module that imports the base JSON and spreads it, then
185
+ pins the test runner and mutate set:
177
186
 
178
- ```jsonc
179
- {
180
- "extends": ["mandrel-platform/stryker.base.json"],
181
- "testRunner": "vitest",
182
- "mutate": ["src/**/*.ts", "!src/**/*.test.ts"]
183
- }
187
+ ```js
188
+ // stryker.config.mjs — import the base and spread it
189
+ import base from "mandrel-platform/stryker.base.json" with { type: "json" };
190
+
191
+ /** @type {import('@stryker-mutator/api/core').PartialStrykerOptions} */
192
+ export default {
193
+ ...base,
194
+ testRunner: "vitest",
195
+ mutate: ["src/**/*.ts", "!src/**/*.test.ts"],
196
+ };
184
197
  ```
185
198
 
186
199
  #### `commitlint.base.mjs`
@@ -229,8 +242,9 @@ resolve the package export and add repo-specific rules
229
242
 
230
243
  size-limit's own config is a per-entry **array** whose paths and limits
231
244
  are inherently repo-specific, so the base ships the shared *check
232
- options* (gzip sizing, `running: false`). Spread it into each entry of
233
- your `.size-limit.json`:
245
+ options* (gzip sizing, `running: false`). A JSON config can't `import` the
246
+ base, so use a JS-module config (`.size-limit.js`) that imports the base
247
+ JSON and spreads it into each entry:
234
248
 
235
249
  ```jsonc
236
250
  // .size-limit.js — spread the base into each entry
@@ -7,11 +7,22 @@
7
7
  * decision function — `createRateLimiter` — parameterized by limit, window, a
8
8
  * key extractor, and a pluggable store, so a consumer swaps the in-memory store
9
9
  * for a Cloudflare KV / Durable Object store without re-deriving the limiter
10
- * logic. The default store is a self-pruning in-memory `Map` suitable for a
10
+ * logic. The default store is a bounded in-memory `Map` suitable for a
11
11
  * single-isolate dev / small deployment; production multi-isolate consumers
12
12
  * pass a shared store.
13
13
  */
14
14
 
15
+ /**
16
+ * Max number of live buckets the default in-memory store retains before it
17
+ * evicts. A stream of distinct keys (e.g. a rotating-IP flood, or a spoofed
18
+ * forwarded header — see `defaultKeyExtractor`) would otherwise grow the
19
+ * backing `Map` without bound. Once the store holds this many buckets, the
20
+ * least-recently-touched entry is evicted (LRU) on the next `set`. The value
21
+ * is large enough to be a non-event for legitimate single-isolate traffic
22
+ * while capping worst-case memory.
23
+ */
24
+ const DEFAULT_MAX_BUCKETS = 10_000;
25
+
15
26
  /**
16
27
  * @typedef {Object} RateLimitStore
17
28
  * @property {(key: string) => Promise<{ count: number, resetAt: number } | null> | { count: number, resetAt: number } | null} get
@@ -19,14 +30,45 @@
19
30
  */
20
31
 
21
32
  /**
22
- * In-memory fixed-window store. Self-prunes expired buckets on access so it
23
- * does not leak unboundedly. NOT shared across isolates — fine for dev / single
24
- * instance; pass a KV-backed store in production.
33
+ * In-memory fixed-window store. Bounded two ways so a stream of distinct keys
34
+ * cannot grow the backing `Map` without bound:
35
+ *
36
+ * 1. **Expired-bucket sweep.** `get` evicts a bucket the moment its window has
37
+ * elapsed, and `set` amortizes a full sweep of expired buckets across
38
+ * writes. Keys that stop being seen do not linger past their window.
39
+ * 2. **Max-size LRU cap.** The `Map` retains at most `maxBuckets` live
40
+ * entries. When a `set` would exceed the cap after sweeping, the
41
+ * least-recently-touched entry is evicted first (a `Map` preserves
42
+ * insertion order, and every touch re-inserts, so the first key is the
43
+ * LRU one). This caps worst-case memory even under an active flood of
44
+ * keys that have not yet expired.
45
+ *
46
+ * NOT shared across isolates — fine for dev / single instance; pass a
47
+ * KV-backed store in production.
48
+ *
49
+ * @param {{ maxBuckets?: number }} [options]
50
+ * Optional cap override. Defaults to {@link DEFAULT_MAX_BUCKETS}. Callers
51
+ * that pass no argument get the default — the zero-arg signature is
52
+ * preserved for existing consumers.
25
53
  * @returns {RateLimitStore}
26
54
  */
27
- export function createMemoryStore() {
55
+ export function createMemoryStore(options) {
56
+ const maxBuckets =
57
+ options && typeof options.maxBuckets === "number" && options.maxBuckets >= 1
58
+ ? Math.floor(options.maxBuckets)
59
+ : DEFAULT_MAX_BUCKETS;
28
60
  /** @type {Map<string, { count: number, resetAt: number }>} */
29
61
  const buckets = new Map();
62
+
63
+ /** Evict every bucket whose window has already elapsed. */
64
+ function sweepExpired(now) {
65
+ for (const [key, bucket] of buckets) {
66
+ if (bucket.resetAt <= now) {
67
+ buckets.delete(key);
68
+ }
69
+ }
70
+ }
71
+
30
72
  return {
31
73
  get(key) {
32
74
  const bucket = buckets.get(key);
@@ -37,10 +79,31 @@ export function createMemoryStore() {
37
79
  buckets.delete(key);
38
80
  return null;
39
81
  }
82
+ // Re-insert so the touched key moves to the most-recently-used end,
83
+ // keeping the LRU eviction order in `set` honest.
84
+ buckets.delete(key);
85
+ buckets.set(key, bucket);
40
86
  return bucket;
41
87
  },
42
88
  set(key, value) {
89
+ const now = Date.now();
90
+ // A re-`set` of an existing key must not double-count toward the cap;
91
+ // drop it first so the size check and LRU ordering stay correct.
92
+ buckets.delete(key);
43
93
  buckets.set(key, value);
94
+ if (buckets.size > maxBuckets) {
95
+ // Cheap first: reclaim anything already expired.
96
+ sweepExpired(now);
97
+ }
98
+ // Still over cap (an active flood of un-expired keys) — evict LRU
99
+ // entries (insertion-order-oldest) until we are back within bound.
100
+ while (buckets.size > maxBuckets) {
101
+ const oldest = buckets.keys().next().value;
102
+ if (oldest === undefined) {
103
+ break;
104
+ }
105
+ buckets.delete(oldest);
106
+ }
44
107
  },
45
108
  };
46
109
  }
@@ -50,10 +113,12 @@ export function createMemoryStore() {
50
113
  * @property {number} limit Max requests allowed per window. Required.
51
114
  * @property {number} windowMs Window length in milliseconds. Required.
52
115
  * @property {(request: Request) => string} [keyExtractor]
53
- * Derives the rate-limit bucket key from the request. Defaults to the
54
- * client IP from `CF-Connecting-IP` / `X-Forwarded-For` (first hop), falling
55
- * back to a constant so a missing IP fails *closed* into one shared bucket
56
- * rather than bypassing the limit per-request.
116
+ * Derives the rate-limit bucket key from the request. Defaults to
117
+ * {@link defaultKeyExtractor}, which trusts only `CF-Connecting-IP` and
118
+ * falls back to a constant shared bucket it does NOT read
119
+ * `X-Forwarded-For`, which is client-spoofable. See that function's doc for
120
+ * the trust boundary and how to opt back into `X-Forwarded-For` when your
121
+ * own edge is known to overwrite it.
57
122
  * @property {RateLimitStore} [store] Defaults to `createMemoryStore()`.
58
123
  */
59
124
 
@@ -67,24 +132,42 @@ export function createMemoryStore() {
67
132
  */
68
133
 
69
134
  /**
70
- * Default key extractor: client IP, failing closed to a shared bucket.
135
+ * Default key extractor: the Cloudflare-supplied client IP, failing closed to
136
+ * a shared bucket.
137
+ *
138
+ * **Trust boundary.** Identity is derived only from `CF-Connecting-IP`, a
139
+ * header Cloudflare's edge sets (and overwrites) from the terminating TCP
140
+ * connection — a client cannot forge it. `X-Forwarded-For` is deliberately
141
+ * NOT consulted: any client can send an arbitrary `X-Forwarded-For`, so
142
+ * keying off it lets an attacker mint a fresh bucket per request (defeating
143
+ * the limit) or impersonate another client's bucket. When no trusted client
144
+ * IP is present, we fail *closed* into one shared `"anonymous"` bucket rather
145
+ * than handing every request its own unlimited allowance.
146
+ *
147
+ * If your own reverse proxy is known to strip inbound `X-Forwarded-For` and
148
+ * append the real client, opt back in explicitly with a custom
149
+ * `keyExtractor`, e.g.:
150
+ *
151
+ * ```js
152
+ * createRateLimiter({
153
+ * limit, windowMs,
154
+ * keyExtractor: (req) =>
155
+ * req.headers.get("CF-Connecting-IP") ??
156
+ * req.headers.get("X-Forwarded-For")?.split(",").pop()?.trim() ??
157
+ * "anonymous",
158
+ * });
159
+ * ```
160
+ *
71
161
  * @param {Request} request
72
162
  * @returns {string}
73
163
  */
74
164
  function defaultKeyExtractor(request) {
75
165
  const cf = request.headers.get("CF-Connecting-IP");
76
166
  if (cf) {
77
- return cf;
78
- }
79
- const xff = request.headers.get("X-Forwarded-For");
80
- if (xff) {
81
- const first = xff.split(",")[0];
82
- if (first) {
83
- return first.trim();
84
- }
167
+ return cf.trim();
85
168
  }
86
- // No identifiable client fail closed into one shared bucket rather than
87
- // handing every anonymous request its own unlimited allowance.
169
+ // No trusted client identity. `X-Forwarded-For` is intentionally ignored
170
+ // here because it is client-spoofable; fail closed into one shared bucket.
88
171
  return "anonymous";
89
172
  }
90
173
 
package/default.json CHANGED
@@ -80,34 +80,19 @@
80
80
  "groupSlug": "clerk"
81
81
  },
82
82
  {
83
- "description": "Keep .nvmrc and engines.node in lockstep — bump both when Node.js advances",
83
+ "description": "Keep .nvmrc and engines.node in lockstep — Renovate's nvm manager bumps .nvmrc; group the matching engines.node bump into the same PR. NOTE: automatically rewriting engines.node requires a postUpgradeTasks command, which hosted Mend (the Renovate app) DISABLES for security. Self-hosted Renovate runners must allowlist the command via allowedCommands; on hosted Mend, bump engines.node by hand in the same PR — see docs/runbooks/dependency-update.md.",
84
84
  "matchManagers": ["nvm"],
85
85
  "matchPackageNames": ["node"],
86
- "postUpdateOptions": ["nodeToolchainFile"],
87
- "postUpgradeTasks": {
88
- "commands": [
89
- "node -e \"const fs=require('fs'),path=require('path');const ver=fs.readFileSync('.nvmrc','utf8').trim().replace(/^v/,'');function walk(d){return fs.readdirSync(d,{withFileTypes:true}).flatMap(e=>{const p=path.join(d,e.name);return e.isDirectory()&&e.name!=='node_modules'?walk(p):e.isFile()&&e.name==='package.json'?[p]:[]});}walk('.').forEach(f=>{try{const pkg=JSON.parse(fs.readFileSync(f,'utf8'));if(pkg.engines&&pkg.engines.node){pkg.engines.node=ver;fs.writeFileSync(f,JSON.stringify(pkg,null,2)+'\\n','utf8');console.log('Updated engines.node to '+ver+' in '+f);}}catch(e){}});\""
90
- ],
91
- "fileFilters": ["**/.nvmrc", "**/package.json"]
92
- },
93
86
  "automerge": true,
94
87
  "automergeType": "pr",
95
88
  "platformAutomerge": true
96
89
  },
97
90
  {
98
- "description": "Advance wrangler compatibility_date to today when wrangler is bumped",
91
+ "description": "Group wrangler bumps so the wrangler compatibility_date can be advanced alongside. NOTE: automatically rewriting compatibility_date requires a postUpgradeTasks command, which hosted Mend (the Renovate app) DISABLES for security. Self-hosted Renovate runners must allowlist the command via allowedCommands; on hosted Mend, advance compatibility_date by hand in the same PR — see docs/runbooks/dependency-update.md.",
99
92
  "matchManagers": ["npm"],
100
93
  "matchPackageNames": ["wrangler"],
101
- "postUpgradeTasks": {
102
- "commands": [
103
- "node -e \"const fs=require('fs'),path=require('path');function walk(d){return fs.readdirSync(d,{withFileTypes:true}).flatMap(e=>{const p=path.join(d,e.name);return e.isDirectory()&&e.name!=='node_modules'?walk(p):e.isFile()&&/wrangler\\.(json|jsonc|toml)$/.test(e.name)?[p]:[]});}const today=new Date().toISOString().slice(0,10);walk('.').forEach(f=>{let c=fs.readFileSync(f,'utf8');const u=c.replace(/compatibility_date\\s*=\\s*\\\"[0-9-]+\\\"/g,'compatibility_date = \\\"'+today+'\\\"').replace(/\\\"compatibility_date\\\"\\s*:\\s*\\\"[0-9-]+\\\"/g,'\\\"compatibility_date\\\": \\\"'+today+'\\\"');if(u!==c){fs.writeFileSync(f,u,'utf8');console.log('Updated compatibility_date in '+f);}});\""
104
- ],
105
- "fileFilters": [
106
- "**/wrangler.json",
107
- "**/wrangler.jsonc",
108
- "**/wrangler.toml"
109
- ]
110
- }
94
+ "groupName": "wrangler",
95
+ "groupSlug": "wrangler"
111
96
  }
112
97
  ]
113
98
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel-platform",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Shared CI/deploy workflows, composite toolchain action, npm config package, Renovate preset, and operator runbook templates.",
5
5
  "license": "MIT",
6
6
  "repository": {