mandrel-platform 0.13.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
package/README.md
CHANGED
|
@@ -12,14 +12,23 @@ consumer convergence matrix, and the forward roadmap are tracked privately.
|
|
|
12
12
|
## Reusable workflows
|
|
13
13
|
|
|
14
14
|
The shared `workflow_call` workflows — `pr-quality.yml` and
|
|
15
|
-
`deploy-cloudflare.yml` (plus `
|
|
16
|
-
their public input/secret
|
|
15
|
+
`deploy-cloudflare.yml` (plus `secret-scan-push.yml`, `release-automation.yml`,
|
|
16
|
+
`codeql.yml`, and `smoke-dispatch.yml`) — and their public input/secret
|
|
17
|
+
contract are documented in
|
|
17
18
|
**[docs/reusable-workflows.md](docs/reusable-workflows.md)**. Consumers should
|
|
18
19
|
configure their callers from that reference (input types, defaults,
|
|
19
20
|
when-to-override, the frozen `{CLOUDFLARE_*, TURSO_*}` deploy secret allowlist,
|
|
20
21
|
the single `ci-required` aggregator context, and the pin-by-tag/SHA versioning
|
|
21
22
|
model).
|
|
22
23
|
|
|
24
|
+
`release-automation.yml` extends the platform from CI/deploy into the **full
|
|
25
|
+
release lifecycle**: a thin caller gets conventional-commit-driven version
|
|
26
|
+
bumps, a `CHANGELOG.md`, and tags via release-please — the same convention the
|
|
27
|
+
platform's own release train uses. It does not publish to a registry (consumers
|
|
28
|
+
deploy to Cloudflare, not npm); see its section in the reference for the
|
|
29
|
+
out-of-scope boundary and the `release_created` / `tag_name` outputs a
|
|
30
|
+
publish/deploy job keys off.
|
|
31
|
+
|
|
23
32
|
---
|
|
24
33
|
|
|
25
34
|
## Shared Composite Actions
|
|
@@ -89,6 +98,186 @@ and standard formatter defaults (2-space indent, 100-char line width).
|
|
|
89
98
|
}
|
|
90
99
|
```
|
|
91
100
|
|
|
101
|
+
### Code-quality tooling base configs
|
|
102
|
+
|
|
103
|
+
The package also ships shared base configs for the five code-quality /
|
|
104
|
+
hygiene tools every consumer runs — **Knip**, **Stryker**,
|
|
105
|
+
**dependency-cruiser**, **size-limit**, and **Lighthouse**. Each is the
|
|
106
|
+
best-of-breed union of the consumers' previously hand-maintained configs.
|
|
107
|
+
Adoption is opt-in via `extends` (or a spread / deep-merge where the tool
|
|
108
|
+
has no native `extends`), and every project-specific knob — entrypoints,
|
|
109
|
+
mutate globs, bundle paths, score floors, and budgets — stays
|
|
110
|
+
**consumer-tunable** locally.
|
|
111
|
+
|
|
112
|
+
#### `knip.base.json`
|
|
113
|
+
|
|
114
|
+
Shared Knip defaults (`ignoreExportsUsedInFile`, the `mandrel` binary +
|
|
115
|
+
`mandrel-platform` dependency ignores). Knip supports a native `extends`,
|
|
116
|
+
so consumers point at the base and add their own `entry` / `project`
|
|
117
|
+
globs (`knip.json`):
|
|
118
|
+
|
|
119
|
+
```jsonc
|
|
120
|
+
{
|
|
121
|
+
"extends": ["mandrel-platform/knip.base.json"],
|
|
122
|
+
"entry": ["src/index.ts", "scripts/*.ts"],
|
|
123
|
+
"project": ["src/**", "scripts/**"]
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
#### `stryker.base.json`
|
|
128
|
+
|
|
129
|
+
Shared Stryker mutation-testing defaults (pnpm package manager,
|
|
130
|
+
`perTest` coverage analysis, HTML + clear-text + progress reporters,
|
|
131
|
+
`ignoreStatic`, a 60 s timeout, and high/low/break thresholds). Stryker
|
|
132
|
+
supports a native `extends`; the consumer pins its test runner and
|
|
133
|
+
mutate set (`stryker.config.json`):
|
|
134
|
+
|
|
135
|
+
```jsonc
|
|
136
|
+
{
|
|
137
|
+
"extends": ["mandrel-platform/stryker.base.json"],
|
|
138
|
+
"testRunner": "vitest",
|
|
139
|
+
"mutate": ["src/**/*.ts", "!src/**/*.test.ts"]
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
#### `dependency-cruiser.base.json`
|
|
144
|
+
|
|
145
|
+
Shared dependency-cruiser rule set (no-circular, no-orphans,
|
|
146
|
+
not-to-unresolvable, no-non-package-json, not-to-dev-dep,
|
|
147
|
+
no-deprecated-core, and the dep-type hygiene rules) plus resolver
|
|
148
|
+
options. dependency-cruiser supports a native `extends` to a JSON path —
|
|
149
|
+
resolve the package export and add repo-specific rules
|
|
150
|
+
(`.dependency-cruiser.json`):
|
|
151
|
+
|
|
152
|
+
```jsonc
|
|
153
|
+
{
|
|
154
|
+
"extends": "mandrel-platform/dependency-cruiser.base.json",
|
|
155
|
+
"forbidden": [
|
|
156
|
+
// repo-specific layering rules only
|
|
157
|
+
]
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
#### `size-limit.base.json`
|
|
162
|
+
|
|
163
|
+
size-limit's own config is a per-entry **array** whose paths and limits
|
|
164
|
+
are inherently repo-specific, so the base ships the shared *check
|
|
165
|
+
options* (gzip sizing, `running: false`). Spread it into each entry of
|
|
166
|
+
your `.size-limit.json`:
|
|
167
|
+
|
|
168
|
+
```jsonc
|
|
169
|
+
// .size-limit.js — spread the base into each entry
|
|
170
|
+
import base from "mandrel-platform/size-limit.base.json" with { type: "json" };
|
|
171
|
+
|
|
172
|
+
export default [
|
|
173
|
+
{ ...base, path: "dist/index.js", limit: "10 kB" },
|
|
174
|
+
{ ...base, path: "dist/cli.js", limit: "25 kB" }
|
|
175
|
+
];
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
#### `lighthouse.base.json`
|
|
179
|
+
|
|
180
|
+
Lighthouse's `lighthouserc.json` has no whole-file `extends`, so the
|
|
181
|
+
base ships the shared `ci` block — collect settings plus the four
|
|
182
|
+
category assertions on the `lighthouse:recommended` preset. Deep-merge
|
|
183
|
+
it and add your repo-specific `ci.collect.url` /
|
|
184
|
+
`ci.collect.staticDistDir`:
|
|
185
|
+
|
|
186
|
+
```jsonc
|
|
187
|
+
// lighthouserc.js — deep-merge the base, add repo-specific collect targets
|
|
188
|
+
import base from "mandrel-platform/lighthouse.base.json" with { type: "json" };
|
|
189
|
+
|
|
190
|
+
export default {
|
|
191
|
+
ci: {
|
|
192
|
+
...base.ci,
|
|
193
|
+
collect: {
|
|
194
|
+
...base.ci.collect,
|
|
195
|
+
staticDistDir: "./dist"
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
> **Budgets stay consumer-tunable.** These bases standardize *which*
|
|
202
|
+
> tools run and their shared defaults — not *what each tool gates on*
|
|
203
|
+
> per consumer. Override any threshold, score floor, or budget locally;
|
|
204
|
+
> the base provides the floor, the consumer sets the ceiling.
|
|
205
|
+
|
|
206
|
+
### Edge-security middleware units
|
|
207
|
+
|
|
208
|
+
The package ships reusable **per-env edge-security middleware** so the next
|
|
209
|
+
consumer inherits the closed-allowlist CORS, security-header, and app-layer
|
|
210
|
+
rate-limit invariants instead of re-deriving them. They are distributed through
|
|
211
|
+
the **npm package-export channel** (the same channel as the base configs and
|
|
212
|
+
`scripts/*`), under `mandrel-platform/edge-security`:
|
|
213
|
+
|
|
214
|
+
| Sub-path | Unit |
|
|
215
|
+
| ----------------------------------------------------- | -------------------------------------------------------------------- |
|
|
216
|
+
| `mandrel-platform/edge-security` | Barrel — re-exports every unit below. |
|
|
217
|
+
| `mandrel-platform/edge-security/cors-astro.mjs` | `createAstroCors()` — closed-allowlist CORS as Astro middleware. |
|
|
218
|
+
| `mandrel-platform/edge-security/cors-hono.mjs` | `createHonoCorsOptions()` — closed-allowlist options for `hono/cors`.|
|
|
219
|
+
| `mandrel-platform/edge-security/security-headers.mjs` | `buildSecurityHeaders()` / `applySecurityHeaders()` — CSP/HSTS/XFO/XCTO/Referrer-Policy. |
|
|
220
|
+
| `mandrel-platform/edge-security/rate-limit.mjs` | `createRateLimiter()` + Astro/hono adapters — fixed-window app-layer limiter. |
|
|
221
|
+
| `mandrel-platform/edge-security/allowlist.mjs` | `createAllowlist()` — the shared closed-allowlist origin resolver. |
|
|
222
|
+
|
|
223
|
+
**Two CORS variants, by design.** CORS code legitimately differs by
|
|
224
|
+
architecture: domio drives an Astro `(context, next)` middleware, while
|
|
225
|
+
athportal / swarm-os use `hono/cors`. Both variants ship — the divergence is
|
|
226
|
+
preserved, not flattened into one form. Both inherit the same closed allowlist
|
|
227
|
+
and the **no-wildcard-with-credentials invariant, enforced by construction**:
|
|
228
|
+
building either unit with `['*']` + `credentials: true` throws at construction
|
|
229
|
+
time (before a request is ever served), so a consumer cannot mis-configure the
|
|
230
|
+
forbidden `Access-Control-Allow-Origin: *` + `Access-Control-Allow-Credentials: true`
|
|
231
|
+
shape.
|
|
232
|
+
|
|
233
|
+
**Per-env allowlist.** Each unit takes the allowed-origin set for the current
|
|
234
|
+
deployment environment, so the same code path applies in production and preview:
|
|
235
|
+
|
|
236
|
+
```ts
|
|
237
|
+
// Astro — src/middleware.ts
|
|
238
|
+
import { defineMiddleware, sequence } from "astro:middleware";
|
|
239
|
+
import { createAstroCors } from "mandrel-platform/edge-security/cors-astro.mjs";
|
|
240
|
+
import { applySecurityHeaders } from "mandrel-platform/edge-security/security-headers.mjs";
|
|
241
|
+
import { createAstroRateLimit } from "mandrel-platform/edge-security/rate-limit.mjs";
|
|
242
|
+
|
|
243
|
+
const cors = createAstroCors({
|
|
244
|
+
allowedOrigins: import.meta.env.PROD ? ["https://godomio.com"] : ["http://localhost:4321"],
|
|
245
|
+
credentials: true,
|
|
246
|
+
});
|
|
247
|
+
const rateLimit = createAstroRateLimit({ limit: 100, windowMs: 60_000 });
|
|
248
|
+
|
|
249
|
+
export const onRequest = sequence(
|
|
250
|
+
defineMiddleware(cors),
|
|
251
|
+
defineMiddleware(rateLimit),
|
|
252
|
+
defineMiddleware(async (_ctx, next) => {
|
|
253
|
+
const res = await next();
|
|
254
|
+
applySecurityHeaders(res.headers);
|
|
255
|
+
return res;
|
|
256
|
+
}),
|
|
257
|
+
);
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
```ts
|
|
261
|
+
// hono — app entry
|
|
262
|
+
import { Hono } from "hono";
|
|
263
|
+
import { cors } from "hono/cors";
|
|
264
|
+
import { createHonoCorsOptions } from "mandrel-platform/edge-security/cors-hono.mjs";
|
|
265
|
+
import { createHonoRateLimit } from "mandrel-platform/edge-security/rate-limit.mjs";
|
|
266
|
+
|
|
267
|
+
const app = new Hono();
|
|
268
|
+
app.use("*", cors(createHonoCorsOptions({ allowedOrigins: ["https://athportal.com"], credentials: true })));
|
|
269
|
+
app.use("*", createHonoRateLimit({ limit: 100, windowMs: 60_000 }));
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
> **Headers / rate-limit are framework-agnostic.** `buildSecurityHeaders()`
|
|
273
|
+
> returns a plain `Record<string,string>` you can spread onto any response, and
|
|
274
|
+
> `createRateLimiter()` exposes a `check(request)` decision function with a
|
|
275
|
+
> pluggable store (swap the default in-memory store for a Cloudflare KV /
|
|
276
|
+
> Durable Object store in production). The Astro/hono adapters are thin wrappers
|
|
277
|
+
> over those cores.
|
|
278
|
+
|
|
279
|
+
---
|
|
280
|
+
|
|
92
281
|
### `scripts/audit-check.mjs`
|
|
93
282
|
|
|
94
283
|
CVE gate script. Runs `pnpm audit --prod` and blocks on any **unsuppressed**
|
|
@@ -235,8 +424,15 @@ pnpm run bootstrap
|
|
|
235
424
|
|
|
236
425
|
## Package exports
|
|
237
426
|
|
|
238
|
-
| Export
|
|
239
|
-
|
|
|
240
|
-
| `mandrel-platform/tsconfig.base.json`
|
|
241
|
-
| `mandrel-platform/biome.base.json`
|
|
242
|
-
| `mandrel-platform/
|
|
427
|
+
| Export | Path |
|
|
428
|
+
| ----------------------------------------------- | ------------------------------------- |
|
|
429
|
+
| `mandrel-platform/tsconfig.base.json` | `config/tsconfig.base.json` |
|
|
430
|
+
| `mandrel-platform/biome.base.json` | `config/biome.base.json` |
|
|
431
|
+
| `mandrel-platform/knip.base.json` | `config/knip.base.json` |
|
|
432
|
+
| `mandrel-platform/stryker.base.json` | `config/stryker.base.json` |
|
|
433
|
+
| `mandrel-platform/dependency-cruiser.base.json` | `config/dependency-cruiser.base.json` |
|
|
434
|
+
| `mandrel-platform/size-limit.base.json` | `config/size-limit.base.json` |
|
|
435
|
+
| `mandrel-platform/lighthouse.base.json` | `config/lighthouse.base.json` |
|
|
436
|
+
| `mandrel-platform/edge-security` | `config/edge-security/index.mjs` |
|
|
437
|
+
| `mandrel-platform/edge-security/*` | `config/edge-security/*` |
|
|
438
|
+
| `mandrel-platform/scripts/*` | `scripts/*` |
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://raw.githubusercontent.com/sverweij/dependency-cruiser/main/src/schema/configuration.schema.json",
|
|
3
|
+
"forbidden": [
|
|
4
|
+
{
|
|
5
|
+
"name": "no-circular",
|
|
6
|
+
"comment": "Circular dependencies make code hard to reason about and refactor. Break the cycle by extracting the shared part, or invert a dependency.",
|
|
7
|
+
"severity": "error",
|
|
8
|
+
"from": {},
|
|
9
|
+
"to": { "circular": true }
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"name": "no-orphans",
|
|
13
|
+
"comment": "Orphan modules (imported by nothing, importing nothing relevant) are usually dead code. Remove them, or add to the ignore list if intentional.",
|
|
14
|
+
"severity": "warn",
|
|
15
|
+
"from": {
|
|
16
|
+
"orphan": true,
|
|
17
|
+
"pathNot": [
|
|
18
|
+
"(^|/)\\.[^/]+\\.(js|cjs|mjs|ts|cts|mts|json)$",
|
|
19
|
+
"\\.d\\.ts$",
|
|
20
|
+
"(^|/)tsconfig\\.json$",
|
|
21
|
+
"(^|/)(babel|webpack)\\.config\\.(js|cjs|mjs|ts|json)$"
|
|
22
|
+
]
|
|
23
|
+
},
|
|
24
|
+
"to": {}
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"name": "no-deprecated-core",
|
|
28
|
+
"comment": "Deprecated Node core modules will be removed in a future Node release. Migrate to the documented replacement.",
|
|
29
|
+
"severity": "error",
|
|
30
|
+
"from": {},
|
|
31
|
+
"to": {
|
|
32
|
+
"dependencyTypes": ["core"],
|
|
33
|
+
"path": ["^v8/tools/codemap$", "^node-inspect/lib/_inspect$", "^punycode$", "^domain$", "^constants$", "^sys$", "^_linklist$", "^_stream_wrap$"]
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"name": "not-to-deprecated",
|
|
38
|
+
"comment": "This module uses a (version of an) npm module marked as deprecated. Upgrade to a non-deprecated version.",
|
|
39
|
+
"severity": "warn",
|
|
40
|
+
"from": {},
|
|
41
|
+
"to": { "dependencyTypes": ["deprecated"] }
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"name": "no-non-package-json",
|
|
45
|
+
"comment": "This module depends on an npm package not in package.json. Add it as a dependency to keep installs reproducible.",
|
|
46
|
+
"severity": "error",
|
|
47
|
+
"from": {},
|
|
48
|
+
"to": {
|
|
49
|
+
"dependencyTypes": ["npm-no-pkg", "npm-unknown"]
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"name": "not-to-unresolvable",
|
|
54
|
+
"comment": "This module depends on something that cannot be resolved on disk. Fix the import path or install the missing dependency.",
|
|
55
|
+
"severity": "error",
|
|
56
|
+
"from": {},
|
|
57
|
+
"to": { "couldNotResolve": true }
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"name": "no-duplicate-dep-types",
|
|
61
|
+
"comment": "A module is in more than one dependency-type bucket (e.g. both dependencies and devDependencies). Consolidate to a single bucket.",
|
|
62
|
+
"severity": "warn",
|
|
63
|
+
"from": {},
|
|
64
|
+
"to": {
|
|
65
|
+
"moreThanOneDependencyType": true,
|
|
66
|
+
"dependencyTypesNot": ["type-only"]
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"name": "not-to-dev-dep",
|
|
71
|
+
"comment": "Production code must not depend on a devDependency. Move the package to dependencies, or move the importing code into a non-shipped path.",
|
|
72
|
+
"severity": "error",
|
|
73
|
+
"from": {
|
|
74
|
+
"path": "^(src)",
|
|
75
|
+
"pathNot": "\\.(spec|test)\\.(js|mjs|cjs|ts|mts|cts)$"
|
|
76
|
+
},
|
|
77
|
+
"to": {
|
|
78
|
+
"dependencyTypes": ["npm-dev"],
|
|
79
|
+
"dependencyTypesNot": ["type-only"],
|
|
80
|
+
"pathNot": ["node_modules/@types/"]
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
"name": "optional-deps-used",
|
|
85
|
+
"comment": "An optionalDependency is required without a guarded fallback. Either make the require optional or promote it to a regular dependency.",
|
|
86
|
+
"severity": "info",
|
|
87
|
+
"from": {},
|
|
88
|
+
"to": {
|
|
89
|
+
"dependencyTypes": ["npm-optional"]
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"name": "peer-deps-used",
|
|
94
|
+
"comment": "This module depends on a peerDependency, which is brittle. Inject it as a parameter instead.",
|
|
95
|
+
"severity": "warn",
|
|
96
|
+
"from": {},
|
|
97
|
+
"to": {
|
|
98
|
+
"dependencyTypes": ["npm-peer"]
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
],
|
|
102
|
+
"options": {
|
|
103
|
+
"doNotFollow": {
|
|
104
|
+
"path": ["node_modules"]
|
|
105
|
+
},
|
|
106
|
+
"tsConfig": {
|
|
107
|
+
"fileName": "tsconfig.json"
|
|
108
|
+
},
|
|
109
|
+
"tsPreCompilationDeps": true,
|
|
110
|
+
"enhancedResolveOptions": {
|
|
111
|
+
"exportsFields": ["exports"],
|
|
112
|
+
"conditionNames": ["import", "require", "node", "default", "types"],
|
|
113
|
+
"mainFields": ["module", "main", "types", "typings"]
|
|
114
|
+
},
|
|
115
|
+
"reporterOptions": {
|
|
116
|
+
"text": {
|
|
117
|
+
"highlightFocused": true
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* allowlist.mjs — the closed-allowlist origin resolver shared by both CORS
|
|
3
|
+
* variants (Astro middleware + `hono/cors`).
|
|
4
|
+
*
|
|
5
|
+
* This module is the single home of the **no-wildcard-with-credentials
|
|
6
|
+
* invariant**, enforced *by construction*: the resolver never echoes a request
|
|
7
|
+
* `Origin` it has not been told to trust, and the factories that build a CORS
|
|
8
|
+
* unit refuse — at construction time — to combine a wildcard origin with
|
|
9
|
+
* `credentials: true`. A consumer cannot mis-configure the unit into the
|
|
10
|
+
* forbidden `Access-Control-Allow-Origin: *` + `Access-Control-Allow-Credentials: true`
|
|
11
|
+
* shape that the Fetch spec itself rejects and that browsers treat as a CORS
|
|
12
|
+
* failure (and which, when a server hand-rolls it, silently disables the
|
|
13
|
+
* credentialed-request protection the allowlist is meant to provide).
|
|
14
|
+
*
|
|
15
|
+
* The resolver is per-env: a consumer passes the allowlist for the current
|
|
16
|
+
* deployment environment (e.g. the production origin set vs. the preview /
|
|
17
|
+
* localhost set) and the same code path applies in every env.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export const WILDCARD = "*";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @typedef {Object} AllowlistOptions
|
|
24
|
+
* @property {boolean} [credentials=false]
|
|
25
|
+
* When true the unit will send `Access-Control-Allow-Credentials: true`. A
|
|
26
|
+
* wildcard origin is **rejected at construction** when this is true — that is
|
|
27
|
+
* the no-wildcard-with-credentials invariant, enforced by construction.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Normalize a single allowed-origin entry to its scheme+host+port form so that
|
|
32
|
+
* `https://app.example.com` and `https://app.example.com/` (trailing slash)
|
|
33
|
+
* compare equal, and a bare host is rejected rather than silently matching.
|
|
34
|
+
*
|
|
35
|
+
* @param {string} entry
|
|
36
|
+
* @returns {string} normalized origin, or the literal `*` wildcard
|
|
37
|
+
*/
|
|
38
|
+
export function normalizeOrigin(entry) {
|
|
39
|
+
if (typeof entry !== "string") {
|
|
40
|
+
throw new TypeError(
|
|
41
|
+
`[edge-security] allowlist entry must be a string, got ${typeof entry}`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
const trimmed = entry.trim();
|
|
45
|
+
if (trimmed === WILDCARD) {
|
|
46
|
+
return WILDCARD;
|
|
47
|
+
}
|
|
48
|
+
let url;
|
|
49
|
+
try {
|
|
50
|
+
url = new URL(trimmed);
|
|
51
|
+
} catch {
|
|
52
|
+
throw new TypeError(
|
|
53
|
+
`[edge-security] allowlist entry "${entry}" is not a valid absolute origin ` +
|
|
54
|
+
`(expected e.g. "https://app.example.com" or "*")`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
// `URL.origin` is already scheme://host[:port] with no trailing slash.
|
|
58
|
+
return url.origin;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Build a closed-allowlist origin resolver from a per-env list of allowed
|
|
63
|
+
* origins.
|
|
64
|
+
*
|
|
65
|
+
* The returned object exposes:
|
|
66
|
+
* - `isWildcard` — whether the env trusts every origin (`*`).
|
|
67
|
+
* - `credentials` — the resolved credentials flag (always `false` when wildcard).
|
|
68
|
+
* - `origins` — the normalized, de-duplicated allowed-origin set (frozen).
|
|
69
|
+
* - `resolve(requestOrigin)` — returns the value to echo into
|
|
70
|
+
* `Access-Control-Allow-Origin` for this request, or `null` when the origin
|
|
71
|
+
* is not trusted (the request gets NO ACAO header — a closed allowlist).
|
|
72
|
+
*
|
|
73
|
+
* @param {string[]} allowed Per-env allowed origins (absolute, or a single `*`).
|
|
74
|
+
* @param {AllowlistOptions} [options]
|
|
75
|
+
* @returns {{
|
|
76
|
+
* isWildcard: boolean,
|
|
77
|
+
* credentials: boolean,
|
|
78
|
+
* origins: ReadonlySet<string>,
|
|
79
|
+
* resolve: (requestOrigin: string | null | undefined) => string | null,
|
|
80
|
+
* }}
|
|
81
|
+
*/
|
|
82
|
+
export function createAllowlist(allowed, options = {}) {
|
|
83
|
+
if (!Array.isArray(allowed)) {
|
|
84
|
+
throw new TypeError(
|
|
85
|
+
"[edge-security] createAllowlist(allowed): `allowed` must be an array of origins",
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
const credentials = options.credentials === true;
|
|
89
|
+
|
|
90
|
+
const normalized = allowed.map(normalizeOrigin);
|
|
91
|
+
const isWildcard = normalized.includes(WILDCARD);
|
|
92
|
+
|
|
93
|
+
// ── The no-wildcard-with-credentials invariant, enforced by construction ──
|
|
94
|
+
if (isWildcard && credentials) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
"[edge-security] Refusing to build a CORS unit with a wildcard origin (`*`) " +
|
|
97
|
+
"AND credentials enabled. `Access-Control-Allow-Origin: *` with " +
|
|
98
|
+
"`Access-Control-Allow-Credentials: true` is forbidden by the Fetch spec " +
|
|
99
|
+
"and disables the credentialed-request protection. Either enumerate the " +
|
|
100
|
+
"allowed origins explicitly, or set credentials: false.",
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (isWildcard && normalized.length > 1) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
"[edge-security] Wildcard `*` cannot be combined with explicit origins — " +
|
|
107
|
+
"pass either `['*']` or an explicit allowlist, not both.",
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const origins = Object.freeze(new Set(normalized));
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* @param {string | null | undefined} requestOrigin
|
|
115
|
+
* @returns {string | null}
|
|
116
|
+
*/
|
|
117
|
+
function resolve(requestOrigin) {
|
|
118
|
+
if (isWildcard) {
|
|
119
|
+
// Wildcard env, credentials already proven false above: a literal `*` is
|
|
120
|
+
// the correct, safe ACAO value (any origin, no credentials).
|
|
121
|
+
return WILDCARD;
|
|
122
|
+
}
|
|
123
|
+
if (!requestOrigin) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
let candidate;
|
|
127
|
+
try {
|
|
128
|
+
candidate = new URL(requestOrigin).origin;
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
// Closed allowlist: echo the request origin *only* when it is trusted.
|
|
133
|
+
// Never reflect an untrusted Origin back (the reflection footgun).
|
|
134
|
+
return origins.has(candidate) ? candidate : null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return { isWildcard, credentials, origins, resolve };
|
|
138
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cors-astro.mjs — closed-allowlist CORS as an Astro middleware factory.
|
|
3
|
+
*
|
|
4
|
+
* Astro consumers (e.g. domio) hand-roll CORS in `src/middleware.ts` rather
|
|
5
|
+
* than reaching for a `hono/cors`-style helper, because Astro's middleware
|
|
6
|
+
* signature is `(context, next) => Response`. This variant ships the same
|
|
7
|
+
* closed-allowlist invariant as `cors-hono.mjs` but in the shape Astro's
|
|
8
|
+
* `defineMiddleware` expects — the architecture-driven divergence the Story
|
|
9
|
+
* preserves (two variants, not one flattened form).
|
|
10
|
+
*
|
|
11
|
+
* The no-wildcard-with-credentials invariant is inherited *by construction*
|
|
12
|
+
* from `createAllowlist`: building this middleware with `['*']` +
|
|
13
|
+
* `credentials: true` throws before a request is ever served.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createAllowlist } from "./allowlist.mjs";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {Object} AstroCorsOptions
|
|
20
|
+
* @property {string[]} allowedOrigins
|
|
21
|
+
* Per-env allowed origins (absolute, or a single `*`). Required.
|
|
22
|
+
* @property {boolean} [credentials=false]
|
|
23
|
+
* Send `Access-Control-Allow-Credentials: true`. Rejected at construction
|
|
24
|
+
* when combined with a wildcard origin.
|
|
25
|
+
* @property {string[]} [methods=["GET","HEAD","POST","PUT","PATCH","DELETE","OPTIONS"]]
|
|
26
|
+
* Methods echoed into `Access-Control-Allow-Methods` on preflight.
|
|
27
|
+
* @property {string[]} [allowedHeaders=["Content-Type","Authorization"]]
|
|
28
|
+
* Request headers echoed into `Access-Control-Allow-Headers` on preflight.
|
|
29
|
+
* @property {string[]} [exposedHeaders=[]]
|
|
30
|
+
* Response headers echoed into `Access-Control-Expose-Headers`.
|
|
31
|
+
* @property {number} [maxAge=86400]
|
|
32
|
+
* Preflight cache lifetime, seconds (`Access-Control-Max-Age`).
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const DEFAULT_METHODS = [
|
|
36
|
+
"GET",
|
|
37
|
+
"HEAD",
|
|
38
|
+
"POST",
|
|
39
|
+
"PUT",
|
|
40
|
+
"PATCH",
|
|
41
|
+
"DELETE",
|
|
42
|
+
"OPTIONS",
|
|
43
|
+
];
|
|
44
|
+
const DEFAULT_ALLOWED_HEADERS = ["Content-Type", "Authorization"];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Build an Astro-shaped CORS middleware: `(context, next) => Promise<Response>`.
|
|
48
|
+
*
|
|
49
|
+
* Wire it in `src/middleware.ts`:
|
|
50
|
+
*
|
|
51
|
+
* ```ts
|
|
52
|
+
* import { defineMiddleware, sequence } from "astro:middleware";
|
|
53
|
+
* import { createAstroCors } from "mandrel-platform/edge-security/cors-astro.mjs";
|
|
54
|
+
*
|
|
55
|
+
* const cors = createAstroCors({
|
|
56
|
+
* allowedOrigins: import.meta.env.PROD
|
|
57
|
+
* ? ["https://godomio.com"]
|
|
58
|
+
* : ["http://localhost:4321"],
|
|
59
|
+
* credentials: true,
|
|
60
|
+
* });
|
|
61
|
+
*
|
|
62
|
+
* export const onRequest = sequence(defineMiddleware(cors));
|
|
63
|
+
* ```
|
|
64
|
+
*
|
|
65
|
+
* @param {AstroCorsOptions} options
|
|
66
|
+
* @returns {(context: { request: Request }, next: () => Promise<Response>) => Promise<Response>}
|
|
67
|
+
*/
|
|
68
|
+
export function createAstroCors(options) {
|
|
69
|
+
if (!options || !Array.isArray(options.allowedOrigins)) {
|
|
70
|
+
throw new TypeError(
|
|
71
|
+
"[edge-security] createAstroCors({ allowedOrigins }): `allowedOrigins` (string[]) is required",
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const credentials = options.credentials === true;
|
|
76
|
+
// Constructs the allowlist — throws here on wildcard + credentials.
|
|
77
|
+
const allowlist = createAllowlist(options.allowedOrigins, { credentials });
|
|
78
|
+
|
|
79
|
+
const methods = options.methods ?? DEFAULT_METHODS;
|
|
80
|
+
const allowedHeaders = options.allowedHeaders ?? DEFAULT_ALLOWED_HEADERS;
|
|
81
|
+
const exposedHeaders = options.exposedHeaders ?? [];
|
|
82
|
+
const maxAge = options.maxAge ?? 86400;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Apply the CORS response headers for a resolved (trusted) origin.
|
|
86
|
+
* @param {Headers} headers
|
|
87
|
+
* @param {string} allowOrigin
|
|
88
|
+
*/
|
|
89
|
+
function applyCorsHeaders(headers, allowOrigin) {
|
|
90
|
+
headers.set("Access-Control-Allow-Origin", allowOrigin);
|
|
91
|
+
// When we echo a specific origin (not `*`), Vary: Origin is mandatory so
|
|
92
|
+
// shared caches don't serve one origin's ACAO to another.
|
|
93
|
+
if (allowOrigin !== "*") {
|
|
94
|
+
headers.append("Vary", "Origin");
|
|
95
|
+
}
|
|
96
|
+
if (credentials) {
|
|
97
|
+
headers.set("Access-Control-Allow-Credentials", "true");
|
|
98
|
+
}
|
|
99
|
+
if (exposedHeaders.length > 0) {
|
|
100
|
+
headers.set("Access-Control-Expose-Headers", exposedHeaders.join(", "));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return async function astroCorsMiddleware(context, next) {
|
|
105
|
+
const request = context.request;
|
|
106
|
+
const requestOrigin = request.headers.get("Origin");
|
|
107
|
+
const allowOrigin = allowlist.resolve(requestOrigin);
|
|
108
|
+
|
|
109
|
+
// ── Preflight (OPTIONS) ──
|
|
110
|
+
if (request.method === "OPTIONS") {
|
|
111
|
+
const headers = new Headers();
|
|
112
|
+
if (allowOrigin) {
|
|
113
|
+
applyCorsHeaders(headers, allowOrigin);
|
|
114
|
+
headers.set("Access-Control-Allow-Methods", methods.join(", "));
|
|
115
|
+
headers.set("Access-Control-Allow-Headers", allowedHeaders.join(", "));
|
|
116
|
+
headers.set("Access-Control-Max-Age", String(maxAge));
|
|
117
|
+
}
|
|
118
|
+
// 204 No Content is the conventional preflight response. Untrusted
|
|
119
|
+
// origins get a 204 with no CORS headers — the browser then blocks the
|
|
120
|
+
// real request (a closed allowlist, not a hard 4xx).
|
|
121
|
+
return new Response(null, { status: 204, headers });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── Actual request ──
|
|
125
|
+
const response = await next();
|
|
126
|
+
if (allowOrigin) {
|
|
127
|
+
applyCorsHeaders(response.headers, allowOrigin);
|
|
128
|
+
}
|
|
129
|
+
return response;
|
|
130
|
+
};
|
|
131
|
+
}
|