mandrel-platform 0.12.0 → 0.14.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/README.md +203 -7
- package/config/dependency-cruiser.base.json +121 -0
- package/config/edge-security/allowlist.mjs +138 -0
- package/config/edge-security/cors-astro.mjs +131 -0
- package/config/edge-security/cors-hono.mjs +111 -0
- package/config/edge-security/index.mjs +34 -0
- package/config/edge-security/rate-limit.mjs +219 -0
- package/config/edge-security/security-headers.mjs +148 -0
- package/config/knip.base.json +7 -0
- package/config/lighthouse.base.json +27 -0
- package/config/size-limit.base.json +6 -0
- package/config/stryker.base.json +15 -0
- package/package.json +23 -2
- package/scripts/check-action-pins.mjs +344 -0
- package/scripts/check-action-pins.test.mjs +240 -0
- package/scripts/check-coverage-threshold.mjs +300 -0
- package/scripts/check-coverage-threshold.test.mjs +350 -0
- package/scripts/check-destructive-migration.mjs +313 -0
- package/scripts/check-destructive-migration.test.mjs +183 -0
- package/scripts/check-pin-drift.mjs +361 -17
- package/scripts/check-pin-drift.test.mjs +344 -1
- package/scripts/edge-security.test.mjs +300 -0
- package/scripts/pin-drift-consumers.json +2 -0
- package/scripts/platform-repair.mjs +748 -0
- package/scripts/platform-repair.test.mjs +458 -0
- package/scripts/platform-sync.mjs +0 -0
- package/scripts/platform-sync.test.mjs +0 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cors-hono.mjs — closed-allowlist CORS as a `hono/cors` options factory.
|
|
3
|
+
*
|
|
4
|
+
* `hono`-based consumers (athportal, swarm-os) reach for the built-in
|
|
5
|
+
* `hono/cors` middleware, whose options object takes an `origin` that may be a
|
|
6
|
+
* function `(origin, c) => string | null`. This variant builds exactly that
|
|
7
|
+
* options object — pre-wired with the closed-allowlist resolver — so the
|
|
8
|
+
* consumer writes `app.use("*", cors(createHonoCorsOptions({...})))` and
|
|
9
|
+
* inherits the invariant instead of re-deriving the origin callback.
|
|
10
|
+
*
|
|
11
|
+
* The no-wildcard-with-credentials invariant is inherited *by construction*
|
|
12
|
+
* from `createAllowlist`: building these options with `['*']` +
|
|
13
|
+
* `credentials: true` throws before the app ever starts.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createAllowlist } from "./allowlist.mjs";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {Object} HonoCorsOptions
|
|
20
|
+
* @property {string[]} allowedOrigins
|
|
21
|
+
* Per-env allowed origins (absolute, or a single `*`). Required.
|
|
22
|
+
* @property {boolean} [credentials=false]
|
|
23
|
+
* Maps to the `hono/cors` `credentials` option. Rejected at construction
|
|
24
|
+
* when combined with a wildcard origin.
|
|
25
|
+
* @property {string[]} [methods=["GET","HEAD","POST","PUT","PATCH","DELETE","OPTIONS"]]
|
|
26
|
+
* @property {string[]} [allowedHeaders=["Content-Type","Authorization"]]
|
|
27
|
+
* @property {string[]} [exposedHeaders=[]]
|
|
28
|
+
* @property {number} [maxAge=86400]
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const DEFAULT_METHODS = [
|
|
32
|
+
"GET",
|
|
33
|
+
"HEAD",
|
|
34
|
+
"POST",
|
|
35
|
+
"PUT",
|
|
36
|
+
"PATCH",
|
|
37
|
+
"DELETE",
|
|
38
|
+
"OPTIONS",
|
|
39
|
+
];
|
|
40
|
+
const DEFAULT_ALLOWED_HEADERS = ["Content-Type", "Authorization"];
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Build the options object for `hono/cors`'s `cors()` middleware.
|
|
44
|
+
*
|
|
45
|
+
* The returned `origin` is a function so the closed allowlist is applied
|
|
46
|
+
* per-request: it echoes the request origin only when trusted, and returns
|
|
47
|
+
* `null` otherwise (no `Access-Control-Allow-Origin` header — `hono/cors`
|
|
48
|
+
* omits the header for a `null` return, which is the closed-allowlist
|
|
49
|
+
* behaviour). For a wildcard env it returns the literal `*` (credentials are
|
|
50
|
+
* proven `false` by construction in that case).
|
|
51
|
+
*
|
|
52
|
+
* ```ts
|
|
53
|
+
* import { Hono } from "hono";
|
|
54
|
+
* import { cors } from "hono/cors";
|
|
55
|
+
* import { createHonoCorsOptions } from "mandrel-platform/edge-security/cors-hono.mjs";
|
|
56
|
+
*
|
|
57
|
+
* const app = new Hono();
|
|
58
|
+
* app.use(
|
|
59
|
+
* "*",
|
|
60
|
+
* cors(
|
|
61
|
+
* createHonoCorsOptions({
|
|
62
|
+
* allowedOrigins: ["https://athportal.com"],
|
|
63
|
+
* credentials: true,
|
|
64
|
+
* }),
|
|
65
|
+
* ),
|
|
66
|
+
* );
|
|
67
|
+
* ```
|
|
68
|
+
*
|
|
69
|
+
* @param {HonoCorsOptions} options
|
|
70
|
+
* @returns {{
|
|
71
|
+
* origin: (origin: string) => string | null,
|
|
72
|
+
* allowMethods: string[],
|
|
73
|
+
* allowHeaders: string[],
|
|
74
|
+
* exposeHeaders: string[],
|
|
75
|
+
* credentials: boolean,
|
|
76
|
+
* maxAge: number,
|
|
77
|
+
* }}
|
|
78
|
+
*/
|
|
79
|
+
export function createHonoCorsOptions(options) {
|
|
80
|
+
if (!options || !Array.isArray(options.allowedOrigins)) {
|
|
81
|
+
throw new TypeError(
|
|
82
|
+
"[edge-security] createHonoCorsOptions({ allowedOrigins }): `allowedOrigins` (string[]) is required",
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const credentials = options.credentials === true;
|
|
87
|
+
// Constructs the allowlist — throws here on wildcard + credentials.
|
|
88
|
+
const allowlist = createAllowlist(options.allowedOrigins, { credentials });
|
|
89
|
+
|
|
90
|
+
const allowMethods = options.methods ?? DEFAULT_METHODS;
|
|
91
|
+
const allowHeaders = options.allowedHeaders ?? DEFAULT_ALLOWED_HEADERS;
|
|
92
|
+
const exposeHeaders = options.exposedHeaders ?? [];
|
|
93
|
+
const maxAge = options.maxAge ?? 86400;
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
/**
|
|
97
|
+
* `hono/cors` origin callback. Returns the value for
|
|
98
|
+
* `Access-Control-Allow-Origin`, or `null` when the origin is untrusted.
|
|
99
|
+
* @param {string} requestOrigin
|
|
100
|
+
* @returns {string | null}
|
|
101
|
+
*/
|
|
102
|
+
origin(requestOrigin) {
|
|
103
|
+
return allowlist.resolve(requestOrigin);
|
|
104
|
+
},
|
|
105
|
+
allowMethods,
|
|
106
|
+
allowHeaders,
|
|
107
|
+
exposeHeaders,
|
|
108
|
+
credentials,
|
|
109
|
+
maxAge,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* edge-security — reusable per-env edge-security middleware units for
|
|
3
|
+
* mandrel-platform consumers.
|
|
4
|
+
*
|
|
5
|
+
* Covers the three invariants every consumer was hand-rolling per env:
|
|
6
|
+
* - **CORS** (closed allowlist; Astro + hono variants), with the
|
|
7
|
+
* no-wildcard-with-credentials invariant enforced *by construction*.
|
|
8
|
+
* - **Security headers** (CSP / HSTS / XFO / XCTO / Referrer-Policy).
|
|
9
|
+
* - **App-layer rate limiting** (fixed-window; Astro + hono adapters).
|
|
10
|
+
*
|
|
11
|
+
* Distributed through the npm package-export channel (see
|
|
12
|
+
* `mandrel-platform/edge-security/*`), consistent with the platform's other
|
|
13
|
+
* reusable code (`config/*.base.json`, `scripts/*`). Import the barrel for
|
|
14
|
+
* everything, or a single sub-path for one unit.
|
|
15
|
+
*
|
|
16
|
+
* The CORS code legitimately differs by architecture (Astro middleware vs
|
|
17
|
+
* `hono/cors`), so both variants ship — the divergence is preserved, not
|
|
18
|
+
* flattened.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export { createAllowlist, normalizeOrigin, WILDCARD } from "./allowlist.mjs";
|
|
22
|
+
export { createAstroCors } from "./cors-astro.mjs";
|
|
23
|
+
export { createHonoCorsOptions } from "./cors-hono.mjs";
|
|
24
|
+
export {
|
|
25
|
+
applySecurityHeaders,
|
|
26
|
+
buildSecurityHeaders,
|
|
27
|
+
} from "./security-headers.mjs";
|
|
28
|
+
export {
|
|
29
|
+
createAstroRateLimit,
|
|
30
|
+
createHonoRateLimit,
|
|
31
|
+
createMemoryStore,
|
|
32
|
+
createRateLimiter,
|
|
33
|
+
rateLimitHeaders,
|
|
34
|
+
} from "./rate-limit.mjs";
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rate-limit.mjs — app-layer rate limiting as a framework-agnostic fixed-window
|
|
3
|
+
* limiter plus thin Astro / hono adapters.
|
|
4
|
+
*
|
|
5
|
+
* Consumers were each hand-rolling an in-memory / KV-backed limiter with the
|
|
6
|
+
* same intent (N requests per window per key). This unit ships the core
|
|
7
|
+
* decision function — `createRateLimiter` — parameterized by limit, window, a
|
|
8
|
+
* key extractor, and a pluggable store, so a consumer swaps the in-memory store
|
|
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
|
|
11
|
+
* single-isolate dev / small deployment; production multi-isolate consumers
|
|
12
|
+
* pass a shared store.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {Object} RateLimitStore
|
|
17
|
+
* @property {(key: string) => Promise<{ count: number, resetAt: number } | null> | { count: number, resetAt: number } | null} get
|
|
18
|
+
* @property {(key: string, value: { count: number, resetAt: number }) => Promise<void> | void} set
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
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.
|
|
25
|
+
* @returns {RateLimitStore}
|
|
26
|
+
*/
|
|
27
|
+
export function createMemoryStore() {
|
|
28
|
+
/** @type {Map<string, { count: number, resetAt: number }>} */
|
|
29
|
+
const buckets = new Map();
|
|
30
|
+
return {
|
|
31
|
+
get(key) {
|
|
32
|
+
const bucket = buckets.get(key);
|
|
33
|
+
if (!bucket) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
if (bucket.resetAt <= Date.now()) {
|
|
37
|
+
buckets.delete(key);
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
return bucket;
|
|
41
|
+
},
|
|
42
|
+
set(key, value) {
|
|
43
|
+
buckets.set(key, value);
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @typedef {Object} RateLimiterOptions
|
|
50
|
+
* @property {number} limit Max requests allowed per window. Required.
|
|
51
|
+
* @property {number} windowMs Window length in milliseconds. Required.
|
|
52
|
+
* @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.
|
|
57
|
+
* @property {RateLimitStore} [store] Defaults to `createMemoryStore()`.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @typedef {Object} RateLimitDecision
|
|
62
|
+
* @property {boolean} allowed
|
|
63
|
+
* @property {number} limit
|
|
64
|
+
* @property {number} remaining
|
|
65
|
+
* @property {number} resetAt Epoch ms when the current window resets.
|
|
66
|
+
* @property {number} retryAfter Seconds until reset (0 when allowed).
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Default key extractor: client IP, failing closed to a shared bucket.
|
|
71
|
+
* @param {Request} request
|
|
72
|
+
* @returns {string}
|
|
73
|
+
*/
|
|
74
|
+
function defaultKeyExtractor(request) {
|
|
75
|
+
const cf = request.headers.get("CF-Connecting-IP");
|
|
76
|
+
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
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// No identifiable client — fail closed into one shared bucket rather than
|
|
87
|
+
// handing every anonymous request its own unlimited allowance.
|
|
88
|
+
return "anonymous";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Build a fixed-window rate limiter. The returned `check(request)` resolves to
|
|
93
|
+
* a decision object the caller turns into a 429 (or passes through).
|
|
94
|
+
*
|
|
95
|
+
* @param {RateLimiterOptions} options
|
|
96
|
+
* @returns {{ check: (request: Request) => Promise<RateLimitDecision> }}
|
|
97
|
+
*/
|
|
98
|
+
export function createRateLimiter(options) {
|
|
99
|
+
if (
|
|
100
|
+
!options ||
|
|
101
|
+
typeof options.limit !== "number" ||
|
|
102
|
+
typeof options.windowMs !== "number"
|
|
103
|
+
) {
|
|
104
|
+
throw new TypeError(
|
|
105
|
+
"[edge-security] createRateLimiter({ limit, windowMs }): numeric `limit` and `windowMs` are required",
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
if (options.limit < 1 || options.windowMs < 1) {
|
|
109
|
+
throw new RangeError(
|
|
110
|
+
"[edge-security] createRateLimiter: `limit` and `windowMs` must be >= 1",
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
const limit = options.limit;
|
|
114
|
+
const windowMs = options.windowMs;
|
|
115
|
+
const keyExtractor = options.keyExtractor ?? defaultKeyExtractor;
|
|
116
|
+
const store = options.store ?? createMemoryStore();
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
/**
|
|
120
|
+
* @param {Request} request
|
|
121
|
+
* @returns {Promise<RateLimitDecision>}
|
|
122
|
+
*/
|
|
123
|
+
async check(request) {
|
|
124
|
+
const key = keyExtractor(request);
|
|
125
|
+
const now = Date.now();
|
|
126
|
+
const existing = await store.get(key);
|
|
127
|
+
|
|
128
|
+
let count;
|
|
129
|
+
let resetAt;
|
|
130
|
+
if (existing && existing.resetAt > now) {
|
|
131
|
+
count = existing.count + 1;
|
|
132
|
+
resetAt = existing.resetAt;
|
|
133
|
+
} else {
|
|
134
|
+
count = 1;
|
|
135
|
+
resetAt = now + windowMs;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
await store.set(key, { count, resetAt });
|
|
139
|
+
|
|
140
|
+
const allowed = count <= limit;
|
|
141
|
+
const remaining = Math.max(0, limit - count);
|
|
142
|
+
const retryAfter = allowed ? 0 : Math.ceil((resetAt - now) / 1000);
|
|
143
|
+
|
|
144
|
+
return { allowed, limit, remaining, resetAt, retryAfter };
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Standard rate-limit response headers for a decision. Spread onto a 429 (or a
|
|
151
|
+
* passed-through response) so clients see their budget.
|
|
152
|
+
* @param {RateLimitDecision} decision
|
|
153
|
+
* @returns {Record<string, string>}
|
|
154
|
+
*/
|
|
155
|
+
export function rateLimitHeaders(decision) {
|
|
156
|
+
/** @type {Record<string, string>} */
|
|
157
|
+
const headers = {
|
|
158
|
+
"RateLimit-Limit": String(decision.limit),
|
|
159
|
+
"RateLimit-Remaining": String(decision.remaining),
|
|
160
|
+
"RateLimit-Reset": String(Math.ceil((decision.resetAt - Date.now()) / 1000)),
|
|
161
|
+
};
|
|
162
|
+
if (!decision.allowed) {
|
|
163
|
+
headers["Retry-After"] = String(decision.retryAfter);
|
|
164
|
+
}
|
|
165
|
+
return headers;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Astro middleware adapter: returns `(context, next) => Response`. Short-circuits
|
|
170
|
+
* with a 429 when the limiter denies the request, otherwise annotates the
|
|
171
|
+
* downstream response with the budget headers.
|
|
172
|
+
*
|
|
173
|
+
* @param {RateLimiterOptions} options
|
|
174
|
+
* @returns {(context: { request: Request }, next: () => Promise<Response>) => Promise<Response>}
|
|
175
|
+
*/
|
|
176
|
+
export function createAstroRateLimit(options) {
|
|
177
|
+
const limiter = createRateLimiter(options);
|
|
178
|
+
return async function astroRateLimitMiddleware(context, next) {
|
|
179
|
+
const decision = await limiter.check(context.request);
|
|
180
|
+
if (!decision.allowed) {
|
|
181
|
+
return new Response("Too Many Requests", {
|
|
182
|
+
status: 429,
|
|
183
|
+
headers: rateLimitHeaders(decision),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
const response = await next();
|
|
187
|
+
for (const [key, value] of Object.entries(rateLimitHeaders(decision))) {
|
|
188
|
+
response.headers.set(key, value);
|
|
189
|
+
}
|
|
190
|
+
return response;
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* hono middleware adapter: returns `(c, next) => Promise<Response | void>`.
|
|
196
|
+
* Short-circuits with `c.text("Too Many Requests", 429)` when denied.
|
|
197
|
+
*
|
|
198
|
+
* ```ts
|
|
199
|
+
* import { createHonoRateLimit } from "mandrel-platform/edge-security/rate-limit.mjs";
|
|
200
|
+
* app.use("*", createHonoRateLimit({ limit: 100, windowMs: 60_000 }));
|
|
201
|
+
* ```
|
|
202
|
+
*
|
|
203
|
+
* @param {RateLimiterOptions} options
|
|
204
|
+
* @returns {(c: any, next: () => Promise<void>) => Promise<Response | void>}
|
|
205
|
+
*/
|
|
206
|
+
export function createHonoRateLimit(options) {
|
|
207
|
+
const limiter = createRateLimiter(options);
|
|
208
|
+
return async function honoRateLimitMiddleware(c, next) {
|
|
209
|
+
const decision = await limiter.check(c.req.raw);
|
|
210
|
+
const headers = rateLimitHeaders(decision);
|
|
211
|
+
if (!decision.allowed) {
|
|
212
|
+
return c.text("Too Many Requests", 429, headers);
|
|
213
|
+
}
|
|
214
|
+
await next();
|
|
215
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
216
|
+
c.res.headers.set(key, value);
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* security-headers.mjs — the uniform security-header set every consumer was
|
|
3
|
+
* hand-rolling per env: CSP, HSTS, X-Frame-Options, X-Content-Type-Options,
|
|
4
|
+
* and Referrer-Policy.
|
|
5
|
+
*
|
|
6
|
+
* Ships two surfaces so both architectures inherit the same invariant:
|
|
7
|
+
* - `buildSecurityHeaders(options)` — a framework-agnostic `Record<string,string>`
|
|
8
|
+
* a consumer can spread onto any `Headers` / response (Astro, hono, plain
|
|
9
|
+
* Workers `fetch`).
|
|
10
|
+
* - `applySecurityHeaders(headers, options)` — mutates a `Headers` instance
|
|
11
|
+
* in place (the common middleware path).
|
|
12
|
+
*
|
|
13
|
+
* Every header is parameterized but ships a safe default, so a consumer that
|
|
14
|
+
* passes `{}` still inherits a hardened baseline rather than re-deriving the
|
|
15
|
+
* directive strings.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {Object} SecurityHeaderOptions
|
|
20
|
+
* @property {string | false} [contentSecurityPolicy]
|
|
21
|
+
* The full CSP string. Defaults to a strict self-only policy. Pass `false`
|
|
22
|
+
* to omit the CSP header entirely (e.g. when a CDN injects it).
|
|
23
|
+
* @property {Object} [hsts]
|
|
24
|
+
* @property {number} [hsts.maxAge=63072000] `max-age` seconds (default 2y).
|
|
25
|
+
* @property {boolean} [hsts.includeSubDomains=true]
|
|
26
|
+
* @property {boolean} [hsts.preload=false]
|
|
27
|
+
* @property {false} [hsts.enabled]
|
|
28
|
+
* Pass `{ enabled: false }`-style by setting `hsts: false` to omit HSTS
|
|
29
|
+
* (e.g. on a non-HTTPS preview host).
|
|
30
|
+
* @property {string | false} [frameOptions="DENY"]
|
|
31
|
+
* `X-Frame-Options`. `false` omits it (rely on CSP `frame-ancestors`).
|
|
32
|
+
* @property {string | false} [contentTypeOptions="nosniff"]
|
|
33
|
+
* `X-Content-Type-Options`. `false` omits it.
|
|
34
|
+
* @property {string | false} [referrerPolicy="strict-origin-when-cross-origin"]
|
|
35
|
+
* `Referrer-Policy`. `false` omits it.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
const DEFAULT_CSP = [
|
|
39
|
+
"default-src 'self'",
|
|
40
|
+
"base-uri 'self'",
|
|
41
|
+
"frame-ancestors 'none'",
|
|
42
|
+
"object-src 'none'",
|
|
43
|
+
"upgrade-insecure-requests",
|
|
44
|
+
].join("; ");
|
|
45
|
+
|
|
46
|
+
const DEFAULT_HSTS_MAX_AGE = 63072000; // 2 years
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Build the `Strict-Transport-Security` value from the hsts option, or `null`
|
|
50
|
+
* when HSTS is disabled.
|
|
51
|
+
* @param {SecurityHeaderOptions["hsts"] | false | undefined} hsts
|
|
52
|
+
* @returns {string | null}
|
|
53
|
+
*/
|
|
54
|
+
function buildHsts(hsts) {
|
|
55
|
+
if (hsts === false) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
const maxAge =
|
|
59
|
+
hsts && typeof hsts.maxAge === "number" ? hsts.maxAge : DEFAULT_HSTS_MAX_AGE;
|
|
60
|
+
const includeSubDomains = !hsts || hsts.includeSubDomains !== false;
|
|
61
|
+
const preload = Boolean(hsts && hsts.preload);
|
|
62
|
+
|
|
63
|
+
let value = `max-age=${maxAge}`;
|
|
64
|
+
if (includeSubDomains) {
|
|
65
|
+
value += "; includeSubDomains";
|
|
66
|
+
}
|
|
67
|
+
if (preload) {
|
|
68
|
+
value += "; preload";
|
|
69
|
+
}
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Build the security-header set as a plain object. Keys are only present when
|
|
75
|
+
* the corresponding header is enabled (a disabled header is omitted, not set
|
|
76
|
+
* empty).
|
|
77
|
+
*
|
|
78
|
+
* @param {SecurityHeaderOptions} [options]
|
|
79
|
+
* @returns {Record<string, string>}
|
|
80
|
+
*/
|
|
81
|
+
export function buildSecurityHeaders(options = {}) {
|
|
82
|
+
/** @type {Record<string, string>} */
|
|
83
|
+
const headers = {};
|
|
84
|
+
|
|
85
|
+
const csp =
|
|
86
|
+
options.contentSecurityPolicy === undefined
|
|
87
|
+
? DEFAULT_CSP
|
|
88
|
+
: options.contentSecurityPolicy;
|
|
89
|
+
if (csp !== false) {
|
|
90
|
+
headers["Content-Security-Policy"] = csp;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const hsts = buildHsts(options.hsts);
|
|
94
|
+
if (hsts !== null) {
|
|
95
|
+
headers["Strict-Transport-Security"] = hsts;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const frameOptions =
|
|
99
|
+
options.frameOptions === undefined ? "DENY" : options.frameOptions;
|
|
100
|
+
if (frameOptions !== false) {
|
|
101
|
+
headers["X-Frame-Options"] = frameOptions;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const contentTypeOptions =
|
|
105
|
+
options.contentTypeOptions === undefined
|
|
106
|
+
? "nosniff"
|
|
107
|
+
: options.contentTypeOptions;
|
|
108
|
+
if (contentTypeOptions !== false) {
|
|
109
|
+
headers["X-Content-Type-Options"] = contentTypeOptions;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const referrerPolicy =
|
|
113
|
+
options.referrerPolicy === undefined
|
|
114
|
+
? "strict-origin-when-cross-origin"
|
|
115
|
+
: options.referrerPolicy;
|
|
116
|
+
if (referrerPolicy !== false) {
|
|
117
|
+
headers["Referrer-Policy"] = referrerPolicy;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return headers;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Mutate a `Headers` instance in place with the security-header set. Returns
|
|
125
|
+
* the same instance for chaining.
|
|
126
|
+
*
|
|
127
|
+
* ```ts
|
|
128
|
+
* import { applySecurityHeaders } from "mandrel-platform/edge-security/security-headers.mjs";
|
|
129
|
+
* const response = await next();
|
|
130
|
+
* applySecurityHeaders(response.headers);
|
|
131
|
+
* return response;
|
|
132
|
+
* ```
|
|
133
|
+
*
|
|
134
|
+
* @param {Headers} headers
|
|
135
|
+
* @param {SecurityHeaderOptions} [options]
|
|
136
|
+
* @returns {Headers}
|
|
137
|
+
*/
|
|
138
|
+
export function applySecurityHeaders(headers, options = {}) {
|
|
139
|
+
if (!headers || typeof headers.set !== "function") {
|
|
140
|
+
throw new TypeError(
|
|
141
|
+
"[edge-security] applySecurityHeaders(headers): `headers` must be a Headers instance",
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
for (const [key, value] of Object.entries(buildSecurityHeaders(options))) {
|
|
145
|
+
headers.set(key, value);
|
|
146
|
+
}
|
|
147
|
+
return headers;
|
|
148
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "Shared Lighthouse CI base for mandrel-platform consumers. Lighthouse's `lighthouserc.json` has no whole-file `extends`, so this ships the shared `ci` block (collect settings + category assertions on the recommended preset). Consumers deep-merge it and add repo-specific `ci.collect.url` / `ci.collect.staticDistDir` and any per-repo assertion overrides (see README). Category score floors stay consumer-tunable.",
|
|
3
|
+
"ci": {
|
|
4
|
+
"collect": {
|
|
5
|
+
"numberOfRuns": 3,
|
|
6
|
+
"settings": {
|
|
7
|
+
"preset": "desktop"
|
|
8
|
+
}
|
|
9
|
+
},
|
|
10
|
+
"assert": {
|
|
11
|
+
"preset": "lighthouse:recommended",
|
|
12
|
+
"assertions": {
|
|
13
|
+
"categories:performance": ["error", { "minScore": 0.9 }],
|
|
14
|
+
"categories:accessibility": ["error", { "minScore": 0.9 }],
|
|
15
|
+
"categories:best-practices": ["error", { "minScore": 0.9 }],
|
|
16
|
+
"categories:seo": ["error", { "minScore": 0.9 }],
|
|
17
|
+
"uses-responsive-images": "off",
|
|
18
|
+
"unused-javascript": "warn",
|
|
19
|
+
"unused-css-rules": "warn",
|
|
20
|
+
"csp-xss": "warn"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"upload": {
|
|
24
|
+
"target": "temporary-public-storage"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "Shared size-limit check defaults for mandrel-platform consumers. size-limit's own config is a per-entry array whose paths/limits are inherently repo-specific, so this base ships the *shared check options* (gzip sizing, time budget off, import-cost mode) rather than entry paths. Consumers spread it into each entry of their `.size-limit.json` array: `[{ ...base, \"path\": \"dist/index.js\", \"limit\": \"10 kB\" }]` (see README). `path` and `limit` stay consumer-tunable.",
|
|
3
|
+
"gzip": true,
|
|
4
|
+
"brotli": false,
|
|
5
|
+
"running": false
|
|
6
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://raw.githubusercontent.com/stryker-mutator/stryker-js/master/packages/api/schema/stryker-core.json",
|
|
3
|
+
"_comment": "Shared Stryker base for mandrel-platform consumers. Consumers extend via stryker.config.json's `extends` (string or array): { \"extends\": [\"mandrel-platform/stryker.base.json\"], \"testRunner\": \"vitest\", \"mutate\": [\"src/**/*.ts\"] }. testRunner, mutate, and per-repo thresholds stay consumer-tunable.",
|
|
4
|
+
"packageManager": "pnpm",
|
|
5
|
+
"reporters": ["html", "clear-text", "progress"],
|
|
6
|
+
"coverageAnalysis": "perTest",
|
|
7
|
+
"ignoreStatic": true,
|
|
8
|
+
"cleanTempDir": true,
|
|
9
|
+
"timeoutMS": 60000,
|
|
10
|
+
"thresholds": {
|
|
11
|
+
"high": 80,
|
|
12
|
+
"low": 60,
|
|
13
|
+
"break": 50
|
|
14
|
+
}
|
|
15
|
+
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mandrel-platform",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.2",
|
|
4
4
|
"description": "Shared CI/deploy workflows, composite toolchain action, npm config package, Renovate preset, and operator runbook templates.",
|
|
5
5
|
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/dsj1984/mandrel-platform.git"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/dsj1984/mandrel-platform#readme",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/dsj1984/mandrel-platform/issues"
|
|
13
|
+
},
|
|
14
|
+
"packageManager": "pnpm@11.5.2",
|
|
6
15
|
"engines": {
|
|
7
16
|
"node": "24.16.0",
|
|
8
17
|
"pnpm": ">=11.5.2"
|
|
@@ -10,6 +19,13 @@
|
|
|
10
19
|
"exports": {
|
|
11
20
|
"./tsconfig.base.json": "./config/tsconfig.base.json",
|
|
12
21
|
"./biome.base.json": "./config/biome.base.json",
|
|
22
|
+
"./knip.base.json": "./config/knip.base.json",
|
|
23
|
+
"./stryker.base.json": "./config/stryker.base.json",
|
|
24
|
+
"./dependency-cruiser.base.json": "./config/dependency-cruiser.base.json",
|
|
25
|
+
"./size-limit.base.json": "./config/size-limit.base.json",
|
|
26
|
+
"./lighthouse.base.json": "./config/lighthouse.base.json",
|
|
27
|
+
"./edge-security": "./config/edge-security/index.mjs",
|
|
28
|
+
"./edge-security/*": "./config/edge-security/*",
|
|
13
29
|
"./scripts/*": "./scripts/*"
|
|
14
30
|
},
|
|
15
31
|
"files": [
|
|
@@ -18,6 +34,10 @@
|
|
|
18
34
|
"scripts/",
|
|
19
35
|
"templates/"
|
|
20
36
|
],
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public",
|
|
39
|
+
"provenance": true
|
|
40
|
+
},
|
|
21
41
|
"dependencies": {
|
|
22
42
|
"mandrel": "^1.78.0"
|
|
23
43
|
},
|
|
@@ -27,8 +47,9 @@
|
|
|
27
47
|
"test": "node --test \"scripts/**/*.test.mjs\"",
|
|
28
48
|
"platform:sync": "node scripts/platform-sync.mjs",
|
|
29
49
|
"sync:commands": "node .agents/scripts/sync-claude-commands.js",
|
|
50
|
+
"prepare": "node .agents/scripts/sync-claude-commands.js",
|
|
30
51
|
"bootstrap": "node .agents/scripts/bootstrap.js",
|
|
31
52
|
"quality:preview": "node .agents/scripts/quality-preview.js --changed-since HEAD",
|
|
32
53
|
"quality:watch": "node .agents/scripts/quality-watch.js"
|
|
33
54
|
}
|
|
34
|
-
}
|
|
55
|
+
}
|