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 +34 -20
- package/config/edge-security/rate-limit.mjs +103 -20
- package/default.json +4 -19
- package/package.json +1 -1
- package/scripts/audit-check.mjs +321 -180
- package/scripts/audit-check.test.mjs +263 -0
- package/scripts/check-action-pins.mjs +106 -173
- package/scripts/check-coverage-threshold.mjs +44 -6
- package/scripts/check-coverage-threshold.test.mjs +43 -0
- package/scripts/check-docs-staleness.mjs +130 -81
- package/scripts/check-docs-staleness.test.mjs +130 -0
- package/scripts/check-pin-drift.mjs +61 -110
- package/scripts/check-pin-drift.test.mjs +175 -3
- package/scripts/check-workflow-portability.mjs +163 -118
- package/scripts/check-workflow-portability.test.mjs +199 -0
- package/scripts/edge-security.test.mjs +81 -1
- package/scripts/lib/args.mjs +93 -0
- package/scripts/lib/args.test.mjs +152 -0
- package/scripts/lib/gh-json.mjs +119 -0
- package/scripts/lib/semver-duration.mjs +84 -0
- package/scripts/lib/uses-pins.mjs +220 -0
- package/scripts/lib/uses-pins.test.mjs +219 -0
- package/scripts/lib/walk.mjs +74 -0
- package/scripts/platform-repair.mjs +9 -3
- package/scripts/update-semgrep-rules.mjs +76 -5
- package/templates/runbooks/README.md +9 -5
- package/templates/runbooks/branch-protection-setup.md +9 -3
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
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
```
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
176
|
-
|
|
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
|
-
```
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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`).
|
|
233
|
-
|
|
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
|
|
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.
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
|
54
|
-
*
|
|
55
|
-
* back to a constant
|
|
56
|
-
*
|
|
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
|
|
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
|
|
87
|
-
//
|
|
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
|
|
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": "
|
|
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
|
-
"
|
|
102
|
-
|
|
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