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 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 `codeql.yml` and `smoke-dispatch.yml`) — and
16
- their public input/secret contract are documented in
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 | Path |
239
- | ------------------------------------- | --------------------------- |
240
- | `mandrel-platform/tsconfig.base.json` | `config/tsconfig.base.json` |
241
- | `mandrel-platform/biome.base.json` | `config/biome.base.json` |
242
- | `mandrel-platform/scripts/*` | `scripts/*` |
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
+ }